资讯动态

FPGA串口通信实战:UART协议解析与Verilog代码实现

发布时间:2026/9/8 7:49:16 来源:尧图企业网站定制
1. 为什么串口至今仍是 FPGA 里最常用的调试通道先说结论我在做 FPGA 项目的时候第一个解决的问题永远是串口。不管是 AD 采集板、图像预处理模块还是和 STM32 的联动系统UART 串口通信都是最简单、最稳妥、最容易把“看不见的数据”拉到眼前的手段。你不需要额外引一根时钟线不需要考虑主从仲裁只要两根信号线甚至一根 TX 一根 RX就能把 FPGA 内部状态、寄存器数值、图像灰度直方图、卡尔曼滤波后的目标坐标统统发给上位机或者单片机。很多人觉得串口“太基础”“太慢”一到项目里就想直接上 PCIe、MIPI、LVDS结果板子一回来首先要干的还是先把 UART 调通。因为 PCIe 要调试链路训练、MIPI 要调 lane 对齐、LVDS 要看 serdes 时钟这一堆东西只要有一个时序边界没满足整个系统就处于“看起来死了”的状态。而串口不一样只要有示波器、逻辑分析仪或者串口助手你就能一点点确认问题出在哪一级是 FPGA 没发数据还是电平不对还是上位机根本没打开对应 COM 口。这个项目里的场景是这样的前端传感器输出的数据量不大但经过 FPGA 预处理后需要把几个关键结果上报给外部 MCU同时保留一个调试口给 PC。外部 MCU 和 FPGA 之间本来也有高速接口比如 STM32 的 FMC 总线但高速接口一旦初始化失败你根本不知道是地址线问题、数据线问题还是时序问题。这时候就靠串口在关键节点打点打印到 PC 的串口助手上一点一点把问题圈出来。串口在 FPGA 开发里的角色本质上是“最低成本的可观测性”。它不像 SPI 那样需要主机产生时钟也不像 I2C 需要处理开漏上拉和应答信号更不需要像 PCIe 那样处理分层协议。UART 是异步串行通信收发双方只需要提前约定好波特率、数据位、停止位、校验位然后在同一时间轴上采样电平变化即可。下面这张表是我常用来对比几种常见总线的小结方便刚接触 FPGA 的读者理解 UART 的位置接口信号线通信方式典型速率在 FPGA 里的实现难度UARTTX/RX 两根线异步9600bps ~ 数Mbps低状态机加计数器SPISCLK/MOSI/MISO/CS同步主从几十Mbps低但对接外设时序较烦I2CSDA/SCL 开漏同步多端100k ~ 3.4Mbps中要处理 ACK 和仲裁CANCANH/CANL 差分异步总线最高数Mbps中控制器 IP 比较成熟LVDS差分对取决于上层协议几百Mbps以上高涉及 serdes 和字节对齐所以我一直建议 FPGA 新手把 UART 通信作为第一个上板实验它能把最核心的三个概念一次讲清楚——时钟分频、状态机设计、异步信号的采样。这三个概念后面做任何复杂 IP 都用得上。2. 协议拆解起始位、数据位、停止位到底在线上怎么走做 FPGA 实现之前必须先把 UART 线上的波形吃透。UART 协议说“异步”意思是收发两边没有共享时钟但每帧数据里自带时间参考。这个参考就是“起始位”下降沿。线上空闲时保持高电平要发送数据时先拉低一个位时间接收端检测到这个下降沿就知道“要开始接收了”。一帧典型的数据结构是1 个起始位低电平8 个数据位LSB 先发可选偶校验1 个停止位高电平。如果忽略校验位那 8-N-1 就是最常见的配置8 个数据位、无校验、1 个停止位。假设我们要发送十六进制数 0x53二进制是 0101_0011。如果按照常规思维容易先发左边的 0但 UART 规定 LSB First所以实际在线上的数据位顺序是 1、1、0、0、1、0、1、0。这个细节非常容易弄反。我曾经在调试一段图像数据时发现接收到的字节和发送端不一致后来拿逻辑分析仪一量才意识到字节内位序搞反了。位序号线电平含义-高电平空闲0低电平起始位11数据位 00x53 的最低位21数据位 130数据位 240数据位 351数据位 460数据位 571数据位 680数据位 70x53 的最高位9高电平停止位“波特率”这个概念也值得单独强调。波特率是每秒传输的码元个数在串口里基本上就等于每秒传输的比特数。9600 波特率意味着每比特持续的时间是 1/9600 秒约 104.17 微秒。这在 FPGA 里的意义是你需要根据系统时钟算出这个位时间对应多少个时钟周期。举个实际数字系统时钟 50MHz每周期 20ns9600 波特率一个位时间是 104.17us对应的时钟周期数是 104.17us / 20ns 5208.33向下取整后就是 5208。也就是说计数器从 0 数到 5207正好发出一个 UART 位。这里还要说一个初学者容易混淆的点UART 是“每字节一帧”发送完停止位后可以立刻发下一字节也可以让总线空闲。如果上位机要求连续不断的波形比如用示波器看串口那么帧和帧之间如果没有任何延时波形看起来就是连续的起始位、数据位、停止位循环。但在实际应用中我更习惯在帧之间加一点硬件空闲期避免接收端状态机对边界判断产生歧义。校验位这块如果项目里只做短距离板级通信可以不用。校验位能检查“单个位翻转”这种错误但代价是降低了有效数据吞吐。我在做 FPGA-UART 时通常把校验位禁用把数据放到结构里更简单或者通过 CRC 校验一包完整数据这样比奇偶校验更有意义。3. 波特率发生器分频计算、误差和参数实现UART 的核心就是波特率发生器它在发送端决定每一位的电平保持多久在接收端决定什么时候去采样线上电平。FPGA 实现波特率发生器时最常用的办法是把系统时钟做分频用一个计数器产生“位时钟”。分频公式很简单分频计数 系统时钟频率 / 目标波特率以 50MHz 系统时钟、115200 波特率为例50000000 / 115200 434.027取整后是 434于是实际波特率变成50000000 / 434 115207误差只有约 0.006%完全在串口通信可接受范围内。但是要注意如果系统时钟频率比较低或者目标波特率比较高分频后的取整误差就可能变大。比如 25MHz 系统时钟下要跑 921600 波特率25000000 / 921600 27.13取整为 27 后实际波特率是 925925误差约 0.47%虽然看起来不大但在传输长数据帧、或者接收端采样边界比较差时容易引起误码。常见做法是让系统时钟至少是波特率的 16 倍更好是 32 倍以上保证采样点数够用。下面是以 50MHz 为系统时钟时常见波特率的分频参数目标波特率分频理想值取整值实际波特率误差96005208.3352089600.610.0064%192002604.17260419201.230.0064%384001302.08130238402.460.0064%115200434.03434115207.370.0064%92160054.2554925925.930.4700%实现波特率发生器时我推荐把时钟频率和波特率做成模块参数这样不同板卡、不同调试速度下只需要改参数不需要动逻辑。如果分频计数值超过 65535就得把计数器位宽加大。我在实际项目里习惯用 16 位计数器可以覆盖 50MHz 下最低约 9600 波特率的情况。如果系统还有 100MHz 时钟域一般需要把计数器位宽加大到 17 或 18 位否则会有溢出风险。很多刚接触 Verilog 的人会把波特率发生器做成一个独立 always 块产生一个占空比 50% 的使能时钟然后所有位状态机都在这个使能时钟沿下工作。这种做法不是不行但会引入一个容易踩的坑使能时钟不是一个全局时钟综合工具可能会把它当成门控时钟或者多时钟源跨时钟域分析变得复杂。更好的做法是“时钟使能”风格所有逻辑仍然由系统时钟驱动用一个baud_tick信号表示“当前周期正好走到一个位时间边界”当baud_tick为高时状态机推进一个位。如果用时钟使能的方式波特率发生器本身就非常简单reg [15:0] clk_cnt; wire baud_tick; always (posedge clk or negedge rst_n) begin if (!rst_n) begin clk_cnt 16d0; end else if (clk_cnt (BAUD_CNT - 1)) begin clk_cnt 16d0; end else begin clk_cnt clk_cnt 1b1; end end assign baud_tick (clk_cnt (BAUD_CNT - 1));这里BAUD_CNT是CLK_FREQ / BAUD_RATE的整数结果。这样baud_tick每个位时间出现一个高电平脉冲后续发送状态机、接收状态机都依靠它来切换位索引。好处是整个设计只有一个时钟域时序约束好做仿真波形也直观。4. 发送模块设计状态机写法与完整代码UART 发送模块的本质是一个按位发送的移位状态机。空闲时 TX 线保持高电平检测到发送请求后先把数据锁存到内部寄存器然后依次输出起始位低电平、8 个数据位、停止位高电平。我把发送状态机分成四个状态IDLE、START、DATA、STOP。位周期由baud_tick控制每收到一个baud_tick状态机就把当前位输出到 TX 引脚然后切换到下一位。核心代码我直接给出来这个版本我在 Xilinx Artix-7 和国产 FPGA 平台都用过逻辑综合和在线仿真都没问题module uart_tx #( parameter integer CLK_FREQ 50_000_000, parameter integer BAUD_RATE 115_200 )( 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 integer BAUD_CNT CLK_FREQ / BAUD_RATE; localparam IDLE 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; reg [15:0] baud_cnt; reg [ 2:0] bit_idx; reg [ 7:0] data_buf; reg [ 1:0] state; wire baud_tick (baud_cnt (BAUD_CNT - 1)); always (posedge clk or negedge rst_n) begin if (!rst_n) begin tx 1b1; tx_busy 1b0; state IDLE; baud_cnt 16d0; bit_idx 3d0; data_buf 8d0; end else begin case (state) IDLE: begin tx 1b1; if (tx_start) begin tx_busy 1b1; data_buf tx_data; state START; end else begin tx_busy 1b0; end end START: begin tx 1b0; if (baud_tick) begin baud_cnt 16d0; state DATA; bit_idx 3d0; end else begin baud_cnt baud_cnt 1b1; end end DATA: begin tx data_buf[bit_idx]; if (baud_tick) begin baud_cnt 16d0; if (bit_idx 3d7) begin state STOP; end else begin bit_idx bit_idx 1b1; end end else begin baud_cnt baud_cnt 1b1; end end STOP: begin tx 1b1; if (baud_tick) begin baud_cnt 16d0; state IDLE; tx_busy 1b0; end else begin baud_cnt baud_cnt 1b1; end end endcase end end endmodule这个代码有几个地方需要特别说明。第一baud_tick用的是组合逻辑判断baud_cnt (BAUD_CNT - 1)。baud_cnt在每个baud_tick时清零否则累加。这里清零和状态切换发生在同一个时钟上升沿所以不会出现多计一拍的问题因为baud_tick是组合信号用的是计数器的当前值而baud_cnt到下一个时钟沿后才变成 0。第二tx data_buf[bit_idx]这一句写在 DATA 状态里意味着在 DATA 状态的整个位周期里TX 引脚一直保持当前索引对应的数据位。位索引在下一次baud_tick时才递增这保证了每一位的持续时间刚好是一个完整的波特率周期。第三START 状态没有单独检查tx_busy信号外面调用模块时要避免在上一个字节还在发送时又拉高tx_start。如果应用层不确定可以在IDLE状态增加一个tx_busy反馈等tx_busy拉低后再置tx_start。这也是我实际使用时更保险的调用方式。串口发送最容易遇到的问题就是“发得太快”上层逻辑每几个时钟周期就产生一个新的发送请求但发送模块每字节需要约 10 个位周期。比如 115200 波特率下每字节约 86.8us而上层逻辑可能 1us 就能产生一包数据。这时必须在上层加 FIFO或者让数据源等tx_busy释放。我在这个项目里加了一个简单的 FIFO用写请求接收中断数据、读请求连接发送模块的tx_start这样即使瞬时数据量超过串口吞吐率也只是让 FIFO 水位变高不会丢数据。5. 接收模块与采样点的细节发送是主动行为接收是被动行为而且由于没有共享时钟接收端对“什么时候采样”的把握比发送端要难得多。UART 接收模块的任务是等线上出现下降沿确认这是起始位然后在每一位的中间位置采样电平把 8 个数据位装进移位寄存器最后在停止位阶段判断接收完成。为什么要在位的中间采样因为异步通信的时钟会有误差如果每次都在起始沿和位边界采样稍微有一点抖动和频率偏差就可能在边沿附近采到不稳定的电平。而位的中间是最稳定的区域对时钟误差的容忍度最大。接收状态机可以用四个状态IDLE、START、DATA、STOP。IDLE检测下降沿后进入STARTSTART状态等半个位时间确认总线仍然为低再进入DATA。DATA状态每个位周期采样一次连续采 8 次。STOP状态再等一个位周期输出有效数据。module uart_rx #( parameter integer CLK_FREQ 50_000_000, parameter integer BAUD_RATE 115_200 )( input wire clk, input wire rst_n, input wire rx, output reg [7:0] rx_data, output reg rx_done ); localparam integer BAUD_CNT CLK_FREQ / BAUD_RATE; localparam integer HALF_BAUD BAUD_CNT / 2; localparam IDLE 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; reg [15:0] clk_cnt; reg [ 3:0] bit_idx; reg [ 7:0] shift_reg; reg [ 1:0] state; reg rx_d; wire falling_edge rx_d !rx; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; rx_d 1b1; clk_cnt 16d0; bit_idx 4d0; shift_reg 8d0; rx_data 8d0; rx_done 1b0; end else begin rx_d rx; rx_done 1b0; case (state) IDLE: begin clk_cnt 16d0; if (falling_edge) begin state START; end end START: begin // 等半个位时间后确认确实收到起始位 if (clk_cnt (HALF_BAUD - 1)) begin if (!rx) begin clk_cnt 16d0; bit_idx 4d0; state DATA; end else begin state IDLE; end end else begin clk_cnt clk_cnt 1b1; end end DATA: begin if (clk_cnt (BAUD_CNT - 1)) begin clk_cnt 16d0; shift_reg[bit_idx] rx; if (bit_idx 3d7) begin state STOP; end else begin bit_idx bit_idx 1b1; end end else begin clk_cnt clk_cnt 1b1; end end STOP: begin if (clk_cnt (BAUD_CNT - 1)) begin clk_cnt 16d0; state IDLE; rx_data shift_reg; rx_done 1b1; end else begin clk_cnt clk_cnt 1b1; end end endcase end end endmodule这个接收模块的采样点设计很关键IDLE检测到下降沿时数据位还没有开始因为起始位本身要持续一个完整位时间。进入START后我并没有立即转DATA而是等HALF_BAUD个周期相当于走到起始位的中间。此时再确认一次rx仍然为低说明这不是毛刺而是一个真正的起始位。然后从起始位中间开始计时再等一个完整位周期正好到达第一个数据位的中间也就是rx上第一个数据位“稳如泰山”的位置。这时候采样把结果存入shift_reg[0]因为 UART 是 LSB First最先到达的数据位应该放在最低位。后续每个位周期依次把数据位存入shift_reg[bit_idx]等第八个数据位采样完进入STOP状态对停止位计时并在停止位中段输出rx_done。这里我发现不少教程里对采样的说法是“每个位周期采样一次”但没讲清楚从哪里开始计时。如果不加HALF_BAUD这一步直接进入数据位采样采到的位置会明显偏后波特率误差稍微大一点就可能读错位。我自己调试时就遇到过这种问题波特率设得越低数据越稳波特率一高或者上位机发送连续字节时就出现乱码。后来把采样点调到位的中心问题才真正解决。接收路径还需要注意一个异步问题rx是外部信号直接进入逻辑用于状态判断时理论上存在亚稳态风险。工程上常用的做法是先把rx打两拍生成同步后的rx再做边沿检测。上面的代码已经用rx_d做了一级同步更严格的场合会再加一级reg rx_d1, rx_d2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_d1 1b1; rx_d2 1b1; end else begin rx_d1 rx; rx_d2 rx_d1; end end然后所有逻辑都使用rx_d2。虽然 UART 速率低、传输距离短不一定每次都会出问题但在工业现场做项目时主板和接口板之间有长排线或者外部环境有干扰这个两级寄存器能挡住大部分亚稳态风险。这种“低成本但高收益”的做法建议直接养成习惯。6. 上板联调电平转换、USB转串、虚拟串口与故障排查写完 Verilog、仿真通过不等于串口就能通。上板后的第一个问题往往不是逻辑而是电平。FPGA 的 IO 通常是 3.3V 或者 2.5V 电平而电脑自带的串口或者工业设备上的串口常常是 RS232 电平两者高电平和低电平定义完全不同。直接用杜邦线把 FPGA 的 TX 接到 RS232 设备的 RX大概率没有任何数据甚至会损伤引脚。常见电平标准有三个我这里按应用场景区分电平标准高电平低电平典型应用TTL/3.3V3.3V 或 5V0VFPGA与单片机直连、板级通信RS232-3V ~ -15V3V ~ 15V老式电脑串口、工业设备RS485AB 差分电压AB 差分电压长距离、抗干扰总线所以 FPGA 要想和 PC 串口通信最常见的两个方案一是板子上已经集成了 USB 转 UART 芯片比如 FT232、FT231、CH340、CP2102 之类的二是自己外接一片分立的电平转换芯片比如 MAX3232 用于 RS232或者 MAX3485 用于 RS485。FT231 和 FT232 这类 USB 转 UART 芯片驱动装好之后会在系统里映射成一个虚拟 COM 口。Windows 上直接到设备管理器看“端口 (COM 和 LPT)”这一项就能找到对应端口号。Linux 里一般是/dev/ttyUSB0或者/dev/ttyUSB1。有个很常见的坑驱动装了设备识别到了但串口助手打开时提示“端口被占用”多半是有其他程序先打开了同一个 COM 口。Windows 下直接用设备管理器把端口号改成预期值能省很多事。再说一个开发中经常被问到的问题宿主机 Windows 怎么通过串口和 VMware 里的 Linux 通信这个在实际 FPGA 联调里很常见因为上位机程序在 Windows 下用而编译 RISC-V 软核、跑 Vitis/PetaLinux 或者写 Python 脚本抓串口数据的工具链在 Linux 虚拟机里。方法不复杂先通过 VMware 虚拟机设置的“串行端口”添加一个物理串口或命名管道让虚拟机能访问宿主机上的 COM 口。Linux 里再用minicom或stty设置波特率打开即可。但要注意宿主机上不能同时用另一个终端软件占住 COM 口否则虚拟机里的进程打开端口时就会失败。另外在 VMware 里直接使用 USB 转串设备时需要把 USB 设备从宿主机“连接到虚拟机”这一步操作完成后Windows 侧会短暂看到设备消失这是正常现象。联调排查时我一般按下面的顺序走命中率很高用示波器或者逻辑分析仪直接看 FPGA TX 引脚确认有没有波形。没有波形说明逻辑没跑起来回仿真查状态机。看 TX 引脚的波形电平幅值。如果高电平只有 1.8V而 USB 转串芯片只能识别 3.3V那就要查 IO bank 电压和寄存器配置。确认 USB 转串芯片的 TX 是否连到了 FPGA 的 RX两个方向不要交叉接反。FPGA 的 TX 接外部设备的 RXFPGA 的 RX 接外部设备的 TX这个“交叉”细节最容易犯。打开串口助手波特率、数据位、停止位、校验位要和 FPGA 完全一致。只看波特率一样还不够8-N-1 和 7-E-1 也会让数据完全乱掉。发送端先发纯数据 0x55它的二进制是 0101_0101因为 LSB First 和电平翻转在示波器上看应该是方波频率等于波特率一半的稳定波形。如果 0x55 都收发不对那协议实现里一定有基本问题。如果单个字节能通、连续多字节乱码优先怀疑波特率误差或者接收端没有加同步寄存器。还有一个常见故障是插上 USB 转串工具后设备管理器显示正常但串口助手里收到“EE EE”之类的乱码。这种情况先不要怀疑 FPGA 逻辑先看看你是不是把 USB 转串工具的 TX 和 RX 短接了这会自发自收收到的内容就是自己发出去的内容如果波特率没对上照样显示乱码。RS485 场景下多一个方向控制问题。RS485 是半双工差分总线发送时要拉高发送使能发送完要立刻拉低使能切回接收。如果用 FPGA 直接控制 DE/RE 引脚容易犯的错误是“使能切换太晚”。TCP 发送完成后如果 DE 仍然保持高电平总线就被本地驱动占住外部设备发过来的数据根本进不来。比较稳妥的做法是在发送状态机进入IDLE后再延迟一小段时间拉低 DE给总线留出足够的翻转时间。这个延时大小不同芯片要求不同常见 485 收发器要求 10us 左右具体以数据手册为准。最后分享一个我在项目里反复用过的小技巧不要直接在顶层把发送模块的tx_start和接收模块的rx_done接成回环也就是 “接收一个字节马上原样发回去” 来做测试。这样做确实能验证通路但如果顶层逻辑和状态机里存在一个跨时钟域小问题你看到的现象只是“回显乱码”很难定位是接收端错还是发送端错。更好的做法是先在 FPGA 内部生成一个固定递增数比如从 0x00 到 0xFF 循环发送用上位机接收并检查连续性然后再把 PC 发过来的已知数据回显。分两步走每一步的结论都是干净的排错效率会高很多。串口这个外设虽小但它是整个 FPGA 工程里离“实物”最近的一层把这层调稳了后面接图像算法、接传感器、接高速接口才真正有了底气。

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

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

免费获取报价