IPADS OS Course Lab Manual
本仓库包含上海交通大学IPADS实验室设计的操作系统课程系列实验。每个实验位于独立的目录。
课程教材:
note
如果你有任何建议或更正意见,欢迎提交 Pull Requests 或 Issues。让我们一起合作改进实验
Lab0: 拆炸弹 (ARM 汇编)
该实验受到CSAPP课程启发,CSAPP课程设计了一个针对x86/x86-64汇编的拆炸弹实验。 不同之处在于,本实验目标是熟悉ARM汇编语言,并为后续的ARM/树莓派内核实验做好准备。
Tutorial: https://www.bilibili.com/video/BV1q94y1a7BF/?vd_source=63231f40c83c4d292b2a881fda478960
Lab1: 内核启动
该实验的主要内容是关于如何在内核启动过程中设置CPU异常级别、配置内核页表并启用MMU。 在内核实验系列中,我们将使用 ChCore 微内核 的基础版本,并使用 Raspi3b+作为实验平台(无论是使用QEMU树莓派模拟器还是树莓派开发板都可以)。
Tutorial: https://www.bilibili.com/video/BV1gj411i7dh/?spm_id_from=333.337.search-card.all.click
Lab2: 内存管理
该实验主要内容是关于内核中的伙伴系统和slab分配器的实现,并为应用程序设置页表。
Tutorial: https://www.bilibili.com/video/BV1284y1Q7Jc/?vd_source=316867e8ad2c56f50fa94e8122dd7d38
Lab3: 进程与线程
该实验主要内容包括创建第一个用户态进程和线程,完善异常处理流程和系统调用,编写一个Hello-World在实验内核上运行。
Tutorial: https://www.bilibili.com/video/BV11N411j7bR/
Lab4:多核调度与IPC
该实验中可以看到多核是如何启动的、多线程如何调度、基于capability权限管控的进程间通信机制。
Tutorial: https://www.bilibili.com/video/BV1AS421N7rU/
Lab5:虚拟文件系统
该实验关注虚拟文件系统(Virtual File System,VFS), VFS抽象层使得不同类型的文件系统可以在应用程序层面以统一的方式进行访问。
Lab6:GUI (Optional)
该实验将详细介绍ChCore上基于Wayland的GUI系统的运行原理,包括Wayland通信协议和Wayland Compositor,并且要求读者在了解基于Wayland的GUI系统运行原理的基础上,基于ChCore的GUI框架编写自己的具有GUI界面的APP。
如何开始实验
环境准备
对于所有的操作系统,本实验必须依赖Docker环境且仅能在Linux系统上进行运行(我们不支持Mac OS系统), 请按照Docker官方指示为你运行的操作系统安装对应的Docker发行版。
关于docker
由于中国大陆地区的网络限制,请确保你的docker能够连接到DockerHub,测试方法可以使用 docker pull nginx:latest
,如果无法访问,您可以依照该文档为你的docker daemon添加代理规则。如果你缺少代理,你可以使用这个以下的几个链接: 百度云(提取uwuv) 交大云(仅上海交通大学内部可以访问)。
下载压缩好的Docker镜像,镜像使用gzip
解压缩镜像文件到标准输出流之后再由docker进行导入(下方的yy.mm请以当前的大版本号替换,如版本tag为24.09.1则请将yy.mm替换成24.09)。
gzip -cd docker.ipads.oslab.yy.mm.tar.gz | docker load
关于虚拟机
如果你使用的是Windows/MacOS系统,如果不想手动安装docker以及下载镜像,我们也准备了基于VMWare 17的虚拟机镜像,你也可以使用上方的链接下载vmware虚拟机镜像,你可以在解压之后导入vmware即可使用。
用户stu 密码为123456
warning
小心使用sudo,请注意我们在编译的时候我们会同步的在编译文件夹时写入timestamp文件,如果你使用root权限,会导致后续普通的正常构建返回错误。
使用Dev-Container (推荐)
如果你使用的是带有支持Microsoft规范下Dev-Container插件的代码编辑器或者集成开发环境,亦或者你使用的是非Linux平台的开发环境,我们强烈建议你使用Dev-container直接进行开发,我们已经在其中已经预先安装好了你可能需要使用的所有工具链。并且针对vscode我们在每个Lab的分支目录下都已经配置好了合适的插件配置,简单安装即可以一键启用。安装完之后进入本实验的根目录,此时dev-container会识别到容器开发环境,重新进入后就可以直接使用了。
本地手动安装
如果你并不想要使用Dev-container,且你使用的是原生Linux平台或者自行创建的Linux虚拟机,我们在下方准备了所有流行发行版的工具链安装命令。同时我们也推荐安装clangd
,其可以根据CMake
生成的compile_commands.json
即编译信息数据库提供增强的LSP的提示功能,用于支撑区分相同函数签名但是不同编译单元参与编译的重名跳转以及根据宏定义用于高显源文件。
以下是本Lab最主要的依赖
- Qemu-system-aarch64
- Qemu-user
- Python >= 3.7
- Make
以下是针对每个主流发行版的除去Docker以外的安装命令。
- Ubuntu/Debian
apt install qemu-system-aarch64 qemu-user python3 python3-pip python3-psutil make gdb-multiarch
- Fedora
dnf install qemu-system qemu-user python3 python3-pip python3-psutil make gdb
- Arch Linux
pacman -Sy qemu-user qemu-system-aarch64 python python-pip python-psutil base-devel gdb
- Gentoo
USE="qemu_mmu_targets_aarch64 qemu_user_targets_aarch64" emerge -1v qemu python dev-python/psutil python-pip make gdb qemu
- OpenSuse Tumbleweed
zypper install qemu qemu-linux-user python3 python3-psutil make gdb
important
当你完成后,请务必直接运行Scripts/gendeps.sh
,此时会根据你的系统环境生成环境定义。
文档说明
各实验文档除开lab0
为单独的实验内容,其他都包含了以下的几种习题,请根据下方的指示完成对应实验报告以及对应的编程题。
思考题
思考题为需要在实验报告中书面回答的问题,需要用文字或者是图片来描述
练习题
练习题需在 ChCore 代码中填空,并在实验报告阐述实现过程,完成即可获得实验大多数的分数。
挑战题
挑战题为难度稍高的练习题,作为实验的附加题用于加深你对代码结构以及系统设计的理解。
CI评分
在新实验中,我们特意准备了支持github actions以及gitlab ci的CI配置,你可以在你所使用的代码托管平台上自动运行脚本,为了确保你不会篡改预编译的.obj
文件,我们会根据本仓库的主线的各个Lab中的filelist.mk
(详细见贡献指南)的定义自动提取提交中所需要的文件与当前仓库主线的文件树进行合并,最终进行评分。
贡献指南
代码规范
commit
我们参照Conventional Commit构建了Pull Request的Blocker,并且关闭了对主分支的直接Push,请确保你的Commit符合Conventional Commit规范
github
我们使用Github Issues跟踪所有的问题,如果你在实验过程中产生了任何预期以外的错误,欢迎提交Issues.
mdbook
我们使用基于MdBook构建Markdown文档体系,你可以参照.github/github-pages.yaml
中的下载指示,将所有的Mdbook及其所需要的所有预处理器,都安装到你的系统环境路径中。如果你对文档方面有任何的更正,你可以在Pages/SUMMARY.md
中找到实验手册的文档结构以及对应的所有文件。更改后在仓库根目录你可以运行mdbook-mermaid install .
然后运行mdbook serve
,并访问localhost:3000
查看编译后的文档。我们也使用markdownlint
对所有文档开启的CI检查,请确保提交后能够通过CI.
工具链使用
由于工具链版本问题,可能会导致在不同版本工具链编译的情况下导致在不同Release版本所链接的系统镜像无法正常工作的情况,请确保开发过程中使用与.devcontainer/Dockerfile
即ubuntu 20.04.6的相同版本的交叉编译链进行预编译源文件,本仓库对所有Lab的正确答案的构建同样也开启了CI检查,如果发现在不同Release版本下无法通过,请检查你所使用的工具链是否符合预期。
important
对于所有源代码的预编译,请一定准备两份,并且对调试符号段的所有信息都使用aarch64-gnu-linux-strip
进行删除。
如何提交新实验
对于新实验,我们使用两个文件进行定义实验规范,即filelist.mk
以及scores.json
,其中scores.json
用于定义给分点以及对应的分数,
filelist.mk
则是提交文件列表,用于定义该Lab的提交文件,运行make submit
后make
会读取filelist.mk
的文件定义,并按照一致的相对路径进行打包。
文件定义样例在Lab1中可以查看。
v24.09 更新内容
- 集中化统一的目录管理
- 完成Lab1-Lab5的迁移设置
- 更新基于vscode的lab的开发环境
- 更新lsp设置
- 修复多个构建以及运行时问题
- 迁移文档至mdbook
Lab0:拆炸弹
简介
在实验 0 中,你需要通过阅读汇编代码以及使用调试工具来拆除一个 二进制炸弹程序。本实验分为两个部分:第一部分介绍拆弹实验的基本知 识,包括 ARM 汇编语言、QEMU 模拟器、GDB 调试器的使用;第二部分 需要分析炸弹程序,推测正确的输入来使得炸弹程序能正常退出。
warning
在完成本实验之前,请务必将你的学号填写在student-number.txt
当中,否则本lab实验的成绩将计为0分
Makefile 讲解
make bomb
: 使用student-number.txt提供的学号,生成炸弹,如果您不是上海交通大学的学生可以自行随意填写。make qemu
: 使用qemu-aarch64二进制模拟运行炸弹make qemu-gdb
: 使用qemu-aarch64提供的gdb server进行调试make gdb
: 使用仓库目录自动生成的$(GDB)定义连接到qemu-aarch64的gdb-server进行调试
评分与提交规则
本实验你只需要提交ans.txt
以及student-number.txt
即可
important
运行 make grade
来得到本实验的分数
运行 make submit
会在检查student-number.txt内容之后打包必要的提交文件
基本知识
info
本部分旨在熟悉 ARM 汇编语言,以及使用 QEMU 和 QEMU/GDB调试
熟悉Aarch64汇编
AArch64 是 ARMv8 ISA 的 64 位执行状态。《ARM 指令集参考指 南》是一个帮助入门 ARM 语法的手册。在 ChCore 实验中,只 需要在提示下可以理解一些关键汇编和编写简单的汇编代码即可。 你可以在 Arm 的网站 上搜索具体的指令, 常备快速参考手册也有帮助,比如 这里。
tip
如果你完全没接触过 ARM,这些提示可以帮助你更顺利进入实验:
- x0-x31 是 64 位通用寄存器
- x0-x7 用作传参,x0 还用作返回值
- x31 (sp) 是栈指针
- x30 (lr) 是返回地址
- x29 (fp) 是栈帧指针
- w0-w31 是 x0-x31 对应的 32 位寄存器
- [Xn] 和 [Xn, #imm] 是两种常用的取址模式,寄存器内的值解释为地址,加上可选的常量偏移
使用 QEMU 运行炸弹程序
我们在实验中提供了bomb二进制文件,但该文件只能运行在基于 AArch64 的 Linux 中。通过 QEMU,我们可以在其他架构上模拟运行。同时,QEMU 可以结合 GDB 进行调试(如打印输出、单步调试等)
tip
QEMU 不仅可以模拟运行用户态程序,也可以模拟运行在内核态的操作系统。在前一种模式下,QEMU 会模拟运行用户态的汇编代码,同时将系统调用等翻译为对宿主机的调用。在后一种模式下,QEMU 将在虚拟硬件上模拟一整套计算机启动的过程。
在lab0目录下,输入以下命令可以在 QEMU 中运行炸弹程序
[user@localhost Lab0]$ make qemu
炸弹程序的标准输出将会显示在 QEMU 中:
Type in your defuse password:
QEMU 与 GDB
在实验中,由于需要在 x86-64 平台上使用 GDB 来调试 AArch64 代 码,因此使用gdb-multiarch代替了普通的gdb。使用 GDB 调试的原理是, QEMU 可以启动一个 GDB 远程目标(remote target) (使用-s或-S参数 启动),QEMU 会在真正执行镜像中的指令前等待 GDB 客户端的连接。开 启远程目标之后,可以开启 GDB 进行调试,它会在某个端口上进行监听。
打开两个终端,在bomb-lab目录下,输入make qemu-gdb和make gdb命 令可以分别打开带有 GDB 调试的 QEMU 以及 GDB,在 GDB 中将会看 到如下的输出:
...
0x0000000000400540 in ?? ()
...
(gdb)
二进制炸弹拆除
我们在实验中提供了一个二进制炸弹程序bomb以及它的部分源码bomb.c。在 bomb.c 中,你可以看到一共有 6 个 phase。对每个 phase,bomb程序将从标准中输入中读取一行用户输入作为这一阶段的拆弹密码。若这一密码错误,炸弹程序将异常退出。你的任务是通过 GDB 以及阅读汇编代码,判断怎样的输入可以使得炸弹程序正常通过每个 phase。以下是一次失败尝试的例子:
[user@localhost lab0] $ make qemu
qemu -aarch64 bomb
Type in your defuse password:
1234
BOOM !!!
tip
你需要学习gdb、objdump的使用来查看炸弹程序对应的汇编,并通过断点等方法来查看炸弹运行时的状态(寄存器、内存的值等)。以下是使用gdb来查看炸弹运行状态的例子。在这个例子中,我们在main函数的开头打了一个断点,通过continue让程序运行直至遇到我们设置的断点,使用info查看了寄存器中的值,最终通过x查看了x0寄存器中的地址指向的字符串的内容。以下是输入与输出。
add symbol table from file "bomb"
(y or n) y
Reading symbols from bomb ...
(gdb) break main
Breakpoint 1 at 0x4006a4
(gdb) continue
Continuing.
Breakpoint 1, 0x00000000004006a4 in main ()
(gdb) disassemble
Dump of assembler code for function main:
0x0000000000400694 <+0>:stp0x0000000000400698 <+4>:mov
x29 , x30 , [sp , # -16]!
x29 , sp
0x000000000040069c <+8>:adrpx0 , 0x464000 <free_mem +64>
0x00000000004006a0 <+12>:addx0 , x0 , #0x778
=> 0x00000000004006a4 <+16>:bl0x413b20 <puts >
0x00000000004006a8 <+20>:bl0x400b10 <read_line >
0x00000000004006ac <+24>:bl0x400734 <phase_0 >
0x00000000004006b0 <+28>:bl0x400708 <phase_defused >
0x00000000004006b4 <+32>:bl0x400b10 <read_line >
0x00000000004006b8 <+36>:bl0x400760 <phase_1 >
0x00000000004006bc <+40>:bl0x400708 <phase_defused >
0x00000000004006c0 <+44>:bl0x400b10 <read_line >
0x00000000004006c4 <+48>:bl0x400788 <phase_2 >
0x00000000004006c8 <+52>:bl0x400708 <phase_defused >
0x00000000004006cc <+56>:bl0x400b10 <read_line >
0x00000000004006d0 <+60>:bl0x400800 <phase_3 >
0x00000000004006d4 <+64>:bl0x400708 <phase_defused >
0x00000000004006d8 <+68>:bl0x400b10 <read_line >
0x00000000004006dc <+72>:bl0x4009e4 <phase_4 >
0x00000000004006e0 <+76>:bl0x400708 <phase_defused >
0x00000000004006e4 <+80>:bl0x400b10 <read_line >
0x00000000004006e8 <+84>:bl0x400ac0 <phase_5 >
0x00000000004006ec <+88>:bl0x400708 <phase_defused >
0x00000000004006f0 <+92>:adrpx0 , 0x464000 <free_mem +64>
0x00000000004006f4 <+96>:addx0 , x0 , #0x798
0x00000000004006f8 <+100>:bl0x413b20 <puts >
0x00000000004006fc <+104>:movw0 , #0x0
0x0000000000400700 <+108>:ldpx29 , x30 , [sp], #16
0x0000000000400704 <+112>:ret
// #0
End of assembler dump.
(gdb) info registers x0
x0
0x464778
4605816
(gdb) x /s 0x464778
0x464778:
"Type in your defuse password!"
在破解后续阶段时,为了避免每次都需要输入先前阶段的拆弹密码,你可以通过重定向的方式来让炸弹程序读取文件中的密码:
[user@localhost lab0] $ make qemu < ans.txt
qemu -aarch64 bomb
Type in your defuse password:
5 phases to go
4 phases to go
3 phases to go
2 phases to go
1 phases to go
0 phases to go
Congrats! You have defused all phases!
Lab1: 机器启动
简介
本实验作为 ChCore 操作系统课程实验的第一个实验,分为三个部分。
- RTFSC: 代码导读,由于是Lab1,我们主要注重于Chcore的构建系统,这部分没有习题。
- 机器启动:介绍aarch64结构启动时的关键寄存器以及关键的启动函数。
- 页表配置:介绍aarch64页表结构,以及针对树莓派3平台的内存布局编写页表配置。
调试指北
在开始实验之前,请务必读完调试指北,以帮助你快速上手调试。
本实验你可以在QEMU模拟器上完成实验,也可以在树莓派开发板上完成。 本实验代码包含了基础的ChCore 微内核操作系统,除了练习题相关的源码以外,其余部分通过预先编译的二进制格式提供。
完成本实验的练习题之后,你可以进入 ChCore shell,运行命令或执行程序。
例如,可以在 shell 中输入 hello_world.bin
运行一个简单的用户态程序;
输入ls
查看目录内容。
______ __ __ ______ __ __ ______ __ __
/\ ___\ /\ \_\ \ /\ ___\ /\ \_\ \ /\ ___\ /\ \ /\ \
\ \ \____ \ \ __ \ \ \___ \ \ \ __ \ \ \ __\ \ \ \____ \ \ \____
\ \_____\ \ \_\ \_\ \/\_____\ \ \_\ \_\ \ \_____\ \ \_____\ \ \_____\
\/_____/ \/_/\/_/ \/_____/ \/_/\/_/ \/_____/ \/_____/ \/_____/
Welcome to ChCore shell!
$
RTFSC(1)
important
RTFSC = Read the FRIENDLY Source Code
Lab1的代码很多,在第一部分的代码架构解析的部分我们主要来讲解内核镜像是如何构建产生,以及评分基础设施是如何工作的。
构建系统
Makefile
makefile
如果你对Makefile的语法有疑问的话,你可以参考这个网站的教程熟悉Makefile的写法。1
原有的Chcore的构建系统仅围绕着Scripts/chbuild
这个脚本进行构建,但是由于OS Course Lab
需要增加评分的设施,
我们为此在chbuild
之外添加了Makefile的基础结构,以下为Lab1/Makefile
的内容
LAB := 1
include $(CURDIR)/../Scripts/lab.mk
注意到我们我们仅仅我们定义了Lab的标识符,然后使用include
将上层Scripts/lab.mk
导入到当前的Makefile
中。
# Note that this file should be included directly in every Makefile inside each lab's folder.
# This sets up the environment variable for lab's Makefile.
ifndef LABROOT
LABROOT := $(CURDIR)/..
endif
SCRIPTS := $(LABROOT)/Scripts
ifeq (,$(wildcard $(SCRIPTS)/env_generated.mk))
$(error Please run $(SCRIPTS)/gendeps.sh to create the environment first!)
endif
ifeq (,$(LAB))
$(error LAB is not set!)
endif
LABDIR := $(LABROOT)/Lab$(LAB)
SCRIPTS := $(LABROOT)/Scripts
GRADER ?= $(SCRIPTS)/grader.sh
include $(SCRIPTS)/env_generated.mk
# Toolchain configuration
GDB ?= gdb
DOCKER ?= docker
DOCKER_IMAGE ?= ipads/oslab:24.09
ifeq (,$(wildcard /docker.env))
DOCKER_RUN ?=
else
DOCKER_RUN ?= $(DOCKER) run -it --rm \
-e SCRIPTS=$(SCRIPTS) \
-e LABROOT=$(LABROOT) \
-e LABDIR=$(LABDIR) \
-e TIMEOUT=$(TIMEOUT) \
-e LAB=$(LAB) \
-u $(shell id -u $(USER)):$(shell id -g $(USER)) \
-v $(LABROOT):$(LABROOT) -w $(CURDIR) \
--security-opt=seccomp:unconfined \
ipads/oslab:24.09
endif
QEMU-SYS ?= qemu-system-aarch64
QEMU-USER ?= qemu-aarch64
# Timeout for grading
TIMEOUT ?= 10
ifeq ($(shell test $(LAB) -eq 0; echo $$?),1)
QEMU := $(QEMU-SYS)
ifeq ($(shell test $(LAB) -gt 4; echo $$?),0)
include $(LABROOT)/Scripts/extras/lab$(LAB).mk
else
include $(LABROOT)/Scripts/kernel.mk
endif
include $(LABROOT)/Scripts/submit.mk
else
QEMU := $(QEMU-USER)
endif
而lab.mk
主要针对Lab环境进行检查,同时定义一些关键的变量,最终根据当前的$(LAB)
的序号,再去导入不同的定义,在此处由于
我们的$(LAB)
变量为1,所以我们真正使用的Makefile定义为kernel.mk
V ?= 0
Q := @
ifeq ($(V), 1)
Q :=
endif
BUILDDIR := $(LABDIR)/build
KERNEL_IMG := $(BUILDDIR)/kernel.img
_QEMU := $(SCRIPTS)/qemu_wrapper.sh $(QEMU)
QEMU_GDB_PORT := 1234
QEMU_OPTS := -machine raspi3b -nographic -serial mon:stdio -m size=1G -kernel $(KERNEL_IMG)
CHBUILD := $(SCRIPTS)/chbuild
SERIAL := $(shell tr -dc A-Za-z0-9 </dev/urandom | head -c 13; echo)
export LABROOT LABDIR SCRIPTS LAB TIMEOUT
all: build
defconfig:
$(Q)$(CHBUILD) defconfig
build:
$(Q)test -f $(LABDIR)/.config || $(CHBUILD) defconfig
$(Q)$(CHBUILD) build
$(Q)find -L $(LABDIR) -path */compile_commands.json \
! -path $(LABDIR)/compile_commands.json -print \
| $(SCRIPTS)/merge_compile_commands.py
clean:
$(Q)$(CHBUILD) clean
$(Q)find -L $(LABDIR) -path */compile_commands.json -exec rm {} \;
distclean:
$(Q)$(CHBUILD) distclean
qemu: build
$(Q)$(_QEMU) $(QEMU_OPTS)
qemu-grade: build
$(SCRIPTS)/change_serial $(KERNEL_IMG) $(SERIAL)
$(Q)$(_QEMU) $(QEMU_OPTS)
qemu-gdb: build
$(Q)echo "[QEMU] Waiting for GDB Connection"
$(Q)$(_QEMU) -S -gdb tcp::$(QEMU_GDB_PORT) $(QEMU_OPTS)
gdb:
$(Q)$(GDB) --nx -x $(SCRIPTS)/gdb/gdbinit
grade:
$(MAKE) distclean
$(Q)$(DOCKER_RUN) $(GRADER) -t $(TIMEOUT) -f $(LABDIR)/scores.json -s $(SERIAL) make SERIAL=$(SERIAL) qemu-grade
.PHONY: qemu qemu-gdb gdb defconfig build clean distclean grade all
这里简述一下用法,其中我们定义了一个变量V
,当我们运行例如make V=1
时会将Q
的定义重新设置,Q
的目的主要是为了做
字符串的拼接。
makefile怎么工作?
在Makefile中,其主要分为两种定义,全局定义以及规则定义。全局定义主要是定义变量以及Makefile宏或者是函数,规则定义则是根据变量或者字面字符串定义进行拼接,然后使用shell执行拼接后的命令,例如此处的Q在V不处于Verbose模式时就会被视作@,此时Make就不会打印下面的命令。
当我们在Lab1运行make build
,其就会转到kernel.mk
的build
这个规则下,此时Make
会调用cmake完成进一步的构建
CMake
镜像定义生成
当我们运行make build
之后,我们便进到了chbuild
脚本中了,当开始时我们会使用chbuild defconfig
这个bash函数调用cmake的其他脚本来生成镜像配置文件.config
,由于我们默认使用raspi3
配置,我们会将Scripts/defconfigs/raspi3.config
复制到当前Lab的根目录下,这个是chcore的平台定义文件,之后则会调用Scripts/build/cmake
添加到Lab当中,之后则会运行_config_default
这个函数主要负责递归读入Lab目录下的config.cmake
文件并按照默认设置将平台无关的镜像配置文件,之后运行_sync_config_with_cache
将镜像定义设置.config
同步到CMakeCache中进行缓存,并返回到Make
当中,之后Make
继续运行chbuild build
,按照.config
定义进行构建镜像脚本。
defconfig() {
if [ -d $cmake_build_dir ]; then
_echo_err "There exists a build directory, please run \`$clean_command\` first"
exit 1
fi
if [ -z "$1" ]; then
plat="raspi3"
else
plat="$1"
fi
_echo_info "Generating default config file for \`$plat\` platform..."
cp $defconfig_dir/${plat}.config $config_file
_config_default
_sync_config_with_cache
_echo_succ "Default config written to \`$config_file\` file."
}
_config_default() {
_echo_info "Configuring CMake..."
cmake -B $cmake_build_dir -C $cmake_init_cache_default
}
_sync_config_with_cache() {
cmake -N -B $cmake_build_dir -C $cmake_init_cache_dump >/dev/null
}
定义多态设计
Chcore通过config.cmake
这个文件来定义规则的,但是我们单独去看的时候它使用了chcore_config
这个宏,但是这个指令是不存在,实际上
所有的config.cmake
也是通过include
指令来导入的,所以chbuild
的每个指令都是去定义了cmake
的chcore_config
来执行不同的行为。
大致的过程图如下, DumpConfig.cmake
主要是将chcore_config
中的内容进行提取,并全部添加到defconfig
生成的.conifg
文件中,而CMakeList.txt
构建时的chcore_config
则是根据.config
中的内容定向的配置子项目的编译选项。如果感兴趣你可以阅读Scripts/build/cmake/
下的cmake
脚本文件。
flowchart LR chbuild["build()"] chdump["_sync_config()"] chdefault["_config_default()"] cmakebuild["CMakeList.txt"] cmakedump["DumpConfig.cmake"] cmakeload["LoadConfig.cmake"] config["config.cmake"] file[".config"] cache["CMakeCache.txt"] image["kernel.img"] subgraph 编译 chbuild-->cmakebuild cmakebuild-->config config-->image end subgraph 输出.config chdump-->cmakedump cmakedump-->config config-->file end file-->image subgraph 同步cache chdefault-->cmakeload cmakeload-->config config-->cache end
镜像编译
Chcore的编译是从CMakelists.txt
的上层开始的,总的来说经过了如下的编译过程
flowchart TD topcmake["CMakeLists.txt"] subkernel["subproject(kernel)"] incclean["kernel-inc-clean"] config[".config"] cache_args["_cache_args"] common_args["common_args"] kernelTools["KernelTools.cmake"] sources[".c .S"] downcmake["CMakeLists.txt"] objects[".dbg.obj .obj"] procmgr["procmgr"] incbin["incbin.tpl.S"] incbin-procmgr["incbin-procmgr.S"] subgraph toplevel topcmake-->cache_args config-->cache_args topcmake-->incclean topcmake-->common_args common_args-->subkernel cache_args-->subkernel end subgraph kernel incbin-->|configure|incbin-procmgr procmgr-->|include|incbin-procmgr kernelTools-->downcmake sources-->downcmake incbin-procmgr-->downcmake downcmake-->objects objects-->kernel.img linker.tpl.ld-->|configure|linker.ld linker.ld-->kernel.img end subkernel-->kernel incclean -.- kernel.img
首先上层的CMakeLists.txt
会根据.config
的内容构造_cache_args
以及_common_args
分别对应的是下层CMake
的子项目的CMake
构建参数以及变量参数,然后
创建kernel-incclean
用于删除kernel.img
构建时的副产物,对应到最上层chbuild clean
以及make clean
时的清理选项,然后会递归进入kernel
这个子项目。
进入子项目后,CMake
首先会去导入KernelTools.cmake
这个脚本去定义一些关键函数以及关键宏,同时会定义关键的工具链选项以及包含路径,最后再逐步地将每个子目录的CMakeLists.txt
进行导入,对于源文件进行编译,对于预编译文件则是按照调试选项对应添加.dbg.obj
或者是.obj
文件进入文件列表,之后则是将user/procmgr
这个文件利用incbin.tpl.S
去生成对应的二进制汇编进行编译,最后使用linker.tpl.ld
所生成的linker.ld
的linker script
进行链接最后得到kernel.img
的镜像。
linker script
如果你对链接脚本感兴趣,你可以参考这个附录3.
QEMU
当kernel构建完成后,我们将使用qemu-system-aarch64
进行模拟,当我们运行make qemu
或者是make qemu-gdb
时我们会进入如下的规则,
KERNEL_IMG := $(BUILDDIR)/kernel.img
_QEMU := $(SCRIPTS)/qemu_wrapper.sh $(QEMU)
QEMU_GDB_PORT := 1234
QEMU_OPTS := -machine raspi3b -nographic -serial mon:stdio -m size=1G -kernel $(KERNEL_IMG)
$(Q)find -L $(LABDIR) -path */compile_commands.json -exec rm {} \;
distclean:
$(Q)$(CHBUILD) distclean
qemu: build
此时Make
会将QEMU_OPTS
以及可能QEMU_GDB_PORT
进行字符串的拼接,然后将参数传入qemu_wrapper.sh
转到qemu
程序中。
评分系统
我们使用make grade
时会将TIMEOUT
参数以及评分定义scores.json
以及被评分的指令传入grader.sh
,
#!/usr/bin/env bash
test -f ${LABDIR}/.config && cp ${LABDIR}/.config ${LABDIR}/.config.bak
if [[ -z $LABROOT ]]; then
echo "Please set the LABROOT environment variable to the root directory of your project. (Makefile)"
exit 1
fi
. ${LABROOT}/Scripts/shellenv.sh
info "Grading lab ${LAB} ...(may take ${TIMEOUT} seconds)"
bold "==========================================="
${LABROOT}/Scripts/capturer.py $@ 2> /dev/null
score=$?
if [[ $score -eq 255 ]]; then
error "Something went wrong. Please check the output of your program"
exit 0
fi
info "Score: $score/100"
bold "==========================================="
test -f ${LABDIR}/.config.bak && cp ${LABDIR}/.config.bak ${LABDIR}/.config && rm .config.bak
if [[ $score -lt 100 ]]; then
exit $?
else
exit 0
fi
在备份.config
之后,其会调用capturer.py
的内容,去动态捕捉命令的输出,并按照顺序与scores.json
的内容进行比对,
从而计算评分,如果提前退出或者接收到SIGINT
信号,则整个程序会直接退出并返回0分
。
bug
请注意我们是根据capturer.py的返回值来进行评分,如果有问题欢迎提交issues!
内核启动
树莓派启动过程
在树莓派 3B+ 真机上,通过 SD 卡启动时,上电后会运行 ROM 中的特定固件,接着加载并运行 SD 卡上的 bootcode.bin
和 start.elf
,后者进而根据 config.txt
中的配置,加载指定的 kernel 映像文件(纯 binary 格式,通常名为 kernel8.img
)到内存的 0x80000
位置并跳转到该地址开始执行。
而在 QEMU 模拟的 raspi3b
(旧版 QEMU 为 raspi3
)机器上,则可以通过 -kernel
参数直接指定 ELF 格式的 kernel 映像文件,进而直接启动到 ELF 头部中指定的入口地址,即 _start
函数(实际上也在 0x80000
,因为 ChCore 通过 linker script 强制指定了该函数在 ELF 中的位置,如有兴趣请参考附录)。
启动 CPU 0 号核
_start
函数(位于 kernel/arch/aarch64/boot/raspi3/init/start.S
)是 ChCore 内核启动时执行的第一块代码。由于 QEMU 在模拟机器启动时会同时开启 4 个 CPU 核心,于是 4 个核会同时开始执行 _start
函数。而在内核的初始化过程中,我们通常需要首先让其中一个核进入初始化流程,待进行了一些基本的初始化后,再让其他核继续执行。
思考题 1
阅读 _start
函数的开头,尝试说明 ChCore 是如何让其中一个核首先进入初始化流程,并让其他核暂停执行的。
hint
可以在 Arm Architecture Reference Manual 找到 mpidr_el1
等系统寄存器的详细信息。
切换异常级别
AArch64 架构中,特权级被称为异常级别(Exception Level,EL),四个异常级别分别为 EL0、EL1、EL2、EL3,其中 EL3 为最高异常级别,常用于安全监控器(Secure Monitor),EL2 其次,常用于虚拟机监控器(Hypervisor),EL1 是内核常用的异常级别,也就是通常所说的内核态,EL0 是最低异常级别,也就是通常所说的用户态。
QEMU raspi3b
机器启动时,CPU 异常级别为 EL3,我们需要在启动代码中将异常级别降为 EL1,也就是进入内核态。具体地,这件事是在 arm64_elX_to_el1
函数(位于 kernel/arch/aarch64/boot/raspi3/init/tools.S
)中完成的。
为了使 arm64_elX_to_el1
函数具有通用性,我们没有直接写死从 EL3 降至 EL1 的逻辑,而是首先判断当前所在的异常级别,并根据当前异常级别的不同,跳转到相应的代码执行。
BEGIN_FUNC(arm64_elX_to_el1)
/* LAB 1 TODO 1 BEGIN */
/* BLANK BEGIN */
/* BLANK END */
/* LAB 1 TODO 1 END */
// Check the current exception level.
cmp x9, CURRENTEL_EL1
beq .Ltarget
cmp x9, CURRENTEL_EL2
beq .Lin_el2
// Otherwise, we are in EL3.
// Set EL2 to 64bit and enable the HVC instruction.
mrs x9, scr_el3
mov x10, SCR_EL3_NS | SCR_EL3_HCE | SCR_EL3_RW
orr x9, x9, x10
msr scr_el3, x9
// Set the return address and exception level.
/* LAB 1 TODO 2 BEGIN */
/* BLANK BEGIN */
/* BLANK END */
/* LAB 1 TODO 2 END */
.Lin_el2:
// Disable EL1 timer traps and the timer offset.
mrs x9, cnthctl_el2
orr x9, x9, CNTHCTL_EL2_EL1PCEN | CNTHCTL_EL2_EL1PCTEN
msr cnthctl_el2, x9
msr cntvoff_el2, xzr
// Disable stage 2 translations.
msr vttbr_el2, xzr
// Disable EL2 coprocessor traps.
mov x9, CPTR_EL2_RES1
msr cptr_el2, x9
// Disable EL1 FPU traps.
mov x9, CPACR_EL1_FPEN
msr cpacr_el1, x9
// Check whether the GIC system registers are supported.
mrs x9, id_aa64pfr0_el1
and x9, x9, ID_AA64PFR0_EL1_GIC
cbz x9, .Lno_gic_sr
// Enable the GIC system registers in EL2, and allow their use in EL1.
mrs x9, ICC_SRE_EL2
mov x10, ICC_SRE_EL2_ENABLE | ICC_SRE_EL2_SRE
orr x9, x9, x10
msr ICC_SRE_EL2, x9
// Disable the GIC virtual CPU interface.
msr ICH_HCR_EL2, xzr
.Lno_gic_sr:
// Set EL1 to 64bit.
mov x9, HCR_EL2_RW
msr hcr_el2, x9
// Set the return address and exception level.
adr x9, .Ltarget
msr elr_el2, x9
mov x9, SPSR_ELX_DAIF | SPSR_ELX_EL1H
msr spsr_el2, x9
isb
eret
.Ltarget:
ret
END_FUNC(arm64_elX_to_el1)
练习题 2
在 arm64_elX_to_el1
函数的 LAB 1 TODO 1
处填写一行汇编代码,获取 CPU 当前异常级别。
hint
通过 CurrentEL
系统寄存器可获得当前异常级别。通过 GDB 在指令级别单步调试可验证实现是否正确。注意参考文档理解 CurrentEL
各个 bits 的意义。
eret
指令可用于从高异常级别跳到更低的异常级别,在执行它之前我们需要设置
设置 elr_elx
(异常链接寄存器)和 spsr_elx
(保存的程序状态寄存器),分别控制eret
执行后的指令地址(PC)和程序状态(包括异常返回后的异常级别)。
练习题 3
在 arm64_elX_to_el1
函数的 LAB 1 TODO 2
处填写大约 4 行汇编代码,设置从 EL3 跳转到 EL1 所需的 elr_el3
和 spsr_el3
寄存器值。
hint
elr_el3
的正确设置应使得控制流在 eret
后从 arm64_elX_to_el1
返回到 _start
继续执行初始化。 spsr_el3
的正确设置应正确屏蔽 DAIF 四类中断,并且将 SP 正确设置为 EL1h
. 在设置好这两个系统寄存器后,不需要立即 eret
.
练习完成后,可使用 GDB 跟踪内核代码的执行过程,由于此时不会有任何输出,可通过是否正确从 arm64_elX_to_el1
函数返回到 _start
来判断代码的正确性。
跳转到第一行 C 代码
降低异常级别到 EL1 后,我们准备从汇编跳转到 C 代码,在此之前我们先设置栈(SP)。因此,_start
函数在执行 arm64_elX_to_el1
后,即设置内核启动阶段的栈,并跳转到第一个 C 函数 init_c
。
BEGIN_FUNC(_start)
mrs x8, mpidr_el1
and x8, x8, #0xFF
cbz x8, primary
/* Wait for bss clear */
wait_for_bss_clear:
adr x0, clear_bss_flag
ldr x1, [x0]
cmp x1, #0
bne wait_for_bss_clear
/* Set cntkctl_el1 to enable cntvct_el0.
* Enable it when you need to get current tick
* at EL0, e.g. Running aarch64 ROS2 demos
mov x10, 0b11
msr cntkctl_el1, x10 */
/* Turn to el1 from other exception levels. */
bl arm64_elX_to_el1
/* Prepare stack pointer and jump to C. */
mov x1, #INIT_STACK_SIZE
mul x1, x8, x1
adr x0, boot_cpu_stack
add x0, x0, x1
add x0, x0, #INIT_STACK_SIZE
mov sp, x0
wait_until_smp_enabled:
/* CPU ID should be stored in x8 from the first line */
mov x1, #8
mul x2, x8, x1
ldr x1, =secondary_boot_flag
add x1, x1, x2
ldr x3, [x1]
cbz x3, wait_until_smp_enabled
/* Set CPU id */
mov x0, x8
b secondary_init_c
/* Should never be here */
b .
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
add x0, x0, #INIT_STACK_SIZE
mov sp, x0
b init_c
/* Should never be here */
b .
END_FUNC(_start)
思考题 4
说明为什么要在进入 C 函数之前设置启动栈。如果不设置,会发生什么?
进入 init_c
函数后,第一件事首先通过 clear_bss
函数清零了 .bss
段,该段用于存储未初始化的全局变量和静态变量(具体请参考附录)。
思考题 5
在实验 1 中,其实不调用 clear_bss
也不影响内核的执行,请思考不清理 .bss
段在之后的何种情况下会导致内核无法工作。
初始化串口输出
到目前为止我们仍然只能通过 GDB 追踪内核的执行过程,而无法看到任何输出,这无疑是对我们写操作系统的积极性的一种打击。因此在 init_c
中,我们启用树莓派的 UART 串口,从而能够输出字符。
在 kernel/arch/aarch64/boot/raspi3/peripherals/uart.c
已经给出了 early_uart_init
和 early_uart_send
函数,分别用于初始化 UART 和发送单个字符(也就是输出字符)。
void uart_send_string(char *str)
{
/* LAB 1 TODO 3 BEGIN */
/* BLANK BEGIN */
/* BLANK END */
/* LAB 1 TODO 3 END */
}
练习题6
在 kernel/arch/aarch64/boot/raspi3/peripherals/uart.c
中 LAB 1 TODO 3
处实现通过 UART 输出字符串的逻辑。
第一个字符串
恭喜!我们终于在内核中输出了第一个字符串!
感兴趣的同学请思考early_uart_send
究竟是怎么输出字符的。
启用 MMU
在内核的启动阶段,还需要配置启动页表(init_kernel_pt
函数),并启用 MMU(el1_mmu_activate
函数),使可以通过虚拟地址访问内存,从而为之后跳转到高地址作准备(内核通常运行在虚拟地址空间 0xffffff0000000000
之后的高地址)。
关于配置启动页表的内容由于包含关于页表的细节,将在本实验下一部分实现,目前直接启用 MMU。
在 EL1 异常级别启用 MMU 是通过配置系统寄存器 sctlr_el1
实现的(Arm Architecture Reference Manual D13.2.118)。具体需要配置的字段主要包括:
- 是否启用 MMU(
M
字段) - 是否启用对齐检查(
A
SA0
SA
nAA
字段) - 是否启用指令和数据缓存(
C
I
字段)
练习题7
在 kernel/arch/aarch64/boot/raspi3/init/tools.S
中 LAB 1 TODO 4
处填写一行汇编代码,以启用 MMU。
由于没有配置启动页表,在启用 MMU 后,内核会立即发生地址翻译错误(Translation Fault),进而尝试跳转到异常处理函数(Exception Handler),
该异常处理函数的地址为异常向量表基地址(vbar_el1
寄存器)加上 0x200
。
此时我们没有设置异常向量表(vbar_el1
寄存器的值是0),因此执行流会来到 0x200
地址,此处的代码为非法指令,会再次触发异常并跳转到 0x200
地址。
使用 GDB 调试,在 GDB 中输入 continue
后,待内核输出停止后,按 Ctrl-C,可以观察到内核在 0x200
处无限循环。
important
以上为Lab1 Part1 的内容
页表映射
AArch64 地址翻译
在配置内核启动页表前,我们首先回顾实验涉及到的体系结构知识。这部分内容课堂上已经学习过,如果你已熟练掌握则可以直接跳过这里的介绍(但不要跳过思考题)。
在 AArch64 架构的 EL1 异常级别存在两个页表基址寄存器:ttbr0_el1
1 和 ttbr1_el1
2,分别用作虚拟地址空间低地址和高地址的翻译。那么什么地址范围称为“低地址”,什么地址范围称为“高地址”呢?这由 tcr_el1
翻译控制寄存器3控制,该寄存器提供了丰富的可配置性,可决定 64 位虚拟地址的高多少位为 0
时,使用 ttbr0_el1
指向的页表进行翻译,高多少位为 1
时,使用 ttbr1_el1
指向的页表进行翻译4。一般情况下,我们会将 tcr_el1
配置为高低地址各有 48 位的地址范围,即,0x0000_0000_0000_0000
~0x0000_ffff_ffff_ffff
为低地址,0xffff_0000_0000_0000
~0xffff_ffff_ffff_ffff
为高地址。
Arm Architecture Reference Manual, D13.2.144
Arm Architecture Reference Manual, D13.2.147
Arm Architecture Reference Manual, D13.2.131
Arm Architecture Reference Manual, D5.2 Figure D5-13
了解了如何决定使用 ttbr0_el1
还是 ttbr1_el1
指向的页表,再来看地址翻译过程如何进行。通常我们会将系统配置为使用 4KB 翻译粒度、4 级页表(L0 到 L3),同时在 L1 和 L2 页表中分别允许映射 2MB 和 1GB 大页(或称为块)5,因此地址翻译的过程如下图所示:
操作系统:原理与实现
其中,当映射为 1GB 块或 2MB 块时,图中 L2、L3 索引或 L3 索引的位置和低 12 位共同组成块内偏移。
每一级的每一个页表占用一个 4KB 物理页,称为页表页(Page Table Page),其中有 512 个条目,每个条目占 64 位。AArch64 中,页表条目称为描述符(descriptor)6,最低位(bit[0])为 1
时,描述符有效,否则无效。有效描述符有两种类型,一种指向下一级页表(称为表描述符),另一种指向物理块(大页)或物理页(称为块描述符或页描述符)。在上面所说的地址翻译配置下,描述符结构如下(“Output address”在这里即物理地址,一些地方称为物理页帧号(Page Frame Number,PFN)):
- L0、L1、L2 页表描述符
- L3 页表描述符
Arm Architecture Reference Manual, D5.3
思考题 8
请思考多级页表相比单级页表带来的优势和劣势(如果有的话),并计算在 AArch64 页表中分别以 4KB 粒度和 2MB 粒度映射 0~4GB 地址范围所需的物理内存大小(或页表页数量)。
页表描述符中除了包含下一级页表或物理页/块的地址,还包含对内存访问进行控制的属性(attribute)。这里涉及到太多细节,本文档限于篇幅只介绍最常用的几个页/块描述符中的属性字段:
字段 | 位 | 描述 |
---|---|---|
UXN | bit[54] | 置为 1 表示非特权态无法执行(Unprivileged eXecute-Never) |
PXN | bit[53] | 置为 1 表示特权态无法执行(Privileged eXecute-Never) |
nG | bit[11] | 置为 1 表示该描述符在 TLB 中的缓存只对当前 ASID 有效 |
AF | bit[10] | 置为 1 表示该页/块在上一次 AF 置 0 后被访问过 |
SH | bits[9:8] | 表示可共享属性7 |
AP | bits[7:6] | 表示读写等数据访问权限8 |
AttrIndx | bits[4:2] | 表示内存属性索引,间接指向 mair_el1 寄存器中配置的属性9,用于控制将物理页映射为正常内存(normal memory)或设备内存(device memory),以及控制 cache 策略等 |
Arm Architecture Reference Manual, D5.5
Arm Architecture Reference Manual, D5.4
Arm Architecture Reference Manual, D13.2.97
配置内核启动页表
有了关于页表配置的前置知识,我们终于可以开始配置内核的启动页表了。
操作系统内核通常运行在虚拟内存的高地址(如前所述,0xffff_0000_0000_0000
之后的虚拟地址)。通过对内核页表的配置,将虚拟内存高地址映射到内核实际所在的物理内存,在执行内核代码时,PC 寄存器的值是高地址,对全局变量、栈等的访问都使用高地址。在内核运行时,除了需要访问内核代码和数据等,往往还需要能够对任意物理内存和外设内存(MMIO)进行读写,这种读写同样通过高地址进行。
因此,在内核启动时,首先需要对内核自身、其余可用物理内存和外设内存进行虚拟地址映射,最简单的映射方式是一对一的映射,即将虚拟地址 0xffff_0000_0000_0000 + addr
映射到 addr
。需要注意的是,在 ChCore 实验中我们使用了 0xffff_ff00_0000_0000
作为内核虚拟地址的开始(注意开头 f
数量的区别),不过这不影响我们对知识点的理解。
在树莓派 3B+ 机器上,物理地址空间分布如下10:
物理地址范围 | 对应设备 |
---|---|
0x00000000 ~0x3f000000 | 物理内存(SDRAM) |
0x3f000000 ~0x40000000 | 共享外设内存 |
0x40000000 ~0xffffffff | 本地(每个 CPU 核独立)外设内存 |
现在将目光转移到 kernel/arch/aarch64/boot/raspi3/init/mmu.c
文件,我们需要在 init_kernel_pt
为内核配置从 0x00000000
到 0x80000000
(0x40000000
后的 1G,ChCore 只需使用这部分地址中的本地外设)的映射,其中 0x00000000
到 0x3f000000
映射为 normal memory,0x3f000000
到 0x80000000
映射为 device memory,其中 0x00000000
到 0x40000000
以 2MB 块粒度映射,0x40000000
到 0x80000000
以 1GB 块粒度映射。
思考题 9
请结合上述地址翻译规则,计算在练习题 10 中,你需要映射几个 L2 页表条目,几个 L1 页表条目,几个 L0 页表条目。页表页需要占用多少物理内存?
练习题 10
在 init_kernel_pt
函数的 LAB 1 TODO 5
处配置内核高地址页表(boot_ttbr1_l0
、boot_ttbr1_l1
和 boot_ttbr1_l2
),以 2MB 粒度映射。
hint
你只需要将 addr
(0x00000000
到 0x80000000
) 按照要求的页粒度一一映射到 KERNEL_VADDR + addr
(vaddr
) 上。vaddr
对应的物理地址是 vaddr - KERNEL_VADDR
. Attributes 的设置请参考给出的低地址页表配置。
思考题11
请思考在 init_kernel_pt
函数中为什么还要为低地址配置页表,并尝试验证自己的解释。
完成 init_kernel_pt
函数后,ChCore 内核便可以在 el1_mmu_activate
中将 boot_ttbr1_l0
等物理地址写入实际寄存器(如 ttbr1_el1
),随后启用 MMU 后继续执行,并通过 start_kernel
跳转到高地址,进而跳转到内核的 main
函数(位于 kernel/arch/aarch64/main.c
, 尚未发布,以 binary 提供)。
思考题12
在一开始我们暂停了三个其他核心的执行,根据现有代码简要说明它们什么时候会恢复执行。思考为什么一开始只让 0 号核心执行初始化流程?
hint
secondary_boot_flag
将在 main 函数执行完时钟,调度器,锁的初始化后被设置。
success
以上为Lab1 Part2 的内容 如果顺利的话 运行make grade你会得到100/100
Lab 2:内存管理
本实验主要目的在于让同学们熟悉内核启动过程中对内存的初始化和内核启动后对物理内存和页表的管理,包括三个部分。
- 物理内存管理: 理解并完成伙伴系统以及SLAB系统
- 虚拟页表管理: 深入理解页表分配机制以及页表项权限机制,并完成页表分配函数。
- 缺页异常处理: 理解aarch64架构下的异常处理机制,并按照页表项的配置完成按需分配以及写时拷贝的缺页管理设置。
warning
本Lab不包括代码导读
跟先前的Lab相同,本实验代码包含了基础的 ChCore 操作系统镜像,除了练习题相关部分的源码以外(指明需要阅读的代码),其余部分通过二进制格式提供。 在正确完成本实验的练习题之后,你可以在树莓派3B+QEMU或开发板上进入 ChCore shell。
物理内存管理
伙伴系统
内核初始化过程中,需要对内存管理模块进行初始化(mm_init
函数),首先需要把物理内存管起来,从而使内核代码可以动态地分配内存。
ChCore 使用伙伴系统(buddy system)1对物理页进行管理,在 mm_init
中对伙伴系统进行了初始化。为了使物理内存的管理可扩展,ChCore 在 mm_init
的开头首先调用平台特定的 parse_mem_map
函数,该函数解析并返回了可用物理内存区域,然后再对各可用物理内存区域初始化伙伴系统。
伙伴系统中的每个内存块都有一个阶(order)表示大小,阶是从 0 到指定上限 BUDDY_MAX_ORDER
的整数。一个 \( n \) 阶的块的大小为 \( 2^n \times PAGE\_SIZE \),因此这些内存块的大小正好是比它小一个阶的内存块的大小的两倍。内存块的大小是 2 次幂对齐,使地址计算变得简单。当一个较大的内存块被分割时,它被分成两个较小的内存块,这两个小内存块相互成为唯一的伙伴。一个分割的内存块也只能与它唯一的伙伴块进行合并(合并成他们分割前的块)。
ChCore 中每个由伙伴系统管理的内存区域称为一个 struct phys_mem_pool
,该结构体中包含物理页元信息的起始地址(page_metadata
)、伙伴系统各阶内存块的空闲链表(free_lists
)等。
练习题1
完成 kernel/mm/buddy.c
中的 split_chunk
、merge_chunk
、buddy_get_pages
、 和 buddy_free_pages
函数中的 LAB 2 TODO 1
部分,其中 buddy_get_pages
用于分配指定阶大小的连续物理页,buddy_free_pages
用于释放已分配的连续物理页。
hint
- 可以使用
kernel/include/common/list.h
中提供的链表相关函数和宏如init_list_head
、list_add
、list_del
、list_entry
来对伙伴系统中的空闲链表进行操作 - 可使用
get_buddy_chunk
函数获得某个物理内存块的伙伴块 - 更多提示见代码注释
SLAB分配器
我们希望通过基于伙伴系统的物理内存管理,在内核中进行动态内存分配,也就是可以使用 kmalloc
函数(对应用户态的 malloc
)。ChCore 的 kmalloc
对于较小的内存分配需求采用 SLAB 分配器2,对于较大的分配需求则直接从伙伴系统中分配物理页。动态分配出的物理页被转换为内核虚拟地址(Kernel Virtual Address,KVA),也就是在 LAB 1 中我们映射的 0xffff_ff00_0000_0000
之后的地址。我们在练习题 1 中已经实现了伙伴系统,接下来让我们实现 SLAB 分配器吧。
练习题2
完成 kernel/mm/slab.c
中的 choose_new_current_slab
、alloc_in_slab_impl
和 free_in_slab
函数中的 LAB 2 TODO 2
部分,其中 alloc_in_slab_impl
用于在 slab 分配器中分配指定阶大小的内存,而 free_in_slab
则用于释放上述已分配的内存。
hint
- 你仍然可以使用上个练习中提到的链表相关函数和宏来对 SLAB 分配器中的链表进行操作
- 更多提示见代码注释
Kmalloc
有了伙伴系统和 SLAB 分配器,就可以实现 kmalloc
了。
练习题 3
完成 kernel/mm/kmalloc.c
中的 _kmalloc
函数中的 LAB 2 TODO 3
部分,在适当位置调用对应的函数,实现 kmalloc
功能
hint
- 你可以使用
get_pages
函数从伙伴系统中分配内存,使用alloc_in_slab
从 SLAB 分配器中分配内存 - 更多提示见代码注释
kmalloc
现在内核中已经能够正常使用 kmalloc
和 kfree
了
success
以上为Lab2 Part1的所有内容。
正确完成这一部分的练习题后,运行 make grade
,你应当能够得到 30 分。注意,测试可能会遗漏你代码中的一些问题。因此即使通过这部分测试,代码中的隐藏问题也可能会对后续实验产生影响导致无法通过最终的测试。不过,我们会按照 make grade
的结果为你计分。^_^
操作系统:原理与实现,5.1.3 伙伴系统原理
操作系统:原理与实现,5.1.5 SLAB 分配器的基本设计
页表管理
在LAB 1 中我们已经详细介绍了 AArch64 的地址翻译过程,并介绍了各级页表和不同类型的页表描述符,最后在内核启动阶段配置了一个粗粒度的启动页表。现在,我们需要为用户态应用程序准备一个更细粒度的页表实现,提供映射、取消映射、查询等功能。
练习题4
完成 kernel/arch/aarch64/mm/page_table.c
中的 query_in_pgtbl
、map_range_in_pgtbl_common
、unmap_range_in_pgtbl
和 mprotect_in_pgtbl
函数中的 LAB 2 TODO 4
部分,分别实现页表查询、映射、取消映射和修改页表权限的操作,以 4KB 页为粒度。
hint
- 需要实现的函数内部无需刷新 TLB,TLB 刷新会在这些函数的外部进行
- 实现中可以使用
get_next_ptp
、set_pte_flags
、virt_to_phys
、GET_LX_INDEX
等已经给定的函数和宏 - 更多提示见代码注释
页表配错了怎么办?
在Aarch64的架构中,每当系统进入异常处理流程,寄存器ELR_EL1
将保存错误发生的指令地址,而对于出错的虚拟内存地址,你可以通过查询FAR_EL1
找到。
思考题5
阅读 Arm Architecture Reference Manual,思考要在操作系统中支持写时拷贝(Copy-on-Write,CoW)1需要配置页表描述符的哪个/哪些字段,并在发生页错误时如何处理。(在完成第三部分后,你也可以阅读页错误处理的相关代码,观察 ChCore 是如何支持 Cow 的)
思考题6
为了简单起见,在 ChCore 实验 Lab1 中没有为内核页表使用细粒度的映射,而是直接沿用了启动时的粗粒度页表,请思考这样做有什么问题。
挑战题7
使用前面实现的 page_table.c
中的函数,在内核启动后的 main
函数中重新配置内核页表,进行细粒度的映射。
success
以上为Lab2 Part2的所有内容
正确完成该练习题后,运行 make grade
,你应当能够得到 70 分。同样的,正确实现功能是通过测试的充分非必要条件。
操作系统:原理与实现,12.4 原子更新技术:写时拷贝
缺页异常处理
缺页异常(page fault)是操作系统实现延迟内存分配的重要技术手段。当处理器发生缺页异常时,它会将发生错误的虚拟地址存储于 FAR_ELx
寄存器中,并触发相应的异常处理流程。ChCore 对该异常的处理最终实现在 kernel/arch/aarch64/irq/pgfault.c
中的 do_page_fault
函数。本次实验暂时不涉及前面的异常初步处理及转发相关内容,我们仅需要关注操作系统是如何处缺页异常的。
练习题8
完成 kernel/arch/aarch64/irq/pgfault.c
中的 do_page_fault
函数中的 LAB 2 TODO 5
部分,将缺页异常转发给 handle_trans_fault
函数。
在 ChCore 中,一个进程的虚拟地址空间由多段“虚拟地址区域”(VMR,又称 VMA)组成,一段 VMR 记录了这段虚拟地址对应的“物理内存对象”(PMO),而 PMO 中则记录了物理地址相关信息。因此,想要处理缺页异常,首先需要找到当前进程发生页错误的虚拟地址所处的 VMR,进而才能得知其对应的物理地址,从而在页表中完成映射。
练习题9
完成 kernel/mm/vmspace.c
中的 find_vmr_for_va
函数中的 LAB 2 TODO 6
部分,找到一个虚拟地址找在其虚拟地址空间中的 VMR。
hint
- 一个虚拟地址空间所包含的 VMR 通过 rb_tree 的数据结构保存在
vmspace
结构体的vmr_tree
字段 - 可以使用
kernel/include/common/rbtree.h
中定义的rb_search
、rb_entry
等函数或宏来对 rb_tree 进行搜索或操作
缺页处理主要针对 PMO_SHM
和 PMO_ANONYM
类型的 PMO,这两种 PMO 的物理页是在访问时按需分配的。缺页处理逻辑为首先尝试检查 PMO 中当前 fault 地址对应的物理页是否存在(通过 get_page_from_pmo
函数尝试获取 PMO 中 offset 对应的物理页)。若对应物理页未分配,则需要分配一个新的物理页,再将页记录到 PMO 中,并增加页表映射。若对应物理页已分配,则只需要修改页表映射即可。
练习题10
完成 kernel/mm/pgfault_handler.c
中的 handle_trans_fault
函数中的 LAB 2 TODO 7
部分(函数内共有 3 处填空,不要遗漏),实现 PMO_SHM
和 PMO_ANONYM
的按需物理页分配。你可以阅读代码注释,调用你之前见到过的相关函数来实现功能。
挑战题 11
我们在map_range_in_pgtbl_common
、unmap_range_in_pgtbl
函数中预留了没有被使用过的参数rss
用来来统计map映射中实际的物理内存使用量1,
你需要修改相关的代码来通过Compute physical memory
测试,不实现该挑战题并不影响其他部分功能的实现及测试。如果你想检测是否通过此部分测试,需要修改kernel/config.cmake
中CHCORE_KERNEL_PM_USAGE_TEST
为ON
challenge
为了防止你通过尝试在打印scores.json里的内容来逃避检查,我们在每一次评分前都会修改elf序列号段的信息并让其chcore在评分点进行打出,当且仅当评分程序捕捉到序列号输出之后,我们才会对检查进行打分,如果没有通过序列号验证,你的评分将为0分。
success
以上为Lab2 Part3。
正确完成上述练习题后,运行 make qemu
后 ChCore 应当能正常进入 Shell;运行 make grade
,你应当能够得到 100 分。如果你无法通过测试,请考虑到也有可能是你前面两个部分的实现存在漏洞。
Lab 3:进程与线程
用户进程是操作系统对在用户模式运行中的程序的抽象。在Lab 1 和Lab 2 中,已经完成了内核的启动和物理内存的管理,以及一个可供用户进程使用的页表实现。现在,我们将一步一步支持用户态程序的运行。 本实验包括五个部分:
- RTFSC(2): 代码导读,了解Chcore微内核的核心机制以及用户态和内核态是如何进行交互的。
- 线程管理: 支持创建第一个用户态进程和线程,分析代码如何从内核态切换到用户态。
- 异常处理: 完善异常处理流程,为系统添加必要的异常处理的支持。
- 系统调用:正确处理部分系统调用,保证用户程序的正常输出。
- 用户态程序编写:编写一个简单用户程序,使用提供的 ChCore libc 进行编译,并加载至内核镜像中。
工具链准备
从Lab3开始我们开放了用户态的一些代码,你需要使用如下的命令下载libc.
git submodule update --init --recursive
关于超时
如果你发现时不时因为超时原因而无法正常通过测试,你可以尝试修改Lab文件夹的Makefile的TIMEOUT变量设置超时时间。
RTFSC (3)
note
此为代码导读的第三部分,请仔细阅读。同之前的章节相同,本节不包含习题。
本次代码导读主要聚焦从main函数开始自上而下讲解Lab2 Lab3内核态的资源管理机制以及用户态和内核态的互相调用
hint
你可能需要重新结合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 */
以下为Chcore内核初始化到运行第一个用户线程的主要流程图
flowchart TD lock["lock_init() 锁初始化"] uart["uart_init() uart初始化"] cpu["init_per_cpu_info() cpu结构体初始化"] mm["mm_init() 内存管理初始化"] sched["sched_init() 调度初始化"] fpu["init_fpu_owner_locks() fpu初始化"] root_thread["create_root_thread() 创建原始线程"] eret["eret_to_thread()"] pmo["create_pmo() pmo创建"] vmspace["vmspace_map_range() vm映射"] cap_group["create_root_cap_group()"] thread_alloc["thread_alloc"] memory_mapping["memory_mapping"] subgraph main lock-->uart-->cpu-->mm-->sched-->fpu-->root_thread-.->eret end subgraph thread_init root_thread-->pmo-->vmspace-->cap_group-->thread_alloc-->memory_mapping-->eret end
我们在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代码完成销毁工作。
note
你可以根据上述的描述来梳理根进程创建以及普通进程创建的异同,最后梳理出创建进程的标准模式。
用户态构建
我们在Lab1
的代码导读阶段说明了kernel
目录下的代码是如何被链接成内核镜像的,我们在内核镜像链接中引入了procmgr
这个预先构建的二进制文件。在Lab3
中,我们引入了用户态的代码构建,所以我们将procmgr
的依赖改为使用用户态的代码生成。下图为具体的构建规则图。
flowchart LR topcmake["CMakeLists.txt"] chcorelibc["chcore-libc"] libcso["libc.so"] procmgr["procmgr"] ramdisk["ramdisk"] ramdisk_cpio["ramdisk.cpio"] tmpfs["ramdisk/tmpfs.srv"] procmgr_tool["procmgr_tool"] kernel["kernel"] kernel_img["kernel.img"] subgraph libc chcorelibc-->|autotools|libcso end subgraph system_services ramdisk-->|cpio|ramdisk_cpio ramdisk_cpio-->tmpfs tmpfs-->procmgr libcso-->procmgr procmgr-->procmgr_tool procmgr_tool-->procmgr end topcmake-->system_services topcmake-->libc procmgr-->kernel_img kernel-->kernel_img
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镜像的拓扑结构如下
flowchart LR kernel_img("kernel.img") kernel_objects("kernel/*.o") procmgr("procmgr") chcore_libc("libc.so") ramdisk("ramdisk") ramdisk_cpio("ramdisk.cpio") tmpfs("tmpfs.srv") fsm("fsm.srv") kernel_img-->kernel_objects kernel_img-->procmgr procmgr-->fsm procmgr-->tmpfs tmpfs-->ramdisk_cpio ramdisk_cpio-->ramdisk ramdisk_cpio-->chcore_libc
线程生命周期管理
本实验的 OS 运行在 AArch64 体系结构,该体系结构采用“异常级别”这一概念定义程序执行时所拥有的特权级别。从低到高分别是 EL0、EL1、EL2 和 EL3。每个异常级别的具体含义和常见用法已在课程中讲述。
ChCore 中仅使用了其中的两个异常级别:EL0 和 EL1。其中,EL1 是内核模式,kernel
目录下的内核代码运行于此异常级别。EL0 是用户模式,user
目录下的用户库与用户程序代码运行在用户模式下。我们在之前的RTFSC中提到了,在Chcore中内核对用户态提供的所有的资源,如Lab2的内存对象,都围绕着cap_group以及capability展开。同目前所有的主流操作系统一样,ChCore 中的每个进程至少包含一个主线程,也可能有多个子线程,而每个线程则从属且仅从属于一个进程。在 ChCore 中,第一个被创建的进程是 procmgr
,是 ChCore 核心的系统服务。本实验将以创建 procmgr
为例探索在 ChCore 中如何创建进程,以及成功创建第一个进程后如何实现内核态向用户态的切换。
在 ChCore 中,第一个被创建的进程是 procmgr
,是 ChCore 核心的系统服务。本实验将以创建 procmgr
为例探索在 ChCore 中如何创建进程,以及成功创建第一个进程后如何实现内核态向用户态的切换。
权利组创建
创建用户程序至少需要包括创建对应的 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 测试点。
capgroup
完成create_root_cap_group
函数后并通过测试后,你可以得到20分。
hint
可以阅读 kernel/object/capability.c
中各个与 cap 机制相关的函数以及参考文档。
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 加载到刚刚创建的进程地址空间中。
hint
- 程序头可以参考
kernel/object/thread_env.h
。 - 内存分配操作使用
create_pmo
,可详细阅读kernel/object/memory.c
了解内存分配。 - 本练习并无测试点,请确保对 elf 文件内容读取及内存分配正确。否则有可能在后续切换至用户态程序运行时出错。
进程调度
完成用户程序的内存分配后,用户程序代码实际上已经被映射在root_cap_group
的虚拟地址空间中。接下来需要对创建的线程进行初始化,以做好从内核态切换到用户态线程的准备。
练习题3
在 kernel/arch/aarch64/sched/context.c
中完成 init_thread_ctx
函数,完成线程上下文的初始化。
至此,我们完成了第一个用户进程与第一个用户线程的创建。接下来就可以从内核态向用户态进行跳转了。
回到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 寄存器的方式查看是否正确完成内核态向用户态的切换。
思考题4
思考内核从完成必要的初始化到第一次切换到用户态程序的过程是怎么样的?尝试描述一下调用关系。
无法继续执行
然而,目前任何一个用户程序并不能正常退出,也不能正常输出结果。这是由于程序中包括了 svc #0
指令进行系统调用。由于此时 ChCore 尚未配置从用户模式(EL0)切换到内核模式(EL1)的相关内容,在尝试执行 svc
指令时,ChCore 将根据目前的配置(尚未初始化,异常处理向量指向随机位置)执行位于随机位置的异常处理代码,进而导致触发错误指令异常。同样的,由于错误指令异常仍未指定处理代码的位置,对该异常进行处理会再次出发错误指令异常。ChCore 将不断重复此循环,并最终表现为 QEMU 不响应。后续的练习中将会通过正确配置异常向量表的方式,对这一问题进行修复。
success
以上为Lab3 Part1 的所有内容,完成后你将获得40分
异常管理
由于 ChCore 尚未对用户模式与内核模式的切换进行配置,一旦 ChCore 进入用户模式执行就再也无法正常返回内核模式使用操作系统提供其他功能了。在这一部分中,我们将通过正确配置异常向量表的方式,为 ChCore 添加异常处理的能力。
在 AArch64 架构中,异常是指低特权级软件(如用户程序)请求高特权软件(例如内核中的异常处理程序)采取某些措施以确保程序平稳运行的系统事件,包含同步异常和异步异常:
- 同步异常:通过直接执行指令产生的异常。同步异常的来源包括同步中止(synchronous abort)和一些特殊指令。当直接执行一条指令时,若取指令或数据访问过程失败,则会产生同步中止。此外,部分指令(包括
svc
等)通常被用户程序用于主动制造异常以请求高特权级别软件提供服务(如系统调用)。 - 异步异常:与正在执行的指令无关的异常。异步异常的来源包括普通中 IRQ、快速中断 FIQ 和系统错误 SError。IRQ 和 FIQ 是由其他与处理器连接的硬件产生的中断,系统错误则包含多种可能的原因。本实验不涉及此部分。
发生异常后,处理器需要找到与发生的异常相对应的异常处理程序代码并执行。在 AArch64 中,存储于内存之中的异常处理程序代码被叫做异常向量(exception vector),而所有的异常向量被存储在一张异常向量表(exception vector table)中。可参考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
中的异常向量表,并且增加对应的函数跳转操作。
success
以上为Lab2 Part2的全部内容,完成后你可以获得60分
系统调用
内核支持
系统调用是系统为用户程序提供的高特权操作接口。在本实验中,用户程序通过 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
地址。
练习题6
填写 kernel/arch/aarch64/irq/irq_entry.S
中的 exception_enter
与 exception_exit
,实现上下文保存的功能,以及 switch_to_cpu_stack
内核栈切换函数。如果正确完成这一部分,可以通过 Userland 测试点。这代表着程序已经可以在用户态与内核态间进行正确切换。显示如下结果
Hello userland!
用户态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
。
思考题7
尝试描述 printf
如何调用到 chcore_stdout_write
函数。
hint
chcore_write
中使用了文件描述符,stdout
描述符的设置在user/chcore-libc/musl-libc/src/chcore-port/syscall_dispatcher.c
中。
chcore_stdout_write
中的核心函数为 put
,此函数的作用是向终端输出一个字符串。
练习题8:
在其中添加一行以完成系统调用,目标调用函数为内核中的 sys_putstr
。使用 chcore_syscallx
函数进行系统调用。
至此,我们完成了对 printf
函数的分析及完善。从 printf
的例子我们也可以看到从通用 api 向系统相关 abi 的调用过程,并最终通过系统调用完成从用户态向内核态的异常切换。
success
以上为Lab3 Part3的所有内容,完成后你可以获得80分
用户程序编写
我们完成了内核态向用户态的切换,以及用户态向内核态的异常切换。同时,我们拥有了一个完整的 libc
,可以帮助我们进行系统调用。接下来,我们将尝试使用 ChCore 的 libc
及编译器进行简单的程序编译,并将其加载到内核镜像中运行。
练习题9
尝试编写一个简单的用户程序,其作用至少包括打印以下字符(测试将以此为得分点)。
Hello ChCore!
使用 chcore-libc 的编译器进行对其进行编译,编译输出文件名命名为 hello_world.bin
,并将其放入 ramdisk 加载进内核运行。内核启动时将自动运行 文件名为 hello_world.bin
的可执行文件。
hint
- ChCore 的编译工具链在
build/chcore-libc/bin
文件夹中。 - 如使用 cmake 进行编译,可以将工具链文件指定为
build/toolchain.cmake
,将默认使用 ChCore 编译工具链。
到这里,你的程序应该可以通过所有的测试点并且获得满分。你可以编写一些更复杂的程序并尝试放入 ChCore 中运行。
Lab 4:多核调度与IPC
在本实验中,我们将逐步实现ChCore的多核支持以及微内核系统的核心:进程间通信,本Lab包含四个部分:]
- 多核启动支持: 使ChCore通过树莓派厂商所提供的固件唤醒多核执行
- 多核调度: 使ChCore实现在多核上进行round-robin调度。
- IPC: 使ChCore支持进程间通信
- IPC调优: 为ChCore的IPC针对测试的特点进行调优。
跟先前的Lab相同,本实验代码包含了基础的 ChCore 操作系统镜像,除了练习题相关部分的源码以外(指明需要阅读的代码),其余部分通过二进制格式提供。
在正确完成本实验的练习题之后,你可以在树莓派3B+QEMU或开发板上进入 ChCore shell。
注释/* LAB 4 TODO BEGIN (exercise #) */
和/* LAB 4 TODO END (exercise #) */
之间代表需要填空的代码部分。
多核支持
note
本部分实验没有代码题,仅有思考题。
为了让ChCore支持多核,我们需要考虑如下问题:
- 如何启动多核,让每个核心执行初始化代码并开始执行用户代码?
- 如何区分不同核心在内核中保存的数据结构(比如状态,配置,内核对象等)?
- 如何保证内核中对象并发正确性,确保不会由于多个核心同时访问内核对象导致竞争条件?
在启动多核之前,我们先介绍ChCore如何解决第二个问题。ChCore对于内核中需要每个CPU核心单独存一份的内核对象,都根据核心数量创建了多份(即利用一个数组来保存)。ChCore支持的核心数量为PLAT_CPU_NUM(该宏定义在 kernel/common/machine.h
中,其代表可用CPU核心的数量,根据具体平台而异)。 比如,实验使用的树莓派3平台拥有4个核心,因此该宏定义的值为4。ChCore会CPU核心的核心ID作为数组的索引,在数组中取出对应的CPU核心本地的数据。为了方便确定当前执行该代码的CPU核心ID,我们在 kernel/arch/aarch64/machine/smp.c
中提供了smp_get_cpu_id函数。该函数通过访问系统寄存器tpidr_el1来获取调用它的CPU核心的ID,该ID可用作访问上述数组的索引。
#include <common/vars.h>
/* raspi3 config */
#define PLAT_CPU_NUM 4
#define PLAT_RASPI3
启动多核
在实验1中我们已经介绍,在QEMU模拟的树莓派中,所有CPU核心在开机时会被同时启动。在引导时这些核心会被分为两种类型。一个指定的CPU核心会引导整个操作系统和初始化自身,被称为CPU主核(primary CPU)。其他的CPU核心只初始化自身即可,被称为CPU从核(backup CPU)。CPU核心仅在系统引导时有所区分,在其他阶段,每个CPU核心都是被相同对待的。
思考题 1
阅读Lab1
中的汇编代码kernel/arch/aarch64/boot/raspi3/init/start.S。说明ChCore是如何选定主CPU,并阻塞其他其他CPU的执行的。
然而在树莓派真机中,从还需要主C核手动指定每一个CPU核心的的启动地址。这些CPU核心会读取固定地址的上填写的启动地址,并跳转到该地址启动。在kernel/arch/aarch64/boot/raspi3/init/init_c.c中,我们提供了wakeup_other_cores
函数用于实现该功能,并让所有的CPU核心同在QEMU一样开始执行_start函数。
与之前的实验一样,主CPU在第一次返回用户态之前会在kernel/arch/aarch64/main.c
中执行main函数,进行操作系统的初始化任务。在本小节中,ChCore将执行enable_smp_cores函数激活各个其他CPU。
思考题 2
阅读汇编代码kernel/arch/aarch64/boot/raspi3/init/start.S
, init_c.c以及kernel/arch/aarch64/main.c,解释用于阻塞其他CPU核心的secondary_boot_flag是物理地址还是虚拟地址?是如何传入函数enable_smp_cores
中,又是如何赋值的(考虑虚拟地址/物理地址)?
success
以上为Lab4 part1 的所有内容
多核调度
ChCore已经可以启动多核,但仍然无法对多个线程进行调度。本部分将首先实现协作式调度,从而允许当前在CPU核心上运行的线程主动退出或主动放弃CPU时,CPU核心能够切换到另一个线程继续执行。其后,我们将驱动树莓派上的物理定时器,使其以一定的频率发起中断,使得内核可以在一定时间片后重新获得对CPU核心的控制,并基于此进一步实现抢占式调度。
ChCore中与调度相关的函数与数据结构定义在kernel/include/sched/sched.h
中。
struct sched_ops {
int (*sched_init)(void);
int (*sched)(void);
int (*sched_periodic)(void);
int (*sched_enqueue)(struct thread *thread);
int (*sched_dequeue)(struct thread *thread);
/* Debug tools */
void (*sched_top)(void);
};
sched_ops是用于抽象ChCore中调度器的一系列操作。它存储指向不同调度操作的函数指针,以支持不同的调度策略。
cur_sched_ops则是一个sched_ops的实例,其在内核初始化过程中(main函数)调用sched_init进行初始化。
ChCore用在 kernel/include/sched/sched.h
中定义的静态函数封装对cur_sched_ops的调用。sched_ops中定义的调度器操作如下所示:
- sched_init:初始化调度器。
- sched:进行一次调度。即将正在运行的线程放回就绪队列,然后在就绪队列中选择下一个需要执行的线程返回。
- sched_enqueue:将新线程添加到调度器的就绪队列中。
- sched_dequeue:从调度器的就绪队列中取出一个线程。
- sched_top:用于debug,打印当前所有核心上的运行线程以及等待线程的函数。
在本部分将实现一个基本的Round Robin(时间片轮转)调度器,该程序调度在同一CPU核心上运行的线程,因此内核初始化过程调用sched_init时传入了&rr作为参数。该调度器的调度操作(即对于sched_ops定义的各个函数接口的实现)实现在kernel/sched/policy_rr.c
中,这里简要介绍其涉及的数据结构:
current_threads
是一个数组,分别指向每个CPU核心上运行的线程。而current_thread
则利用smp_get_cpu_id
获取当前运行核心的id,从而找到当前核心上运行的线程。
struct queue_meta
定义了round robin调度器使用的就绪队列,其中queue_head
字段是连接该就绪队列上所有等待线程的队列,queue_len
字段是目前该就绪队列的长度,queue_lock
字段是用于保证该队列并发安全的锁。 kernel/sched/policy_rr.c
定义了一个全局变量rr_ready_queue_meta
,该变量是一个struct queue_meta
类型的数组,数组大小由PLAT_CPU_NUM
定义,即代表每个CPU核心都具有一个就绪队列。运行的CPU核心可以通过smp_get_cpu_id
获取当前运行核心的id,从而在该数组中找到当前核心对应的就绪队列。
调度队列初始化
内核初始化过程中会调用sched_init
初始化调度相关的元数据,sched_init
定义在kernel/sched/sched.c
中,该函数首先初始化idle_thread(每个CPU核心拥有一个idle_thread,当调度器的就绪队列中没有等待线程时会切换到idle_thread运行),然后会初始化current_threads
数组,最后调用struct sched_ops rr
中定义的sched_init函数,即rr_sched_init
。
练习题 1
在 kernel/sched/policy_rr.c
中完善 rr_sched_init
函数,对 rr_ready_queue_meta
进行初始化。在完成填写之后,你可以看到输出“Scheduler metadata is successfully initialized!”并通过 Scheduler metadata initialization 测试点。
tip
sched_init 只会在主 CPU 初始化时调用,因此 rr_sched_init 需要对每个 CPU 核心的就绪队列都进行初始化。
调度队列入队
内核初始化过程结束之后会调用create_root_thread
来创建第一个用户态进程及线程,在create_root_thread
最后会调用sched_enqueue
函数将创建的线程加入调度队列之中。sched_enqueue
最终会调用kernel/sched/policy_rr.c中定义的rr_sched_enqueue
函数。该函数首先挑选合适的CPU核心的就绪队列(考虑线程是否绑核以及各个CPU核心之间的负载均衡),然后调用__rr_sched_enqueue
将线程插入到选中的就绪队列中。
练习 2
在 kernel/sched/policy_rr.c 中完善 __rr_sched_enqueue
函数,将thread
插入到cpuid
对应的就绪队列中。
success
在完成填写之后,你可以看到输出“Successfully enqueue root thread”并通过 Schedule Enqueue 测试点。
调度队列出队
内核初始化过程结束并调用create_root_thread
创建好第一个用户态进程及线程之后,在第一次进入用户态之前,会调用sched
函数来挑选要返回到用户态运行的线程(虽然此时就绪队列中只有root thread一个线程)。sched
最终会调用kernel/sched/policy_rr.c中定义的rr_sched
函数。
该调度函数的操作非常直观,就是将现在正在运行的线程重新加入调度器的就绪队列当中,并从就绪队列中挑选出一个新的线程运行。
由于内核刚刚完成初始化,我们还没有设置过current_thread
,所以rr_sched
函数中old
为NULL
,后面的练习中我们会考虑old
不为NULL
的情况。紧接着rr_sched
会调用rr_sched_choose_thread
函数挑选出下一个运行的线程,并切换到该线程。
rr_sched_choose_thread
内部会调用find_runnable_thread
从当前CPU核心的就绪队列中选取一个可以运行的线程并调用__rr_sched_dequeue
将其从就绪队列中移除。
练习 3
在 kernel/sched/sched.c 中完善 find_runnable_thread
函数,在就绪队列中找到第一个满足运行条件的线程并返回。 在 kernel/sched/policy_rr.c
中完善 __rr_sched_dequeue
函数,将被选中的线程从就绪队列中移除。
success
在完成填写之后,运行 ChCore 将可以成功进入用户态,你可以看到输出“Enter Procmgr Root thread (userspace)”并通过 Schedule Enqueue 测试点。
协作式调度
顾名思义,协作式调度需要线程主动放弃CPU。为了实现该功能,我们提供了sys_yield
这一个系统调用(syscall)。该syscall可以主动放弃当前CPU核心,并调用上述的sched
接口完成调度器的调度工作。kernel/sched/policy_rr.c
中定义的rr_sched
函数中,如果当前运行线程的状态为TS_RUNNING
,即还处于可以运行的状态,我们应该将其重新加入到就绪队列当中,这样该线程在之后才可以被再次调度执行。
练习 4
在kernel/sched/sched.c
中完善系统调用sys_yield
,使用户态程序可以主动让出CPU核心触发线程调度。
此外,请在kernel/sched/policy_rr.c
中完善rr_sched
函数,将当前运行的线程重新加入调度队列中。
success
在完成填写之后,运行 ChCore 将可以成功进入用户态并创建两个线程交替执行,你可以看到输出“Cooperative Schedluing Test Done!”并通过 Cooperative Schedluing 测试点。
抢占式调度
使用刚刚实现的协作式调度器,ChCore能够在线程主动调用sys_yield
系统调用让出CPU核心的情况下调度线程。然而,若用户线程不想放弃对CPU核心的占据,内核便只能让用户线程继续执行,而无法强制用户线程中止。 因此,在这一部分中,本实验将实现抢占式调度,以帮助内核定期重新获得对CPU核心的控制权。
ChCore启动的第一个用户态线程(执行user/system-services/system-servers/procmgr/procmgr.c
的main
函数)将创建一个“自旋线程”,该线程在获得CPU核心的控制权后便会执行无限循环,进而导致无论是该程序的主线程还是ChCore内核都无法重新获得CPU核心的控制权。就保护系统免受用户程序中的错误或恶意代码影响而言,这一情况显然并不理想,任何用户应用线程均可以如该“自旋线程”一样,通过进入无限循环来永久“霸占”整个CPU核心。
为了处理“自旋线程”的问题,ChCore内核必须具有强行中断一个正在运行的线程并夺回对CPU核心的控制权的能力,为此我们必须扩展ChCore以支持处理来自物理时钟的外部硬件中断。
物理时钟初始化
本部分我们将通过配置ARM提供的Generic Timer来使能物理时钟并使其以固定的频率发起中断。 我们需要处理的系统寄存器如下(Refer):
- CNTPCT_EL0: 它的值代表了当前的 system count。
- CNTFRQ_EL0: 它的值代表了物理时钟运行的频率,即每秒钟 system count 会增加多少。
- CNTP_CVAL_EL0: 是一个64位寄存器,操作系统可以向该寄存器写入一个值,当 system count 达到或超过该值时,物理时钟会触发中断。
- CNTP_TVAL_EL0: 是一个32位寄存器,操作系统可以写入 TVAL,处理器会在内部读取当前的系统计数,加上写入的值,然后填充 CVAL。
- CNTP_CTL_EL0: 物理时钟的控制寄存器,第0位ENABLE控制时钟是否开启,1代表enble,0代表disable;第1位IMASK代表是否屏蔽时钟中断,0代表不屏蔽,1代表屏蔽。
对物理时钟进行初始化的代码位于kernel/arch/aarch64/plat/raspi3/irq/timer.c
的plat_timer_init
函数。
练习 5
请根据代码中的注释在kernel/arch/aarch64/plat/raspi3/irq/timer.c
中完善plat_timer_init
函数,初始化物理时钟。需要完成的步骤有:
- 读取 CNTFRQ_EL0 寄存器,为全局变量 cntp_freq 赋值。
- 根据 TICK_MS(由ChCore决定的时钟中断的时间间隔,以ms为单位,ChCore默认每10ms触发一次时钟中断)和cntfrq_el0 (即物理时钟的频率)计算每两次时钟中断之间 system count 的增长量,将其赋值给 cntp_tval 全局变量,并将 cntp_tval 写入 CNTP_TVAL_EL0 寄存器!
- 根据上述说明配置控制寄存器CNTP_CTL_EL0。
hint
由于启用了时钟中断,但目前还没有对中断进行处理,所以会影响评分脚本的评分,你可以通过运行ChCore观察是否有"[TEST] Physical Timer was successfully initialized!: OK"
输出来判断是否正确对物理时钟进行初始化。
物理时钟中断与抢占
我们在lab3中已经为ChCore配置过异常向量表(kernel/arch/aarch64/irq/irq_entry.S
),当收到来自物理时钟的外部中断时,内核会进入handle_irq
中断处理函数,该函数会调用平台相关的plat_handle_irq
来进行中断处理。plat_handle_irq
内部如果判断中断源为物理时钟,则调用handle_timer_irq
。
ChCore记录每个线程所拥有的时间片(thread->thread_ctx->sc->budget
),为了能够让线程之间轮转运行,我们应当在处理时钟中断时递减当前运行线程的时间片,并在当前运行线程的时间片耗尽时进行调度,选取新的线程运行。
练习 6
请在kernel/arch/aarch64/plat/raspi3/irq/irq.c
中完善plat_handle_irq
函数,当中断号irq为INT_SRC_TIMER1(代表中断源为物理时钟)时调用handle_timer_irq
并返回。 请在kernel/irq/irq.c中完善handle_timer_irq
函数,递减当前运行线程的时间片budget,并调用sched函数触发调度。 请在kernel/sched/policy_rr.c
中完善rr_sched
函数,在将当前运行线程重新加入就绪队列之前,恢复其调度时间片budget为DEFAULT_BUDGET。
success
在完成填写之后,运行 ChCore 将可以成功进入用户态并打断创建的“自旋线程”让内核和主线程可以拿回CPU核心的控制权,你可以看到输出"Hello, I am thread 3. I'm spinning."
和“Thread 1 successfully regains the control!”
并通过 Preemptive Scheduling
测试点。
success
以上为Lab4 Part2的所有内容
进程间通信(IPC)
在本部分,我们将实现ChCore的进程间通信,从而允许跨地址空间的两个进程可以使用IPC进行信息交换。
进程间通讯概览
ChCore的IPC接口不是传统的send/recv接口。其更像客户端/服务器模型,其中IPC请求接收者是服务器,而IPC请求发送者是客户端。 服务器进程中包含三类线程:
-
主线程:该线程与普通的线程一样,类型为
TYPE_USER
。该线程会调用ipc_register_server
将自己声明为一个IPC的服务器进程,调用的时候会提供两个参数:服务连接请求的函数client_register_handler和服务真正IPC请求的函数server_handler(即图中的ipc_dispatcher
),调用该函数会创建一个注册回调线程; -
注册回调线程:该线程的入口函数为上文提到的client_register_handler,类型为
TYPE_REGISTER
。正常情况下该线程不会被调度执行,仅当有Client发起建立IPC连接的请求时,该线程运行并执行client_register_handler,为请求建立连接的Client创建一个服务线程(即图中的IPC handler thread)并在服务器进程的虚拟地址空间中分配一个可以用来映射共享内存的虚拟地址。 -
服务线程:当Client发起建立IPC连接请求时由注册回调线程创建,入口函数为上文提到的server_handler,类型为
TYPE_SHADOW
。正常情况下该线程不会被调度执行,仅当有Client端线程使用ipc_call
发起IPC请求时,该线程运行并执行server_handler(即图中的ipc_dispatcher
),执行结束之后会调用ipc_return
回到Client端发起IPC请求的线程。
注意
注册回调线程和服务线程都不再拥有调度上下文(Scheduling Context),也即不会主动被调度器调度到。其在客户端申请建立IPC连接或者发起IPC请求的时候才会被调度执行。为了实现该功能,这两种类型的线程会继承IPC客户端线程的调度上下文(即调度时间片budget),从而能被调度器正确地调度。
具体流程
为了实现ChCore IPC的功能,首先需要在Client与Server端创建起一个一对一的IPC Connection。该Connection保存了IPC Server的服务线程(即上图中IPC handler Thread)、Client与Server的共享内存(用于存放IPC通信的内容)。同一时刻,一个Connection只能有一个Client接入,并使用该Connection切换到Server的处理流程。ChCore提供了一系列机制,用于创建Connection以及创建每个Connection对应的服务线程。下面将以具体的IPC注册到调用的流程,详细介绍ChCore的IPC机制:
-
IPC服务器的主线程调用:
ipc_register_server
(user/chcore-libc/musl-libc/src/chcore-port/ipc.c
中)来声明自己为IPC的服务器端。-
参数包括server_handler和client_register_handler,其中server_handler为服务端用于提供服务的回调函数(比如上图中IPC handler Thread的入口函数
ipc_dispatcher
);client_register_handler为服务端提供的用于注册的回调函数,该函数会创建一个注册回调线程。 -
随后调用ChCore提供的的系统调用:
sys_register_server
。该系统调用实现在kernel/ipc/connection.c
当中,该系统调用会分配并初始化一个struct ipc_server_config
和一个struct ipc_server_register_cb_config
。之后将调用者线程(即主线程)的general_ipc_config字段设置为创建的struct ipc_server_config
,其中记录了注册回调线程和IPC服务线程的入口函数(即图中的ipc_dispatcher
)。将注册回调线程的general_ipc_config字段设置为创建的struct ipc_server_register_cb_config
,其中记录了注册回调线程的入口函数和用户态栈地址等信息。
-
-
IPC客户端线程调用
ipc_register_client
(定义在user/chcore-libc/musl-libc/src/chcore-port/ipc.c
中)来申请建立IPC连接。-
该函数仅有一个参数,即IPC服务器的主线程在客户端进程cap_group中的capability。该函数会首先通过系统调用申请一块物理内存作为和服务器的共享内存(即图中的Shared Memory)。
-
随后调用
sys_register_client
系统调用。该系统调用实现在kernel/ipc/connection.c
当中,该系统调用会将刚才申请的物理内存映射到客户端的虚拟地址空间中,然后调用create_connection
创建并初始化一个struct ipc_connection
类型的内核对象,该内核对象中的shm字段会记录共享内存相关的信息(包括大小,分别在客户端进程和服务器进程当中的虚拟地址和capability)。 -
之后会设置注册回调线程的栈地址、入口地址和第一个参数,并切换到注册回调线程运行。
-
-
注册回调线程运行的入口函数为主线程调用
ipc_register_server
是提供的client_register_handler参数,一般会使用默认的DEFAULT_CLIENT_REGISTER_HANDLER
宏定义的入口函数,即定义在user/chcore-libc/musl-libc/src/chcore-port/ipc.c
中的register_cb
。-
该函数首先分配一个用来映射共享内存的虚拟地址,随后创建一个服务线程。
-
随后调用
sys_ipc_register_cb_return
系统调用进入内核,该系统调用将共享内存映射到刚才分配的虚拟地址上,补全struct ipc_connection
内核对象中的一些元数据之后切换回客户端线程继续运行,客户端线程从ipc_register_client
返回,完成IPC建立连接的过程。
-
-
IPC客户端线程调用
ipc_create_msg
和ipc_set_msg_data
向IPC共享内存中填充数据,然后调用ipc_call
(user/chcore-libc/musl-libc/src/chcore-port/ipc.c
中)发起IPC请求。ipc_call
中会发起sys_ipc_call
系统调用(定义在kernel/ipc/connection.c
中),该系统调用将设置服务器端的服务线程的栈地址、入口地址、各个参数,然后迁移到该服务器端服务线程继续运行。由于当前的客户端线程需要等待服务器端的服务线程处理完毕,因此需要更新其状态为TS_WAITING,且不要加入等待队列。
-
IPC服务器端的服务线程在处理完IPC请求之后使用
ipc_return
返回。ipc_return
会发起sys_ipc_return
系统调用,该系统调用会迁移回到IPC客户端线程继续运行,IPC客户端线程从ipc_call
中返回。
练习题 7
在user/chcore-libc/musl-libc/src/chcore-port/ipc.c与kernel/ipc/connection.c
中实现了大多数IPC相关的代码,请根据注释补全kernel/ipc/connection.c
中的代码。之后运行ChCore可以看到 “[TEST] Test IPC finished!” 输出,你可以通过 Test IPC 测试点。
success
以上为Lab4 Part3的所有内容
实机运行与IPC性能优化
在本部分,你需要对IPC的性能进行优化。为此,你首先需要在树莓派3B实机上运行ChCore。
练习题 8
请在树莓派3B上运行ChCore,并确保此前实现的所有功能都能正确运行。
在ChCore启动并通过测试后,在命令行运行
$ ./test_ipc_perf.bin
你会得到如下输出结果
[TEST] test ipc with 32 threads, time: xxx cycles
[TEST] test ipc with send cap, loop: 100, time: xxx cycles
[TEST] test ipc with send cap and return cap, loop: 100, time: xxx cycles
[TEST] Test IPC Perf finished!
练习题 9
尝试优化在第三部分实现的IPC的性能,降低test_ipc_perf.bin的三个测试所消耗的cycle数
IPC性能测试程序的测试用例包括:
- 创建多个线程发起IPC请求(不传递cap),Server收到IPC后直接返回。记录从创建线程到所有线程运行结束的时间。
- Client创建多个PMO对象,并发起IPC请求(传递PMO);Server收到IPC后读取PMO,并依据读出的值算出结果,将结果写回随IPC传递的PMO中并返回;Client在IPC返回后读取PMO中的结果。将上述过程循环多次并记录运行时间。
- Client创建多个PMO对象,并发起IPC请求(传递PMO);Server收到IPC后读取PMO,并依据读出的值算出结果,然后创建新的PMO对象,将结果写入新创建的PMO中,并通过
ipc_return_with_cap
返回;Client在IPC返回后读取返回的PMO中的结果。将上述过程循环多次并记录运行时间。
在测试能够顺利通过的前提下,你可以修改任意代码。(测试程序所调用的函数位于 user/chcore-libc/libchcore/porting/overrides/src/chcore-port/ipc.c
)
hint
我们所有的任务都要求多次创建ipc链接并进行操作,你需要具体理解ipc链接的创建过程并根据测试的单独场景进行优化。
success
以上为Lab4 的所有内容
Lab 5:虚拟文件系统
虚拟文件系统(Virtual File System,VFS)提供了一个抽象层,使得不同类型的文件系统可以在应用程序层面以统一的方式进行访问。这个抽象层隐藏了不同文件系统之间的差异,使得应用程序和系统内核可以以一致的方式访问各种不同类型的文件系统,如 ext4、tmpfs、 FAT32 等。在 ChCore 中,我们通过 FSM 系统服务以及 FS_Base 文件系统 wrapper 将不同的文件系统整合起来,给运行在 ChCore 上的应用提供了统一的抽象。
本Lab一共分为三个部分:
- Posix适配:分析ChCore是如何实现兼容posix的文件接口的。
- FSM:FSM是ChCore的虚拟文件系统的实现层,其主要负责页缓存,挂载点管理,以及路径对接。我们在此部分实现这一文件系统转发层。
- FS_Base: FS_Base是文件系统实现层,由于在微内核系统中文件系统实际由一个个进程实现,所以我们统一包装标准的文件操作到通用库即为FS_Base,我们需要在这一个部分实现它。
跟先前的Lab相同,本实验代码包含了基础的 ChCore 操作系统镜像,除了练习题相关部分的源码以外(指明需要阅读的代码),其余部分通过二进制格式提供。
在正确完成本实验的练习题之后,你可以在树莓派3B+QEMU或开发板上进入 ChCore shell。与之前的Lab不同的地方是,本Lab不涉及任何内核态的代码编写,你需要将所有的目光聚焦在user
这个目录下面的文件。注释/* LAB 5 TODO BEGIN (exercise #) */
和/* LAB 5 TODO END (exercise #) */
之间代表需要填空的代码部分。
Posix 适配
无论我们采用的是什么样的操作系统,如果我们希望能够对上用户态的程序的话,我们都希望其采用同一套的调用规范。相同的在我们开发用户态程序的时候,我们也希望下层的libc提供的接口保持一致,以便于开发者进行移植。而在现代操作系统中Posix
是一个非常重要的规范。我们都可以在Windows, MacOS, Linux以及其他的衍生系统上找到它的身影,Posix
针对文件系统提出了一系列的API规范。下面是一个简要的描述
- mount, umount API:用于文件系统的挂载以及
- open, close:用于打开以及关闭文件描述符
- write, read:用于文件的读写
- mkdir, rmdir, creat, unlink, link, symlink:用于文件以及目录的创建与删除
- fcntl (byte range locks, etc.):用于修改文件描述符的具体属性
- stat, utimes, chmod, chown, chgrp:用于修改文件的属性
- 所有的文件路径都以及'/'开始
例如当我们在Linux系统中使用strace
去追踪cat
指令的系统调用时我们可以得到如下的系统调用序
openat(AT_FDCWD, "foo", O_RDONLY) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=4, ...}) = 0
fadvise64(3, 0, 0, POSIX_FADV_SEQUENTIAL) = 0
mmap(NULL, 139264, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7cb8b6fd3000
read(3, "foo\n", 131072) = 4
write(1, "foo\n", 4foo
) = 4
read(3, "", 131072) = 0
撇去一些无关紧要的系统调用后,我们可以看到其首先调用了openat
这个系统指令,其负责打开一个系统路径下的文件,并返回其一个文件描述符号。
练习1
阅读 user/chcore-libc/libchcore/porting/overrides/src/chcore-port/file.c
的 chcore_openat
函数,分析 ChCore 是如何处理 openat
系统调用的,关注 IPC 的调用过程以及 IPC 请求的内容。
Lab5 的所有代码都运行在用户态,不同应用间通过 IPC 进行通信,可调用的 IPC 相关函数定义在 user/chcore-libc/libchcore/porting/overrides/include/chcore/ipc.h
。
如果你感兴趣的话,你也可以继续阅读read
以及write
这些文件系统调用的实现,来看看chcore-libc
是怎么将Chcore文件系统的实现对齐在Posix的API之上的。
success
以上为Lab5 Part1的所有内容
FSM
只要实现了 FSBase 和 FSWrapper 的接口的 IPC 服务,都可以成为一个文件系统示例。FSM 负责管理文件系统,为用户态建立文件系统连接并创建 IPC 的客户端,由于文件系统与其挂载点密切相关,所以 FSM 会处理以下类型的请求:
/* Client send fsm_req to FSM */
enum fsm_req_type {
FSM_REQ_UNDEFINED = 0,
FSM_REQ_PARSE_PATH,
FSM_REQ_MOUNT,
FSM_REQ_UMOUNT,
FSM_REQ_SYNC,
};
当 FSM 收到 Client 的FSM_REQ_MOUNT 类型的请求时,其会执行挂载文件系统的操作,增加挂载的文件系统数量,创建对应的 mount_info_node
添加到挂载信息表中,直至最后与文件系统建立 IPC 连接,并将创建完的 IPC 客户端保存在挂载信息中,总而言之,FSM 仅负责挂载和文件系统同步有关的工作,剩下的其他功能由每个具体的FS服务进行处理。
struct mount_point_info_node {
cap_t fs_cap;
char path[MAX_MOUNT_POINT_LEN + 1];
int path_len;
ipc_struct_t *_fs_ipc_struct; // fs_client
int refcnt;
struct list_head node;
};
练习题 2
实现 user/system-services/system-servers/fsm/fsm.c
的 fsm_mount_fs
函数。
提示:
你应当回顾Lab4的代码以查看ChCore是怎么基于IPC服务的cap来创建并维护连接的。
当 FSM 收到 Client 的 FSM_REQ_PARSE_PATH 类型的请求时,其首先会尝试解析 IPC 请求中访问文件的路径,通过遍历挂载信息链表,找到对应的最匹配的文件系统以及其挂载点路径。通过匹配的文件系统,获取到该文件系统的客户端 cap。如果 Client 已经获取到了文件系统的 cap,则直接返回解析后的挂载点路径
;否则 FSM 会把挂载路径以及其对应的文件系统的 cap 也一并返回给 Client,并记录该 Client 已获取的文件系统 cap 的信息(FSM 会记录所有已经发送给某个 Client 的文件系统的 cap,见 user/system-services/system-servers/fsm/fsm_client_cap.h
)。
练习题 3
实现 user/system-services/system-servers/fsm/fsm.c
的 IPC 请求处理函数。
提示:
- 完成
user/system-services/system-servers/fsm/fsm_client_cap.c
中的相关函数。 - 所有关于挂载点有关的helper函数都在
user/system-services/system-servers/fsm/mount_info.c
- IPC handler 返回的 IPC msg 的数据类型为
struct fsm_request
,其有关的含义在user/chcore-libc/libchcore/porting/overrides/include/chcore-internal/fs_defs.h
有详细的解释。 - 使用
user/system-services/system-servers/fsm/mount_info.h
定义的函数来帮助你实现 IPC handler。 - 你应当回顾 Lab4 代码以查看 ChCore 是怎么将 cap 对象在进程间收发的,以及 ChCore 中是怎么使用共享内存完成 IPC 调用的。
- 由于 printf 并不经过FS所以你可以放心使用。
我们提供了所有需要实现的文件的 Obj 版本,你可以修改 CMakeLists.txt,将编译所需的源文件从未实现的 C 文件替换为包含了正确实现的 Obj 文件,以此验证某一部分练习的正确性。如果你需要调试某一个部分,你可以将 scripts/grade/cmakelists
下的CMakeLists对应复制到 FSM
以及 FS_Base
的目录下覆盖并重新编译,运行 make qemu
后你就可以查看到 printf 的调试信息。
success
以上为Lab5 Part2的所有内容
执行 make grade
,可以得到 Scores: 20/100
。
FS_Base
在 ChCore 中,FS_Base 是文件系统的一层 wrapper,IPC 请求首先被 FS_Base 接收,再由 FS_Base 调用实际的文件系统进行处理。
vnode
在 FS_Base wrapper 中,ChCore 实现了 vnode 抽象,为文件系统中的对象(如文件、目录、符号链接等)提供一个统一的表示方式。
ChCore 中 vnode 的定义为:
struct fs_vnode {
ino_t vnode_id; /* identifier */
struct rb_node node; /* rbtree node */
enum fs_vnode_type type; /* regular or directory */
int refcnt; /* reference count */
off_t size; /* file size or directory entry number */
struct page_cache_entity_of_inode *page_cache;
cap_t pmo_cap; /* fmap fault is handled by this */
void *private;
pthread_rwlock_t rwlock; /* vnode rwlock */
};
其中,private
表示文件系统特定的私有数据,例如对 inode 的引用,refcnt 代表该 vnode 被引用的次数,在下文的 server_entry
中会提到。
练习4
实现 user/system-services/system-servers/fs_base/fs_vnode.c
中 vnode 的 alloc_fs_vnode
、get_fs_vnode_by_id
、inc_ref_fs_vnode
、dec_ref_fs_vnode
函数。
tip
- 你可能需要回顾Lab2中的代码去了解红黑树的操作方法。
success
完成练习4后,执行 make grade
,可以得到 Scores: 35/100
。
server_entry
文件描述符(File Descriptor,简称 fd)是操作系统用于管理文件和其他输入/输出资源(如管道、网络连接等)的一种抽象标识符。我们来回顾一下计算机系统基础课中学习的unix文件系统抽象。在类 Unix 系统(如 Linux、macOS)中,文件描述符是一个非负整数,它指向一个内核中的文件表项,每个表项包含了文件的各种状态信息和操作方法。ChCore 将进程的 fd 保存在 chcore-libc 当中,同时在文件系统中通过 server_entry 维护了各个 Client 的 fd 的信息,把各个 Client 的 fd 和在文件系统侧的 fid 对应起来((client_badge, fd) -> fid(server_entry)),也就是说 server_entry 对应着每个文件系统实例所对应的文件表项,其包含了对应文件表项的文件 offset 以及 vnode
引用。由于一个 vnode
可能会对应多个文件表项,所以 vnode
的引用数需要进行维护。
FS_Base 的 IPC handler 在处理 IPC 请求时,会先把 IPC 消息中包含的文件 fd 转换为 fid,所以我们需要把进程的 fd 和实际所对应的文件表项的映射建立起来,而在 ChCore 中对应的就是 server_mapping
链表。每当处理 IPC 请求时,文件系统都会通过进程发起的 badge 号找到与之对应的映射表,最终得到文件表项的 ID。
练习5
实现 user/system-services/system-servers/fs_base/fs_wrapper.c
中的 fs_wrapper_set_server_entry
和 fs_wrapper_get_server_entry
函数。
tip
- 通过全局变量
struct list_head server_entry_mapping
遍历server_entry_node
。 - 你可以参考
fs_wrapper_clear_server_entry
来理解每一个变量的含义。
success
完成练习5后,执行 make grade
,可以得到 Scores: 50/100
。
fs_wrapper_ops
当我们拥有了文件表项和VNode抽象后,我们便可以实现真正的文件系统操作了。
我们可以将 FS_Base 以及 FS_Wrapper 的所有逻辑看成一个 VFS 的通用接口,其暴露出的接口定义为 strcut fs_server_ops
。对于每一个文件系统实例,其都需要定义一个全局的名为 server_ops
的全局句柄,并将实际的文件系统操作的实现注册到该句柄中。你可以通过查看 user/system-services/system-servers/tmpfs/tmpfs.c
中查看 ChCore 的默认 tmpfs
文件系统是怎么将其注册到 FS_Wrapper
中的。而到了实际处理文件请求时,上层的 FS_Wrapper 在响应 IPC 请求的时候,只需要调用 server_ops
中的函数指针即可,不需要实际真正调用每一个文件系统实现的操作函数, 这样便完成了一个统一的文件操作逻辑。例如在 tmpfs
中实际的读命令为 tmpfs_read
但在上层的 fs_wrapper
看来其调用只需要调用 server_ops->read
即可而不需要真正知晓 tmpfs
中的函数签名。
对于本 Lab 你只需要实现最基本的 Posix 文件操作即可,即 Open,Close,Read, Write 以及 LSeek 操作。而其下层每个文件系统除了 Open
操作,每当 FS_Base 尝试处理 Posix 文件请求时,其都会调用 translate_fd_to_fid
将对应的 fd
翻译成 fid
并重新写回 struct fs_request
中的 fd
,所以请注意不需要在实际的fs_wrapper_函数中再次调用该函数。下面将简述一下每一个函数的语义。
对于 Open 以及 Close 来说,其主要的目的就是创建以及回收 Server Entry 即文件表项。由于在 VFS 中 VNode 的创建是动态的,所以当进程尝试发出 Open
中,我们需要调用与之对应的 server_ops
并同时分配对应的文件表项。对于每一个新增的文件表项,我们需要将其关联到对应的内存 VNode
中。由于文件表项所对应的 VNode
可能不在内存中,所以当文件系统返回 inode 号时我们需要尝试查找相应的 vnode
,如果不存在则尝试分配并将其添加至对应的红黑树中。当完成 VNode
关联后,我们需要使用上一步实现的映射函数,将 server_entry
与用户 fd
映射,完成文件表项的创建。对于 Close
,我们需要采取类似的逻辑,即回退所有的文件表项操作,减少引用计数,并尝试回收对应的系统资源。
针对 Read/Write/Lseek
操作,你需要参考 man
以及对应的 tests/fs_test
下的所有测试文件,按照 Posix
语义相应地维护 server_entry
以及 vnode
信息,并将数据返回给用户进程。
针对 mmap 操作,我们知道针对文件的 mmap 操作是采取 Demand Paging 的内存映射来实现的,当用户进程调用 mmap
时,FS 会首先为用户新增一个 pmo
即内存对象,并将其对应的类型设置为 PMO_FILE
,并为其创建 Page_Fault
映射(user/system-services/system-servers/fs_base/fs_page_fault.c
),最后将该 pmo
对象发回用户进程并让其进行映射。当用户尝试访问该内存对象,并发生缺页异常时,内核会根据 pmo 的所有者(badge)将异常地址调用到对应FS处理函数进行处理,处理函数为每一个文件系统中的 user_fault_handler
,此时 FS 服务器会根据缺页地址分配新的内存页,填充文件内容并完成缺页的处理,最终返回内核态,从而递交控制权到原来的用户进程。
练习6
实现 user/system-services/system-servers/fs_base/fs_wrapper_ops.c
中的 fs_wrapper_open
、fs_wrapper_close
、fs_wrapper_read
、fs_wrapper_pread
、fs_wrapper_write
、fs_wrapper_pwrite
、fs_wrapper_lseek
、fs_wrapper_fmap
函数。
tip
user/chcore-libc/libchcore/porting/overrides/include/chcore-internal/fs_defs.h
中定义了struct fs_request
,其中定义了文件系统收到的 IPC 信息所包含的数据。- 针对文件表项的helper函数如
alloc_entry
和free_entry
在user/system-services/system-servers/fs_base/fs_vnode.c
中定义。 user/system-services/system-servers/tmpfs/tmpfs.c
中定义了 tmpfs 文件系统提供的文件操作接口 server_ops,fs_wrapper 接口会调用到 server_ops 进行实际的文件操作。- 用户态的所有针对文件的请求,首先会被路由到
user/chcore-libc/libchcore/porting/overrides/src/chcore-port/file.c
中,该文件包含了在调用ipc
前后的预备和收尾工作。 - 你应当回顾 Lab2 的代码,去了解针对 PMO_FILE,内核是怎么处理缺页并将其转发到FS中的。同时你需要查看
user/system-services/system-servers/fs_base/fs_page_fault.c
中的page_fault
处理函数,了解 FS 是如何处理 mmap 缺页异常的。
success
完成练习6后,执行 make grade
,可以得到 Scores: 100/100
。
练习7
思考 ChCore 当前实现 VFS 的方式有何利弊?如果让你在微内核操作系统上实现 VFS 抽象,你会如何实现?
success
以上为Lab5 Part3的所有内容
附录
工具链教程
本教程会对实验中所需使用的一些命令行工具进行介绍,包括为什么要使用这些工具、宏观功能介 绍以及部分工具的详细介绍,对于感兴趣的同学,我们也列出了一些更详细的资料供进一步学习。本教 程中介绍的工具仅代表我们推荐的,在拆弹和后续ChCore实验中的常用命令行工具,但我们不限定同学 们在完成实验时所使用的具体工具或方式,同学们可以使用任何其他自己熟悉的工具来完成实验。 本教程中所介绍的工具和命令,均假设在Linux shell环境(如bash等)下执行。如果同学们希望使用其 他操作系统或平台,请自行查阅相关工具的安装方法以及可能的命令语法不同之处。
TL;DR Cheatsheet
info
<参数> 代表需要被替换的参数,请将其替换为你需要的实际值(包括左右两侧尖括号)。
tmux
- 创建新会话: tmux new -s <会话名>
- 进入会话: tmux attach -t <会话名>
- 临时退出会话(会话中程序保持在后台继续运行): Ctrl-b d
- 关闭会话及其中所有程序: tmux kill-session -t <会话名>
- 水平分屏: Ctrl-b "
- 垂直分屏: Ctrl-b %
gdb
- 不输入任何命令,直接按下回车键:重复上一条命令
- break *address : 在地址 address 处打断点
- break <函数名> : 在函数入口处打断点
- continue : 触发断点后恢复执行
- info breakpoints : 列出所有断点
- delete
: 删除编号为 NUM 的断点 - stepi : 触发断点后单步执行一条指令
- print
: 打印表达式 的求值结果,可以使用部分C语法,例如 print*(int*) 0x1234
可将地址 0x1234 开始存储的4个字节按32位有符号整数解释输出 - print/x
: 以16进制打印 的求值结果 - lay asm : 使用TUI汇编视图
- lay src : 使用TUI源代码视图(要求被调试可执行文件包含调试信息,且本地具有相应源代
- 码文件)
- tui disable : 退出TUI
objdump操作
- objdump -dS <可执行文件> > <输出文件> : 反汇编可执行文件中的可执行section(不含数据
- sections),并保存到输出文件中。在可能的情况下(如有调试信息和源文件),还会输出汇
- 编指令所对应的源代码。
- objdump -dsS <可执行文件> > <输出文件> : 将可执行文件中的所有sections的内容全部导
- 出,但仍然只反汇编可执行sections,且在可能情况下输出源代码
TMUX
简介
如今大部分同学开始使用电脑接触的就是Windows与GUI(图形用户界面)。在GUI下,我们可以通过 桌面和窗口的方式,管理显示器上的二维空间(桌面空间)。许多情况下,桌面空间对于单个应用程序 来说,有些太大了,只使用一个应用程序不能有效地利用显示空间;又或者,我们想要同时处理多个任 务,比如在写文档或者论文时,希望能非常方便地看到自己的参考资料……部分同学可能已经掌握如何使 用分屏以及虚拟桌面来解决这些问题。的确,不论Windows10/11还是macOS抑或是Linux下的桌面环 境,甚至众多基于Android的操作系统,分屏与虚拟桌面已经成为构建高效的GUI工作环境的基本功能。 分屏解决的是如何有效利用桌面空间的问题。通过分屏功能的辅助,我们可以快速将多个应用程序的窗 口紧密排列在桌面空间上,且可以更方便地调整这些窗口所占据的区域,避免自己手动排列带来的混乱 和不便,让我们可以同时使用多个相关的应用程序,最大化利用桌面空间与提高效率。
虚拟桌面则解决的是如何有效隔离多种使用场景的问题,它是分屏功能的进一步衍生。我们经常需要同 时处理几种不同性质的任务,例如,一边在编写文档或论文,一边还可能需要看QQ、微信和回复消息。 如果只有一个桌面空间,要么不时切出和最小化QQ微信的窗口,要么把它们也使用分屏与文档窗口排列 在一起,但这两种使用方式,或多或少都会影响需要专注的文档编写任务。此时,我们可以使用虚拟桌 面。虚拟桌面是在物理桌面空间上“虚拟”出多个互不相干的桌面空间,每个桌面空间内都可以有自己的 窗口布局。虽然同时只能使用一个虚拟桌面,但我们可以在多个虚拟桌面间快速切换。使用虚拟桌面 后,我们可以将比较相关的一类程序的窗口放在同一个虚拟桌面中,其余不相干的程序则放在其他虚拟 桌面中,如此,可以有效减少其他程序对于当前工作任务的干扰,同时又能在多种不同工作环境中快速 切换。
分屏与虚拟桌面有效提高了GUI下的窗口管理效率。但是,窗口和桌面的概念,并非只能局限于GUI中。 利用除了字母数字外的各种字符和颜色,我们同样可以在命令行用户界面(CLI)下“绘制”窗口,相较于 通过命令行参数,窗口这种交互方式对用户更友好,更直观。同样地,在CLI下的窗口中,分屏和虚拟桌 面需要解决的这些效率问题同样是存在的,也一样有着解决这些问题的需求。tmux(terminal multiplexer)项目则是目前在CLI环境下这些问题的主要解决方案。顾名思义,它是一个“终端多路复用 器”,如果说分屏和虚拟桌面是有效利用GUI中的桌面空间,tmux则主要是有效利用终端中的空间。这里 的终端,可以是GUI下的终端模拟器,比如Windows Terminal,iTerm2等,也可以是运行在命令行模 式下的Linux的显示器空间等等。
tmux vs 多个终端模拟器窗口
- 同一窗口内部布局自由构建(部分终端模拟器也可实现)
- 统一管理多个窗口、便捷切换(开多个终端容易混淆,不便于随意切换)
- tmux可以在不使用时将进程保持在后台继续运行(detach,而终端模拟器一旦关闭就会杀死其中所有进程)
- tmux还是一个服务器,可以通过网络连接,如果有需要,可以允许其他人通过网络连接到你的
- tmux界面中,实现网络协作(终端模拟器不支持)
- tmux支持高度自定义的配置,且有丰富的插件生态
Session/Window/Pane
note
在理解了GUI下为什么需要有分屏和虚拟桌面后,类比GUI下的概念,可以很容易地理解tmux中的相关概念。
Pane
pane相当于GUI下的一个窗口。只不过相较于GUI下窗口可以自行自由移动,也可以使用分屏辅助 排列,CLI下窗口还是基于字符的,所以tmux下的pane只能实现类似于GUI分屏的紧密排列,不能 自由移动,也不能实现pane之间的重叠。
Window
window则相当于GUI下的虚拟桌面。一个window是一组pane的集合,不同的window拥有独立的pane以及pane的布局,且可以在多个window间通过快捷键快速切换。
Session
session是tmux特有的概念,它是一组window的集合,代表一个完整的工作环境。一般来说,不 论我们通过显示器、Linux桌面环境下的终端模拟器、Windows或macOS上的终端模拟器+ssh…… 等方式访问Linux命令行,首先都是进入一个shell中,而并不能直接进入tmux。因此,在tmux 中,相较于“打开”和“关闭” tmux,我们更常说"attach","detach"到session。attach指的是从当前 运行的shell进入一个tmux session的过程,而detach则是从tmux session离开,回到单个shell的 过程。相较于“打开”和“关闭”,session+attach/detach有下列好处:
- 在同一个终端中,也可以方便切换不同工作环境(这是最基础的功能)
- 避免同一个session中有过多不相干的window,降低切换效率
- detach不是关闭session,detach后,session中运行的所有程序会在后台继续运行,且tmux会负责收集它们产生的输出,我们随时可以重新attach到某一个session,就可以看到其中程序最新的运行情况和历史输出,这对于某些需要长时间后台运行的任务是非常方便的。
- 可以有多个终端同时attach到一个session,搭配tmux的服务器功能,可以实现向他人共享你的工作界面和环境,以及协作工作。
常用快捷键与子命令
tmux支持丰富的快捷键,同时,也可以通过tmux命令的一组子命令来与tmux进行交互。事实上,如果研究tmux的配置文件,可以看到其中重要的一项内容就是将快捷键绑定到相应的子命令上。
-
前缀(prefix table)快捷键与根(root table)快捷键
- 许多CLI应用程序都会定义自己的快捷键。作为更底层的程序,tmux需要尽可能避免自己的快
- 捷键和上层应用程序的快捷键冲突。前缀键就是为了解决这个问题引入的。
- 前缀快捷键:需要先按下前缀键(默认是 Ctrl+b ),然后再按下相应的快捷键。
- Root快捷键:直接按下对应的快捷键,不需要前缀键。这些通常是一些不与通常使用的终端快捷键冲突的键。
-
命令行模式
<prefix>
: ,可以进入tmux的内部命令行。在该命令行中可以直接输入tmux子命令(而不是 tmux<子命令>
),可以用于使用未绑定快捷键的命令。
-
pane 管理
- 切换pane:
<prefix>
方向键,tmux命令版本是 select-pane -[UDLR] ,U代表上,D代表下,L代表左,R代表右。同时只能有一个pane接收输入,利用这个命令可以切换当前接收输入的pane。分割当前pane,创建两个新的pane:<prefix>"
(上下分割),<prefix>%
(左右分割),tmux命令版本是 split-window (默认是上下分割), split-window -h (左右分割)。在初始情况下,每个window只有一个占满整个window的pane,使用这个命令可以分割当前接收输入的pane,创建两个pane,类似于GUI下的分屏功能。 - 关闭pane:
<prefix>
x ,然后按 y 确认。tmux命令版本是 kill-pane 。
- 切换pane:
-
window管理
- 创建Window:
<prefix>c
,tmux命令版本是 new-window 。 - 切换Window:
<prefix>n
(下一个Window),<prefix> p
(上一个Window),<prefix> l
(最后一个活跃Window),tmux命令版本是 next-window , previous-window , last-window 。 - 切换到指定Window:
<prefix>
窗口序号 ,tmux命令版本是 select-window -t :窗口序号。
- 创建Window:
-
session管理(部分命令没有快捷键,因为不是在session内部进行操作)
- 创建新session: tmux new-session -s mysession , mysession 是新session的名字。
- 列出所有session: tmux list-sessions 。
- attach到一个session: tmux attach -t mysession , mysession 是目标session的名字。
- detach当前session:
<prefix>d
,tmux命令版本是 detach 。 - 终止某个session: tmux kill-session -t mysession , mysession 是你想要终止的session的名字。
-
在某些GUI终端模拟器中,还可以通过鼠标与tmux交互,例如可以通过鼠标拖拽pane的边界来调整各个pane的大小,点击window的名字来切换到指定window等。
拓展阅读
- http://man.openbsd.org/OpenBSD-current/man1/tmux.1
- https://github.com/rothgar/awesome-tmux
- https://github.com/gpakosz/.tmux
- 如果你希望通过tmux提高自己的工作效率,我们强烈建议你编写适合自己使用习惯的tmux配置文件。
GDB
gdb是目前最常用的动态调试工具之一。所谓动态调试,指的是在程序运行的过程中对程序进行观测或 施加干预的过程,一种常见的动态调试方法是断点,通过插入断点使得程序在特定点位暂停运行,让动 态调试器可以对程序的状态进行进一步的观测。与动态调试相对的是静态分析。静态分析不需要实际运 行程序,甚至不需要编译程序,而是在程序源代码或指令的层级进行一系列分析甚至“枚举”,预测程序 的可能执行情况,寻找潜在的问题。某种程度上来说,对于存在问题的程序,程序员直接阅读代码或汇 编指令并分析问题,也可以被视为一种静态分析。
相较于静态分析,动态调试可以真实地反映程序运行的实际情况,包括各种数据的实际值、程序的实际 执行路径等。在某些场景下,使用动态调试寻找程序的问题或理解程序的行为,比直接阅读程序源码要 简单许多,例如使用动态调试器单步运行程序,在每一步运行的前后观察程序的相关状态,可以非常直 观地找到导致问题的程序指令或代码。当然,静态分析也有着广泛的应用,许多静态分析工具在无需编 译或运行程序的情况下便可分析程序的潜在问题,这可以有效节约程序运行的时间,同时静态分析可以 尽可能地枚举程序的可能执行路径,有助于发现实际运行程序时不会出现或非常罕见的问题。
源码级调试 vs 汇编级调试
动态调试是在程序的运行过程中施加干预和观测状态,问题在于,如何干预程序的运行?又该观测程序 的什么状态?以打一个断点为例,应该在什么地方打断点,程序触发断点后,又该检查程序的哪些状 态?动态调试器本身只是为程序员提供了完成上述工作的能力,但如何运用这些能力,仍然需要程序员 本身对于程序的了解。站在程序员的角度,无疑希望能直接在程序执行到某一行源代码时触发断点,触 发断点后,又可以直接检查程序中某个变量的值甚至复杂对象的内容。许多同学此前可能已经接触过各 种IDE自带的调试功能,它们大多都允许程序员在源代码中设置断点,并且可以在触发断点时直接看到各 个变量和对象的内容。这种程序员直接站在源代码的层级,使用源代码级的概念(代码行、变量、对象 等)进行调试的过程称为源码级调试。
然而,从动态调试器的功能而言,要支持源码级调试并非仅有动态调试器即可做到。这是因为,CPU本 身只能执行二进制形式的机器指令,不论是编译执行或是解释执行的高级编程语言,最终在程序运行 时,动态调试器能观察和控制的只是最终的机器指令、寄存器和内存地址。例如,仅使用动态调试器本 身,我们只能指定在某一条指令暂停执行,也不能直接检查变量或者对象的内容,因为在机器指令的层 面并没有变量和对象的概念,只有寄存器和内存。这种只使用机器执行过程中直接可见的概念(指令、 寄存器、内存)进行调试的过程称为汇编级调试/机器级调试。造成上述问题的原因是,在从源代码到可 执行程序的编译或解释过程中,许多信息都丢失了,因为这些信息对于程序的最终执行并无任何帮助: 从程序执行的角度来说,CPU不需要理解某条指令对应源代码中的哪个文件的哪一行,也不需要理解某 个寄存器在某一时刻存储的是哪个变量的值。但是,这些信息的丢失,就给调试带来了较大的困难,因 为高级语言翻译成汇编指令的方式非常多样,并且存在各种复杂的细节,从而使得汇编级调试并不直 观,往往需要远多于源码级调试的时间精力才有可能定位和理解程序存在的问题。
如果想要进行源码级调试,就需要在程序可执行文件中加入一系列的额外信息,来弥补编译/解释过程中 损失的信息,让动态调试器可以把指令地址、寄存器、内存地址“还原”为源代码级的概念如源代码行、 变量、对象等等。但是,嵌入这些信息会使得程序可执行文件的体积增大,所以如果不是在编译时使用 特定的选项,程序往往是没有这些额外信息的。以Linux下最常见的可执行文件格式ELF为例,若要支持 源码级调试,需要ELF文件存在符号表和专门的调试信息。其中符号表可以用于将一些内存地址还原回函 数或全局变量等,除了调试之外,还有许多其他用途,而调试信息则是专门为了将机器级概念还原到源 代码级存在的。如果一个ELF文件只有符号表,没有调试信息,那么绝大多数源码级调试功能也都是不可 用的,但是也可以支持一定程度的源码级调试,例如在函数的入口打断点,检查全局变量的值等等。 对于解释型语言,情况还要更复杂一些。这是因为,gdb等动态调试器,都是把程序视为一个“黑盒”,它 们并不理解一个程序是在完成自身的工作,还是在作为解释器,为一种更高级的语言(如Python)提供 支持。以Python为例,即使Python解释器程序本身有包含调试信息,从gdb的角度来看,也只能看到解 释器本身的工作情况,例如它是如何解析Python字节码的,这个过程中它调用了自身的哪些函数,修改 了自己内部的哪些变量等等。但是从Python程序员的角度来说,往往假设Python解释器本身是正确的, 问题在于自己编写的Python代码,比起理解解释器内部的执行情况,更关注的是Python语言层级的概 念。但gdb等通用动态调试器是无法在Python语言层级进行调试的。对于使用解释执行的语言,需要解 释器本身支持调试功能,往往还需要使用专门的调试器。
在本次实验中,我们提供的炸弹程序是使用非常短的C代码编译而成的,且没有使用过高的优化等级, 汇编指令与原始C代码是高度对应的;程序保留了符号表,但移除了调试信息。这是因为在操作系统中 不可避免地存在无法使用高级语言,必须使用汇编语言编写的部分。因此我们希望通过一个复杂度有限 的汇编程序,提高同学们对于汇编语言以及C语言编译到汇编语言过程的理解,同时增强同学们对gdb的 熟悉程度和调试能力,为后续的实验打下基础。
设置调试目标
源码级调试 vs 汇编级调试
动态调试是在程序的运行过程中施加干预和观测状态,问题在于,如何干预程序的运行?又该观测程序 的什么状态?以打一个断点为例,应该在什么地方打断点,程序触发断点后,又该检查程序的哪些状 态?动态调试器本身只是为程序员提供了完成上述工作的能力,但如何运用这些能力,仍然需要程序员 本身对于程序的了解。站在程序员的角度,无疑希望能直接在程序执行到某一行源代码时触发断点,触 发断点后,又可以直接检查程序中某个变量的值甚至复杂对象的内容。许多同学此前可能已经接触过各 种IDE自带的调试功能,它们大多都允许程序员在源代码中设置断点,并且可以在触发断点时直接看到各 个变量和对象的内容。这种程序员直接站在源代码的层级,使用源代码级的概念(代码行、变量、对象 等)进行调试的过程称为源码级调试。
然而,从动态调试器的功能而言,要支持源码级调试并非仅有动态调试器即可做到。这是因为,CPU本 身只能执行二进制形式的机器指令,不论是编译执行或是解释执行的高级编程语言,最终在程序运行 时,动态调试器能观察和控制的只是最终的机器指令、寄存器和内存地址。例如,仅使用动态调试器本 身,我们只能指定在某一条指令暂停执行,也不能直接检查变量或者对象的内容,因为在机器指令的层 面并没有变量和对象的概念,只有寄存器和内存。这种只使用机器执行过程中直接可见的概念(指令、 寄存器、内存)进行调试的过程称为汇编级调试/机器级调试。造成上述问题的原因是,在从源代码到可 执行程序的编译或解释过程中,许多信息都丢失了,因为这些信息对于程序的最终执行并无任何帮助: 从程序执行的角度来说,CPU不需要理解某条指令对应源代码中的哪个文件的哪一行,也不需要理解某 个寄存器在某一时刻存储的是哪个变量的值。但是,这些信息的丢失,就给调试带来了较大的困难,因 为高级语言翻译成汇编指令的方式非常多样,并且存在各种复杂的细节,从而使得汇编级调试并不直 观,往往需要远多于源码级调试的时间精力才有可能定位和理解程序存在的问题。
如果想要进行源码级调试,就需要在程序可执行文件中加入一系列的额外信息,来弥补编译/解释过程中 损失的信息,让动态调试器可以把指令地址、寄存器、内存地址“还原”为源代码级的概念如源代码行、 变量、对象等等。但是,嵌入这些信息会使得程序可执行文件的体积增大,所以如果不是在编译时使用 特定的选项,程序往往是没有这些额外信息的。以Linux下最常见的可执行文件格式ELF为例,若要支持 源码级调试,需要ELF文件存在符号表和专门的调试信息。其中符号表可以用于将一些内存地址还原回函 数或全局变量等,除了调试之外,还有许多其他用途,而调试信息则是专门为了将机器级概念还原到源 代码级存在的。如果一个ELF文件只有符号表,没有调试信息,那么绝大多数源码级调试功能也都是不可 用的,但是也可以支持一定程度的源码级调试,例如在函数的入口打断点,检查全局变量的值等等。 对于解释型语言,情况还要更复杂一些。这是因为,gdb等动态调试器,都是把程序视为一个“黑盒”,它 们并不理解一个程序是在完成自身的工作,还是在作为解释器,为一种更高级的语言(如Python)提供 支持。以Python为例,即使Python解释器程序本身有包含调试信息,从gdb的角度来看,也只能看到解 释器本身的工作情况,例如它是如何解析Python字节码的,这个过程中它调用了自身的哪些函数,修改 了自己内部的哪些变量等等。但是从Python程序员的角度来说,往往假设Python解释器本身是正确的, 问题在于自己编写的Python代码,比起理解解释器内部的执行情况,更关注的是Python语言层级的概 念。但gdb等通用动态调试器是无法在Python语言层级进行调试的。对于使用解释执行的语言,需要解 释器本身支持调试功能,往往还需要使用专门的调试器。
在本次实验中,我们提供的炸弹程序是使用非常短的C代码编译而成的,且没有使用过高的优化等级, 汇编指令与原始C代码是高度对应的;程序保留了符号表,但移除了调试信息。这是因为在操作系统中 不可避免地存在无法使用高级语言,必须使用汇编语言编写的部分。因此我们希望通过一个复杂度有限 的汇编程序,提高同学们对于汇编语言以及C语言编译到汇编语言过程的理解,同时增强同学们对gdb的 熟悉程度和调试能力,为后续的实验打下基础。
使用简介
设置目标
gdb支持调试本地运行的进程,也支持通过网络等方式远程调试其他机器上运行的进程。在 Linux机器上调试本地进程时,gdb依赖ptrace这个syscall,它从操作系统层面为gdb控制其 他进程的运行提供了基础支持。一般而言,出于安全性考虑,各个Linux发行版都对ptrace syscall的调用进行了程度不等的权限控制。例如只允许通过ptrace调试子进程等。
启动程序为子进程
tip
gdb <program>
其中, <program>
为需要被调试的程序名,可以是一个完整的可执行文件路径,也可以
只提供程序自身的可执行文件的名称。对于后一种情况,gdb会搜索$PATH环境变量来
找到可执行文件的实际路径
执行该命令后,会进入gdb命令行。此时,gdb并不会立即开始执行被调试的程序。但此 时gdb已经载入了可执行文件中的符号表和调试信息(如有),可以在实际执行程序前
就设置一些断点等,以便调试程序运行早期的代码或不接收输入的程序等等。 确认完成准备工作后,在gdb命令行中执行 run 命令,作为gdb的子进程运行被调试程 序。
Attach 进入子进程
tip
sudo gdb -p <pid>
important
一般而言,在常见Linux发行版上,直接attach到运行中进程可能需要root权限。
远程调试
gdb支持通过网络、串口等调试其他机器上运行的程序。这里我们以网络远程调试为例
说明远程调试的基本原理。如果想使用网络远程调试,需要在实际运行被调试程序的机
器上启动一个gdbserver,或任何实现了gdbserver协议的代理程序(以下统称为
gdbserver)。gdb将会连接到这个gdbserver,并通过网络向gdbserver发送命令,以
及通过gdbserver,读取运行中程序的信息等。在这个架构下,实际控制程序运行的是
gdbserver,而不是gdb,但gdbserver又受到gdb的控制。远程调试的主要优点是提供
了一种通用的将调试和程序的运行解耦的方法,而不是必须将被调试程序作为gdb的子
进程运行。例如,远程调试可以允许程序在其他机器上运行,这对于一些必须在内网中
运行或是依赖特殊硬件等的程序很有用;此外,远程调试基于gdbserver协议,任何程序
只要实现了该协议,都可以“表现为”一个gdbserver,如此,程序可以主动将自己的一些
内部信息暴露给gdb。理论上而言,这可以用于实现Python、Go等由虚拟机运行的程序
的调试(虽然实际上这些语言的调试不是这么实现的),也可以用于调试由qemu运行
的程序(详见下文)。
使用 target remote <ip|domain name>:<port>
,即可将gdb的调试目标设为远程的
gdbserver。
如果通过gdb进行远程调试,在运行gdb时是否还需要指定 <program>
?事实上,答案
并非是完全不需要。如果只想进行纯粹的汇编级调试,的确可以不需要指定
<program>
,但如果想要进行源码级调试,则仍需要指定 <program>
。在这种情况
下, <program>
的主要作用是让gdb读取其中的符号表和调试信息。gdb不会直接从
gdbserver中读取这些信息,因为gdbserver不一定能提供这些信息,gdb只会和
gdbserver交换汇编级的信息,它依赖本地 <program>
文件中的符号表和调试信息将源
码级信息翻译回汇编级信息。此外,还需要注意,在触发一个断点后,如果希望看到当
前所执行到的程序源代码,则还需要本地保存了程序的源代码。这是因为,调试信息
(以ELF所使用的DWARF格式为例)只保存了某条指令所对应的源代码的文件路径与行
数,并没有直接保存源代码,gdb所做的工作是根据调试信息中的路径和行数,读取本
地的代码文件并显示相应源代码。如果本地相应路径没有源代码文件或内容有误,那么
gdb将无法正确显示源代码。
如果运行gdb时没有指定 <program>
,还可以通过 add-symbol-file 命令指定
<program>
。如果程序不是在本地编译的,那么源代码的绝对路径可能与本地保存源代
码的路径不同,可以用 set-substitute-path 命令进行替换。
断点控制
- break
<expr>
: 在<expr>
处创建一个断点。<expr>
是一个最终可以求值为某条指令的地址的表达式。例如,如果想直接在某个地址处设置断点,可以写成 *<address>
,如果被调试程序有符号表,可以直接使用函数名。如果被调试程序有调试信息,可以指定在某个源文件的某一行设置断点,甚至可以使用更复杂的C表达式,更详细的信息可以参考手册。但需要注意,不论使用何种表达式,最终其实都是求值到一条指令的地址,gdb只是利用调试信息并帮助程 序员简化了这一步骤。 - info breakpoints : 列出当前的所有断点
- disable/enable
<NUM>
: 禁用/启用编号为<NUM>
的断点,断点编号可以通过前述 info 命令查看。 - delete
<NUM>
: 删除编号为<NUM>
的断点。
执行控制
- 不输入任何命令,直接按下回车键:重复上一条命令
- 在触发断点后,可以通过下列命令控制如何恢复程序的执行
- continue : 恢复程序执行,直到触发下一个断点
- continue
<NUM>
: 恢复程序执行,且忽略此断点<NUM>
次 - kill : 终止程序执行
- quit : 退出gdb
- stepi : 执行下一条机器指令,随后继续暂停执行(单步调试)
- stepi
<NUM>
: 执行接下来<NUM>
条指令 - step : 执行下一条语句,这属于源代码级调试,需要调试信息
- nexti : 执行下一条指令,且不跟踪(step through)函数调用。与 stepi 不同,如果当前指
- 令是一条 bl 等指令,那么 stepi 会在被调用函数的第一条指令暂停(step in),而 nexti
- 会在 bl 指令之后的那条指令,即被调用函数返回后的那条指令上暂停。
- nexti
<NUM>
, next : 与 stepi , step 类似,只是不跟踪函数调用。 - finish : 恢复执行直到当前被调用的函数返回
显示信息
-
backtrace : 显示栈跟踪信息,可以看到当前函数是如何一步步被调用到的。但需要注意,由 于编译器的优化等因素,如果没有调试信息,栈跟踪信息可能是不准确的,甚至无法提供栈跟 踪信息。
-
print
<mods>
<expr>
: 打印表达式<expr>
执行的值。其中<expr>
可以是寄存器,如 $pc , $sp , $x0 ,也可以是C表达式等。如果没有调试信息,则所使用的C表达式不能涉及到程序中 变量的值,但仍可以使用常量或寄存器中的值,例如,可以直接将某个内存地址作为数字打 印: print *(int *)0x1234 ,或使用寄存器中的值参与运算等: print $x0+$x1 。<mods>
是可选的修饰符,可以用来控制打印的格式,详细信息可以参考手册。表达式求值 时,寄存器、变量、内存中的值都基于触发断点时程序的状态。 -
x/NFU
<address>
: 打印内存中指定长度的内容。与 print 命令不同,该命令只能打印内存 中的内容,且不需要如print一样将内存地址转换为指针并求值。这是因为此命令只是将内存 视为字节数组,打印出其中给定数目的字节的内容,而不对字节内容做任何解释。相反, print 在打印指针求值表达式时,会根据指针的类型信息和<mods>
来计算需要打印多少字 节,且会对多字节的内容进行解释,例如将它们显示为一个完整的整数,而非整数在内存中的 各个字节。此命令的修饰符中, N 表示需要打印的单元数目, U 的取值可以为 b , h , w , g ,分 别表示一个单元大小为1,2,4,8字节。 F 表示每个单元的打印格式,如 i 表示作为指令反 汇编, x 表示十六进制, d 表示十进制等,详细信息可以查看手册。 -
display
<mods>
<expr>
: 在每次程序暂停执行时,自动根据<mods>
打印<expr>
的值,适 用于一些频繁更改的表达式。利用这条命令可以在单步调试时自动查看所需关注的值,而无需 反复输入print命令。 -
info display : 列出所有的自动打印。
-
delete display
<NUM>
: 删除编号为<NUM>
的自动打印
TUI
note
TUI(text UI)是gdb内置的高级命令行界面功能。TUI在gdb内部引入了类似窗口的概念,允许用户在使用gdb命令的同时查看被调试程序的汇编指令、源代码等,也允许用户设置自定义的UI布局,提高工作效率。
- lay asm : 进入TUI默认汇编布局,此布局下会同时开启一个gdb命令窗口以及一个汇编指令 窗口,用户可以直观看到当前正在执行的汇编指令。
- lay src : 进入TUI默认源代码布局,此布局下会同时开启gdb命令窗口和源代码窗口。需要 具备调试信息和源代码文件。
- tui disable : 退出TUI。
扩展阅读
- ptrace syscall
- gdb手册(如想深入了解gdb或参考命令的详细用法,非常建议阅读,此文档只是给出了一个非常简要的介绍):TUI: https://sourceware.org/gdb/onlinedocs/gdb/TUI.html
- TUI
Objdump
简介
objdump是GNU binutils中的标准工具之一。它的主要作用是显示二进制程序的相关信息,包括可执行 文件、静态库、动态库等各种二进制文件。因此,它在逆向工程和操作系统开发等底层领域中非常常 用,因为在这些领域中,我们所接触到的程序很可能不提供源代码,只有二进制可执行文件;抑或是所 面临的问题在高级语言的抽象中是不存在的,只有深入到机器指令的层次才能定位和解决。在这些领域 中,objdump最常见的应用是反汇编,即将可执行文件中的二进制指令,根据目标架构的指令编码规 则,还原成文本形式的汇编指令,以供用户从汇编层面分析和理解程序。例如,在拆弹实验中,我们只 提供了炸弹程序的极少一部分源代码,同学们需要通过objdump等工具进行逆向工程,从汇编层级理解 炸弹程序的行为,进而完成实验。
GNU实现 vs LLVM实现
objdump最初作为GNU binutils的一部分发布的。由于它直接处理二进制指令,因此它的功能与CPU架 构等因素强相关。在常见Linux发行版中,通过包管理安装的objdump均为GNU binutils提供的实现, 且它一般被编译为用于处理当前CPU架构的二进制指令。例如,在x86_64 CPU上运行的Linux中安装 objdump,它一般只能处理编译到x86_64架构的二进制文件。对于GNU binutils提供的实现,如果需要 处理非当前CPU架构的二进制可执行文件,则需要额外安装为其他架构编译的objdump,例如,如果需 要在x86_64 CPU上处理aarch64架构的二进制文件,一般需要使用 aarch64-linux-gnu-objdump 。 LLVM是另一个被广泛应用的模块化编译器/工具链项目集合。LLVM项目不仅提供了另一个非常常见的 C/C++编译器 clang ,也提供了一组自己的工具链实现,对应GNU binutils中的相关工具。例如,LLVM 也提供了自己的objdump实现,在许多Linux发行版上,你需要安装和使用 llvm-objdump 命令。LLVM 所提供的工具链实现与GNU binutils中的相应工具是基本兼容的,这意味着它们的命令行参数/语法等是 相同的,但LLVM工具链也提供了其他扩展功能。从用户的角度来说,LLVM工具链与GNU binutils的一 个主要区别是LLVM工具链不需要为每个能处理的架构编译一个单独的版本,以 llvm-objdump 为例,不 论处理x86_64还是aarch64架构中的二进制文件,均可以使用 llvm-objdump 命令(需要在编译 llvm- objdump 时开启相应的支持,从发行版包管理中安装的版本通常有包含)。
可执行文件格式
为了更好地加载和执行程序,操作系统在一定程度上需要了解程序的内部结构和一系列元数据。因此, 一个二进制可执行文件并不仅仅是将所有指令和数据按顺序写到文件中即可,通常它需要以一种特定的 可执行文件格式存储,使用的格式是由它将要运行在的操作系统决定的。例如,对于Unix/Linux操作系 统,ELF格式是目前最主流的可执行文件格式。而Windows下主要的可执行文件格式是PE/COFF。除了 反汇编之外,objdump还可以显示与可执行文件格式相关的众多信息,但需要用户对可执行文件格式自 身的理解。详细分析可执行文件格式超出了本教程的范围,感兴趣的同学建议查阅与ELF相关的资料。在 后续ChCore实验中,也会涉及部分关于ELF的知识。
objdump使用
本节只介绍拆弹实验中可能用到的基础用法。详细用法感兴趣的同学可以参考扩展阅读部分。
-
objdump -dS a.out a.S
: 反汇编a.out
中的可执行section(不含数据 sections),并保存到输出文件a.S
中。在可能的情况下(如有调试信息和源文件),还会输出汇编指 令所对应的源代码。 -
objdump -dsS a.out a.S
: 将a.out
sections的内容全部导出,但 仍然只反汇编可执行sections,且在可能情况下输出源代码。
扩展阅读
- objdump手册
- https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
- https://maskray.me/blog/
- https://fourstring.github.io/ya-elf-guide/guide_zh
Make
简介
make是Linux操作系统上常见的较为基础的构建系统(build system)。构建系统的主要工作,是管理 一个较大规模或较为复杂的项目的各个“部件”,以及如何将这些“部件”“组装”成为最终的项目产物。以C 语言项目的编译过程为例。一个最简单的C语言项目,只需要有一个源文件即可,这种简单的项目,确 实对构建系统没有很强的需求,只需要一条很简短的gcc命令即可完成编译。但是,随着项目规模的增 加,项目的源文件也会越来越多,诚然,我们可以把所有源文件的文件名都提交给gcc命令进行编译,但 是这样会导致每次都要输入一个很长的命令。或许有同学会说,命令历史记录或者写一个简单的脚本都 可以简化这个操作。但是构建系统能做的不仅如此。如果每次都把所有源文件提交给gcc让它进行编译, 那么gcc每次都会执行一次完整编译 ,即重新将每一个源文件编译成对象文件,再将他们进行链接。然 而,事实上,在一个规模较大的项目中,我们在更新这个项目时,通常不会修改项目的所有源文件,常 常只有一小部分或者一部分源文件被修改了,那么那些没有被修改的源文件就完全没有必要再重新编 译,只需要将已有的中间产物 (对象文件)与修改过的文件重新编译后的对象文件进行链接即可,也即 所谓的部分编译 。换句话说,如果每次都使用前述的一条完整gcc命令,那么相当一部分编译时间就被 浪费了,因为gcc自身并不理解哪些文件需要重新编译,哪些文件不需要重新编译,而且除非添加特定参 数,gcc也不会保留中间产物或利用现有的中间产物。相比于在一条命令中把所有源文件都提交给gcc, 更好的做法是把整个构建过程拆散,先逐个将源文件编译为对象文件,并且保存这些对象文件作为中间 产物,随后再将这些中间产物链接到一起,产生完整的程序。当然,这么做相较于一条gcc命令会复杂很 多,但对于大型项目可以显著减少编译时间。由于整个流程从一条命令变成了需要以特定顺序执行的许 多条命令,因此出于可维护性的考虑,应当把整个流程以文件形式保存下来,这就是构建系统中常见的 规则文件 。
如前所述,一个比较复杂的项目,其编译流程涉及多条(甚至是大量)需要按照特定顺序执行的命令。 如何确定这些命令的顺序?如何在项目的结构发生变更后重新确定这些命令执行的顺序?这些问题,是 单纯的shell脚本所不能解决的,因此构建系统的规则文件通常并不是简单的shell脚本。一般而言,构建 系统的规则文件都包含几个基本元素(不同系统中的术语可能不同),以make为例:目标(target)、 配方(recipe)、前置条件(prerequisites)。目标指的是构建系统需要负责生成的一个文件或完成的 一项任务,这个文件可以是某个中间产物文件,也可以是最终的产物或任何其他文件;此外,它也可以 是一项抽象的任务,例如本实验中用到的使用qemu运行炸弹程序,也被作为make的一个目标。前置条 件指的是在完成这个目标前需要完成的其他目标,一个目标只有在前置目标都已完成时才能被执行。最 简单的前置目标是构建这个目标所需的源文件,例如用于生成对象文件app.o的目标,其前置目标可以 是其源文件app.c。构建系统可以根据文件系统中app.o和app.c两个文件的修改时间,来判断是否需要 执行这个目标,如果app.c在app.o产生之后又被修改过,则认为app.o需要重新生成,否则就可以直接 使用现有的app.o。配方则是定义如何完成某一个特定的目标,通常包含一系列shell命令等。从上面的描述中不难看到,规则文件的作用实际上是定义了一个依赖图 ,图中每个目标是一个顶点,如果目标A依赖目标B,则目标B有一条边指向目标A。从原理上来说,构建系统会根据规则文件构建出依赖图,随 后依照图的拓扑序执行每个顶点所定义的配方,最终就能生成完整的产物。还需要注意,构建系统是独 立于编译器之外的,构建系统能做的,是给程序员提供自动生成和执行依赖图的能力,但依赖图的结构 本身仍然是规则文件的编写者定义的,例如,项目的构建涉及到多少目标,每个目标的配方使用什么命 令,每个目标的前置条件是什么,这些问题是不可能由构建系统自身来解决的,而是要由规则文件的编 写者“告知”构建系统。
总而言之,使用构建系统而不是直接调用编译器,对于复杂C/C++项目有下列好处:
- 增强构建流程可维护性
- 简化执行部分任务(类似于脚本),例如清理构建中间产物等
- 通过部分编译/增量编译,可以显著减少大规模项目的编译时间
- 可以并发执行所有前置已被满足的目标(并发编译),有效利用多核CPU,进一步缩短编译时间
扩展阅读
tip
make是一个比较底层的构建系统,在使用上仍有诸多繁琐之处,于是又出现了一些用于替代make或在make更上层,更为简化的构建系统,前者的代表项目之一是ninja,后者的代表项目之一是CMake,感兴趣的同学可以进一步了解。
- https://faculty.cs.niu.edu/~mcmahon/CS241/Notes/makefiles.html
- https://www.gnu.org/software/make/manual/html_node/index.html
QEMU
qemu是目前广泛应用的开源模拟器和虚拟机项目。它可以在一种架构的CPU(如x86)上,模拟其他多 种架构的CPU(如aarch64等),这使得可以通过qemu在x86 CPU上运行为其他CPU架构编译的程序。 例如,本实验的目的是让同学们熟悉aarch64汇编,因此我们提供的炸弹程序是为aarch64 CPU编译 的,如果没有qemu,则程序不能在x86 CPU上执行。此外,qemu也可以模拟一个完整的机器,包括 CPU、内存、磁盘以及多种其他外部硬件设备,此时它的功能基本等价于虚拟机,在这种场景下,它还 可以与KVM技术配合,使用硬件虚拟化提高虚拟机的运行性能,但此时它就不能再运行为其他架构编译 的操作系统了。
进程级模拟 vs 系统级模拟
qemu的模拟粒度可以分为进程级和系统级。在进程级模拟下,qemu只负责运行一个为其他架构编译的 普通程序,这个程序与当前系统中运行的程序的唯一区别是它所使用的指令集不同,qemu会负责将它 所使用的指令集翻译为当前机器可以执行的指令。除此之外,该程序相当于当前系统中的一个普通进 程,它仍然通过当前系统的内核来访问操作系统提供的功能。默认情况下, qemu 命令执行进程级模 拟。例如,我们提供的炸弹程序就只需要使用进程级模拟,它本质上就是一个普通的用户态程序,只是 编译到了aarch64指令集而非x86指令集。除此之外,它的输入/输出等功能,仍然是调用当前操作系统 提供的syscall。
qemu-system 命令可以用于系统级模拟。在系统级模拟下,QEMU会模拟一整套硬件,包括CPU、内 存、磁盘以及多种可选硬件设备,此时QEMU的功能类似于虚拟机。在系统级模拟下,QEMU不能直接 运行普通的用户态程序,而是需要运行完整的操作系统,由操作系统来管理QEMU模拟出的硬件资源。 系统级模拟与是否使用KVM等硬件虚拟化进行加速是正交的。如果不使用KVM,QEMU仍然通过动态指 令集翻译来运行被模拟的操作系统,此时它可以运行为其他架构编译的操作系统。否则,使用硬件虚拟 化可以增强性能,但QEMU不再可以运行为其他架构编译的操作系统。
QEMU + GDB
GDBServer
qemu实现了gdbserver协议,这使得gdb可以连接到qemu,并且给qemu发送指令,以及从qemu中读 取信息。通过gdbserver,qemu可以把自己内部的信息暴露出来,或者说,可以让gdb理解它需要调试 的目标是qemu中被模拟的程序(或操作系统),而不是qemu自身。例如,当gdb发送在地址 0x400000 设置一个断点的指令时,qemu可以在被模拟程序执行到 0x400000 时暂停被模拟程序的执 行,并告知gdb客户端,而不是在qemu自身执行到 0x400000 时暂停。如果gdb需要读取内存中的值, qemu可以返回被调试程序的内存数据,而不是自身的内存数据。 还需要强调,由于gdb和gdbserver之间交换的信息仅停留在汇编级,而且gdb完全使用本地的可执行程 序文件中的符号表和调试信息执行源码级信息到汇编级信息之间的转换,因此在使用qemu系统级模拟 时,我们可能会发现一些意料之外的行为。这是因为,在系统级模拟时,QEMU自身相当于站在CPU的 抽象层面,而不是操作系统内核的抽象层面,这使得QEMU自身并不能理解它内部正在运行的操作系统 的相关信息。例如,如果在qemu中运行一个Linux操作系统,其中的每一个进程的虚拟地址空间都有许 多重叠,那么如果此时在 0x400000 打一个断点,qemu并不能识别出这个断点是针对哪个进程的,它所 能做的只是在CPU“执行”到地址为 0x400000 的指令时暂停执行,不论当前执行的到底是哪个进程。又由 于gdb客户端是完全根据用户所指定的可执行文件进行源码级到汇编级的翻译的,所以可能会出现这样 一种情况:
- gdb客户端从 appA 中读取符号表,其中,函数 funcA 的地址为 0x412345 ,它有2个参数
- 设置断点: break funcA ,但实际上,gdb会根据符号表把 funcA 翻译回 0x412345 ,qemu的 gdbserver只能接收到在 0x412345 打断点的指令。
- 在qemu模拟的Linux操作系统中,进程 appB 恰好在执行,且它的函数 funcB (只有1个参数)的 地址恰好也为 0x412345 ,qemu并不能理解这一点,它同样会暂停进程 appB 的执行。
- 根据 appA 的调试信息,gdb客户端知道 funcA 有2个参数,所以它会要求gdbserver读取寄存器 x0 和 x1 的值,用于显示 funcA 的参数。但由于此时暂停执行的实际上是只有1个参数的 funcB , 所以 x1 中保存的是一个随机值, x0 中保存的值很可能也不符合 funcA 的第一个参数的语义。于是 我们会看到虽然触发了断点,但函数参数却像是乱码。
- 同学们在后续chcore用户态相关的实验中,会对这个问题有更进一步的体会和理解。
扩展阅读
ELF 文件格式
在Lab1中ChCore 的构建系统将会构建出 build/kernel.img
文件,该文件是一个 ELF 格式的“可执行目标文件”,和我们平常在 Linux 系统中见到的可执行文件如出一辙。ELF 可执行文件以 ELF 头部(ELF header)开始,后跟几个程序段(program segment),每个程序段都是一个连续的二进制块,其中又包含不同的分段(section),加载器(loader)将它们加载到指定地址的内存中并赋予指定的可读(R)、可写(W)、可执行(E)权限,并从入口地址(entry point)开始执行。
可以通过 aarch64-linux-gnu-readelf
命令查看 build/kernel.img
文件的 ELF 元信息(比如通过 -h
参数查看 ELF 头部、-l
参数查看程序头部、-S
参数查看分段头部等):
$ aarch64-linux-gnu-readelf -h build/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: 271736 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 4
Size of section headers: 64 (bytes)
Number of section headers: 15
Section header string table index: 14
更多关于 ELF 格式的细节请参考 ELF - OSDev Wiki。
Linker Script
在Chcore 构建的最后一步,即链接产生 build/kernel.img
时,ChCore 构建系统中指定了使用从 kernel/arch/aarch64/boot/linker.tpl.ld
模板产生的 linker script 来精细控制 ELF 加载后程序各分段在内存中布局。
具体地,先将所有的调试信息段使用DWARF_DEBUG
这个宏展开都放到虚拟地址0开始的地方,同时将 ${init_objects}
中所有有效数据段(即除去调试信息段的数据)(即 kernel/arch/aarch64/boot/raspi3
中的代码编成的目标文件)放在了 ELF 内存的 TEXT_OFFSET
(即 0x80000
)位置,.text
(代码段)、 .data
(数据段)、.rodata
(只读数据段)和 .bss
(BSS 段)依次紧随其后。
这里对这些分段所存放的内容做一些解释:
init
:内核启动阶段代码和数据,因为此时还没有开启 MMU,内核运行在低地址,所以需要特殊处理.text
:内核代码,由一条条的机器指令组成.data
:已初始化的全局变量和静态变量.rodata
:只读数据,包括字符串字面量等.bss
:未初始化的全局变量和静态变量,由于没有初始值,因此在 ELF 中不需要真的为该分段分配空间,而是只需要记录目标内存地址和大小,在加载时需要初始化为 0
除了指定各分段的顺序和对齐,linker script 中还指定了它们运行时“认为自己所在的内存地址”和加载时“实际存放在的内存地址”。例如前面已经说到 init
段被放在了 TEXT_OFFSET
即 0x80000
处,由于启动时内核运行在低地址,此时它“认为自己所在的地址”也应该是 0x80000
,而后面的 .text
等段则被放在紧接着 init
段之后,但它们在运行时“认为自己在” KERNEL_VADDR + init_end
也就是高地址。
更多关于 linker script 的细节请参考 Linker Scripts的官方手册。
调试指北
important
调试占据着你的大部分做lab的时间,本节主要讲解实验当中你可能需要使用到的调试手段,以及对应的工具使用方法.
VSCode
gdb
你仍然可以使用gdb,我们在Makefile里保留了gdb的启动指令,运行make gdb
也可以直接使用。
在Lab0中我们学习并熟悉了GDB的使用方法,但是从Lab1开始,Lab中的可调试文件就陡然增加了,使用GDB最麻烦的地方就是定位源文件进行单步调试, 同时在调试的同时我们也无法迅速的对文件进行修改来做出调整,为此我们新增加了vscode的gdb设置,使用它你就可以一步进行对于内核镜像的调试工作。
环境搭建
devcontainer
如果你使用我们提供的vscode devcontainer配置,你可以直接跳过本节。
你需要安装Microsoft所提供的官方C/C++插件.
使用说明
首先你需要在make之后所生成的.config中找到CHCORE_KENREL_DEBUG
并将其值修改ON
。
然后运行make clean && make
即可生成带调试符号的内核镜像。你可以使用aarch64-linux-gnu-objdump
查看这个镜像的所有调试信息。
stu@Chcore:/workspaces/OS-Course-Lab/Lab1/build$ aarch64-linux-gnu-objdump --dwarf=info kernel.img
kernel.img: file format elf64-littleaarch64
Contents of the .debug_info section:
Compilation Unit @ offset 0x0:
Length: 0x2a (32-bit)
Version: 2
Abbrev Offset: 0x0
Pointer Size: 8
<0><b>: Abbrev Number: 1 (DW_TAG_compile_unit)
<c> DW_AT_stmt_list : 0x0
<10> DW_AT_low_pc : 0x80000
<18> DW_AT_high_pc : 0x80080
<20> DW_AT_name : (indirect string, offset: 0x0): /workspaces/OS-Course-Lab/Lab1/kernel/arch/aarch64/boot/raspi3/init/start.S
<24> DW_AT_comp_dir : (indirect string, offset: 0x4c): /workspaces/OS-Course-Lab/Lab1/build/kernel
<28> DW_AT_producer : (indirect string, offset: 0x78): GNU AS 2.34
<2c> DW_AT_language : 32769 (MIPS assembler)
...
dwarf
ELF文件所使用的调试信息格式为DWARF格式,如果感兴趣你可以它的官网1来详细了解。
构建完成后,在左侧侧边栏找到调试按钮,点击后在上方找到QEMU(cppdbg)
的调试设置点击调试,既可以运行我们预先准备好的vscode gdb设置。此时你的调试器会暂停自动停在start.S
中,此时你就可以像c++课上进行debug的方式一样自由的打断点以及观察点并进行调试了。由于qemu-system-aarch64
模拟了所有的机器核心,在左侧显示了四个线程的调用堆栈。当你关闭调试后,qemu会自动退出,与调试普通的程序是一致的.
Debug Console
那么如果我们有的时候还是想要敲一下键盘输一些gui没有的命令,比如让gdb罗列一下这个镜像里有多少函数,此时debug adapter protocol
协议
会帮助我们向gdb传输命令,在代码编辑栏下方的DEBUG CONSOLE
展示的就是我们cpptools所连接的gdb-mi
的所返回的输出,此时使用-exec <command>
即可以如同往常我们使用gdb一样获取我们所需要的信息,如-exec info functions
即可以提取我们需要该镜像中的所有符号。
Disassembler
由于我们对一些重要的代码只提供了预先编译的可链接文件,所以这些文件一般都没有调试符号,但是你仍然可以对其进行的反编译代码进行查看
例如arm64_elX_to_el1
函数你可以使用-exec b arm64_elX_to_el1
进行断点添加,当你点击继续执行后此时gdb-mi
会抛出异常并停下执行。
此时打开vscode的命令面板输入disassembly
打开反编译器面板即找到断点出的代码信息,在这个面板里你也可以使用单步调试对汇编
进行调试。
Hex Editor
有的时候你想对一块内存区域使用16进制编码进行查看,但是苦于gdb羸弱的表达方法效率很差,此时你可以使用Microsoft官方的hex editor
来直接进行查看,
你可以在左方的watchpoint中输入内存地址或者变量地址,当你停下后在watchpoint的右侧会出现一个16进制的按钮点开既可以对你监视点的内存区域的
内容进行16进制的编辑与阅读。这个工具当你在苦于调试一段内存读写代码的时候很有用处。
GDB Stub
你可能不知道由于Qemu 实现了GDB Stub的同时也给gdb stub添加了与qemu相同的指令如system, memory等等指令,所以当你暂停时你同样可以通过-exec monitor <command>
来对qemu-monitor进行操作,例如运行-exec monitor system_reset
此时你的qemu会从头开始重新执行,主要的关系如下
sequenceDiagram participant vscode participant gdb participant qemu vscode->> gdb: -exec monitor system_reset gdb->>qemu: monitor system_reset
代码跳转 compile_commands.json
有的时候,你可能会奇怪为什么代码提示出错了,与脚本语言不同,C/C++的lsp代码提示也是依赖于编译选项的,例如你为编译器指定的包含路径以及宏定义(即编译时的上下文)。如果我们不给LSP服务器提供我们浏览文件是怎么编译的,那么LSP就会按照默认的设置搜索默认的包含路径以及符号表,如果恰好你的项目里有多个同名头文件,
亦或者是在不同的文件里面定义了相同的符号,那么LSP就会报错,因为它不知道到底哪个文件以及哪个符号是参与了最终构建。此时你就需要给LSP服务器提供一个提示性的信息用于告知该文件编译时的上下文。通常来说这个编译时的上下文构建系统是知道的,而llvm他们定义了一个标准的上下文文件格式即compile_commands.json
,你可以在build/kernel/compile_commands.json
里看到它,这个文件在CMake
生成build目录的时候会根据需要编译的文件自动生成。其记录了构建这个elf文件的过程中所使用的编译命令,此时LSP服务器即可以解析这个文件获取编译时刻的上下文信息,完成后就能得出准确的提示信息了。
[
{
"directory": "/workspaces/OS-Course-Lab/Lab1/build/kernel",
"command": "/usr/bin/aarch64-linux-gnu-gcc -DCHCORE -DCHCORE_ARCH=\\\"aarch64\\\" -DCHCORE_ARCH_AARCH64 -DCHCORE_ASLR -DCHCORE_CROSS_COMPILE=\\\"aarch64-linux-gnu-\\\" -DCHCORE_KERNEL_DEBUG -DCHCORE_KERNEL_ENABLE_QEMU_VIRTIO_NET -DCHCORE_PLAT=\\\"raspi3\\\" -DCHCORE_PLAT_RASPI3 -DCHCORE_SUBPLAT=\\\"\\\" -DLOG_LEVEL=2 -I/workspaces/OS-Course-Lab/Lab1/kernel/include -I/workspaces/OS-Course-Lab/Lab1/kernel/user-include -I/workspaces/OS-Course-Lab/Lab1/kernel/include/arch/aarch64 -I/workspaces/OS-Course-Lab/Lab1/kernel/include/arch/aarch64/plat/raspi3 -I/workspaces/OS-Course-Lab/Lab1/kernel/arch/aarch64/boot/raspi3/include -g -Og -g -Wall -Werror -Wno-unused-variable -Wno-unused-function -nostdinc -ffreestanding -march=armv8-a+nofp -fno-pic -fno-pie -mcmodel=large -o CMakeFiles/kernel.img.dir/arch/aarch64/boot/raspi3/init/mmu.c.obj -c /workspaces/OS-Course-Lab/Lab1/kernel/arch/aarch64/boot/raspi3/init/mmu.c",
"file": "/workspaces/OS-Course-Lab/Lab1/kernel/arch/aarch64/boot/raspi3/init/mmu.c"
},
{
"directory": "/workspaces/OS-Course-Lab/Lab1/build/kernel",
"command": "/usr/bin/aarch64-linux-gnu-gcc -DCHCORE -DCHCORE_ARCH=\\\"aarch64\\\" -DCHCORE_ARCH_AARCH64 -DCHCORE_ASLR -DCHCORE_CROSS_COMPILE=\\\"aarch64-linux-gnu-\\\" -DCHCORE_KERNEL_DEBUG -DCHCORE_KERNEL_ENABLE_QEMU_VIRTIO_NET -DCHCORE_PLAT=\\\"raspi3\\\" -DCHCORE_PLAT_RASPI3 -DCHCORE_SUBPLAT=\\\"\\\" -DLOG_LEVEL=2 -I/workspaces/OS-Course-Lab/Lab1/kernel/include -I/workspaces/OS-Course-Lab/Lab1/kernel/user-include -I/workspaces/OS-Course-Lab/Lab1/kernel/include/arch/aarch64 -I/workspaces/OS-Course-Lab/Lab1/kernel/include/arch/aarch64/plat/raspi3 -I/workspaces/OS-Course-Lab/Lab1/kernel/arch/aarch64/boot/raspi3/include -g -Og -g -Wall -Werror -Wno-unused-variable -Wno-unused-function -nostdinc -ffreestanding -march=armv8-a+nofp -fno-pic -fno-pie -mcmodel=large -o CMakeFiles/kernel.img.dir/arch/aarch64/boot/raspi3/init/init_c.c.obj -c /workspaces/OS-Course-Lab/Lab1/kernel/arch/aarch64/boot/raspi3/init/init_c.c",
"file": "/workspaces/OS-Course-Lab/Lab1/kernel/arch/aarch64/boot/raspi3/init/init_c.c"
},
{
"directory": "/workspaces/OS-Course-Lab/Lab1/build/kernel",
"command": "/usr/bin/aarch64-linux-gnu-gcc -DCHCORE -DCHCORE_ARCH=\\\"aarch64\\\" -DCHCORE_ARCH_AARCH64 -DCHCORE_ASLR -DCHCORE_CROSS_COMPILE=\\\"aarch64-linux-gnu-\\\" -DCHCORE_KERNEL_DEBUG -DCHCORE_KERNEL_ENABLE_QEMU_VIRTIO_NET -DCHCORE_PLAT=\\\"raspi3\\\" -DCHCORE_PLAT_RASPI3 -DCHCORE_SUBPLAT=\\\"\\\" -DLOG_LEVEL=2 -I/workspaces/OS-Course-Lab/Lab1/kernel/include -I/workspaces/OS-Course-Lab/Lab1/kernel/user-include -I/workspaces/OS-Course-Lab/Lab1/kernel/include/arch/aarch64 -I/workspaces/OS-Course-Lab/Lab1/kernel/include/arch/aarch64/plat/raspi3 -I/workspaces/OS-Course-Lab/Lab1/kernel/arch/aarch64/boot/raspi3/include -g -Og -g -Wall -Werror -Wno-unused-variable -Wno-unused-function -nostdinc -ffreestanding -march=armv8-a+nofp -fno-pic -fno-pie -mcmodel=large -o CMakeFiles/kernel.img.dir/arch/aarch64/boot/raspi3/peripherals/uart.c.obj -c /workspaces/OS-Course-Lab/Lab1/kernel/arch/aarch64/boot/raspi3/peripherals/uart.c",
"file": "/workspaces/OS-Course-Lab/Lab1/kernel/arch/aarch64/boot/raspi3/peripherals/uart.c"
}
]
我们在每个lab的.vscode
下都为clangd
这个LSP服务器提供了compile_commands.json
的位置配置,同时也不使用vscode
的同学们提供了.clangd
的配置,用于引导clangd正确找到compile_commands.json
的位置,在为LSP获取到正确的编译上下文信息后,他就可以为我们提供正常的重构,跳转,以及代码查看的功能了。
compile database
针对LLVM Compile Database的更多信息,请查阅llvm compile database的官方文档2
防御式编程 Assertions
调试器针对比较小范围的代码的作用是很大的,但大多数时候bug的发生点和异常发生点并不在同一个地方,例如在Lab1中你会配置页表,当你页表配置错误时,其实际异常也是发生在配完页表实际执行到页表所映射的虚拟内存区域时才会出错,或者当你数据结构课编写AVL树时,你会发现你访问了不该是空指针的指针,这种异常的发生和bug发生点不一致的情况,通常是由于我们违反了不定式(invariant)
所造成的。违反不定式并不会立即造成程序崩溃,我们需要做的是让异常在程序违反不定式时立即发生,此时我们就需要使用assert了。例如在AVL树中进行左旋以及右旋的时候,你可以通常使用assert(node->left)
, assert(node)
等手段对其运行时行为进行检查。在ChCore中,我们准备了BUG_ON
宏用于检查运行时的一些expr
是否正常,除了kinfo
以外,你可以使用这个宏来强行拦截异常,便于缩小你的debug的范围。
LLVM Compile Database: https://clang.llvm.org/docs/JSONCompilationDatabase.html