GCC内联汇编极简指北

引入

当我妄图把写的代码移植到linux-gcc上时发现
"intrin.h"不存在
那么函数cpuid也不存在了,哭

PS: 其实还是有的,不过在 "cpuid.h" 里,但是man cpuid只介绍了这条汇编和一些设备相关的东西.....

那该怎么办呢?
内联汇编啊o(〃^▽^〃)o

于是踏上了编译器拓展这条不归路...

How

0x01

C++存在asm关键字来允许程序中嵌入汇编代码。

GCC种的内联汇编分成两种,第一种是基础汇编(Basic Asm),这种形式的内联汇编不能使用GCC拓展的操作数(Operand),第二种呢就是拓展汇编(Extended Asm),能用GCC拓展的操作数...
GCC官方的建议是使用拓展汇编,因为这样性能更好

Using extended asm typically produces smaller, safer, and more efficient code, and in most cases it is a better solution than basic asm

但是拓展汇编只能在函数中使用(而且不能在裸函数中使用)

0x02

不论使用哪种汇编,其格式都是

asm asm-qualifiers ( ... )

对于基础汇编,直接把“...”换为汇编指令的字符串就行啦
扩展汇编么...会多一堆东西..后面再说
asm-qualifiers,就是汇编的限定符,分为三种

  • volatile
  • inline
  • goto

GCC(邪恶的)优化器会尝试优化内联汇编,甚至可能把某段内联汇编移除,不想遭此待遇,那么就要加上volatile 来防止优化,如果你的asm中含有一些side effects的话,这点就非常重要(举个例子 ,对某些单片机初始化会要求对寄存器连续赋值,那么赋值的顺序就十分重要,不能颠倒。但GCC认为汇编就是用输入来产生输出的,为了快速得到输出,会对顺序,甚至语句进行修改)。并且基础汇编隐式的包含此限定符

虽然优化器会对内联汇编进行操作,但是parser不会对内联汇编进行解析哦

使用inline 限定符,那么GCC会生成尽可能小的汇编代码。

goto 限定符只能对拓展汇编使用,提示编译器这段语句可能会跳转到外部的某处label,但是我认为这种用法过于邪恶所以只给出参考

0x03

这里先写一段简简单单的基础汇编--两个int相加
C的写法是

int funcAdd(int a,int b){
    return a+b;
}

要把函数转换成汇编
此时参数a和参数b在哪个寄存器中呢....?
这时候就需要一点小小的知识♪(´▽`)

是什么控制着函数的参数传递呢?Calling Convention!
对32位下的C语言,其函数的默认Calling Convention是cdecl
这代表着其参数的传递方式是从右至左依次压栈(所以栈顶是第一个参数),返回值通过eax传递,而调用者负责清栈
于是可以写出这种代码来

 int funcAdd(int a,int b){
    asm volatile(
        "movl %esp,%ebp\n\t"
        "movl 8(%ebp),%edx\n\t"
        "movl 12(%ebp),%eax\n\t"
        "addl %edx,%eax\n\t"
    );
}

int main(){
    int t = funcAdd(3, 5);
    printf("3 + 5 = %d\n",t);
}

output: 3 + 5 = 8

这段代码在gcc参数-m32下能编译通过且运行成功,否则会出现segmentation fault。

这里可能会有点疑惑:

  • 栈顶指针不是esp么,为什么我在用ebp?
  • 就算ebp代表栈顶,那此处为什么要有8Bytes的偏移量存在?

看一下外部调用的形式
打开反汇编发现在main函数中有这么一段

pushl	$5
pushl	$3
call	funcAdd
addl	$8, %esp

首先,这段代码会把两个参数从右向左依次压栈,接着执行 call 指令跳转到目标函数,在目标函数返回后(也就是内部调用了 ret )后平栈。
Call指令执行时,cpu会自动把ip压栈作为ret时的返回地址(所以ret时会pop ip),由于是32位系统,大小就是4Bytes。

GCC在不为人知的地方做了“努力”,函数的反汇编中会在开头发现两行神秘汇编

pushl	%ebp
movl	%esp, %ebp

这里GCC莫名奇妙为我们压栈了ebp,并且把esp赋值给了ebp。
其实是这样的,由于在程序运行中esp会自动增减(pop,push什么的),这样对于程序员来说不太好定位到目标。于是GCC会先保存栈基地址(也就是push ebp),然后把esp赋值给ebp,方便访问。
那么此时的栈内情况如下

+----------+
|..........|
|----------|  0x0000
|    b     |  
|----------| -0x0020
|    a     |  
|----------| -0x0040 
|    ip    |  
|----------| -0x0080
|   ebp    |  
+----------+ -0x00C0 <--esp

x86的栈是向下生长的哦

这下一切都说的通了

0x04

上面展示的是基础汇编,这种写法很难与C的数据交互,很考验程序员的知识量,并且兼容性很成问题(从x86到x64都得大改一番

于是GCC提供了替代方案,拓展汇编。

拓展汇编的格式如下

asm asm-qualifiers ( AssemblerTemplate 
                      : OutputOperands
                      : InputOperands
                      : Clobbers
                      : GotoLabels)

这里括号内不再是汇编指令了,取而代之的是汇编模板,以及一大堆的参数列表,输出操作数(OutputOperands),输入操作数(InputOperands),Clobbers(这个我真的翻译不来),GOTO标签。

操作数(Operands)极大的方便了汇编与C语言数据的交互,格式如下
[label] "constraints" (variable)
每个操作数都拥有约束符constraints和其标签(可选)

在汇编模板中引用操作数的方法有两个,首先是利用操作数出现的位置N,然后用%N来引用,或者是利用标签,用%[label]来引用。

"constraints"通常代表着操作数在汇编中的存放方式,在这里列出几个可能常用的

  • '=' 代表这个操作数在asm结束后会被进行写入操作(只写),而编译器不会管它之前有没有值,'+'代表对操作数进行读和写,这两个符号被称为修改约束符
  • 'm' 代表这个操作数是一个地址
  • 'r' 代表这个操作数被存在寄存器里
  • 'i' 代表操作数是立即数(需要编译期常量)
  • '0' '1' '2'... 用于输入操作数,代表其constraints和存放的位置与第N个操作数(输出操作数)相同
  • 'a' 'b' 'c'... 代表这个操作数被存放在eax,ebx,ecx或者....

OutputOperands的约束必须以'='或者'+'开头

Clobbers含义比较复杂,大概就是:如果你的汇编修改了某些位置的值,但是没有在OutputOperands中被提到,那么就需要列在这里,用来提示编译器。
这里可以直接列出被修改的寄存器 (比如 "rax", "ebx",除了栈指针),如果内存被修改了那么应该填入"memory";"cc"如果修改了标志寄存器。

栗子时间\( ̄︶ ̄*\))

改写以下之前的加法函数

 int funAdd(int a,int b){
    int ret;
    asm volatile(
        "addl %1,%2\n\t"
        "movl %2,%0\n\t"
        : "=r"(ret)
        : "0"(a),"r"(b) 
        :"cc"
        //对于%0和%1,他们在汇编中表表示的变量是同一个
        //只不过在执行时会读入a的值,输出的时候会覆写ret的值
    );
    return ret;
}

Very easy.
或者这样写

现在提升点难度,把结果输出到一个指针指向的内存中

void funAddP(int a,int b,int* p){
    asm volatile(
        "addl %1,%2\n\t"
        "movl %2,%[out]\n\t"
        : [out]"=m"(*p) 
        : "r"(a),"r"(b) 
        //上面三处也可用"rm",让编译器来帮你选个好位置  
        :"cc"
    );
}

是不是觉得GCC的拓展也就那么回事,简简单单<(^-^)>

0x05

回到最开始的需求 --- 如何用汇编写一个cpuid函数?

	void CpuChecker::m_cpuid(int registers[4], int eax, int ecx = 0) {
		#if defined (__GNUC__)
		asm volatile ("CPUID"
		: "=a" (registers[0]),"=b" (registers[1]), "=c" (registers[2]), "=d" (registers[3])
		: "a" (eax), "c" (ecx)
		);
		#else
		__cpuidex(registers, eax, ecx);
		#endif
	}

首先,把输入分别放入eax,ecx,然后执行cpuid。
此时返回值应该被存在eax,ebx,ecx,edx,于是加上constraint来让值从目标寄存器写回数组里,收工。

Ref

6.47 How to Use Inline Assembly Language in C Code - gcc.gnu.org

END