目录

一、冯·诺依曼体系结构

二、操作系统的理解

1. 操作系统的本质

2. 设计 OS 的目的

3. 管理的理解

4. 操作系统调用

三、进程的理解

1. 进程是什么?

2. 如何管理进程?

3. 查询进程

4. 创建子进程:fork()

四、进程状态

1. 运行状态 (R)

2. 阻塞状态 (S / D)

3. 暂停状态 (T / t)

4. 僵尸状态 (Z)

5. 死亡状态 (X)

6. 孤儿进程

五、进程优先级

1. 什么是优先级?

2. 优先级与权限的区别

3. 优先级的数值计算

4. 进程的性质

六、进程的切换与调度

1. 死循环会打死系统吗?

2. CPU 寄存器的切换

3. Linux 进程调度算法(O(1) 调度器)

七、环境变量

1. main 函数的参数

2. 环境变量 PATH

3. 其他常见环境变量

4. 获取环境变量的三种方式

5. 本地变量与环境变量

6. 内建命令

八、虚拟地址空间

1. 问题:地址相同,值不同?

2. 虚拟地址空间

3. 页表与写时拷贝

4. 虚拟地址的分配

5. 管理虚拟地址:mm_struct

6. 程序的加载

7. 为什么需要虚拟地址空间?

8. 理解野指针

9. 缺页中断

10. 堆的管理:vm_area_struct

11. 进程独立性


一、冯·诺依曼体系结构

  • 结构:输入设备 -> 存储器(内存) -> 输出设备,CPU 与存储器直接交互。

  • 为什么软件运行要先写入内存? CPU 只能从内存中读取指令和数据。这是体系结构规定的。

  • 内存的使用是权衡的结果

    • 数据本质是从一个设备拷贝(流动)到另一个设备。

    • 如果只用磁盘,速度太慢;如果全部用寄存器,成本过高。

    • 内存作为中间层,在速度和成本之间取得了平衡。

二、操作系统的理解

1. 操作系统的本质

  • 操作系统是一个管理硬件和软件的软件

  • 它包括内核(如进程管理、内存管理、文件系统、驱动管理)和其它程序(如函数库、Shell 程序)。

2. 设计 OS 的目的

  • 向下管理软硬件资源。

  • 向上给用户和应用程序提供良好、稳定的运行和使用环境。

3. 管理的理解

  • 管理 = 先描述,再组织

  • 描述:用结构体/类(如 struct task_struct)将管理对象的属性描述出来。

  • 组织:用数据结构(如链表、队列)将这些描述对象组织起来,方便进行增删查改。

  • 这正是面向对象思想的体现:用类存储数据,用容器进行操作。

4. 操作系统调用

  • 操作系统要向用户程序提供服务,但不信任任何用户

  • 用户和操作系统之间通过库、系统调用、指令等形式进行数据交互。

  • 因此,库函数系统调用之间是上下层关系。

  • 类比:银行的大堂经理(库函数)引导用户办理手续,而真正的业务处理在后台(操作系统)。

三、进程的理解

1. 进程是什么?

  • 为了管理正在运行的程序,操作系统需要用一个结构体去描述它的各项参数(状态、优先级、PID 等)。

  • 这个结构体 + 程序本身的代码和数据 = 进程

  • 这个描述进程的结构体就叫做 PCB(Process Control Block),在 Linux 中具体实现为 task_struct

2. 如何管理进程?

  • 进程的所有属性都可以直接或间接通过 task_struct 找到。

  • 对进程的管理,就变成了对 task_struct 对象链表的增删查改。

3. 查询进程

  • 获取进程 PID

    #include <stdio.h>
    #include <sys/types.h>
    #include <unistd.h>
    int main() {
        while (1) {
            sleep(1);
            printf("%d\n", getpid());
        }
        return 0;
    }
    • getpid() 可以获取当前进程的 PID。

  • 使用 ps 命令查看进程

    bash

    [root@VM-0-7-centos ~]# ps ajx | head -1 && ps axj | grep proc
      PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
     26393  4361  4361 26393 pts/0     4361 S+    1001   0:00 ./proc
      4587  5630  5629  4587 pts/1     5629 S+       0   0:00 grep --color=auto proc
    • ./proc 是我们的进程(PID 4361)。

    • grep 命令本身也是一个进程(PID 5630)。

    • 使用 Ctrl + C 可以终止前台进程。

  • 通过 /proc 文件系统查看

    • /proc 目录下包含了所有运行中进程的信息。

    • 进入 /proc/[PID]/ 目录,可以看到 exe 和 cwd 两个链接:

      • exe -> 进程对应的磁盘上的可执行文件路径。

      • cwd -> 进程的当前工作目录。

    • 当使用 fopen("text.txt", "w") 创建文件时,文件默认会创建在进程的当前工作目录(cwd)下。

    • 可以使用 chdir() 系统调用改变进程的工作目录。

  • 查看父进程

    ps ajx | head -1 && ps axj | grep 26393 | grep -v grep
      PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
     26392 26393 26393 26393 pts/0    26393 Ss+   1001   0:00 -bash
    • 可以看到当前 Shell(bash)的 PID 是 26393,它通常是很多命令进程的父进程。

4. 创建子进程:fork()

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main() {
    printf("父进程运行 %d\n", getpid());
    int id = fork();
    if (id < 0) {
        perror("fork");
        return 1;
    } else if (id == 0) {
        // 子进程
        while (1) {
            sleep(1);
            printf("子进程:%d, 父进程:%d\n", getpid(), getppid());
            fflush(stdout);
        }
    } else {
        // 父进程
        while (1) {
            sleep(1);
            printf("父进程:%d, 父进程:%d\n", getpid(), getppid());
            fflush(stdout);
        }
    }
    return 0;
}
  • 运行结果

    text

    子进程:16186, 父进程:16185
    父进程:16185, 父进程:26393
  • 三个核心问题

    1. 为什么父进程返回一个正数(子进程 PID),子进程返回 0?

      • 类比二叉树:父节点需要有指针指向多个子节点,因此需要知道每个子节点的 ID(返回正数)。

      • 子节点只有一个父节点,用 0 标记自己,指针可以理解为“空”。

    2. 为什么一个 fork() 函数会返回两次?

      • fork() 执行后,操作系统创建了子进程的 PCB。

      • 然后,fork() 函数会在父进程中返回一次(子进程的 PID),也会在子进程中返回一次(0)。

      • 本质上,这是两个进程在执行同一个函数,各自返回。

    3. 为什么一个变量 id 可以有两个不同的值?

      • 进程具有独立性。父进程和子进程的 id 变量不是同一个,它们位于各自的地址空间。

      • 当父/子进程只是读取变量值时,不会发生拷贝。但当其中一个进程要修改变量时,就会触发写时拷贝,操作系统会为修改者拷贝一份数据,使其拥有独立的副本。​​​​​​​

四、进程状态

  • 进程状态本质上是 task_struct 结构体里的一个整数。

1. 运行状态 (R)

  • 进程正在 CPU 上运行,或者位于运行队列中等待被调度。

  • 注意:下面这个程序用 ps 查看时,状态可能是 S 而不是 R

    #include <stdio.h>
    int main() {
        while (1) {
            printf("程序运行1\n");
        }
        return 0;
    }

    • 原因printf 要向显示器输出,这是 I/O 操作,大部分时间进程在等待 I/O(阻塞状态),只有很少时间在运行。因此查询结果多为 S

2. 阻塞状态 (S / D)

  • S(可中断睡眠):进程在等待某个资源或事件(如键盘输入、磁盘 I/O)。当资源就绪或收到信号时,可以被唤醒。

  • D(不可中断睡眠,深度睡眠):也是在等待 I/O,但在此期间即使收到信号也不会被唤醒。主要用于防止在某些极端情况下(如系统内存不足)操作系统误杀还在进行关键 I/O 操作的进程。

3. 暂停状态 (T / t)

  • T:作业控制暂停,通常由用户输入 Ctrl + Z 触发。

  • t:追踪暂停,常见于调试过程中遇到断点时。​​​​​​​

4. 僵尸状态 (Z)

  • 子进程已经退出,但它的大部分资源已经被释放,其 PCB (task_struct) 仍然保留,等待父进程来“收尸”(读取其退出码等信息)。

  • 示例

    #include <stdio.h>
    #include <sys/types.h>
    #include <unistd.h>
    int main() {
        int id = fork();
        if (id == 0) {
            int c = 5;
            while (c--) {
                printf("子进程\n");
                sleep(1);
            }
            // 子进程退出
        } else {
            while (1) {
                printf("父进程\n");
                sleep(1);
            }
        }
        return 0;
    }

    https://media/image6.png

    • 当子进程结束后,如果父进程还在运行且没有调用 wait() 或 waitpid() 来回收子进程的信息,子进程就会变成僵尸进程(Z)

  • 危害:僵尸进程的 PCB 无法被释放,造成内存泄漏。对于常驻内存的服务器程序,如果产生大量僵尸进程,危害较大。

5. 死亡状态 (X)

  • 进程被父进程回收后,彻底消亡,状态变为 X。

6. 孤儿进程

  • 当一个进程的父进程先结束时,这个子进程就会被 1 号进程(init 或 systemd“领养”,成为孤儿进程。​​​​​​​

  • 孤儿进程会变为后台进程,此时 Ctrl + C 无法结束它,通常需要 kill 命令。

五、进程优先级

1. 什么是优先级?

  • 进程获得 CPU 资源的先后顺序。

  • 为什么需要优先级? CPU 资源是稀缺的,需要通过优先级来确认执行顺序,确保多任务的有序进行。

2. 优先级与权限的区别

  • 优先级:先后问题,所有进程最终都能得到 CPU。

  • 权限:能否问题,没有权限的进程无法访问某些资源。

3. 优先级的数值计算

  • 使用 ps -al 命令可以查看进程的优先级信息。

    • PRI:进程的实时优先级,默认值为 80。数值越低,优先级越高。

    • NI:进程的 Nice 值,表示进程优先级的修正值,取值范围是 -20 到 19

  • 最终优先级(影响调度的值) ≈ PRI + NI

  • Nice 值范围较小,是为了保证进程调度的公平性,防止某个进程通过设置极端优先级而长时间霸占 CPU,导致其他进程饥饿

4. 进程的性质

  • 竞争性:多个进程争夺 CPU 资源。

  • 独立性:进程之间互不干扰,一个进程的崩溃不会影响另一个进程。

  • 并行:多个进程在多个 CPU 核心上同时运行。

  • 并发:多个进程在一个 CPU 核心上,通过进程切换的方式交替运行,看起来像同时运行。

六、进程的切换与调度

1. 死循环会打死系统吗?

  • 不会。操作系统是分时系统,会给每个进程分配一个时间片。即使进程是死循环,时间片用完后也会被强制切换出去,让其他进程运行。

2. CPU 寄存器的切换

  • 寄存器是 CPU 内部的临时存储单元。

  • 重要的是:寄存器本身是硬件,只有一套;但寄存器里的数据(上下文)是属于进程的,有多套。

  • 切换过程

    1. 进程 A 运行时,其数据保存在 CPU 寄存器中。

    2. 时间片到了,系统将 CPU 寄存器中的数据(上下文)保存到进程 A 的 task_struct 或内核栈中。

    3. 将进程 B 之前保存的上下文数据加载到 CPU 寄存器中。

    4. 进程 B 开始运行。

  • 因此,保存和恢复上下文是 CPU 进程切换的核心。

3. Linux 进程调度算法(O(1) 调度器)

  1. queue[140]:这是一个存放 task_struct 指针的数组,类似于哈希表。

    • 0~99:实时优先级队列(用于实时操作系统)。

    • 100~139:对应普通进程的 40 个优先级(Nice 值 -20~19 映射至此)。

    • 进程根据其优先级挂在对应的链表上。

  2. unsigned bitmap[5]:这是一个 140 位的位图(5个32位无符号整数)。

    • 每一位对应 queue 数组的一个位置,1 表示该优先级队列中有进程,0 表示空。

    • 调度器可以通过位图快速(O(1) 时间复杂度)找到当前最高优先级且有进程等待的队列。

  3. 两个数组:active 和 expired

    • 只有一个 queue 会带来问题:进程运行完后放回原处,下次调度可能还会选到它,导致低优先级进程永远得不到 CPU。

    • 解决方案:使用两个完全一样的优先级队列数组。

      • active:指向当前正在被调度执行的进程队列。

      • expired:指向已耗尽时间片的进程队列。

    • 进程时间片用完后,被移动到 expired 队列中,并重新计算其优先级(可能调整)。

    • 当 active 队列为空时,交换 active 和 expired 指针。这样保证所有进程都有机会被执行。

  4. 新进程:新创建的进程(就绪状态)通常先被加入到 expired 队列,等待下一轮调度。

七、环境变量

1. main 函数的参数

#include <stdio.h>
int main(int argc, char* argv[]) {
    for (int i = 0; i < argc; i++) {
        printf("argv[%d]:%s\n", i, argv[i]);
    }
    return 0;
}
  • argc:命令行参数的个数。

  • argv[]:命令行参数的字符串数组。

  • 运行 ./code a b

    argv[0]:./code
    argv[1]:a
    argv[2]:b
  • 作用:Shell(bash)将用户输入的命令行字符串拆分,通过这两个参数传递给新创建的进程,使程序可以支持命令行选项(如 ls -l)。

2. 环境变量 PATH

  • 执行 code 时需要写 ./code,但执行 ls 不需要写 ./ls

  • 原因:Shell 会去环境变量 PATH 指定的目录列表中查找可执行程序。

  • PATH 是一个系统环境变量,告诉 Shell 去哪里找命令。

  • 查看所有环境变量env

  • 查看 PATHecho $PATH

  • 临时添加当前目录到 PATHPATH=$PATH:. (关闭 Shell 后失效)

  • 永久生效:需要修改 Shell 的配置文件,如 ~/.bashrc 或 ~/.bash_profile

    cd ~
    ls -la
    # 编辑 .bashrc 文件,在末尾添加 export PATH=$PATH:/your/path

3. 其他常见环境变量

  • HOME:当前用户的家目录。

  • SHELL:当前使用的 Shell 路径。

  • USER:当前用户名。

  • HISTSIZE:Shell 历史命令的保存条数。

  • OLDPWD:上一次所在的目录。

  • 导出和取消环境变量export 命令导入,unset 命令取消。

4. 获取环境变量的三种方式

  1. main 函数的第三个参数

    #include <stdio.h>
    int main(int argc, char* argv[], char* env[]) {
        for (int i = 0; env[i]; i++) {
            printf("%d:%s\n", i, env[i]);
        }
        return 0;
    }
    • 环境变量是由父进程(如 bash)通过 _start 函数传递给子进程的。因此,环境变量具有全局性,可以被子进程继承。

  2. getenv 函数

    #include <stdio.h>
    #include <stdlib.h>
    int main() {
        printf("%s\n", getenv("PATH"));
        return 0;
    }
    • 可以直接获取指定环境变量的值。

    • 应用:可以用它来限制程序只能由特定用户执行。

      if (strcmp(getenv("USER"), "xc") == 0) {
          printf("执行\n");
      }
  3. environ 全局变量

    #include <stdio.h>
    extern char** environ;
    int main() {
        for (int i = 0; environ[i]; i++) {
            printf("%s\n", environ[i]);
        }
        return 0;
    }

5. 本地变量与环境变量

  • 本地变量:在 Shell 中直接定义,如 i=1。只在当前 Shell 进程中有效,不会被子进程继承。

  • 环境变量:可以被所有子进程继承。

  • 查看set 命令可以查看所有变量(包括本地和环境),env 命令只查看环境变量。

  • 转换export i 可以将本地变量提升为环境变量。

6. 内建命令

  • 像 exportcd 这样的命令,如果作为子进程执行,无法改变父进程(Shell)的环境。

  • 因此,它们其实是 Shell 进程自己执行的,不创建子进程。这类命令称为内建命令

八、虚拟地址空间

1. 问题:地址相同,值不同?

if (id == 0) {
    // 子进程
    a++;
    printf("子进程:值:%d,地址:%p\n", a, &a);
} else {
    // 父进程
    printf("父进程:值:%d,地址:%p\n", a, &a);
}
  • 输出子进程:值:2,地址:0x60103c 和 父进程:值:1,地址:0x60103c

  • 两个进程打印出的变量地址一样,但值却不同。

  • 解释:程序打印的地址是虚拟地址,不是物理地址。

2. 虚拟地址空间

  • 每个进程都有一个独立的虚拟地址空间(32 位系统下是 4GB)。

  • 通过 页表,将虚拟地址映射到实际的物理内存地址。

  • 每个进程都有自己的页表。

  • 子进程创建时,会拷贝父进程的虚拟地址空间和页表(浅拷贝),所以相同变量的虚拟地址是一样的。当子进程修改变量时,触发写时拷贝,操作系统为它分配新的物理内存,并修改其页表映射,但虚拟地址保持不变。

3. 页表与写时拷贝

  • 查地址:CPU 拿到虚拟地址,通过页表查询对应的物理地址。

  • 写时拷贝:当页表和虚拟空间拷贝给子进程后,如果子进程要修改变量,操作系统就会在物理内存中开辟新空间,复制原数据,然后修改子进程的页表,使其指向新的物理地址。父进程的页表不受影响。

4. 虚拟地址的分配

  • 虚拟地址空间给每个进程“画了一个 4GB 的大饼”。

  • 但实际上,物理内存是有限的,不可能给每个进程都分配 4GB 的物理内存。

  • 操作系统采用“按需分配”策略:当进程真正访问某个虚拟地址时,如果发现没有对应的物理内存(缺页中断),才去分配物理内存并建立映射。

5. 管理虚拟地址:mm_struct

  • 进程的虚拟地址空间也需要管理。

  • 管理方式依然是 先描述,再组织

  • 描述:用 mm_struct 结构体描述整个虚拟地址空间的属性(如代码段、数据段、堆、栈的起始和结束地址)。

  • 由于内存区域是连续的,只需要记录这些区间的 start 和 end 即可。

6. 程序的加载

  • 可执行程序被加载到物理内存后,操作系统会为其创建 mm_struct 和页表,建立虚拟地址到物理地址的映射。

  • 程序中的代码段、数据段的内容,就对应了虚拟地址空间中的代码区和数据区。

  • const char* str = "hello"; 中的字符串 "hello" 存储在只读数据段(或代码段),该区域在页表中的权限是只读

7. 为什么需要虚拟地址空间?

  1. 将无序变有序:通过页表映射,使每个进程都认为自己拥有连续、有序的内存空间。

  2. 解耦合(降低与内存的相关性)

    • 当物理内存不足时,可以将进程暂时不用的数据(如阻塞进程的数据)从内存移到磁盘的 swap 分区(挂起)。只需清空页表即可。

    • 需要时再加载回内存,重新建立页表映射。这降低了对物理内存容量的硬性依赖。

  3. 保护物理内存(权限管理)

    • 页表中可以设置内存页的访问权限(读、写、执行)。

    • 如果程序试图修改只读段(如 char* str = "hello"; *str = 'a';),CPU 会发现权限不符,触发异常,操作系统会终止该进程,从而保护了内存中代码段等关键数据不被篡改。

8. 理解野指针

  • 野指针指向一个随机的虚拟地址。

  • 如果这个虚拟地址对应的页表项不存在(没有映射到物理内存),CPU 访问时就会触发缺页异常,而操作系统发现这是一个非法访问(没有对应的合法内存区域),就会发送信号终止进程。

  • 如果野指针“侥幸”指向了某个已存在的合法内存区域(如栈、堆),程序可能不会立即崩溃,但会导致难以预料的逻辑错误。

9. 缺页中断

  • 程序加载时,可能只加载了部分代码和数据到物理内存。

  • 当 CPU 执行到某个虚拟地址,发现页表中没有对应的物理内存时,会触发缺页中断。操作系统会暂停当前进程,从磁盘加载所需内容到物理内存,更新页表,然后恢复进程执行。

10. 堆的管理:vm_area_struct

  • 堆空间是动态分配的,其内存区域可能不连续。

  • 在 mm_struct 下,会有链表来管理每一个堆区域,每个区域用一个 vm_area_struct 结构体描述(包含起始地址、结束地址、权限等)。

11. 进程独立性

  • 进程的独立性体现在两个层面:

    1. 内核数据结构独立:每个进程有自己的 task_structmm_struct、页表等。

    2. 加载到内存的代码和数据独立:通过虚拟地址空间和页表映射,每个进程的地址空间相互隔离,一个进程的崩溃不会影响其他进程。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐