这一篇开始进入 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 野指针
野指针是指向未知或已经无效内存的指针。
常见来源:
- 指针未初始化。
- 指向已经释放的内存。
- 指向已经结束生命周期的局部变量。
- 指针越界移动后继续使用。
未初始化指针
指针定义后,如果暂时不知道指向哪里,就初始化为 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; }
|
如果 p 是 int *,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 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; }
|
复盘时重点看:
p 保存的是谁的地址。
*p 修改的是哪块内存。
print_array 为什么需要单独传 count。
const int *arr 表示函数不应该修改数组内容。
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 |
待复盘 |
| 能说明野指针的常见来源 |
待复盘 |
| 能理解指针步长由指向类型决定 |
待复盘 |
| 能解释数组名退化成首元素地址 |
待复盘 |
| 能说明数组传参为什么要单独传长度 |
待复盘 |
| 能区分字符数组和字符串常量指针 |
待复盘 |
| 能写出函数声明、函数定义和函数调用 |
待复盘 |
| 能把一个小程序拆成输入、判断、输出几个函数 |
待复盘 |
这一篇的核心是“指针保存地址,类型决定解释方式”。只要涉及指针,就要同时看三件事:它指向哪里、目标是否有效、按什么类型访问。