一、概述
本系列的目的是使用 C 和 x86 汇编语言从头开始学习操作系统 (OS) 开发,典型特点是:“增量”学习。
实践参考:
http://www.brokenthorn.com/Resources/OSDevIndex.html
注:在开始对系统进行编程时,您会发现根本没有任何东西可以帮助您。上电时,系统也以 16 位实模式运行,32 位编译器不支持该模式。这是第一件重要的事情:如果你想创建一个16位实模式操作系统,你必须使用16位C编译器。但是,如果您决定创建 32 位操作系统,则必须使用 32 位 C 编译器。 16 位 C 代码与 32 位 C 代码不兼容。
二、OS基础知识
1. 整体上操作系统向下管理硬件,向上为用户/应用提供服务,总体上功能分为:
(1)CPU管理:主要是程序的执行,如中断、进程、调度算法等。
(2)内存管理:主要对ROM、RAM等的管理,包括分配内存、释放内存、内存保护。
(3)设备管理:即IO设备,包括FLASH, 硬盘、USB、鼠标、键盘、显示器等读写。
(4)文件系统:主要是对数据存储管理,包括读写操作。
(5)UI界面: 包括shell命令行和图形化操作界面。
(6)信息安全: 授权访问管理,如用户、角色权限访问管理。
2. 操作系统启动过程
操作系统的启动过程是一个复杂且关键的步骤,它涉及到硬件、固件(BIOS/UEFI)、引导程序(Bootloader)以及操作系统本身的多个阶段。以下是一个简化的概述,描述了从计算机开机到操作系统完全加载的典型过程:
(1)上电与自检(Power-On Self-Test, POST):当计算机接通电源后,首先进行的是上电自检(POST)。这是由计算机的固件(BIOS或UEFI)执行的一系列检查,用于确保硬件设备(如CPU、内存、硬盘、外设等)正常工作。如果POST检测到任何问题,计算机可能会发出声音或显示错误信息。
(2)固件初始化:固件(BIOS或UEFI)初始化后,会寻找可启动的设备。这通常是通过在硬盘、SSD、光盘驱动器、USB设备等中查找引导扇区来完成的。固件会按照预设的启动顺序检查这些设备。
(3)引导加载程序(Bootloader):一旦固件找到了可启动的设备,它会读取该设备的第一个扇区(引导扇区),这个扇区通常包含一个小型的引导加载程序。这个程序的任务是加载更复杂的引导管理程序或直接加载操作系统。
(4) 加载操作系统内核:引导加载程序接下来会加载操作系统的内核到内存中。这通常涉及到从硬盘或其他存储介质读取内核文件,并将其复制到内存中适当的位置。
(5)内核初始化:内核被加载到内存后,控制权被交给内核。内核初始化各种驱动程序,设置中断处理程序,初始化系统服务和进程调度器等。这一步骤是操作系统启动过程中最关键的部分。
(6)系统服务和守护进程:内核初始化完成后,它会启动系统服务和守护进程,如网络服务、日志系统等。
(7)用户界面:操作系统会加载用户界面。对于图形界面(如Windows、macOS或Linux的桌面环境),这涉及到启动显示管理器(如X Window System)和登录界面。对于命令行界面,用户将直接看到一个命令提示符或终端。
(8) 登录过程:用户在登录界面输入凭据后,操作系统会验证用户身份,并根据用户配置加载个人设置和启动应用程序。
3. 局部性原理
局部性通常有两种形式:时间局部性(temporal locality):时间局部性指的是:被引用过一次的存储器位置在未来会被多次引用(通常在循环中)。空间局部性(spatial locality)如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。
4. 分层存储器
存储器分为三类:CPU寄存器、SRAM、DRAM和磁盘存储,其中:
(1)CPU寄存器:是CPU内部组成单元,由NOT,AND, OR,NOR等门电路组成的一个一个具备读写功能的触发器,直接参与逻辑运算、算术运算,速度最快,1个时钟周期,也最昂贵。
(2)一/二级高速缓存: SRAM,由行和列组成的存储阵列(由存储单元阵列、地址译码器、读写控制逻辑、输入/输出缓冲器和电源组成),SRAM存储单元使用锁存器保持数据,不需要像DRAM那样定期刷新。访问速度在2~4个时钟周期,价格上比寄存器便宜。
(3) 动态存储(DRAM): 与SRAM相似采用由行和列组成的存储阵列组成,二者区别主要在于存储单元实现不同,DRAM采用的是由一个晶体管和一个电容器组成,晶体管作为开关控制电容器的充电状态,电容器存储的是电荷,代表数据的0和1状态,所以必须不断周期刷新才能保存数据;SRAM采用的是由六个晶体管组成,形成一个双稳态的翻转锁存器。这个翻转锁存器可以稳定地存储一位数据(0或1),直到被明确地改变。DRAM结构简单,价格低,但由于每次访问前均需要刷新,因此速度较SRAM慢。
(4)磁盘: 与上述不同,磁盘属于块设备(每块大小512字节),顺序访问按照扇区读写,不能随机访问。
扇区组成:
主引导记录(MBR,Main Boot Record)是位于磁盘最前边的一段引导(Loader)代码。MBR扇区位于物理硬盘的0柱面,0磁头,1扇区,也就是整个硬盘的第一个扇区(偏移量为0),共占512个字节(即一个扇区),每个物理硬盘只有一个MBR扇区。
MBR扇区由三部分构成:第一部分是446字节的引导代码,也就是上面提到的MBR;第二部分是DPT(Disk Partition Table,硬盘分区表),包含4个表项,每个表项16字节,共占用64字节;第三部分是2个字节的结束标志,0x55AA。
(5)FLASH盘
Flash存储器,也称为闪存,是一种广泛使用的非易失性存储技术,能够在断电情况下保存数据,进一步分为两类:一是NAND flash,以块的方式可以代替硬盘,即当前的SSD盘(寿命在10年);而是NOR flash,可以像DRAM那样随机访问。
(6)ROM
ROM主要由地址译码器、存储体、读出线及读出放大器等部分组成。整体一次性写入,后续只能读。
三、研发环境
在msys2环境中安装gcc、nasm、make、qemu。
1. msys2默认的软件安装包工具是pacman,pacman的使用方法如下:
pacman -Syy 让本地的包数据库和远程的软件仓库同步
pacman -Syu 也可以使用一句命令同时进行同步软件库并更新系统到最新状态
安装或者升级单个软件包,或者一列软件包(包含依赖包),使用如下命令:
pacman -S package_name1 package_name2
卸载软件包
删除单个软件包,保留其全部已经安装的依赖关系
pacman -R package_name
删除指定软件包,及其所有没有被其他已安装软件包使用的依赖关系:
pacman -Rs package_name
指定根目录
pacman -s XXX -r /mnt
2. 安装nasm
运行命令 pacman -S nasm
$ pacman -S nasm
resolving dependencies...
looking for conflicting packages...
Packages (1) nasm-2.16.01-1
Total Download Size: 0.32 MiB
Total Installed Size: 2.57 MiB
:: Proceed with installation? [Y/n] y
3. 安装qemu,并设置环境变量
运行命令 pacman -S mingw-w64-x86_64-qemu
使用nano文本编辑器, 修改~/.bashrc, 添加:
export PATH="$PATH:/mingw64/bin"
lazybird@lazybird-home UCRT64 ~
$ nano ~/.bashrc #在最后添加export PATH="$PATH:/mingw64/bin"
lazybird@lazybird-home UCRT64 ~
$ source ~/.bashrc
启动qemu试验:qemu-system-i386
3. 安装micro 代替nano,因为micro支持分屏
(1)启动micro: micro 1.txt;
(2)Ctrl+E: 切换到micro的命令(注:Ctrl+B是切换到bash命令);
(3)在micro命令下使用:vsplit或hsplit切分窗口;
(4)Ctrl+W:在不同窗口之间切换。
四、实践代码
为了简单起见,直接用汇编语言来实现,编译器选用nasm,批处理编译命令采用make工具。
1. 所需要的程序设计背景知识:
(1)
扇区的大小在历史上通常是512字节,但现代硬盘可能使用更大尺寸的扇区,如4KB(4096字节)。存储设备的操作通常以扇区为单位进行。当计算机需要读取或写入数据时,它会指定一个扇区号,然后设备会定位到该扇区并执行读取或写入操作。在硬盘或其他可启动的存储设备上,第一个扇区(通常是第一个扇区)通常被用作引导扇区,它包含了启动计算机所需的代码和数据。
(2)引导扇区(EBR)会以0x55AA作为结尾标志。这个标志告诉BIOS或其他启动固件,引导扇区已经结束,且引导代码是有效的。
(3)x86内存结构
16位地址线可访问的内存为2^16=64K字节,而早期的内存可以做到1M字节(2^20),如何利用这16位地址来表示1M的内存空间呢,提出了段地址访问方式,也就是说用两个数据来表示一个地址:
物理地址=段基址:段内偏移
将整个1M内存分为16个段(Segment),每个段的大小最大为2^16=64k, 找一个物理地址时,先找到所在的段,然后左移4位,然后再加上段内的偏移地址:
物理地址 = (段基址 << 4) + 偏移量。
段寄存器 (例如 CS, DS, ES)
+------------------+
| 选择子 (段号) | <-- 这通常会指向GDT中的一个段描述符
+------------------+
|
| <-- 段描述符中包含段的基址和限长等信息
V
+------------------+ +-----------------+
| 段描述符 (在GDT中) | | 段基址 |
+------------------+ +-----------------+
| 基址: 0x0000 | | 限长: 0xFFFF |
| 访问权限: 读/写 | | ... |
+------------------+ +-----------------+
|
| <-- 实际的物理地址是通过组合段基址和偏移量来计算的
V
+------------------+
| 段内偏移量 (Offset) | <-- 这是相对于段基址的偏移
+------------------+
|
| <-- 物理地址 = (段基址 << 4) + 偏移量
V
+------------------+
| 物理地址 = 0x0000 + Offset | <-- 这是最终访问的物理地址
+------------------+
x86使用了CS (代码段)、DS(数据段)、SS(堆栈段)、ES,FS,GS(Extra Segment)四种类型来存储段基址。
内存地址访问: segment: [ base + index * scale + displacement]
对于16位x86: segment可以是CS,DS,SS,DS,ES,FS,GS
base: BP/BX
index: SI/DI
scale:1
displacement: 常量数值
示例:
(4) 中断0x10调用打印功能
x86使用SI/DI寄存器存储索引:
2. 编写源码
(1)首先设定BIOS加载引导加载程序的地址:org 0x7C00;
(2)在0x7C00地址设置第一条指令为跳转指令,跳转到main函数入口:jmp main
(3)在main函数中
首先设置数据段和堆栈,然后调用puts
子程序打印出"Hello world!";
(4)puts:
:子程序,用于打印字符串到屏幕上。它通过BIOS中断0x10实现打 印功能;
(5)hlt
:这条指令让CPU进入等待状态,直到下一个外部中断。
org 0x7C00 ;这条指令设置了程序的起始地址为0x7C00。这是传统的引导扇区的最大偏移量,也是BIOS加载引导加载程序的地址。
bits 16 ; 这条指令指定了汇编器应该使用16位的汇编语法。
%define ENDL 0x0D, 0x0A ;这条指令定义了一个宏,ENDL,它代表回车换行符,用于在字符串输出后添加换行。
start: ;这是程序的入口点。
jmp main
;
; Prints a string to the screen
; Params:
; - ds:si points to string
;
puts:
; save registers we will modify
push si
push ax
push bx
.loop:
lodsb ; loads next character in al
or al, al ; verify if next character is null?
jz .done
mov ah, 0x0E ; call bios interrupt
mov bh, 0 ; set page number to 0
int 0x10
jmp .loop
.done:
pop bx
pop ax
pop si
ret
main:
; setup data segments
mov ax, 0 ; can't set ds/es directly
mov ds, ax
mov es, ax
; setup stack
mov ss, ax
mov sp, 0x7C00 ; stack grows downwards from where we are loaded in memory
; print hello world message
mov si, msg_hello
call puts
hlt
.halt:
jmp .halt
msg_hello: db 'Hello world!', ENDL, 0
times 510-($-$$) db 0 ; 计算出从当前位置到引导扇区末尾还需要多少字节的空间,然后用0(空字节)来填充这些空间,确保生成的二进制文件大小正好是512字节,满足引导扇区的大小要求。这是制作启动盘时常见的做法,以确保引导扇区的完整性和正确性。- $:这个符号代表当前地址。当你在编写代码时,每写入一个指令或者定义一个标签,$的值就会更新为当前的程序计数器(PC)值,即下一条将要执行或定义的指令的地址。- $$:这个符号代表当前段的基地址。在段式内存管理中,每个段(如代码段、数据段等)都有一个基地址。$$就是用来引用当前段的基地址的。- `times`:重复指定的次数- `db 0`:`db`是"define byte"的缩写,意味着定义一个字节。`0`是字节的值
dw 0AA55h;这是引导扇区的结束标志,0xAA55是一个特定的值,用于标识引导扇区的有效性。
3. 编译程序
使用makefile以实现自动化:
ASM=nasm
SRC_DIR=src
BUILD_DIR=build
$(BUILD_DIR)/main_floppy.img: $(BUILD_DIR)/main.bin
cp $(BUILD_DIR)/main.bin $(BUILD_DIR)/main_floppy.img
truncate -s 1440k $(BUILD_DIR)/main_floppy.img
$(BUILD_DIR)/main.bin: $(SRC_DIR)/main.asm
mkdir -p $(BUILD_DIR)
$(ASM) $(SRC_DIR)/main.asm -f bin -o $(BUILD_DIR)/main.bin
目录结构如下:
└─buidos
└─part1
│ Makefile
│
├─build
│ main.bin
│ main_floppy.img
│
└─src
main.asm
五、运行调试
bash:
$ qemu-system-i386.exe main_floppy.img