很多朋友玩FPGA,一开始都是冲着Verilog逻辑设计去的,跑通一个LED流水灯就觉得入门了。但一旦涉及真正的项目,比如要和上位机通信、要调试寄存器、要控制外部设备,你就绕不开一个东西:嵌入式软核处理器。Zynq系列之所以受欢迎,就是因为ARM处理器和FPGA在同一个芯片里,既能跑软件,又能做硬件逻辑。而Vitis,就是Xilinx(现在是AMD)官方用来开发Zynq ARM端软件的工具。
Part.5我来聊聊怎么在Vitis里搭建一个最基础的工程,目标有两个:用GPIO点亮LED,再用UART实现回环(echo)。这两个功能通了,基本就说明你的软硬件链路是完整的,后面再上复杂应用心里才有底。
1. 内容整体设计与思路拆解
1.1 为什么是Vitis,它不是个新东西
好多教程现在还习惯叫“Vivado SDK”,搞得新手一脸懵。这里先理清版本线:Vivado 2019.2之后,原来的SDK正式更名为Vitis,底层还是Eclipse那套,但集成了更多功能,尤其是对AI推理和软件加速的支持。不过对我们做基础嵌入式开发来说,Vitis和SDK用起来几乎一模一样,就是换个名字、换个启动图标而已。
这个系列的前几部分如果用的是Vivado 2019.2以上版本,那Part.5跟着用Vitis就对了。如果你还在用老的2018.3,界面可能有点差异,但核心流程——导出硬件、创建platform、写应用、跑调试——是通用的,不用慌。
1.2 硬件工程导出的关键一步
在进入Vitis之前,你必须在Vivado里完成三件事:
- 创建Block Design,加入Zynq PS核
- 配置好UART(一般是MIO 14/15,对应板载USB转串口)
- 配置好GPIO(这里用的是MIO外接的LED/按键,或者EMIO都行,看你的板子)
然后在Vivado里跑完综合、实现、生成比特流,最后执行 File -> Export Hardware,注意勾选Include bitstream。这一步产生的.xsa文件就是Vitis工程的基石。
很多人会在这个环节踩坑:只导出了硬件描述文件,没勾选包含比特流,结果Vitis里Program FPGA时找不到bitstream。虽然可以在Vitis里重新指定,但多一事不如少一事,勾上最省心。
1.3 Vitis工程的两个核心部件
打开Vitis后,你的整个工作区其实围绕两个东西转:Platform和Application。
Platform相当于硬件平台的定义,它告诉编译器你的ARM核主频多少、有什么外设、DDR怎么分配。Application就是你的裸机程序(或者Linux应用,但这里先讲裸机),它跑在Platform之上,通过BSP提供的库函数操作硬件。
这个分层逻辑很像我平时调STM32的方式:Platform对应HAL库的底层配置,Application就是你的main函数。好处是换硬件时只要重新生成Platform,上层软件基本不用动。
2. 核心细节解析与实操要点
2.1 Zynq的GPIO到底有几个,别搞混了
Zynq的GPIO分两部分,这是新手最容易懵的地方。
第一部分是MIO(Multiplexed I/O),一共54个引脚,直接挂在PS端ARM处理器上。它们不经过FPGA逻辑,所以响应快,用起来像单片机。MIO的功能是复用式的,除了当GPIO,还能配置成UART、SPI、I2C等各种外设引脚。
第二部分是EMIO(Extended MIO),一共64个引脚,走的是FPGA逻辑侧。你在Vivado Block Design里把GPIO引脚数拉到64以上,多出来的就是EMIO。EMIO需要经过FPGA的引脚约束才能到外部,所以延时大一些,但好处是引脚位置灵活,可以随便分配。
我Part.5的板子用的是MIO接的LED和按键,所以只需要在Vivado里勾选GPIO MIO Bits就可以了。如果你的板子LED接在FPGA侧,就选EMIO,然后在约束文件里指定物理引脚。
2.2 UART的波特率与时钟树关系
Zynq的UART控制器是挂在一个可配置时钟域上的,默认情况下UART参考时钟通常是100MHz(来自IO PLL或者ARM PLL分频)。Vivado会根据你的波特率设置自动计算分频系数。
这里要提醒一个细节:在Vivado里配置UART时,波特率字段有个"Override"选项,如果你选了标准波特率比如115200,但实际上板载USB转串口芯片(比如FT232或CP2102)的时钟精度不高,长时间通信可能出现偶发乱码。这种情况不如在代码里用XUartPs_SetBaudRate动态设置一次,驱动会自动重新计算分频器,比依赖Vivado的静态配置更稳。
我自己实测下来,PS端的UART处理器在115200下非常稳,除非你把参考时钟改得很离谱。所以这个坑主要出现在时钟配置混乱的项目里,Part.5用默认配置就行。
2.3 中断还是轮询,第一次别贪多
UART和GPIO的驱动方式都有三种:轮询(Polling)、中断(Interrupt)、DMA。
很多教程一上来就教你配中断,一配就是全套:中断控制器初始化、中断回调注册、使能中断源。对零基础的朋友来说,这信息量有点大。而且一旦中断服务函数里出了bug,查起来比轮询难一个量级。
我的建议是Part.5先全部用轮询方式:
- GPIO读按键,直接
XGpio_DiscreteRead,管它准不准,先读出来再说 - UART收数据,用
XUartPs_Recv加超时判断,收到什么发什么
这样代码逻辑是线性的,出了问题分段打串口日志就能定位。等这套流程跑顺了,Part.6再引入中断,你会更容易理解中断到底帮你省了什么资源,而不是稀里糊涂地把一堆寄存器配完就完事。
2.4 为什么要同时做GPIO和UART
把这两个外设放在同一个工程里是有讲究的。很多人觉得单独跑个LED闪烁叫"点亮",单独打通串口叫"通信"。但实际项目里,GPIO和UART通常是联动的。
比如你想知道某个传感器数据什么时候更新,习惯做法是:传感器数据来了,GPIO拉高通知处理器,处理器通过UART把这个事件发到上位机。或者反过来,上位机通过UART发送控制指令,处理器解析后通过GPIO输出电平控制继电器。这种“一个输入一个输出、一个控制一个反馈”的组合,才是嵌入式开发的基本盘。
所以Part.5看似是两个独立外设的实验,其实是在给你搭建一套完整的外设协同处理框架。
3. 实操过程与核心环节实现
3.1 第一步:Vivado侧完整导出
假设你已经在Vivado里建好了带Zynq PS的Block Design,接下来是最容易漏掉的流程。
首先打开Block Design,双击Zynq PS核,进入配置界面。在Peripheral IO Pin和MIO Configurations面板里,确认UART1的TX/RX勾选在MIO 14/15上(这是最常见的板载串口映射)。确认GPIO里MIO相关位被勾上,具体哪几位能用要看你的板卡原理图。
然后回到Vivado主界面,在Sources窗口右键你的Block Design,选择Generate Output Products,等待完成。接下来在左侧Flow Navigator点击Run Synthesis、Run Implementation,最后Generate Bitstream。烧完bitstream后,执行File -> Export Hardware,勾选Include bitstream,导出的文件保存为design_1_wrapper.xsa。
实际操作里我会顺手检查一下.xsa文件的大小,如果只有几KB,很可能没包含bitstream;正常情况应该有几十KB以上,虽然这不是绝对标准,但可以辅助判断。
3.2 第二步:创建Vitis平台工程
打开Vitis,选择一个空的工作目录,菜单栏File -> New -> Platform Project,名字叫zynq_platform。在弹窗里点击Browse,选择刚才导出的design_1_wrapper.xsa,然后点击Finish。
Vitis会自动解析这个.xsa文件,生成platform。这一步完成后,左侧Project Explorer里会出现zynq_platform,展开可以看到ps7_cortexa9_0等子项,这就是你的ARM核硬件抽象层。
第一次生成platform会比较慢,因为要编译BSP库。如果中途报错,多半是.xsa文件本身有问题,或者Vitis版本和Vivado版本不匹配。官方要求大版本一致,比如都是2020.2或2021.1,混用版本经常会出一些奇怪问题。
3.3 第三步:创建Application应用工程
选择菜单File -> New -> Application Project,名字叫hello_uart_gpio,在弹出的界面里选择Use existing platform,指定刚才的zynq_platform。接下来选择模板,这里不要选Hello World,我们选Empty Application(C),从零开始写。
Application创建完成后,右键工程名 -> Build Project,先把整个工程编译一遍。此时虽然没有任何源文件,但这个步骤能验证platform和编译器工具链是否正常。
然后右键hello_uart_gpio下的src目录,选择New -> Source File,创建一个main.c,把下面的代码写进去。
3.4 第四步:核心代码实现与解析
先说代码框架,Part.5需要三个文件:main.c(主逻辑)、gpio_driver.c(GPIO封装)、uart_driver.c(UART封装)。工程虽小,但分文件是习惯问题,为以后代码膨胀打基础。
main.c里最核心的逻辑:
#include "xparameters.h" #include "xgpio.h" #include "xuartps.h" #include "xil_printf.h" #define GPIO_DEVICE_ID XPAR_XGPIO_0_DEVICE_ID #define UART_DEVICE_ID XPAR_XUARTPS_0_DEVICE_ID #define LED_CHANNEL 1 #define BTN_CHANNEL 2 #define LED_MIO_PIN 0x01 #define BTN_MIO_PIN 0x01 static XGpio gpio_inst; static XUartPs uart_inst; static u8 recv_buffer[64]; int main(void) { int status; u32 led_state = 0; status = XGpio_Initialize(&gpio_inst, GPIO_DEVICE_ID); if (status != XST_SUCCESS) { xil_printf("GPIO init failed\r\n"); return -1; } XGpio_SetDataDirection(&gpio_inst, LED_CHANNEL, 0x0); // 输出 XGpio_SetDataDirection(&gpio_inst, BTN_CHANNEL, 0x1); // 输入 status = XUartPs_LookupConfig(UART_DEVICE_ID); if (status == NULL) { xil_printf("UART config lookup failed\r\n"); return -1; } XUartPs_CfgInitialize(&uart_inst, status, UART_DEVICE_ID); XUartPs_SetBaudRate(&uart_inst, 115200); xil_printf("UART GPIO Test Start\r\n"); while (1) { u32 btn_val = XGpio_DiscreteRead(&gpio_inst, BTN_CHANNEL); if (btn_val & BTN_MIO_PIN) { led_state ^= 0x01; XGpio_DiscreteWrite(&gpio_inst, LED_CHANNEL, led_state); xil_printf("Button pressed, LED toggled\r\n"); } int bytes = XUartPs_Recv(&uart_inst, recv_buffer, sizeof(recv_buffer)); if (bytes > 0) { XUartPs_Send(&uart_inst, recv_buffer, bytes); } usleep(50000); // 50ms防抖 } return 0; }这段代码有几个细节我要说一下:
第一,XGpio_SetDataDirection第二个参数传的是通道号。XGpio驱动用通道区分不同bank,对应Vivado里的GPIO位宽配置。一般通道1对应MIO [0:31],通道2对应MIO [32:53],如果你的配置不同,需要对照xparameters.h里的宏定义。
第二,XUartPs_Recv的返回值是实际接收到的字节数,不是寄存器状态。如果你发现回环总是漏数据,多半是缓冲区满之前上位机已经发了超过64字节的数据。Part.5的测试规模下,64字节完全够用。
第三,防抖我用的是最简单的usleep。这个方法对教学够了,但实际项目里做按键检测应该用定时器中断加状态机来实现,避免阻塞主循环。这里先不展开,后面系列里专门讲。
gpio_driver.c的封装:
#include "gpio_driver.h" static XGpio s_gpio; int gpio_init(u16 device_id, u32 out_mask, u32 in_mask) { int status = XGpio_Initialize(&s_gpio, device_id); if (status != XST_SUCCESS) { return status; } XGpio_SetDataDirection(&s_gpio, 1, out_mask); XGpio_SetDataDirection(&s_gpio, 2, in_mask); return XST_SUCCESS; } void gpio_write(u32 value) { XGpio_DiscreteWrite(&s_gpio, 1, value); } u32 gpio_read(void) { return XGpio_DiscreteRead(&s_gpio, 2); }这里把LED通道固定为通道1、按键通道固定为通道2,在板子固定的情况下,这种简化是合理的。如果你是通用模块,建议把通道号也做成参数传进去。
uart_driver.c的封装:
#include "uart_driver.h" static XUartPs s_uart; int uart_init(u16 device_id, u32 baud_rate) { XUartPs_Config *cfg = XUartPs_LookupConfig(device_id); if (cfg == NULL) { return XST_FAILURE; } int status = XUartPs_CfgInitialize(&s_uart, cfg, device_id); if (status != XST_SUCCESS) { return status; } XUartPs_SetBaudRate(&s_uart, baud_rate); return XST_SUCCESS; } int uart_send(u8 *buffer, u32 length) { return XUartPs_Send(&s_uart, buffer, length); } int uart_recv(u8 *buffer, u32 length) { return XUartPs_Recv(&s_uart, buffer, length); }封装的好处是main函数里看不到一堆驱动对象,逻辑会非常清爽。你写复杂应用时,这些基础函数改都不用改,直接在业务层调用就行。
3.5 第五步:硬件验证流程
编译工程无误后,点击菜单Xilinx -> Program FPGA,这时候会弹出硬件配置文件选择框,建议选上design_1_wrapper.bit。如果你的JTAG链上还有其他设备,这里可能显示多个FPGA器件,选对型号就行。
Program完成后,右键hello_uart_gpio工程,选择Run As -> Launch on Hardware (Single Application Debug)。Vitis会自动下载elf文件到DDR里并运行。这时串口终端应该输出UART GPIO Test Start。
然后做两个实验:
- 按一下按键,LED状态翻转,串口同时打印
Button pressed, LED toggled - 在串口终端里输入任意内容,比如
hello FPGA,发出去,终端里会原样收到一遍
这两个实验都通过,恭喜你,Zynq的软硬件链路已经彻底打通了。
3.6 关于Vitis里运行Linux的后续思路
如果你最终目标是vitis 运行linux,也就是在Zynq上跑PetaLinux或者手工构建的Linux镜像,那Part.5这套裸机代码其实没白写。Linux设备树里UART和GPIO的节点描述,本质上就是在告诉内核硬件资源在哪、中断号是多少,跟你在Vivado里配置外设的思维模式是一致的。
裸机程序调试熟了,你对PMU、DDR、MIO这些硬件的理解会直接迁移到Linux开发里。我见过很多直接上手PetaLinux的初学者,连devicetree.dts里的status = "okay"是什么意思都搞不明白,就是因为缺了裸机这一步。
4. 常见问题与排查技巧实录
4.1 程序下载后串口没有任何输出
大概率不是程序问题,是你板子的USB转串口芯片驱动没装对。Zynq开发板用得最多的三款芯片是FT232、CP2102和CH340,对应驱动分别是FTDI VCP、Silicon Labs CP210x和沁恒官方Driver。
检查方法很简单:打开设备管理器,看端口下面有没有带感叹号的设备。如果是FT232,驱动装好后会识别成USB Serial Port;CP2102会识别成Silicon Labs CP210x USB to UART Bridge。要是这些都没出现,可能需要手动指定驱动路径。
另外一个容易忽视的是接线。虽然MIO 14/15已经被Vivado配置成UART,但板子上通常有个跳线帽或者拨码开关控制USB口跟PS UART的连接方向。拿万用表量一下,或者翻一下板子原理图,确认USB转串口芯片的RXD接到了PS的TX(MIO15),TXD接到了PS的RX(MIO14)。
4.2 LED不亮的三种可能
第一,方向配置错了。XGpio_SetDataDirection(&gpio_inst, LED_CHANNEL, 0x0)是全0输出,如果你不小心写成0x1,那只有bit0是输入、其余全输出,如果LED接在bit0上就亮不了。
第二,物理极性反了。开发板的LED大多是高电平点亮,但也有一些是灌电流接法,高电平反而灭。这时候调试方法是在代码里先写死一个值,比如XGpio_DiscreteWrite(&gpio_inst, LED_CHANNEL, 0xFFFFFFFF),看LED亮不亮。亮了说明极性正常,不亮就把值改成0x0再试。
第三,你用的管脚被其他外设占用了。Zynq的MIO有些引脚默认会被配置成Quad SPI Flash或者SD卡控制器。你在Vivado里如果没有显式将这些引脚设为GPIO,系统可能默认保留给其他控制器。这时候去Zynq配置界面的MIO Configurations里逐项检查,确保对应引脚属于GPIO。
4.3 UART接收乱码或丢字节
新手上路最常见的UART问题就是乱码。如果终端显示的是乱七八糟的符号,先检查波特率。Vivado里默认如果是115200,你要确保串口工具也是115200、8、N、1。这种低级错误我见过太多次了。
排除波特率因素后,再看时钟。Zynq的UART参考时钟如果有地方被改动了,跟Vivado导出硬件时的配置不一致,会直接导致波特率偏移。建议在代码里加一句校验:
u32 baud_actual = XUartPs_GetBaudRate(&uart_inst); xil_printf("Actual baud rate: %lu\r\n", baud_actual);输出如果是115200,那硬件链路基本没问题,问题大概率出在线材或干扰上。
丢字节则通常是上位机发送太快、下位机来不及接收。轮询方式下,XUartPs_Recv每次最多只能收满指定的缓冲长度,剩余字节会留在FIFO里等下一次调用。如果上位机一次性发几百字节,轮询代码一次取64字节,要好几轮才能取完,中间如果有其他耗时操作就会丢。Part.5工程你不用担心这个,但后续做实际项目时建议直接换中断或DMA方式。
4.4 Vitis连不上开发板的快速排查
点Program FPGA时弹出No hardware target之类的话,先别急着重装驱动。
第一步,检查JTAG线缆连接,Zynq开发板一般用内置USB-JTAG,不需要外接下载器。第二步,在Vitis的菜单Xilinx -> XSCT Console里输入connect,看能不能发现设备。如果提示connect: timeout,大概率是JTAG链路被其他程序占了,关掉Vivado Hardware Manager再试。
我遇到过一种很隐蔽的情况:第一次Program FPGA成功,第二次就报ERROR: [Vitis-60] No hardware target is available。后面发现是Vivado的Hardware Manager还在后台开着,跟Vitis抢JTAG资源。把Vivado那边的硬件连接断开,问题立刻解决。
5. 进阶玩法与实际项目联想
5.1 用这个框架做FMCC通信验证
很多工业项目是stm32h743和fpga实现fmc通信,Zynq想验证类似的并行总线通信,思路也差不多。
你可以把Zynq的GPIO配置成16位或者32位并行数据总线,再拿几个GPIO当读/写/片选控制信号,连接到一个简单的外设。在代码里写一个软件模拟的寄存器读写函数,上位机通过UART发送寄存器地址和值,Zynq解析后控制GPIO时序,就像在操作FMC总线一样。
这样做的好处是先把总线的时序逻辑在软件里调通,等以后接到真正的高速外设时,你已经对建立时间、保持时间、片选时序有概念了,再迁移到FPGA侧的时序逻辑会顺手很多。
5.2 跑卡尔曼滤波做传感器融合
在Zynq上跑卡尔曼滤波 fpga这个方向,属于PS+PL协同工作的典型例子。通常PS端采集传感器数据、做预处理,PL端实现高吞吐的滤波或者Hardware Accelerator。
Part.5的UART和GPIO框架可以作为这个方向的骨架:GPIO模拟读取传感器中断信号,UART上报滤波结果。后面你需要做的就是在ARM上调通卡尔曼矩阵运算,再决定要不要把核心计算挪到PL端的AXI接口IP里去。
5.3 尝试高云FPGA或国产FPGA的迁移
如果你工作环境用的是高云FPGA这类国产器件,Vitis这套流程就不适用了,因为高云有自己的IDE,叫Tang Dynasty或者高云云源软件,流程完全不同。但从思维模型上看,仍是“软核/硬核处理器 + 可编程逻辑”这套组合。
我在帮一个朋友评估国产FPGA方案时发现,很多国产器件的GPIO和串口设计逻辑跟Xilinx差距没有想象中大。只要在Xilinx平台把外设驱动的分层思想学明白,换平台无非是重新学一遍寄存器和编译工具,上手周期比从零开始短很多。所以我一直建议新手先用Xilinx建立整体概念,再去接触国产工具链,兼容颗粒度完全不同,效果会更好。
6. 写在最后的几个心得
做完Part.5这个工程,我自己最大的体会是:软件开发不能拖着硬件问题一起调试。很多人喜欢把UART、GPIO、中断、DMA全塞进一个工程里,结果出了问题你根本不知道找软件还是找硬件。我现在的习惯是,每个外设先单独验证,验证通过后再组合。Part.5的GPIO和UART其实也是两个独立实验,只是放进了一个工程文件里,组合价值体现在交互逻辑上。
另外一点是版本管理。Vitis工程的.xsa文件我建议和Vivado工程一起放到Git仓库里,虽然二进制文件diff起来不方便,但至少能保证换电脑或者回溯版本时不至于找不到硬件描述文件。我见过不少同事半年后重新打开老项目,xsa文件找不到了,Vivado工程里又改过配置,导出后行为跟当初完全不一样,排查起来极其痛苦。
Vitis本身的生成文件确实很大,每次编译都会产生几十MB到上百MB的中间产物。我在实际项目里的做法是:workspace下面只提交src目录和platform目录的描述性文件,其余generated文件夹全部加到.gitignore里。如果你的协作伙伴用同一个版本的Vivado和Vitis,他拿到src和platform工程也能完整编译。
Part.5的代码量虽然不大,但覆盖了嵌入式开发里最能打的两个外设。把这两个玩透,后续的UART协议解析、GPIO模拟I2C/SPI、甚至操作系统移植,你都会觉得顺理成章。下一期如果继续,我可能会讲Zynq的中断系统,或者直接上新版Vitis里的Linux流程。