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* 在嵌入式系统里的经典用法:

  • 内存操作函数memcpymemsetmemmove 的参数都是 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 本章知识体系

下面这张图总结了指针与内存操作的核心脉络,我建议你把它记在脑子里:

指针与内存操作知识体系 指针的本质 指针运算 加减法、步长、比较 指针与数组 数组名、下标运算、sizeof 指针与函数 函数指针、回调 void* 指针 万能指针、类型转换 应用场景:内存池、链表、驱动抽象、零拷贝

我的学习建议:

指针这东西,光看书是学不会的。我建议你打开编译器,把上面的代码都敲一遍。然后自己改改,看看指针地址的变化。多动手,多画内存布局图,慢慢就通了。


公众号:蓝海资料掘金营,微信deep3321