lab 3: 程序與執行緒
前言:timeout情況不再贅述。
有沒有感到編譯時間已經長到難以忍受?是的,作者在第一次編譯的時候甚至深受編譯的困擾(長達10分鐘!)評分的時候,大家也想要很快地看到綠色的100分,因此,作者提供一個歪招給大家參考(慎用!)
在Scripts/kernel.mk
中的grade處,註釋掉make distclean
。
慎用!慎用!
實驗報告僅供個人參考,不對正確性負責!
Contents
使用者程序是作業系統對在使用者模式執行中的程式的抽象。在Lab 1 和Lab 2 中,已經完成了核心的啟動和實體記憶體的管理,以及一個可供使用者程序使用的頁表實現。現在,我們將一步一步支援使用者態程式的執行。 本實驗包括五個部分:
- 程式碼導讀,瞭解Chcore微核心的核心機制以及使用者態和核心態是如何進行互動的。
- 執行緒管理: 支援建立第一個使用者態程序和執行緒,分析程式碼如何從核心態切換到使用者態。
- 異常處理: 完善異常處理流程,為系統新增必要的異常處理的支援。
- 系統呼叫:正確處理部分系統呼叫,保證使用者程式的正常輸出。
- 使用者態程式編寫:編寫一個簡單使用者程式,使用提供的 ChCore libc 進行編譯,並載入至核心映象中。
3.1 程式碼導讀
首先我們需要結合lab2中的main函式來了解核心初始化的過程。本次程式碼導讀主要聚焦從main函式開始自上而下講解Lab2 Lab3核心態的資源管理機制以及使用者態和核心態的互相呼叫。
核心初始化
/*
* @boot_flag is boot flag addresses for smp;
* @info is now only used as board_revision for rpi4.
*/
void main(paddr_t boot_flag, void *info)
{
u32 ret = 0;
/* Init big kernel lock */
ret = lock_init(&big_kernel_lock);
kinfo("[ChCore] lock init finished\n");
BUG_ON(ret != 0);
/* Init uart: no need to init the uart again */
uart_init();
kinfo("[ChCore] uart init finished\n");
/* Init per_cpu info */
init_per_cpu_info(0);
kinfo("[ChCore] per-CPU info init finished\n");
/* Init mm */
mm_init(info);
kinfo("[ChCore] mm init finished\n");
void lab2_test_buddy(void);
lab2_test_buddy();
void lab2_test_kmalloc(void);
lab2_test_kmalloc();
void lab2_test_page_table(void);
lab2_test_page_table();
#if defined(CHCORE_KERNEL_PM_USAGE_TEST)
void lab2_test_pm_usage(void);
lab2_test_pm_usage();
#endif
/* Mapping KSTACK into kernel page table. */
map_range_in_pgtbl_kernel((void*)((unsigned long)boot_ttbr1_l0 + KBASE),
KSTACKx_ADDR(0),
(unsigned long)(cpu_stacks[0]) - KBASE,
CPU_STACK_SIZE, VMR_READ | VMR_WRITE);
/* Init exception vector */
arch_interrupt_init();
timer_init();
kinfo("[ChCore] interrupt init finished\n");
/* Enable PMU by setting PMCR_EL0 register */
pmu_init();
kinfo("[ChCore] pmu init finished\n");
/* Init scheduler with specified policy */
#if defined(CHCORE_KERNEL_SCHED_PBFIFO)
sched_init(&pbfifo);
#elif defined(CHCORE_KERNEL_RT)
sched_init(&pbrr);
#else
sched_init(&rr);
#endif
kinfo("[ChCore] sched init finished\n");
init_fpu_owner_locks();
/* Other cores are busy looping on the boot_flag, wake up those cores */
enable_smp_cores(boot_flag);
kinfo("[ChCore] boot multicore finished\n");
#ifdef CHCORE_KERNEL_TEST
kinfo("[ChCore] kernel tests start\n");
run_test();
kinfo("[ChCore] kernel tests done\n");
#endif /* CHCORE_KERNEL_TEST */
#if FPU_SAVING_MODE == LAZY_FPU_MODE
disable_fpu_usage();
#endif
/* Create initial thread here, which use the `init.bin` */
create_root_thread();
kinfo("[ChCore] create initial thread done\n");
kinfo("End of Kernel Checkpoints: %s\n", serial_number);
/* Leave the scheduler to do its job */
sched();
/* Context switch to the picked thread */
eret_to_thread(switch_context());
}
以下為Chcore核心初始化到執行第一個使用者執行緒的主要流程圖
我們在Lab2
中主要完成mm_init以及記憶體管理器與vmspace和pmo的互聯,現在我們再從第一個執行緒建立的資料流來梳理並分析
Chcore微核心的資源管理模式。
核心物件管理
在Chcore中所有的系統資源都叫做object(物件),用物件導向的方法進行理解的話,object即為不同核心物件例如vmspace, pmo, thread(等等)的父類,
Chcore透過能力組機制管理所有的系統資源,能力組本身只是一個包含指向object的指標的陣列
- 所有程序/執行緒都有一個獨立的能力組,擁有一個全域性唯一ID (Badge)
- 所有物件(包括程序或能力組本身)都屬於一個或多個能力組當中,也就是說子程序與執行緒將屬於父程序的能力組當中,在某個能力組的物件擁有一個能力組內的能力ID(cap)。
- 物件可以共享,即單個物件可以在多個能力組中共存,同時在不同cap_group中可以有不同的cap
- 對所有物件的取用和返還都使用引用計數進行追蹤。當引用計數為0後,當核心垃圾回收器喚醒後,會自動回收.
- 能力組內的能力具有許可權,表明該能力是否能被共享(CAP_RIGHT_COPY)以及是否能被刪除(CAP_RIGHT_REVOKE)
struct object {
u64 type;
u64 size;
/* Link all slots point to this object */
struct list_head copies_head;
/* Currently only protect copies list */
struct lock copies_lock;
/*
* refcount is added when a slot points to it and when get_object is
* called. Object is freed when it reaches 0.
*/
volatile unsigned long refcount;
/*
* opaque marks the end of this struct and the real object will be
* stored here. Now its address will be 8-byte aligned.
*/
u64 opaque[];
};
const obj_deinit_func obj_deinit_tbl[TYPE_NR] = {
[0 ... TYPE_NR - 1] = NULL,
[TYPE_CAP_GROUP] = cap_group_deinit,
[TYPE_THREAD] = thread_deinit,
[TYPE_CONNECTION] = connection_deinit,
[TYPE_NOTIFICATION] = notification_deinit,
[TYPE_IRQ] = irq_deinit,
[TYPE_PMO] = pmo_deinit,
[TYPE_VMSPACE] = vmspace_deinit,
#ifdef CHCORE_OPENTRUSTEE
[TYPE_CHANNEL] = channel_deinit,
[TYPE_MSG_HDL] = msg_hdl_deinit,
#endif /* CHCORE_OPENTRUSTEE */
[TYPE_PTRACE] = ptrace_deinit
};
void *obj_alloc(u64 type, u64 size)
{
u64 total_size;
struct object *object;
total_size = sizeof(*object) + size;
object = kzalloc(total_size);
if (!object)
return NULL;
object->type = type;
object->size = size;
object->refcount = 0;
/*
* If the cap of the object is copied, then the copied cap (slot) is
* stored in such a list.
*/
init_list_head(&object->copies_head);
lock_init(&object->copies_lock);
return object->opaque;
}
void __free_object(struct object *object)
{
#ifndef TEST_OBJECT
obj_deinit_func func;
if (object->type == TYPE_THREAD)
clear_fpu_owner(object);
/* Invoke the object-specific free routine */
func = obj_deinit_tbl[object->type];
if (func)
func(object->opaque);
#endif
BUG_ON(!list_empty(&object->copies_head));
kfree(object);
}
所有的物件都有一個公共基類,並定義了虛構函式列表,當引用計數歸零即完全被能力組移除後核心會執行deinit程式碼完成銷燬工作。
根據上面的描述,梳理根程序建立以及普通程序建立的異同,梳理出建立程序的方式。
相同點:
- 首先,我們需要為從使用者態的程序分配一個能力組,提供給一個全域性的id。那麼就需要分配一個object,將這個型別選擇為
cap_group
. - 初始化我們的cap_group,建立PCB。
- 需要注意的是,所有的程序相關資訊都透過object來進行抽象。因此vmspace也是透過分配object分配而來的。
- 建立程序的執行緒。線上程內,為執行緒分配記憶體物件PMO和初始執行環境,分配虛擬地址空間,處理器上下文,核心棧。
不同點:
- 建立根程序的全域性id和能力(capability)和其他程序的相對應id和能力不同。
- 根程序所具有的虛擬地址與普通程序不相同。
使用者態構建
我們在Lab1
的程式碼導讀階段說明了kernel
目錄下的程式碼是如何被連結成核心映象的,我們在核心映象連結中引入了procmgr
這個預先構建的二進位制檔案。在Lab3
中,我們引入了使用者態的程式碼構建,所以我們將procmgr
的依賴改為使用使用者態的程式碼生成。下圖為具體的構建規則圖。
procmgr
是一個自包含的ELF
程式,其程式碼在procmgr
中列出,其主要包含一個ELF
執行器以及作為Chcore微核心的init
程式啟動,其構建主要依賴於fsm.srv
以及tmpfs.srv
,其中fsm.srv
為檔案系統管理器其扮演的是虛擬檔案系統的角色用於橋接不同掛載點上的檔案系統的實現,而tmpfs.srv
則是Chcore
的根檔案系統其由ramdisk
下面的所有檔案以及構建好libc.so
所打包好的ramdisk.cpio
構成。當構建完tmpfs.srv
後其會跟libc.so
進行動態連結,最終tmpfs.srv
以及fsm.srv
會以incbin指令碼的形式以二進位制的方式被連線至procmgr
的最後。在構建procmgr
的最後一步,cmake
會呼叫read_procmgr_elf_tool
將procmgr
這個ELF
檔案的縮略資訊貼上至procmgr
之前。此後procmgr
也會以二進位制的方式進一步巢狀進入核心映象之後,最終會在create_root_thread
的階段透過其elf
符號得以載入。 最終,Chcore的Kernel映象的拓撲結構如下
3.2 執行緒管理
權利組建立(我覺得應該是權力組)
在AArch64體系結構中,我們擁有從低到高4個異常級別EL0,EL1,EL2,EL3.在Chcore中採用兩個特權級別,EL0,EL1。後者為核心模式。
在/kernel
目錄下的程式碼執行於核心級別,也就是EL1。在/user
目錄下的程式碼執行於使用者態,也就是EL0。我們在ChCore中的第一個程序為procmgr
,也接受cap_group
和capability
的管理。在建立該程序後,再進行核心態向使用者態的切換。
建立使用者程式至少需要包括建立對應的 cap_group
、載入使用者程式映象並且切換到程式。在核心完成必要的初始化之後,核心將會跳轉到建立第一個使用者程式的操作中,該操作透過呼叫 create_root_thread
函式完成,本函式完成第一個使用者程序的建立,其中的操作包括從procmgr
映象中讀取程式資訊,呼叫create_root_cap_group
建立第一個 cap_group
程序,並在 root_cap_group
中建立第一個執行緒,執行緒載入著資訊中記錄的 elf
程式(實際上就是procmgr
系統服務)。此外,使用者程式也可以透過 sys_create_cap_group
系統呼叫建立一個全新的 cap_group
.
練習題1:
在kernel/object/cap_group.c
中完善sys_create_cap_group
、create_root_cap_group
函式。在完成填寫之後,你可以透過 Cap create pretest 測試點。
Hint
閱讀kernel/object/capability.c
中的各個與cap機制相關的函式以及相關參考文件。
我們首先先看一下我們的能力組和物件的相關結構。
我們可以看見,object(物件)可以看作是一切系統資源的基類,其中在object_type
中有著其派生出來的類別,包括能力組,執行緒,連線,提示,中斷請求(irq),實體記憶體物件(pmo),虛擬地址空間等。object物件中,type
指明瞭該物件的型別,size
是大小,copies_head
提供了從連結串列中獲取該資源的方式(可以說是後門hhh),copies_lock
是鎖結構,用於保證多執行緒訪問和共享安全。refcount
可以用於垃圾回收裝置(gc),而真正的內容儲存在64位的opaque
記憶體中。
接下來我們來看cap_group
的相關定義。
首先是有關object的連結串列結構的定義,這裡定義了能力組指向的連結串列,以及連結串列的每個槽slot指向的object。object_slot內儲存著slot的id,所屬於的能力組,所含有的object,索引方式copies,以及物件所擁有的權力(不是“利”)。在kernel/user-include/uapi/types.h
中的註釋中我們可以發現:該權力分為兩類,一種是物件獨有的權力,一種是普遍的權力。這會在後面有所體現。
接下來就是有關能力組的定義。如上圖所示,首先,能力組包含了slot_table. 其次,thread_cnt
記錄了該能力組所擁有的執行緒的數目。badge
是全域性的id,由procmgr設定,也可以視為核心態的id。pid
則是使用者態中該程序的id。
我們接著來理清我們建立程序所需要的過程:
- 首先,我們需要為程序分配一個能力組,並且為該能力組提供給一個全域性的id。那麼就需要分配一個object(所有程序,執行緒和能力組都是物件的派生),將這個型別選擇為
cap_group
. - 呼叫初始化函式,將object初始化我們的cap_group。
- 為我們的程序,也就是這個能力組分配虛擬空間。
- 建立執行緒。一個程序至少有一個以上的執行緒,相關的上下文資訊和PMO透過執行緒進行管理。
參考我們的capability.c
,具體的部分可以參見注釋。
cap_t sys_create_cap_group(unsigned long cap_group_args_p)
{
struct cap_group *new_cap_group;
struct vmspace *vmspace;
cap_t cap;
int r;
struct cap_group_args args = {0};
r = hook_sys_create_cap_group(cap_group_args_p);
if (r != 0)
return r;
if (check_user_addr_range((vaddr_t)cap_group_args_p,
sizeof(struct cap_group_args))
!= 0)
return -EINVAL;
r = copy_from_user(
&args, (void *)cap_group_args_p, sizeof(struct cap_group_args));
if (r) {
return -EINVAL;
}
/* cap current cap_group */
/* LAB 3 TODO BEGIN */
/* Allocate a new cap_group object */
/*
* 為我們的能力組分配對應型別的物件。
* 我們的函式在capability.c:
* void *obj_alloc(u64 type, u64 size)
*/
new_cap_group = obj_alloc(TYPE_CAP_GROUP, sizeof(*new_cap_group));
/* LAB 3 TODO END */
if (!new_cap_group) {
r = -ENOMEM;
goto out_fail;
}
/* LAB 3 TODO BEGIN */
/* initialize cap group from user*/
// 初始化我們的能力組。為程序分配使用者態id。
// 函式原型:注意最後一個需要傳入引用。
// __maybe_unused static int cap_group_init_user(struct cap_group *cap_group, unsigned int size, struct cap_group_args *args)
cap_group_init_user(new_cap_group, BASE_OBJECT_NUM, &args);
new_cap_group->pid = args.pid;
/* LAB 3 TODO END */
// 在能力組內分配槽位。如果沒有足夠的槽位則前往新的能力組。
cap = cap_alloc(current_cap_group, new_cap_group);
if (cap < 0) {
r = cap;
goto out_free_obj_new_grp;
}
/* 1st cap is cap_group */
// 分配自身能力capability,放入槽表中的第一個槽。
if (cap_copy(current_thread->cap_group,
new_cap_group,
cap,
CAP_RIGHT_NO_RIGHTS,
CAP_RIGHT_NO_RIGHTS)
!= CAP_GROUP_OBJ_ID) {
kwarn("%s: cap_copy fails or cap[0] is not cap_group\n",
__func__);
r = -ECAPBILITY;
goto out_free_cap_grp_current;
}
/* 2st cap is vmspace */
/* LAB 3 TODO BEGIN */
// 分配虛擬空間並且放入能力組的槽表的第二個槽。
vmspace = obj_alloc(TYPE_VMSPACE, sizeof(*vmspace));
/* LAB 3 TODO END */
if (!vmspace) {
r = -ENOMEM;
goto out_free_obj_vmspace;
}
vmspace_init(vmspace, args.pcid);
r = cap_alloc(new_cap_group, vmspace);
if (r != VMSPACE_OBJ_ID) {
kwarn("%s: cap_copy fails or cap[1] is not vmspace\n",
__func__);
r = -ECAPBILITY;
goto out_free_obj_vmspace;
}
return cap;
out_free_obj_vmspace:
obj_free(vmspace);
out_free_cap_grp_current:
cap_free(current_cap_group, cap);
new_cap_group = NULL;
out_free_obj_new_grp:
obj_free(new_cap_group);
out_fail:
return r;
}
/* This is for creating the first (init) user process. */
// procmgr的建立,和上面大同小異。有不同的地方將會註釋標出。
struct cap_group *create_root_cap_group(char *name, size_t name_len)
{
struct cap_group *cap_group = NULL;
struct vmspace *vmspace = NULL;
cap_t slot_id;
/* LAB 3 TODO BEGIN */
// UNUSED(vmspace);
// UNUSED(cap_group);
// 分配能力組物件。
cap_group = obj_alloc(TYPE_CAP_GROUP, sizeof(*cap_group));
/* LAB 3 TODO END */
BUG_ON(!cap_group);
/* LAB 3 TODO BEGIN */
/* initialize cap group with common, use ROOT_CAP_GROUP_BADGE */
// 按照註釋呼叫common初始化能力組。
cap_group_init_common(cap_group, BASE_OBJECT_NUM, ROOT_CAP_GROUP_BADGE);
/* LAB 3 TODO END */
slot_id = cap_alloc(cap_group, cap_group);
BUG_ON(slot_id != CAP_GROUP_OBJ_ID);
/* LAB 3 TODO BEGIN */
// 分配虛擬地址空間
vmspace = obj_alloc(TYPE_VMSPACE, sizeof(*vmspace));
/* LAB 3 TODO END */
BUG_ON(!vmspace);
/* fixed PCID 1 for root process, PCID 0 is not used. */
vmspace_init(vmspace, ROOT_PROCESS_PCID);
/* LAB 3 TODO BEGIN */
// 為自身分配槽id。對照上一個函式的寫法並且適配下面的assert。
slot_id = cap_alloc(cap_group, vmspace);
/* LAB 3 TODO END */
BUG_ON(slot_id != VMSPACE_OBJ_ID);
/* Set the cap_group_name (process_name) for easing debugging */
memset(cap_group->cap_group_name, 0, MAX_GROUP_NAME_LEN + 1);
if (name_len > MAX_GROUP_NAME_LEN)
name_len = MAX_GROUP_NAME_LEN;
memcpy(cap_group->cap_group_name, name, name_len);
root_cap_group = cap_group;
return cap_group;
}
附錄:不想閱讀這部分的可以跳過。我們將會結合
capability.c
來梳理整個流程。建立一個程序後,使用者態下陷到核心態,接下來就會在核心態建立該程序的PCB核心態部分。
- 判斷核心是否能夠喚起建立能力組。採用
hook_sys_create_cap_group
。 - 從使用者態中的
proc_cap
部分獲取資訊,傳遞到核心態。 - 分配核心態的能力組。該能力組也是物件。
obj_alloc
. - 呼叫普通程序的能力組初始化。
cap_group_init_user
。在這一操作中,首先,將程序pid賦予給能力組。然後,初始化執行緒數和ptrace,並且分配核心態全域性標識(badge)。 - 接下來初始化能力組的槽表(slot_table)。
cap_alloc
。首先獲取當前程序中的執行緒的能力組(透過current_cap_group
宏定義得到),然後進入到cap_alloc_with_rights
中。 - 分配能力與權力
cap_alloc_with_rights
。首先獲得當前程序能力組物件真正儲存的地址(也就是我們熟悉的container_of
宏定義,計算地址量的偏差然後回溯到結構體的所在地址),並且關聯到執行緒擁有的槽表。 - 為槽表分配id,並且在槽表中分配一個槽。將執行緒自身的cap_group放入第一個槽(如圖中所示)。
- 將新的物件加入到連結串列中(前面的虛擬記憶體/實體記憶體管理需要),並且更新程序的能力組的槽表的id。
install_slot
- 接下來為vmspace(虛擬地址空間)分配物件和能力組。
這樣我們就完成了執行緒管理的第一部分。
ELF載入
然而,完成 cap_group 的分配之後,使用者程式並沒有辦法直接執行,因為cap_group只是一個資源集合的概念。執行緒才是核心中的排程執行單位,因此還需要進行執行緒的建立,將使用者程式 ELF 的各程式段載入到記憶體中。
(此為核心中 ELF 程式載入過程,使用者態進行 ELF 程式解析可參考user/system-services/system-servers/procmgr/libs/libchcoreelf/libchcoreelf.c
,如何載入程式可以對user/system-services/system-servers/procmgr/srvmgr.c
中的procmgr_launch_process
函式進行詳細分析)
練習題2: 在
kernel/object/thread.c
中完成create_root_thread
函式,將使用者程式 ELF 載入到剛剛建立的程序地址空間中。
- 程式頭可以參考
kernel/object/thread_env.h
。 - 記憶體分配操作使用 create_pmo,可詳細閱讀
kernel/object/memory.c
瞭解記憶體分配。
回顧我們建立程序的過程。我們整體的程序建立有五步:
- 建立PCB。(√)
- 虛擬記憶體初始化。(√)
- 核心棧初始化。
- 載入可執行檔案到記憶體。
- 初始化使用者棧和執行環境。
接下來我們需要完成3,4,5步來完成我們根程序的第一個執行緒。
首先,我們先了解我們的ELF檔案。可以參見Lec 04 系統呼叫的第五節。我們的ELF檔案具有如下的格式:
其次,我們來到我們的create_root_thread
函式中。
1.我們需要將我們的啟動服務(也就是procmgr
)的ELF頭部載入進入我們的記憶體中。因為這是我們的根程序。如下圖所示。
2.然後為我們的根程序內的第一個執行緒建立所屬於的能力組,並且分配和初始化虛擬記憶體空間。
3.接下來建立實體記憶體物件,建立頁表,建立實體記憶體與虛擬記憶體之間的對映。
4.建立執行緒(執行緒也是物件),然後正式進入載入程式ELF的程式頭部表部分。
其實程式都給我們一個例子。我們有:
memcpy(data,
(void *)((unsigned long)&binary_procmgr_bin_start
+ ROOT_PHDR_OFF + i * ROOT_PHENT_SIZE
+ PHDR_FLAGS_OFF),
sizeof(data));
flags = (unsigned int)le32_to_cpu(*(u32 *)data);
來獲得flags的資訊。
這樣我們直接照葫蘆畫瓢,找到對應的在thread_env.h
偏移定義即可。
/* LAB 3 TODO BEGIN */
/* Get offset, vaddr, filesz, memsz from image*/
// UNUSED(flags);
// UNUSED(filesz);
// UNUSED(offset);
// UNUSED(memsz);
memcpy(data,
(void *)((unsigned long)&binary_procmgr_bin_start
+ ROOT_PHDR_OFF + i * ROOT_PHENT_SIZE
+ PHDR_OFFSET_OFF),
sizeof(data));
offset = (unsigned long)le64_to_cpu(*(u64 *)data);
memcpy(data,
(void *)((unsigned long)&binary_procmgr_bin_start
+ ROOT_PHDR_OFF + i * ROOT_PHENT_SIZE
+ PHDR_VADDR_OFF),
sizeof(data));
vaddr = (unsigned long)le64_to_cpu(*(u64 *)data);
memcpy(data,
(void *)((unsigned long)&binary_procmgr_bin_start
+ ROOT_PHDR_OFF + i * ROOT_PHENT_SIZE
+ PHDR_FILESZ_OFF),
sizeof(data));
filesz = (unsigned long)le64_to_cpu(*(u64 *)data);
memcpy(data,
(void *)((unsigned long)&binary_procmgr_bin_start
+ ROOT_PHDR_OFF + i * ROOT_PHENT_SIZE
+ PHDR_MEMSZ_OFF),
sizeof(data));
memsz = (unsigned long)le64_to_cpu(*(u64 *)data);
/* LAB 3 TODO END */
5.接下來還要為程式頭部表分配實體記憶體物件。在分配這裡是我們使用段實體記憶體物件,也就是segment_pmo
。然後,每次分配一個物理頁大小的段記憶體,並且對映到虛擬空間中。因此這裡需要計算程式頭部表中需要多少個物理頁,並且從虛擬地址中計算出實體地址,填寫到對應的段成員中。我們有:
struct pmobject *segment_pmo = NULL;
/* LAB 3 TODO BEGIN */
// UNUSED(segment_pmo);
size_t pmo_size = ROUND_UP(memsz, PAGE_SIZE)
vaddr_t segment_content_kvaddr = ((unsignelong) &binary_procmgr_bin_start) + offset;
/* LAB 3 TODO END */
BUG_ON(filesz != memsz);
ret = create_pmo(PAGE_SIZE,
PMO_DATA,
root_cap_group,
0,
&segment_pmo,
PMO_ALL_RIGHTS);
BUG_ON(ret < 0);
kfree((void *)phys_to_virt(segment_pmo -> start));
/* LAB 3 TODO BEGIN */
/* Copy elf file contents into memory*/
segment_pmo -> start = virt_to_phys(segment_content_kvaddr);
segment_pmo -> size = pmo_size;
/* LAB 3 TODO END */
6.接下來設定虛擬空間的許可權。因為這是程式頭部表部分,需要讓程式能夠對虛擬空間進行讀寫和執行。回顧我們在lab2中設定flag的經驗,我們採用或的方式設定。(位於OS-Course-Lab/lab2/kernel/mm/vmspace.c
)中。
因此我們有:
/* LAB 3 TODO BEGIN */
/* Set flags*/
if(flags & PHDR_FLAGS_R)
vmr_flags |= VMR_READ;
if(flags & PHDR_FLAGS_W)
vmr_flags |= VMR_WRITE;
if(flags & PHDR_FLAGS_X)
vmr_flags |= VMR_EXEC;
/* LAB 3 TODO END */
這樣我們就完成了ELF載入過程。
以下內容可以跳過。關於
srvmgr.c
中的procmgr_launch_process
的相關解讀
int procmgr_launch_process(int argc, char **argv, char *name,
bool if_has_parent, badge_t parent_badge,
struct new_process_args *np_args, int proc_type,
struct proc_node **out_proc)
這是一個透過根程序procmgr載入指定名字的可執行檔案的程式。首先,先讀取我們的ELF檔案,判斷這是不是一個動態連結程式。
- 動態連結VS靜態連結?
- 靜態連結:這是我們最為常用的方式。對於幾個已經編寫好的程式,我們利用make,cmake等確定編譯規則和連結規則。然後進行以下三步:(1)預處理。也就是粗暴地將標頭檔案,預編譯指令等等直接插入程式中生成.i檔案。(2)編譯,詳情參見
編譯原理
,透過編譯器將程式生成.s彙編檔案。(3)彙編:將彙編指令翻譯成機器指令,生成可重定位二進位制目標檔案。(4)連結:我們將生成的.o檔案根據依賴關係進行連結,最後生成一個可以執行的程式。最後一個過程就是靜態連結。在linux中可以將可執行檔案打包生成靜態連結庫為.a。 - 動態連結:為了解決空間問題和更新困難問題,可以將程式按照模組拆分成相對獨立的部分。只有在程式執行時才將他們連線在一起,而不是在執行前就連結形成可執行檔案。並且,可複用性很強。當程式一已經載入了動態連結程式時,程式二也需要使用時,只需要將相同的實體地址對映到不同的程式的不同虛擬空間內的虛擬地址即可。這樣大大減少了實體記憶體的佔用,不需要重複載入相同的程式塊到記憶體中。
接下來從檔案中讀取elf的檔案頭,再根據檔案頭讀取elf程式檔案。
隨後進入到啟動程序部分。首先檢查是否有父節點(是否是一個程序喚起的子執行緒,例如fork)。接著就是建立執行緒節點。一樣的過程(分配object,初始化程序節點)。
然後,將elf內的相關資訊和傳入該函式的相關資訊用於初始化新的程序的相關成員。接著根據是否為動態/靜態連結來啟動這個程序。最後初始化程序節點的相關狀態,成員組等元資訊。
這就是整個函式的大致過程。
在
user/system-services/system-servers/procmgr/include/libchcoreelf.h
中具有elf的詳細定義和相關資訊。
程序排程
完成使用者程式的記憶體分配後,使用者程式程式碼實際上已經被對映在root_cap_group
的虛擬地址空間中。接下來需要對建立的執行緒進行初始化,以做好從核心態切換到使用者態執行緒的準備。
練習題3:在
kernel/arch/aarch64/sched/context.c
中完成init_thread_ctx
函式,完成執行緒上下文的初始化。
首先,我們需要回顧我們的上下文結構。
在進行上下文切換時,我們需要儲存上述的通用暫存器,特殊暫存器中的使用者棧暫存器(我們的stack虛擬地址)以及系統暫存器中用於儲存PC(也就是我們傳進來的函式的虛擬地址)和PSTATE的暫存器。此時進行初始化的時候,應該設定我們的PSTATE在使用者態上,也就是SPSR_EL0t
.
這樣初始化就可以直接完成如下:
void init_thread_ctx(struct thread *thread, vaddr_t stack, vaddr_t func,
u32 prio, u32 type, s32 aff)
{
/* Fill the context of the thread */
/* LAB 3 TODO BEGIN */
/* SP_EL0, ELR_EL1, SPSR_EL1*/
thread->thread_ctx->ec.reg[SP_EL0] = stack; // 使用者棧暫存器
thread->thread_ctx->ec.reg[ELR_EL1] = func; // PC暫存器
thread->thread_ctx->ec.reg[SPSR_EL1] = SPSR_EL1_EL0t; // 狀態暫存器
/* LAB 3 TODO END */
/* Set the state of the thread */
thread->thread_ctx->state = TS_INIT;
/* Set thread type */
thread->thread_ctx->type = type;
/* Set the cpuid and affinity */
thread->thread_ctx->affinity = aff;
/* Set the budget and priority of the thread */
if (thread->thread_ctx->sc != NULL) {
thread->thread_ctx->sc->prio = prio;
thread->thread_ctx->sc->budget = DEFAULT_BUDGET;
}
thread->thread_ctx->kernel_stack_state = KS_FREE;
/* Set exiting state */
thread->thread_ctx->thread_exit_state = TE_RUNNING;
thread->thread_ctx->is_suspended = false;
}
至此,我們完成了第一個使用者程序與第一個使用者執行緒的建立。接下來就可以從核心態向使用者態進行跳轉了。 回到kernel/arch/aarch64/main.c
,在create_root_thread()
完成後,分別呼叫了sched()
與eret_to_thread(switch_context())
。 sched()
的作用是進行一次排程,在此場景下我們建立的第一個執行緒將被選擇。
switch_context()
函式的作用則是進行執行緒上下文的切換,包括vmspace、fpu、tls
等。並且將cpu_info
中記錄的當前CPU執行緒上下文記錄為被選擇執行緒的上下文(完成後續實驗後對此可以有更深的理解)。switch_context()
最終返回被選擇執行緒的thread_ctx
地址,即target_thread->thread_ctx
。
eret_to_thread
最終呼叫了kernel/arch/aarch64/irq/irq_entry.S
中的 __eret_to_thread
函式。其接收引數為target_thread->thread_ctx
,將 target_thread->thread_ctx
寫入sp暫存器後呼叫了 exception_exit
函式,exception_exit
最終呼叫 eret
返回使用者態,從而完成了從核心態向使用者態的第一次切換。
注意此處因為尚未完成exception_exit
函式,因此無法正確切換到使用者態程式,在後續完成exception_exit
後,可以透過 gdb 追蹤 pc 暫存器的方式檢視是否正確完成核心態向使用者態的切換。
思考核心從完成必要的初始化到第一次切換到使用者態程式的過程是怎麼樣的?嘗試描述一下呼叫關係。
我們有如下的流程圖
然而,目前任何一個使用者程式並不能正常退出,也不能正常輸出結果。這是由於程式中包括了 svc #0
指令進行系統呼叫。由於此時 ChCore 尚未配置從使用者模式(EL0)切換到核心模式(EL1)的相關內容,在嘗試執行 svc 指令時,ChCore 將根據目前的配置(尚未初始化,異常處理向量指向隨機位置)執行位於隨機位置的異常處理程式碼,進而導致觸發錯誤指令異常。同樣的,由於錯誤指令異常仍未指定處理程式碼的位置,對該異常進行處理會再次出發錯誤指令異常。ChCore 將不斷重複此迴圈,並最終表現為 QEMU 不響應。後續的練習中將會透過正確配置異常向量表的方式,對這一問題進行修復。
3.3 異常處理
由於 ChCore 尚未對使用者模式與核心模式的切換進行配置,一旦 ChCore 進入使用者模式執行就再也無法正常返回核心模式使用作業系統提供其他功能了。在這一部分中,我們將透過正確配置異常向量表的方式,為 ChCore 新增異常處理的能力。
在 AArch64 架構中,異常是指低特權級軟體(如使用者程式)請求高特權軟體(例如核心中的異常處理程式)採取某些措施以確保程式平穩執行的系統事件,包含同步異常和非同步異常:
- 同步異常:透過直接執行指令產生的異常。同步異常的來源包括同步中止(synchronous abort)和一些特殊指令。當直接執行一條指令時,若取指令或資料訪問過程失敗,則會產生同步中止。此外,部分指令(包括
svc
等)通常被使用者程式用於主動製造異常以請求高特權級別軟體提供服務(如系統呼叫)。 - 非同步異常:與正在執行的指令無關的異常。非同步異常的來源包括普通中 IRQ、快速中斷 FIQ 和系統錯誤 SError。IRQ 和 FIQ 是由其他與處理器連線的硬體產生的中斷,系統錯誤則包含多種可能的原因。本實驗不涉及此部分。
發生異常後,我們需要從kernel/arch/aarch64/irq/irq_entry.S
中找到對應的異常處理程式程式碼(異常向量)執行。每個異常級別在aarch64中有自己獨立的異常向量表,其虛擬地址由該異常級別下的異常向量基地址暫存器(VBAR_EL3
,VBAR_EL2
和 VBAR_EL1
)決定。每個異常向量表中包含 16 個條目,每個條目裡儲存著發生對應異常時所需執行的異常處理程式程式碼。以上表格給出了每個異常向量條目的偏移量。
在 ChCore 中,僅使用了 EL0 和 EL1 兩個異常級別,因此僅需要對 EL1 異常向量表進行初始化即可。在本實驗中,ChCore 內除系統呼叫外所有的同步異常均交由 handle_entry_c
函式進行處理。遇到異常時,硬體將根據 ChCore 的配置執行對應的彙編程式碼,將異常型別和當前異常處理程式條目型別作為引數傳遞,對於 sync_el1h 型別的異常,跳轉 handle_entry_c
使用 C 程式碼處理異常。對於 irq_el1t、fiq_el1t、fiq_el1h、error_el1t、error_el1h、sync_el1t 則跳轉 unexpected_handler
處理異常。
按照前文所述的表格填寫
kernel/arch/aarch64/irq/irq_entry.S
中的異常向量表,並且增加對應的函式跳轉操作。
配置EL1級別下和EL0級別下的異常向量,填寫異常向量表。根據上邊的表格描述,我們發現針對不同異常發生時的處理器狀態(四種)和不同的異常情況(四種)共分成16種情況,每種情況按照0x80對齊(128位元組)。因此我們可以使用前面的 exception_entry
彙編宏定義,將我們的異常按照128位元組對齊後跳轉到對應的異常型別的異常處理向量。因此我們可以:
/* LAB 3 TODO BEGIN */
exception_entry sync_el1t // Synchronous EL1t
exception_entry irq_el1t // IRQ EL1t
exception_entry fiq_el1t // FIQ EL1t
exception_entry error_el1t // Error EL1t
exception_entry sync_el1h // Synchronous EL1h
exception_entry irq_el1h // IRQ EL1h
exception_entry fiq_el1h // FIQ EL1h
exception_entry error_el1h // Error EL1h
exception_entry sync_el0_64 // Synchronous 64-bit EL0
exception_entry irq_el0_64 // IRQ 64-bit EL0
exception_entry fiq_el0_64 // FIQ 64-bit EL0
exception_entry error_el0_64 // Error 64-bit EL0
exception_entry sync_el0_32 // Synchronous 32-bit EL0
exception_entry irq_el0_32 // IRQ 32-bit EL0
exception_entry fiq_el0_32 // FIQ 32-bit EL0
exception_entry error_el0_32 // Error 32-bit EL0
/* LAB 3 TODO END */
進行128位元組對齊,這樣雖然每種型別的異常處理向量數目不同,但是每種型別都等長地佔據相同的空間,減少異常處理的時間。
3.4 系統呼叫
核心支援
系統呼叫是系統為使用者程式提供的高特權操作介面。在本實驗中,使用者程式透過 svc
指令進入核心模式。在核心模式下,首先作業系統程式碼和硬體將儲存使用者程式的狀態。作業系統根據系統呼叫號碼執行相應的系統呼叫處理程式碼,完成系統呼叫的實際功能,並儲存返回值。最後,作業系統和硬體將恢復使用者程式的狀態,將系統呼叫的返回值返回給使用者程式,繼續使用者程式的執行。
透過異常進入到核心後,需要儲存當前執行緒的各個暫存器值,以便從核心態返回使用者態時進行恢復。儲存工作在 exception_enter
中進行,恢復工作則由 exception_exit
完成。可以參考kernel/include/arch/aarch64/arch/machine/register.h
中的暫存器結構,儲存時在棧中應準備ARCH_EXEC_CONT_SIZE
大小的空間。
完成儲存後,需要進行核心棧切換,首先從TPIDR_EL1
暫存器中讀取到當前核的per_cpu_info
(參考kernel/include/arch/aarch64/arch/machine/smp.h
),從而拿到其中的cpu_stack地址。
填寫
kernel/arch/aarch64/irq/irq_entry.S
中的exception_enter
與exception_exit
,實現上下文儲存的功能,以及switch_to_cpu_stack
核心棧切換函式。如果正確完成這一部分,可以透過 Userland 測試點。這代表著程式已經可以在使用者態與核心態間進行正確切換。顯示如下結果
Hello userland!
首先我們先完成異常進入和異常退出部分。
這一部分可以直接參照書本上的內容。首先,進入異常處理前需要進行儲存上下文,然後進行異常處理。最後恢復上下文。我們有:
先儲存x0——x29通用暫存器,再儲存x30和三個暫存器(使用者棧暫存器SP_EL0,PC暫存器ELR_EL1和使用者狀態暫存器SPSR_EL0)。恢復時先恢復x30和三個暫存器,再恢復x0-x29.
/* See more details about the bias in registers.h */
.macro exception_enter
/* LAB 3 TODO BEGIN */
sub sp, sp, #ARCH_EXEC_CONT_SIZE
// saving general register.
stp x0, x1, [sp, #16 * 0]
stp x2, x3, [sp, #16 * 1]
stp x4, x5, [sp, #16 * 2]
stp x6, x7, [sp, #16 * 3]
stp x8, x9, [sp, #16 * 4]
stp x10, x11, [sp, #16 * 5]
stp x12, x13, [sp, #16 * 6]
stp x14, x15, [sp, #16 * 7]
stp x16, x17, [sp, #16 * 8]
stp x18, x19, [sp, #16 * 9]
stp x20, x21, [sp, #16 * 10]
stp x22, x23, [sp, #16 * 11]
stp x24, x25, [sp, #16 * 12]
stp x26, x27, [sp, #16 * 13]
stp x28, x29, [sp, #16 * 14]
/* LAB 3 TODO END */
// special register saving.
mrs x21, sp_el0
mrs x22, elr_el1
mrs x23, spsr_el1
/* LAB 3 TODO BEGIN */
stp x30, x21, [sp, #16 * 15]
stp x22, x23, [sp, #16 * 16]
/* LAB 3 TODO END */
.endm
.macro exception_exit
/* LAB 3 TODO BEGIN */
// do upside down of enter.
ldp x22, x23, [sp, #16 * 16]
ldp x30, x21, [sp, #16 * 15]
/* LAB 3 TODO END */
msr sp_el0, x21
msr elr_el1, x22
msr spsr_el1, x23
/* LAB 3 TODO BEGIN */
ldp x0, x1, [sp, #16 * 0]
ldp x2, x3, [sp, #16 * 1]
ldp x4, x5, [sp, #16 * 2]
ldp x6, x7, [sp, #16 * 3]
ldp x8, x9, [sp, #16 * 4]
ldp x10, x11, [sp, #16 * 5]
ldp x12, x13, [sp, #16 * 6]
ldp x14, x15, [sp, #16 * 7]
ldp x16, x17, [sp, #16 * 8]
ldp x18, x19, [sp, #16 * 9]
ldp x20, x21, [sp, #16 * 10]
ldp x22, x23, [sp, #16 * 11]
ldp x24, x25, [sp, #16 * 12]
ldp x26, x27, [sp, #16 * 13]
ldp x28, x29, [sp, #16 * 14]
add sp, sp, #ARCH_EXEC_CONT_SIZE
/* LAB 3 TODO END */
eret
.endm
接著根據我們前面提及的:
- 對於 sync_el1h 型別的異常,跳轉
handle_entry_c
使用 C 程式碼處理異常。 - 對於 irq_el1t、fiq_el1t、fiq_el1h、error_el1t、error_el1h、sync_el1t 則跳轉
unexpected_handler
處理異常。
需要注意的是,在sync_el1h型別的異常,跳轉到C函式後,退出異常前需要儲存好返回值。(如註釋裡所說)。因為大部分的sync錯誤來源於以下四種情況,因此需要儲存好我們的返回值。
這樣我們將會有以下方式:
irq_el1t:
fiq_el1t:
fiq_el1h:
error_el1t:
error_el1h:
sync_el1t:
/* LAB 3 TODO BEGIN */
bl unexpected_handler
/* LAB 3 TODO END */
sync_el1h:
exception_enter
mov x0, #SYNC_EL1h
mrs x1, esr_el1
mrs x2, elr_el1
/* LAB 3 TODO BEGIN */
/* jump to handle_entry_c, store the return value as the ELR_EL1 */
bl handle_entry_c
str x0, [sp, #16 * 16] /* store the return value as the ELR_EL1 */
/* LAB 3 TODO END */
exception_exit
這樣我們完成了異常向量配置和異常進入/退出部分。
使用者態libc支援
在本實驗中新加入了 libc
檔案,使用者態程式可以連結其編譯生成的libc.so
,並透過 libc
進行系統呼叫從而進行向核心態的異常切換。在實驗提供的 libc
中,尚未實現 printf
的系統呼叫,因此使用者態程式無法進行正常輸出。實驗接下來將對 printf
函式的呼叫鏈進行分析與探索。
printf
函式呼叫了 vfprintf
,其中檔案描述符引數為 stdout。這說明在 vfprintf
中將使用 stdout
的某些操作函式。
在 user/chcore-libc/musl-libc/src/stdio/stdout.c
中可以看到 stdout
的 write
操作被定義為 __stdout_write
,之後呼叫到 __stdio_write
函式。
最終 printf
函式將呼叫到 chcore_stdout_write
。
printf
如何呼叫到chcore_stdout_write
?
chcore_write
中使用了檔案描述符,stdout
描述符的設定在user/chcore-libc/musl-libc/src/chcore-port/syscall_dispatcher.c
中。
chcore_stdout_write
中的核心函式為 put
,此函式的作用是向終端輸出一個字串。
printf
函式定義在user/chcore-libc/musl-libc/src/stdio/printf.c
.
我們有如下的呼叫鏈:
1.呼叫vfprintf()
函式。
2.使用printf_core()
函式檢查相關的輸入格式是否正確。
3.呼叫f->write
函式。此時,因為傳入的流為stdout
。這個定義於stdout.c
檔案中:
因此將會呼叫__stdout_write
函式。隨後呼叫到__stdio_write
函式。
4.此時將會呼叫系統服務,也就是定義在syscall_dispatcher
中。syscall
有宏定義:
5.這裡首先根據宏定義確定具體的系統呼叫(syswritev),隨後確定引數量為3,因此最終呼叫__syscall3
,隨後在case SYS_writev
下呼叫__syscall6
.所以實際上對於需要x個引數的系統呼叫,需要傳入x+1個引數,其中第一個引數為需要核心進行的具體系統呼叫函式型別。
6.呼叫chcore_write
函式,需要從我們的描述符中判斷應該呼叫哪種write函式。我們有:
7.此時我們的檔案描述符是STDOUT
,因此執行的fd_ops
類中的write
時,應該執行的是stdout_ops
物件描述的write
。我們再觀察stdout_ops
.
因此執行到了chcore_stdout_write
函式。其相對地址在user/chcore-libc/libchcore/porting/overrides/src/chcore-port/stdio.c
中。
在其中新增一行以完成系統呼叫,目標呼叫函式為核心中的
sys_putstr
。使用chcore_syscallx
函式進行系統呼叫。
觀察,我們的put有兩個引數,因此我們需要呼叫需要(使用)兩個引數的系統呼叫,也就是chcore_syscall2
即可。在先前我們已經發現,需要(使用)兩個引數的系統呼叫需要傳入三個引數。因此我們有:
static void put(char buffer[], unsigned size)
{
/* LAB 3 TODO BEGIN */
// 2 arguments for syscall2.
chcore_syscall2(CHCORE_SYS_putstr, (vaddr_t)buffer, size);
/* LAB 3 TODO END */
}
至此,我們完成了對 printf
函式的分析及完善。從 printf
的例子我們也可以看到從通用 api 向系統相關 abi 的呼叫過程,並最終透過系統呼叫完成從使用者態向核心態的異常切換。
3.5 編寫使用者態程式
終於到了最後一刻。萬事俱備,我們開始編寫我們的使用者態程式,透過libc執行系統呼叫,利用Chcore的libc進行編譯,載入到核心映象中執行。
我們首先編寫檔案如下。為了防止出現意外,我們的stdio
標頭檔案採用libc內的標頭檔案。
# include "build/chcore-libc/include/stdio.h"
int main()
{
printf("Hello ChCore!");
return 0;
}
接下來使用工具進行編譯並且重定向到目標資料夾。在命令列中輸入:
./build/chcore-libc/bin/musl-gcc printf.c -o ./ramdisk/hello_world.bin
直接執行程式。
我們可以看到我們在userland後執行了hello chcore。