由于在很多情况下,C语言无法完全代替汇编语言,比如:操作某些特殊的CPU寄存器,操作主板上的某些IO端口或者性能达不到要求等情况下,我们必须在C语言里面嵌入汇编语言,以达到我们的需求。
当需要在C语言里嵌入汇编语言段的时候,对于GNU C我们可以使用它提供的关键词“asm”来实现。先看下面一段代码:
#define nop() __asm__ __volatile__ ("nop \n\t")
这段内嵌汇编是什么意思呢?根据函数名,读者们大概也能猜到了,这个是nop函数的实现,而且这个nop是我们操作系统内核里面的库函数。让我们先学习内嵌汇编的格式,然后在下面小节里会讲解一些常用的库函数内容。
首先,介绍_ asm _ 和 _ volatile _ 这两个关键字。
_ asm _ 修饰这个是一段汇编语言,它是GCC定义的关键字asm的宏定义(#define _ asm _ asm),它用来声明一个内嵌汇编表达式。所以,任何一个内嵌汇编表达式都以它开头,它是必不可少的;如果要编写符合ANSI C标准的代码(即:与ANSI C兼容),那就要使用_ asm _;
_ volatile _ 修饰这段代码不被编译器优化,保持代码原样。这个volatile正是我们需要的,如果经过编译器优化,很有可能将我们写的程序修改,并达不到预期的执行效果了。如果要编写符合ANSI C标准的代码(即:与ANSI C兼容),那就要使用_ volatile _;
然后,该介绍内嵌汇编的语言了。 一般而言,C语言里嵌入汇编代码片段都要比纯汇编语言写的代码复杂得多。因为这里有个怎样分配寄存器、怎样与C代码中的变量融合的问题。为了这个目的,必须要对所用的汇编语言做更多的扩充,增加对汇编语言的明确指示。
C语言里的内嵌汇编代码可分为四部分,以“:”号进行分隔,其一般形式为:
指令部分:输出部分:输入部分:损坏部分
指令部分
第一部分就是汇编语言的语句本身,其格式与在汇编语言程序中使用的格式基本相同,但也有不同之处。这一部分被称为“指令部分”说明他是必须有的,而其它各部分则视具体情况而定,如果不需要的话是可以忽略的,所以在最简单的情况下就与常规的汇编语句基本相同。
指令部分的编写规则:
当指令列表里面有多条指令时,可以在一对双引号中全部写出,也可将一条或多条指令放在一对双引号中,所有指令放在多对双引号中;
- 如果是将所有指令写在一对双引号中,那么,相邻俩条指令之间必须用分号”;“或换行符(\n)隔开,如果使用换行符(\n),通常\n后面还要跟一个\t;或者是相邻两条指令分别单独写在两行中;
- 如果将指令放在多对双引号中,除了最后一对双引号之外,前面的所有双引号里的最后一条指令后面都要有一个分号(;)或(\n)或(\n\t);
在涉及到具体的寄存器时就要在寄存器名前面加上两个”%”号,以免混淆。
输出部分
第二部分,紧接在指令部分后面的是“输出部分”,用来指定当前内嵌汇编语句的输出表达式。
格式为:“操作约束”(输出表达式)
用括号括起来的部分,它用于保存当前内嵌汇编语句的一个输出值。在输出表达式内需要用(=)或(+)来进行修饰。 等号(=)加号(+)的区别:等号(=)表示当前表达式是一个纯粹的输出操作,而加号(+)则表示当前表达式不仅仅是一个输出操作,还是一个输入操作;但无论是等号(=)还是加号(+),所表示的都是可写,只能用于输出,只能出现在输出部分,而不能出现在输入部分;在输出部分可以出现多个输出操作表达式,多个输出操作表达式之间必须用逗号(,)隔开;;
用双引号括起来的部分,被称作是:”输出操作约束“,也可以称为”输出约束“;关于约束部分将在后面一起进行讲解。
输入部分
第三部分用来指定当前内嵌汇编语句的输入;称为输入表达式;
格式为:”操作约束“(输入表达式)
输入部分同样也由两部分组成:由双引号括起来的部分和由圆括号括起来的部分;这两个部分对于当前内嵌汇编语句的输入来说,是必不可少的;用于约束当前内嵌汇编语句中的当前输入;这个部分也成为”输入操作约束“,也可以成为是”输入约束“;与输出表达式中的操作约束不同的是,输入表达式中的操作约束不允许指定等号(=)约束或加号(+)约束,也就是说,它只能是只读的;约束中必须指定一个寄存器约束;
操作约束
每一个输入和输出表达式都必须指定自己的操作约束;约束的类型有:寄存器约束、内存约束、立即数约束、通用约束;
- 寄存器约束
当你的输入或输出需要借助于一个寄存器时,你需要为其指定一个寄存器约束;
可以直接指定一个寄存器名字;比如:
__asm__ __volatile__("movl %0,%%cr0"::"eax"(cr0));
也可以指定寄存器的缩写名称;比如:
__asm__ __volatile__("movl %0,%%cr0"::"a"(cr0));
如果指定的是寄存器的缩写名称,比如:字母a;那么,GCC将会根据当前操作表达式的宽度来决定使用%rax、%eax、%ax还是%al;
常用的寄存器约束的缩写:
r:I/O,表示使用一个通用寄存器,由GCC在%rax/%eax/%ax/%al、%rbx/%ebx/%bx/%bl、%rcx/%ecx/%cx/%cl、%rdx/%edx/%dx/%dl中选取一个GCC认为是合适的;
q:I/O,表示使用一个通用寄存器,与r的意义相同;
g:I/O,表示使用寄存器或内存地址;
m:I/O,表示使用内存地址;
a:I/O,表示使用%rax/%eax/%ax/%al;
b:I/O,表示使用%rbx/%ebx/%bx/%bl;
c:I/O,表示使用%rcx/%ecx/%cx/%cl;
d:I/O,表示使用%rdx/%edx/%dx/%dl;
D:I/O,表示使用%rdi/%edi/%di;
S:I/O,表示使用%rsi/%esi/%si;
f:I/O,表示使用浮点寄存器;
t:I/O,表示使用第一个浮点寄存器;
u:I/O,表示使用第二个浮点寄存器;
A:I/O,表示把%eax与%edx组合成一个64位的整数值;
o:I/O,表示使用一个内存位置的偏移量;
V:I/O,表示仅仅使用一个直接内存位置;
i:I/O,表示使用一个整数类型的立即数;
n:I/O,表示使用一个带有已知整数值的立即数;
F:I/O,表示使用一个浮点类型的立即数;
- 内存约束
如果一个输入/输出操作表达式,表现为一个内存地址(指针变量),不想借助于任何寄存器,则可以使用内存约束;
例如:
__asm__ __volatile__ ("lgdt %0":"=m"(__gdt_addr)::);
__asm__ __volatile__ ("lgdt %0"::"m"(__gdt_addr));
内存约束使用约束名“m”,表示的是使用系统支持的任何一种内存方式,不需要借助于寄存器;
- 立即数约束
如果一个输入/输出操作表达式是一个数字常数,不想借助于任何寄存器或内存,则可以使用立即数约束;
由于立即数在表达式中只能作为右值使用,所以,对于使用立即数约束的表达式而言,只能放在输入部分;
比如:
__asm__ __volatile__("movl %0,%%eax"::"i"(100));
立即数约束使用约束名“i”表示输入表达式是一个整数类型的立即数,不需要借助于任何寄存器,只能用于输入部分;使用约束名“F”表示输入表达式是一个浮点数类型的立即数,不需要借助于任何寄存器,只能用于输入部分;
- 通用约束
约束名“g”可以用于输入和输出,表示可以使用通用寄存器、内存、立即数等任何一种处理方式;通用约束“g”是一个非常灵活的约束,当程序员认为一个表达式在实际操作中,无论使用寄存器方式、内存方式还是立即数方式都无所谓时,或者让GCC可以根据不同的表达式生成不同的访问方式时,就可以使用通用约束“g”。
约束名“0,1,2,3,4,5,6,7,8,9”只能用于输入,表示与第n个操作表达式使用相同的寄存器/内存;
- 修饰符
等号(=)和加号(+)作为修饰符,已经在输出部分讲解过了,这里主要讲解“&”符。
符号“&”也只能写在输出表达式的约束部分,用于约束寄存器的分配,但是只能写在约束部分的第二个字符的位置上。因为,第一个字符的位置我们要写(=)或(+)。
用符号“&”进行修饰时,等于向GCC声明:“GCC不得为任何输入操作表达式分配与此输出操作表达式相同的寄存器”;其原因是,GCC会先使用输出值对被修饰符“&”修饰的输出操作表达式进行赋值,然后,才对输入操作表达式进行赋值。这样的话,如果不使用修饰符“&”对输出操作表达式进行修饰,一旦后面的输入操作表达式使用了与输出操作表达式相同的寄存器,就会产生输入和输出数据混乱的情况;
值得注意的是:如果一个输出操作表达式的寄存器约束被指定为某个寄存器,只有当至少存在一个输入操作表达式的寄存器约束为可选约束(意思是GCC可以从多个寄存器中选取一个,或使用非寄存器方式)时,比如“r”或“g”时,此输出操作表达式使用符号“&”修饰才有意义!如果你为所有的输入操作表达式指定了固定的寄存器,或使用内存/立即数约束时,则此输出操作表达式使用符号“&”修饰没有任何意义;
如果没有使用修饰符“&”修饰输出操作表达式会是什么样子呢?那就意味着GCC会先把输入操作表达式的值输入到选定的寄存器中,然后经过处理,最后才用输出值填充对应的输出操作表达式;
占位符
每一个占位符对应一个输入/输出操作表达式,内嵌汇编中有两种占位符:序号占位符和名称占位符;
- 序号占位符
GCC规定:一个内嵌汇编语句中最多只能有10个输入/输出操作表达式,这些操作表达式按照他们被列出来的顺序依次赋予编号0到9;对于占位符中的数字而言,与这些编号是对应的;比如:占位符%0对应编号为0的操作表达式,占位符%1对应编号为1的操作表达式,依次类推;
由于占位符前面要有一个百分号“%”,为了区别占位符与寄存器,GCC规定:在内嵌汇编语句的指令列表里列出的寄存器名称前面必须使用两个百分号(%%),以区别于占位符语法。GCC对占位符进行编译的时候,会将每一个占位符替换为对应的输入/输出操作表达式所指定的寄存器/内存/立即数;
- 名称占位符
由于GCC中限制这种占位符的个数最多只能由这10个,这也就限制了输入/输出操作表达式的数量做多只能有10个;如果需要的表达式的数量超过10个,那么,这些需要占位符就不够用了;GCC内嵌汇编提供了名称占位符来解决这个问题;即:使用一个名字字符串与一个表达式对应;这个名字字符串就称为名称占位符;而这个名字通常使用与表达式中的变量完全相同的名字;
使用名字占位符时,内嵌汇编的输入/输出操作表达式中的格式如下:
[name] "constraint"(变量)
此时,指令列表中的占位符的书写格式如下:
%[name]
这个格式等价于序号占位符中的%0,%1,%2等等,使用名称占位符时,一个name对应一个变量;
例如:
__asm__("imull %[value1],%[value2]"
:[value2] "=r"(data2)
:[value1] "r"(data1),"0"(data2));
此例中,名称占位符value1就对应变量data1,名称占位符value2对应变量data2;GCC编译的时候,同样会把这两个占位符分别替换成对应的变量所使用的寄存器/内存地址/立即数;而且也增强了代码的可读性;
这个例子,使用序号占位符的写法如下:
__asm__("imull %1,%0"
:"=r"(data2)
:"r"(data1),"0"(data2));
损坏部分
有的时候,当您想通知GCC当前内嵌汇编语句可能会对某些寄存器或内存进行修改,希望GCC在编译时能够将这一点考虑进去;那么您就可以在损坏部分声明这些寄存器或内存;
- 寄存器修改通知
这种情况一般发生在一个寄存器出现在指令列表中,但又不是输入/输出操作表达式所指定的,也不是在一些输入/输出操作表达式中使用“r”或“g”约束时由GCC选择的。同时,此寄存器被指令列表中的指令所修改,而这个寄存器只供当前内嵌汇编语句使用;比如:
__asm__("movl %0,%%ebx"::"a"(__foo):"bx");
这个内嵌汇编语句中,%ebx出现在指令列表中,并且被指令修改了,但是却未被任何输入/输出操作表达式所指定。所以,您需要在损坏部分指定“bx”,以让GCC知道这一点。
在损坏部分声明这些寄存器的方法很简单,只需要将寄存器的名字用双引号括起来就可以了;如果要声明多个寄存器,则相邻两个寄存器名字之间用逗号隔开。
注意:因为你在输入/输出操作表达式中指定寄存器,或当你为一些输入/输出操作表达式使用“r”/“g”约束,让GCC为你选择一个寄存器时,GCC对这些寄存器的状态是非常清楚的,它知道这些寄存器是被修改的,你根本不需要在损坏部分声明它们;但除此之外,GCC对剩下的寄存器中哪些会被当前内嵌汇编语句所修改却一无所知;所以,如果你真的在当前内嵌汇编指令中修改了它们,那么就最好在损坏部分声明它们,让GCC针对这些寄存器做相应的处理;否则,有可能会造成寄存器不一致,从而造成程序执行错误;
寄存器名称串:
“al”/“ax”/“eax”:代表寄存器%eax
“bl”/“bx”/“ebx”:代表寄存器%ebx
“cl”/“cx”/“ecx”:代表寄存器%ecx
“dl”/“dx”/“edx”:代表寄存器%edx
“si”/“esi”:代表寄存器%esi
“di”/“edi”:代表寄存器%edi
所以,只需要使用“ax”,“bx”,“cx”,“dx”,“si”,“di”就可以了,因为他们都代表对应的寄存器;
如果你在一个内嵌汇编语句的损坏部分向GCC声明了某个寄存器会发生改变。那么,在GCC编译时,如果发现这个被声明的寄存器的内容在此内嵌汇编之后还要继续使用,GCC会首先将此寄存器的内容保存起来,然后在此内嵌汇编语句的相关代码生成之后,再将其内容恢复。
另外需要注意的是,如果你在损坏部分声明了一个寄存器,那么这个寄存器将不能再被用作当前内嵌汇编语句的输入/输出操作表达式的寄存器约束,如果输入/输出操作表达式的寄存器约束被指定为“r”/“g”,GCC也不会选择已经被声明在损坏部分中的寄存器;
- 内存修改通知
除了寄存器的内容会被修改之外,内存的内容也会被修改。如果一个内嵌汇编语句的指令列表中的指令对内存进行了修改,或者在此内嵌汇编出现的地方,内存内容可能发生改变,而且被改变的内存地址没有在其输出操作表达式中使用“m”约束。这种情况下,您需要在损坏部分使用字符串“memory”向GCC声明。
如果一个内嵌汇编语句的损坏部分存在“memory”,那么GCC会保证在此内嵌汇编之前,如果某个内存的内容被装入了寄存器,那么,在这个内嵌汇编之后,如果需要使用这个内存处的内容,就会直接到这个内存处重新读取,而不是使用被存放在寄存器中的拷贝;因为这个时候寄存器中的拷贝很可能已经和内存处的内容不一致了。
- 标志寄存器修改通知
当一个内嵌汇编中包含影响标志寄存器eflags的条件,那么也需要在损坏部分中使用“cc”来向GCC声明这一点。
本文已在图灵出版社社区连载中,欢迎大家前去阅读。