news 2026/11/4 9:19:06

C语言指针入门:给内存换名字的艺术与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C语言指针入门:给内存换名字的艺术与实战

这一讲是《C语言对话》系列的第五篇,标题我琢磨了好一阵子,最后是从一句大家耳熟的西方谚语里找到的灵感:A rose by any other name would smell as sweet——玫瑰就算换个名字,开出来照样香。乍一听和C语言没什么关系,但它恰好点破了指针这个概念最核心的特征:同一块数据,完全可以通过不同的名字去访问。变量名是一种叫法,指针变量是另一种叫法,函数形参又是第三种叫法,它们背后指向的其实是同一片内存。本讲适合刚学完变量、运算符、分支循环和函数,正准备跨进指针门槛的读者。我会沿用对话的方式,把一个知识点掰开揉碎,附上可以直接运行的代码,顺便把初学者最容易掉的坑一个个指给你看。

1. 玫瑰不叫玫瑰:变量、地址和"另一个名字"是什么意思

正式开始之前,我先问A同学一个问题:你在纸上写了一个int rose = 10;,然后把它交给另一个人,对方想知道 rose 的数据到底存放在哪里,你能用什么方式告诉他?

最直接的办法是把 rose 的地址抄给他。在C语言里,这恰好就是&运算符的用途。&rose的意思是"取出变量 rose 所在内存位置的地址"。你可以把内存想象成一条街道,每个字节都有一个门牌号,编译器负责给变量安排房间,而我们通过&运算符,可以把房间号掏出来给人看。

1.1 先用一段最小代码看看"门牌号"长什么样

#include <stdio.h> int main(void) { int rose = 10; printf("rose 的值 = %d\n", rose); printf("rose 的地址 = %p\n", (void *)&rose); return 0; }

这里有两个细节值得较真。第一,%p是专门打印地址的占位符,而&rose的类型是"指向 int 的指针",也就是int *。有些编译器在标记严格的参数下会提醒你转成void *再传,因为%p期待的是void *。虽然绝大多数平台上int *和void *的打印结果一样,但养成强转习惯,能让代码在更多编译环境下不报警告。

第二,地址其实是一串十六进制数。你运行两次程序,会发现两次数值很可能不一样,这是现代操作系统普遍的内存地址随机化机制在起作用。你完全不需要关心具体门牌号是多少,只需要建立两个认知:每个变量都占据一块内存,&能把这块内存的起点地址取出来。

1.2 指针的定义:专门用来存地址的变量

A同学听到这里插了一句:"既然&rose能取地址,那这个地址总得有个地方放吧?"

对,这就引出了指针变量。声明一个指针,只需要在变量名前加一个星号:

int *p;

这条语句读作:p 是一个"指向 int 的指针变量",它可以保存一个 int 类型变量的地址。这里有一个很常见的误区:int *是一个整体类型,星号属于类型,不属于变量名。写int* p和int *p都合法,但如果你写int* p, q;,那么只有 p 是指针,q 只是普通 int。想让两个都是指针,必须写int *p, *q;。这个细节每年都要绊倒一批人。

拿到地址之后,给这块内存起"第二个名字"就顺理成章了。下面的例子把 rose 的地址装进 p,然后用*p去读写同一块内存:

int rose = 10; int *p = &rose; printf("%d\n", *p); // 读出 10 *p = 99; printf("%d\n", rose); // 现在变成 99

*p的意思很直白:把 p 里存的地址"解开来",访问它指向的那个 int。你可以把 p 想象成一把钥匙,钥匙本身是一串地址编号,而*p就是打开门之后看到的东西。给*p赋值,等于隔着另一个名字去修改原来的变量——这正是标题里"通过任何其他名字"想要表达的动作。

2. 从 & 到 *:给一块内存建立"第二名字"的三段式操作

很多教材一上来就堆概念,把指针讲得像玄学。其实初始化一个指针并让它工作,只需要三步:声明、指向、解引用。每一步都有明确的语法规则,把这三步拼起来,就是完整的"第二个名字"构造流程。

2.1 三段式:声明只做一件事,赋值必须绑定,使用才能访问

先说声明。int *p;做的事情,是向编译器申请一个"用来存 int 地址的变量"。它像一个还没填门牌号的信封。此时 p 里面的值是随机的,我们管这种状态叫野指针,绝对不能立刻解引用。

第二步是绑定。p = &rose;就是把 rose 的门牌号装进信封。你也可以在声明的时候直接绑定:int *p = &rose;,这是最安全的写法,我强烈推荐新手这么做。声明与绑定一步到位,就不会出现"忘了赋值就去用"的惨剧。

第三步才是访问。*p出现的位置,几乎可以等价于 rose 本身:读它,得到 rose 的值;写它,修改 rose 的值。用一个完整的程序把三步串起来:

#include <stdio.h> int main(void) { int rose = 10; int *p = &rose; // 第一步 + 第二步:声明并绑定 printf("原始值 = %d\n", rose); printf("通过 *p 读取 = %d\n", *p); // 第三步:读 *p = 20; // 第三步:写,等价于 rose = 20 printf("修改 *p 后 = %d\n", rose); return 0; }

运行结果:

原始值 = 10 通过 *p 读取 = 10 修改 *p 后 = 20

A同学看到结果后很兴奋:"真的改了!好像我只是换了个名字去叫它,里面的内容一点没变。"

这正是指针最大的价值:无论你管这块内存叫 rose 还是叫*p,它从头到尾就是同一个 int。所谓"通过任何其他名字",描述的就是这种同一性。

2.2 scanf 里的 & 为什么不能省

讲到这里,顺便把一个新手必踩的坑解释掉:很多人在scanf的变量前忘记写&。比如scanf("%d", a);,编译器会给出格式不匹配的警告,但往往仍能编译通过,运行时会试图把输入写到"以 a 的数值作为地址"的位置——那个地址几乎肯定是非法的,轻则崩溃,重则悄悄改坏其他内存。正确写法是scanf("%d", &a);。

scanf之所以必须传地址,是因为它需要在函数内部修改 main 里定义的变量,而函数默认只能拿到值的副本。要"隔着另一个名字"改真正的内容,就得把地址传进去,让scanf顺着地址找过去写数据。这个"被调用者通过地址修改调用者变量"的机制,在下一节讲函数交换变量时,会看得更清楚。

3. 函数参数为什么要绕一个弯:交换两个变量的千古名题

学指针的时候,几乎每个教程都会用交换变量的例子,因为它把"值传递的局限性"和"指针参数的解决方案"演得明明白白。先来看大多数人第一次写的版本。

3.1 值传递:你递给函数的是复印件,不是原件

#include <stdio.h> void swap_value(int x, int y) { int temp = x; x = y; y = temp; } int main(void) { int a = 3, b = 7; swap_value(a, b); printf("a = %d, b = %d\n", a, b); return 0; }

输出是a = 3, b = 7。因为C语言的函数参数一律是值传递。调用swap_value(a, b);时,实际发生的是把 a 和 b 的值各抄一份,放进形参 x 和 y,函数体内交换的是 x 和 y 这两张"复印件",main 里的 a 和 b 从头到尾没被动过。你可以这样理解:别人借走了你名字的复印件,复印件上再乱涂乱画,原件不会受影响。

3.2 指针版本:把地址传进去,改的才是原件

要让函数真正修改调用者的变量,就得把变量的地址传进去:

void swap_pointer(int *x, int *y) { int temp = *x; *x = *y; *y = temp; }

调用方式也变了,原来是swap_value(a, b),现在得写swap_pointer(&a, &b)。在这里,x 和 y 里放的是 a 和 b 的地址,*x和*y直接操作的就是 main 里那块内存。打个比方:你把自家的门牌号告诉了别人,对方顺藤摸瓜进到屋里,帮你把家具调换了位置。所以执行完swap_pointer后,a 和 b 的值真的对调了。

A同学问:"为什么非要用指针?能不能把变量声明成全局变量,在函数里直接改全局?"

能,但这是很差的做法。全局变量会让函数之间的依赖变得隐晦,程序一复杂就难以维护。指针参数的价值,在于明确告诉读者:这个函数可能会修改传入的变量,调用时大家心里都有数。这也是C语言编程风格里非常看重的一点——能局部就局部,传参尽量显式。

3.3 哪些场景真的需要指针参数

根据我的使用经验,可以总结成四条,遇到下面这些情况,优先考虑指针参数。

第一个场景是修改调用侧的变量。除了交换,最常见的还有计数器、累加器这类场景,函数要把结果通过指针"带出去"。

第二个场景是函数需要返回多个结果。一个函数只能return一个值,想同时返回商和余数、平面坐标的 x 和 y,可以让函数返回其中一个,另一个通过指针参数输出。

第三个场景是传大型对象时避免拷贝。一个结构体可能几百个字节,每次传值都要整体复制一份,传指针只复制一个地址,成本低得多。后面学到结构体、链表的时候,你会发现几乎所有接口都是用指针传大型对象的。

第四个场景是数组参数。数组作为形参时,天然就是指针,这个下一节专门展开。

4. 数组名带着地址"隐身":数组和指针的相爱相杀

数组是C语言里最早让人对指针产生困惑的地方,因为数组名总是指针,却不完全是指针。搞懂它们的关系,指针才算真正入门。

4.1 arr 与 &arr[0]:写法不同,地址相同

先看一段代码:

#include <stdio.h> int main(void) { int arr[5] = {10, 20, 30, 40, 50}; printf("arr = %p\n", (void *)arr); printf("&arr[0] = %p\n", (void *)&arr[0]); return 0; }

输出两行地址完全一样。原因很简单:数组是一段连续的内存,首元素的地址就是整块内存的起点。而数组名在绝大多数表达式中会"退化"成指向首元素的指针,于是int *p = arr;和int *p = &arr[0];完全等价。

这里有一个非常关键的区别:&arr和arr不是一回事。arr 的类型是int *,指向 int;而&arr的类型是int (*)[5],指向"包含5个int的整个数组"。虽然它们的数值相同,但类型不同,做指针运算时行为完全不同:p + 1会跳到下一个 int 元素,而int (*)[5]类型指针加 1 会直接跳过整个数组。新手看到 p 和 &p 混用就懵,根源就是类型没区分开。

4.2 指针运算:p + 1 不是简单地地址加一

数组是连续内存,我们就能靠"第几个元素"来导航。假设指针 p 指向 arr[0],那么 p + 1 指向 arr[1],p + 2 指向 arr[2]。这里最反直觉的一点是:p + 1 的地址值并不总是加 1。如果 arr 是 int 数组,int 占 4 字节,p + 1 实际让地址值加 4;如果 arr 是 double 数组,double 占 8 字节,p + 1 让地址值加 8。

C语言规定:指针加 n,等于跳过 n 个"它所指向类型"的长度。这是给你方便的设计,不是来坑你的。你用int *遍历 int 数组,加一个正好落到下一个元素起点,完全不用关心元素占多少字节。标准库里的遍历代码,全都依赖这个语义。

看一段指针遍历数组的对比:

int arr[5] = {10, 20, 30, 40, 50}; int *p = arr; // 下标写法 for (int i = 0; i < 5; i++) printf("%d ", arr[i]); // 指针写法 for (int *q = arr; q < arr + 5; q++) printf("%d ", *q);

q < arr + 5这个比较也值得琢磨:两个同类型的指针可以比较大小,本质是比较它们指向的地址先后。q 从 arr 出发,每轮加 1,直到越过 arr[4] 的位置,遍历结束。很多老手偏爱指针写法,因为它让"我正在处理元素的存储位置"这个意图更明显。

4.3 数组参数退化:函数里再也算不出数组大小

这是每个学指针的人都必须接受的事实:数组作为参数时,传的不是整个数组,而是首元素地址。

#include <stdio.h> void print_size(int arr[]) { printf("函数内 sizeof(arr) = %zu\n", sizeof(arr)); } int main(void) { int arr[10] = {0}; printf("main 中 sizeof(arr) = %zu\n", sizeof(arr)); print_size(arr); return 0; }

在 main 里,sizeof(arr)算出的是 10 个 int 的总字节数,比如 40;在函数里,int arr[]这种形式会被编译器自动解释成int *arr,所以sizeof(arr)算出的只是一个指针的大小,比如 8。两个值天差地别,这就是"数组参数退化"。

A同学看到这个结果特别惊讶:"那函数里怎么知道数组有多长?"

答案很直接:不知道,所以必须另外传一个长度参数。大量C语言库函数的接口都遵循这个约定,比如字符串处理函数的原型里,基本都带着长度上限参数。这也是C语言和其他带"数组自带长度"的语言最大的不同。将来你学链表时,会发现链表的长度还得自己用一个计数器维护,本质上都是同一个原因。

5. typedef 与 void*:另外两种"换名字"的玩法

指针已经让同一块内存有了第二个名字,而在C语言里,"换名字"这件事还有另外两种姿势:typedef给类型换名字,void *给地址换容器。用好了,代码的可读性和通用性能上一大截。

5.1 typedef:给类型起个小名,代码瞬间好读

typedef的作用是给已有类型起一个新名字,本质是编译期的别名替换。最经典的例子是处理无符号长整型:

typedef unsigned long long u64; u64 total = 0;

写u64比每次打unsigned long long短得多,而且暗示了数据范围,一眼就知道这些变量是 64 位无符号整数。很多项目里你会看到u8、u16、s32之类的别名,都是同一个套路。

和结构体配合时效果更明显。如果不用typedef,每次定义结构体变量都要写struct关键字:

struct Book book;

有了typedef就能直接写:

typedef struct { char title[100]; int pages; } Book; Book b;

注意,typedef并不会创造一种"新"类型,它只是给现有类型换了个称呼。所以用 Book 定义出来的变量,和用原始结构体定义出来的变量,在类型层面完全一致——又回到标题那句话:不叫玫瑰,依然是玫瑰。

5.2 void*:装着任何地址的"万能容器"

void *是一个特殊的指针类型:它可以接收任意类型的地址。这也是一种"换名字"——不管原先是什么类型的指针,都能装进void *这个口袋里。

#include <stdio.h> int main(void) { int a = 42; double b = 3.14; void *p = &a; printf("%d\n", *(int *)p); // 取出时必须告诉编译器:这是个 int * p = &b; printf("%.2f\n", *(double *)p); // 现在又变成 double * return 0; }

这里的关键是:void *能存任何地址,但编译器不知道它指向什么类型的对象,所以解引用前必须用(int *)或(double *)把它转回具体类型,否则没法判断该读几个字节、按什么格式解释。后面学动态内存分配时,malloc的返回类型就是void *,所以常见写法是int *p = (int *)malloc(...),本质就是在"叫回"它真正的类型名字。

void *给C语言带来泛化能力,同时把类型安全的责任交给了程序员。传送地址的一方和接收地址的一方必须约定好真实类型,一旦转错,运行时不会有任何提示,数据解释就会完全错乱。这正是"换名字"最需要谨慎的地方。

5.3 指针使用中最容易摔的四个坑

结合我在实际项目中看到的报错和线上问题,把最常见的四类坑整理成一张表。建议收藏,写代码之前先扫一眼:

问题典型写法后果正确做法
野指针int *p;直接*p = 10;段错误,或悄悄改坏未知内存声明后立即指向有效地址,或先赋 NULL
空指针解引用p = NULL;然后*p = ...段错误使用前先if (p != NULL)判断
类型不匹配double *p = &intVar;编译警告,运行时数据错乱指针类型必须与指向对象一致
数组名与取地址混淆int *p = &arr;编译警告,p + 1 会跳过整个数组写int *p = arr;或&arr[0]

野指针和空指针是两大主力。野指针最危险的地方在于它指向哪里完全不确定,解引用可能不会立刻报错,却在未来的某一次运行中突然崩溃,排错成本极高。空指针至少是确定的,借用一个空地址几乎必然触发段错误,而段错误在调试器里排查起来反而清楚。我的习惯是:暂时没有合法目标的指针,一律先赋NULL,宁可让它"确定地崩",也不要让它"随机地坏"。

6. 动手串一串:把"第二名字"用到极致的练手程序

理论讲得再多,不如亲手写一个。这一节我们写一个完整的小程序,把地址、指针、数组参数和函数指针参数全部串起来。功能很简单:读一个整数数组,反转顺序,再打印出来。

6.1 完整代码

#include <stdio.h> void print_array(int arr[], int len) { for (int i = 0; i < len; i++) printf("%d ", arr[i]); printf("\n"); } void reverse_array(int arr[], int len) { int left = 0; // 左下标 int right = len - 1; // 右下标 while (left < right) { int temp = arr[left]; arr[left] = arr[right]; arr[right] = temp; left++; right--; } } int main(void) { int numbers[6] = {1, 2, 3, 4, 5, 6}; int *p = numbers; printf("原始数组: "); print_array(numbers, 6); reverse_array(numbers, 6); printf("反转数组: "); for (int i = 0; i < 6; i++) printf("%d ", *(p + i)); printf("\n"); return 0; }

6.2 逐段拆解:每一行在做什么

main 里的int *p = numbers;让 p 成为 numbers 的"另一个名字"。随后打印数组用的是*(p + i)而不是numbers[i],目的就是让你习惯从地址的角度看数组:p + i 是第 i 个元素的地址,*(p + i)是它的值。两种写法完全等价,只是视角不同。

print_array的形参虽然写成int arr[],但正如前面所说,它实际是int *arr。函数里既可以写arr[i],也可以写*(arr + i)。这个函数自己不知道数组有多长,所以 len 必须由调用者传进来。

reverse_array是整个程序的精华:它接收数组地址后,通过arr[left]这样的形式修改了 main 里的 numbers。如果当初误以为参数传入的是"整个数组的副本",反转就不会生效——现在你应该能看出来,arr[]和int *arr在参数列表里是同一个东西,传的都是地址,改的自然是原件。

A同学盯着代码看了一会儿,说:"原来我一直以为arr[]是复制一份,今天才知道传的是地址。怪不得以前自己写的排序函数总不生效。"这句话本身就是这个知识点最好的注脚。无数初学者写了看似正确的排序、反转函数,主程序里数组纹丝不动,根因就在这里。

6.3 马上能试的三种小改版

为了加深记忆,你可以在当前程序上做三个小改动,每改一处就重新编译运行一次。

第一,把reverse_array的数组下标交换改成纯指针写法。用int *left = arr; int *right = arr + len - 1;,然后交换*left和*right,每次循环left++、right--。注意别把循环条件写成left <= right,两个指针相等后再交换一次,会把中间元素又换回去。

第二,把print_array的形参故意改成int *arr,你会发现函数体内完全不用动,再次印证了数组参数和指针参数在编译层面是同一件事。

第三,在reverse_array里加一句printf("%zu\n", sizeof(arr));,亲眼确认函数内的sizeof(arr)是 8 而不是 24,亲手摸到"数组参数退化"的边界。

这三步做完,书上的理论就变成手上的手感了。

6.4 说点教学心得:这一课最容易卡住的地方

这些年带过不少初学者,我观察到,指针这课真正让人卡住的地方,往往不是某一个语法,而是思维方式的转变。此前学变量,大家都习惯了"变量名等于数据"这个等式;到了指针,必须强迫自己接受"变量名只是一个找到数据的线索,数据本身住在内存里,地址才是定位它的根本"。一旦接受内存模型,指针其实没什么神秘。

还有一个非常实际的习惯建议:画内存图。学到指针、数组、结构体这些内容时,纸笔比屏幕高效得多。画一个小方框代表变量,旁边标注它的地址,再用箭头画出指针指向的对象,很多绕脑子的问题,看图十秒就想通了。

A同学临走前问了一句:"下一讲是不是要说到字符串了?到时候char *又该怎么理解?"

我笑着回答:"等跑熟今天这几段代码,字符串里的char *就只是'指向字符的指针',顺着今天的思路往下走就行。名字可以换,内存还是那块内存。"

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/30 23:28:24

计算机毕设怎么做?基于Hadoop的网络安全入侵流量特征分析与可视化系统源码 毕业设计 选题推荐 毕设选题 数据分析 机器学习

> ✍✍计算机编程指导师 ⭐⭐个人介绍&#xff1a;自己非常喜欢研究技术问题&#xff01;专业做Java、Python、小程序、安卓、大数据、爬虫、Golang、大屏等实战项目。 ⛽⛽实战项目&#xff1a;有源码或者技术上的问题欢迎在评论区一起讨论交流&#xff01; ⚡⚡如果你遇到…

作者头像 李华
网站建设 2026/11/2 2:19:12

PE启动盘装Win10全指南:UEFI与GPT分区引导避坑手册

1. 为什么我一直推荐用PE装Windows 101.1 直接跑ISO镜像安装的痛点很多人觉得装系统就是把ISO镜像解压到U盘&#xff0c;然后从U盘启动开装。这个操作在全新硬盘、引导干净的情况下确实能跑通&#xff0c;但只要稍微遇到点"特殊情况"就非常难受&#xff1a;比如你的硬…

作者头像 李华
网站建设 2026/11/2 12:15:14

深度学习优化器实战指南:从SGD到Nadam的选型逻辑与避坑方法

1. 为什么换一个优化器&#xff0c;模型训练就从“卡死”变成“丝滑收敛”我第一次在某图像分类模拟项目X里遇到这个问题&#xff1a;用默认的SGD跑ResNet-18&#xff0c;学习率设0.01&#xff0c;loss曲线像心电图一样上下乱跳&#xff0c;第30个epoch还在震荡&#xff0c;验证…

作者头像 李华
网站建设 2026/11/3 8:36:06

小参数智能体提速10倍:从生成式推理到选择题决策的工程实践

1. 从"写作文"到"做选择"&#xff1a;一个反直觉的提速思路如果你最近在折腾小参数模型驱动的智能体&#xff0c;大概率会遇到一个让人抓狂的场景&#xff1a;明明模型参数量不大&#xff0c;单次前向推理也就几百毫秒&#xff0c;但一个稍微复杂点的任务跑…

作者头像 李华
网站建设 2026/11/3 13:27:44

Go Channel 源码深度剖析:锁、环形缓冲与等待队列

1. 从一次线上事故说起&#xff1a;我为什么非要啃 Channel 源码大概半年前&#xff0c;我维护的一个高并发推送服务突然出现诡异现象&#xff1a;上游业务方反馈消息延迟从平均 5ms 暴涨到 300ms&#xff0c;CPU 占用率倒是不高&#xff0c;但 goroutine 数量涨到了平时的十几…

作者头像 李华
网站建设 2026/10/18 16:04:04

artcraft:一种面向创作演化的模块化声明式范式

1. 项目概述&#xff1a;从“artcraft”这个词开始&#xff0c;我们到底在做什么&#xff1f;“artcraft”这个词乍看像一个合成词&#xff0c;拆开是 art&#xff08;艺术&#xff09;和 craft&#xff08;工艺&#xff09;&#xff0c;合起来直译就是“艺术工艺”。但放在当下…

作者头像 李华