《操作系统真象还原》chapter13 笔记与整理

First Post:

Last Update:


不太好用比较好看的格式来说明这章的内容,就我个人的感受来说,主要是科普了一下计算机和硬盘是如何交互的,顺便对外部设备的驱动编写有了一点比较模糊的认识。

名次解释

首先是关于硬盘的几个名词解释:

  • 盘面:磁盘上的任何一面都能称之为盘面
  • 柱面:将多个磁盘叠在一起,相同磁道号构成的圆柱面
  • 磁头:用于读写磁盘的设备,一个磁盘上下两面各有一个
  • 磁道:任何一个磁盘上用于储存数据的带磁同心圆
  • 分区:认为界定一个磁盘各个区域的名词
  • 扇区:标准扇区512字节,每个磁道由多个扇区构成

反正具体的样貌大概都能搜出来,名次解释并没有太大意义,这里写出来是为了让文章看起来比较舒服。

另外还需要记录一点,有关磁盘储存数据的方式:

  • 每个主盘的第一个磁道用于存放MBR,而MBR只占用一个扇区,多余扇区往往不使用。一个磁道一般63个扇区(过去是这样,现在更多更大了,但出于向前兼容的缘故,应该认为每个磁道的扇区数相同)
  • 第一个扇区除了MBR外还需要存放64字节的分区表,分区表记录整块磁盘的分区数据

不过现代硬盘为了支持更多的分区(早期只支持4个主分区),引申出了逻辑分区的概念。将硬盘分为3个主分区和一个逻辑分区

逻辑分区是理论上可以无限被分割的分区,它为从自身再分配出去的每个分区单独赋予一张分区表,每张分区表通过隐式链接的方法可以追溯到下一个分区。

每个分区的第一个磁道都是引导记录,只是第一个分区的叫做MBR(Main Boot Record),其他的都叫做EBR(Extended Boot Record)。而每个分区的第二个磁道开始还放了一个OBR(OS Boot Record)。EBR和MBR是完全一样的结构,只是在名字上做了区别;而OBR则不同于MBR,它就是普通的存放在磁道上的数据而已,用于完成操作系统的自举。

分区表条目如下:

1
2
3
4
5
6
7
8
9
10
11
12
struct partition_table_entry {
uint8_t bootable; // 是否可引导
uint8_t start_head; // 起始磁头号
uint8_t start_sec; // 起始扇区号
uint8_t start_chs; // 起始柱面号
uint8_t fs_type; // 分区类型
uint8_t end_head; // 结束磁头号
uint8_t end_sec; // 结束扇区号
uint8_t end_chs; // 结束柱面号
uint32_t start_lba; // 本分区起始扇区的lba地址
uint32_t sec_cnt; // 本分区的扇区数目
} __attribute__ ((packed)); // 保证此结构是16字节大小

主要通过start_lba+sec_cnt*512来实现下一个分区的寻址,所以叫隐式链接。

本书有一张非常形象的图用以解释这个方法,不过因为我懒得拍一张下来,有兴趣的师傅可以去翻翻看,P577-图13-23。

IDE通道实现

操作系统和硬盘的交互主要是走IDE(Integrated Drive Electronics)通道,个人目前对IDE的认知是一套由操作系统实现的驱动接口。所以实现硬盘驱动就是在写IDE。

不过作者在本章才实现thread_yield,让这章的结构看起来有些混乱(虽然这似乎看起来是顺理成章的事情),所以关于thread_yield和idle的内容会放在本片笔记的结尾。

首先是关于操作系统如何与硬盘进行交互的内容:

  • BIOS在启动之初就会像磁盘写入一系列数据,其中硬盘数量被写在0x475地址处
  • 和之前的8259A芯片等设备相同,硬盘也提供了一些寄存器用以让操作系统向其发送指令,包括IDENTIFY、READ_SECTOR、WRITE_SECTOR三个指令。
  • 操作系统向对应的寄存器中写入硬盘编号、起始偏移、所需扇区数后,待硬盘完成对应的寻址和返回操作以后,便能够从固定的端口读出硬盘数据
  • 另外IDENTIFY指令发送后,硬盘会返回一系列有关硬盘本身的信息,可以用它们来构建整个硬盘的分区结构
  • 硬盘也分主盘和从盘,在发送指令的时候需要指定发送的目标硬盘。当硬盘完成任务以后会触发8259A芯片上的IRQ14和IRQ15中断响应处理器

更加具体的操作直接看代码和注释吧。

init_ide:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/* 硬盘数据结构初始化 */
void ide_init() {
uint8_t hd_cnt = *((uint8_t*)(0x475));// 获取硬盘的数量
list_init(&partition_list);
channel_cnt = DIV_ROUND_UP(hd_cnt, 2); // 一个ide通道上有两个硬盘,根据硬盘数量反推有几个ide通道
struct ide_channel* channel;
uint8_t channel_no = 0, dev_no = 0;

/* 处理每个通道上的硬盘 */
while (channel_no < channel_cnt) {
channel = &channels[channel_no];
sprintf(channel->name, "ide%d", channel_no);

/* 为每个ide通道初始化端口基址及中断向量 */
switch (channel_no) {
case 0:
channel->port_base = 0x1f0; // ide0通道的起始端口号是0x1f0
channel->irq_no = 0x20 + 14; // 从片8259a上倒数第二的中断引脚,温盘,也就是ide0通道的的中断向量号
break;
case 1:
channel->port_base = 0x170; // ide1通道的起始端口号是0x170
channel->irq_no = 0x20 + 15; // 从8259A上的最后一个中断引脚,我们用来响应ide1通道上的硬盘中断
break;
}

channel->expecting_intr = false; // 未向硬盘写入指令时不期待硬盘的中断
lock_init(&channel->lock);

/* 初始化为0,目的是向硬盘控制器请求数据后,硬盘驱动sema_down此信号量会阻塞线程,
直到硬盘完成后通过发中断,由中断处理程序将此信号量sema_up,唤醒线程. */
sema_init(&channel->disk_done, 0);

register_handler(channel->irq_no, intr_hd_handler);

/* 分别获取两个硬盘的参数及分区信息 */
while (dev_no < 2) {
struct disk* hd = &channel->devices[dev_no];
hd->my_channel = channel;
hd->dev_no = dev_no;
sprintf(hd->name, "sd%c", 'a' + channel_no * 2 + dev_no);
identify_disk(hd); // 获取硬盘参数
if (dev_no != 0) { // 内核本身的裸硬盘(hd60M.img)不处理
partition_scan(hd, 0); // 扫描该硬盘上的分区
}
p_no = 0, l_no = 0;
dev_no++;
}
dev_no = 0; // 将硬盘驱动器号置0,为下一个channel的两个硬盘初始化。
channel_no++; // 下一个channel
}

printk("\n all partition info\n");
/* 打印所有分区信息 */
list_traversal(&partition_list, partition_info, (int)NULL);
printk("ide_init done\n");
}

过程并不复杂,根据注释大概就能理解过程了,细节参考一下本书代码中的结构体和讲解应该不难理解。

该函数主要是完成两个ide通道的初始化,让之后读取能够顺利进行:

1
2
3
4
5
6
7
8
9
10
/* ata通道结构 */
struct ide_channel {
char name[8]; // 本ata通道名称, 如ata0,也被叫做ide0. 可以参考bochs配置文件中关于硬盘的配置。
uint16_t port_base; // 本通道的起始端口号
uint8_t irq_no; // 本通道所用的中断号
struct lock lock;
bool expecting_intr; // 向硬盘发完命令后等待来自硬盘的中断
struct semaphore disk_done; // 硬盘处理完成.线程用这个信号量来阻塞自己,由硬盘完成后产生的中断将线程唤醒
struct disk devices[2]; // 一个通道上连接两个硬盘,一主一从
};

identify_disk就是发送identify指令并获取硬盘信息,而partition_scan负责扫描该磁盘,并向hd中填入数据(换个说法吧,partition_scan会开始扫描磁盘,通过磁盘里每个MBR和EBR的分区表来初始化hd指针指向的结构体)。

select_disk:

1
2
3
4
5
6
7
static void select_disk(struct disk* hd) {
uint8_t reg_device = BIT_DEV_MBS BIT_DEV_LBA;
if (hd->dev_no == 1) { // 若是从盘就置DEV位为1
reg_device = BIT_DEV_DEV;
}
outb(reg_dev(hd->my_channel), reg_device);
}

写入硬盘寄存器,表示需要访问对应的磁盘

1
2
3
4
5
6
7
8
9
10
static void select_sector(struct disk* hd, uint32_t lba, uint8_t sec_cnt) {
struct ide_channel* channel = hd->my_channel;
outb(reg_sect_cnt(channel), sec_cnt); // 如果sec_cnt为0,则表示写入256个扇区
outb(reg_lba_l(channel), lba); // lba地址的低8位,不用单独取出低8位.outb函数中的汇编指令outb %b0, %w1会只用al。
outb(reg_lba_m(channel), lba >> 8); // lba地址的8~15位
outb(reg_lba_h(channel), lba >> 16); // lba地址的16~23位
/* 因为lba地址的24~27位要存储在device寄存器的0~3位,
* 无法单独写入这4位,所以在此处把device寄存器再重新写入一次*/
outb(reg_dev(channel), BIT_DEV_MBS BIT_DEV_LBA (hd->dev_no == 1 ? BIT_DEV_DEV : 0) lba >> 24);
}

同理,将需要写的扇区起始地址和需要访问的扇区数写入对应的寄存器。

完成之后,再向硬盘发送read指令然后挂起进程陷入沉睡,等待硬盘响应(发起中断)后,告诉硬盘可以继续发出中断后,从固定端口读写数据即可。

中断处理函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void intr_hd_handler(uint8_t irq_no) {
ASSERT(irq_no == 0x2e irq_no == 0x2f);
uint8_t ch_no = irq_no - 0x2e;
struct ide_channel* channel = &channels[ch_no];
ASSERT(channel->irq_no == irq_no);
/* 不必担心此中断是否对应的是这一次的expecting_intr,
* 每次读写硬盘时会申请锁,从而保证了同步一致性 */
if (channel->expecting_intr) {
channel->expecting_intr = false;
sema_up(&channel->disk_done);

/* 读取状态寄存器使硬盘控制器认为此次的中断已被处理,
* 从而硬盘可以继续执行新的读写 */
inb(reg_status(channel));
}
}

线程调度

最后是有关线程调度的新内容,首先是主动挂起:

1
2
3
4
5
6
7
8
9
void thread_yield(void) {
struct task_struct* cur = running_thread();
enum intr_status old_status = intr_disable();
ASSERT(!elem_find(&thread_ready_list, &cur->general_tag));
list_append(&thread_ready_list, &cur->general_tag);
cur->status = TASK_READY;
schedule();
intr_set_status(old_status);
}

就是简单的把自己设为ready状态并挂进等待队列而已。

另外一个是在调度队列中没有可调度的线程时,让调度器不至于出错而设定的线程:

1
2
3
4
5
6
7
8
/* 系统空闲时运行的线程 */
static void idle(void* arg UNUSED) {
while(1) {
thread_block(TASK_BLOCKED);
//执行hlt时必须要保证目前处在开中断的情况下
asm volatile ("sti; hlt" : : : "memory");
}
}

hlt指令是让处理器停止运行,直到遇到中断为止。

初始化时会主动创建该线程并将其阻塞。当调度器在调度队列中找不到可调度的线程时,会主动唤醒该线程,该线程会手动阻塞自己并等待下一次中断发生。


插画ID:91443649