Verilog手撕代码:从数字“1”到电路直觉的构建
1. 从“1”开始为什么Verilog手撕代码是数字IC的基石最近在带新人发现一个挺有意思的现象很多刚接触数字电路设计的朋友一上来就想搞懂复杂的CNN加速器或者DDR控制器但往往在写一个最简单的计数器时逻辑就乱了套。这让我想起自己刚入行时导师丢给我的第一个任务就是“用Verilog实现一个‘1’”。当时觉得这太简单了甚至有点被轻视的感觉但真正动手才发现这个看似简单的“1”背后藏着从组合逻辑到时序逻辑从行为描述到可综合代码的整个思维转变过程。今天我就围绕“关于‘1’的Verilog相关手撕代码”这个主题和大家深入聊聊那些面试和工作中高频出现的基础代码块。这些代码是构建一切复杂系统的砖瓦手撕它们不是为了炫技而是为了建立最扎实的电路直觉。无论你是正在准备秋招的应届生还是希望夯实基础的工程师这篇文章都会带你从“是什么”深入到“为什么”并分享我踩过的坑和总结的技巧。2. 核心逻辑拆解从行为描述到电路映射2.1 “1”的多种面孔常量、向量与参数化在Verilog里一个孤零零的“1”可以代表很多东西。最直接的就是一个单比特的逻辑高电平1‘b1。但更多时候我们需要的是一个特定宽度的向量比如一个4位的常数1写作4’d1或4‘b0001。这里第一个坑就来了直接写1在赋值给一个多位寄存器时会被解释为32’d1因为Verilog中未指定位宽的整数默认为32位这可能导致意想不到的位宽扩展和资源浪费。注意在 always 块内对 reg 型变量赋值时务必保证赋值符号右侧的位宽与左侧匹配否则综合工具会进行隐式转换可能产生你不期望的比较器或截断逻辑。更深一层“1”可以是一个参数parameter或局部参数localparam。例如定义一个状态机的IDLE状态localparam S_IDLE 1‘b0, S_WORK 1’b1;。这里“1”作为状态编码。更进一步在循环生成generate for或数组初始化中“1”可以作为索引的起始或步进值。理解“1”在不同上下文中的具体含义是写出严谨代码的第一步。我见过有人因为状态编码用了2‘b01和2’b10结果在状态判断时用了if(state 1‘b1)导致永远无法进入第二个状态调试了半天。2.2 组合逻辑中的“1”比较、选择与运算在组合逻辑中“1”最常作为比较的基准或运算的操作数。例如判断一个信号是否有效assign valid_flag (data_in ! 8‘b0);。这里不等于0本质上就是在判断数据中是否有“1”。再比如一个多路选择器MUX其选择信号sel为1时输出a为0时输出bassign out sel ? a : b;。这个三元运算符映射到硬件上就是一个二选一MUX。一个更典型的“手撕代码”题目是奇偶校验位生成。要求根据输入的8位数据计算其奇校验位使得9位数据中“1”的个数为奇数。很多新手会写一个for循环来计算但for循环在可综合代码中会被展开可能存在时序问题。更好的做法是使用缩减异或运算符^assign parity_bit ^data_in; // 偶校验位 assign odd_parity_bit ~(^data_in); // 奇校验位这行代码综合出来就是一个多输入的异或门/同或门树非常简洁高效。这里的关键是理解运算符的位宽^data_in是对所有位进行异或结果是一个单比特这就是“1”的另一种产生方式。2.3 时序逻辑中的“1”计数器、状态机与使能信号时序逻辑是Verilog的核心也是面试手撕代码的重灾区。这里的“1”常常代表一个时钟周期的脉冲或一个稳定的使能信号。最经典的莫过于计数器。实现一个从0计数到9然后清零的十进制计数器是必考题。核心代码通常如下always (posedge clk or negedge rst_n) begin if(!rst_n) begin cnt 4‘d0; end else if (cnt_en) begin // cnt_en是一个使能信号为“1”时计数 if (cnt 4’d9) begin cnt 4‘d0; end else begin cnt cnt 4’d1; // 这里的“1”是加法操作数 end end end这里有两个“1”cnt_en信号为“1”表示使能4‘d1是计数步长。我踩过的坑是曾经把cnt 4’d9写成cnt 4‘d9导致计数器在9状态只停留了一个周期不符合设计需求。另一个细节是如果使能信号cnt_en不是周期性的上述写法可能会错过使能这时需要更复杂的控制逻辑。另一个离不开“1”的是有限状态机FSM。三段式状态机是推荐写法。其中状态转移条件往往就是某个或某几个信号为“1”。例如// 状态转移逻辑第二段组合逻辑 always (*) begin next_state IDLE; case (current_state) IDLE: if (start 1‘b1) next_state WORK; WORK: if (done 1’b1) next_state IDLE; default: next_state IDLE; endcase end这里的1‘b1就是触发状态转移的“钥匙”。务必注意状态转移条件要互斥且完备否则会生成锁存器Latch这是设计大忌。我曾经因为在一个状态下的if-else没有写全缺少default分支综合后出现了难以调试的Latch导致芯片功能异常。3. 高频手撕代码场景深度剖析3.1 时钟分频与使能脉冲生成生成比系统时钟慢的时钟信号或者产生周期性的使能脉冲是基础中的基础。奇数分频如3分频、5分频比偶数分频更常考因为它需要双边沿操作或产生相位差。奇数分频以3分频为例一种经典方法是产生两个占空比非50%的时钟然后相或。reg [1:0] cnt; reg clk1, clk2; // 计数器 always (posedge clk or negedge rst_n) begin if(!rst_n) cnt 2‘d0; else cnt (cnt 2’d2) ? 2‘d0 : cnt 2’d1; end // 上升沿触发生成的时钟 always (posedge clk or negedge rst_n) begin if(!rst_n) clk1 1‘b0; else if (cnt 2’d0) clk1 ~clk1; end // 下降沿触发生成的时钟关键 always (negedge clk or negedge rst_n) begin if(!rst_n) clk2 1‘b0; else if (cnt 2’d1) clk2 ~clk2; end assign clk_div3 clk1 | clk2;这段代码的精髓在于利用了下降沿来生成第二个时钟clk2两者相位错开相或后得到占空比接近50%的3分频时钟。面试时如果只写出计数器而想不到双边沿的方法就说明对时钟的理解还不够深入。更工程化的做法是生成使能脉冲而不是直接生成时钟。因为直接生成时钟Generated Clock会带来时钟域、时钟偏斜Skew等复杂问题。推荐使用使能信号reg [1:0] cnt; reg div_en; // 分频使能信号每3个周期产生一个高脉冲 always (posedge clk or negedge rst_n) begin if(!rst_n) begin cnt 2‘d0; div_en 1’b0; end else begin if (cnt 2‘d2) begin cnt 2’d0; div_en 1‘b1; // 产生一个周期的高脉冲 end else begin cnt cnt 2’d1; div_en 1‘b0; end end end // 后续模块在 div_en 为高时进行操作 always (posedge clk or negedge rst_n) begin if(!rst_n) data_out ‘d0; else if (div_en) begin // 使用使能信号而非新时钟 data_out data_in; end end这种方法将所有逻辑都统一在原始时钟域下更安全可靠。3.2 边沿检测与脉冲同步检测一个异步信号的上升沿并将其转换为一个时钟周期宽度的同步脉冲是跨时钟域处理CDC的入门课也是高频面试题。经典的边沿检测电路reg sig_dly; wire pos_edge; always (posedge clk or negedge rst_n) begin if(!rst_n) sig_dly 1‘b0; else sig_dly async_sig; end assign pos_edge (~sig_dly) async_sig; // 组合逻辑检测上升沿这段代码将异步信号async_sig用本地时钟clk打一拍得到sig_dly然后通过组合逻辑判断当前拍采到的是‘1’而上一拍是‘0’从而检测到上升沿。但这里有个巨坑async_sig可能相对于clk是亚稳态的直接将其接入组合逻辑亚稳态可能传播导致pos_edge产生毛刺或错误脉冲。正确的做法是采用两级或多级寄存器同步后再检测边沿reg [2:0] sig_sync; always (posedge clk or negedge rst_n) begin if(!rst_n) sig_sync 3‘b000; else sig_sync {sig_sync[1:0], async_sig}; // 三级同步 end wire sig_stable sig_sync[2]; // 同步后的稳定信号 reg sig_stable_dly; wire pos_edge_pulse; always (posedge clk or negedge rst_n) begin if(!rst_n) sig_stable_dly 1’b0; else sig_stable_dly sig_stable; end assign pos_edge_pulse (~sig_stable_dly) sig_stable; // 对稳定信号检测边沿这里用了三级触发器进行同步极大降低了亚稳态传播到后续逻辑的概率。检测边沿是在完全同步后的信号sig_stable上进行的这样产生的脉冲pos_edge_pulse才是干净可靠的。记住对于任何来自异步时钟域的信号都必须先同步再使用。3.3 序列检测器与滑动窗口序列检测器是状态机和移位寄存器结合的典型应用。例如检测输入数据流中是否出现连续序列 “1011”。使用状态机实现定义状态代表已匹配到的序列前缀。这种方法逻辑清晰但状态数随序列长度指数增长最坏情况。localparam S0 3‘d0, S1 3’d1, S2 3‘d2, S3 3’d3, S4 3‘d4; reg [2:0] state, next_state; reg det_out; // 状态转移 always (*) begin next_state S0; case(state) S0: next_state (data_in 1’b1) ? S1 : S0; S1: next_state (data_in 1‘b0) ? S2 : S1; S2: next_state (data_in 1’b1) ? S3 : S0; S3: next_state (data_in 1‘b1) ? S4 : S1; // 注意匹配到“101”后再来一个“1”才成功 S4: next_state (data_in 1’b1) ? S1 : S2; // 成功后的状态转移考虑重叠序列“1011 011” default: next_state S0; endcase end // 输出逻辑当状态为S4时输出检测成功脉冲 always (posedge clk or negedge rst_n) begin if(!rst_n) det_out 1‘b0; else det_out (next_state S4); end这里的关键是状态S4的定义和成功后的转移。如果序列不允许重叠即检测到“1011”后下一个比特从新序列开始那么S4的次态应为S0。如果允许重叠即“1011011”被认为包含两个“1011”则次态应为S1因为末尾的“1”可以作为下一个序列的开头。面试官常通过这个问题考察你对序列重叠情况的理解。使用移位寄存器实现这种方法更直观将输入数据移位然后直接比较寄存器值。reg [3:0] shift_reg; always (posedge clk or negedge rst_n) begin if(!rst_n) shift_reg 4‘b0; else shift_reg {shift_reg[2:0], data_in}; end assign det_out (shift_reg 4’b1011);这种方法简单粗暴但不够灵活且当序列很长时比较器会很大。通常短序列用移位寄存器长序列或复杂规则用状态机。3.4 异步FIFO的深度计算与指针设计虽然完整手撕一个异步FIFO代码量较大但面试中经常要求你计算FIFO的最小深度并解释读写指针的同步原理。这是考察对跨时钟域和时序约束理解深度的绝佳题目。FIFO深度计算假设写时钟频率f_w 100MHz写突发长度B 120读时钟频率f_r 50MHz。问传输这120个数据FIFO需要多深写120个数据所需时间T_write B / f_w 120 / 100e6 1200 ns。在这1200 ns内读侧能读走的数据量N_read f_r * T_write 50e6 * 1200e-9 60。因此最大缓存数据量 B - N_read 120 - 60 60。考虑最坏情况FIFO深度至少需要60。但通常为了安全会取2的整数次幂如64并留有一定余量。格雷码指针与同步异步FIFO的核心是使用格雷码Gray Code作为读写指针。格雷码相邻数值间只有一位变化当指针被同步到另一个时钟域时即使发生亚稳态指针值也只会跳变到相邻值不会出现从“0111”跳变到“1000”二进制这种多位同时跳变的灾难性错误。// 二进制转格雷码 function [ADDR_WIDTH-1:0] bin2gray; input [ADDR_WIDTH-1:0] bin; begin bin2gray bin ^ (bin 1); end endfunction // 写指针逻辑示例 always (posedge wclk or negedge wrst_n) begin if(!wrst_n) begin wptr_bin ‘d0; wptr_gray ‘d0; end else if (winc !wfull) begin wptr_bin wptr_bin 1‘b1; wptr_gray bin2gray(wptr_bin 1’b1); // 注意是对加1后的二进制数转换 end end // 将写指针格雷码同步到读时钟域打两拍 always (posedge rclk or negedge rrst_n) begin if(!rrst_n) {wptr_gray_sync_r, wptr_gray_sync} ‘d0; else {wptr_gray_sync_r, wptr_gray_sync} {wptr_gray_sync, wptr_gray}; end这里的关键细节是wptr_gray必须由wptr_bin 1‘b1转换而来而不是由wptr_bin转换后再加1。因为格雷码的加法没有直接定义。空满判断是通过比较同步后的格雷码指针来进行的虽然同步导致指针“过时”但格雷码的特性保证了这不会引起误判在深度为2的幂次方时。4. 工程实践中的技巧与避坑指南4.1 代码风格与可综合约束手写代码不仅要功能对还要整洁、可维护、可综合。一些铁律时序逻辑一律用非阻塞赋值组合逻辑一律用阻塞赋值。这是避免仿真与综合结果不一致的首要原则。我见过有人在一个always (*)块里混用两种赋值导致仿真通过了综合后的网表功能完全错误。避免使用初始化语句。reg [3:0] cnt 4‘d0;这种写法在仿真中有效但大部分综合工具会忽略或者将其解释为上电复位值与实际的复位信号可能冲突。可靠的初始化必须通过复位逻辑实现。小心锁存器Latch。在组合逻辑的always块中如果if或case语句没有写全所有分支或者对某些输入变化不敏感就会综合出锁存器。锁存器对毛刺敏感静态时序分析困难应尽量避免。确保组合逻辑的always块中所有输入信号的任意变化都能导致输出被明确赋值。参数化设计。使用parameter来定义位宽、深度等提高代码复用性。例如parameter DATA_WIDTH 8;然后reg [DATA_WIDTH-1:0] data;。4.2 仿真调试与Testbench编写代码写出来能通过仿真才算成功了一半。一个良好的测试平台Testbench至关重要。时钟与复位生成这是Testbench的骨架。initial begin clk 1‘b0; forever #5 clk ~clk; // 10ns周期100MHz时钟 end initial begin rst_n 1’b0; #100 rst_n 1‘b1; // 复位100ns后释放 end激励生成避免使用绝对延时使用(posedge clk)等事件控制使激励与时钟同步。initial begin data_in 8‘d0; valid 1’b0; (negedge rst_n); // 等待复位结束 repeat(10) (posedge clk); // 空等10个周期 for(int i0; i100; i) begin (posedge clk); valid 1‘b1; data_in $urandom_range(0, 255); // 生成随机数 end (posedge clk); valid 1’b0; end自动检查不要只用肉眼看波形。在Testbench中加入断言assertion或自动比较逻辑。always (posedge clk) begin if (valid ready) begin expected_data (data_in * 2); // 假设DUT功能是乘2 if (data_out ! expected_data) begin $error(“Mismatch at time %t: in%h, out%h, exp%h”, $time, data_in, data_out, expected_data); end end end4.3 综合与静态时序分析STA意识写代码时就要想着电路想着时序。关键路径组合逻辑过长会导致建立时间Setup Time违例。例如一个大的优先级选择器if-else if 链或复杂的算术运算如乘法、除法。解决方法插入寄存器进行流水线切割。保持时间通常由时钟偏斜和短路径引起。确保信号从发送触发器到接收触发器之间的延迟不能太短。在FPGA中工具通常能较好地处理在ASIC中需要仔细约束。扇出过大一个信号驱动太多负载会导致延迟增加、斜率变差。可以通过插入缓冲器Buffer或复制寄存器Register Duplication来解决。使用generate for进行规整结构描述当需要实例化多个相同模块时generate for比手动复制粘贴更安全、更易维护。genvar i; generate for (i0; i8; ii1) begin: BIT_SLICE my_module u_my_module ( .clk(clk), .data_in(data_bus[i]), .data_out(result_bus[i]) ); end endgenerate从“1”出发我们遍历了常量定义、组合与时序逻辑、分频、边沿检测、序列检测、异步FIFO核心概念等数字逻辑设计的核心关卡。手撕代码的本质不是背诵而是理解每一个赋值、每一个运算符、每一个always块所对应的实际电路。当你看到一行代码脑海中能立刻浮现出对应的门电路、触发器和连线时你就真正入门了。最后分享一个我自己的习惯写完任何一段代码尤其是状态机我都会画一个简单的状态转移图哪怕只是草稿。这能帮你一眼看出逻辑是否完备转移条件是否互斥很多时候比仿真更快地发现设计缺陷。

相关新闻

最新新闻

日新闻

周新闻

月新闻