在前面几节中,我们总是通过ROM-BIOS从硬盘的主引导扇区读取一段程序并加载到内存运行,但是处理器是如何访问硬盘呢?这是一个值得我们思考的问题
OK,我们先看一张图
所有这些和计算机主机连接的设备,叫做外围设备,也叫IO设备。IO设备的控制与访问是通过总线技术将多个设备挂载在Bus上,然后通过输入输出控制设备集中器(I/O Controller Hub,ICH)芯片连接不同的总线,并协调各个I/O接口对处理器的访问。
I/O端口与端口访问
外围设备和处理器之间的通信是通过相应的I/O接口进行的。具体地说,处理器是通过端口(Port)来和外围设备打交道的。本质上,端口就是一些寄存器,类似于处理器内部的寄存器。不同之处仅仅在于,这些叫做端口的寄存器位于I/O 接口电路中。
端口是处理器和外围设备通过I/O接口交流的窗口,每一个I/O接口都可能拥有好几个端口,比如,连接硬盘的PATA/SATA 接口就有命令端口、状态端口、参数端口和数据端口。
- 命令端口:向该端口写入0x20 ,表明是从硬盘读数据;写入0x30 ,表明是向硬盘写数据
- 状态端口:输出硬盘工作工作状态数据或操作执行情况
- 参数端口:指定硬盘读写的扇区数量,起始的逻辑扇区号
- 数据端口:数据传输
端口在不同的计算机系统中有着不同的实现方式。在一些计算机系统中,端口号是映射到内存地址空间的。而在另一些计算机系统中,端口是独立编址的,不和内存发生关系。如下图所示,在这种计算机中,处理器的地址线既连接内存,也连接每一个I/O接口。
在这种模式下,处理器还有一个特殊的引脚M/IO#,==#表示低电平有效。当处理器访问内存时,M/IO#引脚呈高电平,和内存相关的电路就会打开;当处理器访问I/O端口时,M/IO#引脚呈低平,内存电路被禁止。处理器发出的地址和M/IO#==信号一起用于指定某个I/O 接口。
NOTE 重点说这个独立编址
硬盘访问
那现在来说独立编址下的硬盘访问,硬盘访问通过PATA/SATA接口实现,每个PATA和SATA接口分配了8个端口。ICH 芯片内部通常集成了两个PATA/SATA接口,分别是主硬盘接口和副硬盘接口。主硬盘接口分配的端口号是0x1f0~0x1f7
,副硬盘接口分配的端口号是0x170~0x177
。
- 0x1f0 16位数据端口
- 0x1f1 错误状态端口
- 0x1f2 操作扇区数量端口
- 0x1f3~0x1f6 起始扇区端口
- 0x1f7 命令端口
端口的访问不能使用类似于mov 这样的指令,取而代之的是in和out指令。
in al, dx
in ax, dx
;in 指令的目的操作数必须是寄存器AL或者AX
;in 指令的源操作数应当是寄存器DX
;in 指令不允许使用别的通用寄存器,也不允许使用内存单元作为操作数
out 0x37, al ;写0x37端口 - 8位端口
out 0xf5, dx ;写0xf5端口 - 16位端口
out dx, al ;端口号在dx寄存器中 - 8位端口
out dx, ax ;端口号在dx寄存器中 - 16位端口
;out 指令的目的操作数可以是8 位立即数或者寄存器DX
;out 指令的源操作数必须是寄存器AL或者AX
硬盘
硬盘读写的基本单位是扇区,读写以扇区为单位进行,使得主机和硬盘之间的数据交换是成块的,所以硬盘是典型的块设备。
访问流程
- 设置读取扇区数量
mov dx, 0x1f2 ;0x1f2端口
mov al, 0x01 ;1个扇区
out dx, al
;如果写入的值为0,则表示要读取256个扇区。每读一个扇区,这个数值就减一。
- 设置起始LBA 扇区号
扇区的读写是连续的,因此只需要给出第一个扇区的编号就可以了。28 位(LBA28模式)的扇区号太长,需要将其分成4 段,分别写入端口0x1f3、0x1f4、0x1f5 和0x1f6 号端口。其中,0x1f3 号端口存放的是0~7 位;0x1f4 号端口存放的是8~15 位;0x1f5 号端口存放的是16~23 位,最后4 位在0x1f6 号端口。
;起始扇区号为2
mov dx, 0x1f3
mov al, 0x02
out dx, al ;LAB[7:0]
inc dx ;0x1f4
mov al, 0x00
out dx, al ;LAB[15:8]
inc dx ;0x1f5
out dx, al ;LAB[23:16]
inc dx ;0x1f6
mov al, 0xe0 ;高4位用于指定模式等,低四位为0
out dx, al
;每个PATA/SATA接口允许挂接两块硬盘,分别是主盘和从盘。
;0x1f6端口的低4位用于存放逻辑扇区号的24~27位,第4位用于指示硬盘号,0表示主盘,
;1表示从盘。高3 位是“111”,表示LBA模式
- 读硬盘
mov dx, 0x1f7 ;0x1f7端口 - 8位端口
mov al, 0x20 ;读命令
out dx, al
- 等待读操作完成
端口0x1f7 既是命令端口,又是状态端口。发送读写命令后,它将0x1f7 端口的第7位置1,表明忙。一旦硬盘系统准备就绪,它将此位清零,并将第3 位置1,请求主机发送或者接收数据
;检测硬盘是否准备数据就绪
mov dx, 0x1f7
__wait:
in al, dx
and al, 0x88 ;取端口0x1f7的第3位和第7位
cmp al, 0x08
jnz __wait ;al不等于0x08跳转
- 连续取数据
0x1f0是硬盘接口的数据端口,一旦硬盘控制器准备数据就绪,从这个端口写入或者读取数据。
mov cx, 256 ;读取字数
mov dx, 0x1f0 ;16位数据端口
__read:
in ax, dx ;读取数据
mov [bx], ax ;数据传送到bx指向的偏移地址
add bx, 2
loop __read ;cx不为0跳转
完整读取扇区子程序
;-----------------------------------------------------------------------
;从硬盘读取一个逻辑扇区
;输入:DI:SI=起始逻辑扇区号
;DS:BX=目标缓冲区地址
read_hard_disk_0:
push ax
push bx
push cx
push dx ;保存现场
mov dx,0x1f2
mov al,1
out dx,al ;读取的扇区数
inc dx ;0x1f3
mov ax,si ;si在被read_hard_disk_0被调用前已被初始化
out dx,al ;LBA地址7~0
inc dx ;0x1f4
mov al,ah
out dx,al ;LBA地址15~8
inc dx ;0x1f5
mov ax,di
out dx,al ;LBA地址23~16
inc dx ;0x1f6
mov al,0xe0 ;LBA28模式,主盘
or al,ah ;ah = 0x00(LBA地址27~24) al = 0xe0
out dx,al
inc dx ;0x1f7
mov al,0x20 ;读命令
out dx,al
__waits:
in al,dx
and al,0x88
cmp al,0x08
jnz __waits ;不忙,且硬盘已准备好数据传输
mov cx,256 ;总共要读取的字数
mov dx,0x1f0
__readw:
in ax,dx
mov [bx],ax ;目标内存
add bx,2
loop __readw
;恢复现场
pop dx
pop cx
pop bx
pop ax
ret ;返回指令 返回时自动恢复IP寄存器的值
用户程序
通过前面的内容,我们知道ROM-BIOS将读取主引导扇区的内容,并将它加载到内存地址0x0000:0x7c00处,然后通过jmp指令跳转到那里执行。通常,主引导扇区的程序的功能是从硬盘的其他部分读取更多的内容加以执行,像Windows这样的操作系统就是一步一步运行起来的。
现在,假如有一个段分配如下图所示的程序存储在硬盘中,等待处理器加载运行。
用户程序头部
如上图所示,通常应用程序的头部需要包含一下信息:
- 用户程序尺寸
- 应用程序的入口点
包括段地址和偏移地址。加载器并不清楚用户程序的分段情况,更不知道第一条要执行的指令在用户程序中的位置。因此,必须在头部给出第一条指令的段地址和偏移地址,这就是所谓的应用程序入口点(Entry Point)。 - 段重定位表
程序加载到内存后,段的地址必须根据加载地址重新确定,而在用户程序头部的段重定位表,将帮助加载器确定每个段在用户程序内的位置 - 表项数
加载器会根据这些信息进行扇区读取、段重定位并跳转到指定入口运行程序
该程序涉及到的汇编指令如下:
- SECTION/SEGMENT
定义段指令
格式:SECTION 段名称
说明:一旦使用SECTION定义段,后面的内容就都属于该段,直至出现另一个段的定义
子句:align=
: 指令某个段的汇编地址的对齐方式
vstart=
:段中元素的汇编地址的计算方式
NOTE
Intel 处理器要求段在内存中的起始物理地址起码是16 字节对齐
使用SECTION定义段时,段中元素的汇编地址是从整个程序开头计算的,当使用vstart=
子句后,段中元素的汇编地址是从段开头计算的 - section.段名称.start
取得该段相对于整个程序开头的汇编地址
;文件说明:用户程序头部
;=======================================================================
SECTION header vstart=0 ;定义用户程序头部段
;程序总长度[0x00]
program_length dd program_end
;用户程序入口点
code_entry dw start ;偏移地址[0x04]
dd section.code_1.start ;段地址[0x06]
;段重定位表项个数[0x0a]
realloc_tbl_len dw (header_end-code_1_segment)/4
;段重定位表
code_1_segment dd section.code_1.start ;[0x0c]
code_2_segment dd section.code_2.start ;[0x10]
data_1_segment dd section.data_1.start ;[0x14]
data_2_segment dd section.data_2.start ;[0x18]
stack_segment dd section.stack.start ;[0x1c]
header_end:
加载器(BootLoader)
那么我们接下来就来看看如何通过主引导扇区中的BOOT程序加载其他程序并读取到内存中运行!
加载用户程序需要确定一个内存物理地址。如上图所示,物理地址0x0FFFF以下,是加载器及其栈的势力范围;物理地址A0000以上,是BIOS和外围设备的势力范围,有很多传统的老式设备将自己的存储器和只读存储器映射到这个空间。故可用的空间就位于0x10000~9FFFF,那我们就把用户程序加载到0x10000这个16字节对齐的地址运行吧。
;代码清单8-1
;文件名:c08_mbr.asm
;文件说明:硬盘主引导扇区代码(加载程序)
;equ伪指令用于声明常数,作用类似于'define'
app_lba_start equ 100
;SECTION 用于定义段,后面跟段名称
;align = 16,用于指定段的对齐方式为16字节对齐
;Intel 处理器要求段在内存中的起始物理地址起码是16 字节对齐的
;'vstart='子句用于指定段内元素的偏移地址的计算起始地址
SECTION mbr align=16 vstart=0x7c00
;设置堆栈段和栈指针
mov ax,0
mov ss,ax
mov sp,ax ;ss = sp = 0x0000
;phy_base dd 0x00010000,定义在段末尾,减少对程序的影响
mov ax,[cs:phy_base] ;计算用于加载用户程序的逻辑段地址
mov dx,[cs:phy_base+0x02] ;phy_base是双字数据,用dx存储高16位
mov bx,16
div bx ;商在ax中,余数在dx中 ax = 0x1000
mov ds,ax ;DS和ES指向该段以进行操作
mov es,ax
;以下读取程序的起始部分
xor di,di ;LBA28高12位
mov si,app_lba_start ;程序在硬盘上的起始逻辑扇区号
xor bx,bx ;加载到DS:0x0000处
;过程调用
call read_hard_disk_0 ;读取一个扇区
;以下判断整个程序有多大
mov dx,[2] ;长度高16位
mov ax,[0] ;长度低16位
mov bx,512 ;512字节每扇区
div bx ;32位除法-商在ax中,余数在dx中
cmp dx,0
jnz __read_b1 ;未除尽,因此结果比实际扇区数少1
dec ax ;已经读了一个扇区,扇区总数减1
__read_b1:
cmp ax,0 ;考虑实际长度小于等于512个字节的情况
jz __direct ;全部读取完成,执行重定位
;读取剩余的扇区
push ds ;以下要用到并改变DS寄存器
mov cx,ax ;循环次数(剩余扇区数)
__read_b2:
mov ax,ds
add ax,0x20 ;得到下一个以512字节为边界的段地址
mov ds,ax
xor bx,bx ;每次读时,偏移地址始终为0x0000
inc si ;下一个逻辑扇区
call read_hard_disk_0
loop __read_b2 ;循环读,直到读完整个功能程序
pop ds ;恢复数据段基址到用户程序头部段
;计算入口点代码段基址
__direct:
mov dx,[0x08] ;入口地址高16位
mov ax,[0x06] ;入口地址低16位
call calc_segment_base
mov [0x06],ax ;回填修正后的入口点代码段基址
;开始处理段重定位表
mov cx,[0x0a] ;需要重定位的项目数量
mov bx,0x0c ;重定位表首地址
__realloc:
mov dx,[bx+0x02] ;32位地址的高16位
mov ax,[bx]
call calc_segment_base
mov [bx],ax ;回填段的基址
add bx,4 ;下一个重定位项(每项占4个字节)
loop __realloc
jmp far [0x04] ;转移到用户程序
;-----------------------------------------------------------------------
;从硬盘读取一个逻辑扇区
;输入:DI:SI=起始逻辑扇区号
;DS:BX=目标缓冲区地址
read_hard_disk_0:
push ax
push bx
push cx
push dx ;保存现场
mov dx,0x1f2
mov al,1
out dx,al ;读取的扇区数
inc dx ;0x1f3
mov ax,si ;si在被read_hard_disk_0被调用前已被初始化
out dx,al ;LBA地址7~0
inc dx ;0x1f4
mov al,ah
out dx,al ;LBA地址15~8
inc dx ;0x1f5
mov ax,di
out dx,al ;LBA地址23~16
inc dx ;0x1f6
mov al,0xe0 ;LBA28模式,主盘
or al,ah ;ah = 0x00(LBA地址27~24) al = 0xe0
out dx,al
inc dx ;0x1f7
mov al,0x20 ;读命令
out dx,al
__waits:
in al,dx
and al,0x88
cmp al,0x08
jnz __waits ;不忙,且硬盘已准备好数据传输
mov cx,256 ;总共要读取的字数
mov dx,0x1f0
__readw:
in ax,dx
mov [bx],ax ;目标内存
add bx,2
loop __readw
;恢复现场
pop dx
pop cx
pop bx
pop ax
ret ;返回指令 返回时自动恢复IP寄存器的值
;-----------------------------------------------------------------------
;计算16位段地址
;输入:DX:AX=32位物理地址
;返回:AX=16位段基地址
calc_segment_base:
push dx
add ax,[cs:phy_base]
adc dx,[cs:phy_base+0x02]
shr ax,4
ror dx,4
and dx,0xf000
or ax,dx
pop dx
ret
;-----------------------------------------------------------------------
phy_base dd 0x10000 ;用户程序被加载的物理起始地址
times 510-($-$$) db 0
db 0x55,0xaa
难受,后面再补充吧,晚安Bro