1. 主模式到底主在哪:先搞懂"主/从"对RTL设计的影响
1.1 主设备 vs 从设备:谁说话、谁听话
网上关于状态机的讨论很多,从状态机java到MCU状态机编程,再到给初学者画的环岛状态机比喻,思路其实都是同一个:把复杂流程拆成若干稳定的"位置",每个位置只做固定的事。但落到RTL设计里,真正让状态机变得绕不开的场景,是总线上的主模式(Master)模块。
我给不少初学者改过代码,发现很多人卡住,不是因为状态机不会写,而是没想明白一个问题:我这个模块在系统里到底是"主"还是"从"。这个定位直接决定状态机的设计思路,也决定后面要不要碰三态驱动。
主设备是什么?它是总线上主动发起事务的一方。比如CPU要从外部存储器读数据,CPU就是主,存储器是从。主设备必须自己管理整条时序链路:什么时候拉高请求、什么时候输出地址、等多久采样数据、什么时候结束事务、失败要重试多少次。从设备就简单多了,它只需要盯着别人给的控制信号,准备好数据或者接收数据。
这个差别反映到RTL上很直接:主模式的模块,状态机是骨架,三态驱动是手臂。状态机决定"下一步干什么",三态驱动解决"在共享数据总线上怎么该听谁的话"。两者缺一不可。
1.2 主模式控制器的共同形态:请求、事务、总线
我推荐大家用"外部总线接口主控制器"来练习主模式RTL,比如读一个并行SRAM或者并口Flash。这个项目麻雀虽小五脏俱全,包含了主模式设计的三个关键要素:
- 请求接口:别人(比如CPU逻辑)发来读/写请求,主控制器接收后转入忙状态。
- 事务状态机:管理地址建立、读写时序、数据锁存这一整条流程。
- 共享数据总线:数据引脚是inout,读的时候必须释放成高阻让外设驱动,写的时候才由主控制器驱动。
这三个要素挨个拆开都不难,但组合起来就会出现经典问题:状态转移是写对了,可三态使能时序错了,仿真波形里数据总线一会儿是高阻一会儿又是未知态,连挂在同一条总线上的其他器件都被带坏了。
所以这一讲我打算用一个实际的SRAM主控制器例子,把状态机和三态放在同一条设计链路里讲清楚。
2. 三段式状态机为什么是主模式设计的主心骨
2.1 一段式、二段式、三段式到底差在哪
很多教材喜欢把状态机分成一段式、二段式、三段式,考试也爱考。但实际工程里,三段的优势要在稍微复杂一点的时序场景里才体现得出来,比如我们正在做的这个主模式读写控制器。
一段式:单进程,把状态跳转和输出写在一个always块里。写法直接,状态少的demo没问题,但一旦状态多了,组合逻辑和时序逻辑混在一起,每个状态改输出时都容易搞出锁存器,仿真后仿一乱,很难查。
二段式:第一段是状态寄存器,第二段是组合逻辑,同时处理次态计算和输出译码。二段式比一段式清晰,但输出是组合逻辑,容易产生毛刺。主模式控制器的控制信号(比如OE_n、WE_n)直接连外部器件,毛刺是致命的。
三段式:状态寄存器一段,次态逻辑一段,输出一段。输出那段如果是组合逻辑,可以在时序上打一拍来消除毛刺;时序上允许的话,直接把输出写成寄存器,连外部信号更干净。
这么一对比就清楚了。正因为主模式控制器要对时序敏感的外部器件发号施令,输出信号必须干净、可预测,三段式是最稳的选择。还有一点:三段式的"第三段"写起来很直白,可读性也高,后续别人接手好维护。
2.2 一个可以直接改用的三段式状态机骨架
下面这个例子是一个典型的SRAM主读状态机骨架,三段式结构。我删掉了一些细节,方便看清楚三段的分工:
module sram_master #( parameter ADDR_W = 16, parameter DATA_W = 16 )( input wire clk, input wire rst_n, // 主模式请求接口 input wire req, input wire wr_n, input wire [ADDR_W-1:0] addr, output reg busy, // 外部SRAM接口 output reg [ADDR_W-1:0] a_out, output reg ce_n, output reg oe_n, output reg we_n, output reg [DATA_W-1:0] d_out, output reg d_out_en, inout wire [DATA_W-1:0] d_io ); localparam [2:0] IDLE = 3'd0, READ_SETUP = 3'd1, READ_ACCESS = 3'd2, READ_LATCH = 3'd3, WRITE_SETUP = 3'd4, WRITE_DRIVE = 3'd5, TURN_AROUND = 3'd6; 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 (req && !wr_n) next_state = WRITE_SETUP; else if (req && wr_n) next_state = READ_SETUP; READ_SETUP: next_state = READ_ACCESS; READ_ACCESS: next_state = READ_LATCH; READ_LATCH: next_state = TURN_AROUND; WRITE_SETUP: next_state = WRITE_DRIVE; WRITE_DRIVE: next_state = TURN_AROUND; TURN_AROUND: next_state = IDLE; default: next_state = IDLE; endcase end // 第三段:输出与时序逻辑(寄存器输出) always @(posedge clk or negedge rst_n) begin // 下面接着写 end endmodule骨架里第二段有个容易被忽略的细节:case里默认先赋值next_state = state再写转移。这样可以避免组合逻辑产生锁存器,也省得每个分支都写一堆不跳转的默认路径。我用这个习惯好多年了,坑少很多。
第三段输出我放到下一节跟读写事务一起讲,因为光给状态机不给时序配合,还是看不出主模式控制器到底在忙什么。
3. 读写事务的时序拆解:状态图与数据通路的配合
3.1 读事务:从IDLE到数据锁存的完整路径
读一个地址单元,主控制器要做的表面动作是拉低片选、拉低读使能,其实内部数据总线的方向切换比这微妙得多。我把读路径逐一拆开:
- IDLE:所有控制信号复位到无效电平,d_out_en拉低,数据总线是高阻。这一步的意义是:在没有任何事务的时候,主控制器不能占着数据总线。
- READ_SETUP:输出地址,拉低CE_n。此时数据总线仍然保持高阻。容易犯的错是在这个状态就把OE_n拉了,甚至拉低d_out_en去驱动数据总线——都早了。
- READ_ACCESS:拉低OE_n,数据总线继续保持高阻,等外部存储器驱动数据。这个等待周期长短取决于存储器的访问时间,短了读到的数据是错的。这就是为什么状态机里要有独立的等待状态。
- READ_LATCH:高阻状态下,把d_io上的数据采样进内部寄存器。这个是读事务最核心的时刻。采样一定要在OE_n有效且数据稳定之后,不能跟OE_n同时发生。
- TURN_AROUND:撤销OE_n、CE_n、地址。这个周期就是总线方向切换的缓冲期,防止后面紧接着写事务时,外部存储器还在驱动总线,而这边已经切到输出了。
对应第三段输出逻辑:
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin busy <= 1'b0; ce_n <= 1'b1; oe_n <= 1'b1; we_n <= 1'b1; d_out_en <= 1'b0; d_out <= {DATA_W{1'b0}}; a_out <= {ADDR_W{1'b0}}; end else begin case (state) IDLE: begin busy <= 1'b0; ce_n <= 1'b1; oe_n <= 1'b1; we_n <= 1'b1; d_out_en <= 1'b0; end READ_SETUP: begin busy <= 1'b1; ce_n <= 1'b0; a_out <= addr; end READ_ACCESS: begin oen <= 1'b0; end READ_LATCH: begin // data_reg <= d_io; 内部数据寄存器的采样 end TURN_AROUND: begin ce_n <= 1'b1; oe_n <= 1'b1; end // 写事务分支见下文 endcase end end这段代码里有个原则:每个状态只改变该改变的信号,别的信号保持默认值。这样做的好处是,一个状态的输出行为看一小段代码就能完全确定,不会出现"这个信号为什么在这里被莫名其妙改掉"的困惑。
3.2 写事务与读写切换里的隐藏陷阱
写事务比读事务多一个麻烦:主设备既要输出目标地址,又要在数据总线上驱动数据,还要用WE_n做节拍控制。WRITE_SETUP状态里输出地址、CE_n拉低,数据总线保持高阻;WRITE_DRIVE状态里d_out_en拉高,把d_out推到总线上,同时拉低WE_n,再放一段时间,让外设可靠采样。
看起来顺利,但真正让我吃过亏的是读写切换。如果上一个周期是读事务,外部器件还在驱动数据总线,下个周期立刻进入写事务,你瞬间拉高d_out_en,同一个net上就有两个驱动源,仿真波形直接出X,综合后则是总线冲突,严重的情况长期拉低外设内部信号,把存储器接口烧了。
所以主模式状态机的最后一个状态几乎都是TURN_AROUND。这个状态存在的意义不是说"我已经干完了,准备回家",而是给总线释放方向切换留出台面时间。我见过有人在TURN_AROUND里什么都懒得做,就空转一个周期,其实这恰恰是它的作用。还有一个细节:TURN_AROUND里要把CE_n、OE_n、WE_n全部回到无效电平,否则外设可能认为又启动了一次新的读写。切换严苛的时候,IDLE里都要保留一个完整周期再响应下一笔请求。
4. 三态驱动:inout端口不是简单的三选一
4.1 三态门、高阻与双向总线的物理意义
三态驱动这个概念,很多初学者是在芯片引脚上第一次见到的:一个引脚怎么既能输入又能输出?奥秘在于高阻态,它既不算逻辑0,也不完全等于逻辑1,而是让引脚处于"断开连接"的状态,相当于这个引脚从电路上主动撤退,把总线让给别人。
一位网友的说法很形象:三态驱动就像你站在一条单行道上,别人要用这条路的时候,你就退到路边;你要走这条路的时候,你得确认别人已经退开了再迈步。RTL里对应"退到路边"的动作,就是把输出使能信号d_out_en拉低,把inout引脚和对外的驱动寄存器断开。
物理上,FPGA的引脚内部是基于三态缓冲器实现的:一个输入通路、一个输出通路、一个输出使能控制。RTL层面我们只是表达控制逻辑,综合工具把它映射到真正的三态缓冲器上。你要做的是把方向控制逻辑写对,剩下的交给工具。
assign d_io = d_out_en ? d_out : {DATA_W{1'bz}};这一行assign就是三态驱动配置的核心。注意几点:
- d_out_en必须是明确的时序信号,不能有毛刺,否则总线会出现瞬间错位。
- 读事务时d_out_en保持0,所以外部器件驱动的数据会直接出现在d_io上,内部通过
read_data = d_io采样。 - 写事务时d_out_en为1,d_io上就是d_out的值,外部器件去采样就行。
4.2 inout代码的三种常见错误与正确写法
我在给初学者看代码时,inout相关的错误出现频率最高,而且错误模式非常集中。我列一个对照表:
| 错误做法 | 现象 | 正确思路 |
|---|---|---|
| 把inout直接用reg声明 | 编译报错,或仿真结果完全对不上 | inout必须是wire/net类型,驱动通过assign完成 |
| 忘记拉低d_out_en就进入读事务 | 总线双驱动,仿真出现X,硬件可能冒烟 | 读事务前必须有一个周期把d_out_en设0 |
| 读写切换间没有TURN_AROUND周期 | 仿真波形有毛刺,长时间运行后外设行为异常 | 状态机末尾插入至少一个总线方向切换周期 |
还有一类隐藏很深的错误:内部信号通路写对了,但顶层例化的时候,inout引脚既没在顶层assign,也没连到真正的pad,仿真里看d_io波形永远是Z。用内部信号做inout端口必须在顶层用正确方式接线,不能只把它悬空。
代码层面,关键是让d_out_en和状态机严格对齐。不要单独去设计一个"当前忙我就输出"的逻辑,那样输出方向会和状态机脱节,时序上容易出孔。正确做法是直接在状态机第三段里,根据状态给d_out_en赋值:WRITE_DRIVE给1,其他状态给0。这样状态机既是流程控制中心,也是总线方向控制的唯一来源。
5. 在Modelsim里把状态机和三态调到明明白白
5.1 Modelsim能不能看RTL电路图?能,而且该这样用
搜索"modelsim中能不能查看rtl电路图"的人很多,说明大家都有这个需求。答案是能,ModelSim确实提供RTL视图查看功能:编译完设计之后,在源码窗口或者Workspace里选中模块,通过View菜单里的RTL Viewer就能打开综合前的寄存器传输级电路结构。你在这里能看到状态机的实现结构、各个always块对应的逻辑、数据通路的方向,以及inout端口是怎么挂在三态缓冲器上的。
不过我要说句实在话:RTL Viewer这个东西,用来快速审查模块连接关系是有用的,例如检查一个信号有没有被莫名其妙多驱动,看inout引脚旁边有没有挂正确的三态缓冲器。但如果指望它帮你查状态机为什么跳错,效率反而低。更直接的工具是ModelSim的状态机视图(State Machine Viewer),它能把状态机的寄存器状态和跳转条件画成状态转移图,比对着代码翻case清晰得多。你会发现前面讲的三段式结构,在状态机视图里是一目了然的一张图。
需要注意的是ModelSim不同版本里这个功能的位置有差异,有些面向教育的基础版本只保留部分查看能力,如果你打开没看到相关菜单,先确认版本和授权,不要以为代码错了。
5.2 用波形把三态总线"看穿"的实操方法
查状态机和三态问题,真正高效的做法永远是分两层看波形:一层看状态机控制流,一层看总线电平。
先在工作区把关键信号摆到一组:clk、state、next_state、busy、ce_n、oe_n、we_n、d_out_en、d_io,以及一个内部采样寄存器。这样摆的目的,是让"状态"和"引脚行为"在时间上对齐,一眼能看出每个状态对应的总线动作。
我调试时有一个判断顺序,分享给大家:
- 检查状态是否顺序跳:如果state卡在某个状态不动,优先看第二段次态逻辑的转移条件,注意是不是请求信号req没进来,或者被自己拉起的busy挡住了。这是主模式最常见的自锁问题。
- 检查读事务里d_io:应该先是Z,然后外部器件驱动的数据稳定出现。如果d_io上全程是Z,说明存储器那边没有工作——去看CE_n和OE_n时序;如果d_io上出现X,极有可能是d_out_en没有在正确状态归零,内部驱动和外设驱动打架了。
- 检查写事务里d_out_en:拉高的时间必须与WE_n的有效区间匹配。如果d_out_en在IDLE态还保持高电平,总线就一直被内部逻辑占着,接下来任何人的读操作都被你害了。
多调几次你会发现,几乎所有三态问题在波形上都有非常明显的特征:该Z的地方出了X,或者该X的地方出了持续的高电平。前者基本是多驱动,后者基本是忘释放总线。对着波形按这几条查,比在代码里空想要快得多。
另外一个小技巧:把state信号用符号化显示,即把3'd0映射成IDLE的名字,这样波形窗里看到的不是一列数字,而是可读的状态名。摆波形的时候顺手做掉,排查速度能再快一截。
ModelSim这种仿真工具,核心价值不在于"画了一张多漂亮的电路图",而在于能让你把时间维度的信号行为看清楚。状态机和三态驱动的联合调试,恰恰是时间关系最容易混乱的场景。状态机负责"什么时候",三态负责"谁来驱动",用波形把这两件事对齐了,主模式RTL设计里最难啃的骨头也就啃完了。