深入理解计算机技术总结

来源:互联网 发布:ping不通阿里云服务器 编辑:程序博客网 时间:2024/06/05 08:23

参考了http://wdxtub.com/2016/04/16/thin-csapp-1/这篇博客及“深入理解计算机操作系统”这本书。谢谢

一、前言

计算机脱胎于图灵机的构想,简单说,就是能够执行有限逻辑数学过程的计算模型。图灵机中最重要的两个『物理』硬件是纸带和读写头(这里『物理』指的是相对于图灵机其他部分而言)。计算机学科的发展,与其说是众人拾柴火焰高,不如说是天才引导的历程。

O(n3) 真的很糟糕,O(1) 真的就很好吗?虽然在单纯的算法分析中是如此,但是在计算机系统中,算法只是一小部分。假设一个 O(1) 的算法会导致死锁,虽然看起来比 O(n3) 的算法好得多,然而真正执行起来,可能就是无尽的等待了。程序执行并不是一锤子买卖,从算法到数据表示再到程序流程,从内存到缓存再到运算器。不理解计算机系统本身,不理解程序是如何编译执行,又怎么能够写出好程序呢?如果一定要在计算机系统中找一个关键词,在我看来一定是『权衡』,在之后的学习过程中,我们会常常看到因为实际与理论的差异不得不做出的妥协,而真正的智慧结晶,则是在妥协的同时找到最接近完美的权衡,可谓『带着镣铐跳舞』。

研究问题有两种方法,一种是自顶向下,另一种是自底向上。对于设计来说,很多时候是自顶向下的,从一个整体想法出发,然后慢慢细化;而在学习化学的时候,往往是自底向上的,比方说先去了解组成元素的基本粒子,然后在这些粒子的基础上进行更加抽象的研究。从这个角度看,学习计算机系统,自底向上可能是一个好的方向。

二、布尔代数

当做传统代数题时,要遵循一定的规则(如交换律等)。这些规则可能已经和实践融为一体,以至于我们不再认为它们是规则,甚至忘记了它们的名字。但规则确实是任何形式的数学的基础。传统代数的另外一个特点是它总是处理数字。在布尔代数中(布尔的代数最终被这样命名)操作数不是指数字,而是指集(类)。一个类仅仅表示一组事物,也就是后来熟知的集合。

人类的推理和判断,因此就变成了数学运算。

20世纪初,英国科学家香农指出,布尔代数可以用来描述电路,或者说,电路可以模拟布尔代数。于是,人类的推理和判断,就可以用电路实现了。这就是计算机的实现基础。虽然布尔代数可以判断命题真伪,但是无法取代人类的理性思维。原因是它有一个局限。它必须依据一个或几个已经明确知道真伪的命题,才能做出判断。比如,只有知道"所有人都会死"这个命题是真的,才能得出结论"苏格拉底会死"。布尔代数只能保证推理过程正确,无法保证推理所依据的前提是否正确。如果前提是错的,正确的推理也会得到错误的结果。而前提的真伪要由科学实验和观察来决定,布尔代数无能为力。

三、比特

在计算机中,我们看到的一切,归根到底,都是比特,每个比特不是 0就是 1。计算机就是通过对比特进行不同方式的编码和描述,来完成执行不同的任务。那么问题来了,为什么是比特而不是其他呢?这就要从模拟电路讲起,一言以蔽之就是,比特这种描述方式很好存储,并且在有噪声或者传输不那么准确的情况下,也能保持比较高的可靠度(电压值有一定的容错范围)。

四、数的表示

原码、反码、补码

补码的设计目的是: 
⑴使符号位能与有效值部分一起参加运算,从而简化运算规则. 
⑵使减法运算转换为加法运算,进一步简化计算机中运算器的线路设计 

五、从 C 到机器代码

机器代码就是处理器能够直接执行的字节层面上的程序,但是对于人类来说基本上是不可读的,所以把字节按照具体含义进行『翻译』,就成了人类可读的汇编代码。注意这里的用词是『翻译』而不是『编译』,可以认为汇编代码就是机器代码的可读形式。

机器代码和 C 代码应用两套完全不同的逻辑,机器代码是纯粹从『执行』的方式来进行思考的,而 C 的话则因为较多的抽象有了『程序设计』这个概念。

处理器能够执行的操作其实是非常有限的,简单来说只有三种:存取数据、计算和传输控制。存取数据是在内存和寄存器之间传输数据,进行计算则是对寄存器或者内存中的数据执行算术运算,传输控制主要指非条件跳转和条件分支。这也就是为什么汇编代码有固定的 指令操作数1 (,操作数2 ,操作数3) 这样的形式了。

六、程序优化

最根源的优化是对编译器的优化,比方说在寄存器分配、代码排序和选择、死代码消除、效率提升等方面,都可以由编译器做一定的辅助工作。

但是因为这毕竟是一个自动的过程,而代码本身可以非常多样,在不能改变程序行为的前提下,很多时候编译器的优化策略是趋于保守的。并且大部分用来优化的信息来自于过程和静态信息,很难充分进行动态优化。

接下来会介绍一些我们自己需要注意的地方,而不是依赖处理器或者编译器来解决。

1、代码移动

如果一个表达式总是得到同样的结果,最好把它移动到循环外面,这样只需要计算一次。编译器有时候可以自动完成,比如说使用 -O1 优化。一个例子:

void set_row(double *a, double *b, long i, long n){

    long j;

    for (j = 0; j < n; j++){

        a[n*i + j] = b[j];

    }

}

这里 n*i 是重复被计算的,可以放到循环外面

long j;

int ni = n * i;

for (j = 0; j < n; j++){

    a[ni + j] = b[j];

}

2、减少计算强度

用更简单的表达式来完成用时较久的操作,例如 16*x 就可以用 x << 4 代替,一个比较明显的例子是,可以把乘积转化位一系列的加法,如下:

for (i = 0; i < n; i++){

    int ni = n * i;

    for (j = 0; j < n; j++)

        a[ni + j] = b[j];

}

可以把 n*i 用加法代替,比如:

int ni = 0;

for (i = 0; i < n; i++){

    for (j = 0; j < n; j++)

        a[ni + j] = b[j];

    ni += n;

}

3、公共子表达式

可以重用部分表达式的计算结果,例如:

/* Sum neighbors of i, j */

up =    val[(i-1)*n + j  ];

down =  val[(i+1)*n + j  ];

left =  val[i*n     + j-1];

right = val[i*n     + j+1];

sum = up + down + left + right;

可以优化为

long inj = i*n + j;

up =    val[inj - n];

down =  val[inj + n];

left =  val[inj - 1];

right = val[inj + 1];

sum = up + down + left + right;

虽然说,现代处理器对乘法也有很好的优化,但是既然可以从 3 次乘法运算减少到只需要 1 次,为什么不这样做呢?蚂蚁再小也是肉嘛。

4、小心过程调用

我们先来看一段代码,找找有什么问题:

void lower1(char *s){

    size_t i;

    for (i = 0; i < strlen(s); i++)

        if (s[i] >= 'A' && s[i] <= 'Z')

            s[i] -= ('A' - 'a');

}

问题在于,在字符串长度增加的时候,时间复杂度是二次方的!每次循环中都会调用一次 strlen(s),而这个函数本身需要通过遍历字符串来取得长度,因此时间复杂度就成了二次方。

可以怎么优化呢?简单,那么只计算一次就好了:

void lower2(char *s){

    size_t i;

    size_t len = strlen(s);

    for (i = 0; i < len; i++)

        if (s[i] >= 'A' && s[i] <= 'Z')

            s[i] -= ('A' - 'a');

}

为什么编译器不能自动把这个过程调用给移到外面去呢?

前面说过,编译器的策略必须是保守的,因为过程调用之后所发生的事情是不可控的,所以不能直接改变代码逻辑,比方说,假如 strlen 这个函数改变了字符串 s 的长度,那么每次都需要重新计算。如果移出去的话,就会导致问题。

所以很多时候只能靠程序员自己进行代码优化。

5、注意内存问题

接下来我们看另一段代码及其汇编代码

// 把 nxn 的矩阵 a 的每一行加起来,存到向量 b 中

void sum_rows1(double *a, double *b, long n)

{

    long i, j;

    for (i = 0; i < n; i++)

    {

        b[i] = 0;

        for (j = 0; j < n; j++)

            b[i] += a[i*n + j];

    }

}

可以看到在汇编中,每次都会把 b[i] 存进去再读出来,为什么编译器会有这么奇怪的做法呢?因为有可能这里的a 和 b 指向的是同一块内存地址,那么每次更新,都会使得值发生变化。但是中间过程是什么,实际上是没有必要存储起来的,所以我们引入一个临时变量,这样就可以消除内存引用的问题。

(因为a,b是指针,不确定是否指向同一内存。而定义一个局部变量,此时肯定不会在同一个内存上)

// 把 nxn 的矩阵 a 的每一行加起来,存到向量 b 中

void sum_rows2(double *a, double *b, long n)

{

    long i, j;

    for (i = 0; i < n; i++)

    {

        double val = 0;

        for (j = 0; j < n; j++)

            val += a[i*n + j];

        b[i] = val;

    }

}

6、处理条件分支

因为现在处理器采用的一般都是流水线,所以遇到分支的时候会跳转到其他地方,顺序加入的程序可能不是需要的。这个时候就只能清空流水线,然后重新进行载入。为了减少清空流水线所带来的性能损失,处理器内部会采用称为『分支预测』的技术。

七、异常 Exception

这里的异常指的是把控制交给系统内核来响应某些事件(例如处理器状态的变化),其中内核是操作系统常驻内存的一部分,而这类事件包括除以零、数学运算溢出、页错误、I/O请求完成或用户按下了 ctrl+c 等等系统级别的事件。

系统会通过异常表(Exception Table)来确定跳转的位置,每种事件都有对应的唯一的异常编号,发生对应异常时就会调用对应的异常处理代码。

八、并发编程

所谓并发编程是指在一台处理器上同时处理多个任务。并发是在同一实体上的多个事件。多个事件在同一时间间隔发生。(不一定是同时,同时的叫并行,也可称为并发)

1. 并发:在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行。其中两种并发关系分别是同步和互斥
2. 互斥:进程间相互排斥的使用临界资源的现象,就叫互斥。
3. 同步:进程之间的关系不是相互排斥临界资源的关系,而是相互依赖的关系。进一步的说明:就是前一个进程的输出作为后一个进程的输入,当第一个进程没有输出时第二个进程必须等待。具有同步关系的一组并发进程相互发送的信息称为消息或事件。其中并发又有伪并发和真并发,伪并发是指单核处理器的并发,真并发是指多核处理器的并发。
4. 并行:在单处理器中多道程序设计系统中,进程被交替执行,表现出一种并发的外部特种;在多处理器系统中,进程不仅可以交替执行,而且可以重叠执行。在多处理器上的程序才可实现并行处理。从而可知,并行是针对多处理器而言的。并行是同时发生的多个并发事件,具有并发的含义,但并发不一定并行,也亦是说并发事件之间不一定要同一时刻发生。

5. 多线程:多线程是程序设计的逻辑层概念,它是进程中并发运行的一段代码。多线程可以实现线程间的切换执行。

6. 异步:异步和同步是相对的,同步就是顺序执行,执行完一个再执行下一个,需要等待、协调运行。异步就是彼此独立,在等待某事件的过程中继续做自己的事,不需要等待这一事件完成后再工作。线程就是实现异步的一个方式。异步是让调用方法的主线程不需要同步等待另一线程的完成,从而可以让主线程干其它的事情。

现代操作系统提供了三种基本的构造并发程序的方法

1、基于进程

内核自动管理多个逻辑流

每个进程有其私有的地址空间(也就是说进程切换的时候需要保存和载入数据)

2、基于事件

由程序员手动控制多个逻辑流

所有的逻辑流共享同一个地址空间

这个技术称为 I/O multiplexing

3、基于线程

内核自动管理多个逻辑流

每个线程共享地址空间

属于基于进程和基于事件的混合体

程序计数器(PC)值的序列叫做逻辑控制流,简称逻辑流。

这里简单归纳下三种并行方法的特点:

基于进程:难以共享资源,但同时也避免了可能带来的共享问题、添加/移除进程开销较大

基于事件:非常底层的实现机制、使用全局控制而非调度、开销比较小、但是无法提供精细度较高的并行、无法充分利用多核处理器

基于线程:容易共享资源,但也容易出现问题、开销比进程小、对于具体的调度可控性较低、难以调试(因为事件发生的顺序不一致)

举个现实的例子也许你就清楚了:如果把我们上课的过程看成一个进程的话,那么我们要做的是耳朵听老师讲课,手上还要记笔记,脑子还要思考问题,这样才能高效的完成听课的任务。而如果只提供进程这个机制的话,上面这三件事将不能同时执行,同一时间只能做一件事,听的时候就不能记笔记,也不能用脑子思考,这是其一;如果老师在黑板上写演算过程,我们开始记笔记,而老师突然有一步推不下去了,阻塞住了,他在那边思考着,而我们呢,也不能干其他事,即使你想趁此时思考一下刚才没听懂的一个问题都不行,这是其二。

现在你应该明白了进程的缺陷了,而解决的办法很简单,我们完全可以让听、写、思三个独立的过程,并行起来,这样很明显可以提高听课的效率。而实际的操作系统中,也同样引入了这种类似的机制——线程。(也可写为一个个的进程,但是进程间的切换比较费时,而且进程间的通信比较复杂,必须使用显式的机制。而一个进程的线程都在同一地址空间内。切换与通信都很简单)

进程和线程的主要差别在于它们是不同的操作系统资源管理方式。进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉就等于整个进程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。但对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。

关于I/O多路复用(又被称为“事件驱动”)相当于不用每个socket轮换着查询是否有数据,统一用一个线程去查询,有数据的时候就通知相应的socket执行就可以了。

下面举一个例子,模拟一个tcp服务器处理30个客户socket。
假设你是一个老师,让30个学生解答一道题目,然后检查学生做的是否正确,你有下面几个选择:

1. 第一种选择:按顺序逐个检查,先检查A,然后是B,之后是C、D。。。这中间如果有一个学生卡主,全班都会被耽误。
这种模式就好比,你用循环挨个处理socket,根本不具有并发能力。
2. 第二种选择:你创建30个分身,每个分身检查一个学生的答案是否正确。 这种类似于为每一个用户创建一个进程或者线程处理连接。
3. 第三种选择,你站在讲台上等,谁解答完谁举手。这时C、D举手,表示他们解答问题完毕,你下去依次检查C、D的答案,然后继续回到讲台上等。此时E、A又举手,然后去处理E和A。
什么是I/O多路复用

       关于什么是I/O多路复用,在知乎上有个很好的回答,

  这里记录一下自己的理解。我认为要理解这个术语得从两方面去出发,一是:多路是个什么概念?二是:复用的什么东西?先说第一个问题。多路指的是多条独立的i/o流,i/o流可以这么理解:读是一条流(称之为读流,比如输入流),写是一条流(称之为写流,比如输出流),异常也是一条流(称之为异常流),每条流用一个文件描述符来表示,同一个文件描述符可以同时表示读流和写流。再来看第二个方面,复用的是什么东西?复用的是线程,复用线程来跟踪每路io的状态,然后用一个线程就可以处理所有的io。

       当然,不提什么I/O多路复用也能在一个线程就处理完所有的io流,用个while循环挨个处理一次不就解决了嘛?那为什么还要提出这个技术呢?原因就是刚才我们想的方法(轮询)效率太低了,资源利用率也不高。试想一下,如果某个io被设置成了阻塞io,那么其他的io将被卡死,也就浪费掉了其他的io资源。另一方面,假设所有io被设置成非阻塞,那cpu一天到晚也不用干别的事了,就在这不停的问,现在可以进行io操作了吗,直到有一个设备准备好环境才能进行io,也就是在设备准备io环境的这一段时间,cpu是没必要瞎问的,问了也没结果。

       随后硬件发展起来了,有了多核的概念,也就有了多线程。这个时候可以这样做,来一条io我开一个线程,这样的话再也不用轮询了。然而,管理线程是要耗费系统资源的,程序员也开始头疼了,线程之间的交互是十分麻烦的。这样一来程序的复杂性蹭蹭蹭地往上涨,io效率是可能提高了,但是软件的开发效率却可能减低了。

       所以也就有了I/O多路复用这一技术。简单来说,就是一个线程追踪多条io流(读,写,异常),但不使用轮询,而是由设备本身告知程序哪条流可用了,这样一来就解放了cpu,也充分利用io资源。

父进程为什么要创建子进程

       在程序设计时,某一个具体的功能模块可以通过函数或是线程等不同的形式来实现。对于同一进程而言,这些函数、线程都是存在于同一个地址空间下的,而且在执行时,大多只对与其相关的一些数据进行处理。如果算法存在某种错误,将有可能破坏与其同处一个地址空间的其他一些重要内容,这将造成比较严重的后果。为保护地址空间中的内容可以考虑将那些需要对地址空间中的数据进行访问的操作部分放到另外一个进程的地址空间中运行,并且只允许其访问原进程地址空间中的相关数据。(进程之间都是有独立的占用内存,子进程只是把父进程的相关变量值复制到了自己的变量中,所以修改的都是自己内存区域的变量的值。)

指针指向的是相同的地址,为什么相同内存地址存储的数据是不一样的呢?

        程序里看到的指针的值,比如0x804a0d4,都是虚拟地址。每个进程都有独立的4G的虚拟地址空间,映射到不同的物理空间。比如我现在同时运行IE和QQ,他们都有可能访问各自的0x804a0d4这个虚拟地址,但是映射之后的物理内存就不会是同一个地址。

九、内存

从逻辑地址到物理地址的映射称为地址重定向。分为:

静态重定向--在程序装入主存时已经完成了逻辑地址到物理地址和变换,在程序执行期间不会再发生改变。

动态重定向--程序执行期间完成,其实现依赖于硬件地址变换机构,如基址寄存器。

逻辑地址:CPU所生成的地址。CPU产生的逻辑地址被分为 :p (页号) 它包含每个页在物理内存中的基址,用来作为页表的索引;d (页偏移),同基址相结合,用来确定送入内存设备的物理内存地址。

物理地址:内存单元所看到的地址。

用户程序看不见真正的物理地址。用户只生成逻辑地址,且认为进程的地址空间为0max。物理地址范围从R+0R+max,R为基地址,地址映射-将程序地址空间中使用的逻辑地址变换成内存中的物理地址的过程。由内存管理单元(MMU)来完成。

虚拟存储器是一个抽象概念,它为每一个进程提供了一个假象,好像每个进程都在独占的使用主存。每个进程看到的存储器都是一致的,称之为虚拟地址空间。

每个进程都有自己的虚拟地址空间,对于具有32位寻址能力的机器来说,这个虚拟空间的大小为4GB

虽然每一个32位进程可使用4GB的地址空间,但并不意味着每一个进程实际拥有4GB物理地址空间,该地址空间仅仅是一个虚拟地址空间,此虚拟地址空间只是内存地址的一个范围。进程实际可以得到的物理内存要远小于其虚拟地址空间。进程的虚拟地址空间是为每个进程所私有的,在进程内运行的线程对内存空间的访问都被限制在调用进程之内,而不能访问属于其他进程的内存空间。这样,在不同的进程中可以使用相同地址的指针来指向属于各自调用进程的内容而不会由此引起混乱。

进程使用虚拟地址,由操作系统协助相关硬件,把它转换成真正的物理地址。虚拟地址通过页表(Page Table)映射到物理内存,页表由操作系统维护并被处理器引用。内核空间在页表中拥有较高特权级,因此用户态程序试图访问这些页时会导致一个页错误(page fault)。在Linux中,内核空间是持续存在的,并且在所有进程中都映射到同样的物理内存。内核代码和数据总是可寻址,随时准备处理中断和系统调用。与此相反,用户模式地址空间的映射随进程切换的发生而不断变化

linux操作系统每个进程的地址空间都是独立的,其实这里的独立说得是物理空间上得独立。进程可以使用相同的虚拟地址,这不奇怪,因为转换后的物理地址并非相同的。

既然虚拟地址最终要转换为物理地址,那么为何还需要虚拟地址呢?这有以下几个原因: 
1. 虚拟地址还提供了权限检查功能:在虚拟地址被转换为物理地址访问设备之前,要先进行权限检查。比如我们设置虚拟地址和物理地址之间的映射关系时,可以设置某块地址是只读的、只写的、只有CPU处于管理模式时才能访问等。这些功能可以让系统的内核、用户程序的运行空间相互独立:用户程序即使出错,也无法破坏内核;用户程序A崩溃了,也无法影响到用户程序B。 
2. 设想这种情况:系统同时运行用户程序A、B时,它们都保存在内存中,切换到哪个程序就从哪块内存中取指执行。如果没有虚拟地址, A、B程序的运行地址是不一样的,这使得编译程序时需要程序员自己分配运行地址。但是有虚拟地址后,A、B程序的运行空间都是一样的,至于它们对应哪块实际的地址,这通过设置不同的地址映射关系来确定。这使得我们编程时,无需理会这类繁锁的问题:A程序放在这里、B程序放在那里。 
虚拟地址的引入,不仅使得用户程序可以运行在同样的虚拟地址上,还使得用户程序“看起来”能够使用的内存很大:一个程序在运行之前,没有必要全部装入内存,而仅需要将那些当前要运行的部分先装入内存,其余部分在用到时再从磁盘调入,而当内存耗光时再将暂时不用的部分调出到磁盘。这使得一个大程序可以在较小的内存空间中运行,也使得内存中可以同时装入更多的程序并发执行,从用户的角度看,该系统所具有的内存容量,将比实际内存容量大得多。

从物理内存到虚拟内存

物理地址一般应用在简单的嵌入式微控制器中(汽车、电梯、电子相框等),因为应用的范围有严格的限制,不需要在内存管理中引入过多的复杂度。

但是对于计算机(以及其他智能设备)来说,虚拟地址则是必不可少的,通过 MMU(Memorymanagement unit)把虚拟地址(Virtual Address, VA)转换为物理地址(Physical Address, PA),再由此进行实际的数据传输。大致的过程如下图所示

使用虚拟内存主要是基于下面三个考虑:

  1. 可以更有效率的使用内存:使用 DRAM 当做部分的虚拟地址空间的缓存
  2. 简化内存管理:每个进程都有统一的线性地址空间(但实际上在物理内存中可能是间隔、支离破碎的)
  3. 隔离地址控件:进程之间不会相互影响;用户程序不能访问内核信息和代码

虚拟内存的三个角色:

作为缓存工具

概念上来说,虚拟内存就是存储在磁盘上的 N 个连续字节的数组。这个数组的部分内容,会缓存在 DRAM 中,在 DRAM 中的每个缓存快(cache block)就称为页(page).

每个页表实际上是一个数组,数组中的每个元素称为页表项(PTE,page table entry),每个页表项负责把虚拟页映射到物理页上。

作为内存管理工具


在内存分配中没有太多限制,每个虚拟页都可以被映射到任何的物理页上。这样也带来一个好处,如果两个进程间有共享的数据,那么直接指向同一个物理页即可(也就是上图 PP 6 的状况,只读数据)

虚拟内存带来的另一个好处就是可以简化链接和载入的结构(因为有了统一的抽象,不需要纠结细节)

作为内存保护工具

页表中的每个条目的高位部分是表示权限的位,MMU 可以通过检查这些位来进行权限控制(读、写、执行)

 

内存管理

所有的程序在执行时都存在主存中。这些程序引用的数据也都存储在主存中,以便程序能够访问他们。

单块内存管理:把应用程序载入一段连续的内存区域的内存管理方法。(一次只处理一个程序)

优点在于实现和管理都很简单,但却大大浪费了内存空间和CPU时间。应用程序一般不可能需要操作系统剩余的所有空间,而且在程序等待某些资源的时候,还会浪费CPU时间。

分区内存管理:又分固定分区法(每段内存大小固定)及动态分区法(根据程序的需要创建分区,可能程序会释放一些空间,但是这些空间不连续,每一段大小可能不够,从而无法利用)

分区内存管理法同时把几个程序载入内存,可以有效地利用主存。要记住,一个分区必须能够容纳整个程序。虽然固定分区比动态分区容易管理,但是限制了进来的程序的机会。系统本身可能有足够的空间容纳这些程序。在动态分区中,作业可以在内存中移动,以创建较大的空白分区。

页式内存管理:把进程划分成大小固定的页,载入内存时存储在帧中。(程序不必连续存储)

帧:大小固定的一部分主存,用于存放进程页。

页:大小固定的一部分进程,存储在帧中

页映射表:操作系统用于记录页和帧之间的关系的表

分页的优点在于不必再把进程存储在连续的内存空间中。这种分割进程的能力把为进程寻找一大块可用空间的问题转化成了寻找足够多的小块内存。

采用虚拟内存法,则经常需要在主存与二级存储设备间进行页面交换。会造成系统颠簸,降低系统的性能。

内存陷阱

关于内存的使用需要注意避免以下问题:

  • 解引用错误指针
  • 读取未初始化的内存
  • 覆盖内存
  • 引用不存在的变量
  • 多次释放同一个块
  • 引用已释放的块
  • 释放块失败

可执行程序在存储(没有调入内存)时分为代码区,数据区,未初始化数据区三部分。 

(1)代码区存放CPU执行的机器指令。通常代码区是共享的,即其它执行程序可调用它。代码段(codesegment/text segment)通常是只读的,有些构架也允许自行修改。 

(2)数据区存放已初始化的全局变量,静态变量(包括全局和局部的),常量。static全局变量和static函数只能在当前文件中被调用。 

(3)未初始化数据区(BlockStarted by Symbol,BSS)存放全局未初始化的变量。BSS的数据在程序开始执行之前被初始化为0或NULL。 

代码区所在的地址空间最低,往上依次是数据区和BSS区,并且数据区和BSS区在内存中是紧挨着的。  

text段和data段在编译时已分配了空间,而bss段并不占用可执行文件的大小,它是由链接器来获取内存的。

bss段(未手动初始化的数据)并不给该段的数据分配空间,只是记录数据所需空间的大小。

data(已手动初始化的数据)段则为数据分配空间,数据保存在目标文件中。

数据段包含经过初始化的全局变量以及它们的值。BSS段的大小从可执行文件中得到 ,然后链接器得到这个大小的内存块,紧跟在数据段后面。当这个内存区进入程序的地址空间后全部清零。包含数据段和BSS段的整个区段此时通常称为数据区。

可执行程序在运行时又多出了两个区域:栈区和堆区。 

(4)栈区。由编译器自动释放,存放函数的参数值,局部变量等。每当一个函数被调用时,该函数的返回类型和一些调用的信息被存储到栈中。然后这个被调用的函数再为它的自动变量和临时变量在栈上分配空间。每调用一个函数一个新的栈就会被使用。栈区是从高地址位向低地址位增长的,是一块连续的内在区域,最大容量是由系统预先定义好的,申请的栈空间超过这个界限时会提示溢出,用户能从栈中获取的空间较小。 

(5)堆区。用于动态内存分配,位于BSS和栈中间的地址位。由程序员申请分配(malloc)和释放(free)。堆是从低地址位向高地址位增长,采用链式存储结构。频繁地malloc/free造成内存空间的不连续,产生碎片。当申请堆空间时库函数按照一定的算法搜索可用的足够大的空间。因此堆的效率比栈要低的多。 

举个例子说明各种变量存放在什么区: 

int a=0; //a在全局已初始化数据区 

char *p1; //p1在BSS区(未初始化全局变量) 

main()

{

int b; //b为局部变量,在栈区 

chars[]="abd"; //s为局部数组变量,在栈区 

//"abc"为字符串常量,存储在已初始化数据区 

char *p1,*p2; //p1,p2为局部变量,在栈区 

char*p3="123456"; //p3在栈区,123456\0在已初始化数据区 

static int c=0; //c为局部(静态)数据,在已初始化数据区 

//静态局部变量会自动初始化(因为BSS区自动用0或NULL初始化)

p1=(char*)malloc(10);//分配得来的10个字节的区域在堆区 

p2=(char*)malloc(20);//分配得来的20个字节的区域在堆区 

free(p1);

free(p2);

p1=NULL; //显示地将p1置为NULL,避免以后错误地使用p1

p2=NULL;

}

0 0
原创粉丝点击