ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Verilog描述电路与信号流动:从RTL到综合的思维模型

Verilog描述电路与信号流动:从RTL到综合的思维模型 作为常年跟数字电路打交道的工程师我对一个说法印象特别深Verilog 既能描述电路又能描述信号的流动。很多人觉得这是一回事其实不是。描述电路是在回答“这个模块有哪些输入输出、内部由哪些小模块拼起来”描述信号流动是在回答“数据从输入端进来之后经过哪些逻辑节点、在哪个时钟沿被锁存、最后怎么到达输出端”。写 Verilog 这些年我最深的体会是把这两种描述方式分开理解看架构图、调试波形、甚至和同事争论代码风格的时候思路都会清晰很多。1. 为什么 Verilog 既在“画电路”又在“讲故事”1.1 一块电路板的两种描述方式刚入门 Verilog 的时候很多人会拿着语言教程里那几句assign、always反复看却始终想不通一个核心问题我写的这些代码到底是“电路原理图”还是“程序逻辑”答案其实是两者都有。Verilog 天生具备双重身份它可以用结构化的方式描述一块电路里存在哪些元件、它们之间的连线关系是什么也可以用行为化的方式描述输入信号在时序逻辑中被如何采样、如何变换、如何输出。前者更像是在画一张电路原理图后者更像是在讲一个“信号如何一步步流过去”的故事。这也是“Verilog 描述电路”和“Verilog 描述信号的流动”这两个看似近义、实则互补的说法来源。我见过不少朋友写代码时只盯着语法结果犯了一个典型错误他们以为写 Verilog 跟写 C 语言一样只要逻辑对就行完全不考虑综合之后会变成什么样的电路结构。等到下板跑起来发现时序违例、资源爆了才回头看代码意识到自己描述的不是电路而是“一段看起来能跑的流程”。所以理解这两种描述方式之间的差异是写高质量 RTL 的第一步。1.2 结构描述是“零件清单”信号流描述是“流水线工序”打个比方结构描述就像你去买一套家具包装里附带的“零件清单”哪块板子是桌面、哪根是桌腿、哪几颗螺丝用来固定哪里清清楚楚。行为描述则是安装说明书上的“装配流程”先固定哪条腿再连接哪块侧板最后把桌面放上去一步步告诉你力是怎么传导的。在数字电路里结构描述对应的是模块例化、端口连接、门级原语。信号流描述对应的是assign数据流语句、always过程块里的寄存器传输逻辑。一个合格的设计者既要能“列零件清单”——把各功能子模块像积木一样排布清楚也要能“讲装配流程”——把每个时钟周期里信号从哪里来、到哪里去梳理明白。两者缺一写出来的代码要么难维护要么综合出来的电路和你的设计意图对不上。1.3 从行业热搜词看大家的真实需求有意思的是我查看最近相关的技术热搜词时发现大家关心的东西很杂既有计数器、UART、SPI、CRC 这类基础数字模块也有 buck 电路、图腾柱电路、差分放大电路、PFC 电路这类偏模拟电源方向的词还有 cache 的 Verilog 实现、递归二分树、轮询仲裁器这类偏体系结构和算法的问题。这说明一个现状很多人其实是在用 Verilog 解决具体模块设计问题而不是单纯学语言。但不管你是要写计数器还是写 Cache绕不开的核心始终是那两件事——你用什么方式描述电路结构你用什么方式描述信号在这段电路里的流动路径。下面的内容我会从这两个角度把 Verilog 的核心建模方式拆开讲然后再配合几个短视频上经常被点名的模块走一遍从 RTL 到综合的思路。2. 用 Verilog 描述电路模块、端口与层次化例化2.1 端口是电路对外连接的唯一通道很多新手第一次看到 module 定义时只看懂了input和output却没意识到端口本身就是“电路对外接口”的体现。在真实硬件里一个芯片有引脚一块电路板有连接器。在 Verilog 里module的端口列表就是这块电路对外的全部接口定义。module uart_tx ( input clk, // 系统时钟 input rst_n, // 异步复位低有效 input start, // 启动发送 input [7:0] data_in, // 并行待发送数据 output reg txd // 串行发送线 );这段代码里clk、rst_n、start、data_in是输入端口txd是输出端口。它描述的是“这个 UART 发送模块拥有哪些对外引脚”而不是“这些引脚之间如何传递信号”。很多新手会把所有信号在端口列表里一股脑列出来也不区分方向这就像一个芯片手册里连引脚方向都不标后面所有人都会踩坑。我的习惯是每个端口必须明确方向如果有必要还要写注释说明“为什么这个端口存在”。一个端口如果既不是输入也不是输出基本就是设计中存在逻辑漏洞的信号综合时会自动被优化掉。2.2 模块例化与层次化设计构建一颗逻辑树有了基础模块之后更重要的能力是例化。模块例化是“结构描述”里最典型的一类写法你定义好一个子模块后在父模块里把它例化出来并连接好信号这就像在图纸上放置了一个器件并连线。module top ( input clk, input rst_n, input [7:0] sw, output [7:0] led ); // 例化一个按键消抖模块用于处理外部按键输入 wire [7:0] sw_debounced; debounce u_debounce ( .clk (clk), .rst_n (rst_n), .in (sw), .out (sw_debounced) ); // 例化计数器模块计数结果输出到LED counter #( .WIDTH (8) ) u_counter ( .clk (clk), .rst_n (rst_n), .cnt (led) ); endmodule这种代码描述的不是信号怎么流动而是说明“顶层电路里有一个消抖模块还有一个计数器模块它们之间的连线是什么”。只要端口列表定义清楚例化时按名字连接.clk(clk)工具就能知道这条网络连到了哪个引脚。层次化例化对维护能力是巨大的提升。我做大型项目时分模块是最高优先级的工程纪律每个子模块负责一段相对独立的功能端口越少越好。这样在仿真调试时你可以单独拉出某一块电路观察它的信号在综合时工具也会保留层次方便你查看每个子模块的面积和时序报告。2.3 门级原语最低层的“电路描述”除了模块例化Verilog 还保留了最底层的门级原语and、or、not、xor等。它们直接对应 CMOS 电路里的逻辑门。module and_or_gate ( input a, input b, input c, output y ); wire t; and u1 (t, a, b); // t a b or u2 (y, t, c); // y t | c endmodule这种方式描述的完全是物理电路结构没有半点“信号行为”的影子。现代设计中除了教学或生成特定门级网表我们很少手写门级原语。但我建议初学者还是花半小时了解它因为它能帮你想明白一件事综合工具到底在干什么它做的事情本质上是把你写的assign和always翻译成由这些逻辑门构成的网表。你理解得越深写的 RTL 就越容易综合出高效电路。3. 描述信号的流动wire/reg、assign/always、阻塞与非阻塞从“描述电路结构”切换到“描述信号流动”是 Verilog 真正有意思的地方。这部分也是新手最容易翻车的区域。大家常问的一个问题就是wire和reg到底有什么区别assign和always什么时候用阻塞赋值和非阻塞赋值又为什么不能混用3.1 wire 是导线属性reg 是历史状态的记录很多教程会告诉你“wire 对应组合逻辑reg 对应时序逻辑”。这个说法方便却容易造成误解。更准确地说wire描述的是“连接关系”它就像一个物理导线只要输入端信号有变化输出端立即跟随变化。reg描述的是“寄存器”表示这个信号会保持上一个被赋值的结果直到下一次被赋值改变。但注意reg不一定会被综合成触发器。如果你在always组合逻辑块里用reg综合工具可能只会生成一个组合逻辑输出并不会产生存储单元。我更喜欢从“信号流动”的角度来理解wire上的信号像水管里的水水流是连续不断的reg上的信号则像水箱里的水位只有阀门打开赋值发生才会变化。理解了这一点你会自然明白为什么连续赋值语句assign只能对wire赋值而过程赋值always通常用来给reg赋值。3.2 assign 连续赋值组合逻辑中信号的持续流动assign语句是数据流建模最直接的体现它描述的是只要右侧表达式的输入信号发生变化左侧的输出信号就会跟着重新计算。这个过程中信号是持续流动的没有时钟参与。module mux2_1 ( input a, input b, input sel, output y ); assign y sel ? b : a; endmodule这段代码描述的是一个二选一多路选择器。从信号流动的角度看sel信号一旦跳变y就会在组合逻辑延迟之后跟随变化。综合工具会把assign语句转换成逻辑门网络信号从a/b/sel流向y中间没有任何寄存器。我经常用“交通信号灯”来类比clk是红绿灯周期assign则是没有红绿灯的十字路口车来了就走畅通无阻。所以在组合逻辑路径比较长时必须注意组合延迟是否满足时序约束。这也是为什么在高频设计中组合逻辑不宜过深——信号流动得越急留给它的传播时间就越短。3.3 always 过程块信号在时钟节拍里按序流动assign描述的是连续流动always则允许你描述“某个时刻有条件的信号变化”。当时钟沿到来时always块里的内容会被触发信号完成一次从输入到输出的流动并被锁存。module dff ( input clk, input rst_n, input d, output reg q ); always (posedge clk or negedge rst_n) begin if (!rst_n) q 1b0; else q d; end endmodule这段代码描述了一个带异步复位的 D 触发器。信号d并不会像组合逻辑那样持续流到q而是在时钟上升沿“拍一张快照”把此刻的d锁存到q。这个行为模型准确描述了信号在整个数字系统中最常见的流动方式按节拍移动。这也对应了数字电路里的那根全局时钟树——所有寄存器在同一个时钟沿更新信号就像阅兵方阵一样在时钟指挥下同步前进。3.4 阻塞与非阻塞两种信号流动速度的差异这部分是我每次带新人必讲的知识点。阻塞赋值用非阻塞赋值用。两者最核心的区别在于信号流动的方式不同。阻塞赋值先计算当前语句右侧的值立刻赋给左侧信号再继续执行下一条语句。信号是一条路走到头中途被截走就会影响后续逻辑。非阻塞赋值所有右侧表达式在同一时刻采样然后在当前时间步结束时统一更新到左侧。信号像并行流水线谁都不挡住谁。// 阻塞赋值示例q1 和 q2 会得到相同值 always (posedge clk) begin q1 d; q2 q1; end // 非阻塞赋值示例q1 和 q2 形成移位链 always (posedge clk) begin q1 d; q2 q1; end在时序逻辑里如果把非阻塞赋值改成阻塞赋值综合工具虽然可能会产生正确的触发器但仿真结果很容易和实际硬件行为不一致。因为非阻塞赋值在仿真时是把整个always块作为一个整体并行更新的这和真实硅片里寄存器同时沿时钟更新的物理行为是一致的。所以我的原则很简单时序逻辑块里一律用组合逻辑块用或assign。不要凭习惯混用这是我让很多同事避免深夜改 bug 的黄金法则。4. 从“信号流动”角度拆解几个常见电路模块理解了信号流动的基本概念我们拿几个大家经常搜到的模块来实战拆解包括计数器、按键消抖、UART 发送和轮询仲裁器。4.1 计数器最有代表性的时钟信号流动计数器是几乎所有数字系统都会用到的模块也是理解“信号按时钟节拍流动”的最佳例子。module counter #( parameter WIDTH 8 ) ( input clk, input rst_n, input en, output reg [WIDTH-1:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt {WIDTH{1b0}}; else if (en) cnt cnt 1b1; end endmodule从信号流动的角度看这个计数器的数据流形成了一条反馈环cnt的当前值沿着加法器回到cnt的输入端等待下一个时钟沿再次锁存。每个时钟上升沿这个环就“转一圈”计数器的值就增加 1。这里有一个关键点cnt 1b1在同一个时钟周期内通过组合逻辑产生加法结果但这个结果只会寄存到下一个时钟沿到来时才被写上cnt。所以综合后电路里看到的实际上是“寄存器加法器”的反馈结构而不是一个无限累加的函数。写计数器时最容易犯的错误是计数位宽不够。假设时钟频率是 50MHz你想实现每秒钟翻转一次 LED计数器需要数满 25_000_000。2^24 16,777,216不够2^25 33,554,432刚好够所以位宽至少取 25。这种位宽计算虽然简单但信号流动环路的周期数对不少人来说第一次算是会算错的建议列一下数值再写参数。4.2 按键消抖数字信号流动中的毛刺滤波按键消抖是热搜词里的常客。机械按键在按下和松开时由于触点抖动会输出几个毫秒的毛刺信号。如果不处理这些毛刺会像错误信号一样流入后续逻辑造成一次按键被识别成多次。从信号流动的角度常见的消抖方案是检测到信号变化后开始计数只有信号稳定持续 N 个时钟周期才认定电平变化有效。这里信号从“毛刺横飞”的输入进入计数器逻辑过滤掉短暂跳变再输出到后续状态机。module debounce #( parameter N 20 // 稳定计数阈值 ) ( input clk, input rst_n, input key_in, output reg key_out ); reg [N-1:0] cnt; reg key_tmp; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; key_out 1b1; key_tmp 1b1; end else begin key_tmp key_in; if (key_tmp ! key_in) begin cnt 0; // 信号发生变化重新计数 end else if (cnt) begin key_out key_in; // 信号稳定输出跟随 end else begin cnt cnt 1b1; end end end endmodule这段代码背后的信号流动逻辑是输入key_in先被寄存一拍得到key_tmp两者作为组合比较信号进入判断逻辑一旦发现输入和上一拍不一致就把计数器清零否则一直累加。当计数器累加到全 1cnt说明输入在很长一段时间内没有变化就认为按键电平稳定key_out才更新。常见问题有两个一是计数阈值 N 选太小比如只有 4~5根本过滤不了机械抖动通常建议让消抖时间达到 10~20ms二是复位后key_out的初值必须和按键释放电平一致否则系统启动瞬间会误判一次按键。4.3 UART 发送并行数据到串行数据的时序流动UART 是另一个高频搜词。它的本质是把 8 位并行数据变成一位一位在导线上发送的串行比特流发送时序完全由波特率钟决定。从“信号流动”角度理解 UART 特别清晰数据从并行的data_in端口进入在状态机的控制下按位移动到发送移位寄存器再从txd一位一位流出。module uart_tx ( input clk, input rst_n, input start, input [7:0] data_in, output reg txd ); localparam IDLE 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; reg [1:0] state; reg [3:0] bit_cnt; reg [15:0] baud_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; txd 1b1; bit_cnt 0; baud_cnt 0; end else begin case (state) IDLE: begin if (start) begin state START; baud_cnt 0; end end START: begin txd 1b0; // 起始位 if (baud_cnt 16d5207) begin baud_cnt 0; state DATA; bit_cnt 0; end else begin baud_cnt baud_cnt 1; end end DATA: begin txd data_in[bit_cnt]; if (baud_cnt 16d5207) begin baud_cnt 0; if (bit_cnt 4d7) state STOP; else bit_cnt bit_cnt 1; end else begin baud_cnt baud_cnt 1; end end STOP: begin txd 1b1; // 停止位 if (baud_cnt 16d5207) begin baud_cnt 0; state IDLE; end else begin baud_cnt baud_cnt 1; end end endcase end end endmodule注意这里波特率计数器的值5207是按 50MHz 时钟、9600 波特率算出来的50_000_000 / 9600 ≈ 5208所以每个比特需要计数约 5208 个时钟周期代码里取 5207 是考虑到计数器从 0 开始。这段代码看着复杂但它的核心其实就是一个信号流控制状态机决定数据应该从哪个端口流出波特率定时器决定每个比特停留多久。UART 调试时最典型的坑是波特率误差累计。如果你用的时钟不是整数倍关系比如 50MHz 除以 9600 不是整数那么每个 bit 都会累积一点误差连续发送 10 个 bit 后可能导致接收端采样错位。所以我在工程里一般会优先选择能被波特率整除的时钟频率或者干脆用带小数分频能力的时钟管理器。4.4 轮询仲裁器多路请求信号的优先级流动仲裁器在总线类设计里非常常见。热搜词里“轮询仲裁器”被点出来说明很多人都在尝试实现多个主机请求同一资源的分配逻辑。轮询仲裁器的信号流动是多个request信号按照优先级规则竞争一条授权通路。module round_robin_arb #( parameter N 4 ) ( input clk, input rst_n, input [N-1:0] req, output reg [N-1:0] grant ); reg [N-1:0] last_grant; always (posedge clk or negedge rst_n) begin if (!rst_n) begin grant {N{1b0}}; last_grant {N{1b0}}; end else begin if (req ! 0) begin // 从上次授权的下一个位置开始搜索 for (int i 0; i N; i) begin int pos; pos (i $clog2(last_grant) 1) % N; // 简化示意 end end end end endmodule上面的代码只是为了展示思路实际工程中我不会这样写因为它既不清晰也不可综合。更常见的做法是把环形搜索逻辑拆成一个组合函数计算“下一个应该授予谁”然后在时钟沿更新grant。从信号流动的角度看仲裁器就是让多个req信号进入一个优先级判断网络网络的输出是grant信号再反馈给请求方。请求方看到自己的 grant 被拉高后开始发起传输传输结束后释放req仲裁器再去响应下一个请求。理解这个闭环很重要仲裁器不是主动“分配”通路而是响应请求信号的流动状态。5. 为什么有些代码综合出来是电路有些却成了“信号博物馆”在讨论设计和综合的时候一个常被忽略的问题是同样的语法有些写法能综合成真实电路有些写法只能活在仿真器里。理解这个边界比背语法更有价值。5.1 可综合与不可综合信号要有确定流向简单来说可综合代码的每一条语句都能被映射到具体硬件单元。assign、always块中的if/else、case等只要没有使用仿真专用的延时和文件操作一般都能综合。而像#10这种显式延时、initial块、fork/join等通常不能综合。以initial为例它在仿真中用来给信号赋初值但在真实芯片里上电时寄存器处于不确定状态必须通过复位信号来初始化。所以很多 FPGA 工程里initial写在仿真 testbench 里没问题但绝对不应该出现在可综合 RTL 中。另一个典型的不可综合代码是“过度复杂的循环”。如果你在always里写了一个带while的循环循环次数不固定综合工具根本无法展开成确定性的电路结构因为它不知道信号要在这个循环里转几圈。这种写法本质上描述的不是硬件而是一段软件思维。5.2 组合逻辑中 always 的使用边界组合逻辑既可以用assign描述也可以用always块描述。但用always描述组合逻辑有一个大坑如果你没有给所有分支覆盖到赋值综合工具会推断出锁存器latch。比如下面这段代码always (*) begin if (en) y a; end当en 0时y没有赋值综合工具会推断出一个锁存器把y保持住。这在设计者脑海里可能只是“没变化”但硬件上会多出一个你不想有的锁存器。所以在组合逻辑always块里我习惯在开头对所有输出变量赋默认值然后再写条件分支这样就能保证每个分支都有赋值避免生成 latch。从信号流动角度看可综合的组合逻辑意味着任何输入变化都能在有限时间内传播到输出输出不应该保存历史状态。锁存器本质上是在组合通路中引入了“保存”能力这会破坏你对信号连续流动的假设。5.3 仿真波形里的信号竞争亚稳态与初值即使你写的代码综合得很干净仿真阶段还是会遇到一些信号竞争问题。最常见的情况是多个always块同时给同一个reg赋值这在 Verilog 里是禁止的因为它会引发仿真器内部的竞争最终波形结果不确定。另一个常见问题是异步信号的跨时钟域处理。比如外部输入信号直接打进两级触发器做同步如果你中间没有任何同步处理综合工具会给出亚稳态警告。亚稳态的本质是信号在触发器采样窗口内发生跳变导致触发器输出既不是稳定的 0也不是稳定的 1而是一个处于中间状态的电压需要一小段时间才能恢复稳定。处理亚稳态的传统做法是打两拍同步器reg sync_1, sync_2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sync_1 1b0; sync_2 1b0; end else begin sync_1 async_in; sync_2 sync_1; end end assign safe_in sync_2;这段代码描述的信号流动路径是异步信号先进入第一级触发器等待一个时钟周期后进入第二级触发器再输出到后续逻辑。第二级触发器的输出已经很大概率避开了亚稳态窗口可以作为同步信号使用。6. 调试信号流时的实用建议作为有过不少项目实战经验的人最后想分享两个我自己在调试“信号流动”问题时的习惯。6.1 用波形替代脑补把信号流动画出来很多新手写完代码综合不报错就以为万事大吉结果上板或者跑仿真时发现功能不对才开始一行行读代码。实际上信号流动的问题最好用波形来验证。在仿真工具里把关键信号加进波形窗口观察每个时钟沿前后这些信号如何变化这会比任何代码阅读都直观。我的调试流程一般是先看输出波形是否符合预期如果不符合按数据流方向往前倒查先查最后一级组合逻辑的输出再查上一级寄存器的值。定位到某个信号异常后再看它是在哪个时钟沿发生变化、变化是否符合设计逻辑。这套方法帮我解决过不少状态机跑飞、计数时序错乱的问题。6.2 写代码前先在纸上画一遍信号流向听起来很像老派做法但我真的建议你在写 RTL 之前拿张纸把模块内部的信号流动方向画出来包括输入信号进入模块后是直接进入组合逻辑还是先被寄存器采样。关键运算的结果是立即输出还是要在寄存器中暂存几个周期。模块之间数据是在同一个时钟域内流动还是跨了时钟域。画完这张图再动笔写代码你会发现很多设计问题在动手之前就暴露了。比如某个信号源和目的端跨越了时钟域却没有同步处理比如反馈回路里插入了太多组合逻辑可能导致时序违例。这些在代码里看起来不显眼在信号流图上却一眼就能看出来。我自己的习惯是在自己的工程笔记本上给每个核心模块都画一张这样的流向图。有时候过一个月回去看旧代码靠这张图能迅速恢复记忆比直接翻几百行 Verilog 高效得多。6.3 命名规范和单时钟域布线最后分享一个几乎所有资深工程师都会强调的经验信号命名要体现它的流动身份。时钟信号统一命名为clk低有效复位统一命名rst_n跨时钟域信号加cdc前缀寄存器输出加_r后缀组合逻辑输出加_c后缀。这样做的好处是当你写assign data_c a b;时任何读者都能从名字里知道这是一个组合信号而不是一个被时钟驱动的寄存器值。另一个经验是尽可能让整个设计工作在单时钟域中。如果不同模块需要使用不同频率尽量用时钟管理器产生同源时钟并且严格约束跨时钟域路径。信号流动一旦跨了时钟域就进入了一个充满亚稳态、时序约束和仿真不确定性的领域复杂度会成倍上升。不是不能做而是要付出额外精力去验证。对大多数中小型设计来说保持信号在单一、干净的时钟域内流动是最简单的稳定之道。
返回列表