C/C++内存与运行时深入研究

 
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窗口说的 &hellip;&hellip;
}
返回页首

(八)引用的实现机理
引用的工作方式是什么呢 不纠缠于语法的解释,看代码和汇编结果最直接。举下面这个小例子程序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()返回一个值

你可能感兴趣的:(c,function,gcc,callback,float,编译器)