下面以 x86-64 Linux 为主,把前面讨论的 task_struct、用户栈、内核栈、pt_regs、thread_struct、per-CPU 运行队列、socket 等全部串起来。
最重要的两个结论:
- 用户态进入内核态:通常还是同一个线程,只是 CPU 权限级、执行代码和栈发生变化。
- 线程上下文切换:一定由内核调度器在内核态完成,不存在用户态 A 直接切到用户态 B。
css
用户态进入内核态:
User A → Kernel A → User A
发生线程切换:
User A → Kernel A → Kernel B → User B
一、整体数据结构
一个用户线程对应一个 task_struct:
arduino
struct task_struct {
unsigned int __state; // 睡眠、可运行等状态
int on_rq; // 是否处于调度运行队列
void *stack; // 线程自己的内核栈
struct thread_struct thread; // 架构相关线程状态
struct mm_struct *mm; // 用户地址空间
struct files_struct *files; // 文件描述符表
...
};
完整关系:
css
task_struct A
├── __state
├── 调度信息
│
├── stack ───────────────→ A 的内核栈
│ ├── pt_regs
│ ├── 内核函数调用栈
│ └── context-switch frame
│
├── thread
│ ├── sp ──────────────→ A 被切出时的内核栈位置
│ ├── FS/GS、TLS
│ ├── FPU/SIMD 状态
│ └── 其他架构相关状态
│
├── mm ──────────────────→ 用户地址空间
│ ├── 代码段
│ ├── 堆
│ ├── mmap 区域
│ └── A 的用户栈
│
└── files ───────────────→ fd 表
└── fd → file → socket → sock
thread_struct 不是另一个线程结构体。线程本身由 task_struct 表示,thread_struct 只保存与 x86、ARM64 等具体 CPU 架构相关的状态。
二、CPU 硬件状态和 per-CPU 数据
CPU 正在执行线程 A 时,一部分状态真正位于 CPU 硬件内部:
逻辑 CPU
├── RAX、RBX 等通用寄存器
├── RIP:当前执行地址
├── RSP:当前栈顶
├── CR3:当前页表入口
├── RFLAGS
└── TLB
这些不是内存中的 C 结构体。
内核另外在内存中为每个逻辑 CPU 维护 per-CPU 数据:
arduino
CPU 0 的 per-CPU 数据
├── current_task → task A
├── struct rq
└── softnet_data.poll_list
CPU 1 的 per-CPU 数据
├── current_task → task B
├── struct rq
└── softnet_data.poll_list
其中:
current_task:当前 CPU 正在执行哪个task_structstruct rq:这个 CPU 的可运行任务队列poll_list:网络 NAPI 需要轮询哪些 RX 队列
current 是内核快速访问当前 CPU 的 current_task 的方式。在 x86-64 上通常借助 per-CPU 区域和 GS 基址。
这些队列彼此不同:
| 结构 | 保存对象 | 含义 |
|---|---|---|
CPU struct rq |
task_struct |
哪些线程可以运行 |
| socket 等待队列 | 等待节点/回调 | 哪些线程在等待 socket |
| socket 接收队列 | sk_buff |
socket 收到了哪些数据 |
NAPI poll_list |
napi_struct |
哪些网卡 RX 队列需要轮询 |
| epoll ready list | 就绪 fd | 哪些 fd 已经就绪 |
三、用户栈和内核栈
每个用户线程通常有两套栈。
用户栈
用户栈位于进程的用户虚拟地址空间:
css
task A 在用户态:
CPU.RSP
│
▼
A 的用户栈
它保存:
- 用户函数调用帧
- 返回地址
- 局部变量
- 被保存的用户寄存器
- 可能位于栈上的接收缓冲区
用户栈不是堆的一部分:
用户地址空间
高地址
┌────────────────────┐
│ 用户栈,向下增长 │
├────────────────────┤
│ mmap 区域 │
├────────────────────┤
│ 堆,通常向上增长 │
├────────────────────┤
│ 数据段/BSS │
├────────────────────┤
│ 代码段 │
└────────────────────┘
低地址
内核栈
每个 task_struct 还有自己的内核栈:
css
task_struct A
└── stack → A 的内核栈
线程进入系统调用、中断或异常后,CPU 改用内核栈:
css
CPU.RSP → A 的内核栈
内核栈保存:
pt_regs- 系统调用调用链
- 中断/异常调用链
schedule()调用链- 上下文切换栈帧
栈本身不会在上下文切换时复制,只需要保存和恢复 RSP。
四、用户态如何进入内核态
用户态进入内核态主要有三种原因:
perl
系统调用:syscall
异常:缺页、除零等
中断:定时器、设备中断等
系统调用进入
假设线程 A 执行:
ini
char buf[4096];
ssize_t n = recv(fd, buf, sizeof(buf), 0);
用户态 libc 最终执行类似:
perl
syscall
x86-64 Linux 系统调用参数主要通过寄存器传递:
ini
RAX = 系统调用号
RDI = fd
RSI = buf
RDX = 4096
R10 = flags 或第四个参数
R8 = 第五个参数
R9 = 第六个参数
syscall 进入内核入口后,内核汇编代码:
- 暂存用户态 RSP。
- 切换到 A 的内核栈。
- 保存用户寄存器。
- 在内核栈上构造
struct pt_regs。
css
A 的内核栈
高地址
┌────────────────────────┐
│ pt_regs │
│ ├── ax:系统调用号 │
│ ├── di:fd │
│ ├── si:buf 地址 │
│ ├── dx:4096 │
│ ├── ip:用户返回地址 │
│ ├── sp:用户栈指针 │
│ └── flags │
├────────────────────────┤
│ 系统调用函数调用栈 │
└────────────────────────┘
低地址
此时:
ini
current 仍然是 task A
CPU.RIP = 内核系统调用代码
CPU.RSP = A 的内核栈
CPU 权限级 = 内核态
所以进入内核不等于切换线程。
中断进入
如果 A 正在用户态计算,定时器中断到达:
css
用户态 A
↓ 定时器中断
内核态 A
CPU 和内核入口代码会保存用户 RIP、RSP、RFLAGS、通用寄存器等,最终同样在 A 的内核栈上形成 pt_regs。
syscall 和中断入口的底层栈切换细节不同;现代 x86 还可能经过 KPTI 入口栈。但最终目的相同:
把用户现场保存成
pt_regs,并让 CPU 使用当前线程的内核栈执行内核代码。
五、recv() 有数据时发生什么
假设 socket 接收队列中已经有数据:
arduino
struct sock
└── sk_receive_queue
├── skb
└── skb
内核通过系统调用参数获得用户缓冲区地址:
ini
void __user *buf = (void __user *)regs->si;
然后执行类似:
scss
copy_to_user(buf, skb_data, count);
如果 buf 是:
ini
char buf[4096];
那么它位于用户栈。
如果是:
ini
char *buf = malloc(4096);
那么它位于堆。
所以数据流是:
scss
socket 接收队列中的 skb
│
│ copy_to_user()
▼
用户提供的 buf
系统调用返回值
假设成功读取 120 字节,内核系统调用处理函数返回:
kotlin
return 120;
系统调用分发代码概念上执行:
ini
regs->ax = sys_recv(...);
于是:
ini
pt_regs->ax = 120
系统调用退出时,内核从 pt_regs 恢复用户寄存器:
rust
CPU.RAX ← pt_regs->ax = 120
CPU.RIP ← pt_regs->ip
CPU.RSP ← pt_regs->sp
然后使用 sysret 或 iret 返回用户态。
x86-64 ABI 规定函数返回值放在 RAX,因此用户态 libc 的 recv() 得到:
ini
RAX = 120
最终对应:
ini
n = 120;
因此:
scss
收到的数据:
skb → copy_to_user() → buf
系统调用返回值:
内核返回值 → pt_regs->ax → CPU.RAX → 变量 n
六、recv() 没有数据时发生什么
如果 socket 接收队列为空,A 需要睡眠。
1. 加入 socket 等待队列
arduino
struct sock
├── sk_receive_queue
│ └── 空
│
└── socket_wq
└── wait_queue_head
└── wait_queue_entry
└── private → task_struct A
等待队列中通常不是直接挂 task_struct,而是挂一个等待节点,节点再指向 A。
2. 修改任务状态
概念上:
ini
A->__state = TASK_INTERRUPTIBLE;
TASK_RUNNING 在 Linux 中通常定义为 0,睡眠状态才是相应非零状态位。
3. 调用调度器
scss
schedule();
此时 A 已经处于内核态,使用自己的内核栈:
scss
A 的内核栈
├── pt_regs:用户态现场
├── recv() 调用链
├── socket 等待调用链
└── schedule() 调用链
接下来才发生真正的线程上下文切换。
七、线程 A 如何切换到线程 B
调度器查看当前 CPU 的运行队列:
arduino
CPU 0 的 struct rq
├── task B
├── task C
└── task D
选择 B:
ini
prev = A;
next = B;
1. 保存 A 的内核现场
上下文切换代码把必须保存的寄存器压入 A 的内核栈:
scss
A 的内核栈
├── pt_regs:A 的用户态现场
├── recv() 调用栈
├── schedule() 调用栈
└── switch frame
├── 被调用者保存寄存器
└── 内核返回地址
然后保存 A 的内核栈指针:
ini
A->thread.sp = CPU_RSP;
thread.sp 不保存整个栈,只保存恢复入口:
arduino
A->thread.sp
│
▼
A 内核栈上的 switch frame
2. 必要时切换地址空间
如果 A 和 B 属于不同进程:
css
A->mm != B->mm
需要切换地址空间,可能涉及:
- 页表
- CR3
- PCID
- TLB 管理
如果 A、B 是同一进程内的线程:
css
A->mm == B->mm
通常不需要真正切换用户页表,但它们仍有独立的:
task_struct- 用户 RSP
- 内核栈
pt_regsthread_struct- TLS
- 调度状态
3. 恢复 B 的架构状态
架构切换代码还需要处理:
objectivec
FS/GS、TLS
FPU/SSE/AVX
调试寄存器
其他架构相关状态
这些状态由 thread_struct、FPU 状态区域和架构切换代码共同管理。
4. 加载 B 的内核栈
ini
CPU_RSP = B->thread.sp;
CPU 的栈从 A 的内核栈切换为 B 的内核栈:
ini
保存:
A->thread.sp = CPU.RSP
恢复:
CPU.RSP = B->thread.sp
同时更新当前任务:
ini
per-CPU current_task = B
rq->curr = B
然后 CPU 从 B 的 switch frame 恢复寄存器。
B 不是从头开始,而是从上一次被切出的位置继续。通常表现为:
scss
B 从自己上一次 schedule() 后继续
如果 B 是刚创建的新线程,内核会提前在 B 的内核栈中构造初始 switch frame,使它第一次被切入时进入类似 ret_from_fork 的路径。
八、网络数据到达后如何唤醒 A
网卡收到数据后:
markdown
网卡 RX 队列
↓
硬中断
↓
napi_struct 加入某 CPU 的 poll_list
↓
NET_RX_SOFTIRQ
↓
NAPI poll 批量收包
↓
生成/处理 skb
↓
TCP/UDP
↓
skb 进入 socket 接收队列
数据结构关系:
markdown
CPU 的 softnet_data
└── poll_list
└── napi_struct
struct sock
├── sk_receive_queue
│ ├── skb
│ └── skb
│
└── socket 等待队列
└── task A 的等待节点
网络协议栈唤醒 A:
css
A:睡眠状态 → TASK_RUNNING
然后选择一个目标 CPU,把 A 加入其运行队列:
arduino
CPU 1 的 struct rq
└── task A
这时 A 只是 runnable,不代表已经立即运行。
连续来多个小包时,可能多次执行唤醒检查,但同一个 A 不会被重复放入运行队列:
css
包 1:A sleeping → runnable,加入 rq
包 2:A 已经 runnable,不会重复入队
包 3:A 已经 runnable,不会重复入队
NAPI、GRO、TCP 合并以及调度延迟也会让多个小包在 A 真正运行前积累起来。
九、A 被重新调度后如何继续
以后调度器再次选择 A:
arduino
保存当前任务 B 的 thread.sp
加载 A->thread.sp
CPU 重新使用 A 的内核栈:
scss
A 的内核栈
├── pt_regs
├── recv() 调用栈
├── socket 等待调用栈
├── schedule() 调用栈
└── switch frame ← 从这里恢复
A 从自己的 schedule() 调用后继续:
- 清理等待节点。
- 再次检查 socket 接收队列。
- 发现 skb 已经存在。
- 从 skb 取数据。
copy_to_user()到用户缓冲区。- 设置
pt_regs->ax为读取字节数。 - 从
pt_regs恢复用户寄存器。 - 使用
sysret/iret返回用户态。
完整过程:
css
用户态 A:recv()
↓ syscall
内核态 A:构造 pt_regs
↓
socket 无数据
↓
A 加入 socket 等待队列
↓
A->__state = TASK_INTERRUPTIBLE
↓
schedule()
↓
保存 A->thread.sp
↓
加载 B->thread.sp
↓
内核态/用户态 B 运行
↓
网卡收到数据
↓
NAPI → skb → socket 接收队列
↓
唤醒 A,将 A 加入 CPU rq
↓
调度器以后选择 A
↓
加载 A->thread.sp
↓
A 从 schedule() 后继续
↓
skb → copy_to_user(buf)
↓
pt_regs->ax = 接收字节数
↓
恢复 pt_regs
↓
用户态 A:recv() 返回
less
```mermaid
sequenceDiagram
participant UA as "用户态线程 A"
participant KA as "内核态线程 A"
participant S as "调度器"
participant B as "线程 B"
participant N as "NAPI/网络协议栈"
UA->>KA: "syscall: recv(fd, buf, len)"
Note over KA: "切换到 A 内核栈,构造 pt_regs"
KA->>KA: "socket 接收队列为空"
KA->>KA: "加入等待队列,设置睡眠状态"
KA->>S: "schedule()"
Note over S: "保存 A->thread.sp,加载 B->thread.sp"
S->>B: "运行 B"
N->>N: "NAPI 批量收包,处理 skb"
N->>S: "skb 入 socket 队列,唤醒 A 并加入 rq"
S->>KA: "以后重新选择 A,加载 A->thread.sp"
KA->>KA: "从 schedule() 后继续"
KA->>UA: "copy_to_user,pt_regs->ax=n,sysret"
```
十、用户态运行中的线程如何被抢占
假设 A 一直在用户态计算,没有调用系统调用。
CPU 不会直接从用户态 A 切到用户态 B,而是:
css
用户态 A
↓ 定时器中断
内核态 A
↓ 保存 A 的 pt_regs
↓ 设置 need_resched
↓ 在允许调度的位置进入调度器
↓ 保存 A->thread.sp
↓ 加载 B->thread.sp
内核态 B
↓ 恢复 B 的 pt_regs
用户态 B
也就是:
css
User A → Kernel A → Kernel B → User B
定时器中断处理本身处于中断上下文;真正调度会在允许调度的中断退出/抢占路径执行,而不是随意在不可调度的原子区域切换。
如果 B 上次也在用户态被抢占,那么 B 的内核栈上保存着:
css
B 的 pt_regs
├── B 用户 RIP
├── B 用户 RSP
└── B 用户寄存器
调度器切到 B 的内核栈后,中断退出路径恢复 B 的 pt_regs,最终回到用户态 B。
十一、进程或线程刚创建时,栈从哪里来
exec() 创建新程序映像
内核为新程序建立:
新 mm_struct
├── ELF 代码段
├── 数据段
├── 堆
└── 初始用户栈
初始用户栈中包含:
css
argc
argv[]
envp[]
auxv
参数和环境变量字符串
内核设置初始用户现场:
ini
pt_regs->ip = ELF 入口地址
pt_regs->sp = 初始用户栈顶
程序先进入 _start,经过运行库初始化后才调用 main()。
fork() 创建子进程
子进程继承父进程的用户地址空间和用户栈,通常使用写时复制 COW:
markdown
父进程页表 ─┐
├── 共享只读物理页
子进程页表 ─┘
父子进程的用户栈虚拟地址可以相同,但属于不同的 mm_struct。
pthread_create() 创建新线程
用户态线程库通常先通过 mmap() 准备新线程用户栈,再调用 clone()/clone3():
共享的 mm_struct
├── 主线程用户栈
└── 新线程用户栈
内核另外创建:
arduino
新 task_struct
新内核栈
新 thread_struct
初始 pt_regs
初始 switch frame
因此多个线程共享地址空间和堆,但用户栈和内核栈各自独立。
十二、最终记忆模型
可以把一个线程的上下文分成四部分:
markdown
1. CPU 当前活跃状态
通用寄存器、RIP、RSP、CR3、TLB
2. 用户态现场
内核栈上的 pt_regs
3. 内核态现场
内核栈上的调用链和 switch frame
thread_struct.sp 指向恢复位置
4. 架构特殊状态
FS/GS、TLS、FPU/SIMD、调试状态等
由 thread_struct 和架构代码管理
用户态进入内核态:
同一个 task
用户 RSP → 内核 RSP
用户寄存器 → pt_regs
用户 RIP → 内核入口代码
线程上下文切换:
ini
prev 的寄存器 → prev 内核栈
prev->thread.sp = CPU.RSP
必要时切换 prev->mm → next->mm
current = next
CPU.RSP = next->thread.sp
next 内核栈 → 恢复寄存器
返回用户态:
rust
pt_regs->ax → CPU.RAX
pt_regs->ip → CPU.RIP
pt_regs->sp → CPU.RSP
sysret/iret → 用户权限级
最简洁的主线就是:
arduino
用户现场由 pt_regs 保存;
内核调用现场保存在每线程内核栈;
thread.sp 负责找到内核栈恢复点;
调度器通过 per-CPU rq 选择下一个 task;
真正的线程切换永远由内核态完成。