《程序员的自我修养》学习笔记(五)————可执行文件的装载与进程

        可执行文件只有装载到内存中以后才能被CPU执行。早期的程序装载的基本过程就是把程序从外部存储器读到内存中的某个位置。随着硬件MMU的诞生,多进程、多用户、虚拟存储的操作系统的出现,装载过程变得复杂起来。程序,也就是可执行文件,是一个静态的概念,装载到内存中以后就成为了进程,进程是一个动态的概念,正所谓“Process is a program in execution”。

1.虚拟地址空间

         现在的电脑和操作系统都支持虚拟内存技术,由MMU(Memory Management Unit)进行虚拟地址和物理地址的相互转换。每个进程拥有独立的虚拟地址空间(Virtual Address Space),它的大小由计算机的硬件平台决定。32位的CPU下,程序中能访问的虚拟地址空间最大为4G,但是实际上可用的计算机的物理内存空间可以通过PAE(Physical Address Extension),AWE(Address Windowing Extensions)等方式进行扩大。PAE主要是通过扩展地址线来实现的,32位地址线最大4G内存,36位地址线则扩大了物理地址大小,再通过相应的映射方法来使用扩大的物理内存空间。

2.装载方式

         程序执行时所需要的指令和数据必须在内存中才能正常运行,最简单的方法就是把程序运行需要的指令和数据全部装入内存中,这就是静态装载。但是这样会浪费宝贵的内存,并且很多情况下,程序所需要的内存大小大于物理内存。根据局部性原理,我们可以将程序最常用的部分放在内存中,不常用的放在磁盘里,用的时候再装入,这就是动态装载基本原理。覆盖装入(Overlay)和页映射(Paging)是两种典型的动态装载的方法。

         覆盖装入在虚拟内存技术发明之前使用比较广泛。程序员在编写程序时将程序分割成若干块,然后编写一个小小的辅助代码,也就是覆盖管理器(Overlay Manager)来管理这些模块何时应该驻留在内存,何时应该被替换掉。程序员需要将这些模块依据调用关系组织成树状结构,以便于确定何时覆盖和装入某个模块。跨模块的调用都要经过覆盖管理器,以确保被调用的模块都在内存中,如果不在还要从磁盘读取装入,速度比较慢。

         页映射是虚拟内存技术的一部分,它将程序和内存以 “页”(Page)为单位进行划分,装载的单位就是页。将需要用到的页从磁盘载入内存中,如果物理内存已被用完,就要由页替换算法决定被替换的页。几乎目前所有的主流操作系统都采用的这种方式装载可执行文件。

3. 从操作系统角度看可执行文件的装载

         事实上,从操作系统的角度来看,一个进程最关键的特征是它拥有独立的虚拟地址空间。创建一个进程,然后装载相应的可执行文件并且执行,上述过程最开始只需要做三件事:
(1)创建一个独立的虚拟地址空间。
(2)读取可执行文件,并且建立虚拟地址空间与可执行文件的映射关系。
(3)将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。

        创建虚拟地址空间。一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,那么创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的相应的数据结构,在Linux下,创建虚拟地址空间实际上只是分配一个页目录就可以了,甚至不设置映射关系,这些映射关系等后面程序发生也错误的时候再进行设置。
        读取可执行文件,并且建立虚拟地址空间与可执行文件的映射关系。上面那一步的页映射关系函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。我们知道,当程序执行发生也错误的时候,操作系统将从物理内存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟页与物理页的映射关系,这样程序才能得以正常运行。但是很明显的一点是,当操作系统捕获到缺页错误的时候,它应该知道程序当前所需要的页在可执行文件中的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。从某种程度来说,这一步是整个装载过程中最重要的一步,也是传统意义上“装载”的过程。
        将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。第三步其实也是最简单的一部,操作系统通过设置CPU的指令寄存器将控制权转交给进程,由此进程开始执行。此过程中可以简单地认为操作系统执行了一条跳转指令,直接跳转到可执行文件的入口地址,这个入口地址就是ELF文件头中保存的入口地址。

4. ELF文件的链接视图和执行视图

         一个ELF可执行文件往往由很多个节构成,在操作系统装载可执行文件时,如果把每个节映射到一个页,由于它们大小不一样,肯定会产生内存浪费。对于操作系统来说,实际上在装载时它主要关心页的权限问题,即可读、可写或可执行。而ELF文件中的节的权限往往只有为数不多的几种组合:
可读,可执行,如代码节
可读,可写,如数据节和.bss节
只读,如只读数据节

          那么对于权限相同的节,完全可以把它们合并到一起进行映射。ELF文件引入了一个概念叫做段(Segment),一个段包括一个或多个属性类似的节(Section)。装载的时候就把一个段当作一个整体进行映射。这样可以明显的减少页面内部的碎片,节省内存空间。从链接的角度看,ELF文件是按照节存储的,从装载的角度看,ELF文件又可以按照段进行划分。从节的角度来看ELF文件就是链接视图(Linking View),从段的角度来看就是执行视图(Execution View)。段的概念实际上是从装载的角度重新划分了ELF的各个节。在将目标文件链接成可执行文件时,链接器会尽量把权限属性相同的节分配在同一空间。在ELF中把这些属性相似的、又连在一起的段叫做一个”Segment”,而系统正是按照”Segment”而不是”Section”来映射可执行文件的。

#include <stdlib.h>
int main(void)
{
	while(1)
	{
		sleep(1000);
	}
	return 0;
}

gcc -static SimpleSection.c -o SimpleSection.elf 

我们以上面的简单程序为例,使用

readelf -S SimpleSection.elf

可以发现,可执行文件有33个段(Section)。

         正如描述节的属性的结构叫做节表,描述段的属性的结构叫做程序头表(Program Header Table),它描述了ELF文件如何被操作系统映射到进程的虚拟内存空间。由于ELF目标文件不需要被装载,它没有程序头表,而ELF可执行文件和共享文件都有。

        可以看到,这个可执行文件共有6个Segment。从装载的角度看,目前只关心两个“LOAD”类型的Segment,因为只有它是需要被映射的,其它的诸如“NOTE”、“TLS”、“GNU_STACK”都是在装载时起辅助作用的。所有相同属性的“Section”被归类到一个“Segment”,并且映射到同一个VMA(Virtual Memory Area)。
        总的来说,“Segment”和“Section”是从不同的角度来划分同一个ELF文件。这个在ELF中被称为不同的视图(View),从“Section”的角度来看ELF文件就是链接视图(Linking View),从“Segment”的角度来看就是执行视图(Execution View)。当我们在谈到ELF装载时,“段”专门指“Segment”;而在其它的情况下,“段”指的是“Section”。

/* Program segment header.  */
typedef struct
{
  Elf32_Word	p_type;			/* Segment type */
  Elf32_Off	p_offset;		/* Segment file offset */
  Elf32_Addr	p_vaddr;		/* Segment virtual address */
  Elf32_Addr	p_paddr;		/* Segment physical address */
  Elf32_Word	p_filesz;		/* Segment size in file */
  Elf32_Word	p_memsz;		/* Segment size in memory */
  Elf32_Word	p_flags;		/* Segment flags */
  Elf32_Word	p_align;		/* Segment alignment */
} Elf32_Phdr;

typedef struct
{
  Elf64_Word	p_type;			/* Segment type */
  Elf64_Word	p_flags;		/* Segment flags */
  Elf64_Off	p_offset;		/* Segment file offset */
  Elf64_Addr	p_vaddr;		/* Segment virtual address */
  Elf64_Addr	p_paddr;		/* Segment physical address */
  Elf64_Xword	p_filesz;		/* Segment size in file */
  Elf64_Xword	p_memsz;		/* Segment size in memory */
  Elf64_Xword	p_align;		/* Segment alignment */
} Elf64_Phdr;

         对于”LOAD”类型的”Segment”来说,p_memsz的值不可以小于p_filesz,否则就是不符合常理的。如果p_memsz大于p_filesz, 就表示该”Segment”在内存中所分配的空间大小超过文件中实际的大小,这部分”多余”的部分则全部填充为”0”。这样做的好处是,我们在构造ELF可执行文件时不需要再额外设立BSS的”Segment”了,可以把数据”Segment”的p_memsz扩大,那些额外的部分就是BSS。因为数据段和BSS的唯一区别就是:数据段从文件中初始化内容,而BSS段的内容全都初始化为0。这也就是在前面的例子中只看到了两个”LOAD”类型的段,而不是三个,BSS已经被合并到了数据类型的段里面。

5 堆和栈

        在操作系统里面,VMA除了被用来映射可执行文件中的各个”Segment”以外,它还可以有其它的作用,操作系统通过使用VMA来对进程的地址空间进行管理。进程在执行的时候它还需要用到栈(Stack)、堆(Heap)等空间,事实上它们在进程的虚拟空间中的表现也是以VMA的形式存在的,很多情况下,一个进程中的栈和堆分别都有一个对应的VMA。

        上图的输出结果中:第一列是VMA的地址范围;第二列是VMA的权限,”r”表示可读,”w”表示可写,”x”表示可执行,”p”表示私有(COW, Copy on Write),”s”表示共享。第三列是偏移,表示VMA对应的Segment在映像文件中的偏移;第四列表示映像文件所在设备的主设备号和次设备号;第五列表示映像文件的节点号。最后一列是映像文件的路径。我们可以看到进程中有7个VMA,只有前两个是映射到可执行文件中的两个Segment。另外五个段的文件所在设备主设备号和次设备号及文件节点号都是0,则表示它们没有映射到文件中,这种VMA叫做匿名虚拟内存地址(Anonymous Virtual Memory Area)。我们可以看到有两个区域分别是堆(Heap)和栈(Stack),这两个VMA几乎在所有的进程中存在,我们在C语言程序里面最常用的malloc()内存分配函数就是从堆里面分配的,堆由系统库管理。栈一般也叫做堆栈,每个线程都有属于自己的堆栈,对于单线程的程序来讲,这个VMA堆栈就全都归它使用。另外有一个很特殊的VMA叫做”vdso”,它的地址已经位于内核空间了,事实上它是一个内核的模块,进程可以通过访问这个VMA来跟内核进行一些通信。

6 段地址对齐

        装载的过程一般是通过虚拟内存的页映射机制完成的。在映射的过程中,页是最小单位。对于Intel 80x86系列处理器来说,默认的页大小为4096字节。也就是说如果我们要将一段物理内存和进程的虚拟地址空间之间建立映射关系,这段内存空间的长度必须是4096的整数倍,并且这段空间在物理内存和进程虚拟地址空间中的起始地址必须是4096的整数倍。那么可执行文件就要尽量优化自己的空间和地址安排,以节省空间。
         最简单的做法就是每个段分开映射,长度不足一个页的部分也占据一个页,也就是说段的首地址对齐到了4096的整数倍。但是这样会造成很多内部碎片。为了解决这种问题,有些UNIX系统采用了一种取巧的方法,就是让那些各个段接壤的部分共享一个物理页面,然后将该物理页面分别映射两次。从某种角度看,好像是整个ELF文件从文件开头到某个点结束,被逻辑上分成了以4096字节为单位的若干个块,每个块都被装载到物理内存中去。那些包含了多个段的物理内存中的块,将会被映射到虚拟地址空间中多次。当然不同的段还有自己的不同对齐属性,这一点在为ELF文件分配虚拟内存空间时也要考虑。

7 Linux内核装载ELF过程简介

        当我们在Linux系统的bash下输入一个命令执行某个ELF程序时,首先在用户层面,bash进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用执行指定的ELF文件,原先的bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。execve()系统调用被声明在/usr/include/unistd.h中。Glibc对execve()系统调用进行了包装,提供了execl()、execlp()、execle()、execv()和execvp()等5个不同形式的exec系列API,它们只是在调用的参数形式上有所区别,但最终都会调用到execve()这个系统中。
        在进入execve()系统调用之后,Linux内核就开始进行真正的装载工作。在内核中,execve()系统调用相应的入口是sys_execve(),sys_execve()进行一些参数的检查复制之后,调用do_execve()。do_execve()会首先查找被执行的文件,如果找到文件,则读取文件的前128个字节,目的是判断文件的格式,每种可执行文件的格式的开头几个字节都是很特殊的,特别是开头4个字节,常常被称做魔数(Magic Number),通过对魔数的判断可以确定文件的格式和类型。比如ELF的可执行文件格式的头4个字节为0x7F、’E’、’L’、’F’;而Java的可执行文件格式的头4个字节为’c’、’a’、’f’、’e’;如果被执行的是Shell脚本或perl、python等这种解释型语言的脚本,那么它的第一行往往是”#!/bin/sh”或”#!/usr/bin/perl”或”#!/usr/bin/python”,这时候前两个字节’#’和”!”就构成了魔数,系统一旦判断到这两个字节,就对后面的字符串进行解析,以确定具体的解释程序的路径。
        当do_execve()读取了这128个字节的文件头部以后,然后调用search_binary_handle()去搜索和匹配合适的可执行文件装载处理过程。Linux中所有被支持的可执行文件格式都有相应的装载处理过程,search_binary_handle()会通过判断文件头部的魔数确定文件的格式,并且调用相应的装载处理过程。比如ELF可执行文件的装载处理过程叫做load_elf_binary();a.out可执行文件的装载处理过程叫做load_aout_binary();而装载可执行脚本程序的处理过程叫做load_script()。
load_elf_binary()的主要步骤是:
(1). 检查ELF可执行文件格式的有效性,比如魔数、程序头表中段(Segment)的数量。
(2). 寻找动态链接的”.interp”段,设置动态链接器路径。
(3). 根据ELF可执行文件的程序头表的描述,对ELF文件进行映射,比如代码、数据、只读数据。
(4). 初始化ELF进程环境,比如进程启动时EDX寄存器的地址应该是DT_FINI的地址。
(5). 将系统调用的返回地址修改成ELF可执行文件的入口点,这个入口点取决于程序的链接方式,对于静态链接的ELF可执行文件,这个程序入口就是ELF文件的文件头中e_entry所指的地址;对于动态链接的ELF可执行文件,程序入口点就是动态链接器。
当load_elf_binary()执行完毕,返回至do_execve()再返回sys_execve()时,上面的第5步中已经把系统调用的返回地址改成了被装载的ELF程序的入口地址了。所以当sys_execve()系统调用从内核态返回到用户态时,EIP寄存器直接跳转到了ELF程序的入口地址,于是新的程序开始执行,ELF可执行文件加载完成。

 

 

 

 

 

 

 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
程序员自我修养:链接,装载与库》是一本由林锐、郭晓东、郑蕾等人合著的计算机技术书籍,在该书中,作者从程序员的视角出发,对链接、装载与库等概念进行了深入的阐述和解析。 在计算机编程中,链接是指将各个源文件中的代码模块组合成一个可执行的程序的过程。链接可以分为静态链接和动态链接两种方式。静态链接是在编译时将所有代码模块合并成一个独立的可执行文件,而动态链接是在运行时根据需要加载相应的代码模块。 装载是指将一个程序从磁盘上加载到内存中准备执行的过程。在装载过程中,操作系统会为程序分配内存空间,并将程序中的各个模块加载到相应的内存地址上。装载过程中还包括解析模块之间的引用关系,以及进行地址重定位等操作。 库是指一组可重用的代码模块,通过链接和装载的方式被程序调用。库可以分为静态库和动态库。静态库是在编译时将库的代码链接到程序中,使程序与库的代码合并为一个可执行文件。动态库则是在运行时通过动态链接的方式加载并调用。 《程序员自我修养:链接,装载与库》对于理解链接、装载和库的原理和机制具有极大的帮助。通过学习这些概念,程序员可以更好地优化代码结构和组织,提高程序的性能和可维护性。同时,了解链接、装载和库的工作原理也对于进行调试和故障排除具有重要意义。 总之,链接、装载与库是计算机编程中的重要概念,对于程序员来说掌握这些知识是非常必要的。《程序员自我修养:链接,装载与库》这本书提供了深入浅出的解释和实例,对于想要学习和掌握这些知识的程序员来说是一本非常有价值的参考书籍。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值