☰
FPGA上板必备:Verilog HDL PoC模块库与仿真脚手架
2026/10/26 3:16:27 网站建设 项目流程

简介:面向数字逻辑与FPGA初学者的Verilog HDL概念验证工程包,围绕双向端口、三态门及顶层CPU波形验证展开,适合用来理解inout端口驱动、使能逻辑与总线共享的初步实现。压缩包共77个文件,约215KB,以Verilog源码、Quartus工程配置(qpf/qsf)、引脚与层次信息、仿真波形(vwf/cvwf)以及综合、布局布线、映射和仿真报告与数据库中间文件为主,可据此还原工程结构、查看约束与仿真结果。已有463人学习下载。读者可从中获得一个可打开复现的POC工程样例,结合报告和波形理解顶层测试、端口连接与综合流程,并借鉴模块划分和验证思路,用于课程实验或小型数字系统设计的前期验证。

1. 板子上电后数据不动:这份 Verilog HDL PoC 代码包先解决什么问题

板子上电,时钟脚上有波形,LED 常亮不闪,串口助手一个字都收不到——很多人第一次把 Verilog 代码烧进 FPGA,遇到的就是这个场面。这份 Verilog HDL PoC 代码包针对的正是这类启动阶段的问题:分频、按键消抖、PWM、同步与异步 FIFO、UART 收发、SPI 主机、两级同步器,一个模块一个目录,每个都配了能自检的 testbench 和一条可直接执行的仿真命令。它不教语法,定位是「能跑、能改、能对着波形抠细节」的示例工程。刚学 verilogHDL、分不清阻塞与非阻塞的读者,可以照着跑一遍建立直觉;已经在写 RTL 的工程师,可以把它当成新项目起步时的模块库和验证脚手架。

2. 目录结构与模块分层:四层划分和一套固定四件套怎么组织

拿到压缩包先别急着点开某个.v文件,先看目录。这套 PoC 的分层逻辑是按职责切分,不按编写者切分,目的是让改动发生在局部:改 UART 的波特率,不需要打开 FIFO;读 FIFO 的人,不会被 SPI 状态机干扰。分层清楚之后,例化关系才是有向的,综合工具报的层级路径也才看得懂。

2.1 目录树:设计、激励、脚本、约束四层分开

先看目录骨架,这套结构在多数中小规模 RTL 工程里都通用,可以直接照搬到自己的工程上:

verilog-poc/ ├── rtl/ # 可综合设计代码,按功能域分层 │ ├── common/ # 基础设施:同步器、复位同步、边沿检测 │ │ ├── sync_ff2.v │ │ ├── rst_sync.v │ │ └── edge_det.v │ ├── basic/ # 能直接观察效果的模块:分频、消抖、PWM │ │ ├── clk_div.v │ │ ├── debounce.v │ │ └── pwm_gen.v │ ├── mem/ # 存储类:同步 FIFO、异步 FIFO、双口 RAM │ │ ├── sync_fifo.v │ │ └── async_fifo.v │ └── io/ # 外设接口:UART、SPI │ ├── uart_tx.v │ ├── uart_rx.v │ └── spi_master.v ├── tb/ # 与 rtl 一一对应的 testbench ├── sim/ # 编译脚本、波形输出、回归入口 ├── constr/ # 引脚与时序约束,按板卡分目录 └── doc/ # 每模块一页:端口表、时序说明、已知限制

common是最底层,只依赖clk、rst_n和它自己,不引用任何上层模块;mem和io可以例化common,但彼此之间不互相调用;basic保持独立,方便单独上板点灯验证。这条依赖方向一旦反过来,比如让sync_ff2去例化uart_rx做边沿检测,例化树就会打结,后面想把这套模块搬到别的工程里时,只能连根拔起。

sim/目录单独存在的原因很实际:很多人把 testbench 和设计代码放同一层,编译脚本里写一长串文件名,换个目录就找不到文件。这套 PoC 里编译脚本统一用相对工程根目录的路径,从任何位置执行make -C sim都能跑。constr/按板卡分目录存放,是因为引脚约束跟设计无关,只跟硬件有关,混进rtl/会造成「换块板子要改设计文件」的错觉。

2.2 四件套:每个模块都必须凑齐的东西

一个模块进代码库,只交一个.v文件是不够的,那只是半成品。我一般要求每个模块至少凑齐四样东西:

文件位置作用缺了会怎样
x.vrtl/<域>/可综合的设计代码无法综合上板
tb_x.vtb/带自检的激励改完不知道对错
run_x.shsim/一键重跑仿真换台机器就跑不起来
x.mddoc/端口表、时序图、限制三个月后自己看不懂

doc/x.md最容易被跳过,也最值钱。里面固定写三段:端口表(方向、位宽、时钟域、复位极性)、时序约定(比如tx_start只需要单周期脉冲,rd_en与rd_data之间是否有寄存延迟)、已知限制(比如「本模块不支持背靠背传输,两次启动之间至少隔 2 个时钟」)。这三段把模块的接口约定固定下来,后面在别的工程里例化它时,不用回头翻代码猜哪根线是脉冲、哪根线是电平。

命名统一用前缀_功能的方式:sync_fifo、async_fifo、uart_tx、spi_master。避免出现fifo1.v、fifo_new.v、fifo_final.v这种在仓库里越堆越多的命名。文件名与模块名必须一致,综合工具和 lint 工具都靠这条约定定位顶层。

2.3 参数与宏:位宽、深度这些数字只写一次

数字散落在各个模块里,是返工的主要来源。这套 PoC 把常量分成两类:板级事实进头文件,模块级可调项进parameter。

// rtl/common/poc_defs.vh `ifndef POC_DEFS_VH `define POC_DEFS_VH // 只放"整块板子必须一致"的常量,改一处全工程重编译 `define POC_SYS_CLK_HZ 50_000_000 `define POC_UART_BAUD 115200 `endif

模块内部的位宽、深度、分频系数一律用parameter,例化时可覆盖:

module sync_fifo #( parameter DATA_W = 8, // 数据位宽 parameter DEPTH = 16 // 深度,允许非 2 的幂,见 3.1 的说明 )( // 端口略 ); localparam AW = $clog2(DEPTH); // 地址位宽由工具算,不手写 endmodule

区别在于:define是编译期文本替换,全局生效,改完全工程重新编译一遍;parameter是模块级常量,例化时点对点覆盖,综合后同样是常量,不占资源。我一般只把系统时钟频率、目标波特率这类「换个板子就变、且所有模块都得知道」的值放进.vh,其余全部用parameter。手写AW = 4是常见的隐患:深度改成 20 之后地址位宽还是 4,综合不报错,仿真跑到第 17 个数据时才出错,波形上看是数据被覆盖了。

提示:$clog2(16)返回 4,$clog2(17)返回 5,但对深度 10 这种非 2 的幂,地址位宽是 4,指针回绕的次数就不整齐了,满判断必须换写法。这一点在下一节展开。

3. 核心模块拆解:FIFO 指针、UART 采样与两级同步器的写法

这一章挑三个最有代表性的模块。选它们的理由不是「经典」,而是这三个模块几乎覆盖了新手会踩的核心问题:指针与满空判断考的是位宽和时序直觉,UART 考的是计数与状态机,跨时钟域考的是对亚稳态的正确理解。把这三个读透,剩下的消抖、PWM、SPI 都是同一套思路的变形。

3.1 同步 FIFO:多一位指针解决满空判断

同步 FIFO 读写都在同一个时钟域,难点不在数据存储,而在「满」和「空」这两个标志怎么区分——读写指针相等时,既可能是空,也可能是满。业内最省事的做法是指针多开一位:

module sync_fifo #( parameter DATA_W = 8, parameter DEPTH = 16 )( input wire clk, input wire rst_n, input wire wr_en, input wire [DATA_W-1:0] wr_data, input wire rd_en, output reg [DATA_W-1:0] rd_data, output wire full, output wire empty ); localparam AW = $clog2(DEPTH); // 地址位宽 reg [DATA_W-1:0] mem [0:DEPTH-1]; reg [AW:0] wr_ptr, rd_ptr; // 关键:多一位用于区分满/空 assign empty = (wr_ptr == rd_ptr); // 高位不同、低位相同 => 写指针比读指针多绕了一圈 => 满 assign full = (wr_ptr[AW] != rd_ptr[AW]) && (wr_ptr[AW-1:0] == rd_ptr[AW-1:0]); // 写侧:非阻塞赋值,满时忽略写入,避免覆盖未读数据 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin wr_ptr <= {(AW+1){1'b0}}; end else if (wr_en && !full) begin mem[wr_ptr[AW-1:0]] <= wr_data; wr_ptr <= wr_ptr + 1'b1; end end // 读侧:先出数再挪指针,rd_en 与 rd_data 同拍有效 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rd_ptr <= {(AW+1){1'b0}}; rd_data <= {DATA_W{1'b0}}; end else if (rd_en && !empty) begin rd_data <= mem[rd_ptr[AW-1:0]]; rd_ptr <= rd_ptr + 1'b1; end end endmodule

逐段看。wr_ptr和rd_ptr定义了AW+1位,多出来的最高位不当地址用,只当「圈数计数器」。空判断是两指针完全相等,这个和普通写法一样。满判断拆成两个条件:最高位不同说明写指针比读指针多绕了一圈,低位相同说明两者指向同一个物理地址,加起来就是「写满了一整圈」。用这种方法,深度是 2 的幂时完全正确,代码也短。

第二个要注意的是读写冲突时的赋值方式。这里全部用非阻塞<=,并且在写入条件里加了!full、读出条件里加了!empty,原因是 FIFO 不该在满的时候悄悄丢数据、也不该在空的时候吐出上一次的残值。如果你想做「写满覆盖最旧数据」的环形缓冲,把这个判断去掉即可,但那是另一种语义,接口文档里必须写明。

第三个是rd_data的输出时序。这段代码是「读使能同拍出数」,因为mem是 reg 数组,综合后一般是分布式 RAM 或块 RAM,读操作本身有一拍延迟,所以代码里的非阻塞赋值刚好对齐这一拍。如果你的下游模块期望「先给地址、下一拍才有数据」的 SRAM 语义,就要把rd_data改成组合读取mem[rd_ptr],两者的波形差别很大,验证时要靠 testbench 里的时序断言固定住。

真正会遇到的问题是非 2 的幂深度。把DEPTH改成 10,地址位宽仍然是 4,指针会走到 10、11 这些超出数组范围的值,仿真可能不报错(越界读返回 x),上板就是随机数据。稳妥做法有两条:一是把深度向上取整到 2 的幂,用DEPTH=16但软件只写到 10;二是改成「计数器 + 距离判断」,用cnt记录当前存了多少个,full = (cnt == DEPTH)、empty = (cnt == 0),指针仍然在0..DEPTH-1之间回绕。我一般选第一种,代价是浪费一点存储资源,换来的是逻辑简单、时序好收敛。

3.2 UART 发送:波特率计数器与状态机的分工

UART 是很多人写的第一个外设,也是最容易「仿真对、上板乱码」的模块。乱码九成出在波特率误差,一成出在采样点。先看发送侧的实现:

module uart_tx #( parameter CLK_HZ = 50_000_000, parameter BAUD = 115200, parameter DATA_W = 8 )( input wire clk, input wire rst_n, input wire tx_start, // 单周期脉冲 input wire [DATA_W-1:0] tx_data, output reg tx, output reg tx_busy ); localparam integer BAUD_DIV = CLK_HZ / BAUD; // 50MHz/115200 = 434 localparam integer CW = $clog2(BAUD_DIV); reg [CW-1:0] baud_cnt; reg baud_tick; reg [3:0] bit_idx; // 0=起始位,1..8=数据位,9=停止位 reg [DATA_W-1:0] shifter; // 波特率发生器:只在发送期间计数,空闲时清零 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin baud_cnt <= {CW{1'b0}}; baud_tick <= 1'b0; end else if (tx_busy) begin if (baud_cnt == BAUD_DIV - 1) begin baud_cnt <= {CW{1'b0}}; baud_tick <= 1'b1; // 一个时钟周期的高电平 end else begin baud_cnt <= baud_cnt + 1'b1; baud_tick <= 1'b0; end end else begin baud_cnt <= {CW{1'b0}}; baud_tick <= 1'b0; end end // 发送状态机:起始位立刻拉低,之后每位等一个 tick always @(posedge clk or negedge rst_n) begin if (!rst_n) begin tx <= 1'b1; // 空闲为高 tx_busy <= 1'b0; bit_idx <= 4'd0; shifter <= {DATA_W{1'b0}}; end else if (!tx_busy) begin if (tx_start) begin shifter <= tx_data; tx_busy <= 1'b1; bit_idx <= 4'd0; tx <= 1'b0; // 起始位 end end else if (baud_tick) begin if (bit_idx == 4'd8) begin tx <= 1'b1; // 停止位 bit_idx <= 4'd9; end else if (bit_idx == 4'd9) begin tx_busy <= 1'b0; // 一帧结束,回到空闲 end else begin tx <= shifter[0]; // LSB 先发 shifter <= {1'b0, shifter[DATA_W-1:1]}; bit_idx <= bit_idx + 1'b1; end end end endmodule

逻辑分成两块:计数器负责「一位有多长」,状态机负责「现在发的是哪一位」。分开写的好处是调波特率时只动BAUD_DIV,调帧格式(数据位、停止位)时只动状态机,互不干扰。baud_tick只拉高一个时钟周期,作为状态机的推进信号,这样状态机的每个分支都只跑一次,不会因为计数器持续为高而重复移位。

BAUD_DIV = CLK_HZ / BAUD是整数除法,50MHz 除以 115200 得 434,实际波特率是 50000000/434 ≈ 115207,误差 0.006%,接收端完全能容忍。但如果时钟换成 27MHz、波特率还是 115200,整数除法得 234,实际 115384,误差 1.6%,有些串口工具就开始丢字节了。这时候要么选一个能整除的波特率,要么上小数分频(累加器法),别用「差一点点没关系」安慰自己,串口乱码的麻烦之处就在于它偶发。

接收侧比发送侧多一个采样点的设计。正确做法是检测到起始位下降沿后,先等半个位时间,再每隔一个位时间采样一次,保证采样点落在每一位的中间。如果起始位一到就立刻开始按整位计数采样,累积误差会让最后几位采在边沿上,表现为「收到的字节偶尔高位翻转」。这套 PoC 里uart_rx用了一个half_tick标志做半位对齐,注释里写得很直白,值得对照波形看一遍。

3.3 跨时钟域:两级同步器只对单 bit 有效

跨时钟域是 RTL 里最容易出问题的地方。仿真里永远是对的,因为仿真器不会模拟亚稳态;上板之后随机出错,一天出一次,抓不到现场。最基础的结构是两级触发器:

module sync_ff2 ( input wire clk, // 目的时钟域 input wire rst_n, // 目的时钟域复位 input wire async_in, // 源时钟域信号 output wire sync_out ); reg meta, sync; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin meta <= 1'b0; sync <= 1'b0; end else begin meta <= async_in; // 第一级:可能进入亚稳态 sync <= meta; // 第二级:给足一个周期让电平稳定 end end assign sync_out = sync; endmodule

第一级meta采样异步信号,输出可能在一段时间内处于非 0 非 1 的中间电平,这段不确定时间叫建立保持窗口;第二级sync用一整个时钟周期去采meta,只要时钟频率不是高得离谱,meta到第二个沿时已经稳定。两级之间的这一拍延迟,就是拿面积换可靠性。

这段逻辑有两个硬性限制必须记住:第一,它只对单 bit 电平信号有效。多 bit 总线(比如一个 8 位计数值)如果每根线各打两拍,各位的稳定时刻不同,会出现「0000 跳到 1111 的中间态 0110 被采到」的错码,这种错误在波形上叫数据偏斜。第二,输入信号的高低电平宽度必须大于目的时钟周期,否则慢时钟域根本采不到快时钟域的一个短脉冲。

多 bit 数据跨时钟域,常见做法只有两种:数据变化不频繁的,用握手——源域把数据放稳,拉一个req单 bit 信号过去,目的域同步req后拉ack回传,源域收到ack才允许改数据;数据速率高的,用异步 FIFO,读写指针各自在本地域做格雷码编码再跨域,格雷码相邻值只变一位,同步时最多错一位,不会出现越界指针。这两种结构这套 PoC 里都有,建议先跑异步 FIFO 的 testbench,它内置了两端不同频率、不同相位的时钟,能直接把亚稳态问题暴露成仿真里的x态。

注意:跨时钟域的约束不要只写在脑子里。至少要在设计文档里列出每个信号属于哪个时钟域、用了哪种同步方式,综合工具的时序分析里也要把跨域路径设成 false path,否则工具会按单周期时序去优化,反而把同步器优化掉。

4. 仿真流水线:testbench 骨架、iverilog 编译与波形自查

写代码花的时间远小于排查的时间,所以仿真流程值得一次搭好、长期复用。这套 PoC 的仿真流水线只有三步:写一个带自检的 testbench、用一条命令编译执行、用脚本跑全量回归。不依赖任何图形化 IDE,在纯命令行环境里也能复现。

4.1 testbench 骨架:时钟、复位、激励、自检四段

一个能反复用的 testbench,结构是固定的。以同步 FIFO 为例:

`timescale 1ns / 1ps module tb_sync_fifo; localparam DATA_W = 8; localparam DEPTH = 16; reg clk = 1'b0; reg rst_n = 1'b0; reg wr_en = 1'b0; reg [DATA_W-1:0] wr_data = {DATA_W{1'b0}}; reg rd_en = 1'b0; wire [DATA_W-1:0] rd_data; wire full, empty; always #10 clk = ~clk; // 周期 20ns,即 50MHz sync_fifo #( .DATA_W (DATA_W), .DEPTH (DEPTH) ) dut ( .clk(clk), .rst_n(rst_n), .wr_en(wr_en), .wr_data(wr_data), .rd_en(rd_en), .rd_data(rd_data), .full(full), .empty(empty) ); // 复位保持 3 个时钟沿,别只给 #1 initial begin rst_n = 1'b0; repeat (3) @(posedge clk); rst_n = 1'b1; end // 顺序激励 + 自检:写满、读空、逐字节比对 integer i; initial begin $dumpfile("sim/wave/tb_sync_fifo.vcd"); $dumpvars(0, tb_sync_fifo); wait (rst_n === 1'b1); for (i = 0; i < DEPTH; i = i + 1) begin @(posedge clk); wr_en <= 1'b1; wr_data <= i[DATA_W-1:0]; end @(posedge clk); wr_en <= 1'b0; if (full !== 1'b1) $fatal(1, "FAIL: 写满 %0d 个后 full 未拉高", DEPTH); for (i = 0; i < DEPTH; i = i + 1) begin @(posedge clk); rd_en <= 1'b1; @(posedge clk); if (rd_data !== i[DATA_W-1:0]) $fatal(1, "FAIL: 第 %0d 个数据期望 %0d,实际 %0d", i, i, rd_data); end @(posedge clk); rd_en <= 1'b0; if (empty !== 1'b1) $fatal(1, "FAIL: 读空后 empty 未拉高"); $display("PASS: tb_sync_fifo"); $finish; end endmodule

四段结构很清晰。时钟段用always #10生成,半周期 10ns,方便换算;复位段用repeat (3) @(posedge clk)而不是固定延时,保证复位至少跨过三个时钟沿,避免那些「复位只给了一个仿真时间单位」的隐性 bug;激励段按功能场景写,写满、读空是最基本的两个边界;自检段用$fatal直接终止仿真并打印期望值与实际值。

自检这件事值得强调。只打印不判断的 testbench 等于没有 testbench,因为没有人会逐行看 log。$fatal(1, ...)会让vvp返回非零退出码,回归脚本才能自动识别失败。$display("PASS: ...")则是给回归脚本抓的关键字,成功和失败各留一个可 grep 的标记。

$dumpvars(0, tb_sync_fifo)里的 0 表示 dump 整个层次,调试阶段很方便,但模块多了之后 VCD 文件会到几百 MB。规模上来之后改成只 dump 关心的信号,或者换用支持压缩波形的格式。

4.2 命令行三步:编译、执行、看波形

不开 IDE 也能完整跑通,命令就三行:

# 1) 编译:-g2012 打开 SystemVerilog 常用子集,-Wall 打开告警 iverilog -g2012 -Wall -o sim/sync_fifo.out \ rtl/mem/sync_fifo.v tb/tb_sync_fifo.v # 2) 执行:非零退出码代表 $fatal 被触发 vvp sim/sync_fifo.out # 3) 看波形:仿真退出后再打开 VCD gtkwave sim/wave/tb_sync_fifo.vcd

第一步的-o指定输出文件,后面的文件列表顺序无所谓,但顶层模块必须能通过-s指定,多顶层工程里漏掉-s会报「找不到顶层」。第二步vvp执行编译产物,$fatal会让它返回 1,$finish返回 0,回归脚本靠这个判断。第三步打开波形,建议在 testbench 里提前把关键信号加到$dumpvars的第二个参数里,省得每次手点。

编译之外还要加一道静态检查。lint 工具能在不跑仿真的情况下抓出位宽不匹配、隐式线网、未驱动信号这几类问题,速度快、覆盖广:

verilator --lint-only -Wall -Wno-DECLFILENAME \ rtl/mem/sync_fifo.v rtl/common/sync_ff2.v

-Wall打开全部告警,-Wno-DECLFILENAME屏蔽「文件名与模块名不一致」这条噪音(有些工程按目录前缀命名文件)。lint 报的WIDTH类告警一定要看,它对应的就是位宽截断,属于仿真可能对、上板一定错的类型。

用 Makefile 把这两步串起来,换机器只要执行make sim和make lint:

RTL = rtl/mem/sync_fifo.v TB = tb/tb_sync_fifo.v OUT = sim/sync_fifo.out sim: $(OUT) vvp $(OUT) $(OUT): $(RTL) $(TB) mkdir -p sim/wave iverilog -g2012 -Wall -o $@ $(RTL) $(TB) lint: verilator --lint-only -Wall -Wno-DECLFILENAME $(RTL) .PHONY: sim lint

4.3 回归脚本:一次跑完全部 testbench

模块一多,逐个手动跑就不现实了。回归脚本的逻辑很简单:遍历tb/下的所有 testbench,逐个编译执行,抓PASS关键字,遇到失败立刻停下并打印是哪个模块。

#!/usr/bin/env bash set -euo pipefail mkdir -p sim/wave sim/log fail=0 for tb in tb/tb_*.v; do name=$(basename "$tb" .v) # 例如 tb_sync_fifo mod=${name#tb_} # 去掉前缀得到 sync_fifo # 找到与 testbench 同名的设计文件,约定优于配置 rtl=$(find rtl -name "${mod}.v" | head -n 1) if [ -z "$rtl" ]; then echo "[SKIP] $mod 没有对应的 rtl 文件" continue fi iverilog -g2012 -Wall -o "sim/${mod}.out" "$rtl" "$tb" \ > "sim/log/${mod}.build.log" 2>&1 || { echo "[BUILD FAIL] $mod"; fail=1; continue; } if vvp "sim/${mod}.out" > "sim/log/${mod}.run.log" 2>&1; then echo "[PASS] $mod" else echo "[RUN FAIL] $mod,详见 sim/log/${mod}.run.log" fail=1 fi done exit $fail

这里的约定是「testbench 去掉tb_前缀就是设计文件名」,脚本靠这个自动配对,新增模块不用改脚本。set -euo pipefail让脚本遇到未定义变量或管道失败时立刻退出,避免出现「明明报错了却显示全部通过」这种更麻烦的情况。日志分 build 和 run 两份,编译错误和执行错误分开看,定位快很多。

5. 避坑与排查:五个仿真通过、上板却跑不通的故障

下面这五条都是在这类 PoC 工程里反复出现的,按「现象 → 原因 → 解决」记录,遇到问题可以先按这个顺序过一遍。

第一条:仿真全 PASS,烧进板子 LED 不闪,串口无输出。现象是下载完成后所有输出静止,示波器上时钟脚有波形。原因通常是复位信号只来自 testbench 里的initial,综合工具直接把initial忽略了,或者上电复位时间不够,触发器停在未知态。解决是在设计里加一个复位同步模块,用外部按键或上电复位电路产生至少 100ns 的低电平,并在约束文件里给时钟脚加create_clock,把频率告诉时序分析工具。

第二条:综合日志里出现 LATCH inferred,功能时对时错。现象是综合告警里有一堆 latch 推断,仿真却看起来正常。原因是组合逻辑的always @(*)里存在分支没赋值,工具只能推断出锁存器来保持旧值。解决是每个组合块开头先给所有输出赋默认值,case语句补default。时序逻辑里用<=、组合逻辑里用=,这条规则别破例。

第三条:把 FIFO 深度从 16 改成 10,满标志永远不拉高。现象是写入 10 个数据后full仍然是 0,第 11 个数据把第 1 个覆盖了。原因是满判断依赖「多一位指针绕圈」这个前提,深度不是 2 的幂时指针回绕点对不上。解决有两条:把深度向上取整到 2 的幂,或者在模块内加一个cnt计数器,用cnt == DEPTH判断满、cnt == 0判断空。

第四条:多 bit 数据跨时钟域,打了两拍之后偶尔错一个码。现象是长时间跑才会出现,错的值恰好是相邻两个采样值的中间态。原因是两级同步器只保证单 bit 信号不亚稳态,多根线各自的稳定时刻不同,组合起来就是错码。解决是改用异步 FIFO,或者用req/ack握手把数据保持住,让目的域在数据稳定后再采。单 bit 的控制信号才可以直接打两拍。

第五条:换台机器跑仿真,波形全挤在一起,时序对不上。现象是同样的代码在另一台机器上#10的实际时长变了。原因是文件头没有写`timescale,或者不同文件的 timescale 不一致,仿真器按各自的精度解释延时。解决是每个.v文件第一行统一写`timescale 1ns / 1ps,编译时统一加-g2012,团队内把这条写进提交检查。

6. 二次开发:改深度、加模块、上板验证的三步走

拿到这套代码之后,最常见的改造需求有三个:改参数适配自己的板子、加一个新模块、把仿真通过的模块搬上板。这三件事有固定顺序,跳步就会返工。

改参数最省事,也最容易出错。系统时钟在poc_defs.vh里改,模块深度在位宽parameter上改,改完先跑 lint 再跑回归。我一般会先把DEPTH改成非 2 的幂试一遍,确认满空判断按预期工作,再改回正式值——这一步看着多余,但它能提前暴露第三节里说的指针回绕问题。

加模块的流程是固定的四步:写rtl/<域>/new_mod.v、写tb/tb_new_mod.v(必须带$fatal自检)、跑make lint和make sim、补doc/new_mod.md的端口表和限制说明。第三步里 lint 的WIDTH和LATCH告警必须清零再往下走,这两类告警没有「暂时忽略」的说法。

上板验证建议按「分频 → 点灯 → 串口回环」的顺序推进,每一级只引入一个新变量。先用clk_div把系统时钟分到 1Hz 驱动 LED,确认时钟和复位通了;再接debounce做按键计数,确认输入路径正常;最后接uart_tx/uart_rx做回环,确认时序和波特率都对。每一级失败时,问题范围都被限制在刚加的那个模块里,比一次性全接上再抓瞎快得多。

改造点需要同步改的地方验证方式
系统时钟频率poc_defs.vh、BAUD_DIV计算串口回环,测实际波特率误差
FIFO 深度parameter DEPTH、满判断写法写满读空自检,边界值测试
新增模块rtl、tb、sim 脚本、doc 四件套lint + 回归脚本 + 单独上板

有一个技巧值得单独说:给关键模块加断言。比如在 FIFO 里加assert property (@(posedge clk) !(wr_en && full) || $stable(mem)),或者更简单地在 testbench 里用always @(posedge clk) if (full && wr_en) $error("写入时已满")。这类断言不占综合资源,但能在回归里第一时间抓住「满写」这种平时不出现、现场偶发的问题。我吃过一次亏,一个 FIFO 在满的时候还在写,仿真波形上恰好没体现,上板跑了两个小时才丢一帧数据,查了一整天才定位到。

从那以后我每次新增一个模块,都强制走一遍「lint 清零、自检 tb、回归脚本」这三步,谁也不跳。波形是最后才看的东西,不是第一眼就盯着的东西。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询