Thinking 1.1


尝试分别使用实验环境中的原生 x86 工具链(gcc、ld、readelf、objdump 等)和 MIPS 交叉编译工具链(带有mips-linux-gnu- 前缀,如 mips-linux-gnu-gcc、mips-linux-gnu-ld),重复其中的编译和解析过程,观察相应的结果,并解释其中向 objdump 传入的参数的含义。

写了一个 night.c 程序作为实验对象:

1
2
3
4
5
6
#include <stdio.h>
int main () {
printf ("Night fall...\n");
printf ("Sleep well, wake well.");
return 0;
}
1
gcc -E night.c

前面补上一大堆 typedefstructextern

1
gcc -c night.c

night.o

image-20260325000727645

反汇编:

1
objdump -DS night.o > night.s

image-20260325001139378

文件格式:elf64-x86-64

用交叉编译工具链 gcc + 反汇编出的是 elf32-tradbigmips,看起非常亲切(终于有个能看懂的了)

image-20260325112659865

readelf

image-20260325113044240image-20260325113101285

image-20260325113159790

发现 Entry Point 不同,正常,ABI 不同,start_ 也不同;

发现连 段头/节头 的数目都不同,MIPS 少一些。

objdump 中的参数:

1
2
-S	# 尽量 “源代码 + 汇编混合显示”,但我没加 -g 调试模式,所以没起效
-D # 所有 section,不止 .text

Thinking 1.2


• 尝试使用我们编写的 readelf 程序,解析之前在 target 目录下生成的内核 ELF 文件。

• 也许你会发现我们编写的 readelf 程序是不能解析 readelf 文件本身的,而我们刚才介绍的系统工具 readelf 则可以解析,这是为什么呢?(提示:尝试使用 readelf-h,并阅读 tools/readelf 目录下的 Makefile,观察 readelf 与 hello 的不同)

实验:

用我们编写的 readelf 文件解析 target/mos 的结果是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
0:0x0
1:0x80020000
2:0x800220b0
3:0x800220c8
4:0x800220e0
5:0x0
6:0x0
7:0x0
8:0x0
9:0x0
10:0x0
11:0x0
12:0x0
13:0x0
14:0x0
15:0x0
16:0x0
17:0x0
18:0x0

而用系统工具 readelf 解析结果是(我用的是 -a ,太长了,省略部分):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
ELF 头:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
类型: EXEC (可执行文件)
系统架构: MIPS R3000
入口点地址: 0x80021c60
程序头起点: 52 (bytes into file)
Start of section headers: 23976 (bytes into file)
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 4
Size of section headers: 40 (bytes)
Number of section headers: 19
节头:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS 80020000 0000c0 0020b0 00 WAX 0 0 16
.......
程序头:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
ABIFLAGS 0x002188 0x800220c8 0x800220c8 0x00018 0x00018 R 0x8
REGINFO 0x002170 0x800220b0 0x800220b0 0x00018 0x00018 R 0x4
......
There is no dynamic section in this file.
该文件中没有重定向信息
......
ISA: MIPS32
GPR size: 32
CPR1 size: 32
CPR2 size: 0
FP ABI: Hard float (32-bit CPU, Any FPU)
.......

系统 readelf 解析我的 readelf

image-20260325135528080

我发现我的 readelf 有重定位节。

image-20260325140345360

我的 readelf 自我解析 && 我的 readelf 解析系统 readelf

image-20260325142737403

解析出来俩空文件。

系统的 readelf 自我解析:

image-20260325142542674

思考:

  • 为什么我的 resdelf 不能自我解析?注意到虽然解析输出为空但并没有报错,应该不是 “自己解析自己” 这个操作不允许。我认为是由于我们的 readelf 并非完全版,是针对 MOS 内核的(比如默认了一些取值 🤔),我们的 readelf 自身显然不是内核,所以解析不出来。
  • 对哦,我们的 readelf 全是针对 32 位的,但它自己能在宿主机运行,那肯定是 64 位的,所以解析不了。
  • 系统的 “通用 readelf“ 自然是通用的。

Thinking 1.3


在理论课上我们了解到,MIPS 体系结构上电时,启动入口地址为 0xBFC00000(其实启动入口地址是根据具体型号而定的,由硬件逻辑确定,也有可能不是这个地址,但一定是一个确定的地址),但实验操作系统的内核入口并没有放在上电启动地址,而是按照内存布局图放置。思考为什么这样放置内核还能保证内核入口被正确跳转到?

(提示:思考实验中启动过程的两阶段分别由谁执行。)

  • pre:两阶段由谁执行:
    • CPU 固定复位到 BootLoader,这个由硬件执行。
    • BootLoader 引导 CPU 跳到内核,这个由 BootLoader 执行。
  • 所以为什么:
    • CPU 本身不认识内核入口,但 BootLoader 认识,BootLoader 会通过 ELF 头找到和内核的契约,然后引导 CPU 跳过去。
    • 欸?所以 BootLoader 也是一种 readelf ?还是说它只是接受 readelf 解析出的结果?🤔
      • 关键 BootLoader 起作用的时候 readelf 还不在运行链路里啊。
        • GPT 说 BootLoader 包含一小段和 readelf 相似的逻辑,来解析 ELF 头。

交叉编译:

编译器生成的 目标代码编译器本身 在不同的平台上运行。为什么要交叉编译?因为 QEMU 模拟的是 MIPS 平台环境,我们的 MOS 是在 MIPS 上跑的;而我们的跳板机显然是在 X86 上跑的。

1
2
gcc
mips-linux-gnu-gcc # 用一个运行在 x86 上的程序,生成给 MIPS 用的程序

QEMU 的简化版启动:

QEMU 模拟器支持直接加载 ELF 格式的内核,也就是说,QEMU 已经提供了 bootloader 的引导(启动)功能。MOS 操作系统不需要再实现 bootloader的功能。

我们在理论课上学习了相当复杂的启动流程,主要包括 ① BootLoader 引导 ② 硬件环境逐步复苏 ③ 将内核加载到内存中的指定位置 ④ CPU 跳转到内核入口执行。而 QEMU 模拟器帮我们实现了大部分,启动流程被简化为 ③④。

启动

init/start.S 中的 _start 函数是 CPU 控制权被转交给内核后执行的第一个函数,主要工作是初始化 CPU 和栈指针,为之后的内核初始化做准备。最后跳到 init.c 中的 mips_init。观察 mips_init 发现它只是一个占位的东西,还没有实现任何内核相关内容。

所以其实我们 lab1 干的事儿跟内核啥关系没有。

Makefile:

上来就是一句:.ONESHELL: 意味着我们可以不去注意 Makefile 中 “换行即换 shell“ 这件事。

1
2
3
4
5
6
7
8
Lab1
|- Makefile
|- lib
|- Makefile
|- init
|- Makefile
|- kernel
|- Makefile

Makefile 里的东西很多很杂,而且不乏涉及到一些与实验无关内容,我没有试图完全理解,下面挑重点描述:

1
2
3
4
5
6
target_dir              := target	# 最终的 MOS 构建目标所在目录
mos_elf := $(target_dir)/mos # 我们要完成的操作系统其实就是个 ELF 文件
link_script := kernel.lds
# Linker Script 是用来指导链接器将多个 .o 文件链接成目标可执行文件的脚本
modules := lib init kern # 子模块
targets := $(mos_elf)

简要阅读 Makefile 即可发现,其中存在大量 “指来指去” 的内容,化简完如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
all: $(mos_elf)
$(mos_elf): $(modules) $(target_dir) # $(LD) 链接所有目标文件
$(LD) $(LDFLAGS) -o $(mos_elf) -N -T $(link_script) $(objects)
$(target_dir):
mkdir -p $@
$(modules): # 进入各个子目录(子模块)进行 Make
$(MAKE) --directory=$@
clean:
for d in * tools/readelf user/* tests/*; do # 遍历所有目录,寻找其内部 Makefile 并执行 clean
if [ -f $$d/Makefile ]; then
$(MAKE) --directory=$$d clean
fi
done
rm -rf *.o *~ $(target_dir) include/generated
find . -name '*.objdump' -exec rm {} ';'
run:
$(QEMU) $(QEMU_FLAGS) -kernel $(mos_elf)

注意这里没有像通常那样把 all 作为第一个(默认)规则,而是 clean-and-all,可能是出于方便考虑,我们无需手动清理之前生成的副产物,直接 make 就行。注意评测命令是 make && make run

变长参数列表

比如这段代码:

1
2
3
4
5
6
void printk(Const chsr *fmt, ...) {
va_list ap;
va_start(ap, fmt);
vprintfmt(outputk, NULL, fmt, ap);
va_end(ap);
}
1
va_list ap; // 变长参数

初始化变长参数:

1
va_start(ap, fmt /* 指向 ... 前的最后一个参数 */); 

(连续多次地)使用 va_arg 来接受 … 里的参数,比如 va_arg(ap, int); 就会读到一个 int 类型的值。

1
2
3
int a = va_arg(ap /*...*/, int /* 指定接受参数的类型 */);
int b = va_arg(ap, long);
...

注意这里最少接受 4 个字节的参数,char 不允许。

最后:

1
va_end(ap);

有关为什么 printk 为什么要使用回调函数,因为输出函数不只有 printk,(还有 print ,printf, sprintf……),都使用 vprintfmt 接口,通过指定(设计)回调函数来指定(设计)输出方式。

ELF:

先看 elf.h

我对 .h 文件 0 了解,所以刚看到 elf.h 是懵的,因为在我的认知中,struct 中变量出现的先后顺序是无所谓的,但这里显然要用变量在 struct 中出现的顺序取对应它们在 ELF 中出现的顺序。

总的来说,ELF 里 文件头 肯定是最先出现的,剩下的 节/段 出现(其实是指解析)的顺序取决于 ELF 头中指定的偏移量(大致上是无所谓的),【节/段 内部的顺序严格对应 elf.h 中规定的顺序】【并非如此】

有问题。

Executable and Linkable Format - Wikipedia

P.S. 这张图我忘了是哪儿来的了,可能是从哪位学长或者学姐的博客里拿来的……反正不是我自己画的

C 语言相关

C 语言环境分为 FreestandingHosted 两种,前者有操作系统支持,后者没有。显然没有操作系统支持的 Freestanding 不可能支持 I/O 操作,所以 我们使用的 Freestanding C 语言环境 + 我们实现的 I/O 操作 ➡️ 能进行 I/O 操作的 C 语言环境。

独立环境下能使用的头文件:

1
2
3
4
5
6
7
<float.h>  
<iso646.h>
<limits.h>
<stdarg.h>
<stdbool.h>
<stddef.h>
<stdint.h>

然后我们下发的代码又给我们补了一些,比如说 string.h,里面的也是我们能直接用的。

回调函数 && printk

这四个代码一眼看上去有点乱。printk.cprint.c 如胶似漆(各种相互调用),print.hprintk.h 存在感模糊。

printk.coutputkprint.c 里还改了个名字叫做 out,我一开始在根目录下 grep -r "out" 啥也没找着。

我后来明白为什么要改名字了。outputk 只是 printk 对应的输出方式(输出到 stdout),如果,我要加一个 sprinfmt,我给它写一个,比方说,soutput,那 soutput 也要走 out 接口,如果还管他叫 outputk 就不合适了。

其实就是这样:⬇️

回调函数首先是在 print.h 中出现的:

1
typedef void (*fmt_callback_t)(void *data, const char *buf, size_t len);

这里命名了一个函数指针类型 fmt_callback_t,其指向的函数格式为:

1
void somedef(void *data, const char *buf, size_t len)

它被用在 vprintfmt 里:

1
void vprintfmt(fmt_callback_t out, void *data, const char *fmt, va_list ap);

这里的注释挺详细的,奈何我不懂英文。

注释:

vprintfmt 是一个格式化函数,它允许使用不同的后端(即输出目标)来完成打印。它接收四个参数:

  • fmt_callback_t out:一个指向输出目标的函数指针,这个输出目标函数负责接收 vprintfmt 生成的格式化输出。

  • void *data:一个上下文指针,会被传递给 out 回调函数。它可以用来保存与具体输出目标相关的额外数据。

  • const char *fmt:格式字符串,和 printf 的格式字符串类似。

  • va_list ap:可变参数列表,提供要被格式化的那些参数。

格式化回调函数 out 接收下面这些参数:

  • void *data
    • 就是传给 vprintfmt 的那个同一个 data 指针。
  • const char *buf
    • 指向一个缓冲区,这个缓冲区里存放的是 vprintfmt 生成好的格式化输出。
  • size_t len
    • 这个缓冲区中有多少字节有效。

注意,这个缓冲区不一定以 \0 结尾,而且里面还可能包含嵌入的 \0 字节,因此输出目标在处理时,应该把 len 当作真正需要输出的长度。

也就是说,这里的字符串输出并非像我们平时用的读到 ‘\0’ 自动结束,而是从 buf[0] 到 buf[len-1],输完为止。

具体表现为 printk.c 中的:

1
2
3
4
5
6
7
8
9
10
/* 正 */
void outputk(void *data, const char *buf, size_t len) {
for (int i = 0; i < len; i++) {
printcharc(buf[i]);
}
}
/* 误 */
void outputk(void *data, const char *buf, size_t len) {
printf("%s", buf);
}

本质上就是 print.cprintk.c 里函数的相互调用。

这里的 *fmt 都是用户程序提供的格式化字符串,比如说 "%s, %-4d"*buf 都是整理好准备输出的字符串,比如说 "hello, 3 "

这里还有个很有意思的事儿,先把负数取绝对值再连同负号输出,这遇到 LONG_MIN 不就炸了吗。

scancharc

lab1 没有任何涉及到输入的上层实现,但我发现 machine.c 里有一个孤零零的 scancharc,它和 printcharc 差不多,都是最底层的实现。

我仿照 outputk 写了一个 inputk

image-20260329143343268