目录
- 一、认识冯·诺依曼体系结构
- 二、操作系统
-
- 1.操作系统的本质
- [2.操作系统管理的核心逻辑: 先描述,再组织](#2.操作系统管理的核心逻辑: 先描述,再组织)
- 3.操作系统的分层架构
- 三、深入理解进程概念
-
- 1.进程概念
- 2.创建进程
- 3.进程状态
-
- [1. 僵尸进程](#1. 僵尸进程)
- [2. 孤儿进程](#2. 孤儿进程)
- 3.调度、阻塞和挂起
- [4. 进程调度](#4. 进程调度)
- [5. Linux进程优先级](#5. Linux进程优先级)
-
- 1.什么是进程优先级?
- 2.为什么要有优先级?
- [3. Linux优先级的查看与修改](#3. Linux优先级的查看与修改)
- 6.进程切换
- 四、环境变量
-
- [1. 命名行参数](#1. 命名行参数)
- [2. 环境变量](#2. 环境变量)
- 五、地址空间
一、认识冯·诺依曼体系结构

冯·诺依曼体系定义了计算机的五大核心部件,设备之间是相互链接的:
- 输入设备:键盘、鼠标、摄像头、硬盘、网卡等,负责把数据写入存储器(内存)
- 输出设备:显示器、声卡、硬盘、网卡等,是从存储器读取数据
- 存储器 :核心就是内存,本质上是一个巨大的缓存区,数据和程序都要先到这里,CPU才能处理
- 运算器:负责加减乘除、逻辑判断等所有计算工作
- 控制器:整个计算机的指挥中心,协调所有部件按步骤干活
重点:冯诺依曼结构图就表明了存储器(内存)是整个数据交互的中心枢纽
核心体现:
- CPU不直接和外设打交道,只和内存打交道
- 外设的输入/输出数据,也必须先交给内存,再由CPU处理或写回
💡关键理解
-
数据流动的本质是拷贝:数据不是直接 "瞬移" 到另一个设备上,而是从一个设备复制到另一个设备。比如你打字,键盘把信号拷贝到内存,CPU从内存读取计算,将结果拷贝到内存,最后拷贝到显示器上
-
数据在设备间拷贝的快慢,直接决定了电脑的整体运行效率
补充:存储金字塔

这张金字塔讲的是计算机里不同存储设备的层级关系,记住这个规律:
离 CPU 越近 → 速度越快、容量越小、成本越高
冯诺依曼体系为什么要这么设计?
硬盘是离CPU最远的设备,可以说速度是非常慢的。但是CPU的速度是非常快的,这就导致两者的时间差很大,直接让CPU等硬盘会浪费大量的时间,所以设计了缓存(内存)来作中转站,把内存作为中间层,就可以用"缓冲"的方式解决速度差问题:外设先把数据放到内存,CPU再按需读取,处理完再写回内存,外设再慢慢输出,互不阻塞
二、操作系统
1.操作系统的本质
操作系统是管理软硬件资源的软件。对上给用户和应用程序提供统一、简单的接口,不用直接操作复杂的硬件,对下管理所有的硬件资源(CPU、内存、磁盘、网卡等)
💡关键 :**用户/程序永远不能直接操作硬件,必须通过操作系统!**就像你去银行存钱,不能直接进金库,必须通过柜员(操作系统)办理,银行谁也不相信只相信自己,OS也是只相信自己
2.操作系统管理的核心逻辑: 先描述,再组织
例子:老师是如何 "管理" 学生的?
1.先描述
要管理一个对象(学生/进程/硬件设备),首先得先用数据结构把它的属性信息描述出来
管理学生:用struct stu结构体,记录姓名,学号,成绩等信息进行描述。
管理进程:操作系统会用struct task_struct这样的结构体,记录进程的ID,状态,优先级,内存地址等信息。
管理硬件:比如strcut dev结构体,记录设备的类型,厂商,状态等。
简单说:你要管理它,就得先把它的信息用数据结构 "装起来"
- 再组织
描述完对象后,操作系统会用链表,队列,哈希表等数据结构,把这些结构体组织起来,方便统一的管理。
管理学生:用链表struct stu* head把所有的学生串起来,方便增删改查。
管理进程:用进程链表,进程队列,把所有的进程按照状态或其他进行组织,方便操作系统调度
💡本质:操作系统对所有资源的管理,本质上都是对数据的管理,也就是数据结构! 这也是为什么学操作系统前要先学数据结构的原因。
3.操作系统的分层架构

由下到上:硬件 → 驱动 → 操作系统内核 → 系统调用 → 库函数 / Shell → 可执行程序 → 用户,每一层都在为上层提供服务,同时隐藏了下层的复杂性。分层抽象,每一层只关心自己的事情,不管下一层的实现细节。
三、深入理解进程概念
1.进程概念
操作系统中,进程可以同时存在非常多!操作系统得对进程进行管理 (先描述,再组织)
- 要管理进程,首先得用数据结构把进程的信息 "描述" 出来,这就是PCB(process control block 进程控制块)
- 再用链表/队列把所有PCB组织起来,方便操作系统管理调度
PCB里面都有啥?
在Linux下,struct PCB实际上是struct task_struct包含:
- 进程标识信息:PID(进程ID),PPID(父进程ID)每个ID都是唯一的
- 进程状态:运行,就绪,阻塞,挂起
- 资源信息:打开的文件描述符,内存地址空间,CPU寄存器上下文
- 链接信息:
struct task_struct* next,把所有进程串起来,方便管理
进程的本质:进程=内核task_struct结构体+程序的代码和数据
PID:进程的唯一标识
- 每个进程都有自己的唯一标识PID,存在
task_struct里 - 程序可以通过系统调用
getpid()获取自己的进程PID,通过系统调用getppid()获得父进程的PID - 每次运行同一个程序,都会创建新的进程,PID也会不同
2.创建进程

图文解释:
在程序运行前,操作系统内核首先从磁盘被加载到内存并常驻。这是因为操作系统作为硬件和用户之间的"中间层",负责提供内存分配,进程创建,CPU调度,中断处理等核心服务。若操作系统只在磁盘上,每次服务请求都需要读取磁盘,系统性能无法满足运行需求。随后,用户程序的可执行文件(如processbar)被加载到内存,形成代码段和数据段。此时,操作系统为该程序创建PCB,记录其PID,状态,内存指针等信息,并将其加入到系统进程管理链表。只有当PCB创建完成后,程序才真正成为一个被操作系统管理的进程,可被CPU调度执行。
从磁盘到运行的顺序:

为什么是这个顺序?
如果先创建PCB在加载到内存 :PCB里面的内存指针一开始是 "空的",没有指向任何有效地址,操作系统无法知道这个进程的代码和数据在哪里,就无法调度CPU执行
先加载再创建PCB:PCB的内存指针就可以直接指向已经加载好的内存区域了,操作系统拿到PCB就能直接找到进程的所有资源,完成调度,执行,管理。
总结:
- 硬盘上的processbar二进制文件,只是一段静态的代码和数据
- 当运行的时候,操作系统会把它加载到内存,创建一个进程,为它分配资源,让CPU执行它的指令
3.进程状态
Linux内核用struct task_struct里的state字段标识进程状态,内核里定义了对应的字符串数组:
c
static const char* const tast_state_array[]={
"R(running)", //0 --运行/就绪状态
"S(sleeping)", //1 --可中断睡眠
"D(disk sleep)", //2 --不可中断睡眠(深度睡眠)
"T(stopped)", //4 --暂停状态(可恢复)
"t(tarcing stop)", //8 --调试暂停(gdb断点)
"X(dead)", //16 --进程完全退出(被回收)
"Z(zombie)" //32 --僵尸进程(已退出,等待父进程回收)
}
| 状态 | 符号 | 核心特点 | 典型场景 |
|---|---|---|---|
| 运行态 | R | 进程要么正在CPU上运行,要么在运行队列里面等待调度(就绪) | 所有正在占用CPU,或者排队等CPU的进程 |
| 可中断睡眠 | S | 进程等待资源(IO资源等)可被信号唤醒 | read()/scanf()阻塞,等待用户输入的进程 |
| 不可中断睡眠 | D | 深度睡眠,不响应信号,只能自己醒来 | 磁盘IO读写时(如写文件),怕被打断导致数据损坏 |
| 暂停态 | T | 进程被暂停,不允许也不退出,可被信号恢复 | kill -STOP暂停进程,kill -CONT恢复进程 |
| 调试暂停 | t | 调试器(gdb)打断点暂停 | 进程停在断点处 |
| 死亡态 | X | 进程完全退出了,PCB被内核回收,不存在了 | 进程被正常回收后,就会进入这个状态,只是比较短暂,难以捕捉 |
1. 僵尸进程
成因 :子进程先执行完退出,父进程还活着,但是没有调用wait()/waitpid()读取子进程的退出信息
特点 :子进程的代码和数据已经释放,但PCB进程控制块会保留,状态显示为Z+
危害 :占用系统资源(PCB),如果大量僵尸进程堆积,会导致内核无法创建新进程(因为PID和PCB资源有限),会出现 "内存泄漏问题"
解决 :父进程调用wait()/waitpid()回收,或父进程退出后由init(PID=1)进程领养回收
示例:
c
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
pid_t id=fork();
if(id>0)
{
//father
printf("我是父进程,PID=%d,PPID=%d\n",getpid(),getppid());
sleep(10);
}
else if(id==0)
{
//child
printf("我是子进程,PID=%d,PPID=%d\n",getpid(),getppid());
}
return 0;
}
让父进程休眠10s,子进程先退出,此时父进程还在休眠,没有及时回收子进程,会出现僵尸进程,10s后才会回收掉子进程

2. 孤儿进程
成因:父进程先退出,子进程还在运行,变成了"没人管的孩子"
特点:会被系统的init(PID=1) 的进程领养,变成init的子进程,退出时会被init自动回收,不会变成僵尸进程
意义:避免子进程退出后没人回收,导致资源泄漏
示例:
c
int main()
{
pid_t id=fork();
if(id>0)
{
//father
printf("我是父进程,PID=%d,PPID=%d\n",getpid(),getppid());
}
else if(id==0)
{
//child
int cnt=5;
while(cnt--)
{
printf("我是子进程,PID=%d,PPID=%d\n",getpid(),getppid());
sleep(2);
}
}
return 0;
}
运行结果:现象

为什么PPID会变成1?
从代码中可以看到,父进程是先结束的,当父进程(PID 6005)退出后,子进程(PID 6006)就失去了它的父进程,变成了一个孤儿进程。Linux系统规定:所有的孤儿进程,都会被系统的init(PID=1)的进程收养,所以子进程的父进程ID(PPID)会有6005变成1。
为什么命令提示符会再次出现?
谁占用终端,谁就拥有命令提示符。 最开始输入./testexe→bash运行这个程序,bash把终端控制权交给该进程,此时不会再显示命令提示符,fork出父子进程,父进程一退出,终端控制权立刻还给了bash,bash拿到控制权,马上打印命令提示符,但是不影响子进程的输出,所以就出现了上述的现象
3.调度、阻塞和挂起
状态流转:
就绪 → 运行:CPU调度器选中它,给它分配CPU时间
运行 → 就绪:时间片用完,被系统强制切换
运行 → 阻塞:进程主动等待事件(比如read读键盘),主动放弃CPU
阻塞 → 就绪:等待的事件发生(比如用户按下键盘,读取到键盘资源),被唤醒,重新进入就绪队列
阻塞状态
- 当进程需要等待I/O(比如读键盘、读磁盘)时,会主动进入阻塞状态
- 它不会留在CPU的运行队列里,而是被移到对应设备的等待队列(wait_queue)里。(比如读键盘的进程,会进入键盘设备的wait_queue)
- 当设备完成I/O(比如用户按下键盘),设备驱动会唤醒等待队列里的进程,把它移回到CPU的运行队列,重新变成就绪状态
每个设备都有自己的wait_queue,就是用来管理等待它的进程的
挂起状态 :内存不足时的"磁盘交换"
挂起状态(Swap),解决的时"内存不够用怎么办"的问题:
- 当内存不足时,操作系统会暂时把不活跃的进程的代码和数据,换出到磁盘的
swap分区(虚拟内存) - 进程的
task_struct(PCB,进程控制块)会留在内存里,但进程本身变成了挂起状态 - 当进程需要运行时,再把它的数据从磁盘换回内存,恢复成就绪/运行状态
缺点:磁盘的读写速度远慢于内存,会导致系统性能下降(这就是"用时间换空间")
总结:
| 概念 | 理解 |
|---|---|
task_struct |
Linux里描述进程的结构体(PCB),包含进程状态、PID、优先级等所有信息 |
运行队列runqueue |
存放所有就绪进程的队列,CPU调度器从这里选进程 |
等待队列wait_queue |
存放等待I/O/事件的进程队列,每个设备都有自己的等待队列 |
| 时间片轮转 | 让多个进程再单核CPU上"同时运行"的调度方式 |
| 阻塞状态 | 进程主动等待事件,放弃CPU,进入设备等待队列 |
| 挂起状态 | 内存不足时,进程数据被换到磁盘swap分区,用时间换空间 |
4. 进程调度
进程调度的本质:操作系统怎么"同时" 跑多个进程?
1.调度的核心机制:进程队列
- 操作系统会把所有的进程的
struct task_struct,放到不同的队列里(运行队列,就绪队列,阻塞队列) - CPU每次只能执行一个进程,它会按时间片轮转,轮流执行队列里的进程
2.动态运行的核心:进程在不同的队列间切换
只要我们的进程task_struct将来在不同的队列中,进程就可以访问不同的资源
- 进程处于运行队列时,CPU正在执行它的指令,它就能访问CPU资源了
- 进程处于就绪队列时,它已经准备好运行,只等CPU调度
- 进程处于阻塞队列时,它在等待IO(比如读文件,网络请求),CPU会切换去执行其他进程
💡对用户来说,多个进程好像在"同时"运行,但本质上时CPU在快速切换这些task_struct,这些都是"并发"给用户带来的错觉
并行 :同一时刻,多个进程真的在同时运行(前提是多核CPU,每个进程用各自的CPU,互不干涉)
并发:同一时间段内,多个进程轮流使用同一个CPU,通过快速切换,让用户感觉它们在"同时"运行(单核CPU,或者进程数量超过CPU数量,CPU靠时间片轮转调度,给进程轮流分配执行权)
5. Linux进程优先级
1.什么是进程优先级?
进程优先级,就是进程获取CPU资源的先后顺序
在Linux中,优先级数字越小优先级越高 ,进程的优先级信息,存储在task_struct(进程控制块)中,由内核维护
2.为什么要有优先级?
核心原因是资源有限:
- CPU资源是有限的,但系统中同时运行的进程很多
- 如果没有优先级调度,系统可能会出现 "饥饿问题"---某些进程长时间得不到CPU时间片,永远无法运行
- 优先级调度的目的是在"保证基本公平"的情况下,优先调度更重要的进程,同时避免其他进程饿死
3. Linux优先级的查看与修改
1.查看优先级的命令:ps -al查看所有终端的进程

列表头中的
PRI:表示进程优先级(默认都是80)
NI:nice值(这里是0)
nice值有范围限制:范围是[-20,19],一共40个值,Linux的优先级是通过PRI和nice调整的,优先级PRI = PRI默认值80 + nice的 ,则优先级PRI的范围在[60,99],数字越小优先级越高,其中只有root能设置负的nice值,普通用户只能设置0~19,防止恶意程序抢占CPU
2.如何修改进程优先级?
int nice(int inc); 程序运行时自己修改优先级
参数 inc 不是直接设置的新nice值,而是增量
示例:在进程中修改
bash
nice(5) #当前的nice值+5(优先级降低)
nice(-2) #当前的nice值-2(优先级提高,需要root权限/指令提权)
返回值:成功返回修改后的新nice值,失败返回-1并设置errno
renice [新nice值] -p [PID] 进程已经跑起来了,在终端里手动修改它的优先级
示例:
bash
renice 5 -p 1234 #将PID为1234的进程nice值改为5
sudo renice -5 -p 1234 #将PID为1234的进程的nice值改为-5
6.进程切换
Linux进程切换的核心原理:上下文保护与恢复
1.什么是进程上下文?
进程在CPU上运行时,所有临时状态都存在CPU寄存器里,比如:
eax/ebx/ecx等通用寄存器(存计算结果,临时变量)eip(指令指针,存下一条要执行的指令地址)CR0~CR4控制寄存器(存进程的内存管理,状态信息)
CPU内部的所有寄存器中的临时数据,就叫做进程的上下文
2.进程切换的核心矛盾
CPU寄存器是硬件,只有一套,但系统里有很多进程。
矛盾点:进程1运行时,寄存器里全是它的数据,时间片用完了,要切换到进程2运行,如果直接覆盖寄存器,进程1的状态就全丢了,下次再切回来时,它根本不知道自己执行到哪里了,又要重头来,所以进程切换最关键的一步就是上下文的保存与恢复
3.进程切换的完整流程
1.保存上下文(切出进程1)
当进程1的时间片用完时,系统会把CPU寄存器里的所有数据(进程1的上下文),全部复制到进程1的task_struct(PCB进程控制块)里的"上下文数据"区域保存起来,这样进程1的状态就完整存好了,它可以被放到就绪队列里排队,等待下一次调度。
2.恢复上下文(切入进程2)
当调度器选中进程2运行时,系统会从进程2的task_struct里,把它之前保存的上下文数据,全部写回到CPU寄存器里,CPU寄存器恢复成进程2上次运行结束时的状态,进程2就可以接着之前的状态继续执行了。
四、环境变量
1. 命名行参数
1.int main(int argc, char* argv[])是什么?
argc是命令行参数的个数(包括程序本身在内)
argv是一个字符串数组,存了argc个命令参数,数组最后自动以NULL结尾
示例:
bash
./myprocess -a -b -c
argc=4 (因为有./myprocess、-a、-b、-c四个参数)
argv[0]=./myprocess(程序路径+名称)
argv[1]=-a
argv[2]=-b
argv[3]=-c
argv[4]=NULL

图文解释:在终端输入命令行./myprocess -a -b -c首先会背父进程bash接收,bash会解析这行命令,将它拆分成多个字符串,构建出argv数组,然后通过exec*系列系统调用传递给新创建的myporcess进程,所以argc/argv本质上是父进程传递给子进程的命令行参数
2.命令行参数的意义:像ls -l这类带选项的命令,都是通过解析argv来实现的,后续我们也可以自己实现一个简单的shell
2. 环境变量
1.什么是环境变量?
环境变量是bash进程里的全局配置信息,相当于系统给程序的"全局备忘录",用来告诉程序一些关键信息,比如:
PATH:命令搜索路径,bash会按这里的目录顺序找可执行文件
HOME:当前用户的家目录
PWD:当前所在的工作目录
SHELL:使用的shell类型
HISTSIZE:历史命令能保存的最大条数
2.查看和修改环境变量
env查看所有的环境变量

echo $环境变量名:查看单个变量的值

export name=value:创建/修改环境变量
把一个变量升级为环境变量,这样它就可以被当前Shell的子进程继承
将HelloWorld声明成环境变量,可以通过查看单个变量的方式查看

在环境变量表中也能查看到HelloWorld变量,说明已经创建成功了

加export :环境变量(自己和子进程都能用),这个变量会被放进环境表 ,当前Shell能看见,子进程(比如你运行的C程序,bash脚本)也能继承
当不加export时
也可以通过查看单个变量的方式查看

不加export :本地变量(只给自己用),这个变量只存在于当前bash进程里,当前Shell能看见,echo $name能输出,但子进程看不见
总结 :不加export(本地变量--局部),加export(环境变量--全局) ,两种变量都能用echo $xxx查看,
unset name删除环境变量
把一个环境变量从当前Shell中移除

将HelloWorld从环境变量表中移除后,就找不到它的值了,已经不存在了

3.环境变量的完整生命周期
1.磁盘上的源头:配置文件
环境变量的初始定义都在这些文件里:
- 系统级 :
/etc/bashrc,/etc/profile(所有用户生效) - 用户级 :
~/.bashrc,~/.bash_profile(当前用户生效)
当你登录系统时,bash会读取这些文件,把变量加载到自己的内存中
2.bash进程里的存储:environ表
bash会把所有的环境变量组织成一个char *environ[]字符数组:
每个元素是键=值的形式的字符串(比如PATH=/usr/bin:......)
数组最后以NULL结尾,和argv数组的结构完全一样
这个数组是bash进程的内存级数据,用env命令看到的就是它
3.子进程的继承:从bash到./myprocess
当bash执行./myprocess时:
- bash会创建子进程
- 把自己的
argv[](命令行参数)和environ[](环境变量表)完整复制一份,传给子进程的main函数参数argv[]和envp[]
c
int main(int argc, char* argv[], char* envp[])
{
for(int i=0; envp[i]; i++) //可以通过envp来查看环境变量
printf("%s\n", envp[i]);
return 0;
}
- 所以子进程启动后,能直接拿到父进程的所有环境变量,这就是"继承性"
程序还可以通过extern char** environ全局变量来查看环境变量
示例:
c
#include<stdio.h>
extern char** environ;//声明系统全局的环境变量表
int main()
{
for(int i=0;environ[i];i++)
printf("env[%d]->%s\n",i,environ[i]);
return 0;
}
编译运行后,会打印出和终端命令env一模一样的环境变量内容,因为它直接访问了bash传给它的environ表
4.C语言操作环境变量的标注函数getenv和putenv
getenv:获取指定环境变量的值
c
#include<stdio.h>
char* getenv(const char *name); //函数原型
用法:
c
char* path=getenv("PATH");
printf("PATH=%s\n",path); //给它一个环境变量名,返回对应的值(只读),找不到返回NULL
putenv:添加/修改环境变量
c
#include<stdio.h>
int putenv(char* string);//函数原型
作用:传入一个name=value格式的字符串,如果环境变量已经存在,则覆盖修改;若不存在,则新增
c
putenv("Yes_No=123");
五、地址空间
1.页表和虚拟地址空间
根据代码看现象

矛盾点:父子进程的g_val地址完全一样,但值不一样,这说明它们绝对不是同一个物理变量
1.核心原理:虚拟地址vs物理地址
我们在C/C++代码里面看到的所有地址,其实都是虚拟地址。物理地址由操作系统和MMU(内存管理单元)统一管理,用户进程根本看不到,父进程的&g_val是虚拟地址相同 ,但通过页表映射到了不同的物理地址 。
为什么地址会一样?
fork()创建子进程时,会完整复制父进程的虚拟地址空间和页表,所以父子进程的虚拟地址和布局完全一样,变量的虚拟地址自然也一样,最初它们的页表映射的是同一块物理内存(写时拷贝机制)。
写时拷贝:当子进程修改g_val=100时,触发了写时拷贝,操作系统会给子进程分配一块新的物理内存,把父进程那块的物理内存数据复制过来,修改子进程的页表,让0x60104c这个虚拟地址重新映射到新的物理内存,父进程的页表映射则不变,所以看见的还是100,谁修改谁重新映射,但虚拟地址还是最初的那个