ChCore Lab1 机器启动 实验笔记

本文为上海交大 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 + 0x1000boot_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 研究所能给我们提供这么帮的学习资料。

posted @ 2021-05-20 23:04  康宇PL  阅读(11543)  评论(7编辑  收藏  举报