__asm__ __volatile__ GCC 的内嵌汇编语法 AT&T 汇编语言语法
开发一个 OS,尽管绝大部分代码只需要用 C/C++等高级语言就可以了,但至少和硬件相关部分的代
码需要使用汇编语言,另外,由于启动部分的代码有大小限 制,使用精练的汇编可以缩小目标代码的 Size。
另外,对于某些需要被经常调用的代码,使用汇编来写可以提高性能。所以我们必须了解汇编语言,即使
你有可 能并不喜欢它。如果你是计算机专业的话,在大学里你应该学习过 Intel 格式的 8086/80386 汇编,
这里就不再讨论。如果我们选择的 OS 开发工具是 GCC 以及 GAS 的话,就必须了解 AT&T 汇编语言语法,
因为 GCC/GAS 只支持这种汇编语法。
本书不会去讨论 8086/80386 的汇编编程,这类的书籍很多,你可以参考它们。这里只会讨论 AT&T 的汇编
语法,以及 GCC 的内嵌汇编语法。
1.寄存器引用
引用寄存器要在寄存器号前加百分号%,如“movl %eax, %ebx”。
80386 有如下寄存器:
8 个 32-bit 寄存器 %eax,%ebx,%ecx,%edx,%edi,%esi,%ebp,%esp;
8 个 16-bit 寄存器,它们事实上是上面 8 个 32-bit 寄存器的低 16 位:%ax,%bx,%cx,%dx,%di,%si,%bp,%sp;
8 个 8-bit 寄存器:%ah,%al,%bh,%bl,%ch,%cl,%dh,%dl。它们事实上是寄存器%ax,%bx,%cx,%dx
的高 8 位和低 8 位;
6 个段寄存器:%cs(code),%ds(data),%ss(stack), %es,%fs,%gs;
3 个控制寄存器:%cr0,%cr2,%cr3;
6 个 debug 寄存器:%db0,%db1,%db2,%db3,%db6,%db7;
2 个测试寄存器:%tr6,%tr7;
8 个浮点寄存器栈:%st(0),%st(1),%st(2),%st(3),%st(4),%st(5),%st(6),%st(7)。
2. 操作数顺序
操作数排列是从源(左)到目的(右),如“movl %eax(源), %ebx(目的)”
3. 立即数
使用立即数,要在数前面加符号$, 如“movl $0x04, %ebx”
或者:
para = 0x04
movl $para, %ebx
指令执行的结果是将立即数 04h 装入寄存器 ebx。
4. 符号常数
符号常数直接引用 如
value: .long 0x12a3f2de
movl value , %ebx
指令执行的结果是将常数 0x12a3f2de 装入寄存器 ebx。
引用符号地址在符号前加符号$, 如“movl $value, % ebx”则是将符号 value 的地址装入寄存器 ebx。
5. 操作数的长度
操作数的长度用加在指令后的符号表示 b(byte, 8-bit), w(word, 16-bits), l(long, 32-bits),如“movb %al, %bl”,
“movw %ax, %bx”,“movl %eax, %ebx ”。
如 果没有指定操作数长度的话,编译器将按照目标操作数的长度来设置。比如指令“mov %ax, %bx”,由
于目标操作数 bx 的长度为 word,那么编译器将把此指令等同于“movw %ax, %bx”。同样道理,指令“mov
$4, %ebx”等同于指令“movl $4, %ebx”,“push %al”等同于“pushb %al”。对于没有指定操作数长度,
但编译器又无法猜测的指令,编译器将会报错,比如指令“push $4”。
6. 符号扩展和零扩展指令
绝大多数面向 80386 的 AT&T 汇编指令与 Intel 格式的汇编指令都是相同的,符号扩展指令和零扩展指令则
是仅有的不同格式指令。
符号扩展指令和零扩展指令需要指定源操作数长度和目的操作数长度,即使在某些指令中这些操作数是隐
含的。
在 AT& T 语法中,符号扩展和零扩展指令的格式为,基本部分"movs"和"movz"(对应 Intel 语法的 movsx 和
movzx),后面跟上源操作数长度和 目的操作数长度。movsbl 意味着 movs (from)byte (to)long;movbw
意味着 movs (from)byte (to)word;movswl 意味着 movs (from)word (to)long。对于 movz 指令也
一样。比如指令“movsbl %al, %edx”意味着将 al 寄存器的内容进行符号扩展后放置到 edx 寄存器中。
其它的 Intel 格式的符号扩展指令还有:
cbw -- sign-extend byte in %al to word in %ax;
cwde -- sign-extend word in %ax to long in %eax;
cwd -- sign-extend word in %ax to long in %dx:%ax;
cdq -- sign-extend dword in %eax to quad in %edx:%eax;
对应的 AT&T 语法的指令为 cbtw,cwtl,cwtd,cltd。
7. 调用和跳转指令
段内调用和跳转指令为"call","ret"和"jmp",段间调用和跳转指令为"lcall","lret"和"ljmp"。
段 间 调 用 和 跳 转 指 令 的 格 式 为“lcall/ljm p $SECTION, $OFFSET” , 而 段 间 返 回 指 令 则 为“lret
$STACK-ADJUST”。
8. 前缀
操作码前缀被用在下列的情况:
字符串重复操作指令(rep,repne);
指定被操作的段(cs,ds,ss,es,fs,gs);
进行总线加锁(lock);
指定地址和操作的大小(data16,addr16);
在 AT&T 汇编语法中,操作码前缀通常被单独放在一行,后面不跟任何操作数。例如,对于重复 scas 指令,
其写法为:
repne
scas
上述操作码前缀的意义和用法如下:
指定被操作的段前缀为 cs,ds,ss,es,fs,和 gs。在 AT&T 语法中,只需要按照 section:memory-operand 的格式就
指定了相应的段前缀。比如:lcall %cs:realmode_swtch
操作数/地址大小前缀是“data16”和"addr16",它们被用来在 32-bit 操作数/地址代码中指定 16-bit 的操作
数/地址。
总 线加锁前缀“lock”,它是为了在多处理器环境中,保证在当前指令执行期间禁止一切中断。这个前缀
仅仅对 ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG,DEC, INC, NEG, NOT, OR, SBB, SUB, XOR,
XADD,XCHG 指令有效,如果将 Lock 前缀用在其它指令之前,将会引起异常。
字符串重复操作前缀"rep","repe","repne"用来让字符串操作重复“%ecx”次。
9. 内存引用
Intel 语法的间接内存引用的格式为:
section:[base+index*scale+displacement]
而在 AT&T 语法中对应的形式为:
section:displacement(base,index,scale)
其 中,base 和 index 是任意的 32-bit base 和 index 寄存器。scale 可以取值 1,2,4,8。如果不指定 scale 值,
则默认值为 1。section 可以指定任意的段寄存器作为段前 缀,默认的段寄存器在不同的情况下不一样。如
果你在指令中指定了默认的段前缀,则编译器在目标代码中不会产生此段前缀代码。
下面是一些例子:
-4(%ebp):base=%ebp,displacement=-4,section 没有指定,由于 base=%ebp,所以默认的 section=%ss,index,scale
没有指定,则 index 为 0。
foo(,%eax,4):index=%eax,scale=4,displacement=foo。其它域没有指定。这里默认的 section=%ds。
foo(,1):这个表达式引用的是指针 foo 指向的地址所存放的值。注意这个表达式中没有 base 和 index,并且
只有一个逗号,这是一种异常语法,但却合法。
%gs:foo:这个表达式引用的是放置于%gs 段里变量 foo 的值。
如果 call 和 jump 操作在操作数前指定前缀“*”,则表示是一个绝对地址调用/跳转,也就是说 jmp/call 指
令指定的是一个绝对地址。如果没有指定"*",则操作数是一个相对地址。
任何指令如果其操作数是一个内存操作,则指令必须指定它的操作尺寸(byte,word,long),也就是说必须带
有指令后缀(b,w,l)。
.3 GCC Inline ASM
GCC 支持在 C/C++代码中嵌入汇编代码,这些汇编代码被称作 GCC Inline ASM——GCC 内联汇编。这是
一个非常有用的功能,有利于我们将一些 C/C++语法无法表达的指令直接潜入 C/C++代码中,另外也允许
我们直接写 C/C++代码中使用汇编编写简洁高效的代码。
1.基本内联汇编
GCC 中基本的内联汇编非常易懂,我们先来看两个简单的例子:
__asm__("movl %esp,%eax"); // 看起来很熟悉吧!
或者是
__asm__("
movl $1,%eax // SYS_exit
xor %ebx,%ebx
int $0x80
");
或
__asm__(
"movl $1,%eax\r\t" \
"xor %ebx,%ebx\r\t" \
"int $0x80" \
);
基本内联汇编的格式是
__asm__ __volatile__("Instruction List");
1、__asm__
__asm__是 GCC 关键字 asm 的宏定义:
#define __asm__ asm
__asm__或 asm 用来声明一个内联汇编表达式,所以任何一个内联汇编表达式都是以它开头的,是必不可少
的。
2、Instruction List
Instruction List 是汇编指令序列。它可以是空的,比如:__asm__ __volatile__(""); 或__asm__ ("");都是完全合
法的内联汇编表达式,只不过这两条语句没有什么意义。但并非所有 Instruction List 为空的内联汇编表达式
都是没有意义的,比如:__asm__ ("":::"memory"); 就非常有意义,它向 GCC 声明:“我对内存作了改动”,
GCC 在编译的时候,会将此因素考虑进去。
我们看一看下面这个例子:
$ cat example1.c
int main(int __argc, char* __argv[])
{
int* __p = (int*)__argc;
(*__p) = 9999;
//__asm__("":::"memory");
if((*__p) == 9999)
return 5;
return (*__p);
}
在 这段代码中,那条内联汇编是被注释掉的。在这条内联汇编之前,内存指针__p 所指向的内存被赋值为
9999,随即在内联汇编之后,一条 if 语句判断__p 所指向的内存与 9999 是否相等。很明显,它们是相等的。
GCC 在优化编译的时候能够很聪明的发现这一点。我们使用下面的命令行对其进行编译:
$ gcc -O -S example1.c
选项-O 表示优化编译,我们还可以指定优化等级,比如-O2 表示优化等级为 2;选项-S 表示将 C/C++源文
件编译为汇编文件,文件名和 C/C++文件一样,只不过扩展名由.c 变为.s。
我们来查看一下被放在 example1.s 中的编译结果,我们这里仅仅列出了使用 gcc 2.96 在 redhat 7.3 上编译后
的相关函数部分汇编代码。为了保持清晰性,无关的其它代码未被列出。
$ cat example1.s
main:
pushl %ebp
movl %esp, %ebp
movl 8(%ebp), %eax # int* __p = (int*)__argc
movl $9999, (%eax) # (*__p) = 9999
movl $5, %eax # return 5
popl %ebp
ret
参 照一下 C 源码和编译出的汇编代码,我们会发现汇编代码中,没有 if 语句相关的代码,而是在赋值语
句(*__p)=9999 后直接 return 5;这是因为 GCC 认为在(*__p)被赋值之后,在 if 语句之前没有任何改变(*__p)
内容的操作,所以那条 if 语句的判断条件(*__p) == 9999 肯定是为 true 的,所以 GCC 就不再生成相关代码,
而是直接根据为 true 的条件生成 return 5 的汇编代码(GCC 使用 eax 作为保存返回值的寄存器)。
我们现在将 example1.c 中内联汇编的注释去掉,重新编译,然后看一下相关的编译结果。
$ gcc -O -S example1.c
$ cat example1.s
main:
pushl %ebp
movl %esp, %ebp
movl 8(%ebp), %eax # int* __p = (int*)__argc
movl $9999, (%eax) # (*__p) = 9999
#APP
# __asm__("":::"memory")
#NO_APP
cmpl $9999, (%eax) # (*__p) == 9999 ?
jne .L3 # false
movl $5, %eax # true, return 5
jmp .L2
.p2align 2
.L3:
movl (%eax), %eax
.L2:
popl %ebp
ret
由于内联汇编语句__asm__("":::"memory")向 GCC 声明,在此内联汇编语句出现的位置内存内容可能了改变,
所以 GCC 在编译时就不能像刚才那样处理。这次,GCC 老老实实的将 if 语句生成了汇编代码。
可能有人会质疑:为什么要使用__asm__("":::"memory")向 GCC 声明内存发生了变化?明明“Instruction List”
是空的,没有任何对内存的操作,这样做只会增加 GCC 生成汇编代码的数量。
确 实,那条内联汇编语句没有对内存作任何操作,事实上它确实什么都没有做。但影响内存内容的不仅仅
是你当前正在运行的程序。比如,如果你现在正在操作的内存 是一块内存映射,映射的内容是外围 I/O 设
备寄存器。那么操作这块内存的就不仅仅是当前的程序,I/O 设备也会去操作这块内存。既然两者都会去
操作同一块 内存,那么任何一方在任何时候都不能对这块内存的内容想当然。所以当你使用高级语言
C/C++写这类程序的时候,你必须让编译器也能够明白这一点,毕竟高 级语言最终要被编译为汇编代码。
你可能已经注意到了,这次输出的汇编结果中,有两个符号:#APP 和#NO_APP,GCC 将内联汇编语 句中
"Instruction List"所列出的指令放在#APP 和#NO_APP 之间,由于__asm__("":::"memory")中“Instruction List”
为空,所以#APP 和#NO_APP 中间也没有任何内容。但我们以后的例子会更加清楚的表现这一点。
关于为什么内联汇编__asm__("":::"memory")是一条声明内存改变的语句,我们后面会详细讨论。
刚才我们花了大量的内容来讨论"Instruction List"为空是的情况,但在实际的编程中,"Instruction List"绝大多
数情况下都不是空的。它可以有 1 条或任意多条汇编指令。
当 在"Instruction List"中有多条指令的时候,你可以在一对引号中列出全部指令,也可以将一条或几条指令
放在一对引号中,所有指令放在多对引号中。如果是前者,你可以将 每一条指令放在一行,如果要将多条
指令放在一行,则必须用分号(;)或换行符(\n,大多数情况下\n 后还要跟一个\t,其中\n 是为了换行,
\t 是为了 空出一个 tab 宽度的空格)将它们分开。比如:
__asm__("movl %eax, %ebx
sti
popl %edi
subl %ecx, %ebx");
__asm__("movl %eax, %ebx; sti
popl %edi; subl %ecx, %ebx");
__asm__("movl %eax, %ebx; sti\n\t popl %edi
subl %ecx, %ebx");
都是合法的写法。如果你将指令放在多对引号中,则除了最后一对引号之外,前面的所有引号里的最后一
条指令之后都要有一个分号(;)或(\n)或(\n\t)。比如:
__asm__("movl %eax, %ebx
sti\n"
"popl %edi;"
"subl %ecx, %ebx");
__asm__("movl %eax, %ebx; sti\n\t"
"popl %edi; subl %ecx, %ebx");
__asm__("movl %eax, %ebx; sti\n\t popl %edi\n"
"subl %ecx, %ebx");
__asm__("movl %eax, %ebx; sti\n\t popl %edi;" "subl %ecx, %ebx");
都是合法的。
上述原则可以归结为:
任意两个指令间要么被分号(;)分开,要么被放在两行;
放在两行的方法既可以从通过\n 的方法来实现,也可以真正的放在两行;
可以使用 1 对或多对引号,每 1 对引号里可以放任一多条指令,所有的指令都要被放到引号中。
在基本内联汇编中,“Instruction List”的书写的格式和你直接在汇编文件中写非内联汇编没有什么不同,
你可以在其中定义 Label,定义对齐(.align n ),定义段(.section name )。例如:
__asm__(".align 2\n\t"
"movl %eax, %ebx\n\t"
"test %ebx, %ecx\n\t"
"jne error\n\t"
"sti\n\t"
"error: popl %edi\n\t"
"subl %ecx, %ebx");
上面例子的格式是 Linux 内联代码常用的格式,非常整齐。也建议大家都使用这种格式来写内联汇编代码。
3、__volatile__
__volatile__是 GCC 关键字 volatile 的宏定义:
#define __volatile__ volatile
__volatile__ 或 volatile 是可选的,你可以用它也可以不用它。如果你用了它,则是向 GCC 声明“不要动我
所写的 Instruction List,我需要原封不动的保留每一条指令”,否则当你使用了优化选项(-O)进行编译时,
GCC 将会根据自己的判断决定是否将这个内联汇编表达式中的指 令优化掉。
那么 GCC 判断的原则是什么?我不知道(如果有哪位朋友清楚的话,请告诉我)。我试验了一下,发现一
条内联汇编语句如果是基本 内联汇编的话(即只有“Instruction List”,没有 Input/Output/Clobber 的内联
汇编,我们后面将会讨论这一点),无论你是否使用__volatile__来修饰, GCC 2.96 在优化编译时,都会原
封不动的保留内联汇编中的“Instruction List”。但或许我的试验的例子并不充分,所以这一点并不能够得
到保证。
为了保险起见,如果你不想让 GCC 的优化影响你的内联汇编代码,你最好在前面都加上__volatile__,而不
要依赖于编译器的原则,因为即使你非常了解当前编译器的优化原则,你也无法保证这种原则将来不会发
生变化。而__volatile__的含义却是恒定的。
2、带有 C/C++表达式的内联汇编
GCC 允许你通过 C/C++表达式指定内联汇编中"Instrcuction List"中指令的输入和输出,你甚至可以不关心到
底使用哪个寄存器被使用,完全靠 GCC 来安排和指定。这一点可以让程序员避免去考虑有限的寄存器的使
用,也可以提高目标代码的效率。
我们先来看几个例子:
__asm__ (" " : : : "memory" ); // 前面提到的
__asm__ ("mov %%eax, %%ebx" : "=b"(rv) : "a"(foo) : "eax", "ebx");
__asm__ __volatile__("lidt %0": "=m" (idt_descr));