在上一篇文章中,我们已经完成了bootloader的运行:设置内核段描述符、进入保护模式、载入ELF格式内核,并将控制交给内核代码。
但是,此时操作系统的启动过程并未完全完成,本篇文章接着bootloader完成的工作,还是以Nanos为范例,具体的分析一下操作系统内核的初始化过程(内核初始化所使用的代码可以在Nanos的github找到,见这里)。
内核初始化主要完成了下面这些工作:
- 初始化时钟
- 初始化可编程中断控制器
- 初始化中断描述符表(IDT)
还是从Makefile文件入手,内核的Makefile指定了kernel的生成方式如下:
1 2 |
kernel: $(OBJS) $(LD) $(LDFLAGS) -e entry -Ttext 0x00100000 -o kernel $(OBJS) |
参数的含义是以符号entry代表的函数作为程序入口,并且代码段定位从0x00100000地址开始。也就是说,当bootloader将内核载入到内存后,会将控制交给0x00100000处的代码,即内核入口entry()函数。entry()函数极其简单:
1 2 3 4 5 6 7 8 9 10 11 12 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
/* 读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的设置。关于中断更详细的介绍在这里。
初始化时钟
回到正题,内核接管控制后,我们首先要执行init_timer函数来设置计算机时钟生成的频率,虽然代码乍一看似乎不知所云,但实际上都是硬件通信的简单协定而已:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
/* 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进行处理,如下图所示:
因此在内核启动初期,我们必须对PIC进行初始化,使PIC可以按照内核期望的方式处理硬件中断。初始化PIC的原理和初始化PIT的原理相同,只是PIC的控制协定复杂了许多,初始化代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
#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级联组成,就像这样:
初始化代码中首先告知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位,其中每个表项的结构如下:
1 2 3 4 5 6 7 8 9 10 |
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设置中断描述符表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
#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寄存器中:
1 2 3 4 5 6 7 8 9 |
/* 修改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)); } |
中断处理
当完成了上面这些繁杂初始化工作后,Nanos的内核初始化工作便完成了!之后entry函数打开了硬件中断,进入死循环并开始响应硬件中断。最后让我们来看看Nanos响应中断的具体过程吧:
- 当一个时钟中断由我们设置的PIT发出,PIC收到IRQ 0,因为我们所设置的偏移量,PIC会向CPU发出32号中断
- 当CPU执行完当前的语句后发现了PIC送达的中断,此时硬件首先将eflags、cs、eip寄存器依次压栈
- 通过中断描述符表找到中断处理函数irq0(),修改cs、eip寄存器指向irq0()的线性地址,修改eflags的IF位(因为这是一个中断门),转至irq0()函数执行中断处理
- irq0()函数见下方代码,irq0以汇编的形式定义,首先压栈0作为error code(注:之所以这么做是因为当发生部分中断时会由硬件自动压栈一个error code,而部分中断硬件没有这么做,为了统一栈中的空间布局,硬件没有自动压栈error code的中断Nanos在中断处理函数中手动压栈0作为error code,Linux中也是这样实现的),再压栈一个内部定义的中断码,然后jmp至asm_do_irq函数
- asm_do_irq函数中首先保存了大量的寄存器现场信息,之后修改段基址寄存器,将运行环境切换至内核空间,最后压栈sp指针作为参数调用C函数irq_handle
- irq_handle函数的参数为保存的sp指针,实际上指向了Nanos的TrapFrame(保存了进程的寄存器上下文),irq_handle通过tf指针访问之前压栈的中断码,根据不同的中断码做出响应处理中断
- 当irq_handle返回到asm_do_irq后,汇编代码继而执行大量的pop指令对保存的进程寄存器上下文进行恢复,最后通过iret语句恢复最初由硬件压栈的eflags、cs、eip寄存器,内核中断响应路径至此结束,控制又交回了中断发生前在运行的进程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
.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函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
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的实现进行记录。
坚持不懈:)
参考资料
- OSDev Wiki
- Nanos课程主页
- Developing Your Own OS On IBM PC –Darwin yuan
- 《现代操作系统》
- 《深入理解Linux内核》