这一篇开始进入 C 语言里最容易卡住、但也最值得啃下来的部分:指针。

指针不是一个单独的语法点,它连接了变量、地址、数组、字符串、函数调用和内存布局。后面做安全开发、逆向调试、漏洞分析时,很多问题最终都会落到一句话上:这块内存在哪里,指针指向哪里,按什么类型解释,能不能读写。

8.0 内存存储单元

程序运行时,变量会被放进内存。内存可以先粗略理解成一排连续编号的存储单元,每个单元都有自己的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int main(void)
{
int age = 20;
double score = 95.5;
char level = 'A';

printf("age = %d, address = %p\n", age, (void *)&age);
printf("score = %.1f, address = %p\n", score, (void *)&score);
printf("level = %c, address = %p\n", level, (void *)&level);

return 0;
}

&变量名 可以取出变量的地址。地址本身也是一个值,只不过这个值表示某块内存的位置。

变量、地址、内容

概念 含义
变量名 程序员给内存起的名字
地址 内存位置的编号
内容 这块内存里保存的数据
类型 告诉编译器如何解释这块内存

同一块内存,如果按不同类型解释,看到的结果可能不同。这也是为什么类型和指针要一起理解。

8.1 指针变量

指针变量用来保存地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int main(void)
{
int value = 100;
int *p = &value;

printf("value = %d\n", value);
printf("&value = %p\n", (void *)&value);
printf("p = %p\n", (void *)p);
printf("*p = %d\n", *p);

return 0;
}

这里的几个符号要分清楚:

写法 含义
int *p 定义一个指向 int 的指针变量
&value 取出 value 的地址
p 指针变量里保存的地址
*p 访问 p 指向地址里的内容

通过指针修改变量

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

int main(void)
{
int value = 10;
int *p = &value;

*p = 99;

printf("value = %d\n", value);

return 0;
}

*p = 99; 修改的是 p 指向的那块内存,所以 value 的值也变了。

8.2 指针内存图解

可以把指针关系画成三层:

1
2
3
4
5
6
7
变量 value
地址 0x1000
内容 10

变量 p
地址 0x2000
内容 0x1000

p 自己也是变量,也有自己的地址;只不过它保存的内容刚好是另一个变量的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

int main(void)
{
int value = 10;
int *p = &value;

printf("&value = %p\n", (void *)&value);
printf("p = %p\n", (void *)p);
printf("&p = %p\n", (void *)&p);

return 0;
}

指针关系复盘

表达式 看到的东西
value 普通变量的值
&value 普通变量的地址
p 指针保存的地址
*p 指针指向地址里的内容
&p 指针变量自己的地址

指针难,不是因为符号多,而是因为它同时涉及“指针变量自身”和“指针指向的目标”。复盘时尽量把这两层分开。

8.3 指针占用内存大小

指针变量保存的是地址,所以指针大小主要取决于平台位数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int main(void)
{
int *p_int = NULL;
char *p_char = NULL;
double *p_double = NULL;

printf("sizeof(p_int) = %zu\n", sizeof(p_int));
printf("sizeof(p_char) = %zu\n", sizeof(p_char));
printf("sizeof(p_double) = %zu\n", sizeof(p_double));

return 0;
}

在 64 位程序中,指针通常占 8 字节;在 32 位程序中,指针通常占 4 字节。

指针大小和指向类型不是一回事

表达式 含义
sizeof(p_int) 指针变量自身占多大
sizeof(*p_int) 指针指向的 int 占多大
sizeof(p_char) char * 指针自身占多大
sizeof(*p_char) 指针指向的 char 占多大
1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

int main(void)
{
int value = 10;
int *p = &value;

printf("sizeof(p) = %zu\n", sizeof(p));
printf("sizeof(*p) = %zu\n", sizeof(*p));

return 0;
}

8.4 空指针

空指针表示“不指向任何有效对象”。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

int main(void)
{
int *p = NULL;

if (p == NULL)
{
printf("p is null\n");
}

return 0;
}

使用前先判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>

void print_value(int *p)
{
if (p == NULL)
{
printf("null pointer\n");
return;
}

printf("value = %d\n", *p);
}

int main(void)
{
int value = 10;

print_value(&value);
print_value(NULL);

return 0;
}

空指针不能解引用。*p 的前提是 p 指向一块有效内存。

指针使用前先判断是否为 NULL,这是很基础但很重要的防御习惯。尤其是函数参数、内存申请结果、文件打开结果,都要先检查。

8.5 野指针

野指针是指向未知或已经无效内存的指针。

常见来源:

  1. 指针未初始化。
  2. 指向已经释放的内存。
  3. 指向已经结束生命周期的局部变量。
  4. 指针越界移动后继续使用。

未初始化指针

1
2
3
// 错误示例:p 没有初始化
// int *p;
// *p = 10;

指针定义后,如果暂时不知道指向哪里,就初始化为 NULL

1
int *p = NULL;

指针失效后置空

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
int *p = (int *)malloc(sizeof(int));

if (p == NULL)
{
return 1;
}

*p = 100;
printf("%d\n", *p);

free(p);
p = NULL;

return 0;
}

free(p) 之后,p 里原来的地址不再应该使用。把它置为 NULL,可以减少误用风险。

8.6 指针步长

指针加减不是简单的地址数字加减,而是按指向类型的大小移动。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

int main(void)
{
int arr[3] = {10, 20, 30};
int *p = arr;

printf("p = %p, *p = %d\n", (void *)p, *p);
printf("p + 1 = %p, *(p+1) = %d\n", (void *)(p + 1), *(p + 1));
printf("p + 2 = %p, *(p+2) = %d\n", (void *)(p + 2), *(p + 2));

return 0;
}

如果 pint *p + 1 通常移动 4 字节;如果是 char *p + 1 移动 1 字节。

不同类型的步长

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

int main(void)
{
int value = 0x12345678;

int *p_int = &value;
unsigned char *p_byte = (unsigned char *)&value;

printf("p_int = %p\n", (void *)p_int);
printf("p_int + 1 = %p\n", (void *)(p_int + 1));

printf("p_byte = %p\n", (void *)p_byte);
printf("p_byte + 1 = %p\n", (void *)(p_byte + 1));

return 0;
}

这个例子适合观察“同一个地址,按不同指针类型移动时,步长不同”。

8.7 指针与数组

数组名在很多表达式里会退化成首元素地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int main(void)
{
int arr[4] = {11, 22, 33, 44};
int *p = arr;

printf("arr[0] = %d\n", arr[0]);
printf("*p = %d\n", *p);
printf("arr[2] = %d\n", arr[2]);
printf("*(p+2) = %d\n", *(p + 2));

return 0;
}

数组下标和指针形式

1
arr[i] 等价于 *(arr + i)

但不要因此把数组和指针完全混为一谈。

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

int main(void)
{
int arr[4] = {1, 2, 3, 4};
int *p = arr;

printf("sizeof(arr) = %zu\n", sizeof(arr));
printf("sizeof(p) = %zu\n", sizeof(p));

return 0;
}

sizeof(arr) 是整个数组大小,sizeof(p) 是指针变量大小。

数组传参会退化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>

void print_array(int arr[], size_t count)
{
for (size_t i = 0; i < count; i++)
{
printf("%d\n", arr[i]);
}
}

int main(void)
{
int nums[] = {1, 2, 3, 4};
size_t count = sizeof(nums) / sizeof(nums[0]);

print_array(nums, count);

return 0;
}

函数参数里的 int arr[] 本质上是 int *arr,所以长度要单独传进去。

8.8 指针与常量字符串

字符串常量通常存放在只读区域,不应该被修改。

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

int main(void)
{
const char *p = "hello";

printf("%s\n", p);

return 0;
}

推荐用 const char * 接收字符串常量,明确告诉自己:这里的内容不能改。

字符数组和字符串指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int main(void)
{
char text1[] = "hello";
const char *text2 = "world";

text1[0] = 'H';

printf("%s\n", text1);
printf("%s\n", text2);

return 0;
}
写法 含义
char text1[] = "hello"; 创建一个字符数组,内容可改
const char *text2 = "world"; 指向字符串常量,内容不应修改

不要写 char *p = "hello"; p[0] = 'H';。这类代码把只读字符串当成可写内存,可能直接崩溃。

8.9 函数组成规则

函数用于把一段逻辑封装成可复用的单元。

1
2
3
4
返回值类型 函数名(参数列表)
{
函数体;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int add(int left, int right)
{
return left + right;
}

int main(void)
{
int result = add(3, 5);
printf("result = %d\n", result);

return 0;
}

函数的几个组成部分

部分 示例 说明
返回值类型 int 函数执行后返回什么类型
函数名 add 调用函数时使用的名字
参数列表 int left, int right 调用者传给函数的数据
函数体 { ... } 函数真正执行的代码
返回语句 return left + right; 把结果交还给调用者

函数声明和函数定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int add(int left, int right); // 函数声明

int main(void)
{
printf("%d\n", add(1, 2));
return 0;
}

int add(int left, int right) // 函数定义
{
return left + right;
}

函数声明告诉编译器“这个函数存在,长这样”;函数定义才是函数真正的实现。

9.0 函数框架解析

一个稍微完整一点的 C 程序,通常会把输入、处理和输出拆成多个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <stdio.h>

int input_score(void)
{
int score = 0;

printf("input score: ");

#ifdef _MSC_VER
scanf_s("%d", &score);
#else
scanf("%d", &score);
#endif

return score;
}

int is_valid_score(int score)
{
return score >= 0 && score <= 100;
}

const char *get_level(int score)
{
if (score >= 90)
{
return "A";
}
else if (score >= 80)
{
return "B";
}
else if (score >= 60)
{
return "C";
}
else
{
return "D";
}
}

int main(void)
{
int score = input_score();

if (!is_valid_score(score))
{
printf("invalid score\n");
return 1;
}

printf("level = %s\n", get_level(score));

return 0;
}

拆函数时先问三件事

问题 说明
输入是什么 通过参数、全局变量、文件还是用户输入
输出是什么 返回值、指针参数、打印输出还是修改外部状态
责任是什么 这个函数只做一件事,还是混了太多逻辑

指针和函数的连接

函数参数如果只是普通类型,通常是值传递;如果想让函数修改外部变量,可以传地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>

void set_value(int *p)
{
if (p == NULL)
{
return;
}

*p = 100;
}

int main(void)
{
int value = 0;

set_value(&value);

printf("value = %d\n", value);
return 0;
}

这就是指针和函数最常见的交汇点:把地址交给函数,让函数通过地址访问或修改外部数据。

本章最小复盘代码

这段代码把指针、数组、字符串常量和函数拆分放在一起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <stdio.h>

void print_array(const int *arr, size_t count)
{
if (arr == NULL)
{
printf("array is null\n");
return;
}

for (size_t i = 0; i < count; i++)
{
printf("arr[%zu] = %d, address = %p\n",
i, arr[i], (void *)&arr[i]);
}
}

void change_value(int *p)
{
if (p == NULL)
{
return;
}

*p = 2026;
}

int main(void)
{
int nums[] = {10, 20, 30, 40};
size_t count = sizeof(nums) / sizeof(nums[0]);

print_array(nums, count);

int value = 100;
int *p = &value;

printf("before: value = %d\n", value);
change_value(p);
printf("after : value = %d\n", value);

const char *message = "pointer and function";
printf("%s\n", message);

return 0;
}

复盘时重点看:

  1. p 保存的是谁的地址。
  2. *p 修改的是哪块内存。
  3. print_array 为什么需要单独传 count
  4. const int *arr 表示函数不应该修改数组内容。
  5. const char *message 为什么适合指向字符串常量。

常见错误整理

错误 示例 问题
指针未初始化 int *p; *p = 10; 写入未知地址
空指针解引用 int *p = NULL; *p = 1; 程序崩溃或异常
释放后继续使用 free(p); *p = 1; 典型悬空指针问题
忘记指针步长 p + 1 当成地址加 1 字节 实际按元素大小移动
数组传参后用 sizeof 算长度 函数参数中 sizeof(arr) 得到指针大小
修改字符串常量 char *p = "hi"; p[0] = 'H'; 可能写只读内存
函数缺少声明 调用发生在定义之前 编译警告或错误
返回局部变量地址 return &local; 函数结束后地址失效
指针参数不判空 直接 *p 外部传 NULL 会出问题

学习检查清单

检查项 状态
能说清楚变量、地址、内容和类型之间的关系 待复盘
能定义指针变量并保存普通变量地址 待复盘
能区分 p*p&p&value 待复盘
能通过指针修改外部变量 待复盘
能解释指针变量自身占用内存大小 待复盘
能在使用指针前判断 NULL 待复盘
能说明野指针的常见来源 待复盘
能理解指针步长由指向类型决定 待复盘
能解释数组名退化成首元素地址 待复盘
能说明数组传参为什么要单独传长度 待复盘
能区分字符数组和字符串常量指针 待复盘
能写出函数声明、函数定义和函数调用 待复盘
能把一个小程序拆成输入、判断、输出几个函数 待复盘

这一篇的核心是“指针保存地址,类型决定解释方式”。只要涉及指针,就要同时看三件事:它指向哪里、目标是否有效、按什么类型访问。