首先我们知道,通过系列之十一 加载根文件系统 中文件系统的建设:
以及系列之十二 打开终端设备文件 讲解的打开一个文件的操作:
我们已经可以很方便地通过一个文件描述符 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);
}
太棒了!没剩多少了,一个个击破!
fd
,在进程表里拿到了 file
信息,进而拿到了 inode
信息。buf
区域的内存做校验。就这三步,很简单吧~在进程表 filp
中拿到 file
信息进而拿到 inode
信息这一步就不用多说了,这是在打开一个文件时,或者像管道文件一样创建出一个管道文件时,就封装好了 file
以及它的 inode
信息。
我们看接下来的两步。
对 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 的小格子。
我们假设要复制到的内存的起始地址 start 和要复制的字节数 size 在图中的那个位置。那么开始的两行计算代码:
// fork.c
void verify_area(void * addr,int size) {
...
size += start & 0xfff;
start &= 0xfffff000;
...
}
就是将 start 和 size 按页对齐一下:
然后,又由于每个进程有不同的数据段基址,所以还要加上它:
// fork.c
void verify_area(void * addr,int size) {
...
start += get_base(current->ldt[2]);
...
}
具体说来就是加上当前进程的局部描述符表 LDT
中的数据段的段基址:
每个进程的 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;
}
}
也就是这些页:
这些 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
函数了:
// 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
:
// 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
在记录文件所在块号时,采用了多级索引的方式:
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
返回的,就是要读入的块号,从全局看在块设备的哪个逻辑块号下。也就是说,假如我想要读这个文件的第一个块号的数据,该函数返回的是你这个文件的第一个块在整个硬盘中的哪个块中
拿到这个数据块号后,回到 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
就是具体的数据块号,作为其中其中一个参数,传入下一个函数 bread
。bread
这个方法的入参除了数据块号 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 结构快速查找这个 dev
和 block
是否有对应存在的缓冲块。
如果没有,那就从之前建立好的双向链表结构的头指针 free_list
开始寻找,直到找到一个可用的缓冲块
具体代码逻辑,还包含当缓冲块正在被其他进程使用,或者缓冲块对应的数据已经被修改时的处理逻辑,你可以看一看,关键流程我已加上了注释:
// 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++);
...
}
这个过程,仅仅是内存之间的复制,所以不必紧张
// 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 函数完美谢幕!
通过 verify_area
对内存做了校验,需要写时复制的地方在这里提前进行好了。
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 所讲的内容,就会明白这个方法。
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;
};
表示一个读盘的请求参数。
有了这些参数,底层方法拿到这个结构之后,就知道怎么样访问硬盘了。那是谁不断从这个 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
方法:
第一个唤醒了该请求项所对应的进程 &CURRENT->waiting
,告诉这个进程我这个请求项的读盘操作处理完了,你继续执行吧。
第二个是唤醒了因为 request
队列满了没有将请求项插进来的进程 &wait_for_request
。
随后,将当前设备的当前请求项 CURRENT
,即 request
数组里的一个请求项 request
的 dev
置空,并将当前请求项指向链表中的下一个请求项。
这样,do_hd_request
方法处理的就是下一个请求项的内容了,直到将所有请求项都处理完毕。
整个流程就这样形成了闭环,通过这样的机制,可以做到好似存在一个额外的进程,在不断处理 request 链表里的读写盘请求一样。
当设备的当前请求项为空时,也就是没有在执行的块设备请求项时,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 最终读取到硬盘数据的全部细节。
再配合上系列之十五和十六的内容,我们解释清楚了从键盘输入,到 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<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 */
这里把所有 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
#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;
}
这是个死循环的 main 函数,只不过,通过 signal
注册了 SIGINT
的信号处理函数,里面做的事情仅仅是打印一下信号值。
编译并运行它,我们会发现在按下 CTRL+C
之后程序不再退出,而是输出了我们 printf
的话。
我们多次按 CTRL+C
,这个程序仍然不会退出,会一直输出上面的话:
这就做到了亲手捕获 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;
}
我们发现,这次按下第二次 CTRL+C
程序就会退出了,这也间接证明了,当没有为 SIGINT
注册信号处理函数时,程序接收到 CTRL+C
的 SIGINT
信号时便会退出。
至此,有关信号的内容,就讲明白了。
信号是进程间通信的一种方式,管道也是进程间通信的一种方式,所以通过系列之十六中 解析并执行 shell 命令 讲述的管道原理,与本回讲述的信号原理,你已经掌握了进程间通信的两种方式了。
通过这种类似 “倒叙” 的讲述方法,希望你能明白,其实技术的本质并不复杂,只不过被抽象之后,由于你不了解下面的细节,就变得云里雾里了。