18. 与 C 语言互操作
前 17 章编写的都是纯汇编程序。现实中汇编更常见的使用场景是:用 C 写主体逻辑,仅在性能关键路径、硬件访问、或启动代码中用汇编。汇编与 C 的互操作依赖 ABI——只要双方遵守同一套寄存器使用和参数传递约定,它们就可以无缝集成。本章覆盖 C 调用汇编函数、汇编调用 C 标准库、GCC 内联汇编、以及跨语言协作中的常见陷阱。
C 调用汇编函数
汇编侧:导出符号与遵守 ABI
汇编侧只需要做三件事:
- 用
.globl导出函数符号,使链接器对 C 可见 - 从 a0-a7 读取参数(第 11 章详述)
- 返回值放入 a0,返回前恢复 callee-saved 寄存器
# add.s —— 汇编实现整数加法
.text
.globl add # 导出符号,使 C 能链接到它
.type add, @function # ELF 符号类型标注(可选但推荐)
add:
# 函数入口:a0 持有第一参数,a1 持有第二参数
# 这是一个叶子函数(不调用其他函数),无需设置栈帧
add a0, a0, a1 # a0 = a0 + a1,和作为返回值放入 a0
ret # jalr x0, ra, 0;ra 存有调用者的返回地址ret 是伪指令,展开为 jalr x0, ra, 0。调用者(C 代码)在 call add 时将返回地址写入 ra(第 11 章详述),ret 通过 ra 跳回。
C 侧:extern 声明
C 侧用 extern 声明汇编函数,然后正常调用:
// main.c
#include <stdio.h>
// 声明外部汇编函数
extern int add(int a, int b);
int main(void) {
int x = 42, y = 58;
int result = add(x, y);
printf("%d + %d = %d\n", x, y, result); // 42 + 58 = 100
return 0;
}extern 告诉编译器:add 符号存在于其他目标文件中,不要报符号未定义错误,链接时会解析。
编译与链接
# 方法一:分别编译后链接
riscv64-linux-gnu-as -o add.o add.s
riscv64-linux-gnu-gcc -c -o main.o main.c
riscv64-linux-gnu-gcc -o prog add.o main.o
# 方法二:一步到位(GCC 自动处理 .s 汇编)
riscv64-linux-gnu-gcc -o prog main.c add.s复杂示例:带局部变量和栈帧的函数
# sum_array.s —— 对整数数组求和(需 callee-saved 寄存器)
.text
.globl sum_array
sum_array:
# a0 = int *arr(数组首地址)
# a1 = int n (元素个数)
# 返回:a0 = 所有元素之和
addi sp, sp, -16 # 栈帧:16 字节(ABI 要求 16 字节对齐)
sd ra, 8(sp) # 保存返回地址(如果本函数调别的函数)
sd s0, 0(sp) # 保存 s0(callee-saved)
mv s0, x0 # s0 = sum = 0
mv t0, x0 # t0 = i = 0
beq a1, x0, done # n == 0 → 直接返回 0
loop:
# RV64 下 int 是 32 位,用 lw 而非 ld
lw t1, 0(a0) # t1 = arr[i]
add s0, s0, t1 # sum += arr[i]
addi a0, a0, 4 # arr++(int 占 4 字节)
addi t0, t0, 1 # i++
blt t0, a1, loop # i < n → 继续
done:
mv a0, s0 # 返回值 = sum
ld s0, 0(sp) # 恢复 s0
ld ra, 8(sp) # 恢复 ra
addi sp, sp, 16 # 释放栈帧
ret要点:函数使用了 s0(callee-saved),必须在 prologue 保存、epilogue 恢复。即使本函数是叶子函数(不调别的函数),一旦用了 s0-s11,仍需保存恢复。
汇编调用 C 标准库
入口点链接模型
当汇编程序链接 libc 时,可以用 main 作为入口——C 运行时(crt0.o)提供的 _start 会初始化 libc 然后调 main:
_start (crt0.o)
→ __libc_start_main
→ 初始化 stdio/stdlib、处理 env/auxv
→ main(argc, argv) ← 你的代码从这里开始
→ exit(main 返回值)如果入口为 main 并使用 libc 函数,链接时必须用 gcc(而非裸 ld),因为 gcc 会自动链接 crt0.o 和 libc:
riscv64-linux-gnu-gcc -o prog main.s # gcc 自动链接 libc 和启动文件
# 不要用: riscv64-linux-gnu-ld -o prog main.o # 缺少 _start 和 libc调用 printf
# hello_libc.s —— 汇编调用 printf
.section .rodata
fmt:
.asciz "Hello, %s! x = %d\n"
name:
.asciz "RISC-V"
.text
.globl main
main:
# main 入口:a0=argc, a1=argv
addi sp, sp, -32 # 16 字节对齐 + 额外空间
sd ra, 24(sp) # 保存 ra(main 调 printf,ra 会被覆盖)
# printf(fmt, name, 42)
la a0, fmt # 参数 1:格式串
la a1, name # 参数 2:%s 替换
li a2, 42 # 参数 3:%d 替换
call printf # 调 C 库函数
# 返回值
li a0, 0 # main 返回 0
ld ra, 24(sp)
addi sp, sp, 32
ret关键:call printf 会将 ra 设为返回地址后再跳转——因此必须在 main 的 prologue 中把入口时的 ra 保存到栈。否则 ret 时 ra 是 call printf 写入的值,永远无法返回到 C 运行时。
调用 malloc / free
# 动态分配示例
.text
.globl main
main:
addi sp, sp, -16
sd ra, 8(sp)
# void *p = malloc(128)
li a0, 128
call malloc # a0 = 分配的内存指针(或 NULL)
# 检查失败
beq a0, x0, exit_fail
# 用 memset 清零(可选)
mv s0, a0 # s0 = p(注意 s0 需保存和恢复)
mv a1, x0 # memset 第二参数 = 0
li a2, 128 # memset 第三参数 = 128
call memset
# free(p)
mv a0, s0
call free
li a0, 0
exit_done:
ld ra, 8(sp)
addi sp, sp, 16
ret
exit_fail:
li a0, 1
j exit_donemalloc 和 free 是 C 标准库函数,需要链接 libc。调用约定与自定义汇编函数完全相同——a0-a7 传参,a0 返回。
GCC 内联汇编基础
当汇编片段仅需嵌入数条指令时,不需要整个 .s 文件。GCC 的内联汇编(inline assembly)在 C 代码中直接写汇编,编译器处理寄存器分配和操作数绑定。
基本语法形式
asm [volatile] (
"指令1\n\t"
"指令2\n\t"
: 输出操作数列表 // outputs(可选)
: 输入操作数列表 // inputs(可选)
: 破坏描述列表 // clobbers(可选)
);每条指令用字符串字面量表示,\n\t 分隔以避免编译器将多指令合并为一行。三个冒号段依次为 outputs、inputs、clobbers。
最简单的内联汇编
// 无操作数,仅触发 NOP
asm volatile("nop");
// 读 CSR(无输出,无输入,无 clobber)
asm volatile("csrr %0, cycle" : "=r"(cycles));内联加法函数
static inline int add_inline(int a, int b) {
int result;
asm volatile(
"add %0, %1, %2"
: "=r"(result) // %0:输出操作数,任意寄存器
: "r"(a), "r"(b) // %1=%2:输入操作数,任意寄存器
);
return result;
}%0、%1、%2 按输出→输入的声明顺序编号。"=r" 表示"写入任意寄存器";"r" 表示"从任意寄存器读取"。
约束符速查
| 约束符 | 含义 | 示例 |
|---|---|---|
r | 任意通用寄存器 | "r"(x) |
m | 内存操作数 | "m"(*ptr) |
i | 立即数(编译时常量) | "i"(42) |
n | 已知数值的立即数 | "n"(64) |
+r | 既读取又写入的寄存器 | "+r"(x) |
=r | 仅写入的寄存器 | "=r"(result) |
&r | 早期破坏(earlyclobber) | "=&r"(tmp) |
"=&r" 的 earlyclobber 标记很重要:它告诉编译器该输出在输入被完全消费之前就会被写入——防止编译器将输入和输出分配到同一寄存器。
内联系统调用
// 用内联汇编封装 write 系统调用
#include <unistd.h>
static inline ssize_t raw_write(int fd, const void *buf, size_t len) {
register long a0 asm("a0") = fd;
register long a1 asm("a1") = (long)buf;
register long a2 asm("a2") = len;
register long a7 asm("a7") = 64; // __NR_write
asm volatile(
"ecall"
: "+r"(a0) // a0 既是输入也是输出
: "r"(a1), "r"(a2), "r"(a7)
: "memory"
);
// 原始 syscall 返回负 errno
if (a0 < 0) {
errno = -a0;
return -1;
}
return a0;
}register ... asm("a0") 语法将 C 变量绑定到指定寄存器,配合 ecall 的 ABI 约束使用。"memory" clobber 告诉编译器:系统调用可能修改任意内存——阻止编译器将 load/store 跨越这条内联汇编重排。
带 register ... asm() 的局部变量声明是 GCC 扩展——变量不一定要分配到指定寄存器(编译器可自由选择),但当它出现在内联汇编的操作数列表中时,编译器会确保该变量就位在声明的寄存器中。
clobber list 详解
clobber list 告诉编译器:内联汇编除了显式声明的 outputs 之外,还会修改哪些寄存器/状态。编译器在分配寄存器时会避开 clobber 寄存器。
asm volatile(
"call some_func"
: // 无输出
: // 无输入
: "ra", // call 写入 ra(返回地址)
"t0", "t1", "t2", // 被调用者可能破坏的临时寄存器
"a0", "a1", // 函数可能使用参数寄存器
"memory" // 函数可能访问/修改任意内存
);常用 clobber 项:
| Clobber | 含义 |
|---|---|
"memory" | 汇编代码读取或写入编译器的内存视图之外的地址;阻止编译器跨越该 asm 重排 load/store 和缓存内存值 |
"cc" | 修改了条件码标志(RISC-V 无此问题,x86 需要) |
| 寄存器名 | 该寄存器的内容将被破坏;编译器保证该 asm 执行时 live 变量不在此寄存器中 |
"memory" 的性能代价:它充当完全的内存屏障。如果内联汇编只读写一个确定地址,应使用 "m" 操作数精确告知编译器,避免全局内存屏障的开销。
跨语言协作踩坑指南
1. callee-saved 寄存器:必须保存恢复
这是最高频的错误。C 调用汇编函数时,C 编译器可能会在 s0-s11 中持有 live 变量。汇编函数如果使用这些寄存器,必须在 prologue 保存、epilogue 恢复。
# 错误示例:直接使用 s0,没有保存恢复
bad_func:
mv s0, a0 # 破坏调用者的 s0!
add a0, s0, a1
ret # 调用者拿回被破坏的 s0,崩溃或误算
# 正确示例
good_func:
addi sp, sp, -16
sd s0, 0(sp) # 保存调用者的 s0
mv s0, a0
add a0, s0, a1
ld s0, 0(sp) # 恢复调用者的 s0
addi sp, sp, 16
ret不用 s0-s11 就不需要保存——这正是叶子函数不需要栈帧的原因。
2. 栈 16 字节对齐
RISC-V ABI 要求 sp 始终 16 字节对齐。在函数入口、每次 sp 调整、以及在信号处理上下文中均如此。违反此约束可能导致:
- 变长参数函数(如
printf)访存异常 - 浮点 load/store 地址未对齐
- 信号处理栈不可用
# 错误:8 字节对齐
addi sp, sp, -8 # sp 只有 8 字节对齐
# 正确:16 字节对齐
addi sp, sp, -16 # 即使只需要 8 字节,也分配 16 字节3. 结构体传参规则
RV64 ABI 中,小结构体(按值传递)通过寄存器传递:
- 小于等于 16 字节的结构体:直接通过 a0-a1(或浮点寄存器)传值——不是传指针,而是传内容
- 16 字节内,由数据布局决定分别放入 a0 和 a1
- 超过 16 字节:调用者分配临时空间,传指针(由调用者负责)
// C 侧
typedef struct { int x; int y; } Point; // 8 字节
Point add_points(Point a, Point b); // a 进 a0, b 进 a1
// 汇编侧
// Point add_points(Point a, Point b)
// 入口:a0 = a.x, a1 = a.y, a2 = b.x, a3 = b.y ← 注意拆开了
// 返回:a0 = result.x, a1 = result.y
add_points:
add a0, a0, a2 # a0 = a.x + b.x
add a1, a1, a3 # a1 = a.y + b.y
ret8 字节内结构体直接"展开"成 1-2 个寄存器——不是传指针。C 调用者和汇编实现必须就此达成一致。
4. 全局变量访问
汇编中访问 C 全局变量需要两步:用 la 加载地址,然后用 ld/lw/sd/sw 读写:
// C 侧定义全局变量
int counter = 0;# 汇编侧访问
la t0, counter # t0 = &counter
lw t1, 0(t0) # t1 = counter(读 32 位 int)
addi t1, t1, 1 # t1++
sw t1, 0(t0) # counter = t1(写回)la 是伪指令,链接时展开为 auipc+addi(或 lui+addi)序列以计算绝对地址。
5. 链接顺序与符号可见性
C 调汇编函数时,链接顺序通常无关紧要——链接器默认做全局符号解析。但如果多个 .o 中有同名符号,第一个出现的优先(取决于是否 --whole-archive 等)。建议在汇编中为内部辅助函数加 LOCAL 标记:
# 保持私有的辅助函数——C 看不到
.type helper_func, @function
.hidden helper_func # ELF 可见性:hidden = 不跨共享库导出
helper_func:
...
ret6. 调试信息
在汇编中加 .cfi_* 伪指令可让调试器生成准确的回溯信息。虽不强制,但在混合调用栈崩溃时价值极大:
func:
.cfi_startproc
addi sp, sp, -16
.cfi_def_cfa_offset 16
sd ra, 8(sp)
.cfi_offset ra, -8
...
ld ra, 8(sp)
.cfi_restore ra
addi sp, sp, 16
.cfi_def_cfa_offset 0
ret
.cfi_endproccfi(Call Frame Information)记录栈帧布局的元数据,编入 .eh_frame section。gdb 据此打印回溯(backtrace)、展开栈帧。
本章要点
- C 调用汇编:汇编侧
.globl导出 + 遵守 ABI(a0-a7 收参、a0 返回、callee-saved 保存恢复);C 侧extern声明 - 汇编调用 C:入口设为
main(由 C 运行时_start调用),调printf/malloc/free遵守 ABI,必须用 gcc 链接以自动引入 libc 和启动文件 - GCC 内联汇编适用于嵌入数条指令:操作数用约束符(
r/m/i)绑定 C 变量,"memory"clobber 阻止编译器错误重排内存访问 - 常见陷阱:s0-s11 必须保存恢复、sp 16 字节对齐、结构体按寄存器拆开传递、全局变量需
la+ load/store