在head设置了页表、GDT和IDT之后,然后就进入了main程序,这里首先介绍一些参数:
ORIG_ROOT_DEV,该参数是读取0x901FC的两个byte读取的数据,这两个byte就是bootsect模块的0x509、0x510两个byte的数据,该参数的设置经过了两个步骤,第一个就是直接bootsect中的ROOT_DEV变量指定,然后通过build组装程序对命令参数的解析判定是否指定了根文件系统设置,如果有指定就对其进行设置;
DRIVE_INFO,硬盘参数,该参数是setup程序中基于BIOS中断获取到的信息:BIOS将第一块硬盘信息存储在中断向量的0x41位置,将第二个块存储在0x46位置;另外对于第二块会经过BIOS中断程序判定是否有安装,没有会进行第二块硬盘参数的清零处理;
EXT_MEM_K,扩展内存大小,整块内存的内存大小减去原本的1M内存,以K为单位;也是在setup中基于BIOS中断程序获取得到
RAMDISK宏,该宏作为编译条件使用,在makefile中进行定义
这里需要了解一下内存的空间分配:内核模块代码+高速缓冲区+虚拟盘+主内存区;对应的一些参数如下:
memory_end -- 整个操作系统管理的内存空间大小
buffer_memory_end -- 高速缓冲区末端位置,缓冲区用于缓冲外存数据,以缓冲块头和缓冲块的数据结构对缓冲块进行数据管理
main_memory_start -- 主内存区起始地址,从该地址开始到memory_end整块内存是主内存区,用于分页管理空间;
那么首先就是根据RAMDISK宏判定一下是否定义了内存虚拟盘大小,只有定义了内存虚拟盘才需要在内存整体布局中分配出对应的内存虚拟盘空间,也就是将main_memory_start位置后移,将buffer_memory_end到main_memory_start部分的空间作为内存虚拟盘使用,并对内存虚拟盘进行初始化;这里对于块设备的读取写入不做详细描述;
然后对主内存区进行初始化(mem_init),主内存区的范围为main_memory_start到memory_end,这里有个数组,就是mem_map,这个数组用于分页管理功能,负责针对主内存区的物理地址资源管理;这里有个概念,就是对于一个资源的引用量,Linux 0.11中很多地方使用了这个概念,诸如高速缓冲块、文件表资源,i节点资源,i节点被目录项引用数量等,其基本目的就是对资源的重复引用,减少资源的加载释放次数,以提升效率,基本实现过程就是对资源增加一个引用计数变量用于标记该资源被引用数,第一次引用和最后一个释放引用的时候分别进行资源的加载和释放操作;该数组用于以页为单位管理主内存区,数组中的每个量都是对应物理内存页对应的当前引用数;
之后就是中断表的一些设置,trap_init(),用以设置在中断触发的时候调用什么程序,其中中断表的初始化已经在head中进行了默认设置(只是打印一个字符串);该函数中用到了两个宏set_trap_gate和set_system_gate,两者本质上都是设置了陷阱门,区别就在用调用权限的不同,前者只能是硬件中断或者内核权限才能触发,而后者则是用户权限也可以调用,而之所以存在门就是为了可以由用户层跳转到内核层代码,且改变其CPL,正常情况下,只有非一致性代码才可以可以进行执行,但是即使执行了,也不会修改CPL,由此无法访问到内核数据,经过门则可以以内核CPL进行程序执行。这里要注意一下,就是8259A中断控制器有两个,一个是级联在另外一个之上的,且级联位置就是IRQ2,因此在解除中断控制器屏蔽的时候,需要对两块都进行解除操作;至于其中的中断程序,主要就是0-16中断向量,这些向量是为Intel CPU本身保留定义的,有兴趣可以百度一下。
接着就是块设备初始化程序blk_dev_init(),这里需要了解一下块设备读取逻辑,整体上即使基于请求项对不同块设备进行缓冲,缓冲单位以块为单位,而缓冲块所在位置就是高速缓冲区。至于如果定位需要的块,就和文件系统扯上了关系,有兴趣可以看一下;那么这里的初始化就是直接设置请求项都可用状态;
接着就是字符设备初始初始化程序chr_dev_init(),此处啥都没有;
然后就是终端初始化程序tty_init(),Linux 0.11中的终端有两种,一种是串口终端,有两个;一种是键盘和显示器组成的本地终端,这里需要提到一个数据结构,就是tty_struct,系统中定义了该结构的一个数组tty_table,数组的第一项就是控制台终端(输入为键盘,输出为屏幕);第二项和第三项为串口终端,其中定义了一些参数,这里不进行叙述;等到了聊终端的时候再进行叙述;那么tty_init()程序中调用的rs_init()就是针对串口终端的初始化工作,主要是串口终端的中断描述符设置,然后通过端口地址对串口控制器进行了一些初始化;然后去除掉中断控制器IRQ3和IRQ4的中断屏蔽,这两者正对应两个串口;rs_init()调用完毕之后就调用con_init()程序,该程序就是用于初始化本地终端的,也就是键盘和屏幕:
con_init()函数,这里在看这个函数的时候需要了解另外一些参数:
video_num_columns -- 屏幕的列数,是通过ORIG_VIDEO_COLS赋值进行的,而ORIG_VIDEO_COLS该量是在setup执行过程中通过BIOS中断程序进行数据提取(int 0x10,ah == 0x0f);
video_size_row -- 屏幕一行需要的数据空间大小,每个字符需要2 byte数据,一个存储字符,一个存储字符属性,因此需要乘2
video_num_lines -- 屏幕行数,这里直接定义了25,应该和Linus当时使用的硬件属性有关;
video_page -- 根据BIOS中断函数提取,为当前显示页码?
video_erase_char -- 屏幕显示使用的擦除字符,0x0720,就是一个空格
ORIG_VIDEO_MODE -- 根据BIOS中断函数提取,屏幕的显示模式,为7时是80*25 单色文本模式
ORIG_VIDEO_EGA_BX --
以下为个人理解,不正当望提出正确思路:
首先这里根据ORIG_VIDEO_MODE去判断使用的是什么类型的显示适配器,这里直接只判断了该模式时候为7,但是在查看BIOS相关中断的时候,当显示模式为0x00、0x06等等也是单色模式,因此这里的模式应该和当时硬件设备有关;
总之,Linux 0.11根据读取到的显示模式是否为7这个标准去确定显示适配器遵守MDA亦或者是CGA显示标准,也就是单色或者彩色显示,MDA卡配置有8K内存(0xb0000 -- 0xb2000),这段内存是直接分配给显卡的,也就是说在这段内存上写数据就可以通过MDA卡进行数据显示;而CGA卡则配置有16K内存(0xb8000 -- 0xbc000),为彩色显示;另外,EGA兼容CGA和MDA,因此通过setup中判断区分是EGA还是CGA或MDA;然后根据不同情况设定内存使用范围,另外,对于上述3种显卡,他们的端口都是兼容的,其中0x3d4为索引寄存器,0x3d5位数据寄存器;类似地址线和数据项,使用的时候先指定索引寄存器,再指定数据寄存器可以对指定寄存器进行参数设置;
最后根据根据BIOS获取到的当前行列坐标设置当前显示内存位置;
最后就是设置IRQ1,就是键盘中断并设置对应的键盘中断程序keyboard_interrupt;然后解除中断控制器对应屏蔽位;
然后就是操作系统的时间初始化time_init(),该函数就是兑取了CMOS中数据,CMOS简单理解也可以理解为一个内存,只不过由于其硬件由电池供电,因此电脑断电后其中存储信息不会丢失,另外,CMOS中存储的时间都是BCD码,需要经过转换才是十进制数,最后通过kernel_mktime将获取到的时间信息进行计算,得到从1970年1月1日到当前时间的秒数设置给startup_time作为系统开机时间;
接着就是调度程序的初始化sched_init(),关于任务的数据结构:
任务有PID和任务号,PID是不断递增的,而任务号是在一个数组中寻找可用槽对应的索引号;
每个任务在操作系统中都会有一个对应的任务结构task_struck,他独自占据一页空间,一页的空间存放该结构体还有剩余空间,剩余的空间作为该任务的内核栈使用;
关于操作系统中的任务,每个任务都需要占用两个GDT段描述符符,一个存储TSS段信息,一个存储LDT段信息,占据的GDT段从GDT表中索引为4的GDT段开始,前面有4个GDT段描述符,其中0,3不用,1,2被用于描述内核代码段和数据段;
首先在GDT表中设置了任务0的TSS段和LDT段描述符,这里的任务0是一个由操作系统默认指定的任务,然后对任务数组task进行清理,该任务数组在使用的时候会指向一个完整的页,当前只会使用到页中的一部分,而剩余部分就作为内核栈使用;然后置零对应的之后需要用到的GDT表段描述符,之后加载任务0的TSS段到TR寄存器,加载LDT段到LDTR寄存器;之后设置时钟中断周期为10ms,并设置时钟中断程序,解除时钟中断屏蔽;另外设置系统调用中断;
接下来就是高速缓冲区的初始化工作,高速缓冲区整体的数据结构是缓冲块头在高速缓冲区前面那部分,缓冲块在高速缓冲区后面那部分,每个缓冲块大小是1K,和外存数据块一一对应,然后缓冲块头由前向后,缓冲块由后向前不断逼近,最终布局使用完毕所有缓冲区内存,完成布局后,整个缓冲块头就是一个数组结构,因此组成双向循环链表的时候可直接按照数组格式进行寻址;然后清空哈希链表;哈希链表和空闲缓冲块链表的意义如下:
首先需要明确一点,就是说高速缓冲区存在的目的是解决块设备读取过程消耗时间较长的问题,当然,如果每次获取的都是块设备都是不同数据块的数据的话,缓冲块这种东西也白扯。
那么整个缓冲块机制基于两个链表,一个是空闲链表,描述当前没使用的缓冲块,一个是哈希链表,表征当前已经映射块设备的缓冲块,哈希链表的哈希函数是基于块设备的设备号以及要缓冲的数据块号进行计算的,当需要映射块设备的一个数据块时,首先从空闲缓冲块链表free_list中找一个空闲的缓冲块,然后根据计算获取到哈希表中对应索引号,接着链接到哈希链表中;
软驱、硬驱初始化函数hd_init(),设定硬盘请求项处理程序,设定硬盘中断处理程序,接触中断控制器对应中断门屏蔽;
经过上面的初始化设置,一系列需要的中断处理函数也完成了设置,该解开屏蔽的中断控制器也解开了对应的屏蔽。因此直接sti解除EFLAG对中断的屏蔽;
之后执行move_to_user_mode()从内核态进入用户态:
move_to_user_mode(),由于在前面的sched_init()程序中设定了任务0的TSS段和LDT段,这里就是基于iret机制从内核态跳转到用户态,这里用到了内核栈,此时的内核栈为head.s程序中设定的,也就是kernel/sched.c中定义的stack_start一页内存空间。该栈空间在初始化阶段被用作内核栈,初始化完毕后在该函数中又将其交由任务0作为该任务的用户态栈;
这里需要说明一下就是用户态中的LDT段,其中索引为1的为代码段,索引为2的为数据段;
然后先入栈用户ss,再入栈用户esp,再入栈EFLAGS,再入栈用户代码段cs,再入栈用户eip(1f为该宏后面的局部标号1);接着iret触发,跳转到任务0的用户态,然后继续执行局部标号1中的代码,将那几个段描述符设置为程序的用户态数据段;
执行过move_to_user_mode()后,注意此时move_to_user_mode是个宏,执行完毕后会继续执行该宏之后的程序,也就是fork那一段;
对于fork函数:
fork()函数,该函数是个系统调用函数,他的定义是通过一个宏来定义的,就在init/main.c程序中,也就是static inline _syscall0(int,fork),展开后就是:
int fork(void)
{
long __res;
__asm__ volatile ("int $0x80" : "=a" (__res) : "0" (__NR_fork));
if (__res >= 0) return (type) __res;
errno = -__res;
return -1;
}
本质上就是调用int 0x80触发系统调用,整个系统调用部分此处不描述,之后描述,简单来看最终他调用了kernel/system_call.s/_sys_fork程序,该程序中首先调用了find_empty_process,该程序在kernel/fork.c中定义,具体就是遍历task任务数组,找到一个可用的任务槽,然后返回对应的任务号,接着调用copy_process程序(也在kernel/fork.c文件中定义)对任务管理资源进行复制;
copy_process函数:
经过find_empty_process找到空闲任务号后,会作为参数传递给copy_process中,变量名就是nr,这里需要了解一下C程序调用函数过程,首先就是参数从右向左入栈,然后将当前eip入栈,最后跳转目标函数位置;
首先申请一页用于存放操作系统中的任务结构(页中剩余空间作为内核栈使用)并将其指针存放到对应的任务指针数组中;然后初始化任务数组数据,fork出来的任务作为当前任务的子进程,pid是一直累加的,内核栈esp0设置为任务页边界(不包括对应位置),eip指向原本任务eip,意味着程序继续执行的时候就是从fork后面的程序继续执行,eflag沿用父进程eflag,局部段描述符表使用新的GDT段描述符;
然后使用copy_mem进行父进程代码段和数据段的复制,整个复制过程涉及到以下几个内容:一个就是LDT表更新,一个就是新建任务LDT表映射代码段和数据段数据;对于LDT表,这个和任务数据在操作系统的布局相关:每个任务的代码段和数据段使用线性空间的内存64M,而决定一个任务的线性空间内存区域基于任务的任务号,而不是pid;然后复制页表等信息,使新建任务LDT代码段和数据段映射到父进程的页表数据范围;
由于复制的时候直接将文件句柄数据也进行了复制,因此需要进行对文件引用的处理工作;还有文件i节点的引用数;然后设置子进程的GDT段描述符(TSS和LDT表);最终完成一个子进程的创建;父进程返回对应子进程的pid,而对于子进程由于在copy_process对TSS段中eax寄存器设置为0,因此在进行进程切换的时候,返回的是0.
然后进程0进入循环pause,进程经过调度切换到任务1进行执行,也就是开始执行init()程序;
对于任务1的init函数:
首先使用系统调用setup:
setup是个系统调用,在main.c中定义,是通过一个宏来进行定义的,扩展开就是:
static inline int setup(void* BIOS)
{
long __res;
__asm__ volatile ("int $0x80" : "=a" (__res) : "0" (__NR_setup),"b" ((long)(a)));
if (__res >= 0) return (int)__res;
errno = -__res;
return -1;
}
整个就是调用经过系统调用机制,最终调用的了kernel/blk_drv/hd.c/sys_setup();这里需要注意一个点,就是C程序的符号表格式和C++的不一样,C只是基于函数返回值类型和函数名称,而C++会掺杂参数类型信息,因此C程序不具备函数重载功能,而C++具备;这样的话,C函数的函数声明并不需要函数参数,只需要函数返回值和函数名称即可;
sys_setup(),该函数有个参数,参数的传递通过寄存器进行传递的,传递这个参数涉及到了整个系统调用机制,这里暂时不进行细述,之后单独一章进行描述,传递进来的参数是drive_info,其内是经过BIOS设置的硬盘参数,其中检测了第二个硬盘,如果不存在第二个硬盘,对应数据为0;
首先就是对HD_TYPE进行一次判定,该宏直接定义了硬盘参数,如果硬盘参数是通过手动定义的话,那么就不再使用BIOS检测到的硬盘参数,且BIOS检测到的硬盘参数中如果没有对应硬盘,那么会设置对应16 byte空间为0;
然后设置了硬盘整体的起始扇区位置和扇区总数量;由于可能是手动设置硬盘参数,且自动设置硬盘参数对于第一个硬盘没有检测是否合法,因此在最后又通过CMOS信息查询是否兼容AT硬盘(和硬件相关大体意思是这样);这里很奇怪就是这个第一个硬盘和第二个硬盘是以什么标准来区分出来的?没太懂;
然后获取整体硬盘的第一个扇区(只有兼容AT硬盘才是这样的结构?),该扇区存储了硬盘分区表,然后将硬盘分区表信息提取到硬盘数组hd[]中;
然后对内存虚拟盘进行处理,首先对rd_length进行了一次判定,该参数初始化为0,如果makefile中定义了内存虚拟盘使用空间的话,那么就会对该参数进行设定,这在另一方面也将其作为是否使用内存虚拟盘的一个标志,在Linux 0.11中,内存虚拟盘的载入是为了在一张软盘上同时支持引导盘和根目录系统的作用,也就是根文件系统存放在操作系统之后,即此时的根文件系统也是引导软盘,这里就是读取根文件系统的超级块,这里有两个概念,一个就是文件系统的逻辑块,一个就是硬盘本身的数据块,这两种块之间是有一个关系的,他这个信息也是存在于文件系统的超级块中,这个信息的意义为一个逻辑块意味着数据块的数量,因此才有了nblock的计算;另外,在MakeFile中指定了内存虚拟盘的大小,如果整个文件系统需要占用空间超过了预留给内存虚拟盘的内存空间大小,那么就意味着无法创建内存虚拟盘,如果可以创建内存虚拟盘的话,那么就基于缓冲块去将整个文件系统进行加载,然后一一复制到预留的内存虚拟盘空间位置;注意,此时内存虚拟盘占用的空间和缓冲块没有任何关系,他仅存在于内存中。
如果使用了内存虚拟盘,那么根文件系统就是该盘,设置根文件系统设备为0x0101;
上面的rd_load()将原本存在于外存的文件系统加载到了内存虚拟盘上,此时他仅仅存在,但还没有挂载,即无法读取该文件系统中的数据,需要挂载后才可以对文件系统进行使用,挂载后就有了该文件系统的超级块信息,有了超级块信息结合文件系统本身数据结构就可以对数据进行访问;挂载根文件系统通过mount_root()函数来实现:
mount_root():如果根文件系统是软盘的话,由于软盘使用过程中可以睡衣拔插,因此这里输出了"Insert root floppy and press ENTER"提示进行软盘插入然后继续之后的软盘上文件系统的挂载,这里说是按下ENTER,但是程序内部只是等待键盘一个输入,因此任意字符皆可;之后初始化超级块数组,然后读取根文件设备的超级块和文件系统根节点(Linux 0.11文件系统使用的是MINIX文件系统,该文件系统的根节点就在索引为1的i节点);
这里,超级块基于设备号进行索引,而i节点需要首先找到超级块,i节点就是通过设备号对超级块进行寻找,找到后就可以通过文件系统结构,进而获取到i节点数据,在i节点存储着存储数据的逻辑块,这样就可以获取到i节点对应的存储数据;对于一个要寻址的i节点,只需要根目录i节点即可通过目录项不断寻找找到目标i节点;其中,对于一个任务,在他存在过程中会持续拥有两个i节点,一个是当前任务的根目录节点,一个是当前任务的当前目录节点;根据这两个节点去搜索目标i节点;
完成上面的setup后,文件系统完成了加载,此时就可以通过文件的方式对文件系统中数据进行访问(即i节点);加载文件系统后,使用open系统调用打开/dev/tty0,该文件是个字符设备,linux中一切皆文件,字符设备也被抽象称为了一个文件,打开该文件后,又dup两个文件句柄(文件句柄的申请方式是从0开始编译文件句柄数组,找到为0的就返回),因此这里使文件句柄0,1,2都指向开启文件i节点(/dev/tty0),这个文件的i节点中有该文件的文件类型,如块设备、字符设备、目录文件、一般文件等信息;而/dev/tty0是一个终端文件,是个tty文件(主设备号为4);
然后又创建了一个进程去执行"/etc/rc"中的脚本程序,具体实现和"/bin/sh"shell软件内部实现有关,整体逻辑应该是不断read标准输入并不断解析执行程序,注意此时的标准输入重定向到了"/etc/rc"中,因此和控制端输入数据无关;在此过程中init是等待的,无任何操作;
执行完毕"/etc/rc"初始化脚本后,init进入死循环,就是不断创建子进程,然后exec为"/bin/sh"shell程序,然后读取标准输入进行shell程序执行,如果再次过程中shell被关闭了,那就再次循环--创建子进程,exec为"/bin/sh"程序一直循环;
至此,整体linux 0.11操作系统的控制流交由shell程序进行控制执行。