
UART串口通信FPGA实现这算是我在FPGA路上做的第一个正经项目也是很多初学者躲不开的坎。看起来不就是一根信号线拉高拉低吗但真要把它调通协议解析、波特率分频、状态机设计、异步信号处理、板级联调全得过一遍。做完这个小项目之后你再去看SPI、I2C、甚至PCIe会发现很多设计套路都是相通的。这篇文章适合两类人刚入门FPGA、准备从点灯进阶到通信协议的以及单片机玩得很熟、但想知道FPGA怎么用纯逻辑实现串口收发的人。我会从UART协议的基础时序讲起然后给出完整的发送、接收模块Verilog代码最后聊一聊仿真和上板调试中我实际踩过的坑。保证你看完能干完活也搞明白里面每个参数为什么这么定。1. UART串口协议拆解FPGA里要先搞懂帧格式与时序很多人拿到UART项目第一反应是搜代码这个思路不对。串口协议本身非常简单但它的时序特征直接决定了FPGA内部模块怎么设计。先花十分钟把波形看懂写代码就是顺水推舟的事。1.1 帧格式起始位、数据位、停止位怎么排UART全称是Universal Asynchronous Receiver/Transmitter异步收发器。所谓异步就是发送端和接收端之间没有独立的时钟线两端各用各的时钟靠双方约定的波特率来保证节奏一致。线在空闲状态下保持高电平。要开始发送一个字节时先把线拉低一个位时间这就是起始位。接收端正是通过检测这个下降沿来知道“数据要来了”所以起始位是整帧的同步信号。起始位之后依次发送数据位通常是8位注意顺序是LSB first也就是最低位先发。数据位之后可以加一个可选的校验位最后是停止位停止位必须拉高至少一个位时间用来告诉接收端这一帧结束了。最常用的格式是8N1即8位数据、无校验None、1位停止位。一帧总共10个位时间1个起始位 8个数据位 1个停止位。有的场景会用7E1、8O1、8N2对FPGA实现来说区别不大无非是状态机里多加一个校验状态。帧格式起始位数据位校验位停止位常见场景8N118无1最常用8E118偶校验1工业通信8O118奇校验1旧式设备8N218无2抗干扰场景1.2 波特率与时序一位数据在线上持续多久波特率就是每秒传输的比特数单位是bps。9600bps和115200bps是最常见的两个档位。每一位在线上持续的时间是1/波特率115200bps每个位约8.68微秒一帧8N1约86.8微秒9600bps每个位约104.17微秒一帧8N1约1041.7微秒这个时间参数非常关键。FPGA设计UART时本质就是用一个计数器把系统时钟分频成“位时间”的节奏。比如系统时钟是50MHz周期20纳秒那么115200bps下每个位大约是434个时钟周期。发送端每434个时钟周期翻转一次数据线的状态接收端同样以这个节奏去采样。这里有个所有新手都会问的问题既然是异步通信发送端和接收端时钟频率不完全一致怎么办答案是靠误差容忍。UART协议规定只要收发双方波特率误差控制在2%到3%以内一帧10个位累积下来的偏移不会超过半个位时间接收端在数据位中心采样就不会出错。这也是为什么5600bps能通、4800bps不通这类问题多半是波特率分频值算错了。2. FPGA端整体架构从数据流到波特率分频的计算搞懂协议之后再看FPGA内部应该怎么划分模块。UART的完整应用场景是PC通过USB转串口工具发一个字节给FPGAFPGA的接收模块把串行数据解析成并行8位数据内部逻辑处理完再由发送模块把并行数据转成串行数据发回PC。2.1 顶层模块与数据流设计整个设计可以分成三块接收模块、发送模块、波特率生成模块。接收模块负责把RX引脚上的串行波形变成8位并行数据发送模块负责把8位并行数据变成TX引脚上的串行波形波特率生成模块负责给收发模块提供统一的位时间基准。实际编码时我更倾向于把波特率计数器直接做在收发模块内部而不是单独拉出来。原因是收发两个模块对计数器的用法不同发送端需要从起始位开始计数接收端需要从检测到下降沿之后延迟半个位时间再采样分开写更清晰也便于后续单独仿真。顶层模块的数据流是外部串行数据进入uart_rx解析完成后输出rx_data和rx_valid内部逻辑比如一个LED指示、一个FIFO、一个协议栈拿到这个字节需要回发时把数据给uart_tx同时拉高tx_start信号uart_tx把并行数据串行化之后从TX引脚输出。2.2 波特率分频计数器的设计与误差计算波特率分频的核心公式是分频计数值DIV 系统时钟频率 / 波特率以50MHz系统时钟、115200bps为例50_000_000 / 115200 ≈ 434.03。计数器的做法是从0计数到DIV-1产生一个周期等于位时间的脉冲或者直接在这个计数范围内保持数据线状态不变。这里要注意DIV的取整误差会直接影响实际波特率。比如取434实际波特率是50_000_000 / 434 ≈ 115207bps与标准的115200相差约0.006%完全没问题。但如果系统时钟是典型的27MHz、33MHz这类非整数倍频率分频误差会更大选型时要先算一笔账。计算误差时不要只看一位的误差要看整帧累积。8N1帧共10个位最大允许偏差应为半个位时间换算成百分比就是5%。实际经验是控制在1%以内最稳因为对端PC的串口芯片也会有自己的晶振误差两侧误差还会叠加。2.3 为什么不在FPGA内部再生成一个波特率时钟很多从单片机转过来的朋友第一反应是用一个分频器生成115200Hz的时钟然后用这个时钟去驱动发送和接收逻辑。这种做法在FPGA里要尽量避免。原因有两个。第一FPGA内部全局时钟网络的资源有限用组合逻辑生成时钟会引起时钟偏斜和毛刺时序收敛很难保证。第二接收端需要在数据位中间采样如果只是简单地用波特率时钟驱动寄存器采样点的位置会落在位边界附近一旦有干扰就容易采错。更稳妥的方式是所有寄存器仍然由系统时钟驱动波特率分频器只产生一个“脉冲使能”信号每到一个位时间就拉高一个时钟周期。发送和接收模块看到这个脉冲就知道该切换数据位了。这个思路叫“时钟使能”或者“脉冲分频”是FPGA开发里非常核心的时序设计技巧。3. 发送与接收模块的Verilog实现可直接抄作业下面给出完整的发送和接收模块代码。风格上采用简单的三段式状态机参数化系统时钟频率和波特率换板子改参数就能用不需要重新改逻辑。3.1 发送模块状态机怎么写才不飘发送模块有4个状态IDLE空闲、START起始位、DATA数据位、STOP停止位。空闲时TX线保持高电平检测到tx_start有效就把数据锁存到移位寄存器然后拉低TX进入起始位状态。数据位阶段每个位时间移出一位LSB first。最后停止位拉高回到空闲。module uart_tx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire tx_start, input wire [7:0] tx_data, output reg tx ); localparam DIV CLK_FREQ / BAUD_RATE; localparam DIV_WIDTH $clog2(DIV); reg [DIV_WIDTH-1:0] baud_cnt; reg [2:0] bit_cnt; reg [7:0] tx_shifter; reg [1:0] state; localparam IDLE 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; always (posedge clk or negedge rst_n) begin if (!rst_n) begin tx 1b1; baud_cnt {DIV_WIDTH{1b0}}; bit_cnt 3d0; tx_shifter 8d0; state IDLE; end else begin case (state) IDLE: begin tx 1b1; baud_cnt {DIV_WIDTH{1b0}}; bit_cnt 3d0; if (tx_start) begin state START; tx_shifter tx_data; end end START: begin tx 1b0; if (baud_cnt DIV - 1) begin baud_cnt {DIV_WIDTH{1b0}}; state DATA; end else begin baud_cnt baud_cnt 1b1; end end DATA: begin tx tx_shifter[0]; if (baud_cnt DIV - 1) begin baud_cnt {DIV_WIDTH{1b0}}; tx_shifter {1b0, tx_shifter[7:1]}; if (bit_cnt 3d7) begin bit_cnt 3d0; state STOP; end else begin bit_cnt bit_cnt 1b1; end end else begin baud_cnt baud_cnt 1b1; end end STOP: begin tx 1b1; if (baud_cnt DIV - 1) begin baud_cnt {DIV_WIDTH{1b0}}; state IDLE; end else begin baud_cnt baud_cnt 1b1; end end default: state IDLE; endcase end end endmodule这里有个要注意的地方tx_start信号最好只保持一个时钟周期。如果外部逻辑一直拉高tx_start状态机在数据发送完成回到IDLE后会立刻再次启动导致连续发送同一个字节。所以调用方要做上升沿检测或者用一个握手信号。3.2 接收模块下降沿检测与中间采样接收模块比发送稍麻烦一点因为RX引脚是外部异步信号不能直接用状态机的时钟去采必须先做同步处理否则会产生亚稳态。代码里用rx_sync0和rx_sync1打两拍把异步信号同步到系统时钟域同时用组合逻辑检测下降沿。采样策略采用“延迟半位 中间采样”检测到下降沿后先等半个位时间确认这是真正的起始位中心之后每隔一个位时间采样一次正好落在每个数据位的正中间。这个方案在抗干扰和实现复杂度之间平衡得最好也是大多数UART IP核采用的做法。module uart_rx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire rx, output reg rx_valid, output reg [7:0] rx_data ); localparam DIV CLK_FREQ / BAUD_RATE; localparam HALF_DIV DIV / 2; localparam DIV_WIDTH $clog2(DIV); reg [DIV_WIDTH-1:0] baud_cnt; reg [2:0] bit_cnt; reg [7:0] rx_shifter; reg [2:0] state; reg rx_sync0, rx_sync1; wire rx_negedge; localparam IDLE 3d0; localparam START 3d1; localparam DATA 3d2; localparam STOP 3d3; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_sync0 1b1; rx_sync1 1b1; end else begin rx_sync0 rx; rx_sync1 rx_sync0; end end assign rx_negedge rx_sync1 ~rx_sync0; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; baud_cnt {DIV_WIDTH{1b0}}; bit_cnt 3d0; rx_shifter 8d0; rx_valid 1b0; rx_data 8d0; end else begin rx_valid 1b0; case (state) IDLE: begin baud_cnt {DIV_WIDTH{1b0}}; bit_cnt 3d0; if (rx_negedge) begin state START; end end START: begin if (baud_cnt HALF_DIV - 1) begin baud_cnt {DIV_WIDTH{1b0}}; state DATA; end else begin baud_cnt baud_cnt 1b1; end end DATA: begin if (baud_cnt DIV - 1) begin baud_cnt {DIV_WIDTH{1b0}}; rx_shifter {rx_sync1, rx_shifter[7:1]}; if (bit_cnt 3d7) begin bit_cnt 3d0; state STOP; end else begin bit_cnt bit_cnt 1b1; end end else begin baud_cnt baud_cnt 1b1; end end STOP: begin if (baud_cnt DIV - 1) begin baud_cnt {DIV_WIDTH{1b0}}; state IDLE; rx_valid 1b1; rx_data rx_shifter; end else begin baud_cnt baud_cnt 1b1; end end default: state IDLE; endcase end end endmodule接收模块里我最想提醒的一点是HALF_DIV如果遇到奇数分频会向下取整这在极低波特率下会有微小的时间偏移但在正常范围内不影响通信。真正影响通信的是DIV计算时取整误差太大这个前面已经算过账了。3.3 顶层回环验证把TX和RX接在一起写完收发两个模块后最快的验证方式是做回环。把接收模块输出的rx_data和rx_valid直接接到发送模块的tx_data和tx_startFPGA外部用一根杜邦线把TX和RX短接。这样PC发什么FPGA就原样发回来串口助手能看到回显就是通过。module uart_loopback #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire rx, output wire tx ); wire [7:0] data; wire valid; uart_rx #( .CLK_FREQ (CLK_FREQ), .BAUD_RATE (BAUD_RATE) ) u_rx ( .clk (clk), .rst_n (rst_n), .rx (rx), .rx_valid(valid), .rx_data (data) ); uart_tx #( .CLK_FREQ (CLK_FREQ), .BAUD_RATE (BAUD_RATE) ) u_tx ( .clk (clk), .rst_n (rst_n), .tx_start (valid), .tx_data (data), .tx (tx) ); endmodule回环验证最大的价值是先把FPGA自身的收发逻辑跑通把问题边界缩小。如果回环都不通问题一定在FPGA内部回环通了再连PC串口线、驱动、电平的问题才有意义去查。4. 仿真验证与板级调试实录乱码、收不到、波特率不对的排查代码写完之后直接上板是最容易心态爆炸的。我强烈建议先仿真再上板。仿真能把你逻辑里的结构性问题揪出来上板时只需要面对真实世界的电气问题。4.1 编写Testbench做仿真验证仿真的核心思路是模拟一个外部串口设备按照波特率时序把并行数据变成串行波形灌进RX引脚。同时在FPGA侧发起一次发送观察TX引脚波形是否符合协议。一个实用的testbench可以这样写用任务task生成一个Byte的串行波形支持任意波特率。比如模拟发送0x55这正好是01010101交替的字节能直观看到波形翻转。用#延时模拟每个位的时间例如115200bps下每位约8.68微秒。发送时先拉低一个位时间作为起始位然后依次输出8个数据位LSB first最后拉高一个位时间作为停止位。仿真时重点看两个地方一是rx_valid脉冲是否在停止位结束后拉高且rx_data是否是预期字节二是观察状态机的切换是否按IDLE→START→DATA→STOP顺序执行。如果发现rx_valid一直不来优先检查分频参数和时钟频率是否匹配。另外建议仿真时故意输入一个错误的波特率比如实际发送的是9600bps的数据但模块配置成115200观察接收到的数据是不是乱码。这能帮你建立“波特率不匹配会产生什么现象”的直觉。4.2 上板实测接线、电平与串口工具仿真通过之后就可以上板。这里有个容易忽略的问题FPGA引脚和PC之间一般不能直接连RS232。PC串口是RS232电平正负电压范围在正负3V到15V之间FPGA引脚是TTL/CMOS电平一般是3.3V。直接用会烧引脚。常见的做法是用USB转TTL模块比如CP2102、CH340、FT232R这类芯片。这些模块把USB信号转成TTL电平的串口信号直接和FPGA的3.3V引脚相连。接线时注意交叉连接模块的TX接FPGA的RX模块的RX接FPGA的TXGND必须共地。我在实际调试中遇到最多的低级错误就是地线没接。USB转TTL模块和FPGA开发板各自独立供电如果不共地两者的电平参考点不一致收发数据必然是乱码或者完全收不到。所以上板第一步一定是检查共地。开发板自带的USB转串口芯片也需要正确安装驱动。Windows系统一般会自动识别CH340和CP2102FT232R有时需要手动安装驱动。装好驱动后用串口助手打开对应COM口波特率、数据位、停止位、校验位要和FPGA内部配置完全一致。另外提一个场景宿主机是Windows虚拟机里跑Linux想通过串口与虚拟机内的Linux通信。方法是把USB转串口设备在虚拟机软件中直通给虚拟机或者使用VMware的串口映射功能把宿主机物理串口映射为虚拟机内的/dev/ttyS0。然后在虚拟机里用minicom或screen打开串口设备。注意物理串口被宿主机占用时虚拟机无法读取要避免同时打开同一个COM口。4.3 常见问题排查实录我做这个项目时调了一晚上最后发现只是分频参数少写了一个零。这种低级错误其实很常见整理几个典型问题供你对照排查。现象可能原因排查方法完全收不到数据地线没接、RX/TX接反、引脚约束错误回环测试排除FPGA内部问题用万用表量电平收到乱码波特率不匹配、分频参数算错、电平不稳用示波器或逻辑分析仪抓波形数位时间9600能通4800不通分频值溢出、对端串口配置不一致检查DIV计算确认4800的计数常数是否被优化掉发一个字节收到多个tx_start保持时间过长改为单周期脉冲触发或加握手逻辑数据偶尔错一位采样点靠近位边沿、干扰毛刺改用16倍过采样增加起始位确认单片机通信正常但PC不行USB转TTL模块驱动异常、串口被占用更换串口工具检查设备管理器“9600能通4800不通”这个问题值得单独说。这类问题多半不是FPGA逻辑有问题而是分频参数设置不合理。比如50MHz时钟下4800bps的DIV是10416这个值很大如果你定义的分频计数器位宽不够高位就被截断实际分频数完全不对通信自然失败。还有一种情况是代码里参数化做得不好改波特率时只改了发送端没改接收端或者反过来。4.4 调通之后还能往哪个方向做回环跑通只是第一步。想让这个UART模块真正可用还可以在现有基础上做几件事。第一是加FIFO缓冲。回环场景里rx_valid一来就发逻辑简单但真实应用中上位机下发一串数据时接收端处理速度可能跟不上需要一个FIFO做速率匹配。Xilinx和Intel都有免费的FIFO IP核用异步FIFO可以顺便解决跨时钟域问题。第二是增加校验位支持。8N1没有校验但有些工业现场协议会要求偶校验或奇校验这需要在状态机里加一个PARITY状态在数据位和停止位之间插入校验位。第三是RS485方向控制。RS485是半双工总线发送时要把方向引脚拉高发送完拉低。把UART发送模块的IDLE状态和STOP状态做一个方向信号输出就能直接在现有模块上扩展。第四是提高抗干扰能力。目前的接收模块只做了一次采样可以改成16倍过采样在每个数据位内采样16次用多数表决或中间三次采样取多数的方式判决能明显增强抗噪能力。这个优化在实际工业环境中非常有效。如果在调试过程中手边有逻辑分析仪或者FPGA内嵌的逻辑分析仪ILA一定要用起来。有一次我怎么都调不通后来用ILA抓到内部状态机发现是复位信号在板子启动过程中出现毛刺导致状态机跳到了default分支。这种问题靠肉眼盯代码根本盯不出来抓波形一秒钟就明白了。我个人在做这个项目时最大的体会是UART虽然简单但它逼着你把同步设计、计数器思想、状态机写法、异步信号处理这些FPGA基本功全部过了一遍。做完以后再学SPI和I2C明显轻松很多。如果你正在被UART调不通折磨别灰心按照先仿真后上板、先回环后接PC的顺序一步步来大部分问题半天之内都能解决。