线程启动全流程 —— 主线程与子线程从创建到执行第一条业务指令
核心问题:shell 敲下
./program后,main()是怎么被调到的?pthread_create()返回后,新线程是"已经在跑了"还是"还没出生"?它们的第一个内核函数和第一个用户函数分别是什么?新线程从未被执行过,它的内核栈上有调用帧吗——调度器凭什么能"跳"过去?从内核态回到用户态时,又是怎么找到线程入口代码的?
本文对比两条截然不同的启动路径——主线程(execve → _start → main)和子线程(clone → ret_from_fork → start_thread → 入口函数)——拆解每一步的调用栈、内核函数执行过程、用户态/内核态切换时机,以及调度器何时介入。重点(§2.3.4)拆解
copy_thread()如何伪造内核栈上的 pt_regs 和返回地址、switch_to()如何通过 RSP 切换"跳"到新线程的ret_from_fork、以及sysretq如何读取伪造的pt_regs.rip定位用户态入口代码。
前置阅读:线程创建的整体框架见 thread-creation;进程创建见 process-creation;ELF 加载细节见 compile-link-load;上下文切换实现见 context-switch;调度器逻辑见 scheduling;段寄存器与特权级切换见 segment-management。
零、全景图:两条路径,一个调度器
主线程和子线程虽然最终都在用户态执行,但它们的"出生"路径完全不同:

| 维度 | 主线程(execve 路径) | 子线程(clone 路径) |
|---|---|---|
| 触发者 | shell 调用 fork()+execve() | 任一已有线程调用 pthread_create() |
| 进程/线程关系 | 新建进程(新 PID),主线程即该进程的第一个线程 | 新建线程(新 tid),加入已有进程的线程组 |
| 地址空间 | 全新——旧地址空间销毁,加载 ELF 构建新的 | 共享——父线程的地址空间(CLONE_VM) |
| 栈 | 内核在用户态地址空间准备初始栈 | glibc 用 mmap 分配新栈(默认 ~8MB) |
| 内核入口 | sys_execve() | sys_clone() |
| 内核出口 | start_thread() → iretq → 用户态 _start | copy_thread() → ret_from_fork → sysretq → 用户态 start_thread(glibc) |
| 第一个用户函数 | _start(在 crt1.o 中) | start_thread(glibc/NPTL 内部) |
| 业务入口 | main(argc, argv) | fn(arg)(pthread_create 传入的函数指针) |
| 第一次被调度 | execve 返回时立即运行(当前 task 正在 CPU 上) | clone 返回后由调度器择机运行(可能与父线程并发) |
一、主线程启动:从 execve() 到 main()
1.1 概述:shell 如何启动一个程序
用户敲下 ./program 后,shell 走的是经典的 fork + execve 两段式:
shell (PID 100)
→ fork() → 子进程 (PID 101)
子进程: execve("./program", argv, envp) ← 本文从这里开始execve() 的特殊之处:它不返回(成功时)。调用它的进程镜像被整个替换——旧代码、旧数据、旧栈全部消失,取而代之的是一个全新的程序。从内核视角看,PID 没变,但 task->mm 指向了全新的地址空间。
1.2 阶段一:内核态 —— sys_execve() 的完整调用链
内核从 sys_execve() 进入,经过层层调用,最终在 load_elf_binary() 中完成所有繁重工作。以下是内核函数调用栈(按调用顺序从上到下):
用户态
═══════════════════════════════════ syscall ═══════════════════════════════════
内核态
┌─ sys_execve(filename, argv, envp) // fs/exec.c
│ ├─ do_execve(getname(filename), argv, envp)
│ │ └─ do_execveat_common(AT_FDCWD, filename, argv, envp, 0)
│ │ ├─ alloc_bprm() // 分配 linux_binprm 结构体
│ │ │ └─ 分配临时内核空间,拷贝 argv、envp
│ │ ├─ prepare_bprm_creds() // 准备新凭证(若 setuid 会在此切换)
│ │ ├─ exec_binprm(bprm)
│ │ │ └─ search_binary_handler(bprm)
│ │ │ └─ load_elf_binary(bprm) // ★ 核心:fs/binfmt_elf.c
│ │ │ ├─ elf_check_arch() // 校验 ELF 架构匹配(x86-64)
│ │ │ ├─ flush_old_exec() // 销毁旧地址空间:释放页表、
│ │ │ │ // 清空 mmap、重置信号处理
│ │ │ ├─ setup_new_exec() // 设置新进程属性
│ │ │ │ └─ arch_pick_mmap_layout() // 决定 mmap 基址
│ │ │ ├─ 解析 ELF Program Headers
│ │ │ │ └─ elf_map() // ★ mmap 每个 PT_LOAD 段
│ │ │ │ (代码段: PROT_READ|PROT_EXEC, MAP_PRIVATE)
│ │ │ │ (数据段: PROT_READ|PROT_WRITE, MAP_PRIVATE)
│ │ │ │ (BSS: 匿名映射,初始化为 0)
│ │ │ ├─ 处理 ELF Interpreter (PT_INTERP)
│ │ │ │ └─ load_elf_interp() // 加载 ld-linux-x86-64.so.2
│ │ │ │ 返回 ld.so 的入口地址
│ │ │ ├─ set_brk() // 设置堆 (brk) 起始地址
│ │ │ ├─ create_elf_tables() // ★ 构建用户态初始栈
│ │ │ │ 栈底: argc
│ │ │ │ 栈底+8: argv[0], argv[1], ..., NULL
│ │ │ │ 栈底+N: envp[0], envp[1], ..., NULL
│ │ │ │ 栈底+M: auxiliary vector (AT_PHDR, AT_ENTRY,
│ │ │ │ AT_PAGESZ, AT_RANDOM, ...)
│ │ │ ├─ 确定入口地址:
│ │ │ │ 动态链接 → ld.so 的入口 (e_entry of interpreter)
│ │ │ │ 静态链接 → ELF 自己的 e_entry (即 _start)
│ │ │ └─ start_thread(regs, entry, sp)
│ │ │ ★ 伪造 pt_regs,让进程"返回到"新程序入口
│ │ └─ ... (execve 成功后不返回此处)
└──────────────────────────────────────────────────────────────────────关键内核函数的工作:
| 内核函数 | 做了什么 | 对后续的影响 |
|---|---|---|
flush_old_exec() | 释放旧进程的全部页表、mm_struct,清空 VMA(虚拟内存区域)链 | 旧代码彻底消失,execve "不可能返回"的根源 |
elf_map() | 调用 vm_mmap() 把 ELF 的每个 PT_LOAD 段映射到虚拟地址空间 | 代码段 (R+X)、数据段 (R+W) 就位,缺页时从磁盘读入 |
create_elf_tables() | 在用户栈顶写入 argc, argv[], envp[], auxv[] | _start 能拿到 argc/argv——它们已经在栈上 |
load_elf_interp() | 把 ld-linux-x86-64.so.2 也 mmap 进来 | 动态链接的程序先跑 ld.so,做完重定位后才跳 main |
start_thread() | 修改当前 task 内核栈上的 pt_regs,设置 CS/SS/RIP/RSP | execve "返回"时不回到旧代码,而是出现在 _start |
1.3 阶段二:start_thread() —— 伪造"被中断过"的用户态返回现场
这是整个 execve 路径里最精妙的一步。execve 本质上是当前 task 发出的一个普通系统调用——它和 read/write 一样,是通过 syscall 指令陷入内核的。系统调用返回时,CPU 会根据内核栈上保存的 pt_regs 恢复用户态寄存器并执行 sysretq(或 iretq),回到 syscall 下一条指令。
但 execve 不打算回原来的代码了。所以 start_thread() 直接覆盖当前 task 内核栈上的 pt_regs,把它改成新程序的执行环境:
当前 task 的内核栈(在 start_thread 之前):
┌──────────────────────────────┐
│ pt_regs (由 syscall 入口保存) │ ← RIP 指向 syscall 下一条指令
│ CS=0x33, SS=0x2B │ RSP 指向原用户态栈
│ RIP=原代码地址 │ ...
│ RSP=原用户栈指针 │
├──────────────────────────────┤
│ ... (内核栈剩余部分) ... │
└──────────────────────────────┘
start_thread 覆盖后:
┌──────────────────────────────┐
│ pt_regs (被"伪造"过) │ ← RIP 改为 _start (或 ld.so 入口)
│ CS=0x33 ← 用户代码段 │ RSP 改为新用户栈顶
│ SS=0x2B ← 用户数据/栈段 │ RFLAGS 清零(无 IF 中断也暂时不开)
│ RIP=entry ← 新程序的入口 │
│ RSP=new_sp ← 新用户栈顶 │ 其他通用寄存器全部清零
│ RFLAGS=0 │
├──────────────────────────────┤
│ ... (内核栈剩余部分) ... │
└──────────────────────────────┘对应的内核代码(简化):
// arch/x86/kernel/process_64.c
void start_thread(struct pt_regs *regs, unsigned long new_ip,
unsigned long new_sp)
{
// 设置段寄存器——关键:CS=0x33 用户代码段、SS=0x2B 用户数据段
start_thread_common(regs, new_ip, new_sp,
__USER_CS, __USER_DS, 0);
// __USER_CS = 0x33 (GDT[6], RPL=3, 用户态 64 位代码段)
// __USER_DS = 0x2B (GDT[5], RPL=3, 用户态数据段)
}为什么 CS=0x33? 这是本文最关键的数字之一。
0x33= Index 6 + TI=0 + RPL=3 → GDT[6] = 用户代码段描述符,DPL=3。CPU 根据CS.Selector.RPL决定 CPL(Current Privilege Level),所以进程启动时 CPL=3 即用户态。这对数字的完整解释见 segment-management §二。
start_thread 的调用参数来源:
| 参数 | 值 | 来源 |
|---|---|---|
new_ip (→ RIP) | 动态链接:ld.so 的 e_entry;静态链接:ELF 的 e_entry(即 _start) | ELF header → elf_entry → 若动态链接用 interp 的入口 |
new_sp (→ RSP) | 用户栈顶地址 | create_elf_tables() 写入 argc/argv/envp/auxv 后栈顶的位置 |
1.4 阶段三:sysretq → 用户态 _start
start_thread() 修改完 pt_regs,内核函数调用链依次返回。当返回到 do_execveat_common() → 系统调用出口路径时,内核发现 pt_regs 已经指向了新程序的入口,于是:
syscall 出口路径
→ arch_exit_to_user_mode_prepare()
→ exit_to_user_mode_loop()
→ exit_to_user_mode_prepare()
→ sysretq (或 iretq)
★ CPU 从 pt_regs 恢复:
CS = 0x33 (→ CPL=3,用户态,64 位代码段)
SS = 0x2B
RIP = _start ← 新程序的真正入口
RSP = 新用户栈顶
RFLAGS = 0 (初始状态)
★ 执行流跳转到 _start,从此跑在用户态一句话:execve 是内核"借用"当前 task 的 syscall 返回路径,把它"劫持"到了新程序的
_start。当前进程中原始程序的所有痕迹——代码、数据、栈、页表——已在内核中提前被flush_old_exec()彻底清除。
1.5 阶段四:_start → __libc_start_main → main()
进入用户态后,第一个执行的函数是 _start。它不在你的代码里——它在 crt1.o(C Runtime)中,由链接器在编译时自动加入。
1.5.1 静态链接程序的路径(简化)
_start (crt1.o, AT&T 汇编示意):
xorl %ebp, %ebp ; 清空帧指针(标记调用链顶端)
movl %edx, %r9d ; 传递 fini 函数指针
popq %rsi ; RSI = argc(栈顶第一个值)
movq %rsp, %rdx ; RDX = argv(argc 弹出后 RSP 指向 argv[0])
andq $-16, %rsp ; 栈 16 字节对齐(ABI 要求)
call __libc_start_main ; 调用 glibc 初始化 → 最终调 main
hlt ; __libc_start_main 永远不返回这里1.5.2 动态链接程序的路径(先跑 ld.so)
动态链接的程序更复杂——start_thread() 设置的 RIP 不是你自己程序的 _start,而是 ld.so 的入口。
阶段 4a: ld.so 自举
────────
RIP = ld-linux-x86-64.so.2 的 _start
→ _dl_start() // ld.so 自举:重定位自己
→ _dl_start_final()
→ _dl_sysdep_start()
→ 解析 auxiliary vector(从栈上 auxv 读取 AT_PHDR/AT_ENTRY 等)
阶段 4b: 加载依赖库
────────
→ dl_main()
→ _dl_init_paths() // 初始化库搜索路径(LD_LIBRARY_PATH 等)
→ 遍历 ELF .dynamic 段,加载所有 DT_NEEDED 的 .so
→ _dl_map_object_from_fd() // mmap 每个 .so
→ _dl_relocate_object() // 重定位:填写 GOT/PLT
→ _dl_start_user: // 跳回主程序的 _start
→ jmp *%r12 // R12 = 主程序的 _start 地址
阶段 4c: 主程序的 _start
────────
_start (crt1.o):
→ call __libc_start_main(main, argc, argv, init, fini, ...)1.5.3 __libc_start_main() 做了什么
// glibc: csu/libc-start.c (简化)
int __libc_start_main(
int (*main)(int, char **, char **), // main 的函数指针
int argc,
char **argv,
void (*init)(void), // __libc_csu_init → .init_array
void (*fini)(void), // .fini_array
void (*rtld_fini)(void),
void *stack_end)
{
// 1. 初始化 TLS: arch_prctl(ARCH_SET_FS, tcb)
// 将 FS 段基址指到线程控制块 (TCB)
__tls_init_tp();
// 2. 设置 __environ (全局 environ 指针,指向栈上的 envp)
__environ = argv + argc + 1;
// 3. 调用初始化函数
if (init)
(*init)(argc, argv, __environ); // → __libc_csu_init → .init_array
// 4. ★ 调用你的 main
int result = main(argc, argv, __environ);
// 5. 调用 exit(result) —— main 返回后不直接结束,
// exit() 会: 调 atexit 函数 → 调 .fini_array → _exit 系统调用
exit(result);
}
__libc_start_main()永远不会返回到_start——main()返回后它调用exit(),而exit()最终调用_exit系统调用,整个进程在此终止。
1.6 主线程启动的完整调用栈总览
在给出完整调用栈之前,先用一张图把用户态这一段的接力关系收拢一下——从内核把执行流交出来的那一刻起,控制权要经过最多四站才到你的 main:

这张图有两个值得记住的点。其一,动态链接的程序多走一站:start_thread() 伪造的 pt_regs.rip 指向的是 ld.so 而不是 _start,你的程序入口要等 ld.so 把所有依赖库 mmap 进来、做完 GOT/PLT 重定位之后才被跳到——静态链接则直接从第二站 _start 开始。其二,这条链是单向的:__libc_start_main 调完 main 后接着调 exit(),执行流永远不会沿原路返回,_start 里 call 后面的 hlt 只是理论上的兜底。
══════════════════ 用户态 ══════════════════
main(argc, argv) ★ 你的代码
↑ __libc_start_main() 调用
__libc_start_main(main, argc, argv, ...) glibc 框架
├─ __tls_init_tp() 设置 TLS (ARCH_SET_FS)
└─ init() → __libc_csu_init .init_array 初始化
↑ call from _start
_start (crt1.o) 汇编入口
══════════════════ syscall ══════════════════
↑ sysretq —— CPU 从 pt_regs 恢复,返回到 _start
══════════════════ 内核态 ══════════════════
start_thread(regs, entry, sp) 伪造 pt_regs:
start_thread_common(regs, ip, sp, CS = __USER_CS (0x33)
__USER_CS, SS = __USER_DS (0x2B)
__USER_DS, 0) RIP = entry (_start 或 ld.so)
↑ load_elf_binary() 调用
load_elf_binary(bprm) 加载 ELF:
├─ flush_old_exec() 销毁旧地址空间
├─ elf_map() × N mmap 代码/数据段
├─ load_elf_interp() 加载 ld.so(动态链接)
├─ create_elf_tables() 构建用户栈: argc/argv/envp/auxv
└─ start_thread() 伪造返回现场
↑ exec_binprm() → search_binary_handler() 调用
do_execveat_common(AT_FDCWD, filename, ...)
↑ do_execve() → sys_execve() 调用
══════════════════ syscall ══════════════════
↑ 系统调用入口 (syscall 指令)
══════════════════ 用户态(原程序)══════════
execve("/bin/program", argv, envp)二、子线程启动:从 pthread_create() 到线程入口函数
2.1 概述:父线程如何制造子线程
pthread_create() 是 POSIX 线程创建接口,用户代码看起来是"函数调用"——但实际上它内部走了一个完整的 clone() 系统调用,在内核中创建一个可独立调度的新 task。
与 execve 最根本的区别:子线程没有"原程序被替换"的过程——父线程和子线程共享同一地址空间,只是执行流分叉了。
2.2 阶段一:用户态准备 —— glibc/NPTL 分配栈和 TLS
在发起 clone() 系统调用之前,glibc(具体是 NPTL —— Native POSIX Thread Library)需要准备好新线程"安身立命"的资源:
用户态:pthread_create(&tid, &attr, func, arg)
→ __pthread_create_2_1() // nptl/pthread_create.c
阶段 1a: 分配线程栈
├─ allocate_stack(iattr, &pd, ...) // nptl/allocatestack.c
│ ├─ mmap(NULL, stacksize, // 默认 8MB
│ │ PROT_READ|PROT_WRITE,
│ │ MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK,
│ │ -1, 0)
│ │ → 返回地址如 0x7ffff73fd000(栈顶附近)
│ ├─ 在栈顶下方放置 guard page (PROT_NONE)
│ │ → 栈溢出时会触发 SIGSEGV 而非悄无声息覆盖其他数据
│ └─ pd->stackblock = mmap 返回的地址
│ pd->stackblock_size = stacksize
阶段 1b: 准备 TLS
├─ _dl_allocate_tls_storage() // 为 TCB 分配内存
│ └─ pd->tcb = 分配的 TCB (Thread Control Block)
└─ 在 TCB 中初始化 TLS 变量槽位 (errno 等)
阶段 1c: 设置 clone 参数
└─ pd->start_routine = func; // 用户传入的线程入口函数此时新线程的用户栈布局(pd->stackblock 指向的区域):
0x7ffff73fd000 高地址
┌──────────────────────────┐
│ 栈顶 (RSP 初值指向这里) │ ← pd->stackblock + stacksize
│ ... 给新线程用的栈空间 ... │ (实际 RSP 由内核在 clone 时设置)
│ │
│ 栈帧: start_thread 的参数 │ ← glibc 在这里写好 func 和 arg
├──────────────────────────┤
│ guard page (PROT_NONE) │ 栈溢出保护
└──────────────────────────┘
0x7ffff73fc000 低地址2.3 阶段二:clone() 系统调用 —— 父线程进入内核
glibc 准备好环境后,发起 clone() 系统调用。
2.3.1 用户态 → 内核态
// glibc 内部: 通过内联汇编发起 clone
// 关键参数:
// clone_flags = CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|
// CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|
// CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID
// child_stack = pd->stackblock + pd->stackblock_size (新线程栈顶)
// parent_tid = &pd->tid (内核填回 tid)
// tls = pd->tcb (TLS 描述符)
// child_tid = &pd->tid (线程退出时内核清理用)
// 实际调用: clone(fn_wrapper, child_stack, clone_flags, arg, ...)
// → syscall(SYS_clone, ...) ← ★ 模式切换: 用户态 → 内核态2.3.2 内核态调用链
用户态
════════════════════════════════════════ syscall ════════════════════════════
内核态(运行在父线程的上下文)
┌─ sys_clone(flags, child_stack, parent_tid, child_tid, tls)
│ └─ kernel_clone(args)
│ ├─ copy_process(NULL, trace, node, args)
│ │ ├─ dup_task_struct(current) // 分配新 task_struct
│ │ │ ├─ alloc_task_struct_node() // kmalloc task_struct
│ │ │ ├─ alloc_thread_stack_node() // ★ 分配子线程的内核栈
│ │ │ │ (通常 16KB: THREAD_SIZE, 两个页)
│ │ │ └─ arch_dup_task_struct()
│ │ │ └─ 复制 parent 的 thread_struct (里面有 TLS 等)
│ │ │
│ │ ├─ 检查资源限制 (RLIMIT_NPROC, 信号数等)
│ │ │
│ │ ├─ copy_creds() // 共享/复制凭证
│ │ │
│ │ ├─ sched_fork() // 调度器初始化
│ │ │ └─ p->state = TASK_NEW // 状态设为 "新生"
│ │ │
│ │ ├─ ★ 根据 flags 共享/复制各类资源:
│ │ │ ├─ copy_mm() // CLONE_VM=1 → 共享 mm,不复制页表
│ │ │ ├─ copy_files() // CLONE_FILES=1 → 共享 fd 表
│ │ │ ├─ copy_fs() // CLONE_FS=1 → 共享 cwd/umask
│ │ │ ├─ copy_sighand() // CLONE_SIGHAND=1 → 共享信号处理表
│ │ │ ├─ copy_signal() // CLONE_THREAD=1 → 共享 signal_struct
│ │ │ └─ copy_namespaces() // 共享 namespace
│ │ │
│ │ ├─ copy_thread(flags, child_stack, child_stack_size)
│ │ │ // ★ 关键:设置子线程的内核栈上的"初始帧"
│ │ │ // → 让子线程首次被调度时从 ret_from_fork 开始
│ │ │ // → 设置子线程返回用户态后的 RIP (start_thread) 和 RSP (新栈)
│ │ │
│ │ ├─ 分配 PID/tid → p->pid = 新 tid
│ │ │
│ │ └─ CLONE_SETTLS → 设置 p->thread.fsbase = tls 参数
│ │
│ ├─ wake_up_new_task(p)
│ │ ├─ p->state = TASK_RUNNING // ★ 状态变为 "可运行"
│ │ ├─ activate_task(rq, p) // 加入运行队列
│ │ │ └─ enqueue_task(rq, p)
│ │ │ └─ 按 vruntime 插入 CFS 红黑树
│ │ ├─ check_preempt_curr() // 检查是否需要抢占当前 task
│ │ │ └─ 若新线程的 vruntime < 当前 task → 置 need_resched
│ │ └─ 返回
│ │
│ └─ clone 返回子线程的 tid (给父线程的返回值)关键观察:
clone()系统调用在父线程的内核上下文中执行上述所有步骤。子线程的task_struct在copy_process()中被创建,但在wake_up_new_task()之后才真正变为可运行。此时子线程可能还在运行队列中排队——它的第一条指令尚未被执行。
2.3.3 copy_thread() —— 子线程内核栈的初始帧
copy_thread() 是新线程出生点的关键。它在子线程的内核栈上构建一个伪造的"被中断过"的栈帧,确保子线程首次被调度时从 ret_from_fork 开始执行:
// arch/x86/kernel/process.c (简化)
int copy_thread(unsigned long clone_flags, unsigned long sp,
unsigned long arg, struct task_struct *p, ...)
{
struct inactive_task_frame *frame;
struct pt_regs *childregs;
// childregs 指向子线程内核栈上 pt_regs 的位置
childregs = task_pt_regs(p);
*childregs = *current_pt_regs(); // 先拷贝父线程的 pt_regs
// ★ 然后根据 clone_flags 修改:
childregs->ax = 0; // 子线程的 fork/clone 返回值 = 0
if (sp) {
// ★ sp != 0 → 这是用户线程(CLONE_VM)
childregs->sp = sp; // RSP → 新线程的用户栈
}
// 设置 ret_from_fork 的返回地址(子线程首次被调度时从这里开始)
p->thread.sp = (unsigned long)childregs;
p->thread.ip = (unsigned long)ret_from_fork;
// 设置 TLS
if (clone_flags & CLONE_SETTLS)
p->thread.fsbase = ...; // FS 段基址 → TCB
return 0;
}子线程内核栈上的初始布局:
子线程的内核栈顶
┌──────────────────────────────────┐
│ pt_regs (从父线程拷贝 + 修改) │ ← childregs = task_pt_regs(p)
│ CS = 0x33 │ ★ 用户代码段(与父线程相同)
│ SS = 0x2B │ ★ 用户数据段
│ RSP = child_stack │ ★ 新线程的独立用户栈顶
│ RIP = glibc start_thread 地址 │ ★ 返回用户态后第一跳
│ RAX = 0 │ ★ clone() 返回值 = 0(子线程视角)
│ 其他通用寄存器 = 父线程的快照 │
├──────────────────────────────────┤
│ inactive_task_frame │
│ rip = ret_from_fork │ ← p->thread.ip: 首次被调度时从此开始
├──────────────────────────────────┤
│ ... (剩余内核栈空间) ... │
└──────────────────────────────────┘为什么
ret_from_fork? 子线程并不是从某个"真实中断/系统调用"返回——它从未跑过任何代码。但内核用同一个机制来"假扮"它曾被中断过:在内核栈上伪造pt_regs,然后通过ret_from_fork→ 系统调用出口路径 →sysretq进入用户态。这就是为什么copy_thread既要设置p->thread.ip(内核态起点)也要设置pt_regs(用户态终点)。
2.3.4 深入:新线程内核栈到底是不是"空的"?调度器如何"凭空"找到入口?
这是理解新线程启动的核心问题。拆成三个子问题逐步解答。
问题 1:新线程的内核栈上有调用帧吗?
不是空的,但也不是"正常函数调用产生的调用帧"——是 copy_thread() 手工构建的一条伪造帧链。
对比一下"正常"的内核栈长什么样:
正常线程的内核栈(比如从 syscall 陷入后):
┌──────────────────────────────────┐
│ pt_regs │ ← syscall 入口硬件自动压入(SS/RSP/
│ (由 CPU 在陷入时自动压栈) │ RFLAGS/CS/RIP/err/...)
│ 包括: CS, SS, RIP, RFLAGS, │
│ RSP, RAX, ... 等 │
├──────────────────────────────────┤
│ 函数调用帧 A (call xxx) │ ← syscall 处理函数层层调用,每层
│ 函数调用帧 B (call yyy) │ `call` 压一个返回地址
│ 函数调用帧 C ... │
├──────────────────────────────────┤
│ ... │
└──────────────────────────────────┘
新线程的内核栈(copy_thread 伪造后):
┌──────────────────────────────────┐ ← RSP 在这里(p->thread.sp 指向这里)
│ pt_regs │ 不是硬件压的!是 copy_thread()
│ CS = 0x33 ← 用户代码段 │ 从父线程拷贝后逐字段修改的
│ SS = 0x2B ← 用户数据段 │
│ RSP = child_stack ← 新线程独立用户栈 │
│ RIP = start_thread(glibc 地址) │ ★ 这是"返回用户态后去哪里"的答案
│ RAX = 0 ← clone 返回值 │
│ ... │
├──────────────────────────────────┤
│ inactive_task_frame │ 这也不是 `call` 指令压的!
│ 其他 callee-saved 寄存器 │ 是 copy_thread() 手写在这里的
│ ... │
├──────────────────────────────────┤
│ 返回地址 = ret_from_fork │ ← p->thread.ip 的值被写在栈上
│ │ switch_to 返回时从这里 pop
├──────────────────────────────────┤
│ 空(未使用的内核栈剩余空间) │
└──────────────────────────────────┘关键区别:正常内核栈上的帧是 call 指令的产物(硬件自动压返回地址、被调用函数压 rbp 形成链),而新线程栈上的帧是 copy_thread() 手工 memset-like 写进去的值。它们是"长得像栈帧的数据结构",不是"函数调用的历史记录"。
一句话:新线程内核栈上有数据(
pt_regs+inactive_task_frame),但它们是被构造出来骗 CPU 的——骗 CPU 相信"这个线程曾经中断过,现在该返回了"。
把 task_struct 里的两个字段和栈上三块数据的指向关系画出来,整套"骗局"的机关就一目了然了:

这张图要盯住的是两条"引线":thread.sp 决定 switch_to 切栈后 RSP 落在哪里(伪造 pt_regs 的顶部),栈上那个手写返回地址决定 ret 之后 RIP 落在哪里(ret_from_fork)。内核态起点和用户态终点就是这样被分别预埋在两处——thread.ip/栈上返回地址管"先跳到 ret_from_fork",pt_regs.rip 管"最终回到 glibc 的 start_thread"。调度器全程不需要知道新线程"是谁",它只是机械地执行切栈、pop、ret,剩下的都由 copy_thread() 事先写好的数据完成。
问题 2:switch_to() 如何"跳"到从未运行过的新线程?
这是上下文切换的魔法时刻。结合 switch_to 的实现来追踪整个栈切换过程:
; arch/x86/entry/entry_64.S → switch_to(prev, next), 简化
switch_to:
; ───── 步骤 ①: 保存当前线程的现场 ─────
pushq %rbp ; 压 callee-saved 寄存器
pushq %rbx ; 到当前线程的内核栈
pushq %r12
pushq %r13
pushq %r14
pushq %r15
; ───── 步骤 ②: 保存/切换栈指针 ★ ─────
movq %rsp, THREAD_SP(%rdi) ; prev->thread.sp = 当前 RSP
; ← "记住我停在哪了"
movq THREAD_SP(%rsi), %rsp ; RSP = next->thread.sp
; ← ★★★ 全世界在这一行换了!
; 对于新线程: next->thread.sp =
; copy_thread 里设置的 childregs 地址
; 即 → 指向伪造的 pt_regs 顶部
; ───── 步骤 ③: 从新栈恢复现场 ─────
popq %r15 ; 从新线程的内核栈弹出
popq %r14
popq %r13
popq %r12
popq %rbx
popq %rbp
; ───── 步骤 ④: "返回"到新线程的执行流 ─────
ret ; ★ 从新线程的内核栈 pop 返回地址
; 对于新线程: 这个地址是 ret_from_fork
; (因为 p->thread.ip 栈上是这个值)逐步骤追踪:
时刻 CPU 寄存器 新线程内核栈(栈顶在上)
──────────────────────────────────────────────────────────────────
t0 RSP = 父线程内核栈 ┌────────────────────┐
(switch_to 尚未切栈) │ pt_regs │ ← next->thread.sp 指向这里
│ ... │
├────────────────────┤
│ inactive_task_frame│
├────────────────────┤
│ 返回地址: │
│ ret_from_fork │ ← ★
└────────────────────┘
t1 RSP = 父线程内核栈
(push 了 6 个 callee-saved 寄存器到父线程栈上)
t2 movq %rsp, prev->thread.sp
→ 保存了父线程的栈指针
t3 movq next->thread.sp, %rsp ┌────────────────────┐ ← ★ RSP 现在指这里!
→ ★★★ RSP 换了! │ pt_regs │
现在 RSP 指向新线程内核栈 │ RIP = start_thread│
的 pt_regs 顶部 │ RSP = child_stack │
│ CS = 0x33 │
│ ... │
├────────────────────┤
│ inactive_task_frame│
├────────────────────┤
│ ret_from_fork │ ← ret 指令从这里 pop
└────────────────────┘
t4 popq %r15 ~ %rbp
→ 从新线程栈弹出 callee-saved 寄存器(都是初始值/0)
t5 ret
→ pop 返回地址 = ret_from_fork
→ RIP = ret_from_fork
→ ★ 新线程的第一条内核指令被执行了!核心机制:
switch_to不需要"知道"跳转目标——它只是机械地做mov next->thread.sp, %rsp然后pop和ret。因为copy_thread()已经把ret_from_fork的地址写在了新内核栈正确的位置上(p->thread.sp指向的栈顶区域),ret指令自然就跳到了ret_from_fork。
问题 3:从 ret_from_fork 如何找到用户态入口代码?
ret_from_fork 执行时,RSP 恰好指向伪造的 pt_regs(见上一步 t5 时刻的栈图)。它走的是标准的系统调用返回路径:
; arch/x86/entry/entry_64.S
ret_from_fork:
; RSP → pt_regs(copy_thread 伪造的)
call schedule_tail ; 调度后清理
; 检查是否需要处理信号/seccomp
testb $3, CS-ORIG_RAX(%rsp)
jnz ret_from_fork_orig
; ★ 走标准 syscall 返回路径
; GLOBAL(entry_SYSCALL_64_after_hwframe) 之后的逻辑:
;
; 从 pt_regs 恢复通用寄存器:
; pop rax, pop rcx, pop rdx, pop rsi, ...
;
; 然后 sysretq:
; RIP := RCX ← pt_regs.rip = start_thread (glibc 用户态地址)
; RSP := R11 ← pt_regs.rsp = child_stack
; CS := MSR_STAR[63:48] + 16 | 3 = 0x33
; SS := MSR_STAR[63:48] + 8 | 3 = 0x2B
; ★ CPL 从 0 变为 3,进入用户态
jmp .Lsysret_return用户态入口代码的定位过程:
copy_thread() 在创建时设置的 pt_regs:
┌──────────────────────────────┐
│ pt_regs.rip = 0x7ffff7xxx... │ → glibc 的 start_thread 函数地址
│ pt_regs.rsp = 0x7ffff73fe... │ → 新线程的用户栈顶
│ pt_regs.cs = 0x33 │ → 用户代码段
│ pt_regs.ss = 0x2B │ → 用户数据段
│ pt_regs.rax = 0 │ → clone() 返回值 = 0
└──────────────────────────────┘
│
│ sysretq 读取这些值
▼
┌──────────────────────────────────────┐
│ 用户态: RIP = glibc start_thread │
│ RSP = child_stack │
│ CS = 0x33 (CPL=3) │
│ RAX = 0 ("我是子线程") │
│ │
│ start_thread(pd): │
│ → ARCH_SET_FS(pd) // 设 TLS │
│ → pd->start_routine(pd->arg) │
│ → 用户入口函数 func(arg) 执行 ★ │
└──────────────────────────────────────┘两行关键赋值(
copy_thread中):
childregs->rip = start_thread→ 决定了用户态的第一条指令
p->thread.ip = ret_from_fork→ 决定了内核态的第一条指令
p->thread.sp = childregs(指向pt_regs顶部)确保switch_to切完栈后,ret弹出的地址是ret_from_fork,而ret_from_fork看到的pt_regs恰好在 RSP 位置。
完整的数据流图:谁在什么时候写了什么,谁在什么时候读了什么
创建时(copy_thread,父线程上下文):
────────────────────────────────────────
新线程
内核栈
[pt_regs] ← ① childregs->rip = glibc_start_thread
.rip = 0x7ffff... childregs->rsp = child_user_stack
.rsp = 0x7ffff... childregs->cs = 0x33
.cs = 0x33 childregs->ax = 0
...
[inactive_frame]
[返回地址] ← ② 栈上写入 ret_from_fork 地址
task_struct
thread.sp ──────────→ ③ 指向 pt_regs 顶部
thread.ip = ret_from_fork
────────────────────────────────────────
↓ 调度时刻(某 CPU 的 context_switch)
────────────────────────────────────────
switch_to(prev, next):
④ 读 next->thread.sp
⑤ mov next->thread.sp, %rsp → RSP = pt_regs 顶部
⑥ pop %r15...%rbp → 从新栈恢复 callee-saved 寄存器
⑦ ret → 从新栈 pop 返回地址
= ret_from_fork
────────────────────────────────────────
↓ 进入 ret_from_fork
────────────────────────────────────────
ret_from_fork:
⑧ RSP 正指向 pt_regs(第⑤步设置)
⑨ call schedule_tail
⑩ jmp syscall 返回路径
⑪ 读 pt_regs.rip → RCX
读 pt_regs.rsp → R11
读 pt_regs.cs → 用户 CS
⑫ sysretq → CPL=3, RIP=glibc_start_thread ★2.4 阶段三:子线程被调度 —— ret_from_fork → 用户态
wake_up_new_task() 将子线程加入运行队列后,父线程继续执行——clone 系统调用返回到 glibc,pthread_create 返回到用户代码。而子线程的"第一条指令"何时执行,完全由调度器决定。
内核栈的伪造机制和
switch_to→ret_from_fork→sysretq的栈切换全流程,已在上一节(§2.3.4)逐步骤拆解,本节不再重复。
2.4.1 子线程首次被调度时的内核路径
调度器选中新线程(由某 CPU 核的 pick_next_task 选中)
→ context_switch(prev, next) // next = 子线程
├─ switch_mm() // 同进程线程 → 共享 mm → CR3 不变
└─ switch_to(prev, next) // ★ 切换寄存器现场
→ mov next->thread.sp, %rsp // RSP → 子线程内核栈(指向 pt_regs)
→ pop callee-saved 寄存器
→ ret // pop 返回地址 = ret_from_fork
→ ★ 执行流"变成"了子线程,第一条内核指令: ret_from_fork2.4.2 ret_from_fork 的执行流程
; arch/x86/entry/entry_64.S (简化)
ret_from_fork:
; 此时跑在子线程的内核栈上,pt_regs 已就位
call schedule_tail ; 调度后清理: 释放 prev 的引用
; 若设置了 need_resched,可能会再次 schedule
; 检查是否有系统调用工作 (seccomp / trace)
testb $3, CS-ORIG_RAX(%rsp)
jnz ret_from_fork_orig
; 没有额外工作 → 直接返回用户态
; pt_regs 已经指向:
; RIP → glibc 的 start_thread
; RSP → 新线程的用户栈
; CS → 0x33 (用户态)
; SS → 0x2B
jmp .Lsysret_return ; → sysretq → 用户态schedule_tail() 的作用:它标志着"上一个 task 已经被我们彻底抢占成功了",做一些引用计数清理和抢占标志检查。如果有信号待处理,也会在这里处理。
2.4.3 sysretq → 进入用户态
sysretq (CPU 硬件操作):
CS.Selector := MSR_STAR[63:48] | 3 = 0x33 (用户代码段, RPL=3)
SS.Selector := MSR_STAR[63:48] + 8 | 3 = 0x2B (用户数据段)
RIP := RCX → 即 glibc 的 start_thread 地址
RSP := R11 → 即新线程的用户栈顶
RFLAGS := R11 (来自 pt_regs)
★ CPL 从 0 变为 3 —— 已进入用户态关于 sysretq 如何切换段寄存器的完整机制,见 segment-management §五。
2.5 阶段四:用户态 start_thread → 真正的线程入口函数
子线程进入用户态后,第一个运行的函数是 glibc 内部的 start_thread(注意:这不是内核的 start_thread(),而是 NPTL 中同名但完全不同的用户态函数):
// nptl/pthread_create.c (简化)
static void *
start_thread(void *arg)
{
struct pthread *pd = (struct pthread *)arg;
// arg 是 clone 时通过 CLONE_SETTLS 传递的 TCB 指针
// 1. 完成 TLS 初始化: arch_prctl(ARCH_SET_FS, pd)
// 确保 FS 段基址指向当前线程的 TCB
// (clone 时内核已设置 fsbase,这里做用户态补全)
// 2. 注册线程退出时的清理函数
__pthread_unwind_next(&pd->cancel_jmp_buf);
// 3. 设置线程的亲和性(如果通过 pthread_attr 指定了)
if (pd->cpuset)
set_thread_affinity(pd);
// 4. 注册 atfork 处理(如果 fork 时需要在子进程中重置锁)
// 5. ★ 调用用户传入的线程入口函数
void *retval = pd->start_routine(pd->arg);
// ↑ 这就是 pthread_create(&tid, NULL, func, arg) 里的 func
// 6. 入口函数返回 → 线程退出
__pthread_cleanup_upto(...);
__do_cancel();
// 最终: exit(0) → _exit 系统调用 → 内核回收线程
}gdb 取证:当你在一个线程入口函数打断点时,
bt(backtrace)会显示:
#0 my_thread_func(arg) ← 你的线程入口
#1 start_thread(arg) ← glibc NPTL 内部
#2 ?? ← clone 伪造的栈帧,没有符号
你永远看不到内核栈帧(
ret_from_fork、schedule等),因为 gdb 只能回溯用户态栈帧。
2.6 阶段五:父线程视角 —— clone 返回
父线程调用栈(clone 返回后):
═══ 内核态(clone 返回路径) ═══
kernel_clone() 返回子线程 tid
→ sys_clone() 返回
→ 系统调用出口: sysretq
═══ 用户态 ═══
glibc clone 封装:
pd->tid = 内核返回的 tid
(若 clone 时用了 CLONE_PARENT_SETTID,内核已自动写入)
__pthread_create_2_1() 返回
pthread_create() 返回 0 (成功) —— ★ 此时子线程可能还没跑过一条指令!父线程和子线程现在都是 TASK_RUNNING 状态,在同一个或不同 CPU 核的运行队列中,由调度器独立调度——两者是真正的并发关系。
2.7 子线程启动完整调用栈
════════════════ 用户态(子线程)═════════════
my_thread_func(arg) ★ 你的线程函数
↑ pd->start_routine(pd->arg) 调用
start_thread (glibc NPTL) glibc 内部
├─ ARCH_SET_FS → 设置 TLS
└─ 调用 pd->start_routine(pd->arg)
════════════════ sysretq ═══════════════════
↑ CPU 从 pt_regs 恢复: RIP→start_thread, RSP→新用户栈
════════════════ 内核态(子线程首次被调度)═══
ret_from_fork arch/x86/entry/entry_64.S
├─ schedule_tail() 释放 prev、检查信号
└─ jmp .Lsysret_return → 返回用户态
↑ p->thread.ip 指向这里
context_switch(prev, p) 内核调度
├─ switch_mm() CLONE_VM → CR3 不变
└─ switch_to(prev, p) ★ 执行流切换点
↑ pick_next_task() 选中子线程
schedule() 调度入口
↑ (某个触发原因: tick/阻塞/唤醒抢占)
════════════════ 内核态(父线程创建时)═══════
copy_thread() 伪造子线程内核栈上的 pt_regs
p->thread.ip = ret_from_fork ★ 首次被调度时的入口
childregs->rip = start_thread ★ 返回用户态后的入口
childregs->rsp = child_stack ★ 新线程的用户栈顶
childregs->cs = __USER_CS (0x33) ★ 用户代码段
childregs->ss = __USER_DS (0x2B) ★ 用户数据段
wake_up_new_task(p)
p->state = TASK_RUNNING
→ enqueue_task(rq, p) 加入运行队列
copy_process() 创建 task_struct、复制资源
kernel_clone() clone 调度层
════════════════ syscall ═══════════════════
↑ 父线程执行 clone() 系统调用
════════════════ 用户态(父线程)═══════════
pthread_create(&tid, NULL, func, arg) 父线程的用户态入口三、调度器何时介入 —— 三张时序图
3.1 主线程:调度器不介入(execve 直接在当前 task 上"换脑")
时刻 主线程 (PID 100 → 仍是 PID 100)
─────────────────────────────────────────────
t0 用户态: execve("./program", ...)
t1 内核态: sys_execve → ... → load_elf_binary
t2 内核态: start_thread() 伪造 pt_regs
t3 内核态: execve 返回路径 → sysretq
t4 用户态: _start (新程序,PID 不变)
─────────────────────────────────────────────
★ 全程没有 schedule(),没有 context_switch()
★ PID 不变——只是"同一个人换了新的思维"3.2 子线程:clone 的两种调度时序
时序一:子线程先跑(wake_up_new_task 触发抢占)
父线程 子线程 (新 tid)
─────────────────────────────────────────────
用户态: pthread_create()
→ clone 系统调用
内核态: copy_process() ← 尚不存在
内核态: wake_up_new_task() ← 被创建,加入运行队列
→ check_preempt_curr():
子线程 vruntime < 父线程 → 置 need_resched
内核态: clone 返回 (tid)
系统调用出口: 检测到 need_resched
→ schedule()
→ context_switch(父→子) ─────→ 内核态: ret_from_fork ★
父线程 → TASK_RUNNING (入队) → schedule_tail()
→ sysretq
用户态: start_thread (glibc)
→ my_thread_func()
...在运行队列中排队... ★ 子线程已经在跑我们的代码了!
某时刻被重新调度:
用户态: pthread_create 返回把上面这种"子线程先跑"的抢占时序抽成参与者交互图,关键角色只有三个:父线程、调度器、子线程。

这张图里最反直觉的一点是:pthread_create 返回和子线程开始跑之间没有任何先后保证。时序一里父线程甚至还没从 pthread_create 返回到用户态,子线程的第一条业务指令就已经在另一个核上执行了——因为 wake_up_new_task() 里的 check_preempt_curr() 发现新线程 vruntime 更小,直接给父线程挂了 need_resched,clone 的系统调用出口顺势就切走了。所以任何依赖"父线程先做完某件事"的逻辑,都必须显式同步,不能赌时序。
时序二:父线程先跑完 pthread_create(子线程在队列中等)
父线程 子线程 (新 tid)
─────────────────────────────────────────────
用户态: pthread_create()
→ clone 系统调用
内核态: copy_process() ← 尚不存在
内核态: wake_up_new_task() ← 被创建,加入运行队列
→ check_preempt_curr(): 不触发抢占
(子线程 vruntime 不够小,或有其他原因)
内核态: clone 返回
sysretq
用户态: pthread_create 返回 ★
用户态: 父线程继续执行后续代码... ...在运行队列中等待...
用户态: 父线程做别的事情... ...仍在等待...
[某个 tick 或父线程阻塞] 某核: pick_next_task() 选中子线程
→ schedule() ─────→ 内核态: ret_from_fork ★
→ context_switch(父→子) → sysretq
用户态: start_thread
→ my_thread_func()
...在运行队列中排队... ★ 子线程终于开始跑了实际生产中两种时序都有,glibc 的
pthread_create专门不做任何保证。如果你的代码依赖于"父线程先跑完 xx 再让子线程跑 yy",请用pthread_join、条件变量、或 barrier 等同步原语。
3.3 子线程退出后父线程如何"回收"
子线程 父线程
─────────────────────────────────────────────
my_thread_func() 返回
→ start_thread 获取返回值
→ __pthread_cleanup_upto()
→ exit(0) → _exit 系统调用
内核态: do_exit()
→ 释放资源、通知父进程 (SIGCHLD)
→ task_struct 变为 zombie
→ CLONE_CHILD_CLEARTID: 清除 child_tid
→ schedule() — 永不返回 (若 pthread_join)
用户态: pthread_join(tid, &retval)
→ futex(FUTEX_WAIT, &tid)
内核态: clone 时设置的
CLONE_CHILD_CLEARTID
触发 futex_wake
用户态: pthread_join 返回
拿到子线程的返回值四、主线程 vs 子线程启动:逐阶段对比
| 阶段 | 主线程 | 子线程 |
|---|---|---|
| 触发系统调用 | execve(path, argv, envp) | clone(flags, child_stack, ...) |
| 内核核心函数 | load_elf_binary() | copy_process() |
| 地址空间处理 | flush_old_exec() 销毁旧 mm → 全新 mm | CLONE_VM → 共享父线程的 mm |
| 内核栈 | 复用当前 task 的内核栈(原 program 的) | alloc_thread_stack_node() 分配新的 ~16KB |
| 用户栈 | create_elf_tables() 在内核中构建初始栈 | glibc mmap 分配 ~8MB,栈顶传给 clone |
| pt_regs 来源 | start_thread() 覆盖原 pt_regs | copy_thread() 先复制父 pt_regs 再局部修改 |
| CS/SS | CS=0x33, SS=0x2B(都一样) | CS=0x33, SS=0x2B(都一样) |
| RIP(用户态) | ELF entry (_start 或 ld.so) | glibc 的 start_thread |
| RSP(用户态) | 内核构造的新用户栈顶 | glibc 分配的线程栈顶 |
| RAX(返回值) | execve 成功后不返回(返回值无意义) | 0(clone 约定:子进程/子线程返回 0) |
| 内核态起点 | syscall 返回路径(被劫持到 entry) | ret_from_fork |
| 第一个用户函数 | _start(crt1.o) | start_thread(glibc NPTL) |
| 业务入口 | main(argc, argv) | func(arg) |
| 调度介入 | 不介入——execve 全程在当前 task 上 | 介入——wake_up_new_task 加入运行队列 |
| TLS 设置 | __libc_start_main → __tls_init_tp() | clone 时 CLONE_SETTLS + start_thread 补全 |
| 进程/线程 ID | PID 不变 | 新 tid,同 PID(CLONE_THREAD) |
五、关键系统调用一览
| 系统调用 | 触发位置 | 作用 | 本文出现位置 |
|---|---|---|---|
| execve | shell / 进程内部 | 销毁当前进程镜像,加载新 ELF,伪造返回现场到 _start | §一 主线程启动 |
| clone | glibc pthread_create | 创建新 task,根据 flags 共享/复制资源,子 task 从 ret_from_fork 开始 | §二 子线程启动 |
| mmap | glibc NPTL allocate_stack | 为用户线程分配独立栈空间 (~8MB) | §2.2 阶段一 |
| arch_prctl(ARCH_SET_FS) | glibc __tls_init_tp / start_thread | 设置 FS 段基址 → 线程局部存储 (TLS) 的硬件支撑 | §1.5 / §2.5 |
| futex(FUTEX_WAIT/FUTEX_WAKE) | pthread_join / 子线程 exit | 线程退出时唤醒等待者,传递返回值 | §3.3 线程退出 |
| _exit | pthread_exit / thread return | 终止当前线程,触发 do_exit() → zombie → 父回收 | §2.5 / §3.3 |
| sched_yield | 用户主动让出 | 当前线程放弃 CPU,立即重新调度 | 未出现在主路径,但常在热点路径中 |
六、与仓库其他文档的关系
- thread-creation —— 线程创建的整体框架(
pthread_create→clone、join/detach、结论性总结),本文为其执行流程深化版,聚焦内核函数调用链和切换细节。 - segment-management ——
CS=0x33/SS=0x2B从哪里来、ARCH_SET_FS怎么设置fs_base、syscall/sysret怎样切换段寄存器——本文多处依赖该文档的结论。 - scheduling —— 运行队列结构(CFS 红黑树)、
pick_next_task选人逻辑、调度类分层、CFS vruntime 公平性——本文 §三 的时序分析依赖这些概念。 - context-switch ——
schedule()→context_switch()→switch_mm()/switch_to()的完整实现,本文 §2.4.1 直接引用。 - process-creation ——
fork()+execve()的整体框架,本文 §一 是其中 execve 部分的执行流程深化。 - syscall-details ——
syscall指令如何陷入内核、pt_regs如何保存/恢复,本文在start_thread()和ret_from_fork两处涉及。 - compile-link-load —— ELF 加载、
_start→__libc_start_main→main的静态/动态链接路径,本文 §1.5 直接引用。 - x86-64-registers —— FS/GS/CS/SS 寄存器的角色、MSR_STAR 的设置,本文多处涉及。
一句话总结:主线程的出生是"借尸还魂"——
execve借当前 task 的内核栈和 syscall 返回路径,通过start_thread()伪造pt_regs,在sysretq后出现在_start;子线程的出生是"凭空造人"——clone在内核里创建全套新task_struct,通过copy_thread()在新内核栈上伪造pt_regs并指向ret_from_fork,由调度器选中后跳入用户态start_thread(glibc)。两者的共同点是:它们的第一条"用户态指令"都不是你的代码——中间都隔着 glibc 的启动框架。