3 — fork() and exec(): making a process
What does the kernel copy when a process forks, what does it merely share, and what exactly happens when the child calls execve?
Requirements: the machine from chapter 0, with bpf in /sys/kernel/security/lsm. It helps to read chapter 5 first.
The fork path
glibc’s fork() is clone() with SIGCHLD | CLONE_CHILD_SETTID | CLONE_CHILD_CLEARTID (arch-fork.h). It arrives at kernel_clone (kernel/fork.c), which calls copy_process. Keep the source open and step through it:
dup_task_structcopies thetask_structand allocates a kernel stack, 16 KiB by default or 32 KiB with KASAN (page_64_types.h).copy_credshandles credentials.sched_forksets up scheduling state, andtask_fork_fairprepares the child, though it doesn’t place it yet.copy_filescopies the fd table. The child gets the samestruct files, so offsets are shared.copy_fs,copy_sighand,copy_signalfollow.copy_mmcallsdup_mmap, usually the expensive one. Every VMA is copied.copy_page_rangecopies page tables, marking writable anonymous pages read-only on both sides, which sets up copy-on-write.vma_needs_copycan skip some VMAs entirely (memory.c).CLONE_VMskips all this, and that’s what makes a thread.copy_namespaces,copy_io,copy_threadcome next.copy_threadsets the child’sraxto 0, which is whyfork()returns 0 there.alloc_pidgets a PID, and the child joins its parent’s cgroup throughcgroup_post_fork(chapter 11).
Back in kernel_clone, the sched_process_fork tracepoint fires first, and then wake_up_new_task runs. That function reaches activate_task and place_entity, which is where the child is actually placed (fork.c).
The exec path
do_execveat_common (fs/exec.c) calls alloc_bprm, which opens the file. do_execveat_common then copies the arguments and environment into new stack pages. Next:
bprm_execvereachesexec_binprmandsearch_binary_handler, which tries the registered formats.security_bprm_checkruns there. On ELF files,load_elf_binary(fs/binfmt_elf.c) takes over.load_elf_binaryreads the headers and findsPT_INTERP, the dynamic loader, if any.begin_new_execis the point of no return. It kills other threads withde_thread, andexec_mmapreleases the old address space.do_close_on_execclosesO_CLOEXECfiles before signal handlers are reset. Credentials change later, incommit_creds(exec.c).setup_new_execandsetup_arg_pagesbuild the stack. EachPT_LOADsegment is mapped, and the interpreter too.create_elf_tableswrites the auxiliary vector, includingAT_PHDR,AT_ENTRYandAT_RANDOM.start_threadsets the instruction pointer to the entry point and the stack pointer to the new stack. The return to user mode lands in a different program, andsched_process_execfires.
Hands-on: trace it
-
Measure fork latency:
bpftrace -e 'kprobe:copy_process { @s[tid] = nsecs; } kretprobe:copy_process /@s[tid]/ { @us = hist((nsecs - @s[tid]) / 1000); delete(@s[tid]); }' -
See how it scales. Write
f.c, which maps and touches N MiB, forks, and has the child_exit(0). Run it at 10 and 1024, and graph each:cd /sys/kernel/tracing grep -w -E 'dup_mmap|copy_page_range' available_filter_functions echo copy_process > set_graph_function; echo 6 > max_graph_depth echo function_graph > current_tracer; ~/f 1024; cp trace /tmp/f1g.txt echo nop > current_tracer; echo > set_graph_functionLook for: where the time goes. If
dup_mmapis missing on your build, readcopy_page_rangeinstead. -
Prove the order of events:
bpftrace -e 'tracepoint:sched:sched_process_fork { printf("fork tp %d\n", args.child_pid); } kprobe:wake_up_new_task { printf("wake\n"); } tracepoint:sched:sched_prepare_exec { printf("prepare_exec\n"); } kprobe:begin_new_exec { printf("begin_new_exec\n"); } tracepoint:sched:sched_process_exec { printf("exec tp\n"); }'Expected:
fork tpbeforewake, thenbegin_new_exec,prepare_execandexec tp. Runbpftrace -lfirst to confirmsched_prepare_execexists. -
Count mappings. Compare the count with
readelf -lW /bin/true | grep LOAD, plus the interpreter’s segments:bpftrace -e 'kprobe:vm_mmap /comm == "true"/ { @ = count(); }'Don’t assume one mapping per segment. Count, then explain the number.
Hands-on: change it
Write an LSM-BPF program that refuses to run one file.
-
Generate
vmlinux.h:bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h -
Write a libbpf program with
SEC("lsm/bprm_check_security")that returns-EPERMwhen the file’s inode matches one you choose. -
Attach it, then try to run that file.
-
Expected:
execveshould returnEPERM, the old process should survive to see the error, andkprobe:begin_new_execshould not fire. The refusal comes after the file was opened and before the point of no return. -
Compare it with the seccomp block from chapter 1: that’s earlier and blunter, with no knowledge of the file.
If it fails
- Attach succeeds but nothing is blocked:
bpfisn’t in the active LSM list. - Odd
load_elf_binaryresults: some builds have native and compat versions. - Mapping counts look high: a dynamic binary includes the loader’s mappings.
The takeaway
Copy what must diverge; share what won’t; defer the rest. fork shares the fd table’s files, the signal handlers, and (until written) every page; it copies only the metadata needed to let the two processes go their own way. exec then discards almost all of it. The design is a worked example of choosing, for each piece of state, between copy, share and copy-on-write: the decision every cache, every snapshot and every container image makes.