嵌入式汇编(内联汇编)

星比天高乐于分享

2022-01-18 13:20芝罘区灵歆网络工作室
关注

语法介绍

在 Linux 内核中常常看到 C 语言中嵌入汇编指令的地方。这是因为在 GCC 中支持在 C 代码中嵌入汇编指令,因此这些汇编代码被称为 GCC Inline ASM也即是 GCC 内联汇编。

使用内联汇编主要目的是为了提高效率,同时还是为了实现 C 语言无法实现的部分。

内联汇编的基本格式:

asm("汇编语句"
: 输出部分
: 输入部分
: 会被修改的部分);

共四个部分:汇编语句,输出部分,输入部分,会被修改的部分。

各部分使用“:”格开,汇编语句必不可少,其他三部分可选,如果使用了后面的部分,而前面部分为空,也需要用“:”格开,相应部分内容为空。例如:

__asm__ __volatile__("cli": : :"memory")

第一部分是汇编语句,其中 “asm” 是内联汇编语句关键词。

"汇编语句"是你写汇编指令的地方,其格式和汇编语言程序中使用的基本相同。这一部分是必须要有的。后面带冒号的行若不使用就都可以省略。语句之间使用“;”、“\n” 或 “\n\t” 分开。

在汇编语句中,数字加前缀 %,如 %0、%1 等,表示需要使用寄存器的样板操作数。可以使用的此类操作数的总数取决于 CPU 中通用寄存器的数量。由于这些样板操作数也是用%前缀,因此,在涉及到具体的寄存器时就要在寄存器前面加上2个%,以免混淆。

“输出部分”表示当这段嵌入汇编执行完之后,对输出变量的规定,也即是目标操作数如何结合的约束条件。每个这样的条件成为一个“约束”。必要时“输出部分”可以有多个约束,互相以逗号分隔。每个输出约束以“=” 号开头,然后是一个字母表示对操作数类型的说明,然后是关于变量结合的约束。

“输入部分”表示在开始执行汇编代码时,这里指定的一些寄存器中应存放的输入值,它们也分别对应着一 C 变量或常数值。输入约束的格式和输出约束相似,但不带“=”号。当“输出部分”为空,也即没有输出约束时,若有输入约束存在,则必须保留分隔标记 “:” 号。

“会被修改的部分”表示你已对其中列出的寄存器中的值进行了改动,gcc 编译器不能再依赖于它原来对这些寄存器加载的值。如果必要的话,gcc 需要重新加载这些寄存器。因此我们需要把那些没有在输出/输入寄存器中的部分列出,但是在汇编语句中明确使用到或隐含使用到的寄存器明列在这个部分。

操作数的编号从输出部分的第一个约束(序号为0)开始,顺序数下来,每个约束计数一次。在“汇编语句”中引用这些操作数或分配这些操作数的寄存器时,就在序号前面加上一个 “%” 号。

在汇编语句中引用一个操作数时总是把它当作一个32位的“长字”,但对其实施的操作,则根据需要也可以是字节操作或字(16位)操作。对操作数进行的字节操作默认为对其最低字节的操作,字操作也是一样。不过,在一些特殊的操作中,对操作数进行字节操作时也允许明确指出是哪一个字节操作,此时在%与需要之间插入一个“b”表示低字节,插入一个“h”表示次低字节。

表示约束条件的字母有很多。主要有:

“m”, “v” 和“o”      -------- 表示内存单元
“r” -------- 表示任何寄存器

“q” -------- 表示寄存器 eax、ebx、ecx、edx之一

“i” 和 “h” -------- 表示直接操作数
“E” 和 “F” -------- 表示浮点数

“g” -------- 表示“任意”

“a”,“b”,“c”,“d” -------- 分别表示要求使用寄存器 eax、ebx、ecx或edx

“S”, “D” -------- 分别表示要求使用寄存器 esi 或 edi

“I” -------- 表示常数(0至31)

此外,若一个操作数要求使用与前面某个约束中所要求的是同一个寄存器,那就把与那个约束对应的操作数编号放在约束条件中。在“会被修改的部分”常常会以 “memory” 为约束条件,表示操作完成后内存中的内容已发生改变,若原来某个寄存器(也许在本次操作中并未用到)的内容来自内存,则现在可能已经不一致。

示例分析一

__asm__ __volatile__("movl %1,%0" : "=r" (result) : "m" (input));

_asm_ 表示后面的代码为内嵌汇编,asm 是 _asm_ 的别名。
_volatile_ 表示编译器不要优化代码,后面的指令保留原样,volatile 是它的别名。

movl %1,%0 是指令模板;%0 和 %1 代表指令的操作数,称为占位符,内嵌汇编靠它们将C 语言表达式与指令操作数相对应。

指令模板后面用小括号括起来的是 C 语言表达式,本例中只有两个:result 和

input ,他们按照出现的顺序分别与指令操作数 %0 、%1 对应;注意对应顺序:第一个 C 表达式对应 %0 ;第二个表达式对应 %1 ,依次类推,操作数至多有10 个,分别用 %0, %1 …. %9 表示。

在每个操作数前面有一个用引号括起来的字符串,字符串的内容是对该操作数的限制或者说要求。

result 前面的限制字符串是 =r ,其中 = 表示 result 是输出操作数, r 表示需要将 result 与某个通用寄存器相关联,先将操作数的值读入寄存器,然后在指令中使用相应寄存器,而不是 result 本身,当然指令执行完后需要将寄存器中的值存入变量 result (因为它是输出部分),从表面上看好像是指令直接对 result 进行操作,实际上 GCC 做了隐式处理,这样我们可以少写一些指令。

input 前面的 r 表示该表达式需要先放入某个寄存器,然后在指令中使用该寄存器参加运算。

C 表达式或者变量与寄存器的关系由 GCC 自动处理,我们只需使用限制字符串指导 GCC 如何处理即可。限制字符必须与指令对操作数的要求相匹配,否则产生的汇编代码将会有错,读者可以将上例中的两个 r,都改为 m (m表示操作数放在内存,而不是寄存器中),编译后得到的结果是:

movl input, result

很明显这是一条非法指令,因此限制字符串必须与指令对操作数的要求匹配。例如指令 movl 允许寄存器到寄存器,立即数到寄存器等,但是不允许内存到内存的操作,因此两个操作数不能同时使用 m 作为限定字符。

示例分析二

static __inline__ void atomic_add(int i, atomic_t *v)
{
__asm__ __volatile__(
LOCK "addl %1 %0"
: "=m" (v->counter)
: "ir" (i), "m" (v->counter));
}

输出部分为

  : "=m" (v->counter)

这里只有一个约束,"=m" 表示相应的目标操作数(汇编代码中的 %0 )是一个内存单元 v->counter 。凡是与输出部分中说明的操作数相结合的寄存器或操作数本身,在执行嵌入的汇编代码以后均不保留执行之前的内容,这就给 gcc 提供了调度使用这些寄存器的依据。

输入部分第一个为 “ir (i)” ,表示汇编代码中的%1可以是一个在寄存器中的“直接操作数”(i表示 immcdiate ),并且该操作数来自于C代码中变量名(这里是调用参数)i。 第二个约束为 m”(v->counter),是一个内存单元。若一个输入约束要求使用寄存器,则在预处理时 gcc 会为之分配一个寄存器,并自动插入必要的指令将操作数即变量的值装入该寄存器。与输入部分中说明的操作数结合的寄存器或操作数本身,在执行嵌入的汇编代码以后也不保留执行之前的内容。例如,这里的 %1 要求使用寄存器,因此 gcc 会为其分配一个寄存器,并自动插入一条 movl 指令把参数i的数值装入该寄存器,可是这个寄存器原来的内容就不复存在了。若这个寄存器本来就是空的,那倒是无所谓,可是若所有的寄存器都在使用,而只好暂时借用一个,那就的保证在使用以后恢复其原值。此时 gcc 会自动在开头处插入一条 pushl 指令,将该寄存器原值保存在堆栈中,而在结束后插入一条 popl 指令,恢复寄存器内容。

经过分析来看,这段代码的作用就是将参数i的值加入到 v->counter上。代码中的关键字 LOCK 表示在 addl 指令时要把系统的总线锁住,不让别的 CPU(若系统有多个 CPU)打扰。也有可能有人会问,将两个数相加是很简单的操作,C语言中明明有相应的语言成分,如 “v->counter += i;”,为什么还要使用汇编呢?原因在于,这里要求整个操作只由一条指令完成,并且要将总线锁住,以保证操作的“原子性(atomic)”。相比之下,上述的C语句在编译之后到底有几条指令是没有保证的,也无法要求在计算过程中对总线加锁。

示例分析三

static inline void *__memcpy(void *to, const void *from, size_t n)
{
int d0, d1, d2;
__asm__ __volatile__(
"rep ; movsl\n\t"
"testb $2, %b4\n\t"
"je 1f\n\t"
"movsw\n"
"1:\ttestb $1, %b4\n\t"
"je 2f\n\t"
"movsb\n"
"2:"
: "=&c" (d0), "=&D" (d1), "=&S" (d2))
: "0" (n/4), "q" (n), "1" ((long) to), "2" ((long) from)
: "memory");
return (to);
}

从函数名可知,__memcpy是内核中对memcpy的实现,用来复制一块内存空间的内容,而忽略其数据结构。

输出部分有三个约束:

   : "=&c" (d0), "=&D" (d1), "=&S" (d2))

对应于操作数%0, %1, %2。其中变量 d0 为操作数%0,必须放到 ecx 中,同样,d1 即 %1 必须放到 edi 中,d2 即 %2 必须放到寄存器 esi 中。

输入部分有四个约束:

    : "0" (n/4), "q" (n), "1" ((long) to), "2" ((long) from)

对应于操作数 %3 至 %6。其中操作数 %3 与操作数 %0 使用同一个寄存器,所以也必须是ecx;并且要求gcc自动插入必要的指令,事先将其设置成n/4,实际上是将复制长度从字节个数n换算成长字节个数 n/4。 至于n 本身,则要求 gcc 任意分配一个寄存器存放。操作数 %5 与 %6, 即参数 to 与from,分别与 1% 和 %2 使用相同的寄存器,所以也必须是 edi 和 esi。

第一条指令是 “rep”,表示下一条指令movsl要重复执行,每重复一遍就把寄存器ecx中的内容减1,直到变成 0 为止。所以,在这段代码中一共执行了 n/4次。

那么, movsl 都干了什么呢?

它从 esi 所指的地方复制一个长字节到 edi 所指的地方,并使 esi 和 edi 分别加4。

所以 "rep ; movsl\n\t" 执行过程如下:

while(ecx) {  
movl (%esi), (%edi);
esi += 4;
edi += 4;
ecx--;
}

"rep ; movsl\n\t" 执行完说明所有的长字节都已复制好了,剩下的最多只剩下三个字节了,在这个过程中,实际上使用了 ecx、esi 和 edi 三个寄存器,即 %0(同时也是 %3 )、 %2(同时也是 %6 )、 %1(同时也是 %5 )三个操作数,这些都隐含在指令中,从字面上看不出来。同时也说明了为什么这些操作数必须存放在指定的寄存器中。

接下来就是处理剩下的三个字节了。

先通过 "testb $2, %b4\n\" 测试操作数 %4,即复制长度n的最低字节中的bit2,若这一位为1,说明还有至少2个字节,所以通过指令movsw复制一个段字( esi 和 edi 则分别加2),否则就把它跳过。

再通过 "1:\ttestb $1, %b4\n\t"(注意testb前面有个 \t,表示在预处理后的汇编代码中插入一个 TAB 字符)测试操作数 %4 的 bit1,若这一位为1,说明还剩下也给字节,所以通过指令 movsb 再复制一个字节,或者就把它跳过。到达标号2的时候,执行就结束了。

举报/反馈