ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA实现UART串口通信:从协议原理到Verilog代码与调试

FPGA实现UART串口通信:从协议原理到Verilog代码与调试 1. 项目概述做FPGA开发的人几乎没有人能绕过UART串口通信。无论是调试用的打印信息、上位机下发指令、还是和MCU之间的低速数据交互UART串口永远是最先用起来、也最容易出问题的那一环。我最早接触FPGA串口是在一个数据采集项目里。当时STM32负责采集传感器数据FPGA做并行预处理两边需要通信。一开始想用SPI后来发现STM32那边的DMA配置麻烦FPGA这边又要做从机时序来回调试了两天没搞定。最后回归UART一个发送状态机加一个接收状态机一个晚上就跑通了。从那以后我对能用串口解决的事就别整花活这句话深有体会。这篇内容适合几类人刚入门FPGA、想把串口模块彻底搞懂的初学者正在做FPGA和MCU联调、被乱码和丢字节折磨的嵌入式工程师以及虽然会抄代码、但从来没搞明白波特率误差和中点采样是怎么回事的开发人员。我尽量把原理、代码、调试经验一次讲透你看完不说能直接写出一套工业级UART IP核但至少能解决90%以上的串口通信问题。2. 整体设计思路与模块划分2.1 为什么串口协议选择FPGA实现先说个基础认知。UART本身是一种异步串行通信协议硬件上只需要两根线TX和RX收发双方各自维护自己的时钟不需要额外的时钟线。和SPI、I2C相比UART最大的特点就是异步。SPI有SCK时钟线主从设备跟着时钟沿走时序压力小I2C有SCL时钟线和地址机制协议上自己做仲裁。UART没有时钟线双方必须约定好相同的波特率然后各自用自己的时钟去采样。这个约定波特率 各自采样的机制决定了UART的实现核心在于精确的分频和可靠的采样点判断。用FPGA实现UART和用单片机实现思维上有本质区别。单片机上有现成的USART外设你只需要配置寄存器硬件帮你完成波特率生成、帧格式组合、移位收发、错误检测。FPGA没有这个现成外设除非用硬核IP一切都要用逻辑自己搭。但这恰恰是好处你可以精确控制每一个时钟周期发生的事情可以做到完全确定性的时序甚至可以把接收模块的采样窗口优化到最适合当前线缆环境的程度。这在工业场景里非常重要。我个人的看法是如果你只是想在STM32上跑个串口没必要用FPGA但如果你的系统里已经有FPGA又想省掉一个MCU芯片那么在FPGA里实现一个UART模块是完全可行的。更常见的场景是FPGA做数据采集、算法加速需要和上位机或者MCU通信那UART就是那个最不容易出错的通信桥梁。2.2 模块划分三个子模块各司其职在动手写Verilog之前先想清楚模块怎么划分。一个完整的UART收发链路我习惯拆成三个部分波特率生成模块负责把系统时钟分频成和波特率对应的采样时钟发送模块把并行的8位数据按帧格式逐位发送出去接收模块在数据线上检测起始位然后在恰当的时间点采样数据位有些工程师习惯把波特率生成和收发模块揉在一起省掉一个文件。我一开始也这么干过后来发现调试的时候很痛苦仿真波形里找波特率计数器要翻好几屏而且如果多个串口每个都要复制一份大模块。拆开之后清爽很多复用性也强。这种划分方式还有一个好处可以针对每个模块独立做仿真验证。先验证波特率生成对不对再验证发送波形是否符合协议最后验证接收能不能正确恢复数据。问题定位会精确很多。2.3 要不要上FIFO缓冲这是个绕不开的架构选择题。如果只是打印调试信息FPGA内部的UART直接透传就够用不需要FIFO。但如果串口数据要送给某个处理模块而处理模块的吞吐速率和串口速率不一致那中间一定要加FIFO缓冲。否则数据会丢。我常用的做法是在收发两端各加一个8级深度的小FIFO用FPGA内部的Block RAM或者LUTRAM实现。8级深度虽然不大但对UART这种低速通信足够。关键是FIFO可以隔离时钟域——如果上位机发送模块的时钟和接收模块不在同一个时钟域通过FIFO做异步缓冲能避免亚稳态问题。不过加FIFO会引入新的问题空满标志的时序、读使能信号的握手、溢出处理。新手一开始做的时候先别急着加FIFO直接用寄存器透传把UART本身调通再回来加FIFO。一次引入太多复杂度出了问题很难定位。3. UART协议核心原理拆解3.1 帧格式起始位、数据位、停止位关于UART协议很多人只知道8N1三个字母但具体为什么是这种格式值得展开说一下。标准UART一帧数据由这么几部分组成空闲状态TX线处于高电平起始位TX线拉低一个位时间告诉接收方我要开始发数据了数据位通常8位低位在前LSB first这是协议默认约定校验位可选奇校验或偶校验用于简单的错误检测停止位TX线拉高至少一个位时间标志一帧结束8N1就是8个数据位、无校验、1个停止位的缩写。还有一种8E1是偶校验多了一个校验位帧长度变成10位1起始 8数据 1校验 1停止。很多国产设备用8N1工业设备则经常用8E1或8O1。你如果和某个设备对不上号首先检查是不是校验位设置问题。我遇到过一次很典型的场景一台老式工业仪表输出偶校验我用默认的8N1去收收到的数据看着大部分是对的但偶尔会跳出一个错字节。用逻辑分析仪抓了才发现每帧多了一位这就是校验位不匹配的典型症状。关于停止位还有个容易忽视的点停止位拉高的时间长度代表了两帧之间的最小间隔。如果发送端时钟有误差停止位可以起到缓冲作用。所以有些设备支持1.5位或2位停止位就是为了容忍时钟误差更大的场景。3.2 波特率收发双方的时间约定波特率就是每秒传输的bit数单位是bps。比如9600bps理论上每秒传9600个bit也就是960帧8N1格式下1帧10bit。但实际工程里9600波特率下允许的时钟误差是多少这个问题比想象中重要。接收方在数据位中点采样如果发送方和接收方的时钟频率不一致误差会在一帧的传输过程中不断累积。举个例子发送方波特率比接收方快1%发送完10个bit时接收方采样第10位的时间点偏了0.1个bit。这个还在容错范围内。但如果误差到了3%第8、9个数据位就可能在边界上采样导致误码。理论上UART的容错能力大约在±4%5%的波特率误差范围内前提是接收方在位的中间采样。如果采样点不在中点容错范围还会缩小。这也是接收模块设计时必须做中点采样的核心原因。波特率误差还有个来源是系统时钟分频后取整的舍入误差。比如系统时钟50MHz要产生115200波特率分频系数就是50000000/115200≈434.03取整后误差极小可以忽略。但如果用一个不合适的系统时钟比如33MHz产生115200误差会大一些仿真时不容易发现上板跑一会儿就偶尔出错。好在大多数FPGA开发板都用了50MHz或100MHz晶振经典波特率都能做到误差小于0.1%。3.3 发送模块为什么用状态机而不是计数器实现发送模块有两条路线一条是纯计数器驱动另一条是状态机加计数器配合。计数器驱动思路简单每个bit的时间到了就把数据位移出去数满10个bit就结束。这种写法对简单场景完全够用代码也更好理解。问题在于扩展性差——你想在发送过程中加入校验位、加奇偶校验、加break信号、加帧间隔代码会越写越乱。状态机加计数器是我推荐的做法也是工业代码里最常见的写法。状态机有四个状态IDLE空闲状态TX线保持高电平START发送起始位拉低TX线一个位时间DATA发送8个数据位移位输出STOP发送停止位拉高TX线一个位时间每个状态下用一个计数器累加到一个位时间对应的时钟周期数到点就切换状态或移位。这样每个状态都是独立的加校验位只需要在DATA之后插入一个PARITY状态加break信号只需要在IDLE状态下额外拉低一段时间的TX线。维护成本低很多。还有一个细节。发送模块里移位寄存器的位序安排要非常小心。UART是低位先发所以如果8位数据存放在reg[7:0]的data里你应该先发送data[0]然后是data[1]一直到data[7]。写代码时用右移操作最方便每到一个数据位就把data右移一位最低位直接连到TX线上。3.4 接收模块为什么要中点采样接收模块比发送模块难难在它是在一根随时可能变化的线上恢复数据。先说检测起始位。接收线在空闲时是高电平一旦检测到下降沿就意味着可能出现了起始位。但线缆上的毛刺、串扰也可能产生下降沿所以不能看到下降沿就认定是起始位。标准的做法是检测到下降沿后延时半个位时间然后在起始位的中间点再次采样。如果采样到的还是低电平才确认这是真正的起始位。如果采到的是高电平就当成毛刺忽略掉回到空闲状态继续等。这个确认机制能过滤掉绝大多数短于半个位时间的毛刺。确认了起始位之后接下来的8个数据位每个位都在位中间点采样。为什么必须是中间点因为信号从发送端到接收端经过线缆传输电平翻转需要时间再加上双方时钟有误差只有在位的中间位置采样才最安全。看一个具体的时序假设系统时钟50MHz波特率115200那么一个位时间大约是8.68us对应434个系统时钟周期。接收模块用一个计数器在起始位确认后每隔434个周期采一次样正好落在每个数据位的中间位置第一次采样在起始位中间做确认之后每隔一个完整位时间采一次采到的就是数据位的中间点。这个设计细节直接决定了接收质量。如果你在位的边缘采样哪怕时钟误差很小也可能采样到正在翻转的电平导致误码。我在调试中发现很多FPGA串口接收问题根源都在采样点位置不对而不是波特率不对。你把采样点调到位的中心问题就消失了。4. 核心代码实现与参数解析4.1 参数化设计一个模块适配任意波特率先规划好参数定义。一个干净的UART模块至少要有这几个参数CLK_FREQ系统时钟频率单位MHz默认50BAUD_RATE波特率默认115200DATA_WIDTH数据位宽度固定8根据这两个参数可以在内部算出分频计数器的目标值localparam integer BAUD_DIV CLK_FREQ * 1_000_000 / BAUD_RATE; localparam integer BAUD_DIV_HALF BAUD_DIV / 2; localparam integer BAUD_CNT_WIDTH clog2(BAUD_DIV) 1;BAUD_DIV就是一个位时间对应的系统时钟周期数。比如50MHz时钟、115200波特率那么BAUD_DIV 50000000 / 115200 434。BAUD_DIV_HALF就是217半位时间。后面接收模块做起始位确认就靠它。clog2是计算2的对数向上取整用来确定计数器的位宽。如果你懒得写这个函数直接定义成一个足够大的位宽比如16位也行就是浪费一点点寄存器。参数化设计的好处是换场景时不用改逻辑。同一个模块改成9600波特率只需换个参数改时钟频率也只需换个参数。我自己的代码库里这个模块多少年没改过逻辑全是靠参数适配不同项目。4.2 波特率生成模块计数器溢出就是节拍波特率生成模块本质上就是一个计数器。计数器从0累加到BAUD_DIV - 1后清零同时输出一个脉冲。这个脉冲就是一个位时间到了的节拍信号。module uart_baud_gen #( parameter integer CLK_FREQ 50, // MHz parameter integer BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire en, output reg baud_pulse, output reg baud_pulse_half ); localparam integer BAUD_DIV CLK_FREQ * 1000000 / BAUD_RATE; localparam integer BAUD_DIV_HALF BAUD_DIV / 2; reg [15:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 16d0; end else if (en) begin if (cnt BAUD_DIV - 1) cnt 16d0; else cnt cnt 16d1; end end always (posedge clk or negedge rst_n) begin if (!rst_n) begin baud_pulse 1b0; baud_pulse_half 1b0; end else if (en) begin baud_pulse (cnt BAUD_DIV - 1); baud_pulse_half (cnt BAUD_DIV_HALF - 1); end else begin baud_pulse 1b0; baud_pulse_half 1b0; end end endmodule注意模块里有两个输出baud_pulse在计数器走到最后一个周期时产生脉冲这是完整的位节拍baud_pulse_half在计数器走到一半时产生脉冲这是半位节拍接收模块做起始位确认和位中点采样都需要它。设计上有个小细节en使能信号。平时en拉高模块持续产生脉冲。但如果你要支持帧间隔控制或仅在发送时开启波特率可以通过en控制模块的工作状态省一点动态功耗。虽然FPGA上这种规模的功耗可以忽略但接口预留上能更灵活。4.3 发送模块四状态机逐位发送发送模块核心代码大致如下module uart_tx #( parameter integer CLK_FREQ 50, parameter integer BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire [7:0] tx_data, input wire tx_valid, output reg tx_ready, output reg tx_line ); localparam IDLE 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; reg [1:0] state; reg [2:0] bit_cnt; reg [7:0] shift_reg; reg baud_en; wire baud_pulse; uart_baud_gen #( .CLK_FREQ(CLK_FREQ), .BAUD_RATE(BAUD_RATE) ) u_baud_gen ( .clk(clk), .rst_n(rst_n), .en(baud_en), .baud_pulse(baud_pulse), .baud_pulse_half() ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; tx_line 1b1; tx_ready 1b1; bit_cnt 3d0; shift_reg 8d0; baud_en 1b0; end else begin case (state) IDLE: begin tx_line 1b1; tx_ready 1b1; if (tx_valid) begin state START; shift_reg tx_data; tx_ready 1b0; baud_en 1b1; end end START: begin tx_line 1b0; if (baud_pulse) begin state DATA; bit_cnt 3d0; end end DATA: begin tx_line shift_reg[0]; if (baud_pulse) begin shift_reg {1b0, shift_reg[7:1]}; if (bit_cnt 3d7) begin state STOP; end else begin bit_cnt bit_cnt 1b1; end end end STOP: begin tx_line 1b1; if (baud_pulse) begin state IDLE; baud_en 1b0; end end endcase end end endmodule几个要点说下。握手信号。tx_valid是发送请求tx_ready是发送模块可以接收新数据的标志。tx_valid和tx_ready同时拉高时数据被锁存进去。这种握手方式在FPGA接口设计里很常用以后接FIFO、接别的处理模块都顺手。锁存数据的时机。在IDLE状态检测到tx_valid的同一个时钟周期把tx_data锁存到shift_reg里。这样从START状态开始发送模块就完全自洽地逐位移位输出不再依赖外部数据线。移位方式。每次baud_pulse到来时shift_reg右移一位shift_reg[0]直接驱动tx_line。因为UART是LSB first所以第一次DATA状态发的是shift_reg[0]也就是tx_data[0]。这个顺序千万别搞反了反了的话数据会字节反转比如0x55变成0xAA。停止位的处理。STOP状态下tx_line必须拉高。这一位结束之后不能直接回IDLE然后立刻开始下一帧——需要至少保持一小段高电平让接收方能正确识别帧边界。如果连续发送多帧数据要在发送下一帧之前预留一个位时间的空闲间隔。严格说停止位本身就已经提供了这个间隔但如果你的停止位设置成1位而下一帧的起始位紧跟其后接收方通常也能正确识别因为起始位就是下降沿停止位起始位之间有一个完整的高电平阶段。只是有些严格要求帧间隔的设备可能会出错稳妥起见可以在STOP后额外增加几个空闲位再做下一帧。4.4 接收模块起始位确认加中点采样接收模块是UART实现的核心难点代码也稍复杂module uart_rx #( parameter integer CLK_FREQ 50, parameter integer BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire rx_line, output reg [7:0] rx_data, output reg rx_valid ); localparam IDLE 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; reg [1:0] state; reg [2:0] bit_cnt; reg [7:0] shift_reg; reg baud_en; wire baud_pulse; wire baud_pulse_half; reg rx_line_d0; reg rx_line_d1; wire rx_negedge; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_line_d0 1b1; rx_line_d1 1b1; end else begin rx_line_d0 rx_line; rx_line_d1 rx_line_d0; end end assign rx_negedge rx_line_d1 ~rx_line_d0; uart_baud_gen #( .CLK_FREQ(CLK_FREQ), .BAUD_RATE(BAUD_RATE) ) u_baud_gen ( .clk(clk), .rst_n(rst_n), .en(baud_en), .baud_pulse(baud_pulse), .baud_pulse_half(baud_pulse_half) ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; rx_data 8d0; rx_valid 1b0; bit_cnt 3d0; shift_reg 8d0; baud_en 1b0; end else begin rx_valid 1b0; case (state) IDLE: begin if (rx_negedge) begin state START; baud_en 1b1; end end START: begin if (baud_pulse_half) begin if (rx_line_d0 1b0) begin state DATA; bit_cnt 3d0; end else begin state IDLE; baud_en 1b0; end end end DATA: begin if (baud_pulse) begin shift_reg {rx_line_d0, shift_reg[7:1]}; if (bit_cnt 3d7) begin state STOP; end else begin bit_cnt bit_cnt 1b1; end end end STOP: begin if (baud_pulse) begin state IDLE; baud_en 1b0; rx_data shift_reg; rx_valid 1b1; end end endcase end end endmodule这里有几个细节必须解释清楚。输入同步。rx_line从外部引脚进来首先要经过两级触发器同步消除亚稳态。第一级输出的信号可能有亚稳态风险但经过第二级触发后信号稳定可靠的概率就非常大了。rx_negedge是下降沿检测信号用同步后的相邻两级信号做逻辑判断。起始位确认。检测到下降沿后从START状态开始等baud_pulse_half也就是半位时间后再次采样。如果采样到低电平说明是真正的起始位进入DATA状态如果采到高电平说明之前的下降沿只是毛刺回到IDLE等待。这里有个时序细节要注意从下降沿到半位时间后采样rx_line_d0采到的确实是半位时间之后那一刻的电平因为波特率生成器在START状态启动后计数从0开始经过BAUD_DIV_HALF个周期产生第一个半位脉冲。这个脉冲到来时正好是起始位的中间位置。数据位采样。进入DATA状态后每个baud_pulse到来时采一次样。注意第一个baud_pulse产生的时候计数器又走了BAUD_DIV个周期也就是从起始位确认开始又过了一个完整位时间正好是第0个数据位的中间位置。之后每隔一个位时间采样下一个数据位。这样连续8次采样采到的就是8个数据位的中点值。移位方向。接收端的数据是低位先到所以shift_reg要从高位往低位填最高位存最晚到达的数据位。最后在STOP状态下把shift_reg整体赋给rx_data同时拉高rx_valid指示数据有效。这里用非阻塞赋值保证STOP状态接收到的最后一位数据能完整地出现在rx_data里。停止位的检查。有些严格的设计会在STOP状态检查采样到的电平是否为高。如果采到低电平说明出现了帧错误可以置一个错误标志。我平时会在STOP状态也加一个检查但不是必须的。加了能在调试时提前发现线缆问题或波特率不匹配的问题不加也能正常工作。5. 仿真验证与上板联调5.1 写一个能自检的testbench代码写完之后直接上板调试是新手最容易犯的错误。FPGA不像单片机没有断点调试错误定位全靠内部逻辑分析仪和信号抓取。如果连基本的功能仿真都没做过上板之后光靠眼睛看串口助手的现象排查效率会低很多。推荐的做法是先在仿真环境里搭一个回环testbench例化一个发送模块和一个接收模块把发送的TX连到接收的RX然后发送一组已知数据比如0x55、0xAA、递增序列检查接收模块恢复出来的数据是否一致。module uart_loopback_tb; reg clk; reg rst_n; reg tx_valid; reg [7:0] tx_data; wire tx_line; wire tx_ready; wire [7:0] rx_data; wire rx_valid; uart_tx #( .CLK_FREQ(50), .BAUD_RATE(115200) ) u_tx ( .clk(clk), .rst_n(rst_n), .tx_data(tx_data), .tx_valid(tx_valid), .tx_ready(tx_ready), .tx_line(tx_line) ); uart_rx #( .CLK_FREQ(50), .BAUD_RATE(115200) ) u_rx ( .clk(clk), .rst_n(rst_n), .rx_line(tx_line), .rx_data(rx_data), .rx_valid(rx_valid) ); initial begin clk 0; forever #10 clk ~clk; // 50MHz end initial begin rst_n 0; tx_valid 0; tx_data 8d0; #100; rst_n 1; #100; // 发送三个字节 send_byte(8h55); send_byte(8hAA); send_byte(8hA5); #50000; $finish; end task send_byte(input [7:0] data); begin (posedge clk); tx_valid 1; tx_data data; (posedge clk); tx_valid 0; wait(tx_ready 1); end endtask initial begin $monitor(rx_data%02h rx_valid%b, rx_data, rx_valid); end endmodule仿真跑完后重点看这几个地方发送波形里TX线是否正确拉低一个位时间再逐位输出数据接收模块的rx_valid是否在每个字节结束时拉高一次恢复出来的数据是否和发送的数据一致5.2 Modelsim/Vivado里常见仿真坑仿真本身不复杂但有一些坑我踩过提一下帮你省时间。复位时间不够。testbench里复位至少要拉低几十个时钟周期让所有寄存器都稳定复位。如果复位时间太短有些模块可能进入未知状态仿真波形看起来全是X。握手时序不对导致数据没锁存进去。发送模块的tx_valid信号要至少保持一个完整时钟周期高电平而且要在时钟上升沿之前稳定建立。如果testbench里用#10这种延迟方式拉高信号配合(posedge clk)可能会有竞争风险。稳妥做法是用非阻塞赋值配合时钟沿同步或者直接让tx_valid拉高多个周期。仿真速度问题。115200波特率下一个字节传输需要约87us在仿真器里跑起来其实很快。但如果系统时钟是100MHz仿真器每秒只能模拟几毫秒的FPGA时间所以交互式的仿真环境下等一个字节可能要等好几秒。我习惯先在testbench里用$display打印关键信号变化减少看波形的时间成本。5.3 上板联调不一定非要上位机代码仿真通过后开始上板联调。这一步最容易出的问题不是逻辑错误而是环境问题线没接对、电平不匹配、USB转串口工具不稳定。最简单的联调方案是回环测试把FPGA的TX引脚直接连到自己的RX引脚板子上如果已经连好就省事然后用逻辑分析仪或者示波器观察TX线波形。如果你的FPGA有ILAIntegrated Logic Analyzer集成逻辑分析仪核可以直接在Vivado里抓FPGA内部信号看看接收模块有没有正确恢复数据连外部示波器都省了。测试步骤分两步走。第一步用一个简单的发送功能在FPGA里写个计数器每隔一定时间比如1秒通过UART发送一帧递增数据。用上位机串口助手接收看数据是否正确。如果数据全是错的先检查引脚约束对不对、波特率设置是否一致。第二步测试接收功能上位机每隔一段时间发送一帧数据FPGA收到后原样回发。上位机能收到回显数据说明收发链路都通了。联调过程中我最常被问到的问题是为什么用CP2102或者FT232这类USB转串口工具时上位机打开串口会失败这个问题和FPGA本身无关通常是驱动没装好或者串口被其他程序占用。Windows下检查设备管理器里的COM口号确定驱动正常后再重新插拔转接头。5.4 逻辑分析仪和示波器的使用技巧调试UART示波器和逻辑分析仪是两件趁手的工具。示波器适合看模拟信号质量波形是否有过冲、振铃、边沿是否陡峭。如果你发现数据有乱码但逻辑没问题用示波器看看波形很可能是线缆过长、电平不匹配或者地线环路引起的信号质量差。逻辑分析仪适合看协议时序抓一段波形按波特率解码直接能看到每一帧的数据内容。现在市面上便宜的USB逻辑分析仪能解码UART设置波特率后自动解析能直观地确认发送的数据帧格式对不对。用逻辑分析仪时的建议采样率至少是波特率的10倍以上否则解码可能出错。比如115200波特率逻辑分析仪采样率至少1Mbps才靠谱。触发模式设为下降沿触发能稳定抓到起始位。6. 常见问题与排查技巧6.1 串口乱码先分清是哪个环节的问题乱码是出现频率最高的问题但乱码本身信息量太少先要分清是哪一种乱码。第一种完全无规律乱码字节内容每次都不一样。这种情况优先怀疑波特率不匹配。两边一个设9600一个设115200收到的自然是乱码。排查方法是确认上位机和FPGA的波特率完全一致包括8N1这样的帧格式也一致。第二种数据基本对但个别字节错位或者丢失。这种多半是线缆接触不良、信号质量差或者接收模块采样点位置不佳。用示波器看看波形检查TX线是否有明显的振铃接收端是否有干扰耦合。第三种一帧变两帧。如果在接收端看到两个连续的字节但发送端只发了一个那可能是波特率误差太大导致的位错位。两个设备之间的时钟误差超过了容错范围接收方在停止位附近采样到低电平误判为起始位产生错误的帧边界。用逻辑分析仪抓波形放大看停止位附近有没有毛刺。6.2 接收数据全FF或全00接收数据全是FF即全高电平说明接收模块一直在IDLE状态没有检测到有效的起始位。可能的因素包括TX和RX接线接反了、共地没接好、FPGA引脚约束错误、接收模块的输入同步逻辑有问题。先查线再查约束最后查代码。接收数据全是00即全低电平说明接收线被钳在低电平。一种可能是外部设备发送了break信号连续拉低超过一帧时间另一种可能是电平转换电路故障还有可能是FPGA引脚和外部设备之间的电平不匹配把信号拉死了。6.3 在上位机收不到任何数据收不到数据有三种可能FPGA根本没发送发送了但TTL电平没法被USB转串口接收USB转串口驱动有问题。排查顺序先用示波器测FPGA的TX引脚有没有波形确认FPGA在正常发送。有波形但上位机收不到用万用表测一下FPGA TX引脚的直流电平空闲时应该接近VCCIO电平。如果发现FPGA的TX引脚是3.3V而USB转串口是5V电平的RS232那就需要电平转换电路。这里涉及到一个常见误区大多数USB转串口工具内部是TTL电平输入输出范围通常是0~3.3V或者0~5V兼容可以直接和FPGA连接。但如果是真正的RS232电平设备需要额外的电平转换芯片比如MAX3232不能直接连。确认你的转接工具是哪种类型。还有个容易忽略的点不管用CP2102还是FT232都要先装好驱动在设备管理器里看到对应的COM口。USB转串口工具驱动装好之前上位机软件是打不开串口的。6.4 代码质量自查清单最后分享一份我自己调试UART时用的自查清单写代码之前过一遍能省不少事引脚约束是否正确TX、RX分配到了正确的FPGA管脚波特率分频系数是否计算正确接收模块是否做了输入同步两级触发器起始位确认是否在半位时间后采样数据位采样是否在位的中间点移位方向是否正确LSB first停止位后是否留了至少一个空闲位上位机和FPGA的帧格式是否一致串口助手打开前是否检查过COM口号设备间是否共地7. 扩展应用与调试心得7.1 从UART到RS485工业场景的常见变体标准的UART是单端信号传输距离有限。工业场景里经常用到RS485接口它本质上是UART协议 差分信号传输。硬件上需要一颗RS485收发芯片把UART的TX/RX信号转换成差分信号还要多一根方向控制线DE/RE。FPGA实现RS485时核心的UART收发逻辑不用改只需要在顶层加一个三态门控制方向assign rs485_bus tx_en ? tx_line : 1bz;发送数据时把tx_en拉高发送完成后拉低让总线释放给外部设备。这个方向切换的时机要特别小心如果切换得太快可能把停止位截断太慢则发送完第一帧后总线还被占用影响下一帧。我实测下来在进入STOP状态后延迟一个位时间再释放总线兼容性最好。7.2 多串口扩展模块复用的正确姿势很多FPGA项目需要多个串口比如一路和上位机通信、一路和传感器通信。这时模块复用的价值就体现出来了。参数化设计好的UART模块例化两次分别配置不同的波特率就是两个独立的串口通道。但要注意一点多个实例共用一个测速时钟时如果波特率不同各自的分频器独立工作不互相影响。只需保证每个例化的参数正确防止例化时参数写错。我见过有人例化两个模块时把波特率参数写反结果一路是9600一路是115200排查了很久才发现是参数书写错误。7.3 踩坑总结那些仿真测不出来的问题仿真环境永远无法完全模拟真实硬件的行为。说几个我实际遇到、仿真又测不出来的问题。第一个是引脚驱动能力不足。FPGA的IO引脚默认驱动强度可能不够接长线缆或高容性负载时波形变差。在Xilinx FPGA里可以通过IO约束设置驱动强度比如从默认的8mA改成12mA或16mA。这个调整只在信号质量有问题时需要平时不用动。第二个是上电瞬间的毛刺。有些FPGA的IO在配置完成前会有一个短暂的高阻或不定状态。如果外部设备在这段时间里发送数据就会造成误码。解决方法是外部设备等FPGA配置完成后再开始通信或者FPGA内部用一个配置完成信号门控UART模块的输出。第三个是热插拔问题。USB转串口工具在带电状态下插拔可能会在信号线上产生负压毛刺。如果直接连到了FPGA引脚长期的负压冲击会损伤IO。稳妥做法是在信号线上串联一个几百欧的限流电阻或者在FPGA引脚上加钳位二极管保护。工业级设计里这是标准做法我早期做开发板时没注意烧过两片FPGA的IO口后面都老实加保护了。我自己在实际操作中的体会是FPGA串口通信最磨人的其实是环境问题而不是逻辑问题。逻辑bug在仿真阶段就能发现但线缆接触、电平匹配、时钟误差这些只能在真实环境里暴露出来。所以每次遇到串口不通我的第一反应不是重新打开代码而是拿着示波器去量信号线。如果以后再扩展可以考虑给这个串口模块加上DMA式的数据搬运功能或者做成AXI接口的外设挂到片上总线上这样FPGA上的软核处理器也能直接用它。不过那是后话了先把收发逻辑和调试方法吃透串口这个坎就算迈过去了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进