46.读硬盘数据全流程

46.读硬盘数据全流程

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

1
2
3
4
5
$ cat > info.txt <<EOF
name:flash
age:28
language:java
EOF

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

1
2
[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 来表示,单位是字节。

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

1
2
3
int sys_read(unsigned int fd,char * buf,int count) {    
    ...
}

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

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

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

 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
// 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;
}

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

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

 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
// 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 下一切皆文件,所以这个函数将管道文件、字符设备文件、块设备文件、目录文件、普通文件分别指向了不同的具体实现。

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

再简化下代码。

1
2
3
4
5
6
7
8
9
// 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 区域的内存做校验的部分,说是校验,里面还挺有说道呢。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 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 在图中的那个位置。那么开始的两行计算代码。

1
2
3
4
5
6
7
// fork.c
void verify_area(void * addr,int size) {    
    ...    
    size += start & 0xfff;    
    start &= 0xfffff000;    
    ...
}

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

图片

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

1
2
3
4
5
6
// 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 要加上它自己进程的数据段基址,才对。

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

1
2
3
4
5
6
7
8
9
// fork.c
void verify_area(void * addr,int size) {    
    ...    
    while (size>0) {        
        size -= 4096;        
        write_verify(start);        
        start += 4096;    
    }
}

也就是这些页。

图片

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 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 函数了。

 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
// 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 去掉,简化起来就是这样。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 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()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 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,通过直接索引这种最简单的方式读的代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 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 分支也没了。

1
2
3
4
5
6
7
8
9
// 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 函数接着看。

1
2
3
4
5
6
7
8
// 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,表示设备号。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 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 开始寻找,直到找到一个可用的缓冲块。

图片

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

 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
// buffer.c
struct buffer_head * bread(int dev,int block) {
    struct buffer_head * bh = getblk(dev,block);
    ...
}
struct buffer_head * getblk(int dev,int block) {
    struct buffer_head * tmp, * bh;
repeat:
    // 先从 hash 结构中找
    if (bh = get_hash_table(dev,block))
        return bh;
    // 如果没有就从 free_list 开始找遍双向链表
    tmp = free_list;
    do {
        if (tmp->b_count)
            continue;
        if (!bh || BADNESS(tmp)<BADNESS(bh)) {
            bh = tmp;
            if (!BADNESS(tmp))
                break;
        }
    } while ((tmp = tmp->b_next_free) != free_list);
    // 如果还没找到,那就说明没有缓冲块可用了,就先阻塞住等一会
    if (!bh) {
        sleep_on(&buffer_wait);
        goto repeat;
    }
    // 到这里已经说明申请到了缓冲块,但有可能被其他进程上锁了
    // 如果上锁了的话,就先等等
    wait_on_buffer(bh);
    if (bh->b_count)
        goto repeat;
    // 到这里说明缓冲块已经申请到,且没有上锁
    // 但还得看 dirt 位,也就是有没有被修改
    // 如果被修改了,就先重新从硬盘中读入新数据
    while (bh->b_dirt) {
        sync_dev(bh->b_dev);
        wait_on_buffer(bh);
        if (bh->b_count)
            goto repeat;
    }
    if (find_buffer(dev,block))
        goto repeat;
    // 给刚刚获取到的缓冲头 bh 重新赋值
    // 并调整在双向链表和 hash 表中的位置
    bh->b_count=1;
    bh->b_dirt=0;
    bh->b_uptodate=0;
    remove_from_queues(bh);
    bh->b_dev=dev;
    bh->b_blocknr=block;
    insert_into_queues(bh);
    return bh;
}

总之,经过 getblk 之后,我们就在内存中,找到了一处缓冲块,用来接下来存储硬盘中指定数据块的数据。

那接下来的一步,自然就是把硬盘中的数据复制到这里啦,没错,ll_rw_block 就是干这个事的。

这个方法的细节特别复杂,也是我看了好久才看明白的地方,我会在下一回把这个方法详细地展开讲解。

在这一回里,你就当它已经成功地把硬盘中的一个数据块的数据,一个字节都不差地复制到了我们刚刚申请好的缓冲区里。

接下来,就要通过 put_fs_byte 方法,一个字节一个字节地,将缓冲区里的数据,复制到用户指定的内存 buf 中去了,当然,只会复制 count 字节。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 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++);
    ...
}

put_fs_byte:将 bread 读入的缓冲块数据复制到用户指定的内存中

这个过程,仅仅是内存之间的复制,所以不必紧张。

1
2
3
4
5
// segment.h
extern _inline void
put_fs_byte (char val, char *addr) {
    __asm__ ("movb %0,%%fs:%1"::"r" (val),"m" (*addr));
}

有点难以理解,我改成较为好看的样子。(参考赵炯《Linux 内核完全注释 V1.9.5》)

1
2
3
4
5
6
7
// segment.h
extern _inline void
put_fs_byte (char val, char *addr) {
    _asm mov ebx,addr
    _asm mov al,val;
    _asm mov byte ptr fs:[ebx],al;
}

其实就是三个汇编指令的 mov 操作。

至此,我们就将数据从硬盘读入缓冲区,再从缓冲区读入用户内存,一个 read 函数完美谢幕!

图片

首先通过 verify_area 对内存做了校验,需要写时复制的地方在这里提前进行好了。

接下来,file_read 方法做了读盘的全部操作,通过 bmap 获取到了硬盘全局维度的数据块号,然后 bread 将数据块数据复制到缓冲区,然后 put_fs_byte 再将缓冲区数据复制到用户内存。

updatedupdated2024-05-052024-05-05