第3章 中断系统:从向量表到嵌套实战

中断,说白了就是CPU正在干活,突然被一个紧急事件打断,跑去处理完再回来继续干活。这个机制在嵌入式系统里太重要了。我刚开始学RISC-V中断时,被向量表和优先级搞得晕头转向,后来踩了几个坑才真正搞明白。

今天咱们就把CH32的中断系统彻底讲透。你跟着我走一遍,保证能上手写中断服务函数。

3.1 中断向量表——CPU的紧急电话本

中断向量表是什么?你可以把它想象成一个电话本。每个中断源都有一个对应的电话号码(入口地址)。当CPU收到中断信号时,就去查这个电话本,找到对应的处理函数跳过去执行。

CH32系列基于RISC-V架构,它的向量表起始地址默认在0x00000000。但有个坑——RISC-V的向量表有两种模式:直接模式和向量模式。

模式 说明 CH32默认
直接模式(Direct) 所有中断跳转到同一个入口(mtvec.BASE),由软件判断中断源 否
向量模式(Vectored) 每个中断有独立入口,硬件自动跳转 是(推荐)

关键寄存器:mtvec

mtvec寄存器控制向量表基地址和模式。位[1:0]:00=直接模式,01=向量模式。CH32上电默认是向量模式,基地址0x00000000。

我个人习惯在项目启动时显式配置一下,防止某些Bootloader改过这个寄存器:

// 设置向量表基地址为0x00000000,向量模式
__asm volatile("csrw mtvec, 0x00000001");

嗯,这里要注意:向量表必须4字节对齐。如果你把程序放在0x08000000(Flash偏移),mtvec就要写成0x08000001(最后一位1表示向量模式)。

3.2 中断优先级——谁更紧急?

多个中断同时来了怎么办?CPU得有个规矩,先处理优先级高的。CH32的中断优先级分为抢占优先级和子优先级,说白了就是:

  • 抢占优先级:决定能不能打断正在执行的中断
  • 子优先级:抢占优先级相同时,谁先执行

CH32的NVIC支持最多4位优先级配置(0~15),但具体多少位取决于芯片型号。我查过CH32V203的数据手册,它支持4位抢占优先级,没有子优先级。

优先级值 抢占优先级 说明
0 最高 系统异常,不可屏蔽
1~3 高 关键外设中断(如TIM1)
4~7 中 普通外设中断(如UART)
8~15 低 非关键中断(如按键)

我曾经踩过的坑:把两个中断设成相同优先级,结果它们互相抢占,导致系统卡死。后来才明白——相同优先级的中断不会互相打断,必须等当前中断处理完才能响应下一个。

3.3 外部中断配置——GPIO也能触发中断

外部中断就是把GPIO引脚的变化变成中断信号。CH32的每个GPIO口都可以配置为外部中断源,但要注意:同一组GPIO(如PA0~PA15)共享一个中断线。

配置步骤其实就四步:

  1. 使能GPIO时钟和AFIO时钟
  2. 配置GPIO模式为输入(上拉/下拉)
  3. 配置EXTI线:触发方式(上升沿/下降沿/双边沿)
  4. 配置NVIC:使能中断、设置优先级

来看个实际例子,配置PA0为下降沿触发的外部中断:

void EXTI0_Init(void)
{
    // 1. 使能时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE);
    
    // 2. 配置PA0为上拉输入
    GPIO_InitTypeDef GPIO_InitStruct;
    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0;
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU;  // 上拉输入
    GPIO_Init(GPIOA, &GPIO_InitStruct);
    
    // 3. 配置EXTI线0,下降沿触发
    GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0);
    
    EXTI_InitTypeDef EXTI_InitStruct;
    EXTI_InitStruct.EXTI_Line = EXTI_Line0;
    EXTI_InitStruct.EXTI_Mode = EXTI_Mode_Interrupt;
    EXTI_InitStruct.EXTI_Trigger = EXTI_Trigger_Falling;  // 下降沿
    EXTI_InitStruct.EXTI_LineCmd = ENABLE;
    EXTI_Init(&EXTI_InitStruct);
    
    // 4. 配置NVIC
    NVIC_InitTypeDef NVIC_InitStruct;
    NVIC_InitStruct.NVIC_IRQChannel = EXTI0_IRQn;
    NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 2;  // 抢占优先级2
    NVIC_InitStruct.NVIC_IRQChannelSubPriority = 0;
    NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStruct);
}

小技巧:如果多个引脚共用一条EXTI线(比如PA0和PB0都用EXTI0),只能同时使能一个。我一般用跳线帽或者PCB上做选择,避免冲突。

3.4 中断服务函数编写——名字不能写错

中断服务函数(ISR)的名字是固定的,必须和启动文件里的向量表一致。CH32的启动文件里已经声明了弱符号(weak),你只需要定义同名的函数就行。

常见的ISR命名规则:

  • EXTI0_IRQHandler — EXTI线0中断
  • USART1_IRQHandler — USART1中断
  • TIM1_UP_IRQHandler — TIM1更新中断

写ISR时有个铁律:快进快出。中断里别做复杂运算,别调printf,别等延时。我见过有人在中断里做浮点运算,结果系统响应慢得像蜗牛。

// 正确的ISR写法
void EXTI0_IRQHandler(void)
{
    if(EXTI_GetITStatus(EXTI_Line0) != RESET)
    {
        // 清除中断标志位——这一步必须做!
        EXTI_ClearITPendingBit(EXTI_Line0);
        
        // 处理中断:比如设置一个标志位
        g_button_pressed = 1;
        
        // 不要在中断里做耗时操作
        // printf("Button pressed!\n");  // ❌ 错误示范
        // delay_ms(100);                // ❌ 错误示范
    }
}

重要提醒:RISC-V的中断服务函数末尾不需要加interrupt属性,编译器会自动处理返回指令。但如果你用汇编写ISR,记得用mret指令返回,不是ret。

3.5 中断嵌套——高优先级打断低优先级

中断嵌套说白了就是:正在处理一个中断,又来一个更高优先级的中断,CPU先处理新的,处理完再回来继续旧的。

CH32默认支持中断嵌套,但有个前提:新中断的抢占优先级必须高于当前中断。相同优先级不会嵌套,会排队等。

来看个实际场景:

  1. CPU正在执行主循环
  2. UART中断来了(优先级3),CPU跳去处理
  3. UART处理到一半,TIM1中断来了(优先级1,更高)
  4. CPU暂停UART处理,去处理TIM1
  5. TIM1处理完,返回继续处理UART
  6. UART处理完,返回主循环

这个流程我用SVG画出来,你一看就明白:

中断嵌套执行流程 主循环执行 UART中断处理 TIM1中断处理 UART中断 TIM1中断(更高优先级) TIM1处理完,返回UART UART处理完,返回主循环 时间→ 主循环 UART TIM1 UART继续 主循环继续 关键点: 1. 只有更高抢占优先级的中断才能嵌套 2. 相同优先级不会嵌套,会排队等待

写嵌套中断时,我有个习惯:低优先级中断里尽量少做事。因为如果你在低优先级中断里磨蹭太久,高优先级中断来了就得等着,这会影响实时性。

中断嵌套的黄金法则:

  • 高优先级中断:只做最紧急的事(比如读取数据寄存器)
  • 低优先级中断:可以做一些非紧急处理(比如协议解析)
  • 所有中断:绝对不要调用delay()或printf()

3.6 实战:按键中断控制LED

光说不练假把式。咱们写个完整的例子:按键触发外部中断,翻转LED状态。

// main.c
#include "ch32v20x.h"

volatile uint8_t g_button_pressed = 0;

void EXTI0_IRQHandler(void)
{
    if(EXTI_GetITStatus(EXTI_Line0) != RESET)
    {
        EXTI_ClearITPendingBit(EXTI_Line0);
        g_button_pressed = 1;  // 设置标志位,主循环处理
    }
}

int main(void)
{
    // 初始化LED(PC13)
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE);
    GPIO_InitTypeDef GPIO_InitStruct;
    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_13;
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP;
    GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOC, &GPIO_InitStruct);
    GPIO_SetBits(GPIOC, GPIO_Pin_13);  // 初始关闭
    
    // 初始化外部中断(PA0)
    EXTI0_Init();
    
    while(1)
    {
        if(g_button_pressed)
        {
            g_button_pressed = 0;
            // 翻转LED——在主循环里做,不在中断里做
            if(GPIO_ReadOutputDataBit(GPIOC, GPIO_Pin_13))
                GPIO_ResetBits(GPIOC, GPIO_Pin_13);
            else
                GPIO_SetBits(GPIOC, GPIO_Pin_13);
        }
    }
}

经验之谈:中断里只设标志位,主循环里处理具体操作。这样既保证了中断响应速度,又避免了中断里做复杂操作带来的风险。我所有项目都这么干,从来没出过问题。

好了,中断系统这块就讲完了。你把这几个概念理清楚——向量表、优先级、外部中断配置、ISR写法、嵌套规则——基本上就能应对大部分项目需求了。下次遇到中断相关的问题,先检查向量表配置对不对,再看优先级有没有冲突,最后看ISR里有没有做耗时操作。这三板斧下去,90%的问题都能解决。