Linux源码解读-启动过程(三)

121 阅读30分钟

前言

本文是本人操作系统的学习笔记

上一篇文章记录了main函数中的各种初始化操作,各种准备工作做完后,接下来的流程就是要把前面初始化好的各种工具串联起来,成为一个完整版的操作系统,在看这些代码之前,我们先打打基础,看看三架马车中的文件系统是长什么样子的

文件系统

linux0.12中的文件系统是MINIX文件系统,有6个部分组成,分别是引导块、超级块、i节点位图、逻辑块位图、i节点、数据块 image.png 图中,整个块设备被划分成以1KB为单位的磁盘块,因此对于一个360KB的磁盘,上图中共有360个磁盘块,每个方块表示一个磁盘块

引导块是计算机加电启动时可由ROM BIOS自动读入的执行代码和数据盘块。也就是之前笔记中写的操作系统启动区。但一个系统中并非所有硬盘都有启动区,但我们还是得预留出这个位置,以保持格式的统一

超级块用于存放盘设备上文件系统的结构信息,并说明各部分的大小

i节点用于存放盘设备上每个文件和目录名的索引信息。i节点位图用于说明i节点是否被使用,同样是每个比特位代表一个i节点。对于1K大小的盘块来讲,一个盘块就可表示8192(1024*8(一个字节等于8位))个i节点的使用状况,逻辑块位图也是同理

文件中的数据存放在磁盘块的数据块中,而一个文件名则通过对应的i节点与这些数据磁盘块相联系,这些盘块的号码就存放在i节点的逻辑块数组i_zone[]中。其中,i_zone[]数组用于存放i节点对应文件的盘块号。i_zone[0]i_zone[6]用于存放文件开始的7个磁盘块号,称为直接块。若文件长度小于等于7K字节,则根据其i节点可以很快就找到它所使用的盘块。若文件大一些,就需要用到一次间接块了i_zone[7],这个盘块中存放着附加的盘块号,一个盘块可以存放512个盘块号(1个盘块1K,每个盘块号需要2个字节的存储空间),因此可以寻址512个盘块。若文件还要大,则需要使用二次间接盘块i_zone[8],二次间接块的作用类似一次间接块,因此使用二次间接块可以寻址512*512个盘块

接下来看看超级块的数据结构是什么样子

struct super_block {
  // inode数
  unsigned short s_ninodes;
  // 逻辑块数
  unsigned short s_nzones;
  // i节点位图所占用的数据块数
  unsigned short s_imap_blocks;
  // 逻辑块位图所占用的数据块数
  unsigned short s_zmap_blocks;
  // 第一个数据逻辑块号
  unsigned short s_firstdatazone;
  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];
  struct buffer_head * s_zmap[8];
  unsigned short s_dev;
  struct m_inode * s_isup;
  struct m_inode * s_imount;
  unsigned long s_time;
  // 等待该超级块的进程
  struct task_struct * s_wait;
  // 被锁定标志
  unsigned char s_lock;
  // 只读标志
  unsigned char s_rd_only;
  // 已修改(脏)标志
  unsigned char s_dirt;
};

再看看i节点的数据结构

struct m_inode {
  // 文件类型和属性(rwx位)
  unsigned short i_mode;
  // 用户id
  unsigned short i_uid;
  // 文件大小
  unsigned long i_size;
  // 修改时间
  unsigned long i_mtime;
  unsigned char i_gid;
  // 文件目录项链接数
  unsigned char i_nlinks;
  // 直接(0-6)、间接(7)或双重间接(8)逻辑块号
  unsigned short i_zone[9];
/* these are in memory also */
  struct task_struct * i_wait;
  struct task_struct * i_wait2; /* for pipes */
  unsigned long i_atime;
  unsigned long i_ctime;
  unsigned short i_dev;
  unsigned short i_num;
  // i节点被使用的次数,0表示该i节点空闲
  unsigned short i_count;
  unsigned char i_lock;
  unsigned char i_dirt;
  unsigned char i_pipe;
  unsigned char i_mount;
  unsigned char i_seek;
  unsigned char i_update;
};

那文件是怎么跟进程关联在一起的,全靠下面这个数据结构

struct file {
  unsigned short f_mode; // 文件操作模式(RW位)
  unsigned short f_flags; // 文件打开和控制的标志
  unsigned short f_count; // 对应文件句柄引用计数
  struct m_inode * f_inode; // 指向对应内存i节点
  off_t f_pos;
};
​
struct file file_table[NR_FILE]; // 文件表数组,共64

在进程的数据结构中,定义了本进程打开文件的文件结构指针数组filp[NP_OPEN]字段。其中NR_OPEN=20,表示一个进程可以打开的文件,既可以打开多个不同的文件,也可以同一个文件多次打开,每打开一次文件(不论是否是同一个文件),就要在filp[20]中占用一个项(比如hello.txt文件被一个用户进程打开两次,就要在filp[20]中占用两项)记录指针,所以,一个进程可以同时打开的文件次数不能超过20次。操作系统中file_table[64]是管理所有进程打开文件的数据结构,不但记录了不同的进程打开不同的文件,也记录了不同的进程打开同一个文件,甚至记录了同一个进程多次打开同一个文件。与filp[20]类似,只要打开一次文件,就要在file_table[64]中记录。这行代码的意思是清空所有文件的引用次数

读取文件

读文件就是从用户进程打开的文件中读取数据,read()函数最终映射到sys_read()系统调用函数去执行。在执行主体内容之前,先对此次操作的可行性进行检查,包括用户进程传递的文件句柄、读取字节数是否在合理范围内,用户进程数据所在的页面能否被写入数据,等等。在这些检查都通过后,开始执行主体内容,即调用file_read()函数,读取进程指定的文件数据

// fd是文件句柄,buf是缓冲区,count是欲读字节数
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;
  // 校验buf区域的内存限制
  verify_area(buf,count);
  inode = file->f_inode;
  // 根据i节点的属性,分别调用相应的读操作函数
  // 管道文件
  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;
}
void verify_area(void * addr,int size)
{
  unsigned long start;
  // 将起始地址start调整为其所在页面开始位置,同时调整复制区域size
  start = (unsigned long) addr;
  size += start & 0xfff;
  start &= 0xfffff000;
  // 加上进程数据段基址,就把start变成CPU整个线性地址空间中的地址
  start += get_base(current->ldt[2]);
  // 进行页面验证,如果页面存在但不可写,则执行页面复制
  while (size>0) {
    size -= 4096;
    write_verify(start);
    start += 4096;
  }
}

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

内存是以4K为一页来划分的,如果参数addr不是4K的整数倍,代码需要对其进行调整,按页对齐

image.png 由于每个进程有不同的数据段基址,所以还需要加上当前进程局部描述表LDT中的数据段的段基址

image.png

int file_read(struct m_inode * inode, struct file * filp, char * buf, int count)
{
  int left,chars,nr;
  struct buffer_head * bh;
​
  if ((left=count)<=0)
    return 0;
  while (left) {
    // 每次循环,最多将一个缓冲块的数据复制到buf空间内
    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;
      // 将chars字节的数据复制到用户指定空间内
      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;
}
打开文件

打开文件的本质就是要建立filp[20]file_table[64]inode_table[32]三者之间的关系

这个过程分为三个步骤进行

  1. 将用户进程task_struct中的*filp[20]与内核中的file_table[64]进行挂接

  2. 以用户给定的路径名/mnt/user/user1/user2/hello.txt为线索,找到hello.txt文件的i节点

  3. hello.txt对应的i节点在file_table[64]中进行登记

    具体的操作是在进程中调用open()函数实现打开文件,该函数最终映射到sys_open()系统调用函数执行

#define NR_OPEN 20 // 进程可以打开文件的最大数
#define NR_FILE 64 // 操作系统可以打开文件的最大数
​
int sys_open(const char * filename,int flag,int mode)
{
  struct m_inode * inode;
  struct file * f;
  int i,fd;
​
  mode &= 0777 & ~current->umask;
  // 从当前进程*filp[20]中寻找空闲项
  for(fd=0 ; fd<NR_OPEN ; fd++)
    if (!current->filp[fd])
      break;
  // 检查*filp[20]结构是否已经超出使用极限
  if (fd>=NR_OPEN)
    return -EINVAL;
  current->close_on_exec &= ~(1<<fd);
  f=0+file_table;
  // 在file_table[64]中寻找空闲项
  for (i=0 ; i<NR_FILE ; i++,f++)
    if (!f->f_count) break;
  // 检查在file_table[64]结构是否已经超出使用极限
  if (i>=NR_FILE)
    return -EINVAL;
  // 将进程的文件描述符数组项和系统的文件表项对应起来
  (current->filp[fd]=f)->f_count++;
  // 获取文件i节点
  if ((i=open_namei(filename,flag,mode,&inode))<0) {
    // 如果没有获取到i节点,将*filp[20]申请到的表项置为NULL
    current->filp[fd]=NULL;
    // 如果没有获取到i节点,将file_table[64]申请到的表项引用计数置0
    f->f_count=0;
    return i;
  }
/* ttys are somewhat special (ttyxx major==4, tty major==5) */
  if (S_ISCHR(inode->i_mode))
    if (check_char_dev(inode,inode->i_zone[0],flag)) {
      iput(inode);
      current->filp[fd]=NULL;
      f->f_count=0;
      return -EAGAIN;
    }
/* Likewise with block-devices: check for floppy_change */
  if (S_ISBLK(inode->i_mode))
    check_disk_change(inode->i_zone[0]);
  // 填充file数据
  f->f_mode = inode->i_mode;
  f->f_flags = flag;
  f->f_count = 1;
  f->f_inode = inode;
  f->f_pos = 0;
  return (fd);
}

image.png

通过open函数,会得到一个int型的数值fd,称作文件描述符,通过这个文件描述符,我们可以找到对应文件的inode信息,有了这个信息,就能够找到文件在磁盘中的位置,进行读写

刚刚返回的open函数为0号fd,这个作为标准输入设备

main函数

讲完文件系统后,我们承接上文,各种初始化完成后,就走到了下面这几行代码

if (!fork()) {    /* we count on this going ok */
  init();
}

点进去init()函数看看,看上去非常复杂,我们一段一段看

void init(void)
{
  int pid,i;
​
  setup((void *) &drive_info);
  // 下面以读写访问方式打开设备 /dev/tty1,它对应中断控制台。由于这是第一次打开文件操作,因此产生
  // 的文件句柄号(文件描述符)肯定是0。该句柄是UNIX类操作系统默认的控制台标准输入句柄stdin(0)
  // 这里再把它以读和写的方式分别打开是为了复制产生标准输出句柄stdin(1)和标准出错输出句柄stdin(2)
  // 函数前面的(void)前缀用于强制函数无需返回值
  (void) open("/dev/tty1",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);
  // 下面再创建一个子进程(进程2),并在该子进程中运行/etc/rc文件中的命令。对于被创建的子进程,fork()将返回0值
  // 对于原进程(父进程)则返回子进程的进程号pid。这个if是子进程中执行的代码,该子进程的代码首先把标准输入stdin
  // 重定向到/etc/rc文件,然后使用execve()函数运行/bin/sh程序。该程序从标准输入中读取rc文件中的命令,并以解释
  // 方式执行。sh运行时所携带的参数和环境变量分别由argv_rc和envp_rc数组给出
  if (!(pid=fork())) {
    close(0);
    if (open("/etc/rc",O_RDONLY,0))
      _exit(1);
    execve("/bin/sh",argv_rc,envp_rc);
    _exit(2);
  }
  // 下面是父进程(进程1)执行的语句。wait()等待子进程停止或终止,返回值应是子进程的进程号(pid)
  // 这三句的作用是父进程等待子进程的结束。&i是存放返回状态信息的位置。如果wait()返回值不等于子进程号
  // 则继续等待
  if (pid>0)
    while (pid != wait(&i))
      /* nothing */;
  // 如果执行到这里,说明刚创建的子进程已执行完/etc/rc文件(或文件不存在),因此该子进程自动停止或终止
  // 下面循环中会再次创建一个子进程,用于运行登录和控制台shell程序
  // 该新建子进程首先将关闭所有以前还遗留的句柄(stdin、stdout、stderr),新创建一个会话,然后重新打开
  // /dev/tty0作为stdin,并复制生成stdout和stderr。然后再次执行/bin/sh程序
  // 此后父进程再次运行wait()等待,如果子进程又停止运行(例如用户执行了exit命令),则在标准输出上显示出错信息
  // 子进程pid停止运行,返回码是i,然后继续重试下去,形成大死循环
  while (1) {
    if ((pid=fork())<0) {
      printf("Fork failed in init\r\n");
      continue;
    }
    if (!pid) {
      close(0);close(1);close(2);
      setsid();
      (void) open("/dev/tty1",O_RDWR,0);
      (void) dup(0);
      (void) dup(0);
      _exit(execve("/bin/sh",argv,envp));
    }
    while (1)
      if (pid == wait(&i))
        break;
    printf("\n\rchild %d died with code %04x\n\r",pid,i);
    sync();
  }
  _exit(0); /* NOTE! _exit, not exit() */
}

fork()函数

fork是一个系统调用,流程比较长

static inline _syscall0(int,fork)
#define _syscall0(type,name) \
type name(void) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
  : "=a" (__res) \
  : "0" (__NR_##name)); \
if (__res >= 0) \
  return (type) __res; \
errno = -__res; \
return -1; \
}

这段代码的意思是

执行:"0" (__NR_fork)这一行,意思是将fork在sys_call_table[]中对应的函数编号_NR_fork(也就是2)赋值给eax。这个编号即sys_fork()函数在sys_call_table中的偏移值

紧接着就执行int$0x80,产生一个软中断,CPU从3特权级的进程0代码跳到0特权级内核代码中执行。中断使CPU硬件自动将SS、ESP、EFLAGS、CS、EIP这5个寄存器的数值按照这个顺序压入init_task中的进程0内核栈。

值得注意,压栈的EIP指向当前指令int$0x80的下一行,即if(_res>=0)这一行。这一行就是进程0从fork函数系统调用中断返回后第一条指令的位置

那我们接着看sys_call_table是啥

fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
sys_setreuid,sys_setregid, sys_sigsuspend, sys_sigpending, sys_sethostname,
sys_setrlimit, sys_getrlimit, sys_getrusage, sys_gettimeofday, 
sys_settimeofday, sys_getgroups, sys_setgroups, sys_select, sys_symlink,
sys_lstat, sys_readlink, sys_uselib };

sys_call_table类似系统调用函数表,这个表记录了系统调用对应的执行函数,从第0项开始数,第二项就是sys_fork函数,至此,我们终于找到了fork函数,它在内核的函数就是sys_fork

_sys_fork:
  call _find_empty_process
  testl %eax,%eax
  js 1f
  push %gs
  pushl %esi
  pushl %edi
  pushl %ebp
  pushl %eax
  call _copy_process
  addl $20,%esp
1:  ret

image.png

在创建新进程的过程中,系统首先在任务数组中找出一个还没有被任何进程使用的空项(空槽)。如果系统已经有64个进程在运行,则fork()系统调用会因为数组表中没有可用项而出错返回。否则系统就会为新建进程在主内存区中申请一页内存来存放其任务数据结构信息,并复制当前进程任务数据结构中的所有内容作为新进程任务数据结构的模版。为了防止这个还未处理完成的新建进程被调度函数执行,此时应该立刻将新进程任务置为不可中断的等待状态

随后对复制的任务数据结构进行修改。把当前进程设置为新进程的父进程,清除信号位图并复位新进程各统计值,并设置初始运行时间片值为15个系统滴答数(150毫秒)。接着根据当前进程设置任务状态段(TSS)中各寄存器的值。由于创建进程时新进程返回值应为0,所以需要设置tss.eax = 0。新建进程内核态堆栈指针tss.esp0被设置成新进程任务数据结构所在内存页面的顶端,而堆栈段tss.ss0被设置成内核数据段选择符。tss.ldt被设置为局部表描述符在GDT中的索引值

// 使用全局变量last_pid来存放系统自开机以来累计的进程数,也将此变量用作新建进程的进程号
long last_pid=0;
// 为新创建的进程找到一个空闲的位置,NR_TASKS是64
int find_empty_process(void)
{
  int i;
  repeat:
    // 如果last_pid增1后超出进程号的正数表示范围,则重新从1开始使用pid号
    // 从进程数组中搜索刚设置的pid号是否已经被任何进程使用。如果是则跳转到函数开始处重新获得一个pid号
    if ((++last_pid)<0) last_pid=1;
    for(i=0 ; i<NR_TASKS ; i++)
      if (task[i] && ((task[i]->pid == last_pid) ||
                (task[i]->pgrp == last_pid)))
        goto repeat;
  // 进程0项已被占用,忽略
  // 接着在进程数组中为新任务寻找一个空闲下标,并返回下标。如果此时进程数组中64个下标已经被全部占用,则返回错误码
  for(i=1 ; i<NR_TASKS ; i++)
    if (!task[i])
      return i;
  return -EAGAIN;
}

最终这个方法返回task[]数组的索引,之后在这里新建一个新的进程。由于现在只有0号进程,所以task[]数组除了0号索引位置,其它地方都是空的,所以这个方法运行完,last_pid就是1,也就是新进程被分配的pid就是1

int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
    long ebx,long ecx,long edx, long orig_eax, 
    long fs,long es,long ds,
    long eip,long cs,long eflags,long esp,long ss)
{
  struct task_struct *p;
  int i;
  struct file *f;
  // 首先为新进程数据结构分配内存(如果分配出错,则返回错误码并退出)。然后将新进程结构指针放入进程
  // 数组的nr项中。其中nr为进程数组的下标,由前面的find_empty_process()返回,接着把当前进程任务
  // 结构内容复制到刚申请的内存页面p开始处
  p = (struct task_struct *) get_free_page();
  if (!p)
    return -EAGAIN;
  task[nr] = p;
  // current是指向当前进程的task_struct的指针,当前进程是进程0
  // 所以这行代码是将父进程的task_struct赋给子进程,父子进程的task_struct将完成一样
  *p = *current;  /* NOTE! this doesn't copy the supervisor stack */
  // 随后对复制来的进程数据结构内容进行一些修改,作为新进程的任务结构。先将新进程的状态置为不可中断等待状态
  // 以防止内核调度其执行。然后设置新进程的进程号pid,并初始话进程运行时间片等于其priority值(一般为15个嘀嗒)
  // 接着复位新进程的信号位图、报警定时值、session领导标识leader、进程及其子进程在内核和用户态运行时间统计值,
  // 还设置进程开始运行时间start_time
  p->state = TASK_UNINTERRUPTIBLE;
  p->pid = last_pid;
  p->counter = p->priority;
  p->signal = 0;
  p->alarm = 0;
  p->leader = 0;    /* process leadership doesn't inherit */
  p->utime = p->stime = 0;
  p->cutime = p->cstime = 0;
  p->start_time = jiffies;
  // 修改任务状态段TSS内容。由于系统给任务结构p分配了1页新内存,所以(PAGE_SIZE+(long)p)让esp0正好指向该页顶端
  // ss:esp0用作程序在内核态执行时的栈。另外,每个进程在GDT表中都有两个段描述符,一个是进程的TSS段描述符,另一个是任务
  // 的LDT表段描述符,p->tss.ldt = _LDT(nr)把GDT中本任务LDT段描述符的选择符保存在本进程的TSS段中。当执行进程切换时
  // CPU会自动从TSS中把LDT段描述符的选择符加载到ldtr寄存器中
  p->tss.back_link = 0;
  p->tss.esp0 = PAGE_SIZE + (long) p;
  p->tss.ss0 = 0x10;
  p->tss.eip = eip;
  p->tss.eflags = eflags;
  p->tss.eax = 0;
  p->tss.ecx = ecx;
  p->tss.edx = edx;
  p->tss.ebx = ebx;
  p->tss.esp = esp;
  p->tss.ebp = ebp;
  p->tss.esi = esi;
  p->tss.edi = edi;
  p->tss.es = es & 0xffff;
  p->tss.cs = cs & 0xffff;
  p->tss.ss = ss & 0xffff;
  p->tss.ds = ds & 0xffff;
  p->tss.fs = fs & 0xffff;
  p->tss.gs = gs & 0xffff;
  p->tss.ldt = _LDT(nr);
  p->tss.trace_bitmap = 0x80000000;
  if (last_task_used_math == current)
    __asm__("clts ; fnsave %0 ; frstor %0"::"m" (p->tss.i387));
  // 复制进程页表
  if (copy_mem(nr,p)) {
    task[nr] = NULL;
    free_page((long) p);
    return -EAGAIN;
  }
  // 下面将父进程相关文件属性的引用计数加1,表明父子进程共享文件
  for (i=0; i<NR_OPEN;i++)
    if (f=p->filp[i])
      f->f_count++;
  if (current->pwd)
    current->pwd->i_count++;
  if (current->root)
    current->root->i_count++;
  if (current->executable)
    current->executable->i_count++;
  if (current->library)
    current->library->i_count++;
  set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));
  set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));
  p->p_pptr = current;
  p->p_cptr = 0;
  p->p_ysptr = 0;
  p->p_osptr = current->p_cptr;
  if (p->p_osptr)
    p->p_osptr->p_ysptr = p;
  current->p_cptr = p;
  // 设置子进程为就绪态
  p->state = TASK_RUNNING;  /* do this last, just in case */
  return last_pid;
}
p = (struct task_struct *) get_free_page();
if (!p)
  return -EAGAIN;
task[nr] = p;
*p = *current;  /* NOTE! this doesn't copy the supervisor stack */

首先get_free_page()会在主内存末端申请一个空闲页面,然后,拿到这个内存起始地址,给了task_struct结构的p,

*p = *current就是把当前进程,也就是0号进程的task_struct的全部值都复制给即将创建的进程p,目前它们两者就完全一样了

image.png

进程1和进程0目前是完全复制的关系,但有一些值是需要个性化处理的,下面代码就是把这些不一样的值覆盖掉

image.png

int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
    long ebx,long ecx,long edx, long orig_eax, 
    long fs,long es,long ds,
    long eip,long cs,long eflags,long esp,long ss)
{
  ...
  // 随后对复制来的进程数据结构内容进行一些修改,作为新进程的任务结构。先将新进程的状态置为不可中断等待状态
  // 以防止内核调度其执行。然后设置新进程的进程号pid,并初始话进程运行时间片等于其priority值(一般为15个嘀嗒)
  // 接着复位新进程的信号位图、报警定时值、session领导标识leader、进程及其子进程在内核和用户态运行时间统计值,
  // 还设置进程开始运行时间start_time
  p->state = TASK_UNINTERRUPTIBLE;
  p->pid = last_pid;
  p->counter = p->priority;
  ...
  p->tss.esp0 = PAGE_SIZE + (long) p;
  p->tss.ss0 = 0x10;
  ...
  p->tss.edx = edx;
  p->tss.ebx = ebx;
  p->tss.esp = esp;
  ...
  return last_pid;
}

不一样的值,一部分是 statepidcounter 这种进程的元信息,另一部分是 tss 里面保存的各种寄存器的信息,即上下文

这里有两个寄存器的值的赋值有些特殊,就是 ss0 和 esp0,这个表示 0 特权级也就是内核态时的 ss:esp 的指向。

int copy_mem(int nr,struct task_struct * p)
{
  unsigned long old_data_base,new_data_base,data_limit;
  unsigned long old_code_base,new_code_base,code_limit;code_limit=get_limit(0x0f);
  data_limit=get_limit(0x17);
  old_code_base = get_base(current->ldt[1]);
  old_data_base = get_base(current->ldt[2]);
  if (old_data_base != old_code_base)
    panic("We don't support separate I&D");
  if (data_limit < code_limit)
    panic("Bad data_limit");
  new_data_base = new_code_base = nr * TASK_SIZE;
  p->start_code = new_code_base;
  set_base(p->ldt[1],new_code_base);
  set_base(p->ldt[2],new_data_base);
  if (copy_page_tables(old_data_base,new_data_base,data_limit)) {
    free_page_tables(new_data_base,data_limit);
    return -ENOMEM;
  }
  return 0;
}

要讲这段代码,首先要讲操作系统的分段分页机制,为什么需要分页机制这里就不赘述了,一个地址是怎么经过分页机制转换的呢?看下图

image.png CPU将线性地址解析成页目录项页表项页内偏移

高10位负责在页目录表中找到一个页目录项,这个页目录项的值加上中间10位拼接后的地址去页表中去寻找一个页表项,这个页表项的值,再加上后12位偏移地址,就是最终的物理地址

而这一切的操作,都由计算机的一个硬件叫MMU,中文名字叫内存管理单元,有时也叫做PMMU,分页内存管理单元。由这个部件来负责将虚拟地址转换为物理地址

按照当前的页目录表和页表这种机制,一个页目录表最多包含1024个页目录项(也就是1024个页表),1个页表最多包含1024个页表项(也就是1024个页),一页为4KB(因为有12位偏移地址),因此,16M的地址空间可以用1个页目录表+4个页表搞定

4(页表数)* 1024(页表项数)* 4KB(一页大小)= 16MB

int copy_mem(int nr,struct task_struct * p)
{
  ...
  code_limit=get_limit(0x0f); // 0x0f即1111
  data_limit=get_limit(0x17); // 0x17即10111
  // 获取父进程(现在是进程0)的代码段、数据段基址
  old_code_base = get_base(current->ldt[1]);
  old_data_base = get_base(current->ldt[2]);
  ...
}

段基址是取决于当前是几号进程,也即是nr的值,这里的0x04000000等于64M,也就是说,今后每个进程通过段基址的手段,分别在线性地址空间中占用64M的空间,且紧挨着

#define TASK_SIZE 0x04000000
​
int copy_mem(int nr,struct task_struct * p)
{
  ...
  new_data_base = new_code_base = nr * TASK_SIZE;
  p->start_code = new_code_base;
  // 设置子进程代码段基址,设置到LDT表中
  set_base(p->ldt[1],new_code_base);
  // 设置子进程数据段基址,设置到LDT表中
  set_base(p->ldt[2],new_data_base);
  ...
}

image.png

经过以上的步骤,就通过分段的方式,将进程映射到了相互隔离的线性地址空间里,这就是段式管理

进入copy_page_tables()函数后,先为新的页表申请一个空闲页面,并把进程0中第一个页表里面前160个页表项复制到这个页面中(1个页表项控制一个页面4 KB内存空间,160个页表项可以控制640 KB内存空间)。进程0和进程1的页表暂时都指向了相同的页面,意味着进程1也可以操作进程0的页面。之后对进程1的页目录表进行设置。最后,用重置CR3的方法刷新页变换高速缓存。进程1的页表和页目录表设置完毕

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 new_page;
  unsigned long nr;
  // 0x3fffff是4MB,是一个页表的管辖范围,二进制是22个1,||的两边必须同为0,所以,from和to后22位必须都为0,即4MB的整数      // 倍,意思是一个页表对应4MB连续的线性地址空间必须是从0x000000开始的4MB的整数倍的线性地址,不能是任意地址开始的4MB,
  // 才符合分页的要求
  if ((from&0x3fffff) || (to&0x3fffff))
    panic("copy_page_tables called with wrong alignment");
  // 一个页目录项的管理范围是4MB,一项是4字节,项的地址就是项数×4,也就是项管理的线性地址起始地址的M数,比如:0项的地址是0,
  // 管理范围是0~4 MB,1项的地址是4,管理范围是4~8 MB,2项的地址是8,管理范围是8~12MB……>>20就是地址的MB数,
  // &0xffc就是&111111111100b,就是4MB以下部分清零的地址的MB数,也就是页目录项的地址
  from_dir = (unsigned long *) ((from>>20) & 0xffc); /* _pg_dir = 0 */
  to_dir = (unsigned long *) ((to>>20) & 0xffc);
  size = ((unsigned) (size+0x3fffff)) >> 22;
  for( ; size-->0 ; from_dir++,to_dir++) {
    if (1 & *to_dir)
      panic("copy_page_tables: already exist");
    if (!(1 & *from_dir))
      continue;
    // from_dir是页目录项中的地址,0xfffff000&是将低12位清零,高20位是页表的地址
    from_page_table = (unsigned long *) (0xfffff000 & *from_dir);
    if (!(to_page_table = (unsigned long *) get_free_page()))
      return -1;  /* Out of memory, see freeing */
    *to_dir = ((unsigned long) to_page_table) | 7;
    nr = (from==0)?0xA0:1024;
    // 复制父进程页表
    for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {
      this_page = *from_page_table;
      if (!this_page)
        continue;
      if (!(1 & this_page)) {
        if (!(new_page = get_free_page()))
          return -1;
        read_swap_page(this_page>>1, (char *) new_page);
        *to_page_table = this_page;
        *from_page_table = new_page | (PAGE_DIRTY | 7);
        continue;
      }
      this_page &= ~2;
      *to_page_table = this_page;
      if (this_page > LOW_MEM) {
        *from_page_table = this_page;
        this_page -= LOW_MEM;
        this_page >>= 12;
        mem_map[this_page]++;
      }
    }
  }
  invalidate();
  return 0;
}

安装文件系统

接下来看init()函数,setup是个系统调用,会通过中断最终调用到sys_setup函数,sys_setup函数非常长,都是获取硬盘信息的代码,那一段我们不看,核心代码是mount_root()这个方法,我们细看下

setup((void *) &drive_info);
​
int sys_setup(void * BIOS)
{
  ......
  mount_root();
  return (0);
}
void mount_root(void)
{
  int i,free;
  struct super_block * p;
  struct m_inode * mi;
​
  if (32 != sizeof (struct d_inode))
    panic("bad i-node size");
  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;
  }
  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");
  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;
  free=0;
  i=p->s_nzones;
  while (-- i >= 0)
    if (!set_bit(i&8191,p->s_zmap[i>>13]->b_data))
      free++;
  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++;
}
for(i=0;i<NR_FILE;i++)
    file_table[i].f_count=0;

上文文件系统中讲过file_table,这里不多赘述。这行代码的意思是清空所有文件的引用次数

struct super_block super_block[NR_SUPER];
​
for(p = &super_block[0] ; p < &super_block[NR_SUPER] ; p++) {
  p->s_dev = 0;
  p->s_lock = 0;
  p->s_wait = NULL;
}

将数组super_block清零,super_block中存储这个设备的超级块信息

接下来就是将磁盘的信息读进内存中

// 读取超级块信息
p=read_super(ROOT_DEV)
// 读取根inode信息
mi=iget(ROOT_DEV,ROOT_INO)
// 将inode设置为当前进程(也就是进程1)的当前工作目录和根目录
current->pwd = mi;
current->root = mi;
// 记录块位图信息
free=0;
i=p->s_nzones;
  while (-- i >= 0)
    if (!set_bit(i&8191,p->s_zmap[i>>13]->b_data))
      free++;
// 记录inode位图信息
free=0;
i=p->s_ninodes+1;
while (-- i >= 0)
  if (!set_bit(i&8191,p->s_imap[i>>13]->b_data))
    free++;

打开终端设备文件

(void) open("/dev/tty1",O_RDWR,0);
(void) dup(0);
(void) dup(0);

调用open("/dev/tty1",O_RDWR,0)函数后,或获取到fd文件描述符

dup为1号fd赋值,这个作为标准输出设备

dup为2号fd赋值,这个作为标准输出设备

这个Linux常说的stdin、stdout、stderr

dup的原理仍然是通过系统调用方式,调用到sys_dup函数,就是从进程的filp中找到下一个空闲项,然后把要复制的文件描述符fd的信息,统统复制到这里

int sys_dup(unsigned int fildes)
{
  return dupfd(fildes,0);
}
​
​
​
static int dupfd(unsigned int fd, unsigned int arg)
{
  if (fd >= NR_OPEN || !current->filp[fd])
    return -EBADF;
  if (arg >= NR_OPEN)
    return -EINVAL;
  // 在当前进程的文件结构指针数组中寻找索引号等于或大于arg,但还没有使用的项
  // 如果找到的新句柄值arg大于最多打开文件数(即没有空闲项),则返回错误码并退出
  while (arg < NR_OPEN)
    if (current->filp[arg])
      arg++;
    else
      break;
  if (arg >= NR_OPEN)
    return -EMFILE;
  // 针对找到的空闲项,在执行时关闭标志位图close_on_exec中复位该句柄位
  // 即在运行exec()类函数时,不会关闭用dup()创建的句柄。
  // 并令该文件结构指针等于原句柄fd的指针,并且将文件引用计数增1
  current->close_on_exec &= ~(1<<arg);
  (current->filp[arg] = current->filp[fd])->f_count++;
  return arg;
}

image.png

经过这个动作后,进程1已经拥有了与外设交互的能力,同时也使得之后从进程1 fork出来的进程2也天生拥有和进程1同样的与外设交互的能力

进程2的创建及执行

...
if (!(pid=fork())) {
  close(0);
  if (open("/etc/rc",O_RDONLY,0))
    _exit(1);
  execve("/bin/sh",argv_rc,envp_rc);
  _exit(2);
}
​
// 下面是父进程(进程1)执行的语句。wait()等待子进程停止或终止,返回值应是子进程的进程号(pid)
// 这三句的作用是父进程等待子进程的结束。&i是存放返回状态信息的位置。如果wait()返回值不等于子进程号
// 则继续等待
if (pid>0)
  while (pid != wait(&i))
    /* nothing */;
// 如果执行到这里,说明刚创建的子进程已执行完/etc/rc文件(或文件不存在),因此该子进程自动停止或终止
// 下面循环中会再次创建一个子进程,用于运行登录和控制台shell程序
// 该新建子进程首先将关闭所有以前还遗留的句柄(stdin、stdout、stderr),新创建一个会话,然后重新打开
// /dev/tty0作为stdin,并复制生成stdout和stderr。然后再次执行/bin/sh程序
// 此后父进程再次运行wait()等待,如果子进程又停止运行(例如用户执行了exit命令),则在标准输出上显示出错信息
// 子进程pid停止运行,返回码是i,然后继续重试下去,形成大死循环
while (1) {
  if ((pid=fork())<0) {
    printf("Fork failed in init\r\n");
    continue;
  }
  if (!pid) {
    close(0);close(1);close(2);
    setsid();
    (void) open("/dev/tty1",O_RDWR,0);
    (void) dup(0);
    (void) dup(0);
    _exit(execve("/bin/sh",argv,envp));
  }
  while (1)
    if (pid == wait(&i))
      break;
  printf("\n\rchild %d died with code %04x\n\r",pid,i);
  sync();
}
_exit(0); /* NOTE! _exit, not exit() */

通过fork()函数创建进程2后,fork()函数返回值为2,因此!(pid=fork())值为假,于是进程1调用wait()函数,此函数的功能是:如果进程1有等待退出的子进程,就为该进程的退出做善后工作;如果有子进程,但并不等待退出,则进行进程切换;如果没子进程,函数返回

进程2被fork出来后,会执行/etc/rc文件,这个文件可以存储一些开机启动前需要做的一些事情,比如输入账号密码,执行函数execve是个超级复杂的函数,过多的细节反而会阻碍我们对操作系统主流程的理解(其实是我看不懂)

操作系统启动完毕

while (1) {
  if ((pid=fork())<0) {
    printf("Fork failed in init\r\n");
    continue;
  }
  if (!pid) {
    close(0);close(1);close(2);
    setsid();
    (void) open("/dev/tty1",O_RDWR,0);
    (void) dup(0);
    (void) dup(0);
    _exit(execve("/bin/sh",argv,envp));
  }
  while (1)
    if (pid == wait(&i))
      break;
  printf("\n\rchild %d died with code %04x\n\r",pid,i);
  sync();
}

接下来操作系统会进入一个大的死循环,等待键盘的输入,然后通过fork+execve函数执行命令

参考资料

github.com/dibingfa/fl…

《Linux内核设计的艺术》

《linux内核完全注释》