状态机训练绕不开序列检测,这算是Verilog训练笔记里最经典的开题。我这次要总结的是非重叠序列检测,简单说就是在串行输入流里识别一个指定的bit序列,识别完一遍之后立刻把状态清空重新开始,绝不允许跟前一段检测共用任何一个bit。听起来很好懂,但真正写代码时,不少人在“重叠”两个字上翻车——同样是检测1011,输入1011011,有人输出一次,有人输出两次,谁对谁错完全取决于题目里有没有“非重叠”。这篇文章就围绕检测序列1011展开,把状态机方案、完整可综合代码、仿真验证、移位寄存器写法都过一遍,也记录几个我实际调试时踩过的坑。适合刚学数字逻辑、准备笔试面试,或者正在用状态机做协议解析的读者。
1. 非重叠检测和重叠检测:同样一段1011011,输出为什么差一倍
1.1 先看一个反直觉的测试序列
“检测连续的1011序列”,这个需求如果不加上“非重叠”三个字,其实是个不完整需求。我给学生上课时经常用同一个输入串演示:
输入串:1 0 1 1 0 1 1
目标是检测1011。这个串里藏了两个可以形成1011的地方:
- 第1位到第4位:
1011 - 第4位到第7位:
1011,因为第4位是1,第5位是0,第6位是1,第7位是1
所以,如果实现的是重叠检测,就会输出两次;如果实现的是非重叠检测,只会输出一次。第一次匹配完成后,最后一个bit如果被当作下一次匹配的开头,那就是在“重用”旧序列里的bit,这就违背了非重叠的定义。
1.2 重叠与非重叠的完整时序对照
把两种行为放在一张表里,差异非常直观。下面这个表里,“匹配末尾”那一列写的是状态机在输出后应该去哪里。
| 周期 | 输入 | 重叠检测的累计状态 | 重叠输出 | 非重叠检测的累计状态 | 非重叠输出 |
|---|---|---|---|---|---|
| 1 | 1 | 已匹配“1” | 0 | S1,已匹配“1” | 0 |
| 2 | 0 | 已匹配“10” | 0 | S2,已匹配“10” | 0 |
| 3 | 1 | 已匹配“101” | 0 | S3,已匹配“101” | 0 |
| 4 | 1 | 完整命中,最后一个1保留为新起点 | 1 | 完整命中,状态回到S0 | 1 |
| 5 | 0 | 从“1”开始,继续匹配“10” | 0 | S0,忽略这个0 | 0 |
| 6 | 1 | 匹配到“101” | 0 | S1,只匹配到“1” | 0 |
| 7 | 1 | 完整命中 | 1 | 仍是S1,需要等0 | 0 |
这个表基本就是两种检测器的规格说明书。你会发现关键差异在第4拍之后:重叠检测会把当前bit的“1”作为新序列的第一个bit留下来,所以第5拍输入0时它已经处于“匹配到10”的状态;非重叠检测则把已经消费掉的四个bit整体丢弃,第5拍的0被当成无效数据忽略掉。
1.3 用“单词匹配”理解非重叠的含义
可以把串行数据流想象成一连串字母,目标序列1011想象成一个单词。非重叠检测的意思是:在一段文本里搜索这个单词,匹配成功一个之后,就从这个单词的最后一个字母后面重新开始找下一个单词,绝不允许这个单词的最后一个字母同时充当下一个单词的第一个字母。
现实中英文文本没有这种连续bit流的尴尬,但数字信号是严格连续的。所以在设计前必须跟需求方确认清楚:检测到一遍之后,是“彻底清空重新累积”,还是“保留最后几个bit继续累积”。这个决策会直接改变状态转移表,也会改变最终电路。
2. 基于Mealy状态机的1011非重叠检测:从状态表到可综合代码
2.1 状态定义与状态转移表推导
检测固定序列1011,用Mealy型状态机最省状态。划分四个状态就够了:
- S0:初始态,还没有匹配到任何有效bit,等待第一个“1”
- S1:已经匹配到序列开头“1”
- S2:已经匹配到“10”
- S3:已经匹配到“101”
状态转移表如下:
| 现态 | 输入0的次态 | 输入1的次态 | 输出说明 |
|---|---|---|---|
| S0 | S0 | S1 | 无输出 |
| S1 | S2 | S1 | 无输出 |
| S2 | S0 | S3 | 无输出 |
| S3 | S0 | S0 | 仅当S3且输入1时输出1 |
表里最需要解释的是最后一行。状态S3表示已经攒齐了“101”,如果这时输入为1,那当前这一bit让整个序列变成“1011”,检测成功,输出1。但非重叠要求清空,所以次态是S0,而不是S1。可能有读者会问:当前这个1为什么不能作为下一个序列的开头?因为严格意义上的非重叠,不允许同一个bit被前后两个序列共享。它已经被消费掉了,下一轮要从下一个新bit开始。
S2状态下,如果输入0,当前累计变成“100”,不可能再以“0”开头形成目标序列,所以次态也回S0。S1状态下如果输入1,累计还是“1”,继续停在S1等0。
2.2 完整可综合代码
按照上面的状态表,用三段式状态机写出来的代码比较清晰。三段分别负责:状态寄存器更新、次态组合逻辑计算、输出逻辑处理。
module seq_detect_1011_nonoverlap( input wire clk, input wire rst_n, input wire data_in, output reg detected ); localparam [1:0] S0 = 2'd0, S1 = 2'd1, S2 = 2'd2, S3 = 2'd3; reg [1:0] state, next_state; // 第一段:状态寄存器 always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= S0; else state <= next_state; end // 第二段:次态组合逻辑 always @(*) begin next_state = S0; // 防止意外综合出 latch case (state) S0: next_state = data_in ? S1 : S0; S1: next_state = data_in ? S1 : S2; S2: next_state = data_in ? S3 : S0; S3: next_state = data_in ? S0 : S0; default: next_state = S0; endcase end // 第三段:输出逻辑,寄存器输出 always @(posedge clk or negedge rst_n) begin if (!rst_n) detected <= 1'b0; else if (state == S3 && data_in) detected <= 1'b1; else detected <= 1'b0; end endmodule这个模块只有两个状态寄存器bit,加一个时钟周期的输出脉冲,综合面积非常小。检测到1011后,detected会在下一个时钟上升沿置高,并维持一个周期,方便下游逻辑采样。
2.3 代码里的关键取舍:为什么输出要打一拍
Mealy状态机的天然输出是组合逻辑,也就是assign detected = (state == S3) && data_in;。这样写更快,data_in一到就能立刻给出结果。但组合输出有一个工程问题:输出直接跟随data_in变化,如果data_in上有毛刺,detected上也会出现毛刺;而且在时序分析里,组合输出直接接到下游时钟域,容易出现竞争风险。
我在这份代码里选择把输出寄存器化,让detected稳定一个周期。虽然严格来说输出晚了一个时钟沿,但与非重叠序列检测的“检测到之后清空”语义并不冲突。训练项目里优先保证波形干净、逻辑容易验证,真正对时序有极致要求的场景再单独考虑组合输出。
2.4 状态编码与综合细节
四个状态只需要两个bit,用二进制编码就够了。如果是几十个状态的大状态机,通常用独热码减少组合逻辑深度,代价是FF数量更多。这个题目状态太少,不需要额外优化。
还有一点,next_state组合块开头写next_state = S0;,然后在case里对每个分支都显式赋值。这样万一case有遗漏分支,也不会综合出latch。综合工具的警告一般比较烦人,养成这个习惯能省去很多检查时间。
3. testbench与波形判读:用1011011验证非重叠行为
3.1 为什么非要用1011011当测试向量
很多教材的testbench只给1011_1111这种简单串,能验证模块不会输出错误,但验证不出“重叠”和“非重叠”的区别。1011011这个串是故意设计的:它让第一次成功检测的最后一个bit,正好可以与后面的011再拼成一个1011。如果模块实现成了重叠检测,这里就会多输出一次。
所以我强烈建议,凡是做序列检测练习,手里至少要备三组输入:
| 测试串 | 预期非重叠输出次数 | 主要验证点 |
|---|---|---|
1011 | 1 | 基本功能正常 |
1011011 | 1 | 是否误实现了重叠逻辑 |
1011011011 | 3 | 连续多次非重叠检测是否正常 |
第三组1011011011可以拆成三段1011_011_011,但只有第一段能命中,后面全是杂散数据。不过如果你把输入改成连续的101110111011,非重叠检测应该每4拍输出一次。
3.2 可仿真的testbench写法
写testbench时有个经验:不要在时钟上升沿的同一边沿改变data_in,容易造成仿真竞争。一般做法是在时钟下降沿改变输入,让数据在上升沿到来前稳定。
`timescale 1ns/1ps module tb_seq_detect_1011_nonoverlap; reg clk; reg rst_n; reg data_in; wire detected; seq_detect_1011_nonoverlap u_dut( .clk (clk), .rst_n (rst_n), .data_in (data_in), .detected(detected) ); initial clk = 1'b0; always #5 clk = ~clk; reg [6:0] seq = 7'b1011011; integer i; initial begin rst_n = 1'b0; data_in = 1'b0; #15 rst_n = 1'b1; for (i = 6; i >= 0; i = i - 1) begin @(negedge clk); data_in = seq[i]; end #10 data_in = 1'b0; #50 $finish; end initial begin $dumpfile("tb.vcd"); $dumpvars(0, tb_seq_detect_1011_nonoverlap); end endmodule这里把序列1011011按位送到了data_in。第4拍开始进入最后一组011之前,state已经被清空,所以后面无论如何都不会再命中。
3.3 仿真波形怎么判读
观察波形时,重点看两个地方。
第一,第一次检测输出位置。1011011送入后,第4个时钟上升沿应该看到detected拉高,并在第5个时钟沿拉低。因为输出是寄存器输出,所以严格说detected置1的沿和检测到序列最后一位的沿是同一个,但数据在第4位之后,state已经被更新成S0,下一个时钟沿到来时输出信号被清掉。
第二,后面第7拍的位置。重叠检测器会在第7个时钟沿再拉高一次,非重叠检测器不会。如果看到这里又出现一个高电平脉冲,几乎可以断定是状态转移表的最后一行写错了,把S3输入1的次态写成了S1。
3.4 自动对比仿真结果的小技巧
训练项目规模小,肉眼盯波形没毛病。但序列多了之后人很容易看花眼,建议在testbench里加一个计数器和一行打印:
reg [3:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) cnt <= 4'd0; else if (detected) cnt <= cnt + 1'b1; end仿真结束后用$display打印cnt。如果cnt不是你预期的次数,说明实现和需求对不上。这种方法对排查重叠非重叠问题特别有效,比自己数波形可靠得多。
4. 另一种实现:移位寄存器法做非重叠检测的代码与取舍
4.1 用“最近4个bit等于1011”来判断
状态机不是唯一解法。序列检测的本质,是判断“当前以及最近三拍输入组成的4位串”是否等于目标序列。所以也可以用移位寄存器保存最近4拍数据,再用比较器判断。
module seq_detect_shift_nonoverlap( input wire clk, input wire rst_n, input wire data_in, output reg detected ); reg [3:0] shift; wire [3:0] shift_next = {shift[2:0], data_in}; // 把当前输入移入后,最新4bit是否等于1011 wire hit = (shift_next == 4'b1011); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin shift <= 4'b0; detected <= 1'b0; end else if (hit) begin // 非重叠:命中后直接清空,当前bit不参与下一轮 shift <= 4'b0; detected <= 1'b1; end else begin shift <= shift_next; detected <= 1'b0; end end endmodule注意这里用shift_next判断,而不是用shift判断。如果用shift判断,等于比较的是“上一拍移入之后的4个bit”,会慢一个周期,逻辑容易绕晕。用shift_next表示“当前data_in进入之后是否能凑成1011”,语义更准确。
那个hit为真的分支里,shift直接清零,没有执行移位。这一拍输入其实已经被比较器消费掉了,如果不清零,下一次就可能检出重叠。
4.2 状态机方案和移位寄存器方案怎么选
我用一张表把两个方案的区别列出来:
| 对比维度 | 状态机方案 | 移位寄存器方案 |
|---|---|---|
| 寄存器资源 | 2个bit状态寄存器 | 4个bit移位寄存器 |
| 判断逻辑 | 状态转移表 | 4位比较器 |
| 非重叠处理 | S3来1回S0 | 命中后清空shift |
| 适合初学练习 | 训练状态机思维 | 训练时序思维 |
| 扩展任意序列 | 状态数线性增加,表会变大 | 只需增加移位寄存器位数和比较器 |
| 可配置多条序列 | 难度较高 | 容易并联多个比较器 |
工程上,如果只是检测一个固定短序列,状态机面积更小,写法也更正统。如果是做协议帧头匹配,要在一拍内同时判断多个可能模式,移位寄存器加比较器会更灵活。比如一个串行数据头可能匹配1011或1101两种,状态机要维护两套状态,移位寄存器方案则直接并两个比较器。
4.3 位移位方案的坑:命中后清零的时序
用移位寄存器方案时,最容易被坑的地方是“命中后清零”会不会丢掉下一拍的有效数据。比如输入1011011,第一次命中在“第4拍”,那一拍的data_in是1。代码里hit为真,shift直接变成0。下一拍data_in变成0,此时shift_next是{3'b000, 0},相当于从0开始重新积累,这是非重叠的正确行为。
如果改成重叠检测,只需要把else if (hit)分支里的shift <= 4'b0;去掉,让数据正常移入,hit信号正常拉高。可见两种检测的代码差异就在这一行,设计时务必写清楚注释。
5. 训练中容易翻车的三个细节与我的复盘习惯
5.1 最容易把非重叠做成重叠的地方
先说最容易翻车的位置:状态转移表的最后一行。检测1011时,S3收到1后次态写S1,输出也正常,看起来检测成功,但实际上这个写法是在允许重叠。这也是为什么1011011这个测试串这么重要,它能在第一次检测后立刻暴露问题。如果拿到一个任意序列检测模块,先喂这个串,看输出次数就能快速判断实现语义。
更一般的做法是:设计完成后,从每个可能形成重叠的位置构造测试序列。比如检测1011,重点就是验证“1011011”这种后缀重叠;检测111,则用“1111”测试,重叠会输出两次,非重叠只输出一次。
5.2 组合输出与时序输出真的按需选
我见过不少训练代码喜欢写assign detected = (state == S3) && data_in;,觉得这是“正宗Mealy”。没错,Mealy是组合输出,但工程落地要另算。组合输出在数据变化快的接口里很容易被毛刺干扰,也让下游逻辑的采样窗口变得不可控。
我的建议是:训练阶段就把两种写法都试一遍。先写组合输出,用1011011测一遍,观察窄脉冲;再改成寄存器输出,波形会干净很多。以后真正做项目,如果检测信号要送给其他模块做握手,优先寄存器输出;如果是纯组合运算路径且有明确的时序预算,再考虑组合输出。
5.3 复位与default分支一个都不能少
异步复位、复位后进S0,这个基本操作也要养成习惯。如果上电后状态机进入S1或S2,可能会把之前的数据误当成有效序列的一部分,导致首次检测位置错位。next_state组合逻辑里的default分支同样重要,因为状态寄存器有2bit,理论上会存在未定义编码,虽然case覆盖了四个状态,但default能防止意外行为。
具体到FPGA工程,复位信号最好统一做异步复位、同步释放。这里不展开细讲,但序列检测这种小状态机,复位处理好可以省去很多上电后的排查时间。
5.4 我自己的训练复盘方法
写这种基础训练项目,不要只追求能跑通。我的习惯是每写完一个功能,就列三件事:仿真里用了哪些特殊向量、哪些case是最容易出错的、如果换一种实现方式会怎样。
对非重叠序列检测来说,我会固定记录:
- 自测向量必须包含
1011011 - 输出信号默认打一拍
- 状态转移表最后一行明确注释“检测成功,回S0,非重叠”
- 同时写一版移位寄存器方案做对比
这一套做完,再把重叠检测也写一遍,两个项目对照着看,状态机设计的感觉就出来了。
这篇笔记主要解决的是“非重叠”三个字背后的状态转移问题。下次遇到检测1101、10101之类的题目,先画状态图,再写转移表,最后用带重叠陷阱的测试串验证,基本不会出错。我已经把这一篇的代码和testbench整理好,打算继续往下记录重叠检测、以及更多位宽输入下的序列检测实现,等积累几个版本再一起分享仿真波形上的对比经验。