与计算机经常打交道的你,是否曾好奇系统是怎样启动的?习惯于高级语言的你,是否在寻找一种新奇的汇编语言学习经历?
如果答案是yes,请随我一起看完这篇文章吧!
本文基于8086处理器[1],分为三个部分,首先介绍十六进制的概念,阐明其使用上的必要性;接着引入指令和指令集,帮助读者掌握内存寻址原理;最后通过编写主引导扇区NASM[2]汇编,实现一个开机显示的效果。
十六进制
如果你认为自己对进制转换已经十分熟悉,那么完全可以跳过这一节。
进制概念
•基数:计数法用来表示的数字符号的个数。例如,二进制可用来表示的数字符号有0和1,基数就是2
。•权:每个数字在不同的位置上(个位、十位、百位……)具有不同的基数放大倍数。用公式表示为 基数n-1 (n表示从右数第几位),就是权。•1B、10D、13H:B(Binary
)表示二进制、D(Decimal
)表示十进制、H(Hexadecimal
)表示十六进制。
十进制和其他进制的换算规则
十进制使用基数转换
将十进制数除去要转换成的进制的基数,存余再取商继续除去基数,反复直到商为0。
然后将余数从下到上的顺序取回表示成从左到右的数字,就完成了进制间的转换。
以10D
转为二进制为例。二进制基数是2:
•10 2 = 5 ... 0•5 2 = 2 ... 1•2 2 = 1 ... 0•1 2 = 0 ... 1
将余数从下到上取回,可以表示为1010B
。
其他进制使用权换算
而其他进制转换成十进制,则是通过将每个位置上的数乘对应进制的权,求和结果就是其他进制到10进制转换。
例如1010B
转10进制,从左往右的权分别是:23、22、21、20
所以结果就是1 * 8 + 0 * 4 + 1 * 2 + 0 * 1 = 10,即10D
。
十六进制的必要性
掌握了进制换算的规则,我们就可以从十进制0~15的范围内,分别计算出对应的二进制和十六进制的结果:
二进制 | 十进制 | 十六进制 |
0000 | 0 | 0 |
0001 | 1 | 1 |
0010 | 2 | 2 |
0011 | 3 | 3 |
0100 | 4 | 4 |
0101 | 5 | 5 |
0110 | 6 | 6 |
0111 | 7 | 7 |
1000 | 8 | 8 |
1001 | 9 | 9 |
1010 | 10 | A |
1011 | 11 | B |
1100 | 12 | C |
1101 | 13 | D |
1110 | 14 | E |
1111 | 15 | F |
仔细观察,可以发现:
每四位二进制可以和任何一位十六进制互相转换。
也就是说,根据这张表,我们可以将一长串二进制按从右往左每四位为一组迅速转换为十六进制,反之亦然。
例如1A3BC4H
,快速分割为:
1_A_3_B_C_4。
借助上表,迅速得出,对应的二进制是:
0001_1010_0011_1011_1100_0100
也就是000110100011101111000100B
众所周知,这世间绝大部分计算机是以二进制位基础的,任何运行其上的程序,最终到达底层都会转换为人类难以阅读的101010101二进制。
而借助使用十六进制,一方面相对二进制提升了其可读性,阅读起来简短;另一方面又可以根据上表迅速转化成二进制结果。
所以在手写汇编的阶段,对人来说就显得十分有必要。
内存寻址
前置概念
•寄存器:处理器在操作过程中将数据临时存储的电路。4位、8位、16位、32位和64位寄存器,表示可以容纳的数据容量比特数(bit)。而本文用到的8086处理器是16位的寄存器。•单位换算:16bit = 2byte = 1word
。•高低位:从右到左为字节上的每一位编号。0是最低位;最左边是最高位。•字:处理器处理数据的单位。能同一时间处理的二进制位数为一个字长,即一个寄存器的位数。对于8086来说是16个二进制位的长度,即2个字节。编号是0 ~ 15。0 ~ 7是低字节,8 ~ 15是高字节。•双字:32位寄存器可以存放4个字节(byte),编号0 ~ 31。0 ~ 15是低字节,16 ~ 31是高字节。
内存储器
内存按字节来组织,单词访问的最小单位为1字节。
内存地址采用十六进制表示法:对于8086处理器来说,第一个字节地址为0000H
,第二个字节地址为0001H
……以此类推。
试着思考如下问题:
设一个内存的容量是65536字节,它的地址范围是多少?
分析:
•设当前字节在内存中为第n+1个,当n=0,也就是第一个字节时,地址为0000H;•当n=1,也就是第二个字节时,地址为0001H,正对应10到16进制的转换。•因此第65536个字节,也就是n=65535时,对应的地址就是FFFFH。
所以答案是0000H~FFFFH
。
内存可以按字节、字、双字和四字进行访问,称作访问的字长。
指令和指令集
addr +---------------+
FFFF | |
| |
| |
≈ …… ≈
| |
| |
-----------------
0040 | 10 |
-----------------
003F | 02 |
-----------------
| |
| |
≈ …… ≈
| |
-----------------
000C | F4 | <----------- 停机
-----------------
000B | 00 |
-----------------
000A | 3F |
-----------------
0009 | A3 |
-----------------
0008 | D8 |
-----------------
0007 | 01 |
-----------------
0006 | 00 | <-----------+
----------------- | 表示数据在内存地址003FH,非立即数 ------+
0005 | 3F | <-----------+ |
----------------- | 从003FH地址取出一个字(1002H),放到RB寄存器中
0004 | 1E | <----------- 读取内存地址 |
----------------- |
0003 | 8B | <----------- 操作码8B,是传送指令,指出传送到寄存器RB --+
-----------------
0002 | 00 | <-----------+
----------------- | 立即数,非指令,表示数据为005DH ------+
0001 | 5D | <-----------+ |
----------------- | 将005DH传入RA寄存器中
0000 | B8 | <----------- 操作码B8,是传送指令,指出传送到寄存器RA -----+
+---------------+
复制
•指令:一般由操作码和操作数构成,指令长度不定。•立即数:紧跟在操作码后面,可以立即从指令取得的操作数。•大小端:以字的方式访问内存地址,一次可以读取到两个字节。根据处理器的规定,分为低端字节序和高端字节序。低端字节序规定高字节位于高地址,低字节位于低地址,而后者相反。•复杂指令:给出字存储的内存地址,访问取出值后送入寄存器。(相当于高级语言里的指针)•指令和数据分开存放:指令中混杂非指令数据会导致处理器不能正常工作。存放指令的区域叫代码区,存放数据的区域叫数据区。
8086处理器
8086处理器内部有8个16位通用寄存器,分别为AX
、BX
、CX
、DX
、SI
、DI
、BP
、SP
。
“通用”的含义
可以互相传送数据,进行算术逻辑运算;也可以和内存单元进行16位数据传送或算术逻辑运算。
前四个寄存器各自可以拆分成两个8位寄存器来使用,总共可以提供8个8位寄存器AH
、AL
、BH
、BL
、CH
、CL
、DH
和DL
。
注:H是高位(High),L是低位(Low)。
一个数据存在16位寄存器中,通常是小端(Little Endian)读取方式。
二进制数据(从右数)编号0到7的前8位存在L寄存器,编号8到16后8位存在H寄存器。
分段机制
处理器在内存中按顺序取指令,只要每条指令正确无误,就能准确知道下一条指令的地址。
所以完成某个工作的指令必须集中存放在某个位置,形成代码段。
某个工作中程序操作的大量数据也应该集中存放起来,形成数据段。
当然,数据段和代码段的划分是逻辑上的。
由于程序运行时在内存中被加载的位置完全是随机的,所以指令存在的地址不能是绝对内存地址,否则下次载入内存的时候无法复现指令,也就是重定位。
内存分段后,每段内的存储单元的地址可以相对于所在的段开始处的距离,例如距离0、1、2、3、4、5,叫做偏移地址。
而考虑到在取得程序运行起始的内存地址后,把它当成从0开始,进行相对读取指令地址,程序处理起来就会变得十分轻松明朗。
因此人们想出了使用逻辑地址运行指令的思路:
•逻辑地址:使用段地址:偏移地址
来表示内存单元的地址。•代码段寄存器(Code Segment
, CS):指向代码段起始地址。•数据段寄存器(Data Segment
, DS):指向数据段起始地址。•数据暂存器:进行数据传送或算术逻辑运算时,结果返回到寄存器之前,使用的中转寄存器。•指令预取队列:预先访问内存取出指令流进行排队等待解码和执行。•指令指针寄存器(Instruction Pointer
, IP):只和CS一起使用,指向段内偏移,和CS共同形成逻辑地址。
8086的四个段寄存器
分别为CS、DS、ES(Extra Segment
)和SS(Stack Segment
)。ES也是数据段寄存器,用于指向另一个数据段。
扩大内存寻址容量
16位的段地址和16位的偏移地址相加,只能形成16位的物理地址,这远远无法满足人们的需求。
因此Intel想办法在8086上实现20位的物理地址访问能力:
将段寄存器的内容左移4位,形成20位的段地址,然后同16位的偏移地址相加,得到20位的物理地址。
以逻辑地址F000H:052DH
为例进行分析:
•其段地址为F_0_0_0H,转化为二进制位就是1111_0000_0000_0000B;•将其左移四位,形成1111_0000_0000_0000_0000B,形成十六进制的F_0_0_0_0H;•这相当于乘十六进制的10,或者十进制的16。
F0000H
加上偏移地址052DH
形成了20位的物理地址F052DH
。
字节对齐
8086处理器的段寄存器起始地址必须是16
的倍数,才能表示成一个偏移地址为0000H的逻辑地址,这被称为按16字节对齐。
段的最大长度
由于偏移地址是16位的,所以表示范围从0000H到FFFFH。
所以最多表示65536个字节(FFFFH=65535D),即最大长度为64KB。
换言之,16位寄存器的最大寻址能力是64KB。
只要满足“物理地址位于其段的64KB范围内”的任意段都可以访问到该物理地址。
也就是说一个物理地址对应着多个逻辑地址。
•1KB = 1024Byte•1MB = 1024KB•1GB = 1024MB
而以此类推,32位寄存器的最大寻址能力(00000000H ~ FFFFFFFFH)约等于4GB。
这也是为什么人们总说“32位操作系统最多只能识别4GB内存”。
编写主引导扇区
准备工具
笔者使用的操作系统为OSX,如果读者使用的是Windows,则部分工具需要自行寻找代替(参考《x86汇编:从实模式到保护模式》[3]即可):
•nasm编译器:使用Homebrew下载。•虚拟机:使用VirtualBox[4]。•VHD文件:使用《x86汇编:从实模式到保护模式》书中提供的虚拟硬盘。需要的读者可以联系笔者获取或者自行搜索下载。•虚拟硬盘读写工具:使用开源的qiaojinxia/VhdWriter[5]。•机器码阅读工具:使用开源的ridiculousfish/HexFiend[6]。•IDE:使用GoLand[7]编辑器及插件NASM Assembly Language[8]。
工具使用说明
# 编译源文件到二进制
nasm -f bin /path/to/xxx.asm -o /path/to/xxx.bin
# 将二进制文件写入虚拟硬盘
./main vhd path/to/xxx.vhd -n=0 -w=/path/to/xxx.bin
复制
前置概念
•内存:动态随机访问存储器(DRAM),访问任何一个内存单元的速度和地址无关。•BIOS:只读存储器(ROM),固化了开机时要执行的指令(主要是硬件的诊断、检测和初始化)。•硬盘:外存储器之一。•磁头:在同一个轴上拥有许多盘片,每个盘片拥有上下两个磁头,编号为0、1、2、3……以此类推。•磁道:磁头距离圆心每步进一次,都可以形成一个圆圈,就是磁道,依次编号为0、1、2、3……。•柱面:寻道过程是机械动作,需要尽量减少对磁头的移动,所以访问数据优先按照磁头0读第一个盘上面的磁道0、磁头1读第一个盘下面磁道0、磁头2读第二个盘上面的磁道0……读完全部盘面的磁道0后再返回磁头0读第一个盘的磁道1——这一访问过程中形成的圆柱就是柱面,依次编号为0、1、2、3……。•扇区:磁道上可以进行分段,呈现扇形,就是扇区。通常为63个。依次编号为1、2、3、4……。•主引导扇区:0面0道1扇区。
注:需要特别注意,扇区是从1开始编号的。
8086启动过程
执行复位(RESET),使代码段寄存器(CS) 内容为 0xFFFF,其他寄存器内容为 0x0000。
地址线分配内存空间给设备,其中顶部64KB分配给ROM,范围0xF0000~0xFFFFF;较低端的640KB分配给DRAM,范围0x00000~0x9FFFF;中间的一部分分配给其他设备。
复位时,CS(0xFFFF)和IP(0x0000)形成物理地址0xFFFF0,就是计算机取的第一条指令的物理地址,正好是ROM-BIOS的范围内。
ROM-BIOS执行完本身的指令后,将硬盘主引导扇区内容加载到0x0000:0x7C00
,执行跳转指令到达该位置执行。
主引导扇区继续引导计算机从硬盘的其他部分读取更多的内容加以执行。
编写主引导扇区代码
根据上面所述的启动过程,我们只需要在硬盘的主引导扇区注入我们写的汇编代码编译成的二进制机器码,就可以在计算机启动后引导计算机执行自定义的指令。
一些指令、概念和要求说明
•概念:•汇编地址:指令在内存段内的偏移地址,由编译期间计算生成。•标号:在NASM汇编中,每条指令前面都可以拥有一个标号,代表该指令的汇编地址,在编译过程中会被替换成汇编地址。
# 这里的infi就是标号
infi: jmp near infi
# 可以不加冒号
infi jmp near infi
# 也可以独占一行
infi:
jmp near infi
复制
•要求:•Intel处理器不允许将一个立即数传送到段寄存器:必须先将立即数传到通用寄存器,然后从通用寄存器传到段寄存器。•相同数据宽度:前文提过,通用寄存器可以当做一个16位寄存器或两个8位寄存器来使用,其中16位或8位即不同的数据宽度。•修饰关键字:包括byte
和word
。在数据宽度不明确的情况下用于指定目的操作数的宽度。
# 源操作数字面值会被编译器转为ASCII码0x4C
# 可以解释为8位的0x4C也可以解释为16位的0x004C,宽度不明确
# 而目的操作数是内存地址[0x00],它的宽度也不明确,可以是字单元或字节单元
# 因此需要使用byte关键字来修饰指定以8位的宽度
mov byte [0x00], `L`
# 因为源操作数为寄存器bh,即16位bx寄存器拆分为两个8位寄存器bh和bl的其中之一
# 所以数据宽度明确为8位,不需要修饰关键字
mov [0x02], bh
# 目的操作数为寄存器ax,数据宽度明确为16位
mov ax, [0x06]
复制
•指令:•mov指令:用于数据传送,格式为mov 目的操作数 源操作数
。
目的操作数必须是通用寄存器或内存单元;源操作数可以是和目的操作数具有相同数据宽度的通用寄存器和内存单元,或者立即数。
mov不允许目的操作数和源操作数同时为内存单元。mov不影响源操作数内容。
•db指令:伪指令,声明字节(Declare Byte),跟在后面的操作数占一个字节长度;如果声明多个数据,各个操作数需要以逗号隔开。•dw指令:伪指令,声明字(Declare Word),其余同上。•dd指令:伪指令,声明双字(Declare Double Word),其余同上。•dq指令:伪指令,声明四字(Declare Quad Word),其余同上。•div指令:除法指令,有两种类型。•16位二进制除8位二进制:被除数必须事先传送到AX
寄存器,除数可以由8位通用寄存器或者内存单元提供。执行后商在寄存器AL中,余数在寄存器AH中。•32位二进制除16位二进制:因为16位处理器无法直接提供32位被除数,所以要求被除数的高16位在DX中,低16位在AX中。
# 使用标号dividnd声明一个字数据,被除数0x3f0即十进制1008
dividnd dw 0x3f0
# 使用标号divisor声明一个字节数据,除数0x3f即十进制63
divisor db 0x3f
# 事先把被除数从内存单元传送到ax寄存器
mov ax, [dividnd]
# 执行除法指令,除数由内存单元提供,指定数据宽度为字节
div byte [divisor]
复制
•xor指令:异或指令,常用于将寄存器清零。对比“把立即数0传送到寄存器”,xor机器码较短,且两个操作数都是通用寄存器,执行速度最快。
两张表
•ASCII码:阅读方式为水平高3位比特加上垂直低4位比特,例如数字5的ASCII码为 011 0101。
二进制 | 000 | 001 | 010 | 011 | 100 | 101 | 110 | 111 |
0000 | NUL | DLE | SPACE | 0 | @ | P | ` | p |
0001 | SOH | DC1 | ! | 1 | A | Q | a | q |
0010 | STX | DC2 | " | 2 | B | R | b | r |
0011 | ETX | DC3 | # | 3 | C | S | c | s |
0100 | EOT | DC4 | $ | 4 | D | T | d | t |
0101 | ENQ | NAK | % | 5 | E | U | e | u |
0110 | ACK | SYN | & | 6 | F | V | f | v |
0111 | BEL | ETB | ' | 7 | G | W | g | w |
1000 | BS | CAN | ( | 8 | H | X | h | x |
1001 | HT | EM | ) | 9 | I | Y | i | y |
1010 | LF | SUB | * | : | J | Z | j | z |
1011 | VT | ESC | + | ; | K | [ | k | { |
1100 | FF | FS | , | < | L | \ | l | | |
1101 | CR | GS | - | = | M | ] | m | } |
1110 | SO | RS | . | > | N | ^ | n | ~ |
1111 | SI | US | / | ? | O | _ | o | DEL |
•80x25文本模式颜色表:由KRGBIRGB
组成,其中前四位为背景色,后四位为前景色。
R | G | B | 背景色 | 前景色 |
K=0时不闪烁,K=1时闪烁 | I=0时正常亮度|I=1时高亮 | |||
0 | 0 | 0 | 黑 | 黑|灰 |
0 | 0 | 1 | 蓝 | 蓝|浅蓝 |
0 | 1 | 0 | 绿 | 绿|浅绿 |
0 | 1 | 1 | 青 | 青|浅青 |
1 | 0 | 0 | 红 | 红|浅红 |
1 | 0 | 1 | 品红 | 品红|浅品红 |
1 | 1 | 0 | 棕 | 棕|黄 |
1 | 1 | 1 | 白 | 白|亮白 |
在显示器上显示白底黑字,即背景黑,前景白,二进制为0000 0111
,十六进制为0x07
当显示器一片漆黑时,显示的是黑底白字的空白字符,即0x07 0x20
。
文本模式
地址线将0xB8000~0xBFFFF分配给显卡设备的内存(即显存),用于文本模式。
在该模式下,屏幕上可以显示25行,每行80个字符,共2000个字符。
使用逻辑地址访问显存
由于显存的起始物理地址是0xB8000,所以它的段地址可以看成0xB800,它的起始逻辑地址就是0xB800:0x0000
。
前文提过,数据寄存器分为DS和ES,我们将显存所在的内存段地址(0xB800)存到ES中。这样访问显存可以通过 段超越前缀"es:" ,例如[es:0x00]
,的方式进行。
在屏幕上显示字符
•显示器上的字符为ASCII编码,所以显示时需要使用ASCII的二进制或十六进制表示。比如数字5,不应该在显存里存储0x05
,而是ASCII表上的011 0101
即0x35
。•屏幕上的每个字符对应着显存中的两个连续字节,前一个是字符在ASCII的代码,后一个是字符的显示属性,即颜色表中的8位二进制。
文本模式的屏幕右下角偏移地址是多少?
每个字符对应着显存中的两个连续字节,也就是1个字符占用了2byte。
而文本模式一共有80x25=2000个字符,所以占用了4000byte。
屏幕右下角即最后一个字符,它的偏移地址为初始偏移地址加3998byte长度后的结果,也就是3998D
,转换为十六进制就是0xF9EH
。
在熟悉上面这些概念后,我们可以写出下面这段代码,效果是:
在屏幕中央显示带有颜色的"HELLO YUCHANNS"
# Intel处理器不允许将一个立即数传送到段寄存器
# 必须先将立即数传到通用寄存器
mov ax, 0xb800
# 然后从通用寄存器传到段寄存器
mov es, ax
# 指定数据宽度为字节,把字面值H的8位ASCII码100 1000B
# 即0x48H传送到es寄存器的段内偏移地址0x7C0上
# 这里使用了段超越前缀指定使用的寄存器
mmov byte [es:0x7C0], 'H'
# 然后在相邻的下个字节处传送颜色信息指令
# 继续以此类推
mov byte [es:0x7C1], 0x04
mov byte [es:0x7C2], 'E'
mov byte [es:0x7C4], 'L'
mov byte [es:0x7C6], 'L'
mov byte [es:0x7C8], 'O'
mov byte [0x7CA], ' '
mov byte [0x7CC], ' '
mov byte [0x7CE], 'Y'
mov byte [0x7CF], 0x03
mov byte [0x7D0], 'U'
mov byte [0x7D2], 'C'
mov byte [0x7D4], 'H'
mov byte [0x7D6], 'A'
mov byte [0x7D8], 'N'
mov byte [0x7DA], 'N'
mov byte [0x7DC], 'S'
jmp $%
复制
然后通过工具写入虚拟硬盘VHD,启动VirtualBox,就可以得到由我们自己亲手写的主引导扇区启动代码:

当然,如果将该二进制文件录进一个物理主板,也可以达到同样的效果。
这就是实现一个操作系统的基础前置要求。
上述代码和一些相关工具也可以在笔者的仓库yuchanns/x86-asm[9]找到。
如果你觉得这篇文章不错,有更多心得想和笔者交流,欢迎添加我的微信,备注【来自 代码炼金工坊】!

引用链接
[1]
8086处理器: https://en.wikipedia.org/wiki/Intel_8086[2]
NASM: https://www.nasm.us/[3]
《x86汇编:从实模式到保护模式》: https://book.douban.com/subject/20492528/[4]
VirtualBox: https://www.virtualbox.org/[5]
qiaojinxia/VhdWriter: https://github.com/qiaojinxia/VhdWriter[6]
ridiculousfish/HexFiend: https://github.com/ridiculousfish/HexFiend[7]
GoLand: https://www.jetbrains.com/go/[8]
NASM Assembly Language: https://plugins.jetbrains.com/plugin/index?xmlId=com.nasmlanguage[9]
yuchanns/x86-asm: https://github.com/yuchanns/x86-asm