1. 项目概述与技术选型思路
1.1 为什么是STM32G4而不是F1或者F4
先说结论:如果你手里正好有一块G4系列开发板(比如NUCLEO-G474RE或者G431RB),拿它入门FreeRTOS完全没问题。G4这颗芯片在STM32家族里属于偏“硬核”的存在,主频170MHz,带FPU,内置数学加速单元,还有一堆高级定时器和运放比较器,平时多用来做数字电源、电机控制这类对实时性要求很高的场景。
但放到我们今天这个“多任务LED控制”的例子里,G4其实属于“杀鸡用牛刀”。那为什么还要拿它来写这篇东西?因为很多朋友直接上手就是G4板子,网上大量教程还停留在F103时代,照着F103的步骤在CubeMX里折腾G4,经常会在时钟配置、外设型号差异这些地方卡半天。我写这篇就是想告诉你,G4跑FreeRTOS跟F1/F4没有本质区别,核心思路一通百通,但有几个G4专属的坑必须先避开。
另外说个题外话,G4的Flash在写入32位数据时有些注意事项,如果你后面要做Bootloader或者OTA升级,会发现G4的Flash编程模型跟F1不太一样。这里不展开,做多任务用不上,但先给你提个醒,别到时候拿着F1的Flash操作代码往G4上直接搬。
1.2 五分钟搞定?先把工具的坑填平
标题里写了“五分钟搞定”,这话得加个前提:你的CubeMX和Keil环境已经装好并且能正常建工程。很多新手第一次跑这个例程,时间全浪费在装软件和环境配置上了。我实际测过,CubeMX从官网下载安装包、装好G4的固件包(STM32CubeG4),再到Keil里装好对应的Device Pack,这步快的人十分钟能搞定,慢的人能折腾一晚上。卡点基本就两个:一是CubeMX下载慢,这个只能耐心等或者找靠谱的镜像;二是Keil的Pack安装失败,常见原因是网络问题或者Keil版本太老。
我建议的版本组合是这样的:
| 工具 | 推荐版本 | 备注 |
|---|---|---|
| STM32CubeMX | 6.9以上 | 6.x版本界面差异不大,新版本对G4支持更完整 |
| STM32CubeG4固件包 | 1.5.x以上 | 在CubeMX里在线安装,或者官网手动下载后导入 |
| Keil MDK | 5.30以上 | 太低版本对G4支持有问题,特别是CMSIS版本太老 |
| STM32G4 Device Pack | 最新版 | Keil Pack Installer里安装,确保能识别G474/G431 |
注意:Keil安装和注册授权的问题属于商业软件范畴,这里不做讨论,你只要保证自己的开发环境是合法可用状态就行。我只说技术路线。
2. CubeMX工程配置细节与参数选择
2.1 新建工程时的几个关键选项
打开CubeMX,选择芯片型号。如果你用的是NUCLEO-G474RE,直接搜“G474RE”就行。这里有两个容易踩的坑:
第一个是芯片封装和型号后缀。G474RE和G474RET6是同一个东西,但CubeMX搜索时输入关键词要准确,别选成G474RC或者G473,引脚数量不同,后面配外设时会发现管脚对不上。我见过不止一个朋友在这个环节就选错了片子,后面全部白搭。
第二个是时钟源选择。新建工程后会弹出“Initialize all peripherals with their default mode”之类的提示,先别管。第一步去System Core -> RCC,把HSE(高速外部时钟)设为Crystal/Ceramic Resonator。如果你用的是NUCLEO板,板载ST-Link上带了一个24MHz的晶振给芯片做HSE输入,但注意:G4的HSE最大支持24MHz,而很多F1板子上的HSE是8MHz,核对了再配。
然后你可能会想,直接用手头板子自带的内部时钟不行吗?可以,HSI16(16MHz内部RC)也能让系统跑起来,但后面你要是用到USB、定时器做精确延时这类功能,内部RC的精度不够,建议从一开始就养成用外部晶振的习惯。
2.2 时钟树配置:核心频率倍频到170MHz
时钟树是CubeMX里最容易被跳过但又最重要的一步。G4系列最高主频170MHz,默认情况下系统时钟可能是16MHz(HSI16直通),你不去动时钟树,系统跑起来也能点灯,但性能完全发挥不出来。
我的配置参数供参考:
- HSE:24MHz(NUCLEO板载晶振)
- PLLM:除以6(24 / 6 = 4MHz)
- PLLN:乘以85(4 x 85 = 340MHz)
- PLLP:除以2(340 / 2 = 170MHz)
- 系统时钟源选择:PLLCLK
最后在Clock Configuration页面里能看到CPU Clock显示为170MHz,那就对了。
这个倍频计算逻辑我解释一下:G4的PLL输入范围要求1~16MHz,所以24MHz的HSE先要降下来,PLLM=6得到4MHz,满足输入范围;PLLN可以在8~128之间选,我们选85得到340MHz的VCO输出;最后PLLP分频到170MHz。这些都是G4参考手册里的硬性参数,CubeMX会在你配置时自动校验合法性,如果填了非法值它会提示红色警告。
有些人喜欢直接把PLLN填一个很大的数字让频率飙上去,千万别这么做。G4的VCO输出限制是128~512MHz,超出范围轻则系统不稳定,重则芯片直接不启动。
2.3 GPIO配置:三个LED三个引脚
今天的实验是三路LED独立闪烁控制,我用的是NUCLEO-G474RE板载的三个LED:
| LED | 引脚 | 说明 |
|---|---|---|
| LED1(绿色) | PA5 | 板载,低电平点亮 |
| LED2(黄色) | PB13 | 板载,低电平点亮 |
| LED3(红色) | PB14 | 板载,低电平点亮 |
如果你手里是别的板子,或者你想外接LED,记住一个原则:通过一个限流电阻(330Ω到1kΩ)把LED接到GPIO上。STM32的GPIO推挽输出能力大概在20mA左右,直接驱动LED没问题,但不加电阻会烧LED甚至损伤引脚。
在CubeMX里,把PA5、PB13、PB14都配置为GPIO_Output,初始电平设为High(为什么?因为板载LED是低电平点亮,初始拉高即熄灭,避免上电瞬间LED全亮)。
这里有个细节很多人不管:GPIO的Maximum output speed。LED控制对这种低速信号来说,Low或者Medium都行,但如果你选的引脚同时要复用其他高速功能(比如定时器PWM),速度等级会影响信号的上升沿质量。今天这个实验无脑选Low就好,省得引入信号完整性问题。
2.4 中间件选择:开启FreeRTOS
在Middleware and Software Packs里找到FREERTOS,Interface选CMSIS_V1还是CMSIS_V2?这个纠结了很多新手,我直接给答案:新工程一律选CMSIS_V2。V2是ARM官方的CMSIS-RTOS2标准,封装更清晰,CubeMX生成的代码更规范,而且Keil的RTX5调试插件支持也更好。V1是老的CMSIS-RTOS v1标准,兼容老代码用,新项目没必要。
然后下面的Configuration页,有几个参数值得说一下:
- Color for Memory:不用管,CubeMX生成的一个标记。
- USE_NEWLIB_REENTRANT:如果你的代码里用了printf、malloc这类C库函数,建议Enable。我今天这个例程只用HAL库和FreeRTOS API,不需要开。但如果开了会占用更多RAM,不开又用printf容易出问题,具体项目按需来。
- TOTAL_HEAP_SIZE:默认给的是3072字节,太小了。我建议直接改到8192(8KB)以上。G4的SRAM有128KB,给FreeRTOS堆8KB完全没压力。堆太小会导致创建任务失败,这是新手最容易碰到的问题之一,后面我在问题排查里细说。
- USE_PREEMPTION(抢占式调度):保持Enable。抢占式调度是多任务实时性的基础,不开启的话任务切换要靠主动让出CPU,LED闪烁这种低优先级任务没问题,但体现不出FreeRTOS的价值。
- USE_TIME_SLICING(时间片调度):Enable,这样同优先级任务可以轮流执行。
- USE_MUTEXES:这次例程用不到,但建议勾上。反正编译进去不占什么资源,以后用得上。
2.5 任务配置:三个任务三种节奏
在Tasks and Queues页面里添加任务,我建了三个:
| 任务名 | 优先级 | 栈大小(Words) | 功能 |
|---|---|---|---|
| TaskLED1 | Normal(1) | 128 | 控制LED1,间隔200ms翻转 |
| TaskLED2 | Normal(1) | 128 | 控制LED2,间隔500ms翻转 |
| TaskLED3 | Normal(1) | 128 | 控制LED3,间隔1000ms翻转 |
这三个任务逻辑一样,就是不同的延时节奏,这样LED1快速闪烁、LED2中速、LED3慢速,视觉上很直观地体现多任务并行。优先级我故意设成一样,方便演示时间片轮转的效果。如果你想看抢占式调度的效果,可以把某个任务优先级调高,它就会先执行,调度顺序差别肉眼可见。
栈大小128个字等于512字节,对LED这么简单的任务绰绰有余。但注意CubeMX这里默认可能是128,如果你任务里要定义大数组或者调用printf之类会消耗栈空间的函数,就要加大。栈溢出是FreeRTOS最常见的坑,后面我详细说怎么排查。
3. 代码逻辑分析与关键函数解读
3.1 CubeMX生成了什么代码,哪些能碰哪些不能碰
CubeMX配置完,点击GENERATE CODE,它会生成一个MDK-ARM工程。打开Keil编译,如果你前面的配置没问题,一次能过。
这时候你打开main.c,会发现代码结构跟手写的工程不太一样。核心逻辑在三个地方:
- main()函数:硬件初始化,然后调用
osKernelStart()启动FreeRTOS调度器。注意main函数里看不到我们自己的业务逻辑,业务逻辑都在任务函数里。 - app_freertos.c:这是FreeRTOS相关代码的大本营,CubeMX生成的默认任务函数在
MX_FREERTOS_Init()里创建,然后调用StartDefaultTask()。 - 任务函数:CubeMX默认只会生成一个
StartDefaultTask示例任务,我们自己的三个任务要自己补全。
这里有个新手经常犯的错误:直接去改main.c里CubeMX生成的初始化代码,或者改app_freertos.c里那些标记为“USER CODE BEGIN/END”之外的区域。结果下次从CubeMX重新生成代码,手动改的内容被全部覆盖,又得重新弄。正确做法是:你自己的代码都写在USER CODE段里面,或者干脆新建一个.c文件放自己的任务代码,CubeMX生成的文件不要去动它。
3.2 手写任务代码:vTaskDelay与HAL_Delay的混用陷阱
在主循环或者任务函数里,最核心的延时函数有两个:HAL_Delay()和vTaskDelay()。这两个函数功能相似,但在FreeRTOS环境里用起来有本质区别。
HAL_Delay()是死等,CPU在那空转。vTaskDelay()是把当前任务阻塞,让出CPU给其他任务。
听上去vTaskDelay更好对吧?但问题来了:CubeMX生成的HAL库代码内部(比如某些外设驱动里)调用的是HAL_Delay,如果你在任务里也用HAL_Delay,这会导致调度器没办法在延时期间切换任务,多任务就变成伪并发了。
还有个更严重的问题:如果某个中断里调用了HAL_Delay,而中断优先级高于FreeRTOS管理的最高优先级(configMAX_SYSCALL_INTERRUPT_PRIORITY),系统会直接卡死或者跑飞。具体原因涉及FreeRTOS对中断安全API的限定,简单说就是低优先级中断里可以调用FreeRTOS的API,但高优先级中断里不行,HAL_Delay内部用了SysTick,冲突概率极高。
所以今天我们的三个任务函数,统一用osDelay()或者vTaskDelay():
void TaskLED1(void *argument) { for(;;) { HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin); osDelay(200); } } void TaskLED2(void *argument) { for(;;) { HAL_GPIO_TogglePin(LED2_GPIO_Port, LED2_Pin); osDelay(500); } } void TaskLED3(void *argument) { for(;;) { HAL_GPIO_TogglePin(LED3_GPIO_Port, LED3_Pin); osDelay(1000); } }HAL_GPIO_TogglePin用来翻转引脚电平,比分别调用SetPin和ResetPin省事。如果你的板子是低电平点亮,翻转操作天然就兼容,因为任何一次翻转都是从亮到灭或者从灭到亮。
注意在CMSIS_V2接口下,CubeMX生成的代码用的是osDelay()而不是直接调vTaskDelay(),二者本质一样,osDelay内部就是调用vTaskDelay。我建议你直接使用CubeMX生成的这套CMSIS接口,好处是以后你把代码移植到其他RTOS时也能少改几行。
3.3 任务创建函数的返回值判断:免费的调试信息不要浪费
在app_freertos.c里,任务是通过osThreadNew()创建的:
LED1TaskHandle = osThreadNew(TaskLED1, NULL, &LED1Task_attributes); if (LED1TaskHandle == NULL) { Error_Handler(); }CubeMX默认生成的代码已经带上了对返回值的简单判断,如果任务创建失败就进Error_Handler。但Error_Handler里通常是死循环,你并不知道失败原因是什么。我建议你把它改一下,把失败的具体信息打印出来,或者设置一个断点方便调试:
LED1TaskHandle = osThreadNew(TaskLED1, NULL, &LED1Task_attributes); if (LED1TaskHandle == NULL) { /* 任务创建失败,常见原因:堆内存不足 */ __BKPT(0); // 停下来看调用栈 }任务创建失败最典型的原因就是TOTAL_HEAP_SIZE太小。我之前测试过,如果你一个任务栈开512字(2KB),再开几个任务,默认的3072字节堆肯定不够。把堆调到8KB以上,基本就没这个问题了。
不要把printf那套串口调试一开始就引进来,先把逻辑跑通再说。多任务系统出问题时,用调试器看变量和调用栈的效率远高于串口打印。
4. 编译烧录与调试实操
4.1 Keil工程选项里必须检查的三个配置
CubeMX生成完工程,打开Keil不要急着编译。先花30秒检查工程选项(魔术棒图标):
- Device页:确认芯片型号正确,最常见的就是G474RE识别成G474RC或者别的,影响不大但代码运行可能有未知问题。
- Target页:确认ARM Compiler版本,建议用V6.x(默认可能是V5.06),V6编译出来代码更紧凑,但对老代码的兼容性查一些,CubeMX生成的代码在V6下编译没有压力。
- Debug页:确认调试器选的是ST-Link,并点击右边的Settings,如果能看到芯片ID,说明ST-Link连接正常。
这三个检查点,每个都能救命。特别是Debug页,很多朋友板子插上电脑但Keil报“No Target Connected”,十有八九是这里没选对调试器或者驱动没装好。
4.2 编译报错的常见类型与处理思路
CubeMX生成的工程理论上不该有编译错误,但实际操作中总有些意外。我见过的几种典型报错:
错误一:Error: L6218E: Undefined symbol这个一般是CubeMX生成的代码依赖某个库文件没被加进工程,或者是代码中某个函数声明了但没实现。不用急着改代码,先看Keil的Build Output窗口,找到具体是哪个符号未定义,再去搜索它应该从哪来。如果是HAL库的函数,那多半是某个外设的源文件没勾选。CubeMX在Project Manager -> Code Generator里有个“Add necessary library files as reference in the toolchain”选项,确保它是勾选状态。
错误二:Error: Q0147E: Failed to create directory .\obj\...这个报错我之前还真遇到过,就是工程路径有问题。Keil默认会把中间文件放到.\obj目录,如果你的工程路径里有中文或者特殊字符,或者目录权限不对,就会创建失败。解决方法是把整个工程移到纯英文路径下再打开。
错误三:warning: #1-D: last line of file ends without a newline这个是警告不是错误,但提醒你编译器版本对代码格式比较敏感,在源码最后一行加个回车就行。CubeMX生成的代码一般不会有这个问题,但你自己手敲的任务函数文件容易犯。
4.3 烧录运行:第一次看到三个LED各自闪烁
编译通过后,点击LOAD按钮烧录。如果一切顺利,板子上三个LED会以200ms、500ms、1000ms的节奏各自闪烁,互不干扰。
第一次运行看到这个效果,说明FreeRTOS已经在你的G4上跑起来了。这时候你可以做个实验加深理解:在TaskLED1的while循环里加个延时,比如osDelay(1000),你会发现LED1的闪烁频率马上改变了,但另外两个任务不受任何影响。这就是多任务并发的意义——每个任务独立运行,有自己的时间节奏。
如果烧录后板子没反应,或者LED状态不对,先别慌,大概率不是代码逻辑问题而是硬件配置问题。比如GPIO初始电平设置反了(板载LED低电平点亮,你初始设成了Low,那上电就等于全亮),或者时钟配置错了导致系统跑不起来。
5. 常见问题排查与避坑技巧
5.1 任务没有正常运行或系统卡死
这个现象分两种:一种是三个灯都不亮,一种是有灯亮但不闪。
三个灯都不亮,大概率是FreeRTOS调度器没启动成功。检查方式:在osKernelStart()前后各加一个断点,如果执行不到osKernelStart()之后的代码,看看是不是卡在某个硬件初始化里(比如HAL_Init、SystemClock_Config)。如果执行到了但任务还没跑,检查堆大小是否足够创建所有任务。
有灯亮但不闪,说明某个任务创建成功了但卡在了里面。最常见的原因是任务函数里用了while(1)死循环但没有调用任何阻塞函数(延时、等待信号量等),低优先级任务永远占着CPU,其他任务抢不到。FreeRTOS的抢占式调度虽然能让高优先级任务抢占低优先级任务,但如果所有任务优先级一样,谁先运行谁就占着CPU不放。
用调试器暂停运行,然后在Keil的Call Stack窗口看看CPU当前停在哪个函数里,一查一个准。
5.2 栈溢出检测打开之后还是找不到爆栈点
FreeRTOS提供了两套栈溢出检测机制,在FreeRTOSConfig.h里配置:
#define configCHECK_FOR_STACK_OVERFLOW 2设成1表示只在任务切换时检查栈指针是否溢出,设成2表示除了检查栈指针还会检查栈末尾的临界值(canary)。设成2更可靠,但会略微增加系统开销。
当栈溢出被检测到,默认会调用vApplicationStackOverflowHook(),这个钩子函数CubeMX会帮你生成一个弱定义,你可以在这里加个断点或者点亮一个错误LED。
不过说实话,栈溢出检测是“事后诸葛亮”,更实用的方法是在设计阶段就估算好栈大小。经验法则:一个简单的任务(调几个函数、用GPIO翻转)128字完全够;任务里如果定义了一个512字节的数组,栈就得加到至少256字;调printf这类库函数,建议至少512字。
5.3 Keil调试时怎么查看任务运行状态和变量值
热搜词里有朋友问到“keil调试助手里面的debug模式如何显示结构体变量”,这里我顺便讲一下。
在Keil的Debug模式下,Watch窗口可以查看变量的实时值。如果你要查看FreeRTOS内部的结构体(比如任务控制块TCB),直接在Watch窗口输入pxCurrentTCB或者pxReadyTasksLists,然后展开结构体就能看到每个任务的栈指针、优先级、状态等信息。
更直观的方式是使用Keil的RTOS插件:Debug -> RTX RTOS -> System Viewer,它能以图形化方式显示当前所有任务的状态(运行、就绪、阻塞等)。前提是你用的是CMSIS_V2接口,并且Keil能识别到RTOS内核。
这个功能对多任务调试很有用,当你的系统“看起来卡死了”,打开System Viewer一看,哪个任务处于Running状态,哪个任务在阻塞等待,一目了然。比盲猜快得多。
5.4 我在实际操作中踩过的几个坑
说实话,光这一节写两千字都不够,我只挑几个对新手最致命的:
坑一:CubeMX生成的工程在Keil里编译,没有勾选“Use MicroLIB”。这会导致你用printf的时候程序卡死或输出乱码。解决:在Keil魔法棒 -> Target页,勾选Use MicroLIB。这个坑成了很多新手调串口时的噩梦,如果碰到串口输出异常,先检查这一项。
坑二:G4的PA5引脚冲突。NUCLEO-G474RE板子上PA5是蓝色用户按键或者某个传感器的引脚,不同的板子引脚功能定义不一样。别以为所有NUCLEO板的PA5都接LED,看原理图最靠谱。
坑三:调试器下载报错“RDDI-DAP Error”。这通常是芯片处于低功耗模式或者调试口被复用。G4在配置了某些休眠模式后,ST-Link连不上芯片,这时候按住板子上的RESET键再点下载,时机对就能连上。
坑四:GPIO初始电平导致LED上电瞬间闪一下。CubeMX里GPIO初始电平默认是Low,如果你的LED是低电平点亮,那上电瞬间LED会亮一下然后进入程序控制逻辑,视觉效果很不好。解决办法就是把初始电平设置为High。
6. 从点灯到进阶:还能往哪个方向扩展
三路LED闪烁只是FreeRTOS多任务的最小示例,但背后这套“创建任务-设置优先级-分配栈空间-任务间通信”的思维模型是通用的。你可以在G4上继续扩展这些内容:
- 用信号量同步任务:一个任务产生事件,另一个任务等待事件,实现生产者消费者模式。
- 用消息队列传递数据:比如ADC采集任务把数据放进队列,LCD显示任务从队列取数据刷新屏幕。
- 用软件定时器做周期任务:FreeRTOS的软件定时器基于Tick实现,比任务+延时的方案更适合执行周期性的后台任务。
- 增加任务数量:比如加入按键扫描、OLED显示、传感器读取等,每个独立任务,观察它们之间如何抢占CPU。
如果你想让系统更复杂一点,可以试着把LED的闪烁时间不用固定延时,而是用一个全局变量来控制,再开个串口指令解析任务,通过串口发送命令实时改变LED闪烁频率。这样你的G4就从“点灯板”变成了一个实打实的“RTOS控制系统”。
我的建议是:在G4上把FreeRTOS的核心API都过一遍——任务管理、信号量、互斥锁、消息队列、事件组、软件定时器——这套东西吃透了,你将来到哪个平台都是无缝迁移。今天的LED多任务只是热身。