ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C语言指针完全指南:从内存地址到指针数组与函数指针

2026/10/1 4:18:40 拓冰建站 浏览量
C语言指针完全指南:从内存地址到指针数组与函数指针 1. 指针的本质地址与间接访问1.1 为什么指针让初学者头大我学C语言指针那会儿身边至少有三分之一的人卡在这一章就放弃了。说实话指针这东西确实反直觉——它不是值本身而是值的地址。但一旦你理解了内存模型指针反而是C语言里最漂亮的设计。很多人学指针最大的障碍是试图用变量的直觉去理解它。比如看到int *p第一反应是p是一个int类型然后死活不明白为什么p不能直接存数字。其实你应该把指针理解为一个装着地址的盒子它指向的不是数据而是数据住的那个房间号。这个思维一转换后面所有指针操作都会顺很多。我当年学指针踩的第一个坑就是把这个盒子和房间混为一谈。教材上写*p 5我就以为是p等于5完全没意识到这里的星号是按地址找房间的意思。所以下面我先从底层的内存模型开始讲把房间和房间号的关系彻底掰扯清楚。1.2 内存模型变量、地址与值的关系C语言程序运行时操作系统会为进程分配一块虚拟内存空间程序里定义的每个变量都会占据这块空间中的一个区域。每个字节都有一个编号这个编号就是地址address。你可以把内存想象成一栋巨大的宿舍楼每个字节是一个床位地址就是床位号。定义一个变量int a 3;假设它在内存里的地址是0x7ffee2a1bcd0那么这个变量的本质就是从0x7ffee2a1bcd0开始的4个字节int通常是4字节里存着数值3。编译器和CPU不关心变量名只关心地址。变量名a不过是编译器帮你维护的一个语义化标签。指针就是用来保存这类地址的变量。int *p a;做的事情是取出a的地址这个地址是0x7ffee2a1bcd0把它存到另一个地方去这个地方就是p。所以p是一个类型为int *的变量它的值是0x7ffee2a1bcd0。理解这层关系之后和*这两个运算符就不难了a取地址运算返回a的地址。*p解引用运算根据p里存的地址去找到那个地址上的变量。如果p里存的是a的地址那*p就是a本身。有点绕的是*在声明和表达式中含义不一样。int *p里的*是类型的一部分表示指向int的指针而*p 10里的*是运算符表示访问p指向的内存单元。很多初学者在这里被绕晕就是因为同一个符号在不同上下文里干了不同的活。1.3 用生活类比理解指针如果你觉得上面还是抽象可以想一个快递柜的例子。假设快递柜有100个格子你要存放一个包裹数据工作人员会给你一张取件码地址。你手里捏着这张取件码就相当于持有指针通过取件码去柜子里拿出包裹就相当于解引用。这个时候如果你把取件码抄给朋友指针赋值朋友也能通过这个码取出同一个包裹。如果取件码被改成了另一个格子的号码指针指向变化你再取就只能拿到那个格子里的东西。关键是取件码本身也有地方存——可能是你手机上的一条短信也可能写在纸上你不关心它在哪你关心的只是码指向的那个柜子。这其实就是C语言里指针是值传递和指针间接访问的生活原型。很多人在学指针时纠结指针到底是什么我会说别把它想得神秘它就是一张写着地址的纸条。2. 指针基础操作声明、赋值、解引用2.1 声明指针与取地址代码是最直接的说明。下面这个例子覆盖了指针最基础的操作#include stdio.h int main() { int a 100; int b 200; int *p a; // p 指向 a printf(a 的值%d\n, a); printf(a 的地址%p\n, a); printf(p 的值存的地址%p\n, p); printf(p 指向的变量*p%d\n, *p); *p 150; // 通过 p 修改 a printf(修改后 a 的值%d\n, a); // 输出 150 p b; // 让 p 改指向 b *p 250; printf(b 的值%d\n, b); // 输出 250 printf(a 的值%d\n, a); // 仍然是 150 return 0; }注意几个关键点。第一p b是把b的地址赋给p让指针换了一个对象而不是把b的值赋给p。前者是让指针指向另一个变量后者是把一个整数当成地址塞给指针编译可能不报错运行绝对出问题。第二*p 250完全没有碰a所以a保持150。这个例子说明了指针最重要的特性它可以间接访问和修改它指向的对象。很多教材喜欢用指针就是地址来概括我认为不严谨。指针是一个变量它有类型、有地址指针本身也有地址、有值。它的值刚好是一个地址罢了。2.2 指针的类型与步长指针1加了几个字节这是我见过初学者最容易忽略的问题。明明都是指针int *p执行p 1和char *q执行q 1在内存中跳过的字节数是不同的。int arr[] {10, 20, 30, 40}; int *p arr; char *q (char *)arr; printf(p %p\n, p); printf(p1 %p\n, p 1); // 相差 4 字节int 占4字节 printf(q %p\n, q); printf(q1 %p\n, q 1); // 相差 1 字节char 占1字节// 某个实际运行结果地址值会变但差值固定 p 0x7ffee2a1bcd0 p1 0x7ffee2a1bcd4 q 0x7ffee2a1bcd0 q1 0x7ffee2a1bcd1指针加减整数时实际移动的字节数是sizeof(指针指向的类型) * n。这就是指针的步长。int *每步4个字节char *每步1个字节double *每步8个字节。为什么要这样设计因为指针算术是为了支持数组遍历。如果p指向arr[0]p 1就必须指向arr[1]而不是指向arr[0]中间某个字节。编译器利用类型信息来计算偏移量所以指针必须带类型。这也是为什么void *不能直接做算术——它不知道步长是多少无法决定移动的字节数。2.3 空指针、野指针与悬空指针指针有三个最容易出问题的状态我建议从一开始就建立清晰的区分空指针NULL值为0的指针表示没有指向任何对象。解引用空指针几乎都会导致段错误Segmentation Fault但至少它是明确标记的无效可以提前判断。int *p NULL; if (p ! NULL) { *p 10; // 不会执行到这里 }野指针Uninitialized Pointer定义了但没赋初值的指针。它里面的值是个垃圾地址你根本不知道它会指向内存的哪里。解引用它比空指针更危险因为问题更随机有时候碰巧不崩有时候在极难排查的时候给你来一下。悬空指针Dangling Pointer指向的变量生命周期已结束。比如int *p; { int temp 99; p temp; } // 出了大括号temp 已销毁p 指向的内存已释放这三种情况建议养成的习惯是声明指针时立即初始化没有对象就置NULL使用前先判空指向的变量销毁后把指针也置空。3. 指针与数组最亲密的伙伴3.1 数组名与指针的关系数组名在大多数表达式中会退化decay为指向第一个元素的指针。也就是说int arr[5]中arr在传给函数或参与运算时会被当成int *值是arr[0]。这解释了为什么arr[i]和*(arr i)完全等价。编译器在编译阶段就把arr[i]翻译成了*(arr i)。也因此1[arr]这种怪写法能成立——它等价于*(1 arr)。我以前在代码评审里见过有人写2[arr]语法没错但可读性为零建议不要这么写。但有一个重要的地方数组名不等于指针变量。arr没有自己的存储空间它是一个编译期符号不能像指针那样做arr p或arr。而指针变量有自己的地址可以重新赋值。下面这段代码是合法的int arr[5] {1, 2, 3, 4, 5}; int *p arr; p; // OKp 现在指向 arr[1]但是int arr[5]; arr; // 编译错误数组名不是可修改的左值在我看来把数组名是常量指针作为理解起点可以但别说得太绝对。准确的表述应该是数组名在表达式中退化为指向首元素的指针但它本身不是指针变量。3.2 用指针遍历数组的实操遍历数组有两种经典写法。下标法和指针法#include stdio.h int main() { int arr[5] {11, 22, 33, 44, 55}; // 下标法 for (int i 0; i 5; i) { printf(%d , arr[i]); } printf(\n); // 指针法 int *p; for (p arr; p arr 5; p) { printf(%d , *p); } printf(\n); return 0; }两种写法结果一样但指针版本在循环体里少做一次下标换算arr[i]在底层就是*(arr i)编译器需要把i乘以步长再加基址而指针增量则是在地址上直接累加性能略好。现代编译器开启优化后差距通常可以忽略但理解指针遍历对理清内存布局帮助很大。注意p arr 5这个判断条件。arr 5指向的是数组最后一个元素的后面一个位置这个地址是合法的允许计算和比较但它不指向任何实际元素。只要你不解引用它就没问题。这是C语言特有的尾后指针past-the-end概念标准库里的迭代器也沿用了这套逻辑。3.3 二维数组与指针数组最容易混淆的组合二维数组与指针的关系是很多人的死穴。记住一个原则二维数组名退化后指向的是它的第一行一个一维数组不是第一个元素。int matrix[2][3] {{1, 2, 3}, {4, 5, 6}};matrix类型是int (*)[3]指向第一行这个长度为3的数组。matrix[0]类型是int *指向第一行的第一个元素。matrix[0][0]同样是指向第一个元素的指针但它没有行的概念。用代码体会一下#include stdio.h int main() { int matrix[2][3] {{1, 2, 3}, {4, 5, 6}}; printf(matrix %p\n, matrix); printf(matrix1 %p\n, matrix 1); // 跳12字节 3个int printf(matrix[0] %p\n, matrix[0]); printf(matrix[0]1 %p\n, matrix[0] 1); // 跳4字节 return 0; }matrix 1跳的是一行3个int共12字节matrix[0] 1跳的是一个元素4字节。这是指向数组的指针与指向元素的指针之间的本质差异。在C语言里int (*p)[3]是指向包含3个int的数组的指针而int *p[3]是一个包含3个int指针的数组。前者是数组指针后者是指针数组。很多书在这两个概念上篇幅很短但实际项目里到处都在用比如const char *months[] {Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec};这里months是指针数组每个元素是一个const char *指向一个字符串字面量。遍历的时候可以months[i]拿到第i个月的字符串。用这种方式存一批字符串比用二维字符数组char months[12][10]更灵活——字符串本身长度不受限制内存也不浪费。3.4 结构体指针与访问成员结构体指针在项目里太常用了我单独拿出来说。定义结构体和指针后访问成员有两种方式用户很容易写混#include stdio.h #include string.h typedef struct { char name[32]; int age; float score; } Student; int main() { Student stu; Student *p stu; // 通过指针赋值 strcpy(p-name, 张三); // 等价于 (*p).name p-age 20; p-score 92.5; // 通过指针读取 printf(姓名%s\n, p-name); printf(年龄%d\n, p-age); printf(成绩%.1f\n, p-score); return 0; }p-name是(*p).name的语法糖。因为.的优先级高于*所以如果你写*p.name编译器会先解析p.name而p是指针没有.name这个成员直接编译报错。括号必须加上。这个细节值得记住我见过很多人在结构体指针这里编译报错其实就差一对括号。另外如果结构体里有数组成员你要拷贝数据不能直接p-name 李四因为数组名不是可修改的左值。必须用strcpy(p-name, 李四)。这也是初学者容易踩的坑。4. 指针与函数传值、传地址与函数指针4.1 为什么两数交换必须用指针这是C语言教材里最经典的例子也是面试高频题。直接看代码#include stdio.h void swap_wrong(int a, int b) { int temp a; a b; b temp; } void swap_right(int *a, int *b) { int temp *a; *a *b; *b temp; } int main() { int x 10, y 20; swap_wrong(x, y); printf(swap_wrong 后x%d, y%d\n, x, y); // 10, 20 没变 swap_right(x, y); printf(swap_right 后x%d, y%d\n, x, y); // 20, 10 交换成功 return 0; }swap_wrong没用的原因是C语言的函数参数传递方式是值传递。调用swap_wrong(x, y)时实参x和y的值10和20被拷贝到形参a和b里函数内部交换的是这两个拷贝实参完全不受影响。而swap_right(x, y)传递的是x和y的地址。形参a、b变成了指针变量它们分别保存了x和y的地址。函数内部通过*a和*b直接访问实参的内存交换自然就生效了。划重点指针传参的本质也是值传递。只不过这次拷贝的值是地址不是数据。地址本身也是值所以如果你在函数内部对形参指针重新赋值比如a z并不会影响到外边的实参指针。如果要改变实参指针本身就得传入指向指针的指针多级指针。这个逻辑不看透后面学链表的时候也很容易卡住。4.2 函数指针把函数当成数据传既然指针可以存储地址而函数也有地址函数的机器码在内存中有固定位置那就有了函数指针。函数指针的声明很容易吓到人但拆开看就很简单int (*func)(int, int)读作func是一个指针它指向一个函数这个函数接收两个int参数并返回int。#include stdio.h int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; } int main() { int (*fp)(int, int); // 声明函数指针 fp add; printf(add(3, 5) %d\n, fp(3, 5)); fp sub; printf(sub(8, 2) %d\n, fp(8, 2)); return 0; }fp add;这行函数名add也像数组名一样会退化成地址。调用时可以直接用fp(3, 5)也可以用(*fp)(3, 5)两种写法都合法。函数指针最大的用处是回调机制。比如写一个排序函数想支持不同的比较逻辑就可以把比较函数作为参数传进去。C标准库的qsort就是这么设计的#include stdio.h #include stdlib.h int cmp_int(const void *a, const void *b) { // 先转成 int 指针再解引用取值比较 return *(int *)a - *(int *)b; } int main() { int arr[] {5, 2, 9, 1, 7}; int n sizeof(arr) / sizeof(arr[0]); qsort(arr, n, sizeof(int), cmp_int); for (int i 0; i n; i) { printf(%d , arr[i]); // 1 2 5 7 9 } printf(\n); return 0; }qsort的第四个参数就是函数指针int (*compar)(const void *, const void *)。你自己写比较逻辑排序框架替你执行这就是回调。理解了这种模式以后看嵌入式里的中断注册、GUI里的回调事件、消息队列处理都会豁然开朗。4.3 函数指针与指针函数别混淆顺便把一对高频概念放在一起说清楚函数指针pointer to function和指针函数function returning pointer。int *func()这是一个指针函数func是一个函数它的返回值类型是int *。int (*func)()这是一个函数指针func是一个指针指向一个返回int的函数。括号不是随便加的。*的优先级低于()如果不加括号int *func()里的[]或()先把func绑定成函数/数组*就变成修饰返回值。加上括号后(*func)先被解析成指针后面的()才表示指向的函数。记住一个速记方法看变量名先和谁结合。int *func()func先和()结合所以是函数int (*func)()func先和*结合所以是指针。下面这个例子是我写回调时最常用的模式#include stdio.h int apply(int (*operation)(int, int), int a, int b) { return operation(a, b); } int multiply(int x, int y) { return x * y; } int main() { printf(%d\n, apply(multiply, 6, 7)); // 42 return 0; }4.4 const 与指针的组合const与指针组合永远是考试和面试的保留项目。实际上只有两种情况定义很清楚const int *p; // 指针指向的内容不可变不能通过 p 修改它指向的对象 int *const p; // 指针本身不可变不能修改 p 指向别的对象读法有技巧从右往左读。const int *pp是一个指针指向const int所以p指向的内容是常量。int *const pp是const指针指向int所以p本身是常量。如果两个都用const int *const p那就是指向常量整型的常指针指针本身和它指向的内容都不能改。实际开发中函数形参里写const char *str的意思是我不会通过这个指针修改字符串内容这是对调用方的承诺。比如实现一个计算字符串长度的函数#include stdio.h size_t my_strlen(const char *str) { size_t count 0; while (*str ! \0) { count; str; // 指针本身可以变但解引用的对象被 const 保护 } return count; }const char *str只约束不能用这个指针修改内容不约束这个指针能不能移动。所以str是合法的。理解了这个你就能看懂很多库函数的接口声明了。5. 常见问题与调试技巧实录5.1 段错误Segmentation Fault的排查思路段错误是所有C程序员都躲不开的噩梦。绝大多数段错误根源都可以归结为两点解引用了无效地址或者越界访问了受保护的内存区域。我总结了一套排查顺序先看编译器的警告。gcc -Wall -Wextra开起来很多指针问题编译器已经告诉你了只是你没当回事。用printf定位。在程序关键位置打印当前执行到这里了二分法确定崩溃位置。检查所有指针解引用前是否判空。检查数组下标是否越界。C语言不检查越界访问arr[-1]不会报编译错运行时就可能毁掉其他变量的内存。检查字符串操作。strcpy、sprintf这类不安全函数特别容易溢出缓冲区改成strncpy、snprintf并确认长度。用工具辅助。Linux下我一般三步走gcc -g编译、gdb看堆栈、valgrind找内存泄漏和非法访问。下面是一个典型的崩溃场景再现#include stdio.h #include stdlib.h int main() { int *p (int *)malloc(sizeof(int)); *p 100; free(p); // 忘了置 NULL p NULL; *p 200; // 崩溃往NULL地址写数据 return 0; }free(p)后p指向的内存已经还给堆管理器这块内存可能立即被复用也可能还在原地。此时解引用它轻则读到脏数据重则段错误。正确做法是free后立刻p NULL;。5.2 字符串逆序、冒泡排序等经典练习里的指针陷阱热搜词里出现了一大堆C语言经典练习题我来挑几个高频题说说用指针解时容易踩的坑。字符串逆序常规思路是首尾交换。用指针这样写#include stdio.h #include string.h void reverse(char *str) { char *left str; char *right str strlen(str) - 1; while (left right) { char temp *left; *left *right; *right temp; left; right--; } } int main() { char s[] hello world; reverse(s); printf(%s\n, s); // dlrow olleh return 0; }陷阱在于如果你的字符串是用char *p hello world定义的它指向的是只读的字符串字面量你尝试通过p修改它就会段错误。必须用char s[]把它存到可写内存里。这是字符串字面量和字符数组的本质区别很多练习平台上的报错都源于此。冒泡排序如果用指针遍历数组注意四件事外层循环次数是n-1内层是n-1-i比较的是*p和*(p1)交换的是*p和解引用后的*(p1)不是指针本身。#include stdio.h void bubble_sort(int *arr, int n) { for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; } } } } int main() { int nums[] {9, 5, 8, 2, 6}; bubble_sort(nums, 5); for (int i 0; i 5; i) { printf(%d , nums[i]); // 2 5 6 8 9 } printf(\n); return 0; }这里arr[j]和arr[j1]本质上是*(arrj)和*(arrj1)。如果你非要用纯指针遍历写也可以但容易在边界判断上出错。我的建议是练习指针时可以故意用纯指针写一遍真正写业务逻辑时用下标法更清晰二者性能差异在绝大多数场景下可以忽略。5.3 多级指针与“指针的指针”专门说说int **p。这东西的声明看着吓人其实逻辑跟int *p完全一样只是它指向的对象是一个int *类型的变量。什么时候必须用多级指针两个高频场景函数里要修改实参指针本身以及动态分配二维数组结构。场景一#include stdio.h #include stdlib.h void alloc_memory(int **p) { *p (int *)malloc(sizeof(int)); **p 42; } int main() { int *ptr NULL; alloc_memory(ptr); // 传入指针的地址 printf(%d\n, *ptr); // 42 free(ptr); return 0; }这里如果只传ptr函数内p malloc(...)只是修改了形参副本外边的ptr仍然是NULL。必须传ptr函数内*p malloc(...)才真正改到外边的ptr。这个原理和第4.1节里两数交换完全一致就是直接改值不行得传地址的层层嵌套。多级指针还有一个用途是维护指针数组的动态扩容。比如按行读文件、动态记录一组字符串时常见做法是char **lines先按行数分配指针数组再给每个元素分配字符串空间。这时候lines[i]是一个char *lines本身是char **。5.4 调试工具gdb 与内存检测调试指针问题printf大法可以解决一半另一半推荐用工具。我常用的是gdb和valgrind。假设代码文件是pointer_bug.c编译时加调试信息gcc -g -Wall -Wextra -o pointer_bug pointer_bug.c运行崩溃后用gdb定位gdb ./pointer_bug在(gdb)提示符下先输入run复现崩溃再输入btbacktrace查看调用栈。如果崩溃在某个指针解引用处用print p看看指针的值用print *p试试解引用基本就能确定是空指针还是越界。valgrind 更擅长找内存泄漏和非法内存访问valgrind --leak-checkfull ./pointer_bug它会明确告诉你哪一行有Invalid read/write、哪一块内存在哪里分配却没有释放。我第一次用valgrind排查一个隐蔽的内存泄漏时五分钟就定位到了问题而之前用printf猜了两个小时。学习阶段就养成用工具的习惯效率完全不同。6. 学习路径建议与个人经验6.1 推荐的练习顺序指针不是看会的是练会的。我用过的练习顺序从易到难单指针基础声明、赋值、解引用配合代码打印地址。指针与数组用指针遍历数组、字符串。指针与函数两数交换、自定义strcpy、strlen等。指针数组与二维数组处理一组字符串、矩阵。结构体指针与链表单链表的创建、遍历、删除节点。函数指针实现一个简单的计算器用函数指针数组分派运算。多级指针动态分配二维数组、释放内存。每一步都要求自己写出完整可运行的程序而不是只看书上的例子。书上的代码我一般会手抄一遍并改参数测试测试的重点是故意写错、然后观察报错和运行结果。比如故意越界、故意不判空、故意忘记释放内存——这些错误实验对建立调试直觉特别有帮助。6.2 指针学习中的几个认知转折点回顾我自己学指针的过程有几个关键时刻让我茅塞顿开第一次是把指针的值是地址和地址指向的内存存着数据彻底分开的那一刻。从此*p、p、p三类表达式不再混淆。第二次是理解指针有类型这件事。类型不仅决定了步长还决定了编译器把解引用后的数据解释成什么。同一个地址用int *读出来是整数用char *读出来是字节用float *读出来是浮点数位模式。这就是C语言里数据的解释方式由指针类型决定的核心思想。第三次是接受C语言把内存管理交给程序员这个事实。指针的强大源于你可以在运行时灵活操控内存代价是你必须自己保证所有访问都是合法、及时释放的。没人替你兜底。想明白这点写代码时自然会谨慎很多。6.3 最后一个建议看懂报错是进步的开始我记得很清楚学指针那段时间编译器的错误信息几乎天天见。Segmentation fault、segmentation fault (core dumped)、Bus error每次都让人烦躁。但后来我意识到与其怕报错不如把它当成程序在告诉你这里有风险。每一次崩溃都是一次定位训练。最好做一个小实验写一个程序先合法地访问一个数组元素然后故意让指针越界访问、故意给空指针赋值、故意释放内存后再访问观察每种情况下的行为差异。这个实验做完你对系统如何保护内存为什么指针访问会有边界的体会比看十遍书都深。指针这个东西总有人把它说得神乎其神。我的体会是它没那么玄就是地址加类型它也没那么简单背后连接着内存布局、编译原理和操作系统。你不需要一开始就搞懂所有细节先从一个小程序出发跑起来打印几个地址改一改崩一崩再打印——这个循环走几遍指针就真的学会了。