1. 为什么要在 ZYNQ 上跑 FreeRTOS:从裸机到多任务的必然选择
很多刚接触 ZYNQ 的朋友,习惯了一上来就在 PS 端写裸机程序,一个main函数里塞进所有初始化、外设轮询和业务逻辑。项目小的时候没问题,一旦涉及网络通信、数据采集、界面刷新同时进行,裸机那套while(1)加状态机的写法就会变得极其难维护。我自己早期做数据采集板的时候就吃过这个亏:串口收包、DMA 搬运、定时上报三件事互相打架,改一处逻辑崩三处。
FreeRTOS 在 ZYNQ 上的价值,说白了就是把这几件事拆成独立的任务,让调度器去决定谁先跑、谁等谁。ZYNQ 的 PS 端是双核 Cortex-A9,跑 FreeRTOS 属于“大材小用”但极其稳当——它不像 Linux 那样需要庞大的内存和启动时间,又能提供任务、队列、信号量这些实时操作系统的基本能力。对于工业控制、仪器仪表、电机驱动这类对响应时间有硬要求、又不想上 Linux 的场景,ZYNQ + FreeRTOS 是非常务实的组合。
这篇内容我会把 Vitis 2023.2 下从零创建一个 FreeRTOS 工程、配置 BSP、写任务代码、再到下载调试的完整链路走一遍。适合两类人:一是刚上手 ZYNQ、想从裸机过渡到 RTOS 的开发者;二是用过老版本 XSDK、现在要迁移到 Vitis 2023.2 的工程师。Vitis 2023.2 相比早期版本在工程结构和 BSP 配置界面上变化不小,很多老教程直接照搬会踩坑,我会把差异点标出来。
提示:本文所有操作基于 ZYNQ-7020 开发板(PS 端为双核 Cortex-A9),Vitis 版本为 2023.2,FreeRTOS 内核版本为 BSP 自带的 10.x。不同板卡在 DDR 型号、时钟配置上会有差异,但流程完全一致。
2. 环境准备与工程创建:Vitis 2023.2 的正确打开方式
2.1 安装与工作区规划
Vitis 2023.2 的安装包体积不小,完整安装(含 Vivado)大概 50GB 以上。如果你只做 PS 端软件开发,其实可以只装 Vitis,但 ZYNQ 项目通常需要 Vivado 导出的 XSA 文件,所以建议统一装。安装时注意勾选 “Install Cable Drivers”,否则后面 JTAG 下载会识别不到下载器——这个坑我在热词里看到有人问“下载调试的时候不识别芯片”,八成就是驱动没装或者被系统拦截了。
工作区(Workspace)路径强烈建议不要带中文和空格。Vitis 底层调用的是 GCC 和一堆 Makefile,路径里有中文会导致编译报奇怪的错误,比如找不到头文件、链接脚本解析失败。我一般建在D:\zynq_ws\这种纯英文短路径下。
2.2 从 XSA 创建平台工程
ZYNQ 开发的起点是硬件平台。你需要先在 Vivado 里配置好 PS 端(DDR 型号、时钟、外设引脚),导出 XSA 文件。然后在 Vitis 里File -> New -> Platform Project,选择这个 XSA。
这里有个关键点:平台工程里要确认 FreeRTOS 的 BSP 是否可用。Vitis 2023.2 创建平台时会自动生成一个 standalone 的 BSP,FreeRTOS 需要你在 BSP 设置里手动切换操作系统。具体路径是:双击平台工程里的platform.spr,找到ps7_cortexa9_0下的 BSP,点Modify BSP Settings,在OS那一栏把standalone改成freertos10_xilinx。
改完之后 BSP 会重新生成,这个过程会拉取 FreeRTOS 源码并编译成库。如果卡在这里很久,多半是网络问题或者本地缓存损坏,可以删掉平台工程的bsp目录重新生成。
2.3 创建应用工程并关联 FreeRTOS
平台建好后,File -> New -> Application Project,选择刚才的平台,模板里选FreeRTOS Hello World。这个模板会自动帮你生成一个带任务创建代码的main文件,非常适合作为起点。
生成的工程结构大概是这样的:
freertos_app/ ├── src/ │ ├── main.c │ └── FreeRTOSConfig.h (实际在 BSP 里,但可覆盖) ├── Debug/ └── ...注意FreeRTOSConfig.h默认在 BSP 目录下,应用工程里如果放一份同名的,会优先使用应用工程里的。这个机制很有用——你可以在不改 BSP 的前提下调整任务优先级数量、堆大小、tick 频率等参数。
3. FreeRTOS 核心配置:BSP 参数与 FreeRTOSConfig.h 详解
3.1 必须搞懂的几个 BSP 参数
在 BSP 设置里,FreeRTOS 相关的参数不少,但真正影响工程能否跑起来的主要是这几个:
| 参数 | 默认值 | 作用 | 调整建议 |
|---|---|---|---|
configTICK_RATE_HZ | 100 | 系统节拍频率 | 需要毫秒级调度可改 1000 |
configTOTAL_HEAP_SIZE | 65536 | FreeRTOS 堆大小 | 任务多、队列大时加到 128K 以上 |
configMINIMAL_STACK_SIZE | 128 | 最小任务栈(字) | 用 printf 的任务至少 512 |
configMAX_PRIORITIES | 8 | 最大优先级数 | 任务分层多可加到 16 |
configUSE_PREEMPTION | 1 | 抢占式调度 | 实时性要求高保持 1 |
configTOTAL_HEAP_SIZE是最容易出问题的地方。FreeRTOS 的任务栈、队列、信号量都从这个堆里分配。默认 64KB 看着不少,但如果你创建五六个任务、每个栈 1KB,再加几个队列,很快就见底了。堆耗尽的表现是xTaskCreate返回errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY,或者直接进vApplicationMallocFailedHook。
3.2 FreeRTOSConfig.h 的关键裁剪
BSP 自带的FreeRTOSConfig.h内容很全,但很多功能你用不上,开着只会增加代码体积和潜在风险。我一般会做这些裁剪:
/* 时钟与节拍 */ #define configCPU_CLOCK_HZ ( 666666666UL ) /* 按实际 PS 频率填 */ #define configTICK_RATE_HZ ( 1000 ) /* 1ms 一个 tick */ /* 调度相关 */ #define configUSE_PREEMPTION 1 #define configUSE_TIME_SLICING 1 #define configUSE_IDLE_HOOK 0 /* 不用空闲钩子就关掉 */ #define configUSE_TICK_HOOK 0 /* 内存 */ #define configSUPPORT_DYNAMIC_ALLOCATION 1 #define configSUPPORT_STATIC_ALLOCATION 0 /* 不用静态分配可关 */ #define configTOTAL_HEAP_SIZE ( 128 * 1024 ) /* 通信机制,按需开启 */ #define configUSE_QUEUE_SETS 0 #define configUSE_MUTEXES 1 #define configUSE_COUNTING_SEMAPHORES 1 #define configUSE_RECURSIVE_MUTEXES 0 /* 调试辅助,开发阶段建议开 */ #define configCHECK_FOR_STACK_OVERFLOW 2 #define configUSE_MALLOC_FAILED_HOOK 1configCHECK_FOR_STACK_OVERFLOW设成 2 是最严格的检查方式,会在任务切换时检查栈末尾的魔术字是否被改写。开发阶段开着它,能帮你提前发现栈溢出。代价是每次切换多几个时钟周期,量产时可以关掉。
configCPU_CLOCK_HZ一定要和实际 PS 频率一致。填错了不会编译报错,但vTaskDelay的延时时间会完全不对——填成 333MHz 而实际跑 666MHz,延时就会变成一半。这个坑很隐蔽,因为程序照跑不误,只是时序全乱。
3.3 堆方案的选择:heap_4 还是 heap_1
FreeRTOS 提供五种堆管理方案,ZYNQ 上最常用的是 heap_4。它支持内存释放和碎片合并,适合任务会动态创建删除的场景。heap_1 最简单但不支持 free,适合任务一次性创建后不再变动的系统。
在 BSP 里切换堆方案,改的是FreeRTOSConfig.h里的configTOTAL_HEAP_SIZE和 BSP 源文件列表。Vitis 2023.2 默认用 heap_4,一般不用动。如果你发现内存碎片严重(长时间运行后分配失败但总空闲内存还很多),可以考虑换成 heap_5 并配置多块非连续内存区域。
4. 任务设计与代码实现:从 Hello World 到实用框架
4.1 任务创建的标准写法
模板生成的main.c里通常只有一个打印任务。实际项目里我会按功能划分任务,比如采集任务、处理任务、通信任务、看门狗任务。下面是一个我常用的任务创建骨架:
#include "FreeRTOS.h" #include "task.h" #include "queue.h" #include "semphr.h" #include "xparameters.h" #include "xil_printf.h" /* 任务句柄 */ static TaskHandle_t xAcqTaskHandle = NULL; static TaskHandle_t xProcTaskHandle = NULL; static TaskHandle_t xCommTaskHandle = NULL; /* 任务间通信 */ static QueueHandle_t xDataQueue = NULL; static SemaphoreHandle_t xUartMutex = NULL; /* 采集任务:优先级最高,周期 10ms */ static void vAcquisitionTask(void *pvParameters) { TickType_t xLastWakeTime = xTaskGetTickCount(); const TickType_t xPeriod = pdMS_TO_TICKS(10); uint32_t ulData = 0; for (;;) { ulData = read_sensor(); /* 假设的传感器读取 */ if (xQueueSend(xDataQueue, &ulData, 0) != pdPASS) { /* 队列满,丢弃并计数,不要阻塞采集 */ g_ulDropCount++; } vTaskDelayUntil(&xLastWakeTime, xPeriod); } } /* 处理任务:优先级中等 */ static void vProcessingTask(void *pvParameters) { uint32_t ulRecv = 0; for (;;) { if (xQueueReceive(xDataQueue, &ulRecv, portMAX_DELAY) == pdPASS) { process_data(ulRecv); } } } /* 通信任务:优先级最低,共享串口需要互斥 */ static void vCommTask(void *pvParameters) { for (;;) { if (xSemaphoreTake(xUartMutex, pdMS_TO_TICKS(100)) == pdTRUE) { xil_printf("Comm task running\r\n"); xSemaphoreGive(xUartMutex); } vTaskDelay(pdMS_TO_TICKS(500)); } } int main(void) { xil_printf("FreeRTOS on ZYNQ start\r\n"); xDataQueue = xQueueCreate(16, sizeof(uint32_t)); xUartMutex = xSemaphoreCreateMutex(); if (xDataQueue == NULL || xUartMutex == NULL) { xil_printf("RTOS object create failed\r\n"); return -1; } xTaskCreate(vAcquisitionTask, "Acq", 512, NULL, 4, &xAcqTaskHandle); xTaskCreate(vProcessingTask, "Proc", 1024, NULL, 3, &xProcTaskHandle); xTaskCreate(vCommTask, "Comm", 512, NULL, 2, &xCommTaskHandle); vTaskStartScheduler(); /* 正常情况下不会执行到这里 */ xil_printf("Scheduler returned, out of heap?\r\n"); for (;;); }这段代码里有几个设计决策值得说清楚。
采集任务用vTaskDelayUntil而不是vTaskDelay。前者是绝对延时,能保证严格的周期;后者是相对延时,任务执行时间波动会导致周期漂移。采集类任务对周期稳定性要求高,必须用vTaskDelayUntil。
队列发送用 0 超时。采集任务不能被队列阻塞,否则会错过下一个采集点。队列满时直接丢弃并计数,这是实时系统的常见取舍——宁可丢数据也不能破坏时序。
串口加互斥锁。多个任务同时调xil_printf会输出交错乱码,用互斥量保护是标准做法。注意xil_printf本身不是线程安全的,即使加了锁,也要确认底层 UART 驱动没有全局状态冲突。
4.2 栈大小的估算方法
栈大小填多少是新手最头疼的问题。填小了栈溢出,填大了浪费内存。我的经验估算法是:
- 纯计算、无函数调用的任务:128 字(512 字节)起步
- 调用
xil_printf的任务:至少 512 字,因为 printf 系列函数栈开销大 - 有递归或大局部数组的任务:按数组大小加 256 字余量
更靠谱的办法是运行时测量。FreeRTOS 提供uxTaskGetStackHighWaterMark,返回任务运行至今栈使用的历史最小值(剩余量)。在调试阶段定期打印这个值,就能知道真实用量:
UBaseType_t uxHighWaterMark; uxHighWaterMark = uxTaskGetStackHighWaterMark(xAcqTaskHandle); xil_printf("Acq task stack free: %u words\r\n", uxHighWaterMark);如果这个值长期低于 50 字,就该加栈了。我一般留 30% 以上余量。
4.3 中断与 FreeRTOS 的配合
ZYNQ 上外设中断很常用,比如定时器中断、DMA 完成中断。在 FreeRTOS 环境里,中断服务函数(ISR)里能调用的 API 是有限制的,必须用带FromISR后缀的版本。
static void vTimerISR(void *CallBackRef, u32 StatusEvent) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; /* 从中断里给队列发数据,必须用 FromISR 版本 */ xQueueSendFromISR(xDataQueue, &ulEvent, &xHigherPriorityTaskWoken); /* 如果唤醒了更高优先级任务,请求切换 */ portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }portYIELD_FROM_ISR这行不能省。它的作用是:如果中断唤醒了比当前任务优先级更高的任务,就在中断返回时立即切换过去,保证实时性。省掉它,高优先级任务要等到下一个 tick 才能运行,响应延迟可能达到毫秒级。
另外,ZYNQ 的中断优先级配置要和 FreeRTOS 的configMAX_API_CALL_INTERRUPT_PRIORITY协调。高于这个阈值的中断不受 RTOS 管理,不能调用任何 FreeRTOS API。这个参数在 Cortex-A9 上的处理方式和 Cortex-M 不同,A9 用的是 GIC,配置时要注意中断号到优先级的映射。
5. 编译、下载与调试:Vitis 2023.2 实操全流程
5.1 编译与常见报错处理
工程建好后直接Build。Vitis 2023.2 的编译输出在Debug/目录下,最终生成.elf文件。常见报错有几类:
找不到FreeRTOS.h:说明 BSP 没生成成功,或者应用工程没关联到正确的 BSP。检查平台工程的 BSP 是否编译通过,应用工程的Project References是否勾选了平台。
undefined reference to xxx:通常是 BSP 里没启用对应的库。比如用了xil_printf但没启用 stdout,或者用了 DMA 但 BSP 里没勾选 DMA 驱动。
堆溢出警告:链接阶段如果提示.heap或.stack段放不下,说明 DDR 配置的可用内存太小,或者configTOTAL_HEAP_SIZE设得超过了实际可用内存。
5.2 JTAG 下载与“不识别芯片”排查
热词里有人问“Vitis 下载调试的时候不识别芯片”,这个问题我遇到过好几次,原因基本集中在三个地方:
第一,下载器驱动没装或被占用。Windows 下装 Vitis 时如果没勾选 Cable Drivers,设备管理器里会显示未知设备。解决办法是到 Vitis 安装目录下的data\xicom\cable_drivers\nt64\手动运行安装脚本。
第二,JTAG 链配置错误。ZYNQ 的 JTAG 链上通常有 ARM DAP 和 FPGA TAP 两个器件。Vitis 的调试配置里要选对目标。如果只识别到 FPGA 没识别到 ARM,检查 PS 端是否正常上电、复位是否释放。
第三,板子供电或启动模式问题。ZYNQ 的启动模式拨码如果设成从 QSPI 或 SD 启动,JTAG 调试时可能被已有程序干扰。调试阶段建议拨到 JTAG 模式,或者先按住复位再连接。
排查顺序我一般是这样:先看设备管理器有没有识别到下载器,再用 Vivado 的 Hardware Manager 测试 JTAG 链能否扫到器件,最后才在 Vitis 里配置调试。Vivado 能扫到而 Vitis 扫不到,基本是 Vitis 调试配置的问题。
5.3 调试技巧:断点、变量观察与串口辅助
Vitis 的调试器基于 GDB,功能挺全。几个我常用的技巧:
条件断点。在循环里调试时,普通断点会疯狂命中。右键断点设条件,比如ulCount == 100,只在满足条件时停下。
观察结构体变量。热词里有人问“调试模式如何显示结构体变量”,Vitis 的 Expressions 窗口默认就能展开结构体。如果显示不全,检查编译时是否开了-g优化级别。-O2优化会打乱变量和代码的对应关系,调试阶段建议用-O0或-Og。
串口辅助调试。JTAG 调试会暂停 CPU,破坏实时性,有些时序问题在断点下根本复现不了。这时候串口打印就是最好的工具。用xil_printf输出关键状态,配合 PC 端的串口调试助手(比如 sscom)观察。注意xil_printf不支持浮点,要打印浮点得用printf并启用浮点支持,代价是代码体积暴涨。
FreeRTOS 感知调试。Vitis 2023.2 对 FreeRTOS 有一定支持,能在调试时看到任务列表和状态。如果没显示,检查 BSP 里是否启用了configUSE_TRACE_FACILITY和configUSE_STATS_FORMATTING_FUNCTIONS。
5.4 固化到 Flash 的注意事项
调试通了之后要固化到 QSPI Flash,让板子脱机运行。热词里有人问“ZYNQ 7020 使用 JTAG 固化 Flash 时必须使用 DDR 吗”,答案是:固化过程本身不强制用 DDR,但你的应用程序如果用到了 DDR,那 FSBL 就必须初始化 DDR。
固化流程是:FSBL + bitstream + 应用程序 打包成 BOOT.bin,通过 JTAG 烧到 QSPI。FSBL 负责初始化 DDR、加载 bitstream、搬运应用程序。如果应用程序链接地址在 DDR 里,FSBL 必须先跑 DDR 初始化代码。这个初始化代码是 Vivado 导出 XSA 时根据 PS 配置自动生成的,一般不用手写。
固化时容易踩的坑:BOOT.bin 里各部分的顺序和地址必须和 FSBL 里的分区表一致。用 Vitis 的Create Boot Image工具生成时,它会自动处理,但如果你手动改过分区,就要仔细核对。
6. 常见问题速查与避坑经验
6.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 调度器启动后无输出 | 堆不足 / 栈溢出 / 时钟配置错 | 查configTOTAL_HEAP_SIZE、栈水位、configCPU_CLOCK_HZ |
| 任务不切换 | 优先级配置相同且无阻塞 | 检查任务是否有vTaskDelay或阻塞调用 |
| 串口输出乱码 | 波特率不匹配 / 多任务竞争 | 核对波特率、加互斥锁 |
| 下载不识别芯片 | 驱动 / JTAG 链 / 启动模式 | 设备管理器、Vivado Hardware Manager |
| 延时时间不对 | configCPU_CLOCK_HZ填错 | 按实际 PS 频率修正 |
| 运行一段时间死机 | 内存碎片 / 栈溢出 | 换 heap_5、开栈溢出检查 |
| 中断里调用 API 崩溃 | 用了非 FromISR 版本 | 全部换成xxxFromISR |
6.2 几条用血换来的经验
第一,开发阶段永远开着栈溢出检查和 malloc 失败钩子。这两个钩子函数里不要做复杂操作,打个标记或者点个灯就行。等产品稳定了再关。
第二,vTaskDelay(0)不等于让出 CPU。它只是让任务进入就绪态等待下一次调度,如果同优先级没有其他任务,它还会继续跑。真正想让出 CPU 用taskYIELD()。
第三,队列传递大结构体时传指针要小心。如果传的是局部变量的指针,发送方函数返回后指针就失效了。要么传值(结构体不大时),要么用静态/全局缓冲区,要么用内存池。
第四,优先级反转要用互斥量的优先级继承来防。普通二值信号量没有优先级继承,高优先级任务可能被低优先级任务长期阻塞。共享资源保护一律用xSemaphoreCreateMutex。
第五,调试 FreeRTOS 问题时,先关掉所有优化。-O2下断点位置会漂移,变量可能被优化掉,看门狗可能误触发。定位到问题后再逐步开优化验证。
6.3 关于 FreeRTOS 学习路径的建议
热词里“freertos 面试题”“freertos 内核源码深度解析”出现频率很高,说明不少人在往深里学。我的建议是:先把任务、队列、信号量、互斥量这四个用熟,能独立设计一个多任务系统;然后再去看调度器源码,理解就绪列表、延时列表、优先级位图这些机制。上来就啃源码容易劝退,因为没有实际场景支撑,很多设计决策你体会不到它的必要性。
ZYNQ 平台还有个额外的好处:你可以把 FreeRTOS 任务和 PL 端的硬件加速结合起来。比如采集任务触发 PL 的 DMA,DMA 完成中断唤醒处理任务,处理任务把结果通过队列发给通信任务。这套流水线在裸机下写起来很痛苦,用 FreeRTOS 就清晰很多。
最后分享一个我常用的调试小技巧:在vApplicationIdleHook里翻转一个 GPIO,用示波器看这个引脚的高低电平比例,就能直观知道 CPU 的空闲率。空闲率高说明任务负载轻,空闲率接近零说明系统快跑满了,该考虑优化或加核了。这个方法比任何软件统计都直观,而且几乎不占开销。