1. 为什么IRIG-B解码值得用FPGA来做如果你做过电力系统、靶场测量或者分布式数据采集的项目大概率绕不开IRIG-B这个时间码协议。它的全称是Inter-Range Instrumentation Group B本质上是一种串行时间编码把天、时、分、秒、年积日这些信息调制到1kHz的载波或者直接以直流电平的形式串行发出来。很多授时设备、GPS/北斗接收机、电力保护装置都标配IRIG-B输出接口。那问题来了既然设备已经输出IRIG-B了为什么还要自己用FPGA解码直接用现成的解码芯片或者单片机读不行吗我最初也是这么想的直到在一个多通道同步采集项目里踩了坑。当时用MCU做解码发现几个致命问题第一MCU的中断响应有抖动解码出来的秒脉冲1PPS抖动在几十微秒级别对于要求纳秒级同步的采集系统来说完全不够用第二多路IRIG-B同时进来的时候MCU要分时处理通道间同步误差更大第三MCU方案在电磁环境恶劣的现场容易跑飞看门狗复位后重新锁定的时间太长。换成FPGA之后这些问题基本都消失了。FPGA的并行特性意味着你可以同时解多路IRIG-B每路独立状态机互不干扰时钟驱动的逻辑天然没有中断抖动1PPS的边沿精度直接取决于系统时钟周期而且纯硬件逻辑抗干扰能力强复位后几个周期就能重新开始捕获。所以这篇内容适合谁看如果你正在做电力自动化、测试测量、雷达同步或者任何需要高精度时间同步的FPGA项目IRIG-B解码是一个非常好的入门级实战案例。它涉及状态机设计、边沿检测、计数器、移位寄存器这些FPGA核心基本功但又不至于复杂到让人望而却步。我下面会把整个解码思路、Verilog核心代码、以及实际调试中踩过的坑都摊开来讲。提示IRIG-B有多种格式变体包括B000、B001、B002、B003、B120、B121、B122、B123等。本文以最常用的B000格式直流码1kHz100pps为例展开其他格式的解码思路类似只是帧结构和字段位置有差异。2. IRIG-B码的帧结构与编码规则拆解2.1 一帧到底长什么样IRIG-B的B000格式每帧固定1秒包含100个码元每个码元宽度10ms。这100个码元里前90个是时间信息秒、分、时、年积日后面跟着控制位和校验位。每个码元根据高电平的宽度不同分为三种类型逻辑0二进制0高电平持续2ms低电平持续8ms逻辑1二进制1高电平持续5ms低电平持续5ms位置标识符P码高电平持续8ms低电平持续2ms这三种码元的区分非常直观你只需要测量每个10ms周期内高电平的持续时间就能判断当前码元是0、1还是P。这也是FPGA解码最核心的原理——用一个计数器测量高电平宽度然后根据宽度范围做判决。一帧的起始位置由连续两个P码元标记这两个P码之间的间隔是10ms而正常情况下P码每10个码元出现一次即每100ms一个P码。连续两个P码意味着新的一秒开始了。2.2 时间字段的位分配B000格式的100个码元中时间信息的位分配是这样的码元位置字段含义位数1-4秒个位BCD45索引标记16-8秒十位BCD39-10位置标识P1211-14分个位BCD415索引标记116-18分十位BCD319-20位置标识P2221-24时个位BCD425索引标记126-27时十位BCD228-30保留331-34年积日个位BCD435索引标记136-39年积日十位BCD440-41年积日百位BCD242-50保留951-54年个位BCD455索引标记156-59年十位BCD460-69保留/控制位1070-79控制位1080-89控制位1090-99控制位/校验10100位置标识P01注意秒、分、时、年积日都是BCD编码也就是说每个十进制位用4位二进制表示。比如秒的个位是9BCD就是1001。这种编码方式的好处是解码后直接就是十进制数不需要做二进制到十进制的转换。2.3 为什么用BCD而不是纯二进制这个问题我一开始也疑惑过。纯二进制编码效率更高同样的位数能表示更大的范围。但IRIG-B选择BCD是有历史原因的早期的授时设备用机电式计数器或者简单的数字逻辑BCD直接对应数码管的段选信号省去了转换电路。而且BCD编码在传输过程中如果出现单比特错误影响范围可控——比如秒个位的某一位翻转最多让秒数偏差1不会像纯二进制那样从9跳到0导致整个字段崩溃。在FPGA里处理BCD其实也不麻烦解码后直接按位拼接就行需要做算术运算的时候再用Double Dabble算法转成二进制。不过对于大多数授时应用来说你只需要把BCD字段原样输出给下游就行不需要转成二进制。3. 解码状态机的设计思路与Verilog实现3.1 状态机该怎么划分IRIG-B解码的状态机其实可以设计得非常简洁。我见过有人用七八个状态来做实际上三个状态就够了IDLE等待第一个P码元的上升沿MEASURE测量高电平宽度判决码元类型FRAME按位置把判决结果存入对应的寄存器但这里有个细节需要注意IRIG-B的P码有两种一种是帧起始的P0连续两个另一种是帧内的P1到P9每10个码元一个。状态机需要区分这两种情况。我的做法是用一个码元计数器0到99来跟踪当前在帧内的位置。当检测到连续两个P码时把计数器清零开始新的一帧。帧内的P码只用来做位置校验不改变计数器。3.2 高电平宽度测量的实现测量高电平宽度是解码的核心。思路很简单系统时钟驱动一个计数器当输入信号为高时计数器递增检测到下降沿时锁存计数值然后根据计数值判断码元类型。假设系统时钟是50MHz那么一个时钟周期是20ns。10ms的高电平对应500000个时钟周期。这个数字太大了用20位计数器才能装下。但实际上我们不需要这么高的精度——IRIG-B的码元宽度容差通常在±10%左右也就是说2ms的高电平可能在1.8ms到2.2ms之间5ms的在4.5ms到5.5ms之间8ms的在7.2ms到8.8ms之间。所以我们可以把时钟分频后再计数。比如分频到1MHz那么2ms对应2000个计数5ms对应50008ms对应8000。这样用14位计数器就够了而且判决阈值很好设计数 3500逻辑03500 ≤ 计数 6500逻辑1计数 ≥ 6500P码分频的好处不仅是省资源更重要的是降低了高频噪声的影响。如果直接用50MHz计数输入信号上的毛刺可能导致计数器多计几个数虽然不影响判决但分频后相当于加了一个简单的低通滤波。3.3 三段式状态机的Verilog代码下面是我实际项目中用的Verilog核心代码采用经典的三段式状态机写法。所谓三段式就是状态寄存器、次态逻辑、输出逻辑分开写这样代码清晰综合结果也稳定。module irig_b_decoder ( input wire clk, // 50MHz系统时钟 input wire rst_n, // 低电平复位 input wire irig_b_in, // IRIG-B输入信号 output reg [5:0] sec_bcd, // 秒 BCD output reg [5:0] min_bcd, // 分 BCD output reg [5:0] hour_bcd, // 时 BCD output reg [9:0] day_bcd, // 年积日 BCD output reg [7:0] year_bcd, // 年 BCD output reg pps_out, // 秒脉冲输出 output reg data_valid // 数据有效标志 ); // 时钟分频50MHz - 1MHz reg [5:0] clk_div_cnt; reg clk_1m; always (posedge clk or negedge rst_n) begin if (!rst_n) begin clk_div_cnt 6d0; clk_1m 1b0; end else if (clk_div_cnt 6d24) begin clk_div_cnt 6d0; clk_1m ~clk_1m; end else begin clk_div_cnt clk_div_cnt 1b1; end end // 输入信号同步与边沿检测 reg irig_b_d1, irig_b_d2, irig_b_d3; always (posedge clk_1m or negedge rst_n) begin if (!rst_n) begin irig_b_d1 1b0; irig_b_d2 1b0; irig_b_d3 1b0; end else begin irig_b_d1 irig_b_in; irig_b_d2 irig_b_d1; irig_b_d3 irig_b_d2; end end wire rise_edge irig_b_d2 ~irig_b_d3; wire fall_edge ~irig_b_d2 irig_b_d3; // 高电平宽度计数器 reg [13:0] width_cnt; reg [13:0] width_latch; always (posedge clk_1m or negedge rst_n) begin if (!rst_n) begin width_cnt 14d0; width_latch 14d0; end else if (rise_edge) begin width_cnt 14d0; end else if (irig_b_d2) begin width_cnt width_cnt 1b1; end else if (fall_edge) begin width_latch width_cnt; end end // 码元类型判决 reg [1:0] symbol_type; // 00:逻辑0, 01:逻辑1, 10:P码 always (posedge clk_1m or negedge rst_n) begin if (!rst_n) symbol_type 2b00; else if (fall_edge) begin if (width_latch 14d3500) symbol_type 2b00; else if (width_latch 14d6500) symbol_type 2b01; else symbol_type 2b10; end end // 状态机定义 localparam S_IDLE 2b00; localparam S_MEASURE 2b01; localparam S_FRAME 2b10; reg [1:0] cur_state, nxt_state; reg [6:0] symbol_idx; // 码元位置 0-99 reg prev_p_flag; // 上一个码元是否为P reg [99:0] frame_data; // 整帧数据移位寄存器 // 状态寄存器 always (posedge clk_1m or negedge rst_n) begin if (!rst_n) cur_state S_IDLE; else cur_state nxt_state; end // 次态逻辑 always (*) begin nxt_state cur_state; case (cur_state) S_IDLE: begin if (fall_edge symbol_type 2b10) nxt_state S_MEASURE; end S_MEASURE: begin if (fall_edge) begin if (symbol_type 2b10 prev_p_flag) nxt_state S_FRAME; else nxt_state S_MEASURE; end end S_FRAME: begin if (symbol_idx 7d99) nxt_state S_IDLE; end default: nxt_state S_IDLE; endcase end // 输出逻辑与数据移位 always (posedge clk_1m or negedge rst_n) begin if (!rst_n) begin symbol_idx 7d0; prev_p_flag 1b0; frame_data 100d0; pps_out 1b0; data_valid 1b0; end else begin pps_out 1b0; data_valid 1b0; case (cur_state) S_IDLE: begin symbol_idx 7d0; prev_p_flag 1b0; end S_MEASURE: begin if (fall_edge) begin prev_p_flag (symbol_type 2b10); end end S_FRAME: begin if (fall_edge) begin frame_data[symbol_idx] (symbol_type 2b01); symbol_idx symbol_idx 1b1; if (symbol_idx 7d99) begin pps_out 1b1; data_valid 1b1; end end end endcase end end // BCD字段提取 always (posedge clk_1m or negedge rst_n) begin if (!rst_n) begin sec_bcd 6d0; min_bcd 6d0; hour_bcd 6d0; day_bcd 10d0; year_bcd 8d0; end else if (data_valid) begin sec_bcd {frame_data[6:8], frame_data[0:3]}; min_bcd {frame_data[15:18], frame_data[10:13]}; hour_bcd {frame_data[25:26], frame_data[20:23]}; day_bcd {frame_data[39:40], frame_data[35:38], frame_data[30:33]}; year_bcd {frame_data[55:58], frame_data[50:53]}; end end endmodule这段代码有几个设计细节值得展开说。3.4 为什么用移位寄存器而不是逐位存储你可能注意到我用了一个100位的frame_data移位寄存器每个码元判决后直接塞进对应位置。这样做的好处是BCD字段提取的时候只需要按位拼接不需要写一大堆if-else或者case语句。而且移位寄存器的综合结果非常规整在FPGA里就是100个触发器资源消耗可控。如果换成逐位存储你需要为每个字段单独定义寄存器然后在状态机里根据symbol_idx的值决定往哪个寄存器写。代码量会大很多而且容易出错。移位寄存器的写法更简洁也更容易验证。3.5 边沿检测为什么要打三拍代码里对输入信号打了三拍irig_b_d1、irig_b_d2、irig_b_d3然后用d2和d3做边沿检测。这是FPGA设计里的标准做法目的是消除亚稳态。IRIG-B输入信号相对于FPGA系统时钟来说是异步的直接用它做边沿检测可能采到亚稳态导致状态机跑飞。打两拍同步之后d2就是稳定的同步信号。再打一拍得到d3用d2和d3做边沿检测就能得到干净的上升沿和下降沿脉冲。注意边沿检测的脉冲宽度只有一个时钟周期如果你后续逻辑需要更宽的脉冲记得做展宽处理。4. 实际调试中遇到的坑与排查过程4.1 第一个坑P码识别不稳定代码写完之后我在SignalTap里抓波形发现P码的识别时好时坏。有时候连续两个P码能正确识别有时候第二个P码被误判成逻辑1。排查过程是这样的先看宽度计数器的值。正常情况下P码的高电平宽度应该是8000左右1MHz时钟下8ms对应8000个计数但实际抓到的值在6500到7000之间波动。这说明输入信号的P码高电平宽度没有达到标准的8ms可能是信号源本身有偏差也可能是传输线路导致了边沿变缓。解决方案有两个一是放宽P码的判决阈值从6500降到6000二是在输入路径上加一个简单的滑动窗口滤波连续采到三个相同的码元类型才确认。我选择了后者因为滑动窗口滤波不仅能解决P码识别问题还能滤掉偶发的毛刺干扰。滑动窗口滤波的Verilog实现很简单reg [1:0] symbol_hist0, symbol_hist1, symbol_hist2; reg [1:0] symbol_filtered; always (posedge clk_1m or negedge rst_n) begin if (!rst_n) begin symbol_hist0 2b00; symbol_hist1 2b00; symbol_hist2 2b00; end else if (fall_edge) begin symbol_hist0 symbol_type; symbol_hist1 symbol_hist0; symbol_hist2 symbol_hist1; end end always (*) begin if (symbol_hist0 symbol_hist1 symbol_hist1 symbol_hist2) symbol_filtered symbol_hist0; else symbol_filtered symbol_hist2; // 保持上一次的稳定值 end这个滤波器的逻辑是只有连续三个码元类型一致时才更新输出否则保持上一次的稳定值。代价是引入了两个码元的延迟但对于1秒一帧的IRIG-B来说这点延迟完全可以接受。4.2 第二个坑帧同步丢失后的恢复在实际现场IRIG-B信号可能会因为线路接触不良或者电磁干扰而短暂丢失。信号恢复之后状态机需要重新找到帧起始位置。我最初的代码在信号丢失后会卡在S_FRAME状态因为symbol_idx没有清零一直在等第99个码元。修复方法是加一个超时计数器如果超过20ms没有检测到下降沿就强制状态机回到S_IDLE重新搜索。20ms的选择依据是IRIG-B的码元周期是10ms正常情况下每10ms必有一个下降沿。如果20ms都没有下降沿说明信号肯定丢了。reg [14:0] timeout_cnt; always (posedge clk_1m or negedge rst_n) begin if (!rst_n) timeout_cnt 15d0; else if (fall_edge) timeout_cnt 15d0; else if (timeout_cnt 15d20000) timeout_cnt timeout_cnt 1b1; end wire timeout (timeout_cnt 15d20000);然后在次态逻辑里加上if (timeout) nxt_state S_IDLE;。这样即使信号丢失状态机也能自动恢复。4.3 第三个坑1PPS输出的抖动1PPS是IRIG-B解码最重要的输出之一它的上升沿应该精确对应每秒的起始时刻。我最初的做法是在symbol_idx 99时直接把pps_out拉高一个时钟周期。但实测发现1PPS的边沿有大约1个时钟周期1us的抖动。原因在于symbol_idx的递增是在下降沿触发的而下降沿的检测本身有1个时钟周期的延迟因为打了三拍。所以1PPS的输出实际上比真正的秒起始时刻晚了1到2个时钟周期。解决方案是用组合逻辑直接根据fall_edge和symbol_idx生成1PPS而不是等到下一个时钟周期。或者更简单的方法在检测到帧起始的连续两个P码时就预判下一秒的1PPS位置提前一个周期输出。我最终采用的是组合逻辑方案assign pps_comb (cur_state S_FRAME) (symbol_idx 7d99) fall_edge;然后把pps_comb打一拍输出这样抖动就降到了半个时钟周期以内。对于1MHz的时钟来说就是500ns的精度足够大多数应用了。5. 资源占用与性能优化经验5.1 综合后的资源消耗在Intel Cyclone IV EP4CE10上综合这个解码器的资源占用如下资源类型使用量总量占比Logic Elements187103201.8%Registers156103201.5%Memory Bits04239360%PLL020%可以看到资源消耗非常小一个入门级的FPGA就能轻松放下多路解码器。如果你需要同时解8路IRIG-B资源占用也不会超过15%。5.2 时钟频率的选择我用的1MHz采样时钟是经过权衡的。理论上你可以用更高的时钟来提高测量精度但收益有限。IRIG-B的码元宽度是毫秒级的1MHz的采样率意味着每个码元有2000到8000个采样点判决精度已经足够了。用更高的时钟只会增加功耗和资源消耗对解码性能没有实质提升。反过来说时钟也不能太低。如果降到100kHz2ms的高电平只有200个采样点判决阈值之间的间隔变小抗噪声能力会下降。1MHz是一个比较平衡的选择。5.3 多路解码的并行化如果你需要解多路IRIG-B最直接的方法就是把解码器模块实例化多次。每路独立的状态机、独立的计数器互不干扰。由于FPGA的并行特性多路解码的延迟和单路完全一样不会因为通道数增加而变慢。但有一点需要注意多路IRIG-B之间的1PPS输出可能会有相位差。如果各路信号来自不同的授时源这个相位差反映了源之间的同步精度。如果各路信号来自同一个源相位差应该为零。你可以在FPGA内部用一个高频计数器测量各路1PPS之间的时间差用来监测同步状态。6. 从解码到应用时间信息的后续处理6.1 BCD到二进制的转换解码出来的秒、分、时、年积日都是BCD格式。如果你需要做时间运算比如计算两个时间点之间的间隔就需要转成二进制。BCD转二进制可以用Double Dabble算法的逆过程也可以用简单的乘加运算// BCD转二进制以秒为例 wire [5:0] sec_bin sec_bcd[3:0] sec_bcd[7:4] * 10;对于秒和分范围0-596位二进制就够了。对于时0-235位。对于年积日1-3669位。这些转换在FPGA里就是几个加法器和乘法器资源消耗可以忽略。6.2 与系统时间的对齐IRIG-B给出的是绝对时间但FPGA内部通常只有一个自由运行的计数器。你需要把IRIG-B的秒脉冲作为基准在1PPS到来时把内部计数器清零这样就实现了系统时间与IRIG-B时间的对齐。对齐之后你可以用内部计数器来产生更高分辨率的时间戳。比如系统时钟是50MHz那么每个时钟周期就是20ns。在1PPS之间内部计数器可以提供20ns分辨率的时间信息远高于IRIG-B本身的1秒分辨率。6.3 实际项目中的应用案例在一个电力故障录波项目里我用这个解码器同时解4路IRIG-B信号分别来自不同的变电站。解码后的时间信息用来给采样数据打时间戳4路之间的同步误差控制在100ns以内。这个精度对于故障录波来说绰绰有余。另一个案例是靶场测量系统需要把多个测量站的数据对齐到统一时间基准。IRIG-B解码器输出的1PPS用来触发ADC采样确保所有站的采样时刻严格对齐。实测下来站间的同步误差小于50ns完全满足项目要求。7. 代码仿真与上板验证的完整流程7.1 用ModelSim做功能仿真在写Testbench之前你需要先搞清楚IRIG-B信号的时序。我通常用一个小脚本生成激励信号先产生连续两个P码8ms高、2ms低然后按照B000格式依次产生各个码元。为了简化可以只仿真前20个码元验证状态机能否正确进入S_FRAME并提取出秒字段。Testbench的核心部分// 生成一个码元 task send_symbol; input [1:0] sym_type; begin case (sym_type) 2b00: begin irig_b 1; #2000; irig_b 0; #8000; end 2b01: begin irig_b 1; #5000; irig_b 0; #5000; end 2b10: begin irig_b 1; #8000; irig_b 0; #2000; end endcase end endtask注意时间单位要跟时钟周期匹配。如果系统时钟是50MHztimescale设为1ns/1ps那么2000个时间单位就是2us对应1MHz时钟下的2个周期。等等这里有个容易搞混的地方Testbench里的#2000是2000ns而1MHz时钟的周期是1000ns。所以2ms的高电平应该是#2000000。我建议在Testbench里直接用timescale 1us/1ns这样#2000就是2000us也就是2ms写起来更直观。7.2 SignalTap抓波形的技巧上板验证的时候SignalTap是最重要的调试工具。但IRIG-B的码元周期是10ms而SignalTap的采样深度有限如果采样时钟太快抓不到完整的一帧。我的做法是用1MHz的采样时钟采样深度设为4096。这样能抓到大约4ms的数据足够看几个码元的波形。如果你想看完整的一帧可以把采样时钟降到100kHz采样深度不变就能抓到40ms的数据覆盖4个码元周期。抓波形的时候重点看这几个信号width_latch高电平宽度、symbol_type码元类型、symbol_idx码元位置、cur_state状态机状态。正常情况下width_latch应该在2000、5000、8000三个值附近波动symbol_type对应为00、01、10。7.3 常见问题速查表现象可能原因排查方法状态机卡在S_IDLE输入信号没有下降沿用示波器看输入信号是否有跳变P码识别为逻辑1高电平宽度不足检查信号源输出幅度和线路阻抗1PPS输出抖动大边沿检测延迟改用组合逻辑输出1PPS数据字段错位帧同步错误检查连续两个P码的检测逻辑信号恢复后不工作状态机没有超时复位加超时计数器强制回S_IDLE8. 写在最后的一些实操体会这个IRIG-B解码器我从第一版到现在稳定运行前后改了大概五六次。最大的体会是FPGA解码的核心不在于代码写得多复杂而在于对信号时序的精确理解。IRIG-B的协议本身很简单但实际信号在传输过程中会有各种畸变你的解码器必须能容忍这些畸变。滑动窗口滤波是我加得最值的一个功能。它只增加了不到20个逻辑单元但把解码的稳定性提升了一个档次。如果你也在做类似的串行协议解码强烈建议加上这个滤波。另外状态机的超时复位机制一定要有。现场环境千变万化信号丢失是常态。没有超时复位的状态机一旦跑飞只能靠人工复位这在无人值守的场景里是不可接受的。最后说一个调试技巧如果你手头没有IRIG-B信号源可以用FPGA自己产生一个。写一个简单的码元生成器按照B000格式输出然后环回给自己的解码器。这样既能验证解码逻辑又能当信号源用一举两得。