YAOTU INSIGHTS

FPGA实现ARM双核锁步DCLS Lockstep:从原理到工程落地

FPGA实现ARM双核锁步DCLS Lockstep:从原理到工程落地
做功能安全相关开发的朋友对“ARM双核锁步DCLS Lockstep”这个词应该不陌生。锁步Lockstep听起来像是在说CPU走正步其实本质就是让两个处理器核心以完全相同的节奏执行同一条指令流然后在关键节点对拍检查一旦发现两边不一致立刻拉高错误标志。这套机制在汽车电子、轨道交通、工业控制这些对可靠性要求极高的场景里几乎是标配中的标配。问题在于真实芯片里的锁步方案比如Cortex-R系列硬核是厂家做好的黑盒你只能通过寄存器配置来打开或关闭看不到内部实现细节更没法针对自己的项目做定制修改。这时候FPGA的价值就出来了你完全可以在FPGA里自己搭一套双核锁步验证平台把比较器、时钟管理、故障注入这些关键环节全部摊开来看想怎么debug就怎么debug。这篇文章我就以“FPGA实现ARM双核锁步DCLS Lockstep”为主线把从原理到落地、从架构设计到实际调试的内容掰开揉碎讲一遍。适合正在做功能安全原型验证的同学也适合对CPU冗余设计感兴趣、想搞清楚锁步到底怎么工作的FPGA工程师。内容偏工程实践看完可以直接拿去搭自己的验证环境。1. 先搞清楚DCLS Lockstep到底在解决什么问题1.1 锁步不是“双备份”而是“双份执行、周期级对拍”很多人一听双核锁步第一反应是“哦是不是就像服务器里的双机热备一个挂了另一个顶上”。这个理解方向对了一半但机制完全不同。双机热备是检测到故障后切换中间有切换时间而且主备之间不是同步运行的而锁步是两边的执行进度严格对齐每个时钟周期都在做一致性的校验。DCLS的全称是Dual Core Lock-Step双核锁步。它的工作方式可以简化成这样一个模型两个处理器核Core0和Core1从同一个复位信号释放吃同一个时钟源执行同一份代码访问同一片地址空间。在任意一个时钟周期里两个核的指令执行轨迹都应该完全一致。既然轨迹一致那么它们对外发出的总线请求、写数据的值、地址信号、控制信号自然也应该逐位一致。比较器就挂在两个核的“输出边界”上实时对拍这些信号。如果某个周期发现Core0和Core1的地址线、数据线或者控制信号有任意一位不相等就直接判定系统出现故障拉高错误输出。这就是DCLS的核心思想不是“错了之后再恢复”而是“错的那一拍就抓出来”。这里要特别强调一个误解Lockstep严格来说是个“故障检测”机制不是“故障容错”机制。它能在错误发生的当拍或几拍之内发现异常但它本身不能保证系统继续正确运行。检测到错误之后如何处理——是重启整个系统、切换到一个安全状态还是触发看门狗复位——那是安全机制里外层干的事。所以设计DCLS的时候不能只盯着比较器本身还得想清楚错误上报之后的响应路径。1.2 锁步的粒度选在哪个层级直接决定成本和效果既然要对拍就涉及到“对拍什么”的问题。锁步的粒度有几档选择从细到粗大致是指令级比较是最严格的方案CPU内部每执行完一条指令就把指令编码、通用寄存器结果、标志位全部送到比较器做比对。这种方案检测能力最强任何一个逻辑错误都逃不掉但实现代价也最高。因为比较点深入到处理器内核内部占用的比较位宽巨大而且比较逻辑会成为关键路径上的瓶颈严重影响主频。更重要的是这需要你能拿到处理器核内部的执行状态如果是外购IP核或者ARM硬核根本不给你这个接口。总线级比较是工程上最常见的折中方案。比较点放在处理器核的对外总线接口上比较地址总线、写数据总线、读数据总线的返回数据、读写控制信号等。这种方案不需要修改处理器核内部结构对IP核的侵入性几乎为零。只要这两个核在做同样的操作它们发出来的总线事务就必须完全一致所以通过对比总线信号就能覆盖绝大多数单点故障。代价是如果故障发生在CPU内部但没体现在这一拍的总线上可能要过几个周期才会暴露实时性比指令级略差。存储级比较是粒度最粗的一档只对最终写入存储器的数据做校验。这种方案实现最简单但检测延迟很大而且对CPU内部寄存器、流水线的故障没有感知能力。真正常见的错误场景比如ALU计算结果错了但写存储的时候恰好没用到这个错值存储级比较就完全抓不到。我做这个FPGA项目时选的是总线级比较具体落在AXI总线的写地址通道、写数据通道和读数据通道上。原因很简单总线信号数目可控FPGA资源够用覆盖范围足够覆盖大部分真实故障场景而且方案可以迁移到任何带标准总线接口的软核处理器上不管你是用MicroBlaze、Nios II还是RISC-V软核。1.3 DCLS跟TMR、双核互检这些方案比优势劣势在哪做冗余设计方案不止DCLS一种。不少做可靠性设计的同学会纠结到底是做三模冗余TMR还是双核锁步还是简单的双核软件自检。这里我列个对比方便大家选型的时候心里有数。方案冗余度故障检测能力纠错能力资源开销典型应用DCLS双核锁步2核强周期级检测无只能检测约2倍逻辑比较器汽车MCU、工业控制器TMR三模冗余3核强有可以多数表决纠错约3倍逻辑表决器航天电子、高可靠存储双核互检软件2核中软件周期检查无低复用已有核消费级、低成本的可靠性增强从开销角度看DCLS只比单核多复制一份CPU逻辑外加一个比较器面积大约是单核的2.1到2.3倍。TMR虽然是1.3倍往上的纠错能力看着很香但面积是3倍起步功耗也跟着涨在很多对成本和功耗敏感的工业场景里根本吃不消。TMR的优势是表决机制天然能“容忍”单次故障系统可以继续运行DCLS的优势是检测延迟低、面积小。两者不是替代关系实际工程里还有组合用法比如CPU子系统用DCLS保证错误快速被发现外部的安全核再用软件手段做恢复策略。软件层的双核互检就便宜很多了。两个核跑不同的代码各自定期通过共享内存交换心跳和校验值发现对方异常就上报。但这种方案检测延迟是毫秒级的而且依赖软件正确执行——如果故障直接把核的PC指针跑飞了软件检查代码本身可能压根就没有执行机会。所以它只能作为低成本辅助手段不能替代硬件锁步。2. 在FPGA里实现DCLS Lockstep的整体架构设计2.1 双核复制、比较器、时钟管理三个部分缺一不可搞清楚了原理再看怎么落地到FPGA。我采用的顶层架构分成三大块双核处理器复制区、锁步比较器、时钟和复位管理单元。双核处理器复制区很好理解就是把一个处理器软核例化两份。这里的关键在于“复制”的程度。如果你用的是MicroBlaze这样的软核直接例化两个相同的IP实例就行两个核的配置参数必须完全一致包括缓存大小、总线接口宽度、中断控制器配置等等。任何一处的配置差异都可能导致两个核行为不一致让比较器瞬间误报。如果你用的是第三方RISC-V软核道理一样两个实例的参数必须逐字相同。时钟和复位管理单元是整个锁步机制的“地基”。两个核必须共享同一个时钟源、同一套复位释放逻辑。这句话说起来容易实际做的时候坑很深。如果两个核的时钟不是同一个BUFG扇出而是各自过了不同的MMCM/PLL那么它们之间的时钟偏斜可能会达到几百皮秒到几纳秒而你的总线信号在这样的偏斜下必然会出现周期对齐误差比较器就会产生大量误报。所以我的设计里是两个核共享同一个时钟网络通过一个全局BUFG驱动到两边比较器则用同一个时钟沿采样两边的信号。锁步比较器是整个系统的核心判决部件。它位于两个核的总线输出端实时采样的信号经过打拍对齐后做逐位比较任何一位不一致就触发错误标志。比较器本身需要有错误锁存功能也就是一旦检测到不匹配错误寄存器保持拉高直到外部安全逻辑主动清除。这是为了保证错误事件不会因为时序巧合被漏掉。2.2 数据流视角下锁步系统是怎么工作的从数据流角度看锁步系统的工作时序大致是这样复位释放之后两个核同时从复位向量取指开始执行同样的程序。因为两个核的存储器是共享的或者至少映射到同一份存储器镜像它们在同一拍发起的取指请求访问相同的地址从存储器读回相同的数据然后计算得到相同的结果。当其中任何一个核需要发起总线写操作时写地址、写数据、写控制信号同时出现在两套总线上比较器在下一拍对这些信号采样比对。如果一致这次写事务就正常放行如果不一致比较器立即拉高错误标志同时可以可选地抑制这次写操作防止错误数据污染系统状态。读事务的处理要稍微复杂一些。因为两个核共享存储器读数据总线是双向的比较器需要在读数据返回路径上也对齐一次。也就是说比较器不仅要比对两个核“发出去”的地址和控制信号还要比对存储器“返回给两个核”的数据是否一致。如果存储器端返回给Core0和Core1的数据不一致——这可能是存储器接口本身出了问题——那也得立刻报警。我在这块实现的时候是把比较器的采样点划分成了两组一组叫上游比较点监视两个核对外的请求信号另一组叫下游比较点监视存储器返回给两个核的响应信号。两组比较点分别打拍对齐后汇入同一个判决逻辑。2.3 为什么比较点选在总线级就够了而不追求核内全比较很多第一次做锁步的同学都会问既然要保证绝对可靠为什么不把比较点深入到CPU的每个流水线级把所有内部信号都拉出来比对想法很好但工程上行不通。第一拿不到核内信号。商用软核IP给你提供的调试接口通常只是AXI总线级的核内部的流水线级信号一般不会开放。除非你自己写一个处理器RTL否则连“全比较”的物理前提都不具备。第二即便拿得到比较位宽会爆炸。一个32位RISC-V核内部流水线相关的信号轻松超过上千根把这上千根信号同时接入比较器光布线就会把时序拖垮主频会掉得非常难看。第三有些内部信号是动态的、只在一个周期有效的拿来做跨周期比对需要额外的同步机制复杂度成倍上升。所以工程上普遍接受的方案就是总线级比较。ARM自家的Cortex-R系列处理器做锁步时比较点也是选在核心边界的总线上而不是每个流水级。总线信号已经足够丰富——地址、数据、控制、响应——只要两个核执行轨迹一致这些信号就必须一致反之如果任何一个功能模块出问题导致了结果偏差它最终都要反映到总线事务上。这就够了。3. 核心环节实操比较器与时钟管理怎么落地3.1 时钟偏斜是锁步的头号杀手先解决同源和相位问题我现在明确告诉你在FPGA里做双核锁步十个误报里至少八个跟时钟偏斜有关而不是真的发生了故障。所以第一步不是写比较器而是把时钟方案定死。我的推荐做法是所有锁步相关逻辑两个核、比较器、错误处理都用同一个时钟域这个时钟由一个MMCM/PLL产生后经过一个全局时钟缓冲器BUFG扇出到所有模块。两个处理器核的时钟端口直接连这个BUFG的输出不要在中间再插任何时钟分频器或者门控逻辑。实际操作中有一个很容易踩的坑为了调试方便有人会在两个核的时钟路径上各自加一个时钟门控单元想实现“单独停掉某个核的时钟”来模拟故障。结果时钟门控在关断和打开瞬间会产生毛刺毛刺一进时钟树两个核的触发沿瞬间错位比较器就开始疯狂报错。正确的做法是故障注入不要通过动时钟来实现后面我会讲更可控的故障注入方法。复位也有讲究。两个核必须使用同步释放的复位信号而且这个复位信号也要走全局资源比如全局复位网络保证两个核在同一个周期退出复位。异步复位同步释放电路是必须的不能把外部按钮的异步复位直接接进两个核的复位端。如果复位释放差了一个周期一个核已经跑起来取指了另一个还停在复位态比较器会在启动那一拍立刻拉高错误。我在仿真阶段就吃过这个亏。一开始图省事复位信号直接用testbench里的异步复位结果仿真波形里两个核的启动就差了一个周期比较器在第一笔总线事务就报警。后来改成统一的复位同步释放模块用同一个BUFG驱动的复位信号问题才消失。3.2 比较器设计要点打拍对齐、错误锁存、安全状态输出比较器本身的逻辑不复杂难的是对齐和时机控制。两个核的总线信号在各自逻辑路径上经过的寄存器数量可能略有不同所以同一拍总线事务到达比较器输入的时间会有细微差异。设计比较器时必须先把两路信号各自打几拍确保比较的是“同一拍”的状态而不是一个核的当前拍对另一个核的上一拍。我用的比较器接口大概是这样的module lockstep_comparator #( parameter ADDR_W 32, parameter DATA_W 32, parameter CTRL_W 8 )( input wire clk, input wire rst_n, // Core0 bus observation bus input wire [ADDR_W-1:0] c0_addr, input wire [DATA_W-1:0] c0_wdata, input wire [CTRL_W-1:0] c0_ctrl, // Core1 bus observation bus input wire [ADDR_W-1:0] c1_addr, input wire [DATA_W-1:0] c1_wdata, input wire [CTRL_W-1:0] c1_ctrl, // valid enable from bus transaction input wire cmp_valid, // output status output reg err_flag, output reg [7:0] err_cnt, output reg [31:0] err_info_addr ); wire [ADDR_WDATA_WCTRL_W-1:0] c0_all {c0_addr, c0_wdata, c0_ctrl}; wire [ADDR_WDATA_WCTRL_W-1:0] c1_all {c1_addr, c1_wdata, c1_ctrl}; reg [ADDR_WDATA_WCTRL_W-1:0] c0_q, c1_q; // stage alignment: collect same-cycle signals // wrong to compare c0_all and c1_all directly at this module port always (posedge clk or negedge rst_n) begin if (!rst_n) begin c0_q 0; c1_q 0; end else if (cmp_valid) begin c0_q c0_all; c1_q c1_all; end end always (posedge clk or negedge rst_n) begin if (!rst_n) begin err_flag 1b0; err_cnt 8h00; err_info_addr 32h0; end else if (cmp_valid) begin if (c0_q ! c1_q) begin err_flag 1b1; err_cnt err_cnt 1b1; err_info_addr c0_q[ADDR_WDATA_WCTRL_W-1 -: ADDR_W]; end end end endmodule这里有个容易被忽略的细节。比较器的输入不能直接拿两个核的原始总线信号做组合逻辑比较因为两个信号的到达时间几乎不可能完全一样。组合比较一旦出现数据在窗口内变化的毛刺输出就会抖动可能产生一拍虚假的“不相等”。所以我在模块入口处先寄存再比较所有输入信号在同一拍锁存下一拍再对锁存后的值做比较。这样虽然多了两级延迟但换来了比较结果的确定性这个代价完全值得。3.3 双核初始化与进入锁步的启动流程两个核同时从复位释放就一定能自动进入锁步吗答案是能进入但需要一些谨慎的处理逻辑。启动阶段的一个重要问题是存储器初始化。如果两个核共享一份BRAM上电后BRAM内容默认是0两边取指都是0x00000000那没问题。但如果你的系统里有一块ROM或者Flash用来放启动代码而两个核访问这份启动代码的路径不一样比如一个走AXI全互连另一个走了简化的直连通道那启动代码返回的数据和时序就可能不一致。解决办法是尽量让两个核对存储器的访问路径完全对称走同一个互连管线。还有一类启动问题是中断。两个核必须使用完全相同的中断配置并且在锁步建立之前不要使能任何中断。如果Core0比Core1多响应了一次中断两边执行流立刻分叉比较器马上报警而且这种错误具有传染性后边永远追不回来。我的做法是启动阶段先屏蔽所有中断源等两个核运行到“锁步握手点”也就是两边都执行到一个约定好的同步标记之后再统一使能中断。握手流程可以这样设计两个核各自执行一段自检代码完成后往各自专用的状态寄存器写一个固定值“0xA5A5”然后进入一个等待循环。外部安全状态机轮询这两个寄存器发现都是0xA5A5之后置位“锁步有效”信号通知比较器开始工作。在锁步有效信号拉高之前比较器处于旁路状态不做判决。这样就把“启动过程中的正常差异”和“运行过程中的真实故障”干净地切开了。4. 工程化落地资源占用、时序收敛与验证方案4.1 不同实现路线的资源开销对比选FPGA型号之前先大概估算一下双核锁步需要多少资源。以我用的32位RISC-V软核为例单个核大约消耗3000到5000个LUT具体取决于配置加上本地中断控制器、总线接口逻辑保守按单个核5000个LUT算双核就是10000个LUT。比较器的位宽如果按总线信号全量算地址32位、写数据32位、控制8位读写各一套大约需要几百个LUT几乎可以忽略。再加上时钟管理和错误处理模块整个系统的LUT开销大约在11000到13000之间。模块预估LUT备注Core032位RISC-V软核4500~5500含总线接口、中断控制器Core1与Core0同配置4500~5500两核完全对称锁步比较器300~500打拍寄存器比较逻辑时钟/复位管理100~200BUFG、复位同步释放故障注入调试接口500~800可选建议保留这个量级的资源在Artix-7级别的FPGA比如XC7A35T就有约20000个LUT上已经可以跑得很舒服。如果你用的是带ARM硬核的Zynq系列可以在PL端用软核搭锁步验证系统PS端ARM跑监控和上层应用这样还能顺便验证异构环境下的锁步集成。选型建议就一句话别为了省资源把两个核的缓存或者总线配置缩水锁步系统里两个核的配置必须完整且完全对称否则后面调试误报的时候你会怀疑人生的。4.2 比较器的时序收敛经验时序收敛是FPGA实现里最让人头疼的环节。双核锁步系统的关键路径往往不在CPU本身而在比较器。比较器的输入信号来自两个核的总线输出这两路信号在布局布线之后到达比较器的路径延迟不可能完全一致。如果比较器还在同一拍做组合比较建立时间裕量会被两路信号的偏斜吃掉时序很容易跑不过。我的处理办法是把比较逻辑多打一拍第一拍锁存两路信号第二拍做比较第三拍锁存错误状态。每增加一级流水关键路径上就能松出大约1到2纳秒的余量换来的是两三个周期的检测延迟对于锁步场景完全可接受。还有一类约束要主动设置。因为比较器比较的是同源数据两个核的双份总线从逻辑上是冗余的工具默认会认为它们之间没有逻辑依赖可能会为了布线方便把这两路信号走到相隔很远的位置导致偏斜加大。这需要手动加约束把两个核的观察总线尽量约束在同一个时钟区域内。Xilinx系列的Pblock约束或者Altera系列的逻辑锁定都可以用来做这件事实操下来对时序收敛帮助很明显。4.3 故障注入验证让系统“假装出故障”再验证它真的能抓住锁步系统做出来不是拿来跑hello world的必须验证它真的能在故障发生时抓住错误。这就需要一个可控的故障注入机制。我最推荐的故障注入方式是在总线观察信号上做多路选择。每个比较器的输入信号前面加一个选择器正常模式下直通原始信号故障模式下选择器输出人为翻转后的信号。这个选择器的控制端暴露成调试寄存器通过串口或者JTAG访问。注入故障时往调试寄存器写一个值选择器就在指定拍把某个数据位强制取反。这样控制精确、可重复而且不影响两个核本身的真实运行。仿真阶段的故障注入更简单。直接用force命令在总线信号上强制赋值观察比较器是否在预期周期拉高了err_flag。我在做功能验证时会对以下场景分别做回归测试写地址总线单bit翻转Core0地址线第7位取反期待比较器报警写数据总线单bit翻转Core1数据线第15位取反期待比较器报警控制信号翻转将两个核的写使能置为不一致期待比较器报警存储返回数据不一致模拟存储接口给两个核返回不同数据期待比较器报警两核同时位翻转且翻转位相同这种“共模故障”比较器理论上发现不了需要靠外层多样化设计兜底。这里引出一个很关键但很多人会忽略的问题如果两个核发生了完全相同的故障比较器会认为“两边一致”故障就溜过去了。真实ASIC里的锁步为了对抗这种共模故障会引入多样化的执行方式比如一个核比另一个核延迟两个周期资料里叫延迟锁步。延迟锁步的好处是同一时刻两个核处于不同的指令上下文同一个物理扰动很难让两个核在各自的上下文里犯下完全相同的错误。FPGA原型验证里我也建议加上这个延迟特性实现就是让其中一个核的总线信号多打两拍再进比较器成本很低但有效性提升明显。5. 常见问题与调试记录5.1 双核“时间上不同步”怎么排查现象是上板之后上电或者复位之后很快就报了err_flag而且每次报错的位置都不固定。这是典型的双核没有真正同步启动的表现。排查步骤我一般按这个顺序来确认两个核的复位信号是否来自同一个复位同步释放模块用逻辑分析仪抓两个核的复位端口确认释放沿在同一拍。确认两个核的时钟是否同源看时钟树报告两个核的时钟路径必须从同一个BUFG扇出。确认两个核的启动代码入口地址是否一致不一致的话一个核跑A程序一个核跑B程序不报警才怪。确认两个核的中断是否完全关闭尤其注意外部中断引脚在上电瞬间的随机电平是否被误判为有效请求。另一个我遇到过的隐蔽情况是BRAM初始化文件的差异。如果Core0和Core1各挂了独立的指令存储器而两份存储器初始化文件因为编译过程的某种原因出现了微小差异比如多了一行数据定义两个核执行的指令流就会在某个点开始分叉。这种分叉后的锁步系统会在错误位置报警但错误跟随机故障没关系纯粹是配置不一致。5.2 比较器误报率过高先怀疑同步设计而非实际故障如果系统运行一段时间后频繁误报但用仿真跑同样的程序又完全正常问题极大概率出在FPGA物理实现层面。首先检查时钟约束。确认create_clock是否只定义了一个主时钟所有相关时钟域关系是否被正确识别。比较器跨时钟域处理不当、异步信号没同步就打拍会导致采样窗口内的亚稳态产生随机误报。其次检查布线报告里比较器相关路径的偏斜值。正常情况下同一时钟区域内的偏斜在几十皮秒量级如果查出来偏斜达到了几百皮秒甚至纳秒级说明布线把两个核的输出路径拉得太远需要用区域约束把它们绑近。最后对照有没有编译器自动做了“不期望的优化”。比如综合工具认为某个信号恒定为0就把逻辑优化掉但你让它做比较的两个信号恰好被工具判定为“同源恒等”比较器就被优化成了永久不报警的空壳。这个问题在综合报告里会体现为“逻辑已被简化”需要检查综合日志必要时给比较器模块加(* keep true *)属性防止被合并。5.3 故障注入之后系统“卡死”而不是进入安全状态Fault注入后如果系统只是死在那里说明错误检测链路本身没问题但安全响应机制没有闭环。锁步比较器拉高err_flag之后外部安全状态机应该立即响应比如生成复位请求、封锁总线写使能、切换LED状态。我在第一次验证时就犯了这个错比较器报了错但总线写操作已经完成了错误数据已经写进了存储器。后来加了“错误抑制”逻辑一旦err_flag拉高立即把两套总线输出的写使能信号强制拉低让后续的错误数据无法进入存储器。同时安全状态机接管系统进入预定义的安全停机状态不再执行正常程序。功能安全设计里这个状态往往叫“安全状态”具体表现因项目而异但前提是必须有一个明确的响应路径。5.4 问题速查表现象可能原因排查动作上电即报错复位不同步/启动代码入口不一致抓复位沿比对启动PC偶发误报时钟偏斜过大/比较器时序紧张检查时钟树报告优化布线运行一段时间后死机存储器内容被意外改写确认错误抑制逻辑是否生效故障注入无反应比较器被综合工具优化添加保持属性重新综合两个核寄存器状态不一致但比较器不报共模故障引入延迟锁步机制仿真正常但上板异常时序约束缺失补充完整时序约束调试的过程其实也是加深对锁步理解的过程。每一次误报都在告诉你你设计里的哪个假设没有被满足——可能是“两个核的时钟完全同源”这个假设可能是“总线信号到达时间完全一致”这个假设。把假设一个个验证清楚锁步系统也就稳定了。最后再多说两句这次做完这个FPGA双核锁步原型我最大的一收获不是把比较器跑通了而是彻底理解了为什么真实芯片里的锁步方案会设计成那样。那些看起来“多余”的寄存器打拍、延迟锁步、错误封锁逻辑每一个都是对应着一个真实世界的故障模型。如果你也想搭一套类似的验证平台我的建议是从最简单的总线级比较开始先把时钟、复位这些地基打稳再逐步加进故障注入、延迟锁步、安全状态机这些高级特性。别一上来就追求全面覆盖能从稳定的双核执行跳到稳定报错就已经成功了一大半。