最近在读linux0.10内核,正好遇到了内联汇编。
gcc其中一个很强大的功能就是内联汇编。
但是苦于广大学生(包括我)都被intel语法毒害很深,结果没几个会用gcc内联汇编的了。。。
一开始我也看的云里雾里,但是一用gcc -S测试一下,马上就非常明白了。
所以还是实践为王。
这里就稍微从AT&T语法一直介绍到gcc内联汇编运用实例。
AT&T语法。
其实AT&T语法真的不难。。。看了20min大概就懂得差不多了。
推荐直接看gnu as的pdf,总共16页,而且涵盖了不少下面要将的内容。
这里只提个概要。
1.源目操作符顺序
同一条语句intel语法 MOV EAX,EBX 源在后,目在前(我自己其实很分不清源和目,只知道咋用),intel这句就是把EBX的值赋给EAX。
AT&T语法就是mov %ebx,%eax,顺序正好相反,其实个人觉得AT&T这个顺序更加直观。
2.寄存器,立即数
上面其实就已经有了寄存器的例子了,用寄存器的话要用%表示,否则会被解释成对应名称的内存地址
立即数同理,只不过必须用$前缀,不然表示地址。立即数方面,intel一般都是用h后缀表示16进制,而AT&T用更像C的0x前缀表示。
3.寻址
intel语法的变址+基址的时候
[基址寄存器+变址寄存器X比例因子+立即数],eg:[bx+si*4+1],很简单啊。。。
AT&T就比较难懂了
立即数(基址寄存器,变址寄存器,比例因子),eg :$1(%bx,%si,$4)再带上之前说的两个标准,看得确实蛋疼了。。。
4.后缀修饰
intel语法使用 word ptr 之类的命令来指明寻址内存时读取数据的长度。虽然vim C-N上下文补全很方便,但是还是蛮蛋疼的。
AT&T直接使用后缀表示,l=long=32bits,w=word=16bits,b=byte=8bits.比起intel语法方便多了
同样,有明确长度时(例如指明了寄存器),就可以省略后缀修饰。
其余变化应该不大
C函数传递。
这个直接关系到C与汇编的混合编程。
当然,这段写的最好的还是gnu as manual。
这里也主要是几个要点。
1.C函数的参数
C语言是通过堆栈传参的。按照参数表顺序依次将参数压入堆栈,然后call。
2.C函数的frame pointer
用gcc编译的时候,如果不用-fomit-frame-pointer选项的话,默认是会开启frame pointer功能。
此时在函数体一开始一般会有
pushl %ebp
mov %esp,%ebp;这两行等价于enter这一条命令
这两句,这就是建立了frame pointer。
此时esp,ebp都指向栈顶,而栈顶是之前的ebp的值。
由于call 调用函数的时候,要压入eip(这里全采用32bit linux为例,linux所有代码处于同一segment然后使用分页机制,所以不存在压入cs的情况)
所以最后一个函数参数就是ebp+8.(4字节被ebp占去,4字节在被之前压入的eip占去)
在ebp+8之前(包括ebp+8。之前指高地址处),就是函数的参数表。
ebp之后(之后指低地址处),就是函数体内的临时变量占用的位置。
如果函数内定义了临时变量,在frame pointer构造好了之后,就要subl $X,%esp,X为临时变量占用的字节数。
否则push pop就会轻松的毁了函数体内的临时变量。
所以此时想要访问到函数的参数,就可以用8(%ebp)这样寻址。(8(%ebp)就是最后一个参数,16(%ebp)就是倒数第二个,以此类推)
想要访问函数体的局部变量就可以用-4(%ebp)这样的寻址。(-4(%ebp)就是第一个局部变量,-8(%esbp)就是第二个,以此类推)
而堆栈的pop push只影响esp,不影响ebp。
所以ebp就被称为frame pointer。是函数体内部临时变量和函数参数表的分水岭。(其实(%ebp)~8(%ebp)之间的分别是eip和原ebp)
退出函数体时,如果函数运行完了,esp正好指在原来的ebp值,直接popl %ebp,因为此时esp就指在原本ebp的值的后面。
如果esp不指在ebp上也很简单:
movl %ebp,%esp
popl %ebp;这两行等价于leave这一条命令
如果用了-fomit-frame-pointer的话,进入函数和退出函数的时候,都不用建立frame pointer,直接使用esp+立即数来寻址。
由于esp会随着堆栈变化而变化,所以debug的时候很是绕脑子。。。都绕人脑了,debuger显然是没法识别出了。不过这样就减少了一点点函数调用的开销。
之后就可以ret了。
3.C函数参数表的清理
既然通过堆栈传参,就必须在函数运行完后,清除堆栈中的参数表(根据上一点,可以发现,函数在ret前销毁了frame pointer,所以堆栈中已经没有函数的临时变量了),而清楚堆栈参数表的工作,是由函数的调用者处理的。
addl 4,%esp
如上就可以清除一个只有一个参数的函数的参数表。
看到这里,应该就有内联汇编所需的基础知识了。
非内联汇编实例:
用C写了个简单的swap函数:
然后用gcc -S 编译成汇编代码。如下:
看得蛋疼了吧。。。
人家gcc不开任何优化的话,就是忠实的按照你的要求,只把寄存器作为中间介质,对内存勤勤恳恳的操作着。
我们都知道那个蛋疼的swap是干啥用的。于是我们稍微改下swap来直接实现交换。由于我们手写,自然用不着建立frame pointer,能省则省:
这下体积大幅精简了。。。
重新编译下,嗯,结果正常。。。但是代码从原来的20+行汇编精简到6行,是不是感觉很爽啊。。。
其实,gcc -S -O2 -fomit-frame-pointer结果如下:
我们可以发现,开了-O2之后,gcc大量使用寄存器,极大的减少了多余的寻址操作。并且用了-fomit-frame-pointer,导致少了4行frame pointer相关的命令,结果总共只用9行就完成了之前20+行的功能。所以-O2还是很强大的,不过再强大还是比手工的多了3行加减法运算。这就是编译器的极限啊。。。。
内联汇编。
有刚才手动修改的汇编实例之后,轮到真正强大内联汇编了。
内联汇编基本命令:
asm ("汇编代码"
:输出变量
:输入变量
:寄存器修改状况)
其中汇编代码必须有(废话),输出输入必须有,空的话就空着。寄存器修改状况可选。
对于输出输入变量的属性,可以参考完整的gnu gcc手册。个人推荐google搜索到的一个gcc-inline-asm.pdf文档。
举例说明比较好:
输出时需要“=”表示输出装载,a表示ax,eax,al这一系列的寄存器,根据长度自动选择。
如果表示装载属性的“”里什么都没有,表示继承前一项的寄存器安排(不集成那个等于号)“m”表示内存。
既然“a”表示a系列寄存器,“b”自然就是b系列了。“r”就是可用寄存器。其余查手册吧。
输出变量的第一个变量在内联汇编中可以通过%0引用,然后依次为%1,%2一直延续到输入变量
在内联汇编中出现的寄存器,都用%%eax这种形式表示。
可能输出输入这种叫法很不好理解,我更倾向与装入这种叫法。
其中输入变量如果是寄存器属性的,是在执行汇编命令前,由gcc负责将数据装入寄存器。内存属性则不作任何变化,直接使用ebp+偏移或者esp+偏移的形式出现在代码中(取决于是否建立frame pointer)。
输出变量如果是寄存器属性的,则在汇编指令执行后,由gcc负责将数据从寄存器送回到原本的地址上。
对于内存属性的,由于本身就在内存上,所以不需要也不存在内存属性的输出变量。
有了上述讲解,再做一个宏+内联汇编的例子:
gcc -S的结果,(-O2不-O2无所谓了。。。)
其中#APP开头到#NO_APP部分,就是内联汇编的代码,
movl 28(%esp), %eax这一行就是gcc做的输入变量的装入。
movl %eax, 28(%esp)这一行就是gcc做的输出变量的回送。
由于做成了宏,不需要通过指针传参,代码再度精简。
于是,下次笔试遇到啥用C宏写个交换两个数的值的时候,就写个内联汇编吧。。。。