关于ARM双核锁步DCLS Lockstep技术,我在FPGA上从零到一完整趟了一遍,这篇把原理、架构选型、代码要点、踩坑记录一次说清楚。适合正在做功能安全原型验证、想用低成本FPGA模拟ASIL-D级冗余机制的工程师,也适合对锁步机制只有概念、想动手落地的人。
1. 锁步技术到底在防什么:DCLS原理与ARM生态
1.1 DCLS的核心故障模型与比较粒度
DCLS(Dual Core Lockstep,双核锁步)本质上是一种硬件冗余技术:两个相同的处理器核心执行同一份程序、接收同一份输入,然后在一个比较点上对两者的输出做逐周期比对。一旦发现任何不一致,立刻判定系统出现故障,进入安全状态。这个机制主要针对两类故障:一是瞬态故障,比如高能粒子轰击存储单元造成的单bit翻转(Single Event Upset),这类故障不会留下物理损伤,但会让某一条指令的运算结果出错;二是永久性故障,比如寄存器闩锁失效、总线驱动短路,这类故障一旦发生就会持续产生错误。DCLS的价值在于,不需要精确知道故障是哪种,只要两个核的输出不一致,就说明有东西出了问题。
比较粒度是DCLS设计里最核心的取舍。最严格的是cycle级比较,每一个时钟周期都比较两个核的全部关键输出信号,包括程序计数器、寄存器堆写端口、总线事务等。cycle级比较的优点是故障检测延迟极低,基本在一个周期内就能发现,且理论上能检测出所有导致逻辑状态变化的故障;缺点是实现难度大,因为主核和检查核必须严格同步,任何一条流水线冒险行为都必须保持一致,这在乱序执行的现代处理器上几乎不可能做到。实际工程中更常见的是transation级比较,也就是比较AXI总线上的完整读写事务,把一笔突发读写的地址、数据、控制信号拿来做比对。这种粒度的检测延迟取决于事务长度和比较窗口,但实现门槛低很多,而且对于SoC级别的功能安全场景,检测出错误总线事务足以触发安全响应。
1.2 ARM硬件锁步与FPGA模拟的差异
ARM在芯片层面已经提供了成熟的硬件锁步方案,典型代表是Cortex-R5和Cortex-R52的Split/Lock模式。split模式就是两个独立核跑不同任务,lock模式则让两个核以锁步方式运行,由内部比较逻辑实时比对两个核的AXI主端口输出,一旦失配就拉低ERROR信号、触发lockstep故障异常。Cortex-A系列里也有类似设计,比如A53和A57在部分车规SoC中会把一对核配置成锁步冗余,作为安全岛使用。这些方案的优点是出厂即用、通过了严苛的安全认证、不需要用户操心比较器逻辑,缺点是绑定特定芯片型号,一旦锁定就失去了灵活性,而且这类车规SoC的采购门槛和开发周期都不低。
FPGA实现DCLS则完全是另一条路线。用FPGA里的软核处理器搭建锁步架构,比较器逻辑完全掌握在自己手里,想比较哪些信号、屏蔽哪些信号、丢失同步时怎么恢复,全部可以自定义。适合的场景无非这么几类:做功能安全原型验证,在流片前用FPGA验证锁步架构是否满足ASIL-D指标要求;做成本敏感的中低产量产品,用FPGA替代昂贵的安全认证SoC;还有教学研究和内部技术验证。FPGA方案也有明显的软肋:软核本身的故障覆盖率需要自己评估,FPGA内部的配置存储器翻转问题也无法靠逻辑层锁步完全解决,所以它更适合作为产品化的验证平台,而不是直接充当最终的量产安全方案。
2. 在FPGA上实现锁步,方案选型与架构权衡
2.1 软核处理器选型:MicroBlaze还是RISC-V软核
想在FPGA里跑两个一模一样的CPU,摆在面前的无非三条路。用Xilinx MicroBlaze软核是最省事的选择,它对AXI总线支持完善,Vivado里直接用IP Integrator搭建双核系统,生成锁步比较器只需要在AXI主端口上插入自己的IP。MicroBlaze本身没有硬件锁步模式,但它有确定性的七级流水线,两个微架构完全相同的实例在相同时钟、相同时钟下必然逐周期状态一致。生态成熟是它最大的优势,Vitis/SDK直接支持双核调试,省去了大量软件适配工作。
RISC-V软核是另一条路,典型代表是VexRiscv和PULPissimo。选择RISC-V软核的动力通常来自两方面,一是规避MicroBlaze的授权成本,二是在研究型项目里需要完全开放、可修改的RISC-V核。VexRiscv有一个很吸引人的特性:它允许用户以插件方式修改流水线行为,理论上可以在核内部插入一个比较器接口。但代价是软件工具链需要自己构建,RISC-V GNU工具链在嵌入式场景下够用,可如果你需要和已有的ARM生态工具协同工作,工程量会直线上升。从我的实际体验看,大部分FPGA DCLS项目选择MicroBlaze是对的,RISC-V虽然在架构上更灵活、更开放,但在DX、调试、第三方IP兼容性上还差着一截。
还有一种思路是用Zynq系列里的ARM硬核,比如双核Cortex-A9当lockstep用。这个方案我试验过,非常不建议作为首选:Cortex-A9本身既没有锁步比较器,也不保证两个核的执行流逐周期一致,因为A9有乱序执行能力,外部逻辑上的软件同步方式需要灾难性的缓存维护开销,才是真正的纯实验方案。如果非要利用ARM硬核,建议仅在FPGA中实现总线事务级比较器,比较两个A9核的DDR访问流,但这不是严格意义上的DCLS,只能算是事务一致性校验。
2.2 系统总体架构设计:比较器到底插在哪里
整个DCLS系统在FPGA上的拓扑是这样的:两个MicroBlaze实例(master和checker)从同一个复位释放,运行同一份固件,通过各自的AXI主端口发出总线请求。这两个AXI主端口并不会各自连到下游内存总线,而是先汇入锁步比较器模块,由比较器对两个端口的AW、W、B、AR、R五类通道信号做逐周期比对。如果一致,比较器把master的AXI事务透传给下游的AXI Interconnect;如果不一致,比较器立即拉高error_flag,同时停止透传,把下游总线置为安全空闲状态。
这个"主核输出经过比较器再下发"的做法有一个关键好处:checker核并不直接驱动任何外部负载,所有内存读写、外设访问都来自master,而checker只是作为一笔参考事务和master逐周期对齐。这样即便checker核内部出现瞬时故障导致某些信号抖动,只要它和master的最终事务结果相同,系统依然正常工作。反过来,如果master出错了,比较器会在差错事务到达内存之前把流切断,保护系统的数据完整性。
还要决定错误上报路径。我的设计里让比较器在检测到失配时做两件事:第一,拉高一个独立的FPGA引脚作为紧急错误信号,这个信号直接接入外部安全逻辑(比如切断电机驱动的使能信号);第二,给两个核分别产生中断,让固件记录错误现场后执行关断流程。这两条路径是并行触发的,如果固件也出了问题,宁可系统直接停机也不能让危险动作继续执行。
2.3 需要防卫的关键资源清单
锁步比较器不可能把FPGA里所有信号都拿来做比对,资源不允许,时序也不允许。必须明确哪些资源是安全攸关的,哪些是可以放过的。处理器核的运算结果最终都要通过总线输出,所以总线事务本身必须完整比对;中断控制器给两个核送出的中断向量必须一致,不然两个核的跳转地址就会偏离;定时器产生的时间戳、看门狗喂狗周期需要一致;DMA的搬运目标和数据内容则必须一致。
无需比对的资源主要包括:非确定性外设,比如UART接收FIFO、随机数发生器、外部拨码开关状态等,因为两个核读取到的值可能不同,但不属于故障;调试接口,比如JTAG和AXI Debug Hub的访问,在调试阶段应该被排除在比较范围之外;还有FPGA内部的版本寄存器、温度传感器读数这类纯状态类信息。这部分我的做法是给比较器设计一个mask掩码寄存器,通过AXI-Lite接口动态配置哪些通道或哪些地址区间跳过比较。量产模式下把所有mask清零,开发调试模式下按需打开,这个灵活性是FPGA方案相比于ASIC硬核最大的优势。
3. 双核锁步的FPGA实现,一步步搭起来
3.1 开发平台与集成步骤
我用的开发板是Xilinx Artix-7系列,具体型号为xc7a100tcsg324,Vivado版本为2021.2。Artix-7足够跑两个MicroBlaze和外部比较逻辑,逻辑资源利用率实测在45%左右,Block RAM利用率在40%左右,留出了充足的余量给后续扩展。如果你用Kintex或Virtex系列当然更好,但没必要为了DCLS原理验证额外买贵板卡。
整个搭建过程分为四步。第一步,在Vivado的IP Integrator里创建两个MicroBlaze处理器实例,关闭两个核的Cache、MMU和分支预测,开启Area Optimized综合策略,这是为了保证两个核的内部状态机尽量简单、行为完全确定。第二步,在地址映射上让master和checker拥有完全一致的地址空间,不对checker做任何地址偏移。第三步,手动例化自研的lockstep_comparator模块,把两个MicroBlaze的AXI主端口分别接到模块的master_axi和checker_axi接口,再从模块的m_axi端口接出去到AXI Interconnect。第四步,将比较器的错误输出连接到AXI Interconnect的S_AXI_CTRL端口,并接入中断控制器。
MicroBlaze的参数配置需要重点注意两处:一是必须让两个核的输出端口的位宽、协议版本完全一致,不然Verilog里端口连不上都是小事,关键是总线ID信号对不上比较器会直接报错;二是两个核Local Memory Bus的基地址必须设置为相同值,因为Linker script会把程序加载地址和栈地址都映射到这个区域,任何地址不一致都会导致两个核运行的数据布局产生差异。
3.2 锁步比较器的Verilog实现要点
比较器模块是最关键的自研IP,核心逻辑是AXI的五个通道逐一比对。AXI协议里AW通道负责写地址和控制信号,W通道负责写数据,B通道负责写响应,AR通道负责读地址和控制信号,R通道负责读数据与读响应。五类通道的比对类似,我拿AW通道的一段核心代码做说明:
reg awaddr_mismatch, awctrl_mismatch; always @(posedge clk or posedge rst) begin if (rst) begin awaddr_mismatch <= 1'b0; awctrl_mismatch <= 1'b0; end else if (s_axi_awvalid && c_axi_awvalid) begin awaddr_mismatch <= (s_axi_awaddr != c_axi_awaddr) | (s_axi_awburst != c_axi_awburst) | (s_axi_awsize != c_axi_awsize) | (s_axi_awlen != c_axi_awlen); awctrl_mismatch <= (s_axi_awid != c_axi_awid) | (s_axi_awlock != c_axi_awlock) | (s_axi_awcache != c_axi_awcache) | (s_axi_awprot != c_axi_awprot) | (s_axi_awqos != c_axi_awqos); end else begin awaddr_mismatch <= 1'b0; awctrl_mismatch <= 1'b0; end end always @(posedge clk or posedge rst) begin if (rst) begin error_flag <= 1'b0; end else if (awaddr_mismatch || awctrl_mismatch) begin error_flag <= 1'b1; end else if (clear_error) begin error_flag <= 1'b0; end end这个模块比较了地址、突发类型、突发大小、突发长度,以及ID、锁类型、缓存属性、保护属性和服务质量这些控制信号。写数据通道W比AW复杂一点,因为W通道的数据在突发传输中每个周期都有效,需要额外用一个计数器记录当前是第几个拍子,确保两个核的写数据逐拍对齐。读数据通道R和B通道作为响应通道,必须等master的请求到达下游内存后再把数据回给两个核。这里有一个非常容易踩坑的点:比较器在写通道上存在一个死锁风险——如果master和checker的写地址在某个周期失配,比较器立即阻断透传,但下游总线可能已经接受了master的写地址,进入等待写数据状态,此时比较器把W通道阻断,下游总线的写数据状态机会永远等不到WVALID,造成死锁。解决办法是在检测到失配后不立即阻断数据透传,而是先把当前所有正在执行的AXI事务完成收尾(也就是握手完成),再隔离总线。这块逻辑我前前后后改了三次才稳定下来。
比较器的另一个重要功能是旁路开关。设计里增加了一个bypass_mode寄存器,置1时比较器直接把master的AXI信号透传给下游,不做任何比对。这个模式在正常运行时必须关闭,但在底层测试和故障注入验证时非常有用——能在不修改硬件的情况下故意把非确定性外设的数据跳过比较,或者临时退出锁步模式看系统的行为是否符合预期。
3.3 双核启动流程与固件同步
双核锁步系统里,软件侧的工作量比很多人想象的要大。两个MicroBlaze必须在同一时刻从同一地址取第一条指令,这是锁步成立的前提。实现方式是在地址映射里把checker核的复位向量指向与master完全相同的代码段首地址,并且让checker核在复位释放后等一个握手标志再启动主循环。
具体做法如下:在Boot ROM中放一段公共启动代码,这段代码会读取一个共享的握手寄存器。master在启动后先完成必要的时钟初始化、内存初始化,然后向握手寄存器写入0xA5A5_A5A5。checker在启动后一直轮询这个寄存器,直到读到0xA5A5_A5A5才继续往下执行。这样能避免因为两个核的BRAM初始化延迟差异导致的启动不同步。注意握手寄存器的写操作本身必须在锁步比较范围内。使用专用的硬件握手逻辑(比如两个核各接一个独立GPIO,master拉高,checker检测到高电平后放行)会更可靠,比共享内存方式少了一层缓存一致性问题。我实际测试下来,GPIO握手的方式在跨时钟域处理上更干净,也能避免编译优化把轮询循环优化掉。
固件里还需要关掉可能破坏确定性行为的中断源。MicroBlaze的时间戳定时器、看门狗、外部中断控制器在锁步模式下都必须使用同一套配置。一个很隐蔽的坑是看门狗计数器在发出超时中断后,如果两个核的中断响应时序有细微偏差,其中一核可能已经进入中断服务程序清除了看门狗,而另一核还在执行主循环,导致两个核的程序计数器分道扬镳。解决方法是把看门狗超时中断全禁掉,改成通过外部安全逻辑直接复位整个锁步系统。
3.4 故障注入与验证策略
锁步架构到底有没有用,不能靠"理论上应该能检测到"来证明,必须做故障注入实验。我在FPGA里的故障注入手段主要有三类:第一类是仿真级注入,用ModelSim或者Vivado Simulator在testbench里对比较器的输入信号做force赋值,人为翻转某个bit,观察error_flag是否拉高;第二类是上板级寄存器注入,如果比较器的待比较信号里有可以通过AXI-Lite寄存器写入的状态影子寄存器,就可以通过软件故意写一个不同的值,让两个核的总线事务在比较器处产生差异;第三类是物理级注入,在FPGA外部用探针或JTAG对BRAM内容做修改,让两个核读到的初始数据不一致。
比较有效的故障注入测试分为三个阶段:正常运行阶段,两个核执行同一个数组求和程序,每一轮循环结束后比较累加结果,看锁步是否稳定;单bit注入阶段,在某个随机周期通过仿真的force命令翻转master_axi_awaddr的最低位,检查error_flag响应时间;持续注入阶段,以每1000个时钟周期一次的频率随机翻转checker核的W数据总线,统计检测率和误报率。
我这里有一个实测数据可以供参考:在50MHz时钟下,事务级比较器的检测延迟平均为3个时钟周期,也就是60ns左右;在1000次故障注入测试中,检测成功率为100%,误报次数为0。这个结果说明基于AXI事务级比较器的DCLS方案在功能上是可靠的,但检测延迟能否满足ASIL-D的时间裕度要求,还需要根据具体产品的安全目标来做系统级分析。
4. 关键设计细节:时钟、复位、内存一致性与比较窗口
4.1 时钟与复位的严格同步设计
锁步成立的第一个前提是两个核收到的时钟边沿完全对齐。在FPGA里,这个可以通过全局时钟网络BUFG实现:用一个MMCM/PLL输出的时钟同时驱动两个MicroBlaze的时钟端口,并且确保比较器模块也使用同一时钟。Vivado综合后会为所有时钟端口分配全局时钟网络,理论上时钟偏斜控制在皮秒级别,满足两个核的时序对齐要求。但要注意一个细节:FPGA内部有些专用路径,比如Block RAM的时钟使能信号和输出寄存器,可能会因为综合工具的优化而产生微小差异,这些差异不影响功能,但会体现在仿真波形里两个核的BRAM输出端口时序稍微有些错位。所以波形仿真时,比较器的数据比较逻辑必须使用同步比较,也就是在每个时钟上升沿之后采一拍再比较,而不是在信号变化后立即组合逻辑比较。
复位逻辑是另一个容易出问题的地方。两个MicroBlaze和比较器必须使用完全相同的复位网络,不能一个用异步复位、另一个用同步复位。我踩过的坑是,最初checker核用了独立的复位同步器,结果在系统掉电后重新上电的过程中,两个核的复位释放时刻相差了大约一个时钟周期,导致checker启动时PC已经跑偏。后来改成统一的复位树:外部复位信号经过全局复位同步器后,同时驱动master、checker和比较器的复位端口,并且在复位释放后再延迟50个周期让BRAM完成初始化,再让Boot ROM开始执行。用这个方案后系统反复上电两百多次,没有出现一次启动不同步。
4.2 内存一致性与AXI仲裁顺序问题
两个锁步核访问共享内存时,AXI Interconnect的仲裁逻辑必须保证master和checker的读写请求被处理的顺序是一致的。最直接的办法是给两个核分配独立的AXI从端口,让Interconnect按端口顺序轮询,而不是用仲裁优先级的方式。因为如果master优先级高于checker,那么当两个核同时发出请求时,master先拿到总线、先完成事务,checker要等一拍,两个核的总线事务在时间轴上出现了偏移。虽然比较器在事务级比较时能容忍这种偏移(比较器只在两个核的相同事务都完成后才比对结果),但偏移会累积,时间一长两个核的循环次数可能对不上。
我在设计中采用了一个更彻底的方案:把两个核的AXI主端口在接入Interconnect之前,先通过一个自定义的AQL(AXI Queue Layer)模块,将两个核的请求统一到达at同一个仲裁周期。也就是说,比较器不只比对数据,它还要扮演一个双端口到单端口的AXI流量整形器——它内部维护了一套队列,把master的事务和checker的事务对齐后在同一个周期里发出去。这个做法的好处是把仲裁顺序从"两个核竞争"变为了"比较器全权安排",彻底消除了锁步核之间的总线竞争。
内存数据的一致性也需要注意。两个核通过BRAM或DDR共享数据时,读写粒度、地址对齐规则必须一致。MicroBlaze不支持非对齐访问,而比较器逐字节比较时会发现master发出的地址是0x1001、checker发出的地址是0x1000,产生误报。所以在固件里所有共享数据结构的访问都必须强制使用volatile关键字,并且手动按4字节对齐,避免出现非对齐访问路径。
4.3 比较窗口设计:粒度越细越安全,但也越脆弱
比较窗口决定了锁步系统检测故障的时间精度,也决定了系统误报的概率。如果比较窗口是单一周期,那么只要两个核在某一拍的总线信号稍有不同,包括非确定性输入导致的差异,都会被判定为故障。如果比较窗口是整个事务,那么在一个完整的AXI突发事务(比如8拍写突发)全部完成之后再比对,误报概率会降低,但检测延迟会上升到事务长度乘以周期数。
从功能安全的角度,检测延迟越短越好,从工程可行性的角度,比较窗口太短又很难保证100%无误报。我的折中方案是:在正常运行时用4拍滑窗比较,也就是允许两个核的总线信号在4个时钟周期内对齐,超过4个周期仍未对齐就触发错误。在启动阶段和看门狗复位阶段,把比较窗口扩大到64个周期,避免初始化次序和看门狗计数器初始值造成的非实质性差异导致的误报。比较窗口的动态调整,是通过一个模式选择寄存器实现的,软件在切换运行状态时显式写入。
比较器的实现还有一个隐藏的时序问题:比较逻辑本身是组合逻辑,如果待比较信号位宽太大,比如32位地址加上128位数据,组合逻辑深度会非常大,导致比较器所在路径成为整个系统Fmax的瓶颈。我实测在Artix-7上,比较器直接比较所有信号时Fmax只能达到80MHz,而优化后通过寄存器打拍和分级比较,Fmax能提升到130MHz以上。具体做法是:第一级比较地址和控制信号,第二级比较写数据和写响应,第三级比较读数据;每级输出一个独立的失配标志,最后用三个标志的OR作为总错误标志。这种做法让组合逻辑深度分散到三个时钟周期里,时序压力大幅缓解。
我在实际工程中花了不少时间调这个模块的时序一致性,有一条心得是:锁步比较器不要过度设计成"什么都要比",而是要想清楚"哪些差异真正代表故障"。如果对非确定性外设的数据也强行要求两个核保持一致,最终交付的只会是一个频繁误报的系统,反而不安全。核之内的计算正确性由锁步保证,核之外的非确定性由架构设计消化掉,这个边界要画清楚。
5. 常见问题与排查技巧实录
5.1 启动阶段频繁误报:程序计数器没对齐
现象:系统刚上电或复位后,在0到几百微秒内,比较器的error_flag经常拉高。造成这个问题的根源往往是两个核的程序计数器并没有在第一个时钟周期保持一致。因为MicroBlaze的复位向量是从固定地址取指令,如果复位释放的不同步导致其中一个核多执行了一条空指令,后续所有指令的PC就整体偏移,比较器必然失配。
排查手段:用ILA(Integrated Logic Analyzer)抓取master和checker的取指地址,对比前二十条指令的PC值。如果发现PC差异从一开始就存在,基本可以确定是复位问题。我最终的解法是让checker核在复位释放后保持Halt状态一段时间,直到master主动写一个启动寄存器,才把checker从Halt状态拉起。换句话说,用软件完成的握手比单纯依赖硬件复位更灵活。
5.2 偶发误报:UART接收FIFO的数据不一致
现象:系统长时间运行,期间只要串口收到一个字符,比较器就会误报。追根溯源后发现,串口接收FIFO在地址0x40600000上,master和checker都会读取这个地址。但两核在同一个时钟周期访问该地址时,读出的数据可能因为FIFO的内部读指针更新时序不同而不一致,导致master的R通道数据和checker的R通道数据出现一个字节的差异。
解决方案是把所有非确定性外设的访问排除在比较范围之外,具体做法是在比较器内增加一个地址过滤器:当访问地址落在非确定性外设地址区间时,比较器自动放行master的读数据,并把checker的读数据强制替换为master的结果。因为checker核不会真正使用这个读到的数据,替换后不会影响checker执行流的一致性。
5.3 比较器综合后时序收敛困难
现象:系统在30MHz下运行正常,把时钟提到100MHz后综合时序不过。前面提到过,比较器的组合逻辑如果放在单个周期内完成,位宽一大必然成为关键路径。所以我在实现中把比较逻辑拆成三级流水线,每一级只比较5到8个信号的组合,这样既保证了比较的准确性,又让每条路径的LUT深度控制在4级以内。如果还不过,可以考虑用Vivado的phys_opt_design对比较器模块所在的pblock做额外优化,或者在比较器里插入寄存器延迟让时序报告更友好。
5.4 故障注入测试时错误标志未触发
现象:通过仿真force翻转了master的AXI地址,但错误标志没有拉高。检查一下是否有两个原因:第一,翻转发生在比较器的bypass_mode寄存器被置1的状态下,此时比较器完全透传,既不会报错也不会阻断;第二,翻转后的数据恰好和checker对应周期的数据一致,因为两个核都通过了AXI Interconnect的同一路径,有可能在读回调过程中因为总线广播机制发生了数据对齐,这类情况在真实的物理故障注入里几乎不会出现,但在仿真中容易遇到。
最终的解决方案是在testbench里禁用广播机制,并且把比较器的错误响应做成立即锁存:一旦失配,error_flag保持为高直到软件手动clear。这样就不会因为故障注入后的后续周期恢复正常而漏掉报警。
结尾分享一个我的个人体会
DCLS锁步技术在FPGA上实现,最大的价值不是替代商用安全SoC,而是让你真正理解"安全机制"是如何在底层运转的。事务级总线比较器的实现难度并不高,但如何定义比较范围、如何设计故障响应路径、如何在检测率和误报率之间找到平衡,这些架构层面的思考才是工程核心。
最后还有一点建议:如果你准备把锁步方案推向量产,务必尽早把安全分析文档(比如FMEDA)和测试策略一并考虑进去。FPGA里的锁步逻辑本身也可能失效,所以通常需要在FPGA外部加额外的安全监控手段。这只是万里长征第一步,但在FPGA上证明锁步机制可行、可控,后面的路会好走很多。