做NBDP全套系统的时候,我首先啃的就是ARQ单元模块。第一次看到窄带直接打印电报NBDP协议栈里那一堆握手状态时,说实话头是大的,但真要把它用Verilog在FPGA上跑起来,把状态机一条条理清之后,整个体系反而变得很清晰。这篇文章就是围绕“基于FPGA的NBDP系统ARQ单元模块的Verilog实现”这个项目做的完整复盘,把架构设计、模块划分、状态机细节、调试踩坑全部倒出来,给后面接手NBDP方向的朋友一个可以直接抄作业的参考。
这个项目要解决的核心问题其实很明确:NBDP(Narrow Band Direct Printing,窄带直接打印电报)是海事中短波频段里常用的数字通信方式,而ARQ(Automatic Repeat reQuest,自动请求重传)单元是整个NBDP系统可靠传输的关键。它负责发起呼叫、建立链路、确认应答、错误重传、链路维护和拆链。传统MCU方案做这套协议栈能跑,但要做到低延迟、高确定性和多通道并行处理,FPGA的优势就很明显了。用Verilog实现ARQ单元,相当于把协议栈里最核心的“交通警察”从软件搬进了硬件逻辑,实时性、稳定性都上了一个台阶。
如果你正在做NBDP收发信机、GMDSS相关终端、或者想用FPGA实现一个通信协议控制单元,这篇文章就是我这份实作记录的核心部分。下面直接进入正题。
1. NBDP系统里ARQ单元到底在负责什么
1.1 先梳理清楚NBDP的通信模型
NBDP本质上是一种面向电传打印的窄带数字通信系统,工作在MF/HF频段,采用FSK调制,把二进制字符流映射到两个频率上传输。很多人第一次接触NBDP时会被它绕晕,因为它不像以太网那样发包发帧,它的传输模型更像“电传打字机时代的上层协议”:一端打出来的字符,经过编码、调制、无线传输、解调、译码,在另一端直接打印出来。
这中间,ARQ单元的位置在“终端数据接口”和“调制解调器”之间。它对上接收终端要发送的字符流,对下控制FSK调制解调器的收发切换,同时负责解析对端发来的应答信号。换句话说,ARQ单元就是NBDP通信链路的中枢神经系统,没有它,收发双方就是两套盲发盲收设备,无法保证数据的正确到达。
NBDP有两种基本工作模式:一种是ARQ模式(点对点、双向自动请求重传),另一种是FEC模式(单向广播前向纠错)。我这个项目聚焦在ARQ模式上。ARQ模式的特点是一问一答,信息帧发过去之后必须等到对方确认帧,确认了才发下一帧,没确认就重传。这个机制保证了极高可靠性,但也对时序控制提出了苛刻要求。
1.2 ARQ单元模块的职责拆解
把ARQ单元从整个NBDP系统里单拎出来看,它的职责可以拆成四个子任务:
第一是呼叫和建链。呼叫方要能发出选择性呼叫(SELCALL)序列,被叫方收到后要能判断是不是呼叫自己,并自动回答。这个应答链路是整个ARQ通信的开端,也是状态机设计中最容易出错的部分。
第二是数据帧收发切换。NBDP通常采用时分复用的半双工模式,收发不是同时进行的。ARQ单元必须精确控制发信机在“发射窗口”和“接收窗口”之间切换,切换早了会把对端的应答切掉,切换晚了又会漏掉自己的发送时隙。这个窗口对齐,在FPGA里靠计数器就能做,但窗口边界的抖动不能超过一个比特周期的几分之一。
第三是错误检测和重传。每一帧数据都要附加上校验信息,接收端校验出错时,要么回一个否定应答,要么干脆不回应,发送端根据超时和应答情况决定是否重传。重传次数是有限的,超过了就要把链路状态标记为异常,并触发告警。
第四是链路监视和拆链。链路建立起来之后并不是一直占据无线信道,通信完毕要有拆链流程。链路长时间没有有效业务时,还要有自动轮询、空闲检测等机制,防止误认为链路断开。
我最初在设计时差点把执行顺序搞反:一上来就写状态机,结果状态机越写越乱。后来冷静下来,先用一张表把ARQ单元要处理的“所有事件”列了一遍,从用户发起呼叫,到收到对方确认,到校验失败,再到链路超时,每个事件对应一个明确动作,状态机才变得清爽起来。
1.3 ARQ单元与调制解调器的边界划分
这个项目里还有一个很重要的原则:ARQ单元不负责调制解调。FSK调制、鉴频、载波检测、位同步恢复这些物理层功能,应当单独封装成调制解调模块,ARQ单元只是消费它们给出的数据流和状态标志。
我遇到过有人把解调器输出的位流直接接到ARQ单元内部去判决,这种方式在仿真里能跑,一到真实信号下就出问题。因为无线信道带来的电平起伏、频率偏移、突发干扰,都需要在调制解调层做预处理。ARQ单元要处理的应该是“已经恢复出来的干净比特流和时钟有效信号”,而不是“模拟波形”。
所以我的顶层设计中,ARQ单元对外接口很干净:
| 接口方向 | 信号名 | 位宽 | 说明 |
|---|---|---|---|
| 输入 | clk_50m | 1 | 系统主时钟,50MHz晶振 |
| 输入 | rst_n | 1 | 低电平异步复位 |
| 输入 | rx_data_i | 1 | 解调器恢复的串行位流 |
| 输入 | rx_clk_i | 1 | 解调器恢复的位时钟有效脉冲 |
| 输入 | tx_data_rdy_i | 1 | 终端数据准备好 |
| 输入 | tx_data_i | 8 | 待发送的ASCII字符(或上层协议字节) |
| 输入 | call_addr_i | 16 | 被叫地址(选择性呼叫用) |
| 输出 | tx_data_o | 1 | 发往调制器的串行位流 |
| 输出 | tx_clken_o | 1 | 发送位时钟有效脉冲 |
| 输出 | link_status_o | 2 | 链路状态:空闲、呼叫中、已建链、异常 |
| 输入 | mode_sel_i | 1 | ARQ模式使能 |
| 输出 | frame_error_o | 1 | 校验失败指示 |
| 输出 | retransmit_cnt_o | 7 | 当前重传计数 |
2. 为什么用FPGA做ARQ单元,以及架构选型的关键思路
2.1 FPGA方案相比MCU方案的优势和代价
NBDP系统之前用单片机做,比如ARM Cortex-M系列,跑一个RTOS,把协议栈写成C代码,也能实现ARQ功能。那为什么还要放到FPGA上?我在项目里总结下来有三点核心差异。
第一是时序确定性。ARQ协议最怕时序飘忽不定。MCU受中断、任务调度、缓存管理影响,某个字符处理的响应时间可能从几微秒漂到几十上百微秒。FPGA不同,时钟滴答一到,状态机固定跳转,Combinational路径上的延迟是可预估的。NBDP很多超时窗口都是以毫秒甚至几百毫秒为单位,看起来MCU也能应付,但一旦涉及多个通道同时工作,MCU的串行处理能力就成了瓶颈。
第二是多通道并行。港口区、海岸电台往往要同时处理多条NBDP链路。MCU方案做多通道就得“时间片轮询”,通道一多,实时性急剧下降。FPGA方案可以一个ARQ单元例化多份,每个通道独占一套状态机,逻辑资源在几百个LUT以内就能实现一个通道,价格和功耗都可控。
第三是软硬件协同测试方便。FPGA里可以同时放下ARQ模块、FSK解调模块、测试数据生成器、在线逻辑分析仪,把整个链路放在一片芯片里闭环验证。这在MCU方案里很难做到同等性价比。
当然,FPGA也有代价。开发周期长、调试手段相对底层、协议升级要重新综合布局布线。如果项目只是做个验证样机、通信量很低,那MCU确实更灵活。我的判断是:凡是要求“链路建立时间可控、重传周期精确、多通道并发”的NBDP设备,FPGA是更合适的选择。
2.2 三个影响全局的设计决策
第一个决策:位时钟为什么不在ARQ模块内部产生全局使能,而是跟随解调器恢复的rx_clk_i走。NBDP虽然不是高速信号,但无线信道里的位同步抖动很常见。如果ARQ单元自己瞎猜时钟,一旦收发双方晶体偏差加上信道噪声叠加,采样点就会逐渐偏移到比特边缘,误码率剧烈上升。解调器用锁相环或者过采样恢复出来的位时钟,本身已经做了边沿对齐,ARQ单元只需要在rx_clk_i有效时取一次样即可。
第二个决策:发送端采用“字符级缓冲寄存器+连续移位输出”的结构。ARQ协议里,一个发送周期内需要先发同步头、再发字符码、再发校验字段。如果每发一位都从RAM里读一次,控制逻辑会很复杂,容易在时序上留空隙。更好的做法是:每次要发送一帧时,先把整帧内容按序写入一个发送移位寄存器,然后由一个发送定时器按照50波特速率打拍移出。这样发送位流的比特间隙绝对均匀。
第三个决策:状态机的运行时钟统一用“协议时隙时钟”,而不是直接跑50MHz主时钟。因为ARQ协议的很多动作是以字符时隙为单位的,比如“当前时隙发送、下一时隙接收”。我设计了一个基础时隙计数器,以50ms为一个协议时隙(这个值可以根据实际信道参数调整),状态机在每个时隙边沿做一次决策,内部逻辑用50MHz时钟做细粒度控制。这样高层次的状态转移和物理层的比特级控制解耦,仿真调试时非常直观。
2.3 资源占用预估
很多人在立项时最关心FPGA资源够不够。我以中等规模的国产FPGA为例,比如逻辑单元在20K LUT左右的器件,做了初步估算:
ARQ状态机大概需要150到300个LUT,字符编解码查表大约50到100个LUT,发送/接收移位寄存器加接口大约200到400个LUT,再加上定时器、计数器、告警逻辑,一个ARQ通道总共不超过1500个LUT。如果你还要在FPGA里集成FSK调制解调、NBDP编解码、音频接口,那整体资源会到2万到5万LUT级别,一般中端FPGA完全能装下,而且还能留下大量余量给其他功能。
所以不用一上来就焦虑资源不足,ARQ单元本身不是吃资源的大户,真正的资源消耗大户是调制解调里的滤波器组和编解码查找表。设计时一定要把ARQ模块做成可复用、参数化的模块,方便后续例化多通道。
3. ARQ单元核心模块的Verilog分层实现
3.1 模块划分总览
我最终把ARQ单元分成了五个Verilog模块,下面这张模块清单可以直接当成项目骨架用:
| 模块名 | 功能定位 | 关键输出 |
|---|---|---|
| nbdp_clk_gen | 生成波特率打拍信号和协议时隙信号 | baud_tick、slot_tick |
| nbdp_arq_fsm | 核心状态机,所有链路状态转移在这里完成 | link_status、ctrl_cmd |
| nbdp_frame_codec | 字符编码/译码,NBDP 7位码与终端字符互相转换 | code_out、char_valid |
| nbdp_rx_parser | 接收位流解析,产生帧开始/帧结束、校验结果 | frame_header、crc_ok |
| nbdp_timer | 重传计时、超时计时、链路监视计时 | timeout_event、retry_count |
这种划分的好处是每一层都只依赖下层接口,不相互耦合。比如frame_codec并不知道arq_fsm现在的状态是什么,它只负责“给我一个终端字符,我给你一个7位码”或者反过来。换掉NBDP码表,不影响状态机;修改重传超时参数,不影响收发解析。
3.2 波特率时钟产生模块nbdp_clk_gen
NBDP在MF/HF频段常用波特率是50波特,一个码元宽度20ms。系统主时钟50MHz,也就是每码元之间有100万个时钟周期。直接用计数器分频即可,但要注意分频不能被“简单到溢出就翻转”给糊弄过去,这样会产生定时抖动。
我习惯用“比较器清零式分频”:
module nbdp_clk_gen #( parameter CLK_FREQ = 50_000_000, parameter BAUD_RATE = 50 )( input wire clk, input wire rst_n, output reg baud_tick ); localparam DIV_CNT = CLK_FREQ / BAUD_RATE - 1; reg [31:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 32'd0; baud_tick <= 1'b0; end else if (cnt >= DIV_CNT) begin cnt <= 32'd0; baud_tick <= 1'b1; end else begin cnt <= cnt + 32'd1; baud_tick <= 1'b0; end end endmodule这样每次baud_tick出现一个高脉冲,脉冲宽度一个系统时钟周期。需要注意,CLK_FREQ / BAUD_RATE 在Verilog里是整数除法,如果主时钟频率不能整除波特率,会带来累计误差。解决办法是把系统里所有时钟安排成50MHz这类能被50整除的频率。千万别用27MHz去分频50波特,除非你用小数分频或者锁相环修正,否则误差一天下来会积累出好几帧错位。
3.3 接收位流解析模块nbdp_rx_parser
接收解析模块要处理的核心问题是:如何从串行位流里找到一帧的起始位置。NBDP ARQ帧结构里通常有前导码和同步码,我用的是一个16位同步字加一个帧长度计数器。
module nbdp_rx_parser #( parameter SYNC_WORD = 16'hA5C3, parameter FRAME_BITS = 40 )( input wire clk, input wire rst_n, input wire rx_clk_i, // 位时钟有效脉冲 input wire rx_data_i, // 解调后的数据位 output reg frame_start, // 帧开始指示 output reg frame_end, // 帧结束指示 output reg crc_result // 校验结果 ); reg [1:0] bit_3of5; // 连续三个位多数表决 reg [15:0] shift_reg; reg [7:0] bit_counter; reg pre_sync_flag; reg expect_data; reg [31:0] sync_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin shift_reg <= 16'd0; end else if (rx_clk_i) begin shift_reg <= {shift_reg[14:0], rx_data_i}; // 检测同步字 if (shift_reg == SYNC_WORD) begin frame_start <= 1'b1; bit_counter <= 8'd0; expect_data <= 1'b1; end end end endmodule注意上面这段代码的关键点:同步字检测用的是“移位寄存器当前值等于同步字”,也就是边比较边移位。这里有个非常经典的坑——如果你的同步字设成全0或全1,或者在数据段里会频繁出现和同步字一样的比特组合,就会出现“假同步”。我在项目里把同步字选成了0xA5C3这种高低交替序列,并且要求同步字后面必须紧跟一个合法的NBDP帧类型字段,只有两者都匹配,才真正进入帧接收状态。
NBDP是半双工通信,ARQ接收帧时还要区分“应答帧”和“信息帧”。这两种帧的帧类型字段不一样。我在rx_parser里加了一个frame_type输出,供状态机判断接下来的动作。
3.4 发送侧与帧组装
发送侧我也构建了一个独立的模块,把“组帧”和“移出”分开。组帧阶段,根据状态机当前的命令,选择发送同步字、地址字段、数据字段、校验字段,按顺序装进一个移位寄存器;移出阶段,由baud_tick逐位移出。
module nbdp_tx_assembler ( input wire clk, input wire rst_n, input wire baud_tick, input wire [7:0] cmd_type, // 帧类型 input wire [15:0] address, input wire [7:0] data_byte, input wire send_start, output reg tx_bit );这个模块里最重要的一点是:数据字段必须在send_start之前就准备好,不能在发送过程中再去改数据,否则会发出去半个旧数据加半个新数据。我在接口里专门加了一条设计约定:发送方API在置起send_start的同时,必须保证data_byte引脚上的值已经稳定至少一个系统时钟周期。这条约定写进模块头注释里,调试时能省很多事。
4. ARQ状态机的完整设计与时序约束
4.1 状态转移表
ARQ状态机是整个项目里最核心、也最容易被改出问题的地方。我一开始设计时只考虑了简化的四状态模型:空闲、发送、等待确认、重传。结果调试时发现少了链路异常处理、呼叫阶段的应答判断、通信结束拆链这些状态,导致某些异常场景下链路卡死。最终版我用了八个状态:
| 状态编号 | 状态名 | 状态含义 |
|---|---|---|
| S0 | IDLE | 空闲或收到呼叫请求 |
| S1 | CALL_SEND | 发送选择性呼叫 |
| S2 | WAIT_CALL_ACK | 等待被叫应答 |
| S3 | ESTABLISHED | 链路已建立,正常传数 |
| S4 | DATA_SEND | 发送信息帧 |
| S5 | WAIT_ACK | 等待信息帧确认 |
| S6 | RETRANSMIT | 进入重传流程 |
| S7 | LINK_BREAK | 链路异常,等待释放 |
状态转移逻辑如下:
- IDLE收到上层呼叫命令,跳转到CALL_SEND;
- CALL_SEND发送完呼叫序列,启动应答定时器,跳转到WAIT_CALL_ACK;
- WAIT_CALL_ACK收到有效应答,置链路已建立标志,跳转到ESTABLISHED;超时未收到,重发呼叫,计数加一;重发次数超过门限,跳转到LINK_BREAK;
- ESTABLISHED收到上层数据发送请求,进入DATA_SEND;
- DATA_SEND发送完成,启动确认定时器,跳转到WAIT_ACK;
- WAIT_ACK收到确认帧且校验正确,回到ESTABLISHED,继续等待下一帧;超时或校验失败,进入RETRANSMIT;
- RETRANSMIT里检查重传计数,未超限就回DATA_SEND重新发送,超限就跳LINK_BREAK;
- LINK_BREAK执行拆链动作,释放无线信道,返回IDLE。
这张状态表看起来简单,但项目里真正花时间的不是画状态表,而是处理状态转移过程中的“中间态”。比如从DATA_SEND跳到WAIT_ACK时,发射机不能立刻切到接收模式,有一段收发切换保护时间。我在状态机里没有专门设切换状态,而是用计数器在WAIT_ACK的入口处强制等待若干个时隙后再启动确认定时器。有的FPGA工程师喜欢再拆一个TX_RX_GUARD状态,也行,看个人习惯,只要保证保护时间足够。
4.2 ARQ状态机的Verilog实现骨架
我给出一个简化版的状态机代码结构,重点在于写法规范、综合友好:
module nbdp_arq_fsm #( parameter RETRY_MAX = 3, parameter ACK_TIMEOUT = 100 )( input wire clk, input wire rst_n, input wire slot_tick, // 协议时隙脉冲 input wire frame_received, // 帧接收完成 input wire rx_ack_valid, // 有效应答 input wire rx_data_valid, // 有效信息帧 input wire crc_ok, input wire tx_done, output reg [2:0] state, output reg [7:0] retry_count ); localparam IDLE=3'd0, CALL_SEND=3'd1, WAIT_CALL_ACK=3'd2, ESTABLISHED=3'd3, DATA_SEND=3'd4, WAIT_ACK=3'd5, RETRANSMIT=3'd6, LINK_BREAK=3'd7; reg [15:0] ack_timer; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; retry_count <= 8'd0; ack_timer <= 16'd0; end else if (slot_tick) begin case (state) IDLE: begin if (tx_call_req) state <= CALL_SEND; end CALL_SEND: begin if (tx_done) begin ack_timer <= 16'd0; state <= WAIT_CALL_ACK; end end WAIT_CALL_ACK: begin if (rx_ack_valid && crc_ok) begin retry_count <= 8'd0; state <= ESTABLISHED; end else if (ack_timer >= ACK_TIMEOUT) begin if (retry_count >= RETRY_MAX) state <= LINK_BREAK; else begin retry_count <= retry_count + 1; state <= CALL_SEND; end end else begin ack_timer <= ack_timer + 1; end end ESTABLISHED: begin if (tx_data_rdy_i) state <= DATA_SEND; end DATA_SEND: begin if (tx_done) begin ack_timer <= 16'd0; state <= WAIT_ACK; end end WAIT_ACK: begin if (rx_ack_valid && crc_ok) begin state <= ESTABLISHED; end else if (ack_timer >= ACK_TIMEOUT) begin if (retry_count >= RETRY_MAX) state <= LINK_BREAK; else begin retry_count <= retry_count + 1; state <= RETRANSMIT; end end else begin ack_timer <= ack_timer + 1; end end RETRANSMIT: begin state <= DATA_SEND; end LINK_BREAK: begin if (release_cmd || cleanup_done) state <= IDLE; end default: state <= IDLE; endcase end end endmodule有两点要特别提醒。
第一,verilog状态机的复位值或者默认值都必须明确,不能仿真里靠X态初始化。我见过很多人在写state <= state;这种保留语句时漏写,导致综合后出现锁存器。上面的代码里default分支强制回到IDLE,是为了防止综合工具推断出意外锁存。第二,ACK_TIMEOUT这个参数的单位是“协议时隙”。如果你的slot_tick是10ms一次,那ACK_TIMEOUT=100表示超时一秒。这个值一定要根据实际信道的收发切换时间、传播延迟留足余量,不能只看理论传播延迟。海上中短波链路存在多径和大气噪声,应答返回时间波动很大,我实际项目里设置的超时一般在1.5倍理论周期以上,不然误判超时的概率会显著上升。
4.3 收发切换与保护时间的处理
ARQ半双工通信最怕的不是数据自己出错,而是收发切换瞬间造成的载波泄漏和信号中断。我在FPGA里专门留了一个收发切换寄存器:
- 状态机进入DATA_SEND时,先置tx_key=1,表示发射机开始加电/锁定频率;
- 等待一个HOLD_OFF_TIME(一般5到20ms),再开始移出数据;
- 数据发完,再等待一个taill时间,置tx_key=0,进入接收。
发送完成信号tx_done必须是在最后一个数据位移完之后,经过taill延时才产生,不能把“最后一个位移出”当成“发送完成”。否则你会看到最后一帧数据被接收端的解调器吃掉一半,因为前端的发送功率已经被切断了。
5. NBDP字符编解码与校验逻辑的实操要点
5.1 字符映射表的设计
NBDP不是直接传输ASCII,而是使用一种7位均衡码编码后的字符字,每个7位码字中“1”的个数被限制在固定范围,并且码字之间的汉明距离足够大,以便于检错。实现时最直接的方式是查表,而不是在FPGA里做复杂的编码算法。
我在项目里用的是两个ROM式always块,一个做编码(终端字符到7位码),一个做译码(7位码到终端字符)。因为NBDP字符集表项不算多,用case语句展开最直观。
reg [6:0] tx_code; reg [7:0] rx_char; always @(*) begin case (tx_char) 8'h41: tx_code = 7'b0010110; // 'A' 8'h42: tx_code = 7'b0101001; // 'B' 8'h43: tx_code = 7'b1001100; // 'C' // 其余按标准码表逐一列出 default: tx_code = 7'b0000000; endcase end这里要特别注意,编码表和译码表的默认值不能随意填0。填0可能会导致一个非法码字被当成空字符,而实际上很多NBDP错误检测算法恰恰是把非法码字作为检错依据的。我的做法是:编码表默认值指向“替换字符”0x2A(星号),译码表默认值打上非法标志,让上层知道这个码字不可信。
5.2 校验逻辑:不只是CRC
NBDP的帧结构里有帧校验序列,我用的CRC-16算法,在FPGA里实现CRC最稳妥的方式是查表或者按位迭代。按位迭代代码虽然慢,但逻辑清晰,也容易做形式化验证。
function [15:0] crc16_update; input [15:0] crc_reg; input data_bit; reg [15:0] tmp; begin tmp = crc_reg; tmp = tmp ^ {15'd0, data_bit}; if (tmp[0]) tmp = (tmp >> 1) ^ 16'hA001; else tmp = (tmp >> 1); crc16_update = tmp; end endfunction这段代码实现的是CRC-16/MODBUS多项式0xA001,用在NBDP帧校验上没有硬性问题,但如果你要对接具体设备,务必以设备协议手册里的多项式为准。我在项目里就遇到过对接方用的CRC初值不是全1,导致两边算出的校验码对不上,排查了整整一天。最后翻协议手册才发现对方用的是CRC-16/X25的初值0xFFFF和反转输出。这个教训很深刻:校验算法本身不复杂,复杂的是“两边约定了同一个非标准参数”。
5.3 位同步与多数表决
在解调出来的rx_clk_i信号上,我加了一个“三取二”多数表决器。原因很简单:无线信道上的噪声会导致个别采样点翻转,如果每个比特只采一次,一个突发的窄脉冲就会造成一个误码。三取二表决器的实现方式是在每个位周期内部采样三次,然后取两次相同的结果。
reg [2:0] sample_reg; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sample_reg <= 3'd0; end else if (sample_en) begin sample_reg <= {sample_reg[1:0], rx_data_i}; end end wire majority_bit = (sample_reg[0] & sample_reg[1]) | (sample_reg[0] & sample_reg[2]) | (sample_reg[1] & sample_reg[2]);多数表决能滤除单个高频毛刺,但对持续半位周期以上的干扰无能为力。真正对付持续干扰还是靠重传机制,多数表决只是降低无效重传的概率。
6. 调试经验与常见问题排查
6.1 假同步导致的“帧错位”问题
这是我在项目里遇到最头疼的问题。现象是:室内测试时一切正常,一到射频环境里,rx_parser频繁报frame_start,但紧接着就报crc错误,链路几乎无法建立。
排查过程是这样的:先用在线逻辑分析仪抓取rx_data_i和shift_reg,发现解调器在某些噪声片段下会生成连续的0x A5C3 模式。也就是说,噪声本身撞上了同步字。这并不完全是算法错误,而是我一开始只检测“同步字”一个条件。
解决方案分两步:第一步,加长同步字,从16位加到24位,噪声撞上24位随机序列的概率几乎为0;第二步,增加帧类型字段二次校验,收到同步字后,必须连续确认了帧类型字段有效才算真正找到帧头。
如果你不想改同步字长度,还有一种变通做法:连续检测到两次同步字后才确认同步。这在中低速系统中很常见,代价是多消耗一点同步时间。
6.2 状态机卡死和超时误判
状态机偶尔会停在WAIT_ACK不动,日志里没有报错,也没有进展。检查之后发现问题出在ack_timer的计数使能上:状态机在WAIT_ACK里用slot_tick作为计时脉冲,但slot_tick本身是由rx_clk_i同步产生的,接收方向一旦没有信号,rx_clk_i就会停,slot_tick也停了,超时判断永远不会触发。
这暴露了一个设计弱点:协议时隙计数应该和物理信号解调完全解耦。超时计时器的基准应当始终来源于系统主时钟,而不是从恢复时钟派生。这个问题在FEC模式里不明显,因为FEC是广播接收,不需要主动确认,但在ARQ模式下,等待确认时接收方向本来就可能是空闲的,如果直接用恢复时钟做计时,整个超时机制就直接失效了。
6.3 收发切换时刻的毛刺
在发给发射机的tx_key信号上,我用在线逻辑分析仪测到了明显的尖峰毛刺。原因是tx_key在状态机里和baud_tick组合逻辑上直接关联,遇到组合逻辑传播延迟,毛刺被放大到了射频电路里。解决方法是把所有对外控制信号全部用寄存器打一拍,绝不能用组合逻辑直接输出到物理层接口:
always @(posedge clk or negedge rst_n) begin if (!rst_n) tx_key_q <= 1'b0; else begin tx_key_q <= tx_key; tx_data_en_q <= tx_data_en; end end这个“寄存器输出原则”在FPGA设计里基本是铁律,但在实际项目里还是会因为赶进度被绕过。尤其是状态机的状态标志直接接在物理层的控制上时,很容易踩坑。
6.4 仿真和真实的差距:一定要加噪声模型
项目刚开始做仿真验证时,我用的是理想信道模型:rx_data_i直接由测试激励按帧格式给,完全没有任何噪声。这样的仿真跑一百次都不会出错,但一到真实射频链路就暴露问题。
后来我在测试环境里加了一个信道模拟模块,在测试激励里注入三种噪声:随机比特翻转、突发脉冲干扰、频率偏移导致的时钟抖动。用这三类噪声反复跑链路建立、数据传送、异常拆链的测试用例,把状态机的每条转移路径都覆盖到。测试结果比单纯跑功能仿真能说明问题得多。我建议任何做NBDP ARQ模块的团队,都把“信道模拟器”和“协议功能测试”分开建两个仿真环境,功能测试用干净激励验证逻辑正确性,信道模拟器则重点验证抗干扰能力和重传机制的鲁棒性。
6.5 常见问题速查表
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 链路建不起来 | 同步字被噪声干扰 | 加长同步字,增加帧类型二次校验 |
| 经常超时重传 | ACK_TIMEOUT设置过短 | 留足收发切换时间,超时设为理论周期的1.5倍以上 |
| 状态机卡住不动 | 恢复时钟停振导致超时器停走 | 超时计时必须用系统主时钟 |
| 发射时有毛刺 | 组合逻辑直接驱动物理输出 | 所有对外控制信号用寄存器打一拍 |
| CRC频繁错误 | 双方CRC参数不一致 | 核对多项式、初值、输出反转 |
| 多通道互相干扰 | 模块复用时计数器未隔离 | 每个通道单独例化clk_gen和timer模块 |
7. 一些收尾的经验补充
这套ARQ单元从规格梳理到可综合代码跑通,我前后迭代了三轮。第一轮只追求功能跑通,状态机写得比较粗糙,仿真掩盖了真实信道上很多细节问题;第二轮加了收发切换保护、超时机制、假同步抑制,稳定性上了一个台阶;第三轮把参数全部改成parameter定义,方便在系统集成时根据不同信道特性做调整。
我现在回头看的经验是:协议模块的FPGA实现,不在于Verilog写得有多花哨,而在于对协议行为的建模是否足够细致。ARQ单元本质上是一台“时序机器”,你要把无线信道的每个延迟、每个异常场景都考虑到,状态机的每个分支都有明确的出口,这样综合出来的电路才是可靠的。
如果你后面也要做NBDP系统的ARQ单元,建议从最简单的“发送-确认-重传”闭环开始,先在仿真里把这一条主路径跑稳,再逐步加入呼叫建立、链路监视、异常拆链。千万别一上来就试图覆盖所有异常场景,那样状态机复杂度会失控,调试起来会非常痛苦。