17 实现FAT16文件系统(1)——基础设施建设:硬盘驱动、RTC

什么是文件系统呢?简而言之,文件系统就是管理文件的系统。当我们谈及对文件的操作的时候,文件系统是动态的,我们可以与它交互;当我们谈及文件系统的磁盘结构之类的东西的时候,文件系统又是静态的,它的每一个字节都摆在那里,随你可看。

本节我们先不着急实现文件系统,以及介绍那个怪怪的 FAT16 到底是个什么东西。我们先来完善一下基础设施建设,写一下硬盘驱动以及 RTC(Real-Time Clock,实时时钟)。

当然,我们先挑软柿子捏,从 RTC 开始实现。

与键盘类似,RTC 也是外部设备,需要使用 in/out 指令从对应的端口来读取数据。这之中,端口 0x70 是索引寄存器,用来告诉 RTC 你要读什么数据;端口 0x71 是数据寄存器,你想读的 RTC 数据就从这里读出。查阅资料可知,当前时刻的世纪、年、月、日、时、分、秒分别对应着索引 0x320x90x80x70x40x20x0。需要注意的是,从 RTC 读出的数据使用 8421BCD 编码,需要手动转换为十进制;具体而言,是将读出的数据的高4位当作十位,低4位当作个位。还有一点需要注意,在读取完后,需要向 0x70 端口发送 0x80,表示读取完成。

好了,我们就用上面一段话完整地描述了 RTC 的实现,相当简单吧?那么,开工。

首先,创建 include/cmos.h,我们来把上面的这一堆常数写在一个地方:

代码 17-1 RTC 声明(include/cmos.h)

#ifndef _CMOS_H_
#define _CMOS_H_

#include "common.h"

#define CMOS_INDEX 0x70
#define CMOS_DATA  0x71

#define CMOS_CUR_SEC 0x0
#define CMOS_CUR_MIN 0x2
#define CMOS_CUR_HOUR 0x4
#define CMOS_CUR_DAY 0x7
#define CMOS_CUR_MON 0x8
#define CMOS_CUR_YEAR 0x9
#define CMOS_CUR_CEN 0x32

#define bcd2hex(n) (((n >> 4) * 10) + (n & 0xf))

typedef struct {
    int year, month, day, hour, min, sec;
} current_time_t;

#endif

不仅定义了这些常量,还在最后添加了 bcd2hex 和一个结构体类型,这纯粹是为了后续方便。

然后,由于 RTC 是外设,我们在 drivers 目录下添加 cmos.c,来写真正操作 RTC 的代码:

代码 17-2 读取 RTC(drivers/cmos.c)

#include "cmos.h"

static uint8_t read_cmos(uint8_t p)
{
    uint8_t data;
    outb(CMOS_INDEX, p);
    data = inb(CMOS_DATA);
    outb(CMOS_INDEX, 0x80);
    return data;
}

void get_current_time(current_time_t *ctime)
{
    ctime->year = bcd2hex(read_cmos(CMOS_CUR_CEN)) * 100 + bcd2hex(read_cmos(CMOS_CUR_YEAR));
    ctime->month = bcd2hex(read_cmos(CMOS_CUR_MON));
    ctime->day = bcd2hex(read_cmos(CMOS_CUR_DAY));
    ctime->hour = bcd2hex(read_cmos(CMOS_CUR_HOUR));
    ctime->min = bcd2hex(read_cmos(CMOS_CUR_MIN));
    ctime->sec = bcd2hex(read_cmos(CMOS_CUR_SEC));
}

总共20行,我们就实现了对 RTC 的读取。那么让我们进入 kernel/main.c 添加测试代码看看效果:

代码 17-3 测试 RTC(kernel/main.c)

void kernel_main() // kernel.asm会跳转到这里
{
    monitor_clear();
    init_gdtidt();
    init_memory();
    init_timer(100);
    init_keyboard();
    asm("sti");

    task_t *task_a = task_init();
    task_t *task_shell = create_kernel_task(shell);
    //task_run(task_shell);

    current_time_t ctime;
    get_current_time(&ctime);
    printk("%d/%d/%d %d:%d:%d", ctime.year, ctime.month, ctime.day, ctime.hour, ctime.min, ctime.sec);

    while (1);
}

我们注释掉了开始运行 task_shell 的这行代码,因为在这三节里都用不上它。

编译,运行,效果如下(运行效果与运行时间有关,请自行与右下角时间对照):

(图 17-1 貌似成功了?)

(图 17-2 实际时间)

我们观察到,运行时显示的 RTC 时间与实际时间相差 8 小时,这是一个非常特殊的数字,因为中国所在的时区就是东八区(UTC+8)。然而,在我换用 VMWare 虚拟机测试的时候,时钟又恢复正常了。看来这一现象的出现与不同虚拟机模拟 RTC 的策略有关。

为了与现实相符合,我们选择手动调节 RTC 的输出,让它加上 8 小时。

代码 17-4 手动加上 8 小时(drivers/cmos.c)

#include "cmos.h"

static uint8_t read_cmos(uint8_t p)
{
    uint8_t data;
    outb(CMOS_INDEX, p);
    data = inb(CMOS_DATA);
    outb(CMOS_INDEX, 0x80);
    return data;
}

#ifdef NEED_UTC_8
static bool is_leap_year(int year)
{
    if (year % 400 == 0) return true;
    return year % 4 == 0 && year % 100 != 0;
}
#endif

void get_current_time(current_time_t *ctime)
{
    ctime->year = bcd2hex(read_cmos(CMOS_CUR_CEN)) * 100 + bcd2hex(read_cmos(CMOS_CUR_YEAR));
    ctime->month = bcd2hex(read_cmos(CMOS_CUR_MON));
    ctime->day = bcd2hex(read_cmos(CMOS_CUR_DAY));
    ctime->hour = bcd2hex(read_cmos(CMOS_CUR_HOUR));
    ctime->min = bcd2hex(read_cmos(CMOS_CUR_MIN));
    ctime->sec = bcd2hex(read_cmos(CMOS_CUR_SEC));
#ifdef NEED_UTC_8
    int day_of_months[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
    if (is_leap_year(ctime->year)) day_of_months[2]++;
    // 校正时间
    ctime->hour += 8;
    if (ctime->hour >= 24) ctime->hour -= 24, ctime->day++;
    if (ctime->day > day_of_months[ctime->month]) ctime->day = 1, ctime->month++;
    if (ctime->month > 12) ctime->month = 1, ctime->year++;
#endif
}

这里需要特别注意的是对边界情况的考虑,如果加上 8 小时后刚好跨天、跨月甚至是跨年,我们都需要做相应的处理,由此带来的还有闰年时 2 月天数的有关问题,但总体上也不算复杂。

同样还需要注意的是,为了应对不同虚拟机间的不同模拟情况,这里使用了宏定义 NEED_UTC_8,当定义这个宏时就会自动增加给 RTC 添加 8 小时的处理,否则就是代码 17-2 的样子。

如果你始终选择用 QEMU 进行模拟,记得在 include/cmos.h 中加入一行 #define NEED_UTC_8

在手动加完 8 小时之后,再次编译运行,效果如下(运行效果与运行时间有关,请自行与右下角时间对照):

(图 17-3 这下对了)

(图 17-4 当前时间)

ok,那么 RTC 就这样被我们轻松拿下。然后是下一个据点:硬盘驱动。

硬盘,显然也是外部设备,如果要真正详细地去实现硬盘,那足够写出这个 OS 现在的代码三分之一的代码量的驱动来(osdev上的 IDE 驱动有 749 行)。不过,只是读取和写入的话,实际上有捷径可走,无需像 osdev 上一样费劲绕道 PCI,只需要几个简单的端口操作即可。

即使是走捷径,硬盘的端口操作仍然多且杂,具体可见下表(仍旧来自《Orange'S:一个操作系统的实现》): (图 17-5 硬盘端口列表)

以上的部分就是我们需要用到的部分,其中的 Secondary 一列代表第二块硬盘,可以不管,反正到最后只需要操作第一块硬盘。

想要读写一块硬盘的一个扇区,大概操作是这样的:

1.等待可能存在的上一个硬盘操作完成。

2.通过向 0x1f2~0x1f6 端口写入适当数据,告知硬盘需要操作的扇区编号及个数。

3.向 0x1f7 端口写入 0x20(代表读)或者 0x30(代表写)。

4.等待硬盘操作完成。

5.从 0x1f0 端口读出数据或向 0x1f0 端口写入数据,一次两个字节。

看上去比较简单,但是有一些具体的技术细节需要注意,还是直接看代码吧:

代码 17-5 硬盘驱动:等待上一个硬盘操作完成,指定操作扇区(drivers/hd.c)

#include "common.h"

// 等待磁盘,直到它就绪
static void wait_disk_ready()
{
    while (1) {
        uint8_t data = inb(0x1f7); // 输入时,0x1f7端口为主硬盘状态寄存器
        if ((data & 0x88) == 0x08) { // 第7位:硬盘忙,第3位:硬盘已经准备好
            // 提取第7位和第3位,判断是否为0x08,即硬盘不忙且已准备好
            return; // 等完了
        }
    }
}

// 选择要操作扇区
static void select_sector(int lba)
{
    // 第一步:向0x1f2端口指定要读取扇区数
    // 输出时,0x1f2端口为操作扇区数
    outb(0x1f2, 1);
    // 第二步:存入写入地址
    // 0x1f3~0x1f5:LBA的低中高8位
    // 0x1f6:REG_DEVICE,Drive | Head | LBA (24~27位)
    // 在实际操作中,只有一个硬盘,Drive | Head = 0xe0
    outb(0x1f3, lba);
    outb(0x1f4, lba >> 8);
    outb(0x1f5, lba >> 16);
    outb(0x1f6, (((lba >> 24) & 0x0f) | 0xe0));
}

以上两个函数便是我前面提到过的“具体细节”,有关说明已经写在注释中了。

或许有人要问:

那你为什么一次只操作一个扇区呢?一次操作多个扇区不好吗?

你说得对,但是,由于 QEMU 的问题(这是第几遍出现了),一次操作多个扇区会莫名其妙卡住,所以只好一次操作一个扇区了。

既然技术细节已经填充上,单独读取和写入一个扇区的函数也就可以写了:

代码 17-6 硬盘驱动:读取和写入一个扇区(drivers/hd.c)

// 读取一个扇区
static void read_a_sector(int lba, uint32_t buffer)
{
    while (inb(0x1f7) & 0x80); // 等硬盘不忙了再发送命令,具体意义见wait_disk_ready
    select_sector(lba); // 第二步:设置读写扇区
    outb(0x1f7, 0x20); // 第三步:宣布要读扇区
    // 0x1f7在被写入时为REG_COMMAND,写入读写命令
    wait_disk_ready(); // 第四步:检测硬盘状态,直到硬盘就绪
    // 第五步:从0x1f0读取数据
    // 0x1f0被读写时为REG_DATA,读出或写入数据
    for (int i = 0; i < 256; i++) {
        // 每次硬盘会发送2个字节数据
        uint16_t data = inw(0x1f0);
        *((uint16_t *) buffer) = data; // 存入buf
        buffer += 2;
    }
}

// 写入一个扇区
// 写入与读取基本一致,仅有的不同之处是写入的命令和写数据的操作
static void write_a_sector(int lba, uint32_t buffer)
{
    while (inb(0x1f7) & 0x80); // 等硬盘不忙了再发送命令,具体意义见wait_disk_ready
    select_sector(lba); // 第二步:设置读写扇区
    outb(0x1f7, 0x30); // 第三步:宣布要写扇区
    // 0x1f7在被写入时为REG_COMMAND,写入读写命令
    wait_disk_ready(); // 第四步:检测硬盘状态,直到硬盘就绪
    // 第五步:从0x1f0读取数据
    // 0x1f0被读写时为REG_DATA,读出或写入数据
    for (int i = 0; i < 256; i++) {
        // 每次硬盘会发送2个字节数据
        uint16_t data = *((uint16_t *) buffer); // 读取数据
        outw(0x1f0, data); // 写入端口
        buffer += 2;
    }
}

这里其实有意地忽略了一个细节:硬盘操作执行完后,会发送一个硬盘中断。不过,由于我们并没有编写硬盘中断处理程序,因此它会被我们的框架自动忽略,看来几节以前我们打的地基还是很有用的。

读写多个扇区就是对读写单个扇区的简单重复:

代码 17-7 硬盘驱动:连续读写多个扇区(drivers/hd.c)

// 读取硬盘
static void read_disk(int lba, int sec_cnt, uint32_t buffer)
{
    for (int i = 0; i < sec_cnt; i++) {
        read_a_sector(lba, buffer); // 一次读一个扇区
        lba++; // 下一个扇区
        buffer += 512; // buffer也要指向下一个扇区
    }
}

// 写入硬盘
static void write_disk(int lba, int sec_cnt, uint32_t buffer)
{
    for (int i = 0; i < sec_cnt; i++) {
        write_a_sector(lba, buffer); // 一次写一个扇区
        lba++; // 下一个扇区
        buffer += 512; // buffer也要指向下一个扇区
    }
}

最后是两个包装函数作为公开的接口,不同之处仅仅是用 void * 替代 uint32_t 作为缓冲区类型:

代码 17-8 硬盘驱动:最终暴露的接口(drivers/hd.c)

// 包装
void hd_read(int lba, int sec_cnt, void *buffer)
{
    read_disk(lba, sec_cnt, (uint32_t) buffer);
}

void hd_write(int lba, int sec_cnt, void *buffer)
{
    write_disk(lba, sec_cnt, (uint32_t) buffer);
}

好,硬盘驱动到此结束,但是怎么测试呢?显然,这时并不存在一个虚拟硬盘。

为了后面行文方便,同时也是为了配置环境方便,这里引入我自制的一个开源工具:myfattools,使用它可以方便地对虚拟硬盘进行操作,包括但不限于创建、格式化、拷贝文件进出等等,目前已经在 Windows 7、Windows 11 和 iOS 上进行过测试(实际上 myfattools 就是在这三种操作系统上开发的)。为了跨平台需要 因为我懒,请读者自行下载这几个 .c 文件,然后用 gcc 自行编译为二进制,放在可以随时调用到的地方(比如这个项目的根目录处)以备调用。

本次测试需要用到的程序为 ftimage,确认这个程序是否存在且可供调用:

(图 17-6 程序存在情况)

如果在命令行输入 ftimage 后,输出如上图所示(或类似),则说明这几个程序配置相当成功;否则,请检查是否把这几个程序放在了正确的地方。

在命令行中执行这条命令:

(图 17-7 执行命令)

若无返回消息,则说明成功。现在 hd.img 就是一个有数据的虚拟硬盘了。

main.c 中将 kernel_main 修改如下:

代码 17-9 测试硬盘用 kernel_main(kernel/main.c)

void kernel_main() // kernel.asm会跳转到这里
{
    monitor_clear();
    init_gdtidt();
    init_memory();
    init_timer(100);
    init_keyboard();
    asm("sti");

    task_t *task_a = task_init();
    task_t *task_shell = create_kernel_task(shell);
    //task_run(task_shell);

    char first_sect[512] = {0};
    hd_read(0, 1, first_sect);
    printk(first_sect);

    while (1);
}

实际上就是读取第一个扇区的内容。

编译,运行,效果如图: (图 17-8 啥也没有?)

什么都没有输出,这是因为我们还没有在 QEMU 上挂载这个虚拟硬盘,修改 Makefile 中的 run 指令如下:

代码 17-10 Makefile 中的 run(Makefile)

run : a.img
    qemu-system-i386 -fda a.img -hda hd.img -boot a

在挂载硬盘的同时指定从软盘启动,因为硬盘里根本啥都没有,从硬盘启动就废了。

编译,运行,效果如图: (图 17-9 成功) (图 17-10 硬盘内真实数据)

在去掉不可打印字符后,输出与硬盘内真实数据一致。由于 FATTOOLS 后紧跟着就是 00,所以后面的问号没有输出。总之,可以认为我们的硬盘驱动已经正常工作了。

那么,实现 FAT16 的基建已经基本铺好,下面的工作就是了解什么是 FAT16,然后动手实践了。