FPGA驱动74HC595:线性状态机设计思路与Verilog实现
第一次用FPGA驱动74HC595的时候我照着开发板例程抄了一个“计数器移位寄存器”的版本跑通之后总觉得别扭想给数据建立时间多留一拍得同时改计数器比较值和移位使能条件改一次就心惊胆战生怕把STCP锁存时序带偏。后来我把整个驱动重写成线性状态机的写法代码没变长多少但每个时钟周期在干什么清清楚楚跟74HC595数据手册上的时序图能逐条对上。这篇文章就把这个驱动模块的完整设计思路、Verilog代码、仿真验证和级联扩展整理出来给正在写74HC595驱动、或者想理解“线性状态机”到底怎么落地的人一份能直接抄作业的参考。文章面向三种读者第一次用FPGA做数码管/点阵扩展的新手写外设驱动时想换一种比“计数器套移位”更直观思路的老手以及马上要交课程设计但还没搞懂状态机怎么写的人。我会把时序参数怎么读、状态步骤怎么分、代码为什么这么写、仿真怎么看对错全部讲透。1. 74HC595在项目里到底在干什么寄存器结构与时序测绘1.1 位移寄存器、存储寄存器与三根控制线的关系先别急着写代码把芯片内部搞清楚驱动就是水到渠成的事。74HC595内部有两个8位寄存器一个是移位寄存器负责接收串行数据另一个是存储寄存器也叫输出锁存器负责把数据并行输出到QA~QH引脚。外部控制线有四根但真正的数据通道只有三根引脚名称作用DS串行数据输入每个SHCP上升沿采样一位数据SHCP移位寄存器时钟上升沿时DS数据移入一位STCP存储寄存器时钟上升沿时把移位寄存器内容整体锁存到输出OE输出使能低电平有效接GND时QA~QH正常输出MR主复位低电平有效正常使用接VCC工作流程可以概括成两句话DS线在SHCP上升沿把数据一位一位推进移位寄存器8位攒齐之后STCP上升沿一次性把8位数据搬到输出引脚上。数据从DS进去之后在芯片内部是从QA往QH方向移动的所以QA是第一个收到数据的引脚QH是最后一个。生活化类比一下移位寄存器是一条传送带DS是传送带的入口SHCP是传送带的步进马达每踩一脚货物往前挪一格存储寄存器是传送带末端的一个货架STCP就是“上架”按钮按一下当前传送带上的8件货物一起摆到货架上由OE控制货架的展示灯亮不亮。驱动模块的任务就是在正确的时间踩马达、在正确的时机按上架按钮。1.2 从数据手册时序图提炼出驱动模块必须满足的约束写驱动之前必须先知道74HC595对时序的底线要求。不同厂商的HC系列参数略有差异但典型值可以作为设计参考时序参数含义典型最小要求tSUDS数据建立时间上升沿前约20 nstHDS数据保持时间上升沿后约3~5 nstW_SHCP_HSHCP高电平最小脉宽约20 nstW_SHCP_LSHCP低电平最小脉宽约20 nstW_STCP_HSTCP高电平最小脉宽约20 nstSHL_STCPSHCP最后一位上升沿到STCP上升沿最小间隔约20 ns这些数字意味着如果你用50MHz系统时钟周期20ns直接驱动所有动作都卡在规格边缘任何一点点引脚电容、走线延迟都可能让芯片采错数据。所以工程上我习惯给足裕量让SHCP工作在5MHz以下每个bit占用多个系统时钟周期建立时间和保持时间留出3到4倍余量。还要注意一点MR和OE这两个引脚虽然看起来“不参与数据”但上电瞬间如果MR悬空移位寄存器可能进入随机状态。稳妥做法是MR直接接VCC、OE直接接GND把两个引脚固定死。如果某个项目需要动态控制OE做输出使能也要记得它是低电平有效别一上来就写成高电平输出。2. 线性状态机为什么适合写74HC595驱动2.1 传统写法的三个痛点很多网上的教程把74HC595驱动写成“计数器移位寄存器”一个计数器cnt对系统时钟分频产生SHCP的节奏一个位计数bit_cnt记录当前发了多少位一个移位寄存器shreg存放待发送数据当cnt计数到某个值时把DS赋成shreg的最高位再拉高SHCP。这套写法能跑但存在三个明显问题。第一时序动作分散在多个条件里。一个周期的动作可能同时受cnt、bit_cnt、stage三个变量控制想加一拍建立时间可能得同时改三个地方漏改一处时序就错了。第二状态含义不直观。看代码很难立刻说出“第13个时钟周期SHCP是高还是低”必须把计数器条件翻译一遍才能对应到时序图上。第三锁存时机容易出错。新手经常在bit_cnt8后立刻拉STCP忘了SHCP还有最后一个下降沿也忘了需要给移位寄存器到存储寄存器留出稳定时间结果输出的最后一位是错的。2.2 线性状态机的核心思想把时序图展开成一条时间轴所谓线性状态机Linear Sequence Machine本质上是把一次完整的数据发送过程展开成一条单向推进的时间轴每个系统时钟周期就是时间轴上的一个“步骤”。驱动模块不需要判断自己处于IDLE、SHIFT还是LATCH这个大状态只需要回答一个问题当前是第几步这一步该执行什么动作。打个比方传统状态机像一张地图你得知道自己在哪个城市、有哪些路可以走线性状态机像一盒电影胶片每一帧的编号就决定了画面内容播放到第几帧就显示第几帧不需要导航也不会迷路。具体到74HC595一次发送可以被分解成若干个步骤第1~4步让DS输出最高位SHCP保持低电平留出建立时间第5~8步SHCP拉高产生上升沿让74HC595采样这一位第9~12步准备下一位数据同时SHCP拉低形成完整脉冲依此类推直到8位全部发完最后几步拉高STCP产生锁存脉冲再完成收尾。步骤编号从1线性递增到总步数中间没有分支没有循环跳转没有死锁可能。这就是线性状态机最讨喜的地方它把“什么时候干什么事”彻底显式化写代码就像在抄时序图出问题时看波形对应到第几步就能定位。2.3 和其他写法的取舍有经验的读者可能会问既然本质是计数器为什么不直接用计数器区别在于视角。线性状态机虽然底层也是一个step计数器但它的设计流程是先画时序图、再分配步骤、最后写代码而传统的“计数器移位”通常是边写代码边想条件结构散了。线性状态机的代码里step的每个取值范围都有明确含义和时序图一一对应这对维护者非常友好——三个月后回来看代码依然一眼能懂。当然线性状态机不是万能的。AHB总线仲裁、UART接收这种有握手、有等待、有动态跳转的协议还是得用经典FSM。74HC595这类“固定时序、单向输出、无应答”的外设芯片正好是线性状态机的舒适区。芯片不会反向给FPGA发信号所有动作都是单方向的时间轴天然就是一条直线硬要套多状态FSM反而是自找麻烦。3. Verilog实现可综合的线性状态机驱动模块3.1 模块接口定义驱动模块对外暴露的接口尽量精简方便在顶层例化信号方向说明clkinput系统时钟rst_ninput异步复位低有效startinput启动发送单周期高脉冲data_ininput [7:0]待发送的8位并行数据sclkoutput连接到74HC595的SHCPrclkoutput连接到74HC595的STCPdoutoutput连接到74HC595的DSbusyoutput忙标志发送期间为高doneoutput发送完成标志单周期高脉冲SHCP和STCP必须由寄存器产生不能在顶层直接把系统时钟引出去当SHCP用。原因很简单寄存器输出可以精确控制相位和脉宽而直接引时钟无法在中间插入“数据建立”阶段也不方便做频率约束。3.2 步骤分配BIT_CYCLES4时的完整时间轴模块用参数BIT_CYCLES表示每个数据位占用多少个系统时钟周期。默认取450MHz时钟下SHCP频率约12.5MHz对多数74HC595在临界点附近如果希望更稳可以改成8。这里为了讲清楚逻辑先用4展开。当BIT_CYCLES4、LATCH_CYCLES2时一次完整发送共需要TOTAL_STEPS 8 * 4 2 1 35第1步作为启动后的第一个动作周期第35步作为结束动作周期全部35步的动作分配如下step范围含义动作1~4发送bit[7]step1更新DS为最高位SHCP低step2保持低step3 SHCP拉高step4保持高5~8发送bit[6]step5更新DS为次高位SHCP低step6保持step7拉高step8保持...继续每4步发送一位29~32发送bit[0]最后一位移入33~34锁存阶段STCP保持高电平产生上升沿锁存35完成阶段STCP拉低done输出一个周期高电平每个bit前半段SHCP低、后半段SHCP高上升沿正好出现在后半段的起点。此时DS已经在bit的最初阶段被更新距离上升沿有2.5个时钟周期建立时间非常充裕。3.3 完整RTL代码// // 74HC595 线性状态机驱动模块 // 功能将8位并行数据通过DS/SHCP/STCP三线发送至74HC595 // 设计思路线性状态机Linear Sequence Machine // 整个发送过程拆分为TOTAL_STEPS个单向步骤 // 每个系统时钟周期执行一个步骤步骤号与动作一一对应。 // module shc595_drv #( parameter integer BIT_CYCLES 4, // 每个数据位占用几个系统时钟周期 parameter integer LATCH_CYCLES 2 // STCP锁存脉冲保持周期数 )( input wire clk, input wire rst_n, input wire start, input wire [7:0] data_in, output reg sclk, output reg rclk, output reg dout, output reg busy, output reg done ); // 总步骤数 8位 * 每位置周期 锁存周期 1个结束步骤 localparam integer TOTAL_STEPS 8 * BIT_CYCLES LATCH_CYCLES 1; reg [15:0] step; // 线性步骤号0表示空闲 reg [7:0] shreg; // 数据移位寄存器 wire step_active (step ! 16d0); wire step_in_shift step_active (step 8 * BIT_CYCLES); wire step_in_latch step_active (step 8 * BIT_CYCLES) (step 8 * BIT_CYCLES LATCH_CYCLES); // 发送阶段内当前是第几个bit、bit内第几个相位 // 注意只有在step_in_shift时取值才有意义 wire [3:0] bit_phase step_in_shift ? ((step - 1) % BIT_CYCLES) : 4d0; wire [3:0] bit_pos step_in_shift ? ((step - 1) / BIT_CYCLES) : 4d0; always (posedge clk or negedge rst_n) begin if (!rst_n) begin step 16d0; shreg 8d0; sclk 1b0; rclk 1b0; dout 1b0; busy 1b0; done 1b0; end else if (start) begin // 启动发送装载数据DS先放最高位SHCP/STCP保持低 step 16d1; busy 1b1; done 1b0; shreg data_in; dout data_in[7]; sclk 1b0; rclk 1b0; end else if (step_active) begin done 1b0; if (step_in_shift) begin // 发送阶段前半段sclk为低后半段sclk为高 sclk (bit_phase BIT_CYCLES / 2) ? 1b1 : 1b0; // 每个bit周期的最后一拍把下一位数据准备好 if (bit_phase BIT_CYCLES - 1) begin if (bit_pos 7) begin shreg {shreg[6:0], 1b0}; dout shreg[6]; end end end else if (step_in_latch) begin // 锁存阶段STCP保持高电平上升沿已产生 rclk 1b1; end else begin // 最后一步STCP拉低输出完成标志 rclk 1b0; done 1b1; end // 步骤线性推进到达TOTAL_STEPS后回空闲 if (step TOTAL_STEPS) begin step 16d0; busy 1b0; end else begin step step 16d1; end end else begin // 空闲状态三根线全部拉低 sclk 1b0; rclk 1b0; busy 1b0; done 1b0; end end endmodule3.4 关键代码注释与时序对应关系这段代码里最容易写错的是dout shreg[6]这一句。为什么不是shreg[7]因为start那个上升沿已经把data_in[7]放进dout了。等到第一个bit周期的最后一拍step4最高位已经在SHCP上升沿被74HC595采样接下来要发送的是data_in[6]也就是当前shreg里的次高位。shreg[6]才是还没发出去的最高位。如果这里写shreg[7]等于把已经发出去的位又发了一遍最后输出会整体错位。sclk的产生方式也要理解透bit_phase BIT_CYCLES / 2时拉高意味着SHCP的高电平出现在每个bit周期的后半段。上升沿在bit_phase从1跳到2的那一刻产生而此时dout早在bit_phase0时就已经稳定DS的建立时间至少覆盖了前半段所有周期非常安全。BIT_CYCLES这个参数我建议保持2的幂次比如4、8、16。因为代码里的取模和除法都是除以常量综合器遇到2的幂会直接优化成位截取和移位不消耗DSP资源如果填成3、5、7这种数逻辑会明显变大。这是实际工程中容易踩的坑。代码在busy期间如果再次收到start会直接重新装载数据重新发送。如果希望“正在发送时忽略新请求”在顶层把start改成start !busy再送进来即可模块内部不必改。4. 仿真验证与上板前的自检4.1 Testbench怎么搭写驱动不仿真等于盲调。我习惯在仿真里放一个74HC595的行为模型直接对比输出比肉眼盯波形可靠得多。74HC595行为模型可以简化成两个always块module shc595_model ( input wire sclk, input wire rclk, input wire dout, input wire mr, output reg [7:0] q ); reg [7:0] sr; // 移位寄存器sclk上升沿采样doutMSB first数据从QA向QH移动 always (posedge sclk or negedge mr) begin if (!mr) sr 8d0; else sr {sr[6:0], dout}; end // 存储寄存器rclk上升沿锁存 always (posedge rclk or negedge mr) begin if (!mr) q 8d0; else q sr; end endmodule注意行为模型里数据从高位往低位移动sr {sr[6:0], dout}表示dout进最低位先发的数据最终停在低位还是高位取决于发送顺序。这个模型能自动帮我们验证驱动模块的时序是否正确。Testbench如下timescale 1ns/1ps module tb_shc595_drv; reg clk 0; reg rst_n 0; reg start 0; reg [7:0] data_in 8h00; wire sclk, rclk, dout, busy, done; wire [7:0] q; always #10 clk ~clk; // 50MHz时钟 shc595_drv #( .BIT_CYCLES(4), .LATCH_CYCLES(2) ) uut ( .clk (clk), .rst_n (rst_n), .start (start), .data_in (data_in), .sclk (sclk), .rclk (rclk), .dout (dout), .busy (busy), .done (done) ); shc595_model u_model ( .sclk(sclk), .rclk(rclk), .dout(dout), .mr (1b1), .q (q) ); integer err_cnt 0; // 发送完成时自动比对 always (posedge done) begin if (q ! data_in) begin $display([ERROR] time%0t q0x%02X expect0x%02X, $time, q, data_in); err_cnt err_cnt 1; end else begin $display([OK] time%0t q0x%02X, $time, q); end end initial begin #50 rst_n 1; #100; (posedge clk); start 1; data_in 8b1010_0101; (posedge clk); start 0; // 等第一次发送完成 wait (done 1); (posedge clk); // 再发一组数据 (posedge clk); start 1; data_in 8h5A; (posedge clk); start 0; #1000; if (err_cnt 0) $display(ALL TESTS PASSED); else $display(TEST FAILED, err_cnt%0d, err_cnt); $finish; end endmodule用Icarus Verilog跑仿真的话命令行是iverilog -o tb.vvp tb_shc595_drv.v shc595_drv.v vvp tb.vvp应该在终端看到两次[OK]最后输出ALL TESTS PASSED。4.2 仿真波形的关键检查点如果只用GTKWave看波形重点观察以下几个位置start脉冲后busy拉高step从1开始递增第一个bit周期内dout输出data_in[7]sclk先低后高高电平宽度正好2个时钟周期之后每个bit周期dout依次切到下一位sclk保持“低高低高”的节奏8位数据发完后rclk拉高并保持2个时钟周期此时q应该等于data_in最后一个步骤中done输出一个时钟周期的高脉冲同时rclk拉低busy释放。整体耗时从start到done是TOTAL_STEPS个时钟周期。BIT_CYCLES4时是35个周期50MHz下正好700ns。4.3 上板前最容易翻车的几个坑第一个坑BIT_CYCLES设太小导致建立时间不足。如果直接把BIT_CYCLES设成2每bit周期40nsSHCP频率25MHz对普通HC系列芯片来说已经到了极限杜邦线连接时更危险。我建议至少4追求稳定就设8。换来的只是总时间变长对数码管刷新率没有实质影响。第二个坑STCP锁存太早。有些简化代码在位计数到8后立即拉STCP没有给SHCP最后一个上升沿留出间隔。务必保证最后一位的SHCP上升沿与STCP上升沿之间至少有2个时钟周期代码里对应的是step_in_shift结束后再进入step_in_latch。第三个坑复位状态输出不确定。如果复位时不把sclk、rclk、dout拉低上电瞬间这三个引脚可能是高阻或随机电平可能让74HC595误移入数据。代码里已经做了异步复位清零顶层例化时别漏接rst_n。第四个坑发送位序反了。74HC595在SHCP上升沿采样DS先发高位还是先发低位由驱动决定。我的代码是MSB first即先发data_in[7]。如果你希望先发最低位需要把数据装载改为dout data_in[0]移位方向也要相应反过来。仿真模型能立刻暴露这种错误q输出跟预期不符时先怀疑位序。5. 多片级联与工程化扩展5.1 两片74HC595级联的接线一个74HC595只能扩展8个输出驱动16位数码管扫描就需要两片级联。级联接线非常规整第一片的Q7串行输出接到第二片的DS两片的SHCP并联、STCP并联、OE并联FPGA仍然只需要三根线控制。级联后数据流向有个容易搞反的结论先发送的数据会流向更后面的芯片。设FPGA依次发送16位数据d1、d2、...、d16经过前8个SHCP上升沿d1~d8会依次进入第一片同时从第一片Q7溢出到第二片。最终第二片保存的是d1~d8第一片保存的是d9~d16。这个结论对应用层影响很大。假设两片74HC595的QA~QH分别接了一个数码管的段选和位选想让第一片输出低字节、第二片输出高字节发送顺序应该是先发高字节再发低字节。如果发反了数码管会显示乱码而且这种错误在仿真里不容易发现必须建双芯片模型或直接上板看。5.2 数据组织把模块改造成多字节发送上面的驱动模块一次只发8位。级联16位时最简单的方法是写一个顶层包装模块把16位数据拆成两个8位先后调用同一个驱动module shc595_16bit_drv ( input wire clk, input wire rst_n, input wire start, input wire [15:0] data_in, output wire sclk, output wire rclk, output wire dout, output wire busy, output wire done ); reg [1:0] byte_sel; reg send_pulse; wire byte_done; reg [15:0] data_buf; shc595_drv u_byte ( .clk (clk), .rst_n (rst_n), .start (send_pulse), .data_in (data_buf[15:8]), // 注意低字节先发级联后低字节落在第一片 .sclk (sclk), .rclk (rclk), .dout (dout), .busy (), .done (byte_done) ); ... endmodule也可以直接把原模块参数化成DATA_WIDTH把data_in改成位宽可调shreg的位宽跟着变。两种方式我都用过前者改动小适合快速验证后者代码更优雅适合正式项目。要注意的是多字节发送时每次发送的字节之间要留出几个空闲周期避免最后一个字节刚发完STCP还没拉低就启动下一次发送把两次数据混在一起。5.3 系统时钟速率与板级连线注意事项关于SHCP时钟频率我做项目时习惯保守一点驱动74HC595时SHCP不超过10MHz最好控制在5MHz左右。原因有三一是建立时间留足裕量温度、电压波动时不至于翻车二是杜邦线连接时信号边沿过冲会带来串扰频率越高越明显三是74HC595本身是低速外设芯片跑太快没有任何收益。板级连线方面如果SHCP引线超过10cm建议在FPGA输出端串一个33到100Ω的电阻抑制反射74HC595的电源引脚旁边放一颗0.1uF陶瓷电容离芯片越近越好。这些细节在面包板和杜邦线调试时可能感觉不到但做成PCB后就是稳定性和不稳定的分水岭。模块里还有一个可以优化的点如果系统时钟很高比如100MHz而BIT_CYCLES又取4SHCP频率会到25MHz此时sclk、rclk、dout这三个寄存器输出可以考虑用OSERDES或寄存器打拍的方式进一步对齐避免三个信号到达芯片时相位偏差过大。不过对于大部分学习项目和实验板场景上面的寄存器输出方案已经够用不必过度设计。最后分享一个我自己的习惯写完驱动先跑行为仿真确认q等于发送数据然后看综合报告确认没有推断出除法器最后才上板。三级检查下来74HC595驱动基本一次通过。这套“线性状态机行为模型验证”的套路后来我用来写SPI、I2C这类简单时序外设也都顺手。