☰
C语言中符号的多重身份:从取地址到位运算与常见陷阱
2026/10/14 8:02:24 网站建设 项目流程

手头压了很多C语言相关的问题,有一个出现频率高到爆,那就是&到底有几个意思。初级学员会问,工作了几年的非科班程序员也会问,而且每个阶段的问法都不一样。刚接触的人问“这里加一个&是干嘛的”,写了一阵子的人问“为什么有时候&不要加,有时候必须加”,再深入一些的人会问“&取到的地址到底是什么类型”。如果你也有类似疑问,这篇文章也许能一次性帮你理清。

C语言里,一个符号有多个含义并不罕见,*既是乘法又是解引用,()既是函数调用又是强制转换,&也是同样的路数。但&的“多重身份”恰恰是初学者最容易翻车的地方,因为它在表达式里出现的场景毫无提示,你只能通过上下文判断。&在C中有三大正面角色:一元取地址、二元按位与、和另一个&组成&&逻辑与。如果再加上复合赋值中的&=,这已经是四个用途了。我不建议一上来死记硬背,更重要的是建立一套“看到符号先分一元还是二元”的本能反应。

1. 别小看一个符号:&的双重身份

1.1 一元与二元,两个完全不同的世界

一元运算符和二元运算符的区别很好判断:操作数数量。一个操作数是一元,两个操作数是二元。&x这种写法只有一个操作数x,必然是一元取地址;a & b这种写法有两个操作数,必然是二元按位与。这一个判断标准,就能过滤掉80%的混乱。

很多从C++转回C写代码的人,会不自觉把&往“引用”上靠。实际上C语言中不存在引用这个概念,int &a = b这种写法在C标准里是完全不合法的。C里看到&,要么取地址,要么按位与,没有第三种“别名”语义。

我还想顺便提一下&&,它和&是两个完全不同的运算符,但在笔误时编译器也会“留给你一线生机”。if (a && b)和if (a & b)都能通过编译,但语义南辕北辙。前者是先看a是否非零,再决定要不要看b;后者是把a和b的每个二进制位对齐做与运算,结果可能是一个非零整数,也可能恰好是零。条件判断里把&&写成&,轻则判断结果不对,重则进入分支逻辑混乱,这种bug极难定位。

下面把每一种&的用法拆开讲,配合代码和场景,尽量把“为什么”也讲透。

1.2 运算符优先级带来的隐藏陷阱

&的一元和二元形态,优先级还不一样。一元&优先级很高,仅次于后缀运算符、括号和强制转换;二元&优先级比较低,甚至低于==和!=。这就埋下了一个经典雷区:

if (a & b == 0)

按照C语言优先级规则,==优先级高于&,所以这个表达式先计算b == 0,得到0或1,再与a做按位与。如果你本意是想判断“a和b按位与的结果是否为0”,正确写法必须是:

if ((a & b) == 0)

这类问题没有一劳永逸的自动修复方法,只能靠编码习惯预防。我自己的习惯是:涉及不同优先级运算符混排时,宁可用括号包起来,也不依赖记忆优先级。编译器不会因为多一个括号而变慢,但人类会因为少一个括号而多排查半小时。

2. 一元取地址符:指针世界的入场券

2.1 &的本质:拿到变量在内存中的门牌号

先说最常规的取地址。任何一个变量,都可以拆成两个属性:名字和值。但实际存储时,系统还需要知道这个变量放在内存哪里,这就是地址。&变量名返回的,正是这个变量所在内存单元的起始地址。

打个生活化的比方。你有一排信箱,每个信箱有编号,你的信件在97号信箱里。取地址运算符做的工作,就是根据“你的信件”反查到“97号信箱”这个编号。后续你想往信箱里塞东西,不需要搬着整个信箱跑,只需要把“97号”这个编号告诉别人。指针变量,本质上就是用来存放这个“编号”的盒子。

#include <stdio.h> int main(void) { int age = 25; int *p = &age; printf("age 的值: %d\n", age); // 输出 25 printf("age 的地址: %p\n", (void *)&age); printf("p 保存的地址: %p\n", (void *)p); printf("*p 解引用结果: %d\n", *p); // 输出 25 return 0; }

这段代码是最基本的入门组合:&取地址,*解引用。值得注意的是,打印地址建议用%p,并且把地址值强制转换为void *,这是标准做法。很多老代码用%x或者%d打印地址,在32位平台上运气好能看,在64位平台上指针是8字节,用%d打印出来的地址是被截断的,完全没有参考价值。

2.2 &与scanf的致命配合

scanf是新手接触&的最早场景,也是最容易出错的场景。原因在于,scanf需要把用户输入的值写入变量,本质上是要“修改一块内存”,所以它必须拿到变量的地址才能动手。scanf("%d", &num)的意思是:把读取到的整数放到num所在的内存地址。而scanf("%d", num)则是把读取到的整数放到“num的值”所指向的地址。如果num没有初始化,这就是往一个随机地址写数据,大概率直接段错误。

同理,scanf("%s", str)不加&反而是对的,因为数组名在大多数表达式中会自动退化为指向首元素的指针,它本身就是一个地址。数组名退化的规则在后面细讲。这里先记住一句话:凡是需要数据“写回来”的场景,就必须传递地址;数组名本身能表示地址,因此不需要再加&。

很多初学者在本地跑代码偶尔不报错,就以为自己写对了,这个错觉很危险。不报错说明你运气好,恰好写到了某个未使用的内存区域;一旦那块区域被系统保护或者被其他数据占用,程序就会以一种非常难看的方式崩溃。编译器的警告信息format '%d' expects argument of type 'int *', but argument 2 has type 'int'并不是多管闲事,而是一条救命提示,好好看它,别嫌烦。

2.3 &与数组下标、指针运算的优先级

有一个经典坑:&arr[i]到底取的是什么?根据优先级,下标[]比一元&更高,所以&arr[i]等价于&(arr[i]),也就是第i个元素的地址。这个语义是合理的,但很多人会直觉上把它理解成(&arr)[i],那就大错特错了。

(&arr)[i]的含义是:先取整个数组arr的地址,得到的是“指向整个数组的指针”,然后对这个指针做下标偏移,移动i个数组长度,这已经越界到天边去了。类似的问题也出现在*p++中,这里++先作用于p,所以*p++等价于*(p++),先解引用p,再把p向后移动一个单位。如果拿不准,加个括号总是没错的。编译器优化的年代,括号不会影响性能,只会让意图更清晰。

3. 二元按位与:底层的位运算利器

3.1 按位与的规则与本质

当&两边各有一个操作数时,它执行的是按位与操作。规则很简单:两个操作数对应的二进制位都是1时,结果位才是1,否则为0。这个操作在电子层面就是逻辑与门,在软件层面则是我们操控单个位的基石。

比如0b101010 & 0b110011,我们一位一位地算:

101010 & 110011 ------ 100010

结果是0b100010,也就是十进制的34。按位与的操作对象中至少有一个是整型,另一个可以也是整型、字符型、枚举等可转换为整型的类型。浮点数不能直接参与按位与,编译器会报错,因为浮点数的存储结构(IEEE 754)不是按普通二进制整数的位逻辑来组织的,直接位操作没有意义。

3.2 掩码判断:用&提取指定bit

按位与最常见的用途是掩码判断。比如一个8位的状态寄存器,bit3表示某个设备是否开启,我们可以定义一个宏:

#define FLAG_ENABLE (1U << 3) uint8_t status = 0x08; if (status & FLAG_ENABLE) { // 设备已开启 }

这里status & FLAG_ENABLE的结果不是0就是1 << 3,所以可以直接作为条件判断。这里有个新手常见疑问:为什么if里不是(status & FLAG_ENABLE) != 0?其实两种写法都对,C语言里非零整数即真,1 << 3是8,自然为真。我建议对自己要求高一点,写成!= 0形式,可读性更强,也避免别人误以为你写的是逻辑与。

掩码还有一个常用方向:清除某一位。清零需要用到取反和与的组合:status & ~FLAG_ENABLE。先对掩码取反,得到除了目标位之外全是1的数,再做按位与,就能把目标位清零而保留其他位。

举个例子,如果status是0x0D(二进制00001101),想清除bit2:

00001101 & 11111011 // ~(00000100) -------- 00001001

结果就是0x09,bit2被清零,其余位保持原样。这个技巧在嵌入式寄存器操作、权限位管理、图形程序颜色分量提取里都是基础中的基础。

3.3 按位与在判断奇偶中的应用

判断一个整数是奇数还是偶数,最朴素的做法是n % 2 == 0。但很多老程序员喜欢用n & 1,因为二进制下最低位为1就是奇数,为0就是偶数。这个用法在绝大多数编译器下和%2性能没有本质区别,但可读性因人而异。我自己写代码时,团队约定用%2判断奇偶,用&只做真正的位掩码操作,这样每个人都能一眼看懂意图。技巧本身没问题,关键是团队沟通成本。如果团队都是老手,n & 1完全OK;如果带新人,就尽量写直白一点。

3.4 位掩码实战:从颜色分量到权限系统

图形学里,一个像素常见的RGB565格式用16位表示颜色:高5位是红色,中间6位是绿色,低5位是蓝色。要从一个像素值中提取红色分量,就用(pixel >> 11) & 0x1F;提取绿色分量是(pixel >> 5) & 0x3F;提取蓝色分量是pixel & 0x1F。每一步都离不开&。

权限系统也同样。假设一个文件权限用8位表示:bit0是可读,bit1是可写,bit2是可执行。判断“是否可执行”就是(perm & 0x04) != 0。设置可执行位是perm |= 0x04,清除可执行位是perm &= ~0x04。这些操作组合起来,就是一个极其紧凑的权限模型,而&在其中承担了“提取”和“清除”两个关键动作。

4. 走查几个常见场景:地址、数组、函数参数

4.1 数组名和&数组名,真的不一样

很多教程说“数组名就是指向首元素的指针”,这句话在多数场景下成立,但并不严谨。数组名在表达式中会退化为指向首元素的指针T *,但&数组名得到的是指向整个数组的指针T (*)[N]。两者虽然数值上相同,但类型截然不同,指针算术的步长也不同。

考虑下面代码:

int arr[5] = {10, 20, 30, 40, 50}; int (*pArr)[5] = &arr; // 指向整个数组的指针 printf("arr = %p\n", (void *)arr); printf("&arr = %p\n", (void *)&arr); printf("arr + 1 = %p\n", (void *)(arr + 1)); // 相差 4 字节 printf("&arr + 1 = %p\n", (void *)(&arr + 1)); // 相差 20 字节

在常见的32位int环境下,arr + 1指向下一个元素,也就是偏移4字节;&arr + 1指向数组末尾之后的一个位置,偏移整整20字节。所以&arr虽然在打印时数值等于arr的首元素地址,但类型语义完全不同。如果你把&arr塞给int *,编译器一定会警告,因为类型不匹配。实际操作中,函数的形参如果声明成int arr[]或int *arr,那调用时传arr即可;如果形参声明成int (*arr)[N],那就必须传&arr。搞混类型就是牵着马当骡子使,运行结果往往莫名其妙。

4.2 函数传参里&的两种思路

C语言函数参数默认是值传递。如果想让函数修改外部变量,就必须传地址。最典型的就是swap:

#include <stdio.h> void swap(int *a, int *b) { int temp = *a; *a = *b; *b = temp; } int main(void) { int x = 5, y = 9; swap(&x, &y); printf("x = %d, y = %d\n", x, y); return 0; }

在这个例子中,&x和&y把变量的地址传进函数,函数内部通过*a和*b间接修改了外部变量。如果你写成swap(x, y),那函数里改的只是形参的副本,x和y在原函数里纹丝不动。这是C语言的一个重要准则:值传递意味着拷贝,地址传递才意味着修改。如果你不想让函数修改外部变量,就不该传地址;反过来,如果你想修改,就必须传地址。判断依据就这么简单。

还有一种用法,是把地址传进去只是为了“避免拷贝大对象”。比如一个结构体有几百个字节,按值传参每次都要复制整份数据,无论时间还是栈空间都不划算。传指针则只复制一个地址,通常是8字节(64位平台),效率高很多。

typedef struct { char name[64]; int scores[10]; double avg; } Student; void printStudent(const Student *s) { printf("name: %s\n", s->name); printf("avg: %.2f\n", s->avg); }

注意这里用了const Student *s,它表示“指向常量的指针”,也就是函数内只能读不能改。传指针本身并不等于允许修改,加不加const是额外的契约。我强烈建议:除非函数确实要修改目标对象,否则一律用const限定。这既是自我约束,也是给调用者一个明确的信号。

4.3 什么时候不需要&?

有些场景下,编译器已经隐式提供了地址,你再加&反而是画蛇添足。除了前面提到的数组名,还有几个典型情况:

  • scanf("%s", str):str是数组名,已经退化为char *,不需要加&。
  • 指向指针的指针场景:如果char *p本身是指针,scanf("%s", p)传入的p值就是地址,不需要再加&。但如果你想在函数里修改p本身,比如让p指向另一块内存,那就要传&p,即char **。
  • 结构体变量的地址:结构体变量本身不是指针,访问其成员时,&stu.age是成员age的地址,&stu是整个结构体的地址。两者起始地址一样,但类型不同。

我见过不少新人在scanf里对数组名加&,编译能过但行为诡异。为什么诡异?如果scanf("%s", &str)传入的是char (*)[100]类型,而%s期望的是char *,二者数值相等但类型不同,有些编译器会警告,有些直接忽略。实际运行时,因为数值相等,输入还是被写入了数组起始位置,所以“多数情况下”能正常工作。这就会让新人误以为自己的写法是对的,直到有一天在某个编译器优化级别下崩了,才追悔莫及。这种“看似能跑”的代码是最麻烦的,因为它隐藏了类型层面的错误。

4.4 结构体成员取地址的应用场景

结构体使用中,&也扮演着关键角色。比如我们要通过一个函数初始化结构体的某个字段:

typedef struct { int id; char name[32]; double score; } Record; void initRecord(Record *r) { r->id = 0; r->name[0] = '\0'; r->score = 0.0; } // 调用 Record rec; initRecord(&rec);

这里传&rec,函数内部用r->来访问成员。注意r->name本身就等价于&r->name[0],所以不需要再额外取地址。结构体指针和&的组合,构成了链表、二叉树等动态数据结构的基础。每次你用malloc分配一个新节点,得到的就是一个地址,而这个地址又必须通过指针变量保存,于是&和指针在动态数据结构里几乎形影不离。

5. 新手最容易踩的 & 相关坑

5.1 为什么scanf一输入就崩溃?

这个场景太经典了:scanf("%d", num),num是一个未初始化的局部变量。此时num的值是一个随机数,可能是0,可能是某个地址,也可能是一个非法地址。scanf按照%d的格式读取输入,然后把值写入num的值所指向的地址。如果这个地址不在进程的合法内存范围内,操作系统直接抛出段错误。

解决办法很简单,写成scanf("%d", &num)。但更根治的解释是:scanf函数的第二个参数要求类型为int *,你必须提供一个地址。拿num这个值去顶替,本身就是类型错误。我见过有些人把scanf的返回值忽略掉,也不检查,这也是隐患。scanf的返回值是成功匹配并赋值的项数,写代码时最好检查一下,至少可以判断用户输入是否符合预期。

5.2 &和&&混用,还有优先级埋雷

前面提过if (a & b)和if (a && b)都能编译,但逻辑完全不同。有一个更隐蔽的变形:if (a & b == 0)。按照C语言的优先级,==的优先级高于&,所以这个表达式先计算b == 0,得到0或1的结果,再做按位与。如果你本来想判断a与b按位与结果为0,正确写法是if ((a & b) == 0)。错误写法在a是偶数时会得到1,a是奇数时会得到0,完全随变量值变化,排查起来极其痛苦。

这类问题没有一劳永逸的自动修复方法,只能靠编码习惯预防。我的习惯是:涉及不同优先级的运算符混排时,一律用括号明确分组,不要依赖记忆。括号不会影响性能,只会让意图更清晰。

5.3 打印地址时的类型转换

还有一个看起来小,但更值得注意的坑:printf("%p", &num)。标准要求%p对应的参数是void *,所以最规范的写法是:

printf("address: %p\n", (void *)&num);

不加(void *)在绝大多数平台上也能跑,因为所有对象指针的大小通常一致,但这严格来说是未定义行为。在涉及带const修饰的指针时,%p与void *的兼容性还要再注意。养成这种小细节,能在你看别人代码时快速判断对方写代码的习惯如何。

5.4 常见&问题速查表

问题现象可能原因解决办法
scanf输入后程序崩溃scanf参数没加&改为&变量名,检查格式占位符
scanf对数组输入行为异常误对数组名加了&去掉&,用数组名本身
变量传给函数后没被修改值传递,传的是拷贝改为传地址&变量,形参用指针
判断条件永远为真/永远为假&&误写成&确认逻辑与和位与的区别
打印地址值看起来不对用%d打印指针改用%p并转成void *
&arr+1结果超出预期不理解步长差异明确&arr是T (*)[N]类型

这张表是我在回答别人问题时经常用的,覆盖了大部分新手困惑。

6. 我在实操中积累的几点经验

6.1 用取地址符辅助排查内存布局

调试的时候,我经常在关键变量处打印地址,帮助判断栈上变量的相对位置:

printf("&a = %p\n", (void *)&a); printf("&b = %p\n", (void *)&b); printf("&arr[0] = %p\n", (void *)&arr[0]);

地址的大小关系能反映变量在栈上的顺序,注意方向因平台而异,不代表简单的递减。这只是辅助手段,不能作为判断编译优化下真实布局的依据,但对初学者理解“变量在内存中有地址”这件事非常有帮助。

6.2 位运算代码的可读性优化

按位与操作往往写得很难看。比如判断一个GPIO寄存器某一位是否为1,有些老驱动是这么写的:

if (reg & (1 << 7)) { ... }

我建议定义有意义的宏或枚举:

#define GPIO_PIN7 (1U << 7) if (reg & GPIO_PIN7) { ... }

这样读代码时,不用在心里默默计算“第7位是哪个”,直接看宏名就能知道意图。这是代码审查时经常被忽略的点,但长线项目里尤其重要。位掩码一旦多了,裸数字一多,维护者的噩梦就开始了。

6.3 面试中常见的&考点

面试官特别喜欢围绕&出题,通常集中在三块:&的一元二元区分、&arr与arr的类型差异性、scanf参数不匹配时的表现。如果你能现场写出swap函数,并说明为什么不能传值,已经及格了。如果再能说清&arr + 1的步长是sizeof(arr),就已经超过一半候选人。如果再能结合const讲出“传指针不等于传修改权”,基本就是加分项了。

我强烈建议初学者做几个小实验:

  1. 打印&x和&x+1的差值,观察不同类型变量的步长。
  2. 打印arr和&arr,观察它们数值相同但+1后步长不同。
  3. 用printf打印a & b和a && b,在多个取值下对比结果。

这三个实验做完,对&的印象就不可能停留在“取地址”三个字上了。

6.4 最后一个技巧:使用静态分析工具

现代编译器对&相关的类型警告已经非常完善。用gcc编译时,建议开启-Wall -Wextra,把潜在的类型不匹配全部暴露出来。如果项目使用CMake,也可以考虑开启-Werror,让警告直接升级为错误,从根本上杜绝“警告被忽略、运行才爆炸”的情况。我在实际项目中看到过太多因为忽略warning而留下的隐患,严格编译选项是最便宜的质量保障。

以上是我个人在长期写C、看C代码、教C的过程中,围绕&这个符号沉淀下来的一些理解和踩坑记录。C语言里符号的“多重身份”其实是一个很好的切入点,搞明白它,等于同时理顺了指针、数组、函数传参、位运算四条主线。如果你读到这里,不妨把文中的小实验自己动手跑一遍,用的时间不会超过二十分钟,但换来的理解往往比读十篇博客更扎实。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询