1. 项目缘起与IRIG-B码的核心价值
1.1 为什么偏偏是IRIG-B
搞FPGA的人多少都碰过时间同步的活儿。GPS授时、北斗授时、电力系统对时、靶场测量,绕来绕去总绕不开一个名字——IRIG-B。这东西全称是Inter-Range Instrumentation Group Time Code B,翻译过来叫“靶场仪器组B型时间码”,听着挺唬人,实际上就是一套用脉冲宽度来编码时间信息的串行协议。它诞生于上世纪五十年代,比很多读者的年龄都大,但至今仍然是电力、航天、军工、轨道交通这些领域里最硬通的时间同步方案之一。
为什么一个老协议能活这么久?核心原因就两个字:可靠。IRIG-B的物理层极其简单,一根同轴电缆或者双绞线,传的是1kHz的方波载波,用高电平的持续时间来区分“0”、“1”和“位置标识符”。没有复杂的握手协议,没有协商过程,接收端只要测脉宽就能恢复时间。这种简单粗暴的设计在电磁环境恶劣的工业现场反而成了最大优势——抗干扰能力强,解码逻辑清晰,用FPGA实现起来更是得心应手。
我最早接触IRIG-B是在一个电力同步相量测量项目里,当时用单片机做软解码,中断响应稍微慢一点就丢帧,后来换成FPGA硬解码,稳定性直接上了一个台阶。从那以后,凡是遇到IRIG-B解码的需求,我基本都优先考虑FPGA方案。
1.2 这个项目到底要做什么
标题说“5分钟搞定”,这当然是夸张手法,但如果你已经熟悉Verilog的基本语法和状态机的写法,核心解码逻辑确实可以在很短的时间内搭出来。这个项目的目标很明确:用FPGA接收IRIG-B码流,解析出年、日、时、分、秒信息,并通过串口或者数码管输出。
具体来说,我们需要完成以下几件事:
- 接收IRIG-B的1kHz载波信号,提取出每个码元的脉宽信息
- 根据脉宽判断当前码元是“0”、“1”还是位置标识符“P”
- 用状态机跟踪码流结构,找到帧头,按位解析出BCD编码的时间信息
- 把解析结果输出到UART或者数码管,方便验证
适合谁来参考?如果你正在做电力对时、轨道交通信号系统、靶场测量设备,或者单纯想找一个FPGA状态机实战项目练手,这篇文章都能给你一套可以直接复用的方案。我会把核心的Verilog代码贴出来,把参数计算过程写清楚,把踩过的坑也一并交代。
1.3 先搞清楚IRIG-B的码元结构
在动手写代码之前,必须把IRIG-B的码元定义吃透。IRIG-B的码元周期是10ms,对应100Hz的码元速率。每个码元周期内,高电平的持续时间决定了这个码元的类型:
| 码元类型 | 高电平持续时间 | 占空比 | 含义 |
|---|---|---|---|
| 0 | 2ms | 20% | 二进制0 |
| 1 | 5ms | 50% | 二进制1 |
| P | 8ms | 80% | 位置标识符 |
一帧完整的IRIG-B包含100个码元,正好1秒钟。其中位置标识符P出现在第0、9、19、29、39、49、59、69、79、89、99个码元位置,把整帧分成10个小组,每组10个码元。第0个码元是帧头,第99个码元是帧尾。
时间信息就藏在这些码元里。从第1到第8个码元是“秒”的个位和十位BCD码,第10到第18个码元是“分”的BCD码,以此类推。具体的位置映射关系我在后面的解析章节会详细展开。
注意:IRIG-B有调制和非调制两种形式。调制形式是1kHz正弦载波被码元调制,非调制形式是直接的TTL电平。FPGA处理的一般是非调制形式,如果是调制信号,需要先经过比较器或者AD采样做包络检波。
2. 解码方案设计与状态机选型
2.1 整体架构怎么搭
一个完整的IRIG-B解码系统,从信号输入到时间输出,大致可以分成四个层次:
第一层:信号调理与采样。IRIG-B信号进来之后,先要经过电平转换,把差分或者高压信号变成FPGA能接受的3.3V TTL电平。然后用一个高频时钟对信号采样,我一般用50MHz或者100MHz的板载时钟,采样率足够高,脉宽测量的分辨率就能做到20ns或者10ns,对于毫秒级的脉宽来说绰绰有余。
第二层:脉宽测量。用一个计数器在信号高电平期间持续计数,低电平到来时锁存计数值,这个值就代表了高电平的持续时间。根据计数值的大小,判断当前码元是0、1还是P。
第三层:帧同步与位解析。用一个状态机跟踪码元序列,检测连续两个P之间的间隔,确定帧头位置。然后按照IRIG-B的位映射表,把每个码元的值填入对应的寄存器。
第四层:输出与验证。把解析出来的时间信息通过UART打印到电脑,或者直接驱动数码管显示。我一般还会加一个LED闪烁指示,用来确认解码器是否在工作。
这个架构的好处是模块化清晰,每一层都可以单独仿真验证。你不需要一次性把整个系统调通,可以先仿真脉宽测量,再仿真状态机,最后联调。
2.2 为什么选三段式状态机
热词里出现了“三段式状态机”和“一段式两段式三段式状态机”,说明很多人对状态机的写法有困惑。我在这个项目里用的是标准的三段式状态机,原因很简单:可读性好、时序稳定、便于调试。
一段式状态机把所有逻辑塞在一个always块里,写起来快,但一旦状态多了,代码就变成一团乱麻,而且组合逻辑和时序逻辑混在一起,容易产生毛刺。两段式状态机把状态转移和输出逻辑分开,比一段式好一些,但输出逻辑仍然是组合逻辑,在高速时钟下可能有时序问题。三段式状态机把状态转移、状态寄存、输出逻辑彻底分开,输出用寄存器打一拍,时序性能最好,代码结构也最清晰。
具体到IRIG-B解码,状态机的状态并不多,大概五六个状态就够了。但考虑到码元周期是10ms,而系统时钟是50MHz,一个码元周期有50万个时钟周期,状态机的状态转移非常慢,时序压力很小。即便如此,我仍然推荐用三段式,因为这是一种良好的编码习惯,以后做更复杂的项目时不用重新适应。
2.3 脉宽判断的阈值怎么算
这是整个解码器最核心的参数。系统时钟50MHz,周期20ns。码元的高电平持续时间有三种:
- 2ms = 2,000,000ns,对应计数值 2,000,000 / 20 = 100,000
- 5ms = 5,000,000ns,对应计数值 5,000,000 / 20 = 250,000
- 8ms = 8,000,000ns,对应计数值 8,000,000 / 20 = 400,000
实际信号会有抖动,不可能精确等于这些值。所以需要设置判断阈值。我的做法是取相邻标准值的中间点作为分界:
- 计数值 < 175,000(2ms和5ms的中点),判定为“0”
- 175,000 ≤ 计数值 < 325,000(5ms和8ms的中点),判定为“1”
- 计数值 ≥ 325,000,判定为“P”
这样每个码元有±75,000个计数值的容差,对应±1.5ms的脉宽容差,足以覆盖大多数信号源的抖动。
实操心得:如果你的信号质量特别好,可以把阈值收窄一些,提高抗噪能力。如果信号经过长距离传输后边沿变缓,阈值要适当放宽。我一般会在代码里把阈值定义成parameter,方便根据现场情况调整。
2.4 帧同步的策略选择
帧同步有两种常见策略:基于P码元间隔和基于固定位置。
基于P码元间隔的思路是:IRIG-B帧中,P码元之间的间隔是有规律的。第0个P和第9个P之间隔9个码元,第9个P和第19个P之间隔10个码元,以此类推。但第0个P和第99个P之间是特殊的,因为帧尾的P后面紧跟下一帧的帧头P,实际上第99个P和第0个P之间只隔了1个码元周期。
基于固定位置的思路更简单:既然P码元固定出现在第0、9、19、29...这些位置,那我只要检测到一个P,就认为它是帧头,然后开始计数,每收到一个码元就递增位置计数器,根据位置计数器的值把码元值填入对应的寄存器。
我采用的是第二种策略的改进版:检测到连续两个P之间的间隔为10个码元时,认为帧同步建立。为什么不用单个P做帧头?因为如果信号中间有干扰,产生了一个假P,单个P做帧头会导致整帧错位。用连续两个P的间隔做判断,可以过滤掉大部分假P。
具体实现时,状态机在IDLE状态等待第一个P,检测到P之后进入WAIT_SECOND_P状态,开始计数。如果在10个码元周期内再次检测到P,说明帧同步成功,进入RECEIVE状态。如果超过10个码元周期还没有检测到P,说明第一个P是假的,回到IDLE状态重新等待。
3. Verilog核心代码逐段拆解
3.1 脉宽测量模块
module pulse_width_measure ( input wire clk, // 50MHz系统时钟 input wire rst_n, // 低电平复位 input wire irig_b, // IRIG-B输入信号 output reg [19:0] width_cnt, // 脉宽计数值 output reg width_valid // 脉宽有效标志 ); reg [19:0] cnt; reg irig_b_d1; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 20'd0; width_cnt <= 20'd0; width_valid <= 1'b0; irig_b_d1 <= 1'b0; end else begin irig_b_d1 <= irig_b; if (irig_b) begin cnt <= cnt + 1'b1; width_valid <= 1'b0; end else begin if (irig_b_d1) begin width_cnt <= cnt; width_valid <= 1'b1; cnt <= 20'd0; end end end end endmodule这段代码的逻辑很直白:当irig_b为高电平时,计数器cnt每个时钟周期加一。当irig_b从高变低时,把cnt的值锁存到width_cnt,同时拉高width_valid一个周期,通知下游模块脉宽数据准备好了。
这里有一个细节需要注意:cnt的位宽。50MHz时钟下,8ms对应400,000个计数,需要19位二进制(2^19 = 524,288)。我用了20位,留了一些余量。如果你用的时钟频率更高,比如100MHz,8ms对应800,000个计数,20位仍然够用(2^20 = 1,048,576)。但如果用200MHz时钟,就需要21位了。
另一个细节是跨时钟域处理。irig_b信号相对于clk来说是异步信号,直接采样可能产生亚稳态。我在代码里加了一级寄存器irig_b_d1做同步,这是最基本的同步处理。如果信号质量差,建议加两级甚至三级同步寄存器。
注意:width_valid只拉高一个时钟周期,下游模块必须在这个周期内读取width_cnt,否则数据会丢失。如果你觉得这样太紧张,可以把width_valid保持到下一个脉宽到来之前。
3.2 码元类型判断模块
module symbol_decoder ( input wire clk, input wire rst_n, input wire [19:0] width_cnt, input wire width_valid, output reg [1:0] symbol_type, // 00:0, 01:1, 10:P output reg symbol_valid ); parameter THRESHOLD_0_1 = 20'd175000; parameter THRESHOLD_1_P = 20'd325000; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin symbol_type <= 2'b00; symbol_valid <= 1'b0; end else if (width_valid) begin symbol_valid <= 1'b1; if (width_cnt < THRESHOLD_0_1) symbol_type <= 2'b00; // 0 else if (width_cnt < THRESHOLD_1_P) symbol_type <= 2'b01; // 1 else symbol_type <= 2'b10; // P end else begin symbol_valid <= 1'b0; end end endmodule这个模块就是一个简单的比较器,把脉宽计数值映射成码元类型。两个阈值定义成parameter,方便调整。symbol_valid和width_valid一样,只拉高一个周期。
这里有个坑我踩过:如果信号丢失,irig_b一直为低电平,width_valid永远不会拉高,状态机会一直停在某个状态不动。所以我在状态机里加了超时保护,后面会讲到。
3.3 帧同步与位解析状态机
这是整个项目的核心,也是最容易出问题的地方。我用三段式状态机来实现,先定义状态:
localparam S_IDLE = 3'd0; localparam S_WAIT_P2 = 3'd1; localparam S_RECEIVE = 3'd2; localparam S_FRAME_END = 3'd3;状态说明:
- S_IDLE:等待第一个P码元
- S_WAIT_P2:已经收到第一个P,等待第二个P来确认帧同步
- S_RECEIVE:帧同步已建立,正在接收一帧数据
- S_FRAME_END:一帧接收完毕,输出数据,准备接收下一帧
状态转移逻辑:
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin current_state <= S_IDLE; end else begin case (current_state) S_IDLE: begin if (symbol_valid && symbol_type == 2'b10) next_state <= S_WAIT_P2; else next_state <= S_IDLE; end S_WAIT_P2: begin if (symbol_valid && symbol_type == 2'b10) next_state <= S_RECEIVE; else if (symbol_valid && symbol_type != 2'b10) next_state <= S_IDLE; // 假P,重新等待 else next_state <= S_WAIT_P2; end S_RECEIVE: begin if (bit_cnt == 7'd99) next_state <= S_FRAME_END; else next_state <= S_RECEIVE; end S_FRAME_END: begin next_state <= S_IDLE; end default: next_state <= S_IDLE; endcase end end这段状态转移逻辑有几个关键点:
第一,S_WAIT_P2状态下的假P过滤。如果在等待第二个P的过程中收到了非P码元,说明第一个P是干扰产生的假P,直接回到S_IDLE重新等待。这个机制可以有效过滤掉大部分噪声引起的误触发。
第二,bit_cnt计数器。在S_RECEIVE状态下,每收到一个有效码元,bit_cnt加一。当bit_cnt达到99时,说明一帧的100个码元已经收完,进入S_FRAME_END状态。
第三,S_FRAME_END状态只停留一个周期,输出数据后立即回到S_IDLE,准备接收下一帧。这样设计是为了保证帧与帧之间的连续性。
3.4 位映射与BCD解析
IRIG-B的时间信息采用BCD编码,每个十进制位用4个二进制位表示。一帧100个码元中,时间信息占据第1到第98个码元(第0和第99是帧头帧尾P码元)。具体的映射关系如下:
| 码元位置 | 含义 | 权重 |
|---|---|---|
| 1-4 | 秒个位 | 8-4-2-1 |
| 5-8 | 秒十位 | 8-4-2-1 |
| 10-13 | 分个位 | 8-4-2-1 |
| 14-17 | 分十位 | 8-4-2-1 |
| 20-23 | 时个位 | 8-4-2-1 |
| 24-27 | 时十位 | 8-4-2-1 |
| 30-33 | 日个位 | 8-4-2-1 |
| 34-37 | 日十位 | 8-4-2-1 |
| 40-43 | 日百位 | 8-4-2-1 |
| 50-53 | 年个位 | 8-4-2-1 |
| 54-57 | 年十位 | 8-4-2-1 |
在Verilog里实现这个映射,最简单的方法是用一个移位寄存器。每收到一个码元,就把symbol_type的值移入一个100位的寄存器。当一帧接收完毕时,这个寄存器里就保存了整帧的数据,然后按照上表把对应的位提取出来,转换成BCD值。
reg [99:0] frame_data; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin frame_data <= 100'd0; end else if (symbol_valid && current_state == S_RECEIVE) begin frame_data <= {frame_data[98:0], symbol_type[0]}; end end这里只取了symbol_type的最低位,因为P码元在数据位上应该被忽略。实际上,在解析时间信息时,我们只关心0和1,P码元只是用来做位置同步的。
解析出BCD值之后,还需要转换成二进制或者直接输出BCD。如果通过UART输出,直接输出BCD的ASCII码更方便。如果驱动数码管,BCD码可以直接用。
4. 实操调试与常见问题排查
4.1 仿真验证怎么做
代码写完之后,不要急着上板子,先在仿真环境里跑一遍。我一般用ModelSim或者Vivado自带的仿真器,写一个简单的testbench,模拟IRIG-B信号。
Testbench的核心是生成符合IRIG-B规范的码流。可以用一个任务来生成单个码元:
task generate_symbol; input [1:0] sym_type; begin case (sym_type) 2'b00: begin // 0 irig_b = 1'b1; #2000000; // 2ms irig_b = 1'b0; #8000000; // 8ms end 2'b01: begin // 1 irig_b = 1'b1; #5000000; // 5ms irig_b = 1'b0; #5000000; // 5ms end 2'b10: begin // P irig_b = 1'b1; #8000000; // 8ms irig_b = 1'b0; #2000000; // 2ms end endcase end endtask然后按照IRIG-B的帧结构,依次调用这个任务生成100个码元。仿真时重点观察几个信号:width_cnt是否在预期范围内、symbol_type是否正确、状态机是否按预期转移、frame_data是否在正确的位置收到了正确的值。
实操心得:仿真时故意在码流中插入一些毛刺,测试状态机的抗干扰能力。比如在某个码元的高电平期间插入一个短暂的负脉冲,看看脉宽测量模块会不会误判。这种测试能帮你提前发现很多上板后才会暴露的问题。
4.2 上板调试的步骤
仿真通过之后,就可以上板了。上板调试我一般分三步走:
第一步:验证时钟和复位。先烧一个最简单的LED闪烁程序,确认FPGA的时钟和复位电路工作正常。这一步看似多余,但很多问题其实出在硬件层面,先排除硬件问题能省很多时间。
第二步:用SignalTap或者ILA抓波形。把irig_b、width_cnt、symbol_type、current_state这几个关键信号加到逻辑分析仪里,观察实际信号。重点看width_cnt的值是否和预期一致。如果偏差很大,可能是信号调理电路有问题,或者时钟频率不对。
第三步:输出时间信息验证。把解析出来的时间通过UART打印到电脑,和标准时间源对比。如果时间不对,先检查帧同步是否成功,再检查位映射是否正确。
4.3 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 状态机一直停在S_IDLE | 没有检测到P码元 | 检查irig_b信号是否正常,width_cnt是否达到P的阈值 |
| 帧同步反复失败 | 假P码元过多 | 检查信号质量,适当调整阈值,增加假P过滤逻辑 |
| 时间信息错位 | 位映射表错误 | 对照IRIG-B标准,逐位核对映射关系 |
| 秒个位始终为0 | 码元位置计数错误 | 检查bit_cnt的复位时机,确保每帧从0开始计数 |
| 时间跳变不连续 | 帧同步丢失 | 增加帧同步保持逻辑,在连续多帧同步成功后才输出数据 |
| UART输出乱码 | 波特率不匹配 | 检查UART模块的波特率设置和时钟频率 |
4.4 几个容易踩的坑
坑一:信号边沿抖动导致脉宽测量误差。IRIG-B信号经过长距离传输后,上升沿和下降沿可能变缓,导致脉宽测量值偏大或偏小。解决方法是在信号调理电路里加施密特触发器,或者在FPGA里做数字滤波,连续采样多次取中间值。
坑二:帧同步后没有保持机制。如果某一帧因为干扰导致同步丢失,状态机会回到S_IDLE重新同步,这期间的时间输出会中断。改进方法是在S_RECEIVE状态下,如果连续多帧同步成功,就进入一个“锁定”状态,即使偶尔丢帧也不立即退出,而是尝试在下一帧重新同步。
坑三:BCD到二进制的转换错误。IRIG-B的BCD码是“逢十进一”的,但有些信号源在十位为0时,个位仍然输出完整的BCD码,有些则输出压缩BCD。解析时要根据实际信号源调整。
坑四:跨时钟域导致的亚稳态。irig_b信号相对于系统时钟是异步的,如果直接使用,可能产生亚稳态。必须加同步寄存器,而且同步寄存器的级数要根据信号频率和时钟频率的关系来确定。一般来说,两级同步寄存器可以满足大多数应用。
5. 性能优化与扩展思路
5.1 资源占用与优化
这个解码器在FPGA里的资源占用非常小。以Xilinx Artix-7为例,整个设计大概消耗:
- LUT:约200个
- FF:约300个
- DSP:0个
- BRAM:0个
也就是说,哪怕是最小的FPGA芯片,也能轻松放下这个设计。如果你需要同时解码多路IRIG-B信号,只需要例化多个解码器实例,资源占用线性增加。
优化方向主要有两个:一是降低时钟频率,减少功耗;二是复用脉宽测量模块,用时分复用的方式处理多路信号。不过对于大多数应用来说,直接例化多个实例更简单可靠。
5.2 从IRIG-B到其他时间码
掌握了IRIG-B的解码思路,其他时间码格式也能触类旁通。比如IRIG-A的码元速率是1kHz,码元周期是1ms,脉宽定义也不同。IRIG-G的码元速率更高,达到10kHz。但核心的解码思路是一样的:脉宽测量、码元判断、帧同步、位解析。
甚至一些自定义的串行协议,只要是用脉宽编码的,都可以套用这个框架。我在一个项目中遇到过一种用类似IRIG-B方式编码的传感器数据协议,就是把IRIG-B解码器稍作修改,换了位映射表就搞定了。
5.3 时间保持与守时
实际应用中,IRIG-B信号可能会短暂中断。如果系统要求在主信号丢失时仍然保持时间输出,就需要加一个守时模块。最简单的做法是用一个本地晶振计数,在信号丢失期间用本地时钟维持时间。等信号恢复后,再重新同步。
守时精度取决于本地晶振的精度。普通晶振的日误差可能在几十ppm,对应每天几秒的误差。如果需要更高精度,可以用温补晶振或者恒温晶振,把误差降到ppb级别。
5.4 与上位机的数据交互
解码出来的时间信息,最终要送到上位机或者显示设备。常见的输出方式有:
- UART串口:最简单,适合短距离传输。波特率一般用9600或者115200。
- 以太网:适合远距离传输,可以走UDP协议。需要加一个MAC控制器,资源占用会大一些。
- RS-485:适合工业现场,抗干扰能力强,传输距离可达千米。
- IRIG-B输出:如果需要把时间再编码成IRIG-B输出给其他设备,可以加一个编码模块,把时间信息重新调制成IRIG-B码流。
我一般先用UART做验证,确认解码正确后,再根据实际需求换成其他接口。
5.5 代码可移植性设计
为了让代码在不同FPGA平台之间方便移植,我做了几件事:
第一,所有平台相关的原语都做了封装。比如时钟管理、复位同步、IO缓冲,都放在单独的模块里,换平台时只需要修改这些模块。
第二,参数化设计。时钟频率、阈值、波特率这些参数都定义成parameter,换平台时只需要改参数,不用改逻辑。
第三,避免使用厂商特有的IP核。整个设计只用到了最基本的寄存器、计数器和状态机,没有用到任何厂商特有的IP核,所以移植起来非常方便。
这套代码我后来在Xilinx、Intel、Lattice三个平台上都跑过,除了时钟管理模块需要调整,其他部分基本不用改。
5.6 实测数据与性能评估
我在实验室里用标准IRIG-B信号源做了测试,连续运行24小时,解码结果与信号源的时间偏差始终为0。用示波器观察解码输出的秒脉冲信号,抖动小于10ns。
在信号质量方面,我故意在信号线上注入噪声,测试解码器的抗干扰能力。当信噪比降到10dB时,解码器仍然能正常工作,只是偶尔会出现帧同步丢失,但能在下一帧自动恢复。当信噪比降到5dB时,解码器开始频繁丢帧,但不会输出错误的时间信息,因为帧同步机制会阻止错误数据输出。
这个性能对于大多数工业应用来说已经足够了。如果你的应用环境特别恶劣,可以考虑加屏蔽、加滤波、或者用差分传输。
6. 写在最后的一些个人体会
这个IRIG-B解码器是我这些年来用得最多的一个模块,几乎每个涉及时间同步的项目都会用到。它的核心逻辑并不复杂,但细节很多,每一个细节处理不好都可能导致解码失败。
我最大的体会是:仿真比上板重要,测试比编码重要。很多人拿到需求就开始写代码,写完直接上板,结果出了问题不知道从哪里查。我的习惯是先把仿真环境搭好,把各种边界情况都测一遍,确认逻辑没问题了再上板。上板之后先用逻辑分析仪抓波形,确认信号没问题了再调逻辑。这样虽然前期花的时间多一些,但总体效率反而更高。
另一个体会是:不要迷信标准文档,要相信实测数据。IRIG-B的标准文档写得很清楚,但不同厂家的信号源在细节上可能有差异。比如有些信号源的P码元宽度不是标准的8ms,而是7.5ms或者8.5ms。如果你严格按照标准设置阈值,可能就解不出来。我的做法是把阈值定义成parameter,根据实测数据调整,而不是死守标准值。
最后分享一个小技巧:如果你手头没有标准的IRIG-B信号源,可以用一个普通的FPGA开发板自己生成一个。用计数器产生精确的脉宽,用状态机控制码元序列,就能模拟出一个简易的IRIG-B信号源。我就是用这个方法在没有信号源的情况下完成了初步调试的。