问题描述昨天下午,我旁边的同事正在研究Linux系统中的虚拟地址映射(经典书籍《程序员的自我修养-链接、装载与库》)。看6.4章的时候,对于一个可执行的ELF文件,虚拟地址的值是几百,很难理解!例如下面的C代码:先编译一个32位的可执行程序(为了避免一些无关的干扰因素,采用静态链接):gcc-m32-statictest.c-otest编译得到可执行文件在ELF格式:测试。这时使用readelf工具查看可执行文件中的段信息(segment):上图红色矩形框中,为什么第二个段的地址是0x080e_9f5c?这篇文章主要是根据书中的解释。具体分析这个值的来龙去脉。ELF文件格式在Linux系统中,ELF格式的文件有4类,包括:目标文件、可执行文件、动态链接库文件和核心转储文件。要想系统地掌握Linux系统中的底层知识,研究ELF格式是免不了的。这里就不细说了,只要记住两点:从编译器的角度来看,ELF文件是由很多节(Section)组成的;从程序加载器的角度来看,ELF文件是由许多节(Section)组成的。);其实它们之间没有本质区别,只是链接器在链接阶段将不同目标文件中的相同节组织在一起,形成一个段。对于刚才编译的测试可执行文件,其加载视图如下:可以看到该文件一共有5个段(segment),前2个段需要加载到内存中,它们的属性分别是:read,执行(RE)和读写(RW),分别是代码段和数据段。绿色箭头表示代码段包含很多段;黄色箭头表示数据段也包含很多节。地址翻译和内存映射从地址翻译的角度来看:Linux系统中的CPU使用的是虚拟地址。虚拟地址寻址时,需要经过MMU地址转换得到实际的物理地址,然后才能在物理地址中使用。读取内存中的指令,或者读写数据。在现代操作系统中,MMU地址翻译单元基本都是通过页表来进行地址翻译的:当然有的系统采用二级翻译(页目录、页表),有的系统采用三级或四级页表.从内存映射的角度来看:当操作系统加载一个可执行程序到系统中时,会将ELF文件中各个段的内容读取到物理内存中,然后将物理内存映射到该段对应的虚拟段中。地址(虚拟地址)。假设一个可执行程序的代码段长度为1.2K字节,数据段长度为1.3K字节。操作系统将它们读入内存时,需要2个物理内存页分别存放(每个物理页的长度为4K):虽然每个物理内存页的大小为4K,但代码段和数据段实际上只使用每页的第一段。当CPU需要读取物理内存上代码段中的指令时,使用的虚拟地址是0x0000_1000~0x0000_1000+1.2K范围内的地址。MMU单元通过页表转换后,会得到存放代码段的物理内存。页面的物理地址。数据段的寻址方式也是一样的:当CPU需要在物理内存上读写数据段中的数据时,使用的虚拟地址是0x0000_2000~0x0000_2000+1.3K范围内的地址。MMU单元通过页表转换后,会得到存放数据段的物理页的物理地址。可以看出,在这样的安排下,每个段的虚拟地址都是按照4K(0x1000)对齐的。如果操作系统是这么简单的映射,那么事情就会简单很多。如果按照这种排列,我们分析一下文章开头的测试可执行程序中的虚拟地址排列:代码段排列的起始虚拟地址为0x0804_8000,4K对齐;代码段的结束虚拟地址应该是0x0804_8000+0xa0725=0x080e_8725;那么数据段的起始地址可以安排在0x080e_8725之后的下一个4K对齐的边界地址,即:0x080e_9000。但是这样的地址安排严重浪费了物理内存空间!1.2K字节的代码段加上1.3K字节的数据段本来只需要1个物理页(4KB),这里却消耗了2个物理页(8KB)。为了减少物理内存的浪费,Linux操作系统采用了一些巧妙的方法来减少物理内存的浪费,即:将文件边界部分的代码段和数据段读入同一个物理内存页,然后在虚拟地址空间中映射两次,详见下文。Linux中的重复内存映射先来看看测试文件的结构:代码段在文件中从0x00000开始,长度为0xa0725。数据段起始位置为:0xa0f5c,长度为0x1024。可以看出它们之间有一个空白间隔,长度为:0xa0f5c-0xa0725=0x837(十进制:2103字节)。操作系统将测试文件读入物理内存时,从文件开头的代码段地址0x00000开始读取,并以4KB为单位存储在一个物理页中。将文件中代码段的0x00000~0x00FFF读入一个物理页;将文件中代码段的0x01000~0x01FFF读入一个物理页;以下内容以这种方式划分和复制;也就是说:相当于测试文件从起始位置开始,按照4KB为单位“裁剪”,然后复制到不同的物理内存页,如下图:注意:这些物理页的地址很可能是不连续的。这里有意思的是:代码段和数据段之间的4KB空间,起始地址为0xA0000,结束地址为0xA0FFF,被复制到物理内存中最上面的橙色物理页。再来看看代码段的虚拟地址:在执行gcc指令时,链接器将代码段的虚拟地址安排在0x0804_8000:也就是说:当CPU(或程序代码)使用0x0804_8000~0x0804_7FFF该范围的地址,经过地址映射后,会在物理内存中找到浅绿色的物理页,而这个物理页也对应于测试可执行文件开头的前4KB空间。而且从虚拟地址来看,它的地址都是连续的,对应测试文件中连续的内容,这也是虚拟地址映射的本质。将代码段的起始位置安排在地址0x0804_8000,由Linux操作系统决定。那么想一想:代码段的最后一部分表示对应的4K页,对应的起始虚拟地址是多少?上图中已经标出,即虚拟地址橙色部分:0x080e_8000,计算如下:通过代码段起始地址0x0804_8000,加上代码段在内存0xa0725的长度,得到结果是0x080e_8725。对齐到4K(0x1000)后,最后一个虚拟页应该是0x080e_8000。也就是说:虚拟地址中0x080e_8000~0x080e_8724范围对应测试文件中代码段的最后一部分指令(0x725字节)。另外,上图最右边:测试文件结构中的两个红色地址:0xA0000、0xA1000,它们是怎么算出来的?代码段的长度为0xA0725,按照4K为单位进行划分,即对0xA0725除以0x1000得到这4KB的起始地址0xA0000。同样,下一个4KB的起始地址是0xA1000。将文件中的这部分4K数据(包括:部分代码段内容+0x837字节空洞+部分数据段内容)复制到上图中物理内存中最上面的橙色物理页中。又因为在虚拟地址空间中,从0x080E_8000开始的4KB空间映射到了这个物理页,所以:在这个虚拟地址空间中,也有一个0x837字节的空洞,如下图:空洞下面是指令代码段;孔上方是数据段的数据。现在,代码和数据都存储在这个物理页面中。那么CPU在查找部分代码和数据的时候,一定是能够找到的!代码段更容易理解:从这个物理页开始的前0x725字节是有效的。从虚拟地址来看,即从0x080e_8000开始的前0x725个字节是有效的。因此,对于这部分代码的寻址,使用的虚拟地址在0x080e_8000~0x080e_8724范围内。数据段呢?重点来了:Linux系统还将虚拟地址空间0x080e_9000~0x080e_9FFF映射到图中物理内存中最上面的橙色物理页!如下图:因为物理页从0x837字节开始为空数据段的内容就是数据段的真实内容,相应的:在虚拟地址0x080e_9000~0x080e_9FFF空间中,0x837字节以上的内容就是数据段。那么在虚拟地址空间中,这个数据段的起始地址应该是多少呢?只需从这个4K页的起始地址算出0x837字节空洞以上的偏移量,然后加上这个4K页的偏移量起始地址为0x080E_9000,就得到了数据段的起始地址(虚拟地址)。因为虚拟地址、物理地址、测试文件都是以4K为单位划分的,所以这个偏移量等于:测试文件中数据段起始地址(0xA0F5C)到本页起始地址的距离(0xA0000)偏移量。0xA0F5C-0xA0000=0xF5C。即:从这个4K页的起始地址开始,偏移0xF5C就是数据段内容的开始。因此,对于虚拟地址来说,从地址0x080e_9000开始,偏移0xF5C之后的内容就是数据段的内容。该地址的值为:0x080e_9000+0xF5C=0x080e_9F5C,如下图:该地址正是readelf工具读取到的内容显示:加载到虚拟地址空间的数据段的起始地址,如下图:至此,文章开头提出的问题已经解释清楚了!我们看一下整个数据段的内容:数据段在内存中占用的空间为0x01e48(readelf工具读取的MemSiz),那么数据段的结束地址为(虚拟地址):0x080e_9F5C+0x01e48=0x080e_bda4如下:在linux系统中这个操作总结:反复映射属于不同段的内容,有点类似共享内存的味道。只是在这里反复映射之后,各个段的虚拟地址还是需要修正为段的合法地址。经过这样的操作,虚拟地址中各个段的边界就明确了,但是映射到的物理内存页可能是一样的。
