实验目的
- 从操作系统角度理解MIPS体系结构
- 掌握操作系统启动的基本流程
- 掌握ELF文件的结构和功能
- 完成printf函数的编写
我们编写的所有代码,可以在提供的Linux 平台通过
Makefile
交叉编译产生可执行文件。最后我们使用GXemul
对可执行文件运行填充完成操作系统。
操作系统内核
关键是放置位置,将硬件初始化的相关工作放在名为“bootloader”的程序中
boot loader
大多数 boot loader 都分为 stage1 和 stage2 两大部分。
GXemul的启动流程
GXemul
支持加载ELF格式内核,所以启动流程被简化为加载内核到内存,之后跳转到内核的入口
gxemul运行选项:
1 | -E 仿真机器的类型 |
举例:
1 | gxemul -E testmips -C R3000 -M 64 vmlinux 用gxemul运行vmlinux |
1 | gxemul -E testmips -C R3000 -M 64 -V vmlinux |
以调试模式打开gxemul,对vmlinux进行调试(进入后直接中断,输入continue或step才会继续运行,在此之前可以进行添加断点等操作)
进入gxemul后使用Ctrl-C可以中断运行。中断后可以进行单步调试,执行如下指令:
1 | breakpoint add addr添加断点 |
- c(执行至下一断点)
- step(step 100)
- unassemble(输出汇编代码)
- dump xxaddr (导出某一地址后续的内存信息)
- reg (导出寄存器的值)
- reg, 0(导出协处理器cp0中的寄存器的值)
- tlbdump(导出TLB中的信息)
- trace(了解程序的运行轨迹)
Makefile内核代码的地图
1 | Main makefile |
斜杠代表这一行没有结束, 下一行的内容和这一行是连在一起的。这种写法一般用于提高文件的可读性。可以把本该写在同一行的东西分布在多行中,使得文件更容易被人类阅读。
LD 变量:这里的 CROSS_COMPILE(交叉编译) 变量是在定义编译和链接等指令的前缀,或者说是交叉编译器的具体位置。/OSLAB/compiler/usr/bin/mips_4KC-ld
ELF—深入研究编译与链接
- -E:C语言的预处理器将头文件的内容添加到了源文件中
- -c:只编译而不链接
- -o:允许链接,进行正常编译
推断:printf的实现是在链接(Link)这一步骤中被插入到最终的可执行文件中的
printf的实现其实早就被编译成了二进制形式,但他并未链接到程序中,它的状态与我们利用-c选项产生的hello.o相仿,都还处于未链接的状态
链接器(Linker)会将所有的目标文件链接在一起,将之前未填写的地址等信息填上,形成最终的可执行文件,这就是链接的过程。
Thinking 1.1
请查阅并给出前述objdump 中使用的参数的含义。使用其它体系结构的编译器(如课程平台的MIPS交叉编译器)重复上述各步编译过程,观察并在实验报告中提交相应结果。
1.运行objdump --help
可知:
-D
:显示所有部分的汇编程序内容
-S
:将源代码与反汇编混合
2.改用Mips交叉编译器:
- 修改CROSS_COMPILE参数(发现已符合要求)
- 创建c程序
- 使用该编译器替换
gcc
(编译用$(CC) 、链接用$(LD))
首先只要求编译器预处理
prac.c
文件中内容:(由于无法连接stdio.h,故未写出,此处使用全局变量进行测试)
1 | int order; |
- 预处理:
1 | /OSLAB/compiler/usr/bin/mips_4KC-gcc -E prac.c |
1 | # 1 "prac.c" |
- 只编译不链接
1 | /OSLAB/compiler/usr/bin/mips_4KC-gcc -c prac.c |
反汇编(main函数部分):
1 | /OSLAB/compiler/usr/bin/mips-linux-objdump -DS prac.o > out.txt |
1 | Disassembly of section .text: |
- 正常编译链接:
1 | /OSLAB/compiler/usr/bin/mips_4KC-ld -o out prac.o |
1 | /OSLAB/compiler/usr/bin/mips-linux-objdump -DS out > out.txt |
1 | Disassembly of section .text: |
ELF文件功能及格式
ELF文件是一种对可执行文件、目标文件和库使用的文件格式
三种文件类型:可重定位(relocatable)文件是1、可执行(executable)文件是2和共享对象(shared object)文件是3
总体来说分为5个部分:
ELF HEADER
、Program Header Table
、Section Header Table
、Segments
、Sections
结构体大小:寻找结构体中最大的内存占用单位,以此为基准乘以元素个数即为结构体大小,有时会存在合并优化。
对于控制数据,ELF 定义了自己的数据结 构,所以并不依赖于所在机器的字长;其它数据使用目标处理器的数据格式,字长是在构建期间由编译器/连接器来指定的,与创建时所在的主机无关。
- ELF文件头:
- e_ident:
- e_ident[EI_MAG0] ~ e_ident[EI_MAG3] 存放魔数0x7f、E、L、F
- EI_CLASS: 0->非法、1->32位、2->64位
- EIDATA: 数据编码格式
- EI_PAD - EI_NIDENT-1未使用
- e_type:
- 0:未知
- 1:.o
- 2:可执行
- 3:动态链接库文件
- 4:Core文件
- e_machine
- e_version
- …
- e_ident:
- 节头表:数组:目标文件中的每一个节一定对应有一个节头(section header),节头中有对节 的描述信息;但有的节头可以没有对应的节,而只是一个空的头。
- sh_name:一个可以索引到字符串的指针
- sh_addr:在运行时本节映射的位置
- sh_offset:在文件中本节存储的位置
- sh_size: 指明节的大小
- 动态链接(程序头):程序头表:数组,包含程序头,描述了一个段的信息。
- p_type
- p_offset:该段在文件中的位置,相对于文件开头的偏移量
- p_vaddr:进程开始时,该段开始位置在虚拟内存的地址
- p_paddr:物理地址
- p_filesz: 该段内容在文件中的大小
- p_memsz:该段内容在内容镜像中的大小
- p_flags
- p_align
为什么C语言中全局变量会有默认值0。这是因为操作系统在加载时将所有未初始化的全局变量所占的内存统一填了0。
对于可重定位(relocatable)文件,查看其e_type值为1。
Mips内存布局——寻找内核的正确位置
在include/mmu.h里有我们的小操作系统内核完整的内存布局图(见下面的代码), 在之后的实验中,善用它可以带来意料之外的惊喜。
内核在kseg0
中
Linker Script——控制加载地址
1 | . = 0x8001 0000; |
Mips汇编与C语言
在函数调用过程中,编译器真的为我们维护了一个栈。这下同学们应该也不难理解,为什么复杂函数在递归层数过多时会导 致程序崩溃,也就是我们常说的“栈溢出”。
通用寄存器使用约定
寄存器编号 | 助记符 | 用途 |
---|---|---|
0 | zero | 值总是为0 |
1 | at | (汇编暂存寄存器)一般由汇编器作为临时寄存器使用。 |
2-3 | v0-v1 | 用于存放表达式的值或函数的整形、指针类型返回值 |
4-7 | a0-a3 | 用于函数传参。其值在函数调用的过程中不会被保存。若函数参数较多,多出来的参数会采用栈进行传递 |
8-15 | t0-t7 | 用于存放表达式的值的临时寄存器;其值在函数调用的过程中不会被保存。 |
16-23 | s0-s7 | 保存寄存器;这些寄存器中的值在经过函数调用后不会被改变。 |
24-25 | t8-t9 | 用于存放表达式的值的临时寄存器;其值在函数调用的过程中不会被保存。当调用位置无关函数(position independent function)时, 25号寄存器必须存放被调用函数的地址。 |
26-27 | k0-k1 | 仅被操作系统使用。 |
28 | gp | 全局指针和内容指针。 |
29 | sp | 栈指针。 |
30 | fp或s8 | 保存寄存器(同s0-s7)。也可用作帧指针。 |
31 | ra | 函数返回地址。 |
其中,只有16-23号寄存器和28-30号寄存器的值在函数调用的前后是不变的。
对于28号寄存器有一个特例:当调用位置无关代码(position independent code)时,28号寄存器的值是不被保存的。
除了这些通用寄存器之外,还有一个特殊的寄存器:PC寄存器。这个寄存器中储存了当前要执行的指令的地址。 当你在Gxemul仿真器上调试内核时,可以留意一下这个寄存器。通过PC的值,我们就能够知道当前内核在执行的代码是哪一条, 或者触发中断的代码是哪一条等等。
实战printf
- console.c
实现向控制台输出字符的函数
- printf.c
实现printf.c函数,将输出字符函数、接受输出参数传递给Ip_print函数
- Ip_print函数
实现了lp_Print 函数,是printf函数的真正内核
关于printf.c函数:
可变长参数:
- 格式:函数参数列表末尾有省略号,代表该函数有变长参数表。
- 要求:需要定义变长参数表的起始位置,且函数需要有至少一个固定参数,变长参数在参数表结尾
- 相关定义:
va_list
、va_start
、va_arg
、va_end
- 具体使用流程
- 使用前需声明一个类型为va_list的变量ap
1 | va_list ap; |
- 然后使用va_start宏进行一次初始化
1 | va_start(ap, lastarg); |
- 初始化后,可以使用va_arg宏获取下一个形式参数
1 | int num; |
- 所有参数处理完毕后使用va_end宏结束可变长参数的使用
Exercise 1.5
补全lib/print.c中lp_Print()函数中缺失的部分来实现字符输出。
实现Ip_Print函数
1.使用宏定义简化myoutput这个函数指针的使用
1 | OUTPUT(arg, s, l) |
2.定义需要使用的变量
1 | int longFlag;//标记是否为 long 型 |
3.找到格式符%,分析输出格式
4.根据输出格式对结果进行输出
分析:
myoutput函数输出字符串,OUTPUT宏为myoutput的简化调用
- 若非法则输出FatalMsg
- 否则输出s数组存储的字符串
各变量定义:
buf[LP_MAX__BUF] -> 承载中间过程的字符串数组
char c -> 指向当前字符
char *s -> 实参中的字符串数组
long int num -> 用于接受数字
int longFlag -> 标记长整数
由此进入printf的实现细节,学习整理如下:
printf有四种格式:
- printf(“字符串\n”);
- printf(“输出控制符”,输出参数);
- printf(“输出控制符1 输出控制符2…”, 输出参数1, 输出参数2, …);
- printf(“输出控制符 非输出控制符”,输出参数);
理解:输出控制符以%
为标志,其他非输出控制符原样输出
常见输出控制符:
控制符 | 说明 |
---|---|
%d | 按十进制整型数据的实际长度输出。 |
%ld | 输出长整型数据。 |
%md | m 为指定的输出字段的宽度。如果数据的位数小于 m,则左端补以空格,若大于 m,则按实际位数输出。 |
%u | 输出无符号整型(unsigned)。输出无符号整型时也可以用 %d,这时是将无符号转换成有符号数,然后输出。但编程的时候最好不要这么写,因为这样要进行一次转换,使 CPU 多做一次无用功。 |
%c | 用来输出一个字符。 |
%f | 用来输出实数,包括单精度和双精度,以小数形式输出。不指定字段宽度,由系统自动指定,整数部分全部输出,小数部分输出 6 位,超过 6 位的四舍五入。 |
%.mf | 输出实数时小数点后保留 m 位,注意 m 前面有个点。 |
%o | 以八进制整数形式输出,这个就用得很少了,了解一下就行了。 |
%s | 用来输出字符串。用 %s 输出字符串同前面直接输出字符串是一样的。但是此时要先定义字符数组或字符指针存储或指向字符串,这个稍后再讲。 |
%x(或 %X 或 %#x 或 %#X) | 以十六进制形式输出整数,这个很重要。 |
%- | 左对齐 |
%+ | 右对齐 |
% | 百分号+空格:若符号为正,则显示空格,负则显示”-“ |
理解:如果是小写的x
,输出的字母就是小写的;如果是大写的X
,输出的字母就是大写的;如果加一个#
,就以标准的十六进制形式输出。
完整:
% - .n l或h 格式字符
①%:表示格式说明的起始符号,不可缺少。
②-:有-表示左对齐输出,如省略表示右对齐输出。
③0:有0表示指定空位填0,如省略表示指定空位不填。
④m.n:m指域宽,即对应的输出项在输出设备上所占的字符数。N指精度。用于说明输出的实型数的小数位数。未指定n时,隐含的精度为n=6位。
⑤l或h:l对整型指long型,对实型指double型。h用于将整型的格式字符修正为short型。
example:%-07.2lf
附加:
输出
%
需加两个%
对于m.n的格式还可以用如下方法表示(例)
1 | char ch[20]; |
前边的*定义的是总的宽度,后边的定义的是输出的个数。分别对应外面的参数m和n 。我想这种方法的好处是可以在语句之外对参数m和n赋值,从而控制输出格式
- 今天()又看到一种输出格式 %n 可以将所输出字符串的长度值赋绐一个变量, 见下例:
1 | int slen; |
总结:
1.printf一般形式:
printf(“格式控制字符串”,输出项列表);
2.格式控制:
(1)普通字符。普通字符在输出时,按原样输出,主要用于输出提示信息。
(2)转义字符。转义字符指明特定的操作,如”\n”表示换行,”\t”表示水平制表等。
(3)格式说明部分由“%”和“格式字符串”组成,他表示按规定的格式输出数据
1 | %[flags][width][.prec][F|N|h|I][type] |
flags->-
/+
/
继续:
1 | longFlag = (%后面有l) ? 1 : 0 |
处理:
外部循环判断是否到达结尾
判断*fmt:
printNum函数:
(buf数组、u(代表实际参数)、base(代表进制)、negFlag(代表是不是负数)、length(代表要占据输出的长度)、ladjust(代表左对齐)、padc(代表填充符)、upcase(代表是否大写出现的字母))
倒序存储数字(一个do-while循环)
1 | 123456->654321 |
若为负数加负号
判断占位长度(若实际长度大,按实际长度输出)
左对齐忽略填充符
右对齐、负数、填充为0
1 | -123456->654321000- |
其他:直接填充
反转字符串
- 左对齐仅反转有效部分
- 右对齐全部反转
返回字符串长度
分析:
fmt指针应指向整个格式字符串
s指针用来指向实参
fmt指向谁?应为引号中的字符串,以’\0’结尾
1 | int longFlag;//标记是否为 long 型 |
1 | printf("my name", a, b); |
1 |
|
测试:
1 | %-9.7ld |
1 | %0034879.324ld |
1 | %-0231.21lf |
修复bug
1.若%d读入为负数则需使num取反(否则对base取余会有问题)
2.longFlag初始化
3.prec初始化
4.未读到%须直接输出
样例:
1 | printf(cjj) |
总结
lab1通过一个printf来加深我们的理解,要想实现这个printf并不难,但是想完全搞清楚lab1还是需要费一番功夫的。
This is copyright.