参考链接

操作系统:设计与实现 (2024 春季学期) (jyywiki.cn)

应用视角下的操作系统

构造一个最小程序

要理解操作系统首先要理解程序,下面是一个最简单的程序:

1
2
3
4
int main()
{
printf("Hello world!");
}

当生成输出文件之后,可以使用以下的命令来查看生成的可执行文件

1
2
3
4
objdump 工具可以查看对应的汇编代码
gcc --verbose 可以查看所有编译选项 (真不少)printf 变成了 puts@plt
gcc -Wl,--verbose 可以查看所有链接选项 (真不少)原来链接了那么多东西还解释了 end 符号的由来
gcc -static 会链接 libc (大量的代码)

当使用以上工具查看生成的main的可执行文件时,发现可执行文件内容较多。编译链接的过程会链接库文件导致可执行文件变大。

那么我们可以尝试着手动的去链接可定位文件,可以先生成一个main.o文件,然后将这个mian.o试着手动链接编译的文件

  1. 直接用 ld 链接失败:因为ld 不知道怎么链接 printf

  2. 所以只能不调用 printf ,将程序改为以下内容

    1
    2
    3
    int main()
    {
    }

    运行时出现 Segmentation Fault ,调试时发现在return时出现错误

  3. 如果改成以下代码

    1
    2
    3
    4
    int main()
    {
    while(1);
    >}

    运行时会卡在死循环中,说明我们的程序时可以运行的

[!NOTE]

为什么会出现Segmention falut

  1. 当函数在renturn时,程序会从内存的rsp寄存器中取出地址给PC,作为下一条执行指令的地址,然后rsp+8,栈(rsp存在栈中)是向下生长的

  2. 当执行完错误的return语句之后,PC的地址变为了1

    这意味着在执行return之前,内存中的rsp指向运行代码的地址,也就是上面的1,所以出现了问题

程序

程序实际上是一个状态机:

1
2
3
4
5
struct CPUState {
uint32_t regs[32], csrs[CSR_COUNT];
uint8_t *mem;
uint32_t mem_offset, mem_size;
};

处理器:无情的、执行指令的状态机

  • 从 𝑀[𝑃𝐶]中取出一条指令
  • 执行它
  • 循环往复

程度的退出

程序自己是不能 “停下来” 的!

  • 指令集里==没有一条关闭计算机的指令==,那么操作系统是如何在关闭所有软件后,切断计算机的电源的?

只能借助操作系统

1
2
3
movq $SYS_exit,  %rax   # exit( 
movq $1, %rdi # status=1
syscall # );
  • 把 “系统调用” 的参数放到寄存器中
  • 执行==syscall==,操作系统接管程序
    • 操作系统可以任意改变程序状态 (甚至终止程序)

[!TIP]

syscall是一条特殊指令,由操作系统提供。

main函数退出时,可以使用syscall指令告诉操作系统,程序即将退出,然后等待syscall的回应,此时一个系统调用就完成了。

[!TIP]

在操作系统之下是电路,实际上在下面的电路也可以看成有一个小型的操作系统,当上层操作系统向底层的操作系统发送指令之后(通过ACPI(高级配置和电源接口)进行交互),底层的操作系统会向它所管理的元器件发送高低电平,从而达到关机的效果

此时就可以构造最小的程序

==mininal.S==

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include // The x86-64 system call Application Binary Interface (ABI): 
// System call number: RAX
// Arguments: RDI, RSI, RDX, RCX, R8, R9
// Return value: RAX
// See also: syscall(2) syscalls(2)
#define syscall3(id, a1, a2, a3) \
movq $SYS_##id, %rax; \
movq $a1, %rdi; \
movq $a2, %rsi; \
movq $a3, %rdx; \
syscall
#define syscall2(id, a1, a2) syscall3(id, a1, a2, 0)
#define syscall1(id, a1) syscall2(id, a1, 0)
.globl _start
_start:
syscall3(write, 1, addr1, addr2 - addr1)
syscall1(exit, 1)
addr1:
.ascii "\033[01;31mHello, OS World\033[0m\n"
addr2:

使用以下指令运行上面的汇编程序

1
2
3
4
5
gcc -g -S minimal.S minimal.s
as minimal.s -o minimal.o
ld -o minimal minimal.o
./minimal
echo $status #使用该行指令打印出RAX中的值

程序 = 状态机

==Everything(二进制文件)= 状态机==

状态

  • gdb 内可见的内存和寄存器

初始状态

  • 由 ABI 规定 (例如有一个合法的 %rsp)

状态迁移

  • 执行一条指令
    • gdb 可以单步观察状态机的执行
  • syscall 指令: 将状态机 “完全交给” 操作系统

[!IMPORTANT]

  • 状态 = 变量数值 + 栈
  • 初始状态 = main 的第一条语句
  • 状态迁移 = 执行一条语句中的一小步

拆解程序

操作系统上有很多不同种类的程序

  1. Core Utilities (coreutils)

    • Standard programs for text and file manipulation

    • 系统中默认安装的是 GNU Coreutils

  2. 系统/工具程序

    • bash,binutils, apt, ip, ssh, vim, tmux, gcc, python, ffmpeg, …

      • 原理不复杂 (例如 apt 是 dpkg 的套壳),但琐碎
    • All-in-one 工具合集:busybox, toybox

  3. 其他各种应用程序

    • Vscode、OBS-Studio、浏览器、音乐播放器:它们在各种工具程序基础上建立起来 (例:ffmpeg)

追踪一个程序

工具程序代表:编译器 (gcc):

1
strace -f gcc a.c  #(gcc 会启动其他进程)
  • 可以管道给编辑器 vim -

  • 编辑器里还可以

    1
    %!grep
    • 对于开发者来说,工具的组合是非常重要的

图形界面程序代表:编辑器 (xedit):

1
strace xedit
  • 图形界面程序和 X-Window 服务器按照 X11 协议通信
  • 虚拟机中的 xedit 将 X11 命令通过 ssh (X11 forwarding) 转发到 Host

任何程序 = minimal.S = 状态机

程序在操作系统上的执行过程:

  1. 总是从被操作系统加载开始
  • 通过另一个进程执行 execve 设置为初始状态
  1. 经历状态机执行 (计算 + syscalls)
  • 进程管理:fork, execve, exit, …
  • 文件/设备管理:open, close, read, write, …
  • 存储管理:mmap, brk, …
  1. 最终调用 _exit (exit_group) 退出

==应用程序 = 计算 + 操作系统 API==

  1. 窗口管理器
  • 能直接管理屏幕设备 (read/write/mmap)

    • 能画一个点,理论上就能画任何东西
  • 能够和其他进程通信 (send, recv)

  1. 任务管理器
  • 能访问操作系统提供的进程对象 (M1 - pstree)
  1. 杀毒软件
  • 文件静态扫描 (read)、主动防御 (ptrace)

==操作系统的职责:提供令应用程序舒适的抽象 (对象 + API)==

编译器

既然说 “任何程序” 都和 minimal.S 是一样的

  • 为什么我们没有在 C 代码里看到系统调用?
  • C 代码是如何变成二进制文件的?
  • 到底编译器什么优化能做、什么优化不能做?

如果说程序就是一个状态机,那么如何写一个C语言代码的解释器?

在写C语言的解释器的时候,我们首先需要将C代码简化,将C 代码改写成 SimpleC,将C转化为SimpleC需要遵循以下规则:

  • 成每条语句至多一次运算 (函数调用也是运算)
  • 条件语句中不包含运算
  • (暂时假设没有指针和内存分配)

Everything (C 程序) = 状态机

  • 状态 = 变量数值 + 栈
  • 初始状态 = main 的第一条语句
  • 状态迁移 = 执行一条语句中的一小步

状态机

“状态机” 是拥有严格数学定义的对象。这意味着你可以把定义写出来,并且用数学严格的方法理解它 —— 形式化方法

状态:

  • [StackFrame, StackFrame, …] + 全局变量

    实际上一个状态就是一个栈帧

初始状态:

  • 仅有一个 StackFrame(main, argc, argv, PC=0)
  • 全局变量全部为初始值
  • 每个栈帧中都有一个PC值,用来保存当前的执行指针

状态迁移:

  • 执行 frames[-1].PC 处的简单语句

什么是编译器

编译器的输入

  • 高级语言 © 代码 = 状态机

编译器的输出

  • 汇编代码 (指令序列) = 状态机

编译器 = 状态机之间的翻译器

SimpleC的直接翻译

运算:

  • 把操作数 load 到寄存器、执行运算、store 写回结果

分支/循环:

  • 使用条件跳转分别执行代码

函数调用:

  • 专门留一个寄存器给栈 (SP, Stack Pointer)
  • 将 Stack frame 的信息保存在内存里
    • 通过 SP 可以访问当前栈帧的变量、返回地址等

所以,==C 被称为高级汇编语言==

  • 存在 C 代码到指令集的直接对应关系
    • 状态机和迁移都可以 “直译”
    • 于是计算机系统里多了一个抽象层 (“一生二、二生三、三生万物”)
  • 更 “高级” 的语言就很难了
    • C++ virtual void foo();
    • Python [1, 2, 3, *rest]
    • Javascript await fetch(...)

编译优化

C 语言编译器在进行代码优化时,遵循的基本准则是在不改变程序的==语义== (即程序的行为和输出结果) 的前提下,提高程序的执行效率和/或减少程序的资源消耗

1
2
3
4
5
int foo(int x)
{
int y = x + 1;
return y - 1;
}

一些 “不改变语义” 的例子 (编译优化中最重要的 “三板斧”):

  • 函数内联:将函数调用替换为函数体本身的内容
  • 常量传播:在编译时计算常量表达式的值并替换
  • 死代码消除:删除永远不会被执行到的代码

例子


==编译优化前==

1
2
3
4
5
int compute(int x)
{
int y = x + 1;
return y - 1;
}

==编译优化前==

1
2
3
4
5
6
7
8
9
10
11
12
int compute(int x)
{
if(true)
{
x = 1;
}
else
{

}
return x;
}

==编译优化后==

1
2
3
4
int compute(int x)
{
return x;
}

==编译优化后==

1
2
3
4
5
6
7
8
9
10
11
12
int compute(int x)
{
//if(true) 死代码消除
//{
// x = 1; 常量传播
//}
//else 死代码消除
//{
//}
//return x;
return 1; //常量传播
}

编译的正确性

系统调用是使程序计算结果可见的唯一方法

  • 不改变语义 = 不改变可见结果
  • 状态机的视角:满足C/汇编状态机生成的所有 syscall 序列完全一致,任何优化都是允许的

C 代码中的不可优化部分

  • External function calls (链接时才能确定到底是什么代码)
    • 未知的代码可能包含系统调用
    • 因此不可删除、移出循环等,且要保证参数传递完全一致
  • 编译器提供的 “不可优化” 标注
    • volatile [load | store | inline assembly]

硬件视角下的操作系统

reset之后执行firmware