BUAA-OS-lab1

Dawn

这一个lab的标题是内核、启动和printf,看得出相当的大杂烩。

内核和启动

这个lab要初步实现一个比较简易的操作系统。

Makefile

课程组给出了数量可观的代码,可以从顶层 Makefile 入手阅读。 Makefile 的作用在于方便增量编译和管理依赖关系。

55行前给出了一堆宏,通过这些宏可以实现配置QEMU模拟器参数和根据实验进度调整编译配置。从lab2到视角回看需要注意以下几个宏:

  • link_script := kernel.lds : 指定链接脚本,决定内核代码在内存中的具体布局
  • modules := lib init kern : 源码目录列表,意味是内核的核心代码分布在 lib(库函数)、init(初始化代码)和 kern(内核核心逻辑)这三个子目录中
  • targets := $(mos_elf) : 定义这次编译任务最终要生成的目标文件
  • objects := $(addsuffix /*.o, $(modules)) $(addsuffix /*.x, $(user_modules)) : 汇合已经编译好的二进制目标文件(.o.x
  • .PHONY 表明列在后面的东西不受make工具关于时间戳的约束,保证调用时被执行

为什么是从lab2视角呢,因为之前太懒了不想写博客拖到五一才动手💤。

55行后给出了具体的编译规则,大体可以分为递归编译 modules 列表里的文件、当所有子模块都编译完后调用链接器 $(LD) 和运行调试接口三部分。

放一张chatgpt生成的依赖关系图,仅供参考。

mos_makefile_dependency

文件目录

  • init 初始化内核,其中:
    • start.S 初始化CPU和栈指针,跳转到 mips_init() 函数
    • init.c 实现 mips_init() 函数,调用内核中各模块的初始化函数
  • include 存放系统头文件
  • lib 存放常用库函数
  • kern 存放内核主体代码
  • tests 存放测试代码

ELF

ELF文件的解析是一个考点,换言之要知道相关结构体如何操作。这部分的内容都在 tools/elf/ 文件夹中,相关结构体定义在其中 elf.h 文件里。

elf.h 文件31~52行规定了一些字段的大小,接着58~119行定义 Elf32_Ehdr 用于存储ELF文件头, Elf32_Shdr 用于存储段头,Elf32_Phdr 用于存储节头,定义了魔数验证相关字段。

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
111
112
113
114
115
116
117
118
119
120
121
122
123
#ifndef _ELF_H
#define _ELF_H

#include <stdint.h>

/* * 来自 GNU C 库的 ELF 定义文件。
* 我们为实验简化了此文件,移除了我们不关心的 ELF64、结构体和枚举定义。
*/

/* 16 位数量类型 */
typedef uint16_t Elf32_Half;

/* 有符号和无符号的 32 位数量类型 */
typedef uint32_t Elf32_Word;
typedef int32_t Elf32_Sword;

/* 有符号和无符号的 64 位数量类型 */
typedef uint64_t Elf32_Xword;
typedef int64_t Elf32_Sxword;

/* 地址类型 */
typedef uint32_t Elf32_Addr;

/* 文件偏移类型 */
typedef uint32_t Elf32_Off;

/* 节索引(Section indices)类型,为 16 位数量 */
typedef uint16_t Elf32_Section;

/* 符号索引类型 */
typedef uint32_t Elf32_Symndx;

/* 实验 1 关键代码 "readelf-struct-def" */

/* ELF 文件头。它出现在每个 ELF 文件的开头。 */

#define EI_NIDENT (16)

typedef struct {
unsigned char e_ident[EI_NIDENT]; /* 魔数和其他信息(如位数、大小端等) */
Elf32_Half e_type; /* 目标文件类型(如可执行文件、重定位文件等) */
Elf32_Half e_machine; /* 目标架构(如 MIPS, x86 等) */
Elf32_Word e_version; /* 目标文件版本 */
Elf32_Addr e_entry; /* 程序入口点的虚拟地址 */
Elf32_Off e_phoff; /* 程序头表在文件中的偏移量 */
Elf32_Off e_shoff; /* 节头表在文件中的偏移量 */
Elf32_Word e_flags; /* 处理器特定的标志 */
Elf32_Half e_ehsize; /* ELF 文件头自身的大小(以字节为单位) */
Elf32_Half e_phentsize; /* 程序头表中单个条目(Entry)的大小 */
Elf32_Half e_phnum; /* 程序头表中条目的数量 */
Elf32_Half e_shentsize; /* 节头表中单个条目(Entry)的大小 */
Elf32_Half e_shnum; /* 节头表中条目的数量 */
Elf32_Half e_shstrndx; /* 节头字符串表在节头表中的索引 */
} Elf32_Ehdr;

/* * e_ident 数组中的字段。
* EI_* 宏是该数组的索引。
* 每个 EI_* 宏下方的宏是该字节可能具有的值。
*/

#define EI_MAG0 0 /* 文件标识第 0 字节索引 */
#define ELFMAG0 0x7f /* 魔数第 0 字节 */

#define EI_MAG1 1 /* 文件标识第 1 字节索引 */
#define ELFMAG1 'E' /* 魔数第 1 字节 */

#define EI_MAG2 2 /* 文件标识第 2 字节索引 */
#define ELFMAG2 'L' /* 魔数第 2 字节 */

#define EI_MAG3 3 /* 文件标识第 3 字节索引 */
#define ELFMAG3 'F' /* 魔数第 3 字节 */

/* 节(Section)头。 */
typedef struct {
Elf32_Word sh_name; /* 节名称(字符串表中的索引) */
Elf32_Word sh_type; /* 节类型(如代码段、数据段、符号表等) */
Elf32_Word sh_flags; /* 节标志(如可写、分配、可执行等) */
Elf32_Addr sh_addr; /* 节在内存中的虚拟地址 */
Elf32_Off sh_offset; /* 节在文件中的偏移量 */
Elf32_Word sh_size; /* 节的大小(以字节为单位) */
Elf32_Word sh_link; /* 关联到其他节的索引 */
Elf32_Word sh_info; /* 额外的节信息 */
Elf32_Word sh_addralign; /* 节的地址对齐要求 */
Elf32_Word sh_entsize; /* 如果节包含固定大小的条目,则为每个条目的大小 */
} Elf32_Shdr;

/* 程序(Program)段头。 */

typedef struct {
Elf32_Word p_type; /* 段类型(如可加载段、动态链接信息等) */
Elf32_Off p_offset; /* 该段在文件中的偏移量 */
Elf32_Addr p_vaddr; /* 该段在内存中的虚拟地址 */
Elf32_Addr p_paddr; /* 该段在内存中的物理地址(通常与虚拟地址相同) */
Elf32_Word p_filesz; /* 该段在文件中的大小 */
Elf32_Word p_memsz; /* 该段在内存中的大小(可能大于文件大小,如 BSS 段) */
Elf32_Word p_flags; /* 段标志(读、写、执行) */
Elf32_Word p_align; /* 段在内存和文件中的对齐要求 */
} Elf32_Phdr;
/* 关键代码 "readelf-struct-def" 结束 */

/* p_type(段类型)的合法值。 */

#define PT_NULL 0 /* 程序头表条目未使用 */
#define PT_LOAD 1 /* 可加载的程序段 */
#define PT_DYNAMIC 2 /* 动态链接信息 */
#define PT_INTERP 3 /* 程序解释器(路径名) */
#define PT_NOTE 4 /* 辅助信息 */
#define PT_SHLIB 5 /* 保留 */
#define PT_PHDR 6 /* 程序头表自身在该段中的条目 */
#define PT_NUM 7 /* 已定义类型的数量 */
#define PT_LOOS 0x60000000 /* 操作系统特定范围的起点 */
#define PT_HIOS 0x6fffffff /* 操作系统特定范围的终点 */
#define PT_LOPROC 0x70000000 /* 处理器特定范围的起点 */
#define PT_HIPROC 0x7fffffff /* 处理器特定范围的终点 */

/* p_flags(段标志)的合法值。 */

#define PF_X (1 << 0) /* 段可执行 */
#define PF_W (1 << 1) /* 段可写 */
#define PF_R (1 << 2) /* 段可读 */
#define PF_MASKPROC 0xf0000000 /* 处理器特定掩码 */

#endif /* elf.h */

考点主要有从ELF头中找到节表头/某节/段表头/某段,打印对应的字段等等。以下以26exam代码为例。

Exam

题目要求输出:

  1. 该ELF文件总节数 ➡️ 读 Elf64_Ehdr 结构体
  2. .symtab 节对应节头在节头表中下标 ➡️ 涉及字符串匹配
  3. 每节名称、文件偏移 sh_offset 和大小 sh_size ➡️ 读对应的 Elf64_Shdr 结构体

注意到 e_shstrndx 是节头字符串表在节头表中的索引,即 Elf32_Shdr 中的 sh_name 字段其实存储在 Section[e_shstrndx] 这一节头表对应的内存中,代码中为从 shstrtab 开始的一块内存。注意到 shstrtabchar* 类型,即以存储地址的大小为大小偏移,所以 sectionName = shstrtab + sh_table[i].sh_name 可以轻松得到存储节名的地址。

此处涉及字符串匹配,别忘了 strcmp 以及 string.h 里的一堆函数。

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
int readelf64(const void *binary, size_t size) {
const Elf64_Ehdr *ehdr = NULL;

ehdr=(const Elf64_Ehdr*)binary;

if (!is_elf_format(ehdr, size)) {
fputs("not an elf64 file\n", stderr);
return 0;
}

unsigned int section_count;
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);

//获取节名称字符串表的节头 shstrShdr 与 节名称字符串表的内容字符串 shstrtab
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) -----
matchSymtab=strcmp(sectionName,".symtab")==0;
if (matchSymtab) {
// 记录符号表.symtab的节头在节头表中的索引(下标)
symtabIndex=i;
}

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;
}

MIPS内存布局

  • kuseg 0x0000_0000~0x8fff_ffff
  • kseg0 0x8000_0000~0x9fff_ffff
  • kseg1 0xa000_0000~0xbfff_ffff
  • kseg2 0xc000_0000~0xffff_ffff

将内核放在kseg0,bootloader在载入内核前会进行 cache 初始化工作,内核可以顺利加载。

Linker Script 中记录了各个节应该如何映射到段(控制了节放置的地址),由此可以控制链接器的链接过程。

.text .data .bss 从0x8002_0000开始放置。

modules :等待编译的源码文件夹,targets :编译得到的 .x.o 文件。

_start 函数:清空bss段地址里的内容,将sp指针指向 0x8040_0000,跳转到 mips_init 函数。

printk

  • void printk(const char *fmt, ...)

    • void: 函数不返回任何值。
    • const char \*fmt: 这是一个字符串指针,指向“格式字符串”(例如 "Value: %d\n")。
    • ...: 这是一个变长参数。它允许你在调用函数时传入任意数量、任意类型的参数。
  • va_list ap; 定义一个名为 ap 的列表变量,用于存储变长参数的信息。

  • va_start(ap, fmt); 初始化 ap。它告诉编译器:变长参数是从 fmt 之后开始的。

  • vprintfmt(outputk, NULL, fmt, ap);

    • vprintfmt: 这是一个通用的格式化引擎。它会扫描 fmt 字符串,每当遇到 %(如 %d, %s, %x)时,就从 ap 中取出一个参数进行转换。
    • outputk: 这是一个回调函数。vprintfmt 每处理好一个字符,就会调用 outputk 把这个字符发送到硬件设备。
    • NULL: 观察到这里的 NULL 对应 void outputk(void *data, const char *buf, size_t len) 里的 data ,但代码里无论是 outputk 还是 printcharc 都没有用到它。
  • va_end(ap); 清理并关闭 ap 列表。在某些架构上这可能只是简单的宏定义,但在编写健壮的代码时,它是规范要求的,能防止栈损坏。

Extra

26年extra要仿照 printk 完成新函数 scank 。本次的注释给得相当到位,堪称宝宝巴士。

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

题中函数调用关系:

  • scank
    • vscanfmt
      • scan_c
      • scan_s
      • scan_u

此处放一下我的代码

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
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
// kern/printk.c
int scank(const char *fmt, ...) {
/* ---------------------------------- */
/* Lab 1-extra: Your code here. (1/4) */
/* 提示:
1. 仿照 printk 的实现,使用 va_list 处理可变参数。
2. 调用 vscanfmt(传入刚写好的 inputk 回调函数)。
3. 本函数的返回值 ret 应为 vscanfmt 的执行结果。
*/
/* ---------------------------------- */
int ret;
va_list ap;
va_start(ap, fmt);
ret=vscanfmt(inputk, NULL, fmt, ap);
va_end(ap);
return ret;
}


// 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') {
// 直接覆盖当前字符,不需要改 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)。
*/
/* ---------------------------------- */

ensure_char(in,data,ch,ch_valid);
*cp=*ch;
*ch_valid=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)。
*/
/* ---------------------------------- */
skip_whitespace(in,data,ch,ch_valid);
ensure_char(in,data,ch,ch_valid);
while(*ch!=' ' && *ch!='\t' && *ch!='\n' && *ch!='\r' && *ch!='\0'){
*cp++=*ch;
in(data,ch,1);
ensure_char(in,data,ch,ch_valid);
}
*cp='\0';
ensure_char(in,data,ch,ch_valid);
}

// ==========================================
// 处理 %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)。
*/
/* ---------------------------------- */
skip_whitespace(in,data,ch,ch_valid);
int res=0;
ensure_char(in,data,ch,ch_valid);
if(*ch=='+'){
in(data,ch,1);
}
ensure_char(in,data,ch,ch_valid);
while(*ch>='0'&&*ch<='9'){
res=res*10+*ch-'0';
in(data,ch,1);
ensure_char(in,data,ch,ch_valid);
}
*ip=res;
ensure_char(in,data,ch,ch_valid);
}

// ==========================================
// 主入口: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;
}

个人觉得这次的重点应该是区分 inensure_char

Comments