1. 为什么我劝你别再手写UART收发逻辑了
刚入行那会儿,我也觉得手写一个UART收发模块是FPGA工程师的"基本功"。波特率分频、起始位检测、中间采样、停止位判断,一套状态机写下来两三百行,仿真波形调半天,上板还得拿示波器抓时序。后来项目一多,我发现一个残酷的事实:手写UART除了感动自己,几乎没有任何工程价值。它不产生差异化,不提升系统性能,纯粹是重复造轮子,而且每次换波特率、换数据位宽都得重新算分频系数,稍不留神就踩坑。
真正让我彻底放弃手写UART的,是Xilinx(现AMD)在Vivado里提供的AXI UART Lite IP核。这个IP核把UART收发逻辑、FIFO缓冲、寄存器接口全部封装好了,你只需要在Block Design里拖一个出来,配置几个参数,连上AXI总线,5分钟就能跑通串口通信。它支持标准的UART协议,波特率可配置,收发各带16字节FIFO,还能产生中断。对于绝大多数FPGA项目来说——无论是调试打印、上位机通信、还是跟MCU做数据交互——这个IP核完全够用。
这篇文章我想聊的不是"怎么点鼠标",而是为什么这么选、参数怎么算、坑在哪里、怎么排查。我会从方案选型讲到实操步骤,再把我这些年踩过的坑整理成速查表。适合刚接触Vivado的FPGA新手,也适合那些还在手写UART、想找个理由说服自己"偷懒"的老工程师。核心关键词就几个:Vivado、AXI UART Lite、IP核、FPGA、串口通信,围绕它们展开。
先说结论:如果你用的是Xilinx 7系列及以上的FPGA,Vivado版本在2015.4之后,那么用AXI UART Lite做串口通信,是性价比最高的方案。没有之一。
2. 方案选型:为什么是AXI UART Lite而不是自己写
2.1 手写UART的隐性成本被严重低估
很多人觉得手写UART"可控性强",但实际项目里,这个"可控性"往往变成"可维护性灾难"。我列几个手写UART常见的隐性成本:
波特率分频计算容易出错。假设系统时钟100MHz,要跑115200波特率,分频系数是100000000/115200≈868.05。你得做小数分频或者取整,取整后实际波特率变成115207,误差0.006%,勉强能用。但如果系统时钟是50MHz,分频系数434.02,取整434,实际波特率115207,误差同样很小。可一旦系统时钟是奇怪的频率,比如27MHz,分频系数234.375,取整234,实际波特率115384,误差0.16%,接近UART容忍极限(通常要求误差小于2%,但实际工程建议小于1%)。这些计算每次都要重来。
采样点对齐问题。UART接收需要在起始位下降沿后,延迟半个位周期采样,之后每隔一个位周期采样一次。手写状态机如果采样点偏移,误码率会飙升。我见过一个项目,仿真没问题,上板偶尔丢数据,查了三天才发现是采样点在第7个时钟周期而不是第8个,导致停止位判断临界。
FIFO和流控要自己搭。没有FIFO,CPU读数据稍慢就丢包;没有流控,高速传输时上位机来不及处理。手写这些逻辑,代码量翻倍。
跨时钟域处理。UART收发时钟和系统时钟不同源时,还得加同步器,又是一堆坑。
这些成本加起来,手写UART至少需要2-3天调试,而AXI UART Lite配置加连线,熟练的话10分钟。
2.2 AXI UART Lite到底封装了什么
AXI UART Lite IP核内部结构大致分三块:AXI4-Lite从接口、寄存器组、UART收发核心。AXI4-Lite接口负责跟MicroBlaze或Zynq PS通信,寄存器组暴露了接收FIFO、发送FIFO、状态寄存器、控制寄存器,UART核心负责实际的串行收发。
关键特性:
| 特性 | 参数 |
|---|---|
| 数据位宽 | 5-8位可配,默认8位 |
| 波特率 | 可配置,支持自定义 |
| 校验位 | 支持奇偶校验,可配 |
| 停止位 | 1位或2位 |
| FIFO深度 | 收发各16字节 |
| 中断 | 支持接收FIFO非空、发送FIFO空等中断 |
| 接口 | AXI4-Lite |
注意,它不支持硬件流控(RTS/CTS),也不支持9位数据模式。如果你的应用需要这些,得考虑AXI UART 16550 IP核。但对于90%的调试和通信场景,Lite版本足够。
2.3 什么场景该用,什么场景不该用
推荐使用:
- Zynq/MicroBlaze系统的调试串口
- 跟PC上位机做低速数据交互(<1Mbps)
- 跟MCU、蓝牙模块、GPS模块等外设通信
- 需要快速验证原型,不想在UART上浪费时间
不推荐使用:
- 需要硬件流控的高速传输
- 需要9位数据模式的多机通信
- 纯FPGA逻辑(无处理器)且资源极度紧张的场景——这时候手写一个精简UART可能更省LUT
我个人的判断标准很简单:如果系统里有MicroBlaze或Zynq PS,无脑用AXI UART Lite;如果是纯逻辑FPGA,看资源余量,余量够也用IP核,省心。
3. 核心细节解析:参数计算与配置要点
3.1 波特率到底怎么算
AXI UART Lite的波特率计算公式在PG142文档里写得很清楚:
波特率 = 时钟频率 / (分频系数 × (数据位 + 校验位 + 停止位 + 1))等等,这个公式容易让人误解。实际上Vivado IP核配置界面里,你只需要填时钟频率和目标波特率,工具会自动算分频系数。但你要知道它怎么算的,才能判断误差是否可接受。
以100MHz时钟、115200波特率为例:
分频系数 = 100000000 / (115200 × 16) ≈ 54.25AXI UART Lite内部对时钟做了16倍过采样,所以分频系数是54.25。工具会取整为54,实际波特率:
实际波特率 = 100000000 / (54 × 16) = 115740 误差 = (115740 - 115200) / 115200 ≈ 0.47%0.47%的误差在UART容忍范围内(通常要求小于2%),实际通信没问题。但如果你的时钟是24MHz,目标115200:
分频系数 = 24000000 / (115200 × 16) ≈ 13.02 取整13,实际波特率 = 24000000 / (13 × 16) = 115384 误差 = 0.16%也还行。但如果时钟是12MHz:
分频系数 = 12000000 / (115200 × 16) ≈ 6.51 取整6,实际波特率 = 12000000 / (6 × 16) = 125000 误差 = 8.5%这就超标了,通信会丢包。所以时钟频率最好选100MHz、50MHz这类能被115200整除得比较整的频率。如果系统时钟不理想,可以在IP核配置时选择"Custom"波特率,手动填分频系数,或者用时钟向导生成一个合适的时钟。
提示:Vivado IP核配置界面里有个"Actual Baud Rate"显示,配置完看一眼实际波特率和误差,超过1%就要警惕。
3.2 AXI4-Lite接口的地址映射
AXI UART Lite占用4个寄存器,地址偏移如下:
| 偏移地址 | 寄存器名 | 功能 |
|---|---|---|
| 0x00 | RX FIFO | 接收数据,读操作 |
| 0x04 | TX FIFO | 发送数据,写操作 |
| 0x08 | STAT | 状态寄存器,读操作 |
| 0x0C | CTRL | 控制寄存器,写操作 |
状态寄存器的位定义:
- Bit 0: RX FIFO非空
- Bit 1: RX FIFO满
- Bit 2: TX FIFO空
- Bit 3: TX FIFO满
- Bit 4: 中断使能
- Bit 5: 溢出错误
- Bit 6: 帧错误
- Bit 7: 奇偶校验错误
控制寄存器的位定义:
- Bit 0: 复位TX FIFO
- Bit 1: 复位RX FIFO
- Bit 2: 使能中断
- Bit 3: 使能奇偶校验
这些寄存器在MicroBlaze或Zynq PS里通过指针访问,比如:
#define UART_BASE 0x40600000 #define UART_RX (UART_BASE + 0x00) #define UART_TX (UART_BASE + 0x04) #define UART_STAT (UART_BASE + 0x08) #define UART_CTRL (UART_BASE + 0x0C) void uart_send_byte(uint8_t data) { while (*(volatile uint32_t*)UART_STAT & 0x08); // 等待TX FIFO非满 *(volatile uint32_t*)UART_TX = data; } uint8_t uart_recv_byte(void) { while (!(*(volatile uint32_t*)UART_STAT & 0x01)); // 等待RX FIFO非空 return *(volatile uint32_t*)UART_RX & 0xFF; }这段代码是我实际项目里用的,简单直接。注意读RX FIFO时要跟0xFF做与操作,因为高24位是无效的。
3.3 中断模式 vs 轮询模式
AXI UART Lite支持中断,但实际项目里我90%的情况用轮询。原因很简单:调试串口的数据量很小,轮询完全够用,而且代码简单,不容易出bug。中断模式适合高速数据流或者低功耗场景,但需要配置中断控制器,增加复杂度。
如果你确实要用中断,配置步骤:
- IP核配置界面勾选"Enable Interrupt"
- 在Block Design里把中断输出连到MicroBlaze的中断控制器或Zynq PS的IRQ
- 软件里写中断服务函数,读STAT寄存器判断中断源,清中断
中断服务函数示例:
void uart_isr(void) { uint32_t stat = *(volatile uint32_t*)UART_STAT; if (stat & 0x01) { // RX FIFO非空 uint8_t data = *(volatile uint32_t*)UART_RX & 0xFF; // 处理数据 } // 清中断(读STAT或写CTRL) }注意:AXI UART Lite的中断是电平触发,不是边沿触发。如果不清中断源,会一直进中断。清中断的方式是读RX FIFO直到空,或者写CTRL寄存器复位FIFO。
4. 实操过程:从零搭建一个串口回环
4.1 Block Design搭建步骤
假设你已经有一个Zynq或MicroBlaze的基础工程,系统时钟100MHz。步骤如下:
- 打开Block Design,添加Zynq Processing System或MicroBlaze。
- 添加AXI UART Lite IP核:在Diagram窗口右键"Add IP",搜索"AXI UART Lite",双击添加。
- 配置IP核:双击IP核,设置:
- Baud Rate: 115200
- Data Bits: 8
- Parity: No Parity
- Stop Bits: 1
- Enable Interrupt: 不勾选(先用轮询)
- 时钟频率:100MHz(跟系统时钟一致)
- 连接时钟和复位:把IP核的s_axi_aclk连到系统时钟,s_axi_aresetn连到复位。
- 连接AXI总线:把IP核的S_AXI连到Zynq的M_AXI_GP0或MicroBlaze的AXI互联。
- 引出UART引脚:IP核的uart_txd和uart_rxd引出到顶层,连到FPGA的IO引脚。
- 分配地址:在Address Editor里给AXI UART Lite分配地址,比如0x40600000。
- 生成Bitstream:Validate Design,Generate Output Products,Run Implementation,Generate Bitstream。
整个过程熟练的话5分钟,不熟练可能15分钟。关键点是时钟频率要填对,填错了波特率就错了。
4.2 引脚约束怎么写
UART的TX和RX引脚需要约束到FPGA的物理引脚。以Xilinx Artix-7为例,假设TX接在P4,RX接在P5:
set_property PACKAGE_PIN P4 [get_ports uart_txd] set_property IOSTANDARD LVCMOS33 [get_ports uart_txd] set_property PACKAGE_PIN P5 [get_ports uart_rxd] set_property IOSTANDARD LVCMOS33 [get_ports uart_rxd]注意TX是输出,RX是输入,方向别搞反。另外,如果FPGA板上有USB转串口芯片(比如CP2102、FT232),TX和RX要交叉连接:FPGA的TX接芯片的RX,FPGA的RX接芯片的TX。
提示:很多开发板已经把USB转串口芯片连好了,你只需要约束到对应的FPGA引脚。查板卡原理图确认。
4.3 软件端代码实现
在Vitis(或SDK)里新建一个Hello World工程,替换main函数:
#include <stdio.h> #include "xparameters.h" #include "xil_io.h" #include "xil_printf.h" #define UART_BASE XPAR_AXI_UARTLITE_0_BASEADDR #define UART_RX (UART_BASE + 0x00) #define UART_TX (UART_BASE + 0x04) #define UART_STAT (UART_BASE + 0x08) #define UART_CTRL (UART_BASE + 0x0C) void uart_init(void) { // 复位TX和RX FIFO Xil_Out32(UART_CTRL, 0x03); // 清除复位 Xil_Out32(UART_CTRL, 0x00); } void uart_send_byte(uint8_t data) { while (Xil_In32(UART_STAT) & 0x08); // 等待TX FIFO非满 Xil_Out32(UART_TX, data); } uint8_t uart_recv_byte(void) { while (!(Xil_In32(UART_STAT) & 0x01)); // 等待RX FIFO非空 return Xil_In32(UART_RX) & 0xFF; } int main(void) { uart_init(); xil_printf("UART Loopback Test\r\n"); while (1) { uint8_t data = uart_recv_byte(); uart_send_byte(data); // 回环:收到什么发什么 } return 0; }这段代码实现了一个简单的回环:PC串口助手发什么,FPGA就回什么。烧录后,打开串口助手,波特率115200,发送"Hello",应该能收到"Hello"。
4.4 上板验证与调试
上板后如果没反应,按以下顺序排查:
- 确认波特率:串口助手和IP核配置的波特率必须一致。
- 确认引脚:用示波器或逻辑分析仪抓TX引脚,看有没有波形。如果没有,检查约束和引脚分配。
- 确认时钟:IP核的时钟频率是否跟实际系统时钟一致。如果系统时钟是50MHz,IP核填了100MHz,波特率会翻倍。
- 确认地址:软件里的UART_BASE是否跟Address Editor里分配的一致。
- 确认FIFO复位:初始化时是否复位了FIFO。有些板子上电后FIFO状态不确定,需要手动复位。
我遇到过最坑的一次是:IP核时钟填了100MHz,但实际系统时钟是50MHz,结果波特率变成230400,串口助手用115200收全是乱码。查了半天才发现是时钟频率填错。
5. 常见问题与排查技巧实录
5.1 串口乱码问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 全是乱码 | 波特率不匹配 | 检查IP核时钟频率和波特率配置,确认串口助手波特率一致 |
| 偶尔乱码 | 波特率误差过大 | 计算实际波特率误差,超过1%换时钟频率或分频系数 |
| 收到固定字符 | 引脚接反 | TX和RX交叉连接,检查约束 |
| 无任何数据 | 引脚未约束或时钟未连接 | 检查约束文件,用示波器抓TX波形 |
| 数据丢失 | FIFO溢出 | 降低发送速率,或启用中断模式及时读数据 |
| 只能收不能发 | TX FIFO满 | 检查STAT寄存器Bit 3,等待TX FIFO非满再写 |
5.2 波特率误差计算实战
假设你的系统时钟是33.333MHz(有些板子用这个频率),目标115200:
分频系数 = 33333000 / (115200 × 16) ≈ 18.08 取整18,实际波特率 = 33333000 / (18 × 16) = 115740 误差 = 0.47%还行。但如果时钟是25MHz:
分频系数 = 25000000 / (115200 × 16) ≈ 13.56 取整14,实际波特率 = 25000000 / (14 × 16) = 111607 误差 = 3.1%超标了。这时候要么换时钟,要么用IP核的"Custom"模式手动填分频系数,但分频系数只能是整数,误差依然存在。最好的办法是用时钟向导生成一个100MHz或50MHz的时钟专门给UART用。
提示:Vivado的Clock Wizard IP可以生成任意频率时钟,但会消耗MMCM/PLL资源。如果资源紧张,尽量选系统已有的合适时钟。
5.3 AXI总线握手失败排查
有时候Block Design里AXI UART Lite连上了,但软件读写寄存器没反应。常见原因是AXI总线握手失败。排查方法:
- 检查Address Editor:确认IP核地址已分配,且没有地址冲突。
- 检查互联:Zynq的M_AXI_GP0是否使能,MicroBlaze的AXI互联是否正确。
- 检查时钟和复位:AXI UART Lite的s_axi_aclk和s_axi_aresetn必须连接,且复位要释放。
- 用ILA抓AXI信号:在Vivado里插入ILA核,抓AWVALID、AWREADY、WVALID、WREADY等信号,看握手是否完成。
我遇到过一次,MicroBlaze的AXI互联少连了一根时钟,导致UART IP核的AXI接口没时钟,软件读写全挂。查了两小时才发现。
5.4 中断不触发的排查
如果用了中断模式但进不了中断服务函数:
- 确认中断使能:CTRL寄存器Bit 2要置1。
- 确认中断控制器配置:MicroBlaze要配置中断控制器,Zynq PS要配置GIC。
- 确认中断连接:IP核的interrupt输出要连到中断控制器。
- 确认中断清标志:读RX FIFO直到空,或者写CTRL复位FIFO。
- 确认优先级:多个中断源时,检查优先级配置。
注意:AXI UART Lite的中断是"RX FIFO非空"或"TX FIFO空"触发的,不是每收一个字节触发一次。如果FIFO里一直有数据没读,中断会一直触发。
5.5 资源占用与性能考量
AXI UART Lite的资源占用(以Artix-7为例):
| 资源 | 数量 |
|---|---|
| LUT | 约200-300 |
| FF | 约300-400 |
| BRAM | 0 |
| DSP | 0 |
FIFO是用分布式RAM实现的,不占BRAM。对于资源紧张的FPGA,这个占用可以接受。如果连这个都嫌多,那只能手写UART,但省下来的LUT可能还不够你调试的时间成本。
性能方面,AXI UART Lite最高支持到约1Mbps(取决于时钟频率)。再高就不稳定了,需要换AXI UART 16550或自己写高速UART。
6. 进阶技巧:让串口通信更稳更高效
6.1 用FIFO阈值中断优化数据接收
AXI UART Lite的RX FIFO深度16字节,如果每收一个字节就进中断,CPU负担很重。可以配置成FIFO达到一定阈值才触发中断。但Lite版本不支持阈值配置,只能靠软件在中断里尽量多读。我的做法是:进中断后循环读RX FIFO直到空,一次性处理完所有数据。
void uart_isr(void) { while (Xil_In32(UART_STAT) & 0x01) { uint8_t data = Xil_In32(UART_RX) & 0xFF; // 存入环形缓冲区 ringbuf_put(&rx_buf, data); } }这样一次中断能处理最多16字节,效率高很多。
6.2 环形缓冲区设计
串口通信最怕数据丢失,加一个环形缓冲区能有效缓解。我通常定义一个256字节的环形缓冲区:
#define BUF_SIZE 256 typedef struct { uint8_t buf[BUF_SIZE]; volatile uint32_t head; volatile uint32_t tail; } ringbuf_t; void ringbuf_put(ringbuf_t *rb, uint8_t data) { uint32_t next = (rb->head + 1) % BUF_SIZE; if (next != rb->tail) { // 缓冲区未满 rb->buf[rb->head] = data; rb->head = next; } } uint8_t ringbuf_get(ringbuf_t *rb) { if (rb->head == rb->tail) return 0; // 缓冲区空 uint8_t data = rb->buf[rb->tail]; rb->tail = (rb->tail + 1) % BUF_SIZE; return data; }中断里只负责往缓冲区塞数据,主循环慢慢处理。这样即使主循环偶尔卡顿,也不会丢数据。
6.3 多串口共存的处理
有些项目需要多个串口,比如一个调试口、一个通信口。AXI UART Lite可以例化多个,每个分配不同地址。软件里定义不同的基地址:
#define UART_DEBUG_BASE 0x40600000 #define UART_COMM_BASE 0x40610000中断模式下,多个UART的中断可以共用一个中断控制器,通过读STAT寄存器判断是哪个UART触发的中断。
6.4 跟DMA配合做高速传输
AXI UART Lite本身不支持DMA,但可以通过AXI DMA IP核间接实现。思路是:UART收到数据后,CPU把数据搬到DMA缓冲区,DMA再搬到内存。但这样绕了一圈,效率不一定比CPU直接读高。对于115200这种低速场景,没必要上DMA。只有波特率超过1Mbps,才考虑DMA方案。
7. 我踩过的那些坑和最后的经验
第一个坑是时钟频率填错。前面提过,IP核配置界面填的时钟频率必须跟实际连接的时钟一致。我有一次系统时钟是50MHz,但IP核里填了100MHz,结果波特率翻倍,串口助手全是乱码。查了一下午才发现。后来我养成习惯:配置完IP核,一定用示波器抓一下TX波形,测一下位宽,确认波特率对不对。
第二个坑是FIFO复位。AXI UART Lite上电后FIFO状态不确定,如果不复位,可能残留垃圾数据。我在代码里加了初始化复位:
Xil_Out32(UART_CTRL, 0x03); // 复位TX和RX FIFO Xil_Out32(UART_CTRL, 0x00); // 清除复位这两行代码花不了多少时间,但能避免很多诡异问题。
第三个坑是AXI地址对齐。AXI UART Lite的寄存器是32位对齐的,地址偏移必须是4的倍数。如果你用结构体映射寄存器,注意编译器对齐问题。我一般直接用宏定义偏移地址,避免结构体对齐的坑。
第四个坑是中断优先级。多个中断源时,如果UART中断优先级太低,可能被其他中断阻塞,导致数据丢失。Zynq PS的GIC可以配置优先级,MicroBlaze的中断控制器也可以。我一般把UART中断设为中等优先级,既不会被高频中断淹没,也不会饿死。
最后一个经验:别在UART上炫技。我见过有人用状态机手写UART,还加了各种校验、重传、流控,代码上千行。结果项目延期,bug一堆。用IP核5分钟搞定的事,何必呢。FPGA工程师的价值在于系统架构和算法实现,不在于重复写外设驱动。把时间省下来,去优化你的图像处理流水线、去调你的高速接口时序,那才是真正产生价值的地方。
AXI UART Lite这个IP核,我从2016年用到现在,经手的项目少说几十个,从来没掉过链子。配置简单、稳定可靠、资源占用低,是Vivado里我最喜欢的IP核之一。如果你还在手写UART,真的可以试试。5分钟配置,省下两天调试时间,这笔账怎么算都划算。