跳转至

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() 注册的回调函数签名为:

int handler(int irq, void *dev);
  • 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

程序运行流程:

  1. 上电 → 注册 GPIO 中断 → 创建消息队列和信号量 → 初始化 tasklet
  2. 按键按下 → 硬件触发 ISR → ISR 写时间戳到队列 → 调度 tasklet
  3. tasklet 执行:读队列 → 50ms 消抖 → 确认后 sem_up
  4. 任务被信号量唤醒 → 打印 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 级响应
功耗 持续唤醒 仅按键时唤醒
实现复杂度 简单 需要理解中断上下文约束
适用场景 调试/原型验证 产品级代码

案例操作指导

第一步:编译

fbb build ws63-liteos-app

第二步:烧录

将固件烧录到 WS63 开发板,按键一端接 KEY_PIN,一端接 GND。

第三步:验证

上电后串口静默(无按键时不输出)。按一下按键:

key pressed: 1

快速连按 5 次:

key pressed: 1
key pressed: 2
key pressed: 3
key pressed: 4
key pressed: 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 中绝对不能做

printf("key pressed\n");            // 死机!printf 极慢且可能阻塞
osal_msleep(10);                    // 死机!ISR 不能休眠
osal_msg_queue_write_copy(..., OSAL_MSGQ_WAIT_FOREVER); // 死机!ISR 不能等待
sle_connect_remote_device(...);     // 死机!不能调 SLE API

第二级: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_countosal_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 第一件事就清