Linux操作系統學習筆記(三)內核初始化

前言

  前文分析到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_rqrt_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_initkthreadd進程之後,才轉變爲真正意義上的idle進程。

剩餘初始化

  rest_init是非常重要的一步,主要包括了區分內核態和用戶態、初始化1號進程和初始化2號進程。

內核態和用戶態

  在運行用戶進程之前,尚需要完成一件事:區分內核態和用戶態。x86 提供了分層的權限機制,把區域分成了四個 Ring,越往裏權限越高,越往外權限越低。操作系統很好地利用了這個機制,將能夠訪問關鍵資源的代碼放在 Ring0,我們稱爲內核態(Kernel Mode);將普通的程序代碼放在 Ring3,我們稱爲用戶態(User Mode)。

img

初始化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);
}

  kernel_thread() 的參數是一個函數 kernel_init(),核心代碼如下:

if (ramdisk_execute_command) 
{ 
    ret = run_init_process(ramdisk_execute_command);
    ...... 
}
...... 
if (!try_to_run_init_process("/sbin/init") || 
    !try_to_run_init_process("/etc/init")  || 
    !try_to_run_init_process("/bin/init")  || 
    !try_to_run_init_process("/bin/sh")) 
   return 0;

  這就說明,1 號進程運行的是一個文件。如果我們打開 run_init_process() 函數,會發現它調用的是 do_execve()

static int run_init_process(const char *init_filename)
{ 
    argv_init[0] = init_filename; 
    return do_execve(getname_kernel(init_filename), 
                     (const char __user *const __user *)argv_init, 
                     (const char __user *const __user *)envp_init);
}

  接着會進行一系列的調用:do_execve->do_execveat_common->exec_binprm->search_binary_handler,這裏search_binary_handler()主要是加載ELF文件(Executable and Linkable Format,可執行與可鏈接格式),代碼如下

int search_binary_handler(struct linux_binprm *bprm)
{ 
    ...... 
    struct linux_binfmt *fmt; 
    ...... 
    retval = fmt->load_binary(bprm); 
    ......
}

  load_binary先調用load_elf_binary,最後調用start_thread

void
start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)
{
    set_user_gs(regs, 0);
    regs->fs  = 0;
    regs->ds  = __USER_DS;
    regs->es  = __USER_DS;
    regs->ss  = __USER_DS;
    regs->cs  = __USER_CS;
    regs->ip  = new_ip;
    regs->sp  = new_sp;
    regs->flags  = X86_EFLAGS_IF;
    force_iret();
}
EXPORT_SYMBOL_GPL(start_thread);

  這個結構就是在系統調用的時候,內核中保存用戶態運行上下文的,裏面將用戶態的代碼段 CS 設置爲 __USER_CS,將用戶態的數據段 DS 設置爲 __USER_DS,以及指令指針寄存器 IP、棧指針寄存器 SP。這裏相當於補上了原來系統調用裏,保存寄存器的一個步驟。最後的 iret 是幹什麼的呢?它是用於從系統調用中返回。這個時候會恢復寄存器。從哪裏恢復呢?按說是從進入系統調用的時候,保存的寄存器裏面拿出。好在上面的函數補上了寄存器。CS 和指令指針寄存器 IP 恢復了,指向用戶態下一個要執行的語句。DS 和函數棧指針 SP 也被恢復了,指向用戶態函數棧的棧頂。所以,下一條指令,就從用戶態開始運行了。

  經過上述過程,我們完成了從內核態切換到用戶態。而此時代碼其實還在運行 kernel_init函數,會調用

if (!ramdisk_execute_command)
    ramdisk_execute_command = "/init";

  結合上面的init程序,這裏出現了第二個init。這是有其存在的必要性的:上文提到的 init 程序是在文件系統上的,文件系統一定是在一個存儲設備上的,例如硬盤。Linux 訪問存儲設備,要有驅動才能訪問。如果存儲系統數目很有限,那驅動可以直接放到內核裏面,反正前面我們加載過內核到內存裏了,現在可以直接對存儲系統進行訪問。但是存儲系統越來越多了,如果所有市面上的存儲系統的驅動都默認放進內核,內核就太大了。這該怎麼辦呢?

  我們只好先弄一個基於內存的文件系統。內存訪問是不需要驅動的,這個就是 ramdisk。這個時候,ramdisk 是根文件系統。然後,我們開始運行 ramdisk 上的 /init。等它運行完了就已經在用戶態了。/init 這個程序會先根據存儲系統的類型加載驅動,有了驅動就可以設置真正的根文件系統了。有了真正的根文件系統,ramdisk 上的 /init 會啓動文件系統上的 init。接下來就是各種系統的初始化。啓動系統的服務,啓動控制檯,用戶就可以登錄進來了。

初始化2號進程

  rest_init 另一大事情就是創建第三個進程,就是 2 號進程。kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES)又一次使用 kernel_thread 函數創建進程。這裏需要指出一點,函數名 thread 可以翻譯成“線程”,這也是操作系統很重要的一個概念。從內核態來看,無論是進程,還是線程,我們都可以統稱爲任務(Task),都使用相同的數據結構,平放在同一個鏈表中。這裏的函數kthreadd,負責所有內核態的線程的調度和管理,是內核態所有線程運行的祖先。

  kthreadd,即2號進程,用於內核態線程的管理,是一個守護線程。其源碼如下所示,運行流程包括

  • 初始化了task結構,並將該線程設置爲允許任意CPU運行。
  • 進入循環,將線程狀態設置爲TASK_INTERRUPTIBLE,如果當前kthread_create_list爲空,沒有要創建的線程,則執行schedule()讓出CPU資源。
  • 如果需要創建,則設置爲TASK_RUNNING狀態,加上鎖spin_lock,從鏈表中取得kthread_create_info 結構的地址,在上文中已經完成插入操作(將kthread_create_info結構中的 list 成員加到鏈表中,此時根據成員 list 的偏移獲得 create)
  • 調用create_kthread(create)完成線程的創建
int kthreadd(void *unused)
{
	struct task_struct *tsk = current;

	/* Setup a clean context for our children to inherit. */
	set_task_comm(tsk, "kthreadd");
	ignore_signals(tsk);
	set_cpus_allowed_ptr(tsk, cpu_all_mask);
	set_mems_allowed(node_states[N_MEMORY]);

	current->flags |= PF_NOFREEZE;
	cgroup_init_kthreadd();

	for (;;) {
		set_current_state(TASK_INTERRUPTIBLE);
		if (list_empty(&kthread_create_list))
			schedule();
		__set_current_state(TASK_RUNNING);

		spin_lock(&kthread_create_lock);
		while (!list_empty(&kthread_create_list)) {
			struct kthread_create_info *create;

			create = list_entry(kthread_create_list.next,
					    struct kthread_create_info, list);
			list_del_init(&create->list);
			spin_unlock(&kthread_create_lock);

			create_kthread(create);

			spin_lock(&kthread_create_lock);
		}
		spin_unlock(&kthread_create_lock);
	}

	return 0;
}

  而create_kthread(create)函數做了一件讓人意外的事情:調用了kernel_thread(),所以又回到了創建1號進程和2號進程的函數上,這次的回調函數爲kthread,該函數纔會真正意義上分配內存、初始化一個新的內核線程。

static void create_kthread(struct kthread_create_info *create)
{
	int pid;

#ifdef CONFIG_NUMA
	current->pref_node_fork = create->node;
#endif
	/* We want our own signal handler (we take no signals by default). */
	pid = kernel_thread(kthread, create, CLONE_FS | CLONE_FILES | SIGCHLD);
	if (pid < 0) {
		/* If user was SIGKILLed, I release the structure. */
		struct completion *done = xchg(&create->done, NULL);

		if (!done) {
			kfree(create);
			return;
		}
		create->result = ERR_PTR(pid);
		complete(done);
	}
}

  下面是kthread的源碼,這裏有個很重要的地方:新創建的線程由於執行了 schedule() 調度,此時並沒有執行,直到我們使用wake_up_process(p)喚醒新創建的線程。線程被喚醒後, 會接着執行最後一段threadfn(data)

static int kthread(void *_create)
{
	/* Copy data: it's on kthread's stack */
	struct kthread_create_info *create = _create;
	int (*threadfn)(void *data) = create->threadfn;
	void *data = create->data;
	struct completion *done;
	struct kthread *self;
	int ret;

	self = kzalloc(sizeof(*self), GFP_KERNEL);
	set_kthread_struct(self);

	/* If user was SIGKILLed, I release the structure. */
	done = xchg(&create->done, NULL);
	if (!done) {
		kfree(create);
		do_exit(-EINTR);
	}

	if (!self) {
		create->result = ERR_PTR(-ENOMEM);
		complete(done);
		do_exit(-ENOMEM);
	}

	self->data = data;
	init_completion(&self->exited);
	init_completion(&self->parked);
	current->vfork_done = &self->exited;

	/* OK, tell user we're spawned, wait for stop or wakeup */
	__set_current_state(TASK_UNINTERRUPTIBLE);
	create->result = current;
	/*
	 * Thread is going to call schedule(), do not preempt it,
	 * or the creator may spend more time in wait_task_inactive().
	 */
	preempt_disable();
	complete(done);
	schedule_preempt_disabled();
	preempt_enable();

	ret = -EINTR;
	if (!test_bit(KTHREAD_SHOULD_STOP, &self->flags)) {
		cgroup_kthread_ready();
		__kthread_parkme(self);
		ret = threadfn(data);
	}
	do_exit(ret);
}

  由此,我們可以總結一下第2號進程的工作流程:

  • 第2號進程kthreadd進程由第0號進程通過kernel_thread()創建,並始終運行在內核空間, 負責所有內核線程的調度和管理
  • 第2號進程會循環檢測kthread_create_list全局鏈表, 當我們調用kernel_thread創建內核線程時,新線程會被加入到此鏈表中,因此所有的內核線程都是直接或者間接的以kthreadd爲父進程
  • 檢測到新線程創建,則調用kernel_thread()創建線程,其回調爲kthread
  • kthread在創建完後調用schedule()讓出CPU資源,而不是直接運行。等待收到wake_up_process(p)的喚醒後再繼續執行threadfn(data)

因此

  • 任何一個內核線程入口都是 kthread()

  • 通過kthread_create()創建的內核線程不會立刻運行,需要手工 wake up.

  • 通過kthread_create() 創建的內核線程有可能不會執行相應線程函數threadfn而直接退出

  回到rest_init(),當完成了1號2號進程的創建後,我們將0號進程真正歸位idle進程,結束rest_init(),也正事結束了start_kernel()函數,由此,內核初始化全部完成。

總結

  本文介紹了內核初始化的幾個重要部分,其實還有很多初始化沒有介紹,如cgroup初始化、虛擬文件系統初始化、radix樹初始化、rcu初始化、計時器和時間初始化、架構初始化等等,這些會在後面有針對性的單獨介紹。

源碼資料

[1] init/main.c

參考資料

[1] Linux-insides

[2] 深入理解Linux內核源碼

[3] Linux內核設計的藝術

[4] 極客時間 趣談Linux操作系統

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章