第6章 编写主引导扇区代码
该章节的程序就是在屏幕上输出:Label offset: number标号的十进制格式,可以在最后章节查看相关输出,这样就能比较清楚前面章节的内容到底要干啥了。
本章代码清单
作者提供了源码,可以查看配书源码和工具的c06文件夹。
欢迎来到主引导扇区
该小节主要说明了计算机启动需要读取主引导扇区的内容,主引导扇区最后2个字节是0x55和0xAA。
下图是编译后的二进制文件:
主引导扇区的地址就是 0 ~ 0x1FF,刚好512个字节。
那么为什么是是0x55和0xAA呢?书中没有介绍,我去查了下资料。关于0xAA和0x55-优快云博客 这篇文章从硬件层面说明了这个问题,感觉上会更合理一些。总结来看就是:
AA展开为10101010, 55展开为01010101,变成串行电平的话就是一个占空比为50%的方波, 这种方波在电路中最容易被分辨是否受干扰或者畸变。
注释
汇编语言的注释以 ; 开头。
; 这里就是注释
在屏幕上显示文字
显卡和显存
该小节主要介绍了显卡和显存的概念,以及显卡的两种基本工作模式:图形模式和黑白模式。
显卡(Graphics processing unit,GPU):为显示器提供内容,并控制显示器的显示模式和状态,有集成显卡和独立显卡。
显存(Video RAM,VRAM):显示存储器,控制屏幕上显示的像素明暗和颜色。
显卡的两种基本工作模式:
1.图形模式(Graphic Mode):现在的手机上,电脑看到的界面都是图形模式。
图形模式又分为两种:
- 黑白模式:将显存上的像素一一对应到屏幕上。0表示不亮,1表示亮。
- 真彩色模式:用3字节对应一个像素,所以可以显示 2^24=16777216 种颜色,称为真彩色。真彩色其实就是RGB(Red-Green-Blue)模式。
2.文本模式(Text Mode):专门用于显示字符的工作方式称为文本模式。例如:Linux命令行版本。
就是将显存的内容按一定规则映射到显示器上。
文本模式在加电自检后会初始化为80*25的文本模式:
为什么是80*25?这个历史原因,我搜了一下,stackexchange 这篇文章说的比较详细了。
- 640像素是自然且易于实现的屏幕宽度,每8位1个字节,所以宽度就是80。
- 为什么25行?
- 4:3是CRT显示器的比例,高度为480;
- 等宽字体的比例是0.43,高度就是8/0.43=18.6像素,约为19;
- 19*25=475 最接近480;
说到等宽字体,我编程和画图就是用等宽字体:Fira Code,感觉很不错。
显存映射到处理器的地址空间里:主要是为了加快访问速度。地址为:0xB8000 ~ 0xBFFFF。
初始化段寄存器
该小节介绍了如何设置 es段寄存器 的段地址。
访问内存使用逻辑地址,就是 段地址:偏移地址 的形式。要访问文本模式的显示缓冲区,就可以将段寄存器设置为 0xb800。
mov ax,0xb800 ;指向文本模式的显示缓冲区
mov es,ax
设置段地址后,后面访问对应的地址就可以直接使用es前缀:
设置段寄存器的通用语法格式:
mov 段寄存器,通用寄存器
mov 段等存器,内存单元
设置段寄存器不能直接用立即数,需要通用寄存器中转。
显存的访问和ASCII代码
ASCII码表:就是美国信息交换标准代码(American Standard Code for Information Interchange),就是将0~127总共128个二进制数字表示字符。
显存的访问:屏幕上的每个字符对应显存中连续的2字节。第一个字符时ASCII代码,后面一个是字符的显示属性(类似web前端的样式)。
其中:
- H 字母的显示属性是0x07=0000_0111,前景色RGB都为1,所以是白色。
- e 字母的显示属性是0x04=0000_0100,前景色R为1,所以是红色。
- 第一个 l 字母的显示属性是0x04=0000_0010,前景色R为1,所以是绿色。
- 第二个 l 字母的显示属性是0x04=0000_0001,前景色B为1,所以是蓝色。
显示字符
其中:
- byte表示传送1个字节。
- 传送的内容可以用ASCII码,也可以直接用字符代替。
类似byte,word表示1个字(2个字节),dword表示双字(4个字节)。
mov指令的格式
该小节介绍了mov指令的用法。
mov指令用于数据传送。
1.传送到寄存器:
mov 寄存器,寄存器
mov 寄存器,内存
mov 寄存器,立即数
其中:要表示内存位置的值,要用中刮号 [] 包裹起来。
示例:
mov ax,bx ;把寄存器bx的值传送到寄存器ax
mov ax,[0x02] ;内存0x02位置开始的2个字节传送到ax
;低端字节序:
; 0x02位置的值存储到al
; 0x03位置的值存储到ah
mov ax,0x02 ;把0x02这个值传送到ax
2.传送到内存:
mov 内存,寄存器
mov [byte|word|dword] 内存,立即数
其中:寄存器可以直接看出字节数,所以不用加 byte、word、dword修饰。
示例:
mov [0x02],ax ;把ax中的值传送到内存0x02开始的2个字节
;低端字节序:
; al存储到0x02位置
; ah存储到0x03位置
mov byte [0x02],0x03 ;把0x03这个值传送到内存0x02位置
mov word [0x02],0x1234 ;把0x1234这个值传送到内存0x02开始的2个字节
;低端字节序:
; 0x02位置存储0x34
; 0x03位置存储0x12
mov dword [0x02],0x12345678 ;把0x1234这个值传送到内存0x02开始的4个字节
;低端字节序:
; 0x02位置存储0x78
; 0x03位置存储0x56
; 0x04位置存储0x34
; 0x05位置存储0x12
本节习题
1.填空题
- 文本模式下显示缓冲区其实地址为:0xB8000,段地址需要除以0x10,即0xB800。
- 屏幕一共可以显示2000个字符,每个字符2个字节,偏移量从0开始,所以要从偏移量3998的位置开始,连续写入:H字符 和 0010_0111。
2.判断哪些不正确。
- A不正确,因为立即数是16个字节,al是8个字节。
- C不正确,因为ds和al寄存器长度不一致。
- D不正确,因为传输到内存单元,没有指定size。
- I不正确,因为ax和bl寄存器长度不一致。
- K不正确,因为内存之间不能互相传递。
显示标号的汇编地址
标号
该小节介绍了汇编地址(Assembly Postion)和标号(Label)。
汇编地址(Assembly Position):编译器会把代码整体上作为一个独立的段来处理,并从开始计算每条指令的地址,即汇编地址。
在编译源文件的时候可以通过 -l 参数可以生成 .lst 文件,.lst 文件可以查看到汇编地址。
例如编译一个打印hello world的汇编程序:
; 代码文件:demo.asm
; 功能:在屏幕上显示Hello world!
jmp near start
message db 'Hello world!'
start:
mov ax,0x7c0 ; 设置代码起始地址
mov ds,ax
mov ax,0xB800 ; 设置显示数据段
mov es,ax
mov cx,start-message ; 循环次数就是start标号地址和message标号地址的差
mov si,message ; 起始地址设置为message标号地址
mov di,0
show:
mov bl,[ds:si] ; 下面4行用来显示一个字节
mov byte [es:di],bl
inc di
mov byte [es:di],0x07
inc si
inc di
loop show
jmp $
times 510-($-$$) db 0
db 0x55,0xaa
编译后输出 .lst 文件:
nasm -f bin .\demo.asm -o .\demo.bin -l .\demo.lst
查看 .lst 文件:
每行汇编代码都有自己的长度,当前行汇编地址 加上 当前行汇编代码长度就是下一行的汇编开始地址。
汇编地址和内存偏移地址的关系:加载到内存后,独立一个段存在,按照汇编地址顺序写入对应的二进制指令,汇编地址和内存内偏移地址是一致的。
例如上例加载到内存段 0x6000 处。
标号(Label):在NASM汇编语言里,每条指令的前面都可以拥有一个标号,以代表和指示该指令的汇编地址。
例如上例的 start 和 show 就是两个标号。
书中用了自带的例子讲解标号,原理都是一样的。
如何显示十进制数字
该小节介绍了标号在程序中的应用和十进制的显示原理。
标号的应用:通过标号可以定位到标号定义的位置。
书中使用 number 标号作为例子,原理是一样的。书中的例子number标号的汇编地址是:0000012E
十进制的显示原理:就是一直除以10,得到余数,然后反向进行显示即可。
书中的例子就是将 number 标号作为十进制进行输出,大概的流程如下图:
书中是把余数存储到 number 标号定义的5个字节的内存空间中。这块的知识点下个章节就有介绍。
在程序中声明并初始化数据
该小节介绍了如何在程序中声明并初始化数据和伪指令(Pseudo Instruction)的概念。
声明并初始化数据:一般通过db、dw、dd、dq伪指令(Pseudo Instruction):
- db(Declare Byte):声明的单个内容空间为字节。
- dw(Declare Word):声明的单个内容空间为字(2字节)。
- dd(Declare Double Word):声明的单个内容空间为双字(4字节)。
- dq(Declare Quad Word):声明的单个内容空间为四字(8字节)。
db 0,0,0,0,0 ;声明5个字节,初始化为0
dw 0,0,0,0,0 ;声明5个字,初始化为0
dd 0,0,0,0,0 ;声明5个双字,初始化为0
dq 0,0,0,0,0 ;声明5个四字,初始化为0
上面例子中虽然初始都为0,但是占用的内存空间是不一样的。
声明的数据一般都要使用,所以需要提供一个标号进行指向,可以理解为C语言的指针。
number1 db 0,0,0,0,0 ;声明5个字节,初始化为0
number2 dw 0,0,0,0,0 ;声明5个字,初始化为0
number3 dd 0,0,0,0,0 ;声明5个双字,初始化为0
number4 dq 0,0,0,0,0 ;声明5个四字,初始化为0
伪指令(Pseudo Instruction):不是处理器指令,只是编译器提供的汇编指令,在编译成机器代码后就消失了。
上面的dd、dw、dd、dq都是伪指令,mov、jmp就是机器指令。
本节习题
找出代码片段的错误:
data1 db 0x55,0xf000,0x0f
data2 dw 0x38,0x20,0x55aa
data1 这样声明了一个 0xf000 2个字节的数字,编译会报错。
分解数的各个数位
该小节详细介绍了处理器是如何做除法的、0x7c00的问题和异或操作。
除法:包括16位二进制除8位二进制和32位二进制除16位二进制两种类型,总结来看就是这样:
0x7c00:为什么要加上0x7c00,因为程序是加载到内存的 0x0000:0x7c00 处。
异或(eXclusion OR):两个数从低到高按位比较,相同则结果为0,不同则结果为1。汇编中异或指令为 xor。
mov ax,0000_0000B ;B表示该数为二进制
xor ax,1111_0000B ;异或结果为:1111_0000B
书中使用了一个代码技巧,用 xor 的方式将1个寄存器的值置为0:
xor dx,dx ;将dx中的值置为0
和 mov dx,0 相比较:
- mov dx,0 的机器码是 BA 00 00;
- xor dx,dx 的机器码则是31 D2,更短;
- xor dx,dx 两个操作数都是通用寄存器,所以执行速度最快。
显示分解出来的各个数位
该小节主要介绍了字符的显示和加法指令。
字符显示:当计算完数字的个十百千万位,并存储在 number标号 开始的5个字节,此时内存映像:
因为存储时从低到高存储个、十、百、千、万位,所以显示要倒过来,这也是书中程序从0x04开始到0x00结束的原因了。
另外数字和数字字符时在计算机中存储的是不同的值,根据ASCII码表,可以知道数字和数字字符相差0x30,所以数字加上0x30就是数字字符了。
加法:非常简单的一个指令,用于两个操作数相加。
add dx,0x100 ;dx寄存器的值加上0x100。
使程序进入无限循环状态
该小节主要介绍了jmp指令和jmp使用near的操作数计算,通过jmp到自身实现无限循环。
jmp:用于跳转指定的地方执行。
例如:
jmp 0x0000:0x5000 ;跳转到 0x0000:0x5000 的地方执行
infi:
...
jmp near infi ;跳转到标号infi开始的地方执行
如果 infi 标号跟着就是 jmp 指令,那么就是无限循环:
infi:jmp near infi ;跳转到标号infi开始的地方执行
jmp使用near的操作数计算:这块感觉会比较难理解,分成两个部分理解会清晰了:①编译器计算跳转地址方式;②处理器运行时计算方式。编译器是根据处理器的方式计算的。
1.编译器计算跳转地址方式:
- 用标号处的汇编地址减去当前指令的下一条指令的汇编地址。
- 因为是near方式,计算结果只保留右边16位。
例如:
mov ax,0x01
start:
mov ax,0x01
mov bx,0x02
mov cx,0x03
jmp near start ;跳转到start
查看汇编地址:
F4FF的计算方式就是:
start的汇编地址 - (jmp指令的下一条地址)
= 0003 - (000C+0003)
= 0003 - 000F
= FFF4
采用低端字节序就是:F4FF。
2.处理器运行时计算方式。
- 用IP内的值加jmp指令操作数,IP值是指向下一条指令。
参考上例:
IP的值 + jmp指令操作数
= (000C+0003) + FFF4
= 000F + FFF4
= 0003
查看计算公式可以发现,编译器和处理器的方式刚好是反过来的。
完成并编译主引导扇区代码
主引导扇区有效标志
该小节主要介绍了主引导扇区的有效标志和times指令。
主引导扇区的有效标志:一个有效的主引导扇区,其最后2字节的数据必须是0x55和0xAA。
db 0x55,0xaa
如果采用dw版本,因为 INTEL处理器 采用低端字节序,所以应该这样写:
dw 0xaa55
times: 指令可以重复若干次。
times 203 db 0 ;分配1个字节,重复203次。相当于分配203个字节。
书中例子主要是为了占坑,确保 0x55 和 0xaa 写在主引导扇区的最后两个字节。
代码的保存和编译
进入代码目录:
$ nasm -f bin .\c06_mbr.asm -o .\c06_mbr.bin -l .\c06_mbr.lst
加载和运行主引导扇区代码
把编译后的指令写入主引导扇区
该小节介绍了如何写入主引导扇区。
启动虚拟机观察运行结果
程序的调试技术
开源的Bochs虚拟机软件
该小节介绍了Bochs虚拟机软件的下载和安装。
下载地址:http://sourceforge.net/projects/bochs/files/bochs/
Bochs下的程序调试入门
该小节初步介绍了Bochs的程序调试方法。
一开始通过jmp跳转到ROM-BIOS执行。
jmp far f000:e05b
为什么该条指令的地址是 0x00000000FFFFFFF0? 书中给出了说明,总结来看就是希望将该指令放在内存的最高位。
Bochs调试功能:
1.s: step 单步执行
2.b: b 0x7c00 设置中断
3.c: continue 一直执行
4.r: 查看寄存器内容
- 这8个32位寄存器分别是EAX、EBX、ECX、EDX、ESI、EDI、EBP和ES
- 同时,指令指针寄存器IP也做了扩展,达到32位,即EIP。
- 在64位处理器上,这些寄存器再次被扩展,达到了64位,即RAX、RBX、RCX、RDX、RSI、RDI、RBP、RSP和RIP
- 64位的x86处理器还新增了8个64位的通用寄存器R8、R9、R10、R11、R12、R13、R14和R15。
5.sreg: Segment Register,查看段寄存器
在32位和64位处理器中,除了段寄存器CS、SS、DS和ES,还新增了两个段寄存器FS和GS
在32位和64位处理器中,以上6个段寄存器都依然是16位的,但都额外增加了一个不可访问的部分,叫作段描述符高速缓存器。
6.xp: eXamine memory at Physical address,查看物理内存内容。
查看0xb8000开始的2个双字(8字节)的内容:
xp/2 0xb8000
7.q: quit,退出的意思。
不用quit退出,下次再启动会报一个占用,再次重启就可以了。