上篇文章我们认识了指针最基本的玩法——& 取地址、* 解引用、类型决定步长。如果你已经搞懂了 int *p = &a; 以及 p+1 跳过几个字节,那我们可以进入更深的水域了。
实际写 C 代码时,指针极少单独出现。它最常见的两个搭档,一个是 const 关键字,一个是函数参数传递。const 让指针有规矩——哪些能改、哪些不能改,写对了是保护,写错了是束缚。而传址调用,是真正让指针发挥不可替代价值的场景——没有它,你连两个变量都交换不了。本文会逐一攻克 const 修饰指针的四种形态、野指针的三大成因与四条对策、assert 断言的使用方法,最后用传址调用把这一切串起来。这一篇的内容每一个都是面试常客,也是后续所有指针进阶的基础,值得逐段细读。
const修饰指针
const 这个关键字的意思很简单——被它修饰的变量变成只读。先看最基础的用法:
#include <stdio.h>
int main()
{
int m = 0;
m = 20; // OK,m没有const限制,可以修改
const int n = 0;
// n = 20; // ❌ 编译错误!n被const修饰,不可修改
return 0;
}但有一个容易产生错觉的地方:const 只是语法层面的限制,不是物理层面的。n 仍然存在于可读写内存中,只是编译器在编译阶段阻止你直接赋值。如果你"绕道"用指针去改,是可以绕过的:
#include <stdio.h>
int main()
{
const int n = 0;
printf("n = %d\n", n); // n = 0
int *p = (int *)&n; // 强转拿掉const属性
*p = 20; // 通过指针修改n
printf("n = %d\n", n); // n = 20(被改了!)
return 0;
}这段代码能通过编译,但这样做破坏了 const 语义——你声明 n 是常量,意味着你告诉所有人"这个值不会变",结果有人从后门改了它。C 标准明确规定,通过非 const 指针修改 const 对象是未定义行为,即使某次运行"恰好成功"也不代表正确。更现实的危险是:编译器可能把 const 变量放进只读段,或者基于"它不会变"做优化(比如把多次读取合并成一次),绕过 const 修改会得到匪夷所思的结果。
所以我们怎么安全地用指针指向 const 变量呢?这就是 const 修饰指针变量本身的需求。
const 修饰指针时,位置不同,含义完全不同。核心记忆口诀:左定值,右定向。const 在 * 的左边,锁定的是"值"(指向的内容不可通过该指针修改);const 在 * 的右边,锁定的是"向"(指针本身的指向不可改变)。
| 声明形式 | const 位置 | *p = xxx(修改内容) | p = xxx(修改指向) |
|---|---|---|---|
int *p | 无 const | ✅ 可以 | ✅ 可以 |
int const *p | * 的左边 | ❌ 不能 | ✅ 可以 |
int * const p | * 的右边 | ✅ 可以 | ❌ 不能 |
int const * const p | * 的两边 | ❌ 不能 | ❌ 不能 |
用代码逐项验证:
#include <stdio.h>
// 情况1:没有const,什么都可变
void test1()
{
int n = 10, m = 20;
int *p = &n;
*p = 30; // ✅ 可以修改指向的内容
p = &m; // ✅ 可以修改指针本身的指向
}
// 情况2:const在*的左边 → 锁定指向的内容
void test2()
{
int n = 10, m = 20;
const int *p = &n; // 等价于 int const *p = &n;
// *p = 30; // ❌ 错误!不能通过p修改n的值
p = &m; // ✅ 可以!p可以重新指向别的变量
}
// 情况3:const在*的右边 → 锁定指针本身的指向
void test3()
{
int n = 10, m = 20;
int * const p = &n; // p是const指针
*p = 30; // ✅ 可以!可以通过p修改n的值
// p = &m; // ❌ 错误!p不能重新指向别的变量
}
// 情况4:const在*的两边 → 内容和指向都锁定
void test4()
{
int n = 10, m = 20;
const int * const p = &n; // 全方位锁定
// *p = 30; // ❌ 错误!
// p = &m; // ❌ 错误!
}
int main()
{
test1();
test2();
test3();
test4();
printf("四种情况测试完成\n");
return 0;
}很多初学者搞不清 const int *p 和 int const *p 是不是同一个意思——答案是完全等价。当 const 在 * 左边时,const int * 和 int const * 是一模一样的类型。可以用一张图帮助记忆:
const 的领地
←───────────────→
const int * const p
│ │ │ │ │
│ │ │ │ └── 指针变量名
│ │ │ └── 锁定p本身(指向不能变)
│ │ └── * 表示"这是一个指针"
│ └── 指向的类型
└── 锁定指向的内容(值不能通过p修改)
规则:
const在*左边 → 管内容(*p只读)
const在*右边 → 管指针(p只读)
两边都有 → 全只读
另一种读法是从右往左读声明:int * const p → "p 是一个 const 指针,指向 int";const int *p → "p 是一个指针,指向 const int"。注意两种读法里"const 贴谁"的位置恰好对应"谁只读"。
还有一个容易栽的坑:const 指针必须初始化。普通指针可以先声明后赋值,但 int * const p; 这种"指针本身 const"的声明,一旦声明就永远不能改指向,所以必须在声明时给初值:
int n = 10;
int * const p = &n; // ✅ 声明即初始化
// int * const q; // ❌ 错误!const指针不初始化,以后永远无法赋值同样要注意的是:const int *p 锁定的只是"通过 p 修改",不阻止通过其他途径修改。比如 n 本身不是 const,那么 n = 30; 直接改 n 完全合法,只是 *p = 30; 不行。const 指针保护的是"这条访问通道",不是"那个对象"。
这种设计在实际代码中非常常见。比如标准库中 strlen 的声明就是 size_t strlen(const char *str);——参数加了 const,意味着"我只是读取你的字符串,保证不会修改它"。这是一种契约设计——调用者一看声明就知道自己的数据安全。反过来,如果你写的函数接受 char * 但实际只读不改,也建议写成 const char *,把"我只读"这个意图写进类型里,让编译器帮你把关——这样调用者传 const char * 字符串常量也不会收到警告。
思考题(const 修饰指针)
const char *p和char *const p和char const *p的区别?(第一个和第三个等价:内容只读、指向可变;第二个:内容可变、指向只读)- 为什么
strlen的参数要写const char *而不是char *?(契约设计:告诉调用者不会修改字符串,也让调用者能传字符串常量) - 把
const int *p赋值给int *q会怎样?(编译警告/错误,会丢失 const 限定;反过来int*赋给const int*是允许的)
参考答案与详解(const 修饰指针)
- 三种写法的区别(记忆口诀"左定值,右定向"):
const char *p:const在*左边 → 锁定"指向的内容",*p不能改,但p可以改(可指向别的地址)。char *const p:const在*右边 → 锁定"指针本身",p不能改指向(必须初始化),但*p可以改。char const *p:和const char *p完全等价(const都在*左边),即"内容只读、指向可变"。 对比一下:第二个是"钉子户"(锁死指针方向),第一/第三个是"只读通道"(不能改内容,但能改方向)。
strlen形参为什么用const char *:① 契约设计——strlen只是统计长度、绝不修改字符串,加const把"我只读"写进类型签名,调用者一看就知道自己的数据是安全的;② 兼容性——如果形参是char *,调用者传字符串常量(如"abc",类型是const char *)时会收到"丢弃 const"的警告,而const char *形参能让字符串常量和普通字符数组都能传。这是"const 修饰形参"这一设计的最佳范例。const int *p赋值给int *q:这会丢失 const 限定,编译器报警告/错误(int *q = p;把"只读"降级成"可写"是危险的)。反过来int *赋给const int *是允许的(写权限高的赋给只读指针,是安全收紧,编译器不拦)。记住:const 只能"从有到无"地丢失,可以加,不能无故删——去掉 const 需要显式强转并用memcpy等规避 UB。
野指针
指针变量里存放的地址,如果是"不可知"的——指向随机内存区域、已回收的栈空间、或不属于你的进程的地址空间——这种指针就叫野指针。解引用野指针会触发未定义行为:运气好立刻崩溃(段错误),运气差覆盖了别人数据导致随机 Bug,最恐怖的是看起来一切正常但未来某刻突然炸了。你可以把野指针想象成没拴绳的野狗——你永远不知道它下一秒咬谁。
野指针主要有三个来源。
成因1:指针未初始化。局部指针变量和普通局部变量一样,不初始化时值是"垃圾值"——一个随机数。把这个随机数当地址去解引用,后果不可预测:
#include <stdio.h>
int main()
{
int *p; // 局部变量未初始化,p的值是随机的
// *p = 20; // ❌ 灾难!往随机地址写入数据
printf("p = %p\n", (void*)p); // 输出一个随机的地址值
return 0;
}注意:某些编译器(如 MSVC 的 Debug 模式)会把未初始化局部变量填充成固定模式(比如 0xCCCCCCCC),方便你认出"这是没初始化的变量"——但这不是标准行为,更不能依赖。GCC 的 Debug 模式下也可能给指针填 0。无论哪种,未初始化就是垃圾值。
成因2:指针越界访问。数组有边界,指针超出边界就成了野指针:
#include <stdio.h>
int main()
{
int arr[10] = {0};
int *p = arr;
int i;
for (i = 0; i <= 11; i++) // 数组只有10个元素,循环却跑了12次
{
*p = i; // i==10 和 i==11 时,p指向了数组之外的地址
p++;
}
return 0;
}arr[10] 的有效下标是 0~9,arr+10 是"尾后"指针(只能做边界比较),arr+11 就是完全非法的访问。这个例子比较温和(通常不会立刻崩溃,因为栈附近还有可写内存),但恰恰因为"不崩溃",它写坏的数据可能让程序在完全无关的地方出错——这类 bug 最难排查。
成因3:返回局部变量的地址。函数内部的局部变量存在栈上,函数返回后栈帧被销毁,变量所占的内存随时可能被其他函数调用覆盖。这种指针叫悬挂指针(Dangling Pointer):
#include <stdio.h>
int *create_bad_pointer()
{
int n = 100; // n是局部变量,存在栈上
return &n; // ❌ 返回局部变量的地址!
} // 函数返回后,栈帧被销毁,n的内存可能被覆盖
int main()
{
int *p = create_bad_pointer();
// printf("%d\n", *p); // ❌ 访问已被释放的栈空间,未定义行为
return 0;
}从内存视角看这个过程:
调用 create_bad_pointer() 时:
┌─────────────────────┐ ← 高地址
│ create_bad_pointer │
│ 的栈帧 │
│ ┌───────────────┐ │
│ │ n = 100 │ │ ← p 指向这里
│ └───────────────┘ │
└─────────────────────┘
函数返回后——栈帧销毁
┌─────────────────────┐
│ main 的栈帧 │
│ (原来n的位置 │
│ 已被其他数据覆盖)│ ← p 还指向这里,但数据已不可靠
└─────────────────────┘
更迷惑人的是:有时候 printf("%d", *p) 第一次打印"恰好"还是 100——因为内存还没被覆盖。这反而更危险:你会以为"返回局部变量地址其实没问题",直到某次函数调用复用了那块栈空间,程序才神秘崩溃。
成因4:释放后未置 NULL(悬空指针的另一种)。用 free 释放堆内存后,指针里还留着旧地址。虽然这块内存理论上已经"不属于你"了,但指针还指着它:
#include <stdlib.h>
int main()
{
int *p = (int *)malloc(sizeof(int));
*p = 100;
free(p); // 释放堆内存
// 此时 p 仍是那个地址,但指向的空间已归还给堆
// *p = 200; // ❌ 悬空指针!可能崩溃,也可能"碰巧能写"(堆没被复用)
p = NULL; // ✅ 释放后立刻置 NULL,斩断联系
return 0;
}(动态内存和 malloc/free 的具体机制在专门的章节讲,这里先记住"释放后置 NULL"这条纪律。)
三种野指针形态很容易混淆,用一张表区分:
| 指针状态 | 定义 | 是否合法 | 典型来源 |
|---|---|---|---|
| 空指针 NULL | 明确表示"不指向任何对象"(地址 0) | 合法,可安全比较 | 初始化时赋 NULL |
| 野指针 | 指向未知/无效区域的指针(未初始化) | 非法 | 未初始化、越界 |
| 悬空指针 | 曾经有效、现在指向已释放内存的指针 | 非法 | 返回局部变量地址、free 后未置 NULL |
思考题(野指针)
- 空指针和野指针最大的区别是什么?(空指针是"明确的空",可以安全地检查;野指针是"未知的垃圾值",检查它本身都没意义)
- 为什么"释放后置 NULL"能防住悬空指针?(置 NULL 后,
*p会立刻因解引用空指针崩溃——快速失败,比"碰巧能写坏数据"好得多) - 局部变量返回地址,在什么时候会"碰巧正确"?(在返回后立即使用、且栈未被覆盖时;但不保证,永远是 UB)
参考答案与详解(野指针)
- 空指针 vs 野指针最大区别:空指针(NULL)是"明确的空"——它是合规的、随时可以用
p != NULL安全检查的状态,代表"我不指向任何对象";而野指针是"未知的垃圾值"——它本身的值不可信、不可判断,对它做p != NULL检查没意义(它的值不是 NULL 会骗过检查)。一句话:空指针是"可知的可控状态",野指针是"不可知的失控状态"。 - 为什么"释放后置 NULL"能防悬空指针:置 NULL 之后,指针变成空指针,再
*p会因解引用空指针立刻触发段错误——这是"快速失败"。相比"留着旧地址、碰巧能写坏堆里别处数据"的隐患,快速失败(尽早暴露问题、且每次必然出现)远优于"时好时坏、难以复现"的悬空指针。置 NULL 的本质是主动剥掉指针的"身份",让它不再冒充指向仍有效的内存。 - 什么时候会"碰巧正确":当你在函数返回后立即解引用、且那块栈空间还没有被其他函数调用复用时,有可能恰好还是原来的值(比如 100)。但这只是"运气",因为标准规定这是未定义行为——下次只要中间多插一次函数调用,栈就被覆盖,结果立刻变化甚至崩溃。正确性不能建立在运气之上,永远不要返回局部变量地址。
针对这三大成因,有四条铁律可以守住安全底线:
铁律1:指针声明就初始化。知道指向谁就赋地址,不知道就赋 NULL:
#include <stdio.h>
int main()
{
int num = 10;
int *p1 = # // 明确知道指向谁,直接赋地址
int *p2 = NULL; // 不知道指向谁,赋NULL
printf("p1 = %p, p2 = %p\n", (void*)p1, (void*)p2);
return 0;
}NULL 本质是 (void *)0——地址 0 是受保护区域,解引用 NULL 会立刻触发段错误。快速失败总比悄悄出错好。标准头文件里 NULL 是这样定义的(C 和 C++ 各取所需):
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endifC 语言里 NULL 是 ((void *)0),C++ 为了类型安全则直接定义成整数 0。对我们来说只需要记住一条:NULL 就是"空指针",它保证不等于任何合法变量的地址。
铁律2:严格限制指针的访问范围。申请了多少内存,指针就在那片区域内活动。数组有 N 个元素,指针的活动范围就是 [首元素, 尾后),越界一步就是 UB。
铁律3:指针用完后及时置 NULL,使用前检查:
#include <stdio.h>
int main()
{
int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int *p = arr;
int i;
for (i = 0; i < 10; i++)
printf("%d ", *(p++));
printf("\n");
// p现在已经越界了,及时"拴住"
p = NULL;
// 下次使用前先检查
if (p != NULL)
{
// 这里不会执行,因为p是NULL
}
else
{
printf("p是NULL,不能使用\n");
}
// 重新赋予有效地址后再使用
p = arr;
if (p != NULL)
printf("p现在有效,arr[0] = %d\n", *p);
return 0;
}这个习惯就像停车拉手刹——多花一秒,避免大祸。NULL 检查只能防止对空指针的解引用,但你得确保在需要时真的给指针赋了有效地址——置 NULL 和检查 NULL 是一套组合拳,缺一不可。
铁律4:绝对不返回局部变量的地址。如果必须从函数返回指针,有三种安全做法:返回全局变量的地址、返回 static 局部变量的地址、或返回 malloc 分配的堆内存地址:
#include <stdio.h>
// ✅ 安全:返回静态局部变量地址
int *create_safe_pointer()
{
static int n = 100; // static变量不在栈上,函数返回后依然存在
return &n;
}
int main()
{
int *p = create_safe_pointer();
printf("%d\n", *p); // 安全,输出100
return 0;
}static 局部变量存放在静态区(也叫全局区),它的生命周期是整个程序运行期,函数返回后依然存在——所以返回它的地址是安全的。但注意它的缺点是全局唯一:整个程序只有一份,多次调用函数拿到的是同一个地址。如果用 static 变量做"返回缓冲区",两次调用会互相覆盖(strtok 函数正是这个毛病,所以它不是线程安全的)。
思考题(防野指针铁律)
- 为什么"NULL 检查"无法防住所有野指针?(NULL 检查只能防空指针;越界后的指针、悬空指针都不是 NULL)
static int n = 100;和int n = 100;在函数里的区别?(static 存静态区,程序结束才销毁;普通局部变量存栈,函数返回即销毁)malloc返回的地址什么时候需要置 NULL?(free 之后;另外malloc失败时返回 NULL,使用前也要检查)
参考答案与详解(防野指针铁律)
- NULL 检查防不住所有野指针:
p != NULL只能区分"空指针"和"非空指针",它管不了"非空但无效"的指针。比如:指针越界后的地址(约等于尾后,非 NULL)、free 后未置 NULL 的悬空地址(仍非 NULL)。这两种指针通过了 NULL 检查,但解引用依然是未定义行为。所以 NULL 检查只是第一道防线,还必须配合"初始化、限范围、不返回栈地址、释放置空"等铁律一起用。 static int nvsint n(函数内):static int n存放在静态区(数据段),生命周期是整个程序运行期,函数返回后它依然存在,所以返回它的地址是安全的;但整个程序只有一份(全局唯一),多次调用返回同一地址,且 mutex 不保护时多线程不安全。而普通int n存放在栈上,函数返回时栈帧销毁,返回其地址就是悬空指针(未定义行为)。malloc的返回地址何时置 NULL:①free(p)之后立刻p = NULL,斩断对已归还堆内存的引用,防止悬空指针;② 另外malloc本身失败时会返回 NULL,所以使用时也要先检查if (p != NULL)。记住:free只是把内存还给堆,不会自动清空指针变量,置 NULL 这一步必须手动做。
assert断言
assert 是 <assert.h> 中定义的宏,它接收一个表达式——表达式为真时什么都不做,为假时立刻终止程序并打印出错的文件名和行号。它最适合用来检测"理论上不应该发生"的错误,比如调用者传入了 NULL 指针:
#include <stdio.h>
#include <assert.h>
// 模拟strlen:计算字符串长度
// 使用assert确保调用者不会传入NULL指针
int my_strlen(const char *str)
{
assert(str != NULL); // 如果str是NULL,立刻报错
int count = 0;
while (*str != '\0')
{
count++;
str++;
}
return count;
}
int main()
{
printf("长度: %d\n", my_strlen("hello")); // 输出: 5
printf("长度: %d\n", my_strlen("")); // 输出: 0
// my_strlen(NULL); // 会触发assert,程序终止
return 0;
}使用 assert 有三重好处:自动定位错误(告诉你文件名和行号)、Debug/Release 切换(定义 NDEBUG 就能全局禁用,Release 版本零开销)、代码即文档(比注释"str 不能为 NULL"更明确)。assert 的失效机制很简单:编译时如果定义了 NDEBUG 宏,assert(expr) 会被展开成空语句:
// 编译时加 -DNDEBUG(GCC)或 /DNDEBUG(MSVC)
// assert(x > 0) 就会被替换成:
// ((void)0)所以要记住,assert 是调试工具,不是运行时错误处理。对于可能在生产环境发生的错误(比如 malloc 失败),应该用 if 判断而不是 assert——因为 Release 版本中 assert 会被优化掉。选型的标准是:这个错误"理论不该发生"(程序逻辑错误)→ assert;这个错误"外部环境可能导致"(内存不足、文件不存在、用户输入非法)→ if 判断。
还有一个反直觉的陷阱:assert 的表达式在 Release 里根本不会求值。所以千万别在 assert 里写有副作用的表达式:
assert(i++ < 10); // ❌ Debug 里 i 会自增,Release 里不会!
// 同一份代码,两种行为,排查到你怀疑人生
i++; // ✅ 副作用单独成句
assert(i < 10); // ✅ assert 里只做纯判断思考题(assert)
assert(p != NULL)和if (p == NULL) return -1;的区别?(前者是"程序逻辑错误就终止",调试期用;后者是"可预期错误就优雅处理",发布期用)- 为什么 assert 里不能写
i++?(NDEBUG 定义后 assert 整体消失,副作用丢失) - assert 失败时程序会怎样?(打印文件名、行号、表达式,然后调用 abort() 终止)
参考答案与详解(assert)
assert(p != NULL)vsif (p == NULL) return -1;:本质是"两类错误"的区分。assert针对程序逻辑错误——理论上"绝不该发生"(比如函数约定好不允许传 NULL,调用者却传了),属于代码 bug,应当立即终止、主动爆炸,让开发者在 Debug 阶段就发现。它默认只在 Debug 生效,Release(定义了NDEBUG)会被整体移除、零开销。if (p == NULL) return -1;针对可预期的运行错误——由外部环境导致(内存不足、文件不存在、非法用户输入),应该优雅处理并返回错误码,让上层继续运行。它在 Release 里仍然生效。- 选型口诀:"理论不该发生"→ assert;"外部环境可能导致"→ if。
- 为什么
assert里不能写i++:因为编译阶段一旦定义了NDEBUG,assert(expr)会被展开成一行空语句((void)0),expr根本不会求值。于是assert(i++ < 10)在 Debug 里i会自增、在 Release 里却不增——同一份代码、两种行为,是最难排查的 bug。正确做法是把副作用单独成句:i++; assert(i < 10);,让 assert 里只放纯判断。 - assert 失败时的动作:
assert(expr)为假时,会往标准错误流stderr写入一条错误信息(包含没通过的表达式文本、出错的文件名、行号),然后调用abort()使进程异常终止。这正是它能在海量代码里自动帮你定位"哪里、哪一行、哪个条件"的原因。
传址调用
现在来到指针真正发挥不可替代价值的场景。假如让你写一个交换两个变量的函数,你的第一反应可能是这样:
#include <stdio.h>
// 自以为能交换的Swap函数
void Swap_v1(int x, int y)
{
int tmp = x;
x = y;
y = tmp;
printf("Swap内部: x=%d, y=%d\n", x, y); // 内部看着是交换了
}
int main()
{
int a = 10, b = 20;
printf("交换前: a=%d, b=%d\n", a, b); // a=10, b=20
Swap_v1(a, b);
printf("交换后: a=%d, b=%d\n", a, b); // a=10, b=20(没变!)
return 0;
}
// 输出:
// 交换前: a=10, b=20
// Swap内部: x=20, y=10
// 交换后: a=10, b=20 ← a和b根本就没交换!函数内部明明交换了 x 和 y,为什么 a 和 b 纹丝不动?因为 C 语言是传值调用:形参是实参的副本。Swap_v1(a, b) 调用时,a 的值 10 被拷贝给 x,b 的值 20 被拷贝给 y。函数内部交换 x 和 y,改的只是副本,原件毫发无损:
调用 Swap_v1(a, b) 时发生的事:
main 的栈帧 Swap_v1 的栈帧
┌──────────────┐ ┌──────────────┐
│ a = 10 │ 拷贝值 │ x = 10 │ ← x 是 a 的副本
│ b = 20 │ ──────→ │ y = 20 │ ← y 是 b 的副本
│ │ │ tmp │
└──────────────┘ └──────────────┘
Swap 内部交换 x 和 y——只改了副本,原件毫发无损
函数返回后,x、y、tmp 随栈帧一起消失
"传值调用"这四个字是关键:实参的值被拷贝进形参,函数对形参的一切修改都不影响实参。这不是 C 的缺陷,而是 C 的设计哲学——函数默认就是"隔离"的,你想让函数影响外部,就必须显式地告诉它"外部在哪"。
解决方案是传地址——让函数知道变量"住在哪":
#include <stdio.h>
// 正确版本:传入地址,通过指针间接修改
void Swap_v2(int *px, int *py)
{
// px里存的是a的地址,py里存的是b的地址
int tmp = *px; // tmp = a 的值(通过解引用拿到)
*px = *py; // 把 b 的值写到 a 的位置
*py = tmp; // 把原来 a 的值写到 b 的位置
}
int main()
{
int a = 10, b = 20;
printf("交换前: a=%d, b=%d\n", a, b); // a=10, b=20
Swap_v2(&a, &b); // 传入a和b的地址
printf("交换后: a=%d, b=%d\n", a, b); // a=20, b=10 ✅
return 0;
}内存视角:
调用 Swap_v2(&a, &b) 时发生的事:
main 的栈帧 Swap_v2 的栈帧
┌──────────────┐ ┌──────────────────┐
│ a = 10 │ ←──┐ │ px = 0x006FFD70 ─┤ px存的是a的地址
│ b = 20 │ ←──┼────│ py = 0x006FFD74 ─┤ py存的是b的地址
│ │ │ │ tmp │
└──────────────┘ │ └──────────────────┘
0x006FFD70 ──────┘
0x006FFD74 ──────┘
Swap 内部通过 *px 和 *py 直接操作 main 里的 a 和 b
所以修改会直接影响原件!
px 和 py 本身仍然是形参(&a 和 &b 这两个地址值也被拷贝了),但 *px 和 *py 指向的是 main 里的原始变量。这就是"间接访问"的威力——形参和实参是两个变量,但操作的是同一块内存。现在你应该明白了为什么 scanf("%d", &a) 要加 &——因为 scanf 需要把用户输入写进变量 a 的内存空间,这就是传址调用。忘了加 & 的后果:scanf 把输入写到"a 的值"当作地址的地方去——a 的值是 10,它往地址 0x0000000A 写数据,直接段错误。
这里有一个重要澄清:C 语言只有传值调用。传地址只是用指针模拟出了"引用"的效果。C++ 中的 void swap(int &x, int &y) 才是真正的传引用——形参直接是实参的别名,没有拷贝。理解这一点在后续学习二级指针时至关重要。
还有三个传址调用的变体值得掌握:
变体一:传数组名也是传址。 void func(int arr[]) 里的 arr 实质是 int *,函数内 arr[i] = x 直接修改调用者的数组——下一讲细说。这里先记住结论:数组作为参数传给函数时,天然就是传址。
变体二:const 保护形参。 如果一个函数只是读取实参、不修改,形参建议写成 const int *:
// 只读函数:const 是契约,告诉调用者"我不会改你的数据"
int GetMax(const int *arr, int n)
{
int i;
int max = arr[0];
for (i = 1; i < n; i++)
if (arr[i] > max)
max = arr[i];
return max;
}变体三:输出参数。 一个函数要"返回"多个结果时,C 的 return 只能返回一个值,其余结果用指针参数"带出来":
#include <stdio.h>
// 同时求商和余数:两个结果用指针带出
void DivMod(int a, int b, int *quotient, int *remainder)
{
*quotient = a / b; // 商写进调用者的变量
*remainder = a % b; // 余数写进调用者的变量
}
int main()
{
int q, r;
DivMod(17, 5, &q, &r);
printf("17/5 商=%d 余=%d\n", q, r); // 商=3 余=2
return 0;
}"输出参数"这个模式在系统编程里遍地都是——Windows API、标准库函数大量使用。
思考题(传址调用)
Swap_v2里如果写成px = py;会怎样?(只改了形参指针的指向,a、b 不变——这是"修改指针本身"和"修改指针指向的内容"的经典区分)scanf("%d", a)如果 a 是int a;会怎样?(编译警告:类型不匹配,a是 int 不是地址;运行时会往地址 0x0000000A 附近写数据,崩溃)- 为什么说"C 语言只有传值调用"?(形参永远是实参的拷贝,传地址只是"拷贝的是地址值")
参考答案与详解(传址调用)
Swap_v2里写px = py;会怎样:这行只把形参指针px的指向改成和py一样,并不会改任何数据。函数返回后px、py随栈帧一起销毁,a、b完全不变——交换失败。这暴露了经典混淆:px = py(改的是指针本身)和*px = *py(改的是指针指向的内容)完全是两回事。要交换值必须用*px/*py解引用去读写main里的变量。scanf("%d", a)(a 是int a;)会怎样:scanf的第一参数后的可变参数期望一个int*地址,而你传了a的值(一个int,如 10)。① 编译器会报警告(类型不匹配:intvsint*);② 更糟的是运行时scanf会把a的值 10 当成地址0x0000000A去写数据——写向一块既不属于你的低地址内存,几乎必然崩溃。所以scanf取变量地址必须写&a。- 为什么 C 只有传值调用:C 语言规定形参是实参的副本——调用
f(a)时,系统把a的值拷贝一份给形参,函数内对形参的修改不影响实参。所谓"传址调用"只是拷贝的那个值恰好是地址(&a),函数拿到地址后通过解引用去改main里的原变量。归根结底"形参总是独立的一块存储,永远拷贝实参的值"这一点没有变。真正的"传引用(别名)"是 C++ 的int &x,C 并不存在。
这篇文章里,const 给你戴上规矩的帽子,野指针的四条铁律给你配了安全带,assert 帮你装上报警器,传址调用则让你第一次感受到指针的不可替代。把这三样东西想明白、写熟练,你就能安全地写出"能改调用者数据"的函数了。
在进入下一篇之前,做两个小练习:
练习 1:写一个
void SetMinMax(int *a, int *b),让调用后*a <= *b(如果*a > *b就交换),用 const 合理标注参数。练习 2:写出三种不同的野指针产生方式,并为每种写一行"如何避免"的注释。
练习 1 参考答案
#include <stdio.h>
// 形参用 int *(而不是 const int *),因为函数要修改 *a 和 *b 的值
// 传址调用:传入两个 int 的地址,函数通过指针间接修改它们
void SetMinMax(int *a, int *b)
{
int tmp;
if (*a > *b) // 只有 a 比 b 大时才交换,保证调用后 *a <= *b
{
tmp = *a;
*a = *b;
*b = tmp;
}
}
int main()
{
int x = 8, y = 3;
SetMinMax(&x, &y); // 8 > 3,应交换
printf("x=%d y=%d\n", x, y); // 输出:x=3 y=8(已经满足 3<=8)
int u = 2, v = 5;
SetMinMax(&u, &v); // 2 < 5,无需交换
printf("u=%d v=%d\n", u, v); // 输出:u=2 v=5(已经满足 2<=5)
return 0;
}解析:核心是传址调用(&x、&y)让函数能改调用者的变量,再用指针解引用比较/交换。关于 const 的取舍:这个函数要修改 *a、*b,所以形参不能写成 const int *(那会导致 *a = *b 编译报错)。本函数形参只能用 int *。如果设计成"只读版"(比如 int Max(const int *a, const int *b) 只读取、不修改),那时才适合加 const——这也正是理解"const 锁内容/锁指向"的现实用途。
练习 2 参考答案
// 3 种野指针的产生方式 + 避免方法
// ① 指针未初始化
int *p; // 局部指针未初始化,p 是垃圾值(野指针)
p = NULL; // 避免:声明时不知道指向谁就赋 NULL,使用前检查
// ② 指针越界访问
int arr[10];
int *q = arr;
for (int i = 0; i <= 11; i++) // i 走到 arr[10]、arr[11],超出数组范围
*(q++) = i; // 越界后 q 成为野指针
while (p != NULL) {} // 避免:严格限制指针活动范围在 [首元素, 尾后) 内
// 用完后及时 q = NULL;
// ③ 返回局部变量的地址(悬空指针)
int *bad()
{
int n = 100;
return &n; // 函数返回后 n 的栈空间被回收,地址悬空
}
// 避免:绝不返回局部变量的地址;
// 若必须返回指针,改用返回 static 局部变量地址 / 全局变量地址 / malloc 堆地址
// ④(补充,第3章"free 后未置 NULL"也算一种)
// int *r = (int*)malloc(sizeof(int)); free(r); r = NULL; // free 后立刻置 NULL解析:这 3 种正是本文"野指针三大成因"的复现——未初始化、越界、返回局部变量地址。关键认知:野指针是非法的(值不可知),不能像空指针那样安全判断;对策的核心是"规范生命周期"——初始化、限范围、销毁后置 NULL、不返回栈地址。
从下一篇开始,我们将进入指针和数组的深度互动——数组名到底是不是指针?为什么 arr[3] 和 3[arr] 居然等价?为什么数组一传入函数就"变小"?这些微妙而重要的知识,是通往 C 语言高手的必经之路。
本文基于C语言教学课件《第12讲:深入理解指针(2)》的内容框架撰写,所有代码示例均在GCC/MinGW环境下验证通过。
还没有评论 — 第一条由你来留。