本文為上海交大 ipads 研究所陳海波老師等人所著的《現代操作系統:原理與實現》的課程實驗(LAB)的學習筆記的第一篇。
書籍官網:現代操作系統:原理與實現,里面有實驗的參考指南和代碼倉庫鏈接。
課程視頻與 PPT:SE315 / 2020 / Welcome。建議做實驗前至少把每個 LAB 對應的那一節視頻看一下,否則可能不知道從哪下手。
我自己的通關代碼:ChCore-lab - Kangyupl - gitee 僅是能通過測試集,不保證完全正確。開源的目的是供自學的朋友們誤入牛角尖、撓破頭皮時做參考。
其他章節的筆記可在此處查看:chcore | 康宇PL's Blog
先說感受:做起來還是蠻爽的。但有的地方還是會硌一下,要是講義再詳細點就好了。
前置知識
我認為需要學過 C 語言、數據結構、匯編語言后才能學這門課。只會 X86 匯編不會 ARM 匯編沒關系,老師在視頻里講過怎樣學習 ARM 匯編。
環境配置
官方的配置指南:ChCore實驗環境配置。這東西是我翻了半天才找到的。因為我們是自學的,所以只要看第一部分就行。
能用虛擬機的請直接用講義里給的虛擬機。想自己配環境的話......這是天坑,請自行研究。
2021/10/27 補記:有網友問我配置指南里的虛擬機鏈接失效怎么辦?我是手動配置的。先在 WSL 里裝了個 ubuntu 18,結果很多依賴版本都太低。然后換成 ubuntu 20,把可選和必選的依賴一股腦全裝上就 OK 了。(依賴安裝慢可以把 docker 和 apt 的軟件源換成國內的,這部分網上都有參考資料。)
構建 chcore 內核
照着實驗文檔里做就行。make build
構建內核,make qemu
在 qemu 里運行構建好的內核。
這里提一下我遇到的問題:如果你在 make qemu
時報錯如下:
make qemu
qemu-system-aarch64 -machine raspi3 -serial null -serial mon:stdio -m size=1G -kernel ./build/kernel.img -gdb tcp::1234
Unable to init server: Could not connect: Connection refused
gtk initialization failed
make: *** [Makefile:21: qemu] Error 1
說明你可能是在 WSL 或者學生雲主機這種沒有顯示設備的機子上編譯的。而 qemu 又是窗口模式執行的,所以因為找不到顯示器就執行不了了。解決方法是在 chcore-lab 根目錄下的 makefile 里在 QEMUOPTS
一行末尾增加 -nographic
選項,這樣 make qemu 時就會以命令行模式啟動 qemu 了。當然我還是建議直接用課程給你的虛擬機,因為自己配環境后面可能會有更多的坑.....
練習1
瀏覽《ARM 指令集參考指南》的 A1、A3 和 D 部分,以熟悉 ARM ISA。請做好閱讀筆記,如果之前學習 x86-64 的匯編,請寫下與 x86-64 相比的一些差異。
直接讀文檔還是蠻有挑戰性的,在此推薦個比較不錯的 ARM 匯編基礎教程,總共七個章節,上過匯編語言課程的話幾個小時就能讀完了。
英文原版:Writing ARM Assembly | Azeria Labs
知乎網友翻譯的中文版:ARM匯編語言入門 | FanMo
練習2
啟動帶調試的 QEMU,使用 GDB 的 where 命令來跟蹤入口(第一個函數)及 bootloader 的地址。
bootloader 為加載操作系統前要執行的一段程序,主要功能后文會分析。
在兩個終端里一個 make qemu-gdb
另一個 make gdb
。則 chcore 會自動在 0x0000000000080000
處卡住,這是第一條指令的位置,也是 bootloader 開始的位置。這里 where 一下。
0x0000000000080000 in ?? ()
(gdb) where
#0 0x0000000000080000 in _start ()
發現當前所在的函數為 _start
,對項目全文搜索下可以發現該函數在 boot/start.S 有定義。
至於 _start
的作用以及為什么會是 0x0000000000080000
這個奇怪的地址我們將在后文分析。
內核的引導與加載
bootloader 為加載操作系統前要執行的一段程序,放在 kernel.img 的 init 段里。而加載的那個東西是操作系統的內核, kernel.img 剩下的其他程序段都歸它所有。
chcore 里的 bootloader 具有兩個功能:
- 調用
arm64_elX_to_el1
函數將特權級切換到 EL1,即內核特權級。 - 初始化 UART、頁表、MMU。然后跳轉到內核入口代碼處。
編譯與可執行文件
練習3-1
結合 readelf -S build/kernel.img 讀取符號表與練習 2 中的 GDB 調試信息,請找出請找出 build/kernel.image 入口定義在哪個文件中。
readelf 的結果:
$ readelf -h kernel.img
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: AArch64
Version: 0x1
Entry point address: 0x80000
Start of program headers: 64 (bytes into file)
Start of section headers: 134360 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 3
Size of section headers: 64 (bytes)
Number of section headers: 9
Section header string table index: 8
$ readelf -S build/kernel.img
There are 9 section headers, starting at offset 0x20cd8:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] init PROGBITS 0000000000080000 00010000
000000000000b5b0 0000000000000008 WAX 0 0 4096
[ 2] .text PROGBITS ffffff000008c000 0001c000
00000000000011dc 0000000000000000 AX 0 0 8
[ 3] .rodata PROGBITS ffffff0000090000 00020000
00000000000000f8 0000000000000001 AMS 0 0 8
[ 4] .bss NOBITS ffffff0000090100 000200f8
0000000000008000 0000000000000000 WA 0 0 16
[ 5] .comment PROGBITS 0000000000000000 000200f8
0000000000000032 0000000000000001 MS 0 0 1
[ 6] .symtab SYMTAB 0000000000000000 00020130
0000000000000858 0000000000000018 7 46 8
[ 7] .strtab STRTAB 0000000000000000 00020988
000000000000030f 0000000000000000 0 0 1
[ 8] .shstrtab STRTAB 0000000000000000 00020c97
000000000000003c 0000000000000000 0 0 1
對於各個程序段的解釋我以前寫過一篇博文C++ 棧內存與堆內存小探究 | 康宇PL's Blog
目前我們只需要知道 .init 段放的是 bootloader 的代碼,.text 段放的是 chcore 內核的代碼。
練習 2 中我們知道了第一條執行的指令地址為 0x0000000000080000
,對比上面的結果發現正好是 Entry point address 和 init 段的地址。查閱資料可知,ELF 文件加載后入口指令的位置由 Entry point address 確定,我們再來研究下這個 Entry point address 是在哪里定義的。
在 chcore 項目里全局搜索 80000
,定位到 image.h 中,這里面定義了代碼段偏移 TEXT OFFSET 和內核虛擬地址 KERNEL VADDR。
// boot/image.h
#pragma once
#define SZ_16K 0x4000
#define SZ_64K 0x10000
#define KERNEL_VADDR 0xffffff0000000000
#define TEXT_OFFSET 0x80000
其中 TEXT_OFFSET 就是我們要找到的 80000
,再看看誰引用了 TEXT_OFFSET。全局搜索定位到 scripts/linker-aarch64.lds.in 中。往下講前先提一下 CMakeLists.txt 中定義了一個變量 init_object
,該變量表示 bootloader 對應的所有目標文件的集合,即編譯好的 bootloader 的機器碼。
# 把 bootloader 所有目標文件的集合打包為 init_object 這個變量
set(init_object
"${BINARY_KERNEL_IMG_PATH}/${BOOTLOADER_PATH}/start.S.o
${BINARY_KERNEL_IMG_PATH}/${BOOTLOADER_PATH}/mmu.c.o
${BINARY_KERNEL_IMG_PATH}/${BOOTLOADER_PATH}/tools.S.o
${BINARY_KERNEL_IMG_PATH}/${BOOTLOADER_PATH}/init_c.c.o
${BINARY_KERNEL_IMG_PATH}/${BOOTLOADER_PATH}/uart.c.o"
)
再看 scripts/linker-aarch64.lds.in,lds 文件為 gcc 的鏈接器腳本文件,語法上只要明白 .
表示“當前指針”的位置,.段名
表示某個段的位置。
// scripts/linker-aarch64.lds.in
#include "../boot/image.h"
SECTIONS
{
. = TEXT_OFFSET; // 當前指針賦值為 TEXT_OFFSET,即 0x80000
img_start = .; // 鏡像開始地址(ELF 文件入口地址)設為當前指針,即 0x80000
init : {
${init_object} // 指定 .init 段的內容為 init_object,即 bootloader 編譯后的機器碼
}
// 定義結束后當前指針將自動更新為 .init 段結尾地址
// ......
對關鍵部分進行簡要分析可知我們把 img_start
和 init 段開始地址都指定為了 TEXT OFFSET 的值,所以前面 Entry point address 和 init 段的地址都等於 0x80000
。
而把 _start
函數和 0x80000
關聯的語句則在 CMakeLists.txt 中
# 編譯時使用以下命令
# -T 指定鏈接器腳本
# -e 指定入口函數
set_property(
TARGET kernel.img
APPEND_STRING
PROPERTY
LINK_FLAGS
"-T ${CMAKE_CURRENT_BINARY_DIR}/${link_script} -e _start"
)
通過上述一系列文件最終規定了 ELF 首條指令為 Entry point address 的值 0x80000
,而該地址對應的函數則為 _start
。
練習3-2
繼續借助單步調試追蹤程序的執行過程,思考一個問題:目前本實驗中支持的內核是單核版本的內核,然而在 Raspi3 上電后,所有處理器會同時啟動。結合 boot/start.S 中的啟動代碼,並說明掛起其他處理器的控制流。
對 boot/start.S 的分析:
#include <common/asm.h>
.extern arm64_elX_to_el1
.extern boot_cpu_stack
.extern secondary_boot_flag
.extern clear_bss_flag
.extern init_c
BEGIN_FUNC(_start)
mrs x8, mpidr_el1 /* mpidr_el1中記錄了當前PE的cpuid */
and x8, x8, #0xFF /* 保留低8位 */
cbz x8, primary /* 若為0,則為首個PE,跳轉到primary */
/* hang all secondary processors before we intorduce multi-processors */
secondary_hang:
bl secondary_hang /* 若不為0,則為非首個PE,進入死循環來掛起 */
primary:
/* Turn to el1 from other exception levels. */
bl arm64_elX_to_el1 /* 調用函數,將異常級別設為內核態 */
/* Prepare stack pointer and jump to C. */
adr x0, boot_cpu_stack /* 讀入數組boot_cpu_stack地址,init_c.c中有定義 */
add x0, x0, #0x1000 /* 棧由高地址向低地址增長,故用加法,相當於給棧分配了4096字節 */
mov sp, x0 /* 設置棧指針寄存器 */
bl init_c /* 調用函數init_c,init_c.c中定義 */
/* Should never be here */
b .
END_FUNC(_start)
對 init_c.c 的分析...其實不用分析,注釋都說的很明白了。
void init_c(void)
{
/* Clear the bss area for the kernel image */
clear_bss();
/* Initialize UART before enabling MMU. */
early_uart_init();
uart_send_string("boot: init_c\r\n");
/* Initialize Boot Page Table. */
uart_send_string("[BOOT] Install boot page table\r\n");
init_boot_pt();
/* Enable MMU. */
el1_mmu_activate();
uart_send_string("[BOOT] Enable el1 MMU\r\n");
/* Call Kernel Main. */
uart_send_string("[BOOT] Jump to kernel main\r\n");
start_kernel(secondary_boot_flag);
/* Never reach here */
}
可知 chcore 掛起其他處理器的方法是通過 mpidr_el1
寄存器的值來判斷當前 PE 的 cpuid,若為 0 則為首個 PE,正常執行后續代碼;若不為 0,則非首個 PE,跳到一個死循環函數中來進行掛起。
唯一可執行的 PE 在后續代碼中完成切換到 EL1、初始化 UART、頁表、MMU 的過程,最后通過 start_kernel
將控制權交給內核代碼。
內核的加載與執行
ELF 文件的啟動分為加載 load 和執行 execute 兩個過程。
- 加載會按照每個段的加載內存地址(Load Memory Address,LMA)將其從硬盤拷貝到內存上指定的地址處
- 執行會在加載階段完成后,按照每個段的虛擬內存地址(Virtual Memory Address,VMA)將其從內存里拷貝或映射到指定的內存地址處,然后開始執行。
下面結合練習 4 具體解釋一下。
練習4
查看 build/kernel.img 的 objdump 信息。比較每一個段中的 VMA 和 LMA 是否相同,為什么?在 VMA 和 LMA 不同的情況下,內核是如何將該段的地址從 LMA 變為 VMA?提示:從每一個段的加載和運行情況進行分析
首先貼出 readelf 的結果:
$ objdump -h build/kernel.img
build/kernel.img: file format elf64-little
Sections:
Idx Name Size VMA LMA File off Algn
0 init 0000b5b0 0000000000080000 0000000000080000 00010000 2**12
CONTENTS, ALLOC, LOAD, CODE
1 .text 000011dc ffffff000008c000 000000000008c000 0001c000 2**3
CONTENTS, ALLOC, LOAD, READONLY, CODE
2 .rodata 000000f8 ffffff0000090000 0000000000090000 00020000 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .bss 00008000 ffffff0000090100 0000000000090100 000200f8 2**4
ALLOC
4 .comment 00000032 0000000000000000 0000000000000000 000200f8 2**0
CONTENTS, READONLY
拋開起注釋作用的 comment 段不管。可以發現只有 init 段的 LMA 和 VMA 是相同的,其他的 text 和 rodata 等等的 LMA 與 VMA 都有一個相同的偏移值 0xffffff000000000
。要理解這些 LMA 和 VMA 是怎么確定的還是得看鏈接腳本
// scripts/linker-aarch64.lds.in
#include "../boot/image.h"
SECTIONS
{
. = TEXT_OFFSET; // 當前指針賦值為 TEXT_OFFSET,即 0x80000
img_start = .; // 鏡像開始地址(ELF 文件入口地址)設為當前指針,即 0x80000
init : {
${init_object} // 指定 .init 段的內容為 init_object,即 bootloader 編譯后的機器碼
}
// 定義結束后當前指針將自動更新為 .init 段結尾地址
. = ALIGN(SZ_16K); // 將當前指針對齊至 16K。這是一種被稱為“內存對齊”的技術,請自行學習。
init_end = ABSOLUTE(.); // 記錄下對齊后的當前指針的值,便於后面使用
// 將 text 段 VMA 設置為 KERNEL_VADDR + init_end, VMA 設置為 init_end
// KERNEL_VADDR 在 boot/image.h 被設置為 0xffffff000000000
.text KERNEL_VADDR + init_end : AT(init_end) {
*(.text*)
}
// 下面的處理同上,不特殊指定的話 LMA 和 VMA 都會自動遞增
. = ALIGN(SZ_64K);
.data : {
*(.data*)
}
. = ALIGN(SZ_64K);
.rodata : {
*(.rodata*)
}
_edata = . - KERNEL_VADDR;
_bss_start = . - KERNEL_VADDR;
.bss : {
*(.bss*)
}
_bss_end = . - KERNEL_VADDR;
. = ALIGN(SZ_64K);
img_end = . - KERNEL_VADDR;
}
通過分析可知 init 段沒有單獨指定 VMA 和 LMA,所以它倆的地址都是 0x80000
,而 text 段開始分別指定了 VMA 和 LMA,因此它倆就不同了。要理解為這么做的理由需要先學完下一章“虛擬內存”再回來看。
下面是我根據自己大二大三這兩年來學習操作系統的經驗給出的答案,並不一定完全正確:
首先前人出於為了給操作系統內核留下足夠多的虛擬地址空間,一般都把內核放在一個非常高的地址處。以 64 味的 Linux 為例就是被放在 \(2^{48}\),也就是 1T 以外的區域。
bootloader 在開始執行時此時內存還處於“物理內存模式”,因為 bootloader 本身只起到一個引導作用,只有一個簡短的 init 段,放哪都能放開,用的內存也不多,所以直接放在從 0 開始的一段地址空間里。而內核的各個程序段按習俗應該放在一個非常高的地址處,但此時虛擬內存機制未起用,所有內存地址都是物理地址,你往一個高地址處放很大概率上我們實際的物理內存是沒有這么大的,完全沒法放。而載入 ELF 文件時每個程序段又必須得讀到內存里,所以我只能在載入階段先把各個程序段放到低地址處,待 bootloader 初始化頁表,啟動好內存管理單元后再把它們映射到高地址處。
內核態基礎功能
內核態輸入輸出
練習5
以不同的進制打印數字的功能(例如 8、10、16)尚未實現,請在 kernel/common/printk.c 中 填 充 printk_write_num 以 完善 printk 的功能。
沒什么好講的,簡單的進制轉換題。
static int printk_write_num(char **out, long long i, int base, int sign,
int width, int flags, int letbase)
{
char print_buf[PRINT_BUF_LEN];
char *s;
int t, neg = 0, pc = 0;
unsigned long long u = i;
if (i == 0) {
print_buf[0] = '0';
print_buf[1] = '\0';
return prints(out, print_buf, width, flags);
}
if (sign && base == 10 && i < 0) {
neg = 1;
u = -i;
}
// TODO: fill your code here
// store the digitals in the buffer `print_buf`:
// 1. the last postion of this buffer must be '\0'
// 2. the format is only decided by `base` and `letbase` here
s = print_buf + PRINT_BUF_LEN - 1;
*s = '\0';
while(u > 0) {
s--;
t = u % base;
if(t <= 9)
*s = t + '0';
else
*s = t - 10 + (letbase ? 'a' : 'A');
u /= base;
}
if (neg) {
if (width && (flags & PAD_ZERO)) {
simple_outputchar(out, '-');
++pc;
--width;
} else {
*--s = '-';
}
}
return pc + prints(out, s, width, flags);
}
函數棧
直接引用實驗指南中對函數棧的解釋:
棧指針(Stack Pointer, SP)寄存器(AArch64 中使用 SP 寄存器)指向當前正在使用的棧頂(即棧上的最低位置)。棧的增長方向是內存地址從大到小的方向,彈出和壓入是棧的兩個基本操作。將值壓入堆棧需要減少 SP,然后將值寫入 SP 指向的位置。從堆棧中彈出一個值則是讀取 SP 指向的值,然后增加 SP。
與之相反,幀指針(Frame Pointer,FP)寄存器(AArch64 中使用 x29 寄存器)指向當前正在使用的棧底(即棧上的最高位置)。FP 與 SP 之間的內存空間,即當前正在執行的函數的棧空間,用於保存臨時變量等。這也意味着每次進入子函數調用都會更新 FP 的值。在 AArch64 中,SP 和 FP 都是 64 位的地址,並且 8 對齊(即保證可以被 8 整除)。
練習6
內核棧初始化(即初始化 SP 和 FP)的代碼位於哪個函數?內核棧在內存中位於哪里?內核如何為棧保留空間?
初始化的代碼定義在 start.S 中,是把 boot_cpu_stack + 0x1000
的值賦值給 SP,這一指令同時也會讓 FP 等於 SP。
感謝網友 LirenWei 指出一處錯誤
2樓 2021-08-11 15:05 LirenWei
博主你好,我查了一下ARMv8手冊mov sp, x0僅僅是add sp, x0, #0的別名指令,似乎沒有你所說的同時更改fp的效果。
/* Prepare stack pointer and jump to C. */
adr x0, boot_cpu_stack
add x0, x0, #0x1000
mov sp, x0
在 boot/init_c.c 中可找到 boot_cpu_stack 的定義,是定義好的 4 個 4096 字節的全局數組,每個 CPU 用其中的一個做自己的 bootloader 用的函數棧。注意當前的函數棧只在 bootloader 里用,一會進了內核后會分配另一個內核使用的函數棧。
#define INIT_STACK_SIZE 0x1000
char boot_cpu_stack[PLAT_CPU_NUMBER][INIT_STACK_SIZE] ALIGN(16);
boot_cpu_stack + 0x1000
為 boot_cpu_stack[0]
這個數組的最高的最高處。之所以取最高處的是因為函數棧是由高地址向低地址增長的。
在開始練習 7 前先提一下 AArch64 上的函數調用慣例:用 gcc 編譯后的函數代碼開頭一般都由一小段特殊的初始化代碼。通常是把父函數的 FP 壓入棧來保存舊的 FP,然后再將當前 SP 復制到 FP 中。另外,還會記錄發生調用后在父函數里中斷的地址(有人稱之為返回地址)、保存父函數的寄存器、保存當前函數傳入的參數等等。其中返回地址被記錄在鏈接寄存器(Link Register, LR)x30 中。根據這些慣例我們就可以遞歸的確定一個函數的調用棧和傳入的參數了。
練習7
為了熟悉 AArch64 上的函數調用慣例,請在 kernel/main.c 中通過 GDB 找到 stack_test 函數的地址,在該處設置一個斷點,並檢查在內核啟動后的每次調用情況。每個 stack_test遞歸嵌套級別將多少個 64 位值壓入堆棧,這些值是什么含義?
往下看前請先自行學習一番 gdb 調試匯編語句的方法。另外因為我的 gdb 默認啟用了多線程模式調試,所以調試時是不是會切換到另一個線程中去,如果你也遇到了請自行學習 gdb 如何調試多線程后即可解決這個問題。
先看下 stack_test
的源碼
// Test the stack backtrace function (lab 1 only)
__attribute__ ((optimize("O1")))
void stack_test(long x)
{
kinfo("entering stack_test %d\n", x);
if (x > 0)
stack_test(x - 1);
else
stack_backtrace();
kinfo("leaving stack_test %d\n", x);
}
再看一下對應的匯編碼
(gdb) x/30i stack_test
=> 0xffffff000008c020 <stack_test>: stp x29, x30, [sp, #-32]! /* FP、LR 入棧 */
0xffffff000008c024 <stack_test+4>: mov x29, sp /* 更新 SP */
0xffffff000008c028 <stack_test+8>: str x19, [sp, #16] /* 保存參數 x */
0xffffff000008c02c <stack_test+12>: mov x19, x0
0xffffff000008c030 <stack_test+16>: mov x1, x0
0xffffff000008c034 <stack_test+20>: adrp x0, 0xffffff0000090000
0xffffff000008c038 <stack_test+24>: add x0, x0, #0x0
0xffffff000008c03c <stack_test+28>: bl 0xffffff000008c620 <printk>
0xffffff000008c040 <stack_test+32>: cmp x19, #0x0
0xffffff000008c044 <stack_test+36>: b.gt 0xffffff000008c068 <stack_test+72>
0xffffff000008c048 <stack_test+40>: bl 0xffffff000008c0dc <stack_backtrace>
0xffffff000008c04c <stack_test+44>: mov x1, x19
0xffffff000008c050 <stack_test+48>: adrp x0, 0xffffff0000090000
0xffffff000008c054 <stack_test+52>: add x0, x0, #0x20
0xffffff000008c058 <stack_test+56>: bl 0xffffff000008c620 <printk>
0xffffff000008c05c <stack_test+60>: ldr x19, [sp, #16] /* 載入之前的 x */
0xffffff000008c060 <stack_test+64>: ldp x29, x30, [sp], #32 /* 載入之前的 FP、LR */
0xffffff000008c064 <stack_test+68>: ret
0xffffff000008c068 <stack_test+72>: sub x0, x19, #0x1 /* x - 1 */
0xffffff000008c06c <stack_test+76>: bl 0xffffff000008c020 <stack_test>
0xffffff000008c070 <stack_test+80>: mov x1, x19
0xffffff000008c074 <stack_test+84>: adrp x0, 0xffffff0000090000
0xffffff000008c078 <stack_test+88>: add x0, x0, #0x20
0xffffff000008c07c <stack_test+92>: bl 0xffffff000008c620 <printk>
0xffffff000008c080 <stack_test+96>: ldr x19, [sp, #16] /* 載入之前的 x */
0xffffff000008c084 <stack_test+100>: ldp x29, x30, [sp], #32 /* 載入之前的 FP、LR */
0xffffff000008c088 <stack_test+104>: ret
通過研究匯編代碼可知每次遞歸調用都會將 FP、LR、參數 x 這三個值壓棧,另外壓入 x 時實際上壓了 16 字節,還有 8 字節未用到。
練習8
在 AArch64 中,返回地址(保存在x30寄存器),幀指針(保存在x29寄存器)和參數由寄存器傳遞。但是,當調用者函數(caller function)調用被調用者函數(callee fcuntion)時,為了復用這些寄存器,這些寄存器中原來的值是如何被存在棧中的?請使用示意圖表示,回溯函數所需的信息(如 SP、FP、LR、參數、部分寄存器值等)在棧中具體保存的位置在哪?
為了觀察內存狀態我們在 stack_backtrace
處打個斷點,運行到斷點處看一看 $x29 - 16
開始若干內存區域的值。為啥要 - 16
?觀察練習 7 里的匯編代碼當前函數的參數在進入函數后才被壓棧到棧頂 + 16 處 x19, [sp, #16]
。為了便於觀察我用 ASCII Flow 做了點處理,標出了 FP 的轉移過程。
(gdb) b stack_backtrace
Breakpoint 1 at 0xffffff000008c0dc
(gdb) c
Continuing.
Thread 1 hit Breakpoint 1, 0xffffff000008c0dc in stack_backtrace ()
(gdb) x/30x $x29-16
0xffffff0000092020 <kernel_stack+7968>: 0x0000000000080000 0x00000000ffffffc0
0xffffff0000092030 <kernel_stack+7984>: ┌─0xffffff0000092050 0xffffff000008c070
0xffffff0000092040 <kernel_stack+8000>: │ 0x0000000000000001 0x00000000ffffffc0
0xffffff0000092050 <kernel_stack+8016>: └►0xffffff0000092070─┐ 0xffffff000008c070
0xffffff0000092060 <kernel_stack+8032>: 0x0000000000000002 │ 0x00000000ffffffc0
0xffffff0000092070 <kernel_stack+8048>: ┌─0xffffff0000092090◄┘ 0xffffff000008c070
0xffffff0000092080 <kernel_stack+8064>: │ 0x0000000000000003 0x00000000ffffffc0
0xffffff0000092090 <kernel_stack+8080>: └►0xffffff00000920b0─┐ 0xffffff000008c070
0xffffff00000920a0 <kernel_stack+8096>: 0x0000000000000004 │ 0x00000000ffffffc0
0xffffff00000920b0 <kernel_stack+8112>: ┌─0xffffff00000920d0◄┘ 0xffffff000008c070
0xffffff00000920c0 <kernel_stack+8128>: │ 0x0000000000000005 0x00000000ffffffc0
0xffffff00000920d0 <kernel_stack+8144>: └►0xffffff00000920f0─┐ 0xffffff000008c0d4
0xffffff00000920e0 <kernel_stack+8160>: 0x0000000000000000 │ 0x00000000ffffffc0
0xffffff00000920f0 <kernel_stack+8176>: 0x0000000000000000◄┘ 0xffffff000008c018
0xffffff0000092100 <kernel_stack+8192>: 0x0000000000000000 0x0000000000000000
根據上圖我們可以建立下面的模型:
| |
| | ^
| | |
+-----------+ | Low Address
|Arg1 | |
+-----------+
|Other Data |
+-----------+
|Father's FP| ------+
+-----------+ |
|LR | |
+-----------+ |
|...... | |
| | |
| | |
|...... | |
+-----------+ |
|Arg1 | |
+-----------+ |
|Other Data | |
+-----------+ |
|Father's FP| <-----+
+-----------+
|LR | ^
+-----------+ | High Address
| | |
| | |
即 x29 里存放着當前的 FP,這個 FP 值為父函數的 FP 的地址。x29 + 8 處為當前 LR,x29 - 16 處開始為當前函數的參數列表。
練習9
使用與示例相同的格式, 在 kernel/monitor.c 中實現 stack_backtrace。為了忽略編譯器優化等級的影響,只需要考慮 stack_test 的情況,我們已經強制了這個函數編譯優化等級。
因為練習 8 已經推導出規律來了,所以這一步驟就很簡單了
__attribute__ ((optimize("O1")))
int stack_backtrace()
{
printk("Stack backtrace:\n");
u64* fp = (u64*) *((u64*)read_fp()); // 當前 FP 為調用 stack_backtrace 的函數的 FP,故加一層間接訪問
while(fp != 0) { // 遞歸到沒有父函數時停止
printk("LR %lx FP %lx Args ", *(fp + 1), fp, *(fp - 2));
printk("%d %d %d %d %d\n", *(fp - 2), *(fp - 1), *(fp), *(fp + 1), *(fp + 2));
fp = (u64*) *fp;
}
return 0;
}
需要注意當前 read_fp
得到的是 stack_backtrace
的 FP,而我們要求的函數是不包括 stack_backtrace
本身的,所以要來一層間接引用取到這個 FP 的父函數的 FP。
另外根據練習 8 的結論,LR 存放在 *(FP + 1)
里,這里的 + 1 是對 u64 指針 + 1,因此實際上是加了 8 個字節。同理參數 x 存放在 *(FP - 2)
處。至於為啥當前 FP 不用加 *
,這里要想明白 *FP
,即 FP 的內存地址處存放着的是父函數的 FP,而當前函數的 FP 是在子函數里時通過 *FP
求出來的,所以不需要加間接訪問符號。
最后你要是疑惑我為啥要輸出五個參數的話,請回過頭仔細閱讀下講義中對練習 9 的實驗要求。
后記
2020-11-06
既然發售當天買的花錢買的第一版,有些感受還是得談談嘛。在課本的致謝中可以看到 ipads 研究所的許多前輩合作完成的,但就目前的初版而言內容充實度實在不能稱為一本操作系統的教材,如果不是配合陳老師的課看的話很多地方根本就讀不懂。雖然陳老師把課程的視頻、講義、配套實驗都開源了,甚至還設了專門論壇,這一套下來肯定對得起書的價格,但就實際體驗而言還是差點兒意思。比如如果匯編和組成原理學的不太好,又不會用linux的話做實驗的時候根本就無從下手。畢竟是剛出的東西,再發展幾年可能會變得更容易上手一些,希望后期可以發展到清華的 ucore 那樣。
2021-05-20
距離上次的做這個實驗已經過去半年了,這兩天抽空把實驗重做了一遍,博客也完全重寫了。這半年里我主要干了備戰 ICPC 區域賽和准備面試暑期實習這倆事兒。在准備面試暑期實習的復習過程中又反反復復看了好幾遍《現代操作系統:原理與實現》這本書,從中有了新的理解和感悟。最終認定這書寫的確實不錯, 但也確實不適合作為學習操作系統的第一本書。如果說你有了一定的理論方面知識,想要再結合實踐來加深對操作系統的理解的話到適合讀一讀這本書。我第一遍學操作系統時各個也能看懂,但總覺得缺少一點脈絡,學的東西很零散。經過一段時間的沉淀后再回來學就有一種提綱挈領地感覺了,閉上眼能想出來一個操作系統該有哪些模塊,每個模塊負責啥了。包括重做這一個 LAB 后也有了很多新收獲。還是很感謝 ipads 研究所能給我們提供這么幫的學習資料。