【Linux系统篇】揭秘冯诺依曼体系与进程管理的奥秘
主题:冯诺依曼体系 · 操作系统 · 进程 · 环境变量 · 程序地址空间
目录
一、冯诺依曼体系

问题:软件运行前为什么必须先加载?
- CPU 获取、写入数据,只能通过内存进行(CPU 执行我们的代码、访问我们的数据)。
- 体系结构规定了程序必须先加载到内存。
- 加载的本质是
Input——数据本质上是从一个设备"拷贝"到另一个设备。 - 整个体系结构的效率,取决于"拷贝"的效率。
核心结论:CPU 在数据层面只和内存打交道,外设也只和内存打交道。
存储分级

- 存储分级:寄存器 → 高速缓存 → 内存 → 磁盘,速度递减、容量递增、成本递减。
二、操作系统
定义: 一个基本的程序集合,称为操作系统(OS)。操作系统是一款进行软硬件管理的软件。
广义上的操作系统包括:
- 内核:进程管理、内存管理、文件管理、驱动管理
- 其他程序:例如函数库、shell 程序等

设计目的
以管理硬件为手段,为用户提供良好的服务。
- 软硬件体系结构是层状结构。
- 访问操作系统必须使用系统调用——本质就是函数,只是由系统提供。
- 程序只要访问了硬件,就必须贯穿整个软硬件体系结构。
- 库函数可能在底层封装了系统调用。
理解操作系统
管理:先描述,后组织。
理解系统调用
- 操作系统向上提供服务,但不相信任何用户,将自己封装起来,只对外提供系统调用。
- 用户与系统之间会进行数据交互。
- 库函数与系统调用属于上层与下层的关系。
三、进程
1. 概念
- 概念:程序的一个执行实例、正在执行的程序。
- 内核观点:担当分配系统资源(CPU 时间、内存)的实体。

核心结论:
进程 = 内核数据结构对象 + 自己的代码和数据
进程 = PCB(task_struct)+ 自己的代码和数据
- 对进程的管理,就变成了对链表的增删查改。
- 我们历史上执行过的所有指令、工具、程序,运行起来都是进程。
常用命令与函数
| 命令 / 函数 | 作用 |
|---|---|
ps ajx |
查看当前进程 |
Ctrl+C |
杀掉当前进程 |
kill -9 [进程ID] |
杀掉指定 ID 的进程 |
getpid() |
获取当前进程 pid |
getppid() |
获取父进程 pid |
/proc 目录 |
以文件方式展现进程 |
/proc 中的两个重要项
exe:磁盘中的可执行文件。程序运行时已加载到内存,即使删掉它,程序依然能继续运行。cwd:当前工作目录,可通过chdir更改。
父进程与 bash
- 重复运行程序,其 pid 会改变,但父进程 pid 不变——用
ps查看发现,父进程就是bash命令行解释器。 - OS 会给每一个登录的用户分配一个
bash。
fork() 创建子进程
- 子进程的 PCB 与父进程基本相同。
- 子进程没有自己的代码和数据(因为没有重新加载程序)。
fork()有两个返回值:成功时,子进程返回 0,父进程返回子进程的 pid。
进程具有独立性!
对于父子进程,如果有一方要修改数据,OS 会在底层产生一份拷贝,让目标进程修改这份拷贝(写时拷贝)。
2. 进程状态
进程状态就是task_struct内的一个整数。

2.1 运行、阻塞、挂起(R / S / RS / BS)
| 状态 | 说明 |
|---|---|
| 运行(R) | 只要进程在调度队列中,进程状态都是 running |
| 阻塞(S) | 等待某种设备或资源就绪 |
| 挂起(RS / BS) | 系统资源不够时,优先将阻塞状态的进程放入磁盘 swap 分区;若资源依旧紧缺,运行状态的进程也会被放入 → 阻塞挂起 / 运行挂起 |
进程状态的变化,表现之一就是在不同队列中流动,本质都是数据结构的增删改查。
2.2 内核进程的链表结构
list_head结构体没有数据,只有前驱和后继,存储于每个task_struct中。- 利用宏
offset算法,可以得到task_struct的起始地址。

- 一个
task_struct里可以定义多个像links一样的结构。 - 根据
offset宏算法,可以将进程间的多个links按不同数据结构(链表、队列、二叉树等)排列。 - 因此,一个进程可以同时出现在多个位置。
2.3 僵尸进程(Z)
- 父进程创建子进程后,如果一直不管、不回收、不获取子进程状态,Z 状态就会一直存在。
- 僵尸进程不清理会导致内存泄漏问题!
- 进程退出后,内存泄漏问题解决;但常驻内存的进程若发生内存泄漏,问题非常麻烦。
2.4 孤儿进程
- 父子进程中,如果父进程先退出,子进程就要被 1 号进程(
systemd)领养,这个被领养的子进程叫孤儿进程。 - 领养之后,新父进程可以对子进程进行统一回收,避免僵尸进程。
- 一个进程变成孤儿进程后,会自动变为后台进程。
3. 进程优先级
定义:进程得到 CPU 资源的先后顺序。 目标资源稀缺,需要通过优先级确认谁先谁后。
优先级 VS 权限
- 优先级:能得到资源,是先后的问题。
- 权限:拿不拿得到资源的问题。
- 优先级也是一个整数,存储在
task_struct中。值越低,优先级越高;值越高,优先级越低。 - 我们的系统是基于时间片的分时操作系统:为保证公平性,优先级可以变化,但幅度不能太大。
- Linux 中,访问任何资源都是进程访问,进程就代表用户。

PRI 与 NI
| 字段 | 含义 |
|---|---|
| PRI | 进程优先级,默认 80 |
| NI | 进程优先级的修正数字(nice 值) |
- 真实优先级 = 默认 PRI + NI(默认 PRI 始终为 80)
- nice 值范围:
[-20, 19]→ Linux 优先级范围:[60, 99] - 优先级设置不合理,会导致低优先级进程长时间得不到 CPU 资源,进而进程饥饿。
4. 补充概念:竞争性、独立性、并行、并发
| 概念 | 含义 |
|---|---|
| 竞争性 | 系统进程数目众多,而 CPU 资源只有少量甚至 1 个,进程之间具有竞争属性。为高效完成任务、合理竞争资源,便有了优先级 |
| 独立性 | 多进程运行需要独享各种资源,运行期间互不干扰 |
| 并行 | 多个进程在多个 CPU 上同时运行 |
| 并发 | 多个进程在一个 CPU 上采用进程切换的方式,在一段时间之内都得以推进 |
5. 进程切换
关于死循环
- 进程一旦占有 CPU,并不会把代码跑完,它会被分配到一个时间片。
- 死循环代码不会打死系统,不会一直占用 CPU。
关于 CPU 与寄存器
- 寄存器是 CPU 内部的临时空间,存储正在运行进程的临时数据。
- 寄存器 ≠ 寄存器中的数据。
如何切换

- 进程要切换时,先将寄存器中的数据保存,再进行切换;
- 进程恢复时,先将原先保存的寄存器数据恢复,再继续运行。
进程切换最核心的,就是保存和恢复进程硬件上下文的数据,即 CPU 内部寄存器的内容。
上下文数据保存到哪里?
- 保存到进程的
task_struct里。由于寄存器非常多,进程内部会有一个 TSS(任务状态段) 字段用于保存。

全新的进程与已经调度过的进程如何区分?
- 可以在
task_struct里加一个标志位来确定。
6. Linux 2.6 内核进程 O(1) 调度队列

在系统中查找一个最合适调度的进程的时间复杂度是常数,不随进程数增多而增加——这就是进程调度 O(1) 算法。
代码参考
struct rq {
spinlock_t lock;
/*
* nr_running and cpu_load should be in the same cacheline because
* remote CPUs use both these fields when doing load calculation.
*/
unsigned long nr_running;
unsigned long raw_weighted_load;
#ifdef CONFIG_SMP
unsigned long cpu_load[3];
#endif
unsigned long long nr_switches;
/*
* This is part of a global counter where only the total sum
* over all CPUs matters. A task can increase this counter on
* one CPU and if it got migrated afterwards it may decrease
* it on another CPU. Always updated under the runqueue lock:
*/
unsigned long nr_uninterruptible;
unsigned long expired_timestamp;
unsigned long long timestamp_last_tick;
struct task_struct* curr, * idle;
struct mm_struct* prev_mm;
struct prio_array* active, * expired, arrays[2];
int best_expired_prio;
atomic_t nr_iowait;
#ifdef CONFIG_SMP
struct sched_domain* sd;
/* For active balancing */
int active_balance;
int push_cpu;
struct task_struct* migration_thread;
struct list_head migration_queue;
#endif
#ifdef CONFIG_SCHEDSTATS
/* latency stats */
struct sched_info rq_sched_info;
/* sys_sched_yield() stats */
unsigned long yld_exp_empty;
unsigned long yld_act_empty;
unsigned long yld_both_empty;
unsigned long yld_cnt;
/* schedule() stats */
unsigned long sched_switch;
unsigned long sched_cnt;
unsigned long sched_goidle;
/* try_to_wake_up() stats */
unsigned long ttwu_cnt;
unsigned long ttwu_local;
#endif
struct lock_class_key rq_lock_key;
};
/*
* These are the runqueue data structures:
*/
struct prio_array {
unsigned int nr_active;
DECLARE_BITMAP(bitmap, MAX_PRIO + 1); /* include 1 bit for delimiter */
struct list_head queue[MAX_PRIO];
};
四、环境变量
环境变量一般是指操作系统中用来指定操作系统运行环境的一些参数。
命令行参数与 argv
main函数的命令行参数,是程序实现不同子功能的方法。- 进程拥有一张
argv表来实现选项功能——这也是指令带选项的实现原理。 argv表以NULL结尾。
PATH:如何找到程序
- 要执行一个程序,必须先找到它。系统中存在环境变量,帮助系统找到目标二进制文件。
PATH:系统搜索指令的默认路径。echo $PATH打印当前环境变量;env查看所有环境变量,本质是变量名=变量值的键值对。

如何理解环境变量(存储角度)
- 登录时,系统会启动一个
bash进程,生成一张环境变量表(指针数组)。 bash内部有两张表:- 输入指令时,
bash先将命令解析成命令行参数表; - 再在环境变量表中找到
PATH,根据PATH找到命令并执行。
- 输入指令时,
环境变量从哪里来?
- 从系统相关的配置文件
.bash_profile和.bashrc中来。 - 修改配置文件中的
PATH后,重新登录即可生效。 - 有多少个用户登录,就有多少个
bash;一个bash两张表。
认识更多环境变量
| 变量 | 含义 |
|---|---|
HOME |
当前用户家目录 |
USER |
当前用户 |
LOGNAME |
登录时的用户(一般情况下与 USER 一致) |
SSHTTY |
当前终端设备 |
HISTSIZE |
最多记录的历史命令条数 |
PWD |
当前工作路径 |
OLDPWD |
上次工作路径 |
直接使用
su,系统不会更新环境变量中的USER和LOGNAME;使用su -,则都会变为root,相当于重新登录。
获取环境变量
命令行操作
| 命令 | 作用 |
|---|---|
export XXX=1234 |
导入环境变量 XXX=1234 |
unset XXX |
取消环境变量 XXX |
echo $XXX |
查看环境变量 XXX |
代码层面
main函数可以有三个参数:int main(int argc, char *argv[], char *env[])argc:参数个数argv:参数列表(命令行参数表),以NULL结尾env:环境变量表,以NULL结尾
- 通过
env获取到的是父进程bash的环境变量——环境变量可以被子进程继承,因此bash以下的所有进程都可以拿到bash的环境变量。所以,环境变量通常具有全局特性。
getenv("XXX"):获取环境变量XXX的内容。char **environ:environ[0]指向环境变量表的第一个元素。
环境变量的特性
- 全局变量具有全局特性。
- 补充概念:

i为本地变量,set可以查看本地变量与环境变量;unset可以取消i。- 本地变量不会被子进程继承,只在
bash内部能使用。 - 子进程能把环境变量
export给父进程吗?- 不能。
export是内建命令(built-in command),执行时不会创建子进程,而是让bash亲自执行。
- 不能。
五、程序地址空间

⚠️ 程序地址空间不是内存!
它实际上是进程地址空间(虚拟地址空间),是一个系统概念,不是语言层的概念!
页表与映射
- 一个进程有一个虚拟地址空间,也有一张页表。
- 创建变量时,会有一个物理地址和一个起始虚拟地址。
- 页表用于虚拟地址与物理地址的映射。
写时拷贝(父子进程)
- 子进程的
task_struct、虚拟地址空间、页表内容,大部分浅拷贝自父进程。 - 当子进程修改父进程创建的全局变量时,系统会先给子进程建一块新的物理地址再修改。
- 新建的物理地址在页表中还是与原先的虚拟地址对应。
- 因此:父子进程变量地址一样,但值不一样——这就是写时拷贝。

mm_struct:虚拟地址空间的数据结构
- 虚拟地址空间本质是一个数据结构
struct mm_struct,是内核给进程创建的一个结构体对象。 - 区域划分只需要记录区域的开始与结束。
- 加载数据到内存的流程:
- 申请物理空间与虚拟空间;
- 在页表中建立映射关系;
- 将物理地址转化为虚拟地址,提供给上层用户使用。
mm_struct也需要开辟空间与初始化,初始化发生在加载时。

为什么要有虚拟地址空间?
- 有序化:物理内存可以映射到虚拟地址,虚拟地址空间将无序的地址变为有序。
- 内存保护:页表中有权限控制,地址转换过程中可以对地址与内存进行合法性判定,进而保护物理内存。
- 什么是野指针?→ 访问到页表中没有记录的区域。
char *str = "hello"; *str = 'H';为什么在字符常量区写入会崩溃?→ 查找页表时,权限拦截了。
- 解耦合:让进程管理与内存管理进行一定程度的解耦合。
扩展理解
- 可以不加载代码和数据,只有
task_struct、mm_struct、页表等,系统会自动进行缺页中断。 - 创建进程:先有
task_struct、mm_struct等,后加载代码和数据。 - 如何理解进程挂起?
- 进程不变,查看页表,将页表中相关进程的地址清除,再将物理地址中的代码和数据移动到磁盘的
swap分区——很好地隔离了进程与内存。
- 进程不变,查看页表,将页表中相关进程的地址清除,再将物理地址中的代码和数据移动到磁盘的
堆区为什么是离散的?


mm_struct里有一个指针指向vm_area_struct。vm_area_struct存储的是一个堆区的开始与结束;多个这样的结构形成一张链表。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)