RISC-V汇编学习(五)—— 汇编实战、GCC内联汇编(基于芯来平台)

发布于:2025-03-15 ⋅ 阅读:(16) ⋅ 点赞:(0)

高级编程语言如C、C++、Java、Python等已经极大地简化了软件开发的过程,并且在大多数应用领域中提供了足够的抽象层次来隐藏底层硬件细节,但在某些特定场景下,汇编语言仍然不可或缺。以下是几个这样的特殊应用场景:

  • 在底层驱动开发中,它允许直接控制硬件并优化性能;
  • 在引导程序中,确保系统的正确初始化,特别是在不同硬件平台上的兼容性;
  • 在高性能算法库中,针对特定架构进行深度优化,以达到极致性能;
  • 安全分析、逆向工程等等方面也有应用

但是掌握汇编语言不仅要求对目标架构有深入的理解,还需要投入大量的时间和精力去学习其语法和最佳实践,这对于大部分的开发者来说还是有难度的,实际上也没有必要完全掌握,因为大部分人并不会直接去写汇编程序。

幸运的是,现代编程环境提供了内嵌汇编(inline assembly)的功能,允许我们在高级语言(如C/C++)中直接插入汇编代码片段。这种方法结合了高级语言易于使用的优点与汇编语言的强大功能,使得开发者能够在不完全脱离高级语言抽象层的情况下,利用底层硬件特性进行针对性的优化。通过这种方式,开发者可以在保持代码可读性和维护性的同时,实现对关键部分代码的细致调优。

因此,我将从编写独立的汇编程序以及在C代码中嵌入内联汇编的角度来介绍RISC-V汇编语言的使用。

RISCV汇编学习系列:

RISC-V汇编学习(一)—— 基础认识
RISC-V汇编学习(二)—— 汇编语法
RISC-V汇编学习(三)—— RV指令集
RISC-V汇编学习(四)—— RISCV QEMU平台搭建(基于芯来平台)
RISC-V汇编学习(五)—— 汇编实战、GCC内联汇编(基于芯来平台)

1 汇编入门

对于新手而言,直接进入汇编语言的世界可能会觉得无从下手;一个更为平滑的学习路径是从大家较为熟悉的C语言入手,我们通过将简单的C程序转换为汇编代码来逐步理解汇编语言。例如,可以先从编写一个基础的“Hello World”程序开始,然后使用如GCC这样的编译器工具将其转换成对应的汇编语言版本。这不仅有助于新手建立起高级语言与底层执行间的直观联系,还能让他们以更易接受的方式接触到汇编语言的基础知识。

1.1 首先需要有一个C源文件作为起点

#include <stdio.h>
int main(void)
{
    printf("Hello World\r\n");
    return 0;
}

1.2 使用GCC生成汇编代码

riscv64-unknown-elf-gcc -S helloworld.c -o helloworld.S

注:该汇编代码示例仅展示了一个使用RISC-V GCC编译器从C语言源码转换而来的简单程序;然而,这个示例并未包含任何针对特定硬件开发板的初始化代码或配置信息,并无法跑在前面的qemu中

1.3 阅读并分析汇编代码

.file	"main.c"                    // 指明源文件名为 main.c,用于调试信息。
.option nopic                   // 禁用位置无关代码(PIC),意味着代码中的地址引用是绝对的而非相对的。
.attribute arch, "rv64i2p0_m2p0_a2p0_zmmul1p0_zaamo1p0_zalrsc1p0"  // 定义目标架构及其扩展集,这里指定了RV64I基础指令集及其他特定扩展。
.attribute unaligned_access, 0  // 设置未对齐访问属性为0,表示不支持未对齐的内存访问。
.attribute stack_align, 16      // 设置栈帧必须按照16字节边界对齐,提高性能和兼容性。

.text                           // 标记代码段开始。
.section	.rodata.str1.8,"aMS",@progbits,1  // 定义一个只读数据节 .rodata.str1.8,用于存储字符串常量,具有分配、合并及字符串表属性。
.align	3                       // 设置后续数据按8字节边界对齐(2^3 = 8)。
.LC0:                           // 定义一个本地标签 LC0,通常用于指向字符串常量。
	.string	"Hello World\r"     // 存储字符串 "Hello World\r" 到当前地址。

.section	.text.startup,"ax",@progbits  // 定义包含主函数的代码段,具有可分配、可执行属性。
.align	2                       // 设置代码按4字节边界对齐(2^2 = 4),以优化取指效率。
.globl	main                    // 声明全局符号 main,使它对外部可见。
.type	main, @function          // 指定 main 是一个函数。

main:                           // 函数 main 的入口点。
	lui	a0,%hi(.LC0)          // 将 .LC0 的高部分加载到寄存器 a0 中。
	addi	sp,sp,-16            // 调整堆栈指针 sp,预留空间保存返回地址和其他局部变量。
	addi	a0,a0,%lo(.LC0)      // 将 .LC0 的低部分添加到 a0 中,完成完整地址加载。
	sd	ra,8(sp)              // 将返回地址 ra 存储到堆栈中。
	call	puts                 // 调用 C 库函数 puts 输出字符串。
	ld	ra,8(sp)              // 从堆栈恢复返回地址 ra。
	li	a0,0                  // 设置返回值为0,表示程序正常退出。
	addi	sp,sp,16             // 恢复堆栈指针 sp。
	jr	ra                   // 返回到调用者。

.size	main, .-main           // 计算并记录 main 函数的大小。
.ident	"GCC: (g553a166de) 14.2.1 20240816"  // 提供编译器版本信息。
.section	.note.GNU-stack,"",@progbits  // 定义一个空的 GNU 栈段,指示操作系统是否需要设置堆栈的可执行权限。

在提供的RISC-V汇编代码示例中,我们可以很清晰看到一个完整的程序结构,它不仅包含了执行逻辑(即主函数),还定义了程序的数据段和代码段属性。

1.4 反汇编

实际上当我们进行程序调试时,尤其是在处理低级别的错误或者优化代码性能时,我们通常面对的是反汇编代码而不是原始的高级语言或汇编代码。这是因为编译后的二进制文件并不包含源代码的信息,而是由机器指令组成。为了理解这些指令的功能,我们可以使用工具如objdump来将二进制文件反汇编成可读的汇编代码;

riscv64-unknown-elf-objdump -S helloworld  > helloworld.dump

以main函数为例:

汇编代码 (Assembly) 反汇编代码 (Disassembly) 说明
main: 0000000000010224 <main>: 函数入口点标识
lui a0,%hi(.LC0) - 加载字符串地址的高部分到寄存器 a0(未在反汇编中直接显示)
addi sp,sp,-16 ff010113 addi sp,sp,-16 调整堆栈指针 sp,预留空间保存返回地址和其他局部变量
- 00113423 sd ra,8(sp) 将返回地址 ra 存储到堆栈中(汇编代码中没有显式使用 sd
addi a0,a0,%lo(.LC0) 7d078513 addi a0,a5,2000 # 137d0 <__errno+0xc> .LC0 的低部分添加到 a0 中,完成完整地址加载(反汇编中通过 a5 实现)
sd ra,8(sp) - 将返回地址 ra 存储到堆栈中(与反汇编中的 sd ra,8(sp) 对应)
call puts 0f0000ef jal 1032c <puts> 跳转并链接到 puts 函数
- 00000793 li a5,0 设置立即数 0 到 a5(汇编中对应 li a0,0
li a0,0 00078513 mv a0,a5 设置返回值为 0,表示程序正常退出
addi sp,sp,16 01010113 addi sp,sp,16 恢复堆栈指针 sp
jr ra 00008067 ret 返回到调用者
- 00813083 ld ra,8(sp) 从堆栈恢复返回地址 ra(汇编中有对应的指令)
- 00013403 ld s0,0(sp) 从堆栈恢复 s0 寄存器的值(额外的寄存器操作,在原始汇编代码中未提及)

在反汇编代码中看到的额外的堆栈操作(如保存和恢复s0寄存器),可能是编译器为了保持某些状态而自动加入的,这在原始汇编代码中是没有提及的。

虽然我们重点关注的是原始汇编代码的设计与实现,但通过将其与反汇编结果进行对比,可以帮助我们更好地理解程序的实际执行流程,以及编译器在这一过程中所起的作用。

2 RISC-V ABI规则

即使到这,直接写汇编还是无从下手,因为学习到的内容就是一盘散沙,不熟悉RISC-V ABI规则(Application Binary Interface)

ABI定义了软件组件之间的低级接口规范,包括函数调用时寄存器的使用、数据类型的大小和布局、栈帧的结构等。对于RISC-V来说,ABI确保了不同程序或库之间能够正确交互。

2.1 RISC-V中的寄存器与调用约定

调用约定定义了函数如何传递参数、返回值,以及寄存器和栈的使用规则。在RISC-V架构中,有32个通用整数寄存器(x0到x31),每个寄存器可以保存一个32位的值。根据ABI,这些寄存器被分配了特定的角色:

  • x0 (zero):总是返回0。
  • x1 (ra):用于保存返回地址。
  • x5-x7, x10-x17 (a0-a7):用于传递参数和返回结果。
  • x8-x9, x18-x27 (s0-s11):称为保存寄存器,必须由调用者保存其值。
  • x2 (sp):堆栈指针。
  • x3 (gp):全局指针。
  • x4 (tp):线程指针。
  1. 示例1:简单函数调用
int add(int a, int b) {
    return a + b;
}

假设我们要编写一个简单的加法函数,该函数接受两个整数作为输入,并返回它们的和。按照RISC-V的调用约定,前两个参数通过a0和a1传递,返回值也通过a0返回。

参数传递:

  • a=3 → a0寄存器
  • b=5 → a1寄存器
    返回值 → a0寄存器
    对应的汇编指令:
# 加法函数 
add_numbers: 
add a0, a0, a1 # 将a0和a1相加,结果存储回a0 
ret # 返回

在这个例子中,我们直接将两个参数相加并将结果放入a0。由于这是一个非常简单的函数,不需要保存任何寄存器或调整栈指针。

  1. 示例2:参数过多时的栈传递
    假设函数有9个参数:

void func(int a, int b, …, int i);
调用时:
• 前8个参数通过a0-a7传递
• 第9个参数i通过栈传递(栈指针sp指向的位置)

2.2 栈帧与局部变量

当函数需要更多的参数或者有局部变量时,就需要使用栈帧。栈帧是为每个函数调用分配的一块内存区域,用来保存局部变量和保存寄存器的状态。RISC-V要求栈指针(sp)按16字节对齐。
例如,如果我们有一个函数需要三个参数,第三个参数将通过栈传递:

# 函数接收三个参数,并返回第一个参数加上第二个参数的结果 
three_arg_func: 
	addi sp, sp, -16 # 分配16字节的栈空间 
	sd ra, 8(sp) # 保存返回地址
	ld t0, 16(sp) # 从栈加载第三个参数到临时寄存器t0 
	add a0, a0, a1 # 计算a0 + a1 
	add a0, a0, t0 # 再加上第三个参数 
	ld ra, 8(sp) # 恢复返回地址 
	addi sp, sp, 16 # 清理栈
	ret # 返回

这里我们看到,为了处理第三个参数,我们分配了一些栈空间,并将返回地址保存到栈上。然后,我们从栈上读取第三个参数,并完成计算。

2.3 代码模型(Code Models)

代码模型决定了如何生成地址。
示例:需要访问全局变量global_var:
lui a0, %hi(global_var) # 加载高20位地址到a0
lw a1, %lo(global_var)(a0) # 用低12位偏移加载值

  • %hi和%lo是汇编器宏,帮助生成绝对地址的高位和低位。

2.4. 线程本地存储(TLS)

线程局部变量在不同线程中有独立的副本。
示例:Local Exec模型

__thread int tid = 0;

对用汇编:

lui   a5, %tprel_hi(tid)    # 加载高20位偏移
add   a5, a5, tp, %tprel_add(tid) # 将偏移加到线程指针(tp)
lw    a0, %tprel_lo(tid)(a5) # 加载tid的值
  • tp寄存器指向线程本地存储区的基地址。

2.5 动态链接与PLT/GOT

动态链接通过PLT(程序链接表)和GOT(全局偏移表)实现延迟绑定。
示例:调用动态库函数
调用printf时:

# 第一次调用printf
auipc t2, %got_pcrel_hi(printf)  # 获取printf在GOT中的地址
ld    t3, %pcrel_lo(t2)(t2)      # 加载GOT条目(此时指向PLT)
jalr  t3                         # 跳转到PLT条目
  • 首次调用:PLT条目调用动态链接器解析printf的真实地址,并更新GOT。
  • 后续调用:直接通过GOT跳转到真实地址。

2.6. 调试信息(DWARF)

DWARF帮助调试器理解程序结构。例如:

  • 寄存器x1(ra)的DWARF编号为1。
  • 浮点寄存器f0的DWARF编号为32。

RISC-V ABI 规则细节还是很多的,这里挂一漏万,错误不足指出,还请批评指正。

3 手写汇编

有了第二节的些许基础,我们来简单手写个汇编。
目标:实现函数int add(int a, int b)并在main中调用

int add(int a, int b) {
    return a + b;
}

int main() {
    int result = add(3, 5);
    return result;
}
  1. 调用约定
    前一节中提到了根据RISC-V调用约定:
  • a0-a7:参数传递与返回值
  • t0-t6:临时寄存器(调用者保存)
  • s0-s11:保存寄存器(被调用者保存)
  • sp:栈指针
  • ra:返回地址寄存器
    在RISC-V中,按照标准的ABI,前8个整数或指针类型的参数通过寄存器a0至a7传递,返回值通常通过a0寄存器返回。对于我们的加法函数,我们只需要两个参数,所以它们将分别位于a0和a1中。
  1. 设计函数逻辑

    1. 将a存入寄存器(如a0)
    2. 将b存入寄存器(如a1)
    3. 相加结果存到目标寄存器(如a0)
  2. 指令选择

    • 数据移动:li(加载立即数)、mv(寄存器间移动)
    • 算术运算:add/sub/mul
    • 内存访问:ld(加载双字)、sd(存储双字)
    • 流程控制:call(函数调用)、ret(返回)
  3. 栈管理
    当需要保存寄存器或局部变量时:

    1. 扩展栈空间:addi sp, sp, -N
    2. 保存数据:sd ra, 8(sp)
    3. 恢复数据:ld ra, 8(sp)
    4. 回收栈空间:addi sp, sp, N

由于这是一个非常简单的函数,它不需要保存任何寄存器或分配栈空间,因为我们不会修改需要保留的寄存器,也不会有局部变量。如果函数更复杂,你可能需要保存一些寄存器的状态,并为局部变量分配栈空间。

.section .text
.globl add
add:
    add a0, a0, a1   # 直接相加,结果存入a0
    ret               # ret = jr ra

.globl main
main:
    li a0, 3         # 第一个参数a=3
    li a1, 5         # 第二个参数b=5
    call add         # 调用函数,结果在a0
    ret              # 退出程序(a0=8作为返回值)

完整栈帧保护

.section .text
#-----------------------------
# add函数(带栈保护)
#-----------------------------
.globl add
add:
    addi sp, sp, -16   # 分配栈空间
    sd ra, 8(sp)       # 保存返回地址(虽然此处不需要,但演示规范)

    add a0, a0, a1     # 核心计算

    ld ra, 8(sp)       # 恢复返回地址
    addi sp, sp, 16    # 回收栈空间
    ret

#-----------------------------
# main函数(带栈保护)
#-----------------------------
.globl main
main:
    addi sp, sp, -16
    sd ra, 8(sp)       # 保存返回地址

    li a0, 3
    li a1, 5
    call add           # 调用后a0=8

    ld ra, 8(sp)       # 恢复返回地址
    addi sp, sp, 16
    ret

4 内联汇编(inline asm)语法

在实际开发中,直接编写汇编代码的情况相对较少,更多时候会使用高级语言如C或C++来编写程序。然而,有时为了性能优化、访问特定硬件特性或者实现某些特定功能(例如原子操作),开发者可能会选择在C/C++代码中嵌入汇编代码——这就是所谓的内联汇编。

4.1 内联汇编基本语法结构

GCC内联汇编

asm [volatile] (
    汇编指令列表
    : 输出操作数列表
    : 输入操作数列表
    : 破坏列表(Clobber List)
);
  • 关键字 asm volatile或(asm volatile)其中:asm 表示是内嵌汇编操作,必需的关键字;volatile是可选的关键字,使用volatile表示gcc不进行任何优化。

  • 汇编指令列表: 在内联汇编中,“汇编指令列表”需用双引号括起来,每条指令间以“\n”或“;”分隔。若缺少分隔符,两个字符串将合并。该列表遵循普通汇编语法,支持标签(Label)、对齐定义(.align n)、段定义(.section name)等。

  • 输出/输入操作数列表:用来指定当前内联汇编程序的输出和输入操作符列表。(非必要)。

  • 破坏列表:用于告知编译器当前内联汇编语句可能会对某些寄存器或内存进行修改,使得编译器在优化时将其因素考虑进去(非必要)。

综上,一个典型的完整内联汇编程序格式如下:

__asm__ __volatile__(
"Instruction_l;" 
"Instruction 2;" 
"...;" 
"Instruction_n;"

 : [out1]"=r"(value1), [out2]"=r"(value2), ... [outn]"=r"(valuen) 
 : [in1]"r"(value1), [in2]"r"(value2), ... [inn]"r"(valuen)
 : "r0", "r1", ... "rn" 
);

4.2 输出和输入操作数

格式:

:[out1]"=r"(valuel), [out2]"=r"(value2)...
  • 新段由冒号:开头,若有多个输出用逗号,间隔
  • 方括号[]中的符号名用于将内联汇编程序中使用的操作数。如[out1]“=r”(valuel) 输出是valuel,汇编代码中可以用%[out1]指代。除了 %[str] 中明确的符号命名指定外,还可以使用 %数字 的方式进行隐含指定。"数字"从0开始,依次表示输出操作数和输入操作数。
  • 双引号"=r" 字母r表示使用编译器自动分配的寄存器来存储该操作数变量。
    常见constraints 含义
    • m 内存操作数
    • r 寄存器操作数
    • i 立即数(整数)
    • f 浮点寄存器操作数
    • F 立即数(浮点)

对于输出操作数来说:“=”表示输出变量,用作输出“+”表示输出变量不仅作为输出,也作为输入

  • 圆括号()中的C/C+变量或者表达式。
    注意:"="约束不适用于输入操作数

4.3 破坏列表(Clobber List)

破坏列表是内联汇编与编译器之间的“通信协议”,明确告知编译器哪些寄存器或内存被隐式修改,防止编译器因优化假设导致程序行为异常。它划定了编译器优化的边界,是保证代码正确性的关键。

4.3.1 寄存器的隐式修改:必须显式声明

  1. 核心逻辑
    编译器默认假设所有寄存器未被修改(除非通过输入/输出操作数绑定)。若内联汇编中的指令隐式修改了某些寄存器(例如直接操作 x1、x2 等),但未在Clobber List中声明,编译器可能继续使用这些寄存器的旧值,导致逻辑错误。
  2. 声明规则
    • 仅声明非绑定寄存器:
      若寄存器已通过输入/输出操作数的约束(如 “r” (var))绑定到变量,编译器会自动跟踪其修改,无需重复声明。
    • 强制声明独立寄存器:
      若指令直接操作未绑定的寄存器(如临时寄存器 x1),必须在Clobber List中以字符串形式列出,例如 “x1”, “x2”。

示例:

asm volatile (
    "custom_op %[in], x1 \n"  // 隐式修改x1
    : [out] "=r" (result)     // 输出寄存器由编译器分配
    : [in] "r" (input)        // 输入寄存器由编译器分配
    : "x1"                    // 显式声明x1被破坏
);

4.3.2 内存的不可预测修改:强制声明"memory"

  1. 核心逻辑
    编译器可能将内存值缓存到寄存器中(如循环变量优化)。若内联汇编隐式修改了内存(例如通过DMA、多线程写入),但未声明 “memory”,编译器可能继续使用寄存器中的缓存值,导致数据不一致。
  2. 声明规则
    • 全局内存屏障:
      使用 “memory” 关键字,强制编译器重新加载所有内存数据(无法指定特定地址)。
    • 替代方案:
      若内存修改可通过输入/输出操作数推断(例如使用 “m” (*ptr) 约束),则无需声明 “memory”。

示例:

asm volatile (
    "dma_copy %[src], %[dst] \n"  // 隐式修改内存
    : 
    : [src] "r" (src_addr), [dst] "r" (dst_addr)
    : "memory"  // 强制内存同步
);

4.5 特殊场景处理

  1. 原子操作
    需使用RISC-V原子指令(如amoadd.w),并确保内存地址对齐:
uint32_t atomic_add(uint32_t *ptr, uint32_t value) {
    uint32_t old;
    asm volatile (
        "amoadd.w %[old], %[val], (%[ptr])"
        : [old] "=r" (old)
        : [ptr] "r" (ptr), [val] "r" (value)
        : "memory"
    );
    return old;
}
  1. CSR访问
    使用csrr/csrw指令,注意权限问题:
uint64_t read_cycle() {
    uint64_t cycle;
    asm volatile ("csrr %0, cycle" : "=r" (cycle));
    return cycle;
}
  1. 标签与跳转
    通过%l[name]引用C标签:
asm volatile (
    "beqz %[cond], %l[exit]\n\t"
    "addi %[out], %[in], 1\n\t"
    "%l[exit]:"
    : [out] "=r" (result)
    : [in] "r" (input), [cond] "r" (cond)
);

5 内联汇编实例

5.1 实例一

为了验证代码正确性,我们将基于芯来RISCV qemu来进行一个简单的加法完整实例

#include <stdio.h>
#include "nuclei_sdk_soc.h"  //
int add(int a, int b){
    int result;
    __asm__ __volatile__(
        "add %[result], %[input1], %[input2]"   // 汇编指令:将input1和input2相加的结果存入result
        : [result] "=r" (result)                // 输出操作数:结果将会存储在变量'result'中
        : [input1] "r" (a), [input2] "r" (b)    // 输入操作数:'a','b',
        :                                       // 破坏描述部分为空,因为我们没有修改任何寄存器或内存
    );
    return result;
}
int main(void)
{
    int a = 7;
    int b = 13;
    int c = add(a, b);
    printf("a + b = %d\n",c);
    while(1){}
}
make CORE=nx900fd SOC=evalsoc DOWNLOAD=ilm ARCH_EXT=_xxldsp run_qemu

运行结果:

在这里插入图片描述
通过 输入/输出操作数约束机制,程序员可以将C/C++变量或表达式直接绑定到内联汇编指令的操作数上,而无需关注底层寄存器的具体分配细节。编译器会根据约束规则(如 “r”、“m” 等)和优化策略,自动为这些操作数分配合适的物理寄存器(如 x1、x2 等)。
这一机制的核心优势在于 抽象化硬件细节:
1. 变量与操作数的解耦:程序员只需声明变量与汇编操作数的逻辑关系(例如输入、输出),无需硬编码寄存器。
2. 编译器的智能分配:编译器基于全局寄存器分配算法,选择未被破坏且符合约束的寄存器,提升代码可移植性和性能。
3. 硬件无关性:同一段内联汇编代码可在不同微架构的RISC-V处理器上运行(如不同寄存器数量的核),由编译器动态适配。

汇编思维(需手动管理寄存器) 内联汇编(抽象化寄存器)
asm volatile (“add x1, x2, %[input]” … ); // 需明确知道使用x1和x2寄存器 asm volatile (“add %[output], %[temp], %[input] \n” : [output] “=r” (result) // 编译器自动分配输出寄存器 : [input] “r” (in_val), [temp] “r” (tmp) );

这种设计将程序员从底层硬件绑定中解放,专注于算法与指令的逻辑关系,同时赋予编译器更大的优化自由度。(不用关注ABI规则了)

5.2 实例二

在实例一的基础上,加上读取riscv特权寄存器的读操作的宏函数,简单统计下add函数用了多少个cpu cycle

#include <stdio.h>
#include "riscv_encoding.h"
#include "nuclei_sdk_soc.h"

#define SYS_CSR_READ(CSR_REG) ({                \
    unsigned long _reg_val;                     \
    asm volatile (                              \
        "csrr %0,"#CSR_REG                      \
        : "=r" (_reg_val)                       \
    );                                          \
    _reg_val;                                   \
})

int add(int a, int b){
    int result;
    __asm__ __volatile__(
        "add %[result], %[input1], %[input2]"   // 汇编指令:将input1和input2相加的结果存入result
        : [result] "=r" (result)                // 输出操作数:结果将会存储在变量'result'中
        : [input1] "r" (a), [input2] "r" (b)    // 输入操作数:'a','b',
        :                                       // 破坏描述部分为空,因为我们没有修改任何寄存器或内存
    );
    return result;
}
int main(void)
{
    int a = 7;
    int b = 13;
    unsigned long start = SYS_CSR_READ(mcycle);
    int c = add(a, b);
    printf("cycle of calculate: %ld\n",SYS_CSR_READ(mcycle) - start);
    printf("a + b = %d\n",c);
    while(1){}
}

运行结果:
在这里插入图片描述

SYS_CSR_READ有几点还是需要注意的:

  • #CSR_REG 是C语言的一个特殊语法,会将入参替换为 “mcycle”,从而形成 “csrr %0, mcycle”
  • 按照输出输入操作数的规则,“%0” 指第一个操作数
  • 宏函数返回值 https://blog.csdn.net/weixin_43083491/article/details/121702081?spm=1001.2014.3001.5502

GCC内联汇编的语法体系看似庞杂艰深,但核心使用场景往往只需掌握少量关键规则。本文以"最小必要知识"为原则,提炼最基础的语法框架和实战代码模板,聚焦20%的核心语法(如操作数约束、Clobber List机制)覆盖80%的日常使用场景。另外对于需要定制复杂汇编逻辑的场景,则可以参考GNU官方spec。

参考:
riscv-abi.pdf
GCC-Inline-Assembly-HOWTO
一起学RISC-V汇编第9讲之RISC-V ABI之函数调用
一起学RISC-V汇编第11讲之内嵌汇编