C/C++内存与运行时深入研究 ----------------------------------------------------------------------------------- (一)整数符号的陷阱 (二)浮点数的本质 (三)堆栈的内存管理结构 (四)符号解析 (五)对齐和总线错误 (六)函数指针 (七)虚函数的实现机理 (八)引用的实现机理 (九)虚拟继承对象的内存结构 (十)混合编程时的初始化顺序 (十一)数组和指针的异同 (十二)const限定的传递性 (十三)数据类型的限定性检查 (十四)使用STL时的类型限制 (十五)迭代器自身的类型 (十六)运行时的类型信息 (十七)new/delete重载 (十八)如何拷贝一个文件 (一)整数符号的陷阱 x<y和x-y<0的结果是一样的吗??看看下面这个简单的程序。 #include<stdio.h> int main(void){ int x=1; unsigned int y=2; int b=x<y; int b2=(x-y<0); printf("%d,%d/n",b,b2); return 0; } 它输出什么呢? 1,0 令人震惊,不是吗,x<y和x-y<0不是一样的! (1)x<y的时候,由于y是无符号数字,所以x也被提升为无符号数字,所有x<y是成立的,返回1 (2)x-y的结果计算的时候,返回一个0xfffffffe,它被当成无符号数字理解并和0比较,显然<0不成立,返回0。 总结一下,整数的运算,加减乘的时候,根本不管是否声明为是否有符号,在2进制cpu上面的计算是相同的,但是比较的时候(<,>,==)会根据类型,调用不同的比较指令,也就是以不同的方式来理解这个2进制结果。当signed和unsigned混用的时候,全部自动提升为无符号整数。 #include<stdio.h> int main(void){ int i=-2; unsigned j=1; if(j+i>1) //提升为两个uint相加 printf("sum=%d/n",j+i);//打印的结果根据%d制定,j+i的内存值永远不变。 return 0; } 输出 > ./a.out sum=-1 再举一个例子 #include<stdio.h> int main(void){ int i=-4; unsigned int j=1; int ii=i+j; unsigned int jj=i+j; printf("%d,%ud/n",ii,jj); if(ii>1){printf("100000");} if(jj>1){printf("100001");} return 0; } 用gcc -S得到汇编,会发现if(ii>1)和if(jj>1)对应两个不同的跳转指令jle和jbe。 总结: int和unit在做比较操作和除法的时候不同,其他情况相同。 返回页首 (二)浮点数的本质 用一个程序来说明浮点数的IEEE表示。注意Linux没有atoi,ltoi,itoa这样的函数,那几个函数是VC独家提供的,不是ANSI C标准,所以*nix要用到sprintf函数来打印整数的内容到字符串里面。IEEE浮点数对于32位的float来说,从高位到低位分别是1bit符号位,8bit指数位,23bit浮点数位。当然由于内存地址是从低到高排列的,所以要把这4个字节的内容反过来,作为整数,转换为字符串打印出来的内容才是正确的。在x86机器上,同样是低位字节在前高位字节在>后,这样做得好处就是可以把浮点数作为有符号整数来排序。 例如浮点书-0.875,符号为1(复数),二进制表示为-0.111,表示为1-2之间的小鼠就是-1.11 x 2^-1,指数项-1,加上128得到1111111(127),因为指数项的8个bit必须保证是无符号数,所以有了这样的表示。而23bit的整数项则是11000000000000000000,也就是取了-1.11在小数点后面的内容,没有的后端补0。 所以,-0.875f的2进制表示就是10111111011000000000000000000000。写一个小程序来验证 #include<stdio.h> #include<stdlib.h> void pfloat(float f){ int i,j; char buf[4][9]; char* p=(char*)&f; printf("before loop/n"); for(i=0;i<4;++i){ for(j=0;j<8;++j){ buf[i][j]=(p[i]&(0x80>>j))>0?''1'':''0''; } buf[i][8]=''/0''; } for(i=3;i>=0;i--){ printf("%s",buf[i]); } printf("/n"); printf("end loop/n"); } int main(void){ float d1=-0.875; pfloat(d1); return 0; } 看看输出和我们预期的一致。浮点数的计算总是充满了陷阱。首先,因为浮点数的精度有限,所以在做四则运算的时候,低位很可能在过程中被舍弃。因此,浮点运算不存在严格的运>算的结合律。在32位系统上面,浮点数float为4字节长,其中整数位23位,表示范围转换为10位数的话有9个有效数字。所以 float f1=3.14; float f2=1e20; float f3=-1e20; printf("%d,%f/n",i,f); printf("%f/n",f1+f2+f3); printf("%f/n",f2+f3+f1); 上面两个printf的结果是不一样的,第一个结果是0,第二个结果是3.14。再举一个例子 float k=1.3456789; float k2=k; k-=1000000.0; printf("%f/n",k); k+=1000000.0; printf("%f/n",k); int b=(k==k2); printf("%d/n",b); 结果是什么呢? b=0,因为k的值在之前的运算中,小数点后面已经有5为被舍入了,所以k不再等于k2。要使得k==k2成立,必须提高京都,使用double--52位整数域,相当于10进制有效数字16位,可以克服上面这个运算的不精确性。 double d1,d2; printf("%f/n",d1); d1=d2=1.3456789; d2+=1000000.0; printf("%f/n",d2); d2-=1000000.0; printf("%f/n",d2); 现在d==d2的返回值就是真了。为了使得运算结果有可以比较的意义,通常定义一个门限值。#define fequals(a,b) fabs(a-b)<0.01f 如果浮点数计算溢出,printf能够输出适当的表示 float nan=3.0f/0.0f; printf("%f/n",nan); 打印inf,如果结果是负无穷大,打印-inf。 返回页首 (三)堆栈的内存管理结构 堆和栈的内存管理(x86机器)与分布是什么样子的?用一个程序来说明问题。看看堆和栈的空间是怎么增长的。 $ cat stk.c #include<stdio.h> #include<stdlib.h> int main(void){ int x=0; int y=0; int z=0; int *p=&y; *(p+1)=2;//这条语句究竟是设置了x还是设置了z?和机器的cpu体系结构有关 int* px=(int*)malloc(sizeof(int)); int* py=(int*)malloc(sizeof(int)); int* pz=(int*)malloc(sizeof(int)); *px=1; *py=1; *pz=1; *(py+1)=3; printf("%d,%d,%d/n",x,y,z); printf("%p,%p,%p/n",px,py,pz); printf("%d,%d,%d/n",*px,*py,*pz); free(px); free(py); free(pz); return 0; } 编译和运行的结果 $ gcc stk.c && ./a.out 2,0,0 0x9e8b008,0x9e8b018,0x9e8b028 1,1,1 (1)如果把上面的分配内存的代码改成 int* px=(int*)malloc(sizeof(int)*3); int* py=(int*)malloc(sizeof(int)*3); int* pz=(int*)malloc(sizeof(int)*3); 第三个printf的输出仍然是 0x9e8b008,0x9e8b018,0x9e8b028 说明什么呢? malloc分配的时候,分配的大小总是会比需要的大一些,也就是稍微有一些不大的内存越界并不会引起程序崩溃。当然这种情况可能导致得不到正确的结果。 我们看看堆和栈的内存分布吧,在一台安装了Linux的x86机器上 --------------------- 0xffffffff ->OS内核代码,占据1/4的内存地址空间 0xc000000 ->stack是运行时的用户栈,地址从高往低增长 | x | y ->int*(&y)+1指向的就是x | z ->共享库的存储器映射区域 0x40000000 ->运行时堆,往上增长 | pz 。。。。。。 | py ->由于py分配的内存大于实际想要的, *(py+1)=3;不对程序结果有影响 。。。。。。 | px ->malloc分配的内存从低往高分配 。。。。。。 ->可读写数据区(全局变量等) ->只读代的代码和数据(可执行文件,字面常量等) 0x08048000 ->是的,代码总是从同一地址空间开始的 ->未使用 0x00000000 --------------------- 如果把程序改为 *(py+4)=3; 那么程序最好一行的输出就是 1,1,3 也就是pz的内容被写入。验证了理论。 返回页首 (四)符号解析 符号是怎么被解析的?什么时候会有符号解析的冲突?假设两个模块里面都有全局变量 $ cat f.c #include<stdio.h> int i=0; void f() { printf("%d/n",i); } $ cat m.c int i=3; extern void f(); int main(void){ f(); return 0; } 这样的话,编译和链接会有错误: $ gcc -o main m.o f.o f.o.bss+0x0): multiple definition of ''i'' m.o.data+0x0): first defined here collect2: ld 返回 1 也就是说,我们定义了重名的全局变量i,那么链接器就不知道应该用哪个i了,用nm可以看到符号表: $ nm m.o f.o m.o: U f 00000000 D i 00000000 T main f.o: 00000000 T f 00000000 B i U printf 解决方法有两种: 1. 在m.c里面把int i=3变成main内部的局部变量,这样的话: $ cat mcp.c extern void f(); int main(void){ int i=3; f(); return 0; } [zhang@localhost kg]$ nm mcp.o U f 00000000 T main 在文件m.o中没有了全局符号i,链接就没有了错误。 2.在f.c中把int i从全局变量变成static静态变量,使得它只在当前文件中可见 $ cat fcp.c #include<stdio.h> static int i=0; void f(){ printf("%d/n",i); } [zhang@localhost kg]$ nm fcp.o 00000000 T f 00000000 b i ->这里i的类型从以前的B变成了b U printf main的执行结果是0,也就是f里面的i就是当前文件的i,不会使用m.c中定义的全局i。这两个i由于不冲突,就被定义在不同的地址上面了。 五)对齐和总线错误 什么是Bus error? 一般是总线寻址造成的,由于指针类型和long有相同大小,cpu总是找到%4/%8的地址作为指针的起始地址,例如: #include<stdio.h> int main(void){ char buf[8]={''a'',''b'',''c'',''d'',''e'',''f''}; char *pb=&(buf[1]); //这里pb的地址不是4bytes或8bytes对齐的,而是从一个奇数地址开始 int *pi=(int*)pb; printf("%d/n",*pi); return 0; } 这类问题的结果和CPU的体系结构有关,取决于CPU寻址的时候能否自动处理不对齐的情况。下面这个小程序是一个例子。分别在 Sparc(solaris+CC)和x86(vc6.0)上面测试: Sparc上面就会崩溃(Bus error (core dumped)),x86就没有问题。 Plus: 在hp的pa-risc(aCC),itanium(aCC),IBM(xlC)的power上面测试 power不会core dump, pa-risc和Itanium也均core dump. 返回页首 (六)函数指针 要控制函数的行为,可以为函数传入一个回调函数作为参数。C++的STL使用的是functional算子对象,C语言可以传递一个函数或者一个函数指针。 #include <stdio.h> #include <stdlib.h> typedef void callback(int i); void p(int i){printf("function p/n");} void f(int i,callback c){c(i);} int main(void) { f(20,p); return 0; } > ./a.out function p 既然可以把函数直接作为回调参数传给另一个主函数,为什么还要用函数指针呢? 相像一下f函数运行在一个后台线程里面,这个线程是个服务器不能被停止,那么我们想要动态改变f的行为就不可能了,除非f的第二个参数是 callback* 而传入的这个变量我们去另一个线程里面改变。这样就实现了灵活性。 返回页首 (七)虚函数的实现机理 因为C++里面有指针,所以所谓的public,private在强类型转换面前没有意义。我们总是可以拿到私有的成员变量。 winXP+gcc3.4.2得到的虚函数表最后一项是0,是个结束符。注意,这是严重依赖编译器的,C++标准甚至都没要求是要用虚函数表来实现虚函数机制。 /*----------------------------------------------------------------------------*/ #include<stdio.h> class B{ int x; virtual void f(){printf("f/n");} virtual void g(){printf("g/n");} virtual void h(){printf("h/n");} public: explicit B(int i) {x=i;} }; typedef void (*pf)(); int main(void){ B b(20); int * pb=(int*)&b; printf("private x=%d/n",pb[1]); pf *pvt=(pf*)pb[0];//虚函数表指针 pf f1=(pf)pvt[0]; pf f2=(pf)pvt[1]; pf f3=(pf)pvt[2]; (*f1)(); (*f2)(); (*f3)(); printf("pvt[3]=%d/n",pvt[3]);//虚函数表结束符号 return 0; } 程序输出 private x=20 f g h pvt[3]=0 理解的关键是,b的第一个dword,里面保存了一个指针,指向虚函数表。我们用两次强制转型,一次得到b的第一个dword,在把这个dword转为 当然,上面的这个结果是和编译器类型以及版本有关系的,gcc2.95.2版本对象的结构就不同,它把虚函数表指针放到了对象的后面,也就是pvt= ((int*)(&b))[1]才是指针域,而且pvt[0]=0是结束符,pvt[1]才是第一个虚函数的起始地址。所以这样写出来的程序是不通用的。同一台机器上,不同的编译器来编上面那个程序,有的能工作,有的coredump。因为C++对象的内存模型不是C++标准的一部分,可以有不同的实现,不同实现编出来的结果(和虚函数有关的)互相之间没有任何通用性。 如果有访问对象的成员呢? 情况更复杂。 #include<string> using namespace std; struct a{ int x; virtual void f(){printf("f(),%d/n",x);} explicit a(int xx){x=xx;} }; int main(void){ a a1(2); a a2(3); int* pi=(int*)&a1; int* pvt=(int*)pi[0]; typedef void(*pf)(); pf p=(pf)pvt[0]; (*p)(); int *p2=(int*)&a2; int *pv2=(int*)p2[0]; pf px=(pf)pv2[0]; (*px)(); return 0; } 输出是什么呢? $ g++ r.cpp &&./a.out f(),3 f(),3 为什么会有这样的错误? 因为成员函数在传递参数的时候默认含有一个this指针,但是我这里的简单调用并没有去指定this指针,所以程序没有挂掉就已经很幸运了。怎么才能得到正确的结果呢? 像下面这样增加一个this类型的调用参数: #include<stdio.h> struct a{ int x; virtual void f(){printf("f(),%d/n",x);}//............ explicit a(int xx){x=xx;} }; int main(void){ a a1(2); a a2(3); int* pi=(int*)&a1; int* pvt=(int*)pi[0]; typedef void(*pf)(a*); pf p=(pf)pvt[0]; (*p)(&a1); int *p2=(int*)&a2; int *pv2=(int*)p2[0]; pf px=(pf)pv2[0]; (*px)(&a2); return 0; } > g++ p.cpp && ./a.out f(),2 f(),3 现在结果就正确了。 再次说明,this指针的传递方法在C++标准里面并没有说明,而是各家编译器各自实现。这里引用OwnWaterloo的一段解释性代码,说明问题。 (1)gcc3.4.x 是通过给参数列表增添一个隐藏参数, 来传递this的, 代码 : /*----------------------------------------------------------------------------*/ class C { int i_; public: explicit C(int i) :i_(i) {} virtual ~C() {} virtual void f() { printf("C::f(%d)/n",i_); } }; #if defined(__GNUC__) #if __GNUC__!=3 #error not test on other gcc version except gcc3.4 #endif #include <assert.h> #include <string.h> #include <stdio.h> #define intprt_t int* int main() { C c1(1212); C c2(326); typedef void (* virtual_function)(C*); // gcc 通过一个增加一个额外参数, 传递this // virtual_function 即是C的虚函数签名 struct { virtual_function* vptr; // 虚函数表指针 // 当然,它指向的表不全是函数, 还有RTTI信息 // 总之, 它就是这个类的标识, 唯一的“类型域” int i; // data member } caster; // 我们猜想, gcc将虚函数表指针安排在对象的最前面。 memcpy(&caster,&c1,sizeof(caster)); printf("c1.i_ = %d/n",caster.i); // 1212 printf("c1.vptr_ = %p/n" ,reinterpret_cast<void*>(reinterpret_cast<intptr_t>(caster.vptr)) ); virtual_function* vptr1 = caster.vptr; memcpy(&caster,&c2,sizeof(caster)); printf("c2.i_ = %d/n",caster.i); printf("c2.vptr_ = %p/n",(void*)caster.vptr); virtual_function* vptr2 = caster.vptr; assert(vptr1==vptr2); // 显然, 它们都是C, 所以vptr指向相同的地址 vptr1[2](&c1); // C::f(1212) vptr2[2](&c2); // C::f(326) /* 我们再猜想 f在虚函数表中的第2项。这里的~C是虚函数表第1项。*/ /* 在存在有虚析构函数的时候,虚表的第0项似乎只是个导引。如果把~C去掉改为别的虚函数,那么f就是虚表的第1项。*/ } (2)MSVC使用另一种实现 int main() { C c1(1212); C c2(326); typedef void (__stdcall* virtual_function)(void); // msvc 通过ecx传递this, 所以参数列表和虚函数相同 // 同时, msvc生成的虚函数, 会平衡堆栈 // 所以这里使用 __stdcall 让调用者不做堆栈的平衡工作 struct { virtual_function* vptr; int i; } caster; // 这同样是对编译器生成代码的一种假设和依赖 memcpy(&caster,&c1,sizeof(caster)); printf("c1.i_ = %d/n",caster.i); // 1212 virtual_function* vptr1 = caster.vptr; printf("c1.vptr_ = %p/n" ,reinterpret_cast<void*>(reinterpret_cast<ptrdiff_t>(vptr1)) ); memcpy(&caster,&c2,sizeof(caster)); printf("c2.i_ = %d/n",caster.i); // 326 virtual_function* vptr2 = caster.vptr; printf("c2.vptr_ = %p/n" ,reinterpret_cast<void*>(reinterpret_cast<ptrdiff_t>(vptr2)) ); assert(vptr1==vptr2); // 显然 c1 c2 都是 C,它们的虚指针是相同的 // 但是, 直接调用是不行的, 因为没传递this //vptr1[2](); // 这样也不行 //_asm { lea ecx, c1 } // 因为下面这行代码, 修改了 ecx // vptr1[2](); // 所以要如下进行直接调用 virtual_function f1 = vptr1[1]; _asm { lea ecx,c1 call f1 } virtual_function f2 = vptr2[1]; _asm { lea ecx,c2 call f2 } // 分别打印出 C::f(1212),C::f(326) // 同时, C::f在虚表的第1项, vs的watch窗口说的 …… } 返回页首 (八)引用的实现机理 引用的工作方式是什么呢 不纠缠于语法的解释,看代码和汇编结果最直接。举下面这个小例子程序gcc -masm=hello -S main.cpp可以得到汇编代码) #include<stdio.h> int x=3; int f1(){return x;} int& f2(){return x;} int main(){ int a=f1(); int y=f2(); y=4;//仍然有x=3 int&z=f2(); z=5; printf("x=%d,y=%d",x,y);//z改变了x return 0; } 输出是什么呢? x=5,y=4 分析: f2是个返回引用的函数,当且仅当int&z =f2()的时候才是真的返回引用,int y=f2()返回的仍然是一个值的拷贝。汇编代码如下(部分) ----------------------------------------------------------------------------------- f1和f2的定义: .globl __Z2f1v .def __Z2f1v; .scl 2; .type 32; .endef __Z2f1v: push ebp mov ebp, esp mov eax, DWORD PTR _x f1()返回一个值 |
|