Linux 0.11-读硬盘数据全流程-46

Posted 热爱编程的大忽悠

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux 0.11-读硬盘数据全流程-46相关的知识,希望对你有一定的参考价值。

Linux 0.11-读硬盘数据全流程-46


读硬盘数据全流程

新建一个非常简单的 info.txt 文件。

name:flash
age:28
language:java

在命令行输入一条十分简单的命令。

[root@linux0.11] cat info.txt | wc -l
3

这条命令的意思是读取刚刚的 info.txt 文件,输出它的行数。

上一回中,我们解释了 shell 程序是如何解释并执行我们输入的命令的,并展开讲解了管道类型命令的原理。

同时也说了,在 第35回 | execve 加载并执行 shell 程序第36回 | 缺页中断,我们已经讲过如何通过 execve 加载并执行 shell 程序,但略过了将数据从硬盘加载到内存的逻辑细节。

那我们这一讲就把它扒开来看看。

将硬盘中的数据读入内存,听起来是个很简单的事情,但操作系统要考虑的问题很多。


如果让你来设计这个函数

我们先别急,一点点来,想想看,如果让你设计这个函数,你会怎么设计呢?

首先我们知道,通过 第32回 | 加载根文件系统 中文件系统的建设。

以及 第33回 | 打开终端设备文件 讲解的打开一个文件的操作。

我们已经可以很方便地通过一个文件描述符 fd,寻找到存储在硬盘中的一个文件了,再具体点就是知道这个文件在硬盘中的哪几个扇区中。

所以,设计这个函数第一个要指定的参数就可以是 fd 了,它仅仅是个数字。当然,之所以能这样方便,就要感谢刚刚说的文件系统建设以及打开文件的逻辑这两项工作。

之后,我们得告诉这个函数,把这个 fd 指向的硬盘中的文件,复制到内存中的哪个位置复制多大

那更简单了,内存中的位置,我们用一个表示地址值的参数 buf,复制多大,我们用 count 来表示,单位是字节。

那这个函数就可以设计为。

int sys_read(unsigned int fd,char * buf,int count) 
    ...

是不是合情合理,无法反驳。


鸟瞰操作系统的读操作函数

实际上,你刚刚设计出来的读操作函数,这正是 Linux 0.11 读操作的系统调用入口函数,在 read_write.c 这个文件里。

// read_write.c
int sys_read(unsigned int fd,char * buf,int count) 
    struct file * file;
    struct m_inode * inode;

    if (fd>=NR_OPEN || count<0 || !(file=current->filp[fd]))
        return -EINVAL;
    if (!count)
        return 0;
    verify_area(buf,count);
    inode = file->f_inode;
    if (inode->i_pipe)
        return (file->f_mode&1)?read_pipe(inode,buf,count):-EIO;
    if (S_ISCHR(inode->i_mode))
        return rw_char(READ,inode->i_zone[0],buf,count,&file->f_pos);
    if (S_ISBLK(inode->i_mode))
        return block_read(inode->i_zone[0],&file->f_pos,buf,count);
    if (S_ISDIR(inode->i_mode) || S_ISREG(inode->i_mode)) 
        if (count+file->f_pos > inode->i_size)
            count = inode->i_size - file->f_pos;
        if (count<=0)
            return 0;
        return file_read(inode,file,buf,count);
    
    printk("(Read)inode->i_mode=%06o\\n\\r",inode->i_mode);
    return -EINVAL;

那我们就分析这个函数就好了。

不过首先我先简化一下,去掉一些错误校验逻辑等旁路分支,并添加上注释。

// read_write.c
int sys_read(unsigned int fd,char * buf,int count) 
    struct file * file = current->filp[fd];
    // 校验 buf 区域的内存限制
    verify_area(buf,count);
    struct m_inode * inode = file->f_inode;
    // 管道文件
    if (inode->i_pipe)
        return (file->f_mode&1)?read_pipe(inode,buf,count):-EIO;
    // 字符设备文件
    if (S_ISCHR(inode->i_mode))
        return rw_char(READ,inode->i_zone[0],buf,count,&file->f_pos);
    // 块设备文件
    if (S_ISBLK(inode->i_mode))
        return block_read(inode->i_zone[0],&file->f_pos,buf,count);
    // 目录文件或普通文件
    if (S_ISDIR(inode->i_mode) || S_ISREG(inode->i_mode)) 
        if (count+file->f_pos > inode->i_size)
            count = inode->i_size - file->f_pos;
        if (count<=0)
            return 0;
        return file_read(inode,file,buf,count);
    
    // 不是以上几种,就报错
    printk("(Read)inode->i_mode=%06o\\n\\r",inode->i_mode);
    return -EINVAL;

这样,整个的逻辑就非常清晰了。

由此也可以注意到,操作系统源码的设计比我刚刚说的更通用,我刚刚只让你设计了读取硬盘的函数,但其实在 Linux 下一切皆文件,所以这个函数将管道文件、字符设备文件、块设备文件、目录文件、普通文件分别指向了不同的具体实现。

那我们今天仅仅关注最常用的,读取目录文件或普通文件,并且不考虑读取的字节数大于文件本身大小这种不合理情况。

再简化下代码。

// read_write.c
int sys_read(unsigned int fd,char * buf,int count) 
    struct file * file = current->filp[fd];
    struct m_inode * inode = file->f_inode;
    // 校验 buf 区域的内存限制
    verify_area(buf,count);
    // 仅关注目录文件或普通文件
    return file_read(inode,file,buf,count);

太棒了!没剩多少了,一个个击破!

第一步,根据文件描述符 fd,在进程表里拿到了 file 信息,进而拿到了 inode 信息。第二步,对 buf 区域的内存做校验。第三步,调用具体的 file_read 函数进行读操作。

就这三步,很简单吧~

在进程表 filp 中拿到 file 信息进而拿到 inode 信息这一步就不用多说了,这是在打开一个文件时,或者像管道文件一样创建出一个管道文件时,就封装好了 file 以及它的 inode 信息。

我们看接下来的两步。


对 buf 区域的内存做校验 verify_area

对 buf 区域的内存做校验的部分,说是校验,里面还挺有说道呢。

// fork.c
void verify_area(void * addr,int size) 
    unsigned long start;
    start = (unsigned long) addr;
    size += start & 0xfff;
    start &= 0xfffff000;
    start += get_base(current->ldt[2]);
    while (size>0) 
        size -= 4096;
        write_verify(start);
        start += 4096;
    

addr 就是刚刚的 buf,size 就是刚刚的 count。然后这里又将 addr 赋值给了 start 变量。所以代码开始,start 就表示要复制到的内存的起始地址,size 就是要复制的字节数

这段代码很简单,但如果不了解内存的分段和分页机制,将会难以理解。

Linux 0.11 对内存是以 4K 为一页单位来划分内存的,所以内存看起来就是一个个 4K 的小格子。

你看,我们假设要复制到的内存的起始地址 start 和要复制的字节数 size 在图中的那个位置。那么开始的两行计算代码。

// fork.c
void verify_area(void * addr,int size) 
    ...
    size += start & 0xfff;
    start &= 0xfffff000;
    ...

就是将 start 和 size 按页对齐一下。

然后,又由于每个进程有不同的数据段基址,所以还要加上它。

// fork.c
void verify_area(void * addr,int size) 
    ...
    start += get_base(current->ldt[2]);
    ...

具体说来就是加上当前进程的局部描述符表 LDT 中的数据段的段基址。

每个进程的 LDT 表,由 Linux 创建进程时的代码给规划好了。具体说来,就是如上图所示,每个进程的线性地址范围,是

(进程号)*64M ~ (进程号+1)*64M

而对于进程本身来说,都以为自己是从零号地址开始往后的 64M,所以传入的 start 值也是以零号地址为起始地址算出来的。

但现在经过系统调用进入 sys_write 后会切换为内核态,内核态访问数据会通过基地址为 0 的全局描述符表中的数据段来访问数据。所以,start 要加上它自己进程的数据段基址,才对。

再之后,就是对这些页进行具体的验证操作。

// fork.c
void verify_area(void * addr,int size) 
    ...
    while (size>0) 
        size -= 4096;
        write_verify(start);
        start += 4096;
    

也就是这些页。

这些 write_verify 将会对这些页进行写页面验证,如果页面存在但不可写,则执行 un_wp_page 复制页面。

// memory.c
void write_verify(unsigned long address) 
    unsigned long page;
    if (!( (page = *((unsigned long *) ((address>>20) & 0xffc)) )&1))
        return;
    page &= 0xfffff000;
    page += ((address>>10) & 0xffc);
    if ((3 & *(unsigned long *) page) == 1)  /* non-writeable, present */
        un_wp_page((unsigned long *) page);
    return;

看,那个 un_wp_page 意思就是取消页面的写保护,就是写时复制的原理,在 第30回 | 番外篇 - 写时复制就这么几行代码 已经讨论过了,这里就不做展开了。


执行读操作 file_read

下面终于开始进入读操作的正题了,页校验完之后,就可以真正调用 file_read 函数了。

// read_write.c
int sys_read(unsigned int fd,char * buf,int count) 
    ...
    return file_read(inode,file,buf,count);


// file_dev.c
int file_read(struct m_inode * inode, struct file * filp, char * buf, int count) 
    int left,chars,nr;
    struct buffer_head * bh;
    left = count;
    while (left) 
        if (nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE)) 
            if (!(bh=bread(inode->i_dev,nr)))
                break;
         else
            bh = NULL;
        nr = filp->f_pos % BLOCK_SIZE;
        chars = MIN( BLOCK_SIZE-nr , left );
        filp->f_pos += chars;
        left -= chars;
        if (bh) 
            char * p = nr + bh->b_data;
            while (chars-->0)
                put_fs_byte(*(p++),buf++);
            brelse(bh);
         else 
            while (chars-->0)
                put_fs_byte(0,buf++);
        
    
    inode->i_atime = CURRENT_TIME;
    return (count-left)?(count-left):-ERROR;

整体看,就是一个 while 循环,每次读入一个块的数据,直到入参所要求的大小全部读完为止。

while 去掉,简化起来就是这样。

// file_dev.c
int file_read(struct m_inode * inode, struct file * filp, char * buf, int count) 
    ...
    int nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE);
    struct buffer_head *bh=bread(inode->i_dev,nr);
    ...
    char * p = nr + bh->b_data;
    while (chars-->0)
         put_fs_byte(*(p++),buf++);
    ...

首先 bmap 获取全局数据块号,然后 bread 将数据块的数据复制到缓冲区,然后 put_fs_byte 再一个字节一个字节地将缓冲区数据复制到用户指定的内存中。

我们一个个看。


bmap:获取全局的数据块号

先看第一个函数调用,bmap。

// file_dev.c
int file_read(struct m_inode * inode, struct file * filp, char * buf, int count) 
    ...
    int nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE);
    ...

// inode.c 
int bmap(struct m_inode * inode,int block) 
    return _bmap(inode,block,0);


static int _bmap(struct m_inode * inode,int block,int create) 
    ...
    if (block<0)
        ...
    if (block >= 7+512+512*512)
        ...
    if (block<7) 
        // zone[0] 到 zone[7] 采用直接索引,可以索引小于 7 的块号
        ...
    if (block<512)
        // zone[7] 是一次间接索引,可以索引小于 512 的块号
        ...
    // zone[8] 是二次间接索引,可以索引大于 512 的块号

我们看到整个条件判断的结构是根据 block 来划分的。

block 就是要读取的块号,之所以要划分,就是因为 inode 在记录文件所在块号时,采用了多级索引的方式。

zone[0] 到 zone[7] 采用直接索引,zone[7] 是一次间接索引,zone[8] 是二次间接索引。

那我们刚开始读,块号肯定从零开始,所以我们就先看 block<7,通过直接索引这种最简单的方式读的代码。

// inode.c
static int _bmap(struct m_inode * inode,int block,int create) 
    ...
    if (block<7) 
        if (create && !inode->i_zone[block])
            if (inode->i_zone[block]=new_block(inode->i_dev)) 
                inode->i_ctime=CURRENT_TIME;
                inode->i_dirt=1;
            
        return inode->i_zone[block];
    
    ...

由于 create = 0,也就是并不需要创建一个新的数据块,所以里面的 if 分支也没了。

// inode.c
static int _bmap(struct m_inode * inode,int block,int create) 
    ...
    if (block<7) 
        ...
        return inode->i_zone[block];
    
    ...

可以看到,其实 bmap 返回的,就是要读入的块号,从全局看在块设备的哪个逻辑块号下

也就是说,假如我想要读这个文件的第一个块号的数据,该函数返回的事你这个文件的第一个块在整个硬盘中的哪个块中。


bread:将 bmap 获取的数据块号读入到高速缓冲块

好了,拿到这个数据块号后,回到 file_read 函数接着看。

// file_dev.c
int file_read(struct m_inode * inode, struct file * filp, char * buf, int count) 
    ...
    while (left) 
        if (nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE)) 
            if (!(bh=bread(inode->i_dev,nr)))
    

nr 就是具体的数据块号,作为其中其中一个参数,传入下一个函数 bread。

bread 这个方法的入参除了数据块号 block(就是刚刚传入的 nr)外,还有 inode 结构中的 i_dev,表示设备号。

// buffer.c
struct buffer_head * bread(int dev,int block) 
    struct buffer_head * bh = getblk(dev,block);
    if (bh->b_uptodate)
        return bh;
    ll_rw_block(READ,bh);
    wait_on_buffer(bh);
    if (bh->b_uptodate)
        return bh;
    brelse(bh);
    return NULL;

这个 bread 方法就是根据一个设备号 dev 和一个数据块号 block,将这个数据块的数据,从硬盘复制到缓冲区里。

关于缓冲区,已经在 第19回 | 缓冲区初始化 buffer_init 说明过了,有些久远。而 getblk 方法,就是根据设备号 dev 和数据块号 block,申请到一个缓冲块

简单说就是,先根据 hash 结构快速查找这个 dev 和 block 是否有对应存在的缓冲块。

如果没有,那就从之前建立好的双向链表结构的头指针 free_list 开始寻找,直到找到一个可用的缓冲块。

具体代码逻辑,还包含当缓冲块正在被其他进程使用,或者缓冲块对应的数据已经被修改时的处理逻辑,你可以看一看,关键流程我已加上了注释。

// buffer.c
struct buffer_head * bread(int dev,int block) 
    struct buffer_head * bh = getblk(dev,block);
    ...


struct buffer_head * getblk(Linux 0.11-信号-48

Linux 0.11-信号-48

Linux 0.11-加载根文件系统-32

Linux 0.11-加载根文件系统-32

Linux 0.11-读取硬盘数据的细节-47

Linux 0.11-读取硬盘数据的细节-47