FreeRTOS深入解析(八)IPC机制之FromISR详解

文章摘要

  前面几篇讲的队列、信号量、互斥量,都是"任务版"用法------会阻塞、会直接切换。但中断服务程序(ISR)里既不能阻塞、也不能直接切上下文,所以 FreeRTOS 为这些 IPC 工具各配了一套 FromISR 版本。这一篇聚焦这三个根本区别:不阻塞立即返回 、用标志位把切换延迟到 PendSV 、以及专治竞态的队列锁 cRxLock/cTxLock。文章先讲清 ISR 为什么需要特殊版本,再逐个拆开三个区别的实现,最后对比 ISR 与任务两种临界区------一个靠计数器、一个靠保存恢复------把中断里的并发保护也讲透。

文章目录

本栏系列文章

(本文为《FreeRTOS 深入解析》系列第 8 篇,前 7 篇分别见《链表与并发保护》《任务状态机、TCB 与内核链表拓扑》《一条完整的生命周期线》《阻塞与唤醒》《IPC 机制之队列》《IPC 机制之信号量》《IPC 机制之互斥量与优先级继承》,后续文章更新见专栏)

其他专栏精品文章

CSDN高赞项目:工程训练竞赛之垃圾分类垃圾桶

前言

  前几篇的 IPC 函数都是在任务里调的,可以随便阻塞。但现实里数据常常来自中断------串口收到一个字节、ADC 采到一个值,都得在 ISR 里塞进队列。ISR 不是任务,两个硬约束摆在那:不能阻塞 、不能直接切上下文。FreeRTOS 于是给每个 IPC 工具配了一套 FromISR 版本。这一篇就把这套版本和普通版本的三个根本区别讲透,尤其是那个专治中断竞态的"队列锁",是整个 IPC 机制里最容易被忽略、也最容易踩坑的一块。


一、为什么需要 FromISR 版本

  普通 xQueueSend/xQueueReceive 会阻塞(队列满/空时挂等待链表),而 ISR 不是任务、不能阻塞,所以 FreeRTOS 提供了一套 FromISR 版本:

对象 FromISR 版本 备注
队列 xQueueSendFromISR / xQueueReceiveFromISR 有,完整
信号量(二值/计数) xSemaphoreGiveFromISR / xSemaphoreTakeFromISR 有,但也是宏
互斥量 ❌ 没有(不能 Take/Give) 只有只读查询 xSemaphoreGetMutexHolderFromISR

  这套版本和普通版本有三个根本区别。


二、区别一:不阻塞,立即返回

  xQueueReceiveFromISR 的实现:

c 复制代码
if (uxMessagesWaiting > 0)   // 有数据就取,没数据立即返回失败
{
    ... 取数据 ...
    xReturn = pdPASS;
}
else
{
    xReturn = pdFAIL;        // 没有数据,直接失败,绝不阻塞
}

  ISR 里没有"挂到等待链表睡一觉"这个选项,有数据就取,没数据立刻返回失败。


三、区别二:不直接切换,返回标志位延迟切换

  看签名,多了一个参数 pxHigherPriorityTaskWoken(queue.c:2035-2037):

c 复制代码
BaseType_t xQueueReceiveFromISR(QueueHandle_t xQueue,
                                void *pvBuffer,
                                BaseType_t *pxHigherPriorityTaskWoken);

  ISR 里不能直接做上下文切换------切换要等所有(可能嵌套的)中断都退出后,由 PendSV 统一做。所以 FromISR 函数把 *pxHigherPriorityTaskWoken 置成 pdTRUE 来"记录需要切换",自己并不切,等 ISR 快结束时由你自己的代码检查这个标志并触发 PendSV。规范的用法:

c 复制代码
BaseType_t xHigherPriorityTaskWoken = pdFALSE;

void MyISR(void)
{
    xQueueSendFromISR(xQueue, &data, &xHigherPriorityTaskWoken);

    // ISR 结束前,检查是否唤醒了高优先级任务
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
    // 若为 pdTRUE,触发 PendSV,等所有中断退出后做延迟切换
}

  portYIELD_FROM_ISR(pdTRUE) 只是设一个 PendSV 挂起位,真正的切换发生在"中断全部退出之后",这就是延迟切换。


四、区别三:cRxLock / cTxLock------队列锁

  队列锁要解决一个典型竞态:任务正在操作队列的等待链表(中间可能关调度/进临界区),ISR 来了想往队列里加数据或取数据。做法是------ISR 照样能加数据,但不能碰等待链表,等任务操作完链表,再"补做"唤醒。

  先看这个竞态是怎么发生的。任务 A 在等队列数据,判断队列为空后准备把自己挂到等待接收链表上,恰好在中间来了中断:

复制代码
 A 检查队列 → 空的
        ↓  ← 中断恰好在这里插进来!
 ISR 来发数据:数据进队列,想唤醒等待者
        但 A 还没来得及把自己挂到等待链表上 → 链表是空的,没东西可唤醒
        ↓  ISR 走了
 A 做②:把自己挂到等待链表上 → 阻塞

  结果是:数据明明在队列里,但 ISR 已经来过了、不会再来了,A 却永远阻塞睡着------数据丢失、任务饿死。

  解决办法是让"检查队列"和"挂链表"这两步原子化,即用一个字段 cTxLock 告诉 ISR:"现在有任务正在动链表,你别碰链表"。ISR 如果在这期间来了,就只做数据(不碰链表),然后记一笔账,等任务忙完了补做唤醒。

  queue.c 里定义了三个状态:

c 复制代码
#define queueUNLOCKED           -1   // 未锁
#define queueLOCKED_UNMODIFIED   0   // 已锁,但 ISR 还没动过
//  > 0                           N   // 已锁,且 ISR 动过 N 次

  上锁 ------prvLockQueue(任务在动等待链表前调用):

c 复制代码
if (cTxLock == queueUNLOCKED)           // 判断是否是 -1(没锁)
    cTxLock = queueLOCKED_UNMODIFIED;   // 改成 0(上锁)

  ISR 侧 ------xQueueGenericSendFromISR 的核心判断:

c 复制代码
if (cTxLock == queueUNLOCKED)   // 队列没被任务锁着
{
    // 直接唤醒等待者(安全,因为没人在动链表)
    xTaskRemoveFromEventList(&xTasksWaitingToReceive);
}
else                            // 队列被任务锁着
{
    // 不能碰等待链表,只把计数 +1("我 ISR 期间发了一个数据")
    prvIncrementQueueTxLock(pxQueue, cTxLock);
}

  解锁 ------prvUnlockQueue(任务操作完链表后调用):

c 复制代码
while (cTxLock > 0)   // ISR 在我锁队列期间发了 N 次数据
{
    xTaskRemoveFromEventList(&xTasksWaitingToReceive);   // 补做唤醒
    --cTxLock;        // N 次发数据 → 唤醒 N 个等待者
}
cTxLock = queueUNLOCKED;   // 解锁,回到 -1

  整个队列锁的思想就一句:ISR 期间数据照存,链表操作记账,任务忙完统一补做唤醒,既不让 ISR 阻塞,也不丢唤醒。


五、portYIELD_FROM_ISR 的触发机制

  Cortex-M 上"切换任务"这件重活只能由 PendSV 这个异常来做,PendSV 的 handler 里装的就是"保存旧任务现场、恢复新任务现场"那套汇编。所以"想切换"这件事,本质就是让 PendSV 跑一次。

  0xE000ED04 是 Cortex-M 的 ICSR 寄存器(中断控制和状态寄存器),第 28 位叫 PENDSVSET,往这里写 1 就是"把 PendSV 异常挂起(pend)",相当于跟 CPU 说"有空的时候帮我跑一下 PendSV"。

  挂起不等于立即执行,只是"预约"了 PendSV,它什么时候跑由优先级决定。因为 PendSV 优先级最低,所以 ISR 里想切换只能等 ISR 执行完,才进入 PendSV,PendSV_Handler 里才真正切上下文。

  portYIELD_FROM_ISR(portmacro.h)的实现:

c 复制代码
#define portEND_SWITCHING_ISR(xSwitchRequired)      \
    do {                                            \
        if (xSwitchRequired != pdFALSE)             /* 标志位为真(要切换) */ \
        {                                           \
            portYIELD();                            /* 设 PendSV 挂起位 */ \
        }                                           \
    } while (0)

#define portYIELD_FROM_ISR(x)   portEND_SWITCHING_ISR(x)

  而 portYIELD() 的实现:

c 复制代码
#define portYIELD()                                   \
{                                                     \
    portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;   /* ← 写这一下 */ \
    __asm dsb; __asm isb;                             \
}

#define portNVIC_INT_CTRL_REG   (*(volatile uint32_t *)0xe000ed04)  // ICSR 寄存器
#define portNVIC_PENDSVSET_BIT  (1UL << 28UL)                        // 第 28 位

  这里要区分 PendSV 和 SVC 两个异常,别搞混:

异常 全称 用途 谁触发
PendSV Pendable Service Call 上下文切换(任务切换) portYIELD() 写 PendSV 挂起位
SVC Supervisor Call 启动调度器(点火第一个任务) 第 3 篇讲的 svc 0 指令

六、ISR 与任务的临界区差异

  FreeRTOS 里临界区操作就是开关中断,靠 BASEPRI 寄存器。taskENTER_CRITICAL() 调用 vPortEnterCritical(),通过 portDISABLE_INTERRUPTS() 把 BASEPRI 抬高,屏蔽掉"优先级 ≤ 某个阈值"的中断,再把嵌套计数器 +1;taskEXIT_CRITICAL() 调用 vPortExitCritical(),计数器 -1,为 0 时才真正开中断。

  临界区和关调度是两个不同重量的工具:

临界区 关调度
操作 抬 BASEPRI 关中断 uxSchedulerSuspended++
挡谁 中断 + 所有任务 别的任务,不挡中断
tick 中断来了 进不来(被屏蔽) 进得来,但只记账
重量 重 轻

  任务版和 ISR 版的临界区实现方式不同。

  任务版:用计数器。 一个时刻只有一个任务在跑,taskENTER_CRITICAL 可能嵌套(进入两层临界区),但都是同一个任务自己在嵌套,用计数器记"进了几层"就行:

c 复制代码
taskENTER_CRITICAL();   // BASEPRI=5,计数器 0→1
taskENTER_CRITICAL();   // BASEPRI=5(重复设没影响),计数器 1→2
taskEXIT_CRITICAL();    // 计数器 2→1(还没到 0,不开中断)
taskEXIT_CRITICAL();    // 计数器 1→0(到 0 了!才开中断 BASEPRI=0)

  任务版里谁进谁出是配对的,所以只需要记"进了几层",最后一层退出才开中断。

  ISR 版:用"保存/恢复"。 中断会嵌套:一个低优先级中断正在跑,一个更高优先级中断可以打断它,两个中断可能各自进了临界区。所以 ISR 版不记层数,而是"进的时候保存旧 BASEPRI,出的时候恢复它"。

  场景:外层 ISR(优先级 7)先进临界区,内层 ISR(优先级 3)打断它,也进临界区:

c 复制代码
// ① 外层 ISR 进临界区:保存旧值 0,设 BASEPRI = configMAX_SYSCALL_INTERRUPT_PRIORITY(假定为 5)
uxSaved = ulPortRaiseBASEPRI();

// ② 内层 ISR 打断外层,也进临界区:保存旧值 5,设 BASEPRI = 5
uxSaved2 = ulPortRaiseBASEPRI();   // BASEPRI 还是 5

// ③ 内层 ISR 退出临界区:恢复 BASEPRI = 5
vPortSetBASEPRI(uxSaved2);

// ④ 外层 ISR 继续,退出临界区:恢复 BASEPRI = 0
vPortSetBASEPRI(uxSaved);

  configMAX_SYSCALL_INTERRUPT_PRIORITY 来自 FreeRTOSConfig.h,它是一道门槛:BASEPRI 设成它之后,优先级数值 ≥ 它的中断全被屏蔽,优先级数值 < 它的中断照常运行(Cortex-M 的规则是数字越小优先级越高)。

时刻 发生什么 BASEPRI 的值 谁在临界区里
一开始 什么都没发生 0 无
① 外层进临界区 保存 0,设成 5 5 外层
② 内层进临界区 保存 5,设成 5 5 外层 + 内层
③ 内层退临界区 恢复成 5 5 外层(还在!)
④ 外层退临界区 恢复成 0 0 无

七、常见面试题

  1. FromISR 版本和普通版本有哪三个根本区别?

  2. pxHigherPriorityTaskWoken 这个参数是干什么的?为什么 ISR 里不能直接切上下文?

  3. 队列锁 cRxLock/cTxLock 解决的是什么竞态?queueUNLOCKED、queueLOCKED_UNMODIFIED、>0 三个状态分别代表什么?

  4. portYIELD_FROM_ISR 的底层是怎么触发 PendSV 的?PendSV 和 SVC 有什么区别?

  5. 任务版和 ISR 版的临界区实现方式有什么不同?为什么一个用计数器、一个用保存/恢复?


八、总结

  本文把 IPC 的 FromISR 版本讲透了:

  1. 三个根本区别 :不阻塞立即返回、用标志位延迟切换、用队列锁 cRxLock/cTxLock 防竞态。

  2. 延迟切换 :ISR 里不能直接切,pxHigherPriorityTaskWoken 记标志,portYIELD_FROM_ISR 设 PendSV 挂起位,等中断全退出后由 PendSV 统一切。

  3. 队列锁 :ISR 期间数据照存、链表操作记账(计数 +1),任务忙完解锁时补做唤醒,既不阻塞 ISR 也不丢唤醒。

  4. 临界区 :任务版用计数器(同一任务自己嵌套),ISR 版用保存/恢复(应对中断嵌套),门槛是 configMAX_SYSCALL_INTERRUPT_PRIORITY。

  这一篇的关键就一句话:ISR 里不能阻塞、不能直接切换,于是有了"立即返回 + 延迟切换 + 队列锁"三件套。 到这里,队列、信号量、互斥量、FromISR 都齐了,IPC 五件套还差最后两件------下一篇讲 事件组与流缓冲,看看"一次等多个事件"和"连续字节流"这两种场景是怎么处理的。

相关推荐
小李不想当小白14 分钟前
HAL库之UART(STM32 HAL库学习笔记)
笔记·stm32·单片机·嵌入式硬件·学习·嵌入式
Dev_XH34 分钟前
【成品设计】基于物联网的旅游生态农业互动系统
stm32·单片机·嵌入式硬件·物联网·旅游
1-1=02 小时前
C# 数据结构和特殊类型
数据结构·c#·.net·.netcore
希和20272 小时前
C语言程序设计题|有序数组插入数字冒泡排序
c语言·数据结构·算法
布莱克60513 小时前
C++ 中 map 的 insert 与 emplace:key 已存在时会覆盖吗?
开发语言·数据结构·c++·算法·map
D e re k ZH16 小时前
C++ 系统学习手记:从语法细节到工程实践
开发语言·数据结构·c++·算法
Zwawa17 小时前
屏幕不止一种:OLED、LCD1602字符屏、TFT/IPS图形屏和串口屏怎么用,这与TTY和PTY又有什么关系
c语言·单片机·嵌入式
无敌贵点大王21 小时前
从寄存器层面重学STM32(7)——定时器
stm32·单片机·嵌入式硬件
zh_xuan21 小时前
LeeCode 496. 下一个更大元素I
数据结构·算法·栈·单调栈