Linux的进程

进程的地址空间

处于隔离性的要求,每个进程所使用的地址为虚拟地址(`VA`),其中地址为 `0x0000000000000000` ~ `0xffffffffffffffff`(合理取64位计算机为例)。进程的地址空间分为用户态地址和内核态地址,其中用户态和内核态的地址范围如下。
地址范围 所属 描述
0x0000000000000000 ~ 0x00007fffffffffff 用户态 每个进程私有,可访问
0xffff800000000000 ~ 0xffffffffffffffff 内核态 全系统共享,只有内核代码可访问
  • 用户态的地址空间分布

我们可以将一个用户态进程的地址空间划分如下(从低地址到高地址)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+-------------------------+
| 用户栈(Stack) | ← 通常从高地址向低地址增长
+-------------------------+
| mmap 区域(共享库、匿名映射) |
+-------------------------+
| 堆(Heap) | ← 从 brk 指向的起始地址向上增长
+-------------------------+
| BSS 段(未初始化全局/静态变量)|
+-------------------------+
| 数据段(Data) | ← 已初始化的全局/静态变量
+-------------------------+
| 代码段(Text) | ← 程序指令
+-------------------------+
| 空洞 / 对齐区域 |
+-------------------------+
| NULL | ← 通常地址空间的最低部分(保护访问)

用户态地址空间各段的解释:

区域 说明
代码段(.text) 存储可执行代码(只读、可执行)
数据段(.data) 存储已初始化的全局变量或静态变量(可读写)
BSS段(.bss) 存储未初始化的全局变量或静态变量(占虚拟空间,运行时初始化为0)
堆(heap) malloc() 等函数动态分配内存,向高地址扩展,由 brk()mmap() 控制
mmap 映射区 用于映射共享库(如 libc.so)、匿名内存(如 mmap(NULL,...))等,地址位置灵活(同时也是用户态线程栈的保存位置)
用户栈(stack) 默认大小通常为 8MB(可调),由内核在启动线程时创建,从高地址向低地址扩展
VDSO / vsyscall 提供某些用户态可调用的内核函数(如 gettimeofday()),提升性能
NULL 保留页 地址空间的最低页通常不映射,用于捕获非法指针访问(如 NULL dereference)
  • 内核态的地址空间分布,其中内核栈和per-CPU,虽然分配在全局地址空间,但是其内容不共享,为任务私有
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
用户空间(每进程私有):
0x0000_0000_0000_0000
...
0x0000_7fff_ffff_ffff ← 用户空间结束

-------------------- 以下为所有进程全局共享的内核空间 --------------------

内核空间(全局共享):
0xffff_8000_0000_0000 ← 内核空间起始
|- 直接映射区(physmap, linear mapping
| 0xffff_8000_0000_0000 ~ 0xffff_c7ff_ffff_ffff
|- vmemmap 区(struct page 映射区,页框管理的数据结构)
| 0xffff_ea00_0000_0000 ~ 0xffff_eeff_ffff_ffff
|- vmalloc 区(动态虚拟内存分配,用于内存的动态分配)
| 0xffff_c800_0000_0000 ~ 0xffff_dfff_ffff_ffff
|- 模块映射区
| 0xffff_e000_0000_0000 ~ 0xffff_efff_ffff_ffff
|- ioremap 区(PCI/IO设备动态映射,实际位于vmalloc子区)
|- 内核镜像区(.text/.data/.bss, KASLR后地址随机)
| 0xffffffff_80000000 ~ 0xffffffff_ffffffff
|- fixmap 区
| 0xffff_fff0_0000_0000 ~ 0xffff_fff0_0fff_ffff
|- vsyscall 区(已废弃,仅保留仿真,现在已经被Visual Dynamic Share Object替代,来提供无需发起系统调用的系统调用)
| 0xffff_ffff_fffc_0000 ~ 0xffff_ffff_fffd_0000
|- EFI/ACPI保留区(部分机器有)
| 0xffff_ffe0_0000_0000 ~ 0xffff_ffff_ffff_ffff
|- 其它保留或架构相关区块
0xffff_ffff_ffff_ffff ← 虚拟地址上限

内核态各段的解释

区域 描述
直接映射物理内存区域(Direct mapping) 将所有物理内存直接线性映射进虚拟地址空间,常用宏 __va()__pa() 转换
vmalloc 区 vmalloc() 申请的非连续物理页区域,适合分配大对象。实现虚拟地址到离散的物理地址的映射
模块映射区 加载的内核模块、驱动代码位置
内核代码段 & 数据段 内核自身代码与静态数据区域(内核本身的代码,不可以修改)
fixmap 区 用于映射特定设备地址或中断向量表,具有固定虚拟地址
ioremap 映射区 IO 设备的 MMIO 寄存器映射到的虚拟地址

进程的实体表示

在Linux内核中,进程的实体表示是使用 task_struct这一数据结构来表示的,在Linux中,Linux 采用 1:1 的线程实现模型,每个用户态线程对应一个内核态线程,内核通过 task_struct 来统一表示进程和线程,线程因此也被称作“轻量级进程”(Lightweight Process, LWP) 。轻量级进程会共享主进程的 mm_structfs_structsignal_struct等等这些内容,他们各自的 task_struct是各不相同的。

下面是一个 task_struct的基本结构:

1
2
3
4
5
6
7
8
9
10
11
task_struct
├── 基本标识:pid, tgid, comm, state
├── 调度信息:prio, se, policy
├── 内存信息:mm, active_mm
├── 线程组信息:group_leader, thread_group
├── 父子关系:real_parent, children
├── 信号处理:signal, sighand
├── 文件系统:fs, files
├── 权限/命名空间:cred, nsproxy
├── 调试/审计:audit_context, start_time
├── 架构相关:thread_struct

基本标识:pid是进程的唯一标识,而tgid则是所在线程组组长的pid(主线程的pid)

调度信息:prio是优先级,se则是实际的调度实体,而policy则是调度的策略

内存信息:mm用户态内存描述符,是一个指向mm_struct的指针,active_mm则是线程当前使用的mm_struct,这是因为内核线程会借用其他进程的,VMA是虚拟内存区域,用来描述进程虚拟地址空间中一段连续虚拟内存区域的数据结构,可以管理进程的虚拟地址空间到物理地址的映射关系,采用了红黑树的数据结构来管理。

线程组信息:group_leader指向线程组的组长,thread_group则是线程组的内部链表

信号处理:signal是指向 signal_struct的,线程组共享; sighand则是指向sighand_struct(信号处理函数表)

文件系统:files指向files_struct,表明打开文件表;而fs则是维护文件系统的信息

进程的状态
  • 从操作系统的宏观角度:

    • 创建:正在创建进程,还未就绪
    • 就绪:已准备好、等待被调度上 CPU
    • 运行:正在 CPU 上执行
    • 阻塞:等待 I/O 或资源,不能运行
    • 终止:执行完毕或被终止,等待资源释放
  • 从Linux内核的具体实现角度:

    • TASK_RUNNING:可运行,可能在 CPU 上,也可能在 runqueue 中等待
    • TASK_INTERRUPTIBLE:可被信号打断的睡眠,常见于等待资源、I/O
    • TASK_UNINTERRUPTIBLE:不可被信号打断的睡眠,如 I/O 阻塞
    • EXIT_ZOMBIE:已退出,变成僵尸进程,等待父进程回收
    • __TASK_STOPPED:被 SIGSTOP 暂停
    • __TASK_TRACED:被调试器(如 gdb)暂停、单步等状态

进程的创建

在Linux中,进程的创建主要有,fork,vfork以及clone三种方式来创建进程。

系统调用 本质功能 地址空间复制 子进程执行时父进程状态 适用场景
fork() 创建一个子进程,复制当前进程 写时复制(Copy-on-write) 父子进程并发执行 一般进程创建
vfork() 类似 fork,但不复制地址空间 共享地址空间(临时) 父进程被阻塞直到子进程 exec()_exit() 创建后立即执行 exec
clone() 最灵活的创建机制,用于创建线程 可选共享(通过标志控制) 父子可以并发执行 多线程、容器、线程池

fork()vfork() 最终都会调用 clone(),区别在于传入的参数不同;所有这三者最终调用内核中的 copy_process() 函数来构建新的 task_struct

进程的切换

简而言之,进程的切换就是保留当前进程的上下文,然后加载目标进程的上下文。

从更深入的角度解释则是:

1
2
3
4
5
6
7
8
9
10
11
[当前CPU]

保存 current->thread_struct 中的寄存器/堆栈

切换 mm_struct(地址空间)

current = next

恢复 next->thread_struct 中的寄存器/堆栈

ret 恢复程序计数器,跳转回用户/内核执行

进程的回收

在前面的介绍中已经知道,所有用户态进程都由 init /systemd 派生,所有内核线程都由 kthreadd 创建。因此,这里进程的回收将从子进程的角度展开介绍。

当子进程调用 exit() 正常退出,或因异常(如段错误)终止时,会进入内核的 do_exit() 函数。

do_exit() 中,内核会关闭打开的文件描述符,调用 exit_mm() 释放虚拟内存资源(仅限拥有 mm_struct 的用户进程),释放信号处理器资源,同时将进程状态设置为 EXIT_ZOMBIE

此后,内核会向父进程发送 SIGCHLD 信号,通知其可以回收当前子进程的退出状态。如果父进程已经提前退出,子进程会被重新挂载到 init(PID 1)进程下,由其负责后续回收。

最后,do_exit() 调用 schedule() 永久让出 CPU,进程不再被调度。

父进程随后通过 wait()waitpid() 等系统调用获取子进程退出信息,并触发 release_task() 对其进行彻底清理,包括释放其 task_struct、内核栈、pid 表项等内核资源,从而完成整个进程回收流程。

进程的调度

Linux的调度是基于分时技术的,CPU的时间被分为片,每个进程的执行都是按照片为单位去执行。在进程调度的时候,调度的优先级是基于静态优先级和动态优先级结合的方法来得出的。

  • 静态优先级:在进程被创建的时候分配的优先级,他是不可以改变的
  • 动态优先级:调度器在运行过程中根据进程行为动态调整的优先级值,通常是通过CFS(complete Fair Schedule)完全公平调度器来通过 vcputime来等效动态优先级

进程和线程在内核视角的区别

在 Linux 内核中,进程(process)和线程(thread)都由 task_struct 统一表示,并由 Slab 分配器分配内存。它们都是调度器中的可调度实体(schedulable entity),因此每个任务都有唯一的 PID

线程通常由 clone() 系统调用创建,不同的 CLONE_* 标志决定了它与父任务共享哪些资源。

  • PID 与 TGID
    • 每个任务(无论进程还是线程)都有唯一的 PID。
    • 主线程:PID = TGID(线程组 ID)
    • 其他线程:PID ≠ TGID,但 TGID 等于主线程的 PID。
  • 栈空间
    • 每个任务有独立的 内核栈 (内核态使用,固定大小)
    • 每个任务有独立的 用户栈 ;线程的用户栈分配在父进程的 mmap 区域内。
  • 资源共享
    • 进程:拥有独立的 mm_struct(虚拟内存)、files_struct(文件描述符表)、fs_struct(文件系统信息)、sighand_struct(信号处理表)等。
    • 线程:通常共享这些资源(取决于 clone() 的标志,如 CLONE_VMCLONE_FILESCLONE_FSCLONE_SIGHAND 等)。
  • 调度
    • 内核调度器并不区分进程或线程,都是以 task_struct 作为调度单位。
    • 区别主要体现在地址空间是否独立,以及资源是否共享。
作者

kosa-as

发布于

2025-07-09

更新于

2025-08-11

许可协议

评论