news 2026/9/28 1:19:01

状态机与三态驱动:主模式总线控制器的Verilog RTL设计实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
状态机与三态驱动:主模式总线控制器的Verilog RTL设计实战

状态机是数字设计里最像“骨架”的东西,而三态驱动又是很多新人在仿真里最头疼的地方。这一讲正好把两者串起来:从零设计一个主模式(Master)总线控制器,地址、读写控制都由我们发起,数据总线用双向三态口跟外部从设备打交道。学完之后你不仅能写出一段可综合的RTL,还能知道仿真里那些 X 态、高阻悬空到底是怎么来的,以及综合工具面对 inout 端口时都在干什么。

如果你正在做 FPGA 接口设计、MCU 外设控制器,或者单纯想把状态机和三态总线的关系理顺,这篇内容基本就是为你准备的。我们不讲空泛的概念,直接从接口规划、状态跳转、三态生成逻辑写到 testbench 验证,全程附带能直接落地的 Verilog 代码。

1. 主模式总线控制器的功能边界:先定接口再写代码

不少同学写 RTL 的习惯是打开编辑器就开始敲状态机,敲到一半发现信号缺了、时序错了,又回头改。我的建议永远是:先把模块当成一个黑盒子,把接口和功能边界定义清楚,再动手写状态机。

1.1 主模式到底意味着什么

“主模式”是相对于“从模式”说的。总线上谁发起读、写请求,谁就是主设备;谁被动响应这个请求,谁就是从设备。就我们这次设计的外部总线控制器而言,它内部的状态机就是整个读写字过程的总指挥:地址什么时候输出、片选什么时候拉低、数据什么时候驱动或释放,全由它控制。

以 MCU 通过外部总线扩展 SRAM 为例,CPU 发起一次读操作,我们的控制器要完成:

  1. 输出目标地址
  2. 产生片选和读使能信号
  3. 释放数据总线,等待 SRAM 把数据推上来
  4. 在正确的时钟沿采样数据
  5. 结束本次读操作,释放所有控制信号

写操作则多一步:控制器主动把数据放到总线上,并拉低写使能,让从设备在指定时刻锁存数据。

这里的关键认知是:主模式控制器不仅要控制自己的行为,还要管理总线的“占用权”。数据总线是双向的,谁占有总线、谁释放总线,都必须被状态机安排得明明白白。这也是这一讲为什么把状态机和三态驱动放在一起讲的原因。

1.2 顶层接口定义与数据通路

我习惯先把输入输出信号列成一张表,相当于给模块画一张“身份证”。下面是我们这次设计用到的信号:

信号名方向位宽功能说明
clk_iinput1系统时钟
rst_n_iinput1异步复位,低有效
start_iinput1启动一次传输
addr_iinput16目标地址
wr_data_iinput8写数据
rd_data_ooutput8读到的数据
done_ooutput1传输完成标志
err_ooutput1超时错误标志
addr_ooutput16对外地址总线
cs_n_ooutput1片选,低有效
wr_n_ooutput1写使能,低有效
rd_n_ooutput1读使能,低有效
data_ioinout8双向数据总线

内部数据通路我把它拆成三块:

  • 状态控制逻辑:状态机的状态寄存器和次态组合逻辑,负责产生各阶段的控制信号
  • 地址锁存与输出:把 addr_i 锁存到 addr_o,整个传输期间保持不变
  • 三态数据驱动:根据当前状态决定 data_io 是输出数据、释放为高阻,还是作为输入采样

这种划分的好处是每个模块的责任单一,调试时定位问题很快。如果你把状态机和三态控制写在一个 always 块里,看起来省事,但后面加超时检测、加总线仲裁时会非常痛苦。

2. 状态机的具体定义:从画圈到可综合代码

状态机不是画一张漂亮的跳转图就算完,关键是把每个状态的进入条件、退出条件、输出行为都定清楚。B 站上很多教程喜欢堆状态图,但真正到了 RTL 层面,我关心的只有三件事:状态编码、次态逻辑、输出逻辑。

2.1 状态设计与跳转条件

以一次最简单的 8 位总线读写为例,我定义了 5 个状态:

状态含义关键行为
IDLE空闲总线全部释放,等待 start_i
ADDR_PHASE地址阶段输出地址、拉低片选,插入若干等待周期
WRITE_PHASE写数据阶段驱动数据总线,拉低写使能
READ_PHASE读数据阶段释放数据总线,等待从设备驱动,采样数据
DONE完成撤销所有控制信号,回 IDLE

跳转条件如下:

  • IDLE 收到 start_i 后进入 ADDR_PHASE,否则保持
  • ADDR_PHASE 等待固定周期(我用 2 拍)后:写操作去 WRITE_PHASE,读操作去 READ_PHASE
  • WRITE_PHASE 维持 1 拍后进 DONE
  • READ_PHASE 维持 2 拍,第 2 拍边沿采样数据,之后进 DONE
  • DONE 无条件回 IDLE,同时拉高 done_o 一个周期

有人会问,读总线为什么要等 2 拍?因为三态总线释放以后,从设备驱动数据需要时间;即便不考虑外部建立时间,仿真器对高阻到有效数据的切换也需要一个周期来更新信号,如果采样沿和释放沿太近,很容易采到旧值或 X 态。很多新人第一次仿真读操作读回来全是 X,十有八九就是这个原因。

2.2 三段式状态机的 RTL 写法

代码方面,我推荐使用三段式状态机:第一段同步状态切换,第二段组合逻辑计算次态,第三段根据当前状态或次态产生输出。下面是核心代码:

// 状态编码 localparam IDLE = 3'b000; localparam ADDR_PHASE = 3'b001; localparam WRITE_PHASE = 3'b010; localparam READ_PHASE = 3'b011; localparam DONE = 3'b100; reg [2:0] state_c; reg [2:0] state_n; // 第一段:状态寄存器 always @(posedge clk_i or negedge rst_n_i) begin if (!rst_n_i) state_c <= IDLE; else state_c <= state_n; end // 第二段:次态组合逻辑 always @(*) begin case (state_c) IDLE: begin if (start_i) state_n = ADDR_PHASE; else state_n = IDLE; end ADDR_PHASE: begin if (addr_cnt == 2'd2) state_n = (wr_en_i) ? WRITE_PHASE : READ_PHASE; else state_n = ADDR_PHASE; end WRITE_PHASE: state_n = DONE; READ_PHASE: begin if (rd_cnt == 2'd2) state_n = DONE; else state_n = READ_PHASE; end DONE: state_n = IDLE; default: state_n = IDLE; endcase end // 第三段:输出逻辑(这里用组合逻辑,时序紧张时建议寄存一拍) always @(*) begin cs_n_o = 1'b1; wr_n_o = 1'b1; rd_n_o = 1'b1; done_o = 1'b0; case (state_c) ADDR_PHASE: begin cs_n_o = 1'b0; end WRITE_PHASE: begin cs_n_o = 1'b0; wr_n_o = 1'b0; end READ_PHASE: begin cs_n_o = 1'b0; rd_n_o = 1'b0; end DONE: done_o = 1'b1; endcase end

这里我使用的是当前状态驱动输出,也就是 Moore 型状态机,好处是输出在某个状态内保持稳定,不会受输入信号毛刺影响。如果你用次态驱动输出(Mealy 型),虽然能省一拍时序,但组合逻辑路径长了很容易成为时序瓶颈。

2.3 状态机的复位与初始化细节

状态机的复位必须想清楚是异步复位还是同步复位。代码里我用了异步复位,rst_n_i一旦拉低,不管时钟在哪,状态立刻回到 IDLE。这在 FPGA 里非常常见,因为复位信号一般来自按键、上电监控芯片或 JTAG 调试器。

但这里有个工程问题:当你在 DFT(可测试性设计)模式下做扫描测试,这条异步复位链怎么处理?简单说,DFT 会在扫描移位阶段强制让每个寄存器进入确定状态,复位信号不能干扰扫描链;所以综合脚本里通常会把复位树单独约束,或者给复位加一个测试模式旁路。不过对新手来说,你只要明白:状态机的复位值必须保证上电后进入一个安全状态,最好所有输出都是释放态,不要让复位瞬间片选还拉着低电平——不然外部器件可能被误操作。

状态编码也值得多说一句。IO 资源紧张的时候,有人喜欢用独热码(One-Hot),有人用二进制编码。二进制编码状态寄存器最少,但相邻状态跳转可能出现多位翻转,在 FPGA 上没有太大影响;ASIC 上如果时序余量紧张,可以考虑格雷码。这里用了二进制编码,没想到后面实际综合时资源占用非常友好,逻辑层级也比独热码少一层。

3. 三态驱动的实现与常见误区

三态驱动看起来就一句话:assign data_io = oe ? data_out : 8'bZ;但在真实项目中,这句话前后要处理的事情很多。三态总线的本质是“谁在驱动总线,谁在采样总线”,以及“切换驱动权的时刻是否安全”。

3.1 三态 IO 的 Verilog 表达方式

先看正确的写法:

reg [7:0] wdata_reg; reg [7:0] rdata_reg; wire [7:0] data_from_slave_tmp; // 输出使能:只有写数据阶段才驱动总线 wire data_oe = (state_c == WRITE_PHASE); // 三态驱动器 assign data_io = data_oe ? wdata_reg : 8'bZ; assign data_from_slave_tmp = data_io; // 读数据阶段采样 always @(posedge clk_i or negedge rst_n_i) begin if (!rst_n_i) rdata_reg <= 8'h00; else if (state_c == READ_PHASE && rd_cnt == 2'd1) rdata_reg <= data_from_slave_tmp; end

这里有几个细节必须注意:

  1. inout 端口在 RTL 里必须声明为 wire 类型,如果你用 SystemVerilog,logic不能直接用来做 inout 端口,综合工具会报“output port must be a net”之类的错。这个坑我在项目里见人踩过不止一次。
  2. 三态驱动器建议单独用 assign 语句,不要写在 always 块里。一些旧式写法会用 bufif1 或 tran 原语,但可综合风格里 assign 加条件表达式就是最清晰的做法。
  3. 读取回环路径要显式表达。把 data_io 直接喂给采样寄存器,也就是先assign data_from_slave_tmp = data_io;再在采样沿打拍。不要图省事直接在 always 块里用 data_io,有些仿真器对 inout 端口直读会有怪异行为。

3.2 驱动权切换的时序窗口

三态总线最容易出问题的时刻,是主机从“自己驱动总线”切换到“释放总线等待从设备驱动”的瞬间。打个比方,一条路本来你是司机,现在你要下车,让另一个人来开;那么你得先解开安全带、离开驾驶位,对方才能坐进来。如果你刚解安全带就踩油门,两个人肯定打起来——也就是总线竞争。

在我们的设计里,从写操作结束到下一次读操作,状态机至少要留出一拍让输出使能彻底释放,主机才能安全地采样外部数据。我刚才在 READ_PHASE 里等了 2 拍,其中第 1 拍本质上是“换司机”时间,第 2 拍才是有效采样点。实际项目中,这个间隔往往由外部存储器的读时序参数决定,比如 SRAM 的地址建立时间、数据输出有效时间等。建议在状态机里把等待周期参数化成常量或者寄存器,方便后续适配不同速度的外部器件。

读操作结束也有类似问题。采样完成之后,从设备还在驱动总线,如果主机马上进入下一个写操作并开始驱动数据总线,就会和从设备产生一个短暂的冲突窗口。所以 DONE 状态里我让所有输出使能都拉高,实际上是在等待从设备撤销驱动。对高速片外器件,这个释放延迟还得在时序约束里注明。

3.3 仿真与综合中的悬空处理

三态信号在仿真里最典型的表现是:高阻状态(Z)如果没有外部驱动,会被读成 X。这就导致很多人写 testbench 时发现读数据永远是“xxxxxx”,第一反应是代码写错了。其实代码没问题,是仿真环境里缺少上拉或从设备模型。

有两种常见解决办法:

// 方法一:在 testbench 里给总线加弱上拉 pullup(data_io); // 方法二:用从设备模型驱动总线,仿真时强制赋值 // 从设备模型内部维护一个 reg 数组,读操作时把数据推上总线

方法二更接近真实硬件。我们可以写一个简单的存储器行为模型,它在片选和读使能有效时,把对应地址的数据赋值给总线;在写使能有效时,采样总线上的数据存入内部数组。这样读到的数据就是确定值,而不是 X。

综合方面,inout 引脚最终会映射到芯片的 IO Pad 结构上。FPGA 一般直接对应一个 IO BUFT 或可配置的 IO 单元;ASIC 则是库里的 PAD cell,带输出使能端。所以 RTL 中的三态驱动信号最终会被综合工具自动连接到 PAD 的 OE 端口上。你唯一要做的,是在约束文件里告诉工具哪些引脚是双向的,并设置合适的驱动强度和上拉配置。

4. 仿真验证的重点与异常场景

我见过不少工程师,RTL 写完激动得不行,结果 testbench 里一跑全是 X 态,然后开始焦头烂额地一个一个查。这一节把仿真里最容易踩的坑和该关注的东西都过一遍。

4.1 自检 testbench 的核心框架

宁可 testbench 写得丑,也不能不写。最简单的自检测试环境包含三部分:时钟生成、复位释放、事务激励。下面是一个可运行的框架:

module tb_bus_ctrl; reg clk_i; reg rst_n_i; reg start_i; reg [15:0] addr_i; reg [7:0] wr_data_i; wire [7:0] rd_data_o; wire done_o; wire err_o; wire [15:0] addr_o; wire cs_n_o, wr_n_o, rd_n_o; wire [7:0] data_io; // 外部存储模型 reg [7:0] mem[0:255]; reg [7:0] data_from_mem; bus_ctrl u_dut( .clk_i(clk_i), .rst_n_i(rst_n_i), .start_i(start_i), .addr_i(addr_i), .wr_data_i(wr_data_i), .rd_data_o(rd_data_o), .done_o(done_o), .err_o(err_o), .addr_o(addr_o), .cs_n_o(cs_n_o), .wr_n_o(wr_n_o), .rd_n_o(rd_n_o), .data_io(data_io) ); // 从设备行为模型:低有效读写,SRAM 风格 always @(*) begin if (!cs_n_o) begin if (!wr_n_o) begin // 写操作,把总线数据写入 mem,注意这里要用阻塞赋值特性模拟 mem[addr_o] = data_io; data_from_mem = 8'bZ; end else if (!rd_n_o) begin // 读操作,把存储数据驱动到总线 data_from_mem = mem[addr_o]; end else begin data_from_mem = 8'bZ; end end else begin data_from_mem = 8'bZ; end end assign data_io = (!cs_n_o && !rd_n_o) ? data_from_mem : 8'bZ; initial begin clk_i = 0; forever #5 clk_i = ~clk_i; end initial begin rst_n_i = 1'b0; #20 rst_n_i = 1'b1; // 写一个字节:地址 0x0010,数据 0x5A @(posedge clk_i); start_i <= 1'b1; addr_i <= 16'h0010; wr_data_i <= 8'h5A; @(posedge clk_i); start_i <= 1'b0; // 等待 done wait (done_o === 1'b1); $display("Write transaction done at time %t", $time); end endmodule

这个框架的重点是从设备模型必须跟真实器件行为一致:写的时候锁存数据,读的时候把数据往总线上放,其他时间输出高阻。如果你只是拉根线把数据和总线绑死,那仿真也就失去了意义。

4.2 读数据的 X 态陷阱

我自己的调试经历里,读数据出现 X 态大约有这几种来源:

  • 复位后没有初始化:内部 rdata_reg 没有复位值,输出之后被其他逻辑读出 X。这个最简单,加复位赋值就行。
  • 三态总线悬空被读取:主机在释放总线后立刻采样,从设备还没来得及驱动,此时总线是 Z,仿真器报告为 X。解决办法是把采样点往后移一拍。
  • 多驱动源冲突:主机和从设备同时驱动总线,总线上的值由多个驱动源共同作用,仿真器报 X 或不定值。这在显示上很隐蔽,因为波形可能看起来是稳定的,但数值逻辑上不对。排查方法是对每个三态信号的 oe 信号单独拉出来看波形,确认 oe 之间没有重叠。

建议你在仿真波形里把 data_io、data_oe、rd_n_o、wr_n_o 这几根信号放一起观察。看到 oe 拉高和从设备驱动有效之间有任何一个时钟周期的重叠,就要警惕。

4.3 状态机死锁与超时保护

真实世界里,外部器件可能没有应答(比如地址错误、器件掉电),状态机如果一直在 READ_PHASE 里等,整个控制器就卡死了。因此成熟的控制器都有一个超时计数器:进入 READ_PHASE 后最多等 N 拍,如果从设备一直没把数据驱动到总线上(不是高阻状态),就强制跳 DONE,并置起 err_o。

localparam TIMEOUT_CNT = 8'd50; reg [7:0] timeout_cnt; always @(posedge clk_i or negedge rst_n_i) begin if (!rst_n_i) timeout_cnt <= 8'd0; else if (state_c == READ_PHASE) timeout_cnt <= timeout_cnt + 1'b1; else timeout_cnt <= 8'd0; end always @(*) begin if (timeout_cnt >= TIMEOUT_CNT) state_n = DONE; // 同时用 err_o 标志超时 end

我额外提个建议:写 testbench 的时候,专门构造一个“从设备不响应”的场景,验证 err_o 是否能在指定周期内拉高。这类异常路径平时不测,一旦上板出问题,绝对让你加班到怀疑人生。

5. 工程化落地与扩展思路

到这里,一个主模式总线控制器的核心 RTL 已经完整:状态机负责时序,三态驱动负责总线切换,testbench 负责验证。最后聊几个工程化相关的问题,方便你把代码移植到真实项目里。

5.1 多主设备与总线仲裁的衔接

我这套设计默认总线上只有一个主设备,也就是咱们的控制器。但真实系统里很可能有 CPU、DMA、调试接口等多个主设备都要访问总线。这时不能简单地把几个三态驱动器直接并在一起,否则就是“两个司机抢方向盘”。

正确思路是给每个主设备增加一根总线请求信号和总线授权信号,由一个仲裁器统一分配使用权。只有获得总线授权的主设备才能把 oe 拉高,其他主设备即使内部有数据输出,也必须保持在 Z 状态。仲裁逻辑一般会带优先级或轮询机制,这部分以后可以单独开一篇讲。你的主设备侧只需要预留bus_grant_i输入,在没拿到授权时强制让 oe 无效。

5.2 用仿真波形反推约束的进阶方法

也许你会发现,同样的 RTL,在功能仿真全部正确的情况下,综合实现后上板就是偶发数据错误。这时候优先检查三态总线在综合后的时序约束是否准确。FPGA 里一般要把 inout 引脚的 max delay / min delay 约束到 IO buffer 上,同时保证 oe 路径和输出数据路径的时钟偏斜不能太大,否则在快速切换时总线会出现毛刺。

具体做法可以参考你手里的开发板示例工程,它的 XDC/SDC 文件里通常已经有 IO 约束模板,重点看set_property IOSTANDARD、DRIVE、PULLUP这些参数。项目初期就把这些约束配齐,后面省去大量排查时间。

5.3 还可以往哪个方向扩展

如果你已经吃透了这套设计,下面几个方向随便挑一个练手:

  1. 增加突发(burst)传输:连续读写多个地址,状态机里加一个 burst_cnt,每拍自动递增地址,直到突发长度结束。
  2. 流水线与 FIFO:当前设计每笔读写都要等状态机走完一遍,如果 CPU 频繁读写,效率不高。把读写请求做成 FIFO + 流水线状态机,吞吐会明显提升。
  3. 参数化总线位宽:把 8 位数据总线改成可配置的 16/32 位,你会发现三态驱动的数量、地址对齐方式、状态机等待周期全都跟着变,对理解接口可复用性很有帮助。

我个人在实际调试中的最大体会是:三态驱动的问题从来不是三态本身,而是总线的所有权切换没想清楚。每次你在代码里看到一个 inout,第一反应就应该是问自己:这一拍,到底是谁在驱动它?是不是存在两个驱动都处于使能状态的窗口?把这个问题养成肌肉记忆,你再回头看状态机的每个状态,会突然觉得视野清晰很多。

如果这一讲的内容你打算动手实现,我的建议是先从最简单的 SRAM 模型开始,把读写时序跑通之后,再加超时和错误处理,最后才考虑多主仲裁。一步一步来,比一次写完整个复杂系统要稳得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 1:19:02

昆山做网站价格揭秘:备案避坑3大注意事项

昆山做网站价格揭秘:备案避坑3大注意事项 备案流程一头雾水?别慌,昆山做网站价格里最容易被忽视的,其实是那些藏在合同细节里的“注意事项”。很多老板觉得建站就是写代码,花了钱还得不到想要的效果,根源往往出在前期没把备案和后续维护的坑填好。 SEO原理速懂:为什么你的站没流量…

作者头像 李华
网站建设 2026/9/28 1:18:59

3年建站避坑:一文搞懂seo优化收费真相

3年建站避坑:一文搞懂seo优化收费真相 别被那些“模板网站太丑不够用”的焦虑吓到,也别盲目相信低价包年。很多老板刚做完站,发现排名纹丝不动,一问才知道,之前买的只是“模板”,并没有包含真正的【seo优化收费】服务。今天不整虚的,结合我10年操盘经验,带你 一文搞懂 这背后的门道。…

作者头像 李华
网站建设 2026/9/28 1:18:58

避开高价陷阱:用免费工具选出最好的素材网站

避开高价陷阱:用免费工具选出最好的素材网站 找建站公司怕被坑高价?别慌,先用免费工具自查需求。 最好的素材网站不是最贵的,而是能省钱的。 拒绝信息差,用数据说话,把每一分预算都花在刀刃上。 运营目标与指标:别被“高价”绑架,先算清ROI 很多老板一上来就问:“做个网站多少钱?”…

作者头像 李华
网站建设 2026/9/28 1:18:51

浙江建设职业技术学院继续教育学院网站搭建5个避坑注意事项

浙江建设职业技术学院继续教育学院网站搭建5个避坑注意事项 网站做好了没人访问,这比做不出网站更让人崩溃。很多做高校继续教育官网或配套工具站的朋友,代码写得再漂亮,上线后打开率惨不忍睹。问题往往不出在功能,而出在那些容易被忽略的 注意事项 上。今天咱们不聊虚的,直接拆解怎么把一个符合 W3C…

作者头像 李华
网站建设 2026/9/28 1:18:38

网站建设元避坑指南:搞懂域名服务器与完整流程

网站建设元避坑指南:搞懂域名服务器与完整流程 域名填错一个字母,服务器选错一个配置,几百块的注册费打水漂不说,网站上线后流量进不来,客户找不到你,这种糟心事在创业团队里太常见了。很多人以为买好域名、租个服务器就万事大吉,结果卡在工信部ICP备案系统的审核上,或者网站加载慢得像蜗牛,用户体验一塌糊涂。…

作者头像 李华
网站建设 2026/9/28 1:18:35

C#手动实现MODBUS TCP通讯:Socket底层拼包与工业级可靠性设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华