资讯动态

C语言指针详解:从基础语法到双指针与调试实战

发布时间:2026/10/6 10:04:09 来源:尧图企业网站定制
如果让我选一个C语言里最容易被误解的概念我一定选指针。很多人一听“C语言指针”就慌觉得它玄、难、绕甚至有人学完整个C语言课程还不敢说自己真懂指针。实际上指针就一个意思存地址的变量。这篇文章不讲标准答案只讲我怎么理解指针以及日常写代码时怎么用不翻车。内容覆盖从最基础的声明赋值、指针与数组的关系到函数指针、二级指针、双指针处理字符串最后补上调试经验。适合刚学完C语言基础但没打通指针任督二脉的读者也适合想系统梳理一遍指针知识的同学参考。1. 先聊清楚C语言指针到底在干嘛1.1 地址是个编号指针是一张快递单内存可以想象成一排编好号的小格子每个字节有自己的编号这个编号就是地址。普通变量是把数据直接放进格子里指针变量则是在另一个格子里记录“那份数据的格子编号”。我用快递单类比指针变量就是一张快递单单子上写的是收件地址。你拿着这张单子可以找到对应的包裹。至于包裹里是整数、字符还是结构体取决于你定义指针时指定的类型。所以学指针先别背“指针就是地址”这句话而是记住地址是编号指针是装着这个编号的变量。C语言里取地址用解引用用*这两个符号就是“写单子”和“按单子找东西”的操作。#include stdio.h int main(void) { int a 5; int *p a; // 把 a 的地址交给 p 保存 printf(a 的值: %d\n, a); printf(p 保存的地址: %p\n, (void *)p); printf(通过 p 解引用得到的值: %d\n, *p); *p 100; // 通过 p 修改 a printf(修改后 a 的值: %d\n, a); return 0; }运行结果会显示p保存的地址和a的地址一致*p能拿到a的值改*p就相当于改a。这就是指针最核心的行为间接访问。1.2 为什么要绕一层来访问既然直接用变量名访问更方便C语言为什么非要搞指针我在实际写代码中总结出四个最刚需的场景第一函数需要修改调用方的变量。C语言函数传参默认是值传递形参是实参的拷贝函数内部改来改去只改到拷贝。想改原变量必须传地址进去。第二动态内存分配。malloc返回的是内存起始地址不用指针根本接不住这块内存。第三构建链表、树、图这类数据结构。节点之间靠指针互相连接没有指针就只能用数组模拟写起来又笨又死板。第四避免大结构体拷贝。一个结构体几百字节每次传参都整体拷贝很浪费传指针只拷贝一个地址开销小得多。所以指针不是C语言故意设置的障碍而是程序运行时“间接寻址”这一基本需求的直接体现。学会指针本质上就是学会用地址来操纵内存。2. 指针基础操作声明、赋值、解引用2.1 声明一个指针不需要背口诀指针声明看起来有一百种写法其实拆开就三层先看变量名是什么再看左边紧邻的*数量最后看类型。int *p; // p 是一个指针指向 int double *dp; // dp 是一个指针指向 double struct User *u; // u 是一个指针指向 struct User读的时候从变量名往外读p是一个指针这个指针指向int。多看几次这个写法比背诵“int* p 和 int *p 哪个对”有用得多。有人纠结星号靠左还是靠右其实编译效果完全一样。我个人习惯写成int *p因为读起来更符合“p 是一个指向 int 的指针”这个语义。给指针赋值最直接的就是用取已有变量的地址int a 10; int *p a;也可以用malloc申请一块新内存把地址放进去int *p (int *)malloc(sizeof(int)); if (p ! NULL) { *p 42; }这里有个新手容易忽略的点malloc返回的是void*C语言里会自动转换但为了代码清晰显式转成目标类型指针更直观。另外malloc之后必须检查是否返回NULL申请失败直接解引用会崩溃。2.2 解引用到底取到了什么*p在声明时是“定义指针的一部分”在语句里是“取出指针指向的目标”。同一个*位置不同含义完全不同这是新手最容易懵的地方。int *p a; // 这里 *p 表示声明说明 p 是指针 *p 100; // 这里 *p 表示解引用向目标写值 int b *p; // 解引用读取目标的值一旦写了*p 100之后a的值就变成了 100。因为p保存的是a的地址*p就是a本身。解引用操作可以读可以写读就是取目标值写就是改目标值。还要注意指针类型必须匹配。int *p不能直接指向double变量即使强行用(int *)doubleVar把地址塞进去按int的步长和长度去解读字节结果也是错的。这种错误不会立刻报错但它属于未定义行为跑出来的数据毫无意义。2.3 空指针和野指针新手最容易踩的两个坑初始化指针时如果还没确定指向哪里直接赋NULL。int *p NULL; if (p NULL) { printf(指针为空不能解引用\n); }对NULL解引用一定会段错误这不是坏事至少程序会立刻停下来告诉你写错了。最怕的是野指针声明后没赋值就直接用它脑子里存着一个垃圾地址解引用时可能没崩溃但会破坏一堆随机内存找错找到怀疑人生。另一个高频问题是free之后没把指针置空。free释放的是指针指向的那块内存指针变量本身还保留着那块地址。这之后它就是悬空指针随时可能被其他数据复用。我的习惯是free完立刻ptr NULL把这张过期的快递单撕掉。free(p); p NULL;这是一个成本极低但能避免大量诡异BUG的习惯。后面讲调试指针问题时会详细说悬空指针的症状。3. 指针与数组C语言里最微妙的搭档3.1 数组名为什么会“退化”成指针C语言里数组名在很多场景下会自动转换成“指向首元素的指针”这叫数组退化。比如你写int a[5]把a传给一个函数函数参数写成int arr[]或int *arr本质上是一样的。void print_array(int *arr, int n) { for (int i 0; i n; i) { printf(%d , arr[i]); } } int a[5] {1, 2, 3, 4, 5}; print_array(a, 5); // a 退化为指针传给函数但有两个场景数组名不会退化一个是sizeof(a)它算的是整个数组的字节数另一个是a它得到的是指向整个数组的指针类型是int (*)[5]和指向首元素的int *不是一回事。a 1和a 1差别很大。假设a是 5 个int一个int占 4 字节a 1跳过一个元素地址加 4 字节a 1跳过整个数组地址加 20 字节。这个区别容易忽略但面试和笔试经常故意考。3.2 指针的加减不是数学上的加减指针加减整数移动的距离由“指向类型的大小”决定。char *加 1 移动 1 字节int *加 1 移动 4 字节struct User *加 1 移动sizeof(struct User)字节。int arr[5] {10, 20, 30, 40, 50}; int *p arr; // 指向第一个元素 p; // 现在指向第二个元素 printf(%d\n, *p); // 输出 20这也是为什么数组下标能写成*(arr i)。arr[i]和*(arr i)在C语言里完全等价指针运算就是下标访问的底层机制。所以指针自增自减特别适合遍历数组。写循环时可以这样int sum 0; for (int *p arr; p arr 5; p) { sum *p; }要小心循环边界。如果写成p arr 5最后一次p已经越界解引用会访问数组尾后一格属于未定义行为。正确写法是p arr 5保证只在合法范围内解引用。3.3 指针数组和数组指针别再搞混了这两个名字长得像双胞胎含义完全不同。指针数组是一个数组里面存的是指针数组指针是一个指针指向整个数组。char *str_arr[3]; // 指针数组3 个元素每个都是 char* int (*arr_ptr)[5]; // 数组指针可以指向 int[5]拆开读str_arr先和[3]结合说明它是数组元素类型是char *arr_ptr先和*结合说明它是指针指向一个有 5 个int的数组。记忆技巧是看变量名先跟谁结合。[]优先级和()一样比*高所以char *str_arr[3]是数组优先int (*arr_ptr)[5]加了括号让*优先。实际工作中指针数组最常见的用途就是存一批字符串比如星期、月份、错误信息const char *days[] {Mon, Tue, Wed, Thu, Fri, Sat, Sun};遍历它和遍历普通数组一样。数组指针用得相对少主要用于处理二维数组的传参。二维数组名传给函数形参要写成int (*matrix)[cols]此时matrix就是数组指针。3.4 字符串和指针的纠葛C语言没有字符串类型字符串本质是char数组操作字符串靠指针。char str[] hello; // 字符数组可修改 const char *s hello; // 指向字符串字面量一般存在只读区这两种写法都能打印出hello但底层完全不同。str是一块可写的内存你可以str[0] Hs指向的是编译期生成的字符串字面量绝大多数平台上只读你写s[0] H大概率段错误。如果只是读字符串用const char *安全如果要拼接、修改用char数组并分配足够空间。这个选择属于日常踩坑频率极高的点。说到字符串处理经典面试题“字符串逆序”非常适合用指针双指针解决我放到后面第 7 部分单独写。4. 函数指针、指针函数、函数指针数组4.1 指针函数返回值是指针的函数先看清名字指针函数本质是函数特征是返回值是指针。char *get_name(void) { static char name[32]; // 填数据 return name; }这里get_name就是指针函数调用后拿到一个char *。这类函数有一个大坑不能返回局部数组的地址。因为局部变量在函数结束后就销毁了返回的指针指向一块已经不可用的内存。上面例子用了static把数组生命周期延长到整个程序运行期间这才安全。如果想返回一块新内存可以用malloc分配并约定调用方负责freechar *create_buffer(int size) { char *buf (char *)malloc(size); return buf; }这样就回到“谁申请谁释放”的问题。C语言里内存所有权不明确是很多内存泄漏和悬空指针的根源设计接口时要在注释里写清楚返回的指针需不需要释放。4.2 函数指针指向函数的变量函数指针本质是变量存的是函数的入口地址。声明写法int (*handler)(int, int);读法handler先和*结合说明是指针接下来(int, int)说明它指向的函数接收两个int最左边int说明函数返回int。赋值和调用int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; } int (*handler)(int, int) add; int result handler(1, 2); // 等价于 add(1, 2)函数指针最大的价值是把函数当作参数传递也就是回调机制。标准库里的qsort就是典型例子#include stdio.h #include stdlib.h int cmp_int(const void *a, const void *b) { int x *(const int *)a; int y *(const int *)b; return (x y) - (x y); // 返回负、0、正 } int main(void) { int arr[] {4, 1, 7, 3, 9}; qsort(arr, 5, sizeof(int), cmp_int); for (int i 0; i 5; i) { printf(%d , arr[i]); } return 0; }qsort不知道你排序的是什么类型它只负责调用你提供的比较函数。这种把策略以函数指针形式传入的设计就是C语言版的“依赖注入”。理解这一点后你会发现函数指针一点都不神秘。4.3 函数指针数组把函数当作数据来管理函数指针数组比单个函数指针更灵活。比如写一个简单的计算器可以不用一长串switch直接建一个跳转表#include stdio.h int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; } int mul(int a, int b) { return a * b; } int divide(int a, int b) { return a / b; } int main(void) { int (*ops[4])(int, int) {add, sub, mul, divide}; int a 10, b 2; for (int i 0; i 4; i) { printf(结果: %d\n, ops[i](a, b)); } return 0; }ops[i]取出的就是一个函数指针后面直接(a, b)调用。把函数地址存进数组你就把“行为”变成了“数据”可以用循环批量执行。这种技巧在实现状态机、命令分发、插件系统时非常实用。如果看到复杂声明头晕我的方法是“从里往外剥”先从变量名开始一级一级看它和谁结合。熟练之后函数指针数组和普通数组一样好读。5. 二级指针和多级指针5.1 什么时候才需要二级指针二级指针就是指向指针的指针。形式int **p。它的最大价值在于当你想在一个函数里修改指针变量本身而不是指针指向的内容时必须传这个指针变量的地址。换句话说实参是一个int *形参必须是int **。经典场景是函数内分配内存并把地址带回给调用方#include stdio.h #include stdlib.h void alloc_array(int **arr, int n) { *arr (int *)malloc(n * sizeof(int)); if (*arr NULL) { perror(malloc failed); return; } for (int i 0; i n; i) { (*arr)[i] i * i; } } int main(void) { int *arr NULL; alloc_array(arr, 5); if (arr ! NULL) { for (int i 0; i 5; i) { printf(%d , arr[i]); } printf(\n); free(arr); arr NULL; } return 0; }如果函数参数写成int *arr那函数里arr malloc(...)只修改了形参实参依然是NULL一出去就丢。用int **才能改到外部的arr本身。判断要不要二级指针有一个通用标准你想在函数里改变参数的“值”而这个值本身是一个指针那就需要指针的指针。5.2 传指针进去真的能改到实参吗这是很多人对指针的理解误区以为只要函数参数是指针就能随意修改实参。其实只对一半。传一级指针进去可以在函数里修改“指针指向的目标”但不能修改“指针变量本身”。void try_change_pointer(int *p) { int x 100; p x; // 只改了形参 } void change_target(int *p) { *p 100; // 改了实参指向的目标 }第一段代码外部指针没有任何变化第二段代码外部的变量值变成了 100。原因还是一样p是形参拷贝拷贝的是地址数值你把这个地址改成别的值外面不知道。所以“传指针能改实参”这句话要精确成“传指针能通过地址操作实参指向的内存想改指针本身还得再取一层地址。”理解这一层链表插入、删除节点时为什么经常用二级指针也就顺理成章了。你想改头结点的指针函数形参就需要接收头指针的地址。6. const与指针的三种组合6.1 三种写法分区明确const加在指针上有三种组合每种含义不同。我常看到有人考完试就忘其实记住“const修饰谁谁就不可变”就够用。写法含义const int *pp指向一个const int不能通过p修改目标但可以让p指向别处int *const pp本身是常量指针必须初始化不能指向别处但可以通过p修改目标const int *const p目标和指针都不可改两边都锁死const int *p和int const *p完全等价只是把const放到了int前面或后面读起来“const 修饰 int”所以都表示目标只读。测试自己有没有懂看*p 10;和p;哪一句会编译报错。对照上面的表逐句判断即可。6.2 我平时写传参的习惯函数的形参如果只是读取数据、不会修改目标我习惯写成const char *或const int *。这有三个好处第一接口语义清楚调用方一眼看出“这个函数保证不改我的数据” 第二编译器帮我把关在函数内部误写data[0] ...会直接报错 第三能接收const数据作为入参不强迫调用方去掉const。size_t my_strlen(const char *s) { size_t len 0; while (s[len] ! \0) { len; } return len; }写这段代码时s指向字符串函数只读不写所以const放在char前面。调用方传普通char *没问题传const char *也没问题接口更通用。但要注意一点const int *p指向常量目标时不能把它直接转成int *传给别人那是绕过类型系统给自己埋坑。如果真的确定目标本身可写可以在函数设计时就明确形参用非const指针。7. C语言双指针法用两个指针解决问题7.1 双指针到底指什么双指针不是C语言语法上的东西而是一种算法编码习惯。它用两个指针变量从不同位置出发靠移动规则完成某些需要O(n)时间的任务。常见形态有三种左右夹逼一个在开头一个在结尾逐步向中间靠拢快慢指针一个走两步一个走一步常用于链表判环、找中点滑动窗口两个指针维护一个区间右指针扩大左指针收缩。双指针解决字符串逆序、判断回文、有序数组两数之和、原地去重等问题非常顺手。核心思想是不要反复从头扫描数组而是用两个指针记录“当前位置”逐步收敛。7.2 实操案例字符串逆序直接用前面设计的reverse函数完整写一版#include stdio.h #include string.h void reverse(char *s) { if (s NULL) { return; } 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 world; reverse(str); printf(%s\n, str); return 0; }运行结果是dlrow olleh。讲一下关键细节right s strlen(s) - 1这个- 1很重要。strlen返回字符串长度比如hello world长度是 11但最后一个字符的下标是 10因为数组还有最后一个\0。不减 1 就把right指向了字符串结束符\0交换时会把\0移到开头打印结果直接变空串或者乱码。while (left right)判断的是地址大小比较。C语言里同一数组内的两个指针可以比较大小left在左侧、地址小right在右侧、地址大。两个指针相遇或者交错时停止正好完成全串逆序。交换三次操作分别做保存左值、右值覆盖左值、保存的原左值覆盖右值。不能直接*left *right; *right *left;那样会把同一个值来回复制字符串内容就丢了。这类题在PTA和面试题里出现频率很高理解left和right的移动规则之后写起这种代码就很顺手。7.3 另一个场景有序数组两数之和再看一个能体现双指针价值的小例子。给一个升序数组和目标值找两个数使其和等于目标值。#include stdio.h int main(void) { int arr[] {1, 3, 5, 7, 9}; int target 10; int left 0; int right 4; int found 0; while (left right) { int sum arr[left] arr[right]; if (sum target) { found 1; break; } else if (sum target) { left; } else { right--; } } if (found) { printf(找到: %d %d %d\n, arr[left], arr[right], target); } else { printf(未找到\n); } return 0; }如果暴力枚举两层循环是 O(n²)双指针只遍历一趟O(n) 搞定。因为数组有序每次比较后总能确定“当前和太小该变大”“当前和太大该变小”指针移动方向唯一确定。这种直觉对算法能力提升很有帮助。8. 调试指针问题段错误、越界、悬空8.1 段错误八成是解引用了一个奇怪地址段错误叫人崩溃但在我眼里它是C语言里“最好的老师”。它明确告诉你程序访问了不属于自己的内存。排查第一件事是把程序编译成带调试信息的版本然后用 gdb 跑。gcc -g -o demo demo.c gdb ./demogdb 里输入run程序崩了之后输入bt看调用栈再用print查看关键指针的值。(gdb) run Program received signal SIGSEGV, Segmentation fault. (gdb) bt #0 main () at demo.c:10 (gdb) print p $1 (int *) 0x0看到0x0基本可以确定对空指针解引用了。如果指针值是一串乱码比如0x7ffc12345大概率是野指针或越界访问。还可以用x/4xw p查看指针指向的内存区内容确认那块区域是不是合理数据。实战经验段错误发生后别急着改代码。先问自己三个问题指针初始化了吗指针指向的内存还在吗数组下标有没有越界九成段错误都在这三件事里。8.2 常见问题速查表现象可能原因建议处理解引用空指针崩溃指针是 NULLmalloc 失败没检查每次 malloc 后判空函数入口对指针判空输出结果随机乱码指针未初始化就使用声明时初始化不确定就赋 NULLfree 之后程序崩溃悬空指针释放后再次访问free 后立刻置 NULL先释放再访问最后置空数组越界但不报错非法访问了旁边内存可能改坏其他变量越界检查用printf打印关键下标验证字符串逆序结果不对指针指向了\0或边界算错重点检查strlen - 1和循环判断条件传参后外层指针没被修改需要二级指针但传了一级确认是否要修改指针本身是则ptr传入函数返回局部数组后乱码返回了局部数组地址改用static或调用方传入目标缓冲区这些坑我基本都踩过。尤其是释放后没有置空我当时为了查一个偶发崩溃找了两天最后用 gdb 一步步跑才发现是悬空指针在作怪。从此严格执行“free 后置 NULL”的习惯。8.3 善用工具valgrind 和 ASangdb 适合查崩溃点内存泄漏和非法访问可以用 Valgrind 查。valgrind --leak-checkfull ./demo它会把所有未释放的内存、越界读写、释放后使用都列出来并标出是程序里哪一行导致的。比如输出Invalid read of size 4 at 0x...: main (demo.c:20) 20 bytes in 1 blocks are definitely lost in loss record看到Invalid read就是越界或非法访问看到definitely lost就是内存泄漏。如果你用 GCC还可以直接开地址消毒器gcc -g -fsanitizeaddress -o demo demo.c ./demoASan 能精准报告堆越界、栈越界、释放后使用等内存错误而且不用额外工具用起来比 Valgrind 快很多。很多大学老师不教这些但工作一到内存相关项目这些工具就是救命稻草。9. 写在最后我对指针的个人态度指针这东西说穿了就是地址和间接访问。我见过太多人可以背出“指针就是变量的地址”这句话但一写链表就漏洞百出一用二级指针就发懵。缺的不是概念背诵而是动手画图、逐行跟踪。我个人的学习方法是任何指针相关代码都在纸上画出盒子图和箭头。一个变量画一个格子指针画成带箭头的格子解引用就是顺着箭头走到目标格子。画图的过程中很多“看不懂的代码”一下子就有了空间感。还有人问我指针到底有什么实用价值引用一个我在实际项目中的感受无论是操作系统里的内存管理还是嵌入式程序里操作寄存器地址又或者是C语言标准库的很多接口设计底层都站着一堆指针。指针不是考试题它是C语言面对真实世界的接口。如果你正被指针折磨我建议从今天开始别把它当作玄学。它就是一张写着地址的纸条。拿着纸条去取快递、改快递不够用时再学写着纸条地址的纸条。一步步来这一关谁都能过。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑