注
博客主要为《Linux内核设计的艺术》(以下简称《设计艺术》)和《Linux内核完全注释》(以下简称《完全注释》),以及非常好的Linux内核视频 - Linux内核精讲内容的搬运和阅读笔记,以及相关博客链接的整理。代码来源于《完全注释》配套代码。
写着玩儿的,如有错误,欢迎指正。
main.c
main.c调用了大量初始化函数。
main()
让我们来到一切的起点,来看main()的第一个片段:
// 此时中断仍被禁止着,做完必要的设置后就将其开启。
// 下面这段代码用于保存:
// 根设备号 -> ROOT_DEV; 高速缓存末端地址 -> buffer_memory_end;
// 机器内存数 -> memory_end;主内存开始地址 -> main_memory_start;
ROOT_DEV = ORIG_ROOT_DEV;
drive_info = DRIVE_INFO;
memory_end = (1<<20) + (EXT_MEM_K<<10);// 内存大小=1Mb 字节+扩展内存(k)*1024 字节。
memory_end &= 0xfffff000; // 忽略不到4Kb(1 页)的内存数。
if (memory_end > 16*1024*1024) // 如果内存超过16Mb,则按16Mb 计。
memory_end = 16*1024*1024;
if (memory_end > 12*1024*1024) // 如果内存>12Mb,则设置缓冲区末端=4Mb
buffer_memory_end = 4*1024*1024;
else if (memory_end > 6*1024*1024) // 否则如果内存>6Mb,则设置缓冲区末端=2Mb
buffer_memory_end = 2*1024*1024;
else
buffer_memory_end = 1*1024*1024;// 否则则设置缓冲区末端=1Mb
main_memory_start = buffer_memory_end;// 主内存起始位置=缓冲区末端;
#ifdef RAMDISK // 如果定义了虚拟盘,则主内存将减少。
main_memory_start += rd_init(main_memory_start, RAMDISK*1024);
#endif
根设备和文件系统有关,与硬件相关的我都不做深究,这部分主要就是划分内存区域。
这里还涉及到1M以下的地址为内核地址,管理方式与1M以上的地址是不同的。
// 以下是内核进行所有方面的初始化工作。阅读时最好跟着调用的程序深入进去看,实在看
// 不下去了,就先放一放,看下一个初始化调用-- 这是经验之谈:)
mem_init(main_memory_start,memory_end);
trap_init(); // 陷阱门(硬件中断向量)初始化。(kernel/traps.c)
blk_dev_init(); // 块设备初始化。(kernel/blk_dev/ll_rw_blk.c)
chr_dev_init(); // 字符设备初始化。(kernel/chr_dev/tty_io.c)空,为以后扩展做准备。
tty_init(); // tty 初始化。(kernel/chr_dev/tty_io.c)
time_init(); // 设置开机启动时间 -> startup_time。
sched_init(); // 调度程序初始化(加载了任务0 的tr, ldtr) (kernel/sched.c)
buffer_init(buffer_memory_end);// 缓冲管理初始化,建内存链表等。(fs/buffer.c)
hd_init(); // 硬盘初始化。(kernel/blk_dev/hd.c)
floppy_init(); // 软驱初始化。(kernel/blk_dev/floppy.c)
sti(); // 所有初始化工作都做完了,开启中断。
这部分是各种初始化配置,这些函数在《设计艺术》中都有较为详细的讲解,主要关注几个小点。
trap_init()
第一个是trap_init()(kernel/traps.c)的定义:
// 下面是异常(陷阱)中断程序初始化子程序。设置它们的中断调用门(中断向量)。
// set_trap_gate()与set_system_gate()的主要区别在于前者设置的特权级为0,后者是3。因此
// 断点陷阱中断int3、溢出中断overflow 和边界出错中断bounds 可以由任何程序产生。
// 这两个函数均是嵌入式汇编宏程序(include/asm/system.h,第36 行、39 行)。
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); //第3特权级
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);
// 下面将int17-48 的陷阱门先均设置为reserved,以后每个硬件初始化时会重新设置自己的陷阱门。
for (i=17;i<48;i++)
set_trap_gate(i,&reserved);
set_trap_gate(45,&irq13);// 设置协处理器的陷阱门。
outb_p(inb_p(0x21)&0xfb,0x21);// 允许主8259A 芯片的IRQ2 中断请求。
outb(inb_p(0xA1)&0xdf,0xA1);// 允许从8259A 芯片的IRQ13 中断请求。
set_trap_gate(39,¶llel_interrupt);// 设置并行口的陷阱门。
}
set_trap_gate的定义:
设置中断门函数。
// 参数:n - 中断号;addr - 中断程序偏移地址。
// &idt[n]对应中断号在中断描述符表中的偏移值;中断描述符的类型是14,特权级是0。
#define set_intr_gate(n,addr) \
_set_gate((unsigned long*)(&(idt[n])),14,0,(unsigned long)addr)
设置陷阱门函数。
// 参数:n - 中断号;addr - 中断程序偏移地址。
// &idt[n]对应中断号在中断描述符表中的偏移值;中断描述符的类型是15,特权级是0。
#define set_trap_gate(n,addr) \
_set_gate((unsigned long*)(&(idt[n])),15,0,(unsigned long)addr)
设置系统调用门函数。
// 参数:n - 中断号;addr - 中断程序偏移地址。
// &idt[n]对应中断号在中断描述符表中的偏移值;中断描述符的类型是15,特权级是3。
#define set_system_gate(n,addr) \
_set_gate((unsigned long*)(&(idt[n])),15,3,(unsigned long)addr)
可以看到,上面三个门都应用了同一个设置函数_set_gate:
设置门描述符宏函数。
// 参数:gate_addr -描述符地址;type -描述符中类型域值;dpl -描述符特权层值;addr -偏移地址。
// %0 - (由dpl,type 组合成的类型标志字);%1 - (描述符低4 字节地址);
// %2 - (描述符高4 字节地址);%3 - edx(程序偏移地址addr);%4 - eax(高字中含有段选择符)。
void _inline _set_gate(unsigned long *gate_addr, \
unsigned short type, \
unsigned short dpl, \
unsigned long addr)
{// c语句和汇编语句都可以通过
gate_addr[0] = 0x00080000 + (addr & 0xffff);
gate_addr[1] = 0x8000 + (dpl << 13) + (type << 8) + (addr & 0xffff0000);
/* unsigned short tmp = 0x8000 + (dpl << 13) + (type << 8);
_asm mov eax,00080000h ;
_asm mov edx,addr ;
_asm mov ax,dx ;// 将偏移地址低字与段选择符组合成描述符低4 字节(eax)。
_asm mov dx,tmp ;// 将类型标志字与偏移高字组合成描述符高4 字节(edx)。
_asm mov ebx,gate_addr
_asm mov [ebx],eax ;// 分别设置门描述符的低4 字节和高4 字节。
_asm mov [ebx+4],edx ;*/
}
关于门描述符的讲解参考博客1
当然,参考博客1中的门描述符格式与代码中有些不一致,具体格式参考参考博客2。
一些地方将系统调用与陷阱混为一谈,感觉在这里是不合理的。另外在_set_gate中我没有看到对IF位的设置,不知道应该在哪个位置?
关于块设备见参考博客3,当然,本文对硬件驱动并不关心,不过,如果需要做嵌入式系统移植的话,就需要很关心这些硬件初始化函数。
还有门的定义参考博客4。
sched_init()
操作系统的初始化以创建0号和1号进程为结束标志,这里需要关注一下0号进程的手动初始化:
/*
* 寻找第1 个TSS 在全局表中的入口。0-没有用nul,1-代码段cs,2-数据段ds,3-系统段syscall
* 4-任务状态段TSS0,5-局部表LTD0,6-任务状态段TSS1,等。
*/
// 全局表中第1 个任务状态段(TSS)描述符的选择符索引号。
#define FIRST_TSS_ENTRY 4
// 全局表中第1 个局部描述符表(LDT)描述符的选择符索引号。
#define FIRST_LDT_ENTRY (FIRST_TSS_ENTRY+1)
// 宏定义,计算在全局表中第n 个任务的TSS 描述符的索引号(选择符)。
#define _TSS(n) ((((unsigned long) n)<<4)+(FIRST_TSS_ENTRY<<3))
// 宏定义,计算在全局表中第n 个任务的LDT 描述符的索引号。
#define _LDT(n) ((((unsigned long) n)<<4)+(FIRST_LDT_ENTRY<<3))
// 宏定义,加载第n 个任务的任务寄存器tr。
//#define ltr(n) __asm__( "ltr %%ax":: "a" (_TSS(n)))
_inline void ltr(unsigned long n)
{
n=_TSS(n);
_asm{
ltr word ptr n
}
}
// 宏定义,加载第n 个任务的局部描述符表寄存器ldtr。
//#define lldt(n) __asm__( "lldt %%ax":: "a" (_LDT(n)))
_inline void lldt(unsigned long n)
{
n=_LDT(n);
_asm{
lldt word ptr n
}
}
......
// 调度程序的初始化子程序。
void sched_init (void)
{
int i;
struct desc_struct *p; // 描述符表结构指针。
if (sizeof (struct sigaction) != 16) // sigaction 是存放有关信号状态的结构。
panic ("Struct sigaction MUST be 16 bytes");
// 设置初始任务(任务0)的任务状态段描述符和局部数据表描述符(include/asm/system.h,65)。
set_tss_desc (gdt + FIRST_TSS_ENTRY, &(init_task.task.tss));
set_ldt_desc (gdt + FIRST_LDT_ENTRY, &(init_task.task.ldt));
// 清任务数组和描述符表项(注意i=1 开始,所以初始任务的描述符还在)。
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++;
}
/* 清除标志寄存器中的位NT,这样以后就不会有麻烦 */
// NT 标志用于控制程序的递归调用(Nested Task)。当NT 置位时,那么当前中断任务执行
// iret 指令时就会引起任务切换。NT 指出TSS 中的back_link 字段是否有效。
// __asm__ ("pushfl ; andl $0xffffbfff,(%esp) ; popfl"); // 复位NT 标志。
_asm pushfd; _asm and dword ptr ss:[esp],0xffffbfff; _asm popfd;
ltr (0); // 将任务0 的TSS 加载到任务寄存器tr。
lldt (0); // 将局部描述符表加载到局部描述符表寄存器。
// 注意!!是将GDT 中相应LDT 描述符的选择符加载到ldtr。只明确加载这一次,以后新任务
// LDT 的加载,是CPU 根据TSS 中的LDT 项自动加载。
// 下面代码用于初始化8253 定时器。
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);
}
TSS用于保存程序运行时一些寄存器的值,没有struct i387_struct i387;的话刚好104个字节(在sched.h的tss_struct里,我亲自数了数,刚好27个long),但我并没有找到i387的作用是什么。TSS段描述符见《设计艺术》P69。
进程描述结构体在sched.h的task_struct里,不过它们的存储位置在操作系统数据段。
set_tss_desc和set_ldt_desc是在设置gdt的项,指向了任务结构体里tss和ldt的位置,但是我没有弄明白的是,这里的&(init_task.task.tss)所指的是虚拟地址还是物理地址?目前所知的是,在C语言中,&a往往是指虚拟地址,但并没有找到在内核中的指向。
关于task_union,它在kernel\sched.c中:
union task_union
{ // 定义任务联合(任务结构成员和stack 字符数组程序成员)。
struct task_struct task; // 因为一个任务数据结构与其堆栈放在同一内存页中,所以
char stack[PAGE_SIZE]; // 从堆栈段寄存器ss 可以获得其数据段选择符。
};
我的理解是,分配了一个页的大小,但是只有前956B作为任务结构体的init,剩下的部分作为内核的堆栈,这里堆栈的长度被精心规划,以避免覆盖任务结构。
但是我感觉并没有看明白的一点是,如果task[64]中的每一项都是956B,那么总共岂不是达到近64KB的存储空间,但上图中内核的数据区似乎不大?另外task_union被分配的地址是谁来控制的呢?这些坑以后来填。
最后创建进程:
// 下面过程通过在堆栈中设置的参数,利用中断返回指令切换到任务0。
move_to_user_mode(); // 移到用户模式。(include/asm/system.h)
if (!fork()) { /* we count on this going ok */
init();
}
/*
* 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到一个信号才会返
* 回就绪运行态,但任务0(task0)是唯一的意外情况(参见'schedule()'),因为任
* 务0 在任何空闲时间里都会被激活(当没有其它任务在运行时),
* 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运行,如果没
* 有的话我们就回到这里,一直循环执行'pause()'。
*/
for(;;) pause();
} // end main
这里move_to_user_mode()也值得关注
_asm { \
_asm mov eax,esp /* 保存堆栈指针esp 到eax 寄存器中。*/\
_asm push 00000017h /* 首先将堆栈段选择符(SS)入栈。*/\
_asm push eax /* 然后将保存的堆栈指针值(esp)入栈。*/\
_asm pushfd /* 将标志寄存器(eflags)内容入栈。*/\
_asm push 0000000fh /* 将内核代码段选择符(cs)入栈。*/\
_asm push offset l1 /* 将下面标号l1 的偏移地址(eip)入栈。*/\
_asm iretd /* 执行中断返回指令,则会跳转到下面标号1 处。*/\
_asm l1: mov eax,17h /* 此时开始执行任务0,*/\
_asm mov ds,ax /* 初始化段寄存器指向本局部表的数据段。*/\
_asm mov es,ax \
_asm mov fs,ax \
_asm mov gs,ax \
}
由于是0进程(其他进程创建的时候只要fork父进程就行了),所以需要手动创建。
另外,linux是只允许进程在特权级为3的情况下fork新的进程,所以在中断返回时(这是特权级翻转的常用手段,低特权向高特权则需采用系统调用(int 80h)来实现),0号进程的特权级为3。特权级翻转的一些细节见参考博客5。
其余初始化函数在《设计艺术》的P59~79都有简要介绍,在此不作赘述。
init()
void init(void)
{
int pid,i;
// 读取硬盘参数包括分区表信息并建立虚拟盘和安装根文件系统设备。
// 该函数是在25 行上的宏定义的,对应函数是sys_setup(),在kernel/blk_drv/hd.c。
setup((void *) &drive_info);
(void) open("/dev/tty0",O_RDWR,0); // 用读写访问方式打开设备“/dev/tty0”,
// 这里对应终端控制台。
// 返回的句柄号0 -- stdin 标准输入设备。
(void) dup(0); // 复制句柄,产生句柄1 号-- stdout 标准输出设备。
(void) dup(0); // 复制句柄,产生句柄2 号-- stderr 标准出错输出设备。
printf("%d buffers = %d bytes buffer space\n\r",NR_BUFFERS, \
NR_BUFFERS*BLOCK_SIZE); // 打印缓冲区块数和总字节数,每块1024 字节。
printf("Free mem: %d bytes\n\r",memory_end-main_memory_start);//空闲内存字节数。
// 下面fork()用于创建一个子进程(子任务)。对于被创建的子进程,fork()将返回0 值,
// 对于原(父进程)将返回子进程的进程号。所以if (!(pid=fork())) {...} 内是子进程执行的内容。
// 该子进程关闭了句柄0(stdin),以只读方式打开/etc/rc 文件,并执行/bin/sh 程序,所带参数和
// 环境变量分别由argv_rc 和envp_rc 数组给出。参见后面的描述。
if (!(pid=fork())) {
close(0);
if (open("/etc/rc",O_RDONLY,0))//系统配置文件,挂接了文件配置
_exit(1); // 如果打开文件失败,则退出(/lib/_exit.c)。
execve("/bin/sh",argv_rc,envp_rc); // 装入/bin/sh 程序并执行。(/lib/execve.c) argv_rc参数 envp_rc 环境变量
_exit(2); // 若execve()执行失败则退出(出错码2,“文件或目录不存在”)。
}
// 下面是父进程执行的语句。wait()是等待子进程停止或终止,其返回值应是子进程的
// 进程号(pid)。这三句的作用是父进程等待子进程的结束。&i 是存放返回状态信息的
// 位置。如果wait()返回值不等于子进程号,则继续等待(即处理孤儿进程)。
if (pid>0)
while (pid != wait(&i))
{ /* nothing */;}
// --
// 如果执行到这里,说明刚创建的子进程的执行已停止或终止了。下面循环中首先再创建
// 一个子进程,如果出错,则显示“初始化程序创建子进程失败”的信息并继续执行。对
// 于所创建的子进程关闭所有以前还遗留的句柄(stdin, stdout, stderr),新创建一个
// 会话并设置进程组号,然后重新打开/dev/tty0 作为stdin,并复制成stdout 和stderr。
// 再次执行系统解释程序/bin/sh。但这次执行所选用的参数和环境数组另选了一套(见上面)。
// 然后父进程再次运行wait()等待。如果子进程又停止了执行,则在标准输出上显示出错信息
// “子进程pid 停止了运行,返回码是i”,
// 然后继续重试下去…,形成“大”死循环。
while (1) {
if ((pid=fork())<0) {
printf("Fork failed in init\r\n");
continue;
}
if (!pid) {
close(0);close(1);close(2);
setsid();
(void) open("/dev/tty0",O_RDWR,0);
(void) dup(0);
(void) dup(0);
_exit(execve("/bin/sh",argv,envp));
}
while (1)
if (pid == wait(&i))
break;
printf("\n\rchild %d died with code %04x\n\r",pid,i);
sync();
}
_exit(0); /* NOTE! _exit, not exit() */
}
这里的逻辑很简单,1号进程一直在循环创建2号进程(众所周知,1号进程是0号进程以外的所有进程的父进程)
while (pid != wait(&i))应该是在处理孤儿进程。
"/etc/rc"是系统配置文件,挂接文件设置。
execve("/bin/sh",argv_rc,envp_rc);则是执行shell程序,这部分参考《完全注释》P247。
exit(0)与_exit(0)见参考博客6,虽然我还是没搞明白啥时候该用哪个。
如此一来,linux的启动部分的笔记暂时告一段落,由于本人的水平实在有限,多有谬误和不全之处。另外,光看代码是没用的,自己上手操作和改动才有好的效果,目前能找到的lab只有mit 6.s081,过段时间看完进程和文件管理篇试着做做吧~