打开终端设备文件
上篇说到setup一系列折腾就是为了open函数打开/dev/tty0
,还有后面的两个 dup
。open 函数会触发 0x80 中断,最终调用到 sys_open
这个系统调用函数,相信你已经很熟悉了。
open.c:
struct file file_table[64] = {0};
int sys_open(const char * filename,int flag,int mode) {
struct m_inode * inode;
struct file * f;
int i,fd;
mode &= 0777 & ~current->umask;
for(fd=0 ; fd<20; fd++)
if (!current->filp[fd])
break;
if (fd>=20)
return -EINVAL;
current->close_on_exec &= ~(1<<fd);
f=0+file_table;
for (i=0 ; i<64 ; i++,f++)
if (!f->f_count) break;
if (i>=64)
return -EINVAL;
(current->filp[fd]=f)->f_count++;
i = open_namei(filename,flag,mode,&inode);
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;
}
if (S_ISBLK(inode->i_mode))
check_disk_change(inode->i_zone[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);
}
先用一张图来描述这一大坨代码的作用:
第一步,在进程文件描述符数组 filp 中找到一个空闲项。还记得进程的 task_struct 结构吧,其中有一个 filp 数组的字段,就是我们常说的文件描述符数组,这里先找到一个空闲项,将空闲地方的索引值即为 fd。
int sys_open(const char * filename,int flag,int mode) {
...
for(int fd=0 ; fd<20; fd++)
if (!current->filp[fd])
break;
if (fd>=20)
return -EINVAL;
...
}
由于此时当前进程,也就是进程 1,还没有打开过任何文件,所以 0 号索引处就是空闲的,fd 自然就等于 0。
第二步,在系统文件表 file_table 中找到一个空闲项。
int sys_open(const char * filename,int flag,int mode) {
int i;
...
int f=0+file_table;
for (i=0 ; i<64; i++,f++)
if (!f->f_count) break;
if (i>=64)
return -EINVAL;
...
}
注意到,进程的 filp 数组大小是 20,系统的 file_table 大小是 64,可以得出,每个进程最多打开 20 个文件,整个系统最多打开 64 个文件。
第三步,将进程的文件描述符数组项和系统的文件表项,对应起来。代码中就是一个赋值操作。
int sys_open(const char * filename,int flag,int mode) {
...
current->filp[fd] = f;
...
}
第四步,根据文件名从文件系统中找到这个文件。其实相当于找到了这个 tty0 文件对应的 inode 信息。
int sys_open(const char * filename,int flag,int mode) {
...
// filename = "/dev/tty0"
// flag = O_RDWR 读写
// 不是创建新文件,所以 mode 没用
// inode 是返回参数
open_namei(filename,flag,mode,&inode);
...
}
接下来判断 tty0 这个 inode 是否是字符设备,如果是字符设备文件,那么如果设备号是 4 的话,则设置当前进程的 tty 号为该 inode 的子设备号。并设置当前进程tty 对应的tty 表项的父进程组号等于进程的父进程组号。这里我们暂不展开讲。
最后第五步,填充 file 数据。其实就是初始化这个 f,包括刚刚找到的 inode 值。最后返回给上层文件描述符 fd 的值,也就是零。
int sys_open(const char * filename,int flag,int mode) {
...
f->f_mode = inode->i_mode;
f->f_flags = flag;
f->f_count = 1;
f->f_inode = inode;
f->f_pos = 0;
return (fd);
...
}
最后再回过头看这张图,是不是就有感觉了?
其实打开一个文件,即刚刚的 open 函数,就是在上述操作后,返回一个 int 型的数值 fd,称作文件描述符。之后我们就可以对着这个文件描述符进行读写。
之所以可以这么方便,是由于通过这个文件描述符,最终能够找到其对应文件的 inode 信息,有了这个信息,就能够找到它在磁盘文件中的位置(当然文件还分为常规文件、目录文件、字符设备文件、块设备文件、FIFO 特殊文件等,这个之后再说),进行读写。
比如读函数的系统调用入口:
int sys_read (unsigned int fd, char *buf, int count) {
...
}
写函数的系统调用入口:
int sys_write (unsigned int fd, char *buf, int count) {
...
}
入参都有个 int 型的文件描述符 fd,就是刚刚 open 时返回的,就这么简单。
好,我们回过头看:
void init(void) {
setup((void *) &drive_info);
(void) open("/dev/tty0",O_RDWR,0);
(void) dup(0);
(void) dup(0);
}
本节利用之前 setup 加载过的根文件系统,通过 open 函数,根据文件名找到并打开了一个文件。返回给上层的是一个文件描述符,然后操作系统底层进行了一系列精巧的构造,使得一个进程可以通过一个文件描述符 fd,找到对应文件的 inode 信息。
好了,我们接着再往下看两行代码。接下来,两个一模一样的 dup 函数,什么意思呢?
其实,刚刚的 open 函数返回的为 0 号 fd,这个作为标准输入设备。
接下来的 dup 为 1 号 fd 赋值,这个作为标准输出设备。
再接下来的 dup 为 2 号 fd 赋值,这个作为标准错误输出设备。
熟不熟悉?这就是我们 Linux 中常说的 stdin、stdout、stderr。
那这个 dup 又是什么原理呢?非常简单,首先仍然是通过系统调用方式,调用到 sys_dup 函数。
int sys_dup(unsigned int fildes) {
return dupfd(fildes,0);
}
// fd 是要复制的文件描述符
// arg 是指定新文件描述符的最小数值
static int dupfd(unsigned int fd, unsigned int arg) {
...
while (arg < 20)
if (current->filp[arg])
arg++;
else
break;
...
(current->filp[arg] = current->filp[fd])->f_count++;
return arg;
}
仍然是把一些错误校验的旁路逻辑去掉了。那这个函数的逻辑非常单纯,就是从进程的 filp 中找到下一个空闲项,然后把要复制的文件描述符 fd 的信息,统统复制到这里。
那根据上下文,这一步其实就是把 0 号文件描述符,复制到 1 号文件描述符,那么 0 号和 1 号文件描述符,就统统可以通过一条路子,找到最终 tty0 这个设备文件的 inode 信息了。
那下一个 dup 就自然理解了吧,直接再来一张图:
ok,进程 1 的 init 函数的前四行就讲完了,此时进程 1 已经比进程 0 多了与 外设交互的能力,具体说来是 tty0 这个外设(也是个文件,因为 Linux 下一切皆文件)交互的能力,这句话怎么理解呢?什么叫多了这个能力?
因为进程 fork 出自己子进程的时候,这个 filp 数组也会被复制,那么当进程 1 fork 出进程 2 时,进程 2 也会拥有这样的映射关系,也可以操作 tty0 这个设备,这就是“能力”二字的体现。
而进程 0 是不具备与外设交互的能力的,因为它并没有打开任何的文件,filp 数组也就没有任何作用。进程 1 刚刚创建的时候,是 fork 的进程 0,所以也不具备这样的能力,而通过 setup 加载根文件系统,open 打开 tty0 设备文件等代码,使得进程 1 具备了与外设交互的能力,同时也使得之后从进程 1 fork 出来的进程 2 也天生拥有和进程 1 同样的与外设交互的能力。
再往后看两行找找感觉:
void init(void) {
setup((void *) &drive_info);
(void) open("/dev/tty0",O_RDWR,0);
(void) dup(0);
(void) dup(0);
printf("%d buffers = %d bytes buffer space\n\r",NR_BUFFERS, \
NR_BUFFERS*BLOCK_SIZE);
printf("Free mem: %d bytes\n\r",memory_end-main_memory_start);
}
接下来的两行是个打印语句,其实就是基于刚刚打开并创建的 0,1,2 三个文件描述符而做出的操作。
刚刚也说了 1 号文件描述符被当做标准输出,那我们进入 printf 的实现看看有没有用到它:
static int printf(const char *fmt, ...) {
va_list args;
int i;
va_start(args, fmt);
write(1,printbuf,i=vsprintf(printbuf, fmt, args));
va_end(args);
return i;
}
看,中间有个 write 函数,传入了 1 号文件描述符作为第一个参数。
细节我们先不展开,这里知道它肯定是顺着这个描述符寻找到了相应的 tty0 也就是终端控制台设备,并输出在了屏幕上。我们赶紧看看实际上有没有输出。仍然是 bochs 启动 Linux 0.11 看效果:
看到了吧,真的输出了,你偷偷改下这里的源码,再看看这里的输出有没有变化吧!
本节之后,init 函数后面又要 fork 子进程了,也标志着进程 1 的工作基本结束了,准确说是能力建设的工作结束了,接下来就是控制流程和创建新的进程了。
进程2的创建
void init(void) {
...
if (!(pid=fork())) {
close(0);
open("/etc/rc",O_RDONLY,0);
execve("/bin/sh",argv_rc,envp_rc);
_exit(2);
}
...
}
我们先尝试口述翻译一遍。
-
fork 一个新的子进程,此时就是进程 2 了。
-
在进程 2 里关闭(close) 0 号文件描述符。
-
只读形式打开(open) rc 文件。
-
然后执行(execve) sh 程序。
听起来还蛮合逻辑的,创建进程(fork)、关闭(close)、打开(open)、执行(execve)四步走,接下来我们一点点拆解。
fork
前面系列之九中fork中进程基本信息的复制讲过了,fork就是将进程的task_struct
结构进行复制,比如进程0 fork出进程1的时候:
之后,新进程再重写一些基本信息,包括元信息和 tss 里的寄存器信息。再之后,用 copy_page_tables
复制了一下页表(这里涉及到写时复制的伏笔),比如进程 0 复制出进程 1 的时候,页表是这样复制的:
而这里的进程 1 fork 出进程 2,也是同样的流程,不同之处在于两点细节:
-
进程 1 打开了三个文件描述符并指向了 tty0,那这个也被复制到进程 2 了,具体说来就是进程结构 task_struct 里的 flip[] 数组被复制了一份:
struct task_struct { ... struct file *filp[NR_OPEN]; ... };
而进程 0 fork 出进程 1 时是没有复制这部分信息的,因为进程 0 没有打开任何文件。这也是刚刚说的与外设交互能力的体现,即进程 0 没有与外设交互的能力,进程 1 有,哎,其实就是这个 flip 数组里有没有东西而已嘛~
-
进程 0 复制进程 1 时页表的复制只有 160 项,也就是映射 640K,而之后进程的复制,统统都是复制 1024 项,也就是映射 4M 空间:
int copy_page_tables(unsigned long from,unsigned long to,long size) { ... nr = (from==0)?0xA0:1024; ... }
整体看就是如图所示:
除此之外,就没有别的区别了。
close
fork 完之后,后面 if 里面的代码都是进程 2 在执行了。
close(0) 就是关闭 0 号文件描述符,也就是进程 1 复制过来的打开了 tty0 并作为标准输入的文件描述符,那么此时 0 号文件描述符就空出来了。下面是 close 对应的系统调用函数:
int sys_close(unsigned int fd) {
...
current->filp[fd] = NULL;
...
}
open
open 函数以只读形式打开了一个叫 /etc/rc 的文件,刚好占据了 0 号文件描述符的位置:
void init(void) {
...
if (!(pid=fork())) {
...
open("/etc/rc",O_RDONLY,0);
...
}
...
}
这个 rc 文件表示配置文件,具体什么内容,取决于你的硬盘里这个位置放了什么内容,与操作系统内核无关,所以我们暂且不用管。此时,进程 2 与进程 1 几乎完全一样,只不过进程 2 通过 close 和 open 操作,将原来进程 1 的指向标准输入的 0 号文件描述符,重新指向了 /etc/rc 文件。
到目前为止,进程 2 与进程 1 的区别,仅仅是将 0 号文件描述符重新指向了 /etc/rc 文件,其他的没啥区别。而这个 rc 文件是干嘛的,现在还不用管,肯定是后面 sh 程序要用到的,到时候在说。
execve
接下来进程 2 就将变得不一样了,会通过一个经典的,也是最难理解的 execve 函数调用,使自己摇身一变,成为 /bin/sh 程序继续运行,这就是下一章的重点。
void init(void) {
...
if (!(pid=fork())) {
...
execve("/bin/sh",argv_rc,envp_rc);
...
}
...
}
这里就包含着操作系统究竟是如何加载并执行一个程序的原理,包括如何从文件系统中找到这个文件,如何解析一个可执行文件(在现代的 Linux 里称作 ELF 可执行文件),如何讲可执行文件中的代码和数据加载到内存并运行。
加载到内存并运行又包含着虚拟内存等相关的知识。所以这里面的水很深,了解了这个函数,再加上 fork 函数,基本就可以把操作系统全部核心逻辑都串起来了。