ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C语言按位或运算符详解:从菜鸟教程第52题学会位运算

C语言按位或运算符详解:从菜鸟教程第52题学会位运算 如果你正在跟着菜鸟教程的C经典100例刷题大概率会和我当初一样在第52题前面多停留了一会儿。这一题的题目是“学习使用按位或 |”程序本身连十行都不到跑起来也只有两个输出但里面藏了三个新手特别容易忽略的考点八进制的书写规则、位运算的逐位逻辑、复合赋值运算符的写法。这篇文章就围绕练习52展开把位运算这个C语言里的“硬骨头”拆开揉碎讲清楚。它适合刚学完基本语法、想通过习题巩固基础的初学者也适合想快速复习位运算要点的嵌入式开发同行。看完你可以自己动手验证所有结论也可以把这题改成日常能直接复用的位操作工具函数。1. 练习52的题目与核心考点拆解1.1 题目原文与输出推演菜鸟教程的经典100例是很多人的入门题库第52题的原始代码长这样#include stdio.h int main() { int a, b; a 077; b a | 3; printf(b %d \n, b); b | 7; printf(b %d \n, b); return 0; }我第一次看到这个代码第一反应是077 是不是写错了会不会想表达 77这就是本题最阴险的地方。在C语言里以0开头的整数字面量是八进制不是十进制。077 表示的是八进制数“七十七”换算成十进制是7 * 8 7 63。它和十进制的77差了整整14一旦认错后面的推演全部跑偏。接下来逐行算a 077即 a 63十进制八位二进制是0011 1111。b a | 33 的二进制是0000 0011逐位按位或得到0011 1111仍是63。b | 7等价于b b | 77 的二进制是0000 0111再次按位或结果还是63。所以这段代码会连续输出两次b 63。完整推演过程我用表格列一下运算二进制8位示意十进制结果a 0770011 11116330000 00113a | 30011 11116370000 01117b | 70011 111163程序很短答案也很简单但我认为它的价值恰恰在简单里。一道题同时测试了你三个层面的理解你是不是认识八进制字面量你是不是真的懂“逐位或”是每一位单独运算你是不是知道|这种复合赋值运算符的含义三个点只要有一个模糊就会对结果产生怀疑。学习C语言怀疑自己、然后验证自己本身就是进步。1.2 为什么这道题值得反复做很多人刷100例就是图一个“刷完”看到52题这种连数组都没用上的题目会觉得太小儿科。实际上位运算是C语言里最贴近硬件的一组运算也是C语言区别于Java、Python这些高级语言的重要标志之一。在Java里你还得用BitSet或者封装好的工具类在Python里一个int可以无限长位运算更像数学运算。但在C语言里|、、^、、直接映射到底层指令操作的是真实的二进制位。这一点在单片机开发、嵌入式系统、网络协议解析里体现得特别明显。比如你写一个LED驱动一个寄存器字节的bit0到bit7可能分别控制8盏灯你要点亮第3盏灯最简单的方式就是REG | (1 2)你要熄灭它就是REG ~(1 2)。这些操作的本质正是练习52练的那件事把一个整数的某几个位置成1并且不影响其他位。所以别小看这几行代码。它看起来只是教学示例实际上是以后写驱动、写状态机、写协议收发模块的基础动作。把位运算的直觉练出来后面看寄存器手册、看数据手册里的位定义会顺畅很多。2. 从位或开始理解C语言六大位运算符2.1 六大运算符一览表C语言一共有6个位运算符。除了练习52的按位或|还有按位与、按位异或^、按位取反~、左移、右移。它们都是直接对二进制位进行操作的。运算符名称典型用途记忆口诀按位与清零、保留某些位有0则0|按位或置位、合并标志有1则1^按位异或翻转位、交换值相同为0不同为1~按位取反生成掩码0变11变0左移快速乘2的n次方置位方便左移补0右移快速除2的n次方取位方便无符号补0有符号看编译器初学者可以背住那张三行真值表aba ba | ba ^ b00000010111001111110这个真值表建议你亲手算一遍、写一遍比看一百遍都管用。做题的时候如果不想动脑可以先把两个数都转成二进制再逐位对照真值表结果一定不会出错。2.2 按位或的三大应用模型练习52反复用|那它到底有什么用我把实践中遇到的场景归纳起来按位或主要有三种应用模型。第一个模型是“置位”。假设你有一个8位的状态字节想在不改变其他位的情况下把第2位置1可以写status | (1 2)。这里1 2是0000 0100与原来的status按位或之后只有第2位被强制变成1其他位原封不动。这个操作在单片机寄存器赋值里几乎每天都在用。第二个模型是“合并标志/选项”。比如一个文件读写函数支持读标志1、写标志2、追加标志4调用时想同时具备读和追加能力就可以传1 | 4作为参数。每个标志位互不重叠用按位或拼成一个整型参数函数内部再用按位与去判断某个标志是否被设置。这也是Linux系统调用里open函数的经典做法。第三个模型是“保留字段/与掩码配合”。如果你想把变量低4位清0但保留高4位逻辑听上去复杂实战中只需要一条公式(x 0xF0) | (new_value 0x0F)。按位或在这里承担了“拼接”任务。你先把要保留的高半字节算出来再把要填入的低半字节算出来一或就合成目标值。2.3 优先级与括号陷阱位运算符的优先级在C语言里特别容易出错。按位与的优先级低于和!按位或|的优先级更低。这意味着什么呢看下面这行代码if (a b 0)很多人想表达“a和b按位与的结果等于0”但编译器实际解析成if (a (b 0))因为的优先级比高。b等于0时b 0为真值为1b不等于0时值为0。然后这个结果再和a做按位与。逻辑彻底跑偏而且这种问题极难定位因为编译器不会给你任何警告运行结果又模模糊糊“有点不对劲”。我的习惯是只要位运算参与表达式就统统加上括号。哪怕有些括号是多余的也比让下一个人或者一个月后的自己读代码时踩坑强。我在实际项目中见过因为少写一对括号导致的动态库崩溃问题排查了三个小时定位后就是一行“优先级”的锅。C语言有句调侃叫“括号不要钱”该用就用。3. 关于进制八进制、十六进制与位运算的纠缠3.1 0开头是八进制0x开头是十六进制练习52最容易被忽略的知识点其实是进制。C语言里整数字面量有三种常用写法十进制正常写八进制必须以0开头十六进制以0x或0X开头。所以077是八进制十进制是630x3F是十六进制十进制也是63。它们只是同一个数的三种“皮肤”而已。这个知识点在笔试里经常变成送命题。比如printf(%d, 010)输出什么答案是8因为010就是八进制10等于十进制8。再比如printf(%d, 0x10)输出16。很多人临场一紧张把八进制和十六进制搞混直接写错。练习52用077作为题眼其实就是逼你记住看到数字前面有个0先别急着当十进制。考试之外八进制还有一个非常接地气的应用场景Linux文件权限。chmod 777、chmod 644这些权限数字就是八进制因为一个权限位正好需要3个二进制位表示而八进制一位正好对应3个二进制位写起来方便又直观。权限755就是111 101 101三个字符分别对应owner、group、other的读写执行权限。理解了这一层你再看077就会觉得C语言当初这个设计是有道理的。3.2 进制转换的实用技巧我在调试代码时基本不会手动去把一个十进制数换算成二进制而是直接看十六进制。原因很简单十六进制一位对应4个二进制位八进制一位对应3个二进制位转换起来几乎不用思考。十六进制二进制00000100012001030011401005010160110701118100091001A1010B1011C1100D1101E1110F1111比如你在调试串口时读到一个寄存器值0x5A一秒钟就能心算出二进制是0101 1010bit0是0bit1是1bit3是1。哪一位是什么状态一眼看穿。如果用十进制90去理解你得多绕一整圈才能回推到位图。所以我强烈建议每个学C语言的人在刷题目的时候顺手练就“十六进制转二进制”的肌肉记忆。练习52里的077是一种提醒但日常调试里0x3F这种写法出现频率远高于八进制。3.3 常犯的错误把十进制当八进制在刷题社区里我看到过不少新手在讨论区问为什么52题运行结果是63不是77十有八九是把077当成了十进制的77。复制代码的时候手动重敲一遍漏掉了最前面的0或者把077理解成“77前面有个装饰”都会导致结果对不上。还有一种常见错误是格式化输入输出不匹配。printf(%d, a)以十进制输出而printf(%o, a)以八进制输出printf(%x, a)以十六进制输出。如果你用%d去打印一个本意是八进制的值不会有任何问题因为值已经被正确存储为二进制只是显示不同。反过来scanf用%o读入时用户输入077或直接输入77都会被当作八进制解析所以scanf(%o, a)后再printf(%d, a)输入的77会变成63。这个问题在键盘输入类编程题里经常出现值得自己实际跑一遍验证。4. 实操我建议你亲手做的5个变体练习4.1 变体1用位或合并两个BCD码当你能把52题的|和组合起来用就进入了一个特别经典的场景合成字节。比如你想把0x12和0x34两个十六进制数拼成一个0x1234代码可以这样写#include stdio.h int main() { unsigned char high 0x12; unsigned char low 0x34; unsigned short combined; combined (high 8) | low; printf(combined 0x%04X\n, combined); return 0; }这里(high 8)把0x12放到高字节得到0x1200再与low做按位或拼出0x1234。这个思路在通信协议里非常常见一个16位的数值往往要拆成高字节和低字节发送收到后再用移位和或拼回去。练习52你只看到|单独出现实战里它几乎总是和左移右移一起搭档。4.2 变体2用位运算实现大小写转换大小写转换是ASCII表相关的一个经典技巧。大写字母A到Z的ASCII码是0x41到0x5A小写字母a到z是0x61到0x7A差值是固定的0x20。所以#include stdio.h int main() { char c B; c | 0x20; // 转成小写 b printf(%c\n, c); c ~0x20; // 再转回大写 B printf(%c\n, c); return 0; }c | 0x20把第5位置成1小写字母的第5位本就是1大写字母的第5位是0所以一或就从小写方向靠。c ~0x20把第5位清成0就回到大写。虽然实际编程里有tolower和toupper函数可用但这个例子能帮你理解ASCII码的位设计逻辑而且它用到了|、、~三个运算符正好把52题学到的技能延伸到了真实字符处理上。4.3 变体3统计二进制中1的个数这是一个经典面试题也是检验你有没有真正理解“位”的好方法。思路不复杂不断把数字右移一位然后和1做按位与最低位是1就计数加一。#include stdio.h int count_ones(unsigned int value) { int count 0; while (value ! 0) { if (value 1u) { count; } value 1; } return count; } int main() { unsigned int x 0x5A; printf(0x%X has %d one(s)\n, x, count_ones(x)); return 0; }0x5A的二进制是0101 1010里面有4个1程序输出4。这个练习表面上是循环套路实质上是把、和计数器放在一起反复用。刷完练习52再做这个变体你会感觉“原来题目里的运算符是这么串起来的”。4.4 变体4用宏定义封装位操作项目里总不能每次写置位都写一遍x | (1 n)所以我们通常把它封装成宏。这个习惯我从51题、52题开始就一直推荐给学生#define BIT_MASK(n) (1u (n)) #define SET_BIT(x, n) ((x) | BIT_MASK(n)) #define CLR_BIT(x, n) ((x) ~BIT_MASK(n)) #define TOGGLE_BIT(x, n) ((x) ^ BIT_MASK(n)) #define GET_BIT(x, n) (((x) (n)) 1u)这套宏在单片机项目里几乎可以照搬。比如SET_BIT(LED_PORT, 2)就是把LED_PORT的第2位置1TOGGLE_BIT(LED_PORT, 2)则是翻转第2位跑马灯效果经常就是靠这个循环翻转实现的。练习52教了|你顺手把它变成宏以后写main函数时清爽非常多。4.5 变体5结构体位域与位运算的关系还有一种“隐晦的位运算”叫位域。C语言允许在结构体里按位定义成员#include stdio.h struct status { unsigned char led1 : 1; unsigned char led2 : 1; unsigned char led3 : 1; unsigned char mode : 5; }; int main() { struct status st {0}; st.led2 1; printf(size %zu, led2 %d\n, sizeof(st), st.led2); return 0; }位域的本质是编译器帮你完成底层的移位和掩码操作它和52题的|在逻辑上是一致的。不过位域在实际开发中有个麻烦不同编译器对位域的布局可能不一样跨平台可移植性并不好。所以我个人的建议是读得懂位域代码但真正需要稳定控制每一位时优先用宏加|、手动操作至少行为是明确的。5. 常见问题与排查技巧实录5.1 为什么我的输出是63而不是77这恐怕是练习52下面最常见的提问。除了漏掉前导0、误把八进制当十进制之外还有一个可能是你修改了printf的格式。原始代码用的是%d输出十进制所以是63。如果你觉得“077看起来像77”顺手把%d改成%o那你又会看到另一种结果%o会把63以八进制形式输出得到77。没错77又回来了这就是进制转换的乐趣同一个数从不同进制看长得完全不一样。排查这个问题的步骤很简单先把数字打印成%d、%o、%x三个格式看一眼同一个变量的三种表达立刻就能定位你是在哪一步产生了误解。5.2 有符号整数的位运算坑练习52里的a和b都是int在很多编译环境里int默认是signed这就会引入一个隐藏问题负数在内存里以补码形式存储它的最高位是符号位。比如-1在32位系统里是0xFFFFFFFF任何数和-1做按位或结果都是0xFFFFFFFF也就是-1。这和你数学课上“或运算能合并”的直觉完全不同。很多人在练习更大的位操作时发现结果“怎么永远是全F”就是因为拿有符号负数和正数做了按位或。我的处理原则是只要涉及位运算明确使用unsigned int或unsigned char。无符号数右移时高位补0行为确定有符号数右移的行为则由编译器决定可能是算术右移高位补符号位。把类型搞成显式的无符号能从源头避开一大批莫名其妙的问题。5.3 输入类题目的scanf陷阱与fgets替代练习52本身不需要用户输入但很多人在刷后续字符串类题目时会碰到一个特别恼火的问题scanf(%d, n)之后再调用fgets读取字符串发现读到的字符串是空的或者少读一行。这其实是scanf的一个经典副作用——它把输入里数字后面的换行符留在缓冲区里下一次fgets直接就把这个残留的换行符读走了。具体现象类似这样先输入一个整数回车再输入一行字符串fgets拿到的却是空行。排查思路是要么在scanf后面主动吃掉换行符比如getchar()要么干脆不用scanf统一用fgets把每一行都当作字符串读进来再用sscanf或者atoi转换成数值。#include stdio.h int main() { char line[100]; int number; fgets(line, sizeof(line), stdin); sscanf(line, %d, number); fgets(line, sizeof(line), stdin); printf(number %d, second line %s, number, line); return 0; }这种方式比混用scanf和fgets要稳定得多尤其是刷题时测试用例爱在一行输入多个数据用fgetssscanf的组合也能灵活拆解。另外做字符串逆序、按空格分词这类题目时fgets比scanf更适合处理含空格的整行输入这一点你在后面的题目里会深有体会。5.4 一个通用的位运算排查流程如果你写了位运算代码但结果不对我建议按这个流程走一遍而不是盯着代码干瞪眼。第一步打印关键变量的%d、%x、%o三种格式确认数值本身没问题。第二步把操作数临时改成全0和全1之类的极端值看看输出规律判断是不是真值表用反了。第三步检查运算符优先级把所有涉及、|、^的表达式都加上括号重新编译运行。第四步确认变量类型是unsigned。第五步把复杂的表达式拆成两行先算中间结果再继续配合printf看每一步的中间值。这套流程我自己在项目里用过无数次90%的位运算问题都能在第二步到第四步之间现出原形。6. 学习方法论菜鸟经典100例怎么刷才有效6.1 不要把100例当成题海很多人刷经典100例是奔着数量去的一天十道十天刷完然后觉得C语言入门了。我直接说结论这种刷法基本留不下什么东西。100例里的很多题目来自早期教材代码风格偏老难度也不算大它的价值不是让你做高难度算法而是让你把语法点真正用熟。比如练习52如果只是照着抄一遍跑一遍你记住的只是一堆输出结果如果你把题目改一改把|换成把077换成0x3F把| 7换成 ~7你才真正开始理解这个运算符的行为边界。我的建议是“一题多吃”抄写一遍原题手算一遍输出修改1-2处然后预判新输出再上机验证。这样一道题能顶四道题的效果。经典100例里从51题到55题正好是位运算专题把这五道连起来刷比单独刷一道52题收获大得多。6.2 如何从一道题延伸出知识树一道小题目可以牵出一条完整的知识树。以练习52为例它的上位知识是“整数在内存中的表示”下位知识是“位运算在寄存器编程中的应用”侧边还能挂上“补码与负数的位运算特性”、“大小端与字节序”、“printf和scanf的格式控制符”、“结构体位域”、“宏封装技巧”。我一直觉得刷题的最高效率不是按顺序刷而是按知识点簇刷。刷到52题时顺手把51题按位与、53题按位异或、55题按位取反一起看了再去做LeetCode里的“位1的个数”、“2的幂”这类简单题知识一下就活了。如果你是按教材顺序学也可以给C语言知识画一张地图数据类型与进制然后是指针之后是数组、字符串再是结构体和文件操作。位运算像一根线把进制、类型、宏、寄存器操作全部串起来。你想往单片机方向发展位运算是跳不过去的基础设施。6.3 练习工具与调试手段刷这100例工具不需要多高端。电脑上装一个gcc编译器配一个顺手编辑器就行不想在本地折腾环境也可以直接用在线的编译器网站把代码粘贴进去点一下运行结果就出来了。我自己的习惯是本地保留一个编译脚本专门用来跑这种小练习尽量不让环境问题干扰学习。调试位运算有一个小小的经验不要用眼睛硬看二进制直接用printf把十进制、十六进制、八进制都打出来对比。很多在线编译器还支持查看指定代码行的运行结果但老派的printf调试在C语言里永远有效。还有一个辅助技巧如果你要检查某一位是否为1可以临时构造一个掩码0x01、0x02、0x04逐个试探比一次性猜整个字节的要快得多。练习52这种题目我大概做过不下五遍每次带新人或者自己复习位运算时还是会从它重新过一遍。现在我写单片机驱动置位操作基本都用宏封装而那一组宏的雏形就是从变体4延伸出来的。建议你刷完这一题后别急着往下翻先亲手把|、、^、、组合起来玩一遍确认自己能在纸上写出每一步的每一位结果再进入下一个专题。C语言的位运算本质上是让你学会读懂硬件说话的方式值得你多花半天时间。
返回列表