思考题

  • Thinking 1.1 在阅读附录中的编译链接详解以及本章内容后,尝试分别使用实验环境中的原生 x86 工具链(gcc、ld、readelf、objdump 等)和 MIPS 交叉编译工具链(带有 mips-linux-gnu- 前缀,如 mips-linux-gnu-gcc、mips-linux-gnu-ld),重复其中的编译和解析过程,观察相应的结果,并解释其中向 objdump 传入的参数的含义。

    不同工具链的比较

    使用 gcc -E hello.c > ori_prep.txtmips-linux-gnu-gcc -E hello.c > cross_prep.txt,获取原生 x86 工具链和 MIPS 交叉编译工具链对于 hello.c 的预处理结果,并通过命令 diff ori_prep.txt cross_prep.txt -y 比较两个预处理结果的不同。可以看到不同工具链使用的工具路径不同、对某些变量的定义不同等。

    工具路径不同:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # 0 "hello.c"                                                   # 0 "hello.c"
    # 0 "<built-in>" # 0 "<built-in>"
    # 0 "<command-line>" # 0 "<command-line>"
    # 1 "/usr/include/stdc-predef.h" 1 3 4 | # 1 "/usr/mips-linux-gnu/include/stdc-predef.h" 1 3
    # 0 "<command-line>" 2 # 0 "<command-line>" 2
    # 1 "hello.c" # 1 "hello.c"
    # 1 "/usr/include/stdio.h" 1 3 4 | # 1 "/usr/mips-linux-gnu/include/stdio.h" 1 3
    # 28 "/usr/include/stdio.h" 3 4 | # 28 "/usr/mips-linux-gnu/include/stdio.h" 3
    # 1 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" | # 1 "/usr/mips-linux-gnu/include/bits/libc-header-start.h" 1
    # 33 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" | # 33 "/usr/mips-linux-gnu/include/bits/libc-header-start.h" 3
    # 1 "/usr/include/features.h" 1 3 4 | # 1 "/usr/mips-linux-gnu/include/features.h" 1 3

    变量的定义不同:

    1
    2
    3
    4
    5
    6
    typedef long unsigned int size_t;                             | typedef unsigned int size_t;
    # ...
    typedef long int __quad_t; <
    typedef unsigned long int __u_quad_t; <
    > __extension__ typedef long long int __quad_t;
    > __extension__ typedef unsigned long long int __u_quad_t;

    objdump 参数

    1
    2
    3
    4
    5
    # objdump --help
    -D, --disassemble-all Display assembler contents of all sections
    --disassemble=<sym> Display assembler contents from <sym>
    -S, --source Intermix source code with disassembly
    --source-comment[=<txt>] Prefix lines of source code with <txt>

    -D 表示从给定文件中反汇编所有的 section(区别于-d:反汇编特定指令机器码的 section)。

    -S 表示尽可能反汇编出源代码,尤其当编译的时候指定了-g 这种调试参数时,效果比较明显。隐含了-d 参数。

  • Thinking 1.2 思考下述问题:

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

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

    1
    2
    3
    4
    5
    6
    # Makefile
    readelf: main.o readelf.o
    $(CC) $^ -o $@

    hello: hello.c
    $(CC) $^ -o $@ -m32 -static -g

    由 Makefile 可知,可执行文件 hello 使用了 -m32 选项,强制生成了 32 位程序;而 readelf 未指明 -m32 选项,默认生成了 64 位程序。使用 file 指令查看可验证:

    1
    2
    3
    4
    # file readelf
    readelf: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=82805d7e063d175cdba625c3a8548f736ae88ec6, for GNU/Linux 3.2.0, not stripped
    # file hello
    hello: ELF 32-bit LSB executable, Intel 80386, version 1 (GNU/Linux), statically linked, BuildID[sha1]=d35d87016cbcf9f641bc0d66a36ddfcf729f2d13, for GNU/Linux 3.2.0, with debug_info, not stripped

    readelf.c 中使用了 Elf32_Ehdr 等 32 位 ELF 结构体,因此 ./readelf 只能正确解析 32 位 ELF 文件,当解析自身时,e_shoff 等字段偏移错误,导致无法定位节头表。

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

    基于 MIPS 架构的 Bootloader 分为 stage1 和 stage2 两个部分。CPU 上电时,取值寄存器复位到启动入口地址(如 0xBFC00000),直接从非易失存储器(如 ROM 或 FLASH)中加载 bootloader 程序,执行 stage1 阶段任务,随后跳转到 RAM 中的 stage2 入口。在 stage2 中 bootloader 将内核镜像从存储器读到 RAM 中,并为内核设置启动参数,最后将 CPU 指令寄存器的内容设置为内核入口函数的地址,由此可以正确跳转到内核入口处,且将 CPU 控制权由 bootloader 转交给操作系统内核。

    因此,CPU 上电时的启动入口地址只是用于加载 bootloader 的 ROM(或 FLASH) 地址;而内核入口由 bootloader 在 RAM 中进行内核初始化时指定(根据链接脚本 kernel.lds)。二者并不冲突。

    image-20250326174503505

难点分析

  1. 实验代码主要文件(以下将实验仓库视为根目录)

    • /Makefile:构建整个操作系统的顶层 Makefile,用于指导源代码形成最终的可执行文件。
    • /kernel.lds
    • /init/start.S /init/init.c:初始化内核。start.S 文件中的 _start 函数是 CPU 控制权被转交给内核后执行的第一个函数,主要工作是初始化 CPU 和栈指针,为之后的内核初始化做准备,最后跳转到 init.c 文件中定义的 mips_init 函数。
    • /include/:存放系统头文件。本章中 mmu.h 头文件有一张内存布局图,填写 linker script 时需要依据其来设置相应节的加载地址。
    • /kern/:存放内核的主体代码。
  2. 一些 Makefile 语法

    1
    2
    $(modules): # 进入各个子目录进行 make
    $(MAKE) --directory=$@

    $@ 会被展开为当前目标的名称(即 $(modules) 中的模块名,如 kern 等),$(MAKE) --directory=$@ 的执行效果等同于手动切换到相应目录再调用 make

    1
    2
    3
    4
    5
    clean:
    for d in $(modules); do \
    $(MAKE) --directory=$$d clean; \
    done; \
    rm -rf *.o *~ $(mos_elf)

    \ 代表这一行没有结束,下一行的内容和这一行是连在一起的。(注:上面代码 \ 不能删!否则相当于开了四个 bash 终端依次输入了这四行代码。)

  3. ELF(Executable and Linkable Format)是一种可用于可执行文件、目标文件和库的文件格式。.o 文件是 ELF 所包含的三种文件类型中的一种,称为可重定位(relocatable)文件,其他两种文件类型分别是可执行(excutable)文件共享对象(shared object)文件。后两种文件都需要链接器对可重定位文件进行处理才能产生。


    ELF 文件结构:

    image-20250326211235927

    段头表和节头表指向了同样的地方,意味着两者只是程序数据的两种视图:

    • 组成可重定位文件,参与可执行文件和可共享文件的链接。此时用节头表
    • 组成可执行文件或可共享文件,在运行时为加载器提供信息。此时用段头表

    ELF 文件内容

    • struct Elf32_Ehdr ELF 头

      unsigned char e_ident[EI_NIDENT]:存放魔数及其他信息,用于验证这是一个有效的 ELF 文件(不能通过文件扩展名来验证)

    • struct Elf32_Shdr 节头

    • struct Elf32_Phdr 段头

    image-20250326212826446


    ELF 文件段信息(readelf -l <elf-file> 查看)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    Elf 文件类型为 EXEC (可执行文件)
    Entry point 0x8049750
    There are 8 program headers, starting at offset 52

    程序头:
    Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
    LOAD 0x000000 0x08048000 0x08048000 0x001e0 0x001e0 R 0x1000
    LOAD 0x001000 0x08049000 0x08049000 0x657c0 0x657c0 R E 0x1000
    NOTE 0x000134 0x08048134 0x08048134 0x00044 0x00044 R 0x4
    TLS 0x0989e8 0x080e09e8 0x080e09e8 0x0000c 0x0002c R 0x4
    GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x10

    Section to Segment mapping:
    段节...
    00 .note.gnu.build-id .note.ABI-tag .rel.plt
    01 .init .plt .text .fini
    02 .rodata .eh_frame .gcc_except_table
    03 .tdata .init_array .fini_array .data.rel.ro .got .got.plt .data .bss
    04 .note.gnu.build-id .note.ABI-tag
    05 .tdata .tbss
    06
    07 .tdata .init_array .fini_array .data.rel.ro .got
    • Offset:代表该段的数据相对于 ELF 文件的偏移;
    • VirtAddr:代表该段最终需要被加载到内存的位置(QEMU 在加载内核时,按照内核【可执行文件】中记录的这个地址将内核中的代码、数据等加载到相应位置);
    • FileSiz:代表该段的数据在文件中的长度;
    • MemSiz:代表该段的数据在内存中应当占的大小(MemSiz 始终大于等于 FileSiz,采用填 0 法填充);
    • Segment mapping:表明每个段各自含有的节。
  4. MIPS 内存布局——内核运行的正确位置

    image-20250326215732920

    • kuseg用户态下唯一可用的地址空间(内核态下也可用),需要使用 MMU 中的 TLB 完成虚拟地址到物理地址的转换。存取都会经过 cache
    • kseg0:内核态下的可用地址,MMU 将最高位清零就得到物理地址用于访存。也就是说,这段虚拟地址被连续地映射到物理地址的低 512MB 空间。存取都会经过 cache
    • kseg1:内核态下的可用地址,高三位清零得到物理地址用于访存。同样连续地映射到物理地址的低 512MB 空间。但是对这段地址的存取不经过 cache ,通常在这段地址上使用 MMIO 技术来访问外设。
    • kseg2:内核态下的物理地址, 需要 MMU 中的 TLB 将虚拟地址转换为物理地址。对这段地址的存取都会经过 cache

    TLB 需要操作系统进行配置管理,因而在载入内核时不能选用需要通过 TLB 的 kusegkseg2。而不经过 cache 的 kseg1 通常用于访问外设。因此我们 将内核放置kseg0

    需要注意,kusegkseg0kseg1 以及 kusegkseg2 位于不同的虚拟地址空间,但是都映射到同一个物理地址空间。不同只在于映射方式访问权限。对于虚拟地址空间和物理地址空间的关系,下面示意图中有较为清晰的展示。

    image-20250326222338209

  5. Linker Script——控制内核加载地址

    image-20250326223847427

    将内核加载到想要的内存地址依赖于 Linker Script 。在链接过程中,目标文件被看成 section 的集合,并使用节头表(section header table)来描述各个 section 的组织。换言之,section 记录了链接过程中的必要信息。其中最为重要的三个 section 为 .text.data.bss

    关于链接后的程序从何处开始执行。程序执行的第一条指令的地址称为入口地址(entrypoint)。我们的实验就在 kernel.lds 中通过 ENTRY(_start) 来设置程序入口为 _start

  6. 实现 printk

    • C 语言的 printf 函数由 C 语言的标准库提供,而 C 语言标准库建立在操作系统基础之上。因此开发操作系统时需要自己实现 printk 函数。

    • ```c // kern/machine.c void printcharc(char ch) { … ((volatile uint8_t )(KSEG1 + MALTA_SERIAL_DATA)) = ch; // 让控制台输出一个字符,实际上是对某一个内存地址写了一个字节 }

      // kern/printk.c void outputk(void data, const char buf, size_t len) { // 用来输出一个字符串 for (int i = 0; i < len; i++) { printcharc(buf[i]); } }

      void printk(const char *fmt, …) { va_list ap; // 定义变长参数表 va_start(ap, fmt); // 初始化变长参数表 vprintfmt(ouputk, NULL, fmt, ap); va_end(ap); // 结束使用变长参数表 }

      1
      2
      3
      4
      5
      6
      7
      8

      当函数参数列表末尾有省略号时,该函数即有变长的参数表。由于需要定位变长参数表的起始位置,函数需要含有至少一个固定参数,且变长参数必须在参数表的末尾。`stdarg.h` 头文件(Linux 自定义头文件)中为处理变长参数表定义了一组宏和变量类型:`va_list`、`va_start(va_list ap, lastarg)`、`va_arg(va_list ap, 类型)`、`va_end(va_list ap)`。

      ```c
      // lib/print.c
      void vprintfmt(fmt_callback_t out, void *data, const char *fmt, va_list ap) {
      ...
      }

      vprintfmt 是一个公共链接库函数,用于解析格式化字符串,通过回调函数 out 完成输出。这样,实现了解析逻辑和输出逻辑的解耦,使程序更加可维护;同时 printk 等上层函数可传入不同的回调函数实现不同的输出行为(例如输出到文件)。data 参数是回调函数 out 需要的额外上下文信息,需要被 vprintfmt 按原样传入 out,可以是输出的目标内存地址等。(可以类比面向对象语言中的设计,将 out 视为 “ 继承自接口的方法实现 ” ,data 则则类似方法中的 this 指针)

实验体会

  • 需要弄清楚操作系统的启动流程,分为两个阶段;
  • 需要掌握操作系统内核的产生过程,主要涉及内核(ELF 可执行文件)的内容、内核在内存中运行的正确位置、使用 Linker Script 指定内核在内存中的位置;
  • 体会实现内核功能(如 printk 函数)的过程。

原创说明

部分内容参考了 《2025 操作系统 Lab1 串讲》以及 DeepSeek。