跳转至

C 代码编译

阅读建议

如果你不想了解编译原理,只想获得工具链和一个编译模板以便开发测试代码,可以直接跳到 2. 安装 RISC-V Toolchain。 但我们建议你阅读整个章节,以便更好地理解编译的过程。

1. 编译流程

我们需要知道软件代码如何翻译成 CPU 可运行的指令,这个过程被称为编译(Compilation)。 将 C 程序翻译成计算机可运行的机器语言程序需要四个经典步骤:

foo.c -- Compiler --> foo.s -- Assembler --> foo.o -- Linker --> a.out -- Loader --> CPU

Note

这些步骤是概念上的,实际上会合并某些步骤来加速翻译过程。

1.1 Compiler & Assembler

编译器负责将高级语言转换成汇编,汇编器负责将汇编转换成机器码。 汇编器的作用不仅是用处理器可理解的指令生成目标代码,还支持一些对汇编语言程序员或编译器开发者有用的操作。 这类操作是常规指令的巧妙特例,称为伪指令。 最经典的例子为 nop,它在 RISC-V 中由 addi x0, x0, 0 实现。

Note

在程序员视角下,32 个 GPR 有不同于 x0 ~ x31 的名称,这被称为 ABI (Application Binary Interface)。

下面是一段汇编

    .text                   # 指示符:进入代码节
    .align 2                # 指示符:将代码按 2^2 字节对齐
    .globl main             # 指示符:声明全局符号 main
    main:                   # main 的开始符号
    addi sp,sp,-16          # 分配栈帧
    sw ra,12(sp)            # 保存返回地址
    lui a0,%hi(string1)     # 计算 string1
    addi a0,a0,%lo(string1) # 的地址
    lui a1,%hi(string2)     # 计算 string2
    addi a1,a1,%lo(string2) # 的地址
    call printf             # 调用 printf 函数
    lw ra,12(sp)            # 恢复返回地址
    addi sp,sp,16           # 释放栈帧
    li a0,0                 # 装入返回值 0
    ret                     # 返回
    .section .rodata        # 指示符:进入只读数据节
    .balign 4               # 指示符:将数据按 4 字节对齐
    string1:                # 第一个字符串符号
    .string "Hello, %s!\n"  # 指示符:以空字符结尾的字符串
    string2:                # 第二个字符串符号
    .string "world"         # 指示符:以空字符结尾的字符串

以英文句号开头的命令称为汇编器指示符(Assembler directives)。 这些命令作用于汇编器,而非由其翻译的代码,具体用于通知汇编器在何处放置代码和数据、指定程序中使用的代码和数据常量等。

Note

汇编器生成的文件为 ELF(Executable and Linkable Format,可执行可链接格式)[TIS Committee 1995] 标准格式目标文件。

1.2 Linker

链接器允许分别编译和汇编各文件,故只改动一个文件时无需重新编译所有源代码。 链接器把新目标代码和已有机器语言模块(如函数库)“拼接” 起来,即编辑目标文件中所有 “跳转并链接(jal)” 指令的链接目标。 例如上述汇编有两个数据符号(string1string2)和两个代码符号(mainprintf)待确定。

根据链接的形式,可以将链接结果分为静态(static linking)和动态(dynamic linking)两种。 前者在程序运行前链接并加载所有库的代码,后者首次调用所需外部函数时才会将其加载并链接到程序中。

在编译和链接程序的过程中,通常会链接标准库和启动文件。 标准库(Standard Library)包含了许多常用的函数,例如输入输出函数、字符串处理函数等。 大多数程序都会使用到标准库中的函数,因此在链接阶段,编译器会将这些函数的代码链接到生成的可执行文件中。

启动文件(Start Files)是一些特殊的对象文件,它们包含了程序启动时需要执行的一些初始化代码。 例如,C 程序的入口点实际上是一个名为 start 或 _start 的函数,这个函数在启动文件中定义,它会设置好运行环境后再调用 main 函数。 具体的启动文件取决于你的编译器和操作系统。 例如,在使用 GCC 编译器的 Linux 系统中,启动文件通常是 crt1.ocrti.ocrtbegin.ocrtend.ocrtn.o。 这些文件中的代码会设置堆栈,初始化全局变量,调用全局构造函数,等等。

Note

当编译器选项中包含 -nostdlib-nostartfiles 时,表示在链接阶段不链接标准库和启动文件。 这通常在编写操作系统或嵌入式系统的代码时使用,因为这些系统可能没有标准库,或者需要自定义启动过程。 需要注意的是,-nostdlib 选项不仅会禁止链接 C 标准库,还会禁止链接启动文件和 GCC 的运行时库。 如果你只想禁止链接 C 标准库,但仍然需要链接启动文件和 GCC 的运行时库,你可以使用 -nodefaultlibs 选项。

对象文件(.o 文件)是编译器生成的中间文件,它包含了源代码编译后的机器代码,但还没有被链接成可以执行的程序。 这些文件通常包含二进制数据,以及一些元数据,如符号表、重定位信息等。符号表中记录了源代码中的函数和变量的名称(符号)以及它们在机器代码中的位置。 重定位信息用于在链接阶段确定符号的最终地址。

Note

你可以使用一些工具来查看对象文件的内容。 例如,你可以使用 objdump 工具来反汇编对象文件,查看它的汇编代码。你也可以使用 nm 工具来查看对象文件中的符号表。 查看反汇编代码: objdump -d foo.o; 查看符号表: objdump -t your_file.o; 查看重定位信息:objdump -r your_file.o

1.3 Loader

运行一个程序时,加载器会将其加载到内存中,并跳转到它的起始地址。 可执行文件可以接收命令行参数。这些参数在程序启动时通过 main 函数的参数传递给程序。 main 函数的原型为 int main(argc, *argv[])

其中,argc 是命令行参数的数量,argv 是一个指向字符指针数组的指针,该数组包含了所有的命令行参数。 argv[0] 是程序的名称,argv[1] 是第一个命令行参数,以此类推。 最后一个元素 argv[argc] 是一个空指针。

例如,如果你的程序名为 prog,并且你通过以下方式启动它:./prog arg1 arg2,那么 argc 将为 3,argv[0] 将为 ./prog,argv[1] 将为 arg1,argv[2] 将为 arg2。

Info

如今的 “加载器” 就是操作系统。

Note

在进行交叉编译时,你的主机上的库(包括 C 标准库)通常不能直接用于目标系统。 这是因为主机和目标系统可能有不同的架构(例如,主机可能是 x86,而目标系统是 RISC-V),并且它们可能有不同的操作系统接口(例如,主机可能是 Linux,而目标系统是 bare-metal)。

因此,当你在 bare-metal RISC-V 环境中编译程序时,你需要一个为 RISC-V 架构和 bare-metal 环境定制的 C 库。 这个库应该包含适合你的目标环境的函数实现,包括 exit 函数。

如果你的程序使用了 C 库中的 exit 函数,但你没有提供一个适合你的目标环境的 exit 函数实现,那么在链接阶段,链接器会报错,因为它找不到 exit 函数的定义。

Tip

你可以查询 RISC-V Assembly Programmer's Manual 来了解如何编写 RISC-V 汇编语言。

2. 安装 RISC-V Toolchain

Warning

需要在 WSL 环境中安装! WSL 的安装以及网络配置请参考 WSL

从 GitHub 上下载 RISC-V 工具链源码并进入该目录:

git clone https://github.com/Siris-Li/RISC-V-GCC-TOOLCHAIN
cd RISC-V-GCC-TOOLCHAIN

安装依赖:

sudo apt-get install autoconf automake autotools-dev curl git libmpc-dev libmpfr-dev libgmp-dev gawk build-essential bison flex texinfo gperf libtool bc zlib1g-dev

(可选)配置 CPU 核数以便多线程编译:

export NUM_JOBS=<num of processors>
如何查看 CPU 核数

WSL:

在 Terminal 中输入:

nproc
Windows:

打开任务管理器,左栏中选择“性能”选项卡,右下角即可看到 CPU 核数(逻辑处理器)。

Windows 查看逻辑处理器个数

逻辑处理器与内核的区别

  • 内核:CPU 的物理核心数
  • 逻辑处理器:CPU 的线程数,例如单个物理 CPU 内核支持双线程,则 2 个这样内核的逻辑处理器数为 4。

配置工具链安装路径:

export RISCV=<install path>

执行脚本安装工具链:

# Fetch the source code of the toolchain (assumes Internet access.)
sh get-toolchain.sh

# Build and install the toolchain (requires write+create permissions for $RISCV.)
sh build-toolchain.sh $RISCV

安装完成后,$RISCV/bin 目录下会有 RISC-V 工具链的可执行文件(例如 riscv-none-elf-gccriscv-none-elf-gdb)。

3. 编译模板

从 GitHub 上下载一个简单的 C 代码模板,用于编译 RISC-V 汇编代码。

git clone https://github.com/Siris-Li/C_compile_template
cd C_compile_template

设置环境变量,指定 RISC-V 工具链路径。

export RISCV_GCC=<riscv-gcc-toolchain>/bin/riscv-none-elf-gcc
export RISCV_OBJDUMP=<riscv-gcc-toolchain>/bin/riscv-none-elf-objdump

修改 C 文件,按需修改汇编代码。 然后执行 make 编译。

Makefile 会生成如下两个文件:

  • *.o:编译后的目标文件,用于 GDB 的 load 指令。
  • *.asmm:反汇编后的文件,用于查看编译结果。
  • *.hex:使用 asm2hex.py 脚本生成的十六进制内存初始化文件,用于仿真。
关于反汇编结果

如果你查看过反汇编结果,你会发现反汇编后的代码和源代码有些不同。 具体来说,反汇编后的代码的开头和结尾会有一些额外的指令,这些指令都与 sp 寄存器相关。

sp 寄存器是栈指针寄存器,用于指向当前栈顶的地址。 C 代码中的函数调用会使用栈来保存函数的返回地址、参数和局部变量。 因此,函数的编译结果包括三个部分:函数的栈分配、函数的代码、函数的栈释放。

关于静态数组与动态数组

在嵌入式 RISC-V 平台上编译 C 程序时,数组大小的不同可能会导致编译器生成不同的代码,进而影响程序的行为。 特别是当数组的大小超过一定阈值时,编译器可能会选择使用库函数(如 memcpy)来初始化或复制数组。

嵌入式环境中通常不使用标准 C 库(如 libc),因为这些库可能依赖于操作系统提供的功能。 在使用 -nostdlib 选项时,你的程序不会链接标准 C 库,因此如果编译器自动生成了对 memcpy 的调用,而你没有提供 memcpy 的实现,就会导致链接错误

为了避免这种情况,我们可以使用静态数组(static),这样编译器就会在编译时将数组的大小固定下来,而不会使用库函数。 编译器会将数组的内容直接放入 .data 段(如果是可修改的)或 .rodata 段(如果是只读的)。

与静态数组相对应的是动态数组(dynamic),它的大小是在运行时确定的,因此编译器无法在编译时将数组的大小固定下来,通常使用动态内存分配函数(如 malloc)来在堆(heap)中分配内存。

关于.rodata字段

如果将 .rodata 段放置在 RAM 中,并且没有任何硬件或软件保护措施,那么这些数据实际上是可修改的,不再具有只读属性。

保持 .rodata 只读属性:

  • 确保 .rodata 段放在只读存储器中(如 Flash/ROM)。
  • 如果必须放在 RAM 中,使用 MMU 或类似的机制来施加只读保护。
  • 在没有硬件支持的嵌入式系统中,依赖编程约定来保证 .rodata 不被修改。

4. 数据加载到指定地址

在测试流程中,我们可能需要将数据加载到特定的内存地址,例如指令基地址为 0x8000_0000,时钟配置寄存器基地址为 0x2000_0000 等。 最直观、简单的加载数据方式为:在 C 代码中显式地定义地址指针,并对该地址进行赋值。

uint64_t *ptr = (uint64_t *)0x80000000; // 指定地址
*ptr = 0x123456789ABCDEF0;              // 将数据写入指定地址

上述方法会导致数据被存储了两份:一份在编译出来的目标文件中,另一份在运行时被写入到指定地址。 如果我们只想存储一份数据,可以定义一个静态数组,并在编译时通过链接器脚本 linker.ld 将其放置到指定地址。

为了实现上述效果,我们需要在 linker.ld 中定义一个段(section),并将该段放置到指定地址;在 C 代码中引用此段,在该段中定义静态数组,示例如下。

// C 代码:定义静态数组并放置到指定段
__attribute__((section(".custom_data")))
static const uint64_t npu_patterns[] = {
    0x1234567890abcdef,
    0xfedcba0987654321,
    0xaaaaaaaaaaaaaaaa,
    0x5555555555555555,
    0x0011223344556677,
    0x8899aabbccddeeff,
    0xdeadbeefdeadbeef,
    0xfeedfacefeedface
};
// linker.ld:定义自定义段并放置到指定地址
SECTIONS
{
    ... // 其他段定义

    /* 定义自定义段 */
    . = 0x30000000;
    .custom_data : {
        *(.custom_data)
        *(.custom_data.*)
    }
}
Page Authors: Siris-Li (95.47%), Bwoah-Kimi (4.53%)