ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Verilog串并转换器设计与仿真验证全流程解析

Verilog串并转换器设计与仿真验证全流程解析 各位做数字逻辑、FPGA或者嵌入式开发的朋友串并转换器这名字听起来像教科书里的基础章节但真到自己写代码、调仿真的时候坑一点也不少。我最近刚完成一个串并转换器的设计与仿真验证把思路、代码和排查过程整理一遍这篇东西适合正在学Verilog、准备做接口逻辑或者被SPI这类串行协议折腾过的同学当参考。先说清楚它到底解决什么问题。串并转换器干的事就是数据宽度的翻译——把串行的一比特一比特数据拼成一个并行总线上的多比特数据或者反过来把并行的N比特数据拆成一串顺序输出的比特。前者叫串转并Serial to Parallel后者叫并转串Parallel to Serial。表面上看起来很简单无非是移位寄存器加计数器但实际设计里牵涉到采样时机、数据对齐、跨时钟域处理以及仿真时如何构造有说服力的激励这些才是决定一个设计能不能真正落地、能不能通过时序收敛的关键。这篇文章我会从设计思路开始讲然后给出一份可直接复用的Verilog实现再完整展示testbench搭建和仿真波形分析最后把我调试过程中遇到的几个典型问题整理成排查记录。整个内容以8比特串并转换器为具体案例但所有设计方法都做了参数化处理改成16比特或者32比特就是改一个参数的事。1. 拆解设计思路为什么不能只写一个移位寄存器很多第一次接触串并转换的人会直接写一个“每个时钟移一位”的模块仿真能过功能也对但只要接到真实系统里问题立马暴露数据在什么时候有效输出什么时候稳定外部电路怎么知道这一拍的数据可以取了这些都需要额外的控制信号来回答。所以完整的串并转换器准确说应该叫“带使能、带标志信号的数据宽度转换模块”。1.1 需求建模先画清楚数据流和时间线在设计之前我先画一个数据流示意图明确几个关键问题数据源是谁在本例中一个串行发送端每个时钟周期在serial_in线上给出1比特有效数据数据在时钟上升沿之后保持稳定。数据接收方是谁一个并行总线要求每收到8个串行比特并行端口parallel_out刷新一次同时给出一个高电平脉冲data_valid表示这一拍并行数据有效。双方有没有独立时钟没有所有逻辑共用一个时钟域这是最基础的场景也方便把精力聚焦在转换逻辑本身。这个建模过程很值得新手学习先想清楚“谁在什么时候给数据、谁在什么时候收数据”再想代码怎么写。否则一上来就写always块很容易写出时序混乱的模块。1.2 方案选型三种常见实现路径对比串并转换的实现思路大致有三类我分别列一下优缺点实现方式核心思想优点缺点移位寄存器加计数器每个时钟把serial_in移入寄存器计数器计满N个脉冲后产生输出标志结构简单、面积小、时序清晰不能随机访问中间位灵活性低计数器加多路选择器用计数器作为地址依次把串行输入写入寄存器阵列或者用多路器依次选出并行位输出便于按任意顺序取用数据逻辑面积较大多路器在宽位宽下消耗明显采用RAM或FIFO实现把串行比特按地址写入存储单元读完N个后并行读出适合超宽位宽或数据缓存场景资源开销大、时序控制复杂小位宽不划算在这个项目里我选择的是第一类方式。原因很简单8比特位宽移位寄存器天然就是为这种“按位搬运”的操作设计的。每一个时钟沿registers[7:0]整体左移一位新来的serial_in填入最低位最高位自然被丢掉这正好对应了“先接收的比特在最高位”的串转并逻辑。如果做并转串思路同样对称。并行数据先被装进移位寄存器用load信号控制然后每个时钟把最高位送到serial_out同时整体左移一位这样并行数据就被逐个“吐”出去了。1.3 为什么一定要有数据有效标志这是最容易忽视的设计要点。如果只做一个寄存器堆并行输出端口的电平每时每刻都在变化接收方根本无法判断哪个时刻的数据是完整的。data_valid信号就是为了解决这个问题——它告诉下游模块现在并行口上的数据已经凑齐了你可以锁存走了。没有这个标志就需要接收方自己去数时钟个数这等于把转换逻辑的一部分职责拆给了别的模块耦合度一下子就上来了日后维护和复用都会很难受。所以我在这份设计里给串转并和并转串都配了对应的valid标志保证“数据与标志同步输出”。2. 核心细节解析接口定义、参数设计与时序约束再好的整体思路落到具体实现都要靠每个信号、每个参数、每个时序细节来支撑。这一节我拆解开来讲把我在这份设计里做的关键决定和踩过的细节坑都放在一起。2.1 模块接口与参数设计先给出模块定义我分了两个模块一个是串转并uart_rx逻辑里经常用到一个是并转串uart_tx逻辑里常用但都放在一个文件里方便仿真。// 串转并模块 module serial_to_parallel #( parameter DATA_WIDTH 8 )( input wire clk, input wire rst_n, input wire serial_in, // 串行输入 input wire data_en, // 输入数据有效使能 output reg [DATA_WIDTH-1:0] parallel_out, // 并行输出 output reg data_valid // 并行数据有效标志 );几个接口的设计理由data_en数据有效使能串行输入不是每个时钟都有效。有些协议里比特流之间有空闲期如果不管三七二十一每个时钟都移位数据就会错位。加一个使能信号只有它拉高时serial_in上的比特才被移入寄存器。这个思路跟AXI-Stream的TVALID、TREADY握手理念异曲同工。DATA_WIDTH参数用parameter定义位宽方便后续把8比特改成16比特、24比特不需要改动逻辑主体。data_valid高电平有效持续一个时钟周期表示parallel_out上的数据已经准备好。并转串模块的定义也一并给出// 并转串模块 module parallel_to_serial #( parameter DATA_WIDTH 8 )( input wire clk, input wire rst_n, input wire load, // 并行装载使能 input wire [DATA_WIDTH-1:0] parallel_in, // 并行输入 output reg serial_out, // 串行输出 output reg busy // 转换忙标志 );这里的load信号是并转串的核心它跟串转并里的data_en地位一样都是权柄信号。load拉高的那个时钟并行数据被整体装进移位寄存器之后每个时钟最高位被送出整体左移直到发完N比特busy拉低表示转换完成。2.2 参数选择的依据位宽和计数逻辑怎么定位宽的确定不是拍脑袋写个8就完事。在真实项目中它取决于你的协议帧格式。比如你要发一个UART帧起始位1比特、数据位8比特、校验位1比特、停止位1比特那你要转换的实际上是8比特数据起始位和停止位是协议层附加的不需要进入移位寄存器。所以我设计里的DATA_WIDTH指的就是“有效数据位宽”不含协议辅助位。计数器是另一个关键参数。串转并需要知道“已经收集了多少位”满8个就产生valid脉冲。这个计数器的位数取决于DATA_WIDTH写作localparam CNT_WIDTH $clog2(DATA_WIDTH);$clog2是Verilog 2001标准里的系统函数能自动算出表示DATA_WIDTH所需的最少比特数。比如DATA_WIDTH8CNT_WIDTH就是3。这种做法比手动写死计数器位宽要稳妥以后改参数不会出现计数溢出。2.3 时序设计的关键数据到底该在哪条边沿走时序设计是串并转换器能不能正常工作的灵魂。我在设计里采用的是最常规的“同沿采样”方式所有寄存器更新都在clk上升沿完成。这里的核心要点是基础逻辑都采用上升沿但输出数据有效信号的时序需要跟数据做匹配。先看串转并核心代码reg [DATA_WIDTH-1:0] shift_reg; reg [CNT_WIDTH-1:0] bit_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin shift_reg {DATA_WIDTH{1b0}}; bit_cnt 0; parallel_out {DATA_WIDTH{1b0}}; data_valid 1b0; end else if (data_en) begin shift_reg {shift_reg[DATA_WIDTH-2:0], serial_in}; if (bit_cnt DATA_WIDTH-1) begin bit_cnt 0; parallel_out {shift_reg[DATA_WIDTH-2:0], serial_in}; data_valid 1b1; end else begin bit_cnt bit_cnt 1b1; data_valid 1b0; end end else begin data_valid 1b0; end end这段代码里有几个非常关键的细节shift_reg {shift_reg[DATA_WIDTH-2:0], serial_in}这是左移拼接语法把原来的高DATA_WIDTH-1位搬到高位最低位填进serial_in。假设DATA_WIDTH8数据发送顺序是高位先发MSB first那第一个到达的比特最终会出现在bit 7位置第二个在bit 6以此类推。这种拼接方式不需要循环写起来干净利落。parallel_out {shift_reg[DATA_WIDTH-2:0], serial_in}为什么不是直接赋值shift_reg因为在这一个时钟的上升沿shift_reg的更新还没有发生这个表达式拿到的shift_reg还是旧值把它和serial_in拼在一起得到的正是“包含最新比特的完整8位数据”。这是非阻塞赋值的精髓所在——RHS取的是上升沿前的旧值LHS在上升沿后才更新。data_valid只拉高一个周期配合parallel_out同时更新下游看到了valid就必须立刻锁存。再看并转串模块reg [DATA_WIDTH-1:0] shift_reg; reg [CNT_WIDTH-1:0] bit_cnt; reg shift_en; always (posedge clk or negedge rst_n) begin if (!rst_n) begin shift_reg {DATA_WIDTH{1b0}}; bit_cnt 0; serial_out 1b0; busy 1b0; shift_en 1b0; end else if (load) begin shift_reg parallel_in; bit_cnt 0; busy 1b1; shift_en 1b1; end else if (shift_en) begin serial_out shift_reg[DATA_WIDTH-1]; shift_reg {shift_reg[DATA_WIDTH-2:0], 1b0}; if (bit_cnt DATA_WIDTH-1) begin bit_cnt 0; busy 1b0; shift_en 1b0; end else begin bit_cnt bit_cnt 1b1; end end end并转串的核心逻辑就是load信号一来并行数据装入然后每个时钟把最高位放到serial_out上接着左移。这样第一个时钟沿输出的是parallel_in[7]第二个是parallel_in[6]依此类推。busy信号在整个移位过程中保持高电平外部电路可以用它做握手。这里的难点在于怎样在LOAD的同时不丢失第一个比特。我的处理方式是load拉高的那个时钟沿shift_reg被装入并行数据但serial_out还不输出下一个时钟沿开始才把最高位送到serial_out。如果你在load同一拍就把最高位送出去就会遇到“并行数据还在路上、串行口已经开口”的时序竞争波形上会有毛刺。这是我自己调试过程中真实踩过的坑后面第4节会细说。2.4 关于复位同步复位还是异步复位代码里用的是异步复位也就是negedge rst_n立刻清零所有寄存器。做FPGA开发特别是Xilinx 7系列的时候异步复位是推荐的做法因为每个寄存器都有专用的复位端口异步复位不会额外占用逻辑资源还能避免恢复/移除时间违例的问题。但如果你做的是ASIC设计就要千万小心。异步复位在ASIC里容易导致复位释放时刻不一致引起亚稳态。常见的补救是“异步复位、同步释放”也就是先把复位信号打两拍再释放。我在这份设计里为了保持示例简洁用了直接异步复位但实际项目里要根据后端流程要求选择。3. 实操过程完整testbench搭建与仿真结果解读光有设计代码只能算完成一半仿真验证才是判断设计正确性的试金石。这一节我从testbench怎么写开始按真实仿真流程一步步走最后把仿真波形里几个关键点逐一分析给你看。3.1 testbench设计如何构造有效的激励先说为什么要花心思写testbench。很多初学者写testbench就是给几个信号赋个值然后run看波形“跳动了”就认为对了。这是大忌。好的testbench必须做到能够观测功能正确与否、能够覆盖边界情况、能够自动做结果比对。我写的testbench分了几个部分timescale 1ns/1ps module tb_serial_to_parallel; parameter DATA_WIDTH 8; parameter CLK_PERIOD 10; // 50MHz 时钟 reg clk; reg rst_n; reg serial_in; reg data_en; wire [DATA_WIDTH-1:0] parallel_out; wire data_valid; serial_to_parallel #( .DATA_WIDTH(DATA_WIDTH) ) dut_ser2par ( .clk(clk), .rst_n(rst_n), .serial_in(serial_in), .data_en(data_en), .parallel_out(parallel_out), .data_valid(data_valid) );时钟生成我习惯用always #(CLK_PERIOD/2)的方式注意timescale一定要写否则仿真器默认时间单位会让你对波形的时间轴判断完全失真。激励部分最关键的是“串行数据是怎么来的”。我这里用一个测试序列8b10110010把它按时钟一位一位送进serial_in。为了模拟最真实的场景我还会在某些时钟周期拉低data_en验证模块在无效数据期间不会错误移位initial begin rst_n 0; data_en 0; serial_in 0; #(CLK_PERIOD*5); rst_n 1; // 喂入数据第一位是MSB10110010 send_bit(1b1); send_bit(1b0); send_bit(1b1); send_bit(1b1); send_bit(1b0); send_bit(1b0); #(CLK_PERIOD); data_en 0; // 插入一段空闲期 #(CLK_PERIOD*3); send_bit(1b1); send_bit(1b0); #(CLK_PERIOD*5); $finish; end task send_bit(input bit_value); begin (posedge clk); data_en 1; serial_in bit_value; (posedge clk); data_en 0; end endtask这里我设计了一个很有意思的细节send_bit任务里先等一个posedge clk然后驱动data_en和serial_in再等下一个posedge clk。这意味着数据在时钟上升沿之后才被驱动到下一个上升沿之前都已经稳定了完全满足建立时间要求。而DUT在第二个上升沿采样到数据并移位。这是最稳妥的驱动方式避免了在时钟边沿同时改变信号导致的竞争问题。中间插入的空闲期data_en拉低3个时钟周期是为了验证一个异常情况如果串行数据在中途断掉模块的位计数器不应该继续走。这一点我在仿真结果里专门核对了。3.2 仿真工具选择与跑仿真的具体流程我用的是Vivado自带仿真器xsim但同样的代码在ModelSim或QuestaSim里也能跑。如果你用的是ModelSim流程是这样vlib work vlog tb_serial_to_parallel.v serial_to_parallel.v vsim -voptargsacc work.tb_serial_to_parallel add wave -position end sim:/tb_serial_to_parallel/* run -allVivado这边操作更简单新建工程后把两个文件加进去在Flow Navigator里选Simulation → Run Behavioral Simulationxsim会自动编译并打开波形窗口。有个小建议跑仿真前编译选项里加上-voptargsacc或者关掉optimization这样波形窗口里才能看到DUT内部信号shift_reg、bit_cnt这些否则默认优化会把内部信号优化掉你在波形的Module树里根本找不到它们。把DUT内部信号加进波形视图是我强烈建议的操作。只看顶层接口的parallel_out和data_valid你只能判断结果对不对看不到过程把shift_reg、bit_cnt这些中间变量拉出来才能真正观察数据移动的每一个节拍排查问题时会事半功倍。3.3 仿真结果逐段分析波形到底应该长什么样仿真跑完我得到的结果波形可以按阶段拆开看。第一阶段是复位完成后到第一个有效比特送入前。这时候shift_reg全0bit_cnt为0data_valid保持低电平parallel_out为0。如果你的设计在复位后data_valid不是低而是X态不定那十有八九是复位逻辑覆盖不完全检查一下有没有漏掉对data_valid的复位赋值。第二阶段是连续送入前4个比特1、0、1、1。在第一个data_en有效时shift_reg的低位变成1第二个时钟后变成了bit[1:0]01bit_cnt跳到001第三个时钟bit[2:0]101第四个时钟bit[3:0]1101因为发送顺序是MSB first1,0,1,1进入后寄存器低四位从高到低依次是第一位1、第二位0、第三位1、第四位1所以是1011从bit3到bit0排布对应的就是1101从低看起。这里容易晕我建议你把发送顺序写出来对照波形看眼睛看一遍比脑子里推三遍都管用。第三阶段是关键验证点插入了3个时钟的空闲期。在这3个时钟内data_en拉低shift_reg保持不变bit_cnt也保持不变。如果这里设计有bug比如没有用data_en条件包住移位逻辑bit_cnt就会继续走数据就错位了。第四阶段是第8个比特送入后的那个时钟沿。波形上可以看到data_valid拉高一个周期parallel_out一次性变为0xB2对应10110010。这是整份设计最核心的功能验证点串行比特流被正确重组为并行字节。这里我还做了一件事在testbench里用$monitor或者$display打印关键信号这样虽然波形窗口已经足够直观但日志输出更便于跟版本管理里的运行记录做对比always (posedge clk) begin if (data_valid) $display(Time %0t: parallel_out %h, expected 0xB2, $time, parallel_out); end仿真日志里干干净净打印出“Time 110000: parallel_out b2, expected 0xb2”这个模块就算基本验证通过。3.4 并转串的仿真验证设计并转串的testbench跟串转并正好相反。我给定一个并行输入8b10110010拉高一个周期的load信号然后观察serial_out上是不是依次输出1、0、1、1、0、0、1、0。这个验证用波形看非常直观busy信号拉高期间每个时钟上升沿之后serial_out依次变化。我自己在验证并转串的时候还加了一个“连续两次load”的场景也就是第一个字节还没发完就再次load这时候时序应该怎样我的设计里load信号是最高优先级无论当前是否在busy状态只要load拉高就立即装载新数据、计数器清零、重新开始发送。这种“抢占式装载”行为在真实系统里对应着突发数据的发送如果设计不支持就会出现发完一半数据被新数据打断总线协议直接错乱。4. 常见问题与排查技巧实录老规矩把我在设计和仿真过程中踩过的坑、以及帮朋友排查过的问题集中整理一下。这些内容往往比教科书里的代码示例更有价值因为它们是“真实世界里会发生”的问题。4.1 数据错位发送顺序和接收顺序不匹配这个问题太典型了。比如发送端先发低位LSB first接收端设计成了先收高位MSB first最后拼出来的并行数据就是彻底反过来的。排查方法不难在testbench里固定发送一个容易识别的序列比如8b10110010或者8b01100110。如果仿真结果是0x4D而不是0xB2说明收到了反序。解决方式有两种一是修改发送端和接收端的约定统一成同一种顺序二是在接收端移位方向做调整原来左移的判断改成右移判断也就是把串行数据填入最高位每次右移。我的建议是模块内部用参数定义MSB_FIRST根据这个参数决定移位的拼接方向这样同一个模块可以适应两种协议不用维护两个版本。4.2 data_valid产生毛刺或者提前拉高如果你的组合逻辑里用了类似assign data_valid (bit_cnt DATA_WIDTH-1) ? 1b0 : 1b1这样的写法data_valid可能会因为组合逻辑的竞争产生毛刺或者由于计数器到达最大值的时间跟数据建立时间不匹配导致下游采样到的是一个不确定值。正确的做法是用寄存器输出data_valid从我给的代码里可以看到data_valid是在always块里被赋值成寄存器的这样就能保证它只在时钟沿跳变不会出现组合毛刺。这算是一条铁律给外部使用的控制信号一定要寄存器打一拍输出不要直接用组合逻辑。4.3 仿真通过了上板实测却不对这种情况十次里有八次是跨时钟域问题。仿真里所有信号都在同一个时钟域时钟偏斜、亚稳态这些问题都不会出现但上板以后如果串行数据来自一个异步的外部源没有经过同步就直接进入移位寄存器rst_n释放后第一拍就采样到亚稳态后面所有数据全部乱掉。解决方法是加两级同步器2-FF synchronizer处理异步输入信号再把同步后的信号送进转换逻辑。如果是多位并行数据跨时钟域那就不能简单打两拍了要用异步FIFO或者握手协议这属于另一个更大的话题这里不展开。4.4 仿真波形里parallel_out变成了X态这个我调试的时候见过复位释放后如果data_en在第一个时钟已经为高同时serial_in还没被驱动成确定值那移位寄存器的低位就会采样到X态。仿真器里显示为红色的X或Z线数据链全断。解决办法有两个一是在testbench里复位释放后、正式发送数据前先把serial_in赋一个默认值比如0确保它不在高阻或未定义状态二是设计代码里对未满宽的情况做屏蔽但更推荐的还是前者——testbench里不要出现任何无驱动信号。4.5 资源占用突然爆炸如果只是写一个8比特串并转换器LUT和FF的消耗应该非常小最多十几个FF加几个LUT。但如果你图省事用generate循环生成了一堆并行比较器或者每个bit都独立判断资源可能成倍增加。我见过有人用多位计数器加解码器来实现移位寄存器的功能最后资源消耗是常规写法的5倍以上还引入了十几纳秒的组合逻辑延迟。排查方法很简单综合后看一下utilization报告如果8比特转换器用了超过五十个LUT大概率实现方式有问题。把代码拆开看是不是生成了复杂的组合逻辑该用移位寄存器的地方就不要用计数器解码。5. 设计复盘与后续扩展方向串并转换器这个模块本身不难但它几乎是所有数字接口设计的基本功。把它吃透等于把“数据宽度转换”这一类问题的通用解法掌握了。做完整份设计我觉得有几个心得值得单独拎出来说一下。参数化设计带来的复利效应特别明显。这份设计里DATA_WIDTH作为参数我后面在一个项目里复用它做16比特串转并只改了一下参数连testbench都只需要调整测试序列的长度省了一天时间。所以从一开始写模块时就坚持参数化不要为“这次只要8比特”就硬编码这是对自己后续项目时间的最好投资。仿真验证的深度决定了设计的可靠性。我在testbench里不仅验证了正常数据流还故意插入了空闲期、连续两次load、复位中途释放等边缘场景。这些场景看起来像是故意找茬但恰恰是这些边界条件帮我把设计里的隐患提前揪了出来。如果只跑一个“黄金模型”路径仿真绿了也可能只是假绿。关于后续扩展我认为有两个方向非常值得继续做下去。第一是加CRC校验串行数据在链路上传输时容易受干扰给并行输出配一个CRC计算模块可以在数据送出时提前发现误码第二是跟FIFO配合把串并转换输出端接到异步FIFO形成一个完整的串行数据采集前端这样就能应对连续多帧数据的场景不至于因为处理不过来丢数据。我在实际调这个模块的时候还有一个体会就是不要小看“打印日志”这个土办法。有些时候波形上看半天看不出问题但把关键信号在每个时钟沿的值打印出来按时间顺序一拍一拍地刷问题很快就跳出来了。尤其是数据错位这类问题日志里比特序列跟预期序列一比对立刻就能定位到是第几个时钟位序出了问题。这份设计里的代码我已经在Vivado 2023.1和ModelSim SE-64里分别跑过仿真逻辑完全相同。如果你拿去做其他芯片平台只要时序约束和复位方式匹配基本可以直接套用。希望这篇分享能让你少踩几个坑。
返回列表