• 你管这叫操作系统源码(十七)


    读硬盘数据全流程

    如果让你来设计这个函数

    首先我们知道,通过系列之十一 加载根文件系统 中文件系统的建设:

    ch17-4

    以及系列之十二 打开终端设备文件 讲解的打开一个文件的操作:

    ch17-5

    我们已经可以很方便地通过一个文件描述符 fd,寻找到存储在硬盘中的一个文件了,再具体点就是知道这个文件在硬盘中的哪几个扇区中。所以,设计这个函数第一个要指定的参数就可以是 fd 了,它仅仅是个数字。当然,之所以能这样方便,就要感谢刚刚说的文件系统建设以及打开文件的逻辑这两项工作。

    之后,我们得告诉这个函数,把这个 fd 指向的硬盘中的文件,复制到内存中的哪个位置复制多大。那更简单了,内存中的位置,我们用一个表示地址值的参数 buf,复制多大,我们用 count 来表示,单位是字节。

    那这个函数就可以设计为

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

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

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

    // read_write.c
    int sys_read(unsigned int fd,char * buf,int count) {
        struct file * file;
        struct m_inode * inode;
    
        if (fd>=NR_OPEN || count<0 || !(file=current->filp[fd]))
            return -EINVAL;
        if (!count)
            return 0;
        verify_area(buf,count);
        inode = file->f_inode;
        if (inode->i_pipe)
            return (file->f_mode&1)?read_pipe(inode,buf,count):-EIO;
        if (S_ISCHR(inode->i_mode))
            return rw_char(READ,inode->i_zone[0],buf,count,&file->f_pos);
        if (S_ISBLK(inode->i_mode))
            return block_read(inode->i_zone[0],&file->f_pos,buf,count);
        if (S_ISDIR(inode->i_mode) || S_ISREG(inode->i_mode)) {
            if (count+file->f_pos > inode->i_size)
                count = inode->i_size - file->f_pos;
            if (count<=0)
                return 0;
            return file_read(inode,file,buf,count);
        }
        printk("(Read)inode->i_mode=%06o\n\r",inode->i_mode);
        return -EINVAL;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

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

    // read_write.c
    int sys_read(unsigned int fd,char * buf,int count) {
        struct file * file = current->filp[fd];
        // 校验 buf 区域的内存限制
        verify_area(buf,count);
        struct m_inode * inode = file->f_inode;
        // 管道文件
        if (inode->i_pipe)
            return (file->f_mode&1)?read_pipe(inode,buf,count):-EIO;
        // 字符设备文件
        if (S_ISCHR(inode->i_mode))
            return rw_char(READ,inode->i_zone[0],buf,count,&file->f_pos);
        // 块设备文件
        if (S_ISBLK(inode->i_mode))
            return block_read(inode->i_zone[0],&file->f_pos,buf,count);
        // 目录文件或普通文件
        if (S_ISDIR(inode->i_mode) || S_ISREG(inode->i_mode)) {
            if (count+file->f_pos > inode->i_size)
                count = inode->i_size - file->f_pos;
            if (count<=0)
                return 0;
            return file_read(inode,file,buf,count);
        }
        // 不是以上几种,就报错
        printk("(Read)inode->i_mode=%06o\n\r",inode->i_mode);
        return -EINVAL;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    这样,整个的逻辑就非常清晰了。由此也可以注意到,操作系统源码的设计比我刚刚说的更通用,我刚刚只让你设计了读取硬盘的函数,但其实在 Linux 下一切皆文件,所以这个函数将管道文件、字符设备文件、块设备文件、目录文件、普通文件分别指向了不同的具体实现。

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

    // read_write.c
    int sys_read(unsigned int fd,char * buf,int count) {
        struct file * file = current->filp[fd];
        struct m_inode * inode = file->f_inode;
        // 校验 buf 区域的内存限制
        verify_area(buf,count);
        // 仅关注目录文件或普通文件
        return file_read(inode,file,buf,count);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    太棒了!没剩多少了,一个个击破!

    1. 第一步,根据文件描述符 fd,在进程表里拿到了 file 信息,进而拿到了 inode 信息。
    2. 第二步,对 buf 区域的内存做校验。
    3. 第三步,调用具体的 file_read 函数进行读操作。

    就这三步,很简单吧~在进程表 filp 中拿到 file 信息进而拿到 inode 信息这一步就不用多说了,这是在打开一个文件时,或者像管道文件一样创建出一个管道文件时,就封装好了 file 以及它的 inode 信息。

    我们看接下来的两步。

    对buf区域的内存做校验verify_area

    buf 区域的内存做校验的部分,说是校验,里面还挺有说道呢

    // fork.c
    void verify_area(void * addr,int size) {
        unsigned long start;
        start = (unsigned long) addr;
        size += start & 0xfff;
        start &= 0xfffff000;
        start += get_base(current->ldt[2]);
        while (size>0) {
            size -= 4096;
            write_verify(start);
            start += 4096;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    addr 就是刚刚的 buf,size 就是刚刚的 count。然后这里又将 addr 赋值给了 start 变量。所以代码开始,start 就表示要复制到的内存的起始地址,size 就是要复制的字节数

    这段代码很简单,但如果不了解内存的分段和分页机制,将会难以理解。

    Linux 0.11 对内存是以 4K 为一页单位来划分内存的,所以内存看起来就是一个个 4K 的小格子。
    ch19-4a
    我们假设要复制到的内存的起始地址 start 和要复制的字节数 size 在图中的那个位置。那么开始的两行计算代码:

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

    就是将 start 和 size 按页对齐一下:
    ch19-4b
    然后,又由于每个进程有不同的数据段基址,所以还要加上它:

    // fork.c
    void verify_area(void * addr,int size) {
        ...
        start += get_base(current->ldt[2]);
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    具体说来就是加上当前进程的局部描述符表 LDT 中的数据段的段基址:
    ch19-5

    每个进程的 LDT 表,由 Linux 创建进程时的代码给规划好了。具体说来,就是如上图所示,每个进程的线性地址范围,是:

    (进程号)*64M ~ (进程号+1)*64M

    而对于进程本身来说,都以为自己是从零号地址开始往后的 64M,所以传入的 start 值也是以零号地址为起始地址算出来的。

    但现在经过系统调用进入 sys_write 后会切换为内核态,内核态访问数据会通过基地址为 0 的全局描述符表中的数据段来访问数据。所以,start 要加上它自己进程的数据段基址才对。

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

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

    也就是这些页:
    ch19-4c
    这些 write_verify 将会对这些页进行写页面验证,如果页面存在但不可写,则执行 un_wp_page 复制页面:

    // memory.c
    void write_verify(unsigned long address) {
        unsigned long page;
        if (!( (page = *((unsigned long *) ((address>>20) & 0xffc)) )&1))
            return;
        page &= 0xfffff000;
        page += ((address>>10) & 0xffc);
        if ((3 & *(unsigned long *) page) == 1)  /* non-writeable, present */
            un_wp_page((unsigned long *) page);
        return;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    看,那个 un_wp_page 意思就是取消页面的写保护,就是写时复制的原理,在系列之十中 写时复制 已经讨论过了,这里就不做展开了。

    执行读操作 file_read

    下面终于开始进入读操作的正题了,页校验完之后,就可以真正调用 file_read 函数了:

    // read_write.c
    int sys_read(unsigned int fd,char * buf,int count) {
        ...
        return file_read(inode,file,buf,count);
    }
    
    // file_dev.c
    int file_read(struct m_inode * inode, struct file * filp, char * buf, int count) {
        int left,chars,nr;
        struct buffer_head * bh;
        left = count;
        while (left) {
            if (nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE)) {
                if (!(bh=bread(inode->i_dev,nr)))
                    break;
            } else
                bh = NULL;
            nr = filp->f_pos % BLOCK_SIZE;
            chars = MIN( BLOCK_SIZE-nr , left );
            filp->f_pos += chars;
            left -= chars;
            if (bh) {
                char * p = nr + bh->b_data;
                while (chars-->0)
                    put_fs_byte(*(p++),buf++);
                brelse(bh);
            } else {
                while (chars-->0)
                    put_fs_byte(0,buf++);
            }
        }
        inode->i_atime = CURRENT_TIME;
        return (count-left)?(count-left):-ERROR;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    整体看,就是一个 while 循环,每次读入一个块的数据,直到入参所要求的大小全部读完为止。while 去掉,简化起来就是这样:

    // file_dev.c
    int file_read(struct m_inode * inode, struct file * filp, char * buf, int count) {
        ...
        int nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE);
        struct buffer_head *bh=bread(inode->i_dev,nr);
        ...
        char * p = nr + bh->b_data;
        while (chars-->0)
             put_fs_byte(*(p++),buf++);
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    首先 bmap 获取全局数据块号,然后 bread 将数据块的数据复制到缓冲区,然后 put_fs_byte 再一个字节一个字节地将缓冲区数据复制到用户指定的内存中。

    我们一个个看

    bmap获取全局数据块

    先看第一个函数调用,bmap

    // file_dev.c
    int file_read(struct m_inode * inode, struct file * filp, char * buf, int count) {
        ...
        int nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE);
        ...}
    
    // inode.c 
    int bmap(struct m_inode * inode,int block) {
        return _bmap(inode,block,0);
    }
    
    static int _bmap(struct m_inode * inode,int block,int create) {
        ...
        if (block<0)
            ...
        if (block >= 7+512+512*512)
            ...
        if (block<7) 
            // zone[0] 到 zone[7] 采用直接索引,可以索引小于 7 的块号
            ...
        if (block<512)
            // zone[7] 是一次间接索引,可以索引小于 512 的块号
            ...
        // zone[8] 是二次间接索引,可以索引大于 512 的块号
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    我们看到整个条件判断的结构是根据 block 来划分的。block 就是要读取的块号,之所以要划分,就是因为 inode 在记录文件所在块号时,采用了多级索引的方式:

    ch17-4

    zone[0] 到 zone[7] 采用直接索引,zone[7] 是一次间接索引,zone[8] 是二次间接索引。

    那我们刚开始读,块号肯定从零开始,所以我们就先看 block<7,通过直接索引这种最简单的方式读的代码

    // inode.c
    static int _bmap(struct m_inode * inode,int block,int create) {
        ...
        if (block<7) {
            if (create && !inode->i_zone[block])
                if (inode->i_zone[block]=new_block(inode->i_dev)) {
                    inode->i_ctime=CURRENT_TIME;
                    inode->i_dirt=1;
                }
            return inode->i_zone[block];
        }
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    由于 create = 0,也就是并不需要创建一个新的数据块,所以里面的 if 分支也没了

    // inode.c
    static int _bmap(struct m_inode * inode,int block,int create) {
        ...
        if (block<7) {
            ...
            return inode->i_zone[block];
        }
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    可以看到,其实 bmap 返回的,就是要读入的块号,从全局看在块设备的哪个逻辑块号下。也就是说,假如我想要读这个文件的第一个块号的数据,该函数返回的是你这个文件的第一个块在整个硬盘中的哪个块中

    bread将获取的数据块号读入到高速缓冲块中

    拿到这个数据块号后,回到 file_read 函数接着看:

    // file_dev.c
    int file_read(struct m_inode * inode, struct file * filp, char * buf, int count) {
        ...
        while (left) {
            if (nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE)) {
                if (!(bh=bread(inode->i_dev,nr)))
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    nr 就是具体的数据块号,作为其中其中一个参数,传入下一个函数 breadbread 这个方法的入参除了数据块号 block(就是刚刚传入的 nr)外,还有 inode 结构中的 i_dev,表示设备号

    // buffer.c
    struct buffer_head * bread(int dev,int block) {
        struct buffer_head * bh = getblk(dev,block);
        if (bh->b_uptodate)
            return bh;
        ll_rw_block(READ,bh);
        wait_on_buffer(bh);
        if (bh->b_uptodate)
            return bh;
        brelse(bh);
        return NULL;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    这个 bread 方法就是根据一个设备号 dev 和一个数据块号 block,将这个数据块的数据,从硬盘复制到缓冲区里。关于缓冲区,已经在 系列之六 缓冲区初始化 buffer_init 说明过了,有些久远。而 getblk 方法,就是根据设备号 dev 和数据块号 block,申请到一个缓冲块。简单说就是,先根据 hash 结构快速查找这个 devblock 是否有对应存在的缓冲块。

    ch13-4

    如果没有,那就从之前建立好的双向链表结构的头指针 free_list 开始寻找,直到找到一个可用的缓冲块

    ch13-3改a

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

    // buffer.c
    struct buffer_head * bread(int dev,int block) {
        struct buffer_head * bh = getblk(dev,block);
        ...
    }
    
    struct buffer_head * getblk(int dev,int block) {
        struct buffer_head * tmp, * bh;
    
    repeat:
        // 先从 hash 结构中找
        if (bh = get_hash_table(dev,block))
            return bh;
    
        // 如果没有就从 free_list 开始找遍双向链表
        tmp = free_list;
        do {
            if (tmp->b_count)
                continue;
            if (!bh || BADNESS(tmp)<BADNESS(bh)) {
                bh = tmp;
                if (!BADNESS(tmp))
                    break;
            }
        } while ((tmp = tmp->b_next_free) != free_list);
    
        // 如果还没找到,那就说明没有缓冲块可用了,就先阻塞住等一会
        if (!bh) {
            sleep_on(&buffer_wait);
            goto repeat;
        }
    
        // 到这里已经说明申请到了缓冲块,但有可能被其他进程上锁了
        // 如果上锁了的话,就先等等
        wait_on_buffer(bh);
        if (bh->b_count)
            goto repeat;
    
        // 到这里说明缓冲块已经申请到,且没有上锁
        // 但还得看 dirt 位,也就是有没有被修改
        // 如果被修改了,就先重新从硬盘中读入新数据
        while (bh->b_dirt) {
            sync_dev(bh->b_dev);
            wait_on_buffer(bh);
            if (bh->b_count)
                goto repeat;
        }
        if (find_buffer(dev,block))
            goto repeat;
    
        // 给刚刚获取到的缓冲头 bh 重新赋值
        // 并调整在双向链表和 hash 表中的位置
        bh->b_count=1;
        bh->b_dirt=0;
        bh->b_uptodate=0;
        remove_from_queues(bh);
        bh->b_dev=dev;
        bh->b_blocknr=block;
        insert_into_queues(bh);
        return bh;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61

    总之,经过 getblk 之后,我们就在内存中,找到了一处缓冲块,用来接下来存储硬盘中指定数据块的数据。那接下来的一步,自然就是把硬盘中的数据复制到这里啦,没错,ll_rw_block 就是干这个事的。这个方法的细节特别复杂,在下节把这个方法详细地展开讲解。在本节里,你就当它已经成功地把硬盘中的一个数据块的数据,一个字节都不差地复制到了我们刚刚申请好的缓冲区里。

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

    // file_dev.c
    int file_read(struct m_inode * inode, struct file * filp, char * buf, int count) {
        ...
        int  nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE);
        struct buffer_head *bh=bread(inode->i_dev,nr);
        ...
        char * p = nr + bh->b_data;
        while (chars-->0)
             put_fs_byte(*(p++),buf++);
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

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

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

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

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

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

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

    小结

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

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

    2. file_read 方法做了读盘的全部操作:

      • 通过 bmap 获取到了硬盘全局维度的数据块号
      • bread 将数据块数据复制到缓冲区
      • put_fs_byte 再将缓冲区数据复制到用户内存

    读硬盘数据的细节

    上文讲到ll_rw_block 方法负责把硬盘中指定数据块中的数据,复制到 getblk 方法申请到的缓冲块里,但没有展开详细讲解。所以我们本节详细讲讲,ll_rw_block 是如何完成这一任务的。

    // buffer.c
    struct buffer_head * bread(int dev,int block) {
        ...
        ll_rw_block(READ,bh);
        ...
    }
    
    void ll_rw_block (int rw, struct buffer_head *bh) {
        ...
        make_request(major, rw, bh);
    }
    
    struct request request[NR_REQUEST] = {0};
    static void make_request(int major,int rw, struct buffer_head * bh) {
        struct request *req;    
        ...
        // 从 request 队列找到一个空位
        if (rw == READ)
            req = request+NR_REQUEST;
        else
            req = request+((NR_REQUEST*2)/3);
        while (--req >= request)
            if (req->dev<0)
                break;
        ...
        // 构造 request 结构
        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);
    }
    
    // ll_rw_blk.c
    static void add_request (struct blk_dev_struct *dev, struct request *req) {
        struct request * tmp;
        req->next = NULL;
        cli();
        // 清空 dirt 位
        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();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63

    调用链很长,主线是从 request 数组中找到一个空位,然后作为链表项插入到 request 链表中。没错 request 是一个 32 大小的数组,里面的每一个 request 结构间通过 next 指针相连又形成链表。

    如果你熟悉系列之四中 块设备请求初始化blk_dev_init 所讲的内容,就会明白这个方法。

    ch11-4

    request 的具体结构:

    // blk.h
    struct request {
        int dev;        /* -1 if no request */
        int cmd;        /* READ or WRITE */
        int errors;
        unsigned long sector;
        unsigned long nr_sectors;
        char * buffer;
        struct task_struct * waiting;
        struct buffer_head * bh;
        struct request * next;
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    表示一个读盘的请求参数。

    ch11-3

    有了这些参数,底层方法拿到这个结构之后,就知道怎么样访问硬盘了。那是谁不断从这个 request 队列中取出 request 结构并对硬盘发起读请求操作的呢?这里 Linux 0.11 有个很巧妙的设计,我们看看。

    有没有注意到 add_request 方法有如下分支:

    // blk.h
    struct blk_dev_struct {
        void (*request_fn)(void);
        struct request * current_request;
    };
    
    // ll_rw_blk.c
    struct blk_dev_struct blk_dev[NR_BLK_DEV] = {
        { NULL, NULL },     /* no_dev */
        { NULL, NULL },     /* dev mem */
        { NULL, NULL },     /* dev fd */
        { NULL, NULL },     /* dev hd */
        { NULL, NULL },     /* dev ttyx */
        { NULL, NULL },     /* dev tty */
        { NULL, NULL }      /* dev lp */
    };
    
    static void make_request(int major,int rw, struct buffer_head * bh) {
        ...
        add_request(major+blk_dev,req);
    }
    
    static void add_request (struct blk_dev_struct *dev, struct request *req) {
        ...
        // 当前请求项为空,那么立即执行当前请求项
        if (!(tmp = dev->current_request)) {
            ...
            (dev->request_fn)();
            ...
        }
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    就是当设备的当前请求项为空,也就是第一次收到硬盘操作请求时,会立即执行该设备的 request_fn 方法,这便是整个读盘循环的最初推手

    当前设备的设备号是 3,也就是硬盘,会从 blk_dev 数组中取索引下标为 3 的设备结构。在系列之六中 硬盘初始化 hd_init 的时候,设备号为 3 的设备结构的 request_fn 被赋值为硬盘请求函数 do_hd_request 了:

    // hd.c
    void hd_init(void) {
        blk_dev[3].request_fn = do_hd_request;
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    所以,刚刚的 request_fn 背后的具体执行函数,就是这个 do_hd_request

    #define CURRENT (blk_dev[MAJOR_NR].current_request)
    // hd.c
    void do_hd_request(void) {
        ...
        unsigned int dev = MINOR(CURRENT->dev);
        unsigned int block = CURRENT->sector;
        ...
        nsect = CURRENT->nr_sectors;
        ...
        if (CURRENT->cmd == WRITE) {
            hd_out(dev,nsect,sec,head,cyl,WIN_WRITE,&write_intr);
            ...
        } else if (CURRENT->cmd == READ) {
            hd_out(dev,nsect,sec,head,cyl,WIN_READ,&read_intr);
        } else
            panic("unknown hd-command");
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    我去掉了一大坨根据起始扇区号计算对应硬盘的磁头 head、柱面 cyl、扇区号 sec 等信息的代码。可以看到最终会根据当前请求是写(WRITE)还是读(READ),在调用 hd_out 时传入不同的参数。hd_out 就是读硬盘的最最最最底层的函数了:

    // hd.c
    static void hd_out(unsigned int drive,unsigned int nsect,unsigned int sect,
            unsigned int head,unsigned int cyl,unsigned int cmd,
            void (*intr_addr)(void))
    {
        ...
        do_hd = intr_addr;
        outb_p(hd_info[drive].ctl,HD_CMD);
        port=HD_DATA;
        outb_p(hd_info[drive].wpcom>>2,++port);
        outb_p(nsect,++port);
        outb_p(sect,++port);
        outb_p(cyl,++port);
        outb_p(cyl>>8,++port);
        outb_p(0xA0|(drive<<4)|head,++port);
        outb(cmd,++port);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    可以看到,最底层的读盘请求,其实就是向一堆外设端口做读写操作。这个函数实际上在系列之五中 时间初始化 time_init 为了讲解与 CMOS 外设交互方式的时候讲过了,简单说硬盘的端口表是这样的:

    端口
    0x1F0数据寄存器数据寄存器
    0x1F1错误寄存器特征寄存器
    0x1F2扇区计数寄存器扇区计数寄存器
    0x1F3扇区号寄存器或LBA块地址0~7扇区号寄存器或LBA块地址0~7
    0x1F4磁道数低 8 位或 LBA 块地址 8~15磁道数低 8 位或 LBA 块地址 8~15
    0x1F5磁道数高 8 位或 LBA 块地址 16~23磁道数高 8 位或 LBA 块地址 16~23
    0x1F6驱动器/磁头或 LBA 块地址 24~27驱动器/磁头或 LBA 块地址 24~27
    0x1F7命令寄存器或状态寄存器命令寄存器寄存器

    读硬盘就是,往除了第一个以外的后面几个端口写数据,告诉要读硬盘的哪个扇区,读多少。然后再从 0x1F0 端口一个字节一个字节的读数据。这就完成了一次硬盘读操作。

    当然,从 0x1F0 端口读出硬盘数据,是在硬盘读好数据并放在 0x1F0 后发起的硬盘中断,进而执行硬盘中断处理函数里进行的。在系列之六中 硬盘初始化 hd_init 的时候,将 hd_interrupt 设置为了硬盘中断处理函数,中断号是 0x2E,代码如下:

    // hd.c
    void hd_init(void) {
        ...
        set_intr_gate(0x2E,&hd_interrupt);
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    所以,在硬盘读完数据后,发起 0x2E 中断,便会进入到 hd_interrupt 方法里:

    // system_call.s
    _hd_interrupt:
        ...
        xchgl _do_hd,%edx
        ...
        call *%edx
        ...
        iret
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    这个方法主要是调用 do_hd 方法,这个方法是一个指针,就是高级语言里所谓的接口,读操作的时候,将会指向 read_intr 这个具体实现:

    // hd.c
    void do_hd_request(void) {
        ...
        } else if (CURRENT->cmd == READ) {
            hd_out(dev,nsect,sec,head,cyl,WIN_READ,&read_intr);
        }
        ...
    }
    
    static void hd_out(..., void (*intr_addr)(void)) {
        ...
        do_hd = intr_addr;
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    看,一切都有千丝万缕的联系,是不是很精妙。我们展开 read_intr 方法继续看:

    // hd.c
    #define port_read(port,buf,nr) \
    __asm__("cld;rep;insw"::"d" (port),"D" (buf),"c" (nr):"cx","di")
    
    static void read_intr(void) {
        ...
        // 从数据端口读出数据到内存
        port_read(HD_DATA,CURRENT->buffer,256);
        CURRENT->errors = 0;
        CURRENT->buffer += 512;
        CURRENT->sector++;
        // 还没有读完,则直接返回等待下次
        if (--CURRENT->nr_sectors) {
            do_hd = &read_intr;
            return;
        }
        // 所有扇区都读完了
        // 删除本次都请求项
        end_request(1);
        // 再次触发硬盘操作
        do_hd_request();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    这里使用了 port_read 宏定义的方法,从端口 HD_DATA 中读 256 次数据,每次读一个字,总共就是 512 字节的数据。

    • 如果没有读完发起读盘请求时所要求的字节数,那么直接返回,等待下次硬盘触发中断并执行到 read_intr 即可。

    • 如果已经读完了,就调用 end_request 方法将请求项清除掉,然后再次调用 do_hd_request 方法循环往复。

    那重点就在于,如何结束掉本次请求的 end_request 方法

    // blk.h
    #define CURRENT (blk_dev[MAJOR_NR].current_request)
    
    extern inline void end_request(int uptodate) {
        DEVICE_OFF(CURRENT->dev);
        if (CURRENT->bh) {
            CURRENT->bh->b_uptodate = uptodate;
            unlock_buffer(CURRENT->bh);
        }
        ...
        wake_up(&CURRENT->waiting);
        wake_up(&wait_for_request);
        CURRENT->dev = -1;
        CURRENT = CURRENT->next;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    两个 wake_up 方法:

    1. 第一个唤醒了该请求项所对应的进程 &CURRENT->waiting,告诉这个进程我这个请求项的读盘操作处理完了,你继续执行吧。

    2. 第二个是唤醒了因为 request 队列满了没有将请求项插进来的进程 &wait_for_request

    随后,将当前设备的当前请求项 CURRENT,即 request 数组里的一个请求项 requestdev 置空,并将当前请求项指向链表中的下一个请求项。
    ch19-7
    这样,do_hd_request 方法处理的就是下一个请求项的内容了,直到将所有请求项都处理完毕。

    整个流程就这样形成了闭环,通过这样的机制,可以做到好似存在一个额外的进程,在不断处理 request 链表里的读写盘请求一样
    ch19-8

    • 当设备的当前请求项为空时,也就是没有在执行的块设备请求项时,ll_rw_block 就会在执行到 add_request 方法时,直接执行 do_hd_request 方法发起读盘请求。

    • 如果已经有在执行的请求项了,就插入 request 链表中。

    do_hd_request 方法执行完毕后,硬盘发起读或写请求,执行完毕后会发起硬盘中断,进而调用 read_intr 中断处理函数。

    read_intr 会改变当前请求项指针指向 request 链表的下一个请求项,并再次调用 do_hd_request 方法。

    所以 do_hd_request 方法一旦调用,就会不断处理 request 链表中的一项一项的硬盘请求项,这个循环就形成了,是不是很精妙

    信号

    通过上篇的讲解,即 读硬盘数据全流程 | 读取硬盘数据的细节,我们知道了应用程序发起 read 最终读取到硬盘数据的全部细节。

    ch19-6

    再配合上系列之十五和十六的内容,我们解释清楚了从键盘输入,到 shell 程序最终解释执行你输入的命令的全过程。

    我们继续往下进行,如果在你的程序正在被 shell 程序执行时,你按下了键盘中的 CTRL+C,你的程序就被迫终止,并再次返回到了 shell 等待用户输入命令的状态。本节就来解释这个过程。

    当你按下 CTRL+C 时,根据系列之十五中 用键盘输入一条命令 所讲述的内容,键盘中断处理函数自然会走到处理字符的 copy_to_cooked 函数里

    #define INTMASK (1<<(SIGINT-1))
    // kernel/chr_drv/tty_io.c
    void copy_to_cooked (struct tty_struct *tty) {
        ...
        if (c == INTR_CHAR (tty)) {
            tty_intr (tty, INTMASK);
            continue;
        }
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    这个函数里有一段上述代码,翻译起来特别简单,就是当 INTR_CHAR 发现字符为中断字符时(其实就是 CTRL+C),就调用 tty_intr 给进程发送信号

    tty_intr 函数很简单,就是给所有组号等于 tty 组号的进程,发送信号:

    // kernel/chr_drv/tty_io.c
    void tty_intr (struct tty_struct *tty, int mask) {
        int i;
        ...
        for (i = 0; i < NR_TASKS; i++) {
            if (task[i] && task[i]->pgrp == tty->pgrp) {
                task[i]->signal |= mask;
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    而如何发送信号,在这段源码中也揭秘了,其实就是给进程 task_struct 结构中的 signal 的相应位置 1 而已。发送什么信号,在上面的宏定义中也可以看出,就是 SIGINT 信号。SIGINT 就是个数字,它是几呢?它就定义在 signal.h 这个头文件里:

    // signal.h
    #define SIGHUP  1       /* hangup */
    #define SIGINT  2       /* interrupt */
    #define SIGQUIT 3       /* quit */
    #define SIGILL  4       /* illegal instruction (not reset when caught) */
    #define SIGTRAP 5       /* trace trap (not reset when caught) */
    #define SIGABRT 6       /* abort() */
    #define SIGPOLL 7       /* pollable event ([XSR] generated, not supported) */
    #define SIGIOT  SIGABRT /* compatibility */
    #define SIGEMT  7       /* EMT instruction */
    #define SIGFPE  8       /* floating point exception */
    #define SIGKILL 9       /* kill (cannot be caught or ignored) */
    #define SIGBUS  10      /* bus error */
    #define SIGSEGV 11      /* segmentation violation */
    #define SIGSYS  12      /* bad argument to system call */
    #define SIGPIPE 13      /* write on a pipe with no one to read it */
    #define SIGALRM 14      /* alarm clock */
    #define SIGTERM 15      /* software termination signal from kill */
    #define SIGURG  16      /* urgent condition on IO channel */
    #define SIGSTOP 17      /* sendable stop signal not from tty */
    #define SIGTSTP 18      /* stop signal from tty */
    #define SIGCONT 19      /* continue a stopped process */
    #define SIGCHLD 20      /* to parent on child stop or exit */
    #define SIGTTIN 21      /* to readers pgrp upon background tty read */
    #define SIGTTOU 22      /* like TTIN for output if (tp->t_local<OSTOP) */
    #define SIGIO   23      /* input/output possible signal */
    #define SIGXCPU 24      /* exceeded CPU time limit */
    #define SIGXFSZ 25      /* exceeded file size limit */
    #define SIGVTALRM 26    /* virtual time alarm */
    #define SIGPROF 27      /* profiling time alarm */
    #define SIGWINCH 28     /* window size changes */
    #define SIGINFO 29      /* information request */
    #define SIGUSR1 30      /* user defined signal 1 */
    #define SIGUSR2 31      /* user defined signal 2 */
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    这里把所有 Linux 0.11 支持的信号都放在这了,有我们熟悉的按下 CTRL+C 时的信号 SIGINT,有我们通常杀死进程时 kill -9 的信号 SIGKILL,还有 core dump 内存访问出错时经常遇到的 SIGSEGV。在现代 Linux 操作系统中,你输入个 kill -l 便可知道你所在的系统所支持的信号,下面是一台腾讯云主机上的结果:
    腾讯云主机

    OK,这么几句话,我就说完了信号的本质,以及信号的种类。

    现在这个进程的 tast_struct 结构中的 signal 就有了对应信号位的值,那么在下次时钟中断到来时,便会通过 timer_interrupt 这个时钟中断处理函数,一路调用到 do_signal 方法:

    // kernel/signal.c
    void do_signal (long signr ...) {
        ...
        struct sigaction *sa = current->sigaction + signr - 1;
        sa_handler = (unsigned long) sa->sa_handler;
        // 如果信号处理函数为空,则直接退出
        if (!sa_handler) {
            ...
            do_exit (1 << (signr - 1));
            ...
        }
        // 否则就跳转到信号处理函数的地方运行
        *(&eip) = sa_handler;
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    时钟中断和进程调度的流程,你可以看系列之八中 从一次定时器滴答来看进程调度,这里不再展开。

    我们可以看到,进入 do_signal 函数后,如果当前信号 signr 对应的信号处理函数 sa_handler 为空时,就直接调用 do_exit 函数退出,也就是我们看到的按下 CTRL+C 之后退出的样子了。

    但是,如果信号处理函数不为空,那么就通过将 sa_handler 赋值给 eip 寄存器,也就是指令寄存器的方式,跳转到相应信号处理函数处运行。怎么验证这一点呢?很简单,信号处理函数注册在每个进程 task_struct 中的 sigaction 数组中:

    // signal.h
    struct  sigaction {
        union __sigaction_u __sigaction_u;  /* signal handler */
        sigset_t sa_mask;               /* signal mask to apply */
        int     sa_flags;               /* see signal options below */
    };
    
    /* union for signal handlers */
    union __sigaction_u {
        void    (*__sa_handler)(int);
        void    (*__sa_sigaction)(int, struct __siginfo *,
            void *);
    };
    
    // sched.h
    struct task_struct {
        ...
        struct sigaction sigaction[32];
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    没错,只需要给 sigaction 对应位置处填写上信号处理函数即可。那么如何注册这个信号处理函数呢,通过调用 signal 这个库函数即可。我们可以写一个小程序:

    #include 
    #include 
    
    void int_handler(int signal_num) {
        printf("signal receive %d\n", signal_num);
    }
    
    int main(int argc, char ** argv) {
        signal(SIGINT, int_handler);
        for(;;)
            pause();
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    这是个死循环的 main 函数,只不过,通过 signal 注册了 SIGINT 的信号处理函数,里面做的事情仅仅是打印一下信号值。

    编译并运行它,我们会发现在按下 CTRL+C 之后程序不再退出,而是输出了我们 printf 的话。

    ch19-9

    我们多次按 CTRL+C,这个程序仍然不会退出,会一直输出上面的话:

    ch19-9a

    这就做到了亲手捕获 SIGINT 这个信号。但这个程序有点不友好,永远无法 CTRL+C 结束了,我们优化一下代码,让第一次按下 CTRL+C 后的信号处理函数,把 SIGINT 的处理函数重新置空:

    #include 
    #include 
    
    void int_handler(int signal_num) {
        printf("signal receive %d\n", signal_num);
        signal(SIGINT, NULL);
    }
    
    int main(int argc, char ** argv) {
        signal(SIGINT, int_handler);
        for(;;)
            pause();
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    我们发现,这次按下第二次 CTRL+C 程序就会退出了,这也间接证明了,当没有为 SIGINT 注册信号处理函数时,程序接收到 CTRL+CSIGINT 信号时便会退出。

    ch19-9b

    至此,有关信号的内容,就讲明白了。

    信号是进程间通信的一种方式,管道也是进程间通信的一种方式,所以通过系列之十六中 解析并执行 shell 命令 讲述的管道原理,与本回讲述的信号原理,你已经掌握了进程间通信的两种方式了。

    通过这种类似 “倒叙” 的讲述方法,希望你能明白,其实技术的本质并不复杂,只不过被抽象之后,由于你不了解下面的细节,就变得云里雾里了。

    系列完结~~

  • 相关阅读:
    CSS3之转换(2D转换,动画,3D转换)
    Shell 文本三剑客 (grep、sed、awk)
    Spring中@Controller 和 @RestController 的作用与区别
    Java泛型方法与普通成员方法以及案例说明(五)
    Alibaba(获得店铺的所有商品) API接口
    《树上差分》小题两则
    (三十三)geoserver源码&添加新的数据存储
    C++学习day3
    应用于伺服电机控制、 编码器仿真、 电动助力转向、发电机、 汽车运动检测与控制的旋变数字转换器MS5905P
    面向移动支付过程中网络安全的研究与分析
  • 原文地址:https://blog.csdn.net/wq_0708/article/details/126575840