我的CSDN博客:FreeRTOS新手入门基本介绍-CSDN博客
1、在传统的裸机程序中,只有一个主程序流程,所以只有一个全局的栈(通常是微控制器启动时由启动代码分配的)。
然而,在实时操作系统(RTOS)中,每个“任务”都被视为一个独立的执行流。当调度器在不同任务之间切换时,它需要保存当前正在运行任务的上下文(包括它的局部变量、返回地址、CPU 寄存器状态等),然后加载即将运行任务的上下文。
为了实现这种独立的执行和上下文切换,FreeRTOS 为每个任务都分配了一个独立的栈空间。这样,当任务 A 暂停执行,任务 B 开始执行时,任务 A 的所有临时数据和状态都被保存在它自己的栈上,而任务 B 则使用它自己的栈。当任务 A 再次被调度运行时,它的上下文可以从自己的栈中恢复,仿佛从未中断过一样。
比方说,在执行LED间隔500ms亮灭并且按键还要实现计数,在裸机里用delay函数显然行不通,但是在RTOS里面使用delay函数,RTOS会在LED任务delay阻塞的时候把cup掌控权交给其他任务,也就是检测按键计次,这样就达到了“仿佛从未中断”的效果。
2、以STM32HAL库举例,在cubemx初始化FreeRTOS之后,会自动在主函数生成如下的初始化代码,程序在osKernelStart完成之后自己启动,RTOS开始调度,while(1)不会执行
还附带有一个初始任务函数,熟练者为了便于管理、初学者为了更好的学习,都不会用cubemx生成自己需要的信号量、线程等等,而是在自己创建的文件里面手动管理,一来有良好的模块化和高度封装性,二来也方便后续添加任务之后作修改,那么系统生成的那个任务函数我一般用来进行初始化(硬件初始化和外设内部RTOS线程等对象的创建)操作,原因:
① 避免栈溢出。用上RTOS的大多是个“大工程”,如果初始化全都丢到main函数里面,那么很可能导致main函数栈溢出,而这个初始化任务的栈空间是可以独立配置,在创建之初就指定了他的堆栈大小,可以根据任务的需要分配足够的栈空间,从而避免栈溢出的风险。
②**效率更高。**裸机里面会出现“开机开很久”的情况,一个初始化卡住了整个cpu,那么在RTOS里面,我们前面说过,当任务阻塞,cpu掌控权会移交,其他已经成功初始化的任务可以执行。如果出现初始化失败的情况,很多工程师做法是while(1)死循环,直接导致系统死机,那么在RTOS里就可以灵活处理,比如过段时间再初始化一次,或者通知其他任务这个子系统没有运行(可能有人就问了:如果有一个优先级比这个StartDefaultTask任务更高的任务,那么岂不是这个初始化都还没开始执行,那个任务就先执行了。实则不然,初始化任务没有执行,其他任务处于未创建的阻塞状态,cpu会选择当前优先级最高的那个就绪任务来执行,也就是我们这个系统创建的任务)
3、好了,知道程序如何启动之后,下面创建一个RTOS任务(或者叫线程),下面的代码就可以自己新建一个led.c和led.h文件
/* 1. 定义任务属性结构体并初始化 */
osThreadAttr_t led_thread_attr = {
.name = "ledTask", // 任务的字符串名称,用于调试器识别,方便调试
.attr_bits = 0, // 保留位,通常设置为 0
.cb_mem = NULL, // 指向自定义任务控制块内存的指针。NULL 表示由 RTOS 自动从堆上分配
.cb_size = 0, // 上述自定义控制块内存的大小。为 0 时,cb_mem 必须为 NULL
.stack_mem = NULL, // 指向自定义任务栈内存的指针。NULL 表示由 RTOS 自动从堆上分配
.stack_size = 1024, // 任务栈的大小,单位是字节。这是最重要的参数之一。
.priority = osPriorityNormal, // 任务的优先级
.tz_module = 0, // 与 TrustZone 相关,非安全环境应用设置为 0
.reserved = 0 // 保留位,必须设置为 0
};
/* 2. 任务函数原型 */
void led_task(void *argument); // 任务必须是一个 void* 函数
/* 3. 创建任务 */
osThreadId_t led_Handle; // 用于存储任务句柄的变量
void led_init(void) {
led_gpio_init();//硬件初始化
// 创建任务
led_Handle = osThreadNew(led_task, NULL, &led_thread_attr);
// 检查是否创建成功
if (led_Handle == NULL) {
// 任务创建失败,可能是内存不足
// 进行错误处理
}
}
/* 4. 任务函数实现 */
void led_task(void *argument) {
// 参数 ‘argument' 可以通过 osThreadNew 的第二个参数传递进来
// 例如,可以传递一个结构体指针,让不同的任务实例共享同一个函数但处理不同的数据
for(;;) { // 任务通常是一个无限循环
// 你的任务代码在这里执行
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
// 必须包含一个阻塞函数!否则高优先级任务会永远占据 CPU,导致低优先级任务无法运行。
osDelay(1000); // 延迟 1000 个时钟节拍(Tick),进入阻塞状态,让出 CPU
}
}
首先初始化任务属性结构体,这里只着重讲三个
①**name**: 只是一个标识符。在调试时,你可以看到这个名称,而不是一个晦涩的地址。没有功能上的影响,但强烈建议设置。
②**stack_size**:这是至关重要的参数。它定义了任务栈的大小。设置多少取决于任务函数的复杂程度(局部变量、函数调用深度)。一个简单的 LED 闪烁任务可能 128 字节就够,而一个处理复杂协议栈的任务可能需要 1KB 或更多。设置太小会导致栈溢出,破坏内存,造成难以调试的错误。
③**priority**: 优先级,决定任务被调度的顺序。关键任务(如电机控制)设高优先级,非关键任务(如日志打印)设低优先级。注意“优先级反转”问题,后续讲互斥量时会提到。通常大部分任务用 osPriorityNormal。
接着在初始化里面创建任务函数,第三个参数所传入的就是我们上面定义的结构体参数
另外任务函数必须是无限循环,循环内必须包含一个能让任务进入“阻塞态”的 API(osDelay()或是等待释放互斥量)。这是 RTOS 能够进行多任务调度的关键。如果一个任务一直在计算(就绪态),且优先级最高,它就会霸占 CPU,导致其他任务无法运行(“饿死”)。
在 RTOS 任务中进行延时,必须使用 osDelay() ,而不能使用 HAL_Delay()。HAL_Delay() 是一个阻塞式延时,它会暂停整个 CPU,阻止其他任务运行,这违背了 RTOS 的设计初衷。osDelay() 会将当前任务挂起,并允许调度器切换到其他就绪的任务。
4、在StartDefaultTask任务函数中调用led_init就成功初始化并创建了一个任务
任务管理
//osThreadSuspend函数可以将一个任务从就绪队列或运行状态中移除,使其停止执行。
osThreadSuspend(my_thread_id); // 挂起 my_thread_id 对应的任务
osThreadSuspend(NULL); // 传入 NULL 表示挂起自己
//osThreadResume函数可以将一个被挂起的任务重新加入到就绪队列中,使其可以被调度器再次调度。
osThreadResume(my_thread_id); // 恢复 my_thread_id 对应的任务
//osThreadTerminate函数可以结束一个任务的执行,并释放其占用的资源。
osThreadTerminate(my_thread_id); // 终止 my_thread_id 对应的任务
在确认所有任务初始化都无误之后,我们可以在StartDefaultTask函数的最后使用
**osThreadTerminate(NULL)**来终止删除这个任务
任务同步与通信
信号量(Semaphore)
在裸机里会出现这样一种情况,例如状态切换,只有状态一结束了之后才会进入状态二,或者是前面的led闪烁并且按键计次,有基础的应该都想得到在定时器里面去记录500ms,但问题是cpu会一直扫描状态一有没有完成,500ms有没有加到,这就明显造成了资源浪费。而这个两个例子也恰好是信号量里面最常用的两种情况
①**生产者-消费者模型:**生产者任务生产数据,然后释放一个信号量;消费者任务等待该信号量,获得后消费数据。
②中断与任务同步: 中断服务程序(ISR)处理完硬件事件后,释放一个信号量,唤醒等待该事件的任务进行后续处理。
二值信号量
/* 1. 定义信号量属性 */
osSemaphoreAttr_t sem_attr = {
.name = "myBinarySemaphore", // 名称,用于调试
.attr_bits = 0,
.cb_mem = NULL, // 通常为 NULL,让 RTOS 管理内存
.cb_size = 0,
};
/* 2. 创建信号量 */
// 创建一个初始计数值为 0,最大计数值为 1 的信号量(即二进制信号量)
osSemaphoreId_t mySemaphore_Handle = osSemaphoreNew(1, 0, &sem_attr);
// ^ ^
// 最大计数值(Max Count)___| |
// 初始计数值(Initial Count)___|
// 任务 A:释放(Give)信号量,增加计数值
void TaskA_Function(void *arg) {
for(;;) {
// ... 做一些工作
if (work_is_done) {
osStatus_t result = osSemaphoreRelease(mySemaphore_Handle);
// 释放信号量。如果此时有任务在等待此信号量,它会被解除阻塞。
// 如果计数值已经达到 max_count,释放会失败返回 osErrorResource。
}
osDelay(10);
}
}
// 任务 B:获取(Take)信号量,减少计数值
void TaskB_Function(void *arg) {
for(;;) {
// 等待信号量。如果信号量计数值 > 0,则立即获取,计数值减 1,然后继续执行。
// 如果信号量计数值 = 0,则任务进入阻塞态,等待直到超时或有任务释放信号量。
osStatus_t result = osSemaphoreAcquire(mySemaphore_Handle, osWaitForever);
// ^
// 超时时间,osWaitForever 表示永远等待
// 也可以设为具体 Tick 数,如 1000
if (result == osOK) {
// 成功获取到信号量,执行关键操作
} else if (result == osErrorTimeout) {
// 等待超时
} else {
// 其他错误
}
}
}
信号量属性结构体一般只指定名称,在初始化函数里面创建信号量和任务(不再赘述),二值信号量简单来说就是当信号量为0时不能被获取,此时任务处于阻塞态,B任务osSemaphoreAcquire函数会获取信号量并减一,由于是二值信号量只有0和1,只有在A任务osSemaphoreRelease函数执行释放信号量使其加一,B任务才会再次运行,否则会一直处于阻塞态
信号量
信号量与二值信号量的区别就是他不止0和1,设置osSemaphoreNew(1, 0, &sem_attr) ,第一个参数最大为多少,即可同时多少个任务运行,任务获取即减一,设置3即3个任务减一为0,那么其他想获取的任务就要等待释放。代码这里不再给出,信号量的好处用下面的例子阐明:
有一个固定大小的缓冲区,不同的生产者不断地往里写,不同的消费者不断地从里读,但是由于生产者数量大于消费者,缓冲区装不下了,那么我们就可以定义最多只有三个生产者往里面写,等消费者读完一个释放信号量,下一个生产者才能往里面写,这样就很好的调度了资源,不会造成浪费和竞争。但是聪明的你肯定就会问了,一个缓冲区,任务又是并行,假如多个任务同时往一个地址里面写怎么办,或者数据还没写完就被消费者读出去了怎么办,那么就要用到互斥量
互斥量(Mutex)
为了保证不发生上述情况(多个生产者往一个地址里面写、消费者还没读完下一个生产者也往里面写),那么我们应该使用互斥量来进行安全管理,对于多个任务可修改的共享资源,确保任何时刻只有一个任务能访问。并且互斥量还拥有优先级继承机制,可以解决优先级反转问题。
/* 1. 定义属性 */
osMutexAttr_t mutex_attr = {
.name = "myMutex",
.attr_bits = osMutexPrioInherit | osMutexRecursive, // 启用优先级继承和可递归
.cb_mem = NULL,
.cb_size = 0,
};
/* 2. 创建互斥量 */
osMutexId_t myMutex_Handle = osMutexNew(&mutex_attr);
// 共享资源,比如一个全局变量、一段缓冲区、一个外设
uint32_t shared_counter = 0;
void Task_Accessing_Shared_Resource(void *arg) {
for(;;) {
// ... 做其他事情
// 在访问共享资源前获取互斥量
osStatus_t result = osMutexAcquire(myMutex_Handle, osWaitForever);
if (result == osOK) {
// 成功获取互斥量,现在可以安全地访问共享资源
shared_counter++;
// 一些复杂的操作...
// 访问结束后,释放互斥量
osMutexRelease(myMutex_Handle);
}
osDelay(1);
}
}
定义属性结构体,名称随意,有多个任务且优先级不一样需要修改同一个变量时,需要启用优先级继承,当现有代码结构中存在嵌套加锁的需求,需要启用可递归。在初始化里面创建互斥量和任务函数,只有在任务函数成功获取互斥量时,才能够访问修改变量。聪明的你肯定就会问了,这不是多此一举吗,刚刚二值信号量也是这个作用,等别人释放自己才能调用,这俩有啥区别呀。
①主要用于实现对共享资源的“互斥访问”,即一次只允许一个任务独占性地访问资源。互斥量更侧重于资源的所有权,由获取它的任务来释放。而二值信号量可以通过中断或者其他任务函数释放。
②互斥量具有优先级继承机制,而信号量没有。
现在,聪明的你肯定会想到,假如有一个高优先级事件A、中优先级事件B(12345678,很多个B)、低优先级事件C,A现在在等待C释放互斥量,但是C前面还有很多个优先级比它高的事件在运行,那么就会导致A阻塞很久,甚至无法运行,而互斥量的优先级反转会完美解决这个问题,任务C会在获取互斥量的时候由RTOS暂时提升为任务A的高优先级状态,等任务执行完释放互斥量之后,任务C又回到它本身的优先级状态。
可递归又有什么实际使用优势呢?当一个任务A获取了互斥量,任务A里面调用了任务B,任务B也需要获取该互斥量,由于A刚刚获取该互斥量还没被释放,那么此时调用任务B时会认为处于阻塞态,这样就阻塞了整个任务的运行,导致死锁。当一个互斥量被设置为 osMutexRecursive 属性时,它就成为了一个可递归互斥量。这意味着同一个任务可以多次获取(加锁)这个互斥量,而不会导致死锁。
这里着重强调,当一个变量需要被多个任务修改时,务必要使用互斥量保证安全,但如果仅仅是被多个任务读取,不被修改,那么就可以不用互斥量
事件组(Event Flags)
未完待续……


