Lab1 裸机环境初探
凡贵通者,贵其能用之也。 ——《徵调曲》
在本次实验中,我们将会了解本课程中使用的实验工具与代码框架。本次实验的主要内容包括:
- 使用 riscv-unknown-linux-gnu 交叉编译系列工具对程序进行编译、汇编、链接、反汇编,了解程序基本的结构和执行方式;
- 使用 Makefile 和裸机库环境搭建 RISC-V 裸机程序的开发环境,并能够在该环境中搭建完全独立的程序。
1 交叉编译工具的使用
请获取 Lab1 所需的代码框架
如果你还没有下载代码框架,可以使用如下命令获取代码框架:
请将工作目录切换到 Lab1,运行以下命令:完成这一步需要完整的 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 汇编代码:
其中的编译选项如下:
-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)。使用如下命令:
汇编器(Assembler, AS)将汇编文件翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在目标文件中。 输出的目标文件是一个二进制文件,可以直接被链接器使用,与其他目标文件、库文件一起链接成可执行文件。
跳过手动汇编
如果你并不关心汇编代码,可以使用 GCC 将 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:
其中的链接选项如下:
-m elf32lriscv
指定了目标可执行文件格式为 32 位小端对齐(l)的 Risc-V 可执行可链接格式(elf);-Ttext 0x80000000
选项指定了程序的入口地址为 0x80000000。
令人难以接受的是,这里产生了一个警告:
这是因为 GCC 默认程序要从 _start 开始执行,但是我们程序只有 main 函数。这时候,我们可以通过反汇编看看到底发生了什么:
输入/输出重定向
通常,使用 riscv64-unknown-linux-gnu-objdump
读取二进制文件时,输出会直接显示到命令行中。这是因为在命令执行时打开了 stdout 作为输出文件,对应将输出写到命令行中。
在上面的命令中使用了 > test.txt
将输出重定向到了 test.txt 中。
这样,命令执行时,就会打开 test.txt 作为输出文件,将内容写到此文件中。
我们也可以使用 >>
重定向输出,两者的区别在于,>
是截断写,而 >>
是追加写。
类似地,我们也可以使用 <
重定向输入。更多高级用法可以自行搜索。
我们发现,程序的入口竟然没有在 main 函数,而是在 bubble-sort!这显然已经违背了我们平时对 C 程序的认知。
发生这种情况的原因是,我们并没有链接 GCC 在链接时的默认链接库,这些库十分冗长,并不适合我们在链接器中直接指定。
所幸,我们可以直接使用 riscv64-unknown-linux-gnu-gcc
指令来链接程序,GCC 会自动地链接某些库:
再次使用 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 裸机程序开发环境的搭建
通过上一部分的实验我们知道,在将目标文件链接成可执行文件时,需要有一些底层支持的基本库。 这些库以二进制的形式存储在实验平台上,我们无法定制,这会给我们的程序开发带来很大的不便。
因此,我们需要搭建一个裸机程序开发环境,使得我们可以在不依赖任何库的情况下开发程序。
2.1 实验裸机环境解读
裸机环境是指约定的一套应用程序接口,它规定了程序起始地址、内存布局、内存地址映射、基本底层库等内容。 一个裸机环境为高级语言应用程序在无操作系统的情况下编译、运行在指定硬件架构上提供了基本的支持。 裸机环境系统的结构大体如下:
用户代码通过与裸机环境中的库函数链接,构成了完整的可以在特定硬件架构上运行的应用程序。
实验框架的裸机环境位于 software/base-port 目录下,涉及的结构如下:
./
├── 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 变量后,我们可以用如下命令来指定要编译的文件:
这种设计给予了编译一定的自由度,能够更方便进行调试。
Task 2.2
-
为了方便调试,请在 base-port/Makefile 中
$(IMAGE).elf
目标的构建规则中的链接操作之后,添加一条反汇编命令。这条反汇编命令需要将反汇编结果保存为$(IMAGE).txt
。 -
请在 software/functest 中创建一个 Makefile,提供 NAMES, SRCS, BASE_PORT 的定义,并且使用 include 命令包含 base-port/Makefile。完成之后请通过以下命令来逐个把 functest 中的程序编译为二进制文件:
你需要将
<pure-filename>
替换为 functest 中的程序名。
Advanced Task
functest 中一共有 30 多个程序,依次手动编译是很费神的。 请改写 functest 中的 Makefile,使其可以一次编译所有的程序。
一种可选的实现思路是,制定一个生成规则,批量生成若干 Makefile 脚本,每个脚本编译一个程序。 生成的脚本事实上也只需要提供 NAMES, SRCS, BASE_PORT 三个变量,并包含一条 include 命令即可。 之后调用生成的脚本进行编译,编译完成后将其删除。 可以参照如下模板:
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 进行编译。 这种思路的模板如下:
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 实验报告
在本次实验报告中,你需要回答以下问题:
- 请比较汇编文件和反汇编文件,说明它们的区别(言之有理即可);
- 请阅读 software/base-port/Makefile,找到 LIBS 变量的定义,并据此写出编译程序时链接了哪些库;
- software/base-port/Makefile 中也是用了
riscv64-unknown-linux-gnu-ld
工具来完成最后的链接,为什么没有报出找不到 _start 的警告? - 你对本次实验有什么意见或建议?
4 后记
当你因为 Debug 无果而开始查看不属于自己的 Makefile 时,你是否感受过深深的无力?
Makefile,一个让人爱恨交织的工具。它为很多工程提供了非常便捷的解决方案,但又很少被人所重视。大家都希望用一个写好的 Makefile 直接来构建工程并验证设计,但那并不是系统设计的真谛。一个真正能为自己所用的调试系统,必须要做到“如何编写、如何构建、如何验证”都要精通,而不是简单地使用一个 Makefile 来完成所有的工作。因此,能理解、能编写 Makefile 是计算机底层系统开发的重要一环。
很多时候我们会发现,处理器系统开发难点并不在于如何进行有效开发,而是在于如何进行快速验证。为了根据自己的问题进行定制调试,熟练的工程构建能力是非常重要的。后续的实验中并不会再大量接触 Makefile,但如果要构建一个真正属于自己的计算系统,就需要我们在关键的时候能够根据需求构建系统。因此,我们希望大家能够在本次实验中掌握 Makefile 的基本用法,为后续的实验打下基础。