4. SPI通信协议实现

中断,是嵌入式系统的灵魂。没有中断,CPU就得轮询——说白了就是傻等。我刚开始用GD32V做项目时,第一个栽跟头就在中断优先级配置上。今天咱们把中断系统掰开揉碎,从向量表讲到嵌套抢占。

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

中断向量表,本质是一张地址表。每个中断源对应一个入口地址,CPU响应中断时自动跳转。GD32V基于RISC-V内核,向量表基址默认在0x00000000,但可以通过mtvec寄存器重映射。

核心要点:GD32V的向量表每个条目占4字节,存放的是中断服务函数的地址。第0号条目是栈指针,第1号是复位向量,从第2号开始才是各类异常和中断。

我习惯在工程启动文件里这样定义向量表:

__attribute__((section(".isr_vector")))
void (* const g_pfnVectors[])(void) = {
    &_estack,                    // 0: 栈顶指针
    Reset_Handler,               // 1: 复位
    NMI_Handler,                 // 2: NMI
    HardFault_Handler,           // 3: 硬错误
    // ... 省略中间条目
    EXTI0_IRQHandler,            // 外部中断0
    EXTI1_IRQHandler,            // 外部中断1
    // ...
};

嗯,这里要注意:向量表必须4字节对齐。我曾经遇到一个同事,把向量表放在未对齐的地址上,结果一进中断就跑飞,查了两天才发现是对齐问题。

4.2 NVIC与中断优先级配置

GD32V的嵌套向量中断控制器(NVIC)支持最多256个中断,每个中断可配置4位抢占优先级和4位子优先级。但具体用几位,由PRICTL寄存器决定。

分组 抢占优先级位数 子优先级位数 应用场景
组0 0 4 所有中断同级,按子优先级排队
组1 1 3 2级抢占,8级子优先级
组2 2 2 4级抢占,4级子优先级(常用)
组3 3 1 8级抢占,2级子优先级
组4 4 0 16级抢占,无子优先级

配置优先级分两步走。第一步设置分组:

NVIC_PRIGROUP_CMD(NVIC_PRIGROUP_2);  // 2位抢占 + 2位子优先级

第二步设置具体中断的优先级:

nvic_irq_enable(EXTI0_IRQn, 2, 1);  // 抢占优先级2,子优先级1

我的经验:项目初期就定好优先级分组,中途别改。我见过一个产品,因为后期调整分组导致所有中断优先级重新洗牌,整个系统的实时性全乱了。

4.3 外部中断(EXTI)实战

EXTI是GD32V最常用的外部中断模块。它支持16个外部中断线,每个线可以映射到多个GPIO引脚,但同一时刻只能选一个。

配置EXTI的标准流程:

  1. 使能GPIO时钟和SYSCFG时钟
  2. 配置GPIO为输入模式
  3. 将GPIO连接到EXTI线
  4. 配置EXTI触发方式(上升沿/下降沿/双边沿)
  5. 使能EXTI中断
  6. 配置NVIC优先级
  7. 编写中断服务函数

来个完整的按键中断示例:

void EXTI_Key_Init(void)
{
    // 1. 使能时钟
    rcu_periph_clock_enable(RCU_GPIOA);
    rcu_periph_clock_enable(RCU_SYSCFG);
    
    // 2. 配置PA0为输入
    gpio_init(GPIOA, GPIO_MODE_IN_FLOATING, GPIO_OSPEED_50MHZ, GPIO_PIN_0);
    
    // 3. 连接EXTI线0到PA0
    syscfg_exti_line_config(EXTI_SOURCE_GPIOA, EXTI_SOURCE_PIN0);
    
    // 4. 配置下降沿触发
    exti_init(EXTI_0, EXTI_INTERRUPT, EXTI_TRIG_FALLING);
    exti_interrupt_flag_clear(EXTI_0);
    
    // 5. 使能EXTI中断
    exti_interrupt_enable(EXTI_0);
    
    // 6. 配置NVIC
    nvic_irq_enable(EXTI0_IRQn, 1, 0);
}

void EXTI0_IRQHandler(void)
{
    if(exti_interrupt_flag_get(EXTI_0) != RESET)
    {
        // 处理按键事件
        // 注意:这里要加消抖,我吃过亏
        delay_1ms(20);
        if(gpio_input_bit_get(GPIOA, GPIO_PIN_0) == RESET)
        {
            // 按键确实被按下
            key_pressed = 1;
        }
        exti_interrupt_flag_clear(EXTI_0);
    }
}

避坑指南:我曾经在EXTI中断里直接调用printf,结果程序卡死。原因是printf用了轮询方式发送,而串口中断优先级比EXTI低,导致死锁。中断服务函数里别做耗时操作,只设标志位,具体处理放到主循环或任务里。

4.4 中断嵌套与抢占

中断嵌套,说白了就是高优先级中断打断低优先级中断。GD32V的NVIC支持硬件自动完成嵌套,不需要软件干预。

嵌套发生的条件:

  • 两个中断的抢占优先级不同
  • 高优先级中断在低优先级中断执行期间触发
  • 高优先级中断未被屏蔽

来看一个典型的嵌套场景:

// 低优先级中断 - 抢占优先级1
void TIMER2_IRQHandler(void)
{
    // 执行到一半...
    volatile uint32_t temp = 100;
    // 此时EXTI0中断(抢占优先级0)触发
    // CPU自动保存上下文,跳转到EXTI0_IRQHandler
    // EXTI0执行完毕后,返回这里继续
    temp = 200;
}

// 高优先级中断 - 抢占优先级0
void EXTI0_IRQHandler(void)
{
    // 这个函数会打断TIMER2的中断
    volatile uint32_t data = 0xFF;
    // 处理完毕,返回被中断的TIMER2
}

为什么会这样?因为NVIC在硬件层面做了优先级比较。当新中断的抢占优先级高于当前中断时,硬件自动压栈当前上下文,然后执行新中断。新中断退出时,硬件自动恢复上下文。

关键点:子优先级不参与嵌套判断。只有抢占优先级不同才会触发嵌套。子优先级只在多个中断同时挂起时决定谁先执行。

我建议在设计中断系统时,遵循几个原则:

  • 时间敏感的中断(如定时器、通信)给高抢占优先级
  • 非关键中断(如按键)给低优先级
  • 同一抢占优先级内,按紧急程度分配子优先级
  • 中断服务函数越短越好,最好只设标志位
中断嵌套执行流程 主程序 低优先级中断 高优先级中断 时间 → 主程序执行中 低优先级中断触发 低优先级中断执行中 高优先级中断触发 高优先级中断执行 返回 低优先级继续执行 返回 主程序继续执行

实用技巧:在FreeRTOS中,中断嵌套要特别小心。FreeRTOS的临界区保护只对任务级代码有效。如果中断里调用了FreeRTOS API,必须使用FromISR后缀的版本,并且要确保中断优先级不高于configMAX_SYSCALL_INTERRUPT_PRIORITY。

最后说个我踩过的坑。有一次调试电机控制,定时器中断里做了PID计算,结果偶尔出现计算错误。查了半天,发现是串口中断(优先级更高)打断了PID计算中的关键变量读取。解决方案很简单:在读取多字节变量前关中断,读完再开。或者用volatile+原子操作也能解决。

中断系统就这些核心内容。记住:优先级分组定好别改,中断服务函数要短,嵌套时注意临界区保护。做到这三点,中断系统基本不会出大问题。