从近似0基础开始FPGA开发 -- part.5 Vitis工程搭建(Uart及GPIO)
前几篇我们把Vivado里的硬件工程搭了起来,Block Design里也把Zynq PS核配好,并且导出了xsa硬件描述文件。但说实话,到这一步,很多刚开始接触Zynq的朋友都会有点懵:Vivado里看着那一堆block就已经晕头转向了,接下来那个叫Vitis的玩意儿又是干嘛的?我到底在哪儿写C代码?
这一篇我不打算绕弯子,直接动手把Vitis工程从零到一建起来,然后把UART串口和GPIO这两个最基础、但也是最常用的外设跑通。我尽量按我实际调试时一步步来的顺序写,你跟着操作,基本上一个晚上就能把这套流程吃透。
先说几个前提条件,免得你卡在环境上:
- Vivado版本2020.1及以上,我这边实测用的是2020.2和2021.1都没问题,如果你用的版本太老,Vitis的界面和操作路径会有差异。
- 硬件环境是Zynq-7000系列或者Zynq UltraScale+都行,本文以Zynq-7000(比如xc7z020)为例,ZU系列流程完全一样,只是PS侧配置略有区别。
- 你已经按照我part.4的内容,在Vivado里完成Block Design的创建、PS侧MIO/UART/GPIO的配置,并且成功生成比特流、导出xsa文件。如果你还没做这一步,先回去补一下。
好,开始。
1. 整体设计与思路拆解
1.1 为什么用Vitis而不是老旧的SDK?
这是个老生常谈的问题,但还是要说清楚。Xilinx从2019.2版本开始,把原来Vivado内置的SDK工具彻底拆出来独立成了Vitis统一软件平台。你如果之前用过2018.3、2019.1版本的SDK,会发现Vitis基本就是换了个皮,但底层改了不少东西:
- 编译工具链从原来的arm-none-eabi-换成了通用的aarch64-linux-gnu或arm-linux-gnueabihf,对不同操作系统的适配更好。
- 不再区分SDK和Vivado的版本匹配问题,只要Vitis版本匹配Vivado版本即可。
- 支持多核开发、AI引擎开发、嵌入式Linux开发、HLS开发等多种模式,说白了就是从MPSoC到RFSoC全覆盖。
站在我个人的角度,Vitis最大的感受是:它对CMake的支持更好了,而且独立的workspace管理比原来嵌在Vivado里那个SDK要清爽很多。当然也有人吐槽Vitis启动慢、内存占用高,但用习惯了其实还好。
1.2 打通UART和GPIO的完整链路
不管你是做传统嵌入式还是FPGA开发,UART串口永远是调试的第一选择。FPGA片上逻辑出了bug,你总不能老用JTAG去抓信号吧?通过串口打印log,是最原始也最有效的手段。
而GPIO就更基础了——点灯。可以说GPIO的工程跑通了,你的Zynq开发环境就是真正能用的了,后面什么DMA、中断、自定义IP核,都是建立在这套流程之上的。
梳理一下我们这一篇要搞定的完整链路:
Vivado硬件工程导出xsa ↓ Vitis新建platform工程(用xsa生成) ↓ Vitis新建application工程(baremetal裸机) ↓ SSH/串口工具连接开发板(比如MobaXterm、SecureCRT) ↓ 下载bitstream到PS/PL,运行程序 ↓ 串口打印UART测试信息和GPIO测试信息这个流程图你看一眼心里就有底了,后面每一个环节我都会展开说。
1.3 选型定版:MIO还是EMIO?
GPIO这块,很多人会在MIO和EMIO之间纠结。直接说我的经验:如果你只是做最简单的点灯验证,优先用MIO;如果你的PL逻辑(FPGA可编程逻辑部分)也需要读写这个GPIO,或者你想用PL侧的引脚走线,那就得用EMIO。
MIO是PS侧专用的引脚,一共54个,直接连到Zynq的PS管脚上,不是通过PL逻辑的,所以它不需要占PL资源,也不需要在原理图上做FPGA管脚约束。而EMIO是PS和PL之间的一个桥接接口,它把PS的GPIO控制信号引到了PL侧,占PL引脚,你需要在管脚约束里重新分配脚位。
说白了,MCU级别的GPIO读写功能用MIO就够了,如果你将来要做高速并行接口、或者希望用PL侧的引脚来扩展GPIO口,再考虑EMIO。这篇我以MIO为主,EMIO会在后面的中断、自定义IP章节再单独提。
2. 核心配置:Vivado端导出xsa和Vitis工程创建全流程
2.1 从Vivado导出xsa前的最后一个检查点
很多人在这一步踩坑,我特意把它单独拉出来说。你在Vivado里打开Block Design,双击Zynq PS核的配置界面,确认这几项没问题:
Peripheral I/O Pins里,UART1要勾上(或者你本来用的UART0也行),并且记下它的MIO编号。一般Zynq开发板默认用UART1,MIO引脚编号可能是MIO48/MIO49,具体看你板卡的原理图。GPIO这一栏里,MIO GPIO要勾上,EMIO GPIO如果有需要也可以勾,但本文不推荐用EMIO做主测试。- 检查时钟配置,PS的CPU主频通常给666.667MHz或650MHz,DDR时钟按你板卡颗粒实际参数来。
另外提醒一点,如果你要在Vivado里生成Bitstream,记得Block Design里的地址映射别冲突,保证PS侧的UART地址、GPIO地址都没有被PL侧的IP核占用。这一步虽然简单,但真有人在这里把UART地址搞错了,后面Vitis里printf全打到空气上。
确认完这些之后,正常走Generate Bitstream。等跑完,File->Export Hardware,勾上Include bitstream,导出得到.xsa文件。
注意:你导出xsa之后,Vivado工程本身还可以继续改,但如果硬件配置变了(比如新增IP核、修改了PS配置),一定要重新生成bitstream并且重新导出xsa,否则Vitis那边用的还是旧的硬件描述。
2.2 第一次打开Vitis,工作区怎么建
打开Vitis以后,第一个弹窗就是让你选workspace目录。我建议你单独建一个文件夹,不要和Vivado工程混在一起,比如我常用的目录结构:
project/ ├── vivado/ # Vivado工程目录 ├── vitis/ # Vitis工作区目录 ├── docs/选择workspace定位到vitis目录,点Launch。进入界面后,File->New->Platform Project,这里新建的就是平台工程。
输入工程名,比如platform_uart_gpio,点Next。关键一步:在Hardware Specification这里选择我们刚才导出的xsa文件。如果你这里没看见xsa选项,可以点Browse手动定位。下方Operating System选择standalone(裸机),Processor选择ps7_cortexa9(如果是Zynq-7000的PS侧)。然后直接Finish。
Vitis会自动开始生成platform工程,这一步会编译所有驱动库、BSP(Board Support Package)、链接脚本等,耗时一二十秒,正常现象。
2.3 platform工程产生完之后,application工程怎么建
平台工程只是地基,真正的用户代码在application工程里。右键platform工程(或者Left面板的Platform标签下),选择New->Application Project。
输入应用工程名,比如uart_gpio_test,点Next。这里会让你选择关联哪个platform,选我们刚建的platform_uart_gpio。继续Next,在模板那一堆例程里,我强烈建议你选Empty Application,而不是那些带DDR测试、Hello World的模板。
为啥选空的?因为Hello World模板虽然能直接跑通串口,但它默认帮你初始化好了很多东西,你看不到背后的过程,反而容易学个寂寞。空工程让你自己敲每一行代码,出问题知道往哪儿找,对新手最友好。当然,如果你这次纯粹只为了验证环境串口通不通,选Hello World也行。
2.4 Vitis工程目录结构长什么样,谁是谁的先搞清楚
工程生成之后,左侧Project Explorer会看到这个结构:
uart_gpio_test/ # 应用工程 ├── src/ # 你的源代码放这里 │ └── helloworld.c # 默认模板生成的示例 ├── src/ ├── vitisconfig/ │ └── ... └── ... platform_uart_gpio/ # 平台工程 ├── bsp/ # Board Support Package,板级支持包 │ └── ps7_cortexa9_0/ │ ├── include/ # 驱动头文件 │ └── lib/ # 编译好的库 ├── hw/ # 硬件描述相关 └── ...对初学者来说,你只需要关心两个地方:
- 应用工程里的
src目录,放你的main.c和其它源文件。 - 平台工程里的
bsp目录,里面是所有Xilinx官方驱动库,比如xuartps.h(UART驱动)、xgpiops.h(PS端GPIO驱动)。你include的头文件就是从这里来的。
一般来说你不用去手改bsp里面的内容,但如果你需要自定义链接脚本,可以去bsp/ps7_cortexa9_0/libsrc/standalone_vX_X/src/里找lscript.ld,按需修改。
3. 核心细节解析:UART和GPIO的寄存器与逻辑
3.1 UART为何要从寄存器层面理解
在Zynq里,UART是可以直接通过挂在APB总线上的寄存器来控制的。你现在用Vitis写的是C代码,但最终编译出来,执行的就是对一组寄存器的读写操作。
很多人会问:“不就是调用Xil_Out32、Xil_In32这类函数嘛,我直接查驱动API不就行了?”没错,API调用是最终的目的,但你懂一点硬件底层逻辑,真的遇到“printf打印出来是乱码”、“串口没反应”这类问题时,排查思路就完全不一样了。
Zynq的UART控制器是兼容16550的增强版本,主要寄存器包括:
Control Register(控制寄存器,地址偏移0x00):负责UART的使能、收发使能、波特率分频设置。Mode Register(模式寄存器,偏移0x04):选择数据位、停止位、奇偶校验。Interrupt Enable Register(中断使能,偏移0x08):中断开关。Channel Sts Register(通道状态寄存器,偏移0x2C):里面能看到FIFO是否为满、是否为空。Receive/Transmit Holding Register(偏移0x30):往它写数据就是发一个字节,从它读数据就是收一个字节。
你在Vitis的bsp里看到的XUartPs_Send、XUartPs_Recv这些函数,本质上就是帮你把这些寄存器读写封装好了。比如XUartPs_Send最核心的一步,就是把数据循环写入XUARTPS_FIFO_OFFSET这个偏移对应的寄存器。
这也是为什么理解寄存器映射这么重要,你在芯片手册上查寄存器地址,然后用Xil_In32/Xil_Out32直接操作,这其实就是写驱动最原生的方式。
3.2 UART波特率背后的数学
串口通信双方要约定同一个波特率,Zynq的UART控制器内部有一个波特率发生器。它的核心公式是:
实际波特率 = 输入时钟频率 / (CD × (BDIV + 1))其中CD(Clock Divisor)和BDIV(Baud Rate Divisor)是两个分频寄存器,你只需要在Vitis的bsp或者代码里给出目标波特率,驱动会自动计算合适的CD和BDIV。但如果你非要自己手动算,给你一个参考计算过程:
假设输入时钟为100MHz(这个值取决于你在Vivado里给UART配置的时钟,Zynq标准情况下UART时钟是50MHz或100MHz),目标波特率9600:
其实大多数情况下,我们根本不需要手动去算这些分频数,Xilinx的驱动库已经帮我们算好了。但我们得知道一个关键点:波特率能不能精确分频,取决于输入时钟频率能否被目标波特率整除。如果误差太大,比如高于2%,通信就会出错乱码。
所以如果你在调试某些非标准波特率(比如76800、250000),发现串口数据偶尔错位,先从配置里确认UART时钟,然后计算一下分频误差,往往就能解释通。
我在实际项目中用的比较多的是115200和921600。115200在50MHz时钟下误差很小,921600稍大一点,但只要在容差范围内,日常调试完全没问题。
3.3 GPIO的MIO和EMIO再展开一点
Zynq的GPIO控制器,官方称呼是“ps7_gpio”,它其实是一个挂在APB总线上的通用寄存器组,管理着MIO和EMIO两类引脚。
MIO一共54个引脚(MIO0~MIO53),直接作为PS的IO引脚使用,可以配置为输入、输出、开漏等模式。EMIO则是把GPIO控制信号接到了PL侧,最多64个(EMIO0~EMIO63),不直接连接到物理引脚,而是通过PL逻辑再连接到FPGA引脚上。
这里有一个非常关键的差异:MIO引脚是直接挂到PS的I/O bank上的,它们的电平标准是固定的(一般3.3V或1.8V,由板卡硬件决定),而EMIO引脚的电平呢,取决于PL侧的bank电压,这就意味着如果你要做不同电平标准的外设,用EMIO更灵活。
在代码层面,MIO和EMIO的操作方式是一样的,都是通过GPIO寄存器的DATA、DIRM、OEN等寄存器控制方向和数据。区别只在引脚编号:
- MIO的编号直接是0~53。
- EMIO在软件里被映射为编号54~117,也就是你如果操作EMIO0,对应软件里的pin号就是54。
这解释了为什么很多例程里看到的GPIO引脚号是54、55之类的——那多半是在操作EMIO。
4. 实操过程:完整UART与GPIO工程的编写与验证
4.1 初始化一下串口,背下来这几个函数
在Vitis里写裸机程序,第一步永远是初始化外设。UART的初始化分为几个层次,你不一定每次都手动接触底层寄存器,但核心的几个函数得心里有数:
#include "xparameters.h" // 硬件参数定义,满是外设基地址 #include "xuartps.h" // UART驱动头文件 #include "xil_printf.h" // Xilinx轻量级printf static XUartPs UartInst; int uart_init(void) { int Status; XUartPs_Config *Config; // 1. 查找UART1的配置(具体哪个UART要对应你的Vivado设置) Config = XUartPs_LookupConfig(XPAR_XUARTPS_0_DEVICE_ID); if (Config == NULL) { return XST_FAILURE; } // 2. 初始化UART驱动实例 Status = XUartPs_CfgInitialize(&UartInst, Config, Config->BaseAddress); if (Status != XST_SUCCESS) { return XST_FAILURE; } // 3. 设置波特率(可选,默认115200) Status = XUartPs_SetBaudRate(&UartInst, 115200); if (Status != XST_SUCCESS) { return XST_FAILURE; } // 4. 设置UART工作模式:8位数据位、无校验、1个停止位 XUartPs_SetFormat(&UartInst, XUARTPS_FORMAT_8_BITS | XUARTPS_FORMAT_NO_PARITY); XUartPs_SetOperMode(&UartInst, XUARTPS_OPER_MODE_NORMAL); return XST_SUCCESS; }几个注意点:
XPAR_XUARTPS_0_DEVICE_ID这个宏在xparameters.h里定义,你不需要记忆具体数值,只需要知道它对应的是Vivado里配置的UART实例编号。- 波特率如果不设置,驱动初始化时默认也会设置为115200,但显式设置可以避免你后面改代码时忘了。
XUartPs_SetFormat调用后uart逻辑会复位,如果你在中断里发送数据,顺序要格外注意。
4.2 GPIO初始化:方向、输出、读取一个不少
GPIO的操作比UART要简单直接。但正因为简单,很多人反而容易忽略方向设置的顺序问题。看代码:
#include "xgpiops.h" static XGpioPs GpioInst; #define MIO_PIN_NUM 7 // 以MIO7为例,实际用哪个看你板卡 int gpio_init(void) { XGpioPs_Config *Config; int Status; // 1. 查找GPIO配置 Config = XGpioPs_LookupConfig(XPAR_XGPIOPS_0_DEVICE_ID); if (Config == NULL) { return XST_FAILURE; } // 2. 初始化GPIO控制器 Status = XGpioPs_CfgInitialize(&GpioInst, Config, Config->BaseAddr); if (Status != XST_SUCCESS) { return XST_FAILURE; } // 3. 设置MIO7为输出方向 XGpioPs_SetDirectionPin(&GpioInst, MIO_PIN_NUM, 1); // 4. 使能MIO7输出(这一步很多新手会漏) XGpioPs_SetOutputEnablePin(&GpioInst, MIO_PIN_NUM, 1); return XST_SUCCESS; }看到区别没有?SetDirectionPin设置方向,SetOutputEnablePin使能输出,两步缺一不可。这算是Xilinx GPIO驱动的一个特点,方向寄存器管方向,输出使能寄存器管输出状态,两步分开控制,给硬件设计留了不少灵活性。
如果你要读取输入,方向设为0即可,不需要调用SetOutputEnablePin。然后通过XGpioPs_ReadPin读回来。
// 设置MIO8为输入 XGpioPs_SetDirectionPin(&GpioInst, 8, 0); // 读取MIO8的电平状态 u32 PinValue = XGpioPs_ReadPin(&GpioInst, 8);4.3 发送和接收:阻塞式与中断式的差别
裸机环境下,最常用的发送方式是轮询/阻塞式。核心就是往FIFO丢数据,丢一个检查一下FIFO是否满了,满了就等。
int uart_send_string(const char *str) { while (*str != '\0') { // 等待发送FIFO不满 while (XUartPs_IsTransmitFull(&UartInst)) { // 空转等待 } XUartPs_Send(&UartInst, (void *)str, 1); str++; } return XST_SUCCESS; }接收端类似,理论上可以轮询接收FIFO是否有数据,然后读出来。但实际项目里,串口接收更多是用中断方式,不然CPU空转等数据浪费太高。
中断方式的使用我放在后面常见问题部分讲,因为新手做验证阶段,轮询收发已经完全够用,先把中断理解成为“串口收到数据后自动跳进一个回调函数”就对了。
4.4 把UART和GPIO合到一起:LED点灯和串口回环
有了上面的初始化函数,合到一起写个完整的main.c就很轻松了。这里我设计一个最简单的验证流程:
- 上电初始化UART和GPIO。
- 串口打印一段欢迎信息。
- 循环里翻转LED(MIO7控制一个LED或直接测量电平变化),每次翻转后串口打印当前状态。
- 同时做串口回环测试:收到什么发送什么,方便验证收发通路。
int main(void) { int Status; // 初始化UART Status = uart_init(); if (Status != XST_SUCCESS) { return XST_FAILURE; } // 初始化GPIO Status = gpio_init(); if (Status != XST_SUCCESS) { return XST_FAILURE; } xil_printf("UART and GPIO test started...\r\n"); u32 LedState = 0; while (1) { // 翻转LED XGpioPs_WritePin(&GpioInst, MIO_PIN_NUM, LedState); LedState = ~LedState; xil_printf("LED state: %d\r\n", LedState); // 简单回环:收到一个字节,发送一个字节 u8 RxBuffer; if (XUartPs_IsReceiveData(&UartInst)) { XUartPs_Recv(&UartInst, &RxBuffer, 1); XUartPs_Send(&UartInst, &RxBuffer, 1); } // 延时,不然LED翻转太快肉眼看不出来 for (volatile int i = 0; i < 10000000; i++); } return 0; }这里有一个非常典型的细节:xil_printf不是标准的printf,它是Xilinx裁剪过的一个轻量级打印函数,不支持浮点数打印(默认情况下),但支持%d、%s、%x、%c这些基本的。好处是占内存小,坏处是如果你要打印浮点数或者用%f,你会得到错误结果或者编译报警。
如果你在裸机工程里特别喜欢用标准printf,需要在编译器里把标准库链接进来,并且在BSP设置里把stdout重定向到UART。操作方法是右键platform工程,Board Support Package Settings,找到stdin和stdout,把它们都设置为ps7_uart_1即可。
4.5 下载运行:bitstream先下还是程序先跑?
这个顺序问题,几乎每次带新人都会遇到。Zynq这种PS+PL一体的芯片,上电后首先是PS侧启动,如果你想让PL逻辑(比如自定义的IP核、Verilog模块)也能工作,就需要先把bitstream下载到PL的配置寄存器里去。
在Vitis里,运行应用工程之前,有两种方式把bitstream配置进PL:
方式一:在Xilinx菜单的Program FPGA里,选择你的bitstream文件(在platform_uart_gpio/hw/目录下能找到),点击Program。
方式二:直接选择应用工程,右键Run As->Launch Hardware。Vitis通常会提示你选择是否Program FPGA。
有时候你会发现,不下载bitstream,UART和GPIO(MIO)也能工作,这是因为MIO是属于PS侧的,它不依赖PL配置。但如果你用了EMIO或者PL逻辑模块,不下载bitstream,PL侧的活动就统统不生效。所以建议你养成习惯:每次下载运行前,先Program FPGA一次。
5. 常见问题与排查技巧实录
5.1 串口完全没有输出,可能不只是代码问题
这是新手遇到最多的问题。我直接按优先级列排查清单:
- 检查串口工具参数:波特率、数据位、停止位、校验位,必须和代码里一致。如果设置了硬件流控(RTS/CTS),Zynq的UART1默认引脚不一定是全功能,很容易卡死。
- 检查USB转串口模块的接线:TXD接RXD、RXD接TXD、GND共地。很多人第一次接反了,查半天。
- 检查串口工具“打开串口”了没:你以为打印出来了,结果工具没开或者被其它进程占用了,Windows下串口被多个工具占用是常见问题。
- 在Vitis里看
Run控制台,确认程序已经Downloaded成功并且开始运行了。如果程序卡死在某个初始化阶段,串口同样没输出。 - 用示波器/逻辑分析仪测UART TX引脚的波形。如果你能看到串口数据波形但电脑收不到,说明是电平转换或USB转串口模块的问题;如果你压根看不到波形,问题就在Zynq侧代码或引脚配置。
有次我一个朋友遇到串口没输出,查了两个小时,结果发现是USB转串口模块是坏的,换个模块马上就好。从此我学到一个教训:不要过度相信硬件,排查问题时先用替换法把链路每一环都验证一遍。
5.2 打印出来是乱码,波特率谁的锅?
一串乱码,第一反应就是波特率不对。这倒不一定是代码里配置的波特率不对,还可能是:
- 串口助手的波特率和你代码里的不匹配。检查代码里
XUartPs_SetBaudRate那行,以及串口助手的设置。 - 如果代码里没设置波特率,驱动默认值不一定是115200,部分版本的bsp默认确实是9600或115200,但你必须确认。
- 晶振频率不对。Zynq上UART时钟来源于PS侧的IO PLL或者某个可编程时钟,默认都是正常配置的,但如果你在Vivado里置了奇怪的时钟频率,实际波特率就会偏离理论值。
- 你的USB转串口模块质量太差,在921600这类高波特率下收发不稳定,换回115200试试就知道。
另外,xil_printf打印%d的时候偶尔也会有乱码感,但那种乱码通常是格式不对(比如打印了无符号当有符号),和波特率乱码的“整体错位”还是能区分的。
5.3 GPIO引脚没反应,先查物理连接再查软件号
GPIO点灯没反应,排查思路要分硬件和软件两个层面。
硬件层面:
- 确认你操作的那个MIO引脚确实引到了板卡上的LED或者测试点。很多开发板的MIO引脚并不是全部引出,可能被DDR、FLASH、SD卡等占用了。
- 确认板卡原理图上LED的极性。如果LED是低电平点亮,你写1它反而不亮,写0才亮。
- 确认没有和别的外设共用一个引脚导致电平冲突。
软件层面:
- 确认你用的引脚号对上了。MIO编号为0~53,EMIO映射为54~117。误把EMIO当MIO,或者反过来,是最常见的错法。
- 确认初始化代码顺序:先设方向再使能输出,顺序反了偶尔会出怪问题。
- 检查
XPAR_XGPIOPS_0_DEVICE_ID到底对应哪个GPIO控制器的设备ID,如果板卡上GPIO控制器例化不止一个,别选错。
遇到这类问题,最快的办法是用万用表测一下引脚电平,看它有没有在高低电平之间翻转。只要电平在跳,问题八成出在你和板卡LED之间的物理连接;如果电平根本没反应,就去查软件配置。
5.4 中断方式的UART接收,初始化顺序有个坑
虽然本篇重点是轮询,但迈向真实项目一定绕不开中断收发。给你一个最小可用的中断初始化参考:
int uart_interrupt_init(void) { // 把UART的中断handler挂到GIC中断控制器上 XScuGic_Config *IntcConfig; XScuGic IntcInst; IntcConfig = XScuGic_LookupConfig(XPAR_SCUGIC_0_DEVICE_ID); XScuGic_CfgInitialize(&IntcInst, IntcConfig, IntcConfig->CpuBaseAddress); // 设置UART中断的回调函数 XUartPs_SetHandler(&UartInst, (XUartPs_Handler)Uart_Intr_Handler, &UartInst); // 连接中断到GIC XScuGic_Connect(&IntcInst, XPAR_XUARTPS_0_INTR, (Xil_ExceptionHandler)XUartPs_InterruptHandler, &UartInst); XScuGic_Enable(&IntcInst, XPAR_XUARTPS_0_INTR); // 开启UART接收FIFO中断 XUartPs_SetInterruptMask(&UartInst, XUARTPS_IXR_RXOVR | XUARTPS_IXR_RXEMPTY); return XST_SUCCESS; }踩坑点在于:XUartPs_SetHandler设置的回调函数,其调用时机是在中断服务程序里,但中断服务程序本身要挂到GIC上,顺序不能反。如果你先开GIC使能,再设回调,就可能出现:中断已经来了,但handler还没有被注册,导致前程未卜的跑飞。
另外,XUARTPS_IXR_RXEMPTY这个中断条件指的是接收FIFO为空时触发,在实际使用中通常配合其它条件。真正常用的接收中断触发条件是XUARTPS_IXR_RXOVR(接收溢出)或者XUARTPS_IXR_RXFULL(FIFO满),具体用哪个取决于你的FIFO阈值设置。我建议先用RXFULL,配合读取FIFO深度,逻辑更直观。
5.5 Vitis编译报错但警告不显示?试试Clean
Vitis偶尔会抽风,代码逻辑没问题,编译却报警告或错误,而且错误信息指向了一些奇怪的文件。遇到这种情况,先别慌着改代码,右键工程Clean Project,然后重新Build。八成是增量编译的缓存出了问题。
如果Clean后还是报错,再看是不是BSP生成有问题。这时候可以在platform上右键Rebuild Platform,让BSP重新生成一遍。通常这么一轮下来,伪报错就消失了。
还有一种极端情况:xparameters.h里面的设备ID和你verilog/IP里的不完全一致,导致驱动的LookupConfig返回NULL。这种情况下程序编译能过,运行时却卡死。你要打开bsp/ps7_cortexa9_0/include/xparameters.h,手动确认里面的UART设备ID和基地址和你Vivado配置的一致。
6. 经验总结与踩坑记录
6.1 我的UART初始化黄金顺序
我经过大量的调试,最终总结出一个最稳的初始化顺序,在新项目里我都是直接套用:
LookupConfig获取配置结构体。CfgInitialize初始化驱动实例。SetOperMode设置为Normal模式(确保之前如果跑过别的模式,先复位到正常状态)。SetBaudRate设置波特率。SetFormat设置帧格式(先设置格式,再使能发送接收)。- 如果开中断,在这里设置Handler并且使能中断。
核心逻辑是:先让控制器回到一个干净的初始状态,然后再按“波特率→帧格式→收发使能”的顺序向下配置。其中SetOperMode最好在SetBaudRate之前,因为它里面涉及到一个软件复位。我见过有人反过来写,结果波特率怎么设都不对,其实就是因为没先复位。
6.2 想少踩GPIO的坑,硬件先确认三件事
- 引脚有没有被别的功能复用:Zynq同一个MIO引脚往往有多种复用功能,查看数据手册里的
Table of Pin Definitions,看清楚哪个功能优先占用。 - 电平标准:MIO的电平由硬件确定,一般3.3V。如果要驱动5V的逻辑,中间必须加电平转换,不能直接怼。
- 上拉/下拉:很多开发板的LED、按键电路自带电阻,如果你的输出模式和外围电路不匹配,读到的电平可能和你写的不一致。
如果你在做按键输入检测,外部没有上拉电阻的话,记得在片内使能弱上拉。Zynq的GPIO控制器里可以配置上拉/下拉,对应的API是XGpioPs_SetPullType,这在按键悬空时很有用。
6.3 从工程管理的角度,建议你养成两个习惯
第一个习惯是:把xsa文件当成交付物来管理。每次硬件配置有变化,就重新导出xsa并且覆盖旧的,同时在git提交记录里注明改动内容。后期如果出现三四个硬件版本,你就会发现这个习惯能救命。
第二个习惯是:application工程的src目录只放自己的源码,别把编译生成的中间文件塞进去。Vitis的构建目录默认在工程内部的build/下,如果git提交时把build目录也提交了,会特别乱。在.gitignore里把build/和.metadata/忽略掉,能省去很多麻烦。
7. 把这两个外设吃透之后,下一步该学什么?
我一直跟新人说,UART和GPIO是Zynq开发的两条腿,把这两条腿站稳了,后面走路才不歪。你花一晚上跟着这篇文章跑通了UART回环和GPIO点灯,恭喜你,Zynq的开发大门已经打开了。
接下来你可以按这个路线往下走:
- 给UART加上中断收发,做一个带简易命令行的串口调试工具。
- 把GPIO扩展成按键中断,配合定时器做一个周期性任务调度。
- 去了解一下Zynq的DDR内存和DMA,这是性能和复杂逻辑的起点。
- 考虑通过EMIO把PS侧的GPIO连到PL侧,和你的Verilog逻辑联动。
实话说,Zynq这套系统比纯MCU复杂,比纯FPGA又多了个CPU,但它的魅力也正在于此:软件和硬件在你手里同一个工具链里打通了。你既可以写C来控制外设,又可以用HDL来实现并行逻辑,这种“软硬通吃”的体验,是单独学STM32或单独学Verilog都很难感受到的。
我这边还留了几个扩展方向,比如通过UART给PL下发配置参数、用GPIO模拟I2C/SPI时序等等,后面如果你们有兴趣,我再写具体篇目。先到这,有问题评论区交流。
最后说个亲测有用的小技巧:如果你在验证UART收发的时候,手里没有逻辑分析仪,可以把TX和RX直接短接,然后在程序里发一个字节看看能不能收到——能收到就说明UART的收发通路是完好的,排除法排查起来特别快。