终于见到操作系统的main函数了,在被保护模式下的汇编蹂躏那么久,终于见到我们“可爱”的C语言了,鸡冻不已啊~让我们先一睹操作系统真容
void main(void)
{
time_init();
tty_init();
trap_init();
sched_init();
buffer_init();
hd_init();
sti();
move_to_user_mode();
if (!fork()) {
init();
}
for(;;) pause();
}
是不是顿时有种不明觉厉的赶脚?这是必须的,每个init都对应了操作系统的一个功能模块,在本节我们将先对这些模块进行简单介绍,后续会有相应章节进行模块详解~
- 系统时间模块<1>设置系统开机时间
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)-1;
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);
startup_time = kernel_mktime(&time);
}
这段代码的主要功能就是设置开机启动时间,这边中断还没开启的,所有读取硬件信息的操作都是通过IO Port方式完成的(inb/outp XX,这个地方的XX地址不是内存地址,是总线地址)。这个模块的内容《Linux内核注释》已经有很详细解释,而且应该不存在理解困难问题,后续这个模块就不详解了。
- 系统终端模块<2>完成系统交互界面的初始化
void tty_init(void)
{
rs_init();
con_init();
}
终端:终端拥有一个可以让进程读取字符输入和可以让进程发送字符的显示器。进程和终端间的数据传输和数据处理由终端驱动程序负责,终端驱动程序提供缓冲、编辑和数据转换的功能。接下来我们来看下终端的结构:
struct tty_queue {
//等待队列缓冲区中当前数据指针字符数[??])。对于串口终端,则存放串//行端口地址。
unsigned long data;
//缓冲区中数据头指针。
unsigned long head;
//缓冲区中数据尾指针。
unsigned long tail;
//等待进程列表。
struct task_struct * proc_list;
//队列的缓冲区。
char buf[TTY_BUF_SIZE];
};
struct tty_struct {
//终端 io 属性和控制字符数据结构。
struct termios termios;
//所属进程组。
int pgrp;
//停止标志。
int stopped;
//tty 写函数指针。
void (*write)(struct tty_struct * tty);
//tty 读队列。
struct tty_queue read_q;
//tty 写队列。
struct tty_queue write_q;
//tty 辅助队列(存放规范模式字符序列),可称为规范(熟)模 式队列。
struct tty_queue secondary;
};
用过Linux的应该知道,我们输入指令执行操作,都需要先打开一个终端,就像windows下的命令行窗口,对照上面的结构我们知道终端用字符串数组来保存输入输出数据(结构中的队列,接收键盘或者其他设备的输入),并且拥有写函数(实现屏幕显示或者发送数据给打印机)
void rs_init(void)
{
set_intr_gate(0x24,rs1_interrupt);
set_intr_gate(0x23,rs2_interrupt);
init(tty_table[1].read_q.data);
init(tty_table[2].read_q.data);
outb(inb_p(0x21)&0xE7,0x21);
}
rs_init主要完成串口的输入输出中断门设置,这两个中断程序rs1_interrupt/ rs2_interrupt在kernel/rs_io.s中定义(通过汇编实现同样使用IO Port方式实现,这两个串口我也不知道干嘛的,网上搜了下好像是连猫或者鼠标的),这边我也不是很清楚,想知道实现细节的,可以自己参阅《Linux内核注释》
void con_init(void)
{
register unsigned char a;
gotoxy(*(unsigned char *)(0x90000+510),*(unsigned char *)(0x90000+511));
set_trap_gate(0x21,&keyboard_interrupt);
outb_p(inb_p(0x21)&0xfd,0x21);
a=inb_p(0x61);
outb_p(a|0x80,0x61);
outb(a,0x61);
}
con_init看名字就很属性,这不就是我们的控制台命令行窗口吗? 最老的系统,开机后应该就是字符界面了(由于0.11内核我只是理论学习,而且学后发觉自身目前的内力还不足以驱动这本“九阴真经”,所以还未实际运行来深入研究,以后当内功深厚能驱动九阴真经的时候,我会编译高级内核并推出相关实验记录的),可以看到这边完成键盘中断门的设置,当我们使用键盘输入的时候会自动触发这个中断,完成键盘字符到控制台终端缓存的保存工作。控制台向屏幕写是通过void con_write(struct tty_struct * tty)函数来实现的,这里面的操作都是对应显存的操作(界面显示的东西是对应内存中的一块内存的),我们可以把这块内存看成一个字符数组SCREEN_START[LINES][COLUMNS],其中SCREEN_START对应显存的内存起始地址0xb8000,行和列都是由宏定义的#define LINES 25 #define COLUMNS 80;我们可以把屏幕想象成网格,网格中的网格单元是和二维数组一一对应的,所以我们要在屏幕的多少行多少列打印一个字符,只要将对应行列的二维数组元素设置成这个字符就可以了(同理删除行、字符等操作都是对二维数组中的元素操作)。con_init完成了操作系统交互界面初始化,使得我们能从键盘向操作系统输入,并且操作系统能在屏幕输出我们的输入。(终端我也没太深入研究,所以后续不会有详解,具体内容大家可以自己对照《Linux内核注释》看下源码)
- 系统硬件中断模块<3>这边完成操作系统对硬件错误的中断服务程序的设置
void trap_init(void)
{
int i;
set_trap_gate(0,÷_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);
for (i=17;i<32;i++)
set_trap_gate(i,&reserved);
}
这些中断服务程序的流程基本都是压入自己写的C处理函数的地址,然后Jmp到一套标准的通用汇编版本处理程序,这套标准的程序在堆栈中找到我们压入的C处理函数地址,然后回调执行,下面就是部分中断的服务程序:
_debug:
pushl $_do_int3 # _do_debug
jmp no_error_code
_nmi:
pushl $_do_nmi
jmp no_error_code
_int3:
pushl $_do_int3
jmp no_error_code
_overflow:
pushl $_do_overflow
jmp no_error_code
_bounds:
pushl $_do_bounds
jmp no_error_code
_invalid_op:
pushl $_do_invalid_op
jmp no_error_code
这边的no_error_code就是标准的处理程序之一,还有个error_code版的,大部分硬件中断都是打印出错信息然后死机~不过page_fault是个特例,就是这个中断实现了缺页中断和写时复制技术,也是我们后面在内存管理模块要重点详解的内容
- 系统进程调度模块<4>完成操作系统进程局部描述符和任务描述符设置,初始化任务列表,并设初始化时钟中断和系统调用中断
void sched_init(void)
{
int i;
struct desc_struct * p;
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;
for(i=1;i<NR_TASKS;i++) {
task[i] = NULL;
p->a=p->b=0;
p++;
p->a=p->b=0;
p++;
}
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);
}
①设置任务0(操作系统进程)的任务状态段描述符(任务切换的时候保存切换任务的现场信息)和局部数据表描述符,我们来看看加载了什么东西(init_task.task结构如下,这个是写死在内核代码里的)
#define INIT_TASK \
{
//部分结构已省略
{ \
{0,0}, \
/* ldt */ {0x9f,0xc0fa00}, \
{0x9f,0xc0f200}, \
}, \
/*tss*/ {0,PAGE_SIZE+(long)&init_task,0x10,0,0,0,0,(long)&pg_dir,\
0,0,0,0,0,0,0,0, \
0,0,0x17,0x17,0x17,0x17,0x17,0x17, \
_LDT(0),0x80000000, \
{} \
}, \
}
通过观察我们发现ldt[0]不用,ldt[1]的含义为0x00c0 fa00 0000 009f(代码长640K,基址0x0,G=1,D=1,DPL=3,P=1 TYPE=0x0,不熟悉的结构的可以参见笔记(二)的残卷三),和我们的gdt[1]相比,限长变短了(gdt-8MB ldt-640k,为什么?因为内核代码实际长度小于640k,ldt是跟着进程变的)、权限变了(gdt-0 ldt-3,即内核态和用户态的区别),其他都一样,同理数据段ldt[2]的变化小伙伴可自行推导下,变化也差不多。之所以操作系统进程还要弄个任务0的ldt是因为,操作系统进程也可看做是一个任务,虽然它的所有代码和数据都是在内核代码里面,但是为了便于和其他子进程共用同一套管理和切换代码,所以必须拥有统一的进程结构。
②初始化任务列表,任务列表保存的是指向进程控制块的指针(Linux只允许有64个进程存在,每启动一个进程就会从任务数组中找一个空闲元素,指向新分配一页空闲内存,用以保存进程控制块)。
③设置时钟中断门(进程切换是根据时间片来的,时钟每运行一个周期即一个时间片,就会触发一次中断,在时钟中断程序中,调用操作系统的进程调函数,判断是否需要进行进程切换,详见do_timer函数kernel/sched.c)以及系统调用中断门(用户程序需要系统服务只能通过系统调用,进程在用户态是不能访问内核地址空间的)
- 系统高速缓冲区模块<5>完成操作系统内存和磁盘之间数据交互的中间层的设置
由于主内存是以页为单位的(大小为4k),磁盘是以盘块为单位的(1k),两者单位不同,存储介质也不同,直接交互操作起来效率不是很高,比如读个盘块到内存页,我们要对整个内存页枷锁,而读取操作是非常慢的,这个操作非常影响操作系统效率,于是操作系统引入了高速缓冲区概念,我们来简要看下引入高速缓冲区后上门操作的变化。高速缓冲区中的缓冲块大小为1k,存储介质和主内存一样都是内存,当需要读取一个盘块内容到内存页的时候,只需对一个空闲的高速缓冲块枷锁,内存页不需要加锁依然可以执行其他操作,当高速缓冲块完成磁盘读取操作后,直接将内容拷贝到内存页中,而内存之间拷贝是非常快的,所以引入高速缓冲区是为了提高内存磁盘数据交换的效率。高速缓冲区操作详细内容将在下节介绍,buffer_init也放在下节详解,而且有图有真相!
- 系统磁盘操作模块<6>完成操作系统磁盘操作准备工作的设置
void hd_init(void)
{
int i;
for (i=0 ; i<NR_REQUEST ; i++) {
request[i].hd = -1;
request[i].next = NULL;
}
for (i=0 ; i<NR_HD ; i++) {
hd[i*5].start_sect = 0;
hd[i*5].nr_sects = hd_info[i].head*
hd_info[i].sect*hd_info[i].cyl;
}
set_trap_gate(0x2E,&hd_interrupt);
outb_p(inb_p(0x21)&0xfb,0x21);
outb(inb_p(0xA1)&0xbf,0xA1);
}
初始化磁盘操作请求队列,然后设置好磁盘操作中断服务程序,当有磁盘操作请求的时候就会自动执行这个中断服务程序。
- 操作系统进程由内核态进入用户态<7>开启中断,任务0从内核态转为用户态
sti();
move_to_user_mode();
操作系统微服私访了,它郑重的向大家宣布,自己也是一个进程,要执行内核代码,自己也要遵守相同的规矩(进程标配:用户态---程序代码、程序数据、用户栈和内核态---操作系统代码、操作系统数据、内核栈+TSS),但是内核代码都是它的东西,这样做也只是做做秀、表表态,就像TC一样~具体的做法就是模仿中断调用返回操作
#define move_to_user_mode() \
__asm__ ("movl %%esp,%%eax\n\t" \
"pushl $0x17\n\t" \
"pushl %%eax\n\t" \
"pushfl\n\t" \
"pushl $0x0f\n\t" \
"pushl $1f\n\t" \
"iret\n" \
"1:\tmovl $0x17,%%eax\n\t" \
"movw %%ax,%%ds\n\t" \
"movw %%ax,%%es\n\t" \
"movw %%ax,%%fs\n\t" \
"movw %%ax,%%gs" \
:::"ax")
这段汇编主要是构造如下的栈帧(图片来自《Linux内核注释》),这也是中断调用返回的时候的内核栈情况
对于中断调用和中断调用返回不熟悉的童鞋不要紧,后面在系统调用我会进行详解,现在只是让大家有个初步认识~
- 系统0进程核心代码<8>执行操作系统0进程核心代码,循环等待进程切换
for(;;) pause();
操作系统核心进程居然只是什么事都不做在这死循环?当然不是,别忘了我们前面介绍的时钟中断,每当一个时间片到达的时候,就会触发一次进程切换,如果没有其他进程的时候,就会到0号进程这边来循环等待(所以当然是死循环,不然操作系统就退出了,操作系统退出什么概念,你懂的~),如果一旦有其他进程就会立刻切换到其他进程去执行,毕竟0进程优先级是最低的~
如果有童鞋发觉我好像讲漏了一点代码,那值得表扬下,说明你是认真看了内核源码的,fork函数只有当我们看完操作系统内核所有模块才能理解,所以我将在内核学习笔记的最后再来解释略过的代码~最后用网上的一张描述当前操作系统内存映像的神图来结束本节内容