目标:6周内将 FreeRTOS 的基础技术栈学完,理解调度器的底层、中断同步、内存管理。
硬件:STM32F407VET6 + J-Link OB Mini + USB-TTL 串口模块
软件:STM32CubeIDE 1.19.0 + 串口助手(115200-8-1-N)
总目标
基本理解 FreeRTOS 的框架,熟悉并熟练它的基本功能。明白它的底层原理,熟练FreeRTOS源码内容,能够自己移植代码。
Week 1:多任务调度基础
目标
- 学会使用CubeMX 生成 FreeRTOS 工程
- 跑通双任务调度
- 理解任务创建、延时、优先级、挂起/恢复、空闲任务与看门狗等基础
Day 1:环境搭建
CubeMX 配置清单:
| 配置项 | 设置 | 备注 |
|---|
| RCC → HSE | Crystal/Ceramic Resonator | 外部 8MHz 晶振 |
| SYS → Debug | Serial Wire | 必须选,否则 ST-Link 只能烧一次 |
| SYS → Timebase Source | TIM6 | ⚠️ 必须改!SysTick 留给 FreeRTOS |
| Clock → HCLK | 168 MHz | 输入 168 回车,自动配 PLL |
| USART1 | Asynchronous, 115200 | PA9/PA10 默认 |
| GPIO | PA6 = Output | LED 引脚(根据实际板子调整) |
| FREERTOS → Interface | CMSIS_V2 | 别选 V1,V2 API 更完整 |
| FREERTOS → Heap | heap_4 | 通用,支持动态分配释放 |
| FREERTOS → Total Heap | 20480 (20KB) | F407 有 192KB SRAM,充裕 |
| Project Manager | Toolchain = STM32CubeIDE | 必须选这个 |
Project Manager 额外勾选:
- Generate peripheral initialization as a pair of .c/.h files
- Keep User Code when re-generating
生成代码验证:
- 点击 Project → Generate Code(或工具栏齿轮图标)
- 编译(Ctrl+B),确认无报错
- 在
main.c 的 while(1) 里临时加 HAL_GPIO_TogglePin + HAL_Delay(500),确认 LED 闪烁 - 删掉临时代码,准备上 FreeRTOS
踩坑记录:
- 生成代码闪退:先按
Ctrl+S 保存 .ioc 文件 - 固件包缺失:离线从 ST 官网下载
STM32CubeF4 包,解压到 C:\Users\xxx\STM32Cube\Repository\ - J-Link OB Mini 连接超时:确认接线 CLK→SWCLK、DIO→SWDIO、GND→GND、3.3V→3.3V;设备管理器确认识别到 J-Link 设备
Day 2:第一个 FreeRTOS 多任务
目标:Task_A 每 500ms 串口打印,Task_B 每 1s 翻转 LED,两个任务同时跑。
核心代码(freertos.c 三个 USER CODE 区域):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
| /* USER CODE BEGIN Includes */
#include "usart.h"
#include "gpio.h"
#include <string.h>
/* USER CODE END Includes */
/* USER CODE BEGIN Variables */
static void _task_a_handler(void *argument);
static void _task_b_handler(void *argument);
static osThreadId_t _task_a_id = NULL;
static osThreadId_t _task_b_id = NULL;
/* USER CODE END Variables */
/* USER CODE BEGIN Init */
void MX_FREERTOS_Init(void)
{
const osThreadAttr_t attr_a = {
.name = "task_a",
.priority = osPriorityNormal,
.stack_size = 512 * 4 /* 512 words = 2048 bytes */
};
const osThreadAttr_t attr_b = {
.name = "task_b",
.priority = osPriorityNormal,
.stack_size = 512 * 4
};
_task_a_id = osThreadNew(_task_a_handler, NULL, &attr_a);
_task_b_id = osThreadNew(_task_b_handler, NULL, &attr_b);
if (_task_a_id == NULL || _task_b_id == NULL) {
Error_Handler();
}
}
/* USER CODE END Init */
/* USER CODE BEGIN Application */
static void _task_a_handler(void *argument)
{
(void)argument;
for (;;) {
const char msg[] = "TaskA: Running\r\n";
HAL_UART_Transmit(&huart1, (uint8_t *)msg, strlen(msg), 100);
osDelay(500);
}
}
static void _task_b_handler(void *argument)
{
(void)argument;
for (;;) {
HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin);
osDelay(1000);
}
}
/* USER CODE END Application */
|
main.c 关键:必须包含初始化调用,否则 FreeRTOS 不启动。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| int main(void)
{
HAL_Init();
SystemClock_Config();
/* USER CODE BEGIN 2 */
MX_GPIO_Init();
MX_USART1_UART_Init();
MX_FREERTOS_Init();
osKernelStart(); /* 启动调度器,之后不会返回 */
/* USER CODE END 2 */
while (1) { } /* 永远不会执行到这里 */
}
|
验证:串口每 500ms 收到 TaskA: Running,LED 每秒闪烁。
踩坑:
main.c 的 while(1) 里还有裸机测试代码 → 删掉- 串口乱码 → 确认 Clock Configuration 里 HCLK=168M
- 只运行一个任务 → 某个任务函数里少了
osDelay,导致忙等饿死另一个
Day 3:优先级与抢占
目标:修改 Task_B 优先级为 osPriorityAboveNormal,验证高优先级不阻塞低优先级;制造"忙等"观察饿死现象。
修改点:
1
2
3
4
5
| const osThreadAttr_t attr_b = {
.name = "task_b",
.priority = osPriorityAboveNormal, /* 改这里 */
.stack_size = 512 * 4
};
|
实验:临时去掉 Task_B 的 osDelay(1000),观察 LED 狂闪、串口停止输出(Task_A 被饿死)。
关键理解:
osDelay 是阻塞等待,阻塞时任务让出 CPU- 高优先级任务如果永不阻塞(忙等),会饿死低优先级任务
- 同优先级默认开启 Time Slicing,每个 tick 强制切换,不会饿死
Day 4:任务挂起与恢复
目标:通过串口命令控制任务启停。
新增 Task_CMD:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| static void _task_cmd_handler(void *argument)
{
(void)argument;
uint8_t rx_byte = 0;
for (;;) {
if (HAL_UART_Receive(&huart1, &rx_byte, 1, 100) == HAL_OK) {
if (rx_byte == 's') {
osThreadSuspend(_task_b_id); /* 暂停 LED */
} else if (rx_byte == 'r') {
osThreadResume(_task_b_id); /* 恢复 LED */
}
}
osDelay(10);
}
}
|
验证:串口发 ’s’ LED 停止,发 ‘r’ LED 恢复。
踩坑:HAL_UART_Receive 超时后 rx_byte 内容不确定,必须判断返回值 HAL_OK。
Day 5:空闲任务钩子 + 独立看门狗(IWDG)
目标:利用"空闲任务"喂狗,验证如果任何任务死循环,看门狗会复位系统。
配置:
- CubeMX → IWDG → Activated,Prescaler=/32,Reload=1000(约 1 秒超时)
FreeRTOSConfig.h:#define configUSE_IDLE_HOOK 1
代码:
1
2
3
4
| void vApplicationIdleHook(void)
{
HAL_IWDG_Refresh(&hiwdg);
}
|
验证:
- 正常情况:LED 闪烁,串口输出,系统不复位
- 死循环测试:Task_A 去掉
osDelay,串口疯狂输出,1 秒后系统复位(反复复位,肉眼可能看不出来,需加复位标志检测)
Week 2:任务间通信(IPC)
目标
掌握 Queue、Semaphore、Mutex、Event Group,理解线程安全与同步机制。这是面试最高频考点。
Day 1:Queue 基础
目标:Task_Producer 每 1s 向 Queue 发送 uint32_t 计数,Task_Consumer 阻塞等待并打印。
API:
1
2
3
| osMessageQueueId_t osMessageQueueNew(uint32_t msg_count, uint32_t msg_size, const osMessageQueueAttr_t *attr);
osStatus_t osMessageQueuePut(osMessageQueueId_t mq_id, const void *msg_ptr, uint8_t msg_prio, uint32_t timeout);
osStatus_t osMessageQueueGet(osMessageQueueId_t mq_id, void *msg_ptr, uint8_t *msg_prio, uint32_t timeout);
|
关键:Put 和 Get 的第二个参数是地址(&cnt),Queue 内部会拷贝数据。
面试预埋:“为什么用 Queue 不用全局变量?”
Queue 有阻塞超时、线程安全、解耦收发速率;全局变量需手动关中断保护,易遗漏。Queue 存的是数据拷贝,不是指针,生产者局部变量销毁后消费者仍安全。
Day 2:Queue 传结构体
目标:传递 SensorData_t(timestamp + value),模拟真实传感器数据包。
1
2
3
4
| typedef struct {
uint32_t timestamp;
uint8_t value;
} SensorData_t;
|
注意 ARM 结构体对齐:uint32_t (4B) + uint8_t (1B) + 填充 3B = 8 字节。Queue 创建用 sizeof(SensorData_t),不要写死数字。
Day 3:二值信号量(中断同步)
目标:USART1 接收中断释放信号量,Task_CMD 阻塞等待并处理命令。
关键:
- 中断里用
osSemaphoreRelease(非阻塞) - 任务里用
osSemaphoreAcquire(可阻塞) - 中断回调里必须重新启动
HAL_UART_Receive_IT - 上电后必须启动第一次
HAL_UART_Receive_IT
面试核心:“为什么中断里不能用 osSemaphoreAcquire?”
中断里不能调用任何阻塞 API,否则中断无法返回,系统卡死。
Day 4:Mutex(互斥访问)
目标:两个任务竞争 UART 打印资源,用 Mutex 保护,输出不再穿插。
1
2
3
4
5
| osMutexId_t _mutex_uart = osMutexNew(NULL);
osMutexAcquire(_mutex_uart, osWaitForever);
HAL_UART_Transmit(&huart1, ...);
osMutexRelease(_mutex_uart);
|
面试核心:“Mutex 和二值信号量有什么区别?”
- Mutex 有优先级继承;二值信号量没有。
- Mutex 只能由获取它的任务释放;二值信号量可由任何任务/中断释放。
- Mutex 不能在中断中使用;二值信号量可以。
Day 5:Event Group(多事件同步)
目标:Task_Waiter 等待"传感器就绪 + 用户按键"两个条件同时满足才执行。
1
2
3
4
5
6
7
| #define EVENT_SENSOR_READY (1 << 0)
#define EVENT_CMD_GO (1 << 1)
uint32_t flags = osEventFlagsWait(_evt_group,
EVENT_SENSOR_READY | EVENT_CMD_GO,
osFlagsWaitAll | osFlagsNoClear,
1000);
|
注意:osFlagsNoClear 返回后不清除标志,需手动 osEventFlagsClear。
面试核心:“Event Group 和 Queue 有什么区别?”
Event Group 是同步原语,传 32 个标志位(0/1),无缓冲,不会累积。Queue 是 IPC 机制,传数据块,有缓冲,可积压。
Day 6-7:周末整合
小项目:温度采集报警系统
- Task_Sample:模拟温度采集,Queue 发送
- Task_Alarm:从 Queue 取温度,超阈值通过 Event Group 触发报警
- Task_CLI:串口设置阈值,Mutex 保护 UART
Week 3:手动移植 + 底层解剖
目标
脱离 CubeMX 封装,理解 FreeRTOS 内核启动、PendSV 上下文切换、SysTick 节拍、栈溢出检测。
Day 1-2:裸机工程搭建
- 新建 STM32CubeIDE 裸机工程(不勾选 FreeRTOS)
- 从 GitHub 下载
FreeRTOS-Kernel,复制以下目录:Source/(tasks.c, queue.c, list.c, timers.c, event_groups.c)Source/portable/GCC/ARM_CM4F/(port.c, portmacro.h)Source/portable/MemMang/heap_4.c
- 添加到工程编译路径,编译通过
Day 3:手写 FreeRTOSConfig.h
1
2
3
4
5
6
7
8
9
| #define configUSE_PREEMPTION 1
#define configCPU_CLOCK_HZ (168000000UL)
#define configTICK_RATE_HZ (1000)
#define configMAX_PRIORITIES (5)
#define configMINIMAL_STACK_SIZE (128) /* 单位:word */
#define configTOTAL_HEAP_SIZE (20 * 1024)
#define configUSE_TRACE_FACILITY 1
#define configCHECK_FOR_STACK_OVERFLOW 2
#define configMAX_SYSCALL_INTERRUPT_PRIORITY (5 << 4)
|
理解要点:
configTOTAL_HEAP_SIZE:20KB 堆,F407 有 192KB SRAM,够用configMAX_SYSCALL_INTERRUPT_PRIORITY:优先级 0-4 的中断不能调用 FreeRTOS API
Day 4:PendSV 汇编解剖
修改 stm32f4xx_it.c:
1
2
3
4
5
6
7
8
9
| extern void xPortSysTickHandler(void);
void SysTick_Handler(void)
{
if (xTaskGetSchedulerState() != taskSCHEDULER_NOT_STARTED) {
xPortSysTickHandler();
}
HAL_IncTick();
}
|
打开 port.c 的 xPortPendSVHandler,逐行注释:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| mrs r0, psp /* 读取进程栈指针(当前任务栈) */
isb
ldr r3, pxCurrentTCBConst
ldr r2, [r3]
stmdb r0!, {r4-r11, r14} /* 软件保存 R4-R11、LR(硬件自动保存 R0-R3, R12, LR, PC, xPSR) */
str r0, [r2] /* 更新当前任务的栈指针 */
stmdb sp!, {r3, r14}
mov r0, %0
msr basepri, r0
bl vTaskSwitchContext /* 选择下一个就绪任务 */
mov r0, #0
msr basepri, r0
ldmia sp!, {r3, r14}
ldr r1, [r3]
ldr r0, [r1]
ldmia r0!, {r4-r11, r14} /* 恢复新任务的寄存器 */
msr psp, r0 /* 切换 PSP 到新任务栈 */
isb
bx r14 /* 返回,执行新任务 */
|
理解:PendSV 是调度器的心脏,负责保存当前任务上下文、选择新任务、恢复新任务上下文。
Day 5:栈溢出检测
configCHECK_FOR_STACK_OVERFLOW = 2vApplicationStackOverflowHook():断点命中,打印任务名- 故意缩小任务栈(
128 * 4),验证触发
Week 4-5:综合项目
项目名称
基于 FreeRTOS 的直流电机闭环控制系统(STM32F407)
系统架构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| ┌─────────────────────────────────────────────┐
│ Task_CLI (Normal) │
│ UART DMA 接收命令,解析后 Queue 发目标转速 │
├─────────────────────────────────────────────┤
│ Task_Motor (AboveNormal) │
│ 从 Queue 取目标值,等待 Semaphore 后执行 PID │
│ 更新 TIM PWM 占空比 │
├─────────────────────────────────────────────┤
│ Task_Sensor (Normal) │
│ 定时读取编码器,Queue 发反馈给 CLI 显示 │
├─────────────────────────────────────────────┤
│ ISR: TIM2 (1kHz) │
│ 释放二值信号量,触发 Motor Task PID 计算 │
├─────────────────────────────────────────────┤
│ Mutex: PID 参数保护 │
│ 串口在线调参 Kp/Ki/Kd,调参时电机不抖 │
└─────────────────────────────────────────────┘
|
Week 4 详细安排
| 天数 | 任务 | 验证方式 |
|---|
| 4-1 | TIM3 PWM 输出 + TIM2 编码器模式 | 示波器看 PWM,手动转轴看编码器计数 |
| 4-2 | UART DMA 接收 + 命令解析(set_speed 1500) | 串口发命令,回显解析结果 |
| 4-3 | Queue 整合:CLI Task → Queue → Motor Task | 串口设转速,Motor Task 拿到目标值 |
| 4-4 | 位置式 PID,1kHz 中断触发 | 电机接电源,开环给固定 PWM 转起来 |
| 4-5 | 闭环:编码器反馈 → PID 计算 → PWM 更新 | 手捏电机轴,PID 维持转速 |
Week 5 详细安排
| 天数 | 任务 | 验证方式 |
|---|
| 5-1 | Mutex 保护 PID 参数,串口在线调参 | 调参时电机不抖 |
| 5-2 | 异常处理:堵转检测、任务看门狗、栈溢出监控 | 堵住电机轴,3 秒后自动停机 |
| 5-3 | 代码重构:下划线命名、结构体封装、注释精简 | 风格统一 |
| 5-4 | 逻辑分析仪抓包:中断周期与任务响应延迟 | 保存波形截图 |
| 5-5 | Git 整理:Week1/Week3/Project 三个分支 | 仓库可公开 |
| 5-6~7 | README(技术细节、接线图、效果视频) | 别人能复现 |
关键代码模式:中断与任务同步
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| static SemaphoreHandle_t _sem_motor_tick = NULL;
void _motor_ctrl_init(void)
{
_sem_motor_tick = xSemaphoreCreateBinary();
configASSERT(_sem_motor_tick != NULL);
}
/* TIM 中断回调,1kHz */
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
if (htim->Instance == TIM2) {
xSemaphoreGiveFromISR(_sem_motor_tick, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
}
/* 电机控制任务 */
static void _task_motor_ctrl(void *argument)
{
(void)argument;
for (;;) {
if (xSemaphoreTake(_sem_motor_tick, portMAX_DELAY) == pdTRUE) {
_pid_calculate_and_update();
}
}
}
|