找回密码
立即注册
搜索
发回帖 发新帖

5486

积分

0

好友

704

主题
发表于 半小时前 | 查看: 5| 回复: 0

在 Linux 驱动与 BSP 岗位的面试中,进程上下文和中断上下文几乎是必考的概念。问起两者的区别,不少同学都能脱口而出"中断上下文中不能睡眠",但真要解释清楚背后的原理,可能就说不透彻了。

那么,中断上下文中为什么不能睡眠呢?

这个问题牵涉进程上下文与中断上下文的差异,也涉及进程睡眠和唤醒的整个过程。下面我们以 RK3399 开发板上的 GPIO 按键驱动为例,把这两条执行路径彻底梳理一遍。

1. 什么是上下文?

平时读文章,我们总说要"联系上下文",因为同一句话放在不同的前后文里,意思可能完全不同。

内核代码也是如此。只看一个函数本身,往往判断不了它能做什么,还得看它是在什么情况下运行起来的。

内核里说的上下文,指的是一段代码运行时所处的环境,包括它因为什么开始执行、替谁在执行,以及执行完回到哪里。

以课程中常用的 RK3399 开发板为例,板子上的 GPIO 按键由内核中的 gpio-keys 驱动管理。这个驱动很常见,很多开发板上用 GPIO 接的按键都由它来管。

gpio-keys 驱动为按键申请 GPIO 中断,并把它注册成一个输入设备。input 子系统中的 evdev 再为这个设备创建 /dev/input/eventX 节点。

用户程序读这个节点就能拿到按键事件,调试按键常用的 evtest 工具做的就是这件事。

从 evtest 开始读取到按下按键,内核里涉及两段代码:

  • evdev_read():evdev 为 /dev/input/eventX 提供的 .read 回调,evtest 调用 read() 时执行。
  • gpio_keys_gpio_isr():gpio-keys 驱动注册的中断处理函数,按键产生 GPIO 中断时执行。

evdev_read() 运行在进程上下文中,gpio_keys_gpio_isr() 运行在中断上下文中,这正是本文要讲清的两种上下文。

两段代码都在内核里执行,一段由 evtest 调用 read() 带进来,另一段由硬件中断触发,进入内核的路径完全不同。下面我们分别沿着这两条路径,把两种上下文讲清楚,再回到开头的问题。

2. 进程上下文

进程上下文,指的是内核代码替某个进程执行时所处的环境:代码因为这个进程的请求开始执行,替这个进程做事,执行完回到这个进程。

进程发起系统调用是最常见的情形。用户程序调用 read()、write()、ioctl() 时,内核中对应的驱动函数就运行在发起调用的那个进程的上下文中。

我们例子中的 evdev_read() 正是这种情况。evdev 给 eventX 节点注册的文件操作是 evdev_fops,evtest 读这个节点时,内核调用的就是其中的 .read:

/* drivers/input/evdev.c */

static const struct file_operations evdev_fops = {
    .owner   = THIS_MODULE,
    .read    = evdev_read,
    .poll    = evdev_poll,
    .open    = evdev_open,
    ...
};

2.1 系统调用的执行过程

evtest 读取按键事件时,调用 C 库的 read()。C 库执行 svc 指令,从用户态进入内核。

在 arm64 上,这一次 read() 在内核中的主干路径如下图所示:

evtest 调用 read 进入 evdev_read 的流程

图 1:evtest 调用 read() 进入 evdev_read() 的过程

这次系统调用依次经过"进入内核、找到驱动、交还数据、返回用户态"四个阶段:

  1. 进入内核:CPU 切换到内核态,开始使用 evtest 自己的内核栈。入口代码把 evtest 在用户态的寄存器保存在这块栈上,内核同时记下当前进程是 evtest。
  2. 找到驱动:内核根据系统调用号找到 read 的处理函数,再由文件描述符找到 evtest 打开的文件,调用这个文件的 .read,也就是 evdev_read()。
  3. 交还数据:evdev_read() 取出按键事件,用 copy_to_user() 写进 evtest 传入的缓冲区。如果缓冲区里还没有事件,它会先让 evtest 等待,这个过程放到第 5 节再讲。
  4. 返回用户态:出口代码恢复 evtest 的寄存器,回到用户态,read() 返回读到的字节数。

这个过程中出现的 task_struct,是内核为每个进程建立的结构体,记录着进程的状态、内核栈的位置,以及进程被切换出去时保存的寄存器。

调度器能够暂停一个进程、以后再恢复它,靠的就是这个结构体。

内核代码里用 current 表示当前进程,它指向当前进程的 task_struct。evdev_read() 执行期间,current 指向的就是 evtest。

2.2 进程上下文的特征

回到第 1 节对上下文的定义,从三个方面来看 evdev_read() 运行时的环境:

  • 因为什么开始执行:evtest 调用 read(),执行 svc 发起系统调用,内核沿着系统调用路径才调到 evdev_read()。
  • 替谁在执行:执行期间 current 就是 evtest,evdev_read() 读的是 evtest 打开的节点,事件也写进 evtest 的缓冲区。
  • 执行完回到哪里:evdev_read() 返回后,系统调用随之返回,CPU 回到 evtest 的用户态接着执行。

三个方面都落在 evtest 这个进程身上,所以说 evdev_read() 运行在进程上下文中。

用 current 来概括:进程上下文中的内核代码在替 current 执行,用的是 current 的内核栈,执行完把结果交还给 current。

内核线程同样属于进程上下文。它没有用户态部分,也不经过系统调用,被调度器选中后直接在内核中运行,current 同样指向它自己。比如 workqueue 的工作函数,就由名为 kworker 的内核线程执行。

3. 中断上下文

中断上下文是内核处理硬件中断时所处的环境:代码因为硬件中断开始执行,不替任何进程做事,执行完回到被打断的地方。

硬件中断随时可能到来,CPU 当时正在运行的进程和这次中断没有任何关系,内核处理中断时也不会切换到别的进程。驱动注册的中断处理函数,就运行在中断上下文中。

我们例子中的 gpio_keys_gpio_isr() 就属于这种情况。

gpio-keys 初始化按键时为它申请 GPIO 中断,把 gpio_keys_gpio_isr() 注册为中断处理函数,触发方式为上升沿和下降沿:

/* drivers/input/keyboard/gpio_keys.c,gpio_keys_setup_key() 节选 */
isr = gpio_keys_gpio_isr;
irqflags = IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING;
...

error = devm_request_any_context_irq(dev, bdata->irq, isr, irqflags,
                                     desc, bdata);

3.1 硬件中断的处理过程

按下按键时,GPIO 引脚上的电平发生变化,产生硬件中断。硬件中断不会等 CPU 空闲下来再处理。

CPU 这时也许正在运行某个用户程序,也许正在替某个进程执行系统调用,也许正空闲着。不管它在做什么,只要没有屏蔽中断,都会暂停当前的执行,转去处理这个中断,处理完再回到刚才被打断的地方。

假设中断到来时 CPU 正在运行进程 A,整个过程如下图所示:

GPIO 中断打断进程 A 处理完后再回到 A

图 2:GPIO 中断打断进程 A,处理完再回到 A

在 RK3399 上,从按下按键到执行 gpio_keys_gpio_isr(),再回到进程 A,主干路径如下图所示:

RK3399 GPIO 按键中断从产生到返回流程

图 3:RK3399 上 GPIO 按键中断从产生到返回的过程

这次中断依次经过四个阶段:

  1. 硬件通知:gpio-keys 为按键申请中断时选择了上升沿和下降沿触发,按下和松开都会产生中断。GPIO 控制器检测到电平变化,通过中断控制器 GIC 把中断送给某个 CPU。
  2. 进入中断:CPU 暂停正在执行的代码,屏蔽中断,跳到中断入口代码。入口代码把进程 A 的寄存器保存到 A 的内核栈上,这一步与系统调用的入口相同。
  3. 分发到处理函数:内核根据中断号找到这个中断对应的处理函数,也就是 gpio-keys 注册的 gpio_keys_gpio_isr(),并调用它。
  4. 返回:处理函数返回后,出口代码恢复 A 的寄存器,A 从被打断的地方继续执行。

3.2 硬中断计数、中断栈与 current

和系统调用路径相比,这条路径多做了两件事。

第一是标记正在处理硬中断。进入中断时,内核把 preempt_count 中的硬中断计数加一,退出时再减掉。只要这个计数不为 0,内核就知道当前代码运行在硬中断里。

第二则是切换到中断栈,中断处理函数运行在每颗 CPU 各自的一块中断栈上,不使用任何进程的内核栈。

这条路径里还有一样东西始终没有变:整个过程中内核没有切换进程,所以 current 一直指向被打断的进程 A。

A 和这次按键没有任何关系。按键发生时,evtest 正在等待事件,一般并不在 CPU 上运行,被打断的是当时恰好在运行的进程。

3.3 中断上下文的特征

同样按照前面的标准,从三个方面来看 gpio_keys_gpio_isr():

  • 因为什么开始执行:按键产生 GPIO 中断,打断了 CPU 正在做的事,没有哪个进程发起过调用它的请求。
  • 替谁在执行:不替任何进程执行。current 虽然指向 A,但 A 和按键无关,gpio_keys_gpio_isr() 也没有替 A 做任何事;等待按键事件的 evtest 这时也不在 CPU 上运行。
  • 执行完回到哪里:中断返回,回到 A 被打断的地方,A 接着执行自己的代码。

三个方面都指向这次硬件中断,对应不到任何一个进程,所以说 gpio_keys_gpio_isr() 运行在中断上下文中。

用 current 和硬中断计数来说:中断处理函数不替任何进程执行,current 只是恰好被打断的进程,内核靠硬中断计数知道当前代码运行在中断上下文中。

中断处理函数执行期间,这颗 CPU 的中断一直是屏蔽的,其他中断要等它返回以后才能得到处理,所以中断处理函数必须尽快返回。

4. 进程上下文与中断上下文的对比

把 evdev_read() 和 gpio_keys_gpio_isr() 放在一起比较,区别如下表所示:

对比项 进程上下文:evdev_read() 中断上下文:gpio_keys_gpio_isr()
怎么进入内核 evtest 执行 svc 发起系统调用 GPIO 中断打断 CPU 当前的执行
current 指向谁 evtest 被打断的进程,和按键无关
替谁做事 evtest,事件写进它的缓冲区 不替任何进程
使用哪块栈 evtest 的内核栈 本 CPU 的中断栈
硬中断计数 为 0 不为 0
执行完回到哪里 系统调用返回,回到 evtest 的用户态 中断返回,回到被打断的地方

两段代码都运行在内核态,所以仅凭用户态和内核态无法区分它们。

区分进程上下文和中断上下文,要看代码是如何进入内核的,以及 current 是不是它服务的那个进程。

进程上下文中,current 就是这段代码服务的进程,它有自己的内核栈和 task_struct。

中断上下文中,current 只是恰好被打断的进程,中断处理函数占用了它的 CPU 时间,却不替它做任何事。

从时间上看,中断处理插在被打断进程的执行中间,很容易让人误以为它是这个进程的一部分。

但它不替这个进程做事,也不在这个进程的内核栈上运行,处理完就回到被打断的地方。进程上下文和中断上下文是两种不同的上下文,中断只是临时借用了被打断进程的 CPU。

表中 current 指向谁和使用哪块栈这两行,正是两种上下文差别最大的地方。

理解了这两点,再看"中断上下文中为什么不能睡眠"就比较容易了。

5. 为什么中断上下文中不能睡眠

5.1 进程睡眠与唤醒的过程

要回答这个问题,先得看清楚进程睡眠时内核做了什么。

前面讲到,evtest 调用 read() 时如果缓冲区里还没有事件,evdev_read() 会让它先等待。这一步调用的是 wait_event_interruptible():

/* drivers/input/evdev.c,evdev_read() */
error = wait_event_interruptible(client->wait,
                client->packet_head != client->tail ||
                !evdev->exist || client->revoked);

client->wait 是 evdev 在 evtest 打开节点时为它建立的等待队列,后面的表达式是等待条件:缓冲区里有了事件,或者设备被移除,条件就成立。

把这个宏展开,去掉处理信号的分支,剩下的主干是下面这个循环:

/* include/linux/wait.h,___wait_event() 的主干 */
struct wait_queue_entry __wq_entry;

init_wait_entry(&__wq_entry, 0);    /* __wq_entry.private = current */
for (;;) {
    prepare_to_wait_event(&client->wait, &__wq_entry, TASK_INTERRUPTIBLE);
    if (condition)
        break;
    schedule();
}
finish_wait(&client->wait, &__wq_entry);

evtest 从开始等待到让出 CPU,依次经历登记、检查条件、摘下和保存现场:

  1. 登记:init_wait_entry() 准备一个等待项,把 current(也就是 evtest)记在等待项里。prepare_to_wait_event() 把等待项挂到 client->wait 上,再把 current 的状态设为 TASK_INTERRUPTIBLE,表示它要睡眠了。
  2. 检查条件:登记完再检查一次条件。事件已经到了就直接退出循环,否则调用 schedule()。
  3. 摘下:schedule() 看到 current 已经不是运行状态,就把它从这颗 CPU 的运行队列中摘下,再选出下一个要运行的进程。
  4. 保存现场:切换进程时,内核把 evtest 此刻的栈指针和寄存器保存进它的 task_struct,换上下一个进程保存的值,current 也随之换成下一个进程。evtest 就停在了 schedule() 里。

evtest 停下以后,等待队列、它的内核栈和 task_struct 之间的关系如下图所示:

evtest 睡眠时等待队列、内核栈与 task_struct 关系

图 4:evtest 睡眠时等待队列、内核栈和 task_struct 的关系

唤醒的过程正好反过来。按下按键以后,gpio-keys 先处理按键中断,再读出按键电平。

按键事件经 input 子系统交给 evdev,evdev 把它写进 evtest 的缓冲区,再调用 wake_up() 系列函数唤醒 client->wait 上的等待者。

唤醒函数从等待项里取出 evtest,把它的状态改回 TASK_RUNNING,放回运行队列。

调度器以后再次选中 evtest 时,恢复它保存的栈指针和寄存器,evtest 从 schedule() 返回,再检查一次条件。这一次缓冲区里有了事件,循环退出,evdev_read() 接着把事件拷贝给 evtest。

整个过程如下图所示:

evtest 睡眠与唤醒的时序

图 5:evtest 睡眠和被唤醒的过程

挂到等待队列上的、被设成睡眠状态的、被摘下又放回运行队列的,都是等待项里记下的那个 current,停下时的现场也保存在它自己的 task_struct 和内核栈上。

能睡眠的前提,是存在一个可以停下来的进程:它的现场保存在自己的 task_struct 里,以后调度器从这里把它恢复。

在 evdev_read() 里这么做没有问题,因为 current 就是 evtest,它本来就在等按键事件。

5.2 中断处理函数没有自己的 task_struct

5.1 里的睡眠之所以能成立,是因为 evtest 是一个进程,有自己的 task_struct 和内核栈。

中断处理函数不是进程,它没有自己的 task_struct。第 3 节讲过,它只是借用了被打断进程的 current,运行在本 CPU 共用的中断栈上。

中断处理函数没有一个可以睡下去、再被唤醒的"自己",这就是中断上下文中不能睡眠的根本原因。

用假设来验证一下。假设在 gpio_keys_gpio_isr() 里直接调用 msleep(100):

/* 假设的错误写法,gpio-keys 实际没有这样写 */
static irqreturn_t gpio_keys_gpio_isr(int irq, void *dev_id)
{
    struct gpio_button_data *bdata = dev_id;

    msleep(100);                          /* 在中断处理函数里睡眠 */
    gpio_keys_gpio_report_event(bdata);   /* 读电平并上报事件 */
    input_sync(bdata->input);

    return IRQ_HANDLED;
}

msleep() 和 5.1 里的等待一样,先把 current 的状态设为睡眠,再调用 schedule()。此时 CPU 上运行的是被打断的进程 A,current 指向 A,代码运行在中断栈上。接下来会这样执行:

  • 睡下去的是 A:被设成睡眠状态、从运行队列中摘下的是 A。A 和这次按键没有关系,也没有要等的东西,却要白白睡 100 毫秒。
  • 内核报错但照样切走:schedule() 发现硬中断计数不为 0,打印 BUG: scheduling while atomic,然后照常把 A 切走,把此刻的栈指针存进 A 的 task_struct。这个栈指针指向的是中断栈。
  • 中断处理恢复不了:gpio_keys_gpio_isr() 停在 msleep() 里,后面的事件上报和中断返回都没有执行。下一次中断来了,又从中断栈的栈顶开始使用,停在那里的现场就被覆盖了。100 毫秒后 A 被唤醒,回到的是一块已经被改写的栈,接下来的执行结果无法预料。

此时进程 A 的 task_struct 和两块栈之间的关系如下图所示:

中断处理函数里睡眠时进程 A 与中断栈状态

图 6:中断处理函数里睡眠时进程 A 和中断栈的状态

内核报错时会打印下面这样的日志,这也是开发中比较常见的:

BUG: scheduling while atomic: <进程名>/<pid>/<preempt_count>

日志里的 atomic 指原子上下文,也就是不允许让出 CPU 的状态,中断上下文是其中最常见的一种。

进程名和 pid 取自被切走的 current,也就是被打断的进程 A。排查时不用去查 A 做错了什么,要看日志后面打印的调用栈,栈里能看到 msleep() 和 gpio_keys_gpio_isr()。

中断处理函数没有自己的 task_struct,需要等待的工作也就不能放在中断处理函数里做,要交给 workqueue 或 threaded IRQ。它们运行在内核线程里,有自己的 task_struct,可以正常睡眠。

6. 总结

这篇讲得比较细,最后再总结一下。下面几点就是进程上下文和中断上下文的本质:

  • 进程上下文:内核代码替某个进程执行,current 就是这个进程,代码用的是它的内核栈。
  • 中断上下文:内核代码因为硬件中断执行,不替任何进程做事,current 只是被打断的进程,代码运行在中断栈上。
  • 睡眠:调度器让一个进程停下,把它的现场保存在它自己的 task_struct 里,以后再从这里恢复。
  • 中断里不能睡眠:中断处理函数没有自己的 task_struct,在中断里睡眠,睡下去的是被打断的无关进程,中断处理也没法再恢复。



上一篇:Roblox财报里的AI游戏信号:生成式创作、Build工具与平台分发逻辑
下一篇:Discord客户端弃Electron转Rust:内存省70%的GPUI重构路径解析
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-11-13 14:28 , Processed in 0.078940 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表