FPGA设计核心:非阻塞赋值的深度解析
FPGA 设计笔记这份笔记围绕一个非常关键的核心问题展开为什么同一个always (posedge clk)块里前面写了rom_ready 1b0;后面再写rom_ready 1b1;不会冲突在这个问题背后实际上牵出了很多 FPGA 初学者必经的关键知识点非阻塞赋值的仿真机制“宏观并行、微观串行”的理解默认赋值 条件覆盖的写法单拍脉冲信号设计边沿检测打拍模块间握手valid/busy/doneI2C / EEPROM 控制器的工程化状态机写法这份笔记会把这些问题全部串起来形成一套能真正落地到 RTL 设计中的方法论。目录这个问题为什么关键Verilog 的“并行”到底是什么意思非阻塞赋值的底层机制为什么默认赋值和后续赋值不会冲突默认赋值 条件覆盖的 RTL 模式为什么这种写法综合出来不会打架这种机制和 I2C / EEPROM 状态机有什么关系单拍信号为什么适合这样写边沿检测为什么要打一拍模块握手为什么离不开默认赋值实战中的常见坑与修正思路最终总结一套可复用的 FPGA 设计习惯1. 这个问题为什么关键很多初学者学 Verilog 时会被一句话误导“非阻塞赋值是并行执行的。”这句话本身不算错但极不完整。如果只记住这句话就很容易产生下面这些困惑同一个 always 块里多次会不会冲突默认赋值是不是会把后面的赋值冲掉为什么done 1b0;放前面后面又done 1b1;还能正常出脉冲为什么有些信号可以这样写有些不能乱写为什么这种代码仿真和综合都能成立这些问题如果理不清后面写状态机、脉冲、握手、边沿检测时就会一直不踏实。所以这个问题看似只是在问rom_ready 0和rom_ready 1会不会冲突实际上是在问Verilog 时序逻辑在仿真器里到底怎么跑综合器又是怎么理解它的2. Verilog 的“并行”到底是什么意思要先建立一个非常重要的认知2.1 不同 always 块之间是并行的例如always (posedge clk) a b; always (posedge clk) c d;这两个always块在硬件上代表的是两个独立的寄存器逻辑它们确实是并行工作的。2.2 单个 always 块内部不是“乱序并行”而是顺序评估例如always (posedge clk) begin x 1b0; if (cond) x 1b1; end这段代码在一个时钟沿触发时仿真器会从上到下顺序评估。所以一定要记住这句话宏观并行微观串行。宏观不同always块之间并行微观同一个always块内部顺序执行这就是理解“默认赋值 条件覆盖”的钥匙。3. 非阻塞赋值的底层机制非阻塞赋值不是“立刻改值”而是分两步3.1 第一步评估右值把右边表达式当前时刻的值算出来。3.2 第二步在本拍末尾统一更新左值不是一执行到就立刻改寄存器而是先把“准备写入的值”放进 NBANon-Blocking Assignment队列等这个时钟沿对应的所有时序逻辑评估完再统一更新。3.3 用rom_ready的例子看一遍假设当前状态state S_SYS_IDLEsave_triggered 1代码大致如下always (posedge clk or negedge rst_n) begin if (!rst_n) begin rom_ready 1b0; end else begin rom_ready 1b0; case (state) S_SYS_IDLE: begin if (save_triggered) rom_ready 1b1; end endcase end end时钟上升沿到来后仿真器会这样做第一步执行rom_ready 1b0;仿真器记住“好这一拍结束时先准备把rom_ready更新成0。”第二步继续往下执行进入case(state)发现满足S_SYS_IDLE且save_triggered 1rom_ready 1b1;仿真器这时会更新同一拍里对rom_ready的调度结果“刚才说要写0但现在后面又让我写1。按照同一个 always 块、同一个时钟事件里的规则最后一次对该 reg 的非阻塞赋值胜出。所以最终记成1。”第三步这个 always 块执行结束到了 NBA 更新阶段rom_ready被更新成1。3.4 结论所以不会冲突也不会“一半是 0 一半是 1”原因不是“两个值同时存在”而是同一个 always 块里对同一个 reg 的多次非阻塞赋值最后一次有效。4. 为什么默认赋值和后续赋值不会冲突这其实就是 FPGA RTL 里最经典的写法之一done 1b0; if (某条件成立) done 1b1;它的意思不是“既给 0 又给 1”而是默认情况下给 0但如果后面的条件成立就把这拍的最终值改成 1也就是说前面的赋值是默认行为后面的赋值是特例覆盖。4.1 这是一种故意利用“后者胜出”的写法这不是偶然能跑而是工程上有意识地利用 Verilog 规则来让代码更简洁。如果不用这个套路你就得在每个分支里都手工写if (state A) done 1b0; else if (state B) done 1b0; else if (state C cond) done 1b1; else done 1b0;这会非常啰嗦还容易漏分支。4.2 为什么初学者会误解因为他们把“非阻塞赋值并行”理解成了每一条都独立生效所以多个赋值会冲突但真实情况是不同 always 块并行同一个 always 块内部顺序评估同一个 reg 在同一拍的最终值由最后一次决定5. 默认赋值 条件覆盖的 RTL 模式这是你后面写状态机、脉冲信号、握手信号时最应该养成的习惯之一。5.1 标准模式always (posedge clk or negedge rst_n) begin if (!rst_n) begin done 1b0; end else begin done 1b0; case (state) ST_STOP: begin if (phase 2d3) done 1b1; end endcase end end含义非常清楚复位时done 0正常运行时先默认done 0如果刚好处在ST_STOP且phase 3这拍改为1这样done就是一个很干净的单拍脉冲。5.2 这类写法特别适合哪些信号特别适合donevalidwr_enrd_enstart_pulseflag_clearrom_readyi2c_cmd_valid这些信号的共同特点是大部分时间应该为 0只有在某一个特定条件成立时才短暂为 1。5.3 不适合什么信号不适合拿来随便处理“需要保持状态”的寄存器例如statecntaddrmodebusy视具体设计而定因为这些寄存器通常不是“默认清零”而是“默认保持原值”或“根据明确状态流转改变”。6. 为什么这种写法综合出来不会打架从综合器视角看它不会去“模拟从上到下执行顺序”那种软件概念而是把整段逻辑翻译成一个组合逻辑网络驱动寄存器的 D 输入。例如rom_ready 1b0; case (state) S_SYS_IDLE: begin if (save_triggered) rom_ready 1b1; end S_WR_ISSUE: begin if (!rom_valid) rom_ready 1b1; end endcase综合器会把它理解成类似这样的选择逻辑if ((state S_SYS_IDLE save_triggered) || (state S_WR_ISSUE !rom_valid)) rom_ready_d 1b1; else rom_ready_d 1b0;然后rom_ready_d接到rom_ready触发器的 D 输入。所以硬件上永远只有一个最终结果要么 D 输入是 0要么 D 输入是 1并不存在“两个电平同时打架”。6.1 硬件本质是 MUX你可以把它想成一个多路选择器条件1成立 ───────→ 1 / 默认值 0 ──MUX \ 条件2成立 ───────→ 1只不过这个 MUX 是综合器自动帮你从if/case语句里提取出来的。7. 这种机制和 I2C / EEPROM 状态机有什么关系关系非常大。因为之前很多 I2C / EEPROM / LED 控制里的代码本质上都在大量使用这个模式。7.1i2c_cmd_valid的拉高与撤销在 EEPROM 控制器中上层发起一次 I2C 写操作时往往会这样写S_WR_BUSY: begin i2c_cmd_valid 1b1; if (i2c_busy) begin i2c_cmd_valid 1b0; state S_WR_DONE; end end这里你会发现前面先让i2c_cmd_valid 1一旦i2c_busy被下层拉高马上又写i2c_cmd_valid 0这也是同一个原理在这一拍里最后一次赋值决定最终值。如果i2c_busy这拍为 1那么最终i2c_cmd_valid就是 0而不会“既是 1 又是 0”。这正是典型的 ISSUE / WAIT_BUSY / WAIT_DONE 调度方式的一部分。7.2rom_ready的默认拉低与特定条件拉高这也是你问题本身最直接的来源。例如默认rom_ready 0当需要 ROM 提供数据时在某个状态把rom_ready 1本质上就是平时都不请求只有进入特定状态才发出一个明确请求信号。7.3done/rd_valid这类单拍信号I2C Master 在一次事务结束的那一拍常写成done 1b0; case (state) ST_STOP: begin if (phase 2d3) done 1b1; end endcase这个写法如果你理解了rom_ready的问题就会一下子通了。8. 单拍信号为什么适合这样写单拍信号的目标是默认都是 0只有某个事件“刚发生”的那一拍是 1下一拍立刻回到 0这种行为正好和“默认赋值 条件覆盖”天然匹配。8.1 例子val2_flag / val3_flag / val4_flag你之前遇到的 LED 控制代码里就有这种模式always (posedge clk) begin if(!rst_n )begin val2_flag 1d0; end else if(Rx_Done data_led[23:0] 24h00_00_FC)begin val2_flag 1d1; end else begin val2_flag 1d0; end end这就是最朴素的单拍写法命中条件那一拍拉高其余时间为 0它和“默认赋值覆盖”是同一类思想只是写法略不同。8.2 更推荐统一成默认赋值风格例如可以理解成always (posedge clk) begin if(!rst_n) val2_flag 1b0; else begin val2_flag 1b0; if (Rx_Done data_led[23:0] 24h00_00_FC) val2_flag 1b1; end end语义更统一也更便于大状态机风格扩展。9. 边沿检测为什么要打一拍除了默认赋值问题之前另一个典型问题是为什么val6_flag不直接看Rx_Done data_led24hFF而要先打一拍val6_set_d这是因为你想要的不是“条件成立时一直为 1”而是只在条件从 0 变 1 的那一拍输出一个脉冲。9.1 标准上升沿检测写法你之前的代码就是很标准的例子reg val6_set_d; always (posedge clk) begin if(!rst_n) val6_set_d 1b0; else val6_set_d (Rx_Done data_led[23:0] 24h00_00_FF); end always (posedge clk) begin if(!rst_n) val6_flag 1b0; else if((Rx_Done data_led[23:0] 24h00_00_FF) !val6_set_d) val6_flag 1b1; else val6_flag 1b0; end这里当前条件 Rx_Done data_led 24h00_00_FF上一拍条件 val6_set_d所以当前为 1 且上一拍为 0 → 上升沿就输出val6_flag 19.2 这个例子和默认赋值的关系val6_flag其实也是典型的单拍脉冲因此同样满足平时默认为 0特定条件下拉高 1 拍只不过这里触发条件不是电平命中而是“边沿命中”。10. 模块握手为什么离不开默认赋值之前在 I2C / EEPROM 设计里反复出现cmd_validbusydonerom_readyrom_valid这些信号里很多都是“只在特定时机拉高”的控制信号。10.1cmd_valid上层只在想发起命令时拉高平时要为 0。10.2done下层只在事务结束那一拍拉高平时要为 0。10.3rom_ready只在“我要向 ROM 取数”那一拍或那几拍有效其余时刻通常应拉低。所以握手机制本质上非常依赖这种 RTL 模式默认低电平特定条件覆盖为高。10.4 为什么这比“记住上一次状态”更自然因为这些信号的含义本来就是“事件通知”不是长期状态保存。busy更像状态done更像事件valid视协议定义有时像事件有时像请求对于事件类信号默认赋值模式最自然。11. 实战中的常见坑与修正思路11.1 坑一误以为“同一 always 里多次 是错误的”不是错误。只要你是有意识地在做默认值 条件覆盖这是标准工程写法。11.2 坑二不同 always 块同时写同一个 reg这才是真的危险。错误示意always (posedge clk) begin if (a) x 1b1; end always (posedge clk) begin if (b) x 1b0; end这会造成多驱动综合通常报错。同一个 reg 只能在一个 always 块里赋值。11.3 坑三把“默认赋值”误用到需要保持的状态寄存器例如这样state IDLE; case(state) IDLE: if(start) state RUN; endcase这段代码会导致每拍默认把state拉回IDLE除非在这一拍刚好满足切换条件大多数状态机不该这么写因为state应该默认保持当前值而不是默认回初始态。正确方式通常是case(state) IDLE: if(start) state RUN; RUN : if(done ) state IDLE; default: state IDLE; endcase或者显式写state state;但一般状态机里更常见的是每个状态都完整指定流转。11.4 坑四脉冲信号没有默认清零例如if (done_cond) done 1b1;如果没有别的地方把done清零done就可能一直保持 1。所以脉冲信号一定要有默认 0。11.5 坑五边沿检测不打一拍如果你想检测“刚发生”却直接写if (Rx_Done data 8hFF) pulse 1b1;那得到的只是“条件成立时为 1”不是严格意义上的“边沿”。要想只触发一次就得保留上一拍状态并比较。12. 最终总结一套可复用的 FPGA 设计习惯把这个问题和之前所有相关问题压缩成一套设计习惯就是下面这些12.1 关于 Verilog 执行模型不同always块并行同一个always块内部顺序评估非阻塞赋值在拍末统一更新同一拍、同一 always、同一 reg最后一次胜出12.2 关于 RTL 编码风格单拍信号适合“默认赋值 条件覆盖”事件信号默认拉低特定时机拉高状态寄存器不要乱用“默认清零”同一个 reg 只能在一个 always 块里赋值12.3 关于边沿与握手要边沿就打一拍要脉冲就默认清零要解耦模块就用valid/busy/done上层调用下层时用ISSUE / WAIT_BUSY / WAIT_DONE12.4 关于工程理解仿真器看“顺序评估 NBA 更新”综合器看“条件逻辑 MUX 触发器”软件式“执行顺序”只是理解手段最终还是要落到硬件结构上一句话总纲宏观并行微观串行默认先给条件后盖脉冲默认清零边沿先打一拍。如果你把这一句话真正理解透后面再看 I2C、EEPROM、UART、SPI、LED 控制器、状态机握手代码都会顺很多。建议文件名FPGA设计笔记_非阻塞赋值与默认覆盖.md或FPGA_Design_Notes_NBA_Default_Override.md