资讯动态

UART串口通信FPGA实现全解析:协议时序到调试避坑指南

发布时间:2026/9/8 4:05:18 来源:尧图企业网站定制
先说个我个人的看法很多入门FPGA的朋友接触的第一个通信协议都是UART串口通信因为开发板上基本都板载了USB转串口芯片插一根线就能和电脑互通看起来非常友好。可真正把它当成一个工程来做的时侯从Verilog代码到上板收发能一次跑通的人其实不多。问题不出在协议概念有多难而在于串口通信这种“异步”机制对时序极其敏感任何一点毛刺、任何一段没对齐的边界最后都变成串口助手里的一行乱码。这篇文章我把“UART串口通信FPGA实现”从头到尾拆一遍包括协议时序、时钟分频计算、Verilog收发模块写法、仿真验证思路以及我实际联调时踩过的各种坑。适合刚接触FPGA、准备拿串口当练手项目的同学也适合已经写通代码、但一上板就收错数据的兄弟们对照排查。1. UART协议拆解为什么说它“简单又不简单”1.1 一帧数据到底长什么样UART全称是Universal Asynchronous Receiver/Transmitter通用异步收发器。它最核心的特点是“异步”也就是收发双方没有共享时钟只靠预先约定好的波特率每秒传输的bit数量来对齐节奏。一帧标准数据由四部分组成起始位线路空闲时保持高电平发送端先拉低1个bit时间表示“我要开始发数据了”。数据位紧跟起始位通常为8位按LSB最低位先行的顺序依次发出。校验位可选用于简单的错误检测可以是奇校验或偶校验也可以不用。停止位至少1个bit时间的高电平表示一帧数据结束线路恢复到空闲状态。最常见配置是8N1即8位数据、无校验、1位停止位。很多MCU和PC串口默认就是这个参数FPGA实现也基本围绕这个格式展开。1.2 收发双方靠什么对齐时间因为没有时钟线接收端要想知道每个bit从哪开始、在哪结束唯一能依赖的就是起始位的下降沿。检测到下降沿后接收端会在每个bit的中点附近采样从而避开数据跳变区域降低误码概率。这也解释了为什么波特率误差不能太大。假设发送端和接收端的波特率相差超过一定比例累积到后面的数据位就会偏到bit边界附近采样点就可能采到错误电平。一般要求误差控制在2%以内工程上更推荐控制在1%以下。1.3 常见参数配置与实际选择实际项目里8N1基本是默认配置。波特率常见的几种波特率适用场景备注9600调试、低速传感器最通用容错率高115200常规数据传输、日志调试速度快仍是主流460800以上高速透传、图像辅助链路对时钟精度和线缆质量要求高这里有个经验如果只是和PC串口助手调试115200完全够用如果FPGA要和STM32等MCU通信建议两边都选一个能够被系统时钟精确分频的波特率误差越小越好。2. 工程立项时钟、电平和模块架构的提前决策2.1 波特率分频的计算与误差评估FPGA内部没有专门的UART硬件外设所有时序都需要用系统时钟去“数”出来。假设系统时钟是50MHz波特率是9600那么一个bit持续的时间对应多少个时钟周期呢计算公式很简单大于需要的bit时间等于系统时钟频率除以波特率。这里就有个隐藏在分数里的坑。以50_000_000 / 9_600为例结果是5208.33不是整数。取5208的话实际波特率变成了50_000_000 / 5_208 9_601.2误差约0.0125%完全在可接受范围内。但换到某些波特率时误差就会大到离谱。比如50MHz时钟跑2_000_000波特率50_000_000 / 2_000_000 25刚好整除没问题。可如果跑19200结果是2604.17取整后误差约0.006%依然安全。但如果系统时钟只有12MHz又非要跑11520012_000_000 / 115_200 104.17取整104后实际波特率是115384.6误差接近0.16%短时间内还能忍受但连续传长包时错误率就会上升。所以设计第一步先算清楚分频系数和误差不行就换系统时钟或换波特率别在源头上埋雷。2.2 电平转换电路的选型逻辑FPGA的IO电平通常是1.8V、2.5V或3.3V取决于bank电压。而PC串口走的是RS232电平正负电压范围高达正负12V左右两者电压域完全不同直接对接必烧芯片。常见的做法是板载USB转串口芯片比如FT232、CH340这类它们内部已经完成了USB到UART的电平转换输出端是3.3V TTL电平。FPGA端3.3V bank可以直接对接注意查看bank的VCCO是否配置为3.3V。如果FPGA bank电压是1.8V和3.3V的USB转串口芯片直连就有风险需要加电平转换芯片或者选择支持1.8V电平的串口芯片。这个细节在选型阶段就要确认等板子打回来才发现就麻烦了。2.3 顶层模块怎么切复用性才高我习惯把UART功能拆成三个独立模块uart_tx负责并行数据转串行发送。uart_rx负责串行接收转并行数据。baud_gen波特率分频器产生一个单周期的bit使能脉冲。如果后续要加RS485只需要在uart_tx/uart_rx外部再包一层方向控制和收发使能逻辑核心收发模块不需要改动。如果要用在Zynq等带ARM核的平台上这样的模块拆分也方便接入AXI-Lite总线做寄存器映射复用性很好。顶层模块不写任何收发逻辑只做实例化和信号连线。这样做的好处是仿真时可以单独测每个子模块定位问题非常快。3. 手写Verilog发送、接收与顶层连线的完整说明3.1 用使能脉冲代替门控时钟分频器的正确姿势很多初学者会把分频后的信号当时钟用比如always块下面再用分频时钟触发。这在简单实验里能跑通但综合后很容易产生时钟偏斜和毛刺一旦系统复杂起来就会莫名其妙出错。正确做法是系统时钟始终保持不变分频器输出一个单周期的使能脉冲所有状态机仍然用系统时钟驱动只在使能脉冲为高时才推进一个bit。这样整个设计只有一个时钟域时序约束也好做。reg [12:0] clk_cnt; wire bit_en; parameter BAUD_CNT_MAX 13d5207; // 50MHz / 9600 always (posedge clk or negedge rst_n) begin if (!rst_n) clk_cnt 13d0; else if (clk_cnt BAUD_CNT_MAX) clk_cnt 13d0; else clk_cnt clk_cnt 1b1; end assign bit_en (clk_cnt BAUD_CNT_MAX);clk_cnt的位宽要根据分频系数来定最大分频值是5207用13位足够。注意比较条件是等于BAUD_CNT_MAX不是BAUD_CNT_MAX减1再减1之类的写法。3.2 发送方向的状态机与时序控制发送模块的状态机相对简单空闲时TX线保持高电平检测到tx_start请求后依次进入起始位、数据位、停止位。核心代码思路如下module uart_tx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 9_600 )( input wire clk, input wire rst_n, input wire tx_start, input wire [7:0] tx_data, output reg tx, output reg tx_busy ); localparam BIT_CNT_MAX CLK_FREQ / BAUD_RATE - 1; localparam HALF_BIT_CNT BIT_CNT_MAX / 2; localparam IDLE 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; reg [1:0] state; reg [2:0] bit_index; reg [15:0] clk_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; tx 1b1; tx_busy 1b0; bit_index 3d0; clk_cnt 16d0; end else begin case (state) IDLE: begin tx 1b1; tx_busy 1b0; if (tx_start) begin tx_busy 1b1; state START; clk_cnt 16d0; end end START: begin tx 1b0; if (clk_cnt BIT_CNT_MAX) begin clk_cnt 16d0; bit_index 3d0; state DATA; end else begin clk_cnt clk_cnt 1b1; end end DATA: begin tx tx_data[bit_index]; if (clk_cnt BIT_CNT_MAX) begin clk_cnt 16d0; if (bit_index 3d7) begin state STOP; end else begin bit_index bit_index 1b1; end end else begin clk_cnt clk_cnt 1b1; end end STOP: begin tx 1b1; if (clk_cnt BIT_CNT_MAX) begin clk_cnt 16d0; state IDLE; end else begin clk_cnt clk_cnt 1b1; end end default: state IDLE; endcase end end endmodule注意两个细节第一tx_start信号只能持续一个周期否则状态机可能重复进入发送流程。如果外部控制信号是电平保持型需要在顶层加一个脉冲边沿检测把长电平转成单周期脉冲。第二发送数据时tx tx_data[bit_index]会产生一个bit的延迟但这恰好和分频计数的时序匹配最终波形对齐在bit中心附近实测没有问题。如果你想在时钟上升沿立刻更新电平需要把赋值时机改成clk_cnt为0时生效但那样反而容易产生毛刺建议先按上面的结构跑通再优化。3.3 接收方向的中点采样与亚稳态处理接收比发送麻烦。发送端知道自己什么时候开始接收端只能靠RX线上的下降沿来判断起始位。关键点在于采样时机。检测到起始位下降沿时这个下降沿本身可能正好落在系统时钟上升沿附近直接用rx信号驱动状态机判断会引入亚稳态。所以第一步必须做同步处理用两级寄存器打两拍消除亚稳态。reg rx_sync1; reg rx_sync2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_sync1 1b1; rx_sync2 1b1; end else begin rx_sync1 rx; rx_sync2 rx_sync1; end end wire rx_negedge rx_sync2 ~rx_sync1;检测到下降沿后理论上起始位的中点是在半bit之后。所以接收状态机启动一个计数器计数到HALF_BIT_CNT时对RX采样之后每隔一个完整bit时间采样一次。这也是接收端比发送端更依赖精确分频的原因。如果在起始位判决时采样点偏了后面8个数据位全都会跟着偏。接收状态机可以这样组织module uart_rx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 9_600 )( input wire clk, input wire rst_n, input wire rx, output reg [7:0] rx_data, output reg rx_done ); localparam BIT_CNT_MAX CLK_FREQ / BAUD_RATE - 1; localparam HALF_BIT_CNT BIT_CNT_MAX / 2; localparam IDLE 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; reg [1:0] state; reg [15:0] clk_cnt; reg [2:0] bit_index; reg [7:0] shift_reg; reg rx_sync1, rx_sync2; wire rx_negedge rx_sync2 ~rx_sync1; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; rx_data 8d0; rx_done 1b0; end else begin rx_done 1b0; rx_sync1 rx; rx_sync2 rx_sync1; case (state) IDLE: begin if (rx_negedge) begin state START; clk_cnt 16d0; end end START: begin if (clk_cnt HALF_BIT_CNT) begin if (rx_sync2 1b0) state DATA; else state IDLE; clk_cnt 16d0; bit_index 3d0; end else begin clk_cnt clk_cnt 1b1; end end DATA: begin if (clk_cnt BIT_CNT_MAX) begin clk_cnt 16d0; shift_reg[bit_index] rx_sync2; if (bit_index 3d7) begin state STOP; end else begin bit_index bit_index 1b1; end end else begin clk_cnt clk_cnt 1b1; end end STOP: begin if (clk_cnt BIT_CNT_MAX) begin rx_data shift_reg; rx_done 1b1; state IDLE; end else begin clk_cnt clk_cnt 1b1; end end default: state IDLE; endcase end end endmoduleSTART状态里加了二次确认也就是在起始位中点再次判断RX是否为低。如果为高说明之前的下降沿是毛刺直接回IDLE。这一手能滤掉大部分窄毛刺干扰是提高接收鲁棒性的关键。3.4 顶层模块的互联与回环测试思路顶层只需要做实例化把发送端和接收端接出来同时预留一个内部回环模式调试时一键切换。所谓内部回环就是把FPGA内部的tx信号直接接到rx信号上逻辑上形成自发自收。这样不需要外部接线也不需要串口助手就能验证收发逻辑是否正确。回环模式对初学者来说是体检工具wire tx_to_rx; assign rx_internal loop_en ? tx_to_rx : rx_external; uart_tx u_tx ( .clk(clk), .rst_n(rst_n), .tx_start(tx_start), .tx_data(tx_data), .tx(tx_to_rx), .tx_busy(tx_busy) ); uart_rx u_rx ( .clk(clk), .rst_n(rst_n), .rx(rx_internal), .rx_data(rx_data), .rx_done(rx_done) );如果回环数据正确说明发送和接收内部逻辑都没问题如果回环都不对就不用急着接外部串口线了先盯着仿真波形查内部状态机吧。4. 仿真与板级调试的完整排查路线4.1 Testbench设计回环测试只是一种“安慰”我在最初写UART时testbench只测了tx和rx回环波形看起来完美结果上板之后第一个字节就错了。原因很简单回环测试的两个模块共用同一个系统时钟rx看到的tx信号没有任何真实世界的时间偏差和噪声这相当于拿同一把尺子量同一段距离自然量不出问题。真正有说服力的testbench要模拟外部设备的时序。比如用另一个独立的testbench进程生成一个9600波特率的波形波形由行为级的延迟模型产生然后作为rx输入给到FPGA内部。这样rx的状态机和外部“假串口设备”之间是不同步的正好模拟真实情况。具体做法就是把testbench里的串口发送部分写成一个任务task每bit用系统时钟周期的整数倍来延时task uart_send_byte(input [7:0] data); integer i; begin tb_rx_line 1b1; #(CLK_PERIOD * 5208); tb_rx_line 1b0; // start #(CLK_PERIOD * 5208); for (i 0; i 8; i i 1) begin tb_rx_line data[i]; #(CLK_PERIOD * 5208); end tb_rx_line 1b1; // stop #(CLK_PERIOD * 5208); end endtask注意这里task内部的延时是基于系统时钟周期的和FPGA内部计数器的值完全独立仿真时才能暴露分频误差带来的采样偏移。4.2 波形异常怎么定位几个真实案例我在调试过程中遇到过三种典型异常后来发现都是细节问题。第一种是发送的起始位被接收端忽略。原因是testbench里起始位的低电平时间太短或者接收端同步器在下降沿检测时发生了打拍延迟导致起始位还被当作毛刺滤掉了。解决办法是把同步器输出用于状态判断而不是直接用原始rx信号。第二种是接收数据顺序颠倒。数据位按LSB先行发送接收端如果也按bit0放进shift_reg[0]收到的就是正确顺序。但如果发送端不小心按MSB先行发接收端又按LSB存最后就是0x01变成了0x80这种问题在波形上一眼就能看出来数据位逐bit颠倒。第三种是停止位之后立刻收到下一帧起始位导致帧粘在一起。真实串口数据通常连续发送停止位和下一帧起始位之间往往没有空闲间隔。接收端在STOP状态不能只发一个rx_done就回IDLE要确保此时RX已经确实回到高电平或者至少留出半个bit的时间窗口。否则IDLE状态检测不到下降沿下一帧的起始位就丢了。4.3 上板查错的检查顺序与工具使用板级调试和仿真完全是两码事我总结了一套固定检查顺序不走弯路确认时钟管脚约束正确开发板上的系统时钟有没有通过PLL或者BUFG进到设计里。用逻辑分析仪或示波器看TX引脚确认发送端能正常翻转。短接FPGA的TX和RX开启回环用串口助手发一个字节看能不能原样收回来。回环没问题后再接外部USB转串口确认电平标准一致、共地可靠。最后再用外部设备收发观察误码率。如果回环正常但外部通信乱码十有八九是电平或接线问题而不是Verilog逻辑问题。这时候去折腾代码就是浪费时间。5. 跨设备联调芯片、电平、线缆和虚拟机的那些坑5.1 电平标准不匹配的连锁反应FPGA最常见的是3.3V IO很多USB转串口芯片也支持3.3V TTL理论上可以直接互联。但如果FPGA的bank电压设置了1.8V那么IO输出高电平只有1.8V对3.3V接收端来说可能仍在逻辑高电平阈值内可反过来3.3V输出到1.8V输入就过压了长期使用会损伤FPGA引脚。所以设计阶段一定要确认bank电压。如果FPGA的VCCO是1.8V必须加电平转换。网上搜“uart电平转换电路3.3 1.8”能找到不少参考图核心就是用MOS管或者专用电平转换芯片实现双向转换只加电阻分压是单向方案无法满足双向通信需求。5.2 与STM32等MCU直连的细节FPGA和STM32这类MCU直连时两边基本都是3.3V TTL电平理论上是兼容的。但我遇到过三个实际坑第一两边没有共地。TTL电平是相对GND的不共地会导致接收端判断电平错误。这是串口通信中最隐蔽的问题没有之一。第二TX和RX接反。FPGA的TX要接STM32的RXFPGA的RX接STM32的TX交叉接线。名字越看越像反而越容易接错。第三复位时序。FPGA上电后复位释放需要时间如果STM32上电后立刻发数据FPGA的rx模块可能还没进入IDLE状态导致前几个字节丢失。解决办法是FPGA复位释放后加一个小的延时窗口或者由STM32端在FPGA初始化完成后延时几十毫秒再开始发送。如果设计里涉及STM32和FPGA通过FMC总线通信那么串口更多是作为调试通道使用。这种情况下建议把串口数据帧格式设计得简单一点比如帧头长度数据校验方便在调试时把FMC交互的日志打出来。5.3 宿主机与虚拟机之间的串口透传设置很多人在Windows宿主机上用VMware跑LinuxFPGA板子插在宿主机上但Linux虚拟机里看不到串口设备。这事跟FPGA本身无关却会让整个调试流程卡住。VMware里可以通过串口透传把宿主机的物理串口映射给虚拟机。步骤是虚拟机设置里添加串行端口选择“使用物理串行端口”把FT232或CH340对应的COM口映射进去然后Linux里就能看到/dev/ttyS0或类似设备。需要注意两点宿主机上的串口调试助手不能同时占用这个COM口否则虚拟机无法打开。如果使用FT232R或FT231X芯片Windows宿主机需要装对应的USB UART驱动否则设备管理器里一直显示未知设备。真实调试中日志量大的情况下把串口映射给Linux虚拟机用minicom或Python pyserial读数据比Windows串口助手更舒服因为可以写脚本自动分析错误帧。6. 几个值得继续扩展的方向UART收发跑通之后这只是串口通信的起点后面有几个很自然的扩展方向。一是加奇偶校验或CRC校验。UART本身只保证格式不保证数据内容正确。校验位能检测单bit错误如果数据量大或者链路质量差建议在应用层加CRC16虽然会多一些逻辑但对数据完整性要求高的场景非常值得。二是加FIFO缓冲。FPGA的收发速度通常比外部设备快如果不加缓冲数据来了就要立刻处理处理不过来就会丢。用一个简单的异步FIFO或同步FIFO暂存数据可以显著降低丢包率。三是从UART扩展到更复杂的接口。UART本质上是最简单的串行协议搞清楚它之后再去接触I2C、SPI、甚至LVDS、MIPI这些高速接口思路会顺很多因为核心都是一样的时钟怎么来、数据怎么对齐、状态机怎么切。唯一的变化是速度从几千波特率提升到几百兆对时序和分析能力的要求完全不是一个级别。如果项目里需要RS485通信核心收发模块也基本不用改只需要在FPGA外部加一个RS485收发芯片再增加一个方向控制信号。方向控制由发送状态机控制tx_busy有效时输出高电平使能发送其他时候拉低使能接收比UART本身还要简单。写在最后的调试心得个人实际跑过几个项目之后最大的体会是UART串口通信FPGA实现这件事代码量不大但每一步都藏着细节。仿真能过不代表上板能跑回环能通不代表外部通信稳定。调试时不要急着改代码先确认电平、接线、共地、驱动、串口占用这些“外围因素”往往问题不是FPGA逻辑本身而是它和真实世界之间的接口。再分享一个小技巧我在板级调试时习惯用0x55这种交替电平的数据做测试因为0x55的二进制是01010101每一位都在翻转对时序毛刺非常敏感。如果0x55收发正确再换0x01、0x80这种边界数据测试基本就能确定整个链路可靠了。这个习惯帮我快速排掉过很多为了排查“偶尔错一个字节”而浪费的半天时间你也可以试试。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价