Linux内核《运行原理及初始化流程》

2023-02-20 00:00:00 线程 内核 初始化 进程 中断

【内核运行原理】

一、开题

我们分析了从按下电源键到BootLoader完成加载的过程。加载完成之后,就要正式启动Linux内核了,而在这之前首先要完成从实模式到保护模式的切换。本文主要分析以下几部分内容

  • 新旧中断的交替
  • 打开A20
  • 进入main函数
  • 内核初始化

其实整个过程中还有很多内容,比如检查各种硬件设备等,在此略过不提。下面就开始潜入Linux源码的海洋畅游啦。

二. 新旧中断的交替

在实模式下的中断显然不可以和保护模式的中断同日而语,因此我们需要关闭旧的中断(cli)并确立新的中断(sti)。main函数能够适应保护模式的中断服务体系被重建完毕才会打开中断,而那时候响应中断的服务程序将不再是BIOS提供的中断服务程序,取而代之的是由系统自身提供的中断服务程序。

cli、sti总是在一个完整操作过程的两头出现,目的是避免中断在此期间的介入。接下来的代码将为操作系统进入保护模式做准备。此处即将进行实模式下中断向量表和保护模式下中断描述符表(IDT)的交接工作。试想,如果没有cli,又恰好发生中断,如用户不小心碰了一下键盘,中断就要切进来,就不得不面对实模式的中断机制已经废除、保护模式的中断机制尚未完成的尴尬局面,结果就是系统崩溃。cli、sti保证了这个过程中,IDT能够完整创建,以避免不可预料中断的进入造成IDT创建不完整或新老中断机制混用。

#boot/setup.s …… do _move: mov es,ax!destination segment add ax,#0x1000 cmp ax,#0x9000 jz end_move mov ds,ax!source segment sub di,di sub si,si mov cx,#0x8000 rep movsw jmp do_move
Bash

如上代码主要完成了一项工作:将位于0x10000处的内核程序复制至内存地址起始位置0x00000处。在 上一节 我们分析了实模式中的存储分布图,在此位置原来存放着由BIOS建立的中断向量表及BIOS数据区。这个复制动作将BIOS中断向量表和BIOS数据区完全覆盖,使它们不复存在。这样做的好处如下:

1、废除BIOS的中断向量表,等同于废除了BIOS提供的实模式下的中断服务程序。
2、收回刚刚结束使用寿命的程序所占内存空间。
3、让内核代码占据内存物理地址开始的、天然的、有利的位置。

此时,重要角色要登场了,他们就是中断描述符表IDT和全局描述符表GDT。

  • GDTGlobal Descriptor Table,全局描述符表),在系统中的存放段寄存器内容(段描述符)的数组,配合程序进行保护模式下的段寻址。它在操作系统的进程切换中具有重要意义,可理解为所有进程的总目录表,其中存放每一个任务(task)局部描述符表(LDT, Local Descriptor Table)地址和任务状态段(TSS, Task Structure Segment)地址,完成进程中各段的寻址、现场保护与现场恢复。GDTR是GDT基地址寄存器,当程序通过段寄存器引用一个段描述符时,需要取得GDT的入口,GDTR标识的即为此入口。在操作系统对GDT的初始化完成后,可以用LGDT(Load GDT)指令将GDT基地址加载至GDTR。
  • IDTInterrupt Descriptor Table,中断描述符表),保存保护模式下所有中断服务程序的入口地址,类似于实模式下的中断向量表。IDTR(IDT基地址寄存器),保存IDT的起始地址。

32位的中断机制和16位的中断机制,在原理上有比较大的差别。明显的是16位的中断机制用的是中断向量表,中断向量表的起始位置在0x00000处,这个位置是固定的;32位的中断机制用的是中断描述符表(IDT),位置是不固定的,可以由操作系统的设计者根据设计要求灵活安排,由IDTR来锁定其位置。GDT是保护模式下管理段描述符的数据结构,对操作系统自身的运行以及管理、调度进程有重大意义。

此时此刻内核尚未真正运行起来,还没有进程,所以现在创建的GDT项为空,第二项为内核代码段描述符,第三项为内核数据段描述符,其余项皆为空。IDT虽然已经设置,实为一张空表,原因是目前已关中断,无需调用中断服务程序。此处反映的是数据“够用即得”的思想。

创建这两个表的过程可理解为是分两步进行的:

1、在设计内核代码时,已经将两个表写好,并且把需要的数据也写好。此处的数据区域是在内核源代码中设定、编译并直接加载至内存形成的一块数据区域。专用寄存器的指向由程序中的lidt和lgdt指令完成
2、将专用寄存器(IDTR、GDTR)指向表。

三. A20

A20启用是一个标志性的动作,由上文提到的lzma_decompress.img 调用 real_to_prot启动。打开A20,意味着CPU可以进行32位寻址,大寻址空间为4 GB。注意图1-19中内存条范围的变化:从5个F扩展到8个F,即0xFFFFFFFF(4 GB)。

实模式下,当程序寻址超过0xFFFFF时,CPU将“回滚”至内存地址起始处寻址(注意,在只有20根地址线的条件下,0xFFFFF+1=0x00000,高位溢出)。例如,系统的段寄存器(如CS)的大允许地址为0xFFFF,指令指针(IP)的大允许段内偏移也为0xFFFF,两者确定的大地址为0x10FFEF,这将意味着程序中可产生的实模式下的寻址范围比1 MB多出将近64 KB(一些特殊寻址要求的程序就利用了这个特点)。这样,此处对A20地址线的启用相当于关闭CPU在实模式下寻址的“回滚”机制。如下所示为利用此特点来验证A20地址线是否确实已经打开。注意此处代码并不在此时运行,而是在后续head运行过程中为了检测是否处于保护模式中使用。

#boot/head.s
……
xorl %eax,%eax
1:incl%eax#check that A20 really IS enabled
movl %eax,0x000000#loop forever if it isn't
cmpl %eax,0x100000
je 1b
……

A20如果没打开,则计算机处于20位的寻址模式,超过0xFFFFF寻址必然“回滚”。一个特例是0x100000会回滚到0x000000,也就是说,地址0x100000处存储的值必然和地址0x000000处存储的值完全相同。通过在内存0x000000位置写入一个数据,然后比较此处和1 MB(0x100000,注意,已超过实模式寻址范围)处数据是否一致,就可以检验A20地址线是否已打开。

四. 进入main函数

这里涉及到一个硬件知识:在X86体系中,采用的终端控制芯片名为8259A,此芯片,是可以用程序控制的中断控制器。单个的8259A能管理8级向量优先级中断,在不增加其他电路的情况下,多可以级联成64级的向量优先级中断系统。CPU在保护模式下,int 0x00~int 0x1F被Intel保留作为内部(不可屏蔽)中断和异常中断。如果不对8259A进行重新编程,int 0x00~int 0x1F中断将被覆盖。例如,IRQ0(时钟中断)为8号(int 0x08)中断,但在保护模式下此中断号是Intel保留的“Double Fault”(双重故障)。因此,必须通过8259A编程将原来的IRQ0x00~IRQ0x0F对应的中断号重新分布,即在保护模式下,IRQ0x00~IRQ0x0F的中断号是int 0x20~int 0x2F。

setup程序通过下面代码将CPU工作方式设为保护模式。这里涉及到一个CR0寄存器:0号32位控制寄存器,放系统控制标志。第0位为PE(Protected Mode Enable,保护模式使能)标志,置1时CPU工作在保护模式下,置0时为实模式。将CR0寄存器第0位(PE)置1,即设定处理器工作方式为保护模式。CPU工作方式转变为保护模式,一个重要的特征就是要根据GDT决定后续执行哪里的程序。前文提到GDT初始时已写好了数据,这些将用来完成从setup程序到head程序的跳转。

#boot/setup.s
mov ax,#0x0001!protected mode(PE)bit
lmsw ax!This is it!
jmpi 0,8!jmp offset 0 of segment 8(cs)

head程序是进入main之前的后一步了。head在空间创建了内核分页机制,即在0x000000的位置创建了页目录表、页表、缓冲区、GDT、IDT,并将head程序已经执行过的代码所占内存空间覆盖。这意味着head程序自己将自己废弃,main函数即将开始执行。具体的分页机制因为较为复杂,所以打算放在后续介绍内存管理的部分再单独介绍。

head构造IDT,使中断机制的整体架构先搭建起来(实际的中断服务程序挂接则在main函数中完成),并使所有中断服务程序指向同一段只显示一行提示信息就返回的服务程序。从编程技术上讲,这种初始化操作,既可以防止无意中覆盖代码或数据而引起的逻辑混乱,也可以对开发过程中的误操作给出及时的提示。IDT有256个表项,实际只使用了几十个,对于误用未使用的中断描述符,这样的提示信息可以提醒开发人员注意错误。

除此之外,head程序要废除已有的GDT,并在内核中的新位置重新创建GDT。原来GDT所在的位置是设计代码时在setup.s里面设置的数据,将来这个setup模块所在的内存位置会在设计缓冲区时被覆盖。如果不改变位置,将来GDT的内容肯定会被缓冲区覆盖掉,从而影响系统的运行。这样一来,将来整个内存中安全的地方就是现在head.s所在的位置了。

下来步骤主要包括

1、初始化段寄存器和堆栈

2、主要包括将DS和ES寄存器指向相同的地址,并将DS和CS设置为相同的值。

3、清零eflag寄存器以及内核未初始化数据区

4、调用decompress_kernel()解压内核映像并跳转至0X00100000处。

5、段寄存器初始化为终值并填充BSS字段为0

初始化临时内核页表

终完成了分页机制初始化后,PG(Paging) 标志位将会置1,表示地址映射模式采取分页机制,终跳转至main函数,内核开始初始化工作。

五. 内核初始化

注意,至此为止,我们尚未打开中断,而必须通过main函数完成一系列的初始化后才会打开新的中断,从而使内核正式运行起来。该部分主要包括:

1、为进程0建立内核态堆栈

2、清零eflags寄存器

3、调用setup_idt()用空的中断处理程序填充IDT

4、把BIOS中获得的参数传递给个页框

5、用GDT和IDT表填充寄存器

完成这些之后,内核就正式运行,开始创建0号进程了。

六. 总结

本文介绍了实模式到保护模式的整个切换过程,完成了内核的加载并开始正式准备创建0号进程。后续将继续分析启动内核创建0号、1号、2号进程的整个过程。


【内核初始化过程】

Linux内核正式启动,完成了实模式到保护模式的切换,并做好了各种准备工作。下来就要看开始内核初始化工作了,源码位置位于init/main.c中的start_kernel()。这包括了一系列重要的初始化工作,本文会介绍其中一部分较为重要的,但是详细的介绍依然会留在后文各个模块的源码学习中单独进行。本文的目的在于承接上文给出一个从内核启动到各个模块开始运转的过程介绍,而不是详细的各部分内容介绍。

  • 创建0号进程:INIT_TASK(init_task)
  • 异常处理类中断服务程序挂接:trap_init()
  • 内存初始化:mm_init()
  • 调度器初始化sched_init()
  • 剩余初始化:rest_init()

二. 0号进程的创建

start_kernel()上来就会运行 set_task_stack_end_magic(&init_task)创建初始进程。init_task的定义是 struct task_struct init_task = INIT_TASK(init_task)。它是系统创建的个进程,我们称为 0 号进程。这是一个没有通过 fork 或者 kernel_thread产生的进程,是进程列表的个。

如下所示为init_task的定义,这里只节选了部分,采用了gcc的结构体初始化方式为其进行了直接赋值生成。

/*
* Set up the first task table, touch at your own risk!. Base=0,
* limit=0x1fffff (=2MB)
*/
struct task_struct init_task
#ifdef CONFIG_ARCH_TASK_STRUCT_ON_STACK
__init_task_data
#endif
= {
......
.state = 0,
.stack = init_stack,
.usage = REFCOUNT_INIT(2),
.flags = PF_KTHREAD,
.prio = MAX_PRIO - 20,
.static_prio = MAX_PRIO - 20,
.normal_prio = MAX_PRIO - 20,
.policy = SCHED_NORMAL,
.cpus_ptr = &init_task.cpus_mask,
.cpus_mask = CPU_MASK_ALL,
.nr_cpus_allowed = NR_CPUS,
.mm = NULL,
.active_mm = &init_mm,
......
.thread_pid = &init_struct_pid,
.thread_group = LIST_HEAD_INIT(init_task.thread_group),
.thread_node = LIST_HEAD_INIT(init_signals.thread_head),
......
};
EXPORT_SYMBOL(init_task);

而 set_task_stack_end_magic(&init_task)函数的源码如下,主要是通过end_of_stack()获取栈边界地址,然后把栈底地址设置为STACK_END_MAGIC,作为栈溢出的标记。每个进程创建的时候,系统会为这个进程创建2个页大小的内核栈。

void set_task_stack_end_magic(struct task_struct *tsk)
{
unsigned long *stackend;

stackend = end_of_stack(tsk);
*stackend = STACK_END_MAGIC; /* for overflow detection */
}

init_task是静态定义的一个进程,也就是说当内核被放入内存时,它就已经存在,它没有自己的用户空间,一直处于内核空间中运行,并且也只处于内核空间运行。0号进程用于包括内存、页表、必要数据结构、信号、调度器、硬件设备等的初始化。当它执行到后(剩余初始化)时,将start_kernel中所有的初始化执行完成后,会在内核中启动一个kernel_init内核线程和一个kthreadd内核线程,kernel_init内核线程执行到后会通过execve系统调用执行转变为我们所熟悉的init进程,而kthreadd内核线程是内核用于管理调度其他的内核线程的守护线程。在后init_task将变成一个idle进程,用于在CPU没有进程运行时运行它,它在此时仅仅用于空转。

三. 中断初始化

由代码可见,trap_init()设置了很多的中断门(Interrupt Gate),用于处理各种中断,如系统调用的中断门set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32)。

void trap_init(void)
{
int i;
//设置系统的硬件中断 中断位于kernel/asm.s 或 system_call.s
set_trap_gate(0,÷_error);//0中断,位于/kernel/asm.s 19行
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);
outb_p(inb_p(0x21)&0xfb,0x21);
outb(inb_p(0xA1)&0xdf,0xA1);
set_trap_gate(39,¶llel_interrupt);
}

四. 内存初始化

内存相关的初始化内容放在mm_init()中进行,代码如下所示

// init/main.c
/*
* Set up kernel memory allocators
*/
static void __init mm_init(void)
{
/*
* page_ext requires contiguous pages,
* bigger than MAX_ORDER unless SPARSEMEM.
*/
page_ext_init_flatmem();
mem_init();
kmem_cache_init();
pgtable_init();
vmalloc_init();
ioremap_huge_init();
/* Should be run before the first non-init thread is created */
init_espfix_bsp();
/* Should be run after espfix64 is set up. */
pti_init();
}

调用的函数功能基本如名字所示,主要进行了以下初始化设置:

  • page_ext_init_flatmem()和cgroup的初始化相关,该部分是docker技术的核心部分
  • mem_init()初始化内存管理的伙伴系统
  • kmem_cache_init()完成内核slub内存分配体系的初始化,相关的还有buffer_init
  • pgtable_init()完成页表初始化,包括页表锁ptlock_init()和
  • vmalloc_init()完成vmalloc的初始化
  • ioremap_huge_init() ioremap实现I/O内存资源由物理地址映射到虚拟地址空间,此处为其功能的初始化
  • init_espfix_bsp()和pti_init()完成PTI(page table isolation)的初始化

此处不展开说明这些函数,留待后面内存管理部分详细分析各个部分。

五. 调度器初始化

调度器初始化通过sched_init()完成,其主要工作包括

  • 对相关数据结构分配内存:如初始化waitqueues数组,根据调度方式FAIR/RT设置alloc_size,调用kzalloc分配空间
  • 初始化root_task_group:根据FAIR/RT的不同,将kzalloc分配的空间用于其初始化,主要结构task_group包含以下几个重要组成部分:se, rt_se, cfs_rq 以及 rt_rq。其中cfs_rq和rt_rq表示run queue,即一种特殊的per-cpu结构体用于内核调度器存储激活的线程。
  • 调用for_each_possible_cpu()初始化每个possibleCPU(存储于cpu_possible_mask为图中)的runqueue队列(包括其中的cfs队列和实时进程队列),rq结构体是调度进程的基本数据结构,调度器用rq决定下一个将要被调度的进程。详细介绍会在调度一节进行。
  • 调用set_load_weight(&init_task),将init_task进程转变为idle进程
  • 需要说明的是init_task在这里会被转变为idle进程,但是它还会继续执行初始化工作,相当于这里只是给init_task挂个idle进程的名号,它其实还是init_task进程,只有到后init_task进程开启了kernel_init和kthreadd进程之后,才转变为真正意义上的idle进程。

六. 剩余初始化

rest_init是非常重要的一步,主要包括了区分内核态和用户态、初始化1号进程和初始化2号进程。

内核态和用户态

在运行用户进程之前,尚需要完成一件事:区分内核态和用户态。x86 提供了分层的权限机制,把区域分成了四个 Ring,越往里权限越高,越往外权限越低。操作系统很好地利用了这个机制,将能够访问关键资源的代码放在 Ring0,我们称为内核态(Kernel Mode);将普通的程序代码放在 Ring3,我们称为用户态(User Mode)。

初始化1号进程

rest_init() 的一大工作是,用 kernel_thread(kernel_init, NULL, CLONE_FS)创建第二个进程,这个是 1 号进程。1 号进程对于操作系统来讲,有“划时代”的意义,因为它将运行一个用户进程,并从此开始形成用户态进程树。这里主要需要分析的是如何完成从内核态到用户态切换的过程。kernel_thread()代码如下所示,可见其中主要的是个参数指针函数fn决定了栈中的内容,根据fn的不同将生成1号进程和后面的2号进程。

/*
 * Create a kernel thread.
 */
pid_t kernel_thread(int (*fn)(void *), void *arg, unsigned long flags)
{
    struct kernel_clone_args args = {
        .flags        = ((flags | CLONE_VM | CLONE_UNTRACED) & ~CSIGNAL),
        .exit_signal    = (flags & CSIGNAL),
        .stack        = (unsigned long)fn,
        .stack_size    = (unsigned long)arg,
    };

    return _do_fork(&args);
}

相关文章