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

103 阅读8分钟

前言

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

上一篇文章记录了执行main函数前的一系列准备工作,从借助BIOS将bootsect.s文件加载到内存开始,相继加载了setup.s文件和system.s文件,从而完成操作系统程序的加载,一切准备就绪后,跳转到main函数执行入口,开始执行main函数

main函数

整个main函数都是各种初始化的动作,为最后面进程的调度做各种准备

void main(void)   
{     
  ROOT_DEV = ORIG_ROOT_DEV;
  SWAP_DEV = ORIG_SWAP_DEV;
  sprintf(term, "TERM=con%dx%d", CON_COLS, CON_ROWS);
  envp[1] = term; 
  envp_rc[1] = term;
  drive_info = DRIVE_INFO;
  memory_end = (1<<20) + (EXT_MEM_K<<10);
  memory_end &= 0xfffff000;
  if (memory_end > 16*1024*1024)
    memory_end = 16*1024*1024;
  if (memory_end > 12*1024*1024) 
    buffer_memory_end = 4*1024*1024;
  else if (memory_end > 6*1024*1024)
    buffer_memory_end = 2*1024*1024;
  else
    buffer_memory_end = 1*1024*1024;
  main_memory_start = buffer_memory_end;
#ifdef RAMDISK
  main_memory_start += rd_init(main_memory_start, RAMDISK*1024);
#endif
  mem_init(main_memory_start,memory_end);
  trap_init();
  blk_dev_init();
  chr_dev_init();
  tty_init();
  time_init();
  sched_init();
  buffer_init(buffer_memory_end);
  hd_init();
  floppy_init();
  sti();
  move_to_user_mode();
  if (!fork()) {    /* we count on this going ok */
    init();
  }
/*
 *   NOTE!!   For any other task 'pause()' would mean we have to get a
 * signal to awaken, but task0 is the sole exception (see 'schedule()')
 * as task 0 gets activated at every idle moment (when no other tasks
 * can run). For task0 'pause()' just means we go check if some other
 * task can run, and if not we return here.
 */
  for(;;)
    __asm__("int $0x80"::"a" (__NR_pause):"ax");
}
​

规划物理内存格局

memory_end = (1<<20) + (EXT_MEM_K<<10);
memory_end &= 0xfffff000;
if (memory_end > 16*1024*1024)
  memory_end = 16*1024*1024;
if (memory_end > 12*1024*1024) 
  buffer_memory_end = 4*1024*1024;
else if (memory_end > 6*1024*1024)
  buffer_memory_end = 2*1024*1024;
else
  buffer_memory_end = 1*1024*1024;
main_memory_start = buffer_memory_end;

这段代码是为了计算出三个变量,main_memory_startbuffer_memory_endmemory_end,而最后一行代码

main_memory_start = buffer_memory_end;发现main_memory_startbuffer_memory_end是一样的,所以上面的代码只计算出了两个变量,这两个变量是根据内存最大值来计算的,内存的最大值由第一行代码可以看出,是等于1M+扩展内存大小,我们假设内存最大值为8M,那么buffer_memory_end为2M,即main_memory_start也为2M,那么整个内存的布局如下

为什么要做这样的布局呢?

主内存区是进程代码运行的空间,也包括内核管理进程的数据结构。缓冲区主要作为计算机与外设进行数据交互的中转站。对内存中缓冲区、主内存的设置、规划,从根本上决定了所有进程使用内存的数量和方式

image.png

内存管理结构mem_map初始化

主内存区和缓冲区的起始位置确定好后,就要开始对该区域的管理结构进行设置,首先设置的是主内存区,对1MB以上的物理内存区域进行初始化设置的工作。内核以页面为单位管理和访问内存,一个内存页面长度为4KB。该函数把1MB以上的所有内存划分成一个个页面,并使用一个页面映射字节数组mem_map[]来管理所有这些页面。对于具有8MB内存的机器,该数组共有1750项,即可管理1750个物理页面,每当一个内存页面被占用就把mem_map[]中对应字节项增1,若释放一个页面,就把对应字节值减1.若字节项为0,则表示对应页面空闲;若字节值大于或等于1,则表示页面被占用或多个进程共享占用,由于内核高速缓冲区跟部分设备需要一定量的内存,因此系统实际可供分配使用的内存量会少些。我们把能够被实际分配使用的内存区域称为主内存区

mem_init(main_memory_start,memory_end);#define LOW_MEM 0x100000
#define PAGING_MEMORY (15*1024*1024)
#define PAGING_PAGES (PAGING_MEMORY>>12)
#define MAP_NR(addr) (((addr)-LOW_MEM)>>12)
#define USED 100
​
extern unsigned char mem_map [ PAGING_PAGES ];
​
void mem_init(long start_mem, long end_mem)
{
  int i;
  // 将1MB到8MB范围内所有内存页面对应的内存映射字节数组项置为已占用状态,即各项字节值全部设置为USED(100)
  // PAGING_PAGES被定义为(PAGING_MEMORY >> 12)
  HIGH_MEMORY = end_mem;
  for (i=0 ; i<PAGING_PAGES ; i++)
    mem_map[i] = USED;
  // 找出主内存区起始位置start_mem处的页面对应内存映射字节数组中项号i,并计算出主内存区页面数
  // 此时mem_map[]数组的第i项正对应主内存区中第1个页面,最后将主内存区中页面对应的数组项清零(表示空闲)
  i = MAP_NR(start_mem);
  end_mem -= start_mem;
  end_mem >>= 12;
  while (end_mem-->0)
    mem_map[i++]=0;
}

mem_init()函数先将所有的内存页面使用计数均设置为USED(100,即被使用),然后再将主内存中的所有页面使用计数全部清零,系统以后只把计数为0的页面视为空闲页面

image.png

那么为什么系统对1MB以内的内存空间不用这种分页方法管理呢?这是因为,操作系统的设计者对内核和用户进程采用了两套不同的分页管理方法。内核采用分页管理方法,线性地址和物理地址是完全一样的,是一一映射的,等价于内核可以直接获得物理地址。用户进程则不然,线性地址和物理地址差异很大,之间没有可递推的逻辑关系。操作系统设计者的目的就是让用户进程无法通过线性地址推算出具体的物理地址,让内核能够访问用户进程,用户进程不能访问其他用户进程,更不能访问内核。1MB以内是内核代码和只有由内核管控的大部分数据所在内存空间,是绝对不允许用户进程访问的。1MB以上,特别是主内存区主要是用户进程的代码、数据所在内存空间,所以采用专门用来管理用户进程的分页管理方法,这套方法当然不能用在内核上

中断初始化

void trap_init(void)
{
  int i;
​
  set_trap_gate(0,&divide_error);
  set_trap_gate(1,&debug);
  set_trap_gate(2,&nmi);
  set_system_gate(3,&int3); /* int3-5 can be called from all */
  set_system_gate(4,&overflow);
  set_system_gate(5,&bounds);
  set_trap_gate(6,&invalid_op);
  set_trap_gate(7,&device_not_available);
  set_trap_gate(8,&double_fault);
  set_trap_gate(9,&coprocessor_segment_overrun);
  set_trap_gate(10,&invalid_TSS);
  set_trap_gate(11,&segment_not_present);
  set_trap_gate(12,&stack_segment);
  set_trap_gate(13,&general_protection);
  set_trap_gate(14,&page_fault);
  set_trap_gate(15,&reserved);
  set_trap_gate(16,&coprocessor_error);
  set_trap_gate(17,&alignment_check);
  for (i=18;i<48;i++)
    set_trap_gate(i,&reserved);
  set_trap_gate(45,&irq13);
  outb_p(inb_p(0x21)&0xfb,0x21);
  outb(inb_p(0xA1)&0xdf,0xA1);
  set_trap_gate(39,&parallel_interrupt);
}

关于什么是中断,可以看看这位大佬的博客,写得非常好 mp.weixin.qq.com/s?__biz=Mzk…

这些代码都是相似的,做的工作的确也是一样的,就是往一个地方写数据,后面需要的时候再拿出来用,那是往哪里写数据呢?

就是往之前IDT的位置写数据,每个中断行就是一个中断处理程序,后面的代码中会多次出现set_trap_gate代码,都是设置中断

image.png

初始化块设备请求项结构

Linux将外设分为两类,一类是块设备,另一类是字符设备。块设备将存储空间等分为若干同样大小的称为块的小存储空间,每个块有块号,可以独立、随机读写。硬盘就是块设备。字符设备以字符为单位进行I/O通信,键盘就是字符设备

进程要想与块设备进行沟通,必须经过计算机内存中的缓冲区。请求项管理结构request[32]就是操作系统管理缓冲区中的缓冲块与块设备上逻辑块之间读写关系的数据结构

void blk_dev_init(void)
{
  int i;
​
  for (i=0 ; i<NR_REQUEST ; i++) {
    request[i].dev = -1;
    request[i].next = NULL;
  }
}
struct request {
  // dev 表示设备号,-1就表示空闲
  int dev;    /* -1 if no request */
  // cmd 表示命令,其实就是READ还是WRITE,也就表示本次操作是读还是写
  int cmd;    /* READ or WRITE */
  // errors 表示操作时产生的错误次数
  int errors;
  // sector 表示起始扇区
  unsigned long sector;
  // nr_sectors 表示扇区数
  unsigned long nr_sectors;
  // buffer 表示数据缓冲区,也就是读盘之后的数据放在内存中的什么位置
  char * buffer;
  // waiting 是个task_struct结构,这可以表示一个进程,也就表示是哪个进程发起了这个请求。bh是缓冲区头指针
  struct task_struct * waiting;
  struct buffer_head * bh;
  // next 指向了下一个请求项
  struct request * next;
};

这个request结构可以完整描述一个读盘操作,比如读请求,结合上面的参数,cmd就是READsectornr_sectors这俩就定位了所要读取的块设备的位置,buffer就定位了这些数据读完之后放在内存的什么位置,然后那个request数组就是把它们都放在一起,并且它们又通过next指针串成链表

image.png

开机启动时间设置

开机启动时间是大部分与时间相关的计算的基础。操作系统中一些程序的运算需要时间参数;很多事务的处理也都要用到时间,比如文件修改的时间、文件最近访问的时间、i节点自身的修改时间等。有了开机启动时间,其他时间就可据此推算出来

static void time_init(void)
{
  struct tm time;
​
  do {
    time.tm_sec = CMOS_READ(0);
    time.tm_min = CMOS_READ(2);
    time.tm_hour = CMOS_READ(4);
    time.tm_mday = CMOS_READ(7);
    time.tm_mon = CMOS_READ(8);
    time.tm_year = CMOS_READ(9);
  } while (time.tm_sec != CMOS_READ(0));
  BCD_TO_BIN(time.tm_sec);
  BCD_TO_BIN(time.tm_min);
  BCD_TO_BIN(time.tm_hour);
  BCD_TO_BIN(time.tm_mday);
  BCD_TO_BIN(time.tm_mon);
  BCD_TO_BIN(time.tm_year);
  time.tm_mon--;
  startup_time = kernel_mktime(&time);
}

具体执行步骤是:CMOS是主板上的一个小存储芯片,系统通过调用time_init()函数,先对它上面记录的时间数据进行采集,提取不同等级的时间要素,比如秒(time.tm_sec)、分(time.tm_min)、年(time.tm_year)等,然后对这些要素进行整合,并最终得出开机启动时间(startup_time),startup_time是从1970年1月1日0时起到开机当时经过的秒数

初始化进程0

进程0是Linux操作系统运行的第一个进程,这次初始化主要包含了三方面的工作

  1. 系统先初始化进程0。进程0管理结构task_struct的母本(init_task={INIT_TASK,})已经在代码设计阶段事先设计好了,但这并不代表进程0已经可用了,还要将进程0的task_struct中的LDT、TSS与GDT相挂接,并对GDT、task[64]以及与进程调度相关的寄存器进行初始化设置
  2. 现代操作系统最重要的标志就是能够支持多进程轮流执行,这要求进程具备参与多进程轮询的能力。系统这里对时钟中断进行设置,以便在进程0运行后,为进程0以及后续由它直接、间接创建出来的进程能够参与轮转奠定基础
  3. 进程0要具备处理系统调用的能力。每个进程在运算时都可能需要与内核进行交互,而交互的端口就是系统调用程序。系统通过函数set_system_gate将system_call与IDT相挂接,这样进程0就具备了处理系统调用的能力了。这个system_call就是系统调用的总入口
void sched_init(void)
{
  int i;
  // 描述符表结构的指针
  struct desc_struct * p;
​
  if (sizeof(struct sigaction) != 16)
    panic("Struct sigaction MUST be 16 bytes");
  // 在全局描述符表GDT中设置初始任务(任务0)的任务状态段TSS描述符和局部数据表LDT描述符
  // FIRST_TSS_ENTRY和FIRST_LDT_ENTRY的值分别是4和5,gdt+FIRST_TSS_ENTRY即为gdt[FIRST_TSS_ENTRY]
  // 也即gdt数组第4项的地址,gdt+FIRST_LDT_ENTRY即为gdt[FIRST_LDT_ENTRY],也即gdt数组第5项的地址
  set_tss_desc(gdt+FIRST_TSS_ENTRY,&(init_task.task.tss));
  set_ldt_desc(gdt+FIRST_LDT_ENTRY,&(init_task.task.ldt));
  
  p = gdt+2+FIRST_TSS_ENTRY;
  // 0项为进程0所用,1往后的项清空
  for(i=1;i<NR_TASKS;i++) {
    task[i] = NULL;
    p->a=p->b=0;
    p++;
    p->a=p->b=0;
    p++;
  }
​
  __asm__("pushfl ; andl $0xffffbfff,(%esp) ; popfl");
  // 将TR寄存器指向TSS0、LDTR寄存器指向LDT0,这样,CPU就能通过TR、LDTR寄存器找到进程0的TSS0、LDT0
  // 也能找到一切和进程0相关的管理信息
  ltr(0);
  lldt(0);
  outb_p(0x36,0x43);    /* binary, mode 3, LSB/MSB, ch 0 */
  outb_p(LATCH & 0xff , 0x40);  /* LSB */
  outb(LATCH >> 8 , 0x40);  /* MSB */
  set_intr_gate(0x20,&timer_interrupt);
  outb(inb_p(0x21)&~0x01,0x21);
  set_system_gate(0x80,&system_call);
}
// 在全局描述符表GDT中设置初始任务(任务0)的任务状态段TSS描述符和局部数据表LDT描述符
// FIRST_TSS_ENTRY和FIRST_LDT_ENTRY的值分别是4和5,gdt+FIRST_TSS_ENTRY即为gdt[FIRST_TSS_ENTRY]
// 也即gdt数组第4项的地址,gdt+FIRST_LDT_ENTRY即为gdt[FIRST_LDT_ENTRY],也即gdt数组第5项的地址
set_tss_desc(gdt+FIRST_TSS_ENTRY,&(init_task.task.tss));
set_ldt_desc(gdt+FIRST_LDT_ENTRY,&(init_task.task.ldt));

TSS叫任务状态段,就是保存和恢复上下文,其实就是各个寄存器的信息而已,这样进程切换的时候,才能做到保存和恢复上下文,继续执行

struct tss_struct {
  long  back_link;  /* 16 high bits zero */
  long  esp0;
  long  ss0;    /* 16 high bits zero */
  long  esp1;
  long  ss1;    /* 16 high bits zero */
  long  esp2;
  long  ss2;    /* 16 high bits zero */
  long  cr3;
  long  eip;
  long  eflags;
  long  eax,ecx,edx,ebx;
  long  esp;
  long  ebp;
  long  esi;
  long  edi;
  long  es;   /* 16 high bits zero */
  long  cs;   /* 16 high bits zero */
  long  ss;   /* 16 high bits zero */
  long  ds;   /* 16 high bits zero */
  long  fs;   /* 16 high bits zero */
  long  gs;   /* 16 high bits zero */
  long  ldt;    /* 16 high bits zero */
  long  trace_bitmap; /* bits: trace 0, bitmap 16-31 */
  struct i387_struct i387;
};

而LDT叫局部描述符表,是与GDT全局描述符表相对应的,内核态的代码用GDT里的数据段和代码段,而用户进程的代码用每个用户进程自己的LDT里的数据段和代码段,设置完内存结构如下图所示

image.png

接下来用for循环将task[64]除进程0占用的0项外的其余63项清空,同时将GDT的TSS1、LDT1往上的所有表项清零

typedef struct desc_struct {
  unsigned long a,b;
} desc_table[256];
​
​
p = gdt+2+FIRST_TSS_ENTRY;
// 0项为进程0所用,1往后的项清空
for(i=1;i<NR_TASKS;i++) {
  task[i] = NULL;
  p->a=p->b=0;
  p++;
  p->a=p->b=0;
  p++;
}
// 将TR寄存器指向TSS0、LDTR寄存器指向LDT0,这样,CPU就能通过TR、LDTR寄存器找到进程0的TSS0、LDT0
// 也能找到一切和进程0相关的管理信息
ltr(0);
lldt(0);
outb_p(0x36,0x43);    /* binary, mode 3, LSB/MSB, ch 0 */
outb_p(LATCH & 0xff , 0x40);  /* LSB */
outb(LATCH >> 8 , 0x40);  /* MSB */
set_intr_gate(0x20,&timer_interrupt);
outb(inb_p(0x21)&~0x01,0x21);
set_system_gate(0x80,&system_call);

这段代码两行是设置中断,四行是端口读写代码

端口读写是CPU与外设交互的方式,这次交互的对象是一个可编程定时器的芯片,这四行代码开启了这个定时器,之后这个定时器会持续的,以一定频率向CPU发出中断信号

第一个中断是时钟中断,中断号为0x20,中断处理程序为time_interrupt,每次定时器向CPU发出中断后,便会执行这个函数,这个时钟中断函数的设置,是操作系统主导进程调度的关键,没有这个中断,操作系统没有办法作为进程管理的主人,通过强制的手段收回进程的CPU执行权限

第二个中断是系统调用system_call,中断号是0x80,所有用户态程序想要调用内核提供的方法,都需要基于这个系统调用来进行

系统调用函数是操作系统对用户程序的基本支持。在操作系统中,依托硬件提供的特权级对内核进行保护,不允许用户进程直接访问内核代码。但进程有大量的像读盘、创建子进程之类的具体事务处理需要内核代码的支持。为了解决这个矛盾,操作系统的设计者提供了系统调用的解决方案,提供一套系统服务接口。用户进程只要想和内核打交道,就调用这套接口程序,之后,就会立即引发int 0x80软中断,后面的事情就不需要用户程序管了,而是通过另一条执行路线——由CPU对这个中断信号响应,翻转特权级(从用户进程3特权级翻转到内核的0特权级),通过IDT找到系统调用端口,调用具体的系统调用函数来处理事务,之后,再iret翻转回到进程的3特权级,进程继续执行原来的逻辑,这样矛盾就解决了

初始化缓冲区管理结构

缓冲区是内存与外设(比如硬盘)进行数据交互的媒介,

extern int end;
struct buffer_head * start_buffer = (struct buffer_head *) &end;
​
void buffer_init(long buffer_end)
{
  struct buffer_head * h = start_buffer;
  void * b;
  int i;
​
  if (buffer_end == 1<<20)
    b = (void *) (640*1024);
  else
    b = (void *) buffer_end;
  while ( (b -= BLOCK_SIZE) >= ((void *) (h+1)) ) {
    h->b_dev = 0;
    h->b_dirt = 0;
    h->b_count = 0;
    h->b_lock = 0;
    h->b_uptodate = 0;
    h->b_wait = NULL;
    h->b_next = NULL;
    h->b_prev = NULL;
    h->b_data = (char *) b;
    h->b_prev_free = h-1;
    h->b_next_free = h+1;
    h++;
    NR_BUFFERS++;
    if (b == (void *) 0x100000)
      b = (void *) 0xA0000;
  }
  h--;
  free_list = start_buffer;
  free_list->b_prev_free = h;
  h->b_next_free = free_list;
  for (i=0;i<NR_HASH;i++)
    hash_table[i]=NULL;
} 

这段代码简化一下,就是下面这个样子

extern int end;
struct buffer_head * start_buffer = (struct buffer_head *) &end;
​
void buffer_init(long buffer_end)
{
  struct buffer_head * h = start_buffer;
  void * b = (void *) buffer_end;
​
    
  while ( (b -= BLOCK_SIZE) >= ((void *) (h+1)) ) {
    ...
    h->b_data = (char *) b;
    h->b_prev_free = h-1;
    h->b_next_free = h+1;
    h++;
    ...
  }
  h--;
  free_list = start_buffer;
  free_list->b_prev_free = h;
  h->b_next_free = free_list;
  for (i=0;i<NR_HASH;i++)
    hash_table[i]=NULL;
} 

先看第一段,这段代码只有两个变量,一个是buffer_head 结构的h,代表缓冲头,其指针值是start_buffer,就是缓冲区开头

一个是b,代表缓冲块,指针值是buffer_end,也就是图中的 2M,就是缓冲区结尾

缓冲区结尾的b每次循环-1024,也就是一页的值,缓冲区结尾的h每次循环 +1(一个 buffer_head 大小的内存),直到碰一块为止

而free_list指向了缓冲头双向链表的第一个结构,然后就可以顺着这个结构,从双向链表中遍历到任何一个缓冲头结构了,而通过缓冲头又可以找到这个缓冲头对应的缓冲块

struct buffer_head * h = start_buffer;
void * b = (void *) buffer_end;
​
​
while ( (b -= BLOCK_SIZE) >= ((void *) (h+1)) ) {
...
h->b_data = (char *) b;
h->b_prev_free = h-1;
h->b_next_free = h+1;
h++;
...
}
h--;
free_list = start_buffer;
free_list->b_prev_free = h;
h->b_next_free = free_list;

image.png

c

初始化一个307大小的hash_table数组,这是干嘛的?

hash嘛,就是为了加快查询速度,方便快速查找缓冲头

image.png

初始化硬盘

#define MAJOR_NR 3
​
void hd_init(void)
{
  blk_dev[MAJOR_NR].request_fn = DEVICE_REQUEST;
  set_intr_gate(0x2E,&hd_interrupt);
  outb_p(inb_p(0x21)&0xfb,0x21);
  outb(inb_p(0xA1)&0xbf,0xA1);
}

blk_dev 数组索引 3 位置处的块设备管理结构blk_dev_structrequest_fn 赋值为了do_hd_request,这是啥意思呢?

因为有很多块设备,所以操作系统用了一个blk_dev[]来进行管理,每一个索引表示一个块设备

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

索引为3这个位置,就是给硬盘hd这个块设备留的位置,接下来就是设置中断了

参考资料

github.com/dibingfa/fl…

《Linux内核设计的艺术》

《linux内核完全注释》