(嵌入式八股)No.4 FreeRTOS(一个月左右)
写在前面
FreeRTOS 是一个小型的、可抢占式的实时操作系统(RTOS),广泛应用于嵌入式系统开发。它提供了任务调度、信号量、队列、互斥量等基本功能,帮助开发者高效地管理多任务并发运行。
FreeRTOS 是完全免费的,支持多种微控制器(MCU)和开发平台,适合资源受限的嵌入式设备。
这个的话推荐先看一下韦东山老师的PDF,就174页,很快就能翻完,知道这个概念就行,重点掌握抢占式任务调度、内存管理、消息队列、锁
建议后面阅读RTOS源码:需要掌握如结构体、指针、数组、函数指针、指针函数、数组指针、指针数组等这些基础知识点。
其次,一 些常用的数据结构要掌握,如链表、队列等。
4.1 堆和栈(这个必考,务必掌握)
先看一下内存分区吧,这个图最好掌握并且熟记。

4.1.1 栈(由高地址向低地址生长)
定义
- 栈是一种后进先出(LIFO)的数据结构,用于存储局部变量、函数调用的上下文信息(如返回地址、参数等)。
特点
- 自动管理:栈的内存分配和释放是自动的,由编译器管理。
注意事项
- 栈溢出:(常问:如何去快速分配一个任务的堆栈大小?怎么监测栈溢出?)
- 栈的大小有限,如果局部变量占用过多内存(如递归调用过深或定义了过大的局部数组),可能会导致栈溢出,进而导致程序崩溃。
void recursive_function(int depth) {
int large_array[1000000]; // 定义过大的局部数组
recursive_function(depth + 1);
}
int main() {
recursive_function(0);
return 0;
}
4.1.2堆(由低地址向高地址生长)
定义
- 堆是一种动态分配的内存区域,用于存储动态分配的数据(如通过 malloc、calloc、realloc 等函数分配的内存)。一般都是程序员手动分配!
特点
- 手动管理:堆的内存分配和释放需要手动管理,由程序员通过调用动态内存分配函数(如 malloc、free)来完成。
- 动态分配:堆内存的大小可以在运行时动态调整。
- 生命周期:堆内存的生命周期由程序员控制,直到调用 free 函数释放。
- 内存分配速度:堆的内存分配和释放速度相对较慢,因为需要进行复杂的内存管理(如查找空闲内存块、合并内存块等)。
- 大小灵活:堆的大小通常较大,受限于系统的可用内存。
注意事项
- 堆内存需要手动管理,如果忘记释放动态分配的内存,会导致内存泄漏。
int* ptr = (int*)malloc(sizeof(int));// 忘记调用free(ptr);
- 如果释放了未分配的内存或重复释放内存,会导致未定义行为。
free(ptr);free(ptr); // 重复释放
4.1.3 总结
特性 | 栈(Stack) | 堆(Heap) |
内存分配方式 | 自动分配和释放(由编译器管理) | 手动分配和释放(由程序员管理) |
存储内容 | 局部变量、函数调用的上下文信息 | 动态分配的数据(如通过malloc分配的内存) |
生命周期 | 函数调用结束后自动释放 | 直到调用 |
内存分配速度 | 快(连续内存空间,操作简单) | 慢(需要复杂的内存管理) |
大小限制 | 通常有限(几MB) | 通常较大(受限于系统可用内存) |
使用场景 | 局部变量、函数调用 | 动态数据结构(如链表、数组等) |
安全性 | 相对安全(栈溢出可能导致程序崩溃) | 需要手动管理,容易出现内存泄漏等问题 |
4.1.4 其他
代码段、.data数据段、bss段构成可执行程序
(1)编译器在编译程序的时候,将程序中所有元素分成了几个部分,各部分构成一个段,所以说段是可执行程序的组成部分。
(2)代码段:就是程序中的可执行部分,直观理解代码段就是函数堆叠而成的。
(3).data数据段:放置初始化了的全局变量和静态变量。
(4)bss段:放置未被显式初始化或被显式初始化为0的全局变量和静态变量。
有些特殊数据会被放到代码段
(1)C语言中使用char *p = “freertos”定义字符串指针时,字符串”freertos”实际被分配在代码段,也就是说它是一个常量字符串。
(2)const型常量:const的实现方法至少有两种,第一种就是编译器将const常量放在代码段实现不能被修改(普遍见于各种单片机编译器);第二种就是由编译器来检查以确保const常量不会被修改,实际上它是存放在数据段中的(gcc)。
总结:C语言中所有变量和常量所使用的内存无非以上三种情况,栈、堆、全局(静态)区。
(1)相同点:三种获取内存的方法,都可以给程序提供可用内存。
(2)不同点:栈内存对应于C语言中的普通局部变量;堆内存完全是独立于程序的存在和管理的;.data数据段和bss段对应于C语言中的全局变量和静态局部变量。
(3)函数内部临时使用,就定义局部变量;如果一个变量只在程序的一个阶段有用,就用堆内存;如果一个变量是和程序一生相伴的,就用全局变量或静态局部变量。
4.2 中断(面试必考的)
中断处理通常是指在嵌入式系统或实时操作系统中,程序对硬件中断信号的响应和处理。C语言本身并没有直接支持中断处理的语法,但可以通过一些特定的机制(如中断服务例程、中断向量表等)来实现中断处理。中断是一种机制,允许硬件设备在需要时中断当前正在执行的程序,请求处理器执行特定的任务。中断处理程序(Interrupt Service Routine, ISR)是响应中断的函数,用于处理中断请求。
4.2.1 特点
- 异步性:中断可以在程序的任何时刻发生。
- 优先级:中断有不同的优先级,高优先级的中断可以打断低优先级的中断。
- 快速响应:中断处理程序需要尽可能快地完成,以减少对主程序的影响。(所以中断函数运行时间一定要短!!!)
4.2.2 中断处理的基本步骤
- 中断请求:硬件设备发出中断信号。
- 中断响应:处理器检测到中断信号后,保存当前程序的状态(如寄存器值、程序计数器等)。
- 中断处理:处理器调用中断处理程序(ISR)来处理中断请求。
- 中断返回:中断处理程序执行完毕后,处理器恢复之前保存的状态,继续执行主程序。
4.2.3 中断处理的注意事项
- 快速响应:中断处理程序应尽可能简洁,避免长时间占用处理器。
- 禁用中断:在中断处理程序中,可能需要禁用其他中断,以防止嵌套中断。
- 数据保护:中断处理程序可能访问共享数据,需要使用互斥机制(如互斥锁)来保护数据。
- 中断优先级:合理设置中断优先级,确保高优先级中断能够及时响应。
- 不能调用任何不可重入的函数。
举个栗子:
interrupt double compute_area (double radius)
{
double area = PI * radius * radius;
printf(" Area = %f", area);return area;
}
- ISR 不能有返回值:中断是异步事件,调用者是硬件而非函数,返回值无处可接收。应改为
void类型。 - ISR 不能带参数:硬件中断机制不支持参数传递,向量表只保存函数地址。所有输入应通过全局变量或硬件寄存器获取。
- 不应进行浮点运算:浮点运算涉及专用寄存器(FPU 上下文),中断进出时通常不自动保存,易导致寄存器数据破坏。
- 不应调用不可重入函数(如
printf):printf执行时间长、使用全局缓冲区、不可重入,在中断中调用可能引发数据竞争、栈溢出或系统崩溃。
RTOS中记住一个寄存器basepri
FreeRTOS就专门做了一种新的开关中断实现机制。关闭中断时仅关闭受FreeRTOS管理的中断,不受FreeRTOS管理的中断不关闭,这些不受管理的中断都是高优先级的中断,用户可以在这些中断里面加入需要实时响应的程序。FreeRTOS能够实现这种功能的奥秘就在于FreeRTOS开关中断使用的是寄存器basepri,而像uCOS这种使用的是primask。
比如我们配置寄存器basepri的数值为16,所有优先级数值大于等于16的中断都会被关闭,优先级数值小于16的中断不会被关闭。对寄存器basepri寄存器赋值0,那么被关闭的中断会被打开。这个就是FreeRTOS开关中断的实现方案。
4.3 进程、线程
在操作系统中,进程(Process)和线程(Thread)是两个非常重要的概念,它们是程序运行的基本单位。
4.3.1 进程(Process)
定义
进程是操作系统分配资源的基本单位。一个进程是一个正在运行的程序的实例,它包含了程序的代码、数据、堆栈、文件描述符等资源。
特点
- 独立性:每个进程都有自己独立的地址空间,包括代码段、数据段、堆和栈。
- 资源分配:操作系统为每个进程分配独立的资源,如内存、文件描述符、信号等。
- 调度单位:进程是操作系统调度的基本单位,操作系统通过进程调度算法来分配CPU时间。
- 生命周期:进程从创建到结束有一个完整的生命周期,包括创建、运行、阻塞、终止等状态。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork(); // 创建一个子进程
if (pid == 0) {
// 子进程
printf("Child process, PID: %d\n", getpid());
} else if (pid > 0) {
// 父进程
printf("Parent process, PID: %d\n", getpid());
wait(NULL); // 等待子进程结束
} else {
// 错误
printf("Fork failed\n");
}
return 0;
}
输出:
Parent process, PID: 1234
Child process, PID: 1235
4.3.2 线程(Thread)
定义
线程是操作系统调度的基本单位,是进程中的一个执行单元。一个进程可以包含多个线程,这些线程共享进程的资源,但每个线程有自己的程序计数器、堆栈和局部变量。
特点
- 轻量级:线程比进程更轻量级,创建和切换线程的开销比进程小。
- 共享资源:线程共享进程的资源,包括内存、文件描述符等。
- 调度单位:线程是操作系统调度的基本单位,操作系统通过线程调度算法来分配CPU时间。
- 并发性:多个线程可以并发执行,提高程序的效率。
#include <stdio.h>
#include <pthread.h>
void* thread_function(void* arg) {
printf("Thread is running\n");
return NULL;
}
int main() {
pthread_t thread;
pthread_create(&thread, NULL, thread_function, NULL); // 创建线程
printf("Main thread is running\n");
pthread_join(thread, NULL); // 等待线程结束
return 0;
}
输出:
Main thread is running
Thread is running
4.3.3 总结
特性 | 进程(Process) | 线程(Thread) |
资源分配 | 操作系统为每个进程分配独立资源 | 线程共享进程的资源 |
地址空间 | 每个进程有自己的独立地址空间 | 线程共享进程的地址空间 |
创建和销毁 | 创建和销毁进程的开销较大 | 创建和销毁线程的开销较小 |
通信方式 | 进程间通信(IPC)需要使用特定机制 | 线程间通信可以通过共享变量实现 |
调度单位 | 进程是调度的基本单位 | 线程是调度的基本单位 |
并发性 | 多进程并发执行 | 多线程并发执行 |
生命周期 | 从创建到结束的完整生命周期 | 从创建到结束的完整生命周期 |
联系
- 线程是进程的一部分:线程是进程中的一个执行单元,多个线程可以并发执行,提高进程的效率。
- 线程共享进程资源:线程共享进程的资源,包括内存、文件描述符等,因此线程间的通信比进程间通信更高效。
- 线程的调度依赖于进程:线程的调度依赖于进程的调度,操作系统首先调度进程,然后在进程内部调度线程。
注意
- 线程安全:多线程程序需要考虑线程安全问题,避免数据竞争和死锁。
- 同步机制:可以使用互斥锁(Mutex)、信号量(Semaphore)、条件变量(Condition Variable)等同步机制来协调线程间的操作。
- 性能优化:合理使用线程可以提高程序的性能,但过多的线程可能会导致上下文切换开销增加。
4.4 任务调度算法(重中之重)
4.4.1 裸机系统
在 51、STM32 等单片机裸机编程(未使用操作系统)时,程序的执行结构一般是:
- 主函数main() 中包含一个无限循环 while(1),用于不断地执行各项任务;
- 中断服务函数(ISR) 用于响应外部事件或定时中断,完成一些对实时性要求较高的处理。
这种结构被称为 单任务系统 或 前后台系统(Foreground-Background System)。
前后台系统结构简单、资源占用少,但实时性差、扩展性弱;
在复杂或实时性要求高的嵌入式应用中,就必须采用多任务系统(RTOS)来实现高效的任务管理与调度。
4.4.2 多任务系统
多任务系统通过“分而治之”和任务并发执行的方式,把复杂问题拆解成多个独立任务,由系统调度快速切换执行,从而实现高效、灵活、实时的任务管理。这些任务是并发处理的,注意,并不是说同一时刻一起执行很多个任务,而是由于每个任务执行的时间很短,导致看起来像是同一时刻执行了很多个任务一样。(务必记住这张图)
● 运行态
当一个任务正在运行时,那么就说这个任务处于运行态,处于运行态的任务就是当前正在
使用处理器的任务。如果使用的是单核处理器的话那么不管在任何时刻永远都只有一个任务处于运行态。
● 就绪态
处于就绪态的任务是那些已经准备就绪(这些任务没有被阻塞或者挂起),可以运行的任务,但是处于就绪态的任务还没有运行,因为有一个同优先级或者更高优先级的任务正在运行!
● 阻塞态
如果一个任务当前正在等待某个外部事件的话就说它处于阻塞态,比如说如果某个任务调
用了函数 vTaskDelay()的话就会进入阻塞态,直到延时周期完成。任务在等待队列、信号量、事件组、通知或互斥信号量的时候也会进入阻塞态。任务进入阻塞态会有一个超时时间,当超过这个超时时间任务就会退出阻塞态,即使所等待的事件还没有来临!
● 挂起态
像阻塞态一样,任务进入挂起态以后也不能被调度器调用进入运行态,但是进入挂起态的
任务没有超时时间。任务进入和退出挂起态通过调用函数 vTaskSuspend()和 xTaskResume()。
任务特性:
在使用 RTOS 的时候每个任务都有自己的运行环境。任何一个时间点只能有一个任务运行,RTOS 调度器因此就会重复的开启、关闭每个任务。RTOS 调度器的职责是确保当一个任务开始执行的时候其上下文环境(寄存器值,堆栈内容等)和任务上一次退出的时候相同。为了做到这一点,每个任务都必须有个堆栈,当任务切换的时候将上下文环境保存在堆栈中,这样当任务再次执行的时候就可以从堆栈中取出上下文环境,任务恢复运行。
4.4.3 TCB(了解)
在 RTOS 中,调度器负责管理任务的执行与切换;通过为每个任务保存和恢复上下文(寄存器、堆栈等),系统实现了多任务的独立运行与平滑切换。
TCB 是 RTOS 调度器用来识别、管理和切换任务的关键结构。 调度器通过 TCB 来:
- 保存任务运行时的上下文(寄存器、堆栈指针等);
- 记录任务的优先级、状态;
- 维护任务之间的调度关系(如就绪链表、延时链表等)。
typedef struct tskTaskControlBlock
{
volatile StackType_t *pxTopOfStack; // 当前任务栈顶指针(保存上下文时使用)
ListItem_t xStateListItem; // 任务在就绪、阻塞或挂起列表中的节点
StackType_t *pxStack; // 任务栈的起始地址
char pcTaskName[configMAX_TASK_NAME_LEN]; // 任务名
UBaseType_t uxPriority; // 任务优先级
StackType_t *pxEndOfStack; // 栈末尾指针(用于检测栈溢出)
void *pvParameters; // 任务入口函数参数
TaskFunction_t pxTaskCode; // 任务函数入口
BaseType_t xTaskRunTimeCounter; // 任务运行时间统计(可选)
} TCB_t;
+-----------------------------+
| tskTCB |
|-----------------------------|
| pxTopOfStack ---> 栈顶地址 |
| pxStack ---> 栈起始地址 |
| uxPriority ---> 当前优先级 |
| xStateListItem ---> 状态链表节点 |
| xEventListItem ---> 事件链表节点 |
| pcTaskName ---> "Task1" |
| ulRunTimeCounter ---> 运行统计 |
| ...(其他字段)... |
+-----------------------------+
TCB 在任务切换中的作用
当任务创建时:
- 系统分配 TCB;
- 初始化任务堆栈;
- 把任务插入就绪队列。
当任务切换时:
- 当前任务的寄存器值等上下文被保存在其 TCB 对应的堆栈中;
- 下一个任务的上下文从其 TCB 堆栈中恢复;
- pxCurrentTCB 指针指向当前正在运行的任务的 TCB。
4.4.4 抢占式任务调度
抢占式任务调度 是 RTOS 的核心机制之一,当有更高优先级任务就绪时,系统会立即中断当前任务,保存上下文并切换执行;这样可以显著提升系统的实时响应能力,让关键任务始终得到优先处理。
场景 | 说明 |
新任务就绪 | 某个高优先级任务因为事件、通知或中断唤醒。 |
时间片到期 | 当前任务耗尽时间片(在时间片轮转中)。 |
任务主动让出 CPU | 调用 taskYIELD()、vTaskDelay()、xQueueReceive() 等。 |
中断中唤醒了高优先级任务 | ISR(中断服务函数)中调用 portYIELD_FROM_ISR() 触发调度。 |
4.4.5 调度的核心机制(务必掌握)
高优先级任务进入就绪态
- 例如:某个中断服务函数唤醒了优先级更高的任务。
触发 PendSV 中断
- 内核设置 PendSV 触发软中断,准备进行任务切换。
保存当前任务上下文
- 将当前任务的寄存器内容压入其堆栈;
- 更新当前任务 TCB 中的 pxTopOfStack。
选择最高优先级就绪任务(本质就是链表的切换)
- 调度器扫描就绪任务列表;
- 找到优先级最高的任务对应的 TCB。
恢复新任务上下文
- 从新任务的堆栈中弹出寄存器内容;
- 恢复 CPU 状态,跳转执行该任务。
更新 pxCurrentTCB
- pxCurrentTCB 指针指向新任务的 TCB。
4.4.6 时间片轮转
时间片轮转:同一优先级的多个就绪任务共享 CPU 时间。调度器会为每个任务分配一个时间片(通常是一个系统节拍 tick),当任务的时间片用完,调度器就会切换到同一优先级的下一个就绪任务运行,从而实现公平调度。
4.5 PendSv
PendSV(Pendable Service Call,延迟可挂起的系统服务调用异常)是 Cortex-M 内核 专门为 RTOS(实时操作系统)任务切换 设计的一个异常类型。
特点
- 可挂起(Pendable) 你可以通过软件设置触发(写 SCB->ICSR 的 PENDSVSET 位),不会被硬件自动触发。
所以,它常用于系统软件层的调度,比如任务切换。
- 最低优先级 通常设置为所有异常中最低优先级,以确保只有当没有其他中断时才执行任务切换。
- 软件触发 通常由 portYIELD() 或 xTaskSwitchContext() 内部触发:
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; // 触发PendSV异常
PendSV 在 FreeRTOS 中的作用
主要用于任务上下文切换(context switch)。 当调度器决定要从任务 A 切换到任务 B 时,会触发 PendSV 异常,让 CPU 进入异常处理模式,在其中:
- 保存当前任务(A)的上下文;
- 恢复下一个任务(B)的上下文;
- 返回到任务 B 的执行点。
PendSV 触发与执行流程
1.任务请求切换
例如在 vTaskDelay()、xSemaphoreGive() 等 API 调用中,系统可能发现当前任务需要让出 CPU,此时:
portYIELD(); // 触发 PendSV
底层会设置:
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
2.PendSV 被触发
当 CPU 空闲(没有更高优先级中断)时,进入 PendSV_Handler。
3.PendSV_Handler 执行(汇编实现)(了解)
__asm void xPortPendSVHandler(void)
{
extern pxCurrentTCB; // 当前任务控制块指针
extern vTaskSwitchContext; // 调度函数
mrs r0, psp // 取出当前任务的进程栈指针
ldr r3, =pxCurrentTCB
ldr r2, [r3] // r2 = 当前任务的 TCB 指针
stmdb r0!, {r4-r11} // 保存 R4-R11 寄存器到当前任务堆栈
str r0, [r2] // 更新 TCB 中的栈指针值
push {lr}
cpsid i
bl vTaskSwitchContext // 调用C函数,选择下一个任务
cpsie i
pop {lr}
ldr r3, =pxCurrentTCB
ldr r1, [r3]
ldr r0, [r1] // 取出下一个任务的堆栈指针
ldmia r0!, {r4-r11} // 恢复 R4-R11
msr psp, r0 // 恢复进程栈指针
bx lr // 返回到新任务
}
与 Systick 的关系
- SysTick:周期性中断,用于产生系统“节拍”,触发任务调度请求;
- PendSV:真正执行上下文切换(保存、恢复寄存器)。
PendSV 是 Cortex-M 提供的用于延迟执行、最低优先级的软件异常。FreeRTOS 借助 PendSV 实现任务上下文切换,是任务调度的核心机制。
4.6 上下文切换
什么是上下文(Context)(了解)
上下文指的是 CPU 执行任务时的完整运行环境,包括:
- CPU 寄存器的内容(如 R0–R12、LR、PC、PSR)
- 任务的堆栈内容
- 程序计数器(PC) —— 表示任务执行到哪一行
- 堆栈指针(PSP/SP) —— 表示任务当前栈的位置
换句话说,上下文 = 让任务能从中断处继续执行所需的全部信息。
什么是上下文切换(Context Switch)
上下文切换就是:
当操作系统(RTOS)决定 CPU 需要从当前任务 A 切换到另一个任务 B 时, 系统要 保存任务 A 的上下文,并 恢复任务 B 的上下文,让 CPU 从 B 上次暂停的地方继续执行。
为什么需要上下文切换
因为在多任务系统中:
- 同一时间 CPU 只能执行一个任务;
- 不同任务会轮流使用 CPU。
如果不保存任务 A 的执行状态,那么下次再切回来时就“忘记自己执行到哪了”。
👉 因此:
- 切出任务:保存现场(保存寄存器、堆栈等);
- 切入任务:恢复现场(恢复寄存器、堆栈等)。
FreeRTOS 中的上下文切换流程
以下是 FreeRTOS 在 Cortex-M 内核 上的典型上下文切换过程(依赖 PendSV 异常):
1.触发切换
- 任务延时、释放信号量或中断中唤醒更高优先级任务时,FreeRTOS 调用:
- SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; // 触发 PendSV
2.进入 PendSV_Handler(汇编级)
在异常入口:
- 保存当前任务上下文
- mrs r0, psp ; 取出当前任务的堆栈指针 stmdb r0!, {r4-r11} ; 保存R4~R11到任务堆栈 str r0, [pxCurrentTCB] ; 更新TCB中记录的任务堆栈位置
3.调用调度函数
- vTaskSwitchContext() 会更新 pxCurrentTCB 指向 下一个要运行的任务。
4.恢复新任务上下文
ldr r0, [pxCurrentTCB] ; 获取新任务的TCB
ldr r0, [r0] ; 取出新任务堆栈指针
ldmia r0!, {r4-r11} ; 恢复R4~R11
msr psp, r0 ; 设置PSP为新任务堆栈
5.返回到新任务执行
- PendSV 异常返回时,硬件自动恢复 R0–R3、R12、LR、PC、xPSR;
- CPU 开始执行新任务。
类型 | 保存方式 | 包含的寄存器 |
硬件自动保存 | 进入异常时由硬件自动压栈 | R0–R3, R12, LR, PC, xPSR |
软件手动保存 | 在 PendSV_Handler 中保存 | R4–R11 |
从入门到上岸,一站式搞定求职! 本硕纯机械,无竞赛无论文,后转行嵌入式软件开发(因为课题组师哥转嵌入式拿到30Woffer之后狠狠心动),秋招最终收获35W+offer可以为27届或者28届的的UU们提供参考,可以关注一下!!!
