X86 寻址方式、AT&T 汇编语言相关知识、AT&T 与 Intel 汇编语言的比较、gcc 嵌入式汇编
注:本分类下文章大多整理自《深入分析linux内核源代码》一书,另有参考其他一些资料如《linux内核完全剖析》、《linux c 编程一站式学习》等,只是为了更好地理清系统编程和网络编程中的一些概念性问题,并没有深入地阅读分析源码,我也是草草翻过这本书,请有兴趣的朋友自己参考相关资料。此书出版较早,分析的版本为2.4.16,故出现的一些概念可能跟最新版本内核不同。
此书已经开源,阅读地址 http://www.kerneltravel.net
注解:不同平台有不同的instruction
set 即指令集,比如x86, PowerPC,
ARM等平台的指令集是不同的。而汇编一直存在两种不同的语法,在intel的官方文档中使用intel语法,Windows也使用intel语法,而UNIX
系统的汇编器一直使用AT&T语法,下文会比较两种语法的区别。
一、X86 寻址方式
x86的通用寄存器有8个。这些寄存器在大多数指令中是可以任意选用的,比如movl
指令可以把一个立即数传送到eax 中,也可传送到ebx 中。但也有一些指令规定只能用其中某个寄存器做某种用途,例如除法指令idivl
要求被除数在eax 寄存器中,edx 寄存器必须是0,而除数可以在任意寄存器中,计算结果的商数保存在eax
寄存器中(覆盖原来的被除数),余数保存在edx 寄存器中。也就是说,通用寄存器对于某些特殊指令来说也不是通用的。
介绍x86常用的几种寻址方式(Addressing Mode)。内存寻址在指令中可以表示成如下的通用格式:
ADDRESS_OR_OFFSET(%BASE_OR_OFFSET,%INDEX,MULTIPLIER)
ADDRESS_OR_OFFSET(%BASE_OR_OFFSET,%INDEX,MULTIPLIER)
它所表示的地址可以这样计算出来:
FINAL ADDRESS = ADDRESS_OR_OFFSET + BASE_OR_OFFSET + MULTIPLIER * INDEX
注:实际上 final
address
也只是逻辑地址中的32位偏移量部分,需要使用段选择符找到段描述符,进而得到段基地址,两者相加才是线性地址,但在Linux实现中段基地址都为0,故偏移量可以直接当作线性地址,再经过分页转换就是真正的物理地址,也就是说final
address 是程序中访问的地址。具体参见 《80386分段分页机制》
其中ADDRESS_OR_OFFSET 和 MULTIPLIER 必须是常数, BASE_OR_OFFSET 和 INDEX 必须是寄存器。
在有些寻址方式中会省略这4项中的某些项,相当于这些项是0。
在有些寻址方式中会省略这4项中的某些项,相当于这些项是0。
直接寻址(Direct Addressing Mode)。只使用ADDRESS_OR_OFFSET寻址,例如movl ADDRESS, %eax 把ADDRESS地址处的32位数传送到eax 寄存器。
变址寻址(Indexed Addressing Mode) 。movl data_items(,%edi,4), %eax 就属于这种寻址方式,用于访问数组元素比较方便。
间接寻址(Indirect Addressing Mode)。只使用BASE_OR_OFFSET寻址,例如movl (%eax), %ebx ,把eax 寄存器的值看作地址,把内存中这个地址处的32位数传送到ebx 寄存器。注意和movl %eax, %ebx 区分开。
基址寻址(Base Pointer Addressing Mode)。只使用ADDRESS_OR_OFFSET和BASE_OR_OFFSET寻址,例如movl 4(%eax), %ebx ,用于访问结构体成员比较方便,例如一个结构体的基地址保存在eax 寄存器中,其中一个成员在结构体内的偏移量是4字节,要把这个成员读上来就可以用这条指令。
立即数寻址(Immediate Mode)。就是指令中有一个操作数是立即数,例如movl $12,%eax 中的$12, 这其实跟寻址没什么关系,但也算作一种寻址方式。
寄存器寻址(Register Addressing Mode)。就是指令中有一个操作数是寄存器, 例如movl $12, %eax 中的%eax ,这跟内存寻址没什么关系,但也算作一种寻址方式。在汇编程序中寄存器用助记符来表示,在机器指令中则要用几个Bit表示寄存器的编号,这几个Bit也可以看作寄存器的地址,但是和内存地址不在一个地址空间。
二、AT&T 与 Intel 汇编语言的比较
1.前缀
在Intel 的语法中,寄存器和和立即数都没有前缀。但是在AT&T 中,寄存器前冠以“%”,而立即数前冠以“$”。在Intel 的语法中,十六进制和二进制立即数后缀分别冠以“h”和“b”,而在AT&T
中,十六进制立即数前冠以“0x”,如表2.2 所示给出几个相应的例子。
2.操作数的方向
Intel 与AT&T 操作数的方向正好相反。在Intel 语法中,第一个操作数是目的操作数,第二个操作数是源操作数。而在AT&T 中,第一个数是源操作数,第二个数是目的操作数。由此可以看出,AT&T
的语法符合人们通常的阅读习惯。
例如:在Intel 中,mov eax,[ecx]
在AT&T 中,movl (%ecx),%eax
3.内存单元操作数
从上面的例子可以看出,内存操作数也有所不同。在Intel 的语法中,基寄存器用“[]”括起来,而在AT&T 中,用“()”括起来。
例如: 在Intel 中,mov eax,[ebx+5]
在AT&T,movl 5(%ebx),%eax
4.间接寻址方式
与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 中时,不应当在其前冠以“$”前缀,表2.3 给出其语法及几个相应的例子。
从表中可以看出,AT&T 的语法比较晦涩难懂,因为[base+index*scale+disp]一眼就可以看出其含义,而disp(base,index,scale)则不可能做到这点。这种寻址方式常常用在访问数据结构数组中某个特定元素内的一个字段,其中,base
为数组的起始地址,scale 为每个数组元素的大小,index 为下标。如果数组元素还是一个结构,则disp 为具体字段在结构中的位移。
5.操作码的后缀
在上面的例子中你可能已注意到,在AT&T 的操作码后面有一个后缀,其含义就是指出操作码的大小。“l”表示长整数(32 位),“w”表示字(16 位),“b”表示字节(8 位)。而在Intel
的语法中,则要在内存单元操作数的前面加上byte ptr、word ptr 和dword ptr,“dword”对应“long”。表2.4 给出了几个相应的例子。
三、AT&T 汇编语言相关知识
在Linux 源代码中,以.S 为扩展名的文件是“纯”汇编语言的文件。这里,我们结合具体的例子再介绍一些AT&T 汇编语言的相关知识。
1.GNU 汇编程序GAS(GNU Assembly)和连接程序
当你编写了一个程序后,就需要对其进行汇编(assembly)和连接。在Linux 下有两种方式,一种是使用汇编程序GAS 和连接程序ld,一种是使用gcc。我们先来看一下GAS 和ld:
GAS 把汇编语言源文件(.o)转换为目标文件(.o),其基本语法如下:
as filename.s -o filename.o
一旦创建了一个目标文件,就需要把它连接并执行,连接一个目标文件的基本语法为:
ld filename.o -o filename
这里 filename.o 是目标文件名,而filename 是输出(可执行) 文件。
GAS 使用的是AT&T 的语法而不是Intel 的语法,这就再次说明了AT&T 语法是UNIX 世界的标准,你必须熟悉它。如果要使用GNC
的C 编译器gcc,就可以一步完成汇编和连接,例如:
gcc -o example example.S
这里,example.S 是你的汇编程序,输出文件(可执行文件)名为example。其中,扩展名必须为大写的S,这是因为,大写的S 可以使gcc 自动识别汇编程序中的C 预处理命令,像#include、#define、#ifdef、#endif
等,也就是说,使用gcc 进行编译,你可以在汇编程序中使用C 的预处理命令。
2.AT&T 中的节(Section)
在AT&T 的语法中,一个节由.section 关键词来标识,当你编写汇编语言程序时,至少需要有以下3 种节。
section .data:这种节包含程序已初始化的数据,也就是说,包含具有初值的那些变量,例如:
hello: .string "Hello world!\n"hello_len : .long 13
.section .bss:这个节包含程序还未初始化的数据,也就是说,包含没有初值的那些变量。当操作系统装入这个程序时将把这些变量都置为0,例如:
name : .fill 30 # 用来请求用户输入名字name_len : .long 0 # 名字的长度(尚未定义)
当这个程序被装入时,name 和name_len 都被置为0。如果你在.bss 节不小心给一个变量赋了初值,这个值也会丢失,并且变量的值仍为0。使用.bss
比使用.data 的优势在于,.bss 节不占用磁盘的空间。在磁盘上,一个长整数就足以存放.bss 节。当程序被装入到内存时,操作系统也只分配给这个节4 个字节的内存大小。
注意,编译程序把.data 和.bss 在4 字节上对齐(align),例如,.data 总共有34 字节,那么编译程序把它对齐在36 字节上,也就是说,实际给它36 字节的空间。
section .text :这个节包含程序的代码,它是只读节,而.data 和.bss 是读/写节。
注:真正在编译程序的时候,section会被合并为segment,如.text和 .rodata 合并为Text Segment,当然有多个源程序一起编译的话它们的Text Segment 也将最终合并在一起。
3.汇编程序指令(Assembler Directive)
上面介绍的.section 就是汇编程序指令的一种,GNU 汇编程序提供了很多这样的指令(directive),这种指令都是以句点(.)为开头,后跟指令名(小写字母),在此,我们只介绍在内核源代码中出现的几个指令(以arch/i386/kernel/head.S
中的代码为例)。
(1).ascii "string"...
.ascii 表示零个或多个(用逗号隔开)字符串,并把每个字符串(结尾不自动加“0“字节)中的字符放在连续的地址单元。
还有一个与.ascii 类似的.asciz,z 代表“0”,即每个字符串结尾自动加一个“0”字节,例如:
int_msg:
.asciz "Unknown interrupt\n"
(2).byte 表达式
.byte 表示零或多个表达式(用逗号隔开),每个表达式被放在下一个字节单元。
(3).fill 表达式
形式:.fill repeat , size , value
其中,repeat、size 和value 都是常量表达式。Fill 的含义是反复拷贝size 个字节。repeat 可以大于等于0。size 也可以大于等于0,但不能超过8,如果超过8,也只取8。把repeat
个字节以8 个为一组,每组的最高4 个字节内容为0,最低4 字节内容置为value。size 和 value 为可选项。如果第2 个逗号和value 值不存在,则假定value 为0。如果第1 个逗号和size 不存在,则假定size 为1。
例如,在Linux 初始化的过程中,对全局描述符表GDT 进行设置的最后一句为:
.fill NR_CPUS*4,8,0 /* space for TSS's and LDT's */
因为每个描述符正好占8 个字节,因此,.fill 给每个CPU 留有存放4 个描述符的位置。
(4).globl symbol
.globl 使得连接程序(ld)能够看到symbl。如果你的局部程序中定义了symbl,那么,与这个局部程序连接的其他局部程序也能存取symbl,例如:
.globl SYMBOL_NAME(idt).globl SYMBOL_NAME(gdt)
定义idt 和gdt 为全局符号。
(5).quad bignums
.quad 表示零个或多个bignums(用逗号分隔),对于每个bignum,其缺省值是8 字节整数。如果bignum 超过8 字节,则打印一个警告信息;并只取bignum 最低8 字节。
例如,对全局描述符表的填充就用到这个指令:
.quad 0x00cf9a000000ffff /* 0x10 kernel 4GB code at 0x00000000 */.quad 0x00cf92000000ffff /* 0x18 kernel 4GB data at 0x00000000 */.quad 0x00cffa000000ffff /* 0x23 user 4GB code at 0x00000000 */.quad 0x00cff2000000ffff /* 0x2b user 4GB data at 0x00000000 */
(6).rept count
把.rept 指令与.endr 指令之间的行重复count 次,例如
.rept 3.long 0.endr
相当于
.long 0.long 0.long 0
(7).space size , fill
这个指令保留size 个字节的空间,每个字节的值为fill。size 和fill 都是常量表达式。如果逗号和fill 被省略,则假定fill 为0,例如在arch/i386/bootl/setup.S 中有一句:
.space 1024
表示保留1024 字节的空间,并且每个字节的值为0。
(8).word expressions
这个表达式表示任意一节中的一个或多个表达式(用逗号分开),表达式的值占两个字节,例如:
gdt_descr:
.word GDT_ENTRIES*8-1
表示变量gdt_descr 的值为GDT_ENTRIES*8-1
(9).long expressions
这与.word 类似
(10).org new-lc , fill
把当前节的位置计数器提前到new-lc(New Location Counter)。new-lc 或者是一个常量表达式,或者是一个与当前子节处于同一节的表达式。也就是说,你不能用.org 横跨节:如果new-lc
是个错误的值,则.org 被忽略。.org 只能增加位置计数器的值,或者让其保持不变;但绝不能用.org 来让位置计数器倒退。
注意,位置计数器的起始值是相对于一个节的开始的,而不是子节的开始。当位置计数器被提升后,中间位置的字节被填充值fill(这也是一个常量表达式)。如果逗号和fill 都省略,则fill 的缺省值为0。
例如:.org 0x2000
ENTRY(pg0)
表示把位置计数器置为0x2000,这个位置存放的就是临时页表pg0。
四、gcc 嵌入式汇编
在Linux 的源代码中,有很多C 语言的函数中嵌入一段汇编语言程序段,这就是gcc 提供的“asm”功能,例如在include/asm-i386/system.h 中定义的,读控制寄存器CR0 的一个宏read_cr0():
C++ Code
1
2 3 4 5 6 7 |
#define read_cr0() ({ \
unsigned int __dummy; \ __asm__( \ "movl %%cr0,%0\n\t" \ :"=r" (__dummy)); \ __dummy; \ }) |
1.嵌入式汇编的一般形式
__asm__ __volatile__("<asm routine>" : output : input : modify);
其中,__asm__表示汇编代码的开始,其后可以跟__volatile__(这是可选项),其含义是避免“asm”指令被删除、移动或组合;然后就是小括弧,括弧中的内容是我们介绍的重点。
• "<asm routine>"为汇编指令部分,例如,"movl %%cr0,%0\n\t"。数字前加前缀“%“,如%1,%2 等表示使用寄存器的样板操作数。可以使用的操作数总数取决于具体CPU 中通用寄存器的数量,如Intel
可以有8 个。指令中有几个操作数,就说明有几个变量需要与寄存器结合,由gcc 在编译时根据后面输出部分和输入部分的约束条件进行相应的处理。由于这些样板操作数的前缀使用了“%”,因此,在用到具体的寄存器时就在前面加两个“%”,如%%cr0。
• 输出部分(output),用以规定对输出变量(目标操作数)如何与寄存器结合的约束(constraint),输出部分可以有多个约束,互相以逗号分开。每个约束以“=”开头,接着用一个字母来表示操作数的类型,然后是关于变量结合的约束。例如,上例中:
:"=r" (__dummy)
“=r”表示相应的目标操作数(指令部分的%0)可以使用任何一个通用寄存器,并且变量__dummy 存放在这个寄存器中,但如果是:
:“=m”(__dummy)
“=m”就表示相应的目标操作数是存放在内存单元__dummy 中。
表示约束条件的字母很多,表 2.5 给出了几个主要的约束字母及其含义。
输入部分(Input):输入部分与输出部分相似,但没有“=”。如果输入部分一个操作数所要求使用的寄存器,与前面输出部分某个约束所要求的是同一个寄存器,那就把对应操作数的编号(如“1”,“2”等)放在约束条件中,在后面的例子中,我们会看到这种情况。
修改部分(modify):这部分常常以“memory”为约束条件,以表示操作完成后内存中的内容已有改变,如果原来某个寄存器的内容来自内存,那么现在内存中这个单元的内容已经改变。
注意,指令部分为必选项,而输入部分、输出部分及修改部分为可选项,当输入部分存在,而输出部分不存在时,分号“:”要保留,当“memory”存在时,三个分号都要保留,例如system.h
中的宏定义__cli():
C++ Code
1
|
#define __cli() __asm__ __volatile__("cli": : :"memory")
|
Linux 源代码中,在arch 目录下的.h 和.c 文件中,很多文件都涉及嵌入式汇编,下面以system.h 中的C 函数为例,说明嵌入式汇编的应用。
(1)简单应用
C++ Code
1
2 3 |
#define __save_flags(x) __asm__ __volatile__("pushfl ; popl %0":"=g"(x): /* no input*/)
#define __restore_flags(x) __asm__ __volatile__("pushl %0 ; popfl": /* no output */:"g"(x):"memory", "cc") |
第1 个宏是保存标志寄存器的值,第2 个宏是恢复标志寄存器的值。第1 个宏中的pushfl指令是把标志寄存器的值压栈。而popl 是把栈顶的值(刚压入栈的flags)弹出到x 变量中,这个变量可以存放在一个寄存器或内存中。这样,你可以很容易地读懂第2
个宏。
(2)较复杂应用
C++ Code
1
2 3 4 5 6 7 |
static inline unsigned long get_limit(unsigned long segment)
{ unsigned long __limit; __asm__("lsll %1,%0" :"=r" (__limit) :"r" (segment)); return __limit + 1; } |
这是一个设置段界限的函数,汇编代码段中的输出参数为__limit(即%0),输入参数为segment(即%1)。lsll 是加载段界限的指令,即把segment 段描述符中的段界限字段装入某个寄存器(这个寄存器与__limit
结合),函数返回__limit 加1,即段长。
(3)复杂应用
在Linux 内核代码中,有关字符串操作的函数都是通过嵌入式汇编完成的,因为内核及用户程序对字符串函数的调用非常频繁,因此,用汇编代码实现主要是为了提高效率(当然是以牺牲可读性和可维护性为代价的)。在此,我们仅列举一个字符串比较函数strcmp,其代码在arch/i386/string.h
中。
C++ Code
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
static inline int strcmp(const char *cs, const char *ct)
{ int d0, d1; register int __res; __asm__ __volatile__( "1:\tlodsb\n\t" "scasb\n\t" "jne 2f\n\t" "testb %%al,%%al\n\t" "jne 1b\n\t" "xorl %%eax,%%eax\n\t" "jmp 3f\n" "2:\tsbbl %%eax,%%eax\n\t" "orb $1,%%al\n" "3:" :"=a"(__res), "=&S" (d0), "=&D"(d1) :"1"(cs), "2"(ct)); return __res; } |
其中的“\n”是换行符,“\t”是tab 符,在每条命令的结束加这两个符号,是为了让gcc 把嵌入式汇编代码翻译成一般的汇编代码时能够保证换行和留有一定的空格。例如,上面的嵌入式汇编会被翻译成:
C++ Code
1
2 3 4 5 6 7 8 9 10 |
1: lodsb //装入串操作数,即从[esi]传送到al 寄存器,然后esi 指向串中下一个元素
scasb //扫描串操作数,即从al 中减去[edi],不保留结果,只改变标志 jne 2f //如果两个字符不相等,则转到标号2 testb %al %al jne 1b xorl %eax %eax jmp 3f 2: sbbl %eax %eax orb $1 %al 3: |
其中3f 表示往前(forword)找到第一个
标号为3 的那一行,相应地,1b 表示往后找。其中嵌入式汇编代码中输出和输入部分的结合情况为:
• 返回值__res,放在al 寄存器中,与%0 相结合;
• 局部变量d0,与%1 相结合,也与输入部分的cs 参数相对应,也存放在寄存器ESI中,即ESI 中存放源字符串的起始地址。
• 局部变量d1,与%2 相结合,也与输入部分的ct 参数相对应,也存放在寄存器EDI中,即EDI 中存放目的字符串的起始地址。