汇编语言程序设计笔记
题图:命运石之门0 CG
前情摘要
开学一看,80286实模式+MS-DOS 平台上教汇编。倒也不是不能说不好,就是现在基本上不可用了…跑个程序还得套虚拟机。不爽,于是这篇笔记着重于从 MS-DOS 上的汇编迁移到现代 x86_64 Linux 平台上的汇编,还会提到一些其他有意思的主题。
汇编语言语法
汇编语言主要分为两种语法,Intel 和 AT&T 语法,教材上是 Intel 语法。当然不同汇编器也会提供自己的扩展,与其说是两种语法,更不如说是两种汇编风格。Intel syntax主要用于DOS和Windows,而AT&T syntax主要用于UNIX世界。简单来概括这两种风格就是Intel语法更简单,AT&T语法更精确。
Intel汇编和AT&T汇编主要区别
寄存器和立即数语法
AT&T相比Intel,寄存器前面要加%,立即数前面要加$
Intel | AT&T |
---|---|
mov eax,1 mov ebx,0ffh int 80h |
movl $1,%eax movl $0xff,%ebx int $0x80 |
指令运算方向
Intel语法第一个参数是操作目标/输出位置,AT&T最后一个参数才是
Intel | AT&T |
---|---|
instr dest,source mov eax,[ecx] |
instr source,dest movl (%ecx),%eax |
寻址格式
Intel语法更C-like且自然一些
Intel | AT&T |
---|---|
mov eax,[ebx] mov eax,[ebx+3] |
movl (%ebx),%eax movl 3(%ebx),%eax |
Intel是segreg:[base+index*scale+disp]
AT&T 语法形式是 %segreg:disp(base,index,scale)
index/scale/disp/segreg都是可选的
segreg仅在实模式有意义表示段地址。
Intel | AT&T |
---|---|
mov eax,[ebx+20h] add eax,[ebx+ecx*2h lea eax,[ebx+ecx] sub eax,[ebx+ecx*4h-20h] |
movl 0x20(%ebx),%eax addl (%ebx,%ecx,0x2),%eax leal (%ebx,%ecx),%eax subl -0x20(%ebx,%ecx,0x4),%eax |
指令后缀
AT&T需要在指令(比如mov)后输入字长相关信息而Intel则不需要。
‘b’(8-bit), ‘w’(16-bit), ‘l’(32-bit)
Intel | AT&T |
---|---|
mov al,bl mov ax,bx mov eax,ebx mov eax, dword ptr [ebx] |
movb %bl,%al movw %bx,%ax movl %ebx,%eax movl (%ebx),%eax |
但因此Intel需要确定内存操作数的大小, 常用’byte ptr’, ‘word ptr’, ‘dword ptr’,'qword ptr’标识。
nasm无需ptr
,还有额外的tword
(80bit),oword
(128bit),yword
(256bit)和zword
(512bit)。
从可执行程序说起
裸二进制文件
最简单的方法放程序就是直接把机器码dump到连续的存储空间上。在单片机上很常见,DOS类似的COM(DOS)文件执行支持。
COM(DOS)执行时将整个二进制文件memcpy到段0x0100
开始的内存上,然后jmp到0x0100
执行。因此COM(DOS)文件最大只有64k,代码段和数据段共用一个段。因此nasm汇编后数据段放在代码段后面。
同样,这也是栈是从底部增长的原因——因为顶部就是程序入口。
nasm程序实例
一个简单的hello world
1 | .data |
输入以下编译
1 | > nasm HELLO.ASM -o HELLO.COM -fbin |
进入dosbox看看
可见确实就是memcpy过来的。
可执行程序文件格式
然而裸二进制文件并不足以满足我们的需求
- 我们需要程序文件能够描述能够运行自己的平台,同时还要求不能轻易被其他人篡改
- 我们需要程序文件能容纳更多段,而且允许在内存任意位置上加载运行
- 我们需要使用同一个库的多个程序共享库的代码段以节省空间
- 我们需要程序文件能在平台无关的运行
也即
- 元信息及数字签名(如windows应用于dll/exe的数字签名)
- 位置无关可执行文件
- 动态链接
- 通用二进制(如apk以及苹果电脑的通用二进制)
为了防止本文篇幅过长,我们将关注位置无关可执行文件和动态链接。
ELF文件
ELF是UNIX系统实验室(USL)作为应用程序二进制接口(Application Binary Interface,ABI)而开发和发布的,也是Linux的主要可执行文件格式。
本节为南京大学 计算机系统基础(一)主讲:袁春风老师 P81 10.3.1–可重定位文件概述(10分钟)的笔记。
ELF文件由ELF文件头,程序头表(只在可执行程序),段头表,段内容组成。ELF文件头包含段头表范围,段头表描述段地址、段大小、段对齐、段类型和段FLAG信息等。可以存储可执行文件、共享目标文件、可重定位目标文件和核心转储(coredump)。
上述的段(segment)只在可执行文件里称呼,在可重定位文件和汇编里称为节(section)。
程序头表描述了程序加载时/运行时的进程镜像,其合并了相同属性的节,能使程序的加载与运行更快。操作系统会按照定义顺序按照程序头表顺着直接加载段,所以最终程序将会占用一段连续的内存空间(除开动态链接库[vvar][vsyscal]这种东西),程序符号的相对位置是固定的。这有利于程序的加载速度。不论后面技术怎么变,这一句话始终不变。
可执行文件/共享目标文件的段有两个地址,一个是文件内的偏移地址,一个是加载到内存里的地址。而可重定位的节只有文件内的偏移地址,其内存地址域全为0。等到重定位时会填充内存地址。
汇编程序通过汇编器汇编成可重定位文件,然后由链接器链接成可执行文件/共享目标文件。
其中有几个重要/常见的段,以下段跟C能做到一一对应
- .text 存放二进制代码(代码段)
- .data 存放已初始化的静态存储期的变量
- .bss 存放静态存储期0初始化的变量
- .rodata 存放字符串和switch/case的跳转表
以下段具有特定作用(之后会提到)
- .strtab段存放符号字符串,如果需要使用一个符号名则往.strtab内追加并使用一对整数[l,r]表示段内的一个子串作为其表达的字符串。
- .symtab段符号表,存放所有符号的信息,包括符号名(上述[l,r]),符号绑定范围符号值(地址)等属性信息.
- .rel.xxx 存放.xxx的重定位信息.
- .got(Global Offset Table 全局偏移量表) 存放模块内符号地址以及其他数据,.got.plt存放plt需要的模块内地址。
- .plt(Procedure Linkage Table 过程链接表) 存放调用动态重定位外部模块函数的代码.
链接的产生
最原始的编程就是上述汇编程序,可以看到过程/变量地址分配是由人类手动计算的。程序越来越复杂,让人们思考简化这一过程,用符号来标记特定位置的子程序(函数)、变量的起始位置,调用函数/使用变量就是对于这个符号的引用。不同程序员可以定义自己的符号也可以使用他人的符号,用汇编器生成多个目标文件之后再由链接器合并&给符号分配地址成一个可执行文件。
用C语言的话说就是不同编译单元可以定义自己的变量/函数,也可以extern
使用别人的变量/函数。不同编译单元.c生成不同目标文件.o最终由链接器合并为一个可执行文件。
如果有多个不互相调用的函数(比如数学函数sin/cos/tan之类),他们放在一个.o文件里面如果主程序只使用了一个函数也会造成空间的浪费。于是可以将这些函数分成很多个编译单元,然后打包成.a文件,链接器就会解压这些.a文件并按需链接。
弱符号和强符号等概念与问题均与C语言一致。
重定位
链接器给符号分配完了地址,但是一些对于符号的引用(比如jmp foo)需要按照一些规则修改机器码的地址。为了解决目标文件生成可执行文件的引用符号时的地址修改问题,链接器使用重定位来按规则修改节中的一些地址。
.rela.xxx节记录了.xxx节需要重定位的符号及其位置,在链接时链接器就会计算出所有符号的地址并按照.rela.xxx节记录的符号及其重定位方式更新特定地址的地址数据(具体算法在下面讲述)。
重定位例子
1 | int a_v=1; |
编译成目标文件并查看重定位节
1 | gcc -c a.c -o a.o && readelf --relocs a.o |
1 | 定位节 '.rela.text' at offset 0x1b8 contains 5 entries: |
结合汇编看一看
1 | 0000000000000000 <gcd>: |
.rela.text的第一项正是这00 00 00 00
的起始位置,R_X86_64_PC32类型指的是按x86_64的32位PC(在x86中PC即等于IP)偏移地址改写,这样才能符合汇编的意思。至于后面的-4是因为实际运行到这条命令时PC会变成下一条地址0x14,而链接器是按照0x10算的,少了4,所以将a_v偏移量-4抵消掉这32位地址的影响。
链接顺序问题
适用于-l选项尝试链接,但是gcc a.o b
这种不适用于下列算法。使用-l选项链接会按需链接,而直接放在参数里面不管有没有用到都会链接。
- 链接器按照命令行给出的参数顺序扫描.o/.a文件
- 扫描期间遇到当前未解析的引用记录到一个列表U中
- 每遇到新的.o/.a中的.o,都试图用其所有符号解析U,如果U中没有使用.o/.a中的.o的任意一个符号就放弃链接这个文件。
- 链接这个文件
- 如果扫描到最后发现U非空,报Undefined Symbol
可见因为放弃链接的存在,将所有-l放在编译参数最后是一个解决办法。
动态链接(Dynamic Link)
动态链接就是运行时链接技术,一般有两种动态链接
- 在第一次加载程序时链接(加载时链接)
- 在已经开始运行后链接(运行时链接)
在Linux世界,希望达成的一个目标就是在C语言层面加载时链接和链接的表现行为是一致的,而且理解加载时链接是理解运行时链接的基础,本文章将专注加载时链接的原理。
因为动态链接库需要加载在任意位置都能运行,所以动态链接库一定是位置无关代码。而且是直接加载进内存所以跟可执行文件地位一致(这也就意味着直接加载对应节内容到内存即可)(如果一个动态链接库包含_start符号甚至都能执行一个动态链接库),所以动态链接库不能依赖传统的重定位方式定位动态链接库符号,需要使用其他手段来解决。
接下来介绍现代x86_64 GNU/Linux下链接器是怎么将动态链接库里的动态链接库的符号翻译成最终地址的。
动态链接库使用动态链接库的符号
函数调用
动态链接库使用自身的函数直接使用相对寻址就行,引用其他库需要使用.plt跳转表,详见可执行文件使用动态链接库的符号(加载时链接)-函数调用
变量引用
对于所有动态链接库中使用的全局变量(包括自身的全局变量),使用_GLOBAL_OFFSET_TABLE_节维护其地址,库内引用即使用相对寻址找到这个表,在取里面的第x项的地址来跳转。
加载器负责维护这个表的正确性。那么具体这个表里面的地址会填什么呢,答案是加载时看情况决定。
- 引用的变量符号在加载的可执行文件中已经使用,则变量实际存放在可执行文件里的.data/.bss段,got对应项存放可执行文件.data/.bss对应位置的地址
- 引用的变量符号在加载的可执行文件中未使用,则变量实际存放在动态链接库里的.data/.bss段,got对应项存放动态链接库里的.data/.bss对应位置的地址
让我们来看一个例子a.c
1 |
|
编译一下顺带反编译一下
1 | gcc a.c -shared -fPIC -o a.so -g && objdump -d ./a.so |
1 | 0000000000001109 <add10>: |
可见f的地址是存放在3fc0
的位置,a的地址存放在3fd0
的位置。都是按IP相对地址来算的。具体情况我们在下面描述。
可执行文件使用动态链接库的符号(加载时链接)
变量引用
直接在对应.bss/.data段分配对应变量,使用相对寻址即可。
让我们来看一段代码b.c
1 |
|
编译并且链接上上一节的动态链接库,反编译看看。
1 | gcc b.c a.so -o b -g -fPIE && objdump -d ./b |
1 | 0000000000001149 <add1>: |
可见是直接相对寻址来的,回到上一节,我们起个gdb来验证上一节的两个结论。
1 | $export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:`pwd` |
可见f和a是存在不同的段里面的,再看看内存映射
1 | (gdb) info proc mappings |
确实
函数调用
过时的方法:同上述引用变量,在got里面加函数首地址,加载时维护。
弊端:每次调用都需要三条指令,占用空间大。现代Linux常用延迟绑定技术来减少指令条数并且能缩短加载时间。需要用到PLT(Procedure Linkage Table 过程链接表)。
PLT是一个代码段,大概也是上述方法的函数封装。我们继续用gdb来看上述的例子。
1 | (gdb) b 10 |
可见调用的是add10@plt,看看plt代码段
1 | (gdb) disassemble 0x555555555040 |
add10@got.plt存放的是add10的实际地址,但是add10@got.plt在最开始并不存放add10的实际地址,而是0x0000555555555046
。计算机将继续执行add10@plt下面的指令,将1压栈,1就是,jmp 0x555555555020,之后就是查找符号表将add10的实际地址加载进add10@got.plt的过程,然后就能成功调用add10了。
CPU的变化
以下是读Intel® 64 and IA-32 Architectures Software Developer’s Manual的笔记。
运行模式
x86_64 CPU总共有5种运行模式,而这五种运行模式又分为两大类IA-32架构和AMD64架构,下文简称32位和64位。
- 32位
- 实模式
- 保护模式
- 系统管理模式
- 64位(子模式)
- 兼容模式
- 64位模式
64位和32位最主要的区别就是把32位架构的寄存器/运算/地址线全部扩展到了64位(虽然地址线实际上也只有48位),多了8个通用寄存器(r8-r15),在对64位寄存器进行32位运算(比如假设rax=0xffffffff
,mov eax,0x22
)时大于32位的位将会填充0(rax
现在等于0x22
)。这跟在寄存器上进行16位/8位运算不修改高位不同。
书上教的是实模式。
内存模型
硬件保证物理地址是一个大的字节数组
x86有三种内存模型
- 平坦
- 分段
- 实地址
其中实地址是书上实模式专属的内存模型,平坦就是不处理,分段机制是实地址简单分段的加强,带处理器特权等级限制以及越界检查等保护措施。然而linux只创建了四个段{内核,用户程序}*{代码段,程序内存段},而且每个段的起始地址都是0。所以所有偏移地址都是有效地址。
三种内存模型后输出线性地址,线性地址可以直接或者使用分页机制映射到物理地址。
分段模式在64位模式下不管段描述符怎样起始地址也都是0,越界检查也会关闭。此时分段是个半残功能。
分页部分超出了本文的范围。
指令的变化
寄存器位扩展
-
CBW/CWDE/CDQE—Convert Byte to Word/Convert Word to Doubleword/Convert Doubleword to Quadword(有符号ax寄存器的字符转单字/单字转双字/双字转四字)(i8转i16/i16转i32/i32转i64)
-
CWD/CDQ/CQO—Convert Word to Doubleword/Convert Doubleword to Quadword(将ax/eax/rax带符号位扩展至dx:ax/edx:eax/rdx:rax)
常用于除法指令
乘法/除法的变化
以64位操作符格式
MUL/IMUL照样是 RDX:RAX:=RAX*r/m64,但是多加了两个格式
IMUL r64, r/m64= Quadword register := Quadword register ∗r/m64.
IMUL r64, r/m64, imm32=Quadword register := r/m64 ∗ immediate doubleword.
简单来说就是一个是a*=b,一个是a=b*c
DIV/IDIV照样是 RDX:RAX/r/m64,RAX是除数,RDX是余数,没啥变化。
扩展阅读:将除法变成乘法
TLDR:令
以下是证明
那么ax就会小于,如果用x86的乘法机制,那么RDX就是答案。
如果是有符号数的负数参与运算就会复杂一点,在此不赘述。
x87FPU
执行环境
早期浮点处理器是作为CPU的外置协处理器出现的,之后才集成进CPU里面。x87特指与x86处理器配套的浮点协处理器架构。486之后集成至x86,因为前身是协处理器,所以遗留了x87的状态字,比较浮点数有两种方式——写x87状态字和写EFLAGS。本文只介绍后一种。
最重要的就是上述x87数据寄存器(下文简称浮点寄存器),形成了类似循环栈的数据结构
每一个寄存器宽80(即long double),浮点寄存器内浮点数均为80位扩展精度。
浮点数在浮点寄存器与内存之间传送将会按需转换浮点类型。
相关指令
- 数据传输指令
- 装入
FLD 将内存/x87数据寄存器装入栈顶
FILD 将内存int转浮点装入栈顶 - 存储
FSTx x为s/l时将ST(0)转换为单/双精度存入存储单元
FSTPx 同FSTx,但是弹出ST(0)
FISTPx x为s/l时将ST(0)截断转换为int存入存储单元,但是弹出ST(0) - 交换
FXCH 交换栈顶和次栈顶/ST(i) - 装入常数至栈顶
FLD1
FLD0
FLDPI
FLD(L2E/L2T/LG2/LN2) log2(e)/log2(10)/log10(2)/ln(2)
- 装入
- 基本运算指令
- 加法
FADD/FADDP/FIADD 基本同ADD,但是来自内存的浮点数只能跟ST(0)组合,如果未带操作数即ST(0) ST(1) - 减法
FSUB/FSUBP/FISUB 同上,只跟一个内存操作数时是ST(0)-=op
FSUBR/FSUBRP/FISUBR 同上,但是调换了次序相减 - 乘法
FMUL/FMULP/FIMUL 同上 - 除法
FDIV/FDIVP/FIDIV 同上
FDIVR/FDIVRP/FIDIVR 同上
- 加法
- 比较指令
FCOMI/FCOMIP/FUCOMI/FUCOMIP 比较ST(0)和op并设置EFLAGS
如FCOMI ST,ST(2)
MMX和SSE
执行环境
MMX、SSE和之后的AVX等都属于SIMD(单指令多数据),对于处理大批量的数据非常有效。
MMX使用八个64位寄存器MM0~MM7,直接借用8个浮点数据寄存器的后64位。可以同时处理8个字节/4个字/2个双字/1个64位数据。
因为MMX未带来3D游戏性能的显著提升于是推出SSE指令,后有SSE2/3/4。统称SSE指令集。
SSE指令集另起了8个128位的寄存器XMM0~XMM7,可同时处理16个字节/8个字/4个双字/2个四字的数据。
SSE2开始还支持128位整数运算,或者2个双精度浮点数。
相关指令
汇编器的变化
NASM
nasm诞生时没有一个好的x86免费汇编器,GAS(即GNU AS)是作为GCC的后端设计的,并不是很易用,MASM很贵而且只能运行在DOS下。
到了现在,nasm是Linux上最流行的汇编器,使用简洁的intel汇编深受广大用户的喜爱。
NASM汇编时有一个2pass的过程,1pass确定所有的代码与数据的大小,2pass产生对应的代码。
NASM程序行组成
1 | label: instruction operands ; comment |
一个NASM汇编除去宏、预处理操作符和汇编器操作符均符合这种形式。
伪指令
伪指令并不是真正的x86机器指令,但还是被用在了instruction域中,因为使用它们可以带来很大的方便。
db,dw,dd,dq,dt同MASM。
bss段中可使用resb,resw,resd,resq,rest声明未初始化数据。
可以使用incbin包含其他二进制文件
1 | incbin "file.dat" ; include the whole file |
equ同MASM
可以使用TIME重复指令
1 | zerobuf: times 64 db 0 ;64b 0 |
汇编器伪指令
1 | aas ;声明一个段 |
以elf为目标文件格式时可以对section使用一些扩展(跟在段名后面)。
- 定义alloc的段必须分配到内存中,noalloc反之
- 定义exec的段有可执行权限,noexec反之
- 定义write的段可写,nowrite反之
- 定义rogbits的段必须有内容,nobits反之,如.bss段
- 定义align=N的段表示以N对齐
NASM默认给以下段限定
1 | 16 .text progbits alloc exec nowrite = |
表达式
$表示其行所在的地址,$$表示其行所在段开始的地址。
本地label
nasm中的label三个层级,全局、本地和宏。宏层label在宏应用时使用,汇编器会分配出一个独一无二的label。
1 | label1 : ; some code |
宏
NASM有者一个强大的预处理器,支持条件汇编,多级文件包含,比C的预处理器强大多了…
以下都是伪指令。
单行宏
1 | 0 i |
字符串操作
1 | 'my string' sometext |
多行宏
bash-like的函数,但是是宏,也有重载,多重参数,默认参数(吊打rust),还可以跑循环。
1 | %macro silly 2 |
%if/%elif/%else/%endif同C,在此不过多赘述;%ifmacro等同于%ifdef,更多的超过了本文的范围,在此不过多赘述。
struc
在NASM的内部,没有真正意义上的定义结构体数据类型的机制; 取代它的是,预处理器的功能相当强大,可以把结构体数据类型以一套宏的形式来运行。宏 ‘STRUCT’ 和’ENDSTRUC’是用来定义一个结构体数据类型的。
1 | ;定义 mytype |
填充对齐
1 | 4 ; 对齐至4字节 |
在gcc中内嵌汇编
gcc的汇编默认使用AT&T语法(其实就是gnu as默认的语法),以下是内嵌汇编的基本格式。
1 | asm (assembler template |
assembler template是一个汇编代码的字符串。得益于C的字符串连接特性,一般这么写。
1 | "movl %%eax, %%ebx\n\t" |
也可以这么写
1 | "movl $10,%0;" |
output operands跟input operands是一样的,描述了输出操作数和输入操作数。其基本格式是用逗号隔开的"[ [asmSymbolicName] ] constraint(cexpression)
,constraint是操作数约束,var是C语言的变量,asmSymbolicName为参数名字,没有就从0开始分配。
约束限制了操作数的寻址模式,众所周知C的左值在内存里,右值可以随便在哪里。这就需要约束限制,常见如r
表示使用通用寄存器传递,每一个在汇编代码里出现的参数将会被替换成通用寄存器(如果是输出左值编译器在之后选择合适的时间写回),m
表示直接用内存传递,每一个在汇编代码里出现的参数将会被替换成访存。
输出约束必须以‘=’或’+'开头描述是只写的还是读/写的。
list of clobbered registers描述了用逗号分隔的一串此汇编代码销毁的寄存器(即运行后和运行前不一样),编译器会自动保存并恢复这些寄存器。还有两个特殊参数"cc"和"memory"。前者表示此内嵌汇编会修改EFLAGS这种标志寄存器。后者表示汇编代码会对输入和输出操作数以外的内存执行读写操作,指示编译器执行后对左值的寄存器-内存绑定做内存->寄存器的刷新。(不是写回内存)
在汇编代码中操作数以 % 为前缀,寄存器就以 %% 作为前缀。
1 | int a, b; |
x86_64 System V ABI
本文只讨论整数类型(小于等于64位)的参数。
用户函数调用约定
头6个参数从左至右依次放入rdi,rsi,rdx,rcx,r8,r9。超出6个的参数从右向左放入栈中。在使用call指令之前调用者确保栈顶16字节对齐。
函数(callee)保护rbx,rsp,rbp,r12-r15。剩下的寄存器可供函数(callee)随意使用。
al指示传入矢量参数个数。
信号/中断/异常处理代码在同一栈上执行,但在任何内容送入栈之前rsp会减去128。这个多出来的128字节被称作红色区域。对于使用小于128字节的叶子函数(不调用其他函数)来说可以在全程保持rsp不变,使用rsp+偏移量寻址栈空间
系统调用调用约定
系统调用从左到右使用rdi,rsi,rdx,r10,r8和r9传递参数,使用rax传递系统调用号,使用rax和rdx返回。系统调用使用syscall指令完成,内核销毁寄存器rcx和r11。
Linux内核不尊重红色区域。x87和SSE等寄存器由内核保护。
系统调用表可以在这里找到Searchable Linux Syscall Table for x86 and x86_64,也可以使用ausyscall --dump
然后查询对应的manpage得知如何调用。
来写代码吧
Helloworld!
helloworld.asm
1 | nasm helloworld.asm -felf64 -o helloworld.out |
1 | "Hello Assembly World!" hw |
A Simple Maximum
内存限制2M,连个libc都不能用…
1 | .bss |