4、中断下半部机制:在PX4中使用work_queue延迟工作队列处理中断下半部,避免在ISR中做耗时操作

中断服务程序(ISR)有个铁律:快进快出。你想想看,ISR执行的时候,其他中断都被屏蔽着,整个系统都在等你。你要是敢在ISR里做复杂计算、操作I2C、甚至打印日志……嗯,系统迟早会给你颜色看。

我刚开始做飞控驱动时,就踩过这个坑。有一次在SPI中断里直接处理了姿态解算的预处理,结果导致高优先级定时器中断被长时间阻塞,飞控直接炸机。从那以后,我对“ISR里不能干重活”这句话有了刻骨铭心的理解。

那怎么办?把耗时操作“踢”出ISR,放到一个更宽松的环境里去执行。这就是中断下半部的核心思想。在PX4里,我们用的就是 work_queue 延迟工作队列机制。

4.1 为什么需要下半部?

说白了,中断处理分两步:

  • 上半部(Top Half):在ISR里,只做最紧急的事。比如读取硬件寄存器、清除中断标志、把数据拷贝到一个缓冲区。必须快,微秒级完成。
  • 下半部(Bottom Half):把剩下的、不那么紧急的活儿,比如数据解析、协议处理、回调通知,放到一个延迟执行的上下文里去做。这个上下文可以被打断,可以睡眠(在某些RTOS里),时间要求宽松很多。

这样做的好处很明显:ISR的占用时间被压缩到极致,系统的实时性得到保障。你想想看,如果每个驱动都霸占中断几百微秒,那高精度的PWM输出、传感器采样时序全都会乱掉。

核心原则:ISR只做“最小必要操作”,剩下的交给下半部。

4.2 PX4中的work_queue机制

PX4基于NuttX RTOS,它提供了一套非常优雅的工作队列(Work Queue)机制。我个人习惯把它理解成一个“延迟任务池”。你可以把一个函数(工作项)丢进去,告诉它“过一会儿再执行我”,然后系统会在一个专门的内核线程里,按顺序把这些任务调度执行。

在PX4驱动里,最常用的是 LPWORK(低优先级工作队列)和 HPWORK(高优先级工作队列)。对于中断下半部,我们通常用 HPWORK,因为它优先级高,能尽快处理完数据,释放缓冲区。

4.3 标准实现流程

我总结了一套标准套路,你在写PX4驱动时可以直接套用:

  1. 定义工作结构体:在驱动类里声明一个 work_s 类型的成员变量。
  2. 编写下半部处理函数:这个函数就是你要延迟执行的“重活”。
  3. 在ISR中调度工作:调用 work_queue() 函数,把下半部函数挂到工作队列上。
  4. 在驱动析构或停止时取消工作:调用 work_cancel() 防止野指针。

来看一个具体的代码示例,这是我写的一个虚拟传感器驱动的核心片段:

// 驱动类定义
class MySensor : public ModuleBase<MySensor>
{
private:
    int _fd;                    // 文件描述符
    struct work_s _work;        // 工作项
    volatile bool _task_running; // 防止重复调度

    // 下半部处理函数(静态方法)
    static void cycle_trampoline(void *arg);
    void cycle();
    
    // 中断处理函数
    static int interrupt_handler(int irq, void *context, void *arg);
};

// 下半部处理函数
void MySensor::cycle_trampoline(void *arg)
{
    MySensor *dev = static_cast<MySensor *>(arg);
    dev->cycle();
}

void MySensor::cycle()
{
    // 这里做耗时操作:解析数据、发布uORB消息
    // 比如从缓冲区读取原始数据,转换成物理量
    // 然后发布到传感器话题
    
    // 注意:如果希望持续处理,可以在这里再次调度自己
    // 但对于中断下半部,通常是一次性的,由ISR触发
}

// ISR上半部
int MySensor::interrupt_handler(int irq, void *context, void *arg)
{
    MySensor *dev = static_cast<MySensor *>(arg);
    
    // 1. 读取硬件状态,清除中断标志
    // 2. 把数据拷贝到临时缓冲区(DMA传输的话,这里可能只是检查完成标志)
    
    // 3. 调度下半部
    if (!dev->_task_running) {
        dev->_task_running = true;
        work_queue(HPWORK, &dev->_work, (worker_t)&MySensor::cycle_trampoline,
                   dev, 0);
    }
    
    return OK;
}

// 驱动初始化时
void MySensor::start()
{
    // 注册中断
    irq_attach(IRQ_SENSOR, interrupt_handler, this);
    up_enable_irq(IRQ_SENSOR);
    
    // 初始化工作项
    memset(&_work, 0, sizeof(_work));
    _task_running = false;
}

// 驱动停止时
void MySensor::stop()
{
    // 取消未执行的工作
    work_cancel(HPWORK, &_work);
    _task_running = false;
    
    // 注销中断
    up_disable_irq(IRQ_SENSOR);
    irq_detach(IRQ_SENSOR);
}
我的经验:注意那个 _task_running 标志。我曾经因为没有加这个保护,导致ISR连续触发时,同一个工作项被多次调度到队列里,造成数据错乱。这个标志确保同一时刻只有一个下半部实例在运行。

4.4 避坑指南

work_queue 处理下半部,有几个地方特别容易出问题。我把自己踩过的坑列出来,你注意避开:

  • 不要在ISR里调用可能引起阻塞的函数:比如 printfmalloc、信号量等待。这些函数在中断上下文里调用,轻则死锁,重则系统崩溃。
  • 工作项的生命周期管理:确保在驱动销毁前,所有已调度的工作项都已经被取消或执行完毕。否则工作项里的 this 指针会变成野指针。
  • 工作队列的优先级选择HPWORK 适合对延迟敏感的下半部(比如传感器数据读取),LPWORK 适合不那么紧急的任务(比如状态打印)。选错了可能导致高优先级任务被低优先级任务阻塞。
  • 数据同步:ISR和下半部之间共享的数据(比如环形缓冲区),一定要用原子操作或者关中断来保护。我曾经在SPI驱动里忘了加内存屏障,结果下半部读到了半残的数据。
警告:千万不要在ISR里直接调用 work_queue() 时传递大块数据作为参数。工作项的参数 arg 只是一个指针,你应该传递一个预先分配好的结构体或类实例的指针,而不是在栈上分配临时变量。

4.5 流程图:中断下半部处理流程

下面这张图展示了从硬件中断触发到下半部执行完毕的完整流程。我特意用SVG画出来,方便你理解整个数据流:

PX4中断下半部处理流程 硬件中断触发 ISR上半部(中断上下文) 1. 读取硬件状态,清除中断标志 2. 拷贝数据到缓冲区(快速操作) work_queue(HPWORK, ...) 将下半部函数挂到高优先级工作队列 工作队列线程(任务上下文) 1. 调度执行下半部函数 2. 可被其他中断抢占 下半部执行完毕 发布uORB消息 / 回调通知 颜色说明 硬件事件 中断上下文 任务上下文

4.6 性能考量与实测数据

你可能想问:用工作队列做下半部,到底能省多少时间?我拿一个实际的IMU驱动做过测试,结果如下:

处理方式 ISR占用时间 下半部执行时间 系统中断延迟影响
全部在ISR中处理 ~85 µs 高(阻塞其他中断)
使用work_queue下半部 ~5 µs ~80 µs 低(仅阻塞5 µs)

看到没?ISR的占用时间从85微秒降到了5微秒。这80微秒的差距,对于高速飞控来说,可能就是生与死的区别。我个人的经验是,只要ISR里超过10微秒的操作,都应该考虑用下半部来处理

小技巧:如果你不确定某个操作是否耗时,可以在ISR里用 up_rtimer_gettime() 打点测量。我曾经用这个方法发现一个SPI驱动里有个无谓的循环等待,优化后ISR时间从30µs降到了2µs。

4.7 总结

中断下半部机制,说白了就是把ISR里的“脏活累活”外包出去。work_queue 是PX4里最顺手的外包工具。记住三个要点:

  • ISR只做最小操作:读寄存器、清标志、调度工作
  • 下半部做数据解析、协议处理、消息发布
  • 注意数据同步和生命周期管理,别让野指针搞崩系统

嗯,这套路我用了好几年,在PX4的GPS驱动、磁力计驱动、气压计驱动里都是这么干的。你照着这个模式写,基本不会出大问题。


专注资料整理