本章目标:
- 模拟操作系统加载应用程序的过程,演示段的重定位方法,彻底理解8086处理器的分段内存管理机制
- 学习x86处理器过程调用的程序执行机制
- 了解x86处理器访问外围硬件设备的方法
- 总结JMP和CALL指令的全部格式
- 认识更多的x86处理器指令,如in,out,shl,shr,rol,ror,jmp,call,ret等
8.1本章代码清单
8-1,主引导扇区程序加载器 8-2,被加载的应用程序
8.2 用户程序的结构
8.2.1 分段、段的汇编地址和段内汇编地址
1.SECTION、SEGMENT段
- NASM汇编指令通过“SECTION”或者“SEGMENT”来定义段,一旦定义段,后面的内容就都属于该段,除非出现了另一个段的定义
SECTION 段名称 或者 SEGMENT 段名称
-
“align=”子句 用于指定某个SECTION的汇编对齐方式 align=16”,表示段是16字节对齐,即该物理地址能被16整除
-
“section.段名称.start”子句 段header相对于整个程序开头的汇编地址是section.header.start
- “vstart”子句 尽管定义了段,但是引用某个标号时,该标号处的汇编地址依然是从整个程序的开头开始算起的,而不是从段开头开始计算
8.2.2 用户程序头部
- 加载器和用户程序是不同的个体开发的,因此需要使用“程序头部”来进行协商
- 程序头部组成 1)用户程序的长度,以字节为单位的大小 2)应用程序的入口点,包括段地址和偏移地址。这是因为应用程序不仅仅只有一个代码段 3)段重定位表。存有每个段的段地址,交由加载器进行重定位工作。因为汇编代码的标号是汇编地址,需要通过重定位表来转为内存逻辑地址
8.3 加载程序(器)的工作流程
8.3.1 初始化和决定加载位置
加载器需要决定两件事
- 看看内存中的什么地方是空闲的,即从哪个物理内存开始加载用户程序
- 用户程序位于硬盘的什么位置,它的起始逻辑扇区号是什么
- 起初,我们直接定义一个可用的物理内存地址0x10000,内存空间规划如下
1、equ伪指令
equ伪指令用来声明常数,它的意思是“等于”。 和db,dw,dd不同,用equ声明的数值不占用任何汇编地址,也不在运行时占用任何内存位置。
8.3.2 准备加载用户程序
解读8-1代码的12到21行
8.3.3 外围设备及其接口
处理器和外围设备通信需要解决两个问题
- 不可能所有的I/O接口都和处理器相连 —— 总线
- 各个I/O接口之间的冲突如何解决 —– 使用输入输出控制设备集中器芯片(I/O Controller Hub,ICH),在个人计算机上,就是所谓的南桥
8.3.4 I/O端口和端口访问
- 处理器是通过端口(PORT)来和外围设备打交道的。本质上,端口就是一些寄存器,类似于处理器内部的寄存器,不同之处在于端口寄存器位于I/O接口电路中。
例如:
连接硬盘的PATA/SATA接口有几个端口 命令端口:当向该端口写入0x20时,表明是从硬盘读数据;写入0x30时,表明是向硬盘写数据。 状态端口:处理器根据这个端口的数据来判断硬盘工作是否正常,操作是否成功,发生了哪种错误 参数端口:处理器通过这些端口告诉硬盘读写的扇区数量,以及起始的逻辑扇区号 数据端口:通过这个端口,连续地取出要读的数据,或者通过这个接口连续地发送要写入地硬盘数据 端口数据宽度可以是8位,或者16位,或者32位。 PATA/SATA的接口的宽度是16位
- 端口在不同计算机系统中有着不同的实现方式 </br> 1)端口映射到内存空间地址。例如0x0~0xE0000是真实的物理内存地址,而0xE0001~0xFFFFF是从很多I/O接口那里映射过来的,当访问这部分地址时,实际是在访问I/O地址</br> 2)端口独立编制,不和内存发生关系。通过特殊的引脚“M/IO#”使访问内存和I/O接口互斥。Intel系统中,只允许65536个端口存在,端口号从0x0000~0xFFFF。使用in和out指令来访问端口。</br>
1、in指令,从端口读
一般形式
in al, dx
in ax, dx
in ax, 0xf0 ;从0xf0端口中读取数据,放到寄存器ax中
- in指令的目的操作数必须是寄存器AL或者AX
- in指令的源操作数必须是寄存器dx或者立即数
- in指令不允许使用别的通用寄存器和内存单元
2、out指令,向端口写
out指令和in指令相反
out 0x37, al ;将al数据写入0x37号端口(这是一个8位端口)
out 0xf5, ax ;写0xf5号端口(这是一个16位端口)
out dx, al ;这是一个8位端口,端口号在寄存器DX中
out dx, ax ;这是一个16位端口,端口号在寄存器DX中
- out指令的目的操作数可以是8位立即数或者寄存器DX
- out指令的源操作数必须是寄存器AL或者AX
8.3.5 通过硬盘控制端口读扇区数据
硬盘读写的基本单位是扇区,512字节。这样主机和硬盘之间的数据交换是成块的,所以硬盘是块设备。 从硬盘读写数据方式: 一是向硬盘控制器分别发送磁头号,柱面号,扇区号,这称为CHS模式 二是所有扇区统一编号,即逻辑扇区号。根据逻辑扇区号来读写数据。这样就牵扯到了逻辑扇区的编号该如何编号的问题?LBA28和LBA48,即用28bit和48bit来编号。28bit最大支持128G,对于现在动不动就1T的硬盘来说不够,因此就推出了LBA48
从硬盘读数据的5个步骤(LBA28为例)
- 设置要读取的扇区数量
mov dx, 0x1f2 ;写入0x1f2端口,这是一个8位端口,意味着,每次最多只能读256个扇区 mov al, 0x01 ;一个扇区 out dx, al
- 设置起始LBA扇区号 扇区的读写是连续的,因此只需要给出第一个扇区的编号。 28位的扇区号将其分成4段,分别写入端口0x1f3,0x1f4,0x1f5,0x1f6号端口 ``` mov dx, 0x1f3 mov al, 0x02 out dx, al ;LBA地址7~0
inc dx ;0x1f4 mov al, 0 out dx, al ;LBA地址 15~8
inc dx ;0x1f5 out dx, al ;LBA地址23~16
inc dx ;0x1f6 mov al, 0xe0 out dx, al ;LBA模式,主硬盘,以及LBA地址27~24
注意代码的最后3行,在现行体系下,每个PATA/SATA接口允许挂接两块硬盘,分别是主盘(Master)和从盘(Slave)。
<img src="https://github.com/firstmoonlight/MarkdownImages/blob/main/x86_assembly_nasm/Image9.png?raw=true" width="70%">
* 向端口0x1f写入0x20,请求硬盘读,这也是一个8位的端口
mov dx, 0x1f7 mov al, 0x20 ;读命令 out dx, al
* 等待读写操作完成
mov dx, 0x1f7 .waits:
in al, dx
and al, 0x88
cmp al, 0x08
jnz .waits ;不忙,且硬盘已准备号传输数据 ``` 端口0x1f7既是命令端口,又是状态端口。通过这个端口发送读写命令之后,硬盘就忙乎开了。如图8-12所示,在它内部操作期间,它将0x1f7端口的第7位置”1“,表明自己很忙。一旦硬盘准备就绪,它将此位清零,说明自己已经忙完了,同时将第3位置”1“,意思是准备好了,请求主机发送或者接收数据。
- 连续取出数据
0x1f0是硬盘接口的数据端口,而且还是一个16位端口。
;硬盘读一个扇区(512字节或者256字节),读取的数据存放到有段寄存器DS指定的数据段,偏移地址由寄存器BX指定 mov cx, 256 mov dx, 0x1f0 .readw: in ax, dx mov [bx], ax add bx, 2 loop .readw
最后,0x1f1端口是错误寄存器,包含硬盘驱动器最后一次执行命令后的状态(错误原因)
8.3.6 过程调用
8-1代码讲到79到148行
call指令
8086处理器支持四种调用方式
- 16位相对近调用
近调用的意思是被调用的目标过程位于当前代码段内,而非另一个不同的代码段,所以只需要得到偏移地址即可。相对近调用是三字节指令,操作码为0xE8,后面跟16位的操作数。
call near proc_1 ;关键字near不是必须的 ;编译阶段:proc_1处汇编地址 - call指令汇编地址 - 3 = 机器码的操作数 ;执行阶段:寄存器IP当前内容 + 机器码操作数 + 3 = 下一步要执行的地址
- 16位绝对近调用
指令中的操作数不是偏移量,而是被调用过程的真实偏移地址,故称为绝对地址。操作码位0xFF
call cx ;目标地址在cx中,省略了near call [0x3000];要先访问内存才能取得目标地址 call [bx] ;要先访问内存才能取得目标地址 call [bx+si+0x02];要先访问内存才能取得目标地址
- 16位直接绝对远程调用
这种调用属于段间调用,称为远程调用(far call)
call 0x2000:0x0030 ;指令编译后的机器码为9A 30 00 00 20,0x9A是操作码,后面跟着的两个字分别是偏移地址和段地址,按规定,偏移地址在前,段地址在后
- 16位间接绝对远程调用
属于段间调用,这里的16位同样是用来限定偏移地址的。
call far [0x2000] call far [proc_1] call far [bx] call far [bx+si] ;间接调用必须使用关键字far ;上述代码的意思是,从指定的内存地址中取出两个字,作为跳转的偏移地址和段地址
ret和retf指令
- ret是折返指令,当它执行时,处理器只做一件事,那就是从栈中弹出一个字到指令指针寄存器IP中
- retf时远程返回指令,当它执行时,处理器分别从栈中弹出两个字到指令指针寄存器IP和代码寄存器CS中
8.3.7 加载用户程序
解读30~53行代码,该段代码的功能是将扇区中的内容加载到内存空间0x10000开始的地方
8.3.8 用户程序的重定位
用户程序的内容被加载到内存中后,用户程序在内存中的地址就确定了,但是可重定位表中保存着的还是汇编地址,需要将其转为物理地址。这就是可重定位的作用。 55~74行代码的功能就是重新写可重定位表。
1、adc指令
adc是带进位加法,它将目的操作数和源操作数相加,然后再加上标志寄存器CF的值。可以用来完成32位的加法。
2、shr和shl指令
shr(Shift logical Right)逻辑右移指令。逻辑右移指令执行时,会将操作数连续地向右移动指定地次数,每移动一次,“挤”出来的比特就被移到标志寄存器CF位,左边空出来的部分用“0”来填充。
shr ax, 4 ;将寄存器AX中的内容右移4位。
- 目的操作数:8位或16位的通用寄存器或者内存单元
- 源操作数:数字 1,8位立即数,寄存器CL
- 格式
shr r/m8, 1 shr r/m16, 1 shr r/m8, imm8 shr r/m16, imm8 shr r/m8, cl shr r/m16, cl
- shl指令是shr指令的配对指令,表示左移
3、ror和rol指令
ror(ROtate Right)是循环右移指令。循环右移指令执行时,每右移一次,移出的比特既送到标志寄存器的CF位,也送进左边空出的位。
- ror的配对指令是循环左移指令rol(ROtate Left)。ror、rol、shr、shl的指令格式都是相同的。
8.3.9 将控制权交给用户程序
处理器执行
jmp far [0x04]
开始执行用户的代码
8.3.10 8086处理器的无条件转移指令
- 相对短转移
段内转移指令,必须使用关键字“short”,操作数是相对于目标位置处的偏移量
操作码:0xEB
操作数:1字节,有符号数
jmp short infinite jmp short 0x2000
- 16位相对近转移
段内转移指令,使用关键字“near”,操作数是相对于目标位置处的偏移量
操作码:0xE9
操作数:16位(2字节),有符号数
jmp near infinite jmp near 0x3000
- 16位间接绝对近转移
段内转移指令,关键字“near”可省略
操作数:16位通用寄存器或者内存地址
jump_dest dw 0xc000 jmp [jump_dest] jmp near cx
- 16位直接绝对远转移 操作码:0xEA
jmp 0x0000:0x7c00
;编译之后的机器指令为EA 00 7C 00 00
;字的存放是按照低端字节序,而且编译之后偏移地址在前,段地址在后
- 16位间接绝对远转移(jmp far) 远转移的目标地址可以通过访问内存来间接得到,这叫间接远转移,但是要使用关键字“far” ``` jump_far dw 0x33c0, 0xf000 jmp far [jump_far] ;关键字far的作用是是告诉编译器,该指令应当编译成一个远转移。处理器执行这条指令后,访问段寄存器DS所指向的数据段,从指令中给出的偏移地址处取出两个字,分别用来替代段寄存器CS和指令指针寄存器IP的内容。
jmp far [bx] jmp far [bx + si] ```
8.4 用户程序的工作流程
8.4.1 初始化段寄存器和栈切换
解释了137~142行的代码
1、resb伪指令
意思是从当前位置开始,保留指定数量的字节,但不初始化它们的值,即每个字节的值都是不确定的。
8.4.2 调用字符串显示例程
解释了171到191行的代码
- 回车(Carriage Return):将坐标推到一行的最左边,也就是一行的开始。
- 换行(Line Feed):将坐标推到下一行。
- 回车换行(CRLF):既回车又换行,即坐标将位于下一行的行首。
- 回车的ASCII码:0x0d。
- 换行的ASCII码:0x0a。
8.4.3 过程的嵌套
- 允许在一个过程中调用另一个过程,这称为过程嵌套。
- 解释了put_string的工作流程:循环从DS:BX中取得单个字符,判断它是否为0,不为0则调用另一个过程put_char,为0则返回主程序。
8.4.4 屏幕光标控制
- 光标控制 光标在屏幕上的位置保存在显卡内部的两个光标寄存器中,每个寄存器是8位的,合起来形成一个16位的数值。 例如:0表示光标在屏幕上第0行第0列。
8.4.5 取当前光标位置
显卡中的很多寄存器只能通过索引寄存器间接访问。 索引寄存器的端口号是0x3d4,可以向它写入一个值,用来指定内部的某个寄存器。比如:两个8位的光标寄存器,其索引值分别是14(0x0e)和15(0x0f),分别用于提供光标位置的高8位和低8位。 指定了寄存器之后,要对它进行读写,这可以通过数据端口0x3d5来进行。