如何在C或C++代码中嵌入ARM汇编代码

时间:2024-01-16 23:47:44

大家知道,用C或者C++等高级语言编写的程序,会被编译器编译成最终的机器指令。这中间,编译器会对代码自动进行优化。但是,这种优化往往不一定非常高效。

所以,出于性能优化的目的,对非常关键的代码,任然需要直接用汇编指令编写。

并且在C和C++中,是无法直接对寄存器进行操作的,如果要实现的功能需要频繁与底层硬件打交道,也需要用汇编指令编写。

GCC编译器支持直接在C或者C++代码中,嵌入ARM汇编代码。其基本格式非常简单,大致如下:

  1. __asm__ [__volatile__] ( assembler template
  2. : [output operand list]                  /* optional */
  3. : [input operand list]                   /* optional */
  4. : [clobbered register list]              /* optional */
  5. );

首先是关键字“__asm__”,其实也可以写成“asm”。但是“asm”并不是所有版本的GCC编译器都支持的,而且有可能和程序中别的地方定义的变量或函数名冲突,所以用“__asm__”的话,兼容性会好一点。

下面是“__volatile__”关键字,这个是可选的,其作用是禁止编译器对后面编写的汇编指令再进行优化。一般情况下,自己写的汇编代码肯定是自己进行设计优化过了的,如果编译器再进行优化的话,很有可能效果还不如不优化,而且也有可能会出现奇怪的错误,所以通常都会带上这个关键字。同样,“__volatile__”也可以写成“volatile”,但可能兼容性会没那么好。

下面,在括号里面的,就是真正的汇编代码了,其主要有四部分组成。第一个是具体的汇编代码,这是必需的。而后面三个是一些辅助参数,这些参数是可选的。

各个部分间使用冒号“:”进行分割。如果前面的部分没有使用,而后面的部分使用了,则前面的部分也需要用冒号留空。例如:

  1. __asm__ __volatile__ ("msr cpsr, %0" : : "r" (status));

可以看出,本例中没有第二部分(输出参数列表),只有第三部分(输入参数列表),但它们中间任然要留出冒号进行分割。同时,也没有第四部分,但并不需要在第三部分后面加上冒号。

下面一一解释各个部分的作用:

1)汇编代码模板

所有的汇编代码必须用双引号括起来。如果有多行汇编代码的话,每一条语句都要用双引号括起来,并且在代码后面要加上换行符(“\n”或者“\n\t”)。这样做是因为GCC会将汇编代码部分作为字符串形式直接传给汇编器,加上换行符后,汇编器就能准确知道哪些字符串表示的是一条汇编语句。同时,为了增加可读性,每条汇编语句都可以换行。

其具体形式如下:

  1. __asm__ __volatile__ ( "instruction 1\n\t"
  2. "instruction 2\n\t"
  3. ......
  4. "last instruction"
  5. );

因为汇编代码部分是必需的,所以即使一行汇编代码也没有,也需要传入空字符串(""),否则会报错。

2)输出操作数列表和输入操作数列表

前面介绍了,第二部分和第三部分分别表示输出操作数列表和输入操作数列表。

输入操作数表示要作为汇编代码输入的C表达式,而输出操作数刚好相反,表示汇编代码处理完后要输出结果的C表达式。如果有多个输出或输入表达式,需要用逗号(“,”)将它们分隔开来。

可以再前面的汇编代码模板中直接应用定义的输出操作数和输入操作数,其用法是使用百分号(“%”)后面接一个数字,0表示定义的第一个操作数,1表示定义的第二个操作数,依次类推。下面举个例子:

  1. __asm__("mov %0, %1, ror #1"
  2. : "=r" (result)
  3. : "r" (value)
  4. );

这里%0代表后面定义的第一个操作数,即输出操作数,代表C语言中的result变量。%1代表定义的第二个操作数,即输入操作数,代表C语言中的value变量。其作用是将value的值右移一位,然后保存到result中。

每一个操作数由三部分组成,分别是修改符(Modifier),限定符(Constraint)和C表达式,其中修改符是可选的。具体形式如下:

  1. "[modifier]constraint" (C expression)

修改符和限定符要用双引号括起来,而C表达式要用括号括起来。那么这些修改符和限定符又是什么呢?有什么作用呢?

我们接下来先来说说所谓的限定符。可以看出,操作数在这里的作用是将C语言定义的变量与汇编语言中要使用到的变量进行一一对应。但并不是所有的汇编指令都可以接受任何类型的变量作为输入或输出变量的,因此汇编器需要知道这些变量到底用在什么地方,从而帮助在传递之前做一些转换。常用的限定符主要有以下一些,而且汇编语句到底是ARM的还是Thumb的,对限定符的定义也会不同:

限定符 在ARM指令集下 在Thumb指令集下
f 浮点寄存器f0...f7 N/A
h N/A 寄存器r8...r15
G 浮点常量立即数 N/A
H 和G作用相同 N/A
I 数据处理指令中用到的立即数 范围为0...255的常量
J 范围为-4095...4095的索引常量 范围为-255...-1的常量
K 和I作用相同 和I作用相同
L 和I作用相同 范围为-7...7的常量
l 和r作用相同 寄存器r0...r7
M 范围为0...32或者是2的幂次方的常量 范围为0...1020的4的倍数的常量
m 内存地址 内存地址
N N/A 范围为0...31的常量
O N/A 范围为-508...508的4的倍数的常量
r 通用寄存器r0...r15 N/A
w 向量浮点寄存器s0...s31 N/A
X 任何类型的操作数 任何类型的操作数

看起来很复杂,但是常用的也就是r,f和m等几个。

好,说完了限定符,下面来看看修改符。修改符是加在限定符之前的,并且是可选的,如果没有修改符的话,则表明这个操作数是只读的。这个对输入操作数没有问题,但是对输出操作数来说,肯定是需要被修改的,那怎么办呢?答案就是使用修改符,修改这个操作数的属性。目前,GCC中定义了三个修改符,分别是:

修改符 含义
= 只写操作数,通常用于输出操作数中
+ 可读且可写操作数,必须要列在输出操作数中
& 寄存器只能用于输出

所以,作为输出操作数,只需要在限定符前加上“=”就可以了。

在汇编代码中,请绝对不要修改输入操作数的值。

如果想让一个C变量既作为输入操作数,也作为输出操作数的话,可以使用“+”限定符,并且这个操作数只需要在输出操作数列表中列出就行了。例如:

  1. __asm__("mov %0, %0, ror #1"
  2. : "+r" (y)
  3. );

上面的例子是将变量y中的值又移1位。因为输入和输出操作数是一个,所以该操作数要既可读也可写,因此添加了“+”修改符。

但是GCC的老版本不一定支持“+”修改符,如果也想达到前面的这种输入和输出操作数是一个的目的,可以用一种变通的做法,并且这种变通的做法GCC的新版本也是支持的。其实,在限定符中,也可以使用数字,其作用是指代前面定义的操作数,0代表第一个,1代表第二个,以此类推。例如:

  1. __asm__("mov %0, %0, ror #1"
  2. : "=r" (y)
  3. : "0" (y)
  4. );

如果GCC编译器支持的话,这个例子的效果和前面的例子是相同的。本例不同的是,先定义了一个可写的输出变量,同时在输入变量列表中,明确用数字0指出了前面定义的第一个操作数同时也要用来作为输入操作数。

好了,已经介绍了两个修改符的用处了,还剩下最后一个“&”。前面的例子是明确要求输出操作数和输入操作数使用同一个寄存器,但有时候刚好相反,输出操作数使用的寄存器一定不能和输入操作数使用的寄存器一样。但是,由于编译器的优化,是完全有可能出现同一个寄存器既用作输入操作数也用作输出操作数的情况的。这时,可以在输出操作数中使用“&”修改符,明确告诉编译器,代表输出操作数的寄存器一定不能使用输入操作数已经使用过的寄存器。下面举个例子:

  1. __asm__ __volatile__("ldr %0, [%1]\n\t"
  2. "str %2, [%1, #4]"
  3. : "=&r" (rdv)
  4. : "r" (&table), "r" (wdv)
  5. : "memory");

本例中,将操作一个table数组,读出它的第一个数存放到rdv中,然后修改第二个数为wdv中存放的值。乍看一下没什么问题,但是如果编译器用同一个寄存器来表示输入操作数&table(%1)和输出操作数rdv(%0)怎么办呢?执行完第一条语句之后,table数组的地址就被修改掉了。所以,可以在输出操作数中加上一个“&”修改符,强制保证输出操作数不能和输入操作数复用同一个寄存器,这个问题就解决了。如果汇编代码中有输入寄存器还没有使用完毕,就对输出操作数进行修改的情况,则特别需要用“&”修改符,保证不复用。

3)修改寄存器列表
在汇编指令中,有可能会用到一些指定的寄存器,但是在执行你定义的汇编程序时,那个指定的寄存器有可能另有别的用途,存放了非常重要的数据。等你的程序执行完成后,那个寄存器的值已经被你修改过了,肯定会造成执行错误。因此,在执行你的程序之前必须要做必要的备份和恢复的动作。但是,编译器并不会分析你的汇编代码,找出这种被你修改过,需要恢复的寄存器,因此你必须显式的告诉编译器,被你修改过的寄存器有哪些。这就是修改寄存器列表所起到的作用。

对于嵌入内联ARM汇编来说,此列表中的值有下面三种类型:

类型 作用
r0...r15 告诉编译器汇编代码中修改了通用寄存器r0...r15
cc 告诉编译器汇编代码会导致CPU状态位的改变
memory 告诉编译器汇编代码会读取或修改内存中某个地址存放的值

对于“memory”来说,它并不是表示寄存器被读取或修改了,而是表示内存中的值被修改了。出于优化的目的,在执行你的汇编代码之前,编译器将某些变量的值还保存在寄存器中,并没有被写到实际的内存中。但是,如果你的汇编代码会读取内存中的值,则很有可能新的值还在寄存器中,而内存中存放的还是老的值,这样就会造成错误。添加了“memory”之后,编译器会在执行你的代码之前,保证将保存在寄存器中,没有更新到内存中的值全部都写入到内存中。

此列表中的每一项都要用双引号("")括起来,每项之间要用逗号(“,”)分割。