4. 中断管理:中断服务程序(ISR)设计原则、中断与任务的同步、临界区保护
中断管理,说白了就是RTOS的「神经反射弧」。
你想想看,电机编码器突然来了一个脉冲,或者驱动器报了个过流故障——这些外部事件不会等你任务调度完再处理。它们需要立刻响应。这就是中断存在的意义。
我个人习惯把中断比作「急诊室」。任务调度是普通门诊,按号排队。中断是急救通道,来了就得先处理。但急诊室也有规矩,不能乱来。这一章我们就聊聊这些规矩。
4.1 中断服务程序(ISR)设计原则
ISR怎么写?很多新手上来就在中断里做复杂运算。我见过有人把PID计算直接塞进ISR,结果系统直接崩了。为什么?因为中断服务程序必须短小精悍。
核心原则就三条:
- 快进快出:ISR里只做最紧急的事。比如读取寄存器、清除中断标志、发送一个信号量。复杂计算丢给任务去做。
- 不能阻塞:ISR里绝对不能调用阻塞型API。比如等待信号量、获取互斥量——这些都会让系统死锁。
- 优先级合理:高优先级中断不能长时间霸占CPU。否则低优先级中断和任务都饿死了。
避坑指南:我曾经在一个伺服驱动项目里,把电流环的PI计算放在了ISR里。结果中断执行时间超过了中断周期,导致中断嵌套爆炸。最后整个系统看门狗复位。从那以后,我强制规定:ISR里只做「读-发信号-清标志」三步走。
来看一个典型的ISR模板:
// 正确的ISR写法
void TIM1_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
// 1. 读取硬件状态(必须快)
uint32_t ulEncoderValue = TIM1->CNT;
// 2. 发送信号量通知任务处理
xSemaphoreGiveFromISR(xEncoderSemaphore, &xHigherPriorityTaskWoken);
// 3. 清除中断标志
TIM1->SR = ~TIM_SR_UIF;
// 4. 请求上下文切换(如果需要)
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
注意看,我没有在ISR里做任何数学运算。只是读取、发信号、清标志。真正的编码器数据处理,交给等待信号量的任务去做。
4.2 中断与任务的同步
中断和任务之间怎么配合?这是RTOS运动控制的核心问题。
我常用的同步机制有三种:
| 同步方式 | 适用场景 | 注意事项 |
|---|---|---|
| 信号量(Semaphore) | 中断通知任务:有数据来了 | ISR中必须用FromISR版本 |
| 消息队列(Queue) | 中断向任务传递数据 | 队列长度要够,防止溢出 |
| 事件标志组(Event Group) | 多个中断触发同一个任务 | 注意位宽限制 |
举个例子。电机控制中,编码器Z信号到来时,需要同步位置零点。我会这样设计:
// 编码器Z信号中断
void EXTI_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
// 发送消息到队列,包含当前编码器值
uint32_t ulZeroPos = TIM2->CNT;
xQueueSendFromISR(xZeroPosQueue, &ulZeroPos, &xHigherPriorityTaskWoken);
EXTI->PR = EXTI_PR_PR0;
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
// 任务侧接收
void vZeroSyncTask(void *pvParameters)
{
uint32_t ulReceivedPos;
for(;;)
{
if(xQueueReceive(xZeroPosQueue, &ulReceivedPos, portMAX_DELAY) == pdTRUE)
{
// 在这里做位置同步,不占用中断时间
vUpdateHomePosition(ulReceivedPos);
}
}
}
为什么会这样设计?因为位置同步可能需要做滤波、校准、甚至写入EEPROM。这些操作放在ISR里就是灾难。通过队列,中断只负责「通知」,任务负责「处理」。
个人经验:我建议队列长度至少设为2。为什么?因为如果任务处理慢,中断又来了,至少能缓存一次。我曾经因为队列长度设为1,丢掉了编码器Z信号,导致位置同步偶尔失败。查了两天才找到原因。
4.3 临界区保护
临界区是什么?就是一段「不能被中断打断」的代码。
你想想看,如果任务A正在更新一个全局变量,突然来了个中断,ISR也修改了这个变量。等中断返回,任务A继续执行——这时候变量值已经乱了。这就是竞态条件。
RTOS提供了两种保护方式:
- 关中断:最粗暴,但最有效。适合保护极短的代码段。
- 使用互斥量(Mutex):更优雅,但ISR里不能用。
看一个实际例子。在运动控制中,我们经常需要读取电机的位置和速度。这两个值必须「同时」读取,否则算出来的速度就不准。
// 错误的做法:没有保护
void vReadMotorState(void)
{
// 如果此时发生中断,ISR修改了g_ulPosition
g_ulPosition = TIM2->CNT; // 可能被中断打断
g_ulVelocity = TIM3->CCR1; // 中断返回后,位置和速度不匹配
}
// 正确的做法:临界区保护
void vReadMotorState(void)
{
taskENTER_CRITICAL();
g_ulPosition = TIM2->CNT;
g_ulVelocity = TIM3->CCR1;
taskEXIT_CRITICAL();
}
警告:临界区里绝对不能做耗时操作!关中断意味着系统无法响应任何外部事件。我见过有人把printf放进临界区,结果串口打印时中断被关了几毫秒,电机直接失步。记住:临界区只保护「读-改-写」这种原子操作,代码行数控制在5行以内。
嗯,这里还要注意一个细节。FreeRTOS里,taskENTER_CRITICAL() 会关掉所有可屏蔽中断。但有些高优先级中断(比如系统滴答定时器)可能不受影响。如果你的硬件有NMI(不可屏蔽中断),那临界区也保护不了它。
4.4 中断嵌套与优先级
中断嵌套是个双刃剑。用好了能提高实时性,用不好就是死锁。
我个人的原则是:尽量少用嵌套。为什么?因为嵌套会增加中断延迟的不确定性。在运动控制中,确定性比绝对速度更重要。
来看一个典型的中断优先级配置:
| 中断源 | 优先级 | 说明 |
|---|---|---|
| 系统滴答定时器 | 最高(0) | RTOS心跳,必须准时 |
| 电机电流环 | 高(1) | 控制周期短,10-50μs |
| 编码器位置捕获 | 中(2) | 需要及时,但可容忍少量延迟 |
| 通信接口(CAN/SPI) | 低(3) | 可以缓冲,延迟影响小 |
这个配置的核心思想是:周期越短、实时性要求越高的中断,优先级越高。电流环如果被耽误,电机就会抖动。通信慢一点,顶多是数据延迟。
避坑指南:我曾经在一个六轴机器人项目里,把所有中断都设成了相同优先级。结果多个中断同时触发时,系统不知道先处理哪个,导致电流环和编码器中断互相干扰。后来我按照控制周期重新分配优先级,问题就解决了。记住:优先级不是随便设的,要根据实时性需求来排。
4.5 中断延迟的测量与优化
中断延迟——从硬件触发中断到ISR开始执行的时间——是衡量RTOS实时性的关键指标。
怎么测?我常用的方法是在ISR入口处翻转一个GPIO,然后用示波器看。简单粗暴,但有效。
// 测量中断延迟
void TIM1_IRQHandler(void)
{
// 进入ISR,立即翻转GPIO
GPIOA->ODR ^= GPIO_PIN_0;
// ... 实际处理代码 ...
// 退出前再翻转一次
GPIOA->ODR ^= GPIO_PIN_0;
}
用示波器测量GPIO的脉冲宽度,就能知道ISR的执行时间。再对比中断触发信号和GPIO上升沿的时间差,就是中断延迟。
影响中断延迟的因素有哪些?
- 关中断时间:临界区越长,延迟越大
- 中断嵌套:高优先级中断会抢占低优先级的
- 硬件设计:中断控制器、总线仲裁都会引入延迟
优化建议:
- 把临界区压缩到极致。我习惯用「三行原则」:读、写、清标志,三行搞定。
- 避免在ISR里调用复杂API。比如
printf、malloc——这些函数内部可能关中断。 - 如果硬件支持,把频繁触发的中断合并。比如多个编码器通道共用一个中断,在ISR里统一处理。
个人经验:我做过一个测试,在Cortex-M4上,FreeRTOS的关中断时间大约是0.2μs。如果临界区里只做简单的变量赋值,中断延迟可以控制在1μs以内。但如果临界区里调用了函数,延迟可能飙升到5μs以上。所以我的原则是:临界区里不调用任何函数,包括内联函数。
4.6 本章小结
中断管理是RTOS运动控制的基石。记住三个核心点:
- ISR要短小精悍,只做「读-通知-清标志」
- 中断和任务通过信号量、队列同步,别在ISR里做复杂运算
- 临界区保护共享数据,但别在里面待太久
嗯,这些原则说起来简单,做起来需要经验积累。我刚开始做运动控制时,也踩过不少坑。但只要记住「快进快出、同步分离、保护得当」这十二个字,基本不会出大问题。