一生一芯学习:程序,运行时环境与AM(一)

目前我们已经跑通了cpu-test和实现了riscv-I型指令所需的42条指令,现在我们已经可以到跑简单程序的地步了,我们也希望运行简单的程序,因此我们需要运行时环境(runtime environment)。

比如现在要结束程序,那我们就要用提前准备好的API如void halt,调用这个halt()就可以来结束运行,那我们怎么实现这个halt呢,来看看源码。

复制代码
void halt(int code) {
  nemu_trap(code);
  // should not reach here
  while (1);
}

nemu_trap(code)是什么意思呢,在/ysyx-workbench/abstract-machine/am/src/platform/nemu/include中有这样一段代码,判断所使用的架构并且给出对应架构退出的内联汇编。

复制代码
#if defined(__ISA_X86__)
# define nemu_trap(code) asm volatile ("int3" : :"a"(code))
#elif defined(__ISA_MIPS32__)
# define nemu_trap(code) asm volatile ("move $v0, %0; sdbbp" : :"r"(code))
#elif defined(__riscv)
# define nemu_trap(code) asm volatile("mv a0, %0; ebreak" : :"r"(code))
#elif defined(__ISA_LOONGARCH32R__)
# define nemu_trap(code) asm volatile("move $a0, %0; break 0" : :"r"(code))
#else
# error unsupported ISA __ISA__
#endif

随便找一个程序来看反汇编,用户程序会以这种形式成为机器码。

复制代码
80000fdc <halt>:

void halt(int code) {
  nemu_trap(code);
80000fdc:	00050513          	mv	a0,a0
80000fe0:	00100073          	ebreak
  //里面是一个嵌入汇编语句,宏会把一个识别结束的结束码移动到通用寄存器中
  // should not reach here
  while (1);
80000fe4:	0000006f          	j	80000fe4 <halt+0x8>

80000fe8 <_trm_init>:
}

正好对应上了。

此时我很好奇# define nemu_trap(code) asm volatile("mv a0, %0; ebreak" : :"r"(code))"r"(code)到底指的是什么。

首先明确说明这是一个内联汇编,具体看https://luyoung0001.github.io/2025/02/06/C语言内联汇编/

指的是操作数和约束,r意思是"通用寄存器",编译器会为操作数分配一个合适的通用寄存器 (例如 x1 - x31)。

在汇编指令模板中,使用 %0, %1, %2... 来引用操作数。

%0 对应第一个操作数,%1 对应第二个操作数,以此类推。

编译生成一个可以在NEMU的运行时环境上运行的程序的过程大致如下:

gcc将ISA-nemu的AM实现源文件编译成目标文件, 然后通过ar将这些目标文件作为一个库, 打包成一个归档文件abstract-machine/am/build/am-ISA-nemu.a

gcc把应用程序源文件(如am-kernels/tests/cpu-tests/tests/dummy.c)编译成目标文件

通过gcc和ar把程序依赖的运行库(如abstract-machine/klib/)也编译并打包成归档文件

根据Makefile文件abstract-machine/scripts/$ISA-nemu.mk中的指示, 让ld根据链接脚本abstract-machine/scripts/linker.ld, 将上述目标文件和归档文件链接成可执行文件。

根据上述链接脚本的指示, 可执行程序重定位后的节从0x100000或0x80000000开始 (取决于_pmem_start和_entry_offset的值), 首先是.text节, 其中又以abstract-machine/am/src/$ISA/nemu/start.S中自定义的entry节开始, 然后接下来是其它目标文件的.text节. 这样, 可执行程序起始处总是放置start.S的代码, 而不是其它代码, 保证客户程序总能从start.S开始正确执行. 链接脚本也定义了其它节(包括.rodata, .data, .bss)的链接顺序, 还定义了一些关于位置信息的符号, 包括每个节的末尾, 栈顶位置, 堆区的起始和末尾.

我们对编译得到的可执行文件的行为进行简单的梳理:

第一条指令从abstract-machine/am/src/$ISA/nemu/start.S开始, 设置好栈顶之后就跳转到abstract-machine/am/src/platform/nemu/trm.c的_trm_init()函数处执行.

在_trm_init()中调用main()函数执行程序的主体功能, main()函数还带一个参数, 目前我们暂时不会用到, 后面我们再介绍它.

从main()函数返回后, 调用halt()结束运行.

有了TRM这个简单的运行时环境, 我们就可以很容易地在上面运行各种"简单"的程序了. 当然, 我们也可以运行"不简单"的程序: 我们可以实现任意复杂的算法, 甚至是各种理论上可计算的问题, 都可以在TRM上解决.

解读一下abstract-machine项目的Makefile

这里的MAKECMDGOALS就是你在命令行make后面带的参数,如果make后面没有参数,就让makecmdgoals等于image,并且让默认目标等于image

复制代码
ifeq ($(MAKECMDGOALS),)
  MAKECMDGOALS  = image
  .DEFAULT_GOAL = image
endif

检查am环境目录以及ARCH架构是否为预估的riscv32-nemu or riscv32e-npc,再判断是否有正确的.c文件。

复制代码
### Override checks when `make clean/clean-all/html`
### 清理和生成文档的时候跳过检查
ifeq ($(findstring $(MAKECMDGOALS),clean|clean-all|html),)

### Print build info message
$(info # Buildin $(NAME)-$(MAKECMDGOALS) [$(ARCH)])

### Check: environment variable `$AM_HOME` looks sane
ifeq ($(wildcard $(AM_HOME)/am/include/am.h),)
  $(error $$AM_HOME must be an AbstractMachine repo)
endif

#notdir用于去掉文件的绝对路径,只保留文件名。path/nemu.mk ---> nemu.mk
#basename取前缀函数,	nemu.mk ---> nemu
ARCHS = $(basename $(notdir $(shell ls $(AM_HOME)/scripts/*.mk)))
#filter函数用于检查 $(ARCH) 是否在 $(ARCHS) 列表中。如果 $(ARCH) 在列表中,返回 $(ARCH)。如果不在,返回空字符串。
#arch如果不在archs中,返回空字符串 ifeq逻辑生效 直接error。
ifeq ($(filter $(ARCHS), $(ARCH)), )
  $(error Expected $$ARCH in {$(ARCHS)}, Got "$(ARCH)")
endif

#subst字符串替换函数,$(subst <from>,<to>,<text>),from替换成to在text中  逻辑:将ARCH中的-替换成"空格"
### Extract instruction set architecture (`ISA`) and platform from `$ARCH`. Example: `ARCH=x86_64-qemu -> ISA=x86_64; PLATFORM=qemu`
#word取单词 1 2是第几个单词,逻辑就是isa取split中第一个单词,platform取split中第二个单词。
ARCH_SPLIT = $(subst -, ,$(ARCH))
ISA        = $(word 1,$(ARCH_SPLIT))
PLATFORM   = $(word 2,$(ARCH_SPLIT))

$(info # Using ISA=$(ISA), PLATFORM=$(PLATFORM))
$(info # SRCS=$(SRCS))

### Check if there is something to build
###flavor是寻找变量函数,当SRCS在makefile及其include中都没有时,函数输出undefined。ifeq就生效执行error
ifeq ($(flavor SRCS), undefined)
  $(error Nothing to build)
endif

### Checks end here
endif

准备好编译所需的环境变量,镜像,链接等等。

复制代码
## 2. General Compilation Targets

### Create the destination directory (`build/$ARCH`)
WORK_DIR  = $(shell pwd)
DST_DIR   = $(WORK_DIR)/build/$(ARCH)
$(shell mkdir -p $(DST_DIR))

### Compilation targets (a binary image or archive)
IMAGE_REL = build/$(NAME)-$(ARCH)
#将image_rel中的文件变成绝对路径之后返回给IMAGE
IMAGE     = $(abspath $(IMAGE_REL))
ARCHIVE   = $(WORK_DIR)/build/$(NAME)-$(ARCH).a

### Collect the files to be linked: object files (`.o`) and libraries (`.a`)
#basename取SRCS的前缀  addsuffix给SRCS的后缀加一个.o  addprefix给SRCS.o加一个类似路径的前缀。
OBJS      = $(addprefix $(DST_DIR)/, $(addsuffix .o, $(basename $(SRCS))))
#sort是排序函数,将$(LIBS) am klib 按照首字母进行升序,并去除重复的单词。
LIBS     := $(sort $(LIBS) am klib) # lazy evaluation ("=") causes infinite recursions
LINKAGE   = $(OBJS)  # library archives are added by LIB_TEMPLATE below

制定好交叉编译所需的工具链,因为最终的可执行文件是需要在RISCV机器(nemu或者是npc)上运行,因此需要使用RV对应的工具链,如果是loonarch或者mips32也可以指定对应的工具链。

告诉编译器正确的路径如何找到库函数.h

并给予合适的编译选项。

复制代码
## 3. General Compilation Flags

### (Cross) compilers, e.g., mips-linux-gnu-g++
AS        = $(CROSS_COMPILE)gcc
CC        = $(CROSS_COMPILE)gcc
CXX       = $(CROSS_COMPILE)g++
LD        = $(CROSS_COMPILE)ld
AR        = $(CROSS_COMPILE)ar
OBJDUMP   = $(CROSS_COMPILE)objdump
OBJCOPY   = $(CROSS_COMPILE)objcopy
READELF   = $(CROSS_COMPILE)readelf

### Compilation flags告诉编译器在哪里找到.h文件
#给LIBS加上amhome前缀之后加上include后缀再加上workdir/include,给库生成正确路径
INC_PATH += $(WORK_DIR)/include $(addsuffix /include/, $(addprefix $(AM_HOME)/, $(LIBS)))

#给incpath加上-I前缀
INCFLAGS += $(addprefix -I, $(INC_PATH))

ARCH_H := arch/$(ARCH).h
#-O2:启用中级优化(平衡性能与编译速度)。-MMD:生成依赖文件(.d),用于自动追踪头文件变更。
#-Wall -Werror:启用所有警告并将其视为错误(严格模式)。 $(INCFLAGS):可能包含额外的头文件搜索路径(如 -Iinclude)。
CFLAGS   += -O2 -MMD -Wall -Werror $(INCFLAGS) \
            -D__ISA__=\"$(ISA)\" -D__ISA_$(shell echo $(ISA) | tr a-z A-Z)__ \
            -D__ARCH__=$(ARCH) -D__ARCH_$(shell echo $(ARCH) | tr a-z A-Z | tr - _) \
            -D__PLATFORM__=$(PLATFORM) -D__PLATFORM_$(shell echo $(PLATFORM) | tr a-z A-Z | tr - _) \
            -DARCH_H=\"$(ARCH_H)\" \
            -fno-asynchronous-unwind-tables -fno-builtin -fno-stack-protector \
            -Wno-main -U_FORTIFY_SOURCE -fvisibility=hidden
CXXFLAGS +=  $(CFLAGS) -ffreestanding -fno-rtti -fno-exceptions
ASFLAGS  += -MMD $(INCFLAGS)
LDFLAGS  += -z noexecstack $(addprefix -T, $(LDSCRIPTS))

引入对应架构的makefile文件,如果是riscv32-nemu的话那就是引入scripts/riscv32-nemu.mk的makefile

复制代码
### 4. Arch-Specific Configurations

### Paste in arch-specific configurations (e.g., from `scripts/x86_64-qemu.mk`)
-include $(AM_HOME)/scripts/$(ARCH).mk

这里就是前面所说的编译过程,首先将各种.c .cpp .cc .S编译成.o后放到对应的dstdir中。

随后用ar工具将这些.o文件打包为一个.a静态库,最终根据riscv32-nemu.mk的指示通过链接脚本将上述目标文件和静态库等等变成一个可执行文件。

复制代码
## 5. Compilation Rules

### Rule (compile): a single `.c` -> `.o` (gcc)
#%.c是依赖的文件,要把.c .cc .cpp .S编译成.o放到dstdir中
$(DST_DIR)/%.o: %.c
	@mkdir -p $(dir $@) && echo + CC $<
	@$(CC) -std=gnu11 $(CFLAGS) -c -o $@ $(realpath $<)

### Rule (compile): a single `.cc` -> `.o` (g++)
$(DST_DIR)/%.o: %.cc
	@mkdir -p $(dir $@) && echo + CXX $<
	@$(CXX) -std=c++17 $(CXXFLAGS) -c -o $@ $(realpath $<)

### Rule (compile): a single `.cpp` -> `.o` (g++)
$(DST_DIR)/%.o: %.cpp
	@mkdir -p $(dir $@) && echo + CXX $<
	@$(CXX) -std=c++17 $(CXXFLAGS) -c -o $@ $(realpath $<)

### Rule (compile): a single `.S` -> `.o` (gcc, which preprocesses and calls as)
$(DST_DIR)/%.o: %.S
	@mkdir -p $(dir $@) && echo + AS $<
	@$(AS) $(ASFLAGS) -c -o $@ $(realpath $<)

###把所有的.s .c .cc .cpp文件转换为.o文件放到对应目录

ifeq ($(MAKECMDGOALS),archive)
### Rule (archive): objects (`*.o`) -> `ARCHIVE.a` (ar)
#make archive用ar把所有的.o文件打包成一个.a静态库
#如果不是archive会递归调用每个依赖库如(am klib)的makefile
#把他们也编译成.a静态库,然后在链接的时候把这些.a静态库和.o文件一起链接成最终的elf文件
$(ARCHIVE): $(OBJS)
	@echo + AR "->" $(shell realpath $@ --relative-to .)
	@$(AR) rcs $@ $^
else
# $(1): library name
define LIB_TEMPLATE =
$$(AM_HOME)/$(1)/build/$(1)-$$(ARCH).a: force
	@$$(MAKE) -s -C $$(AM_HOME)/$(1) archive
LINKAGE += $$(AM_HOME)/$(1)/build/$(1)-$$(ARCH).a
endef

### Rule (recursive make): build a dependent library (am, klib, ...)
$(foreach lib, $(LIBS), $(eval $(call LIB_TEMPLATE,$(lib))))
endif

### 最终链接Rule (link): objects (`*.o`) and libraries (`*.a`) -> `IMAGE.elf`, the final ELF binary to be packed into image (ld)
### 把所有的目标文件和库文件连接成最终的ELF image 
$(IMAGE).elf: $(LINKAGE) $(LDSCRIPTS)
	@echo \# Creating image [$(ARCH)]
	@echo + LD "->" $(IMAGE_REL).elf
ifneq ($(filter $(ARCH),native),)
	@$(CXX) -o $@ -Wl,--whole-archive $(LINKAGE) -Wl,-no-whole-archive $(LDFLAGS_CXX)
else
	@$(LD) $(LDFLAGS) -o $@ --start-group $(LINKAGE) --end-group
endif

### Rule (`#include` dependencies): paste in `.d` files generated by gcc on `-MMD`
-include $(addprefix $(DST_DIR)/, $(addsuffix .d, $(basename $(SRCS))))

makefile的最后是一些依赖和更新,确保有文件修改后makefile能及时更新并进行编译。

复制代码
## 6. Miscellaneous各种依赖及更新
## .PHONY 保证这些命令每次都能执行,不受文件名影响
### Build order control
image: image-dep
archive: $(ARCHIVE)
image-dep: $(IMAGE).elf
.PHONY: image image-dep archive run

### Force to rebuild a rule
force:
.PHONY: force

### Clean a single project (remove `build/`)
clean:
	rm -rf Makefile.html $(WORK_DIR)/build/
.PHONY: clean

### Clean all sub-projects within depth 2 (and ignore errors)
CLEAN_ALL = $(dir $(shell find . -mindepth 2 -name Makefile))
clean-all: $(CLEAN_ALL) clean
$(CLEAN_ALL):
	-@$(MAKE) -s -C $@ clean
.PHONY: clean-all $(CLEAN_ALL)

下一期主要讲一下
看一下am-tests中的用户程序hello.c是如何变成可执行文件并被nemu运行的。