X86

在已经学习过inter汇编相关知识的情况下,学习at&t语法并不困难,毕竟最困难的部分已经过去了。

为什么会有两种语法的x86的汇编语言?

汇编语言这个东西本身就和芯片架构高度绑定,其本身就是机器码的助记符,而x86架构的芯片原本就是intel研制的。

所以这个问题可以转化为,为什么at&t要在已有intel语法的基础上再设计一套新语法?

c语言是大名鼎鼎的贝尔实验室发明的,还有UNIX OS,而贝尔实验室属于AT&T,从四五十年前到现在,跑UNIX的服务器很大一部分使用的是非intel x86 CPU,人家好像也根本就不怎么鸟intel编译器。

直到PC的普及。

然后免费的linux出现了,使个人电脑可以使用类UNIX操作系统。虽说比不上windows,但还是有一部分市场。而linux等类UNIX的官方语言就是C语言。这个时候,AT&T就要考虑如何在x86上把C语言编译成能执行的机器码。

至于at&t为什么没有直接采用intel的语法,倒是有些许说法,有的说使intel申请了专利balabala,也有说at&t还是看不上intel的语法,打算新写一种更通用的语法(以后可以应用在别的芯片上),还可能以上影响都有。

那么,具体的区别呢?

操作数前后缀

在intel的语法中,寄存器和立即数都没有前缀,但在AT&T中,寄存器前冠以“%”,而立即数前冠以“$”。在intel语法中,十六进制和二进制立即数的后缀分别冠以“h”和“b”,而在AT&T中,十六进制前冠以“0x”

Intel与AT&T前缀的区别

Intel语法 AT&T语法
mov eax,8 movl $8,%eax
mov ebx,0ffffh movl $0xffff,%ebx
int 80h int $0x80

操作数的方向

intel与AT&T操作数的方向正好相反。在intel语法中,第一个操作数是目的操作数,第二个操作数源操作数。而在AT&T中,第一个操作数是源操作数,第二个是目的操作数。由此可以看出,AT&T的语法更符合人们通常的阅读习惯。

例如:

Intel语法 AT&T语法
mov eax,[ecx] movl (%ecx),%eax

内存单元操作数

从上面的例子可以看出,内存的操作数也有所不同。在intel的语法中,基寄存器用[]括起来,而AT&T中用()括起来。

例如:

Intel语法 AT&T语法
mov eax,[ebx+5] movl 5(%ebx),%eax

间接寻址

与intel的语法比较,AT&T间接寻址方式可能更晦涩难懂一些intel的指令格格式是

segreg:[base+index*scale+disp]

而AT&T的格式是

%segreg:disp(base,index,scale)

其中index/scale/disp/segreg是可选的,完全可以简化掉,如果没有指定scale而制定了index,则scale的缺省值为1.segreg段寄存器依赖于指令以及应用程序是运行在实模式还是保护模式,在实模式下,它依赖于指令,而在保护模式下segreg是多余的。在AT&T中,当立即数在scale/disp中不应在其前冠以“$”前缀

内存操作数的语法及举例

Intel语法 AT&T语法
指令 foo,segreg:[base+index*scale+disp] 指令 %segreg:disp(base,index,scale),foo
mov eax,[ebx+20h] Movl 0x20(%ebx),%eax
add eax,[ebx+ecx*2h Addl (%ebx,%ecx,0x2),%eax
lea eax,[ebx+ecx] Leal (%ebx,%ecx),%eax
sub eax,[ebx+ecx*4h-20h] Subl -0x20(%ebx,%ecx,0x4),%eax

这个方面,AT&T可能不如intel,[base+index*scale+disp]一眼就可以看出其含义,而disp(base,index,scale)还需要一个简单的计算.

这种寻址方式常常用在访问数据结构数组中某一特定元素的一个字段,其中,base为数组的起始数组,scale为每个数组的大小,index为下标。如果数组元素还是一个结构,则disp为具体字段在结构中的偏移。

操作码的前后缀

在上面的例子中你可能已经注意到,在AT&T的操作码后面有一个后缀,其含义就是指出操作码的大小。“l”表示长整数(32位),“w”表示字(16位),“b”标示字节(8位)。而在Intel语法中,则要内存单元操作数的前面加上byte ptr,word ptr和dword ptr。“dword”即“double word”双字。

也就是说,intel语法把字长声明在操作数,AT&T语法把字长声明在操作数

操作码的后缀举例

Intel语法 AT&T语法
Mov al,bl movb %bl,%al
Mov ax,bx movw %bx,%ax
Mov eax,ebx movl %ebx,%eax
Mov eax, dword ptr [ebx] movl (%ebx),%eax

hello world

顺便简单实现一下汇编语言的hello world。在linux中,有很多办法可以实现在屏幕上显示一个字符串,但最简洁的方式是使用linux内核提供的系统调用。使用这种方法最大的好处是可以直接和操作系统的内核进行通信,不需要链接诸如libc这样的函数库,也不需要使用elf解释器,所以代码尺寸小且执行速度快。

linux是一个运行在兼容32位保护模式的操作系统,采用flat memory模式,.data是可读可写的数据区。而.bss则是可读可写且没有初始化的数据区。代码区和数据区在elf中统称为section。根据实际情况你可以使用其他的标准section,也可以添加自定义的section。但是一个elf可执行文件至少应该有一个.text部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#hello.s
.data # 数据段声明
msg : .string "Hello, world!\\n" # 要输出的字符串
len = . - msg # 字串长度
.text # 代码段声明
.global _start # 指定入口函数
_start: # 在屏幕上显示一个字符串
movl $len, %edx # 参数三:字符串长度
movl $msg, %ecx # 参数二:要显示的字符串
movl $1, %ebx # 参数一:文件描述符(stdout)
movl $4, %eax # 系统调用号(sys_write)
int $0x80 # 调用内核功能
# 退出程序
movl $0,%ebx # 参数一:退出代码
movl $1,%eax # 系统调用号(sys_exit)
int $0x80 # 调用内核功能
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
; hello.asm
section .data ; 数据段声明
msg db "Hello, world!", 0xA ; 要输出的字符串
len equ $ - msg ; 字串长度
section .text ; 代码段声明
global _start ; 指定入口函数
_start: ; 在屏幕上显示一个字符串
mov edx, len ; 参数三:字符串长度
mov ecx, msg ; 参数二:要显示的字符串
mov ebx, 1 ; 参数一:文件描述符(stdout)
mov eax, 4 ; 系统调用号(sys_write)
int 0x80 ; 调用内核功能
; 退出程序
mov ebx, 0 ; 参数一:退出代码
mov eax, 1 ; 系统调用号(sys_exit)
int 0x80 ; 调用内核功能

以上两个代码虽然采用的语法不一样,但功能却都是调用linux内核提供的sys_write来显示一个字符串,然后再调用sys_exit退出程序。

在linux内核源文件include/asm-i386/unistd.h中可以找到所有系统调用的定义。

简单说一下系统调用

即使是最简单的汇编程序,也难免用到诸如输入输出及退出的操作,而要进行这些操作需要调用操作系统所提供的服务,也就是系统调用。出非你的程序只是完成加减乘除等数学运算(不考虑程序开始或结束,不需要外部输入输出),否则讲很难避免使用系统调用,事实上除了系统调用不同之外,各种操作系统的汇编编程往往都是类似的。

在linux上只有两种方式使用系统调用:利用封装后的C库(libc)或则直接使用汇编直接调用。

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

和DOS一样,linux下的系统调用也是很通过中断(int 0x80)来实现的。在执行int 80指令时,寄存器eax中存放的时候系统调用的功能号。而传给系统调用的参数依据32位和64位的不同分别将参数用栈传参和寄存器传参(前6个用 rdi, rsi, rdx, rcx, r8, r9,其余的使用栈传参)。系统调用完成后,返回值可以在寄存器eax中获得。

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

1
ssize_t write(int fd,const void *buf,size_t count);

该函数的功能最终时通过SYS_write这以系统调用实现的。依据上面的约定,参数fd,buf,count分别存在rdi,rsi,rdx,而系统调用号SYS_write则放在寄存器eax中,当int 0x80指令执行完毕,返回值可以从eax中获得。

命令行参数

在linux操作系统中,当一个可执行程序通过命令行启动时,其所需要的参数将被保存到栈中:首先是argc,然后时只想哥哥命令行参数的指针数组argv,最后是指向环境的指针数据envp。在编写汇编语言程序时,很多时候需要对这些参数进行处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# args.s
.text
.globl _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提供了很好的内联汇编支持,最基本的格式是:

1
_asm__("asm statements");

例如:

1
__asm__("nop");这个语句不自行任何操作

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

1
2
3
__asm__( "pushl %%eax \\n\\t"
"movl $0, %%eax \\n\\t"
"popl %eax");
1
2
3
__asm__( "pushl %%eax \\n\\t"
"movl $0, %%eax \\n\\t"
"popl %eax");

通常嵌入到C代码中的汇编代码很难做到与其他部分没有任何关系,因此更多时候需要用到完整的内联汇编格式:

1
__asm__("asm statements" : outputs : inputs : registers-modified);

插入到C代码中的汇编代码是以”:”分割的四个布冯,其中一部分就是汇编代码身,通常被称为之后指令部,其格式和在汇编语言中使用的格式基本相同。指令部分是必须的,而其他部分这则可以根据实际情况进行省略。

在将汇编语句嵌入到C代码中时,操作数如何与C代码中的变量相结合是一个很大的问题。

GCC采用如下办法来解决这个问题:

程序员提供具体的指令,而对寄存器的使用则只需给出”样板”和约束条件就可以了,具体怎样将寄存器和变量解饿起来就是GCC等编译器的事了

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

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

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

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

1
2
3
4
5
6
7
8
9
10
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进行约束,表示变量a和变量b储存在寄存器中。输入约束和输出约束的不同点在于输出约束多一个约束修饰符”=”
  • 在内联汇编语句中使用寄存器eax时,寄存器名前应加两个”%”,即两个”%%eax”.内联汇编使用%0,%1等标示变量,任何只带一个”%”的表示符都看成是操作数,而不是寄存器。
  • 内联汇编的最后一个部分告诉GCC,他将改变寄存器eax的值,GCC编译器在处理时不应该使用该寄存器来储存其他任何值。
  • 由于变量b被指定成输出操作数,当内联汇编语句执行完毕后,她所保存的值将被更新。

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

限定符 意义
“m”、”v”、”o” 内存单元
“r” 任何寄存器
“q” 寄存器eax、ebx、ecx、edx之一
“i”、”h” 直接操作数
“E”和”F” 浮点数
“g” 任意
“a”、”b”、”c”、”d” 分别表示寄存器eax、ebx、ecx和edx
“S”和”D” 寄存器esi、edi
“I” 常数(0至31)

顺便提一下,内联汇编也可以将汇编单独写在一个源文件里,然后extern调用,个人感觉这样可能方便些。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
;nasm -f elf test.asm   compile si as i386 default
;nasm -f elf64 test.asm
;ld -m elf_i386 -o test test.o
;ld -m elf_x86_64 -o test test.o
;这里单独编译也能运行,这里是intel的语法,at&t也还是一样的。
;ld -o test test.o

section .data ;
msg db "hello, world",0xa ;
len equ $ - msg ;
section .text ;

global hello_world ;
;
hello_world: ;
mov edx,len;write(1,str,len)
mov ecx,msg;
mov ebx,1;
mov eax,4;
int 0x80;
mov ebx,0;
mov eax,1;
int 0x80;
ret
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// nasm -f elf64 hello_world.asm
//
// gcc -c test.c -o test.o
// ld test.o srop.o -o test


#include <stdio.h>
#include<linux/prctl.h>

extern hello_world();

int main(int argc, char const *argv[])
{
hello_world();
return 0;
}

资料

https://cloud.tencent.com/developer/article/1804189

https://blog.csdn.net/liaoshengshi/article/details/39989797

2022-12-01

⬆︎TOP