22.1 指针的基本概念
把一个变量所在的内存单元的地址保存在另外一个内存单元中,保存地址的这个内存单元称为指针。
指针在 C 语言中用指针类型的变量表示。
int i;int *pi = &i;char c;char *pc = &c;
&是取地址运算符,这里取变量 i 的地址来初始化 int 型指针变量 pi,同理又定义了一个 char 型的指针变量。
虽然 pi 和 pc 是不同类型的指针变量,但所占内存单元都是 4 个字节,因为要保存 32 的虚拟地址(在 64 位平台上指针变量都占 8 个字节)。
指针有不同类型是有意义的,因为取值时拿着“内存门牌号”取回数据时要知道应该读取多少个字节的数据,char 型指针则到目标地址读取 1 个字节即可,而 int 型指针时则间接寻址时可能要读取 4 个字节的数据。
声明多个指针:int *p, int *q 。
[ ]括号用在声明中表示声明一个数组,用在表达式中表示取下标运算符。同理,*号用在声明中表示声明一个指针类型,用在表达式中是间接寻址运算符。*pi = *pi + 10;表示找到指针 pi 所指向的变量的值,加 10 后再赋值到这个变量中(*运算结果可以左左值)。
& 和 * 互为逆运算,前者根据变量得到地址(指针),后者根据地址(指针)得到变量(或变量的值)。
指针之间可以相互赋值,但要注意两个指针必须是同一类型的,这样能减少程序错误。
指向不确定地址的指针称为“野指针”,为避免出现野指针,在定义指针变量时应明确给它赋值,或初始化成 NULL。
NULL 在 C 标准库的头文件 stddef.h 中有定义 #define NULL ((void *)0),该定义中把整型的 0 强制转换成 void 指针,该指针指向 0 地址,称为*空指针,任何对空指针的访问一定会报段错误,所以很容易排查。
void 类型的指针存在是因为编程时需要一种通用指针,可以和任意其他类型的指针之间相互转换。
void 指针常用于函数传参和传返回值。
22.2 指针类型的参数和返回值
#include <stdio.h>
int *swap(int *px, int *py)
{
int temp;
temp = *px;
*px = *py;
*py = temp;
return px;
}
int main(void)
{
int i = 10, j = 20;
int *p = swap(&i, &j);
printf("now i=%d j=%d *p=%d\n", i, j, *p); // now i=20 j=10 *p=20
return 0;
}
调用函数的传参过程相当于定义形参变量并且用实参的值来初始化,所以相当于 px 和 py 分别指向 main 的局部变量 i 和 j:
int *px = &i;
int *py = &j;
最终结果是 swap 函数的 px 指向哪就让 main 函数的 p 指向哪,正是指向 i。
通过函数返回指针时要注意避免返回“野指针”,返回的局部变量在函数返回之后栈帧就要释放掉了。
push + call / leave + ret
22.3 指针与数组
int a[10];
int *pa = &a[0]; // 简洁的写法是 int *pa = a;
pa++;
pa 是指向首元素的 int 指针,一个 int 型占 4 个字节,pa++ 是地址值加 4 而不是 1。
pa 现在指向 a[1],pa + 2 指向 a[3],pa 可以像数组名一样使用,但用数组名时总是从0开始,而使用指针时不一定。
a[2] 可以写成 (a+2) 或 2[a]。
E1[E2] 等价于 *(E1 + E2)。
数组类型做右值时自动转换成指向首元素的指针。
也因此,数组下标允许是负,pa[-1] 这时的 pa 可能指向的是 a[1] 元素,pa[-1] 就是 *(pa-1) 也就是 a[0] 元素。
指针之间的比较运算比较的是地址,但只有指向同一个数组中的元素的指针之间的相互比较才有意义,也只有指向数组元素的指针加或减一个常数才有意义,否则都属于 Undefined 情况。
同理,两个指向同一数组元素的指针相减表示相差的元素个数。
然而,两指针相加实在想不出有什么意义,所以 C 规定两指针不能相加。
在函数原型中,如果参数写成数组形式,则该参数实际上是指针类型:
void func(int a[10]);
void func(int a[]);
// 都等价于下面的指针类型
void func(int *a);
两种参数形式对编译器来说没区别,都是指针类型,如果该参数指向一个元素,通常写成指针形式;如果该参数指向一串元素中的首元素,则通常写成数组形式。
- p++ 先 ++ 再
- sizeof 是操作符,不是函数,得到的是该数据类型占用空间的大小。
sizeof 数组名得到整个数组的存储空间大小,sizeof 一个指针,得到指针所占存储空间大小(32位是 4,64位是 8)。
#include <stdio.h>
void foo(char buf3[20])
{
printf("%u\n", sizeof(buf3));
}
int main(void)
{
char *buf = "hello world";
char buf2[20] = "hello world";
printf("%u\n%u\n%u\n", sizeof(buf), sizeof(buf2),
sizeof "hello world"); // 8 20 12
foo("hello world"); // 8
return 0;
}
22.4 指针与 const 限定符
类型限定符 const 表示只读,不可修改的语义。
const int *a;a 是指向 const int 型的指针,所指向的内存单元 *a 不可修改,但指针 a 本身可以改写指向到别的内存单元。int *const a;a 是指向 int 型的 const 指针,*a 可以改写,但 a 本身不可改写。const int *const a;a 是指向 const int 型的 const 指针,*a 和 a 本身都不可改写。
在赋值、初始化或函数调用传参过程中,若把指针 p 赋值给指针 q(q = p)时,也要注意 const 限定符的语义:
const char c = 'a';
char *q = &c; // 不可以
&c 是 const char 型,变量 c 并不希望被修改,但这样赋值给 q 之后,由于 q 是 char 型,限定更宽松,导致可以通过 q 指针改写变量 c 的值,因此编译器不允许这样赋值。
一句话概括:
想要把指针 p 赋值给 指针 q,q 指向的类型应该比 p 指向的类型限定得更严格,或至少同样严格,才不至于让 q 违背 p 的意愿下改写了 p 指向的内存单元。
良好的编程习惯应该尽量使用 const 限定符:
- const 读代码时传达有用信息。
- 把程序中不该改变的变量都加上 const 限定符,依靠编译器检查意外改写。
- const 能提示编译器做出额外优化,如可能 const 变量优化成常量。
char *p = "abcd";
p[1] = 'B';
会报错。
C 标准规定字符串字面值做右值时自动转换成 char 指针,但实际 gcc 实现时把这种字符串字面值分配在 .rodata 段,运行时加载到 Text Segment,不能被改写,所以会导致段错误。
因此字符串字面值做右值时应看作 const char 指针,表示指向的内存单元不可被改写。
printf 函数原型的第一个参数是 const char * 型,但字符串中可能包含 % 号 被当成转换说明,从而进一步从栈帧上取可变参数,造成意外的非法内存访问,所以保险写法是 printf("%s", p);。
22.5 指针与结构体
struct unit {
char c;
int num;
};
struct unit u;
struct unit *p = &u;
22.6 指向指针的指针与指针数组
int i;
int *pi = &i;
int **ppi = π
ppi 指向 pi,pi 指向 i,ppi 取 pi 的值,*ppi 取 i 的值。
int *a[10]; // 每个元素都是 int * 指针
int **pa = &a[0];
数组中的每个元素也可以是指针类型,叫指针数组。上例中,pa[0] 和 a[0] 取的是同一个元素。
main 函数的标准原型是 int main(int argc, char *argv[]);。
argc 是命令行参数的个数,argv 是一个指向指针的指针,而不是指针数组,因为函数原型中的 [] 表示一定是指针而不表示数组,所以第二个形参实际等价于 char *argv。数组中每个元素都是 char 指针,指向一个命令行参数字符串。
打印命令行参数:
int main(int argc, char *argv[])
{
for (int i = 0; i < argc; i++)
{
printf("argv[%d]=%s\n", i, argv[i]);
}
}
int main(int argc, char *argv[])
{
for (int i = 0; argv[i] != NULL; i++)
{
printf("argv[%d]=%s\n", i, argv[i]);
}
}
NULL 标示着 argv 结尾,像个哨兵守卫数组边界。
程序名 argv[0] 也是一个命令行参数,如果给程序建立符号链接,通过符号链接运行该程序,就可得到不同的 argv[0],但实际还是执行了同一个程序,如 Busybox 将各种 Linux 命令 cp ls mv ifconfig 等集于一身。
gdb 调试时加命令行参数,可以在 run 或 start 命令后面加参数,或者用 set args 命令设置命令行参数之后再用 run 或 start 运行程序:
const 起不同作用:
const char **p; // **p 代表的内存单元不可改 char *const *p; // 一级指针的指向不可改 char **const p; // 二级指针的指向不可改不能把char赋给const char原因一言难尽,是两不相容类型,不能相互赋值,C 语言深坑。
22.7 指向数组的指针与多维数组
指针可以指向复合类型。 ```c int a[10]; // 等价于 typedef int t; t a[10];
int (a)[10]; // 等价于 typedef int t[10]; t a;
int (*a)[10] 定义了变量 a 是个自身类型为 int (*)[10] 指向 int [10] 类型的指针。
```c
int a[10];
int (*pa)[10] = &a; // 指向数组
pa 指向数组 a,取 a[0] 可用指针的方式 (*pa)[0] —> pa[0][0]。
int a[5][10];
int (*pa)[10] = &a[0]; // 指向二维数组
pa 指向 a 的首元素,pa[0] 和 a[0] 取的是同一元素,比原来复杂之处在于元素类型是 int [10] 数组,p[1][2] 和 a[1][2] 也一样。
而且 pa 比 a 用起来灵活,指针可以支持赋值、自增等运算,pa++ 使 pa 跳过二维数组的一行(int [10] 共 40个字节)。
#include <stdio.h>
int main(void)
{
char a[4][3][2] = {{{'a', 'b'}, {'c', 'd'}, {'e', 'f'}},
{{'g', 'h'}, {'i', 'j'}, {'k', 'l'}},
{{'m', 'n'}, {'o', 'p'}, {'q', 'r'}},
{{'s', 't'}, {'u', 'v'}, {'w', 'x'}}};
char(*pa)[2] = &a[1][0];
char(*ppa)[3][2] = &a[1];
// 通过 pa 或 ppa 访问 'r' 元素
printf("%c\n", pa[5][1]);
printf("%c\n", ppa[1][2][1]);
return 0;
}
22.8 函数类型和函数指针类型
C 语言中,函数也是一种类型,指向函数的指针变量,其内存单元里存放的地址值是函数的入口地址(位于 .text 段)。
#include <stdio.h>
void say_hello(const char *str)
{
printf("Hello %s\n", str);
}
int main(void)
{
void (*f)(const char *) = say_hello; // 函数类型做右值时自动转换成函数指针,或 &say_hello 手动取地址再赋值
f("World");
return 0;
}
上面的 f 就是一个函数指针,指向的类型是 void (const char *); 其实就是约定了某种函数原型。
函数调用运算符() 要求操作数是函数指针类型:
f("World"); // 指针调用最直接
say_hello("World"); // 把函数类型自动转为函数指针类型再调用
(*f)("World"); // 把函数类型自动转为函数指针类型再调用
typedef int F(void); // 定义函数类型 F
F *e(void); // 函数 e 返回一个 F* 类型的函数指针
int (*fp)(void); // 声明函数指针 fp
F *fp; // 声明函数指针 fp
typedef int (*FP)(void); // 定义函数指针类型 FP
((FP)0x12345678)(); // 把某函数首地址强制转换成相应的函数指针,并调用它
((int (*)(void))0x12345678)(); // 不用类型名的强制转换调用
那么通过函数指针调用函数和通过函数名直接调用函数相比有什么区别?用到再说。
22.9 不完全类型和复杂声明
C 语言类型分为:
- 函数类型
- 对象类型
- 标量类型
- 非标量类型
- 不完全类型:暂时未定义好的类型,编译器不知道该占几个字节的空间
不完全类型
在同一编译单元里,具有不完全类型的变量可以通过多次声明组合成一个完全类型:
extern char str[];
extern char str[10];
若编译器处理到编译单元末尾仍无法将 str 类型组合成完全类型,则会报错。
struct s {
struct t *pt;
};
struct t {
struct s *ps;
};
编译器逐步处理,最终 s 和 t 都变为了完全类型。
这种相互引用构成的递归定义是ok的,还可以结构体引用自身:
struct s {
char data[6];
struct *s next;
};
复杂声明体操:
声明了一个函数指针 fp,它指向的函数其参数是 void ,其返回值是 int () [10]:
int (*(*fp)(void *))[10];
再看一个复杂类型,下面声明了一个函数 x:
char (*(*x(void))[3])(void);
// 分解为:
typedef char (*T)(void);
typedef T (*R)[3];
R x(void);
