跳转至

Lab1 裸机环境初探

凡贵通者,贵其能用之也。 ——《徵调曲》

在本次实验中,我们将会了解本课程中使用的实验工具与代码框架。本次实验的主要内容包括:

  • 使用 riscv-unknown-linux-gnu 交叉编译系列工具对程序进行编译、汇编、链接、反汇编,了解程序基本的结构和执行方式;
  • 使用 Makefile 和裸机库环境搭建 RISC-V 裸机程序的开发环境,并能够在该环境中搭建完全独立的程序。

1 交叉编译工具的使用

请获取 Lab1 所需的代码框架

如果你还没有下载代码框架,可以使用如下命令获取代码框架:

shell
$ git clone https://github.com/USTC-System-Courses/CECS-Lab.git
请将工作目录切换到 Lab1,运行以下命令:

shell
$ ./zinit.sh
完成这一步需要完整的 riscv64-unknown-linux-gnu 交叉编译工具链

请参考实验环境搭建中的内容安装 riscv64-unknown-linux-gnu 系列工具。

注意不要使用 APT 来安装。

riscv64-unknown-linux-gnu 交叉编译工具链是基于 GCC 的跨平台编译工具,可以在 x86 平台上将 C/C++ 代码编译成 RISC-V 指令集的汇编码、机器码,并做出相应的程序分析。

通常,将 C/C++ 代码编译成可执行文件的过程包括预处理、编译、汇编和链接四步,各步骤的功能如下图所示:

在实验的这一部分中,我们将会使用 riscv-unknown-linux-gnu 交叉编译工具链来编译、汇编、链接一个简单的 C 语言程序,并使用反汇编程序查看目标文件内容,以熟悉交叉编译工具链的用法,了解程序的基本结构和执行方式。

1.1 编译

在 software/mytest 下,存放有一个冒泡排序 C 程序。该程序没有显式调用外部库,因此没有输入输出。

为何强调“显式调用外部库”?

事实上,即使不主动使用任何外部库函数,也不代表程序不需要链接任何库。在本课程开发过程中曾遇到过的一个问题就是很好的反例:

在指定架构为 rv32i(即基本的 RISC-V 整型指令集,没有乘/除法扩展)时编译带除法运算的 C 程序时,有时会出现找不到 libdiv 的链接错误。 也就是说,尽管没有调用任何库,在某些情况下编译器也会链接到外部的库函数。

事实上,这也是我们要求手动编译安装特定版本 riscv64-unknown-linux-gnu 编译工具链的主要原因。

可以使用以下命令将该程序编译为 RISC-V 汇编代码:

shell
$ riscv64-unknown-linux-gnu-gcc -S -march=rv32g -mabi=ilp32 -o test.s test.c

其中的编译选项如下:

  • -S 表示只输出汇编代码;
  • -march=rv32g 指定了使用带全部扩展的 RISC-V 32 位整型指令集(以后简称为 RV32M 指令集)进行编译;
  • -mabi=ilp32 指定了数据模型:整型(i)、长整型(l)和指针(p)均为 32 位,这是与前面的指令集相适应的数据模型;
  • -o 指定了输出文件名为其后面的 test.s。
Task 1.1

请将 software/mytest 中的 test.c 编译为 RV32M 指令集的汇编代码,并将结果保存为 test.s。

指令集和数据模型编译选项

我们在本课程中是使用 64 位指令集的编译器编译生成 32 位指令集的目标程序,因此需要使用 -march=rv32g-mabi=ilp32 来指定指令集和数据模型。

如果要生成 64 位指令集的目标程序,在本编译器上通常不需要指定这两个选项,或使用 -march=rv64g-mabi=lp64

.s vs .S

两者主要的区别为,扩展名为 .S 的汇编文件支持预处理,而扩展名为 .s 的汇编文件不支持。

一般地,由人工编写的汇编程序使用 .S 作为后缀,而由编译器或反汇编器生成的汇编程序使用 .s 作为后缀。

1.2 汇编

riscv-unknown-linux-gnu 工具链提供了汇编工具,可以将汇编代码编译为目标文件(object)。使用如下命令:

shell
$ riscv64-unknown-linux-gnu-as -march=rv32g -mabi=ilp32 -o test.o test.s

汇编器(Assembler, AS)将汇编文件翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在目标文件中。 输出的目标文件是一个二进制文件,可以直接被链接器使用,与其他目标文件、库文件一起链接成可执行文件。

跳过手动汇编

如果你并不关心汇编代码,可以使用 GCC 将 C 程序直接编译为目标文件:

shell
$ riscv64-unknown-linux-gnu-gcc -c -march=rv32g -mabi=ilp32 -o test.o test.c

其中 -c 选项表示只编译不链接。

该指令同时也适用于将汇编文件编译为目标文件。

Task 1.2

请在 software/mytest 中将 test.s 编译为目标文件,并将结果保存为 test.o。

AS:我分不清 A 和 a

汇编器并不区分大小写,所以如果在汇编文件中使用其他文件的宏定义,很容易出现“符号未定义”错误。但如果我们把 as 换成 gcc -c,就可以解决这个问题。因此,在本课程后续实验中,我们一般直接使用 gcc -c 来编译汇编文件。

1.3 链接

riscv-unknown-linux-gnu 提供了链接器,可以将多个目标文件链接为可执行文件。例如,如下命令将 test.o 链接为 test:

shell
$ riscv64-unknown-linux-gnu-ld -m elf32lriscv -Ttext 0x80000000 -o test test.o

其中的链接选项如下:

  • -m elf32lriscv 指定了目标可执行文件格式为 32 位小端对齐(l)的 Risc-V 可执行可链接格式(elf);
  • -Ttext 0x80000000 选项指定了程序的入口地址为 0x80000000。

令人难以接受的是,这里产生了一个警告:

shell
riscv64-unknown-linux-gnu-ld: warning: cannot find entry symbol _start; defaulting to 80000000

这是因为 GCC 默认程序要从 _start 开始执行,但是我们程序只有 main 函数。这时候,我们可以通过反汇编看看到底发生了什么:

shell
$ riscv64-unknown-linux-gnu-objdump -d test > test.txt
输入/输出重定向

通常,使用 riscv64-unknown-linux-gnu-objdump 读取二进制文件时,输出会直接显示到命令行中。这是因为在命令执行时打开了 stdout 作为输出文件,对应将输出写到命令行中。

在上面的命令中使用了 > test.txt 将输出重定向到了 test.txt 中。 这样,命令执行时,就会打开 test.txt 作为输出文件,将内容写到此文件中。 我们也可以使用 >> 重定向输出,两者的区别在于,> 是截断写,而 >> 是追加写。

类似地,我们也可以使用 < 重定向输入。更多高级用法可以自行搜索。

我们发现,程序的入口竟然没有在 main 函数,而是在 bubble-sort!这显然已经违背了我们平时对 C 程序的认知。

发生这种情况的原因是,我们并没有链接 GCC 在链接时的默认链接库,这些库十分冗长,并不适合我们在链接器中直接指定。 所幸,我们可以直接使用 riscv64-unknown-linux-gnu-gcc 指令来链接程序,GCC 会自动地链接某些库:

shell
$ riscv64-unknown-linux-gnu-gcc -march=rv32g -mabi=ilp32 -o test test.o

再次使用 riscv64-unknown-linux-gnu-objdump 反汇编,我们发现程序的入口已经变成了 _start,而 main 函数是在 _start 中被调用的一个函数。 这是因为 GCC 链接时会自动链接一些库,其中就包括了启动库,这个库中包含了 _start 函数。

事实上,任何程序的起始点都是 _start 函数,main 只是显式执行的第一个函数。main 函数的返回值会视为是否成功执行的标志,决定退出时 exit 函数的系统调用行为。

Task 1.3
  • 请在 software/mytest 中使用 riscv64-unknown-linux-gnu-gcc 将 test.o 链接为可执行文件,并将结果保存为 test。
  • 请使用 riscv64-unknown-linux-gnu-objdump 反汇编 test,并将结果保存到 test.txt 中。

2 裸机程序开发环境的搭建

了解 Makefile

如果想要顺利完成本节的实验,需要了解 Makefile 的基本语法和用法。

如果你对 Makefile 并不熟悉,请参考 Makefile 教程

通过上一部分的实验我们知道,在将目标文件链接成可执行文件时,需要有一些底层支持的基本库。 这些库以二进制的形式存储在实验平台上,我们无法定制,这会给我们的程序开发带来很大的不便。

因此,我们需要搭建一个裸机程序开发环境,使得我们可以在不依赖任何库的情况下开发程序。

2.1 实验裸机环境解读

裸机环境是指约定的一套应用程序接口,它规定了程序起始地址、内存布局、内存地址映射、基本底层库等内容。 一个裸机环境为高级语言应用程序在无操作系统的情况下编译、运行在指定硬件架构上提供了基本的支持。 裸机环境系统的结构大体如下:

image-20230815134633435

用户代码通过与裸机环境中的库函数链接,构成了完整的可以在特定硬件架构上运行的应用程序。

实验框架的裸机环境位于 software/base-port 目录下,涉及的结构如下:

shell
./
├── Makefile
├── base
   ├── Makefile
   ├── include
      ├── arch.h
      ├── base-macro.h
      ├── base.h
      ├── dev-mmio.h
      └── dev.h
   └── src
       ├── disk.c
       ├── gpu.c
       ├── ioe.c
       ├── kbd.c
       ├── rtc.c
       ├── start.S
       └── trm.c
├── script
   ├── base.mk
   ├── linker.ld
   └── riscv32.mk
└── tool
    ├── Makefile
    ├── include
       └── tool.h
    └── src
        ├── int64.c
        ├── stdio.c
        └── string.c
  • base:裸机环境的基本支持,包括启动代码(其中包括了 _start )、MMIO 底层实现、程序中止函数等;
  • tool:裸机环境的工具支持,包括 printf 函数、字符串操作函数、整数运算函数等;
  • script:裸机环境的脚本支持,包括链接脚本、编译脚本等。其中有三个文件:
    • riscv32.mk:指定了交叉编译器和编译选项;
    • base.mk:编译裸机环境,使其成为可链接库;
    • linker.ld:链接脚本,指定了程序的入口地址和内存布局。

在这个裸机环境中,base-port/Makefile 是最为重要的一个脚本,它规定了所有使用该裸机环境的程序的编译规则和库依赖。 如果想要利用这个环境进行编译,只需要提供待编译的 C 和汇编程序的文件名与路径以及 script 文件夹相对工作目录的路径,就可以使用 include 命令包含这个 Makefile 来编译出符合该裸机环境约定的可执行文件。

使用裸机环境的好处就是,我们可以完全掌握生成代码的所有细节,而不会在链接步骤中链接我们并不知晓的库文件。 这非常有利于我们生成能在自己的硬件架构上运行的程序。

2.2 裸机环境的编译

在 base-port/Makefile 中,我们提供了将汇编文件编译为目标文件的规则。 类似地,可以很容易地给出将 C 程序编译为目标文件的规则。

Task 2.1

请参照汇编代码编译的规则,为 base-port/Makefile 添加将 C 程序直接编译为目标文件(而不是先编译为汇编文件再汇编为目标文件)的规则。注意不要在变量所赋值后面加任何的空格。

请注意,你需要在 base-port/Makefile 中添加一个新的目标,而不是修改已有的目标。

此 Makefile 的最终目的是将提供的库和用户代码都汇总起来,编译为一个 elf 文件(在 Makefile 中表示为 $(IMAGE).elf)。

然而,此 Makefile 中并没有 NAMES, SRCS, BASE_PORT 的定义。 若需要使用此 Makefile,就必须要在定义好这三个变量的 Makefile 中使用 include 命令包含此 Makefile,或在 make 命令中指定它们的值。 这三个变量的含义如下:

  • NAMES:去掉路径和扩展名的所有源文件名。例如,src/test.c 应该被截断为 test;
  • SRCS:去掉扩展名后的所有源文件名。例如,src/test.c 应该被截断为 src/test;
  • BASE_PORT:base_port 目录相对于工作目录的路径。
NAMES 是冗余属性?

从信息的角度看,NAMES 的信息确实包含于 SRC。 之所以设置 NAMES 变量,是因为我们设想在使用 make 命令时选择只编译部分指定文件,这时输入带路径的文件名显得过于繁琐。

在设置 NAMES 变量后,我们可以用如下命令来指定要编译的文件:

shell
$ make NAMES=<pure-filename>

这种设计给予了编译一定的自由度,能够更方便进行调试。

Task 2.2
  1. 为了方便调试,请在 base-port/Makefile 中 $(IMAGE).elf 目标的构建规则中的链接操作之后,添加一条反汇编命令。这条反汇编命令需要将反汇编结果保存为 $(IMAGE).txt

  2. 请在 software/functest 中创建一个 Makefile,提供 NAMES, SRCS, BASE_PORT 的定义,并且使用 include 命令包含 base-port/Makefile。完成之后请通过以下命令来逐个把 functest 中的程序编译为二进制文件:

    shell
    $ make NAMES=<pure-filename>
    

    你需要将 <pure-filename> 替换为 functest 中的程序名。

Advanced Task

functest 中一共有 30 多个程序,依次手动编译是很费神的。 请改写 functest 中的 Makefile,使其可以一次编译所有的程序。

一种可选的实现思路是,制定一个生成规则,批量生成若干 Makefile 脚本,每个脚本编译一个程序。 生成的脚本事实上也只需要提供 NAMES, SRCS, BASE_PORT 三个变量,并包含一条 include 命令即可。 之后调用生成的脚本进行编译,编译完成后将其删除。 可以参照如下模板:

Makefile
NAMES ?= <pure-filename> # 可以使用 wildcard 搭配其他函数来获取所有的程序名,使用方法请查阅 Makefile 教程

.PHONY: all
all: $(NAMES)

%: src/%.c
    @echo <content> > $@.mk # 你需要将 <content> 替换为往每个 Makefile 中添加的内容
    @make -s -f $@.mk
    @rm -rf $@.mk

另一种可选的实现思路是,对于每一个 C 程序,直接在规则中使用命令行接口传递 NAMES, SRCS, BASE_PORT 三个变量,使用 base-port/Makefile 进行编译。 这种思路的模板如下:

Makefile
NAMES ?= <pure-filename> # 可以使用 wildcard 搭配其他函数来获取所有的程序名,使用方法请查阅 Makefile 教程

.PHONY: all
all: $(NAMES)

%: src/%.c
    @NAMES=<names> SRCS=<srcs> BASE_PORT=<base-port> make -s -f ../base-port/Makefile # 你需要将尖括号包裹的参数替换为合适的值

在正确地修改完任意一个模板后,你的 Makefile 应当可以实现使用 make 指令一键编译所有程序。 此外,使用 NAMES=<pure-filename> make 指令可以指定编译某一个或若干个程序。

为了能够实现清除编译结果的功能,你还需要创建clean和clean-all规则,分别用来删除functest/build以及functest/build和base-port目录下所有build文件夹。具体实现请参照base-port/Makefile。

3 实验报告

在本次实验报告中,你需要回答以下问题:

  1. 请比较汇编文件和反汇编文件,说明它们的区别(言之有理即可);
  2. 请阅读 software/base-port/Makefile,找到 LIBS 变量的定义,并据此写出编译程序时链接了哪些库;
  3. software/base-port/Makefile 中也是用了 riscv64-unknown-linux-gnu-ld 工具来完成最后的链接,为什么没有报出找不到 _start 的警告?
  4. 你对本次实验有什么意见或建议?

4 后记

当你因为 Debug 无果而开始查看不属于自己的 Makefile 时,你是否感受过深深的无力?

Makefile,一个让人爱恨交织的工具。它为很多工程提供了非常便捷的解决方案,但又很少被人所重视。大家都希望用一个写好的 Makefile 直接来构建工程并验证设计,但那并不是系统设计的真谛。一个真正能为自己所用的调试系统,必须要做到“如何编写、如何构建、如何验证”都要精通,而不是简单地使用一个 Makefile 来完成所有的工作。因此,能理解、能编写 Makefile 是计算机底层系统开发的重要一环。

很多时候我们会发现,处理器系统开发难点并不在于如何进行有效开发,而是在于如何进行快速验证。为了根据自己的问题进行定制调试,熟练的工程构建能力是非常重要的。后续的实验中并不会再大量接触 Makefile,但如果要构建一个真正属于自己的计算系统,就需要我们在关键的时候能够根据需求构建系统。因此,我们希望大家能够在本次实验中掌握 Makefile 的基本用法,为后续的实验打下基础。