计算机硬件基石冯·诺依曼体系结构在理解进程之前首先需要弄清楚程序是在什么样的硬件上运行的。什么是冯·诺依曼体系我们日常使用的计算机——无论是笔记本电脑、台式机还是服务器——绝大多数都遵循冯·诺依曼体系结构。这套体系的核心思想可以概括为程序和数据都存储在存储器中计算机按照存储的程序逐条执行指令。硬件组成冯·诺依曼体系将计算机硬件划分为三大核心部件部件功能常见设备输入单元将外部数据送入计算机键盘、鼠标、扫描仪、手写板中央处理器CPU进行算术运算和逻辑控制包含运算器 控制器输出单元将计算结果输出给用户显示器、打印机几个必须记住的关键规则这里的存储器特指内存而不是硬盘等外部存储。CPU能且只能对内存进行读写无法直接访问外设如键盘、显示器、硬盘——这是在没有考虑缓存Cache的情况下。所有外设输入/输出设备要传输数据也只能通过内存来完成。也就是说所有设备都只能直接和内存打交道。举个生活化的例子想象一下公司里的总裁办CPU和文件室内存总裁办需要数据时只能派人去文件室取不能直接跑到各部门外设去拿。各部门要汇报工作也只能先把文件交到文件室再由总裁办从文件室取阅。文件室成为了整个公司信息流转的唯一枢纽。为什么这样设计原因一速度匹配问题CPU 速度 ≈ 纳秒级10⁻⁹秒内存速度 ≈ 纳秒级10⁻⁹秒— 慢10-100倍硬盘速度 ≈ 毫秒级10⁻³秒— 慢百万倍如果 CPU 直接访问硬盘CPU 99.99%的时间都在等待这是极大的浪费。内存作为缓冲让 CPU 与外设在速度上解耦。原因二统一管理所有数据都通过内存中转操作系统只需管理好内存这一个核心资源降低了管理复杂度原因三程序的可移植性程序只需要以为自己在操作内存具体的硬件差异由操作系统和驱动程序屏蔽数据流动的直观理解QQ聊天场景从你登录QQ开始到给朋友发消息数据是怎么流动的发送方你键盘输入消息 → 数据进入内存CPU从内存读取消息 → 进行编码、加密等处理 → 写回内存网卡输出设备从内存读取数据 → 发送到网络接收方朋友网卡接收到数据 → 写入内存CPU从内存读取 → 解码、解密 → 写回内存显示器从内存读取 → 显示消息发送文件也是同样的路径文件从硬盘 → 内存 → CPU处理 → 内存 → 网卡 → 网络 → 对方网卡 → 内存 → CPU处理 → 内存 → 硬盘。操作系统OS一切的管理者什么是操作系统操作系统是一个基本的程序集合它本质上是一个软件但它的地位非常特殊——它是整个计算机系统中最核心的系统软件。操作系统的构成内核Kernel包含进程管理、内存管理、文件管理、驱动管理其他程序函数库、Shell程序等操作系统的两大设计目标对上为用户程序应用程序提供一个良好的执行环境对下与硬件交互管理所有的软硬件资源操作系统 一款搞管理的软件操作系统的核心功能就是管理管理CPU进程调度管理内存内存分配与回收管理文件文件系统管理设备驱动程序如何理解管理管理的本质分两步描述被管理对象用数据结构如结构体来描述一个对象的属性组织被管理对象用高效的数据结构如链表将这些对象组织起来这也正是操作系统管理进程的核心思想先把进程描述起来再把进程组织起来系统调用与库函数操作系统为了保护自己不会把所有功能都暴露给用户程序。它只暴露一部分接口供上层开发使用这些接口就叫做系统调用。用户程序↓库函数封装↓系统调用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() │ ├── 内核创建子进程几乎完全拷贝父进程 │ ├── 父进程返回子进程PIDret 0 │ └── 子进程返回 0ret 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()回收孤儿进程防止资源泄漏。进程优先级谁先上CPU1. 什么是优先级优先级PRI决定进程获取 CPU 资源的先后顺序值越小优先级越高。2. Nice 值修正值NInice取值范围-20 ~ 19。PRI(new) PRI(old) nicenice 为负 → 优先级提高但需要 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_PRIO1); struct list_head queue[MAX_PRIO]; };140 个优先级0~99 实时100~139 普通。用bitmap5 个 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] 1233. 重要特性特性1argv[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 MYENVhello unset MYENV3. 代码中获取#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虚拟内存区域描述一段连续的虚拟地址区间如栈区、堆区、代码段等。