当前位置: 首页 > 科技观察

从零开始学Linux:16张结构图,吃透【代码重定位】的底层原理

时间:2023-03-20 01:10:07 科技观察

程序结构bootloader从硬盘读取程序到内存代码重定位程序入口点重定位段表重定位跳转到程序的执行入口地址的操作系统程序在上一篇《Linux从零开始05——系统启动过程中的几个神秘地址,你知道是什么意思吗?》中介绍了x86系统开机后几个重要的内存地址作为线索:CPU如何执行第一条指令;BIOS中的程序是如何执行的;操作系统的bootloader被读入物理内存并执行;接下来的环节应该是引导程序(bootloader)将操作系统程序读入内存,然后跳转到操作系统的第一条指令开始执行。在本文中,我们继续以简单的处理器8086为原型来描述程序的加载过程。它的关键部分是代码重定位。我们用画图的方式尽量把程序加载和地址重定位的计算过程描述清楚。PS:文中提到的程序和操作系统文件均指同一事物。程序结构为了便于下面的理解,我们有必要先介绍一下要加载的操作系统程序的文件结构。当然,这里介绍的文件结构是操作系统程序的一个非常简化的版本,它和我们平时写的应用程序本质上是一样的,所以我们也可以把它看成是一个普通的程序文件。操作系统程序静静地躺在硬盘里,等待bootloader读取它。这时候bootloader就可以看成是一个loader。毕竟,它们属于两种不同的事物。为了让引导加载程序知道程序的长度,需要某种“协议”来进行通信。这个“协议”就是程序文件的头信息(Header)。也就是说,在程序的开始,你会详细介绍自己,包括:程序的总长度是多少字节,一共有多少段,入口地址在哪里等等。大家还记得之前介绍过的Linux系统使用的ELF文件格式吗?Linux系统中编译链接的基石——ELF文件:剥去它的层层外衣,从字节码的粒度来探究那篇文章。LinuxELF格式的可执行文件。其实Windows中的程序格式(PE格式)也是类似的,和ELF格式是一脉相承的。1、程序头(Header)的描述信息为了描述方便,我们假设程序包括3个段:代码段、数据段和堆栈段,加上程序头信息,一共4个组成部分。如下图:为什么中间有一个空白?因为每个段并不是相邻排列的,为了段地址内存对齐(16字节对齐),段与段之间可能会有空格。这些空间中的数据是无效的。刚才说了,为了让bootloader尽可能的了解自己,程序文件会在它的Header部分详细描述自己的信息:有了这样的描述信息,bootloader就知道自己需要读取多少内容总共。一个字节程序文件,跳转到哪个位置以允许操作系统指令开始执行。2.关于汇编地址在程序的头信息中,可以看到汇编地址和偏移量等信息。编译器在编译源代码的时候,并不知道bootloader会把程序加载到内存的什么地方。bootloader会检查哪个位置有足够的空间,找到一个可用的位置后,就会把操作系统程序读到这个位置,可以看成是一个动态过程。因此,编译器在编译阶段用来定位变量、标签等??的地址是相对于当前段的起始地址计算的。以刚才的图为例:假设Header部分为32字节,三个段的起始地址为:代码段addrCodeStart:0x00020(距离文件的第一个字节为32字节);datasegmentaddrDataStart:0x01000(距离文件的第一个字节为4KBytes);stacksectionaddrStackStart:0x01200(距离文件的第一个字节为4K+512Bytes);在代码段中,定义了一个标号label_1,它距离代码段的开头(0x00020)是512字节(0x0200)。同时可以计算出,从文件开头算起第一个字节为512+32=544字节,因为代码段的起始地址是从文件开头算起32字节。在label_1之前的代码中,会引用这个标签。然后在使用到的地方填充0x0200,意思是:引用的位置距代码段起始地址512字节。以上地址均指汇编地址。我们以程序入口地址的偏移量为例。入口地址由起始标号定义:假设:在代码段中,入口地址标号start位于距离代码段开头的偏移量0x0100处,即距离代码段开头的距离为256起始位置的字节。然后,在程序的Header信息中,入口点偏移的位置必须填充0x0100。在这种情况下,bootloader将程序读入内存后,可以从这里获取程序入口点的偏移地址,然后通过一系列的重定位就可以准确地跳转到程序的第一条指令去执行。根据刚才假设的地址信息,程序头中的信息如下:最右边的蓝色字体表示每一项占用的字节数,共24字节。前面说了每个段的起始地址是按照16字节对齐的,所以在Header之后,应该还剩下8字节的空间,之后就是代码段的起始地址(0x00020=32Bytes)。bootloader把程序从硬盘读到内存1.读到内存哪里?在bootloader从硬盘读取操作系统文件到内存之前,它必须决定一件事:文件要存放在内存中的位置在哪里?从上一篇文章我们了解到,在阅读操作系统之前,内存布局模型如下:注:这是8086系统中20条地址线可以寻址的1MB地址空间。前64KB映射到ROM中的BIOS程序。最下面的1KB地址空间,从0开始,是存放256个中断向量(下一篇要讲中断)。中间从地址0x07C00开始的地方是存放BIOS从硬盘引导区读取的bootloader程序的地方。黄色部分的空间一共是640KB,全部映射到RAM,所以有足够的空闲地址空间来存放操作系统程序文件。假设:bootloader决定从地址0x20000(128KB)开始存放从硬盘读取的操作系统程序文件。2、bootloader设置数据段的基地址从硬盘读取文件,是以扇区为读取单位,即每次读取一个扇区(512字节)。至于如何通过指定扇区号和发送端口命令从硬盘中读取数据,这是另外一个话题,所以我们暂且关注bootloader。对于bootloader来说,读取操作系统文件等同于读取普通数据。既然已经决定存储从地址0x20000读取的数据,引导加载程序必须将数据段寄存器ds设置为0x2000。这种情况下,通过逻辑地址的计算公式:物理地址=逻辑段地址*16+偏移地址得到正确的物理地址,例如:读取的第一个扇区的数据放在:0x2000:0x0000地址;第二个扇区读取的数据放在:0x2000:0x0200地址;3个扇区的数据放在地址0x2000:0x0400;读到的第10个扇区的数据放在地址:0x2000:0x1200;3.引导加载程序读取所有扇区。bootloader需要把操作系统程序的所有内容读入内存时,需要读入的长度是多少?这个信息在程序文件的Header中,所以bootloader需要读取程序文件的第一个扇区,也就是512字节,放在0x20000的开头。我们继续假设:程序总长度为5K字节(0x01400),那么将程序文件的前512字节(第一个扇区)读入内存,如下所示:注:这是内容thefile读入内存的布局,bottom为低地址,top为高地址,与上面描述的静态文件内容顺序相反。读取第一个扇区后,可以取出从0x20000:0x01400开始的4字节数据,得到程序文件的总长度:5K字节。每个扇区为512字节,5KB为10个扇区。第一个扇区已经读取完毕,需要继续读取剩下的9个扇区。因此,bootloader依次读取所有扇区的数据:0x2000:0x0000,0x2000:0x0200,0x2000:0x0400,...0x2000:0x1200地址。4、程序文件超过64KB怎么办?这里扩展一个问题来思考:8086段寻址方式,由于偏移寄存器的长度是16位,所以最多只能表示64KB的空间。在我们假设的例子中,程序文件只有5KB,完全可以包含在一个数据段中,所以bootloader总是可以使用0x2000:offset来读取文件内容。如果程序的长度是100KB,超过了偏移量的最大寻址空间64KB,bootloader应该怎么做才能将这100KB的程序正确读入内存呢?答:可以在读取文件的过程中读取,动态增加数据段的逻辑地址。例如读取前一个64KB数据(sector1~sector128)时,段寄存器ds设置为0x2000。在读取第65KB数据(129扇区)之前,设置段寄存器ds为0x3000,这样读取的数据将从0x3000:0x0000开始存储。代码重定位现在,操作系统程序已经被读入内存,下一步就是:跳转到操作系统的程序入口点去执行!ProgramentrypointrelocationTheoffsetoftheprogramentrypointhasbeenrecordedIntheHeader(0x04~0x05bytes,orangepart):入口点起始标号在Header中记录的代码段中的偏移量为0x100,即入口点距代码段的起始地址256个字节。同理,代码段中的所有相关地址都是相对于代码段的起始地址计算的。因此,如果(这是如果)bootloader直接把代码段的起始地址(不是整个文件的开始)放在内存的地址0x00000处,那么代码段的所有地址都不需要修改,并且可以直接设置:cs=0x0000,ip=0x0100,这样就直接跳转到start标签开始执行。不幸的是,引导加载程序将操作系统程序读取到地址0x20000开始的地方。所以需要把代码段寄存器cs设置为当前代码段在内存中的实际起始位置,也就是下面五角星的位置:上面两个可以多读几遍!在Header中,数据0x00020、0x06、0x07、0x08、0x09这4个字节,是从代码段开始到程序文件开始的字节数。只要把这个值(0x00020)加上文件存储的起始地址(0x20000),就可以得到代码段起始地址在物理内存中的绝对地址:0x00020+0x20000=0x20020即:起始地址代码段,位于物理内存中的位置0x20020。对于一个物理地址,我们可以用多种不同的逻辑地址来表示,例如:0x20020=0x2002:0x00000x20020=0x2000:0x00200x20020=0x1FF0:0x0120面对这3个选择,我们当然选择第一个,我们可以只选择第一个,因为代码段内部的所有地址偏移在编译时都是以地址0为基准的(也就是上面说的汇编地址),或者叫相对地址。明白了这个道理之后,就可以把cs:ip设置为0x2002:0x0100,这样CPU就会在开始标号处执行。但是,在此操作之前还有其他几件事需要处理。因此,代码段的逻辑段地址0x2002应该写回Header中的4字节0x06~0x09来保存(橙色部分):Segmenttablerelocation此时,系统仍然处于bootloader的控制之下,而数据段寄存器ds还是0x2000。想想为什么?因为bootloader希望在读取操作系统程序的第一个扇区之前将数据读入物理磁盘。在地址0x20000处右移一位得到逻辑段地址0x2000,写入数据段寄存器ds。我们一直忽略引导加载程序使用的堆栈空间,因为这部分与文档的主题无关。操作系统程序要想执行,就必须使用自己程序文件中的数据段和堆栈段。但是Header中记录的两个段的起始地址是相对于程序文件的开头的。而操作系统文件并不知道:引导加载程序在内存中的哪个位置读取了它?因此bootloader也需要重新计算这两个段在内存中的起始地址,然后更新到Header中。这样,当操作系统程序开始执行时,就可以从Header中得到数据段和堆栈段的逻辑段地址。当然,这里给出的例子只有3个段,实际程序中可能包含很多段,每个段的地址都需要重定位。从Header的0x0A~0x0B这2个字节,bootloader可以得到需要重定位多少个段地址。然后依次读取每个段的偏移地址,加上程序文件的加载地址(0x20000),计算出实际的物理地址,然后得到逻辑段地址,如下:代码段偏移0x00020:0x20000+0x00020=0x20020(物理地址),向右移动一位得到逻辑段地址:0x2002;数据段偏移量0x0x01000:0x20000+0x01000=0x21000(物理地址),右移一位得到逻辑段地址:0x2100;堆栈段偏移0x0x01200:0x20000+0x01200=0x21200(物理地址),右移一位得到逻辑段地址:0x2120;下图中橙色部分:我们画出了代码段、数据段、栈段在内存中的所有布局模型Exit:跳转到程序的入口地址。万事俱备,万事俱备。最后一步是进入操作系统程序中代码段的起始入口点。在上述准备工作中,bootloader已经将程序代码段的逻辑段地址0x2002保存在Header中的0x06~0x09这4个字节中,只需将其赋值给代码段寄存器cs即可。程序入口点位于起始标号处,从代码段开始偏移0x100,存放在Header中的2字节0x04~0x05,只要赋值给指令指针寄存器ip即可。我们可以手动从内存中读取并分配给cs和ip寄存器。在8086CPU中也可以直接使用这条指令:jmp[0x04]来实现cs:ip的赋值。因为还在bootloader的控制下,数据段寄存器ds的值还是0x2000,所以跳转到内置0x2000:0x04指示的地址,可以分配正确的逻辑段地址和指令地址tocs:ip,从而开始执行操作系统程序的第一条指令。操作系统程序的执行当操作系统的第一条指令执行时,数据段寄存器ds和栈段寄存器cs中的值仍然是bootloader中设置的值。因此,操作系统必须先将这两个段寄存器设置为自己程序文件的值,然后再开始执行后续指令。如上所述,内存中每个段的逻辑段地址已经被bootloader重新计算并更新到Header中。因此,操作系统可以从ds:0x14位置读取新的堆栈段逻辑地址0x2120,并将其赋值给堆栈段寄存器cs。从此以后,所有的栈操作都是操作系统程序自己的。注意:此时数据段寄存器ds仍然没有变化,仍然是bootloader中使用的0x2000。然后从ds:0x10读取新的数据段逻辑地址0x2100,赋值给数据段寄存器ds。从此以后,所有的数据操作都是操作系统程序自己的了。注意:给cs和ds赋值的顺序不能颠倒。如果你先给ds赋值,那么当你读到Header中cs的逻辑段地址时,就定位不到了。因为ds寄存器已经指向了新地址(ds=0x2100),所以没有办法从地址0x2000:0x14获取数据。最后,还有一点。对于栈操作,除了设置栈的段寄存器cs外,还需要设置栈顶指针寄存器sp。我们假设程序中设置的栈空间为512字节,栈顶指针向低位地址增长,因此需要将sp初始化为512。至此,操作系统程序终于可以开始执行了快乐!在本文中,我们描述了代码重定位的基本原则。以后学习Linux中的重定位,会接触到更多的概念和技术,但底层的基本原理是一样的。希望这篇文章能成为你前进路上的一块垫脚石!本文转载自微信公众号“IOT物联网小镇”,可通过以下二维码关注。转载本文请联系物联小镇公众号。