操作系统-操作系统概述
学习这么课出于很多动机:一个是本来就对内核部分的代码比较感兴趣,第二个是自己操作系统学的不是很扎实,另外一直mark了jyy的这门课挺久了,于是就拿摸鱼时间来学学。老师的 B 站主页是 绿导师
课程要求我们有一个可用的Linux系统,我使用的是 wsl2
笔记
理解操作系统中的三条主线:软件、硬件和操作系统。操作系统是管理硬件和软件的系统级别的软件。想要理解操作系统需要先理解软件和硬件。
先引入一个一个案例:怎么构造最小的 hello world 程序。用 gcc 编译他,虽然程序很简单,但是编译出来的文件其实不小。
GCC 编译有四个阶段,预编译,编译,汇编和链接。
.c
.i
.s
.o
a.out
常见的命令选项包括:
- 预编译:
-E
- 编译:
-S
- 编译和汇编
-c
链接器可以使用ld
当我们使用 printf
的时候需要 #include<stdio.h>
,这类宏其实是复制了大量的 libc
的代码,因此我们想要去掉这个 include
的宏,同时去掉 printf
。然后老师提到了一个问题,状态机是没法停下来的,计算机是怎么停下来的呢?这里引出了系统调用的概念,系统调用相当于能够直接接管程序,获取程序的控制权甚至终止程序。
因此我们想不引入 stdio.h
的情况下使用标准输出的话,我们可以使用系统调用的方式。
#include<sys/syscall.h>
.global _start
_start:
movq $SYS_write, %rax
movq $1, %rdi
movq $st, %rsi
movq $(ed - st), %rdx
syscall
movq $SYS_exit, %rax
movq $1, %rdi
syscall
st:
.ascii "\033[01;31mHello, OS World\033[0m\n"
ed:
然后依次执行
gcc -c mini.S
ld mini.o
./a.out
状态机的模型是很重要的,系统的内存和寄存器相当于状态,所有的指令做的都相当于做计算。高级语言的代码执行也是一个状态机,老师举了一个非递归汉诺塔的例子,还是挺深刻的。想写出非递归的代码,首先要理解递归汉诺塔中的函数调用在汇编中或者说在计算机系统中是怎么执行的。
typedef struct {
// 当前执行的语句
int pc;
// 下面的存储的是函数参数
int n;
char from, to, via;
} Frame;
#define call(...) ({ *(++top) = (Frame){ .pc = 0, __VA_ARGS__ };})
#define ret() ({ top--; })
#define goto(loc) ({ f->pc = (loc) - 1; })
void hanoi(int n, char from, char to, char via) {
Frame stk[64]; // 递归栈
Frame *top = stk - 1; // 指向栈顶的指针, esp
call (n, from, to, via);
// 每次处理当前递归栈栈顶的函数帧
// 这个循环很巧明,他是即遍历了递归栈,又对当前调用的函数的每一个语句进行了调用
// 同一次函数调用,pc 正常来说是逐渐 pc++,或者是 goto
for (Frame *f; (f = top) >= stk; f->pc++) {
n = f->n; from = f->from; to = f->to; via = f->via;
switch(f->pc) {
case 0: if (n == 1) { printf("%c -> %c\n", from, to); goto(4);} break;
case 1: call(n - 1, from, via, to); break;
case 2: call( 1, from, to, via); break;
case 3: call(n - 1, via, to, from); break;
case 4: ret(); break;
default: assert(0);
}
}
}
原始汉诺塔代码和上述代码的输出可以使用 diff -s
来进行比较。
上述代码的核心部分自然是调用栈,每个栈帧都一个 pc,然后每次 push 栈帧时,就是把 pc=0,然后参数也加进去。而函数的执行是取当前栈帧的 pc 对应的语句去执行。
代码的执行也是一个状态机的概念,状态相当于是堆栈,代码执行的初始状态即入口函数的第一条指令,状态迁移即执行一条语句。
可以这样定义:
状态:
- Stack frame 的列表(调用栈,栈中每个元素是函数帧),全局变量
初始状态:
- frame 为
main(argc, argv)
状态转移:
- 执行 frames.top.PC 处的语句
- 函数调用 = push frame, frame.PC = 函数入口
- 函数返回 = pop frame
因此编译器其实是将两个状态机做转换的过程。
操作系统中程序的的一生:
- 被操作系统加载
被另一个进程执行 execve 设置初始状态 - 状态机管理
进程管理,文件/设备管理、存储管理 - 调用 _exit(exit_group) 退出
回到刚才的例子,我们可以用 strace 工具来辅助我们实验
strace 是一个非常重要的命令行工具,帮助我们 “观测” 应用程序和操作系统的边界。实际上,任何程序的执行就是状态机在计算机上的运行,因此 “用合适的方式观测状态机执行” 就是我们理解程序的根本方法。
课后作业
- 写非递归的形式
int f(int n) {
if (n <= 1) return 1;
return f(n - 1) + g(n - 2);
}
int g(int n) {
if (n <= 1) return 1;
return f(n + 1) + g(n - 1);
}
我在 gdb 调试中用到的指令包括
指令 | 作用 |
---|---|
l |
打印附近的代码 |
p |
打印变量 |
b |
添加断点 |
start |
开始执行,在 main 处停止 |
c |
continue |
s |
step in |
n |
step next |
where |
查看当前执行位置 |
info b |
查看断点信息 |
delte |
删除断点 |
b <num> if |
条件断点 |
tb |
临时断点 |
我的代码是
typedef struct
{
int pc;
int n;
int retf, retg;
} Frame;
int EAX;
#define callf(...) ({ *(++top) = (Frame){.pc = 0, __VA_ARGS__}; })
#define callg(...) ({ *(++top) = (Frame){.pc = 6, __VA_ARGS__}; })
#define ret(n) ({ top--; EAX = n; })
#define goto(loc) ({ f->pc = (loc)-1; })
int fg(int n, char func)
{
Frame stk[64], *top = stk - 1;
if (func == 'f') callf(n);
else if (func == 'g') callg(n);
else assert(0);
for (Frame *f; (f = top) >= stk; f->pc++)
{
n = f->n;
switch (f->pc)
{
case 0:
if (n <= 1) ret(1);
break;
case 1:
callf(n - 1);
break;
case 2:
f->retf = EAX;
break;
case 3:
callg(n - 2);
break;
case 4:
f->retg = EAX;
break;
case 5:
ret(f->retf + f->retg);
break;
case 6:
if (n <= 1) ret(1);
break;
case 7:
callf(n + 1);
break;
case 8:
f->retf = EAX;
break;
case 9:
callg(n - 1);
break;
case 10:
f->retg = EAX;
break;
case 11:
ret(f->retf + f->retg);
break;
default:
assert(0);
}
}
return EAX;
}