跳转至

📔 Lecture01&02 概述和操作系统上的程序 学习笔记

1. 什么是操作系统?

摘抄自OSTEP中的定义:

Operating System: A body of software, in fact, that is responsible for making it easy to run programs (even allowing you to seemingly run many at the same time), allowing programs to share memory, enabling programs to interact with devices, and other fun stuff like that. (OSTEP)

不管是在什么角度,对于操作系统的定义本身就是模糊的,没有什么明确的定义。

“精准”的定义毫无定义。

  • 问出正确的问题:操作系统如何从一开始到现在的OS?
  • 主要关注三个重要的点:
    • 计算机(硬件)
    • 程序(软件)
    • 操作系统(管理软件的软件)

对于课程来说,我们讨论的定义是狭义的操作系统。

  • 对单一计算机硬件系统作出抽象、支撑程序执行的软件系统。
  • 学术界谈论“操作系统” 是更为广义的“System”的概念。
    • 比如OSDI
    • SOSP

2. 计算机OS的发展史

  • 1940s的计算机

    • 跨时代、非凡的天才设计,此时计算机相对比较简单。
      • 计算机系统 = 状态机
      • 标准的Mealy型数字电路
        • ENIAC(1946.2.14)
    • 电子计算机的出现
      • 逻辑门:真空电子管
      • 存储器:延迟线
      • 输入/输出:打孔纸带/指示灯
  • 1940s的程序

    • ENIAC程序使用物理线路 “Hard-wire”
    • 早期的程序,如果重编程,就需要重新连接线路。
    • 早期的程序主要用于解决:打印平方数、素数表、计算导弹的弹道等。

    在1940s不存在所谓的OS的概念,对于程序来说,直接使用指令的方式操作硬件。

  • 1950s的计算机

    • 更快更小的逻辑门、更大的内存(磁芯)、丰富的I/O设备。
    • 晶体管的出现,使得逻辑门电路实现的电路板更小。
    • I/O设备的速度已经远低于处理器的速度,在1953年,中断机制开始出现。
  • 1950s的程序

    • 可以去执行更为复杂的任务,包括通用的计算任务。
    • 此时也出现更多的API,可以直接通过调用API去执行任务,而无需直接访问设备。
    • 1957年时, Fortran语言 开始诞生。
  • 1950s的操作系统

    • 管理多个程序依次排队运行的库函数和调度器。
    • 此时的计算非常昂贵,所以一般是军工或者研究院才拥有。由此产生了集中管理计算机的需求:多用户排队共享计算机。
    • 操作系统概念开始形成:
      • 操作(operate)任务(Jobs)的系统(System)
        • 批处理系统 = 程序的自动切换(换卡) + 库函数API
        • Disk Operating Systems(DOS)的出现,此时的OS开始出现“设备”、“文件”、“任务”等对象和API。
  • 1960s的计算机

    • 集成电路和总线出现
    • 更快的处理器
    • 更大、更快的内存,此时虚拟存储概念出现。
      • 可以将多个程序同时载入多个程序,而不用通过以往“换卡”的方式。
    • 更丰富的I/O设备,更加完善的中断/异常机制。
    • 此时也出现了更多的编程语言和编译器的出现。
      • COBOL(1960年)
      • APL(1962年)
      • BASIC(1965年)
  • 1960s的操作系统

    • 可以同时载入多个程序到内存中,且灵活调度对应的管理出席。包括程序可以调用的API
    • 正因为有需要将多个程序载入内存中,也出现了 进程(process) 的概念。
    • 进程在执行I/O时,可以将CPU让给另一个进程。
      • 在多个地址空间隔离的程序之间进行切换。
      • 虚拟存储使得一个程序出现Bug,也不会crash掉整个系统。
    • OS可以完成程序之间切换,此时也出现了基于中断机制的一个概念:时钟机制。
      • 时钟中断:使程序在执行时,异步地插入函数调用
      • 由OS的调度策略来决定是否要切换到另一个程序执行。
      • Multics(MIT,1965年)
        • 现在OS开始诞生。
  • 1970s+的计算机

    • 集成电路发展迅速、PC兴起。
    • CISC指令集出现,中断、I/O、异常、MMU、网络等出现。
    • PC机、超级计算机大量涌现。
  • 1970s+的操作系统

    • 标志:
      • 分时系统走向成熟,UNIX诞生并走向完善,并奠定了现在操作系统的雏形。
    • 1973年:信号API、管道(对象)、grep(应用程序)
    • 1983年:BSD Socket(对象)
    • 1984年:procfs
    • UNIX衍生出了很多分支系列:
      • 1977年:BSD
      • 1983年:GNU
      • 1984年:MacOS
      • 1986年:AIX
      • 1985年:Windows
      • 1987年:Minix
      • 1991年:Linux0.01出现
      • 1996年:Debian
      • 2004年:ubuntu
      • 2007年:iOS
      • 2008年:Android
  • 现代操作系统

    • 标志:
    • 通过 “虚拟化” 硬件资源位程序运行提供服务的软件。
    • 目前的OS是非常复杂的系统之一。
    • 更复杂的处理器和内存
      • 非对称多处理器(ARM、Intel)
      • Non-uniform Memory Access(NUMA)
      • 更多的硬件机制:Intel-VT/AMD-V,TrustZone/SGX、TSX
    • 更多的设备和资源
      • 网卡、SSD、GPU、FPGA
    • 复杂的应用需求和应用环境。
      • 服务器、PC、智能手机、手表、手环、IoT/微控制器等等。

3. 理解OS:三个根本问题

  • OS服务谁?

    • 程序 = 状态机
    • 课程涉及:多线程Linux应用程序
  • (设计/应用视角)OS为程序提供什么服务?

    • 操作系统 = 对象 + API
    • 课程涉及:POSIX + 部分Linux特性
  • (实现/硬件视角)如何实现OS提供的服务?

    • 操作系统 = C程序
      • 完成初始化后就会成为interrupt/trap/fault handler
    • 课程涉及:xv6、自制迷你操作系统

4. 怎么学操作系统?

需要具备一些基本的素质:

  • 是一个合格的OS用户

    • 学会找对材料(Google、Bing、GitHub、StackOverflow等)
    • 会STFW/RTFM自动动手解决问题
    • 不怕使用任何命令行工具
      • vim、tmux、grep、gcc、binutils
  • 不惧怕写代码

    • 能管理一定规模(数千行)的代码
    • 能用正确的工具/方法去调试解决。
  • 参考书籍:

    • Remzi's Operating Systems: Three Easy Pieces
    • Computer Systems: A Programmer's Perspective
  • 最重要的:Get Your Hands Dirty

    • 听课看书不重要。独立完成编程作业即可理解OS。
    • 应用视角(设计):Mini Labs x6
      • 使用OS API实现“黑科技”代码。
    • 硬件视角(实现):OS Labs x5
      • 自动动手实现一个真正的OS
    • 全部OJ
      • 代码不规范会 -Wall -Werror 编译出错。
      • 代码不可移植,编译/运行时出错
      • 硬编码路径/文件名,在运行时出错。

5. 复习:数字电路与状态机

根据数字逻辑电路的角度来说:

  • 状态 = 寄存器保存的值
  • 初始状态 = RESET(implementation Dependent)
  • 迁移 = 组合逻辑电路计算寄存器下一周期的值。

  • 数字逻辑电路:模拟器

    【源代码:stimulator.c

    对于宏定义的使用,可以直接使用 gcc -E stimulator.c,则会直接将所有的宏展开:

    int main()
    {
        static int X, X1; static int Y, Y1;;
        while (1)
        {
            X1 = !X && Y; Y1 = !X && !Y;;
            printf("X" " = %d; ", X); printf("Y" " = %d; ", Y);;
            X = X1; Y = Y1;;
            putchar('\n'); sleep(1);
        }
    }
    
  • 更完整的实现:数码管的实现

    • 输出数码管的配置信号

    【源代码:logisim.c

6. 什么是程序?

因为数字系统本身就是状态机,程序运行在数字系统之上,所以对应的程序也属于状态机。

6.1 源代码视角

C程序的状态机模型(语义,Sematics)

  • 状态 = 堆 + 栈

    • 状态 = stack frame的列表(每个frame有PC) + 全局变量
    • 初始状态 = main的第一条语句
      • main(argc, argv),全局变量初始化
  • 迁移 = 执行一条简单的语句

    • 任何C程序都可以改写成“非复合语句”的C代码。
    • 通过中间代码和解释器的方式进行,。
      • 迁移 = 执行top stack frame PC的语句,PC++
      • 函数调用 = push frame(frame.PC = 入口)
      • 函数返回 = pop frame

整体来说,C程序每执行一条语句,对应的状态就发生变化(状态转移),直到为每一种语句都写出精确对应的程序行为。

6.2 二进制代码视角

在不同的指令集中,不管是x86、arm、RISCV-V来说,都是由两种形式的状态构成:

  • 内存:合法和不合法两种形态,如果合法,则可以正常访问。如果不合法则直接无法执行状态。

  • 寄存器:有PC、rax、rdi等寄存器。

对于任何一个状态(0和1),都可以直接取出指令,然后立马执行。

对于操作系统上的程序,所有的指令都只能进行计算。

6.3 一条特殊的指令

调用操作系统中的syscall()

  • 把当前进程所有的状态,都完全交给操作系统来执行,任其进行任意修改。由操作系统去决定下一个返回的Ack。

  • 实现与操作系统中的其它对象交互。

    • 读写文件/操作系统状态。
    • 改变进程(运行中状态机)的状态,例如创建进程/销毁进程等。

程序 = 计算 + syscall()

6.4 构建最小的 Hello World

1
2
3
4
int main()
{
    printf("Hello World\n");
}

对于程序来说,要满足一个“最小”的概念。在使用gcc编译出来的文件显然是不满足最小。

  • 在使用 --verbose 可以查看所有的汇编选项。

    • printf() 此时变成了put@plt
  • 使用 --static 时会复制libc

直接强行编译 + 链接:gcc -c +ld 的方式

  • 直接用ld链接就失败。

    1
    2
    3
    4
    5
    root@solerho# gcc -c helloworld.c
    root@solerho# ld helloworld.o 
    ld: warning: cannot find entry symbol _start; defaulting to 0000000000401000
    ld: helloworld.o: in function `main':
    helloworld.c:(.text+0x10): undefined reference to `puts'
    

    • 问题点1:
      • 链接时,提示warning,表面没指定代码的起始位置。
    • 问题点2:
      • ld链接库直接失败。在链接过程中,提示未定义 puts。在程序中调用的是printf(),提示puts的原因:在printf() 在底层逻辑上调用了puts。gcc编译器此时做了优化。如果要不让其优化,可以关闭对应的优化选项。
  • 注释掉printf() 调用,只保留main() 函数

    1
    2
    3
    4
    root@solerho# gcc -c helloworld.c
    root@solerho# ld -e main helloworld.o
    root@solerho# ./a.out
    Segmentation fault (core dumped)
    

    • 此时直接就 core dumped,使用gdb进行分析。

从图中可以发现,触发了 Segmentation fault,也就是无权访问内存地址为 0x1 的位置。

  • 解决异常退出?

    • 在程序中,如何让状态机停止,直接使用纯状态的角度,是不可以的,使用死循环时,报错未定义的报错。

    • 解决办法:引入syscall()

    • 调试 helloFaultSolution.c 时,截图如下(使用start指令,再用layout asm方式直接进入):

    • syscall()准备了一个系统调用的参数,会直接将状态完全交给OS,让OS来决定kill掉。
  • Hello World 的汇编实现

    helloworld_asm.S

7. 如何在程序的两个视角之间进行切换?

状态机解决了一个基本的问题:什么是编译器?

在现代或者未来编译优化角度:

  • 在保证观测一致性 (sound) 的前提下改写代码 (rewriting)

    • Inline assembly 也可以参与优化
    • 其他优化可能会跨过不带 barrier 的 asm volatile
    • Eventual memory consistency
    • Call to external CU = write back visible memory
  • 在未来也会有很多优化思路诞生:

    • 基于AI重写。
    • 基于语义合成。
  • 阅读材料

    • An executable formal semantics of C with applications (POPL'12)
    • CompCert C verified compiler and a paper (POPL'06, Most Influential Paper Award)
    • Copy-and-patch compilation (OOPSLA'21, Distinguished Paper

8. 操作系统中的一般程序

操作系统收编了所有的硬件/软件资源。 - 只能用操作系统允许的方式访问操作系统中的对象。 - 实现OS的霸主地位。 - 为了管理多个状态机。 - 权限避免和竞争问题造成状态机异常。

8.1 (二进制)程序也是OS中的对象

  • 可执行文件
  • 与日常使用的文件没有本质区别。

  • 查看可执行文件

    • vim、cat、xxd都可以直接查看可执行文件。
    • vim中二进制的部分无法阅读,但是可以查看字符串常量。
    • 使用xxd可以看到文件 "\x7f"、"ELF" 开头。
    • vscode中可以使用binary editor 插件。

8.2 系统中常见的应用程序

  • Core Utilities(coreutils)

    • standard programs for text file manipulation
    • 系统中安装的是GNU Coreutils
    • 有较小的替代品busybox
  • 系统/工具程序

    • bash、binutils、apt、ip、ssh、vim、tmux、jdk、python
    • Ubuntu Packages(和apt-file工具)支持文件名检索。
  • 其他的应用程序

    • 浏览器
    • 音乐播放器

8.3 main() 之前发生了什么?

面试题:Hello World C程序执行的第一条指令是什么?

  • main()的第一条指令
  • libc的_start

使用gdb可以查看得到:

对于 /lib64/ld-linux-x86-64.so.2 是OS自带的加载器。

使用 info proc mappings 查看进程的信息:

程序就是一个状态机,所以OS也会赋予对应的初始状态地址。

总结流程: - ld-linux-x86-64.so 加载了 libc。 - 之后libc完成了自己的初始化 - RTFM:libc startup on Hurd - main() 的开始/结束并不是整个程序的开始/结束。

8.4 打开程序的执行:Trace

面试题:程序 ./a.out 在执行的过程中,发生了哪些系统调用?

对于该问题,可以使用 Trace 可以进行解决。

trace 的定义:

In general, trace refers to the process of following anything from the beginning to the end. For example, the traceroute command follows each of the network hops as your computer connects to another computer.

对于程序每一步调用,可以使用命令行 strace ./a.out 来进行。

本质上来说,所有的程序都和 Hello world的例子是类似的。

  • 程序 = 状态机 = 计算机 ---> syscall ---> 计算 --->

  • 被操作系统加载

    • 通过另一个进程执行 execve 设置为初始状态。
  • 状态机执行

    • 进程管理:fork、execve、exit
    • 文件/设备管理:open、close、read、write
    • 存储管理:mmap、brk
  • 直到 _exit(exit_group) 退出。