公告

Gentoo交流群:87709706 欢迎您的加入

#401 内存管理模块 » linux源码解读(八):内存管理——分页和分段 » 2022-10-08 14:13:41

batsom
回复: 0

1、计算的内存和磁盘都是用来存储数据的,作用上没有本质区别,但是这两种存储介质的特性却差异巨大:

  内存需要上电才能存储数据,一旦掉电数据就没了,磁盘却不需要用电也能保存数据
       内存的速度很快,大约100ns就能读写数据,而磁盘是毫秒级别的,理论速度差了几万倍;
       所以计算机运行时为了追求速度,会尽量把数据放内存,那么问题来了:内存因为价格原因,空间比磁盘小很多,怎么高效地管理和利用内存的存储空间了?

  2、(1)举个农业的例子:

  上千亩的农田,农场主肯定会根据时令季节、农产种类和价格、农田本身的肥沃度等因素把农田分成很多块,不同的农田种植不同的农产品,内存管理和种田的策略没有本质区别:需要把内存化整为零,不同的部分存储不同的数据,比如内核的代码段、数据段、堆栈段等,这就诞生了GDT表;同理用户的应用程序也有代码段、数据段、堆栈段,所以也诞生了LDT;这两种表需要使用到ds、cs、ss等段寄存器,这就是内存分段的原因;
  这里也引申出了另一个问题:为啥要把内核和用户程序分开了?原因也很简单:如果在一起,用户程序能随意读写内核代码指令或数据,岂不是很危险? 分开后,用户程序执行时如果要调用操作系统的某些接口(比如print、open、read、write、send等),需要通过软中断(int)或系统调用(syscall/sysenter)的方式提权进入内核才行,通过此方式保证用户程序不能随意更改内核代码或数据;
        这里有引申出另一个问题: cpu是典型的指哪打哪,只要eip指向哪,就从哪取数据当成指令来执行,那么cpu是怎么判断当前是内核态还是用户态的了?通过段寄存器的算选择子:一共有2位,可以表示4种不同的状态,一般只用了00和11这两种;00表示0,就是内核态,俗称0环;11表示3,就是用户程序,俗称3环;程序在运行时,如果eip跨越了段寄存器基址+limit的限制(LDT或GDT有标识的),cpu硬件会先检查段选择子的权限,看看是不是从3环到0环;如果是,就必须通过上述的中断或系统调用方式,否则直接在硬件层面出异常报错!当然,从3环到3环也是要报错的(还是通过LDT检查是不是已经超出了本应用程序的cs、ds、ss的范围),这就从硬件层面的机制上保证了A应用程序不会被B应用程序读写而导致数据泄露或程序被破坏(进程切换靠tss,也是cpu从硬件层面保证的机制)!
       还有最后一个问题:为啥通过软中断或系统调用从3环进0环就是安全(当然这是相对的安全)的,而3环直接访问0环的代码或数据就是危险的了? 中断或系统调用,3环只需要提供调用号就行了,不需要知道具体实现的代码是怎么写的,也完全看不见;只要操作系统内核保证系统调用的实现过程是安全的就ok了!软中断或系统调用:本质上就是提供了3环低权限到0环高权限的提权通道,让代码进入0环执行;但执行的代码又是操作系统实现定义好的,用户的应用程序是没法随意更改的,由此又保障了内核的代码和数据安全,一个字:绝!
        上述利用ds、cs、ss等寄存器把内存划分成一段一段地形式,不同的段分别给内核、应用程序使用;代码一旦要跨段运行,需要检查段的权限来确认是否能够跨段,这是早期操作系统和cpu硬件隔离程序的做法,现在的64位操作系统已经不是这么做的了!大家可以用windbg看看64位的windows系统,可以发现ds、cs、ss居然都是从0开始的,limit也都是0xffffffffffffff,用行话说要“平坦化”了,这种情况怎么隔离内核和应用程序了?-----分页
  每个进程都有自己的CR3值,通过CR3页表映射,把逻辑地址转成物理地址;不同的进程有不同的CR3和不同的页表映射,即使是相同的逻辑地址,也会映射到不同的物理地址,由此让不同的进程拥有不用的物理地址,和上述通过ds、cs、ss方式对内存分段的思路是一样的,只不过换了一种方式实现!
      分页的另一个好处:大块的物理内存经过长时间的分配和回收,可能已经支离破碎;经过虚拟内存映射后,把零散的物理内存映射成大整块的虚拟内存;
      分页的漏洞: 人为更改页表,把逻辑地址映射的物理地址改成其他的物理地址,导致应用程序读取的数据或代码是错误的,达到无痕hook的目的!windows下可以用来过PG保护的;
      内核代码不分页,永占物理内存,原因也很简单:内核代码是核心,需要一直运行,当然不能被换出内存了,举个例子:发生缺页异常了,需要把页面的数据从磁盘重新加载到内存,缺页异常的handler也在内核,如果连这部分代码都分页,这个页面被放入磁盘,岂不成了死循环?所以只有应用程序的内存需要分页管理!
  (2)概念澄清:

    逻辑地址:程序员写代码看到和使用的地址,是操作系统分配给每个进程的独立地址空间,也就是CR3转换前的地址空间

    线性地址:等于段基址+逻辑地址;windows 64位的操作系统段平坦化以后,线性地址事实上等于逻辑地址了;

              物理地址:cpu物理总线表示的地址

   (3) 页的属性也有很多位来表示,学名叫页表描述,如下:每位的作用在参考链接1有,这里不再追溯!

FluxBB bbcode 测试

     这里重点说一下第5位access位:该页是否能被访问;如果不能,其他应用程序访问时会报错,这可以用于动态反调试 和反hook;这里有必要解释一下为啥低12bit可以用来表示各种属性,而不是用来表示地址了?

  由于cpu把物理内存按照4KB划分成1页,所以页目录和页表也是存储在物理页的;页目录和页表也是地址,每个item都是4byte,所以每个物理页能容纳1024个item;换句话说:页目录每个item指向一个装了1024个页表的物理页,由于物理页是4KB对齐的,导致页目录表的每个item的低12bit都是0;页目录同理:每个页目录的item都指向物理页的开始位置,导致页目录的每个item的第12bit都是0;所以第12bit实际上不可以不用来表示地址的,刚好就用来表示各种页属性了!整个示意如下:

这里容易混淆的就是页目录地址和页表项,因为存储这两类信息的内存本身是地址,恰好页目录和页表项的含义也是地址,两个地址在编码时非常容易混淆!明显的区别就是:页目录和页表项本身的地址是4字节递增的,但存储的内容是页首地址,以0x1000的颗粒度对齐的!
实际存储时,页目录项和页表项每个item的低12bit大概率会被用于指明各种属性,不太可能直接存0;实际计算下一级地址时需要清零;
后面的代码会大量涉及到*((unsigned long *) ((address>>20) &0xffc))))和((address>>10) & 0xffc),分别是页目录表中页表项的值,和页表项的偏移,两者相加就是页表项的线性地址;

FluxBB bbcode 测试

        3、内存管理

  大家还记得文件的inode和数据块是怎么管理的么?当时用了bitmap来标记的;逻辑块的最小使用大小是4KB。如果这4KB被分配使用,那么bitmap对应的bit设置为1;0.11版本的linux采用了相同的管理策略,仍然采用bitmap的方式标记内存页。如果某一页已经分配使用,那么bitmap对应的bit设置为1,否则设置为0;这里实际采用了名为mem_map的char数组标记3840个页面(15MB内存)被引用的次数;

/* these are not to be changed without changing head.s etc */
// linux0.11内核默认支持的最大内存容量是16MB,可以修改这些定义适合更多的内存。
// 内存低端(1MB)
#define LOW_MEM 0x100000
// 分页内存15 MB,主内存区最多15M.
#define PAGING_MEMORY (15*1024*1024)
// 分页后的物理内存页面数(3840)
#define PAGING_PAGES (PAGING_MEMORY>>12)
// 指定地址映射为页号
#define MAP_NR(addr) (((addr)-LOW_MEM)>>12)
// 页面被占用标志.
#define USED 100

// 物理内存映射字节图(1字节代表1页内存)。每个页面对应的字节用于标志页面当前引
// 用(占用)次数。它最大可以映射15MB的内存空间。在初始化函数mem_init()中,对于
// 不能用做主内存页面的位置均都预先被设置成USED(100).
static unsigned char mem_map [ PAGING_PAGES ] = {0,};

   查找空闲或使用的物理页面是,需要挨个遍历mem_map数组,linux 0.11是这样做的;同时也遍历pa_dir数组,看看哪些页目录和页表项已经被使用了!

//// 计算内存空闲页面数并显示
// [?? 内核中没有其他地方调用该函数,Linus调试过程中用的]
void calc_mem(void)
{
    int i,j,k,free=0;
    long * pg_tbl;

    // 扫描内存页面映射数组mem_map[],获取空闲页面数并显示。然后扫描所有的页目
    // 录项(除0,1项),如果页目录项有效,则统计对应页表中有效页面数,并显示。页
    // 目录项0-3被内核使用,因此应该从第5个目录项(i=4)开始扫描。
    for(i=0 ; i<PAGING_PAGES ; i++)
        if (!mem_map[i]) free++;
    printk("%d pages free (of %d)\n\r",free,PAGING_PAGES);
    for(i=2 ; i<1024 ; i++) {               // 初始值应该等于4;有1024个页目录
        if (1&pg_dir[i]) {
            pg_tbl=(long *) (0xfffff000 & pg_dir[i]);/*得到页表项;每个页目录有1024个页表项;每个页表项对应1024个物理页*/
            for(j=k=0 ; j<1024 ; j++)
                if (pg_tbl[j]&1)
                    k++;
            printk("Pg-dir[%d] uses %d pages\n",i,k);
        }
    }
}

  当然,在正式使用前,必须先初始化的,方法如下:

// 物理内存管理初始化
// 该函数对1MB以上的内存区域以页面为单位进行管理前的初始化设置工作。一个页面长度
// 为4KB bytes.该函数把1MB以上所有物理内存划分成一个个页面,并使用一个页面映射字节
// 数组mem_map[]来管理所有这些页面。对于具有16MB内存容量的机器,该数组共有3840
// 项((16MB-1MB)/4KB),即可管理3840个物理页面。每当一个物理内存页面被占用时就把
// mem_map[]中对应的字节值增1;若释放一个物理页面,就把对应字节值减1。若字节值为0,
// 则表示对应页面空闲;若字节值大于或等于1,则表示对应页面被占用或被不同程序共享占用。
// 在该版本的Linux内核中,最多能管理16MB的物理内存,大于16MB的内存将弃之不用。
// 对于具有16MB内存的PC机系统,在没有设置虚拟盘RAMDISK的情况下start_mem通常是4MB,
// end_mem是16MB。因此此时主内存区范围是4MB-16MB,共有3072个物理页面可供分配。而
// 范围0-1MB内存空间用于内核系统(其实内核只使用0-640Kb,剩下的部分被部分高速缓冲和
// 设备内存占用)。
// 参数start_mem是可用做页面分配的主内存区起始地址(已去除RANDISK所占内存空间)。
// end_mem是实际物理内存最大地址。而地址范围start_mem到end_mem是主内存区。
void mem_init(long start_mem, long end_mem)
{
    int i;

    // 首先将1MB到16MB范围内所有内存页面对应的内存映射字节数组项置为已占用状态,
    // 即各项字节全部设置成USED(100)。PAGING_PAGES被定义为(PAGING_MEMORY>>12),
    // 即1MB以上所有物理内存分页后的内存页面数(15MB/4KB = 3840).
    HIGH_MEMORY = end_mem;                  // 设置内存最高端(16MB)
    for (i=0 ; i<PAGING_PAGES ; i++)
        mem_map[i] = USED;
    // 然后计算主内存区起始内存start_mem处页面对应内存映射字节数组中项号i和主内存区页面数。
    // 此时mem_map[]数组的第i项正对应主内存区中第1个页面。最后将主内存区中页面对应的数组项
    // 清零(表示空闲)。对于具有16MB物理内存的系统,mem_map[]中对应4MB-16MB主内存区的项被清零。
    i = MAP_NR(start_mem);      // 主内存区(也就是用户程序内存)其实位置处页面号
    end_mem -= start_mem;
    end_mem >>= 12;             // 主内存区中的总页面数
    while (end_mem-->0)
        mem_map[i++]=0;         // 主内存区页面对应字节值清零
}

        根据mem_init推断早期linux的内存使用分布如下:
FluxBB bbcode 测试

  主内存(也就是用户应用程序)内存初始化完成后就可以按照页的颗粒度申请使用了;怎么找到空闲的物理页面了?当然是从mem_map数组开始找了,代码如下:

//// 在主内存区中取空闲屋里页面。如果已经没有可用物理内存页面,则返回0.
// 输入:%1(ax=0) - 0; %2(LOW_MEM)内存字节位图管理的其实位置;%3(cx=PAGING_PAGES);
// %4(edi=mem_map+PAGING_PAGES-1).
// 输出:返回%0(ax=物理内存页面起始地址)。
// 上面%4寄存器实际指向mem_map[]内存字节位图的最后一个字节。本函数从位图末端开
// 始向前扫描所有页面标志(页面总数PAGING_PAGE),若有页面空闲(内存位图字节为
// 0)则返回页面地址。注意!本函数只是指出在主内存区的一页空闲物理内存页面,但
// 并没有映射到某个进程的地址空间中去。后面的put_page()函数即用于把指定页面映射
// 到某个进程地址空间中。当然对于内核使用本函数并不需要再使用put_page()进行映射,
// 因为内核代码和数据空间(16MB)已经对等地映射到物理地址空间。
unsigned long get_free_page(void)
{
register unsigned long __res asm("ax");

__asm__("std ; repne ; scasb\n\t"   // 置方向位,al(0)与对应每个页面的(di)内容比较
    "jne 1f\n\t"                    // 如果没有等于0的字节,则跳转结束(返回0).
    "movb $1,1(%%edi)\n\t"          // 1 => [1+edi],将对应页面内存映像bit位置1.
    "sall $12,%%ecx\n\t"            // 页面数*4k = 相对页面其实地址
    "addl %2,%%ecx\n\t"             // 再加上低端内存地址,得页面实际物理起始地址
    "movl %%ecx,%%edx\n\t"          // 将页面实际其实地址->edx寄存器。
    "movl $1024,%%ecx\n\t"          // 寄存器ecx置计数值1024
    "leal 4092(%%edx),%%edi\n\t"    // 将4092+edx的位置->dei(该页面的末端地址)
    "rep ; stosl\n\t"               // 将edi所指内存清零(反方向,即将该页面清零)
    "movl %%edx,%%eax\n"            // 将页面起始地址->eax(返回值)
    "1:"
    :"=a" (__res)
    :"0" (0),"i" (LOW_MEM),"c" (PAGING_PAGES),
    "D" (mem_map+PAGING_PAGES-1)
    );
return __res;           // 返回空闲物理页面地址(若无空闲页面则返回0).
}

  用完之后必须要及时释放,不要“占着茅坑不拉屎”,释放的原理也很简单,把mem_map对应的内容清零就行了,如下:这里不得不吐槽一下,还是下面这种C代码看着简单;上面那种汇编语法看的人脑壳疼┭┮﹏┭┮

//// 释放物理地址addr开始的1页面内存。
// 物理地址1MB以下的内容空间用于内核程序和缓冲,不作为分配页面的内存空间。因此
// 参数addr需要大于1MB.
void free_page(unsigned long addr)
{
    // 首先判断参数给定的物理地址addr的合理性。如果物理地址addr小于内存低端(1MB)
    // 则表示在内核程序或高速缓冲中,对此不予处理。如果物理地址addr>=系统所含物
    // 理内存最高端,则显示出错信息并且内核停止工作。
    if (addr < LOW_MEM) return;
    if (addr >= HIGH_MEMORY)
        panic("trying to free nonexistent page");
    // 如果对参数addr验证通过,那么就根据这个物理地址换算出从内存低端开始记起的
    // 内存页面号。页面号 = (addr - LOW_MEM)/4096.可见页面号从0号开始记起。此时
    // addr中存放着页面号。如果该页面号对应的页面映射字节不等于0,则减1返回。此
    // 时该映射字节值应该为0,表示页面已释放。如果对应页面字节原本就是0,表示该
    // 物理页面本来就是空闲的,说明内核代码出问题。于是显示出错信息并停机。
    addr -= LOW_MEM;
    addr >>= 12;
    if (mem_map[addr]--) return;
    mem_map[addr]=0;
    panic("trying to free free page");
}

  当进程退出do_exit时,不仅要free_page,进程自己的页表、页目录项也要free,操作的代码如下: 找到页目录的基址,然后挨个遍历;遍历时找到页表项,进而找到使用的物理内存,挨个释放!

/*
 * This function frees a continuos block of page tables, as needed
 * by 'exit()'. As does copy_page_tables(), this handles only 4Mb blocks.
 */
//// 根据指定的线性地址和限长(页表个数),释放对应内存页表指定的内存块并置表项为
// 空闲。页目录位于物理地址0开始处,共1024项,每项4字节,共占4K字节。每个目录项
// 指定一个页表。内核页表从物理地址0x1000处开始(紧接着目录空间),共4个页表。每
// 个页表有1024项,每项4字节。因此占4K(1页)内存。各进程(除了在内核代码中的进
// 程0和1)的页表所占据的页面在进程被创建时由内核为其主内存区申请得到。每个页表
// 项对应1耶物理内存,因此一个页表最多可映射4MB的物理内存。
// 参数:from - 起始线性基地址;size - 释放的字节长度。
int free_page_tables(unsigned long from,unsigned long size)
{
    unsigned long *pg_table;
    unsigned long * dir, nr;

    // 首先检测参数from给出的线性基地址是否在4MB的边界处。因为该函数只能处理这
    // 种情况。若from=0,则出错。说明视图释放内核和缓冲所占空间。
    if (from & 0x3fffff)/*4MB取整;4MB也是一个目录项(第二级)对应的地址空间*/
        panic("free_page_tables called with wrong alignment");
    if (!from)
        panic("Trying to free up swapper memory space");
    // 然后计算参数size给出的长度所占的页目录项数(4MB的进位整数倍),也即所占
    // 页表数。因为1个页表可管理4MB物理内存,所以这里用右移22位的方式把需要复制
    // 的内存长度值除以4MB.其中加上0x3fffff(即4MB-1)用于得到进位整数倍结果,即
    // 除操作若有余数则进1。例如,如果原size=4.01Mb,那么可得到结果sieze=2。接
    // 着结算给出的线性基地址对应的其实目录项。对应的目录项号=from>>22.因为每
    // 项占4字节,并且由于页目录表从物理地址0开始存放,因此实际目录项指针=目录
    // 项号<<2,也即(from>>20)。& 0xffc确保目录项指针范围有效,即用于屏蔽目录项
    // 指针最后2位。因为只移动了20位,因此最后2位是页表项索引的内容,应屏蔽掉。
    size = (size + 0x3fffff) >> 22;
    dir = (unsigned long *) ((from>>20) & 0xffc); /* _pg_dir = 0 */
    // 此时size是释放的页表个数,即页目录项数,而dir是起始目录项指针。现在开始
    // 循环操作页目录项,依次释放每个页表中的页表项。如果当前目录项无效(P位=0)
    // 表示该目录项没有使用(对应的页表不存在),则继续处理下一个目录项。否则从目
    // 录项总取出页表地址pg_table,并对该页表中的1024个表项进行处理。释放有效页
    // 表项(P位=1)对应的物理内存页表。然后该页表项清零,并继续处理下一页表项。
    // 当一个页表所有表项都处理完毕就释放该页表自身占据的内存页面,并继续处理下
    // 一页目录项。最后刷新也页变换高速缓冲,并返回0.
    for ( ; size-->0 ; dir++) {
        if (!(1 & *dir))
            continue;
        pg_table = (unsigned long *) (0xfffff000 & *dir);  // 取页表地址
        for (nr=0 ; nr<1024 ; nr++) {
            if (1 & *pg_table)                          // 若该项有效,则释放对应页。 
                free_page(0xfffff000 & *pg_table);
            *pg_table = 0;                              // 该页表项内容清零。
            pg_table++;                                 // 指向页表中下一项。
        }
        free_page(0xfffff000 & *dir);                   // 释放该页表所占内存页面。
        *dir = 0;                                       // 对应页表的目录项清零
    }
    invalidate();                                       // 刷新页变换高速缓冲。
    return 0;
}

  和free_page_table对应的是copy_page_table,这个函数是在copy_mem里面被调用的,而copy_mem是在fork里面被调用的,说明父进程生成子进程时拷贝的内存并不是真正的物理地址,而是先拷贝了页目录项和页表项,物理内存暂时共用,等到缺页时才找空闲的物理页分配!

  整个函数的实现连linus都说在内存管理中是最复杂的之一!

/*
 *  Well, here is one of the most complicated functions in mm. It
 * copies a range of linerar addresses by copying only the pages.
 * Let's hope this is bug-free, 'cause this one I don't want to debug :-)
 *
 * Note! We don't copy just any chunks of memory - addresses have to
 * be divisible by 4Mb (one page-directory entry), as this makes the
 * function easier. It's used only by fork anyway.
 *
 * NOTE 2!! When from==0 we are copying kernel space for the first
 * fork(). Then we DONT want to copy a full page-directory entry, as
 * that would lead to some serious memory waste - we just copy the
 * first 160 pages - 640kB. Even that is more than we need, but it
 * doesn't take any more memory - we don't copy-on-write in the low
 * 1 Mb-range, so the pages can be shared with the kernel. Thus the
 * special case for nr=xxxx.
 */
//// 复制页目录表项和页表项:暂时不拷贝具体的页,提高进程fork的效率
// 复制指定线性地址和长度内存对应的页目录项和页表项,从而被复制的页目录和页表对
// 应的原物理内存页面区被两套页表映射而共享使用。复制时,需申请新页面来存放新页
// 表,原物理内存区将被共享。此后两个进程(父进程和其子进程)将共享内存区,直到
// 有一个进程执行写操作时,内核才会为写操作进程分配新的内存页(写时复制机制)。
// 参数from、to是线性地址,size是需要复制(共享)的内存长度,单位是byte.
int copy_page_tables(unsigned long from,unsigned long to,long size)
{
    unsigned long * from_page_table;
    unsigned long * to_page_table;
    unsigned long this_page;
    unsigned long * from_dir, * to_dir;
    unsigned long nr;

    // 首先检测参数给出的原地址from和目的地址to的有效性。原地址和目的地址都需要
    // 在4Mb内存边界地址上。否则出错死机。作这样的要求是因为一个页表的1024项可
    // 管理4Mb内存。源地址from和目的地址to只有满足这个要求才能保证从一个页表的
    // 第一项开始复制页表项,并且新页表的最初所有项都是有效的。然后取得源地址和
    // 目的地址的其实目录项指针(from_dir 和 to_dir).再根据参数给出的长度size计
    // 算要复制的内存块占用的页表数(即目录项数)。
    if ((from&0x3fffff) || (to&0x3fffff))
        panic("copy_page_tables called with wrong alignment");
    from_dir = (unsigned long *) ((from>>20) & 0xffc); /* _pg_dir = 0 */
    to_dir = (unsigned long *) ((to>>20) & 0xffc);
    size = ((unsigned) (size+0x3fffff)) >> 22;
    // 在得到了源起始目录项指针from_dir和目的起始目录项指针to_dir以及需要复制的
    // 页表个数size后,下面开始对每个页目录项依次申请1页内存来保存对应的页表,并
    // 且开始页表项复制操作。如果目的目录指定的页表已经存在(P=1),则出错死机。
    // 如果源目录项无效,即指定的页表不存在(P=1),则继续循环处理下一个页目录项。
    for( ; size-->0 ; from_dir++,to_dir++) {
        if (1 & *to_dir)
            panic("copy_page_tables: already exist");
        if (!(1 & *from_dir))
            continue;
        // 在验证了当前源目录项和目的项正常之后,我们取源目录项中页表地址
        // from_page_table。为了保存目的目录项对应的页表,需要在住内存区中申请1
        // 页空闲内存页。如果取空闲页面函数get_free_page()返回0,则说明没有申请
        // 到空闲内存页面,可能是内存不够。于是返回-1值退出。
        from_page_table = (unsigned long *) (0xfffff000 & *from_dir);
        if (!(to_page_table = (unsigned long *) get_free_page()))
            return -1;    /* Out of memory, see freeing */
        // 否则我们设置目的目录项信息,把最后3位置位,即当前目录的目录项 | 7,
        // 表示对应页表映射的内存页面是用户级的,并且可读写、存在(Usr,R/W,Present).
        // (如果U/S位是0,则R/W就没有作用。如果U/S位是1,而R/W是0,那么运行在用
        // 户层的代码就只能读页面。如果U/S和R/W都置位,则就有读写的权限)。然后
        // 针对当前处理的页目录项对应的页表,设置需要复制的页面项数。如果是在内
        // 核空间,则仅需复制头160页对应的页表项(nr=160),对应于开始640KB物理内存
        // 否则需要复制一个页表中的所有1024个页表项(nr=1024),可映射4MB物理内存。
        *to_dir = ((unsigned long) to_page_table) | 7;
        nr = (from==0)?0xA0:1024;
        // 此时对于当前页表,开始循环复制指定的nr个内存页面表项。先取出源页表的
        // 内容,如果当前源页表没有使用,则不用复制该表项,继续处理下一项。否则
        // 复位表项中R/W标志(位1置0),即让页表对应的内存页面只读。然后将页表项复制
        // 到目录页表中。
        for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {
            this_page = *from_page_table;
            if (!(1 & this_page))
                continue;
            this_page &= ~2;
            *to_page_table = this_page;
            // 如果该页表所指物理页面的地址在1MB以上,则需要设置内存页面映射数
            // 组mem_map[],于是计算页面号,并以它为索引在页面映射数组相应项中
            // 增加引用次数。而对于位于1MB以下的页面,说明是内核页面,因此不需
            // 要对mem_map[]进行设置。因为mem_map[]仅用于管理主内存区中的页面使
            // 用情况。因此对于内核移动到任务0中并且调用fork()创建任务1时(用于
            // 运行init()),由于此时复制的页面还仍然都在内核代码区域,因此以下
            // 判断中的语句不会执行,任务0的页面仍然可以随时读写。只有当调用fork()
            // 的父进程代码处于主内存区(页面位置大于1MB)时才会执行。这种情况需要
            // 在进程调用execve(),并装载执行了新程序代码时才会出现。
            // *from_page_table = this_page; 这句是令源页表项所指内存页也为只读。
            // 因为现在开始有两个进程公用内存区了。若其中1个进程需要进行写操作,
            // 则可以通过页异常写保护处理为执行写操作的进程匹配1页新空闲页面,也
            // 即进行写时复制(copy on write)操作。
            if (this_page > LOW_MEM) {
                *from_page_table = this_page;
                this_page -= LOW_MEM;
                this_page >>= 12;
                mem_map[this_page]++;
            }
        }
    }
    invalidate();
    return 0;
}

  经过上面的线性地址虚拟化操作,给人感觉就是个活生生的“盗梦空间”:线性地址本身是人为虚拟构造出来的,硬件层面读写数据还要先转成物理内存,所以线性地址并不是真正的地址(这不废话么),32bit可以根据业务需求赋予各种不同的含义,只要在页目录表和页表项的对应关系映射好就行了!这里的映射具体怎么操作了?也很简单:就是给数组元素赋值,或者是给指针内容赋值;linux 0.11版本把页表项和物理地址映射的方法如下:

  最核心的“映射”代码其实很简单:page_table[(address>>12) & 0x3ff] = page | 7;

/*
 * This function puts a page in memory at the wanted address.
 * It returns the physical address of the page gotten, 0 if
 * out of memory (either when trying to access page-table or
 * page.)
 */
//// 把一物理内存页面映射到线性地址空间指定处。
// 或者说是把线性地址空间中指定地址address出的页面映射到主内存区页面page上。主
// 要工作是在相关页面目录项和页表项中设置指定页面的信息。若成功则返回物理页面地
// 址。在处理缺页异常的C函数do_no_page()中会调用此函数。对于缺页引起的异常,由于
// 任何缺页缘故而对页表作修改时,并不需要刷新CPU的页变换缓冲(或称Translation Lookaside
// Buffer - TLB),即使页表中标志P被从0修改成1.因为无效叶项不会被缓冲,因此当修改
// 了一个无效的页表项时不需要刷新。在次就表现为不用调用Invalidate()函数。
// 参数page是分配的主内存区中某一页面(页帧,页框)的指针;address是线性地址。
unsigned long put_page(unsigned long page,unsigned long address)
{
    unsigned long tmp, *page_table;

/* NOTE !!! This uses the fact that _pg_dir=0 */

    // 首先判断参数给定物理内存页面page的有效性。如果该页面位置低于LOW_MEM(1MB)
    // 或超出系统实际含有内存高端HIGH_MEMORY,则发出警告。LOW_MEM是主内存区可能
    // 有的最小起始位置。当系统物理内存小于或等于6MB时,主内存区起始于LOW_MEM处。
    // 再查看一下该page页面是否已经申请的页面,即判断其在内存页面映射字节图mem_map[]
    // 中相应字节是否已经置位。若没有则需发出警告。
    if (page < LOW_MEM || page >= HIGH_MEMORY)
        printk("Trying to put page %p at %p\n",page,address);
    if (mem_map[(page-LOW_MEM)>>12] != 1)
        printk("mem_map disagrees with %p at %p\n",page,address);
    // 然后根据参数指定的线性地址address计算其在也目录表中对应的目录项指针,并
    // 从中取得二级页表地址。如果该目录项有效(P=1),即指定的页表在内存中,则从中
    // 取得指定页表地址放到page_table 变量中。否则就申请一空闲页面给页表使用,并
    // 在对应目录项中置相应标志(7 - User、U/S、R/W).然后将该页表地址放到page_table
    // 变量中。
    page_table = (unsigned long *) ((address>>20) & 0xffc);
    if ((*page_table)&1)
        page_table = (unsigned long *) (0xfffff000 & *page_table);
    else {
        if (!(tmp=get_free_page()))
            return 0;
        *page_table = tmp|7;
        page_table = (unsigned long *) tmp;
    }
    // 最后在找到的页表page_table中设置相关页表内容,即把物理页面page的地址填入
    // 表项同时置位3个标志(U/S、W/R、P)。该页表项在页表中索引值等于线性地址位21
    // -- 位12组成的10bit的值。每个页表共可有1024项(0 -- 0x3ff)。
    page_table[(address>>12) & 0x3ff] = page | 7;
/* no need for invalidate */
    return page;
}

  4、(1)为了节约物理内存,不同进程可能会共享同样的物理页面,举个例子:同时打开两个notepad,操作系统会同时生成两个进程,但由于运行的是同样的程序,最起码代码段是可以共享的,所以这两个进程的代码段是可以设置成一样的!linux设置共享物理页的方式如下:

  因为共享的肯定是物理页面,所以要先根据线性地址算出物理地址;
       修改目标进程的页表项,让某个页表项保存物理页起始地址(这种物理页的挂载思路在windows下叫shadow walker,可以用来过PG保护的);

/*
 * try_to_share() checks the page at address "address" in the task "p",
 * to see if it exists, and if it is clean. If so, share it with the current
 * task.
 *
 * NOTE! This assumes we have checked that p != current, and that they
 * share the same executable.
 */
//// 尝试对当前进程指定地址处的页面进行共享处理。
// 当前进程与进程p是同一执行代码,也可以认为当前进程是由p进程执行fork操作产生的
// 进程,因此它们的代码内容一样。如果未对数据段内容做过修改那么数据段内容也应一
// 样。参数address是进程中的逻辑地址,即是当前进程欲与p进程共享页面的逻辑页面地
// 址。进程P是将被共享页面的进程。如果P进程address出的页面存在并且没有被修改过的
// 话,就让当前进程与p进程共享之。同时还需要验证指定地址处是否已经申请了页面,若
// 是则出错,死机。返回:1 - 页面共享处理成功;0 - 失败。
static int try_to_share(unsigned long address, struct task_struct * p)
{
    unsigned long from;
    unsigned long to;
    unsigned long from_page;
    unsigned long to_page;
    unsigned long phys_addr;

    // 首先分别求的指定进程p中和当前进程中逻辑地址address对应的页目录项。为了计
    // 算方便先求出指定逻辑地址address出的'逻辑'页目录项号,即以进程空间(0 - 64 MB)
    // 算出的页目录项号。该'逻辑'页目录项号加上进程p在CPU 4G线性空间中的实际页目
    // 录项from_page。而'逻辑'页目录项号加上当前进程CPU 4G线性空间中起始地址对应
    // 的页目录项,即可最后得到当前进程中地址address处页面所对应的4G线性空间中的
    // 实际页目录项to_page。
    from_page = to_page = ((address>>20) & 0xffc);
    from_page += ((p->start_code>>20) & 0xffc);
    to_page += ((current->start_code>>20) & 0xffc);
    // 在得到p进程和当前进程address对应的目录项后,下面分别对进程p和当前进程进行
    // 处理。下面首先对p进程的表项进行操作。目标是取得p进程中address对应的物理内
    // 存页面地址,并且该物理页面存在,而且干净(没有被修改过)。
    // 方法是先取目录项内容。如果该目录项无效(P=0),表示目录项对应的二级页表不存
    // 在,于是返回。否则取该目录项对应页表地址from,从而计算出逻辑地址address
    // 对应的页表项指针,并取出该页表项内容临时保存在phys_addr中。
/* is there a page-directory at from? */
    from = *(unsigned long *) from_page;
    if (!(from & 1))
        return 0;
    from &= 0xfffff000;
    from_page = from + ((address>>10) & 0xffc);
    phys_addr = *(unsigned long *) from_page;//终于找到物理地址了
/* is the page clean and present? */
    // 接着看看页表项映射的物理页面是否存在并且干净。0x41对应页表项中的D(dirty)
    // 和P(present)标志。如果页面不干净或无效则返回。然后我们从该表项中取出物
    // 理页面地址再保存在phys_addr中。最后我们再检查一下这个物理页面地址的有效性,
    // 即它不应该超过机器最大物理地址值,也不应该小于内存低端(1 MB).
    if ((phys_addr & 0x41) != 0x01)
        return 0;
    phys_addr &= 0xfffff000;
    if (phys_addr >= HIGH_MEMORY || phys_addr < LOW_MEM)
        return 0;
    // 下面首先对当前进程的表项进行操作。目标是取得当前进程中address对应的页表
    // 项地址,并且该页表项还没有映射物理页面,即其P=0。
    // 首先取当前进程页目录项内容->to.如果该目录项无效(P=0),即目录项对应的二级
    // 页表不存在,则申请一空闲页面来存放页表,并更新目录项to_page内容,让其指向
    // 内存页面。
    to = *(unsigned long *) to_page;
    if (!(to & 1)) {
        if ((to = get_free_page()))
            *(unsigned long *) to_page = to | 7;
        else
            oom();
    }
    // 否则取目录项中的页表地址->to,加上页表项索引值<<2,即页表项在表中偏移地址,
    // 得到页表地址->to_page.针对页表项,如果我们此时我们检查出其对应的物理页面
    // 已经存在,即页表的存在位P=1,则说明原本我们想共享进程p中对应的物理页面,
    // 但现在我们自己已经占有了(映射有)物理页面。于是说明内核出错,死机。
    to &= 0xfffff000;
    to_page = to + ((address>>10) & 0xffc);
    if (1 & *(unsigned long *) to_page)
        panic("try_to_share: to_page already exists");
    // 在找到了进程p中逻辑地址address处对应的干净且存在的物理页面,而且也确定了
    // 当前进程中逻辑地址address所对应的耳机页表项地址之后,我们现在对他们进行
    // 共享处理。方法很简单,就是首先对p进程的页表项进行修改,设置其写保护(R/W=0,
    // 只读)标志,然后让当前进程复制p进程的这个页表项。此时当前进程逻辑地址address
    // 处页面即被映射到p进程逻辑地址address处页面映射的物理页面上。
/* share them: write-protect */
    *(unsigned long *) from_page &= ~2;
    *(unsigned long *) to_page = *(unsigned long *) from_page;
    // 随后刷新页变换高速缓冲。计算所操作屋里页面的页面号,并将对应页面映射字节数
    // 组项中的引用递增1。最后返回1,表示共享处理成功。
    invalidate();
    phys_addr -= LOW_MEM;
    phys_addr >>= 12;
    mem_map[phys_addr]++;
    return 1;
}

  (2)当发生缺页时,首先看看有没有运行同样文件的进程;如果有,先共享一下改进程的物理页面,达到节约内存的目的:注意这里面有个count字段,可以用来检测app多开的!由此也引申出了另一个沙箱的概念:虚拟出另一块内存,但是count就是1,避开检测!

//// 共享页面处理。
// 在发生缺页异常时,首先看看能否与运行同一个执行文件的其他进程进行页面共享处理
// 该函数首先判断系统是否有另一个进程也在运行当前进程一样的执行文件。若有,则在
// 系统当前所有任务中寻找这样的任务。若找到了这样的任务就尝试与其共享指定地址处
// 的页面。若系统中没有其他任务正在运行与当前进程相同的执行文件,那么共享页面操
// 作的前提条件不存在,因此函数立刻退出。判断系统中是否有另一个进程也在执行同一
// 个执行文件的方法是零用进程任务数据结构中的executable字段。该字段指向进程正在
// 执行程序在内存中的i节点。根据该i节点的引用次数i_count我们可以进行这种判断。若
// executable->i_count值大于1,则表明系统中可能有两个进程运行同一个执行文件,于
// 是可以再对task struct数组中所有任务比较是否有相同的executable字段来最后确定多个
// 进程运行着相同执行文件的情况。
// 参数address是进程中的逻辑地址,即是当前进程欲与p进程共享页面的逻辑页面地址。
// 返回:1 - 共享操作成功,0 - 失败。
static int share_page(unsigned long address)
{
    struct task_struct ** p;

    // 首先检查一下当前进程的executable字段是否指向某执行文件的i节点,以判断本
    // 进程是否有对应的执行文件。如果没有,则返回0.如果executable的确指向某个i
    // 节点,则检查该i节点引用计数值。如果当前进程运行的执行文件的内存i节点引用
    // 计数等于1(executable->i_count=1),表示当前系统中只有1个进程(即当前进程)在
    // 运行该执行文件。因此无共享可言,直接退出函数。
    if (!current->executable)
        return 0;
    if (current->executable->i_count < 2)
        return 0;
    // 否则搜索任务数组中所有任务。寻找与当前进程可共享页面的进程,即运行相同的
    // 执行文件的另一个进程,并尝试对指定地址的页面进行共享。如果找到某个进程p,
    // 其executable字段值与当前进程的相同,则调用try_to_share()尝试页面共享。若
    // 共享操作成功,则函数返回1。否则返回0,表示共享页面操作失败.
    for (p = &LAST_TASK ; p > &FIRST_TASK ; --p) {
        if (!*p)
            continue;
        if (current == *p)
            continue;
        // 如果executable不等,表示运行的不是与当前进程相同的执行文件,因此也继续
        // 寻找。
        if ((*p)->executable != current->executable)
            continue;
        if (try_to_share(address,*p))
            return 1;
    }
    return 0;
}

  (3)发生缺页后,linux搜先检查能不能和其他进程共享物理页;如果还是不行,那么再重新分配物理页,从磁盘把对应的可执行文件读到物理页(注意:这里是把可执行文件的数据拷贝到内存,和windows下有个隐藏的pagefile.sys文件不一样,后者存放的是临时换出来的内存物理页),最后把物理页挂载到进程的页表项,整个过程就是do_no_page:

//// 执行缺页处理
// 是访问不存在页面处理函数。页异常中断处理过程中调用的函数。在page.s程序中被调
// 用。函数参数error_code和address是进程在访问页面时由CPU因缺页产生异常而自动生
// 成。该函数首先尝试与已加载的相同文件进行页面共享,或者只是由于进程动态申请内
// 存页面而只需映射一页物理内存即可。若共享操作不成功,那么只能从相应文件中读入
// 所缺的数据页面到指定线性地址处。
void do_no_page(unsigned long error_code,unsigned long address)
{
    int nr[4];
    unsigned long tmp;
    unsigned long page;
    int block,i;

    // 首先取线性空间中指定地址address处页面地址。从而可算出指定线性地址在进程
    // 空间相对于进程基地址的偏移长度值tmp,即对应的逻辑地址。
    address &= 0xfffff000;
    tmp = address - current->start_code;
    // 若当进程的executable节点指针空,或者指定地址超出(代码+数据)长度,则申请
    // 一页物理内存,并映射到指定的线性地址处。executable是进程正在运行的执行文
    // 件的i节点结构。由于任务0和任务1的代码在内核中,因此任务0,任务1以及任务1
    // 派生的没有调用过execute()的所有任务的executable都为0.若该值为0,或者参数
    // 指定的线性地址超出代码加数据长度,则表明进程在申请新的内存页面存放堆或栈
    // 中数据。因此直接调用取空闲页面函数get_empty_page()为进程申请一页物理内存
    // 并映射到指定线性地址处。进程任务结构字段start_code是线性地址空间中进程代
    // 码段地址,字段end_data是代码加数据长度。对于Linux0.11内核,它的代码段和
    // 数据段其实基址相同。
    if (!current->executable || tmp >= current->end_data) {
        get_empty_page(address);
        return;
    }
    if (share_page(tmp))
        return;
    if (!(page = get_free_page()))
        oom();
/* remember that 1 block is used for header */
    // 因为块设备上存放的执行文件映象第1块数据是程序头结构,因此在读取该文件时
    // 需要跳过第1块数据。所以需要首先计算缺页所在数据块号。因为每块数据长度为
    // BLOCK_SIZE=1KB,因此一页内存课存放4个数据块。进程逻辑地址tmp除以数据块大
    // 小再加上1即可得出缺少的页面在执行映象文件中的起始块号block。根据这个块号
    // 和执行文件的i节点,我们就可以从映射位图中找到对应块设备中对应的设备逻辑块
    // 号(保存在nr[]数组中)。利用bread_page()即可把这4个逻辑块读入到物理页面page中。
    block = 1 + tmp/BLOCK_SIZE;
    for (i=0 ; i<4 ; block++,i++)
        nr[i] = bmap(current->executable,block);
    bread_page(page,current->executable->i_dev,nr);
    // 在读设备逻辑块操作时,可能会出现这样一种情况,即在执行文件中的读取页面位
    // 置可能离文件尾不到1个页面的长度。因此就可能读入一些无用的信息,下面的操作
    // 就是把这部分超出执行文件end_data以后的部分清零处理。
    i = tmp + 4096 - current->end_data;
    tmp = page + 4096;
    while (i-- > 0) {
        tmp--;
        *(char *)tmp = 0;
    }
    // 最后把引起缺页异常的一页物理页面映射到指定线性地址address处。若操作成功
    // 就返回。否则就释放内存页,显示内存不够。
    if (put_page(page,address))
        return;
    free_page(page);
    oom();
}

   (4)当两个进程共享代码、甚至数据段的物理内存时,如果一个进程改写了物理内存,那么另一个进程是不是也要受影响了?比如同时打开两个notepad,其中一个编辑,另一个不变,是不是另一个也能实时跟新编辑内容了?显然不行(如何行那还的了?)!  其中一个编辑,会更改数据段的内容,此时如果想要另一个进程免受影响,会启动“copy on write”机制,给其中一个进程单独分配物理页,并重新挂载到页目录项,实现代码如下:

/*
 * This routine handles present pages, when users try to write
 * to a shared page. It is done by copying the page to a new address
 * and decrementing the shared-page counter for the old page.
 *
 * If it's in code space we exit with a segment error.
 */
//// 执行写保护页处理。
// 是写共享页面处理函数。是页异常中断处理过程中调用的C函数。在page.s程序中被调用。
// 参数error_code是进程在写写保护页面时由CPU自动产生,address是页面线性地址。
// 写共享页面时,需复制页面(写时复制).
void do_wp_page(unsigned long error_code,unsigned long address)
{
#if 0
/* we cannot do this yet: the estdio library writes to code space */
/* stupid, stupid. I really want the libc.a from GNU */
    if (CODE_SPACE(address))
        do_exit(SIGSEGV);
#endif
    // 调用上面函数un_wp_page()来处理取消页面保护。但首先需要为其准备好参数。参
    // 数是线性地址address指定页面在页表中的页表项指针,其计算方法是:
    // 1.((address>>10) & 0xffc): 计算指定线性地址中页表项在页表中的偏移地址;因
    // 为根据线性地址结构,(address>>12)就是页表项中的索引,但每项占4个字节,因
    // 此乘4后:(address>>12)<<2=(address>>10)&0xffc就可得到页表项在表中的偏移
    // 地址。与操作&0xffc用于限制地址范围在一个页面内。又因为只移动了10位,因此
    // 最后2位是线性地址低12位中的最高2位,也应屏蔽掉。因此求线性地址中页表项在
    // 页表中偏移地址直观一些的表示方法是(((address>>12)&ox3ff)<<2).
    // 2.(0xfffff000 & *((address>>20) &0xffc)):用于取目录项中页表的地址值;其中,
    // ((address>>20) &0xffc)用于取线性地址中的目录索引项在目录表中的偏移地址。
    // 因为address>>22是目录项索引值,但每项4个字节,因此乘以4后:(address>>22)<<2
    // = (address>>20)就是指定在目录表中的偏移地址。&0xffc用于屏蔽目录项索引值中
    // 最后2位。因为只移动了20位,因此最后2位是页表索引的内容,应该屏蔽掉。而
    // *((address>>20) &0xffc)则是取指定目录表项内容中对应页表的物理地址。最后与
    // 上0xfffff000用于屏蔽掉页目录项内容中的一些标志位(目录项低12位)。直观表示为
    // (0xfffff000 & *(unsigned log *) (((address>>22) & 0x3ff)<<2)).
    // 3.由1中页表项中偏移地址加上2中目录表项内容中对应页表的物理地址即可得到页
    // 表项的指针(物理地址)。这里对共享的页面进行复制。
    un_wp_page((unsigned long *)
        (((address>>10) & 0xffc) + (0xfffff000 &
        *((unsigned long *) ((address>>20) &0xffc)))));

}

   上面只调用了一个函数,核心功能就是:(1)去掉页面写保护   (2)从新找个未使用的物理页挂载到页表项(第二级) (3)物理页数据复制

//// 取消写保护页面函数。用于页异常中断过程中写保护异常的处理(写时复制)。
// 在内核创建进程时,新进程与父进程被设置成共享代码和数据内存页面,并且所有这些
// 页面均被设置成只读页面。而当新进程或原进程需要向内存页面写数据时,CPU就会检测
// 到这个情况并产生页面写保护异常。于是在这个函数中内核就会首先判断要写的页面是
// 否被共享。若没有则把页面设置成可写然后退出。若页面是出于共享状态,则需要重新
// 申请一新页面并复制被写页面内容,以供写进程单独使用。共享被取消。本函数供下面
// do_wp_page()调用。
// 输入参数为页表项指针,是物理地址。[up_wp_page -- Un-Write Protect Page]
void un_wp_page(unsigned long * table_entry)
{
    unsigned long old_page,new_page;

    // 首先取参数指定的页表项中物理页面位置(地址)并判断该页面是否是共享页面。如
    // 果原页面地址大于内存低端LOW_MEM(表示在主内存区中),并且其在页面映射字节
    // 图数组中值为1(表示页面仅被引用1次,页面没有被共享),则在该页面的页表项
    // 中置R/W标志(可写),并刷新页变换高速缓冲,然后返回。即如果该内存页面此时只
    // 被一个进程使用,并且不是内核中的进程,就直接把属性改为可写即可,不用再重
    // 新申请一个新页面。
    old_page = 0xfffff000 & *table_entry;
    if (old_page >= LOW_MEM && mem_map[MAP_NR(old_page)]==1) {
        *table_entry |= 2;
        invalidate();
        return;
    }
    // 否则就需要在主内存区申请一页空闲页面给执行写操作的进程单独使用,取消页面
    // 共享。如果原页面大于内存低端(则意味着mem_map[]>1,页面是共享的),则将原页
    // 面的页面映射字节数组递减1。然后将指定页表项内容更新为新页面地址,并置可读
    // 写等标志(U/S、R/W、P)。在刷新页变换高速缓冲之后,最后将原页面内容复制
    // 到新页面上。
    if (!(new_page=get_free_page()))
        oom();
    if (old_page >= LOW_MEM)
        mem_map[MAP_NR(old_page)]--;
    *table_entry = new_page | 7;
    invalidate();
    copy_page(old_page,new_page);
}

  (5)page_fault的编号是0x14,linux的handler是这样的。里面有两个最重要的函数调用:缺页时调用do_no_page;写入只读页时调用do_wp_page;

/*
 * page.s contains the low-level page-exception code.
 * the real work is done in mm.c
 */

.globl page_fault       # 声明为全局变量。将在traps.c中用于设置页异常描述符。

page_fault:
    xchgl %eax,(%esp)       # 取出错码到eax;发生异常后,cpu会把error_code自动入栈,不需要软件设置干预
    pushl %ecx
    pushl %edx
    push %ds
    push %es
    push %fs
    movl $0x10,%edx         # 置内核数据段选择符
    mov %dx,%ds
    mov %dx,%es
    mov %dx,%fs
    movl %cr2,%edx          # 取引起页面异常的线性地址;发生异常后,cpu会把异常的地址放入CR2寄存器,不需要软件设置干预
    pushl %edx              # 将该线性地址和出错码压入栈中,作为将调用函数的参数
    pushl %eax
    testl $1,%eax           # 测试页存在标志P(为0),如果不是缺页引起的异常则跳转
    jne 1f
    call do_no_page         # 调用缺页处理函数
    jmp 2f
1:    call do_wp_page         # 调用写保护处理函数
2:    addl $8,%esp            # 丢弃压入栈的两个参数,弹出栈中寄存器并退出中断。
    pop %fs
    pop %es
    pop %ds
    popl %edx
    popl %ecx
    popl %eax
    iret

其他:

  (1)判断给定线性地址是否位于当前进程的代码段中,这个思路可用于检测自己的so是否被第三方调用了;

// CODE_SPACE(addr)((((addr)+0xfff)&~0xfff)<current->start_code+current->end_code).
// 该宏用于判断给定线性地址是否位于当前进程的代码段中,"(((addr)+4095)&~4095)"
// 用于取得线性地址addr所在内存页面的末端地址。
/*
    ~4095=0xF000,作用是把低12bit清零,只保留高4bit;
    (addr)+4095:相当于页的进位相加,比如addr=2048,那么结果等于6143=0x17ff
    (((addr)+4095)&~4095) = 0x17ff&0xF000=0x1000,也就是说虚拟地址2048所在页的末端地址是0x1000=4096
*/
#define CODE_SPACE(addr) ((((addr)+4095)&~4095) < \
current->start_code + current->end_code)

  (2)史上最快内存数据复制函数:直接用movsl批量复制

// 从from处复制1页内存到to处(4K字节)。
#define copy_page(from,to) \
__asm__("cld ; rep ; movsl"::"S" (from),"D" (to),"c" (1024))

   (3)如果运行的用户程序实在太多,物理内存确实不够用了,需要物理内存的应用程序会被中止,并报OOM的错误(java的码农同学是不是很熟悉了?)

//// 显示内存已用完出错信息,并退出。
static inline volatile void oom(void)
{
    printk("out of memory\n\r");
    // do_exit应该使用退出代码,这里用了信号值SIGSEGV(11)相同值的出错码含义是
    // “资源暂时不可用”,正好同义。
    do_exit(SIGSEGV);
}

  (4)源码中有大量的这种代码,这是用来干啥的了?

*((unsigned long *) ((address>>20) &0xffc))

  我们挨个分解:

address>>22是目录项索引值(地址移位后只剩10bit了,也就是第一级的页目录表)
由于是索引值,需要乘以4得到地址,所以(address>>22)<<2 = address>>20了
由于只移动了20位,还有2位要清零,所以&FFC
最后 *((address>>20) &0xffc) 则是取指定目录表项(第一级)内容中对应页表的物理地址(取出来的物理地址0x1000对齐)
 

参考:

1、https://zhuanlan.zhihu.com/p/67053210  页表描述符

#402 文件系统模块 » linux源码解读(七):文件系统——可执行文件的加载和执行 » 2022-10-08 13:57:22

batsom
回复: 0

1、windows中可执行文件是PE格式的,以exe作为后缀结尾(当然驱动sys和动态链接dll也是PE格式的,但普通用户用不上);用户使用也很方便,直接双击exe文件就能开始运行了;linux也类似,可执行文件是ELF格式的,用户双击也能运行;这么方便的功能在底层是怎么实现的了?先阐述一下大概的流程:

  可执行文件是放磁盘的,既然要执行,用户在双击后肯定要先加载到内存的高速缓存区
       0号和1号进程都是操作系统的内核,其他用户进程都是这两个进程fork出来的,新进程也不例外,这里先调用fork创建新进程
       从高速缓存区读取文件头,里面存了代码段、数据段的起始和size信息,由此设置进程的ldt;
       设置新进程的tss,保存运行时的context;
       重定位操作系统提供的api地址、设置sp;
       分配内存空间存放程序的参数(用户传递的)和环境变量(操作系统内核传递的);
  目前windows下部分杀毒软件能监控用户有没有点击运行危险的程序,大概率是hook了鼠标双击的中断handler(也就是上述的第一步);一旦发现用户尝试打开危险程序就弹窗告警!

  2、(1)正式解读代码前,先做一些铺垫:早期ds和fs两个寄存器都在使用,区别如下:如果代码在用户态运行,ds和fs都指向用户程序的数据段,没任何区别;但是如果代码在内核态运行,ds指向内核的数据段,而fs仍然指向用户态的数据段!不同的态取数据时需要设置不同的fs值!这点很重要,下面的copy_string()函数会频繁设置fs的值!

FluxBB bbcode 测试

   (2)用户输入参数后,总要找个地方存嘛,既然是进程的参数,就放进程的内存空间呗!linux使用了32个页面,一共128KB的内存空间存放用户参数和环境变量,拷贝代码如下(作用和三环下的memcpy是一样的,没本质区别):

/*
 * 'copy_string()' copies argument/envelope strings from user
 * memory to free pages in kernel mem. These are in a format ready
 * to be put directly into the top of new user memory.
 *
 * Modified by TYT, 11/24/91 to add the from_kmem argument, which specifies
 * whether the string and the string array are from user or kernel segments:
 * 
 * from_kmem     argv *(参数指针,也就是参数地址)           argv **(真正的用户输入参数)
 *    0          user space                                   user space
 *    1          kernel space                                 user space
 *    2          kernel space                                 kernel space
 * 
 * We do this by playing games with the fs segment register.  Since it
 * it is expensive to load a segment register, we try to avoid calling
 * set_fs() unless we absolutely have to.
 */
//// 复制指定个数的参数字符串到参数和环境空间中。
// 参数:argc - 欲添加参数个数; argv - 参数指针数组;page - 参数和环境空间页面
// 指针数组。p - 参数表空间中偏移指针,始终指向已复制串的头部;from_kmem - 字符
// 串来源标志。在 do_execve()函数中,p初始化为指向参数表(128kb)空间的最后一个长
// 字处,参数字符串是以堆栈操作方式逆向往其中复制存放的。因此p指针会随着复制信
// 息的增加而逐渐减小,并始终指向参数字符串的头部。字符串来源标志from_kmem应该
// 是TYT为了给execve()增添执行脚本文件的功能而新加的参数。当没有运行脚本文件的
// 功能时,所有参数字符串都在用户数据空间中。
// 返回:参数和环境空间当前头部指针。若出错则返回0.
/*
char *str="hello";
copy_strings(1,&str,page,p,1);
*/
static unsigned long copy_strings(int argc,char ** argv,unsigned long *page,
        unsigned long p, int from_kmem)
{
    char *tmp, *pag=NULL;
    int len, offset = 0;
    unsigned long old_fs, new_fs;

    // 首先取当前段寄存器ds(指向内核数据段)和fs值,分别保存到变量new_fs和
    // old_fs中。如果字符串和字符串数组(指针)来自内核空间,则设置fs段寄存器指向
    // 内核数据段。
    if (!p)
        return 0;    /* bullet-proofing */
    new_fs = get_ds();
    old_fs = get_fs();
    if (from_kmem==2)/*字符串和字符串数组(指针)来自内核空间*/
        set_fs(new_fs);/*参数指针和参数本身都在内核,让fs和ds指向的位置相同,表示当前代码运行的所有数据都用内核态的数据段取*/
    while (argc-- > 0) {/*遍历每个参数*/
        // 首先取需要复制的当前字符串指针。如果字符串在用户空间而字符串数组(字
        // 符串指针)在内核空间,则设置fs段寄存器指向内核数据段(ds).并在内核数
        // 据空间中取了字符串指针tmp之后就立刻回复fs段寄存器原值(fs再指回用户空
        // 间)。否则不用修改fs值而直接从用户空间取字符串指针到tmp.
        if (from_kmem == 1)/*参数指针在内核态,但是参数本身在用户态*/
            set_fs(new_fs);/*因为参数指针在内核态,而下面要用fs访问内核态,所以需要把fs设置程ds*/
        if (!(tmp = (char *)get_fs_long(((unsigned long *)argv)+argc)))/*tmp还是参数指针,并不是参数本身;指向最后一个参数,在argsc--的带动下在遍历每个参数*/
            panic("argc is wrong");
        if (from_kmem == 1)
            set_fs(old_fs);/*因为最终的参数在用户态,而下面要用fs去访问,所以要用之前保存的fs还原*/
        // 然后从用户空间取该字符串,并计算该参数字符串长度len.此后tmp指向该字
        // 符串末端。如果该字段字符串长度超过此时参数和环境空间中还剩余的空闲长
        // 度,则空间不够了。于是回复fs段寄存器值(如果被改变的话)并返回0.不过因
        // 为参数和环境空间留有128KB,所以通常不可能发生这种情况。
        len=0;        /* remember zero-padding */
        do {
            len++; /*求单个参数的字符长度*/
        } while (get_fs_byte(tmp++));
        if (p-len < 0) {    /* this shouldn't happen - 128kB :P:剩余可存参数和环境变量的字符个数*/
            set_fs(old_fs);
            return 0;
        }
        // 接着我们逆向逐个字符地把字符串复制到参数和环境空间末端处。在循环复制
        // 字符串的字符过程中,我们首先要判断参数和环境空间相应位置是否已经有内
        // 存页面。如果还没有就先为其申请1页内存页面。偏移量offset被用作为在一
        // 个页面中的当前指针偏移量。因为刚开始执行本函数时,偏移量offset被初始
        // 化为0,所以(offset-1<0)肯定成立而使得offset重新被设置为当前p指针在页
        // 面返回内的偏移值。
        while (len) {/*遍历参数的每个字符*/
            --p; /*这里偏移P也在变化,P的初始值PAGE_SIZE*MAX_ARG_PAGES-4,在do_execve中定义的*/
            --tmp;--len; 
            if (--offset < 0) {
                offset = p % PAGE_SIZE;
                // 如果字符串和字符串数组都在内核空间中,那么为了从内核数据空间
                // 复制字符串内容,下面会把fs设置为指向内核数据段。
                if (from_kmem==2)
                    set_fs(old_fs);
                // 如果当前偏移量p所在的串空间页面指针数组项page[p/PAGE_SIZE]==
                // 0,表示此时p指针所处的空间内存页面还不存在,则需申请一空闲内
                // 存页,并将该页面指针填入指针数组,同时也使页面指针pag 指向该
                // 新页面,若申请不到空闲页面则返回0.
                if (!(pag = (char *) page[p/PAGE_SIZE]) &&
                    !(pag = (char *) page[p/PAGE_SIZE] =
                      (unsigned long *) get_free_page())) 
                    return 0;
                // 如果字符串在内核空间,则设置fs段寄存器指向内核数据段(ds)。
                if (from_kmem==2)
                    set_fs(new_fs);

            }
            // 然后从fs段中复制字符串的1字节到参数和环境空间内存页面pag的offset出。
            *(pag + offset) = get_fs_byte(tmp);
        }
    }
    // 如果字符串和字符串数组在内核空间,则恢复fs段寄存器原值。最后,返回参数和
    // 环境空间已复制参数的头部偏移值。
    if (from_kmem==2)
        set_fs(old_fs);
    return p;
}

  上面的代码稍微有点繁琐,这里画个图直观展示
FluxBB bbcode 测试

   代码中好些地方涉及到set_fs,为啥要频繁设置fs了?原因如下:argv本身也是个变量,本身也要内存来存放;argv*和argv同理,这两个并不存用户输入的参数,仅仅是指针;argv**指向的地址才是最终存用户输入参数的地方,那么现在问题来了:argv*和argv**可能分别存在内核和用户态,但代码要get_fs函数来读取这两个变量,怎么办了?只能不停的改变fs来反复读取argv*和argv**了!比如from_kmem参数是1,argv*和argv**分别被存放在内核和用户态,此时如果要用get_fs_long读取argv*,需要把fs设置成ds,所以要调用set_fs(new_fs);读取完argv*后如果要继续读取用户态的argv**,就要把已经改成ds的fs还原成以前的fs了,所以要调用set_fs(old_fs);
* from_kmem     argv *(参数指针)                       argv **(真正的用户输入参数)
*    0          user space                                   user space
*    1          kernel space                                 user space
*    2          kernel space                                 kernel space
   (3)参数或环境变量要求用空格隔开,个数是这样计算的:因为argv是二级指针,所以tmp每隔4个byte查找1次。如果指针是空,说明没指向任何参数;

/*
 * count() counts the number of arguments/envelopes
 */
//// 计算参数个数
// 参数:argv - 参数指针数组,最后一个指针项是NULL
// 统计参数指针数组中指针的个数。关于函数参数传递指针的指针的作用,在sched.c中。
static int count(char ** argv)
{
    int i=0;
    char ** tmp;

    if ((tmp = argv))
        while (get_fs_long((unsigned long *) (tmp++)))
            i++;

    return i;
}

   (4)总所周知,文件都是有头部信息的,不同平台对不同文件都规定了不同的头部信息,操作系统就是根据文件的头部信息区分文件类型;早期linux 0.11版本的文件头信息如下: 只有8个字段,非常简单;

struct exec {
  unsigned long a_magic;    /* Use macros N_MAGIC, etc for access;可执行文件的类型 */
  unsigned a_text;        /* length of text, in bytes */
  unsigned a_data;        /* length of data, in bytes */
  unsigned a_bss;        /* length of uninitialized data area for file, in bytes */
  unsigned a_syms;        /* length of symbol table data in file, in bytes */
  unsigned a_entry;        /* start address */
  unsigned a_trsize;        /* length of relocation info for text, in bytes */
  unsigned a_drsize;        /* length of relocation info for data, in bytes */
};

    a_magic字段的取值:

#ifndef N_MAGIC
#define N_MAGIC(exec) ((exec).a_magic)
#endif

#ifndef OMAGIC
/* Code indicating object file or impure executable.  */
#define OMAGIC 0407
/* Code indicating pure executable.  */
#define NMAGIC 0410
/* Code indicating demand-paged executable.  */
#define ZMAGIC 0413
#endif /* not OMAGIC */

    (5)shell脚本中,上个可执行文件运行完后,如果要接着运行下一个脚本,需要更改ldt,linux 的代码如下;注意:ldt中代码段基址和数据段基址是一样的!

//// 修改任务局部描述符表的内容
// 修改局部描述符表LDT中描述符的段基址和段限长,并将参数和环境空间页面放置在数
// 据段末端。
// 参数:text_size - 执行文件头部中a_text字段给出的代码长度值;
// page - 参数和环境空间页面指针数组。
// 返回:数据段限长值(64MB)
static unsigned long change_ldt(unsigned long text_size,unsigned long * page)
{
    unsigned long code_limit,data_limit,code_base,data_base;
    int i;

    // 首先根据执行文件头部代码长度字段a_text值,计算以页面长度为边界的代码段限
    // 长。并设置数据段查高难度为64 MB.然后取当前进程局部描述符表代码段描述符中
    // 代码段基址,代码段基址与数据段基址相同。并使用这些新值重新设置局部表中代
    // 码段和数据段描述符中的基址和段限长。这里请注意,由于被加载的新程序的代码
    // 和数据段基址与原程序相同,因此没有必要再重复去设置他们。
    code_limit = text_size+PAGE_SIZE -1;/*PAGE_SIZE是文件头长度,这里加上代码段长度*/
    code_limit &= 0xFFFFF000;/*代码段低12bit清零,和页对齐*/
    data_limit = 0x4000000;/*数据段长度*/
    code_base = get_base(current->ldt[1]);
    data_base = code_base;/*代码段和数据段的基址都一样*/
    set_base(current->ldt[1],code_base);
    set_limit(current->ldt[1],code_limit);
    set_base(current->ldt[2],data_base);
    set_limit(current->ldt[2],data_limit);
/* make sure fs points to the NEW data segment */
    // fs段寄存器中放入局部表数据段描述符的选择符(0x17)。即默认情况下fs都指向任
    // 务数据段。__asm__("pushl $0x17\n\tpop %%fs"::);
    // 然后将参数和环境空间已存放数据的页面(最多有MAX_ARG_PAGES页,128kb)放到
    // 数据段末端。方法是从进程空间末端逆向一页一页地放。函数put_page()用于吧物
    // 理页面映射到进程逻辑空间中。
    __asm__("pushl $0x17\n\tpop %%fs"::);
    data_base += data_limit;
    for (i=MAX_ARG_PAGES-1 ; i>=0 ; i--) {
        data_base -= PAGE_SIZE;
        if (page[i])
            put_page(page[i],data_base);
    }
    return data_limit;
}

(6)前面做了大量铺垫,本文最重要的函数do_execve终于闪亮登场。整个流程原理上并不复杂:

    先把文件从磁盘读到缓存区,然后检查文件的各种权限
      然后检查文件头a_magic字段,看看是shell还是elf(当然这个时候的文件头和现在的elf格式差异还挺大的,但原理都是一样的);
      如果是shell,拷贝环境变量和参数,并逐行读取shell命令执行
      如果是可执行文件,同样拷贝环境变量和参数,设置ldt;
      根据文件头的a_entry数据设置eip的值
  前面4步都是准备工作,第5步相当于扣动扳机了!

/*
 * 'do_execve()' executes a new program.
 */
//// execve()系统中断调用函数。加载并执行子进程
// 该函数是系统中断调用(int 0x80)功能号__NR_execve调用的函数。函数的参数是进
// 入系统调用处理过程后直接到调用本系统嗲用处理过程和调用本函数之前逐步压入栈中
// 的值。
// eip - 调用系统中断的程序代码指针。
// tmp - 系统中断中在调用_sys_execve时的返回地址,无用;
// filename - 被执行程序文件名指针;
// argv - 命令行参数指针数组的指针;
// envp - 环境变量指针数组的指针。
// 返回:如果调用成功,则不返回;否则设置出错号,并返回-1.
/*
    ./add  1  2  3  
    ./run.sh  helloworld
*/
int do_execve(unsigned long * eip,long tmp,char * filename,
    char ** argv, char ** envp)
{
    struct m_inode * inode;
    struct buffer_head * bh;
    struct exec ex;
    unsigned long page[MAX_ARG_PAGES];/*每个进程都有参数指针数组;注意:每个元素都是参数指针,并不直接存放参数*/
    int i,argc,envc;
    int e_uid, e_gid;
    int retval;
    int sh_bang = 0;                            // 控制是否需要执行的脚本程序
    /*p指向参数和环境空间的最后部;注意:P只是个偏移,不是绝对地址;
    每次调用copy_string后P都会减少,以此确保copy到连续的内存空间
    */ 
    unsigned long p=PAGE_SIZE*MAX_ARG_PAGES-4;  //p=128KB-4

    // 在正式设置执行文件的运行环境之前,让我们先干这些杂事。内核准备了128kb(32
    // 个页面)空间来存放化执行文件的命令行参数和环境字符串。上杭把p初始设置成位
    // 于128KB空间中的当前位置。
    // 另外,参数eip[1]是调用本次系统调用的原用户程序代码段寄存器CS值,其中的段
    // 选择符当然必须是当前任务的代码段选择符0x000f.若不是该值,那么CS只能会是
    // 内核代码段的选择符0x0008.但这是绝对不允许的,因为内核代码是常驻内存而不
    // 能被替换掉的。因此下面根据eip[1]的值确认是否符合正常情况。然后再初始化
    // 128KB的参数和环境串空间,把所有字节清零,并取出执行文件的i节点。再根据函
    // 数参数分别计算出命令行参数和环境字符串的个数argc和envc。另外,执行文件必
    // 须是常规文件。
    if ((0xffff & eip[1]) != 0x000f)/*先判断一下权限够不够*/
        panic("execve called from supervisor mode");
    for (i=0 ; i<MAX_ARG_PAGES ; i++)    /* clear page-table */
        page[i]=0;/*参数指针清零*/
    if (!(inode=namei(filename)))        /* get executables inode:查看文件的元信息 */
        return -ENOENT;
    argc = count(argv);
    envc = count(envp);
    
restart_interp:
    if (!S_ISREG(inode->i_mode)) {    /* must be regular file */
        retval = -EACCES;
        goto exec_error2;
    }
    // 下面检查当前进程是否有权运行指定的执行文件。即根据执行文件i节点中的属性,
    // 看看本进程是否有权执行它。在把执行文件i节点的属性字段值取到i中后,我们首
    // 先查看属性中是否设置了"设置-用户-ID"(set-user_id)标志和“设置-组-ID”(set_group-id)
    // 标志。这两个标志主要是让一般用户能够执行特权用户(如超级用户root)的程序,
    // 例如改变密码的程序passwd等。如果set-user-id标志置位,则后面执行进程的有
    // 效用户ID(euid)就设置成执行文件的用户ID,否则设置成当前进程的euid。如果执
    // 行文件set-group-id被置位的话,则执行进程的有效组ID(egid)就被设置为执行
    // 文件的组ID。否则设置成当前进程的egid。这里暂时把这两个判断出来的值保存在
    // 变量e_uid和e_gid中。
    i = inode->i_mode;                      // 取文件属性字段
    e_uid = (i & S_ISUID) ? inode->i_uid : current->euid;
    e_gid = (i & S_ISGID) ? inode->i_gid : current->egid;
    // 现在根据进程的euid和egid和执行文件的访问属性进行比较。如果执行文件属于运
    // 行进程的用户,则把文件属性值i右移6位,此时最低3位是文件宿主的访问权限标
    // 志。否则的话如果执行文件与当前进程的用户属性同租,则使属性值最低3位是执
    // 行文件组用户的访问权限标志。否则此时属性值最低3位就是其他用户访问该执行
    // 文件的权限。
    // 然后我们根据属性字i的最低3bit值来判断当前进程是否有权限运行这个执行文件。
    // 如果选出的相应用户没有运行该文件的权利(位0是执行权限),并且其他用户也没
    // 有任何权限或者当前进程用户不是超级用户,则表明当前进程没有权利运行这个执
    // 行文件。于是置不可执行出错码,并跳转到exec_error2处去做退出处理。
    if (current->euid == inode->i_uid)
        i >>= 6;
    else if (current->egid == inode->i_gid)
        i >>= 3;
    if (!(i & 1) &&/*是否有执行权限*/
        !((inode->i_mode & 0111) && suser())) {
        retval = -ENOEXEC;
        goto exec_error2;
    }
    // 程序执行到这里,说明当前进程有运行指定执行文件的权限。因此从这里开始我们
    // 需要取出执行文件头部数据并根据其中的信息来分析设置运行环境,或者运行另一
    // 个shell程序来执行脚本程序。首先读取执行文件第1块数据到高速缓冲块中。并复
    // 制缓冲块数据到ex中。如果执行文件开始的两个字节是字符'#!',则说明执行文件
    // 是一个脚本文件。如果想运行脚本文件,我们就需要执行脚本文件的解释程序(例
    // 如shell程序)。 他指明了运行脚本
    // 文件需要的解释程序。运行方法从脚本文件第一行中取出其中的解释程序名及后面
    // 的参数(若有的话),然后将这些参数和脚本文件名放进执行文件(此时是解释程序)
    // 的命令行参数空间中。在这之前我们当然需要先把函数指定的原有命令行参数和环
    // 境字符串放到128KB空间中,而这里建立起来的命令行参数则放到它们前面位置处(
    // 因为是逆向放置)。最后让内核执行脚本文件的解释程序。下面就是在设置好解释
    // 程序的脚本文件名等参数后,取出解释程序的i节点并跳转去执行解释程序。由于
    // 我们需要跳转去执行,因此在下面确认处并处理了脚本文件之后需要设置一个禁止
    // 再次执行下面的脚本处理代码标志sh_bang。在后面的代码中该标志也用来表示我
    // 们已经设置好执行的命令行参数,不用重复设置。
    if (!(bh = bread(inode->i_dev,inode->i_zone[0]))) {/*当前执行程序或shell脚本文件头结构,就是ELF的雏形*/
        retval = -EACCES;
        goto exec_error2;
    }
    ex = *((struct exec *) bh->b_data);    /* read exec-header:读取的文件头数据用结构体“格式化” */
    if ((bh->b_data[0] == '#') && (bh->b_data[1] == '!') && (!sh_bang)) {/*根据a_magic判断文件类型,这里是shell脚本*/
        /*
         * This section does the #! interpretation.
         * Sorta complicated, but hopefully it will work.  -TYT
         */

        char buf[1023], *cp, *interp, *i_name, *i_arg;
        unsigned long old_fs;

        // 从这里开始,我们从脚本文件中提取解释程序名以及其参数,并把解释程序名、
        // 解释程序的参数和脚本文件名组合放入环境参数块中。首先复制脚本文件头1
        // 行字符'#!'后面的字符串到buf中,其中含有脚本解释程序名,也可能包含解
        // 释程序的几个参数。然后对buf中的内容进行处理。删除开始空格、制表符。
        strncpy(buf, bh->b_data+2, 1022);/*跳过#!两个char字符,把后续所有的数据拷到buf*/
        brelse(bh);
        iput(inode);
        buf[1022] = '\0';/*一个block是1024byte,除去文件头的#!,只剩1022byte了;然后以0结尾*/
        if ((cp = strchr(buf, '\n'))) {
            *cp = '\0';
            for (cp = buf; (*cp == ' ') || (*cp == '\t'); cp++);
        }
        if (!cp || *cp == '\0') {
            retval = -ENOEXEC; /* No interpreter name found */
            goto exec_error1;
        }
        // 此时我们得到了开头是脚本解释程序名的一行内容(字符串)。下面分析改行。
        // 首先取第一个字符串,它应该是解释程序名,此时i_name指向该名称。若解释
        // 程序名后还有字符,则它们应该是解释程序的参数串,于是令i_arg指向该串。
        interp = i_name = cp;
        i_arg = 0;
        for ( ; *cp && (*cp != ' ') && (*cp != '\t'); cp++) {
             if (*cp == '/')
                i_name = cp+1;
        }
        if (*cp) {
            *cp++ = '\0';
            i_arg = cp;
        }
        /*
         * OK, we've parsed out the interpreter name and
         * (optional) argument.
         */
        // 现在我们要把上面解析出来的解释程序名i_name及其参数i_arg和脚本文件名作
        // 即使程序的参数放进环境和参数块中。不过首先我们需要把函数提供的原来一
        // 些参数和环境字符串先放进去,然后再放这里解析出来的。例如对于命令行参
        // 数来说,如果原来的参数是"-arg1-arg2"、解释程序名是bash、其参数是"-iarg1
        //  -iarg2"、脚本文件名(即原来的执行文件名)是"example.sh",那么放入这里
        //  的参数之后,新的命令行类似于这样:
        //  "bash -iarg1 -iarg2 example.sh -arg1 -arg2"
        //  这里我们把sh_bang标志置上,然后把函数参数提供的原有参数和环境字符串
        //  放入到空间中。环境字符串和参数个数分别是envc和argc-1个。少复制的一
        //  个原有参数是原来的执行文件名,即这里的脚本文件名。[[??? 这里可以看
        //  出,实际上我们需要去另行处理脚本文件名,即这里完全可以复制argc个参
        //  数,包括原来执行文件名(即现在的脚本文件名)。因为它位于同一个位置上]]
        //  注意!这里指针p随着复制信息增加而逐渐向小地址方向移动,因此这两个复
        //  制串函数执行完后,环境参数串信息块位于程序命令行参数串信息块的上方,
        //  并且p指向程序的第一个参数串。copy_strings()最后一个参数(0)指明参数
        //  字符串在用户空间。
        if (sh_bang++ == 0) {
            /*每拷贝1次,P就减少拷贝的字节数*/
            p = copy_strings(envc, envp, page, p, 0);
            /*下面传入的P是上面的返回值;由于P是偏移,所以下面接着上面往下继续copy*/
            p = copy_strings(--argc, argv+1, page, p, 0);
        }
        /*
         * Splice in (1) the interpreter's name for argv[0]
         *           (2) (optional) argument to interpreter
         *           (3) filename of shell script
         *
         * This is done in reverse order, because of how the
         * user environment and arguments are stored.
         */
        // 接着我们逆向复制脚本文件名、解释程序的参数和解释程序文件名到参数和环
        // 境空间中。若出错,则置出错码,跳转到exec_error1。另外,由于本函数参
        // 数提供的脚本文件名filename在用户空间,而这里赋予copy_string()的脚本
        // 文件名指针在内核空间,因此这个复制字符串函数的最后一个参数(字符串来
        // 源标志)需要被设置成1.若字符串在内核空间,则copy_strings()的最后一个
        // 参数要设置成2。
        p = copy_strings(1, &filename, page, p, 1);
        argc++;
        if (i_arg) {
            p = copy_strings(1, &i_arg, page, p, 2);
            argc++;
        }
        p = copy_strings(1, &i_name, page, p, 2);
        argc++;
        if (!p) {
            retval = -ENOMEM;
            goto exec_error1;
        }
        /*
         * OK, now restart the process with the interpreter's inode.
         */
        // 最后我们取得解释程序的i节点指针,然后跳转到上面去执行解释程序。为了
        // 获得解释程序的i节点,我们需要使用namei()函数,但是该函数所使用的参数
        // (文件名)是从用户数据空间得到的,即从段寄存器fs指向空间中取得。因此调
        // 用namei()函数之前我们需要先临时让fs指向内核数据空间,以让函数能从内
        // 核空间得到解释程序名,并在namei()返回后恢复fs的默认设置。因此这里我
        // 们先临时保存原fs段寄存器(原指向用户数据段)的值,将其设置成指向内核
        // 数据段,然后取解释程序的i节点。之后再恢复fs的原值。并跳转到restart_interp
        // 出重新处理新的执行文件——脚本文件解释程序。
        old_fs = get_fs();
        set_fs(get_ds());
        if (!(inode=namei(interp))) { /* get executables inode */
            set_fs(old_fs);
            retval = -ENOENT;
            goto exec_error1;
        }
        set_fs(old_fs);
        goto restart_interp;
    }
    // 此时缓冲块中的执行文件头结构数据已经复制到了ex中。于是先释放该缓冲块,并
    // 开始对ex中的执行头信息进行判断处理。对于Linux0.11内核来说,它仅支持ZMAGIC
    // 执行文件格式,并且执行文件代码都从逻辑地址0开始执行,因此不支持含有代码
    // 或数据重定位信息的执行文件。当然,如果执行文件实在太大或者执行文件残缺不
    // 全,那么我们也不能运行它。因此对于下列情况将不执行程序:如果执行文件不是
    // 需求页可执行文件(ZMAGIC)、或者代码和数据重定位部分不等于0,或者(代码段
    // + 数据段+堆)长度超过50MB、或者执行文件长度小于(代码段+数据段+符号表长度
    // +执行头部分)长度的总和。
    brelse(bh);
    if (N_MAGIC(ex) != ZMAGIC || ex.a_trsize || ex.a_drsize ||
        ex.a_text+ex.a_data+ex.a_bss>0x3000000 ||/*程序加上栈堆才4M,这么大的量肯定是错的*/
        inode->i_size < ex.a_text+ex.a_data+ex.a_syms+N_TXTOFF(ex)) {
        retval = -ENOEXEC;
        goto exec_error2;
    }
    // 另外,如果执行文件中代码开始处没有位于1个页面(1024字节)边界处,则也不能
    // 执行。因为需求页(Demand paging)技术要求加载执行文件内容时以页面为单位,
    // 因此要求执行文件映象中代码和数据都从页面边界处开始。
    if (N_TXTOFF(ex) != BLOCK_SIZE) {
        printk("%s: N_TXTOFF != BLOCK_SIZE. See a.out.h.", filename);
        retval = -ENOEXEC;
        goto exec_error2;
    }
    // 如果sh_bang标志没有设置,则复制指定个数的命令行参数和环境字符串到参数和
    // 环境空间中。若sh_bang标志已经设置,则表明是将运行脚本解释程序,此时环境
    // 变量页面已经复制,无须再复制。同样,若sh_bang没有置位而需要复制的话,那
    // 么此时指针p随着复制信息增加而逐渐向小地址方向移动,因此这两个复制串函数
    // 执行完后,环境参数串信息块位于程序参数串信息块上方,并且p指向程序的第1个
    // 参数串。事实上,p是128KB参数和环境空间中的偏移值。因此如果p=0,则表示环
    // 境变量与参数空间页面已经被占满,容纳不下了。
    if (!sh_bang) {
        p = copy_strings(envc,envp,page,p,0);
        p = copy_strings(argc,argv,page,p,0);
        if (!p) {
            retval = -ENOMEM;
            goto exec_error2;
        }
    }
/* OK, This is the point of no return */
    // 前面我们针对函数参数提供的信息对需要运行执行文件的命令行参数和环境空间进
    // 行了设置,但还没有为执行文件做过什么实质性的工作,即还没有做过为执行文件
    // 初始化进程任务结构信息、建立页表等工作。现在我们就来做这些工作。由于执行
    // 文件直接使用当前进程的“躯壳”,即当钱进程将被改造成执行文件的进程,因此我
    // 们需要首先释放当前进程占用的某些系统资源,包括关闭指定的已打开文件、占用
    // 的页表和内存页面等。然后根据执行文件头结构信息修改当前进程使用的局部描述
    // 符表LDT中描述符的内容,重新设置代码段和数据段描述符的限长,再利用前面处
    // 理得到的e_uid和e_gid等信息来设置进程任务结构中相关的字段。最后把执行本次
    // 系统调用程序的返回地址eip[]指向执行文件中代码的其实位置处。这样当本系统
    // 调用退出返回后就会去运行新执行文件的代码了。注意,虽然此时新执行文件代码
    // 和数据还没有从文件中加载到内存中,但其参数和环境块已经在copy_strings()中
    // 使用get_free_page()分配了物理内存页来保存数据,并在change_ldt()函数中使
    // 用put_page()放到了进程逻辑空间的末端处。另外,在create_tables()中也会由
    // 于在用户栈上存放参数和环境指针表而引起缺页异常,从而内存管理程序也会就此
    // 为用户栈空间映射物理内存页。
    //
    // 这里我们首先放回进程原执行程序的i节点,并且让进程executable字段指向新执行
    // 文件的i节点。然后复位原进程的所有信号处理句柄。再根据设定的执行时关闭文件
    // 句柄(close_on_exec)位图标志,关闭指定的打开文件,并复位该标志。
    /*
    一个shell脚本可能有N个文件顺序执行;执行完前面的文件后,需要把当前线程的executable换成下一个文件的
    */
    if (current->executable)
        iput(current->executable);
    current->executable = inode;/*指向当前需要执行文件的inode节点*/
    for (i=0 ; i<32 ; i++)
        current->sigaction[i].sa_handler = NULL;/*清空当前进程的信号处理函数*/
    for (i=0 ; i<NR_OPEN ; i++)
        if ((current->close_on_exec>>i)&1)/*原线程执行后该关闭的文件都先关闭了,并清空位图*/
            sys_close(i);
    current->close_on_exec = 0;
    // 然后根据当前进程指定的基地址和限长,释放原来程序的代码段和数据段所对应的
    // 内存页表指定的物理内存页面及页表本身。此时新执行文件并没有占用主内存区任
    // 何页面,因此在处理器真正运行新执行文件代码时就会引起缺页异常中断,此时内
    // 存管理程序执行缺页处理而为新执行文件申请内存页面和设置相关表项,并且把相
    // 关执行文件页面读入内存中。如果“上次任务使用了协处理器”指向的是当前进程,
    // 则将其置空,并复位使用了协处理器的标志。
    free_page_tables(get_base(current->ldt[1]),get_limit(0x0f));
    free_page_tables(get_base(current->ldt[2]),get_limit(0x17));
    if (last_task_used_math == current)
        last_task_used_math = NULL;
    current->used_math = 0;
    // 然后我们根据新执行文件头结构中的代码长度字段a_text的值修改局部表中描述符
    // 基地址和段限长,并将128KB的参数和环境空间页面放置在数据段末端。执行下面
    // 语句之后,p此时更改成以数据段起始处为原点的偏移值,但仍指向参数和环境空
    // 间数据开始处,即已转换成为栈指针值。然后调用内部函数create_tables()在栈中
    // 穿件环境和参数变量指针表,供程序的main()作为参数使用,并返回该栈指针。
    p += change_ldt(ex.a_text,page)-MAX_ARG_PAGES*PAGE_SIZE;/*上一个文件已经执行完,当然要换成下一个文件的ldt*/
    p = (unsigned long) create_tables((char *)p,argc,envc);
    // 接着再修改各字段值为新执行文件的信息。即令进程任务结构代码尾字段end_code
    // 等于执行文件的代码长度a_text;数据尾字段end_data等于执行文件的代码段长度
    // 加数据段长度(a_data+a_text);并令进程堆结尾字段brk=a_text+a_data+a_bss.
    // brk用于指明进程当前数据段(包括未初始化数据部分)末端位置。然后设置进程
    // 栈开始字段为栈指针所在页面,并重新设置进程的有效用户id和有效组id。
    current->brk = ex.a_bss +
        (current->end_data = ex.a_data +
        (current->end_code = ex.a_text));
    current->start_stack = p & 0xfffff000;
    current->euid = e_uid;
    current->egid = e_gid;
    // 如果执行文件代码加数据长度的末端不再页面边界上,则把最后不到1页长度的内
    // 存过空间初始化为零。
    i = ex.a_text+ex.a_data;
    while (i&0xfff)
        put_fs_byte(0,(char *) (i++));
    // 最后将原调用系统中断的程序在堆栈上的代码指针替换为指向新执行程序的入口点,
    // 并将栈指针替换为执行文件的栈指针。此后返回指令将这些栈数据并使得CPU去执
    // 行新执行文件,因此不会返回到原调用系统中断的程序中去了。
    eip[0] = ex.a_entry;        /* eip, magic happens :-):前面做了大量的准备工作,终于在这里改eip了 */
    eip[3] = p;            /* stack pointer */
    return 0;
exec_error2:
    iput(inode);
exec_error1:
    for (i=0 ; i<MAX_ARG_PAGES ; i++)
        free_page(page[i]);
    return(retval);
}

   上述代码很多,为方便理解,这里继续画个内存分布图:大部分代码都在往进程的空间拷贝各种数据!
FluxBB bbcode 测试

  总的来说,进程内存从上到下的分布:参数列表、环境变量、栈、数据段、代码段;

  某大厂有句厂训名言:指哪打哪!意思就是领导要求基层员工干啥,基层员工就干啥,100%服从命令,不能有任何质疑!个人感觉cpu是典型的指哪打哪:

  ds指向的内存地址就是数据段的开始,mov等指令就从ds指定的数据段取数据;至于业务逻辑上是不是对的,cpu硬件是没法判断的,需要软件程序员来确保!
       ss指向的内存地址就是栈段的开始,push、pop等指令就在ss指定的段读写数据;
       cs段指向的内存地址就是代码段的开始,eip就把当前指向内存地址的二进制码读出来当成代码执行;至于取出来的二进制码是不是代码(比如错误把数据当成了代码),执行的逻辑是不是对的,cpu硬件也是没法判断的,同样需要软件程序员来保障!
  理清了上面的逻辑后再去看代码,发现代码虽然多,但是并不难:主要就干了下面几件事:

     权限检查、其他struct的属性字段(文件inode、进程task struct)
     数据来回复制倒腾(我感觉80%都是这类代码)
     设置eip,跳转到目标地址

#403 文件系统模块 » linux源码解读(六):文件系统——虚拟文件系统VFS » 2022-10-08 13:51:45

batsom
回复: 0

linux的设计理念:万物皆文件!换句话说:所有的设备,包括但不限于磁盘、串口、网卡、pipe管道、打印机等统一看成是文件。对于用户来说,所有操作都是通过open、read、write、ioctl、close等接口操作的,确实很方便;但是对于linux,底层明明是不同的硬件设备,这些设备怎么才能统一被上述接口识别和适配了?识别和适配这层接口的功能就是虚拟文件系统,简称VFS,整体架构图如下:

FluxBB bbcode 测试

   1、file_dev.c定义了文件的读写函数;

          (1)为了更好地管理文件,linux同样也定义了file结构体:

struct file {
    unsigned short f_mode;/*FMODE_READ或FMODE_WRITE,标识标识文件是否可读或可写*/
    unsigned short f_flags;/*O_RDONLY/O_NONBLOCK/O_SYNC:O_NONBLOCK 打开文件是否阻塞*/
    unsigned short f_count;/*文件被多少进程引用?*/
    struct m_inode * f_inode;/*文件对应的inode节点,里面存了很多文件的元信息;文件存放的描述:磁盘block->inode->struct file->file_table->file descriptor*/
    off_t f_pos;/*当前文件的读写位置偏移,lseek修改的*/
};

  (2)这个file_read函数已经很接近用户使用的read函数了,仅仅是多了第一个inode参数:

//// 文件读函数 - 根据i节点和文件结构,读取文件中数据。
// 由i节点我们可以知道设备号,由filp结构可以知道文件中当前读写指针位置。buf指定
// 用户空间中缓冲区位置,count是需要读取字节数。返回值是实际读取的字节数,或出错号(小于0).
int file_read(struct m_inode * inode, struct file * filp, char * buf, int count)
{
    int left,chars,nr;
    struct buffer_head * bh;

    // 首先判断参数的有效性。若需要读取的字节数count小于等于0,则返回0.若还需要读
    // 取的字节数不等于0,就循环执行下面操作,直到数据全部读出或遇到问题。在读循环
    // 操作过程中,我们根据i节点和文件表结构信息,并利用bmap()得到包含文件当前读写
    // 位置的数据块在设备上对应的逻辑块号nr。若nr不为0,则从i节点指定的设备上读取该
    // 逻辑块。如果读操作是吧则退出循环。若nr为0,表示指定的数据块不存在,置缓冲块
    // 指针为NULL。(filp->f_pos)/BLOCK_SIZE用于计算出文件当前指针所在的数据块号。
    if ((left=count)<=0)
        return 0;
    while (left) {
        if ((nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE))) {/*把f_pos位置之前数据所在磁盘的block号返回*/
            if (!(bh=bread(inode->i_dev,nr)))/*从对应的磁盘block号读取数据,也就是从磁盘读取(filp->f_pos)/BLOCK_SIZE块的数据到内存的缓存区*/
                break;
        } else
            bh = NULL;
        // 接着我们计算文件读写指针在数据块中的偏移值nr,则在该数据块中我们希望读取的
        // 字节数为(BLOCK_SIZE-nr)。然后和现在还需读取的字节数left做比较。其中小值
        // 即为本次操作需读取的字节数chars。如果(BLOCK_SIZE-nr) > left,则说明该块
        // 是需要读取的最后一块数据。反之还需要读取下一块数据。之后调整读写文件指针。
        // 指针前移此次将读取的字节数chars,剩余字节计数left相应减去chars。
        nr = filp->f_pos % BLOCK_SIZE;/*f_pos在block内的offset*/
        chars = MIN( BLOCK_SIZE-nr , left );/*BLOCK_SIZE-nr:当前块的剩余区域;left:文件要读取的剩余size; 注意:要读取的count不一定等于文件当前大小f_pos*/
        filp->f_pos += chars;
        left -= chars;
        // 若上面从设备上读到了数据,则将p指向缓冲块中开始读取数据的位置,并且复制chars
        // 字节到用户缓冲区buf中。否则往用户缓冲区中填入chars个0值字节。
        if (bh) {
            char * p = nr + bh->b_data;
            while (chars-->0)
                put_fs_byte(*(p++),buf++);/*从内核缓存区copy到用户指定的buf*/
            brelse(bh);
        } else {
            while (chars-->0)
                put_fs_byte(0,buf++);
        }
    }
    // 修改该i节点的访问时间为当前时间。返回读取的字节数,若读取字节数为0,则返回
    // 出错号。CURRENT_TIME是定义在include/linux/sched.h中的宏,用于计算UNIX时间。
    // 即从1970年1月1日0时0分0秒开始,到当前的时间,单位是秒。
    inode->i_atime = CURRENT_TIME;
    return (count-left)?(count-left):-ERROR;
}

   与上面对应的是file_write,把用户指定的数据写入缓存区(注意:此时并未调用sys_sync函数将数据从缓存区写入磁盘):

//// 文件写函数 - 根据i节点和文件结构信息,将用户数据写入文件中。
// 由i节点我们可以知道设备号,而由file结构可以知道文件中当前读写指针位置。buf指定
// 用户态缓冲区的位置,count为需要写入的字节数。返回值是实际写入的字节数,或出错号。
int file_write(struct m_inode * inode, struct file * filp, char * buf, int count)
{
    off_t pos;
    int block,c;
    struct buffer_head * bh;
    char * p;
    int i=0;

/*
 * ok, append may not work when many processes are writing at the same time
 * but so what. That way leads to madness anyway.
 */
    // 首先确定数据写入文件的位置。如果是要向文件后添加数据,则将文件读写指针移到
    // 文件尾部。否则就将在文件当前读写指针处写入。
    if (filp->f_flags & O_APPEND)
        pos = inode->i_size;
    else
        pos = filp->f_pos;
    // 然后在已写入字节数i(刚开始为0)小于指定写入字节数count时,循环执行以下操作。
    // 在循环操作过程中,我们先取文件数据块号(pos/BLOCK_SIZE)在设备上对应的逻辑
    // 块号block。如果对应的逻辑块不存在就创建一块。如果得到的逻辑块号=0,则表示
    // 创建失败,于是退出循环。否则我们根据该逻辑块号读取设备上的相应逻辑块,若出
    // 错也退出循环。
    while (i<count) {
        if (!(block = create_block(inode,pos/BLOCK_SIZE)))
            break;
        if (!(bh=bread(inode->i_dev,block)))
            break;
        // 此时缓冲块指针bh正指向刚读入的文件数据库。现在再求出文件当前读写指针在该
        // 数据块中的偏移值c,并将指针p指向缓冲块中开始写入数据的位置,并置该缓冲块已
        // 修改标志。对于块中当前指针,从开始读写位置到块末共可写入c=(BLOCK_SIZE - c)
        // 个字节。若c大于剩余还需写入的字节数(count - i),则此次只需再写入c = (count - i)
        // 个字节即可。
        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指针前移此次需写入的字节数。如果此时pos位置值超过了文件当前长度,则
        // 修改i节点中文件长度字段,并置i节点已修改标志。然后把此次要写入的字节数c累加到
        // 已写入字节计数值i中,供循环判断使用。接着从用户缓冲区buf中复制c个字节到告诉缓
        // 冲块中p指向的开始位置处。复制完后就释放该缓冲块。
        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++);/*注意:此时数据只是写入了缓存区,并未立即写入磁盘*/
        brelse(bh);
    }
    // 当数据已全部写入文件或者在写操作工程中发生问题时就会退出循环。此时我们更改文件修改
    // 时间为当前时间,并调整文件读写指针。如果此次操作不是在文件尾部添加数据,则把文件
    // 读写指针调整到当前读写位置pos处,并更改文件i节点的修改时间为当前时间。最后返回写入
    // 的字节数,若写入字节数为0,则返回出错号-1.
    inode->i_mtime = CURRENT_TIME;
    if (!(filp->f_flags & O_APPEND)) {
        filp->f_pos = pos;
        inode->i_ctime = CURRENT_TIME;
    }
    return (i?i:-1);
}

   2、和读写文件类似,linux 0.11版本也提供了读写块设备的api,在block_dev.c文件中;代码结构和读写文件没本质区别:

  注意:这里求block号、block内部偏移的代码:int block = *pos >> BLOCK_SIZE_BITS;  int offset = *pos & (BLOCK_SIZE-1);  搞逆向时遇到这类代码,需要第一时间知道代码背后的逻辑意义!

//// 数据块写函数 - 向指定设备从给定偏移出写入制定长度数据。
// 参数:dev - 设备号; pos - 设备文件中偏移量指针;buf - 用户空间中缓冲区地址;
// count - 要传送的字节数
// 返回已写入字节数。若没有写入任何字节或出错,则返回出错号。
// 对于内核来说,写操作是向高速缓冲区中写入数据。什么时候数据最终写入设备是由高
// 速缓冲管理程序决定并处理的。另外,因为块设备是以块为单位进行读写,因此对于写
// 开始位置不处于块起始处时,需要先将开始字节所在的整个块读出,然后将需要写的数
// 据从写开始处填写满该块,再将完整的一块数据写盘(即交由高速缓冲程序去处理)。
int block_write(int dev, long * pos, char * buf, int count)
{
    // 首先由文件中位置pos换算成开始读写盘快的块序号block,并求出需写第1字节在该
    // 块中的偏移位置offset.
    int block = *pos >> BLOCK_SIZE_BITS;        // pos所在文件数据块号,相当于除以1024
    int offset = *pos & (BLOCK_SIZE-1);         // pos在数据块中偏移值,相当于模1024
    int chars;
    int written = 0;
    struct buffer_head * bh;
    register char * p;                          // 局部寄存器变量,被存放在寄存器中

    // 然后针对要写入的字节数count,循环执行以下操作,知道数据全部写入。在循环执行
    // 过程中,先计算在当前处理的数据块中可写入的字节数。如果写入的字节数填不满一块,
    // 那么就只需写count字节。如果正要写1块数据内容,则直接申请1块高速缓冲块,并把
    // 用户数据放入即可。否则就需要读入将被写入部分数据的数据块,并预读下两块数据。
    // 然后将块号递增1,为下次操作做好准备。如果缓冲块操作失败,则返回已写字节数,
    // 如果没有写入任何字节,则返回出错号(负数).
    while (count>0) {
        chars = BLOCK_SIZE - offset;
        if (chars > count)
            chars=count;
        if (chars == BLOCK_SIZE)
            bh = getblk(dev,block);
        else
            bh = breada(dev,block,block+1,block+2,-1);
        block++;
        if (!bh)
            return written?written:-EIO;
        // 接着先把指针p指向读出数据的缓冲块中开始写入数据的位置处。若最后一次循环写入
        // 的数据不足一块,则需从块开始处填写(修改)所需的字节,因此这里需预先设置offset
        // 为零。此后将文件中偏移指针pos前移此次将要写的字节数chars,并累加这些要写的
        // 字节数到统计值written中,再把还需要写的计数值count减去此次要写的字节数chars.
        // 然后我们从用户缓冲区复制chars个字节到p指向的高速缓冲中开始写入的位置处。复制
        // 完后就设置该缓冲区块已修改标志,并释放该缓冲区(也即该缓冲区引用计数递减1)。
        p = offset + bh->b_data;
        offset = 0;
        *pos += chars;
        written += chars;           // 累计写入字节数
        count -= chars;
        while (chars-->0)
            *(p++) = get_fs_byte(buf++);
        bh->b_dirt = 1;
        brelse(bh);
    }
    return written;
}

//// 数据块读函数 - 从指定设备和位置处读入指定长度数据到用户缓冲区中。
// 参数:dev - 设备号;pos - 设备文件中偏移量指针;buf - 用户空间缓冲区地址;
// count - 要传送的字节数。
// 返回已读入字节数。若没有读入任何字节或出错,则返回出错号。
int block_read(int dev, unsigned long * pos, char * buf, int count)
{
    // 首先由文件中位置pos换算成开始读写盘块的块序号block,并求出需读第1个字节在块中
    // 的偏移位置offset.
    int block = *pos >> BLOCK_SIZE_BITS;
    int offset = *pos & (BLOCK_SIZE-1);
    int chars;
    int read = 0;
    struct buffer_head * bh;
    register char * p;

    // 然后针对要读入的字节数count,循环执行以下操作,直到数据全部读入。在循环执行
    // 过程中,先计算在当前处理的数据块中需读入的字节数。如果需要读入的字节数还不满
    // 一块,那么就只需要读count字节。然后调用读块函数breada()读如需要的数据块,并
    // 预读下两块数据,如果读操作出错,则返回已读字节数,如果没有读入任何字节,则
    // 返回出错号。然后将块号递增1.为下次操作做好准备。如果缓冲块操作失败,则返回已
    // 写字节数,如果没有读入任何字节,则返回出错号(负数)。
    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
        // 为零。此后将文件中偏移指针pos前移此次将要读的字节数chars,并且累加这些要读
        // 的字节数到统计值read中。再把还需要读的计数值count减去此次要读的字节数chars。
        // 然后我们从高速缓冲块中p指向的开始读的位置处复制chars个字节到用户缓冲区中,
        // 同时把用户缓冲区指针前移。本次复制完后就释放该缓冲块。
        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;
}

    3、除了常见的磁盘等块设备,还有串口这类的字符型设备,读写接口定义在了char_dev.c文件中了:

extern int tty_read(unsigned minor,char * buf,int count);
extern int tty_write(unsigned minor,char * buf,int count);

// 定义字符设备读写函数指针类型
typedef int (*crw_ptr)(int rw,unsigned minor,char * buf,int count,off_t * pos);

//// 串口终端读写操作函数。
// 参数:rw - 读写命令;minor - 终端子设备号;buf - 缓冲区;count - 读写字节数
// pos - 读写操作当前指针,对于中断操作,该指针无用
// 返回:实际读写的字节数。若失败则返回出错码。
static int rw_ttyx(int rw,unsigned minor,char * buf,int count,off_t * pos)
{
    return ((rw==READ)?tty_read(minor,buf,count):
        tty_write(minor,buf,count));
}

//// 终端读写操作函数。
// 同rw_ttyx,只是增加了对进程是否有控制终端的检测。
static int rw_tty(int rw,unsigned minor,char * buf,int count, off_t * pos)
{
    // 若进程没有控制终端,则返回出错号。否则调用终端读写函数rw_ttyx(),
    // 并返回实际读写字节数。
    if (current->tty<0)
        return -EPERM;
    return rw_ttyx(rw,current->tty,buf,count,pos);
}

// 内存数据读写,早期版本暂时没实现
static int rw_ram(int rw,char * buf, int count, off_t *pos)
{
    return -EIO;
}

// 物理内存数据读写,早期版本暂时没实现
static int rw_mem(int rw,char * buf, int count, off_t * pos)
{
    return -EIO;
}

// 内核虚拟内存数据读写,早期版本暂时没实现
static int rw_kmem(int rw,char * buf, int count, off_t * pos)
{
    return -EIO;
}

//// 端口读写操作函数
// 参数:rw - 读写命令; buf - 缓冲区; cout - 读写字节数; pos - 端口地址。
// 返回:实际读写的字节数。
static int rw_port(int rw,char * buf, int count, off_t * pos)
{
    int i=*pos;

    // 对于所要求读写的字节数,并且端口地址小于64K时,循环执行单个字节的
    // 读写操作。若是读命令,则从端口i中读取一个字节内容并放到用户缓冲区中。
    // 若是写命令,则从用户数据缓冲区中取一字节输出到端口i。
    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;
}

//// 内存读写操作函数
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;
    }
}

  这里的编码方式非常巧妙:先定义一个数组,数组的每个元素都是函数入口;然后在rw_char函数中,根据major(dev)找到对应所需的函数入口,然后通过函数指针的方式调用:

// 字符设备读写函数指针表 file_operations
static crw_ptr crw_table[]={
    NULL,        /* nodev */
    rw_memory,    /* /dev/mem etc */
    NULL,        /* /dev/fd */
    NULL,        /* /dev/hd */
    rw_ttyx,    /* /dev/ttyx */
    rw_tty,        /* /dev/tty */
    NULL,        /* /dev/lp */
    NULL};        /* unnamed pipes */

// 字符设备读写操作函数
// 参数:rw - 读写命令;dev - 设备号;buf - 缓冲区; count - 读写字节数;pos - 读写指针。
// 返回:实际读/写字节数
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);
}

  4、前面说了,不同硬件设备有不同的读写接口,但是在VFS这一层确统一起来了,linux 0.11版本是怎么做的了?在rea_write.c函数中做了封装:

    (1)这里先是导入不同类型的读写函数:

// 字符设备读写函数。
extern int rw_char(int rw,int dev, char * buf, int count, off_t * pos);
// 读管道操作函数。
extern int read_pipe(struct m_inode * inode, char * buf, int count);
// 写管道操作函数
extern int write_pipe(struct m_inode * inode, char * buf, int count);
// 块设备读操作函数
extern int block_read(int dev, off_t * pos, char * buf, int count);
// 块设备写操作函数
extern int block_write(int dev, off_t * pos, char * buf, int count);
// 读文件操作函数
extern int file_read(struct m_inode * inode, struct file * filp,
        char * buf, int count);
// 写文件操作函数
extern int file_write(struct m_inode * inode, struct file * filp,
        char * buf, int count);

  (2)通过sys_read和sys_write彻底封装了上述导入的不同类型设备的读写函数:这两个函数内部都会通过S_ISCHR(inode->i_mode)、S_ISBLK(inode->i_mode)、S_ISDIR(inode->i_mode) 、S_ISREG(inode->i_mode)等方式判断,根据不同的设备类型调用不同的读写函数!

//// 读文件系统调用
// 参数fd是文件句柄,buf是缓冲区,count是预读字节数
int sys_read(unsigned int fd,char * buf,int count)
{
    struct file * file;
    struct m_inode * inode;

    // 函数首先对参数有效性进行判断。如果文件句柄值大于程序最多打开文件数NR_OPEN,
    // 或者需要读取的字节计数值小于0,或者该句柄的文件结构指针为空,则返回出错码并
    // 退出。若需读取的字节数count等于0,则返回0退出。
    if (fd>=NR_OPEN || count<0 || !(file=current->filp[fd]))
        return -EINVAL;
    if (!count)
        return 0;
    // 然后验证存放数据的缓冲区内存限制。并取文件的i节点。用于根据该i节点的属性,分
    // 别调用相应的读操作函数。若是管道文件,并且是读管道文件模式,则进行读管道操作,
    // 若成功则返回读取的字节数,否则返回出错码,退出。如果是字符型文件,则进行读
    // 字符设备操作,并返回读取的字符数。如果是块设备文件,则执行块设备读操作,并
    // 返回读取的字节数。
    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);
    // 如果是目录文件或者是常规文件,则首先验证读取字节数count的有效性并进行调整(若
    // 读去字节数加上文件当前读写指针值大于文件长度,则重新设置读取字节数为文件长度
    // -当前读写指针值,若读取数等于0,则返回0退出),然后执行文件读操作,返回读取的
    // 字节数并退出。
    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;
}

//// 写文件系统调用
// 参数fd是文件句柄,buf是用户缓冲区,count是欲写字节数。
int sys_write(unsigned int fd,char * buf,int count)
{
    struct file * file;
    struct m_inode * inode;

    // 同样地,我们首先判断函数参数的有效性。若果进程文件句柄值大于程序最多打开文件数
    // NR_OPEN,或者需要写入的字节数小于0,或者该句柄的文件结构指针为空,则返回出错码
    // 并退出。如果需读取字节数count等于0,则返回0退出。
    if (fd>=NR_OPEN || count <0 || !(file=current->filp[fd]))
        return -EINVAL;
    if (!count)
        return 0;
    // 然后验证存放数据的缓冲区内存限制。并取文件的i节点。用于根据该i节点属性,分别调
    // 用相应的读操作函数。若是管道文件,并且是写管道文件模式,则进行写管道操作,若成
    // 功则返回写入的字节数,否则返回出错码退出。如果是字符设备文件,则进行写字符设备
    // 操作,返回写入的字符数退出。如果是块设备文件,则进行块设备写操作,并返回写入的
    // 字节数退出。若是常规文件,则执行文件写操作,并返回写入的字节数,退出。
    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);
    // 执行到这里,说明我们无法判断文件的属性。则打印节点文件属性,并返回出错码退出。
    printk("(Write)inode->i_mode=%06o\n\r",inode->i_mode);
    return -EINVAL;
}

   (3)open.c的sys_open函数,返回文件句柄(本质就是file结构体的指针):

//// 打开(或创建)文件系统调用。
// 参数filename是文件名,flag是打开文件标志,它可取值:O_RDONLY(只读)、O_WRONLY
// (只写)或O_RDWR(读写),以及O_EXCL(被创建文件必须不存在)、O_APPEND(在文件
// 尾添加数据)等其他一些标志的组合。如果本调用创建了一个新文件,则mode就用于指
// 定文件的许可属性。这些属性有S_IRWXU(文件宿主具有读、写和执行权限)、S_IRUSR
// (用户具有读文件权限)、S_IRWXG(组成员具有读、写和执行权限)等等。对于新创
// 建的文件,这些属性只应用与将来对文件的访问,创建了只读文件的打开调用也将返回
// 一个可读写的文件句柄。如果调用操作成功,则返回文件句柄(文件描述符),否则返回出错码。
int sys_open(const char * filename,int flag,int mode)
{
    struct m_inode * inode;
    struct file * f;
    int i,fd;

    // 首先对参数进行处理。将用户设置的文件模式和屏蔽码相与,产生许可的文件模式。
    // 为了为打开文件建立一个文件句柄,需要搜索进程结构中文件结构指针数组,以查
    // 找一个空闲项。空闲项的索引号fd即是文件句柄值。若已经没有空闲项,则返回出错码。
    mode &= 0777 & ~current->umask;
    for(fd=0 ; fd<NR_OPEN ; fd++)
        if (!current->filp[fd])
            break;
    if (fd>=NR_OPEN)
        return -EINVAL;
    // 然后我们设置当前进程的执行时关闭文件句柄(close_on_exec)位图,复位对应的
    // bit位。close_on_exec是一个进程所有文件句柄的bit标志。每个bit位代表一个打
    // 开着的文件描述符,用于确定在调用系统调用execve()时需要关闭的文件句柄。当
    // 程序使用fork()函数创建了一个子进程时,通常会在该子进程中调用execve()函数
    // 加载执行另一个新程序。此时子进程中开始执行新程序。若一个文件句柄在close_on_exec
    // 中的对应bit位被置位,那么在执行execve()时应对应文件句柄将被关闭,否则该
    // 文件句柄将始终处于打开状态。当打开一个文件时,默认情况下文件句柄在子进程
    // 中也处于打开状态。因此这里要复位对应bit位。
    current->close_on_exec &= ~(1<<fd);
    // 然后为打开文件在文件表中寻找一个空闲结构项。我们令f指向文件表数组开始处。
    // 搜索空闲文件结构项(引用计数为0的项),若已经没有空闲文件表结构项,则返回
    // 出错码。
    f=0+file_table;
    for (i=0 ; i<NR_FILE ; i++,f++)
        if (!f->f_count) break;
    if (i>=NR_FILE)
        return -EINVAL;
    // 此时我们让进程对应文件句柄fd的文件结构指针指向搜索到的文件结构,并令文件
    // 引用计数递增1。然后调用函数open_namei()执行打开操作,若返回值小于0,则说
    // 明出错,于是释放刚申请到的文件结构,返回出错码i。若文件打开操作成功,则
    // inode是已打开文件的i节点指针。
    (current->filp[fd]=f)->f_count++;
    if ((i=open_namei(filename,flag,mode,&inode))<0) {
        current->filp[fd]=NULL;
        f->f_count=0;
        return i;
    }
    // 根据已打开文件的i节点的属性字段,我们可以知道文件的具体类型。对于不同类
    // 型的文件,我们需要操作一些特别的处理。如果打开的是字符设备文件,那么对于
    // 主设备号是4的字符文件(例如/dev/tty0),如果当前进程是组首领并且当前进程的
    // tty字段小于0(没有终端),则设置当前进程的tty号为该i节点的子设备号,并设置
    // 当前进程tty对应的tty表项的父进程组号等于当前进程的进程组号。表示为该进程
    // 组(会话期)分配控制终端。对于主设备号是5的字符文件(/dev/tty),若当前进
    // 程没有tty,则说明出错,于是放回i节点和申请到的文件结构,返回出错码(无许可)。
/* ttys are somewhat special (ttyxx major==4, tty major==5) */
    if (S_ISCHR(inode->i_mode)) {
        if (MAJOR(inode->i_zone[0])==4) {
            if (current->leader && current->tty<0) {
                current->tty = MINOR(inode->i_zone[0]);
                tty_table[current->tty].pgrp = current->pgrp;
            }
        } else if (MAJOR(inode->i_zone[0])==5)
            if (current->tty<0) {
                iput(inode);
                current->filp[fd]=NULL;
                f->f_count=0;
                return -EPERM;
            }
    }
/* Likewise with block-devices: check for floppy_change */
    // 如果打开的是块设备文件,则检查盘片是否更换过。若更换过则需要让高速缓冲区
    // 中该设备的所有缓冲块失败。
    if (S_ISBLK(inode->i_mode))
        check_disk_change(inode->i_zone[0]);
    // 现在我们初始化打开文件的文件结构。设置文件结构属性和标志,置句柄引用计数
    // 为1,并设置i节点字段为打开文件的i节点,初始化文件读写指针为0.最后返回文
    // 件句柄号。
    f->f_mode = inode->i_mode;
    f->f_flags = flag;
    f->f_count = 1;
    f->f_inode = inode;
    f->f_pos = 0;
    return (fd);
}

  为了方便直观理解,图示如下:
FluxBB bbcode 测试

为什么linux系统的理念是“万物皆文件”了?我们现在使用的这套软硬件系统最原始的名称叫information technology,核心目的是存储和读取数据!IT高速发展到现在,最核心的目的还是存储和读取数据,这一点几十年都没变!为了方便上层app读写数据,linux抽象出了VFS:上层app所有的操作都统一了接口名称,不同的设备实现这些接口就行了!对于app来说,只认接口就足够了!

   

参考:

1、https://zhuanlan.zhihu.com/p/66597013   详解 Linux 中的虚拟文件系统

2、https://www.bilibili.com/video/BV1tQ4y1d7mo?p=27 linux内核精讲

#404 文件系统模块 » linux源码解读(五):文件系统——文件和目录的操作 » 2022-10-08 13:42:01

batsom
回复: 0

对于普通用户,平时使用操作系统是肯定涉及到创建、更改、删除文件(比如mkdir、rmdir、rm、chmod、ln等);有些文件是高权限用户建的,低权限用户甚至都打不开,也删不掉;为了方便管理不同业务类型的文件,还需要在不同的逻辑分区建文件夹,分门别类各种文件;linux下用ls -l命令还可以查看文件的详细属性,这一系列的功能构师怎么实现的了?功能都在fs/namei.c文件中

  1、(1)权限检查,核心就是依靠inode结构体中的i_mode成员变量了!这个变量是unsigned short类型,一共2byte=16bit长;linux用低9位表示当前用户权限、用户组权限、其他用户权限,用户平时用ls -l查到的权限就是靠这个字段得到的!举个例子:rwx------表示当前用户有读写执行权限,用户组没有任何权限,其他用户也没有任何权限,所有权限表示刚好使用9bit;

  i_mode节点右移3位,与上0007后得到用户组权限
       i_mode节点右移6位,与上0007后得到当前用户权限
       chmod改的就是i_mode这个字段

/*
 *    permission()
 *
 * is used to check for read/write/execute permissions on a file.
 * I don't know if we should look at just the euid or both euid and
 * uid, but that should be easily changed.
 */
//// 检测文件访问权限
// 参数:inode - 文件的i节点指针;mask - 访问属性屏蔽码。
// 返回:访问许可返回1,否则返回0.
static int permission(struct m_inode * inode,int mask)
{
    int mode = inode->i_mode;

/* special case: not even root can read/write a deleted file */
    // 如果i节点有对应的设备,但该i节点的连接计数值等于0,表示该文件
    // 已被删除,则返回。否则,如果进程的有效用户ID(euid)与i节点的
    // 用户id相同,则取文件宿主的访问权限。否则如果与组id相同,
    // 则取组用户的访问权限。
    if (inode->i_dev && !inode->i_nlinks)
        return 0;
    else if (current->euid==inode->i_uid)
        mode >>= 6;
    else if (current->egid==inode->i_gid)
        mode >>= 3;
    /* &0007:取最后3位
       &mask:取传入参数的位
    */
    if (((mode & mask & 0007) == mask) 
            || suser())/*要么是管理员,是超级用户*/
        return 1;
    return 0;
}

   (2)因为是涉及到设备名、文件名、目录路径的比对,自然少不了字符串相关的操作。平时在3环做应用开发,码农都习惯于使用操作系统提供的库函数,比如strcmp、strcat等,但是现在还在内核,哪来的库函数直接调用了,只能自己动手重新写字符串的比较函数,如下:

*
 * ok, we cannot use strncmp, as the name is not in our data space.
 * Thus we'll have to use match. No big problem. Match also makes
 * some sanity tests.
 *
 * NOTE! unlike strncmp, match returns 1 for success, 0 for failure.
 */
//// 指定长度字符串比较函数
// 参数:len - 比较的字符串长度;name - 文件名指针;de - 目录项结构
// 返回:相同返回1,不同返回0. 
// 下面函数中的寄存器变了same被保存在eax寄存器中,以便高效访问。
static int match(int len,const char * name,struct dir_entry * de)
{
    register int same ;

    // 首先判断函数参数的有效性。如果目录项指针空,或者目录项i节点等于0,或者
    // 要比较的字符串长度超过文件名长度,则返回0.如果要比较的长度len小于NAME_LEN,
    // 但是目录项中文件名长度超过len,也返回0.
    if (!de || !de->inode || len > NAME_LEN)
        return 0;
    if (len < NAME_LEN && de->name[len])
        return 0;
    // 然后使用嵌套汇编语句进行快速比较操作。他会在用户数据空间(fs段)执行字符串的比较
    // 操作。%0 - eax(比较结果same);%1 - eax (eax初值0);%2 - esi(名字指针);
    // %3 - edi(目录项名指针);%4 - ecx(比较的字节长度值len).
    __asm__("cld\n\t"
        "fs ; repe ; cmpsb\n\t"
        "setz %%al"
        :"=a" (same)
        :"0" (0),"S" ((long) name),"D" ((long) de->name),"c" (len)
        );
    return same;
}

  (3)还有在某个目录下查找名为xxx的文件,比如:"find /home -name test"命令,就是在home目录下查找名为test的文件,实现如下:

  注意:函数的参数有两个双重指针,第二个双重指针明显是用来保存返回值的!

/*
 *    find_entry()
 *
 * finds an entry in the specified directory with the wanted name. It
 * returns the cache buffer in which the entry was found, and the entry
 * itself (as a parameter - res_dir). It does NOT read the inode of the
 * entry - you'll have to do that yourself if you want to.
 *
 * This also takes care of the few special cases due to '..'-traversal
 * over a pseudo-root and a mount point.
 */
//// 查找指定目录和文件名的目录项。 find -name "xxx" /xxx/xxx
// 参数:*dir - 指定目录i节点的指针;name - 文件名;namelen - 文件名长度;
// 该函数在指定目录的数据(文件)中搜索指定文件名的目录项。并对指定文件名
// 是'..'的情况根据当前进行的相关设置进行特殊处理。关于函数参数传递指针的指针
// 作用,请参见seched.c中的注释。
// 返回:成功则函数高速缓冲区指针,并在*res_dir处返回的目录项结构指针。失败则
// 返回空指针NULL。
static struct buffer_head * find_entry(struct m_inode ** dir,
    const char * name, int namelen, struct dir_entry ** res_dir)
{
    int entries;
    int block,i;
    struct buffer_head * bh;
    struct dir_entry * de;
    struct super_block * sb;

    // 同样,本函数一上来也需要对函数参数的有效性进行判断和验证。如果我们在前面
    // 定义了符号常数NO_TRUNCATE,那么如果文件名长度超过最大长度NAME_LEN,则不予
    // 处理。如果没有定义过NO_TRUNCATE,那么在文件名长度超过最大长度NAME_LEN时截短之。
#ifdef NO_TRUNCATE
    if (namelen > NAME_LEN)
        return NULL;
#else
    if (namelen > NAME_LEN)
        namelen = NAME_LEN;
#endif
    // 首先计算本目录中目录项项数entries(也即是当前目录中能存放的最大目录个数)。目录i节点i_size字段中含有本目录包含的数据
    // 长度,因此其除以一个目录项的长度(16字节)即课得到该目录中目录项数。然后置空 
    // 返回目录项结构指针。如果长度等于0,则返回NULL,退出。
    entries = (*dir)->i_size / (sizeof (struct dir_entry));
    *res_dir = NULL;
    if (!namelen)
        return NULL;
    // 接下来我们对目录项文件名是'..'的情况进行特殊处理。如果当前进程指定的根i节点就是
    // 函数参数指定的目录,则说明对于本进程来说,这个目录就是它伪根目录,即进程只能访问
    // 该目录中的项而不能后退到其父目录中去。也即对于该进程本目录就如同是文件系统的根目录,
    // 因此我们需要将文件名修改为‘.’。
    // 否则,如果该目录的i节点号等于ROOT_INO(1号)的话,说明确实是文件系统的根i节点。
    // 则取文件系统的超级块。如果被安装到的i节点存在,则先放回原i节点,然后对被安装到
    // 的i节点进行处理。于是我们让*dir指向该被安装到的i节点;并且该i节点的引用数加1.
    // 即针对这种情况,我们悄悄的进行了“偷梁换柱”工程。:-)
/* check for '..', as we might have to do some "magic" for it */
    if (namelen==2 && get_fs_byte(name)=='.' && get_fs_byte(name+1)=='.') {
/* '..' in a pseudo-root results in a faked '.' (just change namelen) */
        if ((*dir) == current->root)
            namelen=1;
        else if ((*dir)->i_num == ROOT_INO) {
/* '..' over a mount-point results in 'dir' being exchanged for the mounted
   directory-inode. NOTE! We set mounted, so that we can iput the new dir */
            sb=get_super((*dir)->i_dev);
            if (sb->s_imount) {
                iput(*dir);
                (*dir)=sb->s_imount;
                (*dir)->i_count++;
            }
        }
    }
    // 现在我们开始正常操作,查找指定文件名的目录项在什么地方。因此我们需要读取目录的
    // 数据,即取出目录i节点对应块设备数据区中的数据块(逻辑块)信息。这些逻辑块的块号
    // 保存在i节点结构的i_zone[9]数组中。我们先取其中第一个块号。如果目录i节点指向的
    // 第一个直接磁盘块好为0,则说明该目录竟然不含数据,这不正常。于是返回NULL退出,
    // 否则我们就从节点所在设备读取指定的目录项数据块。当然,如果不成功,则也返回NULL 退出。
    if (!(block = (*dir)->i_zone[0]))
        return NULL;
    if (!(bh = bread((*dir)->i_dev,block)))
        return NULL;
    // 此时我们就在这个读取的目录i节点数据块中搜索匹配指定文件名的目录项。首先让de指向
    // 缓冲块中的数据块部分。并在不超过目录中目录项数的条件下,循环执行搜索。其中i是目录中
    // 的目录项索引号。在循环开始时初始化为0.
    i = 0;
    de = (struct dir_entry *) bh->b_data;
    while (i < entries) {
        // 如果当前目录项数据块已经搜索完,还没有找到匹配的目录项,则释放当前目录项数据块。
        // 再读入目录的下一个逻辑块。若这块为空。则只要还没有搜索完目录中的所有目录项,就
        // 跳过该块,继续读目录的下一逻辑块。若该块不空,就让de指向该数据块,然后在其中继续
        // 搜索。其中DIR_ENTRIES_PER_BLOCK可得到当前搜索的目录项所在目录文件中的块号,而bmap()
        // 函数则课计算出在设备上对应的逻辑块号.
        if ((char *)de >= BLOCK_SIZE+bh->b_data) {
            brelse(bh);
            bh = NULL;
            if (!(block = bmap(*dir,i/DIR_ENTRIES_PER_BLOCK)) ||
                !(bh = bread((*dir)->i_dev,block))) {
                i += DIR_ENTRIES_PER_BLOCK;
                continue;
            }
            de = (struct dir_entry *) bh->b_data;
        }
        // 如果找到匹配的目录项的话,则返回该目录项结构指针de和该目录项i节点指针*dir以及该目录项
        // 数据块指针bh,并退出函数。否则继续在目录项数据块中比较下一个目录项。
        if (match(namelen,name,de)) {
            *res_dir = de;
            return bh;
        }
        de++;
        i++;
    }
    // 如果指定目录中的所有目录项都搜索完后,还没有找到相应的目录项,则释放目录的数据块,
    // 最后返回NULL(失败)。
    brelse(bh);
    return NULL;
}

   这个函数的开头就出现了一个新的结构体dir_entry,是这样定义的:结构体很简单,只有2个字段,分别是当前文件或目录中包含的inode个数,以及自己的名字;最后一个参数也是用这个结构体保存找到的文件名称和inode节点号数,通过inode节点号数从inode位图查看该inode是否被使用,也可以查找到该文件的inode节点在磁盘的block位置,进而找到文件元信息;

struct dir_entry {
    unsigned short inode;
    char name[NAME_LEN];
};

  (4)既然能够查找文件,也就能新建文件或目录,linux的实现方式如下:

/*
 *    add_entry()
 *
 * adds a file entry to the specified directory, using the same
 * semantics as find_entry(). It returns NULL if it failed.
 *
 * NOTE!! The inode part of 'de' is left at 0 - which means you
 * may not sleep between calling this and putting something into
 * the entry, as someone else might have used it while you slept.
 */
//// 根据指定的目录和文件名添加目录项
// 参数:dir - 指定目录的i节点;name - 文件名;namelen - 文件名长度;
// 返回:高速缓冲区指针;res_dir - 返回的目录项结构指针。
static struct buffer_head * add_entry(struct m_inode * dir,
    const char * name, int namelen, struct dir_entry ** res_dir)
{
    int block,i;
    struct buffer_head * bh;
    struct dir_entry * de;

    // 同样,本函数一上来也需要对函数参数的有效性进行判断和验证。
    // 如果我们在前面定义了符号常数NO_TRUNCATE,那么如果文件名长
    // 度超过最大长度NAME_LEN,则不予处理。如果没有定义过NO_TRUNCATE,
    // 那么在文件名长度超过最大长度NAME_LEN时截短之。
    *res_dir = NULL;
#ifdef NO_TRUNCATE
    if (namelen > NAME_LEN)
        return NULL;
#else
    if (namelen > NAME_LEN)
        namelen = NAME_LEN;
#endif
    // 现在我们开始操作,向指定目录中添加一个指定文件名的目录项。因此
    // 我们需要先读取目录的数据,即取出目录i节点对应块数据区中的数据块
    // 信息。这些逻辑块的块号保存在i节点结构i_zone[9]数组中。我们先取
    // 其中第1个块号,如果目录i节点指向的第一个直接磁盘块号为0,则说明
    // 该目录竟然不含数据,这不正常。于是返回NULL退出。否则我们就从节点
    // 所在设备读取指定目录项数据块。当然,如果不成功,则也返回NULL退出。
    // 如果参数提供的文件名长度等于0,则也返回NULL退出。
    if (!namelen)
        return NULL;
    if (!(block = dir->i_zone[0]))/*目录文件存储的第一个磁盘逻辑块号,肯定不会是0(0是引导块)*/
        return NULL;
    //目录数据必须存磁盘,不能只存内存,否则关机后就全丢了
    if (!(bh = bread(dir->i_dev,block)))/*读取目录文件第一个逻辑块的数据到缓存区,里面存放的都是dir_entry,所以下面把b_data强转成dir_entry*/
        return NULL;
    // 此时我们就在这个目录i节点数据块中循环查找最后未使用的空目录项。
    // 首先让目录项结构指针de指向缓冲块中的数据块部分,即第一个目录项处。
    // 其中i是目录中的目录项索引号,在循环开始时初始化为0.
    i = 0;
    de = (struct dir_entry *) bh->b_data;
    while (1) {
        // 如果当前目录项数据块已经搜索完毕,但还没有找到需要的空目录项,
        // 则释放当前目录项数据块,再读入目录的下一个逻辑块。如果对应的逻辑块。
        // 如果对应的逻辑块不存在就创建一块。如果读取或创建操作失败则返回空。
        // 如果此次读取的磁盘逻辑块数据返回的缓冲块数据为空,说明这块逻辑块
        // 可能是因为不存在而新创建的空块,则把目录项索引值加上一块逻辑块所
        // 能容纳的目录项数DIR_ENTRIES_PER_BLOCK,用以跳过该块并继续搜索。
        // 否则说明新读入的块上有目录项数据,于是让目录项结构指针de指向该块
        // 的缓冲块数据部分,然后在其中继续搜索。其中i/DIR_ENTRIES_PER_BLOCK可
        // 计算得到当前搜索的目录项i所在目录文件中的块号,而create_block函数则可
        // 读取或创建出在设备上对应的逻辑块。
        if ((char *)de >= BLOCK_SIZE+bh->b_data) {
            brelse(bh);
            bh = NULL;
            block = create_block(dir,i/DIR_ENTRIES_PER_BLOCK);
            if (!block)
                return NULL;
            if (!(bh = bread(dir->i_dev,block))) {
                i += DIR_ENTRIES_PER_BLOCK;
                continue;
            }
            de = (struct dir_entry *) bh->b_data;
        }
        // 如果当前所操作的目录项序号i乘上目录结构大小所在长度值已经超过了该目录
        // i节点信息所指出的目录数据长度值i_size,则说明整个目录文件数据中没有
        // 由于删除文件留下的空目录项,因此我们只能把需要添加的新目录项附加到
        // 目录文件数据的末端处。于是对该处目录项进行设置(置该目录项的i节点指针
        // 为空),并更新该目录文件的长度值(加上一个目录项的长度),然后设置目录
        // 的i节点已修改标志,再更新该目录的改变时间为当前时间。
        if (i*sizeof(struct dir_entry) >= dir->i_size) {
            de->inode=0;
            dir->i_size = (i+1)*sizeof(struct dir_entry);
            dir->i_dirt = 1;
            dir->i_ctime = CURRENT_TIME;
        }
        // 若当前搜索的目录项de的i节点为空,则表示找到一个还未使用的空闲目录项
        // 或是添加的新目录项。于是更新目录的修改时间为当前时间,并从用户数据区
        // 复制文件名到该目录项的文件名字段,置含有本目录项的相应高速缓冲块已修改
        // 标志。返回该目录项的指针以及该高速缓冲块的指针,退出。
        if (!de->inode) {
            dir->i_mtime = CURRENT_TIME;
            for (i=0; i < NAME_LEN ; i++)
                de->name[i]=(i<namelen)?get_fs_byte(name+i):0;
            bh->b_dirt = 1;
            *res_dir = de;
            return bh;
        }
        de++;
        i++;
    }
    // 本函数执行不到这里。这也许是Linus在写这段代码时,先复制了上面的find_entry()
    // 函数的代码,而后修改成本函数的。:-)
    brelse(bh);
    return NULL;
}

  (5)截至目前,linux文件系统涉及到好多的结构体、缓存区、磁盘(主要是inode、buffer_head、dir_entry、block等),不熟悉的初学者看到这里估计都开始晕菜了,这里有个现成的图示(参考1),展示了各个结构体的关系:
FluxBB bbcode 测试

   整个磁盘文件数据读取流程如下:

  先根据文件路径找到文件对应的inode节点。假设是个绝对路径,文件路径是/a/b/c.txt;系统初始化的时候我们已经拿到了根目录对应的inode(磁盘上第一个inode节点就是根目录所在的节点,从这里也可以看出,文件目录也必须保存在磁盘,而不仅仅是保存在内存,避免断电后丢失),把根目录文件的block内容读进来,是一系列的dir_entry结构体。然后逐个遍历,比较文件名是不是等于a,最后得到一个目录a对应的dir_entry;
       dir_entry结构体不仅保存了文件名,还保存了对应的inode号;根据inode号把a目录文件的内容也读取进来;以此类推,得到c对应的dir_entry
       再根据c对应的dir_entry的inode号,从磁盘把inode的内容读进来,发现就是个普通文件;至此,找到了这个文件对应的inode节点,完成fd->file结构体->inode结构体的赋值
       最后根据fd找到对应的inode节点,根据file结构体的pos字段;根据数据在文件中的偏移,可以算出应该取i_zone[9]字段的哪个索引,文件的前7块对应索引0-6,前7到7+512对应索引7等。得到索引后,读取i_zone数组在该索引的值,即我们要读取的数据在硬盘的数据块。然后把这个数据块从硬盘读取进来。返回给用户
        整个流程总结:磁盘inode首节点->dir_entry->根据pathname查找目录或文件inode编号->从磁盘读取inode内容->分析i_zone得到文件内容的block编号->从磁盘读数据;整个思路流程和内存管理的CR3分页检索没有任何本质区别;
      (6)linux常用的命令还有“cat  /home/test.c”、“cd /home/jdk/java” 等目录相关的操作。通过前面的分析得知,操作文件或目录,本质就是读写其元信息,这些都存放在inode里面,所以想想办法得到inode,代码如下:

/*
 *    get_dir()
 *
 * Getdir traverses the pathname until it hits the topmost directory.
 * It returns NULL on failure.
 */
//// 搜寻指定路径的目录(或文件名)的i节点。
// 参数:pathname - 路径名
// 返回:目录或文件的i节点指针。
static struct m_inode * get_dir(const char * pathname)
{
    char c;
    const char * thisname;
    struct m_inode * inode;
    struct buffer_head * bh;
    int namelen,inr,idev;
    struct dir_entry * de;

    // 搜索操作会从当前任务结构中设置的根(或伪根)i节点或当前工作目录i节点
    // 开始,因此首先需要判断进程的根i节点指针和当前工作目录i节点指针是否有效。
    // 如果当前进程没有设定根i节点,或者该进程根i节点指向是一个空闲i节点(引用为0),
    // 则系统出错停机。如果进程的当前工作目录i节点指针为空,或者该当前工作目录
    // 指向的i节点是一个空闲i节点,这也是系统有问题,停机。
    if (!current->root || !current->root->i_count)
        panic("No root inode");
    if (!current->pwd || !current->pwd->i_count)
        panic("No cwd inode");
    // 如果用户指定的路径名的第1个字符是'/',则说明路径名是绝对路径名。则从
    // 根i节点开始操作,否则第一个字符是其他字符,则表示给定的相对路径名。
    // 应从进程的当前工作目录开始操作。则取进程当前工作目录的i节点。如果路径
    // 名为空,则出错返回NULL退出。此时变量inode指向了正确的i节点 -- 进程的
    // 根i节点或当前工作目录i节点之一。
    if ((c=get_fs_byte(pathname))=='/') {
        inode = current->root;
        pathname++;
    } else if (c)
        inode = current->pwd;
    else
        return NULL;    /* empty name is bad */
    // 然后针对路径名中的各个目录名部分和文件名进行循环出路,首先把得到的i节点
    // 引用计数增1,表示我们正在使用。在循环处理过程中,我们先要对当前正在处理
    // 的目录名部分(或文件名)的i节点进行有效性判断,并且把变量thisname指向
    // 当前正在处理的目录名部分(或文件名)。如果该i节点不是目录类型的i节点,
    // 或者没有可进入该目录的访问许可,则放回该i节点,并返回NULL退出。当然,刚
    // 进入循环时,当前的i节点就是进程根i节点或者是当前工作目录的i节点。
    inode->i_count++;
    while (1) {
        thisname = pathname;
        if (!S_ISDIR(inode->i_mode) || !permission(inode,MAY_EXEC)) {
            iput(inode);
            return NULL;
        }
        // 每次循环我们处理路径名中一个目录名(或文件名)部分。因此在每次循环中
        // 我们都要从路径名字符串中分离出一个目录名(或文件名)。方法是从当前路径名
        // 指针pathname开始处搜索检测字符,知道字符是一个结尾符(NULL)或者是一
        // 个'/'字符。此时变量namelen正好是当前处理目录名部分的长度,而变量thisname
        // 正指向该目录名部分的开始处。此时如果字符是结尾符NULL,则表明以及你敢搜索
        // 到路径名末尾,并已到达最后指定目录名或文件名,则返回该i节点指针退出。
        // 注意!如果路径名中最后一个名称也是一个目录名,但其后面没有加上'/'字符,
        // 则函数不会返回该最后目录的i节点!例如:对于路径名/usr/src/linux,该函数
        // 将只返回src/目录名的i节点。
        for(namelen=0;(c=get_fs_byte(pathname++))&&(c!='/');namelen++)
            /* nothing */ ;
        if (!c)
            return inode;
        // 在得到当前目录名部分(或文件名)后,我们调用查找目录项函数find_entry()在
        // 当前处理的目录中寻找指定名称的目录项。如果没有找到,则返回该i节点,并返回
        // NULL退出。然后在找到的目录项中取出其i节点号inr和设备号idev,释放包含该目录
        // 项的高速缓冲块并放回该i节点。然后去节点号inr的i节点inode,并以该目录项为
        // 当前目录继续循环处理路径名中的下一目录名部分(或文件名)。
        if (!(bh = find_entry(&inode,thisname,namelen,&de))) {
            iput(inode);
            return NULL;
        }
        inr = de->inode;                        // 当前目录名部分的i节点号
        idev = inode->i_dev;
        brelse(bh);
        iput(inode);
        if (!(inode = iget(idev,inr)))          // 取i节点内容。
            return NULL;
    }
}

   (7)查找目录的最高路径,比如:

cd  /home/test的最高路径是test(最后一个反斜杠后面是test):basename是test,namelen=4;
cd  /home/test/的最高路径是空的(最后一个反斜杠后面是空的):basename是null,namelen=0;

/*
 *    dir_namei()
 *
 * dir_namei() returns the inode of the directory of the
 * specified name, and the name within that directory.
 */
// 参数:pathname - 目录路径名;namelen - 路径名长度;name - 返回的最顶层目录名。
// 返回:指定目录名最顶层目录的i节点指针和最顶层目录名称及长度。出错时返回NULL。
// 注意!!这里"最顶层目录"是指路径名中最靠近末端的目录。
static struct m_inode * dir_namei(const char * pathname,
    int * namelen, const char ** name)
{
    char c;
    const char * basename;
    struct m_inode * dir;

    // 首先取得指定路径名最顶层目录的i节点。然后对路径名Pathname 进行搜索检测,查出
    // 最后一个'/'字符后面的名字字符串,计算其长度,并且返回最顶层目录的i节点指针。
    // 注意!如果路径名最后一个字符是斜杠字符'/',那么返回的目录名为空,并且长度为0.
    // 但返回的i节点指针仍然指向最后一个'/'字符钱目录名的i节点。
    if (!(dir = get_dir(pathname)))
        return NULL;
    basename = pathname;
    while ((c=get_fs_byte(pathname++)))
        if (c=='/')
            basename=pathname;
    *namelen = pathname-basename-1;
    *name = basename;
    return dir;
}

   (8)这个可能是最有用的函数之一了:namei函数,用户传入路径,返回对应的inode节点

/*
 *    namei()
 *
 * is used by most simple commands to get the inode of a specified name.
 * Open, link etc use their own routines, but this is enough for things
 * like 'chmod' etc.
 */
//// 取指定路径名的i节点。
// 参数:pathname - 路径名。
// 返回:对应的i节点。
struct m_inode * namei(const char * pathname)
{
    const char * basename;
    int inr,dev,namelen;
    struct m_inode * dir;
    struct buffer_head * bh;
    struct dir_entry * de;

    // 首先查找指定路径的最顶层目录的目录名并得到其i节点,若不存在,则返回NULL退出。
    // 如果返回的最顶层名字长度是0,则表示该路径名以一个目录名为最后一项。因此我们
    // 已经找到对应目录的i节点,可以直接返回该i节点退出。
    if (!(dir = dir_namei(pathname,&namelen,&basename)))
        return NULL;
    if (!namelen)            /* special case: '/usr/' etc */
        return dir;
    // 然后在返回的顶层目录中寻找指定文件名目录项的i节点。注意!因为如果最后也是一个
    // 目录名,但其后没有加'/',则不会返回该目录的i节点!例如:/usr/src/linux,将只返回
    // src/目录名的i节点。因为函数dir_namei()把不以'/'结束的最后一个名字当作一个文件名
    // 来看待,所以这里需要单独对这种情况使用寻找目录项i节点函数find_entry()进行处理。
    // 此时de中含有寻找到的目录项指针,而dir是包含该目录项的目录的i节点指针。
    bh = find_entry(&dir,basename,namelen,&de);
    if (!bh) {
        iput(dir);
        return NULL;
    }
    // 接着取该目录项的i节点号和设备号,并释放包含该目录项的高速缓冲块并返回目录i节点。
    // 然后取对应节点号的i节点,修改其被访问时间为当前时间,并置已修改标志。最后返回
    // 该i节点指针。
    inr = de->inode;
    dev = dir->i_dev;/*子目录设备号要和父目录一致*/
    brelse(bh);
    iput(dir);
    dir=iget(dev,inr);
    if (dir) {
        dir->i_atime=CURRENT_TIME;
        dir->i_dirt=1;
    }
    return dir;
}

   namei没有做任何权限的判断,也只是查找现成的dir_entry,如果没有就返回null了,所以只能用在find -name这种命令;但实际用户使用时,还涉及到文件的权限校验,文件打开方式判断(只读?可读可写?)等,情况比find -name这种命令复杂很多,需要单独重新写个接口来实现这些需求,如下:相比namei,

  检查了权限和打开模式;
       如果没找到对饮的inode就新建inode,而不是直接返回null;“宏观”层面感受:用户调用open函数想打开一个文件,如果该文件不存在,就新建文件,并返回文件的handler!

/*
 *    open_namei()
 *
 * namei for open - this is in fact almost the whole open-routine.
 */
//// 文件打开namei函数。
// 参数filename是文件名,flag是打开文件标志,他可取值:O_RDONLY(只读)、O_WRONLY(只写)
// 或O_RDWR(读写),以及O_CREAT(创建)、O_EXCL(被创建文件必须不存在)、O_APPEND(在文件尾
// 添加数据)等其他一些标志的组合。如果本调用创建了一个新文件,则mode就用于指定文件的
// 许可属性。这些属性有S_IRWXU(文件宿主具有读、写和执行权限)、S_IRUSR(用户具有读文件
// 权限)、S_IRWXG(组成员具有读、写和执行权限)等等。对于新创建的文件,这些属性只应用于
// 将来对文件的访问,创建了只读文件的打开调用也将返回一个可读写的文件句柄。
// 返回:成功返回0,否则返回出错码;res_inode - 返回对应文件路径名的i节点指针。
int open_namei(const char * pathname, int flag, int mode,
    struct m_inode ** res_inode)
{
    const char * basename;
    int inr,dev,namelen;
    struct m_inode * dir, *inode;
    struct buffer_head * bh;
    struct dir_entry * de;

    // 首先对函数参数进行合理的处理。如果文件访问模式标志是只读(0),但是文件截零标志
    // O_TRUNC却置位了,则在文件打开标志中添加只写O_WRONLY。这样做的原因是由于截零标志
    // O_TRUNC必须在文件可写情况下才有效。然后使用当前进程的文件访问许可屏蔽码,屏蔽掉
    // 给定模式中的相应位,并添上对普通文件标志I_REGULAR。该标志将用于打开的文件不存在
    // 而需要创建文件时,作为新文件的默认属性。
    if ((flag & O_TRUNC) && !(flag & O_ACCMODE))
        flag |= O_WRONLY;
    mode &= 0777 & ~current->umask;
    mode |= I_REGULAR;
    // 然后根据指定的路径名寻找对应的i节点,以及最顶端目录名及其长度。此时如果最顶端目录
    // 名长度为0(例如'/usr/'这种路径名的情况),那么若操作不是读写、创建和文件长度截0,
    // 则表示是在打开一个目录名文件操作。于是直接返回该目录的i节点并返回0退出。否则说明
    // 进程操作非法,于是放回该i节点,返回出错码。
    if (!(dir = dir_namei(pathname,&namelen,&basename)))
        return -ENOENT;
    if (!namelen) {            /* special case: '/usr/' etc */
        if (!(flag & (O_ACCMODE|O_CREAT|O_TRUNC))) {
            *res_inode=dir;
            return 0;
        }
        iput(dir);
        return -EISDIR;
    }
    // 接着根据上面得到的最顶层目录名的i节点dir,在其中查找取得路径名字符串中最后的文件名
    // 对应的目录项结构de,并同时得到该目录项所在的高速缓冲区指针。如果该高速缓冲指针为NULL,
    // 则表示没有找到对应文件名的目录项,因此只可能是创建文件操作。此时如果不是创建文件,则
    // 放回该目录的i节点,返回出错号退出。如果用户在该目录没有写的权力,则放回该目录的i节点,
    // 返回出错号退出。
    bh = find_entry(&dir,basename,namelen,&de);
    if (!bh) {
        if (!(flag & O_CREAT)) {
            iput(dir);
            return -ENOENT;
        }
        if (!permission(dir,MAY_WRITE)) {
            iput(dir);
            return -EACCES;
        }
        // 现在我们确定了是创建操作并且有写操作许可。因此我们就在目录i节点对设备上申请一个
        // 新的i节点给路径名上指定的文件使用。若失败则放回目录的i节点,并返回没有空间出错码。
        // 否则使用该新i节点,对其进行初始设置:置节点的用户id;对应节点访问模式;置已修改
        // 标志。然后并在指定目录dir中添加一个新目录项。
        inode = new_inode(dir->i_dev);
        if (!inode) {
            iput(dir);
            return -ENOSPC;
        }
        inode->i_uid = current->euid;
        inode->i_mode = mode;
        inode->i_dirt = 1;
        bh = add_entry(dir,basename,namelen,&de);
        // 如果返回的应该含有新目录项的高速缓冲区指针为NULL,则表示添加目录项操作失败。于是
        // 将该新i节点的引用计数减1,放回该i节点与目录的i节点并返回出错码退出。否则说明添加
        // 目录项操作成功。于是我们来设置该新目录的一些初始值:置i节点号为新申请的i节点的号
        // 码;并置高速缓冲区已修改标志。然后释放该高速缓冲区,放回目录的i节点。返回新目录
        // 项的i节点指针,并成功退出。
        if (!bh) {
            inode->i_nlinks--;
            iput(inode);
            iput(dir);
            return -ENOSPC;
        }
        de->inode = inode->i_num;
        bh->b_dirt = 1;
        brelse(bh);
        iput(dir);
        *res_inode = inode;
        return 0;
    }
    // 若上面在目录中取文件名对应目录项结构的操作成功(即bh不为NULL),则说明指定打开的文件已
    // 经存在。于是取出该目录项的i节点号和其所在设备号,并释放该高速缓冲区以及放回目录的i节点
    // 如果此时堵在操作标志O_EXCL置位,但现在文件已经存在,则返回文件已存在出错码退出。
    inr = de->inode;
    dev = dir->i_dev;
    brelse(bh);
    iput(dir);
    if (flag & O_EXCL)
        return -EEXIST;
    // 然后我们读取该目录项的i节点内容。若该i节点是一个目录i节点并且访问模式是只写或读写,或者
    // 没有访问的许可权限,则放回该i节点,返回访问权限出错码退出。
    if (!(inode=iget(dev,inr)))
        return -EACCES;
    if ((S_ISDIR(inode->i_mode) && (flag & O_ACCMODE)) ||
        !permission(inode,ACC_MODE(flag))) {
        iput(inode);
        return -EPERM;
    }
    // 接着我们更新该i节点的访问时间字段值为当前时间。如果设立了截0标志,则将该i节点的文件长度
    // 截0.最后返回该目录项i节点的指针,并返回0(成功)。
    inode->i_atime = CURRENT_TIME;
    if (flag & O_TRUNC)
        truncate(inode);
    *res_inode = inode;
    return 0;
}

   至此,不知道各读者有没有发现文件和目录相关操作的共性:全都围绕inode和dir_entry两个结构体各种操作!

  dir_entry有字符串数组,存放了目录或文件的字符,可以先根据字符串找到目标dir_entry
       取出目标dir_entry的inode字段,这个字段标时了inode节点的偏移位置(或者说在磁盘上block的位置)
       利用inode偏移从磁盘读取inode节点,这里面包含了文件的元信息,尤其时i_zone字段,根据这个进一步从磁盘读取文件数据
       磁盘中的inode通过inode位图标记是否使用;dir_entry在inode根节点;内存中inode存放在inode_table数组!不论是在磁盘,还是在内存,本质上都是把inode或dir_entry结构体的实例集合起来统一管理(检索查找)!
  两个结构体本质上都是用来做索引,dir_entry字段少,相当于简版的索引!inode字段多,相当于完整的索引!

(9)依次类推,mknod也是类似的操作(这居然还是个系统调用,级别相当的高):

//// 创建一个设备特殊文件或普通文件节点(node)
// 该函数创建名称为filename,由mode和dev指定的文件系统节点(普通文件、设备特殊文件或命名管道)
// 参数:filename - 路径名;mode - 指定使用许可以及所创建节点的类型;dev - 设备号。
// 返回:成功则返回0,否则返回出错码。
int sys_mknod(const char * filename, int mode, int dev)
{
    const char * basename;
    int namelen;
    struct m_inode * dir, * inode;
    struct buffer_head * bh;
    struct dir_entry * de;

    // 首先检查操作许可和参数的有效性并取路径名中顶层目录的i节点。如果不是超级用户,则返回
    // 访问许可出错码。如果找不到对应路径名中顶层目录的i节点,则返回出错码。如果最顶端的
    // 文件名长度为0,则说明给出的路径名最后没有指定文件名,放回该目录i节点,返回出错码退出。
    // 如果在该目录中没有写的权限,则放回该目录的i节点,返回访问许可出错码退出。如果不是超级
    // 用户,则返回访问许可出错码。
    if (!suser())
        return -EPERM;
    if (!(dir = dir_namei(filename,&namelen,&basename)))
        return -ENOENT;
    if (!namelen) {
        iput(dir);
        return -ENOENT;
    }
    if (!permission(dir,MAY_WRITE)) {
        iput(dir);
        return -EPERM;
    }
    // 然后我们搜索一下路径名指定的文件是否已经存在。若已经存在则不能创建同名文件节点。
    // 如果对应路径名上最后的文件名的目录项已经存在,则释放包含该目录项的缓冲区块并放回
    // 目录的i节点,放回文件已存在的出错码退出。
    bh = find_entry(&dir,basename,namelen,&de);
    if (bh) {
        brelse(bh);
        iput(dir);
        return -EEXIST;
    }
    // 否则我们就申请一个新的i节点,并设置该i节点的属性模式。如果要创建的是块设备文件或者是
    // 字符设备文件,则令i节点的直接逻辑块指针0等于设备号。即对于设备文件来说,其i节点的
    // i_zone[0]中存放的是该设备文件所定义设备的设备号。然后设置该i节点的修改时间、访问
    // 时间为当前时间,并设置i节点已修改标志。
    inode = new_inode(dir->i_dev);
    if (!inode) {
        iput(dir);
        return -ENOSPC;
    }
    inode->i_mode = mode;
    if (S_ISBLK(mode) || S_ISCHR(mode))
        inode->i_zone[0] = dev;
    inode->i_mtime = inode->i_atime = CURRENT_TIME;
    inode->i_dirt = 1;
    // 接着为这个新的i节点在目录中新添加一个目录项。如果失败(包含该目录项的高速缓冲块指针为
    // NULL),则放回目录的i节点,吧所申请的i节点引用连接计数复位,并放回该i节点,返回出错码退出。
    bh = add_entry(dir,basename,namelen,&de);
    if (!bh) {
        iput(dir);
        inode->i_nlinks=0;
        iput(inode);
        return -ENOSPC;
    }
    // 现在添加目录项操作也成功了,于是我们来设置这个目录项内容。令该目录项的i节点字段于新i节点
    // 号,并置高速缓冲区已修改标志,放回目录和新的i节点,释放高速缓冲区,最后返回0(成功)。
    de->inode = inode->i_num;/*刚创建的inode和dir_entry做映射*/
    bh->b_dirt = 1;
    iput(dir);
    iput(inode);
    brelse(bh);
    return 0;
}

   早期连创建目录都是系统调用,只能系统管理员创建的:

//// 创建一个目录
// 参数:pathname - 路径名;mode - 目录使用的权限属性。
// 返回:成功则返回0,否则返回出错码。
int sys_mkdir(const char * pathname, int mode)
{
    const char * basename;
    int namelen;
    struct m_inode * dir, * inode;
    struct buffer_head * bh, *dir_block;
    struct dir_entry * de;

    // 首先检查操作许可和参数的有效性并取路径名中顶层目录的i节点。如果不是超级用户,则
    // 放回访问许可出错码。如果找不到对应路径名中顶层目录的i节点,则返回出错码。如果最
    // 顶端的文件名长度为0,则说明给出的路径名最后没有指定文件名,放回该目录i节点,返回
    // 出错码退出。如果在该目录中没有写权限,则放回该目录的i节点,返回访问许可出错码退出。
    // 如果不是超级用户,则返回访问许可出错码。
    if (!suser())
        return -EPERM;
    if (!(dir = dir_namei(pathname,&namelen,&basename)))
        return -ENOENT;
    if (!namelen) {
        iput(dir);
        return -ENOENT;
    }
    if (!permission(dir,MAY_WRITE)) {
        iput(dir);
        return -EPERM;
    }
    // 然后我们搜索一下路径名指定的目录名是否已经存在。若已经存在则不能创建同名目录节点。
    // 如果对应路径名上最后的目录名的目录项已经存在,则释放包含该目录项的缓冲区块并放回
    // 目录的i节点,返回文件已经存在的出错码退出。否则我们就申请一个新的i节点,并设置该i
    // 节点的属性模式:置该新i节点对应的文件长度为32字节(2个目录项的大小),置节点已修改
    // 标志,以及节点的修改时间和访问时间,2个目录项分别用于‘.’和'..'目录。
    bh = find_entry(&dir,basename,namelen,&de);
    if (bh) {
        brelse(bh);
        iput(dir);
        return -EEXIST;
    }
    inode = new_inode(dir->i_dev);
    if (!inode) {
        iput(dir);
        return -ENOSPC;
    }
    inode->i_size = 32;/*目录的inode节点size是32,这个是固定的*/
    inode->i_dirt = 1;
    inode->i_mtime = inode->i_atime = CURRENT_TIME;
    // 接着为该新i节点申请一用于保存目录项数据的磁盘块,用于保存目录项结构信息。并令i节
    // 点的第一个直接块指针等于该块号。如果申请失败则放回对应目录的i节点;复位新申请的i
    // 节点连接计数;放回该新的i节点,返回没有空间出错码退出。否则置该新的i节点已修改标志。
    if (!(inode->i_zone[0]=new_block(inode->i_dev))) {
        iput(dir);
        inode->i_nlinks--;
        iput(inode);
        return -ENOSPC;
    }
    inode->i_dirt = 1;
    // 从设备上读取新申请的磁盘块(目的是吧对应块放到高速缓冲区中)。若出错,则放回对应
    // 目录的i节点;释放申请的磁盘块;复位新申请的i节点连接计数;放回该新的i节点,返回没有
    // 空间出错码退出。
    if (!(dir_block=bread(inode->i_dev,inode->i_zone[0]))) {
        iput(dir);
        free_block(inode->i_dev,inode->i_zone[0]);
        inode->i_nlinks--;
        iput(inode);
        return -ERROR;
    }
    // 然后我们在缓冲块中建立起所创建目录文件中的2个默认的新目录项('.'和'..')结构数据。
    // 首先令de指向存放目录项的数据块,然后置该目录项的i节点号字段等于新申请的i节点号,
    // 名字字段等于'.'。然后de指向下一个目录项结构,并在该结构中存放上级目录的i节点号
    // 和名字'..'。然后设置该高速缓冲块 已修改标志,并释放该缓冲块。再初始化设置新i节点
    // 的模式字段,并置该i节点已修改标志。
    de = (struct dir_entry *) dir_block->b_data;
    de->inode=inode->i_num;
    strcpy(de->name,".");/*新创建的目录,用ls -al查询会发现有.和..这两个目录*/
    de++;
    de->inode = dir->i_num;
    strcpy(de->name,"..");
    inode->i_nlinks = 2;
    dir_block->b_dirt = 1;
    brelse(dir_block);
    inode->i_mode = I_DIRECTORY | (mode & 0777 & ~current->umask);
    inode->i_dirt = 1;
    // 现在我们在指定目录中新添加一个目录项,用于存放新建目录的i节点号和目录名。如果
    // 失败(包含该目录项的高速缓冲区指针为NULL),则放回目录的i节点;所申请的i节点引用
    // 连接计数复位,并放回该i节点。返回出错码退出。
    bh = add_entry(dir,basename,namelen,&de);
    if (!bh) {
        iput(dir);
        free_block(inode->i_dev,inode->i_zone[0]);
        inode->i_nlinks=0;
        iput(inode);
        return -ENOSPC;
    }
    // 最后令该新目录项的i节点字段等于新i节点号,并置高速缓冲块已修改标志,放回目录和
    // 新的i节点,是否高速缓冲块,最后返回0(成功).
    de->inode = inode->i_num;
    bh->b_dirt = 1;
    dir->i_nlinks++;
    dir->i_dirt = 1;
    iput(dir);
    iput(inode);
    brelse(bh);
    return 0;
}

   (10)创建硬链接:本质就是新建该路径的dir_entry,然后和文件原inode映射绑定!

找到指定文件的inode
再指定文件的路径中创建新的dir_entry
新创建的dir_entry映射到原inode:de->inode = oldinode->i_num

//// 为文件建立一个文件名目录项
// 为一个已存在的文件创建一个新链接(也称为硬链接 - hard link)
// 参数:oldname - 原路径名;newname - 新的路径名
// 返回:若成功则返回0,否则返回出错号。
int sys_link(const char * oldname, const char * newname)
{
    struct dir_entry * de;
    struct m_inode * oldinode, * dir;
    struct buffer_head * bh;
    const char * basename;
    int namelen;

    // 首先对原文件名进行有效性验证,它应该存在并且不是一个目录名。所以我们先取得原文件
    // 路径名对应的i节点oldname.若果为0,则表示出错,返回出错号。若果原路径名对应的是
    // 一个目录名,则放回该i节点,也返回出错号。
    oldinode=namei(oldname);
    if (!oldinode)
        return -ENOENT;
    if (S_ISDIR(oldinode->i_mode)) {
        iput(oldinode);
        return -EPERM;
    }
    // 然后查找新路径名的最顶层目录的i节点dir,并返回最后的文件名及其长度。如果目录的
    // i节点没有找到,则放回原路径名的i节点,返回出错号。如果新路径名中不包括文件名,
    // 则放回原路径名i节点和新路径名目录的i节点,返回出错号。
    dir = dir_namei(newname,&namelen,&basename);
    if (!dir) {
        iput(oldinode);
        return -EACCES;
    }
    if (!namelen) {//以反斜杠结尾,后面啥都没了,导致namelen=0;
        iput(oldinode);
        iput(dir);
        return -EPERM;
    }
    // 我们不能跨设备建立硬链接。因此如果新路径名顶层目录的设备号与原路径名的设备号不
    // 一样,则放回新路径名目录的i节点和原路径名的i节点,返回出错号。另外,如果用户没
    // 有在新目录中写的权限,则也不能建立连接,于是放回新路径名目录的i节点和原路径名
    // 的i节点,返回出错号。
    if (dir->i_dev != oldinode->i_dev) {
        iput(dir);
        iput(oldinode);
        return -EXDEV;
    }
    if (!permission(dir,MAY_WRITE)) {
        iput(dir);
        iput(oldinode);
        return -EACCES;
    }
    // 现在查询该新路径名是否已经存在,如果存在则也不能建立链接。于是释放包含该已存在
    // 目录项的高速缓冲块,放回新路径名目录的i节点和原路径名的i节点,返回出错号。
    bh = find_entry(&dir,basename,namelen,&de);
    if (bh) {
        brelse(bh);
        iput(dir);
        iput(oldinode);
        return -EEXIST;
    }
    // 现在所有条件都满足了,于是我们在新目录中添加一个目录项。若失败则放回该目录的
    // i节点和原路径名的i节点,返回出错号。否则初始设置该目录项的i节点号等于原路径名的
    // i节点号,并置包含该新添加目录项的缓冲块已修改标志,释放该缓冲块,放回目录的i节点。
    bh = add_entry(dir,basename,namelen,&de);
    if (!bh) {
        iput(dir);
        iput(oldinode);
        return -ENOSPC;
    }
    de->inode = oldinode->i_num;/*老的inode对一个的block号给新建的dir_entry,借此建立映射*/
    bh->b_dirt = 1;
    brelse(bh);
    iput(dir);
    // 再将原节点的硬链接计数加1,修改其改变时间为当前时间,并设置i节点已修改标志。最后
    // 放回原路径名的i节点,并返回0(成功)。
    oldinode->i_nlinks++;
    oldinode->i_ctime = CURRENT_TIME;
    oldinode->i_dirt = 1;
    iput(oldinode);
    return 0;
}

  总结:

  目录本质上就是一系列dir_entry的集合!创建/修改目录就是创建/修改dir_entry,创建/修改文件就是创建/修改inode;   
       逆向或破解时掌握dir_entry和inode,就相当于掌握了所有的目录和文件;




参考:

1、https://zhuanlan.zhihu.com/p/76595175    深入浅出文件系统原理之文件读取(基于linux0.11)

2、https://www.bilibili.com/video/BV1tQ4y1d7mo?p=27  linux内核精讲

#405 文件系统模块 » linux源码解读(四):文件系统——挂载和卸载 » 2022-10-08 13:33:22

batsom
回复: 0

对于普通用户而言,日常用的都是windows操作系统。windows把整个物理硬盘分成C、D、E、F.....等逻辑分区,用户可以随意在各个逻辑分区存放数据文件;逻辑分区之间是独立互不影响的,格式化某个逻辑分区,不会影响其他逻辑分区的数据,所以C、D、E、F.....等逻辑分区就是磁盘的根目录;如果要调整逻辑分区的大小,需要用专业的工具操作,比如windows自带的diskmgmt.msc工具。扩容时,需要把空闲的磁盘空间“加入”到目标逻辑分区,这个过程就是挂载,挂载后用户就能正常使用;而在linux下,所有的文件、目录、设备都有一个路径,这个路径永远以/开头,用/分隔。如果有设备加入(比如增加磁盘、U盘等),同样需要挂载到某个路径下、让linux的目录和新设备的目录合二为一后才能正常使用(谁让linux是万物皆文件了?设备都被当成是文件,自然要放在某个目录下了);上面的说法可能有点抽象,这里举个栗子:公司跳槽来了一位新同事,这位新同事先要和公司签订劳动合同,然后去HR那里注册身份信息、提交银行卡号等,这些一切手续办理完毕后才会去业务部门报道开始干活;linux下面也类似:新增设备后,先把设备相关信息加入到某些结构体(这里用的是超级块),然后通过这些结构体去读写(检索)该设备,否则内核是没法管理新增设备的! 前面介绍过,linux采用了super_block保存inode位图和数据块位图信息,所以super_block是整个文件系统“最上层”的结构体,所有mount、unmount等操作也都是从super_block开始的!所有相关的操作都在super.c文件里,接下来我们一一解析!

  1、这里再说明一下各种概念之间的关系,如下图:

  linux操作系统内核或app不会直接读写磁盘,都是直接读写高速缓存区的(通过memcpy拷贝内存数据)
       高速缓存区调用ll_rw_block函数读写磁盘
       super_block、inode、数据块等结构体即存磁盘,也可以存内存,通过ll_rw_block函数在内存和磁盘之间同步
       buffer_head的block和磁盘中的block是有一定对应关系的

FluxBB bbcode 测试

  这个图看起来有点复杂,其实原理很简单:

  内存和磁盘本质功能都一样:都是用来存储数据的,唯一的区别就是掉电后数据丢不丢
       既然存了数据,自然也要读取了,就涉及到寻址;内存寻址很简单:内存最小的存储单位是byte,每个byte都有自己的地址编号,可以根据地址编号直接读数据,比如 int *p=0x12345678,意味着从地址0x12345678处开始依次读取4byte的数据,数据结尾地址就是0x12345678+4=0x1234567b;
       相比内存,磁盘读数据稍微要“麻烦”一点:磁盘被人为划分成了块,每块的大小是512byte*8=4KB;为了方便读取块的数据,也需要给每个块编号,这个编号和内存的地址编号本质都是一样的;为了标记块是否被使用,或者被哪些文件使用,诞生了inode结构体,具体使用了i_zone字段记录!进一步:为了记录哪些inode结构体被使用,又诞生了inode位图(存放inode结构体的block是有限的,导致inode结构体的数量也受限,需要省着点用!)
       内存、磁盘这类块存储设备,使用结构体管理时,结构体的字段一般都有:是否锁定lock、是否更新update、是否有数据dirty、被使用/引用次数count、当前被那个进程在使用task、后面有哪些进程在排队等待wait.......
   2、(1)既然super_block是“根索引”,意味着抓住了super_block,就等于抓住了整个文件系统(类似进程的内存用页目录表、页表项管理和索引类似,抓住了CR3,就得到了进程的所有内存)。为了管理所有的super_block,Linux早期的0.11版本采用了最原始的数组:

   // 超级块结构表数组(NR_SUPER = 8)
   struct super_block super_block[NR_SUPER];
  这里要吐槽了:数组只有8个元素,意味着只能同时管理8个文件系统;不过后期的linux貌似改了,用链表串接了所有的super_block,只要内存够,可以存好多的super_block!

      (2)超级块本质上也是内存的一块,所以和其他快一样,都涉及到锁、等待、释放等操作,代码如下:可以看到和inode、block等思路完全一样,没任何区别!

// 以下3个函数(lock_super()、free_super()和wait_on_super())的作用与inode.c文件中头
// 3个函数的作用雷同,只是这里操作的对象换成了超级块。
//// 锁定超级块
// 如果超级块已被锁定,则将当前任务置为不可中断的等待状态,并添加到该超级块等待队列
// s_wait中。直到该超级块解锁并明确地唤醒本地任务。然后对其上锁。
static void lock_super(struct super_block * sb)
{
    cli();                          // 关中断
    while (sb->s_lock)              // 如果该超级块已经上锁,则睡眠等待。
        sleep_on(&(sb->s_wait));
    sb->s_lock = 1;                 // 会给超级块加锁(置锁定标志)
    sti();                          // 开中断
}

//// 对指定超级块解锁
// 复位超级块的锁定标志,并明确地唤醒等待在此超级块等待队列s_wait上的所有进程。
// 如果使用ulock_super这个名称则可能更妥贴。
static void free_super(struct super_block * sb)
{
    cli();
    sb->s_lock = 0;             // 复位锁定标志
    wake_up(&(sb->s_wait));     // 唤醒等待该超级块的进程。
    sti();
}

//// 睡眠等待超级解锁
// 如果超级块已被锁定,则将当前任务置为不可中断的等待状态,并添加到该超级块的等待
// 队列s_wait中。知道该超级块解锁并明确的唤醒本地任务.
static void wait_on_super(struct super_block * sb)
{
    cli();
    while (sb->s_lock)
        sleep_on(&(sb->s_wait));
    sti();
}1

   (3)设备是被super_block总管的,所以设备肯定要和super_block映射的,这里通过设备号在super_block数组挨个查找映射好的super_block!

//// 取指定设备的超级块
// 在超级块表(数组)中搜索指定设备dev的超级块结构信息。若找到刚返回超级块的指针,
// 否则返回空指针
struct super_block * get_super(int dev)
{
    struct super_block * s;

    // 首先判断参数给出设备的有效性。若设备号为0则返回NULL,然后让s指向超级块数组
    // 起始处,开始搜索整个超级块数组,以寻找指定设备dev的超级块。
    if (!dev)
        return NULL;
    s = 0+super_block;
    while (s < NR_SUPER+super_block)
        // 如果当前搜索项是指定设备的超级块,即该超级块的设备号字段值与函数参数指定的
        // 相同,则先等待该超级块解锁。在等待期间,该超级块项有可能被其他设备使用,因此
        // 等待返回之后需要再判断一次是否是指定设备的超级块,如果是则返回该超级块的指针。
        // 否则就重新对超级块数组再搜索一遍,因此此时s需重又指向超级块数组开始处。
        if (s->s_dev == dev) {
            wait_on_super(s);
            if (s->s_dev == dev)
                return s;
            s = 0+super_block;
        // 如果当前搜索项不是,则检查下一项,如果没有找到指定的超级块,则返回空指针。
        } else
            s++;
    return NULL;
}

      super_block用完后可以释放,这里把super_block结构体的dev清零,表示不和任何设备映射(或则说部管理任何设备)!同时释放位图块和逻辑块,最后释放该块上的锁,然后唤醒等待的进程;

//// 释放指定设备的超级块
// 释放设备所使用的超级块数组项,并释放该设备i节点的位图和逻辑块位图所占用的高速缓冲块。
// 如果超级块对应的文件系统是根文件系统,或者其某个i节点上已经安装有其他的文件系统,
// 则不能释放该超级块。
void put_super(int dev)
{
    struct super_block * sb;
    /* struct m_inode * inode;*/
    int i;

    // 首先判断参数的有效性和合法性。如果指定设备是根文件系统设备,则显示警告信息“根
    // 系统盘改变了,准备生死决战吧”,并返回。然后在超级块表现中寻找指定设备号的文件系统
    // 超级块。如果找不到指定设备的超级块,则返回。另外,如果该超级块指明该文件系统所安装
    // 到的i节点还没有被处理过,则显示警告信息并返回。在文件系统卸载操作中,s_imount会先被
    // 置成NULL以后才会调用本函数。
    if (dev == ROOT_DEV) {
        printk("root diskette changed: prepare for armageddon\n\r");
        return;
    }
    if (!(sb = get_super(dev)))
        return;
    if (sb->s_imount) {
        printk("Mounted disk changed - tssk, tssk\n\r");
        return;
    }
    // 然后在找到指定设备的超级块之后,我们先锁定该超级块,再置该超级块对应的设备号字段
    // s_dev为0,也即释放该设备上的文件系统超级块。然后释放该超级块占用的其他内核资源,
    // 即释放该设备上文件系统i节点位图和逻辑块位图在缓冲区中所占用的缓冲块。下面常数符号
    // I_MAP_SLOTS和Z_MAP_LOTS均等于8,用于分别指明i节点位图和逻辑块位图占用的磁盘逻辑块
    // 数。注意,若这些缓冲块内荣被修改过,则需要作同步操作才能把缓冲块中的数据写入设备
    // 中,函数最后对该超级块解锁,并返回。
    lock_super(sb);
    sb->s_dev = 0;
    for(i=0;i<I_MAP_SLOTS;i++)
        brelse(sb->s_imap[i]);
    for(i=0;i<Z_MAP_SLOTS;i++)
        brelse(sb->s_zmap[i]);
    free_super(sb);
    return;
}

  读取指定设备上的super_block:

  传入设备号,通过设备号找到内存的super_block结构体,并初始化部分属性
       调用bread读取指定设备的第1号block(0号block是磁盘的引导块),也就是超级块;这里不理解的小伙伴建议看看上面的图:block在磁盘内部也是顺着编号的
       从设备读出来的super_block结构体目前还防缓存区,这里需要复制到从super_block数组中找到的super_block中
       继续调用bread读inode位图块和数据块位图,然后存放在super_block的s_imap、s_zmap数组中
       解锁super_block结构体,唤醒其他进程使用

//// 读取指定设备的超级块
// 如果指定设备dev上的文件系统超级块已经在超级块表中,则直接返回该超级块项的指针。否则
// 就从设备dev上读取超级块到缓冲块中,并复制到超级块中。并返回超级块指针。
static struct super_block * read_super(int dev)
{
    struct super_block * s;
    struct buffer_head * bh;
    int i,block;

    // 首先判断参数的有效性。如果没有指明设备,则返回空指针。然后检查该设备是否可更换过
    // 盘片(也即是否软盘设备)。如果更换盘片,则高速缓冲区有关设备的所有缓冲块均失效,
    // 需要进行失效处理,即释放原来加载的文件系统。
    if (!dev)
        return NULL;
    check_disk_change(dev);
    // 如果该设备的超级块已经在超级块表中,则直接返回该超级块的指针。否则,首先在超级块
    // 数组中找出一个空项(也即字段s_dev=0的项)。如果数组已经占满则返回空指针。
    if ((s = get_super(dev)))
        return s;
    for (s = 0+super_block ;; s++) {
        if (s >= NR_SUPER+super_block)
            return NULL;
        if (!s->s_dev)
            break;
    }
    // 在超级块数组中找到空项之后,就将该超级块项用于指定设备dev上的文件系统。于是对该
    // 超级块结构中的内存字段进行部分初始化处理。这些配置项只在内存出现
    s->s_dev = dev;
    s->s_isup = NULL;
    s->s_imount = NULL;
    s->s_time = 0;
    s->s_rd_only = 0;
    s->s_dirt = 0;
    // 然后锁定该超级块,并从设备上读取超级块信息到bh指向的缓冲块中。超级块位于设备的第
    // 2个逻辑块(1号块)中,(第1个是引导盘块)。如果读超级块操作失败,则释放上面选定
    // 的超级块数组中的项(即置s_dev=0),并解锁该项,返回空指针退出。否则就将设备上读取
    // 的超级块信息从缓冲块数据区复制到超级块数组相应项结构中。并释放存放读取信息的高速
    // 缓冲块。
    lock_super(s);
    if (!(bh = bread(dev,1))) {
        s->s_dev=0;
        free_super(s);
        return NULL;
    }
    *((struct d_super_block *) s) =
        *((struct d_super_block *) bh->b_data);
    brelse(bh);
    // 现在我们从设备dev上得到了文件系统的超级块,于是开始检查这个超级块的有效性并从设备
    // 上读取i节点位图和逻辑块位图等信息。如果所读取的超级块的文件系统魔数字段不对,说明
    // 设备上不是正确的文件系统,因此同上面一样,释放上面选定的超级块数组中的项,并解锁该
    // 项,返回空指针退出。对于该版Linux内核,只支持MINIX文件系统1.0版本,其魔数是0x1371。
    if (s->s_magic != SUPER_MAGIC) {
        s->s_dev = 0;
        free_super(s);
        return NULL;
    }
    // 下面开始读取设备上i节点的位图和逻辑块位图数据。首先初始化内存超级块结构中位图空间。
    // 然后从设备上读取i节点位图和逻辑块位图信息,并存放在超级块对应字段中。i节点位图保存
    // 在设备上2号块开始的逻辑块中,共占用s_imap_blocks个块,逻辑块位图在i节点位图所在块
    // 的后续块中,共占用s_zmap_blocks个块。
    for (i=0;i<I_MAP_SLOTS;i++)
        s->s_imap[i] = NULL;
    for (i=0;i<Z_MAP_SLOTS;i++)
        s->s_zmap[i] = NULL;
    block=2;
    for (i=0 ; i < s->s_imap_blocks ; i++)
        if ((s->s_imap[i]=bread(dev,block)))
            block++;/*block块号后移*/
        else
            break;
    for (i=0 ; i < s->s_zmap_blocks ; i++)
        if ((s->s_zmap[i]=bread(dev,block)))
            block++;/*block块号后移*/
        else
            break;
    // 如果读出的位图块数不等于位图应该占有的逻辑块数,说明文件系统位图信息有问题,
    // 因此只能释放前面申请并占用的所有资源,即释放i节点位图和逻辑块位图占用
    // 的高速缓冲块、释放上面选定的超级块数组项、解锁该超级块项,并返回空指针退出。
    if (block != 2+s->s_imap_blocks+s->s_zmap_blocks) {
        for(i=0;i<I_MAP_SLOTS;i++)
            brelse(s->s_imap[i]);
        for(i=0;i<Z_MAP_SLOTS;i++)
            brelse(s->s_zmap[i]);
        s->s_dev=0;
        free_super(s);
        return NULL;
    }
    // 否则一切成功,另外,由于对申请空闲i节点的函数来讲,如果设备上所有的i节点已经全被使用
    // 则查找函数会返回0值。因此0号i节点是不能用的,所以这里将位图中第1块的最低bit位设置为1,
    // 以防止文件系统分配0号i节点。同样的道理,也将逻辑块位图的最低位设置为1.最后函数解锁该
    // 超级块,并放回超级块指针。
    s->s_imap[0]->b_data[0] |= 1;
    s->s_zmap[0]->b_data[0] |= 1;
    free_super(s);
    return s;
}

  3、本文最重要的两个函数:sys_umount和sys_mount;从函数名就能看出来,这两个都是系统调用!linux的mount和unmount命令底层调用就是这两个函数。先看sys_unmount:

传入的参数是设备名称,比如这里要卸载u盘,传入u盘名称,根据名称找到inode节点
根据inode节点找到设备号
如果这个inode节点有进程正在使用(比如正在从u盘复制数据),返回BUSY
一切就绪后重置super_block的关键字段,同时释放super_block,同步缓存区和设备的数据;
  从代码看,umount做了很多判断,也释放和同步了数据,所以为什么U盘用完后建议先umount(windows下是卸载),完毕后再拔U盘,而不是复制完后直接简单粗暴地拔U盘!

//// 卸载文件系统(系统调用)
// 参数dev_name是文件系统所在设备的设备文件名
// 该函数首先根据参数给出的设备文件名获得设备号,然后复位文件系统超级块中的相应字段,释放超级
// 块和位图占用的缓冲块,最后对该设备执行高速缓冲与设备上数据的同步操作。若卸载操作成功则返回
// 0,否则返回出错码。
int sys_umount(char * dev_name)
{
    struct m_inode * inode;
    struct super_block * sb;
    int dev;

    // 首先根据设备文件名找到对应的i节点,并取其中的设备号。设备文件所定义设备的设备号是保存在其
    // i节点的i_zone[0]中的,参见sys_mknod()的代码。另外,由于文件系统需要存放在设备上,因此如果
    // 不是设备文件,则返回刚申请的i节点dev_i,返回出错码。
    if (!(inode=namei(dev_name)))
        return -ENOENT;
    dev = inode->i_zone[0];
    if (!S_ISBLK(inode->i_mode)) {
        iput(inode);
        return -ENOTBLK;
    }
    // OK,现在上面为了得到设备号而取得的i节点已完成了它的使命,因此这里放回该设备文件的i节点。接着
    // 我们来检查一下卸载该文件系统的条件是否满足。如果设备上是根文件系统,则不能被卸载,返回。
    iput(inode);
    if (dev==ROOT_DEV)
        return -EBUSY;
    // 如果在超级块表中没有找到该设备上文件系统的超级块,或者已找到但是该设备上文件系统
    // 没有安装过,则返回出错码。如果超级块所指明的被安装到的i节点并没有置位其安装标志
    // i_mount,则显示警告信息。然后查找一下i节点表,看看是否有进程在使用该设备上的文件(if (inode->i_dev==dev && inode->i_count)),
    // 如果有则返回出错码;比如挂载的U盘正在复制数据,这时的设备是被某个进程占用的,此刻umount肯定时不行的
    if (!(sb=get_super(dev)) || !(sb->s_imount))
        return -ENOENT;
    if (!sb->s_imount->i_mount)
        printk("Mounted inode has i_mount=0\n");
    for (inode=inode_table+0 ; inode<inode_table+NR_INODE ; inode++)
        if (inode->i_dev==dev && inode->i_count)
                return -EBUSY;
    // 现在该设备上文件系统的卸载条件均得到满足,因此我们可以开始实施真正的卸载操作了。
    // 首先复位被安装到的i节点的安装标志,释放该i节点。然后置超级块中被安装i节点字段为
    // 空,并放回设备文件系统的根i节点。接着置超级块中被安装系统根i节点指针为空。
    sb->s_imount->i_mount=0;
    iput(sb->s_imount);
    sb->s_imount = NULL;
    iput(sb->s_isup);
    sb->s_isup = NULL;
    // 最后我们释放该设备上的超级块以及位图占用的高速缓冲块,并对该设备执行高速缓冲与
    // 设备上数据的同步操作,然后返回0,表示卸载成功。
    put_super(dev);
    sync_dev(dev);
    return 0;
}

  sys_mount的操作和sys_umount基本是相反的:由于需要把新设备挂载到某个目录下,所以这里涉及到目录的操作了,但是不复杂,目录的结构体也是inode!

//// 安装文件系统(系统调用)
// 参数dev_name是设备文件名,dir_name是安装到的目录名,rw_flag被安装文件系统的可
// 读写标志。将被加载的地方必须是一个目录名,并并且对应的i节点没有被其他程序占用。
// 若操作成功则返回0,否则返回出错号。
int sys_mount(char * dev_name, char * dir_name, int rw_flag)
{
    struct m_inode * dev_i, * dir_i;
    struct super_block * sb;
    int dev;

    // 首先根据设备文件名找到对应的i节点,以取得其中的设备号。对于块特殊设备文件,
    // 设备号在其i节点的i_zone[0]中。另外,由于文件凶必须在块设备中,因此如果不是
    // 块设备文件,则放回刚取得的i节点dev_i,返回出错码。
    if (!(dev_i=namei(dev_name)))
        return -ENOENT;
    dev = dev_i->i_zone[0];
    if (!S_ISBLK(dev_i->i_mode)) {
        iput(dev_i);
        return -EPERM;
    }
    // OK,现在上面为了得到设备号而取得i节点dev_i已完成了它的使命,因此这里放回该
    // 设备文件的i节点。接着我们来检查一下文件系统安装到的目录名是否有效。于是根据
    // 给定的目录文件名找到对应的i节点dir_i。如果该i节点的引用计数不为1(仅在这里引用),
    // 或者该i节点的节点号是根文件系统的节点号1,则放回该i节点的返回出错码。另外,如果
    // 该节点不是一个目录文件节点,则也放回该i节点,返回出错码。因为文件系统只能安装
    // 在一个目录名上。
    iput(dev_i);
    if (!(dir_i=namei(dir_name)))
        return -ENOENT;
    if (dir_i->i_count != 1 || dir_i->i_num == ROOT_INO) {
        iput(dir_i);
        return -EBUSY;
    }
    if (!S_ISDIR(dir_i->i_mode)) {
        iput(dir_i);
        return -EPERM;
    }
    // 现在安装点也检查完毕,我们开始读取要安装文件系统的超级块信息。如果读取超级块操
    // 作失败,则返回该安装点i节点的dir_i并返回出错码。一个文件系统的超级块首先从超级
    // 块表中进行搜索,如果不在超级块表中就从设备上读取。
    if (!(sb=read_super(dev))) {
        iput(dir_i);
        return -EBUSY;
    }
    // 在得到了文件系统超级块之后,我们对它先进行检测一番。如果将要被安装的文件系统已经
    // 安装在其他地方,则放回该i节点,返回出错码。如果将要安装到的i节点已经安装了文件系
    // 统(安装标志已经置位),则放回该i节点,也返回出错码。
    if (sb->s_imount) {
        iput(dir_i);
        return -EBUSY;
    }
    if (dir_i->i_mount) {
        iput(dir_i);
        return -EPERM;
    }
    // 最后设置被安装文件系统超级块的“被安装到i节点”字段指向安装到的目录名的i节点。并设置
    // 安装位置i节点的安装标志和节点已修改标志。然后返回(安装成功)。
    sb->s_imount=dir_i;
    dir_i->i_mount=1;
    dir_i->i_dirt=1;        /* NOTE! we don't iput(dir_i) */
    return 0;            /* we do that in umount */
}

   4、最后一个重要的函数:挂载根文件

既然是根文件,说明文件内部还未存放任何数据,先把file_table的引用计数清零
接着把super_block的dev、lock、wait属性清零
读取设备的super_block初始化,后续会用super_block结构体管理根节点和设备
设置super_block的inode位图和逻辑块位图

//// 安装根文件系统
// 该函数属于系统初始化操作的一部分。函数首先初始化文件表数组file_table[]和超级块表(数组)
// 然后读取根文件系统超级块,并取得文件系统根i节点。最后统计并显示出根文件系统上的可用资源
// (空闲块数和空闲i节点数)。该函数会在系统开机进行初始化设置时被调用。
void mount_root(void)
{
    int i,free;
    struct super_block * p;
    struct m_inode * mi;

    // 若磁盘i节点结构不是32字节,则出错停机。该判断用于防止修改代码时出现不一致情况。
    if (32 != sizeof (struct d_inode))
        panic("bad i-node size");
    // 首先初始化文件表数组(共64项,即系统同时只能打开64个文件)和超级块表。这里将所有文件
    // 结构中的引用计数设置为0(表示空闲),并发超级块表中各项结构的设备字段初始化为0(也
    // 表示空闲)。如果根文件系统所在设备是软盘的话,就提示“插入根文件系统盘,并按回车键”,
    // 并等待按键。
    for(i=0;i<NR_FILE;i++)
        file_table[i].f_count=0;                        // 初始化文件表,文件统一用这个数组表示
    if (MAJOR(ROOT_DEV) == 2) {
        printk("Insert root floppy and press ENTER");   // 提示插入根文件系统盘
        wait_for_keypress();
    }
    for(p = &super_block[0] ; p < &super_block[NR_SUPER] ; p++) {
        p->s_dev = 0;
        p->s_lock = 0;
        p->s_wait = NULL;
    }
    // 做好以上“份外”的初始化工作之后,我们开始安装根文件系统。于是从根设备上读取文件系统
    // 超级块,并取得文件系统的根i节点(1号节点)在内存i节点表中的指针。如果读根设备上超级
    // 块是吧或取根节点失败,则都显示信息并停机。
    if (!(p=read_super(ROOT_DEV)))
        panic("Unable to mount root");
    if (!(mi=iget(ROOT_DEV,ROOT_INO)))
        panic("Unable to read root i-node");
    // 现在我们对超级块和根i节点进行设置。把根i节点引用次数递增3次。因此后面也引用了该i节点。
    // 另外,iget()函数中i节点引用计数已被设置为1。然后置该超级块的被安装文件系统i节点和被
    // 安装到i节点。再设置当前进程的当前工作目录和根目录i节点。此时当前进程是1号进程(init进程)。
    mi->i_count += 3 ;    /* NOTE! it is logically used 4 times, not 1 */
    p->s_isup = p->s_imount = mi;
    current->pwd = mi;
    current->root = mi;
    // 然后我们对根文件系统的资源作统计工作。统计该设备上空闲块数和空闲i节点数。首先令i等于
    // 超级块中表明的设备逻辑块总数。然后根据逻辑块相应bit位的占用情况统计出空闲块数。这里
    // 宏函数set_bit()只是在测试bit位,而非设置bit位。“i&8191”用于取得i节点号在当前位图块中对应
    // 的bit位偏移值。"i>>13"是将i除以8192,也即除一个磁盘块包含的bit位数。
    free=0;
    i=p->s_nzones;
    while (-- i >= 0)
        if (!set_bit(i&8191,p->s_zmap[i>>13]->b_data))/*逻辑块位图*/
            free++;
    // 在显示过设备上空闲逻辑块数/逻辑块总数之后。我们再统计设备上空闲i节点数。首先令i等于超级块
    // 中表明的设备上i节点总数+1.加1是将0节点也统计进去,然后根据i节点位图相应bit位的占用情况计算
    // 出空闲i节点数。最后再显示设备上可用空闲i节点数和i节点总数
    printk("%d/%d free blocks\n\r",free,p->s_nzones);
    free=0;
    i=p->s_ninodes+1;
    while (-- i >= 0)
        if (!set_bit(i&8191,p->s_imap[i>>13]->b_data))
            free++;
    printk("%d/%d free inodes\n\r",free,p->s_ninodes);
}

   5、前几天把办公的笔记本加装了一块SSD盘,一共232GB大小,未存储任何文件,结果windows光是建system volume就耗费1.2GB的内存,大概率是用在了类似super_block、inode类似的管理结构体
FluxBB bbcode 测试

参考:

1、https://www.disktool.cn/content-center/resize-partition/how-to-change-partition-size-windows-10.html  如果在windows扩展或缩减分区大小

#406 文件系统模块 » linux源码解读(三):文件系统——inode » 2022-10-08 12:37:17

batsom
回复: 0

众所周知,计算机系统在掉电后也能存储数据的就是磁盘了,所以大量数据大部分时间是存放在磁盘的;现在新买的PC,磁盘从数百G到1TB不等;服务器的磁盘从数十TB到上百TB,这么大的存储空间,该怎么高效地管理和使用了?站在硬件角度,cpu的分页机制把虚拟内存切割成大量4KB大小的块,所以4KB也成了硬件层面最小的内存分配单元;对比内存,磁盘的管理方式也类似,只不过磁盘最小的存储或读写单元是512byte,称之为扇区(用户哪怕只想读1格byte,驱动每次也要读512byte的数据);不过现在的文件一般都远超512byte,所以存储单个文件肯定需要超过1个扇区的空间,这就导致了磁盘的磁头要挨个读不同的扇区,花费大量时间在磁盘上寻址,导致IO效率低下,形成了瓶颈!为了提升读取效率,磁盘一般都是一次性连续读取多个扇区,即一次性读取一个"块"(block)。这种由多个扇区组成的"块",是文件存取的最小单位。"块"的大小,最常见的是4KB(和内存页的大小保持一致,便于从磁盘读写数据???),即连续八个 sector组成一个 block;

  1、上一篇文章介绍了高速缓存区,为了方便管理这么一大块缓存区,linux采用了buffer_head结构体来描述缓存区的各种属性;同理:磁盘上也是被人为划分成了很多“块”,为了方便管理这些块,也需要相应的结构体,linux采用结构体叫m_inode(或则这样理解:文件数据都存放在block中,那么很显然,我们还必须找到一个地方储存文件的元信息,比如文件的创建者、文件的创建日期、文件的大小等等。这种储存文件元信息的区域就叫做inode,中文译名为“索引节点”;每一个文件都有对应的inode,里面包含了与该文件有关的一些信息),如下:

  注意:

  一个文件只需要一个inode节点来存储文件的元信息就够了,所以文件和inode节点是一一对应的(注意这里是文件,不是文件名);
        如果说文件很大,占用了很多的磁盘block,怎么才能找全文件的占用的所有磁盘block了?此刻就要用到inode结构体的i_zone[9]字段了,文件中的数据存放在哪个硬盘上的逻辑块上就是由这个数组来映射的:前面7个是直接存储文件数据块,第8个是间接块,第9个是二级间接块!所有直接+间接+二级间接块加起来,一共64M,这个在0.11版本所在的1991年已经非常大了!

struct m_inode {
    unsigned short i_mode;/*文件类型和属性,ls查看的结果,比如drwx------*/
    unsigned short i_uid;/*文件宿主id*/
    unsigned long i_size;
    unsigned long i_mtime;/*文件内容上一次变动的时间*/
    unsigned char i_gid;/*groupid:宿主所在的组id*/
    unsigned char i_nlinks; /*链接数:有多少个其他的文件夹链接到这里*/
    unsigned short i_zone[9];/*文件映射的逻辑块号*/
/* these are in memory also */
    struct task_struct * i_wait;/*等待该inode节点的进程队列*/
    unsigned long i_atime;/*文件上一次打开的时间*/
    unsigned long i_ctime;/*文件的inode上一次变动的时间*/
    unsigned short i_dev;/*设备号*/
    unsigned short i_num;
    /* 多少个进程在使用这个inode*/
    unsigned short i_count;
    unsigned char i_lock;/*互斥锁*/
    unsigned char i_dirt;
    unsigned char i_pipe;
    unsigned char i_mount;
    unsigned char i_seek;
    /*
    数据是否是最新的,或者说有效的,
    update代表数据的有效性,dirt代表文件是否需要回写,
    比如写入文件的时候,a进程写入的时候,dirt是1,因为需要回写到硬盘,
    但是数据是最新的,update是1,这时候b进程读取这个文件的时候,可以从
    缓存里直接读取。
      */
    unsigned char i_update;
};

  为了把内存文件块的数据映射到磁盘的block,linux专门写了_bmap函数:
FluxBB bbcode 测试

i_zone映射关系图示:

//// 文件数据块映射到盘块的处理操作。(block位图处理函数,bmap - block map)
// 参数:inode - 文件的i节点指针;block - 文件中的数据块号;create - 创建块标志。
// 该函数把指定的文件数据块block对应到设备上逻辑块上,并返回逻辑块号。如果创建标志
// 置位,则在设备上对应逻辑块不存在时就申请新磁盘块,返回文件数据块block对应在设备
// 上的逻辑块号(盘块号)。
static int _bmap(struct m_inode * inode,int block,int create)
{
    struct buffer_head * bh;
    int i;

    // 首先判断参数文件数据块号block的有效性。如果块号小于0,则停机。如果块号大于
    // 直接块数7+间接块数(相当于二级指针)512+二次间接块数(相当于三级指针)512*512,超出文件系统表示范围,则停机。
    // 这种间接块、二次间接块类似内存分页的机制
    if (block<0)
        panic("_bmap: block<0");
    if (block >= 7+512+512*512)
        panic("_bmap: block>big");
    // 然后根据文件块号的大小值和是否设置了创建标志分别进行处理。如果该块号小于7,
    // 则使用直接块表示。如果创建标志置位,并且i节点中对应块的逻辑块(区段)字段为0,
    // 则相应设备申请一磁盘块(逻辑块),并且将磁盘上逻辑块号(盘块号)填入逻辑块
    // 字段中。然后设置i节点改变时间,置i节点已修改标志。然后返回逻辑块号。
    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];
    }
    // 如果该块号>=7,且小于7+512,则说明使用的是一次间接块。下面对一次间接块进行处理。
    // 如果是创建,并且该i节点中对应间接块字段i_zone[7]是0,表明文件是首次使用间接块,
    // 则需申请一磁盘块用于存放间接块信息,并将此实际磁盘块号填入间接块字段中。然后
    // 设置i节点修改标志和修改时间。如果创建时申请磁盘块失败,则此时i节点间接块字段
    // i_zone[7] = 0,则返回0.或者不创建,但i_zone[7]原来就为0,表明i节点中没有间接块,
    // 于是映射磁盘是吧,则返回0退出。
    block -= 7;
    if (block<512) {
        if (create && !inode->i_zone[7])
            if ((inode->i_zone[7]=new_block(inode->i_dev))) {
                inode->i_dirt=1;
                inode->i_ctime=CURRENT_TIME;
            }
        if (!inode->i_zone[7])
            return 0;
        // 现在读取设备上该i节点的一次间接块。并取该间接块上第block项中的逻辑块号(盘块
        // 号)i。每一项占2个字节。如果是创建并且间接块的第block项中的逻辑块号为0的话,
        // 则申请一磁盘块,并让间接块中的第block项等于该新逻辑块块号。然后置位间接块的
        // 已修改标志。如果不是创建,则i就是需要映射(寻找)的逻辑块号。
        if (!(bh = bread(inode->i_dev,inode->i_zone[7])))
            return 0;
        i = ((unsigned short *) (bh->b_data))[block];
        if (create && !i)
            if ((i=new_block(inode->i_dev))) {
                ((unsigned short *) (bh->b_data))[block]=i;
                bh->b_dirt=1;
            }
        // 最后释放该间接块占用的缓冲块,并返回磁盘上新申请或原有的对应block的逻辑块号。
        brelse(bh);
        return i;
    }
    // 若程序运行到此,则表明数据块属于二次间接块。其处理过程与一次间接块类似。下面是对
    // 二次间接块的处理。首先将block再减去间接块所容纳的块数(512),然后根据是否设置了
    // 创建标志进行创建或寻找处理。如果是新创建并且i节点的二次间接块字段为0,则序申请一
    // 磁盘块用于存放二次间接块的一级信息,并将此实际磁盘块号填入二次间接块字段中。之后,
    // 置i节点已修改标志和修改时间。同样地,如果创建时申请磁盘块失败,则此时i节点二次
    // 间接块字段i_zone[8]为0,则返回0.或者不是创建,但i_zone[8]原来为0,表明i节点中没有
    // 间接块,于是映射磁盘块失败,返回0退出。
    block -= 512;
    if (create && !inode->i_zone[8])
        if ((inode->i_zone[8]=new_block(inode->i_dev))) {
            inode->i_dirt=1;
            inode->i_ctime=CURRENT_TIME;
        }
    if (!inode->i_zone[8])
        return 0;
    // 现在读取设备上该i节点的二次间接块。并取该二次间接块的一级块上第 block/512 项中
    // 的逻辑块号i。如果是创建并且二次间接块的一级块上第 block/512 项中的逻辑块号为0的
    // 话,则需申请一磁盘块(逻辑块)作为二次间接块的二级快i,并让二次间接块的一级块中
    // 第block/512 项等于二级块的块号i。然后置位二次间接块的一级块已修改标志。并释放
    // 二次间接块的一级块。如果不是创建,则i就是需要映射的逻辑块号。
    if (!(bh=bread(inode->i_dev,inode->i_zone[8])))
        return 0;
    i = ((unsigned short *)bh->b_data)[block>>9];
    if (create && !i)
        if ((i=new_block(inode->i_dev))) {
            ((unsigned short *) (bh->b_data))[block>>9]=i;
            bh->b_dirt=1;
        }
    brelse(bh);
    // 如果二次间接块的二级块块号为0,表示申请磁盘块失败或者原来对应块号就为0,则返回
    // 0退出。否则就从设备上读取二次间接块的二级块,并取该二级块上第block项中的逻辑块号。
    if (!i)
        return 0;
    if (!(bh=bread(inode->i_dev,i)))
        return 0;
    i = ((unsigned short *)bh->b_data)[block&511];
    // 如果是创建并且二级块的第block项中逻辑块号为0的话,则申请一磁盘块(逻辑块),作为
    // 最终存放数据信息的块。并让二级块中的第block项等于该新逻辑块块号(i)。然后置位二级块
    // 的已修改标志。
    if (create && !i)
        if ((i=new_block(inode->i_dev))) {
            ((unsigned short *) (bh->b_data))[block&511]=i;
            bh->b_dirt=1;
        }
    // 最后释放该二次间接块的二级块,返回磁盘上新申请的或原有的对应block的逻辑块号。
    brelse(bh);
    return i;
}

  通过上述的结构体,inode是管理起来了,但还是不够,还缺了一些属性,比如inode又多少了?那些被使用了?哪些还空着?块被锁定了么等等,为了继续管理这些属性,linux又创建了一个叫做super_block的结构体:

struct super_block {
    unsigned short s_ninodes;/*i节点数量*/
    unsigned short s_nzones;/*文件系统总长度:block < sb->s_firstdatazone || block >= sb->s_nzones*/
    unsigned short s_imap_blocks;/*i节点位图数量*/
    unsigned short s_zmap_blocks;/*数据块位图数量*/
    unsigned short s_firstdatazone;/*第一个块的位置:block < sb->s_firstdatazone || block >= sb->s_nzones*/
    unsigned short s_log_zone_size;
    unsigned long s_max_size;
    unsigned short s_magic;
    /* These are only in memory */
    struct buffer_head * s_imap[8];/*i node位图在高速缓存区的指针数组*/
    struct buffer_head * s_zmap[8];/*逻辑块位图在高速缓存区的指针数组*/
    unsigned short s_dev;/*设备号,可以通过该号找到超级块*/
    struct m_inode * s_isup;/*根目录的i node*/
    struct m_inode * s_imount; /*文件系统filesystem安装的i node*/
    unsigned long s_time;/*修改时间*/
    struct task_struct * s_wait;/*等待该块的进程*/
    unsigned char s_lock;/*是否被锁定*/
    unsigned char s_rd_only;/*是否只读*/
    unsigned char s_dirt;/*是否被修改*/
};

  Linux文件系统格式化时候,格式化上面三个区域:supper block, inode 与 block 的区块,假设某一个数据的属性与权限数据是放置到 inode 5 号,而这个 inode 记录了档案数据的实际放置点为 3,4,10 这四个 block 号码,此时我们的操作系统就能够据此来寻找数据了,称为索引式文件系统;上述的文字描述看起来可能有点抽象,这些属性之间的关系如下图所示:通过超级块检索数据块位图和inode块位图;再通过数据块位图检索数据块,inode块位图检索inode节点块!所以说抓住了超级块,就等于检索了整个文件系统!

  注意:
  下面图示中每个块的大小统一都是1024byte=1KB,所以一个数据块位图能表示1024*8=8192个数据块!每个数据块是1KB,单个超级块一共能管理8MB的磁盘空间!0.11这个版本一共用了8个超级块,能管理8*8MB=64MB的磁盘空间!
        block号是线性增加的,所以block号的计算方法(inode.c/read_node方法):block = 2 + sb->s_imap_blocks + sb->s_zmap_blocks + (inode->i_num-1)/INODES_PER_BLOCK;
  inode也是存放在快里面的,每块能存放inode节点数量计算公式:#define INODES_PER_BLOCK  ((BLOCK_SIZE)/(sizeof (struct d_inode)))

FluxBB bbcode 测试

  和task数组类似,linux仍然采用数组的形式统一集中管理所有超级块,这个版本一共设置了8个超级块:

// 超级块结构表数组(NR_SUPER = 8)
struct super_block super_block[NR_SUPER];
  通过遍历超级块数组、比对设备号找到超级块结构体;这里注意:linux常见的mount命令,本质就是把super_block的dev字段设置成对应的设备,让super_block关联上设备;然后把super_block读到高速缓存区,后续操作系统或应用程序直接读写该缓存区;最后把super_block的实例加入超级块数组,便于统一管理!

//// 取指定设备的超级块
// 在超级块表(数组)中搜索指定设备dev的超级块结构信息。若找到刚返回超级块的指针,
// 否则返回空指针
struct super_block * get_super(int dev)
{
    struct super_block * s;
    // 首先判断参数给出设备的有效性。若设备号为0则返回NULL,然后让s指向超级块数组
    // 起始处,开始搜索整个超级块数组,以寻找指定设备dev的超级块。
    if (!dev)
        return NULL;
    s = 0+super_block;
    while (s < NR_SUPER+super_block)
        // 如果当前搜索项是指定设备的超级块,即该超级块的设备号字段值与函数参数指定的
        // 相同,则先等待该超级块解锁。在等待期间,该超级块项有可能被其他设备使用,因此
        // 等待返回之后需要再判断一次是否是指定设备的超级块,如果是则返回该超级块的指针。
        // 否则就重新对超级块数组再搜索一遍,因此此时s需重又指向超级块数组开始处。
        if (s->s_dev == dev) {
            wait_on_super(s);
            if (s->s_dev == dev)
                return s;
            s = 0+super_block;
        // 如果当前搜索项不是,则检查下一项,如果没有找到指定的超级块,则返回空指针。
        } else
            s++;
    return NULL;
}

   2、上面的各种框架搭建好后,在正式填充和使用这些结构体之前,还需要完善位图工具,毕竟数据块和inode都涉及到位图块的使用了嘛!linux有个bitmap.c文件提供了大量的位图操作,比如:

  (1)clear_block:清空1024byte的内存,作用了memset完全一样!

//// 将指定地址(addr)处的一块1024字节内存清零
// 输入:eax = 0; ecx = 以字节为单位的数据块长度(BLOCK_SIZE/4);edi = 指定
// 起始地址addr。
#define clear_block(addr) \
__asm__ __volatile__ ("cld\n\t" \       // 清方向位
    "rep\n\t" \                         // 重复执行存储数据(0).
    "stosl" \
    ::"a" (0),"c" (BLOCK_SIZE/4),"D" ((long) (addr)))

   (2) 指定bit位置1,并返回原bit值;

//// 把指定地址开始的第nr个位偏移处的bit位置位(nr可大于321).返回原bit位值。
// 输入:%0-eax(返回值):%1 -eax(0);%2-nr,位偏移值;%3-(addr),addr的内容。
// res是一个局部寄存器变量。该变量将被保存在指定的eax寄存器中,以便于高效
// 访问和操作。这种定义变量的方法主要用于内嵌汇编程序中。详细说明可以参考
// gcc手册”在指定寄存器中的变量“。整个宏是一个语句表达式(即圆括号括住的组合句),
// 其值是组合语句中最后一条表达式语句res的值。
// btsl指令用于测试并设置bit位。把基地址(%3)和bit位偏移值(%2)所指定的bit位值
// 先保存到进位标志CF中,然后设置该bit位为1.指令setb用于根据进位标志CF设置
// 操作数(%al)。如果CF=1则%al = 1,否则%al = 0。
#define set_bit(nr,addr) ({\
register int res ; \
__asm__ __volatile__("btsl %2,%3\n\tsetb %%al": \
"=a" (res):"0" (0),"r" (nr),"m" (*(addr))); \
res;})

   相应的,也有对指定bit清0的方法:

//// 复位指定地址开始的第nr位偏移处的bit位。返回原bit位值的反码。
// 输入:%0-eax(返回值);%1-eax(0);%2-nr,位偏移值;%3-(addr),addr的内容。
// btrl指令用于测试并复位bit位。其作用与上面的btsl类似,但是复位指定bit位。
// 指令setnb用于根据进位标志CF设置操作数(%al).如果CF=1则%al=0,否则%al=1.
#define clear_bit(nr,addr) ({\
register int res ; \
__asm__ __volatile__("btrl %2,%3\n\tsetnb %%al": \
"=a" (res):"0" (0),"r" (nr),"m" (*(addr))); \
res;})

  (3)从指定地址开始寻找第一个bit为0的位,目的就是找第一个没被用的块;

//// 从addr开始寻找第1个0值bit位。
// 输入:%0-ecx(返回值);%1-ecx(0); %2-esi(addr).
// 在addr指定地址开始的位图中寻找第1个是0的bit位,并将其距离addr的bit位偏移
// 值返回。addr是缓冲块数据区的地址,扫描寻找的范围是1024字节(8192bit位)。
#define find_first_zero(addr) ({ \
int __res; \
__asm__ __volatile__ ("cld\n" \         // 清方向位
    "1:\tlodsl\n\t" \                   // 取[esi]→eax.
    "notl %%eax\n\t" \                  // eax中每位取反。
    "bsfl %%eax,%%edx\n\t" \            // 从位0扫描eax中是1的第1个位,其偏移值→edx
    "je 2f\n\t" \                       // 如果eax中全是0,则向前跳转到标号2处。
    "addl %%edx,%%ecx\n\t" \            // 偏移值加入ecx(ecx是位图首个0值位的偏移值)
    "jmp 3f\n" \                        // 向前跳转到标号3处
    "2:\taddl $32,%%ecx\n\t" \          // 未找到0值位,则将ecx加1个字长的位偏移量32
    "cmpl $8192,%%ecx\n\t" \            // 已经扫描了8192bit位(1024字节)
    "jl 1b\n" \                         // 若还没有扫描完1块数据,则向前跳转到标号1处
    "3:" \                              // 结束。此时ecx中是位偏移量。
    :"=c" (__res):"c" (0),"S" (addr)); \
__res;})

  3、光有工具还不够,要先生成超级块、inode位图和数据块才能运营整个文件系统,不是么?所以还要先建inode:

//// 为设备dev建立一个新i节点。初始化并返回该新i节点的指针。
// 在内存i节点表中获取一个空闲i节点表项,并从i节点位图中找一个空闲i节点。
struct m_inode * new_inode(int dev)
{
    struct m_inode * inode;
    struct super_block * sb;
    struct buffer_head * bh;
    int i,j;

    // 首先从内存i节点表(inode_table)中获取一个空闲i节点项,并读取指定设备的
    // 超级块结构。然后扫描超级块中8块i节点位图,寻找首个0bit位,寻找空闲节点,
    // 获取放置该i节点的节点号。如果全部扫描完还没找到,或者位图所在的缓冲块无效
    // (bh=NULL),则放回先前申请的i节点表中的i节点,并返回NULL退出(没有空闲的i节点)。
    if (!(inode=get_empty_inode()))
        return NULL;
    if (!(sb = get_super(dev)))
        panic("new_inode with unknown device");
    j = 8192;
    for (i=0 ; i<8 ; i++)
        if ((bh=sb->s_imap[i]))
            if ((j=find_first_zero(bh->b_data))<8192)
                break;
    if (!bh || j >= 8192 || j+i*8192 > sb->s_ninodes) {
        iput(inode);
        return NULL;
    }
    // 现在我们已经找到了还未使用的i节点号j。于是置位i节点j对应的i节点位图相应bit位。
    // 然后置i节点位图所在缓冲块已修改标志。最后初始化该i节点结构(i_ctime是i节点内容改变时间)。
    if (set_bit(j,bh->b_data))
        panic("new_inode: bit already set");
    bh->b_dirt = 1;
    inode->i_count=1;                           // 引用计数
    inode->i_nlinks=1;                          // 文件目录项连接数
    inode->i_dev=dev;                           // i节点所在的设备号
    inode->i_uid=current->euid;                 // i节点所属用户ID
    inode->i_gid=current->egid;                 // 组id
    inode->i_dirt=1;                            // 已修改标志置位
    inode->i_num = j + i*8192;                  // 对应设备中的i节点号
    inode->i_mtime = inode->i_atime = inode->i_ctime = CURRENT_TIME;
    return inode;
}

  上述方法调用了get_empty_inode,核心思想是从inode_table中找空闲的inode,主要依靠判断i_count、i_dirt、i_lock这3个字段:

//// 从i节点表(inode_table)中获取一个空闲i节点项。
// 寻找引用计数count为0的i节点,并将其写盘后清零,返回指针。引用计数被置1.
struct m_inode * get_empty_inode(void)
{
    struct m_inode * inode;
    static struct m_inode * last_inode = inode_table;
    int i;

    do {
        // 在初始化last_inode指针指向i节点表头一项后循环扫描整个i节点表。如果last_inode
        // 已经指向i节点表的最后一项之后,则让其重新指向i节点表开始处,以继续循环寻找空闲
        // i节点项。如果last_inode所指向的i节点的计数值为0,则说明可能找到空闲i节点项。
        // 让inode指向该i节点。如果该i节点的已修改标志和锁定标志均为0,则我们可以使用该i
        // 节点,于是退出for循环。
        inode = NULL;
        for (i = NR_INODE; i ; i--) {
            if (++last_inode >= inode_table + NR_INODE)
                last_inode = inode_table;
            if (!last_inode->i_count) {
                inode = last_inode;
                if (!inode->i_dirt && !inode->i_lock)
                    break;
            }
        }
        // 如果没有找到空闲i节点(inode=NULL),则将i节点表打印出来供调试使用,并停机。
        if (!inode) {
            for (i=0 ; i<NR_INODE ; i++)
                printk("%04x: %6d\t",inode_table[i].i_dev,
                    inode_table[i].i_num);
            panic("No free inodes in mem");
        }
        // 等待该i节点解锁,如果该i节点已修改标志被置位的话,则将该i节点刷新,因为刷新时
        // 可能会睡眠,因此需要再次循环等待该i节点解锁。
        wait_on_inode(inode);
        while (inode->i_dirt) {
            write_inode(inode);
            wait_on_inode(inode);
        }
        // 如果i节点又被其他占用的话(i节点的计数值不为0了),则重新寻找空闲i节点。否则
        // 说明已找到符合要求的空闲i节点项。则将该i节点项内容清零,并置引用计数为1,
        // 返回该i节点指针。
    } while (inode->i_count);
    memset(inode,0,sizeof(*inode));
    inode->i_count = 1;
    return inode;
}

  用完后可以释放:注意看最后一行调用了memset,直接把整个inode节点存的数据全部清零!(这里可以对比后续的iput方法,只是执行了inode->i_count--,把引用计数减一,并未清空inode的任何数据!)

//// 释放指定的i节点
// 该函数首先判断参数给出的i节点号的有效性和课释放性。若i节点仍然在使用中则不能
// 被释放。然后利用超级块信息对i节点位图进行操作,复位i节点号对应的i节点位图中
// bit位,并清空i节点结构。
void free_inode(struct m_inode * inode)
{
    struct super_block * sb;
    struct buffer_head * bh;

    // 首先判断参数给出的需要释放的i节点有效性或合法性。如果i节点指针=NULL,则
    // 退出。如果i节点上的设备号字段为0,则说明该节点没有使用。于是用0清空对应i
    // 节点所占内存区并返回。memset()定义在include/string.h中,这里表示用0填写
    // inode指针指定处、长度是sizeof(*inode)的内存快。
    if (!inode)
        return;
    if (!inode->i_dev) {
        memset(inode,0,sizeof(*inode));
        return;
    }
    // 如果此i节点还有其他程序引用,则不能释放,说明内核有问题,停机。如果文件
    // 连接数不为0,则表示还有其他文件目录项在使用该节点,因此也不应释放,而应该放回等。
    if (inode->i_count>1) {
        printk("trying to free inode with count=%d\n",inode->i_count);
        panic("free_inode");
    }
    if (inode->i_nlinks)
        panic("trying to free inode with links");
    // 在判断完i节点的合理性之后,我们开始利用超级块信息对其中的i节点位图进行
    // 操作。首先取i节点所在设备的超级块,测试设备是否存在。然后判断i节点号的
    // 范围是否正确,如果i节点号等于0或大于该设备上i节点总数,则出错(0号i节点
    // 保留没有使用)。如果该i节点对应的节点位图不存在,则出错。因为一个缓冲块
    // 的i节点位图有8192 bit。因此i_num>>13(即i_num/8192)可以得到当前i节点所在
    // 的s_imap[]项,即所在盘块。
    if (!(sb = get_super(inode->i_dev)))
        panic("trying to free inode on nonexistent device");
    if (inode->i_num < 1 || inode->i_num > sb->s_ninodes)
        panic("trying to free inode 0 or nonexistant inode");
    if (!(bh=sb->s_imap[inode->i_num>>13]))
        panic("nonexistent imap in superblock");
    // 现在我们复位i节点对应的节点位图中的bit位。如果该bit位已经等于0,则显示
    // 出错警告信息。最后置i节点位图所在缓冲区已修改标志,并清空该i节点结构
    // 所占内存区。
    if (clear_bit(inode->i_num&8191,bh->b_data))
        printk("free_inode: bit already cleared.\n\r");
    bh->b_dirt = 1;
    memset(inode,0,sizeof(*inode));
}

  4、由于数据都是先存在高速缓存区,不会直接读写磁盘,所以此时要先在高速缓存区新建块,这里面就涉及到了上面的位图操作!

//// 向设备申请一个逻辑块。
// 函数首先取得设备的超级块,并在超级块中的逻辑块位图中寻找第一个0值bit位(代表一个
// 空闲逻辑块)。然后位置对应逻辑块在逻辑块位图中的bit位。接着为该逻辑块在缓冲区中取得
// 一块对应缓冲块。最后将该缓冲块清零,并设置其已更新标志和已修改标志。并返回逻辑块
// 号。函数执行成功则返回逻辑块号,否则返回0.
int new_block(int dev)
{
    struct buffer_head * bh;
    struct super_block * sb;
    int i,j;

    // 首先获取设备dev的超级块。如果指定设备的超级块不存在,则出错当机。然后扫描
    // 文件系统的8块逻辑位图,寻找首个0值bit位,以寻找空闲逻辑块,获取放置该逻辑块的
    // 块号。如果全部扫描完8块逻辑块位图的所有bit位(i >= 8 或 j >= 8192)还没找到0值
    // bit位或者位图所在的缓冲块指针无效(bh=NULL)则返回0退出(没有空闲逻辑块)。
    if (!(sb = get_super(dev)))
        panic("trying to get new block from nonexistant device");
    j = 8192;
    for (i=0 ; i<8 ; i++)
        if ((bh=sb->s_zmap[i]))
            if ((j=find_first_zero(bh->b_data))<8192)
                break;
    if (i>=8 || !bh || j>=8192)
        return 0;
    // 接着设置找到的新逻辑块j对应逻辑块位图中的bit位。若对应bit位已经置位,则出错
    // 停机。否则置存放位图的对应缓冲区块已修改标志。因为逻辑块位图仅表示盘上数据区
    // 中逻辑块的占用情况,则逻辑块位图中bit位偏移值表示从数据区开始处算起的块号,
    // 因此这里需要加上数据区第1个逻辑块的块号,把j转换成逻辑块号。此时如果新逻辑块
    // 大于该设备上的总逻辑块数,则说明指定逻辑块在对应设备上不存在。申请失败,返回0退出。
    if (set_bit(j,bh->b_data))
        panic("new_block: bit already set");
    bh->b_dirt = 1;
    j += i*8192 + sb->s_firstdatazone-1;
    if (j >= sb->s_nzones)
        return 0;
    // 然后在高速缓冲区中为该设备上指定的逻辑块号取得一个缓冲块,并返回缓冲块头指针。
    // 因为刚取得的逻辑块其引用次数一定为1(getblk()中会设置),因此若不为1则停机。
    // 最后将新逻辑块清零,并设置其已更新标志和已修改标志。然后释放对应缓冲块,返回
    // 逻辑块号。
    if (!(bh=getblk(dev,j)))
        panic("new_block: cannot get block");
    if (bh->b_count != 1)
        panic("new block: count is != 1");
    clear_block(bh->b_data);
    bh->b_uptodate = 1;
    bh->b_dirt = 1;
    brelse(bh);
    return j;
}

  块用完后也需要释放,如下:先把位图对应的位置清0,再把高速缓存区对应的块释放;

//// 释放设备dev上数据区中的逻辑块block.
// 复位指定逻辑块block对应的逻辑块位图bit位
// 参数:dev是设备号,block是逻辑块号(盘块号)
void free_block(int dev, int block)
{
    struct super_block * sb;
    struct buffer_head * bh;

    // 首先取设备dev上文件系统的超级块信息,根据其中数据区开始逻辑块号和文件系统中逻辑
    // 块总数信息判断参数block的有效性。如果指定设备超级块不存在,则出错当机。若逻辑块
    // 号小于盘上面数据区第一个逻辑块的块号或者大于设备上总逻辑块数,也出错当机。
    if (!(sb = get_super(dev)))
        panic("trying to free block on nonexistent device");
    if (block < sb->s_firstdatazone || block >= sb->s_nzones)
        panic("trying to free block not in datazone");
    // 然后从hash表中寻找该块数据。若找到了则判断其有效性,并清已修改和更新标志,释放
    // 该数据块。该段代码的主要用途是如果该逻辑块目前存在于高速缓冲区中,就释放对应
    // 的缓冲块。
    bh = get_hash_table(dev,block);
    // 下面的代码会造成数据块不能释放。因为当b_count > 1时,这段代码会仅打印一段信息而
    // 没有执行释放操作。
    if (bh) {
        if (bh->b_count != 1) {
            printk("trying to free block (%04x:%d), count=%d\n",
                dev,block,bh->b_count);
            return;
        }
        bh->b_dirt=0;
        bh->b_uptodate=0;
        brelse(bh);
    }
    // 接着我们复位block在逻辑块位图中的bit(置0),先计算block在数据区开始算起的数据
    // 逻辑块号(从1开始计数)。然后对逻辑块(区块)位图进行操作,复位对应的bit位。如果对应
    // bit位原来就是0,则出错停机。由于1个缓冲块有1024字节,即8192比特位,因此block/8192
    // 即可计算出指定块block在逻辑位图中的哪个块上。而block&8192可以得到block在逻辑块位图
    // 当前块中的bit偏移位置。,不用担心偏移超出8191的范围。
    block -= sb->s_firstdatazone - 1 ;
    if (clear_bit(block&8191,sb->s_zmap[block/8192]->b_data)) {
        printk("block (%04x:%d) ",dev,block+sb->s_firstdatazone-1);
        panic("free_block: bit already cleared");
    }
    // 最后置相应逻辑块位图所在缓冲区已修改标志。
    sb->s_zmap[block/8192]->b_dirt = 1;
}

  注意:上述的各种块操作,都是针对内存中的高速缓存区,并未直接操作磁盘!

   5、(1)前面建好了block和inode,至此终于可以开始读写数据了,比如这里的write_inode函数:

//// 将i节点信息写入缓冲区中。
// 该函数把参数指定的i节点写入缓冲区相应的缓冲块中,待缓冲区刷新时会写入盘中。为了确定i节点
// 所在的设备逻辑块号(或缓冲块),必须首先读取相应设备上的超级块,以获取用于计算逻辑块号的
// 每块i节点数信息INODES_PER_BLOCK。在计算出i节点所在的逻辑块号后,就把该逻辑块读入一缓冲块
// 中。然后把i节点内容复制到缓冲块的相应位置处。
static void write_inode(struct m_inode * inode)
{
    struct super_block * sb;
    struct buffer_head * bh;
    int block;

    // 首先锁定该i节点,如果该i节点没有被修改或者该i节点的设备号等于零,则解锁该i节点,并退出。
    // 对于没有被修改过的i节点,其内容与缓冲区中或设备中的相同。然后获取该i节点的超级块。
    lock_inode(inode);
    if (!inode->i_dirt || !inode->i_dev) {
        unlock_inode(inode);
        return;
    }
    if (!(sb=get_super(inode->i_dev)))
        panic("trying to write inode without device");
    // 该i节点所在的设备逻辑块号=(启动块+超级块)+i节点位图占用的块数+逻辑块位图占用的块数
    // +(i节点号-1)/每块含有的i节点数。我们从设备上读取i节点所在的逻辑块,并将该i节点信息复制
    // 到逻辑块对应i节点的项位置处。
    block = 2 + sb->s_imap_blocks + sb->s_zmap_blocks + (inode->i_num-1)/INODES_PER_BLOCK;
    if (!(bh=bread(inode->i_dev,block)))
        panic("unable to read i-node block");
    ((struct d_inode *)bh->b_data)
        [(inode->i_num-1)%INODES_PER_BLOCK] =
            *(struct d_inode *)inode;
    // 然后置缓冲区已修改标志,而i节点内容已经与缓冲区中的一致,因此修改标志置零。然后释放该
    // 含有i节点的缓冲区,并解锁该i节点。
    bh->b_dirt=1;
    inode->i_dirt=0;
    brelse(bh);
    unlock_inode(inode);
}

  注意:上面的write函数并未直接把数据写入磁盘,而是先写入了缓存区,所以linux又提供了专门同步的接口,如下:这两个函数最终都调用了ll_rw_block方法向磁盘写数据!其中sys_sync还是个系统调用了!

//// 设备数据同步,这是个系统调用;
// 同步设备和内存高速缓冲中数据,其中sync_inode()定义在inode.c中。
// 把内存中高速缓存区的数写回到磁盘,需要调用磁盘的驱动代码
int sys_sync(void)
{
    int i;
    struct buffer_head * bh;

    // 首先调用i节点同步函数,把内存i节点表中所有修改过的i节点写入高速缓冲中。
    // 然后扫描所有高速缓冲区,对已被修改的缓冲块产生写盘请求,将缓冲中数据写入
    // 盘中,做到高速缓冲中的数据与设备中的同步。
    sync_inodes();        /* write out inodes into buffers */
    bh = start_buffer;
    for (i=0 ; i<NR_BUFFERS ; i++,bh++) {
        wait_on_buffer(bh);                 // 等待缓冲区解锁(如果已经上锁的话)
        if (bh->b_dirt)
            ll_rw_block(WRITE,bh);          // 产生写设备块请求
    }
    return 0;
}

//// 对指定设备进行高速缓冲数据与设备上数据的同步操作
// 该函数首先搜索高速缓冲区所有缓冲块。对于指定设备dev的缓冲块,若其数据已经
// 被修改过就写入盘中(同步操作)。然后把内存中i节点表数据写入 高速缓冲中。之后
// 再对指定设备dev执行一次与上述相同的写盘操作。
int sync_dev(int dev)
{
    int i;
    struct buffer_head * bh;

    // 首先对参数指定的设备执行数据同步操作,让设备上的数据与高速缓冲区中的数据
    // 同步。方法是扫描高速缓冲区中所有缓冲块,对指定设备dev的缓冲块,先检测其
    // 是否已被上锁,若已被上锁就睡眠等待其解锁。然后再判断一次该缓冲块是否还是
    // 指定设备的缓冲块并且已修改过(b_dirt标志置位),若是就对其执行写盘操作。
    // 因为在我们睡眠期间该缓冲块有可能已被释放或者被挪作他用,所以在继续执行前
    // 需要再次判断一下该缓冲块是否还是指定设备的缓冲块。
    bh = start_buffer;
    for (i=0 ; i<NR_BUFFERS ; i++,bh++) {
        if (bh->b_dev != dev)               // 不是设备dev的缓冲块则继续
            continue;
        wait_on_buffer(bh);                     // 等待缓冲区解锁
        if (bh->b_dev == dev && bh->b_dirt)
            ll_rw_block(WRITE,bh); //lowlevel
    }
    // 再将i节点数据吸入高速缓冲。让i节点表inode_table中的inode与缓冲中的信息同步。
    sync_inodes();
    // 然后在高速缓冲中的数据更新之后,再把他们与设备中的数据同步。这里采用两遍同步
    // 操作是为了提高内核执行效率。第一遍缓冲区同步操作可以让内核中许多"脏快"变干净,
    // 使得i节点的同步操作能够高效执行。本次缓冲区同步操作则把那些由于i节点同步操作
    // 而又变脏的缓冲块与设备中数据同步。
    bh = start_buffer;
    for (i=0 ; i<NR_BUFFERS ; i++,bh++) {
        if (bh->b_dev != dev)
            continue;
        wait_on_buffer(bh);
        if (bh->b_dev == dev && bh->b_dirt)
            ll_rw_block(WRITE,bh);
    }
    return 0;
}

  (2)同理,也有读read_inode的函数:

//// 读取指定i节点信息。
// 从设备上读取含有指定i节点信息的i节点盘块,然后复制到指定的i节点结构中。为了确定i节点
// 所在的设备逻辑块号(或缓冲块),必须首先读取相应设备上的超级块,以获取用于计算逻辑
// 块号的每块i节点数信息INODES_PER_BLOCK.在计算出i节点所在的逻辑块号后,就把该逻辑块读入
// 一缓冲块中。然后把缓冲块中相应位置处的i节点内容复制到参数指定的位置处。
static void read_inode(struct m_inode * inode)
{
    struct super_block * sb;
    struct buffer_head * bh;
    int block;

    // 首先锁定该i节点,并取该节点所在设备的超级块。
    lock_inode(inode);
    if (!(sb=get_super(inode->i_dev)))
        panic("trying to read inode without dev");
    // 该i节点所在的设备逻辑块号=(启动块+超级块)+i节点位图占用的块数+逻辑块位图占用的块数
    // +(i节点号-1)/每块含有的i节点数。虽然i节点号从0开始编号,但第i个0号i节点不用,并且
    // 磁盘上也不保存对应的0号i节点结构。因此存放i节点的盘块的第i块上保存的是i节点号是1--16
    // 的i节点结构而不是0--15的。因此在上面计算i节点号对应的i节点结构所在盘块时需要减1,即:
    // B=(i节点号-1)/每块含有i节点结构数。例如,节点号16的i节点结构应该在B=(16-1)/16 = 0的
    // 块上。这里我们从设备上读取该i节点所在的逻辑块,并复制指定i节点内容到inode指针所指位置处。
    block = 2 + sb->s_imap_blocks + sb->s_zmap_blocks +
        (inode->i_num-1)/INODES_PER_BLOCK;
    if (!(bh=bread(inode->i_dev,block)))
        panic("unable to read i-node block");
    *(struct d_inode *)inode =
        ((struct d_inode *)bh->b_data)
            [(inode->i_num-1)%INODES_PER_BLOCK];
    // 最后释放读入的缓冲块,并解锁该i节点。
    brelse(bh);
    unlock_inode(inode);
}

  (3)inode用完后,并不是直接调用free_inode去清零inode节点的数据,而是先把i_count计数减一,如果计数是0了,再清零inode节点的数据,如下:

//// 放回(放置)一个i节点引用计数值递减1,并且若是管道i节点,则唤醒等待的进程。
// 若是块设备文件i节点则刷新设备。并且若i节点的链接计数为0,则释放该i节点占用
// 的所有磁盘逻辑块,并释放该i节点。
void iput(struct m_inode * inode)
{
    // 首先判断参数给出的i节点的有效性,并等待inode节点解锁,如果i节点的引用计数
    // 为0,表示该i节点已经是空闲的。内核再要求对其进行放回操作,说明内核中其他
    // 代码有问题。于是显示错误信息并停机。
    if (!inode)
        return;
    wait_on_inode(inode);
    if (!inode->i_count)
        panic("iput: trying to free free inode");
    // 如果是管道i节点,则唤醒等待该管道的进程,引用次数减1,如果还有引用则返回。
    // 否则释放管道占用的内存页面,并复位该节点的引用计数值、已修改标志和管道标志,
    // 并返回。对于管道节点,inode->i_size存放这内存也地址。
    if (inode->i_pipe) {
        wake_up(&inode->i_wait);
        if (--inode->i_count)
            return;
        free_page(inode->i_size);
        inode->i_count=0;
        inode->i_dirt=0;
        inode->i_pipe=0;
        return;
    }
    // 如果i节点对应的设备号 = 0,则将此节点的引用计数递减1,返回。例如用于管道操作
    // 的i节点,其i节点的设备号为0.
    if (!inode->i_dev) {
        inode->i_count--;
        return;
    }
    // 如果是块设备文件的i节点,此时逻辑块字段0(i_zone[0])中是设备号,则刷新该设备。
    // 并等待i节点解锁。
    if (S_ISBLK(inode->i_mode)) {
        sync_dev(inode->i_zone[0]);
        wait_on_inode(inode);
    }
    // 如果i节点的引用计数大于1,则计数递减1后就直接返回(因为该i节点还有人在用,不能
    // 释放),否则就说明i节点的引用计数值为1。如果i节点的链接数为0,则说明i节点对应文件
    // 被删除。于是释放该i节点的所有逻辑块,并释放该i节点。函数free_inode()用于实际释
    // 放i节点操作,即复位i节点对应的i节点位图bit位,清空i节点结构内容。
repeat:
    if (inode->i_count>1) {
        inode->i_count--;
        return;
    }
    if (!inode->i_nlinks) {
        truncate(inode);
        free_inode(inode);
        return;
    }
    // 如果该i节点已做过修改,则回写更新该i节点,并等待该i节点解锁。由于这里在写i节点
    // 时需要等待睡眠,此时其他进程有可能修改i节点,因此在进程被唤醒后需要再次重复进行
    // 上述判断过程(repeat)。
    if (inode->i_dirt) {
        write_inode(inode);    /* we can sleep - so do again */
        wait_on_inode(inode);
        goto repeat;
    }
    // 程序若能执行到此,则说明该i节点的引用计数值i_count是1、链接数不为零,并且内容
    // 没有被修改过。因此此时只要把i节点引用计数递减1,返回。此时该i节点的i_count=0,
    // 表示已释放。
    inode->i_count--;
    return;
}

参考:

1、https://www.jianshu.com/p/9ef6542ced92  Linux文件系统和inode

2、https://blog.csdn.net/YuZhiHui_No1/article/details/43951153   Linux内核源码分析--文件系统

#407 文件系统模块 » linux源码解读(二):文件系统——高速缓存区 » 2022-10-08 12:21:09

batsom
回复: 0

用户的应用程序会经常读写磁盘文件的数据到内存,但是内存的速度和磁盘的速度理论上差了好几个数量级;为了更高效地解决内存和磁盘的速度差,linux也在内存使用了缓存区(作用类似于cpu内部为了解决寄存器和内存速度差异的的L1、L2、L3 cache):如果数据要写入磁盘文件,先放在缓存区,等凑够了一定数量后再批量写入磁盘文件,借此减少磁盘寻址的次数,来提升写入效率(这里多说几句:比如U盘插上电脑后,如果要拔出,建议先卸载再拔出,而不是直接拔出,为啥了?U盘的数据也是先放入缓冲区的,缓冲区有自己的管理机制,很久没有使用的块可以给其他进程使用,如果是脏块则要进行写盘。缓冲在某些情况下才会有写盘操作,所以要拔出U盘时,应该先进行卸载,这样才会写盘,否则数据可能丢失,文件系统可能损坏。);如果从磁盘读数据,也会先放入缓存区暂存,一旦有其他进程或线程读取同样的磁盘文件,这是就可以先从内存的缓存区取数据了,没必要重新从磁盘读取,也提升了效率!linux 0.11的缓冲区是怎么工作的了?

  在main.c的main函数中,有设置缓存区的大小,代码如下:内存不同,缓存区的大小也不同,linux是怎么管理和使用这些缓存区了的?

void main(void)        /* This really IS void, no error here. */
{            /* The startup routine assumes (well, ...) this */
/*
 * Interrupts are still disabled. Do necessary setups, then
 * enable them
 */
//前面这里做的所有事情都是在对内存进行拷贝
     ROOT_DEV = ORIG_ROOT_DEV;//设置操作系统的根文件
     drive_info = DRIVE_INFO;//设置操作系统驱动参数
     //解析setup.s代码后获取系统内存参数
    memory_end = (1<<20) + (EXT_MEM_K<<10);
    //取整4k的内存大小
    memory_end &= 0xfffff000;
    if (memory_end > 16*1024*1024)//控制操作系统的最大内存为16M
        memory_end = 16*1024*1024;
    if (memory_end > 12*1024*1024) 
        buffer_memory_end = 4*1024*1024;//设置高速缓冲区的大小,跟块设备有关,跟设备交互的时候,充当缓冲区,写入到块设备中的数据先放在缓冲区里,只有执行sync时才真正写入;这也是为什么要区分块设备驱动和字符设备驱动;块设备写入需要缓冲区,字符设备不需要是直接写入的
    else if (memory_end > 6*1024*1024)
        buffer_memory_end = 2*1024*1024;
    else
        buffer_memory_end = 1*1024*1024;
    main_memory_start = buffer_memory_end;

  1、cpu有分页机制,硬件上以4KB为单位把内存分割成小块供程序使用;这个颗粒度是比较大的,有些时候可能会浪费比较多的内存,所以linux缓存区采用了1KB的大小来分割整个缓存区;假设缓存区有2MB,那么一共被分割成了2000个小块,这么多的缓存区该怎么管理了?

  每个缓存都有各自的属性,比如是否使用、数据是否更新、缓存数据在磁盘的位置、缓存的起始地址等,要想统一管理这么多的属性,最好的办法自然是构建结构体了;一个结构体管理1块(也就是1KB)的缓存区;假设这里有2000个缓存区,就需要2000个结构体,那么问题又来了:这个多的结构体,又该怎么去管理了?

  参考前面的进程task结构体管理方式:用task数组来管理所有的进程task结构体,最大限制为64个进程,但是放在这里显然不适用:不同机器的物理内存大小是不同的,导致缓存区的block数量是不同的,但数组最大的缺点就是定长,无法适应不同的物理内存,那么这里最适合的只剩链表了,所以linux 0.11版本使用的结构体如下:

struct buffer_head {
    char * b_data;            /* pointer to data block (1024 bytes):单个数据块大小1KB */
    unsigned long b_blocknr;    /* block number */
    unsigned short b_dev;        /* device (0 = free) */
    unsigned char b_uptodate;    /*数据是否更新*/
    unsigned char b_dirt;        /* 0-clean空现,1-dirty已被占用*/
    unsigned char b_count;        /* users using this block */
    /*如果缓冲区的某个block被锁,上层应用是没法从这个block对应的磁盘空间读数据的,这里有个漏洞:
    A进程锁定了某block,B进程想办法解锁,然后就能监听A进程从磁盘读写了哪些数据
    */
    unsigned char b_lock;        /* 0 - ok, 1 -locked:锁用于多进程/多线程之间同步,避免数据出错*/
    struct task_struct * b_wait;/*A正在使用这个缓存,并已经锁定;B也想用,就用这个字段记录;等A用完后从这里找到B再给B用*/
    struct buffer_head * b_prev;
    struct buffer_head * b_next;
    struct buffer_head * b_prev_free;
    struct buffer_head * b_next_free;
};

  每个字段的含义都在注释了,这里不再赘述;既然采用了链表,解决了数组只能定长的缺点,但是链表本身也有缺点:无法直接找到目标实例,需要挨个遍历链表上的每个节点;还是假设有2000个块,好巧的不巧的是程序所需的block刚好在最后一个节点,那么需要遍历1999个节点才能到达,效率非常低,这又该怎么解决了?刚好这种快速寻址(时间复杂度O(1))是数组的优势,怎么解决数组和链表各自的优势了?-----hash表!

  linux 0.11版本采用hash表的方式快速寻址,hash映射算法如下:

// hash表的主要作用是减少查找比较元素所花费的时间。通过在元素的存储位置与关
// 键字之间建立一个对应关系(hash函数),我们就可以直接通过函数计算立刻查询到指定
// 的元素。建立hash函数的指导条件主要是尽量确保散列在任何数组项的概率基本相等。
// 建立函数的方法有多种,这里Linux-0.11主要采用了关键字除留余数法。因为我们
// 寻找的缓冲块有两个条件,即设备号dev和缓冲块号block,因此设计的hash函数肯定
// 需要包含这两个关键值。这两个关键字的异或操作只是计算关键值的一种方法。再对
// 关键值进行MOD运算就可以保证函数所计算得到的值都处于函数数组项范围内。
#define _hashfn(dev,block) (((unsigned)(dev^block))%NR_HASH)
#define hash(dev,block) hash_table[_hashfn(dev,block)]

  映射的算法也很简单:每个buffer_head结构体都有dev和block两个字段,这两个字段组合起来本身是不会重复的,所以把这两个字段异或后模上hash表的长度,就得到了hash数组的偏移;现在问题又来了:这个版本的hash_table数组长度设定为NR_HASH=307,远不如buffer_head的实例个数,肯定会发生hash冲突,这个该怎么解决了?--这里就要用上链表变长的优点了:把发生hash冲突的bufer_head实例首位相接不久得了么?最终的hash_table示意图如下:hash表本身用数组,存储buffer_head实例的地址;如果发生hash冲突,相同hash偏移的实例通过b_next和b_prev链表首尾连接!

FluxBB bbcode 测试

   当这个一整套存储机制建立后,怎么检索了?linux的检索方式如下:先通过dev和block号定位到hash表的偏移,再遍历该偏移处的所有节点,通过比对dev和block号找到目标buffer_head实例!

//// 利用hash表在高速缓冲区中寻找给定设备和指定块号的缓冲区块。
// 如果找到则返回缓冲区块的指针,否则返回NULL。
static struct buffer_head * find_buffer(int dev, int block)
{        
    struct buffer_head * tmp;

    // 搜索hash表,寻找指定设备号和块号的缓冲块。
    for (tmp = hash(dev,block) ; tmp != NULL ; tmp = tmp->b_next)
        if (tmp->b_dev==dev && tmp->b_blocknr==block)
            return tmp;
    return NULL;
}

   根据dev和block号找到缓存区的buffer_head并不代表万事大吉,因为该缓存区可能已经被其他进程/线程占用,当前线程如果一定要用这个缓存区,只能等了,所以最终查找缓存区的代码如下:这里增加了wait_on_buffer函数:

//// 利用hash表在高速缓冲区中寻找指定的缓冲块。若找到则对该缓冲块上锁
// 返回块头指针。
struct buffer_head * get_hash_table(int dev, int block)
{
    struct buffer_head * bh;

    for (;;) {
        // 在高速缓冲中寻找给定设备和指定块的缓冲区块,如果没有找到则返回NULL。
        if (!(bh=find_buffer(dev,block)))
            return NULL;
        // 对该缓冲块增加引用计数,并等待该缓冲块解锁。由于经过了睡眠状态,其他任务可能会更改这个缓存区对应的dev和block号
        // 因此有必要在验证该缓冲块的正确性,并返回缓冲块头指针。
        bh->b_count++;
        wait_on_buffer(bh);
        if (bh->b_dev == dev && bh->b_blocknr == block)
            return bh;
        // 如果在睡眠时该缓冲块所属的设备号或块设备号发生了改变,则撤消对它的
        // 引用计数,重新寻找。
        bh->b_count--;
    }
}

  wait_on_buffer函数实现:如果发现该缓存区已经上锁,那么调用sleep_on函数让出cpu,阻塞在这里等待;这个sleep_on函数传入的参数是二级指针,并且内部用了tmp变量保存临时变量;由于二级指针是全局的,所以如果有多个task等待同一个缓存区,sleep_on函数是通过先进后出的栈的形式唤醒等待任务的;参考1有详细的说明,感兴趣的小伙伴建议好好看看!

//// 等待指定缓冲块解锁
// 如果指定的缓冲块bh已经上锁就让进程不可中断地睡眠在该缓冲块的等待队列b_wait中。
// 在缓冲块解锁时,其等待队列上的所有进程将被唤醒。虽然是在关闭中断(cli)之后
// 去睡眠的,但这样做并不会影响在其他进程上下文中影响中断。因为每个进程都在自己的
// TSS段中保存了标志寄存器EFLAGS的值,所以在进程切换时CPU中当前EFLAGS的值也随之
// 改变。使用sleep_on进入睡眠状态的进程需要用wake_up明确地唤醒。
static inline void wait_on_buffer(struct buffer_head * bh)
{
    cli();                          // 关中断
    while (bh->b_lock)              // 如果已被上锁则进程进入睡眠,等待其解锁
        sleep_on(&bh->b_wait);
    sti();                          // 开中断
}

   先进后出的栈形式唤醒等待任务:
FluxBB bbcode 测试

  接下来可能就是buffer.c中最重要的函数之一了:struct buffer_head * getblk(int dev,int block),根据设备号和块号得到buffer_head的实例,便于后续使用对应的缓存区;

//// 取高速缓冲中指定的缓冲块
// 检查指定(设备号和块号)的缓冲区是否已经在高速缓冲中。如果指定块已经在
// 高速缓冲中,则返回对应缓冲区头指针退出;如果不在,就需要在高速缓冲中设置一个
// 对应设备号和块好的新项。返回相应的缓冲区头指针。
struct buffer_head * getblk(int dev,int block)
{
    struct buffer_head * tmp, * bh;

repeat:
    // 搜索hash表,如果指定块已经在高速缓冲中,则返回对应缓冲区头指针,退出。
    if ((bh = get_hash_table(dev,block)))
        return bh;
    // 扫描空闲数据块链表,寻找空闲缓冲区。
    // 首先让tmp指向空闲链表的第一个空闲缓冲区头
    tmp = free_list;
    do {
        // 如果该缓冲区正被使用(引用计数不等于0),则继续扫描下一项。对于
        // b_count = 0的块,即高速缓冲中当前没有引用的块不一定就是干净的
        // (b_dirt=0)或没有锁定的(b_lock=0)。因此,我们还是需要继续下面的判断
        // 和选择。例如当一个任务该写过一块内容后就释放了,于是该块b_count()=0
        // 但b_lock不等于0;当一个任务执行breada()预读几个块时,只要ll_rw_block()
        // 命令发出后,它就会递减b_count; 但此时实际上硬盘访问操作可能还在进行,
        // 因此此时b_lock=1, 但b_count=0.
        if (tmp->b_count)
            continue;
        // 如果缓冲头指针bh为空,或者tmp所指缓冲头的标志(修改、锁定)权重小于bh
        // 头标志的权重,则让bh指向tmp缓冲块头。如果该tmp缓冲块头表明缓冲块既
        // 没有修改也没有锁定标志置位,则说明已为指定设备上的块取得对应的高速
        // 缓冲块,则退出循环。否则我们就继续执行本循环,看看能否找到一个BANDNESS()
        // 最小的缓冲块。BADNESS等于0意味着b_block和b_dirt都是0,这块缓存区还没被使用,目标缓存区已经找到,可以跳出循环了
        if (!bh || BADNESS(tmp)<BADNESS(bh)) {
            bh = tmp;
            if (!BADNESS(tmp))
                break;
        }
/* and repeat until we find something good */
    } while ((tmp = tmp->b_next_free) != free_list);
    // 如果循环检查发现所有缓冲块都正在被使用(所有缓冲块的头部引用计数都>0)中,
    // 则睡眠等待有空闲缓冲块可用。当有空闲缓冲块可用时本进程会呗明确的唤醒。
    // 然后我们跳转到函数开始处重新查找空闲缓冲块。
    if (!bh) {
        sleep_on(&buffer_wait);
        goto repeat;
    }
    // 执行到这里,说明我们已经找到了一个比较合适的空闲缓冲块了。于是先等待该缓冲区
    // 解锁(多任务同时运行,刚找到的缓存块可能已经被其他任务抢先一步找到并使用了,所以要再次检查)。如果在我们睡眠阶段该缓冲区又被其他任务使用的话,只好重复上述寻找过程。
    wait_on_buffer(bh);
    if (bh->b_count)
        goto repeat;
    // 如果该缓冲区已被修改,则将数据写盘,并再次等待缓冲区解锁。同样地,若该缓冲区
    // 又被其他任务使用的话,只好再重复上述寻找过程。
    while (bh->b_dirt) {
        sync_dev(bh->b_dev);
        wait_on_buffer(bh);
        if (bh->b_count)
            goto repeat;
    }
/* NOTE!! While we slept waiting for this block, somebody else might */
/* already have added "this" block to the cache. check it */
    // 在高速缓冲hash表中检查指定设备和块的缓冲块是否乘我们睡眠之际已经被加入
    // 进去(毕竟是多任务系统,有可能被其他任务抢先使用并放入has表)。如果是的话,就再次重复上述寻找过程。
    if (find_buffer(dev,block))
        goto repeat;
/* OK, FINALLY we know that this buffer is the only one of it's kind, */
/* and that it's unused (b_count=0), unlocked (b_lock=0), and clean */
    // 于是让我们占用此缓冲块。置引用计数为1,复位修改标志和有效(更新)标志。
    bh->b_count=1;
    bh->b_dirt=0;
    bh->b_uptodate=0;
    // 从hash队列和空闲队列块链表中移出该缓冲区头,让该缓冲区用于指定设备和
    // 其上的指定块。然后根据此新的设备号和块号重新插入空闲链表和hash队列新
    // 位置处。并最终返回缓冲头指针。
    remove_from_queues(bh);
    bh->b_dev=dev;
    bh->b_blocknr=block;
    insert_into_queues(bh);
    return bh;
}

   代码的整体逻辑并不复杂,但是有些细节想展开说说:

  BADNESS(bh):从表达式看,b_dirt左移1位后再和b_lock相加,明显b_dirt的权重乘以了2,说明作者认为缓存区是否被使用的权重应该大于是否被锁!但是实际使用的时候,会一直循环查找BADNESS小的缓存区,说明作者认为b_block比b_dirt更重要,也就是缓存区是否上锁比是否被使用了更重要,这个也符合业务逻辑!
// 下面宏用于同时判断缓冲区的修改标志和锁定标志,并且定义修改标志的权重要比锁定标志大。
//  b_dirt左移1位,权重比b_block高
   #define BADNESS(bh) (((bh)->b_dirt<<1)+(bh)->b_lock)
  循环停止的条件如下:tmp初始值就是free_list,这里的停止的条件也是tmp == free_list,说明free_list是个环形循环链表;所以整个do while循环本质上就是在free_list中找BADNESS值最小的buffer_head;如果找到BADNESS等于0(意味着b_block和b_dirt都为0,该缓存区还没被使用)的buffer_head,直接跳出循环!
   while ((tmp = tmp->b_next_free) != free_list);
函数结尾处: 再次检查dev+block是否已经在缓存区了,如果在,说明其他任务捷足先登,已经使用了该缓存区,本任务只能重新走查找的流程;如果该缓存块还没被使用,先设置一些标志/属性位,再把该buffer_head节点从旧hash表和free_list队列溢出,再重新加入hash_table和free_list队列,作者是咋想的?为啥要重复干这种事了?

/* already have added "this" block to the cache. check it */
    // 在高速缓冲hash表中检查指定设备和块的缓冲块是否乘我们睡眠之际已经被加入
    // 进去(毕竟是多任务,期间可能会被其他任务抢先使用并放入hash表)。如果是的话,就再次重复上述寻找过程。
    if (find_buffer(dev,block))
        goto repeat;
/* OK, FINALLY we know that this buffer is the only one of it's kind, */
/* and that it's unused (b_count=0), unlocked (b_lock=0), and clean */
    // 于是让我们占用此缓冲块。置引用计数为1,复位修改标志和有效(更新)标志。
    bh->b_count=1;
    bh->b_dirt=0;
    bh->b_uptodate=0;
    // 从hash队列和空闲队列块链表中移出该缓冲区头,让该缓冲区用于指定设备和
    // 其上的指定块。然后根据此新的设备号和块号重新插入空闲链表和hash队列新
    // 位置处。并最终返回缓冲头指针。
    /*将缓冲块从旧的队列移出,添加到新的队列中,即哈希表的头,空闲表的尾,这样能够迅速找到该存在的块,而该缓冲块存在的时间最长*/
    remove_from_queues(bh);
    bh->b_dev=dev;
    bh->b_blocknr=block;
    insert_into_queues(bh);
    return bh;

  先来看看remove_from_queues和insert_into_queu函数代码:remove_from_queues没啥好说的,就是简单粗暴的从hash表和free_list删除,也是常规的链表操作,重点在insert_into_queu函数:

bh节点加入了free_list链表的末尾,直接减少了后续查询遍历链表的时间,这不就直接提升了查询效率么?
bh节点加入hash表某个偏移的表头,后续通过hash偏移不就能第一个找到该节点了么?又省了遍历链表的操作!

//// 从hash队列和空闲缓冲区队列中移走缓冲块。
// hash队列是双向链表结构,空闲缓冲块队列是双向循环链表结构。
static inline void remove_from_queues(struct buffer_head * bh)
{
/* remove from hash-queue */
    if (bh->b_next)
        bh->b_next->b_prev = bh->b_prev;
    if (bh->b_prev)
        bh->b_prev->b_next = bh->b_next;
    // 如果该缓冲区是该队列的头一个块(每个hash偏移的头),则让hash表的对应项指向本队列中的下一个
    // 缓冲区。
    if (hash(bh->b_dev,bh->b_blocknr) == bh)
        hash(bh->b_dev,bh->b_blocknr) = bh->b_next;
/* remove from free list */
    if (!(bh->b_prev_free) || !(bh->b_next_free))
        panic("Free block list corrupted");
    bh->b_prev_free->b_next_free = bh->b_next_free;
    bh->b_next_free->b_prev_free = bh->b_prev_free;
    // 如果空闲链表头指向本缓冲区,则让其指向下一缓冲区。
    if (free_list == bh)
        free_list = bh->b_next_free;
}

//// 将缓冲块插入空闲链表尾部,同时放入hash队列中。
static inline void insert_into_queues(struct buffer_head * bh)
{
/* put at end of free list */
    bh->b_next_free = free_list;
    bh->b_prev_free = free_list->b_prev_free;
    free_list->b_prev_free->b_next_free = bh;
    free_list->b_prev_free = bh;
/* put the buffer in new hash-queue if it has a device */
    // 请注意当hash表某项第1次插入项时,hash()计算值肯定为Null,因此此时得到
    // 的bh->b_next肯定是NULL,所以应该在bh->b_next不为NULL时才能给b_prev赋
    // bh值。
    bh->b_prev = NULL;
    bh->b_next = NULL;
    if (!bh->b_dev)
        return;
    bh->b_next = hash(bh->b_dev,bh->b_blocknr);
    hash(bh->b_dev,bh->b_blocknr) = bh;
    bh->b_next->b_prev = bh;                // 此句前应添加"if (bh->b_next)"判断
}

   当一个block使用完后就要释放了,避免“占着茅坑不拉屎”;释放的逻辑也简单,如下:引用计数count--,并且唤醒正在等待该缓存区的其他任务;

// 释放指定缓冲块。
// 等待该缓冲块解锁。然后引用计数递减1,并明确地唤醒等待空闲缓冲块的进程。
void brelse(struct buffer_head * buf)
{
    if (!buf)
        return;
    wait_on_buffer(buf);
    if (!(buf->b_count--))
        panic("Trying to free free buffer");
    wake_up(&buffer_wait);
}

  前面很多的操作,尤其是节点的增删改查都涉及到了hash表和链表,那么hash表和链表都是怎么建立的了?这里用的是buffer_init函数:hash表初始化时所有的偏移都指向null;

// 缓冲区初始化函数
// 参数buffer_end是缓冲区内存末端。对于具有16MB内存的系统,缓冲区末端被设置为4MB.
// 对于有8MB内存的系统,缓冲区末端被设置为2MB。该函数从缓冲区开始位置start_buffer
// 处和缓冲区末端buffer_end处分别同时设置(初始化)缓冲块头结构和对应的数据块。直到
// 缓冲区中所有内存被分配完毕。
void buffer_init(long buffer_end)
{
    struct buffer_head * h = start_buffer;
    void * b;
    int i;

    // 首先根据参数提供的缓冲区高端位置确定实际缓冲区高端位置b。如果缓冲区高端等于1Mb,
    // 则因为从640KB - 1MB被显示内存和BIOS占用,所以实际可用缓冲区内存高端位置应该是
    // 640KB。否则缓冲区内存高端一定大于1MB。
    if (buffer_end == 1<<20)
        b = (void *) (640*1024);
    else
        b = (void *) buffer_end;
    // 这段代码用于初始化缓冲区,建立空闲缓冲区块循环链表,并获取系统中缓冲块数目。
    // 操作的过程是从缓冲区高端开始划分1KB大小的缓冲块,与此同时在缓冲区低端建立
    // 描述该缓冲区块的结构buffer_head,并将这些buffer_head组成双向链表。
    // h是指向缓冲头结构的指针,而h+1是指向内存地址连续的下一个缓冲头地址,也可以说
    // 是指向h缓冲头的末端外。为了保证有足够长度的内存来存储一个缓冲头结构,需要b所
    // 指向的内存块地址 >= h 缓冲头的末端,即要求 >= h+1.
    while ( (b -= BLOCK_SIZE) >= ((void *) (h+1)) ) {
        h->b_dev = 0;                       // 使用该缓冲块的设备号
        h->b_dirt = 0;                      // 脏标志,即缓冲块修改标志
        h->b_count = 0;                     // 缓冲块引用计数
        h->b_lock = 0;                      // 缓冲块锁定标志
        h->b_uptodate = 0;                  // 缓冲块更新标志(或称数据有效标志)
        h->b_wait = NULL;                   // 指向等待该缓冲块解锁的进程
        h->b_next = NULL;                   // 指向具有相同hash值的下一个缓冲头
        h->b_prev = NULL;                   // 指向具有相同hash值的前一个缓冲头
        h->b_data = (char *) b;             // 指向对应缓冲块数据块(1024字节)
        h->b_prev_free = h-1;               // 指向链表中前一项
        h->b_next_free = h+1;               // 指向连表中后一项
        h++;                                // h指向下一新缓冲头位置
        NR_BUFFERS++;                       // 缓冲区块数累加
        if (b == (void *) 0x100000)         // 若b递减到等于1MB,则跳过384KB
            b = (void *) 0xA0000;           // 让b指向地址0xA0000(640KB)处
    }
    h--;                                    // 让h指向最后一个有效缓冲块头
    free_list = start_buffer;               // 让空闲链表头指向头一个缓冲快
    free_list->b_prev_free = h;             // 链表头的b_prev_free指向前一项(即最后一项)。
    h->b_next_free = free_list;             // h的下一项指针指向第一项,形成一个环链
    // 最后初始化hash表,置表中所有指针为NULL。
    for (i=0;i<NR_HASH;i++)
        hash_table[i]=NULL;
}

  截至目前,前面围绕缓存区做了大量的铺垫,最终的目的就是和磁盘之间读写数据,那么linux又是怎么利用缓存区从磁盘读数据的了?bread函数代码如下:整个逻辑也很简单,先申请缓存区,如果已经更新就直接返回;否则调用ll_rw_block读磁盘数据;读数据是要花时间的,这段时间cpu没必要闲着,可以跳转到其他进程继续执行;等数据读完后唤醒当前进程,检查buffer是否被锁、是否被更新;如果都没有,就可以安心释放了!

//// 从设备上读取数据块。
// 该函数根据指定的设备号 dev 和数据块号 block,首先在高速缓冲区中申请一块
// 缓冲块。如果该缓冲块中已经包含有有效的数据就直接返回该缓冲块指针,否则
// 就从设备中读取指定的数据块到该缓冲块中并返回缓冲块指针。
struct buffer_head * bread(int dev,int block)
{
    struct buffer_head * bh;

    // 在高速缓冲区中申请一块缓冲块。如果返回值是NULL,则表示内核出错,停机。
    // 然后我们判断其中说是否已有可用数据。如果该缓冲块中数据是有效的(已更新)
    // 可以直接使用,则返回。
    if (!(bh=getblk(dev,block)))
        panic("bread: getblk returned NULL\n");
    if (bh->b_uptodate)
        return bh;
    // 否则我们就调用底层快设备读写ll_rw_block函数,产生读设备块请求。然后
    // 等待指定数据块被读入,并等待缓冲区解锁。在睡眠醒来之后,如果该缓冲区已
    // 更新,则返回缓冲区头指针,退出。否则表明读设备操作失败,于是释放该缓
    // 冲区,返回NULL,退出。
    ll_rw_block(READ,bh);
    wait_on_buffer(bh);
    if (bh->b_uptodate)
        return bh;
    brelse(bh);
    return NULL;
}

   ll_rw_block:ll全称应该是lowlevel的意思;rw表示读或者写请求,bh用来传递数据或保存数据。先通过主设备号判断是否为有效的设备,同时请求函数是否存在。如果是有效的设备且函数存在,即有驱动,则添加请求到相关链表中;

  对于一个当前空闲的块设备,当 ll_rw_block()函数为其建立第一个请求项时,会让该设备的当前请求项指针current_request直接指向刚建立的请求项,并且立刻调用对应设备的请求项操作函数开始执行块设备读写操作。当一个块设备已经有几个请求项组成的链表存在,ll_rw_block()就会利用电梯算法,根据磁头移动距离最小原则,把新建的请求项插入到链表适当的位置处;

void ll_rw_block(int rw, struct buffer_head * bh)
{
    unsigned int major;

    if ((major=MAJOR(bh->b_dev)) >= NR_BLK_DEV ||
    !(blk_dev[major].request_fn)) {
        printk("Trying to read nonexistent block-device\n\r");
        return;
    }
    make_request(major,rw,bh);
}

  该函数内部继续调用make_request生成request:函数首先判断是否为提前读或者提前写,如果是则要看bh是否上了锁。上了锁则直接返回,因为提前操作是不必要的,否则转化为可以识别的读或者写,然后锁住缓冲区;数据处理结束后在中断处理函数中解锁;如果是写操作但是缓冲区不脏,或者读操作但是缓冲区已经更新,则直接返回;最后构造request实例,调用add_request函数把实例添加到链表!

  add_request函数用了电梯调度算法,主要是考虑到早期机械磁盘的移臂的时间消耗较大,要么从里到外,要么从外到里,顺着某个方向多处理请求。如果req刚好在磁头移动的方向上,那么可以先处理,这样能节省IO(本质是寻址)的时间;

/*
 * add-request adds a request to the linked list.
 * It disables interrupts so that it can muck with the
 * request-lists in peace.
 */
static void add_request(struct blk_dev_struct * dev, struct request * req)
{
    struct request * tmp;

    req->next = NULL;
    cli();
    if (req->bh)
        req->bh->b_dirt = 0;
    if (!(tmp = dev->current_request)) {
        dev->current_request = req;
        sti();
        (dev->request_fn)();
        return;
    }
    for ( ; tmp->next ; tmp=tmp->next)
        if ((IN_ORDER(tmp,req) || 
            !IN_ORDER(tmp,tmp->next)) &&
            IN_ORDER(req,tmp->next))
            break;
    req->next=tmp->next;
    tmp->next=req;
    sti();
}

static void make_request(int major,int rw, struct buffer_head * bh)
{
    struct request * req;
    int rw_ahead;

/* WRITEA/READA is special case - it is not really needed, so if the */
/* buffer is locked, we just forget about it, else it's a normal read */
    if ((rw_ahead = (rw == READA || rw == WRITEA))) {
        if (bh->b_lock)
            return;
        if (rw == READA)
            rw = READ;
        else
            rw = WRITE;
    }
    if (rw!=READ && rw!=WRITE)
        panic("Bad block dev command, must be R/W/RA/WA");
    lock_buffer(bh);
    if ((rw == WRITE && !bh->b_dirt) || (rw == READ && bh->b_uptodate)) {
        unlock_buffer(bh);
        return;
    }
repeat:
/* we don't allow the write-requests to fill up the queue completely:
 * we want some room for reads: they take precedence. The last third
 * of the requests are only for reads.
 */
    if (rw == READ)
        req = request+NR_REQUEST;
    else
        req = request+((NR_REQUEST*2)/3);
/* find an empty request */
    while (--req >= request)
        if (req->dev<0)
            break;
/* if none found, sleep on new requests: check for rw_ahead */
    if (req < request) {
        if (rw_ahead) {
            unlock_buffer(bh);
            return;
        }
        sleep_on(&wait_for_request);
        goto repeat;
    }
/* fill up the request-info, and add it to the queue */
    req->dev = bh->b_dev;
    req->cmd = rw;
    req->errors=0;
    req->sector = bh->b_blocknr<<1;
    req->nr_sectors = 2;
    req->buffer = bh->b_data;
    req->waiting = NULL;
    req->bh = bh;
    req->next = NULL;
    add_request(major+blk_dev,req);
}

   add_request中定义了宏IN_ORDER,真正的电梯调度算法体现在这里了:read请求排在写请求前面,先处理读请求,再处理写请求;同一读或写请求先处理设备号小的设备请求,再处理设备号大的设备请求;同一读或写请求,同一设备,按先里面的扇区再到外面的扇区的顺序处理。

/*
 * This is used in the elevator algorithm: Note that
 * reads always go before writes. This is natural: reads
 * are much more time-critical than writes.
 */
#define IN_ORDER(s1,s2) \
((s1)->cmd<(s2)->cmd || ((s1)->cmd==(s2)->cmd && \
((s1)->dev < (s2)->dev || ((s1)->dev == (s2)->dev && \
(s1)->sector < (s2)->sector))))

参考:

1、https://blog.csdn.net/jmh1996/article/details/90139485     linux-0.12源码分析——缓冲区等待队列(栈)sleep_on+wake_up分析2

2、https://blog.csdn.net/ac_dao_di/article/details/54615951   linux 0.11 块设备文件的使用

3、https://cloud.tencent.com/developer/article/1749826 Linux文件系统之 — 通用块处理层

#408 文件系统模块 » 深入理解Linux文件系统与日志分析 » 2022-09-22 14:57:11

batsom
回复: 0

引言

本章内容我们讲解了inode和block的关系,恢复xfs 、ext类型的文件以及日志文件的管理与分析
一、inode与block
1.inode和block概述

    文件数据包括元信息与实际数据 (元信息:包含属性的相关信息,实际数据:文件内容)
    文件是存储在硬盘上的,硬盘的最小存储单位叫做"扇区”(sector),每个扇区存储512字节。
    一般连续八个扇区组成一个"块"(block),一个块是4K大小,是文件存取的最小单位。操作系统读取硬盘的时候,是一次性连续读取多个扇区,即一个块一个块的读取的。
    block(块)

    连续的八个扇区组成一个block(4K)
    是文件存取的最小单位

    inode(索引节点)

    中文译名为“索引节点”,也叫i节点
    用于存储文件元信息

文件数据包括实际数据与元信息(类似文件属性)。文件数据存储在"块"中,存储文件元信息(比如文件的创建者、创建日期、文件大小、文件权限等)的区域就叫做inode。因此,一个文件必须占用一个inode,并且至少占用一个 block。

inode不包含文件名。文件名是存放在目录当中的。Linux系统中一切皆文件,因此目录也是一种文件。

每个inode都有一个号码,操作系统用inode号码来识别不同的文件。Linux系统内部不使用文件名,而使用inode号码来识别文件。对于系统来说,文件名只是inode号码便于识别的别称,文件名和inode号码是一一对应关系,每个inode号码对应一个文件名。

所以,当用户在Linux系统中试图访问一个文件时,系统会先根据文件名去查找它对应的inode号码,通过inode号码,获取inode信息﹔根据inode信息,看该用户是否具有访问这个文件的权限;如果有,就指向相对应的数据block,并读取数据。

2.inode的内容

inode包含文件的元信息,具体来说有以下内容:

    文件的字节数 就是字节占了多少空间和文件大小
    文件拥有者的User ID
    文件的Group ID
    文件的读、 写、执行权限
    文件的时间戳
    文件类型
    链接数
    有关文件的其他数据.

2.2 Linux系统文件三个主要的时间属性

        ctime(change time)
        ◆最后一次改变文件或目录(属性)的时间
        atime(access time)
        ◆最后一次访问文件或目录的时间
        mtime(modify time)
        ◆最后一次修改文件或目录(内容)的时间

2.3目录文件结构
        目录也是一种文件
        目录文件的结构

    每个inode都有一个号码,操作系统用inode号码来识别不同的文件
    Linux系统内部不使用文件名,而使用inode号码来识别文件
    对于用户,文件名只是inode号码便于识别的别称

3.inode的号码

用户通过文件名打开文件时,系统内部的过程

    1.系统找到这个文件名对应的inode号码
    2.通过inode号码,获取inode信息
    3.根据inode信息,找到文件数据所在的block,读出数据

查看inode号码的方法

    ls -i命令:查看文件名对应的inode号码
    ls -i aa.txt
    stat命令:查看文件inode信息中的inode号码
    stat aa.txt

4.inode的大小

        inode也会消耗硬盘空间,每个inode的大小,一般是128字节或256字节
        格式化文件系统时确定inode的总数
        使用df -i命令可以查看每个硬盘分区的inode总数和已经使用的数量

5.inode的特殊作用

由于inode号码与文件名分离,导致Linux系统具备以下几种特有的现象:

    1.文件名包含特殊字符,可能无法正常删除。这时直接删除 inode,也可以删除文件
    2.移动文件或重命名文件,只是改变文件名,不影响inode号码
    3.打开一个文件以后,系统就以inode号码来识别这个文件,不再考虑文件名

    格式

    普通文件 find ./ -inum 52305140 -exec rm -i {} ;
    find ./ -inum 52345140 -exec rm -rf {} \ 目录

    find ./ -inum 50464299 -delete

6.链接文件

        为文件或目录建立链接文件
        链接文件分类
硬链接

ln 源文件目标位置

软链接

ln [-s] 源文件或目录... 链接文件或目标位置

#410 文件系统模块 » Linux文件系统详解 » 2022-09-22 13:34:05

batsom
回复: 0

Linux的一切皆文件

Linux 中的各种事物比如像文档、目录(Mac OS X 和 Windows 系统下称之为文件夹)、键盘、监视器、硬盘、可移动媒体设备、打印机、调制解调器、虚拟终端,还有进程间通信(IPC)和网络通信等输入/输出资源都是定义在文件系统空间下的字节流。
一切都可看作是文件,其最显著的好处是对于上面所列出的输入/输出资源,只需要相同的一套 Linux 工具、实用程序和 API。你可以使用同一套api(read, write)和工具(cat , 重定向, 管道)来处理unix中大多数的资源.
设计一个系统的终极目标往往就是要找到原子操作,一旦锁定了原子操作,设计工作就会变得简单而有序。“文件”作为一个抽象概念,其原子操作非常简单,只有读和写,这无疑是一个非常好的模型。通过这个模型,API的设计可以化繁为简,用户可以使用通用的方式去访问任何资源,自有相应的中间件做好对底层的适配。
现代操作系统为解决信息能独立于进程之外被长期存储引入了文件,文件作为进程创建信息的逻辑单元可被多个进程并发使用。在 UNIX 系统中,操作系统为磁盘上的文本与图像、鼠标与键盘等输入设备及网络交互等 I/O 操作设计了一组通用 API,使他们被处理时均可统一使用字节流方式。换言之,UNIX 系统中除进程之外的一切皆是文件,而 Linux 保持了这一特性。为了便于文件的管理,Linux 还引入了目录(有时亦被称为文件夹)这一概念。目录使文件可被分类管理,且目录的引入使 Linux 的文件系统形成一个层级结构的目录树

    在Linux系统中,一切都是文件,理解文件系统,对于学习Linux来说,是一个非常有必要的前提

Linux上的文件系统一般来说就是EXT2或EXT3,但这篇文章并不准备一上来就直接讲它们,而希望结合Linux操作系统并从文件系统建立的基础——硬盘开始,一步步认识Linux的文件系统。
1. 机械硬盘的物理存储机制

        现代计算机大部分文件存储功能都是由机械硬盘这种设备提供的。(现在的SSD和闪存从概念和逻辑上都部分继承自机械硬盘,所以使用机械硬盘来进行理解也是没有问题的)
        机械硬盘能实现信息存储的功能基于:磁性存储介质能够被磁化,且磁化后会长久保留被磁化的状态,这种被磁化状态能够被读取出来,同时这种磁化状态还能够不断被修改,磁化正好有两个方向,所以可以表示0和1。
        于是硬盘就是把这种磁性存储介质做成一个个盘片,每一个盘片上都分布着数量巨大的磁性存储单位,使用磁性读写头对盘片进行写入和读取(从原理上类似黑胶唱片的播放)。
        一个硬盘中的磁性存储单位数以亿计(1T硬盘就有约80亿个),所以需要一套规则来规划信息如何存取(比如一本存储信息的书我们还会分为页,每一页从上到下从左到右读取,同时还有章节目录)
        于是就有了这些物理、逻辑概念:

            一个硬盘有多张盘片叠成,不同盘片有编号
            每张盘片上的存储颗粒成环形一圈圈地排布,每一圈称为磁道,有编号
            每条磁道上都有一圈存储颗粒,每512*8(512字节,0.5KB)个存储颗粒作为一个扇区,扇区是硬盘上存储的最小物理单位
            N个扇区可以组成簇,N取决于不同的文件系统或是文件系统的配置,簇是此文件系统中的最小存储单位
            所有盘面上的同一磁道构成一个圆柱,称为柱面,柱面是系统分区的最小单位

    磁头读写文件的时候,首先是分区读写的,由inode编号(区内唯一的编号后面介绍)找到对应的磁道和扇区,然后一个柱面一个柱面地进行读写。机械硬盘的读写控制系统是一个令人叹为观止的精密工程(一个盘面上有几亿个存储单位,每个磁道宽度不到几十纳米,磁盘每分钟上万转),同时关于读写的逻辑也是有诸多细节(比如扇区的编号并不是连续的),非常有意思,可以自行搜索文章拓展阅读。

    有了硬盘并不意味着LInux可以立刻把它用来存储,还需要组合进Linux的文件体系才能被Linux使用。

2.Linux文件体系

Linux以文件的形式对计算机中的数据和硬件资源进行管理,也就是彻底的一切皆文件,反映在Linux的文件类型上就是:普通文件、目录文件(也就是文件夹)、设备文件、链接文件、管道文件、套接字文件(数据通信的接口)等等。而这些种类繁多的文件被Linux使用目录树进行管理, 所谓的目录树就是以根目录(/)为主,向下呈现分支状的一种文件结构。不同于纯粹的ext2之类的文件系统,我把它称为文件体系,一切皆文件和文件目录树的资源管理方式一起构成了Linux的文件体系,让Linux操作系统可以方便使用系统资源。
所以文件系统比文件体系涵盖的内容少很多,Linux文件体系主要在于把操作系统相关的东西用文件这个载体实现:文件系统挂载在操作系统上,操作系统整个系统又放在文件系统里。但本文中文件体系的相关内容不是很多,大部分地方都可以用文件系统代替文件体系。

1. Linux中的文件类型:

1.1. 普通文件(-)

从Linux的角度来说,类似mp4、pdf、html这样应用层面上的文件类型都属于普通文件
Linux用户可以根据访问权限对普通文件进行查看、更改和删除

1.2. 目录文件(d,directory file)

目录文件对于用惯Windows的用户来说不太容易理解,目录也是文件的一种
目录文件包含了各自目录下的文件名和指向这些文件的指针,打开目录事实上就是打开目录文件,只要有访问权限,你就可以随意访问这些目录下的文件(普通文件的执行权限就是目录文件的访问权限),但是只有内核的进程能够修改它们
虽然不能修改,但是我们能够通过vim去查看目录文件的内容

1.3. 符号链接(l,symbolic link)

这种类型的文件类似Windows中的快捷方式,是指向另一个文件的间接指针,也就是我们常说的软链接

1.4. 块设备文件(b,block)和字符设备文件(c,char)

这些文件一般隐藏在/dev目录下,在进行设备读取和外设交互时会被使用到
比如磁盘光驱就是块设备文件,串口设备则属于字符设备文件
系统中的所有设备要么是块设备文件,要么是字符设备文件,无一例外

1.5. FIFO(p,pipe)

管道文件主要用于进程间通讯。比如使用mkfifo命令可以创建一个FIFO文件,启用一个进程A从FIFO文件里读数据,启动进程B往FIFO里写数据,先进先出,随写随读。

1.6. 套接字(s,socket)

用于进程间的网络通信,也可以用于本机之间的非网络通信
这些文件一般隐藏在/var/run目录下,证明着相关进程的存在

Linux 的文件是没有所谓的扩展名的,一个 Linux文件能不能被执行与它是否可执行的属性有关,只要你的权限中有 x ,比如[ -rwx-r-xr-x ] 就代表这个文件可以被执行,与文件名没有关系。跟在 Windows下能被执行的文件扩展名通常是 .com .exe .bat 等不同。
不过,可以被执行跟可以执行成功不一样。比如在 root 主目彔下的 install.log 是一个文本文件,修改权限成为 -rwxrwxrwx 后这个文件能够真的执行成功吗? 当然不行,因为它的内容根本就没有可以执行的数据。所以说,这个 x 代表这个文件具有可执行的能力, 但是能不能执行成功,当然就得要看该文件的内容了。
虽然如此,不过我们仍然希望能从扩展名来了解该文件是什么东西,所以一般我们还是会以适当的扩展名来表示该文件是什么种类的。
所以Linux 系统上的文件名真的只是让你了解该文件可能的用途而已, 真正的执行与否仍然需要权限的规范才行。比如常见的/bin/ls 这个显示文件属性的指令要是权限被修改为无法执行,那么ls 就变成不能执行了。这种问题最常发生在文件传送的过程中。例如你在网络上下载一个可执行文件,但是偏偏在你的 Linux 系统中就是无法执行,那就可能是档案的属性被改变了。而且从网络上传送到你 的 Linux 系统中,文件的属性权限确实是会被改变的

2. Linux目录树

对Linux系统和用户来说,所有可操作的计算机资源都存在于目录树这个逻辑结构中,对计算机资源的访问都可以认为是目录树的访问。就硬盘来说,所有对硬盘的访问都变成了对目录树中某个节点也就是文件夹的访问,访问时不需要知道它是硬盘还是硬盘中的文件夹。
目录树的逻辑结构也非常简单,就是从根目录(/)开始,不断向下展开各级子目录。
3.硬盘分区

硬盘分区是硬盘结合到文件体系的第一步,本质是「硬盘」这个物理概念转换成「区」这个逻辑概念,为下一步格式化做准备。
所以分本身并不是必须的,你完全可以把一整块硬盘作为一个区。但从数据的安全性以及系统性能角度来看,分区还是有很多用处的,所以一般都会对硬盘进行分区。

讲分区就不得不先提每块硬盘上最重要的第一扇区,这个扇区中有硬盘主引导记录(Master boot record, MBR) 及分区表(partition table), 其中 MBR 占有 446 bytes,而分区表占有 64 bytes。硬盘主引导记录放有最基本的引导加载程序,是系统开机启动的关键环节,在附录中有更详细的说明。而分区表则跟分区有关,它记录了硬盘分区的相关信息,但因分区表仅有 64bytes , 所以最多只能记彔四块分区(分区本身其实就是对分区表进行设置)。

只能分四个区实在太少了,于是就有了扩展分区的概念,既然第一个扇区所在的分区表只能记录四条数据, 那我可否利用额外的扇区来记录更多的分区信息。
把普通可以访问的分区称为主分区,扩展分区不同于主分区,它本身并没有内容,它是为进一步逻辑分区提供空间的。在某块分区指定为扩展分区后,就可以对这块扩展分区进一步分成多个逻辑分区。操作系统规定:

    四块分区每块都可以是主分区或扩展分区
    扩展分区最多只能有一个(也没必要有多个)
    扩展分区可以进一步分割为多个逻辑分区
    扩展分区只是逻辑概念,本身不能被访问,也就是不能被格式化后作为数据访问的分区,能够作为数据访问的分区只有主分区和逻辑分区
    逻辑分区的数量依操作系统而不同,在 Linux 系统中,IDE 硬盘最多有 59 个逻辑分区(5 号到 63 号), SATA 硬盘则有 11 个逻辑分区(5 号到 15 号)
    一般给硬盘进行分区时,一个主分区一个扩展分区,然后把扩展分区划分为N个逻辑分区是最好的

是否可以不要主分区呢?不知道,但好像不用管,你创建分区的时候会自动给你配置类型
特殊的,你最好单独分一个swap区(内存置换空间),它独为一类,功能是:当有数据被存放在物理内存里面,但是这些数据又不是常被 CPU 所取用时,那么这些不常被使用的程序将会被丢到硬盘的 swap 置换空间当中, 而将速度较快的物理内存空间释放出来给真正需要的程序使用
4.格式化

我们知道Linux操作系统支持很多不同的文件系统,比如ext2、ext3、XFS、FAT等等,而Linux把对不同文件系统的访问交给了VFS(虚拟文件系统),VFS能访问和管理各种不同的文件系统。所以有了区之后就需要把它格式化成具体的文件系统以便VFS访问。

标准的Linux文件系统Ext2是使用「基于inode的文件系统」

我们知道一般操作系统的文件数据除了文件实际内容外, 还带有很多属性,例如 Linux 操作系统的文件权限(rwx)与文件属性(拥有者、群组、 时间参数等),文件系统通常会将属性和实际内容这两部分数据分别存放在不同的区块
在基于inode的文件系统中,权限与属性放置到 inode 中,实际数据放到 data block 区块中,而且inode和data block都有编号
Ext2 文件系统在此基础上

文件系统最前面有一个启动扇区(boot sector)
这个启动扇区可以安装开机管理程序, 这个设计让我们能将不同的引导装载程序安装到个别的文件系统前端,而不用覆盖整个硬盘唯一的MBR, 也就是这样才能实现多重引导的功能
把每个区进一步分为多个块组 (block group),每个块组有独立的inode/block体系
如果文件系统高达数百 GB 时,把所有的 inode 和block 通通放在一起会因为 inode 和 block的数量太庞大,不容易管理
这其实很好理解,因为分区是用户的分区,实际计算机管理时还有个最适合的大小,于是计算机会进一步的在分区中分块
(但这样岂不是可能出现大文件放不了的问题?有什么机制善后吗?)
每个块组实际还会分为分为6个部分,除了inode table 和 data block外还有4个附属模块,起到优化和完善系统性能的作用
所以整个分区大概会这样划分:

1. inode table

主要记录文件的属性以及该文件实际数据是放置在哪些block中,它记录的信息至少有这些:
大小、真正内容的block号码(一个或多个)
访问模式(read/write/excute)
拥有者与群组(owner/group)
各种时间:建立或状态改变的时间、最近一次的读取时间、最近修改的时间
没有文件名!文件名在目录的block中!
一个文件占用一个 inode,每个inode有编号
Linux 系统存在 inode 号被用完但磁盘空间还有剩余的情况
注意,这里的文件不单单是普通文件,目录文件也就是文件夹其实也是一个文件,还有其他的也是
inode 的数量与大小在格式化时就已经固定了,每个inode 大小均固定为128 bytes (新的ext4 与xfs 可设定到256 bytes)
文件系统能够建立的文件数量与inode 的数量有关,存在空间还够但inode不够的情况
系统读取文件时需要先找到inode,并分析inode 所记录的权限与使用者是否符合,若符合才能够开始实际读取 block 的内容
inode 要记录的资料非常多,但偏偏又只有128bytes , 而inode 记录一个block 号码要花掉4byte ,假设我一个文件有400MB 且每个block 为4K 时, 那么至少也要十万条block 号码的记录!inode 哪有这么多空间来存储?为此我们的系统很聪明的将inode 记录block 号码的区域定义为12个直接,一个间接, 一个双间接与一个三间接记录区(详细见附录)

2. data block

放置文件内容数据的地方
在格式化时block的大小就固定了,且每个block都有编号,以方便inode的记录
原则上,block 的大小与数量在格式化完就不能够再改变了(除非重新格式化)
在Ext2文件系统中所支持的block大小有1K, 2K及4K三种,由于block大小的区别,会导致该文件系统能够支持的最大磁盘容量与最大单一文件容量各不相同:
Block 大小 1KB 2KB 4KB
最大单一档案限制 16GB 256GB 2TB
最大档案系统总容量 2TB 8TB 16TB
每个block 内最多只能够放置一个文件的资料,但一个文件可以放在多个block中(大的话)
若文件小于block ,则该block 的剩余容量就不能够再被使用了(磁盘空间会浪费)
所以如果你的档案都非常小,但是你的block 在格式化时却选用最大的4K 时,可能会产生容量的浪费
既然大的block 可能会产生较严重的磁碟容量浪费,那么我们是否就将block 大小定为1K ?这也不妥,因为如果block 较小的话,那么大型档案将会占用数量更多的block ,而inode 也要记录更多的block 号码,此时将可能导致档案系统不良的读写效能
事实上现在的磁盘容量都太大了,所以一般都会选择4K 的block 大小

3. superblock

记录整个文件系统相关信息的地方,一般大小为1024bytes,记录的信息主要有:
block 与inode 的总量
未使用与已使用的inode / block 数量
一个valid bit 数值,若此文件系统已被挂载,则valid bit 为0 ,若未被挂载,则valid bit 为1
block 与inode 的大小 (block 为1, 2, 4K,inode 为128bytes 或256bytes);
其他各种文件系统相关信息:filesystem 的挂载时间、最近一次写入资料的时间、最近一次检验磁碟(fsck) 的时间
Superblock是非常重要的, 没有Superblock ,就没有这个文件系统了,因此如果superblock死掉了,你的文件系统可能就需要花费很多时间去挽救
每个块都可能含有superblock,但是我们也说一个文件系统应该仅有一个superblock 而已,那是怎么回事?事实上除了第一个块内会含有superblock 之外,后续的块不一定含有superblock,而若含有superblock则该superblock主要是做为第一个块内superblock的备份,这样可以进行superblock的救援

4. Filesystem Description

文件系统描述
这个区段可以描述每个block group的开始与结束的block号码,以及说明每个区段(superblock, bitmap, inodemap, data block)分别介于哪一个block号码之间

5. block bitmap

块对照表
如果你想要新增文件时要使用哪个block 来记录呢?当然是选择「空的block」来记录。那你怎么知道哪个block 是空的?这就得要通过block bitmap了,它会记录哪些block是空的,因此我们的系统就能够很快速的找到可使用的空间来记录
同样在你删除某些文件时,那些文件原本占用的block号码就得要释放出来, 此时在block bitmap 中对应该block号码的标志位就得要修改成为「未使用中」

6. inode bitmap

与block bitmap 是类似的功能,只是block bitmap 记录的是使用与未使用的block 号码, 至于inode bitmap 则是记录使用与未使用的inode 号码
5.挂载

在一个区被格式化为一个文件系统之后,它就可以被Linux操作系统使用了,只是这个时候Linux操作系统还找不到它,所以我们还需要把这个文件系统「注册」进Linux操作系统的文件体系里,这个操作就叫「挂载」 (mount)。
挂载是利用一个目录当成进入点(类似选一个现成的目录作为代理),将文件系统放置在该目录下,也就是说,进入该目录就可以读取该文件系统的内容,类似整个文件系统只是目录树的一个文件夹(目录)。
这个进入点的目录我们称为「挂载点」。

由于整个 Linux 系统最重要的是根目录,因此根目录一定需要挂载到某个分区。 而其他的目录则可依用户自己的需求来给予挂载到不同的分去。

到这里Linux的文件体系的构建过程其实已经大体讲完了,总结一下就是:硬盘经过分区和格式化,每个区都成为了一个文件系统,挂载这个文件系统后就可以让Linux操作系统通过VFS访问硬盘时跟访问一个普通文件夹一样。这里通过一个在目录树中读取文件的实际例子来细讲一下目录文件和普通文件。
6.目录树的读取过程

首先我们要知道

    每个文件(不管是一般文件还是目录文件)都会占用一个inode
    依据文件内容的大小来分配一个或多个block给该文件使用
    创建一个文件后,文件完整信息分布在3处地方,生成2个新文件:
    3.1 文件名记录在该文件所在目录的目录文件的block中,没有新文件生成
    3.2 文件属性、权限信息、记录具体内容的block编号记录在inode中,inode是新生成文件
    3.3 文件具体内存记录在block中,block是新生成文件
    因为文件名的记录是在目录的block当中,「新增/删除/更名文件名」与目录的w权限有关
    所以在Linux/Unix中,文件名称只是文件的一个属性,叫别名也好,叫绰号也罢,仅为了方便用户记忆和使用,但系统内部并不需要用文件名来定为文件位置,这样处理最直观的好处就是,你可以对正在使用的文件改名,换目录,甚至放到废纸篓,都不会影响当前文件的使用,这在Windows里是无法想象的。比如你打开个Word文件,然后对其进行重命名操作,Windows会告诉你门儿都没有,关闭文件先!但在Mac里就毫无压力,因为Mac的操作系统同样采用了inode的设计。

创建文件过程

当在ext2下建立一个一般文件时, ext2 会分配一个inode 与相对于该文件大小的block 数量给该文件

    例如:假设我的一个block 为4 Kbytes ,而我要建立一个100 KBytes 的文件,那么linux 将分配一个inode 与25 个block 来储存该文件
    但同时请注意,由于inode 仅有12 个直接指向,因此还要多一个block 来作为区块号码的记录

创建目录过程

当在ext2文件系统建立一个目录时(就是新建了一个目录文件),文件系统会分配一个inode与至少一块block给该目录

    inode记录该目录的相关权限与属性,并记录分配到的那块block号码
    而block则是记录在这个目录下的文件名与该文件对应的inode号
    block中还会自动生成两条记录,一条是.文件夹记录,inode指向自身,另一条是..文件夹记录,inode指向父文件夹

从目录树中读取某个文件过程

    因为文件名是记录在目录的block当中,因此当我们要读取某个文件时,就一定会经过目录的inode与block ,然后才能够找到那个待读取文件的inode号码,最终才会读到正确的文件的block内的资料。
    由于目录树是由根目录开始,因此操作系统先通过挂载信息找到挂载点的inode号,由此得到根目录的inode内容,并依据该inode读取根目录的block信息,再一层一层的往下读到正确的文件。
    举例来说,如果我想要读取/etc/passwd 这个文件时,系统是如何读取的呢?
    先看一下这个文件以及有关路径文件夹的信息:

    1$ ll -di / /etc /etc/passwd
    2     128 dr-xr-x r-x . 17 root root 4096 May 4 17:56 /
    333595521 drwxr-x r-x . 131 root root 8192 Jun 17 00:20 /etc
    436628004 -rw-r-- r-- . 1 root root 2092 Jun 17 00:20 /etc/passwd
    复制代码

于是该文件的读取流程为:

    /的inode:
    通过挂载点的信息找到inode号码为128的根目录inode,且inode规定的权限让我们可以读取该block的内容(有r与x)
    /的block:
    经过上个步骤取得block的号码,并找到该内容有etc/目录的inode号码(33595521)
    etc/的inode:
    读取33595521号inode得知具有r与x的权限,因此可以读取etc/的block内容
    etc/的block:
    经过上个步骤取得block号码,并找到该内容有passwd文件的inode号码(36628004)
    passwd的inode:
    读取36628004号inode得知具有r的权限,因此可以读取passwd的block内容
    passwd的block:
    最后将该block内容的资料读出来

#411 文件系统模块 » 详细分析Linux文件系统 » 2022-09-19 11:57:15

batsom
回复: 0

本片文章针对Linux文件系统从原理到运行机制做了非常详细的理论分析,有助于读者对此深入的理解,以下是具体内容:

Linux上的文件系统一般来说就是EXT2或EXT3,但这篇文章并不准备一上来就直接讲它们,而希望结合Linux操作系统并从文件系统建立的基础——硬盘开始,一步步认识Linux的文件系统。

1.机械硬盘的物理存储机制

现代计算机大部分文件存储功能都是由机械硬盘这种设备提供的。(现在的SSD和闪存从概念和逻辑上都部分继承自机械硬盘,所以使用机械硬盘来进行理解也是没有问题的)

机械硬盘能实现信息存储的功能基于:磁性存储介质能够被磁化,且磁化后会长久保留被磁化的状态,这种被磁化状态能够被读取出来,同时这种磁化状态还能够不断被修改,磁化正好有两个方向,所以可以表示0和1。
于是硬盘就是把这种磁性存储介质做成一个个盘片,每一个盘片上都分布着数量巨大的磁性存储单位,使用磁性读写头对盘片进行写入和读取(从原理上类似黑胶唱片的播放)。

一个硬盘中的磁性存储单位数以亿计(1T硬盘就有约80亿个),所以需要一套规则来规划信息如何存取(比如一本存储信息的书我们还会分为页,每一页从上到下从左到右读取,同时还有章节目录)
于是就有了这些物理、逻辑概念:

一个硬盘有多张盘片叠成,不同盘片有编号每张盘片上的存储颗粒成环形一圈圈地排布,每一圈称为磁道,有编号每条磁道上都有一圈存储颗粒,每512*8(512字节,0.5KB)个存储颗粒作为一个扇区,扇区是硬盘上存储的最小物理单位 N个扇区可以组成簇,N取决于不同的文件系统或是文件系统的配置,簇是此文件系统中的最小存储单位所有盘面上的同一磁道构成一个圆柱,称为柱面,柱面是系统分区的最小单位

磁头读写文件的时候,首先是分区读写的,由inode编号(区内唯一的编号后面介绍)找到对应的磁道和扇区,然后一个柱面一个柱面地进行读写。机械硬盘的读写控制系统是一个令人叹为观止的精密工程(一个盘面上有几亿个存储单位,每个磁道宽度不到几十纳米,磁盘每分钟上万转),同时关于读写的逻辑也是有诸多细节(比如扇区的编号并不是连续的),非常有意思,可以自行搜索文章拓展阅读。

有了硬盘并不意味着LInux可以立刻把它用来存储,还需要组合进Linux的文件体系才能被Linux使用。

2.Linux文件体系

Linux以文件的形式对计算机中的数据和硬件资源进行管理,也就是彻底的一切皆文件,反映在Linux的文件类型上就是:普通文件、目录文件(也就是文件夹)、设备文件、链接文件、管道文件、套接字文件(数据通信的接口)等等。而这些种类繁多的文件被Linux使用目录树进行管理, 所谓的目录树就是以根目录(/)为主,向下呈现分支状的一种文件结构。不同于纯粹的ext2之类的文件系统,我把它称为文件体系,一切皆文件和文件目录树的资源管理方式一起构成了Linux的文件体系,让Linux操作系统可以方便使用系统资源。

所以文件系统比文件体系涵盖的内容少很多,Linux文件体系主要在于把操作系统相关的东西用文件这个载体实现:文件系统挂载在操作系统上,操作系统整个系统又放在文件系统里。但本文中文件体系的相关内容不是很多,大部分地方都可以用文件系统代替文件体系。

Linux中的文件类型

那就先简单说说Linux中的文件类型,主要关注普通文件、目录文件和符号连接文件。

普通文件(-) 从Linux的角度来说,类似mp4、pdf、html这样应用层面上的文件类型都属于普通文件 Linux用户可以根据访问权限对普通文件进行查看、更改和删除 目录文件(d,directory file) 目录文件对于用惯Windows的用户来说不太容易理解,目录也是文件的一种目录文件包含了各自目录下的文件名和指向这些文件的指针,打开目录事实上就是打开目录文件,只要有访问权限,你就可以随意访问这些目录下的文件(普通文件的执行权限就是目录文件的访问权限),但是只有内核的进程能够修改它们虽然不能修改,但是我们能够通过vim去查看目录文件的内容 符号链接(l,symbolic link) 这种类型的文件类似Windows中的快捷方式,是指向另一个文件的间接指针,也就是我们常说的软链接 块设备文件(b,block)和字符设备文件(c,char) 这些文件一般隐藏在/dev目录下,在进行设备读取和外设交互时会被使用到比如磁盘光驱就是块设备文件,串口设备则属于字符设备文件系统中的所有设备要么是块设备文件,要么是字符设备文件,无一例外 FIFO(p,pipe) 管道文件主要用于进程间通讯。比如使用mkfifo命令可以创建一个FIFO文件,启用一个进程A从FIFO文件里读数据,启动进程B往FIFO里写数据,先进先出,随写随读。 套接字(s,socket) 用于进程间的网络通信,也可以用于本机之间的非网络通信这些文件一般隐藏在/var/run目录下,证明着相关进程的存在

Linux 的文件是没有所谓的扩展名的,一个 Linux文件能不能被执行与它是否可执行的属性有关,只要你的权限中有 x ,比如[ -rwx-r-xr-x ] 就代表这个文件可以被执行,与文件名没有关系。跟在 Windows下能被执行的文件扩展名通常是 .com .exe .bat 等不同。

不过,可以被执行跟可以执行成功不一样。比如在 root 主目彔下的 install.log 是一个文本文件,修改权限成为 -rwxrwxrwx 后这个文件能够真的执行成功吗? 当然不行,因为它的内容根本就没有可以执行的数据。所以说,这个 x 代表这个文件具有可执行的能力, 但是能不能执行成功,当然就得要看该文件的内容了。

虽然如此,不过我们仍然希望能从扩展名来了解该文件是什么东西,所以一般我们还是会以适当的扩展名来表示该文件是什么种类的。

所以Linux 系统上的文件名真的只是让你了解该文件可能的用途而已, 真正的执行与否仍然需要权限的规范才行。比如常见的/bin/ls 这个显示文件属性的指令要是权限被修改为无法执行,那么ls 就变成不能执行了。这种问题最常发生在文件传送的过程中。例如你在网络上下载一个可执行文件,但是偏偏在你的 Linux 系统中就是无法执行,那就可能是档案的属性被改变了。而且从网络上传送到你 的 Linux 系统中,文件的属性权限确实是会被改变的

Linux目录树

对Linux系统和用户来说,所有可操作的计算机资源都存在于目录树这个逻辑结构中,对计算机资源的访问都可以认为是目录树的访问。就硬盘来说,所有对硬盘的访问都变成了对目录树中某个节点也就是文件夹的访问,访问时不需要知道它是硬盘还是硬盘中的文件夹。
目录树的逻辑结构也非常简单,就是从根目录(/)开始,不断向下展开各级子目录。

3.硬盘分区

硬盘分区是硬盘结合到文件体系的第一步,本质是「硬盘」这个物理概念转换成「区」这个逻辑概念,为下一步格式化做准备。

所以分本身并不是必须的,你完全可以把一整块硬盘作为一个区。但从数据的安全性以及系统性能角度来看,分区还是有很多用处的,所以一般都会对硬盘进行分区。

讲分区就不得不先提每块硬盘上最重要的第一扇区,这个扇区中有硬盘主引导记录(Master boot record, MBR) 及分区表(partition table), 其中 MBR 占有 446 bytes,而分区表占有 64 bytes。硬盘主引导记录放有最基本的引导加载程序,是系统开机启动的关键环节,在附录中有更详细的说明。而分区表则跟分区有关,它记录了硬盘分区的相关信息,但因分区表仅有 64bytes , 所以最多只能记彔四块分区(分区本身其实就是对分区表进行设置)。

只能分四个区实在太少了,于是就有了扩展分区的概念,既然第一个扇区所在的分区表只能记录四条数据, 那我可否利用额外的扇区来记录更多的分区信息。

把普通可以访问的分区称为主分区,扩展分区不同于主分区,它本身并没有内容,它是为进一步逻辑分区提供空间的。在某块分区指定为扩展分区后,就可以对这块扩展分区进一步分成多个逻辑分区。操作系统规定:

四块分区每块都可以是主分区或扩展分区扩展分区最多只能有一个(也没必要有多个)扩展分区可以进一步分割为多个逻辑分区扩展分区只是逻辑概念,本身不能被访问,也就是不能被格式化后作为数据访问的分区,能够作为数据访问的分区只有主分区和逻辑分区逻辑分区的数量依操作系统而不同,在 Linux 系统中,IDE 硬盘最多有 59 个逻辑分区(5 号到 63 号), SATA 硬盘则有 11 个逻辑分区(5 号到 15 号)

一般给硬盘进行分区时,一个主分区一个扩展分区,然后把扩展分区划分为N个逻辑分区是最好的

是否可以不要主分区呢?不知道,但好像不用管,你创建分区的时候会自动给你配置类型特殊的,你最好单独分一个swap区(内存置换空间),它独为一类,功能是:当有数据被存放在物理内存里面,但是这些数据又不是常被 CPU 所取用时,那么这些不常被使用的程序将会被丢到硬盘的 swap 置换空间当中, 而将速度较快的物理内存空间释放出来给真正需要的程序使用

4.格式化

我们知道Linux操作系统支持很多不同的文件系统,比如ext2、ext3、XFS、FAT等等,而Linux把对不同文件系统的访问交给了VFS(虚拟文件系统),VFS能访问和管理各种不同的文件系统。所以有了区之后就需要把它格式化成具体的文件系统以便VFS访问。

标准的Linux文件系统Ext2是使用「基于inode的文件系统」

我们知道一般操作系统的文件数据除了文件实际内容外, 还带有很多属性,例如 Linux 操作系统的文件权限(rwx)与文件属性(拥有者、群组、 时间参数等),文件系统通常会将属性和实际内容这两部分数据分别存放在不同的区块在基于inode的文件系统中,权限与属性放置到 inode 中,实际数据放到 data block 区块中,而且inode和data block都有编号

Ext2 文件系统在此基础上

文件系统最前面有一个启动扇区(boot sector) 这个启动扇区可以安装开机管理程序, 这个设计让我们能将不同的引导装载程序安装到个别的文件系统前端,而不用覆盖整个硬盘唯一的MBR, 也就是这样才能实现多重引导的功能 把每个区进一步分为多个块组 (block group),每个块组有独立的inode/block体系 如果文件系统高达数百 GB 时,把所有的 inode 和block 通通放在一起会因为 inode 和 block的数量太庞大,不容易管理这其实很好理解,因为分区是用户的分区,实际计算机管理时还有个最适合的大小,于是计算机会进一步的在分区中分块(但这样岂不是可能出现大文件放不了的问题?有什么机制善后吗?) 每个块组实际还会分为分为6个部分,除了inode table 和 data block外还有4个附属模块,起到优化和完善系统性能的作用

所以整个分区大概会这样划分:

inode table 主要记录文件的属性以及该文件实际数据是放置在哪些block中,它记录的信息至少有这些: 大小、真正内容的block号码(一个或多个)访问模式(read/write/excute) 拥有者与群组(owner/group) 各种时间:建立或状态改变的时间、最近一次的读取时间、最近修改的时间没有文件名!文件名在目录的block中! 一个文件占用一个 inode,每个inode有编号 Linux 系统存在 inode 号被用完但磁盘空间还有剩余的情况注意,这里的文件不单单是普通文件,目录文件也就是文件夹其实也是一个文件,还有其他的也是 inode 的数量与大小在格式化时就已经固定了,每个inode 大小均固定为128 bytes (新的ext4 与xfs 可设定到256 bytes) 文件系统能够建立的文件数量与inode 的数量有关,存在空间还够但inode不够的情况系统读取文件时需要先找到inode,并分析inode 所记录的权限与使用者是否符合,若符合才能够开始实际读取 block 的内容 inode 要记录的资料非常多,但偏偏又只有128bytes , 而inode 记录一个block 号码要花掉4byte ,假设我一个文件有400MB 且每个block 为4K 时, 那么至少也要十万条block 号码的记录!inode 哪有这么多空间来存储?为此我们的系统很聪明的将inode 记录block 号码的区域定义为12个直接,一个间接, 一个双间接与一个三间接记录区(详细见附录) data block 放置文件内容数据的地方在格式化时block的大小就固定了,且每个block都有编号,以方便inode的记录 原则上,block 的大小与数量在格式化完就不能够再改变了(除非重新格式化) 在Ext2文件系统中所支持的block大小有1K, 2K及4K三种,由于block大小的区别,会导致该文件系统能够支持的最大磁盘容量与最大单一文件容量各不相同: Block 大小 1KB 2KB 4KB 最大单一档案限制 16GB 256GB 2TB 最大档案系统总容量 2TB 8TB 16TB 每个block 内最多只能够放置一个文件的资料,但一个文件可以放在多个block中(大的话)若文件小于block ,则该block 的剩余容量就不能够再被使用了(磁盘空间会浪费) 所以如果你的档案都非常小,但是你的block 在格式化时却选用最大的4K 时,可能会产生容量的浪费既然大的block 可能会产生较严重的磁碟容量浪费,那么我们是否就将block 大小定为1K ?这也不妥,因为如果block 较小的话,那么大型档案将会占用数量更多的block ,而inode 也要记录更多的block 号码,此时将可能导致档案系统不良的读写效能事实上现在的磁盘容量都太大了,所以一般都会选择4K 的block 大小 superblock 记录整个文件系统相关信息的地方,一般大小为1024bytes,记录的信息主要有: block 与inode 的总量未使用与已使用的inode / block 数量一个valid bit 数值,若此文件系统已被挂载,则valid bit 为0 ,若未被挂载,则valid bit 为1 block 与inode 的大小 (block 为1, 2, 4K,inode 为128bytes 或256bytes);其他各种文件系统相关信息:filesystem 的挂载时间、最近一次写入资料的时间、最近一次检验磁碟(fsck) 的时间 Superblock是非常重要的, 没有Superblock ,就没有这个文件系统了,因此如果superblock死掉了,你的文件系统可能就需要花费很多时间去挽救每个块都可能含有superblock,但是我们也说一个文件系统应该仅有一个superblock 而已,那是怎么回事?事实上除了第一个块内会含有superblock 之外,后续的块不一定含有superblock,而若含有superblock则该superblock主要是做为第一个块内superblock的备份,这样可以进行superblock的救援 Filesystem Description 文件系统描述这个区段可以描述每个block group的开始与结束的block号码,以及说明每个区段(superblock, bitmap, inodemap, data block)分别介于哪一个block号码之间 block bitmap 块对照表如果你想要新增文件时要使用哪个block 来记录呢?当然是选择「空的block」来记录。那你怎么知道哪个block 是空的?这就得要通过block bitmap了,它会记录哪些block是空的,因此我们的系统就能够很快速的找到可使用的空间来记录同样在你删除某些文件时,那些文件原本占用的block号码就得要释放出来, 此时在block bitmap 中对应该block号码的标志位就得要修改成为「未使用中」 inode bitmap 与block bitmap 是类似的功能,只是block bitmap 记录的是使用与未使用的block 号码, 至于inode bitmap 则是记录使用与未使用的inode 号码


5.挂载

在一个区被格式化为一个文件系统之后,它就可以被Linux操作系统使用了,只是这个时候Linux操作系统还找不到它,所以我们还需要把这个文件系统「注册」进Linux操作系统的文件体系里,这个操作就叫「挂载」 (mount)。

挂载是利用一个目录当成进入点(类似选一个现成的目录作为代理),将文件系统放置在该目录下,也就是说,进入该目录就可以读取该文件系统的内容,类似整个文件系统只是目录树的一个文件夹(目录)。

这个进入点的目录我们称为「挂载点」。

由于整个 Linux 系统最重要的是根目录,因此根目录一定需要挂载到某个分区。 而其他的目录则可依用户自己的需求来给予挂载到不同的分去。

到这里Linux的文件体系的构建过程其实已经大体讲完了,总结一下就是:硬盘经过分区和格式化,每个区都成为了一个文件系统,挂载这个文件系统后就可以让Linux操作系统通过VFS访问硬盘时跟访问一个普通文件夹一样。这里通过一个在目录树中读取文件的实际例子来细讲一下目录文件和普通文件。

6.目录树的读取过程

首先我们要知道

每个文件(不管是一般文件还是目录文件)都会占用一个inode 依据文件内容的大小来分配一个或多个block给该文件使用创建一个文件后,文件完整信息分布在3处地方,生成2个新文件: 文件名记录在该文件所在目录的目录文件的block中,没有新文件生成文件属性、权限信息、记录具体内容的block编号记录在inode中,inode是新生成文件文件具体内存记录在block中,block是新生成文件 因为文件名的记录是在目录的block当中,「新增/删除/更名文件名」与目录的w权限有关

所以在Linux/Unix中,文件名称只是文件的一个属性,叫别名也好,叫绰号也罢,仅为了方便用户记忆和使用,但系统内部并不需要用文件名来定为文件位置,这样处理最直观的好处就是,你可以对正在使用的文件改名,换目录,甚至放到废纸篓,都不会影响当前文件的使用,这在Windows里是无法想象的。比如你打开个Word文件,然后对其进行重命名操作,Windows会告诉你门儿都没有,关闭文件先!但在Mac里就毫无压力,因为Mac的操作系统同样采用了inode的设计。

创建文件过程

当在ext2下建立一个一般文件时, ext2 会分配一个inode 与相对于该文件大小的block 数量给该文件

例如:假设我的一个block 为4 Kbytes ,而我要建立一个100 KBytes 的文件,那么linux 将分配一个inode 与25 个block 来储存该文件但同时请注意,由于inode 仅有12 个直接指向,因此还要多一个block 来作为区块号码的记录 创建目录过程

当在ext2文件系统建立一个目录时(就是新建了一个目录文件),文件系统会分配一个inode与至少一块block给该目录

inode记录该目录的相关权限与属性,并记录分配到的那块block号码而block则是记录在这个目录下的文件名与该文件对应的inode号 block中还会自动生成两条记录,一条是.文件夹记录,inode指向自身,另一条是..文件夹记录,inode指向父文件夹 从目录树中读取某个文件过程 因为文件名是记录在目录的block当中,因此当我们要读取某个文件时,就一定会经过目录的inode与block ,然后才能够找到那个待读取文件的inode号码,最终才会读到正确的文件的block内的资料。由于目录树是由根目录开始,因此操作系统先通过挂载信息找到挂载点的inode号,由此得到根目录的inode内容,并依据该inode读取根目录的block信息,再一层一层的往下读到正确的文件。

举例来说,如果我想要读取/etc/passwd 这个文件时,系统是如何读取的呢?
先看一下这个文件以及有关路径文件夹的信息:

$ ll -di / /etc /etc/passwd
128 dr-xr-x r-x . 17 root root 4096 May 4 17:56 /
33595521 drwxr-x r-x . 131 root root 8192 Jun 17 00:20 /etc
36628004 -rw-r-- r-- . 1 root root 2092 Jun 17 00:20 /etc/passwd

于是该文件的读取流程为:

/的inode: 通过挂载点的信息找到inode号码为128的根目录inode,且inode规定的权限让我们可以读取该block的内容(有r与x) /的block: 经过上个步骤取得block的号码,并找到该内容有etc/目录的inode号码(33595521) etc/的inode: 读取33595521号inode得知具有r与x的权限,因此可以读取etc/的block内容 etc/的block: 经过上个步骤取得block号码,并找到该内容有passwd文件的inode号码(36628004) passwd的inode: 读取36628004号inode得知具有r的权限,因此可以读取passwd的block内容 passwd的block: 最后将该block内容的资料读出来

附录:开机流程和硬盘主引导记录

可以稍微讲下开机流程和硬盘主引导记录(MBR,或者叫主引导分区)

一台可正常运行的计算机会在BIOS上设置一块启动硬盘,其实每块硬盘都可以作为启动盘,硬盘本身的设计提供的这种可能,这就要从硬盘上的第一个扇区说起,这个扇区中有硬盘主引导记录(Master boot record, MBR)及分区表(partition table), 其中 MBR 占有 446 bytes,而分区表则占有 64 bytes。

计算机主板上有一段写入到主板的程序BIOS,BIOS是开机之后计算机系统会主动执行的第一个程序。BIOS 会去分析计算机里面有哪些储存设备,我们以硬盘为例,BIOS 会依据使用者的设定去取得能够开机的硬盘, 并且到该硬盘里面去读取第一个扇区的MBR位置。

MBR 这个仅有 446 bytes 的硬盘容量里面会放置最基本的引导加载程序(Boot loader),它的目的是加载操作系统内核文件,由于引导加载程序是操作系统在安装的时候所提供的,所以它会认识硬盘内的文件系统格式,因此就能够读取操作系统内核文件。接下来就是内核文件的工作,也就是大家所知道癿操作系统的任务了。

所以简单说开机流程就是:

BIOS:开机主动运行的程序,会识别第一个可开机的设备 MBR-引导加载程序:第一个可开机设备的第一个扇区内的主引导分区中的引导加载程序,可读取操作系统内核文件操作系统内核文件:不同的操作系统中关于开启自己的程序

由上面的说明我们会知道,BIOS和MBR 都是硬件本身会支持的功能,到MBR中的Boot loader 则是操作系统写在 MBR 上面的一段程序了。由于 MBR 仅有 446 bytes,因此这个引导加载程序是非常小而美的,它的主要任务有:

提供菜单:用户可以选择不同的开机项目,这也是多重引导的重要功能载入操作系统内核:直接指向可开机的程序区段来启动操作系统转交其他 loader:将引导加载功能转交给其他 loader 负责 这点很有趣,表示你的计算机系统里面可能具有两个以上的引导加载程序有可能吗?我们的硬盘不是只有一个 MBR 而已?是这样,但是引导加载程序除了可以安 装在 MBR 之外, 还可以安装在每个分区的引导扇区(boot sector)内分区的引导扇区这个特色造就了『多重引导』的功能(具体可以看鸟哥的书第三章第四节) 机械硬盘物理存储结构拓展阅读 蒋致诚. 硬盘驱动器巨磁电阻 (GMR) 磁头: 从微米到纳米[J]. 物理, 2004, 33(07): 0-0. 近年来电脑硬盘存储密度的飞速增长最关键的因素是自旋阀纳米多层膜结构,即巨磁电阻(GMR)读传感器磁头的应用。巨磁电阻磁头读传感器已经实现由微电子器件向纳米电子器件转化,这一过程包含了自旋电子学、材料科学、微电子工程学、化学、微机械力学和工程学等诸学科和相关微加工技术综合性挑战极限。 磁盘工作原理揭秘大多数永久性或半永久性电脑数据都是将磁盘上的一小片金属物质磁化来实现。然后再将这些磁性图可被转换成原始数据。 机械硬盘内部硬件结构和工作原理详解给扇区编号的最简单方法是l,2,3,4,5,6等顺序编号。如果扇区按顺序绕着磁道依次编号,那么,控制器在处理一个扇区的数据期间,磁盘旋转太远,超过扇区间的间隔(这个间隔很小),控制器要读出或写入的下一扇区已经通过磁头,也许是相当大的一段距离。在这种情况下,磁盘控制器就只能等待磁盘再次旋转几乎一周,才能使得需要的扇区到达磁头下面。这就很浪费时间了。许多年前,IBM的一位杰出工程师想出了一个绝妙的办法,即对扇区不使用顺序编号,而是使用一个交叉因子(interleave)进行编号。 格式化的其他细节 每种操作系统能够使用的文件系统并不相同。举例来说,windows 98 以前的微 软操作系统主要利用的文件系统是 FAT (或 FAT16),windows 2000 以后的版本有所谓的 NTFS 文件系统,至于 Linux 的正统文件系统则为Ext2 (Linux second extended file system, ext2fs) 这一个。而且在默认的情况下,windows 操作系统是不会认识 Linux的Ext2的。传统的磁盘与文件系统的应用中,一个分区只能够被格式化成为一个文件系统,所以我们可以说 一个 文件系统 就是一个分区。但是由于新技术的利用,例如我们常听到的 LVM 与软件磁盘阵列(software raid), 这些技术可以将一个分区格式化为多个文件系统,也能够将多个分区合成一个文件系统,所以说,目前我们在格式化时已经不再说成针对分区来格式化了, 通常我们可以称呼一个可被挂载的数据为一个文件系统而不是一个分区。 inode/block 与文件大小的关系(有趣)

我们简单分析一下EXT2 的inode / block 与文件大小的关系。inode 要记录的资料非常多,但偏偏又只有128bytes , 而inode 记录一个block 号码要花掉4byte ,假设我一个文件有400MB 且每个block 为4K 时, 那么至少也要十万条block 号码的记录!inode 哪有这么多空间来存储?为此我们的系统很聪明的将inode 记录block 号码的区域定义为12个直接,一个间接, 一个双间接与一个三间接记录区。这是啥?我们将inode 的结构画一下好了。

上图最左边为inode本身(128 bytes),里面有12个直接指向block号码的对照,这12条记录就能够直接取得block号码啦!至于所谓的间接就是再拿一个block来当作记录block号码的记录区,如果文件太大时,就会使用间接的block来记录号码。如上图中间接只是拿一个block来记录额外的号码而已。同理,如果文件持续长大,那么就会利用所谓的双间接,第一个block仅再指出下一个记录号码的block在哪里,实际记录的在第二个block当中。依此类推,三间接就是利用第三层block来记录号码啦!
这样子inode 能够指定多少个block 呢?我们以较小的1K block 来说明好了,可以指定的情况如下:

12个直接指向: 12*1K=12K 由于是直接指向,所以总共可记录12笔记录,因此总额大小为如上所示 间接: 256*1K=256K 每笔block号码的记录会花去4bytes,因此1K的大小能够记录256笔记录,因此一个间接可以记录的文件大小如上; 双间接: 2562561K=256 2 K 第一层block会指定256个第二层,每个第二层可以指定256个号码,因此总额大小如上; 三间接: 256256256*1K=256 3 K 第一层block会指定256个第二层,每个第二层可以指定256个第三层,每个第三层可以指定256个号码,因此总额大小如上; 总额:将直接、间接、双间接、三间接加总,得到12 + 256 + 256256 + 256256*256 (K) = 16GB 此时我们知道当文件系统将block格式化为1K大小时,能够容纳的最大文件为16GB,比较一下文件系统限制表的结果可发现是一致的!但这个方法不能用在2K及4K block大小的计算中,因为大于2K的block将会受到Ext2文件系统本身的限制,所以计算的结果会不太符合 文件系统大小与磁盘读取性能

关于文件系统的使用效率,当你的一个文件系统规划的很大时,例如100GB这么大时,由于磁盘上的资料总是来来去去的,所以,整个文件系统上面的文件通常无法连续写在一起(block号码不连续),而是填入式的将资料填入没有被使用的block当中。如果文件写入的block真的分的很散,此时就会有所谓的文件资料离散的问题发生了。
如前所述,虽然我们的ext2 在inode 处已经将该文件所记录的block 号码都记上了, 所以资料可以一次性读取,但是如果文件真的太过离散,确实还是会发生读取效率低的问题。因为磁盘读取头还是得要在整个文件系统中来来去去的频繁读取!果真如此,那么可以将整个文件系统内的资料全部复制出来,将该文件系统重新格式化, 再将资料给他复制回去即可解决这个问题。

此外,如果文件系统真的太大了,那么当一个文件分别记录在这个文件系统的最前面与最后面的block 号码中, 此时会造成磁碟的机械手臂移动幅度过大(不是还会分块吗?),也会造成资料读取效能的低落。而且读取头在搜寻整个文件系统时, 也会花费比较多的时间去搜寻。因此分区的规划并不是越大越好, 而是真的要针对你的主机用途来进行规划才行。

Linux的一切皆文件

Linux 中的各种事物比如像文档、目录(Mac OS X 和 Windows 系统下称之为文件夹)、键盘、监视器、硬盘、可移动媒体设备、打印机、调制解调器、虚拟终端,还有进程间通信(IPC)和网络通信等输入/输出资源都是定义在文件系统空间下的字节流。

一切都可看作是文件,其最显著的好处是对于上面所列出的输入/输出资源,只需要相同的一套 Linux 工具、实用程序和 API。你可以使用同一套api(read, write)和工具(cat , 重定向, 管道)来处理unix中大多数的资源.

设计一个系统的终极目标往往就是要找到原子操作,一旦锁定了原子操作,设计工作就会变得简单而有序。“文件”作为一个抽象概念,其原子操作非常简单,只有读和写,这无疑是一个非常好的模型。通过这个模型,API的设计可以化繁为简,用户可以使用通用的方式去访问任何资源,自有相应的中间件做好对底层的适配。

现代操作系统为解决信息能独立于进程之外被长期存储引入了文件,文件作为进程创建信息的逻辑单元可被多个进程并发使用。在 UNIX 系统中,操作系统为磁盘上的文本与图像、鼠标与键盘等输入设备及网络交互等 I/O 操作设计了一组通用 API,使他们被处理时均可统一使用字节流方式。换言之,UNIX 系统中除进程之外的一切皆是文件,而 Linux 保持了这一特性。为了便于文件的管理,Linux 还引入了目录(有时亦被称为文件夹)这一概念。目录使文件可被分类管理,且目录的引入使 Linux 的文件系统形成一个层级结构的目录树。

#412 文件系统模块 » linux系统目录详解(很详细) » 2022-09-19 11:47:50

batsom
回复: 1

给大家一篇关于Linux目录 方面的详细说明,好好读一下!

Linux目录详解(RHEL5.4)

linux有四种基本文件系统类型:
--普通文件:如文本文件、c语言源代码、shell脚本等,可以用cat、less、more、vi等来察看内容,用mv来改名;
--目录文件:包括文件名、子目录名及其指针,可以用ls列出目录文件;
--链接文件:是指向一索引节点的那些目录条目,用ls来查看时,链接文件的标志用l开头,而文件后以"->"指向所链接的文件;
--特殊文件:如磁盘、终端、打印机等都在文件系统中表示出来,常放在/dev目录内;
可以用file命令来识别。


linux系统中,所有的文件与目录都是由根目录/开始,不是以/开头的就是相对路径;
.:表示当前目录,也可以用./表示;
..:表示上一级目录,也可以用../表示;
~:代表用户自己的宿主目录;

/:处于Linux文件系统树形结构的最顶端,我们称它为Linux文件系统的root,它是Linux文件系统的入口。所有的目录、文件、设备都在/之下,它是Linux文件系统最顶层的唯一的目录;
        一般建议在根目录下面只有目录,不要直接存放文件;根目录是linux系统启动时系统第一个载入的分区,所以启动过程中用到的文件应该都放在这个分区中,其中/etc、/bin、/dev、/lib、/sbin这5个子目录都应该要与根目录连在一起,不可独立成为某个分区;
/bin:存放所有用户都可以使用的linux基本操作命令;(目录中多是可执行的二进制文件)
/dev:设备文件目录,虚拟文件系统,主要存放所有系统中device的相关信息,不论是使用的或未使用的设备,只要有可能使用到,就会在/dev中建立一个相对应的设备文件;设备文件分为2种类型:        字符设备文件和块设备文件(目录中基本上都是设备文件,如硬盘设备文件/dev/sda)
        /dev/console:系统控制台,也就是直接和系统连接的监视器;
        /dev/hd:IDE设备文件;
        /dev/sd:sata、usb、scsi等设备文件;
        /dev/fd:软驱设备文件;
        /dev/tty:虚拟控制台设备文件;
        /dev/pty:提供远程虚拟控制台设备文件;
        /dev/null:所谓"黑洞",所有写入该设备的信息都将消失,如当想要将屏幕上的输出信息隐藏起来时,只要将输出信息输入到/dev/null中即可;
       

/home:默认存放用户的宿主目录(除了root用户)
        /home/~/.bashrc:提供bash环境中所需使用的别名;
        /home/~/.bash_profile:提供bash环境所需的变量;一般先执行.bashrc后,才会再执行.bash_profile;
        /home/~/.bash_history:用户历史命令文件,记录用户曾经输入过的所有命令;(默认为1000条,可以通过HISTSIZE变量更改)
        /home/~/.bash_logout:当用户注销的同时,系统会自动执行.bash_logout文件,如果管理员需要记录用户注销的一些额外记录、动作或其他信息,就可以利用这个机制去完成;
/lost+found:当系统在运行时,有时会无法避免宕机、断电或不正常重启动,在这样的情况下,当系统重新启动时,发现某些文件写入未完成或其他问题产生,一般会使用fsck进行文件修复,而这些被修复或救回的文件,就会被放在这个目录下,只要是一个文件系统,系统就会自动在该文件系统所在的目录下建立"lost+found"目录
/misc:自动挂载服务目录,对应autofs服务;
/proc:虚拟文件系统,此目录是kernel加载后,在内存里面建立的一个虚拟目录,有专属的文件系统,主要提供系统一些实时的信息,此目录下不能建立和删除文件;(某些文件可以修改)
        /proc主要作用可以整理为:
        --整理系统内部的信息;
        --存放主机硬件信息;
        --调整系统执行时的参数;
        --检查及修改网络和主机的参数;
        --检查及调整系统的内存和性能;
        /proc下常用的信息文件有:
        /proc/cpuinfo:cpu的硬件信息,如类型、厂家、型号和性能等
        /proc/devices:记录所有在/dev目录中相关的设备文件分类方式
        /proc/filesystems:当前运行内核所配置的文件系统
        /proc/interrupts:可以查看每一个IRQ的编号对应到哪一个硬件设备
        /proc/loadavg:系统"平均负载",3个数据指出系统当前的工作负载
        /proc/dma:当前正在使用的DMA通道
        /proc/ioports:将目前系统上所有可看到的硬件对应到内存位置的分配表的详细信息呈现出来
        /proc/kcore:系统上可以检测到的物理内存,主机内存多大,这个文件就有多大
        /proc/kmsg:在系统尚未进入操作系统阶段,把加载kernel和initrd的信息先记录到该文件中,后续会将日志信息写入/var/log/message文件中
        /proc/meminfo:记录系统的内存信息
        /proc/modules:与lsmod命令查看到的模块信息完全一致
        /proc/mtrr:负责内存配置的机制
        /proc/iomem:主要用于储存配置后所有内存储存的明细信息
        /proc/partitions:这个文件可以实时呈现系统目前看到的分区
        /proc/数字目录:数字目录很多,它们代表所有目前正在系统中运行的所有程序
        /proc/bus:有关该主机上现有总线的所有信息,如输入设备、PCI接口、PCMCIA扩展卡及USB接口信息
        /proc/net目录:存放的都是一些网络相关的虚拟配置文件,都是ASCII文件,可以查看(与ifconfig、arp、netstat等有关)
        /proc/scsi:保存系统上所有的scsi设备信息(包括sata和usb设备的信息)
        /proc/sys目录:存放系统核心所使用的一些变量,根据不同性质的文件而存放在不同的子目录中,可以通过/etc/sysctl.conf文件设置和更改其默认值;变量时实时的变更,有很多设置很象是开关,设置后马上生效;
        /proc/tty:存放有关目前可用的正在使用的tty设备的信息
        /proc/self:存放到查看/proc的程序的进程目录的符号连接,当2个进程查看proc时,这将会是不同的连接;主要便于程序得到它自己的进程目录;
        /proc/stat:系统的不同状态信息;
        /proc/uptime:系统启动的时间长度;
        /proc/version:系统核心版本;
/sbin:系统管理相关的二进制文件存放在这个目录下,一些可执行文件普通用户只具备较小的权限;(多数管理命令默认只有管理员可以使用)
/srv:默认为空,主要用于存放一些软件的配置文件,某些软件可能会把配置文件默认存放在这个目录下,多数都是/etc目录下,此目录没有被具体的定义;
/tftpboot:远程启动tftpserver的根目录,这个目录只有安装了tftp-server软件后才会产生;
/usr:安装除操作系统本身外的一些应用程序或组件,一般可以认为linux系统上安装的应用程序默认都安装在此目录中;
        /usr/bin:一般用户有机会使用到的程序,或者该软件默认就是要让所有用户使用才会放在该目录中;
        /usr/sbin:一些系统有可能会用到的系统命令,与/sbin比起来,都是一些较次要的文件;
        /usr/etc:自行安装或非系统主要的配置文件目录;
        /usr/games:只要是电脑游戏相关的软件,就都安装到这个目录;
        /usr/include:存放的文件都是一些系统中用户所会使用到的C语言header文件,保存的都是".h"的文件;
        /usr/kerberos:kerberos是一种安全机制,让用户可以直接使用支持kerberos机制系统上的部分资源;
        /usr/lib:存放一些函数库、执行文件及连接文件,特别的是,存放在这里面的文件都是不希望直接被用户或shell脚本所使用的文件,在/usr/lib中有非常多的子目录,每一个软件都有其各自所需的函数库;
        /usr/libexec:这个目录下的文件及文件夹应该都可以放置在/usr/lib下;
        /usr/local:linux系统中安装的共享软件程序最好的方式是安装在/usr/local下,按照linux标准目录结构,新建立的软件都应该放在/usr/local下;
                /usr/local/bin:存放软件执行文件的目录;
                /usr/local/sbin:同样存放软件执行文件的目录,但此目录专门针对系统所使用的文件;
                /usr/local/lib:软件相关的函数库;
                /usr/local/share:当文件性质不好归属时就会放在此,man手册就放在这个目录下;
                /usr/local/src:所安装软件的源代码放置在此;
        /usr/share:此目录都是一些共享信息,最常被用到的就是/usr/share/man这个目录,/usr/share里的信息时跨平台的;
        /usr/share/doc:放置一些系统帮助文件的地方;
        /usr/share/man:manpage的文件存放目录,也是使用man查看手册页时查询的路径;
        /usr/src:主要储存内核源代码的文件;
        /usr/X11R6:存放一些X windows系统的相关文件;

/boot:存放开机启动加载程序的核心文件;(如kernel和grup)
        config-2.6.18-164.el5:系统kernel的配置文件,内核编译完成后保存的就是这个配置文件;
        lost+found:说明/boot是一个独立的ext3文件系统;
        vmlinuz-2.6.18-164.el5:系统使用kernel,非常重要;
        grub:多系统启动管理程序grub的目录,里面存放的都是grub在启动时所需要的画面、配置及各阶段的配置文件;其中grub.conf是grub的配置文件;
        symvers-2.6.18-164.el5.gz
        initrd-2.6.18-164.el5.img:此文件是linux系统启动时的模块供应主要来源,initrd的目的就是在kernel加载系统识别cpu和内存等核心信息之后,让系统进一步知道还有那些硬件是启动所必须使用的;
        System.map-2.6.18-164.el5:是系统kernel中的变量对应表;(也可以理解为是索引文件)
/etc:主机、系统或网络配置文件存放目录;
        简单的将/etc目录分为以下几类:
        --基本文件:所有直接放在/etc目录下的文件归类为基本文件;
                aliases:用于设置邮件别名;
                auto.*:代表的是一系列autofs服务所需要的配置文件,这个服务主要是让管理员可以事先定义出一些网络、本机或光驱等默认的路径;
                auto.master:负责规划目录的分配与使用,目前默认提供三种自动挂载模式;
                auto.misc:文件中的配置都以实体连接本机的磁盘驱动器为主;
                auto.net:并不是一个配置文件,而是一个脚本文件,在使用上其实不须做任何调整;;
                auto.smb:与auto.net一样,都是以个脚本文件;
                bashrc:用户登录功能配置,全局配置,对所有用户生效,主要配置别名;
                profile:与系统环境配置或初始化软件的相关配置,全局配置,对所有用户生效,主要配置变量;
                DIR_COLORS:用于配置ls命令的颜色,主要针对tty登录的用户;
                DIR_COLORS.xterm:用于配置ls命令的颜色,主要针对xterm登录的用户;
                fstab:系统启动时自动挂载文件系统的配置文件;
                inittab:启动时系统所需要的第一个配置文件;也即是init进程的配置文件;
                issue:用户本机登录时,看到的欢迎信息;
                issue.net:用户网络登录时,看到的欢迎信息;
                ld.so.conf:包含ld.so.conf.d/*.conf配置;主要是ld.so.conf.d/*.conf目录的作用;
                localtime:系统所使用的时区对应的配置文件;对应的时区文件都存在于/usr/share/zoneinfo/
                motd:登录成功的用户显示的信息对应的配置文件;
                mtab:可以当做是检查当前文件系统挂载情况的配置文件;与mount命令结果一致;
                prelink.conf:定义哪些执行文件和函数库是需要预先连接的;
                securetty:主要是login程序在使用的,只要是列在该文件中的接口,就表示是可以使用的接口,相反,若从列表中删除,则无法使用该接口;
                shells:记录目前系统所拥有shell种类的路径,通过cssh命令使用;
                sudoers:sudo命令对应的配置文件,用于配置权限的分配方式;
                sysctl.conf:主要是帮助用户配置/proc/sys目录下所有文件的值,与sysctl命令对应;
                syslogd.conf:是syslogd服务的配置文件
                host.conf:主机名解析配置文件,主要说明解析的方式及顺序;
                hosts:主机名解析配置文件,主要列出所有需要本地解析的主机名与IP地址的对应关系;
                hosts.allow和hosts.deny:linux网络安全机制TCP Wrapper对应的配置文件;
                nsswitch.conf:主要记录系统应如何查询主机名、密码、用户组、网络等,或是查询顺序的编排;
                resolv.conf:记录DNS服务器地址,用于DNS域名解析;
                services:定义了网络服务的默认端口号;
                xinetd.conf:xinetd的主配置文件,目的是为xinetd.d下的所有子服务建立一个标准的规范使其可以遵循;
                anacrontab:属于一种任务计划软件的配置文件,anacrontab软件和crond其实有点相辅相成,crond负责任务计划,而anacrontab则是负责以"间隔多久"为主要的目标;
                at.deny:该文件属于拒绝列表,只要被记录在其中的用户,就无法使用at所提供的任务计划服务;
                at.allow:与at.deny刚好相反;
                crontab:crontab的主配置文件,crond默认会执行的文件可以参考此配置文件;
                cron.deny:该文件属于拒绝列表,只要被记录在其中的用户,就无法使用crond所提供的任务计划服务;
                cron.allow:与cron.deny刚好相反;
                exports:是NFS服务的主配置文件,主要目的就是将本机的目录共享到网络上,供其他人使用;
                group与gshadow:用户组配置文件,group主要保存用户组信息,gshadow主要保存群组密码;
                login.defs:设置系统在建立账号时所参考的配置;
                passwd:主要保存系统用户账号的信息;
                shadow:linux系统通常包经过"hash"处理后的密码存储在这个文件中;
                protocols:通信协议对应端口号的一个对照表,包含协议名称、协议号码、注释等;
                wgetrc:wget程序对应的配置文件,其中有quota、mail header、重传文件的预设次数、firewall和proxy等相关设置;
                init.d:RHEL中所有服务的默认启动脚本都存放在这里;这个是链接文件,链接到/etc/rc.d/init.d;
                csh.cshrc和csh.login: 用户启动c shells执行的初始化配置文件;
                printcap:linux系统中打印机设备对应的配置文件;
        --服务器目录:如samba、http、vsftpd等服务器配置相关目录;
                cups:linux下的打印机服务器,目录下存放的是打印机服务的配置文件;
                dnsmasq.d:dnsmasq是一种DNS的"轻薄机种",转为区域或小型网络所设计,拥有比一般DNS更为方便简易的配置;
                httpd:apache网页服务器的配置文件所在目录;
                mail:Mail Server组件的主要配置目录,如sendmail;
                ntp:网络时间服务器的配置目录,其主要配置文件为/etc/ntp.conf;
                openldap:目录明显是LDAP的配置目录,软件名称为OpenLDAP;
                postfix:postfix组件所提供的主要配置文件目录;
                samba:文件共享服务samba的主要配置文件目录;
                smrsh:这是sendmail为了限制用户可使用的命令设计的程序,将原本用户所使用的/bin/sh替换为/usr/sbin/smrsh;
                snmp:简单网络管理软件的配置文件目录,存在snmpd.conf主配置文件;
                squid:这是linux下的代理服务器squid的配置文件目录,主配置文件是squid.conf;
                ssh:SSH服务的主要配置目录,主配置文件是sshd_config;
                vsftpd:vsftpd服务器的主要配置目录,主配置文件是vsftpd.conf;
                xinetd.d:xinetd是一个管理多个服务的daemon,这个目录下列出的服务都是由xinetd进程管理的,其主配置文件是/etc/xinetd.conf;
        --系统目录:如sysconfig、xen或网络配置等与系统运行相关的目录;
                blkid:此目录所存放的其实是一个块设备ID的临时文件,主要是记录系统中所有区块设备的标签名称、硬件的唯一识别码、文件系统的格式等基本信息;
                bluetooth:linux下使用蓝牙设备所需的配置文件;启动蓝牙检测的主要服务仍是/etc/rc.d/init.d/bluetooth,该程序使用的是hcid.conf配置文件;
                cron.X:cron.X的目录都是给cron软件存放其需要任务计划的文件所使用的,按任务计划时间的长短及配置特性分为cron.d、cron.daily、cron.hourly、cron.monthly、cron.weekly五个主要目录;
                dbus-1:D-BUS的主要配置目录,D-BUS也是一种IPC交流的方式;
                default:这里是存放一些系统软件默认值的目录,存放某些软件执行时的基本参数;
                firmware:这个目录所存放的东西是非常底层的信息,是CPU所需的microcode的实体文件;
                foomatic:与打印机相关的配置目录,实现打印一对多的方式,在foomatic中,可以记录多条打印机数据,让用户只在使用前先行配置所有需要使用的打印机即可;
                hal:全名Hardware Abstraction Layer,是linux一种管理硬件的机制,它会帮所有的应用程序或用户搜集所有PCI及USB等硬件信息,因此,用户可以很简单并实时地通过HAL的方式取得硬件的相关数据;
                isdn:ISDN服务的主要配置目录,里面包含可拨号的用户、电话、联机方式等;
                ld.so.conf.d:这个目录是ldconfig所使用的,更准确的说,它是由/etc/ld.so.conf文件所决定的;ldconfig命令的目的在于将系统中的一些函数库预先存放到内存中,让系统使用时可以比以往通过硬盘的读取速度来的更快,这样可以大幅提高系统性能,尤其当要重复读取时更明显;ldconfig要将哪些函数库丢到内存中,则须看/etc/ld.so.conf文件中所记录的信息;
                logrotate.d:此目录对系统管理员来说,是十分重要的一个目录,因为目录中的文件,记录了如何定期备份系统所需要备份的系统或软件日志文件及备份方式,目录是由logrotate组件所提供的,而里面所有文件是由各软件各自产生的;其主要配置文件是/etc/logrotate.conf;               
                logwatch:logrotate主要是实现如何备份日志文件,这个目录就是记载如何分析日志文件并告诉用户的软件logwatch的配置目录;
                lsb-release.d:LSB是一个由很多人所执行的项目,其目的是将所有的Linux发行版定义为一些共同的标准;
                lvm:这个目录是LVM的基本配置文件,但配置或操作一般都只需要通过LVM提供的命令,而不会用到这个目录,除非要使用到很高级的配置才会更改此文件;
                makedev.d:MAKEDEV软件对应的配置文件目录,MAKEDEV主要用来产生设备文件,也就是说,在/dev目录下的文件都由这个命令产生的,此目录下的文件主要是针对设备文件的定义或属性,目录中存在的设备文件可以由MAKEDEV来创建,否则需要使用mknod命令了;
                modprobe.d:是modprobe命令的住配置目录,一般系统启动默认要加载的模块放在/etc/modprobe.conf中;
                netplug和netplug.d:这两个目录和网络接口的联机与否由直接关系,因为主要是控制联机时的接口操作;
                opt:此目录原本是定义为存放所有额外安装软件的主机配置文件,但目前并没有被使用到,此目录为空;
                pcmcia:这是PCMCIA的配置文件目录,PCMCIA是笔记本电脑不可或缺的接口,需要即插即用的方式,此接口使用较少;
                pm:由pm-utils组件所提供的目录,pm-utils是一套电源管理的工具软件,其中/usr/lib/pm-utils也是主要目录之一;
                ppp:ppp相关的配置文件都放在这个目录中;
                profile.d:这个目录存放的是系统部分的软件配置,但会按不同的shell执行不同的文件,默认所使用的bash会直接执行该目录下所有扩展名为.sh的文件;
                rc.d:主要用来定义在每一个执行阶段必须要执行哪些系统服务或程序,在目录中主要分为三个重要的部分:
                        --rc.sysinit:系统一开始启动时所遇到的第一个文件,此脚本文件记录服务启动之前所需准备的所有事情,包括启动时看到的欢迎画面;
                        --rcX.d:在rc.sysinit文件之后所要执行的,X是系统启动时的initdefault值,值为几则会转到那个目录下,并执行其中的所有文件,在此目录中,文件一律都由两个英文字母开始K和S,K代表kill,S代表Start;
                        --rc.local:系统初始化过程中最后一个执行的脚本文件,可以将需要开机启动的程序或脚本放置在这个脚本文件中,以实现自动运行的目的;
                readahead.d:是readahead程序的主要配置目录,为了加速操作系统的使用速度,readahead_early和readahead_later这两个进程在系统加载时,直接将日常所需要的一些文件,全部先放到硬盘的高速缓存中;
                redhat-lsb:都lsb-release.d目录都是由程序redhat-lsb所提供的;
                rwtab.d:这个目录是一个在启动时会去参考的目录,主要的文件在/etc/rwtab;这是一个系统初期的备份机制;
                sane.d:这是在系统下要使用扫描仪所需的配置目录,主要配置文件是sane.conf,sane为了方便用户在各式的扫描仪连接时都可以使用,因此,在这一目录中放置了很多种不同类型扫描仪的硬件信息,让系统在检测到扫描仪时可以直接使用;
                setuptool.d:这个目录是"setup"系统配置工具的主要配置目录;
                skel:用于初始化用户宿主目录的配置目录,当建立一个用户时,会把此目录下的所有文件复制一份到用户的宿主目录,作为用户的初始化配置;
                sysconfig:非常重要的系统配置文件的存放目录,里面放置了大量系统启动及运行相关的配置文件;
                sysconfig/network-scripts/ifcfg-eth0:网卡eth0对应的配置文件,设置内容包括设备名称、IP地址、广播地址、网关地址、网段、开机是否激活等参数
                udev:udev程序本身是一套设备的管理机制,udev通过sysfs的文件系统,可以正确地掌握目前系统上存在的硬件设备,以及针对每一个硬件设备做出不同的判断与执行;
                yum和yum.repos.d:这两个都是yum的配置目录,是一套在linux下可以自动帮助用户安装、更新、移除等的管理组件,可用来替代rpm包管理方式,主配置文件是/etc/yum.conf;yum是更新方式及外挂程序的配置目录,yum.repos.d是存放定期更新组件内容的信息;
        --安全性目录:如selinux或pam.d等管理系统安全性的目录;
                audit:这个目录所代表的是一种和目录名称一致的audit安全机制,主要以服务的方式协助管理员持续监控各文件被存取的情况;目录下的audit.rules文件主要是定义一些必要的监控规则;
                pam.d:此目录是Linux-PAM的所有配置文件,配合/lib/security目录中所有觉得函数库,提供Linux下的应用程序认证的机制;
                pam_pkcs11:PAM机制中的一种登录模块,可以让用户通过smart card做登录的操作;
                pki:PKI是一种公开密钥的管理方式,通过这样的管理模式,可以让所有网络传输有更多保障;
                racoon:这个目录是由ipsec-tools组件所提供的,ipsec的主要目的是让系统实现VPN的网路技术,在racoon目录的主配置文件racoon.conf中,定义在ipsec操作中所需要的加密算法种类以及其他细节的配置;
                security:与pam.d目录相辅相成,pam.d中的所有PAM的规则都要用到/lib/security下的PAM函数库,而/etc/security目录中,就是针对这些函数库,提供以配置文件的方式进行细节配置,对希望调整系统安全性部分增加了非常大的方便性;
                selinux:selinux是一个很新的安全性方案,它是一种针对各种文件、目录、设备或daemon等在linux所需使用到的安全性机制,而且其安全性的数据时直接记录在文件系统中;
                wpa_supplicant:这个目录被归类到安全性目录中,是因为其属于无线中安全认证的部分,存在wpa_supplicant.conf配置文件,用户可以在这个目录中加入已知可登陆的AP;
        --X Windows目录:如X11或gdm管理X windows启动或使用上的配置目录;
                alternatives:linux下可辨识扩展名的"文件类型"选项,可以针对同一类型的文件,选出一个默认用户所要使用的程序去执行;/etc/alternatives目录下有所有目前已经定义的程序名称,都以软链接的方式存在,里面每一个文件其实都有定义好的默认执行程序,可以使用alternatives命令查看及修改配置;
                fonts:这个目录就是fontconfig软件的最主要配置目录,其中/etc/fonts/fonts.conf就是对应的配置文件,/etc/fonts目录下的配置都是以XML的方式配置的;
                gconf:这一目录是GConf2的组件所建立的,GConf的作用就是提供GNOME下的应用程序注册的机制,有些类似于windows下的regedit;
                gdm:全名为GNOME Display Manger,也就是协助X Windows启动的管理软件,在GDM中的主配置文件是custom.conf,在X windows下可以利用gdmsetup命令对这个文件进行配置;
                gnome-vfs-2.0:GNOME VFS机制,让GNOME的系统可以知道每一种文件格式要如何开启或浏览,而所有的配置都需要有相对应的函数库;
                gtk-2.0:由gtk+组件提供的目录,主要是提供X Windows窗口的颜色、按钮或图案,包含软件选项的画面、选项的按钮、滚动轴的样式等;
                kde:KDE Desktop Manager的主要配置目录;主配置文件是kdmrc;
                NetworkManager:此目录的目的是让用户不需要做任何操作和配置,只要用户曾经登陆过无线AP,系统就可以记录下来,以后再次登陆时就可以方便的登陆;
                pango:pango是一套协助GTK+将字体描绘出来的函数库,不论任何的字体或语言,都可以通过pango描绘出来;
                rhgb:系统在进入X Windows之前,有一个前置配置的图形接口,这个接口就是rhgb,其主要目的是让系统启动变得漂亮;
                scim:是Linux下目前很好用的输入法;
                sound:GNOME下有许多的应用软件,很多都会有其特殊的声音,这个目录中存放所有声音的命令路径;
                X11:X windows的核心配置目录;该目录下比较重要的文件有prefdm(判断X windows使用哪一个Display Manager)、主配置文件xorg.conf(定了X windows所需使用的键盘、鼠标、显卡等相关硬件设备,重点是关于显卡的配置)、xinit子目录(里面都是一些X windows资源相关的配置)
                xdg:X windows上的菜单画面,就是从这里出来的,所有在X windows中使用的菜单文字及分类,都可以在这个目录下做配置,其下的子目录menu,可以通过配置里面的文件自定义应用程序、系统管理、外观等菜单内容                       
        --其他目录:针对单一特殊软件的配置或未能按以上分类方式则放在此目录中;
                a2ps.cfg和a2ps-site.cfg:用于将一份文件格式转换为postscript的格式,在某些打印机或要将文件输出成一份标准格式的文件时,它会被用到;
                alsa:主要任务在于提供linux声音及声音的功能,并试着让其性能达到最佳化;
                ghostscript:在linux下要读取Adobe格式文件(如pdf),最方便的方式就是使用ghostscript命令,这个目录主要用于设置在显示时使用哪种字体作为默认字体;
                gre.d:GRE是Mozilla注册的一种机制,目录中的配置文件gre.conf会注明所使用的Mozilla软件的路径和版本;
                iproute2:iproute2是一套非常强大的网络管理软件,iproute2提供的功能有很多种,此目录中存放一些网络的基本配置值;
                java:这个目录是由jpackage-utils软件提供的,这个目录是这个软件的主要配置目录,除此之外还有maven、jvm、jvm-common都是由jpackage-utils软件产生的,jpackage是一个专门为了提供java程序与函数库所存在的软件;
                mgetty+sendfax:主要用于使用linux架构一台fax server,可以使用mgetty.config来配置需要有关传真接收和发送的操作;
                php.d:主要存放的各软件(如dbase、ldap、mysql等)与php相关的配置文件;
                reader.conf.d:存放smart card配置文件的目录,由程序pcsc-lite提供,这个程序的主配置文件是/etc/reader.conf;
                dumpdates:存放dump命令的执行日期,dump命令可以对ext2/ext3文件系统进行检查备份;
/lib:需要共享的函数库与kernel模块,系统kernel启动所使用的函数库,或者当执行一些在/bin和/sbin中的命令时使用的函数库;
/media:移动存储设备默认挂载点;(如光盘)
/mnt:临时挂载用的设备挂载点;(如磁盘分区,网络共享)
/opt:额外所安装的应用程序目录,有些软件包我们可以将它安装在该目录中;(一般为空,某些应用软件安装需要这个目录)
/root:管理员root的宿主目录
/sys:虚拟文件系统,被建立在内存中,是在2.6版的kernel之后才被加入到正式的文件系统中,以分类的方式将系统的信息存放在这个目录中,以方便linux用户通过不同的分类找出系统相关的信息;
/tmp:临时文件存放区域;(默认被设置了粘滞位)
/var:动态文件或数据存放目录,默认日志文件都存放在这个目录下,一般建议把此目录单独划分一个分区;
        /var/account:是linux系统下的审核机制(psacct)对应的目录;
        /var/cache:该目录下的文件时所有程序所产生的缓存数据,也就是当应用程序启动时,会将数据留一份在这个目录中;
        /var/empty:默认是sshd程序用到的这个目录,当建立ssh连接,ssh服务器必须使用该目录下的sshd子目录;
        /var/ftp:ftp服务器软件一般默认会将匿名登陆的用户的宿主目录;
        /var/gdm:gdm所使用的目录,里面存放一些系统当前所占用的console记录及通过gdm执行的X windows记录,只有通过gdm窗口的日志才会存放在此;
        /var/lib:该目录下存放很多与应用程序名称同名的子目录,每个子目录下都是应用执行的状态信息;
        /var/lock:每个服务一开始都会在这个目录下产生一个该服务的空文件,主要是避免服务启动冲突;
        /var/log:常用目录,专门用来存放所有日志文件的目录,里面存放很多系统、软件、用户等相关的日志信息;里面有一些文件是比较常用的;
                lastlog:记录用户最后一次登录的信息,使用lastlog命令读取;
                message:记录系统的几乎所有信息,主要包括启动信息,syslogd服务记录的信息等;
                wtmp:记录所有用户登陆及注销的信息,使用last命令读取;
                secure:记录登录系统访问数据的文件,如ssh pop3 telnet ftp等都会记录在此文件中
                /var/log/httpd/access_log:httpd访问日志
                /var/log/httpd/error_log:httpd错误日志
                btmp:记录失败的用户登录
                utmp: 纪录当前登录的每个用户
                xferlog:ftp会话日志
                boot.log:记录开机或一些服务启动时所显示的启动和关闭信息
                /var/log/maillog或/var/log/mail/*:记录邮件访问或往来的用户信息
                cron: 记录crontab例行性服务的内容
                dmesg:开机引导日志信息
                sudolog:纪录使用sudo发出的命令
                sulog: 纪录使用su命令的使用
        /var/named:bind软件实现的DNS服务器的区域数据文件都存放在这个目录下;
        /var/nis和/var/yp:都是NIS服务机制所使用的目录,nis主要记录所有网络中每一个client的连接信息;yp是linux的nis服务的日志文件存放的目录;
        /var/run:此目录中的大部分文件都记载目前系统正在执行程序的PID值,每一个文件都是以个独立的PID记录;此目录下存放一个特殊文件utmp,此文件记录目前谁在使用系统,必须使用utmpdump命令才能看到其中的内容;
        /var/spool:里面主要都是一些临时存放,随时会被用户所调用的数据;打印机、邮件、代理服务器等假脱机目录存放在该目录下;
        /var/tmp:专门为了一些应用程序在安装或执行时,需要在重启后使用的某些文件时,能将该文件暂时存放在这个目录中,完成后再行删除;
        /var/www:apache网页服务器的宿主目录;
好文要顶 关注我 收藏该文

#413 内核模块 » 进程间的通信 » 2022-09-03 15:23:12

batsom
回复: 0

前面在聊到进程的概念的时候,我们知道,进程在操作系统中是一个个相互独立的个体,也就是说,在没有外力介入的条件下,进程之间相互独立,是无法相互看见对方的。

那么操作系统是如何使进程之间相互独立,无法看见对方呢?这里用到了一种叫做虚拟内存的技术。

在前面聊到动态库的时候,我们提过一句虚拟内存的机制。(想要看动态库内容的朋友点这里)
那么究竟虚拟内存是个什么情况呢?
70

这个就是虚拟内存到物理内存转化的示意图,也就是说,我们用高级语言写出来的程序,其中指针所指向的地址,其实是虚拟地址。

进程1和进程2在创建出来的时候,都会认为它本身独占4G内存。但实际上,这4G内存是操作系统提供的虚拟地址,只有通过页表转化成物理地址的部分,才占有实际的物理内存。

那么为什么要 使用虚拟地址呢?

其实在早期的时候,加载程序是使用物理地址的,但是后来发现各个相互独立的进程之间可能会发生影响,如果一个进程访问出错,越界访问到了别的进程,那么这两个进程最终可能都会挂掉。

所以后来智慧的程序猿就研究出了这样的方法,也就是虚拟内存机制,这极大的提升了进程的独立性,但是也带来了一些问题。

比如,两个进程的确有一些需求,想要进行一些数据传输。
多个进程之间共享同样的资源。
一个进程需要向另一个进程发送消息,通知它们发生了某种事件。
有些进程希望完全控制另一个进程,此时控制进程希望能够拦截另一个进程所陷入的异常,并且能够及时知道它的状态改变(Debug进程)。

这就是我们进程通信的目的。

而现在我们需要通过一些手段来进行进程间的通信,既然进程之间是相互独立的,它们之间相互无法看见,那么作为进程的管理者,当然是有办法看见不同的进程了。

所以我们现在进程通信的方法,都是两个进程通过某种中间的媒介,来达到通信的目的。

现在常见的进程通信有以下分类:

    管道:

    匿名管道pipe。
    命名管道。

    System V IPC

    消息队列
    共享内存
    信号量

    POSIX IPC

    消息队列
    共享内存
    信号量
    读写锁

但是一旦涉及到通信,势必会涉及到同步和互斥的问题!

首先解释什么叫做同步:同步就是进程间的相互制约,多个进程需要相互配合完成一项任务。

举个栗子:A进程是一个打印进程,B进程是一个计算进程,A进程要打印B进程的执行结果,在执行过程中,A进程先运行了,但是等A执行到打印那一步的时候,B进程的结果还没有计算出来,这个时候A进程就被阻塞在这里了,只有等B执行出计算结果的时候,A进场才可以完成打印。在A进程阻塞等待B进程的过程就可以理解为同步。

那么什么是互斥呢?

互斥是由于多个进程之间需要访问临界资源,而临界资源同时只能被有限个进程访问,各个进程需要相互竞争使用这些资源,这种关系称为互斥。

临界资源:同时只允许有限个进程访问的资源,被称之为临界资源,访问临界资源的代码叫做临界区。

同样,举个栗子:A进程和B进程都是打印进程,但是这台计算机只有一台打印机,A和B同时只能有一个进程在访问打印机,那么A和B就是互斥关系,打印机就是临界资源。

进程之间的通信在很多情况下都会涉及到同步互斥,比如两个进程,一端在读,一端在写,那么写端在写的时候,读端就不能读,否则可能会读出错误信息。而读端在读的时候,写端就不能写,否则还是可能会造成信息不对称。

#414 内核模块 » Linux进程间通信(七):消息队列 msgget()、msgsend()、msgrcv()、msgctl() » 2022-09-02 16:17:18

batsom
回复: 0

下面来说说如何用不用消息队列来进行进程间的通信,消息队列与命名管道有很多相似之处。有关命名管道的更多内容可以参阅我的另一篇文章:Linux进程间通信 -- 使用命名管道
一、什么是消息队列

消息队列提供了一种从一个进程向另一个进程发送一个数据块的方法。  每个数据块都被认为含有一个类型,接收进程可以独立地接收含有不同类型的数据结构。我们可以通过发送消息来避免命名管道的同步和阻塞问题。但是消息队列与命名管道一样,每个数据块都有一个最大长度的限制。

Linux用宏MSGMAX和MSGMNB来限制一条消息的最大长度和一个队列的最大长度。
二、在Linux中使用消息队列

Linux提供了一系列消息队列的函数接口来让我们方便地使用它来实现进程间的通信。它的用法与其他两个System V PIC机制,即信号量和共享内存相似。

1、msgget()函数

该函数用来创建和访问一个消息队列。它的原型为:

    int msgget(key_t, key, int msgflg);

与其他的IPC机制一样,程序必须提供一个键来命名某个特定的消息队列。msgflg是一个权限标志,表示消息队列的访问权限,它与文件的访问权限一样。msgflg可以与IPC_CREAT做或操作,表示当key所命名的消息队列不存在时创建一个消息队列,如果key所命名的消息队列存在时,IPC_CREAT标志会被忽略,而只返回一个标识符。

它返回一个以key命名的消息队列的标识符(非零整数),失败时返回-1.

2、msgsnd()函数

该函数用来把消息添加到消息队列中。它的原型为:

    int msgsend(int msgid, const void *msg_ptr, size_t msg_sz, int msgflg);

msgid是由msgget函数返回的消息队列标识符。

msg_ptr是一个指向准备发送消息的指针,但是消息的数据结构却有一定的要求,指针msg_ptr所指向的消息结构一定要是以一个长整型成员变量开始的结构体,接收函数将用这个成员来确定消息的类型。所以消息结构要定义成这样:

struct my_message {
    long int message_type;
    /* The data you wish to transfer */
};

msg_sz 是msg_ptr指向的消息的长度,注意是消息的长度,而不是整个结构体的长度,也就是说msg_sz是不包括长整型消息类型成员变量的长度。

msgflg 用于控制当前消息队列满或队列消息到达系统范围的限制时将要发生的事情。

如果调用成功,消息数据的一分副本将被放到消息队列中,并返回0,失败时返回-1.

3、msgrcv()函数

该函数用来从一个消息队列获取消息,它的原型为

    int msgrcv(int msgid, void *msg_ptr, size_t msg_st, long int msgtype, int msgflg);

msgid, msg_ptr, msg_st 的作用也函数msgsnd()函数的一样。

msgtype 可以实现一种简单的接收优先级。如果msgtype为0,就获取队列中的第一个消息。如果它的值大于零,将获取具有相同消息类型的第一个信息。如果它小于零,就获取类型等于或小于msgtype的绝对值的第一个消息。

msgflg 用于控制当队列中没有相应类型的消息可以接收时将发生的事情。

调用成功时,该函数返回放到接收缓存区中的字节数,消息被复制到由msg_ptr指向的用户分配的缓存区中,然后删除消息队列中的对应消息。失败时返回-1。

4、msgctl()函数

该函数用来控制消息队列,它与共享内存的shmctl函数相似,它的原型为:

    int msgctl(int msgid, int command, struct msgid_ds *buf);

command是将要采取的动作,它可以取3个值,

    IPC_STAT:把msgid_ds结构中的数据设置为消息队列的当前关联值,即用消息队列的当前关联值覆盖msgid_ds的值。
    IPC_SET:如果进程有足够的权限,就把消息列队的当前关联值设置为msgid_ds结构中给出的值
    IPC_RMID:删除消息队列

buf是指向msgid_ds结构的指针,它指向消息队列模式和访问权限的结构。msgid_ds结构至少包括以下成员:

struct msgid_ds
{
    uid_t shm_perm.uid;
    uid_t shm_perm.gid;
    mode_t shm_perm.mode;
};

成功时返回0,失败时返回-1.
三、使用消息队列进行进程间通信

马不停蹄,介绍完消息队列的定义和可使用的接口之后,我们来看看它是怎么让进程进行通信的。由于可以让不相关的进程进行行通信,所以我们在这里将会编写两个程序,msgreceive()和msgsned()来表示接收和发送信息。根据正常的情况,我们允许两个程序都可以创建消息,但只有接收者在接收完最后一个消息之后,它才把它删除。

接收信息的程序源文件为msgreceive.c的源代码为:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/msg.h>
#include <errno.h>

struct msg_st
{
    long int msg_type;
    char text[BUFSIZ];
};

int main(int argc, char **argv)
{
    int msgid = -1;
    struct msg_st data;
    long int msgtype = 0;   // 注意1

    // 建立消息队列
    msgid = msgget((key_t)1234, 0666 | IPC_CREAT);
    if (msgid == -1)
    {
        fprintf(stderr, "msgget failed width error: %d\n", errno);
        exit(EXIT_FAILURE);
    }

    // 从队列中获取消息,直到遇到end消息为止
    while (1)
    {
        if (msgrcv(msgid, (void *)&data, BUFSIZ, msgtype, 0) == -1)
        {
            fprintf(stderr, "msgrcv failed width erro: %d", errno);
        }

        printf("You wrote: %s\n", data.text);

        // 遇到end结束
        if (strncmp(data.text, "end", 3) == 0)
        {
            break;
        }
    }

    // 删除消息队列
    if (msgctl(msgid, IPC_RMID, 0) == -1)
    {
        fprintf(stderr, "msgctl(IPC_RMID) failed\n");
    }

    exit(EXIT_SUCCESS);
}

发送信息的程序的源文件msgsend.c的源代码为:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/msg.h>
#include <errno.h>

#define MAX_TEXT 512

struct msg_st
{
    long int msg_type;
    char text[MAX_TEXT];
};

int main(int argc, char **argv)
{
    struct msg_st data;
    char buffer[BUFSIZ];
    int msgid = -1;

    // 建立消息队列
    msgid = msgget((key_t)1234, 0666 | IPC_CREAT);
    if (msgid == -1)
    {
        fprintf(stderr, "msgget failed error: %d\n", errno);
        exit(EXIT_FAILURE);
    }

    // 向消息队里中写消息,直到写入end
    while (1)
    {
        printf("Enter some text: \n");
        fgets(buffer, BUFSIZ, stdin);
        data.msg_type = 1; // 注意2
        strcpy(data.text, buffer);

        // 向队列里发送数据
        if (msgsnd(msgid, (void *)&data, MAX_TEXT, 0) == -1)
        {
            fprintf(stderr, "msgsnd failed\n");
            exit(EXIT_FAILURE);
        }

        // 输入end结束输入
        if (strncmp(buffer, "end", 3) == 0)
        {
            break;
        }

        sleep(1);
    }

    exit(EXIT_SUCCESS);
}

运行结果如下:

四、例子分析——消息类型

这里主要说明一下消息类型是怎么一回事,注意msgreceive.c文件main()函数中定义的变量msgtype(注释为注意1),它作为msgrcv()函数的接收信息类型参数的值,其值为0,表示获取队列中第一个可用的消息。再来看看msgsend.c文件中while循环中的语句data.msg_type = 1(注释为注意2),它用来设置发送的信息的信息类型,即其发送的信息的类型为1。所以程序msgreceive()能够接收到程序msgsend()发送的信息。

如果把注意1,即msgreceive.c文件main()函数中的语句由long int msgtype = 0;改变为long int msgtype = 2;会发生什么情况,msgreceive()将不能接收到程序msgsend()发送的信息。因为在调用msgrcv()函数时,如果msgtype(第四个参数)大于零,则将只获取具有相同消息类型的第一个消息,修改后获取的消息类型为2,而msgsend()发送的消息类型为1,所以不能被msgreceive()程序接收。重新编译msgreceive.c文件并再次执行,其结果如下:

我们可以看到,msgreceive并没有接收到信息和输出,而且当msgsend输入end结束后,msgreceive也没有结束,通过jobs命令我们可以看到它还在后台运行着。
五、消息队列与命名管道的比较

消息队列跟命名管道有不少的相同之处,通过与命名管道一样,消息队列进行通信的进程可以是不相关的进程,同时它们都是通过发送和接收的方式来传递数据的。在命名管道中,发送数据用write(),接收数据用read(),则在消息队列中,发送数据用msgsnd(),接收数据用msgrcv()。而且它们对每个数据都有一个最大长度的限制。

与命名管道相比,消息队列的优势在于:

    1、消息队列也可以独立于发送和接收进程而存在,从而消除了在同步命名管道的打开和关闭时可能产生的困难。

    2、同时通过发送消息还可以避免命名管道的同步和阻塞问题,不需要由进程自己来提供同步方法。

    3、接收程序可以通过消息类型有选择地接收数据,而不是像命名管道中那样,只能默认地接收。

#415 内核模块 » Linux进程间通信(六):共享内存 shmget()、shmat()、shmdt()、shmctl() » 2022-09-02 16:14:48

batsom
回复: 0

下面将讲解进程间通信的另一种方式,使用共享内存。
一、什么是共享内存

顾名思义,共享内存就是允许两个不相关的进程访问同一个逻辑内存。共享内存是在两个正在运行的进程之间共享和传递数据的一种非常有效的方式。不同进程之间共享的内存通常安排为同一段物理内存。进程可以将同一段共享内存连接到它们自己的地址空间中,所有进程都可以访问共享内存中的地址,就好像它们是由用C语言函数malloc()分配的内存一样。而如果某个进程向共享内存写入数据,所做的改动将立即影响到可以访问同一段共享内存的任何其他进程。

特别提醒:共享内存并未提供同步机制,也就是说,在第一个进程结束对共享内存的写操作之前,并无自动机制可以阻止第二个进程开始对它进行读取。所以我们通常需要用其他的机制来同步对共享内存的访问,例如前面说到的信号量。有关信号量的更多内容,可以查阅另一篇文章:Linux进程间通信 -- 使用信号量
二、共享内存的使得

与信号量一样,在Linux中也提供了一组函数接口用于使用共享内存,而且使用共享共存的接口还与信号量的非常相似,而且比使用信号量的接口来得简单。它们声明在头文件 sys/shm.h 中。

1、shmget()函数

该函数用来创建共享内存,它的原型为:

    int shmget(key_t key, size_t size, int shmflg);

第一个参数,与信号量的semget函数一样,程序需要提供一个参数key(非0整数),它有效地为共享内存段命名,shmget()函数成功时返回一个与key相关的共享内存标识符(非负整数),用于后续的共享内存函数。调用失败返回-1.

不相关的进程可以通过该函数的返回值访问同一共享内存,它代表程序可能要使用的某个资源,程序对所有共享内存的访问都是间接的,程序先通过调用shmget()函数并提供一个键,再由系统生成一个相应的共享内存标识符(shmget()函数的返回值),只有shmget()函数才直接使用信号量键,所有其他的信号量函数使用由semget函数返回的信号量标识符。

第二个参数,size以字节为单位指定需要共享的内存容量

第三个参数,shmflg是权限标志,它的作用与open函数的mode参数一样,如果要想在key标识的共享内存不存在时,创建它的话,可以与IPC_CREAT做或操作。共享内存的权限标志与文件的读写权限一样,举例来说,0644,它表示允许一个进程创建的共享内存被内存创建者所拥有的进程向共享内存读取和写入数据,同时其他用户创建的进程只能读取共享内存。

2、shmat()函数    -- at:attach

第一次创建完共享内存时,它还不能被任何进程访问,shmat()函数的作用就是用来启动对该共享内存的访问,并把共享内存连接到当前进程的地址空间。它的原型如下:

    void *shmat(int shm_id, const void *shm_addr, int shmflg);

第一个参数,shm_id是由shmget()函数返回的共享内存标识。

第二个参数,shm_addr指定共享内存连接到当前进程中的地址位置,通常为空,表示让系统来选择共享内存的地址。

第三个参数,shm_flg是一组标志位,通常为0。

调用成功时返回一个指向共享内存第一个字节的指针,如果调用失败返回-1.

3、shmdt()函数    -- dt:detach

该函数用于将共享内存从当前进程中分离。注意,将共享内存分离并不是删除它,只是使该共享内存对当前进程不再可用。它的原型如下:

    int shmdt(const void *shmaddr);

参数shmaddr是shmat()函数返回的地址指针,调用成功时返回0,失败时返回-1.

4、shmctl()函数    -- ctl:control

与信号量的semctl()函数一样,用来控制共享内存,它的原型如下:

    int shmctl(int shm_id, int command, struct shmid_ds *buf);

第一个参数,shm_id是shmget()函数返回的共享内存标识符。

第二个参数,command是要采取的操作,它可以取下面的三个值 :

    IPC_STAT:把shmid_ds结构中的数据设置为共享内存的当前关联值,即用共享内存的当前关联值覆盖shmid_ds的值。
    IPC_SET:如果进程有足够的权限,就把共享内存的当前关联值设置为shmid_ds结构中给出的值
    IPC_RMID:删除共享内存段

第三个参数,buf是一个结构指针,它指向共享内存模式和访问权限的结构。

shmid_ds结构 至少包括以下成员:

struct shmid_ds
{
    uid_t shm_perm.uid;
    uid_t shm_perm.gid;
    mode_t shm_perm.mode;
};
三、使用共享内存进行进程间通信

说了这么多,又到了实战的时候了。下面就以两个不相关的进程来说明进程间如何通过共享内存来进行通信。其中一个文件shmread.c创建共享内存,并读取其中的信息,另一个文件shmwrite.c向共享内存中写入数据。为了方便操作和数据结构的统一,为这两个文件定义了相同的数据结构,定义在文件shmdata.c中。结构shared_use_st中的written作为一个可读或可写的标志,非0:表示可读,0:表示可写,text则是内存中的文件。

shmdata.h的源代码如下:

#ifndef _SHMDATA_H_HEADER
#define _SHMDATA_H_HEADER

#define TEXT_SZ 2048

struct shared_use_st
{
    int written; // 作为一个标志,非0:表示可读,0:表示可写
    char text[TEXT_SZ]; // 记录写入 和 读取 的文本
};

#endif

源文件shmread.c的源代码如下:

#include <stddef.h>
#include <sys/shm.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include "shmdata.h"

int main(int argc, char **argv)
{
    void *shm = NULL;
    struct shared_use_st *shared; // 指向shm
    int shmid; // 共享内存标识符

    // 创建共享内存
    shmid = shmget((key_t)1234, sizeof(struct shared_use_st), 0666|IPC_CREAT);
    if (shmid == -1)
    {
        fprintf(stderr, "shmat failed\n");
        exit(EXIT_FAILURE);
    }

    // 将共享内存连接到当前进程的地址空间
    shm = shmat(shmid, 0, 0);
    if (shm == (void *)-1)
    {
        fprintf(stderr, "shmat failed\n");
        exit(EXIT_FAILURE);
    }

    printf("\nMemory attached at %X\n", (int)shm);

    // 设置共享内存
    shared = (struct shared_use_st*)shm; // 注意:shm有点类似通过 malloc() 获取到的内存,所以这里需要做个 类型强制转换
    shared->written = 0;
    while (1) // 读取共享内存中的数据
    {
        // 没有进程向内存写数据,有数据可读取
        if (shared->written == 1)
        {
            printf("You wrote: %s", shared->text);
            sleep(1);

            // 读取完数据,设置written使共享内存段可写
            shared->written = 0;

            // 输入了 end,退出循环(程序)
            if (strncmp(shared->text, "end", 3) == 0)
            {
                break;
            }
        }
        else // 有其他进程在写数据,不能读取数据
        {
            sleep(1);
        }
    }

    // 把共享内存从当前进程中分离
    if (shmdt(shm) == -1)
    {
        fprintf(stderr, "shmdt failed\n");
        exit(EXIT_FAILURE);
    }

    // 删除共享内存
    if (shmctl(shmid, IPC_RMID, 0) == -1)
    {
        fprintf(stderr, "shmctl(IPC_RMID) failed\n");
        exit(EXIT_FAILURE);
    }

    exit(EXIT_SUCCESS);
}

源文件shmwrite.c的源代码如下:

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/shm.h>
#include "shmdata.h"

int main(int argc, char **argv)
{
    void *shm = NULL;
    struct shared_use_st *shared = NULL;
    char buffer[BUFSIZ + 1]; // 用于保存输入的文本
    int shmid;

    // 创建共享内存
    shmid = shmget((key_t)1234, sizeof(struct shared_use_st), 0666|IPC_CREAT);
    if (shmid == -1)
    {
        fprintf(stderr, "shmget failed\n");
        exit(EXIT_FAILURE);
    }

    // 将共享内存连接到当前的进程地址空间
    shm = shmat(shmid, (void *)0, 0);
    if (shm == (void *)-1)
    {
        fprintf(stderr, "shmat failed\n");
        exit(EXIT_FAILURE);
    }

    printf("Memory attched at %X\n", (int)shm);

    // 设置共享内存
    shared = (struct shared_use_st *)shm;
    while (1) // 向共享内存中写数据
    {
        // 数据还没有被读取,则等待数据被读取,不能向共享内存中写入文本
        while (shared->written == 1)
        {
            sleep(1);
            printf("Waiting...\n");
        }

        // 向共享内存中写入数据
        printf("Enter some text: ");
        fgets(buffer, BUFSIZ, stdin);
        strncpy(shared->text, buffer, TEXT_SZ);

        // 写完数据,设置written使共享内存段可读
        shared->written = 1;

        // 输入了end,退出循环(程序)
        if (strncmp(buffer, "end", 3) == 0)
        {
            break;
        }
    }

    // 把共享内存从当前进程中分离
    if (shmdt(shm) == -1)
    {
        fprintf(stderr, "shmdt failed\n");
        exit(EXIT_FAILURE);
    }

    sleep(2);
    exit(EXIT_SUCCESS);
}

再来看看运行的结果:

分析:

1、程序shmread创建共享内存,然后将它连接到自己的地址空间。在共享内存的开始处使用了一个结构struct_use_st。该结构中有个标志written,当共享内存中有其他进程向它写入数据时,共享内存中的written被设置为0,程序等待。当它不为0时,表示没有进程对共享内存写入数据,程序就从共享内存中读取数据并输出,然后重置设置共享内存中的written为0,即让其可被shmwrite进程写入数据。

2、程序shmwrite取得共享内存并连接到自己的地址空间中。检查共享内存中的written,是否为0,若不是,表示共享内存中的数据还没有被完,则等待其他进程读取完成,并提示用户等待。若共享内存的written为0,表示没有其他进程对共享内存进行读取,则提示用户输入文本,并再次设置共享内存中的written为1,表示写完成,其他进程可对共享内存进行读操作。
四、关于前面的例子的安全性讨论

这个程序是不安全的,当有多个程序同时向共享内存中读写数据时,问题就会出现。可能你会认为,可以改变一下written的使用方式,例如,只有当written为0时进程才可以向共享内存写入数据,而当一个进程只有在written不为0时才能对其进行读取,同时把written进行加1操作,读取完后进行减1操作。这就有点像文件锁中的读写锁的功能。咋看之下,它似乎能行得通。但是这都不是原子操作,所以这种做法是行不能的。试想当written为0时,如果有两个进程同时访问共享内存,它们就会发现written为0,于是两个进程都对其进行写操作,显然不行。当written为1时,有两个进程同时对共享内存进行读操作时也是如些,当这两个进程都读取完是,written就变成了-1.

要想让程序安全地执行,就要有一种进程同步的进制,保证在进入临界区的操作是原子操作。例如,可以使用前面所讲的信号量来进行进程的同步。因为信号量的操作都是原子性的。
五、使用共享内存的优缺点

1、优点:我们可以看到使用共享内存进行进程间的通信真的是非常方便,而且函数的接口也简单,数据的共享还使进程间的数据不用传送,而是直接访问内存,也加快了程序的效率。同时,它也不像匿名管道那样要求通信的进程有一定的父子关系。

2、缺点:共享内存没有提供同步的机制,这使得我们在使用共享内存进行进程间通信时,往往要借助其他的手段来进行进程间的同步工作。

#416 内核模块 » 冬天OS(二十三):宏内核改微内核 » 2022-09-01 18:01:07

batsom
回复: 0

--------------------------------------------------------
IPC 消息机制的建立

--------------------------------------------------------

宏内核 VS 微内核
将内核完成的任务交给专门的任务进程来完成:例如 get_ticks 系统调用,在宏内核中 get_ticks 是作为一个系统调用函数存在的,通过系统调用函数表,内核在 ring0 执行函数 get_ticks,get_ticks 返回,这次系统调用才算结束,而在微内核中,get_ticks 不是系统调用,而是作为某个任务进程将要执行的功能函数而存在的,内核在 ring0 通过发送消息唤醒任务进程之后就立即返回!

一,建立消息通信机制

·sys_send 函数:
PRIVATE int msg_send(struct proc *current, int dest, MESSAGE *m)// current 是谁要发							{								// dest 是发给谁								 							// m 是要发送的消息
	struct proc *sender = current;
	struct proc *p_dest = proc_table + dest;
 
	// 确保 sender 不是给自己发
	assert(dest != proc2pid(sender));
 
	/* 确保没有死锁 */
	if (deadlock(proc2pid(sender), dest))
		panic(">>DEADLOCK<< %s->%s", sender->name, p_dest->name);
 
	if ((p_dest->p_flags & RECEIVING) && /* dest is waiting for the msg */
		(p_dest->p_recvfrom == proc2pid(sender) ||
		 p_dest->p_recvfrom == ANY))
	{
 
		assert(p_dest->p_msg); // p_dest->p_msg 的内存必须要开辟好
		assert(m);
 
		phys_copy(va2la(dest, p_dest->p_msg), 		// p_dest 的 msg 的线性地址同样是等于:[p_dest 的 DS : offset(p_msg)]
				  va2la(proc2pid(sender), m), 	// 内核态下所有对用户态下的数据的访问,都要基于这种形式
				  sizeof(MESSAGE));
 
		p_dest->p_msg = 0;
		p_dest->p_flags &= ~RECEIVING; /* dest has received the msg */
		p_dest->p_recvfrom = NO_TASK;  // 清空 p_dest 上次是因为等待谁而阻塞!
		unblock(p_dest);
 
		assert(p_dest->p_flags == 0);
		assert(p_dest->p_msg == 0);
		assert(p_dest->p_recvfrom == NO_TASK);
		assert(p_dest->p_sendto == NO_TASK);
		assert(sender->p_flags == 0);
		assert(sender->p_msg == 0);
		assert(sender->p_recvfrom == NO_TASK);
		assert(sender->p_sendto == NO_TASK);
	}
	else
	{ 	/* dest is not waiting for the msg */
		sender->p_flags |= SENDING;
		assert(sender->p_flags == SENDING);
		sender->p_sendto = dest;
		sender->p_msg = m; // m 其实就是 sender 要发送给 dest 的包,
				   // 只是这里在构建 sender 的阻塞状态的时候需要让 p_msg 指向 m
 
		/* append to the sending queue */
		struct proc *p;
		if (p_dest->q_sending)
		{
			p = p_dest->q_sending;
			while (p->next_sending)
				p = p->next_sending;
			p->next_sending = sender;
		}
		else
			p_dest->q_sending = sender;
		sender->next_sending = 0;
 
		block(sender); // 把控制权交给别人,等同于自己同时阻塞
 
		assert(sender->p_flags == SENDING);
		assert(sender->p_msg != 0);
		assert(sender->p_recvfrom == NO_TASK);
		assert(sender->p_sendto == dest);
	}
 
	return 0;
}

——sys_send 首先简单地检查是否产生死锁,然后检查如果 recv 方正在 recving 并且 want recv 就是自己(或者 any),就将消息赋值给 recv 方,然后唤醒对方!否则 recv 方没打算接收或者想接收的不是自己,就进行阻塞前的准备设置然后阻塞!

·sys_receive 函数:
PRIVATE int msg_receive(struct proc *current, int src, MESSAGE *m) // m:将消息往回收到 m 中
{																   // sec:从哪里收
																   // current:谁想回收
	struct proc *p_who_wanna_recv = current;					  
	struct proc *p_from = 0;									   /* from which the message will be fetched */
	struct proc *prev = 0;
	int copyok = 0;
 
	assert(proc2pid(p_who_wanna_recv) != src); // 不能从自己接收
 
	if ((p_who_wanna_recv->has_int_msg) &&
		((src == ANY) || (src == INTERRUPT)))
	{
		/* There is an interrupt needs p_who_wanna_recv's handling and
		 * p_who_wanna_recv is ready to handle it.
		 */
 
		MESSAGE msg;
		reset_msg(&msg);
		msg.source = INTERRUPT;
		msg.type = HARD_INT;
 
		assert(m);
 
		// 代表着接收到了中断消息,然后满载而归!
		// 注意这里并没有随之唤醒“中断进程”,因为中断不会阻塞着等待接收进程接收!
		phys_copy(va2la(proc2pid(p_who_wanna_recv), m), &msg,
				  sizeof(MESSAGE));
 
		p_who_wanna_recv->has_int_msg = 0;
 
		assert(p_who_wanna_recv->p_flags == 0);
		assert(p_who_wanna_recv->p_msg == 0);
		assert(p_who_wanna_recv->p_sendto == NO_TASK);
		assert(p_who_wanna_recv->has_int_msg == 0);
 
		return 0;
	}
 
	/* Arrives here if no interrupt for p_who_wanna_recv. */
	if (src == ANY)
	{
		/* p_who_wanna_recv is ready to receive messages from
		 * ANY proc, we'll check the sending queue and pick the
		 * first proc in it.
		 */
		if (p_who_wanna_recv->q_sending)
		{
			// p_frome 就是 p_who_wanna_recv 等待队列上的第一个
			p_from = p_who_wanna_recv->q_sending;
			copyok = 1;
 
			assert(p_who_wanna_recv->p_flags == 0);
			assert(p_who_wanna_recv->p_msg == 0);
			assert(p_who_wanna_recv->p_recvfrom == NO_TASK);
			assert(p_who_wanna_recv->p_sendto == NO_TASK);
			assert(p_who_wanna_recv->q_sending != 0);
			assert(p_from->p_flags == SENDING);
			assert(p_from->p_msg != 0);
			assert(p_from->p_recvfrom == NO_TASK);
			assert(p_from->p_sendto == proc2pid(p_who_wanna_recv));
		}
	}
	else if (src >= 0 && src < NR_TASKS + NR_PROCS)
	{
		/* p_who_wanna_recv wants to receive a message from
		 * a certain proc: src.
		 */
 
		// 接收者就想接收指定进程的消息,那么开始判断指定进程是否在发送消息并且在给自己发送消息
		// 之后把他的数据接受了并把它唤醒,然后从自己的发送阻塞队列上剔除!
		p_from = &proc_table[src];
		if ((p_from->p_flags & SENDING) &&
			(p_from->p_sendto == proc2pid(p_who_wanna_recv)))
		{
			/* Perfect, src is sending a message to
			 * p_who_wanna_recv.
			 */
			copyok = 1;
 
			struct proc *p = p_who_wanna_recv->q_sending;
 
			assert(p); /* p_from must have been appended to the
				    * queue, so the queue must not be NULL
				    */
 
			// 这里在 p_who_wanna_recv 的 q_sending 队列上找到那个具体的进程
			// 估计要做更新等待列表的工作
			while (p)
			{
				assert(p_from->p_flags & SENDING);
 
				if (proc2pid(p) == src) /* if p is the one */
					break;
 
				prev = p;
				p = p->next_sending;
			}
 
			assert(p_who_wanna_recv->p_flags == 0);
			assert(p_who_wanna_recv->p_msg == 0);
			assert(p_who_wanna_recv->p_recvfrom == NO_TASK);
			assert(p_who_wanna_recv->p_sendto == NO_TASK);
			assert(p_who_wanna_recv->q_sending != 0);
			assert(p_from->p_flags == SENDING);
			assert(p_from->p_msg != 0);
			assert(p_from->p_recvfrom == NO_TASK);
			assert(p_from->p_sendto == proc2pid(p_who_wanna_recv));
		}
	}
 
	if (copyok)
	{
		/* It's determined from which proc the message will
		 * be copied. Note that this proc must have been
		 * waiting for this moment in the queue, so we should
		 * remove it from the queue.
		 */
		// update the queue!
		if (p_from == p_who_wanna_recv->q_sending)
		{ /* the 1st one */
			assert(prev == 0);
			p_who_wanna_recv->q_sending = p_from->next_sending;
			p_from->next_sending = 0;
		}
		else
		{
			assert(prev);
			prev->next_sending = p_from->next_sending;
			p_from->next_sending = 0;
		}
 
		assert(m);
		assert(p_from->p_msg);
 
		/* copy the message */
		phys_copy(va2la(proc2pid(p_who_wanna_recv), m),
				  va2la(proc2pid(p_from), p_from->p_msg),
				  sizeof(MESSAGE));
 
		p_from->p_msg = 0;
		p_from->p_sendto = NO_TASK;
		p_from->p_flags &= ~SENDING;
 
		// 将发送方解锁掉,下一次就会加入调度
		unblock(p_from);
	}
	else
	{ /* nobody's sending any msg */
		/* Set p_flags so that p_who_wanna_recv will not
		 * be scheduled until it is unblocked.
		 */
		p_who_wanna_recv->p_flags |= RECEIVING;
		p_who_wanna_recv->p_msg = m;
		p_who_wanna_recv->p_recvfrom = src; // recv 方阻塞自己的时候指明它原本是想从哪里接收数据!
		block(p_who_wanna_recv);
 
		assert(p_who_wanna_recv->p_flags == RECEIVING);
		assert(p_who_wanna_recv->p_msg != 0);
		assert(p_who_wanna_recv->p_recvfrom != NO_TASK);
		assert(p_who_wanna_recv->p_sendto == NO_TASK);
		assert(p_who_wanna_recv->has_int_msg == 0);
	}
 
	return 0;
}

——msg_recv 首先检查是否有中断通知,如果有,就“接受”中断通知然后返回,如果没有中断通知,就判断 recv 是想从 any 接收还是从具体的进程接收,从 any 接收好办,选择 recv 的发送队列的第一个 send 然后接收他的消息就行了,如果是从具体进程 recv ,那么就要判断那个具体的进程是否正在给 recv 发送,如果正在,那么一拍即合(否则就 recv 就会阻塞的),拿走那个 send 的数据然后激活 send ,自己返回!

测试消息机制:
开始 TestA 阻塞在从 TestB 接收数据,并且 TestC 阻塞在想从 ANY 接收数据,然后 TestB 发消息给 TestA,TestA 接收到 TestB 的消息了之后又给 TestC 发送消息!

二,宏内核改微内核
消息机制是微内核的核心,我们既然已经有了消息机制,就可以搭建微内核了,上面消息机制的测试中,TestX 都是发送或者接收了一次消息之后就不再发送或者接收消息了,如果我们弄一个进程,在那里不间断地 recv ,再给 MESSAGE 注明类型,我们就可以说:哪个进程给哪个系统任务发送了消息,请求的是这个系统任务的哪种类型的服务!(服务的类型取决于系统任务支持什么类型的服务,不能你虽然把我唤醒了,但请求的不是我力所能及的服务,可以猜猜 sys_server 会做什么反应 smile

1,添加一个系统任务:sys_server
2,增加 sys_server 提供的一种服务类型(GET_TICKS)
3,TestB 和 TestC 之间互相传递消息

·main.c 节选
PUBLIC int get_ticks()
{
	MESSAGE msg;
	reset_msg(&msg);
	msg.type = GET_TICKS;
	send_recv(BOTH, TASK_SERVER, &msg);
	return msg.RETVAL;
}
 
void TestA()
{
	while (TRUE)
	{
		printf("%d  ", get_ticks());
		milli_delay(300);
	}
}
 
void TestB()
{
	MESSAGE _m;
	_m.RETVAL = 1;
	while (TRUE)
	{
		send_recv(SEND, 4, &_m);
		send_recv(RECEIVE, 4, &_m);
		printf("TestB number is [%d]\n", ++_m.RETVAL);
		milli_delay(2000);
	}
}
 
void TestC()
{
	MESSAGE _m;
	while (TRUE)
	{
		send_recv(RECEIVE, 3, &_m);
		printf("TestC number is [%d]\n", ++_m.RETVAL);
		send_recv(SEND, 3, &_m);
	}
}
·sys_task.c
// ----------------------------
// <systask.c>
//                      Jack Zheng
//              Comment:December 7, 2019
// ----------------------------
#include "type.h"
#include "const.h"
#include "protect.h"
#include "string.h"
#include "proc.h"
#include "tty.h"
#include "console.h"
#include "global.h"
#include "keyboard.h"
#include "proto.h"
 
void task_server()
{
 
    MESSAGE _m;
    while (TRUE)
    {
        send_recv(RECEIVE, ANY, &_m);
        int src = _m.source;
        switch (_m.type)
        {
        case GET_TICKS: /* 某个进程请求的这个服务 */
            _m.RETVAL = ticks;
            send_recv(SEND, src, &_m);
            break;
 
        default:
            panic("unknown Message type");
            break;
        }
    }
}

#417 内核模块 » 一文读懂微内核 » 2022-09-01 16:28:04

batsom
回复: 0

一文读懂微内核

2019年8月9日华为 余承东 发布HarmonyOS 1.0,HarmonyOS的发布将一个计算机领域内非常专业的词带到了广大公众的视线内,这就是微内核。

事实上,微内核并不是一个新概念。早在1969年,UNIX系统开始设计的时候,类似微内核架构的操作系统就已经出现。1969年,丹麦计算机科学家Per Brinch Hansen开发的RC 4000 Multiprogramming System操作系统,是历史上第一次将操作系统组件分离为各个相互交互的组件,将内核简化为仅用于通信和支持系统,并使用管道共享内存作为其进程间通信的基础。如今回看RC 4000 Multiprogramming System虽然其本身并不是很成功,但激发了微内核概念。

继续HarmonyOS的话题,看了HarmonyOS 1.0发布会回放后,我有一个疑问:
什么是宏内核,什么又是微内核,微内核相比于宏内核真的有如此大的优势吗?

华为HarmonyOS 1.0发布会直播回放:
https://www.bilibili.com/video/av62950256/
一、操作系统

了解微内核与宏内核之前,首先了解一下操作系统?

操作系统(Operating System)是现代计算平台的基础与核心支撑系统,负责管理硬件资源(包括输入输出设备的初始化、分配与回收)、控制程序运行、改善人机交互以及为上层应用软件提供运行环境等。操作系统作为计算机之“魂”,是释放硬件能力、构建应用生态的基础。

从应用的角度看,操作系统的作用:一是服务于应用,二是管理应用。

    一方面操作系统提供各种不同层次、不同功能的接口,以满足上层应用的需求。
    另一方面,操作系统负责对应用生命周期进行管理,包括初始化、启动、调度、切换、销毁等。

从硬件的角度看,操作系统主要包含两类功能:

    一方面操作系统将不同功能的硬件资源纳入统一的管理。
    例如,内存管理,操作系统识别电脑中存在的多种不连续的、有限的物理内存区域,再采用某种内存管理分配机制进行分配与管理。
    另一方面操作系统负责将不同功能硬件资源进行抽象,将有限的、离散的资源抽象为无限的、连续的资源,并将硬件资源通过接口提供给上层应用调用,从而使上层应用无需关心硬件的具体细节。
    例如,上层应用开发中,开发者无需关心物理内存硬件的容量、型号信息,而是面向一个近似无限的、统一的虚拟地址空间。

通常而言,狭义的操作系统指的是操作系统内核加上一个Shell(即UNIX/Linux等操作系统中的命令行页面)。随着硬件种类和应用需求越来越丰富,大量共性功能沉淀到操作系统中,操作系统的内涵和外延不断扩大,因此,广义的操作系统又可以进一步分为操作系统内核与操作系统框架。操作系统内核负责对硬件资源的管理与抽象,为操作系统框架提供基础的系统服务(操作系统内核又分为宏内核、微内核等);操作系统框架则基于操作系统内核提供的服务为不同的应用提供API接口与运行环境。
二、宏内核&微内核

现在操作系统大多采用宏内核架构(如UNIX、Linux等),操作系统将一些基本的、公共的、与硬件紧密相关的 (如中断处理、内存管理、文件系统、设备驱动等)、运行频率较高的功能(如进程调度、时钟管理等)以及关键性的数据结构独立出来,使之常驻内存,并对其进行保护。内核中采用模块化设计组织各个功能,所有模块运行于内核空间,模块间通信直接调用模块间提供的接口函数实现。

宏内核可以理解为是个很大的进程,其内部又能够被分为若干功能模块(或者是若干层)。宏内核在运行的时,为一个单独的二进制大映象,模块间的通讯是通过直接调用其他模块中的函数实现的,而非消息传递。

宏内核中许多的功能模块都在同一个内核空间上运行,伴随着操作系统的发展,内核模块的复杂度越来越高,操作系统在可靠性与安全性方面慢慢出现了一些问题,一个很小的bug都会使整个系统崩溃。为解决宏内核存在的问题,许多研发人员尝试对宏内核架构进行解耦,将部分非核心功能(如文件系统、设备驱动等)从内核中拆分出来,作为一个独立的服务运行于单独的进程中,并为其提供进程间通信的能力(IPC Inter Process Communication),内核中只保留最核心的功能(如内存管理、进程调度等),这种架构被称为微内核架构。在微内核下服务与服务互相隔离,单个服务即使出现故障或受到安全攻击,也不会导致整个操作系统的崩溃或被攻破,从而有效提供了操作系统的可靠性与安全性。

有兴趣详细研究微内核实现与原理的同学,可以研究一下Minix。
Minix第一个版本于1987年发布,是荷兰计算机科学家Andrew S. Tanenbaum为了教学而创作,如今为Andrew S. Tanenbaum教授所著《操作系统:设计与实现》的示例代码。
Minix启发了Linux内核的创作。1990年,还在上大学的Linus Torvalds从Minix得到灵感,出于对操作系统的兴趣,于1991年发布了Linux。

Minix目前有三个主要的版本:

    Minix1
    https://github.com/gdevic/minix1
    Minix1是《操作系统:设计与实现》教材的演示代码,侧重于教学和学习(年代久远,很难编译安装)。
    Minix 2.0.4
    http://download.minix3.org/previous-ver … tel-2.0.4/
    Minix 2.0.4侧重于自学,链接中有详细的安装教程。
    Minix 3.2.1
    http://download.minix3.org/iso/minix_R3 … 6d.iso.bz2
    Minix 3.2.1是个实用版本,有iso映像可供下载,安装方便。

三、宏内核VS微内核

自宏内核与微内核这两种架构出现伊始,人们就两者的优劣与特点展开了深入的讨论。
当前随着物联网时代的到来,使微内核架构的操作系统架构再次受到广泛关注。

    弹性扩展能力:
    对于一个庞大的宏内核来说,很难仅仅通过简单的剪裁与扩展,使之满足支持资源诉求从KB到TB级别的场景;而对于微内核,内核空间只包含核心功能,天然具备模块化解耦与弹性部署的能力。
    功能安全:
    由于宏内核在故障隔离方面存在的缺陷,其安全与稳定性方面很难与微内核媲美。
    进程间通信:
    微内核将非核心功能以单独进程的方式运行于用户态,不同系统功能的相互调用需要通过进程间通信实现(IPC Inter Process Communication)。相比于宏内核内核空间中模块间通信采用函数,微内核采用进程间通信,通信效率较低。

当前智能终端呈现多样化的发展趋势,面对物联网时代的到来,微内核天生具备的模块化解耦、弹性部署的能力以及安全稳定的特性,非常符合物联网的发展,但进程间通信(IPC Inter Process Communication)的性能无疑成为微内核的软肋。

微内核虽然存在IPC性能软肋,但IPC性能并非不可提升。
德国计算机科学家Jochen Liedtke(L3微内核与L4微内核系列的创造者)曾表示,高性能IPC的设计与实现必然是与体系结构相关的,过度的抽象将极大影响IPC的性能,而利用体系结构相关的状态进行优化则可将IPC性能提升到极致。
2019年华为HarmonyOS发布会中,余承东透露华为在微内核IPC优化方面的成果,采用微内核架构的HarmonyOS在IPC方面性能可以达到同样采用微内核的Fuchsia操作系统的5倍。
相信未来广大技术研发人员不断对IPC性能进行优化,微内核IPC的性能会有大的提升。
参考

维基百科 Regnecentralen:
https://en.wikipedia.org/wiki/Regnecentralen

维基百科 RC_4000_multiprogramming_system:
https://en.wikipedia.org/wiki/RC_4000_m … ing_system

维基百科 微内核:
https://zh.wikipedia.org/wiki/%E5%BE%AE … 7%E6%A0%B8

维基百科 Mach:
https://zh.wikipedia.org/wiki/Mach

现代操作系统:原理与实现
https://item.jd.com/12731379.html

操作系统的发展:
https://www.feng.com/post/6209622

有关微内核:
https://mp.weixin.qq.com/s/MLCR7qqGFWyyP0KcZqW3Kw
========== THE END ==========

#418 内核模块 » IPC » 2022-09-01 15:09:20

batsom
回复: 0

IPC是Inter-Process Communication的缩写,直译为进程间通信,说白了就是进程间发消息。我们在上一节中把这种消息传递比作邮政系统,但实际上这种比喻并不全对。有的消息机制是很像收发邮件的,这种叫做异步IPC,意思是说,发信者发完就去干别的了,收信者也一样,看看信箱里没信,也不坐在旁边傻等。而有另一种消息机制正好相反,被称为同步IPC,它不像邮寄,倒像接力赛,发送者一直等到接收者收到消息才肯放手,接收者也一样,接不到就一直等着,不干别的。
当然你可以把同步IPC也比作邮寄,只不过寄信的人从把信投到信箱里的那一刻开始,就住在邮局不走了,其他什么也不干了,就等着邮局说:“哥们儿,你的信对方已经收到了,放心回家吧!”这才恋恋不舍地离开。收信的也一样,一旦决定收信,就守在自家信箱前面不走了,一直等,连觉也不睡,望穿秋水,等信拿在手里了,这才回屋,每收一次信,就得瘦个十几斤。
我们都是性情中人,我们选择傻等,或曰同步IPC。
同步IPC有若干的好处,比如:
• 操作系统不需要另外维护缓冲区来存放正在传递的消息;
• 操作系统不需要保留一份消息副本;
• 操作系统不需要维护接收队列(发送队列还是需要的);
• 发送者和接收者都可在任何时刻清晰且容易地知道消息是否送达;
• 从实现系统调用的角度来看,同步IPC更加合理──当使用系统调用时,我们的确需要等待内核返回结果之后再继续。
这些特性读者可能无法一下子全部明白,不要紧,我们接下来写完代码,你就全都明白了。
实现IPC
Minix的IPC机制我们已经明白了,它的核心乃在于“int SYSVEC”这个软中断以及与之对应的sys_call()这个函数。增加一个系统调用对我们来讲已是信手拈来的事,按照表7.6一步一步来就好了。我们把这个新的系统调用起名为sendrec。sendrec和sys_sendrec的函数体分别见下面两段代码:
25 sendrec:
26         mov eax, _NR_sendrec
27         mov ebx, [esp + 4] ; function
28         mov ecx, [esp + 8] ; src_dest
29         mov edx, [esp + 12] ; p_msg
30         int INT_VECTOR_SYS_CALL
31         ret
53 /*****************************************************************************
54 * sys_sendrec
55 *****************************************************************************/
56 /**
57 * <Ring 0> The core routine of system call ‘sendrec()’.
58 *
59 * @param function SEND or RECEIVE
60 * @param src_dest To/From whom the message is transferred.
61 * @param m Ptr to the MESSAGE body.
62 * @param p The caller proc.
63 *
64 * @return Zero if success.
65 *****************************************************************************/
66 PUBLIC int sys_sendrec(int function, int src_dest, MESSAGE* m, struct proc* p)
67 {
68         assert(k_reenter == 0); /* make sure we are not in ring0 */
69         assert((src_dest >= 0 && src_dest < NR_TASKS + NR_PROCS) ||
70         src_dest == ANY ||
71         src_dest == INTERRUPT);
72
73         int ret = 0;
74         int caller = proc2pid(p);
75         MESSAGE* mla = (MESSAGE*)va2la(caller, m);
76         mla->source = caller;
77
78         assert(mla->source != src_dest);
79
80         /**
81         * Actually we have the third message type: BOTH. However, it is not
82         * allowed to be passed to the kernel directly. Kernel doesn’t know
83         * it at all. It is transformed into a SEND followed by a RECEIVE
84         * by ‘send_recv()’.
85         */
86         if (function == SEND) {
87                 ret = msg_send(p, src_dest, m);
88                 if (ret != 0)
89                 return ret;
90         }
91         else if (function == RECEIVE) {
92                 ret = msg_receive(p, src_dest, m);
93                 if (ret != 0)
94                 return ret;
95         }
96         else {
97                 panic(”{sys_sendrec}␣invalid␣function:␣”
98                 ”%d␣(SEND:%d,␣RECEIVE:%d).”, function, SEND, RECEIVE);
99         }
100       
101         return 0;
102 }
表7.6的最后一步中提到,如果参数个数与以前的系统调用比有所增加,则需要修改kernel.asm中的sys_call。额外要注意,我们新加的参数是通过edx这个参数传递的,而save这个函数中也用到了寄存器dx,所以我们同时需要修改save,见如下代码:
   311 ; =============================================================================
   312 ; save
   313 ; =============================================================================
   314 save:
   315         pushad ; ‘.
   316         push ds ; |
   317         push es ; | 保存原寄存器值
   318         push fs ; |
   319         push gs ; /
   320
   321         ;; 注意,从这里开始,一直到‘mov esp, StackTop’,中间坚决不能用push/pop 指令,
   322         ;; 因为当前esp 指向proc_table 里的某个位置,push 会破坏掉进程表,导致灾难性后果!
   323
=> 324         mov esi, edx ; 保存edx,因为edx 里保存了系统调用的参数
   325         ;(没用栈,而是用了另一个寄存器esi)
   326         mov dx, ss
   327         mov ds, dx
   328         mov es, dx
   329         mov fs, dx
   330
=> 331         mov edx, esi ; 恢复edx
   332
   333         mov esi, esp                         ;esi = 进程表起始地址
   334
   335         inc dword [k_reenter]                ;k_reenter++;
   336         cmp dword [k_reenter], 0             ;if(k_reenter ==0)
   337         jne .1                               ;{
   338         mov esp, StackTop                    ; mov esp, StackTop <--切换到内核栈
   339         push restart                         ; push restart
   340         jmp [esi + RETADR - P_STACKBASE]     ; return;
   341 .1:                                          ;} else { 已经在内核栈,不需要再切换
   342         push restart_reenter                 ; push restart_reenter
   343         jmp [esi + RETADR - P_STACKBASE]     ; return;
   344                                              ;}
   345
   346
   347 ; =============================================================================
   348 ; sys_call
   349 ; =============================================================================
   350 sys_call:
   351         call save
   352
   353         sti
   354         push esi
   355
   356         push dword [p_proc_ready]
=> 357         push edx
   358         push ecx
   359         push ebx
   360         call [sys_call_table + eax * 4]
=> 361         add esp, 4 * 4
   362
   363         pop esi
   364         mov [esi + EAXREG - P_STACKBASE], eax
   365         cli
   366
   367         ret
sys_sendrec()这个函数被设计得相当简单,它可以描述为:把SEND消息交给msg_send()处理,把RECEIVE消息交给msg_receive()处理。
msg_send()和msg_receive()这两个函数我们过一会儿细细分解,先来看看之前没出现过的assert()和panic()。这两个函数虽然起的是辅助作用,但绝对不是可有可无,因为在我们接下来要处理的消息收发中,有一些编程细节还真容易让人迷糊,这时候assert()就大显神威了,它会在错误被放大之前通知你。panic()的作用也类似,用于通知你发生了严重的错误。

#419 内核模块 » MINIX3 进程通信分析 » 2022-09-01 14:55:26

batsom
回复: 0

6.1MINIX3 进程通信概要

MINIX3 的进程通信是 MINIX3 内核部分最重要的一个部件,我个人认为其实这 是内核中的“内核”,怎么来理解这个概念呢?其实 MINIX3 进程间通信部件的 实行不完全依赖任何一个部件,这个在后面会详细的看到。Minix3  实现进程通 信的方法是----消息机制。何为消息机制呢?

就是进程 A 有消息发送进程 B,希望进程 B 给进程 A 一个服务,进程 A 和 B 在 这里就发生了进程间的通信

注意这里的消息机制其实是不受任何机制管制,具有最高权限,这种消息机制
MINIX3 也没有单独设立进程,也不可能!实现下,如果设立一个进程,那么这
个所谓的“消息传递进程”怎么和其他进程发生联系呢?岂不是又要设立一个“消
息机制”,者就带来了很尴尬的局面:一直想调用另一个消息机制,永远找不到
尽头。

6.2 MINIX3 进程通信原语介绍

IPC 机制是多进程操作系统必须提供的,但是怎么能够让这个系统得到一个非常 高效安全的 IPC 机制是非常难考虑的。MINIX3 定义了一种消息传递机制。 主要通过以下 3 个函数来实现:send(),receive(),notify();理解这 3 个函数能够使得 消息机制有一个绝对宏观和微观的全局把握,在这里,我的讲解方式可能要有一 点改变,鉴于前面讲到内核其他部分,也涉及到消息原语,我们在讲解完消息机 制代码之后,会利用前面已有的消息原语来加以理解。

第一 我们来简要说明下那 3 个函数的大体含义: Send(dest,&message)

87

很容易理解,这个就是向目的进程 dest 发送一个消息。关于这个消息,在 MINIX3
是定义了一种内核能够识别的“呆板的”消息。关于这种消息在后面会有所介绍。
现在继续分析这个函数:内核会把消息传到给 dest 进程。当然就不是这种简单
的复制工作。事实上要比这个复杂的。我们设想下,如果进程 A 和进程 B 合作,
进程 A 要求发送给一个阻塞的进程 B 但是如果进程 B 没有阻塞,它就要阻塞自
己。这点就是一种协同合作的典型例子。事实上,有很多比这个要复杂的协同合
作的例子。但是如果仅仅就是上面那一步无法满足 IPC 机制------同步执行。

所以 MINIX3 提出这样一种内容:就是如果目的进程没有接受自己,自己就会阻 塞自己。

Receive(source,&message)

同样的道理。用于接受一个消息,如果源进程没有发送,它就阻塞自己。

上面 2 个函数就可以实现基本的进程通信机制。但是 MINIX3 还设置了一个通信 原语:

notify(dest),设置就要是出于效率考虑:有时候不需要阻塞自己来发送消息。希望
有的进程发送之后能够继续投入使用。事实上,在 MINIX3 中,使用 notfiy(dest)
都是系统进程间发送消息来使用的,当然不是全部的系统进程发送都是这样的。
在后面的讲解中会看到。这个函数就是个目的进程发送一个通知。而且这个通知
的格式是绝对一样的。是内核自己来生成的。这些点后面都会有详细的说明。

6.3 MINIX3 消息机制的消息格式

现在我们分析另外一个问题:消息格式

MINIX3 定义一种非常让人迷惑并且比较复杂的格式:消息

消息主体是一个含有共用体的结构体。主要是在 include/minix/ipc.h 里。我们拿 出来简要分析下:

#ifndef  _IPC_H
#define  _IPC_H

/*=================================================================== =======*

* Types relating to messages. *

*====================================================================

======*/

#define M1 1

#define M3 3

#define M4 4

#define M3_STRING 14

//定义专门的消息结构---一共有7种消息结构在MINIX3中


typedef struct {int m1i1, m1i2, m1i3; char *m1p1, *m1p2, *m1p3;} mess_1;
typedef struct  {int m2i1, m2i2, m2i3; long m2l1, m2l2; char  *m2p1;}



mess_2;

typedef struct  {int m3i1, m3i2; char  *m3p1; char m3ca1[M3_STRING];} mess_3;

typedef struct  {long m4l1, m4l2, m4l3, m4l4, m4l5;} mess_4;

typedef struct  {short m5c1, m5c2; int m5i1, m5i2; long m5l1, m5l2, m5l3;}mess_5;

typedef struct {int m7i1, m7i2, m7i3, m7i4; char *m7p1, *m7p2;} mess_7; typedef struct {int m8i1, m8i2; char *m8p1, *m8p2, *m8p3, *m8p4;} mess_8; //消息机制的主体结构,把他看成一个结构的含有公用体的结构比较好

typedef struct  {

int m_source; /* who sent the message  */

int m_type; /* what kind of message is it  */

union  {

mess_1 m_m1;
mess_2 m_m2;
mess_3 m_m3;

mess_4 m_m4;
mess_5 m_m5;
mess_7 m_m7;

mess_8 m_m8;
} m_u;

} message;

/* The following defines provide names for useful members.  */ //这些是一些宏定义

#define m1_i1    m_u.m_m1.m1i1
#define m1_i2    m_u.m_m1.m1i2
#define m1_i3    m_u.m_m1.m1i3
#define m1_p1    m_u.m_m1.m1p1
#define m1_p2    m_u.m_m1.m1p2
#define m1_p3    m_u.m_m1.m1p3

#define m2_i1    m_u.m_m2.m2i1
#define m2_i2    m_u.m_m2.m2i2
#define m2_i3    m_u.m_m2.m2i3
#define m2_l1    m_u.m_m2.m2l1
#define m2_l2    m_u.m_m2.m2l2
#define m2_p1    m_u.m_m2.m2p1

#define m3_i1    m_u.m_m3.m3i1
#define m3_i2    m_u.m_m3.m3i2
#define m3_p1    m_u.m_m3.m3p1
#define m3_ca1 m_u.m_m3.m3ca1

89

#define m4_l1 m_u.m_m4.m4l1

#define m4_l2 m_u.m_m4.m4l2

#define m4_l3 m_u.m_m4.m4l3

#define m4_l4 m_u.m_m4.m4l4

#define m4_l5 m_u.m_m4.m4l5

#define m5_c1 m_u.m_m5.m5c1

#define m5_c2 m_u.m_m5.m5c2

#define m5_i1 m_u.m_m5.m5i1

#define m5_i2 m_u.m_m5.m5i2

#define m5_l1 m_u.m_m5.m5l1

#define m5_l2 m_u.m_m5.m5l2

#define m5_l3 m_u.m_m5.m5l3

#define m7_i1 m_u.m_m7.m7i1

#define m7_i2 m_u.m_m7.m7i2

#define m7_i3 m_u.m_m7.m7i3

#define m7_i4 m_u.m_m7.m7i4

#define m7_p1 m_u.m_m7.m7p1

#define m7_p2 m_u.m_m7.m7p2

#define m8_i1 m_u.m_m8.m8i1

#define m8_i2 m_u.m_m8.m8i2

#define m8_p1 m_u.m_m8.m8p1

#define m8_p2 m_u.m_m8.m8p2

#define m8_p3 m_u.m_m8.m8p3

#define m8_p4 m_u.m_m8.m8p4

在 MINIX3 中,我们认为一个非常重要的问题:就是某个进程或者某种机制机制 发送某种消息类型。这点可能比较呆板。但是这样处理的效率和安全性都比较高。 这就是整个 MINIX3 的消息结构。当然在此不去追加具体的含义。事实上在讲解 system 系统函数时,每一个函数我们都认为是接受了某种类型的消息然后进行处 理相关内容。那个消息的各个结构在那里可以看的比较清楚。

6.4 MINIX3 消息机制源码导读

我们继续往下面走,现在我们就深入具体代码一个一个函数的分析: 进程间通信 IPC 机制与外层接口的代码是在 proc.c 中。
展开开头的一小部分代码来分析:

sys_call 是消息机制同外部世界的唯一接口。主要是由于系统陷入而将产生!

/*===================================================================

90

========*

* sys_call *

*==================================================================== =======*/

//这个是消息机制的传递入口,需要好好的理解一翻 PUBLIC int sys_call(call_nr, src_dst, m_ptr)

int call_nr; /* system call number and flags  */

int src_dst; /* src to receive from or dst to send to  */

message  *m_ptr; /* pointer to message in the caller's space */

{

/* System calls are done by trapping to the kernel with an INT instruction. * The trap is caught and sys_call() is called to send or receive a message *  (or both). The caller is always given by 'proc_ptr'.

*/

//系统调用通过INT指令陷入到内核中。这个陷阱被捕获并且sys_call被调用来 发送或者接受消息。

//调用者总是被proc_ptr所指的进程

register struct proc  *caller_ptr  = proc_ptr; /* get pointer to

caller  */

int function  = call_nr & SYSCALL_FUNC; /* get system call function

*/

//计算系统调用号和系统调用标志

unsigned flags  = call_nr & SYSCALL_FLAGS; /* get flags  */

int mask_entry;

int group_size;

int result;

vir_clicks vlo, vhi;

*/

/* bit to check in send mask  */

/* used for deadlock check  */
/* the system call's result  */

/* virtual clicks containing message to send

/* Check if the process has privileges for the requested call. Calls to the

* kernel may only be SENDREC, because tasks always reply and may not
block

* if the caller doesn't do receive().
*/

//防止违法操作调用

if  (!  (priv(caller_ptr)->s_trap_mask &  (1  << function))  ||
(iskerneln(src_dst) && function  != SENDREC

&& function  != RECEIVE))  {

#if DEBUG_ENABLE_IPC_WARNINGS

kprintf("sys_call: trap %d not allowed, caller %d, src_dst %d\n",
function, proc_nr(caller_ptr), src_dst);

#endif


return(ETRAPDENIED); /* trap denied by mask or kernel  */

}

/* Require a valid source and/ or destination process, unless echoing.
*/

//请求一个空操作

if  (src_dst  != ANY && function  != ECHO)  {
if  (! isokprocn(src_dst))  {

#if DEBUG_ENABLE_IPC_WARNINGS

kprintf("sys_call: invalid src_dst, src_dst %d, caller %d\n",
src_dst, proc_nr(caller_ptr));

#endif

return(EBADSRCDST); /* invalid process number  */

}

if  (isemptyn(src_dst))  {
#if DEBUG_ENABLE_IPC_WARNINGS

kprintf("sys_call: dead src_dst; trap  %d, from %d, to %d\n",
function, proc_nr(caller_ptr), src_dst);

#endif

return(EDEADSRCDST);
}

}

/* If the call involves a message buffer, i.e., for SEND, RECEIVE, SENDREC,

* or ECHO, check the message pointer. This check allows a message to

be

* anywhere in data or stack or gap. It will have to be made more elaborate

* for machines which don't have the gap mapped.
*/

if  (function & CHECK_PTR)  {

vlo  =  (vir_bytes) m_ptr  >> CLICK_SHIFT;

vhi  =  ((vir_bytes) m_ptr  + MESS_SIZE  -  1)  >> CLICK_SHIFT; if  (vlo  < caller_ptr->p_memmap[D].mem_vir  || vlo  > vhi  ||
vhi  >= caller_ptr->p_memmap[S].mem_vir  +

caller_ptr->p_memmap[S].mem_len)  {

#if DEBUG_ENABLE_IPC_WARNINGS

kprintf("sys_call: invalid message pointer, trap  %d, caller  %d\n",

function, proc_nr(caller_ptr));

#endif

return(EFAULT); /* invalid message pointer  */

}


}

/* If the call is to send to a process, i.e., for SEND, SENDREC or NOTIFY,
* verify that the caller is allowed to send to the given destination.
*/

if  (function & CHECK_DST)  {

if (! get_sys_bit(priv(caller_ptr)->s_ipc_to, nr_to_id(src_dst)))

{

#if DEBUG_ENABLE_IPC_WARNINGS

kprintf("sys_call: ipc mask denied trap  %d from  %d to  %d\n", function, proc_nr(caller_ptr), src_dst);

#endif

return(ECALLDENIED); /* call denied by ipc mask  */

}

}

/* Check for a possible deadlock for blocking SEND(REC) and RECEIVE.
*/

if  (function & CHECK_DEADLOCK)  {

if  (group_size  = deadlock(function, caller_ptr, src_dst))  { #if DEBUG_ENABLE_IPC_WARNINGS

kprintf("sys_call: trap  %d from  %d to  %d deadlocked, group size  %d\n",

function, proc_nr(caller_ptr), src_dst, group_size);

#endif

return(ELOCKED);

}

}

/* Now check if the call is known and try to perform the request. The
only

* system calls that exist in MINIX are sending and receiving messages. 现在检查调用是否被知道并且尝试一个请求。这是MINIX唯一的系统调用来发送 或者接受一个消息

* - SENDREC: combines SEND and RECEIVE in a single system call

* - SEND: sender blocks until its message has been delivered

* - RECEIVE: receiver blocks until an acceptable message has arrived

* - NOTIFY:    nonblocking call; deliver notification or mark pending

* - ECHO: nonblocking call; directly echo back the message

*/

//这里是最核心的操作,分为SENDREC SEND RECEIVE NOTIFY ECHO这几个操作 这里的几个函数我们将在下面会有详细的介绍

switch(function)  {

case SENDREC:

93

/* A flag is set so that notifications cannot interrupt SENDREC.

*/

priv(caller_ptr)->s_flags  |= SENDREC_BUSY;

/* fall through  */

case SEND: //执行SEND函数

result  = mini_send(caller_ptr, src_dst, m_ptr, flags);

if  (function  == SEND  || result  != OK)  {

break; /* done, or SEND failed  */

} /* fall through for SENDREC  */

case RECEIVE: //执行RECEIVE函数

if  (function  == RECEIVE)

priv(caller_ptr)->s_flags &=  ~SENDREC_BUSY;

result  = mini_receive(caller_ptr, src_dst, m_ptr, flags); break;

case NOTIFY:  //执行NOTIFY函数

result  = mini_notify(caller_ptr, src_dst); break;

case ECHO:  //这是回显操作

CopyMess(caller_ptr->p_nr, caller_ptr, m_ptr, caller_ptr, m_ptr); result  = OK;

break;

default:

result  = EBADCALL; /* illegal system call  */

}

/* Now, return the result of the system call to the caller.  */ //接受或者发送消息完成后,就进程返回操作

return(result);

}

在介绍最为核心的几个操作之前先来看下几个宏,同样也是在 proc.c 文件中 我们将整个 proc.c 前部分的代码放进来,形成一个整体感:

/* This file contains essentially all of the process and message handling. * Together with "mpx.s" it forms the lowest layer of the MINIX kernel. * There is one entry point from the outside:

* 本文件包括了所有进程消息机制处理的本质操作,这个文件也是MINIX最底层 内核部分

* 从外到内只有一个入口:sys_call

*      sys_call: a system call, i.e., the kernel is trapped with

an INT
*

* As well as several entry points used from the interrupt and task level: *当然也有几个其它的入口:(主要用于中断和系统任务)

*      lock_notify: notify a process of a system event

//通知给一个进程:一个系统任务

94

*      lock_send: send a message to a process

//发送一个消息给一个进程

*      lock_enqueue: put a process on one of the scheduling queues

//将一个进程放在调度队列中

*      lock_dequeue: remove a process from the scheduling queues

*从一个调度队列中移掉一个进程

* Changes:

*      Aug  19,  2005

*      Jul 25, 2005
*      May  26,  2005
Herder)

*      May 24, 2005
*      Oct  28,  2004
Herder)

*

rewrote scheduling code (Jorrit N. Herder)

rewrote system call handling (Jorrit N. Herder)

rewrote message passing functions (Jorrit N.

new notification system call (Jorrit N. Herder)

nonblocking send and receive calls (Jorrit N.

* The code here is critical to make everything work and is important for
the

* overall performance of the system. A large fraction of the code deals
with

* list manipulation. To make this both easy to understand and fast to execute

* pointer pointers are used throughout the code. Pointer pointers prevent * exceptions for the head or tail of a linked list.

*这个文件的代码可以说对整个系统的执行效率都有着非常重要的影响(在后面 可以看到,minix采用消息传递机制来实现通信)

*    node_t  *queue,  *new_node; // assume these as global variables

*    node_t  **xpp  = &queue; // get pointer pointer to head of queue

*    while  (*xpp  != NULL) // find last pointer of the linked list

* xpp  = &(*xpp)->next; // get pointer to next pointer

* *xpp  = new_node; // now replace the end  (the NULL pointer)

*    new_node->next  = NULL; // and mark the new end of the list

*

* For example, when adding a new node to the end of the list, one normally
* makes an exception for an empty list and looks up the end of the list
for

* nonempty lists. As shown above, this is not required with pointer pointers.

*/

#include  <minix/com.h>

#include  <minix/callnr.h>
#include "kernel.h"
#include "proc.h"


/* Scheduling and message passing functions. The functions are available
to

* other parts of the kernel through lock_...(). The lock temporarily disables

* interrupts to prevent race conditions.

调度器和消息传递函数。这些函数被提供给内核的其他部分,并且在操作前加上
Lock…()Lock主要是临时关闭中断来阻止其它进程来破坏状态量
*/

FORWARD  _PROTOTYPE( int mini_send,  (struct proc  *caller_ptr, int dst,
message  *m_ptr, unsigned flags));

FORWARD _PROTOTYPE( int mini_receive, (struct proc *caller_ptr, int src,
message  *m_ptr, unsigned flags));

FORWARD _PROTOTYPE( int mini_notify, (struct proc *caller_ptr, int dst)); FORWARD  _PROTOTYPE( int deadlock,  (int function,

register struct proc  *caller, int src_dst));

FORWARD  _PROTOTYPE( void enqueue,  (struct proc  *rp));

FORWARD  _PROTOTYPE( void dequeue,  (struct proc  *rp));

FORWARD  _PROTOTYPE( void sched,  (struct proc  *rp, int  *queue, int *front));

FORWARD  _PROTOTYPE( void pick_proc,  (void));

//宏定义,主要是用于构造消息,m_ptr:消息,src:源进程,dst_ptr:目标进程 //这个宏定义在notify中和receive中会用到,从字面看就是构造一个消息。我 们在后面会看到,这种构造消息的方法主要是用于notify的发送,发送一个小消 息,能够使得速度加快。

//

#define BuildMess(m_ptr, src, dst_ptr)  \

(m_ptr)->m_source  =  (src); \

(m_ptr)->m_type  = NOTIFY_FROM(src); \

(m_ptr)->NOTIFY_TIMESTAMP  = get_uptime(); \

switch  (src)  { \

case HARDWARE: \

(m_ptr)->NOTIFY_ARG  = priv(dst_ptr)->s_int_pending; \

priv(dst_ptr)->s_int_pending  =  0; \

break; \

case SYSTEM: \

(m_ptr)->NOTIFY_ARG  = priv(dst_ptr)->s_sig_pending; \

priv(dst_ptr)->s_sig_pending  =  0; \

break; \

}

//上段代码需要参考整个MINIX消息机制的结构, 对于源而言;主要分为

HARDWRAE和SYSTEM分别进行处理,如果是中断导致消息的发生,这要挂起中断位 图,如果是SYSTEM导致消息的产生,则要挂起sig_pending位图。挂起这2个位图 其实主要是用于内核间进程的一种快速通信。

#if  (CHIP  == INTEL)

#define CopyMess(s,sp,sm,dp,dm)  \

cp_mess(s,  (sp)->p_memmap[D].mem_phys, \

(vir_bytes)sm,  (dp)->p_memmap[D].mem_phys,  (vir_bytes)dm) #endif  /*  (CHIP  == INTEL)  */

//如果是是INTEL,则复制消息时就用cp_mess函数,函数的主体其实用汇编实现 的,在后面会有介绍

//如果是M68000芯片,则不做处理
#if  (CHIP  == M68000)

/* M68000 does not have cp_mess() in assembly like INTEL. Declare prototype

* for cp_mess() here and define the function below. Also define CopyMess.
*/

#endif  /*  (CHIP  == M68000)  */

现在我们接着上面的那个复制函数  ,我们找到源码,也一并进行分析:

*

!*=================================================================== ========*

!* cp_mess 把这个函数放到这里,以便更好的理解后面的函数

过程
*

!*=================================================================== ========*

! PUBLIC void cp_mess(int src, phys_clicks src_clicks, vir_bytes src_offset,

! phys_clicks dst_clicks, vir_bytes dst_offset);

! This routine makes a fast copy of a message from anywhere in the address

! space to anywhere else.    It also copies the source address provided as a parameter to the call into the first word of the destination message. !Note that the message size, "Msize" is in DWORDS  (not bytes) and must be set correctly.    Changing the definition of message in the type file and not changing it here will lead to total disaster.
!这个例程实际是非常重要的,这是一个被封装的C函数的底层实现
!主要是用于信息从一个地址快速的复制到另一个地址空间.

CM_ARGS = 4  +  4  +  4  +  4  +  4 !  4  +  4  +  4  +  4  +  4

! es    ds edi esi eip    proc scl sof dcl dof

.align  16
_cp_mess:
cld

push    esi
push    edi
push    ds
push    es

97

!将esi edi ds es压入栈,这4个寄存器留给下面的程序使用。 !设置数据段选择器,设置为平展模式,准备进行复制工作
mov eax, FLAT_DS_SELECTOR

mov ds, ax

mov es, ax

!将各个寄存器设置成相应的参数,准备进行复制工作

mov esi, CM_ARGS+4(esp) ! src clicks

shl esi, CLICK_SHIFT

add esi, CM_ARGS+4+4(esp) ! src offset

mov edi, CM_ARGS+4+4+4(esp) ! dst clicks

shl edi, CLICK_SHIFT

add edi, CM_ARGS+4+4+4+4(esp) ! dst offset

mov eax, CM_ARGS(esp) ! process number of sender

stos ! copy number of sender to dest message

add esi,  4 ! do not copy first word

mov ecx, Msize  -  1 ! remember, first word does not count

rep

movs ! copy the message

!复制完成之后,就将之前压栈的寄存器值弹回到相应的寄存器里
pop es

pop ds
pop edi
pop esi

ret ! that is all folks!

现在我们进行最核心的操作:也就是前面sys_call函数所调用的

mini_send,mini_recieve,mini_notify.这3个函数我们要仔细的来分析,因为3 个函数其实是MINIX3最精髓的地方:

我们先来看mini_send:

98

FluxBB bbcode 测试

这是大致执行流程图

我们仔细看这个执行流程图:首先判断是否满足条件:

一个进程想发送一个消息给另外一个进程,应该怎么做呢?

首先会检查最基本的:1发往的目的进程时候处于阻塞队列中且标志RECEIEVING 状态。2目的进程p_getfrom是不是ANY或者本进程。

对于第一点:每一个进程描述符项有一个p_rts_flags,这一位其实就是标志着 进程是否阻塞,并且是以接受态还是发送态阻塞。

对于第2点:只要检查目的进程的p_getfrom是不是应该指向本进程或者是任意进
程。

如果满足条件:发送进程就发消息复制给目的进程,之后将阻塞的目的进程放在 调度队列中运行。

99

FluxBB bbcode 测试

在发送时,调用copmess()函数,这个函数前面有分析过,是一个汇编形式的快
速复制消息函数。注意这里复制的真正含义:这里是在目的进程重新开辟一个内
存空间,由p_messbuf指向。之后把源进程的内容确确时时的复制到源进程中。
这一点我们必须明确。不是简单的指针指向。我们想下为什么不用指针简单的指
向呢?如果源进程在发送完消息之后,又准备给另外一个进程发送消息,但是之
前我们发送到得消息的进程还没有来得急使用这个消息,被这个进程发送的另外
一个消息给覆盖了。这样就会带来严重的问题。索性在每一个进程里都安装一个
消息结构的内存,这样就不需要用别人的消息地址内存。就相当于每家每一户都
有一个邮箱,别人发来的消息只装在我自己的邮箱中一样。

如果不满足上面2个条件,就需要做更加进一步的处理了。

如果是因为目的进程不在阻塞队列中,为了保持进程间的同步性,必须要阻塞这
个发送进程。现在我们来看看接下来是怎么做到的!先来看下面这个图:
这个图揭示了没有发送成功时,我们源进程怎么储存自己的消息。因为如果不满
足上述条件时,我们不能复制到对方的“邮箱”中,既然不能复制到对方的邮箱
中,我们只能自己储存起来,等待对方自己主动到我们这里来取邮件,这个过程
看起来非常的简单,其实要处理好非常的困难,首先,怎么让对方到我们这里来
取邮件呢?它有地址吗?没有,没有我们就要给对方设立一个地址,那个地址其
实用数据结构的语言来抽象,就是一个链表,我们可以想象一个问题,现在有一
家人去国外旅游了,有很多人想给这家人发信件,但是对方的邮箱打不开,只能
存回到自己的邮箱中,等待对方自己过来取,但是发送邮件的人就会在他家门口
留下自己的地址,让那家出国旅游的人自己回来取。在数据的世界里,我们就是
使用这个链表结构。这个链表结构就是用p_caller_q和p_q_link域构成。现在我
们先来接受下这2个域的含义:首先看p_caller_q:这个域代表的含义是我们指

100


FluxBB bbcode 测试

向想要发送消息进程的进程结构地址,而p_q_link是指向下一个进程的进程地 址。其实而言也很复杂,就是一个带链表的链式结构。

消息储存好之后还要做到的事情就是把本调度进程移除调度队列。让它处于阻塞 状态。

现在我们就来看代码。

/*=================================================================== ========*

* mini_send *

*====================================================================

=======*/

PRIVATE int mini_send(caller_ptr, dst, m_ptr, flags)

register struct proc  *caller_ptr; /* who is trying to send a message?

*/

int dst; /* to whom is message being sent?  */

message  *m_ptr; /* pointer to message buffer  */

//这里理解m_ptr 这里是message,指向其中的缓冲

unsigned flags; /* system call flags  */

{

/* Send a message from 'caller_ptr' to 'dst'. If 'dst' is blocked waiting

101

* for this message, copy the message to it and unblock 'dst'. If 'dst'
is

* not waiting at all, or is waiting for another source, queue 'caller_ptr'.

*/

register struct proc  *dst_ptr  = proc_addr(dst); register struct proc  **xpp;

/* Check if 'dst' is blocked waiting for this message. The destination's
* SENDING flag may be set when its SENDREC call blocked while sending.
*/

// 检查目的进程是否满足如下条件:第一:目的进程是否在接受消息状态
// 第二:目的进程期望得到的消息来源是ANY或者是caller_ptr
if  (  (dst_ptr->p_rts_flags &  (RECEIVING  | SENDING))  == RECEIVING &&
(dst_ptr->p_getfrom  == ANY  || dst_ptr->p_getfrom  ==
caller_ptr->p_nr))  {

/* Destination is indeed waiting for this message.  */

//通过上述检查,果真就是满足上述条件的话就直接进行copyMess操作

CopyMess(caller_ptr->p_nr, caller_ptr, m_ptr, dst_ptr,
dst_ptr->p_messbuf);

if  ((dst_ptr->p_rts_flags &=  ~RECEIVING)  ==  0) enqueue(dst_ptr); //enqueue是让dst_ptr进入调度队列中

//如果不是满足上面的种情况,那么就进入下面的执行流,操作的目的主要 是阻塞调用者,

//记住在执行这个程序流的时候,调用进程还在调度队列中 } else if  (  !  (flags & NON_BLOCKING))  {

/* Destination is not waiting.    Block and dequeue caller.  */

caller_ptr->p_messbuf  = m_ptr;

if  (caller_ptr->p_rts_flags  ==  0) dequeue(caller_ptr);//看看 p_rts_flags是否为,如果是为标明其还在

//调度队列中,就应该将其移除调度队列

caller_ptr->p_rts_flags  |= SENDING;

//将p_rts_flags标志为发送阻塞状态

caller_ptr->p_sendto  = dst;

//将调用进程的p_sendto设置成为dst

/* Process is now blocked.    Put in on the destination's queue.  */
//通过上面的操作,调用进程已经处于阻塞状态,并且相关的属性值已经设
置好了

//现在要设置的是该进程在目的进程的等待队列中,这个信息供以后目的进 程在接受消息时来处理

xpp  = &dst_ptr->p_caller_q; /* find end of list  */

while  (*xpp  != NIL_PROC) xpp  = &(*xpp)->p_q_link;

*xpp  = caller_ptr; /* add caller to end  */

caller_ptr->p_q_link  = NIL_PROC; /* mark new end of list  */

102

FluxBB bbcode 测试

} else  {

return(ENOTREADY);

}

return(OK);

}

我们先不讲receive,我们先讲下notfiy这个函数。这个函数虽然短,但是不是 很好理解。我们要仔细的分析这个函数:

前面简单的接受这个notify这个函数,这个函数的本意就是快速的发送消息,而
不阻塞本发送进程,这样必定导致了消息是“小”的。注意理解这里的“小”, “小”代表的含义其实就是一个消息结构体非常明确的消息。也就是一个死板的
消息,不是前面的那7种。这种消息主要用于系统进程间的快速通信。
同样首先检查条件:第一 目的进程是否是处于接受的阻塞状态 第二:目的进程
是否是接受ANY或者本进程发送的消息。第3:目的进程的s_flag是否标志为这个
SENDREC_BUSY标记位。

如果这3个条件满足,本调用就会构造一个消息,这个消息构造的宏在前面讲到, 主要是一些位图和消息源以及时间戳的定义。构造完成就复制到目的进程中。之
后就返回

如果3个有一个不满足,就不能构造消息。不能构造消息 难道我们就储存消息 吗?当然这是不太可能的,为啥这样讲呢?如果我们储存了这个消息,源进程就 必定要阻塞,也就违背了MINIX3设计初衷:快速发送消息

MINIX3是怎么解决这个问题的呢?MINIX3是这样解决的:

每一个系统进程都有一些位图,这些位图能够标志有限个进程,一般往往就标记
着系统进程。Mini_notiy就会将这些位图置位,当然也就是置的目的进程的位,

103

在后面我们可以看到,mini_recieve函数首先就会检查这些位图,检查完这些位 图之后发送有之前有进程想发送通知给它,它就会自己构造消息和并且传递消息 给自己。这点就在后面一个函数会非常的明确。我们现在只要只要本函数
mini_notify是动用了哪些位图:

事实上,它就动用了一个位图:s_notify_pending,将本调用进程的s_id号传递
给目的进程的s_notify_pending位图上。主要是为了让mini_receive发现。

/*=================================================================== ========*

* mini_notify *

*====================================================================

=======*/

PRIVATE int mini_notify(caller_ptr, dst)

register struct proc  *caller_ptr; /* sender of the notification  */

int dst; /* which process to notify  */

{

register struct proc  *dst_ptr  = proc_addr(dst);

int src_id; /* source id for late delivery  */

message m; /* the notification message  */

/* Check to see if target is blocked waiting for this message. A process
* can be both sending and receiving during a SENDREC system call.
检查看这个目标是否是在阻塞等待这个消息。在执行一个SENDREC系统调用
时,

一个进程既能做出送也能做出接受
*/

if  ((dst_ptr->p_rts_flags &  (RECEIVING|SENDING))  == RECEIVING &&

!  (priv(dst_ptr)->s_flags & SENDREC_BUSY) &&

(dst_ptr->p_getfrom  == ANY  || dst_ptr->p_getfrom  == caller_ptr->p_nr))  {

//满足条件就进入内部构造消息和发送消息

/* Destination is indeed waiting for a message. Assemble a notification

* message and deliver it. Copy from pseudo-source HARDWARE, since

the

* message is in the kernel's address space.
*/

BuildMess(&m, proc_nr(caller_ptr), dst_ptr);

CopyMess(proc_nr(caller_ptr), proc_addr(HARDWARE), &m,
dst_ptr, dst_ptr->p_messbuf);

dst_ptr->p_rts_flags &=  ~RECEIVING;  /* deblock destination  */ if  (dst_ptr->p_rts_flags  ==  0) enqueue(dst_ptr);
return(OK);

}

104

https://images0.cnblogs.com/blog/665917 … 624108.png

/* Destination is not ready to receive the notification. Add it to the
* bit map with pending notifications. Note the indirectness: the system
id

* instead of the process number is used in the pending bit map.
目的进程不在准备接受一个通知。将这个加到挂起通知位图上。
*/

//这是这个通知最显著的差别。就在于设置目的进程的通知位图.之后返回。 这里并没有来阻塞整个进程的运行

src_id  = priv(caller_ptr)->s_id;

set_sys_bit(priv(dst_ptr)->s_notify_pending, src_id); return(OK);

}

好了,我们现在来介绍最后一个函数调用函函数————mini_receive ,这个函数比较大,我们首先看下执行流程图:

105

继续回到前面分析的那个例子来说明接受进程是怎么来执行这个过程:

还是看那家出国的家庭,那个出国的家庭正好回家了,看到自己家楼下的邮箱外 有很多地址,这家的主人自然就一一跑到上面所写的各个地址上一一找到发送的 主人,取出相关的信件,并且告诉对方“你们现在可以继续发邮件给别人了!!” 还有另外一种情况:就是这个家庭发现自己没有收到自己想要的一家的邮件,所 以他被迫只能一直等到,一直等到想要的那家的邮件发送邮件过来。这个过程可 以比较形象的描述接受过程。

现在来具体的分析过程:

第一:检查调用者自身p_rts_flags标志位是否为SENDING,也就是说调用进程是
否是处在发送等待状态的阻塞队列上。如果是这种情况就比较危险,必须直接返
回。

第二:检查相关的位图信息,也就是s_notify_pending标志位。如果之前有想要 通过notify发送消息给本进程,则应该相应的标志位就应该会应该被置位。被置 位的话,mini_receive()函数这时就会自己构造一个信息,之后发送给自己进程 的消息缓冲区。

第3:如果想要接受mini_send()函数发来的消息的话,则就是接下来的操作了。 这个就对应了那家出国旅游的人回来查看邮箱事件,那家出国旅游回来的一家人 发现了邮箱上有好多地址,他们就一个一个去找,这里也是一样, 通过调用
p_call_q,把链表从头扫到尾,将消息接收过来,之后将那些发送消息的进程放 回到调度队列中。同时也就是标志返回成功。

第四:如果没有想要得到的进程消息,则应该阻塞自己,设置自己的各种标志位, 表明自己是在等待对方。但是有一点要声明,那就是有的进程不希望自己阻塞, 这时就会传递一个flags,如果不想标志自己阻塞就会完成本次mini_receive() 函数的调度而不阻塞自己。

/*=================================================================== ========*

* mini_receive *

*==================================================================== =======*/

//这个函数体是receive的主体主要实现接受信息的功能

PRIVATE int mini_receive(caller_ptr, src, m_ptr, flags)

register struct proc  *caller_ptr;  /* process trying to get message */

int src; /* which message source is wanted  */

message  *m_ptr; /* pointer to message buffer  */

unsigned flags; /* system call flags  */

{

/* A process or task wants to get a message.    If a message is already queued,

* acquire it and deblock the sender.    If no message from the desired source

* is available block the caller, unless the flags don't allow blocking.
*/

106

//如果信息已经在队列中,那么就取信息和将发送者解除阻塞状态

//如果没有想要得到的信息,那么就阻塞这个调用者当然如果调用进程的 flag标志不允许阻塞,

//阻塞操作就不能完成

register struct proc  **xpp;

register struct notification  **ntf_q_pp; message m;

int bit_nr;

sys_map_t  *map;

bitchunk_t  *chunk;

int i, src_id, src_proc_nr;

/* Check to see if a message from desired source is already available.
* The caller's SENDING flag may be set if SENDREC couldn't send. If
it is

* set, the process should be blocked.检查看看一个渴望的消息源是否已 经提供了,这个调用者的SENDING标志位也许被设定,如果SENDREC不能发送时。 如果真的被设定,进程应该是阻塞状态

*/

// 检查调用者的p_rts_flags是否是SENDING状态,调用者的SENDING标志设置 if  (!(caller_ptr->p_rts_flags & SENDING))  {

/* Check if there are pending notifications, except for SENDREC. */ if  (!  (priv(caller_ptr)->s_flags & SENDREC_BUSY))  {

map  = &priv(caller_ptr)->s_notify_pending;

//s_notify_pending表示的通知消息的位图
//这个位图在后面会有介绍

for  (chunk=&map->chunk[0]; chunk<&map->chunk[NR_SYS_CHUNKS]; chunk++)  {

/* Find a pending notification from the requested source. */

if  (!  *chunk) continue; /* no bits in chunk  */

for  (i=0;  !  (*chunk &  (1<<i));  ++i)  {} /* look up the bit

*/

src_id  =  (chunk  - &map->chunk[0])  * BITCHUNK_BITS  + i;

if  (src_id  >= NR_SYS_PROCS) break; /* out of range */

src_proc_nr  = id_to_nr(src_id); /* get source proc */

#if DEBUG_ENABLE_IPC_WARNINGS

if(src_proc_nr  == NONE)  {

kprintf("mini_receive: sending notify from NONE\n");
}

#endif

if  (src!=ANY && src!=src_proc_nr) continue;  /* source not

107

ok  */

*chunk &=  ~(1  << i); /* no longer pending  */

/* Found a suitable source, deliver the notification message.

*/

//找到了一个合适源,发送个通知消息

BuildMess(&m, src_proc_nr, caller_ptr); /* assemble message

*/

CopyMess(src_proc_nr, proc_addr(HARDWARE), &m, caller_ptr,

m_ptr);

return(OK); /* report success  */

}

}

/* Check caller queue. Use pointer pointers to keep code simple. */

xpp  = &caller_ptr->p_caller_q;

// xpp存放调用进程希望发送的进程的首地址。这个时候应该含有相应的信

//这里是将想要接受的消息给予接受,接受完之后就将xpp所指向的进程放 回到调度队列中

while  (*xpp  != NIL_PROC)  {

if  (src  == ANY  || src  == proc_nr(*xpp))  {

/* Found acceptable message. Copy it and update status.  */

CopyMess((*xpp)->p_nr,  *xpp,  (*xpp)->p_messbuf, caller_ptr,

m_ptr);

if (((*xpp)->p_rts_flags &= ~SENDING) == 0) enqueue(*xpp);

*xpp  =  (*xpp)->p_q_link; /* remove from queue  */

return(OK);

}

xpp  = &(*xpp)->p_q_link;
}

}

/* report success  */

/* proceed to next  */

/* No suitable message is available or the caller couldn't send in SENDREC.

* Block the process trying to receive, unless the flags tell otherwise.
*/

//如果调用进程没有想要接受的信息时,就应该阻塞正在尝试接受的进程,当然 如果flags标志不能阻塞,

if  (  !  (flags & NON_BLOCKING))  {
caller_ptr->p_getfrom  = src;
caller_ptr->p_messbuf  = m_ptr;

if  (caller_ptr->p_rts_flags  ==  0) dequeue(caller_ptr); caller_ptr->p_rts_flags  |= RECEIVING;

108

return(OK);

//上面的几个操作主要是用于设定call_ptr的相应的操作值,之后用于阻塞 } else  {

return(ENOTREADY);

}

}

下面几个函数就比较的简单,简单介绍下:

带有Lock_....()函数主要是防止其他进程或者中断干扰这里,所以提出了一种 加锁的概念。也就是说在发送时,会关闭中断,发送完毕,关闭中断。或者接受 之前关闭中断,接受完成,开启中断。

/*=================================================================== ========*

* lock_notify *

*====================================================================

=======*/

PUBLIC int lock_notify(src, dst)

int src; /* sender of the notification  */

int dst; /* who is to be notified  */

{

/* Safe gateway to mini_notify() for tasks and interrupt handlers. The
sender is explicitely given to prevent confusion where the call comes from.
MINIX    kernel is not reentrant, which means to interrupts are disabled
after      the first kernel entry (hardware interrupt, trap, or exception).
Locking    is done by temporarily disabling interrupts.
给为中断处理和系统任务提供一个Mini_notify()安全的方法。发送者被明确得
给出来阻止这种疑惑:调用者来自哪里。MINIX内核不是可以嵌套的,这就意味
着在第一次进入内核之后必须关闭中断。锁就是通过临时的关闭中断。
*/

int result;

/* Exception or interrupt occurred, thus already locked.  */

//k_reenter代表的就是嵌套中断,也就是如果大于0,代表嵌入了,则已经加上 锁了,就没有必要在此加锁。

if  (k_reenter  >=  0)  {

result  = mini_notify(proc_addr(src), dst);

}

/* Call from task level, locking is required.  */
//如果不是,则就在处理前加锁,处理完解开锁。
else  {

lock(0, "notify");

result  = mini_notify(proc_addr(src), dst);

109

unlock(0);

}

return(result);

}

/*=================================================================== ========*

* lock_send 同上函数一样,处理前加锁,处理后解开锁

*

*==================================================================== =======*/

PUBLIC int lock_send(dst, m_ptr)

int dst; /* to whom is message being sent?  */

message  *m_ptr; /* pointer to message buffer  */

{

/* Safe gateway to mini_send() for tasks.  */
int result;

lock(2, "send");

result  = mini_send(proc_ptr, dst, m_ptr, NON_BLOCKING); unlock(2);

return(result);

}

6.5 MINIX3内核部件使用消息机制举例

消息传递机制的主体实现基本上是分析完了,我们现在结合内核其他部分调用消 息传递机制来进一步分析之前的操作过程。

首先看CLOCK进程的消息传递机制
„„„„

„„„„
„„„„

Receive(ANY,&m);
„„„„

„„„„

在 clock_handler 中
„„„„

„„„„
„„„„

lock_notify(HARDWARE, CLOCK)

„„„„

„„„„
„„„„

110

FluxBB bbcode 测试
首先从 RECEIVE(ANY,&m)入手,提出这样一个假设,还有一个能 ANY 消息
机制发送,现在 CLOCK 进程阻塞,之后再某次 clock_handler 中,我们会调用
lock_notify(HARDWARE,CLOCK)主要就是发送 CLOCK 时钟,让 CLOCK 时钟
马上从阻塞队列中移除出来,放到调度队列中准备接受 CPU 调度。我看下示意
图:

这就是 2 者相互同步示意图,当然这里是用 notify 同步。现在我们假设一种相反 的情况:

就是  CLOCK  进程是在就绪队列中 ,但是没有别调度 ,这时先发送了一个
mini_notify(HARDWARE,CLOCK),接下来该怎么处理呢?同样,当 CLOCK 时
钟任务调用 recive(ANY,&m),这时它就会检查位图,发现有消息,就会立即构
造消息并且将其复制自己的消息缓冲队列中。这种模式可以看如下的示意图:


FluxBB bbcode 测试

6.6 MINIX3 IPC 总结

整个过程就是这样,MINIX3 的消息机制其实就相当于送邮件人和发送邮件人的 关系:

1 发件人如果发现接受者正在家里等他来信,则发件人就会把信亲自给他,之后 告诉发件人你的信收到了,可以不必呆在家里,可以出去了。
2 如果送件人发现收件人不在家里,但是送件人又不在送,只有把自己的地址贴 在发件人的门上,并且自己回到家中等他来拿信。

3 收件人刚刚从外面回来,看到门口的信息,马上按照这个地址找到收件人,拿 了信件,并且告诉收件人我现在拿了信件,你可以自己去干其他事情了。
4 这时收件人回到家中,发现自己希望得到另外一个非常重要的来信,但是对方 没有按照规定的时间来,但是他又太想要这封重要的信件,所以他必须等待对方 把信件送来,在没有送来之前,他不得不一直呆在家里,防止错过了。
整个消息传递机制就是上面 4 种可能一直循环往复。这就是消息机制的最核心部 分。MINIX3 的消息传递到此全部结束了!

#420 内核模块 » Linux添加系统调用的两种方法 » 2022-09-01 12:24:17

batsom
回复: 0

前言
系统调用的基本原理

系统调用其实就是函数调用,只不过调用的是内核态的函数,但是我们知道,用户态是不能随意调用内核态的函数的,所以采用软中断的方式从用户态陷入到内核态。在内核中通过软中断0X80,系统会跳转到一个预设好的内核空间地址,它指向了系统调用处理程序(不要和系统调用服务例程混淆),这里指的是在entry.S文件中的system_call函数。就是说,所有的系统调用都会统一跳转到这个地址执行system_call函数,那么system_call函数如何派发它们到各自的服务例程呢?
我们知道每个系统调用都有一个系统调用号。同时,内核中一个有一个system_call_table数组,它是个函数指针数组,每个函数指针都指向了系统调用的服务例程。这个系统调用号是system_call_table的下标,用来指明到底要执行哪个系统调用。当int ox80的软中断执行时,系统调用号会被放进eax寄存器中,system_call函数可以读取eax寄存器获得系统调用号,将其乘以4得到偏移地址,以sys_call_table为基地址,基地址加上偏移地址就是应该执行的系统调用服务例程的地址。
系统调用的传参问题

当一个系统调用的参数个数大于5时(因为5个寄存器(eax, ebx, ecx, edx,esi)已经用完了),执行int 0x80指令时仍需将系统调用功能号保存在寄存器eax中,所不同的只是全部参数应该依次放在一块连续的内存区域里,同时在寄存器ebx中保存指向该内存区域的指针。系统调用完成之后,返回值扔将保存在寄存器eax中。由于只是需要一块连续的内存区域来保存系统调用的参数,因此完全可以像普通函数调用一样使用栈(stack)来传递系统调用所需要的参数。但是要注意一点,Linux采用的是c语言的调用模式,这就意味着所有参数必须以相反的顺序进栈,即最后一个参数先入栈,而第一个参数则最后入栈。如果采用栈来传递系统调用所需要的参数,在执行int 0x80指令时还应该将栈指针的当前值复制到寄存器ebx中。
1.添加系统调用的两种方法

方法一:编译内核法
拿到源码之后

        修改内核的系统调用库函数 /usr/include/asm-generic/unistd.h,在这里面可以使用在syscall_table中没有用到的223号
        添加系统调用号,让系统根据这个号,去找到syscall_table中的相应表项。在 /arch/x86/kernel/syscall_table_32.s文件中添加系统调用号和调用函数的对应关系
        接着就是my_syscall的实现了,在这里有两种方法:第一种方法是在kernel下自己新建一个目录添加自己的文件,但是要编写Makefile,而且要修改全局的Makefile。第二种比较简便的方法是,在kernel/sys.c中添加自己的服务函数,这样子不用修改Makefile.

以上准备工作做完之后,然后就要进行编译内核了,以下是我编译内核的一个过程。

1.make menuconfig (使用图形化的工具,更新.config文件)
2.make -j3 bzImage  (编译,-j3指的是同时使用3个cpu来编译,bzImage指的是更新grub,以便重新引导)
3.make modules   (对模块进行编译)
4.make modules_install(安装编译好的模块)
5.depmod  (进行依赖关系的处理)
6.reboot  (重启看到自己编译好的内核)

方法二:内核模块法
这种方法是采用系统调用拦截的一种方式,改变某一个系统调用号对应的服务程序为我们自己的编写的程序,从而相当于添加了我们自己的系统调用。具体实现,我们来看下:
2.通过内核模块实现添加系统调用

这种方法其实是系统调用拦截的实现。系统调用服务程序的地址是放在sys_call_table中通过系统调用号定位到具体的系统调用地址,那么我们通过编写内核模块来修改sys_call_table中的系统调用的地址为我们自己定义的函数的地址,就可以实现系统调用的拦截。
想法有了:那就是通过模块加载时,将系统调用表里面的那个系统调用号的那个系统调用号对应的系统调用服务例程改为我们自己实现的系统历程函数地址。但是内核已经不知道从哪个版本就不支持导出sys_call_table了。所以首先要获取sys_call_table的地址。
网上介绍了好多种方法来得到sys_call_table的地址,这里介绍最简单的一种方法

grep sys_call_table /boot/System.map-`uname -r`

这样就得到了sys_call_table的地址,但同时也得到了一个重要的信息,该符号对应的内存区域是只读的。所以我们要修改它,必须对它进行清楚写保护,这里介绍两种方法:
第一种方法::我们知道控制寄存器cr0的第16位是写保护位。cr0的第16位置为了禁止超级权限,若清零了则允许超级权限往内核中写入数据,这样我们可以再写入之前,将那一位清零,使我们可以写入。然后写完后,又将那一位复原就行了。

unsigned int clear_and_return_cr0(void)
{
unsigned int cr0 = 0;
unsigned int ret;
asm("movl %%cr0, %%eax":"=a"(cr0));
ret = cr0;
cr0 &= 0xfffeffff;
asm("movl %%eax, %%cr0"::"a"(cr0));
return ret;
}

void setback_cr0(unsigned int val) //读取val的值到eax寄存器,再将eax寄存器的值放入cr0中
{
asm volatile("movl %%eax, %%cr0"::"a"(val));
}

第二种方法:通过设置虚拟地址对应的也表项的读写属性来设置:

int make_rw(unsigned long address) 

        unsigned int level; 
        pte_t *pte = lookup_address(address, &level);//查找虚拟地址所在的页表地址 
        if (pte->pte & ~_PAGE_RW)  //设置页表读写属性
                pte->pte |=  _PAGE_RW; 
         
        return 0; 

 
 
 
int make_ro(unsigned long address) 

        unsigned int level; 
        pte_t *pte = lookup_address(address, &level); 
        pte->pte &= ~_PAGE_RW;  //设置只读属性
 
        return 0; 
}


3.编写系统调用指定自己的系统调用

内核的初始化函数
在这里我使用系统空闲的223号空闲的系统调用号,你也可以换成其他系统调用的调用号,这样你在执行其他函数时,就会调用自己的写的函数的内容。

static int syscall_init_module(void) 

        printk(KERN_ALERT "sys_call_table: 0x%p\n", sys_call_table);//获取系统调用表的地址
        orig_saved = (unsigned long *)(sys_call_table[223]);  //保存原有的223号的系统调用表的地址
        printk(KERN_ALERT "orig_saved : 0x%p\n", orig_saved ); 
 
        make_rw((unsigned long)sys_call_table);  //修改页的写属性
        sys_call_table[223] = (unsigned long *)sys_mycall;  //将223号指向自己写的调用函数
        make_ro((unsigned long)sys_call_table); 
 
        return 0; 
}

自己的系统调用服务例程

asmlinkage long sys_mycall(void)
{
    printk(KERN_ALERT "i am hack syscall!\n");
    return 0;
}

移除内核模块时,将原有的系统调用进行还原

static void syscall_cleanup_module(void) 

        printk(KERN_ALERT "Module syscall unloaded.\n"); 
 
        make_rw((unsigned long)sys_call_table); 
        sys_call_table[223] = (unsigned long *) orig_saved ;   
        make_ro((unsigned long)sys_call_table); 
}

模块注册相关

module_init(syscall_init_module); 
module_exit(syscall_cleanup_module); 
 
MODULE_LICENSE("GPL"); 
MODULE_DESCRIPTION("mysyscall"); 

4.编写用户态的测试程序

  1 #include <linux/unistd.h>
  2 #include <syscall.h>
  3 #include <sys/types.h>
  4 #include <stdio.h>
  5
  6 int main(void)
  7 {
  8     long pid = 0;
  9     pid = syscall(223);
10     printf("%ld\n",pid);
11     return 0;
12 }

当我们使用syscall()这个函数去触发223的系统调用时,dmesg会发现我们自己写的服务函数的输出结果:

#421 内核模块 » 介绍Linux下的系统调用过程 » 2022-09-01 12:22:34

batsom
回复: 0

首先,应用程序能直接调用的是系统提供的API,这个在用户态(Ring3)下就可做到。

然后相应的API就会将相应的系统调用号保存到eax寄存器中(这一步通过内联汇编实现),之后就是使用int 0x80触发中断(内联汇编),进入到中断处理函数中(该函数是完全由汇编代码编写),这个时候就进入到了内核态(Ring0)了。

在中断处理函数中就会调用与系统调用号相对应的那个系统调用。在这个函数中,会把ds、es这两个寄存器设置为指向内核空间。这样一来,我们无法把数据从用户态中传到内核态啊(如open(const char * filename, int flag, ...)中,filename指针指向的字符串的地址是在用户空间中的,在内核空间相应的地方取的话根本没有该字符串),这该怎么办呢?中断处理函数中的fs寄存器被设置为指向了用户空间,所以问题得以解决。

在系统调用中就是进行相应的操作了,如打开文件、写文件等。

处理完后,将会返回到中断处理函数,返回值保存在eax寄存器中。

从中断处理函数中返回到API,依旧是把返回值保存到eax寄存器中。这个时候就从内核态恢复成用户态。

在API中从eax中取出值,做相应的判断返回不同的值,用以表示操作完成情况。
为什么使用int 0x80中断能调用那么多系统调用?

在保护模式下,有各种各样的中断,而系统调用就和0x80号中断绑定。当要调用系统调用时,就触发int 0x80,中断处理函数就通过eax获知想要调用的是哪一个系统调用。这样做的原因是系统调用数量太多,中断号会不够用,所以用一个来集中管理。

操作系统中有一个表,是用来保存各个系统调用函数的地址的。这个表是一个数组,所以通过下标就可以访问到不同函数的地址。故可以做到一个中断号+各样的系统调用号就管理多个系统调用。

#422 内核模块 » linux系统调用的三种方法 » 2022-09-01 12:16:42

batsom
回复: 0

1、通过 glibc 提供的库函数:

glibc 是 Linux 下使用的开源的标准 C 库,它是 GNU 发布的 libc 库,即运行时库。glibc 为程序员提供丰富的 API(Application Programming Interface),除了例如字符串处理、数学运算等用户态服务之外,最重要的是封装了操作系统提供的系统服务,即系统调用的封装。那么glibc提供的系统调用API与内核特定的系统调用之间的关系是什么呢?

通常情况,每个特定的系统调用对应了至少一个 glibc 封装的库函数,如系统提供的打开文件系统调用 sys_open 对应的是 glibc 中的 open 函数;

其次,glibc 一个单独的 API 可能调用多个系统调用,如 glibc 提供的 printf 函数就会调用如 sys_open、sys_mmap、sys_write、sys_close 等等系统调用;

另外,多个 API 也可能只对应同一个系统调用,如glibc 下实现的 malloc、calloc、free 等函数用来分配和释放内存,都利用了内核的 sys_brk 的系统调用。

2、使用 syscall直接调用:

上面通过glibc的方法有很多好处,首先你无须知道更多的细节,如 chmod 系统调用号,你只需了解 glibc 提供的 API 的原型;其次,该方法具有更好的移植性,你可以很轻松将该程序移植到其他平台,或者将 glibc 库换成其它库,程序只需做少量改动。

但有点不足是,如果 glibc 没有封装某个内核提供的系统调用时,我就没办法通过上面的方法来调用该系统调用。如我自己通过编译内核增加了一个系统调用,这时 glibc 不可能有你新增系统调用的封装 API,此时我们可以利用 glibc 提供的syscall 函数直接调用。该函数定义在 unistd.h 头文件中,函数原型如下:

long int syscall (long int sysno, ...)

    sysno :是系统调用号,每个系统调用都有唯一的系统调用号来标识。在 sys/syscall.h 中有所有可能的系统调用号的宏定义。
    ... :剩余可变长的参数,为系统调用所带的参数,根据系统调用的不同,可带0~5个不等的参数,如果超过特定系统调用能带的参数,多余的参数被忽略。

返回值 该函数返回值为特定系统调用的返回值,在系统调用成功之后你可以将该返回值转化为特定的类型,如果系统调用失败则返回 -1,错误代码存放在 errno 中。

3、通过 int 指令陷入:

如果我们知道系统调用的整个过程的话,应该就能知道用户态程序通过软中断指令int 0x80 来陷入内核态(在Intel Pentium II 又引入了sysenter指令),参数的传递是通过寄存器,eax 传递的是系统调用号,ebx、ecx、edx、esi和edi 来依次传递最多五个参数,当系统调用返回时,返回值存放在 eax 中。

#423 内核模块 » 进程间通信的方式有哪些 » 2022-08-31 16:12:24

batsom
回复: 0

进程间通信的方式有:

一、管道

管道,通常指无名管道,是 UNIX 系统IPC最古老的形式。

特点:

    它是半双工的(即数据只能在一个方向上流动),具有固定的读端和写端。

    它只能用于具有亲缘关系的进程之间的通信(也是父子进程或者兄弟进程之间)。

    它可以看成是一种特殊的文件,对于它的读写也可以使用普通的read、write 等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。

二、FIFO

FIFO,也称为命名管道,它是一种文件类型。

1、特点

    FIFO可以在无关的进程之间交换数据,与无名管道不同。

    FIFO有路径名与之相关联,它以一种特殊设备文件形式存在于文件系统中。

三、消息队列

消息队列,是消息的链接表,存放在内核中。一个消息队列由一个标识符(即队列ID)来标识。

特点

    消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级。

    消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除。

    消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。

四、信号量

信号量(semaphore)与已经介绍过的 IPC 结构不同,它是一个计数器。信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。

特点

    信号量用于进程间同步,若要在进程间传递数据需要结合共享内存。

    信号量基于操作系统的 PV 操作,程序对信号量的操作都是原子操作。

    每次对信号量的 PV 操作不仅限于对信号量值加 1 或减 1,而且可以加减任意正整数。

    支持信号量组。

五、共享内存

共享内存(Shared Memory),指两个或多个进程共享一个给定的存储区。

特点

    共享内存是最快的一种 IPC,因为进程是直接对内存进行存取。

    因为多个进程可以同时操作,所以需要进行同步。

    信号量+共享内存通常结合在一起使用,信号量用来同步对共享内存的访问。

#424 引导模块和保护模式 » 引导扇区 » 2022-08-30 20:28:53

batsom
回复: 0

我们以硬盘的引导扇区为例,0柱面0磁道1扇区是整个硬盘的第一个扇区,也是所谓的引导扇区MBR。MBR总共512字节, 446开机管理程序 boot loader,64字节的分区 表, 2字节 0x55aa校验字节。这个开机管理程序是由操作系统写进去的,所以开机管理程序认识操作系统安装分区的文件系统,可以找到操作系统的核心文件,然后加载进 内存,并 将CPU控制权交给操作系统。一个硬盘只有一个MBR,但是当将磁盘进行磁盘分区后,每个分区都会有一个启动扇区,每个分区都会被格式化为一种文件系统

启动扇区都可以安装boot loader,boot loader认识核心文件在文件系统的位置。这就是为什么可以创建双系统的原因。

首先安装windows操作系统,windows占有第一个分区叫做C盘吧,windows系统将自己的boot loader写入MBR,同时也会写入C盘分区的启动扇区(左下角的蓝块),这时如 果有其他分区比如D,E,其他分区的启动扇区都会写入boot loader

加入你现在想安装双系统,你需要分割出一个分区给linux, 叫D分区吧(当然一旦格式化成linux的文件系统比如ext3,windows就不认识这个分区了,在windows的文档管理 里也就看不见这个盘了),格式化分区(也就是写入文件系统信息),然后将linux的boot loader写入MBR和自己所在分区D的启动扇区。

启动机器时执行MBR的boot loader(因为这是最后linux写入的,而linux的boot loader不会覆盖windows所在分区的启动扇区)提供选单,MBR 的 boot loader可以直接加载l inux的核心文件(因为它认识linux的文件系统), 可以转向windows启动扇区的boot loader然后再由windows 自己的boot loader加载windows的核心文件,也可以转向linux启动 扇区的boot loader然后加载Linux的核心文件。这就是多重操作系统的原理。

#425 引导模块和保护模式 » 开机启动流程: » 2022-08-30 20:25:03

batsom
回复: 0

打开电源, 一通电源其实CPU已经开始工作了,CS: eip 指向 FFFF:0, 而FFFF:0处存放着一条跳转指令, 所以CPU就执行这条跳转指令

    跳转指令跳转到BIOS ROM中的程序中执行,控制权交给BIOS

    BIOS 进行硬件检测和一些程序初始化比如设置一些BIOS中断向量(16位下)

    BIOS根据硬件设置,取得第一个可开机装置(这也是我们经常装系统是需要去BIOS中设置的开机启动装置顺序)

    BIOS去开机装置中加载数据,比如硬盘或者软盘就会去读取0号柱面0号磁柱1号扇区(MBR)中的数据,加载到内存0:7c00中(同时用0x55aa作为对引导扇区的校验, 如果不是以这两个字节数据结束,数据加载失败),加载完后cs:eip指向 0:7c00(加载过程是通过调用BIOS提供的扇区读取中断程序)

    从0:7c00开始执行程序,CPU控制权从BIOS转移到开机管理程序也就是从硬盘或者软盘第一个扇区加载进来的数据,所以严格意义上说第一个程序只是一个开机管理程 序

    我们的程序调用BIOS提供的中断程序向显存中写入字符串,并传递数量,现实属性等参数

    最后调用 jmp $进入无限循环

通过这个启动流程发现其实我们写org 7c00h并不能认为将程序加载到7c00处去执行,上面7)之前全部都是硬件执行,我们控制不了,我们做的只是将数据放到确定的地 方,让硬件去读去执行,我们写org 0x7c00h 只是让编译器将其他标签的偏移地址设置正确,如果我们写成org 0x1000h,程序依然会被加载到 0:7c00处,但是 mov ax, BootMessage这句就会执行出错, 因为此时BootMessage会变成 1000h+ 1c(BootMessage和程序开头之间所有指令长度总和),但字符串实际是存在 0:7c1c处的。

页脚

Powered by FluxBB

本站由XREA提供空间支持