1. 这篇文章真正要解决的问题

你是否遇到过这样的场景:一个运行稳定的嵌入式Linux系统,在某个看似无关紧要的版本升级或功能添加后,开始出现偶发性的系统卡死、数据错乱,甚至硬件复位?你排查了内存、驱动、应用逻辑,却始终找不到根因。最终,问题可能指向一个你从未深入关注的内核特性—— 抢占(Preemption) 。

本文要解决的,正是这个在嵌入式开发中极易被忽略的“定时炸弹”。抢占,这个旨在提升系统响应能力的内核机制,在带来实时性好处的同时,也可能将你代码中潜藏的逻辑缺陷、资源竞争和时序问题,以一种极其隐蔽和随机的方式暴露出来。它就像一个高倍显微镜,让原本在“慢吞吞”的非抢占内核下能勉强运行的瑕疵代码,在抢占开启后瞬间原形毕露,引发难以复现和调试的系统级故障。

我们将从一个具体的案例(ER2026)切入,深入剖析Linux内核抢占机制(特别是 PREEMPT_RT 实时补丁)的工作原理,解释它如何改变了中断、软中断和进程调度的时序,从而引爆那些依赖“隐式同步”或“侥幸时序”的代码。更重要的是,本文不仅告诉你“是什么”和“为什么”,更会提供一套完整的“怎么办”的实践指南:如何判断你的系统是否受抢占影响,如何编写抢占安全的代码,以及当抢占导致问题显现时,如何进行系统性的诊断和修复。

如果你正在开发对稳定性要求极高的工业控制、汽车电子或网络设备,或者你的系统正在向更高实时性需求演进,那么理解并驾驭抢占,是你必须跨过的一道坎。

2. 基础概念:抢占、非抢占与实时补丁

在深入问题之前,我们必须清晰理解几个核心概念。很多开发者对“抢占”只有一个模糊的印象,这恰恰是隐患的源头。

2.1 非抢占式内核(Non-Preemptive Kernel) 在经典的Linux内核配置(如 CONFIG_PREEMPT_NONE )下,内核空间本身是不可抢占的。这意味着:

  • 当一个进程通过系统调用进入内核态后,它会一直执行,直到 主动放弃CPU (如调用 schedule() 、等待资源)或 完成系统调用返回用户态 。
  • 在此期间,即使有更高优先级的进程就绪,或者硬件中断到来,当前进程的内核执行路径也不会被强行打断。中断处理程序(ISR)执行完毕后,会返回到被中断的内核代码点继续执行。
  • 带来的“假象” :开发者编写的内核模块、驱动代码,在单CPU上运行时,其指令序列看起来是“原子性”的。如果两段代码没有显式使用锁(spinlock, mutex)进行保护,但由于它们不会相互穿插执行,可能偶然也能正常工作。这培养了危险的编程习惯。

2.2 可抢占式内核(Preemptive Kernel) 通过配置 CONFIG_PREEMPT_VOLUNTARY 或更激进的 CONFIG_PREEMPT ,内核变得可在大部分位置被抢占。

  • CONFIG_PREEMPT :允许在内核态的几乎所有地方(除了一些明确的临界区,如持有自旋锁时)被更高优先级的进程抢占。
  • 改变了什么 :你的驱动或内核代码在执行到两个关键操作中间时,可能会被强行切出CPU,让位给另一个进程。如果这两步操作访问了共享资源(如全局变量、硬件寄存器),而另一个进程也访问它,数据竞争和状态不一致就发生了。

2.3 实时补丁(PREEMPT_RT) 这是抢占机制的终极形态。 PREEMPT_RT 补丁的目标是将Linux内核转化为一个真正的实时操作系统内核。它做了更激进的改造:

  • 中断线程化 :将大部分硬件中断处理程序(ISR)转化为内核线程。中断到来只是唤醒一个高优先级的线程,而线程本身是可以被更高优先级线程抢占的。这极大地降低了中断延迟,但彻底改变了中断上下文的语义。
  • 自旋锁可抢占化 :将许多自旋锁(spinlock)替换为可抢占的互斥锁(rt_mutex),允许在持有锁时发生抢占,这需要极其精细的锁顺序管理,否则极易导致优先级反转或死锁。
  • 对驱动的挑战 :很多传统驱动假设在中断上下文(不可睡眠、不可被抢占)中执行特定操作。在 PREEMPT_RT 下,这个假设被打破,如果驱动使用了可能引起调度或睡眠的函数(如 kmalloc(GFP_KERNEL) 、 mutex_lock ),在非RT内核中可能只是警告,在RT内核中会导致内核崩溃。

简单类比:非抢占内核像是一个单线程的办事窗口,大家排队办完一件事再办下一件,秩序看似“良好”。可抢占内核像是开了多个窗口,但叫号系统可能随时让正在办事的人离开去处理“VIP客户”。而 PREEMPT_RT 则更进一步,连窗口里面的柜员自己都可能被临时调走去干别的,整个办事流程的时序完全不可预测。如果你的业务流程(代码逻辑)没有为这种极端情况设计,混乱是必然的。

3. 环境准备与诊断工具

在分析具体案例前,我们需要搭建一个可以观察和实验抢占行为的环境。这里假设你有一个基于ARM或x86的嵌入式开发板或QEMU模拟环境。

3.1 内核配置确认 首先,必须明确你当前运行的内核抢占模型。

# 查看当前内核的抢占配置
zcat /proc/config.gz | grep -E “PREEMPT|PREEMPTION”
# 或者使用内核构建目录下的.config文件
grep -E “PREEMPT|PREEMPTION” .config

常见的输出和其含义:

  • CONFIG_PREEMPT_NONE=y : 经典的非抢占式内核。适合吞吐量优先的服务器。
  • CONFIG_PREEMPT_VOLUNTARY=y : 自愿式抢占,在内核的一些特定点(如 might_sleep() )检查是否可以调度。
  • CONFIG_PREEMPT=y : 完全可抢占内核(通用桌面/嵌入式常用)。
  • CONFIG_PREEMPT_RT=y : 应用了实时补丁的可抢占内核。
  • CONFIG_PREEMPT_RT_FULL=y : 完整的实时补丁内核。

3.2 关键调试工具

  1. Ftrace : Linux内核内置的最强大跟踪工具,可以跟踪函数调用、中断关闭/开启、调度事件等。
    # 挂载debugfs(通常已挂载)
    mount -t debugfs nodev /sys/kernel/debug
    # 使用function_graph跟踪器,查看特定进程的内核函数调用与调度情况
    echo function_graph > /sys/kernel/debug/tracing/current_tracer
    echo “*spin*” > /sys/kernel/debug/tracing/set_ftrace_filter # 过滤包含spin的函数
    echo $$ > /sys/kernel/debug/tracing/set_ftrace_pid # 跟踪当前shell进程
    echo 1 > /sys/kernel/debug/tracing/tracing_on
    # ... 执行你的测试程序 ...
    echo 0 > /sys/kernel/debug/tracing/tracing_on
    cat /sys/kernel/debug/tracing/trace > /tmp/trace.log
    
  2. /proc/interrupts 和 /proc/softirqs : 观察硬中断和软中断的分布,在RT内核下,许多中断会变成线程,其统计信息呈现方式不同。
  3. strace / ltrace : 跟踪用户空间进程的系统调用和库函数调用,结合内核日志,可以分析用户态-内核态的交互时序。
  4. 内核日志(dmesg) : 关注 sched , irq , preempt 等关键词的警告或错误信息。
  5. cyclictest : 实时性测试的金标准。它可以测量从事件发生(如定时器到期)到用户空间线程被唤醒的实际延迟,是验证RT补丁效果和发现延迟毛刺的直接工具。
    # 安装(通常包含在rt-tests包中)
    # apt-get install rt-tests 或 yum install rt-tests
    # 运行一个基本测试,运行60秒,优先级为80,间隔为1000微秒
    cyclictest -t1 -p80 -n -i 1000 -l 100000 -D 60
    # 关注输出的 “Max Latency” 列,如果远大于间隔(如>100us),说明存在严重的调度延迟。
    

4. 案例深潜:ER2026的“幽灵复位”

假设我们有一个名为ER2026的嵌入式设备,其核心是一个运行Linux的SoC,外接一个通过SPI总线通信的FPGA。FPGA负责高速数据采集,Linux应用通过SPI驱动读取数据。

4.1 问题现象 在将内核从 CONFIG_PREEMPT_NONE 升级到 CONFIG_PREEMPT 以改善UI响应后,系统在连续运行数小时至数天后,会偶发性地发生整个SoC的硬件看门狗复位。复位前,可能伴随少量SPI数据错误,但无内核oops或panic日志。问题极难复现。

4.2 传统排查的盲区

  1. 内存检测 : memtester 长时间测试无错误。
  2. 电源检测 :电源纹波在规范内。
  3. 驱动日志 :SPI驱动打开了调试日志,但发生复位前最后一刻的日志常常是正常的读写操作。
  4. 应用日志 :用户态数据采集进程日志显示,在复位前瞬间收到了异常数据包。

4.3 抢占视角下的根因分析 让我们重新审视SPI驱动和应用的交互代码。

驱动代码(简化且有隐患的版本):

// 驱动中的全局状态结构体
struct er2026_spi_dev {
    struct spi_device *spi;
    u8 *dma_buffer;
    dma_addr_t dma_handle;
    bool transfer_in_progress; // 标志位,表示传输是否进行中
    struct completion xfer_done;
};

static irqreturn_t er2026_spi_dma_callback(int irq, void *dev_id)
{
    struct er2026_spi_dev *dev = dev_id;
    // 标记传输完成
    dev->transfer_in_progress = false;
    complete(&dev->xfer_done);
    return IRQ_HANDLED;
}

static ssize_t er2026_spi_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos)
{
    struct er2026_spi_dev *dev = filp->private_data;
    int ret;

    // [隐患点 A] 检查并设置标志位 —— 非原子操作!
    if (dev->transfer_in_progress) {
        return -EBUSY;
    }
    dev->transfer_in_progress = true; // 假设此刻被抢占...

    // 启动DMA传输
    ret = start_spi_dma_transfer(dev);
    if (ret) {
        dev->transfer_in_progress = false; // 错误恢复
        return ret;
    }

    // 等待DMA完成中断
    wait_for_completion_interruptible(&dev->xfer_done);

    // 复制数据到用户空间
    if (copy_to_user(buf, dev->dma_buffer, count)) {
        dev->transfer_in_progress = false;
        return -EFAULT;
    }

    // [隐患点 B] 清除标志位
    dev->transfer_in_progress = false;
    return count;
}

用户空间应用代码(简化):

// 两个独立的采集线程
void *acquisition_thread_1(void *arg) {
    while (running) {
        read(spi_fd, buffer1, SIZE);
        // 处理数据...
    }
}
void *acquisition_thread_2(void *arg) {
    while (running) {
        read(spi_fd, buffer2, SIZE); // 访问同一个SPI设备!
        // 处理数据...
    }
}

抢占如何引爆隐患?

  1. 时序破坏 :在非抢占内核中, er2026_spi_read 函数从检查 transfer_in_progress 到将其设置为 true ,再到启动DMA,这条执行路径是连续的。即使两个线程同时调用 read ,由于内核不可抢占,第一个线程会完整执行完标志设置和DMA启动,第二个线程在检查标志时就会看到 true 并返回 -EBUSY 。
  2. 抢占介入 :在可抢占内核中,情况截然不同。假设线程1执行到 dev->transfer_in_progress = true; 这行代码 之后 ,但在 start_spi_dma_transfer() 之前 ,被一个更高优先级的任务(比如一个实时调度策略的线程)抢占了CPU。
  3. 竞争窗口 :此时,标志位已被设为 true ,但DMA传输 尚未启动 。线程1被切出,线程2得以运行。线程2调用 read ,检查 transfer_in_progress ,发现是 true (因为线程1已设置),于是直接返回 -EBUSY 给应用。应用可能将此错误理解为临时繁忙,进行重试或记录错误。
  4. 核心灾难 :线程1恢复执行,它从被抢占点( start_spi_dma_transfer 之前)继续运行,启动了DMA传输,然后等待完成。 此时,硬件DMA引擎开始操作SPI控制器和内存缓冲区。
  5. 第二次抢占与冲突 :在线程1等待DMA完成期间,线程2可能再次被调度,并且再次调用 read 。由于线程1正在等待( transfer_in_progress 仍为 true ),线程2可能再次获得 -EBUSY 。但在某种极端时序下,如果线程1的DMA传输完成,中断回调函数将 transfer_in_progress 设置为 false 并完成 completion 。线程1从 wait_for_completion 返回,执行 copy_to_user ,然后将 transfer_in_progress 设为 false 后退出。
  6. 致命时刻 :就在线程1将 transfer_in_progress 设为 false 之后, 线程2的 read 调用刚刚通过标志检查 (因为此时标志为 false ),于是它又将标志设为 true ,并尝试启动 第二次DMA传输 。而此时,第一次DMA传输可能刚刚结束,硬件状态尚未完全稳定,或者DMA描述符正在被释放。两个并发(尽管在代码逻辑上不是并发的)的DMA操作竞争同一组硬件寄存器,导致SPI控制器状态机混乱、DMA地址冲突,最终可能引发总线错误(Bus Error)、内存损坏,或者触发SoC内部的总线监护器或看门狗,导致整个系统复位。

这个Bug的隐蔽性在于:

  • 极小的竞争窗口 :标志检查与设置之间的窗口非常短。
  • 依赖特定调度序列 :需要两次精确的抢占时机。
  • 症状延迟且严重 :竞争导致硬件级故障,直接复位,而非简单的软件错误。

5. 解决方案:编写抢占安全的代码

针对ER2026案例,以及更普遍的抢占环境,我们必须采用新的编程范式。

5.1 使用正确的锁机制 共享的标志位或状态变量,必须用锁保护。对于可能在内核态被抢占的代码路径,优先选择互斥锁( mutex ),因为它可以睡眠,更适合可能发生调度的场景。自旋锁( spinlock )应用于中断上下文或绝对不能睡眠的短临界区。

修复后的驱动代码片段:

#include <linux/mutex.h>

struct er2026_spi_dev {
    // ... 其他成员
    struct mutex device_lock; // 增加一个互斥锁
    bool transfer_in_progress;
    struct completion xfer_done;
};

// 在设备初始化时初始化锁
mutex_init(&dev->device_lock);

static ssize_t er2026_spi_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos)
{
    struct er2026_spi_dev *dev = filp->private_data;
    int ret;

    // 进入临界区
    mutex_lock(&dev->device_lock);

    if (dev->transfer_in_progress) {
        mutex_unlock(&dev->device_lock);
        return -EBUSY;
    }
    dev->transfer_in_progress = true;

    ret = start_spi_dma_transfer(dev);
    if (ret) {
        dev->transfer_in_progress = false;
        mutex_unlock(&dev->device_lock);
        return ret;
    }

    // 注意:在等待完成期间,我们需要释放锁,否则其他线程无法检查状态。
    // 但释放锁后,transfer_in_progress标志需要以其他方式保护。
    // 更优的设计是:将“启动传输”和“等待完成”作为一个原子操作,由锁保护。
    // 这里展示一种改进设计:将标志位的意义改为“设备已打开且正在被某个会话使用”,
    // 而单个传输的同步由completion机制保证。这需要重构,但思路是:
    // 1. 锁保护“设备占用权”。
    // 2. 获得占用权后,启动传输,然后释放锁。
    // 3. 在同一个文件句柄上下文中等待本次传输完成。
    // 4. 传输完成后,其他线程可以竞争设备占用权。

    // 简化修复:使用锁保护整个序列(可能影响吞吐量,但正确性优先)
    // mutex_unlock(&dev->device_lock); // 错误的释放位置

    wait_for_completion_interruptible(&dev->xfer_done);

    if (copy_to_user(buf, dev->dma_buffer, count)) {
        dev->transfer_in_progress = false;
        mutex_unlock(&dev->device_lock);
        return -EFAULT;
    }

    dev->transfer_in_progress = false;
    mutex_unlock(&dev->device_lock); // 在最后释放锁
    return count;
}
// 注意:此简化修复会序列化所有read调用,可能成为性能瓶颈。实际项目中需要更精细的设计。

5.2 原子操作 对于简单的标志位,使用原子操作( atomic_t , set_bit , test_and_set_bit )是更轻量级的选择。

#include <linux/atomic.h>

struct er2026_spi_dev {
    // ...
    atomic_t transfer_busy; // 使用原子变量
};

// 在read函数中
if (atomic_cmpxchg(&dev->transfer_busy, 0, 1) != 0) {
    return -EBUSY;
}
// ... 启动传输 ...
// 传输完成或出错时
atomic_set(&dev->transfer_busy, 0);

atomic_cmpxchg (比较并交换)操作是原子的,它同时完成了“检查是否为0”和“设置为1”两个动作,中间不会被抢占打断,完美闭合了竞争窗口。

5.3 禁止本地抢占 在极短的关键序列中,如果确定不能使用锁(例如在中断上半部),可以使用 preempt_disable() 和 preempt_enable() 来临时禁止当前CPU的抢占。但这必须非常谨慎,因为长时间禁止抢占会导致调度延迟增加,影响系统实时性。

    preempt_disable();
    // 执行不可分割的几行关键代码,例如操作每CPU变量或硬件寄存器
    if (!dev->transfer_in_progress) {
        dev->transfer_in_progress = true;
        start_transfer = true;
    }
    preempt_enable();
    if (start_transfer) {
        // ... 启动传输
    }

5.4 为PREEMPT_RT做好准备 如果计划使用 PREEMPT_RT ,需要全面审查驱动和内核模块:

  • 中断上下文 :检查所有中断处理函数(ISR),确保其中没有调用可能睡眠的函数(如 kmalloc(GFP_KERNEL) , mutex_lock , 某些 wait_event 变体)。RT下中断是线程,可以睡眠,但很多驱动代码并非为此设计。
  • 自旋锁 :评估将 spinlock_t 替换为 raw_spinlock_t 的必要性。RT补丁将普通自旋锁变成了可睡眠的锁,在确实需要关抢占/关中断的底层操作中,需要使用原始自旋锁。
  • 内存分配 :在原子上下文中(如原始的硬中断处理程序、定时器回调),使用 GFP_ATOMIC 标志分配内存。
  • 使用RT专属API :例如,使用 rt_mutex 代替 mutex 以获得RT-aware的优先级继承特性,防止优先级反转。

6. 系统性诊断流程:当抢占问题发生时

当系统在开启抢占后出现不稳定,可以遵循以下流程进行诊断:

  1. 确认配置与现象 :通过 /proc/config.gz 确认抢占模型。详细记录故障现象(复位、死锁、数据错误)、频率、触发条件(负载、特定操作)。
  2. 启用内核调试选项 :重新配置内核,打开以下选项(会增加开销,仅用于调试):
    • CONFIG_DEBUG_PREEMPT : 跟踪抢占事件。
    • CONFIG_DEBUG_SPINLOCK : 检测自旋锁的滥用。
    • CONFIG_DEBUG_MUTEXES : 检测互斥锁的死锁。
    • CONFIG_LOCKDEP : 锁依赖关系检测,能发现潜在的锁顺序死锁。
    • CONFIG_DEBUG_ATOMIC_SLEEP : 检测在原子上下文中非法的睡眠操作(对RT调试至关重要)。
  3. 使用Ftrace进行动态追踪 :
    • 跟踪调度事件 : echo 1 > /sys/kernel/debug/tracing/events/sched/enable 。
    • 跟踪抢占事件 : echo 1 > /sys/kernel/debug/tracing/events/preempt/enable 。
    • 跟踪中断事件 : echo 1 > /sys/kernel/debug/tracing/events/irq/enable 。
    • 设置触发器,在发生特定事件(如 sched_switch )时抓取堆栈: echo ‘stacktrace if prev_comm == “your_app”‘ > /sys/kernel/debug/tracing/events/sched/sched_switch/trigger 。
  4. 使用Lockdep分析报告 :如果内核在死锁时挂起,重启后查看 dmesg ,Lockdep会输出详细的锁依赖图和可能的死锁链。
  5. 压力测试与复现 :使用 stress-ng 工具制造CPU、IO、内存压力,同时运行你的核心业务逻辑,尝试复现问题。结合 cyclictest 监控实时延迟,观察故障发生时是否伴随延迟尖峰。
  6. 代码审查 :聚焦于:
    • 所有共享的全局变量和静态变量。
    • 驱动中的状态标志位。
    • 中断处理程序与进程上下文之间的数据共享。
    • 任何没有显式锁保护的 if-check-then-act 模式代码。

7. 最佳实践与工程建议

  1. 假设内核总是可抢占的 :即使你当前使用 PREEMPT_NONE ,也要以 PREEMPT 或 PREEMPT_RT 的标准来编写代码。这能保证代码的健壮性和可移植性。
  2. 优先使用内核提供的同步原语 :如 mutex , semaphore , completion , atomic 变量。避免自己用整数标志位实现简陋的同步。
  3. 明确区分进程上下文和中断上下文 :为在不同上下文中共享的数据设计清晰的访问接口,通常使用自旋锁( spin_lock_irqsave / spin_unlock_irqrestore )来保护。
  4. 慎用 preempt_disable() :仅在操作每CPU变量或执行必须原子化的极短序列时使用,并立即启用。
  5. 为关键任务设置正确的调度策略 :对于真正的实时任务,使用 SCHED_FIFO 或 SCHED_RR 策略,并赋予合理的优先级。避免让非实时任务占用过高优先级。
  6. 测试策略 :在项目早期就应在多种抢占配置下进行测试。将 CONFIG_PREEMPT 和 CONFIG_PREEMPT_RT 的构建纳入持续集成(CI)测试套件。
  7. 设备树(Device Tree)的考量 :虽然设备树主要描述硬件,但在抢占/RT场景下,需要注意中断的亲和性(affinity)设置。可以将高实时性要求的中断绑定到特定的CPU核,避免与其他负载相互干扰。例如,在设备树中为某个设备节点添加 interrupt-affinity 属性(具体支持情况取决于平台)。
  8. 复位(Reset)电路的启示 :文初提到的“复位”问题,其软件根因可能是抢占导致的竞争。这提醒我们,硬件看门狗是最后的防线,但软件稳定性不能依赖它。稳定的系统应该通过正确的并发控制,避免走到触发看门狗的那一步。

8. 总结

Linux内核的抢占机制,从 PREEMPT_NONE 到 PREEMPT 再到 PREEMPT_RT ,是一把不断锋利的双刃剑。它赋予系统更优的响应性和实时性,同时也要求开发者具备更严谨的并发编程素养。ER2026案例中的“幽灵复位”绝非特例,它是将并发缺陷从“可能”变为“必然”的催化剂。

作为开发者,我们的思维必须从“顺序世界”切换到“并发世界”。任何共享的状态,都必须通过锁或原子操作进行保护。在嵌入式Linux开发中,特别是在向高实时性演进时,将抢占安全性作为代码审查和测试的核心维度,是避免未来深夜调试和现场故障的至关重要的一步。建议你将本文提及的诊断工具和代码实践纳入你的开发流程,防患于未然。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐