1. 项目概述
1.1 核心需求解析
串并转换,这四个字可以说是数字逻辑设计里的基本功,也是很多通信类项目绕不开的一道门槛。串行转并行,是把一路按比特到达的数据流,按照约定好的位宽攒成一整包数据;并行转串行,则是把一条宽总线上的数据按位发出去。很多同学一到写串并转换的Verilog就头疼,要么时序上出错,要么仿真波形对不上,更常见的问题是写完功能对了,但一上板子,采样时刻不对,数据整个错位。
其实串并转换的底层逻辑非常简单:串行进,并行出,本质上就是一组移位寄存器;并行进,串行出,本质上就是一个多路选择器加上计数控制。但工程上的串并转换,要处理的可不只是这两句话。位宽匹配、valid握手、时钟域配合、乒乓缓冲,每一个都藏着坑,而且这些坑往往是写实验代码完全遇不到的,只有做实际项目才会被狠狠教育。
这篇内容适合正在做FPGA开发、数字IC设计,或者刚学完Verilog基础语法准备上手通信接口设计的同学。串并转换是所有接口协议的基础构件,SPI协议、UART帧解析、SerDes链路、ADC数据采集,底层全都有它的影子。把这一块吃透,后面看任何带“串行”二字的模块都会轻松很多。
1.2 应用场景分析
串并转换的应用场景比大多数同学想象的要广得多,我挑几个最有代表性的场景说。
第一个是SPI协议的主机端收发。SPI是一个典型的串行同步协议,主机要发送命令时,把并行寄存器里的数据转换为串行时钟上的比特流,SCLK一个边沿送出去一位;接收从机数据时,又要把MISO上的位流攒成并行字节。这种场景里,串并转换模块几乎就是整个通信接口的核心引擎。
第二个是ADC/DAC的数据接口。现在稍微像样一点的ADC,分辨率都到12位、16位了,但ADC引脚有限,不可能用16根并行线直接连FPGA。于是ADC内部通常把采样值按位串行吐出,FPGA这边得有一个串转并模块,把连续到达的比特按帧对齐、按位宽拼装,还原出完整的采样值。这里的难点是对齐和使能,帧同步信号来了,串转并模块才开始计数积攒,什么时候攒够了就输出一个并行有效标志。
第三个是SerDes和高速串行链路。这类场景里串并转换的位宽和速率都远高于常规逻辑设计,LVDS差分对一根线上可能跑到几百Mbps甚至几个Gbps,这时候通常需要借助IDDR、ISERDES这样的原语来做底层比特收割,再往上才是用户逻辑里的FIFO和帧处理。虽然硬件原语和手写移位寄存器看起来是两码事,但核心思想完全一样,只是把“时序控制”这一层交给了专用硬件资源。
第四个是低速总线与字节流的速率匹配。比如UART收发,一帧10比特里的起始位、数据位、停止位,需要按位采样和拼装。还有并转串场景,比如把8位并行数据按位送到单线的LED灯带控制端、单线协议传感器上,都需要类似的处理。
可以说,只要涉及“一条线,一位一位传”,就离不开串并转换。有时候它独立存在,有时候它藏在协议模块内部,但不管藏在哪里,它的设计质量直接决定整个通信链路的稳定性。
2. 整体设计思路与方案选型
2.1 同步时序下的两种基本方案
拿到一个串并转换需求,第一步是搞清楚数据率的关系。发送端和接收端的时钟是同一个吗?位时钟和字节时钟的比例是多少?这些问题直接决定方案选型。
如果发送端和接收端完全同源,位时钟和字节时钟是最简单的整数倍关系,那么直接用移位寄存器就能搞定,代码最简洁,时序也最好收敛。这种方案在SPI主机侧很常见,发送和接收共用同一个时钟树,SCLK由主时钟分频得到,每来位时钟边沿就移一位,移满8位就置位一个字节有效标志。
那为什么我还听说过用计数器加多路选择器的方案?这通常是为了复用资源。串转并如果只用移位寄存器,那么每转成一个并行字节,需要一组和字节位宽等宽的触发器。如果数据通路里有几十路通道,每个通道都要独立的移位寄存器,LUT和FF消耗会很高。用计数器加选择器的思路,计数器决定当前采到的是第几位,然后把这个比特写到目标寄存器对应的位位置,本质上是“多次写入一个寄存器的不同位”。这种写法在做并转串时更常见,因为并行数据按位送出去本身就是数据选择的过程,用移位寄存器和用多路选择器,两者的资源消耗和代码可读性差别明显。
两种方案的取舍,我直接做过一次对比,放在表格里更直观。
| 实现维度 | 移位寄存器方案 | 计数器+选择器方案 |
|---|---|---|
| 代码复杂度 | 低,一行移位操作 | 中,需要计数状态和控制逻辑 |
| 资源占用 | 每通道一组独立移位寄存器 | 寄存器可共享,多路场景省资源 |
| 时序收敛性 | 简洁,路径短 | 选择器级联可能增加组合延迟 |
| 适用场景 | 通道少、逻辑简单的接口 | 多通道复用、并转串为主的设计 |
实际项目里我见过有人拿着移位寄存器方案硬套多通道场景,逻辑资源翻了好几倍,本来能塞进一个小规模FPGA的设计被迫升到更大型号。所以这个选型不是纯粹的个人喜好,是真有工程后果的。
2.2 握手信号与数据对齐策略
串并转换写对波形容易,写对握手逻辑比较难。比如一个串转并模块,8位攒满了,输出端给出一包完整数据,但接收方那时候正在忙别的,没空接管这包数据。如果发送方不管不顾直接覆盖下一包,这一包就丢了。所以串并转换模块在真正作为独立模块复用时,几乎都需要valid/ready握手机制。
最基础的握手逻辑是:发送方置位valid,告诉接收方“我有数据了”,接收方在空闲时拉低ready表示“我没空”,发送方看到ready再决定是否更新数据。串转并模块攒满一字节后拉高valid,接收方ready拉高,双方一拍完成握手,发送方清空移位寄存器开始攒下一包。
还有一个更隐蔽的问题:串行数据是在位时钟边沿一位一位进来的,但并行数据是字节时钟频率下才有效。两个时钟的关系如果不对齐,或者字节的边界不清晰,攒出来的第一个字节可能只有七位有效,第三位其实是上一帧的末尾。这种数据错位问题,做仿真的时候很难暴露出来,一上板子就会看到解析出来的数据要么全是乱码,要么偶尔错一个字节。
解决对齐问题的标准做法是,用帧同步信号作为串转并模块的启动闸门。通信协议里通常有帧头、同步码、或者专门的同步脉冲信号,模块检测到这个标志之后,才开始按位计数。有些场景没有外部帧同步,就需要模块自己从数据流里找边界,比如检测到一个明确的起始位(UART里的下降沿,SPI里的CS下降沿),从起始沿之后的第一个位时钟开始计数。
还有一种场景是并转串的字节对齐问题。并行数据从上游处理器进来,写入了并行缓冲区,但发送端怎么知道这包数据哪一位是先发的呢?高位在前还是低位在前?这就是bit order的问题。很多人第一次写并转串,发送数据和预期的波形完全反了,原因就是没搞清楚数据手册里要求的MSB first还是LSB first。
2.3 为什么选择模块化封装而不是裸逻辑
串并转换如果只写一次,裸逻辑完全够用,但实际项目中串并转换一般不会只出现在一个角落,很可能在发送通路、接收通路、甚至是多个通道里重复出现。这时候继续用裸逻辑,每个地方都抄一遍,代码行数膨胀,可读性下降,最要命的是如果发现了一个bug,得跑到每个复制的地方去改,漏改一处就是一颗雷。
模块化封装是更好的选择,把串并转换的核心逻辑包成一个参数化的模块,位宽可配置、方向可配置、接口风格统一。这样做有几个好处:一是重复使用,改个参数就能换场景;二是仿真验证可以复用同一套testbench,不同参数下跑同一套验证用例;三是代码评审和问题定位的粒度更清晰,出问题只需要检查模块内部和模块接口两层边界。
有个容易犯的毛病是过度抽象。串并转换模块本身逻辑不复杂,如果把握手协议、缓冲管理、时钟分频全部塞到一个模块里,参数十几个,接口信号二三十个,那反而让使用成本变高。我的习惯是模块只做“串转并、并转串、握手”三件事,时钟分频和缓冲管理一律放到外面。模块就该像积木,单个模块职责单一,组合在一起才灵活。
3. 核心实现:串行转并行(SIPO)RTL设计
3.1 基础移位寄存器实现
先把最核心的移位功能写出来。假设串行数据sipo_din,按位进入,每输入八个比特后,并行数据输出有效。
module sipo #( parameter DATA_WIDTH = 8 ) ( input wire clk, input wire rst_n, input wire bit_valid, // 位有效脉冲,在位时钟下产生 input wire sipo_din, // 串行输入数据 output reg [DATA_WIDTH-1:0] pdata_out, // 并行输出数据 output reg byte_valid // 并行输出有效 ); reg [DATA_WIDTH-1:0] shift_reg; reg [3:0] bit_cnt; wire shift_en; assign shift_en = bit_valid; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin shift_reg <= {DATA_WIDTH{1'b0}}; bit_cnt <= 'd0; pdata_out <= {DATA_WIDTH{1'b0}}; byte_valid <= 1'b0; end else if (shift_en) begin // 低位先入,攒满后整个寄存器内容就是完整的数据包 shift_reg <= {shift_reg[DATA_WIDTH-2:0], sipo_din}; if (bit_cnt == DATA_WIDTH - 1) begin bit_cnt <= 'd0; pdata_out <= {shift_reg[DATA_WIDTH-2:0], sipo_din}; byte_valid <= 1'b1; end else begin bit_cnt <= bit_cnt + 1'b1; byte_valid <= 1'b0; end end else begin byte_valid <= 1'b0; end end endmodule代码逻辑不复杂,但有两个细节值得展开。
第一个是输出数据的时序。串行数据到达第八位时,移位寄存器里的内容是前七位加当前位。这个时候同时完成两件事:把完整数据写给并行输出端口,同时把byte_valid拉高。要注意的是,byte_valid是一个单周期脉冲,接收方在这个脉冲有效的那个时钟沿去采pdata_out,就能拿到完整的一包数据。如果byte_valid持有多个周期,接收方必须用握手信号确认已经拿走,否则下一包数据到来时会把上一包覆盖掉。
第二个是位计数和移位同步的关系。bit_cnt从0数到DATA_WIDTH-1,正好是DATA_WIDTH个有效位。一个常见的错误是计数的起始条件设成1,结果是攒了DATA_WIDTH+1个位才输出,看上去仿真波形好像也对,只是输出晚了一拍,但级联模块位宽一多,拍数累积延迟就乱了,查看定位问题会非常痛苦。
3.2 带使能控制和帧对齐的串转并
基础移位寄存器版本有个问题:它在任何时候只要bit_valid为高就会移位,压根不管输入数据是不是一座数据流的中间位置。假设串行数据前面有干扰,或者字节边界没有对准,那第一个输出的并行数据就是错位的,而且从这之后所有数据都会跟着错,这叫对齐错误传播。
解决办法是引入一个数据有效区间的控制信号,这个信号可以是SPI的CS、UART的起始位、或者是用户自己定义的frame_sync。
module sipo_frame #( parameter DATA_WIDTH = 8 ) ( input wire clk, input wire rst_n, input wire frame_sync, // 帧同步信号,高有效表示一帧开始 input wire bit_valid, input wire sipo_din, output reg [DATA_WIDTH-1:0] pdata_out, output reg byte_valid ); reg [DATA_WIDTH-1:0] shift_reg; reg [3:0] bit_cnt; reg active_flag; // 当前是否处于接收有效帧状态 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin active_flag <= 1'b0; bit_cnt <= 'd0; shift_reg <= {DATA_WIDTH{1'b0}}; pdata_out <= {DATA_WIDTH{1'b0}}; byte_valid <= 1'b0; end else begin byte_valid <= 1'b0; if (frame_sync) begin active_flag <= 1'b1; bit_cnt <= 'd0; shift_reg <= {DATA_WIDTH{1'b0}}; end else if (active_flag && bit_valid) begin shift_reg <= {shift_reg[DATA_WIDTH-2:0], sipo_din}; if (bit_cnt == DATA_WIDTH - 1) begin active_flag <= 1'b0; // 一帧已攒满 bit_cnt <= 'd0; pdata_out <= {shift_reg[DATA_WIDTH-2:0], sipo_din}; byte_valid <= 1'b1; end else begin bit_cnt <= bit_cnt + 1'b1; end end end end endmodule加了一个active_flag之后,效果立竿见影:frame_sync有效时开始接收;没有frame_sync且有bit_valid脉冲时,不进行任何移位。也就是说,数据流中间的毛刺,只要不在有效帧窗口内,就不会污染移位寄存器。
这种写法适用于大多数有明确帧结构的协议。但要注意,frame_sync信号的时序精度很关键。如果frame_sync从检测到信号开始要经过两级逻辑延迟才到达这个模块,而bit_valid的时序比它早,那么第一个有效位可能被漏掉,导致整帧数据少一位。解决办法是把frame_sync和bit_valid放到同一个时钟域,并且确保frame_sync是在bit_valid之前一拍给出。
3.3 乒乓缓冲与连续帧处理
上面这个版本有个短板:模块在攒完一帧之后直接失活,下一帧要等新的frame_sync才能继续。如果帧和帧之间间隔很短,或者两个帧连续到达,模块处理不过来就会丢帧。
处理连续帧的标准方案是乒乓缓冲(ping-pong buffer)。核心思路是:接收侧准备好两组缓存,一组正在被移位寄存器填充,另一组可以被上游逻辑读走。当A组攒满,切换为B组继续填充,同时把A组的完整数据交给接收方。两组交替工作,从外界看起来,数据似乎是连续不断流出的。
乒乓缓冲在RTL层面通常不直接写在串并转换模块内部,而是把串转并模块的输出接到一个双端口RAM或两组寄存器组上,由控制逻辑进行组切换。但这种方案的代码量会明显增加,如果只是单帧偶发传输,用不上乒乓缓冲;如果帧长固定且连续,乒乓缓冲就是必须的。判断标准很简单:只要上游的传输间隙小于一帧的接收时间,就得用乒乓。
我在一个多通道数据采集项目里验证过这个设计。ADC以固定速率不断输出串行采样值,模块每一帧攒满后输出一次。如果用单缓冲,接收方必须在一次帧周期内把数据取走,否则下一帧就把上一帧覆盖了。单缓冲方案在仿真时看不出问题,因为仿真环境的读取速度总是足够的;上了板子之后,一旦读取逻辑被高优先级任务抢断,数据就静悄悄地错了。换成乒乓缓冲之后,哪怕接收方偶尔晚了几拍再去取数据,数据也不会被覆盖,整个系统的鲁棒性好了很多。
4. 核心实现:并行转串行(PISO)RTL设计
4.1 基础并转串实现与装载控制
并转串的经典写法是:准备一个并行寄存器,用户先写入要发送的并行数据,然后发送控制信号启动,之后每个位时钟周期输出一位,直到DATA_WIDTH位全部发完。
module piso #( parameter DATA_WIDTH = 8 ) ( input wire clk, input wire rst_n, input wire load_en, // 并行数据装载使能 input wire [DATA_WIDTH-1:0] pdata_in, // 并行输入数据 input wire bit_valid, // 位发送时钟脉冲 output reg serial_out, // 串行输出 output reg send_busy // 发送忙状态 ); reg [DATA_WIDTH-1:0] shift_reg; reg [3:0] bit_cnt; reg busy_flag; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin shift_reg <= {DATA_WIDTH{1'b0}}; bit_cnt <= 'd0; busy_flag <= 1'b0; serial_out <= 1'b0; end else if (load_en && !busy_flag) begin shift_reg <= pdata_in; bit_cnt <= 'd0; busy_flag <= 1'b1; serial_out <= pdata_in[DATA_WIDTH-1]; // 先发最高位 end else if (busy_flag && bit_valid) begin if (bit_cnt == DATA_WIDTH - 1) begin busy_flag <= 1'b0; // 全部发完,释放总线 end else begin bit_cnt <= bit_cnt + 1'b1; shift_reg <= {shift_reg[DATA_WIDTH-2:0], 1'b0}; serial_out <= shift_reg[DATA_WIDTH-2]; end end end endmodule这段代码的执行流程是:load_en有效时,把并行数据锁存进移位寄存器,busy_flag拉高,立刻把最高位输出。之后每个bit_valid边沿来临时,寄存器左移一位,输出次高位。等到bit_cnt计数到DATA_WIDTH-1,busy_flag拉低,表示这一帧数据发送完毕,可以装载下一帧数据。
这里有一个很重要但容易被忽略的细节:serial_out在装载那一刻就直接输出了最高位,不是在第一个bit_valid边沿才输出。这叫做“装载即发送首位”,是SPI等同步接口的常见做法。如果模块里面等到bit_valid才输出第一位,整个串行输出会整体延后一个位时钟周期,接收端用同一套位时钟采样就会漏采第一位。
4.2 位序问题与MSB/LSB切换设计
串行数据第一位到底是最高位还是最低位,这是硬件设计中最容易踩的坑之一。两种协议习惯都存在:SPI默认是MSB first,UART的数据位则是LSB first(起始位之后先发最低位)。如果并转串模块固定写死一种顺序,第一次接一个LSB first的传感器,数据就全反了。
解决办法是做成可选参数,而不是改代码。加一个参数BIT_ORDER,0表示LSB first,1表示MSB first,在装载时决定输出方向。这样设计的好处是同一个模块可以复用到不同协议上,不需要维护两个几乎相同但位序相反的版本。
从实现层面看,MSB first就是装载后输出最高位,之后依次左移;LSB first就是装载后输出最低位,之后依次右移。两种方向的移位寄存器逻辑差不了几行代码,但综合后的时序路径特性略有不同。在普通FPGA上基本无感,但在时序很紧张的高速设计中,选择适合位序方向的一段寄存器,可以省掉一点额外的组合逻辑级数。
还有一个和位序捆绑的问题:接收方的串转并模块,也要对应格。发送方LSB first,接收方也必须LSB first,也就是先收到的位放在并行输出的最低位。两边位序不一致,数据就是倒序的,在调试时看起来像是随机错误,而且不是每秒都错,很容易让人怀疑是时钟问题而不是数据位序问题,白白排查好几个小时。
4.3 空闲态电平设计与总线释放
串行总线在没有数据发送时,输出到底是什么电平?这个问题决定了接收端会不会误判空闲状态。
对UART来说,空闲态高电平,起始位低电平,下降沿标志着帧开始。所以并转串模块在不发送数据时,serial_out必须保持高电平。假如模块在复位后的初始状态输出低电平,接收方会误认为总线一直忙,永远检测不到正确的起始位。
对SPI来说,空闲时的电平由CPOL参数决定,CPOL=0时空闲低电平,CPOL=1时空闲高电平。但不管空闲电平是什么,都必须明确定下来,不能让输出在float状态,否则接收端会采样到随机值。
我的习惯是:不发送时serial_out输出到一个可配置的电平,通常叫idle_level,模块复位和帧结束两个时间点都强制输出这个电平。这看似多了一个参数,但在对接不同类型的外设时能少很多麻烦。
另外,当并转串模块是多master总线或者共享引脚场景时,输出不仅需要置为空闲电平,还可能需要真正地释放总线,交给三态缓冲控制。这时候serial_out往往不是直接接到芯片引脚上,而是经过一个三态门,busy_flag本身就具备输出使能的语义。负载控制逻辑把busy_flag和输出使能关联起来,发送期间打开三态门,空闲期关闭三态门。这个设计在I2C等半双工总线上特别重要,两个方向的器件要严格错开驱动总线的时间窗。
5. 时序配合、速率匹配与仿真实战
5.1 位时钟与并行数据率的换算关系
串并转换设计里最基础但最容易被忽略的问题,是数据率换算。接收端和发送端明明时钟频率一致,但一个字节的传输时间比理论值多了一拍,导致后续模块累积延迟。
具体到一个8位串转并模块:如果位时钟频率为f_bit,那么并行数据输出的最高频率是f_byte = f_bit / 8。这不是说每8个位时钟就一定产生一个并行数据,因为中间可能有帧间隔、同步开销、空闲时间。设计数据缓冲和下游处理逻辑时,必须按“有效数据率”而不是“理论数据率”来评估吞吐。
举个例子,一个串行数据源会在每8个有效位之间插入两个时钟周期的空闲间隔。那么实际有效数据率只有位时钟频率的8/10。如果下游FIFO的写入速率按1/8计算,写入必然会在空闲间隔期间产生气泡,偶尔写不满,但在数据峰值时可能写溢出。正确做法是预留10%的缓冲余量,或者干脆按峰值速率来设计FIFO深度。
我做时序预算的时候,通常会画一张简单的速率表格,把每个模块的输入速率、输出速率、最大突发长度列出来,再计算缓冲大小。表格一画,哪个环节的吞吐不足一目了然,比对着代码猜高效得多。
5.2 跨时钟域与打拍处理
串并转换模块最常遇到跨时钟域问题的场景,是位时钟和并行数据所在的总线时钟不是同一个源。比如LVDS接收链路,位时钟由外部时钟恢复电路提供,恢复时钟和系统主时钟可能只差几个ppm,也完全可能在不同晶振源下工作。
移位寄存器的移位操作要用位时钟是没错,但移位寄存器攒满后产生的pdata_out,要被主时钟域的逻辑读取,这里就跨了时钟域。如果直接在主时钟域里采样pdata_out,就会碰到亚稳态或者数据不一致问题。
标准的跨时钟域处理方式是用两级触发器打拍同步。第一级触发器输出可能会进入亚稳态,第二级把它稳定下来。但要注意,打拍解决的是“这个信号是否稳定”的问题,解决不了“数据是否有效”的问题。跨时钟域传递多位总线数据,必须配合有效的握手协议,或者直接把数据放到异步FIFO里。
我在项目里通常是这样做的:串转并模块的移位侧完全用位时钟域,攒满后把数据和有效信号送进一个小型异步FIFO(深度大于等于2即可),主时钟域从FIFO读数据。这样彻底隔离了两个时钟域,数据对齐不再依赖两边时钟的默契。虽然看起来多了一个FIFO的逻辑开销,但系统稳定性和调试难度都好了不止一个档次。
5.3 仿真激励与随机化验证方法
串并转换模块功能简单,仿真如果不讲究方法,很容易“假通过”。最常见的问题是用固定数据只跑一遍,或者只用一组最方便观察的8‘b10101010波形测试。这种激励只能证明模块能移位,完全没有覆盖到位序、边界、连续帧、使能过早结束这些真正会出错的地方。
我常用的验证套路有三个。
第一个是随机数据加随机延迟。使用随机数生成器产生随机位流,同时随机控制bit_valid的间隔,让它有时连续、有时隔一拍、有时隔三拍,验证模块在各种节奏下都能正确积攒数据。对并转串也一样,随机加载并行数据,随机改变加载时刻和bit_valid的关系。
第二个是参考模型对拍。在Testbench里写一个高抽象级别的参考模型,用行为级代码“按理想逻辑”完成同样的串并转换,然后把参考模型的输出和被测模块的输出做逐周期对比。只要有不匹配就立刻报错。这类参考模型验证逻辑能覆盖绝大多数功能错误,而且后期加回归用例也方便。
第三个是对边界情况的定向测试。边界包括:恰好攒满DATA_WIDTH位时下一帧就来了、使能信号只有单周期脉冲、位时钟在一个字节的边界上意外停住、复位发生在移位进行到一半时。这些定向用例尤其能发现实际工程中才会遇到的时序问题。
有一个我曾经踩过的坑特别想提醒大家:在串转并的testbench里,用generate循环逐位给移位寄存器赋值,看起来能测出功能正确,但完全测不出真实场景下“位时钟作为独立时钟输入”时的时序问题。仿真里的always块如果用的是同一个时钟边沿,测试再怎么随机也测不出真正的跨时钟域问题。遇到这种情况,建议在仿真里构造两个独立的时钟生成器,让位时钟和主时钟之间带上相位偏斜,再观察输出是否仍然正确。这个测试能暴露出一大批看似“功能正确”但实际时序存在隐患的代码。
// 伪代码示意:带相位偏移的位时钟生成 reg bit_clk; initial begin bit_clk = 0; forever #12.5 bit_clk = ~bit_clk; // 80MHz位时钟 end reg sys_clk; initial begin sys_clk = 0; forever #10 sys_clk = ~sys_clk; // 50MHz主时钟 end6. 常见问题排查与工程化经验
6.1 排查清单与诊断流程
串并转换模块出了问题,调试流程千万不能乱。我的经验是先排查对齐,再排查时序,最后才怀疑逻辑本身。原因是串并转换的逻辑代码通常很短,单纯逻辑写错的概率反而低,更多是接口信号之间没对齐。
首先是“数据全部为0或全部为1”。这种情况最可能是使能信号没有生效,或者位时钟根本没有到达模块。用示波器或逻辑分析仪抓位时钟和使能信号,确认两者是否存在,是否和预期一致。如果是仿真阶段,直接打印使能和位时钟的翻转计数,一目了然。
其次是“数据有一半是对的,但按字节看整体错位”。比如收到的字节序列是E3 7F 5A,期望的序列是1C AF 8F,肉眼看起来完全不相关,但逐位反转后能对上。这是典型的位序反转问题,优先检查发送端的MSB/LSB设置和接收端的对应设置是否一致,两边一边改一边不改,就会这样。
再次是“数据偶尔错一个bit”。这种问题最折磨人,基本可以锁定在时序边界上。可能的原因是采样时刻位于数据翻转窗口内,亚稳态发生了,或者是跨时钟域信号没打拍导致随机采到不稳定值。处理方法是调整采样边沿,在高速接口里就是调整IDELAY或IODELAY的延迟参数,用硬件手段把采样点移到数据稳定区域的中间。
| 现象 | 优先怀疑方向 | 主要排查手段 |
|---|---|---|
| 数据全0或全1 | 使能未生效、时钟未翻转 | 逻辑分析仪抓使能和时钟 |
| 字节错位 | 帧同步偏差、计数起点错误 | 检查bit_cnt起始条件和frame_sync时序 |
| 数据位序颠倒 | 发送/接收端MSB/LSB不一致 | 对比双方位序配置 |
| 偶发单bit错误 | 采样边沿靠近翻转点、亚稳态 | 调整采样点、打拍同步 |
| 连续多bit错误 | 帧同步丢失或跨时钟域未处理 | 检查同步头检测和FIFO设计 |
6.2 工程化设计红线
最后一条要说的是:串并转换模块虽然小,但它通常位于整个数据链路最靠近物理边界的位置,任何一个小问题都可能被放大成整个系统的不稳定。我经历过的几个现场故障,最后追溯下来的根因全都是接口逻辑的小缺陷,而不是主控逻辑的问题。
所以在工程化设计里,我给自己定了几条红线。
第一条是串并转换模块必须有清晰的主时钟域边界。所有进入模块的信号都要明确归属哪个时钟域,跨域信号必须有同步处理,绝不允许一个信号“顺手”就用了。
第二条是位时钟和使能信号不允许出现组合逻辑产生的毛刺。如果使能信号来自某个判断逻辑,要保证它在位时钟有效边沿之前足够稳定,或者干脆用寄存器输出。
第三条是帧同步信号符合系统的时序约束。帧同步要提前于第一个有效位至少一个周期,保证移位模块能准备好接收状态。这个提前量要写进接口时序文档里,不然前端验证和后端实现之间容易对不上。
第四条是并转串模块的输出,在总线空闲时必须保持在明确的空闲电平上,不允许存在未定义的高阻或不确定态输出。外设对空闲电平的判断一旦出错,整个通信协议都会跟着乱掉。
6.3 从RTL到FPGA落地的适配差异
代码仿真通过和上板稳定运行之间,还隔着一道“实践鸿沟”,很多RTL层面的合理性在真实器件上会被重新检验。
FPGA综合器对移位寄存器的处理,通常会自动映射到SRL16或SRL32这种专用的移位查找表资源,而不是用普通触发器。SRL16本质上是一段可配置深度的移位链,支持异步复位吗?常规的SRL16不支持复位,只有上电初始化清零和通过CE配置深度控制。如果代码里对移位寄存器写了异步复位,综合器为了满足复位语义,可能会把SRL推断打断,退回用普通FF实现,逻辑资源相应变多。如果你的设计里移位寄存器需要复位才能保证正确启动,那就必须写清楚复位逻辑,同时接受资源的牺牲;如果不需要复位(很多接口协议靠帧同步来丢弃无效数据),谨慎的做法是把移位逻辑改成基于使能控制的架构,让综合器有机会用到SRL资源。
高速串行场景下,时序收敛的核心约束是移位寄存器到并行输出寄存器的路径。这一路径同时包含了移位寄存器的输出、mux逻辑和输出寄存器的输入组合逻辑,位宽越大,组合逻辑的级数可能越多,容易成为关键路径。解决方案包括:把移位寄存器拆成两段流水,第一段只负责移位,第二段负责寄存输出;或者直接用硬件原语,比如ISERDESE2,将串行比特流以OSERDES/ISERDES原语接收后直接进入并行侧,跳过RTL里大位宽移位链的实现。
还有FPGA设备相关的DDR(双沿采样)问题。LVDS接口在双沿采样模式下,每一位串行数据都要同时用时钟上升沿和下降沿采集,RTL里如果只写了posedge采集逻辑,根本满足不了接口要求。此时必须使用IDDR原语或Clocking Wizard生成的接口逻辑,把双沿的业务逻辑转成单沿的数据率,再送到内部总线。简而言之,高速场景下,RTL的逻辑设计要让位给原语和硬核,不能硬着头皮用纯RTL去拼速率。
7. 个人实操体验与技巧建议
写串并转换模块的次数多了以后,我的体会是:这类基础模块真正考验人的,不是移位那几行代码,而是接口边界条件的处理手法。模块内部越简洁,外部协议细节就越重要。把串并转换写对,相当于把一栋楼的每一块砖都烧合格了,楼能不能立住,还要看砖与砖之间的砂浆(握手、对齐、跨时钟域)是否到位。
这里再分享几个操作性很高的实用技巧,都是我自己或者身边同事踩过坑之后提炼出来的。
技巧一:串转并模块里,寄存器的复位值务必是0,但输出的idle电平务必和协议规定的空闲电平一致。这两个概念要分清楚。很多人在设计UART接收模块时,把串行输入寄存器复位值设为1,输出的并行数据却从0开始拼,表面上波形凑对了,一旦输入悬空或复位后的瞬间,数据就会有额外跳变。
技巧二:做多通道串并转换复用场景时,移位寄存器尽量不要和FIFO写控制逻辑混在同一个模块里,通道数量和FIFO深度要作为参数保留,避免为某个具体通道数量写死代码。实际项目里通道数从4变到8、从8变到16是常有的事,如果写死了,改参数要动大手术,还容易牵出隐藏的bug。
技巧三:仿真互联时,尽量把串并转换模块的接口信号命名加上方向和后缀,比如sipo_din、sipo_dout_valid、piso_load,这样可以避免在集成阶段搞混串转并和并转串的端口。这种命名规范看起来不起眼,但在一个几百个信号的大工程里,可以节约大量连接时间。
技巧四:对并转串模块,load_en信号在busy状态下必须被忽略。有的代码在busy期间仍然允许load_en把数据装进移位寄存器,表面看仿真能跑,打开波形你会发现mid-frame数据被覆盖,接收端收到的是两帧拼接的残次数据。稳妥的做法是像前面代码中那样,在load_en && !busy_flag条件下才允许装载。
技巧五:在验证串转并接收端时,要专门构造“帧头连续出现”和“帧头紧贴上一帧末尾”这样两个边界用例。前者检验帧同步检测逻辑是否重复触发,后者检验bit_cnt清零是否干净。这两个用例在绝大多数功能测试里不会覆盖,但恰恰是协议对接时最容易出问题的场景。
串并转换这一课,放到大的数字逻辑体系里只是个小模块,但它关联着位序、时钟域、握手、缓冲、参数化设计等一堆核心概念。把这一小块吃透,再去读SerDes文档、写DDR控制器接口、实现以太网MAC,你会发现那些复杂系统里的很多设计套路都是相通的。基础模块扎实了,往上堆复杂逻辑心里才有底。