操作系统之内存管理

429 阅读9分钟

操作系统之内存管理

Section & Segment

我们从一个C程序开始说起。

// hello.c
#include <stdio.h>

int main() {
    printf("Hello World!\n");
    return 0;
}

编译这段代码gcc hello.c -o hello会得到一个可执行文件,我们在终端使用size hello指令,会输入如下内容。不难发现1227+548+4=17791227 + 548 + 4 = 17791779(10)=6f3(16)1779(10) = 6f3(16)

> size hello
   text    data     bss     dec     hex filename
   1227     548       4    1779     6f3 hello

我们为size指令加上-A参数,会得到输出如下内容。我了方便观察我在指令输出中做了一些改动,接着我们会发现一个事情。

size(.interp.eh_frame)=1227=size(text)size(.interp\sim.eh\_frame) = 1227 = size(text) size(.init_array.data)=548=size(data)size(.init\_array\sim.data) = 548 = size(data) size(.bss)=4=size(bss)size(.bss) = 4 = size(bss)

> size hello -A
hello  :
section              size      addr

.interp                28   4194872
.note.ABI-tag          32   4194900
.note.gnu.build-id     36   4194932
.gnu.hash              28   4194968
.dynsym                96   4195000
.dynstr                61   4195096
.gnu.version            8   4195158
.gnu.version_r         32   4195168
.rela.dyn              24   4195200
.rela.plt              72   4195224
.init                  26   4195296
.plt                   64   4195328
.text                 386   4195392
.fini                   9   4195780
.rodata                29   4195792
.eh_frame_hdr          52   4195824
.eh_frame             244   4195880
Total                1227

.init_array             8   6295056
.fini_array             8   6295064
.jcr                    8   6295072
.dynamic              464   6295080
.got                    8   6295544
.got.plt               48   6295552
.data                   4   6295600
Total                 548

.bss                    4   6295604

.comment               45         0

Total                1824

.init: 定义了一个小函数叫做_init,程序的初始化代码会调用它。

.text: 已编译程序的机器代码。

.rodata: 只读数据,比如printf语句中的格式串和开关语句的跳转表。

.data: 已初始化的全局和静态C变量。局部C变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss节中。

.bss: 未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分已初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为0。

现在我们可以得出text、data、bss分别都是什么了。

  • text: 只读内存段(代码段)
  • data + bss: 读/写内存段(数据段)

还有一个size -A给出了但size未给出的.comment section,其中存放了不加载到内存的符号表和调试信息。

为什么未初始化的数据称为.bss

它起始于IBM 704汇编语言中块存储开始(Block Storage Start)指令的首字母缩写,并沿用至今。一种记住.data和.bss节之间区别的简单方法是把bss看成是更好地节省空间(Better Save Space)的缩写。

上面说了很多内容总结一下就是一句话:程序分为代码段和数据段。

Relocate

冯诺依曼机的运行原理是取址执行,代码段中是二进制指令,数据段中是二进制数据,它们都是要存储到内存上的。现在假设程序中有这样一条指令100: mov ah, 200,其中100表示这条指令的地址,200表示要操作的数据的地址。

那么这段程序在实际运行时,是否真的从物理地址100取出指令,执行将物理地址200的8位数据拷贝到ah寄存器的操作呢?

事实上100: mov ah, 200这条指令中的100和200都表示的是逻辑地址,我们假设程序的入口地址是0号地址,100和200都只是指令和数据地址的偏移量。

如果你现在理解了逻辑地址的概念,那么再考虑100: mov ah, 100这条指令,假设这条指令二进制表示为01001011...,那么这条指令执行的时候是不是,ax寄存器的值就被置为了01001011呢?

前面提到程序分为代码段和数据段,它们都有各自的逻辑地址空间,也就是说100: mov ah, 100的两个100,指的只是偏移量都是100,但是基址是不同的。因此ax寄存器的值自然也就不会被置为01001011了。

假设代码段基址为1000,数据段基址为2000,那么100: mov ah, 100就表示从物理地址1100取出指令,执行将物理地址2100的8位数据拷贝到ah寄存器的操作。

逻辑地址转换为逻辑地址是在程序运行时进行的,这个过程称之为运行时重定位。相应的还有编译时重定位(程序只能放在内存的固定位置)与载入时重定位(程序一旦载入内存就不能动了)。

操作系统有如下的数据结构来保存不同段的基址,称为段表。

段号基址长度保护
0180K150KR
1360K60KR/W
270K110KR/W
3460K40KR

程序是没有段表的,只有操作系统在内存中为每个段寻找一块空闲空间,并将程序载入内存成为进程才有自己的段表,存放在进程控制块(PCB)中,称为局部描述符表(LDT)。进程切换时发生PCB的切换,CPU中的LDTR寄存器也随之发生切换。

Memory Allocation

程序载入需要操作系统为其每个段分配可用空间,程序的分段相当于把程序打散,在某种程度上也提高了内存空间利用率,那何不把程序打的更碎呢?

内存的低地址由操作系统占据,当有新的进程被装载,就在空闲空间中为其分配空间,当有进程退出时,就回收其内存空间,长此以往,内存中会有非常多的内存碎片,导致虽然内存中有足够的空间,但无法运行新的程序。

在适当的时候采用内存紧缩操作是一个合理的想法,但是在紧缩过程中运行时重定位是失效的,也就是说在内存紧缩期间,进程无法运行,这个代价是无法承担的。

为了更高效的利用内存,将物理内存划分为若干个页(一般页的大小为4K),为了与进程的页区分,称物理内存的页为页框。这样再为程序分配空间时,只需要记录其页与页框的对应关系。分页之后内存中的碎片只会出现在单个页内,即每个进程的内存碎片大小不超过4K。

页号页框号保护
05R
11R/W
23R/W
36R

记录着页与页框对应关系的数据结构称之为页表,记录在PCB中,CPU中相应的CR3寄存器指向当前进程的页表。举个例子jmp 0x2240实际上是跳转到物理地址0x3240

        ————————————
页框7   |           |
页框6   |    页3    |
页框5   |    页0    |
页框4   |           |
页框3   |    页2    |
页框2   |           |
页框1   |    页1    |
页框0   |           |
        ————————————

首先计算0x2240的页号,因为页的大小为4K,即2122^{12},因此0x2240除以4K即为页号,需要将0x2240右移12位,即页号为2,页内偏移为0x240,2号页对应的页框为3号页框,因此物理地址为3×4K+0x2403\times4K+0x240,即0x3240

页表解决了内存碎片的问题,让内存利用率变得更高,但也带来了问题,32位机的页表长度为232÷2122^{32}\div2^{12},假设每个表项4字节,那么一个页表就是4MB,计算机中运行着100个进程,总共就是400MB,这个内存开销已经很大了。

为了解决页表的空间占用问题,引入了多级页表,类似目录一样,通过页目录表索引页表,进程只要存储页目录表和自己需要的页表就可以了。而为了解决多级页表的查找效率问题,引入了快表,存储在CPU缓存TLB中,就是缓存机制,用到了局部性原理。

Virtual Memory

看到这张图片相信很多人都会和自己脑海中的那张虚拟内存图对应上了,没错,虚拟内存就是用于统一段式内存管理和页式内存管理的。

假设一个逻辑地址CS:IP = 0:0x200,那么物理地址计算过程如下。

虚拟地址: 0x4000 + 0x200 = 0x4200
页号: 0x4200 << 12 = 4
页内偏移: 0x4200 % 2^12 = 0x200
页框号: 16
物理地址: 16*2^12 + 0x200 = 0x10200

假设一个进程就一个段,A进程fork一次,变成了了A、B两个进程。首先要分割原A进程的虚拟空间,两个进程有了不同的基址。分别对应同一个页目录表的不同部分,因此也要对新的页目录表项分配空间,并拷贝A的页表。两个进程相同的逻辑地址最终有同样的页,如果两个进程只是读取,那么映射到相同的物理页框,如果B进程写,那么B进程页号映射的页框会发生改变。这就是fork的写时共享,读时复制。需要注意的是,一共就一个多级页表,两个进程分别占据不同的页目录。

Swap In & Swap Out

想要支持虚拟内存免不了要磁盘的支持,让活跃的页都处在内存中,其余的则放在磁盘里,如要的时候在换到内存中。

MMU如果没有在页表中找到页号对应的页框,会产生缺页中断,中断处理例程负责将磁盘中的页换到物理内存中的空闲页框,并修改页表,使得MMU可以通过页号映射到该页框。

分配给一个进程的页框数量应该是动态调整的。

但在换入的时候并不是总有空闲空间的,所以还需要换出操作。页面淘汰算法:先进先出(FIFO)、最佳页面替换(OPT)、最近最少使用(LRU)。

LRU采用Second Chance Replacement, SCR近似实现,称为Clock Algorithm。