目标: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 → HSECrystal/Ceramic Resonator外部 8MHz 晶振
SYS → DebugSerial Wire必须选,否则 ST-Link 只能烧一次
SYS → Timebase SourceTIM6⚠️ 必须改!SysTick 留给 FreeRTOS
Clock → HCLK168 MHz输入 168 回车,自动配 PLL
USART1Asynchronous, 115200PA9/PA10 默认
GPIOPA6 = OutputLED 引脚(根据实际板子调整)
FREERTOS → InterfaceCMSIS_V2别选 V1,V2 API 更完整
FREERTOS → Heapheap_4通用,支持动态分配释放
FREERTOS → Total Heap20480 (20KB)F407 有 192KB SRAM,充裕
Project ManagerToolchain = STM32CubeIDE必须选这个

Project Manager 额外勾选

  • Generate peripheral initialization as a pair of .c/.h files
  • Keep User Code when re-generating

生成代码验证

  1. 点击 Project → Generate Code(或工具栏齿轮图标)
  2. 编译(Ctrl+B),确认无报错
  3. main.cwhile(1) 里临时加 HAL_GPIO_TogglePin + HAL_Delay(500),确认 LED 闪烁
  4. 删掉临时代码,准备上 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.cwhile(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);

关键PutGet 的第二个参数是地址&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 和二值信号量有什么区别?”

  1. Mutex 有优先级继承;二值信号量没有。
  2. Mutex 只能由获取它的任务释放;二值信号量可由任何任务/中断释放。
  3. 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:裸机工程搭建

  1. 新建 STM32CubeIDE 裸机工程(不勾选 FreeRTOS)
  2. 从 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
  3. 添加到工程编译路径,编译通过

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.cxPortPendSVHandler,逐行注释:

 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 = 2
  • vApplicationStackOverflowHook():断点命中,打印任务名
  • 故意缩小任务栈(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-1TIM3 PWM 输出 + TIM2 编码器模式示波器看 PWM,手动转轴看编码器计数
4-2UART DMA 接收 + 命令解析(set_speed 1500串口发命令,回显解析结果
4-3Queue 整合:CLI Task → Queue → Motor Task串口设转速,Motor Task 拿到目标值
4-4位置式 PID,1kHz 中断触发电机接电源,开环给固定 PWM 转起来
4-5闭环:编码器反馈 → PID 计算 → PWM 更新手捏电机轴,PID 维持转速

Week 5 详细安排

天数任务验证方式
5-1Mutex 保护 PID 参数,串口在线调参调参时电机不抖
5-2异常处理:堵转检测、任务看门狗、栈溢出监控堵住电机轴,3 秒后自动停机
5-3代码重构:下划线命名、结构体封装、注释精简风格统一
5-4逻辑分析仪抓包:中断周期与任务响应延迟保存波形截图
5-5Git 整理:Week1/Week3/Project 三个分支仓库可公开
5-6~7README(技术细节、接线图、效果视频)别人能复现

关键代码模式:中断与任务同步

 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();
        }
    }
}