☰
Linux进程创建:kernel_clone与copy_process解析
2026/10/22 4:46:03 网站建设 项目流程

1. Linux 5.x进程创建的入口:kernel_clone是如何出现的

1.1 老do_fork函数的问题

我最早看内核源码时,进程创建这条线还叫do_fork()。那时候无论是fork()、vfork()还是clone(),最终都会汇聚到do_fork()里,函数签名很长,参数七八个,从clone_flags到stack_start、stack_size、parent_tidptr、child_tidptr、tls,一眼看去全是裸参数。每次新增一个系统调用想要复用这套逻辑,都得把这个长参数表再抄一遍。更麻烦的是,不同架构在传递这些参数时习惯又不一样,x86_64下第五个参数要走r10而不是rcx,ARM上又是另一套寄存器规则,所以各架构的sys_clone包装层充斥着类似“从寄存器里抠出参数再传给do_fork”的重复代码。

到了Linux 5.3左右,内核社区终于把这套东西收拾利索了:do_fork()被改名成kernel_clone(),参数也被打包成一个struct kernel_clone_args结构体。你去看5.x内核的kernel/fork.c,kernel_clone()已经成为一个干干净净的入口,所有进程创建逻辑都从这里进去,不同系统调用之间的差异被压缩到“组装args结构”这一步。对于长期读源码的人来说,这个改动最大的价值是:你再也不用被那一大串参数搞晕,只需要关心args里每个字段怎么填、flags怎么组合。

1.2 kernel_clone与kernel_clone_args

kernel_clone()的核心参数结构如下,不同5.x小版本字段略有微调,但主体就是这些:

struct kernel_clone_args { u64 flags; // CLONE_* 标志位 int __user *pidfd; // 返回 pidfd 时用 int __user *child_tidptr; // 子线程的 tid 地址 int __user *parent_tidptr; // 父线程的 tid 地址 const void *stack; // 子进程/子线程栈指针 unsigned long stack_size; // 栈大小 unsigned long tls; // TLS 描述符 pid_t *set_tid; // 在指定 PID 命名空间设置 tid size_t set_tid_size; // set_tid 数组大小 int cgroup; // 目标 cgroup 文件描述符 };

kernel_clone()内部做的事情,用伪代码概括大概是这个样子:

pid_t kernel_clone(struct kernel_clone_args *args) { // 1. 校验 flags 中未知位 // 2. 根据 args 组织复制范围 // 3. 调用 copy_process 复制父进程 // 4. 分配 pid,并处理 pidfd // 5. wake_up_new_task 把新进程挂入就绪队列 }

你注意这里有个关键点:kernel_clone()返回的pid_t,对调用者来说只是“新进程的PID”,但真正的新进程并不是完全从头创建,而是在copy_process()阶段把父进程的task_struct、内存描述符、文件表、信号处理函数等一堆东西按需复制或共享。所以理解kernel_clone,本质上就是理解“哪些资源被复制、哪些资源被共享、新进程从哪条指令开始执行”。

1.3 fork/vfork/clone/clone3如何汇聚到这里

五个.x时代,用户态能触发进程创建的入口系统调用主要是四个:fork()、vfork()、clone()、clone3()。它们各自的包装层不同,但进入内核后最终都会调到kernel_clone():

  • sys_fork:只传SIGCHLD作为默认flags,栈相关参数都是0。因为fork语义就是完整复制地址空间。
  • sys_vfork:在SIGCHLD基础上叠加CLONE_VM | CLONE_VFORK,意思是子进程先借用父进程地址空间,且父进程要阻塞到子进程exec或退出。
  • sys_clone:用户态通过寄存器传一堆参数,内核根据架构从pt_regs里读取,填好args后调用kernel_clone。
  • sys_clone3:这是5.3新增的系统调用,用户态传一个struct clone_args结构体,内核用copy_struct_from_user()拷贝并校验后再调用kernel_clone。

所以kernel_clone并不是一个用户可以直接调用的东西,它是一个内核内部函数。你如果去看源码,会发现PR_SET_MM之类的特殊路径在某些版本里也会间接用到这套机制。对于驱动开发者和做容器、性能分析的人来说,掌握kernel_clone的入口结构,比背系统调用号更有实际意义,因为你需要判断一个新进程到底是以“线程”还是“进程”的身份出现,调度器和内存管理会如何对待它,这些行为的根源都在args的flags组合里。

2. kernel_clone的主流程:一个新进程到底经历了什么

2.1 copy_process:复制与共享的分水岭

kernel_clone()真正最核心的调用是copy_process()。这个名字很直白,但别被“copy”误导,它不是所有东西都复制,而是“该复制的复制,该共享的共享”。决定权完全在args->flags里的CLONE_*标志。

flags作用对象行为
CLONE_VM地址空间mm父子共享同一个mm_struct,不复制页表
CLONE_FILES文件描述符表files父子共享同一个files_struct
CLONE_FS文件系统上下文fs_struct共享根目录、当前目录、umask
CLONE_SIGHAND信号处理函数表共享信号处理器和信号位图
CLONE_THREAD线程组加入同一个线程组,共享tgid
CLONE_NEWNS/NEWPID/NEWNET等命名空间创建新命名空间或加入指定命名空间

copy_process()的内部步骤,按我阅读源码时的顺序大概是这样:先做一系列权限与资源检查,接着dup_task_struct()复制task_struct和内核栈,然后sched_fork()初始化调度相关字段,再依次调用copy_semundo()、copy_files()、copy_fs()、copy_sighand()、copy_signal()、copy_mm()、copy_namespaces()、copy_io()、copy_thread()。每一步都会根据flags决定是“深拷贝一个结构”还是“拿一个引用计数”。

很多人会问,为什么线程创建也走copy_process?因为Linux的线程本质上是“共享地址空间但不共享tgid、且共享信号处理函数”的进程。用户态线程库调用clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD, ...),进入kernel_clone后,copy_mm()看到CLONE_VM就直接把mm指针复制给子任务并增加引用计数,不会复制页表。所以线程创建比进程创建快很多,主要原因就在这里,而不是什么神奇的“轻量级进程”概念。

2.2 copy_thread:子进程的第一条指令

copy_thread()是个很容易被忽视但极其重要的函数。它决定了子进程第一次被调度执行时,CPU从哪里开始取指。在x86_64架构下,copy_thread()会把父进程的pt_regs拷贝到子进程的内核栈上,并把子进程的thread.ip设置为一个汇编入口:ret_from_fork。

为什么要这么设计?因为fork的返回语义很特殊:父进程返回的是子进程的PID,子进程返回的是0。这两个值在同一个函数栈帧里没法用普通return实现,因为子进程根本不是从kernel_clone()的调用点“返回”的,它没有那段调用历史。内核的做法是:子进程挂到运行队列后,第一次获得CPU时会从ret_from_fork这个汇编标签处进入执行,在这个路径里恢复pt_regs中的寄存器现场,最终以“从系统调用返回用户态”的方式切入用户空间。用户态看到的寄存器状态,和父进程刚进入fork系统调用时几乎一样,唯一被刻意改掉的是返回值寄存器(x86_64的rax)被设成0,于是子进程的fork()调用“看起来”返回了0。

理解这层设计,再看那些面试题里“fork之后父子进程从哪里继续执行”就非常清楚:父进程沿kernel_clone原路返回,子进程则绕道ret_from_fork进入用户态,二者在用户态看到的代码位置相同,但背后的内核执行路径完全不同。

2.3 wake_up_new_task:让新进程跑起来

copy_process()成功返回后,子进程还只是一个“静态”的task_struct,没有进入调度器的视野。kernel_clone()接着会调用wake_up_new_task(p),这一步是把新进程真正交给调度器。

wake_up_new_task()和普通的wake_up_process()有微妙差别。普通唤醒一个阻塞任务时,调度器要综合考虑负载、NUMA拓扑、CPU空闲情况,可能把任务迁移到更合适的CPU。但一个刚fork出来的子进程是个“热”任务,它的内存、缓存、TLB等很可能还和父进程相关,把它放到父进程当前运行的CPU上通常最划算。所以在wake_up_new_task()里会带一个WF_FORK标记,select_task_rq()看到这个标记后会更倾向于选择父进程所在的CPU,而不是到处找空闲核。同时在父进程上下文直接activate_task()调度,如果新进程优先级更高,就会触发抢占,让新进程尽快运行。

实际效果你也能观察到:在一个2核小机器上,如果主进程不断fork,子进程很快就跑起来了,而不是被扔到另一个CPU上等待。这就是kernel_clone流程里调度器为“新进程场景”做的专门优化。

3. clone3带来的参数革命:kernel_clone的新入口

3.1 为什么老的clone参数结构不够用

传统clone()的参数是散在寄存器里的,这带来一个很现实的问题:参数数量有限,每个架构的寄存器传参规则又不同。你没法随意加一个新参数而不破坏ABI。比如后来内核想支持“在指定PID命名空间中设置子进程的tid”,这个能力对容器场景很重要,但老的clone接口根本没地方传这个信息。再比如cgroup v2引入后,想支持“创建进程时直接放入某个cgroup”,同样需要一个新参数。

clone3()就是为这个而生的。它把参数打包成struct clone_args,每次内核扩展能力时,只需要往这个结构体里加字段,用户态传一个更大的size即可。这里面有几个比较新的字段值得关注:

  • set_tid和set_tid_size:允许调用者指定子进程在某个PID命名空间里的tid值,容器类软件非常依赖这个能力。
  • cgroup:配合CLONE_INTO_CGROUP标志,进程创建时直接加入指定cgroup,省去创建后再写cgroup.procs的竞态窗口。
  • pidfd:通过CLONE_PIDFD标志,让父进程拿到一个进程文件描述符,用于后续稳定地管理子进程生命周期。

这些能力如果硬塞到老clone()的参数列表里,基本不可能。clone3本质上是把“一次性替换系统调用接口”的机会利用起来,给内核未来扩展留了余地。

3.2 copy_struct_from_user的兼容性校验

clone3在用户态和内核态之间传递的结构体大小是动态的,内核处理这部分非常讲究,核心函数是copy_struct_from_user()。它的核心逻辑是:先看用户传的size和内核自己sizeof(struct clone_args)哪个大。

如果用户传的size比内核小,说明用户态程序比内核老,它不认识新字段。内核会把内核结构体中多出来的部分清零,保证老程序也不会读到垃圾值。如果用户传的size比内核大,说明用户态程序比内核新,它可能期待一些内核还没支持的字段。内核会先把自己认识的部分拷进来,然后把多出来的用户字节逐个检查,只要有任何一位非零,就直接返回E2BIG,拒绝调用。

这种“老内核跑新程序、新内核跑老程序”的兼容策略,在UAPI设计里非常经典。你在调试clone3相关问题时,如果遇到E2BIG,不用怀疑别的,十有八九是用户态传了一个内核不认识且非零的新字段。

3.3 系统调用表里的新面孔

在x86_64上,老的clone系统调用号是56,clone3是435。你去看arch/x86/entry/syscalls/syscall_64.tbl,会看到clone3后面明确标注了“64位专用”之类的说明,而老clone是历史遗留的、跨架构行为不完全一致的入口。

这里有个很容易混淆的点:clone3()在内核里的实现,虽然在kernel_clone()之前也要做copy_struct_from_user()解析,但它复用kernel_clone的方式和老clone是一样的。区别在于老clone的参数要靠架构相关代码从pt_regs里取,clone3的解析是架构无关的。所以clone3在移植性上反而更友好,新架构只要实现了通用的copy_struct_from_user(),就能很容易接上进程创建这套后端逻辑。

4. 用实践追踪kernel_clone:调试手段与常见误区

4.1 用tracepoint观察fork/vfork/clone

看源码是一回事,实际系统里进程创建到底走了哪条路,最好用内核tracepoint验证。最相关的tracepoint是sched_process_fork,它由kernel_clone()成功创建子进程后触发,参数里包含父进程comm、父进程pid、子进程comm、子进程pid。

用bpftrace可以做很轻量的实时观察:

bpftrace -e ' tracepoint:sched:sched_process_fork { printf("fork: %s(%d) -> %s(%d)\n", args->parent_comm, args->parent_pid, args->child_comm, args->child_pid); } '

输出结果类似:

fork: systemd(1) -> systemd-journal(234) fork: myservice(1024) -> myservice(1025)

如果你同时打开/sys/kernel/tracing/events/sched/sched_process_fork/enable,再读trace文件,效果类似。这个tracepoint触发的位置非常贴近kernel_clone的子进程创建成功点,比在内核里自己埋kprobe稳定得多。想深入分析某个进程为何频繁创建线程,可以配合perf record -e sched:sched_process_fork -a抓callchain,直接看到是哪个库函数在反复调用clone。

4.2 kernel_clone不是系统调用:地址与符号的误解

很多刚开始看内核代码的人会误以为,用户态syscall直接跳到kernel_clone。实际上kernel_clone只是一个普通的内核函数,它在/proc/kallsyms里能查到符号,但用户态没有对应系统调用号。系统调用号对应的是__x64_sys_clone、__x64_sys_clone3这些入口,这些入口做完参数解析后才会调kernel_clone。

排查问题时要注意这个层级。比如你想在自定义内核模块里挂钩进程创建,最优做法是注册fork相关的tracepoint或使用LSM的task_alloc钩子,而不是直接kprobekernel_clone。因为kprobekernel_clone能探测到的调用者只有内核函数,无法直接反映用户态发起者是哪个进程、通过哪个系统调用进来的。当然kprobekernel_clone对研究内核内部流程是没问题的,只是别把“系统调用”和“内部函数”混为一谈。

4.3 一次线程风暴定位过程

有次我在调试一个嵌入式设备,现象是运行一段时间后系统响应变慢,top里看到某个服务的CPU占用不高,但内存持续上涨。一开始怀疑内存泄漏,查了业务代码没找到明显问题。后来用tracepoint记录sched_process_fork,发现有一个进程在几十毫秒内连续创建上百个线程,每个线程存活时间极短,跑完就退出。

顺藤摸瓜拿到创建线程的调用栈,发现问题出在一个通用线程池组件的参数配置上:核心线程数被设成0,但队列积压时会无限制扩张线程,业务上某些请求又反复触发队列任务,导致线程频繁创建销毁。虽然最终业务代码是“罪魁祸首”,但如果没有tracepoint给出精确的创建时间线和调用栈,我可能还要在内核和用户态之间来回猜很久。这类问题在容器环境里尤其常见,因为cgroup对线程数可能有上限,一旦达到上限,clone会返回EAGAIN,应用表现就是莫名其妙的“资源不足”。

5. 我读kernel_clone源码时的几个心得

5.1 先看结构,再看流程

如果你刚接触内核源码,我建议不要一上来就钻进copy_process()的几十个步骤里。先花十分钟把struct kernel_clone_args的每个字段搞清楚,再打开kernel/fork.c从上到下理一遍kernel_clone()的骨架:校验flags、检查资源上限、copy_process复制、分配pid、wake_up_new_task唤醒。这个顺序本身就是最自然的理解路径。

拿到一个具体问题后再深入某一环。比如你关心“为什么线程创建这么快”,就重点看copy_mm()里CLONE_VM的处理;你关心“为什么vfork可能卡住父进程”,就去看CLONE_VFORK和completion的等待逻辑;你关心“为什么内核5.x后pid分配不一样了”,就去读alloc_pid()和set_tid在copy_process里的处理。按问题驱动去读源码,比从第一行读到最后一个函数高效得多。

5.2 把内核源码当作品控文档读

内核源码其实是一份非常严谨的“工程规范文档”,变量命名、注释习惯、错误处理路径都很有章法。读kernel_clone相关代码时,我习惯重点关注两类东西:一类是ENOMEM、EAGAIN、EINVAL这些返回值分别在什么条件下产生,这对定位用户态报错很有用;另一类是if (unlikely(...))和if (IS_ERR(...))分支里的处理逻辑,这些往往是边界条件和兼容性坑位。

最后分享一个小技巧:5.x不同minor版本里kernel_clone内部实现会有差异,比如结构体字段略有增减、copy_process里检查顺序变化。你读源码时最好锁定一个具体版本,比如5.10 LTS或你实际生产环境用的版本,并在代码里标注版本信息。这样遇到“这个版本有这个调用、另一个版本没有”的情况时,你就不会糊涂。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询