主引导记录
书上借助主引导扇区的例子,讲解了实模式下分段机制,显存的使用等等基本原理
为啥要借助主引导扇区呢?
因为虚拟机开机之后BIOS自动将磁盘的前512字节作为主引导记录装载进入内存的0x7c00位置并从0x7c00处开始执行.
我们的代码直接写到主引导记录中就可以顺理成章地执行了
自举过程
计算机上电之后,如果设置硬盘位首选的启动设备,则ROM-BIOS将视图读取硬盘的逻辑0号扇区,即物理上的0面0道1扇区.这里就是主引导扇区的位置
ROM-BIOS会把这个扇区的512字节搬到内存的0x7c00处,后面就是对内存中的拷贝进行的一系列操作
ROM-BIOS检查这512字节的最后两个字节是否是0x55,0xAA,这是主引导扇区魔数
如果主引导扇区有效则跳转打0x7c00继续执行
主引导扇区的内容一般是检测操作系统在硬盘上的位置,把grub代码加载道内存,跳转grub举起操作系统来
显示
显卡与显示器
显卡负责给显示器提供内容
显示器负责显式
显示器接到显卡上,显卡接到主板上
显卡控制显示器的最小单位是像素,每个像素点需要24比特存储颜色(RGB各8比特).显卡使用字节的显式存储器(显存,video RAM,VRAM)存放像素点的颜色.要显式啥就把啥信息放到显存里.
显示器是一个二维平面,儿显存就是一个内存条子的存储器,它是线性的,这就涉及到一维数组到二维数组的映射关系了
显存相当于一个缓冲区,CPU把想要输出到屏幕的东西放到显存里.显卡周期性地从显存拿出来按顺序显式到屏幕上.
显卡工作模式
显卡的工作模式分为文本模式和图形模式
图形模式下操作的确实是像素点
文本模式下只需要在显存中写入指定的字符的ASCII码,显卡内部经过字符发生器,就可以翻译成一些像素点的组合输出到屏幕上,也就是代替人干了计算每个像素点位置的事,人只需要指定第几行第几列写哪个字符就可以了
一般个人电脑显卡加电自检之后都会把自己初始化为80×25的文本模式
每行可以显示80个字符,一共可以显示25行.满屏是2000个字符
端口映射内存
如果正儿八经地论起来,除了内存条子,其他的设备都是IO外设,CPU和内存条子打交道的速度得是外设的成千上万倍,如果想要实现细致流畅的游戏画面体验,经过IO访问显存是办不到的.于是把显存也和内存统一编址,这就绕开了IO直接让CPU像访问内存一样访问显存.
对于8086来说,其CPU宽20位,可以寻址1M的内存空间0x0到0x9FFFF,
那从0xA0000到0xFFFFF就不是内存条子的事了,比如
0xB8000到0xBFFFF这32KB的地址空间映射给显存
0xF0000到0xFFFFF这64K的地址空间映射给ROM-BIOS
段寄存器
显存入乡随俗,怎么访问内存就得怎么访问显存
访问内存使用段地址:偏移量,
物理地址=段地址×16+偏移量
那么如果要访问显存,就得根据显存的起始地址设置段地址,由于显存起始地址是0xB8000,因此段地址就是0xB800,寻址的时候CPU会自动把0xB800乘16的
怎样让段寄存器等于0xB800呢?
mov es,0xB800
这样写吗?不可以,Intel的处理器规定不允许将立即数传递给段寄存器,必须使用通用寄存器或者内存单元中转一下,也就是说
1 | mov ax,0xb800 ;指向文本模式的显示缓冲区 |
字符模式下一个表示字符
字符模式下每个字符需要占据连续的两个字节,低字节是该字符的ASCII代码,高字节是该字符的颜色特征
比如这里0xB8000开始的连续两个字节,
低字节0x48是'H'的ASCII码,
高字节0x07=00000111B表示黑背景色不闪烁,白前景色
显示字符
例子中想要显示一串字符串"Label offset:"
1 | mov ax,0xb800 //es段设置为显存地址起始位置 |
每两个相邻字节的低字节放字符ASCII代码,高字节调制颜色,都是不闪烁的黑背景,白前景
寻址的时候使用的是[es:0x00]
,转换成物理地址就是
es*16+0x00
,为啥要写es呢?因为段寄存器默认是ds,这里需要段超越前缀指定使用es段
而es=0x8b00,乘以16之后恰好就是显存映射地址的起始位置
用byte关键字来修饰操作数的范围
1 | mov byte [es:0x00],'L' |
意思是'L'顶多占用一个字节,不能再多了,这条指令只会把0x4C放到es:00上
如果这样写
1 mov word [es:0x00],'L'则把0x4C放到es:0x00,把0x00放到es:0x01
如果是把寄存器的值放到内存上则不用byte或者word修饰操作数的范围,因为寄存器已经自带宽度了
指令地址
汇编地址
前面字句过程中也已经提到过,BIOS会把MBR这个512个字节放到内存条的0x7c00上.也就是说代码是从0x7c00开始的
刚才显存扯了一大堆,但是都是相当于往数据段放东西,这个数据段位于0xB8000
汇编地址:
编译器会把0x7c00开始的MBR代码作为一个独立的段处理
这样每条指令相对于段地址都有一个汇编地址(段内),在lst文件中可以看到这个汇编地址
1 | 行号 段内偏移 指令 汇编 |
第一条有意义的指令是在第6行,段内偏移量为0,指令内容是0xB800B8,翻译成汇编语言就是mov ax,0xb800
,由于这条指令长3字节,因此可以计算得到下一条指令的段内偏移量就是3
第七行指令的段内偏移量就是3
标号
如果要在汇编语言中写出跳转功能,就得从一个地址转到另一个地址.
怎么指定目标地址呢?让人手工计算目标地址然后写到jmp后面吗?显然人工计算指令地址不显示,比如一段千八行的汇编语言,有好多指令都是跳转或者调用指令,现在第一行的指令发现错了要改,假设原来两个字节的指令改成了一个字节,那么后面的所有指令地址就都减1,这就需要所有的跳转和调用指令重定位.让人手工一行一行改太慢了
于是就发明了标号这种东西,类似于一个变量符号,作用是记录一个地址,编译器会自动计算该标号的地址,在生成二进制码的时候自动把标号翻译成地址,根宏定义展开一样
比如主引导记录中有这么一句:
1 | infi: jmp near infi ;无限循环 |
infi标号的这条指令要跳转到infi标号处的指令,也就是自己跳转到自己的开始,这就形成了无线循环
标号还可以直接写到汇编指令里:
1 | dividnd dw 0x3f0 |
这里dividnd标号是0x3f0的地址,divisor是0x3f的地址
在编译时就像宏定义展开一样,mov ax,[dividnd]这里的标号就会自动转化成地址
声明并初始化数据
1 | number db 0,0,0,0,0 |
这里number是标号,db是定义字节的伪指令,后面定义了五个字节,都初始化为0,相邻两个字节之间用逗号隔开.6
这里定义的五个字节就在当前代码段中
但是标注的做法应该是代码数据分离,数据专门放在数据段中,使用段超越方法寻址
byte [ds:00]就表示数据段的最开始的字节
意思是从number开始的五个字节都是0
dw声明字,dd声明双字,dq声明四字
db,dw,dd,dq都是伪指令,当编译完成时就找不到影子了
编译完成后的样子,就是5个0
1 100 0000012E 0000000000 number db 0,0,0,0,0
用db声明的变量就必须在字节大小范围内,最小是0x00,最大是0xFF,如果比这还大就会截断高位
比如db 0xFF00,编译之后保留低字节0x00
段超越前缀
如果这样写mov al,[0x00]
意思是把内存中0x00位置的数据搬到al寄存器中,问题是,这个0x00是相对于哪个段的偏移量呢?
DS?CS?
默认的数据段是DS,因此如果不指明段超越前缀,就是访问的数据段
但是如果要访问ES段寄存器指向的段,应该怎么写呢?
mov al,[es:0x00]
这里es:就是段超越前缀
除法指令
除法指令div只有一个操作数作为除数
被除数默认是AX寄存器
8086允许两种类型的整数除法,
其一是16位被除数除以8位除数由于8086的寄存器宽度为8,因此一个AX就可以放开被除数.除完了商放到AL,余数放到AH
其二是32位被除数除以16位除数
此时除数刚好占用一个寄存器,但是被除数就需要放到两个寄存器中了,即DX放高16位,AX放低16位,除完了商放在AX,余数放在DX
处理器怎么区分两种除法应该用哪种呢?看除数的格式,如果除数是一个字寄存器,比如div cx,就得是32位除被数除以16位除数.如果是div cl,就是16位被除数除以8位除数
在主引导扇区代码的第37到47行是这样写的
1 | mov ax,number ;这里number是一个地址标号 |
主引导记录中一直重复该过程,意思是十进制分解被除数number标号地址的各位,放到0x7c00+number开始的五个字节上
1 | mov ax,number ;?????number??????? |
mov [0x7c00+number+0x00],dl
这就很诡异了,0x7c00是怎么来的?
偏移量换算
1 | mov [0x7c00+number+0x00],dl |
这里多了一个0x7c00,而不是直接mov [number+0x00],dl
这是因为,主引导扇区会被bios装载到内存的0x7c00处
此时CS=0x0000,IP=0x7c00
number是相对于mbr.asm这个文件的偏移量12E.
如果写mov [number+0x00],dl.实际上就是mov [0x12E],dl
放到了内存条上的0x12E位置,直接出了主引导区了
mbr.asm程序自始至终没有修改过cs的值,那么这个值就是BIOS设置的,一直是0x0000不变
显示分解出来的各个数位
分解的各个数位按照个十百千万的顺序放到0x7c00+number+0x00到0x7c00+number+0x04的五个字节中
对于万位上的数字,mbr程序是这样写的:
1 | mov al,[0x7c00+number+0x04] |
把万位数字拿出来放到al里,加上0x30转化成这个数字的ASCII码,然后放到es:0x1a位置,由于es早已设置为0xb800,即显存的起始位置,因此es:0x1a就是第一行的第1a个字符
在es:0x1b位置设置的是该字符的显示样式,设置为0x04意思是无背景不闪烁,红色前景
1 | mov al,[0x7c00+number+0x04] |
最后另外放了一个黑底不闪烁白字'D',意思是十进制的缩写(Decimal)
因此打印效果为
无限循环
在主引导记录中打印了number的数位分解之后,立刻进入无线循环
1 | infi: jmp near infi |
为啥要这样写呢?
因为如果不写无线循环,那么虚拟机就直接寄了,主引导记录并没有抛砖引玉地加载操作系统,这512个字节跑完之后,后面就全是0了,不存在指令了
段内跳转near
jmp near意思是跳转到当前段内的一个地址,也就是不改变CS段寄存器地址,只改变ip的地址,因此操作数只是infi这个标号,不需要cs:infi指定跳转到哪个段的infi
填充与魔数
前面的代码和数据都写完了也远远达不到512个字节,主引导记录需要最后两个字符是0xAA55才有效.因此中间的字节需要说一些废话填充
1 | times 203 db 0 |
连着定义了203个字节都置0,最后两个字节是0xAA55魔数
到此主引导记录才有效
Bochs调试
之前我们一直使用的都是virtualBox虚拟机,只能运行不能调试,唯一能够调试运行的虚拟机就是Bochs
bochs配置
将主引导记录写入虚拟磁盘vhd文件之后启动bochs,需要在开始菜单上进行一些设置
Disk&Boot
都2202年了,不会还有人用软盘吧
ATA,硬盘接口标准,PATA是以前的IDE接口,SATA是当前使用的接口标准
每个计算机有多个ATA通道,允许加多块磁盘
ATA channel 0 是必须的,将nobody.vhd挂到ATA channel0上即可
ATA channel 0
这里Cylinders,Heads等参数不是乱写的,需要填虚拟磁盘的实际情况,可以手工分析最后512个字节的几何参数,也可以使用教材配套软件fixvhdwr.exe
Boot Options
保存
前面两个设置完毕之后Save保存设置,下一次开Bochs的时候就不用重新设置了
保存一个bochsrc.bxrc文件,找一个牢稳的地方保存,比如bochs的根目录,或者虚拟机根目录,反正就是下一次启动bochs需要手动load该文件,能找到就行
bochs运行主引导记录
设置好之后就可以从bochs启动了
可以看到第一行打印出了"Label offset:00302D"
bochs调试
使用bochsdbg启动虚拟机,此时除了模拟虚拟机屏幕窗口之外,还有一个终端窗口用于调试
bochs在执行第一条指令前停下来等待调试命令
此时第一条指令位于f000:fff0,指令内容是
1 | jmp far 0xf000:e05b |
这个cs:ip指向的地址翻译成物理地址就是0xfe05b
而BIOS映射到内存的地址是[0xF0000,0xFFFFF],显然第一条指令跳转到了BIOS中
此后在这个调试终端中就可以输入命令进行调试了
断点
如果想在主引导记录开始下断点,由于主引导记录将被bios装载进入内存的0x7c00位置,
因此可以在调试终端中这样写
1 | b 0x7c00 |
这样就在0x7c00出下拉一个断点,处理器将在执行0x7c00的指令之前停下
执行到断点
在0x7c00下断点后,命令c,一直执行直到遇到断点
1 | (0) [0x000000007c00] 0000:7c00 (unk. ctxt): mov ax, 0xb800 ; b800b8 |
确实是主引导记录的第一条指令,将要通过ax中转设置es为显存基地址
分号后面的十六进制数是该条指令的机器码
查看寄存器
使用r命令查看所有通用寄存器内容
1 | <bochs:5> r |
此时rax存放的是0xAA55,估计是BIOS检查主引导记录有效性时留下的
rip程序计数器此时指向0x7c00
单步执行
单步执行之后,程序停在0x7c03位置,此处的指令是mov es,ax.
1 | <bochs:6> s |
那么此时第一条指令已经执行完毕了,ax的值应该是0xb800,r打印一下
1 | <bochs:7> r |
确实如此
查看段寄存器
单步执行第二条指令mov es,ax.
之后es应该被赋值为0xb800
使用sreg打印所有段寄存器
1 | <bochs:17> sreg |
sreg不但打印了段寄存器内容,还打印了段寄存器隐藏的高速缓存器的内容,这是ollydbg做不到的
高速缓存器还有ldtr,gdtr等寄存器都是在保护模式才会用到,现在不管
查看内存
再连续执行两步,写入显存映射区域
1 | (0) [0x000000007c05] 0000:7c05 (unk. ctxt): mov byte ptr es:0x0000, 0x4c ; 26c60600004c |
使用xp(examine memory at physical address),显示指定物理内存处内容
1 | xp (/数量) <物理地址> |
xp默认情况下一次显示一个双字,xp /n就是显示n个双字
使用xp命令观察0xb8000开始的第一个双字
1 | <bochs:24> xp 0xb8000 |
低两个字节是0x074c,正好对应
1 | mov byte ptr es:0x0000, 0x4c |
退出调试
命令q退出
初始化段地址
汇编地址和物理地址
汇编地址就是文件偏移量
实际加载到内存中时的地址不一定是汇编地址,有可能需要重定位基地址
mbr.asm被BIOS加载到内存的0x7c00处时,所有的汇编地址都需要再加上0x7c00才是其准确的物理地址,这是因为,此时的段寄存器是0,
如果想要在寻址的时候不写这个看上去很奇怪的0x7c00,可以将段寄存器改成0x7c0
1 | mov ax,0x7c0 ;设置数据段基地址 |
此后任何汇编地址A就代表了物理地址A+0x7c0*16
也就是准确的物理地址
实际上就是从0000:A+0x7c00转变成0x7c00:A
段间数据传送
在机组课本上这叫做"串操作指令"
movs/movsb/movsw
数据流DS:SI->ES:DI
由cld和std两条指令置正方向还是反方向
cld之后DF=0,正向拷贝,movsb每次si和di加一,movsw每次si和di加二
std之后DF=1,反向拷贝,movsw每次si和di减一,movsw每次si和di减二
rep 重复执行,每次cx-1直到cx减为0,cx相当于循环变量
1 | jmp near start;跳过数据区 |
将当前数据段的mytext标号开始处的(number-mytext)/2个字,正方向拷贝到0xb800:0000开始的内存区域中,
这恰好把mytext完全拷贝到显存区域
使用循环
loop循环
loop 标号,重复执行标号开始的代码,每次cx-1,如果cx降为0则跳出循环
由于loop指令位于循环的末尾,标号开始的代码无论如何和都要执行至少一次,因此实际上相当于do-while循环
1 | ;得到标号所代表的偏移地址 |
条件跳转循环
1 | ;显示各个数位 |
把number标号地址放到bx上,也就是分解number地址的十位得到的数字的地址
si作为偏移量,实际上bx[si]就构成了一个数组,si每次减1,直到0.
每次将[bx+si]放到al再加上0x30成为该数字的ASCII码,ah放上0x04即显示样式
然后ax整个两个字节被放到es:di上,就放到了显存中
然后di-2给下一个字符腾地方
每次si-1,直到减为-1是jns不满足跳转条件,跳出循环,执行下一条,即mov指令
跳出循环后的第一条指令是把0x0744这个字放到es:di上,即把'D'及其显示样式放到显存上
美元标记
NASM提供两个想美元符号的标记
$
:单个美元符号,表示当前行的行首汇编地址
$$
:双美元符号,表示当前段的起始汇编地址
1 | jmp near $;当前行首隐藏标号 |
jmp near $
就是跳转到本行行首,陷入死循环
times 510-($-$$) db 0
重复db 0这条指令若干次,
具体次数是510-程序到本指令之前已经有的字节数
也就是空闲的地方填满0
最后两个字节放0x55,0xaa,有效魔数
调试运行
还是在0x7c00下断点,然后c一直运行到该断点处停下
然后一直s单步执行到rep movsw指令
1 | <bochs:11> s |
此时用r命令观察一下各个寄存器的情况
1 | <bochs:12> r |
rcx=0x9000d,cx=0x000d,实模式下只用到cx寄存器,高位这个9没有作用
用sreg观察一下段寄存器
1 | <bochs:13> sreg |
es和ds已经设置好了
跳出rep,loop循环n
现在马上要执行rep movsw指令了
如果使用s则会进入循环,如果想步过循环到循环外面的第一条指令,使用n指令
1 | <bochs:14> n |
此时使用xp指令观察显存区域是否已经获得拷贝
1 | <bochs:15> xp /4 0xb8000 |
能够使用n跳出的循环有个特点,就是cx作为循环变量,啥时候跳出循环是很清晰的
如果是跳出循环的条件判断在循环体中,比如一个if条件判断,则n指令就行不通了,此时可以使用u+b+c三种指令实现
反汇编u
1 | u/<行数> |
从当前行开始反汇编若干行指令
在需要跳出条件循环时,可以反汇编若干行,然后在循环外面设置一个断点,然后使用c一直执行到断点停下