杂货边角(3):GCC内嵌汇编

前面介绍过miniCRT自定义运行库的代码中涉及到一段代码

static int open(const char* pathname, int flags, int mode)
{
    int fd = 0;
    asm("movl $5, %%eax \n\t"
        "movl %1, %%ebx \n\t"
        "movl %2, %%ecx \n\t"
        "movl %3, %%edx \n\t"
        "int $0x80      \n\t"
        "movl %%eax, %0 \n\t":
        "=m"(fd):"m"(pathname), "m"(flags), "m"(mode) );
}

这是在Linux操作系统下,借助内嵌汇编直接启动系统调用打开指定文件的功能。GCC编译器支持内嵌汇编,而Windows下提供的是Windows API,故而并不能直接通过内嵌汇编启用系统调用,故而我们谈论的内嵌汇编是指在Linux下编程所需的。

首先来看GCC编译器定义的两个宏

#define __asm__       asm 
#define __volatile__   volatile  //和C语言中声明变量的volatile修饰符不同,这里的volatile是指
       //不准对内嵌汇编代码的指令集进行任何优化,gcc编译器要原样照搬

所以我们可以看到在GCC编译器下的内嵌汇编格式为__asm__ __volatile开头也就不奇怪了。内嵌汇编的语法格式如下

__asm__ __volatile__ ("Instruction List": Output: Input: Clobber/Modify);
__asm__ __volatile__(操作指令: 输出部分: 输入部分: 破坏申明);

需要额外注意的是内嵌汇编的语法和基本汇编的语法并不一致
其中操作指令显然是可以包括多指令语句的,分隔符有”;” “\n” “\n\t”,但是大家都说”\n\t”更工整更好看,所以就默认采用”\n\t”分隔符了。如上面的代码段那样。下面我们以解读上面的内嵌汇编为引子来描述内嵌汇编的各部分语法格式,本文的主要参考来源于https://www.cnblogs.com/latifrons/archive/2009/09/17/1568198.html

1. 输出部分

输出部分和输入部分都是标准的套路
限定符 + (C/C++表达式),其中限定符用引号【”“】限定的,又称为” 操作约束“,表达式用()限定。直接上代码吧。

int cr0 = 10;
__asm__("movl %%cr0, %0": "=a" (cr0));   //等价于 cro = eax
//输出部分的表达式部分较为直接,直接用括号将一个C/C++变量传进来就可以了,该变量将用来暂存返回值
//输出部分的限定符部分便是"=a",稍微复杂,意思是以eax作为最终返回值赋值右式

输出部分的”操作约束“格式是”=” “+”两修饰符之一作为首字符,后跟着地址或使用信息。

等号(=)约束说明当前的表达式是一个 Write-Only的,加号(+)用来说明当前表达式是一个Read-Write的,这两个修饰符是输出部分专属的,+加号的附加作用是 看两个例子就好理解了。

$ cat example2.c

int main(int __argc, char* __argv[]) 
{ 
int cr0 = 5; 

__asm__ __volatile__("movl %%cr0, %0":"=a" (cr0));   //测试等号=的write-only作用

return 0; 
}

$ gcc -S example2.c

$ cat example2.s

main: 
pushl %ebp 
movl %esp, %ebp 
subl $4, %esp 
movl $5, -4(%ebp) # cr0 = 5
#APP 
movl %cr0, %eax //eax = 5
#NO_APP 
movl %eax, %eax 
movl %eax, -4(%ebp) # cr0 = %eax = 5
movl $0, %eax 
leave 
ret 

使用加号(+)约束的情况:

$ cat example3.c

int main(int __argc, char* __argv[]) 
{ 
int cr0 = 5; 

__asm__ __volatile__("movl %%cr0, %0" : "+a" (cr0)); 

return 0; 
}

$ gcc -S example3.c

$ cat example3.s

main: 
pushl %ebp 
movl %esp, %ebp 
subl $4, %esp 
movl $5, -4(%ebp) # cr0 = 5

//内嵌汇编的Input部分初始化部分
movl -4(%ebp), %eax 
        // input ( %eax = cr0 ) 即在进入#APP和#NO_APP限定的内嵌汇编代码区间时,寄存器eax已经被赋值cro。

#APP    //内嵌汇编的指令集区间部分首
movl %cr0, %eax
#NO_APP //内嵌汇编的指令集区间部分尾

 //内嵌汇编的Ouput部分赋值操作
movl %eax, -4(%ebp) // output (cr0 = %eax ) 

movl $0, %eax
leave
ret

所以可以看到其实加号+的使用效果等同于

__asm__ __volatile__("movl %%cr0, %0":"+a" (cr0));
<==>  
__asm__ __volatile__("movl %%cr0, %0":"=a" (cr0): "a"(cr0));

2. 输入部分

输入部分的内容指定和输出部分基本类似,唯一的不同便是修饰符号不同,输出部分可以使用加号【+】或等号【=】甚至【&】来表示对寄存器独占声明,而输入部分是默认操作数Read-only的,不存在和【+】等同类的修饰符号。输入部分唯一的操作数便是【%】,表示此Input操作表达式中的C/C++表达式可以和下一个Input操作表达式中的C/C++表达式互换。
举个代码例子就可以了

$ cat example4.c

int main(int __argc, char* __argv[]) 
{ 
int cr0 = 5; 

__asm__ __volatile__("movl %0, %%cr0"::"a" (cr0)); 

return 0; 
}

$ gcc -S example4.c

$ cat example4.s

main: 
pushl %ebp 
movl %esp, %ebp 
subl $4, %esp 
movl $5, -4(%ebp) # cr0 = 5 

movl -4(%ebp), %eax # %eax = cr0 //输入部分Input的初始化部分

#APP   //内嵌汇编的指令集区间
movl %eax, %cr0 
#NO_APP //内嵌汇编的指令集区间

movl $0, %eax 
leave 
ret 

修饰符 Input/Output 汇总如下
= O 表示此Output操作表达式是Write-Only的
+ O 表示此Output操作表达式是Read-Write的
& O 表示此Output操作表达式独占为其指定的寄存器
% I 表示此Input操作表达式中的C/C++表达式可以和下一个Input操作表达式中的C/C++表达式互换

3. 约束的几种形式

前面讲过了内嵌汇编的”操作约束“是由修饰符加具体的地址或操作信息约束构成的,加号【+】等意义已经介绍过了,并且也知道了 "=a"中的a代表寄存器eax, b代表ebx,那么我们目标代码中的

 "movl %%eax, %0 \n\t":
        "=m"(fd):"m"(pathname), "m"(flags), "m"(mode) );

的”m”代表的是什么?

其实这要说道操作约束的集中可用形式:1.寄存器约束;2,立即数约束;3.内存地址约束;4.通用约束

寄存器约束

格式适用部分解释
rI,O 表示使用一个通用寄存器,由GCC在%eax/%ax/%al, %ebx/%bx/%bl, %ecx/%cx/%cl, %edx/%dx/%dl, %esi, %edi中选取一个GCC认为合适的。
qI,O 表示使用一个通用寄存器,在%eax, %ebx, %ecx, %edx中任选一个。
aI,O 表示使用%eax / %ax / %al
bI,O 表示使用%ebx / %bx / %bl
cI,O 表示使用%ecx / %cx / %cl
dI,O 表示使用%edx / %dx / %dl
DI,O 表示使用%edi / %di
SI,O 表示使用%esi / %si
tI,O 表示使用浮点寄存器
fI,O 表示使用第一个浮点寄存器
uI,O 表示使用第二个浮点寄存器

立即数约束
如果一个Input/Output操作表达式的C/C++表达式是一个数字常数,不想借助于任何寄存器,则可以使用立即数约束。由于立即数在C/C++中只能作为右值,所以对于使用立即数约束的表达式而言,只能放在Input域。

比如:asm volatile(“movl %0, %%eax” : : “i” (100) );

约束 Input/Output 意义
i I 表示输入表达式是一个立即数(整数),不需要借助任何寄存器
F I 表示输入表达式是一个立即数(浮点数),不需要借助任何寄存器

内存约束
如果一个Input/Output操作表达式的C/C++表达式表现为一个内存地址,不想借助于任何寄存器,则可以使用内存约束。比如:

__asm__ ("lidt %0" : "=m"(__idt_addr)); 或 __asm__ ("lidt %0" : :"m"(__idt_addr));

$ cat example5.c
/********************** 本例中,变量sh被作为一个内存输入*************************/
int main(int __argc, char* __argv[]) 
{ 
char* sh = (char*)&__argc; 
__asm__ __volatile__("lidt %0" : : "m" (sh)); 
return 0; 
} 

$ gcc -S example5.c
$ cat example5.s

main: 
pushl %ebp 
movl %esp, %ebp 
subl $4, %esp 

leal 8(%ebp), %eax 
movl %eax, -4(%ebp) # sh = (char*) &__argc

#APP 
lidt -4(%ebp) 
#NO_APP 

movl $0, %eax 
leave 
ret 
$ cat example6.c

通用约束
g I,O 表示可以使用通用寄存器,内存,立即数等任何一种处理方式。
0,1,2,3,4,5,6,7,8,9 I 表示和第n个操作表达式使用相同的寄存器/内存。

通 用约束g是一个非常灵活的约束,当程序员认为一个C/C++表达式在实际的操作中,究竟使用寄存器方式,还是使用内存方式或立即数方式并无所谓时,或者程 序员想实现一个灵活的模板,让GCC可以根据不同的C/C++表达式生成不同的访问方式时,就可以使用通用约束g。比如:
#define JUST_MOV(foo) __asm__ ("movl %0, %%eax" : : "g"(foo))

JUST_MOV(100)JUST_MOV(var)则会让编译器产生不同的代码。

int main(int __argc, char* __argv[]) 
{ 
JUST_MOV(100); 
return 0; 
}
/***编译后生成的代码为:****/

main: 
pushl %ebp 
movl %esp, %ebp 

#APP 
movl $100, %eax 
#NO_APP 

movl $0, %eax 
popl %ebp 
ret 
/***********************很明显这是立即数方式。而下一个例子:*********************/

int main(int __argc, char* __argv[]) 
{ 
JUST_MOV(__argc); 

return 0; 
} 
//经编译后生成的代码为:

main: 
pushl %ebp 
movl %esp, %ebp 

#APP 
movl 8(%ebp), %eax 
#NO_APP 

movl $0, %eax 
popl %ebp 
ret 

/******************这个例子是使用内存方式。*************************/

4. 占位符

一个带有C/C++表达式的内联汇编,其操作表达式被按照被列出的顺序编号,第一个是0,第2个是1,依次类推,GCC最多允许有10个操作表达式。比如:

__asm__ ("popl %0 \n\t"
"movl %1, %%esi \n\t"
"movl %2, %%edi \n\t"
: "=a"(__out)
: "r" (__in1), "r" (__in2));

此例中,__out所在的Output操作表达式被编号为0,"r"(__in1)被编号为1,"r"(__in2)被编号为2。再如:

__asm__ ("movl %%eax, %%ebx" : : "a"(__in1), "b"(__in2));

此例中,"a"(__in1)被编号为0,"b"(__in2)被编号为1。

如果某个Input操作表达式使用数字0到9中的一个数字(假设为1)作为它的操作约束,则等于向GCC声明:“我要使用和编号为1的Output操作表达 式相同的寄存器(如果Output操作表达式1使用的是寄存器),或相同的内存地址(如果Output操作表达式1使用的是内存)”。

上面的描述包含两个 限定:数字0到数字9作为操作约束只能用在Input操作表达式中,被指定的操作表达式(比如某个Input操作表达式使用数字1作为约束,那么被指定的就是编号为1的操作表达式)只能是Output操作表达式。

5. 破坏声明

介绍破坏声明,则不得不介绍编译器优化,我们知道现今程序加速分为硬件加速和软件加速,其中硬件加速涉及到缓冲缓存,引入高速cache等,而软件加速则分为两类:程序员手段更改优化代码和编译器采用 -O启动编译器优化,其中编译器优化主要手段:将内存变量加载到寄存器中一提供高速缓存,调整指令顺序甚至删除部分无用指令。

我们要知道编译器并无多线程的概念的,并不懂得足够的线程互斥和进程同步内容,所以如果采用内存变量加入寄存器提供高速缓存则很可能存在数据不同步的问题。有时候,你想通知GCC当前内联汇编语句可能会对某些寄存器或内存进行修改,希望GCC在编译时能够将这一点考虑进去,比如始终从内存中读取该变量的最新状态(如volatile指定修饰变量的效果那样)。那么就可以在Clobber/Modify域声明这些寄存器或内存。

这 种情况一般发生在一个寄存器出现在”Instruction List”,但却不是由Input/Output操作表达式所指定的,也不是在一些Input/Output操作表达式使用”r”,”g”约束时由GCC 为其选择的,同时此寄存器被”Instruction List”中的指令修改,而这个寄存器只是供当前内联汇编临时使用的情况。比如:

__asm__ ("movl %0, %%ebx" : : "a"(__foo) : "bx");

寄存器%ebx出现在”Instruction List中”,并且被movl指令修改,但却未被任何Input/Output操作表达式指定,所以你需要在Clobber/Modify域指定"bx",以让GCC知道这一点,从而可以保存寄存器ebx的现场。

因 为你在Input/Output操作表达式所指定的寄存器,或当你为一些Input/Output操作表达式使用”r”,”g”约束,让GCC为你选择一 个寄存器时,GCC对这些寄存器是非常清楚的——它知道这些寄存器是被修改的,你根本不需要在Clobber/Modify域再声明它们。但除此之外, GCC对剩下的寄存器中哪些会被当前的内联汇编修改一无所知。所以如果你真的在当前内联汇编指令中修改了它们,那么就最好在Clobber/Modify 中声明它们,让GCC针对这些寄存器做相应的处理。否则有可能会造成寄存器的不一致,从而造成程序执行错误。

在Clobber/Modify域中指定这些寄存器的方法很简单,你只需要将寄存器的名字使用双引号(” “)引起来。如果有多个寄存器需要声明,你需要在任意两个声明之间用逗号隔开。比如:

__asm__ ("movl %0, %%ebx; popl %%ecx" : : "a"(__foo) : "bx", "cx" );

这些串包括:

声明的串                  代表的寄存器 
"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"就可以了,因为其它的都和它们中的一个是等价的。

如果你在一个内联汇编语句的Clobber/Modify域向GCC声明某个寄存器内容发生了改变,GCC在编译时,如果发现这个被声明的寄存器的内容在此 内联汇编语句之后还要继续使用,那么GCC会首先将此寄存器的内容保存起来,然后在此内联汇编语句的相关生成代码之后,再将其内容恢复。我们来看两个例 子,然后对比一下它们之间的区别。


$ cat example7.c

int main(int __argc, char* __argv[]) 
{ 
int in = 8; 

__asm__ ("addl %0, %%ebx" 
: /* no output */ 
: "a" (in) : "bx"); 

return 0; 
}

$ gcc -O -S example7.c

$ cat example7.s

main:
pushl %ebp
movl %esp, %ebp

pushl %ebx       // %ebx内容被保存,保存现场环境
movl $8, %eax    //内嵌汇编的初始化Input部分

#APP
addl %eax, %ebx  //内嵌汇编指令集,占位符的效果优点类似宏参数的感觉
#NO_APP

movl $0, %eax
movl (%esp), %ebx # %ebx内容被恢复
leave
ret

/*********作为对比,则看下如果没有在破坏声明指出"bx"的汇编代码**************/
$ cat example8.c

int main(int __argc, char* __argv[]) 
{ 
int in = 8; 

__asm__ ("addl %0, %%ebx" 
: /* no output */ 
: "a" (in) );  //没有添加"bx"的破坏声明

return 0; 
}

$ gcc -O -S example8.c

$ cat example8.s

main: 
pushl %ebp 
movl %esp, %ebp 

movl $8, %eax //只有内嵌汇编的初始化部分,并没有ebx的现场保存操作

#APP 
addl %eax, %ebx 
#NO_APP 

movl $0, %eax 
popl %ebp 
ret

另外需要注意的是,如果你在Clobber/Modify域声明了一个寄存器,那么这个寄存器将不能再被用做当前内联汇编语句的Input/Output操 作表达式的寄存器约束,如果Input/Output操作表达式的寄存器约束被指定为"r""g",GCC也不会选择已经被声明在 Clobber/Modify中的寄存器。比如:

__asm__ ("movl %0, %%ebx" : : "a"(__foo) : "ax", "bx");

此例中,由于Output操作表达式"a"(__foo)的寄存器约束已经指定了%eax寄存器,那么再在Clobber/Modify域中指定"ax"就是非法的。编译时,GCC会给出编译错误。

除了寄存器的内容会被改变,内存的内容也可以被修改。如果一个内联汇编语句”Instruction List”中的指令对内存进行了修改,或者在此内联汇编出现的地方内存内容可能发生改变,而被改变的内存地址你没有在其Output操作表达式使用”m” 约束,这种情况下你需要使用在Clobber/Modify域使用字符串”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)
: "cx","di","memory");
return s;
}

此 例实现了标准函数库memset,其内联汇编中的stosb对内存进行了改动,而其被修改的内存地址s被指定装入%edi,没有任何Output操作表达 式使用了"m"约束,以指定内存地址s处的内容发生了改变。所以在其Clobber/Modify域使用"memory"向GCC声明:内存内容发生了变动。所以现在就可以明白

__asm__("":::"memory");  //声明内存可能出现更改,故而寄存器中的数据缓存全部作废,重新从内存读一遍

举个例子

/*************启用__asm__("":::"memory"); 设置内存破坏声明********************/
$ cat example1.c

int main(int __argc, char* __argv[]) 
{ 
int* __p = (int*)__argc; 

(*__p) = 9999; 

__asm__("":::"memory"); 

if((*__p) == 9999) 
return 5; 

return (*__p); 
}

$ gcc -O -S example1.c   //启动编译优化选项-O,则编译器会按照优化方案来

$ cat example1.s

main: 
pushl %ebp 
movl %esp, %ebp 
movl 8(%ebp), %eax # int* __p = (int*)__argc
movl $9999, (%eax) # (*__p) = 9999

#APP 
# __asm__("":::"memory")
#NO_APP

cmpl $9999, (%eax) # (*__p) == 9999 ?
jne .L3 # false 
movl $5, %eax # true, return 5 
jmp .L2 
.p2align 2 
.L3: 
movl (%eax), %eax 
.L2: 
popl %ebp 
ret
//正常的if--else--两路结构

/*************注释掉__asm__("":::"memory");看看再次启动编译器优化会得到什么********************/
$ cat example1.s

main: 
pushl %ebp 
movl %esp, %ebp 
movl 8(%ebp), %eax # int* __p = (int*)__argc
movl $9999, (%eax) # (*__p) = 9999 
movl $5, %eax # return 5
popl %ebp 
ret
/*代码段明显简短了不少,但是可以发现并没有if--else--结构,而是系统认为`(*__p)`自从被赋值9999之后就没有动过,
*故而不需要再比较一遍了,直接按照9999 true的方案来。但是如果是`(*__p)`是进程共享的内存块映射对象怎么办?
*这种情况下,盲目的优化可能导致程序不能按照预期的设想运行哦

6. 回到起点

static int open(const char* pathname, int flags, int mode)
{
    int fd = 0;
    asm("movl $5, %%eax \n\t"
        "movl %1, %%ebx \n\t"
        "movl %2, %%ecx \n\t"
        "movl %3, %%edx \n\t"
        "int $0x80      \n\t"
        "movl %%eax, %0 \n\t":
        "=m"(fd):"m"(pathname), "m"(flags), "m"(mode) );
}

再来看这一段代码,并可以清楚的知道这段代码的意思了:启用系统中断,中断号为eax=5代表读取外部文件,ebx=pathname保存着路径名,ecx=flags保存打开的权限类型,edx=mode保存着文件格式,将最后open()操作的返回值保存到fd变量中(0代表失败,1代表成功)。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值