一、.FREERTOS里面任务调度优先级与裸机开发中的中断优先级有什么不同?
简单说:
- FreeRTOS 的任务优先级:决定“哪个任务先运行”。
- 裸机中断优先级:决定“哪个中断先响应、哪个中断可以打断另一个中断”。
它们属于不同层次,不能直接等同。
| 对比项 | FreeRTOS 任务优先级 | 裸机/硬件中断优先级 |
|---|---|---|
| 作用对象 | 软件任务 | 硬件中断 |
| 管理者 | FreeRTOS 调度器 | MCU 的中断控制器,如 NVIC |
| 主要作用 | 决定哪个就绪任务获得 CPU | 决定哪个中断可以抢占另一个中断或当前代码 |
| 是否由硬件直接执行 | 否,由调度器决定 | 是,由硬件中断机制决定 |
| 抢占对象 | 低优先级任务 | 低优先级中断、普通程序或任务 |
| 优先级数量 | 由configMAX_PRIORITIES等配置决定 | 由 MCU 的中断优先级位数决定 |
| 是否能阻塞 | 任务可以延时、阻塞、等待队列 | ISR 通常不能阻塞或延时 |
| 执行时间要求 | 可以较长,但不能长期占用 CPU | 应尽量短,避免影响其他中断 |
高优先级中断可以抢占低优先级任务和中断,低优先级任务可以被高优先级任务抢占,不能抢占中断。
例如:
任务 A 正在运行
↓
定时器中断到来
↓
CPU 进入 ISR
↓
ISR 唤醒了高优先级任务 B
↓
退出 ISR 后,FreeRTOS 发现 B 比 A 优先级高
↓
切换到任务 B
注意:FreeRTOS与中断之间的限制关系
1.任务优先级不是中断优先级,任务优先级只用于决定多个“就绪任务”之间谁运行。它不能改变硬件中断的响应优先级。
2.中断通常比任务优先级更高一层,中断可以打断任务,任务不能打断正在执行的中断。即使任务优先级设置的很高,也不能直接抢占ISR。
3.FreeRTOS任务优先级通常是数值越大,优先级越高。
4.FreeRTOS对中断有额外限制,在ISR中不能随意条用普通FreeRTOS API,只能调用以FromISR结尾的API。
例如:
xQueueSendFromISR()
vTaskNotifyGiveFromISR()
xSemaphoreGiveFromISR()
另外,某些中断优先级过高时,不能调用FreeRTOS API。
高于该限制的中断可以快速响应,但不能调用FreeRTOS内核API;允许调用FreeRTOS API的中断必须处于规定的优先级范围内。
总结:
中断优先级决定“哪个硬件事情优先处理”;任务优先级决定“中断处理完后,哪个软件任务先运行。
设计时通常是:
紧急、时间敏感的事情放在中断中处理。
中断只完成采样、清标志、发送通知等快速操作;
复杂处理放在任务中;
通过任务优先级决定不同业务任务的运行顺序。
二、总堆和任务栈
1.总堆:
FreeRTOS在程序编译时,会分配一块连续的内存,作为总堆。其本质是一个数组。可以使用pvPortMalloc()、vPortFree()函数从中申请和释放内存。FreeRTOS使用heap_1.c~heap_5.c来管理堆的分配。
是系统预留的一块连续内存区域,专门用于动态内存分配,程序中需要临时申请一块内存时,(创建任务、队列、信号量或者调用malloc、pvPortMalloc)就会从总堆中划出一块来使用,用完后可以释放,内存归还给总堆。
特点:
全局共享:所有任务和模块都可以从总堆申请内存。
按需分配:申请多少给多少,用完释放。
需要管理:频繁的申请/释放可能导致内存碎片。
大小固定:在 RTOS 配置中通常由宏定义,例如 FreeRTOS 的
configTOTAL_HEAP_SIZE。
可以把总堆想象成一个“公共仓库”,谁需要空间就去里面租一块,用完归还。
2.任务栈:
每个(线程)任务独立拥有,用于存放任务内的信息。当被打断或调用子函数时,需要保存现场,就把当前的任务信息压入栈中,下次回复时再从栈中取出。压栈和出栈由系统自动操作,不需要手动管理。动态分配管理下,FreeRTOS会从总堆中申请一块内存,作为任务栈;静态分配下,用户可以指定一块内存,作为任务栈。
栈里存什么?
1.函数调用时返回的地址
2.局部变量
3.函数参数
4.CPU寄存器现场
特点:
1.任务私有:每个任务有自己的栈,互不干扰。
2.后进先出:像叠盘子一样,函数调用一层层压栈,返回时一层层弹出。
3.自动管理:由编译器自动分配和释放,无需后动干预
4.大小固定:创建任务时需要是定栈的大小,比如 FreeRTOS 的xTaskCreate中的usStackDepth参数。
任务栈可以看作是每个工人的“个人工作台”,工人工作时所有的临时工具、草稿纸都放在自己的台面上,别人不能乱动。
3.总堆和任务栈的关系
在很多RTOS中,任务栈的内存可以从总堆中分配,例如创建任务时,RTOS会从总堆里申请一块内存作为该任务的栈;同时任务控制款(TCB)等结构体也可能从总堆分配。但这不意味着任务栈就是总堆,他们只是存储位置上的关系
也可以用静态数组的的方式为每一个任务单独分配栈,这时任务栈就不占用总堆。
4. 总堆 vs 任务栈:对比表格
| 对比项 | 总堆(Heap) | 任务栈(Task Stack) |
|---|---|---|
| 归属 | 全局共享 | 每个任务私有 |
| 用途 | 动态内存分配(任务栈、队列等) | 保存运行上下文、局部变量 |
| 管理方式 | 手动申请/释放(可能产生碎片) | 自动压栈/弹栈 |
| 分配方向 | 按算法分配,地址可能不连续 | 通常向低地址增长(连续) |
| 溢出后果 | 分配失败、内存耗尽 | 栈溢出,可能破坏其他数据 |
| 配置方式 | 宏定义总大小 | 创建任务时指定栈大小 |
| 生命周期 | 由程序控制 | 随任务存在而存在 |
五、如果利用二进制信号量配合按键控制串口,按下一次按键,串口就发送一次相应的数据,在这个情况下,如果我快速的按下按键,这个二进制信号量会丢失数据吗?
!!!!会丢失数据(事件)。
如果你连续快速按下按键,中间的某些按键动作会被“吞掉”,串口只会发送部分数据,绝不会“按一次发一次”一一对应。
这并非系统崩溃,而是二进制信号量(Binary Semaphore)的底层机制先天决定的。为了让你彻底明白,我用一个“信箱”的比喻来解释:
1. 为什么二进制信号量会丢数据?
二进制信号量的内部核心只有一个“标志位”(0 或 1),它就像一个只能放一封信的邮箱:
状态 0(空):允许投递。
状态 1(满):拒绝所有后续投递。
快速按键下的时间线推演(假设串口发送耗时 5ms):
第 1 次按键:中断释放信号量(0→1,邮箱放入第 1 封信)。任务被唤醒,拿走信号量(1→0,清空邮箱),开始发送串口数据(耗时 5ms)。
第 2 次按键(在第 1 次发送结束前按下):中断尝试释放信号量。此时信号量为 0(空),释放成功(0→1,邮箱放入第 2 封信)。系统记住了“又有一次按键”。
第 3 次按键(极快,在第 1 次发送结束前再次按下):中断尝试释放信号量。此时信号量已经是 1(满),释放操作被直接忽略(邮箱已满,这封信被当场丢弃)。
第 1 次发送完成:任务循环回来,获取信号量(1→0),取出的是第 2 次按键的记录,发送数据。
发送结束:任务等待,信号量为 0。第 3 次按键永远消失了。
结论:二进制信号量只能记录“是否有按键发生”,无法记录“发生了多少次”。快速按下时,只有最后一次“唤醒动作”被保留,中间的按键会被覆盖。
2. 如何解决?(两种方案)
方案一:改用计数信号量(Counting Semaphore)——简单升级
将osSemaphore改为计数信号量,并设置一个较大的上限(比如 10 或 255)。
原理:计数信号量像一个可以放多封信的邮筒。每按一次按键,计数值 +1;任务处理完一次,计数值 -1。
效果:连续按下 5 次,计数值会累加到 5。任务会连续处理 5 次串口发送,不会丢失。
缺点:如果手速快得离谱,按了 20 次而邮筒上限只有 10,后 10 次依然会丢。而且按键没有区分“短按”和“长按”的数据。
方案二:改用消息队列(Message Queue)——【强烈推荐】业界标准
既然你的目的是“串口发送相应的数据”,为什么不直接把“要发送的数据”存起来呢?
原理:将
osMessageQueue的大小设置为 10 或 20。每次按键,把按键码(比如0x01)直接Put进队列。效果:快速按下 10 次,队列里就有 10 个数据包。任务端循环
Get,取出一个就发送一个,百分百不丢数据,且不需要手动管理堆内存(直接传递值即可)。
3. 特别提醒:串口发送本身也耗时
即使换成队列,如果串口发送采用阻塞方式(等待发送完成),且发送一帧数据需要 10ms,而你在 1ms 内按了 10 次键,任务处理不过来,队列还是会被塞满导致丢数据。
进阶建议:
串口发送使用DMA(直接内存访问)+ 中断,任务只负责把数据丢进 DMA 发送缓冲区并启动传输,不阻塞 CPU。这样接收任务可以极快地清空消息队列,彻底杜绝堆积。
总结
二进制信号量:绝对会丢快速按键的数据(因为只记状态,不记次数)。
解决方案:改用消息队列,将按键数据本身缓存起来,这是嵌入式开发中处理按键、传感器数据流的最标准、最可靠的做法。
六、计数信号量出现的问题
资源管理功能和串口一起使用,可能会出现串口发送有问题,比如任务三在使用串口发送的时候,时间片正好耗尽,然后刚因计数信号量被唤醒的其他任务,发现正要执行其中的串口输出时候,串口正处于忙状态,这个问题可以使用二进制信号量解决,那么为什么会出现这样的问题?
1.问题原因:
并不是“计数信号量”的锅,而是“你把串口当作多资源来管理”导致的逻辑错误。
单纯用“二进制信号量”确实能解决“两个任务同时进”的问题,但如果用在串口上,它存在严重的优先级反转风险。对于串口这种独占型硬件,正确的解法必须是互斥量(Mutex)。(互斥锁)
为什么会出现“任务被抢占,发现串口正忙”?(根本原因)
你之所以会遇到这个问题,是因为你在使用计数信号量的资源管理功能时,把初始值设为了大于 1 的数(比如 3 或 5)。
错误逻辑:你把串口当成“3个相同的DMA通道”来管理了。任务3 拿走一个信号量(计数 3→2),任务X 也拿走一个(计数 2→1)。此时,两个任务都认为自己合法拥有了“串口资源”。
现场冲突:任务3 正在向发送寄存器
DR里写数据,时间片耗尽;任务X 被唤醒,它也向同一个DR寄存器里写数据。两个任务的数据在硬件层面直接“打架”了,导致串口发出乱码或卡死。
结论:串口是独占型外设(单资源),计数信号量(初始值>1)本身就不适合用来管理它。
2. 为什么“二进制信号量”能解决,但有坑?
如果把计数信号量的初始值设为1(此时它退化为二进制信号量),确实能解决问题:
任务3 先
Take(值 1→0),开始发送。时间片耗尽,任务X 抢占,执行
Take(发现值为 0),任务X立即进入阻塞态,让出 CPU。任务3 继续运行,发送完毕,
Give(值 0→1),任务X 被唤醒发送。
但是,如果你在串口发送任务中直接用二进制信号量做互斥,会触发 RTOS 中最经典的“优先级反转”问题:
场景:任务3(低优先级)拿走了串口锁,正在发送大量数据。此时任务1(高优先级)抢占并想要使用串口,发现锁被占用,于是高优先级任务阻塞等待低优先级任务。此时,一个中等优先级的任务2(与串口无关)趁机抢占了 CPU,导致低优先级任务3 无法运行,也就无法释放串口锁。最终,高优先级任务1 被中等优先级任务2 无限期“饿死”。
3. 终极正解:(互斥锁)必须使用互斥量(Mutex)
对于串口、I2C、SPI 这种独占硬件,RTOS 专门提供了互斥量(Mutex),而不是用(计数/二进制)信号量。
互斥量比二进制信号量强在哪里?
它拥有“优先级继承机制”。
当高优先级任务1 等待低优先级任务3 释放串口互斥量时,系统会临时把任务3 的优先级提升到与任务1 相同。
这样,中等优先级任务2 就无法抢占任务3 了。
任务3 得以快速运行完成,释放互斥量,恢复自身优先级,任务1 获得锁继续运行。
七、互斥锁:
概括核心本质:互斥锁(Mutex)是为了解决“多人抢一张桌子”的问题,确保一个人用完了,下一个人再用,并且防止插队的人饿死排队的人。
1.为什么要用互斥锁?(核心三要素)
在裸机编程中,我们通常用“关中断”或“开临界区”来保护资源,但在 RTOS 中必须用互斥锁,原因有三:
1.1. 保护“非原子操作”(防数据撕裂)
串口发送、链表插入、数组修改,这些操作在汇编层面都不是“一条指令”,而是“多条指令”的组合。
例子:任务 A 正在写一个全局变量uint32_t count = 0x12345678;(需要 4 个字节依次写入)。刚写完了前两个字节0x1234,时间片耗尽,任务 B 抢占进来读取count,它读到的就是0x1234????(脏数据/撕裂数据)。加锁后,任务 B 只能等任务 A 把 4 个字节全部写完再读。
1.2. 保护独占硬件(防硬件冲突)
这是你之前问的串口场景。串口只有1 个发送数据寄存器(DR)。
任务 3 往 DR 里写了个'A'(还没触发发送),任务 X 抢占进来往 DR 里写了个'B'。结果串口最终发出的是'B','A'被覆盖了。互斥锁能保证“谁拿锁,谁独享寄存器”。
1.3. 解决“优先级反转”(Mutex 的独门绝技)
这是互斥锁区别于二进制信号量的最大价值!
如果用二进制信号量当锁,低优先级任务拿着锁,高优先级任务抢不到会傻等,而中优先级任务趁机抢占了 CPU,导致低优先级跑不了、释放不了锁,高优先级被饿死。
!!!(重要原因)互斥锁自带“优先级继承”:一旦高优先级任务来抢锁,系统会临时把低优先级任务的优先级提升到和高优先级一样。这样中优先级就抢不赢它了,低优先级飞速跑完释放锁,高优先级立刻拿到锁。这是二进制信号量绝对做不到的。
2.互斥锁三大使用场景
注意:互斥锁绝不仅限于串口!只要满足“独占”、“不可重入”、“写操作”这三个特征之一,就该考虑它。
2.1独占型硬件外设
对象:UART(串口)、SPI 总线、I2C 总线、CAN 总线、EEPROM 芯片。
原因:这些物理总线在同一时刻只能被一个任务操控。
void Task_SendData() { osMutexAcquire(UartMutex, osWaitForever); // 1. 拿锁 HAL_UART_Transmit(&huart1, buffer, len, 100); // 2. 操作硬件(临界区) osMutexRelease(UartMutex); // 3. 放锁(必须成对出现) }2.2共享全局数据结构(内存池、链表、缓冲区)
对象:任务间共用的
全局数组、环形队列、动态内存管理(malloc/free)。原因:比如任务 A 在往链表尾部插入节点(改动了
tail->next),任务 B 正在遍历该链表。如果不加锁,任务 B 可能遍历到一个“断开的”或“半成品”链表,导致 HardFault 硬件错误。
uint8_t Global_Buffer[1024]; void Task_Write() { osMutexAcquire(BufferMutex, osWaitForever); memcpy(Global_Buffer, src, 100); // 修改共享内存 osMutexRelease(BufferMutex); }2.3多步骤“事务”操作(防中间态被读取)
对象:传感器的校准流程、文件系统的扇区写入。
原因:比如修改一个传感器的 3 个配置寄存器(步骤 1、2、3)。如果在步骤 2 执行完后被高优先级任务抢占了,高优先级任务去读取传感器状态,读到的就是“配置了一半”的错误状态。互斥锁保证这 3 步作为一个完整“事务”被一气呵成地执行。传感器校准需要三个步骤,但是校准到一半时候,被高优先级的读取传感器任务抢占,导致读到的数据是传感器校准到一半的错误任务
2.4不能用互斥锁的场景
3、 什么时候不要用互斥锁?(避坑指南)
| 场景 | 为什么不建议用 | 正确替代方案 |
|---|---|---|
| 中断服务函数(ISR)中 | 互斥锁不能在中断里Take(可能阻塞),且优先级继承在中断中无效。 | 关中断(__disable_irq())或使用无阻塞的原子操作。 |
| 任务间“同步/通知”(如按键按下,唤醒任务) | 互斥锁是“锁”,不是“旗”。如果 A 拿着锁不放,B 永远拿不到;但同步信号需要即时触发。 | 信号量(Semaphore)或消息队列(MessageQueue)。 |
保护极其简单的变量(如单一bool或int标志位) | 加锁/解锁需要切换内核态,耗时约几百个 CPU 周期,杀鸡用牛刀。 | 使用原子操作(__atomic_xxx)或关中断(开销更小)。 |
4、互斥锁的应用流程
1.创建:osMutexNew()
2.获取(拿锁):osMutexAcquire()
3.访问(临界区):锁住的对象
4.释放(放锁):osMutexRelease()
5.总结:
[任务调用 osMutexAcquire]
|
v
【检查锁是否空闲?】
/ \
是(空闲) 否(被占用)
| |
v v
[标记当前任务为所有者] [检查持有者优先级是否低于当前任务?]
| | |
v | |
返回成功,进入临界区 | 是 -----------> [临时提升持有者优先级(优先级继承)]
| |
| 否 |
| | |
v v v
[当前任务进入阻塞态] <---- [将当前任务挂到该锁的等待链表]
|
v
[触发任务调度,让出CPU]
|
v
[持有者释放锁(osMutexRelease)]
|
v
[从等待链表中取出最高优先级任务]
|
v
[将锁的所有权交给该任务,并唤醒它]
|
v
[原持有者恢复优先级,新任务进入临界区]