原创 爱写程序的阿波张 源码游记 2019-04-20
汇编语言是每位后端程序员都应该掌握的一门语言,因为学会了汇编语言,不管是对我们调试程序还是研究与理解计算机底层的一些运行原理都具有非常重要的作用,所以建议有兴趣的读者可以多花点时间把它学好。
与高级编程语言一样,汇编语言也是一门完整的计算机编程语言,它所涉及的知识内容也很多,好在我们的主要目标是通过对本小节的学习而有能力去读懂汇编代码,而不是要用汇编语言去写代码,所以本节并不会全面介绍汇编语言,而只会选取汇编语言的一个子集–汇编指令出来做介绍。不过,虽然这里的介绍做了精简,但读者大可放心,熟练运用这些知识就足以应付本书将要分析的goroutine调度器中的汇编代码了。
说到汇编指令,不得不提一下机器指令,二进制格式的机器指令才是CPU能够理解的语言,因为它是二进制格式的,非常便于CPU的解析和执行,但并不利于人类阅读和交流,所以才有了跟机器指令一一对应的汇编指令,汇编指令使用符号来表示机器指令,下面的例子非常直观的说明了这两种指令之间的差异:
0x40054d : add %rdx,%rax // 汇编指令
(gdb) x/3xb 0x40054d 0x40054d : 0x48 0x01 0xd0 // 机器指令 (gdb) 同样是把rdx和rax寄存器中的值相加,汇编指令为:add %rdx,%rax,而机器指令却是三个数字:0x48 0x01 0xd0,显然,汇编指令对人类来说更加友好,它更加易记易读和易写。
汇编指令格式
因为不同的CPU所支持的机器指令不一样,所以其汇编指令也不同,即使是相同的CPU,不同的汇编工具和平台所使用的汇编指令格式也有些差别,由于本书主要专注于AMD64 Linux平台下的go调度器,因此下面我们只介绍该平台下所使用的AT&T格式的汇编指令,AT&T汇编指令的基本格式为:
操作码 [操作数] 可以看到每一条汇编指令通常都由两部分组成:
操作码:操作码指示CPU执行什么操作,比如是执行加法,减法还是读写内存。每条指令都必须要有操作码。
操作数:操作数是操作的对象,比如加法操作需要两个加数,这两个加数就是这条指令的操作数。操作数的个数一般是0个,1个或2个。
来看几个汇编指令的例子
add %rdx,%rax 这条指令的操作码是add,表示执行加法操作,它有两个操作数,rdx和rax。如果一条指令有两个操作数,那么第一个操作数叫做源操作数,第二个操作数叫做目的操作数,顾名思义,目的操作数表示这条指令执行完后结果应该保存的地方。所以上面这条指令表示对rax和rdx寄存器里面的值求和,并把结果保存在rax寄存器中。其实这条指令的第二个操作数rax寄存器既是源操作数也是目的操作数,因为rax既是加法操作的两个加数之一,又得存放加法操作的结果。这条指令执行完后rax寄存器的值发生了改变,指令执行前的值被覆盖而丢失了,如果rax寄存器之前的值还有用,那么就得先用指令把它保存到其它寄存器或内存之中。
再来看一个只有一个操作数的例子:
callq 0x400526
这条指令的操作码是callq,表示调用函数,操作数是0x400526,它是被调用函数的地址。
最后来看一条没有操作数的指令:
retq 这条指令只有操作码retq,表示从被调用函数返回到调用函数继续执行。
为了更好的理解AT&T格式的汇编指令,这里先对其格式做一个简要的说明:
-
AT&T格式的汇编指令中,寄存器名需要加%作为前缀,前面我们已经见过;
-
有2个操作数的指令中,第一个操作数是源操作数,第二个是目的操作数,刚才也讨论过,不过那条指令中的源和目的不是那么清晰,来看一个直白的,mov %eax,%esi,这条指令表示把eax寄存器中的值拷贝给esi,这条指令中源和目的就很清楚了;
-
立即操作数需要加上$符号做前缀,如 “mov $0x1 %rdi” 这条指令中第一个操作数不是寄存器,也不是内存地址,而是直接写在指令中的一个常数,这种操作数叫做立即操作数。这条指令表示把数值0x1放入rdi寄存器中。
-
寄存器间接寻址的格式为 offset(%register),如果offset为0,则可以略去偏移不写直接写成(%register)。何为间接寻址呢?其实就是指指令中的寄存器并不是真正的源操作数或目的操作数,寄存器的值是一个内存地址,这个地址对应的内存才是真正的源或目的操作数,比如 mov %rax, (%rsp)这条指令,第二个操作数(%rsp)中的寄存器的名字用括号括起来了,表示间接寻址,rsp的值是一个内存地址,这条指令的真实意图是把rax寄存器中的值赋值给rsp寄存器的值(内存地址)对应的内存,rsp寄存器本身的值不会被修改,作为比较,我们看一下 mov %rax, %rsp 这条指令 ,这里第二个操作数仅仅少了个括号,变成了直接寻址,意思完全不一样了,这条指令的意思是把rax的值赋给rsp,这样rsp寄存器的值被修改为跟rax寄存器一样的值了。下面的2张图展示了这两种寻址方式的不同:
执行mov %rax, %rsp这条指令之前,rsp寄存器的值是x,rax寄存器的值是y,执行指令之后,rax寄存器的值被复制给了rsp寄存器,所以rsp寄存器的值变成了y,可以看出,采用直接寻址方式时,目的操作数rsp寄存器的值在指令执行之前和指令执行之后发生了变化,源操作数没有变化。再看看间接寻址方式的示意图:
执行mov %rax, (%rsp)这条指令之前,rax寄存器的值是y,rsp寄存器的值是X,它是一个内存地址,如上图所示,我们用了一个红色箭头从rsp寄存器指向了地址为X的内存;执行指令之后,rsp寄存器的值并没有发生变化,而rsp所指的内存中的值却发生了改变,因为这条指令的目的操作数采用了间接寻址方式(%rsp),指令执行的结果是rax寄存器中的值被复制到了rsp寄存器存放的地址所对应的8个内存单元中。另外需要注意的是指令中出现的内存地址仅仅是起始地址,具体要操作以这个地址为起始地址的连续几个内存单元要根据具体的指令而定,比如上图中的mov %rax,(%rsp),因为源操作数是一个64位的寄存器,所以这条指令会复制rax存放的8个字节到地址为X, X+1, X+2, X+3, X+4, X+5, X+6, X+7这8个内存单元中去。
间接寻址格式offset(%register)中前面的offset表示偏移,如-0x8(%rbp),-0x8就是偏移量,整个表示rbp寄存器里面保存的地址值先减去8(因为偏移是负8)得到的地址对应的内存。
- 与内存相关的一些指令的操作码会加上b, w, l和q字母分别表示操作的内存是1,2,4还是8个字节,比如指令 movl $0x0,-0x8(%rbp) ,这条指令操作码movl的后缀字母l说明我们要把从-0x8(%rbp) 这个地址开始的4个内存单元赋值为0。可能有读者会问,那如果我要操作3个,或5个内存单元呢?很遗憾的是cpu没有提供相应的单条指令,我们只能通过多条指令组合起来达到目的。
常用指令详解
x86-64汇编指令上千条,这里不会去详细讲解每一条,读者如果有兴趣可以参考汇编语言相关教程。我们在这里着重关注几条非常常见或是能帮助我们理解程序运行机制的指令。
mov指令
mov 源操作数 目的操作数 该指令复制源操作数到目的操作数。例:
mov %rsp,%rbp # 直接寻址,把rsp的值拷贝给rbp,相当于 rbp = rsp mov -0x8(%rbp),%edx # 源操作数间接寻址,目的操作数直接寻址。从内存中读取4个字节到edx寄存器 mov %rsi,-0x8(%rbp) # 源操作数直接寻址,目的操作数间接寻址。把rsi寄存器中的8字节值写入内存 add/sub指令
add 源操作数 目的操作数 sub 源操作数 目的操作数 加减运算指令。例:
sub $0x350,%rsp # 源操作数是立即操作数,目的操作数直接寻址。rsp = rsp - 0x350 add %rdx,%rax # 直接寻址。rax = rax + rdx addl $0x1,-0x8(%rbp) # 源操作数是立即操作数,目的操作数间接寻址。内存中的值加1(addl后缀字母l表示操作内存中的4个字节) call/ret指令
call 目标地址 ret call指令执行函数调用。CPU执行call指令时首先会把rip寄存器中的值入栈,然后设置rip值为目标地址,又因为rip寄存器决定了下一条需要执行的指令,所以当CPU执行完当前call指令后就会跳转到目标地址去执行。
ret指令从被调用函数返回调用函数,它的实现原理是把call指令入栈的返回地址弹出给rip寄存器。
下面用例子对这两条指令的原理加以说明。
#调用函数片段
0x0000000000400559 : callq 0x400526
#被调用函数片段
0x0000000000400526 : push %rbp
……
0x000000000040053f : retq
上面代码片段中,调用函数使用callq 0x400526指令调用0x400526处的函数,0x400526是被调用函数的第一条指令所在的地址。被调用函数在0x40053f处执行retq指令返回调用函数继续执行0x40055e地址处的指令。注意这两条指令会涉及入栈和出栈操作,所以会影响rsp寄存器的值。
从上图可以看到call指令执行之初rip寄存器的值是紧跟call后面那一条指令的地址,即0x40055e,但当call指令完成后但还未开始执行下一条指令之前,rip寄存器的值变成了call指令的操作数,即被调用函数的地址0x400526,这样CPU就会跳转到被调用函数去执行了。
同时还需要注意的是这里的call指令执行时把call指令后面那一条指令的地址 0x40055e PUSH到了栈上,所以一条call指令修改了3个地方的值:rip寄存器、rsp和栈。
下面我们再看看从被调用函数返回调用函数时执行的ret指令,其示意图如下:
可以看到ret指令执行的操作跟call指令执行的操作完全相反,ret指令开始执行时rip寄存器的值是紧跟ret指令后面的那个地址,也就是0x400540,但ret指令执行过程中会把之前call指令PUSH到栈上的返回地址 0x40055e POP给rip寄存器,这样,当ret执行完成后就会从被调用函数返回到调用函数的call指令的下一条指令继续执行。这里同样要注意的是retq指令也会修改rsp寄存器的值。
jmp/je/jle/jg/jge等等j开头的指令
这些都属于跳转指令,操作码后面直接跟要跳转到的地址或存有地址的寄存器,这些指令与高级编程语言中的 goto 和 if 等语句对应。用法示例:
jmp 0x4005f2 jle 0x4005ee jl 0x4005b8 push/pop指令
push 源操作数 pop 目的操作数 专用于函数调用栈的入栈出栈指令,这两个指令都会自动修改rsp寄存器。
push入栈时rsp寄存器的值先减去8把栈位置留出来,然后把操作数复制到rsp所指位置。push指令相当于:
sub $8,%rsp mov 源操作数,(%rsp)
push指令需要重点注意rsp寄存器的变化。
pop出栈时先把rsp寄存器所指位置的数据复制到目的操作数中,然后rsp寄存器的值加8。pop指令相当于:
mov (%rsp),目的操作数 add $8,%rsp
同样,pop指令也需要重点注意rsp寄存器的变化。
leave指令
leave指令没有操作数,它一般放在函数的尾部ret指令之前,用于调整rsp和rbp,这条指令相当于如下两条指令:
mov %rbp,%rsp pop %rbp
AMD64汇编我们就介绍这么多,下一节我们将介绍go runtime中使用的go汇编语言,它与这里介绍的AMD64汇编类似,但有一些差别。理解了本节的内容,go汇编也就很容易理解了。