3. 中断上下文与进程上下文:上下文切换开销、中断上下文限制、原子操作要求

在嵌入式 Linux 系统中,理解中断上下文与进程上下文的区别是优化 DMA 中断处理延迟的基石。中断处理程序运行在中断上下文中,而 tasklet 和工作队列则分别运行在软中断上下文和进程上下文中。错误的上下文选择会导致系统崩溃、数据竞争或不可预测的延迟。

3.1 上下文切换开销对比

上下文切换是指 CPU 从一个执行流切换到另一个执行流时保存和恢复寄存器、栈指针、程序计数器等状态的过程。不同上下文之间的切换开销差异显著,直接影响 DMA 中断处理的实时性。

上下文类型 切换触发条件 典型开销(周期数) 可抢占性 适用场景
中断上下文 硬件中断信号 50 ~ 200 不可抢占(除非嵌套) 极短时间、必须立即响应的操作
软中断上下文(tasklet) 中断返回前调度 200 ~ 500 不可抢占 中断下半部、非阻塞短任务
进程上下文(工作队列) 内核调度器 1000 ~ 5000 可抢占 阻塞操作、长时间处理、持有锁
关键指标: 中断上下文切换开销最小,但限制最多;进程上下文切换开销最大,但功能最完整。DMA 中断处理的设计目标是将最紧急的操作放在中断上下文,将耗时操作推迟到进程上下文。

下图展示了从硬件中断触发到不同上下文处理阶段的切换路径与开销分布:

上下文切换路径与开销对比 硬件中断触发 中断上下文 软中断上下文 进程上下文 开销:50~200 周期 不可抢占 开销:200~500 周期 不可抢占 开销:1000~5000 周期 可抢占 说明:中断上下文 → 软中断上下文 → 进程上下文,切换开销逐级增大,但可用的内核 API 范围也逐级扩大。

3.2 中断上下文的严格限制

运行在中断上下文中的代码必须遵守一系列严格的限制,违反这些限制会导致内核死锁、调度异常或系统崩溃。这些限制是设计 DMA 中断处理程序时必须牢记的“红线”。

  • 禁止睡眠或阻塞: 中断上下文不能调用任何可能导致睡眠的函数,如 wait_event()、msleep()、mutex_lock()、kmalloc(GFP_KERNEL)。中断上下文没有进程结构,无法被重新调度。
  • 禁止持有互斥锁: 只能使用自旋锁(spinlock)进行同步,因为自旋锁在等待时不会睡眠,而是忙等待。使用互斥锁(mutex)会导致死锁。
  • 禁止访问用户空间: 中断上下文与任何进程无关,无法访问用户空间的内存地址。尝试访问 copy_from_user() 或 copy_to_user() 会导致严重错误。
  • 执行时间必须极短: 中断上下文会屏蔽同级或更低优先级的中断,长时间占用会增大系统中断延迟,导致数据丢失或实时性恶化。
  • 栈空间有限: 中断栈通常只有 4KB ~ 16KB,递归调用或大型局部变量会导致栈溢出。
常见错误: 在 DMA 中断处理程序中调用 printk() 虽然不会立即崩溃,但 printk() 可能触发控制台输出,而控制台驱动可能使用互斥锁或睡眠,这在中断上下文中是危险的。调试时应使用 trace_printk() 或仅在确认安全的上下文中使用 printk()。

下表总结了中断上下文与进程上下文中可用的内核 API 差异:

内核 API / 操作 中断上下文 进程上下文
spin_lock() ✅ 可用 ✅ 可用
mutex_lock() ❌ 禁止 ✅ 可用
kmalloc(GFP_KERNEL) ❌ 禁止 ✅ 可用
kmalloc(GFP_ATOMIC) ✅ 可用 ✅ 可用
copy_from_user() ❌ 禁止 ✅ 可用
schedule() ❌ 禁止 ✅ 可用
wake_up() ✅ 可用 ✅ 可用

3.3 原子操作要求

原子操作是中断上下文与进程上下文共享数据时的核心同步机制。原子操作保证在单个 CPU 指令周期内完成读-修改-写操作,不会被中断或抢占打断。

3.3.1 原子变量与原子位操作

Linux 内核提供 atomic_t 类型和一组原子操作函数,适用于计数器、标志位等简单共享数据的操作:

#include <linux/atomic.h>

atomic_t dma_done_count = ATOMIC_INIT(0);

/* 中断上下文中安全递增 */
void dma_interrupt_handler(void *dev_id) {
    atomic_inc(&dma_done_count);
    /* 触发 tasklet 或工作队列 */
}

/* 进程上下文中读取 */
int check_dma_progress(void) {
    return atomic_read(&dma_done_count);
}

原子位操作适用于设备寄存器标志位的设置与清除:

#include <linux/bitops.h>

unsigned long dma_flags = 0;

/* 在中断上下文中设置完成位 */
set_bit(DMA_COMPLETE_BIT, &dma_flags);

/* 在进程上下文中测试并清除 */
if (test_and_clear_bit(DMA_COMPLETE_BIT, &dma_flags)) {
    /* 处理 DMA 完成事件 */
}

3.3.2 自旋锁在中断上下文中的使用

当需要保护多个共享变量或复杂数据结构时,必须使用自旋锁。在中断上下文中使用自旋锁时,必须使用 spin_lock_irqsave() 变体,以防止死锁:

#include <linux/spinlock.h>

DEFINE_SPINLOCK(dma_lock);
struct dma_buffer *shared_buf;
unsigned long flags;

/* 中断上下文 */
void dma_interrupt_handler(void *dev_id) {
    spin_lock_irqsave(&dma_lock, flags);
    /* 安全访问 shared_buf */
    shared_buf->status = DMA_DONE;
    spin_unlock_irqrestore(&dma_lock, flags);
}

/* 进程上下文(tasklet 或工作队列) */
void dma_worker(struct work_struct *work) {
    spin_lock_irqsave(&dma_lock, flags);
    /* 处理 DMA 数据 */
    process_dma_buffer(shared_buf);
    spin_unlock_irqrestore(&dma_lock, flags);
}
为什么必须使用 spin_lock_irqsave()? 在进程上下文中获取自旋锁时,如果中断处理程序也试图获取同一把锁,就会发生死锁:进程持有锁,中断到来,中断处理程序等待锁释放,而进程被中断打断无法释放锁。spin_lock_irqsave() 会在获取锁之前禁用本地 CPU 的中断,从而避免这种死锁场景。

下图展示了中断上下文与进程上下文之间通过原子操作和自旋锁进行数据同步的典型架构:

中断上下文与进程上下文同步架构 中断上下文 DMA 中断处理 原子递增 设置完成位 spin_lock_irqsave 共享数据 atomic_t / spinlock 进程上下文 工作队列处理 原子读取 测试并清除位 spin_lock_irqsave 核心原则:中断上下文只进行原子操作和快速状态更新, 复杂的数据处理通过 tasklet 或工作队列推迟到进程上下文执行。 自旋锁必须使用 irqsave 变体以防止死锁。

3.4 上下文选择策略与最佳实践

在 DMA 中断处理设计中,合理划分中断上下文与进程上下文的工作边界是优化延迟的关键。以下策略可以帮助开发者做出正确选择:

  1. 中断上下文只做最少工作: 仅执行必须立即完成的操作,如清除中断标志、保存 DMA 状态、递增原子计数器、唤醒等待队列。所有耗时操作必须推迟。
  2. 使用 tasklet 处理非阻塞短任务: 如果下半部不需要睡眠且执行时间较短(微秒级),使用 tasklet。tasklet 在软中断上下文中执行,切换开销较低。
  3. 使用工作队列处理阻塞或长时间任务: 如果下半部需要访问用户空间、持有互斥锁、执行文件 I/O 或进行大量计算,必须使用工作队列。工作队列在进程上下文中执行,可以睡眠。
  4. 优先使用原子操作而非锁: 对于简单的计数器或标志位,使用 atomic_t 或原子位操作,避免锁的开销和死锁风险。
  5. 测量并监控上下文切换开销: 使用 ftrace 或 perf 工具测量中断处理时间、tasklet 延迟和工作队列调度延迟,确保满足实时性要求。
经验法则: 如果 DMA 中断处理程序中的操作可以在 50 微秒内完成且不涉及阻塞,放在中断上下文;如果需要在 200 微秒内完成且不阻塞,使用 tasklet;如果超过 200 微秒或可能阻塞,必须使用工作队列。这条法则可以帮助你在延迟与功能完整性之间取得平衡。