Linux进程内存分布浅析
Linux进程内存分布,在弄清楚这件事情的来龙去脉前,我们需要了解一个事实,Linux对于二进制可执行文件的结构有一定要求。这个要求的格式叫做ELF格式(Executeable and Linkable Format,可执行与可链接格式)。在后续Linux内存分析中,蕴藏一些技术细节,同ELF文件有关系。ELF文件格式有三种存在形态: 1 可重定位目标文件; 2 可执行目标文件; 3 共享目标文件。 后面我会在对应小节来描述这些都是什么。这个格式的确定,也就决定了Linux的二进制执行文件不可以在WIndows上执行,因为Windows的二进制可执行文件对应的是PE格式(Portable Executable)。接下来,我们先来看看Linux中的.c源文件是如何编译为二进制可执行文件的。
一、编译文件的ELF格式
我们在开发的过程中,会先在编辑器中写好数个.c源文件。然后通过编译工具将数个源文件生成可执行文件,大名鼎鼎的gcc就是一个非常棒的编译工具。如果我们想真正了解清楚,ELF格式如何影响内存分布,那么我们对整个编译过程应有个大体认识,不一定需要熟悉编译原理,但是应该有个宏观的了解。我们以gcc编译.c源文件为例,大体步骤如下:
第一步:预处理
经过预处理操作的文件为file.i。在该过程中,gcc编译器会对源文件进行预处理操作,例如,#incldue中的头文件载入,#define的宏替换,sizeof()的内容编译器也会提前换算出来,等等。
第二步:编译
在编译阶段,gcc会将预处理完成的文件进行一系列的词法分析,语法分析,语义分析以及优化后生成相应的汇编代码, 编译过后的文件后缀:file.s。
第三步:汇编
在汇编阶段,编译器会将汇编语言转换为机器码,有的汇编语句对应一条机器码,有的汇编语句会对应多条机器码, 汇编过后的文件后缀:file.o。接下来,我们的主角ELF文件格式登场了。该阶段生成的.o文件,符合ELF文件格式的第一种存在形态:可重定位格式。经历过编译的前三个阶段之后,我们的源文件已经被gcc按照ELF的格式重新组织了,组织过后的文件的格式如下图:
图1 ELF可重定位目标文件
如上图所示,每个色块对应的单位是section即:节,其中每一节的含义如下:
.text | 已编译程序的机器代码; |
.rodata | 只读数据,如字符串; |
.data | 已经过初始化的全局变量,静态变量; |
.bss | 尚未初始化的全局变量,静态变量; |
.symtab | 符号表,保存程序中定义和引用的函数和全局变量的对应地址; |
.rel.text | 重定位表,描述当前文件中哪些跳转地址我们其实不知道,重定位阶段会修改; |
.rel.data | 被当前文件引用的所有全局变量的重定位信息; |
.debug | 调试符号表, gcc -g后出现,同调试相关 |
.line | 调试的每一行的映射,gcc -g后出现,同调试相关 |
.strtab | 字符串表保存了字符串相关的内容,如字符串常量,变量名; |
第四步:链接
接下来,我们该进行编译的第四个阶段,链接。每个文件都变成了二进制目标文件,但现在程序依然无法运行,因为各个文件之间还存在一定的依赖关系,比如全局变量的地址未找到,有些动态库需要关联来找到一些函数的定义,等等。在这个过程中,gcc中的链接器和ELF文件中的 符号表.symtab发挥作用了。链接器会解析所有的目标文件的符号表,构成全局的符号表,再根据重定位表.rel.text,将不确定的跳转地址根据符号表里记录的地址进行更新,再将每个目标文件的对应节进行合并。组织完毕各个源文件的节的内容后,我们的也终于将数个可编辑的源文件编译为一个二进制可执行文件,此时的文件结构如下图:
图2 ELF可执行目标文件
此时,该文件符合ELF的第二种形态可执行文件形态,此时的文件也就是能够执行的二进制文件了。
以上讨论的其实都是静态链接,此外,避免同样功能的代码复用问题,节省Linux内存的空间,我们还会涉及到一种动态链接的方式。当进程在运行时,进程可以直接访问已经加载到内存中的共享库。在windows中是后缀为.dll的动态链接库,在Linux中则是.so的动态库。该动态库则为ELF文件的第三种存在形态。
二、Linux进程内存分布
之前我们讨论的都是静态存储在硬盘上的程序,现在我们来考虑,当静态的程序加载到内存中成为一个动态的进程时会是怎样的过程呢? 这里涉及到了一个叫做加载器的内核组件。当我们在命令行中执行了程序后,加载器做了这样的动作,它将二进制可执行文件从硬盘中复制到了内存中,并且跳转到可执行文件的入口点开始执行程序。这个过程从另一个角度理解,其实也是shell作为父进程创建子进程,调用execve函数族加载二进制可执行程序的过程。加载完毕后,该进程在Linux的虚拟内存中的布局如下图:
图3 64位Linux进程内存分布
在该模型中,内核空间我们不去考虑,里面包含了内核运行所需要的栈区,堆区,和其他各种映射区等等。
在用户空间中,内存不再以section作为单位,一般分析时以segment(段)为讨论单位。
同上文描述的ELF文件的格式一致,在只读段中,.rodata,包含了我们定义的一般的常量、字符串常量等内容;.text,描述了已编译好的二进制代码,比如各个函数的具体实现。该部分的生命周期也就是整个进程运行的时间。
在读写段中,.data,描述了已经初始化的全局变量,静态变量;.bss描述了尚未初始化的全局变量,静态变量。该部分的生命周期也就是整个进程运行的时间。
自由操作的堆区,堆区的内存范围比较大,因此一些比较占内存空间的变量我们可以采取malloc等相关函数的方式,在堆区开辟。使用内存完毕后,将其释放,否则会出现一定程度的内存泄漏现象。堆区的生长方向是从内存的低地址向高地址。,该部分内存由程序员主动申请,主动释放,因此其生命周期是由程序员决定的,如果进程结束后,内存仍未主动释放,那操作系统介入回收。
再向上,到了共享内存的动态链接库的区域了,平常我们加载的一些.so文件,如操作线程时,需要用到glibc的线程库等等。
再往上,到了大名鼎鼎的栈区。栈不同于区堆区,首先,栈区的空间不像堆区这么庞大,栈区的大小是固定的(该参数可以更改,不同Linux的发行版可能会有差异,但是也就几M十几M这个量级)。栈区的生长方向是从高地址到低地址。在栈区中保存的内容,是调用函数时的局部变量,函数的调用参数,以及一些调用函数时的指针位置等信息。栈是随着函数的出入动态变化的,该过程也就体现了每个被调用函数及对应局部变量的生命周期。
至此,我们再回忆第二节的内容,便可以理解在Linux内存中这些分段的由来了,第二节的模型加载到Linux中,Linux又额外的增加了进程动态运行时需要的空间分布。由此可见,C的源文件同Linux的内存模型,却是有着千丝万缕的联系。
三、实验验证
在这一节中我们通过一个实验来分析上两节提到的模型,看看他们之间的联系,以加深我们的理解。
编译及分析环境:ubuntu 18.04,gcc 7.5.0 , gdb 8.1.1 , objdump 2.30
验证程序:
foo.c
#include <stdio.h>
int func2(){
printf("I'm belong to another .c\n");
return 0;
}
main.c
#include <stdio.h>
char* str1 = "Linux memory model test";
int global = 111;
int func2();
int func(){
printf("I'm called.\n");
return 0;
}
int main(){
int a = 1;
char* str2 = "Linux memory model test";
int b = func();
int c = func2();
while(1){
;
}
return 0;
}
我们的实验步骤:
将两个.c源文件编译,先编译成.o目标文件:gcc main.c foo.c -c -g。在实验目录下出现了两个.o文件;foo.o,main.o;
通过objdump观察两个.o文件的大体结构:objdump -d -M intel -S foo.o,objdump -d -M intel -S main.o。其中两个.o结构如下图:
图4 foo.o
图 5 main.o
通过objdump观察main文件的大体结构:objdump -d -M intel -S main,文件较长,截图我只体现比较明显的地方:
图6 man
通过这三张图我们能比较清楚的对比出在.text段的链接的前后状态。其他段我理解应该是类似的,但限于时(zhi)间(shi)关(shui)系(ping),还没有找到很好的体现的方式。
下面我们要查看,在进程中的内存情况,需要用到gdb了。运行gdb,执行指令:gdb main -q,在func函数中打个断点,gdb指令:b 8(看看第8行是什么?)。此时可以看到该语句的位置和上图一致。
图7 查看main.c的第8行
在gdb中我们还可以通过gdb指令:info frame,来看当前的栈空间,如下图:
图8 栈空间内容
栈的位置如同我们的分析。
执行gdb指令:info addr global,如下图所示,可以看到全局变量的位置,也如同我们的分析(info addr是个好东西)。
图9 全局变量global位置
Linux内存空间的分析不再一一查看了,基本上对于一些存疑的变量、语句都可以在gdb中通过info addr查看他们的地址,当然也可以直接打印指向变量的指针,只是这样不好看栈内部的结构。读者后续的操作,也可以自己建个堆,看看堆的位置。
四、总结
该文章整体上描述c静态程序编译后的代码分布情况,和加载到Linux内存中分布情况。限于篇幅,本文重在梳理过程,也因此忽略了很多技术细节,比如:链接过程中,在重定位时,链接器是如何解析符号表的;Linux在加载动态库时,动态库和进程的代码是如何配合的,都没有做详细的分析,后续有时间可以再补充这些内容。
正所谓,吾生也有涯,而知也无涯。以有涯随无涯,殆已!但有时候,快乐和自身价值,也正是在这样追随的过程中体现的,也许这就是技术的魅力之一吧。
(完)