Linux内核-文件系统-虚拟文件系统(VFS)

虚拟文件系统(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去获取得到这个逻辑块的高速缓冲区

文件读取块结构png

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]字段同样是偏移量
管道缓冲区png

这是一个环形缓冲区

管道的一些宏定义

// 管道头、管道尾、管道大小、管道空?、管道满?、管道头指针递增
#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]来操作,把这个缓冲区当成一个数组

循环缓冲区实现

管道循环缓冲区情况png

例如出现这种情况,读指针在头指针前面,数据跨圈

  1. 那么前面chars = PAGE_SIZE-PIPE_TAIL(*inode);得到的就是后面一段数据大小
  2. PIPE_TAIL(*inode) += chars;之后读指针到了最后4096的位置
  3. PIPE_TAIL(*inode) &= (PAGE_SIZE-1); ---> 4096&4095得到结果为0
  4. 于是读指针到了0的位置
  5. 完成此次读取之后,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[] 数组中找出两个空闲的槽位(比如 34)。
  • 分配 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;
  • 绑定文件结构与 inodefile_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就是存储的系统调用读写函数

在这里放一张从用户态最终到硬件执行的全流程图

读写文件全流程png

可以看到首先是用户层进行系统函数调用,这些函数会触发系统调用中断,而系统调用中断根据调用号去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);

文件查找方式

在最后,根据这段时间对于文件系统的学习,笔者大致画了一下文件的查找方式

文件查找方式.png

上一篇