前言

去年在看协程相关内容的时候,通读了腾讯的libco。其中切换协程的一小段代码是用汇编实现的,当时没有去搞明白这一块。最近趁着项目空闲查阅了x64汇编的相关资料,这篇博客打算总结一下,方便以后查阅。

本篇主要翻译自 Introduction to X86-64 Assembly for Compiler Writers  由于是入门教程,原文中函数调用部分的描述和实际所出入, 我补上了相关的内容。废话不多说,下面走你~~

概要

这是一篇简明的X86-64汇编语言介绍,虽然不是完整详尽的描述,但足够帮助你读懂官方文档。

X86-64指令集的详细文档Intel-64 and IA-32 Architectures Software Developer Manuals 。读完这篇如果你还需要继续深入的话,可以阅读手册的以下部分:

开源汇编工具

以下例子。我们将会使用GNU GCC的编译器和汇编器,了解汇编语言最快的方式就是查看编译器输出的汇编代码。为了产生汇编代码,使用gcc 的-S 参数,这样编译器会产生汇编代码。在Unix-like系统中,汇编代码的文件后缀名是.s,下面我们从hello world程序开始,有一个test.c 如下:

 

使用 gcc -S hello.c 会产生汇编文件hello.s,他长这样:

 

汇编代码分为三种元素:

我们知道C的编译的过程一般分为:

上面我们使用gcc -s 产生汇编代码的过程其实已经完成了预处理和编译的过程,下面我们使用 gcc -o完成汇编和链接的过程产生可执行程序:

 

将汇编代码汇编成目标代码(Assembling)也很有趣,然后可以使用nm来显示代码中的符号:

 

这里显示链接器可用的信息,main位于.text(T)的位置0处。并且printf未定义(U):在产生的hello.o 对象中并没有定义printf这个函数。链接的时候,连接器会帮我们找标准库中的printf。你可能也看到了,没有出现像LC0,LFB0等这类的标签,正如我前面说的那样:因为它们是编译器生成的局部标签,没有被声明为.globl。

现在你已经知道使用什么工具了,让我们开始详细了解汇编指令,GO。

补充知识:AT&T语法和Intel语法

GUN GCC使用传统的AT&T语法,它在Unix-like操作系统上使用,而不是dos和windows系统上通常使用的Intel语法。下面我们看一个最常见的AT&T语法的指令:

 

movl是一个最常见的汇编指令的名称,百分号表示esp和ebp是寄存器,在AT&T语法中,有两个参数的时候,始终先给出源(source),然后再给出目标(destination)。
在其他地方(例如英特尔手册),您将看到英特尔语法,区别之处是Intel语法省去了百分号并颠倒了参数的顺序。例如,这是intel语法中的相同指令:

 

在阅读手册和网页时,通过看有没有”%”就知道是用的哪种汇编格式了。

寄存器和数据类型

X86-64有16个通用(几乎都是通用的)64位整数寄存器:

%rax %rbx %rcx %rdx %rsi %rdi %rbp %rsp %r8 %r9 %r10 %r11 %r12 %r13 %r14 %r15

为什么说几乎都是通用寄存器?因为早期处理器的不同寄存器的用途不同,并不是所有的指令都可以应用于每个寄存器。(现在基本上都是通用寄存器了)随着设计的发展,增加了新的指令和寻址模式,使各种寄存器几乎相等。除了几条特殊的指令,比如与字符串处理有关的指令,需要使用%rsi和%rdi,另外,还有两个寄存器保留用作堆栈指针(%rsp)和基址指针(%rbp)。最后的八个被编号的寄存器没有特别的限制。除此之外还有一个特殊的寄存器%rip(instruction pointer),一般情况下你不需要关注这个寄存器,但是这个寄存器很重要,你需要知道他的功能,后面在说函数调用时会提及。

多年来X86架构已经从8位扩展到了32位,因此每个寄存器都有一些内部结构如下图:

      %ah
8 bits
%al
8 bits
      %ax
16 bits
  %eax
32 bits
%rax
64 bits

为了简单起见,下面的讲述会把注意力集中在64位寄存器上。不过你要知道,大多数生产编译器使用混合模式:32位寄存器通常用于整数算术,因为大多数程序不需要整数值超过2 ^ 32(例如在我的ubuntu 64bit使用gcc 64位的编译器, sizeof(int)的值为4)。64位寄存器通常用于保存存储器地址(指针),从而可以寻址最多16EB(exa-bytes)的虚拟内存。

寻址模式

什么是寻址模式?就是数据在内存寄和寄存器之间进行移动时,取得数据地址的不同表达方式。最常用的寻址的汇编指令是mov。x86-64使用的是复杂指令集(cisc),因此mov有许多不同的变体,可以在不同单元之间移动不同类型的据。mov与大多数指令一样,具有单字母后缀,用于确定要移动的数据量。下表用于描述各种大小的数据值:

前缀 全称 Size
B BYTE 1 byte (8 bits)
W WORD 2 bytes (16 bits)
L LONG 4 bytes (32 bits)
Q QUADWORD 8 bytes (64 bits)

So,MOVB移动一个字节,MOVW移动一个字,MOVL移动一个长整形,MOVQ移动一个四字。一般来说,MOV指令移动数据的大小必须与后缀匹配。虽然可以忽略后缀,汇编器将尝试根据参数选择合适的MOV指令。但是,不推荐这样做,因为它可能会造成预料之外的结果。

对于AT&T语法使用MOV寻址时需要两个参数,第一个参数是源地址,第二个参数是目标地址。原地址的表达方式不一样那么寻址的方式也就不一样。比如,访问全局变量使用一个简单变量的名称比如x,我们称之为全局符号寻址。printf一个整数常量,由美元符号+数值表示(例如$ 56),我们称之为直接寻址。访问寄存器的值直接使用寄存器的名字如%rbx,我们称之为寄存器寻址。如果寄存器中存放的是一个地址,访问这个地址中的数据时需要在寄存器外面加上括号如(%rbx),我们称之为间接寻址。如果寄存器中存放的是一个数组的地址,我们需要访问数组中的元素时可能需要操作这个地址进行偏移,如8(%rcx)是指%rcx中存放的的地址加8字节存储单元的值,我们称之为相对基址寻址(此模式对于操作堆栈,局部变量和函数参数非常重要)。在相对基址寻址上有各种复杂的变化,例如-16(%rbx,%rcx,8)是指地址-16 +%rbx +%rcx * 8处的值。此模式对于访问排列在数组中的特殊大小的元素非常有用。

以下是使用各种寻址模式将64位值加载到%rax的示例:

寻址模式 示例
全局符号寻址(Global Symbol) MOVQ x, %rax
直接寻址(Immediate) MOVQ $56, %rax
寄存器寻址(Register) MOVQ %rbx, %rax
间接寻址(Indirect) MOVQ (%rsp), %rax
相对基址寻址(Base-Relative) MOVQ -8(%rbp), %rax
相对基址偏移缩放寻址(Offset-Scaled-Base-Relative) MOVQ -16(%rbx,%rcx,8), %rax

通常,可以使用相同的寻址模式将数据存储到寄存器和内存。但是,并不是所有模式都支持。比如不能对MOV的两个参数都使用base-relative mode。像MOVQ -8(%rbx),-8(%rbx)这样是不行的。要准确查看支持哪些寻址模式组合,您必须阅读相关指令的手册。

基础运算指令

编译器需要四个基本的算术指令: ADD, SUB, IMUL, 和IDIV(加减乘除)。add和sub有两个操作数:一个来源值和一个被操作数。例如,这条指令:

 

将%rbx添加到%rax,并将结果放在%rax中,覆盖之前可能存在的内容。这就要求你在使用寄存器时要小心。
例如,假设你想计算 c = b *(b + a),其中a和b是全局整数。要做到这一点,你必须小心,在执行加法时不要覆盖b的值。这里有一个实现:

 

IMUL指令有点不太一样:它将其参数乘以%rax的内容,然后将结果的低64位放入%rax,将高64位放入%rdx。(将两个64位数字相乘会产生一个128位数字。)

IDIV指令和乘法指令差不多:它以一个128位整数值开始,其低64位位于%rax,高位64位位于%rdx中,并将其除以参数。(CDQO指令用于将%rax符号扩展为%rdx,以便正确处理负值。)商被放在%rax中,余数放在%rdx中。例如,除以5:

 

(大多数语言中的求模指令只是利用%rdx中剩余的余数。)

指令INC和DEC分别递增和递减寄存器。例如,语句a = ++ b可以翻译为:

 

布尔操作的工作方式非常类似:AND,OR,和XOR指令需要两个参数,而NOT指令只需要一个参数。

像MOV指令一样,各种算术指令可以在各种寻址模式下工作。但是,您可能会发现使用MOV将值载入和载出寄存器是最方便的,然后仅使用寄存器来执行算术运算。

补充知识:浮点数

虽然我们不会详细介绍浮点操作,但您至少应该知道它们是由一组不同的指令和一组不同的寄存器处理完成的。
在较老的机器中,浮点指令由一个称为8087 fpu的可选(外部)芯片处理,因此这些功能经常被描述为x87操作,尽管现在这些功能已经集成到了cpu中。

x87 fpu包含八个80位寄存器(r0-r7)排列在一个堆栈中。要执行浮点运算,代码必须将数据推送到fpu堆栈,发出隐式操作堆栈顶部的指令,然后将这些值写回到内存中。在内存中,双精度数字存储占用64位。

这种架构的一个奇怪之处在于内部表示(80位)比内存中的表示(64位)具有更高的精度。因此,浮点计算值可能会发生变化,具体取决于值在寄存器和内存之间移动的精确顺序!

浮点数相关内容的推荐读物:

比较和跳转指令

使用JMP指令,我们可以创建一个简单的无限循环,使用%eax寄存器从零开始计数:

 

为了定义更有用的程序结构,如终止循环和if-then等语句,我们必须有一个可以改变程序流程的机制。在大多数汇编语言中,这些处理由两种不同的指令处理:比较和跳转。

所有的比较都是通过CMP指令完成的。CMP比较两个不同的寄存器,然后在内部EFLAGS寄存器中设置几个位,记录这些值是相同,更大还是更小。你不需要直接看EFLAGS寄存器的值。而是根据结果的不同来做适当的跳转:

指令 意义
JE 如果大于则跳转
JNE 如果不等于则跳转
JL 如果小于则跳转
JLE 如果小于等于则跳转
JG 如果大于则跳转
JGE 如果大于等于则跳转

举个例子,这里是一个循环来使%rax从0到5:

 

再举个例子,一个条件赋值:如果全局变量x>=0,那么全局变量y=10,否则 y=20:

 

跳转的参数是目标标签。这些标签在一个汇编文件中必须是唯一且私密的,除了包含在.globl内的标签 ,其他标签不能在文件外部看到,也就是不能在文件外调用。用c语言来说,一个普通的汇编标签是static的,而.globl标签是extern。

栈是一种辅助数据结构,主要用于记录程序的函数调用历史记录以及不适合寄存器的局部变量。

栈从高地址向低地址增长。%rsp寄存器被称为“栈指针”并跟踪堆栈中最底层(也就是最新的)的数据。因此,要将%rax压入堆栈,我们必须从%rsp中减去8(%rax的大小,以字节为单位),然后写入%rsp指向的位置:

 

从栈中弹出一个值与上面的操作相反:

 

从栈中丢弃最新的值,只需移动堆栈指针即可:

 

当然,压栈(入栈)或出栈是经常使用到的操作,所以都有简化的单条指令,其行为与上面的完全一样:

 

函数调用

所有c标准库中可用的函数也可以在汇编语言程序中使用。以一种称为“调用约定”的标准方式调用,以便用多种语言编写的代码都可以链接在一起。

在x86 32位机器中。调用约定只是将每个参数入栈,然后调用该函数。被调用函数在栈中查找参数,完成它的工作之后,将结果存储到单个寄存器中。然后调用者弹出栈中的参数。

Linux上x86-64使用的调用约定有所不同,称之为System V ABI。完整的约定相当复杂,但以下是对我们来说足够简单的解释:

下面的表格总结了你需要了解的内容:

寄存器 用途 是否需要保存
%rax 保存返回结果 无需保存
%rbx - 被调用者保存
%rcx 参数4 无需保存
%rdx 参数3 无需保存
%rsi 参数2 无需保存
%rdi 参数1 无需保存
%rbp 栈基址指针 被调用者保存
%rsp 栈指针 被调用者保存
%r8 参数5 无需保存
%r9 参数6 无需保存
%r10 - 调用者保存
%r11 - 调用者保存
%r12 - 被调用者保存
%r13 - 被调用者保存
%r14 - 被调用者保存
%r15 - 被调用者保存

每个函数都需要使用一系列寄存器来执行计算。然而,当一个函数被另一个函数调用时会发生什么?我们不希望调用者当前使用的任何寄存器被调用的函数破坏。为了防止这种情况发生,每个函数必须保存并恢复它使用的所有寄存器,方法是先将它们入栈,然后在返回之前将它们从堆栈弹出。在函数调用的过程中,栈基址指针%rbp始终指向当前函数调用开始时栈的位置,栈指针%rsp始终指向栈中最新的元素对应的位置。%rbp和%rsp之间的元素被我们成为”栈帧”,也叫”活动记录”。函数的调用过程其实就是栈帧被创建,扩张然后被销毁的过程。在说明函数调用流程前,我们不得不提到 %rip(instruction pointer) 指令指针寄存器。%rip中存放的是CPU需要执行的下一条指令的地址。每当执行完一条指令之后,这个寄存器会自动增加(可以这样理解)以便指向新的指令的地址。有了这些基础,接下来我们以一段完整的程序代码来解释函数的调用流程,有下面一段c代码:

 

编译为汇编代码之后,为了方便读代码,我们去除一些不需要的指示段之后得到如下代码:

 

我们知道linux系统中main函数是由glibc中的 exec()簇 函数调用的,比如我们从shell环境中启动程序最终就是由 execvp()调用而来。我们这里不展开说明,你只需要知道main函数其实也是被调用的函数。我们从main函数的第一条指令开始:

 

首先,将当前的栈基址指针%rbp入栈,函数调用结束后我们就可以从栈中取得函数调用前%rbp指向的位置,进而恢复栈到之前的样子。然后使当前栈指针指向新的位置。然后:

 

在栈上申请16字节的空间以便存放后面的临时变量x,然后根据System V ABI的调用约定将传递给sum函数的参数放入%esi和%edi中(因为是int类型占用4个字节,所以只需要用寄存器的低4字节即可)。这里你可能会发现编译器没有将需要调用者保存的%r10和%r11入栈,因为编译器知道在main函数中不会使用到%r10和%r11寄存器所以无需保存。然后发出调用指令:

 

需要注意以上的CALL指令等同于:

 

我们把%rip当前的内容放入栈中,以便函数sum调用结束我们可以知道接下来该执行哪条指令,我们假设栈从0xC0000000处开始向低处延伸。到这个阶段栈的变化过程如下所示:

现在程序跳转到sum处执行计算:

 

和main函数被调用一样,sum函数被调用时,首先也是保存%rbp,然后更新栈指针%rsp,将两个参数拷贝到栈中进行使用。在这里你可能看到了和main 函数不一样的地方,局部变量保存在栈中并没有像main函数中那样引起%rsp的移动(对比main函数中的SUBQ 16)。是因为编译器知道sum中不会再调用其它函数,也就不用保存数据到栈中了,直接使用栈空间即可。所以就无需位移%rsp。计算完成后结果保存在%eax中,现在我们更新一下栈的变化:

然后返回到main函数时执行了如下操作:

 

先恢复调用前的栈基址指针%rbp,然后此时栈顶的元素就是函数调用之后需要执行的下一条指令的地址,RET指令等价于:

 

这样就可以跳转到函数结束后的下一条指令 “movl %eax, -4(%rbp)”处继续执行,至此我们看一下完整调用过程中栈的变化:

好了,有了这些基础,我相信你已经可以看懂大部分汇编代码了。

x86-64汇编入门相关推荐

  1. 第19部分- Linux x86 64位汇编GDB单步调试

    第19部分- Linux x86 64位汇编GDB单步调试 本篇我们使用gdb来调试上篇中的汇编代码. gdb调试 使用gdb进行调试. #gdb ./addsum_arg 设置参数: (gdb) s ...

  2. 第41部分-Linux x86 64位汇编MMX使用

    第41部分-Linux x86 64位汇编MMX使用 使用MMX架构需要一下步骤 从整数值创建打包整数值 把打包整数值加载到MMX寄存器中 对打包整数值执行MMX数学操作. 从MMX寄存器获得结果放到 ...

  3. 第77部分- Linux x86 64位汇编 优化编译器代码

    第77部分- Linux x86 64位汇编 优化编译器代码-O1/-O2/-O3 仅仅使用汇编语言代码替换C或者C++不会必然使得程序执行的更好,因为编译器已经把所有高级语言代码都转化成了汇编语言. ...

  4. Golang 汇编入门知识总结

    作者:ivansli,腾讯 IEG 运营开发工程师 在深入学习 Golang 的 runtime 和标准库实现的时候发现,如果对 Golang 汇编没有一定了解的话,很难深入了解其底层实现机制.在这里 ...

  5. go 汇编入门 如何学习Golang?万字详文教你Go语言入门

    以下内容转载自 https://www.toutiao.com/i6882641627349778955/ 原创腾讯技术工程2020-10-12 18:08:00 作者:ivansli,腾讯开发工程师 ...

  6. 汇编 编程实现从键盘输入三位以内的十进制负数_macOS上的汇编入门(二)——数学基础...

    在正式介绍汇编语言之前,我会先用几篇文章讲一些数学基础和硬件基础.如果读者已经具备了一定的知识基础,可以直接跳过这些文章去汇编语言部分. 二进制,八进制与十六进制 在计算机底层的软件层面,我们通常采用 ...

  7. Windows X64汇编入门

    Windows X64汇编入门(1) tankaiha 最近断断续续接触了些64位汇编的知识,这里小结一下,一是阶段学习的回顾,二是希望对64位汇编新手有所帮助.我也是刚接触这方面知识,文中肯定有错误 ...

  8. macOS上的汇编入门(四)——操作系统基础

    当我们学习汇编的时候,除了数学基础以及硬件基础以外,操作系统的基础也是一个至关重要的环节.汇编语言本质上就是机器码的human-readable的版本,而硬件相同,则同一个程序的机器码一定相同.那么我 ...

  9. linux的x64与x86_在Linux x86 64机器上链接

    linux的x64与x86 Linking is the process of combining various pieces of code and files in order to const ...

  10. 汇编入门学习笔记 (十二)—— int指令、port

    疯狂的暑假学习之  汇编入门学习笔记 (十二)--  int指令.port 參考: <汇编语言> 王爽 第13.14章 一.int指令 1. int指令引发的中断 int n指令,相当于引 ...

最新文章

  1. MYSQL数据库从A表把数据插入B表
  2. python tablewidget综合实例_python – 仅通过单击行标题选择QTableWidget行
  3. Maven实战(二)——POM重构之增还是删
  4. 机器学习相关的一些术语
  5. docker创建镜像之Dockerfile
  6. History命令的显示带时间
  7. JQuery杂项方法
  8. 用ASP为blog程序编写Trackback功能 - 小李刀刀(转载)
  9. 未能加载文件或程序集问题
  10. 服务器上phpstudy搭建网站,如何使用PHPstudy在本地搭建一个网站(最新图文教程)...
  11. 分布式系统可用性与一致性
  12. 致远OA漏洞学习——帆软组件 ReportServer 目录遍历漏洞
  13. 如何把流程图转换为软件设计(初稿)
  14. 巴西龟饲养日志----春日野采
  15. java设置httpheaders_HttpClient 请求添加Header头部信息
  16. IDEA+SpringBoot+Vue
  17. BigDecimal 判断大于小于
  18. 前端UI大全(针对后台管理系统)
  19. H.264分隔符AUD误用导致iOS设备无法播放H.265视频的问题解决
  20. Linux下_bak后缀文件是什么?

热门文章

  1. 怎么实时查看mysql当前连接数
  2. 经典案例oracle和mysql分别比较
  3. 【Vue 实战项目】后台管理系统登录页详解附源码
  4. 2019.4.3个人赛
  5. 无人机倾斜摄影全景建模三维数字沙盘电子沙盘人工智能开发教程视频第7课
  6. 转载:Vsphere 出现 “ XXX esx.problem.hyperthreading.unmitigated.formatOnHost not found XXX”的解决方案
  7. OpenCV 图像处理:常用绘图函数
  8. assertThat断言测试方法
  9. 吉林师范计算机考研真题,2021年吉林师范大学历年考研真题各专业汇总
  10. 精品软件 推荐 酷我音乐 一个可以下载320k 音质的音乐播放软件