在 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() 在内核中的主干路径如下图所示:

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

图 2:GPIO 中断打断进程 A,处理完再回到 A
在 RK3399 上,从按下按键到执行 gpio_keys_gpio_isr(),再回到进程 A,主干路径如下图所示:

图 3:RK3399 上 GPIO 按键中断从产生到返回的过程
这次中断依次经过四个阶段:
- 硬件通知:gpio-keys 为按键申请中断时选择了上升沿和下降沿触发,按下和松开都会产生中断。GPIO 控制器检测到电平变化,通过中断控制器 GIC 把中断送给某个 CPU。
- 进入中断:CPU 暂停正在执行的代码,屏蔽中断,跳到中断入口代码。入口代码把进程 A 的寄存器保存到 A 的内核栈上,这一步与系统调用的入口相同。
- 分发到处理函数:内核根据中断号找到这个中断对应的处理函数,也就是 gpio-keys 注册的
gpio_keys_gpio_isr(),并调用它。
- 返回:处理函数返回后,出口代码恢复 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,依次经历登记、检查条件、摘下和保存现场:
- 登记:
init_wait_entry() 准备一个等待项,把 current(也就是 evtest)记在等待项里。prepare_to_wait_event() 把等待项挂到 client->wait 上,再把 current 的状态设为 TASK_INTERRUPTIBLE,表示它要睡眠了。
- 检查条件:登记完再检查一次条件。事件已经到了就直接退出循环,否则调用
schedule()。
- 摘下:
schedule() 看到 current 已经不是运行状态,就把它从这颗 CPU 的运行队列中摘下,再选出下一个要运行的进程。
- 保存现场:切换进程时,内核把 evtest 此刻的栈指针和寄存器保存进它的
task_struct,换上下一个进程保存的值,current 也随之换成下一个进程。evtest 就停在了 schedule() 里。
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。
整个过程如下图所示:

图 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 和两块栈之间的关系如下图所示:

图 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,在中断里睡眠,睡下去的是被打断的无关进程,中断处理也没法再恢复。