CS107 Programming Paradigms (Stanford University) by Jerry Cain (8-14)学习笔记

来源:互联网 发布:高德地图自定义数据库 编辑:程序博客网 时间:2024/05/22 16:58

Lecture8

1.heap与内存管理

int * arr = malloc(40*sizeof(int));

malloc 在heap中得到的内存块有一个头(4或8字节), 用以记录分配出的内存大小. 所以以上语句实际在内存中开辟的区域大小为164或168字节.

当调用 free(arr);  时, 系统会将arr地址减4或8字节, 得到所分配内存大小, 再释放.

两种错误的产生:

A. int* arr = malloc(100*sizeof(int));  //分配在heap中.

     free(arr+60);

B. int array[100];  //分配在stack中.

    free(arr);  //将地址arr( 减4或8字节,得到相应的内存区块的大小, 根据大小和首地址得到整个内存区块.) 所表示的内存区块, 插入到空闲链表中. 表此块区域已回收. 注意: 实际上这时候区块中的数据还未被清空.


Debug模式下, free会对传入的地址作检测. 如果此地址值不是之前通过malloc得到的, 则报错.

实际malloc分配的内存大小, 多为2的n次幂.( 但用户可使用区块大小还是其申请的大小. )


heap中的所有空闲区块组成空闲区链表( 每一块, 有一个固定大小的头部, 用来保存指向下一个空闲区块的首地址. -- 空闲区块不保存描述本区块大小的数据? ).

调用malloc时, 系统会( ( 根据内存管理软件的策略的不同 )搜索, 遍历此链表, 以得到最匹配的区块( 大于等于申请值的最小区块 ).

调用free时,只是将分配出去的内存块, 重新插入到空闲区链表中. 若free释放的区块与空闲链表中某区块相邻, 则会自动将这两者合并.

heap内存区域的合并操作, 会将分散的小区块合并为连续的一整块. 有利于充分利用小区块空间, 但会带来分配出去的内存的地址的变动. 解决这一问题的方法之一, 如Jerry所说的在Mac中, 采用二级指针来隔离用户地址与系统地址. 这样可以当系统对内存进行合并时, 不会对用户所持有的内存地址造成影 响. 但用户在读写区块中数据时, 要使用lock和unlock机制, 以防止合并操作在读写的过程中进行.


< 关于内存管理的问题, 记得Braw W.Kernighan在他的经典著作《C Programming Language》中有过介绍。>

  

2. stack内存

课程中的例子:

void  A()

{

     int  a;

    short  b[4];

    double  c;

    B();

    C();

}


void  B()

{

     int  x;

     char*  y;

     char*  z[2];

     C();

}


void  C()

{

     double  m[3];

     int  n;

}


以上各函数中的变量, 在内存中的地址都是连续的. 函数被调用时, 变量被存放在此函数相应的stack frame中, 同时栈指针减去( 本函数内 )所有局部变量所占内存空间的大小.

函数调用结束时, 栈指针又会加上此内存空间的大小, 表示原来占用的stack frame空间被释放. 注意: 实际上,原空间中的变值仍保留在内存中,而未被清零.


3.代码段、寄存器及指令运行机制

比堆空间更低的( 地址值更小的 )内存中存放的是代码段.存放着程序逻辑( 对应着编译后的汇编代码 ).  

每CPU单元,16/32个寄存器.每个寄存器从硬件实现上都可以从任意内存中读写数据.

ALU进行的操作: 加 乘 移位 与或 

ALU与寄存器相连而不与内存直接连通.所有和计算工作必须使用寄存器来完成.也就是说,内存中heap和stack中的数据要先进入寄存器,然后才能参与( 或被执行 )运算.运算结果也是先保存在寄存器中,然后再写入内存相应的heap或stack中的.

j += i;

如果ALU与内存直接相连( 不采用寄存器结构 )的话,类似 j+= i; 这种语句翻成的汇编代码指令数会相对较少些.而采用寄存器结构翻译成的汇编代码指令数会多些,但总的时间效率上来看,后者要大大优于前者.



Lecture9

C/C++语句所对应的汇编代码及CPU运行机制

        本课程主要是想通过讲解高级语言( C/C++ )与编译后生成的汇编语言的对应关系, 来描述程序是如何在机器中运行的. 为了说明原理, Jerry 事先作了一系列的简化工作, 比如: 自定义了一种汇编语言的格式( 伪汇编 ),  将指令长度规定为定长( 4字节 ), 将指令集中的指令精简为 load, store,  ALU,  JMP等几条.

 关于指令要注意以下几点:

1. 指令系统, 指令与指令格式

 通常指令集( 或称指令系统 )是由CPU生产厂家在生产出某型号的产品后, 针对此产品所发布的. 实际它发布的是一种指令的格式. 严格的来说, " 指令" 指的是运行时候的二进制编码, 它除包含操作码外, 还包含了当前所需要的数据. 所以指令应该是由编译器根据程序汇编而成的, 而不是指令集中的指令格式.

 就Intel指令集来说, 它的指令是变长的.

    指令一般由操作码和操作数构成.操作数既可以是实际要处理的数字值,又可以是存放待处理数值的存储器的地址.( 当操作数表示地址时,一般是指内存地址.在汇编语言中,寄存器名--或称寄存器地址--也出现在这个位置,但实际上在一般的机器码中,对哪个寄存器进行操作是由操作码指定的.也就是说,同样的move操作,面对不同的寄存器时,操作码部分就会被翻译成不同的机器码.具体可参见Intel的指令系统. )

    关于Intel指令集的指令格式(略).

    不论是定长还是变长的指令系统, 其操作码部分都有可能是变长的. ( 从上面Intel指令集格式可知, Intel指令的操作码部分是变长的. )  操作码为定长的好处是缩短了译码时间, 简化硬件结构.


2. 指令系统中的指令类型

 指令系统中的指令少则几十条,多的可达数百条,但大多可以分为以下几类: 

 a. 数据传送  比如: Jerry说的 load/store, 但load/store仅是指寄存器与内存间的数据传送, 实际上数据传送类指令还包括寄存器间的数据传送.

    b. 算术运算

 c. 逻辑运算

    d. 移位操作( C/C++有直接对应的命令与此低层的指令相对应. )

    e. 比较操作

 f.  跳转操作

    g. 调用与返回

 h. 输入与输出

    g. 其他如: 中断,等待等指令

    具体就Intel指令集来说可参见:http://blog.sina.com.cn/s/blog_949d67ea01014ot3.html  对其中机器码部分相关符号的说明可参见:http://zhangxunzi.zxq.net/ebook/xxdos/f/dospro/intelm.htm ( 可以结合上面机器码格式的说明来理解.比如 modr/m  的含义. ) 


3. 指令的处理过程

    指令周期:  是指从取一条指令到执行完毕这条指令的时间总和.

    机器周期:  又称为CPU周期. 是指CPU从内存中读取一条指令的最短时间.

    时钟周期:  又称为CPU脉冲周期,与CPU周期是两个概念.一个CPU周期往往包含几个时钟周期.( 参见:http://www.ic72.com/news/2009-07-07/140579.html 和 http://baike.baidu.com/view/713240.htm?func=retitle) . 通常在买CPU时所说的CPU主频,就是指的CPU脉冲周期或CPU时钟周期(参见:http://detail.zol.com.cn/product_param/index495.html).

    所以课程中出现的,把取指周期说成是一个时钟周期,要么是翻译的问题,要么是Jerry为了简化问题才这么说的.

 一个指令周期往往分为以下几部分:a.取指 b. 译码 c. 执行  d. 存储结果( 如果有必要的话.否则此步可取消. ) 其中第三步--执行--所需要的时间往往和指令中的操作数有关. ( 参见: http://baike.baidu.com/view/178156.htm  )


4. 寻址的概念

 所谓的寻址,不是根据地址去找数据,而是计算出所需地址值的过程.寻址分两大类:对指令的寻址( 即计算指令的地址值 ) 对操作数地址的寻址.

1) 对指令的寻址

  CPU内部有一个指令计数器的概念,简称PC( Program Counter ). 程序编译好后执行时会被加载到内存中.PC记录了当前指令相对代码段基地址的偏移量.Intelx86系列中PC实际上是由IP寄存器来实现的, 而单片机中的指令寄存器又称之为PC.  源代码通过编译会变成一条条的指令--机器码,这些指令一条紧接一条顺序排列.假设指令 A 表前一条指令,指令 B 表紧接其后的指令.那么: B 的地址 = A 的地址 + A 的指令长度( 机器码字节数 ).用VC可以清楚地看到,编译后指令在内存中的排列情况.

  一般情况下指令是按顺序执行的.也就是说获取下一条指令地址时,PC中的地址的计算方法是:当前正在被执行的指令的地址加上当前正在被执行的指令的长度.

  但当程序中出现:分支、循环或函数调用时,PC中地址的计算方法就会发生改变。这种改变是由JMP, Call,  Return. 三种指令造成的。也就是说,当CPU遇到以上三类指令时,会按照这三种指令的要求来修改PC中保存的地址值。

  分支结构实际上是由CMP指令与有条件的JXP指令组合而成的( 带else的分支结构还要加无条件的JMP指令 )。循环结构是由CMP指令, 有条件JXP指令及无条件JMP指令共同组合而成。Call Return, 一般成对出现,用以实现对子函数的调用,及函数调用结束后的返回。

  这些高级语言与指令在结构上的对应情况,可以通过分别写包含最简单的语句结构的程序,利用VC中的工具来直观地看到。

另外还需要注意的两点:

A.  CMP改变的是标志寄存器( 又叫状态寄存器 )中的ZF标志。CMP指令的运算结果是一个布尔值,它决定了ZF标志位的值。条件转换指令JXP是根据当前标志寄存器中的ZF标志来决定是否跳转的,所以为了保证逻辑上的正确性,一般JXP指令会紧随CMP指令之后。

B.  不论是用按地址加指令长度的方式获得下一条指令的地址,还是用按分支、循环的条件进行跳转的方式获得下一条指令的地址,它们的计算过程都是一样的:都是用一个基址( 往往是本函数的入口地址 ),加一个地址偏移量来获得( 这可以通过VC工具可以看到 )。这种方式叫作相对寻址( 也叫偏移寻址)。如果是按“ 自然 ”方式获取地址,其相对寻址方式就相当于,在当前执行指令的偏移量上加了一个当前指令长度( 机器码字节数 ),从而得到了一个新偏移量。而JXP与JMP则需要直接给出目标指令( 下一条指令 )相对于函数入口地址的偏移量。


2) 对操作数地址的寻址

  对操作数的地址的寻址方式有:立即寻址、直接寻址( 直接给出的地址可以是内存地址,也可以是寄存器地址。同一种操作,不同寄存器对应着不同的操作码。 )、间接寻址( 内存地址指向的内存中保存的还是地址,寄存器地址指向的寄存器中保存的还是地址)、基址寻址和变址寻址。

  基址寻址与变址有些类似但用途不同:基址寻址中,基址保存在某寄存器内。此种寻址方式主要用于为程序或数据分配存储空间,一般目的在于扩大寻址范围,或者为保证系统安全而设。变址寻址中,基址是一个内存地址,偏移量保存在寄存器中,故可以用来处理对数组的访问。


Intel寄存器的分类及作用(略)。 指令在机器中的调度方式可以参见《计算机体系结构》的有关章节。

Intel的标志寄存器中有一个跟踪标志位TF,用以控制CPU是否进入单步调试方式。VC和XCode 的 Debug 功能应该就是以此为基础的。另外标志寄存器的OF( 溢出标志位 )、IF( 中断标志位 )应该与操作系统的抛弃常机制有关。


以上是本课程的概念基础.Jerry没有空讲这些抽象的干巴巴的概念.而是通过实际的代码,并将其翻译成实际的汇编语言来生动,深入浅出地讲解.在讲清了C/C++代码与汇编代码间的对应关系的同时,也就讲清了以上的这些概念,更重要的是讲的是活的,有实际指导意义的东西.讲清了计算机的实际运行机制,使听者清清楚地明白,自已写的代码是怎样具体地来指挥计算机的运行的;是怎样操作计算机的.

int  i;

int  j;     //注意函数中,最后定义的变量地址最低,其地址值是函数入口地址。设此地址保存在寄存器R1中。

i = 10;

 M[R1 + 4] = 10;

j = i+7;

    R2 = M[R1 + 4];   //M[] 表内存单元,并假设默认情况下取四字节数据。 ( 下同 )

    R3 = R2 + 7;  //假设有多个寄存器:R1, R2, R3...  ( 下同 ) 

    M[R1] = R3;

j++;

   R2 = M[R1];

   R2 = R2+1;

   M[R2] = R2;


//-----------------------------------------------------------------


int  i;

short  s1;

short  s2;

i = 200;

    M[R1 + 4] = 200;

s1 = i;                             //从内存到内存的数据传送,必须通过寄存器中转。在一个CPU周期内无法实现 load 和 store两个操作。即不同出现M[R1 + 2] = M[R1 + 4]的表达方式。

    R2 = M[R1 + 4];

    M[R1 + 2] = .2 R2;     

s2 = s1+1;

   R2 = .2 M[R1 + 2];

   R3 = R2 + 1;

   M[R1] = .2 R3;


//---------------------------------------------------------------


int  arrar[4];

int  i;

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

{

    array[i] = 0;

}

i --;


M[R1] = 0;  // i = 0;

R2 = M[R1];

BGE  R2  4  PC + 40; //若 i>=4 跳出循环. 一条指令4字节,循环内有9条指令, 故跳出循环后的第一条指令地址的偏移量就是4*10 = 40.

R3 = M[R1];

R4 = R3*4; // 数组当前元素的位置, 数组array中的元素是4字节的整型数,所以要乘以4. C/C++的sizeof用来计算不同数据类型的长度。编译后必须要给出实际计算结果,在汇编      中是不存在类似sizeof这样的表达的。

R5 = R1 + 4; //数组array定义在变量i之前,故R5得到了array的首地址.

R6 = R4 + R5;

M[R6] = 0;  // array[i] = 0;

R2 = M[R1];

R2 = R2 + 1;

M[R1] = R2;   //以上三条指令实现i++

JMP  PC - 40;

....... // i --;


//-------------------------------------------------------

写汇编代码的风格:逐句翻译设级语言,保证上下文无关( 即在翻第二句时,各寄存器的值得重新设定,不要利用寄存器的现有值。以保证数据及操作是独立性--只针对当前要翻译的这一句 )。

//-------------------------------------------------------

定长指令,定长操作码的翻译是容易的。可以在取指后,按固定位数取出操作码,再根据操作码的含义来决定剩下的二进位如何解释。

变长指令,变长操作码的编码一般采用哈夫曼编码( 或称霍夫曼编码 ),其译码过程( 搜索树结构 )从第一字节开始读取就开始翻译,能保证译得正确操作码。然后根据操作码决定剩下的二进制位如何解释。

//-------------------------------------------------------


struct  fraction

{

    int  num;

    int  denom;                         // 注意:函数中的局部变量地址,最后定义的地址值最小,struct中最后定义的域的地址值最大。

};                                                             


struct fraction  pi; // 寄存器R1中保存的是pi的首地址,与域变量num的地址同。

pi.num = 22;

     M[R1] = 22;

pi.denom = 7;

     M[R1 + 4] = 7;

((struct  fraction*)&pi.denom)->denom = 451;

     M[R1 + 8] = 451;


强制类型转换并无对应的指令,而只是允许编译器绕过类型检查来编译代码( 实际上就是用新的方法来计算地址偏移值 )。



Lecture10

1. 程序在内存中的分布

        系统在调用可执行程序时, 会先在内存中开辟所需空间并创建进程. 然后将程序加载到进程空间. 进程空间分为几个区域.  栈处于地址高端, 栈空间( 地址 )的分配是从高到低. 堆处于地址低端. 堆空间( 地址 )的分配是从低到高.  两者之间有一片内存区是用来为动态链接共享库准备的. 进程空间顶端紧挨栈空间的是内核区, 用来保存调用和运行用户程序的相关操作系统内核程序. 进程空间最底端( 地址从0x0开始 ), 被设为NULL区, 当一个指针变量被设为NULL时,就指向这里. 在堆区和NULL区之间, 由低到高分别是代码页( 二进制机器码 ), 已初始化变量( 包括初始化过的全局变量和全局静态变量, 初始化过的局部静态变量. )区, 未初始化变量( 静态及非静态全局变量 )区. 

2. 函数调用与栈

        函数调用是通过栈机制来实现函数调用的. 这个栈不是软件实现的, 而是由硬件机制支撑的. 实现函数调用栈的硬件有: 进程的栈内存区域, SP( ESP )寄存器, BP( EBP )寄存器. 在Jerry的课程中, 只出现了SP寄存器, 大概是为了简化问题, 把主要注意力放在原理的讲解上. SP栈寄存器中保存栈指针, 它始终指向栈空间的最顶端. 实际上在现在的程序函数调用中还用到了一个称之帧指针( FP )的指针变量, 在Intel x86 CPU中, 还有一个称为BP( EBP )的寄存器, 它就专门用来保存函数调用过程中, 当前被调用函数的帧指针.

       在Jerry的课程中, 活动记录(activation record )分为三个部分, 由调用函数填写的实参区域, 由被调函数填写的局部变量区域, 以及夹在这两个区域间的Saved PC区域. Saved PC区域中保存了调用函数中下一条指令的地址( 也是被调函数返回后紧接着要执行的, 位于调用函数中的, 第一条指令的地址 ). Saved PC区域中的这个地址也是由调用函数来填写的. 在实际的函数调用中, 在Saved PC区域和被调函数局部变量区域之间还有一个四字节空间, 这个四字节空间专门用来保存调用函数的帧指针( 也就是调用函数的分配第一个变量之前的地址, 它与调用函数中第一个非静态局部变量的首地址并不相等, 假如第一个局部变量为int 型, 只有把这个地址减4才能得到第一个int型局部变量的首地址. ). 调用函数在将实参入栈后, 会使SP = SP-8( 分配新空间, 并使SP再次指向栈顶. ); 然后分别将被调函数返回后要执行的第一条指令的地址, 及当前BP中保存的地址( 调用函数的帧地址 ), 写入从SP+8开始的8个字节空间. 最后将当前保存在SP中的地址值传给寄存器BP. 这样BP就指向了被调函数的帧地址了.

      注意区分被调函数帧地址与被调函数入口地址的不同.

      关于帧指针和FP寄存器可参考: http://baike.baidu.com/view/770499.htm

      用VC的Registers工具可以查看SP寄存器和BP寄存器中的地址,  可以看到, 被调函数中的所有局部变量的地址都在SP和BP中保存的地址范围之间.

  

void foo(int bar,  int* baz)

{

    char  snink[4];

    short*  why;

        Sp = Sp - 8;   // 为局部变量申请空间

    why = (short*)(snink + 2);

         R1 = Sp + 6;

         M[Sp] = R1;

    * why = 50;

          R1 = M[Sp];

          M[R1] = .2  50;

}

         Sp = Sp + 8;   // foo函数的局部变量出栈, Sp指回到saved pc的内存区.

         RET;   // 取出Sp指向的内存区中的值, 将此值放到PC寄存器中. 并让Sp = Sp + 4; ( 使Saved PC区域出栈 )


foo 的 activation record 内存分布:

高   4  baz

       4  bar      //参数从右至左入栈( 即最左边的参数在栈底, 最右边的参数在栈顶 ). 栈空间分配是从高到低, 但入栈时变量是从内存中低地址的区块开始写的 , 所以baz在bar之上. 

       4 saved pc

       4 snink[0] - [3]

低   4 why


int main(int argc, char** argv)

{

    int  i = 5; 

        Sp = Sp - 4; //为main的局部变量申请内存空间. 减4之前Sp指向main函数的入口地址. 减4后得到的是变量i 的地址.

        M[Sp] = 5;

    foo(i, &i);

         Sp = Sp - 8;  // 为foo传递实参, 分配空间

         R1 = M[Sp+8]; // 默认取四字节

         R2 = Sp+8;  // R2得到变量i 的地址.

         M[Sp] = R1;

         M[Sp+4] = R2;  // 至此两个实参入栈, 填入foo函数的activation record的bar和baz区域中.  上面5行指令相当于 push X X

         CALL <foo> ;   // 使SP = SP+4;(为foo的activation record的Saved PC区域申请内存空间.). 在实际执行CALL的跳转之前, PC指向的是CALL指令地址, 将PC+4( 得到CALL之后的指令"Sp = Sp+8;" 的地址)保存到foo函数的activation record中的 saved PC区. 然后使PC指向foo函数对应的第一条指令的地址.( 注意区分,foo函数对应的第一条指令地址 与 当前Sp指向的foo的activation record的区域地址---是foo函数在分配第一个局部变量前的地址, 这两个概念. )

        Sp = Sp + 8;  // 舍去为foo传实参的8个字节, 至此foo的 activation record完全出栈. 

    return 0;

        RV = 0; // RV寄存器用来在调用和被调函数之间传递返回值.

        Sp = Sp + 4; // 局部变量出栈

}

       RET;  // 取出Sp指向的内存区中的值, 将此值放到PC寄存器中. 并让Sp = Sp + 4; ( 使Saved PC区域出栈 )


//-------------------------------------------------------------------------------------


int  fact(int  n)

{

     if ( n == 0 )

        R1 = M[Sp + 4]; // 跨过Saved PC区获得传递进来的实参值.

        BNE  R1, 0,  PC+12;  //三条指令, 每条四字节

        return 1;

            RV = 1;

            RET;

     return n*fact(n-1);

           R1 = M[Sp+4];  // 跨过Saved PC区获得传递进来的实参值.

           R1 = R1 - 1;  // n = n-1

           Sp = Sp - 4;  //申请子函数的activation record的参数部分的空间

           M[Sp] = R1; //实参入栈

           CALL <fact>;

           Sp = Sp + 4;   //实参空间出栈

           R1 = M[Sp+4];  // 跨过Stack Pointer区获得传递进来的实参值.

           RV = R1*RV;  // n*fact(n-1);

}

           RET;  //由于fact函数中没有要清理的局部变量, 故无需在RET调用前再移动Sp指针.


//-----------------------------------------------------------------------------------

手写汇编要坚持上下文无关的风格的原因是:1) 被调函数也可能使用同样的寄存器. 2)手写汇编随时会需要代码重构,上下文无关的写法在重构时不致于相互影响.



Lecture11


1.  C++中的“引用”的概念.

void  foo()

{

     int  x;

     int  y;

        Sp = Sp - 8; 

     x = 11;

        M[Sp + 4] = 11;

     y = 17;

        M[Sp] = 17;

    swap(&x, &y);

        R1 = Sp;  // &y

        R2 = Sp + 4;  // &x

        Sp = Sp - 8;

        M[Sp] = R2;

        M[Sp + 4] = R1;

        CALL  <swap> ;

        Sp = Sp + 8;

        Sp = Sp + 8;

}

       RET;


void  swap(int* ap, int* bp)

{

     int  temp = *ap;

          Sp = Sp - 4;

          R1 = M[Sp+8];

          R2 = [R1];  // 汇编不支持"[ ]"的嵌套( 比如: M[M[Sp + 8]] )

          M[Sp] = R2;  // temp = *ap;

     *ap = *bp;

          R1 = M[Sp + 12];

          R2 = M[R1];

          R3 = M[Sp + 8];

          M[R3] = R2;  // *ap = *bp;

     *bp = temp;

          R1 = M[Sp];

          R2 = M[Sp+12];

          M[R2] = R1;  // *bp = temp;

         Sp = Sp + 4;

}

        RET;

//----------------------------------------------------------

void  swap(int& a, int& b)

{

      int  temp  = a;

     a = b;

     b = temp;

}


int  main()

{

      int  x;  int y;

      x  = 7;

      y = 11;

     swap(x, y);

}


        函数swap(int& a, int& b)与函数swap(int* ap, int* bp)的汇编代码是完全一样的。在调用swap(int& a, int& b)时,系统会自动取相关变量的地址作为实参传入(在后一个函数需要程序员手动取)。在函数swap(int* ap, int* bp)中,在使用ap, bp对应的内存单元中的数据时,必须程序员手动进行解引用运算。而在函数swap(int& a, int& b)中,当使用变量a, b时,系统会自动解引用它们各自对应的传入的地址( 也就是说在函数体内 a = *ap )。 对返回引用的函数的返回值进行操作时也是如此。对返回值变量的使用,就相当于对返回指针解引用后的变量的使用,不要把返回值当成一个指针来看待,而是把它看成一原来那个被引用的对象。

        因此,引用变量变量名就相当于被引用变量变量名的别名。在变量传递的过程中系统会自动( 隐式 )地进行取址和解引用操作( 实际上传递的是地址 )。 所以可以说引用就是指针,使用起来,引用更方便一些而已( 不需要向指针变量那样加解引用运算符 )。但另一方面,引用不如指针灵活,初始化时一旦与某变量绑定,就无法再改变。


2.  结构和类的比较

C++中的struct和class 在底层的实现上完全一样;当C++的struct不带成员函数时,在底层实现上与C的struct也完全一样。

class binky

{

public:

    int  dundy(int  x,  int  y);

    char*  minky(int*  z)

    {

            int  w = *z;

            return  slinky + dunky(winky, winky);

    }


private:

     int  winky;

    char*  blinky;

    clar  slinky[8];

};


int  main()

{

     int  n = 7;

     binky  b;

     binky.minky(&n);

     return 0;

}


对象b指向的内存区域( 在C++中定义有相同成员变量的struct,或者在C中定义有相同域的struct,其内存区域分布与此处完全相同 ):

高: slinky  8

         blinky  4

低: winky  4


函数调用 b.minky(&n),就相当于函数调用: binky::minky(&b, &n)。其中的&b 是由系统隐式进行的,而函数调用过程与C中的普通函数调用完全一样。要注意的是,传入的地址&b总是保存在 activation record 的参数区域的最前面,也就是&b总是作为第一个参数入栈。这个地址就被称为this指针。

binky的成员函数minky(int *z)中出现的函数调用: dunky(winky, winky) 会被自动设定为: this->dunky(winky, winky). 实际会被翻译成函数调用:binky::dunky(this, winky, winky)。

类的静态成员函数,在调用时则不需要传入对象指针。

类成员函数并不象类定义直观呈现的那样, 保存在对象空间中.  对象是保存在函数的栈中的, 随着函数的调用和返回分配和释放空间.  或者保存在堆中, 由程序员决定何时分配何时收回. 在栈或堆中的对象空间中, 包括且只包括所有的类成员变量, 而并没有用来存放成员函数代码的区域. 类的成员函数代码与C/C++中的普通代码一样, 是保存在进程空间的代码段中的( 紧靠在NULL区, 见上 ). 只不过, 与普通函数不同之处在于, 这些函数都以自已所属的类名为名字空间. 也就是说这些函数有自已的名字空间而已.


以上就是C++与C在底层运行机制上的区别与联系。

//-----------------------------------------------

C++中虚函数的实现机制

       对于带虚函数的类( 不论它的虚函数是自身拥有的, 还是继承得到的 ), 编译器会为它创建一个独立的VTABLE( virtual table ), 并在class/struct相应对象的内存块中放入一个VPTR( vpointer )用来指向VTABLE的首地址. VPTR一般被存放在对象内存块的头部四个字节中,在它之后才是此对象的成员变量( 如果一个类没有成员变量, 则它的对象在实例化时,会被强制设置一个固定大小的空间---- 一般为四个字节. 如果一个类只有虚函数且没有成员变量时, 这四个字节就被VPTR所占用 ). 在调用对象的构造函数时, VPTR被赋予初始值, 指向它的类的VTABLE首地址.

       对于带有虚函数的子类和父类来说, 它们的VTABLE中的虚函数拥有相同的排列顺序. 而调用虚函数的语句, 在编译成相应的汇编代码时, 由 " call  函数指针 " 变成了 " call VPTR+offset ",  VPTR代表类的VTABLE的首地址, offset 是被调函数在VTABLE中相对于其首地址的偏移量. 由于每一个类都有自已的TABLE, 因而在运行中调用时, 操作系统会根据传入的this指针找到此对象所属类自已的VTABLE, 然后再根据偏移量找到这个类的相应的函数. 这样就实现了所谓的" 晚捆绑 "功能. 

//-----------------------------------------------

      派生类对象与其基类对象, 在内存分布上是有区域重叠的. 当函数以传值方式对对象进行参数传递时, 如果实参为派生类对象, 形参为基类对象, 则会发生对象切片的情况. 如果同时基类还使用了虚函数, 那么在传值时, 会调用复制构造函数, 实参对象的VPTR指针指向基类的VTABLE首地址. 

//-----------------------------------------------



Lecture12

预处理器与预编译

1)  #define

#define  kWidth   40

#define  kHeight   80

#define  Max(a, b) (((a)>(b))?(a):(b))

对于以上预定义,在正式编译前,预处理器将在引用处进行纯文本替换。好处:手动修改一处,自动处处更新。坏处:Max(a++, b--);之类的调用会引发错误。


#ifdef  NDEBUG

#define  assert(cond) (void)0

#else

#define  assert(cond) \

               ( (cond)?((void)0) : fprintf(stderr, ".....") ; exit(0);)

#endif


其中,(void)0 表示一个空操作。


2) #include

#include可以嵌套使用,当预处理器遇到#include时,会用递归的方式( 用递归函数来处理?)层层替换,直至生成不含#include和#define的纯文本流。

当两个.h文件相互#include时,会造成循环替换。采用一种#ifndef #define #endif的技术手段可以避免。( #pragma once 在有的编译器的某些版本中也可以起到这个作用,但它并不符合ANSI标准,所以并不是所有编译器都支持。 )


问题:

   A.h文件

#ifndef  _A 

#define _A

#include "B.h"

class A

{

     B    b;

};

#endif


B.h文件

#ifndef  _B

#define _B

#include "A.h"

class B

{

     A   a;

};

#endif


A.cpp文件

#include "A.h"

...


B.cpp文件

#include  "B.h"

...



假设编译器先编译A.cpp, 后编译B.cpp.则:

编译A.cpp时,先由预编译器将A.h展开。展开过程中读到B.h中的#include "A.h"时,由于#ifndef#endif机制,使预编译器跳过这一行,直接将接下来的B的定义部分:

class B

{

     A   a;

};

拷贝到A.cpp的头部,紧随class A 的定义之后的位置。

当编译器编译完A.cpp,开始编译B.cpp时,预编译器并不会保留编译A.cpp时的数据。也就是说,编译器是按照编译单元分别进行编译的,而每个.cpp文件就可以看作是一个独立的编译单元。在linux中这一点体现的最明显:linux中每个源文件分别调用一次gcc来产生.o文件( 每个gcc调用就会导致预编译器与编译器的调用 ),可见A.cpp文件与B.cpp文件在编译时其预编译器是不会相互干扰的。



Lecture13

1.C语言的灵活性和问题所在, C++的改进方案

       指针和指针类型的自由转换, 使C/C++变得很灵活并且能进行很底层的操作. 这使C/C++变得强大的同时,也带来了编程方面的隐患: 比如由于类型转换不当,造成的内存读写错误( 包括内存溢出 ), 内存泄漏等. 这就要求程序员非常小心地使用这些功能.

      另外, C语言源代码在用传统的gcc进行编译时, 如果编译器未能在所提供的所有.h文件中找到函数原型, 则会自动推测存在一个同名函数, 并且假定此函数的参数类型为相应的实参类型, 从而使编译通过( 同时只给出一个warinning而已 ). 在链接过程中, gcc会到所有与当前目标文件关联的库中去寻找这个函数, 只要找到一个同名函数, 就把它链接进来, 而不管这个函数的参数个数及相应参数类型, 是否与编译所得目标文件中的同名函数的相同. 这就会给编程带来很大的隐患. 看下面的例子:


#include <stdlib.h>

#include <stdio.h>

#include <assert.h>

int main()

{

     void * mem = malloc(400);

     assert(mem != NULL);

     printf("Yay!\n");

     free(mem);

     return 0;

}

把 #include<stdlib.h>  #include<stdio.h>去掉后, 编译链接仍可以通过. 程序能正常运行. 原因: 标准库中恰好存在一个同名printf函数, 且函数的参数为字符串首地址.这与编译时编译器"猜的"情况恰好一致.


int main()

{

     int  num = 65;

     int  length = strlen((char*)&num, num);

     printf("length =%d \n",  length);

     return 0;

}

没有strlen函数原型,但编译器可"猜"所以编译通过.gcc只会检查函数名,不会检查参数列表的匹配情况,所以链接可以通过.执行对函数strlen的调用时,由于参数从左向右入栈,并且实参入栈时,由低地址内存往高地址内存写入,并且标准库里的strlen只有一个char*类型的局部变量,所以strlen只能"看到"第一传进来的实参: (char*)&num.因此strlen要么把num当成 A000(内存中的值) 来看待,要么当成 000A (内存中的值) 看待,所以返回的length值要么为1, 要么为0.


函数原型存在的目的就是:让调用函数与被调函数在位于activation record的saved pc区域以上部分的内存布局达成一致.有时为提高编译速度,不将相关.h文件include进来,而是在相关位置将要调用到的函数原型重写一遍,如果函数原型发生改变时漏改,就会在执行造成传入的实参与函数原型不致的情况.从而导致crash. 

例如:

int memcmp(void* v1);  //实际的memcmp是:int memcmp(void* v1, void* v2, int len);

int main()

{

     int  n = 17;

    int  m = memcmp(&n);

    return 1;

}

编译链接可以通过,但执行时crash.


C++为了避免这种错误的发生, 对被调函数及被调函数的参数列表进行严格的类型检查. 比较:

     C:  CALL  <memcmp>

C++:  CALL  <memcmp_void_p> 

以上两指令为memcmp(&n)被编译成相应的汇编时C与C++的不同.


2. 导致程序crash的两类原因

1) seg fault:

     对一个错误的指针解引用时, 比如: *(NULL). 即使用段外地址时.

    NULL区实际上是地址为0x0, 0x1, 0x2, 0x3的四个字节对应的内存区域. 它并不存在于仍何segment中.

2) bus fault:

     系统默认, 非short和非char类型的类型指针( 地址值 )必须是4字节对齐的( 即地址必须为4的倍数 ). 指向short 类型的地址必须是2字节对齐( 即必须为2的倍数 ). 指向char类型的指针则不受此限制. 具体举例来说, 所有地址值位于4的倍数之间( 0x2001, 0x2002, 0x2003 )的内存中,都不允许保存整型值. 否则就会产生bus fault. 可以用VC的内存工具来检验.


3. 两个缓冲溢出导致的无限"循环"

int  main()

{

     int  i;

     int   array[4];

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

     {

           array[i] = 0;

     }

    return 0;

}

溢出值修改了控制变量i.


void  foo()

{

     int  array[4];

     int  i;

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

    {

          array[i]  -= 4;

    }

}

溢出值修改了被调函数foo的activation record的saved pc域.



Lecture14

1. 函数参数与结构域在内存中的分布

    C标准库中有printf()函数, 其函数原型为: int  printf(const char*  control, ...); 第一个参数control 之后的部分"..." 表示此处可有任意多个任意类型的参数. 编译器在编译时不会对其作类型检测.  被调用函数要准确地获取"..."中的数据, 就必须要有一个依据. 根据前面介绍的被调函数栈的运行原理, 第一个参数"control"可担此重任.( 固定大小, 第一个入栈, 紧挨着Saved PC区域.) 在PASCAL语言中,由于参数是由左向右入栈的, 就不可能出现这种可变长的参数列表.( 因为这样一来, 第一个参数会最后一个入栈, 写入栈的最高地址空间, 紧挨着Saved PC区域的就是最右边的参数, 而此时函数的参数列表又是变长的, 所以被调函数就无法定位第一个参数在内存中的位置. 也就无法得到参数列表中, 除第一个参数之外的所有参数的导航了.)

    根据前面所说, C/C++中struct和class在内存中的数据分布是一样的, struct或class变量的第一个域会被编译器设到内存中的最低地址处. 故可以此作为struct变量解析的依据.


2. 虚拟内存

       计算机的实际物理内存是有限的, 而每个进程对内存空间的要求事先无法作限制. 另外, 当计算机同时运行多个进程时, 这种有限与突破有限的矛盾就更加突出. 最后, 在现代的操作系统上运行的程序, 很难在编译阶段就确定它的数据和指令的实际特理地址. 因此, 必须要用有效的技术手段才能解决这些问题.  这种技术称为虚拟内存技术.

      源代码在编译后生成的机器中, 使用的是连续的" 虚拟地址 ", 对应着自身的独立使用的虚拟空间. 程序在运行前, 必须要将这些虚拟地址映射到实际的物理地址中才行. 常见的有两种方法可以实现这种映射. 

      一种被称为" 代码重置 "技术. 每个不同的程序对应一个独立的基地址, 程序一旦被加载运行, 其中的数据和指令的实际物理地址, 就是由其基地址加上原来的虚拟地址而得到的.

     另一种称为" 地址翻译 "技术. 程序在加载到内存后, 其机器码中各数据和指令所用的虚拟地址直接被翻译成实际的物理地址.( 并不是一次性翻译完成,以后重复使用, 也不是每使用一次就要翻译一次, 而是每调度一次就翻译一次, 翻译完成后重复使用, 直到下一次调度为止. 是这样吗?)

      地址翻译也分两种: 一种称为" 分段 "法, 一种被称为" 分页 "法.  分段法是将原来位于连续的虚拟地址空间中的程序, 映射到连续的物理地址空间中. 它的问题是会产生内存碎片.为解决这一问题, 发明了" 分页 "法. 分页法是将原程序占用的连续的虚拟地址空间( 注意, 不是原程序. ), 划分为等长的页(1KB/4KB/8KB), 将物理内存也划分同样等长的页, 然后根据一定的策略, 将当前需要用到的虚拟页映射到实际的物理空间页上, 收回没用的虚拟页所对应的物理页.

      如果, 程序在运行中, 使用到了一个没有实际物理地址对应的虚拟页时, 硬件便会触发一个"缺页中断", 操作系统响应此中断, 重新将它映射( 翻译 )到实际的物理页上. 以使程序能继续运行. 操作系统会长期跟踪应用程序的运行情况, 从中挑出"受难"程序的"受难"页. 回收它所对应的物理页. ( 由此看来, 内存就象一个位于CPU与磁盘之间的缓冲. 这只是从内存管理角度出发的一个类比, 并不十分恰当.  因为内存是程序运行的基础. 它与磁盘的作用区别越来越明显了.) 


3. 进程与线程

     一个程序在静止状态下, 对应着一段二进制机器码, 其中包括了指令码和数据. 程序一旦被执行, 在现代操作系统中, 它就对应着一个进程. 进程不旦包括了前面说过的必须的stack,  heap,  code,  data( 再加上系统内核 )等内存区域, 还包括一个描述当前运行状态的被称为进程控制块( Process  Control  Block, PCB )的数据结构. PCB主要用来保存: 进程ID, 进程当前状态, 进程调度的优先级, 程序计数器, 内存基地址, CPU寄存器等方面的内容( 这些又称为上下文 ).  正是有了PCB的存在, 操作系统才可以对多个进程进行调度. 现代操作系统, 为了实现多任务功能, 将运行中的程序( 进程 )分为若干个状态( Windows中分为:  创建, 终止, 就绪, 活动运行, 活动阻塞, 静止运行, 静止阻塞 七个状态, 当进程被挂起时, 就会由活动状态转为静止状态. Windows中进程的挂起和唤醒是由操作系统根据实际情况来决定的, 而线程的挂起和唤醒, 则一般由操作系统来决定, 但也可以由程序员来控制 ).  在各种状态转换中, 用都是用队列对所有参与转换的进程进行管理的.

     操作系统一般都会设置一个环境变量( Linux, Windows都如此 ). 环境变量主要是提供给运行中的程序, 也就是进程的. 在进程被创建时, 它就自动继承了系统的全局环境变量, ( 当前登录用户的 ) 用户环境变量, 以及父进程的环境变量. 此外,进程也可以有自已的环境变量. 在Windows中可以通过: GetEnvironmentStrings, GetEnvironmentVariable, SetEnvironmentVariable三个函数来获取和设置系统的环境变量. 

    (当一个作业被新建时) 高级调度会根据一定的算法, 从输入的多个作业中选出若干个作业, 为其提供就绪条件, 并为其建立相应的进程, 把它( 们 )加入到就绪队列中. 中级调度负责管理进程在内,外存之间交换的过程. 负责页面置换工作. 将一些未来可能最少被用到的数据淘汰出内存. 当内存有足够的空闲空间时, 再将合适的进程重新转入内存. 转出的进程就从活动状态变成了静止状态,  转入的进程主从静止状态变成了活动状态. 需要注意的是, 处于静止阻塞状态的进程, 在其被挂起期间并不会影响其等待事件的发生. 当处于静止阻塞状态的进程所等待的事件发生时, 它就会进入静止就绪状态. 低级调度( 又称为进程调度 ), 根据一定的算法, 将CPU分派给就绪队列中的一个进程.

    一个进程可以拥有多个线程, 各线程共用进程的heap, code, data( 及系统内核 )空间. 同时在stack区中划分出相距较远, 相互独立的连续内存区块作为本线程独有的栈, 这被称为子栈. 指令在线程中的执行与在独立进程中的执行是一样的. 引用线程概念后, 进程实际上就变成了一个资源的拥有者, 而线程才是这些资源的使用者. 多线程编程, 能真正充分发掘多核处理器的潜在功能.

     Windows系统中, CreateThread函数用来创建线程, 它甚至可以所建线程的stack大小( 当然也可以默认由操作系统自动分配 ). 及指定线程创建后是先挂起还立即进入就绪状态. 同时还可以通过Sleep, SuspendThread, ResumeThread, SwitchToThread, TerminateThread, ExitThread等函数自动参于对线程的调度.

    





原创粉丝点击