做FPGA嵌入式这几年,串口通信接口里用得最多、也最容易出幺蛾子的,就是UART。而Xilinx官方提供的AXI Uartlite这款IP核,是我在Zynq和MicroBlaze平台里最常挂在总线上用的串口控制器。这篇文章不打算讲多高深的理论,就把我从头到尾配置、驱动、调通AXI Uartlite的完整过程,以及踩过的那些坑,整理成一份可以直接照着做的经验记录。无论是刚接触Vivado的新手,还是被串口调试折磨过的老手,应该都能从这里找到点有用的东西。
1. 为什么我最终选择了AXI Uartlite这个IP核
1.1 它到底解决什么问题
做FPGA项目时,尤其用Zynq或MicroBlaze搭建片上系统,UART几乎是一个躲不开的外设。无论是调试打印、引导日志输出,还是和外部MCU、传感器、上位机做低速数据交互,一个稳定好用的UART控制器都是刚需。
Xilinx官方在Vivado里提供了一套现成的串口解决方案,AXI Uartlite就是这个家族里的轻量级选手。它在AXI4-Lite总线上暴露出几个简单的寄存器,通过读写这些寄存器,就能完成波特率设置、数据收发、状态查询和中断控制。相比自己用Verilog手写一套波特率发生器、移位寄存器和FIFO,AXI Uartlite的接入成本低得惊人:
- 图形界面配置,不用写一行RTL代码
- 自动生成AXI接口,适配AXI4-Lite总线读写
- 自带接收FIFO和发送FIFO,数据缓冲不用自己操心
- 官方SDK驱动成熟,上电后调用库函数即可完成收发
对于工程师来说,选择这个IP核的原始动机通常很朴素:省时间。串口本身技术含量不算高,但细节很多,用手写RTL实现一个兼容性好、波特率误差小、能扛住总线持续读写的外设,至少得折腾几天。直接用官方IP核,把时间留给业务逻辑,是性价比最高的方案。
1.2 与UART16550、自定义UART相比,它有哪些取舍
Vivado里除了AXI Uartlite,还提供了AXI UART16550。两者很容易混淆,我刚开始也纠结过很久。
16550是PC时代就存在的经典串口控制器,寄存器布局和PC标准兼容,FIFO深度可以配得更大,而且可以通过DMA方式搬运数据,适合高吞吐场景。Uartlite则更轻,FIFO通常只有16字节,不支持DMA,但占用逻辑资源小,驱动简单,用在调试输出和低速通信上绰绰有余。
我个人的选型经验是这样的:
- 只是打印日志、和外部低速设备通信,选AXI Uartlite
- 需要兼容PC软件、使用标准COM口寄存器、或者通信数据量较大,选UART16550
- 如果系统里连AXI总线都没有,只是一个纯FPGA逻辑模块,那不如直接在Verilog里调用一个开源UART模块,省去总线开销
这里要明确一点,AXI Uartlite不是万能的。它的名字里带着“lite”,定位就是轻量。不要指望它做大数据量的透传,也不要用它去对接需要高实时性的协议。选型之前先搞清楚需求边界,后面才不会返工。
2. Vivado中的IP配置与硬件搭建
2.1 创建并配置AXI Uartlite IP
打开Vivado,在IP Catalog里搜索“Uartlite”,就能看到AXI Uartlite的条目。双击进入配置界面,关键参数不算多,但每一个都有讲究。
先看“C_S_AXI_ACLK_FREQ_HZ”,这个参数是AXI总线时钟频率,必须填准确。这个值会被IP核用来生成波特率分频,填错直接导致串口乱码。我遇到过不止一次,有人把100MHz填成50MHz,实际波特率直接翻倍,收出来的数据全是错位的。
再看“C_BAUDRATE”,这是目标波特率,常见的有9600、115200、460800。这个IP核的波特率生成基于整数分频,通常只能设置标准波特率。如果要跑非标准波特率,一定要先确认实际分频误差是否在可接受范围内。UART通信双方允许的波特率误差一般在±2%以内,超过这个范围就容易出误码。
“C_DATA_BITS”可配置5到8位,一般选8。“C_PARITY”可选无校验、奇校验或偶校验,必须和对端设备保持一致。“C_USE_PARITY”如果不勾选,校验相关状态位不会生效。“C_ODD_PARITY”则决定奇校验还是偶校验。这些参数组合起来,基本能覆盖绝大多数串口设备的协议要求。
配置完成后点击OK,IP核会在Block Design里生成一个带AXI4-Lite从接口、UART收发引脚和中断引脚的模块。它的引脚不算多,核心就四个:s_axi_aclk、s_axi_aresetn、uart_txd、uart_rxd,外加一个interrupt输出。这种简洁的接口,即使第一次用也能很快上手。
2.2 在Block Design里完成总线与中断连接
将IP核加入到Block Design后,需要给它接上AXI总线。在Zynq平台上,一般挂在Processing System的M_AXI_GP0或GP1上;在MicroBlaze平台上,通过AXI Interconnect连接。
连接时有一个细节值得注意:AXI Uartlite的时钟域和复位域必须和AXI Interconnect保持一致。如果你用了Clocking Wizard给不同模块提供不同频率的时钟,务必确认Uartlite的s_axi_aclk和总线的时钟是同一个,否则跨时钟域会出现偶发读写失败。
另一个关键点是中断引脚。AXI Uartlite有一个interrupt输出,当接收FIFO中的数据量达到触发深度,或者发送FIFO空时,会拉高中断。在Zynq里,这个引脚要接到GIC的PL到PS中断线上,例如IRQ0或IRQ1。在MicroBlaze里,则通常先接在Concat IP上,聚合后再通向中断控制器。
这一步是我最常看到新手出错的地方:中断引脚悬空。结果就是轮询能收到数据,中断模式下却永远进不了中断处理函数。别问我怎么知道的,排查过太多次了。硬件上接好中断线后,还要在PS侧配置里勾选对应的中断通道,Vivado生成的平台文件才会把中断ID暴露给SDK。
2.3 时钟与波特率的计算关系
很多文章会把UART通信的波特率配置说成是“软件里写个寄存器就行”,但AXI Uartlite的波特率来源不是这样。波特率的基准来自AXI总线时钟S_AXI_ACLK,IP内部用下面的关系做分频:
实际波特率 = S_AXI_ACLK频率 / 分频系数
这里的“分频系数”是IP核根据C_BAUDRATE和总线时钟频率自动计算并固化的,并不会在软件运行期被动态修改。也就是说,如果你在Block Design里填的时钟频率和实际运行的时钟频率不一致,串口就会以错误的速率工作。
举个例子,总线时钟配置成50MHz,目标波特率115200,实际分频系数就是434,换算出来的实际波特率约115207,误差很小。但如果总线时钟实际是100MHz,配置却填了50MHz,实际波特率就会变成约230400,直接翻倍,双方必然乱码。
有些情况下,你可以通过驱动里的XUartLite_SetBaud函数在软件运行期重新设置波特率,但前提条件是IP核内部的时基已经由硬件配置正确。换句话说,软件可以改目标值,但硬件时钟基准必须对。为了防止这类低级问题,我后来养成了一个习惯:生成比特流之前,花10秒钟确认Block Design里时钟频率和IP配置页面的总线时钟频率一致。看似浪费时间,实际能省下几个小时的调试时间。
2.4 用ILA观测AXI读写时该看什么
调试Uartlite过程中,如果怀疑是AXI总线层面的问题,我会上ILA核挂到总线接口上。很多人问ILA的采样频率是不是有范围限制,确实有。ILA的采样时钟一般直接用s_axi_aclk,采样深度根据实际需要设置,数据位宽则根据要观测的信号数量决定。采样时钟频率太高或采样深度太大,都会显著增加资源占用,严重时甚至导致布局布线失败。
观测AXI4-Lite读写时,重点看三个信号:AWVALID和AWREADY的握手是否完成、WVALID和WREADY握手是否完成、BVALID和BREADY响应是否正常。AXI协议里的valid和ready必须是同时有效才叫一次握手,只有valid拉高但ready一直为低,说明从设备还在忙,这就是所谓的背压。Uartlite作为从设备,它不会主动背压AXI读写事务,所以通常一瞬间就能完成握手。如果ILA里看到WREADY长时间为低,很可能总线频率或复位有问题。
不过说实话,如果只是验证Uartlite寄存器读写,用SDK的Xil_In32和Xil_Out32直接读写基地址更直观,ILA更适合排查复杂总线互连问题。
3. 软件端:从轮询到中断的完整用法
3.1 最小发送/接收程序
建立SDK或者Vitis工程后,BSP里会自动生成uartlite的驱动文件。以Zynq平台为例,分配好基地址后,在BSP设置里可以看到UartLite对应的驱动是uartlite,版本号一般与IP核配套。
API非常简单。初始化部分通常是这样:
#include "xuartlite.h" #include "xuartlite_l.h" XUartLite UartInst; XUartLite_Config *UartConfig; UartConfig = XUartLite_LookupConfig(XPAR_UARTLITE_0_DEVICE_ID); XUartLite_CfgInitialize(&UartInst, UartConfig, UartConfig->RegBaseAddr); XUartLite_ResetFifos(&UartInst); XUartLite_SetBaud(&UartInst, 115200);发送数据时调用:
int sent = XUartLite_Send(&UartInst, buffer, length);这个函数是阻塞发送,会一直等到所有字节写入发送FIFO。因为Uartlite的发送FIFO只有16字节,如果要发送超过这个容量的数据,它会自动分批写入。
接收数据时:
int received = XUartLite_Recv(&UartInst, buffer, length);注意,这个接收函数不会阻塞。如果FIFO里数据不足,它最多返回当前FIFO里已有的字节数。所以实际应用里经常会看到一个循环轮询接收的模式:
while (1) { received = XUartLite_Recv(&UartInst, recv_buf, sizeof(recv_buf)); if (received > 0) { // 处理接收到的数据 } }但轮询有个明显问题,CPU会被一直占用,而且在有更高优先级任务时,接收时机不可控,容易丢数据。这时候就该考虑中断模式了。
3.2 中断模式配置步骤
在Zynq上启用Uartlite中断,步骤比较固定,我把过程拆开来说。
第一步,硬件确认:IP核的interrupt引脚接到PS的中断控制器。在Vivado里,双击Zynq Processing System,展开Interrupts配置,勾选PL-PS Interrupts中的IRQ0或IRQ1,然后把Uartlite中断引脚连上去。硬件重新生成并导出到SDK后,BSP里会自动生成XPAR_FABRIC_UARTLITE_0_INTR_INTR这样的中断ID宏。
第二步,软件里初始化并安装中断处理函数:
#include "xscugic.h" static XScuGic Intc; static XUartLite UartInst; void UartIntrHandler(void *CallBackRef) { XUartLite *UartPtr = (XUartLite *)CallBackRef; u8 buffer[64]; int received = XUartLite_Recv(UartPtr, buffer, sizeof(buffer)); // 这里处理接收到的数据 } void setup_uart_intr(void) { XScuGic_Config *IntcConfig; IntcConfig = XScuGic_LookupConfig(XPAR_SCUGIC_SINGLE_DEVICE_ID); XScuGic_CfgInitialize(&Intc, IntcConfig, IntcConfig->CpuBaseAddress); Xil_ExceptionInit(); Xil_ExceptionRegisterHandler(XIL_EXCEPTION_ID_IRQ_INT, (Xil_ExceptionHandler)XScuGic_InterruptHandler, &Intc); Xil_ExceptionEnable(); XUartLite_Config *UartConfig = XUartLite_LookupConfig(XPAR_UARTLITE_0_DEVICE_ID); XUartLite_CfgInitialize(&UartInst, UartConfig, UartConfig->RegBaseAddr); XUartLite_ResetFifos(&UartInst); XScuGic_Connect(&Intc, XPAR_FABRIC_UARTLITE_0_INTR_INTR, (Xil_ExceptionHandler)UartIntrHandler, &UartInst); XScuGic_Enable(&Intc, XPAR_FABRIC_UARTLITE_0_INTR_INTR); XUartLite_SetInterruptHandler(&UartInst, UartIntrHandler, &UartInst); XUartLite_EnableInterrupt(&UartInst, XUARTLITE_IXR_RX_FIFO_TRIGGER_MASK); }第三步,在中断处理函数中,一般只需要处理接收中断,发送可以继续用轮询方式。因为发送本来就是主动行为,中断接收反而会加大代码复杂度。
这个过程中有个常见的疑问:到底是进InterruptHandler后只读一个字节还是读完整个FIFO。我的建议是读完整个FIFO,虽然Uartlite的接收触发深度是1字节,但在高波特率下,突发数据很容易连续涌入,中断处理函数里多读几次不会增加多少开销,反而避免反复进出中断的上下文损耗。
3.3 使用SDK驱动需要注意的坑
官方驱动的代码质量没问题,但接口行为和很多人预期不一样。我总结几个长期实践中遇到的规律。
第一个坑:XUartLite_Send是阻塞且可能长时间占用CPU。它本质上是循环写发送FIFO,如果发送FIFO满,它就在软件里等待TX FIFO空标志。如果在中断处理函数里调用发送大块数据,很容易造成中断处理时间过长,严重时触发看门狗。我一般把发送数据都放到主循环或独立任务里做。
第二个坑:默认情况下,Uartlite不把帧错误和校验错误当“异常”处理。状态寄存器里虽然能看到错误标志,但如果没有主动去读中断状态寄存器,错误是静默的。我在实际项目中踩过一次:外部设备波特率从115200变成了9600,导致大量帧错误,但系统没有任何提示,数据全是0x00和0xFF交替出现,排查了很久才发现是外部设备配置变了。
第三个坑:中断触发深度固定为1。也就是说只要FIFO里有一个字节,就会触发接收中断。这本来是好事,但如果对端一次性发几百个字节,CPU会被多次中断占用。应对办法就是上面提到的,收到中断后把FIFO里的数据全部读完,而不是只读一个字节就走。
还有一个和平台相关的点:BSP里的UartLite驱动在Zynq和MicroBlaze上行为略有差异。Zynq上如果同时存在PS自带的UART和PL里的Uartlite,要区分清楚XPAR_UARTLITE_0_DEVICE_ID和XPAR_PS7_UART_0_DEVICE_ID,这两个不是同一个设备。很多人把printf重定向到Uartlite时,用错设备ID,怎么调都不通。
4. 调试实录:乱码、丢字节、中断不进的排查过程
4.1 回环测试为什么是第一步
每次配好一个新的Uartlite实例,我第一件事就是做回环验证,不直接连外部设备。
在软件里,把发送引脚和接收引脚短接,或者在FPGA内部把TX输出接到RX输入,然后用一个已知字符串做发送,判断能不能原样收回来。这里有个细节,如果是FPGA内部回环,要在顶层把uart_tx_out和uart_rx_in直接连线,不经过外部引脚约束,这样能避免杜邦线接触不良或噪声干扰带来的假故障。
回环测试跑通的标志是:发送和接收的字节完全一致,且没有帧错误标志。这一步过了,说明硬件配置、时钟、寄存器地址、软件驱动基础都正确。接下来再接外部设备,如果还有问题,就能把排查范围缩小到电平匹配或对端设备配置上。
我自己的回环测试代码非常简单:
char test_buf[] = "Hello Uartlite \r\n"; char recv_buf[32] = {0}; XUartLite_Send(&UartInst, (u8 *)test_buf, strlen(test_buf)); usleep(10000); int n = XUartLite_Recv(&UartInst, (u8 *)recv_buf, 31); if (n > 0 && memcmp(test_buf, recv_buf, n) == 0) { xil_printf("Loopback test passed.\r\n"); } else { xil_printf("Loopback test failed: received %d bytes.\r\n", n); }这里要注意usleep的时间,要给发送FIFO和接收链路足够的时间。对于115200波特率,1个字节大约86微秒,10毫秒的延时足够覆盖16字节FIFO的收发过程。
4.2 常见故障速查表与寄存器快照技巧
我把这些年遇到的Uartlite故障整理成了一张表,方便大家对照排查。
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 完全无输出 | 发送FIFO未复位或使能位未置1 | 检查控制寄存器复位位,确认使能位已置1 |
| 输出乱码 | 波特率分频不匹配 | 核对Block Design时钟频率和IP核配置 |
| 能收不能发 | 引脚约束错误或发送FIFO一直满 | 检查TX引脚IO标准、电平、位置 |
| 中断不触发 | 中断线未连接或中断ID错误 | 检查IP连接和XScuGic_Connect参数 |
| 间歇性丢字节 | 触发深度过大或轮询不及时 | 改用中断接收,读完整个FIFO |
| 偶发帧错误 | 对端波特率漂移或线路干扰 | 用示波器看波形,测量实际波特率 |
| 接收全FF | RX引脚浮空或对端未使能 | 检查外部设备供电和TX引脚输出 |
排查工具方面,可以借助ILA挂到AXI总线上,看总线的读写时序是否正常。但说实话,如果只是验证Uartlite寄存器读写,用Xil_In32和Xil_Out32直接读写基地址更直观。
我分享一个自己常用的寄存器快照函数:
void uart_dump_regs(UINTPTR base_addr) { u32 sr = Xil_In32(base_addr + 0x08); u32 cr = Xil_In32(base_addr + 0x0C); u32 isr = Xil_In32(base_addr + 0x10); xil_printf("SR=0x%08X, CR=0x%08X, ISR=0x%08X\r\n", sr, cr, isr); // SR bit0: RX FIFO有数据 bit1: RX FIFO满 // SR bit2: TX FIFO空 bit3: TX FIFO满 // SR bit9: 帧错误 bit10: 校验错误 }通过寄存器快照,能快速判断是发送侧阻塞还是接收侧异常,比反复用串口助手试要快得多。比如ISR里出现0x00000100,就代表帧错误标志被置位,这时候不用怀疑代码逻辑,先去查波特率和对端设备配置。
4.3 中断不进的深度排查
中断不触发是大家问得最多的问题,我再单独多写几句。
首先确认硬件连接正确,然后用测试代码检查GIC是否已经工作。可以先注册一个空的定时器中断,跑通再验证Uartlite。这种方法能帮助区分是GIC初始化问题还是Uartlite中断配置问题。
其次,查看XPAR_FABRIC_UARTLITE_0_INTR_INTR这个宏的值,它应该对应你在Vivado里连接的中断ID,通常是61或者62(对应IRQ0或IRQ1)。如果宏的值为-1,说明BSP没有正确识别硬件连接,需要清理BSP重新生成。
最后,确认XUartLite_EnableInterrupt里的中断屏蔽字是否正确。驱动里接收中断对应的掩码是XUARTLITE_IXR_RX_FIFO_TRIGGER_MASK,这个值通常等于0x10。如果你误用了其他宏,中断自然不会触发。
5. 工程化选型与个人心得
5.1 什么时候该换UART16550或上DMA
项目里如果有大量数据要通过串口传输,比如几十KB级别的固件升级包,用16字节FIFO的Uartlite会非常吃力。CPU要不停循环发送,期间无法响应其他任务,很容易触发看门狗。
这种场景下,我通常建议换成AXI UART16550,并配合AXI DMA使用。16550的寄存器接口兼容PC标准,很多上位机软件直接按COM口方式访问,兼容性更好。FIFO深度也可以配置到更大的值,配合中断和DMA,CPU负载能大幅下降。
另外有一个正面的经验是,如果数据量不大,但延迟要求高,Uartlite的轮询模式反而比中断模式更稳定,因为省去了中断上下文切换的时间。中断适合突发和不定长数据,轮询适合固定周期小数据量。这个选择要和项目需求挂钩。
5.2 个人积累的几个使用心得
最后分享几个长期实践中总结的小技巧。
第一个,BSP里默认的stdout打印如果接在Uartlite上,printf会变得非常慢。原因很简单,库函数对Uartlite发送是逐字节等待,如果波特率是9600,每秒才约960字节,调试打印会拖慢整个系统。我一般把主调试打印放在PS自带的UART上,Uartlite只用于特定外设通信。
第二个,多核平台要注意中断亲核性。Zynq如果跑双核AMP,Uartlite中断默认是CPU0的GIC,如果CPU1的任务想用这个串口,需要处理跨核中断或共享内存,否则会很难调试。
第三个,也是我最想强调的一点:文档要细读。Xilinx给AXI Uartlite配套的PG142文档虽然页数不多,但寄存器表是最权威的参考。很多人遇到问题时喜欢网上搜,但网上的结论很多是别人项目里的特例,反而容易带偏方向。遇到Uartlite行为不对,先打开PG142,把状态寄存器和控制寄存器的每个位对照一遍,80%的问题都能自己定位。
第四个,关于工程目录管理,一个常用实践是把Vivado工程和SDK工程分开放,SDK工程放到独立目录,这样Vivado升级后SDK工程还可以复用。如果遇到Vivado工程无法生成比特流,优先检查是否有IP核没有升级到当前版本,AXI Uartlite这种老牌IP在版本升级时经常会提示需要升级ip的版本。
我在很多项目里的体会是,AXI Uartlite不是一个需要研究很深的技术点,但它系统里最基础、最不可缺的一环。把这个IP用好,能省下大量调试时间。如果你正准备在新的FPGA项目里加入串口通信,建议直接从这篇文章里的步骤开始做,先跑通回环,再扩展中断、接外部设备,然后再根据具体需求调整。串口这东西,配置一次、调通一次,后面就是十几年都不会忘的技能。