线程上下文切换和用户态进入内核态 linux内核都发生了什么

下面以 x86-64 Linux 为主,把前面讨论的 task_struct、用户栈、内核栈、pt_regsthread_struct、per-CPU 运行队列、socket 等全部串起来。

最重要的两个结论:

  1. 用户态进入内核态:通常还是同一个线程,只是 CPU 权限级、执行代码和栈发生变化。
  2. 线程上下文切换:一定由内核调度器在内核态完成,不存在用户态 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_struct
  • struct 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 进入内核入口后,内核汇编代码:

  1. 暂存用户态 RSP。
  2. 切换到 A 的内核栈。
  3. 保存用户寄存器。
  4. 在内核栈上构造 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

然后使用 sysretiret 返回用户态。

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_regs
  • thread_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() 调用后继续:

  1. 清理等待节点。
  2. 再次检查 socket 接收队列。
  3. 发现 skb 已经存在。
  4. 从 skb 取数据。
  5. copy_to_user() 到用户缓冲区。
  6. 设置 pt_regs->ax 为读取字节数。
  7. pt_regs 恢复用户寄存器。
  8. 使用 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;
真正的线程切换永远由内核态完成。
相关推荐
YIAN21 分钟前
NestJS 入门核心梳理:模块化架构、装饰器与依赖注入
后端·nestjs
唐青枫33 分钟前
别只会 malloc:Zig Allocator、所有权与内存生命周期实战
后端
风曳丷1 小时前
12|用 Attack Tree 和 Trace 固化证据
后端
江华森1 小时前
云原生从0到1:Kubernetes 工作负载实战——Deployment/Service/滚动更新/弹性伸缩
前端·后端
江华森1 小时前
《云原生从0到1:4台华为云ECS搭建Kubernetes 1.28集群实录(上)——环境与踩坑全记录》
前端·后端
那咋乎吧1 小时前
数据包到达网卡后到用户态应用程序的全流程
后端
程序猿阿越1 小时前
kubelet源码阅读
后端·kubernetes·源码阅读
烂蜻蜓2 小时前
Flask入门教程(九):模板渲染——用Jinja2构建动态页面
后端·python·flask
QQ_21696290962 小时前
【源码编号:project93375】SpringBoot汽车维修管理信息系统:客户车辆、维修预约、工单派发、配件结算全流程实战
java·spring boot·后端·汽车·springboot·需求分析