OS_lab3

OS_Lab3:从实模式到保护模式

要求

  • DDL:2025.4.6

  • 提交内容:2+1个任务的代码和实验报告 传课程邮箱os_sysu_lab@163.com+实验报告pdf–提交到腾讯微云

  • 材料的Example的代码放置在src目录下

概述

学习如何从16位的实模式跳转到32位的保护模式,然后在平坦模式下运行32位系统。同时学习如何使用I/O端口和硬件交互,为后面保护模式编程打下基础

基础学习

突破512字节的限制

​ 我们在之前学到了计算机在启动的最后只会自动加载MBR(512字节)到内存中运行,然而这个空间是很小的,所以在实际应用中,MBR只负责定义了一些基本信息,如磁盘大小、扇区大小等。

​ 在运行操作系统前,需要将操作系统内核程序从外存加载到内存中,但这个空间大小无疑是较大的,因而在系统内核加载前,我们的MBR不再是输出“hello world”,而是将一段程序从外存加载到内存(大小无512Bytes限制)。这段程序(称为bootloader)在内存足够的前提下可以尽量大一点–可以实现:从实模式跳转到保护模式、加载操作系统内核等。

​ 无论是MBR还是bootloader,其最开始都是存放在磁盘(外存)上的。而MBR和bootloader只有被加载到内存中才可以被执行,除了MBR外,计算机是不会去外存找程序执行的。MBR是在计算机启动时被计算机自动加载到0x7C00处执行。此后,计算机的任何行为都由我们的程序来控制。也就是说,我们需要自己从外存中加载程序到内存中运行。–学习如何读写硬盘!

LBA方式读写硬盘

读写I/O端口

  • 硬盘属于外设,CPU和外设的交换通过I/O端口进行。
  • I/O端口是一些寄存器,位于I/O接口电路中。当需要进行数据交换时,我们先将命令和数据放入到指定的I/O端口中,等待外设处理完后再从指定的端口取出处理结果。指定的端口也可以获取外设的状态。

(指定–数据交换的I/O端口已经被预先规定好了)

  • 和寄存器编址很相似,每一个端口在I/O电路中都会被统一编址

    • 主硬盘分配的端口地址是0x1f00x1f7,从硬盘分配的端口地址是0x1700x177

    • 由于端口是独立编址的,因此我们无法使用mov指令来对端口赋值,可以使用in(读),out(写)指令。

    • 值得注意的是:in指令的源操作数只能是立即数或dx,目的操作数只能是ax和al;out指令的源操作数只能是al或ax,目的操作数只能是立即数或dx

    • 例子:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      ; in指令
      in al, 0x21 ; 表示从0x21端口读取一字节数据到al
      in ax, 0x21 ; 表示从端口地址0x21读取1字节数据到al,从端口地址0x22读取1字节到ah

      mov dx,0x379
      in al, dx ; 从端口0x379读取1字节到al

      ; out指令
      out 0x21, al ; 将al的值写入0x21端口
      out 0x21, ax ; 将ax的值写入端口地址0x21开始的连续两个字节
      mov dx, 0x378
      out dx, ax ; 将ah和al分别写入端口0x379和0x378

读写硬盘

  • 硬盘的读写是以块为单位的,块在硬盘中也被称为扇区,一般的扇区大小是512字节。
  • LBA的全称是Logical Block Addressing, 逻辑块寻址模式
  • 硬盘的物理结构包括磁头、扇区和柱面(CHS),通过CHS就可以定位磁盘上的数据–在访问硬盘时手动指定磁头、磁道和柱面。

硬盘内部示意图

  • 另一种方法:LBA模式。此模式下磁盘的地址空间被划分一个个的逻辑块,访问时只需要指定对应磁盘地址对应的逻辑块即可。

在实模式下,还有利用BIOS中断来读取硬盘的方式,称为CHS模式。需要手动将逻辑扇区号转化为磁盘对应的磁头、磁道和柱面,比较麻烦。这里不使用BIOS中断的原因是因为BIOS中断是16位程序,在保护模式下无法使用。有兴趣的同学可以自行探索“通过BIOS中断读取硬盘

使用LBA读取硬盘的方式:

  • 设置起始的逻辑扇区号。由于扇区的读写是连续的,因此只要给出第一个扇区的编号就好了。此处使用的是LBA28(28表示使用28位来表示逻辑扇区的编号)的方式来读取硬盘–但IO端口一次只能读取8位,所以要分四段写入端口。

    • 逻辑扇区的07位被写入0x1F3端口,815位被写入0x1F4端口,16~23位被写入0x1F5端口,最后4位被写入0x1F6端口的低4位。
    端口地址 数据位范围 描述
    0x1F3 0~7 逻辑扇区号的低 8 位
    0x1F4 8~15 逻辑扇区号的中间 8 位
    0x1F5 16~23 逻辑扇区号的高 8 位
    0x1F6 24~27 逻辑扇区号的最高 4 位
    • 0x1F6的8个位表示如下

0x1f6端口

  • 将要读取的扇区数量写入0x1F2端口。8位端口,每次最多只能读写255个扇区

  • 向0x1F7端口写入0x20,请求硬盘读。

  • 等待其他读写操作完成。

0x1f7

  • 若在第四步中检测到其他操作已经完成,那么我们就可以正式从硬盘中读取数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
asm_read_hard_disk:                           
; 从硬盘读取一个逻辑扇区

; 参数列表
; ax=逻辑扇区号0~15位
; cx=逻辑扇区号16~28位
; ds:bx=读取出的数据放入地址

; 返回值
; bx=bx+512

mov dx, 0x1f3
out dx, al ; LBA地址7~0

inc dx ; 0x1f4
mov al, ah
out dx, al ; LBA地址15~8

mov ax, cx

inc dx ; 0x1f5
out dx, al ; LBA地址23~16

inc dx ; 0x1f6
mov al, ah
and al, 0x0f
or al, 0xe0 ; LBA地址27~24
out dx, al

mov dx, 0x1f2
mov al, 1
out dx, al ; 读取1个扇区

mov dx, 0x1f7 ; 0x1f7
mov al, 0x20 ;读命令
out dx,al

; 等待处理其他操作
.waits:
in al, dx ; dx = 0x1f7
and al,0x88
cmp al,0x08
jnz .waits


; 读取512字节到地址ds:bx
mov cx, 256 ; 每次读取一个字,2个字节,因此读取256次即可
mov dx, 0x1f0
.readw:
in ax, dx
mov [bx], ax
add bx, 2
loop .readw

ret

保护模式

概述

保护模式,是一种在80286系列之后,基于x86架构的CPU操作模式。在80286及以后,保护模式的引入使得内存地址改为32位,程序至少可以访问到2^32=4G的内存空间

保护模式与实模式相比,主要有两个差别。

  • 保护模式提供了段间的保护机制,防止程序间胡乱访问地址带来的问题。
  • 保护模式访问的内存空间变大,32位地址线最大支持4G内存空间。

从实模式到保护模式

在保护模式下,所有的程序都会运行在自己的段中,一旦程序错误地访问其他段的地址空间,那么CPU就会产生异常来阻止程序访问。可以简单地理解为保护模式保护的是段地址空间,阻止程序越界访问。

CPU需要知道当前运行中程序的段地址空间信息,然后才能执行地址保护。段地址空间信息是通过段描述符(segment descriptor)来给出的,包含了段基地址(段的起始地址)、段界限(段的长度)等,共计64字节

段描述符

  • 段基地址。段基地址共32位,是段的起始地址,被拆分成三部分放置。
  • G位。G表示粒度, G=0表示段界限以字节为单位, G=1表示段界限以4KB为单位。
  • D/B位。D/B位是默认操作数的大小或默认堆栈指针的大小,在保护模式下,该位置为1,表示32位。
  • L位。L位是 64 位代码段标志,由于这里我们使用的是32位的代码,所以L置0。
  • AVL。AVL位是保留位。
  • 段界限。段界限表示段的偏移地址范围,我们在后面详细讨论这个问题。
  • P位。P位是段存在位, P=1表示段存在, P=0表示段不存在。
  • DPL。DPL指明访问该段必须有的最低优先级,优先级从0-3依次降低,即0拥有最高优先级,3拥有最低优先级。
  • S位。S位是描述符类型。S=0表示该段是系统段,S=1表示该段位代码段或数据段。
  • TYPE。TYPE指示代码段或数据段的类型,如下所示。
第11位(X) 第10位(E) 第9位(W) 第8位(A) 含义
0 0 0 * 只读,向上扩展
0 0 1 * 读写,向上扩展
0 1 0 * 只读,向下扩展
0 1 1 * 读写,向下扩展
1 0 0 * 只执行,非一致代码段
1 0 1 * 执行、可读,非一致代码段
1 1 0 * 只执行,一致代码段
1 1 1 * 执行、可读、一致代码段

A位表示是否被使用过,A=1表示使用,A=0表示未被使用,由CPU负责设置,我们不需要去管

向上扩展和向下扩展指的是段的线性基地址段的线性尾地址的大小关系

保护模式的寻址过程:线性地址=base+offset

保护模式下,我们在指令中给出的都是偏移地址,偏移地址和段线性基地址相加后得到线性地址,线性地址通过地址变换部件MMU后得到实际的物理地址。
$$
物理地址=f(线性地址)
$$
但在此时我们并未开启分页机制,所以是恒等变换x=f(x) –>线性地址

对于一个向上扩展的段,如代码段和数据段,段界限给出的是最大的偏移量,寻址时满足下面的条件。
$$
0\le offset +length\le(段界限+1)*粒度
$$
对于一个向下扩展的段,如栈段,段界限给出的是最小的偏移量,寻址时满足如下条件。
$$
(段界限+1)*粒度\le offset-length\le\text{0xFFFFFFFF}
$$

在保护模式,所有段描述符都会被集中放置,这个集中放置的区域被称为全局描述符表(GDT)。

保护模式的内存地址扩展到32位,所以可以使用32位的寄存器。保护模式下段寄存器仍在使用,但保存的不再是段地址,而是段选择子(GDT的索引),用于指示给CPU寻址时使用的是哪个段。

image-20250320143535634

  • 第15-3位是段描述符的索引,表示选择子指向的段描述符是段描述符表中的第几个,编号从 0 开始。
  • 第2位用来指示描述符表,0表示描述符表是 GDT。
  • 第1-0位是请求特权级,特权级编号为 0-3,权限依次降低,0权限最高

保护模式下的寻址:段地址+偏移地址–>表示为 选择子:偏移地址

每个段寄存器都会有一个64位的不可见的部分,这部分被称为描述符高速缓存器。当我们将选择子送入段寄存器时,CPU会自动从描述符表中加载对应的段描述符到描述符高速缓存器中。此后,当需要使用段寄存器时,CPU会直接从描述符高速缓存器中取出相应的内容,无需重新在描述符表中查找对应的段描述符。

CPU向下兼容,Intel 80286以后的CPU首先进入实模式,然后通过切换机制再进入到保护模式。也就是说在BIOS加电启动后,需要在实模式下的MBR编写16位进入保护模式的代码,然后再跳转到保护模式,执行接下来的32位代码。

steps:

  • 准备GDT,用lgdt指令加载GDTR信息

  • 打开第21根地址线

    • 实模式下,第21根地址线的值恒为0,使得当访问越界超过1MB时,自然溢出使得地址的值仍然小于1MB(取模)

    • 所以要进入保护模式,需要打开第21根地址线–开关位于南桥芯片的端口A20,使用in、out指令可以对主板端口进行读写操作

      1
      2
      3
      in al,0x92 ;南桥芯片内的端口
      or al,0000_0010B
      out 0x92,al ;打开A20
  • 开启cr0的保护模式标志位

    • 真正的开关–CR0(32bit寄存器)包含了一系列用户控制CPU操作模式和运行的标志位

    • 第0位是保护模式的开关位–PE位–PE位置1,CPU进入保护模式

      1
      2
      3
      4
      cli ;保护模式下中断机制尚未建立,应禁止中断
      mov eax,cr0
      or eax,1
      mov cr0,eax ;设置PE位为1
  • 远跳转,进入保护模式

GDT的起始位置和大小由我们来确定,保存在寄存器GDTR中,GDTR:

image-20250320144218557

全集描述符表边界–GDT的边界–段界限边界

$$
段描述符最大数量=\frac{2^{16}}{8}=8192
$$
每个段描述符是64bit(8字节)

Example1: bootloader的加载

内存地址安排:bootloader被安排在MBR之后,预留了5个扇区的空间

name start length end
MBR 0x7c00 0x200(512B) 0x7e00
bootloader 0x7e00 0xa00(512B *** 5**) 0x8800

运行步骤:

  • 编译bootloader后写入到硬盘起始编号为1的扇区,共有五个扇区。

    • 编译:nasm -f bin bootloader.asm -o bootloader.bin
    • 加载:dd if=bootloader.bin of=hd.img bs=512 count=5 seek=1 conv=notrunc
  • mbr也要重新编译并写入硬盘其实编号为0的扇区

    • 编译:nasm -f bin mbr.asm -o mbr.bin
    • 加载:dd if=mbr.bin of=hd.img bs=512 count=1 seek=0 conv=notrunc

    回顾一下对应的参数设置:image-20250320164325744

  • 运行 qemu-system-i386 -hda hd2.img -serial null -parallel stdio

运行结果:

image-20250320163929392

Example2:进入保护模式

在进入保护模式之前,我们先对我们的内存地址进行规划。

Name Start Length End
MBR 0x7c00 0x200(512B) 0x7e00
bootloader 0x7e00 0xa00(512B * 5) 0x8800
GDT 0x8800 0x80(8B * 16) 0x8880
  • MBR自动加载到0x7c00
  • bootloader在MBR中显式加载到0x7e00
  • GDT显示加载到0x8800

equ是汇编伪指令,e.g:编译器会在编译时将 LOADER_SECTOR_COUNT出现的地方替换为5 (类似与宏

在操作系统内核设计的过程中,内存规划是一件令人苦恼的事情。从上面的例子可以看到,bootloader紧跟在MBR后面,GDT紧跟在bootloader后面,看起来非常紧凑。但是,只要其中一个发生变化,那么可能我们又要重新规划内存。也就是说,没有一种内存规划方案是完美的。

在bootloader中跳转到保护模式:

  • 定义段描述符(代码段描述符、数据段描述符、栈段描述符和视频段描述符)

平坦模式:让代码段描述符、数据段描述符和栈段描述符中的段线性基地址为0 =>偏移地址和线性地址完全相同

段的存在是为了让CPU执行段保护,放置程序越界访问;

后续实验中会采用二级分页机制,此时页保护页可以组织程序越界访问 =》无需分段了

视频段描述符是显存所在的内存区域的段描述符。注意,GDT的第0个描述符必须是全0的描述符。接着,在GDT中依次放入0描述符,数据段描述符、堆栈段描述符、显存段描述符和代码段描述符

运行结果:

image-20250320171528591

如何在gdb中使用info registers查看寄存器

  • 要记得在qemu启动时加入 -s -S 参数: qemu-system-i386 -hda hd.img -serial null -parallel stdio -s -S
  • 再启动gdb,并且连接端口 target remote:1234
  • 在输入 info registers可以查看

image-20250320172738614

  • 执行程序之后,发现寄存器发生了很大的变化

image-20250320172319147

ps:

gdb指令 含义 实例
break *adress或b *address 在地址adress处设置断点。 break *0x7c00 b *0x7c00
break symbol或b symbol 在符号symbol处设置断点,例如symbol一般是函数名。 break setup_kernel b setup_kernel
break filename:line_number 在文件filename处的第line_numer行设置断点 b mbr.asm:12
add-symbol-file filename address 加载符号表filename到地址address处 add-symbol-file mbr.symbol 0x7c00
x/FMT address address是内存地址,FMT格式是重复的单元个数+格式+大小。 重复的单元个数是一个数字,表示我们希望查看多少个单元。正数表示从address向后查看。负数表示从address向前查看。 格式是一个字符,可以是o(octal), x(hex), d(decimal), u(unsigned decimal), t(binary), f(float), a(address), i(instruction), c(char), s(string)。 大小是一个字符,可以是b(byte, 1 byte), h(halfword, 2 byte), w(word, 4 byte), g(giant, 8 bytes)。 x/5xw 0x8032 x/10i 0x7c00
continue或c 继续执行正在调试的程序到断点处暂停。
step或s 执行一条C语句,如果遇到函数调用语句,则会进入函数体中。
next或n 执行一条C语句,函数调用语句不会进入函数体,把函数当成一条语句执行。
stepi或si 执行一条汇编语句,如果遇到函数调用语句,则会进入函数体中。
nexti或ni 执行一条汇编语句,函数调用语句不会进入函数体,把函数当成一条语句执行。
info registers 查看所有寄存器的值
layout layout_name layout_name包含src,asm,split,regs。 src显示源代码窗口和命令窗口,asm显示汇编代码窗口和命令窗口,split显示源代码窗口、汇编代码窗口和命令窗口,regs显示寄存器窗口。 layout split
focus layout_window 转换当前窗口到layout窗口,layout_window包含src,asm,regs,cmd。任何时刻gdb的当前窗口只有一个,并且使用方向键的效果只会在当前窗口处显示。 focus cmd
file symbol_file 加载符号表,为gdb提供debug信息。 file ../build/kernel.o
set disassembly-flavor intel 设置汇编代码格式为intel风格
set architecture name 设置指令对应的CPU架构,name包含i8086(16位),i386(32位) set architecture i386

任务 1

1.1

复现Example 1,说说你是怎么做的并提供结果截图,也可以参考Ucore、Xv6等系统源码,实现自己的LBA方式的磁盘访问。

提示:部分需要的文件存放在src/example-1下,请根据需要将其放置于自己创建的lab3文件夹下。

1.2

在Example1中,我们使用了LBA28的方式来读取硬盘。此时,我们只要给出逻辑扇区号即可,但需要手动去读取I/O端口。然而,BIOS提供了实模式下读取硬盘的中断,其不需要关心具体的I/O端口,只需要给出逻辑扇区号对应的磁头(Heads)、扇区(Sectors)和柱面(Cylinder)即可,又被称为CHS模式。现在,同学们需要==将LBA28读取硬盘的方式换成CHS读取==,同时给出逻辑扇区号向CHS的转换公式。最后说说你是怎么做的并提供结果截图。

勘误:《于渊:一个操作系统的实现2》P183-184给的是读取软盘的计算公式,而我们读取的是硬盘,因此不适用。参考资料变更如下。

其中,关键参数如下。

参数 数值
驱动器号(DL寄存器) 80h
每磁道扇区数 63
每柱面磁头数(每柱面总的磁道数) 18

LBA向CHS模式转换:

  • LBA值需要通过运算间接的粗cylinder、head、sector这三个变量的值

术语

  • cylinder:磁盘的柱面
  • head:磁盘的磁头,每张磁片有两个磁头
  • sector:磁盘扇区,这里指物理扇区,编号从 1 - 63,每条 track 的最大 sector 数 63
  • SPT(sector_per_track):每磁道上的 sector 数
  • HPC(head_per_cylinder):每个 cylinder 的 head 数量,这个数量应该是磁片数 * 2

LBA寻址

LBA的扇区从0开始计算。
==LBA = (cylinder * HPC + head) * SPT + sector - 1==

  • 先计算track的数量,再加上物理扇区(从1开始编号),转换为LBA扇区需要-1
  • track 的数量计算方式为:cylinder * HPC(head_per_cylinder)+ head

LBA2CHS

  1. 计算cylinder:cylinder=LBA/(SPT*HPC)
    • SPT*HPC这部分计算的是每个cylinder有多少个扇区
    • 然后再计算在磁盘内需要多少个柱面表达
  2. 计算head:head=(LBA/SPT)%HPC
    • 在求出磁盘柱面的情况下,我们需要求得磁盘的磁头
    • LBA/SPT这步在计算磁盘内有多少head
    • 然后再计算出在一个柱面中的磁头编号
  3. 计算sector:sector=LBA%SPT+1
    • LBA % SPT 这步计算可以得出逻辑的扇区号
    • 然后由于物理的扇区是从1开始的,所以要+1

Resources:
代码实现之磁盘的 LBA 寻址转换为 CHS 寻址_lba和cly-CSDN博客
读取磁盘:CHS方式 - 猛练自然强 - 博客园

实现过程:
直接使用int 13h中断模式中的ah=02h功能(读扇区)

1
2
3
4
5
6
7
8
9
10
功能02H: 
功能描述:读扇区 
入口参数:AH02H 
AL=扇区数 
CH=柱面 
CL=扇区 
DH=磁头 
DL=驱动器,00H~7FH:软盘;80H~0FFH:硬盘 
ES:BX=缓冲区的地址 
出口参数:CF=0——操作成功,AH00HAL=传输的扇区数,否则,AH=状态代码,参见功能号01H中的说明

关键参数参考:

参数 数值
驱动器号(DL寄存器) 80h
每磁道扇区数 63
每柱面磁头数(每柱面总的磁道数) 18
扇区号(通过LBA地址+1得到物理地址CSH) 2
柱面号 0
缓冲区地址(bootloader加载地址) 0x7e00

代码:

1
2
3
4
5
6
7
8
9
asm_read_hard_disk:
mov ah,0x02 ;设置模式
mov al,1 ;读取扇区数
mov dl,0x80 ;磁头
mov bx,0x7e00 ;缓冲区地址
mov ch,0 ;柱面号
mov cl,2 ;扇区号?

int 13h

结果截图:

image-20250324213555322

任务 2

复现Example 2,使用gdb或其他debug工具在进入保护模式的4个重要步骤上设置断点,并结合代码、寄存器的内容等来分析这4个步骤,最后附上结果截图。gdb的使用可以参考appendix的“debug with gdb and qemu”部份。

提示:部分需要的文件存放在src/example-2下,请根据需要将其放置于自己创建的lab3文件夹下。

复现

可以直接make run;
结果截图参见上面的Example2部分。
image-20250320171528591

GDB调试方法

resources:
appendix/debug_with_gdb_and_qemu/README.md · young/sysu-2025-spring-operating-system - 码云 - 开源中国
Makefile(超详细一文读懂)-CSDN博客

回顾之前简单的调试:

  • 在qemu启动的前提下,打开一个新的终端,并输入gdb进入gdb调试
  • 在gdb下,连接已经启动的qemu进行调试: target remote:1234
  • 设置断点:break func
  • 输入c(continue)运行

生成符号表

  • 可以使用gdb在debug过程中查看源代码=>提供相关的信息=>符号表(ELF格式)
  • 制作符号表:
    • 先编译汇编代码生成可重定向文件(ELF文件)
    • 使用这个可重定向文件生成可执行文件和bin格式文件

以example-2为例:

  • 删除*.asm中的org语句,后续会在链接的过程中指定他们代码和数据的起始地址
  • 编译mbr.asm生成.o文件 。-g参数是为了加上debug信息
1
nasm -o mbr.o -g -f elf32 mbr.asm 
  • mbr.o文件指定起始地址为0x7c00,分别链接生成可执行文件mbr.symbolmbr.bin
1
2
ld -o mbr.symbol -melf_i386 -N mbr.o -Ttext 0x7c00
ld -o mbr.bin -melf_i386 -N mbr.o -Ttext 0x7c00 --oformat binary
  • 对bootloader.asm重复同样的工作
1
2
3
nasm -o bootloader.o -g -f elf32 bootloader.asm 
ld -o bootloader.symbol -melf_i386 -N bootloader.o -Ttext 0x7e00
ld -o bootloader.bin -melf_i386 -N bootloader.o -Ttext 0x7e00 --oformat binary
  • 将mbr.bin和bootloader.bin都载入磁盘
1
2
dd if=mbr.bin of=hd.img bs=512 count=1 seek=0 conv=notrunc
dd if=bootloader.bin of=hd.img bs=512 count=5 seek=1 conv=notrunc

执行完这些之后会发现生成了这些文件:

image-20250325085117185

设置断点:

在mbr的第一条指令处设置断点

image-20250325085326131

打开显示源代码的窗口:

image-20250325085405878

加载MBR对应的符号表,可以在src窗口看到我们的源代码

==B+表示断点,白色窗口表示下一条要执行的指令==

image-20250325085657137

窗口之间跳转:
注意,我们现在有两个窗口,一个是输入命令的cmd窗口,一个是显示源代码的src窗口。而方向键的效果只会在当前窗口起作用,并且当前窗口只有一个。在src窗口下,上下键的作用是上下滚动代码在cmd窗口下,上键的作用是找到之前执行的命令。如果想要在两个窗口之前切换,可以使用focus命令,例如切换当前窗口到cmd窗口。

image-20250325090100161

image-20250325090218168

设置断点

进入保护模式的四个步骤

  • 准备GDT,用lgdt指令加载GDTR信息
  • 打开第21根地址线
  • 开启cr0的保护模式标志位
  • 远跳转,进入保护模式

更加详细的基本调试思路

  1. qemu启动。我们先在一个Terminal下启动qemu,注意,qemu运行的参数需要加上-s -S参数,且在gdb启动之前不能关闭qemu。
  2. gdb启动。在另一个Terminal下启动gdb并连接上第1步启动的qemu。(如target remote:1234)
  3. 加载符号表。==符号表==会为gdb提供源代码和标识符等debug信息。
  4. 设置断点。gdb运行到我们设置的断点处会暂停,我们会在我们感兴趣的代码地址处设置断点,断点一般是我们认为bug出现的地方。
  5. 运行至断点处。使用命令跳过其他我们不感兴趣的代码,使代码一直执行到我们设置的断点处暂停。
  6. 查看寄存器或特定地址的值。我们可以在gdb暂停的地方查看寄存器或特定地址的值,并根据输出来判断前面执行的代码是否出现bug。
  7. 单步调试跟踪。gdb在断点处暂停后,我们可以一条一条语句地执行来跟踪程序的运行逻辑,gdb每执行条语句就会暂停。
  8. 重复3、4、5、6一直到bug解决。这个过程可能需要反复执行,但不一定是按照{3,4,5,6}{3,4,5,6}的顺序,可以是{3,4,6,5,6,6,6,5,3,4,5}{3,4,6,5,6,6,6,5,3,4,5}。

实验断点设置:

在mbr的初始地址设置断点:

image-20250325102422602

键入c之后,成功初始化

image-20250325102509957

然后打开源代码窗口,并加载MBR对应的符号表

image-20250325102602634

然后为bootloader设置断点,对应情况如下:

  • 这里断点位置的查找(偏移量的计算)使用了nasm -f bin -l bootloader.lst bootloader.asm命令,可以生成一个对应的列表文件,会显示==每条指令的偏移量==
  • 另一种方法是使用反汇编(objdump)–输出结果会呈现在终端中objdump -D -b binary -m i386 -M intel bootloader.bin

image-20250325102052040

  • 到达第一个起始点:

image-20250325103125494

此时的寄存器内容:

image-20250325103155996

image-20250325103217047

image-20250325103229067
栈顶指针指向0x7c00,下一条执行的指令

  • 到达第二个断点–初始设置GDTR之前

image-20250325103308463

寄存器内容:

image-20250325103324172

image-20250325103341062

image-20250325103354840

  • 第三个断点–GDTR设置后,打开第21根地址线之前

image-20250325103450079

寄存器内容:

image-20250325103511283

image-20250325103521281

image-20250325103529256

  • 第四个断点–打开第21根地址线之后,设置保护模式PE位之前

image-20250325103605882

image-20250325103620227

image-20250325103627936

  • 第五个断点–设置PE位之后,进入保护模式之前

image-20250325103649298

寄存器内容:

image-20250325103703503

image-20250325103719845

可以看到这里cr0位改变了,成功设置

进入保护模式前:

image-20250325103805259

  • 执行到最后(进入保护模式)

可以看到多呈现了一行输出–成功进入保护模式

image-20250325103857556

Makefile

基本概念:

Makefile 是 make 命令所读取的配置文件,包含了构建项目的规则。其主要作用是检查项目文件的依赖关系自动执行必要的命令,从而更新目标文件。一般来说,Makefile 主要包括以下三部分内容:

  • 目标:需要生成的文件,例如可执行文件。
  • 依赖:生成目标所依赖的文件或目标。
  • 命令:构建目标时需要执行的命令。
基本语法:
1
2
target:dependencies
command
  • target:目标文件,可以是一个目标文件或一个动作名称(例如:allclean)。
  • dependencies:生成目标所依赖的文件或其他目标。
  • command:构建目标的命令,必须以 Tab 键开头。

变量的使用:

1
VarName=var

使用的时候:$(VarName)
例子:

1
2
3
4
5
CC = g++
CFLAGS = -Wall -g

main.o: main.cpp
$(CC) $(CFLAGS) -c main.cpp -o main.o

常用的内置变量:

  • $@:表示目标文件
  • $^:表示所有的依赖文件
  • $<:表示第一个依赖文件

伪目标:一种==命令名称==
例子:

1
2
3
.PHONY: clean
clean:
rm -f *.o main

方式一:Makefile+直接编译链接(不推荐)

1
2
app:add.c div.c multi.c sub.c main.c
gcc add.c div.c multi.c sub.c main.c -o app

方式二:Makefile+编译+链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
app:add.o div.o multi.o sub.o main.o
gcc add.o div.o multi.o sub.o main.o -o app

add.o:add.c
gcc -c add.c -o add.o

div.o:div.c
gcc -c div.c -o div.o

multi.o:multi.c
gcc -c multi.c -o multi.o

sub.o:sub.c
gcc -c sub.c -o sub.o

main.o:main.c
gcc -c main.c -o main.o

方式三:Makefile+变量
知识点:
自定义变量:

  • 变量名=变量值,如var=hello
    预定义变量:
  • AR : 归档维护程序的名称,默认值为 ar
  • CC : C 编译器的名称,默认值为 cc
  • CXX : C++ 编译器的名称,默认值为 g++
  • $@ : 目标的完整名称
  • $< : 第一个依赖文件的名称
  • $^: 所有的依赖文件
    例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
src=add.o div.o multi.o sub.o main.o
target=app
$(target):$(src)
$(CC) $^ -o $@

add.o:add.c
$(CC) -c $^ -o $@

div.o:div.c
$(CC) -c $^ -o $@

multi.o:multi.c
$(CC) -c $^ -o $@

sub.o:sub.c
$(CC) -c $^ -o $@

main.o:main.c
$(CC) -c $^ -o $@

方式四:Makefile+模式匹配
方法五:Makefile + 函数

运行:
  • makemake run。使用qemu启动hd.img,在此命令执行前应该执行make build
  • make debug。启动qemu并开启gdb调试。
  • make build。编译代码并写入hd.img
  • make clean。清除当前文件夹下以.bin结尾的文件。(也可以自己设置,比如.o结尾,.exe结尾的文件等等)

gdb的命令也可以预先写到文件中,在启动gdb后会自动加载执行
比如:

1
2
3
4
target remote:1234
set disassembly-flavor intel
add-symbol-file mbr.symbol 0x7c00
add-symbol-file bootloader.symbol 0x7c00

任务 3(选做)

改造“Lab2-Assignment 4”为32位代码,即在保护模式后执行自定义的汇编程序。

resources:
x86保护模式——全局描述符表GDT详解_gdt全局描述符表 作用-CSDN博客

GDT介绍

GDT全称:全局描述符表
GDT的数据结构是一个描述符数组,每个描述符8个字节,可以存放在内存当中任意位置

实模式下的初始化代码,主要完成三件事:

  1. 初始化段描述符
  2. 初始化GDT的基址,并存放到GDTR寄存器
  3. 切换到保护模式(打开A20地址线,将CR0寄存器第0位设置为1),并跳转到负责打印字符串的代码段。

现在为止学到的:
从MBR加载bootloader,bootloader理论上是没有字节限制的,可以占据多个扇区,并完成很多功能,然后再将控制权交给操作系统
运行MBR是16位实模式,运行bootloader时通过执行对应的一组指令切换到32位保护模式,才开始加载内核进入内存,并且开始执行用户程序(本task要学习实现的)。

实现

参照原本的bootloader代码,
示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
;# 初始设置和显示信息 #
%include "boot.inc"
;org 0x7e00 这里注释掉,因为会通过命令行设置起到同样作用的命令
[bits 16]
mov ax, 0xb800 ;显存地址
mov gs, ax
mov ah, 0x03 ;青色
mov ecx, bootloader_tag_end - bootloader_tag ;计算要打印的字符串长度
xor ebx, ebx ;置0
mov esi, bootloader_tag ;将字符串移动到寄存器内(指针)
output_bootloader_tag: ;循环输出
mov al, [esi]
mov word[gs:bx], ax
inc esi
add ebx,2
loop output_bootloader_tag
;# 初始设置和显示信息 #

;# 局描述符表GDT设置 #
;空描述符--GDT的第一个条目必须为空
mov dword [GDT_START_ADDRESS+0x00],0x00
mov dword [GDT_START_ADDRESS+0x04],0x00

;创建描述符,这是一个数据段,对应0~4GB的线性地址空间
mov dword [GDT_START_ADDRESS+0x08],0x0000ffff ; 基地址为0,段界限为0xFFFFF
mov dword [GDT_START_ADDRESS+0x0c],0x00cf9200 ; 粒度为4KB,存储器段描述符

;建立保护模式下的堆栈段描述符
mov dword [GDT_START_ADDRESS+0x10],0x00000000 ; 基地址为0x00000000,界限0x0
mov dword [GDT_START_ADDRESS+0x14],0x00409600 ; 粒度为1个字节

;建立保护模式下的显存描述符
mov dword [GDT_START_ADDRESS+0x18],0x80007fff ; 基地址为0x000B8000,界限0x07FFF
mov dword [GDT_START_ADDRESS+0x1c],0x0040920b ; 粒度为字节

;创建保护模式下平坦模式代码段描述符
mov dword [GDT_START_ADDRESS+0x20],0x0000ffff ; 基地址为0,段界限为0xFFFFF
mov dword [GDT_START_ADDRESS+0x24],0x00cf9800 ; 粒度为4kb,代码段描述符

;# 局描述符表GDT设置 #

;#加载GDTR并准备切换到保护模式#
;准备GDTR B+ 1
;初始化描述符表寄存器GDTR
mov word [pgdt], 39 ;描述符表的界限
lgdt [pgdt] ;加载GDTR寄存器,告诉CPU GDT的位置和大小
;打开第21根地址线,B+ 2
in al,0x92 ;南桥芯片内的端口
or al,0000_0010B
out 0x92,al ;打开A20
;设置保护模式标志位 B+ 3
cli ;禁用中断,中断机制尚未工作
mov eax,cr0
or eax,1
mov cr0,eax ;设置PE位
;#加载GDTR并准备切换到保护模式#

;以下进入保护模式 B+ 4
jmp dword CODE_SELECTOR:protect_mode_begin ;远跳转,同时清空CPU流水线并加载CS寄存器为代码段选择子

;16位的描述符选择子:32位偏移
;清流水线并串行化处理器
[bits 32]
protect_mode_begin: ;进入保护模式
;保护模式下的初始化
mov eax, DATA_SELECTOR ;加载数据段(0..4GB)选择子
mov ds, eax
mov es, eax
mov eax, STACK_SELECTOR
mov ss, eax
mov eax, VIDEO_SELECTOR
mov gs, eax
;保护模式必须通过GDT描述符来访问内存,不能直接使用段地址
;32位模式下使用32位寄存器(eax)而不是16位(ax)
;显存访问需要通过专门的显存段描述符
;这里开始执行自己的程序--所以我们只需要修改这部分就好了,同时要保证这部分是32位的
mov ecx, protect_mode_tag_end - protect_mode_tag
mov ebx, 80 * 2
mov esi, protect_mode_tag
mov ah, 0x3
output_protect_mode_tag:
mov al, [esi]
mov word[gs:ebx], ax
add ebx, 2
inc esi
loop output_protect_mode_tag
;=======up======|same|======up===========
jmp $ ; 死循环

;数据定义
pgdt dw 0
dd GDT_START_ADDRESS

bootloader_tag db 'run bootloader'
bootloader_tag_end:

protect_mode_tag db 'enter protect mode'
protect_mode_tag_end:

my_program:前面的部分和上面基本完全一样,下面只展示我的程序部分

  1. 初始化弹射字符变量–存储在内存中(保护模式下更倾向于使用内存变量而不是寄存器存储状态
1
2
3
4
5
6
mov byte [row], 2        ; 初始行位置
mov byte [col], 0 ; 初始列位置
mov byte [row_dir], 1 ; 行方向增量 (1=向下)
mov byte [col_dir], 1 ; 列方向增量 (1=向右)
mov byte [char_color], 0x0A ; 字符颜色 (亮绿色)
mov byte [char_value], '0' ; 显示字符

使用:mov <mem>, <con>,将常数存储到==内存地址row==处,并通过byte指定操作数大小为1字节。
2. 边界检查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bounce_loop:
; 检查上下边界
mov al, [row]
cmp al, 0
jle bounce_up ; 如果行 <= 0,需要向下反弹
cmp al, 24
jge bounce_down ; 如果行 >= 24,需要向上反弹

; 检查左右边界
mov al, [col]
cmp al, 0
jle bounce_right ; 如果列 <= 0,需要向右反弹
cmp al, 79
jge bounce_left ; 如果列 >= 79,需要向左反弹

jmp display ; 通过所有边界检查,显示字符

这里将模板内存地址中存储的值取出到寄存器内,再进行比较。其余的内容差不多。
3. 显存位置计算

1
2
3
4
5
movzx ebx, byte [row]    ; 获取当前行
imul ebx, 80 ; ebx = row * 80
movzx ecx, byte [col] ; 获取当前列
add ebx, ecx ; ebx = row * 80 + col
shl ebx, 1 ; ebx = 2 * (row * 80 + col)

使用32位寄存器代替原本的16位寄存器。
4. 显存写入
区别是32位一次可以写入两个字节,所以我们分别对ah和al进行赋值之后,一起存储到gs中

1
2
3
mov ah, [char_color]     ; 设置字符颜色
mov al, [char_value] ; 设置字符值
mov word [gs:ebx], ax ; 写入显存
  1. 位置更新
    内存上的值要进行计算,需要提取到寄存器之后再计算。
1
2
3
4
5
6
7
8
9
10
mov al, [row]
add al, [row_dir] ; 根据行方向增量更新行
mov [row], al

mov al, [col]
add al, [col_dir] ; 根据列方向增量更新列
mov [col], al

inc byte [char_color] ; 改变颜色
inc byte [char_value] ; 改变字符
  1. 延时实现
    旧的代码中我使用的是bios int15h中断功能,但是这是实模式专属的,在保护模式无法使用。因此要切换成循环计数(纯软件延时)的方式(之前在实模式尝试过这种方式,但是延时效果比较差,应该是空间不够大)
1
2
3
4
mov ecx, 2000000         ; 延时计数器
delay_loop:
nop ; 空操作
loop delay_loop ; 循环直到ecx为0

整个程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
; =========== 弹射字符动画 (32-bit) ===========

; 初始化弹射字符变量--存储在内存中
mov byte [row], 2 ; 初始行位置
mov byte [col], 0 ; 初始列位置
mov byte [row_dir], 1 ; 行方向增量 (1=向下)
mov byte [col_dir], 1 ; 列方向增量 (1=向右)
mov byte [char_color], 0x0A ; 字符颜色 (亮绿色)
mov byte [char_value], '0' ; 显示字符

; 主弹射循环
bounce_loop:
; 检查上下边界
mov al, [row]
cmp al, 0
jle bounce_up ; 如果行 <= 0,需要向下反弹
cmp al, 24
jge bounce_down ; 如果行 >= 24,需要向上反弹

; 检查左右边界
mov al, [col]
cmp al, 0
jle bounce_right ; 如果列 <= 0,需要向右反弹
cmp al, 79
jge bounce_left ; 如果列 >= 79,需要向左反弹

jmp display ; 通过所有边界检查,显示字符

bounce_up:
mov byte [row_dir], 1 ; 将行方向改为向下(正)
jmp display

bounce_down:
mov byte [row_dir], -1 ; 将行方向改为向上(负)
jmp display

bounce_right:
mov byte [col_dir], 1 ; 将列方向改为向右(正)
jmp display

bounce_left:
mov byte [col_dir], -1 ; 将列方向改为向左(负)
jmp display

display:
; 计算显存位置
movzx ebx, byte [row] ; 获取当前行
imul ebx, 80 ; ebx = row * 80
movzx ecx, byte [col] ; 获取当前列
add ebx, ecx ; ebx = row * 80 + col
shl ebx, 1 ; ebx = 2 * (row * 80 + col)

; 显示字符
mov ah, [char_color] ; 设置字符颜色
mov al, [char_value] ; 设置字符值
mov word [gs:ebx], ax ; 写入显存

; 更新位置
mov al, [row]
add al, [row_dir] ; 根据行方向增量更新行
mov [row], al

mov al, [col]
add al, [col_dir] ; 根据列方向增量更新列
mov [col], al

; 更新字符外观
inc byte [char_color] ; 改变颜色
inc byte [char_value] ; 改变字符
cmp byte [char_value], '9'+1
jne add_delay
mov byte [char_value], '0' ; 如果超过'9',重置为'0'

add_delay:
; 保护模式下的延时循环
mov ecx, 2000000 ; 延时计数器
delay_loop:
nop ; 空操作
loop delay_loop ; 循环直到ecx为0

jmp bounce_loop ; 继续主循环

; 保护模式下存储变量的数据区
row db 0 ; 当前行位置
col db 0 ; 当前列位置
row_dir db 0 ; 行方向
col_dir db 0 ; 列方向
char_color db 0 ; 字符颜色
char_value db 0 ; 字符值

protect_mode_tag db 'enter protect mode'
protect_mode_tag_end:

OS_lab3
https://pqcu77.github.io/2025/03/12/OS-lab3/
作者
linqt
发布于
2025年3月12日
许可协议