elf文件的Section与Segment
之前在做6.828Lab的时候,就看过elf文件。维基百科上面写了除了有elf的File header,还有Program header和Section header。对于后面这两者我当时分不清,在kern/env.c/load_icode()
里面,加载一个可执行文件到进程中的那部分也不太清楚,抄的。最近在看《程序员的自我修养——链接、装载与库》这本书,解决了一些疑惑,所以记录一下。
从源文件讲起
就以C来举例子(因为现在在看的,做的都是C,其他的不懂)。写好一个.c文件后,在ide里面,点一下运行就全部完成了。实际上,隐藏在ide背后的基本有这些步骤:
预处理
处理以#开头的行。包括include
,define
等。如,对于include
,把包含的文件内容替换,此过程是递归的,一直替换,直到文件中不含include
。对于define
,在对应位置展开宏定义。
编译
我没学过编译原理,只知道输入就是上一步预处理的结果,输出是汇编代码。中间有词法分析、语法分析等这些。输出的汇编代码是.s文件。
汇编
把汇编代码转换成二进制机器码,直接按照汇编指令和机器指令对照表翻译就可以了。输出目标文件(Object file),是.o文件,elf格式的。
链接
链接不同的.o文件,重定位等,这里不细讲。最后输出可执行文件,也是elf格式的。
elf文件
elf文件中有一个File header,里面记录了一些基本信息。和本文相关并且比较重要的有:
- e_entry:程序的入口地址
- e_phoff:program header table在此文件中的的offset
- e_shoff:section header table在此文件中的的offset
- e_phnum:program header table含有的项数(可以把这个表想成一个数组,其中一个元素就是一项)
- e_shnum:section header table含有的项数
对于elf文件本身来讲,更关注的是section。在目标文件和可执行文件中,都会有不同的section,如
- .text:代码段,是代码的二进制表示
- .data:数据段,保存已初始化的全局变量和局部静态变量
- .bss:保存未初始化的全局变量和局部静态变量
- .rodata:保存只读数据
当然,其他还有很多,在这里就不列举了。这些section,对于进程来讲,都有不同的属性,如.text是可读可执行的,.data是可读可写的,.rodata只读。前面在做Lab的时候也知道,在虚拟内存管理中,页表(PDE/PTE)都有12位的权限位来设置权限,可以跟上面这些属性对应起来。如果直接按照section加载进内存中的话,分配的最小单位是页,有些section的内容很少,但是也要占一页4KB,产生了很多的页内碎片。为了解决这个问题,很自然地,可以把相同属性的section放在一起,一起打包加载进内存,毕竟对于进程来讲,不管它是.text还是.data,只要权限相同就是一样的。这就形成了前面讲的segment。在链接过程中,链接器会尽量把相同属性的section放在一起,这些属性相同,空间上在一起的section合起来就成了一个segment。所以,对于操作系统来讲,装载可执行文件的时候是按照segment来装载的:
1 | static void |
Program header里面存放的就是不同的segment的信息。加载时,只加载类型为ELF_PROG_LOAD
的segment,因为有些segment是不需要加载的。p_filesz
是此segment在可执行文件中的大小,p_memsz
是在内存中的大小。而p_memsz
要大于等于p_filesz
,是因为有些section只存在于section header table,并没有占用实际空间,但是在加载到内存时是要分配内存空间的。比如.bss,保存未初始化的全局变量和局部静态变量。如果用一些命令查看elf文件的section header table,会发现.bss这一项在elf文件中的地址和其他某一项的地址相同,而.bss的大小又不为0(也可能为0,比如没有未初始化的全局变量和局部静态变量)。结合代码看就比较好理解了,为这整个segment分配p_memsz
大小的空间后,把有内容的(p_filesz
大小的)复制过去,剩下的ph->p_memsz - ph->p_filesz
大小的填为0,也就是默认初始化为0。这样就实现了节省空间。
另外需要注意的几点:
- 加载前,从内核页表换成进程页表,是为了只在进程的页表中建立对应的映射关系,加载完以后又换回去了。
- 所谓的加载,其实也就是
memcpy
,直接把可执行文件中的二进制数据复制到对应的地方ph->p_va
,即该segment的起始虚拟地址。 - 这里加载的是可执行文件。而对于目标文件来讲,还没有链接,不需要装载,所以没有segment,也就没有program header table。
region_alloc()
函数设置的权限位是PTE_U | PTE_W
,是可读可写的。代码注释(我删了,因为有点长,不好看)中也说了,All page protection bits should be user read/write for now.所以本应该是可读可执行的.text,现在也变成可写的了。Lab中的操作系统页表的权限位好像本来就没有可执行这一选项,所以所有加载到内存的segment都是可读可写的。利用这一漏洞,做了下面的实验。
可写的代码段
修改user/hello.c
文件如下:
1 | void umain(int argc, char **argv) |
定义a为10,修改0x8000d2处为0x7f,然后打印。0x8000d2就是汇编代码中a的地址,可以通过obj/user/hello.asm
查看或者objdump -d obj/user/hello
查看。
1 | cprintf("a: %d\n", a); |
前面定义a的时候其实在汇编代码里并没有定义,只是在最后cprintf
前直接装进调用栈里面去了,这应该是编译器的优化。8000d1为6a,是push
的机器码,0a就是10。然后push
进"a: %d\n"
的字符串,然后调用打印函数。memset((void *)0x8000d2, 0x7f, 1);
是把0x8000d2处的1个字节设置为0x7f。修改前后打印出8000d1开始的7个字节,至于为什么要(unsigned char),见这里。运行结果如下
1 | before modify:6a 0a 68 a5 10 80 00 |
127是0x7f的十进制。改成0x80后输出-128。也就是补码形式。二进制代码复制到内存中去以后,运行时对于可写的.text用代码实现再次写入,就实现了上面的效果。这只是在此操作系统把.text设置为可写的情况下才是可行的。对于正常情况,我在ubuntu下用此方法改代码,能够编译链接通过,但是运行时报错:段错误 (核心已转储)。在此代码中修改的地方是0x6c6,查看可执行文件的program header table,对于从虚拟地址0开始,大小为0x8b0的可加载的segment,属性是R和E,即可读和可执行,没有可写,所以正常情况下是修改不了的。然后我搜了下,给gcc传递一些参数好像能让.text可写,但我没细看了,因为都已经不太重要了。