3.3 让我们对显示器说点什么吧
3.3.1 CPU 如何与外设通信——IO 接口
IO 接口功能:
- 设置数据缓冲,解决 CPU 与外设的速度不匹配
- 设置信号电平转换电路
- 设置数据格式转换
- 设置时序控制电路来同步 CPU 和外部设备
- 提供地址译码
CPU 通过总线访问 IO 接口,但是同一时刻 CPU 只能和一个 IO 接口通信,当很多的 IO 接口同时想和 CPU 对话时,面对众多接口时 CPU 应该如何选择?这个工作不应该由 CPU 来做,CPU 太忙了,还是好刚使在刀刃上吧,既然分层能解决问题,咱们再加一层,这一层的责任是除了仲裁 IO 接口的竞争,还要连接各种内部总线。由于它的使命,它的名字就叫做输入输出控制中心(I/O control hub,ICH),也就是南桥芯片,如图 3-11 所示。
说到了南桥,多少还要提一句北桥芯片的作用。图 3-11 上标上北桥部分其实是散热片,在它先买你才是北桥。由于南桥和北桥一般是成对出现的, 至少在支持 Intel CPU 的主板中是这样的(话说, AMD 为了减少 CPU 同北桥交换数据的成本,已经把北桥的工作放到了 CPU 内部,所以支持 AMD 的板子上未必有北桥芯片)。南桥用于连接 pci、pci-express、AGP 等低速设备, 北桥用于连接高速设备,如内存。
CPU 通过内部总线连接到南桥芯片中的内部,这个内部总线是专用的,它只通向位于南桥中的 CPU 接口。
IO 接口在诞生之初,就被设计成要通过寄存器的方式同 CPU 通信,其内部有专用于数据交互的寄存器,只不过这里所说的这些寄存器位于 IO 接口中,为了区别于 CPU 内部的寄存器, IO 接口中的寄存器就称为端口(这可不是网络应用程序所开的那种端口,如网络服务器会启动 80 端口,这是两码事)。
3.3.2 显卡概述
mbr 运行在实模式下,所以在实模式下也可以用 BIOS 0x10 中断打印字符串,这是因为中断表只在实模式下存在, 保护模式下没有中断向量表。其次是不希望有更多的依赖,好不容易脱离了对操作系统的依赖,又引入了一个新的依赖,这不科学。
显卡是 pci 设备,所以是安装在主板上 pci 插槽上的, pci 总线是共享井行架构,并行数据就要保证数据发送后必须同时到达目的地,因为这关系到数据的顺序,不能发过去后成一团乱麻 。虽然貌似并行传输是高效的,但对于要保证同 时接收 位数据,这是有困难的,随着并行数据的位宽越来越大,这种困难也越来越明显。于是串行传输 很好地解决了这一问题,一次只发一位,这样顺序问题解决了,数据到目的地看再组合到 起就成了。于 是就有了 PCI Express 总线,这就是串行设备,简称 pcie。
某些 IO 接口也叫适配器,适配器是驱动某外部设备的功能模块。显卡也称为显示适配器,不过归根结底它就是 IO 接口,专门用来连接 CPU 和显示器。我们想操作显示器,没有直接的办法,只能通过它的 IO 接口一一显卡。
3.2.3 显存、显卡、显示器
显存是从 0xB8000~0xBFFFF,范围是 32KB,一屏可以显示 2000 个字符,显示器上的每个字符占 2 字节大小,故每屏字符实际占用 4000 字节。这样,我们的 32KB 的显存可以容纳 32KB/4000B 约等于 8 屏的数据。
屏幕上每个字符的低宇节是字符的 ASCII 码,高字节是字符 属性元信息。在高字节中,低 4 位是字符前景色,高 4 位是字符的背景色。颜色用 RGB 红绿蓝三种基色调和,第 4 位用来控制亮度,若置 1 则呈高亮,若为 0 则为一般正常亮度值。第 7 位用来控制字符是否闪烁(不是背景闪烁)。这两字节如图 3-13 所示。
大家知道,用 红色、 绿色、 蓝色这 种颜色以任意比例混合,可以搭配出其他颜色,其他颜色被认为都可以由这三种颜色组合 而成。不过由于在文本模式下的颜色极其有限, RGB 的各部分比例要么是 1(全部),要么是 0(没有),所以其组合出的颜色屈指可数。
从上面可以看出只要亮度位 ,颜色就是变亮变浅。大家可以结合 位来测试上面的颜色。
3.3.4 改进 MBR,直接操作显卡(实践)
1 | ;主引导程序 |
第 13 行和第 14 行是往 gs 寄存器中存入段基址。
显存段基址放在哪个寄存器中都是没关系的,对于访问的是数据来说,如果不用也做段基址寄存器,就要在寻址中“显式地”指明要用哪个段寄存器的值作为段基址。这个“显式的”的段寄存器叫作段跨越前缀,有的书中叫段超越前缀,个人觉得意义不明确。何为超越?由于有“跨段访问”的说法,所以咱们这里统一为段跨越前缀。“段跨越”相对好理解,如 CPU 的访存策略是“段地址+段内偏移地址”。堆栈段的寄存器是 SS ,代码段寄存器 CS ,这两者不存在默不默认之说,因为它们都不能改变。不过对于数据段来说却有些不同,默认的寄存器是 DS ,但其是可以改变的。一般访问数据时只要给出偏移地址就可以了,这是因为已经存在了默认的段寄存器 DS ,所以访存中给出的偏移地址便是相对于 DS 的偏移量,也就是说访问的地址属于以 DS 为起始的段(是指一般意义上的分段机制,不考虑实模式或保护模式)。但若不想用这个段了,或者访问的地址不属于这个段,想“跨越”这个默认段,而用新的段基址,“跨过”DS 的限制,这就是“跨越”的理解。而“前缀”的意思是在编译后的机器码中,指定的这个新的段寄存器会出现在 IA32 指令格式中的“前缀”宇段,可以参见表 3-1 “IA32 指令格式”。 基于以上两点,为代替默认段基址寄存器而改用的新的段基址寄存器,称为段跨越前缀。
在第 37 50 行执行的 mov 操作都是往显存中写字符。拿 37 行和 38 行举例,第 37 行的mov byte [gs: 0x00],'1'
,是往 gs 为数据段基址,以 gs 为偏移地址的内存中写入字符 ASCII 码。按之前我们讲过的,写入时,要写入 ASCII 0x31,这是最直接的做法。但编译器诞生的意义就是为了给大家带来方便,尽管我们可以把 37 行的代码改为mov byte [gs: 0x00], 0x31
,但这样毕竟还要自己查 ASCII 码表 。编译器对于出现在代码中的字符,它会自动将其改为相应的 ASCII 码,免去了人工查表的过程。即使把表整个背下来了,本质上也是在脑子中经过了一次查表。所以,对于字符的输出,直接写出相应字符就行了,稍微有点人性化的编译器都会自动完成字符到编码的转换。
“mov byte [gs:0x00],‘l’”表示的意思是:把字符 ASCII 码写入以 gs: 0x00 为起始,大小为 1 字节的内存中。 word dword 分别表示 2 字节和 4 字节,意义同理。如果源操作数或目的操作数己经明确了数据宽度,在指令中就不必“显示地”指明操作数所占的空间大小了。例如 mov ax, 0x10,目的操作数是 16 位的,所以不用“显示地”在 ax 前或 0x10 前加个关键字 word。
3.4 bochs 调试方法
bochs 的硬件调试体现在:
- 调试时可以查看页表、 gdt idt 等数据结构;
- 可以查看栈中数据;
- 可以反汇编任意内存;
- 实模式、保护模式互相变换时提醒;
- 中断发生时提醒。
3.4.1 一般用法
xp /nuf <addr>
查看内存u/1 <addr>
将内存数据反汇编成指令
“DEbugger control” 类:
q|quit|exit
退出调试状态,关闭虚拟机show mode
每次 CPU 变换模式时就提示,模式是指保护模式、实模式show int
每次有中断时就提示,同时显示三种中断类型,这三种中断类型包括"softint"、“extint"和"iret”。也可以单独显示某类中断,例如show softint
show call
每次有函数调用发生时就会提示traceon
或traceoff
每执行一条指令,bochs 都会将反汇编的代码打印到控制台u|disasm [/num] [start] [end]
将物理地址 start end 之间的代码反汇编,如果不指定地址,则反汇编 EIP 指向的内存,num 指定反汇编的指令数
"Execution control"类:
c|cont|continue
向下持续执行。s|step [count]
执行 count 条指令。若不指定, count 默认为 1。此指令若遇到函数调用,则会进入函数中去执行;p|n|next
执行 1 条指令,若待执行的指令是函数调用,不管函数内有多少指令,把整个函数当作整体来执行。
"Breakpoint management"类:
- 以地址打断点
vb|vbreak [seg:off]
以虚拟地址添加断点,程序执行到此虚拟地址时停下来,注意虚拟地址是“段: 段内偏移”的形式。最常用的是 vb;lb|lbreak [addr]
以线性地址打断点;pb|pbreak|b|break [addr]
以物理地址打断点;
- 以指令数打断点
sb [delta]
意味着再执行 delta 条指令程序中断;sba [time]
CPU 从运行开始,执行第 time 条指令时中断,从 0 开始的指令数
- 以读写 IO 打断点
watch
:显示所有读写断点watch r|read [phy_addr]
设置读断点watch w|write [phy_addr]
设置写断点unwatch [phy_addr]
清除在此地址上的读写断点blist
显示所有断点信息;bpd|bpe [n]
禁用/启用断点,n 是断点号;d|del|delete [n]
删除断点,n 是断点号,用 blist 查出来
"CPU and memory contents"类:
x /nuf [line_addr]
显示线性地址的内容;xp /nuf [phy_addr]
显示物理地址的内容;info
查看信息的指令族info CPU
info fpu
info idt
info gdt [num]
info ldt
info tss
info ivt [num]
info flags|eflags
info sreg|dreg|creg
显示所有段寄存器|调试寄存器|控制寄存器的值
3.5 硬盘介绍
针对硬盘的 IO 接口是硬盘控制器。硬盘控制器同硬盘的关系,融通显卡和显示器一样,他们都是专门驱动外部设备的模块电路,CPU 只同它们说话,由它们将 CPU 的话转译给外部设备。这是他们的共同点,但不同的是显卡和显示器是分开的,硬盘控制器和硬盘是连接在一起的。但在很久很久以前,硬盘控制器和硬盘也是分开的,后来业界的几个老大合作开发初一中新的接口,这样才将硬盘和硬盘控制器整合在一起,为了凸显“整合”之意,硬盘控制器和硬盘终于在一起了,这种接口便称为集成设备电路(Integrated Drive Electronics, IDE)。随着 IDE 接口标准的影响力越来越广泛,全球变转化协议将此接口使用的技术规范归纳称为全球硬盘标准,这样就产生了 ATA(Advanced Technology Attachment)。由于前几年刚出道大营盘串行接口(Serial ATA,SATA),由于其是串行,所以之前的 ATA 接口只好称为并行 ATA,即(Parallel ATA,PATA)。
以前一般的主机只支持 4 个并口硬盘,但自从出现串口硬盘后, 情况就变了,支持多少块硬盘,取决于主板的能力。有的主板同时兼容这两种接口。
常用的端口寄存器示意图:
3.5.4 常用的硬盘操作方法
大概步骤:
最主要的顺序就是 command 寄存器一定得是最后写,因为一旦 command 寄存器被写入后,硬盘就开始干活了。
- 先选择通道,往该通道的 sector count 寄存器中写入待操作的扇区数。
- 往该通道上的三个 LBA 寄存器写入扇区起始地址的低 24 位。
- 往 device 寄存器中写入 LBA 地址的 24~27 位,并置第 6 位为 1,使其为 LBA 模式,设置第 4 位,选择操作的硬盘( master 硬盘或 slave 硬盘)。
- 往该通道上的 command 寄存器写入操作命令。
- 读取该通道上的 sta 寄存器,判断硬盘工作是否完成。
- 如果以上步骤是读硬盘,进入下一个步骤。否则,完工。
- 将硬盘数据读出
硬盘工作完成后,常用的获取数据方式,也就是数据传送方式包括:
- 无条件传送方式
- 随时准备好了数据,CPU 随取随拿都没问题,如寄存器、内存等设备
- 查询传送方式
- 也称为程序 I/O,PIO,是指传输之前,由程序先去检测设备的状态,数据源在一定的条件下才能传送数据,例如硬盘等设备
- 中断传送方式
- 也称为中断驱动 I/O。上面的“查询传送方式”是 CPU 轮询,效率低。如果数据源设备将数据准备好后再通知 CPU 来取,效率就高了。该方法是当数据源设备准备好数据之后,通过发中断来通知 CPU 来拿数据,这样避免了 CPU 花在查询上的时间。
- 直接存储器存取方式(DMA)
- 在上面的“中断传送方式”中,通过中断来通知 CPU,CPU 就要通过压栈来保护现场,还要执行传输指令,最后还要恢复现场。该方法是不让 CPU 参与运输,完全由数据源设备和内容直接传输。CPU 直接到内存中拿数据就好了。不过 DMA 是由硬件实现的,需要 DMA 控制器才行。
- I/O 处理机传送方式
- DMS 方式中数据输入之后或输出之前还是以后一部分工作要由 CPU 来完成的,例如数据交换、组合、校验等。既然已经引用一个硬件(DMA)了,那一不做二不休再引入一个硬件吧。于是 I/O 处理机诞生了。它专门用来处理 IO,并且他其实是一种处理器,只不过用的是另一套擅长 IO 的指令系统,随时可以处理数据。
3.6 让 MBR 使用硬盘(实践)
3.6.1 改造 MBR
我们的如 MBR 受限于 512 字节大小的,在那么小的空间中,设法为内核准备好环境,更没法将内核成功加载到内存井运行 所以我们要在另一个程序中完成初始化环境及加载内核的任务,这个程序我们称之 loader ,即加载器。 loader 会在下一节中实现。问题来了,loader 在哪里?如何跳过去执行?这就是新款 MBR 的使命,简而言之就是负责从硬盘上把 loader 加载到内存,并将接力棒交给它。
由于 MBR 是占据了硬盘的第 0 扇区(以逻辑 LBA 方式,扇区从 0 开始编号,若是以物理 CHS 方式, 扇区则从 开始编号),第 扇区是空闲的 可以用,但离得太近总感觉不如隔开 点心里踏实,所以把 loader 放到第 扇区。如 IBR 从第 肩区中把它读出来。读出来放到哪里呢?原则上是找个空闲地方就行 了,在表 1-1“实模式下的内存布局”中查看下,只要在“用途”列中注明“可用区域”的地方都可以用 0x500~0x7BFF 和 0x7E00~9FBFF 这两段内存区域都可以。
1 | ;主引导程序 |
程序最开始的%include "boot.inc”,这个%include nasm
编译器中的预处理指令,意思是让编译器在编译之前把 boot.inc 文件包含进来。任何编译器都应该有 include 之类的能够包含其他文件的预处理指令, 不要认为底层的汇编语言就应该简陋到一穷二白,哈哈,这和语言是没关系的,是编译器为了开发人员方便管理代码,应该加的。 boot.inc 的内容很简单,目前就两句话,文件内容如下:
1 | ; -------------- loader和kernel --------------- |
boot.inc 是配置文件,关于加载器的配置信息就卸载里面,今后还会在此添加更多的配置信息。大家看到的这两句也是预处理命令,是 nasm 提供的宏,和 C 语言中的宏是一回事。只不过 nasm 中的语法是:宏名 equ 值,而 C 语言中的宏是由#define 指令来实现的。所以LOADER_BASE_ADDR
和LOADER_START_SECTOR
是两个宏名。
LOADER_BASE_ADDR
定义了 loader 在内存中的位置,MBR 要把 laoder 从硬盘读入后放到抵触。
LOADER_START_SECTOR
定义了 loader 在硬盘上的逻辑扇区地址,即 LBA 地址。
第 4~48 行和上一版本没区别,第 50~52 行为函数rd_disk_m_16
传递参数。在此说明一下汇编语言中定义的函数(或者称为例程,proc),由于汇编语言能够直接操作寄存器,所以其传递参数可以用寄存器,也可以用栈。由于 C 语言中不能直接操作寄存器,所以这里体验一会用寄存器来传递参数的函数是怎样实现的。用寄存器传参数,没有固定的形式,原则上用哪个寄存器都行,只要更具实际应用,别把还有用的寄存器值给覆盖就行,如果真需要用到某个正在使用中的寄存器,只要提前把该寄存器备份好就行了,如备份到其他寄存器或压入栈中。此函数需要三个参数,我么你选择用 eax、bx、bx 寄存器来传递参数。
在寄存器 eax 中的是带读入的扇区起始地址,赋值后 eax 为定义的宏LOADER_START_SECTOR
,即 0x2.
寄存器 cx 是读入的扇区数,cx 其值为 1。到底读入几个扇区,事由实际文件大小来决定的。由于将来会写一个简单的 loader,其大小肯定不会超过 512 字节,所以次数度融入的扇区数置入为 1 即可。
数据从硬盘读进来后放在内存中哪里呢?这就要用寄存器 bx 来指定。在这里, bx 寄存器值为 LOADER BASE ADDR ,即 0x900 。函数名 rd disk m_l6 的意思是“在 16 位模式下读硬盘”。
第 64 行的“ mov esi, eax ”是把 eax 中的值先备份到 esi 。因为 out 指令中会被用到,这会影响到 eax 的低 位。
第 65 行是备份读取的扇区数到 di 寄存器, di 寄存器是 16 位的,和 ex 大小一致。 ex 的值会在读取数据时用到,所以在此提前备份。
第 67~70 行,按照咱们操作硬盘的约定,先选定一个通道,再往 sector count 寄存器中写扇区数。往端口中写入数据用 out 指令,注意 out 指令中 dx 寄存器是用来存储端口号的。先查看 bochs 配置文件关于硬盘的配置部分
虚拟硬盘属于 ata0,是 Primary 通道,所以其 sector count 寄存器是由 0x1f2 端口来访问的。顺便再看第二行的 ata0-master,path=“hd60M.img”,这说明 hd60M.img 是主盘。
第 74~95 行是将 LBA 地址写入三个 LBA 寄存器和 device 寄存器的低 4 位。端口 0x1f3 是寄存器 LBA low,端口 0x1f4 是寄存器 LBA mid,端口 0x1f5 是寄存器 LBA high。shr 指令是逻辑右移指令,这里主要通过此指令置换出地址的相应部分,写入相应的 LBA 寄存器。第 93 行用了“or”指令和 0xe0 做或运算,拼出 device 寄存器的值。高 4 位为 e,即高 4 位的二进制表示为 1110,其第 5 位和第 7 位固定为 1,第 6 位为 1 表示启用 LBA。
第 97~100 行便是写入命令,因为这里是读操作,所以读扇区的命令是 0x20.通过 out 指令写入 command 端口 0x1f7 后,硬盘就开始工作了。
第 102~109 行检测 status 寄存器的 BSY 位。由于 status 寄存器依然是 0x1f7 端口,所以不需要再为 dx 重新赋值。 105 行的 nop 表示空操作,即什么也不做,只是为了增加延迟,相当于 sleep 一小下,目 的是减少打扰硬盘的工作。对同一端口在读写两种操作时有不同的用途,在读硬盘时,此端口中的值是硬盘的工作状态。第 106 行是将 Status 寄存器的值读入到 al 寄存器,通过第 107 行的 and “与”操作,保留第 4 位和第 7 位,第 4 位若为 1,表示数据已经准备好,可以传输了。若第 7 位为 1,表示硬盘现在正忙着。只要判断第 4 位是否为 1 就好了,用第 108 行的 cmp 指令和 0x08 做减法运算,判断第 4 位是否为 1。 cmp 指令并不改变操作数的值,只是根据结果去设置标志位,从而咱们根据标志位反着去判断结果。 cmp 指令会影响的标志位有 ZF、CF、PF 等,这里咱们借助 ZF 位来判断 cmp 的结果。于是用第 109 行的 jnz .not_ready 来判断结果是否不等于 0,即若等于 0,则 status 寄存器的第 4 位为 1,这表示可以读数据了。若不等于 0,说明 status 寄存器的第 4 位为 0,表示硬盘正忙(此时 status 寄存器第 7 位肯定为 1)。not_ready 是个标号,于是跳回去继续判断硬盘状态,直到硬盘把数据准备好才跳出这个循环。
第 111~122 行是从硬盘取数据的过程。由于 data 寄存器是 16 位,即每次 in 操作只读入 2 字节,根据读入的数据总量(扇区数*512 字节)来求得执行 in 指令的次数。这里的乘法用 mul 指令,在实模式下,mul 指令可以做 8 位乘法和 16 位乘法,格式是:mul 操作数。操作数可以是寄存器或内存。乘法运算至少要有两个数参与才行,这里的操作数只是一个乘数,被乘数隐含在 al 或 ax 寄存器中。如果操作数是 8 位,被乘数就是 al 寄存器的值,乘积就是 16 位,位于 ax 寄存器,积的低 16 位在 ax 寄存器。
虽然我们进行的是 16 位的乘法,其结果是 32 位,但由于我们知道这两个乘数 ax 的值和 dx 的值都不大,ax 的实际的值其实是 1,乘出来的这个结果,其高位是 0,所以在第 115 行的“mov cx, ax”我们只将这个结果的低 16 位移入 cx 作为循环读取的次数。此处用 8 位乘法不合适,因为 256 超过了 8 位寄存器表示的范围。在第 118~122 行通过循环来讲数据写入 bx 寄存器指向的内存,没读入 2 个字节,bx 所指的地址便+2.值得注意的是由于在实模式下偏移地址为 16 位,所以呀用 bx 只会访问到 0~FFFFh 的偏移。待写入的地址超过 bx 的范围是,从硬盘上取出的数据会把 0x0000~0xffff 的覆盖,所以此处加载的程序不能超过 bx 的范围时,从硬盘上读出的数据会把 0x0000~0xffff 的覆盖,所以 此处加载的程序不能超过 64KB,即 2 的 16 次方等于 65536。由于本 mbr 是用来加载 loader 的,所以 loader.bin 要小于 64KB 才行。当然,我们最终的 loader 不超过 2KB,将来的内核他也不会超过 70KB。
第 123 行返回指令 ret,它用来从函数中返回。执行完第 123 行后,程序便回到了第 55 行,这里是 MBR 交出接力棒的一刻,采用 jmp 是唯一合适的选择。Jmp 的操作数是 LOADER_BASE_ADDR,即 0x900,这是要跳到内核加载器了,完成第二棒的交接。(第一棒是 BIOS 交给 MBR)。
进行编译:
1 | nasm -I include/ -o mbr.bin mbr.S |
3.6.2 实现内核加载器
1 | %include "boot.inc" |
编译 loader
1 | nasm -I include/ -o boot/loader.bin loader.S |
将 loader 写入硬盘
1 | dd if=boot/loader.bin of=hd60M.img bs=512 count=1 seek=2 conv=notrunc |
运行 bochs,如果程序正确的话,MBR 会跳转到 loader.bin 去运行,屏幕会显示“2 loader”。