本文主要内容包括:

  • 认识冯诺依曼体系结构,理解现代计算机的基本工作原理
  • 学习操作系统的概念与定位,理解管理的本质思想
  • 深入理解进程概念,认识 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 如何理解 管理

管理这件事,最容易理解的方法,就是拿现实中的组织结构做类比。

比如学校里:

  • 学生是被管理对象
  • 辅导员负责日常管理
  • 校长负责更高层次的组织和决策

管理一个对象,通常分两步:

  1. 先描述它
  2. 再组织它

操作系统管理硬件和进程,也遵循同样的思路:

  1. 用结构体描述对象属性
  2. 用链表、树等数据结构组织这些对象

描述起来用结构体,组织起来用链表或其他高效数据结构

在这里插入图片描述


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 查看进程

查看进程信息有两种常见方式:

  1. 直接看 /proc 文件系统
  2. 使用 topps 这类用户态工具

比如 PID 为 1 的进程信息,可以查看 /proc/1 目录。


3-1-5 通过系统调用获取进程标识符

进程最重要的两个标识符是:

  • PID:进程号
  • PPID:父进程号
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
    printf("pid: %d\n", getpid());
    printf("ppid: %d\n", getppid());
    return 0;
}

这两个接口非常常用,尤其在父子进程、守护进程、进程树分析里。


3-1-6 通过系统调用创建进程-fork初识

创建子进程要用 fork

基本概念

  • fork 有两个返回值
  • 父子进程代码共享
  • 数据各自拥有一份
  • 采用写时拷贝机制
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
    int ret = fork();
    printf("hello proc : %d!, ret: %d\n", getpid(), ret);
    sleep(1);
    return 0;
}

通常会结合 if 做分流:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
    int ret = fork();
    if(ret < 0)
    {
        perror("fork");
        return 1;
    }
    else if(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);
    return 0;
}

注意

  • 为什么 fork 会有两个返回值
  • 返回值如何分别给父进程和子进程

这两个问题后面会深入讲进程复制机制。


3-2 进程状态

3-2-1 Linux内核源代码

Linux 内核里,进程状态不是单一的,而是有多种状态。

常见状态包括:

  • R:运行状态
  • S:睡眠状态
  • D:磁盘休眠状态
  • T:停止状态
  • X:死亡状态
  • Z:僵尸状态
    在这里插入图片描述

3-2-2 进程状态查看

查看进程状态常用命令是:

  • ps aux
  • ps axj

其中:

  • a:显示终端上的所有进程
  • x:显示没有控制终端的进程
  • j:显示进程组 ID、会话 ID、父进程 ID 等作业控制信息
  • u:以用户为中心显示详细信息
    在这里插入图片描述

3-2-3 Z(zombie)-僵尸进程

它的形成过程是:

  • 子进程已经退出
  • 父进程还活着
  • 父进程没有调用 waitwaitpid 回收子进程状态
  • 子进程就会进入 Z 状态

僵尸进程本身不再运行,但它的退出信息还留在进程表中,等待父进程读取。

例子:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    pid_t id = fork();
    if(id < 0)
    {
        perror("fork");
        return 1;
    }
    else if(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);
    }
    return 0;
}

僵尸进程危害

僵尸进程会一直占着进程表中的一部分信息,因为它的退出状态还要保留给父进程查看。

这意味着:

  • PCB 相关信息不能立刻释放
  • 如果父进程长期不回收,资源会被浪费
  • 大量僵尸进程会造成系统压力

注意,这类泄漏不是内存块忘了释放,而是进程状态和控制块长期无法回收。


3-2-5 孤儿进程

如果父进程先退出,子进程还在运行,那么这个子进程就叫做 孤儿进程

孤儿进程不会一直无人管理,它会被 1 号进程,也就是 initsystemd 进程领养,后续由它负责回收。

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main()
{
    pid_t id = fork();
    if(id < 0)
    {
        perror("fork");
        return 1;
    }
    else if(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);
    }
    return 0;
}

孤儿进程本身不是错误,但要理解它的归宿是被系统接管。


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 的取值范围通常是 -2019


3-3-4 PRI vs NI

区分:

  • PRI 是进程优先级
  • NI 不是优先级本身
  • NI 是影响优先级的修正值

也就是说,nice 值只是调整项,不是最终值。


3-3-5 查看进程优先级的命令

可以用 top 修改已有进程的 nice 值:

  • 先输入 top
  • 再按 r
  • 输入进程 PID
  • 再输入新的 nice 值

其他命令还有:

  • nice
  • renice

相关系统函数:

#include <sys/time.h>
#include <sys/resource.h>

int getpriority(int which, int who);
int setpriority(int which, int who, int prio);

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 排队

查找过程大致是:

  1. 从低下标开始遍历
  2. 找到第一个非空队列
  3. 取出队首进程执行

为了提高效率,还会配合 bitmap 记录哪些队列非空。

过期队列

时间片用完的进程会进入过期队列。

当活动队列处理完后,再把过期队列中的进程重新计算时间片,交换 active 和 expired 指针,继续下一轮调度。

所以整个查找过程可以保持接近 O(1) 的效率。


四、命令行参数和环境变量

4-1 基本概念

环境变量是操作系统中用来描述运行环境的一组参数。

它有几个典型特点:

  • 和系统配置有关
  • 具有全局属性
  • 会被子进程继承

比如编译和链接时,程序能找到动态库或静态库,很大程度上就和环境变量有关。


4-2 常见环境变量

常见环境变量包括:

  • PATH:命令搜索路径
  • HOME:当前用户主目录
  • SHELL:当前使用的 shell,一般是 /bin/bash

4-3 查看环境变量方法

查看某个环境变量:

echo $NAME

比如:

  • echo $PATH
  • echo $HOME

关于 PATH 的理解

如果一个程序不在系统默认路径里,通常要写完整路径才能执行。

把程序所在目录加入 PATH 后,就可以直接运行。


4-4 和环境变量相关的命令

常见命令有:

  1. echo:查看变量值
  2. export:导出环境变量
  3. env:显示所有环境变量
  4. unset:清除环境变量
  5. set:显示 shell 变量和环境变量

4-5 环境变量的组织方式

在这里插入图片描述

每个程序启动时,都会收到一张环境表。

这张环境表本质上是一个字符指针数组:

  • 每个元素指向一个环境字符串
  • 每个字符串都以 \0 结尾

4-6 通过代码如何获取环境变量

方法一:通过命令行参数传入的 env

#include <stdio.h>

int main(int argc, char *argv[], char *env[])
{
    int i = 0;
    for(; env[i]; i++)
    {
        printf("%s\n", env[i]);
    }
    return 0;
}

方法二:通过全局变量 environ

#include <stdio.h>

int main(int argc, char *argv[])
{
    extern char **environ;
    int i = 0;
    for(; environ[i]; i++)
    {
        printf("%s\n", environ[i]);
    }
    return 0;
}

environ 没有包含在常规头文件中,所以使用前要 extern 声明。


4-7 通过系统调用获取或设置环境变量

常用函数是:

  • getenv
  • putenv

示例:

#include <stdio.h>
#include <stdlib.h>

int main()
{
    printf("%s\n", getenv("PATH"));
    return 0;
}

getenv 用来获取指定环境变量的值。


4-8 环境变量通常是具有全局属性的

环境变量可以被子进程继承。

#include <stdio.h>
#include <stdlib.h>

int main()
{
    char *env = getenv("MYENV");
    if(env)
    {
        printf("%s\n", env);
    }
    return 0;
}

如果只设置:

MYENV="helloworld"

但不 export,程序通常拿不到。

如果执行:

export MYENV="hello world"

再运行程序,就能读取到。

这说明:导出的环境变量会进入环境表,并被子进程继承


五、程序地址空间

核心:

  • 程序地址空间
  • 进程地址空间
  • 虚拟地址
  • 物理地址

5-1 研究平台

这里讨论的环境是:

  • kernel 2.6.32
  • 32 位平台

地址空间布局和内核行为会受平台影响。


5-2 程序地址空间查看

验证不同区域的地址分布:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int g_unval;
int g_val = 100;

int main(int argc, char *argv[], char *env[])
{
    const char *str = "helloworld";
    printf("code addr: %p\n", main);
    printf("init global addr: %p\n", &g_val);
    printf("uninit global addr: %p\n", &g_unval);

    static int test = 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(int i = 0; i < argc; i++)
    {
        printf("argv[%d]: %p\n", i, argv[i]);
    }

    for(int i = 0; env[i]; i++)
    {
        printf("env[%d]: %p\n", i, env[i]);
    }

    return 0;
}

通过打印可以看出:

  • 代码段有自己的地址
  • 全局变量有自己的地址
  • 堆和栈分布不同
  • 字符串常量在只读区
  • argv 和 env 也都有自己的位置

这说明进程内存布局是有规律的。


5-3 虚拟地址

如:

#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(3);
        printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }
    sleep(1);
    return 0;
}

输出会出现一个很关键的现象:

  • 父子进程变量地址一样
  • 但变量值不一样

这说明:

  • 这个地址不是物理地址
  • 它是 虚拟地址

所以我们在 C 和 C++ 里看到的地址,全部都是虚拟地址。物理地址对用户是不可见的,由操作系统统一管理。


5-4 进程地址空间

以前常说程序地址空间,但更准确的说法其实是 进程地址空间
在这里插入图片描述

原因:

  • 每个进程都有自己独立的地址空间
  • 同一个虚拟地址,在不同进程里可以映射到不同物理地址
  • 进程之间互不干扰

进程地址空间由操作系统通过页表进行映射和管理。

描述进程地址空间的核心结构体是:

  • mm_struct:内存描述符

task_struct 中,有一个指向 mm_struct 的指针。


5-5 虚拟内存管理

task_structmm_struct 的关系,可以理解为:

  • task_struct 管进程整体
  • mm_struct 管进程地址空间

mm_struct 描述的是整个用户空间。每个进程都有自己的 mm_struct,这样才能拥有独立地址空间。
在这里插入图片描述

常见字段包括:

  • mmap:指向虚拟区间链表
  • mm_rb:红黑树根
  • task_size:进程虚拟地址空间大小
  • start_codeend_code:代码段范围
  • start_dataend_data:数据段范围
  • start_brkbrk:堆区范围
  • start_stack:栈区起始
  • arg_startarg_end:命令行参数区域
  • env_startenv_end:环境变量区域

vm_area_struct

Linux 用 vm_area_struct 表示一个独立的虚拟内存区域,也叫 VMA。

一个进程通常会有多个 VMA,分别表示:

  • 代码区
  • 数据区
  • 堆区
  • 栈区
  • 文件映射区
  • 共享内存区在这里插入图片描述
    在这里插入图片描述

vm_area_struct 的一些典型字段:

  • vm_start:起始地址
  • vm_end:结束地址
  • vm_nextvm_prev:链表前后指针
  • vm_rb:红黑树节点
  • vm_mm:所属的 mm_struct
  • vm_flags:标志位
  • vm_file:映射文件
  • vm_private_data:私有数据

5-6 为什么要有虚拟地址空间

如果程序直接操作物理内存,会有什么问题?

1. 安全风险

如果每个进程都能直接访问物理内存,那么:

  • 程序可以随便读写系统内存
  • 木马和病毒更容易破坏系统
  • 进程之间也更容易互相干扰

显然不安全。

2. 地址不确定

程序编译好后存放在磁盘上,运行时才被加载到内存。

如果直接用物理地址,那么每次装载的位置都可能不同:

  • 第一次运行,内存可能很空
  • 第二次运行,内存已经有很多程序了
  • 地址每次都可能变化

这样程序就很难稳定运行。

3. 效率低下

如果直接使用物理内存,进程会以整体块的形式管理。

当内存不足时,想把不常用的程序换出到磁盘,就得整块搬移,效率很低。


虚拟地址空间的好处

有了虚拟地址空间和页表机制之后,问题就解决了很多:

  • 进程看起来都有独立的地址空间
  • 进程之间互不干扰
  • 物理内存可以被统一调度
  • 内存管理和进程管理可以解耦
  • mallocnew 时,未必立刻分配物理内存
  • 需要访问时才真正触发分配,这叫延迟分配

虚拟地址空间的本质,是让进程看到一个稳定、连续、安全的内存空间


总结

  • 计算机所有设备最终都要和内存打交道
  • 操作系统的核心任务是管理
  • 进程是操作系统管理资源的基本单位
  • task_struct 是进程的核心描述结构
  • fork 可以创建子进程
  • 进程有运行、睡眠、僵尸、孤儿等状态
  • 优先级和调度决定进程执行顺序
  • 环境变量会被进程继承
  • 程序看到的是虚拟地址,不是物理地址
  • 虚拟地址空间让进程更安全、更稳定、更高效

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐