理解汇编语言

本文通过机器码的角度围绕CALL指令来讲解系统编程的一些底层概念。 这些基础概念是进一步理解dynamic linker, 逆向以及debug的基础。

以下以典型的RSIC为例,即指令长度为固定4字节,如alpha,arm,mips。 x86的指令长度是变化的,下文中有部分内容不适应与此架构。

理解汇编最基本的是要了解ISA,ISA包含的指令数量从几十条到上千条。

但一般的ISA指令都可以归类为以下6类

  1. 算术运算指令 ADD, SUB等
  2. 逻辑运算指令 OR, NOT, XOR等
  3. 浮点运算指令 一般指令都是针对整数进行运算,浮点数是由单独的指令进行提供。
  4. 转移指令 一般CPU都是一条指令一条指令的顺序执行,仅有少量几条指令如JMP, CALL, RET可以 让CPU调到指定地址执行。
  5. 存储指令 LOAD, STORE等与RAM进行交互的指令。 (x86下可以通过算术运算等直接进行RAM操作)
  6. 杂项指令 如CPUID获取CPU信息,PMU,SYSCALL等特定场景需要用到的指令。

本文以CALL为点,帮助大家理解汇编的其他面

函数调用

函数调用通过CALL和RET来完成。

我们可以把CALL理解为一个带参数的函数,如

  1. mips平台是JAL main JAL是带一个参数的, 参数1为”目的函数”,这里为”main”。
  2. alpha平台是CALL R26, main CALL为带两个参数的函数,用来让CPU执行子函数的函数。 参数1为,用来保存结束后的返回地址的通用寄存器 参数2为”目的函数”,这里为”main”。

为何alpha需要保存返回地址,而mips不需要? 这个返回地址的作用是?

先说返回地址的作用,CPU对外的逻辑实际是非常简单的,仅从执行顺序上说。 CPU在执行完当前这条指令后,就从一个内部维护的寄存器PC(有时称作IP)读取下一条指令 的地址。正常情况下PC的更新都是PC+4,也就是指向下一条指令地址。 在使用CALL指令时,在逻辑上可以理解为,就是修改了PC寄存器的内容。 如果ISA允许直接操作PC寄存器,那甚至可以不需要CALL指令,从而实现跳转指令。

当我们通过CALL修改了PC值后,待子程序执行到最后一条指令(假设)遇到RET指令,此时 RET指令必须正确修改PC指向前面CALL指令地址的下一条指令。

因此在执行CALL指令时必须保存下一条指令地址,以便RET可以正常返回。而不同CPU选择的 策略不同,造成ISA的”API”也不同。

  • mips选择使用固定的R31作为存储这个地址的寄存器,这样RET也就是固定的从R31读取地址
  • alpha选择让程序员自己指定地址,默认情况下是使用R26。
  • x86则是通过保存这个地址到stack上,这样RET在返回时就从固定的stack位置读取返回地址。

mips以及alpha采用寄存器来保存这个地址会面临一个问题,如果有以下的调用关系,在mips下

int main()
{
   a(); //此时R31更新为0x1000
   return 0  //假设地址为0x1000
}

//R31==0x1000
void a()
{
   b(); //此时R31更新为0xa000
   return;   //假设地址为0xa000。 
   //此处RET会使用0xa000进行返回,也就是当前地址。从而进入死循环
}

也就是在有函数嵌套的情况下,R31会被后面的CALL覆盖,从而导致RET无法正确返回。 要解决这个问题,只能在进入函数前,类似x86的方式把R31保存在stack上。

由此我们会看RSIC这类ISA虽然寄存器数量比较多,专门弄一个寄存器来保存这个地址。 但必须用stack来保护这个寄存器地址,从而最终和x86一样实际的返回地址也是保存在 stack上了。 这里涉及到一个leaf function的优化,如果我们的a函数里并没有再 调用任何CALL指令,则不需要对R31进行保护,从而不需要把返回地址存储在低速的RAM上。

这个R31有一个专门的名字Link register(LR),*是某个时间点上PC的备份*。 类似的还有一个frame pointer(FP)的概念,*是某个时间点上SP的备份*。

PC加上SP再配合LR,FP就能实现抽象的函数调用概念,理解他们是非常有必要的,这里不 再展开。

指令编码

仅以alpha来讲解,每条指令的长度都是4字节32位。 CALL指令属于RRI格式,即分为4部分

    |Opcode| Ra  | Rb  |  disp          |
    |31  26|25 21|20 16|15             0|
  • opcode占5为,为固定值,假设为0x1
  • Ra为第一个参数,也就是所使用的LR存储器ID。占4位,一共可以编码32个寄存器ID
  • Rb为第二个参数,也就是存储目标函数的地址。
  • disp可以为任意值,不影响功能,实际是提供CPU额外信息进行转移移预测,此处不用关心。

也就是

CALL R26, R12

转换为机器码就是

1  << 26 + //opcode的值
25 << 21 + //Ra的id(R26), id从0开始编码
11 << 16 + //Rb的id(R12), id从0开始编码
0  << 0    //disp的值
//==0x72b0000,使用小端存储为byte就是
//0x00,0x00,0x2b,0x07

实际上的指令编码和文章开始说的6类指令类型有一定对应关系,每个CPU有自己的编码格式,但种类 都不会太多。

了解指令编码格式并非让大家去人肉写机器码,主要目的是

  1. 减少机器码的神秘感,增加掌握汇编的信息。
  2. 了解编码格式的限制,从而更容易理解一些上层逻辑。

重定向

上面说道编码格式的限制,这里具体讲解一个涉及到重定向的地方。

了解编码格式后,我们知道了存放CALL指令第二个参数的是一个长度只有4位的空间,也就不可能 存储”main”这种字符串了,这些字符串我们一般称作symbol,如果main代表一个函数,则特称 其为function symbol。

在编写function symbol时我们使用字符串来标示,在编译的过程时,编译器会将其转换为对应 函数body的第一条指令的地址,简单起见不考虑PIC的情况,这个地址就是此函数第一条指令相对于elf文件 的起始地址。

一般的编译阶段分为

  1. source code —asm-—> object file
  2. object files or library —link-—> target file

第一阶段是针对单独一个源文件,(汇编文件或C语言文件都一样)通过asmber来生成一个.o文件, 得到所有源文件的.o后,再通过linker合并为一个target file.

//假设存储为a.c
void main()
{
   printf("hello world\n");
}

//假设存储为b.c
void fnB(char* s)
{
  puts(s);
}

//a.c写为伪汇编,则为
//假设存储为a.s
TEXT main
   MOVE "hello world", R1 //存储第一个参数
   CALL R26, fnB     //调用b.c中的fnB
   RET

接来下,请大家先好好思考一个问题。 “fnB”这个function symbol的值在哪一个阶段可以计算出出来?

  1. a.c的第一阶段完成后
  2. b.c的第一阶段完成后
  3. 第二阶段中

这个问题涉及到非常一个非常重要的概念”重定向”。正确答案为3,即使 在b.c的第一阶段我们也无法计算出fnB的地址。 依次说明

  1. a.c的第一阶段,此时编译器还未见过fnB,所以自然无法计算fnB的地址
  2. b.c的第一阶段,此时虽然已经定义了fnB,但fnB是存放在b.o里的,此时计算 fnB的偏移和最终target file里的fnB的实际偏移是不太可能一样。
  3. 此时a.o和b.o都已经有了,所以已经可以合并为最终的a.out了,因此fnB的最终 地址可以在合并完成后计算出来。

请确保能理解以上内容。

以上实时揭露一个问题,在a.c的第一阶段,asmber遇到`CALL R26, fnB`时是无法 编码出正确的机器码,因为fnB的地址还不知道。

asmber只能把这些工作推迟到后面的阶段进行处理,这类工作最主要的就是类似fnB编码的 重定向问题。

一般asmber会把Rb位置的内容写为0,并在.o文件的header地方写上一条关于这个0的信息, 以便后面的linker可以修正这个0。

什么时候需要写0,在哪里找到这类0的,0代表的值有几位,header在哪里以及怎么写, linker用什么算法计算正确值,这些就组成了ELF,PE这类标准规范。

实际上linker分为staic linker和dynamic linker,我们一般听到的relocation,linker 等都是后者。前者在编译结束后就已经没有作用了,这类重定向信息也会从在生成target file后 就丢弃了。 所以使用objdump观察.o文件时,需要小心看到的指令里的一些值可能不是最终值,可以通过 readelf -r xx.o确认对应地址里是否包含会被修改的值。

宏指令

这里的宏指令和C语言的宏没有任何关系。 请大家结合指令编码再次思考`CALL R26, fnB`这条指令。

fnB这个function symbol的值为一个地址,假设此CPU架构的内存地址宽度为64位。 那么fnB的值是需要64位存储空间来保存的,而一条指令最多只能编码32位的信息,所以这个地址值一定不是存储 在CALL这条指令的机器码里。 实际上前面也见过地址是存储在Rb这个位置的,假设这里Rb为R12。

但R12的值是如何计算的呢?

MOVE 0xa000abccc(fnB的地址) //R12这样是否可以?

实际上也是不行的,因为MOVE也只能使用32位来存储所有的信息,而地址是64位的。 因此只能通过把64位地址拆分为多个部分使用多条指令进行构造。类似这样

MOVH 0xa, R12 //存储高32位地址到R12
ADD  0xabccc, R12 //将R12加上低32位地址

实际上,一条指令也是存储不下一个32位地址的,因为至少得留一位给opcode使用。

虽然可以继续拆分为更小的单位来解决指令编码的限制, 不过alpha确实是拆分为2条指令来处理的,这里涉及到两个常见的信息。

  1. 指令地址一定是以4字节对齐的,也就是低2位永远为0(因为一条指令的长度固定是32位),从而低2位的信息是 不需要进行编码的。
  2. 64位下CPU可以访问的地址空间并非全部的64地址,实际可用的地址信息受硬件以及kernel的限制,alpha 下实际可用的地址范围是43位。

因此43-2=39,拆分为两条指令,每条指令只要能编码18位的地址信息就能满足需求。

这样一个函数调用至少得3条指令才能完成,还要进行移位等操作,因此一般汇编语言会提供宏指令来自动生成这些 信息。宏指令就是看着像一条指令,实际上会被拆分为一条或多条,甚至会生成重定向信息的指令。 在使用ptrace,kprobe等非常底层的情况下,需要了解这些信息,以便计算出正确的地址位置。

寻址方式

这里只讨论和CALL相关的”PC相对寻址”以及”绝对寻址”, 寻址指的是目标对象地址的获取方式,函数调用的情况下 就是获取目标函数第一条指令的内存地址。

前面说的CALL属于绝对寻址,也就是目的地址已经存储在了Rb里面,但它需要3条指令来完成。 另外还有一类类似CALL的指令BR。他们对外的接口基本是一样的,不同之处就在BR是使用的相对 寻址。也就是只存储偏目的地址相对于BR这条指令地址的差值信息。 在执行BR这条指令时,其地址可以通过PC寄存器获得。因此类似if这种语句就可以通过BR使用一条 指令来编码地址信息。(实际下通过CALL也是可以的)

考虑到编码长度的限制,PC相对寻址能寻址的范围一定不大,而且还要考虑正负数的情况,alpha下 地址信息能使用的实际空间是21位。如果目标地址在同一个.o文件内,则可以直接通过BR来进行函数 调用,因为编码的信息是相对地址,在生成target file时,这个值依旧是正确的。

BR可以更高效,但如果最终的target file太大,则会超出其编码能力。

发表评论

电子邮件地址不会被公开。 必填项已用*标注