4. 进程与线程安全:PCB、进程隔离与线程模型

进程和线程,是操作系统里最基础的概念了。我做了这么多年内核安全,说实话,很多漏洞都出在对这两个东西的理解偏差上。今天咱们就好好聊聊,进程控制块(PCB)到底长什么样,进程之间怎么做到“老死不相往来”,以及内核线程和用户线程到底有啥区别。

4.1 进程控制块(PCB)—— 进程的“身份证”

每个进程在系统里都有一个唯一的“身份档案”,这就是PCB。内核用它来管理所有进程。你想想看,系统里同时跑着几百个进程,内核怎么知道谁是谁?全靠PCB。

PCB里存了啥?我列几个关键字段:

  • 进程标识符(PID):每个进程的唯一编号。我见过一些新手写驱动,直接拿PID当索引用,结果PID被回收后出了大问题。
  • 状态信息:运行、就绪、阻塞……内核调度器就是靠这个字段决定下一个该谁上CPU。
  • 寄存器上下文:进程切换时,CPU寄存器的值要保存到这里。嗯,这里要注意,如果保存不完整,切换回来程序就崩了。
  • 内存管理信息:页表基址、代码段/数据段边界。这是进程隔离的基础,后面细说。
  • 文件描述符表:进程打开了哪些文件、套接字。我曾经遇到过一个问题,子进程继承了父进程的文件描述符,结果父进程关了文件,子进程还在写,数据全乱了。

核心要点:PCB是内核中最重要的数据结构之一。它的安全性直接决定了整个系统的稳定性。如果攻击者能篡改PCB,那基本上就等于拿到了系统的“万能钥匙”。

在Linux里,PCB对应的是 task_struct 结构体。这玩意儿非常大,几百个字段。我刚开始看源码时也吓了一跳。但别慌,核心就上面那几类信息。

// Linux task_struct 简化示意
struct task_struct {
    pid_t pid;                     // 进程ID
    volatile long state;           // 进程状态
    struct thread_info *thread;    // 线程信息
    struct mm_struct *mm;          // 内存描述符
    struct files_struct *files;    // 文件描述符表
    // ... 还有几百个字段
};

4.2 进程隔离 —— 为什么一个进程崩了不会拖垮整个系统?

说白了,进程隔离就是让每个进程都活在自己的“小世界”里。A进程看不到B进程的内存,也改不了B进程的数据。这是怎么做到的?

核心机制有两个:

  1. 虚拟地址空间:每个进程都以为自己拥有整个内存地址空间(比如4GB)。实际上,这些地址都是“假的”,需要通过MMU(内存管理单元)翻译成物理地址。不同进程的虚拟地址可以映射到不同的物理地址,谁也碰不到谁。
  2. 特权级保护:CPU分了Ring 0(内核态)和Ring 3(用户态)。用户进程跑在Ring 3,不能直接执行特权指令,也不能访问内核空间。我见过一个案例,有人试图在用户态直接操作CR3寄存器(页表基址寄存器),结果直接触发#GP异常,进程被干掉。

避坑指南:我曾经调试过一个内核模块,它试图直接读取另一个进程的内存。结果发现,即使在内核态,也不能随便访问任意物理地址。你得先通过进程的页表来映射,否则拿到的数据全是错的。这就是进程隔离的威力——连内核自己都要遵守规则。

进程隔离的另一个重要方面是资源隔离。每个进程有自己的文件描述符表、信号处理表、资源限制(ulimit)。一个进程用光了所有文件描述符,不会影响其他进程。嗯,这个设计很巧妙。

下面这张图展示了进程隔离的核心逻辑:

进程隔离核心机制 进程A 虚拟地址: 0x0000-0xFFFF 页表A → 物理页框1,2,3 文件描述符表A 进程B 虚拟地址: 0x0000-0xFFFF 页表B → 物理页框4,5,6 文件描述符表B 进程C 虚拟地址: 0x0000-0xFFFF 页表C → 物理页框7,8,9 文件描述符表C 隔离 隔离 内核空间(Ring 0) 所有进程共享内核代码、PCB链表、全局数据结构 物理内存 页框1 | 页框2 | 页框3 | 页框4 | 页框5 | 页框6 | 页框7 | 页框8 | 页框9 | ... 每个进程只能访问自己页表映射的物理页框 进程A 进程B 进程C

4.3 内核线程 vs 用户线程 —— 到底谁更“轻量”?

很多人以为线程就是“轻量级进程”。其实这话只说对了一半。关键在于,你问的是内核线程还是用户线程?这两者差别大了去了。

4.3.1 用户线程(User-Level Thread)

用户线程完全由用户空间的线程库管理(比如POSIX的pthread库)。内核根本不知道有线程这回事。它看到的还是那个进程。

  • 优点:创建和切换非常快,不需要陷入内核。我做过测试,用户线程切换大概只需要几十个纳秒,而内核线程切换要几百纳秒甚至微秒级。
  • 缺点:如果一个线程执行了阻塞系统调用(比如read()),整个进程都会被阻塞。因为内核只认识进程,不认识线程。嗯,这是个硬伤。
  • 典型实现:早期的Java线程、GNU Pth库。

4.3.2 内核线程(Kernel-Level Thread)

内核线程是操作系统直接管理的。每个线程在内核里都有自己的PCB(或者说,是PCB的一个子结构)。内核调度器直接调度线程,而不是进程。

  • 优点:一个线程阻塞了,不影响同进程的其他线程。因为内核知道它们是不同的调度实体。
  • 缺点:创建和切换开销大,每次都要陷入内核态。我见过一些应用,创建了上千个内核线程,结果系统响应变得很慢——调度器忙不过来了。
  • 典型实现:Linux的NPTL(Native POSIX Thread Library)、Windows线程。

注意:现代操作系统大多采用“一对一”模型,即一个用户线程对应一个内核线程。Linux的NPTL就是这种。但也有一些系统用“多对多”模型(比如Solaris的早期版本),不过现在很少见了。

我整理了一个对比表格,方便你快速理解:

特性 用户线程 内核线程
管理主体 用户空间线程库 操作系统内核
内核是否感知
创建/切换开销 低(纳秒级) 高(微秒级)
阻塞影响 整个进程阻塞 仅当前线程阻塞
多核利用 差(无法并行) 好(可分配到不同CPU)
典型实现 GNU Pth, 早期Java Linux NPTL, Windows

4.4 从安全角度看进程与线程

聊完了基础,咱们说说安全。进程和线程的设计,本身就有很多安全考量。

进程隔离是安全的第一道防线。一个恶意进程不能直接读取另一个进程的密码、密钥。它必须通过系统调用(比如ptrace、共享内存)来交互,而这些操作都受到权限控制。我见过一个漏洞,攻击者利用内核漏洞修改了进程的页表,实现了跨进程内存读取——这就是典型的“进程隔离被突破”。

线程安全则是另一回事。同一进程内的线程共享地址空间,所以一个线程可以轻松访问另一个线程的局部变量(只要知道地址)。这带来了同步问题,也带来了安全隐患。比如,一个线程如果被劫持,攻击者可以修改其他线程的代码段或数据段。

我的经验:在写多线程程序时,我习惯用线程局部存储(TLS)来存放敏感数据,比如会话密钥。这样即使其他线程被攻破,也拿不到这份数据。当然,这不是万能的——如果攻击者能执行任意代码,他总能找到办法。但至少增加了攻击成本。

最后说一句,理解PCB、进程隔离和线程模型,是内核安全的基础。很多高级漏洞(比如容器逃逸、内核提权)都跟这些概念有关。你把这些搞透了,后面学起来就轻松多了。


公众号:蓝海资料掘金营,微信deep3321