ARTICLE DETAIL

资讯详情

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

从硬件思维到状态机:Verilog入门实战与排坑指南

从硬件思维到状态机:Verilog入门实战与排坑指南 如果你打开过一份几千行的Verilog工程第一反应通常是懵代码里全是always、assign、parameter看着像C又不完全是C。这很正常因为Verilog并不是“主程序加函数”的软件逻辑它描述的是硬件本身。作为数字逻辑设计入门的必学语言Verilog最坑的地方不是语法量而是要把“写代码”变成“画电路”的直觉。这篇文章我打算围绕自己从入门到能独立写仿真模块过程中遇到的真实问题展开内容包括模块例化、parameter写法、运算符优先级、计数器、状态机、分频器再到I2C读写EEPROM和文件读取这类实际场景最后聊一下VSCode和gvim里做Verilog开发的配置。无论你是刚开始碰FPGA的本科生还是想补硬件常识的软件工程师都可以把这篇当成一份带排坑记录的实践手册。1. 打开Verilog工程前先把“硬件思维”这块垫脚石踩稳1.1 为什么Verilog入门容易卡壳不是语法难而是执行模型完全不同我们写C或者Python的时候潜意识里会认为代码是一行一行顺序执行的一个语句跑完再进下一个。到了Verilog这个前提整个废掉了。一个always块里的语句虽然在仿真器里有先后顺序但它描述的实际电路是多个信号同时变化的。你写a b; c a;的时候综合出来的东西和你脑补的“先算b赋给a再用新的a算c”很可能是两回事。所以我在带新人的时候第一件事不是让他们背语法而是反复强调一个判断标准你写的这行Verilog能被映射成什么硬件寄存器、组合逻辑、多路选择器、还是连线如果映射不出来那这行代码大概率有问题。Verilog入门教程最容易被忽略的就是这个“硬件映射”训练。很多人能写出语法正确的代码但一上板子就出事就是因为软件思维残留太重。1.2 工具准备仿真器和编辑器怎么选开始动手前工具要备齐。仿真器是硬需求三大选择分别是厂商自带工具比如Vivado的Xsim、Quartus的ModelSim这类适合跟着实验走独立仿真工具比如Verilator、Icarus Verilog轻量写个快速测试很好用商业工具比如VCS、Questa项目里常见但个人学习成本高没必要一上来就折腾。编辑器方面最容易上手的是VS Code装上Verilog-HDL/SystemVerilog插件后就有语法高亮、自动补全和基本的lint能力习惯命令行的则用gvim配好语法文件之后同样能高亮SystemVerilog。这些配置细节放到第6节细说这里先记住工具不要贪多能仿真、能高亮、能看波形就够了。1.3 学习路径的先后顺序先会读再会改最后才说设计我一直觉得Verilog学习最合理的路径是“先读后写”。不要一上来就啃复杂项目先找一个完整的、能仿真的小模块把模块声明、端口、assign、always、parameter、状态机这几样东西在代码里找出来逐行看它对应什么硬件。然后再改参数、改端口、改逻辑跑仿真看波形。等你把别人代码改明白了再独立写这时候写出来的东西才靠谱。2. 模块、端口、parameter与例化代码里句句都是连接关系2.1 模块与端口input、output和inout的分工Verilog的基本单元是module一个模块就是一个硬件盒子。盒子对外提供端口端口要说明方向module counter #( parameter WIDTH 8 )( input wire clk, input wire rst_n, input wire en, output reg [WIDTH-1:0] q );这里clk、rst_n、en是输入q是输出。input wire有默认的wire类型但写全了更清楚。inout双向端口一般用于I2C数据线这类场景学的时候要额外小心因为它涉及到高阻态z和三态缓冲器日常项目里宁可拆成两个单方向信号也不要乱用。前面热词里有一个“原语将输入信号过一个缓冲器”实际就是用buf原语把输入信号在内部缓冲一次wire sig_in; wire sig_buf; buf (sig_buf, sig_in);这种写法在真实工程里不常见因为综合工具会自己插入缓冲器。但它在教学上能帮你理解Verilog里的连线是没有延迟的原语可以显式地描述出缓冲器这类结构。看懂它就行别到处用。2.2 parameter写法模块参数化到底该怎么选热词里“verilog parameter写法”被搜了很多次说明很多人卡在这里。先分清三种定义方式的区别parameter模块对外可见例化时可以被重写localparam仅当前模块内部使用不能被外部覆盖define全局宏替换和语言本身的宏机制一致。日常写可复用模块优先用parameter做对外可配参数用localparam做内部常量。以计数器为例外部例化时想改位宽就写counter #( .WIDTH(16) ) u_counter ( .clk (clk), .rst_n (rst_n), .en (en), .q (cnt) );这种#(...)参数再显式连接端口的写法是现在最推荐的。它有两大好处第一模块内部逻辑和数据位宽全部跟着WIDTH走一套代码适应多种场景第二例化的时候端口对着名字连接代码可读性比按位置连接安全得多。位置连接省打字但一旦模块端口顺序调整编译期才会发现错误排查成本高我吃过亏。2.3 模块例化顶层到底在做什么模块例化本质上就是把一个个写好的硬件盒子用导线连接起来。很多新手疑惑“为什么我不能像C语言一样在函数里调用函数”因为Verilog里例化不是调用是物理放置。一个模块被例化两次就是板上放了两份同样的电路。顶层模块通常不干具体逻辑活只负责例化底层模块并连线module top ( input wire clk_100m, input wire rst_n, input wire i2c_scl_in, output wire i2c_scl_out, inout wire i2c_sda );这种结构化设计方法对于规模较大的工程特别重要。DDR3读写控制器、I2C控制器、UART控制器都是拆成一个个模块然后在顶层把它们像拼积木一样拼起来。3. wire和reg不是“变量类型”运算符优先级也不是考试题3.1 wire与reg连续赋值和过程赋值背后的电路含义关于wire和reg最经典的误解是“wire对应线reg对应寄存器”。这个说法有误导性。wire是连续赋值reg是过程赋值它俩描述的是赋值方式不是最终映射到哪种器件。组合逻辑完全可以用reg写在always (*)里最后综合出来可能只是一堆门电路不带任何存储器。真正决定是不是寄存器的是always块的敏感列表里有没有时钟沿。有没有posedge clk比变量叫不叫reg重要得多。// 组合逻辑always块里用reg变量声明 reg [7:0] sum; always (*) begin sum a b; end // 时序逻辑reg变量综合为寄存器 reg [7:0] q; always (posedge clk or negedge rst_n) begin if (!rst_n) q 8b0; else if (en) q d; end这段代码里两个都是reg变量但前者是组合逻辑后者是寄存器。写代码时先问自己我需要这个信号在某一个时钟沿瞬间被锁存吗需要就写posedge clk不需要就用组合逻辑描述。3.2 运算符优先级加了括号就别猜热词里“verilog运算符优先级”同样高频率被搜索。Verilog的运算符优先级从高到低大致是优先级运算符类别示例最高一元运算~,!,乘除取模*,/,%加减,-移位,关系比较,,,相等比较,!按位与按位异或/同或^,^~按位或|逻辑与逻辑或||最低三目?:这里面最容易被坑的是和。一个是按位与两个是逻辑与两者的结果差别可以很大。比如4b1010 4b0101得到4b0000而4b1010 4b0101得到逻辑1。另外移位运算符优先级高于关系比较所以a 2 b会被解析成(a 2) b看起来很合理但为了阅读清晰我习惯所有混合表达式都打括号。毕竟硬件代码是给团队看的不是给编译器看的。3.3 异或同或、缩位运算一条表达式玩转CRC校验异或^和同或^~平时用得不算多但在校验和和CRC这类场景里是主力。缩位运算符可以快速把一个向量的所有位合并成一个结果比如^data就是对data的所有位做异或生成奇偶校验位。这在UART数据校验里很常用assign parity ^tx_data;同或~^就是异或取反使用场景比较窄但面试题里很喜欢问。理解它俩的来源其实都是从最底层的布尔代数推出来的背住公式容易忘理解成“异或看不同同或看相同”就记住了。4. 从计数器到四分频再到滑动窗口滤波时序电路的搭法有规律4.1 计数器基础框架与复位策略计数器是时序电路里最常见的基础结构没有之一。一个带使能和异步复位的计数器长这样module counter #( parameter WIDTH 8 )( input wire clk, input wire rst_n, input wire en, output reg [WIDTH-1:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt {WIDTH{1b0}}; else if (en) begin if (cnt {WIDTH{1b1}}) cnt {WIDTH{1b0}}; else cnt cnt 1b1; end end endmodule复位策略上FPGA工程普遍用低电平异步复位rst_n因为FPGA内部寄存器天然支持异步复位不需要额外逻辑。ASIC设计则要谨慎很多ASIC库不推荐大扇出异步复位这点等做ASIC方向再深入研究。选同步复位还是异步复位不是“更喜欢哪种”的问题是综合工具和目标工艺共同决定的问题。4.2 四分频电路verilog的几种写法与常见误区“四分频电路verilog”之所以成为高频搜索词是因为入门者总以为分频就是拿时钟沿数数结果写出一堆问题代码。四分频本质上是每数到4就翻转一次输出也就是2位计数器溢出reg [1:0] cnt_2b; reg clk_div4; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_2b 2b00; clk_div4 1b0; end else begin cnt_2b cnt_2b 1b1; if (cnt_2b 2b11) begin cnt_2b 2b00; clk_div4 ~clk_div4; end end end更精简的写法是直接用最高位作为分频输出2位计数器最高位本身就可以看作4分频时钟不过加了一级寄存器更干净。真正容易踩的坑有三个在always里用posedge clk_div4继续分频制造出多级衍生时钟。这会带来严重的时序收敛问题因为衍生时钟和主时钟之间无法保证skew。实际工程中宁可把所有寄存器统一用主时钟驱动再用计数器产生高电平使能信号。分频时钟直接驱动外部芯片时没约束输出延迟。用阻塞赋值在时序块里给时钟信号赋值仿真一通过综合一版就出毛刺。4.3 滑动窗口滤波verilog从“算平均”到“硬件化”热词里有“滑动窗口滤波verilog”和“阿尔法贝塔滤波器verilog”这类信号处理模块在FPGA里非常典型。滑动窗口滤波的思路不复杂维护最近N个采样值每次来一个新采样就把队列里最老的值丢出去然后求平均。硬件实现时窗口数据可以放在移位寄存器里也可以做成环形缓冲区。N不大时用移位寄存器最直接reg [DATA_W-1:0] window [0:NUM-1]; reg [ACC_W-1:0] sum; always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (int i 0; i NUM; i) begin window[i] {DATA_W{1b0}}; end sum {ACC_W{1b0}}; end else if (data_valid) begin sum sum data_in - window[NUM-1]; for (int i NUM-1; i 0; i--) begin window[i] window[i-1]; end window[0] data_in; end end assign data_avg sum / NUM;这段代码的巧妙之处在于求平均没有每个周期都全加一遍而是做增量更新新的窗口和等于旧和加上新样本、再减去最老样本。这避免了频繁的大量加法也让时序更容易收敛。除法器本身比较耗资源如果N是2的幂用右移代替如果不是可以改用乘法近似或CORDIC思路。从滑动窗口再延伸到阿尔法贝塔滤波器本质是从“纯平均”升级成“带预测的迭代更新”用x_pred x_est v_est * dt做预测再用测量值修正。它比滑动窗口更接近现代控制里的卡尔曼滤波思想Verilog实现时也是把预测、修正、更新三步拆成状态机逐拍完成。5. 状态机、文件读取与接口控制仿真和真实协议之间的桥梁5.1 状态机的三段式写法与状态编码状态机是Verilog里几乎所有复杂控制逻辑的骨架。实际工程里我最推荐的是三段式写法第一段时序逻辑状态跳转第二段组合逻辑判断下一状态第三段时序逻辑输出信号。localparam IDLE 3d0; localparam READ 3d1; localparam WRITE 3d2; localparam DONE 3d3; reg [2:0] state, next_state; always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end always (*) begin next_state state; case (state) IDLE: if (start) next_state READ; READ: if (done) next_state DONE; DONE: next_state WRITE; WRITE: if (done) next_state IDLE; default: next_state IDLE; endcase end always (posedge clk or negedge rst_n) begin if (!rst_n) data_out {WIDTH{1b0}}; else if (state DONE) data_out data_in; end状态编码有二进制、格雷码、独热码三种选择。FPGA里我偏好独热码因为状态译码逻辑简单、时序好虽然寄存器用得多一点但FPGA最不缺寄存器。ASIC设计则常用二进制编码节省面积。这里没有绝对答案只有权衡。5.2 I2C读写EEPROM代码的状态机拆解I2C接口是所有FPGA开发者都会遇到的通信协议特别是I2C读写EEPROM几乎成了标准练手项目。I2C总线上有起始条件、停止条件、设备地址、寄存器地址、数据和应答位每一个环节都需要状态机精确控制。核心状态可以定成IDLE等待发起读写请求START产生起始条件发送设备地址写位ADDR发送EEPROM寄存器地址DATA_W写数据DATA_R读数据STOP产生停止条件。写I2C驱动的时候有一个很容易忽略的问题inout数据脚sda什么时候驱动什么时候释放。驱动时输出数据读应答时释放总线让EEPROM拉低应答位。如果释放时机不对总线冲突或者读出全1都是家常便饭。所以我在sda管脚上一定加三态门控制assign sda sda_oe ? sda_out : 1bz;这个1bz很关键不释放总线就是死锁。5.3 仿真中的文件读取$readmemh与文件不存在时的行为状态机写完之后还要能在仿真里验证。热词“verilog读文件时文件不存在”和“verilog文件是否存在”问的是同一个痛点仿真时想从外部文件初始化存储器或者灌测试数据但文件不在怎么办Verilog提供了$readmemh、$readmemb两个系统任务分别读十六进制和二进制文件。如果文件不存在大多数仿真器的行为是报错并且存储器内容不会被初始化。这很坑因为有些仿真器会打出warning后静默跳过你根本看不出来初始化失败了。所以写通用仿真脚本时我习惯先用$fopen判断文件能否打开integer fd; fd $fopen(../test_data/mem.dat, r); if (fd 0) begin $display([ERROR] Cannot open file, simulation aborted.); $finish; end $readmemh(../test_data/mem.dat, mem); $fclose(fd);如果连文件的路径都可能变还可以用$fscanf逐行读取并判断返回值不过对于初始化存储器的场景$fopen加$readmemh已经足够。5.4 阿尔法贝塔滤波与RR调度控制器可以“状态化”到什么程度阿尔法贝塔滤波器是跟踪系统里的常用手段它有两个方程预测方程和更新方程。Verilog实现它的难度不在数学而在时序调度。整个过程需要按固定节拍执行每个节拍做乘法、加法、状态更新状态机的设计方式天然适合这种“分步计算”的场合。同理RR调度器轮询调度在交换机和多路DDR3访问仲裁时很常见。比如4路主机同时要访问DDR3控制器就必须按优先级或轮询策略分配带宽。用状态机写轮询调度器时核心是维护一个指向当前仲裁对象的指针每次仲裁完成后指针加一回到队列头部继续轮询。这个“指针加一取模”的逻辑在综合后就是一组寄存器和比较器并不复杂但它恰恰是设备能不能稳定工作的胜负手。6. 编辑器配置、DDR3工程组织以及我沉淀下来的Verilog查错习惯6.1 gvim和VSCode里的SystemVerilog高亮与语法纠错配置工欲善其事必先利其器。VSCode配置Verilog最常用的是“Verilog-HDL/SystemVerilog”插件它支持语法高亮、格式化、模块例化和代码片段。要想让它真正能纠错要装一个单独的语法检查工具我这边用的是verilator或者iverilog然后在插件的设置里指定lint路径。配置完的效果是保存文件时自动跑一遍lint报出未声明信号、位宽不匹配、锁存器推断之类的问题这比自己对着代码干瞪眼高效得多。gvim方面老编辑器的优势是轻、快、纯键盘操作。SystemVerilog高亮需要下载相应语法文件放到~/.vim/syntax/下同时在.vimrc里开启文件类型检测filetype plugin indent on syntax on au BufRead,BufNewFile *.sv,*.v set filetypesystemverilog高亮之后关键字、信号名、宏定义一眼就能区分开排错效率明显提升。我个人现在开发环境是VSCode写代码、gvim看快速 diff两者配合体验还不错。6.2 DDR3读写控制与大型工程代码组织DDR3读写控制是FPGA项目里比较硬核的部分。直接操作DDR3颗粒需要遵循严格的时序要求初始化、刷新、激活、读写、预充电每一步都缺不得。实际工程里几乎没人会拿RTL直接操作DDR3颗粒而是用厂商提供的MIG IP核或控制器IP再加上用户侧自定义的读写接口。你的Verilog工作重心在接口状态机和数据通路上。从DDR3这类大型模块身上我总结出的代码组织习惯是一个文件只放一个顶层模块子模块单独一个文件文件命名和模块名保持一致便于查找和脚本处理公共参数抽到独立的宏定义头文件接口握手信号三件套valid、ready、last贯穿所有跨模块传输。6.3 真实的Verilog查错流程从编译警告到波形对比代码写完不是结束查错才是重头戏。我查错的标准顺序是看编译和lint报告。编译器报的warning比error更需要仔细看很多warning是位宽截断、隐式锁存、多驱动这些硬伤的前兆。看综合报告。有没有信号被优化掉有没有触发器数量异常有没有组合逻辑环。跑仿真对比波形。不要只盯着最终输出对不对要看中间状态和握手信号对不对。状态机的跳变顺序、握手信号的时序关系往往一眼就能看出问题。做代码评审。让同事看一遍代码比自己闷头找效率高得多。这一条放在最后但价值一点不低。我在实际项目里最常犯的错误是和混用时序逻辑里用了阻塞赋值仿真结果看着对综合后行为完全不对。现在我的习惯是时序块里只用非阻塞赋值组合块里只用阻塞赋值永远不混用。这个习惯很小但帮我避掉了不少后期调试的坑。还有一个小技巧状态机的default分支一定要写而且最好把next_state赋成安全状态。组合逻辑里漏掉分支很容易综合出锁存器等到上板成为不定态再回来找就麻烦大了。Verilog这门语言说到底是个工具箱不是目的。学完语法、写完模块、跑通仿真真正值钱的不是“会用Verilog”这个标签而是脑子里能不能把寄存器、组合逻辑、状态机、总线和时序全部串起来。希望这篇梳理能帮你少走一些我走过的弯路尤其是那些在编辑器和综合工具之间来回折腾的过程真心不用再重复一遍。
返回列表