为什么使用内嵌汇编?
——解决一些无法直接用C或C++实现的功能,比如C中没有现成的函数或语法可用。
内联汇编的使用原理:
在内嵌汇编中,可以将C语言变量指定为汇编指令的操作数,而且不用去管如何将C语言变量的值读入哪个寄存器,以及如何将计算结果写回C变量,你只要告诉程序中C语言变量与汇编指令操作数之间的对应关系即可, GCC会自动插入代码完成必要的操作。
使用内嵌汇编,要先编写汇编指令模板,然后将C语言变量与指令的操作数相关联,并告诉GCC对这些操作有哪些限制条件。
首先,让我们来共同了解一下 GCC 内联汇编的一般格式:
asm(
代码列表
: 输出运算符列表
: 输入运算符列表
: 被更改资源(破坏描述)列表
);
在代码列表中,每个汇编语句都要用" "括起来。
例:
__asm__ __violate__
("movl %1,%0" : "=r" (result) : "m" (input));
简单说明:
在 C 代码中嵌入汇编需要使用 asm 关键字,用法asm();
" " 引号内部包含的部分是指令部分
: 参数输出部分 函数的返回值,表示这段汇编执行完之后,哪些寄存器用于存放输出数据,而这些寄存器会分别对应一C语言表达式值或一个内存地址,会将这些寄存器的值输出到C语言中的变量中去;
: 参数输入部分 函数的形参,表示在开始执行汇编代码时,这里指定的一些寄存器中应存放的输入值,它们也分别对应着一C变量或常数值;
: 被更改资源列表 内联汇编的声明部分,要被更改的资源。有时在进行某些操作时,除了要用到进行数据输入和输出的寄存器外,还要使用多个寄存器来保存中间计算结果,这样就难免会破坏原有寄存器的内容。如果这条内联汇编执行完之后该被破坏寄存器的值继续被其他程序使用,那么就会导致错误。所以我们需要在“破坏描述部分”声明这些寄存器或内存。若寄存器被列入这个列表,则等于是告诉gcc,这些寄存器可能会被内联汇编命令改写。因此,执行内联汇编的过程中,需要先将这些寄存器入栈,等执行完之后再恢复。例如,某一时刻某进程使用了寄存器ecx,并存入10,然后你插队执行你的内联汇编程序修改了ecx寄存器的值,这将导致其他的程序出现问题。那么如果你将ecx加入破坏描述部分,gcc会在使用ecx寄存器前先push入栈,等使用完ecx后再pop回去。这就保证了ecx寄存器在使用过程中没有被修改。另外,如果某寄存器在内联汇编执行过程中被隐式地使用到,它既不在输出又不在输入列表中声明,那么gcc可能将这个寄存器分配给某个c变量,这使得gcc分配的寄存器与代码列表中隐式地用到的寄存器碰巧为同一个寄存器,导致错误。
1.
"r" 用寄存器来保存参数
"i" 是立即数
"m" 一个有效的内存地址
"x" 只能做输入
+ : 表示参数的可读可写
无: 表示参数只读
= : 表示只写
& : 只能做输出
2.
%0 输出列表和输入列表的第1个成员
%1 输出列表和输入列表的第2个成员
%2 输出列表和输入列表的第3个成员
... 依次类推
3. 冒号部分可以省略,要省略全部省略,否则全部写上
更多内容可参考其他链接:
https://blog.csdn.net/lin111000713/article/details/18892449
https://www.linuxprobe.com/gcc-how-to.html
例子详解:
__asm__ __violate__
("movl %1,%0" : "=r" (result) : "r" (input): "r0", "r1");
1)“movl %1,%0”是指令模板;“%0”和“%1”代表指令的操作数,称为占位符,内嵌汇编靠它们将C语言表达式与指令操作数相对应。
2)用小括号括起来的是C 语言变量,本例中只有两个:“result”和“input”,他们按照出现的顺序分别与指令操作数“%0”,“%1,”对应;注意对应顺序:第一个C变量对应“%0”;第二个变量对应“%1”,依次类推,操作数至多有10个,分别用“%0”,“%1”….“%9,”表示。
在每个操作数前面有一个用引号括起来的字符串,字符串的内容是对该操作数的限制或者说要求。
“result”前面的限制字符串是“=r”,其中“r”表示需要将“result”与某个通用寄存器相关联,先将操作数的值读入寄存器,然后在指令中使用相应寄存器,而不是“result”本身,当然指令执行完后需要将寄存器中的值存入变量“result”,从表面上看好像是指令直接对“result”进行操作,实际上GCC做了隐式处理,这样我们可以少写一些指令。“=”表示“result”是输出操作数:即在汇编里只能改变该C变量的值,而不能取它的值。+号表示可以取变量值,也可改变变量的值。
3)在参数输入部分,不能再有"=","+"号,表示汇编里只能读c变量的值。“input”前面的“r”表示该表达式需要先放入某个寄存器,然后在指令中使用该寄存器参加运算。
4)最后一部分(第三个冒号后)表示告诉编译器不要把r0, r1寄存器分配给%0, %1等。
详细说明:
1. 限制字符含义汇总:
每个操作字前面双引号内的限制字符有很多种,有些是与特定体系结构相关,此处仅列出常用的限定字符和i386中可能用到的一些常用的限定符。它们的作用是指示编译器如何处理其后的 C 语言变量与指令操作数之间的关系。
分类 | 限定符 | 描述 |
通用寄存器 | “a” | 将输入变量放入eax |
“b” | 将输入变量放入ebx | |
“c” | 将输入变量放入ecx | |
“d” | 将输入变量放入edx | |
“s” | 将输入变量放入esi | |
“d” | 将输入变量放入edi | |
“q” | 将输入变量放入eax,ebx,ecx,edx中的一个 | |
“r” | 将输入变量放入通用寄存器,即eax,ebx,ecx,edx,esi,edi之一 | |
“A” | 把eax和edx合成一个64 位的寄存器(use long longs) | |
内存 | “m” | 内存变量 |
“o” | 操作数为内存变量,但其寻址方式是偏移量类型, 也即基址寻址 | |
“V” | 操作数为内存变量,但寻址方式不是偏移量类型 | |
“ ” | 操作数为内存变量,但寻址方式为自动增量 | |
“p” | 操作数是一个合法的内存地址(指针) | |
寄存器或内存 | “g” | 将输入变量放入eax,ebx,ecx,edx之一,或作为内存变量 |
“X” | 操作数可以是任何类型 | |
立即数 | “I” | 0-31之间的立即数(用于32位移位指令) |
“J” | 0-63之间的立即数(用于64位移位指令) | |
“N” | 0-255之间的立即数(用于out指令) | |
“i” | 立即数 | |
“n” | 立即数,有些系统不支持除字以外的立即数,则应使用“n”而非 “i” | |
匹配 | “ 0 ” | 表示用它限制的操作数与某个指定的操作数匹配 |
“1” ... | 也即该操作数就是指定的那个操作数,例如“0” | |
“9” | 去描述“%1”操作数,那么“%1”引用的其实就是“%0”操作数,注意作为限定符字母的0-9 与指令中的“%0”-“%9”的区别,前者描述操作数, 后者代表操作数。 | |
& | 该输出操作数不能使用过和输入操作数相同的寄存器 | |
操作数类型 | “=” | 操作数在指令中是只写的(输出操作数) |
“+” | 操作数在指令中是读写类型的(输入输出操作数) | |
浮点数 | “f” | 浮点寄存器 |
“t” | 第一个浮点寄存器 | |
“u” | 第二个浮点寄存器 | |
“G” | 标准的80387浮点常数 | |
% | 该操作数可以和下一个操作数交换位置,例如addl的两个操作数可以交换顺序(当然两个操作数都不能是立即数) | |
# | 部分注释,从该字符到其后的逗号之间所有字母被忽略 | |
* | 表示如果选用寄存器,则其后的字母被忽略 |
2. 被更改资源列表:
有时在进行某些操作时,除了要用到进行数据输入和输出的寄存器外,还要使用多个寄存器来保存中间计算结果,这样就难免会破坏原有寄存器的内容。如果希望GCC在编译时能够将这一点考虑进去。那么你就可以在“破坏描述部分”声明这些寄存器或内存。
这种情况一般发生在一个寄存器出现在“汇编语句模板”,但却不是由输入或输出操作表达式所指定的,也不是在一些输入或输出操作表达式使用"r"、"g"约束时由GCC为其选择的,同时此寄存器被“汇编语句模板”中的指令修改,而这个寄存器只是供当前内嵌汇编临时使用的情况。比如:
__asm__("movl %0, %%ebx" : : "a"(foo) : "%ebx");
寄存器%ebx出现在“汇编语句模板”中,并且被movl指令修改,但却未被任何输入或输出操作表达式指定,所以你需要在“破坏描述部分”指定"%ebx",以让GCC知道这一点。
因为你在输入或输出操作表达式所指定的寄存器,或当你为一些输入或输出操作表达式使用"r"、"g"约束,让GCC为你选择一个寄存器时,GCC对这些寄存器是非常清楚的——它知道这些寄存器是被修改的,你根本不需要在“破坏描述部分”再声明它们。但除此之外,GCC对剩下的寄存器中哪些会被当前的内嵌汇编修改一无所知。所以如果你真的在当前内嵌汇编语句中修改了它们,那么就最好“破坏描述部分”中声明它们,让GCC针对这些寄存器做相应的处理。否则有可能会造成寄存器的不一致,从而造成程序执行错误。
在“破坏描述部分”中指定这些寄存器的方法很简单,你只需要将寄存器的名字使用双引号引起来。如果有多个寄存器需要声明,你需要在任意两个声明之间用逗号隔开。比如:
__asm__("movl %0, %%ebx; popl %%ecx" : : "a"(foo) : "%ebx", "%ecx" );
注意准备在“破坏描述部分”声明的寄存器必须使用完整的寄存器名称,在寄存器名称前面使用的“%”是可选的。
另外需要注意的是,如果你在“破坏描述部分”声明了一个寄存器,那么这个寄存器将不能再被用做当前内嵌汇编语句的输入或输出操作表达式的寄存器约束,如果输入或输出操作表达式的寄存器约束被指定为"r"或"g",GCC也不会选择已经被声明在“破坏描述部分”中的寄存器。比如:
__asm__("movl %0, %%ebx" : : "a"(foo) : "%eax", "%ebx");
此例中,由于输出操作表达式"a"(foo)的寄存器约束已经指定了%eax寄存器,那么再在“破坏描述部分”中指定"%eax"就是非法的。编译时,GCC会给出编译错误。
除了寄存器的内容会被改变,内存的内容也可以被修改。如果一个“汇编语句模板”中的指令对内存进行了修改,或者在此内嵌汇编出现的地方内存内容可能发生改变,而被改变的内存地址你没有在其输出操作表达式使用"m"约束,这种情况下你需要在“破坏描述部分”使用字符串"memory"向GCC声明:“在这里,内存发生了或可能发生了改变”。例如:
void * memset(void * s, char c, size_t count)
{
__asm__("cld\n\t"
"rep\n\t"
"stosb"
: /* no output */
: "a"(c), "D"(s), "c"(count)
: "%ecx", "%edi", "memory");
return s;
}
此例实现了标准函数库memset,其内嵌汇编中的stosb对内存进行了改动,而其被修改的内存地址s被指定装入%edi,没有任何输出操作表达式使用了"m"约束,以指定内存地址s处的内容发生了改变。所以在其“破坏描述部分”使用"memory"向GCC声明:内存内容发生了变动。
如果一个内嵌汇编语句的“破坏描述部分”存在"memory",那么GCC会保证在此内嵌汇编之前,如果某个内存的内容被装入了寄存器(通常因为编译器优化,会将某个内存处的变量缓存到某寄存器中来使用),那么在这个内嵌汇编之后,如果需要使用这个内存处的内容,就会直接到这个内存处重新读取,而不是使用被存放在寄存器中的拷贝。编译器在优化代码时,将内存的内容放到寄存器中去使用,而我们的内联汇编改变了该内存处的值,如果不告诉编译器,它是意识不到这一点的,就一直把寄存器中的内容当作内存内容来使用,这就与我们本来的意图不一致了。
当一个“汇编语句模板”中包含影响eflags寄存器中的条件标志,那么需要在“破坏描述部分”中使用"cc"来声明这一点。这些指令包括adc,div,popfl,btr,bts等等,另外,当包含call指令时,由于你不知道你所call的函数是否会修改条件标志,为了稳妥起见,最好也使用"cc"。
JOS内核中用到的例子:
1. 在启动kernel并从内核态进入用户态执行用户进程时,需要使用env_pop_tf(struct Trapframe *tf)函数,它的实现就是使用内联汇编:
kern/env.c中:
void env_pop_tf(struct Trapframe *tf)
{
asm volatile(
"\t movl %0, %%esp\n" /* %0对应后面的tf,这里是将tf这个地址值赋给%esp */
"\t popal\n" /* 按*/
"\t popl %%es\n" /* 弹出值到%es */
"\t popl %%ds\n" /* 弹出值到%ds */
"\t addl $0x8, %%esp\n" /* 跳过tf_trapno 和 tf_errcode */
"\t iret\n" /* 从中断返回,将栈中存储数据弹出到eip, cs, eflags寄存器中 */
: : "g"(tf) : "memory"); /* “g”表示将输入变量tf放入eax,ebx,ecx,edx之一,或作为内存变量 */
/* 告诉编译器在执行期间会发生内存变动,以防止错误的代码优化 */
panic("iret failed");
}
2. 在用户代码进行系统调用时,需要用到一个统一的系统调用接口,它的实现就是通过内联汇编:
lib/syscall.c中:
/*
* 在JOS中所有系统调用通过syscall这个函数进行:执行int T_SYSCALL,把函数参数存入若干指定的寄存器
* 并指定函数返回值返回到寄存器ax中
* 用第一个参数num来确定到底是哪个系统调用
* 参数num == SYS_cputs,check == 0,a1 == b->buf, a2 == b->idx,剩下a3、a4、a5都为0
*/
static inline int32_t
syscall(int num, int check, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
int32_t ret;
asm volatile("int %1\n" //汇编指令模板,%1是占位符,对应后面的T_SYSCALL
: "=a" (ret) //=表示在汇编里只能改变该C变量的值,而不能取它的值
//ret值与%ax相联系,即指令执行完后ax的值存入变量ret
: "i" (T_SYSCALL), //中断向量T_SYSCALL,是立即数
"a" (num), //输入参数num,指令执行前先将num变量的值存入%ax
"d" (a1), //输入参数a1,指令执行前先将a1变量的值存入%dx
"c" (a2), //参数a2存入%cx
"b" (a3), //参数a3存入%bx
"D" (a4), //参数a4存入%di
"S" (a5), //参数a5存入%si
: "cc", "memory"); //向gcc声明在这条汇编语言执行后,标志寄存器eflags和内存可能发生改变
//加入“memory”,告诉GCC内存已经被修改,GCC得知这个信息后,
//就会在这段指令之前,插入必要的指令将前面因为优化缓存到寄存器中
//的变量值先写回内存,如果以后又要使用这些变量再重新读取。
if(check && ret > 0)
panic("syscall %d returned %d (> 0)", num, ret);
return ret;
}
3. 在kern/trap.c中的void trap(struct Trapframe *tf)函数有一段内联汇编:
void trap(struct Trapframe *tf)
{
asm volatile("cld" ::: "cc");
...
}
//cld:clear direction flag:将标志寄存器Flag的方向标志位DF清零。
//在字串操作中使变址寄存器SI或DI的地址指针自动增加,字串处理由前往后。
//cli:clear interupt flag:将标志位IF清零,表示禁用中断。