[toc]

本次操作系统实验根据`Orange's 一个操作系统的实现`一书进行操作。在此记录试验的过程及心得

第一章&第二章 Hello,OS world

这两章通过在windows使用虚拟机运行Ubuntu操作系统,在Ubuntu上使用bochs虚拟机来完成操作系统。

1 使用vm运行Ubuntu

我这里虚拟机运行的是Ubuntu20.04,但因为这本书是老书了,64位机运行结果会有问题,因此还是用了Ubuntu16.04的32位虚拟机,安装虚拟机时主要遇到的问题有:

  1. 虚拟机连不上网,但在瞎搞之后能连上了,选择的时NAT连接
  2. VM Tools 自动安装不上,会报错。VMtools可以实现Windows环境和Linux环境直接的文件交换,还是有必要安装的。解决方法是手动下载VM Tools,尽管之后还会报错,但已经能实现文件互通。

2 在Ubuntu中使用bochs

我这里是在官网下载的安装包,版本是2.7
之后参考书上和网上的一些博客进行安装,主要参考博客
这里建议新建一个文件夹解压压缩包
使用命令tar vxzf bochs-2.7.tar.gz解压
之后使用命令cd bochs-2.7进入该文件夹
之后进行配置
输入命令./configure --prefix=$home/你解压的地址 --enable-debugger --enable-disasm --enable-iodebug --enable-x86-debugger --with-x --with-x11
配置时可能遇到问题

  1. fatal error: X11/Xlib.h: No such file or directory
    这里表示x11没有安装完整,输入:sudo apt-get install libghc-x11-dev即可
  2. Ubuntu 编译提示 configure: error: no acceptable C compiler found in $PATH
    这是没有安装 C 编译器,通过命令sudo apt-get install -y build-essential下载一些必要环境即可

随后依次使用命令makesudo make install

这两个命令执行过程中我没有遇到什么问题,如果有遇到的话还请自己搜搜。

之后返回上一级目录,可以发现多了两个文件bin和share文件夹。

之后进入bin文件夹里面可以看到bximage和bochs两个文件,前者用来创建虚拟磁盘,或者用来配置虚拟机。

接下来让我们配置bochs文件,进入bin文件,输入命令sudo gedit bochsrc创建bochsrc配置文件,向该文件中输入以下代码进行虚拟机配置(直接复制粘贴即可)

###############################################################
# Configuration file for Bochs
###############################################################

# how much memory the emulated machine will have
megs: 32

# filename of ROM images
romimage: file=/home/mr-cold/boch/share/bochs/BIOS-bochs-latest
vgaromimage: file=/home/mr-cold/boch/share/bochs/VGABIOS-lgpl-latest

# what disk images will be used 
floppya: 1_44=a.img, status=inserted

# choose the boot disk.
boot: floppy

# where do we send log messages?
log: bochsout.txt

# disable the mouse
mouse: enabled=0

# enable key mapping, using US layout as default.
#keyboard_mapping: enabled=1, map=/home/mr-cold/boch/share/bochs/keymaps/x11-pc-us.map

之后在bin文件夹下输入bochs -f bochsrc即可运行虚拟机,选择6开始模拟,之后输入c即可显示交互命令行界面



在上图中,报错是因为我们还没有驱动磁盘,在bochsrc的配置代码中我们可以看到该虚拟机配备软盘floppy为a.img,那么接下来我们就要创建一个软盘去实现一个最小的操作系统

3 最简单的操作系统

我们首先创建一个虚拟软盘(硬盘应该也可以,书中为软盘,毕竟是09年老书)。命令bximage
随后依据提示创建一个1.44MB的软盘,同时将bochsrc中floppya: 1_44=a.img, status=inserted中的软盘改为你创建磁盘的名字,这将是虚拟机的磁盘。

随后实现书中的boot.asm(可以直接从书附带磁盘复制出来,磁盘从学校云图书馆即可下载):通过basm boot.asm -o boot.bin将asm文件转为bin文件(asm文件即汇编文件,bin文件即二进制文件)

 org 07c00h   ; 告诉编译器程序加载到7c00处
 mov ax, cs
 mov ds, ax
 mov es, ax
 call DispStr   ; 调用显示字符串例程
 jmp $   ; 无限循环
DispStr:
 mov ax, BootMessage
 mov bp, ax   ; ES:BP = 串地址
 mov cx, 16   ; CX = 串长度
 mov ax, 01301h  ; AH = 13,  AL = 01h
 mov bx, 000ch  ; 页号为0(BH = 0) 黑底红字(BL = 0Ch,高亮)
 mov dl, 0
 int 10h   ; 10h 号中断
 ret
BootMessage:  db "Hello, OS world!"
times  510-($-$$) db 0 ; 填充剩下的空间,使生成的二进制代码恰好为512字节
dw  0xaa55    ; 结束标志

这里的boot文件即是引导扇区,其作用是将loader模块调入内存,且大小固定为512B,默认在磁盘的第一个扇区。

将生成的bin文件放入bochs虚拟机的文件夹。使用命令dd if=boot.bin of=a.img bs=512 count=1 conv=notrunc将bin文件写入软盘的第一个扇区,这里conv=notrunc不能删去,否则软盘会被截断(变为bin文件的大小)

  • dd if=boot.bin of=a.img bs=512 count=1 conv=notrunc这句话是一个命令行指令,作用是将文件boot.bin复制到a.img中的第一个扇区(512字节),覆盖原有内容而不追加。其中:
    • dd:一个Linux/Unix系统下的复制工具。
    • if:输入文件的路径。
    • of:输出文件的路径。
    • bs:每次复制的块大小,这里是512字节。
    • count:复制的块数,这里是1个块(即512字节)。
      -conv:转换选项,这里是不做任何转换(notrunc)。

随后即可运行bochs虚拟机,使用指令bochs -f bochsrc

结果:视窗第一行显示Hello, OS world!

第三章 保护模式

3.1 认识保护模式

保护模式的运行环境

  1. 网站得到FreeDOS压缩包
  2. 解压后将文件夹中的a.img重命名为freedos.img仿佛bochs虚拟机的工作目录中
  3. 使用bximage创建虚拟磁盘pm.img
  4. 修改当前bochsrc的配置文件,增加如下几行
    floppya: 1_44=“freedos.img”, status=inserted
    floppyb: 1_44=“pm.img”, status=inserted
    boot: a (这里即选择bochs虚拟机的驱动磁盘)
  5. 启动bochs虚拟机,随后再终端输入 format b:格式化B盘
  6. 从附书磁盘中获取pmtest1.asm,挂载到软驱pm.img中
    注意将pmtest1.asm代码第8行中07c00h改为0100h,否则最后结果无法显示
    nasm pmtest1.asm -o pmtest.com
    sudo mount -o loop pm.img /mnt/floppy/
    sudo cp pmtest1.com /mnt/floppy/
    sudo umount /mnt/floppy/
  7. 启动bochs虚拟机,即启动freedos,输入dir b:可查看软盘B中的内容
  8. 输入b:pmtest1.com即可运行该文件,可以看到以下效果
    3_1_1

实模式和保护模式

  • CPU的实模式和保护模式是两种不同的工作状态,这两种工作状态主要用于控制CPU的访问内存和外设的方式。

  • 实模式是CPU最初的工作状态,也是最基本的工作状态。在实模式下,CPU可以直接访问内存和外设,但只能使用16位的地址总线和处理器寄存器,因此内存地址的范围只能是从0到1MB。此外,在实模式下,CPU不提供对内存的保护,程序可以随意访问内存,这可能会导致内存损坏或数据丢失。

  • 保护模式是CPU的一种高级工作状态,它提供了更高的性能和更强的内存保护。在保护模式下,CPU可以使用32位的地址总线和处理器寄存器,从而可以访问4GB的内存空间。此外,保护模式可以提供多任务处理、虚拟内存、安全性等高级功能。

  • 在保护模式下,CPU将内存划分为许多不同的段,每个段都有自己的段描述符,用于描述该段的访问权限、基地址和长度等信息。这些段描述符是存储在内存中的数据结构,由操作系统来管理和维护。CPU访问内存时,必须遵循这些段描述符的规则,如果访问了不允许的内存区域,CPU会触发异常并终止程序的执行,从而实现了内存的保护。

  • 为什么会有实模式?

    • 实模式最初是设计用于早期的计算机系统,在这种模式下CPU可以直接访问物理内存和外设,这种简单的访问方式可以更容易地与早期的操作系统和应用程序相兼容。但是,实模式存在许多限制,如只能使用16位的地址总线和处理器寄存器,内存保护不足等问题,这些限制已经不能满足现代计算机系统的需求。
    • 为了满足计算机系统日益增长的复杂性和安全性需求,保护模式应运而生。保护模式提供了更多的功能和特性,如内存保护、多任务处理、虚拟内存等,这些特性使得操作系统和应用程序可以更好地控制和保护计算机系统的资源和数据,同时也提高了系统的性能和安全性。
  • 为什么现在还要有实模式到保护模式的转换,实模式不是早期计算机的吗?

    • 实模式虽然是最早的CPU工作模式之一,但是在一些情况下仍然需要将CPU从实模式切换到保护模式。主要原因有以下几个:
        1. 兼容性:一些操作系统或应用程序仍然需要在实模式下运行。例如,一些老旧的DOS程序和BIOS固件仅支持实模式,无法在保护模式下正常运行(这个应该是主要原因)。
        1. 引导程序:操作系统的引导程序(Bootloader)通常运行在实模式下,当引导程序加载完毕后,会切换到保护模式,以便操作系统可以更好地管理系统资源和内存。
        1. 性能:一些特殊场景需要CPU在实模式下运行,以获取更好的性能。例如,在一些嵌入式系统或低级别的驱动程序中,可能需要直接访问硬件设备或内存地址,这时可以选择在实模式下运行。
        1. 调试:在进行系统调试和开发过程中,将CPU从实模式切换到保护模式可以提供更丰富的调试信息和工具支持,帮助开发人员更好地进行调试和优化。

GDT(Global Descriptor Table)

  • 在IA32下,CPU有两种工作模式:实模式和保护模式。

  • 保护模式下,CPU有着强大的寻址能力吗,并为强大的32为操作系统提供了更好的硬件基础。

  • GDT的作用是用来提供段式存储机制,这种机制是通过寄存器和GDT中的描述符共同提供的。

  • 在实模式下,由于早期CPU为16位,其有着16位的寄存器,16位的数据总线,但有20位的地址总线,因此物理地址遵循的计算公式物理地址=段值16+偏移物理地址 = 段值*16+偏移,相当于将段值左移4位再加上偏移,且段值和便宜都是16位的。

  • 在CPU进入32位时代后,寄存器和地址线均为32位,但段值这一手段并没有被抛弃,此时引入实模式向保护模式的转变,保护模式下依然采用"段:偏移"这样的形式来表示,但保护模式下“段”的概念发生根本性的变化。

    • 实模式下,段值可以看作地址的一部分,从物理地址的计算公式可以看出
    • 保护模式下,段值仅变为一个索引,这个索引指向一个数据结构的一个表项,表项中详细定义了段的起始地址,界限,属性等内容。这个数据结构,就是GDT(也可以是LDT)。GDT的表项也叫描述符(Descripter)。GDT即为全局描述符表。
    • 因此GDT的作用是提供段式存储机制,这种机制是通过段寄存器和GDT中的描述符共同提供的
  • 下图为代码段和数据段描述符示例,此外还有系统段描述符和门描述符
    GDT0.jpg

  • 选择子结构,当TI个PRL都为0时,选择子就变成了对应描述符相当于GDT基址的偏移。
    选择子

  • 在保护模式下,寻址方式如下保护模式下的寻址

进入保护模式的主要步骤

  1. 准备GDT
  2. 用lgdt加载gdtr
  3. 打开A20
  4. 置cr0的PE位
  5. 跳转,进入保护模式

描述符属性

  • 一致代码段,一致:
    • 当转移目标是一个特权级更高的一直代码段,当前特权级会被延续下去
    • 而目标是一个特权级更高的非一致代码段,会引起常规保护错误,除非是用调用门或者任务门
    • 目标代码的特权级低,都不能通过call或者jmp转移进去

||特权级低->高|特权级高->低|相同特权级之间|适用于何种代码
|-|-|-|-|
|一致代码段|Y|N|Y|不访问受保护的资源和某些类型的异常处理的系统代码
|非一致代码段|N|N|Y|避免特权级的程序访问而被保护起来的系统代码
|数据段(总是非一致)|N|Y|Y|

3.2 保护模式进阶

LDT (Local Descriptor Table)

  • LDT(Local Descriptor Table)局部描述符表,与GDT差不多,但选择子的TI位必须置为1。在运用它时,需要先使用lldt指令加载ldtr,lldt的操作数是GDT中用来描述LDT的描述符1
  • 保护模式中“保护”二字的含义
    • 在描述符中段基址和段界限定义了一个段的范围,对超越段界限之外的地址访问是被禁止的,这是对段的一种保护
    • 同时复杂的段属性对一个端的各个方面的定义规定限制了段的行为和性质,这算是一种功能保护

特权级概述

  • 特权级

    • 在IA32的分段机制中,特权级共有4个级别,从高到低位0,1,2,3
    • 处理器通过识别CPL,DPL,RPL这三种特权级进行特权级检验
      • CPL:当前执行的程序或任务的特权级
      • DPL:表示段或者门的特权级
      • RPL:请求优先级
    • 特权级转移:通过jmp或call进行直接转移
  • 门:也是一种描述符,可以分为四种

    • 调用门:本质上为入口地址,但可以用来实现不同特权级代码之间的转移
      • 使用调用门的过程分为两个部分:
        1. 一部分是从低特权级到高特权级,通过调用门和call指令实现
        1. 另一部分是从高特权级到低特权级,通过ret指令来实现
    • 终端门
    • 陷阱门
    • 任务门
  • TSS(Task-State Stack)

    • TSS是一个数据结构,用于存储任务的状态信息,TSS通常与任务切换和任务管理相关联,它存储了任务的上下文、特权级别和其他与任务执行相关的信息。
      1. 任务切换:TSS用于在任务间进行切换。当处理器从一个任务切换到另一个任务时,它会保存当前任务的上下文信息到当前任务的TSS中,然后加载新任务的上下文信息从新任务的TSS中。这个过程通常由操作系统的调度器管理。
      2. 上下文信息:TSS中存储了任务的上下文信息,包括通用寄存器、段寄存器、控制寄存器、指令指针等。在任务切换时,处理器会保存当前任务的上下文到TSS中,并在切换到新任务时加载新任务的上下文。
      3. 特权级别:TSS中也包含了任务的特权级别信息,即任务执行时所处的特权级别。这是由特权级别字段(CPL,Current Privilege Level)指示的,它决定了任务对系统资源的访问权限。
      4. 任务管理:TSS也可以用于任务管理,其中每个任务都有一个唯一的TSS。任务管理器可以使用TSS来管理任务的状态、上下文和特权级别。

3.3 页式存储

    1. 什么叫做页?
      所谓页,就是一块内存。
    1. 逻辑地址,线性地址,物理地址
      在未打开分页机制前,线性地址等同于物理地址,可以理解为逻辑地址通过分段机制直接转换为物理地址。
      当分页开启后,分段机制将逻辑地址转换为线性地址,线性地址再通过分页机制转换成物理地址。
    1. 为什么分页?
      其主要目的是实现虚拟存储器,线性地址的任何一个页都可以映射到物理地址中的任何一个页,这使得内存管理变得相当灵活。
  • PDE页目录表,PTE页表

3.4 中断和异常

  • IDT:中断描述符表,描述符可以是以下三种

    • 中断门描述符
    • 陷阱门描述符
    • 任务门描述符
  • IDT作用是将每一个中断向量和一个描述符对应起来,IDT也是一种向量表

  • 异常的三种类型

    • Fault错误:可被更正的异常,且被更正后,程序可以不失连续性地执行
    • Trap陷阱:发生trap地指令执行后立即被报告的异常
    • Abort异常:不总是报告异常发生位置的异常,不允许程序或任务继续执行,用来报告严重错误
  • 中断产生的原因有两种

    • 一种是外部中断,即由硬件产生的中断
    • 另一种是由指令 int n 产生的中断,n即为向量号

3.5 保护模式下的I/O

IOPL和IO许可位图

3.6 小结

保护模式有以下几方面含义:

  • 在GDT,LDT以及IDT中,每个描述符都有自己的界限和属性等内容,是对描述符所描述对象的一种限定和保护
  • 分页机制中的PDE和PTE都含有R/W以及U/S位,提供了页级保护
  • 也是存储的使用使应用程序使用的是线性地址空间而不是物理地址,于是物理内存就被保护起来
  • 终端不再像实模式下一样使用,也提供特权检验等内容
  • I/O指令不再随便使用,于是被端口保护起来
  • 在程序运行过程中,如果遇到不同特权级间的访问等情况,会对CPL,RPL,DPL,IOPL等内容进行严格的检验,同时可能伴随堆栈的切换,这都对不同层级的程序进行了保护

第四章 让操作系统进入保护模式

4.1 突破512字节限制

FAT12

FAT12文件系统的基本组成包括引导扇区(Boot Sector)、文件分配表(File Allocation Table)、根目录区(Root Directory Region)和数据区(Data Region)。

  • 引导扇区(Boot Sector)是文件系统的第一个扇区,包含了引导代码和文件系统的元数据,例如文件系统的标识、扇区大小等。引导扇区还包含一个特殊的标志,被用来指示磁盘是否可引导。
  • 文件分配表(File Allocation Table)是一个记录文件分配信息的表格,它跟踪文件在磁盘上的存储位置。FAT12使用12位来表示每个文件的分配状态,例如是否被占用或空闲,以及文件块的链接关系。
  • 根目录区(Root Directory Region)存储了存储设备的根目录的相关信息,包括文件名、扩展名、属性和起始簇号等。FAT12文件系统中的根目录区是固定大小的。
  • 数据区(Data Region)是存储实际文件数据的地方。文件系统使用文件分配表中的链接关系来跟踪文件数据在数据区的存储位置。

loader模块

一个操作系统从开机到运行,大致经历

graph LR

A[引导]-->B[加载内核到内存];
B-->C[跳入保护模式];
C-->D[开始执行内核]

因此在执行内核前,还需要几步,仅靠一个512B的引导扇区是无法加载内核到内存的,因此我们将这一步工作交给一个称为Loader的模块去实现,该模块没有内存限制。

一个简单的loader模块为

org 0100h

 mov ax, 0B800h
 mov gs, ax
 mov ah, 0Fh    ; 0000: 黑底    1111: 白字
 mov al, 'L'
 mov [gs:((80 * 0 + 39) * 2)], ax ; 屏幕第 0 行, 第 39 列。

 jmp $    ; 到此停住

随后通过nasm loader.asm -o loader.bin转为二进制文件,准备放入磁盘。

加载Loader入内存

这里boot首先要做出改变,首先引导扇区需要BPB头信息才能被识别,其次我们要在boot的代码里实现寻找loader模块,这里代码见书附磁盘。

随后我们就要将loader模块放入磁盘,让boot程序找到它并执行。

命令为

nasm boot.asm -o boot.bin
nasm loader.asm -o loader.bin
dd if=boot.bin of=a.img bs=512 count=1 conv=notrunc
sudo mount -o loop a.img /mnt/floppy/
sudo cp loader.bin /mnt/floppy/ -v
sudo umount /mnt/floppy/

该指令将软盘挂载到/mnt/floppy下,在将loader程序复制进去,以达到将loader放入软盘的目的,但是我的mnt目录下没有floppy文件夹,因此首先用指令mkdir /mnt/floppy/创建一个挂载软盘的区域

4.2 保护模式下的操作系统

  • Loader要做的
    • 加载内核到内存
    • 跳入保护模式

第五章 内核雏形

1 linux下asm文件的运行

运行hello.asm文件

; 编译链接方法
; (ld 的‘-s’选项意为“strip all”)
;
; $ nasm -f elf hello.asm -o hello.o
; $ ld -s hello.o -o hello
; $ ./hello
; Hello, world!
; $

[section .data] ; 数据在此

strHello db "Hello, world!", 0Ah
STRLEN  equ $ - strHello

[section .text] ; 代码在此

global _start ; 我们必须导出 _start 这个入口,以便让链接器识别

_start:
 mov edx, STRLEN
 mov ecx, strHello
 mov ebx, 1
 mov eax, 4  ; sys_write
 int 0x80  ; 系统调用
 mov ebx, 0
 mov eax, 1  ; sys_exit
 int 0x80  ; 系统调用

之后首先转换为.o二进制文件
nasm -f elf hello.asm -o hello.o
再进行链接得到可执行文件
ld -s hello.o -o hello,然而这句话会报错,因为书中系统为32位,而目前(2023)年基本为64位机,所以我们应用指令ld -m elf_i386 -s hello.o -o hello生成可执行文件
再运行./hello可看到输出Hello,World!

2 linux下汇编和C同步使用

书中例子

; 编译链接方法
; (ld 的‘-s’选项意为“strip all”)
;
; $ nasm -f elf foo.asm -o foo.o
; $ gcc -c bar.c -o bar.o
; $ ld -s hello.o bar.o -o foobar
; $ ./foobar
; the 2nd one
; $

extern choose ; int choose(int a, int b);

[section .data] ; 数据在此

num1st  dd 3
num2nd  dd 4

[section .text] ; 代码在此

global _start ; 我们必须导出 _start 这个入口,以便让链接器识别。
global myprint ; 导出这个函数为了让 bar.c 使用

_start:
 push dword [num2nd] ; `.
 push dword [num1st] ;  |
 call choose  ;  | choose(num1st, num2nd);
 add esp, 8  ; /

 mov ebx, 0
 mov eax, 1  ; sys_exit
 int 0x80  ; 系统调用

; void myprint(char* msg, int len)
myprint:
 mov edx, [esp + 8] ; len
 mov ecx, [esp + 4] ; msg
 mov ebx, 1
 mov eax, 4  ; sys_write
 int 0x80  ; 系统调用
 ret
void myprint(char* msg, int len);

int choose(int a, int b)
{
 if(a >= b){
  myprint("the 1st one\n", 13);
 }
 else{
  myprint("the 2nd one\n", 13);
 }

 return 0;
}

使用指令来运行代码
nasm -f elf -o foo.o foo.asm
gcc - m32 -c -o bar.o bar.c(这里和书上的不一样,这里生成32位可执行文件)
ld -m -elf_i386 -s -o foobar foo.o bar.o32位链接
./foobar

运行结果可以根据代码得出

3 ELF(Executable and Linkable Format)

ELF(Executable and Linkable Format)是一种常见的可执行文件和可链接文件的标准文件格式。它被广泛用于类Unix系统(如Linux)和其他操作系统上
操作系统内核位ELF文件格式,该文件格式由四部分组成(ELF头,程序头表,节,节头表),只有ELF头位置固定,其余部分由ELF头位置决定

  • 文件头ELF头(File Header):文件头位于ELF文件的开头,包含了描述整个文件的基本信息,如目标体系结构、入口点地址、段表和节表的位置和大小等。
  • 程序头表(Program Header Table):程序头表描述了在可执行文件加载时需要进行的段的布局和操作。每个条目描述了一个段在内存中的位置、大小、访问权限以及其他相关信息。程序头表对于可执行文件和共享库非常重要。

4 从Loader到内核

loader要做的两项工作:

  1. 加载内核到内存
  2. 跳入保护模式
  • 用loader加载ELF文件

使用附书磁盘中的文件 fat12hdr.inc,boot.asm,loader.asm首先编译连接生成磁盘驱动(boot.asm和loader.asm中已经include过fat12hdr.ihc),此时磁盘中仍没有内核。

此时运行驱动磁盘会显示No KERNEL

之后使用附书磁盘的kernel.asm文件,使用命令

nasm -f elf -o kernel.o kernel.asm
ld -m elf_i386 -s -o kernel.bin kernel.o
sudo mount -o loop a.img /mnt/floppy/
sudo cp kernel.bin /mnt/floppy/ -v
sudo umount /mnt/floppy/

之后再运行驱动磁盘只会显示Ready.字样,此时已将内核加载至内存

  • 跳入保护模式
  • 重新放置内核
  • 向内核交出控制权

以上操作按书上做即可

5 扩充内核

  • 切换堆栈和GDT
  • 整理文件夹与makefile
    • 此后可以更快地产生驱动磁盘,made in heaven
    • 只需make image即可
#########################
# Makefile for Orange'S #
#########################

# Entry point of Orange'S
# It must have the same value with 'KernelEntryPointPhyAddr' in load.inc!
ENTRYPOINT	= 0x30400

# Offset of entry point in kernel file
# It depends on ENTRYPOINT
ENTRYOFFSET	=   0x400

# Programs, flags, etc.
ASM		= nasm
DASM		= ndisasm
CC		= gcc -m32 -fno-stack-protector
LD		= ld 
ASMBFLAGS	= -I boot/include/
ASMKFLAGS	= -I include/ -f elf
CFLAGS		= -I include/ -c -fno-builtin
LDFLAGS		= -m elf_i386 -s -Ttext $(ENTRYPOINT)
DASMFLAGS	= -u -o $(ENTRYPOINT) -e $(ENTRYOFFSET)

# This Program
ORANGESBOOT	= boot/boot.bin boot/loader.bin
ORANGESKERNEL	= kernel.bin
OBJS		= kernel/kernel.o kernel/start.o kernel/i8259.o kernel/global.o kernel/protect.o lib/klib.o lib/kliba.o lib/string.o
DASMOUTPUT	= kernel.bin.asm

# All Phony Targets
.PHONY : everything final image clean realclean disasm all buildimg

# Default starting position
everything : $(ORANGESBOOT) $(ORANGESKERNEL)

all : realclean everything

final : all clean

image : final buildimg

clean :
	rm -f $(OBJS)

realclean :
	rm -f $(OBJS) $(ORANGESBOOT) $(ORANGESKERNEL)

disasm :
	$(DASM) $(DASMFLAGS) $(ORANGESKERNEL) > $(DASMOUTPUT)

# We assume that "a.img" exists in current folder
buildimg :
	dd if=boot/boot.bin of=a.img bs=512 count=1 conv=notrunc
	sudo mount -o loop a.img /mnt/floppy/
	sudo cp -fv boot/loader.bin /mnt/floppy/
	sudo cp -fv kernel.bin /mnt/floppy
	sudo umount /mnt/floppy

boot/boot.bin : boot/boot.asm boot/include/load.inc boot/include/fat12hdr.inc
	$(ASM) $(ASMBFLAGS) -o $@ $<

boot/loader.bin : boot/loader.asm boot/include/load.inc \
			boot/include/fat12hdr.inc boot/include/pm.inc
	$(ASM) $(ASMBFLAGS) -o $@ $<

$(ORANGESKERNEL) : $(OBJS)
	$(LD) $(LDFLAGS) -o $(ORANGESKERNEL) $(OBJS)

kernel/kernel.o : kernel/kernel.asm
	$(ASM) $(ASMKFLAGS) -o $@ $<

kernel/start.o: kernel/start.c include/type.h include/const.h include/protect.h \
		include/proto.h include/string.h
	$(CC) $(CFLAGS) -o $@ $<

kernel/i8259.o : kernel/i8259.c include/type.h include/const.h include/protect.h \
			include/proto.h
	$(CC) $(CFLAGS) -o $@ $<

kernel/global.o : kernel/global.c
	$(CC) $(CFLAGS) -o $@ $<

kernel/protect.o : kernel/protect.c
	$(CC) $(CFLAGS) -o $@ $<

lib/klib.o : lib/klib.c
	$(CC) $(CFLAGS) -o $@ $<

lib/kliba.o : lib/kliba.asm
	$(ASM) $(ASMKFLAGS) -o $@ $<

lib/string.o : lib/string.asm
	$(ASM) $(ASMKFLAGS) -o $@ $<
  • 添加中断处理
    • 这里出现了有关__stack_chk_fail_local的报错,上网查阅后找到解决方法
    • 在gcc编译时加上参数-fno-stack-protector即可,在makefile中更改位置为CC= gcc -m32 -fno-stack-protector
    • 这里不知道为什么会输出乱码,急急急(未解决),但好像使用光盘上的磁盘就没问题,应该是在编译环节出现问题。
    • 经过比对,发现光盘上的boot.asm文件在虚拟机转化为bin文件后在虚拟磁盘上的二进制数,与光盘上的虚拟磁盘上的二进制数不同,即boot文件已有区别,可能是32位机子和64位机子的问题。。。于此开始使用32位的ubuntu16.04

第六章 进程

6.1-6.2 迟到的进程与概述

进程是一个正在运行的程序的实例。它是操作系统进行资源分配和管理的基本单位。每个进程都有独立的地址空间、代码、数据和打开的文件等资源。它拥有自己的执行环境,并可以通过操作系统调度来与其他进程并发执行。进程之间是相互独立的,每个进程都在自己的地址空间中运行,并通过进程间通信(IPC)机制进行必要的数据交换。

线程是在进程内部执行的较小单位。一个进程可以包含多个线程,它们共享相同的地址空间和资源。线程在进程内并发执行,共享进程的上下文、数据和文件等资源。多线程可以实现并行处理,提高程序的性能和响应能力。线程之间的切换比进程之间的切换更轻量级,开销更小。

  • 以下是进程和线程的一些关键区别:
    • 资源拥有:进程是独立的执行实体,拥有自己的地址空间和资源,包括打开的文件、网络连接等。线程是进程内的执行单元,共享进程的资源。
    • 调度和切换:进程是操作系统进行调度和切换的基本单位。线程是在进程内调度和切换的基本单位,切换开销比进程切换小。
    • 通信和同步:不同进程之间的通信需要使用进程间通信(IPC)机制,如管道、消息队列、共享内存等。线程之间可以通过共享内存和同步原语(如锁、条件变量)进行直接通信和同步。
    • 容错性:由于每个进程拥有独立的地址空间,一个进程的崩溃不会影响其他进程。但是,线程共享进程的地址空间,一个线程的错误可能会导致整个进程崩溃。

6.3 最简单的进程

  • 进程切换时的过程
      1. 进程A运行中
      1. 时钟中断发生,ring1 -> ring0,时钟中断处理程序启动
      1. 进程调度,下一个应运行的进程(假设为B进程)被指定
      1. 进程B被恢复,ring0 -> ring1
      1. 进程B运行中
  • 想要实现这些功能,需要实现以下3项
    • 时钟中断处理程序
    • 进程调度模块
    • 两个进程

1 简单进程的关键技术

  • 进程的哪些状态需要保存
    • 只有被改变的才有保存的必要,因此寄存器的值需要保存起来
  • 进程的状态何需要以及怎样被保存
    • 进程被挂起时即刻保存,中断发生则立即执行
  • 恢复进程B的状态
  • 进程表的引入
    • 进程表:保存进程状态的数据结构,也称进程控制块PCB
    • 进程表相当于进程的提纲
  • 进程栈和内核栈
    • 在进程调度模块中会使用到堆栈,而寄存器被压到进程表之后,esp是指向进程的某个位置的
    • 为此,在进程调度后将esp指向内核栈,避免错误的发生
  • 特权级变换:ring1->ring0
    • 由外层向内层转移时,需要从TSS中取出内层ss和esp作为目标代码的ss和esp。因此必须事先准备好TSS。由于每个进程相对独立,将涉及到的描述符放在LDT中,为此,需要给每个进程准备LDT
  • 特权级变换:ring0->ring1
    • 跳转到中断处理程序的后半部分,“假装”发生了一次中断来启动进程A,利用iretd来实现ring0到ring1转移

2 第一步 – ring0 -> ring1

  • 操作系统启动第一个进程时的入口
restart:
	mov	esp, [p_proc_ready]
	lldt	[esp + P_LDT_SEL]
	lea	eax, [esp + P_STACKTOP]
	mov	dword [tss + TSS3_S_SP0], eax
restart_reenter:
	dec	dword [k_reenter]
	pop	gs
	pop	fs
	pop	es
	pop	ds
	popad
	add	esp, 4
	iretd

上述代码中:

  1. p_proc_ready是一个结构类型指针,指向该进程在进程表中的位置;当要恢复一个进程时,便将esp指向这个结构体的开始处,然后运行一系列pop命令将寄存器弹出;
  2. esp+P_LDT_SEL时选择子的位置,这个语句即对ldt_sel的初始化
    1. 将进程表结构体的第一个结构体成员regs的末地址赋给TSS中ring0堆栈指针域esp

接下来一次做一些优先级变化需要的准备工作

  1. 时间中断处理程序:这里先不需要完善的,只要能实现优先级跳转即可
  2. 化整为零:进程表,进程体,GDT,TSS,这四个关系可分为三个部分
    1. 进程表和GDT:进程表内的LDT Selector对应GDT的一个描述符,而这个描述符所指向的内存空间位于进程表
    2. 进程表和进程:进程表是进程的描述,进程在运行过程中如果被打断,各个寄存器的值都会保存在进程表中。此外,程序一定会使用堆栈,因此需要事先指定esp
    3. GDT和TSS:GDT中有一个描述符对应TSS,需要事先初始化这个描述符
  3. 使用restart函数,进行优先级转换

  • 回顾
    • 第一个进程启动过程:
        1. 进程体TestA()准备就绪
        1. 初始化GDT中的TSS和LDT两个描述符,以及初始化TSS(在init_prot()完成)
        1. 准备进程表(在kernel_main()中完成)
        1. 完成跳转,实现ring0->ring1(kernel.asm之restart)

3 第二步 – 丰富中断处理程序

  1. 让时间中断开始起作用
  2. 现场的保护和恢复
    中断例程中使用栈来保护寄存器值
  3. 赋值tss.esp0
    存储在ring0时栈指针的位置
  4. 内核栈
  • 内核栈(Kernel Stack)是操作系统内核为每个运行的进程或线程所分配的一块专用内存空间。它用于保存与进程或线程执行相关的上下文信息,包括函数调用栈、局部变量、寄存器状态等。
  • 每个进程或线程都有自己的内核栈,它在内核模式下使用。当进程或线程发生内核态的切换或触发中断时,当前的执行上下文将被保存到该进程或线程的内核栈中,以便在恢复执行时能够正确地还原上下文。
  • 内核栈通常位于内核地址空间中,并且具有固定的大小。为了确保安全和隔离,每个进程或线程的内核栈是独立的,不同进程或线程之间不会相互干扰。
  1. 中断重入
  • 中断重入是指在一个中断服务程序(ISR)正在执行时,又发生了同样或更高优先级的中断请求,导致中断服务程序被中断。当发生这种情况时,系统会挂起当前的中断服务程序,执行新到来的中断请求,并在处理完该中断后返回到原来的中断服务程序继续执行。
  • 中断处理程序是被动的,它知道中断发生时忠实的执行那段代码,不理会中断何时发生,因此我们需要设置一个全局变量限制中断处理程序的运行。
    • 设置一个全局变量即可,全集变量初值-1,当中断处理程序开始执行时它自加,结束时自加。在处理程序开头处这个变量值需要被检查一下,如果值不是0,说明发生了中断嵌套,直接跳到最后,结束中断程序的执行

6.4 多进程

    1. 添加一个进程B
void TestB(){
  int i=0x1000;
  while(1){
    disp_str("B");
    disp_int(i++);
    disp_str(".");
    delay(1);
  }
}
    1. 相关的变量和宏
    • 进程表,进程体,GDT,TSS
    1. 进程表初始化代码扩充
    • 使用Minix中定义的数组tasktab,该数组的每一项定义了一个进程的开始地址,堆栈等,至此可以用for循环来初始化进程表
    1. 初始化LDT,3,4均在上一步加上循环对多进程赋值即可
    1. 修改中断处理程序
    • 进程从“睡眠”状态到“运行”状态既是将esp指向进程表项的开始处,因此想要恢复不同的进程,只需要将esp指向不同的进程表即可

双进程图:

  • 添加一个进程的步骤总结
      1. 添加一个进程体
      1. 在task_table中增加一项(global.c)
      1. 让NR_TASKS加1
      1. 定义任务堆栈
      1. 修改STACK_SIZE_TOTAL(proc.h)
      1. 添加新进程执行体的函数声明(proto.h)

目前orange’s运转过程

6.5 系统调用

  • 系统调用(System Call)是操作系统提供给应用程序访问其功能和服务的一种接口。应用程序可以通过系统调用向操作系统请求执行特权操作,例如文件读写、网络通信、进程管理等。系统调用允许应用程序在用户态(User Mode)与内核态(Kernel Mode)之间进行切换,以便使用操作系统提供的特权功能。
  • 通过系统调用,应用程序可以利用操作系统的功能来完成一些只有操作系统才能执行的任务,例如访问底层硬件设备、进行进程间通信、分配内存等。系统调用提供了一种安全和受控的方式,使应用程序能够利用操作系统的特权功能,同时保护操作系统免受不当访问和恶意行为的影响。
  • 不同的操作系统具有不同的系统调用接口和调用约定。通常,应用程序需要使用特定的系统调用指令或函数来触发系统调用,并传递参数和获取返回值。操作系统会在接收到系统调用请求时,验证请求的合法性,并执行相应的操作,然后将结果返回给应用程序。
  • 系统调用是操作系统的核心组成部分,它提供了应用程序与操作系统之间的交互接口,使应用程序能够利用操作系统的功能和服务来实现各种任务。

1 实现一个简单的系统调用

  • int get_ticks()统计当前总共发生了多少次时钟中断
  • 更改TestA,使其打印当前ticks

    可以看到第一次打印出A0x0,第二次打印出A0x3,而两次打印之间的#共有三个,所以该系统调用函数一切正常

2 get_ticks()的应用

通过该函数可以写一个判断时间的函数,用来替代丑陋的delay()函数

  1. 8253/8254 PIT
    中断是由一个被称作PIT(Programmable Interval Timer)的芯片触发的.
  2. 不太精确的延迟函数,使用该函数替代原先的delay函数,delay函数之前是纯纯for循环
PUBLIC void milli_delay(int milli_sec){
    int t=get_ticks();
    while(((get_ticks()-t)*1000/HZ)<milli_sec){}
    //通过时间中断次数写延迟函数,比野蛮循环好,但精度不高
} 

6.6 进程调度

  1. 避免堆成–进程的节奏性
    将三个进程的延迟时间不同,而延迟的时间越长,干活时间越少,这与优先级的概念相吻合。
    因此我们可以通过ticks使用时间片轮转算法,及时间片的不同大小对进程进行优先级的确立和调度
    即使用6.5中的系统调用函数,通过设置不同的延迟时长,即可改变各进程的时间片大小
void TestA(){
  ****
  milli_delay(300);
  ****
}
void TestB(){
  ****
  milli_delay(900);
  ****
}
void TestA(){
  ****
  milli_delay(1500);
  ****
}


可以看出A,B进程运行次数比约为3:1;A,C进程运行次数比约为5:1.

  • 如果将延迟的过程拿到进程调度模块中实现,就可以实现进程的优先级,目前的调度算法可以算是时间片流转算法

  • 新的进程调度算法(时间片流转算法):

    • 在进程表中添加两个成员:ticks是递减的,从某个初值到0。定义另一个变量priority,其恒定不变,当所有的进程ticks都变成0时,再将各自的ticks赋值为priority,然后继续执行。
    • 同时,将所有进程的延迟时间全改为相同的值
    • 限制当一个进程的ticks用完之前,其它进程不能获得机会运行
    • 本次实验中,设置进程A,B,C的priority分别为150,50,30
    • 可以看出图片中打印字符数目比值接近15:5:3
  1. 优先级调度总结
    minix中,进程分为任务,服务,用户进程三种,为此设置了3个不同的优先队列,这里仅以get_ticks进入进程调度算法领域的大门

第七章 输入/输出系统

7.1 键盘

1 键盘中断

  • 新建一个文件keyboard.c,添加一个简单的键盘中断程序。
    • 结果是每次按一次键,打印一个星号。
    • 同时为了不受其他进程输出的影响,我们把其它进程的输出注释掉
    • 然而makefile后,出现一个星号就不再响应,看来事情还较为复杂

2 AT,PS/2键盘

AT键盘(又称为PC/AT键盘)是早期PC机使用的键盘接口标准。它使用5针DIN连接器,并且是较早版本的键盘接口。

PS/2键盘是后来引入的键盘接口标准,用于连接键盘到计算机。它使用6针Mini-DIN连接器,比AT键盘接口更小巧。

两种键盘接口在物理连接上有所不同,因此AT键盘不能直接连接到PS/2接口,反之亦然。然而,通过使用适配器,可以将AT键盘连接到PS/2接口,或将PS/2键盘连接到AT接口。

需要注意的是,随着技术的进步,USB接口逐渐取代了AT和PS/2键盘接口,成为现代计算机键盘的主要标准。

3 键盘敲击的过程

  • 键盘敲击的过程:键盘敲击有两个方面的含义

    • 动作:
      • 按下
      • 保持按住的状态
      • 放开
    • 内容:键盘上不同的键,字母键还是数字键等
  • 敲击键盘所产生的编码被称作扫描码(Scan Code),分为Make Code和Break Code

    • Make Code :当一个键被按下或保持住按下时产生
    • Break Code:当键弹起时
    • 除Pause键外,每个按键都对应一个Make Code和Break Code
    • 扫描码总过有三套,Scan code set 1/2/3
  • 过程:

    • 当8048检测到一个键的动作后,会把相应的扫描码发送给8042,8042会把它转换成相应的Scan code set 1的扫描码,并将其放置在输入缓冲区中,然后8042告诉8259A产生中断,此时如果键盘又有新的键被按下,8042将不再接受,一直到缓冲区被清空,8042才能接受更多的扫描码
    • 因此只有把扫描码从缓冲区中读出来后,8042才能继续响应新的按键
  • 修改程序

    • 在键盘中断中加入in_byte(0x60),此时按一下键出现两个星号,每次按键产生一个Make Code和一个Break Code
    • 进一步修改键盘中断,此时键盘按下“a”,“9”可以看到前4组代码0x1E,0x9E,0xA,0x8A,实际这就是“a”“9”的Make Code和Break Code
PUBLIC void keyboard_handler(int irq){
  u8 scan_code = in_byte(0x60);
  disp_int(scan_code);
}

4 用数组表示扫描码

  • 用数组表示扫描码

    • 建立一个数组,以扫描码为下标,对应的元素就是对应字符
  • 新的问题及解决

    • 8042的输入缓冲区大小只有一个字节

5 键盘输入缓冲区

  • 因此需要实现一个缓冲区,放置中断例程中受到的扫描码
  • 代码很简单,如果缓冲区已满,则直接将收到的字节丢弃
PUBLIC void keyboard_handler(int irq){
  u8 scan_code = in_byte(KB_DATA);
  if (kb_in.count < KB_IN_BYTES){
    *(kb_in.p_head)=scan_code;
    kb_in.p_head++;
    if(kb_in.p_head==kb_in.buf + KB_IN_BYTES){
      kb_in.p_head=kb_in.buf;
    }
    kb_in.count++;
  }
}

6 新加任务处理键盘操作

7 解析扫描码

  • 解析扫描码的复杂性

    • 不但分为 Make Code ,Break Code
    • 且有长有短,功能多样,如Home键对应一种功能而不是ASCII码
  • 思路:由简至繁,最终可以显示大部分字母,并对一些功能按键不做反应