你管这叫操作系统源码(十一)

本文详细解析了Linux启动时init进程如何通过setup函数获取硬盘信息,加载根文件系统,并介绍内存中文件系统数据结构。重点讲解了硬盘参数的获取、分区信息设置以及文件系统格式的理解。

拿到硬盘信息

前面讲了进程 0 调用了 fork 函数创建了一个新的进程 —— 进程 1,并且使其达到了可以被调度的状态,fork 就算正式完成了自己的使命。

void main(void) {
    ...
    move_to_user_mode();
    if (!fork()) {
        init();
    }
    for(;;) pause();
}

由于 fork 函数一调用,就又多出了一个进程,子进程(进程 1)会返回 0,父进程(进程 0)返回子进程的 ID,所以 init 函数只有进程 1 才会执行。本文就到了现在的第四部分,shell 程序的到来。而整个第四部分的故事,就是这个 init 函数做的事情。

虽然就一行代码,但这里的事情可多了去了,我们先看一下整体结构。我已经把单纯的日志打印和错误校验逻辑去掉了。

void init(void) {
    int pid,i;
    setup((void *) &drive_info);
    (void) open("/dev/tty0",O_RDWR,0);
    (void) dup(0);
    (void) dup(0);
    if (!(pid=fork())) {
        open("/etc/rc",O_RDONLY,0);
        execve("/bin/sh",argv_rc,envp_rc);
    }
    if (pid>0)
        while (pid != wait(&i))
            /* nothing */;
    while (1) {
        if (!pid=fork()) {
            close(0);close(1);close(2);
            setsid();
            (void) open("/dev/tty0",O_RDWR,0);
            (void) dup(0);
            (void) dup(0);
            _exit(execve("/bin/sh",argv,envp));
        }
        while (1)
            if (pid == wait(&i))
                break;
        sync();
    }
    _exit(0);   /* NOTE! _exit, not exit() */
}

本节先讲第一行代码 setup 的一部分,硬盘信息的获取:

struct drive_info { char dummy[32]; } drive_info;

// drive_info = (*(struct drive_info *)0x90080);

void init(void) {
    setup((void *) &drive_info);
    ...
}

先看入参。drive_info 是来自内存 0x90080 的数据,这部分是由系列之二中进入保护模式前的最后一次折腾内存 讲的 setup.s 程序将硬盘 1 的参数信息放在这里了,包括柱面数、磁头数、扇区数等信息。

setup 是个系统调用,会通过中断最终调用到 sys_setup 函数。关于系统调用的原理,在系列之九中 通过 fork 看一次系统调用 已经讲得很清楚了,此处不再赘述。

所以直接看 sys_setup 函数,我仍然是对代码做了少许的简化,去掉了日志打印和错误判断分支,并且仅当作只有一块硬盘,去掉了一层 for 循环。

int sys_setup(void * BIOS) {

    hd_info[0].cyl = *(unsigned short *) BIOS;
    hd_info[0].head = *(unsigned char *) (2+BIOS);
    hd_info[0].wpcom = *(unsigned short *) (5+BIOS);
    hd_info[0].ctl = *(unsigned char *) (8+BIOS);
    hd_info[0].lzone = *(unsigned short *) (12+BIOS);
    hd_info[0].sect = *(unsigned char *) (14+BIOS);
    BIOS += 16;

    hd[0].start_sect = 0;
    hd[0].nr_sects = 
        hd_info[0].head * hd_info[0].sect * hd_info[0].cyl;
    
    struct buffer_head *bh = bread(0x300, 0);
    struct partition *p = 0x1BE + (void *)bh->b_data;
    for (int i=1;i<5;i++,p++) {
        hd[i].start_sect = p->start_sect;
        hd[i].nr_sects = p->nr_sects;
    }
    brelse(bh);
    
    rd_load();
    mount_root();
    return (0);
}

先看第一部分,硬盘基本信息的赋值的操作。

int sys_setup(void * BIOS) {
    hd_info[0].cyl = *(unsigned short *) BIOS;
    hd_info[0].head = *(unsigned char *) (2+BIOS);
    hd_info[0].wpcom = *(unsigned short *) (5+BIOS);
    hd_info[0].ctl = *(unsigned char *) (8+BIOS);
    hd_info[0].lzone = *(unsigned short *) (12+BIOS);
    hd_info[0].sect = *(unsigned char *) (14+BIOS);
    BIOS += 16;
    ...
}

一开始先往 hd_info 数组的 0 索引处存上刚刚说的磁盘信息(包括柱面数、磁头数、扇区数等信息)。我们假设就只有一块硬盘,所以这个数组也只有一个元素。这个数组里的结构就是 hd_i_struct,就表示硬盘的参数。

struct hd_i_struct {
    // 磁头数、每磁道扇区数、柱面数、写前预补偿柱面号、磁头着陆区柱面号、控制字节
    int head,sect,cyl,wpcom,lzone,ctl;
};
struct hd_i_struct hd_info[] = {}

最终效果:

ch17-1

看第二部分,硬盘分区表的设置:

static struct hd_struct {
    long start_sect;
    long nr_sects;
} hd[5] = {}

int sys_setup(void * BIOS) {
    ...
    hd[0].start_sect = 0;
    hd[0].nr_sects = 
        hd_info[0].head * hd_info[0].sect * hd_info[0].cyl;
    struct buffer_head *bh = bread(0x300, 0);
    struct partition *p = 0x1BE + (void *)bh->b_data;
    for (int i=1;i<5;i++,p++) {
        hd[i].start_sect = p->start_sect;
        hd[i].nr_sects = p->nr_sects;
    }
    brelse(bh);
    ...
}

只看最终效果,就是给 hd 数组的五项附上了值:

ch17-2

这表示硬盘的分区信息,每个分区用 start_sectnr_sects,也就是开始扇区和总扇区数来记录。

这些信息是从哪里获取的呢?就是在硬盘的第一个扇区的 0x1BE 偏移处,这里存储着该硬盘的分区信息,只要把这个地方的数据拿到就 OK 了。所以 bread 就是干这事的,从硬盘读取数据:

struct buffer_head *bh = bread(0x300, 0);

第一个参数 0x300 是第一块硬盘的主设备号,就表示要读取的块设备是硬盘一。第二个参数 0 表示读取第一个块,一个块为 1024 字节大小,也就是连续读取硬盘开始处 0 ~ 1024 字节的数据。

拿到这部分数据后,再取 0x1BE 偏移处,就得到了分区信息:

struct partition *p = 0x1BE + (void *)bh->b_data;

就这么点事:

ch17-3

至于如何从硬盘中读取指定位置(块)的数据,也就是 bread 函数的内部实现,那是相当复杂的,涉及到与缓冲区配合的部分,还有读写请求队列的设置,以及中断。

当然,这个函数就是经典的问题,从硬盘中读取数据的原理,但这些都不影响主流程,因为仅仅是把硬盘某位置的数据读到内存而已,先不去深入细节,细节部分将在第五部分展开说明。

OK,目前我们已经把硬盘的基本信息存入了 hd_info[],把硬盘的分区信息存入了 hd[],我们继续往下看:

int sys_setup(void * BIOS) {
    ...
    rd_load();
    mount_root();
    return (0);
}

就剩两个函数了。其中 rd_load 是当有 ramdisk 时,也就是虚拟内存盘,才会执行。虚拟内存盘是通过软件将一部分内存(RAM)模拟为硬盘来使用的一种技术,一种小玩法而已,我们就先当做没有,否则很影响看主流程的心情。

mount_root 直译过来就是加载根,再多说几个字是加载根文件系统,有了它之后,操作系统才能从一个根开始找到所有存储在硬盘中的文件,所以它是文件系统的基石,很重要。

为了加载根文件系统,或者说所谓的加载根文件系统,就是把硬盘中的数据加载到内存里,以文件系统的数据格式来解读这些信息。所以:

  1. 需要硬盘本身就有文件系统的信息,硬盘不能是裸盘,这个不归操作系统管,你为了启动我的 Linux 0.11,必须拿来一块做好了文件系统的硬盘来。

  2. 需要读取硬盘的数据到内存,那就必须需要知道硬盘的参数信息,这就是我们本节所做的事情的意义。

加载根文件系统

上节已经把硬盘的基本信息存入了 hd_info[],硬盘的分区信息存入了 hd[],并且留了个读取硬盘数据的 bread 函数没有讲,等主流程讲完再展开这些函数的细节。这些都是 setup 方法里做的事情,也就是进程 0 fork 出的进程 1 所执行的第一个方法。本节我们说 setup 方法中的最后一个函数 mount_root

int sys_setup(void * BIOS) {
    ...
    mount_root();
}

展开看看

void mount_root(void) {
    int i,free;
    struct super_block * p;
    struct m_inode * mi;

    for(i=0;i<64;i++)
        file_table[i].f_count=0;

    for(p = &super_block[0] ; p < &super_block[8] ; p++) {
        p->s_dev = 0;
        p->s_lock = 0;
        p->s_wait = NULL;
    }
    p=read_super(0);
    mi=iget(0,1);

    mi->i_count += 3 ;
    p->s_isup = p->s_imount = mi;
    current->pwd = mi;
    current->root = mi;
    free=0;
    i=p->s_nzones;
    while (-- i >= 0)
        if (!set_bit(i&8191,p->s_zmap[i>>13]->b_data))
            free++;

    free=0;
    i=p->s_ninodes+1;
    while (-- i >= 0)
        if (!set_bit(i&8191,p->s_imap[i>>13]->b_data))
            free++;
}

从整体上说,它就是要把硬盘中的数据,以文件系统的格式进行解读,加载到内存中设计好的数据结构,这样操作系统就可以通过内存中的数据,以文件系统的方式访问硬盘中的一个个文件了。

ch17-4

那其实搞清楚两个事情即可:

  1. 硬盘中的文件系统格式是怎样的?

  2. 内存中用于文件系统的数据结构有哪些?

硬盘中的文件系统格式

硬盘中的文件系统,无非就是硬盘中的一堆数据,我们按照一定格式去解析罢了。Linux-0.11 中的文件系统是 MINIX 文件系统,它就长成这个样子:

ch17-4a

每一个块结构的大小是 1024 字节,也就是 1KB,硬盘里的数据就按照这个结构,妥善地安排在硬盘里。

可是硬盘中凭什么就有了这些信息呢?这就是个鸡生蛋蛋生鸡的问题了。你可以先写一个操作系统,然后给一个硬盘做某种文件系统类型的格式化,这样你就得到一个有文件系统的硬盘了,有了这个硬盘,你的操作系统就可以成功启动了。总之,想个办法给这个硬盘写上数据呗。

好了,现在我们简单看看 MINIX 文件系统的格式。

引导块就是我们系列最开头说的启动区,当然不一定所有的硬盘都有启动区,但我们还是得预留出这个位置,以保持格式的统一。

超级块用于描述整个文件系统的整体信息,我们看它的字段就知道了,有后面的 inode 数量,块数量,第一个块在哪里等信息。有了它,整个硬盘的布局就清晰了。

inode 位图和块位图,就是位图的基本操作和作用了,表示后面 inode 和块的使用情况,和我们之前讲的内存占用位图 mem_map[] 是类似的。

再往后,inode 存放着每个文件或目录的元信息和索引信息,元信息就是文件类型、文件大小、修改时间等,索引信息就是大小为 9 的 i_zone[9] 块数组,表示这个文件或目录的具体数据占用了哪些块。

其中块数组里,0~6 表示直接索引,7 表示一次间接索引,8 表示二次间接索引。当文件比较小时,比如只占用 2 个块就够了,那就只需要 zone[0] 和 zone[1] 两个直接索引即可。

ch17-4b

再往后,就都是存放具体文件或目录实际信息的了。如果是一个普通文件类型的 inode 指向的块,那里面就直接是文件的二进制信息。如果是一个目录类型的 inode 指向的块,那里面存放的就是这个目录下的文件和目录的 inode 索引以及文件或目录名称等信息。

好了,文件系统格式的说明,我们就简单说明完了,MINIX 文件系统已经过时,你可以阅读 图解 | 你管这叫文件系统?来全面了解一个 ext2 文件系统的来龙去脉,基本思想都是一样的。

内存中用于文件系统的数据结构

代码是如何加载以这样一种格式存放在硬盘里的数据,以被我们操作系统所管控的:

struct file {
    unsigned short f_mode;
    unsigned short f_flags;
    unsigned short f_count;
    struct m_inode * f_inode;
    off_t f_pos;
};

void mount_root(void) {
    for(i=0;i<64;i++)
        file_table[i].f_count=0;
    ...
}

把 64 个 file_table 里的 f_count 清零。这个 file_table 表示进程所使用的文件,进程每使用一个文件,都需要记录在这里,包括文件类型、文件 inode 索引信息等,而这个 f_count 表示被引用的次数,此时还没有引用,所以设置为零。

而这个 file_table 的索引,就是我们通常说的文件描述符。比如有如下命令:

echo "hello" > 0

就表示把 hello 输出到 0 号文件描述符。0 号文件描述符是哪个文件呢?就是 file_table[0] 所表示的文件。

这个文件在哪里呢?注意到 file 结构里有个 f_inode 字段,通过 f_inode 即可找到它的 inode 信息,inode 信息包含了一个文件所需要的全部信息,包括文件的大小、文件的类型、文件所在的硬盘块号,这个所在硬盘块号,就是文件的位置咯。

接着看:

struct super_block super_block[8];
void mount_root(void) {
    ...
    struct super_block * p;
    for(p = &super_block[0] ; p < &super_block[8] ; p++) {
        p->s_dev = 0;
        p->s_lock = 0;
        p->s_wait = NULL;
    }
    ...
}

又是把一个数组 super_block 做清零工作。这个 super_block 存在的意义是,操作系统与一个设备以文件形式进行读写访问时,就需要把这个设备的超级块信息放在这里。

ch17-4a

这样通过这个超级块,就可以掌控这个设备的文件系统全局了。果然,接下来的操作,就是读取硬盘的超级块信息到内存中来。

void mount_root(void) {
    ...
    p=read_super(0);
    ...
}

read_super 就是读取硬盘中的超级块。

接下来,读取根 inode 信息:

struct m_inode * mi;
void mount_root(void) {
    ...
    mi=iget(0,1);
    ...
}

然后把该 inode 设置为当前进程(也就是进程 1)的当前工作目录和根目录:

void mount_root(void) {
    ...
    current->pwd = mi;
    current->root = mi;
    ...
}

然后记录块位图信息:

void mount_root(void) {
    ...
    i=p->s_nzones;
    while (-- i >= 0)
        set_bit(i&8191, p->s_zmap[i>>13]->b_data);
    ...
}

最后记录 inode 位图信息:

void mount_root(void) {
    ...
    i=p->s_ninodes+1;
    while (-- i >= 0)
        set_bit(i&8191, p->s_imap[i>>13]->b_data);
}

就完事了。其实整体上就是把硬盘中文件系统的各个信息,搬到内存中。之前的图可以说非常直观了:

ch17-4

有了内存中的这些结构,我们就可以顺着根 inode,找到所有的文件了。

至此,加载根文件系统的 mount_root 函数就全部结束了。同时,让我们回到全局视野,发现 setup 函数也一并结束了:

void main(void) {
    ...
    move_to_user_mode();
    if (!fork()) {
        init();
    }
    for(;;) pause();
}

void init(void) {
    setup((void *) &drive_info);
    ...
}

int sys_setup(void * BIOS) {
    ...
    mount_root();
}

setup 的主要工作就是我们今天所讲的,加载根文件系统

我们继续往下看 init 函数:

void init(void) {
    setup((void *) &drive_info);
    (void) open("/dev/tty0",O_RDWR,0);
    (void) dup(0);
    (void) dup(0);
}

看到这相信你也明白了。之前 setup 函数的一番折腾,加载了根文件系统,顺着根 inode 可以找到所有文件,就是为了下一行 open 函数可以通过文件路径,从硬盘中把一个文件的信息方便地拿到。

在这里,我们 open 了一个 /dev/tty0 的文件,那我们接下来的焦点就在这个 /dev/tty0 是个啥?下一篇讲。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值