从零编写linux0.11 - 第八章 软盘操作

编程环境:Ubuntu Kylin 20.04、gcc-9.4.0

代码仓库:https://gitee.com/AprilSloan/linux0.11-project

linux0.11源码下载(不能直接编译,需进行修改)

本章目标

实现通过端口读取软盘扇区,为文件系统提供基础。

在本章,我们会通过 DMA 读写扇区数据,DMA 可以与 CPU 并行,不影响系统的正常运行。我们之前在 bootsect.s 中通过 BIOS 中断读取软盘扇区,但这种方法读取扇区需要等待一段时间,系统的工作效率较低。

本章预设读者了解软盘的工作流程,知道磁头、磁道、扇区等概念。

1.软盘延迟

软盘驱动器在不工作时电动机通常不转,在实际读写软盘之前,需要等待电动机启动并达到正常的运行速度,通常需要0.5秒左右。

当对软盘的读写操作完成时,也需要让电动机停止转动。但可能马上又需要对其进行读写操作,因此在没有操作后还需让其空转一段时间,大约3秒左右,这段时间没有操作就让电动机停止转动。

当软盘的读写操作发生错误,或某些其它情况导致电动机没有被关闭,我们也需要让系统在一定时间后自动将其关闭。Linux 中这个时间为100秒。

本节将会处理这些延时操作。

// sched.c
static struct task_struct *wait_motor[4] = {NULL, NULL, NULL, NULL};    // 等待电动机加速进程的指针数组
static int mon_timer[4] = {0, 0, 0, 0};     // 存放软驱启动加速所需时间值
static int moff_timer[4] = {0, 0, 0, 0};    // 存放软驱电动机停转之前需维持的时间,默认为10000滴答

void floppy_on(unsigned int nr)
{
    cli();
    while (ticks_to_floppy_on(nr))
        sleep_on(nr + wait_motor);
    sti();
}

void floppy_off(unsigned int nr)
{
    moff_timer[nr] = 3 * HZ;        // 3s
}

floppy_on 会指定电动机启动所需时间。ticks_to_floppy_on 会返回启动所需时间(一般不为0),若不为0,就会挂起当前任务。我们会通过时钟中断计算挂起的时间,当挂起0.5秒后,电动机启动完毕,我们再将任务唤醒。

floppy_off 只是单纯设置关闭软驱电动机所需时间。我们也会在时钟中断中计算关闭电动机的时间,到了时间后,就关闭电动机。

// sched.c
unsigned char current_DOR = 0x0C;           // 数字输出寄存器(初值:允许DMA和请求中断、启动FDC)
/**
 * 指定软盘到正常运转状态所需延迟滴答数
 * @param nr 软驱号(0-3)
 * @return 滴答数
 */
int ticks_to_floppy_on(unsigned int nr)
{
    unsigned char mask = 0x10 << nr;

    if (nr > 3)     // 系统最多支持4个软驱
        panic("floppy_on: nr>3");
    moff_timer[nr] = 10000;     // 默认关闭电机时间:100s
    cli();
    mask |= current_DOR;
    mask &= 0xFC;
    mask |= nr;
    if (mask != current_DOR) {
        outb(mask, FD_DOR);
        if ((mask ^ current_DOR) & 0xf0)
            mon_timer[nr] = HZ / 2; // 0.5s
        else if (mon_timer[nr] < 2)
            mon_timer[nr] = 2;
        current_DOR = mask;
    }
    sti();
    return mon_timer[nr];
}

第18行设置关闭软驱电动机默认滴答数,floppy_off 函数会将这个值设置为300个滴答。

数据输出寄存器 DOR(Digital Output Register)在 0x3f2 端口,其各位定义如下。

数据输出寄存器

含义
7 1:启动电动机D
6 1:启动电动机C
5 1:启动电动机B
4 1:启动电动机A
3 1:使能DMA & I/O接口
2 1:使能FDC
1,0 软驱选择。0=A,1=B,2=C,3=D

第10行代码选择要启动的电动机。current_DOR 保存的是上一次输出到 DOR 的值。第16行代码得到完整的标志位。先复位软驱选择位(第17行),再选择当前的软驱(第18行)。

如果 mask 与 current_DOR 的值相等,说明我们上一次已经输出了这个值,就不用再输出一次了。如果不相等,就把这个新值输出到 DOR 中(第20行)。如果上一次启动的电动机与这次启动的电动机不一样,就把开启软驱的时间设置为0.5秒。如果两次启动的电动机相同且延迟滴答数小于2,就把延迟时间设置为2个滴答(每个滴答10ms)。更新 current_DOR 的值。最后返回开启的延迟时间。

接下来我们看看时钟中断是怎么处理延时的。

// sched.c
void do_floppy_timer(void)
{
    int i;
    unsigned char mask = 0x10;

    for (i = 0; i < 4; i++, mask <<= 1) {
        if (!(mask & current_DOR))
            continue;
        if (mon_timer[i]) {
            if (!--mon_timer[i])
                wake_up(i + wait_motor);
        } else if (!moff_timer[i]) {
            current_DOR &= ~mask;
            outb(current_DOR, FD_DOR);
        } else
            moff_timer[i]--;
    }
}

void do_timer(long cpl)
{
	...
    if (current_DOR & 0xf0)
        do_floppy_timer();
	...
    schedule();
}

do_timer 是时钟中断的主要操作。每一次时钟中断就会检查之前是否开启了电动机,如果开启了,就执行 do_floppy_timer 函数。

do_floppy_timer 函数中,第7-18行会递减电动机的开启延时滴答数或关闭延时滴答数。若开启延时滴答数为0,将之前休眠的任务唤醒;若关闭延时滴答数,则通过端口关闭相应的电动机。

2.软盘中断

软盘作为外部设备,其 I/O 速度远远慢于 CPU 速度。为了提高系统效率,使用中断来处理软盘的信号。

// blk.h
#define DEVICE_INTR do_floppy
#ifdef DEVICE_INTR
void (*DEVICE_INTR)(void) = NULL;
#endif
# system_call.s
floppy_interrupt:
    pushl %eax
    pushl %ecx
    pushl %edx
    push %ds
    push %es
    push %fs
    movl $0x10, %eax
    mov %ax, %ds
    mov %ax, %es
    movl $0x17, %eax
    mov %ax, %fs
    movb $0x20, %al
    outb %al, $0x20     # 响应中断
    xorl %eax, %eax
    xchgl do_floppy, %eax
    testl %eax, %eax
    jne 1f
    movl $unexpected_floppy_interrupt, %eax
1:  call *%eax
    pop %fs
    pop %es
    pop %ds
    popl %edx
    popl %ecx
    popl %eax
    iret
// floppy.c
void floppy_init(void)
{
    set_trap_gate(0x26, &floppy_interrupt);
    outb(inb_p(0x21) & ~0x40, 0x21);
}

软盘中断先保存上下文,修改段寄存器,响应中断。然后检查 do_floppy 函数指针是否为空,为空就执行 unexpected_floppy_interrupt 函数,不然就直接执行 do_floppy 指向的函数。下面用 C 语言表示第16-21行代码的逻辑。

	if (do_floppy == NULL) {
        unexpected_floppy_interrupt();
    }
	else {
        do_floppy();
        do_floppy = NULL;
    }

最后将寄存器出栈,还原现场。

有了中断处理函数以后,我们还需要把它的地址放到中断描述符表中,并打开软盘中断,让CPU接收软盘的中断信号。

接下来看看 unexpected_floppy_interrupt 的处理过程。

// floppy.c
void unexpected_floppy_interrupt(void)
{
    output_byte(FD_SENSEI);
    result();
}

软盘控制器共可以接收15条命令。每个命令均经历三个阶段:命令阶段、执行阶段和结果阶段。

命令阶段是 CPU 向 FDC(软盘控制器)发送命令字节和参数字节。每条命令的第一个字节总是命令字节(命令码)。其后跟着0-8字节的参数。第4行代码对应于该阶段。

执行阶段是 FDC 执行命令规定的操作。在执行阶段 CPU 是不加干预的,一般是通过 FDC 发送中断请求获知命令执行的结束。

结果阶段是由 CPU 读取 FDC 数据寄存器返回值,从而获得 FDC 命令执行的结果。返回结果数据的长度为0-7字节。对于没有返回结果数据的命令,则应向 FDC 发送检测中断状态命令获得操作的状态。第5行代码对应于该阶段。

FD_SENSEI 是检测中断状态命令。通常在一个命令执行结束后会向 CPU 发出中断信号。对于读写扇区、读写磁道、读写删除标志、读标识场、格式化和扫描等命令以及非 DMA 传输方式下的命令引起的中断,可以直接根据主状态寄存器的标志知道中断原因。而对于驱动器就绪信号发生变化、寻道和重新校正(磁头回零道)而引起的中断,由于没有返回结果,就需要利用本命令来读取控制器执行命令后的状态信息。

从零编写linux0.11 - 第八章 软盘操作_第1张图片

FD_SENSEI 的命令码是0x08,返回两个结果。更多的细节请看这篇文章:软盘控制器编程方法。

// floppy.c
static void output_byte(char byte)
{
    int counter;
    unsigned char status;

    for(counter = 0; counter < 10000; counter++) {
        status = inb_p(FD_STATUS) & (STATUS_READY | STATUS_DIR);
        if (status == STATUS_READY) {
            outb(byte, FD_DATA);
            return;
        }
    }
    printk("Unable to send byte to FDC\n\r");
}

output_byte 用于向 FDC 传送指令或指令参数。我们首先要检查能否向 FDC 传送数据。

FD_STATUS 代表 0x3f4 端口(FDC 主状态寄存器),其各位含义如下:

FDC 主状态寄存器

名称 说明
7 RQM 数据口就绪:控制器 FDC 数据寄存器已准备就绪。
6 DIO 传输方向:1,FDC --> CPU;0,CPU --> FDC
5 NDM 非 DMA 方式:1,非 DMA 方式;0,DMA 方式
4 CB 控制器忙:FDC 正处于命令执行忙碌状态
3 DDB 软驱 D 忙
2 DCB 软驱 C 忙
1 DBB 软驱 B 忙
0 DAB 软驱 A 忙

我们读取 FDC 主状态寄存器的值到 status 中,如果 status 第7位为1,第6位为0,说明 FDC 数据寄存器已准备就绪,数据传输方向为 CPU 到 FDC,这时,将数据传输到 FD_DATA(FDC 数据寄存器,0x3f5端口)。这里用循环不断检查是否可以传送数据,如果直到循环结束也不能发送数据,就打印出错信息。

// floppy.c
#define MAX_REPLIES 7
static unsigned char reply_buffer[MAX_REPLIES];
static int result(void)
{
    int i = 0, counter, status;

    for (counter = 0; counter < 10000; counter++) {
        status = inb_p(FD_STATUS) & (STATUS_DIR | STATUS_READY | STATUS_BUSY);
        if (status == STATUS_READY)
            return i;
        if (status == (STATUS_DIR | STATUS_READY | STATUS_BUSY)) {
            if (i >= MAX_REPLIES)
                break;
            reply_buffer[i++] = inb_p(FD_DATA);
        }
    }
    printk("Getstatus times out\n\r");
    return -1;
}

前面已经说过结果最多会返回7字节,所以将 MAX_REPLIES 定义为7。

因为要接收来自 FDC 的数据,所以数据传输方向是 FDC 到 CPU。读取 FDC 主状态寄存器的值到 status 中,如果 status 第7位为1,第6位为0,第4位为0,说明已经发送完数据了,直接返回。如果 status 第7、6、4位都为1,则开始接收数据。如果直到循环结束也没有收到数据或收到7个以上的数据,就打印出错信息。

为了测试中断能否正常运行,我们还需要进行一些设置。

// floppy.c
static void seek_interrupt(void)
{
    output_byte(FD_SENSEI);
    result();
    printk("seek interrupt\n\r");
}

static void transfer(void)
{
    do_floppy = seek_interrupt;
    output_byte(FD_RECALIBRATE);
    output_byte(0);
}

void do_fd_request(void)
{
    floppy_on(0);   // 开启电动机
    transfer();
    floppy_off(0);
}

我们在 do_fd_request 中开启软驱电动机,然后将 do_floppy 设置为 seek_interrupt。之后,当触发软盘中断时,就会执行 seek_interrupt 函数。接着发送重新校正命令(FD_RECALIBRATE),并随之发送命令参数。

从零编写linux0.11 - 第八章 软盘操作_第2张图片

该命令用来让磁头退回到0磁道。通常用于在软盘操作出错时对磁头重新矫正定位。命令码是0x7,命令参数是驱动器号,我们软驱的驱动器号是0。

CPU 发送了 FD_RECALIBRATE 命令后继续执行,而 FDC 在处理 FD_RECALIBRATE 命令后,会触发软盘中断,执行 seek_interrupt 函数。

因为 FD_RECALIBRATE 命令并无返回结果,我们需要使用检测中断状态命令(FD_SENSEI)获取运行结果(运行结果的处理将放在后面讲解),如果屏幕上打印了 seek interrupt 就说明软盘中断触发成功。

最后,我们需要调用 do_fd_request 函数。

// sys.h
extern int sys_setup();
fn_ptr sys_call_table[72] = {sys_setup, sys_exit, sys_fork, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, sys_pause};

// unistd.h
#define __NR_setup  0

// main.c
void main(void)
{
    ...
    floppy_init();
    sti();
    ...
}

inline _syscall0(int, setup);

extern void do_fd_request(void);

int sys_setup()
{
    do_fd_request();
    return 0;
}

void init(void)
{
    setup();
    while (1);
}

由于用户不能访问软盘,所以写了一个系统调用来调用 do_fd_request。

看看运行结果吧。软盘中断正常执行。

从零编写linux0.11 - 第八章 软盘操作_第3张图片

3.读取扇区

在读取扇区前,我们要了解软盘的参数,这些参数都是固定的,如下所示。

// floppy.c
static struct floppy_struct {
    // 扇区数,每磁道扇区数,磁头数,磁道数,对磁道是否要进行特殊处理
    unsigned int size, sect, head, track, stretch;
    // 扇区间隙长度(字节数),数据传输速率,参数(高4位步进速率,低4位磁头卸载时间)
    unsigned char gap, rate, spec1;
} floppy_type[] = {
    { 2880,18,2,80,0,0x1B,0x00,0xCF },  /* 1.44MB diskette */
};

我们一直使用的都是1.44MB大小的软盘,所以我只列出这种软盘的参数,其他大小的软盘参数之后一起给出。各成员含义请看注释。

接着需要将参数传给 FDC。

// floppy.c
static struct floppy_struct *floppy = floppy_type;
static void transfer(void)
{
    output_byte(FD_SPECIFY);
    output_byte(floppy->spec1);
    output_byte(6);     // 磁头加载时间12ms,DMA方式
    outb_p(floppy->rate, FD_DCR);
    do_floppy = seek_interrupt;
    output_byte(FD_RECALIBRATE);
    output_byte(0);
}

FD_SPECIFY 是设定驱动器参数命令,该命令用于设定软盘控制器内部的三个定时器初始值和选择传输方式,即把驱动器马达步进速率(STR)、磁头加载/卸载(HLT/HUT)时间和是否采用DMA方式来传输数据的信息送入软驱控制器。

从零编写linux0.11 - 第八章 软盘操作_第4张图片

设定驱动器参数命令有两个参数,虽然它没有结果,但也不触发中断。这里将传输方式设置为 DMA 方式,DMA 方式的好处在于,它可以和 CPU 并行,即在同一时刻 CPU 和 DMA 同时运行,大大提高了系统的效率。

FD_DCR 是0x3f7端口,向它输出数据时它是磁盘控制寄存器(DCR),这个寄存器用于选择盘片在不同类型驱动器上使用的数据传输率。仅使用低2位。00 代表 500kbit/s,01 代表 300kbit/s,10 代表 250kbit/s。我们把数据传输率设置为 500kbit/s。

现在来看看 DMA 初始化的过程。

// floppy.c
extern unsigned char tmp_floppy_area[1024];

#define immoutb_p(val, port)    \
__asm__("outb %0, %1\n\tjmp 1f\n1:\tjmp 1f\n1:"::"a" ((char) (val)), "i"(port))

static void setup_DMA(void)
{
    long addr = (long) tmp_floppy_area;
    cli();
    immoutb_p(4 | 2, 10);   // 屏蔽DMA通道2
    // 保证对16位寄存器的读写是从低字节开始的
    // 选择通道2,禁止自动初始化,单字节传送,传送后地址增1
    __asm__("outb %%al, $12\n\tjmp 1f\n1:\tjmp 1f\n1:\t"
    "outb %%al, $11\n\tjmp 1f\n1:\tjmp 1f\n1:"::
    "a" ((char) (DMA_READ)));
    immoutb_p(addr, 4);     // 地址0-7位
    addr >>= 8;
    immoutb_p(addr, 4);     // 地址8-15位
    addr >>= 8;
    immoutb_p(addr, 0x81);  // 地址16-19位
    // 传送的字节数量count-1 (1024-1=0x3ff) */
    immoutb_p(0xff, 5);     // 低8位
    immoutb_p(3, 5);        // 高8位
    immoutb_p(0 | 2, 10);   // 开启通道2
    sti();
}

我们将 tmp_floppy_area 作为数据传输的目的地址。tmp_floppy_area 在head.s中定义,是专门给软盘驱动程序使用的一块内存,地址为 0x5000。

在设置 DMA 时,需要先关闭中断,设置完成后再打开中断。

端口10是 DMA 的掩码寄存器,通过它我们可以屏蔽 DMA 的通道。

第11行代码将 DMA 掩码寄存器的第1位和第2位设置为1,即设置通道2的掩码位,屏蔽通道2。

第25行代码复位通道2掩码位,开启通道2。

DMA 掩码寄存器

含义
0-1 00 = 选择通道0掩码位
01 = 选择通道1掩码位
10 = 选择通道2掩码位
11 = 选择通道3掩码位
2 0 = 复位掩码位
1 = 设置掩码位
3-7 不用管

端口12用于清除 MSB/LSB 触发器,对其进行复位,保证对16位寄存器的读写是从低字节开始的。(至于为什么写入 DMA_READ,我就不知道了)

端口11是模式寄存器,我们将其设置为 DMA_READ(0x46),对照下表可知,我们选择 DMA 的通道2,写传输,禁止自动初始化,单字节传输模式,传输后地址加1。(为什么读取是写传输?)

DMA 模式寄存器

含义
0-1 00 = 选择通道0
01 = 选择通道1
10 = 选择通道2
11 = 选择通道3
2-3 00 = 校验传输
01 = 写传输
10 = 读传输
11 = 非法
4 0 = 禁止自动初始化
1 = 开启自动初始化
5 0 = 地址加1
1 = 地址减1
6-7 00 = 选择命令模式
01 = 选择单字节模式
10 = 选择块模式
11 = 未使用

端口4是通道2的基地址寄存器,端口5表示通道2传输的字节数量。这两个寄存器都是16位寄存器,因为我们对端口12的设置,我们会从低字节开始写这两个寄存器。

DMA 的基地址寄存器只有16位,寻址范围太小,x86 架构中为它增加了一个4位 I/O 端口,保存高4位地址。DMA 通道2对应的端口是 0x81 端口。

第17-21行代码分别将目的地址输出到相应的端口中(从低字节开始写)。

DMA 传输的次数比端口5的值多一次。所以我们向端口5写入0x3ff,则会传输0x400(1024)次。

关于 DMA 的更多资料可以看这个手册:A8237 数据手册。

设置了 DMA 传输的目的地址,我们还要设置 DMA 传输的起始地址。

// floppy.c
static void rw_interrupt(void)
{
    result();
}

static void setup_rw_floppy(void)
{
    setup_DMA();
    do_floppy = rw_interrupt;
    output_byte(FD_READ);
    output_byte(0);     // 驱动器号
    output_byte(0);     // 磁道号
    output_byte(0);     // 磁头号
    output_byte(1);     // 起始扇区号
    output_byte(2);     // 扇区字节数
    output_byte(floppy->sect);  // 磁道上最大扇区号
    output_byte(floppy->gap);   // 扇区之间间隔长度
    output_byte(0xFF);
}

static void seek_interrupt(void)
{
    output_byte(FD_SENSEI);
    result();
    setup_rw_floppy();
}

先解释一下函数的运行顺序。transfer 函数执行后会触发软盘中断,执行 seek_interrupt,setup_rw_floppy 设置 DMA 的起始和目的地址,向 FDC 发送 FD_READ 命令,FDC 处理完 FD_READ 命令之后,DMA 开始传输数据,传输完成后触发中断,执行 rw_interrupt。

FD_READ 代表读扇区数据命令。该命令用于从磁盘上读取指定位置开始的扇区,经 DMA 控制传输到系统内存中。每当一个扇区读完,参数4就自动加1,以继续读取下一个扇区,直到 DMA 控制器把传输计数终止信号发送给软盘控制器。

对比代码与下表,我们从软盘的第1个扇区开始读取数据,也就是 kernel.img 的 bootloader 部分。我们在 DMA 设置时,设置 DMA 传输1024个字节,也就是2个扇区数据。

从零编写linux0.11 - 第八章 软盘操作_第5张图片

最后我们在系统调用中等待 DMA 传输结束,再把 tmp_floppy_area 中的内容打印出来。

// main.c
inline _syscall0(int, setup);

extern void do_fd_request(void);
extern unsigned char tmp_floppy_area[1024];

int sys_setup()
{
    int i = 5000;
    
    do_fd_request();
    while (i--)
        printk("\b");   // 延时,等待DMA传输结束
    for (i = 0; i < 256; i++) {
        if (i % 16 == 0)
            printk("\n\r");
        printk("%02x ", tmp_floppy_area[i]);
    }
    return 0;
}

void init(void)
{
    setup();
    while (1);
}

运行结果如下:

从零编写linux0.11 - 第八章 软盘操作_第6张图片

kernel.img 中的数据如下:

从零编写linux0.11 - 第八章 软盘操作_第7张图片

可以看到,我们读取的数据是没问题的。不过,这个程序只能读取相对扇区号0(磁道0,磁头0,扇区1)的数据,下一节可以读取任意扇区的数据。

4.查找扇区

在上一节中,我们成功通过 DMA 读取了软盘扇区,但是,读取操作需要我们指定驱动器、磁头、磁道和扇区等一系列参数,这对我们来说不太友好。可以的话,我们希望使用更少的参数得到相同的效果。

另外,我们只实现了读操作,这次顺便把写操作也加上。

为了规范化读写扇区的操作,我们定义一个结构体。

struct request {
    int cmd;                    // 命令(READ或WRITE)
    int errors;                 // 操作时产生错误的次数
    unsigned long sector;       // 相对扇区号
    char *buffer;               // 数据缓冲区
};

每次我们要读写扇区时,创建一个 request 结构体,并对其成员赋值,然后根据这些值分析扇区所在的驱动器、磁头、磁道等,并把数据存储到 buffer 所指向的内存空间中。在文件系统中,我们能够很容易获得相对扇区号。

// floppy.c
struct request *current_request;

struct request r = {READ, 0, 1, (char *) 0x5000};

void floppy_init(void)
{
    current_request = &r;
    set_trap_gate(0x26, &floppy_interrupt);
    outb(inb_p(0x21) & ~0x40, 0x21);
}

上面的代码创建了一个 request 结构体,并用一个全局变量指针指向这个结构体。我们的请求是从相对扇区号1开始读2个扇区到 0x5000 地址(tmp_floppy_area的起始地址)。(我们使用的是 minix 的文件系统,这个文件系统每次读取2个扇区)(相对扇区号1对应于 kernel.img 的 0x200-0x3ff)

接下来我们来解析 request 结构体里的参数。

// fs.h
#define READ 0
#define WRITE 1

// floppy.c
static int seek = 0;    // 是否需要移动磁头
static unsigned char current_drive = 0;     // 当前的驱动器
static unsigned char sector = 0;            // 扇区号
static unsigned char head = 0;              // 磁头号
static unsigned char track = 0;             // 磁道号
static unsigned char seek_track = 0;        // 目的磁道号
static unsigned char current_track = 255;   // 当前磁道号
static unsigned char command = 0;           // 命令(READ或WRITE)
void do_fd_request(void)
{
    unsigned int block;

    seek = 0;
    if (!current_request)
        return;
    block = current_request->sector;

    // 相对扇区号=每磁道扇区数*总磁头数*磁道号+每磁道扇区数*磁头号+扇区号-1
    sector = block % floppy->sect;  // 扇区号 - 1
    sector++;
    block /= floppy->sect;          // 总磁头数*磁道号+磁头号
    head = block % floppy->head;    // 磁头号
    track = block / floppy->head;   // 磁道号
    seek_track = track << floppy->stretch;
    if (seek_track != current_track)
        seek = 1;                   // 需要让磁头移动到指定磁道
    if (current_request->cmd == READ)
        command = FD_READ;
    else if (current_request->cmd == WRITE)
        command = FD_WRITE;
    else
        panic("do_fd_request: unknown command");
    floppy_on(current_drive);
    transfer();
    floppy_off(current_drive);
}

如果没有读写软盘的请求(current_request 为空),直接返回。

接着我们要从相对扇区号得到磁道号,磁头号和扇区号,计算过程如注释所示。1.44M软盘的每磁道扇区数是18,总磁头数是2。相对扇区号0对应于磁道号0,磁头号0,扇区号1;相对扇区号1对应于磁道号0,磁头号0,扇区号2;相对扇区号100对应于磁道号2,磁头号1,扇区号11。

seek_track 代表目的磁道号,current_track 代表磁头所在的磁道号,如果两个值不相等,则需要让磁头移动到目的磁道上。

// floppy.c
static void transfer(void)
{
    output_byte(FD_SPECIFY);
    output_byte(floppy->spec1);
    output_byte(6);     // 磁头加载时间12ms,DMA方式
    outb_p(floppy->rate, FD_DCR);
    if (!seek) {
        setup_rw_floppy();
        return;
    }
    do_floppy = seek_interrupt;
    if (seek_track) {
        output_byte(FD_SEEK);
        output_byte(head << 2 | current_drive);
        output_byte(seek_track);
    } else {
        output_byte(FD_RECALIBRATE);
        output_byte(current_drive);
    }
}

如果磁头所在的磁道就是目的磁道,就直接发送读写命令。

如果需要移动磁道,则有两种情况。如果目的磁道号是0,就发送 FD_RECALIBRATE 命令,让磁头回退到0磁道。如果目的磁道号不为0,发送 FD_SEEK(磁头寻道)命令。

从零编写linux0.11 - 第八章 软盘操作_第8张图片

该命令让选中驱动器的磁头移动到指定磁道上。第1个参数指定驱动器号和磁头号,位0-1是驱动器号,位2是磁头号,其他比特位无用。第2个参数指定磁道号。需要使用 FD_SENSEI(检测中断状态)命令获取执行结果。

// floppy.c
static void setup_rw_floppy(void)
{
    setup_DMA();
    do_floppy = rw_interrupt;
    output_byte(command);
    output_byte(current_drive); // 驱动器号
    output_byte(track);         // 磁道号
    output_byte(head);          // 磁头号
    output_byte(sector);        // 起始扇区号
    output_byte(2);		/* sector size = 512 */
    output_byte(floppy->sect);  // 磁道上最大扇区号
    output_byte(floppy->gap);   // 扇区之间间隔长度
    output_byte(0xFF);	/* sector size (0xff when n!=0 ?) */
}

static void seek_interrupt(void)
{
    output_byte(FD_SENSEI);
    result();
    current_track = reply_buffer[1];
    setup_rw_floppy();
}

若需要移动磁头到指定磁道,在 transfer 函数之后,会触发中断进入 seek_interrupt 函数。发送 FD_SENSEI 命令,它的第二个返回结果是磁头所在磁道号,我们用它来更新 current_track 的值。

setup_rw_floppy 函数中,我们用全局变量作为软盘读写命令的参数,读写扇区数据命令的格式相同。

从零编写linux0.11 - 第八章 软盘操作_第9张图片

// floppy.c
#define copy_buffer(from, to) \
__asm__("cld\n\trep\n\tmovsl" \
    ::"c"(BLOCK_SIZE/4), "S"((long)(from)), "D"((long)(to)) \
    )

static void setup_DMA(void)
{
    long addr = (long) current_request->buffer;

    cli();
    if (addr >= 0x100000) {
        addr = (long) tmp_floppy_area;
        if (command == FD_WRITE)
            copy_buffer(current_request->buffer, tmp_floppy_area);
    }
    immoutb_p(4 | 2, 10);   // 屏蔽DMA通道2
    // 保证对16位寄存器的读写是从低字节开始的
    // 选择通道2,禁止自动初始化,单字节传送,传送后地址增1
    __asm__("outb %%al, $12\n\tjmp 1f\n1:\tjmp 1f\n1:\t"
    "outb %%al, $11\n\tjmp 1f\n1:\tjmp 1f\n1:"::
    "a"((char) ((command == FD_READ) ? DMA_READ : DMA_WRITE)));
    immoutb_p(addr, 4);     // 地址0-7位
    addr >>= 8;
    immoutb_p(addr, 4);     // 地址8-15位
    addr >>= 8;
    immoutb_p(addr, 0x81);  // 地址16-19位
    // 传送的字节数量count-1 (1024-1=0x3ff) */
    immoutb_p(0xff, 5);     // 低8位
    immoutb_p(3, 5);        // 高8位
    immoutb_p(0 | 2, 10);   // 开启通道2
    sti();
}

如果数据缓冲区的地址大于 0x100000(超过了 DMA 的寻址范围),就把地址改为 tmp_floppy_area。如果是写指令的话,还需要把数据缓冲区的内容拷贝到 tmp_floppy_area 中。

第22行代码中,对于读/写命令我们向 DMA 发送不同的数据。

// floppy.c
static void rw_interrupt(void)
{
    result();
    if (command == FD_READ && (unsigned long)(current_request->buffer) >= 0x100000)
        copy_buffer(tmp_floppy_area, current_request->buffer);
}

在读写扇区完成后,DMA 触发中断,执行 rw_interrupt 函数。如果之前是读扇区命令,且数据缓冲区地址超过 DMA 的寻址范围,我们还需要把数据从 tmp_floppy_area 拷贝到数据缓冲区中。

最后来看看执行的效果。

从零编写linux0.11 - 第八章 软盘操作_第10张图片

可以看到,tmp_floppy_area 的数据与 kernel.img 中对应地址的数据相同。

5.申请需求

一般来说,操作系统有多种设备,比如软盘,硬盘,内存条等,我们如何区分这些设备呢?操作系统如何为不同的设备选择不同的驱动程序呢?

在 linux 中使用设备号来区分多种设备。设备号又分为主设备号和次设备号,主设备号用来表示一个特定的驱动程序,次设备号用来表示使用该驱动程序的其他设备。通俗的说就是,主设备号标识设备对应的驱动程序,告诉操作系统使用哪一个驱动程序为该设备服务;而次设备号则用来标识具体且唯一的某个设备。

在 linux0.11 中,软盘的主设备号是2,硬盘的主设备号是3。我们系统上软盘的设备号是 0x21c,它的次设备号是 0x1c,次设备号在代码中的使用方法将在后面进行说明。

对于每种设备存在自己的驱动程序,以及触发驱动程序的请求。使用一个结构体将这两种变量绑定在一起。

// blk.h
struct blk_dev_struct {
    void (*request_fn)(void);
    struct request *current_request;
};
#define DEVICE_REQUEST do_fd_request
void (DEVICE_REQUEST)(void);

// ll_rw_blk.c
struct blk_dev_struct blk_dev[NR_BLK_DEV] = {
    { NULL, NULL },     // 0 - 无设备
    { NULL, NULL },     // 1 - 内存
    { NULL, NULL },     // 2 - 软驱设备
    { NULL, NULL },     // 3 - 硬盘设备
    { NULL, NULL },     // 4 - ttyx设备
    { NULL, NULL },     // 5 - tty设备
    { NULL, NULL }      // 6 - lp打印机设备
};

// floppy.c
#define MAJOR_NR 2
void floppy_init(void)
{
    blk_dev[MAJOR_NR].request_fn = DEVICE_REQUEST;
    set_trap_gate(0x26, &floppy_interrupt);
    outb(inb_p(0x21) & ~0x40, 0x21);
}

其实,我们的操作系统只会用到软盘设备的驱动程序,将 request_fn 和 current_request 定义成全部变量,只供软盘使用,程序会变成更简单。但是,我觉得有必要普及一下用设备号选择特定驱动程序的思想。

将软盘的驱动程序设置为 do_fd_request,如果有读写软盘的请求,就将 blk_dev[2].current_request 指向 request 结构体。在 linux0.11 中也有硬盘的驱动程序 do_hd_request,但是我的系统里不准备加入硬盘操作,所以删掉了。

我们需要定义一个 request 结构体数组,并对其进行初始化。

// blk.h
#define NR_REQUEST  32

struct request {
    int dev;                    // 使用的设备号,无请求则为-1
    int cmd;                    // 命令(READ或WRITE)
    int errors;                 // 操作时产生错误的次数
    unsigned long sector;       // 相对扇区号
    unsigned long nr_sectors;   // 读写扇区数
    char *buffer;               // 数据缓冲区
    struct buffer_head *bh;     // 缓冲区头指针
    struct request *next;       // 指向下一个请求项
};

// ll_rw_block.c
struct request request[NR_REQUEST];

void blk_dev_init(void)
{
    int i;

    for (i = 0; i < NR_REQUEST; i++) {
        request[i].dev = -1;
        request[i].next = NULL;
    }
}

另外,我对 request 结构体做了修改。添加了设备,以表明来自哪个设备的请求;添加读写扇区数,它的值一般为2,linux0.1 的文件系统都是以2个扇区为单位进行读写的;添加缓冲区头指针,缓冲区头指针中保存了读扇区时的目的地址和写扇区时的起始地址;当操作系统中存在多个请求时,会将它们串成链表,一个一个执行,于是添加了 next 指针。

// fs.h
#define MAJOR(a) (((unsigned)(a)) >> 8)

// ll_rw_block.c
void ll_rw_block(int rw, struct buffer_head *bh)
{
    unsigned int major = MAJOR(bh->b_dev);

    if (major >= NR_BLK_DEV || !(blk_dev[major].request_fn)) {
        printk("Trying to read nonexistent block-device\n\r");
        return;
    }
    make_request(major, rw, bh);
}

现在调用 ll_rw_block 就能读写扇区了。

ll_rw_block 函数的参数 rw 代表读写命令,值为 READ 时为读,为 WRITE 时为写。buffer_head 结构体有很多成员,但大部分都在 buffer_init 函数中初始化好了(参考第4章),我们只需要设置 b_dev(设备号)和 b_blocknr(逻辑块号)这2个成员就好。1个逻辑块大小为 1KB,对应于2个扇区。逻辑块号0对应于相对扇区号0和1;逻辑块号2对应于相对扇区号4和5。

用 major 保存设备的主设备号。然后检查主设备号是否合法,设备是否有驱动程序。

make_request 会对 request 结构体成员进行赋值。

// ll_rw_block.c
struct task_struct *wait_for_request = NULL;    // 请求数组没有空闲项时的临时等待处
static void make_request(int major, int rw, struct buffer_head *bh)
{
    struct request *req;

    if (rw != READ && rw != WRITE)	// 如果既不是读命令也不是写命令,就报错
        panic("Bad block dev command, must be R/W/RA/WA");

repeat:
    if (rw == READ)
        req = request + NR_REQUEST; // 对于读请求,将指针指向队列尾部
    else
        req = request + ((NR_REQUEST * 2) / 3); // 对于写请求,指针指向队列2/3处

    while (--req >= request)    // 搜索一个空请求项
        if (req->dev < 0)
            break;
    
    if (req < request) {        // 如果没有找到空闲项,则让该次请求操作睡眠
        sleep_on(&wait_for_request);
        goto repeat;
    }

    req->dev = bh->b_dev;
    req->cmd = rw;
    req->errors = 0;
    req->sector = bh->b_blocknr << 1;
    req->nr_sectors = 2;
    req->buffer = bh->b_data;
    req->bh = bh;
    req->next = NULL;
    add_request(major + blk_dev, req);
}

对于读命令,从请求数组末尾开始搜索空闲项;对于写命令,从请求数组的2/3处开始搜索。为什么要这样设计呢?我们不能让所有请求都是写请求,需要为读请求保留一些空间,因为读操作是优先的。

如果没有找到空闲项,则让发送请求的任务睡眠。等任务重新唤醒时,跳转到 repeat 处,重新搜索空闲项。(当有请求被执行完后,就会唤醒任务)

找到空闲项后,对空闲项成员进行赋值。因为逻辑块是扇区的2倍大小,所以扇区号是逻辑块号的2倍。

add_request 用于执行设备的驱动程序或将请求加入链表中。

// blk.h
#define IN_ORDER(s1, s2)    \
((s1)->cmd < (s2)->cmd || ((s1)->cmd == (s2)->cmd &&    \
((s1)->dev < (s2)->dev || ((s1)->dev == (s2)->dev &&    \
(s1)->sector < (s2)->sector))))

// ll_rw_block.c
static void add_request(struct blk_dev_struct *dev, struct request *req)
{
    struct request *tmp;

    req->next = NULL;
    cli();

    if (!dev->current_request) {    // 如果设备没有读写请求
        dev->current_request = req;
        sti();
        (dev->request_fn)();
        return;
    }
    
    // 将请求加入链表中
    for (tmp = dev->current_request; tmp->next; tmp = tmp->next)
        if ((IN_ORDER(tmp, req) || 
            !IN_ORDER(tmp, tmp->next)) &&
            IN_ORDER(req, tmp->next))
            break;
    req->next = tmp->next;
    tmp->next = req;
    sti();
}

如果设备没有读写请求,就直接执行设备的驱动程序,完成请求。

如果设备已经存在请求,则将请求加入链表中。我们首先看 IN_ORDER 宏定义,参数为两个请求,它的值为1的情况有:(1) s1 为读操作,s2为写操作;(2) s1 与 s2 为相同操作,s1 的设备号小于 s2;(3) s1 与 s2 的操作和设备号相同,s1 的扇区号小于 s2 的扇区号。

第23-27行代码是想找到一个合适的地方插入请求,怎么算合适呢?链表的当前节点的优先级高于请求,而下一个节点的优先级低于请求。找到位置后就插入进去。之后便等待任务被执行。

现在来说说次设备号的含义吧。次设备号有8个比特,低2位代表驱动器号,高6位代表软盘大小。所以设备号 0x21c 代表这个设备是软盘,大小为 1.44MB(floppy_type + 7),驱动器号为0。

// blk.h
#define DEVICE_NR(device) ((device) & 3)

// floppy.c
static struct floppy_struct {
    // 扇区数,每磁道扇区数,磁头数,磁道数,对磁道是否要进行特殊处理
    unsigned int size, sect, head, track, stretch;
    // 扇区间隙长度(字节数),数据传输速率,参数(高4位步进速率,低4位磁头卸载时间)
    unsigned char gap, rate, spec1;
} floppy_type[] = {
    {    0, 0,0, 0,0,0x00,0x00,0x00 },  /* no testing */
    {  720, 9,2,40,0,0x2A,0x02,0xDF },  /* 360kB PC diskettes */
    { 2400,15,2,80,0,0x1B,0x00,0xDF },  /* 1.2 MB AT-diskettes */
    {  720, 9,2,40,1,0x2A,0x02,0xDF },  /* 360kB in 720kB drive */
    { 1440, 9,2,80,0,0x2A,0x02,0xDF },  /* 3.5" 720kB diskette */
    {  720, 9,2,40,1,0x23,0x01,0xDF },  /* 360kB in 1.2MB drive */
    { 1440, 9,2,80,0,0x23,0x01,0xDF },  /* 720kB in 1.2MB drive */
    { 2880,18,2,80,0,0x1B,0x00,0xCF },  /* 1.44MB diskette */
};
static struct floppy_struct *floppy = floppy_type;

我们用 DEVICE_NR 宏定义来获取设备的驱动器号。

之前我只列出了 1.44MB 软盘的参数,现在给出了全部的参数。可以通过 floppy 指针指向 floppy_type 数组的元素,方便在多个不同大小的软盘间切换。

// fs.h
#define MINOR(a) ((a) & 0xff)

// blk.h
#define DEVICE_NR(device) ((device) & 3)
#define REQUEST (blk_dev[MAJOR_NR].current_request)
#define REQUEST_DEV DEVICE_NR(REQUEST->dev)

// floppy.c
void do_fd_request(void)
{
    unsigned int block;

    seek = 0;
repeat:
    if (!REQUEST)	// 不存在请求,就直接返回
        return;
    if (MAJOR(REQUEST->dev) != MAJOR_NR)
        panic(DEVICE_NAME ": request list destroyed");
    floppy = (MINOR(REQUEST->dev) >> 2) + floppy_type;
    if (current_drive != REQUEST_DEV)
        seek = 1;
    current_drive = REQUEST_DEV;
    block = REQUEST->sector;
    if (block + 2 > floppy->size) {
        end_request();	// 执行下一个请求
        goto repeat;
    }
    // 相对扇区号=每磁道扇区数*总磁头数*磁道号+每磁道扇区数*磁头号+扇区号-1
    ...
}

为了方便起见,用 REQUEST 宏定义代表当前执行的请求,REQUEST_DEV 代表驱动器号。

如果发送请求的设备与驱动程序所属的设备不一致,则报错。

让 floppy 指向 1.44MB 软盘的参数结构体。

如果驱动器发生了变化,将 seek 置1,之后需要将磁头移动到目的磁道上。

如果扇区号超过了软盘的总扇区数,则结束当前请求,如果有下一个请求的话,执行下一个请求。

// blk.h
#define DEVICE_OFF(device) floppy_off(DEVICE_NR(device))
static inline void end_request()
{
    DEVICE_OFF(REQUEST->dev);   // 设置驱动器电动机停止的时间
    wake_up(&wait_for_request); // 唤醒等待的任务
    REQUEST->dev = -1;
    REQUEST = REQUEST->next;
}

首先设置驱动器电动机停止的时间,唤醒等待的任务(如果有的话),将当前的请求结构体的设备号设置为-1,这样就可以当作空闲项被使用了。

第8行代码被执行后,如果没有请求了,则 REQUEST 为 NULL,在 do_fd_request 中会导致返回;若还有请求,则 REQUEST 不为 NULL,则在 do_fd_request 中会正常运行,执行读写扇区的流程。

// floppy.c
static int cur_spec1 = -1;
static int cur_rate = -1;
static void transfer(void)
{
    if (cur_spec1 != floppy->spec1) {
        cur_spec1 = floppy->spec1;
        output_byte(FD_SPECIFY);
        output_byte(cur_spec1);
        output_byte(6);     // 磁头加载时间12ms,DMA方式
    }
    if (cur_rate != floppy->rate)
        outb_p(cur_rate = floppy->rate, FD_DCR);
    if (!seek) {
        setup_rw_floppy();
        return;
    }
    do_floppy = seek_interrupt;
    ...
}

由于我们有更换软盘的需求(加载文件系统软盘),而软盘大小不一定一致,所以我们要记录当前软盘的参数,如果这些参数发生了变化,就说明更换了软盘。我们需要重新设置软盘的参数。

// floppy.c
static void setup_DMA(void)
{
    long addr = (long) REQUEST->buffer;	// 修改 current_request 为 REQUEST

    cli();
    if (addr >= 0x100000) {
        addr = (long) tmp_floppy_area;
        if (command == FD_WRITE)
            copy_buffer(REQUEST->buffer, tmp_floppy_area); // 修改 current_request 为 REQUEST
    }
    ...
}

static void rw_interrupt(void)
{
    result();
    if (command == FD_READ && (unsigned long)(REQUEST->buffer) >= 0x100000)
        copy_buffer(tmp_floppy_area, REQUEST->buffer);
    end_request();
    do_fd_request();
}

在读写扇区结束后,会触发中断执行 rw_interrupt 函数,我们需要指定下一个请求,然后在运行一次驱动程序,直至执行了所有的请求。还要稍稍修改一下 setup_DMA 函数。

终于到了测试的环节,我们再编写一下测试代码。

; bootsect.s
ROOT_DEV    equ 0x21c

	...
msg:
    db 13, 10
    db "Loading system ..."
    db 13, 10, 13, 10

    times 0x1fc - ($ - $$) db 0 ; 填写0,直到0x1fc

root_dev:
    dw ROOT_DEV
boot_flag:
    dw 0xaa55       ; 启动盘标识
// super.c
int ROOT_DEV = 0;

// main.c
#define ORIG_ROOT_DEV (*(unsigned short *)0x901FC)

void main(void)
{
    ROOT_DEV = ORIG_ROOT_DEV;
    ...
    blk_dev_init();
    ...
}

inline _syscall0(int, setup);

extern struct buffer_head *start_buffer;

int sys_setup()
{
    struct buffer_head *bh = start_buffer;
    bh->b_dev = ROOT_DEV;
    bh->b_blocknr = 2;
    ll_rw_block(READ, bh);
    return 0;
}

void init(void)
{
    setup();
    while (1);
}

我们在 bootsect.s 中设置软盘的设备号,这个数据一开始会被加载到 0x7dfc 地址,后来会被拷贝到 0x901fc 地址,用一个变量将它保存下来。

扇区的内容会被读取到 bh->b_data 中,在这个例子中,bh->b_data 的值为 0x3FFC00。(具体请看第4章)

在系统调用中设置参数,并调用 ll_rw_block 函数读写扇区。

让我们来预测一下结果。程序会读取逻辑块2的数据,也就是相对扇区号4,5的数据。我们在 Makefile 中设置 bootsect.s 的内容拷贝到相对扇区0中,setup.s 的内容拷贝到相对扇区1-4中,5号扇区开始是内核的内容。由于 setup.s 的内容不多,所以扇区4的数据全为0,扇区5有数据。对应到内存,0x3FFE00地址开始会有数据。看看结果是怎样的。

从零编写linux0.11 - 第八章 软盘操作_第11张图片

可以看到,数据是正确的。

6.延时读写

我们上一节的代码有点问题,程序可能会在中断中休眠。rw_interrupt 函数只会在软盘中断中运行。当存在多个请求时,会出现一系列的函数调用,rw_interrupt --> do_fd_request --> floppy_on --> sleep_on --> schedule。

static void rw_interrupt(void)
{
    ...
    do_fd_request();
}

void do_fd_request(void)
{
    ...
    floppy_on(current_drive);
    transfer();
    floppy_off(current_drive);
}

void floppy_on(unsigned int nr)
{
    cli();
    while (ticks_to_floppy_on(nr))
        sleep_on(nr + wait_motor);
    sti();
}

void sleep_on(struct task_struct **p)
{
    ...
    schedule();
    if (tmp)
        tmp->state = 0;
}

休眠会导致任务调度,任务调度会切换任务上下文,但是中断没有任务上下文,这里就会出现问题。上一节的测试很简单,不会出现这种问题,但是在多进程环境下,很容易出现这种问题。所以,我们得修改一下代码。

应该怎么修改代码呢?之前我们是通过让任务休眠,通过时钟中断计时,时间到了后唤醒任务,然后向软盘控制器发送命令,读写扇区。现在我们不能让任务休眠了,但是我们还是可以通过时钟中断计时,时间到了后就向软盘控制器发送命令,读写扇区。

// sched.c
#define TIME_REQUESTS 64
static struct timer_list {
    long jiffies;           // 定时滴答数
    void (*fn)();           // 定时处理程序
    struct timer_list *next;// 下一个定时器
} timer_list[TIME_REQUESTS], *next_timer = NULL;

为了达到上述目的,定义了一个定时器结构体。使用链表的方式管理定时器,所以添加了 next 指针。next_timer 指向滴答数最小的定时器。

// sched.c
void add_timer(long jiffies, void (*fn)(void))
{
    struct timer_list *p;

    if (!fn)    // 如果定时处理程序为空,则退出
        return;
    cli();
    if (jiffies <= 0)   // 若定时值<=0,则立刻调用其处理程序
        (fn)();
    else {
        // 从定时器数组中,找一个空闲项
        for (p = timer_list; p < timer_list + TIME_REQUESTS; p++)
            if (!p->fn)
                break;
        if (p >= timer_list + TIME_REQUESTS)    // 如果没有空闲项则系统崩溃
            panic("No more time requests free");
        p->fn = fn;
        p->jiffies = jiffies;
        p->next = next_timer;
        next_timer = p;
        // 按定时值从小到大排序链表项
        while (p->next && p->next->jiffies < p->jiffies) {
            p->jiffies -= p->next->jiffies;
            fn = p->fn;
            p->fn = p->next->fn;
            p->next->fn = fn;
            jiffies = p->jiffies;
            p->jiffies = p->next->jiffies;
            p->next->jiffies = jiffies;
            p = p->next;
        }
    }
    sti();
}

如果定时值大于0,并且找到了空闲项,就对该项的成员进行赋值,然后将该项添加到链表中,并按定时值从小到大排序链表项。我举一个例子来说明这个过程吧。

从零编写linux0.11 - 第八章 软盘操作_第12张图片

上图是链表中已有的项。现在,我们需要把一个需要等待10个滴答的定时器加入到链表中,其过程如下所示。

从零编写linux0.11 - 第八章 软盘操作_第13张图片

咦?10怎么变成6了?经过4个滴答后,timer1 的 fn1 开始执行,再经过6个滴答,就刚好10个滴答,timer2 的 fn2 就能执行了。

接下来我们需要在时间中断中处理定时器。

// sched.c
void do_timer(long cpl)
{
    ...
    if (next_timer) {
        next_timer->jiffies--;
        while (next_timer && next_timer->jiffies <= 0) {
            void (*fn)(void);
            
            fn = next_timer->fn;
            next_timer->fn = NULL;
            next_timer = next_timer->next;
            (fn)();     // 调用处理函数
        }
    }
    ...
}

如果有定时器存在,递减定时值,如果时间到了,就将 next_timer 指向下一个定时器,然后调用处理函数。

还记得我们的目的是什么吗?我们想用定时器的方式,等待电机启动,然后读写扇区。

// floppy.c
extern unsigned char current_DOR;
static void floppy_on_interrupt(void)
{
    if (current_drive != (current_DOR & 3)) {
        current_DOR &= 0xFC;
        current_DOR |= current_drive;
        outb(current_DOR, FD_DOR);
        add_timer(2, &transfer);
    } else
        transfer();
}

void do_fd_request(void)
{
    ...
    add_timer(ticks_to_floppy_on(current_drive), &floppy_on_interrupt);
}

ticks_to_floppy_on 函数会启动软驱电动机,设置电动机从启动到正常运作需要的滴答数,最后返回这个滴答数。floppy_on_interrupt 则是正常运作后需要执行的函数(用于读写扇区)。

你是否还记得 current_DOR 的含义?current_DOR 表示上次向 DOR(数据输出寄存器)输出的数值。

如果我们想要操作的软驱与已经开启的软驱不一样,需要重新选择软驱,等待2个滴答(软驱切换),然后才读写扇区。如果一样,就直接调用 transfer 开始读写扇区的流程了。

最后还是写写测试代码。

inline _syscall0(int, setup);

extern struct buffer_head *start_buffer;

int sys_setup()
{
    struct buffer_head *bh = start_buffer;
    bh->b_dev = ROOT_DEV;
    bh->b_blocknr = 0;      // kernel.img中的地址为0
    ll_rw_block(READ, bh);
    bh++;
    bh->b_dev = ROOT_DEV;
    bh->b_blocknr = 2;      // kernel.img中的地址为0x800
    ll_rw_block(READ, bh);
    bh++;
    bh->b_dev = ROOT_DEV;
    bh->b_blocknr = 23;     // kernel.img中的地址为0x5C00
    ll_rw_block(READ, bh);
    return 0;
}

void init(void)
{
    setup();
    while (1);
}

这次会读取三个逻辑块的数据,分别是0,1,4,5,46,47号扇区的数据,将数据读取到 0x3FFC00,0x3FF800,0x3FF400的地址中。

运行成功了,但是我懒得放图片了,自己试着做做吧。

7.处理错误

在之前的代码中,我们并没有考虑发生错误的情况。向 FDC 发送命令超时怎么办?接收来自 FDC 的结果超时怎么办?收到的结果不正确怎么办?我们都没有进行处理,这一节就要对这些情况进行处理。

不知道大家有没有发现,出现错误的地方集中在 output_byte,result 和软盘中断处理函数(如 rw_interrupt,seek_interrupt 等)这三个地方。我们只需要在这三个地方检查有没有错误就行了。

// floppy.c
static int reset = 0;   // 发送命令/接收结果是否出错

static void output_byte(char byte)
{
    int counter;
    unsigned char status;

    if (reset)  // 如果前面有命令出错了,后面的数据也就不用发送了
        return;
    for(counter = 0; counter < 10000; counter++) {
        status = inb_p(FD_STATUS) & (STATUS_READY | STATUS_DIR);
        if (status == STATUS_READY) {
            outb(byte, FD_DATA);
            return;
        }
    }
    reset = 1;  // 直到循环结束也没能发送,则将reset置1
    printk("Unable to send byte to FDC\n\r");
}

static int result(void)
{
    int i = 0, counter, status;

    if (reset)
        return -1;
    for (counter = 0; counter < 10000; counter++) {
        status = inb_p(FD_STATUS) & (STATUS_DIR | STATUS_READY | STATUS_BUSY);
        if (status == STATUS_READY)
            return i;
        if (status == (STATUS_DIR | STATUS_READY | STATUS_BUSY)) {
            if (i >= MAX_REPLIES)
                break;
            reply_buffer[i++] = inb_p(FD_DATA);
        }
    }
    reset = 1;  // 直到循环结束也没能接收数据,则将reset置1
    printk("Getstatus times out\n\r");
    return -1;
}

使用 reset 变量来记录发送命令/接收结果是否出错,之后再统一处理错误。

// floppy.c
static int recalibrate = 0;
void do_fd_request(void)
{
    unsigned int block;

    seek = 0;
    if (reset) {
        reset_floppy();
        return;
    }
    if (recalibrate) {
        recalibrate_floppy();
        return;
    }
repeat:
    ...
}

统一处理错误的地方就在 do_fd_request 函数中。如果函数发送命令或接收结果出错,就调用 do_fd_request 函数,do_fd_request 函数再调用 reset_floppy 函数做具体的处理工作。

有些错误只需要校正软盘(让磁头回到磁道号0的位置)就可以了,对于这些错误,用 recalibrate 变量来记录。出现这些错误的函数也会调用 do_fd_request 函数,最后在 recalibrate_floppy 函数中处理。

// floppy.c
static void transfer(void)
{
    if (cur_spec1 != floppy->spec1) {
        cur_spec1 = floppy->spec1;
        output_byte(FD_SPECIFY);
        output_byte(cur_spec1);
        output_byte(6);     // 磁头加载时间12ms,DMA方式
    }
    if (cur_rate != floppy->rate)
        outb_p(cur_rate = floppy->rate, FD_DCR);
    if (reset) {    // 发送FD_SPECIFY命令/参数是否出错
        do_fd_request();
        return;
    }
    if (!seek) {
        setup_rw_floppy();
        return;
    }
    do_floppy = seek_interrupt;
    if (seek_track) {
        output_byte(FD_SEEK);
        output_byte(head << 2 | current_drive);
        output_byte(seek_track);
    } else {
        output_byte(FD_RECALIBRATE);
        output_byte(current_drive);
    }
    if (reset)  // 发送FD_SEEK或FD_RECALIBRATE命令/参数是否出错
        do_fd_request();
}

static void setup_rw_floppy(void)
{
    setup_DMA();
    do_floppy = rw_interrupt;
    output_byte(command);
    output_byte(current_drive); // 驱动器号
    output_byte(track);         // 磁道号
    output_byte(head);          // 磁头号
    output_byte(sector);        // 起始扇区号
    output_byte(2);		/* sector size = 512 */
    output_byte(floppy->sect);  // 磁道上最大扇区号
    output_byte(floppy->gap);   // 扇区之间间隔长度
    output_byte(0xFF);	/* sector size (0xff when n!=0 ?) */
    if (reset)      // 发送FD_READ或FD_WRITE命令/参数是否出错
        do_fd_request();
}

发送命令之后,需要检查是否出错,如果出错,就调用 do_fd_request,然后在 reset_floppy 中进行处理。

对于软盘中断处理函数中出现的错误(返回结果是否正确),需要具体分析。

// floppy.c
#define MAX_ERRORS 8

#define MAX_REPLIES 7
static unsigned char reply_buffer[MAX_REPLIES];
#define ST0 (reply_buffer[0])
#define ST1 (reply_buffer[1])
#define ST2 (reply_buffer[2])

static void bad_flp_intr(void)
{
	REQUEST->errors++;
	if (REQUEST->errors > MAX_ERRORS) {
		end_request();
	}
	if (REQUEST->errors > MAX_ERRORS / 2)
		reset = 1;
	else
		recalibrate = 1;
}

static void seek_interrupt(void)
{
    output_byte(FD_SENSEI);
    if (result() != 2 || (ST0 & 0xF8) != 0x20 || ST1 != seek_track) {
		bad_flp_intr();
		do_fd_request();
		return;
	}
	current_track = ST1;
    setup_rw_floppy();
}

FD_SENSEI 命令的返回结果有2个,第1个是状态字节0,第2个是磁头所在磁道号。

触发 seek_interrupt 函数的命令为 FD_SEEK 或 FD_RECALIBRATE,此时磁头所在磁道号应该与 seek_track 相等。

状态字节0(ST0)各位的含义为:

从零编写linux0.11 - 第八章 软盘操作_第14张图片

如果 ST0 & 0xF8 为 0x20,说明命令正常结束,寻道操作或重新校正操作结束,设备没有出错,软驱已就绪。如果不为 0x20,就说明出现错误。

把 MAX_ERRORS 定义为8并不表示对每次读错误尝试最多8次,有些类型的错误将把出错数值乘2,所以我们实际上在放弃操作之前只需尝试5~6次即可。根据不同的错误数量执行不同的操作。最后还是要调用 do_fd_request 函数。

// floppy.c
void unexpected_floppy_interrupt(void)
{
    output_byte(FD_SENSEI);
	if (result() != 2)
        reset = 1;
    else
        recalibrate = 1;
}

static void rw_interrupt(void)d
{
    if (result() != 7 || (ST0 & 0xd8) || (ST1 & 0xbf) || (ST2 & 0x73)) {
		if (ST1 & 0x02) {
			printk("Drive %d is write protected\n\r", current_drive);
			end_request();
		}
		else
			bad_flp_intr();
		do_fd_request();
		return;
	}
    if (command == FD_READ && (unsigned long)(REQUEST->buffer) >= 0x100000)
        copy_buffer(tmp_floppy_area, REQUEST->buffer);
    end_request();
    do_fd_request();
}

当执行 unexpected_floppy_interrupt 函数的时候,就说明系统出现了问题,这里只是设置变量,之后调用 do_fd_request 函数的时候才会对软盘进行处理。

触发 rw_interrupt 函数的命令为 FD_READ 或 FD_WRITE,它们的返回结果都是7个。第13行代码的判断请参照给出的3张表。

状态字节1(ST1)各位的含义如下所示:

从零编写linux0.11 - 第八章 软盘操作_第15张图片

状态字节2(ST2)各位的含义如下所示:

从零编写linux0.11 - 第八章 软盘操作_第16张图片

// floppy.c
static void reset_interrupt(void)
{
    output_byte(FD_SENSEI);
    (void) result();
    output_byte(FD_SPECIFY);
    output_byte(cur_spec1);
    output_byte(6);     // 磁头加载时间12ms,DMA方式
    do_fd_request();
}

static void reset_floppy(void)
{
    int i;

    reset = 0;
    cur_spec1 = -1;
    cur_rate = -1;
    recalibrate = 1;
    printk("Reset-floppy called\n\r");
    cli();
    do_floppy = reset_interrupt;
    outb_p(current_DOR & ~0x04, FD_DOR);
    for (i = 0; i < 100; i++)
        __asm__("nop");
    outb(current_DOR, FD_DOR);
    sti();
}

软盘操作出错后,我们需要重新设置 FDC 的寄存器的参数。向 DOR(数据输出寄存器)写入数据会触发软盘中断,系统会执行 reset_interrupt 函数。在 reset_interrupt 中发送 FD_SPECIFY 命令,然后调用 do_fd_request 函数。因为在 reset_floppy 中将 recalibrate 设置为1,在 do_fd_request 函数中会调用 recalibrate_floppy 函数。

// floppy.c
static void recal_interrupt(void)
{
    output_byte(FD_SENSEI);
	if (result() != 2 || (ST0 & 0xE0) == 0x60)
        reset = 1;
    else
        recalibrate = 0;
    do_fd_request();
}

static void recalibrate_floppy(void)
{
    recalibrate = 0;
    current_track = 0;
    do_floppy = recal_interrupt;
    output_byte(FD_RECALIBRATE);
    output_byte(current_drive);
    if (reset)  // 发送FD_RECALIBRATE命令/参数是否出错
        do_fd_request();
}

在 recalibrate_floppy 函数中发送 FD_RECALIBRATE 命令和参数,还是要检查是否出错。如果没有出错,就会触发中断,调用 recal_interrupt 函数,返回结果也没有错误的话,将 recalibrate 置0,然后调用 do_fd_request 处理请求。

代码已经写完了,但遗憾的是,软盘不会那么轻易地出问题,所以我也没办法对代码进行测试。

8.软盘选择

文件系统也是一个软盘文件,在系统做玩初始化后,就加载文件系统软盘。然后让操作系统软盘电动机停下,启动文件系统软盘的电动机,之后就一直对文件系统软盘进行操作。操作系统软盘已经没用了,毕竟我们已经把数据全都读出来了。

// floppy.c
unsigned char selected = 0;
struct task_struct *wait_on_floppy_select = NULL;

static void floppy_on_interrupt(void)
{
    selected = 1;
    if (current_drive != (current_DOR & 3)) {
        current_DOR &= 0xFC;
        current_DOR |= current_drive;
        outb(current_DOR, FD_DOR);
        add_timer(2, &transfer);
    } else
        transfer();
}

selected 表示软驱是否被使用。什么叫做被使用呢?想要读写扇区要经过如下几个步骤:1.添加请求(add_request);2.执行设备驱动程序(do_fd_request);3.添加定时器(add_timer);4.执行定时器处理程序(floppy_on_interrupt);5.通过中断读写扇区。从第4步开始称为被使用过了。因为在 floppy_on_interrupt 会把 selected 置为1。

如果当前选择的软驱不是指定软驱nr且软驱被使用过了,让当前任务休眠。

如果软驱没有被使用过,就一直循环。floppy_on(nr) 中会把 current_DOR 的低2位设置为 nr,所以如果 (current_DOR & 3) != nr 为真,应该是发生了任务切换,其他任务使 current_DOR 的值发生了变化,需要等待其他任务操作完才能继续执行。

如果软驱被更换了,返回1。

// floppy.c
void floppy_deselect(unsigned int nr)
{
    if (nr != (current_DOR & 3))
        printk("floppy_deselect: drive not selected\n\r");
    selected = 0;
    wake_up(&wait_on_floppy_select);
}

static void bad_flp_intr(void)
{
	REQUEST->errors++;
	if (REQUEST->errors > MAX_ERRORS) {
		floppy_deselect(current_drive);
		end_request();
	}
	if (REQUEST->errors > MAX_ERRORS / 2)
		reset = 1;
	else
		recalibrate = 1;
}

static void rw_interrupt(void)
{
    if (result() != 7 || (ST0 & 0xd8) || (ST1 & 0xbf) || (ST2 & 0x73)) {
		if (ST1 & 0x02) {	// 是否为写保护
			printk("Drive %d is write protected\n\r", current_drive);
            floppy_deselect(current_drive);
			end_request();
		}
		else
			bad_flp_intr();
		do_fd_request();
		return;
	}
    if (command == FD_READ && (unsigned long)(REQUEST->buffer) >= 0x100000)
        copy_buffer(tmp_floppy_area, REQUEST->buffer);
    floppy_deselect(current_drive);
    end_request();
    do_fd_request();
}

当扇区读写完毕后,就标记软驱没被使用,并且唤醒之前在 floppy_change 中休眠的任务。

// sched.c
int ticks_to_floppy_on(unsigned int nr)
{
    extern unsigned char selected;
    unsigned char mask = 0x10 << nr;

    if (nr > 3)     // 系统最多支持4个软驱
        panic("floppy_on: nr>3");
    moff_timer[nr] = 10000;     // 100s
    cli();
    mask |= current_DOR;
    if (!selected) {
        mask &= 0xFC;
        mask |= nr;
    }
    if (mask != current_DOR) {
        outb(mask, FD_DOR);
        if ((mask ^ current_DOR) & 0xf0)
            mon_timer[nr] = HZ / 2; // 0.5s
        else if (mon_timer[nr] < 2)
            mon_timer[nr] = 2;
        current_DOR = mask;
    }
    sti();
    return mon_timer[nr];
}

最后还需要修改一下 ticks_to_floppy_on 函数。如果没有软驱被使用过,就重新选择软驱。为什么要这样做呢?每个读写请求需要操作的软驱不一样,所以对每个请求都要重新选择软驱。

本章小结

软盘的内容还是有点难的。我尽量以一个开发者的视角讲解代码,为什么要这样设计代码?这样设计代码有什么好处?虽然我觉得自己的解释还不太通俗,但我的水平也就这样了。

在写博客的时候我学到了很多。比如,为什么请求要用数组不用链表?为什么要用定时器,而不是 floppy_on,floppy_on_interrupt,floppy_off 这种简单的方式读写扇区?为什么要用 reset,recalibrate 这些变量,而不是直接调用 reset_floppy 和 recalibrate_floppy 函数?用 do_floppy 函数指针,使软盘中断的功能变得强大。那些命令可以触发中断?中断触发的顺序等等。

有些代码我不理解的,我就按照自己的思路编写,然后出错了我再改回来,这样我更能明白操作系统的实现方法。我也希望大家不仅能看懂代码,更要懂得为什么这样设计代码,我觉得这样才是编程。

下一章是文件系统,难度要比这章更胜一筹,恐怕又要好久才能写一篇博客了。

你可能感兴趣的:(linux0.11,操作系统,linux)