本文主要内容包括:
- 认识冯诺依曼体系结构,理解现代计算机的基本工作原理
- 学习操作系统的概念与定位,理解管理的本质思想
- 深入理解进程概念,认识 PCB(进程控制块)及其作用
- 学习进程状态,掌握进程创建过程,理解僵尸进程和孤儿进程的形成原因及危害
- 了解 Linux 进程调度机制,掌握进程优先级相关概念
- 理解进程的竞争性与独立性,区分并发与并行
- 理解进程切换过程,认识 Linux 2.6 Kernel 中 O(1) 调度算法架构
- 学习环境变量相关知识,掌握常见环境变量及相关命令
- 熟悉 getenv、setenv 等环境变量操作函数
- 理解 C 语言内存空间分布规律
- 了解进程内存映像与应用程序之间的区别
- 认识虚拟地址空间及其存在意义
一、冯诺依曼体系结构
冯诺依曼体系结构是现代计算机最常见的组织方式。我们平时接触的笔记本、服务器,大体都符合这套模型。它把计算机划分成几个基本部分:
- 输入单元,比如键盘、鼠标、扫描仪
- 中央处理器 CPU,负责运算和控制
- 输出单元,比如显示器、打印机
- 存储器,也就是内存
这里要特别注意:这里说的存储器指的是内存,不是硬盘。
冯诺依曼体系最核心的规则是:
- CPU 只能直接和内存打交道
- 外设也只能先把数据写入内存,或者从内存取数据
- 所有设备的数据传输,本质上都绕不开内存
也就是说,所有设备都只能直接和内存交互。
注意,理解冯诺依曼不能只停留在硬件名词上,还要能把它和软件数据流联系起来。比如 QQ 聊天时:
- 你输入消息,先进入内存
- CPU 处理消息内容
- 消息通过网络协议发出去
- 对方收到后,数据先落到内存,再交给对应程序读取
如果发送文件,过程也是一样的,只是传输的数据量更大,路径更长。
二、操作系统(Operator System)
2-1 概念
操作系统是整个计算机系统中最基础的一类软件。广义上说,它包含两部分:
- 内核,比如进程管理、内存管理、文件管理、驱动管理
- 其他系统级程序,比如函数库、shell 等
操作系统对外看起来像一个整体,但内部其实分工明确。它最重要的任务不是替代应用程序,而是给应用程序提供运行环境。
2-2 设计OS的目的
操作系统存在的意义可以分成两个方向:
- 对下:和硬件交互,管理所有软硬件资源
- 对上:给用户程序提供一个稳定、统一、好用的执行环境
这意味着操作系统既要管硬件,又要服务上层程序。
2-3 核心功能
操作系统在整个软硬件架构中的定位,可以概括成一句话:
它是一款专门负责管理的软件
它要管理的对象很多,比如:
- CPU
- 内存
- 硬盘
- 文件
- 进程
- 设备
所以理解操作系统,最重要的不是把它想成一个神秘的大黑盒,而是把它看成一个管理者。
2-4 如何理解 管理
管理这件事,最容易理解的方法,就是拿现实中的组织结构做类比。
比如学校里:
- 学生是被管理对象
- 辅导员负责日常管理
- 校长负责更高层次的组织和决策
管理一个对象,通常分两步:
- 先描述它
- 再组织它
操作系统管理硬件和进程,也遵循同样的思路:
- 用结构体描述对象属性
- 用链表、树等数据结构组织这些对象
描述起来用结构体,组织起来用链表或其他高效数据结构
2-5 系统调用和库函数概念
操作系统不会把所有能力都直接暴露给用户程序。它会提供一部分基础接口给上层使用,这些接口叫做系统调用。
系统调用的特点是:
- 功能比较底层
- 使用门槛高
- 接口偏基础
为了方便开发,很多系统调用会被进一步封装成更好用的库函数。
简单理解:
- 系统调用负责和内核直接打交道
- 库函数负责把复杂接口包装得更容易用
三、进程
3-1 基本概念与基本操作
教材对进程的定义:程序的一个执行实例,也就是正在执行的程序。
从内核的角度看,进程是:
分配系统资源的实体
现在可以先把进程理解成:
内核数据结构 task_struct + 程序代码和数据
也就是说,进程不是单纯的一段代码,而是代码和内核管理信息的组合。
3-1-2 描述进程-PCB
进程的信息不会散落在内核中,而是统一放在一个专门的数据结构里,这个结构叫做进程控制块 PCB。
在 Linux 中,PCB 对应的就是task_struct。
task_struct 里会包含进程的很多属性
如:
- 进程标识符
- 状态
- 优先级
- 程序计数器
- 内存指针
- 上下文数据
- I/O 状态信息
- 记账信息
可以把它理解成进程的档案袋,进程要被管理,先得有档案。
3-1-3 task_struct
Linux 内核中,进程就是靠 task_struct 描述的。它会被装载到内存里,里面保存了进程的完整信息。
常见字段为:
- 标识符:区分不同进程
- 状态:表示当前处于运行、睡眠、停止等哪种状态
- 优先级:决定调度先后
- 程序计数器:下一条将要执行的指令地址
- 内存指针:代码段、数据段以及共享内存的相关信息
- 上下文数据:寄存器现场
- I/O 状态信息:文件列表、I/O 请求等
- 记账信息:CPU 时间、执行时间限制等
所有进程在内核里还会以双链表的形式组织起来,方便统一管理。
3-1-4 查看进程
查看进程信息有两种常见方式:
- 直接看
/proc文件系统 - 使用
top、ps这类用户态工具
比如 PID 为 1 的进程信息,可以查看/proc/1目录。
3-1-5 通过系统调用获取进程标识符
进程最重要的两个标识符是:
- PID:进程号
- PPID:父进程号
#include<stdio.h>#include<sys/types.h>#include<unistd.h>intmain(){printf("pid: %d\n",getpid());printf("ppid: %d\n",getppid());return0;}这两个接口非常常用,尤其在父子进程、守护进程、进程树分析里。
3-1-6 通过系统调用创建进程-fork初识
创建子进程要用fork。
基本概念
fork有两个返回值- 父子进程代码共享
- 数据各自拥有一份
- 采用写时拷贝机制
#include<stdio.h>#include<sys/types.h>#include<unistd.h>intmain(){intret=fork();printf("hello proc : %d!, ret: %d\n",getpid(),ret);sleep(1);return0;}通常会结合if做分流:
#include<stdio.h>#include<sys/types.h>#include<unistd.h>intmain(){intret=fork();if(ret<0){perror("fork");return1;}elseif(ret==0)// 子进程{printf("I am child : %d!, ret: %d\n",getpid(),ret);}else// 父进程{printf("I am father : %d!, ret: %d\n",getpid(),ret);}sleep(1);return0;}注意:
- 为什么
fork会有两个返回值 - 返回值如何分别给父进程和子进程
这两个问题后面会深入讲进程复制机制。
3-2 进程状态
3-2-1 Linux内核源代码
Linux 内核里,进程状态不是单一的,而是有多种状态。
常见状态包括:
- R:运行状态
- S:睡眠状态
- D:磁盘休眠状态
- T:停止状态
- X:死亡状态
- Z:僵尸状态
3-2-2 进程状态查看
查看进程状态常用命令是:
ps auxps axj
其中:
a:显示终端上的所有进程x:显示没有控制终端的进程j:显示进程组 ID、会话 ID、父进程 ID 等作业控制信息u:以用户为中心显示详细信息
3-2-3 Z(zombie)-僵尸进程
它的形成过程是:
- 子进程已经退出
- 父进程还活着
- 父进程没有调用
wait或waitpid回收子进程状态 - 子进程就会进入 Z 状态
僵尸进程本身不再运行,但它的退出信息还留在进程表中,等待父进程读取。
例子:
#include<stdio.h>#include<stdlib.h>#include<unistd.h>intmain(){pid_tid=fork();if(id<0){perror("fork");return1;}elseif(id>0){printf("parent[%d] is sleeping...\n",getpid());sleep(30);}else{printf("child[%d] is begin Z...\n",getpid());sleep(5);exit(EXIT_SUCCESS);}return0;}僵尸进程危害
僵尸进程会一直占着进程表中的一部分信息,因为它的退出状态还要保留给父进程查看。
这意味着:
- PCB 相关信息不能立刻释放
- 如果父进程长期不回收,资源会被浪费
- 大量僵尸进程会造成系统压力
注意,这类泄漏不是内存块忘了释放,而是进程状态和控制块长期无法回收。
3-2-5 孤儿进程
如果父进程先退出,子进程还在运行,那么这个子进程就叫做孤儿进程。
孤儿进程不会一直无人管理,它会被 1 号进程,也就是init或systemd进程领养,后续由它负责回收。
#include<stdio.h>#include<unistd.h>#include<stdlib.h>intmain(){pid_tid=fork();if(id<0){perror("fork");return1;}elseif(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);}return0;}孤儿进程本身不是错误,但要理解它的归宿是被系统接管。
3-3 进程优先级
3-3-1 基本概念
CPU 资源分配的先后顺序,就是进程的优先级。
优先级高的进程,通常更早获得执行机会。
这在多任务系统里非常重要,因为 CPU 资源有限,进程很多,必须通过优先级来协调竞争。
3-3-2 查看系统进程
在ps -l的输出中,常见字段有:
- UID:执行者身份
- PID:进程号
- PPID:父进程号
- PRI:实际执行优先级
- NI:nice 值
3-3-3 PRI and NI
PRI可以理解为真正的优先级,值越小越优先执行。
NI是 nice 值,可以理解成优先级修正值。
二者关系可以写成:
PRI(new)=PRI(old)+nice所以:
- nice 值越小,优先级越高
- nice 值越大,优先级越低
Linux 中 nice 的取值范围通常是-20到19。
3-3-4 PRI vs NI
区分:
PRI是进程优先级NI不是优先级本身NI是影响优先级的修正值
也就是说,nice 值只是调整项,不是最终值。
3-3-5 查看进程优先级的命令
可以用top修改已有进程的 nice 值:
- 先输入
top - 再按
r - 输入进程 PID
- 再输入新的 nice 值
其他命令还有:
nicerenice
相关系统函数:
#include<sys/time.h>#include<sys/resource.h>intgetpriority(intwhich,intwho);intsetpriority(intwhich,intwho,intprio);3-3-6 竞争、独立、并行、并发
| 概念 | 含义 |
|---|---|
| 竞争性 | 多个进程争抢有限 CPU 资源 |
| 独立性 | 多个进程互不干扰,各自运行 |
| 并行 | 多个进程在多个 CPU 上同时运行 |
| 并发 | 多个进程在一个 CPU 上通过切换推进执行 |
并行强调真正同时执行,并发强调一段时间内都能推进。
3-4 进程切换
进程切换的本质是 CPU 上下文切换,也就是:
- 保存当前任务的寄存器现场
- 恢复下一个任务的寄存器现场
- CPU 转去执行新的任务
注意,时间片是分时系统里非常重要的概念。每个进程都会被分配一定的 CPU 时间片,时间片到了,就会被系统切走。
3-4 Linux2.6内核进程O(1)调度队列
Linux 2.6 时代的调度器采用过 O(1) 调度思想,目标是:查找最合适进程的时间复杂度尽量保持常数级。
基本结构
每个 CPU 对应一个 runqueue。
如果有多个 CPU,就要考虑负载均衡问题。
调度队列分为两个核心数组:
- active:活动队列
- expired:过期队列
优先级划分
- 普通优先级:100 到 139
- 实时优先级:0 到 99
普通进程最常见。
活动队列
时间片没有用完的进程都放在活动队列中。
nr_active:活动进程数queue[140]:每个优先级一个队列- 同优先级按 FIFO 排队
查找过程大致是:
- 从低下标开始遍历
- 找到第一个非空队列
- 取出队首进程执行
为了提高效率,还会配合 bitmap 记录哪些队列非空。
过期队列
时间片用完的进程会进入过期队列。
当活动队列处理完后,再把过期队列中的进程重新计算时间片,交换 active 和 expired 指针,继续下一轮调度。
所以整个查找过程可以保持接近 O(1) 的效率。
四、命令行参数和环境变量
4-1 基本概念
环境变量是操作系统中用来描述运行环境的一组参数。
它有几个典型特点:
- 和系统配置有关
- 具有全局属性
- 会被子进程继承
比如编译和链接时,程序能找到动态库或静态库,很大程度上就和环境变量有关。
4-2 常见环境变量
常见环境变量包括:
PATH:命令搜索路径HOME:当前用户主目录SHELL:当前使用的 shell,一般是/bin/bash
4-3 查看环境变量方法
查看某个环境变量:
echo$NAME比如:
echo $PATHecho $HOME
关于 PATH 的理解
如果一个程序不在系统默认路径里,通常要写完整路径才能执行。
把程序所在目录加入PATH后,就可以直接运行。
4-4 和环境变量相关的命令
常见命令有:
echo:查看变量值export:导出环境变量env:显示所有环境变量unset:清除环境变量set:显示 shell 变量和环境变量
4-5 环境变量的组织方式
每个程序启动时,都会收到一张环境表。
这张环境表本质上是一个字符指针数组:
- 每个元素指向一个环境字符串
- 每个字符串都以
\0结尾
4-6 通过代码如何获取环境变量
方法一:通过命令行参数传入的 env
#include<stdio.h>intmain(intargc,char*argv[],char*env[]){inti=0;for(;env[i];i++){printf("%s\n",env[i]);}return0;}方法二:通过全局变量 environ
#include<stdio.h>intmain(intargc,char*argv[]){externchar**environ;inti=0;for(;environ[i];i++){printf("%s\n",environ[i]);}return0;}environ没有包含在常规头文件中,所以使用前要extern声明。
4-7 通过系统调用获取或设置环境变量
常用函数是:
getenvputenv
示例:
#include<stdio.h>#include<stdlib.h>intmain(){printf("%s\n",getenv("PATH"));return0;}getenv用来获取指定环境变量的值。
4-8 环境变量通常是具有全局属性的
环境变量可以被子进程继承。
#include<stdio.h>#include<stdlib.h>intmain(){char*env=getenv("MYENV");if(env){printf("%s\n",env);}return0;}如果只设置:
MYENV="helloworld"但不export,程序通常拿不到。
如果执行:
exportMYENV="hello world"再运行程序,就能读取到。
这说明:导出的环境变量会进入环境表,并被子进程继承。
五、程序地址空间
核心:
- 程序地址空间
- 进程地址空间
- 虚拟地址
- 物理地址
5-1 研究平台
这里讨论的环境是:
- kernel 2.6.32
- 32 位平台
地址空间布局和内核行为会受平台影响。
5-2 程序地址空间查看
验证不同区域的地址分布:
#include<stdio.h>#include<unistd.h>#include<stdlib.h>intg_unval;intg_val=100;intmain(intargc,char*argv[],char*env[]){constchar*str="helloworld";printf("code addr: %p\n",main);printf("init global addr: %p\n",&g_val);printf("uninit global addr: %p\n",&g_unval);staticinttest=10;char*heap_mem=(char*)malloc(10);char*heap_mem1=(char*)malloc(10);char*heap_mem2=(char*)malloc(10);char*heap_mem3=(char*)malloc(10);printf("heap addr: %p\n",heap_mem);printf("heap addr: %p\n",heap_mem1);printf("heap addr: %p\n",heap_mem2);printf("heap addr: %p\n",heap_mem3);printf("test static addr: %p\n",&test);printf("stack addr: %p\n",&heap_mem);printf("stack addr: %p\n",&heap_mem1);printf("stack addr: %p\n",&heap_mem2);printf("stack addr: %p\n",&heap_mem3);printf("read only string addr: %p\n",str);for(inti=0;i<argc;i++){printf("argv[%d]: %p\n",i,argv[i]);}for(inti=0;env[i];i++){printf("env[%d]: %p\n",i,env[i]);}return0;}通过打印可以看出:
- 代码段有自己的地址
- 全局变量有自己的地址
- 堆和栈分布不同
- 字符串常量在只读区
- argv 和 env 也都有自己的位置
这说明进程内存布局是有规律的。
5-3 虚拟地址
如:
#include<stdio.h>#include<unistd.h>#include<stdlib.h>intg_val=0;intmain(){pid_tid=fork();if(id<0){perror("fork");return0;}elseif(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);}sleep(1);return0;}输出会出现一个很关键的现象:
- 父子进程变量地址一样
- 但变量值不一样
这说明:
- 这个地址不是物理地址
- 它是虚拟地址
所以我们在 C 和 C++ 里看到的地址,全部都是虚拟地址。物理地址对用户是不可见的,由操作系统统一管理。
5-4 进程地址空间
以前常说程序地址空间,但更准确的说法其实是进程地址空间。
原因:
- 每个进程都有自己独立的地址空间
- 同一个虚拟地址,在不同进程里可以映射到不同物理地址
- 进程之间互不干扰
进程地址空间由操作系统通过页表进行映射和管理。
描述进程地址空间的核心结构体是:
mm_struct:内存描述符
在task_struct中,有一个指向mm_struct的指针。
5-5 虚拟内存管理
task_struct和mm_struct的关系,可以理解为:
task_struct管进程整体mm_struct管进程地址空间
mm_struct描述的是整个用户空间。每个进程都有自己的mm_struct,这样才能拥有独立地址空间。
常见字段包括:
mmap:指向虚拟区间链表mm_rb:红黑树根task_size:进程虚拟地址空间大小start_code、end_code:代码段范围start_data、end_data:数据段范围start_brk、brk:堆区范围start_stack:栈区起始arg_start、arg_end:命令行参数区域env_start、env_end:环境变量区域
vm_area_struct
Linux 用vm_area_struct表示一个独立的虚拟内存区域,也叫 VMA。
一个进程通常会有多个 VMA,分别表示:
- 代码区
- 数据区
- 堆区
- 栈区
- 文件映射区
- 共享内存区
vm_area_struct的一些典型字段:
vm_start:起始地址vm_end:结束地址vm_next、vm_prev:链表前后指针vm_rb:红黑树节点vm_mm:所属的 mm_structvm_flags:标志位vm_file:映射文件vm_private_data:私有数据
5-6 为什么要有虚拟地址空间
如果程序直接操作物理内存,会有什么问题?
1. 安全风险
如果每个进程都能直接访问物理内存,那么:
- 程序可以随便读写系统内存
- 木马和病毒更容易破坏系统
- 进程之间也更容易互相干扰
显然不安全。
2. 地址不确定
程序编译好后存放在磁盘上,运行时才被加载到内存。
如果直接用物理地址,那么每次装载的位置都可能不同:
- 第一次运行,内存可能很空
- 第二次运行,内存已经有很多程序了
- 地址每次都可能变化
这样程序就很难稳定运行。
3. 效率低下
如果直接使用物理内存,进程会以整体块的形式管理。
当内存不足时,想把不常用的程序换出到磁盘,就得整块搬移,效率很低。
虚拟地址空间的好处
有了虚拟地址空间和页表机制之后,问题就解决了很多:
- 进程看起来都有独立的地址空间
- 进程之间互不干扰
- 物理内存可以被统一调度
- 内存管理和进程管理可以解耦
malloc和new时,未必立刻分配物理内存- 需要访问时才真正触发分配,这叫延迟分配
虚拟地址空间的本质,是让进程看到一个稳定、连续、安全的内存空间
总结
- 计算机所有设备最终都要和内存打交道
- 操作系统的核心任务是管理
- 进程是操作系统管理资源的基本单位
- task_struct 是进程的核心描述结构
- fork 可以创建子进程
- 进程有运行、睡眠、僵尸、孤儿等状态
- 优先级和调度决定进程执行顺序
- 环境变量会被进程继承
- 程序看到的是虚拟地址,不是物理地址
- 虚拟地址空间让进程更安全、更稳定、更高效
完