【38岁持续学习】-阅读简单汇编来理解自己的代码,从而找到可优化的思路

1 篇文章 0 订阅
1 篇文章 0 订阅

前序:最近要去广州工作了,复习基础知识,看到内存和代码优化这块时,发现要想深入了解代码是怎么工作的,必须要学会看下反汇编指令,然后找到一些大佬用vs调试反汇编查的小技巧

工具:Visual Studio 2022

库:.net6

开始:

基础概念:(原文:如何阅读简单的汇编(持续更新) - 知乎 (zhihu.com)

汇编是什么

机器只能读懂二进制指令,而汇编是一组特定的字符,它们映射到二进制指令,用于方便记忆和编写二进制指令。比如move rax, rdx就是我们常见的汇编。汇编指令经过汇编器(assembler)转变成二进制指令。

现在高级语言会编译到汇编的有C/C++, Go, Rust。其他比如Java,C#,会编译到虚拟机指令而不是直接的汇编。(虚拟机指令与汇编有许多共同的地方)

什么时候会跟汇编打交道

当我们调试release版本的时候,debug symbols已经被去掉了或者根本没有。

当程序crash的时候,只有一个core dump,而且crash的地方不明所以。

当我们想要研究语言的一个高级特性性能如何。(看公众号有个大佬写的几篇.net7的性能提升几期文章,里面都是根据反汇编指令来讲解.net7升级的新特性的

等等,这时候我们都要深入研究一下汇编,去读懂汇编背后的逻辑。

下面让我们先看几个简单的汇编例子。

简单的汇编示例

通过简单的汇编感受一下汇编。

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 rbp 和mov 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是两个字节16bit(1byte=8bit),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

工具操作:

 1,打开vs,新建一个控制台程序.Net6的,然后随便写几行例子代码,打开F5调试

2,快捷键CTRL+K,G准到反汇编

 红框里就是我们代码的汇编代码

对了,别忘记把寄存器和内存窗口打开来:

打开寄存器窗口:快捷键CTL+ALT+G

打开内存窗口:调试--窗口--内存--窗口1

随便选一个内存地址复制到地址栏就看到改地址上的16进制内存数据,cpu是Intel I5,我电脑是64位机器,下面寄存器列出了

RAX = 000000000000001E  传递返回值地址000000000000001E=十进制30

RBX = 00000083F1DEE7D8 临时变量

RCX = 000000000000000A 第四个参数

RDX = 0000000000000014 第三个参数

RSI = 00000083F1DEE798 第二个参数

RDI = 0000000000000001 第一个参数

R8  = 00007FF94004F43B 第五个参数

R9  = 0000008300000001 第六个参数

R10 = 00000FFF2800FD78 

R11 = 00000083F1DEE5B0

R12 = 00000083F1DEE750 临时变量

R13 = 00000083F1DEE8B0 临时变量

R14 = 00000083F1DEE998 临时变量

R15 = 0000000000000008 临时变量

RIP = 00007FF8E06B42EE 存储吓一条要执行的指令

RSP = 00000083F1DEE690 栈顶指针

RBP = 00000083F1DEE690 栈基指针

EFL = 00000206  (eflags) flags 和条件判断结果标志位

然后我自己的理解,来解释下嘿嘿:

00007FF8E06B42A0  push        rbp  
00007FF8E06B42A1  push        rdi  
00007FF8E06B42A2  push        rsi  
00007FF8E06B42A3  sub         rsp,40h  
00007FF8E06B42A7  mov         rbp,rsp  # 序言(prologue)
00007FF8E06B42AA  vxorps      xmm4,xmm4,xmm4 # xor的另一种写法 异或 归零 比如 5^5=0 任何数异或自己都是0 ,自己搜索vxorps是向量寄存器清零 也就是gcc
00007FF8E06B42AE  vmovdqa     xmmword ptr [rbp+30h],xmm4  #是__m128 数据类型,该数据类型映射到xmm[0-7]寄存器,所以这里我理解是xmm4寄存器地址数据拷贝到xmmword ptr 类型的[rbp+30h]上(h我认为是个后缀而已)__m128 | Microsoft Learn
00007FF8E06B42B3  mov         qword ptr [rbp+60h],rcx  # 初始化8字节存储空间 把第四个参数存放到 qword ptr[rbp+60h] DQ | Microsoft Learn
00007FF8E06B42B7  cmp         dword ptr [7FF8E05BACC8h],0 # 同理 对比 化4字节存储空间 和0  DWORD | Microsoft Learn
00007FF8E06B42BE  je          看汇编.Program.Main(System.String[])+025h (07FF8E06B42C5h)  #  根据上面cmp的结果,判断这里是否跳转到 main方法执行(这里理解的不是很清楚,请懂得大佬指教)
00007FF8E06B42C0  call        00007FF9401CD1C0  # 调用方法
00007FF8E06B42C5  nop  
            int i = 10;
00007FF8E06B42C6  mov         dword ptr [rbp+3Ch],0Ah   # 0A=10 把10 存放到rbp+3Ch寄存器地址
            int j = 20;
00007FF8E06B42CD  mov         dword ptr [rbp+38h],14h  # 14=20 把20存放到 rbp+38h地址
            int k=sum(i,j);
00007FF8E06B42D4  mov         ecx,dword ptr [rbp+3Ch]  # ecx(rcx是8字节,现在变为4字节) 存放参数
00007FF8E06B42D7  mov         edx,dword ptr [rbp+38h]  # 存放参数
00007FF8E06B42DA  call        方法存根对象: 看汇编.Program.sum(Int32, Int32) (07FF8E050E3E0h)   #调用sum方法
00007FF8E06B42DF  mov         dword ptr [rbp+2Ch],eax  # eax 是把sum方法的计算结果返回值 存放到寄存器空间rbp+2Ch ,也就是caller从callee拿回eax寄存器的值
00007FF8E06B42E2  mov         eax,dword ptr [rbp+2Ch]  # 从内存拷贝寄存器
00007FF8E06B42E5  mov         dword ptr [rbp+34h],eax  # 从寄存器拷贝到新的一块内存空间
            int m = k;
00007FF8E06B42E8  mov         eax,dword ptr [rbp+34h]  # 为了传给m,把内存数据再次读出到eax寄存器
00007FF8E06B42EB  mov         dword ptr [rbp+30h],eax  # 寄存器中再次移动到内存地址,这个地址是上面vmodqa的寄存器地址 我猜测是用来通知垃圾回收的地址,懂的大佬请指正

使用心得:

自己也是初学,首先大致了解汇编的模样了,然后知道了调用习惯和数据传递,然后能自己调试看下反汇编的样子,能看懂一些,但是还需要多熟悉,多用。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值