整个操作系统就30行代码
前几篇把进入 main 方法前的苦力工作都完成了,我们的程序终于跳到第一个由 c 语言写的,也是操作系统的全部代码骨架的地方,就是 main.c 文件里的 main 方法:
void main(void) {
ROOT_DEV = ORIG_ROOT_DEV;
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;
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()) {
init();
}
for(;;) pause();
}
总共也就30行代码,但这的确是操作系统启动流程的全部秘密了,我用空格将这个代码分成了几个部分。
第一部分(L2-L14)是一些参数的取值和计算,包括根设备 ROOT_DEV,之前在汇编语言中获取的各个设备的参数信息 drive_info,以及通过计算得到的内存边界
main_memory_start
main_memory_end
buffer_memory_start
buffer_memory_end
从哪获得之前的设备参数信息呢?如果你前面看了,那一定还记得那个内存表,都是由 setup.s 这个汇编程序调用 BIOS 中断获取的各个设备的信息,并保存在约定好的内存地址 0x90000 处,现在这不就来取了么,我就不赘述了。
第二部分(L16-L25)是各种初始化 init 操作,包括内存初始化 mem_init,中断初始化 trap_init、进程调度初始化 sched_init 等等。我们知道学操作系统知识的时候,其实就分成这么几块来学的,看来在操作系统源码上看,也确实是这么划分的
第三部分(L27-L33)是切换到用户态模式,并在一个新的进程中做一个最终的初始化 init,这个 init 函数里会创建出一个进程,设置终端的标准 IO,并且再创建出一个执行 shell 程序的进程用来接受用户的命令,到这里其实就出现了我们熟悉的画面(下面是 bochs 启动 Linux 0.11 后的画面)

第四部分(L33)是个死循环,如果没有任何任务可以运行,操作系统会一直陷入这个死循环无法自拔
这里再放上目前的内存布局图:

这个图大家一定要牢记在心,操作系统说白了就是在内存中放置各种的数据结构,来实现“管理”的功能。所以之后我们的学习过程,主心骨其实就是看看,操作系统在经过一番折腾后,又在内存中建立了什么数据结构,而这些数据结构后面又是如何用到的。
比如进程管理,就是在内存中建立好多复杂的数据结构用来记录进程的信息,再配合上进程调度的小算法,完成了进程这个强大的功能。
为了让大家目前心里有个底,我们把前面的工作再再再再在这里做一个回顾,用一张图表示就是:

看到了吧,我们已经把 boot 文件夹下的三个汇编文件的全部代码都一行一行品读过了,其主要功能就是三张表的设置:全局描述符表、中断描述符表、页表。同时还设置了各种段寄存器,栈顶指针。并且,还为后续的程序提供了设备信息,保存在 0x90000 处往后的几个位置上。最后,一个华丽的跳转,将程序跳转到了 main.c 文件里的 main 函数中。
内存先划分三个边界值
本节就讲man函数的第一部分(L2-L14)。
首先,ROOT_DEV 为系统的根文件设备号,drive_info 为之前 setup.s 程序获取并存储在内存 0x90000 处的设备信息,我们先不管这俩,等之后用到了再说。
这一坨代码(L4-L14)虽然很乱,但仔细看就知道它只是为了计算出三个变量罢了
main_memory_start
memory_end
buffer_memory_end
而观察最后一行代码发现,其实main_memory_start和memory_end两个变量是相等的,所以其实仅仅计算出了两个变量。
然后再具体分析这个逻辑,其实就是一堆 if else 判断而已,判断的标准都是 memory_end 也就是内存最大值的大小,而这个内存最大值由第一行代码可以看出,是等于 1M + 扩展内存大小。
那 ok 了,其实就只是针对不同的内存大小,设置不同的边界值罢了,为了理解它,我们完全没必要考虑这么周全,就假设总内存一共就 8M 大小吧。
那么如果内存为 8M 大小,memory_end 就是8 * 1024 * 1024,也就只会走倒数第二个分支,那么 buffer_memory_end 就为2 * 1024 * 1024,那么main_memory_start也为2 * 1024 * 1024
那这些值有什么用呢?一张图就给你说明白了:

你看,其实就是定了三个箭头所指向的地址的三个边界变量,具体主内存区是如何管理和分配的,要看
mem_init(main_memory_start, memory_end);
而缓冲区是如何管理和分配的,就要看
buffer_init(buffer_memory_end);
主内存初始化mem_init
一张表管理内存,进入mem_init函数:
#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
static long HIGH_MEMORY = 0;
static unsigned char mem_map[PAGING_PAGES] = { 0, };
// start_mem = 2 * 1024 * 1024
// end_mem = 8 * 1024 * 1024
void mem_init(long start_mem, long end_mem)
{
int i;
HIGH_MEMORY = end_mem;
for (i=0 ; i<PAGING_PAGES ; i++)
mem_map[i] = USED;
i = MAP_NR(start_mem);
end_mem -= start_mem;
end_mem >>= 12;
while (end_mem-->0)
mem_map[i++]=0;
}
仔细一看这个方法,其实折腾来折腾去,就是给一个 mem_map数组的各个位置上赋了值,而且显示全部赋值为 USED 也就是 100,然后对其中一部分又赋值为了 0。
赋值为 100 的部分就是 USED,也就表示内存被占用,如果再具体说是占用了 100 次,这个之后再说。剩下赋值为 0 的部分就表示未被使用,也即使用次数为零。
是不是很简单?就是准备了一个表,记录了哪些内存被占用了,哪些内存没被占用。这就是所谓的“管理”,并没有那么神乎其神。
那接下来自然有两个问题,每个元素表示占用和未占用,这个表示的范围是多大?初始化时哪些地方是占用的,哪些地方又是未占用的?
还是一张图就看明白了,我们仍然假设内存总共只有 8M

可以看出,初始化完成后,其实就是 mem_map 这个数组的每个元素都代表一个 4K 内存是否空闲(准确说是使用次数)。
-
4K 内存通常叫做 1 页内存,而这种管理方式叫分页管理,就是把内存分成一页一页(4K)的单位去管理。
-
1M 以下的内存这个数组干脆没有记录,这里的内存是无需管理的,或者换个说法是无权管理的,也就是没有权利申请和释放,因为这个区域是内核代码所在的地方,不能被“污染”。
-
1M 到 2M 这个区间是缓冲区,2M 是缓冲区的末端,缓冲区的开始在哪里之后再说,这些地方不是主内存区域,因此直接标记为 USED,产生的效果就是无法再被分配了。
-
2M 以上的空间是主内存区域,而主内存目前没有任何程序申请,所以初始化时统统都是零,未来等着应用程序去申请和释放这里的内存资源。
那应用程如何申请内存呢?我们本节不展开,不过我们简单展望一下,看看申请内存的过程中,是如何使用 mem_map 这个结构的。
在 memory.c 文件中有个函数 get_free_page(),用于在主内存区中申请一页空闲内存页,并返回物理内存页的起始地址。
比如我们在 fork 子进程的时候,会调用 copy_process 函数来复制进程的结构信息,其中有一个步骤就是要申请一页内存,用于存放进程结构信息 task_struct:
int copy_process(...) {
struct task_struct *p;
...
p = (struct task_struct *) get_free_page();
...
}
我们看 get_free_page 的具体实现,是内联汇编代码,看不懂不要紧,注意它里面就有 mem_map 结构的使用
unsigned long get_free_page(void) {
register unsigned long __res asm("ax");
__asm__(
"std ; repne ; scasb\n\t"
"jne 1f\n\t"
"movb $1,1(%%edi)\n\t"
"sall $12,%%ecx\n\t"
"addl %2,%%ecx\n\t"
"movl %%ecx,%%edx\n\t"
"movl $1024,%%ecx\n\t"
"leal 4092(%%edx),%%edi\n\t"
"rep ; stosl\n\t"
"movl %%edx,%%eax\n"
"1:"
:"=a" (__res)
:"0" (0),"i" (LOW_MEM),"c" (PAGING_PAGES),
"D" (mem_map + PAGING_PAGES-1)
:"di","cx","dx");
return __res;
}
就是选择 mem_map 中首个空闲页面,并标记为已使用
本节只是填写了一张大表而已,之后的内存申请与释放等骚操作,统统是跟着张大表 mem_map 打交道而已。
中断初始化trap_init
当你的计算机刚刚启动时,你按下键盘是不生效的,但是过了一段时间后,再按下键盘就有效果了:

就来刨根问底一下,到底过了多久之后,按下键盘才有效果呢?
当然首先你得知道,按下键盘后会触发中断,CPU 收到你的键盘中断后,根据中断号,寻找由操作系统写好的键盘中断处理程序(中断的原理和过程不了解的可以看认认真真的聊聊中断)。
这个中断处理程序会把你的键盘码放入一个队列中,由相应的用户程序或内核程序读取,并显示在控制台,或者其他用途,这就代表你的键盘生效了。
不过放宽心,我们不展开讲这个中断处理程序以及用户程序读取键盘码后的处理细节,我们把关注点放在,究竟是什么时候,按下键盘才会有这个效果。
我们以 Linux 0.11 源码为例,发现进入内核的 main 函数后不久,有这样一行代码(L11):
trap_init();
看到这个方法的全部代码后,你可能会会心一笑,也可能一脸懵逼:
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<48;i++)
set_trap_gate(i,&reserved);
set_trap_gate(45,&irq13);
set_trap_gate(39,¶llel_interrupt);
}
这啥玩意?这么多 set_xxx_gate。有密集恐惧症的话,绝对看不下去这个代码,所以我就给他简化一下。把相同功能的去掉:
void trap_init(void) {
int i;
// set 了一堆 trap_gate
set_trap_gate(0, ÷_error);
...
// 又 set 了一堆 system_gate
set_system_gate(45, &bounds);
...
// 又又批量 set 了一堆 trap_gate
for (i=17;i<48;i++)
set_trap_gate(i, &reserved);
...
}
这就简单多了,我们一块一块看。
首先我们看 set_trap_gate 和 set_system_gate 这俩货,发现了这么几个宏定义
#define _set_gate(gate_addr,type,dpl,addr) \
__asm__ ("movw %%dx,%%ax\n\t" \
"movw %0,%%dx\n\t" \
"movl %%eax,%1\n\t" \
"movl %%edx,%2" \
: \
: "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \
"o" (*((char *) (gate_addr))), \
"o" (*(4+(char *) (gate_addr))), \
"d" ((char *) (addr)),"a" (0x00080000))
#define set_trap_gate(n,addr) \
_set_gate(&idt[n],15,0,addr)
#define set_system_gate(n,addr) \
_set_gate(&idt[n],15,3,addr)
这俩都是最终指向了相同的另一个宏定义 _set_gate,说明是有共性的。
啥共性呢?我直接说吧,那段你完全看不懂的代码,是将汇编语言嵌入到 c 语言了,这种内联汇编的格式非常恶心,所以我也不想搞懂它,最终的效果就是在中断描述符表中插入了一个中断描述符。中断描述符表还记得吧,英文叫 idt
[
这段代码就是往这个 idt 表里一项一项地写东西,其对应的中断号就是第一个参数,中断处理程序就是第二个参数。
产生的效果就是,之后如果来一个中断后,CPU 根据其中断号,就可以到这个中断描述符表 idt 中找到对应的中断处理程序了。比如这个:
set_trap_gate(0,÷_error);
就是设置 0 号中断,对应的中断处理程序是 divide_error。等 CPU 执行了一条除零指令的时候,会从硬件层面发起一个 0 号异常中断,然后执行由我们操作系统定义的 divide_error 也就是除法异常处理程序,执行完之后再返回。
再比如这个:
set_system_gate(5,&overflow);
就是设置 5 号中断,对应的中断处理程序是 overflow,是边界出错中断。
TIPS:这个 system 与 trap 的区别仅仅在于,设置的中断描述符的特权级不同,前者是 0(内核态),后者是 3(用户态),这块展开将会是非常严谨的、绕口的、复杂的特权级相关的知识,不明白的话先不用管,就理解为都是设置一个中断号和中断处理程序的对应关系就好了。
再往后看,批量操作这里:
void trap_init(void) {
...
for (i=17;i<48;i++)
set_trap_gate(i,&reserved);
...
}
17 到 48 号中断都批量设置为了 reserved 函数,这是暂时的,后面各个硬件初始化时要重新设置好这些中断,把暂时的这个给覆盖掉,此时你留个印象。
所以整段代码执行下来,内存中那个 idt 的位置会变成如下的样子

好了,我们看到了设置中断号与中断处理程序对应的地方,那这行代码过去后,键盘好使了么?
NO
键盘产生的中断的中断号是 0x21,此时这个中断号还仅仅对应着一个临时的中断处理程序 &reserved,我们接着往后看。
在这行代码往后几行,还有这么一行代码
void main(void) {
...
trap_init();
...
tty_init();
...
}
void tty_init(void) {
rs_init();
con_init();
}
void con_init(void) {
...
set_trap_gate(0x21,&keyboard_interrupt);
...
}
注意到 trap_init 后有个 tty_init,最后根据调用链,会调用到一行添加 0x21 号中断处理程序的代码,就是刚刚熟悉的 set_trap_gate。而后面的 keyboard_interrupt 根据名字也可以猜出,就是键盘的中断处理程序嘛!
好了,那我们终于找到大案了,就是从这一行代码开始,我们的键盘生效了!没错,不过还有点小问题,不过不重要,就是我们现在的中断处于禁用状态,不论是键盘中断还是其他中断,通通都不好使。而 main 方法继续往下读,还有一行这个东西:
void main(void) {
...
trap_init();
...
tty_init();
...
sti();
...
}
sti 最终会对应一个同名的汇编指令 sti,表示允许中断。所以这行代码之后,键盘才真正开始生效

块设备请求项初始化 blk_dev_init
读取硬盘数据到内存中,是操作系统的一个基础功能。读取硬盘前需要哪些准备工作呢?需要有块设备驱动程序,而以文件的方式来读取则还要在上面包一层文件系统。把读出来的数据放到内存,就涉及到内存中缓冲区的管理。
上面说的每一件事,都是一个十分庞大的体系,我们这节一个都不展开讲,我们就讲讲,读取块设备与内存缓冲区之间的桥梁,块设备请求项的初始化工作。
我们以 Linux 0.11 源码为例,发现进入内核的 main 函数后不久,有这样一行代码
void main(void) {
...
blk_dev_init();
...
}
这个方法如下:
void blk_dev_init(void) {
int i;
for (i=0; i<32; i++) {
request[i].dev = -1;
request[i].next = NULL;
}
}
就是给 request 这个数组的前 32 个元素的两个变量 dev 和 next 附上值,看这俩值 -1 和 NULL 也可以大概猜出,这是没有任何作用时的初始化值。
我们看下 request 结构体
/*
* Ok, this is an expanded form so that we can use the same
* request for paging requests when that is implemented. In
* paging, 'bh' is NULL, and 'waiting' is used to wait for
* read/write completion.
*/
struct request {
int dev; /* -1 if no request */
int cmd; /* READ or WRITE */
int errors;
unsigned long sector;
unsigned long nr_sectors;
char * buffer;
struct task_struct * waiting;
struct buffer_head * bh;
struct request * next;
};
这个 request 结构,这个结构就代表了一次读盘请求,其中:
-
dev表示设备号,-1 就表示空闲。 -
cmd表示命令,其实就是 READ 还是 WRITE,表示本次操作是读还是写。 -
errors表示操作时产生的错误次数。 -
sector表示起始扇区。 -
nr_sectors表示扇区数。 -
buffer表示数据缓冲区,也就是读盘之后的数据放在内存中的什么位置。 -
waiting是个task_struct结构,这可以表示一个进程,也就表示是哪个进程发起了这个请求。 -
bh是缓冲区头指针,这个后面讲完缓冲区就懂了,因为这个 request 是需要与缓冲区挂钩的。 -
next指向了下一个请求项
比如读请求时,cmd 就是 READ,sector 和 nr_sectors 这俩就定位了所要读取的块设备(可以简单先理解为硬盘)的哪几个扇区,buffer 就定位了这些数据读完之后放在内存的什么位置。这四个参数是不是就能完整描述了一个读取硬盘的需求了?而且完全没有歧义,就像下面这样

而其他的参数,肯定是为了更好地配合操作系统进行读写块设备操作嘛,为了把多个读写块设备请求很好地组织起来。这个组织不但要有这个数据结构中 hb 和 next 等变量的配合,还要有后面的电梯调度算法的配合。
总之,这个 request 结构可以完整描述一个读盘操作。然后那个 request 数组就是把它们都放在一起,并且它们又通过 next 指针串成链表

本节讲述的两行代码,其实就完成了上图所示的工作而已。现在简单展望一下,后面读盘的全流程中,是怎么用到刚刚初始化的这个 request[32] 结构的。
读操作的系统调用函数是 sys_read,源代码很长,我给简化一下,仅仅保留读取普通文件的分支,就是如下的样子
int sys_read(unsigned int fd,char * buf,int count) {
struct file * file = current->filp[fd];
struct m_inode * inode = file->f_inode;
// 校验 buf 区域的内存限制
verify_area(buf,count);
// 仅关注目录文件或普通文件
return file_read(inode,file,buf,count);
}
入参 fd 是文件描述符,通过它可以找到一个文件的 inode,进而找到这个文件在硬盘中的位置

另两个入参 buf 就是要复制到的内存中的位置,count就是要复制多少个字节,很好理解。
钻到 file_read 函数里继续看
int file_read(struct m_inode * inode, struct file * filp, char * buf, int count) {
int left,chars,nr;
struct buffer_head * bh;
left = count;
while (left) {
if (nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE)) {
if (!(bh=bread(inode->i_dev,nr)))
break;
} else
bh = NULL;
nr = filp->f_pos % BLOCK_SIZE;
chars = MIN( BLOCK_SIZE-nr , left );
filp->f_pos += chars;
left -= chars;
if (bh) {
char * p = nr + bh->b_data;
while (chars-->0)
put_fs_byte(*(p++),buf++);
brelse(bh);
} else {
while (chars-->0)
put_fs_byte(0,buf++);
}
}
inode->i_atime = CURRENT_TIME;
return (count-left)?(count-left):-ERROR;
}
整体看,就是一个 while 循环,每次读入一个块的数据,直到入参所要求的大小全部读完为止。直接看 bread 那一行
int file_read(struct m_inode * inode, struct file * filp, char * buf, int count) {
...
while (left) {
...
if (!(bh=bread(inode->i_dev,nr)))
}
}
这个函数就是去读某一个设备的某一个数据块号的内容,展开进去看
struct buffer_head * bread(int dev,int block) {
struct buffer_head * bh = getblk(dev,block);
if (bh->b_uptodate)
return bh;
ll_rw_block(READ,bh);
wait_on_buffer(bh);
if (bh->b_uptodate)
return bh;
brelse(bh);
return NULL;
}
其中 getblk 先申请了一个内存中的缓冲块,然后 ll_rw_block 负责把数据读入这个缓冲块,进去继续看
void ll_rw_block(int rw, struct buffer_head * bh) {
...
make_request(major,rw,bh);
}
static void make_request(int major,int rw, struct buffer_head * bh) {
...
if (rw == READ)
req = request+NR_REQUEST;
else
req = request+((NR_REQUEST*2)/3);
/* find an empty request */
while (--req >= request)
if (req->dev<0)
break;
...
/* fill up the request-info, and add it to the queue */
req->dev = bh->b_dev;
req->cmd = rw;
req->errors=0;
req->sector = bh->b_blocknr<<1;
req->nr_sectors = 2;
req->buffer = bh->b_data;
req->waiting = NULL;
req->bh = bh;
req->next = NULL;
add_request(major+blk_dev,req);
}
这里就用到了刚刚说的结构咯。具体说来,就是该函数会往刚刚的设备的请求项链表 request[32] 中添加一个请求项,只要 request[32] 中有未处理的请求项存在,都会陆续地被处理,直到设备的请求项链表是空为止。
具体怎么读盘,就是与硬盘 IO 端口进行交互的过程了,可以继续往里跟,直到看到一个 hd_out 函数为止,本节不展开了。
具体读盘操作,后面会有详细的章节展开讲解,本节你只需要知道,我们在 main 函数的 init 系列函数中,通过 · 为后面的块设备访问,提前建立了一个数据结构,作为访问块设备和内存缓冲区之间的桥梁,就可以了。
本文深入解析操作系统启动过程中的内存管理、中断初始化和块设备请求项初始化。主要内容包括计算内存边界值、主内存初始化、中断处理程序设置以及块设备请求链表的建立。通过对相关代码的分析,阐述了操作系统如何准备内存分配表、设置中断处理程序以及建立块设备读取的数据结构,为后续的系统运行打下基础。
926

被折叠的 条评论
为什么被折叠?



