# 0. 汇编器的说明

不同的汇编器具有不同的汇编伪指令. x86架构的常见的汇编器有 gas, nasm,masm, 不同的汇编器有不同的伪指令和语法.

# 8086指令补充说明

  1. ret: 从栈中出栈一个字,送入到ip
  2. retf: (return far),远返回, 从栈中先出栈一个字送入ip, 再出栈一个字送入代码段寄存器CS, retf通常与远跳转调用call far 0x0000:0x1234 / [内存单元]配合使用

    TIP

    也有一种使用retf来代替跳转指令的写法.

    push word [es:code_1_segment] ;将想要跳转的段压入栈中
    mov ax,continue   ;因为push命令不能压入立即数,因此通过一个寄存器来压入
    push ax           ;加入立即数
    retf              ;从栈中出栈两个字, 分别作为ip和cs. 实现了跳转到 code_1_segment:ax 的位置
    
  3. test: test 寄存器/内存单元, 寄存器/立即数, 功能类似于and,将两个操作数进行安位于操作, 但是不会保存结果, 只会修改对应的标志寄存器, 而and指令保存结果到目的寄存器,并且修改标志寄存器.
  4. not r/m: 反转寄存器或者内存单元的值, 按位取反
  5. hlt: cpu进入低功耗状态,直到中断唤醒, 也就是在hlt指令执行后,不再接着执行后面的指令, 直到出现一个中断请求, 处理完中断请求后,从原本中中断的位置继续执行
  6. int3: 该指令是一个单字节指令, 用于设置断点, 设置断点的原理就是将被设置断点的指令所在位置,的第一个字节替换为int3的操作码, 于是cpu就会停止在int3处,此时我们可以进行查看各个寄存器的值等, 当放行后, 被替换的字节就会恢复.
  7. into: 该指令时溢出检查指令, 如果溢出了就会产生4号中断,否则什么也不做.
  8. div r/m32: 32位除法指令(32位指的是除数是32位, 而被除数必须为64位,不足64位的,需要我们设置为0). 被除数保存在EDX:EAX. 商保存在EAX,余数在EDX
  9. xchg: 交换指令, 两个操作数的宽度必须是相同的. 并且两边不能同时是内存单元 image
  10. loop: 循环指令, 代码段的操作尺寸为16为时,loop指令使用的是cx, 32位时,使用的是ecx.
  11. bswap r: 字节交换指令, 按照字节为大小,交换某个寄存器的字节. image
  12. jmp far [xxx]: 保护模式下,jmp 远跳转指令,会依次取出32位段内偏移,16位选择子(一共6个字节).
  13. sgdt m: 读取GDTR的内容, 将读取的6个字节保存到指定的内存单元.
  14. movzx: 0扩展传送指令, 将源操作数的宽度,扩展为与目标寄存器相同的宽度, 用0去填充 mov eax, al, 以这条指令说明, al为8位寄存器. eax为32位寄存器. 指令的作用就是将al设置给eax, 其余的高位用0填充.
  15. movsx: 符号位扩展传送指令, 与movzx使用0填充不同的是, movsx指令使用的是源操作数的符号为进行填充, 比如说源操作数是一个负数时, 其余位就用1填充. 源操作数为正数,就用1去填充.

# NSAM的语法说明

  1. app_lba_start equ 100: equ是一个关键字, 这里的作用就是说标号app_lba_start等同于100, 也就是定义了一个常数. 可以在接下来的程序使用. 这行代码不占用空间, 当程序编译后消失.
  2. resb num: 保留num个字节的大小的空间 在程序编译时,并不是编译一条指令就向磁盘写入一条指令的结果. 而是临时先保存到内存中. 当编译到resb 256时, 就会跳过256个字节,然后开始编译下一条指令,将下一条指令的编译结果保存到跳过256字节之后的地方. 当都编译完成后, 将整个结果保存到磁盘中. 因为跳过的字节没有被初始化, 因此这个256字节是原本内存的数据. 类似的还有reswresd指令,分别是按照字单位和按照双字为单位

    TIP

    这种方式也可以替换为times 256 db 0, time就是重复后面的指令多少次,在这种写法中不仅保留了字节, 还将这些字节初始化为0

  3. vstart,对于每一个SECTION后面的vstart, vstart只会影响该段中标号的偏移值的计算, 而不会影响该段的section.段名.start的值.
  4. 伪指令bits 位数(16/32), 指示NASM汇编器,接下来的代码,翻译成16位操作尺寸的指令,还是32位的操作尺寸的指令.

# 1. 段的定义

在NASM汇编器中, 使用section,或者SECTION来定义一个段, 后面可以跟上align来指定对齐大小. x86的16位汇编中,一个段的值必须是16的倍数(左移4个bit位),如果当前段不能够以一个16倍数的地址开始, 就会将前端的段进行填充0.保证自己从一个16的倍数的地址开始. img

TIP

如果没有align子句, 则默认使用4字节对齐. 估计会导致无法运行.

  • vstart: vstart 也是section后面的一个子句, 有两个作用, 一是指定当前段的偏移值, 而是当前段的标号都会以这个偏移值开始计算自己的偏移值, 而不是再根据程序的开始位置.
  • section.段名.start: 这个是NASM提供的功能, 返回指定段名的偏移值.

默认来说, 每一个段中定义的标号的值存储的是当前位置与程序开始位置的偏移量. 以下列代码举例: img

  • offset: 这个标号的值为0, 因为它就处于程序开始的位置.
  • str1和str2: 因为使用了vstart=0x100,指定了虚拟标号为0x100, 就说明这个段的起始地址为0x100, 这个段的标号也需要根据这个来计算. str1就是0x100,而str2是0x105.
  • num: 所处段s3, 因为没有指令vstart, 因此这个标号是相对于程序开始位置的. 因为钱两个段都是处在16字节的对齐的位置, 因此num的值是0x20H(32)

# 2. 加载程序

每一个操作系统一般都有程序加载器, 一个编译后的二进制程序. 如果想要执行, 需要知道这个二进制的程序的一些信息.

因此加载器和一个程序需要有共同的协议规定, 也就是程序头部内容. 一下是一个头部的例子, 不同的操作系统有不同的头部规定, 比如说linux是ELF格式, 而windows是PF格式. img

我们通过NASM汇编的伪指令来帮我们添加这些头部信息 img

TIP

这里定义了一个段, 作为头部段来存储头部的信息.

  • 定义了一个双字: 记录程序的大小, program_end这个标号在程序的末尾位置, 所处的段没有加上vstart,因此这个标号的值正好是程序的大小(没有减去头部段的大小)
  • 定义程序的入口点: 记录了这个程序应该从哪个字节开始执行.
  • 记录段的个数: 通过标号计算表项的大小,每个表项是段的起始地址,占用4个字节(双字), 即除以4就可以得到表项的数量.
  • 定义段重定位表.

程序加载器通过这些信息来给这个程序分配内存空间。

# 加载器工作流程

  1. 读取用户程序的所在的起始扇区.
  2. 把整个用户程序都读入内存.
  3. 计算段的物理地址和逻辑段地址(段的重定向).
  4. 转移到用户程序执行(将处理器的控制权交给用户程序).

被加载程序的所在扇区应该是作为加载器的参数. 以c09_mbr.asm这个加载器的代码进行说明. 9-11行代码

SECTION mbr align=16 vstart=0x7c00                                     
    ;设置堆栈段和栈指针 
    mov ax,0      
    mov ss,ax
    mov sp,ax

    mov ax,[cs:phy_base]            ;计算用于加载用户程序的逻辑段地址 
    mov dx,[cs:phy_base+0x02]
    mov bx,16        
    div bx            
    mov ds,ax                       ;令DS和ES指向该段以进行操作
    mov es,ax 

TIP

这个程序只有一个段就是mbr, 如果只有一个段的话为什么要加section呢? 即使不加这个伪指令, 编译之后也会默认它为一个段. 原因是因为我们后面添加的 vstrart, 指令这个段的偏移量为0x7c00, 因此这个段中的所有标号的值都要在基础上加上这个值.

因为这个程序是设计为一个主引导扇区的程序, 会直接被bios加载到内存的0000:7c00处,并且此时代码段寄存器的值为0, ip寄存器的值为7c00. 然后就开始执行加载器的代码.加上了vstart=0x7c00,才能保证这个汇编程序被NASM编译成二进制程序后,里面的标号被正确的准换为正确的物理地址.

然后做的事情就是初始化栈段寄存器为0,栈顶指针sp为0, 因此栈段和代码段当前处于一个段都是0. 且栈顶指针的变化范围为:0000-FFFF, 因为栈顶指针是从0开始的, 由高地址向低地址生长. 因此如果压入一个字, 则会存储在 0000:FFFE这个位置.

phy_base是一个标号, 在给出的代码的下方,这里没有给出. 它对应的内存单元存储的是被加载程序要被加载到的地方的地址,这个地址是你自己决定的. 需要注意的是这个地址必须是16的倍数, 因为被加载的程序的段需要处于16倍数的位置, 并且这个地方内存也需要是空闲的.

这里的作用就是进行了一次32位的除法, 将phy_base的高16位放入到dx寄存器, 低16位放入到ax寄存器.然后除以16,即右移4位,此时的商就是被加载到的内存单元所处的段地址. 将这个值保存到数据段寄存器和附加段寄存器.

# in/out指令访问磁盘

# 磁盘访问方式

磁盘有两种访问方式:

  • chs(磁道.磁头,扇区):即提供指定的磁道,磁头,扇区号,来访问扇区
  • lba模式: 逻辑块地址, 将扇区逻辑编号, 通过编号访问某个扇区(从1开始编号). 又可以细分为lba28和lba48

chs方式 :小于8G (8064MB)

LBA28方式:小于137GB

LBA48方式:小于144,000,000 GB

TIP

磁盘又叫做块设备, 即以一块为单位,只能一块一块访问, 磁盘一个block的大小为512字节.

# 磁盘的端口号

1f0-1f7,一共8个端口.

# 读写扇区

  1. 指定读/写扇区个数
mov dx,0x1f2
mov al,0x01
out dx,al

TIP

0x1f2端口接收的是操作的扇区个数, 这里将1送入0x1f2端口意思就是要读取1个扇区.

指的注意的是 <font color="red">当al为0时,表示读写256个扇区.</font>
  1. 指定读/写扇区的起始逻辑号 1f3-1f6端口都保存了部分的起始地址. 传递给1f6端口的字节数据中,高四位是访问硬盘的模式. 0xe0, 高4位为e表示是1110,d7,d5位固定为1, d6为0表示chs模式,d6为1表示lba模式, d4是主硬盘还是从硬盘, 因为读写的是主硬盘所以设置为0, 低4位时起始逻辑扇区号的高4位 img
  2. 指定读/写
mov dx,0x1f7
mov al,0x20
out dx,al

1f7端口有两个作用,命令端口. 以及状态检测端口, 当我们输出数据到端口时就是指定命令. 这里0x20,表示读命令 4. 检测磁盘状态. 发送读写命令后, 如果想要开始读写还需要磁盘准本就绪. 因此我们需要从1f7端口不断的读取状态. 直到磁盘准备就绪. img 当第7位和第3位分别为0和1时, 才能开始读写数据. 因此我们从1f7端口读取一个字节的状态数据后, 就需要判断这两位的值. 我们让它与0x88相与, 使得除了这两位之外全部设置为0. 然后与0x08进行大小比较(本质是减法), 如果结果为0,表示确实第7位为0,第3位为1. 此时可以读取了,而不是通过jnz命令重新跳转到状态读取的指令. 5. 读写数据 img 1f0端口是磁盘的读写数据端口.该端口的位宽为16bit

以读取数据为例, 假设我们的数据保存在数据段的bx的位置. 因为要读取512个字节, 每次只能读取一个字, 因此我们需要循环256次in指令.

TIP

每次从端口读取一个字的数据, 磁盘控制器自己能够感知到CPU取走了数据,自动就会设置下一个字的数据到端口.

我认为这种读取磁盘的方式是需要经过CPU的. 因此这种IO操作极其浪费CPU.改进方法应该就是后面的DMA方式.

# 显卡端口操作.

我们的显卡工作在文本模式时, 除了可以通过操作显存来访问显卡, 还可以通过端口来访问显卡. 我们要访问外部IO设备中的寄存器通常需要通过IO控制电路的端口. 显卡所连接的IO控制电路中, 有两个端口可以使用0x3d4和0x3d5. 它们分别是索引端口,和数据端口. 因为IO设备内部的寄存器可能有很多, 不可能给每一个寄存器都分配一个端口, 因此0x3d4这个端口是用来选择操作的寄存器的. 而0x3d5是数据端口,负责从这个端口取出数据或者写入数据.

屏幕上闪烁的光标它的位置保存在显卡中的两个寄存器中, 分别是0x0e和0x0f, 0x0e和0x0f都是8位的寄存器.分别保存着光标索引的高八位和低八位的数值. 因为文本模式下, 屏幕上只有25行*80列, 则一共是2000个字符. 因此 0x0e和0x0f联合组成的16空间中的数值的范围是: 0~1999. 通过端口修改0x0e和0x0f的值来实现修改光标的位置. img

# 中断控制器.

CPU有两个引脚, NMI和INTR. 即不可屏蔽中断引脚和可屏蔽中断引脚. NMI负责连接一些非常重要的中断, 比如电源即将掉电,需要立刻保存内容. 或者内存读取数据后发现CRC校验错误,读取的数据是出错的,即使执行也是没用的.

而INTR是可屏蔽中断, 只要当CPU的标志寄存器IF为1时,才会响应INTR的中断请求. INTR只有一个引脚,为了接收更多的中断信号, 需要使用8259A中断控制器.

img

8259A中断控制器通常是成对的,分为主片和从片. 8259A中断控制器是可以编程的, 它内部包含一个8位的寄存器. 每一个bit位分别对应一个中断引脚. 当这个bit位为1时,表示这个中断引脚被屏蔽. 不在处理它的中断请求. 每一个中断请求引脚的中断号也是可以设置的. 通常是在BIOS启动时,他会将主从两片8259A的每一个对应的引脚设置一个中断号.

# RTOC/CMOS RAM

RTOC是一个实时时钟, CMOS RAM是一个存储器. RTOC用于生成时间,日期等信息,保存在CMOS RAM中, 为了关机后,时间依然精准,外面还有一个电池. 现在RTOC和CMOS RAM直接集成在南桥芯片中!

# RTC 时钟芯片 MC146818

MC146818芯片包含时钟部分和一个CMOS RAM. 在这个RAM中保存着时间等相关信息, 以及自己工作时用到的寄存器.

RTC芯片有两个端口, 索引端口用于接收访问CMOS RAM芯片中的地址. 数据端口则是读写数据. img

# RTC芯片产生的三种中断信号.

  1. 周期性中断信号: 每到达一个时间间隔后触发一个中断(如果允许的话)
  2. 更新周期结束中断: RTC芯片每秒读取原时间计算新的日期时间,时间溢出检查等等, 写入完成后触发更新中断(如果允许的话)
  3. 闹钟中断: 到达某个时间后发生中断.

# 周期性中断信号

如果想要RTC产生周期性中断信号. 需要进行 时基选择和分频器的速率选择. 本质上就是对寄存器A进行操作6~4位设置时基, 3~0位选择速率. 寄存器A img 不同的时基选择具有不同的速率选择, 因为是通过分频器对选择的时基进一步的划分出多个频率的新信号. img 寄存器B 通过设置寄存器B的d6位(PIE),来控制是否产生周期性时钟中断. 为1则允许,为0禁止. 当设置寄存器A的速率位3~0位为0时, 该PIE位自动被置为0, 如果我们设置了1,需要就需要设置3~0位选择一个速率

# 更新周期结束中断信号

通过设置寄存器B, 最高位d7,设置是否更新周期即是否进行日期更新, 1允许,0禁止. d4位控制是否在更新周期结束后,允许产生中断信号, 0不产生,1允许产生. img

更新周期进行过程或者`即将进行时,我们是不能从CMOS中读取和写入时间的. 寄存器A的d7位(UIP)即最高位用来指示是否可以读写时间. 即状态. 该位为只读,写入数据时对这一位是无效的. 1表示即将更新或者正在更新, 0表示至少在488微妙内不会进行周期更新. img 当值为1时, CMOS RAM中跟时间相关的存储单元暂时无法访问.

因此我们有两个时机对CMOS RAM时间相关的存储单元进行访问:

  1. 更新周期结束中断信号发生: 表示更新刚刚结束.可以进行访问
  2. 寄存器A的UIP位为0,表示至少在488微妙内不会进行周期更新,我们可以在这个时间段访问.

# 闹钟中断信号

在寄存器B中的d5位(AIE,闹钟中断允许),来控制是否产生闹钟中断信号,1产生,0不产生.

# 三种中断信号共用8259A的一个中断信号引脚.

img 通过只读寄存器C的各个位来判断发生中断的是三种中断的哪一种,以及是否发生中断 每次读取寄存器C都会导致该寄存器的各个位全部置为0.哪个中断产生,对应的位就是1.

# 寄存器B各个位详细说明

img d3: 方波输出允许, 已经没有用了 d2: 时间的数据模式, 0 BCD, 1 二进制 d1: 小时的时间格式, 0 12进制, 1 24进制, 当使用12小时进制时,最高位来代表是上午还是下午. d0: 老软件夏令时, 没有用了.

# 中断

# 中断程序编写需要注意的问题.

对于8259A中断控制器产生的中断,我们需要再执行完中断处理程序后, 将8259A对应的中断服务寄存器清0, 需要向产生中断响应的8259A芯片的写端口输出0x20,即EOI命令, 告知中断控制器我们对于本次中断处理完毕, 可以处理下一个中断了.

对于RTC芯片, 处理它的中断完毕后,也需要给它的寄存器C清0, 告诉它我们处理完本次中断了,否则只能触发一次中断.

在安装中断处理程序时, 当修改中断向量表时, 应该关闭中断, 防止被修改的中断向量被触发.

# RTC端口引脚可以控制NMI

RTC的0x70端口对应的寄存器的最高位,是可以控制NMI的如果将最高位设置为0, 则禁止了非屏蔽中断. img

# 中断的分类

  1. 内部中断: 除0引发的int 0中断, 非法指令的int 6号中断, 也就是CPU自己触发的中断
  2. 软中断: 通过int指令调用的中断都是软中断, 我们也可以手动的调用外中断的中断程序,(比如int 9 键盘输入中断, 虽然我们可以手动的去调用int 9号中断, 但是可能出现问题, 因为这个中断程序应该在键盘输入后执行,才能得到正确的结果)
  3. 硬(件)中断/外中断: 由硬件引发, 去调用的中断.

# 对栈段寄存器操作的关闭中断问题.

只要指令修改了栈段寄存器, IF标志就会被设置为1, 下一条指令应该是一条修改栈顶指针寄存器的指令. IF标志置为1,说明不接收可屏蔽中断请求.

# BIOS中断的中断例程来自哪里

img 8086的内存空间从C000到E0000处, 这里是外设ROM的映射空间, 当BIOS进行初始化时, 以512字节大小为单位,扫描这段内存空间,如果这512字节以55aa开头,表示这里是一个有效的外设映射ROM区域, 会从这段区域中提取中断例程, 设置到中断向量表

也就是说外设的中断例程并不是BIOS去提供的, BIOS负责管理

更新时间: 2024年5月26日星期日下午4点47分