嵌入式系统/混合 C 和汇编编程
许多程序员更习惯于用 C 语言编写代码,这有充分的理由:C 语言是一种中级语言(与汇编语言相比,汇编语言是一种低级语言),它为程序员省去了实际实现的一些细节。
然而,有一些低级任务要么可以用汇编语言更好地实现,要么只能用汇编语言实现。此外,程序员经常需要查看 C 编译器的汇编输出,并手动编辑或手动优化汇编代码,以实现编译器无法实现的方式。汇编语言对于时间关键型或实时进程也很有用,因为与高级语言不同,汇编语言没有关于代码如何编译的歧义。时间可以严格控制,这对于编写简单的设备驱动程序很有用。本节将介绍在混合 C 和汇编程序开发中使用的多种技术。
在 C 编程项目中使用汇编代码片段最常见的方法之一是使用称为**内联汇编**的技术。在不同的编译器中,内联汇编的调用方式不同。此外,内联汇编中使用的汇编语言语法完全取决于 C 编译器使用的汇编引擎。例如,Microsoft C++ 仅接受 MASM 语法中的内联汇编命令,而 GNU GCC 仅接受 GAS 语法中的内联汇编(也称为 AT&T 语法)。本页将讨论一些常见编译器中混合语言编程的基础知识。
#include<stdio.h>
void main() {
int a = 3, b = 3, c;
asm {
mov ax,a
mov bx,b
add ax,bx
mov c,ax
}
printf("%d", c);
}
#include "stdio.h"
#include<iostream>
int main(void)
{
unsigned char r1=0x90,r2=0x1A,r3=0x2C;
unsigned short m1,m2,m3,m4;
__asm__
{
MOV AL,r1;
MOV AH,r2;
MOV m1,AX;
MOV BL,r1;
ADD BL,3;
MOV BH,r3;
MOV m2,BX;
INC BX;
DEC BX:
MOV m3,BX;
SUB BX,AX;
MOV m4,AX;
}
printf("r1=0x%x,r2=0x%x,r3=0x%x\n",r1,r2,r3);
printf("m1=0x%x,m2=0x%x,m3=0x%x,m4=0x%x\n",m1,m2,m3,m4);
return 0;
}
当汇编源文件被汇编器汇编,C 源文件被 C 编译器编译时,这两个**目标文件**可以由**链接器**链接在一起,形成最终的可执行文件。这种方法的优点是汇编文件可以使用程序员熟悉的任何语法和汇编器编写。此外,如果需要更改汇编代码,所有这些代码都存在于一个单独的文件中,程序员可以轻松访问。以这种方式混合汇编和 C 的唯一缺点是:a) 汇编器和编译器都需要运行,b) 这些文件需要由程序员手动链接在一起。这些额外的步骤相对容易,尽管这意味着程序员需要学习编译器、汇编器和链接器的命令行语法。
内联汇编的优点
短的汇编例程可以直接嵌入到 C 代码文件中的 C 函数中。然后,混合语言文件可以用单个命令完全编译到 C 编译器(而不是用汇编器编译汇编代码,用 C 编译器编译 C 代码,然后将它们链接在一起)。这种方法快速且简单。如果内联汇编嵌入到函数中,那么程序员无需担心#调用约定,即使将编译器开关更改为不同的调用约定也是如此。
链接汇编的优点
如果选择了新的微处理器,所有汇编命令都隔离在一个 ".asm" 文件中。程序员只需更新该文件 - 无需更改任何 ".c" 文件(如果它们是可移植编写的)。
在编写单独的 C 和汇编模块并使用链接器将它们链接在一起时,重要的是要记住,许多高级 C 结构是明确定义的,需要由程序的汇编部分正确处理。也许混合语言编程中最大的障碍是函数调用约定的问题。所有 C 函数都是根据程序员选择的特定约定实现的(如果你从未“选择”过特定的调用约定,那是因为你的编译器有一个默认设置)。本页将介绍程序员可能会遇到的一些常见调用约定,并说明如何在汇编语言中实现这些约定。
用一个编译器编译的代码与用不同的调用约定编译的代码链接在一起时,将无法正常工作。如果代码是用 C 或其他高级语言(或嵌入到 C 函数中的内联汇编语言)编写的,这只是一个小麻烦 - 程序员需要选择她今天想使用哪个编译器/优化开关,并以这种方式重新编译程序的每个部分。将汇编语言代码转换为使用不同的调用约定需要更多的手动工作,并且更容易出现错误。
不幸的是,调用约定通常因编译器而异 - 即使在同一个 CPU 上也是如此。偶尔,调用约定会从一个编译器版本更改为下一个版本,甚至在同一个编译器中,使用不同的“优化”开关时也会发生更改。
不幸的是,很多时候,特定编译器版本的特定调用约定没有充分的文档记录。因此,汇编语言程序员被迫使用逆向工程技术来确定他们需要知道的准确细节,以便调用用 C 语言编写的函数,并以便接受来自用 C 语言编写的函数的调用。
典型流程是:[1]
- 用“.c”文件编写存根... 细节??? ... ... 你的汇编语言函数所需的输入和输出数量和类型完全相同。
- 使用适当的开关编译该文件,以生成包含 C 语言注释的混合汇编语言文件(通常为“.cod”文件)。(如果你的编译器不能生成汇编语言文件,则可以手动反汇编二进制“.obj”机器码文件,这很繁琐)。
- 将“.cod”文件复制到“.asm”文件。(有时你需要删除编译的十六进制数字并注释掉其他行,以便将其转换为汇编器可以处理的内容)。
- 测试调用约定 - 将“.asm”文件编译为“.obj”文件,并将它(而不是存根“.c”文件)链接到程序的其余部分。测试以查看“调用”是否正常工作。
- 填写您的“.asm”文件 -“.asm”文件现在应该在每个函数上包含适当的页眉和页脚,以正确实现调用约定。注释掉函数中间的存根代码,并使用您的汇编语言实现填写函数。
- 测试。通常,程序员会逐条执行新代码中的每条指令,确保它按他们想要的方式执行。
- 通常,参数在函数之间传递(无论是用 C 还是汇编语言编写),通过堆栈。例如,如果一个函数 foo1() 调用一个函数 foo2() 并带有 2 个参数(例如字符 x 和 y),那么在控制跳转到 foo2() 的开头之前,两个字节(大多数系统中字符的正常大小)将被填充为需要传递的值。一旦控制跳转到新函数 foo2(),并且您在函数中使用这些值(作为参数传递),它们将从堆栈中检索并使用。
有两种参数传递技术正在使用,
- 1. 按值传递
- 2. 按引用传递
参数传递技术也可以使用
- 从右到左(C 风格)
- 从左到右(Pascal 风格)
在具有大量寄存器(如 ARM 和 Sparc)的处理器上,标准调用约定将 *所有* 参数(甚至返回值地址)都放在寄存器中。
在寄存器数量不足的处理器上(如 80x86 和 M8C),所有调用约定都必须至少将一些参数放在堆栈上或 RAM 中的其它地方。
一些调用约定允许“可重入代码”。
使用按值传递,将实际值(文字内容)的副本传递。例如,如果您有一个接受两个字符的函数,如下所示
void foo(char x, char y){
x = x + 1;
y = y + 2;
putchar(x);
putchar(y);
}
并且您如下调用此函数
char a,b;
a='A';
b='B';
foo(a,b);
那么程序在调用函数 foo 之前,将 'A' 和 'B' 的 ASCII 值副本(分别为 65 和 66)压入堆栈。您可以看到函数 foo() 中没有提及变量 'a' 或 'b'。因此,您对 foo 中这两个值所做的任何更改都不会影响调用函数中 a 和 b 的值。
- 想象一下,您需要将大量数据传递给一个函数,并在该函数中应用修改,将这些修改应用于原始变量的情况。这种情况的例子可能是一个将包含小写字母的字符串转换为大写的函数。将整个字符串(特别是如果它很大)传递给函数,然后在转换完成后将整个结果传递回调用函数,这是一个不明智的决定。在这里,我们将变量的地址传递给函数。这有两个优点,一是您不必传递大量数据,从而节省了执行时间,二是您可以立即处理数据,以便在函数结束时,调用函数中的数据已修改。
- 但是请记住,您对按引用传递的变量所做的任何更改都会导致原始变量被修改。如果这不是您想要的,那么您必须在调用函数之前手动复制变量。
在当前一代的 32 位和 64 位处理器出现之前,80x86 架构使用了一个复杂的段式内存模型(也称为实模式)。对于大多数目的,除非编写极其底层的代码来直接与硬件或外设芯片接口,否则不会遇到这种情况。
现代代码通常被编写为支持“保护模式”,在这种模式下,内存空间对于大多数目的可以被认为是扁平的。
以下关于保护模式中调用约定的信息。
在 CDECL 调用约定中,以下内容成立
- 参数以从右到左的顺序传递到堆栈上,返回值传递到 eax 中。
- 调用函数清理堆栈。这允许 CDECL 函数具有可变长度参数列表(也称为变参数函数)。出于这个原因,编译器不会将参数数量附加到函数名称,因此汇编器和链接器无法确定是否使用了错误数量的参数。
变参数函数通常具有特殊的入口代码,由 va_start()、va_arg() C 伪函数生成。
考虑以下 C 指令
_cdecl int MyFunction1(int a, int b)
{
return a + b;
}
以及以下函数调用
x = MyFunction1(2, 3);
它们将分别生成以下汇编列表
:_MyFunction1
push ebp
mov ebp, esp
mov eax, [ebp + 8]
mov edx, [ebp + 12]
add eax, edx
pop ebp
ret
和
push 3
push 2
call _MyFunction1
add esp, 8
当转换为汇编代码时,CDECL 函数几乎总是以一个下划线开头(这就是为什么所有以前的示例在汇编代码中都使用了“_”)。
STDCALL,也称为“WINAPI”(以及一些其他名称,具体取决于您阅读的位置),几乎被微软专门用作 Win32 API 的标准调用约定。由于 STDCALL 是由微软严格定义的,因此所有实现它的编译器都以相同的方式实现它。
- STDCALL 将参数从右到左传递,并将返回值传递到 eax 中。(微软文档错误地声称参数是从左到右传递的,但事实并非如此。)
- 被调用函数清理堆栈,与 CDECL 不同。这意味着 STDCALL 不允许可变长度参数列表。
考虑以下 C 函数
_stdcall int MyFunction2(int a, int b)
{
return a + b;
}
以及调用指令
x = MyFunction2(2, 3);
它们将分别生成以下汇编代码片段
:_MyFunction@8
push ebp
mov ebp, esp
mov eax, [ebp + 8]
mov edx, [ebp + 12]
add eax, edx
pop ebp
ret 8
和
push 3
push 2
call _MyFunction@8
这里有几个重要的事项需要说明
- 在函数体中,ret 指令有一个(可选)参数,指示函数返回时从堆栈中弹出多少个字节。
- STDCALL 函数以一个下划线开头进行名称修饰,后面跟着一个 @,然后是传递到堆栈上的参数数量(以字节为单位)。这个数字在 32 位对齐的机器上始终是 4 的倍数。
FASTCALL 调用约定在所有编译器之间并不完全标准,因此应谨慎使用。在 FASTCALL 中,前 2 或 3 个 32 位(或更小)参数传递到寄存器中,最常用的寄存器是 edx、eax 和 ecx。附加参数,或大于 4 字节的参数,传递到堆栈上,通常以从右到左的顺序(类似于 CDECL)。调用函数最常见的是负责清理堆栈,如果需要的话。
由于存在歧义,建议仅在速度至关重要的 1、2 或 3 个 32 位参数的情况下使用 FASTCALL。
以下 C 函数
_fastcall int MyFunction3(int a, int b)
{
return a + b;
}
以及以下 C 函数调用
x = MyFunction3(2, 3);
将分别为被调用函数和调用函数生成以下汇编代码片段
:@MyFunction3@8
push ebp
mov ebp, esp ;many compilers create a stack frame even if it isn't used
add eax, edx ;a is in eax, b is in edx
pop ebp
ret
和
;the calling function
mov eax, 2
mov edx, 3
call @MyFunction3@8
FASTCALL 的名称修饰在函数名称前加一个 @,并在函数名称后加 @x,其中 x 是传递给函数的参数数量(以字节为单位)。
许多编译器仍然为 FASTCALL 函数生成堆栈帧,特别是在 FASTCALL 函数本身调用另一个子例程的情况下。但是,如果 FASTCALL 函数不需要堆栈帧,那么优化编译器可以随意省略它。
实际上所有使用 ARM 处理器的人都使用标准调用约定。与其他处理器相比,这使得混合 C 和 ARM 汇编编程相当容易。Thumb 函数最简单的入口和出口序列为:[2]
an_example_subroutine:
PUSH {save-registers, lr} ; one-line entry sequence
; ... first part of function ...
BL thumb_sub ;Must be in a space of +/- 4 MB
; ... rest of function goes here, perhaps including other function calls
; somehow get the return value in a1 (r0) before returning
POP {save-registers, pc} ; one-line return sequence
C 编译器使用哪些寄存器?
char 为 8 位,int 为 16 位,long 为 32 位,long long 为 64 位,float 和 double 为 32 位(这是唯一支持的浮点格式),指针为 16 位(函数指针为字地址,允许寻址 ATmega 设备上的整个 128K 程序内存空间,这些设备具有 > 64 KB 的 flash ROM)。有一个 -mint8 选项(参见 C 编译器 avr-gcc 的选项)可以使 int 为 8 位,但这不受 avr-libc 支持,并且违反了 C 标准(int 必须至少为 16 位)。它可能会在将来的版本中被删除。
可能由 GNU GCC 分配给局部数据。你可以在汇编子程序中自由使用它们。调用 C 子程序可能会覆盖它们,调用者负责保存和恢复它们。
可能由 GNU GCC 分配给局部数据。调用 C 子程序不会改变它们。汇编子程序负责保存和恢复这些寄存器(如果改变)。r29:r28(Y 指针)用作帧指针(指向栈上的局部数据)(如果需要)。即使在编译器为参数传递分配这些寄存器的情况下,被调用方也要保存/保护这些寄存器内容的要求仍然适用。
从不分配给 GNU GCC 用于局部数据,但通常用于固定目的
r0 - 临时寄存器,可以被任何 C 代码覆盖(除了保存它的中断处理程序),可以用来在一部分汇编代码中暂时记住一些东西
r1 - 在任何 C 代码中假设始终为零,可以用来在一部分汇编代码中暂时记住一些东西,但使用后必须清除(clr r1)。这包括任何使用 [f]mul[s[u]] 指令,这些指令在 r1:r0 中返回结果。中断处理程序在进入时保存并清除 r1,在退出时恢复 r1(如果它不为零)。
参数 - 从左到右分配,r25 到 r8。所有参数都对齐以从偶数编号的寄存器开始(奇数大小的参数,包括 char,在它们上面有一个空寄存器)。这允许在增强内核上更好地利用 movw 指令。
如果太多,则那些不适合的将在栈上传递。
返回值:8 位在 r24(不是 r25!)中,16 位在 r25:r24 中,最多 32 位在 r22-r25 中,最多 64 位在 r18-r25 中。8 位返回值由调用方零扩展/符号扩展到 16 位(无符号 char 比有符号 char 更有效 - 只需 clr r25)。具有可变参数列表的函数(printf 等)的参数都在栈上传递,char 扩展为 int。
警告:在 2000-07-01 之前没有这种对齐,包括 gcc-2.95.2 的旧补丁。检查你的旧汇编子程序,并相应地调整它们。
不幸的是,在为 Microchip PIC 编写程序时,使用了多种(不兼容)调用约定。
并且 PIC 架构的几个“特性”使得大多数子程序调用需要几个指令——比许多其他处理器上的单个指令更冗长。
调用约定必须处理
- “分页”闪存程序存储器架构
- 硬件栈的限制(可能通过在软件中模拟栈)
- “分页”RAM 数据存储器架构
- 确保中断例程的子程序调用不会混淆中断返回到主循环后所需的信息。
Sparc 有特殊的硬件支持一个很好的调用约定
一个“寄存器窗口”...
- ↑ ARM 技术支持知识文章:[infocenter.arm.com/help/topic/com.arm.doc.faqs/ka8926.html "从 C 调用汇编例程"]
- ↑ ARM。 ARM 软件开发工具包。 1997。第 9 章:ARM 过程调用标准。第 10 章:Thumb 过程调用标准。
- 从 ASM 代码调用 C/C++ 函数
- 操作系统开发:“GCC 中的 C++ 到 ASM 链接”
- Stack Overflow:“有没有办法在 C 中插入汇编代码?”
- "指令集模拟在 C 中" 描述了从纯 C 算法逐渐转换为混合汇编和 C 语言以进行测试的过程。
- "汇编和 C 源文件的接口 - AN2129" 描述了在 Cypress PSoC 处理器上混合 C 和汇编语言代码。
- "内联汇编食谱" 描述了在 Atmel AVR 处理器上混合 C 和汇编语言代码。