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

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

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

kernel: $(OBJS)
	$(LD) $(LDFLAGS) -e entry -Ttext 0x00100000 -o kernel $(OBJS)

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

void
entry(void) {
    init_timer();                // 初始化时钟
    init_idt();                  // 初始化中断描述符表(IDT)
    init_intr();                 // 初始化可编程中断控制器
    init_serial();
    enable_interrupt();
    while (1) {
        wait_for_interrupt();
    }
    assert(0);
}

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

I/O Port

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

/* 读I/O端口 */
static inline uint8_t
in_byte(uint16_t port) {
    uint8_t data;
    asm volatile("in %1, %0" : "=a"(data) : "d"(port));
    return data;
}
 
/* 写I/O端口 */
static inline void
out_byte(uint16_t port, int8_t data) {
    asm volatile("out %%al, %%dx" : : "a"(data), "d"(port));
}

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

关于中断

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

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

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

CPU和中断
CPU和中断

初始化时钟

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

/* 8253输入频率为1.193182MHz */
// PIT输出Channel 0在x86的I/O ports中编号为0x40
#define TIMER_PORT 0x40
#define FREQ_8253 1193182
 
void
init_timer(void) {
    int counter = FREQ_8253 / HZ;
    // 寄存器(16位Reload register)最高不能超过65536
    assert(counter < 65536);
 
    // 将PIC的0x43 Mode/Command register写为: 00 11 010 0
    // Select channel : Channel 0
    // Access mode : lobyte/hibyte
    // Operating mode : Mode 2 (rate generator), 通常操作系统使用Mode 3(square wave generator)产生IRQ0时钟中断,但使用Mode 2可以得到更精确的时钟频率
    // BCD/Binary mode: 0 = 16-bit binary
    out_byte(TIMER_PORT + 3, 0x34);
 
    // 下面两个写操作写入PIT的Channel 0,设置counter
    // 因为Access mode为lobyte/hibyte,所以两次先后分别写入低8位和高8位
    out_byte(TIMER_PORT + 0, counter % 256);
    out_byte(TIMER_PORT + 0, counter / 256);
}

简单来说,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的控制协定复杂了许多,初始化代码如下:

#define PORT_PIC_MASTER 0x20
#define PORT_PIC_SLAVE  0xA0
#define PORT_PIC_MASTER_COMMAND PORT_PIC_MASTER
#define PORT_PIC_MASTER_DATA (PORT_PIC_MASTER + 1)
#define PORT_PIC_SLAVE_COMMAND PORT_PIC_SLAVE
#define PORT_PIC_SLAVE_DATA (PORT_PIC_SLAVE + 1)
#define IRQ_SLAVE       2
 
/* 初始化8259中断控制器:
 * 硬件中断IRQ从32号开始,自动发送EOI */
void
init_intr(void) {
    out_byte(PORT_PIC_MASTER_DATA, 0xFF);
    out_byte(PORT_PIC_SLAVE_DATA , 0xFF);
 
    // 0x11为PIC初始化请求,PIC接受请求后会等待三条data port上的"initialisation words"
    // 分别设置IRQ的偏移、PIC连接方式和附加信息
    // 初始化MASTER PIC
    out_byte(PORT_PIC_MASTER_COMMAND, 0x11);
    out_byte(PORT_PIC_MASTER_DATA, 32);           // Master PIC vector offset
    out_byte(PORT_PIC_MASTER_DATA, 1 << 2);       // tell Master PIC that there is a slave PIC at IRQ2 (0000 0100)
    out_byte(PORT_PIC_MASTER_DATA, 0x3);          // 0x01 | 0x02, 0x01:8086/88 (MCS-80/85) mode; 0x02:Auto (normal) EOI
    // 初始化SLAVE PIC
    out_byte(PORT_PIC_SLAVE_COMMAND, 0x11);
    out_byte(PORT_PIC_SLAVE_DATA, 32 + 8);        // Slave PIC vector offset
    out_byte(PORT_PIC_SLAVE_DATA, 2);             // tell Slave PIC its cascade identity (0000 0010)
    out_byte(PORT_PIC_SLAVE_DATA, 0x3);           // 0x01 | 0x02, 0x01:8086/88 (MCS-80/85) mode; 0x02:Auto (normal) EOI
 
    out_byte(PORT_PIC_MASTER_COMMAND, 0x68);
    out_byte(PORT_PIC_MASTER_COMMAND, 0x0A);
    out_byte(PORT_PIC_SLAVE_COMMAND, 0x68);
    out_byte(PORT_PIC_SLAVE_COMMAND, 0x0A);
}
}

通过代码你大概也能猜到,现代的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位,其中每个表项的结构如下:

typedef struct GateDescriptor {
    uint32_t offset_15_0      : 16; // offset的低16位
    uint32_t segment          : 16;
    uint32_t pad0             : 8;  // 0
    uint32_t type             : 4;  // Task gate, interrupt gate or trap gate
    uint32_t system           : 1;  // = 0 for interrupt gates.
    uint32_t privilege_level  : 2;  // 调用需要的最低特权等级,防止特权指令被用户空间调用
    uint32_t present          : 1;  // can be set to 0 for unused interrupts or for Paging.
    uint32_t offset_31_16     : 16; // offset的高16位
} GateDescriptor;

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

#define INTERRUPT_GATE_32   0xE
#define TRAP_GATE_32        0xF
 
/* the global IDT list
   in Nanos, each entry of the IDT is either an interrupt gate, or a trap gate */
static GateDesc idt[NR_IRQ];
 
/* setup a interrupt gate for interrupt handlers */
static void set_intr(GateDesc *ptr, uint32_t selector, uint32_t offset, uint32_t dpl) {
	ptr->offset_15_0 = offset & 0xFFFF;
	ptr->segment = selector;
	ptr->pad0 = 0;
	ptr->type = INTERRUPT_GATE_32;
	ptr->system = false;
	ptr->privilege_level = dpl;
	ptr->present = true;
	ptr->offset_31_16 = (offset >> 16) & 0xFFFF;
}
 
/* setup a trap gate for cpu exceptions */
static void set_trap(GateDesc *ptr, uint32_t selector, uint32_t offset, uint32_t dpl) {
	ptr->offset_15_0 = offset & 0xFFFF;
	ptr->segment = selector;
	ptr->pad0 = 0;
	ptr->type = TRAP_GATE_32;
	ptr->system = false;
	ptr->privilege_level = dpl;
	ptr->present = true;
	ptr->offset_31_16 = (offset >> 16) & 0xFFFF;
}
 
void init_idt() {
	int i;
	for (i = 0; i < NR_IRQ; i ++) {
		set_trap(idt + i, SEG_KERNEL_CODE << 3, (uint32_t)irq_empty, DPL_KERNEL);
	}
	set_trap(idt + 0, SEG_KERNEL_CODE << 3, (uint32_t)vec0, DPL_KERNEL);
	set_trap(idt + 1, SEG_KERNEL_CODE << 3, (uint32_t)vec1, DPL_KERNEL);
	set_trap(idt + 2, SEG_KERNEL_CODE << 3, (uint32_t)vec2, DPL_KERNEL);
	set_trap(idt + 3, SEG_KERNEL_CODE << 3, (uint32_t)vec3, DPL_KERNEL);
	set_trap(idt + 4, SEG_KERNEL_CODE << 3, (uint32_t)vec4, DPL_KERNEL);
	set_trap(idt + 5, SEG_KERNEL_CODE << 3, (uint32_t)vec5, DPL_KERNEL);
	set_trap(idt + 6, SEG_KERNEL_CODE << 3, (uint32_t)vec6, DPL_KERNEL);
	set_trap(idt + 7, SEG_KERNEL_CODE << 3, (uint32_t)vec7, DPL_KERNEL);
	set_trap(idt + 8, SEG_KERNEL_CODE << 3, (uint32_t)vec8, DPL_KERNEL);
	set_trap(idt + 9, SEG_KERNEL_CODE << 3, (uint32_t)vec9, DPL_KERNEL);
	set_trap(idt + 10, SEG_KERNEL_CODE << 3, (uint32_t)vec10, DPL_KERNEL);
	set_trap(idt + 11, SEG_KERNEL_CODE << 3, (uint32_t)vec11, DPL_KERNEL);
	set_trap(idt + 12, SEG_KERNEL_CODE << 3, (uint32_t)vec12, DPL_KERNEL);
	set_trap(idt + 13, SEG_KERNEL_CODE << 3, (uint32_t)vec13, DPL_KERNEL);
	set_trap(idt + 14, SEG_KERNEL_CODE << 3, (uint32_t)vec14, DPL_KERNEL);
 
	set_intr(idt+32 + 0, SEG_KERNEL_CODE << 3, (uint32_t)irq0, DPL_KERNEL);
	set_intr(idt+32 + 1, SEG_KERNEL_CODE << 3, (uint32_t)irq1, DPL_KERNEL);
 
	/* the ``idt'' is its virtual address */
	write_idtr(idt, sizeof(idt));
}

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

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

/* 修改IDRT */
static inline void
save_idt(void *addr, uint32_t size) {
    static volatile uint16_t data[3];
    data[0] = size - 1;
    data[1] = (uint32_t)addr;
    data[2] = ((uint32_t)addr) >> 16;
    asm volatile("lidt (%0)" : : "r"(data));
}

对于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寄存器,内核中断响应路径至此结束,控制又交回了中断发生前在运行的进程
.globl irq0;     irq0:  pushl $0;  pushl $1000; jmp asm_do_irq
 
.globl asm_do_irq
.extern irq_handle
asm_do_irq:
    pushl %ds; pushl %es; pushl %fs; pushl %gs
    pushal # 依次把寄存器AX、CX、DX、BX、SP、BP、SI和DI压栈
    movw $SELECTOR_KERNEL(SEG_KERNEL_DATA), %ax
    movw %ax, %ds
    movw %ax, %es
 
    pushl %esp # 作为irq_handle函数的指针参数使用
    call irq_handle
 
    addl $4, %esp
 
    popal
    popl %gs; popl %fs; popl %es; popl %ds
    addl $8, %esp
    iret # ISR(Interrupt Service Routines)必须以iret返回

irq_handle函数:

typedef struct TrapFrame {
    uint32_t edi, esi, ebp, esp_;
    uint32_t ebx, edx, ecx, eax;   // Register saved by pushal
    uint32_t gs, fs, es, ds;       // Segment register
    int32_t  irq;                  // # of irq
    uint32_t err, eip, cs, eflags; // Execution state before trap
} TrapFrame;
 
void irq_handle(struct TrapFrame *tf) {
    if (tf->irq == 1000) {
        printf(".");
    } else if (tf->irq == 1001) {
        uint32_t code = in_byte(0x60);
        uint32_t val = in_byte(0x61);
        out_byte(0x61, val | 0x80);
        out_byte(0x61, val);
        printf("%d\n", code);
    } else {
        if (tf->irq == -1) {
            printf("\nUnhandled exception!\n");
        } else {
            printf("\nUnexpected exception #%d\n", tf->irq);
        }
        assert(0);
    }
}

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

参考资料

Comments