3、指针与内存操作:指针的本质、指针运算、指针与数组、指针与函数、void*指针的使用
3.1 指针的本质——它就是个地址
很多初学者觉得指针很玄乎。其实说白了,指针就是一个存放内存地址的变量。你想想看,我们平时用变量存数字、存字符,指针变量存的不是数据本身,而是数据在内存里的门牌号。
我在项目中遇到过好几次这样的场景:一个结构体几百个字节,如果按值传递,每次都要复制一整块内存。用指针传,只传一个4字节(32位系统)或8字节(64位系统)的地址,效率天差地别。这就是指针最朴素的价值——用最小的代价访问最大的数据。
核心理解:
- 指针变量 = 存放地址的变量
- 指针的类型 = 告诉编译器这个地址里存的是什么类型的数据
- 指针的大小 = 固定(32位系统4字节,64位系统8字节),与指向的类型无关
// 指针的本质演示
int a = 100;
int *p = &a; // p 存的是 a 的地址
printf("a 的值:%d\n", a); // 100
printf("a 的地址:%p\n", &a); // 0x7ffd...
printf("p 的值:%p\n", p); // 0x7ffd...(和上面一样)
printf("p 指向的值:%d\n", *p); // 100(解引用)
嗯,这里要注意:& 是取地址运算符,* 是解引用运算符。这两个符号是理解指针的钥匙。
3.2 指针运算——别踩内存的雷
指针也能做加减法?没错,但它的加减法和普通整数不一样。指针加1,不是地址值加1,而是加上它所指向类型的大小。
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr; // 指向数组首元素
printf("p = %p, *p = %d\n", p, *p); // 0x... , 10
printf("p+1 = %p, *(p+1) = %d\n", p+1, *(p+1)); // 0x...+4 , 20
为什么会这样?因为指针的类型决定了它的步长。int* 每次移动4字节,char* 每次移动1字节,double* 每次移动8字节。这个设计很巧妙——你想想看,如果指针加1只是地址加1,那访问下一个int就得手动加4,多麻烦。
我曾经踩过的坑:
有一次我写一个环形缓冲区,用指针做边界判断。结果指针越界了,跑到了数组外面,读出来的数据全是垃圾。排查了半天才发现是指针运算时没考虑类型大小。从那以后,我每次做指针运算都会在心里默念一遍:指针加减的是元素个数,不是字节数。
指针运算的常用操作:
p + n:向后移动 n 个元素p - n:向前移动 n 个元素p1 - p2:两个指针之间相差的元素个数(前提是它们指向同一数组)++p / p++:自增,指向下一个元素--p / p--:自减,指向上一个元素
3.3 指针与数组——剪不断理还乱
数组名和指针的关系,是C语言里最容易让人迷糊的地方。我直接说结论:数组名在大多数情况下会被隐式转换为指向首元素的指针。
int arr[5] = {1, 2, 3, 4, 5};
// 下面三种写法完全等价
printf("%d\n", arr[2]); // 3
printf("%d\n", *(arr + 2)); // 3
printf("%d\n", *(2 + arr)); // 3(没错,可以这样写)
但有一个重要的例外:sizeof(arr) 返回的是整个数组的大小,而不是指针的大小。这个区别我见过太多人搞混了。
我的个人习惯:
在函数参数里,我从来不用 int arr[] 这种写法,而是直接用 int *arr。因为编译器会把前者当成后者处理,不如直接写指针,语义更清晰。同时我会额外传一个 size_t len 参数,避免数组越界。
// 推荐的做法
void process_array(int *arr, size_t len) {
for (size_t i = 0; i < len; i++) {
arr[i] *= 2; // 或者 *(arr + i) *= 2
}
}
3.4 指针与函数——回调的基石
函数指针,说白了就是指向函数的指针。函数本身也有地址,存在代码段里。我们可以把这个地址存起来,然后通过它来调用函数。
我在写嵌入式驱动时经常用函数指针。比如一个按键驱动,不同的按键按下要执行不同的操作。用函数指针数组来管理,代码会非常干净。
// 函数指针的定义和使用
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int main() {
// 定义一个函数指针,指向返回int、参数为两个int的函数
int (*op)(int, int);
op = add;
printf("add: %d\n", op(10, 5)); // 15
op = sub;
printf("sub: %d\n", op(10, 5)); // 5
return 0;
}
函数指针最常见的用途就是回调函数。比如你写一个排序函数,比较规则由调用者通过函数指针传入。这样你的排序函数就通用多了。
函数指针的典型应用场景:
- 回调函数(定时器、中断、事件处理)
- 状态机(用函数指针数组实现状态跳转)
- 插件架构(动态加载不同功能模块)
- 驱动层抽象(统一接口,不同实现)
3.5 void* 指针——万能钥匙
void* 是C语言里最灵活也最危险的指针类型。它不关心指向什么类型,可以接受任何类型的指针赋值。但反过来,你不能直接解引用 void*,因为编译器不知道它指向什么类型的数据。
int a = 100;
double b = 3.14;
char c = 'X';
void *vp;
vp = &a; // 合法
vp = &b; // 合法
vp = &c; // 合法
// printf("%d\n", *vp); // 错误!不能解引用 void*
printf("%d\n", *(int*)vp); // 正确,先转成 int* 再解引用
我建议你记住一个原则:void* 只负责传递地址,不负责解释数据。谁存进去的,谁就要知道它是什么类型,用的时候转回来。
我曾经踩过的坑:
有一次我写一个通用的内存池,所有分配出去的块都用 void* 返回。结果有个同事拿到指针后,以为它指向的是结构体,直接强转后访问成员。但实际那块内存存的是另一个结构体,数据全乱了。从那以后,我在代码注释里明确写了:void* 的转换责任在调用方,请确保类型匹配。
void* 在嵌入式系统里的经典用法:
- 内存操作函数:
memcpy、memset、memmove的参数都是 void*,因为它们不关心数据类型 - 通用数据结构:链表、队列、栈的节点数据用 void* 存储,可以存放任意类型
- 回调参数:回调函数通常接受一个 void* 参数,用于传递用户自定义数据
// memcpy 的原型(简化版)
void *memcpy(void *dest, const void *src, size_t n);
// 使用示例
int src[10] = {1,2,3,4,5,6,7,8,9,10};
int dst[10];
memcpy(dst, src, sizeof(src)); // 不管什么类型,只管拷贝 n 个字节
3.6 本章知识体系
下面这张图总结了指针与内存操作的核心脉络,我建议你把它记在脑子里:
我的学习建议:
指针这东西,光看书是学不会的。我建议你打开编译器,把上面的代码都敲一遍。然后自己改改,看看指针地址的变化。多动手,多画内存布局图,慢慢就通了。
公众号:蓝海资料掘金营,微信deep3321