diff --git a/_config.butterfly.yml b/_config.butterfly.yml index ea12ff7..7627210 100644 --- a/_config.butterfly.yml +++ b/_config.butterfly.yml @@ -50,7 +50,7 @@ cover: default_cover: /img/cover.jpg index_post_content: - method: 3 + method: 2 length: 500 toc: diff --git a/source/_posts/blog-mig.md b/source/_posts/blog-mig.md new file mode 100644 index 0000000..3c529fa --- /dev/null +++ b/source/_posts/blog-mig.md @@ -0,0 +1,36 @@ +--- +title: 短文 - 博客迁移小记 +date: 2024-10-13 20:35:27 +tags: + - 短文 + - 博客 + - 自言自语 +categories: + - 技术 +--- + +最开始想的迁移方案是使用 `skip-render` 标记 html,但始终觉得不够优雅,因为导航栏、个人信息、头图之类的内容时常都会变,如果 skip-render 那永远都会是当时那个版本的页面,甚至可能超链接都是失效的,除了能显示原本的博文之外其实体验应该是相当差的——横竖感觉就是很突兀嘛! + +直到今天突然意识到,hexo 渲染 markdown 为 html 文本肯定会分为三个大部分: + +1. 正文前部的各种元素,例如头图、导航栏、侧边栏等 +2. 正文的元素 +3. 正文后部的各种元素,例如版权、脚注等 + +一拍大脑, markdown 天生兼容 html 啊!!! + +理论上对于 html 元素 hexo 应该不会过多插手渲染,那我不是理论上可以直接找到之前渲染好的 html 文件中的正文部分直接复制到 markdown 里面然后 `hexo generate` 就完成了?! + +立马就开始动手实践,发现真的可以! + +不过随着 butterfly 版本的迁移,页内标签外挂 `tag` 的渲染结果从 `div-ul-li-button` 变成了 `div-button` 的层级,原本在 `li` 上的类名现在转移到了 `button` 上,遂赶紧尝试使用正则批量替换。这里用了 `
在一切开始之前,请允许我先简要地介绍一下关于这个实验的一切
+这一系列日志将是我对大二下学期操作系统实验课程中实验的一个整体回顾与记录。
+当然,在我写下这些的时候,我还完全不知道这份日志可以做到怎样的完成度,但我仍希望在我写下这些文字的暑假里能够以一系列详实、可复现的日志作为我对过去这一个学期里的这样一门有趣的课程的一个交代。
+我希望,通过这一系列日志,可以
+那么,准备好了吗?
+我们开始吧!
在介绍具体的安装方法前,先在此列出所有需要的环境,方便快速对照安装
+由于实验的需要,以下环境是必须准备的
+Linux环境,因此需要准备一个Linux设备,在我的实现中使用了VMWare WorkStation与Ubuntu 20.04 LTS的组合Linux环境下的虚拟机,用来运行编写的操作系统makefile需要GCC,但是由于操作系统涉及到很多*bare-metal C++*也就是脱离C/C++标准库的代码,GCC在编译的时候会遇到一些棘手的问题,这些问题当然在LLVM中也会遇到,但相对GCC更好解决一些,故在我的实现中最后改为了使用LLVM作为编译器为了提升在实验过程中的编写、调试等体验,以下环境是可选的,并且在我的实现中安装的
+在Windows下
asm语法高亮C/C++语法高亮CMake支持Git可视化管理工具,提供了比VSCode原生更丰富的功能,包括分支等Linux环境支持,使得可以在Windows环境下直接编写Linux环境中的代码并且进行编译等基本操作PWS/CMD工具Microsoft Store中提供的开源的漂亮的命令行工具,用于连接Linux shell.o文件看看里面发生了什么奇怪的错误在Linux下
GDB工具Ubuntu版本以进行实验时的20.04LTS版本为例,其他版本的安装方式基本相同
+前往Ubuntu官网处下载对应版本的镜像文件
+
下载页面会随时间改变,如果需要下载旧版本Ubuntu,可以进入alternative downloads页面下载
+下载得到一个文件名如ubuntu-20.04.4-desktop-amd64.iso的镜像文件
有了镜像文件以后,就可以进入下一步,在虚拟机中使用该镜像安装系统了
+由于我在实验中使用的是VMWare Workstation,此处以VMWare Workstation Pro 16为例进行介绍,其他虚拟机可以使用搜索引擎查询对应Linux配置方式
+在VMWare Workstation中安装Ubuntu的步骤如下:
+自定义(高级)Workstation 16.2.x稍后安装操作系统Linux及Ubuntu 64位4~8GiBNATLSI LogicSCSI创建新虚拟磁盘硬件配置如下
+
为了从上一步下载的镜像文件中安装系统,需要编辑虚拟机设置,在CD/DVD的连接栏选择使用ISO映像文件并设置为上一步中下载的镜像文件,同时勾选启动时连接
+如果设备中没有找到CD/DVD,需要通过下方添加添加一个CD/DVD驱动器
+在启动虚拟机之前,由于在某些情况下虚拟机的显示分辨率会太低导致显示不出安装界面的下一步按钮,因此在启动之前可以在虚拟机设置中调整显示器分辨率。
+这里将显示器设备中的监视器一栏设置为指定监视器设置并设置最大分辨率为当前显示器的分辨率
+如果这一步没有产生效果,并且在安装时确实遇到了无法点击按钮的情况,可以在第一步选择Try Ubuntu进入桌面
之后右键在Display Settings中设置分辨率和缩放
如果没有合适的缩放,可以开启Fractional Scaling进行小数位缩放,可以多出125%、150%和175%的缩放比设置
设置好后启动虚拟机,进入.iso文件中的系统,启动后会自动进入Ubuntu安装程序
如果这一步无法启动,检查CD/DVD是否设置为启动时连接
+安装步骤如下:
+Install Ubuntu进入安装Minimal installation进行最小安装,同时可以取消勾选Download updates while installing Ubuntu,避免因为遇到无法访问的情况卡住安装,更新可以在后续换源后进行,这里可以不着急Installation type步骤可以直接选择Erase disk and install Ubuntu,如果有自己的分区喜好,可以选择Something else,具体分区方式网上有完备介绍,可以跟随教程设置,完成后选择Install NowShanghaiContinue执行安装即可在安装完成之后,系统会提示是否重启系统,由于需要关闭CD/DVD驱动器以免再次进入安装系统,在这一步不进行重启系统,关闭该通知进入系统桌面,右上角菜单选择关机,关机过程中会有一个提示移出安装媒介并按回车键,直接按回车关机即可
+关机后进入编辑虚拟机设置页,移出CD/DVD设备或是取消勾选该设备的启动时连接
+完成后启动虚拟机进入安装好的系统,开机后使用安装时设置的用户密码登录,初次启动会有一些提示框,基本一直下一步即可进入桌面
+进入系统以后同样会遇到分辨率问题,可以参照上文调整分辨率和缩放
+一切准备好之后,就可以进行下一步的换源操作了
+换源实质上就是更改系统所使用的软件源的配置文件,因此要做的事就只有两件:
+清华源中给出的Ubuntu 20.04 LTS版本的配置文件如下,复制该配置文件内容
+1 | # 默认注释了源码镜像以提高 apt update 速度,如有需要可自行取消注释 |
对于不同版本的Ubuntu,配置文件会有所不同,体现在链接后面的focal字样,该字段是Ubuntu的版本号,例如Ubuntu 18.04的版本号就是仿生鱼bionic而Ubuntu 22.04LTS版本号为jammy
如果选择了错误的配置文件,会使得后续apt update环节出现奇怪的错误,因此一定要选择版本对应的配置文件
在终端中输入
+1 | sudo vim /etc/apt/sources.list |
如果还没有安装vim,可以
+sudo apt-get update && sudo apt-get install vim安装vim继续首先注释掉原先的内容,然后在文件尾追加上文中复制的配置文件,之后保存退出
+在vim中使用I进入编辑模式,使用ESC退出编辑模式
在非编辑模式下输入:wq保存并退出,输入:q退出,输入:q!放弃更改退出
在vim中可以使用如下方式批量添加注释
+Ctrl+V进入列选择模式J / K / ↓ / ↑ 移动光标选中行Shift+I(这一步会让光标移到选中列的第一行第一个字符之前,但实际上光标在所有选中行的第一个字符之前)Shift+3(输入注释符#)ESC退出保存了新的配置文件以后,执行如下命令执行更新
+1 | sudo apt-get update |
执行了更新之后就完成了换源的全部步骤,可以进入下一步安装其他软件环境了!
+安装C/C++环境
+终端执行以下命令
+1 | sudo apt install binutils |
或者使用一行命令
+1 | sudo apt install binutils && sudo apt install gcc |
检查是否安装成功
+1 | gcc -v |
如果输出gcc的版本号则表明安装成功
安装NASM
+首先检查已经安装的nasm版本
1 | nasm -v |
如果已经是2.15^版本则可以跳过本节,如果不是的话则需要安装2.15^版本的nasm
首先根据网上教程的指引,下载压缩包
+1 | $ wget https://www.nasm.us/pub/nasm/releasebuilds/2.15.05/nasm-2.15.05.tar.xz |
然后进行解压和配置文件生成
+1 | $ tar -xvf nasm-2.15.05.tar.xz |
上面的bash代码不要直接复制,其包含了输出的显示
+只有 $ 后的内容才需要输入,其他的内容为预期的输出显示
+最后进行make安装
1 | make |
安装LLVM
+如果打算使用LLVM作为编译器,则可以参照官网安装指南进行安装,下面给出脚本安装方式
1 | sudo bash -c "$(wget -O - https://apt.llvm.org/llvm.sh)" |
安装后的clang可能不叫作clang,因此直接执行clang -v可能会报错未安装
输入clang后按TAB两次可以看到安装了的clang版本,例如clang-14和clang++-14,这样输入clang-14 -v就能够显示版本了
之后在编写makefile的时候也要注意不要将编译器写为单独的clang,而是带后缀的安装了的版本
+安装其他工具
+终端执行以下命令
+1 | sudo apt install git |
或是输入一行指令
+1 | sudo apt install git && sudo apt install qemu && sudo apt install cmake && sudo apt install libncurses5-dev && sudo apt install bison && sudo apt install flex && sudo apt install libssl-dev && sudo apt install libc6-dev-i386 && sudo apt install gcc-multilib && sudo apt install g++-multilib |
通过设置Ubuntu环境下的SSH,可以让Windows能够通过SSH连接到Ubuntu环境的shell,从而可以在Windows环境下完成大部分操作
+除此之外,通过VSCode中的Remote插件,可以在Windows环境下编写Ubuntu中的文件
+如果你希望在Ubuntu中完成所有代码的编写,则这一步不是必须的
+通过如下命令安装ssh
1 | sudo apt-get install ssh |
之后配置密钥登录所必须的文件目录和文件并设置权限
+1 | mkdir ~/.ssh && cd ~/.ssh |
通过修改sshd配置文件关闭密码登录并打开密钥登录
打开sshd_config文件
1 | sudo vim /etc/ssh/sshd_config |
之后在# Authentication:下修改如下键值,同时如果有注释则关闭注释
1 | PubkeyAuthentication yes |
保存退出后重启sshd服务
1 | service sshd restart |
之后,需要在VMWare Workstation里转发22端口到虚拟机
为了完成这个任务,需要先获取虚拟机的ip地址
1 | $ ip a |
可以看到输出中给出的ipv4地址,这里假设为192.168.85.130,记住该地址
进入VMWare Workstation中的 编辑-虚拟网络编辑器-更改设置-VMnet8 NAT模式-NAT设置,添加端口转发规则
| 主机端口 | +类型 | +虚拟机IP地址 | +虚拟机端口 | +描述 | +
|---|---|---|---|---|
| 22 | +TCP | +192.168.85.130 | +22 | +SSH | +
保存并应用设置就完成了端口转发的添加
+最后,为了能够让Windows能够顺利连接上虚拟机,需要在Windows环境下生成密钥并且拷贝公钥到上文中创建的authorized_keys文件中
下述操作将在Windows环境中进行
+首先,需要在Windows环境下安装OpenSSH,参照Microsoft官方文档给出的指南,可以使用Powershell安装OpenSSH
以管理员身份运行Powershell并且检查OpenSSH的安装情况
1 | Get-WindowsCapability -Online | Where-Object Name -like 'OpenSSH*' |
如果两者均没有安装,则会返回如下输出
+1 | Name : OpenSSH.Client~~~~0.0.1.0 |
之后,根据需要安装对应未安装的组件
+1 | # Install the OpenSSH Client |
这两者都应该返回如下输出
+1 | Path : |
之后,使用终端生成密钥
+1 | ssh-keygen -t rsa |
根据提示设置对应的文件位置(默认为%USERPROFILE%\.ssh\id_rsa)以及密码
然后使用cat命令显示公钥内容,假定公钥文件为C:\Users\Linloir\.ssh\id_ed25519.pub
1 | cat C:\Users\Linloir\.ssh\id_ed25519.pub |
会返回一个类似如下的输出
+1 | ssh-ed25519 AAAAC3N******************TzC6 linloir@****** |
拷贝该输出,并回到Linux操作
如果希望在Windows和Linux虚拟机间复制内容,可以在VMWare中安装VMWare Tools或Open VM Tools
+如果VMWare Tools安装选项为灰色,可以参照网上的博客进行如下操作
+在测试过程中,安装VMWare Workstation自带的VMWare Tools会提示建议安装Open VM Tools
+可以结束安装并参照官方文档或是Github页面中的指南使用sudo apt-get install open-vm-tools-desktop安装
在Linux终端中使用vim修改authorized_keys文件
1 | vim ~/.ssh/authorized_keys |
进入编辑模式粘贴上文中复制的密钥内容后保存退出
+最后可以在Windows环境下使用下述命令测试连接到虚拟机
在<username>中填写Linux用户名,<ip>填写前文中获得的虚拟机ip地址
1 | ssh <username>@<ip> |
如果连接成功,则证明完成了SSH的全部配置内容,同样,也完成了Ubuntu环境的全部配置!
+接下来,可以直接进入下一节或是跟随教程完成Windows下使用工具的配置和安装
+如果希望在Linux环境下完成所有代码编写与调试,可以跳过此节
+为了提升代码编写体验,在本节中将会介绍如何在Windows环境下配置和使用VSCode以及其插件,从而能够在Windows环境下使用VSCode编写Linux中的代码
+同样,还会介绍通过Windows终端连接Linux终端的方式,并在Windows环境下配置Linux环境中的ZSH终端以及powerlevel10k主题
+前往VSCode官网下载并安装VSCode
+安装完成后进入VSCode页面,在左侧栏中有一积木样图标,点击进入VSCode的插件安装页
+搜索并安装以下插件,具体安装的插件可以根据自己的喜好选择,其中Remote家族为连接虚拟机所需要的插件
在完成了Remote家族插件的安装后,侧边栏会有一远程连接的图标,点击可以进入远程资源管理器
选择SSH Target以添加虚拟机,之后在SSH TARGETS菜单处点击添加键,在弹出窗口中输入
1 | ssh <username>@<ip> |
在弹出的Select SSH configuration file to update页面中选择默认选项,也即直接按回车
之后在左侧SSH TARGETS菜单栏能够看到新添加的地址,鼠标移至其上方,点击其右侧文件夹图标在新窗口中打开
在Select the platform for the remote host窗口中选择Linux
之后输入在上文 配置SSH 中生成密钥步骤中设置的密码即完成连接
+快试试在侧边栏中的文件页面中打开Ubuntu中的项目文件夹吧!
+在我的实验中,在Windows中使用了Windows Terminal,在Linux中使用了ZSH+powerlevel10k的组合,你也可以根据你的喜好设置自己的终端和主题
首先,在Windows Terminal中使用ssh连接到虚拟机终端
+之后,根据ZSH Wiki页面的教程安装ZSH
+1 | # 安装 |
然后重新使用ssh登录,初次进入会进行设置,可以根据自己的喜好进行设置或者直接选择Populate your ~/.zshrc with the configuration recommended by the system administrator and exit进行默认设置
关于各个设置项作用将会在日后更新(如果有空)
+之后依据Oh My Zsh官网来安装Oh-My-Zsh框架
+1 | sh -c "$(wget https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh -O -)" |
完成设置就代表着Zsh安装完成,可以进行后续主题安装
+在安装powerlevel10k主题前,需要安装其依赖的字体
+双击打开下载的字体文件进行安装,之后进入Windows Terminal → 设置 → 配置文件 → 默认值 → 外观 → 字体,选择MesloLGS NF字体
然后使用git安装该主题
1 | git clone --depth=1 https://github.com/romkatv/powerlevel10k.git ${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/themes/powerlevel10k |
可以使用gitee加速下载
1 | git clone --depth=1 https://gitee.com/romkatv/powerlevel10k.git ${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/themes/powerlevel10k |
在~/.zshrc中设置ZSH_THEME="powerlevel10k/powerlevel10k并重启Zsh
1 | vim ~/.zshrc |
之后根据设置向导设置主题即可得到一个漂亮的终端啦
目前为止,环境已经基本配置完成了
+接下来就让我们开始愉快的操作系统实验之旅吧!
本章的序将会首先介绍操作系统是如何运行起来的,并在此基础上介绍实现一个完备的操作系统实验需要实现哪些方面,以及这些部分的先后顺序和依赖关系
+由于这份文档我并不打算作为一份完备的教程文档来编写,因此语言方面的介绍会相对简略或是跳过,对应的详细介绍可以参考学校的同步教程
+在本章的后半部分,将会介绍MBR和中断的相关知识,记录如何编写MBR、测试使用BIOS启动MBR引导程序并通过中断输出字符串进行测试
+在下一章节,将会介绍如何从MBR中加载Bootloader并进行更复杂的启动准备操作
+这里介绍的启动方式是x86架构下的BIOS启动过程,UEFI启动或是在arm架构下启动则是另一种启动方式
+由于本实验使用的是BIOS启动,因此不对UEFI启动和arm架构相关内容进行介绍,有兴趣可以使用搜索引擎进行了解
经典的BIOS启动过程分为了如下五个步骤:
+加电开机:
+按下电源开关以后,电源就会开始向主板和其他设备供电,由于电压还不稳定,主板上的控制芯片组会向CPU发出并保持一个reset信号,初始化CPU。当芯片组检测到电源已经稳定供电则会撤去reset信号,之后CPU立马开始从 0xFFFF0 处执行指令。需要注意的是,这些指令并不需要我们来编写,它们位于系统的BIOS地址范围内。在 0xFFFF0 处实际存放的是一个跳转指令,它指向BIOS真正的启动代码的位置,CPU执行该指令后便进入下一步 BIOS启动 过程
BIOS启动:
+在这一步中,BIOS会进行自检,也称为POST (Power-On-Self-Test),检查系统的关键设备比如内存、显卡等是否正常工作。如果检测到这些关键设备存在致命问题,则会通过蜂鸣音来报告错误,蜂鸣音的长短即次数对应错误类型。在完成自检之后,BIOS就会进入下一步加载MBR并启动
加载MBR:
+MBR,全称为主引导记录 (Master-Boot-Record) ,它存储在存储设备的首扇区512字节。MBR包含了如下的三个部分:
0xAA55 标记这个设备是可以启动的,也就是这512个字节的MBR是有效的因此在BIOS启动之后,它便会根据设置好的引导顺序,按排位顺序检查引导序列中对应地存储设备是否可以启动 (也就是检查MBR的最后两个字节是否是0x55AA),如果满足要求,则复制这512字节到内存中的 0x7C00 地址处,然后跳转到该地址开始执行MBR中启动相关的代码
关于为什么MBR是被加载到0x7C00,而不是加载到0x0000,可以参考Why BIOS loads MBR into 0x7C00 in x86 ?这篇文章
加载bootloader:
+在CPU跳转到 0x7C00 处之后,就会开始执行MBR中的代码,这部分代码将会由我们编写。在这部分代码中,MBR会从磁盘加载bootloader的代码进入内存,进入保护模式,然后跳转到bootloader处执行
加载kernel:
+进入bootloader以后,操作系统会进行相当多准备操作,例如加载文件系统、开启分页机制等,在完成这些工作之后,bootloader会从文件系统中装载系统内核进入内存,然后跳转到内核执行
当进入内核以后,操作系统就基本完成了启动的过程,内核会完成后续操作系统的初始化操作,这些将留到编写内核的时候再详述
+启动过程不是绝对的,从MBR之后的启动方式可以按照自己的设计进行
+例如,在MBR中可以加载类似grub的代码,在第一阶段加载文件系统,然后从系统路径里加载第二阶段的代码,并且在第二阶段中实现多系统引导,加载对应系统的kernel文件然后跳转运行
在这一章节中将会介绍在后续实验中将会大量使用的基础知识,包括了nasm的语法以及常用概念的解释等
+这些内容大部分在用到的时候也会在文章中再次出现
+所以如果想要尽快开始实验,不妨跳过这个章节,继续后面的内容
IA-32 处理器
+IA-32处理器是指从Intel 80386开始到32位的奔腾4处理器,是最为经典的处理器架构
+其有三种基本操作模式:保护模式、实地址模式(简称实模式)、系统管理模式和虚拟8086模式
+我们在操作系统实验过程中仅用到实模式和保护模式
IA-32处理器有8个通用寄存器eax, ebx, ecx, edx, ebp, esp, esi, edi
除此之外,还有6个段寄存器cs, ss, ds, es, fs, gs
以及标志寄存器eflags和指令寄存器eip
更多的寄存器
+后面的实验中,还会遇到更多寄存器,比如开启保护模式需要用到的cr0,存储页目录表基地址的cr3、存储产生缺页错误的地址的寄存器cr2等
+但是目前暂时只需要使用到上面的寄存器
在nasm中,除了可以直接使用上述的名字访问寄存器的全部内容,还提供了访问部分寄存器低位数据的访问方式:
| [31:0] | +[15:0] | +[15:8] | +[7:0] | +
|---|---|---|---|
eax |
+ax |
+ah |
+al |
+
ebx |
+bx |
+bh |
+bl |
+
ecx |
+cx |
+ch |
+cl |
+
edx |
+dx |
+dh |
+dl |
+
esi |
+si |
+– | +– | +
edi |
+di |
+– | +– | +
esp |
+sp |
+– | +– | +
ebp |
+bp |
+– | +– | +
同时,nasm还有一套约定俗成的规矩,用来指定寄存器的作用
其中,有一些规定是被 默认使用 的:
+| 寄存器 | +作用 | +
|---|---|
eax |
+在乘法和除法指令中被自动使用 同时也用做返回值寄存器, C或C++函数的返回值放置在eax中 |
+
ecx |
+在循环 (loop) 指令中被默认为计数器 也即将循环次数存储在 ecx中,loop指令只有当ecx为0时停止 |
+
esp |
+在对栈进行push和pop操作时,会自动从esp中取基地址也即 push eax实际上相当于sub esp, 4,mov eax, [esp] |
+
esi, edi |
+用于内存数据高速传送,本次实验中用不到 | +
ebp |
+通常用于在栈上取数据时使用,一般不用于算数或是数据传输 后续 C++与asm混编后会经常遇到关于ebp的内容 |
+
cs |
+16位代码段寄存器 | +
ds |
+16位数据段寄存器 | +
ss |
+16位栈段寄存器 | +
还有一些类似于习惯的规定,似乎不遵守它们也不会让程序出现错误:
+| 寄存器 | +作用 | +
|---|---|
eax |
+一般用于算数运算 比如 add eax, 4,因而也叫做累加寄存器 |
+
ebx |
+基地址寄存器,用于在取址时提供基地址 比如 mov eax, dword [ebx + 2]为取出ebx + 2位置上32位的数据放入eax |
+
edx |
+数据寄存器 | +
esi |
+源变址寄存器,用于在取值时提供偏移的地址 比如 mov eax, dword [ebx + esi + 2]或是mov eax, dword [esi + 2] |
+
edi |
+目的变址寄存器,用于在取值时提供偏移的地址 比如 mov dword [edi + 2], eax |
+
es, fs, gs |
+16位附加段寄存器,用来存放其他的段 | +
寻址
+基地址寄存器和变址寄存器涉及到 寻址 相关的内容,可以前往寻址页面查看
段寄存器
+段寄存器涉及到 保护模式 相关的内容,可以前往基本概念查看
标识符是我们取的名字,用来表示变量、常量、过程、函数或代码标号
+标识符满足如下要求和特性:
+_或是@,后续字符可以包含数字下面是一些有效的标识符
+1 | var1 |
下面是一些使用标识符的示例
+1 | ; Function |
标号是充当指令或数据位置标记的标识符,可以直接将其理解为一个地址值,指向的是其之后指令或是数据的起始地址
+例如
+1 | count dw 100 |
中count指向的就是其之后4字节长度的数据100的起始地址
如果有更长的数据,则可以这样写
+1 | pgdt dw 0 |
在.asm文件中,可以视为地址是从第一行代码向下增长的
这样写相当于声明了一个值为0x880000000000的数据
其低4字节为dw 0所声明的值,高2字节为dd 0x8800声明的值
而pgdt指向其低四字节的起始地址,也即该数据最低字节的地址
IA-32为小端序
+在实验中使用的IA-32处理器所使用的是小端序 (little-endian)
+这意味着数据的低位被放在低地址处而高位被放在高地址处
+对于一个多字节的数据,其标识符指向的是它的最低字节数据地址
标号也可以用于声明相当于C语言中数组的数据项
数据项之间以,分隔,标号指向第一个数据项最低字节的地址
1 | array dw 1024, 2048 |
上述代码按照1024,2048,4096,8192的顺序存放数据,同时array指向1024
标号也可以用来在过程中添加标记以方便跳转
+1 | Stage_1: |
这里,Stage_1并不会编译成汇编代码,而是会编译为mov eax, ebx这一步的地址值,jmp Stage_1相当于跳转到mov eax, ebx这句执行
标号后面的换行和空格并不会影响标号的值
+也就是说Stage_1: mov eax, ebx中Stage_1跟上面的代码拥有一样的标号值
如何理解寻址
+寻址可以理解为代码获得某个数据的方式
+例如,寄存器寻址 可以理解为代码通过寄存器获得数据
nasm有六种寻址方式,分别为
寄存器寻址:操作数存放在寄存器中,从寄存器中取得数据
+1 | mov eax, ebx ; eax = ebx |
立即数寻址:直接将立即数作为操作数
+1 | mov eax, 0x20 ; eax = 0x20 |
直接寻址:从立即数或是标号指向的地址处取得数据
+从立即数指向的地址取得数据
+1 | ; 从0x5C00处取得数据 |
也可以从标号指向的地址取得数据
+1 | tag dw 0x20 |
基址寻址:将基址寄存器所储存的数值视为地址并从该地址处取得数据
+基址寻址类似于数组的寻址,基址寄存器只能是寄存器bx或bp
1 | tag dw 0x20 |
基址寻址也可以使用基址寄存器和立即数来构成真实的偏移地址
+1 | tag dw 0x20 |
变址寻址:使用变址寄存器和立即数来构成真实的偏移地址,从该地址处取得数据
+变址寻址与基址寻址相似,变址寄存器只能是si或di
1 | mov eax, dword [esi + 4 * 4] ; eax = *(uint32*)(esi + 4 * 4) |
基址变址寻址:通过基址寄存器、变址寄存器、立即数来构成真实的偏移地址,从该地址处取得数据
+1 | mov eax, dword [ebx + esi + 4 * 5] ; eax = *(uint32*)(ebx + esi + 4 * 5) |
不够用的地址?
+在 实模式 下,IA-32处理器使用 20位 的地址线
+不难发现在这种情况下可以访问的内存范围为,范围从0x0000到0xFFFF
+但是寄存器只有 16位,意味着使用基址、变址寻址的时候没办法访问到所有的内存地址
为了解决这个问题,工程师提出了 段 的概念,使用了段地址之后,实际的地址可以表示为
++
也就是物理地址由左移4位的段地址加上偏移地址组成,这个偏移地址就由标号或是寄存器提供
+此段非彼段
+目前我们讨论的是 实模式 下的问题,此时的段是用来解决寄存器不足以访问所有的内存地址的问题
+在后面的 保护模式 中,还将看到另一个段定义,它与本节所述的段并不是同一个概念
段地址的使用一般出现在使用[]来寻址的操作中,因为在这种情况下[]内表示的是一个地址,而这个地址很可能由一个16位的寄存器或是标号提供
添加了段地址的寻址语法为
+1 | mov ax, word [ds:bx] |
在默认的情况下,系统会根据使用的标号和寄存器自动指定段地址,指定的段地址与使用的基址寄存器有直接关系
+由于这里讨论的是实模式,所以只讨论16位寄存器
+对于bx、si、di,默认的段寄存器为ds
对于bp,默认的段寄存器为ss
对于基址变址寻址方式,默认的段寄存器以基址寄存器的使用为准
+1 | ; 下面的语句等价 |
下文中可能用到的标识符解释如下
+| 标识符 | +意义 | +
|---|---|
| reg | +寄存器 | +
| imm | +立即数 | +
| immX | +X位立即数 | +
| tag | +标号 | +
| mem | +使用寻址方式取得的内存数据,例如[ebx] |
+
1 | ; Syntax |
1 | ; Syntax |
imul完成整数乘法操作
1 | ; Syntax |
idiv完成整数除法操作
idiv只有一个操作数,此操作数为除数,而被除数则为edx:eax中的内容 (一个64位的整数)
操作的结果有两个部分
+eax寄存器中edx寄存器中1 | ; Syntax |
shl,shr表示逻辑左移和逻辑右移,空出的位补0
1 | ; Syntax |
inc,dec指令分别表示自增1或自减1
1 | ; Syntax |
and, or, xor分别表示将两个操作数逻辑与、逻辑或和逻辑异或后放入到第一个操作数中
1 | ; Syntax |
not表示对操作数每一位取反,neg表示对操作数取负
1 | ; Syntax |
jmp指令是无条件跳转指令,跳转到代码标号的指令处执行
1 | ; Syntax |
除了无条件跳转指令外,还有条件跳转指令
+1 | je <tag> ; jump when equal |
在使用条件跳转指令之前,要先进行判断,判断使用的是cmp指令
1 | ; Syntax |
一个条件跳转的示例如下,其展示了一个不适用loop指令循环10次的简单实现
1 | xor eax, eax |
push和pop为栈操作,负责将寄存器值压栈、弹栈
in、out为端口读写操作,它们的操作数非常严格
1 | ; Syntax |
其中,al/ax/eax根据端口位宽设置,如果对8位端口指定了16位输入/输出,则会连带该端口的下一个端口一并进行输入/输出
本页的内容基于OSDev Wiki关于实模式的介绍
+实模式是所有的x86处理器都有的一个16位运行模式
这个模式是为早期的操作系统设计的,它的出现远远早于保护模式
+即使现在的操作系统已经运行在保护模式下了,但是处于兼容性考虑,所有的现代操作系统都需要先从实模式开始运行,然后再切换到保护模式
+在实模式下,CPU只有20根地址线可用,也即可用内存为
++
为了让16位寄存器能够对全部内存地址空间进行取址,额外引入了段寄存器cs、ss、ds和es、fs、gs,地址的表示由段地址和寄存器表示的偏移地址组合而成
+
段地址保存在段寄存器中,其中es、fs和gs为附加段寄存器,可以由用户自行指定
本页的内容基于OSDev Wiki关于保护模式的介绍
+保护模式是目前英特尔处理器主流的运行模式,在保护模式中,处理器以32位模式运行,所有的寄存器也都为32位
+为了进入保护模式,操作系统需要在实模式下进行一系列操作:
+具体的切换方式让我们留到实验中再行讲解
+保护模式之所以叫做保护模式,因为其引入了 “段” 的概念,每一个程序都有它自己的段,一旦程序错误地访问其他段的地址空间,那么CPU就会产生异常
+段 (segment)
+段实际上是程序员人为划分的一块块连续的内存区域,或者称为地址空间
误区
+这里的段概念与实模式的段概念并不相同
也就是说,可以认为保护模式保护的是地址空间,防止程序代码错误地访问了非它自己的段地址空间,造成越界访问
+为了让CPU知道段的范围,工程师引入了全局描述符表的概念
+全局描述符表可以理解为一段连续的内存,类似于数组,其中存储了全局描述符,每个描述符都对应了一个段的设置
+为了取出对应的全局描述符,就如同用下标从数组中获取数据那样,CPU使用选择子从全局描述符表中获取描述符
+选择子除了包含了取出描述符所需要的下标信息,还包含了一些权限信息等,这些选择子会被存储在段寄存器中
+更多的保护内容
+除了段保护之外,保护模式实际上还包括了特权级的保护、页保护等
+例如,保护模式会阻值低特权级代码访问高特权级段空间,会依据特权级限制程序操作等
操作系统是由中断驱动的
+操作系统的最终目的是完成用户的任务,但很显然,用户不会时时刻刻都有需要操作系统完成的任务,同时,当用户没有向操作系统提交任务的时候,它应当去干点别的而不是傻傻地待在原地等着用户提交任务——这也就是中断的意义
+可以把操作系统想象成一个忙碌的打工人,当你向他提交了任务以后,他便会开始完成你提交的任务。但如果在他进行到一半的时候,你突然又想让他干点别的,该怎么办呢?对了!就是拍拍他的肩膀,说:“伙计,先停停手上的活,把这件事情做了,然后再回来做现在的活儿。”
+中断对于操作系统的作用也正是这样,通过中断,可以让CPU暂停当前正在运行的代码,转而对产生中断的信息进行处理,当处理完后再返回运行原先的代码
+通过中断,可以让操作系统在等待用户/内核打断的同时进行其他的任务,而不需要在原地循环等待用户的下一步指令
+又由于操作系统实质就是无数个 中断-处理-返回 的过程,所以可以说这就是操作系统进行任务的最根本方式,所有的操作都要经由中断来实施,所以可以说 “操作系统是由中断驱动的”
第零阶段:知识准备
+环境搭建
+在这一步中,需要搭建实验所必需的Linux环境以及虚拟机qemu和C、C++、asm编译器等
如果读完了 操作系统日志 第一章 的话,那么这一步已经完成了
+汇编语法与操作系统概念
+由于实验的第一部分 ( 编写MBR ) 就需要使用到汇编语法,因此首先需要了解的就是nasm汇编语法
其次,为了能够对实验有整体的把握,建议先完整阅读 《 操作系统概念(第九版)》 对操作系统有一个整体视角再着手开始实验,这样更有利于落实自己的想法并体验到实验的乐趣所在
+当然,如果是正在跟随课程实验进行,尚不能提前通读整本书的话,也不要担心,因为随着实验的进行也是完全能够循序渐进地认识操作系统的各个概念的
+如果阅读了前文的 前序知识 的话,那么就已经足够可以让我们编写MBR了,让我们继续吧
+第一阶段:启动Kernel
+MBR
+本节需要完成:
+bootloaderbootloader运行bootloader
+本节需要完成:
+kernelloaderkernelloader运行准备
+从这一节开始,将会从asm编程切换到C++和内联汇编组合编程
为了能够使得操作系统能够方便地组织和使用硬件资源,需要对硬件的接口进行 抽象,编写合适的 驱动
+同时,为了加强代码的可读性,需要将常用的结构包装为 类
+驱动的编写和结构的封装并不需要在这一步就全部完成,而是随着后续代码的编写而逐渐完善
+为了后续实验顺利进行,在这一步需要完成如下驱动:
+UART驱动同时需要完成如下结构体:
+pagetable:页表page:页frame:帧elf head:ELF头kernelloader
+本节需要完成:
+elf parserkernel.oelf parser解析kernel.o中的 ELF头 并加载内核入内存正确地址处想要支持多系统?
+在linux的实现中,多系统通过grub提供引导,关于grub的更多信息可以查看 GNU GRUB的Wiki页面
第二阶段:内核态
+堆分配器
+实现malloc和free
从此节开始后,将可以使用malloc和free函数
+这也意味着同样也可以使用vector等等各种数据结构了,代码的编写自此进入现代化
页帧分配器
+实现 物理页帧 的管理和分配
+虚拟页管理器
+实现 虚拟页 的管理和分配,实现 虚拟页到物理页的映射
+描述符表
+实现GDT、IDT和TSS的抽象,并初始化描述符表
中断
+实现 中断处理函数
+本节中会涉及部分用户进程的知识
+由于用户进程和内核进程都需要使用中断,同时用户进程和内核进程进入中断时在栈上的行为还天杀的不一样
+为了避免反复编写重复代码,会提前介绍用户进程的行为以及如何编写代码解决其中的问题
内核进程
+实现 内核进程 以及 进程管理器
+完成本节后,就有了进程调度机制,可以支持并发了
+第三阶段:用户态
+系统调用
+由于用户进程不能直接运行内核相关的代码,因此需要实现系统调用来在对用户进程透明的情况下执行内核相关代码
+用户进程
+在完成系统调用之后,就可以实现 用户进程 了
+Shell
+实现一个 Shell 程序提供用户交互
+真正的实验从这里开始
+首先给项目创建一个仓库,不妨叫做MyOS
之后,为项目创建一个编译用文件夹build,一个存放镜像文件的文件夹run,一个存放所有代码的文件夹src
由于目前我们需要完成的是操作系统启动的部分,因此本节的代码mbr.asm放置在src/boot文件夹下,同时,为了能够方便地编译项目,在build文件夹下创建项目的makefile文件
创建完成之后的项目目录看起来就像这样
+1 | . |
Hello World实验作为各大语言的必经之路,在操统实验中自然也是必不可少的
+在尝试输出代码之前,先研究一下怎样让操作系统显示出我们想要的东西…
+qemu显示屏实际上是按25x80个字符来排列的矩阵,如下所示
+
为了便于控制显示的内容,IA-32处理器将这个矩阵映射到了内存地址的0xB8000~0xBFFFF处,这段地址被称为显存地址
在文本模式下,控制器的最小可控制单位为字符。每一个显示字符自上而下,从左到右依次使用显存中的两个字节表示,低字节表示显示的字符,高字节表示字符的颜色属性。
+例如,黑底白字的H在显存中如下存放:
| 显存地址 | +值 | +含义 | +
|---|---|---|
| … | +- | +- | +
| B800:0001 | +0x0F | +背景色黑色,前景色白色 | +
| B800:0000 | +0x48 | +字符H |
+
每个字符的颜色又由两部分组成,高4位为字符的背景色,低4位为字符的前景色,每个颜色由R、G、B和K/I位组成
K/I位含义如下
| K/I | +背景色 | +前景色 | +
|---|---|---|
| 0 | +不闪烁 | +深色 | +
| 1 | +闪烁 | +亮(浅)色 | +
字符颜色的对照表如下
+| R | +G | +B | +颜色 | +
|---|---|---|---|
| 0 | +0 | +0 | +黑色 | +
| 0 | +0 | +1 | +蓝色 | +
| 0 | +1 | +0 | +绿色 | +
| 0 | +1 | +1 | +青色 | +
| 1 | +0 | +0 | +红色 | +
| 1 | +0 | +1 | +品红 | +
| 1 | +1 | +0 | +棕色 | +
| 1 | +1 | +1 | +白色 | +
也就是说,0x0F对应的是 背景色为黑色不闪烁,前景色为亮白色的颜色
于是,记 为第 行 列,如果想要在 处显示一个字符,则需要做
+其中,由于一行有80列,每个字符占用两个字节的空间,因此偏移地址计算的公式如下
++
之所以要用偏移地址,是因为很容易发现,0xB8000这一个起始地址已经超出了16位所能表示的范围,因此需要引入段地址,这里再回顾一次引用段地址后实际地址的计算公式:
+
很显然,将段地址设置为0xB800可以让偏移地址从0x0开始取值,因此不妨将段地址0xB800放置在附加段寄存器gs中,然后使用[gs:index]格式进行取址,例如
1 | mov byte [gs:0], 'H' |
就是将黑底白字的H显示在坐标 处
除了使用显存赋值这一操作来显示字符,在实模式下还提供了另一种显示字符的方式——中断
+中断的调用涉及了四个部分:
+bx、cx和dx寄存器或是它们的高低位中的值ax或是ah指定int指令和对应的中断向量号调用中断BIOS提供了很多中断函数,中断向量号和它们的功能可以查看 OSDev Wiki中关于BIOS的页面
+在本次实验中我们暂时只关注int 10h这个函数,它提供了光标相关的操作,对应的参数和返回值可以参考 Wikipedia 中关于光标中断的页面
+为了方便查阅,显示字符所需要用到功能在下表列出
| 功能 | +功能号 | +参数 | +返回值 | +
|---|---|---|---|
| 设置光标位置 | +AH=02H |
+BH:页码DH:行,DL:列 |
+- | +
| 获取光标位置 | +AH=03H |
+BX:页码 |
+AX=0CH:行扫描开始,CL:行扫描结束DH:行,DL:列实验中一般只需要用到 DH和DL |
+
| 在光标位置写入字符 | +AH=09H |
+AL:字符,BL:颜色BH:页码,CX:输出字符个数 |
+- | +
所以在 处写入字符需要如下步骤:
+例如,在 处写入黑底白色的字符H操作为
1 | move_cursor: |
回顾:MBR的加载
+计算机加电启动并完成自检之后,BIOS会根据引导顺序检查磁盘首扇区 (也即存放MBR的扇区) 是否可以启动
+如果可以启动,BIOS会将这512字节加载到0x7C00处开始执行
+此时,CPU运行在 实模式 下
为了让编译器能够正确理解 “我们是在编写MBR” 这件事,需要使用一些汇编的伪指令告知编译器:
+0x7C00处执行翻译成asm语言如下
1 | [org 0x7C00] |
之后,需要对寄存器进行初始化,避免为初始化的段地址影响后续的取址操作
+需要注意的是,段寄存器只能通过ax寄存器赋值,所以可以先将ax置0,然后再通过ax将段寄存器置0
陷阱:不要初始化cs
+在初始化段寄存器的时候,通过实验发现,ds, ss, es, fs, gs都可以正常进行置0操作
+但是如果对cs进行置0,则会导致代码出现意料之外的行为
+所以不要将cs寄存器初始化
1 | ; Initialize registers |
此时栈指针寄存器sp还没有被初始化,由于栈是从高地址向低地址增长的,所以需要给栈分配一段可以向下增长的空间
+这一段空间的选择相对自由,但是由于MBR对栈空间的使用较小,并且当启动到后续阶段的时候也可以再移动栈指针,所以不妨将栈指针放置在0x7C00处让其向下增长
1 | ; Initialize stack pointer |
之后使用显存显示内容的方式来完成Hello World!字串的输出
1 | ; Print something |
陷阱:不要将数据放置在代码中间或者前部
+BIOS从0x7C00处开始执行代码,其并不区分内存中存储的二进制码究竟是数据还是指令,因此如果将数据放置在代码前面,可能会让CPU误认为数据是代码从而被执行,产生无法预料的错误,这类错误往往很难通过调试发现
不要让代码掉进 “虚无”
+可以在代码后面添加jmp $,其中$的意思是当前语句的地址,这句指令无限跳转到其自身执行
+这阻止了CPU继续执行这句代码之后的内容,因为后面可能是数据空间或是虚无,它们同样可能被解读成错误的指令从而产生无法预料的错误
最后,由于还没有开始做文件系统和磁盘分区,不妨将MBR后续字节填充零,使用伪指令times来重复填充,$ - $$很容易解读出其意译为当前地址减去文件起始地址的差
1 | ; Fill 0 before byte 447 |
完整的mbr.asm文件如下
1 | [org 0x7C00] ; Indicates codes below starts at 0x7C00 |
Makefile Tutorial
+如果对Makefile语法尚不熟悉,可以前往 Makefile Tutorial 页面了解
为了让项目能够顺利地运行起来,需要编写Makefile文件。在默认已经了解Makefile语法的前提下,简要介绍从MBR启动所需要的各个文件以及它们的依赖关系
+| 文件 | +作用 | +依赖关系 | +
|---|---|---|
hd.img |
+硬盘文件 QEMU会尝试从这个文件的首扇区加载MBR启动 |
+mbr.bin需要将MBR写入磁盘才能够启动 |
+
mbr.bin |
+MBR编译后的二进制文件 | +mbr.asm |
+
mbr.asm |
+MBR源文件 | +- | +
根据这些依赖关系,可以写出Makefile文件
+1 | ASM_COMPILER := nasm |
进入项目目录下的build/目录
1 | make clean build run |
可以首先清除原先生成的文件,重新构建并且运行
+运行的结果如下

完成本章
+下一章中,将会编写bootloader,从mbr中加载bootloader并且启动,最后在bootloader中让CPU进入保护模式
+如果准备好了的话,就让我们进入下一章吧!
在本章的第一部分中,将会介绍读取硬盘的CHS方式、LBA方式以及如何通过in、out指令读写硬盘,之后会将上一章输出Hello World!的代码移植到BootLoader中,并且从MBR中加载并跳转到编写的BootLoader执行
在第二部分中,会回顾保护模式的概念并介绍进入保护模式的四个步骤,并在开启保护模式之后输出第二个Hello World
MBR只有512字节的大小,甚至如果除去分区表,只有四百多字节的大小,这个大小稍微复杂一些的程序就难以运行了,更不要说是把全部的操作系统都放在里面了
+因此,目前的首要任务就是 突破512字节的限制,从而能够让我们着手编写更复杂的程序,其实,聪明的工程师很早就提出了一个可行的解决方案:在MBR里面把另一段更大的程序加载到内存里面,之后跳转到加载的地址去执行,这个 “更大的程序” 就是我们本节要编写的BootLoader,它在编译之后会被烧录到硬盘镜像中指定的扇区里,MBR要做的事情就是从这些扇区里面把我们编写的BootLoader加载出来放在内存里,然后跳转到加载的内存地址处执行BootLoader的代码
+为什么不直接从MBR跳转到内核运行?
+从上一章的实验路线中可以看到,在加载内核之前有许多需要准备的工作,包括开启虚拟分页机制以及文件系统的装载
+很显然这些并不能在MBR中完成,所以需要在MBR和内核中间添加一些步骤,作为启动内核前的准备操作
CHS全称为 Cylinder-head-sector,是早期用来定位硬盘上扇区的一种方式
+在CHS中,扇区通过不同的 柱面 (Cylinder)、磁头 (Head) 和 扇区 (Sector) 指定,除此之外,在CHS中还有一个额外的概念 磁道 (Track) ,下面简要介绍这四个概念的含义
+柱面:
+磁盘是由很多层碟片堆叠而成的,可以想象为一个实心的圆柱体。如果用一个与磁盘同心的空心圆柱去切割 (或者说做相交的操作),则每一个磁盘都会被相交得到一个空心圆,这些空心圆的组合就叫做柱面,实际上,它们看起来也确实像一个圆柱的外表面,而这个圆柱与做相交时的空心圆柱大小是一样的
换一种说法,每一个碟片都是由很多个同心圆组成的,这些同心圆被叫做 磁道,从外到内由0开始编号,每个碟片上的磁道数都是相同而且对齐的,也就是说,如果我们俯视一个磁盘,那么最上面的碟片的磁道是怎么分布的,它下面的碟片也都是这么分布的,如果我们对所有碟片选定同一个磁道,那么它从上到下看起来就会构成一个圆柱的外表面,也就是 柱面,这个磁道编号就是柱面号
+磁头:
+一个磁盘有很多个碟片,对应地,每个碟片也有一个磁头用来读写数据,磁头从上往下由0开始编号,这个编号就是磁头号。一般来说,磁头会停留在一个同心圆 (也就是磁道) 上,并且可以在不同的磁道间移动
扇区
+磁盘的每一个扇区都可以按照旋转角度被等分为很多段圆弧,每一段圆弧就被称作一个扇区,扇区大小一般为512字节
扇区与数据密度
+很容易发现,如果对整个碟片上所有的磁道都按照一个单一的旋转角度来划分扇区,则每个扇区虽然对应的圆心角相同、数据容量相同,但是长度却不一样,这就会让外圈的磁道数据密度降低
+事实上,老式的磁盘使用的就是这种方式,确实会导致内外磁道数据密度不同的问题
+而目前有新的解决方式按照等密度的方式划分扇区,也就是越靠外的磁道扇区数越多
在实验中将使用经典的理解,也就是老式磁盘的划分方式,同时后面也会看到在LBA模式中这种差异将不会对取址产生影响
+磁道:
+每一个碟片都是由很多个同心圆组成的,这些同心圆被叫做 磁道。同时,磁道也可以理解为是由柱面和磁头对应的盘面相交得到的圆
也就是说,一个柱面和一个磁头可以唯一指定一个磁道,在CHS取址模式中不需要提供磁道号也正是这个原因
+如果还不能够完全理解,可以查看Wikipedia关于CHS模式的页面中精妙的配图如下
+
LBA模式全称为 Logical Block Addressing,顾名思义,就是通过逻辑扇区号去寻址物理扇区号,具体的寻址方式则是对用户透明的
+在LBA模式下,不存在像CHS那样复杂的概念,硬盘的扇区被看作是线性的并从0扇区开始编号,当使用LBA扇区号进行取址的时候,存储设备会自动转换成对应的柱面、磁头和扇区进行读取,用户不需要关心这部分的实现
+陷阱:CHS和LBA模式的扇区编号不同
+在CHS模式下,扇区从1开始编号,而在LBA模式下,扇区从0开始编号
通过CHS编号来计算LBA编号可以使用如下公式,其中HPC为柱面中的磁头数 Heads per Cylinder,SPT为每个磁道中的扇区数 Sectors per Track
+
同样,LBA模式也可以通过下述公式转换为CHS编号
++
接口和模式
+在实验中使用的接口为ATA,使用PIO模式进行访问
ATA PIO模式
+ATA PIO mode的更多信息可以查看OSDev中关于这一主题的页面
ATA、PATA、SATA的区别
+关于ATA、PATA、SATA以及ATA PIO之间的区别,可以查看Reddit中关于这个主题的讨论
了解了硬盘的寻址模式之后,就可以开始着手从磁盘里读取数据了,在实验中我就采用了更为 无脑 简单的LBA28模式
LBA28
+LBA28指的是使用28位来表示逻辑扇区的编号
既然要从磁盘读取数据,那就自然要告诉磁盘 读数据、去哪读和读多少,端口就在这个过程中充当了信使的角色,我们通过向端口发送数据,告诉磁盘读取地址和读取数量,等候磁盘完成后再从端口接收数据
+在磁盘读的操作中,要用到的端口及其描述如下
+| 端口号 | +端口数据传输方向 | +作用 (LBA28) | +描述 | +位长 (LBA28) | +
|---|---|---|---|---|
| 0x1F0 | +读/写 | +数据寄存器 | +硬盘读出/要写入的数据 | +16-bit | +
| 0x1F1 | +读 | +错误码寄存器 | +存储执行的ATA指令所产生的错误码 | +8-bit | +
| 0x1F1 | +写 | +功能寄存器 | +用来指定特定指令的接口功能 | +8-bit | +
| 0x1F2 | +读/写 | +扇区数寄存器 | +存放需要读写的扇区数量 | +8-bit | +
| 0x1F3 | +读/写 | +起始扇区寄存器 | +存放起始扇区0-7位 | +8-bit | +
| 0x1F4 | +读/写 | +起始扇区寄存器 | +存放起始扇区8-15位 | +8-bit | +
| 0x1F5 | +读/写 | +起始扇区寄存器 | +存放起始扇区16-23位 | +8-bit | +
| 0x1F6 | +读/写 | +磁盘、起始扇区寄存器 | +选择磁盘和访问模式 存放起始扇区24-27位 |
+8-bit | +
| 0x1F7 | +读 | +状态寄存器 | +读取当前磁盘状态 | +8-bit | +
| 0x1F7 | +写 | +指令寄存器 | +传送ATA指令 | +8-bit | +
其中,部分端口的位作用比较复杂,部分位在实验中也不需要留意,使用*号注明
+| 端口号 | +位 | +缩写 | +作用 | +
|---|---|---|---|
| 0x1F6 | +0-3 | +- | +在LBA模式中,指定其起始扇区号的24-27位 | +
| 0x1F6 | +4 | +DRV | +指定磁盘0:主硬盘1:从硬盘 |
+
| 0x1F6 | +5 | +1 | +始终置位 | +
| 0x1F6 | +6 | +LBA | +指定访问模式0:CHS模式1:LBA模式 |
+
| 0x1F6 | +7 | +1 | +始终置位 | +
| 端口号 | +位 | +缩写 | +作用 | +
|---|---|---|---|
| 0x1F7 (Read) | +0 | +ERR | +指示是否有错误发生,通过发送新指令可以清除该位 | +
| 0x1F7 (Read) | +1* | +IDX | +索引,始终置为0 | +
| 0x1F7 (Read) | +2* | +CORR | +修正数据,始终置位0 | +
| 0x1F7 (Read) | +3 | +DRQ | +0:硬盘还不能交换数据1:硬盘存在可以读取的数据或是可以写入数据 |
+
| 0x1F7 (Read) | +4* | +SRV | +重叠模式服务请求 | +
| 0x1F7 (Read) | +5* | +DF | +驱动器故障错误 | +
| 0x1F7 (Read) | +6* | +RDY | +0:驱动器发生了减速或是错误1:驱动器运转正常 |
+
| 0x1F7 (Read) | +7 | +BSY | +忙位0:空闲1:忙 |
+
了解了各个端口的作用后,如何从硬盘读取数据似乎就是显而易见的了:
+为了方便代码重用 (虽然MBR的代码也不会重用了),在实验中将读取硬盘的功能包装成一个汇编函数进行调用
NASM中的函数调用
+调用函数前,通过将参数压栈的方式传递参数
+使用call tag调用函数,在执行call指令时会将下一条指令地址压入栈顶
+在函数内部,需要使用pushad为主调函数保存寄存器的值
+在函数结束后,使用ret返回,在执行ret指令时会取出栈顶值并跳转到该值处执行
+调用函数后,通过执行add sp, <imm>操作将栈指针上移,从栈上删除传递的参数
令函数名为read_sectors,并假定主调函数向其传递四个参数值startSector[15:0]、startSector[27:16]、sectorCount和targetAddress,可以据此先写出函数体
1 | read_sectors: |
之后从栈上依次取出起始扇区号并发送到端口0x1F3~0x1F6
陷阱:注意正确设置0x1F6端口高4位
+0x1F6端口高四位包括了主从硬盘位和访问模式位
+如果误使用CHS模式读取硬盘,由于CHS扇区从1开始编号,会导致读出错误的扇区
陷阱:栈上的字长为2字节
+在16位模式中栈上的字长为2字节,因此使用bp在栈上取数据时步进为2而不是4
+也就是,如果使用上文中的函数开头,第一个参数的为word [bp + 2 * 2]
1 | mov bx, word [bp + 2 * 2] ; bx = startSector[15:0] |
然后通过0x1F2端口设置读取的扇区数量
1 | mov al, byte [bp + 4 * 2] ; al = sectorCount |
向0x1F7端口发送0x20指令请求硬盘读
1 | mov dx, 0x1F7 |
在发送读请求后,通过循环检测0x1F7端口的0、3、7位等待硬盘就绪,就绪的标志为这三位依次为0、1、0
陷阱:记得等待硬盘就绪
+在从端口读取数据前,一定要等待硬盘就绪,不然会读出错误的数据
1 | .wait_disk: |
最后,从0x1F0端口读取数据
0x1F0端口为16位端口,因此一次可以读取两个字节,总的读取次数为
+
1 | xor ax, ax ; Set ax = 0 |
所以完整的读取函数为
+1 | ; Loader sector function |
为了验证上一节中读取硬盘函数的正确性,现在需要在项目中创建BootLoader,由于目前我们还不打算在BootLoader中完成很复杂的任务,所以就先将上一节中打印’Hello World!'的代码移植到BootLoader中,然后尝试从MBR加载并跳转到BootLoader执行,看看能否正常运行
+首先我们需要扩充我们的项目结构:
+bootloader.asm,由于它同样属于启动过程,所以可以将它放置在/src/boot/目录下boot.inc,用于指定启动过程中用到的所有常数值,这个文件同样放置在/src/boot/目录下扩充后的项目结构大致像这样
+1 | . |
在开始移植之前,需要先确认BootLoader的起始地址以及它的大小,这涉及到内存地址的安排
+在操作系统内核设计的过程中,内存规划是一件令人苦恼的事情,但同时也是一件自由的事情,只要不发生溢出和重叠之类的问题,将内容放置在哪里是一个相对主观的事情
+这里我将BootLoader直接放置在MBR后面,占用4个扇区的大小,内存的安排如下
+| 用途 | +起始地址 | +终止地址 | +扇区数 | +
|---|---|---|---|
| MBR | +0x7C00 | +0x7E00 | +1 | +
| BootLoader | +0x7E00 | +0x8600 | +4 | +
完成了内存规划之后,就可以将常数写入头文件中了
+1 | ; Constants used during the boot procedure |
打印Hello World!代码的移植步骤就比较简单了,直接复制粘贴入bootloader.asm即可,不过需要注意的是要修改一下头部伪代码中的[org 0x7C00]为BootLoader的起始地址[org 0x7E00]
1 | "boot.inc" |
之后,对mbr.asm中的代码进行修改
向其中添加read_sectors函数并且调用read_sectors函数
1 | ; Read bootloader |
跳转到BootLoader的起始地址
+1 | ; Jump to bootloader |
最后,修改makefile文件,由于添加了两个文件,依赖关系也产生了变化,新的依赖关系如下
| 文件 | +作用 | +依赖关系 | +
|---|---|---|
hd.img |
+硬盘文件 QEMU会尝试从这个文件的首扇区加载MBR启动 |
+mbr.binbootloader.bin |
+
mbr.bin |
+MBR编译后的二进制文件 | +mbr.asmboot.inc |
+
bootloader.bin |
+BootLoader编译后的二进制文件 | +bootloader.asmboot.inc |
+
mbr.asm |
+MBR源文件 | +- | +
bootloader.asm |
+BootLoader源文件 | +- | +
boot.inc |
+头文件 | +- | +
根据新的依赖关系,可以编写新的makefile文件如下,其中除了添加了新的依赖文件编译规则外,还添加了对头文件路径的指定
1 | ASM_COMPILER := nasm |
运行的方式与上一章相同,进入build/目录下执行make clean build run即可以对代码进行编译和运行
运行的结果应该与上一章一样,在屏幕的第一行打印出Hello World!字样

保护模式
+保护模式是目前英特尔处理器主流的运行模式,在保护模式中,处理器以32位模式运行,所有的寄存器也都为32位,因此程序可以访问到的内存空间
+保护模式提出了 段 的概念,在CPU产生地址以后,会判断这个地址是否超出了段所规定的地址,从而避免程序之间的越界访问
+除此之外,保护模式还包括了特权级保护等,在后面的实验中会逐个涉及
+上面提到,在保护模式中提出了段的概念,在保护模式中运行的各种代码都需要声明自己所使用的段,并且由CPU监视地址的访问是否越界。那么,代码是如何告诉CPU自己所使用的段信息,CPU又是如何知道段的界限的呢?
+实际上,为了能够让CPU得知段的具体信息,需要在内存中分配一段地址空间,在其中放置关于各个段的说明信息,这部分空间就被叫做全局描述符表GDT (Global Descriptor Table)
+全局描述符表的起始地址与它的大小组合成一段48位长度的数据提供给CPU作为查询全局描述符表基地址的依据,放置在CPU中的GDTR寄存器中,GDTR的结构如下
+| 位区间 | +描述 | +
|---|---|
| [48:16] | +基地址 (Offset) [31:0] | +
| [15:0] | +大小 (Size) [15:0] | +
其中,大小由最多能够放入的描述符个数Count指定
+
全局描述符表看起来就像一个数组,其存放的都是64位长度的元素,每一个元素都用来描述一个段的信息,被称作段描述符 (Segment Descriptor),全局描述符表中的第一个元素始终为空描述符,从第二个元素开始可以由程序定义
+对于段描述符而言,它由七个部分组成,结构如下
+| 位区间 | +描述 | +
|---|---|
| [63:56] | +起始地址 (Base) [31:24] | +
| [55:52] | +标志位 (Flags) [3:0] | +
| [51:48] | +段界限 (Limit) [19:16] | +
| [47:40] | +访问控制位 (Access Byte) [7:0] | +
| [39:32] | +起始地址 (Base) [23:16] | +
| [31:16] | +起始地址 (Base) [15:0] | +
| [15:0] | +段界限 (Limit) [15:0] | +
其中,访问控制位又分为七个不同的部分,其结构和作用如下
+| 位 | +缩写 | +描述 | +
|---|---|---|
| 0 | +A | +访问位 (Accessed bit)0:段未被访问1:段在上次清除这个访问位后被访问过 |
+
| 1 | +RW | +读写权限位 (Readable/Writable bit) 代码段: 0不可读,1可读,始终不可写数据段: 0不可写,1可写,始终可读 |
+
| 2 | +DC | +增长方向/一致性标志位 (Direction/Conforming bit) 代码段: 0只能由DPL中指定的特权级执行,1代表DPL中指定可执行的最高特权级数据段: 0段向高地址增长,1段向低地址增长 |
+
| 3 | +E | +可执行位 (Executable bit)0:不可执行,为数据段1:可执行,为代码段 |
+
| 4 | +S | +描述符类型位 (Descriptor type bit)0:系统段,例如TSS1:代码段或是数据段 |
+
| 5-6 | +DPL | +描述符特权级 (Descriptor privilege level field)0:最高特权级(内核)3:最低特权级(用户) |
+
| 7 | +P | +存在位 (Present bit)0:该描述符不可用 (invalid)1:描述符可用 |
+
段描述符的标志位则使用三个不同的标志设置了段的粒度、位模式
+| 位 | +缩写 | +描述 | +
|---|---|---|
| 0 | +Reserved | +保留位,始终置0 |
+
| 1 | +L | +长模式标志位 (Long-mode code flag)0:位模式由DB位指定1:段描述的位64位代码段 |
+
| 2 | +DB | +位模式位 (Size flag)0:描述符对应16位保护模式段1:描述符对应32位保护模式段置位时 L位应为0 |
+
| 3 | +G | +粒度标志位 (Granularity flag)0:描述符中的段界限按照字节单位计算1:描述符中的段界限按照4KiB单位计算 |
+
在了解了段描述符各个位的作用后,CPU又是如何通过这些地址和标志位去判断地址的合法性的呢?要明确这个问题,就需要首先知道CPU是如何生成地址的
+进入保护模式之后,每当CPU生成一个地址,它实际上生成的是相对于段的偏移地址Offset,CPU同时还会通过上文中提到的段选择子从全局描述符表中取出段的基地址Base,之后CPU中的地址变换部件会判断偏移地址的合法性,并组合偏移地址和基地址得到真实的物理地址Address,这个最后的物理地址才会用于访问内存数据
也就是说,Address、Base和Offset之间存在如下关系
+
而CPU对地址合法性的检验,实际上就是在做Offset和段描述符中界限Limit之间关系的判断,当然,由于粒度Granularity的引入,在计算实际的界限时还需要掺入粒度单位
对于向上增长的段,它的偏移地址需要满足
++
这个很好理解,因为 代表着段中第一个字节的地址,而 代表段中最后一个字节的地址,偏移量需要介于这两者之间,才是合法的访问
+陷阱:粒度不是直接进行乘4KiB的操作
+实际上, 并不像我们想的那样将界限直接左移12位,而是相当于Limit << 12 | 0xFFF
+也就是说,在4KiB粒度下,0xFFFFF的界限实际对应着0xFFFFFFFF,0x00000对应着0x00000FFF
对于向下增长的段,则稍微有些不一样,一个比较容易理解的阐述是,对于使用相同基址和界限的向上增长段,其合法地址在向下增长的段中不合法,其不合法的地址在向下增长的段中合法,如果使用公式来更严谨的解释这一说法,则偏移地址需要满足
++
举一个例子,假设需要在16位模式下设置一个1KiB栈段,其最高地址为0xFFFF,则其最低地址为0xFC00,大家可能很快就能想到,将基地址设置为0x0000并设置界限为0xFC00既可以描述这个栈段
但且慢,注意到偏移地址的最低合法地址为 ,如果界限设置为0xFC00,则最低合法地址为 ,栈的大小则不是1KiB,而是1KiB - 1了
因此,界限应该设置为0xFBFF才能让栈段的可用地址为1KiB
全局描述符表不仅可以存储段描述符
+在之后的章节中,会了解到全局描述符表中不仅可以存放段描述符,还可以存放其他长度相同的描述符,例如任务状态段TSS (Task State Segment),这将会在实现用户进程的章节介绍
既然是数组,那就意味着可以通过下标(索引)来取出某个位置上的元素,CPU也正是这么做的,当一个程序向CPU声明自己所使用的段时,它实际上是向CPU提供了一个索引,这个16位索引中存储了其所使用的段在全局描述符表中的下标以及特权级信息(将会在用户进程的实现中介绍),被称作段选择子 (Segment Selector)
+段选择子由其对应的段在全局描述符表中的索引、描述符表类型以及特权级构成,其结构如下
+| 位区间 | +描述 | +
|---|---|
| [15:3] | +索引 (Index) [12:0] | +
| [2:2] | +描述符表类型 (TI)0:使用全局描述符表 (GDT)1:使用局部描述符表 (LDT) |
+
| [1:0] | +特权级 (RPL) [1:0] | +
我们目前只需要设置索引位
+由于在实验中不会使用到局部描述符表,因此在此处不会介绍它,这一位直接设置为0
同时特权级位会在后面涉及到用户进程时再次介绍,目前我们只需要关心RPL = 0的段
总的来说,操作系统内的代码首先在内存中的全局描述符表内声明需要用到的段,之后将对应段的选择子提供给CPU,CPU在产生地址后,根据选择子中的信息在全局描述符表中查询段的具体信息来判断访问是否越界
+在了解了保护模式以及全局描述符表的相关知识后,就可以编写代码在BootLoader中开启保护模式了
+进入保护模式
+进入保护模式有五个步骤,分别是 设置全局描述符表、关闭中断, 开启第21根地址线、打开保护模式开关 和 执行一次远跳转送入代码段选择子
首先进行第一步:设置全局描述符表
+通过上一小节可以知道,全局描述符表实际上就是存放在内存中的一段类似于数组的空间,但是目前我们还没有指定这一段空间的位置,所以要先进行内存规划
+实际上位置的指定相对自由,在实验中将GDT放置在0x9000-0x10000的位置处
| 用途 | +起始地址 | +终止地址 | +扇区数 | +
|---|---|---|---|
| MBR | +0x7C00 | +0x7E00 | +1 | +
| BootLoader | +0x7E00 | +0x8600 | +4 | +
| GDT | +0x9000 | +0x10000 | +- | +
将新的常数写入头文件中方便调用
+1 | ; BootLoader |
之后,在BootLoader中向GDT中添加元素,在实验中我们使用平坦模式,所以只需要添加代码段、数据段以及栈段的描述符,以及最初的空描述符
+平坦模式
+平坦模式就是所有的段都对所有地址空间有完整的访问权限,并且所有的程序使用同一个代码段,简化了地址的访问
+之所以使用这个模式是因为在后面的实验中,会有另一套地址管理的机制,称为分页机制,地址的保护将会使用分页机制进行
可以根据段特性来设置描述符的值
+| 段 | +起始地址 | +段界限 | +A | +RW | +DC | +E | +S | +DPL | +P | +L | +DB | +G | +描述符值 | +
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 空 | +0x0 | +0x0 | +0 | +0 | +0 | +0 | +0 | +0 | +0 | +0 | +0 | +0 | +0x00000000_00000000 | +
| 代码 | +0x0 | +0xFFFFFFFF | +0 | +1 | +0 | +1 | +1 | +0 | +1 | +0 | +1 | +1 | +0x00CF9A00_0000FFFF | +
| 数据 | +0x0 | +0xFFFFFFFF | +0 | +1 | +0 | +0 | +1 | +0 | +1 | +0 | +1 | +1 | +0x00CF9200_0000FFFF | +
| 栈 | +0x0 | +0x0 | +0 | +1 | +1 | +0 | +1 | +0 | +1 | +0 | +1 | +1 | +0x00CF9600_0000FFFF | +
确定了描述符值以后,使用mov命令放置在给GDT分配的内存空间中
1 | ; Initialize Global Descriptor Table |
陷阱:注意小端模式
+在IA-32处理器中,数据的存储使用的是小端模式
+这就意味着对于一个64位的数据,低地址存放低32位,高地址存放高32位
+虽然对于低32位,其存放顺序同样是小端模式,但是具体怎么放是mov指令的事情,我们就不需要关心了,我们只需要知道在使用mov指令的时候,宏观上要把低32位放在低地址上就对了
根据写入的顺序就可以设置描述符对应的选择子了,由于我们只使用GDT而且只考虑特权级为0的情况,因此选择子的低3位均为0
将选择子以及描述符个数写入头文件中方便重用
+1 | ZERO_SELECTOR equ 0x00 |
设置好了GDT里的内容后,就要考虑如何将其装载入GDTR了
由于GDTR为糟糕的48位长度,很显然没有一个寄存器能够放下这样长度的数据,因此工程师们又想出了一个天秀的方式:先把要放入GDTR的值放置在内存的一段地址中,然后使用lgdt命令读取这个地址,由该指令将地址之后的48位数据拷贝到GDTR中
所以首先指定一段用于存放GDTR数据的内存
+1 | ; Variables |
陷阱:注意小端模式
+在汇编的变量声明中,先声明的数据位于低地址
+由于IA-32处理器又是小端模式,所以GDTR中的Size段位于低地址,应该先声明,然后再声明其Base段
在BootLoader中由 这一公式来设置GDT的大小
+1 | ; Set table size |
一切准备妥当后,就是用lgdt指令将这糟糕的48位长度数据送入寄存器
1 | ; Load gdt |
然后进行关中断操作
+之所以要关中断,是因为在保护模式下中断的实现与实模式下不一样,在我们尚未实现中断的时候,贸然进入保护模式会产生错误。因此我们先在BootLoader中关闭中断,当我们在后续章节中建立起完善的中断机制之后再打开中断
+1 | cli ; Disable interrupt |
在关闭中断后,就可以进行第三步:开启第21根地址线
+在实模式下,CPU始终将21根地址线置为低电平,这样不论指令寄存器如何自增,始终会因为溢出而在0xFFFFF处回到0x00000
+为了进入保护模式,就需要解除这一层封印,让寻址突破20位限制
在编写BootLoader中的代码前,可以先看一段有趣的代码来感受CPU是如何通过第21根地址线将地址限制在20位的
+1 | ; The following code is public domain licensed |
至于打开这根地址线,由于该地址线由南桥A20端口控制,端口号为0x92,控制位位于第二位,因此代码编写如下
1 | ; Open A20 |
接下来就来到了保护模式真正的开关,CR0寄存器中的PE位
+1 | ; Set PE |
最后执行一次远跳转,送入代码段选择子,正式开启保护模式,其中protected_mode_begin部分将在下一小节中完成
1 | jmp dword KERNEL_CODE_SELECTOR:protected_mode_begin |
远跳转
+远跳转由两个部分组成:段选择子和段内偏移,执行远跳转会将段选择子送入代码段寄存器CS,同时将段内偏移送入EIP,使得CPU在新的代码段的基地址上以新的段内偏移开始执行指令
+由于代码段选择子 不能够手动设置,因此只能够通过远跳转进行设置,所以此处执行的远跳转主要目的是为了送入代码段选择子
进入保护模式以后,自然要输出些什么才能确认前面的代码都正确无误地执行了。因此,在这一小节中就来实现保护模式中的第二个Hello World!
由于进入保护模式以后,代码全面进入了32位模式,所以也要添加对应的伪代码
+1 | ; Protected mode starts here |
这时,我们还没有设置好各个段选择子,因此应当尽快将数据段和栈段的选择子送入寄存器,由于暂时用不到附加的段寄存器,所以不妨将它们设置为空描述符
+1 | ; Set selectors |
接着就可以移植原先实模式下的代码,鉴于实模式与保护模式地区别,在移植时需要进行如下修改
+[gs:bx]这样的访问模式陷阱:不是在32位模式下所有的寄存器都要使用32位
+寄存器位宽的使用始终要符合数据的宽度,例如从内存取出字节的时候就应该使用寄存器的8位模式作为操作数,而不能一味地使用32位寄存器
移植后的代码如下
+1 | ; Print something |
完整的bootloader.asm如下
1 | "boot.inc" |
完成
+至此,就完成了本章的全部任务,赶紧使用make clean build run来测试代码的运行情况吧!
在本章节的第一部分中,将会简要介绍在下一章中将要编写的KernelLoader,以及在开始着手进行它的编写之前所需要完成的,包括各种驱动、文件系统接口等在内的诸多准备工作。
+在第一部分之后,我决定按照KernelLoader中的函数调用顺序,逐节完成KernelLoader中所需要的所有准备工作,因此在第二部分中,将会首先记录如何在项目中使用C语言和汇编混合编程,包括C语言是如何进行函数调用的,以及内联汇编中NASM向AT&T迁移语法所需要注意的问题。有了这部分基础知识,就可以进行第三部分编写一些常用的驱动,并从我个人的角度讲讲为什么要这么做,它对后续的代码编写能够起到哪些帮助。
+在之后的第四部分中,会进行有关文件系统的知识的详述,并且带领大家阅读微软关于FAT文件系统的文档,根据文档完成FAT文件系统接口的设计和实现。
+而第五部分则会关注如何在多个分区的磁盘上通过MBR读取不同分区的信息,从而为正确读取文件系统中的文件提供条件
+在第六部分中,会介绍我个人认为的操作系统中最重要的概念之一的分页机制,并根据我对它的理解,完成对分页机制中主要数据结构的抽象。
+如果你恰好像我一样,在第二部分中阅读的是人生中第一篇官方的文档的话,应当能够克服曾经对于文档的恐惧,甚至会在阅读后面的小节时仍意犹未尽,激动不已,那么第七部分将会趁热打铁,跟随关于ELF文件的文档完成对ELF文件格式中用到的主要数据结构的抽象。
+本章内容提示:
+是的,这一章内容确乎是超乎我想象得多,这也就导致了学校官方教程中绕过了这一部分直接进入Kernel先着手编写Kernel中的内容
+事实上,就实现 从BootLoader加载到Kernel 这一步骤,甚至KernelLoader都并不是必须的,但省略这一部分并不是没有代价的:由于省去了进入Kernel前的分页机制准备工作,学校教程不得不在第八次实验中返回BootLoader,添加开启分页的代码
+除此之外,在我们要实现的ElfLoader的加持下,Kernel不再需要以.bin的形式编译,也不再需要单独的entry.asm文件作为入口,并且可以加载到任意指定的地址处
在上一章中,已经成功地从MBR跳转进入了BootLoader并且在BootLoader中开启了保护模式。
+在成功开启了保护模式之后,其实就已经可以进入内核运行了。事实上,学校的教程也确乎是这么实现的:就如同MBR跳转BootLoader那样,将内核编译为.bin文件并从BootLoader加载后跳转到内核加载地址上运行。
而我的实现则相较于学校教程而言复杂了许多,在我的实现中,BootLoader与Kernel间额外添加了一层跳板,也就是在下一章中要完成的KernelLoader,它负责从硬盘活动分区的/system/文件夹中读取kernel.elf文件,并且解析kernel.elf文件中的信息,将内核加载到正确的地址,之后开启分页机制,最后跳转到Kernel运行。
看上去,学校教程的实现方式更加简单,但这样的捷径并非没有代价,至于学校教程有哪些弊端,以及为什么我采用了一条这样的路线,在后续小节和章节的实现中,都会渐渐明朗。
+为了完成这样一个复杂的跳板程序,需要为其做许多的前期准备工作:
+.elf文件中包含了一个加载并执行一个程序的诸多关键信息,例如程序需要加载到的地址、程序的入口点等等。为了解析文件内的信息,需要像文件系统一样,对.elf文件的结构进行抽象,最后提供接口供程序使用这也就意味着我们的项目目录也会产生一些变化:
+src目录下添加driver文件夹driver文件夹下添加hdd和port文件夹struct文件夹fs、elf、mbr和paging文件夹memcpy、memset等等,这些都可以被称作操作系统需要用到的“库”,可以放在src目录下的lib文件夹中。同样,抽象后的结构也可以看作库的一部分,所以将struct文件夹也放置在lib文件夹下。unsigned int、unsigned char和unsigned long long和它们的别名uint32、uint8和uint64等等,可以将它们编写到头文件constants.h和types.h中。由于它们同样可以视为操作系统库的一部分,所以可以放在lib中叫做common的文件夹下kernel,放置在src目录下在准备妥当后,新的项目结构如下
+1 | . |
现在,就让我们开始接下来激动人心的旅程吧!
+大家不会觉得KernelLoader要使用汇编完成吧,不会吧不会吧
离开了实模式进入32位保护模式后,C++就已经可以大展身手了,也就是说,从接下来开始就将脱离汇编苦海,投入C++的怀抱之中了。
+不过,在正式开始C++的工作前,需要完成一些交接的工作,在这一小节中,将会介绍一个C++程序是如何从源文件编程可执行文件的,以及如何进行C++与汇编的混合编程
+参考:
+本节内容参考了学校教程第四次实验中从代码到可执行文件一章以及C/C++和汇编混合编程一章的内容
在编译器将代码编译成可执行文件的过程中,其实际上进行了四个步骤:预处理、编译、汇编和链接
+预处理又称为预编译,在这一环节中,编译器主要处理宏定义,如#include, #define, #ifndef等,并删除注释行,还会添加行号和文件名标识,在编译时编译器会使用上述信息产生调试、警告和编译错误时需要用到的行号信息。
+经过预编译生成的.i文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入到.i文件中
我们可以通过新建一个简单的C程序main.cpp来测试这一步骤:
1 |
|
之后在编译命令中使用-E参数只进行预编译步骤
1 | gcc -o main.i -E main.cpp |
查看Linux关于指令的手册:
+如果想要知道关于某一程序参数的具体描述,可以使用man命令
+例如,man gcc可以查看关于gcc所有参数的描述和用法,其中关于-E参数就有如下描述:
+-E Stop after the preprocessing stage; do not run the compiler proper. The output is in the form of preprocessed source code, which is sent to the standard output.
可以看到生成了一个main.i文件,其内容如下:
1 | # 1 "main.cpp" |
编译是将预处理后的代码翻译成汇编语法的步骤,具体包括了词法分析、语法分析、语义分析及相关的优化、中间代码生成以及目标代码生成五个环节
+在gcc中使用-S参数可以进行编译步骤,-masm=intel指定输出的语法格式为第二章中介绍的Intel汇编语法
1 | gcc -o main.s -S main.cpp -masm=intel |
生成的main.s文件如下:
1 | "main.cpp" |
在汇编步骤中,编译生成的汇编代码将会被翻译成机器语言,并且组织成为可重定位文件的格式。在Linux下,可重定位文件是ELF格式,关于ELF格式的文档中详细地介绍了ELF格式中的各个部分,以及它们如何对重定位起作用。
+ELF文件有多种类别:
+ELF不仅可以用来描述需要重定位的文件,也可以用在链接环节的生成的可执行文件中,在本文的第六部分中,就要根据文档来介绍如何解析一个可执行的ELF格式文件
汇编步骤仅仅只是将单个的文件转换成了机器语言,如果项目中有许多文件,它们中又存在着调用关系的话,此时是没有办法执行的。可以这么理解:在编译器进行汇编的时候,它会遇到一些只有声明而没有定义的符号,例如一些外部函数和变量,这部分函数和变量的地址此时还并不知道,因此编译就会将它们标记起来,放置在ELF文件一些特殊的区域存储
+在链接步骤中,链接器接受多个输入的可重定位文件,根据输入的参数设置,可以规定程序的入口点地址、程序代码段放置的地址等,然后按照设置的地址将文件中的各个部分组合拼接在一起。
+由于在链接的步骤中,程序起始的地址通过参数规定,同时所有的文件此时都“齐聚一堂”,每个文件各个代码段就可以像安排座位那样依次入座了,整个程序各个符号的绝对地址也就确定了。这样,原先那些没有定义的符号此刻也能够找到了,并且也具有了绝对的地址值,各个文件中的相对地址此刻也都有了对应的绝对地址值,链接器根据这些新确定的绝对地址替换掉类似jmp、call中原先使用的值,将整个程序完整地串联在一起。
类似jmp这样的跳转指令,就是在链接的步骤中,才真正确定跳转的绝对地址的。
即便有了C++这一利器,在实验中的许多地方仍然不可避免会需要用到汇编语言,例如需要操作某个特殊寄存器或是需要读写端口的时候。在这种情况下,有两种可行的解决方案,分别是 内联汇编 和 调用汇编函数
+学校所提供的教程在第四次实验的C/C++和汇编混合编程这一部分介绍了调用汇编函数这一解决方案
+在这个解决方案中,主要需要三个步骤:
+.asm文件中,并声明globalextern "C" boo baz()这样的外部函数声明,并在程序中调用这个函数来执行对应的功能此外,为了能够让汇编函数的编写和调用能够顺利进行,还需要了解编译器是如何处理带参函数的调用的,我们不妨写一个带有参数的函数来试一试
+1 | void func(int a, char b) { |
之后使用编译器编译成汇编代码,其中main函数对应调用func部分的代码如下
+1 | push 65 |
可以看出,两个push操作分别向栈内压入了'A'和1两个参数,之后使用call指令调用函数,在函数返回后再使用add esp, 8将参数移出栈,

进入函数内也不能干坐着,按照C语言的规定,被调函数需要使用ebp从栈上取值,同时还需要为主调函数保留ebp寄存器的值,因此可以看到在汇编被调函数内部有这样的代码
1 | push ebp //Preserve ebp |
当然,尽管C语言似乎并没有要求被调函数主动保存其他寄存器的值,但我个人而言还是习惯在进入函数之后进行一次pushad操作主动保存,这也就让固定的函数骨架变为了
1 | push ebp |
关于调用汇编函数的实例:
+如果想要查看关于上述方案的实例,可以参考学校教程的第四次实验 Example 1 混合编程 相关内容
上述方法为了调用一个汇编函数还要额外添加一个.asm文件,实在是不够优雅,所以我在后续的实现中改为了使用内联汇编来完成汇编代码的部分
实际上,内联汇编的使用极其简单(对于实验中所需要用到的部分而言),由于主要需要和内联汇编打交道的地方几乎无外乎都是要么从寄存器读出一个值,要么往寄存器写入一个值,所以并不需要学习多么复杂的语法,够用即可
+在GCC中使用内联汇编,只需要使用asm关键字,如果不希望编译器改动汇编代码顺序,可以额外添加volatile关键字,于是,一个最简单的内联汇编语句可以长这样:
1 | asm volatile( |
如果代码有很多行的话,就长这样:
+1 | asm volatile( |
是否已经跃跃欲试了?且慢,内联汇编默认使用的是AT&T的语法,因此我们需要进行一次Intel语法向AT&T语法的迁移,在了解了Intel与AT&T语法的主要区别之后,我们不论是写.asm文件还是写内联汇编都将得心应手
参考资料:
+关于语法迁移的内容,参考了 GCC 内联汇编与 Intel 语法迁移 一文
Intel和AT&T的语法区别,无外乎以下几点:
+op [dest], [src]op [src], [dest]eax;立即数直接写立即数值,如0x1000%标明,如%eax;立即数前用$标明,如$0x1000[ebx + esi * 2 - 0x10]-0x10(%ebx, %esi, 2)base、索引index、元素大小size以及偏移量disp四个量进行寻址,格式为disp(base, index, size)[]寻址的过程中,需要指定大小,如mov eax, word [ebx]b为byte,w为word,l为dword,q为qword(64-bit),操作数的大小附加在指令名称后,如movl、addl最后,为了能够让内联汇编功能更强大,GCC还提供了一系列扩展的指令操作:
+1 | asm volatile( |
可以看到,在正常指令的尾部,GCC允许程序员添加三个参数列表,类似于printf函数那样,通过在汇编代码中插入特定的操作数,并在后面的参数列表中按照规定的格式提供变量,可以使用变量来 “替换” 代码中对应的操作数,从而起到将变量值写入寄存器或是将寄存器值写入变量的操作。
其中,参数列表中每一个参数都由一个可选的名称[name],一个限制符如"=r"、"i"等以及对应的变量(var)组成。
限制符在实验中只需要用到"r"以及"i",其中
"r"代表可以使用任意的寄存器"i"代表立即整型操作数除此之外,还有更多的限制符,如
+"a"代表可以使用%eax、%ax、%al"b"代表可以使用%ebx、bax、%bl"c"代表可以使用%ecx、%cx、%cl"d"代表可以使用%edx、%dx、%dl"s"代表可以使用%esi、%si"D"代表可以使用%edi、%di"m"代表内存操作数"o"代表内存操作数,仅用于偏移量"V"代表内存操作数,仅用于非偏移量"n"代表立即整型操作数,允许已知的数值"g"代表允许任何寄存器,但寄存器不是常规的寄存器在汇编指令中,可以用%0、%1…%n这样从零开始的数字来表示参数列表中的第一个、第二个、…第n个变量,例如
1 | int out_eax, in_eax = 10; |
也可以使用%[name]的方式通过名称来直接对应参数列表中指定名字的变量,例如:
1 | int out_eax, in_eax = 10; |
陷阱:额外的%
+当使用了扩展内联汇编之后,由于诸如%0这样的操作数会使用一个%符号,为了区分,所有的语句中的寄存器都需要额外加一个%,例如movl %0, %%eax
陷阱:变量大小与指令操作数大小对应
+例如,如果使用了movl指令,那么对应的参数列表中的变量也应当是32位长度,例如int
至此,内联汇编的优势已然显现出来,其不但减少了杂乱文件的数目,而且还可以方便的与C程序中的变量进行操作,在后续的实验中选择哪种方案相比心里也有了答案。
+但不论选择哪一个方案完成C和汇编的混合编程,至此我们都已经顺利完成了进入C++语言环境的全部知识准备,足够我们进入后面的小节继续实验的进程了。
+在操作系统实现的各个环节中,都离不开和硬件打交道的部分,在我们已经完成的MBR中,就有我们为了加载BootLoader而写的读取磁盘扇区的函数。
+这些需要和硬件进行通信的操作,往往都需要用到一些特殊的端口,通过我们实现MBR的经历可以知道,使用汇编的in和out指令就可以对端口进行读写操作,而这样简单的操作如果是使用C++这样的语言进行编程的话却是无法实现的。
这时,大家可能就会说了,不是刚刚学完了C++和汇编混编吗,这不直接上来就是一个asm ("inb dx, al"),很快啊!
不可否认,这不失为一个解决方式,但放在C++中,它就是显得不那么优雅:如果能把一个端口视为一个对象,比如port,直接port.read(),这才叫优雅嘛!
可喜的是,这在C++中是完全可以实现的:只需要实现一个类,其持有一个端口号作为成员变量,读写操作的实现则都使用内联汇编完成。
+这样就相当于在汇编操作外附加了一层C++的外衣,将原先裸的内联汇编代码包装成了C++代码,不仅提升了代码的可读性,而且由于存在C++的类作为中间层,可以添加很多保护性的代码,比如判断非法端口的访问、只读端口的写入等等,相比直接操作裸汇编代码提升了安全性和灵活性。
+在实现端口驱动的时候,可以根据端口的读写权限分为三个类:
+Port类用来表示可读写的端口PortWriteOnly类用来表示只能写的端口PortReadOnly类用来表示只能读的端口对于Port类,由于汇编中的in和out指令都需要提供端口号,而既然我们现在打算将一个端口封装成一个Port的实例,那就不能每次调用读写函数的时候都提供一次端口号,而是要让实例在初始化的时候就记住它应当是哪个端口。由于这个端口号只有在初始化的时候被赋值,之后就不再需要更改或是暴露给程序,因此可以作为私有的const变量进行储存:
1 | private: |
对于端口的读写函数,由于不同的端口位宽会存在不同,因此读写指令的返回值和参数的类型需要借助类模板的帮助:
+1 | template<typename T> |
又因为在内联汇编中,in和out指令都需要指定操作数的宽度,如inb或是inl,因此在读写函数的实现上,要使用sizeof(T)对模板类的类型参数位宽进行判断,再使用合适的内联汇编函数,例如:
1 | template<typename T> |
对于一个8位端口,使用内联汇编的读取代码可以首先将端口号读取到dx,再使用inb指令读取端口数据到al,最后将数据从al写入到外部变量:
1 | asm volatile( |
内联汇编在编译的时候会检查外部变量的大小是不是和对应指令中指定的操作数宽度相匹配,例如movw指令不能提供一个uint16类型的变量,因此如果直接将T data作为参数传入,会发生报错。对于这个问题,可以在使用sizeof()判断后,显示地将data转换为对应的大小,或是先赋值到一个明确宽度的变量(例如:uint8 _data)中,再在C++中赋值给原先的变量:
1 | template<typename T> |
由于只写或是只读的端口在读、写函数的实现上与可读写端口中的读写函数没有任何区别,因此在我最初的实现中将Port类作为基类而PortWriteOnly和PortReadOnly私有继承Port,之后封装父类中对应的函数为类的公有接口,例如:在Port中的public: void _write(T data)函数在私有继承后,在PortWriteOnly中添加public: void write(T data) { _write(data); }来调用基类中的写接口,而不对读接口进行封装,来实现读写权限的限制。
但在后续的实验中我发现,即便父类中的函数由于继承已经实质是类的私有函数,但在Intellisense中仍然可以看到,感觉还是不够安全。因此我决定不继承的方式,转而对于PortWriteOnly存储一个Port类型的成员变量代替Port中的端口号,在写函数中调用Port::write函数,彻底杜绝在PortWriteOnly中出现read函数的提示。
1 | template<typename T> |
完整的端口驱动ports.h如下:
1 |
|
陷阱:模板类的函数应在头文件中定义
+模板类的函数不能将函数声明写在头文件中,而将函数的定义写在另一个文件中,这样会导致在编译链接的时候出现无法找到函数定义的报错。所有的模板类函数都需要在头文件中就实现函数的定义。
回顾:从硬盘读取多个扇区的数据
+关于如何使用LBA模式通过端口读写磁盘,可以回顾第三章中相关内容的介绍
要将硬盘的读写操作抽象成驱动,在实验中使用了与第三章中相同的PIO模式对硬盘进行访问,在这个模式下与硬盘通信需要使用到8个不同的端口:
+| 端口号 | +端口数据传输方向 | +作用 (LBA28) | +描述 | +位长 (LBA28) | +
|---|---|---|---|---|
| 0x1F0 | +读/写 | +数据寄存器 | +硬盘读出/要写入的数据 | +16-bit | +
| 0x1F1 | +读 | +错误码寄存器 | +存储执行的ATA指令所产生的错误码 | +8-bit | +
| 0x1F1 | +写 | +功能寄存器 | +用来指定特定指令的接口功能 | +8-bit | +
| 0x1F2 | +读/写 | +扇区数寄存器 | +存放需要读写的扇区数量 | +8-bit | +
| 0x1F3 | +读/写 | +起始扇区寄存器 | +存放起始扇区0-7位 | +8-bit | +
| 0x1F4 | +读/写 | +起始扇区寄存器 | +存放起始扇区8-15位 | +8-bit | +
| 0x1F5 | +读/写 | +起始扇区寄存器 | +存放起始扇区16-23位 | +8-bit | +
| 0x1F6 | +读/写 | +磁盘、起始扇区寄存器 | +选择磁盘和访问模式 存放起始扇区24-27位 |
+8-bit | +
| 0x1F7 | +读 | +状态寄存器 | +读取当前磁盘状态 | +8-bit | +
| 0x1F7 | +写 | +指令寄存器 | +传送ATA指令 | +8-bit | +
既然前面已经实现了端口的驱动,那么在这里就可以直接投入使用了,将列表中的端口按照对应的读写权限和用途实例化作硬盘类HDDManager的私有成员变量:
1 | private: |
注意到,对于0x1F7端口,当其作为读端口时用来表示硬盘的状态,其表示形式为按位枚举,端口读出值的每一位都有对应的含义:
| 端口号 | +位 | +缩写 | +作用 | +
|---|---|---|---|
| 0x1F7 (Read) | +0 | +ERR | +指示是否有错误发生,通过发送新指令可以清除该位 | +
| 0x1F7 (Read) | +1* | +IDX | +索引,始终置为0 | +
| 0x1F7 (Read) | +2* | +CORR | +修正数据,始终置位0 | +
| 0x1F7 (Read) | +3 | +DRQ | +0:硬盘还不能交换数据1:硬盘存在可以读取的数据或是可以写入数据 |
+
| 0x1F7 (Read) | +4* | +SRV | +重叠模式服务请求 | +
| 0x1F7 (Read) | +5* | +DF | +驱动器故障错误 | +
| 0x1F7 (Read) | +6* | +RDY | +0:驱动器发生了减速或是错误1:驱动器运转正常 |
+
| 0x1F7 (Read) | +7 | +BSY | +忙位0:空闲1:忙 |
+
什么是按位枚举:
+按位枚举就是指利用二进制位来表示某个事物的属性,不同的二进制位描述不同的属性。
+例如,对于一个人的特征来说,可以用所在的位表示性别,1为男,0为女;用所在的位表示是否是短发,1为是,0为否,等等。
+需要注意的是,在使用按位枚举的时候,不同的二进制位应当描述不同的属性,例如如果要表示性别应当使用一个二进制位的0和1来区别,而不是使用两个二进制位,一个置位表示男,另一个置位则表示女。
由上述的列表中可以看出0x1F7中的每个二进制位都确实描述了一个硬盘状态的不同属性,对于这样的二进制位枚举,可以通过enum来表示:
1 | enum HDDStatus { |
但糟就糟在C++对按位枚举的支持并不好,由于C++默认将枚举类型作为整型值处理,所以常见的按位操作是默认支持的:
+|:按位或,合并存在的属性&:按位与,用掩码取出某个属性的值,例如flag & 0x0F可以单独取出低四位的属性值~:按位取反,用来反转所有属性值^:按位异或,用来反转某个属性值,例如flag ^= 0x08可以反转第4位属性的值但如果想重载一些特殊的运算,或是添加一些特殊的判断函数,枚举类型则显得无从下手了:
+-:删除某个属性+:添加某个属性contains(flag):判断是否包含flag中的全部属性为了支持这些操作,可以使用C++的类对原本的枚举类型进行一次封装:将枚举类型声明在C++类内,使用一个私有变量存储实际的枚举值,并通过重载运算符的方式从而让类的实例能够支持上述所有的运算操作。
+对于目前而言,为硬盘的状态属性定义类HDDStatusFlag,并将原先的枚举类型声明在这个类内:
1 | class HDDStatusFlag { |
这样,如果想访问枚举类型中的某个常量值,例如BUSY,可以使用HDDStatusFlag::BUSY或是HDDStatusFlag::ATTR:BUSY访问到。
由于0x1F7端口为8位端口,所以对于HDDStatusFlag这个枚举类,其实际需要存储的数据为byte类型,因此可以使用一个byte类型的变量作为其私有成员,用来存储实际的枚举值:
1 | private: |
为了能直接使用整型值给HDDStatusFlag类型的变量赋值,需要定义一个参数为byte类型的构造函数:
1 | public: |
有了这样的一个构造函数,类的实例就可以使用如下的方式构造了:
+1 | HDDStatusFlag flag = HDDStatusFlag::ERR_OCCURRED | HDDStatusFlag::BUSY; |
至于其他的按位操作、特殊运算和判断函数的实现此处就不再赘述,无外乎就是对运算符进行重载,然后再对实际的值进行对应的运算或判断即可。
+在完成了硬盘硬盘状态类的设计后,就可以开始实现硬盘的读写函数了。以硬盘的读取为例,其分为两个主要的步骤:从硬盘读取以及写入内存中的缓冲区。
+由于目前在KernelLoader中没有一个完善的内存管理机制,而进入内核之后内存将可以通过堆分配器分配空闲的内存供程序使用,两者在内存申请上会存在很大的区别。
+如果让HDDManager自行管理其在读写时需要用到的缓冲区的话,就需要分别为KernelLoader环境和内核环境设计不同的缓冲区申请和释放的机制,相当麻烦。考虑到驱动的通用性,所以不妨让内存申请的步骤对HDDManager这个类完全不可见,由调用读写函数的调用者主动申请对应大小的内存,并将申请到的内存首地址作为参数传递给读写函数,所以读写函数的签名声明如下:
1 | public: |
在读写函数的实现上,可以将多个扇区的读写拆分为多个单独扇区的读写,将单个扇区的读写函数作为私有成员仅供公有读写函数调用。
+1 | private: |
以读取的函数为例,单个扇区的读取步骤如下:
+_secCntPort端口写入读取的扇区数量0x01_lbaLoPort端口写入读取的扇区号低8位_lbaMidPort端口写入读取的扇区号8至15位_lbaHiPort端口写入读取的扇区号高16至23位_drivePort端口写入读取的扇区号高4位以及驱动的参数0xE0_cmdPort端口写入读取命令0x20_statusPort中读取硬盘的状态,如果状态包含了HDDStatusFlag::DATA_READY属性,则意味着硬盘数据准备完毕其中,如果在等待硬盘就绪的步骤中发生了错误,则会使用false返回值表示读取失败。
具体的实现如下:
+1 | bool HDDManager::_readOneSector(uint idx, byte* dst) { |
在读取多扇区的函数中,如果读取某个扇区时_readOneSector函数返回了false值,则会进而读取_errPort中的错误信息,写入缓冲区首2字节中,并返回false告知主调函数在硬盘读取过程中出现了错误。如果没有遇到错误,则继续下一个扇区的读取,然后向主调函数返回true。
具体的实现如下:
+1 | bool HDDManager::readSector(uint idx, uint cnt, byte* dst) { |
对于硬盘写,操作则与硬盘读大同小异,仅仅是将数据的传输方向颠倒,从由_datPort读出数据变为向_datPort写入数据,从写入缓冲区变为从缓冲区取出数据而已,此处便不再赘述。
完整的hdd.h头文件如下:
1 |
|
头文件中的函数定义则都存放在hdd.cpp中,完整的内容如下:
1 |
|
现在,如果想要从硬盘中读取某个或是某几个扇区,例如,读取5~12扇区,则可以调用HDDManager中的readSectors函数如下:
1 | //Request buffer |
可以看到,代码明显比原先使用汇编时更加地简洁,也更易于阅读,这也就是编写驱动的目的。
+在BootLoader开启了保护模式之后,所有的一切操作,包括上一小节中编写的驱动都是在为了内核的加载而铺路。
+加载:
+这里及后文中说的加载,都包括了将内核编译后的输出文件读入内存中以及将内核的各个部分放置在正确的物理地址处这两个步骤
加载这一个环节又自然离不开将内核编译后的可执行代码从硬盘读入内存的步骤
+在学校教程的实现中,内核被编译为裸二进制文件,也就是.bin文件,像MBR和BootLoader那样直接写入硬盘对应的扇区。在读取的时候,由于写入的起始扇区和扇区数量都已知,所以可以直接从硬盘读取对应的扇区到内存中。由于内核已经被编译为裸二进制文件,只需要将整个文件读取到对应的地址处就完成了内核的加载操作,而不需要额外的操作。
固然,使用这个方式是简单而且方便的,但我始终觉得这样不够优雅。于是我简单地了解了Linux中一个叫做grub的启动引导程序,简单来讲,grub的启动分为了三个阶段:
+/boot/grub/目录中的启动程序,也即第二阶段程序 (stage 2),然后加载到内存运行/boot/grub/grub.cfg文件,显示一个启动菜单给用户,并且负责加载对应系统中的内核运行GNU GRUB
+关于grub的更多信息,可以查看Wikipedia上关于grub的页面
由于grub在中间阶段就引入了对文件系统的加载,从而可以从分区目录中读取需要的文件,看起来就比直接读取裸二进制文件要优雅得多了。因此我决定放弃学校教程的思路,转而在KernelLoader中实现一个简单版的grub程序,其可以从分区的/system/目录下找到内核文件并加载。
为了能够在KernelLoader中读取分区中的目录和文件信息,就必须要让代码能够解析分区上的文件系统,由于FAT是一个成熟且简单的文件系统,所以在实验中硬盘采用了FAT格式进行格式化。
+尽管文件系统中存储的文件不尽相同,但对于同一个格式的文件系统,它们又都有着相同之处,例如文件系统包含了哪些结构,文件在文件系统中是怎样被组织的等等,这些不同中的相同之处就是我们解析文件系统的关键之处。
+我们现在所需要做的就是用代码表示出这些相同的部分,并探究这些相同部分中的信息如何给文件的读取以提示,如何帮助我们了解文件系统中的内容。在下一章中,我们会更进一步,了解如何通过这部分信息从文件系统中读取需要的文件。
+抽象:
+通过代码去表现共同点,并用接口从共同点中提取出需要的信息,从而能够去解析那些特异的数据,这其实就是抽象的过程。
参考资料
+如果想要了解关于文件系统的更为权威、详细的信息,可以阅读课本《操作系统概念》中第四部分关于文件系统的内容,我强烈建议先完成这部分的阅读再继续后面的实验。
+除此之外,Bilibili上南京大学操作系统课程中关于FAT和UNIX文件系统知识的讲解是不错的视频资料,其前半部分讲解了文件系统的原理以及FAT的一些基础知识。
文件系统是对文件的一种组织形式,所以文件固然也就是文件系统中至关重要的部分,了解文件系统首先就要了解文件的构成。
+不论是哪种类型的文件,不论是.doc、.ppt还是.txt抑或是.bin,在硬盘的视角来看,它们中的数据实际上都是一系列二进制的数据块,而类别的区别只在于这些数据内部的组织形式,并不会让它们看起来是除了二进制数据块以外的其他样子。
但是如果一个文件只有数据块又不太够,文件名称是用户用以区分不同文件的关键信息,文件名称有时候对于文件系统来说太复杂了,所以文件系统会用一些特殊的方式记录用户提供的文件名称并通过其来识别不同的文件,这种记录有时候会呈现以标识符的形式,在FAT文件系统中则是一系列字符串。
+除此之外,文件还需要一些其他的信息,例如其类型、数据在硬盘上的位置、尺寸、访问权限、创建或修改日期等等。
+可见,文件由两个关键的部分组成,一个是文件的数据,也就是一系列二进制数据块,另一个则是文件的信息,它用来区分不同的文件并在硬盘上定位到文件数据的位置。
+
在文件系统组织文件的时候,其实际上就是在建立索引文件的数据结构,由于文件是由两部分构成的,文件系统中也就存在着两种主要的数据结构,分别用来索引文件的这两个组成部分。
+其中一个数据结构用来索引文件的信息,这部分数据结构一般呈现树形的结构,文件系统中的文件目录就是由这个数据结构所维护的,树中每个节点都存储着一个文件或是目录的信息。
+
另一个数据结构则用于通过文件的信息来索引文件的数据,由于一个文件可能跨越数个甚至数十、数百个扇区,由于文件时时刻刻都可能会被更改,所以没有办法预先给文件分配一段固定的、连续的、大小适当的空间,因此文件的数据往往分散在磁盘的各个位置,这时就需要一个数据结构来将这些游离的数据块串接起来。说到这里,可能大家就已经能猜到,这部分数据结构一般以链表的形式出现。每一个文件块都对应着一个下一文件块的位置信息,而第一个文件块的位置信息则存储于文件的信息中。
+下图表示了一个文件系统磁盘中可能的样子:
+#0标号的内存空间对应着系统的根目录/节点,其内部有两个有效的文件信息,分别是system和home,均为目录类别。system首文件块指向#8,并且#8为其最后一个文件块,因此#8实际上就对应着/system/节点,其中存储了kernel.elf一个文件的信息。home,首文件块指向#14,其下一个文件块为#16,并且是最后一个文件块,因此#14,#16共同构成/home/节点,在这个节点中存储了两个有效文件信息,分别是boo.baz和bar,分别是文件和目录。kernel.elf首文件块为#4,#4下一个文件块为#6,#6为最后一个文件块,因此kernel.elf对应的文件数据为#4,#6上的数据。boo.baz首文件块为#10,后续文件块依次为#12、#5。因此#10,#12,#5共同构成boo.baz文件的数据bar首文件块指向#18,后续文件块为#11,因此#18,#11共同构成了目录树中的/home/bar/节点的子树信息
由上图可以看出,目录树的遍历方式是要与数据结构中树的遍历方式有很大不同的。每一个节点实际上都对应着一个存储着其子节点的文件的信息的数组,通过这个数组,可以获得子节点对应文件的首块信息,再依据链表就能够获得全部的文件数据。只有当取得了全部的文件数据,才可以进入下一层的节点。
+简单来说,就是在/目录下,我们只能得到system/和home/的信息,但是我们不能从这些信息中直接得到system/下或是home/下有什么文件,除非我们把它们的数据完整地读取出来。
而在一般的数据结构中,对于根节点root,我们只需要访问child = root->children[0]就可以获得整个子节点,得到的child就包含了其所有的子节点数据,而不需要额外的加载操作。
现在,假设我们需要访问/system/kernel.elf文件,那么就需要
system的信息#8,并根据链表中的信息读入整个目录,由于这里只有一个块,所以就读入#8kernel.elf的信息#4,并根据链表读入#6,得到整个文件。不过,为了提高文件读写的速度,系统一般会维护一个打开的文件表 (Open-file table) 用于维护所有打开的文件的信息,所以一般来说,当打开一个文件的时候,除了会将它的全部数据就被读取到内存中,还会将其信息添加到打开文件表中,后续所有的增删改都只会对内存中的数据起作用,直到文件关闭时才再次写回磁盘。这样就避免了每次读写过程中对文件数据的定位以及读取。
+好戏还在后面
+关于操作系统如何维护打开文件表,以及更多更为复杂的操作,将会留到内核中实现
不过,上面只是对文件系统的一种简单的概述,不同的文件系统会有不同的组织文件的方式,但核心总还是离不开怎样组织文件的数据以及如何通过文件的信息索引到文件的数据,区别大抵是数据结构的差异以及具体实现上的差异。
+例如,在上世纪出现的FAT中,由于软盘随机读取的性能奇差,所以如果像上文中图片绘制的那样,在文件数据块的末尾添加上下一个文件块的信息,糟糕的问题就会出现:如果尝试在文件尾部添加些什么,那需要遍历之前所有的文件块才能知道最后一个文件块在哪个位置!
+为此,工程师们将所有文件块中关于下一个块的信息独立出来,存放在磁盘中的一个连续的空间内,这个空间也被称作文件分配表 (File Allocation Table),也就是FAT实际的意义。这样,只需要提前把这一部分空间读取到内存中,就可以很快地定位到文件任意个块的信息了。
+
同时,这样做还有一个好处,一个512字节或是其整数倍的块需要分配几个字节给下一个块的块号,导致每个文件块实际数据大小不是2的幂,现在由于将这部分信息独立出来,所以每一个文件块都是完整的512字节或是512字节的倍数。
+但是,如果每个数据块只占据1个扇区的话,对于大磁盘来说,需要的块号还是太多了,于是工程师就提出了簇 (cluster) 的概念,一簇由许多个扇区组成,而这个簇就相当于前文中的数据块。
+这样,在FAT中存储簇号就大大减少了FAT的大小,虽然增加了碎片,但是提升了文件读写的效率。
+碎片:
+当一个簇很大的时候,例如4KiB,如果一个文件的大小为4KiB + 1B,则仍然需要为其分配8KiB的空间,也就是两簇。这就使得第二簇中几乎所有的空间都被浪费了,这部分空间就被成为碎片
在了解了文件系统是怎样组织和存储文件之后,对于如何从文件系统中读取文件应该有了思路。
+但是细细一想又会发现很多问题,例如:根目录存储在磁盘的哪个位置、如何知道一个簇有多大、文件名是如何存储的、怎样才代表当前的簇是文件的最后一个簇等等
+这便是因为文件系统很多具体的细节还没有落实,程序还无法从现有的文件系统中获得需要的数据。而这就是本节的目的,通过阅读微软关于FAT文件系统的规范文档,对FAT文件系统进行抽象,并且提供接口来解决上面提到的种种问题。
+ +第一章大体可以略过,其中较为重要的概念如下:
+第二章主要介绍了FAT格式卷的结构,除了上文中提到的用来存储下一个簇信息的FAT区域 (FAT Region) 以及存储文件数据的区域 (File and Directory Data Region) ,还包括了一个保留区 (Reserved Region) 和一个根目录区 (Root Directory Region) 。
+根目录区在FAT32格式的卷上不存在
+一个FAT格式的卷结构大概长得就像下面这样
+
陷阱:小端模式
+由于所有的FAT文件系统都是为IBM PC机器的架构所设计,因此其数据结构在硬盘上的存储方式都是小端模式。
在FAT格式的卷的首扇区,有一个叫做BPB (Bios Parameter Block) 的数据结构,其主要存储了关于当前卷上FAT文件系统的关键信息,包括了文件系统各个区域的首扇区号,以及簇大小等等。
+注:
+首扇区并不是所有的内容都属于BPB结构体的范畴,在后续的介绍中,以 BPB_ 开头的域才是属于BPB结构体的域
+In the following description, all the fields whose names start with BPB_ are part of the BPB. All the fields whose names start with BS_ are part of the boot sector and not really part of the BPB.
对于FAT12/16和FAT32,它们的BPB结构在首36字节上完全一致:
+| 域名称 | +偏移 | +大小 | +描述 | +限制 | +
|---|---|---|---|---|
| BS_jmpBoot | +0 | +3 | +跳转到启动代码处执行的指令 由于实验中启动代码位于MBR,不会从这里进行启动,因此可以不用关心这个域实际的内容 |
+一般为0x90**EB或是0x****E9 |
+
| BS_OEMName | +3 | +8 | +OEM厂商的名称,同样与实验无关,不需要关心 | +- | +
| BPB_BytsPerSec | +11 | +2 | +每个扇区的字节数 | +只能是512、1024、2048或4096 | +
| BPB_SecPerClus | +13 | +1 | +每个簇的扇区数量 | +只能是1、2、4、8、16、32、64和128 | +
| BPB_RsvdSecCnt | +14 | +2 | +保留区域的扇区数量 可以用来计算FAT区域的首扇区位置 |
+不能为0,可以为任意非0值 可以用来将将数据区域与簇大小对齐(使数据区域的起始偏移位于簇大小的整数倍处) |
+
| BPB_NumFATs | +16 | +1 | +FAT表数量 | +一般为2,也可以为1 | +
| BPB_RootEntCnt | +17 | +2 | +根目录中的条目数 指根目录中包含的所有的条目数量,包括有效的、空的和无效的条目 可以用来计算根目录区所占用的字节数 |
+FAT32: 必须为0 FAT12/16: 必须满足 |
+
| BPB_TotSec16 | +19 | +2 | +16位长度卷的总扇区数 对于FAT32和更大容量的存储设备有额外的BPB_TotSec32域 应当是为了维持BPB结构的一致性而仍然保留了这个域 |
+FAT32: 必须位0 FAT12/16: 如果总扇区数小于0x10000(也就是能用16位表示)则使用此域表示,否则也使用BPB_TotSec32域 |
+
| BPB_Media | +21 | +1 | +似乎是设备的类型 与实验无关,所以可以不用特别关心 |
+合法取值包括0xF0、0xF8、0xF9、0xFA、0xFB、0xFC、0xFD、0xFE和0xFF本地磁盘(不可移动)的规定值为 0xF8可移动磁盘的往往使用 0xF0 |
+
| BPB_FATSz16 | +22 | +2 | +单个FAT表占用的扇区数 只用于FAT12/16格式的文件系统 |
+FAT32: 必须为0 FAT12/16: 正整数值 |
+
| BPB_SecPerTrk | +24 | +2 | +每个扇区的磁道数 与 0x13中断相关只与具有物理结构(如磁道、磁盘等)并且对 0x13中断可见的存储介质有关与实验无关,可以不用关心 |
+- | +
| BPB_NumHeads | +26 | +2 | +磁头数量 同样与 0x13中断相关,实验不会使用,所以可以不用关心 |
+- | +
| BPB_HiddSec | +28 | +4 | +分区前隐藏的扇区数 在文档中描述这个域为同样只与对 0x13中断可见的存储介质有关,但在实验过程中发现对于一个多分区的磁盘,这个域对应了分区首扇区在整个磁盘中的扇区号,例如首扇区位于磁盘2048扇区(从0开始计算分区号)的分区,其BPB_HiddSec域值就为2048 |
+- | +
| BPB_TotSec32 | +32 | +4 | +32位长度卷的总扇区数 用来描述FAT32卷中的总扇区数或是扇区数多于0x10000的FAT12/16卷中的总扇区数 |
+FAT32: 必须为非零整数值 FAT12/16: 如果扇区数大于0x10000,则为扇区数,否则必须为0 |
+
从第37字节开始,FAT12和FAT16卷上的BPB结构如下:
+| 域名称 | +偏移 | +大小 | +描述 | +限制 | +
|---|---|---|---|---|
| BS_DrvNum | +36 | +1 | +用于0x13中断的驱动器号,可以不用关心 |
+应当设置为0x80或是0x00 |
+
| BS_Reserved1 | +37 | +1 | +保留位 | +必须为0 | +
| BS_BootSig | +38 | +1 | +用来检验启动扇区的完整性的签名,可以不用关心 | +如果BS_VolID、BS_VolLab和BS_FilSysType三个域都存在有效的值 (present),则置为0x29 |
+
| BS_VolID | +39 | +4 | +卷的序列号,可以不用关心 | +- | +
| BS_VolLab | +43 | +11 | +卷标,可以不用关心 在文档中,要求与根目录下的卷标描述文件保持内容一致,但实际上在测试中往往卷标描述文件中存储的是真实的卷标而这个域的内容仍为缺省值"No NAME" |
+缺省值为"NO NAME" | +
| BS_FilSysType | +54 | +8 | +用来描述文件系统类型,但不能用来作为判断文件系统类型的依据 | +“FAT12”、“FAT16"或是"FAT32” | +
| - | +62 | +448 | +空余,置零 | +必须为0 | +
| Signature_word | +510 | +2 | +校验位 | +设置为0xAA55 |
+
| - | +512 | +* | +如果则存在此域,全部置零 | +必须为0 | +
相对的,从37字节开始,FAT32文件系统中BPB的结构如下:
+| 域名称 | +偏移 | +大小 | +描述 | +限制 | +
|---|---|---|---|---|
| BPB_FATSz32 | +36 | +4 | +单个FAT表占用的扇区数 只用于FAT32格式的文件系统 |
+非负整数值 | +
| BPB_ExtFlags | +40 | +2 | +标志位 | +[0:3]: 活动FAT表的标号(按照从零开始计数)[4:6]:保留位[7]:当FAT在运行时会自动镜像写入其他FAT表时,置零,否则对于只有一个活动的FAT表时置位 |
+
| BPB_FSVer | +42 | +2 | +奇怪的版本号域,文档中写了半天描述最后要求置零… | +必须为0 | +
| BPB_RootClus | +44 | +4 | +根目录的首簇簇号 | ++ |
| BPB_FSInfo | +48 | +2 | +FSInfo结构体所在的首扇区号 |
+一般为1 | +
| BPB_BkBootSec | +50 | +2 | +备份启动扇区的扇区号 由于现在的硬盘不像当年软盘那样易失,所以关于备份相关的域实际都可以不用关心,因为用不上 |
+设置为0或6 |
+
| BPB_Reserved | +52 | +12 | +保留位 | +必须为0 | +
| BS_DrvNum | +64 | +1 | +用于0x13中断的驱动器号,可以不用关心 |
+应当设置为0x80或是0x00 |
+
| BS_Reserved1 | +65 | +1 | +保留位 | +必须为0 | +
| BS_BootSig | +66 | +1 | +用来检验启动扇区的完整性的签名,可以不用关心 | +如果BS_VolID、BS_VolLab和BS_FilSysType三个域都存在有效的值 (present),则置为0x29 |
+
| BS_VolID | +67 | +4 | +卷的序列号,可以不用关心 | +- | +
| BS_VolLab | +71 | +11 | +卷标,可以不用关心 在文档中,要求与根目录下的卷标描述文件保持内容一致,但实际上在测试中往往卷标描述文件中存储的是真实的卷标而这个域的内容仍为缺省值"No NAME" |
+缺省值为"NO NAME" | +
| BS_FilSysType | +82 | +8 | +用来描述文件系统类型,但不能用来作为判断文件系统类型的依据 | +“FAT12”、“FAT16"或是"FAT32” | +
| - | +90 | +420 | +空余,置零 | +必须为0 | +
| Signature_word | +510 | +2 | +校验位 | +设置为0xAA55 |
+
| - | +512 | +* | +如果则存在此域,全部置零 | +必须为0 | +
文档在接下来的部分介绍了如何初始化一个FAT卷,由于目前只需要读取FAT卷,所以可以忽略章节3.4。
+在3.5章节中文档就介绍了如何在装载卷时判断FAT的类型,其中关键的判断算法为
+1 | if(CountofClusters < 4085) { |
也就是说,FAT的类型只与簇的数量有关,而与磁盘的大小、扇区数量,包括BPB中的BPB_FilSysType域都无关,其中
+在明白了BPB的结构之后,就可以开始着手对BPB进行抽象了。
+最初我采用了两个不同的类来分别对FAT12/16和FAT32的BPB进行抽象,但这样的问题也是十分明显的,在我不确定FAT实际的类型时我把首512字节视为哪个数据结构都不是很合理。但如果我不通过抽象后的数据结构去解析首512字节,我又没办法判断究竟是那种FAT类型。为了解决这个矛盾,我甚至由引入了一个类来描述FAT12/16与FAT32相同的37字节数据,这就导致代码越来越乱,看起来也让人云里雾里的。
+但后来就注意到虽然对于FAT12/16和FAT32,它们在结构上存在一定的不同,但其BPB有效的部分都是首扇区的首512字节(如果扇区大于512字节后面的也仍然填充0,可以忽略)。
+同时,BPB需要提供的主要功能也是基本相同的,主要的区别基本都在于域宽是16位还是32位。
+如果在函数接口的上不区分FAT12/16和FAT32,只在函数的实现上根据FAT12/16和FAT32的不同从512字节的数据中取出对应需要的域,就可以实现功能的统一,调用函数的代码就只需要关心需要使用FAT的哪种信息,而不需要再关心FAT的类型。
+例如,BPB_FATSz16和BPB_FATSz32实质上都是为了提供FAT表的大小,它们是可以统一到一个接口uint32 fatSize()上的,需要关心FAT表大小的代码不需要关心具体是哪种格式的FAT系统,只需要调用fatSize()既可以取得需要的数值,具体的解析操作交给了函数本身去区分和实现。
阅读上表,可以总结出BPB所需要提供的所有接口:
+1 | uint16 bytesPerSector(); |
可以注意到,有些函数是只有特定的FAT格式才存在的,这时如果调用者强行调用了一个当前FAT格式不存在的接口,实质上是不安全的,因为这样并不会产生任何错误,同时还会返回一个不确定的值。但这样的弊端比起这种实现的优点而言就显得微不足道了。
+更进一步地说,由于调用接口的代码只能是操作系统的代码所调用,而操作系统代码只能由我们所完成,所以我们可以通过主动避免错误的调用来避开这个风险,抑或是,可以在函数内额外判断一次FAT类型,并在不合法的调用处陷入死循环等等,有很多可以解决的方法。
+除了解析BPB数据的接口以外,由于BPB主要的作用是计算FAT文件系统各个区域的大小、偏移量等信息,所以也不妨将这部分琐碎的代码纳入类的函数作为接口提供给其他程序:
+1 | bool isFAT12(); |
于是,完整的BPB头文件fatbpb.h如下:
1 |
|
具体的函数实现则放在fatbpb.cpp中如下:
1 |
|
回顾:
+如果一个文件大小超过了一个簇,那么用来存储它数据的簇可能在磁盘上并不连续,为了能够将这些分散的簇连起来,文件系统一般会为每一个簇对应一个域用来保存关于它下一个簇的信息,从而就可以如同链表那样将整个文件串联在一起。在FAT中,这些域被集中存储在磁盘的一段空间内,这一段空间就叫做FAT (File Allocation Table)。
对于不同的FAT格式,FAT表中每个条目 (entry) 的大小不同:
+这时候就发现了,原来FAT后面的数字就是指代的FAT表中每个条目的长度
+FAT条目中可能的存储值及其含义如下,其中MAX指代磁盘中合法的最大的簇号:
+| FAT12 | +FAT16 | +FAT32 | +含义 | +
|---|---|---|---|
| 0x000 | +0x0000 | +0x0000000 | +当前条目所对应的簇空闲 | +
| 0x000 ~ MAX | +0x0002 ~ MAX | +0x0000002 ~ MAX | +当前条目所对应的簇存在内容,并且条目的值就是下一个簇的簇号 | +
| (MAX + 1) ~ 0xFF6 | +(MAX + 1) ~ 0xFFF6 | +(MAX + 1) ~ 0xFFFFFF6 | +保留的值,不能够使用 | +
| 0xFF7 | +0xFFF7 | +0xFFFFFF7 | +当前条目所对应的簇是损坏的簇 | +
| 0xFF8 ~ 0xFFE | +0xFFF8 ~ 0xFFFE | +0xFFFFFF8 ~ 0xFFFFFFE | +保留的值,有时也作为指示当前条目所对应的簇是文件的最后一个簇 | +
| 0xFFF | +0xFFFF | +0xFFFFFFF | +当前条目所对应的簇是文件的最后一个簇 | +
陷阱:FAT32高四位保留
+FAT32中的每个条目高四位都是被保留的,所以可以看到上表中FAT32对应的值只有7位。除了在格式化的时候,在其他任何时候设置FAT条目时都不应该更改原先高四位的值。
陷阱:FAT表首两个条目保留
+注意到上表中有效的FAT条目值从2开始,因为FAT表中的前两个条目是被保留的。
+这也就导致了簇号和实际的簇产生了2的偏移:对于任意簇号,当在磁盘上访问实际的簇时,应当访问第个簇
FAT表中第一个保留的条目(FAT[0])包含了BPB_Media域中的内容,其他的位被设置为1,对于FAT32高四位同样不进行更改。例如,如果BPB_Media的值为0xF8,那么
0xFF80xFFF80xFFFFFF8第二个保留的条目(FAT[1])在格式化时会被格式化工具赋一个EOC值(具体用处不明)。对于FAT12而言,由于空间有限,所以并没有额外的标记位,对于FAT16和FAT32而言,Windows系统可能会使用高两位作为脏卷的标记位,其中最高位为ClnShutBit,如果该位置位,则意味着上一次该设备没有被正常卸载,可能需要检查文件系统的完整性;次高位为HrdErrBit,如果该位置位,则标明读写功能正常,如果置0则代表遇到了IO错误,提示一些磁盘扇区可能发生了错误。
由于FAT表相当于一个数组,因此其不需要特殊的抽象。
+不过由于FAT12的特殊性,12位长度的数据并不能跟字节对齐,也就是说在FAT12中,一个条目从某个字节的中间开始,所以在根据下标从FAT从取值时可能会稍微复杂一些,在文档中也给出了具体的读取的代码。
+由于具体文件的读取在KernelLoader中进行,因此这部分的内容可以留待下一个章节再详细研究。
+到目前为止,BPB以及FAT表我们都已经清楚了,这样不论是FAT12、FAT16还是FAT32,我们都可以顺利地找到根目录所在的区域并且读取根目录所包含的所有数据了:
+那么,接下来就要解决如何解析根目录内的文件了。更广泛地讲,由于根目录就像其他所有的文件目录一样,所以接下来就要解决如何读取一个目录,并且得到目录中各个文件的信息。
+实际上,目录同样是一个由目录条目构成的数组,其中每一个目录条目都是一个32字节长度的数据结构,而正是这个数据结构中存储的数据描述了一个目录中存储的文件或是一个子目录的详细信息,例如它的创建日期和时间、名称或是最重要的首簇簇号等等。它的完整结构如下:
+| 域名称 | +偏移 | +大小 | +描述 | +
|---|---|---|---|
| DIR_Name | +0 | +11 | +短名称格式的文件名 | +
| DIR_Attr | +11 | +1 | +文件的属性标记 | +
| DIR_NTRes | +12 | +1 | +保留位,必须为0 | +
| DIR_CrtTimeTenth | +13 | +1 | +文件创建时间,单位为10ms | +
| DIR_CrtTime | +14 | +2 | +文件创建时间 | +
| DIR_CrtDate | +16 | +2 | +文件创建日期 | +
| DIR_LstAccDate | +18 | +2 | +文件最近访问日期 | +
| DIR_FstClusHI | +20 | +2 | +首簇簇号高16位 | +
| DIR_WrtTime | +22 | +2 | +文件修改时间 | +
| DIR_WrtDate | +24 | +2 | +文件修改日期 | +
| DIR_FstClusLO | +26 | +2 | +首簇簇号低16位 | +
| DIR_FileSize | +28 | +4 | +文件的大小,单位为字节 | +
其中提到了一些概念,如短名称、文件属性以及一些仍然不明确的结构,如日期和时间的表示格式,如果继续阅读,则会发现文档也一一对它们作出了解释。
+短名称为一种表示文件名称的格式,其11字节长度的域被分为8字节和3字节的空间,其中11字节用来存储文件不含扩展名的部分,而后3字节用来存储文件的扩展名,在存储名字的时候,所有的字母都会以大写字母的形式存储。
+同时,短名称的存储方式会在文件名长度小于最大长度时在其后面填补空格,例如FOO.BAR在存储时由于文件名为三字节,所以会在其后填补5个空格,存储为FOO BAR
除此之外,短名称还遵循以下规则:
+0xE5则代表当前条目为空0x00则同样代表当前条目为空,并且还代表当前条目之后的所有条目都为空0x20的字符以及0x22、0x2A、0x2B、0x2C、0x2E、0x2F、0x3A、0x3B、0x3C、0x3D、0x3E、0x3F、0x5B、0x5C、0x5D、0x7C然而,如果文件名称很长的话,短名称就显得不太够用了,所以FAT还额外提出了长名称的解决方案,并且称存储短名称的条目为 SFNEntry,长名称的目录为 LFNEntry。
+在长名称的解决方案中,一个文件会对应一个短名称的条目和一系列长名称的条目,短名称条目存储文件名称的前数个字符和扩展名,而长名称则存储文件的全部名称。
+在目录中,一个文件对应的长名称条目和短名称条目连续存储,其中地址从低到高依次存储:
+其中,长名称条目的结构如下:
+| 域名称 | +偏移 | +大小 | +描述 | +
|---|---|---|---|
| LDIR_Ord | +0 | +1 | +当前条目在当前文件所有长名称条目中的顺序,从1开始计数 如果是最后一个条目,则需要额外置位 0x40 |
+
| LDIR_Name1 | +1 | +10 | +存储当前条目中文件名的第1~5个字符 | +
| LDIR_Attr | +11 | +1 | +文件的属性标记,与短文件名条目的属性标记域保持相同含义 对于长文件条目,所有的属性位都应被置位 也即 ATTR_READ_ONLY | ATTR_HIDDEN | ATTR_SYSTEM | ATTR_VOLUME_ID |
+
| LDIR_Type | +12 | +1 | +必须为0 | +
| LDIR_Chksum | +13 | +1 | +校验位,用于校验当前条目是否与文件对应的短文件名条目相匹配 | +
| LDIR_Name2 | +14 | +12 | +存储当前条目中文件名的第6~11个字符 | +
| LDIR_FstClusLO | +26 | +2 | +必须为0 | +
| LDIR_Name3 | +28 | +4 | +存储当前条目中文件名的第12~13个字符 | +
陷阱:LDIR_Ord从1开始
+文件对应的第一个长文件名条目在集合中的序号为1,而不是0
陷阱:长文件名使用Unicode存储
+长文件名中的LDIR_Name1、LDIR_Name2和LDIR_Name3中存储的都是Unicode格式的文件名,一个字符占据两个字节。
对于校验码,文档也提供了算法,对应到实验中的实现为:
+1 | //Calculate/Return the checksum of the entry |
文件的属性使用按位枚举的方式表示,其中的六个二进制位分别如下:
+| 属性 | +位 | +描述 | +
|---|---|---|
| ATTR_READ_ONLY | +1 << 0 | +文件只读 | +
| ATTR_HIDDEN | +1 << 1 | +文件隐藏 除非用户或程序显式声明要求访问隐藏的文件,否则不应当在文件列表中被列出 |
+
| ATTR_SYSTEM | +1 << 2 | +文件为系统文件 除非用户或程序显式声明要求访问系统文件,否则不应当在文件列表中被列出 |
+
| ATTR_VOLUME_ID | +1 << 3 | +文件用来描述卷标 | +
| ATTR_DIRECTORY | +1 << 4 | +文件实际上是一个目录 | +
| ATTR_ARCHIVE | +1 << 5 | +当文件被创建、重命名或修改时置位,指示文件是否被修改过 | +
陷阱:卷标文件只能出现在根目录
+卷标文件为根目录中描述卷标的特殊文件,其DIR_NAME域全部用来存储卷标,同时属性域为0x8,也即ATTR_VOLUME_ID,其他部分均为0。
文件的日期和时间有特殊的存储格式
+其中文件的日期包括三个部分:
+[4:0]:日(从1至31)[8:5]:月(从1至12)[9]:从1980年起的年份偏移(从0至127)文件的时间同样也包括三个部分:
+[4:0]:从零起经过的2s间隔数(从0至29)[10:5]:分(从0至59)[15:11]:时(从0至23)可以看到,秒的精度为2s,这也就引出了CrtTimeTenth这个域,其精度为10ms,范围从0至199,正好填补了2s之间的空缺,使得精度提高到10ms
+在了解了目录的结构之后,就可以着手对目录进行抽象了。由于目录本身还是相当于关于目录条目的数组,所以根本在于对目录条目进行抽象。
+因为目录条目分为SFN(短文件名)和LFN(长文件名)两种类型,但其总长度一样,所以可以参考对BPB进行抽象的过程:将两种条目合并在一起,均视为DirEntry类,在调用具体函数的时候再根据条目类型的不同执行不同的判断。
其中,由于文件属性的域涉及到位枚举,而C++没有原生支持位枚举,所以需要我们手动先在头文件中实现位枚举:
+1 | class DirectoryFlag { |
并在.cpp文件中给出重载操作符的实现
1 | DirectoryFlag::DirectoryFlag() { |
之后就可以根据上述表格完成DirEntry类的定义和实现了。需要注意的是,由于要从跟日期和时间有关的域中解析出对应的年月日或是时分秒仍然需要一步操作,所以我将条目中用到的时间和日期又额外包装成FATDate和FATTime放在fatdt.h中,并在这两个类中提供解析出年月日和时分秒的接口,在DirEntry的接口中不在返回裸的数值,而是将其转换为这两个类的实例进行返回。
我所实现的fatdt.h文件内容如下:
1 |
|
关于具体函数的实现,就留给读者自己完成。
+综上,可以完成fatdir.h文件如下:
1 |
|
完整的fatdir.cpp则实现如下:
1 |
|
上一小节中,我们实现了FAT文件系统的抽象,为从文件系统中读取内核文件奠定了基础。对于一个仅有一个分区的硬盘而言,如果其文件系统恰是FAT格式,那么就可以使用我们抽象出的接口访问其中的文件。
+但现如今磁盘中只有一个分区的电脑少之又少,如果磁盘中有多个FAT格式的分区,那么加载程序应该去哪个分区寻找内核文件,又该如何定位到对应分区的位置呢?
+这些就要依赖于存储在MBR中的数据了。在第二章:计算机是如何启动的一节中提到,MBR在447~510这64个字节中存储了分区表信息,包括了分区的引导标志、起始磁道、起始扇区、起始柱面等信息。
+注:实验中使用的是MBR分区表
+在实验中使用的是MBR分区表,而不是GPT分区表,所以通过MBR来解析分区信息。
+在MBR分区表下,最多只能存在四个主分区,这是由MBR存储分区表区域的空间限制所导致的,更多的分区则需要使用逻辑分区来表示,关于逻辑分区的内容与本次实验无关,故此处不进行介绍。
通过这些特殊的信息,加载程序就可以判断哪个分区是活动的,以及这个分区在磁盘上的位置,以及分区上文件系统的类型。在确定了这些信息之后,加载程序就可以使用上一节中关于文件系统的接口来读取和加载内核文件了。
+在经历了对文件系统的抽象之后,此时大家应该很快就能想到,为了能够方便地解析MBR中分区表的数据,可以对MBR的结构进行抽象。
+参考资料:MBR结构
+关于MBR结构的详细信息,此文中参考了 Wikipedia中关于MBR的页面 ,其中,实验采用了Classical generic MBR的格式对MBR结构进行解析。
在最一般的MBR结构中,其包含了三个主要的部分:
+0xAA55,用来向BIOS表示磁盘是可启动的它们的地址和大小如下:
+| 内容 | +起始地址 | +大小 | +
|---|---|---|
| 启动代码 | +0x0000 | +446B | +
| 分区条目1 Partition entry №1 |
+0x01BE | +16B | +
| 分区条目2 Partition entry №2 |
+0x01CE | +16B | +
| 分区条目3 Partition entry №3 |
+0x01DE | +16B | +
| 分区条目4 Partition entry №4 |
+0x01EE | +16B | +
| 启动签名 | +0x01FE | +2B | +
同时,每个分区条目 (Partition entry) 又具有如下的结构:
+| 偏移 | +大小 | +描述 | +
|---|---|---|
| 0x00 | +1 | +描述分区的状态0x80用来表示活动分区0x00表示非活动分区其他值均为无效值 |
+
| 0x01 | +3 | +分区首扇区的CHS表示 | +
| 0x04 | +1 | +分区的类型 | +
| 0x05 | +3 | +分区末扇区的CHS表示 | +
| 0x08 | +4 | +分区首扇区的LBA扇区号 | +
| 0x0C | +4 | +分区总扇区数 | +
其中,根据 Wikipedia上关于分区类型的页面 中的描述,实验中将会用到的分区类型以及它们的值为:
+| 类型 | +值 | +
|---|---|
| FAT12 | +0x01 | +
| FAT16 | +0x04 | +
| FAT32(CHS) | +0x0B | +
| FAT32(LBA) | +0x0C | +
同时,扇区号的CHS表示又具有如下的结构:
+| 偏移 | +大小 | +描述 | +
|---|---|---|
| 0x00 | +1 | +磁头号 (head) | +
| 0x01 | +1 | +由两部分组成: 高2位为柱面 (cylinder) 第8-9位 低6位为扇区号 (sector) 第0-5位 |
+
| 0x02 | +1 | +柱面 (cylinder) 第0-7位 | +
可见,对于一个分区条目而言,其主要的作用就是判断分区是否存在、判断分区的状态(是否是活动分区)、标记分区的文件系统类型以及获取分区的首扇区号和扇区数,因此其接口就主要围绕这些方面进行设计:
+1 | public: |
明确了分区条目的结构和需要提供的接口,可以迅速将分区条目抽象为Partition类型如下:
1 | class Partition { |
上述接口则均实现在partition.cpp文件中:
1 |
|
同时,在分区条目的结构中涉及到的CHS扇区号由上文中提到的CHS结构抽象为CHSAddress类型,其只需要提供从3字节长度的数据中提取柱面、磁头和扇区号的接口:
1 | class CHSAddress { |
注:
+由于在实验中实际上并不会通过CHS的地址来计算扇区号,因此在实验中并没有对这三个接口进行具体的实现,这部分内容就留给读者完成。
实现了对分区条目的抽象之后,对MBR结构的抽象就易如反掌了,因为其实际上就是由一段长度为446的字节数组 (Bootstrap code) 、四个分区条目类型的对象 (Partition table) 以及一个两字节的数组 (Boot signature) 组成的:
+1 | //mbr.h |
由于Partition类型的对象已经提供了足够使用的接口,因此MBR并不需要再设计额外的接口,而只需要将四个Partition的变量暴露给程序即可。如果程序想要从MBR中获得一个分区是否是活动的,以及其起始的LBA扇区号,其可以进行如下的操作:
1 | //Assume there is a MBR typed variable mbr |
实现了MBR和文件系统的抽象就意味着现在我们已经可以从硬盘中找到需要的内核文件了,并且借助硬盘读取的接口将内核的文件从硬盘中读取到内存。下一步就是将内核加载到正确的地址空间中,完成加载内核的全部步骤。但是在开始加载内核之前,需要先了解虚拟内存的概念以及分页机制的原理,并且实现对分页要用到的数据结构进行抽象后才能正确地对内核进行加载。
+参考资料
+如果想要了解关于内存管理策略以及虚拟内存的全部内容,可以阅读课本《操作系统概念》中第三部分关于内存管理的内容,我强烈建议先完成这部分的阅读再继续后面的实验。
+当然,在接下来的相当一部分篇幅中,我也会用我自己的理解介绍什么是虚拟内存,以及分页机制的最基本概念。但这些远不如书本全面、权威,因此再次建议大家先对课本进行一次略读后再继续后面的实验内容。
既然要介绍虚拟内存和分页机制,自然就要从它们出现的原因谈起,而这就绕不开 “程序是如何运行起来的” 这个话题。
+在学完了『操作系统原理』这门课后,想必已经对程序的运行有了一个大致的认识,知道了程序运行的基础是CPU对机器指令的执行,而CPU执行机器指令由是由数个流水级逐步完成的,不考虑分支预测等等复杂的实现,CPU对指令的执行可以抽象为以下的步骤:
+可见,想让程序运行起来,关键就是两步:
+这样,在下一个时钟周期到来的时候,CPU就会从想要执行的指令处开始执行了。
+把指令的地址放置到PC中就十分简单了,在汇编中常见的跳转指令都可以完成这一操作,在硬件上的实现想必大家也是历历在目的(小声),那剩下还要解决的就是把指令存到内存中这个问题啦。
+但偏偏就是把指令存到内存中这么一个看起来很简单的操作,背后却暗藏了各种难题,为了这么一个操作,曾经的工程师们也可谓是八仙过海,各显神通。
+还记得我们在第二部分:从代码到可执行文件一节中提到的程序的链接步骤中提到,在链接时需要指定程序起始的地址,这个地址关系到了整个程序所有代码段中使用到的绝对地址的计算,这就为程序的加载带来了一个大难题:
+如果我不知道程序要加载到哪里,那我应当如何指定程序起始的地址?
+如果强行提前指定了程序的起始地址,那个地址被其他的程序占用了又该怎么办?
于是,曾经伟大的工程师们就提出了一个概念:让程序视角下的地址和它真正运行着的地址分开。通俗点解释,就是令程序在执行的时候中,其指令中的各种地址都视为程序视角下的地址,通过一些手段,让这个地址和真正访问内存的地址不是同一个地址,但又存在着一定的映射关系:
++
结合才学没多久的计算机组成原理课程的知识,可以大致理解为,在从指令中分离出地址后并不是直接送入内存,而是经过一个特殊的地址变换部件 (这也就是常说的MMU) 之后才会送入内存。
+
什么是程序视角下的地址:
+就像上图,当程序在运行的时候,CPU会根据指令生成出一些地址的值,例如mov eax, dword [ebx]会从内存中加载4字节数据,其产生的地址是ebx中存储的值;或是jmp 0x7E00会跳转到0x7E00处执行,CPU会产生0x7E00这个地址送入指令地址寄存器。
然而,CPU所不断地产生出的地址的值,还需要经过地址变换部件才能成为有意义的地址。对于CPU而言,它并不知道MMU的存在,它只知道指令生成了一些地址,而且在运行的过程中这些地址确实产生了正确的效果,看起来就像是程序真的就放在这个地址上运行一样,而不知道这些地址实际上是由于MMU的地址变换作用才产生了正确的效果,所以这实际上这些未经变换都可以被称作是CPU“假想”的地址,也就是常说的虚拟地址,我个人喜欢将它们称为是程序视角下的地址,我认为这样说会更形象、更易于理解。
+由于程序的视角其实通俗理解就是CPU的视角,所以在程序执行过程中由CPU生成的地址,其实就是程序视角下的地址。
+这些地址是虚拟的,是假定的,因为它们并不能代表程序在内存中存放的地址,这一点很重要。
+陷阱:程序某个部分在内存中的地址≠程序视角中这个部分的地址
+要理解后面的分页制度,就一定要有程序视角的地址是“假想”的地址,是程序所认为的它在内存中的位置的概念。
+在出现了地址变换部件这个概念之后,程序在运行的时候由CPU生成的所有地址都不再一定是CPU想要从内存中获取的数据的地址,而是要经由一次MMU的映射才能获得程序想从内存中获取的数据的地址。
这样,即便程序都以0x0为起始地址,也可以通过不同的地址变换方式来达到将程序存储在内存中不同位置的效果。
但是摆在工程师面前的,还有更多的问题:
+如何从空闲的空间中选择合适的位置存储需要加载的程序?
+在操作系统运行的过程中,会不断有程序需要被加载到内存运行,同时也会不断地有程序结束运行。结束运行的程序可以释放出它本身占用的空间,需要加载运行的程序则需要申请能够存放其自身的空间。如此往复的过程中,不断被申请然后被释放的程序所占用的内存空间会让内存的地址空间中出现一个个的空洞 (Hole),原先连续的大片空闲区域就这样被分割成了不连续的许多小的空闲的内存片段:
+ +这听起来就不是什么好事,事实上也确实是如此,当操作系统要加载一个新的程序进入内存的时候,它几乎一定面对的是一个“满目疮痍”的内存空间,而如何从无数的空洞中选择合适的那个来存放程序,也就成了一个难题。过去的工程师提出了三种解决方案:
+碎片:
+在上面的描述的三种为程序的分配内存的方式中可以看出并不是每一次都能够为程序分配恰到好处的空间,往往一个新程序的加载会导致内存中出现小的空洞,这些空洞小到几乎不足以放下任何其他的程序,这就意味着这部分内存直到程序运行完毕被释放之前都不能够被操作系统用来为其他程序分配,这些实际上被浪费掉的内存空间就是碎片。
+碎片又分为外部碎片和内部碎片,前述的这种在为程序分配的空间以外的浪费空间叫做外部碎片,而如果操作系统为一个程序分配了多余它实际要使用的内存空间,那么其内部被浪费掉的空间就叫做内部碎片,这种类型的碎片将会在后面介绍分页机制时看到。
+碎片对于内存空间的利用而言是致命的,试想一个充满了碎片的内存,明明其空闲的空间足够大,但是由于全部都是由碎片组成,而不能放入任何一个程序。
+然而,上面的三种分配方式都仍然是将一个程序视为一个整体进行加载的,多少有点偏执了。
+假设内存中有 个大小为 大小的空洞,需要加载一个大小为 大小的程序,此时不论采用上述哪一种分配方式,都无法找到任何一个合适的空洞来装载新的程序。如果此时摒弃非要把一个程序连续装入内存的落后思想,而是想办法把程序剁成四块,分别为 、 、 和 ,然后装入四个空洞中,不就成了吗?
+ +聪明的工程师自然也想到了,所以分页的概念也就应运而生。
+为了方便操作系统进行管理,工程师将内存和程序都按照一个相同的大小进行分割,例如常见的 ,这个固定大小的空间就叫做页,将内存和程序分割的过程也就叫做分页。
+对于内存而言,剩余不足一页的空间会被舍弃;对于程序而言,不足一页的部分也会分配一个完整的页,这时页中没有被使用的部分就是内部碎片,例如如果程序的大小为 ,而页的大小为 ,则程序会占用两个页,即使第二个页中只有一个字节是真正被使用的,这就产生了 的内部碎片。
+陷阱:对程序分页实质上是对程序地址空间进行分页
+对程序分页是指的对程序视角下的地址空间进行分页,而不是对程序实际在内存中占用的地址空间进行分页,并且这之中还有一个先后的关系,程序只有先对它所用到的地址空间进行分页,才能对应地放入到内存中分好的页里。
物理页和虚拟页:
+在后文中,物理页就将指代内存中的页,而虚拟页就将指代程序的虚拟地址空间中的页。
在内存进行了分页之后,往后内存空间的分配和释放都会以一页为最小单位,因此当需要加载新的程序进入内存时,如果内存中空闲的页不少于程序运行所需要的页,则为程序分配其所需要的数量的页,并且建立一个从程序视角下的页到实际内存中的页的映射关系
+ +有了这样的映射关系,操作系统就可以对应着将程序的内容装载进内存,至于在程序视角下的地址空间内那些空间需要写入内存、怎样写入内存等等将会留到下一小节介绍。
+但是这样复杂的映射关系,CPU是如何记住的呢?
+这就要提到本章节的主角页表了。为了能够让MMU知道程序视角下的页与内存中页的映射关系,早期的工程师创造了一个类似FAT表的表结构,其中每个程序视角下的页都可以对应到一个表项,而每个表项中的内容都对应到一个内存中的页,这个对应关系使用内存页的首字节地址表示。
+ +不过CPU产生的地址肯定不会是页的大小的整数倍,而是可能对应着一个页内的任何一个字节,此时如果只有页的映射关系是否有些不够了呢?
+自然不是的。由于页在映射的时候是将一个页视为一个整体进行映射,所以同一个内容在虚拟页和物理页中的偏移量是相同的,在对CPU生成的地址进行变换的时候,只需要将页的地址和偏移量分离开来,通过页表得到物理页的地址以后,再将其作为基址叠加上原先分离出的偏移量,就可以得到映射后的地址了。
++
不过,只是得到虚拟页的首字节地址还不够,既然要在页表中寻找对应关系,就需要知道虚拟页对应的下标,也就是虚拟页在所有页中的序号。由于页的大小都是 形式,所以第 个虚拟页的地址有如下关系:
++
所以对于一个虚拟页,其在页表中的下标就可以通过虚拟页的地址移位得到。对于大小为 的页,一个虚拟页对应的下标为
++
代入上式可得
++
以 大小的页为例,对于虚拟地址0xC00002a0,其高20位0xC0000代表这个地址所在的页的序号,而低12位0x2a0其实是代表着这个地址在页内部的偏移量。也即在程序的虚拟地址中,0xC00002a0地址位于第0xC0000页中第0x2a0个字节处。访问页表中下标为0xC0000的项,得到虚拟页对应的物理地址,例如0x20000000,再将物理地址和偏移量合并,得到映射后的物理地址0x200002a0。
不过,如果按照这样的方式映射,对于一个32位的系统来说,每一个虚拟页都需要4个字节来存储其对应的物理页的地址,而32位的地址最多可以取到 的地址范围,假设页的大小为 ,以 表示 x所占用的内存字节数,那么页表最多需要占用的内存计算如下:
++
也就是说,光是一个页表结构就要占用 的内存空间,对于现在动辄 的内存来说可能微不足道,但在曾今内存空间寸土寸金的时候,甚至足够跑起一个操作系统内核,这就显得页表有些过于臃肿了。于是,为了解决这个内存占用的问题,工程师们又提出了多级分页 (Multi-level paging) 的概念,其中在32位的操作系统中使用最多的是二级分页 (Two level paging),而64位操作系统多使用四级分页或是更多级别的分页。尽管分页的级数会存在不同,但它们的原理都是相同的,而且都是为了解决一个问题:减少页表自身占用的存储空间。
+以二级分页为例,实际上就是将原先页表的结构同样按照页划分为块,对于上面提到的32位架构中的页表结构可以被划分为 个块,每一个划分出来的块都叫做一个一级页表 (Level 1 Page Table)。每个原先的页表项此时就可以像虚拟地址那样,表示成为一级页表的序号和页表内的偏移,不过此时的偏移就不是以字节为单位的偏移,而是以页表项元素大小(4字节)为单位的偏移。
+对于一个原先页表项的序号 ,它对应的一级页表的序号和页表内的偏移为:
++
通俗而言,就是原先页表项的下标的高10位为这个页表项所在的一级页表的序号,低10位则为这个页表项在其所在的一级页表中的下标。例如页表中原先位于0x80200(0b1000_0000_0010_0000_0000)的页表项,可以认为是在第0x200(0b10_0000_0000)个一级页表中的第0x200个页表项。
在划分了一级页表之后,额外添加了一个页表,其中的每一项是32位的数值,指向这一项下标对应的一级页表所在的首字节的地址,这个页表就是二级页表 (Level 2 Page Table)。由于在32位架构的设备上最多有 个一级页表,而一个一级页表对应的二级页表中的页表项大小为4字节,所以二级页表的大小为
++
正好就是一个页的大小。
+这样,当要将一个虚拟地址转换为物理地址时,需要
+乍一看,好像二级页表的出现是多此一举,本来可以直接用下标在页表里定位到页表项,从页表项直接取得物理页地址,现在还要多此一举,先从二级页表拿到一级页表的地址,然后才能拿到物理页地址。仔细一算的话,对于所有的虚拟页都进行映射的情况,二级分页甚至还要比不采用二级分页多出一个二级页表的大小,这哪里节约了页表占用的空间!
+最初我也是同样对此感到十分地疑惑,一个不但增加了内存占用,复杂了流程还减慢了地址转换地速度的方案怎么就流传下来了呢?后来我意识到我忽略了一个很关键的细节:前文中讨论页表占用的内存是时始终是基于虚拟地址全部映射到物理地址这个前提进行的,然而事实上,大部分时候都不会需要将虚拟地址全部映射在内存中,甚至有时候内存根本就不够映射全部的虚拟地址!
+虚拟地址不完全映射到内存就意味着页表中有空的页表项,对于没有二级分页的方案,由于整个页表都相当于一个巨大的数组结构,即使一个元素为空也不能舍弃它的空间,所以不论映射了多少虚拟页,页表占用的大小始终是固定的。然而对于二级分页的方案,由于二级页表记录的是一级页表的地址,这也就相当于拆散了原先巨大的页表,并且还使得拆分后得到的一级页表可以分散地存储在内存中。这样做还带来了一个巨大的好处,就是页表的内存不再需要一次性分配完毕,因为原先没有多级分页机制的时候,但凡需要页表,就需要一次性申请全部页表的空间,而现在即便一级和二级页表还是相当于数组结构,还是需要为每一个页表分配固定大小的内存,但是它们单个的体积从原先的 降到了 ,同时由于二级页表的存在,如果一整个一级页表内的表项全部为空,那么就可以不分配这个一级页表的空间,从而达到节省空间的目的。
+ +有了二级页表之后,现在摆在我们面前的只剩最后一个问题:
+如果程序代码比内存还要大怎么办?
+早期的RAM相当金贵,远不及如今动辄16GiB、32GiB,能有个几兆几十兆都是奢侈。那如果一个程序就十分不巧,大小竟然比内存还大,那这个庞然大物该如何运行起来呢?
+这就要提到一个叫做 程序运行的局部性原理 的概念,它是指程序在一段时间内程序的执行只限于程序的一部分,就以我们熟悉的冒泡排序算法为例吧:
+1 | for(int i = 0; i < n; i++) { |
即便程序的其余部分无比复杂,哪怕是3A巨制,但只要程序运行到这里,它在相当一段时间里只会在两个for循环里打转转,它访问的内存也只会局限在arr这个数组里,这就是程序的局部性,在这一段时间里程序所用到的空间就是程序所需要的工作集。
由于程序运行具有局部性的原理,所以不论程序占用的虚拟地址空间有多大,它在一个特定的时间长度内都不会访问全部的地址空间,这时只需要将其运行需要的部分加载到内存中,程序就可以正常地运行了。这就好比我们在玩Minecraft时候,往往我们的视野半径只有十几个区块,所以加载无尽的世界的所有区块(这显然是不可能的!)和只加载我们视野范围内的区块在我们看来效果是一样的(甚至肯定会流畅不止一点点),所以为了能让我们愉快的玩上游戏,游戏的设计者就不会让电脑加载视野范围外的区块,或者是限制加载视野范围外的区块,从而让我们体验不变的情况下让游戏能够正常地运行,电脑不至于直接冒烟。
+抖动
+即使程序运行具有局部性,但仍可能出现工作集比内存大小大的情况。
+比如一个程序在某一时刻的工作集为10个页ABCDEFGHIJ,而内存只有9个页可供程序使用。
+假设某一时刻程序加载了ABCDEFGHI页,当它需要J页的时候,其发现页并不在内存中,就会尝试从磁盘加载这个缺失的页进入内存。
+而这个操作就会顶掉ABCDEFGHI中的一个页,很快,程序又会遇到被顶掉的页,其又会加载并顶掉另一个页。
+这样的操作会如此不断重复,程序也就无法再正常地继续运行了,这就是抖动现象。
现在,我们知道了什么是虚拟地址,什么是分页,什么是多级分页。在实验中我们要用到的就是上文中提到的二级分页机制,它用到了一级页表、二级页表和其中的页表项三个主要的数据结构,而一级页表和二级页表实质都是页表项的数组,没有本质上的差异,可以视为同一个数据结构,所以我们只需要实现页表和页表项的数据结构就足够使用了。
+由于页表中需要用到页表项,所以我们首先来对页表项 (PageTableEntry) 进行抽象。
+前文中提到,页表项中存储的是一级页表或是物理页的地址,其实这个描述不是十分准确。如前文所述,由于页的大小固定,并且都是 的大小,所以任何一个页的地址的低位其实都是相同的且都是0,这就意味着保留着些位是毫无意义的,例如,对于32位的架构而言,由于页的大小为 ,所以任何一个页的地址低12位均为0。工程师显然也发现了这一点,所以它们决定利用这12个无用的位来存储更多的信息,包括页的属性、权限等。
根据 OSDevWiki上关于页表项结构的描述,页表项 (Page Table Entry) 的结构如下:
+| 简写 | +全称 | +位 | +描述 | +
|---|---|---|---|
| P | +Present | +[0] |
+页表项存在位0:不存在1:存在 |
+
| R/W | +Read/Write | +[1] |
+写权限位0:不可写1:可写 |
+
| U/S | +User/Supervisor | +[2] |
+访问权限位0:用户级程序不可访问1:用户级程序可以访问 |
+
| PWT | +Page Write Through | +[3] |
+写穿透位,实验中不会用到,可以不用了解 | +
| PCD | +Page Cache Disable | +[4] |
+禁用缓存位,实验中不会用到,可以不用了解 | +
| A | +Accessed | +[5] |
+访问位0:页面在上次清除这个位之后没有被访问过1:页面在上次清除这个位之后被访问过 |
+
| D | +Dirty | +[6] |
+脏位0:页面在上次清除这个位之后没有被写过1:页面在上次清除这个位之后被写过 |
+
| PAT | +Page Attribute Table | +[7] |
+用于指示内存缓存类型 (Memory caching type),实验中不会用到,可以不用了解 | +
| G | +Global | +[8] |
+全局页面位,使得TLB缓存中的页表项不会随着CR3寄存器的更改而失效,实验中不会用到,可以不用了解 | +
| Address | +- | +[31:12] |
+地址高20位 | +
尽管在前文的描述中,好像一二级页表中页表项结构是相同的,但实际上它们在某些位的功能上存在一部分差异,这部分差异即使是我自己在实现的时候也忽略了,直到写这一份日志仔细查阅Wikipedia时才发现。在二级页表中,它的目录项第6位、第7位和第8位的含义有所不同,其中第6、8位在二级页表的页表项中作保留位,而第7位的属性描述如下:
+| 简写 | +全称 | +位 | +描述 | +
|---|---|---|---|
| PS | +Page Size | +[7] |
+页大小位0:页大小为4KiB1:页大小为4MiB在实验中使用4KiB大小的页,所以这一位始终为0 |
+
为了简便起见,我在实验中采用了不那么安全的实现:我将两种不同的属性合并在同一个位枚举类当中,并在后续的使用中主动避免使用不符合类型的属性。如同在硬盘驱动和FAT中介绍的位枚举类那样,页表项中的属性位可以抽象如下:
+1 | class PageFlag { |
对应的实现在复制之前的枚举类函数的基础上对参数类型进行修改,对应_attr变量的类型即可:
1 | PageFlag::PageFlag() { |
接着,就可以抽象页表项的结构了,由上表可以看出,页表项就是由高20位的地址位和低12位的属性位组成。在实现中我选择将整个页表项的值视作一整个变量进行存储:
+1 | class PageTableEntry { |
然后再为类添加从值中解析出地址和属性或是根据参数设置值中的地址位和属性位的成员函数:
+1 | public: |
为了方便类的实例或是指针的构造,额外添加构造函数和静态成员函数如下:
+1 | public: |
小技巧:
+在后面的编程实践中,由于我们往往拿到的都是某个页表项的地址,而不是一个页表项的实例。
+对于像页表项这样比较复杂的结构,如果直接通过地址和指针的解引用然后再对其中的内容进行解析,则会让代码的可读性变得很差,例如获得页表项中存储的地址可以用uint32 address = (*ptr) & 0xFFFFF000实现。
+但由于指针具有可以随意其指向的内容的类型的特性,可以通过将一个指针转换为对应结构的类(例如PageTableEntry)的指针,然后就可以通过指针来调用类的成员结构,从而实现代码重用,并且也让代码的可读性得到了提高。
+例如,原先的操作也可以通过uint32 address = (PageTableEntry*)ptr->address()实现,而这种实现不但可以实现address()的重用,还一下就能够看出语句的目的是获得页表项中存储的地址。
+而static PageTableEntry* from(uint32 addr);这样的函数其实就是为了我们能够更方便也更美观地进行指针的类型转换:PageTableEntry::from(ptr)肯定要比(PageTableEntry*)ptr看起来更合理,并且在某些特定的情形下,这样的转换函数内还可以添加额外的判断,让类型的转换更加安全。
PageTableEntry类的全部定义如下:
1 | class PageTableEntry { |
其中函数的实现如下:
+1 | PageTableEntry* PageTableEntry::from(uint32 addr) { |
而对于页表而言,它的实现就更加简单了,由于它在 的页大小下本质就是一个长度位1024的数组,所以其成员变量就是一个长度为1024的数组变量:
+1 | class PageTable { |
而由于页表本身不需要实现太多的功能,在目前实验中只需要对页表进行初始化以及取出页表中的第idx个页表项,所以其成员函数也十分简单:
1 | public: |
除此之外,由于页表可能是一级页表也可能是二级页表,对于一级页表而言,一般一级页表都是直接拿到地址,所以需要添加一个由32位地址转换为页表对象指针的静态成员函数;而二级页表都是要从一级页表中获得了页表项,解析出地址,然后再转换为二级页表对象的指针,稍微有点麻烦,所以不妨添加一个由PageTableEntry类的对象直接转换为页表对象指针的静态成员函数。
1 | public: |
完整的PageTable类定义如下:
1 | class PageTable { |
其中的成员函数实现如下:
+1 | PageTable* PageTable::from(const PageTableEntry& entry) { |
勇者奖章
+恭喜你,读完了所有日志中最长最复杂的一篇,后面的实验之路将会因这一章的努力而愈发平坦。