本文为 Linux 系统编程系列的第一篇,从冯·诺依曼体系结构出发,深入理解操作系统的管理思想,引出进程的核心概念(PCB、task_struct),并详细讲解如何查看进程、创建进程(fork),以及父子进程的代码共享、数据写时拷贝等关键知识。帮助同学们建立对进程的完整认知。

一、上节课核心回顾:先描述,再组织

在正式进入进程之前,我们需要先回顾上节课最重要的结论:

管理的本质:先描述,再组织。

  • 校长管理学生,不需要和学生见面,只需要通过数据(学籍、成绩等)来管理。

  • 数据由辅导员(相当于驱动程序)收集,录入教务系统(相当于操作系统内核的数据结构)。

  • 校长的日常管理转化为对数据的增删查改

  • 在计算机中,操作系统管理硬件、进程、内存等,同样遵循这一原则:先用结构体描述对象,再用链表等数据结构组织起来

为什么 C++ 要有类和 STL?
类解决“描述”,STL 容器解决“组织”。面向对象语言成为主流,不是因为语言设计者的偏好,而是因为世界本身就是先描述再组织的


二、系统调用与库函数

2.1 银行窗口类比

  • 操作系统不相信任何用户,但又必须提供服务。

  • 就像银行:不让你进金库,但通过窗口给你办理业务。

  • 操作系统提供的“窗口”就是系统调用(System Call)

2.2 系统调用的本质

  • 系统调用是操作系统暴露给上层的C风格函数接口

  • 用户通过系统调用访问操作系统内核的数据和功能(如创建进程、读写文件等)。

  • 系统调用使用起来比较底层、复杂,因此被封装成库函数(如 printfscanf)。

判断一个库函数底层是否调用了系统调用
如果该函数最终要访问硬件(显示器、磁盘、网卡等),则一定封装了系统调用。

2.3 系统调用与库函数的关系图

用户程序(你的代码)
        ↓
   库函数(printf, scanf...)
        ↓
   系统调用(write, read, fork...)
        ↓
   操作系统内核
        ↓
   驱动程序
        ↓
   硬件

三、进程的基本概念

3.1 什么是进程?

  • 课本定义:程序的一个执行实例,正在执行的程序。

  • 内核观点:担当分配系统资源(CPU 时间、内存)的实体。

  • 本课程定义进程 = 内核数据结构(PCB)+ 自己的代码和数据

很多人误以为“把程序加载到内存”就是进程,但严格来说,PCB(进程控制块)才是进程的核心。代码和数据只是进程的“血肉”,PCB 才是“灵魂”。

3.2 为什么要有 PCB?

  • 操作系统要管理多个加载到内存的程序,就必须先描述再组织

  • 描述:为每个加载的程序创建一个 PCB(Process Control Block) 结构体,里面存放进程的所有属性(PID、状态、优先级、内存指针等)。

  • 组织:将所有 PCB 用链表(或更高效的数据结构)连接起来,形成进程列表

  • 操作系统对进程的管理,就转化为对链表的增删查改。

3.3 PCB 在 Linux 中的具体实现:task_struct

  • PCB 是操作系统学科的抽象概念,不同操作系统有不同实现。

  • 在 Linux 中,PCB 的具体结构体叫 task_struct,定义在 <linux/sched.h> 中。

  • task_struct 是一个非常大的结构体(上百个成员),包含:

成员分类说明
标识符PID、PPID 等,唯一标识进程
状态运行、睡眠、停止、僵尸等
优先级决定进程被调度的先后顺序
程序计数器下一条要执行的指令地址
内存指针指向代码段、数据段等
上下文数据寄存器值(用于进程切换)
I/O 状态打开的文件列表、I/O 请求等
记账信息进程使用的 CPU 时间等

在 Linux 内核源码中,task_struct 还包含指向 mm_struct(内存描述符)的指针,用于管理进程的虚拟地址空间。

3.4 查看进程

方法一:通过 /proc 文件系统
ls /proc/               # 查看所有进程的 PID 目录

ls -l /proc/1234/       # 查看 PID 为 1234 的进程信息

  • /proc 是一个内存文件系统,里面的数字目录对应每个进程的 PID。

  • 目录下的文件(如 execwdstatus)动态反映进程的运行状态。

两个重要的符号链接

  • exe → 指向进程对应的可执行文件的绝对路径。

  • cwd → 指向进程的当前工作目录(Current Working Directory)。

补充知识点:如果把当前工作目录删掉,进程还是会继续跑,后续可能还会有其他影响,当你再去查该进程是exe会标红。

方法二:使用 ps 命令
ps aux               # 显示所有进程的详细信息

ps axj               # 显示进程树(包含 PPID)

ps -l                # 显示优先级等信息

  • a:显示一个终端的所有进程

  • x:显示没有控制终端的进程

  • u:以用户为中心显示详细信息

  • j:显示进程组、会话、父进程 ID

示例:查看自己的进程并过滤 grep 自身(需要两个终端一个运行一个查)

ps aux | grep myprocess | grep -v grep

3.5 通过系统调用获取进程 ID

#include <sys/types.h>
#include <unistd.h>

pid_t getpid(void);   // 获取当前进程的 PID
pid_t getppid(void);  // 获取父进程的 PPID

  • 返回值类型 pid_t 本质上是整数(int)。

  • 这两个都是系统调用(查看手册第 2 章)。

示例代码

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
    printf("pid: %d\n", getpid());
    printf("ppid: %d\n", getppid());
    return 0;
}

运行后父进程的ppid不变,我们去查这个进程你会发现父进程 PPID 是当前 bash 的 PID。

3.6 命令行解释器 bash 本身也是一个进程

  • 每次登录,操作系统都会为用户分配一个 bash 进程。

  • bash 不断循环:打印提示符(如 [whb@...]$)→ 等待用户输入命令 → 解析命令 → 创建子进程执行命令 → 回收子进程 → 继续循环。

  • 我们执行的所有命令(lsps./myproc 等),都是 bash 的子进程

四、创建进程:fork 系统调用

4.1 fork 的基本用法

#include <unistd.h>
pid_t fork(void);
  • fork() 用于创建一个子进程

  • 返回值:

    • 成功:父进程返回子进程的 PID,子进程返回 0。

    • 失败:返回 -1。

特点

  • 子进程是父进程的副本(代码共享,数据写时拷贝)。

  • fork 之后,父子进程并发执行(谁先谁后不确定)。

4.2 验证父子进程执行不同代码

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>

int main()
{
    pid_t id = fork();
    if (id < 0) {
        perror("fork");
        return 1;
    } else if (id == 0) {
        // 子进程
        printf("I am child, pid: %d, ppid: %d\n", getpid(), getppid());
    } else {
        // 父进程
        printf("I am parent, pid: %d, ppid: %d\n", getpid(), getppid());
    }
    sleep(1);
    return 0;
}

输出示例(每次运行 PID 可能不同):

I am parent, pid: 12345, child pid: 12346
I am child, pid: 12346, ppid: 12345

4.3 fork 的“两个返回值”之谜

  • 一个函数怎么会返回两次?
    因为 fork 的核心工作(创建子进程 PCB 并放入运行队列)在 return 语句之前就已经完成。
    return 本身也是一条语句,父子进程都会执行这条语句,因此各自返回一次。

  • 为什么父进程返回子进程 PID,子进程返回 0?
    父进程可能有很多子进程,需要通过不同的 PID 区分它们;子进程只需要知道自己是子进程(0 表示成功),父进程的 PID 可以通过 getppid() 获得,父与子的比例是1:n。

4.4 fork 之后的代码共享与数据写时拷贝

  • 代码共享:子进程复制父进程的 task_struct,其中代码段指针指向同一份物理内存(代码只读,共享安全)。

  • 数据写时拷贝(Copy-on-Write, COW)

    • 默认情况下,父子进程的数据段也指向同一份物理内存。

    • 当其中一方尝试修改数据时,操作系统会为该进程单独拷贝一份数据副本,然后修改副本。

    • 这样,父子进程的数据就互相独立了,保证了进程独立性

验证实验

int g_val = 100;

int main()
{
    pid_t id = fork();
    if (id == 0) {
        // child
        g_val = 200;
        printf("child: g_val = %d, &g_val = %p\n", g_val, &g_val);
    } else {
        sleep(2);
        printf("parent: g_val = %d, &g_val = %p\n", g_val, &g_val);
    }
    return 0;
}

输出(地址相同但值不同):

child: g_val = 200, &g_val = 0x601038
parent: g_val = 100, &g_val = 0x601038

同一虚拟地址映射到不同的物理地址 → 虚拟地址空间的引入。后续课程会详细讲解。


五、进程的独立性

  • 独立性:一个进程挂掉,不会影响其他进程,即使是父子进程也不例外。

  • 内核数据结构(PCB)各自独立,代码只读共享不影响,数据通过写时拷贝私有化。

  • 因此,操作系统可以同时运行大量进程,互不干扰。


六、本节课总结

知识点核心内容
管理思想先描述(struct),再组织(链表)
系统调用操作系统提供的接口,用户通过它访问内核
进程定义进程 = PCB(task_struct)+ 代码和数据
查看进程ps aux/proc 文件系统
获取 PIDgetpid()getppid()
创建进程fork(),父进程返回子进程 PID,子进程返回 0
代码与数据代码共享,数据写时拷贝(COW)
进程独立性内核数据结构独立 + 写时拷贝保证互不干扰
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐