Linker and Loader 读书笔记 二 体系结构相关的问题
链接器和加载器的功能注定了这两个系统级的软件必须与软硬件的体系结构有非常相关的联系。链接器最终会把多个目标文件链接在一起创建一个可以执行的程序,在这个过程中要包括符号解析,重定位等操作,这些操作必然会跟遵循软硬件平台的体系结构。就硬件平台来说,有两个方面会影响到链接器:
程序寻址和指令格式。链接器需要做的事情之一就是对数据和指令中的地址及其偏移量都要进行修改。而且:
确保所做的修改符合计算机使用的寻址方式;
当修改指令时还需要进一步确保修改结果不能是无效指令。
ABI: 应用程序二进制接口
引用维基百科对于ABI的定义: In computer software, an application binary interface (ABI) describes the low-level interface between a computer program and the operating system or another program。 是指操作系统下规定的运行在该系统下的底层的二进制接口。
ABI包括什么呢?
ABIs cover details such as:
- the sizes, layout, and alignment of data types
- the calling convention, which controls how functions' arguments are passed and return values retrieved; for example, whether all parameters are passed on the stack or some are passed in registers, which registers are used for which function parameters, and whether the first function parameter passed on the stack is pushed first or last onto the stack
- how an application should make system calls to the operating system and, if the ABI specifies direct system calls rather than procedure calls to system call stubs, the system call numbers
- and in the case of a complete operating system ABI, the binary format of object files, program libraries and so on.
翻译成中文:
- 数据类型的大小,布局,以及对齐方式。
- 调用约定,规定了包括 函数参数的传递和返回值如何获取。比如, 是否是所有的参数都会压栈;还是部分是在寄存器中,部分压栈,寄存器中存储那些参数,以及压栈的顺序等。
- 一个应用程序如何用系统调用,以及系统调用的方式,是直接调用还是通过API来调用,还有系统调用的数量。(使用系统调用的方法)
- 一个完整的OS ABI 还包括 目标文件的二进制格式, 库的格式等等。
从一个应用程序的角度看,ABI既是系统架构的一部分也是硬件体系结构的重点,因此只要违反二者之一的条件约束就会导致程序出现严重错误。以此来说,链接器当然要符合ABI的规定,遵循ABI所定义的方式。
Big Endian and Little Endian
关于大小端的问题,是链接器必须注意的问题。
大小端怎么去区别呢。简单的来说就是低地址为存放大的数值为大端,低地址存放小的值为小端。举例来说明,如果一个32位的整形数,大小端的layout分别是:
Big Endian:
byte addr 0 1 2 3
bit offset 01234567 01234567 01234567 01234567
binary 00001010 00001011 00001100 00001101
hex 0a 0b 0c 0d
Little Endian:
byte addr 3 2 1 0
bit offset 76543210 76543210 76543210 76543210
binary 00001010 00001011 00001100 00001101
hex 0a 0b 0c 0d
这个例子是以数:0X0a0b0c0d 为例,表示存放在内存里的顺序。不难发现,大小端问题不仅是Byte order的问题,还是bit order的问题,如上所示,bit order 往往跟随Byte order的顺序。CPU用定义的端的byte order 以及bit order来解释来自片上寄存器,本地总线,cache,内存 等的多字节类型数据。
指令格式、寻址方式、及过程调用
关于这三方面的内容都是cpu指令体系结构的部分,也是ABI的一部分。程序存储在内存中,其代码和数据的存储位置是不一样的,cpu通常是顺序的读取指令执行,当然除了分支指令和循环指令。这两种指令都包含跳转操作。不管是指令要操作的数据的地址,还是指令要跳转的地址都会被链接器在重定位指令中的地址时处理,而且这时要遵循cpu的寻址方式。 指令的寻址方式通常包括直接寻址(把操作数的地址直接包含在指令里),间接寻址(把操作数的地址存放在寄存器里),以及基址寻址和相对寻址(基址寻址是 把寄存器作为base地址 加上一个数组成目的地址;而相对寻址则是数存放在寄存器里 而基址包含在指令中)。
关于CPU的指令格式,链接器要注意的是有关影响指令和数据地址的指令格式,比如在数据引用和跳转指令中,有的cpu使用相同的指令格式,有的cpu使用不同的指令格式,而intel cpu则是有的相同有的不同。关于指令格式总是分为指令码和操作数,指令码是代表要操作的意义,而操作数是要操作的参数。有的cpu采用变长指令格式,有的cpu采用固定长度的指令格式,
过程调用:每种ABI都通过将硬件定义的调用指令与内存、寄存器的使用约定组合起来定义了一个标准的过程调用序列。硬件的调用指令保存了返回地址(调用执行后的指令地址)并跳转到目标过程。在诸如x86这样具有硬件栈的体系结构中返回地址被压入栈中,而在其它体系结构中它会被保存在一个寄存器里,如果必要软件要负责将寄存器中的值保存在内存中。具有栈的体系结构通常都会有一个硬件的返回指令将返回地址推出栈并跳转到该地址,而其它体系结构则使用一个“跳转到寄存器中地址”的指令来返回。
在一个过程中,数据寻址可以分为四类:
- 调用者可以向过程传递参数。
- 本地变量在过程中分配,并在过程返回前释放。
- 本地静态数据保存在内存的固定位置中,并为该过程私有。
- 全局静态数据保存在内存的固定位置中,并可被很多不同的过程调用。
为每个过程调用分配的一块栈内存称为“栈框架(stack frame)”。下图显示了一个典型的栈框架。
参数和本地变量通常在栈中分配空间,某一个寄存器可以作为栈指针,它可以基址寄
存器来使用。SPARC和x86中使用了该策略的一种比较普遍的变体,在一个过程开始的时候,
会从栈指针中加载专门的框架指针或基址指针寄存器。这样就可以在栈中压入可变大小的对
象,将栈指针寄存器中的值改变为难以预定的值,当仍使过程的参数和本地变量们仍然位于
相对于框架指针在整个过程执行中都不变的固定偏移量处。如果假定栈是从高地址向低地址
生长的,而框架指针指向返回地址保存在内存中的位置,那么参数就位于框架指针较小的正
偏移量处,本地变量在负偏移量处。由于操作系统通常会在程序启动前为其初始化栈指针,
所以程序只需要在将输入压栈或推栈时更新寄存器即可。
对于局部和全局静态数据,编译器可以为一个例程引用的所有静态变量创建一个指针表。如果某个寄存器存有指向这个表的指针,那么例程可以通过使用表指针寄存器将对象在
表中的指针读取出来,加载到另一个使用表指针寄存器作为基址的寄存器中,并将第二个寄存器做为基址寄存器来寻址任何想要访问的静态目标。因此,关键技巧是表的地址存入到第一个寄存器中。在SPARC上,例程可以通过带有立即操作数的一系列指令来加载表地址,同时在SPARC或者370上例程可以通过一系列子例程调用指令将程序计数器(保存当前指令地址的寄存器)加载到一个基址寄存器,虽然后面我们还会讨论这种方法在对待库代码时会遇到问题。一个更好的解决方法是将提取表指针的工作交给例程的调用者,因为调用者已经加载了自己的表指针,并可以从自己的表中获取被调用例程的表的指针。
表中的指针读取出来,加载到另一个使用表指针寄存器作为基址的寄存器中,并将第二个寄存器做为基址寄存器来寻址任何想要访问的静态目标。因此,关键技巧是表的地址存入到第一个寄存器中。在SPARC上,例程可以通过带有立即操作数的一系列指令来加载表地址,同时在SPARC或者370上例程可以通过一系列子例程调用指令将程序计数器(保存当前指令地址的寄存器)加载到一个基址寄存器,虽然后面我们还会讨论这种方法在对待库代码时会遇到问题。一个更好的解决方法是将提取表指针的工作交给例程的调用者,因为调用者已经加载了自己的表指针,并可以从自己的表中获取被调用例程的表的指针。
一个典型的例程调用序列。Rf是框架指针,Rt是表指针,Rx是临时寄存器。调用者将自己的表指针保存到自己的栈框架中,然后将被调用例程的地址和它的指针表地址
载入到寄存器中,再进行调用。被调用的例程可以通过Rt中的表指针找到它需要的所有数据,包括它随后要调用的例程的地址和表指针。
---------------------------------------------------------------------------------------------
理想的调用过程
... 将参数压入堆栈 ...
store Rt xxx(Rf) ; save caller’s table pointer in caller’s stack frame
load Rx MMM(Rt) ; load address of called routine into temp register
load Rt NNN(Rt) ; load called routine’s table pointer
call (Rx) ; call routine at address in Rx
load Rt xxx(Rf) ; restore caller’s table pointer
---------------------------------------------------------------------------------------------
有一些优化方法经常是可能有用的。很多情况下,在一个模块中的所有例程会共享一个指针表,这时模块内的调用不需要改变表指针。SPARC的约定是整个模块共享一个由链接器创建的表,这样表指针寄存器可以在模块内调用时保持不变。同一模块内的调用可以通过一个将被调用例程的偏移量编码到指令中的调用指令实现,这就不需要再将被调用例程的地址加载到寄存器中了。在所有这些优化中,同一模块中对某个例程的调用序列缩减为一个单独的调用指令。
载入到寄存器中,再进行调用。被调用的例程可以通过Rt中的表指针找到它需要的所有数据,包括它随后要调用的例程的地址和表指针。
---------------------------------------------------------------------------------------------
理想的调用过程
... 将参数压入堆栈 ...
store Rt xxx(Rf) ; save caller’s table pointer in caller’s stack frame
load Rx MMM(Rt) ; load address of called routine into temp register
load Rt NNN(Rt) ; load called routine’s table pointer
call (Rx) ; call routine at address in Rx
load Rt xxx(Rf) ; restore caller’s table pointer
---------------------------------------------------------------------------------------------
有一些优化方法经常是可能有用的。很多情况下,在一个模块中的所有例程会共享一个指针表,这时模块内的调用不需要改变表指针。SPARC的约定是整个模块共享一个由链接器创建的表,这样表指针寄存器可以在模块内调用时保持不变。同一模块内的调用可以通过一个将被调用例程的偏移量编码到指令中的调用指令实现,这就不需要再将被调用例程的地址加载到寄存器中了。在所有这些优化中,同一模块中对某个例程的调用序列缩减为一个单独的调用指令。
以下以X86和PowerPC 两种CPU为例,说明其指令格式,寻址方式,和调用过程。