拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C语言指针从入门到实战:内存地址、二级指针与避坑指南

C语言指针从入门到实战:内存地址、二级指针与避坑指南

很多学C语言的朋友,学到数组还能硬着头皮写,一碰到指针就开始打退堂鼓。指针到底是什么?为什么函数里传了指针就能改外面的变量?为什么int **p一出现就头晕?这篇文章就是把这堂课彻底补上。指针是C语言绕不开的核心概念,也是绝大多数新手的分水岭:理解了它,C语言就算真正入门了;没理解,后面的链表、树、进程地址空间、操作系统课程都会像听天书。我会从内存地址讲起,把指针变量、指针数组、数组指针、函数指针、指针的指针、双指针这些高频考点串成一条线,每个知识点配上完整可运行的代码和踩坑记录,适合刚学完基础语法准备啃指针的新手,也适合应付期末或机试前做系统查漏补缺。

1. 指针到底是什么:从内存地址说起

1.1 内存就是一排带编号的柜子

想象一个快递站,柜子有几千个格子,每个格子有唯一编号。你要存东西,就得把东西放进某个编号的格子,记下编号,下次凭编号去取。计算机内存就是这样的柜子,每个“格子”是1个字节(Byte),每个格子都有一个编号,这个编号就叫内存地址。

程序里的变量,本质上是内存里的一块区域。普通变量存的是“数据本身”,比如int a = 10,就是从内存里挑一块4字节的区域,把数值10写进去。而指针变量存的是另一块内存的门牌号,也就是地址。

&运算符的作用就是取地址,拿到变量所在的内存单元编号。我们常说的“变量名”,其实只对编译器有意义,CPU根本不认识a和b,它只认地址。这就是为什么指针如此重要——因为它让我们直接跟内存地址打交道。

1.2 指针变量存的是门牌号

声明一个指针变量的写法很直观:

int a = 10; int *p = &a;

读法是“p是一个指向int类型的指针”,*p就代表“p指向的那个变量的值”。定义时int *是一个整体,表示这是存放int变量地址的指针。

这里有个新手常见困惑:为什么指针变量还要分类型?因为指针不只是存地址,在解引用和指针算术时需要知道“这个地址后面多少个字节属于这个变量”。p++对于int *会跳过4个字节,对于char *只跳1个字节,所以类型起了“步长”的作用。

试跑一下:

#include <stdio.h> int main(void) { int a = 10; int *p = &a; printf("a 的地址 = %p\n", &a); printf("p 的值 = %p\n", p); // 和上面一样 printf("解引用 = %d\n", *p); // 输出 10 *p = 20; // 改的是 p 指向的那块内存 printf("a 的值 = %d\n", a); // 输出 20 return 0; }

*p = 20这一句,相当于顺着p里存的门牌号找到a的那块内存,把数值改成20。所以运行后a变成了20。这就是指针最基本的用法。

1.3 初始化与“绝对不能碰”的野指针

指针变量定义后一定要初始化,要么指向一个明确的变量,要么赋NULL。原因很简单:如果只声明不初始化,指针里的值是随机的垃圾数据,解引用它等于拿着一个随机门牌号去打开别人的柜子,轻则数据错乱,重则直接段错误崩溃。

int *p; // 错误示范:未初始化的指针,值是随机的 *p = 100; // 崩溃风险极大,这叫野指针 int *q = NULL; // 正确做法:先指向空

判断指针是否有效,只要解引用之前检查if (p != NULL)就可以避免大部分灾难。这也是为什么之后谈到的所有正经代码,都会在指针被free之后立刻置为NULL,防止继续使用已经释放的内存。

注意:*在声明和表达式里的含义不同。声明时int *p里的*是类型说明符的一部分;表达式中*p是解引用运算符。两者别混。

2. 指针与数组、字符串、结构体

2.1 数组名就是退化的指针

C语言里,数组名和指针的关系非常紧密。对一维数组int arr[5]来说,arr本质上是一个指向数组首元素的指针常量,它等价于&arr[0]。因此这两个写法完全等价:

  • arr[i]等价于*(arr + i)
  • &arr[i]等价于arr + i

也就是说,数组访问本质上是“指针算术 + 解引用”。arr + i并不是简单地把地址加i,而是加上i * sizeof(int)字节,编译器会按指针类型自动换算步长。

有个高频考点是sizeof的差异:对数组变量执行sizeof(arr)得到整个数组占用的字节数,比如5个int就是20字节;但一旦数组作为参数传给函数,就会退化成指针,sizeof(p)在64位系统上只会得到8字节。两者差得多,很多同学在计算数组长度时踩坑。

#include <stdio.h> void print_len(int arr[]) { printf("函数内 sizeof(arr) = %zu\n", sizeof(arr)); // 8字节,是指针 } int main(void) { int arr[5] = {1, 2, 3, 4, 5}; printf("main 中 sizeof(arr) = %zu\n", sizeof(arr)); // 20字节 print_len(arr); return 0; }

2.2 指针数组和数组指针:别把名字搞混

指针数组是一个数组,数组的每个元素都是指针。例如:

char *names[3] = {"C", "Java", "Python"};

这里names[0]指向字符串字面量"C"的首字符,sizeof(names)在64位环境下是24字节(3个指针×8字节)。指针数组常用于保存一组字符串、处理命令行参数。

数组指针是一个指针,它指向一个“完整数组”。声明语法比较反直觉:

int (*p)[5]; // p 指向“含有5个int的数组”

这里圆括号不能丢,否则int *p[5]就变成元素为int *的指针数组。数组指针通常用于指向二维数组的某一行,接收二维数组参数时会用到。

写法含义常见用途
int *p[5]指针数组:5个int *元素保存多个地址,如字符串数组
int (*p)[5]数组指针:指向含5个int的数组遍历二维数组的行
int *p指向int的指针操作一维数组、函数参数

2.3 字符串:指针和字符数组怎么选

C语言没有原生字符串类型,字符串本质上是字符数组,或者是指向字符常量区的指针。两种写法:

char s1[] = "hello"; // 字符数组,栈上分配,可修改 char *s2 = "hello"; // 指向字符串字面量,通常位于只读区,不要修改

s2[0] = 'H'这种操作在大多数平台上会直接崩溃,因为字面量是只读的;s1[0] = 'H'则合法。所以“需要修改字符串”时用数组,“只读访问、节省拷贝”时用指针。

字符串逆序是经典练习,用双指针(一头一尾)最自然:

#include <stdio.h> #include <string.h> void reverse(char *s) { int i = 0, j = strlen(s) - 1; while (i < j) { char tmp = s[i]; s[i] = s[j]; s[j] = tmp; i++; j--; } } int main(void) { char str[] = "abcdef"; reverse(str); printf("%s\n", str); // fedcba return 0; }

这个例子同时练习了“字符数组传入函数退化成指针”“下标写法与指针等价”两个知识点。

2.4 结构体指针:为什么大家都用箭头

定义结构体和指针:

struct Student { char name[20]; int age; }; struct Student stu = {"Alice", 20}; struct Student *p = &stu;

用指针访问成员,两种写法等价:

(*p).age = 21; // 先解引用再取成员 p->age = 21; // 箭头运算符,本质是 (*p).age 的语法糖

为什么要传结构体指针而不是直接传结构体?因为结构体可能很大,传值会把整个结构体复制一份到函数栈上,时间和空间开销都大;传指针只复制一个8字节的地址,函数内部通过指针修改原结构体也顺理成章。这就是C语言里“传指针效率高”的最直观场景。

3. 指针进阶:二级指针、函数指针与双指针

3.1 指针的指针:什么时候必须用

如果函数内需要修改“外部指针变量本身”,而不是修改指针指向的内容,就要用二级指针int **p。典型场景是链表插入节点、删除头节点。

举个例子,往链表头部插入一个新节点,如果只在函数里传一级指针,函数外部的头指针根本不会被改变,因为指针变量本身是按值传递的。用二级指针就能直接操作外部头指针:

#include <stdio.h> #include <stdlib.h> struct Node { int data; struct Node *next; }; /* 在链表头部插入新节点 */ void insert_head(struct Node **pp_head, int val) { struct Node *new_node = (struct Node *)malloc(sizeof(struct Node)); new_node->data = val; new_node->next = *pp_head; // 新节点指向原来的头 *pp_head = new_node; // 修改外面的头指针 } int main(void) { struct Node *head = NULL; insert_head(&head, 10); insert_head(&head, 20); printf("head->data = %d\n", head->data); // 20 return 0; }

读int **pp的方式是:pp指向一个int *,那个int *再指向一个int。类似地,三级指针就是“指向二级指针的指针”,实践不常用,但理解递归本身有助于读懂复杂声明的规律:从右向左读,每遇到一个*就加一层“指向”。

3.2 函数指针与指针函数:一字之差,天壤之别

指针函数定义:返回值是指针的函数,比如char *my_strchr(char *s, char c),它本身是一个函数,只是返回指针类型。

函数指针定义:指向函数的指针。函数在内存中也有地址,函数指针本质上存的就是这个函数的入口地址,用来做回调、状态机、菜单分发非常合适。

#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); // 声明一个函数指针 op = add; // 指向 add printf("3 + 4 = %d\n", op(3, 4)); op = sub; // 重新指向 sub printf("3 - 4 = %d\n", op(3, 4)); return 0; }

函数指针类型很长,可以用typedef简化:

typedef int (*BinaryOp)(int, int); BinaryOp op = add;

理解函数指针的关键是区分两个东西:op本身是被调用的入口,op(3, 4)就是通过入口调用函数;而int (*op)(int, int)的声明则要和“指针函数int *fun()”分开看。考试最爱考这种辨析,花10分钟对比记忆非常值。

3.3 双指针法:解决“单链表删除”的一招鲜

双指针法是链表中很常用的一招。比如删除链表中第一个值为val的节点,你用prev和cur两个指针一前一后也能做,但边界处理容易出错。改用二级指针pp指向next字段,代码会变得非常简洁:

#include <stdio.h> #include <stdlib.h> struct Node { int data; struct Node *next; }; void delete_first(struct Node **pp, int val) { while (*pp != NULL && (*pp)->data != val) { pp = &(*pp)->next; // pp 指向下一个节点的 next 字段 } if (*pp != NULL) { struct Node *to_delete = *pp; *pp = (*pp)->next; // 让前一个节点的 next 指向待删节点的 next free(to_delete); } }

这种做法的精妙之处是pp从头到尾都是“某个指针变量的地址”,删除头节点和删除中间节点的逻辑完全统一,不需要单独判断“是不是第一个”。

双指针在数组题里也常用,比如有序数组去重:

int remove_duplicates(int *nums, int n) { if (n == 0) return 0; int slow = 0; for (int fast = 1; fast < n; fast++) { if (nums[fast] != nums[slow]) { slow++; nums[slow] = nums[fast]; } } return slow + 1; }

这里的 slow 和 fast 本质是两个数组上的索引指针,快指针负责探路,慢指针负责写入结果,面试和机试中高频出现。

4. 指针实战:从两数交换到鞍点问题

4.1 为什么两数交换必须用指针

很多教程用两数交换作为指针入门的第一个例子。写一个失败的版本:

void swap_bad(int a, int b) { int tmp = a; a = b; b = tmp; }

在main里调用swap_bad(x, y)后,x和y根本没变。原因是C语言函数参数默认按值传递,a、b是实参x、y的副本,函数里交换的是副本,出了函数什么都没发生。

改用指针:

void swap(int *a, int *b) { int tmp = *a; *a = *b; *b = tmp; }

调用时传地址swap(&x, &y),函数通过地址直接操作原来那两个内存单元。理解这个例子后,对“引用传递”这个概念才能真正吃透。

顺带说一下C++的情况:C++里可以用void swap(int &a, int &b),语法上看起来不需要取地址,但编译器在底层生成的代码和指针版本几乎一样。C++的引用本质就是“受限的指针”:引用一旦绑定不能换,指针可以任意指向别处;引用不能为NULL,指针可以。初学者最好先把C指针玩熟,再学引用就不会被绕晕。

4.2 用指针实现冒泡排序

冒泡排序通常用arr[i]的写法,但如果想强化指针理解,可以全部用指针算术实现:

#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 tmp = *(arr + j); *(arr + j) = *(arr + j + 1); *(arr + j + 1) = tmp; } } } } int main(void) { int nums[] = {9, 3, 7, 1, 5}; int n = sizeof(nums) / sizeof(nums[0]); bubble_sort(nums, n); for (int i = 0; i < n; i++) { printf("%d ", nums[i]); } return 0; }

注意*(arr + j + 1)这个写法,它和arr[j + 1]完全等价。既然数组参数传进来就是指针,那么两种写法本质都在做同一件事:从首地址出发偏移j * 4字节再解引用。掌握这个等价关系对后面读复杂代码很有帮助,比如操作系统内核、开源项目里大量使用指针算术。

4.3 5×5鞍点问题的完整解法

浙大PTA、翁恺课程的作业题里,经常出现“求5×5矩阵的鞍点”。鞍点的定义是:在该行最大、同时在该列最小的元素。解题思路分两步:先找到每一行的最大值位置,再检查它是不是所在列的最小值。

#include <stdio.h> #define ROWS 5 #define COLS 5 int main(void) { int matrix[ROWS][COLS]; int found = 0; for (int i = 0; i < ROWS; i++) { for (int j = 0; j < COLS; j++) { scanf("%d", &matrix[i][j]); } } for (int i = 0; i < ROWS; i++) { int col_max = 0; for (int j = 1; j < COLS; j++) { if (matrix[i][j] > matrix[i][col_max]) { col_max = j; // 当前行最大值的列下标 } } int row_min = 0; for (int k = 1; k < ROWS; k++) { if (matrix[k][col_max] < matrix[row_min][col_max]) { row_min = k; // 当前列最小值的行下标 } } if (row_min == i) { printf("鞍点: matrix[%d][%d] = %d\n", i, col_max, matrix[i][col_max]); found = 1; } } if (!found) { printf("不存在鞍点\n"); } return 0; }

这个题目非常综合:二维数组、循环、条件判断都被串起来了。如果进一步用指针改写,可以把内层循环里的matrix[i][j]换成*(*(matrix + i) + j),虽然看起来别扭,但理解二维数组的“数组指针退化”会深入很多。调试时其实建议先用下标版本写对,再用指针版本对照,一步到位最容易把自己绕进去。

5. 指针与内存管理:堆区、栈区与那些坑

5.1 程序运行时,数据都放在哪

C语言程序运行时,内存大致分成几个区域:

  • 栈区(stack):局部变量、函数参数,函数调用结束自动释放。空间小,默认几MB,但速度快。
  • 静态区/全局区(static/global):全局变量、static变量,从程序启动到结束一直存在。
  • 堆区(heap):动态分配的内存,由程序员手动申请和释放,空间大但必须自己管理。
  • 代码区:存放机器指令,只读。

指针变量本身可能存放在栈上或作为静态变量,但指针指向的对象可能存放在任何区域。判断一个指针能不能安全使用,关键是搞清楚它指向的是哪个区域、生命周期多长。

5.2 malloc 和 free 必须配对

用malloc在堆上分配内存,用完必须用free释放,这是硬规矩。一个最容易被忽略的问题是:分配后没有检查返回值。在极低内存环境或连续分配大量内存时,malloc可能返回NULL,此时直接解引用会崩溃。

#include <stdio.h> #include <stdlib.h> int main(void) { int *arr = (int *)malloc(10 * sizeof(int)); if (arr == NULL) { printf("分配失败\n"); return 1; } for (int i = 0; i < 10; i++) { arr[i] = i * i; } free(arr); // 释放堆内存 arr = NULL; // 防止野指针 return 0; }

free(p)后,内存交由系统重新分配,但指针p里存的地址还在,如果不把p置NULL,代码里稍不留神再次解引用就会操作一块已经被别人使用的内存,这种问题极难排查。养成习惯:释放后立刻赋值NULL。

C++里提供了智能指针来替我们“记住释放”,比如std::unique_ptr<int[]> arr = std::make_unique<int[]>(10);,离开作用域自动析构释放。这是对C手动管理内存的改进,思路值得借鉴,但真正理解它,还是先扎实掌握malloc/free才好。

5.3 野指针、悬空指针、内存泄漏

这三个概念容易混淆:

问题定义后果避免方式
野指针指针未初始化,存的是随机地址解引用即崩溃声明时赋初值或置NULL
悬空指针指向的内存已被释放,但指针仍保留旧地址解引用访问无效内存free后置NULL
内存泄漏动态分配的内存从未释放,且指针丢失程序常驻内存持续增长malloc/free严格配对

内存泄漏在大型服务里尤其致命。举个例子,每次循环malloc一段内存,用完只把局部指针变量重新赋值,原来的地址就“找不到了”,没人能帮你释放,内存越用越多。排查手段一般是工具,Linux上用valgrind或AddressSanitizer,Windows上也可以用 VS 的诊断工具。

提示:不要试图依赖“程序结束操作系统会回收内存”来掩盖泄漏。长期进程、嵌入式系统、服务端程序都经不起这种挥霍。

6. 经典误区排查:C语言指针学习避坑实录

6.1 新手最容易踩的五个雷

我把帮人改代码时最常见的错误列成一张速查表:

错误写法后果修正
int *p; *p = 5;段错误先让p指向一个int变量,或malloc
return &local_var;函数返回后局部变量销毁,指针悬空改用静态变量、堆内存或传出参数
arr[i] = ...但越界访问破坏相邻内存严格检查数组长度
free(p); p[0] = ...;访问已释放内存free后置NULL并检查
p++后free(p)free的不是原地址,崩溃保留原始头指针用于free

第二行“返回局部变量地址”很典型:

int *bad_func(void) { int x = 10; return &x; // x 在函数结束后被回收,返回的地址是悬空的 }

表面上第一次调用还能得到10,一旦后面再有别的函数调用,栈上那部分被覆盖,结果就无法预料。正确做法是malloc后在堆上分配,或者通过指针参数接收值。

6.2 定位问题:GDB 和 AddressSanitizer

遇到指针崩溃,别慌。优先做三件事:

  1. printf大法:在解引用前后打印指针值和指针指向的值,定位崩溃前最后一次正常输出的位置。
  2. GDB:gdb ./a.out,运行崩溃后输入bt查看调用栈,能准确告诉你崩溃在哪一行。调试时p命令打印变量,x/4bx查看内存字节。
  3. AddressSanitizer:编译时加-fsanitize=address,运行后直接告诉你“堆越界”“栈溢出”“释放后使用”发生在哪一行。这个工具对自学阶段的纠正效果极好,强烈推荐。

举个例子,出现Segmentation fault时第一反应不是瞎改,而是打开GDB运行bt,看到栈顶函数名后思考:这个函数里哪个指针可能是空的或指向无效地址。大多数情况下问题就藏在解引用前少了NULL判断。

6.3 学习环境与练习资源怎么搭

环境建议二选一:

  • Windows上:用 VSCode + MinGW 的 gcc,扩展安装 C/C++ 插件即可。记得在头文件包含路径里确认 MinGW 安装目录,否则#include <stdio.h>都会报错找不到。
  • Linux上:Ubuntu 最省心,终端直接sudo apt install gcc build-essential gdb,写.c文件后gcc -g -o app app.c,./app运行,gdb app调试。没有图形界面需求的话,纯终端完全够用。

资料方面,翁恺在浙大开的C语言基础公开课逻辑清楚、例子好懂,适合零基础;谭浩强的教材胜在体系完整,把每章习题在PTA上刷一遍就能快速发现自己的薄弱点,比如指针和数组的等价转换、字符串处理等。学有余力再翻《C和指针》,书中对复杂声明和指针算术的解释非常经典,读完后理解会再上一个台阶。

6.4 给学习路线一点具体建议

如果目标只是“看懂指针并通过考试”,我建议按这个顺序推进:先会写int *p = &a; *p = ...;能解释清楚每一步干什么;然后做“指针遍历数组”和“字符串逆序”;再做“结构体指针作为函数参数”;最后做链表插入/删除题。完成这四步,常见的指针用法就基本覆盖了。

练综合题可以试试“C语言打字游戏”和“网吧计费管理小项目”,这类项目不复杂,但涉及结构体数组、指针传参、文件操作,属于把指针揉进真实场景的好办法。比单纯刷题有趣,完成度反而更高。

我个人的体会是:指针之所以难,不是因为规则复杂,而是因为符号*在不同位置表达不同意思,加上“内存地址”本身看不见摸不着,第一印象容易恐慌。我当时是把所有指针问题都翻译成“门牌号”来理解:一个地址是门牌号,找到门牌号再进去拿东西就是解引用,改门牌号就能换一个柜子。想通这层之后,函数指针、二级指针、双指针这些概念都是同一个模型的延伸。

最后再分享一个小技巧:无论你看到多复杂的指针声明,都从右往左读,遇到*就补一句“指向”。像int (*p)[5]读作“p是指向含5个int数组的指针”,一次读顺就不会再和int *p[5]混了。指针这关过了,C语言很多大门都会向你敞开。

返回列表