
虚拟文件系统(VFS)
在linux设备上有很多类型的文件如:字符型设备,块设备,管道,链接,目录,普通文件
如果对每种类型都写一遍专属于该类类型的读写函数,那么整个设计会越来越臃肿难以管理,在linux中采用虚拟文件系统(VFS)来提供一类统一的接口访问不同的设备
struct file结构体
struct file {
unsigned short f_mode; // 文件操作模式(RW 位)
unsigned short f_flags; // 文件打开和控制的标志
unsigned short f_count; // 对应文件句柄(文件描述符)数
struct m_inode * f_inode; // 指向对应 i 节点
off_t f_pos; // 文件位置(读写偏移值)
};
普通文件file_dev
在file_dev.c中的函数用于读写文件,供系统调用函数read()和 write()调用,没有其他地方引用
file_read
/**
* @brief 通过i节点和filp结构读取指定大小的字节到buf
*
* @param inode i节点
* @param filp 文件结构指针
* @param buf 目的缓冲区
* @param count 字节数
* @return int 实际读取的字节数,或出错号
*/
int file_read(struct m_inode * inode, struct file * filp, char * buf, int count)
整个函数主要实现就是通过一个while大循环,left表示从当前文件偏移剩余要读取的字节数
if ((left=count)<=0)
return 0;
while (left) {
if (nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE)) { // 获得逻辑块号 nr
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++);
}
}
首先通过bmap获取当前文件读取偏移位置所对应的逻辑块号,然后使用bread去获取得到这个逻辑块的高速缓冲区

filp->f_pos % BLOCK_SIZE是去获取文件实际在块中的偏移,并且会去比较当前块剩余的内容和left谁更小,放入chars
然后让指针p= nr + bh->b_data,逐字节将缓冲块内容放入用户缓冲区
这其中如果高速缓冲区是空块,对应文件内容不连续的情况,也支持直接填0
file_write
/**
* @brief 将用户数据写入指定设备(实际上是写入内核缓冲区,到时机成熟在同步到设备磁盘)
*
* @param inode i节点
* @param filp 文件结构指针
* @param buf 用户数据缓冲区(源)
* @param count 待写入字节
* @return int 实际写入的字节,或出错号
*/
int file_write(struct m_inode * inode, struct file * filp, char * buf, int count)
在这里最开始有一个判断,当前写文件操作是否是追加写入操作
if (filp->f_flags & O_APPEND)
pos = inode->i_size;
else
pos = filp->f_pos;
如果是追加写那么文件偏移pos就等于文件当前大小,否则就等于文件记录的f_pos
然后进入while大循环和上面读取操作类似
while (i<count) {
if (!(block = create_block(inode,pos/BLOCK_SIZE)))
break;
if (!(bh=bread(inode->i_dev,block)))
break;
c = pos % BLOCK_SIZE;
p = c + bh->b_data; // 指针绑定
bh->b_dirt = 1;
c = BLOCK_SIZE-c;
if (c > count-i) c = count-i;
pos += c;
if (pos > inode->i_size) {
inode->i_size = pos;
inode->i_dirt = 1;
}
i += c;
while (c-->0)
*(p++) = get_fs_byte(buf++); // 将用户数据写入bh结构
brelse(bh);
}
这里的create_block是去获取对应的逻辑块号,如果没有就会创建(对应追加写情况)
if (pos > inode->i_size) {
inode->i_size = pos;
inode->i_dirt = 1;
}
这里进行的判断是否当前写完的文件大小是否大于原本文件大小,如果是那么就更新文件大小
在完成写操作之后就更新时间和文件偏移位置
块设备文件block_dev
块设备文件的读写在block_dev.c文件中,包括block_read()和block_write()两个函数。这两个函数是供系统调用函数read()和 write()
block_write
/* @param dev 设备号
* @param pos 偏移量
* @param buf 待写入字节数组
* @param count 写入字节数量
* @return int 成功写入字节数量
*/
int block_write(int dev, long * pos, char * buf, int count)
数据块写函数 – 向指定设备从给定偏移处写入指定字节数据
首先是去获取传入偏移量对应在哪一个块号,以及在这个块内偏移量;BLOCK_SIZE_BITS为10,右移10位就是除以1024;而BLOCK_SIZE-1对应16进制0x3FF,与操作之后只留下低10位,刚好对应块内偏移
int block = *pos >> BLOCK_SIZE_BITS; // 由 pos 地址换算成开始读写块的块序号 block。并求出需读第 1 字节在该块中的偏移位置 offset
int offset = *pos & (BLOCK_SIZE-1);
然后和file_write类似也是使用一个while大循环,首先获取当前块还剩下多少可写:
- 如果需要写入的小于块剩余那么
chars=count; - 如果块内偏移刚好为0,要写的就是整块,那么就申请一块高速缓冲块
- 如果块内剩余不为0但是count大于块内剩余,那么就需要预申请两块高速缓冲块
breada
预读两块是一种“空间换时间”的经典手法。虽然极端情况下(比如只写极少量数据)会浪费缓存和一点带宽,但在连续大文件写入场景下,它能通过一次磁道读取,覆盖未来 2~3 次循环的磁盘等待,大幅提升整体吞吐量。
while (count>0) {
chars = BLOCK_SIZE - offset; // 计算在该块中可写入的字节数。如果需要写入的字节数填不满一块,则只需写 count 字节
if (chars > count)
chars=count;
if (chars == BLOCK_SIZE) // 如果正好要写 1 块数据,则直接申请 1 块高速缓冲块,否则需要读入将被修改的数据块,并预读下两块数据
bh = getblk(dev,block);
else
bh = breada(dev,block,block+1,block+2,-1);
block++;
if (!bh)
return written?written:-EIO;
最后就是逐字节进行数据写入
p = offset + bh->b_data; // p为写指针
offset = 0;
*pos += chars;
written += chars;
count -= chars;
while (chars-->0)
*(p++) = get_fs_byte(buf++);
bh->b_dirt = 1;
brelse(bh);
}
return written;
block_read
设备块读函数思路是类似的这里就不赘述了
/**
* @brief 从指定设备的指定位置读入指定字节数的数据到buf
*
* @param dev 设备号
* @param pos 位置
* @param buf 数据目的缓冲区
* @param count 字节数
* @return int 成功读取字节数
*/
int block_read(int dev, unsigned long * pos, char * buf, int count)
{
int block = *pos >> BLOCK_SIZE_BITS;
int offset = *pos & (BLOCK_SIZE-1);
int chars;
int read = 0;
struct buffer_head * bh;
register char * p;
while (count>0) {
chars = BLOCK_SIZE-offset;
if (chars > count)
chars = count;
if (!(bh = breada(dev,block,block+1,block+2,-1)))
return read?read:-EIO;
block++;
p = offset + bh->b_data;
offset = 0;
*pos += chars;
read += chars;
count -= chars;
while (chars-->0)
put_fs_byte(*(p++),buf++);
brelse(bh);
}
return read;
}
管道文件pipe
管道文件操作在pipe.c,程序里面包含管道读写操作和管道系统调用sys_pipe()
在创建并初始化管道时,程序会专门申请一个管道i节点,并为管道分配一页缓冲区(4KB)。
- 管道i节点的
i_size字段为指向管道缓冲区的指针。 - 管道数据头指针存放在
i_zone[0]字段,注意存放的是偏移量,不是地址 - 管道数据尾指针存放在
i_zone[1]字段同样是偏移量

这是一个环形缓冲区
管道的一些宏定义
// 管道头、管道尾、管道大小、管道空?、管道满?、管道头指针递增
#define PIPE_HEAD(inode) ((inode).i_zone[0])
#define PIPE_TAIL(inode) ((inode).i_zone[1])
#define PIPE_SIZE(inode) ((PIPE_HEAD(inode)-PIPE_TAIL(inode))&(PAGE_SIZE-1))
#define PIPE_EMPTY(inode) (PIPE_HEAD(inode)==PIPE_TAIL(inode))
#define PIPE_FULL(inode) (PIPE_SIZE(inode)==(PAGE_SIZE-1))
read_pipe
该函数用于读取管道中的数据,若是没有数据就唤醒写管道进程,自身陷入睡眠状态;若读取到数据就将数据传入用户缓冲区
/**
* @brief 读管道
*
* @param inode 管道对应的i节点
* @param buf 目的用户缓冲区
* @param count 待读取的字节数
* @return int 实际读取的字节数
*/
int read_pipe(struct m_inode * inode, char * buf, int count)
整个读取操作是在一个while大循环里面完成的,使用while循环的原因是因为为了实现循环缓冲区的读写
首先是获取管道大小,如果管道大小为0就唤醒写管道,自身陷入睡眠;唤醒的时候要先看一下管道文件是不是有两个使用者(一个读一个写),如果不是直接返回
while (count>0) {
while (!(size=PIPE_SIZE(*inode))) {
wake_up(&inode->i_wait);
if (inode->i_count != 2) /* are there any writers? */
return read;
sleep_on(&inode->i_wait);
}
接下来算出管道可以读取的总大小PAGE_SIZE-PIPE_TAIL(*inode);,要读取的大小不能超过管道大小和用户需要读取的大小
chars = PAGE_SIZE-PIPE_TAIL(*inode);
if (chars > count)
chars = count;
if (chars > size)
chars = size;
count -= chars;
read += chars;
接着让size等于管道尾指针偏移,为后续读取操作作为数组下标做准备,因为马上要对管道尾做更新操作,而不是读取完之后更新
size = PIPE_TAIL(*inode);
PIPE_TAIL(*inode) += chars;
PIPE_TAIL(*inode) &= (PAGE_SIZE-1);
更新管道尾:向前加本次读取字节数,这里PIPE_TAIL(*inode) &= (PAGE_SIZE-1);就是一个循环缓冲区操作:如果PIPE_TAIL+chars大于PAGE_SIZE就通过这个操作让相对偏移变为(0,4096)
最后进行数据读入用户缓冲区操作
while (chars-->0)
put_fs_byte(((char *)inode->i_size)[size++],buf++); // === key ===
}
wake_up(&inode->i_wait);
return read;
这里就是使用i_size[size]来操作,把这个缓冲区当成一个数组
循环缓冲区实现

例如出现这种情况,读指针在头指针前面,数据跨圈
- 那么前面
chars = PAGE_SIZE-PIPE_TAIL(*inode);得到的就是后面一段数据大小 PIPE_TAIL(*inode) += chars;之后读指针到了最后4096的位置PIPE_TAIL(*inode) &= (PAGE_SIZE-1); ---> 4096&4095得到结果为0- 于是读指针到了0的位置
- 完成此次读取之后,
count不为0,又一次进入while循环,这次循环就能够读取完剩余数据
write_pipe
写管道和读管道的实现是一样的,也是通过一个while大循环去实现循环缓冲区
/**
* @brief 写管道
*
* @param inode 管道对应的i节点
* @param buf 源用户缓冲区
* @param count 待写入的字节数
* @return int 实际写入的字节数
*/
int write_pipe(struct m_inode * inode, char * buf, int count)
{
int chars, size, written = 0;
while (count>0) {
while (!(size=(PAGE_SIZE-1)-PIPE_SIZE(*inode))) {
wake_up(&inode->i_wait);
if (inode->i_count != 2) { /* no readers */
current->signal |= (1<<(SIGPIPE-1));
return written?written:-1;
}
sleep_on(&inode->i_wait);
}
chars = PAGE_SIZE-PIPE_HEAD(*inode);
if (chars > count)
chars = count;
if (chars > size)
chars = size;
count -= chars;
written += chars;
size = PIPE_HEAD(*inode);
PIPE_HEAD(*inode) += chars;
PIPE_HEAD(*inode) &= (PAGE_SIZE-1);
while (chars-->0)
((char *)inode->i_size)[size++]=get_fs_byte(buf++);
}
wake_up(&inode->i_wait);
return written;
}
sys_pipe
创建管道系统调用函数,在传入参数fildes所指的数组中创建一对文件句柄(描述符)。这对文件句柄指向一管道 i 节点
//// 创建管道系统调用函数。
// 在 fildes 所指的数组中创建一对文件句柄(描述符)。这对文件句柄指向一管道 i 节点。fildes[0]
// 用于读管道中数据,fildes[1]用于向管道中写入数据。
// 成功时返回 0,出错时返回-1。
int sys_pipe(unsigned long * fildes)
sys_pipe 的本质就是在当前进程的打开文件表里创建两个入口,让它们同时指向一个带有 4KB 内存缓冲区的特殊 inode,从而构建起一条内核态的内存数据通道。
- 分配 2 个
struct file(文件结构):从全局数组file_table[]中找出两个空闲项。 - 分配 2 个整数
fd(文件描述符):在当前进程的current->filp[]数组中找出两个空闲的槽位(比如3和4)。 - 分配 1 个
struct m_inode(管道节点):调用get_pipe_inode()。 - 这里有一个极容易忽略的步骤:
get_pipe_inode()不仅创建了 inode,还立即调用get_free_page()分配了一页物理内存(4KB)作为管道的环形数据缓冲区,并将该页地址赋给inode->i_size。
struct m_inode * inode;
struct file * f[2];
int fd[2];
int i,j;
j=0;
for(i=0;j<2 && i<NR_FILE;i++)
if (!file_table[i].f_count)
(f[j++]=i+file_table)->f_count++;
if (j==1)
f[0]->f_count=0;
if (j<2)
return -1;
j=0;
for(i=0;j<2 && i<NR_OPEN;i++)
if (!current->filp[i]) {
current->filp[ fd[j]=i ] = f[j];
j++;
}
if (j==1)
current->filp[fd[0]]=NULL;
if (j<2) {
f[0]->f_count=f[1]->f_count=0;
return -1;
}
if (!(inode=get_pipe_inode())) {
current->filp[fd[0]] =
current->filp[fd[1]] = NULL;
f[0]->f_count = f[1]->f_count = 0;
return -1;
}
- 绑定文件描述符与文件结构:
current->filp[fd_read] = file_read;和current->filp[fd_write] = file_write; - 绑定文件结构与 inode:
file_read->f_inode = inode;和file_write->f_inode = inode;(重点:两个不同的file结构指向同一个 inode)。 - 设置读写模式:
file_read->f_mode = 1(读);file_write->f_mode = 2(写)。
f[0]->f_inode = f[1]->f_inode = inode;
f[0]->f_pos = f[1]->f_pos = 0;
f[0]->f_mode = 1; /* read */
f[1]->f_mode = 2; /* write */
put_fs_long(fd[0],0+fildes);
put_fs_long(fd[1],1+fildes);
return 0;
字符设备文件char_dev
char_dev.c包括字符设备的文件访问函数
在这里面有一个值的注意的数组:
typedef (*crw_ptr)(int rw,unsigned minor,char * buf,int count,off_t * pos); // 定义字符设备读写函数指针类型
static crw_ptr crw_table[]={
NULL, /* nodev */ /* 无设备(空设备) */
rw_memory, /* /dev/mem etc */ /* 内存 */
NULL, /* /dev/fd */ /* /dev/fd 软驱 */
NULL, /* /dev/hd */ /* /dev/hd 硬盘 */
rw_ttyx, /* /dev/ttyx */ /* /dev/ttyx 串口终端1 */
rw_tty, /* /dev/tty */ /* /dev/tty 串口终端2 */
NULL, /* /dev/lp */ /* /dev/lp 打印机 */
NULL}; /* unnamed pipes */ /* 未命名管道 */
这个数组有file_opreation的雏形,它会根据主设备号不同从而调用不同的字符设备读写函数
rw_char
整个文件里面最重要的函数就是rw_char,他被sys_read,sys_write函数调用
/**
* @brief 字符设备读写操作函数
*
* @param rw 读写命令
* @param dev 设备号
* @param buf 缓冲区
* @param count 读写字节数
* @param pos 读写指针
* @return int 实际读写字节数
*/
int rw_char(int rw,int dev, char * buf, int count, off_t * pos)
{
crw_ptr call_addr;
if (MAJOR(dev)>=NRDEVS)
return -ENODEV;
if (!(call_addr=crw_table[MAJOR(dev)]))
return -ENODEV;
return call_addr(rw,MINOR(dev),buf,count,pos);
}
在函数里面会根据传入的主设备号去crw_table数组中寻找对应的设备读写函数
例如主设备号为1,寻找到rw_memory
rw_memory
/**
* @brief 内存读写操作函数
*
* @param rw 读写命令
* @param minor 子设备号
* @param buf 缓冲区
* @param count 读写字节数
* @param pos 读写操作当前指针
* @return int 实际的读写字节数
*/
static int rw_memory(int rw, unsigned minor, char * buf, int count, off_t * pos)
{
switch(minor) {
case 0:
return rw_ram(rw,buf,count,pos);
case 1:
return rw_mem(rw,buf,count,pos);
case 2:
return rw_kmem(rw,buf,count,pos);
case 3:
return (rw==READ)?0:count; /* rw_null */
case 4:
return rw_port(rw,buf,count,pos);
default:
return -EIO;
}
}
该函数根据传入的次设备号进行细分,不过当前0.1版本并没有实现次设备号
比如次设备号4,就找到rw_port
rw_port
/**
* @brief 端口读写操作函数
*
* @param rw 读写命令
* @param buf 缓冲区
* @param count 读写字节数
* @param pos 端口地址
* @return int 实际读写的字节数
*/
static int rw_port(int rw,char * buf, int count, off_t * pos)
{
int i=*pos;
while (count-->0 && i<65536) {
if (rw==READ)
put_fs_byte(inb(i),buf++);
else
outb(get_fs_byte(buf++),i);
i++;
}
i -= *pos;
*pos += i;
return i;
}
rw_port就是底层的硬件驱动函数
系统调用读写函数
在fs/read_write.c就是存储的系统调用读写函数
在这里放一张从用户态最终到硬件执行的全流程图

可以看到首先是用户层进行系统函数调用,这些函数会触发系统调用中断,而系统调用中断根据调用号去sys_call_table寻找对应的系统调用函数
找到系统调用读写函数之后,会去判读当前是什么文件类型,然后调用对应的设备读写函数,设备读写函数根据主从设备号去找到对应的底层对写函数,最终硬件执行
sys_read
// 系统调用读文件函数
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); // === key ===
}
printk("(Read)inode->i_mode=%06o\n\r",inode->i_mode);
return -EINVAL;
}
根据传入的文件描述符fd在当前进程的filp获取得到对应的struct file结构体指针,然后通过结构体指针得到该文件的inode
在得到inode后就可以进行判断到底是什么设备,然后调用对应的设备读写函数
sys_write
系统调用写函数和读函数一样,这里不做过多解释
// 系统调用写函数
int sys_write(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;
inode=file->f_inode;
if (inode->i_pipe)
return (file->f_mode&2)?write_pipe(inode,buf,count):-EIO;
if (S_ISCHR(inode->i_mode))
return rw_char(WRITE,inode->i_zone[0],buf,count,&file->f_pos);
if (S_ISBLK(inode->i_mode))
return block_write(inode->i_zone[0],&file->f_pos,buf,count);
if (S_ISREG(inode->i_mode))
return file_write(inode,file,buf,count); // === key ===
printk("(Write)inode->i_mode=%06o\n\r",inode->i_mode);
return -EINVAL;
}
sys_open
sys_open函数在fs/open.c,这里面主要是文件打开创建关闭等操作
sys_open主要是以下操作:
1.在当前进程的文件索引表寻找一个空项,这个下标对应文件描述符fd
// 将用户设置的模式与进程的模式屏蔽码相与,产生许可的文件模式
mode &= 0777 & ~current->umask;
for(fd=0 ; fd<NR_OPEN ; fd++)
if (!current->filp[fd]) // 查找一个filp中的空闲项
break;
if (fd>=NR_OPEN)
return -EINVAL;
current->close_on_exec &= ~(1<<fd);//执行时关闭文件句柄位图标志
这里close_on_exec 是一个位图,每一位对应一个文件描述符(fd)。
- 如果该位 = 1:当进程调用
exec()(执行新程序)时,内核会在加载新程序前自动关闭这个文件描述符。 - 如果该位 = 0(默认情况):当进程调用
exec()时,这个文件描述符会被子进程继承并保持打开,新程序可以直接读写这个 fd。
2.在系统的文件表file_table[]中寻找一个空项
f=0+file_table;
for (i=0 ; i<NR_FILE ; i++,f++)
if (!f->f_count) break; // 搜索file_table中的空闲文件结构项
if (i>=NR_FILE)
return -EINVAL;
3.进行当前进程文件索引表和系统文件表的映射
(current->filp[fd]=f)->f_count++;// 让进程的对应文件句柄的文件结构指针指向搜索到的文件结构,并令句柄引用计数递增 1
4.调用open_namei函数创建一个对应文件名的inode节点
5.将inode映射到系统文件表
f->f_mode = inode->i_mode;
f->f_flags = flag;
f->f_count = 1;
f->f_inode = inode;
f->f_pos = 0;
6.最后返回文件描述符
return (fd);
文件查找方式
在最后,根据这段时间对于文件系统的学习,笔者大致画了一下文件的查找方式

