在C语言中,指针变量是强大且实用的功能。指针变量使程序员不仅可以间接引用数据和函数,还可以结合数组下标来选择、读取和写入数组项。但首先需要了解什么是指针和地址以及编译器如何使用它们。不理解指针和地址会很快导致代码故障。利用指针,我们可以编写出许多语法正确的C语言代码,来编译和实现某种功能,但这种功能在不同的C编译器实现中以及不同的目标器件上可能有所不同。甚至可能与我们的期望不同。
指针不是整数
指针变量包含 C 语言数据的地址。例如,查看以下几行代码。
int a, *p;
/* 为指针赋予某个目标的地址 */
p = &a;
/* 解除引用指针以间接访问目标 */
*p = 0;
上面的代码将变量a 的值设置为0。应用到a 的&运算符返回一个表示该变量位置的值(地址)。如果将该值复制到一个指针变量,然后对指针解除引用(使用*运算符),则该表达式表示原始变量a。这很容易让人认为该地址在数值上等于变量a 所在的计算机存储器地址,但在C 语言中并没有此类要求。
以下示例可清楚地说明最后一点:考虑具有多个独立存储区的PIC 器件。对位于数据存储器中器件地址100h 的变量使用地址运算符时应返回什么值?而对位于程序存储器中器件地址100h 的另一个变量使用地址运算符时又应返回什么值?
如果在两种情况下都回答 100h,那么在运行时如何得知100h 是数据存储器中的地址还是程序存储器中的地址呢?显然,在这种情况下,如果稍后要解除引用地址,则需要其他方式来确定应访问哪个存储器。
“其他方式”可以是对地址运算符返回的值进行特殊编码(与MPLAB XC8 编译器配合使用的技术),也可以使用传达相同信息的特殊指针类型限定符(MPLAB XC16 和XC32 编译器使用该方法)。
为保持代码的可移植性,不应假设将整数赋给指针就会使指针能访问任何对象,即使该整数的值与某个对象的器件地址相同。因此对于上面的示例,为指针赋值立即数100h(或者保留此值的整数变量)并不意味着该指针指向变量a。
/* 我们发现“a”被分配到地址100h
*/int a, *p;
/* 注:这涉及整数到指针的隐式转换 */
p = 0x100;
/* 没人知道会发生什么!*/
*p = 0;
请记住,一种地址空间中的取指和存储可能不像另一种地址空间中的取指和存储一样简单——编译器可能需要使用不同的寄存器和指令才能执行访问。
基于同样的原因,在定义指针时,必须使用适当的指针类型限定符。由于 MPLAB XC8 对地址进行编码,因此它不使用特殊地址空间限定符,而MPLAB XC16 和XC32 则使用。但是,两种情况下都必须适时使用通常的const 和volatile 限定符。限定符在数据定义中指定,如果想要可靠地访问该数据,则需要使用与引用该数据的指针相匹配的限定符。例如,使用MPLABXC16 时:
__psv__ char buffer[8] __attribute__((space(psv)))
在闪存程序存储器中放置一个字符数组buffer,可通过“psv”(程序空间可视性)窗口进行访问。直接访问buffer 将使编译器生成可确保psv 窗口(位于处理器地址空间中的特定位置)映射到闪存(包含“buffer”)中适当位置的代码。buffer 的“地址”是所需窗口设置与“buffer”在整个窗口中的可视区域内的偏移量的组合。
通过指针引用“buffer”中的项时,必须使用如下指针:
__psv__ char *bp;
才能使编译器生成正确的代码。不带__psv__限定符的“普通”指针不起作用。
因此指针不仅仅是一个宽到可以保存“地址”的整数,它还具有关联的目标类型;C 语言数据地址不仅仅是一个计算机存储器地址,它可由编译器修改或优化。C 编译器还会考虑其他一些事项。
出问题的位置
如果我们认为指针只是一个值为(计算机存储器)地址的整数,并且认为我们已了解地址的含义以及该存储器中排列数据的方式,我们可能会想要在所编写的C 语言代码中显式执行各种各样的地址运算,进而在程序中嵌入底层运行时环境的特定于实现的详细信息。这样一来,即使现在程序可以运行,但如果针对其他处理器进行编译,可能就无法正常工作,或者可能在看起来无关紧要的更改后莫名停止工作。我们该如何避免这类问题呢?
1. 使用正确的指针类型。根据引用的数据选择适用的指针类型。尽管在你添加一系列转换后程序会进行编译,但不要据此认为程序会实际按照你的期望工作。它会按照你告诉它的方式工作,这可能与你的期望有很大不同。
2. 根据你将用来访问数据的结构来分配数据
3. 不要猜测数据类型的布局
例如,可以分配一个字符缓冲区,然后将该缓冲区的地址转换为指向更大类型数据数组或结构数组的指针。随后你可能会通过不同类型的指针,有时访问字符型数据,有时访问其他类型的数据。为此,必须知道更大类型的数据在字符数据上以及彼此之间的排列方式。这非常危险而且容易出错。如果需要通过多类型“视图”访问数据,请将数据分配成联合数组,然后通过联合访问数据。编译器将清楚你的意图并帮助你正确实现。
示例
下面的 C 程序建立了一个初始化结构数组,显示该数组,修改数组的一个元素,最后显示更新的结果。代码中针对选择和更新要更改的元素提供了几种备选方法。其中一些是常用方法,但实际上是不安全的代码模式:
1: /* 用于演示指针运算问题的测试程序 */
2: #include
3: #include
4:
5: struct twoints {
6: uint8_t a;
7: uint32_t b;
8: };
9:
10: static struct twoints twointbuf[4] = {
11: {1, 5}, {2, 6}, {3, 7}, {4, 8}
12: };
13:
14: int main(int argc, char *argv[])
15: {
16: struct twoints *p;
17: size_t i;
18:
19: /* 输出结构数组 */
20: printf(“Before:\n\n”);
21: i = 0;
22: p = twointbuf;
23: while (i a, (*p).b);
25: ++p;
26: ++i;
27: }
28: printf(“\n”);
29:
30: /* 选择下标为2 的元素的正确方法 */
31: p = twointbuf + 2;
32:
33: /* 等效且同样好的方法 */
34: #ifdef ALSORIGHT
35: p = &twointbuf[2];
36: #endif
37:
38: /* 正确,但没有必要采用的方法 */
39: #ifdef CORRECTBUTWHY
40: p = (struct twoints *)((char *)twointbuf + 2*sizeof(struct twoints));
41: #endif
42:
43: /* 以下是常见错误 */
44: #ifdef REALLYWRONG
45: p = (struct twoints *)((char *)twointbuf + 2*(sizeof(uint8_t) + sizeof(uint32_t)));
46: #endif
47: #ifdef NOTSAFE
48: p = (struct twoints *)((size_t)twointbuf + 2*sizeof(struct twoints));
49: #endif
50:
51: /* 修改元素2 */
52: p->b = 0xffffffff;
53:
54: /* 显示更新的数组 */
55: printf(“After:\n\n”);
56: i = 0;
57: p = &twointbuf[0];
58: while (i a,(p[ i]).b);
60: ++i;
61: }
62: printf(“\n”);
63:
64: return 0;
65: }
我们讨论一下如何访问要修改的第二个结构元素。在第10 行中声明的twointbuf 是一个结构数组,相当于指向该数组首地址的指针。我们可以通过数组或指针语法来访问该数组中的元素,这两种编码风格表示同一个意思。第31 行和第35 行中给出的备选方法均是获取指向数组中元素2 的指针的安全方法。编译器不会将“2”解读成两个字节或两个“字”,而是解读成元素0 和元素1 后面的元素的编号2。
在第 40 行,我们看到了根据数组的字节地址以及前面元素的长度(字节)来计算结构元素地址的示例。如果(char *)上的限定符与数组上的限定符(本示例中没有)匹配,则这种方法可行——只要字符指针和数组均声明为引用相同的地址空间,地址和增量映射到底层存储的规则就会相同,且该代码有效。但为什么要这样做呢?使用C 语言提供的简洁明了的语法,编译器将生成同样正确或更有效的代码。
在第 45 行,此代码假设结构元素的长度(字节)是两个成员的长度之和。这是不安全的假设,因为编译器可能必须对结构进行填充才能使两个成员在自然字边界上对齐。是否使用结构填充将取决于目标器件。
第 48 行上的语句一开始没有将数组指针转换为字符指针,而是转换为大到足以保存指针的整数,从而向编译器隐藏了该值是特定地址空间中的地址的事实。随后执行与第40 行相同的地址运算,并将结果转换回指向结构数组的指针。在这种情况下,编译器没有机会对添加为特定空间中的指针和下标的数字进行解读,且无法应用任何映射规则。因此转换回结构指针的值可能是错误的。
结论
使用C 语言的功能时,应依据功能在语言中的含义:
使用地址运算符来获取要赋给指针的地址。
确保所定义的指针类型在程序执行期间与其可引用的数据相匹配。
决不要假设对象分配到存储器的方式。
不要假设或回避规则来使 C语言代码更“直接”和“有效”,此类代码不会具有可移植性、可靠性或更有效。