一、冯诺依曼体系结构
计算机诞生初期,程序的修改和加载非常繁琐,每次运行都需要人工拨动开关或重新连线。冯诺依曼提出将程序和数据存储在同一个存储器中的思想,奠定了现代计算机的基础。
体系结构由以下硬件组件构成:
组件 | 说明 |
输入单元 | 键盘、鼠标、扫描仪等 |
中央处理器(CPU) | 包含运算器和控制器 |
输出单元 | 显示器、打印机等 |
关键要点:CPU 只能对内存进行读写,不能直接访问外设。所有外设的输入输出也只能写入内存或从内存读取。内存是数据交换的唯一中间站。
程序运行中的数据流:键盘输入到内存,CPU 从内存读取并处理,结果写回内存,再输出到显示器。整个过程数据始终经过内存中转。
二、操作系统概念
2.1 操作系统的定位
早期的计算机程序直接运行在裸机上,程序员需要手动管理所有硬件资源,效率极低且容易出错。操作系统应运而生,向下与硬件交互管理所有软硬件资源,向上为应用程序提供良好的执行环境。其本质是一款纯正的"搞管理"的软件。
2.2 管理的本质
操作系统如何管理硬件?核心方法就是"先描述再组织"。
- 描述:用结构体(struct)将被管理对象的信息封装起来。例如用 task_struct 描述进程信息。
- 组织:用链表或其他高效数据结构将描述信息组织起来,方便增删改查。
计算机管理硬件的本质:描述用 struct,组织用链表。
2.3 系统调用与库函数
操作系统对外暴露部分接口供上层使用,这些接口称为系统调用。系统调用功能基础但使用门槛较高,因此开发者将其封装为库函数,方便二次开发。例如 printf 是对 write 系统调用的封装,malloc 是对 brk 系统调用的封装。
三、进程概念
3.1 进程的定义
在单道程序时代,CPU 一次只能执行一个程序,资源利用率极低。为了充分利用 CPU 资源并支持多任务场景,操作系统中引入了进程的概念。
进程是程序的一个执行实例,是正在运行的程序。从内核角度看,进程是担当分配系统资源(CPU 时间、内存)的实体。
进程 = 内核数据结构(task_struct)+ 程序代码和数据
3.2 进程控制块(PCB)
为了管理进程,操作系统需要记录每个进程的详细信息,由此产生了 PCB。Linux 下称为 task_struct,它包含了进程的所有属性信息(举例部分):
成员 | 说明 |
标识符 | 本进程的唯一标识符(PID),用于区分不同进程 |
状态 | 任务状态、退出代码、退出信号等 |
优先级 | 相对于其他进程的优先级,决定 CPU 调度顺序 |
程序计数器 | 即将被执行的下一条指令的地址 |
内存指针 | 程序代码和进程相关数据的指针,以及共享内存块指针 |
上下文数据 | 进程执行时处理器的寄存器中的中间数据 |
I/O 状态信息 | I/O 请求、分配给进程的 I/O 设备列表 |
3.3 查看进程
进程信息可以通过 /proc 文件系统查看。例如查看 PID 为 1 的进程信息:cat /proc/1/status。更常用的方式是使用 ps 和 top 工具。
ps aux | grep 进程名
ps axj | head -10
top -d 1 -n 5
测试样例:编写一个死循环程序运行,在另一个终端执行 ps aux 观察其状态:
#include <stdio.h>
#include <unistd.h>
int main() {
while(1) { sleep(1); }
return 0;
}
3.4 创建进程 fork
fork 是 Unix 系统经典的进程创建方式。调用成功后,父进程和子进程共享代码,数据各自独立(采用写时拷贝技术)。fork 有两个返回值:父进程返回子进程的 PID,子进程返回 0。
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if(pid == 0) {
printf("子进程运行中, PID: %d\n", getpid());
} else if(pid > 0) {
printf("父进程运行中, 子进程 PID: %d\n", pid);
}
printf("这句话父子进程都会执行\n");
return 0;
}
fork 之后通常要用 if 进行分流。注意 fork 的返回值设计使得一个变量同时出现在两个分支中成为可能,这是因为父子进程的数据空间是独立的。
测试样例:运行后观察父子进程交替执行的输出结果,注意两条 printf 都会被执行。
四、进程状态
4.1 七种状态
多任务系统中,进程在生命周期内会经历多种状态切换。操作系统通过状态管理来合理分配 CPU 和内存资源。Linux 内核源代码中定义了七种进程状态:
状态 | 标识 | 说明 |
R | 运行 | 进程在运行队列中,可能正在运行或等待调度 |
S | 浅度睡眠 | 可中断睡眠,等待事件完成,能响应外部信号 |
D | 深度睡眠 | 不可中断睡眠,通常等待 IO 完成,OS 也无法杀掉 |
T | 暂停 | 收到 SIGSTOP 信号后暂停,可通过 SIGCONT 恢复 |
t | 追踪 | 被调试器跟踪时因断点而暂停 |
X | 死亡 | 进程结束,瞬间状态,在任务列表中看不到 |
Z | 僵尸 | 进程已退出但未被父进程回收,PCB 仍保留 |
4.2 僵尸进程
当子进程退出而父进程没有读取子进程的退出状态时,子进程进入 Z 状态。退出状态保存在 task_struct 中,因此 PCB 必须一直维护,导致内存资源无法释放。如果父进程创建了大量子进程却不回收,会造成严重的内存泄漏。
僵死进程会以终止状态保持在进程表中,等待父进程读取退出状态代码。
pid_t id = fork();
if(id > 0) {
sleep(30); // 父进程休眠,不回收子进程
} else {
exit(0); // 子进程立即退出,成为僵尸
}
测试样例:编译后在一个终端运行,另一个终端用 ps aux 查看子进程状态为 Z。30 秒后父进程退出,子进程被 init 回收。
4.3 孤儿进程
父进程先于子进程退出时,子进程成为孤儿进程。孤儿进程会被 1 号 init 进程或 systemd 进程领养,由它们负责回收,避免无人管理的进程残留。
pid_t id = fork();
if(id == 0) {
printf("子进程运行中\n");
sleep(10);
} else {
exit(0); // 父进程先退出
}
测试样例:运行后用 ps 查看孤儿进程的 PPID 变更为 1。
4.4 进程等待
为避免僵尸进程,父进程应主动回收子进程的退出状态。wait 和 waitpid 是常用的系统调用。wait 等待任意子进程退出,waitpid 可指定等待特定子进程。
pid_t id = fork();
if(id > 0) {
int status;
waitpid(id, &status, 0); // 等待指定子进程
if(WIFEXITED(status)) {
printf("子进程退出码: %d\n", WEXITSTATUS(status));
}
}
五、进程优先级
5.1 基本概念
在分时系统中,多个进程竞争有限的 CPU 资源。为了让重要任务优先执行,操作系统引入了优先级机制。配置进程优先级对多任务环境的 Linux 很有用,可以改善系统性能。
PRI 表示进程的优先级,值越小越早被执行。NI(nice 值)是优先级的修正数值。
计算公式:PRI(new) = PRI(old) + nice
nice 的取值范围是 -20 到 19,共 40 个级别。注意 nice 值不是进程优先级,而是优先级的修正数据。
5.2 修改优先级
启动程序时指定 nice 值:nice -n 5 ./program(将优先级调低)
修改正在运行的进程:使用 top 命令,按 r 键,输入 PID 和新的 nice 值
也可以使用 renice 命令或系统调用 setpriority 来修改。
5.3 竞争、独立、并行与并发
概念 | 说明 |
竞争性 | 系统进程数目众多,CPU 资源有限,进程间具有竞争属性 |
独立性 | 多进程运行期间独享各种资源,互不干扰 |
并行 vs 并发 | 并行:多 CPU 同时运行多进程。并发:单 CPU 通过进程切换在一段时间内推进多进程 |
六、环境变量与命令行参数
6.1 环境变量产生的背景
不同用户的系统环境各不相同,例如命令搜索路径、默认工作目录等。为了让程序在不同环境下都能正常工作,操作系统提供了环境变量机制。环境变量通常具有全局特性,可以被子进程继承下去,从而在进程间传递配置信息。
6.2 常见环境变量
变量名 | 说明与用途 |
PATH | 命令搜索路径,让系统自动找到可执行程序。通过 echo 命令可查看当前值 |
HOME | 用户的主工作目录。执行 cd ~ 时跳转至此目录 |
SHELL | 当前使用的 Shell 路径,通常为 /bin/bash |
6.3 环境变量的组织方式
环境变量表本质上是一个字符指针数组,每个指针指向一个以 \0 结尾的字符串,格式为 "变量名=值"。当操作系统加载一个程序时,都会将一张环境表传递给该进程。
6.4 通过代码获取环境变量
方式一:main 函数的第三个参数 envp
#include <stdio.h>
int main(int argc, char argv[][], char envp[][]) {
for(int i = 0; envp[i]; i++)
printf("%s\n", envp[i]);
return 0;
}
方式二:全局变量 environ(需要 extern 声明)
#include <stdio.h>
int main() {
extern char environ[][];
for(int i = 0; environ[i]; i++)
printf("%s\n", environ[i]);
return 0;
}
方式三:系统调用 getenv(最常用,直接获取指定变量)
#include <stdio.h>
#include <stdlib.h>
int main() {
char result = getenv("PATH");
if(result) printf("PATH value found\n");
return 0;
}
6.5 环境变量的全局属性
环境变量可以被子进程继承。测试方法:在当前终端用 export 导出新变量后,运行程序即可读取到该变量。若只赋值不 export,则仅为 shell 本地变量,子进程无法继承。
# 终端执行
export MYENV=hello
# 然后运行以下程序
#include <stdio.h>
#include <stdlib.h>
int main() {
char buf = getenv("MYENV");
if(buf) printf("MYENV is set\n");
return 0;
}
测试:分别尝试 export 导出和不 export 直接赋值,观察程序能否读取到变量,验证环境变量的继承特性。
6.6 命令行参数
命令行参数允许用户在启动程序时传入配置信息,使同一程序可以处理不同的输入数据。main 函数的参数 argc 表示命令行参数的个数(包括程序名本身),argv 是参数字符串数组,argv[0] 是程序名。
#include <stdio.h>
int main(int argc, char argv[][]) {
printf("参数个数: %d\n", argc);
for(int i = 0; i < argc; i++)
printf("argv[%d]: %s\n", i, argv[i]);
return 0;
}
测试:编译后执行 ./a.out hello world 123,观察 argc 为 4,argv[0] 为 ./a.out,argv[1] 到 argv[3] 分别为传入的三个参数。
6.7 相关命令
命令 | 作用 |
echo | 显示某个环境变量的值 |
export | 设置新的环境变量 |
env | 显示所有环境变量 |
unset | 清除指定的环境变量 |
set | 显示本地定义的 shell 变量和环境变量 |
七、进程地址空间
7.1 虚拟地址空间
在早期计算机中,程序直接运行在物理内存上,地址冲突和越界访问问题严重。虚拟地址空间机制解决了安全性和灵活性问题。在 C/C++ 程序中看到的地址都是虚拟地址,而非物理地址。操作系统通过页表将虚拟地址映射到物理地址。
验证方法:父子进程修改同一全局变量后,变量值不同但地址相同,说明该地址是虚拟地址。
int counter = 0;
pid_t pid = fork();
if(pid == 0) {
counter = 50;
printf("子进程: %d, addr: %p\n", counter, &counter);
} else {
sleep(1);
printf("父进程: %d, addr: %p\n", counter, &counter);
}
测试样例:运行后子进程将 counter 改为 50,父进程仍为 0,但两个进程打印的地址完全相同,从而证明这些地址是虚拟地址。
7.2 进程地址空间布局
进程地址空间由高地址到低地址依次为:内核空间、命令行参数与环境变量、栈区、共享区、堆区、未初始化数据段(BSS)、初始化数据段、代码段。每个进程都有独立的 mm_struct 结构体描述整个用户空间。
不同的虚拟内存区域使用 vm_area_struct 结构体表示。当虚拟区较少时采用单链表组织,由 mmap 指针指向;当虚拟区间较多时采用红黑树管理,由 mm_rb 指向。
7.3 虚拟地址的作用
引入虚拟地址空间带来了三大好处:
- 保护内存安全:所有地址访问必须经过页表映射,在操作系统监管下进行。恶意程序无法随意读写其他进程或内核的内存区域。
- 进程与内存解耦:物理内存可以任意位置加载,进程管理模块和内存管理模块相互独立。程序在物理内存中的位置不再受限于编译时的地址。
- 延迟分配:malloc 申请的空间先分配虚拟地址,实际物理内存在访问时才分配,通过缺页中断触发页表建立。这极大提高了内存利用率。
八、进程调度与切换
8.1 进程切换
为了让多个进程共享单个 CPU,操作系统需要实现进程间的快速切换。当多任务内核决定运行另一个任务时,会保存当前任务的 CPU 寄存器状态(入栈),然后加载新任务的寄存器状态(出栈),这个过程称为上下文切换(context switch)。时间片到达时,进程就会被操作系统从 CPU 上剥离。
8.2 Linux 2.6 O(1) 调度算法
早期 Linux 内核使用 O(n) 调度算法,每次调度都需要遍历所有进程,效率随进程数增加而下降。Linux 2.6 内核引入 O(1) 调度算法,时间复杂度降为常数,不随进程数量增长。
其核心数据结构包含:
- 活动队列(active):存放时间片尚未结束的进程。共有 140 个优先级队列(优先级 100-139),通过 bitmap 位图(5 个 32 位整数)加速查找非空队列。
- 过期队列(expired):存放时间片已耗尽的进程,结构和活动队列完全相同。
- active 指针和 expired 指针分别指向活动队列和过期队列。当活动队列为空时,只需交换两个指针,过期队列立即变为新的活动队列,实现无缝调度。
调度效率与进程数量无关,复杂度为 O(1)。
九、总结速查表
操作 | 命令或函数 |
查看进程 | ps aux / ps axj / top |
创建进程 | fork() |
获取进程标识符 | getpid() / getppid() |
等待子进程 | wait() / waitpid() |
终止进程 | exit() / _exit() |
设置进程优先级 | nice / renice / setpriority |
查看环境变量 | env / getenv |
设置环境变量 | export / putenv / setenv |
查看进程状态 | cat /proc/PID/status |
查看内存映射 | cat /proc/PID/maps |
修改环境变量 | echo / export / unset |