ISR 三级分发
中断处理 — ISR / tasklet / 任务三级分发 — OSAL 中断管理、tasklet(底半部)、GPIO 驱动、消息队列、信号量
前置阅读:多任务并发 — 本篇依赖其中建立的任务模型和调度概念。上篇中按键检测用轮询方式,本篇用中断方式替代,实现更高效的 GPIO 输入处理。
学习目标
- 理解中断上下文与任务上下文的本质区别——为什么 ISR 中不能阻塞、不能调 SLE API、不能耗时
- 掌握
osal_irq_request()注册外设中断的标准流程,理解各参数含义 - 掌握 ISR → 消息队列/信号量 → 任务的经典数据传递链路
- 理解三级分发(ISR → tasklet → 任务)的设计意图——ISR 越快越好,复杂逻辑下沉
- 区分
osal_irq_lock()和osal_kthread_lock()的适用场景和持锁时限 - 能够用中断方式检测按键,替代上篇的轮询方式
基本概念
中断上下文 vs 任务上下文
中断发生时,CPU 暂停当前任务,跳转到 ISR(Interrupt Service Routine)执行。ISR 运行在中断上下文中,和任务上下文有本质区别:
| 对比项 | 中断上下文 (ISR) | 任务上下文 (Task) |
|---|---|---|
| 栈空间 | 2~4KB(中断栈) | 用户自定义(2048~8192+) |
| 阻塞操作 | 禁止 | 允许 |
| 调 SLE/WiFi API | 禁止 | 允许 |
osal_msleep() |
禁止(死机) | 允许 |
printf() |
禁止(极慢,拖死系统) | 允许 |
osal_sem_up() |
允许 | 允许 |
osal_msg_queue_write_copy() |
允许(NO_WAIT) |
允许 |
| 被调度抢占 | 不会被抢占(硬件优先级) | 会被更高优先级任务抢占 |
核心原则:ISR 做的事越少越好。把耗时操作(消抖、协议解析、日志输出)全部推到下半部。
上半部与下半部
嵌入式中断处理的标准做法是"上半部 + 下半部":
上半部(ISR) 下半部(tasklet/任务)
─────────── ─────────────────────
做最紧急的事 做不那么紧急的事
越快越好 (< 5μs) 可以花更多时间 (ms级)
不能阻塞 可以阻塞等待
三级分发是本案例的架构——在经典"上半部+下半部"基础上再细分一层:
flowchart TD
H[按键按下<br/>硬件中断] --> ISR
ISR[第一级: ISR<br/>清中断 + 记时间戳<br/>写消息队列<br/>< 5μs] --> Q[消息队列]
Q --> T[第二级: tasklet<br/>读队列<br/>50ms 消抖判断<br/>可延时、不可阻塞]
T -->|消抖确认| S[信号量 sem_up]
S --> K[第三级: 任务<br/>sem_down 被唤醒<br/>打印按键序号<br/>可阻塞、可调任何 API]
为什么要分三级而不是两级?因为消抖需要延时 50ms——ISR 中绝对不能 osal_msleep(50)(会死机),但 tasklet 中可以。而 tasklet 仍不应调阻塞 API(如等待队列),所以最重的活(printf 打印)交给任务。
中断回调的参数约定
osal_irq_request() 注册的回调函数签名为:
irq:触发的中断号dev:注册时传入的私有数据指针(osal_irq_request最后一个参数)- 返回值:
OSAL_IRQ_HANDLED表示已处理,OSAL_IRQ_NONE表示未处理
临界区保护的三级选择
访问共享数据时需要保护,根据共享对象选保护级别:
| 保护机制 | 禁止什么 | 持锁上限 | 保护对象 |
|---|---|---|---|
osal_irq_lock() |
所有中断 + 调度 | < 10μs | ISR 与任务共享的变量 |
osal_kthread_lock() |
任务调度 | < 1ms | 任务与任务共享的变量 |
osal_mutex_lock() |
同一 mutex 的竞争 | 不限 | 单个复杂数据结构 |
flowchart LR
A[共享数据被谁访问?] --> B{ISR 也访问?}
B -->|是| C[osal_irq_lock<br/>持锁 < 10μs]
B -->|否| D{多任务竞争?}
D -->|是、需阻塞等待| E[osal_mutex_lock]
D -->|是、仅改一个变量| F[osal_kthread_lock<br/>持锁 < 1ms]
涉及 API
| API | 谁调用 | 用途 | 头文件 |
|---|---|---|---|
osal_irq_request(irq, handler, thread_fn, name, dev) |
入口任务 | 注册中断处理函数 | osal_interrupt.h |
osal_irq_free(irq, dev) |
入口任务 | 释放中断 | osal_interrupt.h |
osal_irq_lock() / osal_irq_unlock() / osal_irq_restore(status) |
ISR/任务 | 全局中断锁/解锁/恢复 | osal_interrupt.h |
osal_irq_set_priority(irq, priority) |
入口任务 | 设置中断优先级 | osal_interrupt.h |
osal_tasklet_init(tasklet) |
入口任务 | 初始化 tasklet 结构 | osal_interrupt.h |
osal_tasklet_schedule(tasklet) |
ISR | 调度 tasklet 执行(触发一次执行一次) | osal_interrupt.h |
osal_msg_queue_create(name, len, &id, flags, msg_size) |
入口任务 | 创建消息队列 | osal_msgqueue.h |
osal_msg_queue_write_copy(id, buf, size, timeout) |
ISR | 写消息(ISR 中 timeout 必须为 OSAL_MSGQ_NO_WAIT) |
osal_msgqueue.h |
osal_msg_queue_read_copy(id, buf, &size, timeout) |
tasklet/任务 | 读消息(可阻塞等待) | osal_msgqueue.h |
osal_sem_init(sem, val) / osal_sem_up(sem) / osal_sem_down(sem) |
入口/ISR/任务 | 信号量——ISR 通知任务 | osal_semaphore.h |
uapi_gpio_init() |
入口任务 | 初始化 GPIO 子系统 | gpio.h |
uapi_gpio_set_dir(pin, dir) |
入口任务 | 设置引脚方向 | gpio.h |
uapi_gpio_register_isr_func(pin, trigger, callback) |
入口任务 | 注册 GPIO 中断回调 | gpio.h |
以上 API 构成了从硬件中断到应用处理的完整链路。ISR→tasklet 通过消息队列传递数据,tasklet→任务通过信号量同步。
案例说明
做什么
用中断方式检测按键——按键按下触发硬件中断,经 ISR(计时)→ tasklet(消抖)→ 任务(打印)三级分发,最终在串口打印按键序号。演示嵌入式开发中从中断到业务逻辑的完整处理链路。
与上篇「多任务并发」的关键区别:上篇 KEY 任务用
osal_msleep(100)轮询,CPU 每隔 100ms 来检查一次按键——即使没有按键也在消耗 CPU。本篇用中断:按键不来 CPU 完全休眠,只有按键时才被唤醒。
规格与功能
| 规格项 | 说明 |
|---|---|
| 触发源 | GPIO 按键,下降沿触发,内部上拉 |
| 第一级:ISR | 清中断 + 记录时间戳 + 写消息队列(< 5μs) |
| 第二级:tasklet | 50ms 消抖判断,确认有效按键 |
| 第三级:任务 | 等待信号量唤醒,打印按键序号 |
| ISR→tasklet 通信 | 消息队列(深度 16,ISR 侧用 NO_WAIT) |
| tasklet→任务通信 | 信号量(ISR 中 sem_up,任务中 sem_down) |
程序运行流程:
- 上电 → 注册 GPIO 中断 → 创建消息队列和信号量 → 初始化 tasklet
- 按键按下 → 硬件触发 ISR → ISR 写时间戳到队列 → 调度 tasklet
- tasklet 执行:读队列 → 50ms 消抖 → 确认后
sem_up - 任务被信号量唤醒 → 打印
key pressed: N
三级分发架构
sequenceDiagram
participant H as 硬件 GPIO
participant I as ISR
participant Q as 消息队列
participant T as tasklet
participant S as 信号量
participant K as 打印任务
H->>I: 按键按下,触发下降沿中断
Note over I: 1. 清中断标志<br/>2. 记录时间戳<br/>3. osal_msg_queue_write_copy<br/> (NO_WAIT)
I->>Q: 写入 key_event_t
I->>T: osal_tasklet_schedule
T->>Q: osal_msg_queue_read_copy<br/>(WAIT_FOREVER)
Q-->>T: 返回 key_event_t
Note over T: 50ms 消抖判断<br/>确认真实按键
T->>S: osal_sem_up
K->>S: osal_sem_down (阻塞等待)
S-->>K: 唤醒
Note over K: printf("key pressed: %d", count)
K->>S: 继续 osal_sem_down 等待下一次
与轮询方式的对比
| 对比项 | 轮询(上篇 task) | 中断(本篇) |
|---|---|---|
| CPU 占用 | 每隔 100ms 唤醒一次读 GPIO | 按键不来时完全休眠 |
| 响应速度 | 最多延迟 100ms | μs 级响应 |
| 功耗 | 持续唤醒 | 仅按键时唤醒 |
| 实现复杂度 | 简单 | 需要理解中断上下文约束 |
| 适用场景 | 调试/原型验证 | 产品级代码 |
案例操作指导
第一步:编译
第二步:烧录
将固件烧录到 WS63 开发板,按键一端接 KEY_PIN,一端接 GND。
第三步:验证
上电后串口静默(无按键时不输出)。按一下按键:
快速连按 5 次:
消息队列深度 16,短时间快速连按不会丢事件。
关键配置
| 参数 | 值 | 说明 | 调大影响 | 调小影响 |
|---|---|---|---|---|
| ISR 执行时间 | < 5μs | 清中断 + 写队列,仅此而已 | 拖慢其他中断,可能丢中断 | — |
| tasklet 消抖 | 50ms | 机械按键典型消抖窗口 | 感觉按键响应迟钝 | 可能把一次按压识别为多次 |
| 消息队列深度 | 16 | 缓冲快速连按事件 | 浪费内存 | 快速连按丢事件 |
| 消息大小 | sizeof(key_event_t) |
时间戳 + 电平状态 | 浪费内存 | 信息不全 |
| ISR 侧队列超时 | OSAL_MSGQ_NO_WAIT |
ISR 中绝不能阻塞 | — | 队列满时 ISR 卡住→系统崩溃 |
| 任务侧队列超时 | OSAL_MSGQ_WAIT_FOREVER |
没消息就休眠 | — | 超时返回需要处理 NULL |
| 中断优先级 | GPIO 默认 | 按键延迟几十 ms 可接受 | 抢占更重要的中断 | 响应可能被延迟 |
| GPIO 触发模式 | 下降沿 + 内部上拉 | 机械按键按下拉低 | — | 需要外部上拉电阻 |
ISR 持锁时间是硬约束。
osal_irq_lock()关掉所有中断——锁期间其他中断全部排队等待,超过 10μs 就可能丢中断。
代码详解
数据结构定义
/* ISR 通过消息队列传给 tasklet 的数据 */
typedef struct {
unsigned int timestamp; // 按键时刻(系统 tick)
int level; // 当前电平(0=按下, 1=释放)
} key_event_t;
/* 全局变量 */
static unsigned long g_key_queue_id; // 消息队列 ID
static osal_semaphore g_key_sem; // 信号量——通知打印任务
static osal_tasklet g_key_tasklet; // tasklet——消抖
static volatile int g_key_count = 0; // ISR 与任务共享,需 irq_lock 保护
初始化:注册中断 + 创建队列和信号量
在 app_entry 中完成所有初始化——注册 GPIO 中断、创建消息队列、初始化信号量和 tasklet。
static void app_entry(void)
{
uapi_gpio_init();
/* 配置 KEY 引脚为输入,内部上拉 */
uapi_gpio_set_dir(KEY_PIN, GPIO_DIRECTION_INPUT);
/* 创建消息队列:深度 16,每个消息 sizeof(key_event_t) */
osal_msg_queue_create("KeyQueue", 16, &g_key_queue_id, 0, sizeof(key_event_t));
/* 初始化信号量:初值 0,打印任务初始阻塞 */
osal_sem_init(&g_key_sem, 0);
/* 初始化 tasklet */
g_key_tasklet.handler = key_tasklet_handler;
g_key_tasklet.data = 0;
osal_tasklet_init(&g_key_tasklet);
/* 注册 GPIO 中断:下降沿触发,回调 key_isr */
uapi_gpio_register_isr_func(KEY_PIN, GPIO_TRIGGER_FALLING, key_isr);
/* 创建打印任务 */
osal_task *print_task = osal_kthread_create(
(osal_kthread_handler)key_print_task_handler,
NULL, "KeyPrint", 4096);
osal_kthread_set_priority(print_task, OSAL_TASK_PRIORITY_LOW);
}
app_run(app_entry);
第一级:ISR —— 越快越好
ISR 只做三件事:清中断、记时间、写队列。绝不调 printf、绝不阻塞。
static int key_isr(int irq, void *dev)
{
(void)irq;
(void)dev;
key_event_t event;
event.timestamp = osal_get_tick(); // 记录按键时刻
event.level = 0; // 按下
/* 非阻塞写队列——ISR 中必须用 NO_WAIT
如果队列满了(极短时间内连按超过 16 次),消息被丢弃 */
osal_msg_queue_write_copy(g_key_queue_id,
(void *)&event,
sizeof(key_event_t),
OSAL_MSGQ_NO_WAIT);
/* 调度 tasklet 执行 */
osal_tasklet_schedule(&g_key_tasklet);
return OSAL_IRQ_HANDLED;
}
错误示例——以下操作在 ISR 中绝对不能做:
第二级:tasklet —— 消抖判断
tasklet 在中断返回后执行,可以调 osal_msleep() 做消抖延时。但仍应避免阻塞等待。
static void key_tasklet_handler(unsigned long data)
{
(void)data;
key_event_t event;
unsigned int size = sizeof(key_event_t);
/* 从队列读消息——阻塞直到有数据 */
int ret = osal_msg_queue_read_copy(g_key_queue_id,
(void *)&event,
&size,
OSAL_MSGQ_WAIT_FOREVER);
if (ret != 0) return;
/* 50ms 消抖——等抖动过去后再读电平确认 */
osal_msleep(50);
/* 再次读电平,确认是否真的按下(消抖判断)
如果 50ms 后电平恢复高,说明是抖动,丢弃 */
int level = 1; // TODO: 替换为 uapi_gpio_get_level(KEY_PIN)
if (level == 0) {
/* 确认按键有效——通知打印任务 */
osal_sem_up(&g_key_sem);
}
/* 如果 level != 0:消抖否掉,不做任何事 */
}
消抖的原理:机械按键按下瞬间,金属触点会快速弹跳几次(通常 < 20ms),导致 ISR 被多次触发。消抖策略是等 50ms 后再读电平——如果还是按下状态,说明是真实按压。
第三级:打印任务 —— 业务逻辑
任务中可以做任何事——打印、调 SLE API、长时间处理。用信号量阻塞等待,没按键时休眠。
static int key_print_task_handler(void *data)
{
(void)data;
int count = 0;
while (1) {
/* 阻塞等待——没按键时休眠,CPU 可进低功耗 */
osal_sem_down(&g_key_sem);
/* 被信号量唤醒——有按键! */
count++;
/* 保护全局计数器——ISR 也可能访问它 */
unsigned int flags = osal_irq_lock();
g_key_count = count;
osal_irq_restore(flags);
printf("key pressed: %d\n", count);
}
return 0;
}
g_key_count用osal_irq_lock()保护——如果将来 ISR 中也读写这个变量,锁能保证数据一致性。如果确认只有任务访问,可以降级为osal_kthread_lock()或不用锁。
常见错误
| 错误 | 后果 | 正确做法 |
|---|---|---|
ISR 中调 printf |
死机或系统卡死 | 数据传给任务,任务中打印 |
ISR 中 osal_msleep |
死机 | 数据传给 tasklet,tasklet 中延时 |
ISR 中队列操作用 WAIT_FOREVER |
ISR 卡住→系统崩溃 | 必须用 OSAL_MSGQ_NO_WAIT |
| tasklet 中调 SLE API | 协议栈异常 | 信号量通知任务,任务中调 SLE API |
| 队列太浅(深度 2~4) | 快速连按丢事件 | 至少 8,推荐 16 |
| 忘了清中断标志 | 反复触发同一个中断→死循环 | ISR 第一件事就清 |