计算机系统

RAM & ROM

RAM(Read Access Memory)和ROM(Read Only Memory)是很多小白感到困惑的东西,只知道跟主存有关,傻傻分不清。

RAM分为SRAM和DRAM。DRAM就是通常用作主存的设备,速度快于普通硬盘,访问速度以ns计算,目前已经可以到几十ns。SRAM速度更快,价格更贵,所以通常用作缓存(Cache,一种读写速度比主存更快的设备,用于缓解CPU与主存之间的速度差异,CPU指令周期低于1个ns)。这两种设备都是靠电压驱动的,断电就无法保存数据。

ROM也是一种访问速度很快的设备,它与RAM的区别是,ROM在断电的情况下仍然可以保存数据。ROM分很多类型,比如PROM(可编程的ROM,只能编写一次)、EPROM(可编程1000次)。

Flash memory是基于EPROM的一种重要存储设备,在手机、电子照相机等设备中大量使用。SSD(固态硬盘)也是基于Flash memory的。

ROM设备中存储的程序通常称为固件(firmware)。计算机启动的时候,会先执行ROM中的固件程序。有些系统会在固件中提供了一系列读写函数(比如BIOS)。

<br>

GCC工作流程

gcc从编译源代码到生成可执行程序,要经过四个步骤:

  1. C preprocessor(cpp)预处理源文件(main.c),生成中间文件(main.i,ASCII字符):这一过程中,cpp程序会替换文件中定义的宏,并将头文件拷贝到源文件中,最后生成中间文件(main.i);
  2. C compiler(cc1)编译(main.i)生成汇编代码(main.s);
  3. Assembler(as)编译(main.s)生成目标文件(main.o,relocatable object file)
  4. Linker program(ld)链接所有的目标文件,生成可执行程序。

<br>

Static Linking

静态链接器(Static linker)会将可重定位的目标文件(relocatable object file)链接起来生成可执行文件。这个过程中它要执行两个很重要的任务:

  1. 符号表(Symbol resolution):目标文件中可能有符号引用了其他目标文件定义的内容,链接器要将符号的引用串接起来,并保证定义的唯一性;
  2. 重定位(Relocation):编译器和汇编器生成的代码段和数据段的位置都是从0开始的,链接器需要将这些目标文件组合起来,并重新生成地址。事实上,目标文件可以看作是字节块的集合,Linker只是把这些块重新编排,并分配新的地址给它们。

<br>

Object Files

目标文件分为三种类型:

  1. 可重定位的目标文件(Relocatable object file):包括二进制码和数据,可能被链接器链接起来生成可执行程序;
  2. 可执行的目标文件(Executable object file):包括二进制码和数据,可以直接载入内存并执行;
  3. 可共享的目标文件(Shared object file):一种特殊的relocatable object file,可以在加载或运行时被链接到程序中。

Compiler和assembler生成的是第一和第三种文件。

不同系统生成的目标文件的格式是不同的,例如:Linux使用的是ELF格式的目标文件。

<br>

Relocatable Object Files

这里以ELF格式为例,说明目标文件包含哪些内容:

obj
obj
  1. .text:机器码;
  2. .rodata:程序中的只读数据;
  3. .data:已经初始化的全局变量(c语言);
  4. .bss:未初始化的全局变量,它们在目标文件中没有分配空间,仅仅是一个占位符;
  5. .symtab:目标文件中引用到的函数和全局变量;
  6. .rel.text:一个包含.text段的地址列表,这些地址在Linker组合目标文件的时候是需要修改的,所以事实上这些地址一般是指向一些外部函数或全局变量;
  7. .debug:调试需要的信息,用-g命令生成;
  8. .line:调试需要的信息,用-g命令生成;
  9. .strtab:字符串符号表,可以认为是.symtab和.debug的辅助表。

Symbols and Symbol Table

每个目标文件(relocatable object file)中都包含一个符号表,记录该文件定义和引用的符号,包括函数和一下变量。具体来说,包括以下两类:

  1. Global symbols:目标文件中定义的函数和全局变量(在c里面没有static修饰的符号),目标文件引用的在其他目标文件中定义的函数和全局变量;
  2. Local symbols:目标文件中定义的仅在该文件中使用的函数和变量(在c里面用static修饰的符号)。

需要注意的是,local symbols中不包括程序中的局部变量,这些局部变量是在运行的时候在stack中生成的,而symbols中记录的变量是编译时在目标文件的.data或.bss段中生成的。对于前者,编译器可以保证符号名称的唯一性,对于后者,Linker需要保证其在所有目标文件中的唯一性。

<br>

Linking with Static Libraries

所谓静态链接库,就是事先将一些目标文件压缩打包而成的*.a文件。在链接的时候,Linker会根据应用程序代码中的引用,把静态链接库中需要用到的object modules拷贝到最终的可执行程序中。

可以用ar工具生成静态链接库,例如:

1
unix> ar rcs lib.a lib1.o lib2.o

然后在链接的时候将它们引入:

1
unix> gcc -static -o main main.o lib.a

Linker在解决链接时的符号引用问题时,采用从左向右扫描的方法,如果扫描一个目标文件时,发现一个未定义的符号,就记录下来(保存到未定义符号集),在之后扫描到的目标文件中查找,找到该符号则将其从未定义符号集中删除,并将对应的目标文件放入目标文件集合。

这种方法需要保证目标文件的输入顺序,如果main.c引用了lib.a中的符号,但在编译的时候,把lib.a放在了main.c前面,这样链接将会出错。

<br>

Dynamic Linking with Shared Libraries

动态链接库链接到程序中的方法有两种:

  1. 程序被载入内存时,由loader控制dynamic linker加载动态链接库;

  2. 在程序运行的时候,由程序自己调用dynamic linker加载并链接库,例如:Linux提供了接口来执行这样的操作

    1
    void *dlopen(const char *filename, int flag);

    JNI的工作原理与这种思路很类似。

<br>

Unix I/O

在unix中,文件就是一个字节序列。

所有的I/O设备,例如:网络、硬盘、终端,都被当作是文件模型。所以,所有的输入输出都被当作是对特定文件的读写(比如键盘输入到终端)。

每次应用程序打开文件的时候,kernel会返回一个descriptor,应用程序根据这个descriptor来跟踪文件状态,而文件的所有信息都是由kernel维护的(比如:改变当前文件的读写偏移位置,关闭文件等)。

共享文件

Unix内核通过三种数据结构来表示打开的文件,从而实现文件共享的目的:

  1. Descriptor table:由每个进程单独维护,表中的每个子项指向file table中的一个子项;
  2. File table:打开的文件的集合,由所有进程共享。每个子项包含当前文件的偏移位置、引用数目等。如果一个descriptor指向某个File table的子项,这个子项的引用数目会增加1,当引用数目为0的时候,内核会关闭该文件。同时,每个子项也包含一个指向v-node table子项的指针;
  3. v-node table:由所有进程共享,每个子项包含文件的绝大部分信息。

具体的,通过三幅图了解其工作原理:

noshare
noshare

上图中,进程的descriptor table中,fd1和fd4指向两个不同的子项(File table),这两个子项指向两个不同的文件,所以不存在共享。

share
share

上图中,fd1和fd4同样指向两个子项,但这两个子项却指向同一个v-node table的子项,所以它们实际上使用了同一个文件。由于file table的两个子项维护两个File pos,所以fd1和fd4在读文件的时候是“隔离”的。

process
process

上图是父进程和子进程的文件共享模型,它们使用的是相同的文件,包括File pos。

<br>

Virtual Memory

虚拟内存是位于disk上面的用于模拟物理内存的空间。早期计算机没有虚拟内存,CPU直接通过物理内存地址访问Main Memory。有了虚拟内存后,CPU只拥有Virtual address(VA),先通过MMU转换成物理内存,再访问Main Memory。vm副本

(使用VM)

vm
vm

(没有使用VM)

<br>

VM as Tool for Caching

vm副本 2
vm副本 2

如图,虚拟内存和物理内存都是将空间分成block进行管理的。VM中的块称为virtual page,物理内存上的块称为physical page或page frame。

virtual page有三种类型:

  • Unallocated: 尚未分配空间的虚拟内存;
  • Cached: 已经分配空间且映射到物理内存的页;
  • Uncached: 已经分配空间但还没有映射到物理内存的页(物理内存不够用),当CPU需要访问这块内存时,需要进行页调度。

由于disk跟DRAM的读写速度存在巨大差异,所以VM的主要工作之一是减少页调度。通常来说,virtual page的大小为4KB到2KB(物理内存跟它一致)。

<br>

Page Tables

Page Tables是MMU的辅助工具,简单来讲,它是用来判断virtual page的状态的。

vm副本 3
vm副本 3

Page tables主要包括两个信息:valid bitn-bit address。前者用来表示这块virtual page是否已经缓存到物理内存,后者表示这块virtual page在disk上对应的空间位置(即用户是否拥有使用权)。

Page tables在CPU寻址的时候发挥作用。CPU访问一块virtual page,通常有三种状态:

  • Page Hits: 这块vp刚好在DRAM中,直接引用;
  • Page Faults: 这块vp不在DRAM中,但用户已经在vm上分配了空间。这时发生页调度算法,在vm上找到这块地址空间,将它拷贝到DRAM,如果DRAM没有位置了,需要置换出一个frame,将这块frame写回vm后,再将新的vp写进去。最后要更新Page tables;
  • Allocating Pages: 这个动作通常是malloc等系统调用导致的。先在vm上分配virtual pages,接下来的步骤跟Page Faults是一致。

要知道,程序的内存调优基本上就是为了防止频繁的页调度。频繁的页调度又称为“抖动”,它受限于disk的读取速度。

<br>

VM as a Tool for Memory Management

事实上,操作系统为每个进程单独分配一张Page table,这么做的好处是,对于每个进程而言,它们的内存地址空间可以看作是从0开始的,至于具体对应哪一块物理地址,由MMU决定。

vm副本 4
vm副本 4

这种设计有多种好处:

  1. 简化linking:

    之前学linking的时候,提到linking就是将obj链接起来,为符号分配新的地址,这里的地址其实是相对地址,对于Linux而言,每个进程的起始地址(virtual address)都是一样的(例如:32bit系统是0x08048000),因此只要以这个地址为起点,计算往后的偏移地址,就知道程序运行时真正对应的地址是什么。而这个相同的起始地址其实就是每个进程的virtual address space的起始位置,对应到具体的物理内存肯定是不一样的(参见上图)。这样的工作大大简化了linker的设计。

  2. 简化loading:

    加载程序的时候,会为目标文件中的变量(.text、.data)分配空间,但实际上,初次加载的时候,系统只是在disk的vm上为这些section分配一个virtual pages(没有复制内容,仅仅分配一大块空间,因此速度较快),然后在进程的Page tables中将这些vp的valid bit设为0(not cached),并将address指向obj files中的真正的section, 只有当程序真正引用到它们时,才会将它们载入内存。

    注:这种将连续的vp跟任意文件的任意地址映射起来的技术称为memory mapping。Unix提供了mmap系统调用给开发人员使用。

  3. 简化了sharing:

    如果两个进程都调用了系统函数,不必要将系统函数的代码都复制到进程空间,而是将它们的vp都指向相同的磁盘空间(空间上有相应的内核函数代码)即可。

  4. 简化了memory allocation:

    当程序想分配新的heap空间时,系统可以分配连续的vps给进程(对应到进程的page tables),然后将这些vps映射到物理内存,这样就不用管在物理内存上如何分配连续空间了。

<br>

VM as a Tool for Memory Protection

虚拟内存机制可以控制vp的访问权限,通过在PTE(Page table entry)中加入权限标识位来实现。

vm副本 5
vm副本 5

例如:如果SUP标识位为NO,就表示这个vp内的程序没法访问内核代码。

通过这种方法,可以防止程序在运行时修改代码,或者访问没有权限的区域。