1. 先纠正一个流传最广的误解:C语言其实只有值传递
在讲参数传递之前,我想先问一个问题:你学C语言的时候,有没有听过这样一句话——“C语言有两种传参方式,一种是值传递,一种是地址传递”?如果你点头了,那我要告诉你一个可能颠覆认知的事实:C语言标准里只有值传递这一种机制,所谓的“地址传递”,本质上依然是在传值,只不过传的值是一个地址(指针)。
这不是文字游戏,也不是抠概念。我见过太多人在这个理解上栽跟头。如果你把“地址传递”理解成“把变量本身传给了函数”,那很多代码行为你会解释不通:为什么传了指针,函数里对指针本身做修改(比如想让指针指向别处),外部却感知不到?为什么数组传参之后sizeof的结果会“莫名其妙”变成8?这些问题,只有把“地址传递其实也是值传递”这句话真正想透,才能一眼看穿。
这里先建立一个最底层的认知:C语言函数调用时,实参的值会被复制一份,然后交给形式参数。这个复制动作,是C语言参数传递的全部底层逻辑。不管是普通变量、指针变量、结构体,还是数组名(数组名退化为指针),统统逃不过“复制”这两个字。区别只在于——你复制过来的这个“值”,到底是一个数字,还是一个地址。
为了把这个问题彻底讲清,下面我会从内存的角度,一步步拆开函数调用时到底发生了什么,然后再用几个经典的代码案例,把值传递和“地址传递”(也就是传指针)的边界画得清清楚楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 形参实参的底层真相:函数栈帧里到底多了什么
2.1 一次函数调用,系统偷偷做了三件事
当你写下func(a);这行代码时,编译器在背后做的工作比你想象的多。它的核心动作可以概括为三件事:
- 压栈:把实参
a的值复制一份,放入当前函数栈帧中给形参预留的位置。 - 跳转:将程序计数器跳到
func函数的入口地址。 - 建立新栈帧:为
func内部的局部变量、返回地址等分配新的栈空间。
重点是第一步。a的值被“复制”了一份。这意味着,在func函数内部,你操作的那个形参,和外部调用者手里的实参,虽然拥有相同的值,但它们是两个独立的变量,占用两块不同的内存空间。
你可以把这个过程类比成你去复印店复印了一份合同:原件还在你手上,复印件拿给律师看。律师在复印件上做任何批注、修改,甚至把复印件撕了,都不影响你手上的原件。C语言的值传递,就是这个逻辑。
2.2 用地址来验证“复制”这件事
光说理论没用,我们直接写代码验证。看下面这段简单到不能再简单的程序:
c复制#include <stdio.h>
void test(int x) {
printf("形参 x 的地址: %p\n", (void*)&x);
x = 100; // 尝试修改形参
}
int main() {
int a = 10;
printf("实参 a 的地址: %p\n", (void*)&a);
test(a);
printf("调用结束后 a = %d\n", a);
return 0;
}
在大多数编译器上,你会看到类似这样的输出:
code复制实参 a 的地址: 0x7ffd8f3a2c14
形参 x 的地址: 0x7ffd8f3a2bf4
调用结束后 a = 10
注意看,a和x的地址不一样,中间隔了一小段距离。这段距离就是栈帧的布局空间,x是a的复制品,而不是a本身。所以你在test里把x改成100,a纹丝不动。
这个实验是我带新手时必做的一个演示。明白了地址不同,值传递的本质就懂了一半。很多同学一开始觉得“函数内部不是能改外部变量吗”,其实那是在传指针的情况下,我们下一节会细说。
2.3 那“地址传递”又是什么?
传指针时,底层同样有一个复制动作。看这个例子:
c复制#include <stdio.h>
void test(int *p) {
printf("形参 p 自己的地址: %p\n", (void*)&p);
printf("形参 p 存储的地址: %p\n", (void*)p);
}
int main() {
int a = 10;
int *pa = &a;
printf("实参 pa 自己的地址: %p\n", (void*)&pa);
printf("实参 pa 存储的地址: %p\n", (void*)pa);
test(pa);
return 0;
}
你会发现,pa和p这两个指针变量自身的地址是不同的,说明指针形参也是实参的一份拷贝。但是,它们存储的地址值相同,都指向a。这就是关键:指针形参和指针实参是两个不同的盒子,但两个盒子里装的路标都指向同一个变量。
所以,所谓“地址传递”,准确的说法应该是:传递的是“地址值”。你传了一个地址过去,函数拿到了这个地址,就能顺着地址找到原来的变量,从而修改它。但如果你在函数内部修改指针本身(比如让指针重新指向另一个变量),外部是感知不到的,因为外部那个指针变量和内部这个指针变量,本身就是两个独立的盒子。
| 传参方式 | 实参复制给形参的内容 | 能否修改实参指向的变量 | 能否修改实参指针本身 |
|---|---|---|---|
| 传普通变量 | 变量的值 | 不能 | 不涉及指针 |
| 传指针变量 | 地址值 | 能(通过*p解引用) |
不能(修改的是形参指针副本) |
| 传数组名 | 首元素的地址值 | 能(通过下标或指针运算) | 不能(形参也是副本) |
这张表建议收藏。你以后判断一个函数能不能改变外部数据,就看两件事:一是函数能不能拿到外部变量的地址,二是函数拿到地址后是通过解引用去修改目标,还是只对指针本身做操作。
3. 为什么swap()交换不了?经典翻车现场全复盘
3.1 新手必踩的坑:传值版本的交换函数
几乎每个学C语言的人,都写过下面这段“无法工作”的代码:
c复制#include <stdio.h>
void swap(int a, int b) {
int temp = a;
a = b;
b = temp;
}
int main() {
int x = 5, y = 8;
printf("交换前: x = %d, y = %d\n", x, y);
swap(x, y);
printf("交换后: x = %d, y = %d\n", x, y);
return 0;
}
输出结果永远是:
code复制交换前: x = 5, y = 8
交换后: x = 5, y = 8
明明函数里交换了,怎么外面没变?这个时候如果用我们前面讲的“复印合同”类比,一下就通了:swap(x, y)是把x和y的值复印了一份,复印件在函数内部交换得再欢,原件上的数字也不会变。
3.2 逐步排查:函数内部到底交换了什么
我们给这个swap加一点“探针”,看看它内部发生了什么:
c复制#include <stdio.h>
void swap(int a, int b) {
printf("swap内部: 交换前 a = %d, b = %d\n", a, b);
printf("swap内部: a的地址 = %p, b的地址 = %p\n", (void*)&a, (void*)&b);
int temp = a;
a = b;
b = temp;
printf("swap内部: 交换后 a = %d, b = %d\n", a, b);
}
int main() {
int x = 5, y = 8;
printf("main: x的地址 = %p, y的地址 = %p\n", (void*)&x, (void*)&y);
swap(x, y);
printf("main: 交换后 x = %d, y = %d\n", x, y);
return 0;
}
输出:
code复制main: x的地址 = 0x7ffc9a2d3e10, y的地址 = 0x7ffc9a2d3e14
swap内部: 交换前 a = 5, b = 8
swap内部: a的地址 = 0x7ffc9a2d3dd0, b的地址 = 0x7ffc9a2d3dd4
swap内部: 交换后 a = 8, b = 5
main: 交换后 x = 5, y = 8
注意,a、b的地址和x、y的地址完全不同。函数内部交换的是a和b这两个临时副本,函数一结束,这两个副本连同它们的栈空间一起被回收,什么都不剩。而x、y从头到尾没有参与交换,自然保持不变。
3.3 正确写法:传指针,通过解引用交换“目标”
要真正交换实参,必须让函数拿到x和y的地址,然后通过地址去操作它们:
c复制#include <stdio.h>
void swap(int *pa, int *pb) {
int temp = *pa;
*pa = *pb;
*pb = temp;
}
int main() {
int x = 5, y = 8;
printf("交换前: x = %d, y = %d\n", x, y);
swap(&x, &y);
printf("交换后: x = %d, y = %d\n", x, y);
return 0;
}
这次输出:
code复制交换前: x = 5, y = 8
交换后: x = 8, y = 5
这个版本的要点是:pa和pb虽然也是指针形参的副本,但副本里存的是&x和&y。通过*pa = *pb这样的语句,我们是在“沿着地址找到目标变量,然后修改目标变量”。打个比方:别人复印了你家的门牌号,但他拿着复印件上的门牌号,照样能找到你家。地址本身是复印件,但地址指向的房子是真实的。
3.4 从“工具”和“地址”两个角度理解传指针
我经常会用一个“工具包”的比喻来帮学生理清这个概念:
- 值传递:你把工具箱借给别人,别人对着工具箱拍照(复制),然后把照片上的一把螺丝刀涂成红色,你手里的真实螺丝刀还是原样。
- 传指针:你把工具箱所在房间的地址写给别人,别人按照地址走进房间,把里面的真实螺丝刀换成红色。你再去房间一看,螺丝刀真的变红了。
细心的读者可能会问:那传二级指针(指针的指针)又是什么?你可以理解为:别人拿到的是“记录房间地址的那张纸条”的地址。他可以通过二级指针,找到那张纸条,然后把纸条上的地址改掉,让纸条指向另一个房间。这种操作在某些场景下非常关键,比如在函数里修改外部指针变量本身,我们稍后会专门讲。
4. 数组传参的隐藏陷阱:为什么sizeof一下子“缩水”了
4.1 数组名在传参时变成了什么
数组是C语言里最“表里不一”的东西。你写int arr[10]的时候,arr是一个拥有10个int空间的数组。但当你把arr传给一个函数时,它不会把整个数组复制一份进去,而是退化为指向首元素的指针。
看下面的代码:
c复制#include <stdio.h>
void print_arr(int arr[]) {
// 这里等价于 void print_arr(int *arr)
printf("函数内 sizeof(arr) = %zu\n", sizeof(arr));
}
int main() {
int arr[10] = {0};
printf("main 中 sizeof(arr) = %zu\n", sizeof(arr));
print_arr(arr);
return 0;
}
在64位系统上,输出会是:
code复制main 中 sizeof(arr) = 40
函数内 sizeof(arr) = 8
40是10个int的总字节数(10 × 4),而8是一个指针的大小。这说明什么?说明在函数内部,arr已经不再是那个拥有10个元素的数组,而是一个指向int的指针。sizeof(arr)不再返回数组总大小,而是返回指针的大小。
4.2 为什么C语言要这样设计
从效率角度就很好理解:如果你把整个数组按值传递,每次函数调用都要把数组的每一个元素都复制一遍。一个大数组动辄几兆,复制一次开销巨大。而传一个地址只需要8字节(64位平台),代价几乎可以忽略不计。
但这也带来了一个经典陷阱:在函数内部,你不能通过sizeof(arr)/sizeof(arr[0])来获取数组长度。很多新手在函数里写出这种代码,得到的结果永远是8/4=2,然后百思不得其解。
正确的做法是,在调用函数时,把数组长度作为另一个参数传进去:
c复制#include <stdio.h>
void print_arr(int arr[], int n) {
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
int main() {
int arr[] = {1, 2, 3, 4, 5};
int n = sizeof(arr) / sizeof(arr[0]);
print_arr(arr, n);
return 0;
}
注意,sizeof(arr) / sizeof(arr[0])这个求长度的技巧,只能在数组“尚未退化为指针”的上下文里用。一旦数组名作为函数参数传递,它就变成了指针,这个技巧就失效了。
4.3 为什么传入数组后修改函数内元素,外部会跟着变
很多同学会有疑问:既然传指针也是值传递(复制了一份地址值),为什么修改数组元素就能影响外部?
答案其实前面说过:你复制的是地址值,但地址指向的是同一个内存区域。在函数内写arr[i] = 99,等价于*(arr + i) = 99,这一步是沿着地址找到真实的内存单元,然后修改这个单元的内容。所以外部看到数组元素变了,一点都不奇怪。
我用一个图景来描述:你发给同事一份文件,不是把整个文件复制过去,而是把文件存储路径告诉他。他拿着路径找到文件,改动了内容。你这边打开同一个路径,看到的自然是改过的内容。但如果他在路径上做了修改(比如把路径改到另一个文件),你手里的文件路径不会变。
4.4 二维数组传参的正确姿势
二维数组的传参比一维更容易出问题。先看常见错误写法:
c复制void func(int arr[][3], int rows) {
// 正确,因为编译器需要知道每一行有多少个元素才能计算偏移
}
// 或者写成指针数组形式
void func(int (*arr)[3], int rows) {
// 这里 arr 是指向“包含3个int的数组”的指针
}
但下面的写法就是错的:
c复制void func(int arr[][], int rows) { // 错误!第二维不能省略
}
为什么第二维不能省略?因为二维数组在内存中是连续排列的,arr[i][j]实际上是*(*(arr + i) + j)。编译器要计算arr + i的偏移量,就必须知道每一行有多大,也就是第二维的长度。如果你不告诉它第二维是3,它就不知道第2行从哪里开始。这也是很多初学者在写二维数组传参时编译报错的原因。
| 写法 | 含义 | 适用场景 |
|---|---|---|
int a[] |
指向int的指针 | 一维数组 |
int a[][N] |
指向“长度为N的int数组”的指针 | 二维数组,行数可变,列数固定 |
int (*a)[N] |
与上面等价,指针写法 | 需要改造成指针的场合 |
int *a[] |
指针数组,元素是int* | 字符串数组、不规则二维结构 |
int **a |
指向指针的指针 | 动态分配的二维结构或需要修改指针本身 |
每种写法的边界和用法不一样,但底层逻辑都是一致的:函数拿到的是首地址,配合类型信息,才能正确解引用和计算偏移。
5. 进阶场景:函数需要“改指针本身”时,得靠二级指针
5.1 先看一个典型的“改不动”案例
假设我要写一个函数,它负责分配一块内存,并把指针指向这片内存。很多新手会这样写:
c复制#include <stdio.h>
#include <stdlib.h>
void allocate(int *p, int size) {
p = (int*)malloc(size * sizeof(int));
printf("函数内部 p = %p\n", (void*)p);
}
int main() {
int *ptr = NULL;
allocate(ptr, 10);
printf("函数外部 ptr = %p\n", (void*)ptr);
if (ptr != NULL) {
ptr[0] = 42;
} else {
printf("ptr 仍然是 NULL,分配失败?\n");
}
return 0;
}
运行结果:
code复制函数内部 p = 0x55f4a1b2e2a0
函数外部 ptr = (nil)
ptr 仍然是 NULL,分配失败?
这又是“值传递”在作怪。ptr本身是一个指针变量,传给它p时,p是ptr的一个拷贝。你在函数内部p = malloc(...),只是让拷贝的指针指向了新分配的内存。外部真正的ptr一点没变,还是NULL。
5.2 二级指针的解法
要修改外部指针变量本身,你需要拿到这个指针变量的地址,也就是二级指针:
c复制#include <stdio.h>
#include <stdlib.h>
void allocate(int **pp, int size) {
*pp = (int*)malloc(size * sizeof(int));
printf("函数内部 *pp = %p\n", (void*)*pp);
}
int main() {
int *ptr = NULL;
allocate(&ptr, 10);
printf("函数外部 ptr = %p\n", (void*)ptr);
if (ptr != NULL) {
ptr[0] = 42;
printf("ptr[0] = %d\n", ptr[0]);
}
free(ptr);
return 0;
}
运行结果:
code复制函数内部 *pp = 0x55f4a1b2e2a0
函数外部 ptr = 0x55f4a1b2e2a0
ptr[0] = 42
这里的关键是*pp = malloc(...)。pp是ptr的地址的副本,但通过*pp这个解引用操作,你能找到外部的ptr变量本身,然后修改它。逻辑和前面交换两个int变量的思路完全一致:想改什么,就传什么的地址。想改int变量,传int*;想改int*变量,传int**。
5.3 链表中典型的“头指针更新”问题
二级指针的经典应用场景之一,是链表的头插法。
c复制#include <stdio.h>
#include <stdlib.h>
typedef struct Node {
int data;
struct Node *next;
} Node;
// 头插法:在链表头部插入新节点
void insert_head(Node **head, int value) {
Node *new_node = (Node*)malloc(sizeof(Node));
new_node->data = value;
new_node->next = *head; // 新节点的 next 指向原来的头节点
*head = new_node; // 更新头指针,指向新节点
}
void print_list(Node *head) {
while (head != NULL) {
printf("%d -> ", head->data);
head = head->next;
}
printf("NULL\n");
}
int main() {
Node *head = NULL;
insert_head(&head, 10);
insert_head(&head, 20);
insert_head(&head, 30);
print_list(head);
return 0;
}
输出:
code复制30 -> 20 -> 10 -> NULL
这里如果不用二级指针,而是写void insert_head(Node *head, int value),外部head就永远更新不了,链表就会“丢掉”所有新插入的节点。很多链表操作教材里直接忽略了这个细节,导致学生抄代码时一头雾水。
当然,也有一个“曲线救国”的办法:让函数返回新的头指针,然后在调用处用head = insert_head(head, value)接收。两种方法都可以,但用二级指针的写法和“修改外部变量”的语义更统一。
5.4 什么时候该用指针的指针?一条实用判断标准
很多人分不清何时该用二级指针,这里给一条我觉得最实用的判断标准:
如果在函数内部,你想修改一个指针变量本身的值(让它指向别处),而不是修改它指向的目标,那就要把这个指针变量的地址传进来,也就是用指针的指针。
场景举例:
- 在函数中给指针赋值
malloc结果 —— 需要二级指针。 - 在函数中把链表的头节点换成新节点 —— 需要二级指针或返回新头指针。
- 在函数中修改字符串指针,让它指向另一个字符串 —— 需要二级指针。
反之,如果函数只是通过指针读取或修改它指向的目标数据(比如*p = 100),那用一级指针就够了。很多同学一看见指针就慌,其实只要问自己一句:“我要改的是盒子里的东西,还是要换一个盒子?”答案马上就清晰了。
6. const、空指针与参数传递的边界,以及调试技巧
6.1 const限定符:把输入参数“锁死”的最佳实践
在实际项目中,很多参数只用于读取,不应该被函数修改。这时用const限定指针参数,能帮编译器和你自己都“上一道锁”。看个例子:
c复制#include <stdio.h>
void print_message(const char *msg) {
// 如果函数里试图修改 *msg,比如 msg[0] = 'A',编译器会报错
printf("%s\n", msg);
}
这里的const char *msg表示msg指向的内容不可修改。如果有人接手你的代码,看到const就明白这个函数不会改动字符串内容,维护起来省心很多。而且如果不小心写了修改语句,编译器会在编译期直接拦截,比运行时崩溃好处理得多。
注意区分两种写法:
const char *p:p指向的内容不可改,但p本身可以重新指向别处。char *const p:p本身不可改(不能重新指向别处),但指向的内容可以改。
这两种语义在实际代码里都很常见,面试也爱考。我的建议是,凡是只读的参数,尽量加const修饰。这不仅是好习惯,也能让你在写代码时被迫想清楚:这个参数,函数到底有没有权利修改它?
6.2 返回局部变量地址的隐患
这也是参数传递相关的高频bug。看代码:
c复制#include <stdio.h>
int* get_number() {
int a = 42;
return &a; // 危险!a 是局部变量,函数结束后栈空间释放
}
int main() {
int *p = get_number();
printf("%d\n", *p); // 行为未定义,可能打印出 42,也可能是垃圾值
return 0;
}
a是局部变量,存储在栈上。函数返回后,这块栈空间被回收,理论上“不属于”你了。可很多同学实际跑第一次,发现打印出来还是42,就以为没问题。这是最坑的地方——它可能“碰巧”工作一段时间,然后在某个数据量更大的场景下突然崩溃,或者打印出莫名其妙的值。
正确的做法是:
- 把变量定义为
static,延长生命周期,但要注意线程安全。 - 在调用方分配内存,通过指针参数传入。
- 在函数内用
malloc分配堆内存,调用方负责free。
其中第二种传递方式虽然安全,但有一个前提:调用方必须知道需要分配多大空间,而且分配完的空间必须足够大,否则越界写会破坏其他数据,这就是缓冲区溢出问题的源头。
6.3 传参时的空指针检查
写带指针参数的函数时,最好在函数入口检查指针是否为空,否则一旦传入NULL,函数内直接解引用就会导致程序崩溃。比如:
c复制#include <stdio.h>
void set_value(int *p, int value) {
if (p == NULL) {
fprintf(stderr, "错误:p 是空指针\n");
return;
}
*p = value;
}
这种检查看起来有点“多余”,但在项目里真的能救命。尤其是写库函数、供别人调用时,你无法控制调用方会传什么进来。鲁棒性,就体现在这些细节里。
6.4 用地址验证法调试参数传递问题
最后分享一个我调试参数传递问题时最常用、也最直观的技巧:在关键位置打印变量的值和地址。
比如你怀疑某个函数没有按预期修改外部变量,不要只打印值,一定要打印地址。只要把实参、形参各自的地址打出来,一眼就能判断出是不是值传递副本在“捣乱”。
c复制#include <stdio.h>
void modify(int *p) {
printf("[modify] p 的地址: %p\n", (void*)&p);
printf("[modify] p 指向的地址: %p\n", (void*)p);
if (p != NULL) {
*p = 99;
}
}
int main() {
int x = 10;
int *px = &x;
printf("[main] px 的地址: %p\n", (void*)&px);
printf("[main] px 指向的地址: %p\n", (void*)px);
modify(px);
printf("[main] 修改后 x = %d\n", x);
return 0;
}
输出:
code复制[main] px 的地址: 0x7ffdf6a96a28
[main] px 指向的地址: 0x7ffdf6a96a2c
[modify] p 的地址: 0x7ffdf6a96a08
[modify] p 指向的地址: 0x7ffdf6a96a2c
[main] 修改后 x = 99
看到没,p的地址和px的地址不同,但p和px指向的地址相同。这正好说明了“指针本身是拷贝,但拷贝的地址能定位到同一目标”。如果你在代码里加几行这样的打印,所有关于传参的困惑都会被证据摆平。
在GDB这类调试器里,也可以直接看地址,但用printf的好处是最直观、最容易复现,尤其对刚接触C语言的同学来说,比调试器门槛低很多。
7. 一次带新人的经历:怎么判断对方真的懂了参数传递
我在带新人的时候,特别喜欢问一个问题:“如果我想在函数里修改一个整数,应该传什么?如果我想在函数里修改一个指针,应该传什么?如果我想在函数里修改一个指向指针的指针,应该传什么?”
能秒答“传int*、传int**、传int***”的人很多,但我接着问一句“为什么”,不少人就卡住了。他们记住了“想改什么就传什么的地址”这个口诀,却没想明白背后的原因。
真正弄懂的人,会这样解释:把变量比作一个房间,指针比作门牌号。函数默认只能拿到门牌号的复印件。如果你想通过函数修改房间里家具的摆放,复印件上的门牌号足够用了,按图索骥找到真实房间即可。但如果你想在函数里修改这块门牌号本身、让它指向另一个房间,那你就得把“存放门牌号的位置”告诉别人——也就是门牌号的地址。一层一层套下去,本质都是同一件事:你只能通过地址访问真实内存,而地址本身也是一种数据,它也可以被复制、被修改、被传递。
这个理解到位了,后面学结构体指针、链表、二叉树、回调函数,逻辑全都顺了。本质上,C语言里所有的复杂数据结构操作,都是在“地址”和“目标数据”这两个层面之间来回切换。你越早把参数传递这个地基打牢,后面越不容易被各种“怪问题”绊倒。
我自己写代码这些年,踩过的最深的一个坑,就是在链表插入函数里漏了二级指针,导致头节点永远更新不了,程序看起来“没反应”,定位了整整两个小时。后来想明白了,其实就是一句话:指针变量本身,也只是一个普通的变量。你把它当变量看待,参数传递的规则就全都适用了。
