简介:一份基于Zynq-7010 SoC的UART串口通信完整工程,面向嵌入式与FPGA开发者,演示在Vivado 2017.1环境下实现PS端与PL端协同的中断式UART收发。压缩包内共1146个文件,以260个h头文件和196个c源文件为主,配合84个verilog、9个vhd硬件描述文件与7个xdc约束文件,另有14个tcl脚本、bit比特流和elf可执行文件,以及log/rpt/html等仿真报告,整体大小约21.08MB。已有3039人学习下载。工程对应教程章节CH09_Interrupt_Uart,完整覆盖UART中断处理链路:包括硬件接口配置、波特率发生器、收发FIFO、中断服务例程与裸机驱动API,并附有Vivado工程历史记录(jou)、硬件定义(hdf)和链接脚本(ld)等,便于直接重建工程。建议结合Zynq开发板操作,可快速掌握PL逻辑与PS程序协同调试、中断优先级设置、串口回环测试等实用技能,为后续复杂SOC设计打基础。
1. ZYNQ7010的UART串口程序,最容易翻车的不是UART本身
ZYNQ7010的UART串口程序,很多人第一步就把方向带偏了:拿到板子先打开一个串口调试助手,然后照着单片机思路初始化寄存器、点灯验证,结果不是乱码就是不收发。原因很简单,Zynq-7010的串口不是一个独立外设,而是挂在ARM Cortex-A9的APB总线上、由PS侧UART控制器管理的外设,它的时钟、引脚、中断资源要先由硬件工程(Block Design)定义好,软件程序才有东西可初始化。这篇文章面向正在调7010开发板的工程师,帮你从硬件工程、设备树、裸机驱动到中断收发一次打通,并给出几个我实际踩过的坑。核心观点是:串口本身不难,难的是把PS时钟、MIO引脚、GIC中断和串口工具看成一个闭环。
2. 先分清PS串口与PL串口:ZYNQ7010的UART资源与选择逻辑
2.1 两个UART硬核控制器,以及MIO/EMIO的路径差异
Zynq-7010的PS侧集成了两个UART控制器,命名上一般叫UART0和UART1。这两个控制器是PS里的硬核,不消耗PL的逻辑资源,对外信号可以从两条路引出:一条是MIO,即PS专用引脚,不经过PL直接输出到芯片封装上;另一条是EMIO,信号从PS逻辑引到PL内部,再由PL的引脚约束输出。选MIO的好处是路径确定、时序简单、不占用PL的布线资源,裸机或Linux下用起来最省心。选EMIO的场景通常是MIO引脚已分配完,或者你希望能把UART信号在PL内部做点处理后再引出。
MIO和EMIO的差异,实际工程里往往决定了一个方案能不能快速跑通,我做了个对比表:
| 对比项 | MIO路径 | EMIO路径 |
|---|---|---|
| 是否占用PL资源 | 不占用 | 占用PL引脚与内部连接 |
| 引脚位置 | PS专用MIO引脚,硬件固定 | PL任意可用引脚,需在约束里指定 |
| 时钟来源 | PS侧UART参考时钟 | 同一UART控制器,时钟仍来自PS |
| 配置复杂度 | Block Design里直接指定引脚编号 | 还需创建顶层端口并添加XDC约束 |
| 适用场景 | 控制台串口、调试串口 | MIO被占用、串口不够用需要扩展 |
换句话说,同一个PS UART控制器,用MIO还是EMIO,决定的是“信号怎么走”,不是“控制器怎么配”。开发软件程序时看到的寄存器基址和中断号完全相同,这也是很多人后来才发现两个路径“则是同一套驱动”的原因。
2.2 到底用哪个UART:选型判断与三个常见误区
我选择UART通道时一般按三步判断。第一步,翻开开发板原理图,看板子上哪个串口接到了USB转串口芯片,哪个串口接到了排针或蓝牙模块;这一步能确定硬件上已经可用的通道。第二步,看运行环境:如果跑Linux,优先用接控制台的PS UART,否则内核日志和你的应用会争抢同一个设备节点。第三步,如果通道不够用,再考虑PL侧的UART Lite IP核,但这时软件要换成AXI总线寄存器操作,复杂度立刻上升。
这三个误区我见得太多了。第一个误区是“UART必须写Verilog”,其实Zynq-7010的PS侧自带硬核UART,产品化项目里绝大多数串口需求靠它就够了。第二个误区是给PS的UART外接UART Lite IP,两者挂在不同的总线上,地址空间不同,混用只会让驱动代码变得不伦不类。第三个误区是把MIO当成万能引脚,同一组MIO可能被SDIO、GPIO、UART复用,硬件设计时没有查复用表,等软件调不通再去对原理图,血泪经验。
3. 用Vivado搭出UART最小硬件工程:Block Design配置与导出细节
3.1 Block Design里勾选UART外设:最小Tcl和GUI对照表
我用Vivado搭过太多次Zynq-7010的最小系统,串口这一块的配置说到底是几个开关。常见做法是新建工程后,在IP Catalog里添加ZYNQ7 Processing System(PS7)IP,然后双击打开Re-customize IP页面。整个过程如果用Tcl脚本写,可以这样:
create_project uart_demo ./uart_demo -part xc7z010clg400-1 create_bd_design system create_bd_cell -type ip -name ps7 xilinx.com:ip:processing_system7:5.5 apply_bd_automation -rule xilinx.com:bd_rule:processing_system7 \ -config {make_external "FIXED_IO"} [get_bd_cells ps7] set_property -dict [list \ CONFIG.PSU_UART1_PERIPHERAL_ENABLE {1} \ CONFIG.PSU_UART0_PERIPHERAL_ENABLE {0} \ CONFIG.PSU_UART1_PERIPHERAL_IO {MIO 48 .. 49} \ CONFIG.PSU_UART1_BAUD_RATE {115200} \ ] [get_bd_cells ps7] validate_bd_design save_bd_design这段脚本里最需要注意的配置项有三个。PSU_UART1_PERIPHERAL_ENABLE决定UART1控制器是否使能;PSU_UART1_PERIPHERAL_IO里面的MIO编号必须和硬件原理图一致,我遇到过板子上串口实际接的是MIO 24 .. 25,却按默认MIO 48 .. 49去配置,结果引脚冲突直接报错;PSU_UART1_BAUD_RATE是初始化波特率,注意这里只影响硬件默认值,后面程序里还可以用驱动接口重设。不同IP版本的配置参数名可能有细微差异,如果Tcl报“property not found”,去GUI里手动勾选对应项即可,逻辑完全对应。
这段配置完成后,还要确认PS侧的UART参考时钟。在PS-PL Configuration里能看到UART时钟源,通常是从PS_CLK分频得来的,默认值一般是50MHz或33.333MHz,取决于开发板晶振。这个时钟频率值要和后面程序里期望的一致,否则就是第五章要讲的乱码源头之一。
3.2 生成比特流、导出硬件与导入SDK的固定顺序
硬件工程配置完成只是第一步,关键在导出硬件描述文件(XSA)的顺序,顺序错一步,后面SDK里看到的寄存器地址和中断号就不对。我习惯的固定流程是:先Validate Block Design,再Generate Output Products,然后综合跑实现,生成比特流,最后导出硬件时勾选“Include bitstream”。
导出时的选项对调试影响很大,我整理过一张对照要点:
| 导出选项 | 勾选与否的影响 | 说明 |
|---|---|---|
| Include bitstream | 不勾则后续无法下载FPGA配置 | 即使不跑PL逻辑,也建议勾上 |
| Include generic peripheral | 一般保持默认 | 影响BSP中外设配置的生成 |
| 硬件描述文件格式 | 选XSA | 老版本SDK用HDF,新版本统一用XSA |
拿到XSA后,启动SDK/Vitis,在新建Platform时选择这个XSA文件。这里有个很常见的翻车点:用户用新版本Vivado默认生成了新的XSA格式,却拿老版本SDK去打开;或者反过来,老版本工程导出的HDF硬塞给新版本工具。我的建议是,整个工程链路的Vivado、SDK/Vitis、BSP版本尽量同一条大版本线,避免把时间浪费在“为什么Platform创建失败”这种和串口无关的问题上。
4. 写PS端UART程序:从轮询到中断的代码与参数怎么调
4.1 轮询模式:能跑通收发的最小C程序
拿到SDK生成的BSP后,最简单的方式是用官方驱动库XUartPs。轮询模式适合先验证通路,代码量最小,逻辑也直白。我一般先写一个这种最小程序,确认串口在硬件和驱动层面是通的,再往上加中断。
#include "xuartps.h" #include "xparameters.h" #define UART_DEVICE_ID XPAR_XUARTPS_0_DEVICE_ID XUartPs UartInst; void uart_send_string(char *str) { while (*str != '\0') { /* 逐字节发送,仅用于最小验证,大批量数据请改成批量发送 */ XUartPs_Send(&UartInst, (u8 *)str, 1); str++; } } int main(void) { XUartPs_Config *cfg; u8 recv; cfg = XUartPs_LookupConfig(UART_DEVICE_ID); if (cfg == NULL) { return 1; } XUartPs_CfgInitialize(&UartInst, cfg, cfg->BaseAddress); XUartPs_SetBaudRate(&UartInst, 115200); uart_send_string("ZYNQ7010 UART polling ok\r\n"); while (1) { /* 等待接收FIFO中有数据,然后读出一个字节回显 */ while (!XUartPs_IsReceiveDataReady(&UartInst)) { } if (XUartPs_Recv(&UartInst, &recv, 1) == 1) { XUartPs_Send(&UartInst, &recv, 1); } } return 0; }这段代码里有几个函数的行为要说明清楚。XUartPs_LookupConfig按Device ID查找配置结构体,配置内容由BSP根据XSA自动生成;XUartPs_CfgInitialize完成寄存器基址映射和硬件复位;XUartPs_SetBaudRate会在驱动内部计算分频系数并写入波特率寄存器。XUartPs_Recv是非阻塞的,它只读走当前FIFO里已有的字节,没有数据时立刻返回0,所以轮询接收用while循环配合IsReceiveDataReady是标准写法。这个程序的局限也很明显:主循环被接收等待占死,没法干别的事,所以它只适合验证通路或喂个调试命令解析。
4.2 中断接收:挂到GIC上,用环形缓冲接住每个字节
真实项目里轮询模式基本撑不住,因为主程序还要处理协议逻辑和看门狗。中断接收是Zynq-7010上最常见的做法,但它的初始化链路比单片机长很多:要先初始化GIC(通用中断控制器),再注册UART中断服务函数,最后才使能UART本身的中断屏蔽位。
#include "xuartps.h" #include "xscugic.h" #include "xil_exception.h" #include "xparameters.h" #define UART_DEVICE_ID XPAR_XUARTPS_0_DEVICE_ID #define UART_INTR_ID XPAR_XUARTPS_0_INTR XUartPs UartInst; XScuGic IntcInst; static u8 rx_ring[256]; static volatile u16 rx_widx = 0; static volatile u16 rx_ridx = 0; static void uart_isr(void *CallbackRef) { XUartPs *UartInstPtr = (XUartPs *)CallbackRef; u32 status = XUartPs_GetInterruptStatus(UartInstPtr); /* 读状态寄存器后立刻清除,避免重复进入中断 */ XUartPs_ClearInterruptStatus(UartInstPtr, status); while (XUartPs_IsReceiveDataReady(UartInstPtr)) { u8 ch; int cnt = XUartPs_Recv(UartInstPtr, &ch, 1); if (cnt == 1) { u16 next = (u16)((rx_widx + 1) % sizeof(rx_ring)); if (next != rx_ridx) { rx_ring[rx_widx] = ch; rx_widx = next; } } } } int main(void) { XUartPs_Config *uart_cfg; XScuGic_Config *gic_cfg; uart_cfg = XUartPs_LookupConfig(UART_DEVICE_ID); XUartPs_CfgInitialize(&UartInst, uart_cfg, uart_cfg->BaseAddress); XUartPs_SetBaudRate(&UartInst, 115200); gic_cfg = XScuGic_LookupConfig(XPAR_SCUGIC_SINGLE_DEVICE_ID); XScuGic_CfgInitialize(&IntcInst, gic_cfg, gic_cfg->CpuId); Xil_ExceptionInit(); Xil_ExceptionRegisterHandler(XIL_EXCEPTION_ID_IRQ_INT, (Xil_ExceptionHandler)XScuGic_InterruptHandler, &IntcInst); Xil_ExceptionEnable(); XScuGic_Connect(&IntcInst, UART_INTR_ID, (Xil_ExceptionHandler)uart_isr, &UartInst); XScuGic_Enable(&IntcInst, UART_INTR_ID); /* 使能接收FIFO非空中断和接收覆盖中断 */ XUartPs_SetInterruptMask(&UartInst, XUARTPS_IXR_RXOVR_MASK | XUARTPS_IXR_RXFIFO_MASK); while (1) { /* 主循环解析环形缓冲中的数据,或处理其他任务 */ } return 0; }这段代码里GIC初始化的顺序是不能颠倒的。必须先查GIC配置、完成初始化,再注册ARM异常向量表并打开全局中断,最后才Connect UART中断。很多人中断不触发,问题不在UART,而是Xil_ExceptionEnable没调用,或者注册UART中断之前就把中断屏蔽位打开了。环形缓冲的写法用了next != rx_ridx判断满,防止覆盖未读数据;读写索引都是volatile,避免编译器优化后读到脏值。这套结构下,主循环只管从rx_ring取数据做协议解析,UART中断只负责往缓冲区塞字节,CPU占用和时间抖动都能降下来。
4.3 必调参数:波特率、FIFO触发阈值与intr_mask的关系
中断接收代码写完后,真正决定稳定性的不是C语法,而是几个参数。我列一个自己常用的调参清单:
| 参数 | 常见取值 | 调整依据 |
|---|---|---|
| 波特率 | 115200 / 921600 | 由USB转串口芯片和传输数据量决定,越高对时钟精度要求越高 |
| RX FIFO触发阈值 | 1 / 8 / 16字节 | 中断频率太高就调高阈值,批量读取降低CPU占用 |
| 中断屏蔽位 | RXFIFO + RXOVR | RXOVR不使能时,FIFO溢出可能会静默丢数据 |
| 硬件流控 | 默认关闭 | 如果硬件没接RTS/CTS却打开了流控,对方发数据会被你“拒收” |
FIFO触发阈值在驱动里对应一个配置字,通常叫FIFO Trigger Level。裸机BSP里可以直接写UART的RX FIFO触发寄存器,也可以在XUartPs_Format相关的初始化结构里配。经验是:高速接收场景下调到16字节以上,配合中断服务里while循环读空FIFO,丢包率明显下降;低速调试场景下保持默认即可,没必要为了省中断频繁调它。
5. 串口程序跑不稳定的排查:乱码、无回显、丢字节的高频案例
5.1 乱码:从分频值算出来的波特率误差
现象:程序烧进去后,串口助手能收到字符,但内容全是乱码;或者发送出去的命令,对方收到的长度对但字节内容错位。
原因:Zynq-7010的UART波特率生成器由预分频BDIV和分频计数器CD组成,实际波特率等于参考时钟除以CD × (BDIV + 1)。初始化时会按目标波特率自动算一组分频值,但前提是它拿到的参考时钟和硬件实际情况一致。如果Block Design里选的是50MHz参考时钟,而开发板PS_CLK实际是33.333MHz,算出来的分频值对应的实际波特率可能偏了百分之几。超过2%~3%的误差后,串口接收端的采样点就会逐渐滑出字符窗口,出现稳定乱码。
解决:先用逻辑分析仪或示波器抓TXD引脚,测量一个位的时间宽度,反推实际波特率。然后回Vivado里重新确认UART参考时钟值,必要时手动算出CD/BDIV再写进初始化代码。另外一个容易被忽略的点是USB转串口线:便宜线材默认波特率精度就差,921600下尤其明显,跑高速前先查线材支持的波特率范围。
5.2 开机日志正常,程序却收不到数据
现象:上电后U-Boot和内核日志都正常打印,说明硬件通路没问题,但自己的应用程序里open了串口设备后,write能输出,read永远阻塞或读不到数据。
原因:跑Linux时,串口设备节点可能被内核控制台占用了。常见情况是console=ttyPS0把UART1分配给了内核日志,应用程序再打开/dev/ttyPS0时,内核控制台和你的进程同时在读同一个FIFO,日志打印还会频繁改变寄存器配置。这种问题用设备树和内核参数都不容易一眼看到,属于典型的“日志正常掩盖了程序异常”。
解决:先执行ls -l /sys/class/tty/ttyPS0确认设备存在,再用stty -F /dev/ttyPS0 115200 raw -echo切到原始模式;如果发现内核控制台占用了设备,修改启动参数里的console=,把控制台换到别的UART或直接去掉。修改设备树时记得确认uart节点别名,别把UART0和UART1搞混,我见过有人调了一整天,最后发现打开的是另一个节点的外设。
5.3 一次丢一串字节:FIFO触发阈值和中断耗时
现象:连续发送几十字节,接收端每次丢的都是一段连续的字节,不是零散丢;把发送速度降下来,丢字节现象就消失。
原因:PS UART的接收FIFO深度有限,中断触发阈值默认又比较低。当中断服务函数还没来得及把FIFO读空时,新的数据已经填满了整个FIFO,后续字节就被硬件丢弃。另一种隐蔽情况是中断服务里调了耗时的打印函数,UART发送是阻塞的,一打印就把中断处理拖死。
解决:优先把RX FIFO中断触发阈值提高,让中断批量处理;中断服务里只做“读FIFO写环形缓冲”,不要在ISR里做打印、解析、内存分配。如果还是丢,查一下是否使能了RX Overrun中断,并在ISR里读状态寄存器后立即清除。最后给主循环留出足够的时间消费环形缓冲,不然缓冲满了照样丢。
5.4 换个USB转接模块就失败:电平与地线问题
现象:在调试台上用A型USB转串口线一切正常,换了一根B型线或者某个转接小板后,TX方向不太对、RX方向收不到,甚至一插上就导致程序重启。
原因:很多Zynq-7010核心板的MIO串口是1.8V或3.3V电平,USB转接模块有的是5V TTL电平,两者直接相连不只是逻辑不兼容的问题,还可能损坏IO。地线虚接是另一个隐蔽原因,串口收发是单端信号,参考地不一致时偶尔有数据,传输稍频繁就出错。
解决:先确认板子串口电平,再选匹配的转接模块;没有电平转换条件时,优先找官方配套调试线。连接顺序上,先把GND接好,再接TXD/RXD;串口助手软件里把DTR/RTS关闭掉,避免转接模块自动复位PS端。
6. 让它变成生产级调试通道:串口程序外的三个使用习惯
第一个习惯是给串口输出加一个调试开关宏。裸机程序里我习惯定义UART_DEBUG_ENABLE,发布版本直接关掉所有打印。因为UART发送是阻塞的,调试信息太多会拖慢主循环,尤其是在高波特率下虽然感觉不明显,但中断接收场景里任何阻塞都可能变成丢字节的诱因。
#define UART_DEBUG_ENABLE 1 #if UART_DEBUG_ENABLE #define DBG_PRINT(fmt, ...) xil_printf(fmt, ##__VA_ARGS__) #else #define DBG_PRINT(fmt, ...) #endif第二个习惯是每次调串口前,先做一次物理回环自检。把TXD和RXD用杜邦线短接,发送一串规律数据0x55 0xAA 0x01 0x02 0x03,看回读结果是否完全一致。这个动作5分钟就能排除掉引脚约束、驱动初始化和线材三大类问题,不要一上来就找协议逻辑的毛病。第三个习惯是给上层的串口数据定义一个最简单的帧格式,哪怕只是“帧头+长度+校验+数据”,也能让你日后定位问题时省下大量时间。我自己曾经做过一个项目,前期图省事用裸数据流协议,结果联调时两个模块之间飘进了几个干扰字节,整包数据错位,排查了两天才加上帧同步逻辑,之后再也没有发生过这种悬案。
这些习惯叠加起来,ZYNQ7010的UART串口程序才算真正从“能打印”变成“能交付”。先说这些,希望帮到你。
本文还有配套的精品资源,点击获取