计算机硬件基石:冯·诺依曼体系结构

在理解进程之前,首先需要弄清楚程序是在什么样的硬件上运行的。

什么是冯·诺依曼体系?

我们日常使用的计算机——无论是笔记本电脑、台式机还是服务器——绝大多数都遵循冯·诺依曼体系结构。这套体系的核心思想可以概括为:程序和数据都存储在存储器中,计算机按照存储的程序逐条执行指令。

硬件组成

冯·诺依曼体系将计算机硬件划分为三大核心部件:

部件功能常见设备
输入单元将外部数据送入计算机键盘、鼠标、扫描仪、手写板
中央处理器(CPU)进行算术运算和逻辑控制包含运算器 + 控制器
输出单元将计算结果输出给用户显示器、打印机

几个必须记住的关键规则

  1. 这里的"存储器"特指内存(而不是硬盘等外部存储)。

  2. CPU能且只能对内存进行读写,无法直接访问外设(如键盘、显示器、硬盘)——这是在没有考虑缓存(Cache)的情况下。

  3. 所有外设(输入/输出设备)要传输数据,也只能通过内存来完成。也就是说,所有设备都只能直接和内存打交道

举个生活化的例子

想象一下公司里的总裁办(CPU)文件室(内存)

  • 总裁办需要数据时,只能派人去文件室取,不能直接跑到各部门(外设)去拿。

  • 各部门要汇报工作,也只能先把文件交到文件室,再由总裁办从文件室取阅。

  • 文件室成为了整个公司信息流转的唯一枢纽

为什么这样设计?

原因一:速度匹配问题

  • CPU 速度 ≈ 纳秒级(10⁻⁹秒)

  • 内存速度 ≈ 纳秒级(10⁻⁹秒)— 慢10-100倍

  • 硬盘速度 ≈ 毫秒级(10⁻³秒)— 慢百万倍

如果 CPU 直接访问硬盘,CPU 99.99%的时间都在等待,这是极大的浪费。内存作为"缓冲",让 CPU 与外设在速度上解耦。

原因二:统一管理

  • 所有数据都通过内存中转,操作系统只需管理好内存这一个核心资源

  • 降低了管理复杂度

原因三:程序的可移植性

  • 程序只需要"以为"自己在操作内存

  • 具体的硬件差异由操作系统和驱动程序屏蔽

数据流动的直观理解:QQ聊天场景

从你登录QQ开始,到给朋友发消息,数据是怎么流动的?

发送方(你)

  1. 键盘输入消息 → 数据进入内存

  2. CPU从内存读取消息 → 进行编码、加密等处理 → 写回内存

  3. 网卡(输出设备)从内存读取数据 → 发送到网络

接收方(朋友)

  1. 网卡接收到数据 → 写入内存

  2. CPU从内存读取 → 解码、解密 → 写回内存

  3. 显示器从内存读取 → 显示消息

发送文件也是同样的路径:文件从硬盘 → 内存 → CPU处理 → 内存 → 网卡 → 网络 → 对方网卡 → 内存 → CPU处理 → 内存 → 硬盘。

操作系统(OS):一切的管理者

什么是操作系统?

操作系统是一个基本的程序集合,它本质上是一个软件,但它的地位非常特殊——它是整个计算机系统中最核心的系统软件

操作系统的构成:

  • 内核(Kernel):包含进程管理、内存管理、文件管理、驱动管理

  • 其他程序:函数库、Shell程序等

操作系统的两大设计目标

  • 对上:为用户程序(应用程序)提供一个良好的执行环境

  • 对下:与硬件交互,管理所有的软硬件资源

操作系统 = 一款"搞管理"的软件

操作系统的核心功能就是管理

  • 管理CPU(进程调度)

  • 管理内存(内存分配与回收)

  • 管理文件(文件系统)

  • 管理设备(驱动程序)

 如何理解"管理"?

管理的本质分两步:

  1. 描述被管理对象:用数据结构(如结构体)来描述一个对象的属性

  2. 组织被管理对象:用高效的数据结构(如链表)将这些对象组织起来

这也正是操作系统管理进程的核心思想:先把进程描述起来,再把进程组织起来!

系统调用与库函数

操作系统为了保护自己,不会把所有功能都暴露给用户程序。它只暴露一部分接口,供上层开发使用,这些接口就叫做系统调用

用户程序
    ↓
库函数(封装)
    ↓

系统调用(OS提供的接口)
    ↓
操作系统内核

系统调用功能基础、使用门槛较高。于是开发者对部分系统调用进行封装,形成库函数(如C标准库),方便更上层的开发者使用。

进程(Process):程序的"活体"实例

进程是什么?

从不同角度理解进程:

角度定义
课本定义程序的一个执行实例,正在执行的程序
内核观点担当分配系统资源(CPU时间、内存)的实体
现代观点进程 = 内核数据结构 + 自己的程序代码和数据

程序是"死"的(躺在硬盘上的文件),进程是"活"的(程序被加载到内存中运行)。

PCB(进程控制块):进程的"身份证"

操作系统要管理进程,首先需要用数据结构来描述进程。在Linux中,这个结构体叫做 task_struct

task_struct 被装载在RAM(内存)中,包含了进程的所有信息。它相当于进程的"身份证"或"档案"。

task_struct 包含哪些信息?
信息类别说明
标识符(PID)每个进程唯一的ID,用于区分其他进程
状态运行、睡眠、停止、僵尸等
优先级相对于其他进程的优先级
程序计数器(PC)下一条即将执行的指令地址
内存指针程序代码、数据、共享内存块的指针
上下文数据CPU寄存器的内容(用于进程切换时保存现场)
I/O状态信息分配的I/O设备、文件列表
记账信息CPU使用时间、时钟数等
进程的组织方式

所有运行在系统中的进程,以 task_struct 双链表的形式存在于内核中。

        ┌──────┐    ┌──────┐    ┌──────┐
        │进程A  │←──→│进程B │←──→│进程C  │
        │PCB   │    │PCB   │    │PCB   │
        └──────┘    └──────┘    └──────┘
              (双向循环链表)

查看进程的方法

方法1:通过 /proc 文件系统

ls /proc/1    # 查看PID为1的进程信息
cat /proc/1/status  # 查看进程状态

方法2:使用 ps 命令

ps aux        # 显示所有进程的详细信息
ps axj        # 显示进程组、会话、父进程等信息

方法3:使用 top 命令

top           # 动态显示进程资源占用

通过系统调用获取进程标识符

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

int main() {
    pid_t pid = getpid();   // 获取当前进程PID
    pid_t ppid = getppid(); // 获取父进程PID
    printf("PID: %d, PPID: %d\n", pid, ppid);
    return 0;
}

fork():创建子进程

fork() 是Linux中创建进程的核心系统调用。它的特点是:

fork 有两个返回值!

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

int main() {
    int ret = fork();
    
    if (ret < 0) {
        perror("fork");
        return 1;
    } 
    else if (ret == 0) {
        // 子进程
        printf("I am child, PID: %d, ret: %d\n", getpid(), ret);
    } 
    else {
        // 父进程
        printf("I am father, PID: %d, ret: %d\n", getpid(), ret);
    }
    
    sleep(1);
    return 0;
}
fork 的执行流程
        父进程(PID = 100)
              │
              ├── 执行 fork()
              │
              ├── 内核创建子进程(几乎完全拷贝父进程)
              │
              ├── 父进程返回子进程PID(ret > 0)
              │
              └── 子进程返回 0(ret == 0)
父子进程的关系
  • 代码共享:父子进程共享代码段

  • 数据独立:数据各自开辟空间,互不影响(采用写时拷贝技术)

  • 执行顺序不确定:谁先运行由调度器决定

进程状态:进程的"生命周期"

Linux 内核中的进程状态

在内核源码中,进程状态定义在 task_state_array 中:

static const char * const task_state_array[] = {
    "R (running)",      /* 0  - 运行态 */
    "S (sleeping)",     /* 1  - 可中断睡眠 */
    "D (disk sleep)",   /* 2  - 不可中断睡眠 */
    "T (stopped)",      /* 4  - 停止态 */
    "t (tracing stop)", /* 8  - 追踪停止 */
    "X (dead)",         /* 16 - 死亡态 */
    "Z (zombie)",       /* 32 - 僵尸态 */
};

各状态详解

R(运行状态)

注意:R状态并不意味着进程一定正在运行,它表示进程要么正在CPU上运行,要么在运行队列中排队等待CPU。

运行队列 → [进程A] [进程B] [进程C] ...
                        ↑
            进程A正在CPU上运行,B和C在排队

S(可中断睡眠状态)

进程在等待某个事件完成(如等待用户输入、等待网络数据)。这种睡眠可以被信号中断。

// 例如:scanf() 等待用户输入时,进程处于 S 状态
scanf("%d", &num);  // 进程阻塞在这里,进入S状态
D(不可中断睡眠状态)

也叫"磁盘休眠状态"。进程在等待I/O操作完成(如读写磁盘),在此期间不能被信号中断

为什么需要D状态?如果在写磁盘过程中被中断,可能导致数据损坏或不一致。

T(停止状态)

进程被暂停执行(通过发送 SIGSTOP 信号),可以通过 SIGCONT 信号让其继续运行。

kill -SIGSTOP <PID>   # 暂停进程
kill -SIGCONT <PID>   # 继续运行进程
X(死亡状态)

进程结束时的短暂状态,只是一个返回状态,我们不会在任务列表中看到它。

Z(僵尸状态)

4.3 僵尸进程(Zombie Process)

什么是僵尸进程?

当一个进程退出后,父进程没有读取子进程的退出状态代码时,子进程就会进入僵尸状态。

进程退出 → 需要告知父进程"任务完成情况" → 父进程不读取 → 进入Z状态

僵尸进程的特征
  • 进程已经终止运行(不再消耗CPU)

  • 但仍然在进程表中保留一个条目(PCB)

  • 一直等待父进程来读取它的退出码

为什么会存在僵尸进程?

进程的退出状态必须被维持,因为父进程需要知道"交给子进程的任务办得怎么样了"

  • 正常完成?→ 退出码 0

  • 出错了?→ 退出码 > 0(表示具体错误)

僵尸进程的危害

退出状态信息保存在 task_struct (PCB) 中
        ↓
Z状态一直不退出 → PCB一直保留
        ↓
父进程创建大量子进程不回收 → 大量PCB占用内存
        ↓
        → 内存泄漏!

简单理解:就像你给下属分配了任务,他做完后等你验收,但你一直不去验收,他就一直等在那儿,占用着公司的"编制"(PCB)。

模拟僵尸进程的代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main() {
    pid_t id = fork();
    
    if (id == 0) {
        // 子进程
        printf("Child: PID=%d, PPID=%d\n", getpid(), getppid());
        sleep(5);
        exit(EXIT_SUCCESS);  // 子进程退出,但父进程不回收 → 变成僵尸
    } else {
        // 父进程
        printf("Parent: PID=%d\n", getpid());
        sleep(30);  // 父进程长时间不读取子进程状态
        // 没有调用 wait(),子进程将一直处于Z状态
    }
    return 0;
}

深度理解:三种"不运行"状态的区别

                    ┌─────────────────────────────────┐
                    │         进程不运行                │
                    └─────────────────────────────────┘
                                    │
              ┌─────────────────────┼─────────────────────┐
              │                     │                     │
         等待事件发生           被主动暂停            已终止但未回收
              │                     │                     │
       ┌──────┴──────┐              │                     │
       │             │              │                     │
   可被信号唤醒   不可被信号唤醒    可被SIGCONT恢复      等待父进程回收
       │             │              │                     │
       ▼             ▼              ▼                     ▼
      S状态         D状态          T/t状态               Z状态

关键区别:

  • S状态:进程"活着",只是在等待。可以被信号唤醒。

  • D状态:进程"活着",必须等待I/O完成。不能被信号唤醒——这是为了保护数据完整性。

  • T状态:进程"活着",被暂停。可以用 SIGCONT 恢复。

  • Z状态:进程"已经死了",但尸体还在。需要父进程来"收尸"(调用 wait)。

什么是孤儿进程?

孤儿进程是指:父进程先于子进程退出,子进程就变成了“孤儿”。

但它不会被丢弃,而是被 1号进程(init 或 systemd) 领养,由它负责回收。

// 孤儿进程示例
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main() {
    pid_t id = fork();
    if (id == 0) {
        // 子进程
        printf("I am child, pid: %d\n", getpid());
        sleep(10);
    } else {
        // 父进程先退出
        printf("I am parent, pid: %d\n", getpid());
        sleep(3);
        exit(0);
    }
    return 0;
}

运行后,父进程 3 秒后退出,子进程变成孤儿,被 init 领养。

为什么要领养?

  • 如果父进程提前退出,子进程退出时变成 僵尸(Z)状态,没人回收就会一直占用 PCB。

  • 1 号进程会定期 wait(),回收孤儿进程,防止资源泄漏。

进程优先级:谁先上CPU?

1. 什么是优先级?

优先级(PRI) 决定进程获取 CPU 资源的先后顺序,值越小,优先级越高

2. Nice 值(修正值)

  • NI(nice) 取值范围:-20 ~ 19。

  • PRI(new) = PRI(old) + nice

  • nice 为负 → 优先级提高,但需要 root 权限。

# 查看优先级
ps -l

# 调整优先级(top 中按 r)
top
# 输入 PID,再输入 nice 值

竞争、独立、并行与并发

概念解释
竞争性多个进程争抢有限 CPU 资源
独立性进程互不干扰,拥有自己的地址空间
并行多个进程在多个 CPU 上同时运行
并发一个 CPU 上通过时间片切换,让多个进程推进

进程切换:CPU 的“换人”机制

1. 上下文切换(Context Switch)

当 CPU 从执行进程 A 切换到进程 B 时,需要:

  • 保存 A 的寄存器状态(上下文)

  • 加载 B 的寄存器状态

这个过程叫上下文切换,是操作系统调度的基础。

2. 时间片

  • 每个进程分配一个时间片(如 10ms)。

  • 时间片用完 → 触发中断 → 切换进程。

  • 实现“分时”效果,让用户感觉多个程序在同时运行。

Linux 2.6 O(1) 调度算法

1. 为什么需要 O(1)?

进程数量可能成千上万,调度算法必须高效,不能随进程数增加而变慢。

2. 核心数据结构

  • 活动队列(active):时间片未用完的进程。

  • 过期队列(expired):时间片已耗尽的进程。

struct prio_array {
    unsigned int nr_active;
    DECLARE_BITMAP(bitmap, MAX_PRIO+1);
    struct list_head queue[MAX_PRIO];
};
  • 140 个优先级(0~99 实时,100~139 普通)。

  • 用 bitmap(5 个 32 位) 快速找到非空队列。

  • 切换 active 和 expired 指针,实现 O(1) 调度。

active指针和expired指针

核心概念:两个完全相同的"队列数组"

struct rq {
    struct prio_array *active;   // 指向活动队列
    struct prio_array *expired;  // 指向过期队列
    struct prio_array arrays[2]; // 真实存储两个队列
};
  • active 指针 → 指向当前正在服务的队列

  • expired 指针 → 指向暂时休息的队列

两个队列的结构完全一样(都是prio_array),都有140个优先级链表。

真实场景演示

假设系统有3个进程:P1(高优先级)、P2(中优先级)、P3(低优先级)

时间片 = 10ms

轮次1:
active: [P1, P2, P3]  →  P1运行10ms → expired
active: [P2, P3]      →  P2运行10ms → expired
active: [P3]          →  P3运行10ms → expired
active: []            →  交换指针!

轮次2:
active: [P1, P2, P3]  →  重新开始(时间片已重新计算)

关键观察:

  • 所有进程都被调度(公平)

  • P1优先运行(优先级体现)

  • 交换指针瞬间完成(高效)

命令行参数

1. 基本概念

命令行参数是在执行程序时传递给程序的额外信息,通过 main 函数的参数接收。

#include <stdio.h>

int main(int argc, char *argv[]) {
    // argc: 参数个数
    // argv: 参数字符串数组
    printf("参数个数: %d\n", argc);
    
    for (int i = 0; i < argc; i++) {
        printf("argv[%d] = %s\n", i, argv[i]);
    }
    return 0;
}

2. 运行示例

# 编译
gcc -o test test.c

# 运行并传递参数
./test hello world 123

# 输出:
# 参数个数: 4
# argv[0] = ./test
# argv[1] = hello
# argv[2] = world
# argv[3] = 123

3. 重要特性

特性1:argv[0] 是程序本身

printf("程序路径: %s\n", argv[0]);
// 可能输出: ./test 或 /home/user/test 或 test
特性2:参数以空格分隔
./test hello world    # 两个参数:"hello", "world"
./test "hello world"  # 一个参数:"hello world"
特性3:参数类型都是字符串
// 需要自己转换
int num = atoi(argv[1]);  // 字符串转整数
double pi = atof(argv[2]); // 字符串转浮点数

环境变量:进程的“隐形参数”

1. 环境变量是什么?

操作系统预设的一些全局变量,影响进程行为。

环境变量作用
PATH命令搜索路径
HOME用户主目录
SHELL当前 shell 路径

2. 查看与设置

echo $PATH
export MYENV="hello"
unset MYENV

3. 代码中获取

#include <stdio.h>
#include <stdlib.h>

int main() {
    printf("%s\n", getenv("PATH"));
    return 0;
}
#include <stdio.h>

int main(int argc, char *argv[]) {
    extern char **environ;  // 声明外部变量
    
    int i = 0;
    while (environ[i] != NULL) {
        printf("%s\n", environ[i]);
        i++;
    }
    return 0;
}

环境变量具有全局性,子进程会继承父进程的环境变量。

虚拟地址空间:进程的“专属假地图”

经典内存布局回顾

我们在C语言中学过内存布局,从高地址到低地址大致是:

高地址
+------------------+
|    内核空间       |  (用户不可访问)
+------------------+
|    栈 (stack)     |  ↓ 向下增长
+------------------+
|       ↓           |
|       ↑           |
+------------------+
|    堆 (heap)      |  ↑ 向上增长
+------------------+
|  未初始化数据段    |  (BSS)
+------------------+
|  已初始化数据段    |  (Data)
+------------------+
|    代码段 (Text)   |
+------------------+
低地址

可以用一段代码验证各区域的地址分布:

#include <stdio.h>
#include <stdlib.h>

int g_unval;        // 未初始化全局变量
int g_val = 100;    // 已初始化全局变量

int main(int argc, char *argv[], char *env[]) {
    const char *str = "helloworld";
    static int test = 10;
    char *heap_mem = (char*)malloc(10);
    
    printf("code addr: %p\n", main);
    printf("init global addr: %p\n", &g_val);
    printf("uninit global addr: %p\n", &g_unval);
    printf("static addr: %p\n", &test);
    printf("heap addr: %p\n", heap_mem);
    printf("stack addr: %p\n", &heap_mem);
    printf("read only string addr: %p\n", str);
    return 0;
}

运行结果会显示各区域地址从低到高排列,验证了上述布局。

实验:同样的地址,不同的值

先看一段代码:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int g_val = 0;

int main() {
    pid_t id = fork();
    if (id == 0) {
        // 子进程
        g_val = 100;
        printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
    } else {
        // 父进程
        sleep(3);
        printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }
    return 0;
}

输出结果:

child[3046]: 100 : 0x80497e8
parent[3045]: 0 : 0x80497e8
  • 父子进程打印出来的地址完全一样

  • 但打印出来的值却不一样

结论:这个地址绝对不是物理内存地址,而是虚拟地址

什么是虚拟地址空间?

每个进程都认为自己独占了整个内存空间(在32位系统下是4GB),但实际上它使用的物理内存可能只有几MB。

操作系统通过页表将进程的虚拟地址映射到真正的物理地址

进程视角:  虚拟地址 0x80497e8
                         ↓
                   页表映射
                         ↓
物理内存:  物理地址 0xXXXXXXXX

父子进程的虚拟地址相同,但页表不同,所以映射到的物理地址也不同——这就解释了为什么地址相同但值不同。

为什么要用虚拟地址空间?

问题解决方案
安全风险进程无法直接访问物理内存,必须通过OS监管的页表,恶意程序无法随意篡改其他进程或内核数据。
地址不确定程序编译时使用的是虚拟地址,无论物理内存被占用到什么位置,虚拟地址空间对进程来说都是固定的。
效率低下物理内存不足时,可以按页(4KB) 为单位换入换出,而不需要移动整个进程。

延迟分配:只承诺,不兑现

当我们 malloc 一块大内存时,操作系统只是在虚拟地址空间上划了一块区域给你,但物理内存可能一个字节都没分配

只有当你真正去访问这块内存(读或写)时,才会触发缺页中断,操作系统才真正去申请物理内存并建立页表映射。

这叫延迟分配,它极大地提高了内存利用效率。

内核数据结构全景图

梳理一下整个链路:

用户进程
    ↓
task_struct (PCB)
    ├── pid, state, priority...
    ├── mm → mm_struct (内存描述符)
    │         ├── start_code, end_code (代码段)
    │         ├── start_data, end_data (数据段)
    │         ├── start_brk, brk (堆)
    │         ├── start_stack (栈)
    │         ├── mmap → vm_area_struct (VMA链表)
    │         └── mm_rb → 红黑树 (VMA树)
    ├── active, expired (调度相关)
    └── ...

task_struct:进程控制块,描述一个进程的所有信息。
mm_struct:内存描述符,描述进程的虚拟地址空间布局。
vm_area_struct:虚拟内存区域,描述一段连续的虚拟地址区间(如栈区、堆区、代码段等)。

Logo

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

更多推荐