0%

操作系统自学笔记

文字、程序、图片来源包括但不限于蒋老师的课件、我OS/计组老师的课件、助教的实验文档、我的个人理解


导言

  • 操作系统是一个典型的 “system”——它完成对计算机硬件系统的抽象,提供应用程序的运行环境
  • 从应用程序的视角看,操作系统定义了一系列的对象 (进程/线程、地址空间、文件、设备……) 和操纵它们的 API (系统调用)。这组强大的 API 把计算机的硬件资源有序地管理起来,它不仅能实现应用程序 (浏览器、游戏……),还支持着各类神奇的系统程序 (容器、虚拟机、调试器、游戏外挂……)
  • 从硬件的视角看,操作系统是一个拥有访问全部硬件功能的程序 (操作系统就是个 C 程序,不用怕)。硬件会帮助操作系统完成最初的初始化和加载,之后,操作系统加载完第一个程序后,从此作为 “中断处理程序” 在后台管理整个计算机系统
  • 操作系统为什么难学?最主要原因是操作系统里的主题很多,有些主题对大家来说并不太熟悉。例如,同学们到目前为止编写的大部分代码都是串行的,打个比方,就是写一个程序模仿 “一个人”,一次执行一步动作。但操作系统引入了并发编程,也就是你需要协同多个共享内存的 “多个人” 时,会遇到很多你也许意料之外的问题。
  • AMD是芯片公司造芯片的;x86是Intel提出的指令集,用在AMD造出的芯片上;windows/linux是操作系统

什么是程序?如何理解程序?

logisim_1.c

  • 讲这个程序的目的,首先是为了让你学习宏的一些用法,其次是为了让你感受X-macro的设计美学(用于处理状态机)。

  • 该程序的功能:两位bit,4状态循环模拟器。

  • X-macro 是一种在 C 和 C++ 编程中使用的技术,通过预处理器宏来简化代码的维护和扩展。它的核心思想是将重复代码的部分抽象为一个宏列表,从而避免硬编码多次相似的代码。X-macro 技术主要用于需要在多个地方使用相同一组常量、结构或函数定义的情况,通常在枚举、状态机、错误代码处理等场景下非常有用。

  • 你需要了解一下宏。

  • 简单理解宏就是简单替换。

  • 宏还可以带参数,例如#define FUN(a, b) (a > b ? a : b)

  • 如果想输出参数的名字,例如#define PRINT_VAR(var) printf(#var " = %d\n", var)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    #include <bits/stdc++.h>
    using namespace std;

    #define PRINT_VAR(var) (printf(#var " = %d\n", var))

    int main() {
    int x = 10;
    PRINT_VAR(x);
    // 宏会被展开为:printf("x = %d\n", x);
    }
  • ##起到拼接的作用,例如#define DEFINE(X) static int X, X##1。宏展开后相当于int X, X1

  • 下面让我们来阅读一下logisim.c

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    #include <stdio.h>
    #include <unistd.h>

    #define REGS_FOREACH(_) _(X) _(Y)
    #define RUN_LOGIC \
    X1 = (X ^ Y); \
    Y1 = (Y == 0 ? 1 : 0); \

    #define DEFINE(X) static int X, X##1;
    #define UPDATE(X) X = X##1;
    #define PRINT(X) printf(#X " = %d; ", X);

    int main() {
    REGS_FOREACH(DEFINE); // 定义 X 和 Y
    while (1) { // 无限循环,模拟时钟周期
    RUN_LOGIC; // 执行逻辑运算,计算 X1 和 Y1 的值
    REGS_FOREACH(PRINT); // 打印当前的 X 和 Y 的值
    REGS_FOREACH(UPDATE); // 更新 X 和 Y 为 X1 和 Y1
    putchar('\n'); // 换行
    sleep(1); // 程序暂停 1 秒,模拟时钟的周期
    }
    }
    • <unistd.h> 是 POSIX(可移植操作系统接口)标准中的头文件,提供了对各种操作系统服务的访问,如文件操作、进程控制、用户身份管理等。在上述程序中,sleep用到它了。

    • static 在 C 语言中用于声明具有静态存储类别的变量或函数,这些变量或函数的生命周期贯穿整个程序运行期间,但它们的作用域仅限于定义它们的文件内部,从而提供了数据的持久性和封装性,防止了全局命名空间的污染。

    • 第一行#define REGS_FOREACH(_) _(X) _(Y),这个宏的意思是对X和Y做名为_的函数操作。从main函数可以推理出,_是个函数名。

    • 第二行的RUN_LOGIC意思就是根据X和Y算出X1、Y1的值。'\'表示续行

    • ... ...

    • 所以上面宏的逻辑就是,先写好各种功能宏(DEFINE、UPDATE、PRINT),功能宏传入的参数是一个变量,因为它是作用于一个变量的。

    • 然后为了一次作用于多个变量,要再写个循环宏(REGS_FOREACH),循环宏传入的参数是一个功能宏,因为它展开就是把功能宏作用到各个变量上。所以各个变量的名字就要在循环宏里写好。

      练习:理解后,试着写出上述程序

logisim_2.c

  • 讲这个程序的目的,是为了让你感受到管道符的魅力,以及前后端结合的魅力。

  • 该程序功能:通过两个变量X、Y,4种状态对应数字0、1、2、3。控制对应的七个变量的输出(与下文的seven-seg.py配合使用)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    #include <stdio.h>
    #include <unistd.h>

    #define DEFINE(x) static int x, x##1;
    #define UPDATE(x) x = x##1;
    #define RUN_LOGIC \
    x1 = (x ^ y); \
    y1 = (y == 0 ? 1 : 0); \
    A = (y == 0); \
    B = (x == y); \
    C = (1); \
    D = (x == 1); \
    E = (y == 0); \
    F = !(x == 1 && y == 0); \
    G = (y == 0);
    #define PRINT(x) printf(#x" = %d; ", x);
    #define REGS_FOREACH(_) _(x) _(y)
    #define OUTS_FOREACH(_) _(A) _(B) _(C) _(D) _(E) _(F) _(G)

    int main() {
    REGS_FOREACH(DEFINE);
    OUTS_FOREACH(DEFINE);
    while (1) {
    RUN_LOGIC; // 得到x1, y1, (ABCDEFG)由x,y得到
    REGS_FOREACH(UPDATE); // 更新x, y
    sleep(1);

    OUTS_FOREACH(PRINT);
    putchar('\n');
    }
    return 0;
    }

    练习:试着自己写出这个程序(RUN_LOGIC部分可借鉴)

seven-seg.py

  • 该程序功能:接受一行字符串代表7个灯的亮灭情况,输出对应的数字

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    import sys

    # \033[2J: 清楚整个屏幕
    # \-33[1;1f: 将光标移到左上角
    TEMPLATE = '''
    \033[2J\033[1;1f
    AAAAAAAAA
    BB CC
    BB CC
    BB CC
    BB CC
    DDDDDDDDD
    EE FF
    EE FF
    EE FF
    EE FF
    GGGGGGGGGG
    '''

    BLOCK = {
    0: '\033[37m░\033[0m', # \033[37m表示设置字体颜色为白色,░是一个字符,\033[0m表示重置颜色
    1: '\033[31m█\033[0m', # \033[31m表示设置字体颜色为红色,█是一个字符
    }

    VARS = 'ABCDEFG'
    for v in VARS:
    # Example: globals()['my_var'] = 42 # 动态创建一个名为 my_var 的全局变量并赋值为 42
    globals()[v] = 0

    # 从 stdin 逐行读取
    while True:
    line = sys.stdin.readline().strip() # 使用 readline 逐行读取来自管道的数据
    if line:
    exec(line)
    pic = TEMPLATE
    for v in VARS:
    pic = pic.replace(v, BLOCK[globals()[v]]) # 'A' -> BLOCK[A], ...
    print(pic)
    print(f"Received line: {line}")
    else:
    break

    练习:看懂这个py程序

hanoi-r.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>

void hanoi(int n, char from, char to, char via) {
if (n == 1) printf("%c -> %c\n", from, to);
else {
hanoi(n - 1, from, via, to);
printf("%c -> %c\n", from, to);
hanoi(n - 1, via, to, from);
}
return;
}

int main () {
hanoi(3, 'A', 'B', 'C');
return 0;
}

练习:自己写出这个汉诺塔程序

  • 讲这个程序的目的,是为了让你会用vscode gdb调试,以及引出如何用cpu执行角度去理解c语言程序

  • 首先你先自己写出这个程序

  • 然后去配置一下vscode里的gdb,配置完了试下各种功能以及反汇编视图是否可用

  • 几个快捷键要熟悉:F5启动调试,shift+F5关闭调试,F11单步调试(会进入函数内部),F10逐过程(把函数当作一条语句处理)

  • 然后思考一下带递归的程序是如何从汇编角度和cpu执行角度去理解:

    • 假设你写了一个带递归的程序hanio-r.c,然后你可以得到它的汇编。

    • 回忆一下当时用vivado做计组项目的时候,是不是将一段汇编硬编码到rom里了?(哈佛结构)所以说,汇编程序本质上也是一段线性的,就是靠着PC在上下跳来跳去来实现的。

    • 先思考下循环在汇编里的逻辑,本质就是PC加或者减某个数在每行的汇编里跳来跳去。

    • 再思考下递归在汇编里的逻辑,递归里会开局部变量,这又是如何实现的呢?我以下面的代码来举例:

      1
      2
      3
      4
      5
      6
      int fact(int n) {
      if (n < 1) return 1;
      else return n * fact(n - 1);
      }
      // 参数n在寄存器x10中
      // 为了尽可能少用通用寄存器,尽量复用,所以结果也分配在x10
    • 下面是对应的汇编:

    • 哈佛结构除了ROM里存汇编,RAM里是存数据的。数据存储本质上也是一个“数组”。你可以想象数组长度很长,其实的某一段拿来当栈空间,栈空间的起点就是sp,它是栈空间数组的下标。

    • cpu执行角度是没有什么全局变量/局部变量这种说法的,数据要么就是通用寄存器,那么就存在RAM里。sp在通用regs里。

    • 除了sp外,通用寄存器里,我们还需要一个x1。x1是RAM里数组的一个下标,其对应的数据是“最后一次”发生跳转的那条汇编的PC值+4。

    • 举个例子,比如我在主函数里调用了fact()函数,那么调用这条语句的PC加4之后的PC',就被存到了data[x1]里。然后如果我想return回主函数,只需jalr x0, 0(x1)即可,那么PC就跳到了PC'。

    • 所以,为了实现函数调用,x1和sp必不可少 。

    • 那么局部变量和嵌套调用如何实现呢?

    • 仔细看上面的汇编,进入函数的第一件事,是将当前的x1和x10(结果)存到RAM里的栈空间内,这样后续嵌套递归的话,x1和x10的值一定会被更改,但没事,因为我们已经将当前这一层调用的x1, x10存到RAM里了,当后续的调用return回当前层时,从RAM里重新取出即可。

    • 听了我以上的讲解,相信我已经能完全看懂上面的汇编了,也就完全理解C语言里的递归在cpu执行角度是如何理解的了。

    • 总结一下,cpu执行角度没有全局变量/局部变量之说,数据存储要不就在通用寄存器,要不就在RAM里。通用寄存器里俩很重要的分别是sp和x1,sp掌管着RAM里栈空间的下标,x1掌管着RAM里"调用PC+4"这个元素的下标。

    • 递归本质就是一进入当前层时,把当前层信息(x1 + 数据信息)压入栈中(sp减一个数),当前层return的时候就弹栈(sp加回来)。如果在当前层嵌套调用了自己(语句1),那么在当前层语句1之后的语句,若用到了当前层的数据,就从栈里取出来。不这么做的话,数据信息其实是已经被污染,因为CPU没有作用域这种说法。

  • Well,了解了cpu执行角度的c语言程序,我们就可以做一个高抽象性的概括了,即:C语言本质就是状态机,状态为(通用寄存器, RAM),C语言里的每一条语句,即每一条汇编的执行,都是一个状态到另一个状态的改变。这个状态机不是自发运行的,而是按照ROM里的指令去实现状态之间的改变。

syscall

  • 一条神奇的指令,即调用操作系统
  • 从上面的讲解,我们知道,任意程序本质上就是对状态机(通用寄存器, RAM)状态改变的指挥棒。那么当一个程序调用syscall的时候,就会执行操作系统里的程序,也就是指挥棒交到了操作系统手上。
  • 在程序眼里,操作系统就是syscall这条指令,syscall指令的各种api包罗万象。你想用屏幕?可以,syscall请求一下。你想与别的进程交互?可以,syscall请求一下。So,没什么神奇的,所有的程序与操作系统交互都是通过syscall。
  • 由此,就实现了操作系统与任意程序的交互。

lab0-裸机程序

预备知识

  • makefile快速入门

  • gcc test.c:编译test.c并生成可执行文件a.out

  • gcc test.c -o yyy:编译test.c并将可执行文件重命名为yyy

  • gcc test.c -c:编译test.c但不链接,生成目标文件test.o

  • gcc test.c -c -o yyy:编译test.c但不链接,将目标文件改名为yyy

  • 最普通的makefile写法

1
2
3
4
5
6
7
8
9
10
11
hello: hello.o print.o
gcc hello.o print.o -o hello

hello.o: hello.c
gcc hello.c -c

print.o: print.c
gcc print.c -c

clean:
rm -f *.o
  • .PHONY 后面跟着的东西,就可以使你make xxx的时候执行的是makefile里xxx的逻辑。

  • 局部变量:函数结束就销毁

  • 静态局部变量:静态局部变量永远在,直到程序结束。但仍只能被本函数访问

  • 全局变量:任何文件,只要通过extern关键字,就可以访问它

  • 静态全局变量:只有本文件能够访问它

  • 静态变量 = 静态局部变量 + 静态全局变量


  • objdump -h test.o:查看目标文件test.o的段头
    • 什么是段头?就是程序编译过后的ELF文件的头(元数据)
    • .text段:存放程序编译后生成的机器码
    • .data段:存放已初始化过的死不了的东西,即初始化过的全局变量和静态变量
      • 在ELF文件中,已经记录了这些变量的初始值,运行时便会加载到内存中
    • .bss段:存放未初始化过的死不了的东西,即未初始化过的全局变量和静态变量
      • 在ELF文件中,没有这些变量的初始值,但是会告诉OS在运行时将他们初始化为0/NULL
    • .rodata段:存在只读的常量,若试图修改,会导致运行错误

实现裸机Hello world!

  • 我们以往写用户程序时,通常都只关注代码本身,而将运行时的环境交给了编译器等系统软件进行处理,但我们若要编写裸机程序,就需要进一步揭开运行时环境的神秘面纱。
  • 下表揭示了裸机程序与用户程序的区别:
对比对象 裸机程序 常规用户程序
内存地址空间 自行管理物理地址空间,可以自行对虚拟内存进行配置后使自己运行在虚拟地址空间 由操作系统管理的虚拟地址空间(不考虑Linux NOMMU模式)
系统调用 调用自己 调用更高特权级的操作系统/固件
栈的初始化 自行完成 操作系统载入用户进程时完成(毕竟还要通过栈传递参数)
BSS段的清空 自行完成 操作系统分配虚拟页面时完成清零
  • ok,那开始吧!首先先写一个用于初始化的汇编代码。(用于初始化虚实地址映射方式、栈初始化、执行main)
  • 注释写的非常详细了,请仔细阅读:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# 告诉汇编器main这个东西是外部C语言的main函数
# 不用担心路径,因为在makefile的时候会把C程序和汇编程序绑在一起
# 也不用担心有多个C程序从而有多个main,如果是这样的话会报错的,因为一个项目里只能有一个main函数
.extern main

# .text 伪指令用于指定代码段的开始,通常放在需要生成机器码的指令之前
.text

# 声明_start为全局符号
.globl _start

_start:
# li.w的意思是load immediate word,就是加载一个立即数的意思,就是把0xa0000011加载到t0里
# 为什么要选择t0? 没有什么特殊含义,因为t0是通用寄存器,用于存储临时数据,所以顺手就用它了
# 0xa0000011 = 1010 0000 0000 0000 0000 0000 0001 0001
li.w $t0, 0xa0000011

# 什么是CSR?(Control and Status Registers)
# 简单理解,CSR就是一组寄存器,跟registers里的寄存器一样,只不过这组寄存器的不同的取值会使cpu做一些事情(具体请看龙芯32位手册_v1.02的p65)
# 需要通过特殊的命令,例如csrwr, csrrw来读取和写入
# 下面这句话就是说,把t0的值写入地位为“0x180”的这个CSR里。csrwr的意思是csr write

# 然后补一下关于虚拟地址、物理地址、地址映射的知识
/*
* 物理地址:是内存的实际地址,即内存芯片上的实际位置
* 虚拟地址:由程序生成的地址,不是实际的物理地址
* 地址映射:将虚拟地址转换为物理地址的过程。这种映射通常由硬件(如 MMU,内存管理单元)和操作系统共同完成
* 龙芯中,mmu支持两种翻译模式:直接地址翻译(直接窗口)、映射地址翻译(页表)。优先级直接 > 页表
* 直接窗口:直接窗口是一种地址映射机制,用于将虚拟地址直接映射到物理地址
* 配置直接窗口:直接窗口是一种机制,所以我们需要配置其具体机制的设置,那么在龙芯里,就通过地址为“0x180”这个csr(其实叫CSR_DMWIN0)来
配置第0个直接窗口的设置
*/
/*
* 知道了CSR_DMWIN0这个csr寄存器是第0个直接窗口的配置信息之后,我们需要了解具体什么数对应着什么配置:
* 参考龙芯32位手册_v1.02的p76
* 第31-29位:虚地址的[31:29]位
* 第27-25位:物理地址的[31:29]位
* 第3位:为1表示在特权等级PLV3(用户态)下可以使用该直接窗口进行地址翻译
* 第0位:为1表示在特权等级PLV0(内核态)下可以使用该直接窗口进行地址翻译
* ... ...
*/
csrwr $t0, 0x180 # 所以这里CSR_DMWIN0被配置为:“起始地址0xa0000000, PLV0时该直接窗口激活”

# 下面这两行跟上面两行一样,只不过是配置CSR_DMWIN1,它被配置为:“起始地址0x80000000, PLV0时该直接窗口激活”
# 0x80000001 = 10000000000000000000000000000001
li.w $t0, 0x80000001
csrrw $t0, 0x181

# -----------------------------------------------------------------------------------------------------

li.w $t0, 0xb0 # 0000 0000 0000 0000 0000 0000 1011 0000
# 地址为0x0的csr是CSR_CRMD,所以我们需要了解CSR_CRMD(Control and Status Register for Control and Management)是什么
# 详细参考龙芯32位手册_v1.02的p67
/*
* PLV(Privilege Level)
位范围:第1-0位
取值:
00:内核模式(内核模式下的代码可以执行所有操作,包括直接访问硬件资源、修改系统状态等)
11:用户模式(用户模式下的代码不能执行某些敏感操作,如直接访问硬件资源、修改关键系统状态等)
* DA
位范围:第3位
取值:
0:静默使用直接翻译(实际上当DA=0, PG=1时仍然会静默开启直接翻译)
1:启用使用直接翻译
* PG(Page Enable)
位范围:第4位
取值:
0:禁用分页翻译
1:启动分页翻译(直接翻译仍会起效且优先级比页表翻译高)
* ... ...
*/
csrwr $t0, 0x0 # 所以这里CSR_CRMD被配置为:“内核模式 + 启用分页翻译”

# -----------------------------------------------------------------------------------------------------

# la的意思是load address
# 初始化栈指针为bootstacktop这个地址
la $sp, bootstacktop

# 将main标签的地址给t0
la $t0, main
# 跳转到main标签的地址,即开始执行main函数
jr $t0

# 当main函数执行完后,会回到汇编这里,然后_stack就执行完了,自然会执行到poweroff这里
poweroff:
b poweroff # b是branch的意思,即无条件跳转到poweroff,形成死循环
# 通过 b poweroff 形成的无限循环,可以确保程序在 main 函数返回后不会继续执行无效的代码,防止未定义行为的发生。

# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

# _stack是一个标签,没用
_stack:

# 切换段为数据段
# 需要注意.data里的分配动作在链接阶段就会分配内存空间
.section .data

# 声明bootstack为全局符号
.global bootstack
bootstack:
.space 1024 # 从bootstack这个地址开始,分配1024个空间

# 声明bootstacktop为全局符号
.global bootstacktop
bootstacktop:
.space 64 # 从bootstacktop这个地址开始,分配64个空间

/*
更多相关知识介绍:https://osdocs.cqu.ai/lab0/intro/#io

总结:
这段汇编首先配置了 PLV0下的俩直接翻译窗口CSR_DMWIN0, CSR_DMWIN1,即分别将虚拟地址0xa0000011、0x80000000映射到物理地址上
然后设置了CSR_CRMD,即启用PLV0内核模式 + 启用分页翻译模式(此时也会静默启动直接翻译,因为其优先级高)
然后初始化了栈顶地址($sp),栈的大小在.data中已设置
*/