现在高级语言”横行“,阅读汇编的能力似乎已经不那么重要。但当我们查看 core dump和有时候追求极致性能的时候,汇编还是要读一读。本文尝试以阅读简单汇编为出发点,阐述一些基本技巧。
本文是 如何看懂程序Crash系列 之一。
跟高级语言相比,汇编晦涩难懂。但是它们是有规律可循的,并且有技巧可以帮忙。只要明白这些规律和技巧,阅读基本的汇编很容易。下面让我们来看看这些规律和技巧是什么。
汇编是什么
机器只能读懂二进制指令,而汇编是一组特定的字符,它们映射到二进制指令,用于方便记忆和编写二进制指令。比如 move rax, rdx 就是我们常见的汇编。汇编指令经过汇编器(assembler)转变成二进制指令。
现在高级语言会编译到汇编的有C/C++, Go, Rust。 其他比如Java,C#,会编译到虚拟机指令而不是直接的汇编。 (虚拟机指令与汇编有许多共同的地方)
什么时候会跟汇编打交道
当我们调试release版本的时候,debug symbols已经被去掉了或者根本没有。
当程序crash的时候,只有一个core dump,而且crash的地方不明所以。
当我们想要研究语言的一个高级特性性能如何。
等等,这时候我们都要深入研究一下汇编,去读懂汇编背后的逻辑。
下面让我们先看几个简单的汇编例子。
简单的汇编示例
通过简单的汇编感受一下汇编。
mov rbp, rsp 将寄存器rsp的值存储到寄存器 rbp 中。
mov DWORD PTR [rbp-4], 4 将四个字节的4存储到地址为 rbp-4 的栈上。(什么是4个字节的4?就是0x00000004,大小为四个字节)
sub rsp, 16 将rsp的值减去16。
上面的汇编格式Intel的语法。常见的汇编有两种语法,一种是Intel,另一种是AT & T。Intel的格式是 opcode destination, source ,类似于语法 int i = 4;而AT & T的格式是 opcode source, destination ,直观理解为 move from source to destination。
上面Intel的汇编,如果改写成AT & T,则为
movq %rsp, %rbp
movl $4, -4(%rbp)
subq $16, %rsp
AT & T的汇编另外一个特点是有前缀比如 %,$ 。指令还有后缀 q,l ,等等。这些前缀后缀有特殊的意思,后文会讲解。不同的格式侧重点不太一样,你可以选择你喜欢的格式。
如何一通百通汇编
阅读汇编的关键点是函数调用结构,数据传输方式,常见控制结构,具体指令功能。
函数调用结构
让我们以下面的简单代码的为例,
// 代码1 int square ( int num ) { return num * num ; } int main ( ) { int i = 4 ; int j = square ( i ) ; }
看看它对应的汇编
//汇编1 square(int): push rbp mov rbp, rsp mov DWORD PTR [rbp-4], edi mov eax, DWORD PTR [rbp-4] imul eax, eax pop rbp ret main: push rbp mov rbp, rsp sub rsp, 16 mov DWORD PTR [rbp-4], 4 mov eax, DWORD PTR [rbp-4] mov edi, eax call square(int) mov DWORD PTR [rbp-8], eax mov eax, 0 leave ret
sqaure 和 main 前面的 push rbp 和 mov rbp, rsp 又叫做函数的序言(prologue),几乎每个函数一开始都会有的指令。它和函数最后的 pop rbp 和 ret (epilogue)起到维护函数的调用栈的作用。首先让我们看看什么是函数的调用栈。
程序都是一个函数(称为caller)调用另外一个函数(称为callee),这么嵌套下去(callee在调用其他函数的时候,自己就变成了caller,其他函数是它的callee)。
为了在执行完callee的时候可以跳转回调用的地方(caller调用callee的下一个指令),程序会以栈的方式维护着函数的调用关系。具体是,每个函数都会对应一个frame(栈帧),这个frame包含了用于恢复到caller的信息和当前函数用于计算的数据(又称局部变量)。
见下图,这些用于恢复的信息,包含返回地址,caller(previous frame)的 rbp 。(注意顺序是先push 了返回地址,然后是 rbp ,如下图灰色和绿色的框框。)函数调用的时候,callee的frame就会叠加在已有的frame上面,像一个盘子放在另外一个盘子上面,形成调用栈。蓝色背景是callee的frame,橙色背景是caller的frame。
(注意:栈是向着低地址的方向生长)
函数的调用栈,是理解汇编的第一道坎。第二道坎是函数的调用习惯(calling convention)也就是函数参数的存储和传递方式。为了理解第二道坎,我们要先看看数据的传递。
数据的传递
函数在计算的时候,存储数据的地方总共有三个,寄存器,内存和程序本身。寄存器的个数和名字取决于具体的计算机架构,本文以x86-64为例子。内存分为栈空间和堆(heap)空间,静态区。程序本身是指只读的程序数据片段,比如 int i = 4 ,这个4存储于程序本身,在汇编里面又叫立即数(immediate number)。
知道了数据的存储地方,那么数据的传递就分为以下四个方面
从内存到寄存器
从寄存器到内存
从立即数到寄存器,
从立即数到内存
注意 :数据不能从内存直接传递到内存。如果需要从内存传递到内存,要以寄存器为中介。(这些知识,还是我当年大学学的计算机组成原理里面的)
数据是有大小的,比如 一个word是两个字节,double words是四个字节 。所以传递数据的时候,要知道传递的数据大小。Intel的汇编会在数据前面说明数据大小,比如 mov DWORD PTR [rbp-4], 4 ,意思是将一个4字节的4存储到 栈上(地址为rbp-4)。而AT & T是通过指令的后缀来说明,同样的指令为 movl $4, -4(%rbp) 。而存储的地方,AT & T汇编是通过前缀来区别,比如%q前缀表示寄存器,$表示立即数,()表示内存。
了解了数据的传递方式,那么让我们看看函数的调用习惯。
函数的调用习惯(calling convention)
caller调用callee,要将参数(arguements)传递给callee。一个函数可以接收多个参数,而caller与callee之间约定的每个参数的应该怎么传递就是调用习惯。这样子,callee就会到指定的位置获取相应的参数。
比如一开始的main调用square。参数i如何传递到square里面?通过阅读上面的汇编,我们可以知道在main里面,4先存到栈上,然后存在edi里面,而sqaure函数直接从edi里面读取4的值。这说明了,参数4是通过寄存器edi传给了calle (sqaure) 。可能有读者会以为,从代码看,参数不是直接就传给了sqaure吗。实际上,在汇编,这个变量i是不存在的,只有寄存器和内存。我们需要约定好i的值存在哪里。
下面让我们看看这些约定:常见寄存器负责传递的参数以及一些作用
注意 :
浅蓝色的是callee-owned。棕色背景的是caller-owned。callee-owned表明如果caller要使用这些寄存机,那么它在调用callee前,要把这些寄存器保存好。caller-owned表明如果callee要使用这些寄存器,那么它就要保存好这些寄存器的值,并且返回到caller的时候要将这些值恢复。
一共有六个通用的寄存器用于传递参数。按顺序传递需要通用寄存器传递的参数,如果通用寄存器使用完了,那么就使用栈来传递(第一张图)。详细的规则记录于Effective Debugging这本书里面。将另外用一篇文章来详细说明每个参数是如何传递的。
一共16个通用寄存机,两个特殊寄存器。前6个参数和返回值寄存器是callee-owned, callee可以自由地使用这些寄存器,覆盖已有的值。如果%rax的值,caller想要保留,那么在调用函数之前,calleer需要赋值这个值到“安全”的地方。callee-owned的寄存器是callee理想的操作用具。相反,如果callee想要使用caller-owned的寄存器,那么它必须先保留原来的值,并且在退出调用时还原原来的值。caller-owned的寄存器通常用于caller需要在函数之间保留的局部状态。
有眼尖的读者会发现,汇编1里面,第一个参数是用edi来传递的,为什么这里是rdi?因为rdi是8字节的,4字节的时候对应的就是edi。
如果函数返回比较大的对象,那么第一个参数rdi会用来传递存储这个对象的地址。这个地址是由caller分配的。有了这些基础,那么你就可以理解C++里面的copy elision了,可以挑战一下 Copy/move elision: C++ 17 vs C++ 11
常见控制结构
这个对于入门的程序员很容易理解。控制结构就是if, while循环等等。在汇编里面,它们都是基于判定语句,跳转语句: 做一个计算,检查相应的flag,然后根据flag的值确定要跳转到哪里 。比如下面的If语句
// 代码2 if ( j > 6 ) { std:: cout << j * 2 ; } else { std:: cout << j * 3 ; }
对应的汇编如下, cmpl $6, -8(%rbp) 根据对比结果,修改对应的标志位。下一行汇编 jle .L4 检查对应的标志位,如果less and equal to 6,那么就跳转到 .L4 ,如果不是,就继续执行。
# 汇编2 cmpl $6, -8(%rbp) jle .L4 movl -8(%rbp), %eax addl %eax, %eax movl %eax, %esi movl $_ZSt4cout, %edi call std::basic_ostream<char, std::char_traits<char> >::operator<<(int) jmp .L5 .L4: movl -8(%rbp), %edx movl %edx, %eax addl %eax, %eax addl %edx, %eax movl %eax, %esi movl $_ZSt4cout, %edi call std::basic_ostream<char, std::char_traits<char> >::operator<<(int) .L5: movl $0, %eax leave ret
指令
对于指令,可以直接搜索得知具体的指令的作用,所以就不一一介绍了。讲讲一点小窍门。
CMP destination, source;JBE .L3 是指如果destination <= source则跳转到.L3。
技巧
Compiler Explorer 这个网站会显示代码对应的汇编并且进行了相应的颜色匹配,非常方便查看汇编。而且鼠标点击相应的汇编还会告诉提示,比如这个汇编是干什么的。所以我们可以借助这个网站来阅读汇编。
实际的例子
查看crash的地方
下面我将用gdb一步一步探索当初在产品里的core dump。写下来,也是为了以后我再次遇到相似的问题可以有参考。
某天,产品crash了,生成了core dump, 于是我们可以用命令 gdb <exec> <core> 来加载core dump。
加载完core dump以后,截取crash附近的汇编如下
0x00007ff967434cac <+188>: test %eax,%eax 0x00007ff967434cae <+190>: js 0x7ff967434d53 <_ZN20ImportPackageUtility18hCreateUndoPackageERSt6vectorIhSaIhEE+355> 0x00007ff967434cb4 <+196>: mov 0x0(%rbp),%rax 0x00007ff967434cb8 <+200>: mov %rbp,%rdi 0x00007ff967434cbb <+203>: callq *0x110(%rax) => 0x00007ff967434cc1 <+209>: mov (%rax),%rdx
我们可以看到crash的地方是 move (%rax), %rdx ,那么我们查看寄存器rax的内容,发现是0。加上这个core dump是segment fault,那么crash的理由大概是访问了空指针。
接着,我们看看rax是怎么来的。上一条指令是 call *0x110(%rax) ,猜想是访问虚函数,想知道直觉是怎么来的,请看 怎么理解C++虚函数?fat pointer in GO/Rust vs thin pointer in C++ 。
那么我们可以看看这个虚函数是什么。首先要知道现在rax的值。根据mov 0x0(%rbp), %rax,我们可以知道,rax等于rbp存储的值,所以用下面的命令查看rbp存储的内容
(gdb) x/gx $rbp 0xc5089950: 0x00007ff95dc5f308
接着,我们计算虚函数的地址为: p/x 0x110+$rax = p/x 0x110 + 0x00007ff95dc5f308
得到地址为0x00007ff95dc5f308,接着就可以查看这个地址存储的虚函数是什么 (x/gx 0x7ff95dc5f418),发现是GetStream。所以我们可以知道GetStream返回了空指针。接下来我们就要查看产品代码看看为什么会返回空指针。如果是正常的空指针,那么说明crash的地方要检查指针,如果不是正常的情况,那么我们就要修相应的地方。
全部的命令放到一块就是
(gdb) p/x $rbp $103 = 0xc5089950 (gdb) x/gx 0xc5089950 0xc5089950: 0x00007ff95dc5f308 (gdb) p/x 0x00007ff95dc5f308+0x110 $104 = 0x7ff95dc5f418 (gdb) info symbol 0x7ff95dc5f418 vtable for mpl + 288 in section .data.rel.ro of xx.so (gdb) x/gx 0x7ff95dc5f418 0x7ff95dc5f418 <_ImplE+288>: 0x00007ff95da4dc60 (gdb) info symbol 0x00007ff95da4dc60 Impl::GetStream() in section .text of xx.so
已知虚指针,打印虚函数表
set $i = 0 set $addr = <vtable address fromm info var classname> while $i < 10 p $i p /a *((void**)($addr)) set $addr = $addr + 8 set $i = $i + 1 end
练习题
题目一、请找出下面函数 void g(s* p,int j) 的参数传递
g(s*, int): push rbp mov rbp, rsp sub rsp, 32 mov QWORD PTR [rbp-24], rdi mov DWORD PTR [rbp-28], esi mov rax, QWORD PTR [rbp-24] mov eax, DWORD PTR [rax+16] mov esi, eax mov edi, OFFSET FLAT:_ZSt4cout call std::basic_ostream<char, std::char_traits<char> >::operator<<(int) mov eax, DWORD PTR [rbp-28] mov DWORD PTR [rbp-4], eax mov eax, DWORD PTR [rbp-4] mov esi, eax mov edi, OFFSET FLAT:_ZSt4cout call std::basic_ostream<char, std::char_traits<char> >::operator<<(int) nop leave ret main: push rbp mov rbp, rsp sub rsp, 32 mov DWORD PTR [rbp-32], 7 movsd xmm0, QWORD PTR .LC0[rip] movsd QWORD PTR [rbp-24], xmm0 mov DWORD PTR [rbp-4], 4 mov edx, DWORD PTR [rbp-4] lea rax, [rbp-32] mov esi, edx mov rdi, rax call g(s*, int) mov eax, 0 leave ret __static_initialization_and_destruction_0(int, int): push rbp mov rbp, rsp sub rsp, 16 mov DWORD PTR [rbp-4], edi mov DWORD PTR [rbp-8], esi cmp DWORD PTR [rbp-4], 1 jne .L6 cmp DWORD PTR [rbp-8], 65535 jne .L6 mov edi, OFFSET FLAT:_ZStL8__ioinit call std::ios_base::Init::Init() [complete object constructor] mov edx, OFFSET FLAT:__dso_handle mov esi, OFFSET FLAT:_ZStL8__ioinit mov edi, OFFSET FLAT:_ZNSt8ios_base4InitD1Ev call __cxa_atexit .L6: nop leave ret _GLOBAL__sub_I_g(s*, int): push rbp mov rbp, rsp mov esi, 65535 mov edi, 1 call __static_initialization_and_destruction_0(int, int) pop rbp ret .LC0: .long 1717986918 .long 1074423398
源码是
// Type your code here, or load an example. # include <iostream> class s { public : int i ; double d ; int j ; } ; void g ( s * p , int j ) { std:: cout << p -> j ; int k = j ; std:: cout << k ; } int main ( ) { s s1; s1. i = 7 ; s1. d = 3.3 ; int i = 4 ; g ( & s1 , i ) ; }
题目二、找出下面Sum参数传递的寄存器或者栈
class POD_STRUCT { public : short s ; int a ; double d ; } ; class NONE_POD_STRUCT { virtual bool Verify ( ) { return true ; } public : short s ; int a ; double d ; } ; double Sum ( int i_int0 , int i_int1 , POD_STRUCT i_pod, NONE_POD_STRUCT i_nonpod, long * ip_long , float i_float , long i_long0 , long i_long1 ) { double result = i_int0 + i_int1 + i_pod . a + i_pod . d + i_pod . s + i_nonpod . a + i_nonpod . d + i_nonpod . s + * ip_long + i_float + i_long0 + i_long1 ; int j = i_nonpod . a + 3 ; return result + j ; } int main ( ) { int a_int_0 = 0 ; int a_int_1 = 1 ; POD_STRUCT a_pod = { 5 , 1 , 2.2 } ; NONE_POD_STRUCT a_nonpod; long a_long = 3 ; float a_float = 4.4 ; int j = a_nonpod . a + 4 ; double sum = Sum ( a_int_0 , a_int_1 , a_pod , a_nonpod , & a_long , a_float , a_long , a_long ) ; return 0 ; }
答案是下图
参考文献
《高效C/C++调试》清华大学出版社
X86-64 Architecture Guide
http://www.cs./~evans/cs216/guides/x86.html#calling
https://github.com/hjl-tools/x86-psABI/wiki/x86-64-psABI-1.0.pdf