C代码嵌入汇编语言主要有两个原因:
- C 语言对硬件底层的处理被受到限制,比如 C 语句不能直接修改处理器的程序状态寄存器;
- 写出高度优化的代码。毫无疑问,虽然 GNU C 优化器的工作做得很好,但是其处理结果依然与手工汇编代码有差距。
本节的主题是我们容易忽略的部分:当使用内联汇编语句添加汇编语言代码时, C 编译器的代码优化器会对这些代码进行优化处理。我们来检查一下循环移位例程可能产生的汇编代码:
00309DE5 ldr r3, [sp, #0] @ x, x
E330A0E1 mov r3, r3, ror #1 @ tmp, x
04308DE5 str r3, [sp, #4] @ tmp, y
编译器选择寄存器 r3 做循环移位使用,它也完全可以选择为每个 C 变量分配寄存器。可能不会显式地加载值或存储结果。下面是由不同版本的编译器使用不同编译选项生成的代码:
E420A0E1 mov r2, r4, ror #1 @ y, x
编译器为每个操作数选择一个相应的寄存器,使用已经缓存到 r4 中的值,并将 r2 中的结果传递给后面的代码。这个过程你能理解不?
有时候会更糟糕。有时候编译器甚至完全抛弃你嵌入的汇编代码。C 编译器的这种行为,取决于代码优化器的策略或嵌入汇编处的上下文。例如,如果你在后面的 C 程序中不再使用内联汇编中的任何输出操作数,优化器很有可能会删除掉你的内联汇编语句。在最开始的 NOP 例程中就可能出现这样的情况,因为编译器认为这段代码没有意义,只会增加开销、降低程序的执行效率。
上面问题的解决方法是使用 volatile 关键字指示编译器不要优化这段代码。NOP 的例程修改后的代码如下:
/* NOP example, revised */
asm volatile("mov r0, r0");
下面还有更多的烦恼等着我们。一个设计精细的优化器可能重新排列代码。看下面的代码:
i++;
if (j == 1)
x += 3;
i++;
对于上面的代码,优化器认为两个 i++ 语句将不会影响 if 条件语句的执行,而且如果 i 的值增加 2 将节省一条 ARM 汇编指令,因此编译器将重新组织代码:
if (j == 1)
x += 3;
i += 2;
因此,我们无法保证编译后的代码与源代码中的语句的顺序相同。
这种行为可能会对我们的代码产生很大的副作用。下面一段代码的作用是计算 b 和 c 的乘积。其中,b 和/或 c 的值可能会被中的例程修改。因此,我们在访问变量前先禁止中断,并在完成计算后再重新使能中断。
asm volatile("mrs r12, cpsr\n\t"
"orr r12, r12, #0xC0\n\t"
"msr cpsr_c, r12\n\t" ::: "r12", "cc");
c *= b; /* This may fail. */
asm volatile("mrs r12, cpsr\n"
"bic r12, r12, #0xC0\n"
"msr cpsr_c, r12" ::: "r12", "cc");
不幸的是,优化器可能会让乘积指令先执行,再执行两个内联汇编指令,或者相反。这会让我们的汇编代码毫无意义。
对于这个问题,我们可以借助于 clobber 列表。该例程中的 clobber 列表如下:
"r12", "cc"
这条 colbber 列表将给编译器传达如下信息:我这段汇编代码修改了寄存器 r12 的值,并更新了程序状态寄存器的标志位。顺便说一下,直接指明使用的寄存器,将有可能阻止了最好的优化结果。一般情况下,你应该传递一个变量,让编译器自己选择寄存器。clobber 列表中的关键字,除了寄存器名和 cc(状态寄存器标志位),还包括 memory。memory 关键字用来指示编译器,该汇编指令改变了内存中的值。这将强制编译器在执行汇编指令前将所有缓存的值保存起来,并在汇编指令执行完后再将这些值加载进去。此外,编译器还必须保留执行顺序,因为在执行完带有 memory 关键字的 asm 语句后,所有变量的内容都是无法预测的。
asm volatile("mrs r12, cpsr\n\t"
"orr r12, r12, #0xC0\n\t"
"msr cpsr_c, r12\n\t" :: : "r12", "cc", "memory");
c *= b; /* This is safe. */
asm volatile("mrs r12, cpsr\n"
"bic r12, r12, #0xC0\n"
"msr cpsr_c, r12" ::: "r12", "cc", "memory");
使所有缓存值无效可能是次优化的。你也可以添加一个虚拟操作数来创建一个虚拟依赖:
asm volatile("mrs r12, cpsr\n\t"
"orr r12, r12, #0xC0\n\t"
"msr cpsr_c, r12\n\t" : "=X" (b) :: "r12", "cc");
c *= b; /* This is safe. */
asm volatile("mrs r12, cpsr\n"
"bic r12, r12, #0xC0\n"
"msr cpsr_c, r12" :: "X" (c) : "r12", "cc");
上述代码中,第一条汇编语句尝试去修改变量 b ,第二条汇编语句尝试使用变量 c 。这将保留三个语句的执行顺序,而不要使缓存的变量无效。
理解优化器对内嵌汇编的影响很重要。如果你读到这里还是云里雾里,最好是在看下个主题之前把这段文章再读几遍^_^。
其它要点:
使用内联汇编作为预处理宏
对于经常需要重用的汇编代码,你可以将它们定义成宏放在头文件中。不过,如果这些头文件用在模块中,将导致编译器在严格 ANSI 模式下时产生警告信息。为了消除警告,需要将 asm 用 asm、volatile 用 volatile 替换掉。asm 和 volatile 相当于 asm 和 volatile 的别名。下面的宏可以将一个长整型值从小端转到大段或者相反。
#define BYTESWAP(val) \
__asm__ __volatile__ ( \
"eor r3, %1, %1, ror #16\n\t" \
"bic r3, r3, #0x00FF0000\n\t" \
"mov %0, %1, ror #8\n\t" \
"eor %0, %0, r3, lsr #8" \
: "=r" (val) \
: "0"(val) \
: "r3", "cc" \
);
C 桩函数
当宏被引用时,它将在每个引用处展开为相同的汇编代码,这在大型程序中是不可接受的。在这种情形下,你可以定义一个 C 桩(stub)函数。将上面的宏以 C 函数形式重新实现如下:
unsigned long ByteSwap(unsigned long val)
{
asm volatile (
"eor r3, %1, %1, ror #16\n\t"
"bic r3, r3, #0x00FF0000\n\t"
"mov %0, %1, ror #8\n\t"
"eor %0, %0, r3, lsr #8"
: "=r" (val)
: "0"(val)
: "r3"
);
return val;
}
替换 C 变量的符号名
默认情况下,GCC 在 C 和汇编代码中的函数、变量使用相同的符号名。你可以使用一个特殊的格式— asm 语句 — 为汇编代码指定不同的名字:
unsigned long value asm("clock") = 3686400;
该语句指示编译器在生成汇编代码时使用 clock 作为符号名,而不要使用默认的 value。这只对全局变量有效,因为局部变量(又叫自动变量)在汇编代码中没有符号名。
替换 C 变量的符号名
虽然编译器不允许在函数定义中使用 asm 关键字,但是你可以通过原型声明来改变函数的名字:
extern long Calc(void) asm ("CALCULATE");
调用函数 Clac() 时将会调用到汇编指令中的函数 CALCULATE。
强制使用指定的寄存器
局部变量可以存储在寄存器中。你可以使用内联汇编为局部变量指定一个寄存器。
void Count(void) {
register unsigned char counter asm("r3");
... some code...
asm volatile("eor r3, r3, r3" : "=l" (counter));
... more code...
}
译注:
eor 是异或指令,其原型是 EOR , <操作数1>, <操作数2>。
汇编指令 “eor r3, r3, r3” 将清除变量 counter 的值(清零)。需要注意,在大多数情况下使用该指令都不是一个好主意,原因有两点:该指令会与编译器的优化器产生冲突;GCC 不会为相关的寄存器保留完整的备份。如果编译器认为变量不会再被引用,那么对应的寄存器就会被重用(re-used)。编译器没有能力检查这些寄存器是否与其它预定义寄存器之间存在冲突。如果你用这种方式指定了太多的寄存器,编译器甚至可能在产生代码时就将寄存器耗尽。
临时使用寄存器
如果你使用了寄存器,但是该寄存器没有出现在操作数中,那么你需要告诉编译器你使用了该寄存器。下面的例子将一个值调整为 4 的倍数,它使用 r3 作为 scratch 寄存器,并将其指定在 clobber 列表中,以告知编译器。此外,ands 指令修改了 CPU 的状态标识,因此也将 cc 添加到 clobber 列表中了。
asm volatile(
"ands r3, %1, #3" "\n\t"
"eor %0, %0, r3" "\n\t"
"addne %0, #4"
: "=r" (len)
: "0" (len)
: "cc", "r3"
);
再次声明,直接将寄存器的用法写死(hard coding)总是一个坏习惯!更好的实现方法是使用 C 桩函数,并使用局部变量作为临时值。
使用常量
你可以使用 mov 指令将一个立即数常量值加载到寄存器中。这个常量值通常将限定在 0~255 之间。
asm("mov r0, %[flag]" : : [flag] "I" (0x80));
但是当在给定范围内移位偶数个比特的时候,也可以使用一个更大的值。换言之,该值可以是
其中: n 是上面提到的 0255,x 是 024 中的偶数。由于可以翻转,x 可以被设为 26、28 或 30,在这种情况下,比特 3732 被翻转到比特 50。最后,当使用 mvn 指令代替 mov 指令时,需要使用这些值的二进制补码。
如果你需要跳转到一个由预处理宏定义的固定内存地址处,你可以使用下面的汇编代码:
ldr r3, =JMPADDR
bx r3
如果上述常量是一个合法地址(比如 0x20000000),聪明的汇编器就会将上面的代码转换为:
mov r3, #0x20000000
bx r3
如果不合法(比如 0x00F000F0),汇编器将会从文字池(literal pool)中加载其值。
ldr r3, .L1
bx r3
...
.L1: .word 0x00F000F0
内联汇编与上述汇编代码的的工作方式相同。但是你不需要使用 ldr 指令,你只需要提供一个常量作为寄存器的值:
asm volatile("bx %0" : : "r" (JMPADDR));
根据常量的实际值,你可以使用 mov 或 ldr 指令的变体。例如,如果 JMPADDR 被定义为 0xFFFFFF00,那么相应的代码类似于:
mvn r3, #0xFF
bx r3
真实世界当然比这个更复杂,比如可能有这样的需求:我们需要将一个常量加载到一个特殊寄存器中。假设我们想要调用一个子程序,但是在调用完后返回到另一个地址。当嵌入式固件从 main 返回时,这样做会很有用。在这种情况下,我们需要将值加载到连接寄存器(lr)。汇编代码如下:
ldr lr, =JMPADDR
ldr r3, main
bx r3
想到如何用内联汇编实现这段代码吗?答案是:
asm volatile(
"mov lr, %1\n\t"
"bx %0\n\t"
: : "r" (main), "I" (JMPADDR));
但是还有一个问题。我们这里使用的是 mov 指令。当 JMPADDR 的值合法时,代码将能像我们期待那样正常工作。当不合法时,需要使用 ldr 指令代替。但是不幸的是,内联汇编中没有这样的表达式
ldr lr, =JMPADDR
相反,我们必须写成
asm volatile(
"mov lr, %1\n\t"
"bx %0\n\t"
: : "r" (main), "r" (JMPADDR));
与纯汇编代码相比,我们使用了一条额外的语句作为结尾——使用一个额外的寄存器。
ldr r3, .L1
ldr r2, .L2
mov lr, r2
bx r3
注:
好晕,但是翻译得应该没问题。
寄存器的用途
比较好的学习方法是分析编译后的汇编清单,并学习 C 编译器生成的代码。下面的表格是编译器典型使用的 ARM 寄存器,知道这些将有助于理解代码。
寄存器 | 别名 | 用途 |
---|---|---|
r0 | a1 | 第一个函数参数: Scratch 寄存器 |
r1 | a2 | 第二个函数参数: Scratch 寄存器 |
r2 | a3 | 第三个函数参数: Scratch 寄存器 |
r3 | a4 | 第四个函数参数: Scratch 寄存器 |
r4 | v1 | 寄存器变量 |
r5 | v2 | 寄存器变量 |
r6 | v3 | 寄存器变量 |
r7 | v4 | 寄存器变量 |
r8 | v5 | 寄存器变量 |
r9 | v6 rfp | 寄存器变量 实际的帧指针 |
r10 | sl | 栈接线 |
r11 | fp | 参数指针 |
r12 | ip | 临时 |
r13 | sp | 栈指针 |
r14 | lr | 连接寄存器 |
r15 | pc | 程序计数 |