分享

如何阅读简单的汇编

 imnobody2001 2024-03-25 发布于江苏

现在高级语言”横行“,阅读汇编的能力似乎已经不那么重要。但当我们查看 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

sqauremain前面的push rbpmov rbp, rsp又叫做函数的序言(prologue),几乎每个函数一开始都会有的指令。它和函数最后的pop rbpret(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)。

知道了数据的存储地方,那么数据的传递就分为以下四个方面

  1. 从内存到寄存器

  2. 从寄存器到内存

  3. 从立即数到寄存器,

  4. 从立即数到内存

注意:数据不能从内存直接传递到内存。如果需要从内存传递到内存,要以寄存器为中介。(这些知识,还是我当年大学学的计算机组成原理里面的)

数据是有大小的,比如一个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的值存在哪里。

下面让我们看看这些约定:常见寄存器负责传递的参数以及一些作用

图片

注意

  1. 浅蓝色的是callee-owned。棕色背景的是caller-owned。callee-owned表明如果caller要使用这些寄存机,那么它在调用callee前,要把这些寄存器保存好。caller-owned表明如果callee要使用这些寄存器,那么它就要保存好这些寄存器的值,并且返回到caller的时候要将这些值恢复。

  2. 一共有六个通用的寄存器用于传递参数。按顺序传递需要通用寄存器传递的参数,如果通用寄存器使用完了,那么就使用栈来传递(第一张图)。详细的规则记录于Effective Debugging这本书里面。将另外用一篇文章来详细说明每个参数是如何传递的。

  3. 一共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)的参数传递



p


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

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多