跳至主要內容

C 语言内联汇编

未央大约 7 分钟C 语言汇编语言内联汇编

在任何一个搜索引擎中输入关键字C语言内联汇编都能搜索到八百个结果,但是纸上得来终觉浅。别人的文章写的再好也比不上自己敲一遍在总结出来。老李在这里做一个粗浅的总结,觉得不够深入的朋友还是去看看那八百篇文章。

为什么要学习 C 语言内联汇编?想必当你在搜索引擎中敲下这几个字的时候就已经有了答案,话不多说,看代码。

int main(void)
{
  return 0;
}

你没有看错,这段代码里并没有内联汇编,但我还是想从这个栗子开始。这是一段没有任何意义的代码,它唯一的作用可能就是向操作系统返回0吧。

linux环境下编译并运行这个程序。

$ cc -m32 -fno-pic -g demo.c -o demo
$ ./demo

没有任何输出,但是我们可以查看它的返回结果。在shell中,$?代表上一个命令执行后的退出状态。

$ echo $?
0

没有意外,是0。你可能从来都不会在意编译器会把这段简单的代码编译成什么样子,但是当你决定学习内联汇编的时候,你就需要知道它究竟会被编译成什么样子。为了清晰我决定反编译这段代码对应的目标文件,因为在链接之后的可执行文件里加入了太多和程序主流程无关的东西。

编译:

$ cc -m32 -fno-pic -g -c demo.c -o demo.o

反编译:

$ objdump -d -Msuffix demo.o

demo.o:     文件格式 elf32-i386


Disassembly of section .text:

00000000 <main>:
   0:   55                      pushl  %ebp
   1:   89 e5                   movl   %esp,%ebp
   3:   b8 00 00 00 00          movl   $0x0,%eax
   8:   5d                      popl   %ebp
   9:   c3                      retl

这里我们不讲汇编语言的基础,反编译后的代码放在这里用来和后续的代码做对比。

继续下一个栗子。

int main(void)
{
  asm("nop");
  return 0;
}

和最初的版本比起来多了一行,asm("")包裹起来的就是我们内联的汇编语言代码。我们内联了nop指令,这个指令使处理器空转一个时钟周期,换句话说,这个指令让处理器什么都不做。

运行结果也一样,还是向操作系统返回0。我们还是看一下反编译后的结果。

$ cc -m32 -fno-pic -g -c demo.c -o demo.o
$ objdump -d -Msuffix demo.o

demo.o:     文件格式 elf32-i386


Disassembly of section .text:

00000000 <main>:
   0:   55                      pushl  %ebp
   1:   89 e5                   movl   %esp,%ebp
   3:   90                      nop
   4:   b8 00 00 00 00          movl   $0x0,%eax
   9:   5d                      popl   %ebp
   a:   c3                      retl

对比之前的结果,意料之中。第12行多了一行指令,就是我们在C 语言源代码中内联的nop指令。

这肯定无法让你分泌更多的多巴胺,但在这之前你还是需要先做一个平平无奇的练习,如下。

int main(void)
{
  asm("movl $1, %eax\n\t"
      "movl $4, %ebx\n\t"
      "int $0x80");

  return 0;
}

编译,运行,查看返回值。

$ cc -m32 -fno-pic -g demo.c -o demo
$ ./demo
$ echo $?
4

这次返回给操作系统的并不是0,而是4,第4行代码中的4

为了美观我们使用了 C 语言中自动连接两个相邻双引号中的字符串的技巧,所以这三行汇编代码实际上等于movl $1, %eax\n\tmovl $4, %ebx\n\tint $0x80。这也解释了为什么前两行的行尾要加换行符制表符,换行符用于分割每一条汇编指令,制表符使编译器编译时产生的汇编指令格式保持规范。当然,你也可以把它们换成分号。

这三行汇编指令的作用是以参数4调用linux的系统调用exit4作为退出状态。movl $1, %eax将数字1放入寄存器%eax,这是linux系统调用exit的编号。movl $4, %ebx将程序的退出状态放入寄存器%ebx,这也是规定,调用exit之前必须将退出状态放在这里。int $0x80触发0x80号中断,该中断的处理过程就是内核会去查询%eax拿到系统调用编号然后执行相应的操作。所以程序在执行到return 0之前就已经通过exit(4)退出了,退出状态为4

再来看下一个栗子,在这个例子中,我们会让 C 语言和汇编指令产生交互。

#include <stdio.h>

int main(void)
{
  int x = 3, y = 4, z;

  asm("addl %%ebx, %%eax"
      : "=a"(z)        /* 输出变量列表(可选)*/
      : "b"(x), "a"(y) /* 输入变量列表(可选)*/
      : /* 被破坏的寄存器列表(可选)*/);

  printf("%d + %d = %d\n", x, y, z);

  return 0;
}

编译运行:

$ cc -m32 -fno-pic -g demo.c -o demo
$ ./demo
3 + 4 = 7

汇编指令部分是addl %%ebx, %%eax,寄存器前多加了一个%。因为%在 C 语言中是特殊字符,所以要多加一个区分。随后是输出变量列表,我们只有一个输出变量z,前面双引号中的是约束条件=号指定它是输出操作数,a表示把%eaxz对应起来,最终的结果是%eax的值会输出到变量z中。接着是输入变量列表,x, y两个输入变量,使用逗号隔开。x的约束条件是b,表示把%ebxx关联起来;y的约束条件是a,表示把%eaxy关联起来。

上面的栗子是一个标准的内联汇编的示例。下面给出内联汇编的代码框架:

asm("汇编指令1\n\t"
    "汇编指令2\n\t"
    "汇编指令3\n\t"
    "汇编指令n"
    : 输出变量列表(可选)
    : 输入变量列表(可选)
    : 被破坏的寄存器列表(可选));

在给出部分约束条件的含义:

约束条件含义
a使用寄存器 eax
b使用寄存器 ebx
c使用寄存器 ecx
d使用寄存器 edx
S使用 esi
D使用 edi
q使用动态分配字节可寻址寄存器
r使用任意动态分配的寄存器
A使用寄存器 eax 与 edx 联合
m使用内存地址
o使用内存地址并可以加偏移量
I使用常数 0-31
J使用常数 0-63
K使用常数 0-255
M使用常数 0-3
N使用一字节常数 0-255

如果参数过多,每一个寄存器都要自己定的话很麻烦。这时我们可以使用约束条件r占位符让编译器帮我们决定用哪个寄存器。改造上面的栗子如下:

#include <stdio.h>

int main(void)
{
  int x = 1, y = 2;

  asm("addl %1, %0"
      : "=r"(y)        /* 输出变量列表(可选)*/
      : "r"(x), "0"(y) /* 输入变量列表(可选)*/
      : /* 被破坏的寄存器列表(可选)*/);

  printf("1 + 2 = %d\n", y);

  return 0;
}

占位符用来表示输入输出变量,规则是从输出列表开始,一直到输入列表结束,从左到右,从上到下依次为%0, %1, %2...。所以输出列表中的第一个变量y%0表示,然后到输入列表中的第一个变量x,用%1表示。第二个变量y在之前已经出现过,所以在约束条件处填0,把它们关联起来。除此之外,约束条件r告诉编译器,由编译器分配具体的寄存器。

编译运行:

$ cc -m32 -fno-pic -g demo.c -o demo
$ ./demo
1 + 2 = 3

掌握了这些知识点实际上已经够用了,但是被破坏的寄存器列表我们没有讲。因为现在的编译器已经很智能了,比如说上面我们将%ebxx关联,将%eaxy关联,实际上如果你查看反编译后的代码会发现,编译器已经将它们保护起来了。当以后如果碰到一些被隐式操作到的寄存器时记得将他们写到被破坏的寄存器列表中就可以了。