分类: 操作系统

Nanos note 2 : 内核初始化

上一篇文章中,我们已经完成了bootloader的运行:设置内核段描述符、进入保护模式、载入ELF格式内核,并将控制交给内核代码。
但是,此时操作系统的启动过程并未完全完成,本篇文章接着bootloader完成的工作,还是以Nanos为范例,具体的分析一下操作系统内核的初始化过程(内核初始化所使用的代码可以在Nanos的github找到,见这里)。
内核初始化主要完成了下面这些工作:

  1. 初始化时钟
  2. 初始化可编程中断控制器
  3. 初始化中断描述符表(IDT)

还是从Makefile文件入手,内核的Makefile指定了kernel的生成方式如下:

参数的含义是以符号entry代表的函数作为程序入口,并且代码段定位从0x00100000地址开始。也就是说,当bootloader将内核载入到内存后,会将控制交给0x00100000处的代码,即内核入口entry()函数。entry()函数极其简单:

在依次执行了几个初始化函数之后,入口函数进入死循环状态,之后我们会看到,它实际上成为了操作系统的0号IDLE线程。


I/O Port

在进入具体初始化函数的分析之前,我们先开个小差,看一下两个嵌入式汇编函数in_byte和out_byte:

因为之后的初始化工作有很多需要使用in、out汇编指令操作I/O端口的步骤,因此为了能够在C语言中完成这些工作,上面的代码使用嵌入式汇编定义了这两个函数,其功能是显而易见的,如果你不熟悉嵌入式汇编的指示,请参考这里这里


关于中断

中断在现代操作系统中扮演了极其重要的角色,可以说现代操作系统基本是由中断所驱动的。因此在内核初始化过程中,初始化中断环境是非常重要的一环,也是Nanos内核初始化的主要工作。
如果你还不太熟悉什么是中断,那么下面这段摘自Nanos实验主页的简要介绍可能会帮到你:

随着计算机能力的增强,分时成为了自然的需求。多个终端可以共享昂贵的处理器和内存,而总是通过轮询方式访问设备也带来了很大的浪费。而且如果有程序不幸出错陷入死循环,整个机器都面临重启的命运。中断的诞生解决了这一问题。中断本质来说就是一种来自计算机外的通知机制,它可以在外部事件到来时,暂停CPU当前的工作,跳转到一个预先指定的地点执行。

总的来说,中断可以分为硬件中断和异常。其中硬件中断分为不可屏蔽中断NMI(Non-Maskable Interrupt)和可屏蔽硬件中断INTR。异常来源于CPU内部,比如软件指令执行异常(比如被0除),或者CPU芯片硬件异常,以及编程异常(软中断)。NMI和异常都是不可屏蔽的,状态寄存器IF位的设置对它们没有影响。只有可屏蔽硬件中断INTR是可以屏蔽的,是否屏蔽取决于状态寄存器IF的设置。关于中断更详细的介绍在这里

CPU和中断

CPU和中断


初始化时钟

回到正题,内核接管控制后,我们首先要执行init_timer函数来设置计算机时钟生成的频率,虽然代码乍一看似乎不知所云,但实际上都是硬件通信的简单协定而已:

简单来说,init_timer函数完成的工作就是把时钟中断产生的频率设置为100Hz(代码中HZ为宏定义100)。具体的PIT协定见这里


初始化中断控制器

接下来,我们需要为内核设置中断环境。在intel CPU中,硬件中断如时钟、键盘中断等由8259可编程中断控制器(PIC)进行控制,8259PIC接受这些中断并按照一定的顺序将这些中断传递给CPU处理。例如:当你通过敲击键盘触发一个键盘中断,键盘通过它预设的中断线路(IRQ 1)传递给PIC一个信号,接下来PIC会将这个信号转换为系统中断并传递给CPU进行处理,如下图所示:

硬件设备通过8259s将中断传递到CPU

硬件设备通过8259s将中断传递到CPU

因此在内核启动初期,我们必须对PIC进行初始化,使PIC可以按照内核期望的方式处理硬件中断。初始化PIC的原理和初始化PIT的原理相同,只是PIC的控制协定复杂了许多,初始化代码如下:

通过代码你大概也能猜到,现代的intel体系中多采用两个8259中断控制器:Master和Slave级联组成,就像这样:

slave芯片的输出连接到master芯片的IRQ 2

slave芯片的输出连接到master芯片的IRQ 2

初始化代码中首先告知Master芯片在IRQ2上存在级联的Slave芯片,之后分别对两个芯片各设置了32和40的偏移量,使得总体的IRQ有了32的偏移量。这样做的原因是因为CPU已经使用了0-31的中断号供CPU异常使用,为了避免冲突,所以需要对PIC设置32的偏移量,例如:时钟中断IRQ 0在通过PIC传递给CPU时为32号中断,对PIC更详细的讲解在这里


初始化中断描述符表

当中断到来后,机器硬件会负责跳转到对应的中断处理程序执行。那么,机器是如何知道中断处理程序在哪里呢?在保护模式中,我们通过设置正确的中断描述符表(Interrupt descriptor table)来指导硬件的工作。
中断描述符表和全局描述符表十分相似,在中断描述符表中,由CPU的不可见寄存器idtr存放中断描述符表的起始线性地址(32位)和大小限制(16位)(是不是和gdtr很像?),当中断到来时,硬件通过idtr中的起始线性地址和中断号计算对应的中断描述符地址:

计算中断描述符地址

计算中断描述符地址

中断描述符表中最多有256个表项,每项占64位,其中每个表项的结构如下:

其中最主要的字段为segment和offset,分别为中断处理程序的段选择子和线性地址。下面我们为Nanos设置中断描述符表:

代码中像vec()这样的函数就是内核中相应的中断处理程序,init_idt函数首先将所有中断/陷阱门都设置为irq_empty(),防止出现未处理的中断,之后设置了硬件中断和异常的门结构。中断门和异常门极其相似,但他们的重要区别在于:当硬件通过一个中断门时,硬件会清除控制寄存器的IF位(关中断),而异常门则不会,在之后的内核实现中我们会发现这是十分重要的!Nanos(以及通常的操作系统)为外部硬件中断设置中断门(时钟、键盘中断),为CPU异常和软中断使用异常门。

最后执行write_idtr()函数将idt线性地址和长度通过lidt指令载入到idtr寄存器中:

对于IDT更详细的讲解见这里这里


中断处理

当完成了上面这些繁杂初始化工作后,Nanos的内核初始化工作便完成了!之后entry函数打开了硬件中断,进入死循环并开始响应硬件中断。最后让我们来看看Nanos响应中断的具体过程吧:

  1. 当一个时钟中断由我们设置的PIT发出,PIC收到IRQ 0,因为我们所设置的偏移量,PIC会向CPU发出32号中断
  2. 当CPU执行完当前的语句后发现了PIC送达的中断,此时硬件首先将eflags、cs、eip寄存器依次压栈
  3. 通过中断描述符表找到中断处理函数irq0(),修改cs、eip寄存器指向irq0()的线性地址,修改eflags的IF位(因为这是一个中断门),转至irq0()函数执行中断处理
  4. irq0()函数见下方代码,irq0以汇编的形式定义,首先压栈0作为error code(注:之所以这么做是因为当发生部分中断时会由硬件自动压栈一个error code,而部分中断硬件没有这么做,为了统一栈中的空间布局,硬件没有自动压栈error code的中断Nanos在中断处理函数中手动压栈0作为error code,Linux中也是这样实现的),再压栈一个内部定义的中断码,然后jmp至asm_do_irq函数
  5. asm_do_irq函数中首先保存了大量的寄存器现场信息,之后修改段基址寄存器,将运行环境切换至内核空间,最后压栈sp指针作为参数调用C函数irq_handle
  6. irq_handle函数的参数为保存的sp指针,实际上指向了Nanos的TrapFrame(保存了进程的寄存器上下文),irq_handle通过tf指针访问之前压栈的中断码,根据不同的中断码做出响应处理中断
  7. 当irq_handle返回到asm_do_irq后,汇编代码继而执行大量的pop指令对保存的进程寄存器上下文进行恢复,最后通过iret语句恢复最初由硬件压栈的eflags、cs、eip寄存器,内核中断响应路径至此结束,控制又交回了中断发生前在运行的进程

irq_handle函数:

到这里为止Nanos的框架代码部分全部分析完毕了,之后的文章中将针对我自己对Nanos的实现进行记录。
坚持不懈:)


参考资料