文章摘要
前面几篇讲的队列、信号量、互斥量,都是"任务版"用法------会阻塞、会直接切换。但中断服务程序(ISR)里既不能阻塞、也不能直接切上下文,所以 FreeRTOS 为这些 IPC 工具各配了一套 FromISR 版本。这一篇聚焦这三个根本区别:不阻塞立即返回 、用标志位把切换延迟到 PendSV 、以及专治竞态的队列锁 cRxLock/cTxLock。文章先讲清 ISR 为什么需要特殊版本,再逐个拆开三个区别的实现,最后对比 ISR 与任务两种临界区------一个靠计数器、一个靠保存恢复------把中断里的并发保护也讲透。
文章目录
- 文章摘要
- 本栏系列文章
- 其他专栏精品文章
- 前言
- [一、为什么需要 FromISR 版本](#一、为什么需要 FromISR 版本)
- 二、区别一:不阻塞,立即返回
- 三、区别二:不直接切换,返回标志位延迟切换
- [四、区别三:cRxLock / cTxLock------队列锁](#四、区别三:cRxLock / cTxLock——队列锁)
- [五、portYIELD_FROM_ISR 的触发机制](#五、portYIELD_FROM_ISR 的触发机制)
- [六、ISR 与任务的临界区差异](#六、ISR 与任务的临界区差异)
- 七、常见面试题
- 八、总结
本栏系列文章
其他专栏精品文章
前言
前几篇的 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 五件套还差最后两件------下一篇讲 事件组与流缓冲,看看"一次等多个事件"和"连续字节流"这两种场景是怎么处理的。