☰
学习笔记FREERTOS
2026/10/21 2:14:24 网站建设 项目流程

一、.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. 第 1 次按键:中断释放信号量(0→1,邮箱放入第 1 封信)。任务被唤醒,拿走信号量(1→0,清空邮箱),开始发送串口数据(耗时 5ms)。

  2. 第 2 次按键(在第 1 次发送结束前按下):中断尝试释放信号量。此时信号量为 0(空),释放成功(0→1,邮箱放入第 2 封信)。系统记住了“又有一次按键”。

  3. 第 3 次按键(极快,在第 1 次发送结束前再次按下):中断尝试释放信号量。此时信号量已经是 1(满),释放操作被直接忽略(邮箱已满,这封信被当场丢弃)。

  4. 第 1 次发送完成:任务循环回来,获取信号量(1→0),取出的是第 2 次按键的记录,发送数据。

  5. 发送结束:任务等待,信号量为 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
[原持有者恢复优先级,新任务进入临界区]

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询