汇编格式 AT&T 与 Intel

汇编格式 AT&T 与 Intel

《CSAPP》中为 AT&T 格式,《汇编语言 王爽》中为 Intel 格式

前言

机器指令是用二进制代码表示的 CPU 可以直接识别和执行的一种指令系统的集合,不同的 CPU 架构有不同的机器指令。汇编指令是机器指令便于记忆的书写格式,汇编指令编写完成后通过汇编器将其翻译成机器指令供 CPU 执行,因此,汇编器的功能是将汇编指令翻译成机器指令

同一条机器指令可以用不同的汇编指令表达,确保汇编器执行时无错误即可。不同的汇编指令格式衍生出不同的汇编语法且都有一个与之对应的汇编器。

随着计算机的发展,不同厂家形成了自成一派的汇编语言,并有自己的汇编器。不同的汇编语言,实现相同的机器指令的语法可能不一致

常见的汇编器有:

  • GAS (GNU Assembler),使用AT&T语法格式
  • MASM (Microsoft Macro Assembler),使用Intel语法格式
  • NASM (Netwide Assembler),使用的语法格式与Intel类似,但是更简单
  • FASM (Flat Assembler)

GAS的AT&T的语法格式查询

MASMT的Intel语法格式查询

语法格式

寄存器名

AT&T 中寄存器名要加前缀%,而 Intel 则不需要。例如:

pushl %eax # AT&T 格式
push eax # Intel 格式

立即操作数

AT&T 中用$前缀表示一个立即数,而 Intel 不用带任何前缀。例如:

pushl $1 # AT&T
push 1 # Intel

操作方向

AT&T 与 Intel 中的源操作数和目标操作数的位置正好相反。

AT&T 目标操作数在源操作数的右边,Intel 目标操作数在源操作数的左边。例如:

addl $1,%eax # AT&T
add eax,1 # Intel

操作字长

AT&T 操作数的字长由操作数的最后一个字母决定,后缀以及表示字长如下:

  • b:byte,8 比特(bit)
  • w:word,16比特
  • l:long,32比特

Intel 操作数的字长用byte ptrword ptr等指令前缀来表示。例如:

movb val,%al # AT&T
movl al,byte ptr val # Intel

绝对转移和调用

AT&T 绝对转移jump和调用call的操作数要加入*前缀,而 Intel 则不需要

远程转移和远程子调用

AT&T 远程转移指令ljump和远程子调用指令lcall,而 Intel 则为jmp farcall far

ljump $section,$offset # AT&T
lcall $section,$offset

jmp far section:offset # Intel
call far section:offset

相对应的远程返回

lret $stack_adjust # AT&T
ret far stack_adjust # Intel

内存操作数的寻址方式

AT&T 的格式为section:disp(base,index,scale),而 Intel 为section:[base + index*scale + disp]

下面是一些内存操作数的例子:

# AT&T 
movl -4(%ebp),%eax
movl array(,%eax,4),%eax
movw array(%ebx,%eax,4),%cx
movb $4,%fs:(%eax)

# Intel
mov eax,[ebp - 4]
mov eax,[eax*4 + array]
mov cx,[ebx + 4*eax + array]
mov fs:eax,4

Hello World

在 Linux 操作系统中,最简洁的方法是使用内核提供的系统调用。这种方法的最大好处时可以直接和操作系统内核进行通讯,因为不需要链接诸如libc这样的函数库,也不需要使用ELF解释器,所以代码尺寸小且执行速度快。

Linux 是一个运行在保护模式下的 32 位操作系统,采用 flat memory 模式,目前最常用到的是ELF格式(Executable and Linkable Format,可执行可链接文件格式)的二进制代码。

一个 ELF 格式的可执行程序通常划分为如下几个部分:

  • .text:只读的代码区
  • .data:可读写的数据区
  • .bss:可读写且没有初始化的数据区

代码区和数据区在 ELF 统称为section根据实际需要你可以使用其它标准的section,也可以添加自定义的section,但一个 ELF 可执行程序至少应该有一个.text部分。

下面给出一个输出 Hello, world! 的程序

# AT&T 格式;hello.s
.data    # 数据段声明
    msg : .string "Hello, world!\\n" # 要输出的字符串
    len = . - msg # 字符串长度
.txt # 代码段声明
.global _start # 指定入口函数
_start: # 在屏幕上显示一个字符串
    movl $len,%edx # 参数3,字符串长度
    movl $msg,%ecx # 参数2,要显示的字符串
    movl $1,%ebx # 参数1,文件描述符(stdout)
    movl $4,%eax # 系统调用号(sys_write)
    int $0x80 # 调用内核功能
        # 退出程序
    movl $0,%ebx # 参数1,退出代码
    movl $1,%eax # 系统调用号(sys_exit)
    int $0x80 # 调用内核功能

初次接触到 AT&T 格式的汇编代码时,很多程序员都认为太晦涩难懂了,没有关系,在 Linux 平台上同样可以使用 Intel 格式来编写汇编程序

; Intel 格式;hello.asm
section .data ; 数据段声明
    msg db "Hello,world!",0xA ; 要输出的字符串
    len equ $ - msg ; 字符串长度

section .text ; 代码段声明
global _start ; 指定入口函数

_start: ; 在屏幕上显示一个字符串
    mov edx,len    ; 参数3,字符串长度
    mov ecx,msg ; 参数2,要显示的字符串
    mov ebx,1 ; 参数1,文件描述符(stdout)
    mov eax,4 ; 系统调用号(sys_write)
    int 0x80 ; 调用内核功能
        ; 退出程序
    mov ebx,0 ; 参数1,退出代码
    mov eax,1 ; 系统调用号(sys_exit)
    int 0x80    ; 调用内核功能

上面两个汇编程序采用的语法虽然完全不同,单功能都是调用 Linux 内核提供的sys_write来显示一个字符串,然后再调用sys_exit退出程序。在 Linux 内核文件include/asm-i386/unistd.h中,可以找到所有系统调用的定义

系统调用

即便是最简单的汇编程序,也难免用到输入、输出、退出等操作,要进行这些操作则需要调用操作系统提供的服务,也就是系统调用。除非你的程序只完成加减乘除等数学运算,否则很难避免使用系统调用,事实上各种操作系统的汇编程序除了系统调用不同之外其它都是很类似的。

在 Linux 平台下有两种方式来使用系统调用:

  • 利用封装后的 C 库(libc)
  • 通过汇编直接调用

其中通过汇编语言来使用系统调用,是最高效的使用 Linux 内核服务的方法,因为最终生成的程序不需要与任何库进行链接,而是直接和内核通信

和 DOS 一样,Linux 下的系统调用也是通过中断(int 0x80)来实现的。在执行int 80指令时,寄存器 eax 中存放的是系统调用的功能号,而传给系统调用的参数则必须按照顺序放到寄存器 ebx、ecx、edx、esi、edi 中,当系统调用完成之后,返回值可以在寄存器 eax 中获得

所有的系统调用功能号都可以在文件/usr/include/bits/syscall.h中找到,为了方便使用,它们是用SYS_这样的宏来定义的,如SYS_writeSYS_exit等。例如,经常用到的write函数是如下定义的:

ssize_t write(int fd, const void *buf, size_t count);
# 该函数的功能最终通过 SYS_write 这一系列调用来实现的
# 根据上面的约定,参数 fd、buf、count 分别存在寄存器 ebx、ecx、edx 中
# 而系统调用号 SYS_write 则放在寄存器 eax 中,当 int 0x80 指令执行完后,返回值可以从寄存器 eax 中获得

在进行系统调用时最多只有 5 个寄存器来保存参数,当一个系统调用所需的参数个数大于 5 时,执行 int 0x80 指令时仍需将系统调用功能号保存在寄存器 eax 中,所不同的是全部参数应该依次放在一块连续的内存区域(栈空间)里,同时在寄存器 ebx 中保存指向该内存区域的指针。系统调用完成之后,返回值仍将保存在寄存器 eax 中

命令行参数

在 Linux 操作系统中,当一个可执行程序通过命令行启动时,其所需的参数将被保存到栈中:

  1. argc
  2. 指向各个命令行参数的指令数组 argv
  3. 指向环境变量的指令数据 envp

在编写汇编语言程序时,很多时候需要对这些参数进行处理,下面的代码师范了如何在汇编代码中进行命令行参数的处理:

# args.s
.txt
.global _start

_start:
popl %ecx # argc

vnext:
popl %ecx # argv
test %ecx,%ecx # 空指针表明结束
jz exit
movl %ecx,%ebx
xorl %edx,%edx

strlen:
movb (%ebx),%al
inc %edx
inc %ebx
test %al,%al
jnz strlen
movb $10,-1(%ebx)
movl $4,%eax # 系统调用号(sys_write)
movl $1,%ebx # 文件描述符(stdout)
int $0x80
jmp vnext

exit: movl $1,%eax # 系统调用号(sys_exit)
xorl %ebx,%ebx # 退出代码
int $0x80
ret

GCC 内联汇编

用汇编编写的程序虽然运行速度快,但开发速度非常慢,效率也低。如果只是相对关键代码段进行优化,或许更好的办法是将汇编指令嵌入到 C 语言程序中,从而充分利用高级语言和汇编语言各自的特点。单一般来讲,在 C 代码中嵌入汇编语句要比“纯粹”的汇编语言代码复杂得多,因为需要解决如何分配寄存器,以及如何与 C 代码中的变量相结合等问题

GCC 提供了很好的内联汇编支持,最基本的格式是:__asm__("asm statements");。例如:__asm__("nop");

如果需要同时执行多条汇编语句,则应该用\\n\\t将各个语句分隔,例如:

__asm__("pushl %%eax \\n\\t"
"movl $0,%%eax \\n\\t"
"popl %eax")

通常嵌入到 C 代码中的汇编语句很难做到与其它部分没有任何关系,因此更多时候用到完整的内联汇编格式:__asm__("asm statements" : outputs : inputs : registers-modified)

插入到 C 代码中的汇编语句是以:分隔的四个部分,其中第一个部分就是汇编代码本身,通常称为指令部,其格式在汇编语言中使用的格式基本相同。指令部是必须的,而其它部分则可以根据实际情况而省略。

在将汇编语句嵌入到 C 代码中时,操作数如何与 C 代码中的变量相结合是个很大的问题。GCC 采用如下的方法来解决这个问题:程序员提供具体的指令,而对寄存器的使用则只需要给出样板和约束条件就可以了,具体如何将寄存器与变量结合起来完全由 GCC 和 GAS 来负责的

在 GCC 内联汇编语句的指令部中,加上前缀%的数字(如:%0%1)表示的就是需要使用寄存器的样板操作数。指令部中使用了几个样板操作数,就表明有几个变量需要与寄存器相结合,这样 GCC 和 GAS 在编译和汇编是会根据后面给定的约束条件进行恰当的处理。由于样板操作数也使用了%作为前缀,因此在涉及到具体的寄存器时,寄存器名前面应该是加上两个%,以免混淆

紧跟在指令部后面的输出部,是规定输出变量如何与样板操作数进行结合的条件,每个条件称为一个“约束”,必要时可以包含多个约束,相互之间用逗号分隔开。每个输出的约束都以=号开始,然后紧跟一个对操作数类型说明的字,最后是如何与变量相结合的约束。凡是与输出部中说明的操作数相结合的寄存器或操作数本身,在执行完嵌入的汇编代码后均不保留执行之前的内容,这是 GCC 在调度寄存器时所使用的依据

输出部后面是输入部,输入约束的格式和输出约束相似,但不带=号。如果一个输入约束要求使用寄存器,则 GCC 在预处理时就会为之分配一个寄存器,并插入必要的指令将操作数装入该寄存器。与输入部中说明的操作数结合的寄存器或操作数本身,在指令完嵌入汇编代码后也不保留执行之前的内容

有时在进行某些操作时,除了要用到进行数据输入和输出的寄存器外,还要使用多个寄存器来保存中间计算的结果,这样就难免会破坏原有寄存器的内容。在 GCC 内联汇编格式中最后一个部分中,可以对产生副作用的寄存器进行说明,以便 GCC 能够采用相应的措施

下面是一个内联汇编的简单例子:

#include 

int main()
{
int a = 10, b = 0;
__asm__ __volatile__("movl %1, %%eax;\n\r"
"movl %%eax,%0"
:"=r"(b)
:"r"(a)
:"%eax");
printf("result: %d, %d\n", a, b);
}

上面的程序是将变量 a 赋值到变量 b,有几点需要说明:

  • b是输出操作数,通过%0来引用,而a是输入操作数,通过%1来引用
  • 输入操作数和输出操作数都使用r进行约束,表示将两个变量存储在寄存器中。输入约束和输出约束的不同点在于输出约束多一个约束修饰符=
  • 在内联汇编语句中使用寄存器 eax 时,寄存器名前面应该加两个%。内联汇编中使用%0%1等来标识变量,任何纸袋一个%的标识符都看成是操作数,而不是寄存器
  • 内联汇编语句的最后一个部分高速 GCC 它将改变寄存器 eax 中的值,GCC 在处理时不应使用该寄存器来存储任何其它的值
  • 由于b被指定成输出操作数,当内联汇编语句执行完毕后,它所保存的值将被更新

在内联汇编从用到的操作数从输出部的第一个约束开始编号,从 0 开始,每个约束计数一次,指令部要引用这些操作数是,只需要在序号前加上%作为前缀就可以了。需要注意的是,内联汇编语句的指令部在引用一个操作数总是将其作为 32 位的长字使用,但实际情况可能需要的是字或字节,因此在约束中指明正确的限定符:

  • mvo:内存单元
  • r:任何寄存器
  • q:寄存器 eax、ebx、ecx、edx 之一
  • ih:直接操作数
  • EF:浮点数
  • g:任意
  • abcd:分别表示寄存器 eax、ebx、ecx、edx
  • SD:寄存器 esi、edi
  • I:常数(0~31)

See Also

你可能感兴趣的