1.1 实验环境搭建
1.1.1 bochs
Bochs 是一个开源的 x86 模拟器和调试器,允许您在虚拟环境中模拟 x86 架构的计算机系统。Bochs 的主要用途之一是用于开发和调试操作系统内核、嵌入式系统以及其他与低级系统编程相关的任务。它提供了一种方式来模拟整个计算机系统,包括处理器、内存、设备和外部接口,使您可以在不需要物理硬件的情况下进行系统级别的实验和调试。
windows11 + bochs虚拟机
注:
1.安装时,可以勾选下图所示的“DLX Linux Demo”,这样安装时会带有一个Linux 1.x版本的模拟示例。
2.最好是把 Bochs
直接安装到 C:\
下,路径名中间不能有空格
安装好后bochs根目录如下:
应用程序:
-
bochs.exe直接运行虚拟机
-
bochsdbg.exe可以从头开始调试
-
bximage.exe可以用于生成软盘或磁盘镜像
其他文件:主要用于配置虚拟机
-
BIOS系统镜像,有BIOS-bochs-latest和BIOS-bochs-legacy
-
VGABIOS镜像,提供屏幕接口
-
键盘映射,在keymaps文件中
1.1.2 Linux 0.00
从网站上下载源代码
链接:https://pan.baidu.com/s/1t5yz5-XsqUmY-POA0O9pnQ 提取码:ixm3
-
Linux000 code/
目录下是源码,经修改后可编译成功。 -
windows
下可直接运行Linux000.bat
-
linux000_gui.bxrc
是Bochs
配置文件 -
Image
是生成的Linux 0.00
的二进制文件,被加载到软驱,由Bochs
从配置文件读取并启动执行。
1.1.3运行Linux 0.00
-
打开 bochsdbg.execc
- 点击load,选择Linux000文件下的linux000_gui.bxrc文件,然后点击start出现如下界面
即可开始调试
1.2 实验内容
1.2.1 掌握如何手写Bochs虚拟机的配置文件
1.简介 Bochs
虚拟机的配置文件
Bochs的配置文件是一个文本文件,通常命名为 bochsrc.txt
,它包含了用于定义虚拟机的各种参数和选项的设置。这些参数和选项允许配置虚拟机的硬件、启动选项、内存分配、设备模拟和其他相关设置。
![](https://i-blog.csdnimg.cn/blog_migrate/449cb956ee04632323d60c52db236a7c.png)
bochsrc.txt
,它包含了Bochs虚拟机的各种设置和参数。这个配置文件对于定义虚拟机的硬件和软件环境非常重要。
# Configuration file for Bochs
# Sample bochsrc.txt file
# 设置了虚拟机使用的BIOS镜像文件,BIOS-bochs-latest 是BIOS镜像文件的名称
romimage: file=BIOS-bochs-latest
# 设置了虚拟机使用的VGA BIOS镜像文件,VGABIOS-lgpl-latest 是VGA BIOS镜像文件的名称
vgaromimage: file=VGABIOS-lgpl-latest
# 内存分配,在这里,虚拟机将被分配32兆字节(MB)的内存
megs: 32
# 设置了虚拟CPU的性能参数,ips代表每秒执行的指令数
cpu: ips=1000000
# 设置设备从软驱启动
boot: floppy
# 确保虚拟机配置文件中有正确配置的软驱设备
floppya: 1_44=floppy.img, status=inserted
# 设置设备从硬盘启动
boot: disk
# 确保虚拟机配置文件中正确配置了硬盘设备
ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14, type=disk, path=harddisk.img, cylinders=1024, heads=16, spt=63
# 设置涉及虚拟机中的网络适配器
ne2k: ioaddr=0x300, irq=9
# 设置涉及虚拟机的日志和内部调试功能
debug: action=ignore, guest_os=dos, parport1=none
log: bochsout.txt
debug: internal
2.如何设置从软驱启动
编辑Bochs的配置文件,通常是 bochsrc.txt
在配置文件中,找到以下行并确保正确设置启动选项为 floppy,并
确保虚拟机配置文件中有正确配置的软驱设备。
boot: floppy
floppya: 1_44=a.img, status=inserted
3.如何设置从硬盘启动
在配置文件中,找到以下行并确保正确设置启动选项为 disk,并
确保虚拟机配置文件中正确配置了硬盘设备。
boot: disk
ata0-master: type=disk, path="harddisk.img", mode=flat, cylinders=1024, heads=16, spt=63
4.如何设置调试选项
在配置文件中,找到调试选项的部分或在文件末尾添加以下内容以配置调试选项
# 启用调试输出
debug: action="option_name", parameter="value"
# 例如,设置调试动作为打印所有调试信息
debug: action="debug", option="all"
# 可以设置其他选项,例如设置断点
debug: action="bpoint", name="my_breakpoint", type=address, addr=0x1234
action
:指定要执行的调试操作,例如 "debug"(打印调试信息)或 "bpoint"(设置断点)等。
option
:如果存在,可以设置特定的调试选项,例如 "all"(打印所有调试信息)。
name
:为断点指定一个名称,用于标识它。
type
:指定断点的类型,可以是address
(地址断点)等。
addr
:如果设置了地址断点,指定要设置的地址。
1.2.2 掌握Bochs虚拟机的调试技巧
1.如何单步跟踪
输入s命令即可单步执行,每次输入 s
命令,虚拟机将执行当前指令并停在下一条指令之前。
s
也可以点击上方的step[s]按键
2. 如何设置断点进行调试
使用 b
命令设置断点。命令的基本语法如下
b <address>
# <address>:指定要设置断点的内存地址或符号
Bochs 将在执行到指定地址时停止
使用 info break
命令可以列出当前设置的断点,以查看断点的状态和地址
若要清除断点,使用 bc
命令,然后输入要删除的断点号
3. 如何查看通用寄存器的值
![](https://i-blog.csdnimg.cn/blog_migrate/dcbec744cf7029a4cb7414b5c9170653.png)
使用 r
命令来查看通用寄存器的值。命令的基本语法如下:
r <register_name>
# <register_name>:指定要查看的寄存器名称,如 eax、ebx、ecx、edx、esi、edi、esp 或 ebp 等。
还可以使用 info reg
命令来一次性查看所有通用寄存器的值
4. 如何查看系统寄存器的值
![](https://i-blog.csdnimg.cn/blog_migrate/d3ded24465da0ce5b6307c2dee3478c0.png)
只关心特定系统寄存器的值,可以使用相应的命令来查看
info reg cr0
# Bochs 将显示 CR0 寄存器的当前值
5. 如何查看内存指定位置的值
使用 x
命令来查看内存中特定位置的值。命令的基本语法如下:
x /<count><format> <address>
# <count>:指定要查看的数据项数量。
# <format>:指定数据的显示格式,如 b(字节)、w(字)、d(双字)等。
# <address>:指定要查看的内存地址。
# 例如,要查看内存地址0x1234处的双字(32位数据)的内容
x /1wd 0x1234
6. 如何查看各种表,如 gdt
,idt
,ldt
等
查看 GDT:
使用 info gdt
命令来查看全局描述符表 (GDT) 的内容
info gdt
查看 IDT:
使用 info idt
命令来查看中断描述符表 (IDT) 的内容。
info idt
查看 LDT:使用 info ldt
命令来查看局部描述符表 (LDT) 的内容。
info ldt
7. 如何查看 TSS
使用 info tss
命令来查看任务状态段(TSS)
info tss
8.
如何查看栈中的内容
使用 info stack
命令来查看栈中的内容
info stack
若要查看特定内存地址处的栈内容,可以使用 x
命令,类似于查看内存地址的方式
9. 如何在内存指定地方进行反汇编
使用 disasm
命令来进行反汇编。命令的基本语法如下:
disasm <address> <count>
# <address>:指定要反汇编的内存地址。
# <count>:指定要反汇编的指令数量。
# 例如,要反汇编从内存地址0x1234开始的10条指令
disasm 0x1234 10
1.2.3 计算机引导程序
1.如何查看 0x7c00
处被装载了什么?
要查看在内存地址0x7C00处加载了什么内容,通常是查看引导扇区的内容,因为在x86体系结构中,计算机通常从0x7C00处开始加载引导扇区
使用 x
命令来查看内存地址0x7C00处的内容。命令的基本语法如下:
x /<count><format> 0x7C00
# <count>:指定要查看的数据项数量。
# <format>:指定数据的显示格式,如 b(字节)、w(字)、d(双字)等。
2.如何把真正的内核程序从硬盘或软驱装载到自己想要放的地方;
将真正的内核程序从硬盘或软驱装载到指定位置通常需要编写引导加载程序,这是一个低级程序,负责加载操作系统内核。
利用引导启动程序boot.s , boot.s 主要功能就是将软盘或映像文件中的 head 内核diamagnetic加载到内存中某个指定位置处,并在设置临时 GDT 表等信息后,把处 理器设置成运行在保护模式下,然后跳转到hed 代码处去运行代码。实际上,boot.s 程序会首先利用 ROM BIOS 中断 int 0x13 把软盘中的 head 代码读入到内存0x10000 位置开始处,然后再把这段 head 代码移动到内存 0 处,最后设置控制寄存器CR0 中的开启保护运行模式,并跳转到内存 0 处开始执行 head 代码。
3.如何查看实模式的中断程序?
-
查找中断向量表:在实模式下,中断处理程序通常通过中断向量表来查找。这个表包含中断号与中断处理程序的关联关系。
-
查看中断处理程序:一旦找到了中断处理程序的地址,就可以利用x addr指令即可查看对应地址的内存内容,即中断程序的内容。
4.如何静态创建 gdt
与 idt
?
-
创建 GDT 和 IDT 表项:定义 GDT 和 IDT 表项的结构。每个表项包含段描述符或中断描述符的相关信息,例如段基址、段限制、访问权限等。根据需要,创建所有所需的表项。
-
初始化 GDT 和 IDT 表项:为每个表项设置适当的值,包括段的起始地址、限制、特权级别、类型(代码段、数据段、中断门等)等。
-
创建 GDT 和 IDT 表:将所有初始化的表项组合成 GDT 和 IDT 表,这些表通常存储在内存中。
-
加载 GDT 和 IDT:使用汇编代码将 GDT 和 IDT 表的地址加载到处理器的 GDTR 和 IDTR 寄存器中。这通常涉及到汇编指令
lgdt
(加载 GDT)和lidt
(加载 IDT)。 -
启用中断:如果正在设置 IDT 以处理中断,确保启用中断处理器,以便它可以响应中断。这可以通过设置处理器的中断标志位(IF)来完成。
-
编写中断处理程序:如果设置了 IDT 来处理中断,需要编写相应的中断处理程序。
-
汇编和链接:将所有汇编代码和数据结构汇编并链接成可执行文件。这个文件将包含 GDT 和 IDT 表的初始化和加载代码,以及任何必要的中断处理程序。
-
加载到目标系统:将生成的可执行文件加载到目标系统的内存中,并执行以初始化 GDT 和 IDT 表。
5.如何从实模式切换到保护模式?
-
准备 GDT 和 IDT:在保护模式下,需要配置全局描述符表(GDT)和中断描述符表(IDT)。需要创建和初始化这些表,包括定义段描述符和中断门。
-
加载 GDT 和 IDT:使用
lgdt
汇编指令加载 GDT 表的地址到GDTR
寄存器,并使用lidt
指令加载 IDT 表的地址到IDTR
寄存器。 -
设置 CR0 寄存器:将控制寄存器 CR0 的第0位(PE位)设置为1,以启用保护模式。这可以通过执行汇编指令
mov eax, cr0
,or eax, 1
,mov cr0, eax
来完成。 -
跳转到新代码段:在切换到保护模式后,执行
far jump
指令以跳转到新的代码段。通常,会跳转到新的代码段以开始执行保护模式下的代码。 -
设置栈指针:在保护模式下,通常需要重新设置栈指针(SP)以指向新的栈段。这是因为在实模式下,栈通常在段0x0000下,而在保护模式下通常会有不同的栈段。
-
编写保护模式代码:一旦切换到保护模式,需要编写适用于该模式的代码。
6.调试跟踪 jmpi 0,8
,解释如何寻址?
![](https://i-blog.csdnimg.cn/blog_migrate/5a84369c74be745630aadb8f0464f3c4.png)
![](https://i-blog.csdnimg.cn/blog_migrate/14e8f22356d175b646fa89ad4a8bcd2e.png)
![](https://i-blog.csdnimg.cn/blog_migrate/f370d823b3c5d33cc763fa2875029747.png)
运行一步后发现跳转到了0x0000处
jmpi
指令是一条汇编指令,通常用于在x86架构的实模式下进行跳转。这个指令的作用是无条件跳转到一个新的代码段,以执行那里的指令
-
jmpi
指令:这是一个实模式下的汇编指令,用于在跳转到新的代码段时,提供一个绝对的偏移地址。 -
寻址方式:
jmpi
指令的寻址方式是使用一个绝对的偏移地址。它的操作数是一个16位的偏移地址,其格式通常如下:jmpi <偏移地址> # 偏移地址指定了要跳转到的新代码段中的目标指令
-
目标地址计算:
jmpi
指令的目标地址计算方式是将偏移地址与当前代码段的基址相加,然后跳转到该地址,它允许跳转到不同的代码段。例如,如果当前代码段的基址是
0x0000
,偏移地址为0x0008
,那么目标地址将是0x0000 + 0x0008 = 0x0008
。jmpi
指令执行后,控制权将转移到新代码段的指定地址,开始执行那里的指令。
1.3 实验报告
1.请简述 head.s
的工作原理
通常情况下,head.s
是引导加载程序(bootloader)的一部分,用于引导加载操作系统内核,包含 32 位保护模式初始化设置代码、时钟中断代码、系统调用中断代码和两个任务的代码。
-
加载到内存:
head.s
是一个汇编源代码文件,经过汇编和链接后,生成二进制可执行文件,通常是一个引导扇区(boot sector)。该文件必须存储在引导设备(如硬盘或软驱)的引导扇区中。 -
引导加载程序:计算机启动时,处理器会加载引导设备的引导扇区(通常位于磁盘的第一个扇区)到内存地址0x7C00。这个扇区通常包含了
head.s
的代码。 -
设置环境:
head.s
开始执行,它的主要任务是设置一个适当的环境,以准备加载操作系统内核。这通常涉及到以下几个步骤:- 初始化 GDT/IDT
- 设置系统定时器芯片 8253
- 初始化 TSS
- 跳转到task0 的用户态程序
- task0 或 task1 的用户态程序在运行时,通过系统调用 int 0x80 向屏幕上打印字符A 或B
- 时钟中断发生时,内核的中断处理程序实现task0 和task1 的任务切换
初始化 GDT/IDT
设置 GDT/IDT 代码如下,调用了两个子程序setup_gdt
和setup_idt
:
# setup base fields of descriptors.
call setup_idt
call setup_gdt
movl $0x10,%eax # reload all the segment registers
mov %ax,%ds # after changing gdt.
mov %ax,%es
mov %ax,%fs
mov %ax,%gs
lss init_stack,%esp
/****************************************/
setup_gdt:
lgdt lgdt_opcode
ret
setup_idt:
lea ignore_int,%edx
movl $0x00080000,%eax
movw %dx,%ax /* selector = 0x0008 = cs */
movw $0x8E00,%dx /* interrupt gate - dpl=0, present */
lea idt,%edi
mov $256,%ecx
设置系统定时器芯片
movb $0x36, %al # 控制字:设置通道 0 工作在方式 3、计数初值采用二进制。
movl $0x43, %edx # 8253 芯片控制字寄存器写端口。
outb %al, %dx
movl $LATCH, %eax # 初始计数值设置为 LATCH(1193180/100),即频率 100HZ。
movl $0x40, %edx # 通道 0 的端口。
outb %al, %dx # 分两次把初始计数值写入通道 0。
movb %ah, %al
outb %al, %dx
跳转到 task 0
# Move to user mode (task 0)
pushfl # 将EFLAGS压栈
andl $0xffffbfff, (%esp) # EFLAGS 的NT 标志位(第14 位)置0
popfl
movl $TSS0_SEL, %eax # 把任务0的TSS段选择符加载到TR
ltr %ax
movl $LDT0_SEL, %eax # 把任务0的LDT加载到LDTR
lldt %ax
movl $0, current # 任务号
sti
pushl $0x17 # 数据段选择符
pushl $init_stack # 栈指针
pushfl # EFLAGS
pushl $0x0f # 代码段选择符
pushl $task0 # task0程序入口
iret
最后执行 iret 时,iret 会把栈顶弹出,更新CS 和 IP,然后把下一个栈顶弹出,更新EFLAGS,然后把下两个栈顶弹出,更新 SS 和ESP,此时便跳转到 task0 下执行。
task0 代码
task0:
movl $0x17, %eax
movw %ax, %ds
movb $65, %al /* print 'A' */
int $0x80
movl $0xfff, %ecx
1: loop 1b
jmp task0
task0 和 task1 代码唯一区别就是传入的字符不同。它们都使用 int 80 系统调用打印字符,然后进行 4095 次的空循环。
2.请记录 head.s
的内存分布状况,写明每个数据段,代码段,栈段的起始与终止的内存地址
1) 栈段
-
栈段(stack segment):栈,用于函数调用和本地变量。通常在数据段的上方
-
点击stack查看栈中内容
-
起始地址为0x8000
-
终止地址为0x80c4+4-1,即0x80c7
2)数据段,代码段
-
代码段(text segment):引导加载程序的机器代码,通常位于内存的低地址
-
数据段(data segment):引导加载程序的数据和全局变量,可能位于代码段的下方
-
点击GDT查看全局描述符表 (GDT) 的内容
-
Selector 0x0008
通常用于访问操作系统内核的代码段 -
Selector 0x0010
通常用于访问操作系统内核的数据段 -
根据上图我们可以看到数据段和代码段的起始地址都是0x0,Size均为0x7FFFFF,终止地址=起始地址+Size
-
由此可得数据段和代码段的
-
起始地址为:0x0
-
终止地址为:0x7FFFFF
-
3.简述 head.s
57
至 62
行在做什么?
-
在第56行设置断点,然后继续执行程序
- 反汇编57至62行
-
57至62行代码如下:
- init_stack的内容如下,设置堆栈指针(SS:ESP)的初始值,以确保堆栈能够正常工作
-
任务0的LDT如下(提供代码段选择子和数据段选择子等):
sti
指令用于开启中断,允许处理器响应中断请求。
pushl $0x17
将任务 0 的当前局部数据段选择符入栈。这个选择符是用于任务 0 的堆栈段。
pushl $init_stack
将 init_stack
的地址入栈。这是为了保存任务 0 的堆栈指针。
pushfl
指令将标志寄存器的值入栈。这个值包括处理器状态标志,如进位标志、溢出标志等。
pushl $0x0f
将当前局部代码段选择符入栈。这个选择符通常用于指定任务 0 的代码段。
pushl $task0
将任务 0 的入口地址入栈。这是为了保存任务 0 的执行位置。
iret
是中断返回指令,它会从栈中弹出保存的信息,包括代码指针、代码段选择符、标志寄存器值等,并使用这些信息切换到任务 0 的执行上下文。这实现了从中断处理程序返回到任务 0 的操作。
4.简述 iret
执行后, pc
如何找到下一条指令?
iret
指令用于从中断处理程序返回到正常的程序执行。iret
指令的执行后,处理器会进行下一跳指令的查找和执行,下面是其执行后寻找下一跳指令的过程:
-
iret 恢复寄存器状态:
iret
指令会从栈中弹出一系列值,包括代码段选择符(CS)、代码指针(EIP)、标志寄存器(EFLAGS),以及堆栈指针(ESP)的值。这些值都是在中断处理过程中被保存的,用于恢复中断前的状态。 -
加载代码段选择符(CS):
iret
指令将代码段选择符(CS)从栈中加载到 CS 寄存器中。CS 寄存器包含了新的代码段选择符,它指示了中断返回后应执行的代码段。 -
加载代码指针(EIP):
iret
指令将代码指针(EIP)从栈中加载到 EIP 寄存器中。EIP 寄存器包含了下一条要执行的指令的地址。 -
加载标志寄存器(EFLAGS):
iret
指令将标志寄存器(EFLAGS)从栈中加载到 EFLAGS 寄存器中。这些标志用于控制处理器的状态和行为。 -
堆栈指针(ESP)的修改:
iret
指令还会从栈中弹出堆栈指针(ESP)的值。这是为了确保栈指针正确地指向下一个栈帧。 -
执行下一跳指令:一旦
iret
执行完上述步骤,控制将传递到新的代码段(由 CS 指定)中的新指令(由 EIP 指定)。处理器将从新代码段的新指令地址开始执行,从而继续程序的正常执行,即继续执行任务0的程序
5.记录 iret
执行前后,栈是如何变化的?
iret
执行前
通过对head.s
57
至 62
行的分析,我们可以知道从57行sti
指令开启中断开始,程序开始将任务0的堆栈段选择符、堆栈指针的值、标志寄存器(EFLAGS)的值、代码段选择符和代码指针入栈。
至此在执行 iret
指令之前,堆栈中包含了堆栈段选择符、堆栈指针、标志寄存器、代码段选择符和代码指针。
iret
执行后
iret
指令的目的是从中断处理程序返回到正常程序执行,它会恢复堆栈指针、代码段选择符、代码指针、标志寄存器等寄存器的值,以确保程序的控制流和状态正确地切换到正常程序。
因此当执行 iret
后,堆栈的变化如下:
-
iret
指令会弹出之前被压入栈的值,以恢复任务 0 的状态。-
iret
弹出代码指针(EIP)的值,指示了下一条要执行的指令地址。 -
然后它弹出代码段选择符(CS),指示了代码段的位置。
-
接着,它弹出标志寄存器(EFLAGS)的值,以恢复标志状态。
-
最后,它弹出堆栈指针(ESP)的值,以确保栈指针正确指向下一个栈帧。
-
-
iret
将控制传递到任务 0 中的下一条指令,以继续程序的正常执行。
6.当任务进行系统调用时,即 int 0x80
时,记录栈的变化情况。
-
iret
指令执行后,程序转到了任务0的第一条指令
执行 int 0x80
之前
-
当任务 0 希望执行系统调用时,它会将系统调用号和相关参数加载到寄存器中。
-
寄存器中的内容,包括系统调用号和参数,通常在进入内核前被保存在寄存器中。
-
栈上包含了任务 0 正常执行的栈帧,包括函数调用的参数、局部变量等。
执行 int 0x80
之后
-
当任务 0触发
int $0x80
指令时,处理器会执行以下操作:-
压入标志寄存器(EFLAGS)的值。
-
压入代码段选择符(CS)的值。
-
压入返回地址,指向系统调用处理程序。
-
压入系统调用号和参数。
-
-
进入内核态后,内核会根据系统调用号,从栈上获取参数,执行相应的系统调用服务。
-
系统调用处理程序执行完后,它会将返回值存储在一个特定的寄存器中,通常是
EAX
寄存器。 -
处理程序使用
iret
指令返回到用户态,这会将栈上的内容弹出,恢复到int $0x80
指令执行前的状态。
注:
部分参考Lab1-调试分析 Linux 0.00 引导程序 | Kcxain's Blog (deconx.cn)
仅供备份使用