【操作系统】4.Linux 操作系统与进程管理详解
目录
一、冯·诺依曼体系结构
-
结构:输入设备 -> 存储器(内存) -> 输出设备,CPU 与存储器直接交互。
-
为什么软件运行要先写入内存? CPU 只能从内存中读取指令和数据。这是体系结构规定的。
-
内存的使用是权衡的结果:
-
数据本质是从一个设备拷贝(流动)到另一个设备。
-
如果只用磁盘,速度太慢;如果全部用寄存器,成本过高。
-
内存作为中间层,在速度和成本之间取得了平衡。
-
二、操作系统的理解
1. 操作系统的本质
-
操作系统是一个管理硬件和软件的软件。
-
它包括内核(如进程管理、内存管理、文件系统、驱动管理)和其它程序(如函数库、Shell 程序)。
2. 设计 OS 的目的
-
向下管理软硬件资源。
-
向上给用户和应用程序提供良好、稳定的运行和使用环境。
3. 管理的理解
-
管理 = 先描述,再组织。
-
描述:用结构体/类(如
struct task_struct)将管理对象的属性描述出来。 -
组织:用数据结构(如链表、队列)将这些描述对象组织起来,方便进行增删查改。
-
这正是面向对象思想的体现:用类存储数据,用容器进行操作。
4. 操作系统调用
-
操作系统要向用户程序提供服务,但不信任任何用户。
-
用户和操作系统之间通过库、系统调用、指令等形式进行数据交互。
-
因此,库函数和系统调用之间是上下层关系。
-
类比:银行的大堂经理(库函数)引导用户办理手续,而真正的业务处理在后台(操作系统)。
三、进程的理解
1. 进程是什么?
-
为了管理正在运行的程序,操作系统需要用一个结构体去描述它的各项参数(状态、优先级、PID 等)。
-
这个结构体 + 程序本身的代码和数据 = 进程。
-
这个描述进程的结构体就叫做 PCB(Process Control Block),在 Linux 中具体实现为
task_struct。
2. 如何管理进程?
-
进程的所有属性都可以直接或间接通过
task_struct找到。 -
对进程的管理,就变成了对
task_struct对象链表的增删查改。
3. 查询进程
-
获取进程 PID:
#include <stdio.h> #include <sys/types.h> #include <unistd.h> int main() { while (1) { sleep(1); printf("%d\n", getpid()); } return 0; }-
getpid()可以获取当前进程的 PID。
-
-
使用
ps命令查看进程:bash
[root@VM-0-7-centos ~]# ps ajx | head -1 && ps axj | grep proc PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 26393 4361 4361 26393 pts/0 4361 S+ 1001 0:00 ./proc 4587 5630 5629 4587 pts/1 5629 S+ 0 0:00 grep --color=auto proc
-
./proc是我们的进程(PID 4361)。 -
grep命令本身也是一个进程(PID 5630)。 -
使用
Ctrl + C可以终止前台进程。
-
-
通过
/proc文件系统查看:-
/proc目录下包含了所有运行中进程的信息。
-
进入
/proc/[PID]/目录,可以看到exe和cwd两个链接:-
exe-> 进程对应的磁盘上的可执行文件路径。 -
cwd-> 进程的当前工作目录。
-
-
当使用
fopen("text.txt", "w")创建文件时,文件默认会创建在进程的当前工作目录(cwd)下。 -
可以使用
chdir()系统调用改变进程的工作目录。
-
-
查看父进程:
ps ajx | head -1 && ps axj | grep 26393 | grep -v grep PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 26392 26393 26393 26393 pts/0 26393 Ss+ 1001 0:00 -bash
-
可以看到当前 Shell(
bash)的 PID 是 26393,它通常是很多命令进程的父进程。
-
4. 创建子进程:fork()
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main() {
printf("父进程运行 %d\n", getpid());
int id = fork();
if (id < 0) {
perror("fork");
return 1;
} else if (id == 0) {
// 子进程
while (1) {
sleep(1);
printf("子进程:%d, 父进程:%d\n", getpid(), getppid());
fflush(stdout);
}
} else {
// 父进程
while (1) {
sleep(1);
printf("父进程:%d, 父进程:%d\n", getpid(), getppid());
fflush(stdout);
}
}
return 0;
}
-
运行结果:
text
子进程:16186, 父进程:16185 父进程:16185, 父进程:26393
-
三个核心问题:
-
为什么父进程返回一个正数(子进程 PID),子进程返回 0?
-
类比二叉树:父节点需要有指针指向多个子节点,因此需要知道每个子节点的 ID(返回正数)。
-
子节点只有一个父节点,用 0 标记自己,指针可以理解为“空”。
-
-
为什么一个
fork()函数会返回两次?-
fork()执行后,操作系统创建了子进程的 PCB。 -
然后,
fork()函数会在父进程中返回一次(子进程的 PID),也会在子进程中返回一次(0)。 -
本质上,这是两个进程在执行同一个函数,各自返回。
-
-
为什么一个变量
id可以有两个不同的值?-
进程具有独立性。父进程和子进程的
id变量不是同一个,它们位于各自的地址空间。 -
当父/子进程只是读取变量值时,不会发生拷贝。但当其中一个进程要修改变量时,就会触发写时拷贝,操作系统会为修改者拷贝一份数据,使其拥有独立的副本。

-
-
四、进程状态
-
进程状态本质上是
task_struct结构体里的一个整数。
1. 运行状态 (R)
-
进程正在 CPU 上运行,或者位于运行队列中等待被调度。
-
注意:下面这个程序用
ps查看时,状态可能是S而不是R。#include <stdio.h> int main() { while (1) { printf("程序运行1\n"); } return 0; }
-
原因:
printf要向显示器输出,这是 I/O 操作,大部分时间进程在等待 I/O(阻塞状态),只有很少时间在运行。因此查询结果多为S。
-
2. 阻塞状态 (S / D)
-
S(可中断睡眠):进程在等待某个资源或事件(如键盘输入、磁盘 I/O)。当资源就绪或收到信号时,可以被唤醒。
-
D(不可中断睡眠,深度睡眠):也是在等待 I/O,但在此期间即使收到信号也不会被唤醒。主要用于防止在某些极端情况下(如系统内存不足)操作系统误杀还在进行关键 I/O 操作的进程。
3. 暂停状态 (T / t)
-
T:作业控制暂停,通常由用户输入
Ctrl + Z触发。 -
t:追踪暂停,常见于调试过程中遇到断点时。

4. 僵尸状态 (Z)
-
子进程已经退出,但它的大部分资源已经被释放,其 PCB (
task_struct) 仍然保留,等待父进程来“收尸”(读取其退出码等信息)。 -
示例:
#include <stdio.h> #include <sys/types.h> #include <unistd.h> int main() { int id = fork(); if (id == 0) { int c = 5; while (c--) { printf("子进程\n"); sleep(1); } // 子进程退出 } else { while (1) { printf("父进程\n"); sleep(1); } } return 0; }-
当子进程结束后,如果父进程还在运行且没有调用
wait()或waitpid()来回收子进程的信息,子进程就会变成僵尸进程(Z)。
-
-
危害:僵尸进程的 PCB 无法被释放,造成内存泄漏。对于常驻内存的服务器程序,如果产生大量僵尸进程,危害较大。
5. 死亡状态 (X)
-
进程被父进程回收后,彻底消亡,状态变为 X。
6. 孤儿进程
-
当一个进程的父进程先结束时,这个子进程就会被 1 号进程(
init或systemd)“领养”,成为孤儿进程。
-
孤儿进程会变为后台进程,此时
Ctrl + C无法结束它,通常需要kill命令。
五、进程优先级
1. 什么是优先级?
-
进程获得 CPU 资源的先后顺序。
-
为什么需要优先级? CPU 资源是稀缺的,需要通过优先级来确认执行顺序,确保多任务的有序进行。
2. 优先级与权限的区别
-
优先级:先后问题,所有进程最终都能得到 CPU。
-
权限:能否问题,没有权限的进程无法访问某些资源。
3. 优先级的数值计算
-
使用
ps -al命令可以查看进程的优先级信息。-
PRI:进程的实时优先级,默认值为 80。数值越低,优先级越高。 -
NI:进程的 Nice 值,表示进程优先级的修正值,取值范围是 -20 到 19。
-
-
最终优先级(影响调度的值) ≈
PRI + NI。 -
Nice 值范围较小,是为了保证进程调度的公平性,防止某个进程通过设置极端优先级而长时间霸占 CPU,导致其他进程饥饿。
4. 进程的性质
-
竞争性:多个进程争夺 CPU 资源。
-
独立性:进程之间互不干扰,一个进程的崩溃不会影响另一个进程。
-
并行:多个进程在多个 CPU 核心上同时运行。
-
并发:多个进程在一个 CPU 核心上,通过进程切换的方式交替运行,看起来像同时运行。
六、进程的切换与调度
1. 死循环会打死系统吗?
-
不会。操作系统是分时系统,会给每个进程分配一个时间片。即使进程是死循环,时间片用完后也会被强制切换出去,让其他进程运行。
2. CPU 寄存器的切换
-
寄存器是 CPU 内部的临时存储单元。
-
重要的是:寄存器本身是硬件,只有一套;但寄存器里的数据(上下文)是属于进程的,有多套。
-
切换过程:
-
进程 A 运行时,其数据保存在 CPU 寄存器中。
-
时间片到了,系统将 CPU 寄存器中的数据(上下文)保存到进程 A 的
task_struct或内核栈中。 -
将进程 B 之前保存的上下文数据加载到 CPU 寄存器中。
-
进程 B 开始运行。
-
-
因此,保存和恢复上下文是 CPU 进程切换的核心。
3. Linux 进程调度算法(O(1) 调度器)

-
queue[140]:这是一个存放task_struct指针的数组,类似于哈希表。-
0~99:实时优先级队列(用于实时操作系统)。 -
100~139:对应普通进程的 40 个优先级(Nice 值 -20~19 映射至此)。 -
进程根据其优先级挂在对应的链表上。
-
-
unsigned bitmap[5]:这是一个 140 位的位图(5个32位无符号整数)。-
每一位对应
queue数组的一个位置,1表示该优先级队列中有进程,0表示空。 -
调度器可以通过位图快速(O(1) 时间复杂度)找到当前最高优先级且有进程等待的队列。
-
-
两个数组:
active和expired:-
只有一个
queue会带来问题:进程运行完后放回原处,下次调度可能还会选到它,导致低优先级进程永远得不到 CPU。 -
解决方案:使用两个完全一样的优先级队列数组。
-
active:指向当前正在被调度执行的进程队列。 -
expired:指向已耗尽时间片的进程队列。
-
-
进程时间片用完后,被移动到
expired队列中,并重新计算其优先级(可能调整)。 -
当
active队列为空时,交换active和expired指针。这样保证所有进程都有机会被执行。
-
-
新进程:新创建的进程(就绪状态)通常先被加入到
expired队列,等待下一轮调度。
七、环境变量
1. main 函数的参数
#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;
}
-
argc:命令行参数的个数。 -
argv[]:命令行参数的字符串数组。 -
运行
./code a b:argv[0]:./code argv[1]:a argv[2]:b
-
作用:Shell(
bash)将用户输入的命令行字符串拆分,通过这两个参数传递给新创建的进程,使程序可以支持命令行选项(如ls -l)。
2. 环境变量 PATH
-
执行
code时需要写./code,但执行ls不需要写./ls。 -
原因:Shell 会去环境变量
PATH指定的目录列表中查找可执行程序。 -
PATH是一个系统环境变量,告诉 Shell 去哪里找命令。 -
查看所有环境变量:
env -
查看 PATH:
echo $PATH -
临时添加当前目录到 PATH:
PATH=$PATH:.(关闭 Shell 后失效) -
永久生效:需要修改 Shell 的配置文件,如
~/.bashrc或~/.bash_profile。cd ~ ls -la # 编辑 .bashrc 文件,在末尾添加 export PATH=$PATH:/your/path
3. 其他常见环境变量
-
HOME:当前用户的家目录。 -
SHELL:当前使用的 Shell 路径。 -
USER:当前用户名。 -
HISTSIZE:Shell 历史命令的保存条数。 -
OLDPWD:上一次所在的目录。 -
导出和取消环境变量:
export命令导入,unset命令取消。
4. 获取环境变量的三种方式
-
main函数的第三个参数#include <stdio.h> int main(int argc, char* argv[], char* env[]) { for (int i = 0; env[i]; i++) { printf("%d:%s\n", i, env[i]); } return 0; }-
环境变量是由父进程(如
bash)通过_start函数传递给子进程的。因此,环境变量具有全局性,可以被子进程继承。
-
-
getenv函数#include <stdio.h> #include <stdlib.h> int main() { printf("%s\n", getenv("PATH")); return 0; }-
可以直接获取指定环境变量的值。
-
应用:可以用它来限制程序只能由特定用户执行。
if (strcmp(getenv("USER"), "xc") == 0) { printf("执行\n"); }
-
-
environ全局变量#include <stdio.h> extern char** environ; int main() { for (int i = 0; environ[i]; i++) { printf("%s\n", environ[i]); } return 0; }
5. 本地变量与环境变量
-
本地变量:在 Shell 中直接定义,如
i=1。只在当前 Shell 进程中有效,不会被子进程继承。 -
环境变量:可以被所有子进程继承。
-
查看:
set命令可以查看所有变量(包括本地和环境),env命令只查看环境变量。 -
转换:
export i可以将本地变量提升为环境变量。
6. 内建命令
-
像
export、cd这样的命令,如果作为子进程执行,无法改变父进程(Shell)的环境。 -
因此,它们其实是 Shell 进程自己执行的,不创建子进程。这类命令称为内建命令。
八、虚拟地址空间
1. 问题:地址相同,值不同?
if (id == 0) {
// 子进程
a++;
printf("子进程:值:%d,地址:%p\n", a, &a);
} else {
// 父进程
printf("父进程:值:%d,地址:%p\n", a, &a);
}
-
输出:
子进程:值:2,地址:0x60103c和父进程:值:1,地址:0x60103c -
两个进程打印出的变量地址一样,但值却不同。
-
解释:程序打印的地址是虚拟地址,不是物理地址。
2. 虚拟地址空间
-
每个进程都有一个独立的虚拟地址空间(32 位系统下是 4GB)。
-
通过 页表,将虚拟地址映射到实际的物理内存地址。
-
每个进程都有自己的页表。
-
子进程创建时,会拷贝父进程的虚拟地址空间和页表(浅拷贝),所以相同变量的虚拟地址是一样的。当子进程修改变量时,触发写时拷贝,操作系统为它分配新的物理内存,并修改其页表映射,但虚拟地址保持不变。
3. 页表与写时拷贝
-
查地址:CPU 拿到虚拟地址,通过页表查询对应的物理地址。
-
写时拷贝:当页表和虚拟空间拷贝给子进程后,如果子进程要修改变量,操作系统就会在物理内存中开辟新空间,复制原数据,然后修改子进程的页表,使其指向新的物理地址。父进程的页表不受影响。
4. 虚拟地址的分配
-
虚拟地址空间给每个进程“画了一个 4GB 的大饼”。
-
但实际上,物理内存是有限的,不可能给每个进程都分配 4GB 的物理内存。
-
操作系统采用“按需分配”策略:当进程真正访问某个虚拟地址时,如果发现没有对应的物理内存(缺页中断),才去分配物理内存并建立映射。
5. 管理虚拟地址:mm_struct
-
进程的虚拟地址空间也需要管理。
-
管理方式依然是 先描述,再组织。
-
描述:用
mm_struct结构体描述整个虚拟地址空间的属性(如代码段、数据段、堆、栈的起始和结束地址)。 -
由于内存区域是连续的,只需要记录这些区间的
start和end即可。
6. 程序的加载
-
可执行程序被加载到物理内存后,操作系统会为其创建
mm_struct和页表,建立虚拟地址到物理地址的映射。 -
程序中的代码段、数据段的内容,就对应了虚拟地址空间中的代码区和数据区。
-
const char* str = "hello";中的字符串"hello"存储在只读数据段(或代码段),该区域在页表中的权限是只读。
7. 为什么需要虚拟地址空间?
-
将无序变有序:通过页表映射,使每个进程都认为自己拥有连续、有序的内存空间。
-
解耦合(降低与内存的相关性):
-
当物理内存不足时,可以将进程暂时不用的数据(如阻塞进程的数据)从内存移到磁盘的
swap分区(挂起)。只需清空页表即可。 -
需要时再加载回内存,重新建立页表映射。这降低了对物理内存容量的硬性依赖。
-
-
保护物理内存(权限管理):
-
页表中可以设置内存页的访问权限(读、写、执行)。
-
如果程序试图修改只读段(如
char* str = "hello"; *str = 'a';),CPU 会发现权限不符,触发异常,操作系统会终止该进程,从而保护了内存中代码段等关键数据不被篡改。
-
8. 理解野指针
-
野指针指向一个随机的虚拟地址。
-
如果这个虚拟地址对应的页表项不存在(没有映射到物理内存),CPU 访问时就会触发缺页异常,而操作系统发现这是一个非法访问(没有对应的合法内存区域),就会发送信号终止进程。
-
如果野指针“侥幸”指向了某个已存在的合法内存区域(如栈、堆),程序可能不会立即崩溃,但会导致难以预料的逻辑错误。
9. 缺页中断
-
程序加载时,可能只加载了部分代码和数据到物理内存。
-
当 CPU 执行到某个虚拟地址,发现页表中没有对应的物理内存时,会触发缺页中断。操作系统会暂停当前进程,从磁盘加载所需内容到物理内存,更新页表,然后恢复进程执行。
10. 堆的管理:vm_area_struct
-
堆空间是动态分配的,其内存区域可能不连续。
-
在
mm_struct下,会有链表来管理每一个堆区域,每个区域用一个vm_area_struct结构体描述(包含起始地址、结束地址、权限等)。
11. 进程独立性
-
进程的独立性体现在两个层面:
-
内核数据结构独立:每个进程有自己的
task_struct、mm_struct、页表等。 -
加载到内存的代码和数据独立:通过虚拟地址空间和页表映射,每个进程的地址空间相互隔离,一个进程的崩溃不会影响其他进程。
-
更多推荐




所有评论(0)