1. 先搞清楚 Uartlite 是个什么东西
早些年我第一次在 Vivado 里看到 AXI Uartlite 这个 IP 核时,心里其实挺不屑的——串口嘛,自己写个收发模块不就完事了,何必绕一圈去挂 AXI 总线?后来被项目时间逼着用了一回,才发现这东西在 Zynq 和 FPGA 混合系统里简直就是“基础设施”级别的存在。现在凡是遇到需要调试、日志输出、参数配置的场合,我第一反应就是把它挂上。
AXI Uartlite 是 Xilinx 提供的一个轻量级串口控制器 IP 核,对外接口是 AXI4-Lite,对内部逻辑则通过简单的发送 FIFO、接收 FIFO 和中断引脚来交互。和完整版的 AXI UART16550 相比,它去掉了 modem 控制信号、FIFO 深度也更浅(默认 16 字节),但好处是逻辑极其简单、资源占用小、时序收敛容易,非常适合做调试串口、低速通信、配置通道这类工作。
它解决的核心问题其实有两个:第一,省去了自己写 UART 收发状态机的功夫,也避免了不同工程师写的串口模块风格各异、复用困难的问题;第二,在 PS 和 PL 协同工作的系统里,它天然就是 AXI 总线上的一个从设备,ARM 端直接操作寄存器就能收发数据,不用在 PL 里再堆一组 GPIO 模拟时序的逻辑。
这套路适合谁?适合用 Zynq、Zynq UltraScale+ 做嵌入式开发的人,也适合纯 FPGA 工程里想快速跑通串口通信、把精力放在业务逻辑上的人。哪怕是刚开始接触 Vivado 的新手,照着这篇把模块搭起来,再配合 ILA 看一眼波形,对 AXI 总线和串口协议的理解都会上一个台阶。
2. Vivado 里的关键配置,选错一个参数都要返工
2.1 创建 IP 和基础参数设置
打开 Vivado,在 IP Catalog 里搜索 Uartlite,双击 AXI Uartlite 进入配置界面。第一个要决定的是Board Interface,如果工程是基于官方开发板创建的,比如 ZedBoard、Ultra96 这类,Vivado 会自动关联板级接口,串口的管脚约束都帮你填好了。如果是自己画的板子,这里选 Manual 就行,后面到约束文件里自己写管脚。
接下来是UART 协议参数,这部分看似简单,但实际工程里最容易翻车:
- Baud Rate:默认 9600,很多新手不留意就直接用了,结果上位机那边忘记改波特率,调了半天发现全是乱码。我一般习惯设成 115200,这是调试工具默认支持最好的速率之一,也有设 921600 的,但前提是时钟精度够,否则误码率会明显上升。
- Data Bits:默认 8,没有特殊需求不要改。
- Parity:通常选 No Parity。选了奇偶校验之后,发送和接收的数据格式都得对应,而且一旦出现校验错误,接收 FIFO 的数据是否继续保留、中断如何触发,不同版本的 IP 行为还有细微差别,没必要给自己添堵。
- Stop Bits:默认 1 位停位,够用。
还有一个容易被忽略但很关键的选项:Frequency (MHz)。这个参数填的是 Uartlite 模块所挂时钟的实际频率,IP 内部靠它计算波特率分频系数。如果填错了,后果就是实际波特率偏离设定值,低速时可能还能勉强通信,速率一高就是乱码。我见过有人把 100MHz 的时钟填成 50MHz,表面上 Vivado 不会报错,但逻辑仿真和上板测都会出问题。
2.2 FIFO 深度和中断配置
Uartlite 的 FIFO 深度默认是 16 字节,这意味着接收端如果长时间不读数据,超过 16 字节之后新来的数据就会被丢弃。有些应用里数据是突发到达的,比如一帧几百字节从传感器端发过来,接收端 CPU 被其他中断占着没法及时处理,这种情况下 16 字节的 FIFO 就显得很紧张。
IP 配置界面里有 Receive FIFO depth 和 Transmit FIFO depth 两个参数可选,范围是 16 到 64KB,但没有必要追求大。FIFO 太大有两个问题:一是 BRAM 资源消耗增加,二是接收数据的实时性反而变差——数据在 FIFO 里待的时间长了,控制回路响应不过来。我看到的大部分实际项目里,要么用默认 16,要么升级到 64,够用就好。
中断相关参数里,_Enable Interrupts打开之后,IP 会输出一个 uart_interrupt 信号,接收 FIFO 非空、发送 FIFO 空、接收超时、溢出错误都会触发中断。这里的“接收超时”是指接收 FIFO 非空但一段时间内没有新数据到达,通常用来判断一帧数据接收完毕,很适合包协议的场景。
注意:Uartlite 的中断输出是一个带极性选择的单信号,不像 UART16550 那样有多个独立中断源。在驱动里必须读中断状态寄存器来区分具体是哪种事件触发了中断。
2.3 时钟和复位域的坑
Uartlite 模块只有一个时钟输入 s_axi_aclk,发送和接收逻辑都跑在这个时钟上,所以不存在跨时钟域的问题。但这并不意味着随便接一个时钟就行。
第一,这个频率就是前面说的 Frequency 参数填的值,两者必须一致。第二,如果系统里用 MMCM 或 PLL 产生了多个时钟,Uartlite 的时钟和其他 AXI 外设的时钟最好来自同一个 MMCM,避免异步时钟域之间做 AXI 跨时钟桥接,虽然 Vivado 会自动插入同步逻辑,但 Protocal 错误的风险会增加。
复位信号 s_axi_aresetn 是低有效,这点要特别提醒。有人习惯用高有效的复位,接到这里忘了取反,结果 IP 一直处于复位状态,读写寄存器全部返回 0,现象非常隐蔽。另外复位信号的释放需要和时钟同步,如果你的系统复位是异步复位同步释放的,那没问题;如果是纯异步复位直接撤掉,有概率导致 AXI 从机内部状态机跑飞,实测中这种问题偶发且极难排查。
3. 硬件连接与地址分配,Block Design 里的三件事
3.1 连接 AXI 和中断引脚
在 Block Design 里使用 Uartlite 很简单,从 IP Catalog 拖进来,然后做三件事:
第一,把 S_AXI 接口连到 Zynq 的 M_AXI_GP 端口,或者连到 AXI Interconnect 的从机接口上。PS 访问 PL 外设一般走 M_AXI_GP,带宽不高,但连串口绰绰有余。如果纯 PL 工程里要用微处理器软核,比如 MicroBlaze,那就连到 MicroBlaze 的 M_AXI_DC 或 M_AXI_IC 上,或者走 AXI Interconnect 统一管理。
第二,把 s_axi_aclk 和 s_axi_aresetn 接到系统时钟和复位的网络上。这里有个细节:如果 Uartlite 挂在 AXI Interconnect 的某个时钟域下,它的时钟必须和 Interconnect 的时钟一致,否则 AXI 协议握手时序会乱。
第三,把 interrupt 引脚接到 PS 的 IRQ 控制器上。Zynq-7000 的 PL 中断通过 IRQ_F2P 端口接入,在 Block Design 里会自动生成,只需要把 Uartlite 的中断输出连到这个端口上就可以,PS 端的中断号在 xparameters.h 里会对应到延展 ID。注意多个外设共用 IRQ_F2P 时,需要接一个 concat IP 将多路中断汇聚成一路。
3.2 地址分配和地址冲突
给 IP 分配地址是 Block Design 里必不可少的一步。右键点击 Uartlite 的地址段,选择 Assign Address,Vivado 会自动在 AXI 主设备的地址空间里给它划一块区域。我习惯在自动分配后手动改成规整的地址,比如 0x42C00000,方便记忆,也方便在驱动代码里直接硬编码。
实际项目里还遇到过一个问题:Block Design 里生成了两个一模一样的 Uartlite 实例,一个用于调试、一个用于通信,地址分配时一个设成 0x42C00000,另一个设成 0x42C10000,看起来没什么问题,但地址总线宽度默认只有 12 位,也就是 4KB 空间。如果地址跨越了 4KB 边界,访问第二个外设时地址会被 AXI 解码截断,导致读写行为异常。这个坑出现的概率不高,但一旦出现,排查时往往以为是自己驱动的问题,实际上就是地址段没对齐。
3.3 管脚约束的常见错误
Block Design 生成好之后,Uartlite 的 UART 引脚(uart_txd、uart_rxd)需要手动约束到 FPGA 的物理管脚上。这一步本身不复杂,但经常有人遇到 Vivado 综合时报错“IO placement failed to meet constraints”,其实就是管脚约束和 Bank 电压不匹配,或者管脚被其他外设占用了。
另外需要注意电平标准。开发板上串口芯片一般是 3.3V 电平,约束文件里通常会写成 LVCMOS33。如果板子的串口引脚在 HR Bank,可以用 LVCMOS33;如果在 HP Bank,可能只能用 LVCMOS18 或其他电平标准,这取决于板卡设计。最靠谱的方法是直接抄开发板提供的 XDC 文件,而不是自己凭经验猜。
还有一个容易忽略的点:Uartlite 的 uart_txd 是从 FPGA 输出到外部串口芯片的发送数据线,uart_rxd 是接收外部串口芯片送过来的数据。有些板子的丝印上写着 RX、TX,对应的方向容易搞反,接反之后的现象是:“能发不能收”或者“能收不能发”,上位机里也能看到数据,但内容永远不对。我每次布完板或者接完调试线,第一件事就是用串口助手发个 0x55(二进制 01010101),看回显和波形是否正常。
4. 软件驱动:从轮询到中断,两条路怎么选
4.1 裸机环境下访问 Uartlite 寄存器
Uartlite 的寄存器映射非常简单,一共几个 32 位寄存器,访问地址基地址加上偏移即可。核心的两个是发送保持寄存器(偏移 0x00)和接收缓冲寄存器(偏移 0x04),写发送寄存器启动发送,读接收寄存器取回数据。
然后是状态寄存器(偏移 0x0C),几个关键位分别是:发送 FIFO 满、发送 FIFO 空、接收 FIFO 空、接收 FIFO 有效数据、溢出错误、帧错误、奇偶校验错误。接收数据的典型流程是先读状态寄存器,确认“接收 FIFO 非空”,再读接收缓冲寄存器,避免读到空数据。
用轮询方式发送一串数据时,标准写法是:
while (*pReg != 0) // 伪代码更严谨的裸机写法其实是:
#define UARTLITE_HRR 0x00 #define UARTLITE_RBR 0x04 #define UARTLITE_SR 0x0C void uart_send_byte(uint32_t base, uint8_t data) { volatile uint32_t *sr = (volatile uint32_t *)(base + UARTLITE_SR); volatile uint32_t *hrr = (volatile uint32_t *)(base + UARTLITE_HRR); while ((*sr & 0x08) != 0) // 发送 FIFO 满则等待 ; *hrr = data; } uint8_t uart_recv_byte(uint32_t base) { volatile uint32_t *sr = (volatile uint32_t *)(base + UARTLITE_SR); volatile uint32_t *rbr = (volatile uint32_t *)(base + UARTLITE_RBR); while ((*sr & 0x01) != 0x01) // 接收 FIFO 空则等待 ; return (uint8_t)(*rbr); }状态寄存器需要先读后写,写 1 清除错误标志位。但这里有个细节:状态寄存器的 FIFO 状态位是“读时清零、写时更新”的,也就是说,如果你在读状态寄存器之后立刻写状态寄存器来清错误标志,那么新写入的 1 会把当时 FIFO 的状态覆盖掉。正确做法是先清错误,再读数据。
4.2 Xilinx 官方库函数:XUartLite 的用法
如果用 SDK 或 Vitis 开发,不用自己操作寄存器,直接用 Xilinx 提供的驱动库函数就行。初始化流程是:
XUartLite UartLiteInstance; XUartLite_Config *UartLiteConfig; UartLiteConfig = XUartLite_LookupConfig(XPAR_AXI_UARTLITE_0_DEVICE_ID); XUartLite_CfgInitialize(&UartLiteInstance, UartLiteConfig, UartLiteConfig->RegBaseAddr);初始化完成后,发送数据用XUartLite_Send,接收数据用XUartLite_Recv。这两个函数都支持指定数据长度,但实际发送时不会保证一次性发完所有字节,需要检查返回值,确认实际发送了多少,剩余部分要手动补发。
如果系统里同时有多个 Uartlite,比如一个调试串口、一个通信串口,查找配置时一定要确认 Device ID 和实际寄存器基地址匹配。我在 Vitis 里遇到过 Device ID 和 RegBaseAddr 对不上的情况,原因是生成的 xparameters.h 是旧工程的,没有被正确更新。解决方法是右键 BSP 工程,选择 Regenerate BSP Sources,然后再重新编译。
4.3 中断接收:防止丢数据的正确姿势
轮询接收的缺点是 CPU 被完全占用,而且如果串口数据到达时 CPU 正在执行其他任务,数据会被 FIFO 溢出而丢掉。所以实际项目里,只要数据不是几字节、而是持续流量,我都建议用中断方式。
中断接收的流程分四步:
第一,在初始化阶段注册接收回调函数:
XUartLite_SetRecvHandler(&UartLiteInstance, UartLiteRecvHandler, &UartLiteInstance);第二,前使能接收中断:
XUartLite_EnableInterrupt(&UartLiteInstance);第三,在回调函数里处理数据。注意回调运行在中断上下文,绝对不能在里面做耗时操作,比如 printf 打印、延时、复杂协议处理。正确做法是把数据拷贝到环形缓冲区,然后置一个标志位,主循环检测到标志位后再去解析。
void UartLiteRecvHandler(void *CallBackRef, unsigned int EventData) { XUartLite *InstancePtr = (XUartLite *)CallBackRef; uint8_t byte; while (XUartLite_IsReceiveData(InstancePtr->RegBaseAddress)) { byte = XUartLite_ReadReg(InstancePtr->RegBaseAddress, XUL_RBR_OFFSET); ring_buffer_push(&rx_ring, byte); } }第四,发送时如果需要等发送完成,可以注册发送回调,或者直接在发送循环里检查XUartLite_IsTransmitFull。实测下来,发送 FIFO 一旦满了之后继续写,数据会直接丢掉,并且没有错误标志,所以发送侧必须做好流控。
提示:Uartlite 的接收触发方式默认是“接收 FIFO 非空就触发”,所以回调里要循环读,直到 FIFO 被读空。如果只读一个字节就退出,很快 FIFO 就会溢出,然后触发溢出错误中断。
4.4 轮询和中断的选择标准
这两种方式没有绝对优劣,按场景选:
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 系统启动时打印日志 | 轮询 | 此时中断系统还没完全初始化,轮询最可靠 |
| 短帧、低频率上报 | 轮询 | 简单,不占中断资源 |
| 持续数据流、大文件传输 | 中断 | 防止 CPU 被占死,降低丢数率 |
| 多外设并发 | 中断 | 避免串口长时间占用 CPU |
5. 数据通路与调试手段:信号正确性如何保证
5.1 环路测试的方法
IP 配置完、上板能收发之后,第一件事是做一个环路测试。所谓环路测试,就是让 Uartlite 自己把发送的数据通过外部短路回收到接收端,验证链路是否完整。
具体做法:在上位机串口助手里开启“发送新行”,向板子发送一串测试数据。板子端的裸机程序里,将接收到的数据原封不动通过同一个 Uartlite 发送回上位机。如果上位机能收到发送内容的完整回显,说明 UART 物理链路、IP 配置、驱动收发都正常。
如果回显内容有错位、多字节、丢字节,就要用 ILA 抓到 IP 内部的 AXI 信号,检查状态机跳转和握手时序。开 ILA 时采样深度不用很大,1024 就够;触发条件可以设置为发送 FIFO 状态变化,或者 AXI 写通道上的 WVALID 拉高。
5.2 波特率误差的计算与验证
波特率误差是串口通信最隐蔽的坑。如果 IP 配置的时钟频率和实际运行频率不一致,波特率会偏离标准值。以 115200 波特率为例,IP 内部会计算分频系数,如果时钟源有 100ppm 的偏差,长时间收发后数据就会出错。
实际验证波特率的方式有两种。一种是逻辑分析仪或示波器直接看 TX 引脚波形,测量单个 bit 的宽度,算出实际波特率。另一种是用 ILA 观察接收端采样窗口,看采样点是否落在 bit 中心附近。如果采样点在 bit 边界附近跳动,说明波特率偏差大,需要调整时钟或波特率值。
我在调试时遇到过一种情况:从 PLL 输出给串口的时钟是 96MHz,但配置界面的频率写的却是 100MHz,结果串口通信时而正常、时而乱码。当时还以为是上位机问题,用示波器一测才发现实际 bit 宽度比标准值宽了 4% 左右。所以说,配置界面里 Frequency 的参数值,必须和最终进入 IP 的时钟频率完全一致。
5.3 AXI-Lite 读写的验证思路
如果串口没有数据,惯性的怀疑是引脚接错、波特率不对,但还有一个可能:AXI 总线本身有问题。判断的方法是直接在 SDK 里写一段简单的寄存器读写测试代码,往发送寄存器写一个字节,然后读状态寄存器,看发送 FIFO 空标志是否发生变化。
如果写寄存器没有反应、状态寄存器读出来全是零,问题大概率在上游 AXI 路径上。此时看这几个地方:Block Design 里 Interconnect 的时钟和复位是否连接正确;PS 端 M_AXI_GP 的地址空间映射是否覆盖了 Uartlite 的基地址;地址线位宽是否足够。
这些都是我在实际项目里排查过的问题,任何一个隐蔽的接线问题,最终都需要靠“从寄存器层面逐级往上查”的方式定位,千万不要直接跳过 AXI 验证去怀疑物理层。
6. Linux 环境下使用 Uartlite 的补充
6.1 设备树节点
在嵌入式 Linux 系统里使用 Uartlite,需要在设备树里增加节点。以常见的基于 device tree 启动的内核为例,节点写法如下:
axi_uartlite_0: serial@42C00000 { compatible = "xlnx,xps-uartlite-1.00.a"; reg = <0x42C00000 0x10000>; interrupts = <0 29 4>; // 中断号需根据实际连接修改 interrupt-parent = <&intc>; clock-frequency = <100000000>; };注意 compatible 字符串在不同版本内核里有差异,老内核是 xlnx,opb-uartlite-1.00.b,新内核是 xlnx,xps-uartlite-1.00.a。如果 compatible 不匹配,内核驱动不会绑定这个设备,表现为 /dev/ttyUL0 设备节点不存在。
6.2 内核配置与终端获取
需要在 kernel config 里打开 UARTLITE 驱动支持,一般是CONFIG_SERIAL_UARTLITE=y。打开之后,启动过程中会有多个串口设备,需要确认哪个是 Uartlite 对应的 ttyUL 节点。
如果要把这个串口作为默认控制台,在 bootargs 里指定:
console=ttyUL0,115200这里有个容易忽略的点:ttyUL 的波特率初始值由设备树里的 clock-frequency 和波特率共同决定。设备树里的 clock-frequency 必须和 Uartlite IP 配置界面里填的值一致,否则内核驱动的波特率计算会出错,开机后控制台乱码。
7. 常见问题与排查技巧实录
7.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 完全无数据,TX 引脚无波形 | 引脚约束错误、时钟未接、复位一直有效 | 检查 XDC、检查时钟复位连接 |
| 数据乱码 | 波特率不匹配、Frequency 配置错误 | 示波器测 bit 宽度,核对时钟频率 |
| 只能发不能收 | RX 引脚约束错误、外部芯片方向接反 | 检查引脚方向,检查电路图 |
| 接收丢字节 | FIFO 溢出、中断处理不及时 | 增大 FIFO 深度、优化中断处理 |
| 写寄存器无反应 | AXI 地址映射错误、IP 未初始化 | 检查地址分配、检查寄存器偏移 |
| 发送时数据卡死 | 发送 FIFO 满时未做等待、状态寄存器误读 | 检查发送循环逻辑 |
7.2 ILA 抓不到信号?先检查这几个地方
用 ILA 调试 Uartlite 时,如果始终抓不到期望的信号,先检查三件事:
第一,ILA 的采样时钟是不是和 Uartlite 连的是同一个时钟。如果 ILA 用了另一个时钟域,采样窗口可能完全错开信号的变化。
第二,触发条件的设置是否合理。比如要抓接收数据,就可以用接收 FIFO 非空作为触发条件,确保触发时刻数据已经到达。
第三,ILA 在综合时是否会插入到目标信号路径上。某些情况下,如果信号被优化掉了,比如寄存器输出直接接到引脚、中间没有组合逻辑,ILA 采样点选不到正确的寄存器,就看不到变化。这时候可以在信号的 RTL 代码里加(* keep = "true" *)属性,避免综合工具把它优化掉。
7.3 乱码问题的高级排查思路
乱码问题除了波特率不匹配,还有几个容易被忽略的原因。
第一个是数据位校验位格式不一致。上位机配置为 8N1,但 IP 里被改成 7E1,两边看到的每一位含义完全不同,乱码也就顺理成章了。所以排查乱码时,不要只盯着波特率,把数据格式也核对一遍。
第二个是接地问题。如果 FPGA 板子和上位机的 USB 转串口模块共地不良,逻辑电平判断会不稳定,低速可能没事,高速就会出现随机错位。解决方法是检查共地,或者换一个隔离型 USB 转串口模块。
第三个是电平标准不匹配。FPGA 引脚输出 3.3V,但外部串口芯片是 1.8V 电平,这时候寄存器配置可能都能工作正常,但物理层信号会处于非标准电平区域,导致误码。这类问题示波器看波形最直观——看高电平是否接近预期电压。
7.4 一个困扰我两天的实战案例
最后分享一个真实的排查经历。某个项目里需要 FPGA 在启动时通过 Uartlite 上报版本信息,上位机偶尔能收到,偶尔收不到,完全没有规律。
一开始怀疑是复位时序问题,把复位时间延长后,现象依然存在。然后用 ILA 抓 AXI 写通道,发现写发送寄存器的 WVALID 和 WREADY 都正常,数据也写进去了,但发送 FIFO 空标志一直不拉低。再往深处查,发现 Uartlite 的时钟和 AXI 互连的时钟不是同一个 MMCM 输出的,两者之间有固定相位偏移,导致 IP 内部状态机有时能正常处理 AXI 写请求,有时在跨时钟域握手时采样失败。
最终把时钟统一到同一个 MMCM 输出,问题彻底消失。这个案例再次说明,Uartlite 虽然本身很简单,但它挂在 AXI 总线上,整个链路里任何一个环节的时序问题,最后都可能表现为“串口数据偶尔丢字节”这种看似无关的现象。
8. 结尾:一点个人的操作体会
用过 Uartlite 几年下来,我觉得它最大的价值不是省去了写串口模块的时间,而是提供了一个稳定的、可预测的通信入口,让调试工作从“先确保串口能通”直接跳到“专注业务逻辑”。但如果配置不仔细,这几个参数——时钟频率、FIFO 深度、地址映射——任何一项没有对齐,排查起来都要花掉比写代码多得多的时间。
最后再分享一个小技巧:在工程初期,哪怕只有一块裸板、上面只挂了 Uartlite 一个外设,也要把它的 AXI 接口当作核心逻辑来认真设计,不要因为它简单就随意连接。端口规范、时钟干净、复位同步、地址固定,这几条做到位,后续项目哪怕是换到 UltraScale+ 或者 Versal 平台,这套经验可以直接平移,几乎不需要重新摸索。