北航os操作系统lab1

Post Cover

Lab1——内核、启动和 PRINTF

本人此时已经有点累死了,写的好像不是很容易懂o(╥﹏╥)o

以下内容主要来自指导书、教程配套视频、lmh学长直播

知识梳理

真实的操作系统中,我们需要 Bootloader 完成硬件的初始化以及将内核从硬盘载入 RAM 中,并为内核设置启动参数,将CPU指令寄存器的内容设置为内核入口函数的地址,等等一系列操作。

进行实验的硬件仿真平台 QEMU 已经提供了 bootloader 的引导功能,所以启动被简化为:加载ELF格式内核到内存,之后跳转到内核的入口

我们已经非常清楚一个 c 语言程序的运行过程,我们可否将内核也理解为一个类似的程序?启动内核的过程,实际上也是程序编译、链接、运行的过程。

编译

内核的很多程序文件已经给我们了,我们可以通过 Makefile 了解编译流程

其实这里的 make 包括编译和链接。

以下是打乱顺序的 Makefile

1
2
3
4
5
#Makefile
...
#输入 make :
clean-and-all: clean
$(MAKE) all

运行 clean 和 all 两个命令,clean 即清理一下战场,主要来看 all

1
2
3
4
5
6
#Makefile
...
all: $(targets)
#targets := $(mos_elf)
#mos_elf := $(target_dir)/mos 这即是最终需要生成的可执行文件
#target_dir := target MOS构建目标所在的目录

我们可以理解 make all 即构建./target目录下的可执行文件mos,现在我们来看看如何构建 $(target)($(target) = $(mos_elf))

1
2
3
4
5
6
#Makefile
...
$(mos_elf): $(modules) $(target_dir)
$(LD) $(LDFLAGS) -o $(mos_elf) -N -T $(link_script) $(objects)
#modules := lib init kern 需要生成的子模块,实际上还有 user_modules 但是 lab1 不用管
#target_dir := target MOS构建目标所在的目录

这实际工作是一个链接,我们先分析其依赖对象,等编译过之后,我们还会回过头来看这个链接的

先看 $(target_dir)

1
2
3
4
5
#Makefile
...
$(target_dir):
mkdir -p $@
#target_dir := target MOS构建目标所在的目录
  • -p 是递归创建。当创建1/2/3的时候,不加-p则默认存在1/2只用在1/2目录中创建1/2/3,加上-p可以递归的创建./1/2/3

  • $@ 指的是要构建的对象

本句话翻译一下就是: mkdir -p target,即递归的创建target目录

再看 $(modules)

1
2
3
4
#Makefile
...
$(modules): tools
$(MAKE) --directory=$@

我们依旧先看依赖文件,主要是一些编译内核的工具

1
2
3
4
#Makefile
...
tools:
CC="$(HOST_CC)" CFLAGS="$(HOST_CFLAGS)" $(MAKE) --directory=$@

这里完成了三个任务:给 CC 赋值,给 CFLAGS 赋值,运行了子目录中的make

  1. 前两个赋值内容在 include.mk 中,如下:

    1
    2
    3
    4
    5
    6
    7
    8
    #include.mk:
    CROSS_COMPILE ?= mips-linux-gnu- #实际使用的编译器和链接器等工具的前缀,交叉编译器的具体位置
    #交叉编译指的是编译器生成的目标代码运行的平台和编译器运行的平台不同的编译过程。在我们的实验中,编译器在常见的x86_64 平台上运行,生成的MOS内核在MIPS平台上运行,故属于交叉编译。
    #?= 是指如果这个变量还没有被定义过,就给它赋这个设定的值;如果这个变量已经有值了,那么这行代码就会被忽略,变量保持原有的值不变。
    CC := $(CROSS_COMPILE)gcc
    CFLAGS += --std=gnu99 -$(ENDIAN) -G 0 -mno-abicalls -fno-pic \
    -ffreestanding -fno-stack-protector -fno-builtin \
    -Wa,-xgot -Wall -mxgot -mno-fix-r4000 -march=4kc
  2. 运行子目录中的make

    • $(MAKE) 是 Makefile中的一个递归变量,主要作用于在子目录中调用另一个 Makefile

    • $@ 指的是要构建的对象

      这里指的就是进入到 tool 文件夹中,运行 tool 文件中的 Makefile 的 Make 命令

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      #tool/Makefile
      include include.mk
      .PHONY: all clean

      all: $(targets)

      %: %.c
      $(CC) $(CFLAGS) -o $@ $^
      # %是通配符
      # $^是所有的依赖对象

      clean:
      rm -rf $(targets)

      这里include.mk是 tool 中的 include.mk,而不是主文件夹中的,如下

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      #tool/include.mk
      lab-ge = $(shell [ "$$(echo $(lab)_ | cut -f1 -d_)" -ge $(1) ] && ec ho true)

      ifeq ($(call lab-ge,3), true)
      targets += bintoc
      endif

      ifeq ($(call lab-ge,5), true)
      targets += fsformat
      endif
      # call调用一个函数
      # shell执行一个shell命令
      # cut将输入按给定的字符分割,此处是_
      # 比较其后指定的两个字符串是否相等

      可以看到对于 lab1 $(target) 什么也没有

了解完依赖文件,我们再回过头来看$(modules):

1
2
3
4
5
#Makefile
...
$(modules): tools
$(MAKE) --directory=$@
#modules := lib init kern 需要生成的子模块

这里即一个一个调用了./lib./init./kern中的 Makefile,运行了 make 命令

./lib中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# ./lib/Makefile
INCLUDES := -I../include/
targets := elfloader.o print.o string.o
# elfloader:解析并加载 ELF(可执行和可链接格式)文件中的一个程序段(Segment)到指定的虚拟内存地址中。
# print:所需要完成的printk的一部分
# string:一些关于string的c语言库函数

%.o: %.c
$(CC) $(CFLAGS) $(INCLUDES) -c -o $@ $<
# $<表示第一个依赖对象

%.o: %.S
$(CC) $(CFLAGS) $(INCLUDES) -c -o $@ $<

.PHONY: all clean

all: $(targets)

clean:
rm -rf *~ *.o

此时,make的时候还是运行make all

%部分相当于一个模版,是不会被当作第一个构建的

./kern中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# ./kern/Makefile
include ./include.mk

INCLUDES := -I../include/

%.o: %.c
$(CC) $(CFLAGS) $(INCLUDES) -c -o $@ $<

%.o: %.S
$(CC) $(CFLAGS) $(INCLUDES) -c -o $@ $<

.PHONY: all clean

all: $(targets)

clean:
rm -rf *~ *.o
1
2
3
4
5
6
7
# ./kern/include.mk
lab-ge = $(shell [ "$$(echo $(lab)_ | cut -f1 -d_)" -ge $(1) ] && ec ho true)

targets := machine.o printk.o panic.o
# machine.o:从控制台读写、停止/重置整个系统
# printk.o:要完成的代码,将信息输出到控制台
# panic.o:当操作系统内核遇到无法恢复的致命错误时,留下遗言,然后自杀(停机)

./init中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# ./init/Makefile
INCLUDES := -I../include/

%.o: %.c
$(CC) $(CFLAGS) $(INCLUDES) -c $<

%.o: %.S
$(CC) $(CFLAGS) $(INCLUDES) -c $<

.PHONY: all clean

all: start.o init.o
#要完成的内容之一,即运行的时候首先运行的文件

clean:
rm -rf *.o *~

此时我们便运行完了$(modules)的构建,也就完成了$(mos_elf)的依赖文件构建,让我们回到$(mos_elf)

1
2
3
4
#Makefile
...
$(mos_elf): $(modules) $(target_dir)
$(LD) $(LDFLAGS) -o $(mos_elf) -N -T $(link_script) $(objects)

这其实执行的是链接操作(我们在之前已经完成了所有的汇编!),所以让我们来走进链接吧。

链接

依旧从$(mos_elf)的构建开始展开

1
2
3
4
5
6
7
8
#Makefile
...
$(mos_elf): $(modules) $(target_dir)
$(LD) $(LDFLAGS) -o $(mos_elf) -N -T $(link_script) $(objects)
# mos_elf := target/mos
# objects := $(addsuffix /*.o, $(modules)) $(addsuffix /*.x, $(user_modules)) 遍历需要生成的目标文件,表示要编译出内核所依赖的所有目标文件(用户程序和内核程序的链接方式不同,所以我们需要将用户程序和内核程序的目标文件分为不同的后缀保存,用户程序是.x,内核程序是.o)
# $(addsuffix 后缀, 列表) 这是 Makefile 的一个内置函数,作用是给列表中的每一个单词,强行加上指定的“后缀”。
#link_script := kernel.lds

其使用了 include.mk 文件

1
2
3
4
5
6
7
#include.mk
...
CROSS_COMPILE ?= mips-linux-gnu-
LD := $(CROSS_COMPILE)ld
# 这是交叉编译的链接器
ENDIAN := EL
LDFLAGS += -$(ENDIAN) -G 0 -static -n -nostdlib --fatal-warni ngs

-T $(link_script):LinkerScript 中记录了各个节应该如何映射到段,以及各个段应该被加载到的位置。通过这个选项,可以让链接器按照 kernel.lds 中的链接。

这句话即是 使用$(link_scrpt)$(objects)链接,输出到$(mos_elf)位置

节是什么?段是什么?LinkerScript是什么?让我们一一介绍。

ELF

ELF 的定义

ELF(Executable and Linkable Format) 是 Unix 上常用的一种目标文件格式,用于链接和运行,其分为三种:

  1. 编译生成 .o 文件就是ELF所包含的三种文件类型中的一种,称为可重定位(relocatable)文件
  2. 链接生成的可执行 (executable) 文件,需要链接器对可重定位文件进行处理才能生成。
  3. 共享对象 (shared object) 文件,也需要链接器对可重定位文件进行处理才能生成。
ELF 的结构
image-20260402015904193
image-20260402015904193
  1. ELF头,包括程序的基本信息,比如体系结构和操作系统,同时也包含了节头表和段头表相对文件的偏移量(offset)。
  2. 段头表(或程序头表,programheadertable),主要包含程序中各个段(segment)的信息, 段的信息需要在运行时刻使用。
  3. 节头表(sectionheadertable),主要包含程序中各个节(section)的信息,节的信息需要 在程序编译和链接的时候使用。
  4. 段头表中的每一个表项,记录了该段数据载入内存时的目标位置等,记录了用于指导应用 程序加载的各类信息。
  5. 节头表中的每一个表项,记录了该节程序的代码段、数据段等各个段的内容,主要是链接 器在链接的过程中需要使用。

节是用于链接的,段是用于运行的,段是由节组成的,两者的大致关系如下:(图来自课件)

image-20260402023809650
image-20260402023809650
image-20260402023843442
image-20260402023843442

可以通过./tools/readelf/elf.h 了解:

  1. ELF 头:

    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
    32
    typedef struct {
    unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
    //存放魔数及其其他信息
    //验证其是否有效
    Elf32_Half e_type; /* Object file type */
    //文件类型
    Elf32_Half e_machine; /* Architecture */
    //机器架构
    Elf32_Word e_version; /* Object file version */
    //文件版本
    Elf32_Addr e_entry; /* Entry point virtual address */
    //入口点的虚拟地址(程序开始执行的虚拟内存地址)
    Elf32_Off e_phoff; /* Program header table file offset */
    //程序头表所在处与此文件头的偏移
    Elf32_Off e_shoff; /* Section header table file offset */
    //节头表所在处与此文件头的偏移
    Elf32_Word e_flags; /* Processor-specific flags */
    //针对处理器的标记
    Elf32_Half e_ehsize; /* ELF header size in bytes */
    //ELF文件头的大小(单位是字节)
    Elf32_Half e_phentsize; /* Program header table entry size */
    //程序头表表项大小
    Elf32_Half e_phnum; /* Program header table entry count */
    //程序头表表项数
    Elf32_Half e_shentsize; /* Section header table entry size */
    //节头表表项大小
    Elf32_Half e_shnum; /* Section header table entry count */
    //节头表表项数
    Elf32_Half e_shstrndx; /* Section header string table index */
    //节头字符串编号
    //字符串表本身也成为了 ELF 文件里的一个节(Section),里面存放有节的名字,其也有节头,e_shstrndx 即其在节头表中的索引(编号)(说明是表中第几个节头)。
    } Elf32_Ehdr;
  2. 节头表:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    typedef struct {
    Elf32_Word sh_name; /* Section name */
    //节的名称
    Elf32_Word sh_type; /* Section type */
    //节的类型
    Elf32_Word sh_flags; /* Section flags */
    //节的标志位
    Elf32_Addr sh_addr; /* Section addr */
    //节的地址(指导链接过程)
    Elf32_Off sh_offset; /* Section offset */
    //节的文件内偏移(相对于ELF文件头而言)
    Elf32_Word sh_size; /* Section size */
    //节的大小(以字节计算)
    Elf32_Word sh_link; /* Section link */
    //节头表索引链接
    Elf32_Word sh_info; /* Section extra info */ //info:信息
    //额外信息
    Elf32_Word sh_addralign; /* Section alignment */
    //地址对齐
    Elf32_Word sh_entsize; /* Section entry size */
    //此节头表表项的大小
    } Elf32_Shdr;
  3. 段头表:

    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
    typedef struct {
    Elf32_Word p_type; /* Segment type */
    //段的类型
    //类型见下方,当是1,即PT_LOAD时,整个段要加载进入内存中
    Elf32_Off p_offset; /* Segment file offset */
    //段的文件内偏移(相对于ELF文件头而言)
    Elf32_Addr p_vaddr; /* Segment virtual address */
    //段的虚拟地址
    Elf32_Addr p_paddr; /* Segment physical address */
    //段的物理地址
    Elf32_Word p_filesz; /* Segment size in file */
    //段在ELF文件中的大小
    Elf32_Word p_memsz; /* Segment size in memory */
    //段在内存中的大小
    Elf32_Word p_flags; /* Segment flags */
    //段的标志位
    Elf32_Word p_align; /* Segment alignment */
    //地址对齐
    } Elf32_Phdr;

    #define PT_NULL 0 /* Program header table entry unused */
    #define PT_LOAD 1 /* Loadable program segment */
    #define PT_DYNAMIC 2 /* Dynamic linking information */
    #define PT_INTERP 3 /* Program interpreter */
    #define PT_NOTE 4 /* Auxiliary information */
    #define PT_SHLIB 5 /* Reserved */
    #define PT_PHDR 6 /* Entry for header table itself */
    #define PT_NUM 7 /* Number of defined types. */
    #define PT_LOOS 0x60000000 /* Start of OS-specific */
    #define PT_HIOS 0x6fffffff /* End of OS-specific */
    #define PT_LOPROC 0x70000000 /* Start of processor-specific */

    段在ELF文件中的大小和内存中的大小并不一样!这需要一些程序的基础知识。

    一个程序本质上都是由 bss 节、data 节、text 节三个组成的。

    1. .bss:未初始化的全局变量,静态内存分配
    2. .data:数据段,已初始化的全局变量,静态内存分配
    3. .text:代码段,程序执行代码。大小在程序运行前确定,通常只读。也可能包含一些只读的常数变量,例如字符串等等

    .test 和 .data 都在可执行文件中,系统从可执行文件中加载,而 .bss 不在可执行文件中,由系统初始化。

    内存中比ELF文件大的部分,都会初始化为0。

LinkerScript

运行的时候需要将内核加载在内存正确的位置上,这时我们需要 LinkerScript。首先,我们先了解一下 MIPS 内存布局。

MIPS 内存布局
image-20260402024843038
image-20260402024843038
  1. kuseg 0x00000000-0x7FFFFFFF(2 GB):这一段是用户态下唯一可用的地址空间(内核态下也可使用这段地址空间),大小为2GB,也就是MIPS约定的用户内存空间。需要通过 MMU(Memory Management Unit)中的 TLB 进行虚拟地址到物理地址的变换。对这段 地址的存取都会通过cache。
  2. kseg0 0x80000000-0x9FFFFFFF(512 MB):这一段是内核态下可用的地址,MMU 将地址的最高位清零(&0x7fffffff) 就得到物理地址用于访存。也就是说,这段的虚拟地址被连续 地映射到物理地址的低512MB空间。对这段地址的存取都会通过cache。
  3. kseg1 0xA0000000-0xBFFFFFFF(512 MB):与 kseg0 类似,这段地址也是内核态下可用的地址,MMU将虚拟地址的高三位清零(& 0x1fffffff) 就得到物理地址用于访存。这段虚拟地址也被连续地映射到物理地址的低 512 MB 空间。但是对这段地址的存取不通过 cache,往往在这段地址上使用MMIO(Memory-Mapped I/O)技术来访问外设。
  4. kseg2 0xC0000000-0xFFFFFFFF(1GB):这段地址只能在内核态下使用并且需要MMU中TLB将虚拟地址转换为物理地址。对这段地址的存取都会通过cache。

TLB 需要操作系统管理,但是我们正在载入操作系统内核,所以不可以使用。而 kseg1 是不经过cache的,一般来说,利用MMIO访问外设时才会使用kseg1。所以我们内核放在 kseg0。

cache 初始化前 bootloader 会放在 kseg1。kseg1中的 bootloader 在载入内核前会进行 cache 初始化工作。此后 kseg0 就可以被使用。

于是,让我们把内核放在正确的位置上吧!

LinkerScript

即 kernel.lds 文件,其主要任务是告知程序开始执行的虚拟地址,同时告知编译器应该如何把节放在什么位置。

不需要放入内核的节,一般默认位置是0。

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
32
33
34
/*kernel.lds*/
/*
* Set the architecture to mips.
*/
OUTPUT_ARCH(mips)

/*
* Set the ENTRY point of the program to _start.
* 设置入口地址是_start,即ELF头中的 e_entry 程序开始执行的虚拟内存地址
*/
ENTRY(_start)

SECTIONS {
/* Set the loading address of the text section to the location counter ".". */
. = 0x80020000;
/* “.”是一个特殊符号,用来做定位计数器,指向内存中的一个位置,可以根据节大小增长 */
/* Define the text section. */
.test : {
*(.test)
}
/* Define the data section. */
.data : {
*(.data)
}
bss_start = .;
/* 相当于把当前的内存地址存在了 bss_start 中,后续可通过该标签调用 */
/* Define the bss section. */
.bss : {
*(.bss)
}
bss_end = .;
. = 0x80400000;
end = . ;
}

运行

把内核放入正确位置之后,我们即可运行 make run

1
2
3
4
5
#Makefile
...
run:
$(QEMU) $(QEMU_FLAGS) -kernel $(mos_elf)
# mos_elf := $(target_dir)/mos

即用QEMU运行我们刚刚编译链接生成的 ./target/mos

我们刚刚已经了解到运行是从 _start 开始,这个在哪,其实在我们编译的./init/start.S中,这CPU控制权转交给内核后执行的第一个函数,初始化了CPU和栈指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*./init/start.S*/
#include <asm/asm.h>
#include <mmu.h>

.text
EXPORT(_start)
/*EXPORT是一个宏,将_start函数导出为一个符号,使得链接器可以找到它。可以理解为,其实现了一种在汇编语言中的函数定义。*/
.set at /* 允许使用at寄存器 */
.set reorder /* 允许对接下来的代码进行重排列 */
/* 将.bss段塞满0 */
la v0, bss_start
la v1, bss_end
clear_bss_loop:
beq v0, v1, clear_bss_done
sb zero, 0(v0)
addiu v0, v0, 1
j clear_bss_loop
clear_bss_done:
/* disable interrupts 通过修改cp0中的寄存器,禁止中断 */
mtc0 zero, CP0_STATUS
/* set up the kernel stack 把栈指针放在内存中内核栈顶,注意栈是从高地址向低地地址发展的 */
li sp, KSTACKTOP
/* jump to mips_init */
j mips_init
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//./init/start.S
#include <asm/asm.h>
#include <env.h>
#include <pmap.h>
#include <printk.h>
#include <sched.h>
#include <trap.h>

#ifdef MOS_INIT_OVERRIDDEN
#include <generated/init_override.h>
#else
void mips_init(u_int argc, char **argv, char **penv, u_int ram_low_size) {
printk("init.c:\tmips_init() is called\n");
halt();
}

#endif

其中调用了./kern/machine.c 中的halt()实现了停止整个系统。

同时还调用了./kern/printk.c 中的printk()函数

printk

目前,标答中 printk 无法正确打印 LONG_MIN。不知道后面会不会改。

./kern/printk.c 中的printk()函数:

1
2
3
4
5
6
7
//./kern/printk.c
void printk(const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
vprintfmt(outputk, NULL, fmt, ap);
va_end(ap);
}

参数中有 … 可见其不一般。这里使用了变长参数表。

变长的参数表,至少需要一个参数定位参数表的起始位置。参数是在栈上放置的,具体的实现依赖平台的规定。如果按照右边参数先入 栈的顺序,有:(图来源于配套PPT)

image-20260402034913406
image-20260402034913406

va_list 是变长参数表的变量类型

va_start(va_list ap, lastarg),用于初始化变长参数表的宏(lastarg是该函数最后一个命名的形式参数,即 … 前的最后一个参数)

va_arg(va_list ap,类型) 用于取变长参数表下一个参数的宏 (第二个参数是这次获取的参数的类型)

va_end(ap) 结束使用变长参数表的宏

使用变长参数需要注意

  1. 变长参数并不知道参数有几个,所以可能会出现越界访问

  2. 取变长参数表下一个参数必须要指定参数类型

    其中,在变长参数传递参数的时候 charshort 会以int的传递

    所以 char a = va_arg(va_list ap,char)是违规的

    我们可以 char a = (char)va_arg(va_list ap,int)来读取一个 char 变量

printk()函数调用了vprintfmt(outputk, NULL, fmt, ap);

vprintfmt 定义是void vprintfmt(fmt_callback_t out, void *data, const char *fmt, va_list ap) {}。其主要是解析作用,如面对printk("The number is %-ld", 1000)。vprintfmt 会将没有%的部分直接传给 out 函数输出,有%号的部分按照格式符要求生成相应字符串,再交给 out 函数输出。(格式符的原型为: %[flags][width][length]<specifier>)

vprintfmt 定义中 out 是回调函数,data 是其上下文(是 out 函数的参数),即 vprintfmt 只负责解析,out 函数负责输出,out 函数可以是输出到控制台,也可以是输出到指定文件中,此时指定文件的地址即是 data。out 输出到控制台的函数如下:

1
2
3
4
5
6
//./kern/printk.c
void outputk(void *data, const char *buf, size_t len) {
for (int i = 0; i < len; i++) {
printcharc(buf[i]);
}
}

其调用了printcharc()

1
2
3
4
5
6
7
8
9
//./kern/machine.c
void printcharc(char ch) {
if (ch == '\n') {
printcharc('\r');
}
while (!(*((volatile uint8_t *)(KSEG1 + MALTA_SERIAL_LSR)) & MALTA_SERIAL_THR_EMPTY)) {
}
*((volatile uint8_t *)(KSEG1 + MALTA_SERIAL_DATA)) = ch;
}

printcharc()对某一个内存地址写了一个字节,从而实现输出。

最终我们通过 make && make run 之后,就可以获得一个

1
init.c:  mips_init() is called

接下来是实验报告部分!

Lab1 实验报告

思考题

Thinking 1.1

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

针对hello.c

1
2
3
4
5
6
#include <stdio.h>

int main() {
puts("Hello, world!");
return 0;
}
  1. 使用实验环境中的原生 x86 工具链

    • 只进行预处理(通过-E选项),而不编译

      1
      2
      gcc -E hello.c > hello.c
      cat hello.c

      输出结果:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      ...
      typedef unsigned char __u_char;
      typedef unsigned short int __u_short;
      typedef unsigned int __u_int;
      typedef unsigned long int __u_long;


      typedef signed char __int8_t;
      typedef unsigned char __uint8_t;
      typedef signed short int __int16_t;
      typedef unsigned short int __uint16_t;
      typedef signed int __int32_t;
      typedef unsigned int __uint32_t;

      typedef signed long int __int64_t;
      typedef unsigned long int __uint64_t;

      ...
      int main() {
      puts("Hello, world!");
      return 0;
      }
    • 只编译而不链接

      1
      2
      3
      4
      gcc -g -c hello.c
      objdump --section=.text --disassemble=main --source\
      hello.o > test1
      cat test1

      其中 objdump命令中--disassemble表示反汇编所有代码;--source表示显示汇编代码与源代码的对应关系。

      输出结果:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      hello.o:     文件格式 elf64-x86-64


      Disassembly of section .text:

      0000000000000000 <main>:
      #include <stdio.h>

      int main() {
      0: f3 0f 1e fa endbr64
      4: 55 push %rbp
      5: 48 89 e5 mov %rsp,%rbp
      puts("Hello, world!");
      8: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # f <main+0xf>
      f: 48 89 c7 mov %rax,%rdi
      12: e8 00 00 00 00 call 17 <main+0x17>
      return 0;
      17: b8 00 00 00 00 mov $0x0,%eax
      }
      1c: 5d pop %rbp
      1d: c3 ret

      可见 puts 地址的位置上被填写了一串0

    • 进行链接,也就是正常地编译出可执行文件

      1
      2
      3
      gcc -g -static -o hello hello.c
      objdump --disassemble --source hello>test2
      cat test2

      其中 objdump命令中--section=.text表示仅处理.text 节的内容;--disassemble=main表示仅反汇编 main 符号的代码;--source表示显示汇编代码与源代码的对应关系。

      输出结果:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      hello1:     文件格式 elf64-x86-64
      ...
      Disassembly of section .text:
      ...
      00000000004018b5 <main>:
      #include <stdio.h>

      int main() {
      4018b5: f3 0f 1e fa endbr64
      4018b9: 55 push %rbp
      4018ba: 48 89 e5 mov %rsp,%rbp
      puts("Hello, world!");
      4018bd: 48 8d 05 4c d7 07 00 lea 0x7d74c(%rip),%rax # 47f010 <__rseq_flags+0xc>
      4018c4: 48 89 c7 mov %rax,%rdi
      4018c7: e8 64 34 00 00 call 404d30 <_IO_puts>
      return 0;
      4018cc: b8 00 00 00 00 mov $0x0,%eax
      }
      4018d1: 5d pop %rbp
      4018d2: c3 ret
      4018d3: 66 2e 0f 1f 84 00 00 cs nopw 0x0(%rax,%rax,1)
      4018da: 00 00 00
      4018dd: 0f 1f 00 nopl (%rax)

      call后面已经不再是一串0了。 那里已经被填入了一个地址。

  2. MIPS 交叉编译工具链

  • 只进行预处理(通过-E选项),而不编译

    1
    2
    mips-linux-gnu-gcc -E hello.c > hello.c
    cat hello.c

    输出结果:

    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
    ...
    typedef unsigned char __u_char;
    typedef unsigned short int __u_short;
    typedef unsigned int __u_int;
    typedef unsigned long int __u_long;


    typedef signed char __int8_t;
    typedef unsigned char __uint8_t;
    typedef signed short int __int16_t;
    typedef unsigned short int __uint16_t;
    typedef signed int __int32_t;
    typedef unsigned int __uint32_t;




    __extension__ typedef signed long long int __int64_t;
    __extension__ typedef unsigned long long int __uint64_t;

    ...
    int main() {
    puts("Hello, world!");
    return 0;
    }
  • 只编译而不链接

    1
    2
    3
    mips-linux-gnu-gcc -g -c hello.c
    mips-linux-gnu-objdump --section=.text --disassemble=main --source hello.o > test1
    cat test1

    其中 objdump命令中--disassemble表示反汇编所有代码;--source表示显示汇编代码与源代码的对应关系。

    输出结果:

    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
    32
    33
    hello.o:     文件格式 elf32-tradbigmips


    Disassembly of section .text:

    00000000 <main>:
    #include <stdio.h>

    int main() {
    0: 27bdffe0 addiu sp,sp,-32
    4: afbf001c sw ra,28(sp)
    8: afbe0018 sw s8,24(sp)
    c: 03a0f025 move s8,sp
    10: 3c1c0000 lui gp,0x0
    14: 279c0000 addiu gp,gp,0
    18: afbc0010 sw gp,16(sp)
    puts("Hello, world!");
    1c: 3c020000 lui v0,0x0
    20: 24440000 addiu a0,v0,0
    24: 8f820000 lw v0,0(gp)
    28: 0040c825 move t9,v0
    2c: 0320f809 jalr t9
    30: 00000000 nop
    34: 8fdc0010 lw gp,16(s8)
    return 0;
    38: 00001025 move v0,zero
    }
    3c: 03c0e825 move sp,s8
    40: 8fbf001c lw ra,28(sp)
    44: 8fbe0018 lw s8,24(sp)
    48: 27bd0020 addiu sp,sp,32
    4c: 03e00008 jr ra
    50: 00000000 nop

    lui addiu lw 指令都进行着关于0的运算,t9 寄存器中是0。可见 puts 地址的位置上被填写了一串0

  • 进行链接,也就是正常地编译出可执行文件

    1
    2
    3
    mips-linux-gnu-gcc -g -static -o hello hello.c
    mips-linux-gnu-objdump --section=.text --disassemble=main --source hello>test2
    cat test2

    其中 objdump命令中--section=.text表示仅处理.text 节的内容;--disassemble=main表示仅反汇编 main 符号的代码;--source表示显示汇编代码与源代码的对应关系。

    输出结果:

    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
    32
    33
    hello:     文件格式 elf32-tradbigmips


    Disassembly of section .text:

    00400790 <main>:
    #include <stdio.h>

    int main() {
    400790: 27bdffe0 addiu sp,sp,-32
    400794: afbf001c sw ra,28(sp)
    400798: afbe0018 sw s8,24(sp)
    40079c: 03a0f025 move s8,sp
    4007a0: 3c1c004a lui gp,0x4a
    4007a4: 279c8f80 addiu gp,gp,-28800
    4007a8: afbc0010 sw gp,16(sp)
    puts("Hello, world!");
    4007ac: 3c020046 lui v0,0x46
    4007b0: 24443a00 addiu a0,v0,14848
    4007b4: 8f828060 lw v0,-32672(gp)
    4007b8: 0040c825 move t9,v0
    4007bc: 041103c8 bal 4016e0 <_IO_puts>
    4007c0: 00000000 nop
    4007c4: 8fdc0010 lw gp,16(s8)
    return 0;
    4007c8: 00001025 move v0,zero
    }
    4007cc: 03c0e825 move sp,s8
    4007d0: 8fbf001c lw ra,28(sp)
    4007d4: 8fbe0018 lw s8,24(sp)
    4007d8: 27bd0020 addiu sp,sp,32
    4007dc: 03e00008 jr ra
    4007e0: 00000000 nop

    t9 寄存器已经不再是一串0了。 那里已经被填入了一个地址。

Thinking 1.2

思考下述问题:

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

    命令如下:

    1
    2
    make
    ./tools/readelf/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:0x80021a70
    3:0x80021a88
    4:0x80021aa0
    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
  2. ./readelf readelf 命令没有任何反应

    readelf -h readelf 的结果如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    ELF 头:
    Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
    类别: ELF64
    数据: 2 补码,小端序 (little endian)
    Version: 1 (current)
    OS/ABI: UNIX - System V
    ABI 版本: 0
    类型: DYN (Position-Independent Executable file)
    系统架构: Advanced Micro Devices X86-64
    版本: 0x1
    入口点地址: 0x1180
    程序头起点: 64 (bytes into file)
    Start of section headers: 14488 (bytes into file)
    标志: 0x0
    Size of this header: 64 (bytes)
    Size of program headers: 56 (bytes)
    Number of program headers: 13
    Size of section headers: 64 (bytes)
    Number of section headers: 31
    Section header string table index: 30

    我们可以看到 readelf 是 64 位的。

    在 tools/readelf 目录下的 Makefile 中,我们可以看到 readelf 与 hello 的不同:

    1
    2
    3
    4
    5
    6
    7
    8
    %.o: %.c
    $(CC) -c $<

    readelf: main.o readelf.o
    $(CC) $^ -o $@

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

    可以看到,我们通过 -m32 创建的 hello 是 32位的,而 readelf 默认创建是 64 位的。而我们的 readelf 只能解析32位的。

    经查询资料发现,系统工具 readelf 可以解析32位和64位的ELF文件格式。

    我们的线上实验中提供了可以解析64位的 readelf ,尝试用其解析,结果如下:

    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
    32
    33
    34
    ./readelf64 readelf64
    section_count=31
    [0] name="" offset=0x0 size=0
    [1] name=".interp" offset=0x318 size=28
    [2] name=".note.gnu.property" offset=0x338 size=48
    [3] name=".note.gnu.build-id" offset=0x368 size=36
    [4] name=".note.ABI-tag" offset=0x38c size=32
    [5] name=".gnu.hash" offset=0x3b0 size=40
    [6] name=".dynsym" offset=0x3d8 size=456
    [7] name=".dynstr" offset=0x5a0 size=246
    [8] name=".gnu.version" offset=0x696 size=38
    [9] name=".gnu.version_r" offset=0x6c0 size=64
    [10] name=".rela.dyn" offset=0x700 size=216
    [11] name=".rela.plt" offset=0x7d8 size=288
    [12] name=".init" offset=0x1000 size=27
    [13] name=".plt" offset=0x1020 size=208
    [14] name=".plt.got" offset=0x10f0 size=16
    [15] name=".plt.sec" offset=0x1100 size=192
    [16] name=".text" offset=0x11c0 size=962
    [17] name=".fini" offset=0x1584 size=13
    [18] name=".rodata" offset=0x2000 size=182
    [19] name=".eh_frame_hdr" offset=0x20b8 size=68
    [20] name=".eh_frame" offset=0x2100 size=288
    [21] name=".init_array" offset=0x2d60 size=8
    [22] name=".fini_array" offset=0x2d68 size=8
    [23] name=".dynamic" offset=0x2d70 size=496
    [24] name=".got" offset=0x2f60 size=160
    [25] name=".data" offset=0x3000 size=16
    [26] name=".bss" offset=0x3010 size=16
    [27] name=".comment" offset=0x3010 size=43
    [28] name=".symtab" offset=0x3040 size=1224
    [29] name=".strtab" offset=0x3508 size=743
    [30] name=".shstrtab" offset=0x37ef size=282
    symtab_index=28

Thinking 1.3

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

在真实的 MIPS 硬件体系结构中,CPU 刚上电时,确实会强制将 PC(程序计数器)指向 0xBFC00000 这个固定的物理硬件地址并开始执行指令。但是,放在这个地址上的代码并不是操作系统内核,而是 Bootloader(启动引导程序,类似 PC 上的 BIOS)。

  • 第一阶段(Bootloader 执行):CPU 上电后执行位于 0xBFC00000 的 Bootloader。Bootloader 会进行基本的硬件初始化,然后负责将存储在磁盘或 Flash 中的操作系统内核镜像,按照既定的内存布局图,加载到主存的正确位置中。
  • 第二阶段(内核执行): Bootloader 将内核加载到内存后,会通过一条跳转指令,把 CPU 的控制权(PC 寄存器)直接指向操作系统内核的入口地址(即我们在 kernel.lds 中定义的 _start)。此时,内核才真正开始运行。

因此,内核本身不需要硬编码放置在 0xBFC00000,它只需要静静地待在它该在的内存位置,等待 Bootloader 来跳转到它即可。

进行实验的硬件仿真平台 QEMU 已经提供了 bootloader 的引导功能,所以启动被简化为:加载ELF格式内核到内存,QEMU 直接读取 ELF 文件头中记录的入口地址(Entry Point,即 _start 的地址),并把模拟 CPU 的 PC 寄存器直接设置为该地址。因此,实验操作系统的内核入口并没有放在上电启动地址,也能保证内核入口被正确跳转到。

难点分析

  1. 首先,我觉得非常难以理解的是./Makefile,其干的事情又多又杂,无数依赖文件相互嵌套,陌生语法也很多,需要理很久
  2. 第二,我觉得 printk 函数也很难理解,其需要很多个函数的调用,且函数很长,c语言已经忘得差不多了,同时其还涉及变长参数和回调函数的理解

实验体会

lab1 给我的感觉就是东西很多很杂,感觉是操作系统的整个基础,掌握又非常的重要。感觉指导书是按照“面向对象”的逻辑去写的,但是我感觉“面向过程”的逻辑会更好理解一些。对实验平台的使用还是不是很熟练,这么多函数调用的情况下,我竟然忘记使用 grep 什么的了。在这里说一下实验过程中要注意的地方吧。

课下

Exercise 1.1
  1. c语言中,把一个指针和一个整数相加时,编译器会自动乘以指针所指类型的大小。

    所以sh_table = ehdr->e_shoff + ehdr是错误的,其相当于ehdr + (e_shoff * sizeof(Elf32_Ehdr))

    GCC 编译器作为扩展,默认允许对 void * 做加减并将其当做 1 字节处理,除此之外,按字节移动的指针类型还有 char *等等

  2. Elf32_Off sh_offset 是节的文件内偏移,是相对于ELF文件头而言,段同理。

Exercise 1.3
  1. 栈的空间是高地址向低地址延伸的
  2. 跳转到 mips_init 函数,直接用j就可以了,不用jal,因为其不需要返回
Exercise 1.4
  1. 我当时的第一想法是,直接利用switch-case中的default处理非%的字符,发现这个完全行不通,因为当输入”I see u”的时候会落入到case 's'case 'u'中,导致va_arg()访问越界

课上-exam

进行一个题目的照搬。

题目背景

符号表(.symtab 节)是 ELF 文件中的一个重要节,记录了程序中的符号信息(如函数名、变量名等等),在链接和调试等过程中都有很重要的作用。因此,本题要求你实现一个简化版的 readelf64输出 ELF 文件中所有节头的相关信息,并找出符号表(.symtab 节)对应节头在节头表中的下标

课下练习中,我们已经实现过一个简化版 readelf,用于解析 32-bit little-endian ELF 文件 并输出节头地址。本题在此基础上进一步扩展。出于实用性考虑,要求同学们解析 64-bit little-endian ELF 文件

需要注意的是,64 位 ELF 的整体解析思路与 32 位版本基本一致,主要差别在于所使用的数据结构和部分字段类型不同。更具体地说,本题中最直接的变化是:原先使用的 Elf32_ 系列结构体/变量,需要相应替换为 Elf64_ 系列结构体/变量。其余区别同学们在本题中无需考虑。

在 ELF 文件中,节头表中的每一个表项都描述了一个节(section)的信息,包括该节在文件中的偏移、大小、类型等。每个节头表表项都对应一个 Elf64_Shdr 结构体。通过解析节头表,我们可以获得 ELF 文件中所有节的基本信息。

本题要求你实现一个简化版的 readelf64,输出:

  1. 该 ELF 文件中的 总节数(即节头表表项数量)
  2. 符号表(.symtab 节)对应节头 在节头表中的下标
  3. 每一节的 名称(后文讲解获取方法)、文件偏移(sh_offset)和大小(sh_size
相关知识说明
A. ELF64 文件头(Elf64_Ehdr *ehdr,与 32 位版本相对应)

ELF 文件开头为 ELF 文件头(ELF Header),它描述了这个 ELF 文件的整体布局信息。

它的作用是:告诉我们后续该去哪里找 节头表、节名称字符串表 等关键信息。

在本题中,你只需要 重点关注该结构体的以下字段

  • e_shoff:节头表在 文件中的偏移
  • e_shnum:节头表表项的数量;
  • e_shstrndx节名称字符串表 的索引(即,其对应节头在节头表中的下标, 相关知识说明 C 中会展开讲解)

对应到实现中,可以通过 e_shoffe_shnum 拿到与遍历节头表,用 e_shstrndx 定位 节名称字符串表

B. 节头表与节头结构体(Elf64_Shdr *sh_table,与 32 位版本相对应)

节头表(Section Header Table,代码中对应 sh_table,可以理解为 “全体节的目录”

在本题中,可以直接将节头表视为由 Elf64_Shdr 构成的数组,其作用是:按顺序记录 ELF 文件中每一个节(section)的关键信息,便于程序遍历和查询。

节头表里的每一个表项都对应一个 Elf64_Shdr 结构体(也称为节头)。若将节头表 sh_table 视为数组,则节头包括:sh_table[0], sh_table[1], … … , sh_table[e_shnum-1]。也就是说,每个 Elf64_Shdr 结构体描述一个节的信息。

它们的作用是告诉我们每个节的名字、内容在文件中的位置、大小等信息。

本题 重点关注该结构体的以下字段

  • sh_name:节名称在 节名称字符串表内容字符串 中的 偏移
  • sh_offset:该节在 文件中的偏移
  • sh_size:该节的大小。

注意:sh_name 不是字符串指针,而是一个 偏移量。要想获得节名,需要先找到 节名称字符串表内容字符串

对应到实现时,你需要遍历所有 Elf64_Shdr,利用 sh_name 解析节名,而后输出节名及该节的 sh_offsetsh_size

C. 节名称字符串表

节名称字符串表(通常为 .shstrtab 节),本身也是 ELF 文件中的一个节。

它的内容本质上是一个由 \0 分隔的字符串池,存放所有节名。

例如,其内容字符串可能类似(不含双引号 "):

1
"\0.text\0.data\0.bss\0.symtab\0.strtab\0.shstrtab\0..."

它的作用是:配合每个节头里的 sh_name 偏移,解析出真实节名。

以上述节名称字符串表为例,若某个节头的 sh_name1,则该节的名称为 .text;若 sh_name13,则该节的名称为 .bss


那么如何找到节名称字符串表,又如何借此获取某一节的节名称呢?

注意,ELF 文件头中的 e_shstrndx 表示 节名称字符串表 对应 节头节头表 中的下标。

由此,给出获取某一节 节名称 的过程:

  1. 根据 e_shoff 找到节头表( Elf64_Shdr *sh_table = ((char *)binary + ehdr->e_shoff));
  2. 取出下标为 e_shstrndx 的那个节头Elf64_Shdr *shstrShdr = &sh_table[ehdr->e_shstrndx]);
  3. 通过该节头的 sh_offset(内容在文件中的偏移),找到 节名称字符串表内容字符串 的位置,存到 char *shstrtab(请大家参考 1. 中 e_shoff 的用法,完成相关代码);
  4. 对于某一节(若其对应节头为 shdr),其节名可由 char *sectionName = shstrtab + shdr->sh_name 得到。
题目要求

本题要求你阅读 tools/readelf64 目录下文件,并补全代码。

重点查看以下文件

  • elf64_mini.h:给出了本题涉及的若干 ELF64 相关结构体/变量的定义供参考;
  • readelf64.c:本题需要补全的核心代码。

你需要在 tools/readelf64/readelf64.c 中补全代码,完成以下功能:

  1. 读取 ELF64 文件头
  2. 定位 节头表
  3. 通过 节名称字符串表 解析 节头的名称
  4. 遍历所有节头,输出每一节的:
    • 序号(从 0 开始)
    • 节名称section_name
    • 文件偏移 sh_offset
    • 大小 sh_size
  5. 在遍历过程中,找到名字为 .symtab 的节,并输出它在节头表中的序号(从 0 开始计算)。
输出格式

要求你的程序输出格式如下:

1
2
3
4
5
6
section_count=<count>
[0] name="<section_name>" offset=0x<offset> size=<size>
[1] name="<section_name>" offset=0x<offset> size=<size>
...
[<count-1>] name="<section_name>" offset=0x<offset> size=<size>
symtab_index=<index>

其中:

  • 第一行 <count> 为节头总数,即节头表表项的数量;
  • 最后一行 <index> 为名称恰好等于 .symtab 的节头序号;
  • 从第二行起,[i] 表示第 i 个节头的信息;
    • <offset> 为该节的 sh_offset,以小写十六进制输出,带 0x 前缀,不补前导零;
    • <size> 为该节的 sh_size,按十进制输出;
    • 即使某个节名称为空字符串,也应正常输出。

输出节头信息的格式串请严格使用:

1
"[%d]\tname=\"%s\"\toffset=0x%lx\tsize=%lu\n"
进行一个答案的照搬
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
#include "elf64_mini.h"
#include <stdio.h>
#include <string.h>

/* Overview:
* Check whether specified buffer is valid ELF64 data.
*
* Pre-Condition:
* The memory within [binary, binary+size) must be valid to read.
*
* Post-Condition:
* Returns 0 if 'binary' isn't an ELF64, otherwise returns 1.
*/
static int is_elf_format(const Elf64_Ehdr *ehdr, size_t size) {
return ehdr && size >= sizeof(Elf64_Ehdr) && ehdr->e_ident[EI_MAG0] == ELFMAG0 &&
ehdr->e_ident[EI_MAG1] == ELFMAG1 && ehdr->e_ident[EI_MAG2] == ELFMAG2 &&
ehdr->e_ident[EI_MAG3] == ELFMAG3 && ehdr->e_ident[EI_CLASS] == ELFCLASS64;
}

/* Overview:
* Parse ELF64 section headers and print section info.
*
* Pre-Condition:
* The memory within [binary, binary+size) must be valid to read.
*
* Post-Condition:
* Return 0 if success. Otherwise return < 0.
*/

int readelf64(const void *binary, size_t size) {
const Elf64_Ehdr *ehdr = NULL;
// 设置 ELF64 文件头 ehdr
// ----- Lab1-Exam: Your code here. (1/5) -----
ehdr = (Elf64_Ehdr *)binary;
// 检查文件头 ehdr,判断文件是否为 ELF64 文件
if (!is_elf_format(ehdr, size)) {
fputs("not an elf64 file\n", stderr);
return 0;
}

unsigned int section_count;
// 读取节头总数 section_count
// ----- Lab1-Exam: Your code here. (2/5) -----
section_count = ehdr->e_shnum;
const Elf64_Shdr *sh_table; // 节头表
const Elf64_Shdr *shstrShdr; // 节名称字符串表的节头
const char *shstrtab; // 节名称字符串表的内容字符串

// 获取节头表地址 sh_table
sh_table = (const Elf64_Shdr *)((char *)binary + ehdr->e_shoff);

// 【提示:请大家仔细阅读题面,理解 相关知识说明 中 C. 节名称字符串表 的知识点。】
// 在下方完成 节名称字符串表的节头 shstrShdr 与 节名称字符串表的内容字符串 shstrtab 的获取。
// ----- Lab1-Exam: Your code here. (3/5) -----
shstrShdr = &sh_table[ehdr->e_shstrndx];
shstrtab = (const char *)((char *)binary + shstrShdr->sh_offset);
const char *sectionName; // 提取节名称用的临时变量
int symtabIndex = -1; // 记录符号表.symtab的节头在节头表中的索引(下标)
unsigned int i;
printf("section_count=%u\n", section_count);

// 输出每个节信息,并在遇到符号表.symtab时记录下标
for (i = 0; i < section_count; i++) {
sectionName = shstrtab + sh_table[i].sh_name;

int matchSymtab;
// 判断当前节名称是否为 ".symtab",如果是则matchSymtab为非0,否则为0
// ----- Lab1-Exam: Your code here. (4/5) -----
char *name = ".symtab";
if(strcmp(sectionName, name) == 0)
{
matchSymtab = 1;
}
else {
matchSymtab = 0;
}


if (matchSymtab) {
// 记录符号表.symtab的节头在节头表中的索引(下标)
// ----- Lab1-Exam: Your code here. (5/5) -----
symtabIndex = i;
}

// 提示:请在【至少完成 Lab1-Exam: Your code here. (3/5)】后,
// 取消下方 `printf(...);` 两行的注释,以输出每个节的信息(节名称、sh_offset、sh_size);
// 提前取消注释会访问非法内存地址而导致程序崩溃。
printf("[%d]\tname=\"%s\"\toffset=0x%lx\tsize=%lu\n", i, sectionName,
(unsigned long)sh_table[i].sh_offset, (unsigned long)sh_table[i].sh_size);
}

if (symtabIndex < 0) {
fputs("No .symtab found\n", stderr);
return 0;
}

printf("symtab_index=%d\n", symtabIndex);

return 0;
}

课上-extra

题目背景

在操作系统内核中实现类似于 scanf 的格式化输入功能时,我们需要处理来自外部设备(如键盘、串口)的字符流。由于我们缺乏庞大的标准 C 库,你需要亲手实现一个精简版的格式化解析函数。

本题需要精准实现标准格式控制符(%c%u%s)对字符流的“吞吐行为”。 由于在解析变长数据(如读取数字和字符串)时,程序通常需要多读一个不属于该格式的字符才知道当前读取已经结束。因此,如何妥善保管这个“多读出来”的字符,不让它被丢弃,使得下一个格式符能继续正常解析,是本次实现的关键。

核心解析规则

你需要实现以下三种格式说明符。请严格遵循它们对“前导空白符”和“结束边界”的处理逻辑:

空白符定义:空格 ' '、水平制表符 \t、换行符 \n、回车符 \r

  1. %c(单字符)
    • 前导空白绝不跳过。它会严格读取当前的下一个字符,无论是不是空白符。
    • 结束边界:读取 1 个字符后立即结束。
    • 状态要求:由于字符已被 %c 实体消耗,你需要强制要求系统在下一次解析时重新去读取新字符(即将缓冲区置为无效)。
  2. %u(无符号十进制整数)
    • 前导空白必须跳过所有的前导空白符。
    • 解析行为:匹配连续的数字(支持跳过前导加号 +,如 +10 解析为 10)。如果遇到的第一个非空白字符就不是数字或 +,直接赋值为 0 并结束。
    • 结束边界:遇到第一个非数字字符时停止。
    • 状态要求:导致停止的那个“非数字字符”必须原封不动地留在缓冲区中,等待下一个格式符处理。
    • 数值范围:[0 , 2147483647) (小于int范围)
  3. %s(标准字符串)
    • 前导空白必须跳过所有的前导空白符。
    • 解析行为:连续读取非空白字符,并将其存入指针指向的内存中。
    • 结束边界:遇到第一个空白符\0 时停止,并在末尾自动补充 \0 封口。
    • 状态要求:导致停止的那个“空白符”必须原封不动地留在缓冲区中,等待下一个格式符处理。
    • 保证 0<字符串长度<20
你的任务(Task List)

请按顺序执行以下修改任务:

任务 1:添加头文件声明

include/printk.h 中加入以下声明(要加到#define _printk_h_#endif /* _printk_h_ */的内部):

1
2
// include/printk.h
int scank(const char *fmt, ...);

include/print.h 中加入以下声明(要加到#define _print_h_#endif的内部):

1
2
3
// include/print.h
typedef void (*scan_callback_t)(void *data, char *buf, size_t len);
int vscanfmt(scan_callback_t in, void *data, const char *fmt, va_list ap);
任务 2:实现顶层包装函数

kern/printk.c 中添加以下代码,实现字符流获取与 scan 的包装:

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
// kern/printk.c
// 帮助函数,用于从控制台获取长度为 len 的字符,并写入 buf 中。
void inputk(void *data, char *buf, size_t len) {
for (int i = 0; i < len; i++) {
// 1. 轮询等待键盘输入,没有输入就死等
while ((buf[i] = scancharc()) == '\0') {
}

// 2. 统一回车符:将 '\r' 转成 '\n',方便后续的 vscanfmt 识别空白符
if (buf[i] == '\r') {
buf[i] = '\n';
}

// 3. 终端回显:读到了什么,就立刻印在屏幕上
printcharc(buf[i]);
}
}

int scank(const char *fmt, ...) {
/* ---------------------------------- */
/* Lab 1-extra: Your code here. (1/4) */
/* 提示:
1. 仿照 printk 的实现,使用 va_list 处理可变参数。
2. 调用 vscanfmt(传入刚写好的 inputk 回调函数)。
3. 本函数的返回值 ret 应为 vscanfmt 的执行结果。
*/
/* ---------------------------------- */

}
任务 3:完成核心解析状态机

lib/print.c 中添加并补全以下代码。

注:骨架代码已经提供了单字节缓冲区 ch 和有效标志位 ch_valid 的定义,请利用它们完成【核心解析规则】中要求的行为逻辑。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
// lib/print.c
#include <stdarg.h>

// ==========================================
// 辅助函数:确保缓冲区里有字符
// ==========================================
static inline void ensure_char(scan_callback_t in, void *data, char *ch, int *ch_valid) {
if (!*ch_valid) {
in(data, ch, 1);
*ch_valid = 1;
}
}

// ==========================================
// 辅助函数:跳过前导空白符
// ==========================================
static inline void skip_whitespace(scan_callback_t in, void *data, char *ch, int *ch_valid) {
ensure_char(in, data, ch, ch_valid);
while (*ch == ' ' || *ch == '\t' || *ch == '\n' || *ch == '\r') {
// 直接覆盖当前字符,不需要改 ch_valid 状态,因为它一直握着有效的最新字符
in(data, ch, 1);
}
}

// ==========================================
// 处理 %c:读第一个输入字符,无论是不是空白
// ==========================================
void scan_c(scan_callback_t in, void *data, char *ch, int *ch_valid, char *cp) {
/* ---------------------------------- */
/* Lab 1-extra: Your code here. (2/4) */
/* 提示:
1. 使用辅助函数确保缓冲区里有字符(注意:%c 绝不能跳过空白符!)
2. 将读取到的字符存入 cp 指向的内存。
3. 核心:因为该字符已被 %c 实体吃掉,必须将 缓存标志位 置为无效(值为0)。
*/
/* ---------------------------------- */

}

// ==========================================
// 处理 %s:跳过前导空白,读到空白符终止
// ==========================================
void scan_s(scan_callback_t in, void *data, char *ch, int *ch_valid, char *cp) {
/* ---------------------------------- */
/* Lab 1-extra: Your code here. (3/4) */
/* 提示:
1. 使用辅助函数跳过所有的前导空白符。
2. 连续读取非空白字符并存入 cp(可以在循环内部使用 *cp++ = *ch 进行赋值)。
3. 遇到空白符或 '\0' 时停止读取。
4. 别忘了在字符串末尾添加 '\0' 封口。
5. 停下时:ch 里必须正好握着导致停止的“空白符”,且保持 缓存标志位 置为有效(值为1)。
*/
/* ---------------------------------- */

}

// ==========================================
// 处理 %u:跳过前导空白,读到非数字终止
// ==========================================
void scan_u(scan_callback_t in, void *data, char *ch, int *ch_valid, int *ip) {
/* ---------------------------------- */
/* Lab 1-extra: Your code here. (4/4) */
/* 提示:
1. 定义变量用于累加数字计算结果。
2. 使用辅助函数跳过所有的前导空白符。
3. 兼容可选的前导 '+' 号(至多1个)(如果有,调用 in() 越过它)。
4. 如果遇到的第一个非空白字符就不是数字或 `+`,直接赋值为 `0` 并结束。
5. 连续读取数字字符('0'-'9'),并 计算 十进制数值。
6. 遇到非数字字符时停止读取,并将结果存入 ip。
7. 停下时:ch 里必须正好握着导致停止的“非数字字符”,且保持 缓存标志位 置为有效(值为1)。
*/
/* ---------------------------------- */

}

// ==========================================
// 主入口:vscanfmt
// ==========================================
int vscanfmt(scan_callback_t in, void *data, const char *fmt, va_list ap) {
char ch;
int ch_valid = 0; // 缓存标志位
int ret = 0;

while (*fmt) {
if (*fmt == '%') {
fmt++; // 跳过 '%'

switch (*fmt) {
case 's':
scan_s(in, data, &ch, &ch_valid, va_arg(ap, char *));
ret++;
break;

case 'u':
scan_u(in, data, &ch, &ch_valid, va_arg(ap, int *));
ret++;
break;

case 'c':
scan_c(in, data, &ch, &ch_valid, va_arg(ap, char *));
ret++;
break;


}
}
fmt++;
}
return ret;
}
进行一个答案的照搬
1
2
3
4
5
6
7
8
// kern/printk.c
int scank(const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
int ret = vscanfmt(inputk, NULL, fmt, ap);
va_end(ap);
return ret;
}
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// lib/print.c
static inline void ensure_char(scan_callback_t in, void *data, char *ch, int *ch_valid) {
if (!*ch_valid) {
in(data, ch, 1);
*ch_valid = 1;
}
}

static inline void skip_whitespace(scan_callback_t in, void *data, char *ch, int *ch_valid) {
ensure_char(in, data, ch, ch_valid);
while (*ch == ' ' || *ch == '\t' || *ch == '\n' || *ch == '\r') {
in(data, ch, 1);
}
}

void scan_c(scan_callback_t in, void *data, char *ch, int *ch_valid, char *cp) {
ensure_char(in, data, ch, ch_valid);
*cp = *ch;
*ch_valid = 0;
}

void scan_s(scan_callback_t in, void *data, char *ch, int *ch_valid, char *cp) {
skip_whitespace(in, data, ch, ch_valid);
while (*ch != ' ' && *ch != '\t' && *ch != '\n' && *ch != '\r' && *ch != '\0') {
*cp++ = *ch;
in(data, ch, 1);
}
*cp = '\0';
*ch_valid = 1;
}

void scan_u(scan_callback_t in, void *data, char *ch, int *ch_valid, int *ip) {
int num = 0;
skip_whitespace(in, data, ch, ch_valid);
if(*ch == '+')
{
in(data, ch, 1);
}
while ((*ch >= '0') && (*ch <= '9'))
{
num = num * 10 + *ch - '0';
in(data, ch, 1);
}
*ip = num;
*ch_valid = 1;
}

int vscanfmt(scan_callback_t in, void *data, const char *fmt, va_list ap) {
char ch;
int ch_valid = 0; // 缓存标志位
int ret = 0;

while (*fmt) {
if (*fmt == '%') {
fmt++; // 跳过 '%'

switch (*fmt) {
case 's':
scan_s(in, data, &ch, &ch_valid, va_arg(ap, char *));
ret++;
break;

case 'u':
scan_u(in, data, &ch, &ch_valid, va_arg(ap, int *));
ret++;
break;

case 'c':
scan_c(in, data, &ch, &ch_valid, va_arg(ap, char *));
ret++;
break;


}
}
fmt++;
}
return ret;
}
本作品由 一一 于 2026-04-02 19:00:00 发布
作品地址:北航os操作系统lab1
除特别声明外,本站作品均采用 CC BY-NC-SA 4.0 许可协议,转载请注明来自 anAilurus' Blog
Logo
下一篇北航oo面向对象设计与构造第一单元总结