
正文做FPGA或者数字逻辑设计的人多半都跟74HC595打过交道。这颗8位串转并移位寄存器几乎是成本最低的IO扩展方案芯片几毛钱三根控制线就能换来8路并行输出级联之后更是可以轻松扩展几十路。芯片本身简单真正让新手卡住的是怎么用Verilog把它“驱动”起来——尤其当你想让代码同时具备清晰的结构、稳定的时序和可扩展性时很多随手写的移位逻辑就会暴露出问题。这篇文章我围绕“74hc595驱动模块的Verilog设计线性状态机”这个主题把从时序拆解、状态规划、代码实现到仿真排错的完整过程都过一遍适合刚学完Verilog基础语法、想动手做外设驱动的朋友参考。1. 项目整体设计与思路拆解1.1 这玩意儿到底在解决什么问题74HC595本身是一颗8位串入并出移位寄存器它解决的硬件问题是“MCU或FPGA的IO不够用”。一颗芯片只需要占用三根信号线——串行数据、移位时钟、锁存时钟就能得到8个并行输出级联后每多加一颗芯片只多占一根数据线输出路数却按8的倍数增长。这在点阵屏驱动、数码管动态扫描、按键矩阵扫描、继电器控制这些场景里非常常见。但硬件优点不能直接换成软件收益。74HC595对时序是有明确要求的每一bit数据必须在移位时钟的上升沿之前稳定下来8个bit全部移完后还要给锁存时钟一个上升沿把移位寄存器里的内容整体搬到输出寄存器。这个“先移位8次、再锁存1次”的过程天然就是一个有先后顺序的线性流程。用Verilog实现时如果直接在always块里写一串for循环或者用计数器硬憋代码可能能用但是可读性差、时序边界模糊尤其是后面要接级联、多字节发送时改起来很痛苦。1.2 为什么是线性状态机而不是计数器或复杂FSM这是我在设计里最想强调的一个点驱动74HC595这种外设线性状态机是性价比最高的写法。先解释一下“线性状态机”是什么。它不是指状态编码方式而是指状态转移的结构形态——状态按一条单向链路推进空闲 → 发送bit循环 → 锁存 → 完成 → 回到空闲。整个状态转移图看下来就是一条直线加上一个回环没有复杂的嵌套分支也没有多个并列出口。相比直接用计数器分拍线性状态机的优势在于“每个拍点都有明确意义”。计数器写法里你脑子里要时刻记着“计到0是拉高sclk计到1是拉低计到15是锁存”时间一长或者换个人来看代码完全看不懂。状态机写法则把“现在正在做什么”这件事显式表达出来了代码本身就能当文档读。相比复杂FSM线性状态机又足够简单。复杂FSM常见于通信协议解析、总线仲裁这些有大量条件跳转的场景用它来驱动一颗74HC595属于杀鸡用牛刀反而容易把状态跳转条件写乱。线性状态机逻辑单一综合出来的状态寄存器又小时序收敛也容易。还有一个实际好处这种结构非常方便并行扩展。控制8bit和控制16bit、24bit核心区别只在于移位状态里循环的次数状态机骨架完全不用动。1.3 模块整体架构与信号规划我实现这个驱动时把整个逻辑划分成几个清晰的部分线性状态机主控负责全局状态推进决定当前是空闲、移位还是锁存。移位寄存器保存待发送的数据在移位时钟下降沿的时候往高位方向挪一位。位计数器记录已经发送了多少个bit到8时触发状态切换。输出引脚驱动根据状态机当前状态产生sclk、rclk、sdo三个对外信号。顶层对外只暴露我需要的接口信号内部怎么“折腾”状态都封装在模块里这样在更大的系统里调用时只需要关心start和done这对握手信号很干净。我规划的信号列表是这样的信号方向作用clk输入系统时钟驱动整个状态机rst_n输入异步复位低有效把所有状态和输出清掉start输入发送启动信号高电平有效收到后开始一次串行发送data_in输入8位并行待发送数据sdo输出串行数据输出接74HC595的DS引脚sclk输出移位时钟接SHCP引脚rclk输出锁存时钟接STCP引脚done输出发送完成标志高电平有效持续一个时钟周期这套端口设计里有一个容易被忽略的细节done信号不是“发送结束后一直保持高电平”而是“发送结束的那个时钟周期拉高一拍”。这种脉冲式的完成标志更适合对接后续的状态机逻辑不会产生电平粘连。2. 74HC595时序原理与关键引脚2.1 引脚速览与驱动要点写驱动之前必须把芯片每根引脚吃透。这里只列驱动时真正关心的引脚电源和地就不啰嗦了。引脚名常见别名作用驱动要点DSSER串行数据输入在SHCP上升沿被采样必须提前稳定SHCPSRCLK移位寄存器时钟上升沿时把DS上的值移入移位寄存器STCPRCLK存储寄存器时钟上升沿时把移位寄存器内容锁存到并行输出OE输出使能低电平有效一般直接接地让输出一直是开启状态MR清零低电平有效一般接高电平不用它做复位Q0~Q7并行输出最终输出接LED、数码管段选等Q7QH级联输出移位寄存器溢出的最高位接下一颗DS这里我建议你特别记住一点SHCP管的是“移位”STCP管的是“输出刷新”。这两个时钟是完全独立的概念很多人刚开始驱动时只给SHCP打脉冲忘了STCP结果发现数据明明在移位寄存器里并行输出就是不变其实就是没锁存。2.2 一次完整发送的时序拆解一次完整的8bit发送过程从上到下是这样走的SDO先准备好第7位最高位的数据。SHCP产生一个上升沿74HC595把SDO上的值采进移位寄存器的最高位。SHCP拉低SDO切换为第6位的数据。再次产生SHCP上升沿数据再被采入。重复8次直到第0位也移入。STCP产生一个上升沿移位寄存器里的8个bit一次性锁存到输出寄存器Q0~Q7立刻更新。这里有一个老手都懂的潜规则在SHCP为低电平期间改变SDO是最安全的。因为74HC595在SHCP上升沿采样数据只要在上升沿之前满足建立时间即可。你在低电平区间把数据准备好到上升沿时数据早就稳定了从根上规避建立时间不足的问题。我见过不少新手写的驱动是先把SDO改了紧接着就拉SHCP中间没有任何延迟这在低速时钟下可能没问题一旦时钟频率提高就会出现偶发的错位、乱码。2.3 级联场景下的数据流级联的时候事情稍微多一步但原理不难。把第一颗芯片的Q7接到第二颗芯片的DS两颗芯片的SHCP和STCP并联就会形成“最长移位寄存器链”。发送时数据实际上是先进入前面那颗芯片然后随每个SHCP上升沿逐级往后“挤”相当于数据从第一颗一直流到最后。级联时最常踩的坑是发送顺序搞反。假设有两颗芯片你想让第二颗芯片远离FPGA那颗输出0xAA第一颗芯片输出0x55。这时你需要先发送0xAA、再发送0x55也就是“先发后级、再发前级”。很多人按自然顺序先发0x55结果发现后面那颗芯片死活输出不对。关于多字节发送我在第3节会给出一个更方便的处理方式但核心思想就是准备好多个字节按“最后一个字节先发”的顺序串行推出去。3. 基于线性状态机的Verilog实现3.1 状态定义与状态转移逻辑我设计的线性状态机包含4个状态用3位状态寄存器编码状态编码含义S_IDLE3b000空闲等待start启动S_SHIFT3b001移位发送循环8次S_LATCH3b010置位锁存时钟产生STCP上升沿S_DONE3b011完成拉高done一周期然后回IDLE转移关系是一条直线加一个回环S_IDLE 在start有效时载入数据跳到S_SHIFTS_SHIFT 内部根据bit_cnt循环发送完8bit后跳到S_LATCHS_LATCH 锁存下一拍跳到S_DONES_DONE 释放锁存时钟、给出完成信号下一拍回到S_IDLE。这个结构里唯一有“循环”概念的是S_SHIFT内部根据计数器决定是继续留在S_SHIFT还是进入S_LATCH但整体依然是单向推进没有复杂的跨状态跳跃任何时刻都能明确说出外设处于什么阶段排查问题非常方便。3.2 核心代码8位驱动模块完整实现下面是我调试通过的一个单字节驱动模块支持8位数据发送。我用的是Icarus Verilog做仿真代码本身也兼容Vivado和Quartus。module hc595_driver #( parameter WIDTH 8 )( input wire clk, input wire rst_n, input wire start, input wire [WIDTH-1:0] data_in, output reg sclk, // 接74HC595的SHCP output reg rclk, // 接74HC595的STCP output reg sdo, // 接74HC595的DS output reg done ); // 线性状态机单向推进 回环 localparam S_IDLE 3d0; localparam S_SHIFT 3d1; localparam S_LATCH 3d2; localparam S_DONE 3d3; reg [2:0] state; reg [WIDTH-1:0] shift_buf; reg [$clog2(WIDTH)-1:0] bit_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state S_IDLE; sclk 1b0; rclk 1b0; sdo 1b0; done 1b0; shift_buf {WIDTH{1b0}}; bit_cnt 0; end else begin case (state) S_IDLE: begin sclk 1b0; rclk 1b0; done 1b0; if (start) begin shift_buf data_in; bit_cnt 0; sdo data_in[WIDTH-1]; state S_SHIFT; end end S_SHIFT: begin if (sclk 1b0) begin // SHCP低电平期间SDO数据已经稳定拉高产生上升沿 sclk 1b1; end else begin // SHCP高电平期间数据已被采样拉低并准备下一位 sclk 1b0; shift_buf {shift_buf[WIDTH-2:0], 1b0}; if (bit_cnt WIDTH-1) begin // 8 bit全部发送完成进入锁存阶段 state S_LATCH; end else begin bit_cnt bit_cnt 1b1; sdo shift_buf[WIDTH-2]; end end end S_LATCH: begin // STCP上升沿移位寄存器内容锁存到输出寄存器 sclk 1b0; rclk 1b1; state S_DONE; end S_DONE: begin rclk 1b0; done 1b1; state S_IDLE; end endcase end end endmodule这段代码的核心逻辑是在S_SHIFT状态内部通过判断sclk当前电平来决定下一拍做什么。sclk为低时拉高sclk为高时拉低再加移位这样生成的SHCP波形占空比接近50%而且SDO数据总是在SHCP低电平期间完成切换确保74HC595在上升沿能采到稳定的值。有一点要提醒$clog2是Verilog-2001就支持的系统函数Vivado、Quartus、Icarus Verilog都能用。如果你用的是很老的定制工具保险起见可以把位计数器直接声明成reg [7:0] bit_cnt多占几个触发器而已不影响功能。3.3 多字节发送与级联的参数化扩展实际项目里经常要一次发好几个字节比如驱动4位数码管或者级联多个595。直接在顶层把驱动模块调用4次很蠢更优雅的做法是让驱动模块支持“一次调用发送多字节”。我常用的办法是把单字节驱动包一层在外面做一个字节计数器每发送完一个字节自动加载下一个字节直到所有字节发送完毕。module hc595_multi #( parameter BYTE_NUM 4 )( input wire clk, input wire rst_n, input wire start, input wire [BYTE_NUM*8-1:0] data_in, output reg sclk, output reg rclk, output reg sdo, output reg done ); reg [7:0] send_buf [0:BYTE_NUM-1]; reg [$clog2(BYTE_NUM)-1:0] byte_idx; reg [$clog2(BYTE_NUM)-1:0] byte_cnt; reg send_start; reg byte_done; reg [7:0] cur_byte; hc595_driver #( .WIDTH(8) ) u_driver ( .clk (clk), .rst_n (rst_n), .start (send_start), .data_in (cur_byte), .sclk (sclk), .rclk (rclk), .sdo (sdo), .done (byte_done) ); genvar i; generate for (i 0; i BYTE_NUM; i i 1) begin : data_assign always (*) send_buf[i] data_in[i*8 : 8]; end endgenerate always (posedge clk or negedge rst_n) begin if (!rst_n) begin byte_idx 0; byte_cnt 0; send_start 1b0; done 1b0; end else begin send_start 1b0; done 1b0; if (byte_cnt BYTE_NUM) begin // 所有字节都发完了 done 1b1; byte_cnt 0; byte_idx 0; end else if (byte_done) begin // 当前字节发送完成切下一个字节 byte_cnt byte_cnt 1b1; if (byte_idx BYTE_NUM-1) begin cur_byte send_buf[0]; // 其实不会用到但保持语法完整 end else begin cur_byte send_buf[byte_idx 1]; byte_idx byte_idx 1b1; end end else if (start) begin // 收到启动信号从最后一个字节开始发适配级联顺序 byte_idx BYTE_NUM - 1; byte_cnt 1; // 表示已经在发第1个 cur_byte send_buf[BYTE_NUM-1]; send_start 1b1; end end end endmodule这段包了一层“字节级状态控制”最值得注意的细节是外部传入的数据排列顺序和我实际发送的顺序是反着的。级联场景下数据要从最后一级往前送所以我在收到start后第一个发送的是send_buf[BYTE_NUM-1]然后依次往前。这正好对应第2.3节说的发送顺序陷阱用代码结构从根上规避了。这个封装展示了一种通用的“字节调度器”思路底层驱动模块只负责把单个字节按线性状态机挪出去上层模块负责“哪个字节先发、哪个字节后发、什么时候算全部发完”。上下层职责分离代码才经得起改。3.4 关于代码风格与综合注意事项我写这段代码时有几个坚持这里一并分享尽量用非阻塞赋值。状态机、计数器、移位寄存器这种时序逻辑必须统一用非阻塞赋值否则综合结果和仿真行为会对不上。不要在多个always块里对同一个信号赋值。Vivado会报多驱动错误而且这种错误查起来非常浪费时间。复位信号rst_n要设计好。我建议用异步复位、同步释放的策略虽然这个例子只用了简单的异步复位但项目一复杂复位策略一定要提前想清楚。对外引脚时序最好约束。74HC595不是高速器件一般不用写复杂的时序约束但如果你在100MHz级别系统时钟下驱动建议在XDC里把sclk、rclk、sdo这些输出加上MAX_DELAY约束避免布线延迟造成时序违例。4. 仿真测试与板级验证实操4.1 测试平台编写要点驱动模块写完后第一件事是仿真不要急着上板。我用的仿真工具是Icarus Verilog配合GTKWave免费又好用。测试平台的核心思路是模拟一个start脉冲等done脉冲出现检查sclk产生了8个上升沿、rclk在最后产生了一个上升沿、sdo送出去的值和data_in一致。下面是一个简化但能跑的testbench骨架timescale 1ns/1ps module tb_hc595_driver; reg clk 0; reg rst_n 0; reg start 0; reg [7:0] data_in 0; wire sclk; wire rclk; wire sdo; wire done; hc595_driver #(.WIDTH(8)) u_dut ( .clk (clk), .rst_n (rst_n), .start (start), .data_in (data_in), .sclk (sclk), .rclk (rclk), .sdo (sdo), .done (done) ); always #5 clk ~clk; // 100MHz initial begin $dumpfile(tb_hc595.vcd); $dumpvars(0, tb_hc595_driver); rst_n 0; #20; rst_n 1; #20; // 发送0xA5期望高位先出 data_in 8hA5; (posedge clk); start 1b1; (posedge clk); start 1b0; wait (done 1b1); $display(send 0xA5 done); // 在这里可以统计sclk上升沿次数、对比sdo序列 #100; $finish; end endmodule测试平台的写法不多说了重点是你需要自己验证三个“是否”sclk是否正好出现了8个上升沿每个上升沿到来时sdo上的值是否依次是data_in的第7位、第6位……第0位最后一个sclk下降沿之后、rclk是否产生了一个上升沿这三个都没问题驱动时序基本就稳了。4.2 仿真波形应该长什么样把VCD文件用GTKWave打开你会看到这样一段波形过程rst_n拉高后sclk和rclk都保持低电平sdo保持0状态机在IDLE。start拉高一拍sdo立刻变成data_in的最高位shift_buf载入数据。下一拍开始sclk开始以固定频率翻转每翻转一个完整周期sdo变化一次。仔细观察sdo始终是在sclk为低的时候变化在sclk上升沿处数据已经稳定。第8个sclk下降沿之后状态机进入LATCHrclk拉高一拍。DONE状态里rclk拉低done拉高一拍随后回到IDLE。我实测下来的波形就是这样一个规整的时序。如果发现rclk上升沿和sclk上升沿同时出现或者sdo在sclk高电平期间变化就要回去查代码这是时序不对的典型特征。4.3 上板调试与实测记录仿真通过之后再上板我用的是常见的入门级FPGA开发板系统时钟50MHz观察引脚就接了LED和逻辑分析仪。第一次上板我故意用了最笨的观察法接8个LED发送0x55如果LED亮灭呈交替状态说明基本功能通了。但我必须提醒一句接LED只能验证“通没通”验证不了时序细节。想真正确认sclk和sdo的波形关系手里有逻辑分析仪最好没有的话也可以用示波器看。我实测时用逻辑分析仪抓了一帧完整的发送波形发现sclk的翻转频率、sdo的建立时机和仿真完全吻合这时心里才踏实。上板还有一个建议如果板子上的74HC595模块同时接了OE和MR确保OE接地、MR接高电平。有些模块会把MR接到FPGA引脚如果该引脚在FPGA配置期间出现低电平毛刺会错误地清零移位寄存器导致上电后第一次发送数据不对。5. 常见问题与排错技巧实录5.1 数据错位、乱码这一类时序问题我见过最多的问题就是输出灯乱闪或者本应亮的灯没亮、本应灭的灯亮了。这种情况九成出在“数据位和时钟沿的配合”上。排查思路如下先确认发送顺序是高位先行还是低位先行芯片并不关心顺序但你必须知道自己在发哪一位。我用的是高位先行也就是先发data_in[7]这和很多数码管驱动库的习惯一致但如果你在别的参考代码里看到先发data_in[0]那说明对方是低位先行两种都对但不能混。再看sdo是在sclk高电平还是低电平期间变化的。强烈建议在sclk为低时改变sdo这样每次上升沿采样到的都是稳定数据。这个习惯能帮你省掉大量排查时间。最后看sclk的周期是否足够。如果你用系统时钟直接分频产生sclksclk高电平不能短于74HC595的最小脉冲宽度。按数据手册74HC595在5V供电时最高移位时钟能到几十MHzFPGA系统时钟一般100MHz以内不会有问题。但如果你用PLL把时钟跑到200MHz以上sclk高电平时间就可能太短造成采样不稳定。5.2 状态机不工作或“跑飞”的问题排查还有一个常见的故障现象start给上去了但sclk完全没反应。这种问题往往是复位或者信号端口没接对先查rst_n。有些开发板的复位按键是上电高电平、按下低电平如果你的复位逻辑写成高有效那模块会一直停在复位状态什么都不动。再查start信号是不是被综合工具优化掉了。如果start在顶层恒为低状态机永远进不了SHIFT自然没有输出。仿真时一切正常、上板不动多半就是顶层连线问题。还可以用在线逻辑分析仪抓内部state信号。比如Vivado的ILA、Quartus的SignalTap或者热词里提到的“verilog ila插件”直接把state、bit_cnt、sclk拉出来看就能定位是根本没启动还是卡在某个状态一直跳不出去。5.3 五条我踩坑之后总结出来的硬经验整理几条对我自己帮助最大的经验希望你能少走弯路上电先把sclk、rclk拉低不要让这两个引脚处于高阻或未知状态。74HC595对时钟上升沿敏感如果上电瞬间sclk上有个毛刺移位寄存器里就会莫名多出一位输出直接乱掉。锁存脉冲rclk别拉太长。我见过有人让rclk一直处于高电平只在发送前拉低一下这样也不是不能用但容易在系统其他信号抖动时误触发锁存。保持“平时低、发送完拉高一拍”最安全。多字节级联时数据顺序务必从后往前发。这一点我在代码封装里已经做了但如果你不用封装、自己拼多字节驱动一定要记得这个顺序规则否则排查起来非常费劲。发送数据之前最好清一次OE/MR状态。如果74HC595的MR引脚接到了FPGA口线上电后要给MR一个稳定的高电平如果OE没接地而是接到了FPGA口线确保OE为低否则输出端是高阻LED不会亮但数据其实已经在芯片里了。模块一定要加done握手信号。哪怕你现在的项目就只有一颗74HC595也要养成“发送完成后给一个脉冲”的习惯。后面一旦要接更大的系统上层逻辑通过握手信号来控制发送节奏比你自己用delay猜时间要可靠得多。6. 后续扩展建议这块驱动模块做完后你可以顺手往几个方向扩展练手价值很高。一种是做8位数码管动态扫描。在74HC595后面挂数码管用这个驱动模块刷新段选和位选你会立刻体会到“只占三根IO却驱动几十个LED”的快感。另一种是接点阵屏做滚动显示这时候多字节发送和级联就派上用场了。还有一种是把它改造成SPI主机接口因为74HC595的时序本质上是SPI的一种简化形式把线性状态机的思路迁移到SPI主机模块上你会发现自己的状态机设计能力有明显提升。我个人在实际操作中有一个体会74HC595驱动看起来非常基础但它是一个极好的“状态机设计训练场”。花一个下午把时序吃透、把代码写成线性状态机风格后面写UART、SPI、I2C这些更复杂的协议驱动时你会感谢当初这个看起来很简单的练习。