4. 中断与异常管理:别让CPU空转,也别让它卡死

中断,是嵌入式系统的灵魂。没有中断,你的CPU就得像个傻子一样轮询——不停地问“按键按了吗?数据到了吗?定时器到了吗?”效率极低。有了中断,CPU可以安心睡大觉,有事才被叫醒。

但中断也是一把双刃剑。用不好,系统会莫名其妙地死机、数据错乱、优先级反转……我当年第一个产品就因为中断里做了个延时,整得现场工程师差点砸设备。嗯,咱们今天就把这块彻底讲透。

4.1 中断向量表:CPU的“紧急联络簿”

CPU怎么知道某个中断来了该跳到哪里去执行?靠的就是中断向量表。说白了,这就是一张地址表,每个中断源对应一个入口地址。

在OpenHarmony的LiteOS-M内核里,中断向量表通常放在Flash的起始地址。以ARM Cortex-M系列为例,向量表的前16个是系统异常,后面才是外设中断。

典型向量表布局(Cortex-M33):

  • 地址0x00000000:栈顶指针
  • 地址0x00000004:复位向量 → Reset_Handler
  • 地址0x00000008:NMI异常 → NMI_Handler
  • 地址0x0000000C:硬错误 → HardFault_Handler
  • ……
  • 地址0x00000040+:外设中断(UART、TIM、I2C等)

我个人习惯在启动文件里把不用的中断都指向一个空函数,防止野指针飞了。你想想看,如果某个中断意外触发,向量表里却是0xFFFFFFFF,CPU直接跳飞——这锅你背得起吗?

4.2 中断注册与处理函数:把“紧急联系人”写进系统

OpenHarmony的LiteOS-M提供了统一的中断管理接口。注册一个中断,三步走:

  1. 配置中断控制器:设置触发方式(边沿/电平)、使能中断线
  2. 注册中断处理函数:调用 LOS_HwiCreate
  3. 使能全局中断:调用 LOS_IntUnlock
#include "los_hwi.h"

// 中断处理函数
static void UART_IRQHandler(uint32_t irqNum, void *arg)
{
    // 读取中断状态寄存器
    uint32_t status = REG_READ(UART_ISR);
    if (status & UART_RX_FLAG) {
        char c = REG_READ(UART_RBR);
        // 把数据放进环形缓冲区
        ringbuf_put(&rx_buf, c);
    }
    // 清除中断标志
    REG_WRITE(UART_ICR, status);
}

void uart_init(void)
{
    // 1. 配置UART硬件
    uart_hw_config();

    // 2. 注册中断:IRQ号、处理函数、触发方式、优先级、参数
    LOS_HwiCreate(UART_IRQ_NUM,       // 中断号
                  3,                   // 抢占优先级
                  0,                   // 子优先级
                  UART_IRQHandler,     // 处理函数
                  NULL);               // 参数

    // 3. 使能中断
    LOS_HwiEnable(UART_IRQ_NUM);

    // 4. 开全局中断
    LOS_IntUnlock();
}

我在项目中遇到过一个问题:注册中断时忘了使能,结果调试了一下午,最后发现中断根本没开。嗯,这种低级错误,犯过一次就记住了。

4.3 中断优先级配置:谁更重要,谁先跑

中断优先级决定了当多个中断同时发生时,CPU先处理谁。Cortex-M支持最多8~256级优先级(具体看芯片)。

OpenHarmony里,优先级数值越小,优先级越高。0是最高,不能随便用——那是给系统异常留的。

中断类型 推荐优先级 说明
系统Tick中断 1 系统心跳,必须及时响应
高实时外设(SPI、DMA) 2~3 数据吞吐量大,不能丢
普通外设(UART、I2C) 4~5 可以容忍少量延迟
低优先级(GPIO按键) 6~7 人机交互,慢一点无所谓

我的经验:优先级不要铺满所有等级。留几级给后续扩展。我曾经把优先级从0到7全用上了,后来加个新功能,发现没位置了——只能重构,麻烦得很。

4.4 临界区保护与互斥锁:别让数据打架

中断和任务共享数据时,最容易出问题。比如一个全局变量,任务在写,中断在读——写了一半,中断来了,读到的数据是半成品。这就是典型的“竞态条件”。

解决思路很简单:在访问共享资源时,关掉中断。这段代码区域就叫“临界区”。

// 临界区保护示例
uint32_t intSave;

// 进入临界区:关中断,保存当前中断状态
intSave = LOS_IntLock();

// 操作共享数据
g_shared_counter++;
g_buffer[g_shared_counter] = data;

// 退出临界区:恢复中断状态
LOS_IntRestore(intSave);

但注意:临界区里不能做耗时操作!否则系统实时性会崩。我在项目中见过有人把打印日志放在临界区里——串口打印一个字符串要几毫秒,中断全被堵死了,系统直接“假死”。

如果是在任务之间共享资源,用互斥锁更合适:

// 创建互斥锁
LosMutex mutex;
LOS_MuxCreate(&mutex, "myMutex", 0, NULL);

// 任务A
void TaskA(void)
{
    while (1) {
        LOS_MuxPend(mutex, LOS_WAIT_FOREVER);
        // 访问共享资源
        do_something();
        LOS_MuxPost(mutex);
        LOS_TaskDelay(10);
    }
}

// 任务B
void TaskB(void)
{
    while (1) {
        LOS_MuxPend(mutex, LOS_WAIT_FOREVER);
        // 访问共享资源
        do_something_else();
        LOS_MuxPost(mutex);
        LOS_TaskDelay(10);
    }
}

警告:中断里不要用互斥锁!互斥锁可能导致任务挂起,而中断里不允许阻塞。中断里保护共享数据,只能用关中断的方式。

4.5 避坑指南:中断服务程序不要做耗时操作

这是新手最容易踩的坑,也是我当年摔得最惨的地方。

我曾经在UART中断里直接调用 printf 打印接收到的数据。结果呢?printf内部要等待串口发送完成,而串口发送中断优先级比UART接收低——死锁了!系统直接卡死,连调试器都连不上。

中断服务程序的原则就一句话:快进快出。具体来说:

  • 不要做I/O操作:读写文件、打印日志、操作显示屏
  • 不要调用阻塞函数:信号量等待、互斥锁获取、延时函数
  • 不要做浮点运算:FPU上下文保存开销大,能免则免
  • 不要动态分配内存:malloc/free可能触发系统调用,不可重入

那中断里该做什么?只做最必要的事:

  1. 读取硬件寄存器,保存数据到缓冲区
  2. 清除中断标志
  3. 发一个信号量或事件,通知任务去处理

说白了,中断只负责“接单”,任务负责“干活”。

中断处理的最佳实践模式:

// 中断里:只存数据,发信号
void TIM_IRQHandler(uint32_t irqNum, void *arg)
{
    // 1. 读取采样值
    uint32_t sample = REG_READ(ADC_DR);
    // 2. 存入缓冲区
    adc_buf[adc_idx++] = sample;
    // 3. 通知任务处理
    LOS_SemPost(g_adc_sem);
    // 4. 清中断标志
    REG_WRITE(TIM_ICR, 1);
}

// 任务里:处理数据
void AdcTask(void)
{
    while (1) {
        LOS_SemPend(g_adc_sem, LOS_WAIT_FOREVER);
        // 处理缓冲区数据
        process_adc_data();
    }
}

这样设计,中断执行时间只有几微秒,任务处理时间几百微秒甚至几毫秒——系统实时性杠杠的。

4.6 知识结构总览

下面这张图,把中断管理的核心脉络串起来了:

中断与异常管理知识结构图 中断与异常管理核心脉络 中断向量表 中断注册与处理函数 中断优先级配置 临界区保护与互斥锁 抢占优先级 / 子优先级 关中断保护 / 互斥锁保护 避坑指南:ISR不做耗时操作 核心原则:快进快出,中断只“接单”,任务来“干活”

从向量表到底层保护,再到避坑指南,这条链路缺一不可。中断管理做得好,系统稳如老狗;做不好,调试到怀疑人生。

好了,中断这块就聊到这儿。记住我说的:中断里别干重活,重活交给任务干。这个原则能帮你避开90%的中断相关bug。