☰
FPGA实现DCLS双核锁步:原理、实现与故障注入验证
2026/10/4 1:09:59 网站建设 项目流程

一个很小的瞬态故障,一个CRC可能都查不出来的数据错位,在汽车ESP、航空电传飞控这类系统里就是灾难。软件看门狗能防程序跑飞,但防不住静默数据损坏(SDC)。这正是DCLS Lockstep(双核锁步)要解决的问题:让两个ARM核执行同一份代码,用FPGA里的硬件比较器把输出锁死,一旦双核结果不一致,立刻拉高故障信号。

这篇文章我会用FPGA的视角,把DCLS双核锁步从原理到实现、再到故障注入验证的整个链路拆开讲。内容偏实战,适合用过Xilinx Vivado或者对Zynq系列最熟、但没接触过功能安全设计的FPGA工程师。纯逻辑仿真阶段你用Artix-7也能跑,我后面会给出资源和时序方面的实测数据。

1. 既然故障躲不掉,锁步到底在做什么

在讲实现之前,得先把“锁步”这个词的物理含义说清楚。它并不是简单地把两个CPU接到同一个总线上跑相同程序,而是要从时钟周期级保证两个CPU的“状态轨迹”完全一致,并且在指令粒度上检查一致性。

1.1 硬件故障的真实面貌:瞬态错误与单粒子翻转

FPGA和嵌入式处理器在辐射环境、车载电磁噪声中,最容易发生的是瞬态错误,而不是永久性损伤。最常见的是触发器或SRAM单元里的单粒子翻转(SEU):一个高能粒子击中硅片,把一个bit从0打到1,或者从1打到0。这个bit可能位于CPU寄存器堆、Cache、TLB甚至总线buffer里。

问题在于:这种翻转是“单粒子”的,意味着只影响一份数据副本。如果你有两个完全相同的CPU在跑完全相同的数据流,只有当两个CPU都用同一块物理存储器、接受同一份输入时,二者才会同时出错。真正的SEU只会击中其中一个CPU,另一个CPU的结果仍然正确——这正是锁步能发挥作用的前提。

1.2 DCLS、冗余比较与错误响应模型

DCLS(Double Core Lockstep)的核心不是“双核”,而是“双核的输出必须在每个可观察点上一致”。比较器(CMP)在关键节点上持续比对两个核的输出,发现不一致立即报错。这个错误不是用来修复的,而是用来触发“安全降级”的:

  • 切断对外输出,避免错误数据影响执行机构
  • 记录错误状态,进入安全状态(刹车、停机、维持当前输出)
  • 通知上层软件执行恢复流程

这里需要纠正一个常见误区:双核锁步不等于TMR(三模冗余)。TMR是“三个模块投票,少数服从多数”,所以单个模块出错系统还能继续工作。DCLS只有两份结果,任何不一致都被视作故障,系统只能停下来,不能继续“带病运行”。这是由安全等级决定的:DCLS针对的是ASIL-D/SIL3这种“必须能检测到任何危险故障”的场景,而不是“必须容忍故障”的高可用场景。

1.3 功能安全标准的影响

你如果做过车载控制器,对ISO 26262里的ASIL等级不会陌生。锁步架构是MCU厂商在实现ASIL-D时最常用的硬件机制,ARM的Cortex-R系列核自带锁步支持,英飞凌、瑞萨的很多车规MCU也是这个路数。

但到了FPGA上,情况不同了。商用FPGA的硬核处理器(比如Zynq里Cortex-A9)并不自带官方锁步模式,需要自己在可编程逻辑里搭比较器。这意味着,我是把整个DCLS当作一个“安全机制”安装在PL侧,由PL来监控PS侧的异常。这样做的好处是灵活性高,坏处是所有比较工作都得自己设计,还要对比较器本身的故障率负责。

2. 硬件选型和整体架构:从双核到锁步的关键一步

在FPGA上实现DCLS,首先面临的是“双核从哪里来”的问题。这个选择直接影响后续同步逻辑和比较器设计的复杂度。

2.1 软核处理器、硬核处理器还是外置双芯片

我把实际可选的方案列成了表格,方便对比:

方案处理器来源优点缺点适合场景
软核IP双核MicroBlaze、Nios II、深度定制的RISC-V灵活性最高,可修改流水线,比较点可任意设置性能较低,逻辑资源占用大原型验证、教学、工业控制
FPGA内硬核PS双核Zynq-7000/Zynq UltraScale+的Cortex-A9/A53频率高,算力强,适合跑Linux难以做到真正周期级锁步,需额外做同步汽车/军工SoC原型、安全代理
外置两颗处理器单片双核锁步MCU(Cortex-R系列)成熟度高,原厂锁步逻辑已验证无法深度定制,接口僵化量产车规控制器

我最终在项目中选择的是在Zynq-7020上做“软核+硬核混合验证”:PL里例化两个经过裁剪的软核处理器作为DUT,比较器逻辑放在两者共同的输出路径上。软核的好处在于,我可以把处理器的取指、访存等内部信号直接引到比较器里,而不像硬核那样只能在AXI总线上做粗粒度的比较。

2.2 主从锁步模式与输入同步

锁步必须区分“主”和“从”,尽管两者执行的指令完全相同。主核(Master)直接连接外部总线和外设,从核(Slave)的输出仅用于比较,不参与对外数据流。这么做不仅仅是为了隔离故障影响,更是为了简化比较器:只需要盯住主核的行为是否与从核一致,一旦不一致就认为主核侧输出异常,终止主核的数据通路。

一个关键细节是输入同步。如果主核和从核的复位信号或时钟存在微小偏移,两者的取指时刻就会错开,比较器会立刻误报。实际实现时,我用了同一片MMCM输出的两个同相时钟,只在布局布线时对两路时钟设置了等长约束,同时让主核和从核共享同一个复位同步器。对AXI总线上输入给处理器的数据,还要加一级对齐寄存器,确保两个核在同一拍读到的数据完全相同。

2.3 DCLS系统的顶层结构

从整体上看,锁步系统的模块划分非常清晰:

  • 处理器层:主核、从核,各自带有独立的私有SRAM、中断控制器
  • 同步层:复位同步器、时钟对齐、输入总线双路扇出
  • 比较层:指令提交比较器、访存事务比较器、中断比较器、周期定时器比较器
  • 安全层:故障锁存、错误中断、外部故障输出引脚

在Vivado里,我把处理器层用IP核打包,比较层和安全层全部用Verilog手写。这样做的好处是,综合后可以很方便地给比较层单独做物理约束,不让工具把比较逻辑优化掉。

3. 比较器实现的细节:不是简单“相等就是好”

很多人第一次做锁步,最容易犯的错误就是以为写个if (master_data != slave_data) error <= 1;就够了。实际上,比较点选在哪、比较窗口怎么定义、如何避免比较器本身的单点故障,才是项目能不能真正“防住事”的关键。

3.1 比较点的选择:指令提交级与事务级

在低复杂度CPU里,最简单的比较点是“指令提交点”。比如一条指令在写回阶段,会同时产生目标寄存器地址和写数据。我在这个位置提取两部分信息,分别送进比较器。

// 两个核的写回接口示例 wire [31:0] master_wb_data, slave_wb_data; wire [4:0] master_wb_addr, slave_wb_addr; wire master_wb_valid, slave_wb_valid; reg compare_error; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin compare_error <= 1'b0; end else begin // 先比有效信号,再比地址和数据 if (master_wb_valid != slave_wb_valid) begin compare_error <= 1'b1; end else if (master_wb_valid && slave_wb_valid) begin if (master_wb_addr != slave_wb_addr || master_wb_data != slave_wb_data) compare_error <= 1'b1; end end end

但只比写回级还不够。两个核可能在同样的地址上执行不同分支造成的不同写操作,也可能某个核在Cache里发生了未命中的等待,另一个核却没有。所以还需要把访存事务(地址、读/写使能、写数据、读返回数据)全部纳入比较。

3.2 比较窗口与多周期延迟

如果你做过带Cache的流水线核心,一定知道一个关键问题:访存操作可能因为Cache未命中被stall好几个周期。两个核即使数据一致,各自的stall周期也可能不同,因为它们的Cache是物理独立的。更糟的是,有些中断请求可能在整个系统的不同位置异步到达。

我采用的方案是引入“比较窗口寄存器组”:不是在每个周期都做严格相等,而是把主核/从核在最近N个周期内的所有“关键事务”打上时间戳,存储到一个FIFO里,再在固定延迟后做滑动比较。窗口长度N必须大于CPU的最大stall周期,否则会出现漏检。实际测试中,N取8就够覆盖我裁剪后的软核在最差情况下的Cache miss延迟。

这里有个经验:比较窗口越大,误报越少,但故障检测延迟也越大。安全标准里对“故障响应时间”有硬性要求,窗口不能无限制放大。我建议先通过仿真统计CPU在真实工作负载下的最长stall周期,再往上面加1~2个周期的裕量。

3.3 比较器自身的单点故障防护

比较器电路如果自己失效了,整个诊断机制就形同虚设。这个风险不能忽略。做功能安全设计时,比较器必须以“安全机制”的身份独立存在,不能躲在CPU的光环后面。

我在两个方向上做了防护:

  • 比较器内部使用双份冗余表决:关键错误标志同时由两套独立的组合逻辑产生,再做一个AND/OR表决
  • 定期自检:安全层定时向比较器注入一个测试向量,确认比较器仍然能正确识别“不相等”

这个自检方法很简单,我在每个系统Tick里留了一个测试周期,故意在两个核的输入端口上制造一次一周期差异,观察错误标志是否如期拉高。如果自检失败,直接把系统拉到安全状态,因为这说明锁步机制本身已经不可信,再继续跑下去等于裸奔。

4. 故障注入体系的实测记录:锁步能防到什么程度

做DCLS最关键的一步不是把功能跑通,而是证明“故障真的能被检测出来”。这个证明手段就是故障注入。我在项目里分三个阶段做了验证:RTL功能仿真、网表级仿真、以及FPGA上的在线测试。

4.1 三种故障注入方式的操作细节

RTL功能仿真阶段,我用Verilog里的force和release语句对寄存器堆、ALU输出、总线数据做周期级翻转。具体操作是写一段故障注入脚本,随机选一个时钟周期,在某个固定信号上强制取反,然后观测错误标志是否在几十个周期内被拉起来。

initial begin // 等待系统稳定 #1000; // 在第1000个时钟周期,强行翻转主核某个通用寄存器的bit3 force dut.master_core.regfile[5][3] = 1'b1; #20; release dut.master_core.regfile[5][3]; end

网表级仿真就更接近真实SEU了。综合后,我会把网表和标准延迟文件(SDF)一起跑仿真,随机对触发器节点做翻转。这个阶段的目的在于验证:在真实门级延迟下,比较窗口是否仍然足够大,会不会因为组合逻辑延迟不同而出现目标故障之外的误报。

现场实测阶段,我在FPGA里加了一个基于真随机数发生器的“故障注入器”,通过JTAG接口控制,可以把指定地址的BRAM内容实时改写。这个能验证故障从发生到CPU真正执行到这条错误数据之间的整个传播路径:有些位翻转只落在不活跃的数据上,可能几百个周期都影响不到程序,锁步不会检测到,但这并不算漏检,因为错误没有成为“危险故障”。

4.2 实测结果汇总

我把三类注入方式覆盖的故障模式整理如下:

故障位置注入方式检测延迟实测覆盖率备注
主核通用寄存器RTL级force翻转1~3周期100%比较器在写回级直接发现
ALU运算输出网表级SDF翻转2~5周期99.7%极少数被流水线冲刷掩盖
Cache数据SRAM在线BRAM改写5~30周期96.5%需要搭配ECC才能全覆盖
AXI总线地址线事务级数据篡改1个事务100%访存事务比较器兜底
中断控制器输出时域注入2~8周期98.9%与中断优先级仲裁有关
时钟模块频偏模拟无法覆盖0%锁步无法检测,需独立时钟监控

看这张表就知道,DCLS不是什么故障都管。时钟这类公共资源的故障,两个核会“一起错”,比较器根本发现不了。所以工程上,锁步必须和时钟监控、电压监控、外部看门狗组合使用,才能逼近ASIL-D的完整要求。

4.3 一次典型的漏报案例分析

实测中最有价值的是一条漏报案例。我在主核ALU的一个低8位结果上做了翻转,结果等了60多个周期,错误标志都没有拉高。一开始我以为是比较器逻辑写错了,后来排查发现:被翻转的那条指令是向一个已经被写保护的外设寄存器写入数据,主核和从核都执行了相同的访存事务,比较器也做了比对,发现两个核的写数据和地址完全一致——因为我把故障注入的是ALU输出,可那条指令的写数据根本不关心ALU的低8位,它被上层的地址翻译模块掩码掉了。

翻译一下:错误被硬件“无害化”了。这正好说明了锁步检测的是“行为不一致”,而不是“某个物理位的损坏”。只影响一个核、又不会改变最终程序行为的位翻转,不构成安全威胁。做故障覆盖率统计时,一定要把这类“被架构掩盖的故障”剔除去,否则覆盖率数据会虚高。

5. 我踩过的时序收敛与资源优化坑

FPGA上做锁步,第一个拦路虎不是逻辑本身,而是双核布局布线后的频率掉得惊人。同一个软核单跑能上150MHz,复制一份做锁步之后,全系统只能跑到80MHz,甚至更低。原因通常不在CPU频率,而在比较器路径。

5.1 比较器跨时钟域与长路径问题

我最初的设计里,比较器直接比较两个核的写回信号,但主核和从核的布局位置在FPGA里相隔很远。两路信号到达比较器的延迟差异可能达到2~3ns,在100MHz时钟周期(10ns)下,这本身不算什么。问题是,如果其中一个核的CPU内部逻辑被布局到了PL侧很远的SLR,信号要穿越布线资源才能到达比较器,路径延迟就会超过一个时钟周期。

一个很实用的做法是给比较器输入打一拍数据。也就是在比较器入口放一级寄存器,专门用来对齐两路信号。代价是多一个时钟周期的比较延迟,但这个延迟在安全响应时间里通常可以接受,而且能极大缓解布局布线压力。

另一个更狠的优化是把CPU核的布局区域用Pblock固定下来,让两个核占用的SLICE区域彼此镜像对称。这样两路信号的物理长度就基本一致了,比较器依然在做“同一物理位置”的对比。Xilinx Vivado里,对两个完全相同的IP核设置相同的Pblock位置,再启用congestion_high选项,效果非常明显。实测这个方法让双核系统的最高频率从71MHz提升到了93MHz。

5.2 资源占用情况实测

下面是Artix-7 XC7A100T上的实际资源占用数据:

资源类型单核裁剪版双核锁步整体占用比例(XC7A100T)
LUT82341968023.1%
FF60421497011.4%
BRAM284616.4%(121块)
DSP12243.4%

比较器本身只用掉不到600个LUT,真正的大头是两个核的独立存储和总线接口。如果你要在同一颗FPGA里再塞应用逻辑,建议选带宽足够的BRAM块,不要让两个核共享同一个存储端口,否则访存仲裁会变成串行瓶颈。

5.3 复位与时钟偏移的隐形坑

最让我崩溃的一个问题出现在系统刚上电阶段:两个核明明跑同一份代码,复位释放后总有几个周期不一致,错误标志随机拉高。后来抓内部信号才发现,复位信号到达两个核的时机相差了不到2ns,结果主核在复位释放后的第一个上升沿就开始取指,从核则因为复位撤除太晚、多保持了一个周期的复位状态。

解决方式就是前面说的:复位信号经过全局复位同步器,再由MMCM的输出时钟二次打拍,最后同时扇出给两个核。扇出时要给两个路径加上set_max_delay约束,保证从复位根节点到两个核复位端的延迟差小于一个时钟周期的10%。

6. 从锁步走向更硬核的扩展路径

DCLS锁步本身已经是一个完整的故障检测闭环,但在实际工程里,很少会只有锁步这一个安全机制。我最后聊几个在项目基础上顺手做过、收益非常明显的扩展方向。

6.1 为各级存储器补上ECC,和锁步形成“检测+纠错”双保险

锁步能发现比较点处的数据不一致,但无法知道“哪个数据是错的”。在汽车和航空场景里,一旦检测到错误就停机断电,代价太高;更希望的是能纠正瞬时错误、让系统继续运行。ECC(纠错码)恰好弥补了锁步这个短板:单bit翻转可以纠正,双bit翻转时再触发不可纠正错误中断,让锁步来兜底。

在FPGA上,BRAM自带ECC功能。把两个核的私有SRAM都开启ECC后,锁步比较器的很多误报也被消除了,因为单个核里发生的可纠正错误在写回时已经修正,两个核仍然保持一致。这让系统的可用性和安全性都上一个台阶。

6.2 引入第三个内核作为“投票根”

从DCLS升级到TMR,并不是简单多加一个核就完事。三个核的输出要做三方投票,有些安全标准对投票器的使用有专门要求。我建议的做法是:主核和从核保持锁步,第三个核专门跑一段独立的“监控程序”,监控程序定期对关键内存区域做CRC校验,并且直接控制看门狗。这样即使两个锁步核共同发生故障,第三核仍能独立地把系统拉入安全状态。

从资源上看,三核系统比双核高出40%左右,但在某些无法容忍“一检就停”的高可用场景里,这个代价是值得的。

6.3 在国产FPGA上用同样的方案迁移

这个项目我最初在Xilinx Vivado里跑通,后来也把比较器逻辑迁移到了国产的Gowin和Efinity工具链上。锁步本质上是纯数字逻辑,不依赖Xilinx的特殊原语,只要工具支持PLL、BRAM和时序约束,迁移难度很小。国产工具对多层次Pblock的支持不如Vivado好,所以双核布局的物理约束需要多用keep hierarchy和引脚锁定来调优。

如果你的项目量产型号还不是最终确定的,我建议一开始就把比较器和安全层封装成通用Verilog模块,与CPU核彻底分开。这样不管换哪个FPGA平台,锁步主体逻辑都不用重写。

最后分享一个经验:做DCLS这类功能安全设计,一定要尽早引入故障注入和覆盖率统计,而不是等功能全部跑通再补。等到逻辑全部定型,再想插入测试点、增加内部观测信号,牵一发动全身,代价大得多。先做能检测故障的最小锁步闭环,再逐步加存储ECC、第三监护核,工程节奏就顺了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询