ARTICLE DETAIL

资讯详情

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

FPGA实战:手写UART通信协议与Verilog实现全解析

FPGA实战:手写UART通信协议与Verilog实现全解析 1. 为什么每个FPGA开发者都该亲手写一遍UART先说结论UART是FPGA入门阶段性价比最高的通信协议没有之一。它协议简单、调试直观、硬件依赖少一根USB转TTL线就能搞定全部验证非常适合用来完成从“看懂Verilog”到“写出能跑的系统”这一步跨越。UART全称是Universal Asynchronous Receiver/Transmitter通用异步收发器。和SPI、I2C这类带时钟线的同步协议不同UART只需要两根数据线TX发送、RX接收收发双方事先约定好波特率然后各自按节奏工作谁也不用迁就谁。这种“没有时钟线也能通信”的特性让它在工业控制、传感器采集、调试日志输出等场景里长期占有一席之地FPGA和PC、MCU之间互连时基本都会碰到它。那为什么非要拿FPGA来实现一遍市面上现成的UART芯片、USB转串口方案那么多直接用不就行了这里面的逻辑在于UART虽然简单但涉及数字电路设计中几个最核心的问题——跨时钟域的同步策略、边沿检测、状态机设计、计数分频、亚稳态处理。每个问题单独看都不难但组合在一起恰好构成一个完整的、可实测的练习项目。你在PC上写串口程序永远体会不到“RX线毛刺导致起始位误判”这种硬件问题而在FPGA上这些坑你大概率会踩一遍踩完就长记性了。这篇文章适合正在学FPGA的初学者、准备用FPGA做数据采集或通信项目的工程师以及想把手头的开发板吃透的硬件爱好者。我会从协议最基本的概念讲起一直讲到可综合的Verilog代码、板级调试流程和经典踩坑记录看完可以直接照着抄。2. UART通信机制一次传输到底发生了什么2.1 帧格式里的每个bit都有讲究先看UART传输一帧数据的完整样子。空闲状态下TX线保持高电平要发数据了先拉低一个bit时间起始位然后按低位在前依次送出数据位最后是停止位拉高并保持至少一个bit。这串波形从头到尾就是一次完整的数据帧。下图用字符串示意一帧8位数据、无校验的波形方便建立直观印象空闲高电平 → 起始位0 → D0 → D1 → D2 → D3 → D4 → D5 → D6 → D7 → 停止位1 → 空闲高电平几个关键参数的选择习惯我列一下数据位最常见8位也有5/6/7位的老式ASCII传输有时用7位但现在基本清一色8位。校验位可选无校验、奇校验、偶校验。校验位的作用是简单粗查对噪声敏感的场景可以开但要注意它只能“发现错误”不能“纠正错误”发现错误后协议层得做重传。停止位常见1位、1.5位、2位。停止位本质是给对方一个“这帧结束了准备收下一帧”的时间缓冲。实际中1位停止位已经非常好用2位多见于低速远距离场景。波特率每秒传输的符号数。常见9600、115200、460800等。嵌入式调试里115200是黄金档位速度够快而且USB转串口芯片基本都稳。2.2 为什么说UART是“异步”的同步协议I2C/SPI有SCL/SCK时钟线接收方跟着时钟沿采样数据不需要预先对齐。UART没有这跟线接收方只能靠“事先约定的波特率”来推算每个bit持续多长。打个比方两拨人约好早上9点整排队投篮第一个人先喊一声“开跑”后面的人每隔固定秒数出发一个。只要大家的秒表足够准队列就能一直保持整齐。但要是有一方秒表不准队列就会慢慢漂移最终乱套。UART的原理就是——用波特率做所有人的“秒表”。所以异步通信最怕的是收发双方波特率不一致。发送端按9600发接收端按19200收收到的数据一定是乱的。初学调试串口时遇到乱码第一件事就是查两边的波特率这一点放在任何平台上都成立。2.3 接收端如何确定采样点发送端按波特率把数据一位一位推出去接收端怎么知道“现在该采第几位了”两个机制配合解决起始位对齐RX线从高电平跳变到低电平时接收端认为这是起始位前沿从此开始清零计时。中点采样每个bit周期里接收端不急着在边沿处采样而是等一段时间在bit的正中间位置采一次。这样即使线上有一点边沿抖动、传播延迟也不容易采到信号跳变的过程值。更稳妥的做法是16倍过采样——把波特率周期细分成16个等份检测到起始位后每16个时钟采一次。还有设计会在bit中央连续采样三次取多数表决进一步滤除毛刺。初学阶段先用1倍采样把功能跑通理解了时序后可以再升级到16倍过采样。3. FPGA端整体架构不要把所有逻辑堆在一个文件里3.1 模块划分思路很多新手拿到“用FPGA实现UART”这个需求后直接在顶层文件里写一个很大的状态机把分频、采样、移位全揉在一起。这种写法不是不行但后期调试会非常痛苦——出问题时你不知道是分频有问题、状态机跳错了还是数据移位出错了。我的建议是分成四个模块各司其职模块职责边界波特率生成器由系统时钟分频产生波特率时钟或tick使能只输出一个周期的高电平脉冲发送模块读并行数据按帧格式串行移出不管分频输入数据输出TX线接收模块检测起始位采样RX线拼出并行数据不管分频输入RX线输出并行数据顶层文件实例化以上模块连接复位和FIFO等外围只做装配不写逻辑模块之间用“脉冲使能”baud_tick通信而不是直接连波特率时钟。这样写的好处是逻辑清晰也方便在仿真里单独验证每个模块。3.2 波特率分频系数怎么算假设系统时钟clk_freq 50MHz目标波特率baud_rate 115200分频系数计算公式为divisor clk_freq / baud_rate 50_000_000 / 115200 ≈ 434.028因为有小数取整后实际波特率是actual_baud 50_000_000 / 434 115207.4误差大约0.0064%远小于UART允许的3%左右误差没有任何问题。这里有个经验参数表是我常用的几个配置时钟50MHz波特率分频系数divisor实际波特率误差960052089600.60.006%1152004341152070.006%4608001084629630.47%460800这个档位因为分频系数小、量化精度变差误差接近0.5%但仍然在可用范围内。如果系统时钟更高比如100MHz或200MHz同样的波特率分频系数变大量化误差会更小通信更稳。3.3 复位策略和时钟域问题这个项目里所有模块都在同一个系统时钟域下工作不存在真正的跨时钟域不需要上异步FIFO。但有两个问题要注意异步复位还是同步复位我习惯用异步复位、同步释放的方式复位信号直接进always块的敏感列表释放时经过两级同步器打拍避免复位释放瞬间产生亚稳态。波特率tick不要到处扇出把波特率tick当作普通的单周期脉冲使用不要把它当always的时钟边沿来触发否则综合后可能出现意想不到的时序路径问题仿真和实测表现也会不一致。// 异步复位、同步释放的标准写法 always (posedge clk or negedge rst_n) begin if (!rst_n) begin rst_sync1 1b0; rst_sync2 1b0; end else begin rst_sync1 1b1; rst_sync2 rst_sync1; end end4. 核心Verilog代码实现每个关键分支我都给出完整示例4.1 波特率生成器这个模块的思路很直接用计数器数到divisor-1后清零同时拉出一个单周期的tick。注意不是生成一个时钟信号而是生成脉冲。module uart_baud_gen #( parameter integer DIVISOR 434 // 50MHz时钟下115200波特率 )( input wire clk, input wire rst_n, output reg baud_tick ); reg [15:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 16d0; baud_tick 1b0; end else if (cnt DIVISOR - 1) begin cnt 16d0; baud_tick 1b1; end else begin cnt cnt 1b1; baud_tick 1b0; end end endmodule4.2 发送模块的状态机发送模块用状态机管理帧流程空闲时TX线保持高电平来一个发送请求后依次经历起始、数据位、停止位结束后回到空闲。module uart_tx #( parameter integer DATA_WIDTH 8 )( input wire clk, input wire rst_n, input wire baud_tick, input wire tx_start, // 发送启动脉冲 input wire [DATA_WIDTH-1:0] tx_data, // 待发送的并行数据 output reg txd, // 串行输出 output reg tx_busy // 忙信号高有效 ); localparam IDLE 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; reg [1:0] state; reg [3:0] bit_cnt; reg [DATA_WIDTH-1:0] shift_reg; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; txd 1b1; tx_busy 1b0; bit_cnt 4d0; shift_reg {DATA_WIDTH{1b0}}; end else begin case (state) IDLE: begin txd 1b1; if (tx_start) begin tx_busy 1b1; shift_reg tx_data; bit_cnt 4d0; state START; end else begin tx_busy 1b0; end end START: begin txd 1b0; // 拉低一个bit作为起始位 if (baud_tick) begin state DATA; end end DATA: begin if (baud_tick) begin txd shift_reg[0]; shift_reg {1b0, shift_reg[DATA_WIDTH-1:1]}; // 注意上面两行不能颠倒时序关系下面还有处理 if (bit_cnt DATA_WIDTH - 1) begin bit_cnt 4d0; state STOP; end else begin bit_cnt bit_cnt 1b1; end end end STOP: begin txd 1b1; // 拉高停止位 if (baud_tick) begin state IDLE; tx_busy 1b0; end end default: state IDLE; endcase end end endmodule这里有一个容易踩的坑在DATA状态下发送当前位并移位可以放在同一个baud_tick分支里。但是要注意txd的输出值和移位寄存器更新是有先后逻辑的——如果你把移位写在txd赋值之前那你发出去的是移位后的数据不是原始第一位。常见的写法是先赋值txd shift_reg[0]然后下一拍移位或者在同一拍里配合使用一个中间变量我在上面代码里用了非阻塞赋值的顺序性让txd取到的是移位前的bit0这是正确的。4.3 接收模块起始位检测与中点采样接收端相对发送端要灵活一点容易出错这是整个UART项目里值得反复打磨的部分。module uart_rx #( parameter integer DATA_WIDTH 8 )( input wire clk, input wire rst_n, input wire baud_tick, input wire rxd, // 串行输入 output reg [DATA_WIDTH-1:0] rx_data, // 并行接收结果 output reg rx_done // 接收完成脉冲 ); localparam IDLE 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; reg [1:0] state; reg [3:0] bit_cnt; reg [DATA_WIDTH-1:0] shift_reg; // 打拍同步降低亚稳态风险 reg rxd_d1; reg rxd_d2; wire rxd_negedge rxd_d2 ~rxd_d1; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rxd_d1 1b1; rxd_d2 1b1; end else begin rxd_d1 rxd; rxd_d2 rxd_d1; end end always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; rx_data {DATA_WIDTH{1b0}}; rx_done 1b0; bit_cnt 4d0; shift_reg {DATA_WIDTH{1b0}}; end else begin rx_done 1b0; // 默认为0只在完成那拍拉高 case (state) IDLE: begin if (rxd_negedge) begin state START; end end START: begin // 起始位中点采样确认确实是低电平 if (baud_tick) begin // 如果起始位采样不是0说明是毛刺干扰回到IDLE if (rxd_d2 1b0) begin bit_cnt 4d0; state DATA; end else begin state IDLE; end end end DATA: begin if (baud_tick) begin shift_reg {rxd_d2, shift_reg[DATA_WIDTH-1:1]}; if (bit_cnt DATA_WIDTH - 1) begin bit_cnt 4d0; state STOP; end else begin bit_cnt bit_cnt 1b1; end end end STOP: begin if (baud_tick) begin // 停止位应为高如果不是说明帧错误 if (rxd_d2 1b1) begin rx_data shift_reg; rx_done 1b1; end state IDLE; end end default: state IDLE; endcase end end endmodule接收模块里有两个思路需要单独说明打两拍rxd从外部进来经过两根触发器的打拍变成rxd_d1、rxd_d2这样比较干净地同步到本地时钟域。虽然这只是单bit信号亚稳态概率很低但打拍成本极低作为好习惯值得一直坚持。起始位中点再确认检测到下降沿后先不急着收数据等到起始位的中点再看一次采到的电平。如果此刻不是低电平那就说明刚才的下降沿可能只是干扰毛刺直接回IDLE。这个小技巧能过滤不少线缆上的抖动和噪声。至于数据移位方向我采样的是“先收进来的放在最高位”的写法第一个到达的bit (D0) → shift_reg[7] 第二个到达的bit (D1) → shift_reg[6] ...因此等8个bit收完shift_reg的bit顺序和初始tx_data一致。如果你按相反方向移位最后输出时就要做位序反转否则会出现高低字节全反的怪问题。4.4 顶层模块拼接顶层模块不需要太多逻辑把上面三个模块例化并接信号就够了。这里做一个简单的回环设计接收到一个字节后在LED上显示并把同样的数据原封不动发回去。这个设计非常方便联调串口助手发什么它就回什么。module uart_loopback #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire uart_rx, output wire uart_tx, output wire [7:0] led ); localparam DIVISOR CLK_FREQ / BAUD_RATE; wire baud_tick; wire [7:0] rx_data; wire rx_done; wire tx_busy; uart_baud_gen #(.DIVISOR(DIVISOR)) u_baud ( .clk (clk), .rst_n (rst_n), .baud_tick(baud_tick) ); uart_rx u_rx ( .clk (clk), .rst_n (rst_n), .baud_tick(baud_tick), .rxd (uart_rx), .rx_data (rx_data), .rx_done (rx_done) ); uart_tx u_tx ( .clk (clk), .rst_n (rst_n), .baud_tick(baud_tick), .tx_start (rx_done), // 收到数据后立即回发 .tx_data (rx_data), .txd (uart_tx), .tx_busy (tx_busy) ); assign led rx_data; endmodule这里需要注意tx_start和rx_done的连接接收完成产生一个脉冲发送模块收到后开始发送。脉冲只有一拍不需要额外边沿检测。如果你发的字节比较大想加FIFO缓存也可以在顶层里插入FIFO这个问题后面的章节会专门讲。5. 仿真验证仿真阶段抓不到的问题上板后一定加倍还给你5.1 搭建Testbench的思路不要一上来就写完整系统级仿真应该先分别验证发送和接收。我的习惯是三个层次先单独仿真波特率生成器确认baud_tick周期正确。然后仿真发送模块给一个8位数据抓状态跳变和txd波形逐bit核对。最后仿真接收模块用一个行为级task模拟发送端按帧格式驱动rxd验证接收结果的正确性。Testbench里有个关键点仿真时的“发送端”需要用行为级代码产生波形不能用和DUT完全一样的逻辑否则等于自己验证自己测不出问题。我用过一个很笨但很有效的办法在TB里用#5208这样的延时颗粒来产生对应波特率的数据位虽然不优雅但对初学者理解波特率的含义极其直观。// 行为级仿真产生一帧数据50MHz时钟下115200波特率每bit约8680ns task uart_send_one_byte(input [7:0] data); integer i; begin rxd 1b1; #8680; rxd 1b0; // start bit #8680; for (i 0; i 8; i i 1) begin rxd data[i]; // LSB first #8680; end rxd 1b1; // stop bit #8680; end endtask5.2 常见仿真错误为什么数据总是不对没有等够时间仿真时baud_tick脉冲一出现就立刻查data reg结果data还是旧值。要记住非阻塞赋值在always块结束时才生效在下一个时钟边沿才能看到结果。正确的做法是在baud_tick出现后再等几个仿真时间单位或者检查下一拍的数据。状态机和tick的相位对不上如果发送端和接收端的baud_tick不同步比如一个在前沿计数到满后拉高另一个在下一拍才采样可能每帧的首尾bit会偏半个周期。实际中这点误差通常不影响通信但仿真里要看你的比较时序够不够宽容。发送模块和接收模块时钟频率不一致仿真时如果用两个不同频率的clk驱动两边的模块那你等于人为制造了波特率不匹配结果肯定乱。Testbench里收发两个模块要共享同一份时钟。5.3 一个值得做的仿真用例回环测试里我建议在Testbench里做三组数据的发送中间间隔几个“空闲位”initial begin rst_n 0; #100 rst_n 1; uart_send_one_byte(8hA5); #50000; uart_send_one_byte(8h3C); #50000; uart_send_one_byte(8hFF); #50000; $finish; end这三组数据分别对应1010_0101、0011_1100、1111_1111第一组高低电平交错最多最容易暴露采样时序问题第二组有连续的高低电平块适合核对停止位第三组全是高电平主要是检查空帧和停止位边界。仿真通过后才算具备上板的基础。6. 板级验证与调试从代码下载到串口助手回显6.1 XDC约束文件怎么写以Xilinx 7系列为例引脚约束要同时包含物理引脚和电平标准不然后续上板会遇到io电平不匹配的问题。set_property PACKAGE_PIN N17 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] create_clock -period 20.0 [get_ports clk] set_property PACKAGE_PIN R4 [get_ports uart_rx] set_property IOSTANDARD LVCMOS33 [get_ports uart_rx] set_property PACKAGE_PIN V4 [get_ports uart_tx] set_property IOSTANDARD LVCMOS33 [get_ports uart_tx]注意开发板上的UART口电平标准是3.3V还是2.5V用错了IOBANK的电平标准串口可能完全没反应或者逻辑电平不对。6.2 用串口助手做回环测试上板后第一步用USB转串口线把开发板的UART_TX和UART_RX连到PC。打开串口助手设置相同的波特率、8位数据、无校验、1位停止位发送一个十六进制字节比如AA。如果一切正常串口助手应该收到同样的AA。实际操作中我发现一个很实用的技巧不要只发一个字节就干等。多试几种数据模式比如连续发递增序列00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F这样能在一次传输里同时检查各类边沿和连续变化位。如果回显数据出现“中间断档”多半是某个bit采样错了这时可以用逻辑分析仪抓RX/TX线的真实波形直接对照标准帧格式找原因。6.3 逻辑分析仪和示波器的起手式很多FPGA开发板上不带逻辑分析仪硬件但Xilinx有ILAIntegrated Logic AnalyzerIP核可以综合时直接插入抓片内信号的真值。接入ILA后把baud_tick、state、rxd_d2、shift_reg等都加进探针列表触发条件设为rxd_d2下降沿。这样一旦有数据进来ILA就能把整个接收过程的波形记录回来非常直观。如果没有ILA可用退一步用示波器看TX端口的波形也能干活。重点看起始位是否干净、每个bit的宽度是否一致、停止位有没有变短。bit宽度明显不均匀往往是波特率计数器溢出或分频系数计算错误。6.4 波特率误差为何会导致偶发错位你可能见过这种情况回环测试发100个字节前99个都正常最后1个乱码。这种偶发错位多数是累计误差导致的。前面列过用50MHz时钟生成115200波特率每次分频取整有0.0064%误差。单字节10bit累计误差大约是0.0064%×10bit0.064%个bit时长几乎可以忽略。但如果你的分频系数很小比如时钟25MHz生成921600的波特率divisor27误差会显著变大一帧长度偏差可能达到采样窗口的边缘导致最后几位采错。所以设计时建议留点余量优先选择波特率能被时钟频率较好整除的组合万一必须用较高波特率接收端就优先上16倍过采样把采样窗口进一步缩小到bit中央能吃掉不少误差。7. 工程化拓展从“跑通”到“好用”的几个进阶方向7.1 加入FIFO做缓存回环测试里收到一个字节就发送不存在缓存问题。但实际场景往往是FPGA外设以较高速度产生数据UART按115200的慢速往外发两边速率不匹配丢数据几乎是肯定的。解决办法是在接收端和发送端之间加FIFO。最简单的是用Xilinx的FIFO IP核配成Standard FIFO、读侧独立时钟、数据宽度8位即可。收到一个字节就写FIFO发送端FIFO非空就拉tx_start发送完成后自动取下一个数据。这个结构把“接收速率”和“发送速率”解耦开吞吐量由FIFO深度和UART速率共同决定。7.2 用中断或标志位管理流控和MCU联调时经常会遇到MCU发来大量数据、FPGA来不及处理的情况。此时UART协议本身没有硬件流控除非用RTS/CTS线所以常见做法是在FPGA端增加rx_full标志FIFO快满时拉高某个GPIOMCU读到后再暂停发送。或者直接简单粗暴FPGA收到数据后立即回一个ACK字节MCU只有收到ACK才发下一个。这个机制叫软件流控实现在两边都简单而且回环测试的代码几乎可以直接改造成ACK应答模式。7.3 多字节命令帧的协议设计单字节收发只是第一步真实项目里很少只传裸数据。建议在UART之上设计一个简单的帧协议比如帧头长度命令字数据校验0xAA0x010x10...XOR累加FPGA里用状态机解析这个帧收到完整帧再触发对应的业务逻辑。这一步做完你就从“会用UART”进步到了“能设计通信协议”的层面这个能力在后续很多项目里都用得上。7.4 常见问题速查表问题现象可能原因排查方式串口助手完全收不到数据TX/RX接反、引脚约束错误、波特率设置不一致先查引脚约束再量TX波形收到的数据全是0x00或0xFF停止位采样错误、RX引脚悬空、无上拉用ILA看rxd波形和采样点偶发乱码或丢字节波特率累计误差、外部干扰、FIFO溢出降低波特率、加FIFO、用16倍过采样首字节丢、后面正常复位释放导致FIFO状态未就绪检查复位时序确保复位释放后再操作FIFO回环数据比发送多或者少发送端tx_start和rx_done竞争或FIFO读写冲突加握手确保上一帧发完再取新数据8. 写在最后的一点实操心得这个UART项目我前前后后带过不少人做过踩坑最多的往往不是Verilog语法而是对“异步”这个概念的理解。Timing diagram盯着看了半天等到自己写代码时就忘了“接收端要在bit中央采样”这个关键动作。所以我的建议是先手工画一遍收发波形再动写代码仿真和上板交替验证不要憋着一次写完。另外就是分频系数的计算一定要自己推一遍不要只抄别人代码里的数字。不同开发板时钟频率不一样抄错了波特率代码综合下载后依然跑不通到时候查问题更麻烦。最后再分享一个小技巧调试UART时串口助手的发送延迟尽量调大一点比如每发一个字节间隔50ms以上。这不光是照顾FPGA的处理速度更重要的是方便你在示波器或逻辑分析仪上把每一帧波形看得清清楚楚出问题时能一眼定位是哪个bit出错。等基础逻辑全部验证稳定了再逐步加快发送频率测出系统的真实吞吐上限。UART只是通信的起点但它背后那套“协议分析→架构拆解→仿真验证→板级调试”的方法论才是FPGA开发里真正值钱的东西。这条路上踩过的每个坑后面都会变成你的本能反应。
返回列表