目录
一、冯诺依曼体系:计算机世界的"生命循环系统"
二、操作系统:管理软硬件的"超人"
三、进程:计算机中的"平行宇宙"
三、环境变量:进程的"生存环境"
四、进程地址空间:"虚拟的私人领地"
一、冯诺依曼体系:计算机世界的"生命循环系统"
1945年,冯·诺依曼提出的计算机体系结构至今仍在指引着现代计算机的设计方向。我们可以想象一家繁忙的快递分拣中心:
-
运算器+控制器(CPU):如同智能分拣机器人,负责拆解包裹(指令)并进行处理
-
存储器(内存):像高速传送带上的临时货架,暂存正在分拣的包裹
-
输入设备:扫描枪读取快递单信息
-
输出设备:打包完成的包裹运出分拣中心
这个精巧的循环系统(输入→存储→处理→输出)正是现代计算机的基石。当我们同时启动浏览器、音乐播放器和文档编辑器时,操作系统就像经验丰富的调度员,通过进程管理协调这些"快递包裹"的分拣优先级。
冯诺依曼体系结构示意图
我们常见的计算机、笔记本等大部分都遵守冯诺依曼体系。所以我们就可以用冯诺依曼体系来解释一些日常生活中的场景。
比如我们在微信聊天的时候,信息通过键盘(输入设备)流入存储器。这一过程就像快递员将不同包裹(键盘输入、鼠标点击)统一贴上条形码,暂存到分拣中心(内存)。接下来数据由存储器流向运算器,运算器负责将文本信息处理成二进制信号。然后信号由存储器流向输出设备(网卡)。
在朋友端的过程也是类似的,输入设备(网卡)接收电信号 → 内存缓冲区,CPU解密数据包 → 信息显示在输出设备(显示器)。
整个过程中数据流严格遵循:
输入设备 → 存储器 → 运算器 → 存储器 → 输出设备
现在我们可以回答一个问题:为什么程序要运行需要加载到内存中呢?
因为冯诺依曼体系结构规定,一个程序要运行,必须先加载到内存中运行。
二、操作系统:管理软硬件的"超人"
操作系统就像一个大学的校长,学生甚至可能没有见过校长,但校长通过一些管理方法让学校井然有序。操作系统为我们做了非常多厉害的事情,以至于我们根本察觉不到。接下来就来介绍一下操作系统:
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。笼统的理解,操作系统包括:
- 内核(进程管理、内存管理、文件管理、驱动管理)
- 其他程序(如库函数、shell程序等)
操作系统的定位就是一款进行管理软硬件的软件。那么,操作系统是如何管理好软硬件的?简单来说是六个字:
先描述、再组织。
描述:结构体、类
组织:STL容器 (链表)
结构体就像学生的信息,比如学号,姓名,班级等,用这些信息来描述一个学生。而要管理这些学生,即对这些信息进行增删查改。只需要将信息存到一个容器中,比如链表,对学生信息的管理就变成了对链表的增删查改了。
在学习进程之前,我们就已经可以回答操作系统是如何管理进程的了,答案就是先把进程描述起来,再把进程组织起来!
操作系统并不相信用户,但操作系统又是为用户提供服务的。为了保证自身数据的安全,操作系统只能将自己封装起来,只提供了一系列“窗口”,称为系统调用。大多数库函数最终都会调用一个或多个系统调用来实现其功能。例如,C语言中的`printf`函数,最终会调用`write`系统调用来将数据写入到文件描述符中。
三、进程:计算机中的"平行宇宙"
操作系统的四大模块分别是进程管理、内存管理、文件系统管理和输入输出设备管理。这篇文章先来介绍一下进程。
进程是程序运行时的实例。每一个进程都有自己独立的内存空间、CPU时间片等资源,用来执行程序中的指令。
进程在加载到内存的时候,要先描述进程,描述进程的结构体对象就是:
PCB(进程控制块)(Process Control Block): 包含了进程的各种信息,如进程ID、进程状态、优先级、内存地址等(即进程属性的集合)。PCB是操作系统用来管理进程的重要数据结构。
在Linux上,PCB为 task_struct ,它会被装载到RAM(内存)里并且包含着进程的信息,比如:
- 标识符PID:描述一个进程的唯一标识符;
- 进程状态;
- 优先级;
- 程序计数器:程序中即将被执行的下一条指令的地址;
- 上下文数据:进程执行时处理器的寄存器中的数据;
- e.t.c
进程的信息可以通过 /proc 查看,proc 目录是一个包含当前系统正在运行的进程信息的目录,每个进程的信息都在一个以自身PID命名的目录中,存放在 proc 下。比如说我们要查看一号进程的进程信息,就可以输入 cat /proc/1。此外,也可以使用ps或者top来获取进程信息。
Linux 为了高效地管理和调度进程,需要对进程在执行生命周期中的不同阶段进行区分,这就产生了进程状态的概念。操作系统必须知道每个进程当前所处的状态,才能合理分配 CPU、内存、I/O 等资源,并制定相应的调度策略,从而保证系统的整体性能和响应速度。
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 */
};
-
运行态(Running 或 Runnable)
进程正在使用 CPU 或已就绪等待 CPU 分配资源。 -
休眠态(Sleeping)
进程正在等待某个事件(如 I/O 操作)的完成。- 可中断睡眠(Interruptible Sleep):进程处于等待状态,但可以被信号中断。
- 不可中断睡眠(Uninterruptible Sleep):进程等待关键资源,通常不会响应信号,以保证操作的原子性。
-
出现这一状态可能代表:
- 硬盘性能问题或负载过高。
- 文件系统挂载点变得不可用(如网络挂载的 NFS 断开)。
- 磁盘硬件故障或损坏。
- I/O 队列过载,导致进程长时间等待。
-
-
停止态(Stopped)
进程由于接收到停止信号(如 SIGSTOP 或 SIGTSTP)而被暂停运行,通常在调试时使用。 -
僵尸态(Zombie)
进程已经终止,但父进程尚未回收其退出状态信息,此时进程的资源还未完全释放。-
僵尸进程危害:
进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),他交给我的任务办的怎么样了。可父进程如果一直不读取,那子进程就一直处于Z状态。维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话说,Z状态一直不退出,PCB一直都要维护。如果一个父进程创建了很多子进程,就是不回收,就会造成内存资源的浪费,因为数据结构对象本身就要占用内存。
-
-
跟踪态(Traced)
进程正在被调试器跟踪,处于一种特殊状态。
对于一个父子进程,父进程先退出,子进程的父进程会背改为1号进程(system)
父进程是1号进程 —— 孤儿进程,该进程被系统领养。
我们知道执行程序可以创建进程,除此之外,我们还可以通过系统调用来创建进程:
> NAME
> fork - create a child process
> SYNOPSIS
> `#include <sys/types.h>`
> `#include <unistd.h>`
>
> `pid_t fork(void);`
> RETURN VALUE
> On success, the PID of the child process is returned in
> the parent, and 0 is returned in the child. On failure,
> -1 is returned in the parent, no child process is cre‐
> ated, and errno is set appropriately.
系统调用 fork 是Linux中用于创建新进程的核心机制。通过 fork,一个进程(称为父进程)可以生成一个几乎完全相同的副本(称为子进程),子进程随后可以独立运行不同的代码路径。
返回值:
- 父进程:收到子进程的 PID(进程ID)(返回值 > 0)。
子进程:收到返回值 0。
出错时:返回 -1(如系统资源不足)。
根据不同的返回值,一般需要在fork执行后用if进行分流:
#include <stdio.h>
#include <unistd.h>int main()
{pid_t pid = fork(); // 创建子进程if (pid < 0) {perror("fork failed");} else if (pid == 0) {printf("This is the child process. PID: %d\n", getpid());} else {printf("This is the parent process. Child PID: %d\n", pid);}return 0;
}
解释现象:
- fork做了什么?
- fork为什么要给子进程返回0,给父进程返回子进程的pid?
- 一个函数是如何做到返回两次的,如何理解?
- 一个变量怎么会有不同的内容?
fork 复制当前调用进程(父进程),创建的新进程(子进程)几乎完全继承父进程的内存空间、文件描述符、环境变量等。子进程和父进程是独立的,但运行同一段代码。
返回不同的返回值是为了区分不同的执行流,执行不同的代码块。
调用fork时,操作系统会创建一个新进程,这个新进程是父进程的副本,拥有父进程的相同内存空间(初始时是相同的,但之后互不影响)。
子进程的执行起点是从fork函数返回的位置继续。
后面的代码是共享的,所以会被父与子分别返回一次。
由此可见,fork 的“两次返回”并非函数本身的特性,要深入理解fork创建子进程的事情,还需要了解下面的内容:
三、环境变量:进程的"生存环境"
在终端中输入env,你会看到类似这样的信息:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
HOME=/home/user
LANG=en_US.UTF-8
env是用于展示环境变量的基本指令,环境变量是影响运行系统程序或者进程的动态值,不同的环境变量有不同的用户,通常具有全局属性。
查看环境变量的方法:
echo $NAME //NAME : 环境变量的名称
这里主要介绍一下动态变量:PATH——Linux指令搜索路径
PATH的存在可以解释一个现象:执行指令可以直接例如top,而我们自己创建的程序需要加./,就是因为执行的时候会自动在PATH中去寻找,而我们创建的程序不存在于PATH中,只能在执行指令前加 ./ 明确告诉shell要执行的程序在哪儿。
那么,能新增路径到PATH中,执行我们自己创建的程序就不用加 ./ 了吗?
答案是 YES ,我们可以通过下面这条指令来新增PATH的路径:
PATH=$PATH:(要添加的路径)
要注意,这种修改仅在当前会话中有效。一旦关闭终端或重启shell,PATH变量会恢复为默认值。
除了通过env查看环境变量,我们也可以系统调用 getenv() 来获取环境变量。例如:
printf("PATH: %s\n", getenv("PATH"));
main函数的参数列表,我们见过带两个参数的形式:
int main(int argc, char* argv[])
首先,main函数为什么可以带参数?因为main函数虽然是程序的入口点,而操作系统(或运行时环境)在程序启动时会将命令行参数传递给它。其次,main 的参数:argc记录argv的个数,argv记录字符串。
有一个名为mycmd的程序,代码如下:
#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;
}
在输入 ./mycdm -a -b -c 后。输出:
argv[0]:./mycmd
argv[1]:-a
argv[2]:-e
argv[3]:-f
可以看到,参数被打散放入argv数组中,之后我们就可以通过if和strcmp来分流执行的参数段。
此外,main函数还可以有第三个参数env,用于获取环境变量:
int main(int argc, char* argv[], char* env[])
我们所运行的进程,都是子进程,bash在启动的时候,会从操作系统的配置文件中读取环境变量信息,子进程会继承父进程交给它的环境变量。main函数有两张核心向量表,一是命令行参数表,二是环境变量表 。而这些main函数的参数就是为了获取这些表的信息。前两个参数用于传入命令行参数的信息,第三个参数用于传入环境变量的信息。
但是我们会发现,即使不写main函数的参数,我们也可以在程序中正常通过getenv来获取环境变量的信息。因为environ全局变量的存在,允许程序访问环境变量。getenv() 通过 environ 访问环境变量,无需 env 参与。
环境变量通常具有全局属性,可以被子进程继承下去。如果当我们尝试在main函数中通过getenv获取我们定义的的本地变量,就会发现获取不到。就是因为本地变量没有全局属性,无法被子进程继承,getenv自然而然也就没有本地变量的信息。
想在子进程中获取自己定义的本地变量也有方法,就是将本地变量导入到全局,成为全局变量。
使用export可以把本地变量导入到环境变量表中,从而使得该环境变量可以被子进程继承。
export MY_VARIABLE="my_value"
删除环境变量:
unset MY_VARIABLE
那为什么定义一个本地变量后可以通过echo打印出来,自己写的c程序里面通过getenv接口却获得不到本地变量的值?echo不也是会创建一个子进程吗,怎么继承到本地变量的值的?
其实,echo并没有创建子进程,命令分为两种:
- 常规命令:通过创建子进程完成
- 内建命令:不创建子进程,由自己亲自执行(类似于bash调用了自己写的或者系统提供的函数)
四、进程地址空间:"虚拟的私人领地"
进程地址空间是操作系统中一个非常重要的概念,它就像一个"虚拟的私人领地",为每个运行中的程序(进程)提供独立的内存使用体验。我们用图书馆来做个比喻,帮助你理解这个抽象概念:
-
想象每个运行的应用程序(如浏览器、游戏)都拥有一个私人图书馆
-
这个图书馆有严格的区域划分:教科书区、笔记本区、自由书写区、便签区(对应进程地址空间中的堆、栈、代码区等)
-
虽然物理层面上图书馆可能和别人共享书架,但管理员(操作系统)会让每个读者觉得自己拥有完整的图书馆
这样设计的好处有很多,比如:管理员可以偷偷把不常用的书架内容存到仓库(对于操作系统就是磁盘空间的交换),需要时再搬回来;不同读者的相同书架编号(内存地址)实际指向不同的物理书架;读者无法直接进入别人的图书馆区域(内存隔离保护)
下面这张图就是电脑中的这个“共享图书馆”,它分为命令行参数和全局变量区、栈、堆、未初始数据区、初始化数据区和代码区。其中栈堆中间有一段很大的共享区域,栈向下增长、堆向上增长。
我们可以通过下面这段代码测试一下:
#include <stdio.h>
#include <stdlib.h>int g_val_1;
int g_val_2 = 100;int main()
{printf("code addr : %p\n", main);const char *str = "hello world";printf("read only string addr : %p\n", str);printf("init global value addr : %p\n", &g_val_2);printf("uninit global value addr : %p\n", &g_val_1);char *mem = (char*)malloc(100);printf("heap addr : %p\n", mem);printf("stack addr : %p\n", &str);return 0;
}
输出示例:
code addr : 0x55f9317fe189
read only string addr : 0x55f9317ff018
init global value addr : 0x55f931801010
uninit global value addr : 0x55f931801018
heap addr : 0x55f9331f76b0
stack addr : 0x7ffd430ffa98
接下来再看这段代码的运行效果:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int g_val = 0;int main()
{pid_t id = fork();if(id < 0){perror("fork");return 0;}else if(id == 0) //子进程{ g_val=100;printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);}else //父进程{sleep(5);printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);}sleep(1);return 0;
}
由于父进程中sleep了5秒,那么子进程肯定先跑完,也就是子进程先修改,完成之后,父进程再读取。然后我们运行后却发现父子进程,输出地址是一致的,但是变量内容不一样,现在我们虽然还不能解释这是为什么,但是能得出结论:
如果变量的地址是物理地址,不可能出现上面的现象,所以绝对不是物理地址,而是线性地址和虚拟地址。
物理地址和虚拟地址:
虚拟地址是程序直接使用的地址,也称为逻辑地址,是应用程序视角中的内存地址。物理地址是指实际在计算机内存(RAM)中的物理存储位置,由操作系统和硬件管理。
虚拟地址需要通过 页表(Page Table) 映射到物理地址,具体过程如下:
- CPU 生成一个虚拟地址。
- MMU 查询页表,将虚拟地址转换为物理地址。
- 如果页表中找不到对应的映射(页错误),则触发缺页中断:OS 从磁盘中读取数据,将其加载到内存,并更新页表映射。然后重新执行被中断的指令,继续访问该页。
- 最终,CPU 访问对应的物理地址并读取/写入数据。
前面我们用fork创建子进程,在子进程中对值进行了修改,由于写时拷贝(Copy-on-Write, COW)机制(多个进程共享同一页,写入时才真正复制),这里就会触发了缺页中断。
在 fork() 后,父子进程共享同一份内存页,并将这些页都标记为只读。当某个进程试图写入这些只读页时,CPU 触发缺页中断(由于写操作违反了只读权限),操作系统在缺页中断处理程序中检测到这是一个 COW 触发的异常,随后为该进程分配新的物理内存页并将原有数据复制过去,最后更新页表使该进程可以对新页进行写操作。这一过程既保证了内存共享,又实现了数据隔离。
值在写入的时候,会先改变页表的映射关系,在物理内存中重新开辟空间(这个过程虚拟地址时0感知的)。因为不同的映射关系,所以一个地址(指的是虚拟地址)父子进程能读取到不一样的值。
类似PCB一样,地址空间也是需要被系统管理的:先描述,再管理——`mm_struct`结构体,包含例如`long code_start`、`long code_end`等。
每个当前正在执行的进程,其页表的起始位置(页表的物理地址)都被记录在CPU内部一个叫做 CR3 的寄存器中。并且,进程在切换时,CR3寄存器中的内容会被当成进程的上下文打包带走,所以进程完全不用担心切换后找不到上下文。
地址空间让程序看起来拥有连续且足够大的内存,从而简化了编程,程序员不用关心物理内存的实际情况,同时让操作系统能灵活地将不连续的物理内存映射成连续的虚拟空间。页表负责将虚拟地址转换为实际的物理地址,支持如共享内存、写时拷贝等高级内存管理机制,提升了内存利用率和系统效率。因为有地址空间和页表的存在,进程管理模块和内存管理模块才能进行进行解耦合。
通过上述的学习,我们赞叹内存管理的精妙设计:抽象虚拟内存屏蔽了物理内存的不连续性,让应用程序感觉自己拥有整块内存,从而简化编程模型,并实现了进程间的隔离和保护。灵活高效的地址映射通过页表将虚拟地址映射到物理内存上,不仅节省了内存资源,还能按需分配,利用局部性原理只为实际使用的部分分配物理内存。按需加载与延迟分配利用缺页中断技术,实现了按需加载内存页,只有在真正访问到某个虚拟页时才分配物理内存,避免了内存浪费。写时拷贝(COW)机制在进程创建(如 fork())时,通过让父子进程共享同一份物理内存并标记为只读,只有在写操作时才复制数据,从而大大降低了内存复制的开销,提高了系统效率。
这些设计既保证了内存的安全隔离,又实现了高效利用和动态管理,使得系统在支持多进程运行的同时,能够灵活应对内存资源的变化,真正做到安全、高效与经济的统一。