这一章围绕“指针到底能不能安全地访问内存”展开。C 语言给了很大的自由,也把边界检查的责任交给了程序员。后面做安全开发时,很多 bug 都会落在空指针、野指针、越界、重复释放和类型解释错误上。

3.0 内存属性探测

内存不仅有地址,还有属性:能不能读、能不能写、能不能执行、属于栈还是堆、是否映射文件。安全分析时看到一个地址,第一反应应该是问:这个地址在哪个区域?权限是什么?为什么会被访问?

属性 复盘问题
可读 读这个地址是否会异常
可写 是否允许修改内容
可执行 是否可能被当成代码执行
私有/映射 来自堆、栈、模块还是映射文件

3.1 void * 指针

void * 是“无类型指针”,可以保存任意对象地址,但不能直接解引用。它常用于通用接口,比如内存拷贝、回调参数、容器数据。

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

void print_int(void *data) {
int *p = (int *)data;
printf("%d\n", *p);
}

int main(void) {
int value = 42;
print_int(&value);
return 0;
}

复盘重点:void * 必须配合明确的真实类型,否则同一段内存会被错误解释。

3.2 堆区内存管理

堆内存由程序主动申请、主动释放。它比栈更灵活,但也更容易出错:忘记释放、重复释放、释放后继续使用,都是典型问题。

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

int main(void) {
int *arr = (int *)malloc(sizeof(int) * 4);
if (arr == NULL) {
return 1;
}

for (int i = 0; i < 4; ++i) {
arr[i] = i + 1;
}

free(arr);
arr = NULL;
return 0;
}
操作 复盘重点
malloc 判断返回值是否为空
使用 永远带着长度一起使用
free 释放后不要继续访问
置空 降低悬空指针误用概率

3.3 内存回收机制

C 语言没有自动垃圾回收。谁申请,谁负责释放;如果所有权不清楚,代码很快会变乱。

问题 表现 解决习惯
内存泄漏 程序运行久了占用越来越多 明确释放位置
重复释放 崩溃或堆结构损坏 释放后置空,统一所有权
释放后使用 偶发崩溃或数据异常 不保留过期指针
越界写 邻近内存被破坏 所有函数都传长度

3.4 设置内存数据

memset 常用于初始化内存,但它按字节设置,不理解对象类型。对结构体和字节缓冲区常用;对复杂 C++ 对象不要乱用。

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

typedef struct Buffer {
char data[16];
int length;
} Buffer;

int main(void) {
Buffer buffer;
memset(&buffer, 0, sizeof(buffer));
snprintf(buffer.data, sizeof(buffer.data), "hello");
buffer.length = 5;
printf("%s %d\n", buffer.data, buffer.length);
return 0;
}

3.5 常量指针与指针常量

const 指针组合是 C/C++ 的基本功。核心看 const 修饰谁:修饰指向内容,还是修饰指针本身。

写法 含义
const int *p 不能通过 p 修改指向内容,p 可以改指向
int * const p p 不能改指向,可以通过 p 修改内容
const int * const p 指向和内容都不能通过 p
1
2
3
4
5
6
void read_only(const char *text) {
if (text == NULL) {
return;
}
puts(text);
}

3.6 指向常量的指针与常量的指针常量

这节可以当作上节的加强复盘。函数参数里优先用 const 表达只读意图,既能保护数据,也能让调用者看懂接口。

场景 推荐写法
只读字符串 const char *text
只读数组 const int *arr, int count
不允许改指向的内部指针 T * const p
既不改指向也不改内容 const T * const p

3.7 数组寻址与指针步长

指针加一不是地址加一字节,而是加一个元素大小。int * 加一通常跨过 4 字节,char * 加一跨过 1 字节。

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

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

printf("%d\n", *(p + 1));
printf("%p %p\n", (void *)p, (void *)(p + 1));
return 0;
}

3.8 指针运算规则

指针运算必须在同一个数组或连续对象范围内才有意义。跨对象乱算地址,在 C 语言里非常危险。

运算 合法场景
p + n 数组范围内移动
p - n 数组范围内回退
p2 - p1 两个指针指向同一数组
比较 同一数组范围内比较更有意义

3.9 多级指针属性探测

多级指针本质是“指针的地址”。常见于需要在函数里修改外部指针的场景,比如申请内存后把地址返回给调用者。

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

int alloc_int(int **out) {
if (out == NULL) {
return 0;
}

*out = (int *)malloc(sizeof(int));
if (*out == NULL) {
return 0;
}

**out = 100;
return 1;
}

复盘时把每一级画出来:out 是二级指针,*out 是外部指针变量,**out 才是最终的整数。

本章最小复盘代码

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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct Message {
char text[32];
int length;
} Message;

int create_message(Message **out, const char *text) {
if (out == NULL || text == NULL) {
return 0;
}

Message *msg = (Message *)malloc(sizeof(Message));
if (msg == NULL) {
return 0;
}

memset(msg, 0, sizeof(Message));
snprintf(msg->text, sizeof(msg->text), "%s", text);
msg->length = (int)strlen(msg->text);
*out = msg;
return 1;
}

int main(void) {
Message *msg = NULL;
if (create_message(&msg, "hello")) {
printf("%s %d\n", msg->text, msg->length);
}

free(msg);
msg = NULL;
return 0;
}

学习检查清单

检查项 状态
能说明 void * 为什么需要显式类型解释 待复盘
能写出申请、使用、释放、置空的堆内存流程 待复盘
能区分 const int *int * const 待复盘
能解释指针步长和数组寻址公式 待复盘
能画出二级指针修改外部指针的过程 待复盘