老“码”识途
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

1.2 理解指针和指针强制转换

1.2.1 指针和它丢失的类型信息

我们在学习C语言的时候,最难把握的是指针,特别是遇到指针强制转换时。通过反汇编,我们能够直接透视其本质,重新建立起清晰的认知。按照猜测实证的原则,首先应该对指针有一个简单的猜想,然后通过实验求证。

指针存储了内存的地址,同时指针是有类型的,如int *、float *,那么,一个自然的猜想就是指针变量应该存储这两方面的信息:地址和指针类型。比如,就像下面的结构体:

struct pointer{
  long address;                     //地址
  int type;                         //指针的类型
}

先做一个简单的实验,打印sizeof(int *)。出乎意料,这个值是4!再打印sizeof(float *),还是4。在1.1节中对全局变量赋值及mov指令的分析中,我们已经了解,全局变量的地址是4字节,即下面代码中的00417140h。

gi = 12;
0041138E     mov  dword ptr ds:[00417140h],0ch

既然4字节是存储内存地址用的,反过来就说明指针并没有存储类型信息的地方。那么,为什么指针要有类型?为什么还要有指针类型强制转换?而且不转换,不同类型的指针无法赋值(如int*不能直接赋值给float *,会产生编译错误)。要实验就用反汇编直指其根本。反汇编一段简单代码,见DM1-6。

                                  DM1-6
int gi;
int *pi;
void main()
{
  …
  pi = &gi;
  00411452    mov  dword ptr ds:[00417164h],417168h
  * pi = 12;
  0041145C    mov  eax, dword ptr ds:[00417164h]
  00411461    mov  dword ptr [eax],0ch
  …
}

如果还觉得不清晰,右键单击代码,在弹出的快捷菜单中选取“显示符号名”,见DM1-7。

                                  DM1-7
pi = &gi;
00411452   mov  dword ptr[pi(417164h)], offset gi(417168h)
* pi = 12;
0041145C   mov  eax, dword ptr [pi(417164h)]
00411461   mov  dword ptr [eax],0ch

从反汇编结果可知,在第1条mov指令中, 417168h是gi的地址,而417164h是pi的地址(可通过监视窗体验证)。“pi = &gi;”就是通过一条mov指令将gi的地址放入一个4字节的变量pi中。指针确实就只有4字节(417164h指向的4字节)存储了地址信息,且没有存储指针类型(即int *)的代码。因为“pi = &gi;”只有一条mov指令与它对应,难道指针变量真的没有类型?如果没有类型,为什么C语言还有int *、float *这些指针类型?

在汇编语言中没有指针的概念,只有地址。为什么C语言包装了一个指针的概念?从表面看,指针除了存储地址信息,还有类型区别。(思考问题往往要从源头去想,想想它的用途和存在价值。)想想数据指针的用途,我们不是拿地址来玩,实际上要用它来访问内存。是否“访问内存,只要地址就可以了”?(一定要想清楚细节,每个细节。)把整数1存储到地址0x12345678中,需要从这个地址开始写几字节?1字节、2字节还是4字节?在CPU中,它们都是1的合法表示,对应到C语言的数据类型分别是char、short和int。那么,到底要写几字节?实际上,该信息应该反映到赋值语句中。“* pi = 12;”语句的反汇编如下:

mov  eax, dword ptr [pi(417164h)]         //将pi中存储的gi的地址存入eax
mov  dword ptr [eax], 0ch                 //将12(0ch)存储到eax指向的地址

在第2条赋值语句中,除了要赋的值(0ch)、被赋值的地址(在eax中)外,还有一个符号我们之前未注意——dword。是它回答了我们的问题:“写几字节?”dword表明写4字节。到此我们发现,其实指针的类型信息决定了赋值/读取时写/读多少字节。

读/写多少字节的信息不是存放在指针变量中,而是放到了与该地址相关的赋值指令中,mov指令中的dword指明了这个信息。

C语言之所以要包装出指针的概念,是在汇编地址的内涵上增加了另一层含义,即读/写多少字节。不同类型指针,访问字节数不同。int *访问4字节,short *访问2字节。这样就方便我们操控一个地址,否则如果只有地址信息,每次访问它还要附加说明访问的字节数。这时,我们也能理解指针加/减1不是加/减1字节,而是加/减长度为该指针指向类型的长度的字节数。比如,int *指针加1是加4字节,short*指针则是加2字节。我们也能理解,void *类型的指针为什么无法进行加、减运算。因为它只是汇编语言中的地址,没有类型信息,加、减的时候不知道加/减多少字节。

为了进一步确认“指针类型信息放入了赋值指令中”这一结论,我们再做一个实验:对short指针指向的地址赋值,见DM1-8。

                                  DM1-8
short gi;
short *pi;
void main()
{
  …
  pi = &gi;
  00413762    mov  dword ptr [pi(417164h)], offset gi(417168h)
  * pi = 12;
  0041376C    mov  eax, 0ch
  00413771    mov  ecx, dword ptr [pi(417164h)]
  00413777    mov  word ptr [ecx], ax
  …
}

“pi = &gi;”的反汇编与前面是一样的,只有一条mov指令将gi的地址放入pi中,并没有指针类型信息的相关操作。

注意“*pi = 12;”对应的3条指令:

① mov eax, 0ch:将12放入eax中, eax为4字节,12存放在eax的低2字节即ax中。

② mov ecx, dword ptr [pi (417164h)]:将pi存储的地址即gi的地址放入ecx中(pi的地址是417164h,[417164h]中存储的是gi的地址)。

③ mov word ptr [ecx], ax:将eax的低2字节存储的内容(就是要赋值的12)存入ecx指向的地址(即gi的地址)中。“word”表明了如果向gi所在地址存储,将写入2字节(前面对int *指向地址的写操作涉及的mov指令是dword,即double word,为4字节)。我们再次看到,指针类型信息short *体现在赋值指令mov中,而不是存放在指针变量中,指针变量只存放了地址。

还不够?我们继续这个实验,将gi改为char类型,将pi改为char *类型。通过反汇编,则最后将12赋值给*pi:

mov byte ptr [eax], 0ch

byte指明了这个赋值语句是写1字节,因为pi为char *类型。

至此,我们已经可以得出结论:

C语言的指针类型包含两方面信息:一是地址,存放在指针变量中;二是类型信息,关乎读写的长度,没有存储在指针变量中,位于用该指针读写时的mov指令中,不同的读写长度对应的mov指令不同。

看清了这一点,我们就要进一步直面许多人都有点发虚的指针强制转换,以及相关的转换安全性问题。

1.2.2 指针强制转换

问题,还是从问题开始我们的猜测和实证之旅,看如下代码:

int *pi;
short * ps;
…
pi = ps;

问题:

① 为什么,类型不同的指针变量不能赋值,如果将一个short *指针变量ps赋值给int *变量pi (pi = ps),编译时会报错?

② 为什么,可以强制转换后赋值“pi = (int *)ps;”,强制转换到底发生了什么?

对于第一个问题,1.2.1节的分析已可给出答案。如果允许将ps指向的地址赋值给pi,那么将来生成“*pi = 2;”赋值语句的指令时,必然生成mov dword指令,即写4字节。而pi指向的是ps指向的变量,该变量为short,只有2字节,那么赋值4字节必然导致越界。所以,编译器为了帮助读者避免越界错误,便产生了编译错误。

对于第二个问题,我们只有去看看汇编才知道真相。还是先猜想:这种强制转换有类型信息的转换吗?根据1.2.1节的结论,指针变量本身不存储类型信息,只存储地址信息,那么,似乎强制转换只是形式上的,“pi = (int *)ps;”应该只是将ps的值存入pi而已。所谓类型转换的效果,应该还是体现在对该地址指向内存的存取上吧?猜想到此,开始我们的实验。

在代码DM1-9中,整数i的地址赋值给了int *、short*和char *(即pi、ps、pc)三种类型的指针变量。其中,对ps和pc的赋值进行了指针强制转换,最后3行是用它们对同一地址进行赋值操作。

                                  DM1-9
int i;
int *pi;
short *ps;
char * pc;
void main(int argc, char* argv[])
{
  pi = &i;
  ps = (short *)&i;
  pc = (char *)&i;
  *pi = 0x1234;
  *ps = 0x1234;
  *pc = 0x12;
}

先看看主函数中的前3句对指针变量的赋值语句的反汇编:

pi = &i;
0041138e  mov  dword ptr [pi(417148h)], offset i (41714ch)
ps = (short *)&i;
00411398  mov  dword ptr[ps(417144h)],offset i(41714ch)
pc = (char *)&i;
004113a2  mov  dword ptr[pc(417140h)],offset i(41714ch)

这3条mov指令都是将41714ch即i的地址赋值给相关变量。只有赋值地址的3条指令,没有产生任何与类型相关的指令。可知,在指针变量赋值上,强制转换只是编译器的一个善意提醒,没有产生实际的指令。

再看后面3条表面上与强制转换无关的赋值语句的反汇编:

*pi = 0x1234;

箭头所示的3条指令是同一地址在3种指针身份下的对应mov指令。注意其中的黑体部分,对int * pi指向地址的赋值语句是mov dword,对short * ps指向地址的赋值语句是mov word,对char *pc指向地址的赋值语句是mov byte。指针强制转换的影响不是在转换的时候发生,而是在用转换后的身份去访问内存时体现到了指令中。

那么,什么情况下转换是安全的呢?就要看用这个转换后的身份去访问内存是否安全。简单地说有以下原则:

如果转换后指针指向的数据类型大小小于原数据类型大小,那么用该转化后的指针访问就不会越过原数据的内存,是安全的,否则危险,要越界。

对上例而言,“ps = (short *)&i;”强制转换后,用ps来访问内存是2字节,而i本身是4字节,所以不会越界,是安全的。而下面代码就是危险的:

short s;
int * p;
p = (int *)&s;

因为p指向的是short变量s,大小为2字节,而p为整数指针,用它访问指向的内存将生成访问4字节的指令,访问会越界。

我们清晰了指针强制转换的本质,也了解了安全性原则,来看下面的例子(我们真地能运用这些原则了吗?):

int * pi;
short si = 12;
pi = (int *) &si;
printf(“%d,   %x”, * pi, * pi);

打印的结果是什么?是“12, 0c”?笔者发现,即使讲完了以上分析后马上问学生该问题,也几乎无人发现其问题。(在了解原则的情况下,还需要多多练习,多多怀疑,让猜测和实证将知识深深植入到思想和灵魂中,成为“活生生”的一部分。程序是手艺,而手艺是熟练和思想的交点。)来看这段代码的反汇编结果:

-859045876   cccc000c

哪里有错?这次我们没有头绪,没有猜测。(没关系,调试吧,观察吧,或许观察能够给我们线索。)在“pi = (int *) &si;”语句上设置断点,然后用内存窗体查看si的值,看它是否为12。先在监视窗体中输入“&si”,获得si的地址0x0012FF54,然后在内存窗体中输入这个值,见图1.22。

图1.22

第1字节是0c(0c就是12),第2字节是00。(也对,在小端机的内存中0c 00代表0x000c,即12。)12还可以用4字节表示,图中0x0012FF54开始的4字节0c 00 cc cc,它代表的整数是0xcccc000c。(这个值就是打印结果的十六进制表示。找到问题了。)将si的地址强制转换为int *类型,然后赋值给pi(即pi = (int *) &si;),那么*pi会访问4字节。这时越界了,将si后的2字节纳入了范围,即cc cc,它们与0c 00合在一起正好与结果吻合。(我们违反了强制转换的原则,越界了。)