进程与线程
进程
-
进程就是处于执行期的程序
-
实际构成:不仅仅是代码段!还包括打开的文件、挂起的信号、内核数据、内存地址空间及执行线程 。
-
在 Linux 内核眼中,没有严格意义上的线程 !
-
在内核中使用task_struct 结构体来表示一个线程或者进程
-
一个task_struct大约为1.7kb,一个系统的进程线程使用双向循环链表存储
-
Linux 底层管理物理内存使用的是“伙伴系统”,它的最小分配单位是页(Page,通常是 4KB)。 如果内核只需要为一个 task_struct(假设大小为 1.5KB左右)分配内存,找伙伴系统要一个页(4KB),那剩下的 2.5KB 就浪费了。这种浪费叫做内部碎片。而且,内核中存在大量频繁创建和销毁的细碎结构体(比如进程描述符、文件 inode、网络数据包等),如果每次都向伙伴系统申请和释放整页,不仅浪费内存,效率还极低。
-
为了解决这个问题,Slab 分配器应运而生。Slab 分配器会向伙伴系统批发一大块内存(几个连续的页),然后把这块内存像切豆腐一样,切成一个个固定大小的小块。每个小块专门用来存放同一种数据结构。比如,有一个专门给 task_struct 用的 Slab 缓存池,里面的每个格子都刚好能放下一个 task_struct。
-
如果很多相同数据结构的地址在物理内存中的偏移量完全一样,它们在被加载到 CPU 硬件高速缓存(L1/L2 Cache)时,很容易映射到同一条缓存线上,导致频繁的缓存冲突和替换(Cache Thrashing)。Slab 会故意在分配这些对象时,给它们的起始地址加上一点点不同的偏移量(就像涂上不同的颜色),让它们均匀地散布在 CPU 缓存里,极大提升了硬件缓存的命中率。
如何找到当前进程线程的指针
- 在早期的linux X86上,操作系统会在栈尾端创建 thread_info 结构,通过屏蔽栈指针后 13 位有效位算出偏移量,间接找 task_struct
- thread_info 被放在内核栈的底部。这带来了一个致命的安全隐患:栈溢出攻击。黑客发现,如果内核代码有漏洞导致往栈里写的数据超出了界限,就会顺势往下覆盖掉底部的 thread_info。一旦黑客修改了 thread_info 里面的权限标志位(比如 addr_limit),就能轻易拿到 root 权限。
- 搬家: 把 thread_info 直接从内核栈底移走,塞进 task_struct 内部,成为 task_struct 的第一个成员
- 内核为每个 CPU 核心分配了一个独立的变量,叫 current_task,里面存着当前正在这个 CPU 上跑的进程的 task_struct 地址。
- RISC/ARM 等架构的解法:由于寄存器富余,直接拿出一个专属寄存器保存当前进程指针
进程的状态
🏃 TASK_RUNNING (运行):正在执行或排队等待 。
😴 TASK_INTERRUPTIBLE (可中断):睡眠等待条件,收到信号可唤醒 。
🪨 TASK_UNINTERRUPTIBLE (不可中断):深度睡眠,对信号完全不响应 。
🛑 TASK_TRACED / TASK_STOPPED:被调试或停止运行
进程是怎么样创建的
Linux 创建进程最经典的 fork() + execve() 两步走战略
-
你在终端编译出来的 a.out 或者任何可执行文件,在 Linux 眼里就是一个有着严格格式的文件(ELF)。它里面分门别类地装好了: .text 段: 你的机器码指令(只读)。 .data / .bss 段: 你的全局变量和静态变量。 入口地址: 告诉系统,程序启动后的第一条指令在哪。
-
Linux 极少凭空捏造一个新进程。当你在终端敲下 ./my_program 回车时:
- 终端(Shell 进程)会调用 fork() 系统调用,克隆出一个几乎一模一样的子进程。
- 此时,内核会为子进程分配一个全新的 task_struct,但它的内存空间、执行的代码,全都和 Shell 一模一样。这是一个“没有自己灵魂的空壳”。
- 子进程紧接着调用的 execve() 系统调用
execve()指令
int execve(const char *pathname, char *const argv[], char *const envp[]);
-
pathname(身体): 你要执行的那个可执行文件的绝对路径或相对路径(比如 /bin/ls 或者 ./my_program)。
-
argv(台词): 传递给新程序的命令行参数数组。也就是我们在 C 语言 main(int argc, char *argv[]) 里接收到的那个 argv。数组必须以 NULL 结尾。
-
envp(生存环境): 传递给新程序的环境变量数组(比如 PATH=…, USER=…),同样以 NULL 结尾
第一步:找文件与验明正身。 内核根据 pathname 找到磁盘上的文件,检查你有没有执行权限(x 权限)。然后读取文件的前几个字节(魔数,Magic Number),判断这是个什么类型的文件。如果是 Linux 标准的可执行文件,通常是 ELF 格式(以 \x7fELF 开头);如果是脚本,可能会看到 #!(Shebang)。
第二步:砸掉旧房子(清空内存)。 内核会找到当前进程的内存大管家 mm_struct,把旧程序之前申请的虚拟内存空间(代码段、数据段、堆、栈)全部解除映射并清空。
第三步:按新图纸装修(解析 ELF 并映射)。 内核里的 ELF 加载器(load_elf_binary 函数)登场。它会读取 ELF 文件头里的信息,在进程的虚拟地址空间里重新划出地盘:
把文件里的 .text(代码)映射到内存。
把 .data(已初始化变量)映射到内存。
在最高地址处分配一块全新的空地作为用户栈(Stack)。
第四步:搬入新家具(拷贝参数)。 把你传入的 argv(命令行参数)和 envp(环境变量)小心翼翼地拷贝到刚刚建好的那个全新用户栈的栈底。这样新程序的 main 函数一启动,就能从栈里拿到这些参数。
第五步:修改命运的指针(设置入口点)。 一切准备就绪,内核会修改保存在内核栈上的 CPU 寄存器现场(比如 ARM64 的 elr_el1,x86 的 rip)。把它强行指向 ELF 文件头里写明的**“程序入口地址”**(Entry Point)。
fork()的调用
fork()、vfork() 乃至各种线程库,底层统统调用 clone()
把 clone() 比作一个拥有无数开关的机器。开哪些开关,决定了生出来的是“普通进程”还是我们俗称的“线程

进程树
在task_struct 结构体中有一个parent参数就是记录他的父进程,所有的进程都是 PID 为 1 的 init 进程的后代,也是系统启动时最先启动的进程,所以进程也可以表示为一个树,init进程时父节点
这里init进程启动时会读取系统的初始化脚本(initscript)并执行,在实际工程中也经常修改这些开机脚本来实现对应程序的自启动
孤儿进程
异常情况:亲爹(父进程)先死了怎么办? 寻父机制 (find_new_reaper): 优先在当前线程组内找一个线程当“养父” 。 如果组里没亲戚了,就甩锅给全局的 init 进程兜底 。