☰
GD32 移植 FreeRTOS 实战:从裸机到多任务稳定运行
2026/9/28 4:36:53 网站建设 项目流程

FreeRTOS 在 GD32 上的移植,很多人第一次做的时候都会卡在同一个地方:工程编译通过了,但任务调度器一启动就 HardFault,或者串口一个字符都打印不出来。我前后在 GD32F303、GD32F407、GD32E230 这几颗片子上都跑过 FreeRTOS,踩过的坑基本覆盖了从时钟配置到中断优先级再到堆栈分配的各个环节。这篇内容就把整套流程拆开讲清楚,从建立裸机工程开始,一直到多任务稳定运行,每一步为什么这么做、哪里最容易出问题,都会说明白。适合已经有 Keil 和一点单片机基础、准备把 FreeRTOS 落到 GD32 上的朋友,也适合从 STM32 转过来、发现两者并不完全一样的人。

1. 移植之前先把裸机工程跑通

1.1 为什么不能直接拿 STM32 的工程改

GD32 和 STM32 在引脚和大部分外设寄存器上确实高度兼容,很多人就想着把 STM32 的 FreeRTOS 工程直接改个芯片型号就完事。我一开始也这么干过,结果编译能过,运行就是不稳定。原因在于两者虽然同属 Cortex-M 内核,但 Flash 等待周期、时钟树结构、启动文件里的向量表定义都有差异。GD32 的 Flash 在更高主频下需要插入更多等待周期,如果沿用 STM32 的配置,高频下取指会出错,表现出来就是随机的 HardFault。

所以正确的做法是:先不要碰 FreeRTOS,用 GD32 官方的固件库或者 Embedded Builder 生成一个干净的裸机工程,把时钟、串口、GPIO 都调通,确认能正常打印、能点灯,再往上加操作系统。这一步看起来慢,实际上省掉了后面大量"到底是系统问题还是底层问题"的排查时间。

1.2 建立标准裸机工程的关键配置

用 Keil 建 GD32 工程,核心是这几件事:选对器件包(GigaDevice 的 DFP)、加对启动文件(startup_gd32fxxx.s)、配好系统时钟。以 GD32F303 跑 120MHz 为例,外部晶振一般是 8MHz,经过 PLL 倍频到 120MHz。这里有个容易忽略的点:GD32 的 PLL 配置和 STM32 不完全一样,不能照抄。下面是一个典型的时钟配置思路:

/* 使能外部高速晶振 */ RCU_CTL |= RCU_CTL_HXTALEN; while(!(RCU_CTL & RCU_CTL_HXTALSTB)); /* 配置 PLL:8MHz 晶振,预分频后倍频到 120MHz */ RCU_CFG0 &= ~(RCU_CFG0_PLLSEL | RCU_CFG0_PLLMF | RCU_CFG0_PREDV0); RCU_CFG0 |= RCU_PLLSRC_HXTAL_IRC48M; RCU_CFG0 |= RCU_PLL_MUL15; /* 8M * 15 = 120M */

配置完之后一定要用示波器或者 MCO 引脚把系统时钟输出出来量一下,确认真的是 120MHz,而不是你以为的 120MHz。我遇到过 PLL 没锁定的情况,程序照样往下跑,但主频只有内部 RC 的 8MHz,后面所有基于主频计算的延时全错。

1.3 串口打印是移植过程中的"眼睛"

在移植 FreeRTOS 的过程中,串口打印几乎是唯一的调试手段。建议在裸机阶段就把串口重定向做好,让printf能直接输出。GD32 的 USART 配置和 STM32 类似,但要注意 RCU 里外设时钟的使能位名字不同。重定向的代码如下:

int fputc(int ch, FILE *f) { usart_data_transmit(USART0, (uint8_t)ch); while(RESET == usart_flag_get(USART0, USART_FLAG_TBE)); return ch; }

提示:重定向之前确认在 Keil 的 Target 选项里勾选了 Use MicroLIB,否则printf会依赖标准库,体积大且可能跑不起来。

裸机工程跑通的标志很简单:LED 能按预期闪烁,串口能稳定打印,延时函数的时间是准的。这三条都满足,再进入下一步。

2. FreeRTOS 源码的取舍与工程接入

2.1 到底需要拷贝哪些文件

FreeRTOS 的源码包看起来文件很多,但真正需要放进工程的核心就几类。我一般这样组织目录:

目录内容是否必需
FreeRTOS/Sourcetasks.c、queue.c、list.c、timers.c 等必需
FreeRTOS/Source/portable/RVDS/ARM_CM3port.c必需(M3/M4 内核)
FreeRTOS/Source/portable/MemMangheap_4.c 等必需,选一个
FreeRTOS/Source/include所有头文件必需

portable 目录下的编译器分支要选对。Keil 用的是 ARMCC,对应 RVDS 目录;如果你用 GCC 或者新版 Keil 的 AC6 编译器,要换成 GCC 目录。这个选错了会直接报一堆汇编语法错误。内核分支方面,GD32F303 是 Cortex-M3,选 ARM_CM3;GD32F407 是 M4,选 ARM_CM4F(带浮点单元的那个)。

2.2 heap 文件怎么选不是随便挑的

FreeRTOS 提供了 heap_1 到 heap_5 五种内存管理方案,很多人随手选 heap_4 就完事,其实这里值得想一下。heap_1 最简单,只能分配不能释放,适合任务和队列在启动时就全部创建好、之后不再动态创建的场景,代码量最小。heap_4 支持释放和碎片合并,是最常用的。heap_5 支持多块不连续的内存区域,适合内存分散的芯片。

对于 GD32 这种 SRAM 不算特别大的片子,我一般用 heap_4,并且把configTOTAL_HEAP_SIZE设成实际需要再留 20% 余量。设太大浪费 SRAM,设太小创建任务时直接返回失败。以 GD32F303 的 48KB SRAM 为例,如果跑三四个任务加两个队列,configTOTAL_HEAP_SIZE设 10KB 到 12KB 比较稳妥。

2.3 FreeRTOSConfig.h 是移植的灵魂

这个头文件决定了整个系统的行为,是移植里最需要逐项确认的地方。几个关键配置:

#define configUSE_PREEMPTION 1 #define configCPU_CLOCK_HZ (120000000UL) #define configTICK_RATE_HZ ((TickType_t)1000) #define configMAX_PRIORITIES (32) #define configMINIMAL_STACK_SIZE ((unsigned short)128) #define configTOTAL_HEAP_SIZE ((size_t)(12 * 1024)) #define configMAX_TASK_NAME_LEN (16) #define configUSE_16_BIT_TICKS 0

configCPU_CLOCK_HZ必须和实际系统主频一致,这个值错了,vTaskDelay的延时就会整体偏掉。configTICK_RATE_HZ设 1000 表示 1ms 一个 tick,这是最常用的值。configUSE_16_BIT_TICKS设 0 表示用 32 位 tick 计数,对于长时间运行的系统必须这样设,否则 tick 会很快溢出。

还有几个和中断相关的宏,是后面中断优先级配置的基础,这里先列出来:

#define configKERNEL_INTERRUPT_PRIORITY (15 << 4) #define configMAX_SYSCALL_INTERRUPT_PRIORITY (5 << 4)

这两个值后面会详细解释为什么这么设。

3. 中断优先级:移植失败的头号元凶

3.1 Cortex-M 中断优先级的反直觉设计

这是我在 GD32 上移植 FreeRTOS 时踩得最狠的一个坑,也是绝大多数人第一次移植失败的根本原因。Cortex-M 的中断优先级有个反直觉的地方:数值越小,优先级越高。0 是最高优先级,15(对于 4 位优先级)是最低。

FreeRTOS 需要一个"系统可管理"的中断优先级范围。高于configMAX_SYSCALL_INTERRUPT_PRIORITY的中断(也就是数值更小的),不受 FreeRTOS 的临界区保护,不能调用任何 FreeRTOS 的 API;低于它的中断才能安全调用FromISR结尾的 API。这个机制是为了保证内核临界区的原子性。

3.2 GD32 上优先级分组必须配对

GD32 和 STM32 一样,用 NVIC 的优先级分组来划分抢占优先级和子优先级。FreeRTOS 要求所有优先级位都用作抢占优先级,也就是分组要设成 NVIC_PRIORITY_GROUP_4(4 位全给抢占)。如果分组设错了,比如设成了 2 位抢占 2 位子优先级,那么你算出来的优先级值和 FreeRTOS 的预期就对不上,临界区保护会失效,表现出来就是偶发的数据错乱或者死机。

/* 必须在 FreeRTOS 启动前设置,且全工程只设一次 */ nvic_priority_group_set(NVIC_PRIGROUP_PRE4_SUB0);

设置好分组之后,任何要调用 FreeRTOS API 的中断,其优先级数值必须大于等于 5(也就是优先级要低于或等于configMAX_SYSCALL_INTERRUPT_PRIORITY)。比如串口接收中断如果要在里面发信号量,就得设成 5 到 15 之间。

3.3 一个真实的 HardFault 排查过程

我印象很深的一次,串口中断里调用了xQueueSendFromISR,程序跑几分钟就 HardFault 一次。排查过程是这样的:先怀疑堆栈溢出,把任务堆栈加大,没用;再怀疑队列满了,加了判断,还是崩。最后用调试器在 HardFault 处理函数里看寄存器,发现出错时正好在中断上下文里操作队列。

问题就出在串口中断的优先级设成了 4,比configMAX_SYSCALL_INTERRUPT_PRIORITY(5)还高。这个优先级的中断不受内核临界区保护,在里面操作队列,恰好撞上内核正在改队列链表,链表指针就乱了。把串口中断优先级改成 6 之后,问题彻底消失。

注意:判断一个中断能不能调用 FreeRTOS API,看它的优先级数值是否大于等于configMAX_SYSCALL_INTERRUPT_PRIORITY右移 4 位后的值。这个换算很容易搞错,建议直接写个宏来算。

4. 启动流程与第一个任务的落地

4.1 从 main 到调度器启动发生了什么

裸机程序是main里一个 while 循环跑到底,而 FreeRTOS 的main只负责初始化硬件、创建任务,最后调用vTaskStartScheduler()把控制权交给调度器。这个函数一调用就不会返回(除非堆内存不够导致创建空闲任务失败)。

int main(void) { /* 硬件初始化 */ system_clock_config(); usart_config(); nvic_priority_group_set(NVIC_PRIGROUP_PRE4_SUB0); /* 创建任务 */ xTaskCreate(led_task, "LED", 128, NULL, 2, NULL); xTaskCreate(uart_task, "UART", 256, NULL, 3, NULL); /* 启动调度器,正常情况下不会返回 */ vTaskStartScheduler(); /* 只有内存不足导致调度器启动失败才会走到这里 */ while(1); }

调度器启动时会自动创建空闲任务(优先级 0)和可选的定时器任务。空闲任务的堆栈由configMINIMAL_STACK_SIZE决定,这个值不能设太小,否则空闲任务里跑钩子函数会溢出。

4.2 任务堆栈大小到底怎么估

任务堆栈大小是新手最容易设错的地方。xTaskCreate里的堆栈参数单位是"字"(word),不是字节。在 32 位 MCU 上,128 表示 128 个字,也就是 512 字节。很多人以为 128 是字节,结果栈严重不足。

估算方法:先给一个偏大的值(比如 256 字),跑起来之后用uxTaskGetStackHighWaterMark()查剩余堆栈。这个函数返回任务运行过程中堆栈剩余的最小值,单位还是字。如果返回值很小(比如小于 20),说明栈快满了,要加大;如果剩余很多,可以适当减小。

UBaseType_t watermark = uxTaskGetStackHighWaterMark(NULL); printf("stack free: %u words\r\n", watermark);

我一般会在调试阶段定期打印这个值,等系统稳定运行一段时间后,根据实际余量把堆栈调到合理大小。注意printf本身很吃栈,如果任务里用了printf,堆栈要给足,否则打印的时候就会溢出。

4.3 第一个任务跑起来后的验证清单

任务能跑起来不代表移植就成功了,要逐项验证:

  • 多个不同优先级的任务能否按预期抢占
  • vTaskDelay的延时是否准确(用示波器量 LED 翻转周期)
  • 队列、信号量能否正常收发
  • 中断里调用FromISRAPI 是否稳定
  • 长时间运行(几小时)是否会出现 HardFault

这几项都过了,才算真正移植成功。尤其是最后一项,很多隐藏的优先级或堆栈问题要跑一段时间才会暴露。

5. 那些文档里不会写的实战细节

5.1 系统节拍中断的归属问题

FreeRTOS 需要一个周期性的节拍中断来驱动调度,默认用的是 SysTick。但 GD32 的固件库里,SysTick_Handler可能已经被库函数占用了(比如做延时用)。如果两边都用了 SysTick,就会冲突。

解决办法有两个:一是把 FreeRTOS 的节拍源改成其他定时器(比如 TIMER2),在FreeRTOSConfig.h里定义configTICK_SOURCE相关宏;二是把库里的 SysTick 延时改成用其他方式实现。我一般选第一种,用一个独立的定时器做节拍,避免和库函数抢 SysTick。改的时候要注意定时器的中断优先级也要设成最低(15),因为节拍中断会调用内核 API。

5.2 临界区保护和 Flash 等待的配合

GD32 在高主频下 Flash 需要插入等待周期,这个在裸机阶段就配好了。但要注意,FreeRTOS 的临界区会关中断,关中断期间如果执行 Flash 里的代码,取指延迟是固定的,不会出问题。真正要注意的是,临界区里不要做耗时操作,否则会拉长中断响应延迟,影响系统实时性。

5.3 从 STM32 工程迁移时的隐藏差异

如果你手上有一个 STM32 的 FreeRTOS 工程想迁到 GD32,除了前面说的时钟和启动文件,还有几个地方要改:中断向量表的名字(GD32 的中断处理函数名和 STM32 不完全一样)、外设寄存器的位定义(大部分兼容但有个别差异)、以及 Flash 编程相关的操作。我建议不要直接改,而是用 GD32 的库重新建工程,把应用层的任务代码搬过去,这样最干净。

5.4 调试 FreeRTOS 时的几个实用技巧

在 Keil 里调试 FreeRTOS,可以装 FreeRTOS 的调试插件,能在 Watch 窗口里直接看到所有任务的状态、优先级、堆栈使用情况,非常直观。另外,把configASSERT打开,在参数错误时直接断下来,比事后排查高效得多。configASSERT的定义可以指向一个死循环加打印,方便定位。

#define configASSERT(x) if((x) == 0) { printf("assert failed\r\n"); while(1); }

还有一点,HardFault 处理函数里可以把出错时的堆栈内容打印出来,配合__asm读取 LR 和 PC,能快速定位是哪条指令出的错。这个技巧在排查内存越界和空指针时特别有用。

6. 让系统真正稳定运行的收尾工作

移植完成、任务能跑,只是第一步。要让系统在真实产品里稳定运行,还有几件事要做。首先是堆栈溢出检测,把configCHECK_FOR_STACK_OVERFLOW设成 2,并实现vApplicationStackOverflowHook,一旦某个任务栈溢出就能立刻发现。其次是空闲任务钩子,可以在里面做低功耗处理或者喂狗。最后是内存分配失败的钩子vApplicationMallocFailedHook,动态创建对象失败时能及时报警。

void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { printf("stack overflow: %s\r\n", pcTaskName); while(1); }

我个人在实际项目里的体会是,FreeRTOS 在 GD32 上的移植难点从来不在"能不能编译过",而在"跑起来之后稳不稳"。时钟配置、中断优先级、堆栈大小这三样,任何一样没弄对,系统都会在某个不确定的时刻出问题。把这三样在移植阶段就彻底搞清楚,后面开发应用逻辑会省心很多。另外,养成用uxTaskGetStackHighWaterMark定期检查堆栈的习惯,很多偶发死机都能提前发现。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询