指针到底是什么

学 C 语言绕不开指针,而指针之所以让人发怵,往往是因为一开始就被“指针就是地址”这句话带偏了。更准确的说法是:内存是一排带门牌号的抽屉,变量是抽屉里放着的东西,指针是写着门牌号的纸条。当你写下 int a = 5;,编译器为你安排一个抽屉、贴上地址、放进 5;当你写下 int *p = &a;,你拿到的是那个抽屉的门牌号。

理解了“值”和“地址”是两个层次,后面所有语法就都顺了:p 是门牌号本身,*p 是“顺着门牌号打开抽屉看到的东西”,&a 是“取 a 的门牌号”。解引用(dereference)不是魔法,只是“按地址访问”。

#include <stdio.h>

int main(void) {
    int a = 5;
    int *p = &a;                 /* p 保存 a 的地址 */

    printf("a 的值      = %d\n", a);
    printf("a 的地址    = %p\n", (void *)&a);
    printf("p 保存的地址 = %p\n", (void *)p);
    printf("p 指向的值   = %d\n", *p);   /* 顺着地址取到 5 */

    *p = 42;                     /* 通过指针改写 a */
    printf("修改后 a    = %d\n", a);    /* 输出 42 */
    return 0;
}
指针本身也有地址,指针的类型决定“一次解引用读几个字节、地址加一时跳多远”。忘记后者,就会写出一半的数组越界 bug。
本文核心知识点
  • 指针存放地址的变量,类型决定解引用宽度与步长。
  • 内存分区栈、堆、静态区、常量区、代码区,生命周期各不相同。
  • malloc/free堆内存手动申请必须手动释放,且一一配对。
  • 三类错误悬垂指针、越界访问、内存泄漏,可用工具自动定位。
C 语言指针与内存管理示意:一排方形内存方块中,发光箭头指针指向其中一块,背景为栈堆分层结构与齿轮
把内存想成一排带地址的抽屉:指针只是写着门牌号的纸条,解引用就是按号取物。

栈、堆与静态区:内存怎么分区

一个进程的地址空间被人为划分成几个区域,理解它们的生命周期,是判断“这块内存该由谁释放”的唯一依据。局部变量在栈上,随函数返回自动回收;malloc 拿到的内存在堆上,只能手动释放;全局变量和 static 变量在静态区,程序运行期间一直存在。

区域存放什么生命周期谁负责回收
栈 stack局部变量、函数参数、返回地址函数调用期间编译器自动
堆 heapmalloc / calloc / realloc 申请的内存从申请到 free程序员手动
静态区 data/bss全局变量、static 变量整个程序运行期进程结束时由系统回收
常量区 rodata字符串字面量、const 常量整个程序运行期只读,不可释放

三条实用推论:

  • 函数里返回局部数组的地址是错的——函数一返回,栈帧就没了,那块内存随时会被别的调用覆盖。
  • 字符串字面量 "hello" 落在只读区,试图写它会直接崩溃,需要可改的字符串要用 char buf[] = "hello";。
  • 栈空间通常只有几 MB,递归太深或超大局部数组会“爆栈”,这类数据应改用堆。

指针运算与数组的天然关系

在 C 里,数组名在多数表达式中会“退化”为指向首元素的指针。于是 arr[i] 与 *(arr + i) 完全等价,而这里的 + i 不是加 i 个字节,而是加 i 个元素——编译器用元素类型的大小替你做了换算。

#include <stdio.h>

int main(void) {
    int arr[5] = {10, 20, 30, 40, 50};
    int *p = arr;                /* 等价于 &arr[0] */

    printf("%d %d\n", arr[2], *(p + 2));   /* 都是 30 */
    printf("步长(字节) = %zu\n", sizeof(int));

    /* 用指针遍历,注意用 end 作为边界,避免每轮重算长度 */
    for (int *q = p; q < p + 5; ++q) {
        printf("%d ", *q);
    }
    printf("\n");
    return 0;
}

指针减指针得到的是“相隔多少个元素”(类型为 ptrdiff_t),不是字节差;p + 5 指向数组最后一个元素的下一个位置,可以比较、不可解引用。sizeof(arr) 与 sizeof(p) 的结果天差地别,原因是数组名未退化时才保留“数组类型”。这正是把数组传进函数后长度信息丢失、必须额外传 len 的原因。

动态内存:malloc 与 free 的配对

当所需内存大小要到运行时才知道,就必须上堆。malloc 只申请不初始化,calloc 申请并清零,realloc 在保留原内容的前提下调整大小。三者都可能失败返回 NULL,且返回值不能直接赋给有类型的指针变量(C 语言里可隐式转换,但显式转换更清晰)。

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int n = 100;
    int *buf = malloc((size_t)n * sizeof(int));
    if (buf == NULL) {           /* 1. 必须检查失败 */
        perror("malloc");
        return 1;
    }
    for (int i = 0; i < n; ++i) buf[i] = i * i;
    printf("buf[9] = %d\n", buf[9]);   /* 81 */

    free(buf);                   /* 2. 释放后立即置空 */
    buf = NULL;
    return 0;
}

三条纪律:申请后必须判空、使用后必须释放、释放后必须置空。把 free 之后的指针置为 NULL,能让“释放两次”这类错误立刻变成可发现的崩溃,而不是潜伏下来。不要对同一块内存 free 两次,也不要 free 非 malloc 家族返回的指针(比如栈数组地址),否则行为未定义。

悬垂、越界与泄漏三类问题

内存相关 bug 难查,是因为它们往往不会立刻报错,而是“污染”了别处的数据后才在某个不相干的地方崩掉。按症状可分为三类:

类型典型成因症状修法
悬垂指针free 后继续用、返回局部变量地址读到垃圾值、随机崩溃释放后置 NULL,不返回栈地址
越界访问下标超长、strcpy 不查长度破坏相邻变量、栈保护报错用长度参数、改用带界函数
内存泄漏申请后未 free、异常路径漏 free长跑进程内存持续上涨每条成功路径都要有对应释放
  • 越界写最隐蔽:它可能恰好改掉函数返回地址,直到函数返回时才“爆雷”。
  • 泄漏在短命的小程序里看不出来,但在服务器进程里会持续吃掉内存。
  • 推荐用 valgrind 或编译器自带的 AddressSanitizer 在开发阶段就把它们揪出来。

用工具定位内存错误

靠 printf 猜内存 bug 效率极低。正确做法是打开编译器的消毒器(sanitizer),让运行时替你做检查。GCC / Clang 只需一个编译选项,就能把越界、使用已释放内存、泄漏都能报出来,并给出出错时的调用栈。

# 用 AddressSanitizer + LeakSanitizer 编译并运行
clang -g -fsanitize=address,undefined -o demo demo.c
./demo

# Linux/macOS 上也可用 valgrind 逐行报告泄漏点
valgrind --leak-check=full --show-leak-kinds=all ./demo

# 典型输出(节选):直接指出哪一行分配、哪一行泄漏
# 40 bytes in 1 blocks are definitely lost ... in main (demo.c:12)

养成习惯:本地调试构建默认带上 -fsanitize=address,undefined,发布构建再去掉。注意 Sanitizer 与 -O2 也能共存,不必为了检查而牺牲调试体验。此外,把编译警告开满(-Wall -Wextra)并当作错误处理(-Werror),能提前拦下大量“看起来能编过”的隐患。