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