进程线性地址空间

我们在之前的博客中尝试描述了内核加载进物理内存,物理内存划分和物理页框管理 buddy system 以及内核小对象内存管理器 slab system,这三部分内容 主要想要读者能以线性思维理解:

  1. 作为内核的代码和数据是在什么时机加载到物理内存的,
  2. 以及内核加载之后是如何管理和分配物理内存的,

至此内核的存在形式,不再神秘,但仍然复杂。

内核充分初始化后,具有了分配整个 page frame 和任意固定大小内存块的能力,充分满足了内核各个模块的内存分配需求,虽然其中的细节很复杂。接下来开始 我们的重头戏:

  • 进程是什么?
  • 进程在哪里?
  • 进程中提及的堆、栈以及更宽泛概念的进程线性地址空间是什么?

这是几个看起来简单但是很有趣的问题,它是一种对抽象能力的锻炼。我尝试给出一个简单的回答,如果是我刚开始学习编程的时候,我是这样回答的:

  1. 进程是系统中拥有资源的最小逻辑单位,线程是最小的运行单位
  2. 进程在操作系统中
  3. 栈是进程用来放置临时变量的,从高地址向地址拓展,一般只有 4MB;堆是进程用来分配大型空间的,从低地址向高地址拓展,一般可以分配多达 GB 级别的 数据。进程线性地址空间是操作系统给地址分配的连续的线性地址空间 [0GB, 4GB)。

这些回答原理上没有错误,也是一些网上的答案。但是再仔细想下这个回答,我会问出一下几个问题:

  1. 拥有资源和运行单位是什么意思?具体拥有哪些资源,以什么形式拥有以及以什么形式运行?
  2. 进程在操作系统中是什么样的存在?在操作系统的哪里?
  3. 为什么要设计堆栈?堆栈是什么?线性地址空间具体是什么东西?是一段连续物理内存吗?

我在头几年工作的时候,大量接触的都是业务逻辑代码或者一些内存数据库,消息队列等等具备业务级别抽象或者产品形态抽象的代码,其中大量都会涉及到多线程,线程同步,堆内存 分配和文件/网络操作等等。在空闲之余,我就会想起这些问题,虽然不知道这些问题答案的时候,我写的代码也能在线上正常运行,但是内心实在惶恐不安,因为我实在 不知道我用的这些系统概念是个什么东西。

进程和线性地址空间

带着这些问题,我们开始介绍进程和线性地址空间的概念。前面几篇博客已经或多或少提及了一些相关的概念,比如介绍逻辑地址的时候就提及了线性地址和内存分页的概念。

为了说明进程和线性地址空间的存在形式,我们从编程人员的视角出发。

首先在编程人员的视角上看,执行逻辑和内存需求都是相当线性的。

  • 执行逻辑上,业务逻辑一般从 main 函数开始,然后我们可能会定义一些全局变量和函数内的局部变量,对于一些并行逻辑,我们调用一些线程创建系统调用 pthread_create 或者 子进程创建系统调用 fork,然后对于一些并行执行流都有可能访问到的内存单元,加锁或者加临界区保护。最后就是主线程执行一个循环等待网络连接或者其他形式的通知,业务执行流一直 运行直到一些预期外的错误导致逻辑退出,比如进程 core 或者内存超标被系统 kill。
  • 内存需求上,根据作用域和空间需求,有全局作用域的全局变量,函数内的局部变量。局部变量存在于栈中,函数退出后对应的局部变量就销毁了,对于一些大空间缓存的需求,使用 new 或者 NEW 分配堆上内存。

以上的描述非常容易理解,因为在思考方式上它是线性的,看起来非常够用,起码在这套逻辑上已经构建了影响了整个世界的软件,比如 TikTok, Taobao 等等。不管是在 windows,android 还是 linux 操作系统上。
这就有意思了,上面的描述中就没有看到内核的影子,内核存在地方可能就是系统调用使用 IO 或者网络等硬件设施的地方,这几个地方也可以是线性的,就是一段操作对应硬件设施的代码而已。

看起来我写的从 main 开始的代码从头到尾,占据了 CPU 和物理内存,并且一直运行直到终止。
作为一个编程人员,世界真是简单而美好,一些都这么线性。对的,这就是操作系统的美妙之处,它把海量的细节藏起来了,但你并没有觉得难受,反而觉得简单而美好。

为了说明操作系统是何时以何种形式介入到用户执行逻辑中的,我们要再回顾下最早我们在内核在做什么 的博客中对 CPU 和内存的描述:

  • CPU 是个被时钟信号驱动的不断执行指令的物理组件
  • 物理内存是提供存储的物理组件,空间一般按照字节被寻址读写

对于这两个关键物理组件,对应上我们在上面描述的编程人员视角的执行逻辑和内存需求,可以发现是对应的,顺序的业务执行流<-->CPU 顺序执行指令内存分配<--->连续物理内存。 虽然你会疑惑:

  • 并发执行流是如何并发的
  • 申请的内存具体是在物理内存的哪个位置分配的

这些疑问是非常合理自然的,这些疑问的答案正是内核。早期计算机和现在的一些单片机在没有内核支持的情况下,肯定是没有并发执行流和自动内存分配这些高级能力的。仔细想下,对于开发人员而言,需要的其实是顺序执行的逻辑流和连续可用的内存空间。注意这里的区别,

  • 顺序执行的逻辑流 != 独占 CPU
  • 连续可用的内存空间 != 独占内存

理解了这里,我们就能讲清楚内核是从哪里介入业务逻辑的。

  • 对于顺序执行的逻辑流而言,
    需要的是一个当前执行状态+已经存在的状态。这个描述意思是说,我们需要一个顺序的执行流并不意味着我们需要一个连续执行不间断的顺序执行流。
    举个例子:
    比如用户逻辑中有以下 5 行伪代码,
    define A
    A=A+3
    A=A*3 
    // temporary stop 
    define B
    B=2*A
    

    这样一段顺序执行的代码,我们可以在第 3 句执行之后暂停再恢复。此时我们保留下执行第三句之后的当前执行状态到内存的某个位置,其他一些历史状态也保留在内存中,比如代码和分配的内存。 恢复的时候把重放当前执行状态,暂停恢复的时间可长可短,但是恢复后和不间断执行在结果上一样的。
    从编程人员的角度看来,代码逻辑就是顺序连续执行的。所以,连续这个概念就不是操作上的不间断,而是一种 主观感受。类比于连续的视频画面实际上是每秒 24 帧图片,只要图片更新频率高于或者等于 24 帧/s,人的肉眼就感觉不到明显的卡顿。

业务逻辑流执行也是一样的道理。这里讲得更加具体一点,当前执行状态专业的叫法是硬件上下文,包括指令寄存器 eip,栈寄存器 esp/ebp, 和一些通用寄存器等等,这些寄存器的值构成了能恢复当前执行流的 所有条件,而已经存在的状态包括数据段,代码段,打开的一些文件状态等等,有部分存在于文本(比如 exec 执行文件中的代码段),有部分则已经在内存中。

所以站在内核的角度看,编程人员编写顺序执行流只是 硬件上下文+已经存在的状态, 只要有合适的数据结构能保存这两样东西,就能随时暂停和恢复用户定义的执行流,并且为用户营造 独占 CPU 并且一直顺序执行的假象。之所以编程人员感受不到逻辑被切换,是因为内核使用周期性的时钟中断或者其他中断来强行打断 CPU 当前执行流,从而实现进程切换,这个我们在讲进程切换相关内容时再 仔细讲解。对于内存篇,我们只需要了解进程本质上是为了存储硬件上下文+已经存在的状态而存在的。
process-logic-running
source

  • 对于连续可用的内存空间而言,
    我们并不需要真正的连续物理存储,因为那样很局限,编程人员需要知道系统中其他人使用了哪些物理内存区间,而自己可用的物理内存区间是什么,需要关注的无关细节太多,抽象和 封装做得不好。所以一个更好的做法是,给每个进程 4GB 的连续线性地址空间,在内核层增加一个中间层,也就是前面我们说的进程页表,维护进程线性地址页到真正物理页框的映射关系,这样 CPU 在每次访问具体某个 进程的线性地址时,就可以通过查询进程页表的方式,找到对应数据的物理地址,从而操作数据,而不必关系操作的物理地址是什么。

物理地址就被封装到了内核中,用户使用线性地址这个抽象就可以了。站在内核的角度看,编程人员操作的连续内存空间只是 连续线性地址+进程页表

进程线性区间

前面我们讲到,内核是如何为编程人员提供顺序执行的逻辑流和连续可用的内存空间这两个抽象概念的,也大致描述了它们分别是如何实现的。进程就是实现上面两个抽象的资源集合,具体就是为每个用户进程定义一个 task struct,这个结构体中存储了上面我们提及的硬件上下文,用户代码段和进程页表之类的数据。

我们知道所有的数据和代码都需要地址才能被 CPU 寻址到,对于进程而言,就是线性地址。为了有效管理 4G 的线性地址空间,包括访问权限,用途之类的信息,进程按照功能需求将整个地址空间划分成多个线性区间。比较有名 的是我们经常谈及的栈区,堆区,代码区和数据区。需要注意的是,这里的线性区只是一个逻辑上概念,我们在分配一个线性区的时候,最简单的只需要 2 个字段即可,一个是线性区在线性地址空间中的起始位置,一个是线性区在 线性地址空间中的终止位置。当然实际实现的时候肯定其他一些权限信息,引用计数信息之类的字段,不过那些不影响我们对核心概念的理解。

有了线性区间之后,对于不同类型的线性地址,内核大概是这样处理的。

  • 对于存储代码段的线性区间,它的权限是只读,当我们编写的代码写一个变量,比如A=1时,假设 A 的地址不小心被改成了指向了代码区的某个地址,此时内核检查变量线性地址所在的线性区间权限是只读时,就意味着不合理的 内存访问行为,给进程发送 SIGSEGV 的信号,进程终止。
  • 对于可读可写区间的数据段,同样当我们编写的代码写一个变量,比如A=1时,假设 A 的地址落在了合法的数据线性区间,此时就查询进程的页表信息,查看对应的硬件页是否已经存在了。如果已经存在硬件页,CPU 就 通过内存总线读取对应的物理地址的数据;如果硬件页不存在,就触发内核的缺页逻辑,分配物理页之后再重新执行这个语句。代码寻址和数据寻址的逻辑类似,但是多了将硬盘中的可执行文件加载到对应硬件页中的操作。

以上大概就是进程和进程线性地址空间的核心概念。下面我们简单介绍下一些重点的线性区间。

代码区和数据区

  • 代码区:存储用户编写代码。用户编写代码一般都是用一些高级语言,经过编译器、链接器和装载器处理之后,用户代码变成了机器代码,也就是能被 CPU 理解的命令(字节码),放在了代码区(访问到才会从文件中加载到内存中)。
  • 数据区:用户定义的初始化和未初始化的全局变量,经过处理后会被放入对应不同的数据区,放入数据区后不同的变量就有了对应的线性地址。

    栈区

    栈区也是一个线性区间,一段连续的线性地址空间,因为访问模式上和数据结构中的栈相同,所以叫做栈区。 抽象出这个概念的原因主要有 2 个:

    1. 便于模拟函数调用的过程。举个例子: ``` func A{ define a1, a2; call B(); }

    func B{ define b1, b2; call C(); }

    func C{ define c1; c1=1; } ``` 上面是我们写代码逻辑的常见结构,为了代码逻辑好维护,我们会将不同的逻辑放入不同的函数中,进行递进调用func A -> func B -> func C。 同时,在每个函数中,我们会传递一些参数和定义自己的局部变量。 函数调用是从上到下的,但是函数执行是从下到上的,刚好和数据结构中的栈一样,先入栈的后出栈。所以我们通过使用一个线性区间来模拟栈,为函数调用提供一个临时的存储区间。 2. locality 原理。函数使用的临时变量一个重要特点是,作用域小,访问密集,函数退出后就无效了。针对这个 locality 特点,定义一个小区间,这个小区间除了具备栈的操作特征之后,还可以充分利用高速缓存中的 cacheline, 不需要频繁的加载内存就可以在 cacheline 中持续读写相同位置的临时变量。这样也有坏处,比如 C/C++ 语言不会自动初始化临时变量的值,所以有时候用户会发现逻辑调试的时候没有问题,在线上运行 的时候出现了奇怪难以理解的变量值,也和这个有关。
    stack-area
    source

所以栈区从高地址向地地址拓展这个特性和栈操作模式本身没有关系,从低地址向高地址拓展也可以实现栈的访问模式,我猜可能和 push/pop 这类命令对于内存的操作有关系,push 时 esp 寄存器值递减,pop 时 esp 寄存器值递增。

堆区

堆区也是一个线性区间,为编程人员提供大块的连续内存区间,其实也可以是小块的。堆内存的作用域是进程级别的,栈内存是函数作用域级别的。所以编写人员把变量定义在堆区的主要目的是能跨函数作用域访问和满足大块连续区间 的需求,比如 vector 大连续内存数组。

从这里我们可以看到,堆区和栈区只是一个抽象概念上的区别,抽象上区分了不同线性区的功能,因为使用习惯的不同导致了其中的数据具有不同的作用域和特征。但是在实现上,都是一个区间,具体的物理内存都是通过进程共用的 页表进行转换。

共享内存区和文件映射区

  • 共享内存区:操作系统实现进程通信的一个原语。我们知道每个进程逻辑上表示一个独立的操作实体,比如一个人。人与人之间要交换信息,进程也一样,所以操作系统需要定义一些进程间交换信息的方式,这个我们后面将 CPU 篇 会具体讲,这里只提及共享内存区。它的具体意思是,把同一个物理页框映射到不同进程的线性区间中,这样进程 A 往自己的线性区间中读写数据时,其他进程也能看到变更,类似设计中的 model-view 模式。物理页表是 model, 不同的进程的线性区间是 view。当然,也需要进程级别的同步机制保证不同进程有序操作共享内存的相同数据。 shared-area source
  • 文件映射区:操作系统提供了操作文件的系统调用,比如常见的 read 和 write,这里的路径大致是用户态缓存 -> 内核缓存(文件页缓存) -> 磁盘缓存。为了缩短其中的交互路径,其中一个思路就是文件映射,把用于页缓存的物理 页直接映射到用户态的线性区,这样就可以向读写内存一样读写文件,也不用再进行频繁的系统调用。

总结

有了以上的铺垫,我们再尝试回答一开始提出的 3 个问题作为结尾。

  1. 进程是一个独立执行流相关的资源集合,包括硬件上下文,用户代码,内核页表等信息。
  2. 进程作为资源集合,在内核中的表示形式是 task struct, 由内核维护状态,存在于物理内存中。
  3. 进程地址空间是内核为进程提供的连续可用内存空间的抽象,通过这种方式隔离与物理内存相关的细节。而为了区分进程对于不同类型不同功能内存,进一步在进程线性地址空间中抽象出线性区的概念,比如堆区和栈区。 不同线性区在使用方式上有区别,但是在实现上是一样的,都是通过进程页表进行对应物理内存地址的寻址。