一文入门64位x86汇编

本文我试图用学习一个普通编程语言的思路讲述x86_64汇编。

本文所有汇编代码均在linux系统写成,并且使用了很多linux系统调用。

需要C语言基础。

持续更新中。

目录

  • 〇、汇编语言的选择
    • (一)如何获得汇编器
    • (二)如何编写汇编代码
  • 一、Hello World
    • (一)简单分析代码
    • (二)运行代码
  • 二、指令格式与汇编器语法
    • (一)指令格式
      • intel格式
      • att格式
      • 关于汇编指令
    • (二)汇编器语法
      • nasm
      • gas
  • 三、x86_64架构寄存器
    • (一)通用寄存器
    • (二)专用寄存器
    • (三)其它寄存器
      • mmx寄存器
      • xmm寄存器
  • 四、函数调用和变量使用
    • (一)栈帧与调用约定

〇、汇编语言的选择

现在x86_64平台上有很多种不同的汇编语言,这些汇编语言一般只能由特定的汇编器汇编。
现在的x86_64汇编器主要有nasmgas、微软MASM等,汇编代码的编码格式也有些不同,分别是以nasm为代表的intel汇编格式和以gas为代表的ATT汇编格式。
本文使用nasmgas两种汇编语言。

(一)如何获得汇编器

  • windows平台
    • nasm 下载nasm官网安装程序并安装即可。
    • gas 它是GCC的一部分,安装mingw-w64,并适当配置环境变量即可,运行它的命令是as
  • linux平台
    • nasm 使用软件包管理器即可安装。
    • gas 是GCC的一部分,只要有GCC环境就一定有as命令。

(二)如何编写汇编代码

使用任意一款支持汇编语法高亮的代码编辑器即可。如vscodevimemacs等。

一、Hello World

首先我们编写一个Hello World程序hello.s

	global _start

section .text

_start:
	endbr64
	push rbp
    mov rbp, rsp

    ;系统调用sys_write(stdout, hellomsg, strlen)
	mov rdi, 1          ;第一个参数,文件描述符,stdout是1
    mov rsi, hellomsg   ;第二个参数,字符串地址
    mov rdx, msglen     ;第三个参数,字符串长度
    mov rax, 1          ;系统调用号
    syscall

    leave

    ;系统调用sys_exit(0)
    xor rdi, rdi        ;参数,返回值0
    mov rax, 60         ;系统调用号
    syscall

	nop

section .data

hellomsg: db "Hello World!", 0xa
msglen: equ $-hellomsg

(一)简单分析代码

_start:
汇编语言以_start为入口点。
endbr64
在x86_64平台中,endbr64指令须在远跳转后立即出现。

    ;系统调用sys_write(stdout, hellomsg, strlen)
	mov rdi, 1          ;第一个参数,文件描述符,stdout是1
    mov rsi, hellomsg   ;第二个参数,字符串地址
    mov rdx, msglen     ;第三个参数,字符串长度
    mov rax, 1          ;系统调用号
    syscall

此段程序通过调用linux系统调用sys_writestdout文件写入字符串。

    ;系统调用sys_exit(0)
    xor rdi, rdi        ;参数,返回值0
    mov rax, 60         ;系统调用号
    syscall

此段程序通过调用linux系统调用sys_exit退出进程。

hellomsg: db "Hello World!", 0xa
msglen: equ $-hellomsg

此处定义了一个字符串"Hello World!\n",nasm不支持转义字符,因此用\n的16进制码0xa代替。

section .text
section .data

这两个语句就是定义程序中的.text段和.data段的,一般程序分为.text(程序代码).data(全局变量).rodata(全局常量).bss(符号区)等段,这些段会在后文仔细讲解。

(二)运行代码

打开终端输入如下命令:

nasm -felf64 -o hello.o hello.s
ld -o hello hello.o

运行链接好的程序hello可看到如下输出:

Hello World!

如图所示:一文入门64位x86汇编_第1张图片
你的第一个x86_64汇编运行成功了!

二、指令格式与汇编器语法

(一)指令格式

x86汇编大体分为两个格式:intel格式att格式。intel格式语法简洁,大多数x86汇编器都使用这种格式;att格式语法复杂,表达精确,适合编译器由高级语言编译输出,gasllvm使用这种格式,因此GNU C语言和clang C语言的内嵌汇编也使用这种格式。
汇编的指令格式非常简单,由指令助记码和0个、1个、2个或3个操作数(基本没有3个以上的)组成,几个操作数之间一般用,隔开,助记码与操作数之间用空格或缩进隔开。
操作数有源操作数目的操作数,操作数分为3类

  • 立即数
  • 寄存器(后面的章节会讲到)
  • 内存

intel格式

此处讲解的intel格式以nasm汇编为准。

  • 对于助记码,intel格式即intel白皮书上列出的指令表。
  • 立即数与寄存器都是直接写出即可。
  • 目的操作数在前,源操作数在后,对于3操作数指令第一个是目的操作数,其后都是源。
    • 两种操作数的含义将在后文解释
  • 内存操作数是由[]包围的常数、寄存器、寄存器与常数相加或相乘的数(具体能用什么寄存器、哪几个常数详见后文),有时需要在[]前加上限定字符串限定长度:
    • byte一字节
    • word两字节(一个字)
    • dword四字节(两个字)
    • qword八字节(四字)
      有些intel格式汇编需后加ptr

att格式

  • 对于助记码需在intel助记码后加长度限定后缀:
    • b字节
    • w
    • l双字
    • q四字
      如intel格式的汇编语句:
mov rax, dword [rel data]

写成att格式为:

movl data(%rip), %rax

上例同时可以展示att格式相比intel格式的区别,

  • att格式源操作数在前,目的操作数在后,对于3操作数指令,最后一个是目的操作数。
    • 上述汇编指令会将源操作数即内存地址data(%rip)开始的四字节的值传送给寄存器%rax
  • 寄存器前缀%
  • 内存寻址使用()
  • 另外,立即数前缀为$

关于汇编指令

后面的文章中,我会讲到很多常用汇编指令,但是仅此一文根本无法讲解全部汇编指令,因此我将intel白皮书挂到了github上供大家查阅。

(二)汇编器语法

这里先列出一些常用的汇编器器语法。

nasm

  • 填充数据
    此语法定义的数据用,隔开
    • db填充字节;也可以定义字符串但是不支持转义字符。例如上节所示代码倒数第二行。
    • dw填充字。
    • dd填充双字。
    • dq填充四字。
  • section定义段。
  • global声明一个全局标记,使其能够被链接程序ld识别。
  • extern声明一个外来标记,ld时被替换为其他.o文件中标记定义的地址。

gas

gas语法较为复杂,将在后文逐步渗透。

三、x86_64架构寄存器

  • 此节中若无特殊说明均为64位寄存器。

(一)通用寄存器

  • rax累加寄存器,通常用于传递函数返回值;
  • rcx计数寄存器,通常用于循环计数和函数传参;
  • rbx基址寄存器,现在的x86架构中可以随意使用;
  • rdx数据寄存器,可以随意使用,也用于函数传参;
  • rsi源变址寄存器,用于串指令和函数传参;
  • rdi目的变址寄存器,用于串指令和函数传参;
  • rbp栈底指针;
  • rsp栈顶指针;
  • r8 r9用于函数传参;
  • r10~r15随意使用;

(二)专用寄存器

  • cs ds ss es fs gs等段寄存器在64位平台几乎没什么用;
  • ip指令指针寄存器,指向下一条指令的地址;
  • flags标志位寄存器:
    • ZF零标志;
    • CF进位/借位标志;
    • SF符号标志,正为0负为1;
    • OF溢出标志,加减乘运算时结果超出能表示的范围时为1;
    • DF方向标志,用于串指令,向高地址方向为0,向低地址方向为1;

(三)其它寄存器

mmx寄存器

mm0~mm7,用于mmx指令集。

xmm寄存器

xmm0~xmm7,128位寄存器;
ymm0`ymm15`,256位寄存器,`ymm0`ymm7的低128位是xmm的映射;
zmm0`zmm31`,512位寄存器,`zmm0`zmm15的低256位是ymm的映射。

  • 接下来的章节我们将用独特的视角来逐步了解x86_64汇编。
  • 最开始列举的汇编代码中难免有没讲解过的,对于这些代码,复制粘贴即可。

四、函数调用和变量使用

由于这两个都在栈上进行,因此放在同一节中讲解。
首先我们使用C语言写一个简单的递归斐波那契数列:

#include 
int fib(int n)
{
	if (n == 1 || n == 2)
		return 1;
	else
		return fib(n - 1) + fib(n - 2);
}

int main()
{
	int n;
	scanf("%d", &n);
	printf("%d\n", fib(n));
}

接下来的工作是,如何将这个代码翻译成汇编。
首先我们解决fib()函数。

(一)栈帧与调用约定

在x86_64架构中,栈位于内存中,需要rsp rbp两个寄存器进行栈的管理;
栈从高地址开始,向低地址延伸;
rbprsp指向的地址之间的一段内存,称为栈帧,一般一个函数只能拥有一个栈帧;
因为一个函数由其它函数调用执行,而这个函数不能使用调用者的栈帧,因此函数被调用后的第一件事就是创建栈帧。
汇编中,一个函数声明就是函数名:

fib:
	endbr64
	push rbp
	mov rbp, rsp

进入函数的第一个指令是endbr64,这个指令是为了跳转时的安全。
push rbp
push指令的作用是将寄存器内存值立即数压入栈中;实际执行的效果是将操作数的值复制到rsp指向的地址处,并将rsp的值减去操作数的长度(指字节数)。此指令将rbp的值复制到rsp指向的地址,意为保存上一个栈帧的栈底,使函数返回时能够恢复上一栈帧;rsp自减8(rbp为64位寄存器)。
mov rbp, rsp
mov指令就是移动数据;此指令将rsp的值复制给rbp。这时,rsprbp的值相等,代表此时已经退出了调用者的栈帧,可以创建自己的栈帧了。
这时其它寄存器中可能还保存着上一个函数使用的值,因此创建栈帧的第一步是将此函数需要使用的寄存器值push到栈上:

	push rbx
	push rcx

这两个汇编指令分别将rbx rcx的值复制到栈顶,并将栈顶指针-8。
接下来的工作是给栈上变量创建空间,此函数不需要栈上变量,此内容留到主函数中讲解。
然后是语句if (n == 1 || n == 2) return 1;,首先分析此语句的执行过程:

  1. 首先判断n==1,若成立,则返回1;
  2. 若不成立,判断n==2,若成立,返回1;
  3. 若不成立,继续执行。
    首先,第1步和第2步,都会返回1,所以首先写一下返回1的代码:
F1:
mov rax, 1
ret

第一行的F1标号,标记着紧跟着它的指令的地址,可以通过jmp跳转指令或者条件跳转指令使CPU执行对应地址处的代码。
调用约定中,rax规定为返回值寄存器;因此用mov指令使rax=1,并使用ret从fib函数中返回。
接下来实现if (n==1||n==2)条件判断:

cmp rdi, 1
je F1
cmp rdi, 2
je F1

cmp指令是比较指令,比较两个操作数的大小,比较结果会放到rflags寄存器中;紧跟其后的je是条件跳转指令,它会读取rflags中的某些位来确定最近执行的cmp指令的结果是否是相等,如果是相等,就会跳转到操作数地址处执行,否则跳过此指令。
rdi是储存函数第一个参数的寄存器,调用约定中,函数的参数使用寄存器传递,按顺序分别是rdi,rsi,rdx,rcx,r8,r9,更多的参数可以用栈传递,不过本人更喜欢先用完其余的r10~r15寄存器。
这四个指令分别先判断参数与1和2是否相等,若相等直接跳转至F1处返回1。
最终语句if (n == 1 || n == 2) return 1;的汇编代码是这样:

cmp rdi, 1
je F1
cmp rdi, 2
je F1
jmp F0
F1:
mov rax, 1
ret

你可能感兴趣的:(linux,windows)