1 单片机程序设计模式

1.1 裸机程序设计模式

裸机程序的设计模式可以分为:轮询前后台(中断)定时器驱动基于状态机。前面3种方法都无法解决一个问题:假设有 A、B 两个都很耗时的函数,无法降低它们相互之间的影响。 第4种方法可以解决这个问题,但是实践起来有难度。比如这样一个场景:一位职场妈妈需要同时解决 2 个问题:给小孩喂饭、回复工作信息。

1.1.1 轮询模式

示例代码如下:

1
2
3
4
5
6
7
8
9
/* 经典单片机程序: 轮询 */
void main()
{
while (1)
{
喂一口饭();
回一个信息();
}
}

轮询模式在main函数中是一个while循环,里面依次调用2个函数,这两个函数相互之间有影响:如果“喂一口饭”太花时间,就会导致迟迟无法“回一个信息”;如果“回一个信息” 太花时间,就会导致迟迟无法“喂下一口饭”。

1.1.2 前后台(中断)

所谓“前后台”就是使用中断程序。假设收到同事发来的信息时,电脑会发出“滴”的一声,这时候妈妈才需要去回复信息。示例程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* 前后台程序 */
void main()
{
while (1)
{
// 后台程序
喂一口饭();
}
}

// 前台程序
void 滴_中断函数()
{
回一个信息();
}

前后台(中断)方式在main函数里while循环里的代码是后台程序,平时都是while循环在运行; 当同事发来信息,电脑发出“滴”的一声,触发了中断。妈妈暂停喂饭,去执行“滴_中断”给同事回复信息;在这个场景里,给同事回复信息非常及时:即使正在喂饭也会暂停下来去回复信息。“喂一口饭”无法影响到“回一个信息”。但是,如果“回一个信息”太花时间,就会导致 “喂一口饭”迟迟无法执行。

1.1.3 定时器驱动

定时器驱动模式,是前后台模式的一种,可以按照不用的频率执行各种函数。比如需要每2分钟给小孩喂一口饭,需要每5分钟给同事回复信息。那么就可以启动一个定时器,让它每1分钟产生一次中断,让中断函数在合适的时间调用对应函数。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* 前后台程序: 定时器驱动 */
void main()
{
while (1)
{
// 后台程序
}
}

// 前台程序: 每 1 分钟触发一次中断
void 定时器_中断()
{
static int cnt = 0;
cnt++;
if (cnt % 2 == 0)
{
喂一口饭();
}
else if (cnt % 5 == 0)
{
回一个信息();
}
}

1.1.4 基于状态机

当“喂一口饭”、“回一个信息”都需要花很长的时间,无论使用前面的哪种设计模式, 都会退化到轮询模式的缺点:函数相互之间有影响。可以使用状态机来解决这个缺点,示例代码如下:

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
/* 状态机 */
void main()
{
while (1)
{
喂一口饭();
回一个信息();
}
}
void 喂一口饭(void)
{
static int state = 0;
switch (state)
{
case 0:
{
/* 舀饭 */
/* 进入下一个状态 */
state++;
break;
}
case 1:
{
/* 喂饭 */
/* 进入下一个状态 */
state++;
break;
}
case 2:
{
/* 舀菜 */
/* 进入下一个状态 */
state++;
break;
}
case 3:
{
/* 喂菜 */
/* 恢复到初始状态 */
state = 0;
break;
}
}
}

void 回一个信息(void)
{
static int state = 0;

switch (state)
{
case 0:
{
/* 查看信息 */
/* 进入下一个状态 */
state++;
break;
}
case 1:
{
/* 打字 */
/* 进入下一个状态 */
state++;
break;
}
case 2:
{
/* 发送 */
/* 恢复到初始状态 */
state = 0;
break;
}
}
}

以“喂一口饭”为例,函数内部拆分为 4个状态:舀饭、喂饭、舀菜、喂菜。每次执行“喂一口饭”函数时,都只会执行其中的某一状态对应的代码。以前执行一次“喂一口饭” 函数可能需要4秒钟,现在可能只需要 1秒钟,就降低了对后面“回一个信息”的影响。

使用状态机模式,可以解决裸机程序的难题:假设有 A、B 两个都很耗时的函数,怎样降低它们相互之间的影响。但是很多场景里,函数 A、B 并不容易拆分为多个状态,并且这些状态执行的时间并不好控制。所以这并不是最优的解决方法,需要使用多任务系统。

1.2 多任务系统

对于裸机程序,无论使用哪种模式进行精心的设计,在最差的情况下都无法解决这个问题:假设有A、B两个都很耗时的函数,无法降低它们相互之间的影响。使用状态机模式时, 如果函数拆分得不好,也会导致这个问题。本质原因是:函数是轮流执行的。假设“喂一口饭”需要 t1~t5这 5 段时间,“回一个信息”需要ta~te 这 5 段时间,轮流执行时:先执行完t1~t5,再执行ta~te,如下图所示:

对于职场妈妈,她怎么解决这个问题呢?她是一个眼明手快的人,可以一心多用,她这样做:

1
2
3
- 左手拿勺子,给小孩喂饭 
- 右手敲键盘,回复同事
- 两不耽误,小孩“以为”妈妈在专心喂饭,同事“以为”她在专心聊天

上述过程看似完美,但是脑子只有一个啊,虽然说“一心多用”,但是谁能同时思考两件事?只是她反应快,上一秒钟在考虑夹哪个菜给小孩,下一秒钟考虑给同事回复什么信息,这个过程简单说本质是:交叉执行t1,t5ta,te交叉执行,如下图所示:

基于多任务系统编写程序时,示例代码如下:

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
/* RTOS 程序 */
喂饭任务()
{
while (1)
{
喂一口饭();
}
}

回信息任务()
{
while (1)
{
回一个信息();
}
}

void main()
{
// 创建2个任务(子线程)
create_task(喂饭任务);
create_task(回信息任务);

// 启动调度器
start_scheduler();
}

基于多任务系统编写程序时,反而更简单了: ① 上面第2~8行是“喂饭任务”的代码; ② 第10~16行是“回信息任务”的代码,编写它们时甚至都不需要考虑它和其他函数的相互影响。就好像有2个单片机:一个个运行“喂饭任务”这个函数、另一个个运行“回信息任务”这个函数。

多任务系统会依次给这些任务分配时间:你执行一会,我执行一会,如此循环。只要切换的间隔足够短,用户会“感觉这些任务在同时运行”。如下图所示:

1.2.2 互斥操作

多任务系统中,多个任务可能会“同时”访问某些资源,需要增加保护措施以防止混乱。 比如任务 A、B 都要使用串口,能否使用一个全局变量让它们独占地、互斥地使用串口?示例代码如下:

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
/* RTOS 程序 */
int g_canuse = 1;

void uart_print(char *str)
{
if (g_canuse)
{
g_canuse = 0;
printf(str);
g_canuse = 1;
}
}

task_A()
{
while (1)
{
uart_print("0123456789\n");
}
}

task_B()
{
while (1)
{
uart_print("abcdefghij");
}
}

void main()
{
// 创建 2 个任务
create_task(task_A);
create_task(task_B);
// 启动调度器
start_scheduler();
}

程序的意图是:task_A打印0123456789,task_B打印abcdefghij。在task_A或task_B打印的过程中,另一个任务不能打印,以避免数字、字母混杂在一起,比如避免打印这样的字符:012abc。第6行使用全局变量g_canuse实现互斥打印,它等于1时表示“可以打印”。在进行实际打印之前,先把g_canuse设置为0,目的是防止别的任务也来打印。

该程序大部分时间没问题,但只要它运行的时间足够长,就会出现数字、字母混杂的情况。看视频或者自己分析一下就很容易明白。

从上面的例子可以看到,基于多任务系统编写程序时,访问公用的资源的时候要考虑“互斥操作”。任何一种多任务系统都会提供相应的函数。

1.2.3 同步操作

如果任务之间有依赖关系,比如任务A执行了某个操作之后,需要任务B进行后续的处理。如果代码如下编写的话,任务B大部分时间做的都是无用功。

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
/* RTOS 程序 */
int flag = 0;

void task_A()
{
while (1)
{
// 做某些复杂的事情
// 完成后把 flag 设置为 1
flag = 1;
}
}

void task_B()
{
while (1)
{
if (flag)
{
// 做后续的操作
}
}
}

void main()
{
// 创建 2 个任务
create_task(task_A);
create_task(task_B);
// 启动调度器
start_scheduler();
}

上述代码中,在任务A没有设置flag为1之前,任务B的代码都只是去判断flag。而任务 A、B 的函数是依次轮流运行的,假设系统运行了100秒,其中任务A总共运行了50秒,任务B总共运行了50秒,任务A在努力处理复杂的运算,任务B仅仅是浪费CPU资源。

如果可以让任务B阻塞,即让任务B不参与调度,那么任务A就可以独占CPU资源加快处理复杂的事情。当任务A处理完事情后,再唤醒任务B。使用【信号量】的示例代码如下:

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
// RTOS 程序  
void task_A()
{
while (1)
{
// 做某些复杂的事情
// 释放信号量,会唤醒任务 B;
}
}

void task_B()
{
while (1)
{
// 等待信号量, 会让任务 B 阻塞
// 做后续的操作
}
}

void main()
{
// 创建 2 个任务
create_task(task_A);
create_task(task_B);
// 启动调度器
start_scheduler();
}

第15行:任务B运行时,等待信号量,不成功时就会阻塞,不再参与任务调度。
第7行:任务A处理完复杂的事情后,释放信号量会唤醒任务B。
第16行:任务B被唤醒后,从这里继续运行。

在这个过程中,任务 A处理复杂事情的时候可以独占CPU资源,加快处理速度。

PS:这里的信号量有点类似于前两天学习的“大丙老师的的多线程课程中的互斥锁、条件变量”的作用。

2 创建 FreeRTOS 工程

2.1 创建 STM32CubeMX 工程

双击运行STM32CubeMX,在首页面选择“Access to MCU Selector”,如下图所示:

然后来到MCU选型界面,在序列号那里输入想要开发的芯片,例如 STM32F103C8T6:

2.2 配置时钟

先配置处理器的时钟,在“System Core”的“RCC”处选择外部高速时钟源和低速时钟源。DshanMCU-F103使用了外部高速时钟源,如下图所示:

另外,本实验使用了FreeRTOS,FreeRTOS的时基使用的是Systick,而STM32CubeMX中默认的HAL库时基也是 Systick,为了避免可能的冲突,最好将HAL库的时基换做其它的硬件定时器(比如TIM4):

最后去时钟配置界面配置系统时钟频率。直接在HCLK时钟那里输入MCU允许的最高时钟频率。F103的最高频率是 72Mhz,所以直接在那里输入72然后按回车:

回车后,STM32CubeMX会自动计算得到各个分频系数和倍频系数。

2.3 配置 GPIO

板载 LED的使用的 GPIO是PC13,所以在STM32CubeMX的引脚配置界面,找到 PC13,然后在芯片图中,使用鼠标左键点击 PC13,会弹出此 IO支持的模式,这里选择GPIO Output,让PC13配置为通用输出IO,以便用来驱动LED 的亮灭。

2.4 配置 FreeRTOS

STM32CubeMX 已经将 FreeRTOS 集成到工具中,并且将 RTOS 的接口进行了封装 CMSIS-RTOS V1/V2,相较之于 V1版本的CMSIS-RTOS API,V2 版本的API的兼容性更高,为了将来的开发和移植,建议开发者使用V2版本的API:

选择 CMSIS V2接口后,还要进一步配置 FreeRTOS的参数和功能。

2.4.1 配置参数

FreeRTOS 的参数包括时基频率任务堆栈大小是否使能互斥锁等等,需要开发者根据自己对FreeRTOS的了解以及项目开发的需求,来定制参数。 先如下图进行配置:

2.4.2 添加任务

使用 STM32CubeMX,可以手工添加任务、队列、信号量、互斥锁、定时器等等。但是本课程不想严重依赖 STM32CubeMX,所以不会使用STM32CubeMX来添加这些对象,而是手写代码来使用这些对象。

使用 STM32CubeMX 时,会自动创建一个默认任务,此任务无法删除,只能修改其名称和函数类型, 如下图所示:

2.5 生成 Keil MDK 的工程

当对外设配置完成后,就去“Project Manager”中设置工程的名称、存储路径和开发IDE:

随后去同界面的“Code Generator”设置、生成工程:

2.6 添加用户代码

STM32CubeMX 只是帮我们初始化了所配置的硬件模块,你要实现什么功能,需要自己添加代码。

2.6.1 打开工程

在工程的“MDK-ARM”目录下,双击以.uvprojx结尾的文件,就会使用Keil打开工程。

2.6.2 修改文件

双击打开freertos.c文件,找到void StartDefaultTask(void* argument)函数里的循环。我们编写的代码,需要位于USER CODE BEGIN xxxUSER CODE END xxx之间,否则以后再次使用STM32CubeMX 配置工程时,不在这些位置的用户代码会被删除。

2.6.3 创建新的任务/子线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include "task.h"
/**
* @breaf FreeRTOS的动态任务/线程创建函数
* @note 从FreeRTOS管理的堆中分配内存(包括任务控制块TCB和任务栈空间),并将新任务加入就绪列表等待调
* @param pxTaskCode 回调函数指针,创建出的任务/子线程的处理动作,也就是该函数在此任务/子线程中执行
* @param pcName 一个字符串,可任意定义,描述当前任务/子线程,比如"mytask"
* @param usStackDepth 任务的栈空间大小,单位:字(注意,单位不是字节)
* @param pvParameters 回调函数的参数,没有就填NULL
* @param uxPriority 任务的优先级
* @param pxCreatedTask 输出参数,返回新创建任务的句柄
* @return 任务创建成功返回1,失败返回-1
*/
BaseType_t xTaskCreate(TaskFunction_t pxTaskCode,
const char* const pcName,
const configSTACK_DEPTH_TYPE usStackDepth,
void * const pvParameters,
UBaseType_t uxPriority,
TaskHandle_t * const pxCreatedTask);

此函数每个参数的详细说明:

  • pxTaskCode
    • 类型:TaskFunction_t(函数指针类型,定义为typedef void (*TaskFunction_t)( void * )
    • 作用:指向任务函数的指针,该函数需为无限循环且无返回值的函数‌。
    • 示例:void vTaskFunction(void *pvParameters)
  • pcName
    • 类型:const char* const
    • 作用:任务描述性名称,用于调试,长度不超过configMAX_TASK_NAME_LEN宏定义的限制‌。
    • 注意:系统不会主动使用该名称,仅开发者可见‌。
  • usStackDepth
    • 类型:configSTACK_DEPTH_TYPE(通常为uint16_t
    • 作用:指定任务栈深度,单位为‌字(WORD)‌而非字节。例如32位系统中传入100,实际分配400字节(100*4)‌。
    • 关键限制:栈深度×栈宽度需小于size_t类型的最大值(如16位系统需≤65535)‌,怎么确定栈的大小,并不容易,很多时候是估计。 精确的办法是看反汇编码。
  • pvParameters
    • 类型:void* const
    • 作用:传递给任务函数的参数指针,若无参数可设为NULL‌。
    • 传递机制:通过TCB中的成员传递给任务函数‌
  • uxPriority
    • 类型:UBaseType_t(通常为unsigned short
    • 作用:任务优先级,范围0(最低)到configMAX_PRIORITIES-1(最高)。支持特权模式的任务可通过设置portPRIVILEGE_BIT位实现‌
  • pxCreatedTask
    • 类型:TaskHandle_t* const
    • 作用:传出参数,返回新创建任务的句柄(即TCB指针),可用于后续API操作(如删除任务、修改任务优先级等)‌。
    • 注意:若不需要句柄可设为NULL‌
  • 返回值
    • 类型‌:BaseType_t(通常为int
      • 当返回值为pdPASS(即代表数字1),表示任务创建成功并加入就绪列表‌
      • 当返回值为errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY(即代表数字-1),表示内存不足创建失败‌

3 FreeRTOS 源码概述

3.1 FreeRTOS 目录结构

使用 STM32CubeMX 创建的FreeRTOS工程中,FreeRTOS相关的源码如下:

主要涉及2个目录:

  • Core 目录
    • Inc 目录下的FreeRTOSConfig.h是配置文件
    • Src 目录下的freertos.c是STM32CubeMX 创建的默认任务
  • Middlewares\Third_Party\FreeRTOS\Source 目录
    • 根目录下是核心文件,这些文件是通用的
    • portable 目录下是移植时需要实现的文件
      • 目录名为:[compiler]/[architecture]
      • 比如:RVDS/ARM_CM3,这表示 cortexM3 架构在 RVDS 工具上的移植文件

3.2 核心文件

FreeRTOS的最核心文件只有2个:FreeRTOS/Source/tasks.cFreeRTOS/Source/list.c

其他文件的作用也一起列表如下:

FreeRTOS/Source/下的文件 作用
tasks.c 必需,任务相关的操作, 包括任务的创建、删除、调度及优先级控制等核。
list.c 必需,链表相关的操作
queue.c 基本必需,提供队列操作、信号量(semaphore)相关的操作
timer.c 可选,software timer
event_groups.c 可选,提供时间组(event group)功能
croutine.c 可选,过时了

每个文件的作用可以询问AI,我问了一下【FreeRTOS的tasks.c文件的作用是什么?】,回答的还是比较好的。

3.3 移植时涉及的文件

移植FreeRTOS时涉及的文件放在FreeRTOS/Source/portable/[compiler]/[architecture]目录下, 比如:RVDS/ARM_CM3,这表示cortexM3架构在RVDS或Keil工具上的移植文件。 里面有2个文件: port.cportmacro.h

3.4 头文件相关

  • FreeRTOS需要3个头文件目录:
    • FreeRTOS本身的头文件: Middlewares\Third_Party\FreeRTOS\Source\include
    • 移植时用的头文件:Middlewares\Third_Party\FreeRTOS\Source\portable\[compiler]\[architecture]
    • 含有配置文件 FreeRTOSConfig.h 的目录:Core\Inc
  • 头文件的作用:
头文件 作用
FreeRTOSConfig.h FreeRTOS 的配置文件,比如选择调度算法:configUSE_PREEMPTION,本课程的每个 demo 都必定含有 FreeRTOSConfig.h,建议去修改 demo 中的 FreeRTOSConfig.h,而不是从头
FreeRTOS.h 使用 FreeRTOS API 函数时,必须包含此文件。在 FreeRTOS.h 之后,再去包含其他头文件,比如:task.h、queue.h、semphr.h、event_group.h

3.5 内存管理

文件在 Middlewares\Third_Party\FreeRTOS\Source\portable\MemMang 下,它也是放在“portable”目录下,表示你可以提供自己的函数。 源码中默认提供了5个文件,对应内存管理的5种方法。

文件 优点 缺点
heap_1.c 分配简单,时间确定 只分配、不回收
heap_2.c 动态分配、最佳匹配 存在内存碎片、时间不定
heap_3.c 调用标准库函数 速度慢、时间不定
heap_4.c 相邻空闲内存可合并 可解决碎片问题、时间不定
heap_5.c 在 heap_4 基础上支持分隔的内存块 可解决碎片问题、时间不定

3.6 入口函数

在 Core\Src\main.c 的 main 函数里,初始化了 FreeRTOS 环境、创建了任务,然后启动调度器。源码如下:

1
2
3
4
5
6
/* Init scheduler */  
osKernelInitialize(); /* 初始化FreeRTOS运行环境 */
MX_FREERTOS_Init(); /* 创建任务 */

/* Start scheduler */
osKernelStart(); /* 启动调度器 */

3.7 数据类型和编程规范

略。

4 ARM架构简要介绍

4.1 精简指令集

ARM芯片属于精简指令集计算机(RISC:Reduced Instruction Set Computing),它所用的指令比较简单,有如下特点:

① 对内存只有读、写指令

② 对于数据的运算是在CPU内部实现

③ 使用RISC指令的CPU复杂度小一点,易于设计

对于上图所示的乘法运算a = a * b,在RISC中要使用4条汇编指令:

1
2
3
4
- 读内存a
- 读内存b
- 计算a*b
- 把结果写入内存

问题:在CPU内部,用什么来保存aba * b

4.2 CPU内部寄存器

无论是cortex-M3/M4,还是cortex-A7,CPU内部都有R0、R1、……、R15寄存器;它们可以用来“暂存”数据。

对于寄存器R13、R14、R15,还另有用途:

  1. R13:别名SP(Stack Pointer, SP),栈指针,一般来说就用来保存栈的地地址。
  2. R14:别名LR(Link Register, LR),用来保存返回地址,在调用函数处需要保存函数执行完之后的返回地址。
  3. R15:别名PC(Program Counter, PC),程序计数器,表示当前指令地址,写入新值即可跳转到指定的地址运行程序。

4.3 汇编指令

4.3.1 基础的汇编指令

  • 读内存:Load
1
2
3
LDRB R0, R1		; 读地址"R1", 得到的1字节数据存入R0
LDRH R0, [R2, #1] ; 读地址"R2+1", 得到的2字节(半字)数据存入R0
LDR R0, [R1, #4] ; 读地址"R1+4", 得到的4字节数据存入R0
  • 写内存:Stroe
1
2
3
STRB  R0, R1		; 把R0的1字节数据写入地址R1
STRH R0, R2 ; 把R0的2字节(半字)数据写入地址R2
STR R0, [R1, #4] ; 把R0的4字节数据写入地址"R1+4"
  • 加减
1
2
3
4
ADD R0, R1, R2  	; 表示 R0=R1+R2
ADD R0, R0, #1 ; 表示 R0=R0+1
SUB R0, R1, R2 ; 表示 R0=R1-R2
SUB R0, R0, #1 ; 表示 R0=R0-1
  • 比较
1
CMP R0, R1  ; 结果保存在PSR(程序状态寄存器,即上图中最后一个寄存器)
  • 跳转
1
2
B  main  ; Branch表示令PC=main函数地址, 直接跳转,走了就不再回来了
BL main ; Branch and Link令PC=main函数地址 LR=返回地址, 先把返回地址保存在LR寄存器里再跳转

4.3.2 C语言函数的反汇编

示例c语言函数:

1
2
3
4
5
6
int add(volatile int a, volatile int b)
{
volatile int sum;
sum = a + b;
return sum;
}

使用指令fromelf --text -a -c --output=xxx.dis xxx.axf让Keil生成反汇编:

查看生成的汇编代码:这一部分代码的分析请观看视频反汇编代码讲解 - 韦东山,手写笔记太麻烦,而且不如动态视频好理解。

4.4 内存分析

4.4.1 C语言内存分区

C语言在内存中一共分为如下几个区域,分别是:

(一)栈区

  • 栈区的特点
    • 栈区由编译器自动分配释放,由操作系统自动管理,无须手动管理。
    • 栈区上的内容只在函数范围内存在,当函数运行结束,这些内容也会自动被销毁。
    • 栈区按内存地址由高到低方向生长,其最大大小由编译时确定,速度快,但自由性差,最大空间不大。
    • 栈区是先进后出原则(LIFO),其操作方式数据结构中的栈是一样的。
  • 存放内容:
    • 临时创建的局部变量存放在栈区。
    • 函数调用时,其入口参数存放在栈区。
    • 函数返回时,其返回值存放在栈区。
    • (const 定义的)局部变量存放在栈区。

栈的大小是有限的,通常 Visual C++ 编译器的默认栈的大小为 1MB,所以不要定义 int a[1000000] 这样的超大数组。

看下面这样一个例子,main()函数中调用了a()函数,a()函数中调用了b()c()函数:

1
2
3
4
main()
|- a()
|-b()
|-c()

根据这个例子,有如下3个问题需要理解和解答:

  • ① 每次函数调用前都会将返回地址保存到LR寄存器中,然后PC寄存器指向被调用的函数地址,但是上面这种情况会产生嵌套调用,LR寄存器的值被频繁更改,如何保证函数返回后程序回到正确的地址?
    • 答:处理方法是:每次进入被调用的函数,函数的一开头都会先把上一个在LR中的返回地址保存起来,即压到栈中(栈位于内存SRAM中),函数调用完成后再把栈中LR的值弹出来,这样就能保证程序返回到正确的地址运行。
  • ② 局部变量是如何在栈中分配的?

需要说明的是,函数中的局部变量不一定会保存到栈(内存SRAM中),也有可能直接保存到寄存器中(cpu这样优化有一个原因是寄存器的速度更快)。这地方可以联系volatile关键字的用法。

  • ③ 为什么每个FreeRTOS任务都要有自己独有的栈空间?
    • 答:因为FreeRTOS的本质还是顺序运行,通过极为快速的任务切换,使得看起来多个任务是并行执行的。所以说这其中涉及到任务的切换,那么就必然要在切换前保存正在执行的任务的“现场”(基本上就是保存所有的寄存器,除了SP寄存器,SP的值保存到当前任务的结构体TCB中)。所以说每个任务都要有自己独享的栈空间。

(二)堆区

  • 堆区的特点
    • 堆区按内存地址由低到高方向生长,其大小由系统内存/虚拟内存上限决定,速度较慢,但自由性大,可用空间大。
    • 堆区用于存放程序运行中被动态分布的内存段,可增可减。
    • 可以用malloc等函数实现动态分配堆内存,但它的存储空间一般是不连续的,所以会产生内存碎片
    • malloc函数分配的堆内存,必须用free进行内存释放,否则会造成内存泄漏。
    • 注意它与数据结构中的堆是两回事,不过分配方式类似于链表。
1
2
3
char* p = (char*)malloc(sizeof(char)*20);
// 这行代码在Heap中开辟了20个char长度的空间,同时在Stack上压入了p,
// 指针变量p存在于栈上,其值为刚刚在堆上开辟的空间的首地址。

4.4.2 单片机/MCU内存分配解读

(一)FLASH与RAM的基本结构

单片机内存被总分为Flash(ROM)和SRAM(RAM),Flash里面的数据掉电可保存,SRAM中的数据掉电就丢失,SRAM的执行速度要快于Flash,Flash容量大于SRAM。

我们正常下载程序都是下载存储进Flash里面,这也是为什么断电可保存的原因。单片机的程序存储分为code(代码存储区)RO-data(只读数据存储区)RW-data(读写数据存储区)ZI-data(零初始化数据区)

Flash存储code和RO-data,SRAM存储RW-data和ZI-data

一个进程运行时,所占用的内存,可以分为如下几个部分:
(1)栈区(stack):由编译器自动分配释放,存放函数的参数值,局部变量的值等。
(2)堆区(heap):一般由程序员分配释放,若程序员不释放,程序结束时可能由OS释放。
(3)全局变量、静态变量:初始化的全局变量和静态变量放在一块区域,未初始化的全局变量和和未初始化的静态变量在相邻的另一块区域。程序结束后由系统自动释放。
(4)文字常量:常量字符串就是放在这里的。这些数据是只读的,分配在RO-data(只读数据存储区),则被包含在Flash中,程序结束后由系统自动释放。
(5)程序代码(code):存放函数体的二进制代码。

(二)keil 编译后的字段解析

图中:

Code 表示 程序代码部分 = 程序代码区(code)
RO-data 表示 程序定义的常量 = 文字常量区
RW-data 表示 已初始化的全局变量 = 栈区(stack)堆区(heap)全局区(静态区)(static)
ZI-data 表示 未初始化的全局变量。

5 内存管理

5.1 为什么要自己实现内存管理

后续的章节涉及这些内核对象:task、queue、semaphores和event group等。为了让FreeRTOS更容易使用,这些内核对象一般都是动态分配:用到时分配,不使用时释放。使用内存的动态管理功能,简化了程序设计:不再需要小心翼翼地提前规划各类对象,简化API函数的涉及,甚至可以减少内存的使用。

内存的动态管理是C程序的知识范畴,并不属于FreeRTOS的知识范畴,但是它跟FreeRTOS关系是如此紧密,所以我们先讲解它。

在C语言的库函数中,有mallcofree等函数,但是在FreeRTOS中,它们不适用:,原因有以下几点:

  • 不适合用在资源紧缺的嵌入式系统中
  • 这些函数的实现过于复杂、占据的代码空间太大
  • 并非线程安全的(thread-safe)
  • 运行有不确定性:每次调用这些函数时花费的时间可能都不相同
  • 内存碎片化
  • 使用不同的编译器时,需要进行复杂的配置
  • 有时候难以调试

注意:我们经常”堆栈”混合着说,其实它们不是同一个东西:

  • 堆,heap,就是一块空闲的内存,需要提供管理函数
    • malloc:从堆里划出一块空间给程序使用
    • free:用完后,再把它标记为”空闲”的,可以再次使用
  • 栈,stack,函数调用时局部变量保存在栈中,当前程序的环境也是保存在栈中
    • 可以从堆中分配一块空间用作栈

5.2 FreeRTOS 的 5 种内存管理方法

FreeRTOS 中内存管理的接口函数为:pvPortMalloc()vPortFree(),对应于 C 库的malloc()free()

文件在FreeRTOS/Source/portable/MemMang下,它也是放在portable目录下,表示你可以提供自己的函数。 源码中默认提供了5个文件,分别是heap_1.cheap_2.cheap_3.cheap_4.cheap_5.c,对应内存管理的5种方法。这五种方法的优劣请参考“3.5 内存管理”小节。

5.2.1 Heap_1

它只实现了pvPortMalloc()函数,没有实现vPortFree()。 如果你的程序不需要删除内核对象,那么可以使用heap_1.c

  • 实现最简单,没有碎片问题。
  • 一些要求非常严格的系统里,不允许使用动态内存,就可以使用 heap_1

它的实现原理很简单,首先定义一个大数组:

1
2
3
4
5
6
7
8
9
/* Allocate the memory for the heap. */  
##if ( configAPPLICATION_ALLOCATED_HEAP == 1 )

/* The application writer has already defined the array used for the RTOS
* heap - probably so it can be placed in a special segment or address. */
extern uint8_t ucHeap[ configTOTAL_HEAP_SIZE ]; // 定义一个大数组
##else
static uint8_t ucHeap[ configTOTAL_HEAP_SIZE ];
##endif /* configAPPLICATION_ALLOCATED_HEAP */

然后,对于pvPortMalloc()调用时,从这个数组中分配空间。

FreeRTOS在创建任务时,需要2个内核对象:task control block(TCB)、stack。 使用heap_1时,内存分配过程如下图所示:

  • A:创建任务之前整个数组都是空闲的;
  • B:创建第1个任务之后,蓝色区域被分配出去了;
  • C:创建3个任务之后的数组使用情况;

5.2.2 Heap_2

Heap_2之所以还保留,只是为了兼容以前的代码。新设计中不再推荐使用Heap_2。建议使用Heap_4来替代Heap_2,更加高效。
Heap_2也是在数组上分配内存,跟Heap_1不一样的地方在于:

  1. Heap_2使用最佳匹配算法(best fit)来分配内存
  2. 它支持vPortFree()

最佳匹配算法

  • 假设heap有3块空闲内存:5字节、25字节、100字节,pvPortMalloc()想申请20字节,找出最小的、能满足pvPortMalloc()的内存:25字节,把它划分为20字节、5字节,返回这20字节的地址,剩下的5字节仍然是空闲状态,留给后续的pvPortMalloc()使用。

与Heap_4相比,Heap_2不会合并相邻的空闲内存,所以Heap_2会导致严重的”碎片化”问题。

但是,如果申请、分配内存时大小总是相同的,这类场景下Heap_2没有碎片化的问题。 所以它适合这种场景:频繁地创建、删除任务,但是任务的栈大小都是相同的(创建任务时, 需要分配TCB和栈,TCB总是一样的)。虽然不再推荐使用heap_2,但是它的效率还是远高于malloc、free。

使用heap_2时,内存分配过程如下图所示:

  • A:创建了3个任务
  • B:删除了一个任务,空闲内存有3部分:顶层的、被删除任务的TCB空间、被删除任务的Stack空间
  • C:创建了一个新任务,因为TCB、栈大小跟前面被删除任务的TCB、栈大小一致,故刚好分配到原来的内存

5.2.3 Heap_3

Heap_3 使用标准 C 库里的malloc()free()函数,所以堆大小由链接器的配置决定,配置项 configTOTAL_HEAP_SIZE不再起作用。

C库里的malloc()free()函数并非线程安全的,Heap_3中先暂停FreeRTOS的调度器,再去调用这些函数,使用这种方法实现了线程安全。

5.2.4 Heap_4

跟 Heap_1、Heap_2 一样,Heap_4 也是使用大数组来分配内存

Heap_4使用首次适应算法(first fit)来分配内存。它还会把相邻的空闲内存合并为一个更大的空闲内存,这有助于较少内存的碎片问题。

首次适应算法(first fit)

假设堆中有3块空闲内存:5字节、200字节、100字节,pvPortMalloc()想申请20字节,找出第1个能满足pvPortMalloc()的内存:200字节,把它划分为20字节、180字节,返回这20字节的地址,剩下的180字节仍然是空闲状态,留给后续的pvPortMalloc()使用。

Heap_4会把相邻空闲内存合并为一个大的空闲内存,可以较少内存的碎片化问题。适用于这种场景:频繁地分配、释放不同大小的内存。

Heap_4的使用过程举例如下:

  • A:创建了3个任务
  • B:删除了一个任务,空闲内存有2部分:顶层的被删除任务的TCB空间、被删除任务的Stack空间合并起来的

  • C:分配了一个Queue,从第1个空闲块中分配空间

  • D:分配了一个User数据,从Queue之后的空闲块中分配
  • E:释放的Queue,User前后都有一块空闲内存
  • F:释放了User数据,User前后的内存、User本身占据的内存,合并为一个大的空闲内存

Heap_4执行的时间是不确定的,但是它的效率高于标准库的malloc、free。

5.2.5 Heap_5

Heap_5 分配内存、释放内存的算法跟 Heap_4 是一样的。

相比于Heap_4,Heap_5并不局限于管理一个大数组:它可以管理多块、分隔开的内存。 在嵌入式系统中,内存的地址可能并不连续,这种场景下可以使用Heap_5。 既然内存是分隔开的,那么就需要进行初始化:确定这些内存块在哪、多大:

  • 在使用pvPortMalloc()之前,必须先指定内存块的信息
  • 使用vPortDefineHeapRegions()来指定这些信息

怎么指定一块内存?使用如下结构体:

1
2
3
4
5
typedef struct HeapRegion 
{
uint8_t * pucStartAddress; // 起始地址
size_t xSizeInBytes; // 大小
} HeapRegion_t;

怎么指定多块内存?使用一个HeapRegion_t数组,在这个数组中,低地址在前、高地址在后。

1
2
3
4
5
6
HeapRegion_t xHeapRegions[] = 
{
{ ( uint8_t * ) 0x80000000UL, 0x10000 }, // 起始地址0x80000000,大小0x10000
{ ( uint8_t * ) 0x90000000UL, 0xa0000 }, // 起始地址0x90000000,大小0xa0000
{ NULL, 0 } // 表示数组结束
};

vPortDefineHeapRegions函数原型如下:

1
void vPortDefineHeapRegions( const HeapRegion_t * const pxHeapRegions ); 

xHeapRegions数组传给vPortDefineHeapRegions函数,即可初始化Heap_5。

5.3 Heap 相关的函数

5.3.1 pvPortMalloc/vPortFree

1
2
3
4
5
6
7
8
9
10
11
12
/**
* @brief 从FreeRTOS管理的堆中分配指定大小的内存块(类似标准库的malloc)
* @param xWantedSize:size_t类型,请求分配的字节数
* @return 成功:返回分配的内存块起始地址(void*类型),失败:返回NULL(如堆空间不足或碎片化严重)
*/
void* pvPortMalloc(size_t xWantedSize);

/**
* @brief 释放由pvPortMalloc()分配的内存(类似标准库的free)
* @param pv:void*类型,指向待释放内存块的指针
*/
void vPortFree(void* pv);

5.3.2 xPortGetFreeHeapSize

1
2
3
4
5
6
/**
* @brief 返回当前堆中剩余的可分配字节数(实时值)
* @note 在heap_3中无法使用
* @return size_t类型,表示当前空闲内存量
*/
size_t xPortGetFreeHeapSize(void);

5.3.3 xPortGetMinimumEverFreeHeapSize

1
2
3
4
5
6
/**
* @brief 程序运行过程中,空闲内存容量的最小值。系统运行以来堆空间的最小剩余值(历史峰值使用量)
* @note 只有heap_4、heap_5支持此函数。
* @return size_t类型,表示历史最低空闲内存量。
*/
size_t xPortGetMinimumEverFreeHeapSize(void);

5.3.4 pvPortMalloc 失败的钩子函数

pvPortMalloc()函数内部:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void * pvPortMalloc( size_t xWantedSize )vPortDefineHeapRegions  
{
......
#if ( configUSE_MALLOC_FAILED_HOOK == 1 )
{
if( pvReturn == NULL )
{
extern void vApplicationMallocFailedHook( void );
vApplicationMallocFailedHook();
}
}
#endif

return pvReturn;
}

所以,如果想使用这个钩子函数:

  1. 在FreeRTOSConfig.h 中,把configUSE_MALLOC_FAILED_HOOK定义为1
  2. 提供vApplicationMallocFailedHook()函数
  3. pvPortMalloc()失败时,才会调用此函数

6 任务管理

对于整个单片机程序,我们称之为application,应用程序。使用FreeRTOS时,我们可以在application中创建多个任务(task),有些文档把任务也称为线程(thread)

6.1 任务/子线程创建与删除

6.1.1 什么是任务

在 FreeRTOS中,任务就是一个函数,原型如下:

1
void A_Task_Function_Example( void *pvParameters ); 
  • 要注意的是:

    • 这个函数不能返回;

    • 同一个函数,可以用来创建多个任务/线程;换句话说,多个任务/线程可以运行同一个函数;

    • 函数内部,尽量使用局部变量:

      • 每个任务/线程都有自己的栈;
      • 每个任务运行这个函数时,任务A的局部变量放在A的栈里、任务B的局部变量放在B的栈里;
      • 不同任务的局部变量,有自己的副本;
    • 函数使用全局变量、静态变量的话:

      • 只有一个副本:多个任务使用的是同一个副本;

      • 要防止冲突(后续会讲) 。

下面是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void A_Task_Function_Example( void *pvParameters )
{
/* 对于不同的任务,局部变量放在任务的栈里,有各自的副本 */
int32_t lVariableExample = 0;

/* 任务函数通常实现为一个无限循环 */
for( ;; )
{
/* 任务的代码 */
}

/* 如果程序从循环中退出,一定要使用vTaskDelete删除自己
* NULL表示删除的是自己
*/
vTaskDelete( NULL );

/* 程序不会执行到这里, 如果执行到这里就出错了 */
}

FreeRTOS使用一个链表管理任务:

6.1.2 创建任务

创建任务时可以使用2个函数:动态分配内存、静态分配内存。

  • 使用动态分配内存(TCB和栈)的函数如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include "task.h"
/**
* @breaf FreeRTOS的动态任务/线程创建函数
* @note 从FreeRTOS管理的堆中分配内存(包括任务控制块TCB和任务栈空间),并将新任务加入就绪列表等待调
* @param pxTaskCode 回调函数指针,创建出的任务/子线程的处理动作,也就是该函数在此任务/子线程中执行
* @param pcName 一个字符串,可任意定义,描述当前任务/子线程,比如"mytask"
* @param usStackDepth 任务的栈空间大小,单位:字(注意,单位不是字节)
* @param pvParameters 回调函数的参数,没有就填NULL
* @param uxPriority 指定任务的优先级
* @param pxCreatedTask 输出参数,返回新创建任务的句柄,也就是TCB结构体
* @return 任务创建成功返回1,失败返回-1
*/
BaseType_t xTaskCreate(TaskFunction_t pxTaskCode,
const char* const pcName,
const configSTACK_DEPTH_TYPE usStackDepth,
void * const pvParameters,
UBaseType_t uxPriority,
TaskHandle_t * const pxCreatedTask);

该函数的参数的详细解析请看“2.6.3 创建新的任务/子线程”小节。

  • 使用静态分配内存(TCB和栈)的函数如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include "task.h"
/**
* @breaf FreeRTOS的静态任务/线程创建函数
* @note FreeRTOS用于静态分配内存创建任务的API函数,需要用户预先分配好任务堆栈和任务TCB的内存空间
* @param pxTaskCode 回调函数指针,创建出的任务/子线程的处理动作,也就是该函数在此任务/子线程中执行
* @param pcName 一个字符串,可任意定义,描述当前任务/子线程,比如"mytask"
* @param configSTACK_DEPTH_TYPE 任务的栈空间大小,单位:字(注意,单位不是字节)
* @param pvParameters 回调函数的参数,没有就填NULL
* @param uxPriority 任务的优先级
* @param puxStackBuffer 用户提供的栈内存数组(类型为StackType_t[]),
* @param pxTaskBuffer 输出参数,静态分配的任务结构体的指针,用它来操作这个任务
* @return 任务/子线程创建后系统返回的任务/子线程句柄,用于后续任务/子线程管理操作
*/
TaskHandle_t xTaskCreateStatic(TaskFunction_t pxTaskCode,
const char * const pcName,
const uint32_t ulStackDepth,
void * const pvParameters,
UBaseType_t uxPriority,
StackType_t * const puxStackBuffer,
StaticTask_t * const pxTaskBuffer //
);

该函数是FreeRTOS中用于静态创建任务的函数,与动态创建的xTaskCreate()不同,它要求程序员预先分配任务控制块(TCB)和任务栈内存,适用于内存受限或需要确定性内存管理的场景 。

相比于使用动态分配内存创建任务的函数,只有最后2个参数和返回值不一样,因此这里只介绍最后2个参数和返回值:

  • puxStackBuffer
    • 类型:StackType_t *
    • 作用:静态分配的栈内存,比如可以传入一个数组, 它的大小要大于等于usStackDepth*4。
  • pxTaskBuffer
    • 类型:StaticTask_t *(实质为指向用户提供的TCB内存块的指针)
    • 作用:用户提供的TCB内存(StaticTask_t类型),需保证生命周期与任务一致
  • 返回值
    • ‌类型‌:TaskHandle_t(实质为StaticTask_t*的别名)
    • ‌作用‌:返回指向已初始化的TCB的指针(即任务句柄),用于后续操作任务(如删除、修改优先级等),若创建失败(如参数无效),返回NULL
    • 返回值本质指向用户提供的pxTaskBuffer内存块,但该内存已被系统初始化为有效的TCB结构体,返回值与的‌参数pxTaskBuffer指向的物理地址相同‌,但逻辑意义不同:
      • pxTaskBuffer是“原材料”(未初始化的内存——TCB结构体)。
      • 返回值是“成品”(已初始化的TCB句柄)

从这两个参数就能大概看出动态和静态两种创建方法的区别:

  • 动态方法:只需要提供任务的栈空间大小,函数内部自动动态分配内存给栈空间和TCB结构体
  • 静态方法:不仅要提供任务的栈空间大小,还要手动提供具体的栈空间puxStackBuffer‌和TCB结构体空间pxTaskBuffer,并且还要保证这两个参数的生命周期与任务一致。

示例使用:

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
// 任务函数定义(pxTaskCode将指向此函数)
void myTaskFunction(void *pvParameters) {
while(1) {
// 任务执行代码
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
}

void createStaticTask() {
// 静态分配所需内存
StaticTask_t xTaskBuffer;
StackType_t xStack[configMINIMAL_STACK_SIZE];

// 创建任务(注意参数和返回值的不同用途)
TaskHandle_t xHandle = xTaskCreateStatic(
myTaskFunction, // pxTaskCode参数: 任务函数指针
"MyTask", // 任务名称
configMINIMAL_STACK_SIZE, // 堆栈深度
NULL, // 参数
1, // 优先级
xStack, // 堆栈缓冲区
&xTaskBuffer // 任务控制块
);

if(xHandle != NULL) {
// 使用返回值进行任务管理
vTaskPrioritySet(xHandle, 2);
}
}

6.1.3 任务的删除

删除任务时使用的函数如下:

1
2
3
4
5
6
#include "task.h"
/**
* @breaf 删除任务/子线程函数,又或者称为子线程退出函数
* @param xTaskToDelete 任务句柄,使用xTaskCreate创建任务时得到的句柄。也可传入NULL,表示删除自己
*/
void vTaskDelete( TaskHandle_t xTaskToDelete );

怎么删除任务?举个不好的例子:

  • 自杀:vTaskDelete(NULL),类似于Linux线程退出函数void pthread_exit(void *retval)
  • 被杀:别的任务执行vTaskDelete(pvTaskCode),pvTaskCode是自己的句柄 ;
  • 杀人:执行vTaskDelete(pvTaskCode),pvTaskCode是别的任务的句柄 。

6.2 任务优先级和 Tick

6.2.1 任务优先级

任务/线程的优先级的取值范围是:0 ~ (configMAX_PRIORITIES–1)数值越大优先级越高。FreeRTOS的调度器可以使用2种方法来快速找出优先级最高的、可以运行的任务。使用不同的方法时,configMAX_PRIORITIES的取值有所不同。

  • 通用方法

使用C函数实现,对所有的架构都是同样的代码。对configMAX_PRIORITIES的取值没有限制。但是configMAX_PRIORITIES的取值还是尽量小,因为取值越大越浪费内存,也浪费时间。 configUSE_PORT_OPTIMISED_TASK_SELECTION被定义为0、或者未定义时,使用此方法。

  • 架构相关的优化的方法

架构相关的汇编指令,可以从一个32位的数里快速地找出为1的最高位。使用这些指令,可以快速找出优先级最高的、可以运行的任务。使用这种方法时, configMAX_PRIORITIES的取值不能超过32。 configUSE_PORT_OPTIMISED_TASK_SELECTION被定义为1时,使用此方法。

在学习调度方法之前,你只要粗略地知道:

  1. FreeRTOS会确保最高优先级的、可运行的任务,马上就能执行;
  2. 对于相同优先级的、可运行的任务,轮流执行。

获取任务的优先级

1
2
3
4
5
6
/**
* @brief 获得任务的优先级
* @param xTask 任务函数的句柄,当此参数设置为NULL时,表示获取自己任务/线程的优先级
* @return 任务的优先级
*/
UBaseType_t uxTaskPriorityGet( const TaskHandle_t xTask );

修改任务的优先级

1
2
3
4
5
6
7
/**
* @brief 修改任务的优先级
* @param xTask 任务函数的句柄,当此参数设置为NULL时,表示修改自己任务/线程的优先级
* @param uxNewPriority 表示新的优先级,取值范围是0 ~ (configMAX_PRIORITIES-1)
*/
void vTaskPrioritySet(TaskHandle_t xTask,
UBaseType_t uxNewPriority );

6.2.2 Tick

对于同优先级的任务,它们“轮流”执行。怎么轮流?你执行一会,我执行一会。 “一会”怎么定义?

就像人有心跳,心跳间隔基本恒定。 FreeRTOS中也有心跳,它使用定时器产生固定间隔的中断。这叫Tick、滴答,比如每10ms发生一次时钟中断。

如下图:

假设 t1、t2、t3发生时钟中断,两次中断之间的时间被称为时间片(time slice、tick period) ,时间片的长度由 configTICK_RATE_HZ 决定,假设configTICK_RATE_HZ为100,那么时间片长度就是10ms 。

相同优先级的任务怎么切换呢?请看下图:

上图的的流程可描述为:任务2从t1执行到t2,在t2发生tick中断,进入tick 中断处理函数,选择下一个要运行的任务执行完中断处理函数后,切换到新的任务任务1,任务1从t2执行到t3。从图中可以看出,任务运行的时间并不是严格精确地从t1,t2,t3时刻开始,中间的任务切换也需要时间。

有了Tick的概念后,我们就可以使用Tick来衡量时间了,比如:

1
2
3
4
5
// 等待2个Tick,假设configTICK_RATE_HZ=100, Tick周期时10ms, 等待20ms 
vTaskDelay(2);

// 还可以使用pdMS_TO_TICKS宏把ms转换为tick
vTaskDelay(pdMS_TO_TICKS(100)); // 等待100ms

注意,基于Tick实现的延时并不精确,比如vTaskDelay(2)的本意是延迟2个Tick周期, 有可能经过1个Tick多一点就返回了。如下面这种特殊情况:

使用vTaskDelay()函数时,建议以ms为单位,使用pdMS_TO_TICKS把时间转换为Tick。这样的代码就与configTICK_RATE_HZ无关,即使配置项configTICK_RATE_HZ改变了,我们也不用去修改代码。

6.3任务状态

以前我们很简单地把任务的状态分为2种:运行(Runing)非运行(Not Running)。但对于非运行的状态,还可以继续细分,比如前面的FreeRTOS_04_task_priority中:

Task3执行vTaskDelay 后:处于非运行状态,要过3 秒种才能再次运行,Task3运行期间,Task1、Task2也处于非运行状态,但是它们随时可以运行,这两种”非运行”状态就不一样,可以细分为:

  1. 阻塞状态(Blocked)
  2. 暂停状态(Suspended)
  3. 就绪状态(Ready)

一个任务被创建出来后处于就绪态(Ready),可以随时被调度器切换为运行态(Running)。

没有结合下面的章节内容,写到这里为止,我个人区分阻塞态和暂停态的方法是:

对于阻塞态,它是等待某个事件,当事件发生的时候,函数就会自动由阻塞态转变为就绪态,随时可运行,这个过程是自动的;而对于暂停态,必须由其他任务主动地调用vTaskResume()函数来唤醒,是被动的。

6.3.1 阻塞状态(Blocked)

在日常生活的例子中,母亲在电脑前跟同事沟通时,如果同事一直没回复,那么母亲的工作就被卡住了、被堵住了、处于阻塞状态(Blocked)。重点在于:母亲在等待。

在FreeRTOS_04_task_priority实验中,如果把任务3中的vTaskDelay()调用注释掉,那么任务1、任务2根本没有执行的机会,任务1、任务2被”饿死”了(starve)。

实际产品中,不会让一个任务一直运行,而是使用”事件驱动“的方法让它运行:

1️⃣ 任务要等待某个事件,事件发生后它才能运行;
2️⃣ 在等待事件过程中,它不消耗CPU 资源;
3️⃣ 在等待事件的过程中,这个任务就处于阻塞状态(Blocked);

在阻塞状态的任务,它可以等待两种类型的事件:

  • 一是:时间相关的事件
    • 可以等待一段时间:我等2分钟
    • 也可以一直等待,直到某个绝对时间:我等到下午3点

所谓时间相关的事件,就是设置超时时间:在指定时间内阻塞,时间到了就进入就绪状态。使用时间相关的事件,可以实现周期性的功能、可以实现超时功能。

  • 二是:同步事件,这事件由别的任务,或者是中断程序产生
    • 例子1:任务A等待任务B给它发送数据
    • 例子2:任务A等待用户按下按键

所谓同步事件,就是:某个任务在等待某些信息,别的任务或者中断服务程序会给它发送信息。

怎么”发送信息”?方法很多,同步事件的来源有很多(这些概念在后面会细讲): ① 队列(queue) ,② 二进制信号量(binary semaphores),③ 计数信号量(counting semaphores),④ 互斥量(mutexes),⑤ 递归互斥量、递归锁(recursive mutexes),⑥ 事件组(event groups),⑦ 任务通知(task notifications)。这些方法用来发送同步信息,比如表示某个外设得到了数据。

在等待一个同步事件时,可以加上超时时间。比如等待队里数据,超时时间设为10ms:
▶ 10ms之内有数据到来:成功返回
▶ 10ms到了,还是没有数据:超时返回

6.3.2 暂停状态(Suspended)

在日常生活的例子中,母亲正在电脑前跟同事沟通,母亲可以暂停:
💦 好烦啊,我暂停一会
💤 领导说:你暂停一下

FreeRTOS中的任务也可以进入暂停状态,唯一的方法是通过vTaskSuspend()函数。函数原型如下:

1
2
3
4
5
/**
* @brief 暂停任务的执行
* @param xTaskToSuspend 要暂停的任务函数的句柄,当此参数设置为NULL时,表示暂停自己的任务/线程
*/
void vTaskSuspend(TaskHandle_t xTaskToSuspend );

要退出暂停状态,只能由别的函数来操作,已经处在暂停态的任务无法唤醒自己,可通过下面两种方法:

▶ 别的任务调用:vTaskResume()

1
2
3
4
5
/**
* @brief 恢复被暂停的任务到就绪态
* @param xTaskToSuspend 被暂停的任务函数的句柄
*/
void vTaskResume(TaskHandle_t xTaskToSuspend );

▶ 中断程序调用:xTaskResumeFromISR()

1
2
3
4
5
6
7
8
/**
* @brief 在中断服务例程(ISR)中恢复被挂起的任务
* @note 在中断函数中恢复某一个被挂起的程序必须使用该函数,不能使用上面的函数,函数内部有特殊的处理
(比如xTaskResumeFromISR会临时屏蔽中断,确保恢复操作原子性)
* @param xTaskToSuspend 被暂停的任务函数的句柄
* @return 返回值是BaseType_t类型,用于指示恢复任务后的优先级状态
*/
BaseType_t xTaskResumeFromISR(TaskHandle_t xTaskToSuspend );
  • 返回参数的进一步说明:
    • pdTRUE‌:恢复的任务优先级等于或高于当前运行任务,中断退出后可能需要执行任务切换 。
    • pdFALSE‌:恢复的任务优先级低于当前运行任务,中断退出后无需执行任务切换。

在FreeRTOS实时操作系统中,‌vTaskSuspend()挂起的任务不会占用CPU资源‌。这是FreeRTOS任务调度机制的基本特性,具体表现为:

  1. 调度排除‌:挂起状态的任务会被完全移出调度器的就绪列表,调度器在选择下一个运行任务时不会考虑这些任务;
  2. 状态隔离‌:挂起状态是独立于运行/就绪/阻塞状态的特殊状态,任务进入此状态后完全脱离调度系统。

实际开发中,暂停状态用得不多。

补充:阻塞和挂起状态的区分

在FreeRTOS实时操作系统中,任务挂起(vTaskSuspend)和任务阻塞虽然都使任务不占用CPU资源且不参与调度,但两者在触发机制、恢复方式和系统管理等方面存在本质区别:

  1. 任务挂起‌是‌主动行为‌,需要显式调用API将任务移出调度系统
  2. 任务阻塞‌是‌被动行为‌,由任务等待特定事件(如信号量、延时等)自动触发
  3. 两者都不占用CPU时间片,但挂起任务完全脱离调度管理,而阻塞任务仍被特定事件链表管理
特性 任务挂起(Suspended) 任务阻塞(Blocked)
触发方式 显式调用vTaskSuspend() 自动进入(如调用vTaskDelay()、等待信号量)
状态性质 主动暂停 被动等待
典型触发场景 调试暂停、系统资源管理 定时事件、同步事件(队列/信号量/互斥锁)
超时机制 无超时 可设置超时

恢复机制差异

  • 挂起任务恢复

    • 必须显式调用vTaskResume()xTaskResumeFromISR()
    • 无自动恢复机制,即使等待的事件发生也不会唤醒任务
    • 多次挂起只需一次恢复即可使任务重新就绪
  • 阻塞任务恢复

    • 自动恢复,无需显式调用恢复函数
    • 恢复条件包括:
      • 定时事件到期(如vTaskDelay()完成)
      • 同步事件发生(如信号量给出、队列收到数据)
      • 超时时间到达(即使事件未发生)

调度器管理方式

  1. 挂起任务管理‌:
    • 被移动到专门的xSuspendedTaskList链表
    • 完全从调度器的就绪/阻塞链表中移除
    • 调度器决策时完全忽略挂起任务
  2. 阻塞任务管理‌:
    • 根据等待的事件类型进入不同链表(如延时链表、信号量等待链表)
    • 仍被调度器的事件管理系统跟踪
    • 超时或事件发生时自动转移到就绪链表

6.3.3 就绪状态(Ready)

这个任务完全准备好了,随时可以运行:只是还轮不到它,这时它就处于就绪态(Ready)。

6.4 Delay 函数

6.4.1 两个 Delay 函数

有两个Delay函数:

  • vTaskDelay():至少等待指定个数的Tick Interrupt才能变为就绪状态
  • vTaskDelayUntil():等待到指定的绝对时刻,才能变为就绪态。

这 2个函数原型如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
*@brief 延时函数
*@param 延时多少个Tick
*/
void vTaskDelay( const TickType_t xTicksToDelay ); /* xTicksToDelay: */

/**
*@brief 绝对延时函数
*@note 是FreeRTOS中用于实现周期性任的关键函数
*@param pxPreviousWakeTime 上一次被唤醒的时间
*@param xTimeIncrement 要阻塞到(pxPreviousWakeTime + xTimeIncrement)
*@return
*/
BaseType_t xTaskDelayUntil(TickType_t * const pxPreviousWakeTime,
const TickType_t xTimeIncrement );

关于xTaskDelayUntil()函数的参数和返回值的详细说明:

  • pxPreviousWakeTime‌
    • 类型:TickType_t* const(指向时间戳的指针)
    • 作用:
      • 输入时:传递任务上次唤醒时刻的时钟节拍值(需初始化为当前时间,使用下面的函数BaseType_t xTaskGetTickCount()
      • 输出时:函数内部会更新该值为下一次预期的唤醒时间(即*pxPreviousWakeTime + xTimeIncrement)。
    • 关键:必须为全局变量或静态变量,确保生命周期覆盖任务运行全程‌ 。
  • xTimeIncrement
    • 类型:const TickType_t(常量节拍数)
    • 作用:指定任务的固定周期间隔(单位:系统节拍,如1ms/节拍)。
    • 示例:若需100ms周期,且系统节拍为1ms,则传入100‌。
  • 返回值
    • 类型:BaseType_t(实际为pdTRUE/pdFALSE宏)
    • 含义:
      • pdTRUE:任务因延迟时间已过而立即唤(可能因系统节拍溢出导致)。
      • pdFALSE:任务正常进入阻塞状态,等待周期到。
    • 典型用法:通常可忽略返回值,除非需处理极端时间同步场景。

下面进一步说明这两个Delay函数的区别,假设有下面这样一个函数,这个函数里面的do_something()的运行时间不定:

1
2
3
4
5
6
7
8
9
void my_task()
{
...
while(1)
{
do_something();
vTaskDelay(4);
}
}

1
2
3
4
5
6
7
8
9
10
void my_task()
{
...
BaseType_t preTime = xTaskGetTickCount()
while(1)
{
do_something();
xTaskDelayUntil(&preTime, 7);
}
}

6.5 空闲任务及其钩子函数

6.5.1 介绍

空闲任务(prvIdleTask()任务)的作用之一:释放被删除的任务的内存。 原因如下:

如图所示,这个任务函数不是死循环,而是执行10次循环就退出了,但是退出后没有经过任何处理,这就会导致退出后函数进入prvTaskExitError()执行,在这个函数的最后两行代码的作用分别是关闭全部中断(包括系统Tick中断)、死循环,这样就会导致整个系统都无法运行了。

因此,要想正常退出任务,必须使用vTaskDelete()来自杀或者被他杀。如果是自杀,那么必须由空闲任务来收尸(回收资源),如果是他杀,则由执行杀任务的任务给被杀任务收尸(回收资源)。

除了上述目的之外,为什么必须要有空闲任务?一个良好的程序,它的任务都是事件驱动的:平时大部分时间处于阻塞状态。有可能我们自己创建的所有任务都无法执行,但是调度器必须能找到一个可以运行的任务:所以,我们要提供空闲任务。在使用vTaskStartScheduler()函数来创建、启动调度器时,这个函数内部会创建空闲任务。空闲任务有如下两个特点:

  1. 空闲任务优先级为 0:它不能阻碍用户任务运行
  2. 空闲任务要么处于就绪态,要么处于运行态,永远不会阻塞

空闲任务的优先级0,这意味着一旦某个用户的任务变为就绪态,那么空闲任务马上被切换出去,让这个用户任务运行。在这种情况下,我们说用户任务”抢占”(pre-empt)了空闲任务,这是由调度器实现的。

要注意的是:如果使用vTaskDelete()来删除任务,那么你就要确保空闲任务有机会执行,否则就无法释放被删除任务的内存。

我们可以添加一个空闲任务的钩子函数(Idle Task Hook Functions),空闲任务的循环每执行一次,就会调用一次钩子函数。钩子函数的作用有这些:

  • 执行一些低优先级的、后台的、需要连续执行的函数 ;
  • 测量系统的空闲时间:空闲任务能被执行就意味着所有的高优先级任务都停止了,所以测量空闲任务占据的时间,就可以算出处理器占用率;
  • 让系统进入省电模式:空闲任务能被执行就意味着没有重要的事情要做,当然可以进入省电模式了;

空闲任务的钩子函数的限制:

  • 不能导致空闲任务进入阻塞状态、暂停状态
  • 如果你会使用vTaskDelete()来删除任务,那么钩子函数要非常高效地执行。如果空闲任务移植卡在钩子函数里的话,它就无法释放内存。

6.5.2 使用钩子函数的前提

FreeRTOS\Source\tasks.c中,可以看到如下代码,所以前提就是:

  • 把这个宏定义为 1:configUSE_IDLE_HOOK
  • 实现vApplicationIdleHook()函数

6.6 调度算法

6.6.1配置调度算法

所谓调度算法,就是怎么确定哪个就绪态的任务可以切换为运行状态。

通过配置文件FreeRTOSConfig.h的两个配置项来配置调度算法:configUSE_PREEMPTIONconfigUSE_TIME_SLICING

PS:还有第三个配置项:configUSE_TICKLESS_IDLE,它是一个高级选项,用于关闭Tick中断来实现省电,后续单独讲解。现在我们假设configUSE_TICKLESS_IDLE=0,也即先不使用这个功能。

调度算法的行为主要体现在两方面:

  • ① 高优先级的任务先运行

  • ② 同优先级的就绪态任务如何被选中

调度算法要确保同优先级的就绪态任务能”轮流”运行,策略是轮转调度(Round Robin Scheduling)。轮转调度并不保证任务的运行时间是公平分配的,我们还可以细化时间的分配方法。

从3个层级统一理解多种调度算法:

  • 层级1:可否抢占?高优先级的任务能否优先执行(配置项: configUSE_PREEMPTION)
    • 可以:被称作“可抢占调度”(Pre-emptive),高优先级的就绪任务马上执行,并且如果高优先级的任务不执行完成或者主动让出CPU资源,则低优先级永远无法执行,该层级下面进一步再细化。
    • 不可以:不能抢就只能协商了,被称作“合作调度模式”(Co-operative Scheduling),当前任务执行时,更高优先级的任务就绪了也不能马上运行,只能等待当前任务主动让出CPU资源。
  • 层级2:可否轮流?可抢占的前提下,同优先级的任务是否轮流执行(配置项:configUSE_TIME_SLICING)
    • 可以轮流执行:被称为“时间片轮转”(Time Slicing),同优先级的任务轮流执行,你执行一个时间片、我再执行一个时间片 。
    • 不可以轮流执行:英文为”without Time Slicing”,当前任务会一直执行,直到主动放弃、或者被高优先级任务抢占。
  • 层级3:空闲任务是否让步于用户任务?(配置项:configIDLE_SHOULD_YIELD)
    • 是,则意味着空闲任务低人一等,每执行一次循环,就看看是否主动让位给用户任务。
    • 否,则意味着空闲任务跟用户任务一样,大家轮流执行,没有谁更特殊。
配置项 方案1 方案2 方案3 方案4 方案5
configUSE_PREEMPTION 1 1 1 1 0
configUSE_TIME_SLICING 1 1 0 0 x
configIDLE_SHOULD_YIELD 1 0 1 0 x
说明 常用 很少用 很少用 很少用 几乎不用

注:

  • 方案1:可抢占+时间片轮转+空闲任务让步
  • 方案2:可抢占+时间片轮转+空闲任务不让步
  • 方案3:可抢占+非时间片轮转+空闲任务让步
  • 方案4:可抢占+非时间片轮转+空闲任务不让步
  • 方案5:合作调度

未完成,请结合vision和【任务管理与调度】视频补充笔记