深入浅出OS的内存管理
聊聊OS中的内存管理,不侧重原理,侧重实现。
1. 链接、装载
1.1 可执行文件格式
目前主流的可执行文件(Executable File)格式主要有:
- Linux的ELF(Executable Linkable Format)。
- Windows的PE(Portable Executable)。
编译器将源代码编译后得到的中间文件叫做目标文件。然后再由链接器将目标文件以及它们的所依赖的库链接起来,就得到了最终的可执行文件。目标文件与可执行文件的格式几乎是一样的,所以我们可以将它们看做是同一种类型的文件。
除了可执行文件和目标文件按照可执行文件的格式存储之外,动态链接库(DLL,Dynamic Linking Library)和静态链接库文件也都按照可执行文件的格式存储。
下面的表格总结了这几种文件在Linux和Windows系统中常用的后缀名。
可执行文件 | 目标文件 | 动态链接库 | 静态链接库 | |
---|---|---|---|---|
Linux | .out或无后缀 | .o | .so | .a |
Windows | .exe | .obj | .dll | .lib |
ELF文件标准还将各种采用ELF格式的文件归为4类,如下表所示:
可重定位文件(Relocatable FIle) | 可执行文件(Executable FIle) | 共享目标文件(Shared Object File) | 核心转储文件(Core Dump File) | |
---|---|---|---|---|
说明 | 包含代码和数据,可被链接成可执行文件或共享目标文件 | 可直接执行的程序,一般没有后缀名 | 包含代码和数据。可以与其他的可重定位文件和共享目标文件链接;也可以与可执行文件结合(动态链接),作为进程映像的一部分运行 | 进程意外终止时,该文件用于存储该进程的地址空间的内容以及其他一些信息 |
实例 | Linux的.o;Windows的.obj | 如Linux上的/bin/bash;Windows上的.exe | Linux的.so;Windows的.dll | Linux上的CoreDump |
在Linux中,你可以使用file
命令来查看文件的格式。
1.2 目标文件的结构
总览
目标文件中包含的内容主要有程序编译后的机器指令代码和程序的数据。除此之外,目标文件中还包括一些链接时所需的信息,如符号表、调试信息、字符串等。所有的这些内容在目标文件中以节(Section)的形式存储,在链接器将它们链接之后,则以段(Segment)的形式存储。
程序编译后的机器指令通常放在代码段(Code Segment)中,代码段通常以.code或者.text命名。而全局变量和局部静态变量通常放在数据段(Data Segment)中,数据段的名称一般为.data。
节(Section)和段(Segment)有什么区别?
节(Section)是编译后,链接前的概念,存在于目标文件中,包含了链接器所需的信息;段(Segment)是链接后的概念,存在于可执行文件中,包含了OS所需的信息。
要真正理解它们的区别,我们需要明白节头(Section Header)中的字段和程序头(Program Header)中的表项的含义,以及它们分别是如何被链接器和操作系统使用的。
它们所提供的一些主要信息如下。
- 节:告诉链接器
- 这一节的类型,是数据或是代码,比如
.data
,.text
等等。这些内容是会保留到运行时的。- 或者是关于其他节的一些格式化的元信息,这些信息将会被链接器用到,但是仅保留在链接阶段,而不会保留到程序运行时。例如
.symtab
,.srttab
,.rela.text
等等。经过链接器的链接,不同目标文件中的节被归类整理到最终可执行文件中的段内。
- 段:告诉操作系统
- 这个段应该被加载到虚拟地址空间中的哪个位置。
- 这个段所具有的的权限(可读,可写,可执行)。这些信息将可以帮助操作系统和处理器进行特权级检查,实现任务的保护。
一个段(Segment)中会包含多个节(Section)吗?
是的,链接器会将多个目标文件中的节放到它们在可执行文件中的对应段上。在Linux中,你还可以通过后缀为.ld的链接脚本指定链接器链接的规则,控制节如何放到段中。
通过执行
$ ld --verbose
命令你可以查看链接器的默认链接规则,你还可以通过-T
选项来指定自己的链接规则。比如下面的链接脚本:
.text :
{
*(.text.unlikely .text.*_unlikely .text.unlikely.*)
*(.text.exit .text.exit.*)
*(.text.startup .text.startup.*)
*(.text.hot .text.hot.*)
*(.text .stub .text.* .gnu.linkonce.t.*)
}就是告诉链接器将名为
.text.unlikely
,.text.*_unlikely
,.text.exit
等等的节放置到.text
段中。如何知道段中包含了哪些节?
一旦可执行文件链接完成,我们只能通过链接器存储的额外信息(是否保存这个信息对链接器来说是可选的)来获取节到段的映射。不过即是没有,Linux的
readelf
命令也可以自动计算这个映射。
实际上,节和段之间的界限不用划分得那么清楚,它们不过是同一种内容在程序编译的不同阶段中的表现形式罢了,所以在后面的内容中我们可能会混用节和段的称呼。因为段的称呼更常用,所以我们可能更多采用段的描述。
接下来通过一个简单的程序来大概了解一下编译后目标文件的结构。
int printf(const char* format, ... ); |
使用gcc以下命令生成目标文件。-c
选项表示仅编译而不链接。
$ gcc -c Section.c |
可以通过file
命令查看一下目标文件的信息:
file Section.o |
可以看到,这是个x86-64架构的小端序(LSB)的可重定位文件(relocatable)。
接下来,使用objdump
命令可以查看该目标文件的结构和内容。参数-h
表示打印ELF文件的节头(Section Header)内容,-x
可以打印所有的头信息。
objdump -h Section.o |
可以看到,Section.o包含几个主要的节(段):
代码段(.text)。存储程序的代码。
- 数据段(.data)。存储已经初始化的全局变量和局部静态变量。
BSS段(.bss)。不占空间,仅用于为未初始化的全局变量和局部静态变量预留位置。
以及其他一些段:
- 只读数据段(.rodata)
- 注释信息段(.comment)
- 堆栈提示段(.note.GNU-stack)
- ...
objdump
命令为我们输出了这些段的主要信息,包括编号(Idx),名称(Name),大小(Size),虚拟内存地址(VMA),线性内存地址(LMA),所在位置(File offset),对齐方式(Align)。
size
命令可以查看ELF文件的代码段、数据段和BSS段的大小。
size Section.o
text data bss dec hex filename
221 8 4 233 e9 Section.odec表示个隔断长度的十进制和,hex表示十六进制和。
代码段
使用-s
参数可以objdump
命令将所有段的内容以十六进制的形式打印出来,-d
参数可以将所有包含指令的段反汇编。
在下列输出中,左边的一列就是各个段的十六进制数据。右边的一列是各个段的ASCII码形式。
可以看到,代码段位于位于整个文件的偏移量0x0
到0x60
之间,总大小为0x61
字节,和之前的Section Header的信息相吻合。除此之外,十六进制信息也与下面反汇编中的十六进制信息相吻合。
$ objdump -s -d Section.o |
数据段
数据段保存的是已经初始化的全局变量和局部静态变量。
只读数据段保存的是只读数据,比如这里源代码中的字符串%d\n
(%
的ASCII码是0x25
,d
的ASCII码是0x64
,换行符即\n
的ASCII码为0x0a
)。注意,C语言中的字符串都是以\0
(ASCII码为0x00
)结尾的。
... |
.data段中的0x13
和0x14
正是源代码中已经初始化的global_init_var
和static_init_var
的值,这里采用的是小端序存储。
BSS段
-s
选项并没有打印BSS段的内容,这也是正常的,因为BSS段在目标文件中并不真正占据空间,只有真正链接成可执行文件后链接器才会为它分配空间,所以它的信息此时实际上只能在符号表(Symbol Table)之中找到。-t
参数可以打印出符号表。
$ objdump -t Section.o |
可以看到,static_uninit_var
被放在了.bss段,而global_uninit_var
则使用一个COMMON符号*COM*
标记,表示未定义。
1.3 ELF文件的结构
ELF文件头
在Linux中,可以试用readelf
命令来查看ELF文件的结构。我们首先使用-h
参数查看一下文件头。
readelf -h Section.o |
从上到下分别是:
- 魔数。
- 头四个字节必须是
0x7f 45 4c 46
。0x7f
是ASCII码中的DEL控制符,0x 45 4c 46
正好对应ELF的ASCII码。所以这四个字节代表ELF文件的魔数。很多其它文件也有自己的魔数,如a.out形式的文件以0x01 07
开头,Java的class文件以0xCA FE BA BE
开头。 - 第五个字节表示机器字长,
0x01
对应32位,0x02
对应64位。 - 第六个字节表示字节序,
0x01
表示小端序。 - 第七个字节表示ELF文件的主版本号,一般是1。因为ELF文件的标准从1.2版本之后就没有再更新过了
- 头四个字节必须是
- ELF文件类型。这里是64位的ELF文件
- 数据存储方式。这里表示以2的补码形式存储,小端序。
- ELF文件版本。
- 操作系统环境。这里运行环境为类UNIX系统。
- ABI版本。
- ELF重定位类型。
- 硬件平台。
- 硬件平台版本。
- 程序入口地址。
- 程序头位置。
- 段表位置。
- ...
段表
objdump
命令只是显示了ELF文件中的关键段,而忽略了一些辅助性的段,如符号表、字符串表、重定位表、段名字符串表等。这里使用readelf
工具可以查看真正的段表结构,附加-S
参数。
readelf -S Section.o |
这里比较有用的信息是段的类型,以及各个段的地址、权限等,下标主要解释段的类型。
类型 | 含义 | 类型 | 含义 |
---|---|---|---|
NULL | 无效段 | DYNAMIC | 动态链接信息 |
PROGBITS | 程序段、代码段、数据段 | NOTE | 提示性信息 |
SYMTAB | 符号表 | NOBITS | 该段无内容,如BSS段 |
STRTAB | 字符串表 | REL | 重定位信息 |
RELA | 重定位表 | SHLIB | 保留 |
HASH | 符号表的哈希表 | DNYSYM | 动态链接的符号表 |
1.4 地址随机化技术
地址空间布局随机化(Address Space Layout Randomization,ASLR)是一种防止利用内存漏洞的安全技术。为了防止攻击者跳转到内存中某个被利用的特定函数所在的位置,ASLR随机排布进程关键数据区域的虚拟地址空间位置,包括可执行文件的基址以及堆栈、堆和库的位置。
ASLR在2005年被引入到Linux的内核 kernel 2.6.12 中,当然早在2004年就以patch的形式被引入。随着内存地址的随机化,使得响应的应用变得随机。这意味着同一应用多次执行所使用内存空间完全不同,也意味着简单的缓冲区溢出攻击无法达到目的。
位置无关可执行文件(Position Independent Executable)是完全由位置无关代码(PIC)构成的可执行二进制文件。现在主流的Linux发行版中基本都使用PIE二进制文件,这样就可以通过使用地址空间布局随机化,来防止攻击者根据某个漏洞在二进制文件代码段中的偏移量进行安全攻击。
Ubuntu在17.10版本之后在所有的架构中都默认启用了PIE。值得一提的是,现在在默认情况下,GCC也被配置为构建PIE二进制文件(默认开启了-pie
选项)。如果不想让GCC生成PIE文件,那么可以使用-no-pie
选项。
我们编写一个简单的死循环代码loop.c:
|
使用GCC 执行以下编译命令:
gcc loop.c -o loop.out |
然后使用readelf
查看loop.out的类型:
readelf -h loop.out |
可以看到,它的类型是共享目标文件,这就是说loop.out默认被GCC编译成为了PIE格式。实际上现在Linux系统所自带的可执行文件和库基本都是PIE格式的了,比如/bin/bash,/usr/lib/x86_64-linux-gnu/libc-2.31.so等等。
如果我们加上-no-pie
选项进行编译后再次查看,就会看到:
readelf -h loop.out |
此时GCC就会将loop.out编译为传统的可执行文件的形式。
1.5 ELF文件的加载
为了体会是否开启ASLR对虚拟地址的影响,我们可以对照一下它们被操作系统加载执行后所在的虚拟地址。首先查看一下loop.out的程序头,里面描述了loop.out应该如何被操作系统映射到进程的虚拟空间:
readelf -l loop.out |
上面的Program Headers中,我们只需要关注类型为LOAD的段即可,因为只有类型为LOAD的段才需要被映射到虚拟地址空间,其他的段只是提供一些辅助信息。下面的Section to Segment mapping则存储的是目标文件中的Section到可执行文件中的Segment的映射关系。
接下来我们先查看一下非PIE格式的loop.out文件的虚拟内存地址。执行以下命令让loop.out在后台运行:
./loop.out & |
该命令返回的是此时loop.out的进程ID。根据此ID,我们可以在/proc文件夹下查看该进程的相关信息。
cat /proc/931731/maps |
一个小细节,这里可以看出64位Linux系统的虚拟内存地址是48位的。
由于我们的函数中引入了stdlib的sleep
函数,所以输出信息除了loop.out的各个段的地址,还包括动态库的各个段的地址。上面输出信息的各个字段含义如下:
- 第一列是各个段(在Linux中又称为虚拟内存区域VMA)的虚拟地址范围。
- 第二列是各个段的权限,包括可读、可写、可执行、私有还是共享(p为私有,s为共享)。
- 第三列是偏移量,表示该段(VMA)在映像文件(Image File)中的偏移。
- 第四列表示映像文件所在设备的主设备号和次设备号。
- 第五列表示映像文件的Inode节点号(使用
stat
命令可以查看文件的Inode号等各种信息)。 - 第六列是映像文件的路径。
由于可执行文件在被操作系统加载时需要到映射到虚拟内存空间,所以可执行文件又被称为映像文件(Image FIle)。
在这里,我们只需关注loop.out本身的各个段的信息即可。可以看到loop.out的代码段被加载到了虚拟地址0x00400000
处。这与32位下的情况不同。在Linux中,代码段被映射的默认起始地址有两种情况:
- 当操作系统为32位时,代码段的默认起始地址为
0x08048000
。 - 当操作系统为64位时,代码段的默认起始地址为
0x00400000
。
在Linux中,可以通过ld
命令来查看当前的默认链接设置。
ld --verbose | grep SEGMENT_START |
当然,以上情况都是没有开启PIE的。现在我们使用默认的GCC配置,即开启PIE来编译loop.c,然后再查看它的地址空间布局:
55a81588e000-55a81588f000 r--p 00000000 fc:02 1827049 /home/ubuntu/demo/loop.out |
中间略去了动态库的信息。我们发现,此时不管是loop.out的地址空间还是栈的地址空间都发生了很大的变化,比如代码段的起始地址变为了0x55a81588e000
这就是开启了地址随机化的之后的效果。
想要查看当前操作系统的ASLR配置情况,有两种命令可供选择:
cat /proc/sys/kernel/randomize_va_space |
- 0 = 关闭
- 1 = 半随机。共享库、栈、mmap() 以及 VDSO 将被随机化。
- 2 = 全随机。除了1中所述,还有heap。
ASLR开启时,程序每次运行时动态库的加载地址都不同。使用ldd
命令可以观察到程序所依赖动态库加载的地址空间:
ldd loop.out |
在shell中,运行两次ldd
命令,即可对比出前后地址的不同之处,当然,ASLR开启时才会变化。
如果要关闭ASLR,有以下几种方法:
方法一:手动修改randomize_va_space文件。设置的值不同,linux内核加载程序的地址空间的策略就会不同。这里0代表关闭ASLR。
echo 0 > /proc/sys/kernel/randomize_va_space
。注意,这里是先进root权限,后执行。不要问为什么sudo echo 0 > /proc/sys/kernel/randomize_va_space
为什么会报错。方法二: 使用
sysctl
控制ASLR:sysctl -w kernel.randomize_va_space=0
。这是一种临时改变随机策略的方法,重启之后将恢复默认。如果需要永久保存配置,需要在配置文件/etc/sysctl.conf中增加这个选项。方法三: 使用
setarch
命令控制单个程序的随机化。如果你想历史关闭单个程序的ASLR,使用setarch
是很好的选择。setarch -R ./loop.out
。-R
参数代表关闭地址空间随机化。
ASLR与PIE的区别的区别在于,ASLR有一个模糊的值1
,既不是全开启也不是全关闭,而是部分关闭,那这部分到底是什么,很容易产生歧义。另外ASLR 不负责代码段以及数据段的随机化工作,这项工作由 PIE 负责。但是只有在开启 ASLR 之后,PIE 才会生效。
我们这里使用方法三,单独控制loop.out的随机化:
setarch -R ./loop.out & |
可以看到,此时代码段的起始地址变为了0x555555554000
。为啥是这个值?我这里的Linux内核版本是5.4,查看对应的源码/arch/x86/include/asm/elf.h:
/* |
即PIE代码段加载的起始位置是DEFAULT_MAP_WINDOW
的2/3,代码中的注释也说了,取2/3是为了让出低32位的地址空间。DEFAULT_MAP_WINDOW
的定义在arch/x86/include/asm/processor.h中:
/* 注:这里减去 PAGE_SIZE 是为了创造一个 gap 来保护内核空间 */ |
这个值也是TASK_SIZE_MAX
的值,即用户进程所能访问的最大地址空间。而(2^47 - 4096) / 3 * 2 = 0x1000000000000 / 3 = 0x555555554aaa
。而代码段又是按页对齐的,所以0x555555554aaa
再向下按页对齐就得到了0x555555554000
这个值。
2 Linux的地址空间布局
首先回忆虚拟地址空间这个概念,先根据字面意思进行解释:
- 它可以用来加载程序数据;
- 它对应着一段连续的内存地址,起始位置为0;
- 之所以说虚拟是因为这个地址被虚拟出来的, 不是真正物理内存的地址。
虚拟地址空间的大小也由操作系统决定,32位的操作系统虚拟地址空间的大小为2^32字节,也就是 4G,64位系统的操作系统虚拟地址空间大小为2^64字节,这是一个非常大的数。
下面我们的讨论都基于32位的操作系统。
2.1 虚拟地址空间的划分
始终记住,一个进程用到的虚拟地址都是由链接器和操作系统来决定的,实际用不了4G。操作系统是计算机上电后加载的第一个程序,它负责管理整个计算机的运行,包括管理用户程序。用户程序的加载运行,以及用户程序所具有的权限、所使用的内存等等全部由操作系统管理,操作系统拥有绝对的控制权。因此,从用户进程的视角来看,只有一个内核,而从内核的视角来看,则有多个用户进程,二者是多对一的关系。
操作系统负责将虚拟地址空间划分给用户,常见的划分方式有以下几种。
Linux内核采用3:1的划分方式,即用户进程使用0~3G的虚拟地址空间,对于每一个进程来说,高1G都是属于内核的虚拟地址空间。内核使用是3G以上的1G虚拟地址空间,其中有896M是由内核提前设置好页表,直接映射到物理地址的0-896M,剩下的位于3G+896M以上的128M虚拟内存则留给操作系统按需映射,即所谓高位内存(High Memory)。
因此我们要分清”可以寻址”和“可以使用”的区别。
其实我们所说的的每个进程都有4G虚拟地址空间,都说的都是“可以寻址”4G,意思是虚拟地址的0-4G对于处于用户态的进程和内核来说是可以寻址到的,而3-4G是只有在内核态的情况下才可以访问的。所以用户进程并不能用满0-4G的虚拟地址空间。
如上图,虚拟地址空间中用户区地址范围是 0~3G,里边分为多个部分(和我们前述的链接过程的分段一致):
保留区:位于虚拟地址空间的最底部,未赋予物理地址。任何对它的引用都是非法的,程序中的空指针(NULL)指向的就是这块内存地址。
.text段:代码段也称正文段或文本段,用于存放程序的执行代码 (即 CPU 执行的机器指令),代码段一般情况下是只读的,这是对执行代码的一种保护机制。
.data段:数据段通常用于存放程序中已初始化且初值不为 0 的全局变量和静态变量。数据段属于静态内存分配 (静态存储区),可读可写。
.bss段:未初始化以及初始为0的全局变量和静态变量,操作系统会将这些未初始化的变量初始化为0。
堆(heap):用于存放进程运行时动态分配的内存。
- 堆中内容是匿名的,不能按名字直接访问,只能通过指针间接访问。
- 堆向高地址扩展(即 “向上生长”),是不连续的内存区域。这是由于系统用链表来存储空闲内存地址,自然不连续,而链表从低地址向高地址遍历。
内存映射区(mmap):作为内存映射区加载磁盘文件,或者加载程序运作过程中需要调用的动态库。
栈(stack):存储函数内部声明的非静态局部变量,函数参数,函数返回地址等信息,栈内存由编译器自动分配释放。栈和堆相向而生,地址”向下生长”,分配的内存是连续的。
还有图中未画出的:
- 命令行参数:存储进程执行的时候传递给
main()
函数的参数,如argc
,argv []
,env[]
等。 - 环境变量: 存储和进行相关的环境变量,比如:工作路径,进程所有者等信息。
2.2 高位内存
要理解High Memory是什么,就要知道它的提出是为了解决解决什么问题。
回忆一下内核地址转换的方式。在内核中我们往往要频繁地进行虚拟/物理地址相关的操作,在这种情况下,快速高效的虚拟地址到物理地址的转换就很重要。可如果按照多级页表去查找,开销就比较大,因此一种简单的”固定映射“的思路是:将0xC0000000-0xFFFFFFFF
的虚拟地址直接映射到0x00000000-0x3FFFFFFF
,也就是将最高的1G地址全部映射到最低的1G,这样虚拟地址与物理地址之间就有固定的3G偏移,每当遇到一个内核中的符号,我们需要得到其物理地址时,直接减去3G即可。
上述这种简单粗暴的处理方式很方便理解,效率也比较高(只需要简单的减法操作),但也有自己的局限性。在32位处理器下,按照Linux经典用户态与内核3:1的划分比例,内核能够使用的虚拟地址只有1G大。按照这种固定的映射方式,这意味着内核能够使用的物理地址大小也只有1G,而且范围也定死在了0-1G。
因此通俗地讲,High Memory的提出要解决的是32位下虚拟地址空间不足带来的问题(而显然,对64位系统这个问题就不存在了)。实际上在很早以前这个问题就讨论过了,在当时已经有一些临时的方法去规避这个问题,比如重新划分用户/内核的地址空间比例,变为2.5:1.5等等,但在特定场景下(比如用户态使用的内存非常非常多)会使得用户态运行效率降低,同时带来一些非对其问题,因此也不是一个很好的办法。
因此我们可以把内核的1G虚拟地址空间,划分成两部分,一部分用来固定映射,一部分用来动态映射。以x86为例,实际中的做法是,0xC0000000-0xF7FFFFFF
的896MB用作固定映射,0xF8000000-0xFFFFFFFF
的128MB用作动态映射。即前者仍然对应于物理地址的0x00000000-0x37FFFFFF
(只不过部分要优先分配给DMA);后者就是所谓的High Memory。当然,High Memory也有自己的缺点,就是效率比较低(既然是动态的,就绕不开重映射、pte操作等等)。
具体实现上,Linux将内核地址空间划分为三部分:
- ZONE_DMA
- ZONE_NORMAL
- ZONE_HIGHMEM
前面我们解释了高端内存的由来。当内核想访问高于896MB物理地址内存时,从0xF8000000 ~ 0xFFFFFFFF地址空间范围内找一段相应大小空闲的逻辑地址空间,借用一会。借用这段逻辑地址空间,建立映射到想访问的那段物理内存(即填充内核页表),临时用一会,用完后归还。这样别人也可以借用这段地址空间访问其他物理内存,实现了使用有限的地址空间,访问所有所有物理内存。
3. 内存管理策略
3.1 连续式内存分配
3.2 非连续式内存分配:段式寻址
多段模型
多段模型到底是什么?当我们使用汇编语言编写程序时,我们要自己定义好数据段,代码段,堆栈段之类的东西,这就定义了多个段,而当我们使用它们的时候(就像使用栈,都要指定栈基址,再根据需要给sp即栈顶指针寄存器赋予相应的值,才能对数据进行正确的操作)。多个段可能在不同的内存分段中存在,大小不一,所以才需要进行相应的跳转访存。
如下图所示,每个段都有自己的段描述符,里面包括了该段的访问权限(用于提供任务的特权级保护)、段基址和段界限。在保护模式下,段寄存器中存储的是16位的段选择子,即段描述符在段描述符表中的索引。
像汇编这种低级语言,我们可以允许程序员对程序进行分段,能灵活编排布局,属于人为将程序分成段,这就是采用分段模型编程。(程序的分段是逻辑上的划分)
程序分段的好处:
- 程序分段能赋予程序段不同属性,按此执行不同的安全策略(代码段read only之类的);
- 能提高cpu缓存命中,按照局部性原理,在分段的基础下对不同的段采取不同策略;
- 还能节省内存,例如在一个程序的多个副本一同执行时,没必要在内存中有多个相同的代码块,把代码段共享就好了。
在内存分段的环境下,8086(实模式)CPU的16位寄存器最多只能在一个内存段的64kb空间内寻址,要是超过64kb,它只能先变换段基址:存储于CS寄存器中,来达到长距离取指的目的。一个程序肯定包括多个段(这是程序分段),这样它就要花费更多功夫去寻址,访存。
平坦模型(Flat Model)
平坦模型是相对于多段模型而言的。
平坦模型之所以相对多段模型而存在,是因为它可以理解成至始至终只有一个段,它能直接访问内存空间,不用再进行段基址的变换。在32位环境的保护模式下,用一个段就能访问4GB的内存位置,所以它不用再分段,只需将所有段描述符的基址设为0,段界限设为4G即可。这样就可以不用来回切换要访问的段。沟壑成坦途。
一定要搞清楚一件事:“程序分段和cpu内存分段是不同的概念”,现代操作系统(比如Linux)一般都是在平坦模型下工作(整个4GB空间为一个段),编译器也是按照平坦模型为程序布局,程序中的代码和数据都在同一个段中整齐排列。
平坦模型下,各个段的空间完全重叠,代码和数据不会产生冲突吗?
理论上可能,但实际上不会。只要搞清楚两个问题:
- 代码段和数据段的虚拟地址是在何时分配的?
- 具体的虚拟地址是在哪里被定义的?
明白这两个问题,就可以理解为什么他们不会冲突。
这个问题涉及到前文所述编译和链接的过程(此处忽略ALSR和应用层动态链接的情况)。在编译器把源码文件编译成目标文件后,目标文件中对外部全局变量和外部函数的调用都是需要重定位的,此时还不知道外部符号的具体地址是什么。
然后,在链接的过程中,链接器扫描所有目标文件的全局变量、静态变量和函数,为他们统一分配虚拟地址,分配虚拟地址的根据就是链接脚本,链接脚本实质上控制了二进制文件中段的起始虚拟地址、长度及其他一些属性。在linux内核中,vmlinux.lds.S是链接脚本的源码,开发人员在该文件中实现各个段的定义。在编译内核时,vmlinux.lds.S被编译成vmlinux.lds链接脚本,链接脚本控制了链接阶段各个段的虚拟地址分配。所以代码段和数据段的理论虚拟地址空间虽然是重叠的,但他们最终可执行文件中所占据的实际虚拟地址空间不可能重叠。这是由链接器所保证的。
一般的高级语言都不允许程序员自己将代码分成各种各样的段,就是因为编译器是针对某个操作系统编写的,操作系统采用的是平坦模型,所以该编译器要编译出适合此操作系统加载运行的程序,由于处理器支持了具有分页机制的虚拟内存,操作系统也采用了分页模型,因此编译器会将程序按内容分成代码段和数据段等部分,而后操作系统会将各个段转化到线性地址空间,然后通过分页机制分配到不同的物理内存上,后面的事儿我们就不用操心了。
为什么依然保留段式寻址?
首先,回顾一下x86处理器引入分段机制的历史。早在8086时期,寄存器是16bit的,但是地址总线被设计成20bit,可以访问1MB的内存。由于cpu的字长只有16bit,为了能访问这1MB的内存,Intel就引入了4个段寄存器(cs、ds、ss、es),引入这些段寄存器的目的是为了访问更多的内存。访问内存时,16bit的段基址左移4位,再加上16bit的偏移地址,就可以生成20bit的物理地址。
到了80286时期,有了访问16MB内存的能力,但是80286的寄存器位数仍然是16bit,为了能访问16MB的地址空间,段寄存器中不再存储段基地址,而是段选择子,段选择子作为索引指向段描述符表中的段描述符,段描述符中保存了24bit的段基地址,偏移地址的位数仍然是16bit,这样24bit的段基址+16bit的偏移地址生成24bit的物理地址。更重要的是,段选择符中包含了请求者优先级RPL,段描述符中包含了描述符优先级DPL,CPU通过这两个优先级的比较实现了经典的保护模式,区分了操作系统的权限和用户权限。
到了80386时期,所有的寄存器被扩展到了32bit,段寄存器也不例外,它继承了80286的保护模式,段偏移地址位数也扩展到了32bit,每个段的寻址空间扩展到了4GB。更重要的是80386引入了分页机制,这时候cpu访问物理内存的流程进化为:
==逻辑地址=虚拟地址==---分段--->>==线性地址==---分页--->>==物理地址==。
具体如下图:
分段可以给每个进程分配不同的线性地址空间,但是在16MB的地址空间时期,每个段最长64k,也就是最多可以有256段分配给不同的进程,在有更多进程需要物理内存时,需要把内存中其他的进程整个替换掉。而分页可以用相同的线性地址映射不同的物理地址,如果内存紧张时可以按页替换,理论上对进程的运行数量没有限制,实现了高效的多任务运行。所以在这个时期分段失去了优势。
到了x86_64时期,cpu寄存器位数为64bit,进入了long mode时代,操作系统基本上弃用了分段功能,因为分页机制即有保护模式的功能,又能灵活的实现多任务。
至于保留分段,这完全是由处理器所决定的。x86处理器无法关闭分段部件。而Linux又采用了平坦模型,因此,Linux内核只会在段描述符表中设置四个通用的段描述符:
名称 描述 段基址 段界限 短描述符特权级 __KERNEL_CS 内核代码段 0 4 GiB 0 __KERNEL_DS 内核数据段 0 4 GiB 0 __USER_CS 用户代码段 0 4 GiB 3 __USER_DS 用户数据段 0 4 GiB 3 所以实际上段部件的分段操作对Linux的内存寻址毫无影响,真正产生影响的是页部件所执行的分页操作。值得一提的是,当前的Linux会利用分段机制来实现线程本地存储(Thread Local Store)。
段错误实例
段错误(Segment Fault)是编写程序时最常出现的错误之一。以下是一些产生段错误的经典例子。
写只读内存
写入只读内存会引发段错误。当程序尝试写自己的代码段或只读数据段时,就会发生这种情况。
下面是一个ANSI C代码的例子,它试图修改只读的字符串,通常会在有内存保护的平台上导致段错误。根据ANSI C标准,这是未定义行为。大多数编译器在编译时不会捕获它,而是将其编译为会crash的可执行代码。
int main(void) { |
当包含此代码的程序被编译时,字符串“hello world”被放在程序可执行文件的.rodata段,即数据段的只读部分。指针s指向该字符串,并试图将一个H字符写入内存,导致段错误。
gcc segfault.c -g -o segfault |
从该进程的core dump文件中可以看到:
Program received signal SIGSEGV, Segmentation fault. |
这段代码可以通过使用数组而不是字符指针来纠正,因为这样会在堆栈上分配内存并将其初始化为字符串字面量的值:
char s[] = "hello world"; |
即使字符串字面量不应该被修改(这在C标准中有未定义的行为),在C中它们是静态char[]
类型,所以在原始代码中没有隐式转换(将char *
指向该数组),而在c++中它们是静态const char[]
类型,因此有隐式转换,所以编译器通常会捕获这个特定的错误。
空指针解引用
在C语言和类C语言中,空指针被用来表示“指向任何对象的指针”,并作为错误指示符,空指针解引用(通过空指针进行的读或写操作)是一个非常常见的程序错误。C标准没有说空指针与指向内存地址0的指针相同,尽管在实际中可能是这样。大多数操作系统会映射空指针的地址,这样访问它会导致段错误。C标准不能保证这种行为。在C语言中,对空指针的解引用是未定义的行为:
int *ptr = NULL; |
这个示例代码创建一个空指针,然后尝试访问它的值。在大多数操作系统上,这样做会在运行时导致段错误。解引用空指针然后赋值给它通常也会导致段错误:
int *ptr = NULL; |
下面的代码包含一个空指针解引用,但是通常不会导致段错误,因为该值是未使用的,因此解引用通常会被编译器通过死代码消除优化掉:
int *ptr = NULL; |
缓冲区溢出
下面的代码访问字符数组边界外的值。根据编译器和处理器的不同,这可能会导致段错误。
char s[] = "hello world"; |
栈溢出
另一个例子是无限递归:
int main(void) { |
这个代码会导致堆栈溢出,从而导致段错误。无限递归不一定会导致堆栈溢出,具体取决于语言,编译器执行的优化以及代码的确切结构。在这种情况下,未定义的代码(返回语句)的行为是不确定的,因此编译器可以通过尾递归消除来优化它。其他可能的优化还包括将递归转换为迭代。
段错误究竟如何产生?
上面提到,Linux操作系统完全采用了平坦模型,分段机制已经名存实亡了。那么我们在Linux系统中经常见到的程序的Segment Fault究竟是如何产生的呢?
所有现代 CPU 都具有中断当前正在执行的机器指令的能力。它们保存了足够的状态(通常但不总是在堆栈上),以便以后可以恢复执行,就好像什么都没发生一样(程序会从中断的指令处恢复执行)。发生中断时,CPU转而执行一个中断处理程序,它放置在一个特殊的位置,CPU 知道它在哪里。中断处理程序始终是操作系统内核的一部分:运行在最高特权级下。
中断可以是同步的,这意味着它们由 CPU 本身触发,作为对当前执行指令所做的事情的直接响应,也可以是异步的,这意味着它们由于外部事件而在不可预测的时间发生,例如数据到达网络端口。
现在,大多数现代操作系统都有进程的概念。这不仅是一台计算机可以同时运行多个程序的机制,也是操作系统如何配置内存保护的一个关键方面,这是大多数操作系统的一个特性。现代 CPU。它与虚拟内存一起使用,虚拟内存能够改变内存地址和 RAM 中实际位置之间的映射。内存保护允许操作系统为每个进程提供一个私有内存空间,只有操作系统和进程自己可以访问。它还允许操作系统将特定的内存区域指定为只读、可执行、在一组协作进程之间共享等。
当进程对某个虚拟地址的访问不合法时(如该虚拟地址没有映射到物理地址),CPU会产生一个缺页中断(还记得吗,平摊模型中段部件不起作用,只有页部件的分页会发生作用),请求内核进行处理。通常情况下,进程并没有违反内存保护规则,但是内核需要做一些工作才能让进程继续执行。例如,如果进程在内存中的一某页被“逐出”到交换文件以释放内存中的空间用于其他内容,则内核将会在页表中标记该页不存在。下次该进程尝试使用这一页时,CPU就会产生一个缺页中断,此时内核将从交换文件中检索对应页面,将其放回原来的位置,再将其标记为可访问,然后进程就能够继续执行。
但是假设某个进程确实违反了内存保护规则。比如它试图访问一个不属于它的地址空间(如内核的地址),或者它试图将数据段作为代码段执行,又或者它试图修改只读数据段中的数据等等。类Unix的操作系统通常都使用信号来处理这种情况。信号类似于中断,但信号是由内核生成并由进程处理的,而中断是由硬件生成并由内核处理的。进程可以在自己的代码中定义信号处理程序,并告诉内核它们在哪里。这些信号处理程序将在必要时执行,中断正常的程序控制流。信号都有一个数字和两个名称,其中一个是首字母缩写词,另一个是解释性的短语。在Linux系统中,当进程违反内存保护规则时生成的信号值为11,它的名称是SIGSEGV,意义就是发生了段错误。
信号和中断之间的一个重要区别是内核为每个信号都定义了一个默认行为。如果操作系统没有为所有的中断定义中断处理程序,那就是操作系统中的一个严重的Bug,当 CPU 尝试调用这个缺少的中断处理程序时,整个计算机将崩溃。但是进程没有义务为所有信号定义信号处理程序。如果内核为一个进程生成一个信号,并且进程没有定义信号处理程序,那么内核将为执行默认行为,而不打扰该进程。大多数信号的默认行为要么是“什么都不做”,要么是“终止这个进程,可能还会产生一个核心转储(core dump)”。SIGSEGV就是后者之一。
所以,回顾一下,假设我们有一个非法的地址访问, CPU 暂停了正在执行的进程并产生了一个缺页中断。由内核定义的中断处理程序将处理该中断,如果内核发现该进程试图违反内存保护规则,那么内核为该进程生成一个 SIGSEGV 信号。假设进程没有为 SIGSEGV 设置信号处理程序,那么Linux内核将执行默认行为,即终止进程。这与_exit系统调用有一些相同的效果,比如关闭打开的文件,释放内存等等。
到目前为止,控制台中还没有打印任何消息,并且根本没有涉及 shell(更准确地说,刚刚被终止的进程的父进程)。SIGSEGV信号进入违反内存保护规则的进程,而不是其父进程。但是,下一步就是通知父进程其子进程已终止。这可以通过几种不同的方式来实现,其中最简单的情况就是当父进程已经在等待子进程的通知,使用等待系统调用之一(wait、waitpid、wait4等)。在这种情况下,内核只会让该系统调用返回,并为父进程提供一个称为退出状态的代码号。退出状态会告知父进程为什么子进程被终止,在这种情况下,父进程将了解到子进程由于SIGSEGV信号而被终止,因此父进程据此可以执行相关的操作。
例如,下面的程序就可以捕获SIGSEGV信号,并自定义打印信息:
|
因此,信号处理程序或者父进程可以通过向控制台打印信息向用户报告这个事件。shell程序几乎总是这样做,这就是为什么当你在shell中执行某个包含段错误的程序时,会看到控制台打印”Segment Fault“这个消息。
关于SIGSEGV信号的其他妙用
在Java中,JVM使用SIGSEGV来实现一些重要的功能,比如最常见的:
- 空指针解引用 - JVM接收到SIGSEGV信号后,就会抛出一个我们在Java代码中最常见的NullPointerException;
- 内存回收的写屏障 - JVM将很少更改的页面被标记为只读,通过SIGSEGV信号可以捕获对它们的写入,这样垃圾收集器就不必一直重新扫描所有内存。