Lab1 - 引导程序
实验目的
- 了解操作系统的启动过程(引导程序加载、硬件初始化、操作系统内核启动).
- 学习中断处理机制,掌握时钟中断的设置和处理.
- 掌握基本的定时器操作,学会如何使用定时器进行时间控制.
- 掌握从磁盘中加载用户程序,理解内存模型
实验要求
- 在实模式下按一定时间间隔打印”Hello World”
设置8253/4定时器芯片,自定义时间间隔(建议<1000ms)触发一次时钟中断。 通过设置时钟中断处理程序,实模式下定期在终端间隔
1s
打印一行”Hello, World!”
- 在保护模式下实现一个”Hello World”程序
从实模式切换至保护模式,并在保护模式下在终端中打印”Hello, World!”(单次)
- 在保护模式下加载磁盘中的”Hello World”程序并运行
从实模式切换至保护模式,在保护模式下,加载磁盘 1 号扇区中的 “Hello World” 程序到内存指定位置,执行该程序,并在终端输出 “Hello, World!”
相关资料
CPU在加电之后,它的第一条指令在哪?
关键词:CPU、内存、BIOS、磁盘、主引导扇区、加载程序、操作系统
我们知道,CPU在电源稳定后会将内部的寄存器初始化成某个状态,然后执行第一条指令.第一条指令在哪?答案是内存
内存是用来存数据的,但是有过了解的同学都知道,断电后内存中的内容会丢失.那上哪找第一条指令?
其实内存除了我们说的内存条这种RAM,还有ROM;在i386机器刚启动时,内存的地址划分如下
- 基本内存:占据
0~640KB
地址空间- 上位内存:占据
640KB~1024KB
地址空间.分配给显示缓冲存储器、各适配卡上的ROM和系统ROM BIOS.(这个区域的地址分配给ROM,相应的384KB的RAM被屏蔽掉)- 扩展内存 占据1MB以上地址空间
不管是i386还是i386之前的芯片,在加电后的第一条指令都是跳转到BIOS固件进行开机自检,然后将磁盘的主引导扇区(Master Boot Record, MBR ;0号柱面,0号磁头,0号扇区对应的扇区,512字节,末尾两字节为魔数0x55
和0xaa
)加载到0x7c00
.
查看磁盘的MBR
在 Linux 中,如果你的磁盘使用的是 MBR 分区格式(现代操作系统中已不常见),可以通过以下方法轻松查看磁盘的 MBR 信息(需要 root 权限):
head -c 512 /dev/sda | hd
你可以在输出结果的末尾看到魔数0x55
和0xaa
.
有了这个魔数, BIOS就可以很容易找到可启动设备了: BIOS依次将设备的首扇区加载到内存0x7c00
的位置, 然后检查末尾两个字节是否为0x55
和0xaa
. 0x7c00
这个内存位置是BIOS约定的, 如果你希望知道.
为什么采用
0x7c00
, 而不是其他位置?这里可以给你提供一些线索. 如果成功找到了魔数, BIOS将会跳到
0x7c00
的内存位置, 执行刚刚加载的启动代码, 这时BIOS已经完成了它的使命, 剩下的启动任务就交给MBR; 如果没有检查到魔数, BIOS将会尝试下一个设备; 如果所有的设备都不是可启动的, BIOS将会发出它的抱怨: “找不到启动设备”.
BIOS加载主引导扇区后会跳转到CS:IP=0x0000:0x7c00
执行加载程序,这就是我们操作系统实验开始的地方。在我们目前的实验过程中,主引导扇区和加载程序(bootloader)其实代表一个东西。但是现代操作系统中,他们往往不一样,请思考一下为什么?
主引导扇区中的加载程序的功能主要是
- 将操作系统的代码和数据从磁盘加载到内存中
- 跳转到操作系统的起始地址
其实真正的计算机的启动过程要复杂很多,有兴趣请自行了解.
搞明白本小节关键字中的各种名词的含义和他们间的关系了吗?
请你在实验报告中阐述相关内容
8254定时器芯片
8254定时器芯片(Intel 8254 Programmable Interval Timer, PIT)是一款用于生成时钟信号和定时中断的可编程定时器芯片,广泛应用于x86架构的计算机中。它能够生成周期性的时钟信号、延迟事件,并且通常用于操作系统中的时间管理、定时器中断以及其他时间相关任务。
中断号: 在 x86 架构中,中断号 是一个 8 位的值,表示中断向量表(IVT Interrupt Vector Table)中对应的入口地址。中断号 1C
对应的是定时器中断服务程序。
中断向量表: 中断向量表是一个包含中断处理程序入口地址的结构。每个中断号都对应一个特定的中断向量,在 8086 等早期 x86 处理器中,中断向量表从物理内存地址 0x0000
开始,通常会根据中断号每次占用 4 个字节的空间,其中包括:
- 前 2 个字节:中断处理程序的 偏移地址。
- 后 2 个字节:中断处理程序的 段基址。
在 8086 处理器上:
IRQ0(硬件定时器中断) 由 8254 计时器触发,默认情况下,其I/O 端口范围为 0x20-0x23
,对应的中断号为 0x08
。
软件定时器中断 提供了用户级的定时器访问功能,使用的是中断号 0x1C
,其对应的 IVT 地址范围为 0x70-0x73
:
0x70
(0x1C << 2
) 存储 中断服务程序的偏移地址。0x72
(0x1C << 2 + 0x2
) 存储 中断服务程序的段基址。
因此,如果需要重定向定时器中断,应该修改这两个地址,使其指向用户自定义的中断处理程序。
I/O ADDR | INT TYPE | FUNCTION |
---|---|---|
6C ~ 6F | 1B | Ctrl - Break 控制的软中断 |
70 ~ 73 | 1C | 定时器控制的软中断 |
.code16
.global start
start:
...
# 将 clock_handle 的偏移地址存储到 0x70(偏移量)
movw $clock_handle, 0x70
movw $0, 0x72
...
# 处理中断的代码
clock_handle:
# 在此编写定时器中断的处理逻辑
...
设置8254定时器芯片
参考实现,让定时器每隔20ms
就产生一个中断
# 发送命令字节到控制寄存器端口0x43
movw $0x36, %ax #方式3 , 用于定时产生中断00110110b
movw $0x43, %dx
out %al, %dx
# 计算计数值, 产生20 毫秒的时钟中断, 时钟频率为1193180 赫兹
# 计数值 = ( 时钟频率/ 每秒中断次数) − 1
# = (1193180 / (1 / 0.02 ) ) − 1= 23863
movw $23863, %ax
# 将计数值分为低字节和高字节, 发送到计数器0的数据端口( 端口0x40 )
movw $0x40, %dx
out %al, %dx
mov %ah, %al
out %al, %dx
sti
IA-32的存储管理
在IA-32下,CPU有两种工作模式:源于8086的实模式和源于80386的保护模式
实模式简介
8086为16位CPU,有16位的寄存器(Register),16位的数据总线(Data Bus),20位的地址总线(Address Bus),寻址能力为1MB
8086 的寄存器集合
- 通用寄存器(16 位): AX,BX,CX,DX,SP,BP,DI,SI
- 段寄存器(16 位): CS,DS,SS,ES
- 状态和控制寄存器(16 位): FLAGS,IP
寻址空间与寻址方式
- 采用实地址空间进行访存,寻址空间为
2^20
- 物理地址 = 段寄存器 « 4 + 偏移地址
CS=0x0000:IP=0x7C00
和 CS=0x0700:IP=0x0C00
以及 CS=0x7C0:IP=0x0000
所寻地址是完全一致的
8086的中断
- 中断向量表存放在物理内存的开始位置(
0x0000
至0x03ff
) - 最多可以有256个中断向量
0x00
至0x07
号中断为系统专用0x08
至0x0F
,0x70
至0x77
号硬件中断为8259A
使用
实模式或者说8086本身有一些缺点
- 安全性问题
- 程序采用物理地址来实现访存,无法实现对程序的代码和数据的保护
- 一个程序可以通过改变段寄存器和偏移寄存器访问并修改不属于自己的代码和数据
- 分段机制本身的问题
- 段必须是连续的,从而无法利用零碎的空间
- 段的大小有限制(最大为 64KB),从而限制了代码的规模
保护模式
80386开始,Intel处理器步入32位CPU;80386有32位地址线,其寻址空间为2^32=4GB
;为保证兼容性,实模式得以保留,PC启动时CPU工作在实模式,并由Bootloader
迅速完成从实模式向保护模式的切换
保护模式带来的变化
- 通用寄存器(从 16 位扩展为 32 位): EAX,EBX,ECX,EDX,ESI,EDI,EBP,ESP
- 段寄存器(维持 16 位): CS,DS,SS,ES,FS,GS
- 状态和控制寄存器(32/64 位): EFLAGS,EIP,CR0,CR1, CR2,CR3
- 系统地址寄存器: GDTR,IDTR,TR,LDTR
- 调试与测试用寄存器: DR0,· · · ,DR7,TR0,· · · ,TR7
寄存器类型 | 8086 寄存器 | 80386 寄存器 |
---|---|---|
通用寄存器 | AX, BX, CX, DX SP, BP, DI, SI |
EAX, EBX, ECX, EDX ESI, EDI, EBP, ESP |
段寄存器 | CS, DS, SS, ES | CS, DS, SS, ES, FS, GS |
段描述符寄存器 | 无 | 对程序员不可见 |
状态和控制寄存器 | FLAGS, IP | EFLAGS, EIP CR0, CR1, CR2, CR3 |
系统地址寄存器 | 无 | GDTR, IDTR, TR, LDTR |
调试寄存器 | 无 | DR0, …, DR7 |
测试寄存器 | 无 | TR0, …, TR7 |
寻址方式的变化
- 在保护模式下,分段机制是利用一个称作段选择子(Selector)的偏移量到全局描述符表中找到需要的段描述符,而这个段描述符中就存放着真正的段的物理首地址,该物理首地址加上偏离量即可得到最后的物理地址
- 一般保护模式的寻址可用
0xMMMM:0xNNNNNNNN
表示,其中0xMMMM
表示段选择子的取值,16 位(其中高 13 位表示其对应的段描述符在全局描述符表中的索引,低 3 位表示权限等信息),0xNNNNNNNN
表示偏移量的取值,32 位 - 段选择子为 CS,DS,SS,ES,FS,GS 这些段寄存器
GDT(全局描述符表,Global Descriptor Table)是保护模式下内存管理的核心数据结构,由多个段描述符组成。每个段描述符定义了内存段的以下属性:
- 32 位基地址:段的起始物理地址。
- 段界限:段的大小。
- 属性:如访问权限、粒度、段类型等
段描述符的结构如下图所示
-
每个段描述符的大小为 8 字节(64 位)。
-
段基址:位于描述符的第 2、3、4 和 7 字节,共 32 位。
- 段限长:由描述符的第 0、1 字节和第 6 字节的低 4 位组成,共 20 位,用于表示段的最大长度。
- 当
G
位为 0 时,20 位段限长表示实际段的最大长度(最大为 1MB)。 - 当
G
位为 1 时,段限长将左移 12 位并加上0xFFF
,得到实际段的最大长度(最大为 4GB)。
- 当
D/B
位:该位的意义根据段的类型不同而变化:- 在可执行代码段中,称为
D
位:D=1
时使用 32 位地址和 32/8 位操作数;D=0
时使用 16 位地址和 16/8 位操作数。 - 在向下扩展的数据段中,称为
B
位:B=1
时段的上界为 4GB,B=0 时段的上界为 64KB。
- 在可执行代码段中,称为
-
在堆栈段描述符中,
B
位表示操作数大小:B=1
时使用 32 位操作数(堆栈指针为 ESP);B=0 时使用 16 位操作数(堆栈指针为 SP)。 -
AVL
:可用和保留位,通常设为 0。 -
P
位:存在位,当P=1
时表示该段在内存中。 -
DPL
:描述符特权级,取值为 0 到 3,表示该段在访问时 CPU 所处的最低特权级(0 为最高特权级,3 为最低特权级)。后续实验将详细讨论。 S
位:描述符类型标志:S=1
表示该段为代码段或数据段。S=0
表示该段为系统段(如TSS
、LDT
)或门描述符。
TYPE
:当S
为 1,TYPE
表示的代码段,数据段的各种属性 如下表所示
段描述符的示例
假设一个段描述符的基址为
0x00100000
,限长为0xFFFFF
,G = 1
,则:
- 实际段限长 =
0xFFFFF * 4KB = 4GB
- 该段的范围为
0x00100000
到0x00100000 + 4GB
为进入保护模式,需要在内存中开辟一块空间存放GDT
;80386提供了一个寄存器GDTR
用来存放GDT的32位物理基地址以及表长界限;在将GDT
设定在内存的某个位置后,可以通过LDGT
指令将GDT
的入口地址装入此寄存器
GDT REGISTER
15 0
+---------------+
| GDT LIMIT |
+----------------+---------------|
| GDT BASE |
+--------------------------------+
31 0
在保护模式下,访问 GDT
是通过 段选择子 完成的。段选择子是一个 16 位的值,存储了段描述符在 GDT
中的索引和权限信息。
为了访问一个段,需要将段选择子加载到相应的段寄存器中:
- 数据段:选择子存储到
DS
寄存器。 - 代码段:选择子存储到
CS
寄存器
15 3 1 0
+-------------------------+-+---+
| |T| |
| INDEX | |RPL|
| |I| |
+-------------------------+-+---+
TI - TABLE INDICATOR, 0 = GDT, 1 = LDT
RPL - REQUESTOR'S PRIVILEGE LEVEL
TI
位表示该段选择子为全局段还是局部段,PRL
表示该段选择子的特权等级,13位Index
表示描述符表中的编号
Selector:Offset
表示的逻辑地址可如下图所示转化为线性地址,倘若不采用分页机制,则该线性地址即物理地址
15 0 31 0
LOGICAL +----------------+ +-------------------------------------+
ADDRESS | SELECTOR | | OFFSET |
+---+---------+--+ +-------------------+-----------------+
+------+ ! |
| DESCRIPTOR TABLE |
| +------------+ |
| | | |
| | | |
| | | |
| | | |
| |------------| |
| | SEGMENT | BASE +---+ |
+->| DESCRIPTOR |-------------->| + |<----+
|------------| ADDRESS +-+-+
| | |
+------------+ |
!
LINEAR +------------+-----------+--------------+
ADDRESS | DIR | PAGE | OFFSET |
+------------+-----------+--------------+
- 相关资料:关于GDT的理解
显存映射
在实模式下,我们可以通过 BIOS 中断在屏幕上显示文本。然而,一旦切换到 32 位保护模式,BIOS 中断将不再可用,这就带来了一个问题:如何在屏幕上打印信息?
切换到 32 位模式后,首个挑战就是如何在屏幕上显示文本。此前,我们通过请求 BIOS 将 ASCII 字符显示在屏幕上。那么,BIOS是如何在正确的位置显示字符的呢?
显示设备通常支持两种模式:文本模式和图像模式。屏幕上显示的内容实际上是内存中特定区域内容的可视化表示。因此,要操作屏幕显示,我们需要管理当前模式下的内存区域。显示设备就是这样一个将内存映射到屏幕的硬件。
大多数计算机在启动时,尽管可能有更先进的图像硬件,通常会先进入一个简单的 VGA 文本模式(80x25
字符的显示)。在文本模式下,开发人员无需手动渲染每个字符的像素点,因为 VGA 显示设备内部已定义了一个简单的字符集。在这种模式下,屏幕上每个字符单元都由两字节表示:第一个字节是字符的 ASCII 编码,第二个字节包含字符的属性,例如前景色、背景色以及是否闪烁等。
因此,如果我们希望在屏幕上显示一个字符,就需要将相应的 ASCII 值写入 VGA 显示内存中的正确位置。通常,VGA 显示内存的起始地址是 0xb8000
。
解决思路
在实模式下按一定时间间隔打印”Hello World”
通过陷入屏幕中断调用BIOS打印字符串Hello, World
屏幕中断的相关内容可以查阅BIOS中断表.xlsx
定时器的相关内容,搜索8253/8254芯片的相关资料
Tip
在框架代码中,你可以自己设置栈指针SP。
思考:栈指针(SP)的设置会如何影响程序的运行?
实模式切换保护模式
关闭中断,打开A20
数据总线,加载GDTR
,设置CR0
的PE
位(第0位)为1b
,通过长跳转设置CS
进入保护模式,初始化DS
, ES
, FS
, GS
, SS
.
这里设置了三个GDT
表项,其中代码段与数据段的基地址都为0x0
,视频段的基地址为0xb8000
.
.code16
start:
#关闭中断
inb $0x92, %al #启动A20总线
orb $0x02, %al
outb %al, $0x92
data32 addr32 lgdt gdtDesc #加载GDTR
#设置CR0的PE位(第0位)为1
data32 ljmp $0x08, $start32 #长跳转切换至保护模式
.code32
start32:
... #初始化DS ES FS GS SS 初始化栈顶指针ESP
jmp bootMain #跳转至bootMain函数 定义于boot.c
gdt:
.word 0,0 #GDT第一个表项必须为空
.byte 0,0,0,0
.word #代码段描述符
.byte
.word #数据段描述符
.byte
.word #视频段描述符
.byte
...
gdtDesc:
.word (gdtDesc - gdt -1)
.long gdt
CR0寄存器
CR0寄存器的结构如下图所示:
CR0 寄存器中的 PE
位(Protection Enable)表示是否启用了保护模式。当 PE
位被置为 1
时,表示已启用保护模式,而在开机加电时,PE
位默认是 0
。
同样,若要启用分页机制,需要将 PG
位(分页标志位)设置为 1
。本次讨论中不涉及分页机制,感兴趣的同学可以在课后进一步深入研究 CR0 寄存器。
加载磁盘中的程序并运行
由于中断关闭,无法通过陷入磁盘中断调用BIOS进行磁盘读取,本次实验提供的代码框架中实现了readSec(void *dst, int offset)
这一接口(定义于bootloader/boot.c
中),其通过读写(in
,out
指令)磁盘的相应端口(Port)来实现磁盘特定扇区的读取
通过上述接口读取磁盘MBR后,将扇区中的程序加载到内存的特定位置,并跳转执行(注意代码框架app/Makefile
中设置的该Hello World程序入口地址)
代码框架
本次实验提供一个示范代码框架
lab1-STUID # 待修改
├── lab1
│ ├── Makefile
│ ├── app
│ │ ├── Makefile
│ │ └── app.s # 用户程序
│ ├── bootloader
│ │ ├── Makefile
│ │ ├── boot.c # 加载磁盘上的用户程序
│ │ ├── boot.h # 磁盘I/O接口
│ │ └── start_1.s # lab1.1的引导程序
│ │ └── start_2.s # lab1.2的引导程序
│ │ └── start_3.s # lab1.3的引导程序
│ └── utils
│ └── genBoot.pl # 生成MBR
└── report
└── 231220000.pdf # 待替换
作业提交
start_1.s
中完成实模式下间隔一段时间打印字符串的功能start_2.s
中完成实模式到保护模式的切换start_3.s
中完成保护模式加载用户程序,并打印Hello World
Tip
请仔细阅读lab1的Makefile,不同的指令将会编译出不同的镜像
需要你在实验报告中简单阐述这三个功能的实现,以及你在实验过程中的一些思考
- 请大家在提交的实验报告中注明你的邮箱, 方便我们及时给你一些反馈信息.
- 其他问题参看
Introduction
中的作业规范与提交一章 - 本实验最终解释权归助教所有