嵌入式驱动开发:量产级工程化实战(第 2 篇)——中断服务程序里到底该放什么
嵌入式驱动开发量产级工程化实战第 2 篇——中断服务程序里到底该放什么一次通信丢包的排查先看一个真实的排查案例。某工业设备用 UART 接收上位机的 Modbus 指令波特率 115200。实验室测试一切正常但到了现场偶尔会丢包。概率不高大概每几千帧丢一帧。用逻辑分析仪抓波形发现丢包的时候UART 接收引脚上的数据是完整的没有干扰没有丢位。但 MCU 的接收缓冲区里就是少了一帧数据。排查到最后根因在 ISR 里voidUSART1_IRQHandler(void){if(USART1-SRUSART_SR_RXNE){uint8_tbyteUSART1-DR;// 在 ISR 里直接解析 Modbus 协议if(ParseModbusByte(byte)){// 解析完成处理指令HandleModbusCommand(modbus_frame);// 构造响应BuildModbusResponse(modbus_frame,tx_buf);// 直接发送UART_SendBlocking(tx_buf,tx_len);}}}这段代码的问题在于ISR 里做了太多事情。 HandleModbusCommand 里可能涉及 Flash 读写、传感器采集UART_SendBlocking 是阻塞发送等待发送完成。当 ISR 执行时间过长时下一个 UART 字节到达时RXNE 标志已经被置位但 ISR 还没退出无法响应新的中断。如果此时又来了两个字节硬件接收移位寄存器通常是 1 字节深就被覆盖了。这就是丢包的根因。ISR 的核心原则是快进快出。但快不是目的快是为了保证中断延迟和中断响应不受影响。这篇文章我要讲的是ISR 到底该放什么不该放什么以及当业务逻辑必须在中断触发后执行时怎么用下半部机制把 ISR 做薄。一、中断延迟被忽视的时间账什么是中断延迟中断延迟Interrupt Latency是指从中断信号产生到 ISR 第一条指令执行中间经过的时间。这个时间包括三部分硬件响应时间中断信号到达 CPU 内核到 CPU 完成当前指令、保存上下文、跳转到中断向量。在 Cortex-M 上这个时间通常是 12 个时钟周期尾部链式到 16 个周期非链式。如果是浮点上下文还要额外 12-28 个周期。软件屏蔽时间如果当前代码处于临界区taskENTER_CRITICAL 或 __disable_irq中断会被屏蔽直到临界区退出。这段屏蔽时间直接加到中断延迟上。更高优先级 ISR 的执行时间如果当前正在执行一个更高优先级的中断低优先级中断要等它执行完。这段等待时间也加到中断延迟上。中断延迟决定了系统的实时性。 如果一个电机控制中断的延迟超过了 PWM 周期的 10%控制精度就会受影响。如果一个通信中断的延迟超过了一个字节的传输时间就会丢包。用逻辑分析仪实测中断延迟理论计算是必要的但实测更可靠。方法很简单在中断触发点翻转一个 GPIO在 ISR 入口翻转另一个 GPIO。 两个 GPIO 之间的时间差就是中断延迟。// 中断触发源比如 UART 接收完成voidUART_RxComplete_Callback(void){GPIO_SetHigh(DEBUG_PIN_1);// 中断触发GPIO_SetLow(DEBUG_PIN_1);}// ISR 入口voidUSART1_IRQHandler(void){GPIO_SetHigh(DEBUG_PIN_2);// ISR 开始GPIO_SetLow(DEBUG_PIN_2);// ... ISR 内容}用逻辑分析仪同时抓两个引脚就能看到中断延迟。如果延迟是稳定的几十纳秒说明没问题。如果延迟偶尔跳到几百微秒说明有临界区或更高优先级中断在阻塞。我在一个项目里实测过 理论计算的中断延迟是 0.5μs但实测偶尔会跳到 80μs。排查发现是一个低优先级任务里调用了 taskENTER_CRITICAL而且临界区里有 Flash 擦除操作。Flash 擦除一次要几毫秒这段时间中断全被屏蔽了。这就是典型的临界区过大导致中断延迟不可控。中断延迟的预算怎么定不同类型的系统对中断延迟的要求不同设计原则是系统里最严格的那个中断延迟要求决定了所有临界区的最大长度。如果电机控制要求 5μs 延迟那么任何临界区都不能超过 5μs。Flash 擦除这种毫秒级操作绝对不能放在临界区里。二、ISR 里该放什么不该放什么三条铁律铁律一ISR 里只做必须立刻做的事。什么是必须立刻做的事只有一件读取硬件寄存器清除中断标志把数据搬到安全的地方。剩下的所有事情都可以放到 ISR 外面做。以 UART 接收为例ISR 里只做voidUSART1_IRQHandler(void){// 1. 读数据寄存器读操作会自动清 RXNE 标志uint8_tbyteUSART1-DR;// 2. 把数据放入环形缓冲区非阻塞RingBuffer_Put(rx_ringbuf,byte);// 3. 通知任务FromISR 版本非阻塞BaseType_t xHigherPriorityTaskWokenpdFALSE;vTaskNotifyGiveFromISR(xCommTaskHandle,xHigherPriorityTaskWoken);portYIELD_FROM_ISR(xHigherPriorityTaskWoken);}就这三件事。读取、存储、通知。整个 ISR 执行时间在微秒级。铁律二ISR 里不能调用任何可能阻塞的 API。FreeRTOS 的 API 分两个版本普通版本和 FromISR 版本。在 ISR 里必须用 FromISR 版本。原因很简单普通版本的 API 内部可能会调用 vTaskDelay 或等待信号量而中断上下文不允许阻塞。典型错误// 错误在 ISR 里用了普通版本的 xQueueSendvoidUSART1_IRQHandler(void){uint8_tbyteUSART1-DR;xQueueSend(xRxQueue,byte,portMAX_DELAY);// 错误}铁律三ISR 里不能有浮点运算和耗时库函数。在 Cortex-M4F/M7 上如果 ISR 里使用了浮点运算硬件会自动保存浮点上下文FPU 寄存器这会额外增加 12-28 个时钟周期的中断延迟。更严重的是如果 configUSE_TASK_FPU_SUPPORT 配置不当可能导致任务切换时浮点上下文丢失。printf、sprintf、malloc、free 这些库函数绝对不能在 ISR 里调用。它们的执行时间不可预测而且可能涉及锁操作。一张对照表三、下半部机制把 ISR 做薄的标准方法ISR 只做必须立刻做的事剩下的可以稍后做的事放到下半部Bottom Half执行。FreeRTOS 提供了三种下半部机制各有适用场景。机制一任务通知Task Notification—— 最快任务通知是 FreeRTOS 里最快的任务间通信机制。它不需要创建额外的队列或信号量直接操作任务的 TCB任务控制块。速度比信号量快 45%比队列快 25%。适用场景 ISR 只需要通知任务有事发生了不需要传递大量数据。// 定义队列QueueHandle_t xSensorQueue;// ISR传感器数据就绪voidEXTI0_IRQHandler(void){EXTI-PREXTI_PR_PR0;// 清中断标志SensorData_t data;data.timestampGetTickCount();data.valueReadSensorRegister();BaseType_t xHigherPriorityTaskWokenpdFALSE;xQueueSendFromISR(xSensorQueue,data,xHigherPriorityTaskWoken);portYIELD_FROM_ISR(xHigherPriorityTaskWoken);}// 任务voidvSensorProcessTask(void*pvParameters){SensorData_t data;for(;;){if(xQueueReceive(xSensorQueue,data,portMAX_DELAY)pdTRUE){ProcessSensorData(data);}}}队列的代价 每次 xQueueSend 都会复制数据。如果数据很大比如一帧 256 字节的通信数据复制的开销不可忽视。对于大数据量建议传递指针而不是数据本身。机制三信号量Semaphore—— 最灵活信号量适合事件通知场景不传递具体数据。二值信号量用于事件发生计数信号量用于资源计数。适用场景 ISR 需要唤醒一个任务但不需要传递数据。比如 DMA 传输完成、定时器到期。// 定义信号量SemaphoreHandle_t xDmaCompleteSem;// ISRDMA 传输完成voidDMA1_Channel1_IRQHandler(void){DMA1-IFCRDMA_IFCR_CGIF1;// 清中断标志BaseType_t xHigherPriorityTaskWokenpdFALSE;xSemaphoreGiveFromISR(xDmaCompleteSem,xHigherPriorityTaskWoken);portYIELD_FROM_ISR(xHigherPriorityTaskWoken);}// 任务voidvDmaProcessTask(void*pvParameters){for(;;){if(xSemaphoreTake(xDmaCompleteSem,portMAX_DELAY)pdTRUE){ProcessDmaBuffer();}}}三种机制的对比与选择选择原则只需要通知有事发生不需要传数据 →任务通知需要传递数据数据量不大 →队列需要事件同步多个任务等待同一个事件 →信号量但注意二值信号量只能唤醒一个任务如果需要唤醒多个任务用事件组一个完整的例子UART 接收 协议解析把前面讲的整合起来这是我在量产项目里的标准做法// 全局变量 #defineRX_RINGBUF_SIZE512staticRingBuffer_t rx_ringbuf;staticTaskHandle_t xProtocolTaskHandle;// ISR只做三件事 voidUSART1_IRQHandler(void){// 1. 读数据自动清 RXNE 标志uint8_tbyteUSART1-DR;// 2. 存入环形缓冲区RingBuffer_Put(rx_ringbuf,byte);// 3. 通知协议任务BaseType_t xHigherPriorityTaskWokenpdFALSE;vTaskNotifyGiveFromISR(xProtocolTaskHandle,xHigherPriorityTaskWoken);portYIELD_FROM_ISR(xHigherPriorityTaskWoken);}// 协议任务做所有耗时的事 voidvProtocolTask(void*pvParameters){uint8_tbyte;ModbusFrame_t frame;for(;;){// 等待通知最多等 50msulTaskNotifyTake(pdTRUE,pdMS_TO_TICKS(50));// 从环形缓冲区读取所有可用数据while(RingBuffer_Get(rx_ringbuf,byte)){if(ParseModbusByte(frame,byte)){// 一帧解析完成HandleModbusCommand(frame);BuildModbusResponse(frame,tx_buf);UART_Send(tx_buf,tx_len);// 非阻塞发送}}}}这个设计的价值ISR 执行时间 5μs中断延迟可控协议解析、指令处理、响应构造都在任务里做不阻塞中断环形缓冲区作为 ISR 和任务之间的缓冲即使任务暂时没运行数据也不会丢前提是缓冲区够大四、ISR 与任务共享资源的保护数据竞争的三种场景ISR 和任务共享数据时如果保护不当会出现数据竞争。常见场景有三种场景一ISR 写任务读。比如 ISR 更新一个全局变量任务读取它。如果变量是 32 位的在 32 位 MCU 上读写是原子的一般不会有问题。但如果是 64 位变量或者结构体读写可能被打断导致读到半新半旧的数据。场景二任务写ISR 读。比如任务更新一个配置参数ISR 读取它。这种场景更危险因为任务写入过程中可能被 ISR 打断。场景三双方都写。比如 ISR 和任务都维护一个计数器。这种场景必须加保护否则计数器会丢失更新。保护方法临界区 vs 原子操作方法一临界区Critical Section。// 任务里taskENTER_CRITICAL();shared_datanew_value;taskEXIT_CRITICAL();// ISR 里UBaseType_t savedtaskENTER_CRITICAL_FROM_ISR();shared_datanew_value;taskEXIT_CRITICAL_FROM_ISR(saved);taskENTER_CRITICAL 会关闭中断或提高到某个优先级以上保证临界区内的代码不被打断。代价是增加了中断延迟。 所以临界区必须尽可能短。方法二原子操作。对于单个变量的读写如果变量是自然对齐的32 位变量在 32 位地址上在 ARM Cortex-M 上读写是原子的不需要加保护。// 32 位变量自然对齐读写原子volatileuint32_tg_tick_count;// ISRg_tick_count;// 任务uint32_ttickg_tick_count;// 原子读不需要保护但要注意volatile 只保证编译器不优化读写不保证原子性。对于 64 位变量或结构体即使加了 volatile读写也可能被打断。方法三双缓冲Double Buffering。对于大数据块的共享用双缓冲可以避免加锁。ISR 写缓冲区 A任务读缓冲区 B通过一个指针切换。typedefstruct{uint8_t*write_buf;uint8_t*read_buf;volatileuint32_twrite_len;}DoubleBuffer_t;// ISRvoidDMA_Complete_ISR(void){// 切换缓冲区uint8_t*tmpdb.write_buf;db.write_bufdb.read_buf;db.read_buftmp;db.write_lencurrent_len;// 通知任务vTaskNotifyGiveFromISR(xTaskHandle,xHigherPriorityTaskWoken);}// 任务voidvProcessTask(void*pvParameters){for(;;){ulTaskNotifyTake(pdTRUE,portMAX_DELAY);// 读取 read_buf此时 ISR 在写 write_buf不冲突ProcessBuffer(db.read_buf,db.write_len);}}双缓冲的优点是不需要关中断中断延迟不受影响。缺点是内存开销翻倍且只适用于单生产者单消费者场景。五、常见误区与踩坑记录误区一ISR 里调用 printf 调试这是新手最常犯的错误。printf 内部会调用 malloc、fwrite、可能还会等待 UART 发送完成。在 ISR 里调用轻则执行时间不可预测重则死锁。正确做法在 ISR 里设置一个标志或写入环形缓冲区在任务里打印。误区二忘记清中断标志有些中断标志需要手动清除有些读寄存器会自动清除。如果忘记清除ISR 退出后会立即再次进入形成中断风暴。典型症状 系统看起来卡死了但 CPU 占用率 100%调试器暂停后发现一直在 ISR 里。排查方法 在 ISR 入口和出口翻转 GPIO用示波器看 ISR 的执行频率。如果频率异常高就是中断标志没清。误区三在 ISR 里用 vTaskDelayvTaskDelay 是阻塞函数在 ISR 里调用会触发 configASSERT 失败如果开启了断言或者直接死机。正确做法如果 ISR 需要延时用硬件定时器或者直接返回让任务去处理延时。误区四临界区里做耗时操作// 错误临界区里有 Flash 擦除taskENTER_CRITICAL();Flash_EraseSector(addr);// 几毫秒的操作taskEXIT_CRITICAL();这几毫秒里所有中断被屏蔽。如果此时有通信数据到达就会丢包。正确做法 临界区只保护共享数据的读写耗时操作放在临界区外面。****误区五FromISR API 的返回值没处理xQueueSendFromISR、xSemaphoreGiveFromISR 等函数的最后一个参数是 pxHigherPriorityTaskWoken。如果这个值被设为 pdTRUE说明有更高优先级任务被唤醒需要在 ISR 退出前触发一次任务切换。BaseType_t xHigherPriorityTaskWokenpdFALSE;xQueueSendFromISR(xQueue,data,xHigherPriorityTaskWoken);// 忘记调用 portYIELD_FROM_ISR(xHigherPriorityTaskWoken);如果忘记调用被唤醒的高优先级任务要等到下一个 Tick 才能运行增加了任务响应延迟。在硬实时系统里这可能就是致命的。六、本篇小结ISR 设计的核心是快进快出。具体来说**第一ISR 里只做三件事**读硬件寄存器、清中断标志、把数据搬到安全的地方。 剩下的所有事情都放到任务里做。第二用下半部机制把 ISR 做薄。任务通知最快适合简单事件队列最通用适合传递数据信号量最灵活适合事件同步。根据场景选择。第三ISR 和任务共享资源要保护。短临界区保护共享变量双缓冲避免大数据块的竞争原子操作处理自然对齐的单个变量。第四实测中断延迟。用 GPIO 翻转 逻辑分析仪测量从触发到 ISR 入口的时间。如果延迟不稳定或超出预算检查临界区和更高优先级中断。下一篇我们讲优先级翻转的真实案例。这是 RTOS 系统里最隐蔽的问题之一一个低优先级任务持有互斥锁一个高优先级任务等待它结果被一个中优先级任务插队导致高优先级任务被无限期阻塞。我会用示波器和逻辑分析仪演示翻转现象并对比互斥锁和二值信号量的正确选择。思考题你的项目里最长的 ISR 执行时间是多少有没有实测过如果你在 ISR 里调用 xQueueSend非 FromISR 版本会发生什么临界区里的代码最长允许多少时间这个时间由什么决定欢迎在评论区留下你的答案。下一篇见。点赞 收藏 关注如果这篇文章让你对 ISR 设计有了新的认识点赞让更多同行看到收藏方便随时查阅关注不错过下一篇《优先级翻转的真实案例》。