你管这叫操作系统源码(完结)

读硬盘数据全流程

如果让你来设计这个函数

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

ch17-4

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

ch17-5

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

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

那这个函数就可以设计为

int sys_read(unsigned int fd,char * buf,int count) {
    ...
}

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

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

// read_write.c
int sys_read(unsigned int fd,char * buf,int count) {
    struct file * file;
    struct m_inode * inode;

    if (fd>=NR_OPEN || count<0 || !(file=current->filp[fd]))
        return -EINVAL;
    if (!count)
        return 0;
    verify_area(buf,count);
    inode = file->f_inode;
    if (inode->i_pipe)
        return (file->f_mode&1)?read_pipe(inode,buf,count):-EIO;
    if (S_ISCHR(inode->i_mode))
        return rw_char(READ,inode->i_zone[0],buf,count,&file->f_pos);
    if (S_ISBLK(inode->i_mode))
        return block_read(inode->i_zone[0],&file->f_pos,buf,count);
    if (S_ISDIR(inode->i_mode) || S_ISREG(inode->i_mode)) {
        if (count+file->f_pos > inode->i_size)
            count = inode->i_size - file->f_pos;
        if (count<=0)
            return 0;
        return file_read(inode,file,buf,count);
    }
    printk("(Read)inode->i_mode=%06o\n\r",inode->i_mode);
    return -EINVAL;
}

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

// read_write.c
int sys_read(unsigned int fd,char * buf,int count) {
    struct file * file = current->filp[fd];
    // 校验 buf 区域的内存限制
    verify_area(buf,count);
    struct m_inode * inode = file->f_inode;
    // 管道文件
    if (inode->i_pipe)
        return (file->f_mode&1)?read_pipe(inode,buf,count):-EIO;
    // 字符设备文件
    if (S_ISCHR(inode->i_mode))
        return rw_char(READ,inode->i_zone[0],buf,count,&file->f_pos);
    // 块设备文件
    if (S_ISBLK(inode->i_mode))
        return block_read(inode->i_zone[0],&file->f_pos,buf,count);
    // 目录文件或普通文件
    if (S_ISDIR(inode->i_mode) || S_ISREG(inode->i_mode)) {
        if (count+file->f_pos > inode->i_size)
            count = inode->i_size - file->f_pos;
        if (count<=0)
            return 0;
        return file_read(inode,file,buf,count);
    }
    // 不是以上几种,就报错
    printk("(Read)inode->i_mode=%06o\n\r",inode->i_mode);
    return -EINVAL;
}

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

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

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

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

  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;
    }
}

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;
    ...
}

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

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

具体说来就是加上当前进程的局部描述符表 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;
    }
}

也就是这些页:
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;
}

看,那个 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;
}

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

// file_dev.c
int file_read(struct m_inode * inode, struct file * filp, char * buf, int count) {
    ...
    int nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE);
    struct buffer_head *bh=bread(inode->i_dev,nr);
    ...
    char * p = nr + bh->b_data;
    while (chars-->0)
         put_fs_byte(*(p++),buf++);
    ...
}

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

我们一个个看

bmap获取全局数据块

先看第一个函数调用,bmap

// file_dev.c
int file_read(struct m_inode * inode, struct file * filp, char * buf, int count) {
    ...
    int nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE);
    ...}

// inode.c 
int bmap(struct m_inode * inode,int block) {
    return _bmap(inode,block,0);
}

static int _bmap(struct m_inode * inode,int block,int create) {
    ...
    if (block<0)
        ...
    if (block >= 7+512+512*512)
        ...
    if (block<7) 
        // zone[0] 到 zone[7] 采用直接索引,可以索引小于 7 的块号
        ...
    if (block<512)
        // zone[7] 是一次间接索引,可以索引小于 512 的块号
        ...
    // zone[8] 是二次间接索引,可以索引大于 512 的块号
}

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

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];
    }
    ...
}

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

// inode.c
static int _bmap(struct m_inode * inode,int block,int create) {
    ...
    if (block<7) {
        ...
        return inode->i_zone[block];
    }
    ...
}

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

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

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

// file_dev.c
int file_read(struct m_inode * inode, struct file * filp, char * buf, int count) {
    ...
    while (left) {
        if (nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE)) {
            if (!(bh=bread(inode->i_dev,nr)))
    }
}

nr 就是具体的数据块号,作为其中其中一个参数,传入下一个函数 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;
}

这个 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;
}

总之,经过 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++);
    ...
}

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

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

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

有点难以理解,改成较为好看的样子。(参考赵炯《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;
}

其实就是三个汇编指令的 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();
}

调用链很长,主线是从 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;
};

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

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)();
        ...
    }
    ...
}

就是当设备的当前请求项为空,也就是第一次收到硬盘操作请求时,会立即执行该设备的 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;
    ...
}

所以,刚刚的 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");
}

我去掉了一大坨根据起始扇区号计算对应硬盘的磁头 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);
}

可以看到,最底层的读盘请求,其实就是向一堆外设端口做读写操作。这个函数实际上在系列之五中 时间初始化 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);
    ...
}

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

// system_call.s
_hd_interrupt:
    ...
    xchgl _do_hd,%edx
    ...
    call *%edx
    ...
    iret

这个方法主要是调用 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;
    ...
}

看,一切都有千丝万缕的联系,是不是很精妙。我们展开 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();
}

这里使用了 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;
}

两个 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;
    }
    ...
}

这个函数里有一段上述代码,翻译起来特别简单,就是当 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;
        }
    }
}

而如何发送信号,在这段源码中也揭秘了,其实就是给进程 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&LTOSTOP) */
#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 */

这里把所有 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;
    ...
}

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

我们可以看到,进入 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];
    ...
}

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

#include <stdio.h>
#include <signal.h>

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;
}

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

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

ch19-9

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

ch19-9a

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

#include <stdio.h>
#include <signal.h>

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;
}

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

ch19-9b

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

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

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

系列完结~~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值