ARTICLE DETAIL

资讯详情

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

串并转换器设计实战:Verilog移位寄存器实现与仿真调试案例

串并转换器设计实战:Verilog移位寄存器实现与仿真调试案例 上周我在调一颗SPI接口的温湿度传感器寄存器读出来永远是0x00和0xFF两个极端值示波器抓的波形看起来又是对的折腾到半夜才反应过来——串行数据每一位的顺序和我代码里移位寄存器的方向对不上。这种“看似简单、翻车几率却不小”的模块就是串并转换器Serial-to-Parallel Converter。串并转换器在数字系统里太常用了。UART把RXD引脚上一位一位进来的数据拼成8位字节SPI从MISO引脚上把传感器返回的位流拼接成寄存器值摄像头DVP接口把像素数据从串行位流还原成并行总线这些都离不开它。这篇博文把串并转换器的设计、仿真、调试、工程化应用完整讲一遍适合正在做FPGA或者MCU底层驱动、想搞懂接口数据链路的朋友参考。1. 串并转换器是什么一次实战场景引发的需求分析1.1 一个典型的现实场景SPI温湿度传感器的数据读取先把场景铺开。我手上这颗温湿度传感器是标准SPI接口只有四个引脚CS、SCK、MOSI、MISO。读寄存器时需要MCU先通过MOSI发送读命令传感器再通过MISO把数据返回。问题在于MISO上返回的数据不是一次给一个字节而是按照SCK时钟一个bit一个bit地往外送。也就是说MCU在8个SCK周期里要连续从MISO引脚上采样8次才能凑齐一个完整的字节。这个“凑齐”的过程就是串并转换。MCU内部并不能直接在MISO上拿到一个reg [7:0]变量它必须先声明一个移位寄存器每来一个SCK上升沿就把MISO的电平移到移位寄存器里等8个时钟都收满了再把移位寄存器的值整体搬到一个并行数据寄存器里供后续逻辑使用。这听起来简单但真正写代码的时候有几个问题会立刻跳出来第一个bit进移位寄存器后是放在最高位还是最低位有没有使能信号来控制移位节奏最后一拍如何产生“数据有效”的脉冲这每一个都是坑。1.2 为什么要串行传输又为什么必须转回并行有人可能会问既然串行数据接收这么麻烦为什么接口还要设计成串行原因很朴素省引脚、省布线。一个8位并行接口至少需要8根数据线加若干控制线而SPI只要4根线单总线方案甚至只要1根线比如DHT11用的单总线。在PCB面积紧张、连接器引脚有限、或者需要隔离的场合串行传输的优势几乎是碾压级的。UART也是一样远距离通信时一对差分线就能搞定如果用并行总线线缆成本会成倍上升。但另一方面芯片内部的处理几乎都是并行的。CPU的寄存器是32位、64位宽的DMA搬数据是按字节或者按字搬的内部总线也是按字节对齐的。外部串行数据进来如果每bit都让CPU处理一次效率低到没法用。所以几乎所有串行接口的外围电路第一件事就是把串行位流转成并行数据塞进寄存器或者FIFO里让CPU按字节甚至按块读取。串并转换器就是这两个世界之间的“翻译官”。2. 完整设计方案参数化的串并转换Verilog模块2.1 模块接口设计和参数选择设计串并转换器的时候我建议直接按照可复用模块的标准来写而不是在某个顶层文件里堆一堆临时逻辑。因为串并转换这种东西今天可能在SPI里用明天又跑到UART里用后天的某个调试接口也可能用得到写成一个带参数的模块后面所有项目都能直接吃老本。接口我做了这么几个信号clk是系统时钟rst_n是异步复位s_data是串行输入s_valid是串行数据有效使能p_data是并行输出p_valid是并行数据有效脉冲。另外再定义两个参数DATA_WIDTH控制并行数据位宽默认设成8如果你想接一个16位的ADC或者24位的加速度传感器直接改参数就能复用LSB_FIRST控制数据位序约定默认0表示MSB first最高位先到设为1表示LSB first最低位先到。为什么要做LSB_FIRST这个参数因为这是我在实际项目里踩过的最深的坑之一。SPI默认是MSB first但有些国产传感器不走寻常路文档角落里写着“Data is transmitted LSB first”如果代码里写死了移位方向换一颗芯片就要改一大堆逻辑。把位序做成可配置参数适配新芯片时只需要实例化时改动一个parameter省时省心。2.2 核心代码实现移位寄存器加位计数器核心实现非常简单一个移位寄存器加上一个位计数器。下面是我反复使用并验证过的完整模块代码module serial_to_parallel #( parameter DATA_WIDTH 8, parameter LSB_FIRST 0 )( input wire clk, input wire rst_n, input wire s_data, input wire s_valid, output reg [DATA_WIDTH-1:0] p_data, output reg p_valid ); localparam CNT_WIDTH $clog2(DATA_WIDTH); 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 {CNT_WIDTH{1b0}}; p_data {DATA_WIDTH{1b0}}; p_valid 1b0; end else if (s_valid) begin // 根据位序参数决定移位的方向 if (LSB_FIRST) shift_reg {s_data, shift_reg[DATA_WIDTH-1:1]}; else shift_reg {shift_reg[DATA_WIDTH-2:0], s_data}; if (bit_cnt DATA_WIDTH - 1) begin // 最后一拍直接把拼接好的数据输出 if (LSB_FIRST) p_data {s_data, shift_reg[DATA_WIDTH-1:1]}; else p_data {shift_reg[DATA_WIDTH-2:0], s_data}; p_valid 1b1; bit_cnt {CNT_WIDTH{1b0}}; end else begin bit_cnt bit_cnt 1b1; p_valid 1b0; end end else begin p_valid 1b0; end end endmodule这段代码里shift_reg负责把串行bit一位一位地缓存起来。MSB first模式下每个时钟周期新进来的bit被放到最低位原来的bit往高位移这样第一个进来的bit最终会停在最高位LSB first模式刚好反过来新bit进最高位原来的bit往低位移第一个进来的bit最终停在最低位。bit_cnt是位计数器它从0开始每收到一个bit就加1。当计数到DATA_WIDTH - 1时说明这一帧已经收满这时直接把shift_reg和新bit拼接成完整数据同时拉高p_valid并清零计数器准备接收下一帧。这里有个细节值得注意最后一拍用的是p_data {shift_reg[DATA_WIDTH-2:0], s_data}也就是组合拼接后直接寄存而不是“先完成第8次移位再等下一个时钟把移位寄存器内容搬出去”。这样做的好处是p_data和p_valid在同一个时钟沿产生不需要额外等待一拍延迟最小也避免了valid和数据错开一个周期的问题。2.3 为什么p_valid只拉高一个时钟周期p_valid在最后一拍拉高然后下一个时钟沿到来时如果s_valid已经拉低p_valid就会被清零。这在工程上叫做“单周期有效脉冲”意思是告诉下游逻辑“这一拍的数据是有效的赶紧拿走下一拍就没了”。有人可能会问为什么不把p_valid一直拉到下一个数据准备好呢这取决于下游怎么用数据。在我的项目中通常p_data后面接一个FIFO或者寄存器组它们只需要一个写使能脉冲就能把数据锁存。单周期脉冲足够用而且逻辑简单清晰不会产生数据被连续写入两次的bug。如果你的应用场景希望p_valid电平保持也很容易改只需要在else分支里去掉p_valid 1b0改成保持逻辑即可但默认我推荐保留单周期脉冲的设计因为这是最不容易出问题的接口习惯。3. 仿真平台搭建与结果分析3.1 用Icarus Verilog搭建零成本仿真环境做数字设计肯定要仿真而仿真工具的选择有个很现实的问题Vivado的XSim要开整个工程ModelSim要装license对于只想快速验证一个小模块的场景这些工具都显得重。我习惯用开源的Icarus Verilogiverilog加GTKWave组合命令行两行就能跑完仿真没有任何图形界面负担最适合这种小模块的验证。在Linux或者Windows的WSL环境里安装iverilog只需要一条命令sudo apt install iverilog gtkwave然后把模块代码存成serial_to_parallel.v把下面的testbench存成tb_serial_to_parallel.v编译运行iverilog -o tb_serial_to_parallel.vvp tb_serial_to_parallel.v serial_to_parallel.v vvp tb_serial_to_parallel.vvp gtkwave tb_serial_to_parallel.vcd3.2 带自检功能的Testbench设计好的testbench不应该只是“跑一下看波形”而是要有自动对比、自动报错的能力。我写了一个带自检功能的testbench里面定义了一个发送任务和一个检查任务测试向量依次发入每发完一个字节就对比一下输出结果最后统计通过和失败的数量。timescale 1ns / 1ps module tb_serial_to_parallel; reg clk; reg rst_n; reg s_data; reg s_valid; wire [7:0] p_data; wire p_valid; integer errors; serial_to_parallel #( .DATA_WIDTH (8), .LSB_FIRST (0) ) uut ( .clk (clk), .rst_n (rst_n), .s_data (s_data), .s_valid (s_valid), .p_data (p_data), .p_valid (p_valid) ); initial clk 1b0; always #5 clk ~clk; // MSB first模式下的发送任务 task send_byte_msb_first; input [7:0] data; integer i; begin (posedge clk); s_valid 1b1; for (i 7; i 0; i i - 1) begin s_data data[i]; (posedge clk); end s_valid 1b0; s_data 1b0; end endtask // 输出对比任务 task check_output; input [7:0] expected; begin if ((p_data ! expected) || (p_valid ! 1b1)) begin $display(FAIL: expect%02h, got%02h, p_valid%b, expected, p_data, p_valid); errors errors 1; end else begin $display(PASS: data%02h, p_data); end end endtask initial begin errors 0; rst_n 1b0; s_data 1b0; s_valid 1b0; #20; rst_n 1b1; #10; send_byte_msb_first(8hB5); #5; check_output(8hB5); send_byte_msb_first(8h3C); #5; check_output(8h3C); send_byte_msb_first(8hFF); #5; check_output(8hFF); #50; if (errors 0) $display(ALL TESTS PASSED); else $display(TOTAL %0d FAILED, errors); $finish; end initial begin $dumpfile(tb_serial_to_parallel.vcd); $dumpvars(0, tb_serial_to_parallel); end endmodule注意这里的check_output必须在p_valid拉高后的半个时钟周期内执行也就是最后一个bit采样时钟沿之后下一个时钟沿之前。我使用#5延时正好卡在5ns的位置半个时钟周期所以检测到的是p_valid保持为高的状态。如果你的时钟频率变了这个延时也要相应调整或者改用等待posedge p_valid的方式。3.3 仿真结果逐周期解读以MSB first模式接收0xB5二进制1011_0101为例我推导了一遍芯片内部逐周期的状态变化整理成表格方便对照时钟周期s_data输入shift_reg内容bit_cntp_valid说明11bit70000_000100最高位先到20bit60000_001010左移一位31bit50000_010120左移一位41bit40000_101130左移一位50bit30001_011040左移一位61bit20010_110150左移一位70bit10101_101060左移一位81bit01011_010171输出0xB5p_valid拉高从表格可以直观看到第8个时钟周期时shift_reg已经包含了前7位数据组合上当前输入的s_data拼接结果正好是完整的1011_0101p_valid在这一拍拉高。用GTKWave打开生成的VCD文件你会看到p_valid是一个窄脉冲宽度正好等于一个时钟周期p_data在这个脉冲位置的值就是0xB5。整个波形干净、规整没有毛刺和不确定态。4. 调试实录三个绕不开的坑4.1 坑一位序反了原因竟然是MSB/LSB约定回到我开头说的传感器问题。当时读寄存器永远读到0x00和0xFF为什么是这两个极端值因为如果移位方向和数据手册约定的顺序不一致第一个进来的bit被放到了错误的位置最后拼出来的数据就会发生按位反转。比如正确数据是1011_01010xB5位序反了之后变成1010_11010xAD你看不出来它错了更极端的是1000_0000和0000_0001这种反转后会得到完全不同的两个数。但0x00和0xFF反转后还是自己所以当时我读到这两个值说明传感器的某个状态位本身就是极端值加上位序错乱看起来就像完全没读到数据。排查方法很简单用固定的已知数据比如0xA5或者0x5A在仿真里发送然后看输出的p_data是否等于预期值。0xA51010_0101的反转是1010_0101正好是它自己用这个值测不出位序问题。0xB5和0xAD才是好的测试向量。所以我建议仿真测试向量里至少包含一个像0xB5或0x4E这样的非对称数据确保位序错误能被发现。4.2 坑二跨时钟域采样导致偶发误码第二个坑出现在一个MIPI CSI-2转并口的项目里。串行数据是像素时钟域的而拼接逻辑跑在系统时钟域两个时钟频率不相同、相位也不固定。这种情况下直接用系统时钟采样外部串行数据会产生亚稳态表现为偶发的误码——数据99%的时候是对的但偶尔会冒出一个错误的bit。解决这个问题的标准做法是加两级同步器先把外部信号打两拍再使用。我在这里贴一个常用模板reg s_data_raw; reg s_data_sync1; reg s_data_sync2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin s_data_raw 1b0; s_data_sync1 1b0; s_data_sync2 1b0; end else begin s_data_raw s_data; s_data_sync1 s_data_raw; s_data_sync2 s_data_sync1; end end后面移位寄存器统一采样s_data_sync2。两级触发器的作用是给亚稳态一个完整的时钟周期去“稳定下来”虽然不能100%消除亚稳态但能把发生概率降到极低工程上完全够用。如果你对时序要求更高还可以用四路同步器加握手逻辑不过对付串并转换这种场景两级同步已经完全够用。4.3 坑三连续数据流下valid信号的时序设计第三个坑和我项目中的s_valid信号设计有关。在一个传感器数据采集设计里传感器输出的每个bit后面都有一个valid脉冲但valid脉冲之间有随机的空闲周期。我最初版本的代码里bit_cnt只是简单地“收到一个bit加1”结果在某个空闲周期里valid信号的一个毛刺导致计数器多计了一次整个数据帧全部错位后面所有字节都解不出来。后来我把s_valid改成帧同步模式外部只在整帧数据开始时拉高一个周期然后后续bit靠固定的时钟节拍移位不再逐个判断valid。这样一来计数逻辑只依赖时钟沿对毛刺的容忍度大大增加。如果你的协议里没有帧同步信号而是每个bit一个valid那么建议在valid进来时先做边沿检测或者滤波确保不是毛刺再用于计数。真实项目中常见的做法是把单个bit的valid信号接入一个简单的RC滤波逻辑或者用连续两个时钟周期采样到相同电平时才认为有效。5. 从模块到项目串并转换器的工程化应用5.1 用这套设计拼一个简化版UART接收器把串并转换模块拉出来稍微组合一下就能做一个简化版的UART接收器。UART的数据帧一般是一个起始位低电平、8个数据位、一个停止位高电平。接收器需要做两件事第一检测RXD引脚上的下降沿作为帧起始第二在数据位的中间点采样避免在数据翻转沿附近采样到不确定电平。下降沿检测也很简单本质上还是打拍同步然后检测“前一拍是高、当前拍是低”wire start_flag rxd_sync2_reg (~rxd_sync1_reg);检测到起始位后延时1.5个波特率时钟周期让采样点正好落在第一个数据位的正中间后面每隔一个波特率时钟采样一次依次送到我们的串并转换模块。UART是LSB first传输所以实例化时把LSB_FIRST参数设成1。等数据移位完成p_valid拉高时一个字节的UART数据就完整地呈现在p_data上了。这里每一部分下降沿检测、位中间采样、移位拼接正好都是串并转换器这篇主线下顺理成章的延伸。5.2 面积与性能评估一个16位串并转换器有多大很多初学者不太清楚这种模块在FPGA上到底占多少资源。我用Vivado实际综合了一下这个模块在默认8位配置下资源占用大约在14个触发器和7个LUT左右整个模块在Xilinx 7系列上几乎是“零成本”的存在。把参数改成16位后触发器数量大概在26个左右LUT也不会超过15个。即使在资源最紧张的MCU级FPGA比如ICE40系列上这个模块也不会成为瓶颈。性能方面单bit移位逻辑的关键路径非常短通常就是一个MUX加一个触发器移位寄存器本身没有复杂的组合逻辑。所以工作频率基本由系统时钟决定300MHz以上轻轻松松。如果你要做更高频率的串行接口比如LVDS需要考虑的事情就会多很多比如源同步时钟、SERDES原语但那是另一个层面的问题串并转换核心思想依然一致。5.3 设计可复用性的经验总结最后总结一下可复用模块设计的几个经验。第一参数化是一切复用的基础位宽、位序都做成parameter不要写死在代码里第二接口信号命名要规范清晰clk、rst_n、s_data、s_valid、p_data、p_valid这组命名简单直白任何人拿到模块都能一眼看懂第三仿真testbench要自检不要只靠人工看波形自动对比任务和错误统计是保证重构安全性的底线第四把一些调试经验固化到设计里比如位序参数、同步器结构、valid时序约定这样同一个错误不会在下一个项目里再犯第三次。串并转换器这个模块虽然小但它牵涉到的知识点一点都不少移位寄存器的方向与位序约定、valid脉冲的时序语义、跨时钟域的同步、仿真平台的搭建与自检、可复用参数的设计哲学。理解透了这个模块很多串行接口协议SPI、UART、I2S、DVP摄像头接口都能迎刃而解。按照我个人经验遇到串行接口问题先不要急着调板子回到仿真里把数据流逐周期推一遍大多数问题都能在几分钟之内定位。希望这篇分享对你有所帮助。
返回列表