ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Verilog case语句详解:从语法到综合陷阱与调试技巧

Verilog case语句详解:从语法到综合陷阱与调试技巧 说出来你们可能不信我真正开始做数字设计之后回头调过的那些“仿真怎么都不对”的bug里一大半的根源都落在verilog里最不起眼的case语句上。case语句看起来不就是if-else的简化写法嘛列几个分支给个default完事。可真到了状态机、译码器、数据通路这些场景位宽扩展的坑、x态传播的坑、综合器悄悄改逻辑的坑一个接一个往外冒。这篇文章就想把verilog中的case语句从头到尾捋清楚从最基础的语法格式和匹配规则讲起再对比case、casez、casex三兄弟的差别接着聊综合时的full_case与parallel_case这两个经典陷阱最后给几个可以直接抄的状态机、译码器实例附一份排查速查表。不管你是刚接触数字电路的学生还是已经写过几块FPGA板卡的工程师应该都能从里面找到点有用的东西。1. case语句为什么是数字设计里的“骨架”1.1 用switch做类比先理解case的分支语义学过C语言的人看到case第一反应就是switch-case这个直觉不算错但要小心verilog的case和C语言的switch差别非常大。C语言的switch本质是一条一条条件跳转指令执行流程是一条线走到底的而verilog的case描述的是硬件逻辑它更像一张“真值表”给定一个输入表达式去和每个分支项做匹配匹配上了就把对应的赋值或者操作“接通”。这张真值表的本质决定了一件事case语句在综合后大概率会变成一个多路选择器或者译码器结构而不是一堆跳转指令。所以写case的时候脑子里要时刻想着“我在画电路”不是在写软件。我在很多代码评审里看到新手把case当if-else的“语法糖”来用一个状态机里嵌套四层case每个分支里还挂一堆if-else最后综合出来的逻辑一团乱麻时序也难收敛。倒不是说嵌套绝对不行而是你应该先意识到一个原则case最适合的是“一个表达式多个并列的取值分支”这种场景比如操作码译码、地址段选择、状态机跳转。如果分支条件是多个信号的组合、带有复杂的优先级关系那才轮到if-else链上场。1.2 典型应用场景译码器、多路选择器、状态机case在实际工程里的三个高频场景我按出现频率排一下。第一是译码器。地址译码、LCD段码译码、按键扫描的键值译码都离不开case。这类逻辑的特点就是输入一个编码值输出一套对应的比特组合分支之间天然互斥逻辑上没有任何重叠可能。第二是多路选择器。数据通路上经常要根据控制信号选不同的数据来源比如从寄存器堆里选源操作数、从总线协议里选数据通道。case写出来的MUX结构清晰可读性比一连串三元运算符好得多。第三是状态机。这是case最大、也最容易写崩的战场。状态机的次态逻辑和输出逻辑用case来组织几乎是行业默认做法。一个三段式状态机里case负责按当前状态给出下一状态、按当前状态输出控制信号。这里最容易出的问题有两个状态编码不写parameter直接用裸数字、漏了default导致没覆盖的状态跑到未知区域。这两个坑后面会展开。顺带说一句case和if-else的选用我个人的标准很简单当分支条件是对同一个表达式的不同取值做判断时用case当分支条件是多个不同变量、或者有明显的优先级要求时用if-else。这不仅是风格问题综合出来的电路结构差异也很大。if-else天然有优先级会综合成串联的比较器链case分支之间是平级的综合成并联的MUX结构对时序更友好。2. case、casez、casex的语法细节与匹配规则2.1 标准case的基本语法格式先看一段最基础的写法case (表达式) 常量1: 语句; 常量2: 语句; default: 语句; endcase表达式的位宽决定了整个case的位宽上下文。每个分支项都必须是常量表达式可以是数字、parameter、localparam但必须是编译期就能确定的值。如果某个分支下要执行多条语句必须用begin...end包起来否则只有第一条语句属于这个分支。这个细节我在很多新手代码里见到过一写多语句就忘了begin end结果综合器报错报得莫名其妙。每次写case我都会强制自己把default写上哪怕所有分支已经覆盖了所有取值。并不是因为default一定必要——如果case表达式是2位四个分支写全了组合逻辑也不会有锁存器问题——但default是一个安全网。它能防止后续别人改代码时新增一个取值没覆盖也能明确告诉阅读者设计者考虑过这个情况。而且仿真时default往往会救你一命输入意外出现x态或未定义状态时有default会让逻辑落入明确行为而不是悬空。2.2 case的匹配规则是“严格相等”这是case很容易被误解的地方。verilog的case做比较时使用的是“恒等比较”等价于全等比较运算符。也就是说比较时不仅看0和1还要看x和z只有每一位都完全相同才算匹配。举个例子reg [1:0] sel; case (sel) 2b00: y 0; 2b1x: y 1; default: y 2; endcase如果sel的值是2b1x它就会命中分支2b1x而不是走default。这跟普通的比较完全不同普通比较中x和任何值都不相等比较结果也是x。理解了这一点你就能解释很多诡异的仿真现象。另外必须注意位宽上下文。case表达式的位宽是多少每个分支常量就会按这个位宽扩展。我见过一个真实案例bus_width是8位case里写4hf想匹配值0x0f这没问题但有人以为4hf能匹配高4位为0、低4位为f的情况。其实4hf扩展成8位是8h0f如果你想让8位全f命中必须写8hff。这类位宽不匹配导致的“分支永远不命中”是仿真阶段最隐蔽的错误之一因为综合工具通常不会为这种写法报错。2.3 casez与casex通配符带来的便利和风险casez和casex是case的两个变种专门处理带无关位的匹配。casez把z当作“不关心”的位任何值都能匹配。在代码里也常用问号?来表示不关心的位因为?在数字字面量里就代表高阻z的写法。比如casez (sel) 4b1???: grant 0; 4b01??: grant 1; 4b001?: grant 2; 4b0001: grant 3; default: grant 3b111; endcase这一段描述的是典型的优先级仲裁逻辑sel的高位越早为1优先级越高。用casez写这种地址匹配、中断优先级判断的代码非常简洁比用一长串if-else漂亮得多。它的行为本质上是把分支变成带优先级的匹配链综合出来也有对应的门级结构。casex更狠把x和z都当成不关心。看起来比casez更“宽容”但实际工程里我几乎不用casex也建议你尽量别用。原因很简单casex会把x态也直接吞掉。仿真时x态本来是在提醒你“这里有问题”但casex会让逻辑假装什么都没发生一路绕过去结果就是仿真看起来一切正常到了实际芯片或者在更严格的后仿真里问题突然爆发而且极难定位。用术语说casex掩盖了x态传播让“不该被忽略的问题”被静默忽略了。如果一定要用通配符casez是相对安全的选择因为它只忽略z位。但casez也一样要慎用后面第5部分我会讲一个真实翻车案例。2.4 default到底是不是必须的不管网上怎么争论我的结论是组合逻辑里必须写default时序逻辑里也建议写default。为什么组合逻辑里这么强调因为缺了default很容易综合出锁存器。锁存器意味着你的组合逻辑在case分支覆盖不到时维持旧值这不是你想要的“未知时输出0”或者“未知时复位到某个安全值”而是把之前的值锁住了。时序逻辑中default的作用更多是防止状态机跑飞。状态变量如果用了2位四个状态理论上都能覆盖但别忘了复位后的初始状态、外部干扰导致的亚稳态都有可能让状态变量落到非预期编码。没有default状态机就会在“谁都没匹配”的地方空转。我的习惯是状态机的次态逻辑里default直接回到IDLE输出逻辑里default给所有输出赋一个确定的初值这样状态机永远不会卡死在未知状态。3. 从RTL到综合full_case与parallel_case的经典坑3.1 综合器眼中的case是什么这一段是很多人写了几年代码也没真正搞懂的地方。逻辑仿真器把case当成行为模型来跑按顺序匹配第一个命中的分支但综合器不是这个思路它要把case“翻译”成真实电路。标准case在不加任何约束的情况下综合器会默认分支之间可能有重叠也默认case没有覆盖所有可能的输入组合。因此它会为每个分支生成独立的使能条件再用优先级或互斥逻辑组合起来对于没有default的情况它还会推断出锁存器。这些设计是保守的目的是保证综合结果在任何输入组合下都和仿真行为一致。但保守带来的代价就是面积和延迟。如果你能告诉综合器“这些分支绝对互斥”或者“所有输入组合都覆盖了”它就能生成更精简的MUX结构去掉多余的保护逻辑。这就是parallel_case和full_case注释的由来。3.2 parallel_case声明分支互斥但要小心后果在case关键字后面加一行综合注释比如case (sel) // synthesis parallel_case 2b00: data a; 2b01: data b; 2b10: data c; default: data d; endcase这个注释的意思是我保证sel的各个取值互不重叠综合器可以放心地生成并行MUX不用为重叠情况生成额外的优先级判断逻辑。它能省掉一些优先级比较器时序和面积都可能变好。问题是这个“保证”是你在口头保证综合器不会去验证。如果你的分支真的存在重叠可能加了parallel_case之后综合出来的电路在某些输入下会输出一个错误的结果而这个错误在仿真阶段根本看不出来。因为仿真器依然按顺序匹配第一个分支而综合器基于互斥假设生成了并行逻辑两者行为就分叉了。这种“仿真正确、上板错误”的bug最难查。所以我的建议是除非你非常确定分支绝对互斥否则不要随手加parallel_case。需要对性能较真时先用综合报告看看到底哪里因为优先级结构成了关键路径再针对性加不要满代码敲注释。3.3 full_case声明全覆盖也要付出信任成本再看另一个注释case (sel) // synthesis full_case 2b00: data a; 2b01: data b; 2b10: data c; default: data d; endcasefull_case告诉综合器所有可能的输入取值我都列出来了没列到的情况不会出现你可以把未列出的情况当don‘t care处理。加了它综合器就不会因为“缺少分支覆盖”而推断锁存器可以生成更精简的逻辑。表面上看它像一个“不用写default也能避免锁存器”的捷径。但这里有一个极大的隐患。full_case注释让综合器把未列出的输入当成无关项可能优化掉一部分逻辑让电路行为在真实世界出现未列输入时变得不可预测。仿真阶段呢仿真器依然老老实实地按case规则执行没有分支匹配就走default或维持原值。于是再次出现仿真和综合不一致的情况。我的态度很简单与其用full_case注释去省一个default不如老老实实把default写上。default写完整了full_case带来的电路优化收益远远没有你想象的那么大但风险却实打实高了一个数量级。代码是给人读的也是给机器综合的清晰和安全永远是第一位。4. 实战案例三段式状态机与ALU译码器4.1 状态机里的case怎么写才算写得对状态机是case的经典战场直接上代码看一段我常用的三段式写法module fsm_example ( input wire clk, input wire rst_n, input wire start, output reg done ); localparam IDLE 2d0, RUN 2d1, DONE 2d2; reg [1:0] state; reg [1:0] next; // 第一段时序逻辑状态更新 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next; end // 第二段组合逻辑次态计算 always (*) begin next state; case (state) IDLE: begin if (start) next RUN; end RUN: begin next DONE; end DONE: begin next IDLE; end default: next IDLE; endcase end // 第三段组合逻辑输出计算 always (*) begin done 1b0; case (state) RUN: done 1b1; DONE: done 1b1; default: done 1b0; endcase end endmodule这里有几个细节值得展开。次态计算中我习惯先把next state写在case之前再在分支里覆盖。这样做的目的很直白绝大多数状态下状态机是不跳转的。有了这个默认赋值每个分支只需要描述“退出条件”省去在每一分支里重复写“否则保持原状态”的逻辑代码行数锐减可读性也高。输出计算中我把done 1b0写在case前面同样是为了让未命中的分支自动获得一个安全输出。这个技巧避免了在每个default里重复写一堆输出赋值也避免因为分支漏写某个输出导致仿真时出现锁存器行为。组合逻辑里赋初值再分支覆盖是彻底杜绝意外锁存器最有效的手段。还有一点状态编码一定用localparam不要用define。define是全局宏会在整个编译单元里生效很容易撞名污染localparam作用范围只在模块内部综合和仿真行为一致。想扩展状态时改localparam一处就行。4.2 ALU译码器用case表达并行分支再看一个典型的ALU操作译码器module alu_decode ( input [2:0] opcode, input [7:0] a, input [7:0] b, output reg [7:0] result ); localparam ADD 3d0, SUB 3d1, AND 3d2, OR 3d3, XOR 3d4; always (*) begin case (opcode) ADD: result a b; SUB: result a - b; AND: result a b; OR : result a | b; XOR: result a ^ b; default: result 8b0; endcase end endmodule这个例子想说明两件事。第一操作码译码的各分支在语义上天然互斥opcode是3位每条取值只属于一个分支。再加上default兜底整个case的匹配行为是确定的综合器能轻松优化成纯MUX。这种代码风格下完全不需要加parallel_case注释因为综合器也能分析出互斥性硬加反而可能引入风险。第二default给了8b0。设想一下如果opcode出现3b101这种未定义取值没有default时result会保持旧值综合时就可能推断出锁存器有default时result被赋成0行为完全确定。虽然3b101在正常ALU流程里可能永远不会出现但一旦出现确定的0比未知的保持旧值好排查得多。4.3 用function封装case写出可复用的译码逻辑case除了写在always块里还能配合function使用这个技巧对复杂译码逻辑非常有用。比如七段数码管的段码译码直接在多个always块里各写一份case代码又臭又长维护起来想死。用function包起来就干净了function [6:0] seg7; input [3:0] hex; begin case (hex) 4h0: seg7 7b0111111; 4h1: seg7 7b0000110; 4h2: seg7 7b1011011; 4h3: seg7 7b1001111; 4h4: seg7 7b1100110; 4h5: seg7 7b1101101; 4h6: seg7 7b1111101; 4h7: seg7 7b0000111; 4h8: seg7 7b1111111; 4h9: seg7 7b1101111; default: seg7 7b0000000; endcase end endfunction之后在任何always块里都能直接调用seg7(some_value)不需要重新写一遍case。这比把段码表放在模块外面、用一堆assign拼逻辑清晰太多了。function里的case同样要写default这个好习惯不能因为封装就丢掉。5. 常见问题排查实录与避坑清单5.1 仿真出现x态十个里有八个是case的问题仿真波形里看到红色的x第一反应不要只查上游数据先看case。最典型的一个场景组合逻辑块里的case缺了default而case表达式的某个取值没有被任何分支覆盖仿真器最终执行“什么都没有”输出保持上一个值。这种“不是latch但像latch”的行为在波形上看起来就是一段完全不可控的输出极难解释。排查方法很直接把case表达式和所有分支项打印出来肉眼对照一遍。我在Icarus Verilog下调试时经常用$display在case块前后打印相关信号always (*) begin $display(sel%b, sel); case (sel) // ... endcase end这样仿真一跑立刻能看到sel到底出现过哪些不在分支里的取值。引入default之后x态大概率会被固定成一个确定值问题就好定位了。比较隐蔽的还有位宽问题。前面提过4hf扩展成8位变成8h0f的例子这类问题在仿真里表现为某个分支永远不命中但综合和时序仿真给出的结果又有微妙差异。建议写case时所有分支常量的位宽都和case表达式保持一致不要图省事写缩水位宽。5.2 仿真正常综合后行为不对多半是full_case或parallel_case作祟“前仿全对后仿或上板失败”是硬件工程师最头疼的难题之一。如果代码里用了full_case或parallel_case注释我建议你第一时间把它们全部临时删掉重新综合一遍试试。我遇到过不止一次加了parallel_case后因为两个分支条件在实际硬件中存在重叠综合器生成的并行MUX选出了错误的一路而前仿真的顺序匹配完全看不出来。这里有一个衡量标准综合工具的报告里如果有warning提到case不是full或者不是parallel不要大意。它说明你的case结构可能和你的假设不一致。正确的做法是先消除warning而不是用注释强行压制。另外要留意不同综合工具对同一条注释的理解不完全一样。同一份代码在这个工具里能过换一个工具可能行为就变了甚至有些工具会忽略这些注释。所以最稳妥的方案永远是把代码本身的逻辑写清楚、写完整让综合器不需要依赖注释就应该能识别出互斥和全覆盖。5.3 一个casez翻车的真实经历最后分享一个我自己的教训。有一回做总线仲裁器请求信号有4位我用casez写优先级匹配casez (req) 4b1???: grant 0; 4b01??: grant 1; 4b001?: grant 2; 4b0001: grant 3; default: grant 3b111; endcase仿真跑起来一切正常各种组合都对。结果上了板子偶尔出现仲裁结果完全乱掉的情况。查了很久最后发现请求信号在某个边界时刻出现了高阻z而casez把z当成“不关心”于是一路匹配到高优先级分支把本不该响应的请求当作最高优先级处理了。这个例子告诉我两件事。第一casez适合用来匹配地址总线和指令编码这类“确定不会出现z”的信号不适合用来匹配来自外部引脚、可能悬空或处于高阻状态的电平信号。第二如果确实需要优先级匹配外部请求更稳的写法是用一长串if-else让z态被显式判断为无效条件而不是被通配符悄悄吸收掉。从那以后我给自己定了个规矩casez只用在内部寄存器值的匹配上外部输入一律先做同步和合法性检查再进分支结构。5.4 case调试速查表下面这份速查表是我做代码评审和自查时常用的对照清单整理成表格方便随时翻。现象可能原因排查方向组合逻辑出现锁存器case缺default或分支覆盖不全补齐所有分支给未覆盖值赋确定初值仿真出现x态且输出悬空分支未命中且无default打印case表达式取值分析非法输入来源某分支永远不命中分支常量位宽与case表达式不一致统一位宽直接用完整位宽常量仿真通过综合/上板错误full_case或parallel_case注释与实际情况不符先删除注释完善代码逻辑后再综合外部信号出现z导致误匹配casez把z当不关心改用if-else显式判断或先同步过滤外部输入状态机跑飞state未覆盖全部编码或缺失复位次态逻辑default回到IDLE复位状态显式赋值输出多个分支都有赋值代码冗长case分支重复写默认输出在case前赋默认值分支只写差异项最后顺手放几个我一直遵守的规则写case之前先列出这个表达式的全部合法取值再决定每个取值输出什么default永远写哪怕只是在里面赋一个常数casez只在内部信号匹配时使用不滥用full_case和parallel_case注释。这些事情单独拎出来都很小但叠加在一起能省掉后面大量调试时间。我这个人的习惯是拿到一段新的RTL代码第一件事就是把所有的case语句找出来通读一遍先不看分支逻辑对不对就专门检查位宽、default、分支重叠这三个点。做完这一轮比跑十轮仿真发现的问题还要多。你能看到这里说明你对verilog的case大概率不只是想要一句“语法说明”而是真的想把它用得明白、用得稳。那就从手头最近的代码开始按上面的清单过一遍应该会有收获。
返回列表