跳到主要内容

6. 内核机制 essentials —— 进程与线程

上一章我们折腾完了内核模块的入门,现在你应该已经能在内核里写点简单的代码了。但这只是冰山一角。Linux 内核庞大、复杂且深奥,如果我们想在这里面自由穿梭,光会 printk 是远远不够的。

这一章,我们将真正推开内核内部机制的大门。我们要聊的话题是进程与线程在内核中到底是如何被管理的。为什么这很重要?因为如果你不理解内核眼中的“进程”和“线程”,你就无法理解下一章关于内存管理的讨论,更别说写出高效的驱动程序了。我们会看到那些你在用户空间习以为常的概念——比如栈、堆、甚至“我在运行”这个事实——在内核视角下是如何被完全重构的。

其实,写内核代码和写用户空间代码有一个根本的区别:在用户空间,你是进程的主宰;在内核空间,你是在借来的上下文里干活。搞清楚你在“借”谁的上下文,以及这个上下文有什么限制,是这一章的核心任务。

为了把这件事讲清楚,我把这部分内容拆成了两章。本章专注于进程与线程的架构,下一章我们将深入内存管理内幕。当然,真正的内核开发经验是散落在整本书里的——比如 CPU 调度、同步原语这些——但那些都是后话。

这一章我们要解决的核心问题包括:

  • 内核代码到底是在谁的“地盘”上运行?(进程上下文 vs 中断上下文)
  • 进程的虚拟地址空间(VAS)到底长什么样?
  • 内核如何组织这些进程、线程以及它们的栈?
  • 如何找到并操作内核中那个描述任务的终极结构体 —— task_struct
  • 如何遍历系统中的所有任务?

准备好了?我们先把 VM(虚拟机)准备好,确保你已经按照 Online Chapter 里的步骤配置好了环境。如果没有,赶紧回去搞定——接下来的内容最好是你亲手敲进去验证一遍。


6.1 理解进程与中断上下文

在第 4 章写第一个内核模块时,我们曾简单提过内核架构。现在,是时候把这个话题摊开来讲了。

现在的 CPU 通常都有特权级的概念。比如 x86 有 4 个 Ring(Ring 0 最特权,Ring 3 最普通),ARM-32 有 7 种模式,ARM64 有 Exception Level(EL0-EL3)。但不管是哪个架构,现代操作系统在实际使用时都简化为两级特权级(内核模式)和非特权级(用户模式)。

这一点至关重要。

接下来的事情可能会稍微挑战一下你的直觉:Linux 是一个单内核。这个词的字面意思是“一块巨大的石头”。这意味着什么?意味着当你的进程发起一个系统调用时,并没有一个神秘的“内核进程”跳出来帮你干活。

真相是:那个进程自己切入了内核模式,亲自执行内核代码。

所以,我们说内核代码是在进程上下文中执行的。这不仅仅是术语,这是理解内核行为的基础。绝大多数驱动代码、异常处理(比如缺页中断)、甚至调度器的一部分,都是在这种上下文中跑的。

但你可能会问:除了进程上下文,内核代码还有别的活法吗?

当然有。想象一下,当你正沉浸在敲代码的快乐中时,网卡突然收到了一个数据包。硬件中断信号瞬间触发。CPU 这时候不管在干嘛(哪怕正在执行内核代码),都得立刻停下来,保存当前状态,然后跳转去执行中断处理程序(ISR)。这时候执行的代码,就是在中断上下文中运行的。

这是两种完全不同的世界观:

  1. 进程上下文:由进程主动发起(系统调用或异常),是同步的。本质上还是“我”在干活,只是换了个更高级的权限。
  2. 中断上下文:由硬件异步触发,此时你不是任何进程,你就是中断本身。你不能睡觉,不能阻塞,必须速战速决。

Figure 6.1 展示了这种概念视图:用户空间的线程通过系统调用切入内核;同时,纯内核线程(不需要用户空间的那种)也在默默干活;而中断则是那个不速之客,随时打断一切。

模式 A:SICP 紧绷模式 这里的区别不是学术游戏,是生与死的界限。 在进程上下文里,你可以申请内存,可以等待锁,可以睡眠。 在中断上下文里,如果你敢睡觉,系统立刻崩溃。

为什么? 因为如果你在睡眠时触发了调度器,调度器怎么知道唤醒谁? 你甚至不是一个“进程”。

稍后我们会教你如何判断自己到底在哪个上下文。但先记住这个直觉。


6.2 理解进程虚拟地址空间(VAS)的基础

在深入内核之前,我们需要先回顾一下进程的“家”——虚拟地址空间(VAS)。这里有一个铁律:内存是沙盒化的。进程以为自己独占整个内存空间,往“外”看是不可能的事情。

用户空间的 VAS 被划分成了若干个同质的区域,我们称之为或者更技术性的映射——因为它们本质上是内核通过 mmap() 系统调用拼凑出来的。

Figure 6.2 展示了一个标准的 Linux 用户空间进程 VAS 的最小集合。让我们从低地址到高地址快速过一遍:

Text 段(代码段)

这是存放机器指令的地方。指令指针(IP/PC)就在这里跳舞。它是只读(r-x)的。注意,Text 段并不从 0 地址开始,0 地址附近的那个页面是著名的“空指针陷阱页”,专门用来抓 NULL 指针访问的。

Data 段(数据段)

紧接着 Text 段。存放全局和静态变量(rw-)。实际上它分三块:

  1. Initialized Data:已初始化的全局/静态变量。
  2. Uninitialized Data (BSS):未初始化的全局/静态变量,运行时自动归零。
  3. Heap(堆)malloc() 的老家。但注意,现代 glibc 中,只有小于 128KB(MMAP_THRESHOLD)的请求才从堆里分。大块内存会通过 mmap() 另外开炉灶,这叫匿名映射。堆是动态的,而且它是唯一向高地址增长的段。堆顶的合法地址边界叫 Program Break(可以用 sbrk(0) 查看)。

共享库

所有动态链接的共享库(.so)都被映射在堆和栈之间的某个区域。

Stack(栈)

这是 LIFO(后进先出)的世界。函数调用、参数传递、局部变量全在这里。在现代 CPU(x86, ARM)上,栈是向下增长的,这就是所谓的 Fully Descending Stack

这里有一个很有趣的细节:虽然逻辑上我们说“每次函数调用分配栈帧”,但实际上物理操作并没有那么复杂——栈指针(SP)动一下,栈帧就出现了,return 时弹回去就行。这种设计快得惊人。

关于线程: 一个进程至少有一个线程(即 main() 线程)。如果有多线程(Figure 6.2 中的 thrd2, thrd3),它们共享进程 VAS 里的几乎所有东西——除了栈。每个线程都有自己独立的私有栈。主线程的栈在 VAS 的最顶端,其他线程的栈则在堆和栈之间的“共享区”里乱长。

类比回收:回到那个“沙盒” 我们可以把进程的 VAS 想象成一个四合院(Sandbox)。

  • Text/Data 是地基和承重墙,建好了就不动。
  • Heap 是你在院子里临时搭的棚子,可以向院子中心(高地址)不断扩张。
  • Stack 是你在天花板上挂的篮子,东西越多,篮子越沉,绳子放得越长(向低地址增长)。
  • 线程:如果是四合院里的一家人,大家共享厨房和厕所,但每个人都有自己的**私房钱(栈)**藏在各自的行李箱里。你不能去翻别人的行李箱,否则就乱套了。

这个类比能帮你理解为什么多线程编程里,局部变量是安全的(在各自篮子里),而全局变量需要小心(大家都能看见)。


6.3 进程、线程与栈的组织 —— 用户空间与内核空间

传统的 UNIX 哲学是“一切皆进程”。但在现代内核眼里,线程才是调度的基本单位。Linux 内核不区分进程和线程,线程就是“共享了某些资源的进程”。每一个线程 —— 无论是用户空间的还是内核的 —— 都对应一个内核元数据结构:task_struct。我们后面会详聊这个结构。

这里有一个容易被忽视的关键点:我们需要为每个线程在每个特权级上都准备一个栈。

Linux 有两个特权级:用户模式和内核模式。这意味着,每一个活着的用户空间线程,其实都有两个栈:

  1. 用户空间栈:跑用户代码时用。你 C 语言里写的局部变量都在这。
  2. 内核空间栈:一旦你通过系统调用陷入内核,或者触发异常(比如缺页中断),CPU 切换到内核模式,栈指针(SP)会瞬间指向这个内核栈。所有的内核函数调用、内核里的局部变量,都在这个小小的内核栈上。

唯一的例外是内核线程。它们出生就在内核,眼睛里只有内核空间,所以它们只有内核栈,没有用户栈。

⚠️ 踩坑预警 千万别以为内核栈很大。 32位系统上通常只有 2 页(8KB),64位上只有 4 页(16KB)。 在内核模块里定义一个 int huge_array[4096]? 你可能直接把栈撑爆了,导致难以调试的崩溃。 以后我们在内核里搞大块内存,必须动态分配(kmalloc/vmalloc),绝对不能在栈上贪便宜。

还有一个细节:硬件中断栈(IRQ Stack) 当硬件中断发生时,CPU 并不一定会使用被中断进程的内核栈(那太小了,万一中断处理程序也很贪吃呢?)。很多架构(包括 x86_64)会为每个 CPU 核心准备一个独立的 IRQ 栈,专门用来处理中断。这避免了中断处理程序把无辜进程的内核栈给踩塌了。


6.4 查看用户栈和内核栈

栈是调试时的命根子。如果你想搞清楚“程序是怎么运行到这里的”,或者“为什么它卡在这里不动了”,看栈几乎是唯一的办法。但正如刚才说的,每个线程有两个栈,我们怎么看?

查看内核栈:/proc/PID/stack

好消息:内核把这部分做得非常人性化。只要你有 root 权限,直接读 /proc/PID/stack 就能拿到该进程当前的内核栈回溯。

$ sudo cat /proc/2549/stack
[<0>] do_wait+0x184/0x340
[<0>] kernel_wait4+0xaf/0x150
[<0>] __do_sys_wait4+0x89/0xa0
[<0>] __x64_sys_wait4+0x1e/0x30
[<0>] do_syscall_64+0x5c/0x90
[<0>] entry_SYSCALL_64_after_hwframe+0x63/0xcd

怎么读?

  • 从下往上。最下面是入口(entry_SYSCALL_64...),最上面是当前正在跑的函数(do_wait)。
  • do_wait+0x184/0x340 意思是:当前停在这个函数内部偏移 0x184 字节处,整个函数总长 0x340 字节。
  • [<0>] 原本是代码段的地址,但为了防止黑客通过这些信息泄露内核布局(KASLR),现代内核通常把它置零。

看,我们的 Bash 进程此时正通过 wait4 系统调用在内核里傻等子进程结束呢。

查看用户栈:GDB 的魔法

查看用户栈反而麻烦一点。最通用的办法是用 GDB。为了方便,你可以写个简单的脚本 ch6/ustack,核心就是调用 GDB 的 bt 命令。

sudo gdb \
-ex "set pagination 0" \
-ex "thread apply all bt" \
--batch -p $PID

如果我们把它甩给刚才的 Bash 进程,你会看到类似这样的输出:

Thread 1 (process 2549):
#0 0x00007fadd3109c3a in __GI___wait4 ...
#1 0x0000555b98cd4f03 in ?? ()
#2 0x0000555b98cd6373 in wait_for ()
...

同样,这也是从下往上读。最上面的 #0 就是当前帧。

eBPF:现代魔法

如果你觉得上面那套太“传统”,那我们来看看现代兵器 —— eBPF

利用 BCC (BPF Compiler Collection) 工具集中的 stackcount,我们可以做到更酷的事情:同时追踪内核栈和用户栈

# 举个栗子,我们想看 ping 程序在干嘛
sudo stackcount-bpfcc -d \
`pgrep ping` \
--delimited \
ping_echo_send

这个工具会像发传单一样贴着 Ping 进程,一旦它调用 ping_echo_send,就把那一瞬间内核和用户的调用栈都抓下来打印出来。--delimited 参数会在输出里画一条 -- 分界线,左边是内核,右边是用户。

费曼流动模式 说实话,第一次看到 eBPF 这类工具的输出时,我整个人是“不明觉厉”的。 你不需要现在就掌握 BCC 的所有细节,但你需要建立一种直觉: 现在的内核调试已经不再是单纯盯着 /proc 文件看了,而是可以通过插桩的方式,让内核在关键路径上“自爆”给你看。

这是一种上帝视角。以前我们是猜发生了什么,现在我们是直接盯着它发生。


6.5 内核任务结构:task_struct

好了,现在让我们回到那张大大的全家福图(Figure 6.3/6.5)。内核空间里除了代码和数据,最核心的就是那一个个 task_struct 结构体。

这就是内核眼中的“身份证”。每一个活着的线程(不管是用户的还是内核的),都有一个对应的 task_struct。它存了一切:PID、内存描述符、打开的文件、信号处理、调度信息……甚至包括这个线程刚才是不是经常在睡眠。

这个结构体定义在 include/linux/sched.h 里。它大到离谱(在 6.1 内核上 x86_64 是 13KB+)。

SICP 紧绷模式 这不仅仅是一个数据结构。 这是操作系统对现实世界的一个抽象。 当我们在代码里写下 current->pid 时,我们其实是在向内核询问: “现在正在 CPU 上运行的那个灵魂是谁?”


6.6 通过 current 宏访问任务结构

既然有几百几千个 task_struct 挂在内核的链表上,当我的内核代码正在运行时,我怎么知道“我是谁”?

内核开发这帮人很聪明,他们搞出了一个宏:current

#include <linux/sched.h>

current->pid; // 拿到当前进程(线程)的 PID
current->comm; // 拿到当前进程的名字(去掉路径的)

你可以把 current 想象成 C++ 里的 this 指针,但它是指向当前正在执行内核代码的那个线程的 task_struct

它的实现非常依赖于架构:

  • x86_64:利用了 per-CPU 变量,速度极快,不需要锁。
  • ARM64 / PowerPC:甚至专门腾出了一个通用寄存器来存这玩意儿,查起来就是一条指令的事。

判定上下文:我是谁?我在哪?

我们之前反复强调不能在原子上下文里睡觉。那怎么知道自己是不是在原子上下文?内核提供了一个极其好用的宏:

#include <linux/preempt.h>

if (in_task()) {
// 这是进程上下文,通常可以睡觉
foo();
} else {
// 这是中断上下文(或持有自旋锁),绝对不能睡!
bar();
}

⚠️ 踩坑预警 in_task() 返回 true 并不代表你一定可以睡! 如果你在进程上下文里拿着一个自旋锁,你还是原子上下文的一部分。 这时候调用 msleep()?那你就是在等死。

记住一个简单的原则:拿锁的时候别睡,中断里别睡。

实战:current_affairs 模块

写个模块试试水。我们在模块初始化(init)和退出(cleanup)的时候,打印一下当前的上下文信息。

/* 代码节选自 ch6/current_affairs/current_affairs.c */
static void show_ctx(char *nm)
{
// ... 头文件包含 ...
if (likely(in_task())) {
pr_info("Running in process context ::\n"
" name : %s\n"
" PID : %6d\n"
" TGID : %6d\n"
" UID : %6u\n"
" EUID : %6u (%s root)\n"
// ... 打印指针地址 ...
current->comm,
task_pid_nr(current), // 推荐使用 helper 宏
task_tgid_nr(current),
// ...
);
} else {
pr_alert("Whoa! running in interrupt context! Should NOT happen here\n");
}
}

这里用到了一个常见的微优化宏 likely()。它告诉编译器:“这条件十有八九是真的,你把汇编代码优化的往那边靠”。这在内核里随处可见。

当你 insmod 这个模块时,猜猜 current 指向谁?

insmod 这个进程自己!

回扣:Linux 是单内核 还记得我们在章节引子里说的吗?“你是借来的上下文”。 当你在终端敲 insmod module.ko 时,insmod 进程发起系统调用,内核切入特权模式。 并没有什么“内核守护进程”来接管你的模块。 是你自己(那个 insmod 进程)在内核模式下执行了模块的 init 函数。

这就是单内核的本质。 (微内核才是那种发消息给“服务器进程”去干的模式。)


6.7 遍历内核的任务列表

既然所有的 task_struct 都串在一个巨大的双向循环链表上,那我们能不能像翻花名册一样一个个看过去?当然可以。

内核提供了一个宏 for_each_process()

#include <linux/sched/signal.h>

struct task_struct *p;
for_each_process(p) {
// p 指向每一个进程的 task_struct
// 注意:它只遍历每个进程的主线程
}

但这有个坑:for_each_process 严格来说只遍历每一个进程组的“头儿”(主线程)。如果你想找出系统里所有的线程(包括多线程进程里的那些小弟),你需要用更强力的宏:

  • 老内核 (< 6.6)do_each_thread(p, t) { ... } while_each_thread(p, t);
  • 新内核 (>= 6.6)for_each_process_thread(p, t);

我们写了一个演示模块 ch6/foreach/thrd_showall。当你把它插进去并在 dmesg 里看输出时,你会看到密密麻麻的任务列表。

Figure 6.12 的输出里有一列很有意思:TGID vs PID

  • PID:在内核眼里,这就是线程 ID。每个线程都不一样。
  • TGID:线程组 ID。也就是我们在用户空间说的“进程 ID”。

如果一个进程是单线程的,PID == TGID。如果是多线程,主线程 PID == TGID,而其他线程 PID 各异,但 TGID 都等于主线程的 PID。

你可以用 ps -LA 命令验证这一点:

  • 第一列 PID 其实是 TGID。
  • 第二列 LWP (Light-Weight Process) 才是内核里的 PID。

本章小结

这一章我们构建了一幅完整的地图:从 CPU 的特权级,到进程的虚拟地址空间(堆、栈、代码段),再到内核如何用 task_struct 和双向链表管理成百上千个线程,以及我们如何通过 current 宏在这张巨大的网中定位自己。

特别是进程上下文与中断上下文的区别,以及**“用户空间进程自己执行内核代码”** 这一单内核特性,是你理解后续所有驱动模型的基础。如果不理解这些,当你写出“在持有自旋锁时调用 copy_from_user”这种代码时,你就只能对着系统崩溃抓瞎了。

下一章,我们将深入这块内存的另一个维度 —— 内存管理。到时候你会发现,今天讲的这些结构(task_struct, mm_struct)是如何真正挂载到物理内存页上的。

这是一场漫长的旅程,但现在的你已经有了地图。


练习题

练习 1:understanding

题目:在 Linux 内核编程中,为什么中断上下文绝对禁止调用可能睡眠的函数(如 kmalloc(GFP_KERNEL)down())?这背后与进程调度有什么关系?

答案与解析

答案:因为中断上下文不关联任何特定的进程描述符,没有“重入”的进程可供调度器切换;如果在中断中睡眠,调度器无法找到正确的进程来恢复执行,导致系统死锁或崩溃。

解析:考察对 Process Context 和 Interrupt Context 本质区别的理解。

  1. 进程上下文:由系统调用或异常触发,依附于特定的进程(current 指针有效)。如果睡眠,调度器可以挂起当前进程,稍后唤醒恢复。
  2. 中断上下文:由硬件异步触发,不是任何特定进程的一部分。中断处理程序打断了正在运行的进程(不管是在用户态还是内核态)。

如果中断处理程序试图睡眠,内核调度器会尝试将 CPU 切换到另一个进程。但由于中断并非由“进程”发起,调度器无法将 CPU 返回到中断发生时的状态(因为没有保存足够的“进程上下文”信息来允许这种重入)。因此,中断处理程序必须快速执行完毕,不能阻塞或睡眠。

练习 2:application

题目:在一个运行中的 Linux 系统上,您编写了一个内核模块。在某个函数中,您需要判断当前代码是否处于“原子上下文”中。您会使用哪个内核宏来实现这一判断?请写出该宏的名称并简述其返回值为真时的两种典型场景。

答案与解析

答案:使用 in_atomic() 宏。

两种典型场景:

  1. 硬件中断处理程序或软中断中执行时。
  2. 持有自旋锁的进程上下文中。

解析:考察对内核上下文检测工具的实际应用。

  • in_atomic():这个宏用于检测代码是否处于原子上下文,即是否允许发生进程调度。
  • 场景 1 (Interrupt Context):当处理硬件中断时,显然不能睡眠,此时 in_atomic() 返回真。
  • 场景 2 (Spinlock):在进程上下文中,如果代码已经持有了自旋锁,它也进入了原子临界区。虽然属于进程上下文,但在持有自旋锁期间也不能睡眠(否则可能导致死锁),因此 in_atomic() 同样返回真。

注意:in_interrupt() 也可以用于检测中断上下文,但 in_atomic() 覆盖面更广,包括了持有自旋锁的情况。

练习 3:application

题目:假设您正在编写一个 64 位系统的内核驱动程序。您在驱动中定义了一个局部数组 char buf[4096]。虽然编译通过,但系统在高负载下偶尔会发生崩溃(Kernel Panic)。根据关于“内核栈大小”的知识,分析最可能的崩溃原因是什么?为什么在用户空间编程中通常不需要担心这个问题?

答案与解析

答案:崩溃原因是内核栈溢出

内核栈非常小(64位系统通常仅 16KB),且是固定的。函数调用链每深一层、局部变量每大一点,都在消耗这有限的栈空间。如果调用链较深,4096 字节的局部数组很容易占掉 1/4 到 1/2 的栈空间,导致栈溢出,覆盖邻近的 thread_info 或其他关键数据,引发 Panic。

而在用户空间,栈空间是动态分配的,通常默认限制为 8MB 左右,且可以通过 ulimit 调整,空间非常充裕,极少发生局部数组导致的栈溢出。

解析:考察对内核栈与用户栈区别的实际工程认知。

  1. 内核栈限制:每个线程的内核栈通常只有 2 页(32位)或 4 页(64位),即 8KB 或 16KB。这需要容纳整个内核调用路径上的所有函数栈帧。
  2. 风险:在内核中定义大的局部变量是非常危险的。系统高负载时,中断处理、驱动嵌套调用可能较深,瞬间耗尽栈空间。
  3. 用户栈对比:用户空间的栈位于 VAS 的 Stack Segment,不仅空间大,而且还可以动态增长。Linux 内核不仅提供空间,还会在栈接近上限时通过 guard page 机制进行扩展处理,除非递归失控,一般不易溢出。
  4. 最佳实践:内核中需要大块内存时,应使用 kmalloc() 或在堆上分配,而非使用局部数组。

练习 4:thinking

题目:思考题:Linux 内核为了安全性引入了“用户空间影子栈”来防御 ROP(返回导向编程)攻击。然而,该特性(在提到的章节中)明确指出是针对“用户空间”的。请分析:为什么内核通常不需要同样的硬件级影子栈来保护自己的返回地址?或者更深层地,如果内核代码被攻击利用,这种硬件保护机制是否依然有效?

答案与解析

答案:内核通常不需要独立的硬件影子栈,且硬件保护机制在内核最高权限下往往失效。

分析理由:

  1. Ring 0 权限:内核运行在最高特权级。如果攻击者已经能够控制内核执行流(劫持返回地址),这意味着攻击者已经获得了 Ring 0 的权限。拥有最高权限的攻击者可以关闭影子栈功能(如修改 CR4 寄存器),或者直接读写控制影子栈的内存模型寄存器(MSR),从而绕过保护。
  2. 性能与开销:内核对性能极其敏感,开启影子栈会带来明显的上下文切换开销和中断处理延迟。
  3. 现有机制:内核主要依靠严格的代码审查、利用 __builtin_return_address 编译时检查(如 STACKPROTECTOR)、以及内存分区来缓解栈溢出问题,而不是依赖运行时硬件对返回地址的锁定。

解析:这是一道综合思考题,涉及安全机制与权限模型。

  • 影子栈的原理:它将返回地址单独存放在一个受保护的只读内存区域。当函数返回时,CPU 比较普通栈上的返回地址和影子栈中的地址,如果不匹配则触发异常。
  • 用户空间适用性:用户程序运行在低权限,无法篡改影子栈的配置或内存,因此这种硬件强制保护非常有效。
  • 内核空间的悖论:安全机制的基石是“高权限保护低权限”。但如果攻击者攻破了内核,就拥有了最高权限。谁能监督拥有最高权限的人?
    • 如果攻击者能覆盖内核栈上的返回地址,说明已经存在内核写漏洞。攻击者完全可以在覆盖返回地址的同时,构建 ROP 链去执行关闭影子栈保护的指令。
    • 因此,内核安全更侧重于防止内存被非预期写入(如利用 SMEP/SMAP 防止内核执行用户空间代码),而不是单纯保护返回地址不被修改。

要点提炼

Linux 采用单内核架构,这意味着系统调用或异常发生时,并不是由某个神秘的“内核进程”接管 CPU,而是当前进程主动切入特权级(内核模式),借用自己的上下文来执行内核代码。理解这一点至关重要,因为内核代码几乎总是运行在“借来的上下文”中,且必须时刻警惕当前所处的执行环境(进程上下文或中断上下文),因为后者严格禁止睡眠或阻塞操作。

内核通过一个巨大的双向链表和核心结构体 task_struct 来管理所有的调度实体。在 Linux 内核眼中,线程才是调度的基本单位,而所谓的“进程”实质上是共享特定资源(如内存地址空间)的一组线程。无论是用户空间的线程还是纯内核线程,在内核中都由一个独立的 task_struct 描述,这意味着内核并不特别区分进程和线程,只看作为调度实体的个体。

由于特权级的划分,每一个活跃的用户空间线程实际上都拥有两个栈:一个用于运行用户代码的用户栈(通过 malloc 等动态分配),和一个在陷入内核时使用的内核栈(通常很小,x86_64 上仅 16KB)。在编写内核模块时,开发者必须时刻警惕内核栈的尺寸限制,严禁在栈上分配大数组,否则极易导致栈溢出和系统崩溃,大块内存必须通过动态分配获取。

进程的虚拟地址空间(VAS)被严格沙盒化,分为代码段、数据段、堆和栈等区域。堆向高地址增长,而栈(Fully Descending Stack)向低地址增长。对于多线程程序,所有线程共享进程的 VAS(代码段、全局变量等),但每个线程都拥有自己独立的用户栈区域,这正是多线程编程中局部变量安全而全局变量需要同步保护的根源所在。

在内核代码中定位“我是谁”是通过宏 current 实现的,它指向当前正在 CPU 上运行的任务的 task_struct。开发者可以使用 in_task() 等辅助宏判断当前处于进程上下文还是中断上下文:在进程上下文中通常可以申请资源或短暂睡眠,但在中断上下文或持有自旋锁时,任何可能导致阻塞的操作都会引发系统崩溃。此外,遍历系统所有任务需使用 for_each_process_thread 宏,并需区分内核概念中的线程 ID(PID)和用户空间看到的进程 ID(TGID)。