本文我试图用学习一个普通编程语言的思路讲述x86_64汇编。
本文所有汇编代码均在linux系统写成,并且使用了很多linux系统调用。
需要C语言基础。
持续更新中。
现在x86_64平台上有很多种不同的汇编语言,这些汇编语言一般只能由特定的汇编器汇编。
现在的x86_64汇编器主要有nasm
、gas
、微软MASM
等,汇编代码的编码格式也有些不同,分别是以nasm
为代表的intel汇编
格式和以gas
为代表的ATT汇编
格式。
本文使用nasm
和gas
两种汇编语言。
nasm
下载nasm官网安装程序并安装即可。gas
它是GCC的一部分,安装mingw-w64,并适当配置环境变量即可,运行它的命令是as
。nasm
使用软件包管理器即可安装。gas
是GCC的一部分,只要有GCC环境就一定有as
命令。使用任意一款支持汇编语法高亮的代码编辑器即可。如vscode
、vim
、emacs
等。
首先我们编写一个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_write
向stdout
文件写入字符串。
;系统调用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!
x86汇编大体分为两个格式:intel格式
和att格式
。intel格式语法简洁,大多数x86汇编器都使用这种格式;att格式语法复杂,表达精确,适合编译器由高级语言编译输出,gas
和llvm
使用这种格式,因此GNU C语言和clang C语言的内嵌汇编也使用这种格式。
汇编的指令格式非常简单,由指令助记码和0个、1个、2个或3个操作数(基本没有3个以上的)组成,几个操作数之间一般用,
隔开,助记码与操作数之间用空格或缩进隔开。
操作数有源操作数
和目的操作数
,操作数分为3类
此处讲解的intel格式以nasm
汇编为准。
[]
包围的常数、寄存器、寄存器与常数相加或相乘的数(具体能用什么寄存器、哪几个常数详见后文),有时需要在[]
前加上限定字符串限定长度:
byte
一字节word
两字节(一个字)dword
四字节(两个字)qword
八字节(四字)ptr
。b
字节w
字l
双字q
四字mov rax, dword [rel data]
写成att格式为:
movl data(%rip), %rax
上例同时可以展示att格式相比intel格式的区别,
data(%rip)
开始的四字节的值传送给寄存器%rax
%
()
$
后面的文章中,我会讲到很多常用汇编指令,但是仅此一文根本无法讲解全部汇编指令,因此我将intel白皮书
挂到了github上供大家查阅。
这里先列出一些常用的汇编器器语法。
,
隔开
db
填充字节;也可以定义字符串但是不支持转义字符。例如上节所示代码倒数第二行。dw
填充字。dd
填充双字。dq
填充四字。section
定义段。global
声明一个全局标记,使其能够被链接程序ld
识别。extern
声明一个外来标记,ld
时被替换为其他.o
文件中标记定义的地址。gas
语法较为复杂,将在后文逐步渗透。
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;mm0
~mm7
,用于mmx指令集。
xmm0
~xmm7
,128位寄存器;
ymm0
`ymm15`,256位寄存器,`ymm0`ymm7
的低128位是xmm的映射;
zmm0
`zmm31`,512位寄存器,`zmm0`zmm15
的低256位是ymm的映射。
由于这两个都在栈上进行,因此放在同一节中讲解。
首先我们使用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
两个寄存器进行栈的管理;
栈从高地址开始,向低地址延伸;
rbp
与rsp
指向的地址之间的一段内存,称为栈帧
,一般一个函数只能拥有一个栈帧;
因为一个函数由其它函数调用执行,而这个函数不能使用调用者的栈帧,因此函数被调用后的第一件事就是创建栈帧。
汇编中,一个函数声明就是函数名:
。
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
。这时,rsp
与rbp
的值相等,代表此时已经退出了调用者的栈帧,可以创建自己的栈帧了。
这时其它寄存器中可能还保存着上一个函数使用的值,因此创建栈帧的第一步是将此函数需要使用的寄存器值push到栈上:
push rbx
push rcx
这两个汇编指令分别将rbx
rcx
的值复制到栈顶,并将栈顶指针-8。
接下来的工作是给栈上变量创建空间,此函数不需要栈上变量,此内容留到主函数中讲解。
然后是语句if (n == 1 || n == 2) return 1;
,首先分析此语句的执行过程:
n==1
,若成立,则返回1;n==2
,若成立,返回1;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