0%

操作系统实验三_操作系统内核

操作系统实验三_操作系统内核

[toc]

实验目的

1、加深理解操作系统内核概念
2、了解操作系统开发方法
3、掌握汇编语言与高级语言混合编程的方法
4、掌握独立内核的设计与加载方法
5、加强磁盘空间管理工作

实验要求

1、知道独立内核设计的需求
2、掌握一种x86汇编语言与一种C高级语言混合编程的规定和要求
3、设计一个程序,以汇编程序为主入口模块,调用一个C语言编写的函数处理汇编模块定义的数据,然后再由汇编模块完成屏幕输出数据,将程序生成COM格式程序,在DOS或虚拟环境运行。
4、汇编语言与高级语言混合编程的方法,重写和扩展实验二的的监控程序,从引导程序分离独立,生成一个COM格式程序的独立内核。
5、再设计新的引导程序,实现独立内核的加载引导,确保内核功能不比实验二的监控程序弱,展示原有功能或加强功能可以工作。
6、编写实验报告,描述实验工作的过程和必要的细节,如截屏或录屏,以证实实验工作的真实性

实验环境

  • Windows 10
  • WSL (Windows Subsystem for Linux) [Ubuntu 18.04.2 LTS]:WSL 是以软件的形式运行在 Windows 下的 Linux 子系统,是近些年微软推出来的新工具,可以在 Windows 系统上原生运行 Linux。
  • gcc version 7.5.0:C 语言程序编译器,Ubuntu 自带。
  • ld version 2.3.0: 链接器,Ubuntu自带
  • NASM version 2.13.02:汇编程序编译器,通过sudo apt install nasm安装在 WSL 上。
  • Oracle VM VirtualBox :轻量开源的虚拟机软件,安装在Windows下。
  • VSCode - Insiders v1.33.0:好用的文本编辑器,有丰富的插件,可以用它来打开WSL中的文件夹,用它自带的终端执行make命令。
  • GNU Make 4.1:安装在 Ubuntu 下,一键编译并连接代码,生成最终的文件。
  • Bochs 2.1.1:安装在Windows下,用于调试代码。

自制工具

​ 由于我的虚拟机和Bochs都安装在Windows下,所以需要将WSL中生成的文件写入至Windows的磁盘,这可以用我编写的工具 do 来解决,只需要执行

1
./do 文件名 写入的扇区

​ 就可以了。

实验内容

概述

​ 首先了解c语言程序与汇编语言程序混合编译的方法,再开发操作系统内核,它具有两个主要功能:

  • 提供加载用户程序的方法,用户可以将程序写入磁盘,然后让操作系统执行这些用户程序。

  • 控制键盘输入和屏幕输出,使得用户可以与操作系统交互。

​ 再将上一次实验的4个程序放进磁盘,让操作系统执行它们,查看执行结果是否正确。

c与汇编混合编译

​ 由于操作系统内核非常复杂,只用汇编语言是很难完成的,因此需要使用c语言和汇编语言的混合编译,生成可执行的二进制文件。

​ 可以用gcc将一个c语言程序编译生成汇编语言文件,如对下面这个test.c:

1
2
3
4
5
6
int f(int val){
return val+1;
}
int main(){
f(3);
}

​ 执行

1
gcc -march=i386 -m16 -ffreestanding -fno-PIE -masm=intel -S test.c -o test.asm

​ 会生成一个x86格式的汇编语言文件test.asm。它的内容很复杂,但我们只用关注一些关键的地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
main:
.LFB1:
.cfi_startproc
push ebp;函数开始时,总要push ebp,保护这个寄存器
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
mov ebp, esp
.cfi_def_cfa_register 5
push 3 ;传递参数用push,把参数压到栈里,被调用的函数就能发现
call f ;这里调用了f这个函数,用的是call指令
add esp, 4 ;由于前面的ret和pop指令,现在esp指向的值就是3,也就是刚才传的参数,所以把esp+=3,会让栈恢复到函数调用之前的状态
nop
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret ;返回只用一条ret指令就可以了。由于我的test.c文件没有return 0,所以gcc帮我补上了一条ret指令
.cfi_endproc

​ f 函数是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
f:
.LFB0:
.cfi_startproc
push ebp ;这里也是要push ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
mov ebp, esp
.cfi_def_cfa_register 5
mov eax, DWORD PTR [ebp+8] ;main函数用了call指令,esp要减4;前面push 了 ebp ,esp又要减4,所以esp+8才是我们传递给f函数的参数。
inc eax ;返回一个值,可以把它放在eax里
pop ebp ;这里把ebp pop 掉,esp+=4
.cfi_restore 5
.cfi_def_cfa 4, 4
ret ;这里返回,esp+=4
.cfi_endproc

​ 看来c语言生成的汇编程序也不算神秘,除了一些奇奇怪怪的指令,跟我们写的汇编程序也没有很大差别。通过上述分析,我们对函数调用和传递参数过程有了更深入的了解。

​ 接着,我们编写一个汇编程序(msg.asm)和c程序(count.c)混合编程实例。汇编模块中定义一个字符串(为了方便,假设它以’\n’结尾),调用C语言的函数,统计其中某个字符出现的次数,汇编模块显示统计结果。

​ c程序如下:

1
2
3
4
5
6
7
8
9
10
int count(char* str){
int i=0;
for(;*str!='\n';str++){
if(*str=='e')i++;
}
return i;
}
int main(void){
count(0);
}

​ 其中,汇编程序调用c程序的函数的过程如下:

1
2
3
4
push 0
push string; string 是一个标号,占2个字节,但c语言的指针是4个字节,于是要把前两个字节置为0,就在上面多push一个0
push 0;call会压栈2个字节,但c语言中默认的是压栈4个字节,如果我们不压栈4个字节,c程序中栈的位置会错乱
call count

​ 函数返回会把返回值放在eax寄存器,然后汇编程序可以调用 10h 号中断把它显示出来。

​ 编写makefile文件(makefile2)如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kernel:msg.o count.o my_mbr
ld -m elf_i386 -N --oformat binary -Ttext 0x7e00 msg.o count.o -o kernel
#ld 用于将汇编程序生成的二进制文件和c程序生成的二进制文件链接起来, -Ttest用于指定程序的起始位置为0x7e00处
./do kernel 1 # 将kernel文件写入磁盘的第一个扇区
./do my_mbr 0 # my_mbr是引导扇区程序,它会将kernel文件加载到0x7e00处
msg.o:msg.asm
nasm -felf msg.asm -o msg.o # 如果不加 -felf 参数好像就不能调用c程序中的函数
nm msg.o > tem.txt #用 nm 命令来分析符号表,输出重定向到tem.txt文件
count.o:count.asm
gcc -march=i386 -m16 -mpreferred-stack-boundary=2 -ffreestanding -fno-PIE -masm=intel -c count.c -o count.o
#这里跟上文的编译方式基本一样,不同的是 -c 参数指定生成 .o 文件
nm count.o >> tem.txt #用 nm 命令来分析符号表,输出重定向到tem.txt文件的末端
my_mbr:my_mbr.asm
nasm my_mbr.asm -o my_mbr

​ 在VSCode自带终端中输入:

1
make -f makefile2

​ 就完成了编译、链接和写入磁盘的工作,非常方便。得到的符号表如下:

1
2
3
4
5
6
0000001a t _end #因为msg.asm里将_start声明为全局变量,_end没有,所以这一行是 小写字母t 表示这是局部变量
00000000 T _start #这一行是大写字母T表示全局变量
U count
0000001c t string
00000000 T count
0000003f T main

​ 用bochs运行,结果如下:

image-20200509142152549.png

​ 运行结果正常。

开发操作系统内核

​ 理论上来说,用纯C语言开发内核也是可以的,但要用到很多内嵌汇编,会使程序看起来令人不适。为此,我把 C 程序中需要用到的大量汇编语言代码放置在entry.asm中,c程序只需要执行

1
call 标号

​ 就可以调用entry.asm 里的各种过程。

汇编程序部分

​ 这部分的内容在entry.asm这个文件中,主要分为三部分。从第6行到第13行是第一部分,主要负责将控制权交给C程序中的main函数,当main函数返回时停机。

​ 从第15行到第71行_load_program过程,用于加载用户程序。将程序从磁盘加载到内存可以调用 16h 号中断来实现,但我一调用就会出bug,于是我只能用《x86汇编语言:从实模式到保护模式》这本书里提供的代码。

​ 调用这个过程之前要将用户程序被加载到的位置放置在dx中,用户程序在磁盘中的起始扇区放置在si中,用户程序的所占扇区数放置在bx中。

​ 从第73行到第111行是clear过程,用于将屏幕清空。这个过程十分简单,不必赘述。

C程序部分

​ main.c这个文件中的内容是操作系统内核的主要部分。它包括基础I/O操作、工具函数和各种用户交互命令。

I/O 操作

​ 为了方便输入输出,我首先编写了getchar()、putchar()、getline()、put()这4个函数。

​ 第23行到第32行的内容是 getchar() 函数,它通过调用 16h 号中断来得到一个输入字符。如果这个字符的值是13(回车符的键盘码),则返回 ‘\n’ 。

​ 第34行到第86行的内容是putchar() 函数,它接受一个字符类型的参数,将字符打印到屏幕上。打印的位置由locr 、 locc 这两个全局变量来决定。

​ 当要打印的字符是一个普通的字符时,调用 10h 号中断在当前光标处输出这个字符,并调整locr 和 locc 两个变量的值;

​ 当要打印的字符是回车符时,将locc置0,将locr++,调用 10h 号中断设置光标的位置为 locc 和 locr 指定的位置;

​ 当要打印的字符是退格符时,首先修改 locc 和 locr 两个变量的值,保证这两个值始终都是下一次打印的字符在的屏幕上的位置。然后调用10h号中断修改当前光标的位置为为 locc 和 locr 指定的位置,将这个位置的值清0。

​ getline()函数接受一个字符类型的指针,不断调用getchar(),把读进来的字符存到字符串里,读到回车符就终止,给字符串加上一个 ‘\0’ 。

​ put()函数接受一个字符类型的指针,不断调用putchar()输出它,遇到 ‘\0’ 就终止。

工具函数

​ 为了方便,我编写了 int_to_str () , str_to_int () 和 strcmp () 这几个函数。意思很明显,内容也缺乏技术含量,不再赘述。

用户交互

​ 程序会不断循环,每次都用getline读取用户输入的命令,并进行交互,共有clear、help、load 和 quit 4种交互命令。

​ clear命令会将屏幕清空,这只需调用前面说过的entry.asm里的_clear过程就可以了。

​ help命令会打印提示信息。

​ quit命令会终止操作系统内核的执行。

​ load命令是最重要的命令。设计这个命令的初衷是,假设用户有一块装有我的操作系统内核的硬盘,但他完全不懂电脑,只会将程序写入硬盘。这个命令可以让他将程序加载到内存中并运行,而完全不需要修改操作系统的代码。

​ load命令会打印一条提示信息提示,提示用户输入程序在磁盘中的起始位置,再打印一条提示信息,提示用户输入程序在磁盘中占用的扇区数。然后调用 _load_program 过程将程序加载进内存0xa000处,设置es、ds寄存器的值,并将控制权交给用户程序。用户程序运行结束后,操作系统会将es、ds寄存器的值清0,并清空屏幕。

实验过程

​ 编写一个makefile文件,然后就可以在VSCode自带的终端里输入make完成大量的工作:

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
user_programs=user_program_1 user_program_2 user_program_3 user_program_4
all: kernel my_mbr $(user_programs)
./do kernel 1 # kernel 文件是生成的操作系统内核二进制文件
./do my_mbr 0 # my_mbr 是引导扇区程序
kernel:entry.o main.o
ld -m elf_i386 -N --oformat binary -Ttext 0x7e00 entry.o main.o -o kernel
# 这里将 entry.o 和 main.o 链接成kernel文件,参数在上文介绍过了,不再赘述
nm entry.o > symbol_table.txt #分析符号表,结果在 symbol_table.txt
nm main.o >> symbol_table.txt
entry.o:entry.asm
nasm -felf $< -o $@
main.o:main.c
gcc -march=i386 -m16 -mpreferred-stack-boundary=2 -ffreestanding -fno-PIE -masm=intel -c $< -o $@ #参数在上文已经介绍过了,不再赘述
my_mbr:my_mbr.asm
nasm my_mbr.asm -o my_mbr
user_program_1:user_program_1.asm # user_program是一些用户程序,把它们分别写入到第10,20,30,40个扇区,然后可以在操作系统中加载它们
nasm $< -o $@
./do $@ 10
user_program_2:user_program_2.asm
nasm $< -o $@
./do $@ 20
user_program_3:user_program_3.asm
nasm $< -o $@
./do $@ 30
user_program_4:user_program_4.asm
nasm $< -o $@
./do $@ 40

​ 用bochs运行,依次输入load,10,1(第一个用户程序在磁盘的第10个扇区,大小为1个扇区):

image-20200509201901481.png

​ 效果拨群:

image-20200509202009328.png

​ 按下Ctrl+C,返回操作系统:

image-20200509202136463.png

​ 再依次输入load,20,2 加载第二个程序:

image-20200509205136902.png

​ 效果如下:

image-20200509205234324.png

​ 输入 load 40 4 后效果如下:

image-20200509205342547.png

​ 输入load 30 3 后效果如下:

image-20200509205446870.png

​ 输入help打印提示信息:

image-20200509205640183.png

​ 输入clear清空:

image-20200509205739629.png

image-20200510163248587.png

​ 输入quit可以退出操作系统:

image-20200509205827461.png

实验总结

​ 这次实验算得上是很硬核了,用到了很多x86汇编的知识,还要用 ld 这种完全不熟的工具,用gcc里各种奇奇怪怪的参数,甚至为了方便我还学了一下makefile (好像早就该学了吧) ,遇到的困难也有很多,主要有:

  • ​ 对汇编语言不够了解。主要是函数调用和传参那里,非常麻烦,之前从没有深入了解过。而且C语言程序编译生成的汇编代码跟自己写的在风格上有很大差别,让我很不适应。一开始的时候只能看懂一些关键的语句的意思,好在我看多了之后还是克服了心中的 “恐惧感” 。

  • ​ debug实在是太麻烦了。虽然我会使用bochs调试程序,但很多bug非常隐蔽,包括但不限于:

    1. 在键盘中输入alt+tab切换屏幕,导致后面的输入无法被bochs读入。至今我都没想到解决的办法,好在不影响我写程序。

    2. 向c语言的函数传字符串常量会出错。这个问题好像不止我一个遇到,其他的同学和网上的博客也有这样的问题。我至今也没找到原因,只能把字符串常量改为char*类型。

    3. 向指向int类型的指针传short类型变量的地址。我声明了一个short变量,调用 str_to_int (char* s,int* val) 函数的时候把它的地址传了进去,本来我觉得反正都会进行类型转换,没啥问题。可是我执行了

      1
      *val=0;

      之后,问题就来了: short类型变量是2个字节,但 val 指针是int类型的指针,上面的操作会把内存中4个字节全部置为0。更要命的是,由于第一个参数先压栈,第二个字符后压栈,val指向的后面两个字节刚好就是 s 字符串的前两个字节,于是这个函数就不会得到正确的结果。当我想到这个问题时,我不禁为这世上有如此巧妙的bug而感到震惊。

image-20200509000616841.png

image-20200509000427794.png

  1. int 16h 读键盘会导致光标位置出错。这是一个很奇怪的bug,不调用int 16h时,用int 10h 中断(ah=3)来读取光标位置可以正常运行,当我用 int 16h 读键盘输入后,用int 10h 中断(ah=03)就完全得不到正确结果,但在光标处打印字符却没有问题。我最终也没整明白这其中的缘由,只能放弃使用int 10h 中断来读取光标位置,改用 locc locr 这两个变量。

    我后来想到,debug不一定要对着bochs的那些汇编代码一行行看,用内核输入输出函数也能帮助我debug,这样稍微缓解了我的压力。

  • ​ 很多工具不会用。说出来有点丢脸,在这次实验之前我从来没用过 ld 这个工具,当我看到老师给的ppt里那一串参数时,突然认识到自己是多么不学无术。经过我反复地尝试、不断地失败后,我总算学会了如何使用ld和gcc完成C程序与汇编程序混合编译事实上只是能跑起来而已,学会是不可能学会的

​ 虽然困难很多,但收获也同样不少。通过这次实验,我大大加强了对汇编语言和C语言的了解,对操作系统的工作方式的认识也更加深入了。同时,完成操作系统内核的开发也算是一件很鼓舞人心的事情,这让我有了更多的勇气和兴趣来进一步学习更深的知识。

参考资料

  1. https://wu-kan.cn/_posts/2019-03-28-%E7%94%A8%E6%B1%87%E7%BC%96%E4%B8%8EC%E8%AF%AD%E8%A8%80%E5%BC%80%E5%8F%91%E7%8B%AC%E7%AB%8B%E5%86%85%E6%A0%B8/ (今年大三的学长,博客写的很不错)
  2. https://blog.csdn.net/a200710716/article/details/45936643 (关于键盘输入的ASCII码的资料)
  3. https://www.cnblogs.com/johnshao/archive/2011/06/13/2079638.html (int 10h 中断的详细介绍)
  4. https://blog.csdn.net/ZCMUCZX/article/details/80462394?locationNum=4&fps=1 (int 16h 中断的详细介绍)
  5. https://blog.csdn.net/daydayup654/article/details/78630341 ( ld 工具的详细介绍)
  6. http://c.biancheng.net/view/661.html (gcc 的各种使用姿势)