本文主要对汇编一些个人认为比较重要的点进行阐述。
下文如无特别说明,所采用的汇编语言为 AT&T 格式的汇编语言。关于汇编语言开发,有一篇写的很好的 IBM 的博文值得参考:Linux 汇编语言开发指南(如果链接失效,可从百度云下载得到。)。
汇编只是一种助记符
在以前博文 Linux 内核学习笔记:预备知识之“目标文件”中我们曾提到 C/C++ 源代码经编译后可以生成汇编代码,而汇编代码经过汇编器能够被直接“翻译”(注意这里用的是“翻译”而不是“生成”或“转换”)成二进制代码(可重定位目标文件)。从这个意义上来说,汇编其实是一种中间代码,它可以不存在。实际上,它是机器指令的助记符,它跟机器指令是一一对应的。
例如,如下一个简单 C 程序:
1 | int sum(int x, int y) |
我们对其进行编译并查看其汇编代码:
1 | $ gcc -O0 -S sum.c |
然后我们再将该文件汇编为可重定位目标文件并查看其二进制代码与汇编代码的对应:
1 | $ gcc -O0 -c sum.s |
得到如下结果:
上图中,左边红色方框内的是计算机能够识别并执行的机器代码,右边红色方框是这些机器代码一一对应的汇编代码。这些汇编代码是由"objdump"命令从机器代码翻译过来,而且它们跟"sum.s"文件内容一模一样(除去指导汇编器的助记符)。
通过以上分析,汇编代码和机器代码之间能够互相翻译。这说明汇编代码确实是机器代码的一种助记符(因为计算机只能够识别并执行机器代码),只不过汇编代码的可读性要更好。
汇编跟指令集的关系
汇编语言是一种低级的跟机器相关的语言,可移植性不够好。那具体原因是什么呢?
我们知道,不同的处理器架构支持的指令集(Instruction Set)是不一样的,如 Intel 处理器使用的是复杂指令集(Complex Instruction Set),ARM 处理器使用的精简指令集(Reduced Instruction Set)。又因为汇编语言依赖于处理器所能支持的指令集,所以,不同架构的处理器支持的汇编语言并不一样,这样就导致了汇编语言的可移植性比较差。那为什么高级语言的可移植性会比较好?这要归功于编译器,是编译器将同样的一份高级语言代码编译为某个处理器所支持的汇编语言。
到这里,我们不得不问一下,上边提到的指令集究竟是什么?
维基百科给出的定义是这样子的:
An instruction set, or instruction set architecture (ISA), is the part of the computer architecture related to programming, including the native data types, instructions, registers, addressing modes, memory architecture, interrupt and exception handling, and external I/O.
An ISA includes a specification of the set of opcodes (machine language), and the native commands implemented by a particular processor.
通俗来讲,指令集就是机器指令的一个集合,这些指令有不同的操作码(opcodes)。
而我们在上一节提到汇编语言其实跟机器语言是一一对应的,所以我们可以这样理解汇编语言跟指令集的关系:汇编语言本质只是指令集中指令的一种助记符。
题外:
同一指令集可以有不同的实现,如 Intel 和 AMD 都开发出了支持 X86 指令集的处理器,但它们具体是怎么实现的并不一样。一般来说,对指令集中每个指令的实现采用的是流水线技术,即取指->译码->执行->访存->回写
。详细可参考《自己动手写 CPU》和《深入理解计算机系统》第四章“处理器体系架构”。
另外,不同的指令有不同的操作码(opcodes)。维基百科上的定义如下:
In computing, an opcode (abbreviated from operation code) is the portion of a machine language instruction that specifies the operation to be performed. Beside the opcode itself, instructions usually specify the data they will process, in form of operands.
也就是说,操作码告诉计算机具体要做的事情。
在 Intel 处理器,它定义每条指令格式为:
寻址方式
在之前博文 Linux 内核学习笔记:预备知识之“存储器模型”提到,默认的“段+偏移”寻址组合如下图所示:
作为补充,《汇编语言》(第 2 版)(王爽著)对寻址方式有一个小结特别详细(下图汇编语言格式是 Intel,针对 16 位 8086):
需要注意的是:
- 在 16 位实模式(8086),内存寻址采用的是真正的“段+偏移”,所以线性地址为“段基址*16 + 偏移”;
- 在 32 位保护模式下,内存寻址采用的并不是真正的“段+偏移”,而是“段基址”用作“段选择子”,具体可参考前边博文 Linux 内核学习笔记:预备知识之“存储器管理基础”。
- 在 AT&T 汇编格式中,内存操作数的寻址方式是
section:disp(base, index, scale)
(disp -> displacement) - 在 Intel 汇编格式中,内存操作数的寻址方式为
section:[base + index*scale + disp]
- 所以,在 32 位保护模式下,线性地址(虚拟地址)在计算地址时不用考虑段基址和偏移量,而是采用这样的地址计算方法:
disp + base + index * scale
- 如下图所示的一些例子:
- 图片来源
- 在 AT&T 汇编格式中,内存操作数的寻址方式是
其他一些值得一说的点
这部分主要参考自《深入理解计算机系统》第 3 章“程序的机器级表示”。
比较和测试
有两类指令(有 8、16 和 32 位形式)只设置标志寄存器一些标志位的值而不改变其它寄存器:比较和测试,如下图所示:
cmp
指令根据它们的两个操作数之差来设置标志位。除了只设置标志位而不更新目标寄存器外,它跟sub
指令的行为是一样的。如果两个数相等,零标志位ZF
将会被设置为 1,而其他的的标志位可以用来确定两个操作数之间的大小。test
指令除了只设置标志位而不更新目标寄存器外,它跟and
指令的行为是一样的。典型的用法是,两个操作数是一样的(例如,testl %eax, %eax 用来检查 %eax 是负数、零还是正数),或其中的一个操作数是一个掩码,用来指示哪些位应该被测试。
这两个指令的意义在于它们会改变标志寄存器的一些标志位的值,从而作为其它指令转移的条件。
条件跳转
条件跳转指令如下图所示:
注意:上图中的jmp
是无条件跳转,不属于条件跳转。
关于条件转移指令,请参考博文关于汇编跳转指令的说明。
一个结合cmp
和jmp
指令的例子如下:
- C 源程序
1
2
3
4
5
6
7
8
9
10
11int getSum()
{
int i = 0;
int sum = 0;
for(; i < 10; ++i)
{
sum += i;
}
return sum;
} - 汇编代码可得结果如下:
1
2$ gcc -c getsum.c
$ objdump -d getsum.o
GCC 内联汇编
这部分主要参考资料:
GCC-Inline-Assembly-HOWTO
Linux C 中内联汇编的语法格式及使用方法(Inline Assembly in Linux C)
基本语法规则
内联汇编基本语法模板如下:
1 | asm [ volatile ] ( |
**关键字 asm 和 volatile **
asm
为 gcc 关键字,表示接下来要嵌入汇编代码。为避免 asm 与程序中其它部分产生命名冲突,gcc 还支持__asm__
关键字,与 asm 的作用等价。volatile
为可选关键字,表示不需要 gcc 对汇编代码做任何优化。同样出于避免命名冲突的原因,__volatile__
也是 gcc 支持的与 volatile 等效的关键字。
assembler template
这部分就是要嵌入的汇编命令,由于是在 C 语言中内联汇编代码,故需用双引号""
将命令括起来,以便 gcc 以字符串形式将这些命令传给汇编器 as。例如可以写成这样:”movl %eax, %ebx”
有时候,汇编命令可能有多个,则通常分多行写,每行的命令都用双引号括起来,命令后紧跟 \n\t
之类的分隔符(当然,也可以只用 1 对双引号将多行命令括起来,从语法来说,两种写法均有效,我们可自行决定用哪种格式来写)。示例代码如下所示:
1 | __asm__ __volatile__ ( "movl %eax, %ebx\n\t" \ |
还有时候,根据程序上下文,嵌入的汇编代码中可能会出现一些类似于魔数(Magic Number )的操作数,比如下面的代码:
1 | int a=10, b; |
我们看到,movl指令的操作数(operand)中,出现了 %1、%0,这往往让人摸不着头脑。其实只要知道下面的规则就不会产生疑惑了:
- 在内联汇编中,
操作数通常用数字来引用,具体的编号规则为:若命令共涉及 n 个操作数,则第 1 个输出操作数(the first output operand)被编号为 0,第 2 个输出操作数编号为 1,依次类推,最后 1 个输入操作数(the last input operand)则被编号为 n-1。若无输出操作数,则从输入操作数开始算起。
具体到上面的示例代码中,涉及到 2 个操作数变量 b、a,b 是 output operand,a 是input operand。根据操作数的引用规则,b 用 %0 来引用,a 用 %1 来引用。
- 另外,
当命令中同时出现寄存器和以 %num 来引用的操作数时,会以 %%reg 来引用寄存器(如上例中的 %%eax),以便帮助 gcc 来区分寄存器和由 C 语言提供的操作数。
output operands
该字段为可选项,用以指明输出操作数,典型的格式为:
1 | : "=a" (out_var) |
其中,”=a”指定 output operand 的应遵守的约束(constraint),out_var 为存放指令结果的变量,通常是个 C 语言变量。本例中,“=”是 output operand 字段特有的约束,表示该操作数是只写的(write-only);“a”表示先将命令执行结果输出至 %eax,然后再由寄存器 %eax 更新位于内存中的 out_var。
关于常用的约束规则,本文后面会给出说明。对于输出操作数特有的约束如下:
1 | = 输出变量只写 |
若输出有多个,则典型格式示例如下:
1 | asm ( "cpuid" \ |
可见,我们可以为每个 output operand 指定其约束。
input operands
该字段为可选项,用以指明输入操作数,其典型格式为:
1 | : "constraints" (in_var) |
其中,constraints 可以是 gcc 支持的各种约束方式,in_var 通常为 C 语言提供的输入变量。
与 output operands 类似,当有多个 input 时,典型格式为:
1 | : "constraints1" (in_var1), "constraints2" (in_var2), "constraints3" (in_var3), ... |
当然,input operands + output operands 的总数通常是有限制的,考虑到每种指令集体系结构对其涉及到的指令支持的最多操作数通常也有限制,此处的操作数限制也不难理解。此处具体的上限为 max(10, max_in_instruction),其中 max_in_instruction 为 ISA 中拥有最多操作数的那条指令包含的操作数数目。
需要明确的是,在指明 input operands 的情况下,即使指令没有 output operands,其 : 也需要给出。例如 asm ("sidt %0\n" : :"m"(loc));,该指令即使没有具体的 output operands 也要将 : 写全,因为有后面跟着 :input operands字段。
list of clobbered registers
该字段为可选项,用于列出指令中涉及到的且没出现在 output operands 字段及 input operands 字段的那些寄存器
,如下例子。若寄存器被列入 clobber-list,则等于是告诉 gcc,这些寄存器可能会被内联汇编命令改写。因此,执行内联汇编的过程中,这些寄存器就不会被 gcc 分配给其它进程或命令使用。
1 | int a=10, b; |
常用约束
前面介绍 output operands 和 input operands 字段过程中,我们已经知道这些 operands 通常需要指明各自的 constraints,以便更明确地完成我们期望的功能(试想,如果不明确指定约束而由 gcc 自行决定的话,一旦代码执行结果不符合预期,调试将变得很困难)。
下面开始介绍一些常用的约束项。
寄存器操作数约束(register operand constraint, r)
当操作数被指定为这类约束时,表明汇编指令执行时,操作数被将存储在指定的通用寄存器(General Purpose Registers, GPR)中。例如:
1 | asm ("movl %%eax, %0\n" : "=r"(out_val)); |
该指令的作用是将 %eax 的值返回给 %0 所引用的 C 语言变量 out_val,根据 “=r” 约束可知具体的操作流程为:先将 %eax 值复制给任一 GPR,最终由该寄存器将值写入 %0 所代表的变量中。r 约束指明 gcc 可以先将 %eax 值存入任一可用的寄存器。
通常还可以明确指定作为“中转”的寄存器,约束参数与寄存器的对应关系为:
1 | +---+--------------------+ |
例如,如果想指定用%ebx作为中转寄存器,则命令为:
1 | asm ("movl %%eax, %0\n" : "=b"(out_val)); |
内存操作数约束(Memory operand constraint, m)
当我们不想通过寄存器中转,而是直接操作内存时,可以用 m
来约束。例如:
1 | asm volatile ( "lock; decl %0" : "=m" (counter) : "m" (counter)); |
该指令实现原子减一操作,输入、输出操作数均直接来自内存(也正因如此,才能保证操作的原子性)。
关联约束(matching constraint)
在有些情况下,如果命令的输入、输出均为同一个变量,则可以在内联汇编中指定以 matching constraint 方式分配寄存器,此时,input operand 和 output operand 共用一个“中转”寄存器。例如:
1 | int a = 1, b = 1, c = 0, d = 0; |
上述代码中的 “0” 说明输入操作数 a 与输出操作数 c 共用寄存器 eax,“1” 说明了 b、d 共用寄存器 ebx。注意这里的 “0”、“1” ...(以此类推,按输出操作数出现顺序)就是关联约束字段,修饰输入操作数。
其他约束
其他约束如下:
1 | "m" : A memory operand is allowed, with any kind of address that the machine supports in general. |
x86 专用约束:
1 | Following constraints are x86 specific. |
大总结
博文 GCC 内联汇编(GCC内嵌ARM汇编规则) (转自其他博文)对内联汇编(i386)中常用的约束字符有一个很全面的总结: