1 - Hand on system programming with Linux - 虚拟内存

虚拟内存

我的博客

虚拟内存

现代操作系统基于称作虚拟内存的内存模型。这些操作系统包括 Linux、UNIX、MS Windows 以及 macOS。要想真正理解现代操作系统,必须深入理解虚拟内存以及内存管理。

没有虚拟内存会有什么问题

现在,让我们考虑一个只具有 64 MB RAM 物理内存的情况。在老的操作系统,包括现代的实时操作系统是以这样的方式工作的,现在一些实时操作系统依然以这种方式运行。

显然,这个设备上运行的所有的内容,包括操作系统、设备驱动、库以及应用,都要共享这个物理内存空间。我们可以用一个例子可视化这个情景,在这个例子中,由操作系统,少量驱动,一些库,以及两个应用组成一个系统。这一整个系统的物理内存映射可能如下:

对象 空间大小 地址范围
操作系统 3 MB 0x03d0 0000 - 0x0400 0000
设备驱动 5 MB 0x02d0 0000 - 0x0320 0000
10 MB 0x00a0 0000 - 0x0140 0000
应用二 1 MB 0x0010 0000 - 0x0020 0000
应用一 0.5 MB 0x0000 0000 - 0x0008 0000
剩余内存 44.5 MB 范围变化

上面的内存可以使用下面的形式表示:

内存空间
操作系统
-
设备驱动
-
-
应用二
-
应用一

一般情况下,系统在发布之前都会进行严格测试,防止有 bug 存在。

让我们假设在应用一中有一个非常严重的 bug,可能是因为无疑的编程错误,也可能是因为蓄意破坏,在使用 memcpy 时出现了严重问题。

/* memcpy 函数长这样: */
void *memcpy(void *dest, const void *src, size_t n);

我们本意是将源地址 300KB 内存位置的 1024 字节数据复制到目的地址 400KB 内存位置,假设物理地址的偏置从 0 开始(仅作假设,实际上并不会有这样的内存地址存在,因为这表示 NULL),下面是伪代码:

phy_offset = 0x0;
src = phy_offset + (300 * 1024);	/* 0x0004b000 */
dest = phy_offset + (400 * 1024);	/* 0x00064000 */
n = 1024;
memcpy(dest, src, n);

但是在出 bug 的版本上实际却做了下面伪代码描述的操作:

phy_offset = 0x0;
src = phy_offset + (300 * 1024);	/* 0x0004b000 */
dest = phy_offset + (400 * 1024 * 156);	/* 0x03cf0000 */
n = 1024*64;
memcpy(dest, src, n);

这将会侵占系统所在的内存空间。

我们在使用 C 语言开发,我们能够自由读写物理内存,因此出现这样的 bug 并不是语言的问题,而是我们自己的问题!

虚拟内存

虚拟内存总是被我们误解。本节我们会解释一些术语,比如内存金字塔、寻址以及页的含义。

什么是进程?一个进程是程序执行的实例,程序是一个二进制可执行文件,比如以 cat 程序:

$ ls -l /bin/cat
-rwxr-xr-x 1 root root 43416 Sep  5  2019 /bin/cat

当我们运行 cat 时,它成为运行时可被调度的实例,在 UNIX 宇宙中,我们称它为进程。

我们以一个简单的模拟模型进行后续的讨论。假设一个微处理器具有 16 位地址线,也就是说,它的总寻址空间为 64 KB。假设设备实际具有的内存比较少,只有 32 KB。

我们只有 32 KB 的物理内存,但是每一个进程依然会希望可以有 64KB 的内存空间,这可能有点荒谬。实际上,我们必须意识到,内存并不仅仅是由 RAM 构成的,真实的内存是以 cpu cache、RAM、Swap 空间这样的金字塔形构成的。

在实际生活中一切都是折衷的。在金字塔的顶端,我们速度是超高的,但是价格是昂贵的,因此长度是很小的;而在金字塔的底端,长度是很长的,价格是低廉的,但是速度却是缓慢的。我们可以将 CPU 的寄存器放在金字塔的顶端,在 cpu cache 之上。

在上面的描述中,Swap 是一种文件系统类型,一个盘的分区在系统安装时做为一个交换空间。它被操作系统视作是第二层级的 RAM,在操作系统运行超过 RAM 范围时,就会使用交换空间。系统管理员有时候将交换空间配置为可用 RAM 空间的两倍。

为了方便量化,我们可以参考 Computer Architecture, A Quantitative Approach, 5th Ed 中列出的内容:

类型 CPU 寄存器 L1 cache L2 cache L3 cache RAM Swap/存储
服务器 1000 字节 64 KB 256 KB 2 - 4 MB 4 - 16 GB 4 - 16 TB
服务器 300 皮秒 1 纳秒 3 - 10 纳秒 10 - 20 纳秒 50 - 100 纳秒 5 - 10 毫秒
嵌入式 500 字节 64 KB 256 KB - 256 - 512 MB 4 - 8 GB 闪存
嵌入式 500 皮秒 2 纳秒 10 - 20 纳秒 - 50 - 100 纳秒 25 - 50 微妙

一些嵌入式 Linux 系统不支持交换分区,因为嵌入式大部分使用闪存做为第二个存储媒介,写闪存会对它造成磨损,因此嵌入式设计者会做出牺牲,使用 RAM 来代替交换空间。不过嵌入式系统依旧可以是基于虚拟内存的。

后面介绍的内容都是概念性的内容,现实实现可能有很大不同。

寻址方式一 - 最简单的方式,平坦映射

假设每一个活跃的进程都会占用整个可用的虚拟内存空间 (VAS: Virtual address space)。因此每一个进程都会覆盖其他的进程空间。这还能工作吗?直观概念上是不行的。为了让这一策略能够工作,系统必须将每一个虚拟地址映射到物理地址:

进程 P:虚拟地址 -> RAM:物理地址

进程 P1,P2,Pn 活跃于虚拟内存中。它们的虚拟地址空间覆盖 0 - 64KB 互相覆盖。在这个虚拟的系统上的物理内存空间是 32 KB 的 RAM。

做为例子,两个进程的虚拟地址使用下面的方式展示:

P'r':va'n',其中 r 是进程编号,n 是 1 与 2。

现在就是将每一个进程的虚拟地址映射到物理地址了,因此我们需要下面的映射:

P1:va1 -> P1:pa1

P1:va2 -> P1:pa2

...

P2:va1 -> P1:pa1

P2:va2 -> P2:pa2

我们需要操作系统来帮助完成这个映射,操作系统需要为每一个进程维护一个映射表。这看起来好像很简单,实际上它并不能正常工作。为了将进程的虚拟地址映射到 RAM 的物理地址中,操作系统需要维护每一个虚拟地址转物理地址的 va-to-pa 的转换表,这样的代价是昂贵的,因为每一个表格都超过了物理内存的大小。

让我们使用一个简单的计算来展示一下这样的情景,我们具有 64KB 的虚拟内存,既 65536 字节的寻址空间,每一个虚拟地址需要一个转换为物理地址的映射,因此每一个进程需要 \(65536*2 = 131072 = 128 KB\) 的映射表,而实际上,操作系统还需要为每一个地址映射条目存储一些元数据,假设每一个元数据都是 8 个字节,现在每一个进程需要 \(65536*2*8=1048576=1MB\) 的映射表。

当然,我们可以不映射每一个字节,而只映射字,如果四个字节为一个字,那么需要 \(65536*2=262144=256KB\) 的映射表。

寻址方式二 - 页

为了应对这个问题,计算机科学家提出一种解决方案,不单独将虚拟地址映射到它们对应的物理地址,这样代价太高了。而是将物理地址以及虚拟内存空间划分为块进行映射。

一般有两种方法实现:

  • 硬件分段
  • 硬件分页

硬件分段:将虚拟内存以及物理地址空间划分为任意大小称为段的块,最好的例子就是 Intel 32 位处理器

硬件分页:将虚拟内存以及物理地址空间划分位等大的称作页的块,大部分处理器支持硬件分页,包括 Intel、ARM、PPC 以及 MIPS

实际上,使用上面两个策略的哪一种,并不是操作系统开发者决定的,而是由硬件 MMU 决定的。

下面,让我们假设使用页表策略,我们会映射虚拟页到物理页帧。这里,有虚拟地址 VAS: virtual address space,在进程中的虚拟地址虚拟页 page,在 RAM 中的物理页帧 page frame。一般情况下,每一个页是 4KB 大小。需要再次声明,MMU 决定具体页大小是多少。

回到我们的问题,我们需要 64KB 的虚拟内存,可划分为 16 个页;实际有 32KB 的物理内存,可以划分为 8 个页帧。管理 16 个页与 8 个页帧的对应关系,每一个进程只需要 16 个条目,\(16*2*8=256\) 字节就可以完成映射。

操作系统为每一个进程维护一个映射表,因此每一个进程在运行时拥有自己独有的将页映射为页帧的映射表。这个表通常称作页表。

简化版的页表

在我们的例子中,我们有 64KB 的虚拟内存,既 16 个页,有 32KB 的 RAM,既 8 个页帧。将 16 个虚拟页映射到对应的物理页帧,每一个进程就需要有 16 个映射条目,由操作系统创建的页表,看起来将类似下面这样:

虚拟页 物理页帧
0 3
1 2
2 5
... ...
15 6

我们马上就能发现一个问题:我们有 16 个页,而只能映射到 8 个页帧,那么剩下的 8 个页怎么办呢,考虑下面的方案:

  • 进程只使用可用的内存,剩下的虚拟内存保持空闲
  • 内存不够用时,使用交换空间

使用交换空间会有下面的页表:

虚拟页 物理页帧
0 3
1 2
2 5
... ...
13 交换地址
14 交换地址
15 6

需要注意的是,本问介绍的所有内容都只停留在概念层面,实际实现更加复杂并与CPU/MMU架构强相关

间接关系

通过引入页寻址,我们实际上引入了一个间接关系,我们不再将虚拟地址视作是从零开始的一个绝对偏置,而是将它看作是 va = (page,offset) 这样的组合。我们将所有虚拟地址都视作是页号与这个页的偏置组合,使用了这个间接关系。因此,进程的虚拟地址,需要使用这个进程的页表转化为对应的物理地址。

地址转换

在运行时,当进程看到虚拟内存是从 0 开始的 9192 字节的偏置,它的虚拟地址是 va = 9192 = 0x000023E8,如果每一个页有 4096 字节,这意味着这个虚拟地址在第三页 (page #2),并在这一页 1000 字节偏置的位置。因此通过间接关系,我们可以得到 va = (page, offset) = (2, 1000)

我们得到这个转换关系,现在,我们可以看一下地址转换是怎样工作的:操作系统看到这个进程需要 page 2 的地址,它查询这个进程的页表,找到对应的页帧为 5,计算物理地址为:

pa = (pf * PAGE_SIZE) + offset
   = (5 * 4096) + 1000
   = 21480 = 0x000053E8

系统现在就可以将换算到的地址由总线发出了,实际上,并不是操作系统执行地址转换的计算。这是因为在软件中执行计算太慢了(需要牢记,地址转换几乎每时每刻都在发生)。这是通过 CPU 中的 MMU 子模块执行的转换操作。下面是需要注意的:

  • 操作系统负责为每一个进程创建并维护页表
  • MMU 负责在运行时使用操作系统页表进行地址转换
  • 现代硬件支持硬件加速,比如使用 TLB,使用 CPU Cache,以及虚拟化扩展,可以得到可观的性能

使用虚拟内存的好处

第一眼看虚拟内存,可能会想它需要进行地址转换,代价会很高,但实际上:

  • 现代硬件加速(通过 TLB/CPU Cache/预取) 具有很高的性能
  • 使用虚拟内存得到的好处,也可以让我们可以为其付出一些代价

在基于虚拟内存的系统上,我们可以得到下面的好处:

  • 进程隔离
  • 程序员不需要担心物理内存
  • 内存区域保护

进程隔离

当使用虚拟内存时,每一个进程都运行在一个沙盒中,这是虚拟内存的扩展,遵循一个关键规则,它不能看到沙盒外部。对于一个进程而言,它不能够窥探或偷偷修改其他进程的虚拟内存,这能够帮助构建安全稳定的系统。

比如,我们有两个进程 A 与 B,进程 A 想要改写进程 B 的虚拟内存 0x10ea 位置的内容,A 不能做到,当它尝试写 0x10ea 地址时,实际上写的是自己虚拟内存对应的位置,读也类似。

这样,我们就得到了进程隔离,每一个进程都是彼此隔离的。进程 A 的虚拟地址 X 与进程 B 的虚拟地址 X 是不同的,它们将会被各自的页表转换到不同的物理地址。

程序员不需要担心物理内存

在老的操作系统中,甚至是现代的实时操作系统中,程序员都需要了解整个系统的内存布局,并谨慎使用内存,显然,这将会对开发工作带来很大的负担。

而在现代操作系统的开发者,则不需要担心这些问题,如果我们需要 512Kb 的内存,我们只需要进行动态分配就可以了,将实现的细节交由操作系统实现。而不需要担心是否有足够的 RAM 空间,这个分配将会使用哪一个页帧等问题。

内存区域保护

虚拟内存最重要的优点可能就在于,操作系统以此定义对虚拟内存的保护,在类 UNIX 系统中,允许对内存页的四种保护权限:

保护类型 含义
无权限 对该页没有任何权限
可读 可以读该页
可写 可以写改写
可执行 页(代码)可以被执行

让我们考虑一个小例子,我们给我们的进程分配了四页内存(0 - 3),默认情况下,页的保护权限是RW(可读可写),在操作系统级的虚拟内存支持下,操作系统提供 API (mmap(2) 以及 mprotect(2) 系统调用),使用这样的系统调用,用户可以修改默认的页保护权限,使用这样的 API,我们可以改写单个页的内存保护模式。

应用(实际上是操作系统)能够实现这种强大的功能。我们可以设置特定的页某种保护模式,但是如何一个应用试图违背这个规则呢?比如在设置第 3 页为只读权限后,应用或操作系统尝试写这个页呢?在试能虚拟内存的系统上,MMU 能够确定每一次的内存访问是否违背了规则,如果遵守了规则,那么能够成功访问;如果违背了规则,那么 MMU 硬件将会置起一个异常(与中断类似但不同)。操作系统跳转到异常处理的程序中去执行。操作系统异常处理程序来判断这次访问是否真的违背了规则,如果确实违背了规则,那么操作系统将立刻杀死这个尝试进行非法访问的进程。

测试 C 程序 memcpy()

现在我们已经初步了解了什么是虚拟内存系统,让我们看一个伪代码例子,在这个例子中,我们尝试复制一些内存到一个错误的目的地址(在我们只有物理内存的系统上,这样的操作会覆盖操作系统自身)。但是这次这个类似的 C 程序运行在具有虚拟内存的操作系统 Linux 上,让我们看一下这个程序:

/* mem_app1buggy.c */
#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char *argv[])
{
    void *ptr = NULL;
    void *dest, *src = "abcdef0123456789";
    void *arbit_addr = (void *)0xffffffffff601000;
    int n = strlen(src);
    
    ptr = malloc(256 * 1024);
    if(!ptr)
        	printf("malloc(256*1024) failed\r\n");
    
    if(1 == argc)
        dest = ptr;			/* correct */
    else
        dest = arbit_addr;	/* bug! */
    
    memcpy(dest, src, n);
    free(ptr);
    
    exit(0);
}

在这个程序中,我们设置目的地址为一个无效的虚拟地址。

arv@arv:~$ gcc test.c
arv@arv:~$ ./a.out test
Segmentation fault (core dumped)

当我们尝试以具有 bug 的方式运行这个程序时,就像前面描述的那样,这样的复制造成了 MMU 错误,操作系统的故障处理代码意识到这是一个 bug,并杀死了这个问题进程。在这个例子里,进程死掉了,而不是我们的操作系统,操作系统不仅处理了这个问题,并向开发者做了提醒,程序有问题需要被修复。系统是通过进程的页表项确定某个区域是否可以由其访问,并在每一次访问时由 MMU 检查。通过软硬件配合工作,实现了这样的机制。

进程内存布局

一个进程是正在执行程序的实例,它由操作系统调度。操作系统,或者说内核,将与进程有关的元数据存储到内核内存的一个数据结构中。在 Linux 中,这个结构体通常被称作进程描述符 process descriptor,任务结构 task structure 可能是更精确的描述。进程属性被存储在任务结构中,进程 ID PID: process identifier 是这个进程独占的整数,这个进程的凭证、打开的文件信息、信号量信息等都在这个结构体中。

前面讲到虚拟内存,我们可以说一个进程除了其他属性,它还具有虚拟地址系统,虚拟地址系统是它能够访问的潜在空间的总和。在我们前面的例子中,有地址总线有 16 根,那么每一个进程的虚拟地址系统将会是 64KB。

现在,让我们考虑一个更加贴近现实的系统:在一个 32 位的 CPU,它的地址总线有 32 根,那么每一个进程的虚拟地址系统将会有 4GB 0x100000000;因此,它的寻址范围是 0 - 0xffff ffff。现在开始,我们不考虑其他复杂情况。

映射或段

当一个新的进程被创建时,它的虚拟地址系统由操作系统设置。所有现代操作系统将虚拟地址系统划分为段,不同的段具有不同的同质化内容。一个段中具有进程的同质化内容,它由虚拟页组成。段具有很多属性,比如起始地址、结束地址,保护属性,映射类型等。所有属于同一个段的页享有相同的属性。

技术上将,从操作系统的视角看,段可以被称为映射。从现在开始,当我们讲到段时,就表示映射,反之亦然。

简单来说,从低地址到高地址,每一个 Linux 进程具有下面的段(或映射):

  • 代码段(Text/Code)
  • 数据段(Data)
  • 库(Library)
  • 栈(Stack)
从高地址开始依次向下
栈(可读可写-) (向下生长)
...
...
堆(数据段) (向上生长)(可读可写-)
未初始化数据(数据段)
初始化的数据(数据段)
代码段(可读-可执行)
低地址 0x0

代码段

代码段就是代码,操作码与操作符组成的机器指令喂给 CPU 去执行,在虚拟地址系统中机器码所在的段为代码段 Text。比如,我们有一个程序具有 32KB 的代码段,当我们运行它时,它成为一个进程,代码段占用 32KB 的虚拟内存,32KB/4KB = 8 页。

为了优化并保护代码段,操作系统将代码段所在的 8 个页标记为可读可执行,代码被从内存中读出并由 CPU 执行,但不会被修改。

在 Linux 中,代码段总是在进程最低的地址位置。注意到它不会从 0 地址开始。一个典型的例子,在 IA-32 设备上,段通常从 0x08048000 开始。代码段开始的位置与架构相关。

数据段

紧挨着代码段的就是数据段,这里存储着程序的全局与静态变量。实际上,这里并不只是一个段,数据段由三个不同的映射组成。从这个段的最低地址开始,依次存放着经初始化的数据段,未初始化的数据段,堆空间段。

我们理解在 C 程序中,未初始化的全局与静态变量自动会被初始化为 0。显式初始化的全局与静态变量存储在这个初始化的数据段。

未初始化的全局变量与静态变量存储在未初始化的数据段中。需要注意的是,这些变量被隐式初始化为 0。在老的书本中,这一个段被称作是 BSS 段。BSS: Block Started by Symbol 是一个老的汇编指令,这个指令可以被忽略。今天,BSS 区域只表示进程虚拟地址系统未被初始化的数据段。

大部分 C 开发者都熟悉堆空间,这个区域用作进行动态内存分配。

需要注意的一点是,代码段、经初始化的数据段、未初始化的数据段,长度都是固定的。堆空间是一个动态段,它可以随运行时需求伸缩。同时需要注意的一点是,堆段向上(高虚拟地址)生长。

当链接程序时,我们有两种选择:

  • 静态链接
  • 动态链接

静态链接表示所有的库代码与数据都将保存到生成的二进制可执行文件中(这样显然会使文件变得很大,但是执行会更快)。

动态链接表示它使用共享的库与数据,并不会将这部分共享内容合进最终的二进制可执行文件中,库被所有的进程共享,并在运行时被映射到虚拟地址系统中(因此二进制可执行文件会更小,但是加载库会令执行过程变慢)。动态链接是默认的。

考虑 hello world C 程序。我们会调用 printf() 函数,我们是否有自己实现 printf 函数呢?显然没有。我们都清除,这个函数在 glibc 库中,并会在运行时链接进入到我们的进程中。这也是动态链接中发生的事。

另一个需要注意的事:我们可能发现有其他到这个空间的映射(除了库代码与数据),一个典型的案例就是,由开发者进行的显式内存映射(使用 mmap 系统调用);还有隐式的映射如由进程间通讯(共享内存)等。

什么是栈

我们应该清楚,栈只是一种具有特殊 push/pop 用法的内存,我们将新的内容放到栈的顶部,如果执行一个 pop 操作,我们得到这个内容,并从栈顶移除。

为什么进程需要栈

我们被教导要写模块化的代码,将我们的工作划分为子程序,并将它们实现为小的、容易理解、容易维护的 C 函数。CPU 并不真正理解如何去调用 C 函数,如何传参、存储本地变量、以及返回结果到调用函数。我们伟大的编译器将 C 代码翻译成汇编码实现这个过程。

编译器生成汇编码,进行函数调用,传递参数,为局部变量分配空间,最后将返回值返回给调用者。为实现这些操作,需要用到栈。因此与堆类似,栈也是个动态的段。每次进行函数调用时,都会在栈空间分配内存,用来存储函数调用的元数据,进行参数传递并进行函数返回。每一个函数的元数据区域被称作栈帧。栈帧的结构与 CPU 以及编译器强相关,在 CPU ABI 文档中可以找到相关介绍。

IA-32 处理器中,栈帧结构如下:

栈帧
高地址
函数参数
RET 地址
保存的帧指针
局部变量
栈指针(SP):最低地址

如果一个处理器进行依次 push 或 pop 指令,它是怎么知道栈在哪里呢?答案是,我们使用一个特殊的 CPU 寄存器栈指针寄存器 SP,SP 总是指向栈顶。

另一个需要注意的点是,栈向虚拟地址的下面生长。需要注意的是,栈生长的方向也是与 CPU 的 ABI 强相关的,大部分现代的 CPU (包括 Intel,ARM,PPC,Alpha,Sun SPARC 等) 都遵从栈向下生长。

仔细看一下栈

我们可以使用不同的方式查看进程的栈,比如:

  • 通过 gstack 工具查看
  • 使用 GDB 调试器手动查看

一些 ubuntu 用户可能不能使用 gstack 工具。这里不再展示。

下面简单的 C 程序,进行了一些嵌套调用,pause 系统调用是一个阻塞调用,它使调用这个函数的进程进入睡眠状态,等待一个事件,比如一个给到进程的信号量,有是源码 stacker_dbg.c

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

static void bar_is_now_closed(void)
{
        printf("In function %s\r\n"
                        "\t(bye, pl go '~/' now).\r\n", __FUNCTION__);

        printf("\r\n Now blocking on pause()...\r\n"
                        " Connect via GDB's 'attach' and then issue the 'bt' command"
                        " to view the process stack\r\n");
        pause();
}

static void bar(void)
{
        printf("In function %s\r\n", __FUNCTION__);
        bar_is_now_closed();
}

static void foo(void)
{
        printf("In function %s\r\n", __FUNCTION__);
        bar();
}

int main(int argc, char *argv[])
{
        printf("In function %s\r\n", __FUNCTION__);
        foo();

        exit(EXIT_SUCCESS);
}

如果希望使用 GDB 查看符号,我们必须使用 -g 选项编译源码,使用下面的命令编译源码:

gcc -o stacker_gdb -g stacker_gdb.c

使用下面的命令让程序在后台运行,同时获知到进程 PID

arv@arv:~/xudong/prog/1-stack-gdb$ ./stacker_gdb &
[1] 92557
arv@arv:~/xudong/prog/1-stack-gdb$ In function main
In function foo
In function bar
In function bar_is_now_closed
        (bye, pl go '~/' now).

 Now blocking on pause()...
 Connect via GDB's 'attach' and then issue the 'bt' command to view the process stack

在 ubuntu 中,出于安全考虑,GDB 不允许普通用户链接到某个进程,因此需要管理员权限,使用下面的命令调试,查看栈信息:

root@arv:/home/arv/xudong/prog/1-stack-gdb# gdb --quiet
(gdb) attach 92557
Attaching to process 92557
Reading symbols from /home/arv/xudong/prog/1-stack-gdb/stacker_gdb...
Reading symbols from /lib/x86_64-linux-gnu/libc.so.6...
Reading symbols from /usr/lib/debug/.build-id/18/78e6b475720c7c51969e69ab2d276fae6d1dee.debug...
Reading symbols from /lib64/ld-linux-x86-64.so.2...
Reading symbols from /usr/lib/debug/.build-id/45/87364908de169dec62ffa538170118c1c3a078.debug...
0x00007f5e52013e47 in __libc_pause () at ../sysdeps/unix/sysv/linux/pause.c:29
29      ../sysdeps/unix/sysv/linux/pause.c: No such file or directory.
(gdb) bt
#0  0x00007f5e52013e47 in __libc_pause () at ../sysdeps/unix/sysv/linux/pause.c:29
#1  0x000055be1e32d1da in bar_is_now_closed () at stacker_gdb.c:13
#2  0x000055be1e32d202 in bar () at stacker_gdb.c:19
#3  0x000055be1e32d22a in foo () at stacker_gdb.c:25
#4  0x000055be1e32d25d in main (argc=1, argv=0x7fff26d38fc8) at stacker_gdb.c:31

高级虚拟内存划分

前面我们介绍的内容并不是全部的内容,实际上,这个地址空间需要在用户空间与内核空间之间共享。回想我们在前面介绍库段时,如果希望我们的 Hello, world 能够工作,需要能够映射到 printf 库程序。这是通过在运行时,将动态或共享库内存映射到进程虚拟地址空间实现的(由加载程序实现)。

在第一章中,我们知道,系统调用代码实际上是位于内核地址空间中。因此,如果成功进行一次系统调用,我们需要将 CPU 的指令指针 IP 重定向到系统调用代码的地址,这个地址位于内核地址空间。我们之前提到过,虚拟地址空间是一个沙盒,进程不能够看到盒子外的内容。那么它又是怎么使用库函数的呢?为了令整个策略能够成功,即便是内核虚拟地址空间,也能够映射进入到进程的虚拟地址系统中。

我们前面看到的,在 32 位系统上,一个进程能够使用的虚拟地址系统总共是 4GB 空间。那么是否就意味着栈空间就位于 4GB 的顶部呢?实际情况是,操作系统创建进程虚拟地址空间,并为其准备其内部的段空间,但它会保留顶部一部分的虚拟内存,留做内核或操作系统映射(内核代码,数据结构,栈与驱动)。这个包含内核代码与数据段的段,通常称作内核段。

为内核段准备多少虚拟内存空间,是可以由内核开发者(或系统管理员)配置的。

posted @ 2023-05-06 23:24  ArvinDu  阅读(116)  评论(0编辑  收藏  举报