C语言指针详解:从内存地址到二级指针的完整指南

📅 发布时间:2026/9/7 22:51:32
C语言指针详解:从内存地址到二级指针的完整指南 1. 指针到底存了什么从门牌号开始理解1.1 变量的门牌号取地址*访客进门我当年学C语言最先卡死的地方就是指针。老师在黑板上画方框、画箭头说“指针就是指向变量的变量”我听完更晕——变量还能指向变量后来自己写代码才慢慢悟明白一件事指针解决的根本问题就是“程序怎么找到一块内存”。把内存想象成一条很长的街道每个字节都有一个门牌号地址。定义int age 25;的时候系统在街道某处找了四个连续房间把25存进去然后让名字age跟那个门牌号绑定。你写代码时只需要用age但计算机真正需要的是地址。age就是“查询门牌号”*则是“拿着门牌号进屋取东西或放东西”。#include stdio.h int main(void) { int age 25; int *p age; printf(age 的值: %d\n, age); // 25 printf(p 的值: %p\n, (void *)p); // 某个十六进制地址 printf(*p 的值: %d\n, *p); // 25 *p 30; // 等价于 age 30 printf(age 的新值: %d\n, age); // 30 return 0; }p保存的是age的地址所以p是“指针变量”。*p表示“p 所指向的那个 int 变量”因此*p 30会直接修改age所在内存里的内容。新手最容易混淆的其实是声明和使用的区别int *p;里的*是在告诉编译器“p 是指针”而后面单独写*p是在解引用。我后来有个习惯——把代码里的*p读作“p 所指的那个东西”很多混淆就自动消失了。1.2 类型决定解释方式但不决定指针的大小有一个问题几乎人人都会问int *p和char *p指针变量占的内存一样大吗在64位系统上答案是一样大通常8字节。因为指针变量的内容本质上是一个地址地址就是整数大小由系统位数决定。那类型有什么用类型决定的是“拿到地址之后怎么解释这块内存”。指针类型解引用读取长度常见平台p 1 的地址偏移char *1 字节1short *2 字节2int *4 字节4double *8 字节8struct Node *sizeof(struct Node)sizeof(struct Node)int *p; p 1不是简单地“地址1”而是让地址跳过一个 int 的长度也就是4字节。很多人觉得指针的加减运算神出鬼没根子全在这个“步长”上。写代码时如果发现指针加1后地址没有按预期跳多半是类型写错了或者用%p打印地址时没有转成void*。1.3 打印地址的正确姿势打印指针值要用%p并且建议显式转成void *printf(addr %p\n, (void *)p);%p匹配void*虽然不转在很多编译器上也能过但严格按标准来可以避免不必要的warning。还有一个细节不要试图用printf打印“指针指向的地址的十进制值”来做所谓的“验证”除非你在做底层调试否则毫无必要还容易引入未定义行为。2. 数组与指针别再说“数组名就是指针”了2.1 数组名会退化但它不是指针变量网上有句话传播很广“数组名就是指针。”这句话害了不少人。严格说数组名在多数表达式中会“退化”成首元素的地址所以下面的代码能编译通过int a[5] {10, 20, 30, 40, 50}; int *p a; // a 在这里退化为 a[0]但数组名本身不是指针变量。它不能出现在赋值运算符左侧比如a、a p都是非法的。区分两者的最简单方式是sizeofprintf(%zu\n, sizeof(a)); // 整个数组大小例如 20 printf(%zu\n, sizeof(p)); // 指针变量大小例如 8a是一个包含5个int的数组p只是保存地址的变量。它们的区别在C语言面试里几乎必考理解这一点后面看二维数组和函数参数才不会乱。2.2 指针算术p[i] 就是 *(pi)数组下标p[i]编译器最终会理解成*(p i)。这个等价关系是理解数组和指针的钥匙。int a[5] {10, 20, 30, 40, 50}; int *p a; printf(%d\n, *p); // 10 printf(%d\n, *(p 2)); // 30 printf(%d\n, p[2]); // 30 printf(%d\n, 2[p]); // 30合法但别这么写2[p]能跑是因为2[p]会被解析为*(2 p)而*(2p)和*(p2)完全一样。知道有这回事就行实际项目里没人这么写但看见别人写了要能看懂。数组作为函数参数时形参写int arr[]和int *arr完全等价因为数组在传参时退化成指针了。也正因如此函数内部用sizeof(arr)拿到的是指针大小不是数组大小。这就是为什么C语言里传数组必须额外传长度int sum(int *arr, int n) { int total 0; for (int i 0; i n; i) { total *(arr i); // 等价于 arr[i] } return total; }2.3 二维数组为什么不能直接当 int** 传很多初学者刷题时写过类似代码二维数组传进函数形参写成int **arr结果编译警告或者运行崩溃。原因在内存布局。int matrix[3][4]在内存里是一段连续12个int而int**描述的是“指向指针的指针”内存布局完全不同。正确的写法有两种本质是一样的void print_matrix(int m[][4], int rows) { ... } // 或 void print_matrix(int (*m)[4], int rows) { ... }这里int (*m)[4]是“指向一个含4个int数组的指针”也就是数组指针。小括号是关键int *m[4]是“含4个int指针的数组”也就是指针数组俩完全不是一回事。我一直用一个口诀[]优先级比*高所以int *m[4]是数组用括号把*m包起来才是指针。#include stdio.h void print_matrix(int (*m)[4], int rows) { for (int i 0; i rows; i) { for (int j 0; j 4; j) { printf(%d , m[i][j]); } putchar(\n); } } int main(void) { int matrix[3][4] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; print_matrix(matrix, 3); return 0; }print_matrix(matrix, 3)调用时matrix退化成指向第一行的指针也就是int (*)[4]。记住这个“退化”方向就不会再疑惑为什么形参写成int**不对了。3. 字符串和指针搞清楚可改和不可改3.1 字符数组和字符指针一个想改就崩一个碰巧能跑C语言里没有独立的字符串类型字符串是用字符数组或字符指针表示的。下面两行长得像实际天差地别char s1[] hello; // 栈上分配6字节内容从常量区拷贝过来可修改 char *s2 hello; // s2保存字符串字面量的首地址内容在只读段s1[0] H安全改的是自己栈上的数组。s2[0] H是未定义行为在Linux上多半直接段错误在Windows上可能“碰巧能跑”然后留给你一个后半夜才能查出来的bug。为什么字符串字面量hello在程序加载时被放在只读区域任何写入都会触发保护机制。我的习惯是只要不打算改字符串内容统一写成const char *s hello;。这样万一后面有人试图改它编译器直接报错把运行时问题提前到编译期。想要能改的字符串就用char s[]。3.2 字符串逆序双指针移动的思路字符串逆序是很多练习平台比如PTA的经典题。用指针写比用下标写更能展示“指针操作内存”的含义#include stdio.h #include string.h void reverse(char *s) { char *left s; char *right s strlen(s) - 1; while (left right) { char tmp *left; *left *right; *right tmp; left; right--; } } int main(void) { char str[] hello; reverse(str); printf(%s\n, str); // olleh return 0; }left从头往后right从尾往前两者都通过指针运算移动。注意调用strlen(s)的前提是s指向以\0结尾的字符串如果传进去的是不可写内存还是会出问题所以调用reverse前要保证参数是char[]这类可写数组。3.3 指针数组存放字符串排序只需要换指针处理多个字符串时有人会下意识用char words[3][16]这种二维数组。好处是内存连续坏处是浪费空间、长度写死排序时交换两个字符串要strcpy成本高。更常见也更推荐的是指针数组#include stdio.h #include string.h int main(void) { char *words[] {pear, apple, orange}; int n sizeof(words) / sizeof(words[0]); for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (strcmp(words[j], words[j 1]) 0) { char *tmp words[j]; words[j] words[j 1]; words[j 1] tmp; } } } for (int i 0; i n; i) { printf(%s\n, words[i]); // apple orange pear } return 0; }words是一个数组里面每个元素是char *。排序时交换的是“指针指向的地址”字符串本身在原地没动所以效率很高。但如果想用scanf(%s, words[i])往里面读字符串问题就来了——指针数组里的指针还没有指向任何可写的内存必须先malloc或者改成二维数组。这算是我见过最频繁的崩溃原因之一。4. 函数、参数和函数指针指针不只是用来传地址4.1 值传递为什么swap必须传指针C语言函数传参一律是“拷贝”。写一个交换函数如果不传指针等于白写void swap_wrong(int a, int b) { int tmp a; a b; b tmp; } void swap_right(int *a, int *b) { int tmp *a; *a *b; *b tmp; }swap_wrong里交换的是形参的副本调用结束后什么都没改变。swap_right虽然也是值传递但它拷贝的是两个地址。通过地址能定位到调用者的变量于是交换成功。理解这一步的关键是指针本身也是值只不过这个值是一个地址。要修改一个int传int *要修改一个int *就得传int **。这个推导关系到二级指针时特别有用。4.2 函数指针把函数当成一种数据函数不是变量但函数有地址也就是入口地址。函数名在表达式中也会退化成函数指针。所以可以写#include stdio.h int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; } int main(void) { int (*op)(int, int) add; printf(add: %d\n, op(3, 4)); // 7 op sub; printf(sub: %d\n, op(3, 4)); // -1 return 0; }声明int (*op)(int, int)的括号一定不能丢。int *op(int, int)会被解析成“返回int*的函数”int (*op)(int, int)才是“指向函数的指针”。函数指针最常见的用途是回调标准库qsort最后那个参数就是比较函数指针。我曾经写过一个基于qsort的字符串排序#include stdlib.h #include string.h int cmp_str(const void *a, const void *b) { return strcmp(*(const char **)a, *(const char **)b); } // 调用 qsort(words, n, sizeof(char *), cmp_str);qsort不知道你排的是int还是char *它只拿到元素的起始地址和大小所以比较函数的参数是const void *。在cmp_str里把void *转回char **再解引用得到char *也就是字符串本身。这段代码是函数指针、二级指针和指针数组的一次组合拳建议在本地跑一遍。4.3 空指针的防御NULL不是用来“撞”的是用来“查”的NULL在C语言里是“不指向任何有效内存”的特殊指针值。很多运行时报错比如网上常搜的“timer执行查询报空指针”本质上就是拿了一个NULL去解引用程序当然崩。我的防御习惯有三条malloc、fopen这类可能失败的函数返回值立刻判断是否为NULL。函数入口对形参做检查if (p NULL) return;释放内存后把指针置NULLfree(p); p NULL;为什么释放后要置NULL因为free只是把内存还给了堆管理器p里保存的地址还在。这个地址所指的内存可能马上被分配给别人再解引用就是读或写未知数据。置NULL之后解引用会立刻崩溃反而比悄悄出错好排查得多。5. 结构体指针与二级指针链表和“改指针”的真相5.1 结构体指针使用 - 的底层逻辑p-data其实就是(*p).data的简写。先解引用拿到结构体再取成员。箭头让代码更直观但心里要清楚它在做什么。结构体在C语言里的地位很高因为它是组织复合数据的基础。链表节点就是结构体指针最典型的应用#include stdio.h #include stdlib.h typedef struct Node { int data; struct Node *next; } Node; Node *create_node(int data) { Node *node (Node *)malloc(sizeof(Node)); if (node NULL) { return NULL; } node-data data; node-next NULL; return node; } int main(void) { Node *head create_node(1); head-next create_node(2); printf(%d - %d\n, head-data, head-next-data); return 0; }结构体可能很大函数传结构体指针比传整个结构体拷贝更高效。链表、二叉树这类动态数据结构更是离不开指针next字段本身就是一个Node *。写链表的时候新手最容易问的问题就是“为什么节点里要放一个指向自己类型的指针”因为这样节点才能串起来没有指针就没有链条。5.2 二级指针只有需要修改指针本身时才用一级指针用于修改普通变量的值二级指针用于修改一级指针的值。当函数需要让调用者的指针变量指向一块新内存时必须传head#include stdio.h #include stdlib.h typedef struct Node { int data; struct Node *next; } Node; void init_list(Node **head) { *head (Node *)malloc(sizeof(Node)); if (*head NULL) { return; } (*head)-data 0; (*head)-next NULL; } int main(void) { Node *head NULL; init_list(head); if (head ! NULL) { printf(head-data %d\n, head-data); } free(head); return 0; }如果函数签名写成void init_list(Node *head)函数内部对head赋值改的只是形参副本调用结束后main里的head依然是NULL。这一点和 swap 是同一个道理要改int用int *要改int *就用int **。链表的头插法和删除头节点都需要这种能力因为头指针本身会变。5.3 指针赋值分清是改指向还是改值学习指针时有一个特别基础、特别容易混的操作int a 10, b 20; int *p a; p b; // 改指向p 现在指向 b *p 99; // 改值b 变成 99a 不受影响判断方法很简单看等号左边是p还是*p。p ...是在改“门牌号”让指针指向别处*p ...是在改“房间里的东西”。我反复强调这个是因为发现很多段错误其实是把这两行搞反了尤其是传递参数时。另一个经典错误是“未初始化指针直接解引用”int *p; *p 10; // p 没初始化里面是随机地址往随机地址写数据大概率崩溃正确的做法是让指针先指向某个合法变量或者用malloc分配内存或者直接置为NULL再判断。三者选其一具体看场景。6. 动态内存、文件指针与指针排错6.1 malloc、realloc 和 free 的正确配合谈到指针不可能绕开动态内存。malloc返回void *它是一块“大小已知但类型未知”的内存需要转换成具体类型的指针。分配的字节数建议用sizeof计算不要手写数字。free只释放指针指向的那块内存不会把指针变量清掉所以释放后要习惯性置NULL。还有一个更隐蔽的坑是realloc#include stdio.h #include stdlib.h int main(void) { int *p (int *)malloc(10 * sizeof(int)); if (p NULL) { return 1; } int *tmp (int *)realloc(p, 20 * sizeof(int)); if (tmp NULL) { // 失败p 仍然指向原有内存仍然有效 perror(realloc); } else { p tmp; } free(p); return 0; }不要写p realloc(p, 20 * sizeof(int));。如果realloc失败它会返回NULL但原来那块内存还活着你把NULL覆盖给p原来的地址就丢了既造成泄漏后面再想释放也无法释放。动态内存泄漏在每个malloc都必须对应一个free。长期运行的服务如果泄漏内存会逐渐上涨直到被系统杀掉。我在本地排查泄漏时常用valgrind后来也直接用编译器的 AddressSanitizer效果很好。6.2 文件指针FILE* 的读写套路C语言里文件操作的FILE *也是指针。fopen返回一个文件指针失败时返回NULL。这跟malloc很像第一步永远是判空#include stdio.h int main(void) { FILE *fp fopen(scores.txt, r); if (fp NULL) { perror(fopen); return 1; } int score; while (fscanf(fp, %d, score) 1) { printf(score %d\n, score); } fclose(fp); return 0; }这里有个细节循环条件用的是fscanf(...) 1不是!feof(fp)。feof要等到读操作已经触底之后才会返回真用它做循环条件经常会多处理一次这是C语言文件读写里非常经典的低级错误。写入用fprintf(fp, score %d\n, score);模式改为w。文件操作结束后记得fclose并且习惯性把fp置NULL逻辑和动态内存的悬空指针一模一样。6.3 指针Bug排查三板斧指针出问题时我最常干的三件事按顺序来编译时开警告。gcc -Wall -Wextra -g能把很多未初始化、类型不匹配的问题直接亮出来。运行时开 AddressSanitizer。gcc -fsanitizeaddress -g越界读写、use-after-free、栈溢出这类问题会直接报出文件和行号。小范围加打印。printf(p%p *p%d\n, (void *)p, *p);看指针指向哪里、解引用得到什么往往三五分钟就能定位。还有一个多年积累的习惯在代码里明确注释每个指针的“所有权”。谁malloc谁负责free函数是“借”指针来用还是“接管”这块内存。多人协作时很多Bug都源于“都以为对方会释放”。指针不是玄学它只是地址真正坑人的是“地址指向哪里、生命周期归谁管”这两件事没想清楚。把这套思路内化之后你再看链表、二叉树、文件操作和回调会发现它们都是同一套底层逻辑在不同场景的变体。