资讯动态

C语言中取地址与按位与:一文彻底搞懂双重身份

发布时间:2026/10/15 17:33:26 来源:尧图企业网站定制
“”这玩意儿看着就一个符号但在C语言里它至少干着两件完全不相干的事一个是取地址一个是按位与。我教C语言这几年十个初学者里至少有八个在第一周就被它搞晕——今天写scanf忘了加明天判断奇偶又写成了if(a 1 0)后天看别人代码里有个还以为是按位与的加强版。这篇就把的多重用途彻底掰开揉碎从“它到底是什么”讲到“实战里怎么用”再附上我自己调试时踩过的坑。不管你是刚学C语言的本科生、准备计算机二级的考生还是用单片机做开发的嵌入式工程师这篇文章都值得花十分钟读完。读懂它你就能理解C语言里一半的指针语法和一半的位运算技巧这两样恰恰是C语言最核心的难点。先说个总体的判断在C语言里就两个身份一个指向“内存地址”一个指向“二进制位”。这两个身份背后对应的是C语言最底层的内存模型和整数二进制表示模型。很多初学者喜欢用“地址”和“位运算”这种死记硬背的方式区分但真正理解它的关键是搞清楚编译器到底怎么看待这个符号——它取决于后面的操作数是什么类型以及它所在的表达式上下文。下面我一条条拆。1. 先分清的两个基本身份取地址与按位与1.1 取地址运算符变量在内存里的“门牌号”先看第一种也是初学者最先接触到的场景——scanf里那个让人又爱又恨的int age 0; scanf(%d, age);为什么这里必须写age因为scanf需要的不是age的值而是age在内存里的地址。你可以把内存想象成一栋公寓楼每个房间都有一个门牌号变量age就是住在一楼的某个房间里。scanf像是一个快递员它要往这个房间送东西必须知道门牌号也就是age。如果你直接传age相当于快递员拿到的是房间里的旧家具不是门牌号东西自然送不进去。在C语言中取地址运算符的作用就是“获取变量在内存中的起始地址”。它只能作用于内存中确实存在的对象也就是左值。这句话什么意思就是说你不能对常量取地址比如100就是非法的也不能对表达式取地址比如(a b)同样非法因为(a b)的结果只是临时算出来的一个值它没有固定的内存位置。但是你可以对数组元素、结构体成员取地址因为它们都是实实在在的内存对象。用printf验证一下会非常直观int a 42; printf(变量a的值: %d\n, a); printf(变量a的地址: %p\n, (void*)a);这里提醒一下打印地址%p才是正规写法很多教材用%x或%d打印虽然能出结果但那是未定义行为换平台就翻车。1.2 按位与运算符二进制位上的“AND门”第二种身份是二元运算符叫按位与。它对两个整数的每一位分别做逻辑与操作只有当两个对应位都是1时结果的那一位才是1否则是0。逻辑表只有四条0 0 00 1 01 0 01 1 1举个例子十进制5的二进制是0101十进制3的二进制是00115 3的结果就是0001也就是十进制1int result 5 3; // 结果是1按位与后的结果不再像取地址那样是一个指针而是一个整数。这一点要牢牢记在心里取地址是单目运算符只有一个操作数结果是地址类型是指针按位与是双目运算符需要两个操作数结果是整数。我把两者的区别做成一张对照表方便你随时查阅对比维度取地址按位与运算符性质单目运算符双目运算符操作数个数1个2个操作数类型变量/数组元素/成员等左值整数类型运算结果指针地址值整数典型场景scanf传参、函数传址掩码、清位、判断特定位示例ageflags 0x011.3 为什么同一个符号要干两件事这个问题很多人问过C语言设计者为什么不分两个符号答案是历史原因加语法自解释性。作为取地址符诞生时C语言还很年轻当时的语言设计追求简洁符号数量控制得比较严。而后缀表达式和单目运算符在语法上天然不会和双目运算符冲突——因为取地址只有右边有操作数按位与左右两边都有操作数。编译器在读入之后如果发现下一个token不再是运算符、而是一个表达式并且这个是作为某个表达式的最左侧起点那它就可以判断出这是取地址运算如果左边已经有一个表达式了那就是按位与。所以C语言敢于让一个符号身兼两职语法上并不会产生歧义。不过这对初学者就不太友好了。我的建议是你先别背“什么时候用哪个”而是多看代码、多做练习看到就停下来问自己这个的上文有操作数吗如果左边空着那大概率是取地址如果左边已经有一个变量、常量或表达式那一定是按位与。这个判断习惯比死记硬背牢靠得多。2. 取地址的核心应用指针、scanf与函数传参2.1 scanf里为什么必须写什么时候可以不写先解决最常见的疑惑。scanf的每个格式说明符都对应一个“地址值”它要根据地址找到变量把读入的数据写进去。如果你写scanf(%d, age)相当于把age当前的值当成了地址——比如age是0那它就会往地址0上写数据轻则崩溃重则产生各种诡异的内存错误。那什么时候可以不写答案是当操作数本身已经是地址的时候。最常见的是数组名和指针变量char buf[100]; scanf(%s, buf); // 不用写buf因为数组名本身代表数组首元素地址 char *ptr buf; scanf(%s, ptr); // ptr本身存的就是地址不用再取地址还有结构体变量的数组元素写scanf(%s, stu[0].name)也不需要因为name是数组名。这个规则用一句话概括如果传给scanf的参数本身已经是个地址就直接传如果是个普通变量就用取地址后再传。我见过不少同学把scanf(%s, buf)这种写法写出来——编译不报错运行可能也没事但它传给scanf的类型是char()[100]也就是“指向整个数组的指针”类型和char不一致严格说属于未定义行为只是很多编译器睁一只眼闭一只眼。2.2 函数参数里的传值还是传址差之毫厘谬以千里C语言默认的参数传递方式是值传递。什么意思就是你把变量a传给函数函数里拿到的只是a的值副本你在函数里修改它不影响外面的a。想让函数能修改外部变量就不传a的值要传a的地址也就是avoid swap(int *x, int *y) { int temp *x; *x *y; *y temp; } int main() { int m 3, n 5; swap(m, n); // 传地址才能改到m和n printf(m%d, n%d\n, m, n); return 0; }这里的关键点在于m产生了一个临时指针值这个值本身也是按值传递的但函数拿着这个地址通过解引用操作*x就能间接访问main函数里的m。你可以把地址想象成一把钥匙值传递传的是钥匙的复制品但复制品也能开门。所以判断一个函数参数要不要加核心标准就一条你需不需要让函数修改外部变量的值需要就传地址不需要就直接传值。这也是后来C里引用被引入的原因——C的引用本质上是个语法糖编译器在底层还是用地址但程序员不用在那个参数前写、在函数内部也不用解引用代码写起来更自然。但你要明白C语言里的取地址和C里的引用虽然看起来都是但语法地位完全不同。2.3 结构体、链表与二级指针在复杂类型中如何发力取地址运算在处理复杂类型时特别有用。先看结构体typedef struct Student { int id; char name[32]; } Student; Student stu; initStudent(stu); // 传结构体变量地址避免整个结构体拷贝这里用stu传给函数函数内部通过指针修改成员。结构体如果直接按值传递整个结构体要被拷贝一份数据量大时效率低用指针只需要复制一个地址值效率高得多。这也是C语言里大量函数都接收结构体指针的原因之一。再看链表插入节点的场景。链表是C语言面试和计算机二级考试的常客它的核心就是靠指针把节点串联起来。插入头节点时很多人写错typedef struct Node { int data; struct Node *next; } Node; // 下面这样写是错误的 void insertHead(Node *head, int value) { Node *newNode createNode(value); newNode-next head; head newNode; // 只是修改了局部变量head外部head根本没变 }为什么会错因为head参数是个指针值但它本身是局部变量函数内给head重新赋值不影响外部的head。要让外部head也指向新节点必须传head也就是二级指针void insertHead(Node **head, int value) { Node *newNode createNode(value); newNode-next *head; *head newNode; } // 调用时 insertHead(head, 10);这就是和二级指针的组合用法。初学者第一次看到head会头皮发麻你只要想清楚head本身是一个指向Node的指针变量它也有自己的地址head就是Node类型。函数接收这个二级指针通过head读到外部head里面存的地址再通过head newNode把新节点的地址写回外部的head。一层一层剥开逻辑其实很清晰。3. 按位与的实战应用掩码、清位与位域操作3.1 用掩码提取特定位判断开关、权限与状态按位与的经典应用是掩码。什么叫掩码就是用一个二进制模式从另一个数里“筛出”某些位。比如你想判断一个变量的最低位是0还是1unsigned char flags 0b10110110; if (flags 0x01) { // 最低位是1 } else { // 最低位是0 }这里的0x01就是掩码。按位与运算会把flags除了最低位之外的所有位都“清成0”只保留最低位如果最低位是1结果就是1if条件为真如果最低位是0结果就是0if条件为假。这比用算术方法求余数要快得多也更符合底层思维。多个开关状态经常打包在一个寄存器或一个变量里。比如一个字节有8个bit可以表示8个开关。单片机开发里经常操作的就是寄存器比如某个寄存器bit4是中断使能位你想读取它的状态就写if (reg (1 4)) { // bit4为1中断使能 }这里的(1 4)就是预先算好的掩码0x10。我建议你在代码里用宏定义把掩码命名清楚别直接写魔法数字否则过一个月自己都看不懂#define INT_ENABLE_MASK (1 4) // bit4中断使能 if (reg INT_ENABLE_MASK) { // 已使能 }3.2 清除某一位、保留某些位位运算的“橡皮擦”按位与还有一个重要用处是“清位”就是强制把一个变量的某些位变成0。思路是让想清除的那一位和0做与运算那一位置零想保留的位和1做与运算。比如你有一个8位寄存器想清除高四位、保留低四位reg reg 0x0F; // 高四位清零低四位保留这里的0x0F是00001111高四位都是0与1一遇就清零低四位都是1原样保留。这就是按位与在寄存器配置里最常见的操作。再看一个综合例子假设你用8个bit管理一个硬件设备的8个状态bit3是电源开关1代表开、0代表关。要关闭电源而不影响其他位reg ~(1 3); // (13)是00001000取反后是11110111先把(1 3)取反得到的掩码中只有bit3是0其他位都是1再和reg相与reg的bit3被清零其他位保持不变。这一招在嵌入式开发里几乎是每日基本功很多初学者看到和~的组合就懵实际上你把二进制展开一眼就懂。3.3 判断奇偶、实现对齐和位域中的判断一个整数是奇数还是偶数用按位与最高效if (num 1) { // 奇数 } else { // 偶数 }原理是二进制的最低有效位是1就一定是奇数是0就一定是偶数。取模num % 2也能判断但编译器在优化级别较高时通常会把num % 2优化成num 1所以你直接写成 1既直观又给编译器省事。内存对齐判断也常与有关。比如你想判断一个地址是否8字节对齐可以写if ((addr 0x07) 0) { // 地址是8字节对齐的 }因为8字节对齐意味着地址的低3位都是0。malloc返回的地址通常已对齐但你自己做内存池管理时这个判断就很实用。位域在结构体里也常常和按位与搭配。C语言允许程序员定义结构体成员占几个bit叫位域struct Status { unsigned char power : 1; // 占1bit unsigned char mode : 3; // 占3bit unsigned char erties : 4; // CPU类型占4bit };虽然位域本身的语法用的是冒号而不是但它和按位与是孪生兄弟——编译器在底层通过掩码和移位来读写这些成员原理就是按位与、按位或、按位取反。硬件寄存器驱动里这两种技术经常配合使用理解位掩码才能理解位域。3.4 单片机和寄存器场景宏定义与的配合单片机开发是的重度使用场景。以最常见的配置寄存器为例比如你想配置一个GPIO引脚的模式寄存器#define GPIO_MODE_INPUT (0 2) // 输入模式bit20 #define GPIO_MODE_OUTPUT (1 2) // 输出模式bit21 void gpio_set_mode(unsigned int *reg, unsigned char mode) { *reg (*reg ~(3 2)) | mode; // 先清bit2、bit3两位再写入新值 }这段代码的思路是“清位再置位”~(3 2)把bit2和bit3位清零其他位保持不变然后按位或把mode的值写进去。这比直接*reg mode要安全得多因为你只改了自己想改的两位不影响其他位。很多新手写寄存器驱动时直接赋值结果把别的配置冲掉了调试半天发现是这里的问题。注意寄存器地址往往需要强转成volatile指针再访问比如(volatile unsigned int *)0x40021000volatile是为了防止编译器优化掉对内存的重复读取。4. 最容易踩坑的混淆场景语法细节与优先级陷阱4.1 与一字之差天壤之别这是C语言初学者最高频的错误之一。是按位与是逻辑与。逻辑与的结果只有0或1它表示“两个条件同时为真”按位与的结果可以是任意整数它表示“每一对二进制位做与运算”。典型错误写法// 错误想判断a和b都非零但用了按位与 if (a b) { // ... } // 正确逻辑与 if (a b) { // ... }a和b如果都是1那a b的结果是1碰巧没问题但a2、b1时a b的结果是0而a b的结果是1两者就分道扬镳了。你写代码时心里想的通常是“两个条件同时成立”应当用只有明确要做位运算时才用。一个记忆技巧是single、bitwise是double、logical。4.2 运算符优先级比和!低得多C语言的运算符优先级非常反直觉。按位与的优先级比关系运算符和!还要低这意味着下面这段代码if (flags 0x01 0) { // 你以为在判断“最低位是否为0” }实际被解析成了if (flags (0x01 0)) { // 先比较0x01和0结果是0然后flags 0结果恒为0 }这个if永远为假而且编译器还不一定给出警告。排查这种bug需要瞪大眼睛盯半天。正确写法是一律加括号if ((flags 0x01) 0) { // 最低位确实为0 }我自己的习惯是只要表达式里同时出现位运算符和比较运算符不管有没有歧义都加括号。这个习惯看起来多余实际上能省下大量排查时间。不只是按位与的优先级也比赋值运算符高比逻辑与低。举个例子a b || c的求值顺序是(a b) || c因为位运算的优先级高于逻辑或。写这种代码时如果不加括号阅读的人很容易产生误解。4.3 与数组名、字符串字面量的特殊关系再来说一个容易在高阶场景踩坑的点数组名和数组名的区别。int arr[10]; printf(%p\n, arr); // 数组首元素地址类型是int* printf(%p\n, arr[0]); // 同上int* printf(%p\n, arr); // 整个数组的地址类型是int(*)[10]arr和arr[0]打印出来的数值通常一样但arr的类型完全不同。sizeof(arr)看起来是8字节64位系统但arr 1会跳过整个数组也就是增加10*sizeof(int)个字节而不是增加一个int的大小。这个差别在指针算术里体现得淋漓尽致很容易在二维数组传参、函数指针等场景里翻车。字符串字面量也有类似的问题。char *s hello; 这里的s本身指向字符串字面量你可以写s world让s指向别的字符串但不能写scanf(%s, s)去修改这个字符串——修改字符串字面量是未定义行为。如果要用scanf读字符串先给s分配空间char buf[128]; scanf(%s, buf); // buf是数组名本身就是地址不用4.4 取地址与解引用的对称关系与*的互逆操作最后补充一个理解的重要角度和*互为逆运算。是解引用把地址变成地址所指向的变量是取地址把变量变成它的地址。对于任何合法变量a(a)都等价于a。这个对称性不是废话它是理解指针的关键。上代码演示int x 10; int *p x; // p指向x printf(%d\n, *p); // 10通过p解引用访问x printf(%d\n, *(x)); // 10先取x地址再解引用很多指针难题本质上都是这几层关系没理顺。比如函数传参时想修改指针变量本身就要用指针的指针想修改指针指向的内容就用一级指针。这个选择标准就是你现在操作的是“指针变量”还是“指针指向的变量”。前者需要取地址再传后者直接传指针值即可。5. 进阶避坑及实用调试技巧5.1 判断一个表达式到底是“取地址”还是“按位与”的三个方法代码读多了你会在别人代码里看到大量混用的情况怎么快速判断我总结三个实用方法。方法一看操作数个数。单目运算后面只有一个操作数肯定是取地址双目运算前后各有一个操作数肯定是按位与。这是最可靠的语法区分。方法二看结果使用方式。如果结果赋给指针变量或者作为参数传给指针形参那大概率是取地址如果结果参与算术运算、位运算、逻辑判断那大概率是按位与。比如intp aa结果赋给int显然是取地址if ((x mask) ! 0)显然是按位与。方法三看上下文语义。如果它出现在scanf的实参、函数的地址传递、指针初始化的地方那就是取地址如果它出现在位掩码判断、寄存器操作、权限校验、状态提取的代码里那就是按位与。三种方法互相印证基本不会出错。5.2 调试指针和地址时的打印技巧%p与类型转换调试时打印地址务必用%p。%p通常打印十六进制地址格式是0x开头不同编译器略有差异它比用%d打印地址要安全得多。地址值在64位系统下是8字节用%d打印只会读到低4字节输出的数字完全没意义。打印的时候要注意类型转换int a 0; int *p a; printf(a的地址: %p\n, (void *)p);printf的%p期望接收一个void类型你传int进去严格说格式不匹配最好显式转换成(void*)。虽然大多数编译器不报错但严谨一点没坏处。5.3 位运算在负数、大小端和可移植性上的注意点按位与运算在负数上也有讲究。C标准规定有符号整数做位运算时使用的是补码表示。比如-1的二进制表示是全1的补码那么-1 0x0F的结果是15也就是0x0F这个结果你可能觉得意外但它是符合规范的。不过可移植性问题就在这里有符号整数的右移是算术右移还是逻辑右移由实现定义负数左移更是未定义行为。所以做位运算时我强烈建议尽量使用无符号类型unsigned int或unsigned char。这样位运算的行为才完全由标准定义代码换平台结果也是一样的。大小端问题也影响位运算。同一份代码在一个小端机器上和大端机器上如果你用指针逐字节访问一个整数读到的字节顺序是不同的。但按位与操作本身不受大小端影响因为它是编译器层面的位运算不是内存层面的字节操作。把这两件事分开遇到大小端问题时就不会蒙圈。5.4 一个综合练习题用和位运算实现权限管理系统理论说再多不如动手写一个综合示例。下面我用实现一个简化版权限系统把掩码、判断、置位、清位全部串起来#include stdio.h #define PERM_READ (1 0) // 读权限 0x01 #define PERM_WRITE (1 1) // 写权限 0x02 #define PERM_EXEC (1 2) // 执行权限0x04 void print_perm(unsigned char perm) { printf(权限: ); if (perm PERM_READ) printf(读 ); if (perm PERM_WRITE) printf(写 ); if (perm PERM_EXEC) printf(执行); printf(\n); } int main() { unsigned char perm 0; // 提升为读写权限 perm | (PERM_READ | PERM_WRITE); print_perm(perm); // 输出权限: 读 写 // 检查是否有执行权限 if (perm PERM_EXEC) { printf(有执行权限\n); } else { printf(没有执行权限\n); } // 撤销写权限保留其他 perm ~PERM_WRITE; print_perm(perm); // 输出权限: 读 return 0; }这个例子里的|是置位~是清位是判断。你在权限系统、单片机寄存器、嵌入式状态标识里见到的所有位运算套路本质上都是这三个操作来回组合。把这三招练熟位运算这块基本就通了。5.5 常见问题速查表和编译警告的利用我把评论区和高频提问整理成一张速查表帮你快速定位问题报错或异常现象常见原因解决办法scanf读取后变量没变化忘了写传了值而非地址检查scanf实参是否加或本身已是地址编译报错“lvalue required”对常量或表达式取地址取地址只能作用于变量、数组元素等左值程序运行崩溃或乱码scanf的%c和%s混用或格式符与参数类型不匹配清空输入缓冲区统一传地址类型位判断失效if总进不去与优先级搞错写成(expr) 0务必加括号数组名1跳了老远误用了arr做指针运算区别arr和arrarr加1跳过整个数组链表插入后头指针没变形参指针被重新赋值没改外部指针用二级指针Node**传head打印地址值不对用%d打印指针改用%p并强转(void*)调试时还有个容易被忽略的工具编译警告。-Wall -Wextra一定要开起来很多位运算和类型不匹配的警告会被编译器揪出来。比如在gcc下编译gcc -Wall -Wextra -o demo demo.c如果有“format ‘%d’ expects argument of type ‘int’, but argument has type ‘int *’”这样的警告千万别忽视直接修复。一个不写警告的代码库比一个编译满屏警告的代码库debug时间节省一半以上。最后分享两个实用小技巧我在实际调试中养成了一个习惯凡是遇到指针、地址相关的bug先用printf把关键变量的地址打出来确认它指向哪里。很多时候你以为是算法问题其实是某个地址没初始化、某个结构体成员取模没对齐、某个函数的指针参数被错误地写成了值参数。打印地址能让你把指针的“指向关系”可视化比对着代码发呆高效得多。第二个小技巧是写位运算时统一用宏定义命名掩码千万不要在代码里散落0x01、0x80这种魔法数。有意阅读性二来重造代码时只要改宏定义一处就能全局更新。我现在写代码哪怕只是判断一个bit都会先define一个MASK宏再使用这个习惯帮我避免过好几次因为魔法数字写错导致的稀烂bug。这一套练下来在你手里就不再是“那个奇怪符号”而是取地址和位运算的钥匙打开C语言底层世界的那扇门。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑