TOC
Open TOC
OS EX Virtualization
操作系统上的进程
操作系统启动后到底做了什么?
CPU Reset → Firmware → Boot loader → Kernel _start()
操作系统会加载第一个程序,从此以后,Linux Kernel 就进入后台,用 syscall 创造整个世界
使用 pstree 观察 systemd 进程
定制最小的 Linux
没有存储设备,只有包含几个文件的 initramfs
其中 init
即为操作系统加载的第一个程序
主要使用工具为 busybox
首先将一些命令建立符号链接
这样就不必输入 /bin/busybox ls
而可以直接输入 ls
然后挂载了一些目录,将部分系统信息暴露给应用程序
例如可以使用 pstree
或 top
命令查看进程信息
接着修改 PS1,即终端的提示符
最后进入 shell,注意 shell 是不会返回的
另外,可以直接在文件系统中添加静态链接的二进制文件,比如 hello
也可以使用 vi 编辑代码
只不过没有 gcc
加上 vmlinuz 内核镜像就可以在 QEMU 里启动了
有一定概率失败,不知道如何退出
OS API Overview
- 进程(状态机)管理
- fork, execve, exit - 状态机的创建/改变/删除
- 存储(地址空间)管理
- mmap - 虚拟地址空间管理
- 文件(数据对象)管理
- open, close, read, write - 文件访问管理
- mkdir, link, unlink - 目录管理
fork()
虚拟化 → 操作系统在物理内存中保存多个状态机
为此,我们需要有创建状态机的 API
- 立即复制状态机,内存加寄存器
- 新创建进程返回 0
- 执行 fork 的进程返回子进程的进程号
Fork Bomb
Don’t try it (or try it in docker)
https://www.geeksforgeeks.org/fork-bomb/
ex1
画 fork 图
理论上,一共有 7 种不同的 pid,共 8 行输出
可能的输出如下,并发程序输出不确定
ex2
等价于
理论上共 6 行输出
然而注意到子进程会继承父进程的缓冲区
所以上述分析是在 line buffer 的假设之下的,即 \n
会清空缓冲区
若使用管道,如
会有 8 行输出
由于管道是 full buffer 的,直到缓冲区被填满,才调用 write 系统调用
或者不使用 \n
,这样
都是 8 行输出
或者使用 fflush
强制刷新缓冲区,这样
都是 6 行输出
或者使用 setbuf(stdout, NULL)
设置为无缓冲,这样
都是 6 行输出
总结来说
- 及时清空缓冲区,或者没有缓冲区,则为 6 行
- 直到缓冲区被填满,才 write,则为 8 行
ex3
多线程程序的某个线程执行 fork()
,应该发生什么?
execve()
将当前运行的状态机重置成成另一个程序的初始状态
- 执行名为
filename
的程序 - 允许对新状态机设置参数 argv 和环境变量 envp
- 刚好对应了
main()
的参数
一个例子
相当于在命令行中键入
不过自定义了环境变量
似乎 bash 的配置也没有了,没有颜色了……
可以通过 strace 观察参数的传递
execve 不会返回,所以看不到 Hello, World!
于是可以 hack 一下 PATH
观察一下对 PATH 的解析
找不到汇编器了
_exit()
立即摧毁状态机
exit 的几种写法
exit(0)
-stdlib.h
中声明的 libc 函数- 会调用
atexit
- 会调用
_exit(0)
- glibc 的 syscall wrapper- 执行
exit_group
系统调用终止整个进程,包括其中的所有线程 - 不会调用
atexit
- 执行
syscall(SYS_exit, 0)
- 执行
exit
系统调用终止当前线程 - 不会调用
atexit
- 执行
RTFM
进程的地址空间
进程的地址空间
observation
和 readelf 里的信息互相验证
如何查看
pmap [pid]
/proc/[pid]/maps
三个例子
minimal.S
readelf 查看
使用 gdb 调试
starti
后键入 info inferiors
得到进程号
/proc/[pid]/maps
里有更详细的信息
format 可以 man 5 proc
查阅
注意这里的 vdso 和 vvar,后面会提到
- 静态链接
readelf
可以发现多了堆区和数据区
- 动态链接
readelf
可以发现多了 INTERP
和 DYNAMIC
段
starti
后
此时有 ld-2.33.so
执行到 main
处
已经加载好了 libc
一些 pathname 为空的部分是主程序和 libc 的 bss 段
可以在主程序开头添加 char arr[1 << 30];
summary
于是总结如下,进程的地址空间是若干连续的段
- 段的内存可以访问
- 不在段内/违反权限的内存访问触发
SIGSEGV
- gdb 可以越权访问,但不能访问不存在的地址(操作系统开了后门,这是伏笔)
vdso
只读的系统调用也许可以不陷入内核执行
关键思想 → 使用共享内存和内核通信
一个例子 time
时间内核维护秒级的时间,所有进程映射同一个页面
使用 gdb 调试
发现如下的指令
而这个内存区域位于 vvar 中
系统调用的实现
int 指令的代价太大,于是有了 syscall
SYSCALL — Fast System Call
进程的地址空间管理
之前观察到,libc 可以动态被加载
这需要如下的 APIs
管理进程地址空间的系统调用
注意 map 的参数 fd 和 offset
这代表可以把文件映射到进程地址空间
于是 ELF loader 用 mmap 非常容易实现
解析出要加载哪部分到内存,直接 mmap 就完了
两个例子
mmap-alloc.c
用 mmap 申请大量内存空间,瞬间完成
也许是标记
可以看到变化
pathname 为空,不知道是什么区域
mmap-disk.py
用 mmap 映射整个磁盘,瞬间完成
pip 后还是会报错
理论上会 dump 主引导扇区的前 512 字节
文件和内存的一致性问题 → msync(2)
地址空间的隔离
每个 *ptr
都只能访问本进程(状态机)的内存
- 除非 mmap 显示指定、映射共享文件或共享内存多线程
- 实现了操作系统最重要的功能:进程之间的隔离
但是我们有 gdb
游戏修改器
- 金山游侠
想象成是另一个进程内存的调试器
在进程的内存中找到重要属性并且改掉
关键代码
得到游戏的进程号,并以读写文件的方式访问其内存
找到对应的内存位置修改即可
2000 → 1700
- 按键精灵
给进程发送键盘/鼠标事件
做个驱动;或者利用操作系统/窗口管理器提供的 API
https://github.com/jordansissel/xdotool
- 变速齿轮
本质是欺骗进程的时钟
源头:闹钟、睡眠、gettimeofday
代码注入
- 透视
计算机图形学
软件热补丁
代码可以静态/动态/vtable/DLL… 注入
https://zhuanlan.zhihu.com/p/425845057
下面介绍动态代码注入
Dynamic Software Update, DSU
关键代码如下
一些变量的值
hooking 之前
hooking 之后
实际上修改 foo 开头的指令序列为
实际上跳转到 0x5555555551a9 + 0x25 + 0x5 = 0x5555555551d3
也就是 foo_new
跨页的情形需要多修改一个页面的权限
感觉有点像 attack lab
矛与盾
- 控制/数据流完整性
- 保护进程的完整性
- 保护隐私数据不被其他进程读写
- AI 监控/社会工程学:如果你强得不正常,当然要盯上你
- 云/沙盒渲染:计算不再信任操作系统
系统调用和 UNIX Shell
Shell
内核 Kernel 提供系统调用
Shell 封装操作系统 API,提供用户接口
Shell 是一门把用户指令翻译成系统调用的编程语言
RTFM → man sh
复刻经典
sh-xv6.c
- 零库函数依赖
-ffreestanding 编译
不链接库函数
从 _start
处开始执行
- ld 链接
可以作为最小 Linux 的 init 程序
在最小的 Linux 上测试
RTFSC
阅读之前提示
- 使用 gdb 调试
- 观察
sh-xv6.c
中的系统调用
分离 strace 的输出和 shell 的输出
strace.log
会动态的增加
更好的观察系统调用 → 将进程绑定 CPU 核心
https://www.jianshu.com/p/f59d7df06432
cd
为内置命令,不使用 fork + execve
例如
对应
对于其他命令
parsecmd
解析命令
注意解析命令时,使用 zalloc
为字符串分配空间
不使用 free,利用子进程返回后,OS 自动释放内存
然后使用 runcmd
运行命令
这里的 syscall
如下
通过 man syscall
查阅 ABI
下面分析 runcmd
函数
注意由于没有环境变量,需要 cd /bin
才能方便的运行一些 coreutils
将命令分为
- EXEC
为叶子节点,使用 SYS_execve
系统调用
例如
系统调用如下
经典 fork + execve
- REDIR
使用 SYS_open
系统调用
例如
系统调用如下
先关闭 stdout,然后打开 out 文件,使用最小可用的文件描述符,即为 1,相当于重定向到 stdout
然后执行子命令 minimal
- LIST
顺序执行命令
例如
系统调用如下
多一次 fork 来执行子命令 minimal
回顾:
cmd1; cmd2
先执行 cmd1,不管 cmd1 是否出错,接下来执行 cmd2
通过
$?
观察执行后返回的状态,0 表示没有错误,非 0 表示有错误
cmd1 && cmd2
只有当 cmd1 正确运行完毕后,才执行 cmd2
cmd1 || cmd2
只有当 cmd2 出错后,才执行 cmd2
- PIPE
管道
例如
系统调用如下
中间 trace 了很多 /bin/wc
的系统调用,直接研究源码
首先 pipe 一个管道,p[0]
是读端,p[1]
是写段
然后 fork 出一个子进程,关闭 stdout,将写段覆盖 stdout
最小可用的文件描述符
子进程关闭读端和写段
然后 fork 出一个子进程,关闭 stdin,将读端覆盖 stdin
最小可用的文件描述符
子进程关闭读端和写段
最后父进程关闭读端和写段
效果就是第一个子命令的 stdout 成为了第二个子命令的 stdin
- BACK
创建后台进程组
例如
系统调用如下
注意 exit 的位置
Traps and Pitfalls
- 操作的优先级
ls > a.txt | cat
不同的 shell 会有不同的响应,如 bash/zsh
- 文本数据责任自负
基于字符
有空格,后果自负
- 行为并不总是 intuitive
联系之前的源码
在执行 sudo ...
之前,就已经试图打开 /etc/a.txt
进行重定向
终端和 Job control
关于 Job control 可以参考 Shell Lab
一个例子
然后使用 fg %1
进入 Vim 界面
再键入 <C-z>
使其成为后台进程
这其中涉及到了信号机制
- SIGINT
<C-c>
来自键盘的中断,可以被捕获
- SIGQUIT
<C-\>
来自键盘的退出,可以被捕获
- SIGTSTP
<C-z>
来自终端的停止信号,可以被捕获
- SIGTERM
terminate 程序,kill 命令默认产生这个信号,可以被捕获
- SIGSTOP
不是来自终端的停止信号,不可以被捕获
- SIGKILL
杀死程序,不可以被捕获
使用 kill -l
观察所有信号
捕获信号的例子可以参考 signal-handler.c
注意 fork 出的子进程也会收到信号,因为在同一个终端中
更确切的说
- Ctrl-C 是终端设备发的信号,发给 foreground 进程组
- 所有 fork 出的进程,默认同一个 PGID,都会收到信号
于是需要引入终端、会话、进程组等概念
RTFM → setpgid/getpgid(2)
它解释了 process group, session, controlling terminal 之间的关系
终端是 UNIX 操作系统中一类非常特别的设备
每一次打开 terminal,默认前台运行着 bash shell
然后 shell 通过系统调用创建前台进程组和后台进程组
tmux 实际上在一块屏幕上模拟了多个终端
使用 tty 观察,结果是不一样的
甚至可以通过写入终端的方式进行输入
fork-printf
可以通过系统调用识别 tty 和管道
可以发现没有使用管道时多了一行系统调用
C 标准库的实现
Freestanding
可用的头文件
https://en.cppreference.com/w/c/language/conformance
发现了一些有意思的东西
iso646.h
and/or/not
https://en.cppreference.com/w/c/language/operator_alternative
这也是 C 程序
inttypes.h
printf
定宽类型
封装
memset
- 考虑编译器优化
parallel101 → 从汇编角度看编译器优化
矢量化 SIMD
- 考虑数据竞争
标准库只对标准库内部数据的线程安全性负责
如 printf
的 buffer
printf
- 可变参数列表
stdarg.h
- 文件描述符
FILE *
封装了文件描述符上的系统调用
使用 gdb 观察
fopen
后
注意 _fileno
fprintf
后
注意 _IO_buf_base
popen 和 pclose
封装 pipe
一个设计有缺陷的 API
Since a pipe is by definition unidirectional, the type
argument may specify only reading or writing, not both; the resulting stream is correspondingly read-only or write-only.
execve
在 M3 中只能使用 execve
,发现并不好用
比如 pathname 是不考虑环境变量的
另外 man execve
对于 #!
语法也给出了解释
我们需要高情商的 API
示例程序
- execv
envp
自动设置为调用进程的 environ
这一点并不是 execv 的特点,只是因为没有 e
后缀
如果有 e
后缀,就可以指定 envp
- execvp
执行 pathname 时考虑环境变量,就像 shell 一样
- execlp
使用可变参数列表代替 argv
注意最后的 NULL
- system
更方便的写法
注意 system 会返回
error
示例程序
三行均输出
注意 warn 不会 exit(status)
查看 errnum
另外注意 errno 是 thread-local 的,例证
预编译后
https://stackoverflow.com/questions/1694164/is-errno-thread-safe
还有一个 perror 也不会 exit(status)
输出为
通常包装起来使用
environ
实现 env 指令
观察 environ 是如何被赋值的
- 静态链接
- 动态链接
starti 后观察不到
到 _start
后
打 watchpoint 失效,始终为 (char **) 0x0
怀疑 libc 库没有调试信息
https://stackoverflow.com/questions/10000335/how-to-use-debug-version-of-libc
malloc 和 free
L1 实验指南
脱离 workload 做优化就是耍流氓
https://www.microsoft.com/en-us/research/uploads/prod/2019/06/mimalloc-tr-v1.pdf
设置两套系统:
- fast path
- 性能极好、并行度极高、覆盖大部分情况
- 但有小概率会失败 → fall back to slow path
- Segregated List (Slab)
- slow path
- 不在乎那么快,但把困难的事情做好
- 计算机系统里有很多这样的例子,比如 cache
- Buddy system
RTFM
推荐 → https://www.gnu.org/software/libc/manual/html_node/Pattern-Matching.html
A fork() in the Road
fork() 行为的补充解释
offset
共享文件描述符的 offset
RTFM: write(2), BUGS section
另外 dup 的两个文件描述符也是共享 offset
copy-on-write
概念上状态机被复制,但实际上复制后内存都被共享
被复制后,整个地址空间都被标记为只读
当写页面时,操作系统捕获 Page Fault,并酌情复制页面
操作系统会维护每个页面的引用计数
证明 -> cow-test.c
推论 -> 统计进程占用的内存是个伪命题
状态机、fork() 和魔法
搜索并行化
加速状态空间搜索
每次探索都 fork 一个新进程
不需要回溯,直接 exit 即可
跳过初始化
初始化代价很大
相当于备份了初始化的状态
实际应用
- Zygote Process (Android)
- Java Virtual Machine 初始化涉及大量的类加载
- 一次加载,全员使用
- App 使用的系统资源
- 基础类库
- libc
- …
- Chrome site isolation (Chrome)
- Fork server (AFL)
备份和容错
虚拟机快照 yyds
主进程 crash 了,启动快照重新执行
有些 bug 可能调整一下环境就消失了,比如并发
POSIX Spawn
如果只有内存和文件描述符,fork + execve
是十分优雅的方案
但是我们还有
- 信号
信号处理程序,操作系统负责维护
- 线程
Linux 为线程提供了 clone 系统调用
- 进程间通信对象
- ……
于是 fork 的设计越来越复杂
这篇论文 https://www.microsoft.com/en-us/research/uploads/prod/2019/04/fork-hotos19.pdf 罗列了 fork 的罪行
我们有新设计的 API
参数
pid
: 返回的进程号path
: 程序,重置的状态机file_actions
: open, close, dupattrp
: 信号、进程组等信息argv
,envp
: 同execve
手册中 https://man7.org/linux/man-pages/man3/posix_spawn.3.html 给出了一个例子
可执行文件
目前只考虑静态链接
RTFM
http://jyywiki.cn/pages/OS/manuals/sysv-abi.pdf
https://refspecs.linuxbase.org/
状态机的描述
一个描述了状态机的初始状态 + 迁移的数据结构
经典的 模型
操作系统上的可执行文件
execve
hack a.c
hack a.out
RTFM -> execve ERRORS
She-bang
以 #!
开头
运行
注意参数的顺序
Hello World 视为一个参数
解析可执行文件
- 生成可执行文件
- ld (linker), as (assembler)
- ar, ranlib
- 分析可执行文件 - 静态
- objcopy/objdump/readelf
- addr2line, size, nm
- 分析可执行文件 - 动态
- gdb
调试信息
编译器
调试信息完成了不完美的逆变换(考虑到编译优化)
- 定义了一个 Turing Complete 的指令集
DW_OP_XXX
- 可以执行任意计算将当前机器状态映射回 C
观察
- 汇编
使用 -g -S
选项
- 目标文件
使用 -g -c
选项
- 可执行文件
使用 -g
选项
其中 401645
为 popcount
函数首地址
栈回溯信息
考虑 x86 函数调用的栈帧结构
几乎所有的函数开头都是
考虑到函数调用,有
即栈中存在关系
所以可以利用 rbp
进行栈回溯
结果比较粗糙,还会 segfault
需要使用 -fno-omit-frame-pointer
选项
逆向工程
- 调试信息是绝对不可能了
- 连符号表都没有
stripped
- 看起来就是一串指令序列
重定位
相对地址
反汇编 main.o
重定位有 assertion
可以显示写入 hello.c 中
与指令密切相关
所以可重定位目标文件是部分状态机的容器
assertion 存在于 ELF 文件中
重填 32-bit value 为 S + A - P
整个编译工具链
编译器 (gcc)
- High-level semantics C 状态机 → low-level semantics 汇编
汇编器 (as)
- Low-level semantics → Binary semantics 状态机容器
- 一一对应地翻译成二进制代码
- sections, symbols, debug info
- 不能决定的要留下之后怎么办的信息
- relocations
- 一一对应地翻译成二进制代码
链接器 (ld)
- 合并所有容器,得到一个完整的状态机
- ldscript (
-Wl,--verbose
); 和 C Runtime Objects (CRT) 链接 - missing/duplicate symbol 会出错
- ldscript (
ELF 的细节
ELF 就是一个容器数据结构,包含了必要的信息
完全可以试着自己定义二进制文件格式
收敛到 ELF + 理解 FM
可执行文件的加载
静态 ELF 加载器
- 解析数据结构
/usr/include/elf.h
结构体已经定义好了
- 复制到内存
使用 mmap
,在 loader 的地址空间中映射
祭出 PA 的图
- 创建进程运行时初始状态
System V ABI Figure 3.9 Initial Process Stack
宏技巧
那么
即
- 跳转
实际上,我们使用 read + mmap + close
系统调用实现了 execve
系统调用
也就是说 OS 只需要加载 loader 一个程序,其余的程序让 loader 加载即可
即其余的程序的初始状态处为 loader 的代码
将加载的代码从内核态转移到用户态
Boot Block Loader
abstract-machine/am/src/x86/qemu/boot/main.c
详见 操作系统的状态机模型
机制完全一致,只不过从 mmap
变成了显式操纵磁盘和内存
Linux Kernel ELF Loader
直接下载最新的稳定版本
https://mirrors.nju.edu.cn/kernel/v5.x/linux-5.17.3.tar.gz
解压缩后
瞬间发现 ccache 的好处
然后用 bzImage 替换 linux-minimal 中的 vmlinuz
似乎配置时关闭 Networking support 就没问题了
使用现代的工具 vscode + compile_commands
- 生成 compile_commands.json 文件
- https://stackoverflow.com/questions/59324820/linux-kernel-generate-compile-commands-json-for-module
动态链接和加载
核心 -> 查表
设计一个新的二进制文件格式 .dl
用最小代价为 .dl
文件配齐全套工具链
- 生成,开局一条狗,出门全靠偷
- 假设编译器可以生成位置无关代码 (PIC)
- as = GNU as
- ld = objcopy
- 分析,自己写
- readdl (readelf)
- objdump
- 加载,自己写
- loader
示例 main.S
gcc -E
输出为
as + objcopy -> main.dl
文件格式如下
下面分析 dlbox.c
- 生成
as + objcopy
仅拷贝代码到二进制文件的 code 位置
- 分析
objcopy -> 解析文件之后,遍历符号表 symtab,根据类型输出即可
objdump -> 解析文件之后,使用 ndisasm 解析代码段
- 加载
解析文件之后,遍历符号表找到 main,然后跳转即可
所以关键在于解析二进制文件,即 dlopen
的实现
遍历符号表 symtab,根据类型进行处理
dlload
会递归加载 lib 到全局的libs
中dlexport
则导出符号到全局的符号表syms
dlsym
根据全局的syms
进行地址的重填,填上正确的偏移量
举例来说
main.dl
加载了libc.dl
libc.dl
中导出了符号putchar
和exit
main.dl
接着加载libhello.dl
libhello.dl
中导入了putchar
,在相应的位置重填上正确的偏移量,并且导出了符号hello
main.dl
导入了符号hello
,进行地址重填,并导出符号main
作为起始地址
dl 文件的设计缺陷
存储保护和加载位置
- 允许将 .dl 中的一部分以某个指定的权限映射到内存的某个位置
- program header table
允许自由指定加载器,而不是 dlbox
- 加入 INTERP
空间浪费
- 字符串存储在常量池,统一通过指针访问
DSYM 是间接内存访问
一种写法,两种情况
- 来自其他编译单元
- 直接 PC 相对跳转即可
- 否则性能太低
- 动态链接库
- 必须查表
为了统一两种情况,提升性能,诞生了 Procedure Linkage Table (PLT)
而上文的符号表便是 Global Offset Table (GOT)
数据
-
stdout/errno/environ 的麻烦
-
多个库都会用,但应该只有一个副本
-
特殊对待,示例如下
对于程序
动态链接后查看 ELF 文件的 .rela.dyn
段
另外,对于 errno,为了保证线程安全
使用宏将 errno 替换成函数 __errno_location
,在 .rela.plt
段中
可以参考 https://blog.csdn.net/lidan113lidan/article/details/119901186
xv6 代码导读
RTFM
xv6: A simple, Unix-like teaching operating system
RTFSC
https://github.com/mit-pdos/xv6-riscv
实操
参考
https://pdos.csail.mit.edu/6.828/2020/xv6.html
https://pdos.csail.mit.edu/6.828/2020/tools.html
安装
之后
即可
一些细节
单核
为了方便调试,修改 Makefile 中 CPUS
为 1
qemu 的一些快捷键
切换到控制台 <C-a-c>
退出 <C-a-x>
tmux 中枪
配置 vscode
使用 bear 生成 compile_commands.json
然后进入 vscode,会提示生成 .vscode/c_cpp_properties.json
对于 OS 也是同理,只不过是在 kernel
文件夹内生成 compile_commands.json
,然后使用 vscode 打开根目录
发现 vscode 和 vim 都不报错了……
gdb 调试
vscode 调试
参考
https://www.cnblogs.com/KatyuMarisaBlog/p/13727565.html
https://code.visualstudio.com/docs/cpp/launch-json-reference
建立 .vscode/launch.json
注意需要注释掉 target remote 127.0.0.1:26000
构建过程
- 内核代码
- initcode
initcode.o -> initcode.out -> initcode -> initcode.asm
- 用户代码
可以发现,对于一个用户程序,有相关的一系列文件
xxx.c 源代码
xxx.o 可重定位目标文件
xxx.asm 反汇编代码
xxx.sym 符号表
xxx.d 包含关系,尚不知如何生成
_xxx 实际文件系统中的程序
- 文件系统
参考
https://hitsz-lab.gitee.io/os-labs-2021/remote_env_gdb/
上下文切换
xv6 中的进程
静态视角
gcc/ld 创建代码、数据,参考 ldscript
动态视角
- 寄存器
通用寄存器 + $pc
- 内存
$satp
配置出的地址空间
QEMU 使用 info mem 查看
- 持有的操作系统对象
不可见
文件描述符
另外在 xv6 中键入 <C-p>
似乎可以打印进程
感觉不是 qemu 的功能
调试内核代码
kernel/main.c
中 main
函数调用 kernel/proc.c
中的 userinit
函数
其中会分配页面,并将 initcode 的内容拷贝进来
实际上就是虚拟地址的 0x0 处,打断点观察
此时的地址空间如下
其中
0000003ffffff000
处为trampoline
0000003fffffe000
处为trapframe
用户进程不可见
来到 ecall 指令处
打印 $stvec
也就是中断处理程序 trampoline
的首地址,打上断点
可以发现指令就是 kernel/trampoline.S
里面的内容
此时打印 $sscratch
也就是 trapframe
的地址
trampoline.S
通过这个地址保存寄存器现场,包括 $satp
到 trapframe
结构体中
也就是整个进程的状态被封存了起来
trapframe
结构体的定义位于 kernel/proc.h
中
trampoline.S
最后会切换 $satp
到内核的地址空间
注意大部分映射都是恒等映射
并跳转到 usertrap
函数,位于 kernel/trap.c
中
其中会调用 syscall
函数
通过系统调用 exec 执行用户程序 _init
而 user/init.c
则会打开 console,并 fork 出进程执行 _sh
程序
关键在于,操作系统可以通过 struct proc
完全控制进程的状态
trapframe
结构体是 struct proc
的一部分
也就是说,操作系统修改任何一个状态机,例如,执行系统调用
也可以将任何另一个状态机调度到处理器上
这便实现了处理器虚拟化上下文切换的机制
状态的封存:体系结构相关的处理
x86-64
- 中断/异常会伴随堆栈切换
- 中断前的寄存器保存在堆栈上
- 中断处理程序非常好写,指令实现很复杂
xv6
- 把进程的 trap frame 分配到固定的虚拟地址,保存在
$sscratch
中 - 保存完毕后切换到内核线程执行,包括堆栈切换
- 中断处理程序稍微复杂一点,指令实现很简单
TODO
- 同时调试内核代码和用户代码
vscode 只能写两个配置
或者显式的切换
gdb 加入符号文件后
直接打断点
<optimized out>
vscode 和 gdb 都有这个问题
怀疑是 riscv64-linux-gnu-gdb
的问题
于是考虑安装 riscv64-unknown-elf-gdb
还需要 libpython3.8.so.1.0
然后报错
https://github.com/riscv-collab/riscv-gnu-toolchain/issues/935
似乎是编译器的问题,尝试重新编译
暂时放弃
处理器调度
理想的世界
简化假设
- 单 CPU
- 只有两种进程 - 计算密集 / IO 密集
- 进程之间没有协作 - 共享资源
Round-Robin
问题是 IO 密集的进程会频繁让出 CPU
于是 Vim 疯狂卡顿
如果引入 Producer 和 Consumer,同样会因为信号量而频繁让出 CPU
于是可以手动设置优先级
UNIX niceness
-20 .. 19
的整数,越 nice 越让别人得到 CPU
-20
most favorable to the process19
least favorable to the process
坏人躺下好人才能上
例如
-c
代表绑定到某个 CPU 上运行
MLFQ
然而手动设置实在过于麻烦
于是引入动态优先级
设置若干个 Round-Robin 队列,每个队列对应一个优先级
动态优先级调整策略
- 优先调度高优先级队列
- 用完时间片 → 坏人
- 让出 CPU IO → 好人
然而计算密集的进程可以主动让出 CPU 假装成好人
并且在这种策略下,Producer/Consumer 会获得最高优先级,while (1)
会完全饥饿
于是需要定期把所有人优先级拉平
CFS
让系统里的所有进程尽可能公平地共享处理器
- 为每个进程记录精确的运行时间
- 中断/异常发生后,切换到运行时间最少的进程执行
为了实现优先级,可以设置每个进程 vruntime
好人的钟快一些,坏人的钟慢一些
一些细节和问题
- fork 出的新进程应继承父进程的 vruntime
- I/O 以后回来 vruntime 严重落后,为了赶上,CPU 会全部归它所有
- vruntime 整数溢出,参考 CS144
wrapping_integers.hh
现实的世界
优先级翻转
在实时任务操作系统中,低优先级的任务和高优先级的任务存在共享资源
高优先级执行完了,才能是低优先级
一旦低优先级的任务在持有互斥锁的时候被赶下了处理器,高优先级的任务就和低优先级一样了
多处理器调度
- 迁移
迁移?在处理器之间迁移会导致 cache/TLB 全都白给
不迁移?线程退出,瞬间处理器开始围观
- 多用户、多任务
A 和 B 使用同一个服务器
A 要跑一个任务,因为要调用一个库,只能单线程跑
B 跑并行的任务,创建 1000 个线程跑
B 获得几乎 100% 的 CPU
对策 Linux Namespaces Control Groups (cgroups)
namespaces (7), cgroups (7)
轻量级虚拟化,创造操作系统中的操作系统
- Big.LITTLE/能效比
调度器还需要了解 CPU 之间的差异
- Non-Uniform Memory Access
Producer/Consumer 位于同一个/不同 module 性能差距可能很大
分配了 1/2 的处理器资源,反而速度更快了
调度
- 建模
- 理解和总结过去发生了什么
- profiling 和 trace; PMU
- 预测
- 试图预知未来可能发生什么
- 决策
- 应该如何调整系统行为
操作系统不完全背这个锅,让程序提供 scheduling hints
操作系统设计
操作系统设计:一组对象 + 访问对象的 API
操作系统实现:一个 C 程序实现上面的设计
操作系统到底应该提供什么对象和 API
可以大而全 (Linux/Windows API)
The Open Group Base Specifications Issue 7 (2018 Ed.)
API 意味着可以互相模拟
- Windows Subsystem for Linux (WSL)
- Linux Subsystem for Windows (Wine)
可以只有最少的硬件抽象 (Microkernel)
把尽可能多的功能都用普通进程实现
失效隔离在进程级
比如之前提过可以将加载的代码从内核态转移到用户态
只把不能放在用户态的东西留在内核里
- 状态机
- 状态机之间的协作机制 - 进程间通信
- 权限管理
赋予进程最少的权限,就能降低错误带来的影响
只需要 send 和 receive 两个系统调用
主要用来实现 RPC (remote procedure call)
例子
- Minix
- seL4 - Whitepaper
可以没有用户态 (Unikernel)
我们有虚拟机 - 硬件虚拟化
直接让 Lab2 跑应用程序
应用代码直接和 klib, AbstractMachine, Lab 代码静态链接
任何操作 (包括 I/O) 都可以直接做
系统调用直接变成普通的函数调用
极限速通操作系统实验
一把大锁保平安
直接使用
需要考虑嵌套使用 atomic
由于只有一把锁,只需要在第一次调用 lock 时上锁,最后一次调用 unlock 时解锁即可
software engineering
two versions
- functional - model - naive correct
- performance - buggy