2017-2018-1 20155303 《信息安全系统设计基础》第十一周学习总结
————————CONTENTS————————
教材内容总结
本周学习目标:①理解虚拟存储器的概念和作用;②理解地址翻译的概念;③理解存储器映射;④掌握动态存储器分配的方法;⑤了解C语言中与存储器有关的错误。
『一 虚拟存储器的概念和作用』
虚拟存储器
-
虚拟存储器的三个重要能力:
- 它将主存看成是一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据,通过这种方式,高效的使用了主存。
- 它为每个进程提供了一致的地址空间,从而简化了存储器管理。
- 它保护了每个进程的地址空间不被其他进程破坏。
-
程序员需要理解虚拟存储器的三个原因:
- 虚拟存储器是中心的:它是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的交互中心;
- 虚拟存储器是强大的:它可以创建和销毁存储器片、可以映射存储器片映射到磁盘某个部分等等;
- 虚拟存储器若操作不当则十分危险。
地址空间
- 地址空间是一个非负整数地址的有序集合:{0,1,2,……}
- 线性地址空间:地址空间中的整数是连续的。
- 虚拟地址空间:CPU从一个有 N=2^n 个地址的地址空间中生成虚拟地址,这个地址空间成为称为虚拟地址空间。
- 地址空间的大小:由表示最大地址所需要的位数来描述。
- 物理地址空间:与系统中的物理存储器的M个字节相对应。
- 虚拟存储器的基本思想:主存中的每个字节都有一个选自虚拟地址空间的虚拟地址和一个选自物理地址空间的物理地址。
虚拟存储器作为缓存的工具
- 虚拟存储器——虚拟页(VP),每个虚拟页大小为P=2^p字节。
- 物理存储器——物理页(PP),也叫页帧,大小也为P字节。
- 任意时刻,虚拟页面的集合都被分为三个不相交的子集:
- 未分配的:VM系统还没分配(创建)的页,不占用任何磁盘空间。
- 缓存的:当前缓存在物理存储器中的已分配页。
- 未缓存的:没有缓存在物理存储器中的已分配页。
页表
- 页表:是一个数据结构,存放在物理存储器中,将虚拟页映射到物理页,就是一个页表条目的数组。
- 页表就是一个页表条目PTE的数组。
- PTE:由一个有效位和一个n位地址字段组成的,表明了该虚拟页是否被缓存在DRAM中。
- 页表的组成:有效位+n位地址字段
- 如果设置了有效位:地址字段表示DRAM中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。
- 如果没有设置有效位:
- 空地址:表示该虚拟页未被分配
- 不是空地址:这个地址指向该虚拟页在磁盘上的起始位置。
『二 地址翻译』
- 地址翻译:一个N元素的虚拟地址空间(VAS)中的元素和一个M元素的物理地址空间(PAS)之间的映射。
- MAP: VAS → PAS ∪ ∅
- MAP = A' ,如果虚拟地址A处的数据在PAS的物理地址A'处
- MAP = ∅ ,如果虚拟地址A处的数据不在物理存储器中
- CPU中的一个控制寄存器页表基址寄存器指向当前页表,n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO) 和一个(n-p)位的虚拟页号,页表条目中的物理页页号和虚拟地址中的VPO串联起来,就得到了相应的物理地址。
『三 存储器映射』
- 存储器映射:Linux通过将一个虚拟存储器区域与一个磁盘上的对象关联起来,以初始化这个虚拟存储器区域的内容的过程。
- 映射对象:
- Unix文件系统中的普通文件
- 匿名文件(全都是二进制0)
- 一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件之间换来换去。交换文件也叫交换空间,或交换区域。
『四 动态存储器分配的方法』
- 当运行时需要额外虚拟存储器时,使用动态存储器分配器维护一个进程的虚拟存储器区域。
- 分配器有两种风格:
- 显示分配器:要求应用显式地释放任何已经分配的块。
- 隐式分配器:要求分配器检测一个已分配块何时不再被程序所使用,就释放这个块。也叫做垃圾收集器。
『五 C语言中与存储器有关的错误』
- 1、间接引用坏指针
- 在进程的虚拟地址空间中有较大的洞,没有映射到任何有意义的数据,如果试图引用一个指向这些洞的指针,操作系统就会以段异常来终止程序。
- 典型的错误是:scanf("%d",val);,没有加&符号
- 2、读未初始化的内存
- 虽然bass存储器位置总是被加载器初始化为0,但对于堆存储器却并不是这样的。
- 常见的错误就是假设堆存储器被初始化为0.
- 3、允许栈缓冲区溢出
- 如果一个程序不检查输入串的大小就写入栈中的目标缓冲区,程序就会出现缓冲区溢出错误。
- 4、假设指针和指向他们的对象大小是相同的。
- 5、造成错位错误。
- 6、引用指针,而不是他所指向的对象。
- 注意C的优先级和结合性
- 7、误解指针运算
- 忘记了指针的算术操作是以它们指向的对象的大小为单位来进行,而这种大小单位不一定是字节。
- 8、引用不存在的变量
- 理解栈的规则,有时会引用不再合法的本地变量。
- 9、引用空闲堆块中的数据
- 一个相似的错误是引用已经被释放了的堆块中的数据。
- 10、引起存储器泄露
- 当不小心忘记释放已分配块,而在堆里创建了垃圾时,就会引起存储器泄露。
学习过程中遇到的问题及解决
『问题一』:教材P565提到,可以利用Linux的getrusage函数监测缺页的数量以及许多其他的信息,具体如何使用这个函数呢?
『问题一解决』:
首先使用“man -k getrusage”获取函数的初步信息:
得知getrusage在手册的第二节,接下来使用“man 2 getrusage”查看函数基本格式和需包含的头文件:
getrusage函数有两个参数。第一个参数可以设置为RUSAGE_SELF或者RUSAGE_CHILDREN。如果设置成 RUSAGE_SELF,那么将会以当前进程的相关信息来填充rusage(数据)结构。反之,如果设置成RUSAGE_CHILDREN,那么 rusage结构中的数据都将是当前进程的子进程的信息。
我们还注意到,里面介绍了一个非常重要的结构体:struct rusage,其中包含的内容如下所示
各个字段的解释如下:
- ru_utime:返回进程在用户模式下的执行时间,以timeval结构的形式返回(该结构体在bits/timeval中声明)。
- ru_stime:返回进程在内核模式下的执行时间,以timeval结构的形式返回(该结构体在bits/timeval中声明)。
- ru_maxrss(Linux 2.6.32起支持):返回最大驻留集的大小,单位为kb。当who被指定为RUSAGE_CHILDREN时,返回各子进程最大驻留集的大小中最大的一个,而不是进程树中最大的最大驻留集。
- ru_ixrss:目前不支持
- ru_idrss:目前不支持
- ru_isrss:目前不支持
- ru_minflt:缺页中断的次数,且处理这些中断不需要进行I/O;不需要进行I/O操作的原因是系统使用“reclaiming”的方式在物理内存中得到了之前被淘汰但是未被修改的页框。(第一次访问bss段时也会产生这种类型的缺页中断)
- ru_majflt:缺页中断的次数,且处理这些中断需要进行I/O。
- ru_nswap:目前不支持
- ru_inblock(Linux 2.6.22起支持):文件系统需要进行输入操作的次数。
- ru_oublock(Linux 2.6.22起支持):文件系统需要进行输出操作的次数。
- ru_msgsnd:目前不支持
- ru_msgrcv:目前不支持
- ru_nsignals:目前不支持
- ru_nvcsw(Linux 2.6起支持):因进程自愿放弃处理器时间片而导致的上下文切换的次数(通常是为了等待请求的资源)。
- ru_nivcsw(Linux 2.6起支持):因进程时间片使用完毕或被高优先级进程抢断导致的上下文切换的次数。
执行成功返回0,发生错误返回-1,同时设置errno的值。
下面使用一个简单的程序测试函数的功能:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/resource.h>
int main(int argc, char **argv)
{
struct rusage buf;
if(argc == 2) {
system(argv[1]);
}else {
fprintf(stderr,"./getrusage "ls -l > /dev/null"
");
exit(0);
}
int err = getrusage(RUSAGE_CHILDREN, &buf);
//int err = getrusage(RUSAGE_SELF, &buf);
printf("ERR=%d
", err);
printf("%20s:%ld/%ld %s
", "ru_utime",
buf.ru_utime.tv_sec, buf.ru_utime.tv_usec,
"user time used (secs/usecs)");
printf("%20s:%ld/%ld %s
", "ru_stime",
buf.ru_stime.tv_sec,
buf.ru_stime.tv_usec,
"system time used (secs/usecs)");
printf("%20s:%-10ld %s
",
"ru_maxrss",
buf.ru_maxrss,
"maximum resident set size");
printf("%20s:%-10ld %s
",
"ru_ixrss",
buf.ru_ixrss,
"integral shared memory size");
printf("%20s:%-10ld %s
",
"ru_idrss",
buf.ru_idrss,
"integral unshared data size");
printf("%20s:%-10ld %s
",
"ru_isrss",
buf.ru_isrss,
"integral unshared data stack size");
printf("%20s:%-10ld %s
",
"ru_minflt",
buf.ru_minflt,
"page reclaims");
printf("%20s:%-10ld %s
",
"ru_majflt",
buf.ru_majflt,
"page faults");
printf("%20s:%-10ld %s
",
"ru_nswap",
buf.ru_nswap,
"swaps");
printf("%20s:%-10ld %s
",
"ru_inblock",
buf.ru_inblock,
"block input operations");
printf("%20s:%-10ld %s
",
"ru_oublock",
buf.ru_oublock,
"block output operations");
printf("%20s:%-10ld %s
",
"ru_msgsnd",
buf.ru_msgsnd,
"messages sent");
printf("%20s:%-10ld %s
",
"ru_msgrcv",
buf.ru_msgrcv,
"messages received");
printf("%20s:%-10ld %s
",
"ru_nsignals",
buf.ru_nsignals,
"signals received");
printf("%20s:%-10ld %s
",
"ru_nvcsw",
buf.ru_nvcsw,
"voluntary context switches");
printf("%20s:%-10ld %s
",
"ru_nivcsw",
buf.ru_nivcsw,
"involuntary context switches");
exit(0);
}
测试结果如下:
『问题二』:教材P567提到,每个PTE中添加了三个许可位,其中SUP位表示进程是否必须运行在内核(超级用户)模式下才能访问该页。什么是内核模式呢?与之相对应的是什么模式呢?
『问题二解决』:
查阅资料了解到,Linux操作系统使用了双模式(内核模式和用户模式),可以有效地实现时间共享。
在Linux机器上,CPU要么处于受信任的内核模式,要么处于受限制的用户模式。除了内核本身处于内核模式以外,所有的用户进程都运行在用户模式之中。
内核模式的代码可以无限制地访问所有处理器指令集以及全部内存和I/O空间。如果用户模式的进程要享有此特权,它必须通过系统调用向设备驱动程序或其他内核模式的代码发出请求。另外,用户模式的代码允许发生缺页,而内核模式的代码则不允许。
- 内核空间和用户空间:
Linux简化了分段机制,使得虚拟地址与线性地址总是一致,因此,Linux的虚拟地址空间也为0~ 4G。Linux内核将这4G字节的空间分为两部分。将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为“内核空间”。而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为“用户空间“)。因为每个进程可以通过系统调用进入内核,因此,Linux内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有4G字节的虚拟空间。
- 内核态和用户态:
当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态(或简称为内核态)。此时处理器处于特权级最高的(0级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。每个进程都有自己的内核栈。
当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。即此时处理器在特权级最低的(3级)用户代码中运行。当正在执行用户程序而突然被中断程序中断时,此时用户程序也可以象征性地称为处于进程的内核态。因为中断处理程序将使用当前进程的内核栈。这与处于内核态的进程的状态有些类似。
- 进程上下文和中断上下文:
处理器总处于以下状态中的一种:
1、内核态,运行于进程上下文,内核代表进程运行于内核空间;
2、内核态,运行于中断上下文,内核代表硬件运行于内核空间;
3、用户态,运行于用户空间。
用户空间的应用程序,通过系统调用,进入内核空间。这个时候用户空间的进程要传递很多变量、参数的值给内核,内核态运行的时候也要保存用户进程的一些寄存器值、变量等。所谓的“进程上下文”,可以看作是用户进程传递给内核的这些参数以及内核要保存的那一整套的变量和寄存器值和当时的环境等。
硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。所谓的“中断上下文”,其实也可以看作就是硬件传递过来的这些参数和内核需要保存的一些其他环境(主要是当前被打断执行的进程环境)。
『问题三』:如何理解malloc/brk/mmap函数?
『问题三解决』:
结合之前学习过的fork相关知识,参考以下代码:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<signal.h>
#include<sys/mman.h>
int ga = 1;
int main()
{
int a = 10;
int *pa = malloc(sizeof(int));
*pa = 100;
int *ma = mmap(0,4,PROT_READ|PROT_WRITE,
MAP_ANONYMOUS|MAP_SHARED/*MAP_PRIVATE*/,0,0);
*ma = 1000;
int *spa = sbrk(4);
*spa = 10000;
if(fork())
{
printf("parent:ga = %d
",ga);
printf("parent:a = %d
",a);
printf("parent:*pa = %d
",*pa);
printf("parent:*ma = %d
",*ma);
printf("parent:*spa = %d
",*spa);
a = 7;
ga = 77;
*pa = 777;
*ma = 7777;
*spa = 77777;
}
else
{
sleep(5);
printf("
child:ga = %d
",ga);
printf("child:a = %d
",a);
printf("child:*pa = %d
",*pa);
printf("child:*ma = %d
",*ma);
printf("child:*spa = %d
",*spa);
}
return 0;
}
运行结果如下:
我们知道,通过fork创建的子进程克隆父进程的内存区域(全局区、栈区、堆区、代码区),但内存区域通过映射之后指向不同的物理空间,所以,尽管子进程克隆了父进程的内存区域,但他们的实际内存是独立. 不能相互访问。可以看出,malloc/brk/mmap对fork创建的子进程的操作并不完全相同。
- brk、mmap:系统调用
brk系统调用,可以让进程的堆指针增长一定的大小,逻辑上消耗掉一块本进程的虚拟地址区间,malloc向OS获取的内存大小比较小时,将直接通过brk调用获取虚拟地址,结果是将本进程的brk指针推高。
mmap系统调用,可以让进程的虚拟地址区间里切分出一块指定大小的虚拟地址区间vma_struct,并返回给用户态进程,被mmap映射返回的虚拟地址,逻辑上被消耗了,直到用户进程调用munmap,才回收回来。malloc向系统获取比较大的内存时,会通过mmap直接映射一块虚拟地址区间。mmap系统调用用处非常多,比如一个进程的所有动态库文件.so的加载,都需要通过mmap系统调用映射指定大小的虚拟地址区间,然后将.so代码动态映射到这些区域,以供进程其他部分代码访问;另外,多进程通讯,也可以使用mmap,这块另开文章详解。
无论是brk还是mmap返回的都是虚拟地址,在第一次访问这块地址的时候,会触发缺页异常,然后内核为这块虚拟地址申请并映射物理页框,建立页表映射关系,后续对该区间虚拟地址的访问,通过页表获取物理地址,然后就可以在物理内存上读写了。
- malloc:libc库函数
malloc是 libc实现的库函数,主要实现了一套内存管理机制,当其管理的内存不够时,通过brk/mmap等系统调用向内核申请进程的虚拟地址区间,如果其维护的内存能满足malloc调用,则直接返回,free时会将地址块返回空闲链表。
malloc(size) 的时候,这个函数会多分配一块空间,用于保存size变量,free的时候,直接通过指针前移一定大小,就可以获取malloc时保存的size变量,从而free只需要一个指针作为参数就可以了calloc 库函数相当于 malloc + memset(0)
『问题四』:mmap() vs read()/write()?
『问题四解决』:
系统调用mmap()可以将某文件映射至内存(进程空间),如此可以把对文件的操作转为对内存的操作,以此避免更多的lseek()与read()、write()操作,这点对于大文件或者频繁访问的文件而言尤其受益。但有一点必须清楚:mmap的addr与offset必须对齐一个内存页面大小的边界,即内存映射往往是页面大小的整数倍,否则maaped_file_size%page_size内存空间将被闲置浪费。
以下代码分别使用mmap与read/write两种方法,将test.txt文件中的字符转换成大写:
- mmap:
/*
* @file: t_mmap.c
*/
#include <stdio.h>
#include <ctype.h>
#include <sys/mman.h> /*mmap munmap*/
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
int fd;
char *buf;
off_t len;
struct stat sb;
char *fname = "test.txt";
fd = open(fname, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
if (fd == -1)
{
perror("open");
return 1;
}
if (fstat(fd, &sb) == -1)
{
perror("fstat");
return 1;
}
buf = mmap(0, sb.st_size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
if (buf == MAP_FAILED)
{
perror("mmap");
return 1;
}
if (close(fd) == -1)
{
perror("close");
return 1;
}
for (len = 0; len < sb.st_size; ++len)
{
buf[len] = toupper(buf[len]);
/*putchar(buf[len]);*/
}
if (munmap(buf, sb.st_size) == -1)
{
perror("munmap");
return 1;
}
return 0;
}
使用strace运行程序:
- read/write:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <ctype.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
int fd, len;
char *buf;
char *fname = "/tmp/file_mmap";
ssize_t ret;
struct stat sb;
fd = open(fname, O_CREAT|O_RDWR, S_IRUSR|S_IWUSR);
if (fd == -1)
{
perror("open");
return 1;
}
if (fstat(fd, &sb) == -1)
{
perror("stat");
return 1;
}
buf = malloc(sb.st_size);
if (buf == NULL)
{
perror("malloc");
return 1;
}
ret = read(fd, buf, sb.st_size);
for (len = 0; len < sb.st_size; ++len)
{
buf[len] = toupper(buf[len]);
/*putchar(buf[len]);*/
}
lseek(fd, 0, SEEK_SET);
ret = write(fd, buf, sb.st_size);
if (ret == -1)
{
perror("error");
return 1;
}
if (close(fd) == -1)
{
perror("close");
return 1;
}
free(buf);
return 0;
}
使用strace运行程序:
可以看出:read()/write()在频繁访问大文件时,需要调用多个lseek()来确定位置。每次编辑read()/write(),在物理内存中的双份数据。而mmap内存映射文件之后,操作内存即是操作文件,可以省去不少系统内核调用(lseek, read, write)。
『问题五』:fork/写时复制是如何使用内存空间的?
『问题五解决』:
我们知道,一个进程在地址空间上的表现形式主要是:正文段,数据段,堆,栈。内核为其分配相应的数据结构来表示它们,其看做是进程在地址空间的实体,也可以想象为灵魂。随后内核会为这四部分分配相应的载体,即真正的物理存储,那么这些物理存储就是进程的真正实体。就像灵魂要附之于身体一样。
有一个父进程P1,这是一个主体(有灵魂也身体)。现在在其虚拟地址空间(有相应的数据结构表示)上有:正文段,数据段,堆,栈这四个部分。相应的,内核要为这四个部分分配各自的物理块,即:正文段块,数据段块,堆块,栈块。
- 现在P1用fork()函数为进程创建一个子进程P2,内核:(1)复制P1的正文段,数据段,堆,栈这四个部分,注意是其内容相同。(2)为这四个部分分配物理块,P2的:正文段->PI的正文段的物理块,其实就是不为P2分配正文段块,让P2的正文段指向P1的正文段块,数据段->P2自己的数据段块(为其分配对应的块),堆->P2自己的堆块,栈->P2自己的栈块。如下图所示:同左到右大的方向箭头表示复制内容。
- 写时复制技术:内核只为新生成的子进程创建虚拟空间结构,它们来复制于父进程的虚拟究竟结构,但是不为这些段分配物理内存,它们共享父进程的物理空间,当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间。
代码托管
学习感悟和思考
- 第九章主要介绍了虚拟内存系统的工作方式和特性,将计算机系统中的硬件和软件结合起来,进行了详细地阐述。本章还介绍了标准库的malloc和free等操作,加深了对虚拟内存系统运作过程的理解。最后总结了C语言中与存储器有关的错误,的确都是初学者容易忽略的问题,在今后编程的过程中需格外注意。
- 本周学习时比以往更加注重实践,遇到单靠读教材难以理解的问题就查阅相关资料,动手验证。例如mmap()和read()/write()等函数的性能差异等等。而解决问题往往需要一些其他知识的积累,或是总能碰见与之相关的知识拓展。如果学有余力,不妨顺便了解了解,这样一来,点状的知识结构就能在一次次拓展与关联中编织成网状,更加稳固坚实了。
学习进度条
代码行数(新增/累积) | 博客量(新增/累积) | 学习时间(新增/累积) | 重要成长 | |
---|---|---|---|---|
目标 | 5000行 | 20篇 | 400小时 | |
第一周 | 50/50 | 1/1 | 8/8 | 了解计算机系统、静态链接与动态链接 |
第三周 | 451/501 | 2/3 | 27/35 | 深入学习计算机算术运算的特性 |
第四周 | 503 / 1004 | 1/4 | 20/55 | 掌握程序崩溃处理、Linux系统编程等知识,利用所学知识优化myod,并实现head和tail命令 |
第五周 | 315 / 1319 | 3/7 | 29/84 | 掌握“进程”的概念,并学习应用相关函数;了解程序如何在机器上表示 |
第七周 | 264 / 1583 | 1/8 | 15/99 | 了解处理器的体系结构,学习多线程的概念 |
第九周 | 634 / 2217 | 2/10 | 10/109 | 学习存储器系统的层次结构,以及改善程序性能的方法 |
第十一周 | 486 / 2703 | 2/12 | 23/132 | 理解虚拟内存的工作原理和特性 |