ARTICLE DETAIL

资讯详情

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

Verilog case语句从入门到工程实战:语法、综合与调试全解析

Verilog case语句从入门到工程实战:语法、综合与调试全解析 初学 Verilog 的时候case 语句大概是除了 assign 和 always 之外第三个让人“又爱又恨”的语法点。爱的是它写状态机、写多路选择实在顺手恨的是明明照着书抄的代码综合出来不是多了一堆锁存器就是仿真波形跟实际电路对不上。这篇就把我在 FPGA 和 ASIC 项目中反复用到、也反复踩坑的 case 语句经验整理出来从语法细看到综合原理从仿真调试到工程规范一次说透。1. case语句基础语法与内部机制1.1 标准case语法结构与全等匹配规则先放一段最标准的写法这是所有变体的地基always (*) begin case (sel) 2b00 : data_out data_a; 2b01 : data_out data_b; 2b10 : data_out data_c; 2b11 : data_out data_d; default : data_out {WIDTH{1b0}}; endcase end注意 case 括号里的表达式可以是 reg 型变量、wire 型信号也可以是几个信号的位拼接结果比如case ({a, b, c})。每个分支项既可以是常量也可以是位拼接表达式只要最终位宽匹配就行。这里有一个最容易忽略的底层规则case 做的是全等比较也就是用来比较不是。这意味着分支匹配时要求表达式和分支项每一位完全相同包括 0、1、x、z 这四种逻辑状态都要一一对应。举个例子sel 2b0x时case (sel)里写2b00 : ...是不会匹配的2b0x必须写一个2b0x的分支才能接住。很多新手在这个地方翻车以为仿真里看到 x 态只是“没初始化”其实它会直接改变 case 的跳转行为。1.2 casez 与 casex不确定态分别怎么处理casez 和 casex 是 case 的两个“放宽版本”它们的差别在于通配符的处理方式casez把z视为不关心位可以用?表示通配。综合时?会被当成任意值处理常用于优先级编码器这类场景。casex把x和z都视为不关心位匹配比 casez 更宽松。看这个优先级编码器的例子always (*) begin casez (irq) 4b1??? : vector 2b11; 4b01?? : vector 2b10; 4b001? : vector 2b01; 4b0001 : vector 2b00; default : vector 2b00; endcase endirq的最高位是 1 时不管低三位是什么直接匹配第一项。这是中断控制器里非常经典的做法。不过 casez 里的?只是个书写习惯等价于z写成4b1zzz也一样。casex 我要特别提醒一句仿真里好使综合里慎用。因为 casex 把 x 态也当成通配而综合工具处理 x 态的方式和仿真器完全不同很容易出现“RTL 仿真好好的综合出来的网表行为却变了”的问题。工程师圈子里有个共识casez 用于仿真和综合都没大问题casex 尽量只在仿真模型里用别拿到可综合代码里除非你有充分的理由并且清楚综合工具会怎么解释它。1.3 为什么硬件分支要选 case 而不是堆 if-else软件思维里if-else 和 switch 只是写法差异性能差别不大。硬件里完全不是一回事这俩综合出来的电路结构有本质区别。if-else 天生是优先级结构综合工具会把它变成级联的 MUX// if-else 结构 if (en_a) q data_a; else if (en_b) q data_b; else if (en_c) q data_c;这段代码综合后data_c到输出端要穿过两级 MUXdata_b穿一级data_a直通。也就是说每一个信号到达输出端的延迟不一样这就是优先级结构。条件多了关键路径会变长时序就不容易收敛。case 语句则不同综合工具会优先把它实现成并行 MUX所有分支信号到达输出端的路径基本等长。这正好符合数字电路里“多路选择器”的实际需求。所以我的经验法则很简单分支条件互斥、各分支地位平等 → 用 case分支条件有明显优先级、需要提前短路 → 用 if-else条件里夹着复杂算术比较比如a threshold而不是单比特匹配 → 用 if-else 更自然用生活类比来说case 像一个转盘你拨到哪个档位就直接连到哪个输出if-else 像一长串安检门每个包得一个一个过过一个门看一眼没过完所有门到不了出口。转盘当然比几百个安检门快但你要是本来就有优先级需求那安检门反而是对的电路。2. 综合实现与原理解析锁存器、优先级与位宽陷阱2.1 没有default的综合后果锁存器是怎么冒出来的这是 case 语句最常见的问题没有之一。看下面这段代码always (*) begin case (sel) 2b00 : q data_a; 2b01 : q data_b; 2b10 : q data_c; endcase endsel 2b11时没有任何分支匹配也没有 default。问题是 q 这时候该是什么硬件电路里没有“什么都不做”这个概念组合逻辑的输出每一刻都得有个确定的电平。综合工具的解法是让 q 在未匹配时保持原值——这就在 q 上悄悄推算出了一个锁存器。学过数字电路都清楚组合逻辑块里出现锁存器是严重的时序隐患它会破坏综合器对寄存器的统一管理导致时序分析结果完全不可信。更隐蔽的是这种锁存器不是每个综合工具都会给你报 warning。即使报了新人也很容易忽略。正确的处理方式有两种每个 case 都写全 default 分支在 always 开头先给输出赋一个默认值case 里只写需要特殊处理的对应关系。第二种写法更稳健因为它不仅防住了 case 分支不完整还防住了多个 case 分支相交时的赋值遗漏always (*) begin q {WIDTH{1b0}}; // 先给默认值 case (sel) 2b00 : q data_a; 2b01 : q data_b; 2b10 : q data_c; endcase end这两招不管组合逻辑还是时序逻辑的 case 都适用后者尤其适合那种分支特别多、每个分支只改某几个位的译码逻辑。2.2 组合逻辑与时序逻辑中case的写法差异case 语句放在组合逻辑和时序逻辑块里写法习惯差别很大。放在时序逻辑里时最典型的状态机写法长这样always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else case (next_state) IDLE : state IDLE; RUN : state RUN; DONE : state DONE; default : state IDLE; endcase end注意时序逻辑里用的是非阻塞赋值组合逻辑里用阻塞赋值。这两个用反了仿真波形会乱成一团整条链路都会出问题。另一个容易忽视的点是时序逻辑里如果 case 没写全分支且没有 default同样会推断锁存器——只不过这里不是组合逻辑推断的透明锁存器而是时序逻辑里容易造成状态机“跑飞”后无法自恢复。状态机的 state 信号要是进入了一个未定义编码而 case 又没有 default 兜底状态机就会卡死在那里再也回不到正常流程。这也是行业里检查清单里必查的一项状态机所有状态必须完整枚举default 必须回到复位态或 IDLE 态。2.3 位宽不匹配为什么表达式对不上分支case 的匹配是基于完整位宽的。看这段代码reg [3:0] sel; always (*) begin case (sel) 4d0 : q 1b0; 4d1 : q 1b1; default : q 1b0; endcase end这个没问题因为表达式和分支都是 4 位的。但是如果有人图省事把sel定义成 4 位分支却写成case (sel)2d0、2d1综合工具在比较时会把2d0扩展成4b0000把2d1扩展成4b0001。这其实也能匹配上。问题出在更隐蔽的场景表达式是{a, b}这种拼接结果时分支项位宽数错了就可能出现“看起来该匹配却死活不匹配”的现象。强烈建议case 表达式和分支项都用同一位宽的常量写法不要混用不同进制的数字。比如统一写成4b0001或者统一用参数定义localparam S_IDLE 4b0000; localparam S_RUN 4b0001; localparam S_DONE 4b0010;这样既避免位宽问题又让代码可读性好得多。2.4 早期综合指令 full_case 与 parallel_case 的工程红线很多教材会提到给 case 加综合指令case (sel) // synthesis full_case parallel_case这两个指令分别告诉综合工具这个 case 覆盖了所有分支full_case以及所有分支互斥且并行parallel_case。加了之后即使你没写 default综合工具也不会推断锁存器即使你的分支条件有重叠它也会强行按并行 MUX 来生成。听起来很方便但我要泼一盆冷水这两个指令能不用就别用至少别在仿真和综合共用一套 RTL 时用。原因很简单——它们改变的是综合结果不是仿真行为。full_case 在仿真里不会补足默认分支仿真时该不匹配还是不匹配该有锁存器还是锁存器于是仿真的行为和网表的行为就会不一致。这种“仿真和综合不一致”的问题在工程上定位起来极其痛苦。如果非要追求 full_case 和 parallel_case 的效果正确做法是老老实实写全 default然后通过代码风格保证分支互斥。这样仿真、综合、形式验证三方看到的语义完全一致。3. 实操案例从状态机到协议解析的case实战3.1 滑动窗口滤波状态机中的case写法滑动窗口滤波是个好东西做信号处理的人应该不陌生。它的核心逻辑是维护一个固定长度的窗口每次新数据进来窗口整体滑动再对窗口内的数据求平均。用 Verilog 实现时状态机非常适合而状态机的核心就是一个大 case。看一个简化版本module sliding_window ( input wire clk, input wire rst_n, input wire data_in, input wire data_valid, output reg [7:0] filtered_out ); parameter WINDOW_SIZE 8; localparam S_IDLE 3b000; localparam S_ACCUM 3b001; localparam S_SHIFT 3b010; localparam S_CALC 3b011; localparam S_DONE 3b100; reg [2:0] state, next_state; reg [10:0] sum; reg [3:0] cnt; reg [7:0] window [0:WINDOW_SIZE-1]; // 状态寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) state S_IDLE; else state next_state; end // 状态转移核心就是case always (*) begin next_state S_IDLE; case (state) S_IDLE : begin if (data_valid) next_state S_ACCUM; else next_state S_IDLE; end S_ACCUM : begin if (cnt WINDOW_SIZE - 1) next_state S_CALC; else next_state S_ACCUM; end S_CALC : next_state S_DONE; S_DONE : next_state S_IDLE; default : next_state S_IDLE; endcase end // 数据通路 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; sum 0; end else begin case (state) S_ACCUM : begin window[cnt] data_in; sum sum {{3{1b0}}, data_in}; cnt cnt 1b1; end S_CALC : begin filtered_out sum / WINDOW_SIZE; end default : begin cnt 0; sum 0; end endcase end end endmodule这个例子里的 case 写法有几个值得借鉴的地方状态编码全部用 localparam不裸写魔法数组合逻辑的状态转移 always 块里先给next_state赋默认值再写 case锁存器问题直接消失default 分支明确回到 S_IDLE保证状态机永远有出口数据通路单独用一个时序 always 块和状态转移分开避免一个大 always 里又管状态又管数据代码乱得没法维护。滑动窗口滤波里 case 真正的价值在于当你需要在多个阶段切换时它保证了任意时刻只有一个阶段在生效而且阶段之间的数据交接是可控的。你可以在 S_ACCUM 里把窗口填满在 S_CALC 里算平均值在 S_DONE 里输出结果并复位累加器。这种多阶段流水式的控制用 if-else 嵌套去写会非常痛苦case 则一目了然。3.2 出租车计费系统设计中的状态机拆分思路“出租车计费系统设计 verilog”这个主题在课程设计里出镜率极高。拆开来看计价器无非是三个核心功能里程计算、等待计时、金额累加。这三件事天然适合用状态机来管理。我的拆分思路是这样的localparam S_WAIT 3b000; // 空车待客 localparam S_RUN 3b001; // 载客行驶 localparam S_STOP 3b010; // 行驶中等待 localparam S_FINISH 3b011; // 到达结算状态转移用一个 casealways (*) begin next_state S_WAIT; case (state) S_WAIT : begin if (start_signal) next_state S_RUN; else next_state S_WAIT; end S_RUN : begin if (stop_signal) next_state S_STOP; else if (arrive_signal) next_state S_FINISH; else next_state S_RUN; end S_STOP : begin if (go_signal) next_state S_RUN; else next_state S_STOP; end S_FINISH : next_state S_WAIT; default : next_state S_WAIT; endcase end金额累加的部分则利用 case 判断当前状态决定每个时钟周期是否加里程费、是否加等待费always (posedge clk or negedge rst_n) begin if (!rst_n) fare 16d0; else begin case (state) S_RUN : begin if (distance_pulse) fare fare FARE_PER_KM; end S_STOP : begin if (wait_pulse) fare fare FARE_PER_MIN; end default : fare fare; endcase end end这种设计最关键的技巧是计价不关心“现在处于什么状态”之外的任何前置条件。case 在这里充当了一个清晰的表驱动逻辑——查表决定这个周期该执行什么动作。这比一串if (state S_RUN distance_pulse) ... else if (state S_STOP ...)清晰太多了。3.3 数码管译码与优先级编码器的经典case写法逐位译码这类逻辑是 case 最典型、也最能发挥价值的场景。7 段数码管显示 0~9甚至十六进制的 A~F用 case 写段码表是教科书级的标准操作always (*) begin case (hex_data) 4h0 : seg 7b1000000; 4h1 : seg 7b1111001; 4h2 : seg 7b0100100; 4h3 : seg 7b0110000; 4h4 : seg 7b0011001; 4h5 : seg 7b0010010; 4h6 : seg 7b0000010; 4h7 : seg 7b1111000; 4h8 : seg 7b0000000; 4h9 : seg 7b0011000; default : seg 7b1111111; endcase end类似地在前文提到的中断优先级编码器场景里casez 的?通配符让代码极其简洁。要注意的是 casez 和 case 的混合使用规范如果设计里同时存在普通 case 和 casez最好在命名或注释里明确标注避免后人误读。3.4 协议解析中的caseI2C读EEPROM的应用i2c 读写 eeprom 是很多同学练习状态机的标准项目。一条完整的 I2C 读操作包括起始位、发送器件地址写位、等待 ACK、发送寄存器地址、等待 ACK、重复起始位、发送器件地址读位、等待 ACK、读数据、发送 NACK、停止位。每个环节一个状态串起来就是一个典型的 case 状态机localparam S_START 4d0; localparam S_ADDR_W 4d1; localparam S_ACK1 4d2; localparam S_REG_ADDR 4d3; localparam S_ACK2 4d4; localparam S_RESTART 4d5; localparam S_ADDR_R 4d6; localparam S_ACK3 4d7; localparam S_READ 4d8; localparam S_NACK 4d9; localparam S_STOP 4d10;状态转移的 case 里每个状态只负责产生对应阶段的 SCL 和 SDA 电平然后根据移位计数器的值决定下一步跳转。写这类代码最重要的心得是不要把所有状态混在一个 always 块里处理而要把“状态转移”和“数据移位/输出数据”分成两个 always 块每个块各司其职。这样你在调试时波形里看到状态跳错、数据错、时序错能很快定位到具体块。I2C 协议里有个很经典的坑读操作最后一个字节主机要回 NACK否则从机不释放 SDA 总线。如果你在 case 里的状态转移写错了这一步仿真波形会一直卡在 ACK 等待上。这个坑不是语法问题而是对协议的理解问题但 case 的状态设计如果不清晰也会加剧排查难度。4. Icarus Verilog仿真环境下的case调试实录4.1 用开源工具链快速搭建case仿真环境Icarus Verilogiverilog是我调试 case 相关逻辑最常用的开源仿真工具轻量、免费、跑得快。对于只需要验证 RTL 逻辑正确性的场景它比动辄几个 G 的商业仿真器方便得多。仿真流程一般是三步iverilog -o sim.vvp tb_sliding_window.v sliding_window.v vvp sim.vvp gtkwave dump.vcd第一行编译第二行跑仿真并生成 VCD 波形文件第三行用 GTKWave 打开波形查看。我通常习惯在每个 case 分支相关的信号变化点用$display打印关键信息always (posedge clk) begin if (state ! next_state) $display(t%0t state%0d - next_state%0d, $time, state, next_state); end这样即使不看波形也能在终端里快速确认状态机每一步跳转是否符合预期。case 里最常出现的问题是“某个分支进去了但是没往下一个状态跳”。这种情况往往不是 case 本身写错而是分支内部的条件判断逻辑有问题用$display把每个条件和结果打出来一目了然。4.2 仿真中case匹配异常的定位方法在仿真里遇到 case 匹配异常不外乎三个原因。排查顺序我建议按下面来第一表达式出现了 x 态或 z 态。case 全等匹配意味着4b1x00不会匹配4b1000分支它只会匹配4b1x00这个显式分支。如果你没写这种分支它会落到 default。很多状态机莫名其妙跑进 default大概率是状态变量里混进了 x 态。定位方法很简单在状态转移的 always 块里加if (state[0] 1bx) $display(ERROR: state has x at t%0t, $time);第二case 表达式位宽不对。比如case(sel)里的 sel 是 4 位分支项却写成4b1虽然综合器可能自动扩展但仿真器在处理时可能按精确位宽比较行为和预想不一致。这种情况直接把所有信号和分支项都打出来对比很快能发现。第三多个分支具有相同的匹配条件。case 的匹配是自顶向下的最上面的分支优先生效。这不是错误但很容易写成“想匹配后一个分支却总被前一个截胡”。尤其是用 casez 写通配时4b1???和4b1?0?有重叠区域靠前的那条永远赢。排查时把输入从全 0 到全 1 遍历一遍逐个确认每个组合落在哪个分支这是最稳妥的做法。4.3 仿真和综合不一致的典型场景前面反复强调 full_case、parallel_case 会造成仿真和综合不一致这里用一个具体例子说明。假设代码这样写always (*) begin case (sel) // synthesis full_case 2b00 : q a; 2b01 : q b; 2b10 : q c; endcase end仿真时sel 2b11case 不匹配任何分支而 full_case 指令在仿真里被当作注释忽略。由于没有 defaultq 会保持上一个值——仿真波形里看到的是保持。但综合时 full_case 生效工具认为所有 4 种情况都覆盖了不会再给 q 生成锁存器而是直接按 2b11 是“无关项”处理。综合结果是什么取决于工具优化策略可能与仿真行为相差十万八千里。遇到这类问题我只有一个建议**把所有综合指令从 RTL 里拿掉老老实实写 default 和完整分支。**不要图省事把问题留给后续的实验室测试去发现那时候定位成本高十倍。5. 常见问题速查与工程化实战建议5.1 初学者最容易犯的六个错误把我在实际项目里见过最多的 case 问题统一列成一个表方便遇到问题快速对照错误现象根本原因解决方法组合逻辑输出带了锁存器case 分支不全且没写 default补 default或 always 开头赋默认值状态机跑飞回不来状态编码未覆盖所有可能值状态转移 case 加 default 回到 IDLE仿真波形全是 X表达式里有 x 态case 全等匹配落空检查复位和未初始化寄存器分支匹配总是不对case 表达式位宽和分支项不一统一用 localparam 定义完整位宽仿真好但板级就是不对full_case/parallel_case 改了综合结果删掉综合指令补 default多个分支同时命中条件分支有重叠用 unique0 审查或重新拆分分支条件每一条都是实打实踩过的坑。特别是锁存器那条我可以负责任地说几乎每个入门 FPGA 的人都会经历一次“明明是按书上写的怎么就一直报 latch inferred”。5.2 工程级代码规范状态编码、参数与注释case 语句在工程里写得好不好直接影响队友的维护体验。我总结了几条硬规范照着做基本不会出大问题状态编码一律用localparam定义禁止裸数字散落在代码里。名字用大写加下划线例如S_IDLE、S_TRANS、S_DONE。组合逻辑的状态转移 always 块里先给next_state赋默认值时序逻辑的 case 里default 必须回到复位态。每个 case 至少要有一个 default 分支哪怕你非常确定分支已经完整覆盖。工具每年都在变今天认为覆盖完整的枚举明天换一个综合器可能就多出几个非法态。多级嵌套 case 要慎重。case 里面再套 case可读性会断崖式下跌。如果确实有嵌套需求拆成两个 always 块分别用两个 case 处理然后通过中间信号交互。case 分支要避免出现“靠位置决定优先级”的隐性依赖。不要在一个 case 里先匹配4b1???再匹配4b10??这种代码逻辑上就是优先级编码建议显式写成 casez或者干脆用 if-else 表达。另外关于verilog task在 case 状态机中的应用我习惯把每个状态里要执行的复杂操作封装成 tasktask automatic i2c_start; begin scl 1b1; sda 1b0; #100; scl 1b0; end endtask然后在对应 case 分支里调用 task。这样 case 分支的代码量大幅减小状态机本身的逻辑一目了然task 内部实现单独维护。需要注意的是 task 里的延时写法只在仿真中有效可综合代码不要这么干——可综合的 RTL 里 task 只能做组合或时序逻辑的抽象封装不能含#延时。5.3 什么时候不该用case几条反直觉的经验case 好归好但它不是万能的。有些场景用 case 反而给自己添堵。第一种场景是连续区间判断。比如if (count 10 count 20)这种区间判断用 case 写会很别扭因为 case 的匹配是精确值不是范围。硬要写就得把 10 到 19 十个分支全部列出来纯属自找麻烦。这种就老老实实用 if-else。第二种场景是分支间存在重叠条件。比如执行到这里要同时判断“是否超时”和“是否收到数据”两者可以同时为真处理方式可能是叠加的。case 的互斥语义会让这种叠加逻辑变得绕用 if-else 表达反而自然。第三种场景是分支数量极少比如就两个分支。写 case 不比 if-else 简洁综合结果也没本质区别那就不必非得用 case。这些反直觉经验是代码评审时我常拿出来讨论的点。case 是为了让表达清晰不是为了炫技。如果一个分支结构用 case 写出来比 if-else 还绕说明这个场景选错了表达方式。5.4 与C语言switch的对比思维方式转换很多人是从 C 语言转过来学 Verilog 的总喜欢拿switch...case和 case 语句做类比。两者表面上都是“一个表达式对多个值分支”但背后的语义完全不同。C 语言的 switch 是软件顺序比较每个 case 分支是语句块执行完毕要靠break跳出Verilog 的 case 是硬件并行选择所有分支条件同时判断匹配成功的那个分支直接驱动输出。C 语言分支里可以干任何事——调函数、改全局变量、循环Verilog 的每个分支是在描述硬件行为本质上是在告诉综合器“这个条件下输出应该接哪个信号”。这个差异最直接的体现是C 语言里 switch 分支写多写少不会影响电路Verilog 里少写一个分支就会多出一个锁存器C 语言里分支顺序无所谓最多影响一点性能Verilog 里分支顺序影响匹配优先级进而影响综合电路。带着这个角度去看 case 语句很多怪现象就解释得通了——为什么 case 会综合出锁存器为什么没有 default 会警告为什么分支顺序会改变结果。这些在软件思维里都不存在只有在硬件的语义里才成立。写在最后我个人在工程里用得最多的是状态机和控制通路里的 case数据通路里反而用 if-else 多一些。上手久了你会发现case 不只是语法更是一种“用表格思考硬件”的思维方式——把输入空间按离散值划分每个输出值都有一一对应的映射。最后一个实用小技巧如果你的 case 分支特别多建议写完后用脚本把所有分支的取值空间扫描一遍确认没有重叠也没有遗漏。我习惯写一个小的 testbench遍历所有可能的输入组合断言输出不会落在未知状态里。这套方法帮我拦住过不止一次回归测试才会暴露的边界问题。
返回列表