My note and record when learning os (nju jyy)

先验知识

在正式学习操作系统之前,先来补齐有关计算机基础的相关知识
这一部分我们用问题导入的方式来解决,从几个深入底层的问题,来思考计算机运作的原理:

一个C程序,写完之后是怎么运行起来的

给定一个a.c程序,来看它的运行过程

#include<stdio.h>
int main(){
    printf("hello world\n");
    return 0;
}

这玩意叫源代码,从它到真正跑起来需要以下的过程

  1. (编译)源代码 -> 机器指令
  2. 可执行文件 -> 内存
  3. CPU从入口地址开始执行,直到main函数

假设你在终端里面敲出来./a.out,操作系统会完成以下的事情:

  1. 找到 a.out 这个可执行文件
  2. 把它加载到内存
  3. 创建一个进程
  4. 给这个进程准备地址空间
  5. 初始化一些寄存器
  6. 设置好栈
  7. 把程序入口地址交给 CPU
  8. CPU 从入口开始执行

注意:CPU的入口并不是main函数,而是_start,之后初始化完成再去调用main函数

一个抽象图,内存就这些部分

这里插入细究一下栈的作用,这个很关键:
以函数调用为例,比如说一个简单的add函数

int add(int a, int b) {
    int c = a + b;
    return c;
}

在主程序调用这个函数的时候,a,b,c都是局部变量,那么计算的时候最优的情况就是三个变量全都存到寄存器里面直接算完了返回(因为寄存器的运算速度是非常快的),但是这是一个理想情况,很多时候寄存器是不够存这么多玩意的,那就需要在内存中开一块空间专门用来处理这一次的函数调用了,也就是创建了一块对应函数的栈帧

栈帧这个区域负责这个函数需要的部分内容,包括返回地址、旧栈地址、局部变量等等,还是用函数调用去分析,不是所有的函数调用都像add一样简单,现代的函数可能很复杂,一个函数里面有一大堆的局部变量,受限于寄存器的内存,它虽然算的快但是存不了所有的,那就得栈帧发挥作用了

简言之,这个函数调用相关的都可能存到栈帧里面,然后我们再用整体的眼光去看栈帧,举一个例子,在main函数里面调用add函数,从_start开始,CPU执行到了main函数之后,在栈里面开一块main函数的栈帧,而这个main函数里面又调用了add函数,栈顶又会压入一个add函数的栈帧,这刚好与栈的”先进后出”的性质完全匹配!

你经常会看到:

int main(int argc , char* argv[])

这里的argc是指的命令行参数个数,argv指的是字符串序列,比如:
比如说./prog file1 file2
那么argc = 3,argv = [“./prog”,”file1”,”file2”]

返回值是通过寄存器实现的,就是因为速度快而且直接,在函数调用的时候提前规定好返回的寄存器,然后取出来赋值给一个变量

从一个.c文件,是怎么到可执行程序的

最标准的链路其实是这 4 步:

.c
-> 预处理
-> 编译
-> 汇编
-> 链接
-> 可执行文件

首先看预处理阶段,这一阶段处理的是程序的头文件,包括#include,#define以及注释/宏定义这些,其实是把这些内容展开,生成一段更加完全的源代码,从本质上来看这一部分的产物(.i文件)还是c代码,把原始.c文件处理到.i文件的命令,至此,我们得到了编译器要处理的真正的源码:

gcc -E test.c -o test.i

下一步是编译,把刚才获得到的源码翻译成汇编指令,这一步的转换涉及到过多编译原理的知识,这里不细讲,实现的命令和得到的.s文件如下:
(srds,我完全不知道这个汇编是干啥的,but应该对理解没啥影响)

gcc -S test.c -o test.s
	.file	"test.c"
	.text
	.section	.rodata
.LC0:
	.string	"%d\n"
	.text
	.globl	main
	.type	main, @function
main:
.LFB0:
	.cfi_startproc
	endbr64
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	movl	$10, %esi
	leaq	.LC0(%rip), %rax
	movq	%rax, %rdi
	movl	$0, %eax
	call	printf@PLT
	movl	$0, %eax
	popq	%rbp
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (Ubuntu 11.4.0-1ubuntu1~22.04.3) 11.4.0"
	.section	.note.GNU-stack,"",@progbits
	.section	.note.gnu.property,"a"
	.align 8
	.long	1f - 0f
	.long	4f - 1f
	.long	5
0:
	.string	"GNU"
1:
	.align 8
	.long	0xc0000002
	.long	3f - 2f
2:
	.long	0x3
3:
	.align 8
4:

接下来进行汇编,将上面得到的汇编语言转换为二进制文件(.o),具体命令是:

gcc -c test.c -o test.o

到这里并没有结束,实际项目中,会有多个.c文件封装不同的函数互相调用,举一个最简单的例子,一个main.c,一个add.c,在main.c里面调用add.c中的函数,如果只是孤零零的两个binary文件,他怎么知道调用的函数是干啥的呢?这就需要我们的下一步:链接,把多个文件的目标文件和库文件拼接起来,解决引用问题,再加上启动代码_start,动态链接库,libc这样的标准库之类的东西

终于,我们得到了可执行文件!

操作系统期末复习总结

时间不够了,直接开冲本学期的操作系统复习,所以资料均来自上课的PPT

3.1 程序的基本结构 – 简单程序

3.1.1
我们来详细深入一下这张图片:

  • .text是代码段,表示可执行指令,一个函数整体在编译之后就会变成机器指令
  • .rodata是不可修改的常量初值,也就是const定义的变量
  • .rwdata是有全局初始化的变量,一开始有赋值
  • .zidata是全局未初始化的变量
  • .heap: 给临时变量动态分配内存(malloc)
  • .stack: 栈帧,类似于给每个状态一个切片,给每个状态创建一个快照可以追溯

在一个真的可执行文件当中,我们是都需要这些信息的,但是还有一点:每一个部分的具体的数值用什么存?

这里就需要我们的.head来维护每一部分的起始地址,总长度等信息

3.2: 程序的基本结构 – 复杂的程序

程序地址空间内存问题:如果两个或多个程序链接到的地址是重复的,那就会造成互相卡住的问题,下面来看如何解决这个问题:

  • 简单分区
    直接把物理地址划分成若干个区域,然后每一个程序就链接到固定的一片区域

注意一个权限的问题,就是程序和地址相当于是有一个强制绑定的关系,所以每个物理内存的区域都会有对应权限放行的机器指令列表

除了检查这一条指令是否符合物理地址空间的范围,还需要检查这个权限是否匹配,二者都正确才能放入

  • 虚拟地址空间
    实际上程序会认为自己要访问一个地址,我们称之为”逻辑地址”,虚拟地址空间就是按照某些规则把逻辑地址和物理地址之间建立起某种联系来,保持在访问过程中不会乱

在这个基础下面衍生出了几种经典的管理方式:

  • 分段
    每一个程序准备储存的时候,先去物理内存中检查有没有空,然后开一个跟自己内存占用大小匹配的空间

  • 分页
    给整个物理地址区域用一个个来分开,这个页就是一个固定大小的区域(比如4KB),然后每一个程序的逻辑地址都跟这个某一个页建立映射,并用页表的结构维护

如果程序很大就会占用多个页,小的话会导致一个页的空间没有被占满造成浪费,这就是内部碎片

TLB:加速访问,CPU在访问一个程序的时候,先读到的是虚拟内存,必须通过查表在能找到物理内存,所以通过TLB(原理类似于cache),把所有查过的记录存起来下一次进行O(1)的读取

4.1:时间的协调 - 处理器

指令流:程序的基本逻辑组成单位,由计算部分和I/O部分构成

一个程序可以由多个指令流组合来完成,多个指令流协作完成任务的时候有的时候需要搞好指令流的运行顺序,合作执行的方式就是把每个指令流分成好几部分,每一部分的末尾都让操作系统告诉CPU执行下一份指令流

指令流会被打断,再次恢复的时候需要保存信息,这就引出了两个需要的信息:

  • 指令流自己的上下文
  • 对CPU的占用状态

上下文主要就是通过寄存器来保持一些信息,包括通用寄存器(里面会存一些临时变量,但还没有来得及写入内存里面),程序计数器(PC),这个是指向下一条指令的地址的,状态寄存器(当前CPU的一些临时状态,溢出进位之类的),再加上执行栈(函数调用层面的现场,也就是那个包含了各个状态的栈帧的)

状态:创建 运行 阻塞 就绪 结束


Give it time, let it pass.