之前所说的相当于是给访问内存的方式,做了个初步规划,包括去哪找代码、去哪找数据、去哪找栈,以及如何通过分段和分页机制将逻辑地址转换为最终的物理地址。
目前的内存分布图
现在开始正式进入 main 函数分析。
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 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(); }
参数取值与运算 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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; ... }
根设备 ROOT_DEV
,各设备的参数信息 drive_info
,以及计算得到的内存边界main_memory_start
、main_memory_end
、buffer_memory_start
、buffer_memory_end
。
具体说说内存边界的计算。
1 2 3 4 5 6 7 8 9 10 11 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;
整理一下可以发现,计算的就是三个变量:main_memory_start
、memory_end
、buffer_memory_end
。又因为最后一行 main_memory_start = buffer_memory_end
,所以最后其实只计算了两个变量buffer_memory_end
、memory_end
。
具体逻辑就是一堆以 memory_end 为标准的 if else 判断,也就是内存的最大值,通过memory_end = (1<<20) + (EXT_MEM_K<<10)
可以看出,内存最大值等于 1M + 扩展内存大小
。
所以这里说白了就是根据不同内存的大小,设置不同的边界值 。
我们假设内存为 8 M 大小,,那么 memory_end 就是 8 * 1024 * 1024,buffer_memory_end = main_memory_start = 2 * 1024 * 1024。用一张图表示就是:
获得之前的设备参数信息的途径之前也说过,都是由 setup.s 这个汇编程序调用 BIOS 中断获取的各个设备的信息,并保存在以 0x90000 为首地址的内存处。
内存地址
长度(字节)
名称
0x90000
2
光标位置
0x90002
2
扩展内存数
0x90004
2
显示页面
0x90006
1
显示模式
0x90007
1
字符列数
0x90008
2
未知
0x9000A
1
显示内存
0x9000B
1
显示状态
0x9000C
2
显卡特性参数
0x9000E
1
屏幕行数
0x9000F
1
屏幕列数
0x90080
16
硬盘1参数表
0x90090
16
硬盘2参数表
0x901FC
2
根设备号
初始化 init 操作 上一节说白了就是给主内存、缓冲区、内核程序定义了三个边界 。每个区具体怎么定义的还得看这一节。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 void main (void ) { ... 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(); ... }
包括内存初始化 mem_init ,中断初始化 trap_init 、进程调度初始化 sched_init 等等。
mem_init() 具体主内存区怎么管理和分配就要看 mem_init 里干了啥了。
管理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 #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 , };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,表示被占用 100 次,赋值为 0 表示未被占用;说白了就是准备了一个表,记录内存中哪些被占用了哪些没被占用 。这就是所谓的管理。
那么问题来了,数组能表示的范围有多大?初始化时那些地方时占用的,哪些地方又是未占用的?
用一张图就可以解释,我们假设内存总共只有 8M。
可以看出,初始化完成后,其实就是 mem_map 这个数组每个元素都代表一个 4k 内存是否空闲(准确来说是使用次数)。
4k 内存叫做 1页 内存,将内存分成一页一页(4k)的单位去管理,也就是分页管理 。
1M 以下的内存是没有被管理的,因为这里是内核代码所在的地方,没有权限管理。
1M ~ 2M 是缓冲区 ,2M 是缓冲区的末端,不是主内存区域,所以被标记为 USED ,表示无法再被分配。
2M 以上就是主内存区域 ,初始化时都是 0 。
简单讲一下程序是怎么申请内存,以及是怎么使用 mem_map 这个结构的:
在 memory.c 文件中有个函数 **get_free_page()**,用于在主内存区中申请一页空闲内存页,并返回物理内存页的起始地址。
比如我们在 fork 子进程的时候,会调用 copy_process 函数来复制进程的结构信息,其中有一个步骤就是要申请一页内存 ,用于存放进程结构信息 task_struct。
1 2 3 4 5 6 int copy_process (...) { struct task_struct *p ; ... p = (struct task_struct *) get_free_page(); ... }
我们看 get_free_page 的具体实现,是内联汇编代码,看不懂不要紧,注意它里面就有 mem_map 结构的使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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_init() 干的事:用 men_map 结构记录内存中哪些被占用哪些没被占用来管理主内存区
trap_init() 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 void main (void ) { ... 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); 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 是啥意思呢,得先去看看它们的宏定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 #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_trap_gate 和 set_system_gate 都指向了另一个宏定义 _set_gate ,所以现在具体看一看 _set_gate,笑死根本看不懂,只需要知道结果就行,最终效果是在中断描述符表中插入了一个中断描述符 。
所以上面那一段代码就是往中断描述符表里插入一个又一个中断描述符 ,第一个参数是中断号,第二个参数是中断处理程序地址。
set_trap_gate 和 set_system_gate 区别又是什么呢?这里只简单讲一下,区别只在于中断描述符的特权级不同,0 表示内核态,3 表示用户态。
再然后就是用 for 循环的一个批量赋值操作:
1 2 3 4 5 6 void trap_init (void ) { ... for (i=17 ;i<48 ;i++) set_trap_gate(i,&reserved); ... }
17 到 47 号都设置为了 reserved 函数,这只是暂时给它赋了这个值,之后各个硬件初始化会重新设置这些中断。
所以现在内存图长这样:
总结一下 trap_init() 干的事:完善了 idt 表,其中 0~16
中断号被设置为了相对应的中断处理程序,17~47
中断号被设置为了临时函数 reserved 。
blk_dev_init() 块设备初始化,块设备是 I/O 设备中的一类,是将信息存储在固定大小的块中,每个块都有自己的地址,还可以在设备的任意位置读取一定长度的数据,例如硬盘,U盘,SD卡等。
讲人话就是读取这些设备之前做的准备工作。
1 2 3 4 5 6 7 8 9 10 11 12 13 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 初始化。
看一下 request 结构体,这个结构体可以完整描述一个读盘操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 struct request { int dev; int cmd; int errors; unsigned long sector; unsigned long nr_sectors; char * buffer; struct task_struct * waiting ; struct buffer_head * bh ; struct request * next ; };
dev:设备号,-1 表示空闲
cmd:命令,READ & WRITE,表示本次操作是读或者是写
errors:操作时产生的错误次数
sector:起始扇区
nr_sectors:扇区数
buffer:数据缓冲区,也就是读盘后数据放在内存中的位置
waiting:task_struct 结构,表示发起请求的进程
bh:缓冲区头指针
next:指向下一个请求项的指针
request 数组就相当于把 32 个 request 结构体集成,然后用 next 指针串成链表。
简单说一下系统是怎么用到 request[32] 这个结构的:
读操作的系统调用函数是 sys_read,简化一下就是如下样子
1 2 3 4 5 6 7 8 int sys_read (unsigned int fd,char * buf,int count) { struct file * file = current->filp[fd]; struct m_inode * inode = file->f_inode; verify_area(buf,count); return file_read(inode,file,buf,count); }
第二行的 fd 是文件描述符,通过它可以找到一个文件的 inode,进而找到这个文件在硬盘中的位置。
file_read
函数第一、二个参数也可以从图中对应,第三个参数 buf 指要复制到的内存的地址,第四个参数 count 指要赋值的字节数。
继续看看 file_read 函数源码
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 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 那一行,这个函数就是去读某一个设备的某一个数据块号的内容
1 2 3 4 5 6 7 8 9 10 11 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 负责把数据读入这个缓冲块。
跟进
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 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 ); while (--req >= request) if (req->dev<0 ) break ; ... 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 结构。
具体说来,就是该函数会往刚刚的设备的请求项链表 request[32] 中添加一个请求项,只要 request[32] 中有未处理的请求项存在,都会陆续地被处理,直到设备的请求项链表是空为止。
所有 request[32] 是块设备驱动程序 和内存缓冲区 的桥梁,通过它可以完整地表示一个块设备读写操作要做的事。
tty_init() 执行完成后,我们会具备键盘输入到显示器输出字符这个最常用的功能。
1 2 3 4 5 6 7 8 9 10 void main (void ) { ... tty_init(); ... } -------------------------------------------- void tty_init (void ) { rs_init(); con_init(); }
先看第一个函数 rs_init()
1 2 3 4 5 6 7 8 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 ); }
串口中断的开启,以及设置对应的中断处理程序,串口已经很少用到了,所以直接忽略。
再看第二个函数 con_init()
先将大致框架写出:
1 2 3 4 5 6 7 8 9 10 11 12 13 void con_init (void ) { ... if (ORIG_VIDEO_MODE == 7 ) { ... if ((ORIG_VIDEO_EGA_BX & 0xff ) != 0x10 ) {...} else {...} } else { ... if ((ORIG_VIDEO_EGA_BX & 0xff ) != 0x10 ) {...} else {...} } ... }
一大堆 if else,这是为了应对不同的显示模式,来分配不同的变量值。那么我们就只需要看一个,便能推出其他所有。
显示模式是什么呢?或者追溯本源,一个字符是怎么显示在屏幕上的呢?操作系统和 CPU 等硬件设备为了这件事都干了啥呢?
之前有说过,设备会将自己要处理的数据映射到内存上。
从图中可以看出,内存中有一部分区域是,是和显存映射的。说人话就是,往这些内存区域写数据,相当于写在了显存中,往显存中写数据,就相当于在屏幕上写数据。
举个例子
当我们写这样一行汇编代码
就会在屏幕中输出h
这个字符。
0xB8000
指的是什么呢,这里只浅说一下,这片内存是每两个字节表示一个显示在屏幕上的字符,第一个是字符的编码,第二个是字符的颜色 。(不理解也么事)
所以现在代码就可以简化成这个样子
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 #define ORIG_X (*(unsigned char *)0x90000) #define ORIG_Y (*(unsigned char *)0x90001) void con_init (void ) { register unsigned char a; video_num_columns = (((*(unsigned short *)0x90006 ) & 0xff00 ) >> 8 ); video_size_row = video_num_columns * 2 ; video_num_lines = 25 ; video_page = (*(unsigned short *)0x90004 ); video_erase_char = 0x0720 ; video_mem_start = 0xb8000 ; video_port_reg = 0x3d4 ; video_port_val = 0x3d5 ; video_mem_end = 0xba000 ; origin = video_mem_start; scr_end = video_mem_start + video_num_lines * video_size_row; top = 0 ; bottom = video_num_lines; gotoxy(ORIG_X, ORIG_Y); 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 ); }
之前操作系统用汇编在内存中存了很多数据:
内存地址
长度(字节)
名称
0x90000
2
光标位置
0x90002
2
扩展内存数
0x90004
2
显示页面
0x90006
1
显示模式
0x90007
1
字符列数
0x90008
2
未知
0x9000A
1
显示内存
0x9000B
1
显示状态
0x9000C
2
显卡特性参数
0x9000E
1
屏幕行数
0x9000F
1
屏幕列数
0x90080
16
硬盘1参数表
0x90090
16
硬盘2参数表
0x901FC
2
根设备号
第一部分:获取 0x90006
处的数据,对应着表就是获取显示模式。
第二部分:显存映射的内存地址范围。
第三部分:设置一些滚动屏幕时需要的参数,定义顶行(第一行)和底行(最后一行)是哪里
第四部分:把光标定位到之前保存的光标位置处(内存 0x90000
处数据),设置并开启中断。
开启键盘中断后,每敲击一个按键就会触发一次中断,中断程序就会把键盘码转换成 ASCII 码,然后写到光标处的内存地址,也就相当于往显存写,于是这个键盘敲击的字符就显示在了屏幕上。
这一切具体是怎么做到的呢?
从整段代码调用第一个函数开始看起
跟进
1 2 3 4 5 6 static inline void gotoxy (unsigned int new_x,unsigned int new_y) { ... x = new_x; y = new_y; pos = origin + y*video_size_row + (x<<1 ); }
x
表示列数;y
表示行数;pos
表示根据行号和列号计算出来的内存指针,也就是往坐标(x,y)
所在的地址里写数据。
当按下键盘,触发键盘中断,程序调用链如下
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 _keyboard_interrupt: ... call _do_tty_interrupt ... void do_tty_interrupt (int tty) { copy_to_cooked(tty_table+tty); } void copy_to_cooked (struct tty_struct * tty) { ... tty->write(tty); ... } void con_write (struct tty_struct * tty) { ... __asm__("movb _attr,%%ah\n\t" "movw %%ax,%1\n\t" ::"a" (c),"m" (*(short *)pos) :"ax" ); pos += 2 ; x++; ... }
直接看 con_write
内联汇编处,把键盘输入的字符c
写入poc
指针指向的内存,相当于在屏幕上输出。
之后的pos += 2
和x++
,相当于改变pos
的值,也就是改变光标。
所以说白了,内存把屏幕上所有的坐标点所对应的地址都映射到了内存中,当写入字符时,就是给光标所对应的坐标点的地址赋值,然后再移动光标。
换行 :处于当前行的最后一列,就将光标计算出一个新值,使其处于下一行的开头,程序调用链如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 void con_write (struct tty_struct * tty) { ... if (x>=video_num_columns) { x -= video_num_columns; pos -= video_size_row; lf(); } ... } static void lf (void ) { if (y+1 <bottom) { y++; pos += video_size_row; return ; } ... }
在/kernel/chr_drv/console.c
文件中还可以找到各种各样操作的源码,例如滚屏、回车、删除、插入等,简单列出一些:
1 2 3 4 5 6 7 8 9 10 11 static inline void gotoxy (unsigned int new_x, unsigned int new_y) {}static void scrup (void ) {}static void lf (int currcons) {}static void cr (void ) {}... static void delete_line (void ) {}
总结一下 tty_init() 干的事:完善了键盘的输入和显示器的输出功能
time_init() 操作系统是怎么获取当前时间的呢?联网时,可以通过网络同步;但是不联网时,为什么时间是准确的呢?
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 #define CMOS_READ(addr) ({ \ outb_p(0x80 |addr,0x70 ); \ inb_p(0x71 ); \ }) #define BCD_TO_BIN(val) ((val)=((val)&15) + ((val)>>4)*10) 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 ); 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); time.tm_mon--; startup_time = kernel_mktime(&time); }
可以看到主要是由CMOS_READ
和BCD_TO_BIN
这两个函数实现的。
CMOS_READ
1 2 3 4 #define CMOS_READ(addr) ({ \ outb_p(0x80 |addr,0x70 ); \ inb_p(0x71 ); \ })
这个outb_p
和inb_p
是什么意思呢?
out 相当于写了一下,in 相当于读了一下,这是 CPU 与外设交互的一种方式,CPU 与外设打交道基本是通过端口,在特定端口上写上特定的值来命令外设干嘛,然后从另一个端口读值来接受外设的反馈。
整段代码代表对 CMOS 读取了一些数据,CMOS 是主板上一个可读写的存储设备,它会在计算机关机时存储一些信息,这里只要知道它是一种外设就行了。
这里以硬盘为例讲一下交互
先看一下硬盘的端口表
端口
读
写
0x1F0
数据寄存器
数据寄存器
0x1F1
错误寄存器
特征寄存器
0x1F2
扇区计数寄存器
扇区计数寄存器
0x1F3
扇区号寄存器或 LBA 块地址 0~7
扇区号或 LBA 块地址 0~7
0x1F4
磁道数低 8 位或 LBA 块地址 8~15
磁道数低 8 位或 LBA 块地址 8~15
0x1F5
磁道数高 8 位或 LBA 块地址 16~23
磁道数高 8 位或 LBA 块地址 16~23
0x1F6
驱动器/磁头或 LBA 块地址 24~27
驱动器/磁头或 LBA 块地址 24~27
0x1F7
命令寄存器或状态寄存器
命令寄存器
读硬盘时,会先往除0x1F0
端口的其他端口上写数据,告诉硬盘要读的是哪个扇区,读多少。然后再从0x1F0
这个端口读数据。
具体过程:
在 0x1F2 写入要读取的扇区数
在 0x1F3 ~ 0x1F6 这四个端口写入计算好的起始 LBA 地址
在 0x1F7 处写入读命令的指令号
不断检测 0x1F7 (此时已成为状态寄存器的含义)的忙位
如果第四步骤为不忙,则开始不断从 0x1F0 处读取数据到内存指定位置,直到读完
所以我们再回到代码
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 #define CMOS_READ(addr) ({ \ outb_p(0x80 |addr,0x70 ); \ inb_p(0x71 ); \ }) #define BCD_TO_BIN(val) ((val)=((val)&15) + ((val)>>4)*10) 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 ); 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); time.tm_mon--; startup_time = kernel_mktime(&time); }
do_while 循环,通过读写 CMOS 上的指定端口,获取年月日时分秒 。至于 CMOS 是如何知道时间的,这个就不在我们讨论范围内了。
BCD_TO_BIN
将 BCD 转换成 BIN,因为从 CMOS 中获取的都是 BCD 码值,所以要转换成存储在我们变量上的二进制数值。
kernel_mktime
将之前收集到的时间数据,计算从 1970 年 1 月 1 日 0 时 **起到开机当时经过的秒数,作为开机时间,存储在 **startup_time 这个变量里。
总结一下 time_init() 干的事:对 CMOS 进行读写操作,获取时间数据,经过一系列转换后得到开机时间。
shed_init() 进程调度初始化,多进程的基石!!!
1 2 3 4 5 void sched_init (void ) { set_tss_desc(gdt+4 , &(init_task.task.tss)); set_ldt_desc(gdt+5 , &(init_task.task.ldt)); ... }
初始化 TSS
和 LDT
,先说一下这两句话干了啥,之前有说过全局描述符表 gdt,gdt 表中不同位置存储了不同的段,这两句话就是把TSS
和LDT
存入gdt
。
现在再来说说这两个结构事干嘛的:
TSS
TSS
是任务状态段 ,会保存和恢复进程的上下文 ,上下文就是各个寄存器的信息。这样进程切换的时候,即从A进程切换到B进程时,会先保存A进程寄存器的信息,当再次切换回A进程时,就会恢复寄存器里的信息,以便继续执行。
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 struct tss_struct { long back_link; long esp0; long ss0; long esp1; long ss1; long esp2; long ss2; long cr3; long eip; long eflags; long eax, ecx, edx, ebx; long esp; long ebp; long esi; long edi; long es; long cs; long ss; long ds; long fs; long gs; long ldt; long trace_bitmap; struct i387_struct i387 ; };
LDT
LDT
是局部描述符表 ,与 GDT 全局描述符表相对应。内核态代码用 GDT 里的数据段和代码段,而用户进程代码则用每个用户进程自己的 LDT 里的数据段和代码段。
接着往下看
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 struct desc_struct { unsigned long a,b; } struct task_struct * task [64] = {&(init_task.task), };void sched_init (void ) { ... int i; struct desc_struct * p ; p = gdt+6 ; for (i=1 ;i<64 ;i++) { task[i] = NULL ; p->a=p->b=0 ; p++; p->a=p->b=0 ; p++; } ... }
循环给长度为64,结构为 task_struct 的数组 task 赋值 NULL (1~63)
task_struct 结构代表每个进程的信息,很重要
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 struct task_struct { long state; long counter; long priority; long signal; struct sigaction sigaction [32]; long blocked; int exit_code; unsigned long start_code,end_code,end_data,brk,start_stack; long pid,father,pgrp,session,leader; unsigned short uid,euid,suid; unsigned short gid,egid,sgid; long alarm; long utime,stime,cutime,cstime,start_time; unsigned short used_math; int tty; unsigned short umask; struct m_inode * pwd ; struct m_inode * root ; struct m_inode * executable ; unsigned long close_on_exec; struct file * filp [NR_OPEN ]; struct desc_struct ldt [3]; struct tss_struct tss ; };
循环后面的内容就是给 gdt 剩下的位置都填充为 0,也就是把剩下留给 TSS 和 LDT 的描述符都先附上空值。
以后每创建一个新进程,就会在后面添加一组 TSS 和 LDT 表示这个进程的任务状态段以及局部描述符表信息。
简单看一下,不懂也没事:
为啥还没创建进程就会有一组 TSS 和 LDT 呢?是因为当进程调度机制建立起来,正在执行的代码就会变成 进程0 的代码,也就是说正在运行的代码会作为未来的一个进程的指令流。(不懂也没事)
接着往下看
1 2 3 4 5 6 7 8 9 #define ltr(n) __asm__("ltr %%ax" ::"a" (_TSS(n))) #define lldt(n) __asm__("lldt %%ax" ::"a" (_LDT(n))) void sched_init (void ) { ... ltr(0 ); lldt(0 ); ... }
ltr
是给tr
寄存器赋值,以告诉 CPU 任务状态段 TSS 在内存中的位置;lldt
是给ldt
寄存器赋值,以告诉 CPU 局部描述符 LDT 在内存中的位置。 (同之前的lidt
和lght
)
接着往下看
1 2 3 4 5 6 7 8 9 10 void sched_init (void ) { ... outb_p(0x36 ,0x43 ); outb_p(LATCH & 0xff , 0x40 ); outb(LATCH >> 8 , 0x40 ); set_intr_gate(0x20 ,&timer_interrupt); outb(inb_p(0x21 )&~0x01 ,0x21 ); set_system_gate(0x80 ,&system_call); ... }
四行端口读写代码,交互的外设是一个可编程定时器的芯片(LATCH),这四行代码就开启了这个定时器,之后这个定时器变会持续的、以一定频率的向 CPU 发出中断信号 。
两行设置中断代码,第一个是时钟中断 ,中断号为 0x20,中断程序为 timer_interrupt,每次定时器向 CPU 发出中断,都会执行这个函数。第二个是系统调用 ,中断号为 0x80,灰常关键,所有用户态程序想要调用内核提供的方法,都需要基于这个系统调用来进行。
来总结一下现在的中断都设置了哪些:
中断号
中断处理函数
0 ~ 0x10
trap_init 里设置的一堆
0x20
timer_interrupt
0x21
keyboard_interrupt
0x80
system_call
0 ~ 0x10:一些基本的中断,比如除零异常等 (trap_init)
0x20:时钟中断
0x21:处理键盘输入,使得键盘能用 (tty_init)
0x80:系统调用
总结一下 shed_init() 干的事:
在全局描述符表 gdt 里写入了两个结构 TSS 和 LDT,作为未来进程 0 的任务状态段和局部描述符表信息。
初始化了一个 task_struct 结构数组(task[0]=init_task.init,task[1~63]=NULL), 作为未来进程 0 的信息。
和一个可编程定时器芯片进行交互,使其持续向 CPU 发出中断信号;设置了时间中断 0x20 和系统调用 0x80,一个是进程调度的起点,一个是用户程序调用操作系统的桥梁。
buffer_init(buffer_memory_end) 首先要注意到的是,这个函数一开始就传入了一个参数,这个参数是什么呢?是 main 函数最开始计算的三个变量的其中一个。
而之前我们又用 mem_init
设置好了主内存 的管理结构 mam_map
。
而这个函数就是要把缓冲区也初始化管理起来 。
看看源码
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 extern int end;struct buffer_head * start_buffer = (struct buffer_head *) &end;void buffer_init (long buffer_end) { struct buffer_head * h = start_buffer; void * b = (void *) buffer_end; while ( (b -= 1024 ) >= ((void *) (h+1 )) ) { h->b_dev = 0 ; h->b_dirt = 0 ; h->b_count = 0 ; h->b_lock = 0 ; h->b_uptodate = 0 ; h->b_wait = NULL ; h->b_next = NULL ; h->b_prev = NULL ; h->b_data = (char *) b; h->b_prev_free = h-1 ; h->b_next_free = h+1 ; h++; } h--; free_list = start_buffer; free_list->b_prev_free = h; h->b_next_free = free_list; for (int i=0 ;i<307 ;i++) hash_table[i]=NULL ; }
先看第一行,将一个外部变量 end 的地址赋值给缓冲区开始位置 start_buffer
。这个 end 变量并不是操作系统写好的,而是由链接器 ld 在链接整个程序时设置的一个外部变量,是已经计算好了的内核代码的末尾地址。
用一张图表示就是:
可以说是靠 end 这个值划分了内核程序和缓冲区。
接着往下看
1 2 3 4 5 6 7 8 9 10 11 12 void buffer_init (long buffer_end) { struct buffer_head * h = start_buffer; void * b = (void *) buffer_end; while ( (b -= 1024 ) >= ((void *) (h+1 )) ) { ... h->b_data = (char *) b; h->b_prev_free = h-1 ; h->b_next_free = h+1 ; h++; } ... }
对两个变量进行操作
第一个变量是 buffer_head 结构的 h ,代表缓冲头,指针值是 start_buffer,也就是 end 的地址。
第二个变量是 b ,指针值是 buffer_end,代表缓冲区的结尾。
b 每循环一次 -1024
,缓冲头 h + 1。所以 b 代表缓冲块,h 代表缓冲头,一个从上往下,一个从下往上。
而且 h 被赋上了各种属性,其中 b_data
属性就被赋值成了缓冲块 b。
两个空闲 buffer 指针:b_prev_free 表示前一个空闲缓冲头,b_next_free 表示后一个空闲缓冲头。可以说缓冲头由双向链表链成。
接着往下看
1 2 3 4 5 6 7 void buffer_init (long buffer_end) { ... free_list = start_buffer; free_list->b_prev_free = h; h->b_next_free = free_list; ... }
将第一个缓冲头赋给 free_list,并将 free_list(第一个缓冲头) 的 b_prev_free 赋值为 h(最后一个缓冲头),并将 h(最后一个缓冲头) 的 b_next_free 赋值为 free_list(第一个缓冲头),说人话就是最后一个缓冲头的下一个缓冲头是第一个缓冲头,第一个缓冲头的上一个缓冲头是最后一个缓冲头。用图表示就是:
free_list 可以在这个双向链表中遍历任何一个缓冲头,然后通过特定的缓冲头又能对应到特定的缓冲块。管理系统就这样建成了。
接着往下看
1 2 3 4 5 void buffer_init (long buffer_end) { ... for (i=0 ;i<307 ;i++) hash_table[i]=NULL ; }
这个 hash_table
数组是干啥的呢?
我们知道这个 buffer.c 是在 fs 文件夹里的,fs 代表着文件系统。当读取块设备的数据时,会先读到缓冲区中,如果缓冲区已经有了,就不会再从块设备里读取了,而是直接从缓冲区取走。
那系统怎么知道缓冲区已经有了要读取的块设备中的数据呢?
每一次都遍历双向链表效率太低,所以就需要这个 hash_table 的结构快速查找。
现在只是初始化了这个 hash_table 结构。
简单补充一下 hash_table 的调用过程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 #define _hashfn(dev,block) (((unsigned)(dev^block))%307) #define hash(dev,block) hash_table[_hashfn(dev,block)] struct buffer_head * getblk (int dev,int block) { ... struct buffer_head bh = get_hash_table(dev,block); ... } struct buffer_head * get_hash_table (int dev, int block) { ... find_buffer(dev,block); ... } static struct buffer_head * find_buffer (int dev, int block) { ... hash(dev,block); ... }
可以看到最关键的是 (dev^block))%307 ,hash_table下标的寻找方式。即(设备号^逻辑块号) % 307
利用了数据结构哈希表来实现
总结一下buffer_init()干的事:初始化缓冲区管理
hd_init() 1 2 3 4 5 6 7 8 9 10 11 12 void hd_init (void ) { blk_dev[3 ].request_fn = do_hd_request; set_intr_gate(0x2E ,&hd_interrupt); outb_p(inb_p(0x21 )&0xfb ,0x21 ); outb(inb_p(0xA1 )&0xbf ,0xA1 ); }
硬盘初始化
硬件设备的初始化大体都是:
往某些 IO 端口上读写一些数据,表示开启它
然后向中断向量表中添加一个中断,使得 CPU 能够响应这个硬件设备的动作
初始化一些数据结构来管理
先看第一行
1 2 3 4 void hd_init (void ) { blk_dev[3 ].request_fn = do_hd_request; ... }
将 blk_dev 数组的索引 3 位置处的块设备管理结构 blk_dev_struct 的 request_fn 赋值为了 do_hd_request
Linux 0.11 内核用了一个 blk_dev[] 来进行管理,每一个索引表示一个块设备。
1 2 3 4 5 6 7 8 9 struct blk_dev_struct blk_dev [NR_BLK_DEV ] = { (kernel/blk_drv/ll_rw_blk.c) { NULL , NULL }, { NULL , NULL }, { NULL , NULL }, { NULL , NULL }, { NULL , NULL }, { NULL , NULL }, { NULL , NULL } };
可以看到索引为 3 的地方表示 hd。
每个块设备执行读写请求都有自己的函数实现,在上面看来就是一个统一函数 request_fn ,对于硬盘来说,实现的就是do_hd_request
函数。
再看第二行
1 2 3 4 5 void hd_init (void ) { ... set_intr_gate(0x2E ,&hd_interrupt); ... }
设置中断,中断号为 0x2E
,中断程序为hd_interrupt
,也就是执行 do_hd_request()
函数。
1 2 3 4 5 6 7 8 9 10 11 _hd_interrupt: ... xchgl _do_hd,%edx ... static void read_intr (void ) { ... do_hd_request(); ... }
再来总结一下中断
中断号
中断处理函数
0 ~ 0x10
trap_init
里设置的一堆
0x20
timer_interrupt
0x21
keyboard_interrupt
0x2E
hd_interrupt
0x80
system_call
0~0x10:17个基本中断,如除零异常,trap_init 初始化设置。
0x20:时钟中断。开启定时器,sched_init 初始化设置。
0x21:键盘中断,此时按键盘开始起效,con_init 初始化设置。
0x2E:硬盘中断,读写硬盘完成后触发中断,d_init 初始化设置。
0x80:系统调用中断,sched_init 初始化设置。
可以发现,让操作系统工作的唯一方式,就是触发中断。
接着往下看:
1 2 3 4 5 void hd_init (void ) { ... outb_p(inb_p(0x21 )&0xfb ,0x21 ); outb(inb_p(0xA1 )&0xbf ,0xA1 ); }
往几个 IO 端口上读写,目的是允许硬盘控制器发送中断请求信号 。
总结一下 hd_init() 干的事:给硬盘赋值读写请求函数,设置了新的中断号和中断程序,和 IO 设备交互,发送中断。
读硬盘最底层的操作流程是怎样的呢?之前 tty_init 的时候也讲过:
硬盘的端口表:
端口
读
写
0x1F0
数据寄存器
数据寄存器
0x1F1
错误寄存器
特征寄存器
0x1F2
扇区计数寄存器
扇区计数寄存器
0x1F3
扇区号寄存器或 LBA 块地址 0~7
扇区号或 LBA 块地址 0~7
0x1F4
磁道数低 8 位或 LBA 块地址 8~15
磁道数低 8 位或 LBA 块地址 8~15
0x1F5
磁道数高 8 位或 LBA 块地址 16~23
磁道数高 8 位或 LBA 块地址 16~23
0x1F6
驱动器/磁头或 LBA 块地址 24~27
驱动器/磁头或 LBA 块地址 24~27
0x1F7
命令寄存器或状态寄存器
命令寄存器
读硬盘时,会先往除0x1F0
端口的其他端口上写数据,告诉硬盘要读的是哪个扇区,读多少。然后再从0x1F0
这个端口读数据。
具体过程:
在 0x1F2 写入要读取的扇区数
在 0x1F3 ~ 0x1F6 这四个端口写入计算好的起始 LBA 地址
在 0x1F7 处写入读命令的指令号
不断检测 0x1F7 (此时已成为状态寄存器的含义)的忙位
如果第四步骤为不忙,则开始不断从 0x1F0 处读取数据到内存指定位置,直到读完
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 static void hd_out (unsigned int drive,unsigned int nsect,unsigned int sect, unsigned int head,unsigned int cyl,unsigned int cmd, void (*intr_addr)(void )) { ... do_hd = intr_addr; outb_p(hd_info[drive].ctl,HD_CMD); port = 0x1f0 ; outb_p(hd_info[drive].wpcom>>2 ,++port); outb_p(nsect,++port); outb_p(sect,++port); outb_p(cyl,++port); outb_p(cyl>>8 ,++port); outb_p(0xA0 |(drive<<4 )|head,++port); outb(cmd,++port); }
outb_p
转换成汇编就是out
指令,往指定的硬盘 IO 端口写数据。
当我们在用户层写 read/write 函数,即便是经过系统调用、文件系统、缓冲区管理等等过程,但只要是读写硬盘,最终都要调用到这个最底层的函数。
floppy_init() 软盘初始化,现在基本不常用了,就不多说了。
sti() sti 对应一个同名的汇编指令 sti(),表示允许中断 ,在这之后,所有的中断开始生效。本质上是将 eflags 寄存器里的中断允许标志位 IF 位置 1。