Skip to content
Published at:

18. 与 C 语言互操作

前 17 章编写的都是纯汇编程序。现实中汇编更常见的使用场景是:用 C 写主体逻辑,仅在性能关键路径、硬件访问、或启动代码中用汇编。汇编与 C 的互操作依赖 ABI——只要双方遵守同一套寄存器使用和参数传递约定,它们就可以无缝集成。本章覆盖 C 调用汇编函数、汇编调用 C 标准库、GCC 内联汇编、以及跨语言协作中的常见陷阱。

C 调用汇编函数

汇编侧:导出符号与遵守 ABI

汇编侧只需要做三件事:

  1. .globl 导出函数符号,使链接器对 C 可见
  2. 从 a0-a7 读取参数(第 11 章详述)
  3. 返回值放入 a0,返回前恢复 callee-saved 寄存器
asm
# 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 声明汇编函数,然后正常调用:

c
// 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 符号存在于其他目标文件中,不要报符号未定义错误,链接时会解析。

编译与链接

bash
# 方法一:分别编译后链接
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

复杂示例:带局部变量和栈帧的函数

asm
# 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:

bash
riscv64-linux-gnu-gcc -o prog main.s   # gcc 自动链接 libc 和启动文件
# 不要用: riscv64-linux-gnu-ld -o prog main.o  # 缺少 _start 和 libc

调用 printf

asm
# 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 保存到栈。否则 retracall printf 写入的值,永远无法返回到 C 运行时。

调用 malloc / free

asm
# 动态分配示例
    .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_done

mallocfree 是 C 标准库函数,需要链接 libc。调用约定与自定义汇编函数完全相同——a0-a7 传参,a0 返回。

GCC 内联汇编基础

当汇编片段仅需嵌入数条指令时,不需要整个 .s 文件。GCC 的内联汇编(inline assembly)在 C 代码中直接写汇编,编译器处理寄存器分配和操作数绑定。

基本语法形式

c
asm [volatile] (
    "指令1\n\t"
    "指令2\n\t"
    : 输出操作数列表    // outputs(可选)
    : 输入操作数列表    // inputs(可选)
    : 破坏描述列表      // clobbers(可选)
);

每条指令用字符串字面量表示,\n\t 分隔以避免编译器将多指令合并为一行。三个冒号段依次为 outputs、inputs、clobbers。

最简单的内联汇编

c
// 无操作数,仅触发 NOP
asm volatile("nop");

// 读 CSR(无输出,无输入,无 clobber)
asm volatile("csrr %0, cycle" : "=r"(cycles));

内联加法函数

c
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 标记很重要:它告诉编译器该输出在输入被完全消费之前就会被写入——防止编译器将输入和输出分配到同一寄存器。

内联系统调用

c
// 用内联汇编封装 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 寄存器。

c
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 恢复。

asm
# 错误示例:直接使用 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 地址未对齐
  • 信号处理栈不可用
asm
# 错误: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
// 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
    ret

8 字节内结构体直接"展开"成 1-2 个寄存器——不是传指针。C 调用者和汇编实现必须就此达成一致。

4. 全局变量访问

汇编中访问 C 全局变量需要两步:用 la 加载地址,然后用 ld/lw/sd/sw 读写:

c
// C 侧定义全局变量
int counter = 0;
asm
# 汇编侧访问
    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 标记:

asm
# 保持私有的辅助函数——C 看不到
    .type helper_func, @function
    .hidden helper_func      # ELF 可见性:hidden = 不跨共享库导出
helper_func:
    ...
    ret

6. 调试信息

在汇编中加 .cfi_* 伪指令可让调试器生成准确的回溯信息。虽不强制,但在混合调用栈崩溃时价值极大:

asm
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_endproc

cfi(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