之前我们介绍了一种C语言与汇编代码混合编程方式,就是两个文件分开编写,分开编译,最后通过链接的形式结合在一起形成可执行文件,另一种方式就是C语言内联汇编,这一切都要归功于强大的GCC。
内联汇编称为inline assembly,GCC支持在C代码中直接嵌入汇编代码,所以称为GCC inline assembly。大家知道,C语言不支持寄存器操作,汇编语言可以,所以自然就想到了在C语言中嵌入内联汇编提升“战斗力”的方式,通过内联汇编,C程序员可以实现C语言无法表达的功能,这样使开发能力大为提升。
内联汇编按格式分为两大类,一类是最简单的基本内联汇编,另一类是复杂一些的扩展内联汇编,在介绍它们之前,其实还有一点点头疼的事,内联汇编中所用的汇编语言,其语法是AT&T,并不是咱们熟悉的Intel汇编语法,GCC只支持它,所以咱们还得了解下AT&T。
我之前学的一门课,微机原理教的就是Intel语法,发现身边的汇编语言什么的教的也都是Intel语法,也许这和教学系统都是微软的操作系统DOS和Windows有关。其实不论是Intel语法或者是AT&T语法,其在某一个平台上编译出来的机器码是一样的,其本质没有区别,只是表达方式有点区别。
AT&T首先在UNIX中使用,可当初UNIX并不是在x86处理器上开发的,最初是在PDP-11机器上开发的,后来又移植到VA X和68000的处理器上,所以AT&T的语法自然更接近于这些处理器的特性。虽然UNIX后来又移植到x86上了,但还是要尊重UNIX圈内的习惯,其汇编语法接近于那些前辈处理器上的语法,这就是AT&T语法。
无论语法怎么改变,汇编语言中指令关键字不能有太大的出入,名字非常接近,只是在指令名字的最后加上了操作数大小后缀,b表示一字节,w表示二字节,l表示四字节,比如压栈指令,在Intel语法中是 push
,在AT&T语法中是 pushl
,最后这个l表示要压栈的数据长度是4字节,在了解Intel语法的情况下,基本能看懂AT&T语法,他们的主要差别是语法风格
上表未列出两种语法在内存寻址之间的区别,我们详细说一下
在Intel语法中,立即数就是普通的数字,如果让立即数成为内存地址,需要将他用中括号括起来,才能表示以立即数为地址的内存
而AT&T认为,内存地址既然是数字,那数字理所应当的也应该被当做内存地址,所以数字优先被认为是内存地址,也就是说,操作数如果是数字,则统统按照以该数字为地址的内存来访问。这样的话我们想要单纯的表示立即数就要在前面加一个 $
。
Intel语法有很多的寻址方式,包括直接寻址,基址寻址,变址寻址,基址变址寻址,而且不知道是不是开始学的时候就是Intel语法,所以觉得Intel语法是很直接的一种寻址方式。
在AT&T中内存寻址有着固定的格式
segreg(段基址):base_address(offset_address,index,size)
此格式对应的表达式为
segreg(段基址):base_address+offset_address+index*size)
看上去格式有些怪异,但其实这是一种“通用”格式,格式中短短的几个成员囊括了它所有内存寻址的方式,任意一种内存寻址方式,其格式都是这个通用格式的子集,都是格式中各种成员的组合。下面介绍下这些成员项。
base_address
是基地址,可以为整数、变量名,可正可负。
offset_address
是偏移地址,index
是索引值,这两个必须是那8个通用寄存器之一。
size
是个长度,只能是1、2、4、8。
下面看看内存寻址中有哪些方式,注意,这些方式都是上面通用格式的一部分。
直接寻址:此寻址中只有base_address项,后面括号中的内容全不要,base_address便为内存啦,比如 movl $225,0xc00008F0
,或者用变量名 mov $6,var
寄存器间接寻址: 此寻址中只有offset_address项,即格式为(offset_address),要记得,offset_address只能是通用寄存器。寄存器中是地址,不要忘记格式中的圆括号,如mov (%eax),%ebx
。
**寄存器相对寻址:**此寻址中有offset_address项和base_address项,即格式为base_address(offset_address)。这样得出的内存地址是基址+偏移地址之和。如movb -4(%ebx),%al
。
**变址寻址:**此类寻址称为变址的原因是含有通用格式中的变量Index。因为index是size的倍数,所以有index的地方就有size。既然是变址,只要有index和size就成了,base_address和offset_address可有可无,注意,格式中没有的部分也要保留逗号来占位。一共有4种变址寻址组合,下面各举个例子。
无base_address,无offset_address:movl %eax,(,%esi,2)
无base_address,有offset_address:movl %eax,(%ebx,%esi,2)
有base_address,无offset_address:movl %eax,base_value(,%esi,2)
有base_address,有offset_address:movl %eax,base_value(%ebx,%esi,2)
基本内联汇编是最简单的内联形式,其格式为:
asm [volatile] ("assembly code")
关键字asm用于声明内联汇编表达式,这是内联汇编固定的部分,不可少。
asm
和__asm__
是一样的,是由gcc定义的宏:#define __asm__ asm
。gcc有一个优化选项 -O
,可以指定优化级别,当用 -O
来编译的时候,gcc按照自己的意图优化代码,说不定就会把自己写的代码改了(他认为你写的太烂了), 关键字volatile是可选项,它告诉gcc:“不要修改我写的汇编代码,请原样保留”,volatile
和__volatile__
是一样的,是由gcc定义的宏:#define __volatile__ volatile
。
“assembly code”是咱们所写的汇编代码,它必须位于圆括号中,而且必须用双引号引起来。这是格式要求,只要满足了这个格式asm [volatile] (“”),assembly code甚至可以为空。
下面说下assembly code的规则。
提醒一下,即使是指令分布在多个双引号中,gcc最终也要把它们合并到一起来处理,合并之后,指令间必须要有分隔符。所以,当指令在多个双引号中时,除最后一个双引号外,其余双引号中的代码最后一定要有分隔符,这和其他编程语言中表示代码结束的分隔符是一样的,如:
asm("movl $9,%eax;","pushl %eax") # 正确
asm("movl $9,%eax","pushl %eax") # 错误
我们再举一个打印字符串的例子
char *str = "hello ASM C!";
int count = 0;
int main(){
asm( "pusha;\
movl $4,%eax;\
movl $1,%ebx;\
movl str,%ecx;\
movl $12,%edx;\
int $0x80;\
mov %eax,count;\
popa \
");
return 0;
}
在上述代码中,若要内联汇编引用c变量,只能将c变量定义为全局变量,只有定义为全局变量的时候才能链接这两个符号,如果定义为局部变量,链接时会找不到这两个符号,这就是基本的内联汇编,我们编译运行一下
gcc -m32 -o inline.bin inline.c
./inline.bin
由于基本内联汇编功能太薄弱了,所以才对它进行了扩展以使其功能强大。不过,易用性往往与功能强弱是成正比的,如您所料,扩展内联汇编确实有点难。
gcc本身是个c编译器,要让其支持汇编语言,必然牵扯到以下问题。
您看,内联汇编真不是简单地写两句汇编代码就完事了,所以,很多人宁可单独写纯汇编文件再链接,也不愿意写内联汇编。
假设目前没有扩展内联汇编,当汇编代码嵌入到C代码中,如果汇编代码想把C代码中的变量作为操作数加载到寄存器,如何找到可用的寄存器,这可是个大问题,程序员并不知道哪个寄存器已经被分配了,哪些寄存器是空闲的。即使知道了寄存器的分配情况也还不够,有些底层操作,对寄存器的要求是固定的(比如in/out指令,就得使用al作为数据寄存器),万一那个固定的寄存器已经被占用了,咱们在使用前还得把它备份。
也许你觉得不难啊,我之前在c中使用了那些寄存器,我现在在内联汇编中用那些寄存器就先将其入栈备份,用完了再恢复,但是让用户自己保证数据的完整性会出大问题,万一漏掉一个寄存器,就完啦!!!,程序就不知道会运行到哪里,再说,运行中有大量的压栈操作,访问内存本身就比较慢,不如在编译阶段由编译器优化,直接分配给寄存器或用寄存器缓存,这样程序运行才更快。所以,这类事情还是交给编译器自己做这事才放心。
既然编译器不放心,那么这件事情就变成了如何将C代码中的变量编程汇编代码中的操作数,由于编译器无法预测用户的需求,所以编译器向用户提供了一个模板,让用户在模板中提出要求,其余工作他负责实现,这些用户提出的要求就是后面说的约束。
因此,内联汇编的格式也变了,感觉既不像C语言,也不像汇编语言,似乎是一种中间产物,不信您看。
asm [volatile] (“assembly code”:output : input : clobber/modify)
和前面的基本内联汇编相比,扩展内联汇编在圆括号中变成了4部分,多了output、input和clobber/modify三项。其中的每一部分都可以省略,甚至包括assembly code。
assembly code
:还是用户写入的汇编指令,和基本内联汇编一样。
output
:用来指定汇编代码的数据如何输出给C代码使用。如果想将汇编运行结果存储到c变量中,就用此项指定输出的位置。output中每个操作数的格式为:
“操作数修饰符约束名” (c变量名)
其中的引号和圆括号不能少,操作数修饰符通常为等号’=‘。多个操作数之间用逗号’,'分隔。
input
:用来指定C中数据如何输入给汇编使用,input中每个操作数的格式为:
“[操作数修饰符]约束名” (c变量名)
其中的引号和圆括号不能少,操作数修饰符为可选项。多个操作数之间用逗号’,'分隔。
clobber/modify
:汇编代码执行后会破坏一些内存或寄存器资源,通过此项通知编译器,可能造成寄存器或内存数据的破坏,这样gcc就知道哪些寄存器或内存需要提前保护起来。
上面所说的“要求”,在扩展内联汇编中称为“约束”,它所起的作用就是把C代码中的操作数(变量、立即数)映射为汇编中所使用的操作数,实际就是描述C中的操作数如何变成汇编操作数。这些约束的作用域是input和output部分,咱们看看这些约束是怎么体现的,约束分为四大类。
寄存器约束就是要求gcc使用哪个寄存器,将input或者output中变量约束在某个寄存器中,常见的寄存器约束有
我们来感受一下基本内联汇编和扩展内联汇编之间的区别
基本内联汇编
#include
int in_a=1,in_b=2,out_sum;
int main(){
asm("pusha;\
movl in_a,%eax;\
movl in_b,%ebx;\
addl %ebx,%eax;\
movl %eax,out_sum;\
popa;\
");
printf("%d\n",out_sum);
return 0;
}
扩展内联汇编
#include
int main(){
int in_a=1,in_b=2,out_sum;
asm("addl %%ebx,%%eax":"=a"(out_sum):"a"(in_a),"b"(in_b));
printf("%d\n",out_sum);
}
编译链接执行可以打印字符串 3
。
同样是为加法指令提供参数,in_a和in_b是在input部分中输入的,用约束名a为c变量in_a指定了用寄存器eax,用约束名b为c变量in_b指定了用寄存器ebx。addl指令的结果存放到了寄存器eax中,在output中用约束名a指定了把寄存器eax的值存储到c变量out_sum中。output中的’='号是操作数类型修饰符,表示只写,其实就是out_sum=eax的意思。
通过对比我们发现他的功能确实强大了很多,我们只需要把数据往里面放,剩下的交给GCC。
内存约束是要求gcc直接将位于input和output中的C变量的内存地址作为内联汇编代码的操作数,不需要寄存器做中转,直接进行内存读写,也就是汇编代码的操作数是C变量的指针。
我们依旧是举个例子
#include
int main(){
int in_a=1,in_b=2;
asm("movb %b0, %1;"::"a"(in_a),"m"(in_b));
printf("%d\n",in_b);
}
编译运行一下
gcc -m32 -o in.bin in.c
./in.bin
我们发现控制台可以输出 1
立即数即常数,此约束要求gcc在传值的时候不通过内存和寄存器,直接作为立即数传给汇编代码。由于立即数不是变量,只能作为右值,所以只能放在input中。
立即数为节约篇幅,后面将立即数约束同其他约束一起演示,这里没有单独样例。
0~9:此约束只用在input部分,但表示可与output和input中第n个操作数用相同的寄存器或内存。
为方便对操作数的引用,扩展内联汇编提供了占位符,它的作用是代表约束指定的操作数(寄存器、内存、立即数),我们更多的是在内联汇编中使用占位符来引用操作数。
序号占位符是对在output和input中的操作数,按照它们从左到右出现的次序从0开始编号,一直到9,也就是说最多支持10个序号占位符。
操作数用在assembly code中,引用它的格式是%0~9。在操作数自身的序号前面加1个百分号’%'便是对相应操作数的引用。一定要切记,占位符指代约束所对应的操作数,也就是在汇编中的操作数,并不是圆括号中的C变量。
我们举个例子
asm("addl %%ebx, %%eax":"=a"(out_sum):"a"(in_a),"b"(in_b));
等价于
asm("addl %2, %1":"=a"(out_sum):"a"(in_a),"b"(in_b));
"=a"(out_sum)
序号为0,%0对应的是eax。
"a"(in_a)
序号为1,%1对应的是eax。
"b"(in_b)
序号为2,%2对应的是ebx。
由于扩展内联汇编中的占位符要有前缀%,为了区别占位符和寄存器,只好在寄存器前用两个%做前缀啦,这就是为什么本例中寄存器前面要有两个%做前缀的原因。
占位符所表示的操作数默认情况下为32位数据。指令的操作数大小并不一致,有的指令操作数大小是32位,有的是16位,有的是8位。当为这些指令提供操作数时,编译器会自动取32位数据的低16位给需要16位操作数的指令,取32位的低8位给需要8位操作数的指令。由于32位数据中,高16位没法直接使用,所以对于16位操作数只能取32位中的低16位。但对于8位操作数就不一样了,尽管默认情况下会用低8位(0~7位)作为字节指令的操作数,但32位数据中能直接使用的字节不只是低8位,还有第8~15位。拿32位的寄存器eax举例,其常用的部分是eax、ax、al(高16位没法直接用)。有些指令的操作数是字,所以用ax做操作数即可。有些指令操作数是字节,用al或ah都可以,默认情况下会将al当作操作数。这时候我们可以在%和序号之间插入字符’h’来表示操作数为ah(第8~15位),或者插入字符’b’来表示操作数为al(第0~7位)。
举个例子
#include
int main() {
int in_a = 0x12345678, in_b = 0;
asm("movw %1, %0;":"=m"(in_b):"a"(in_a));
printf("word in_b is 0x%x\n", in_b);
in_b = 0;
asm("movb %1, %0;":"=m"(in_b):"a"(in_a));
printf("word in_b is 0x%x\n", in_b);
in_b = 0;
asm("movb %h1, %0;":"=m"(in_b):"a"(in_a));
printf("word in_b is 0x%x\n", in_b);
return 0;
}
我们编译链接的时候报了异常
in.c: Assembler messages:
in.c:5: Warning: using `%ax' instead of `%eax' due to `w' suffix
in.c:9: Warning: using `%al' instead of `%eax' due to `b' suffix
可见其非常智能哈,执行一下得到结果
word in_b is 0x5678 # 传入一个字,默认低16位
word in_b is 0x78 # 传入一个字节,默认低8位al
word in_b is 0x56 # 传入一个字节,拉到高8位ah
名称占位符与序号占位符不同,序号占位符靠本身出现在output和input中的位置就能被编译器辨识出来。而名称占位序需要在output和input中把操作数显式地起个名字,它用这样的格式来标识操作数:
[名称]”约束名”(C变量)
我们举个例子
#include
int main(){
int in_a = 18,in_b=3,out=0;
asm("divb %[divisor];movb %%al,%[result]"\
:[result]"=m"(out)\
:"a"(in_a),[divisor]"m"(in_b)\
);
printf("result is %d\n",out);
return 0;
}
编译执行结果为6,当然这没什么说的。 可以看到第4行除法指令divb可以通过%[divisor]引用除数所在的内存,进行除法运算,个人认为没有符号占位符方便,毕竟起名称是程序员最难得工作。无论是哪种占位符,它都是指代C变量经过约束后、由gcc分配的对应于汇编代码中的操作数,和C变量本身无关。这个操作数就是通过约束名所指定的寄存器、内存、立即数等,最终编译器要将占位符转换成这三种操作数类型之一。
在约束中还有操作数类型修饰符,用来修饰所约束的操作数:内存、寄存器,分别在ouput和input中有以下几种。
在output中有三种
=:
表示操作数是只写,相当于为output括号中的C变量赋值,如=a(c_var),此修饰符相当于c_var=eax。
+:
表示操作数是可读写的,告诉gcc所约束的寄存器或内存先被读入,再被写入。
&:
表示此output中的操作数要独占所约束(分配)的寄存器,只供output使用,任何input中所分配的寄存器不能与此相同。注意,当表达式中有多个修饰符时,&要与约束名挨着,不能分隔。
在input中有一种
%:
该操作数可以和下一个输入操作数互换。
这一部分是扩展内联汇编的最后一部分,这部分用于通知gcc,我们修改了哪些寄存器或内存。
由于我们在C程序中嵌入了一些汇编代码,所以这必然会造成一些资源的破坏,本来人家C代码翻译之后也要用到寄存器,突然来了一堆抢寄存器的汇编指令,从c跳到汇编,再从汇编跳到C的过程可能会导致之前在寄存器中的信息消失,或者保存在内存中的信息消失,所以我们得让GCC知道我们改变了那些寄存器或内存
如果在output和input中通过寄存器约束指定了寄存器,GCC必然会知道这些寄存器会被修改,所以需要在这个单独的部分通知的寄存器一定是没在之前出现过的。
也许您会认为,牵扯到修改寄存器或内存的部分,只差assembly code了,这部分GCC可以自己扫描一下啊,为什么要我们显式的指出呢,问题是GCC能扫描到明处的指令,我使用了哪个寄存器,但是暗处的他就无能为力了,比如在汇编中调用了一个函数,该函数内部会修改一些资源,或者该函数中又调用了其他函数,这保不准在哪一层调用有修改资源的代码,简直无法跟踪(不过也说不定,万一以后GCC会有强大的前后文联系能力与推理能力呢)。所以必须我们显式的告诉GCC我们动了那些资源,这个资源就是寄存器和内存。
只要在clobber/modify部分明确写出来就行了,记得要用双引号把寄存器名称引起来,多个寄存器之间用逗号’,‘分隔,这里的寄存器不用再加两个’%'啦,只写名称即可,如:
asm("movl %%eax, %0;movl %%eax,%%ebx":"=m" (ret_value)::"bx")
这里就指出了我们使用了bx寄存器。
如果我们的内联汇编代码修改了标志寄存器eflags中的标志位,同样需要在clobber/modify中用”cc”声明。
如果我们修改了内存,我们需要在clobber/modify中”memory”声明。
使用memory的原因还有一个就是清除寄存器缓存,我们知道一个值被使用过了就是被缓存到寄存器缓存中,因为根据局部性原理,一个值被使用了就有很大概率还会被使用,缓存可以加快访问速度,但是我们这个值在内存中发生了改变时,还使用缓存中的值的话就会出问题,所以这个时候C语言有关键字 volatile
,他表示这个值不需要被缓存,直接去内存中读取,这就避免了这个问题,但汇编中的volatile是定义的宏 #define __volatile__ volatile
这和C语言中的volatile不冲突。利用这个原理,不管变量的值是否会被编译器缓存到寄存器中,当我们需要绕过寄存器缓存,也就是希望读取到内存中最新的数据时,我们就可以在内联汇编中的clobber/modify部分用”memory”声明,通知编译器变量所在的内存数据变啦,这样它就会从内存再读取一次新数据啦。当然我们也可以在C语言中用volatile去修饰所定义的变量,但是变量多了就有些麻烦
在前面介绍序号占位符的时候,咱们已经引出了机器模式的内容:为了指定寄存器中的某部分,咱们引用了字符’h’和字符’b’,它们分别用来指定寄存器的第8~15位和低8位,这只是机器模式的用途之一。
比如寄存器约束a表示寄存器al、ax、eax,可以在序号占位符中增加前缀字符h和b来引用寄存器ah和al。不过这次我不想指定ah或al啦,如果我想指定ax或eax,怎么做?为了回答这个问题,咱们再举个例子
#include
int main(){
int in_a = 0x1234, in_b = 0;
asm("movw %1,%0":"=m"(in_b):"a"(in_a));
printf("in_b now is 0x%x\n",in_b);
}
这段代码很简单,目的是把in_a的低16位复制到in_b中。但是第四行中,变量in_a的约束时a,这表示由GCC把in_a的值分配给寄存器 AL、AX或EAX。这很模糊,到底GCC把in_a的值分配给了谁?之后的movw指令也很模糊,我们只能这样理解:movw指令将al、ax或eax中的2个字节复制到in_b所在的内存中。我们编译一下,报了异常
in.c: Assembler messages:
in.c:4: Warning: using `%ax' instead of `%eax' due to `w' suffix
他帮我们从ax寄存器换成了eax寄存器,但是在实际代码中我们并没有添加w,这说明默认情况下,GCC用占位符引用操作数的时候,根据指令操作数大小的不同,添加了适当的前缀。我们实验一下
#include
int main(){
int in_a = 0x1234, in_b = 0;
asm("movw %w1,%0":"=m"(in_b):"a"(in_a));
printf("in_b now is 0x%x\n",in_b);
}
这次编译的过程没报任何错误,这里的字符w是什么意思呢?这是我们新接触的控制字符。
w和h、b一样,都是操作码,用来指代某种机器模式类型。
我们看一下权威的解释
/* Meaning of CODE:
L,W,B,Q,S,T -- print the opcode suffix for specified size of operand.
C -- print opcode suffix for set/cmov insn.
c -- like C, but print reversed condition
F,f -- likewise, but for floating-point.
O -- if HAVE_AS_IX86_CMOV_SUN_SYNTAX, expand to "w.", "l." or "q." otherwise nothing
R -- print embeded rounding and sae.
r -- print only sae.
z -- print the opcode suffix for the size of the current operand.
Z -- likewise, with special suffixes for x87 instructions.
* -- print a star (in certain assembler syntax)
A -- print an absolute memory reference.
E -- print address with DImode register names if TARGET_64BIT.
w -- print the operand as if it's a "word" (HImode) even if it isn't.
s -- print a shift double count, followed by the assemblers argument delimiter.
b -- print the QImode name of the register for the indicated operand. %b0 would print %al if operands[0] is reg 0.
w -- likewise, print the HImode name of the register.
k -- likewise, print the SImode name of the register.
q -- likewise, print the DImode name of the register.
x -- likewise, print the V4SFmode name of the register.
t -- likewise, print the V8SFmode name of the register.
g -- likewise, print the V16SFmode name of the register.
h -- print the QImode name for a "high" register, either ah, bh, ch or dh.
y -- print "st(0)" instead of "st" as a register.
d -- print duplicated register operand for AVX instruction.
D -- print condition for SSE cmp instruction.
P -- if PIC, print an @PLT suffix.
p -- print raw symbol name.
X -- don't print any sort of PIC '@' suffix for a symbol.
& -- print some in-use local-dynamic symbol name.
机器模式用在机器层面上指定数据的大小及格式,用我自己的理解是GCC支持内联汇编,由于各种约束均不能确切地表达具体的操作数对象,所以引用了机器模式,用来从更细的粒度上描述数据对象的大小及其指定部分。GCC根据不同的硬件平台,将机器模式定义在多个文件中,其中所有平台都通用的机器模式定义在gcc/machmode.def文件中,其他与具体平台相关的机器模式定义在自己的平台路径下。
其实我们只需要初步了解几个操作码就够了,寄存器按是否可单独使用,可分成几个部分,拿eax举例。
低部分的一字节:al
高部分的一字节:ah
两字节部分:ax
四字节部分:eax
h –输出寄存器高位部分中的那一字节对应的寄存器名称,如ah、bh、ch、dh。
b –输出寄存器中低部分1字节对应的名称,如al、bl、cl、dl。
w –输出寄存器中大小为2个字节对应的部分,如ax、bx、cx、dx。
k –输出寄存器的四字节部分,如eax、ebx、ecx、edx。
这一块就不深究了,深究也不会了