ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

位运算六种运算符详解:从寄存器配置到协议解析

位运算六种运算符详解:从寄存器配置到协议解析 位运算这六个符号我在嵌入式岗位和上层应用开发里来回折腾十年发现一个现象科班出身的人觉得它理所当然半路出家的开发者则能躲就躲。但一旦你真正理解它看寄存器手册、调协议解析、做性能优化都会觉得“原来这么简单”。今天这篇不整虚的我把位与、位或|、位非~、异或^、左移、右移这六个运算符一次讲透结合单片机控制、Python刷题、通信协议解析、ADC数据处理这些热搜里的真实场景说清楚它们是什么、怎么用、为什么这么用。先说清楚这篇适合谁刚接触编程、被位运算绕晕的初学者写嵌入式代码但每次配置寄存器都要翻手册的开发者准备面试刷题、经常遇到“用位运算实现XX”的求职者。看完你不需要背任何口诀只需要理解二进制这层皮以及六个运算符各自在干什么。1. 位运算之前必须先建立的两个底层认知很多教程上来就写“位与就是两个都是1才为1”然后丢几个例子。这种教法最大的问题在于你记住了真值表但遇到实际问题还是不知道用哪个运算符。我建议先补两个更底层的东西理解了它们六个运算符就是水到渠成的事。1.1 计算机里的数据本质就是一排开关任何数据类型——int、float、char、bool——在内存里最终都是一串二进制位。一个二进制位只能是0或1就像一排开关断开是0闭合是1。8个开关组成一个字节Byte4个字节组成一个int在大多数32位/64位平台上。位运算就是直接在这排开关上操作把某些开关拨到1、把某些开关拨到0、或者把所有开关的状态反过来。这个类比不是比喻而是事实。CPU里的ALU算术逻辑单元做加减乘除最终也是拆成一堆逻辑门操作。比如加法底层就是用异或门计算本位和、用与门计算进位。所以当你用位运算时其实是在用最接近硬件的方式写代码——这也是为什么嵌入式开发者几乎离不开位运算。1.2 有符号数的补码表示位非的陷阱之源第二个必须搞清楚的是负数在计算机里怎么存答案是补码。一个负数的补码是“对应正数取反加一”。比如-1正数1的二进制是00000001取反得11111110加一得11111111所以-1在8位下就是0xFF。这也是为什么位非~1的结果不是0而是-2对00000001取反得到11111110按补码解读最高位是1所以它是负数再转回十进制就是-2。很多人在这一步懵掉是因为把“位非”和“取负号”搞混了。~x和-x不是一回事它们的关系是-x ~x 1。理解补码之后再去看位非、右移、异或都会顺畅得多。我在面试别人时经常问“~5等于多少”能一口答出-6的人说明对补码是真理解了。2. 位与掩码、取位与清零的底层工具位与的规则一句话两位同时为1结果为1否则为0。对应到生活场景就是“两个开关都闭合电路才通”——串联电路。这个运算最核心的应用是“掩码”Mask即用一串二进制位去“过滤”另一串二进制位。2.1 用取一个字节的指定比特位我得先坦白一个踩过的坑刚学单片机时要判断P1端口第3位是不是高电平我写的是if(P1 0x08)结果是错的。为什么因为P1端口可能同时有其他引脚为高直接比较0x08要求整个端口必须是00001000而实际上只要第3位是1就行其他位无所谓。正确做法是if(P1 0x08)。0x08就是00001000位与之后只有第3位能通过掩码保留下来其他位全部被清零。如果第3位是1结果是非零值条件成立如果第3位是0结果就是0条件不成立。这就是掩码的“过滤”作用——只关心你关心的那一位屏蔽其他所有干扰。这个方法在寄存器状态判断里太常用了。比如一些MCU的状态寄存器bit0表示忙闲、bit1表示错误你要判断bit1就写if(reg 0x02)。记住一个经验拿不准端口状态时不要用直接比较优先考虑掩码。2.2 用清零特定位保留其他位另一个高频操作是“清零某一位但不动其他位”。比如你要把一个寄存器的bit2清零让某个外设停止工作直接赋值0xFB即11111011看起来很对但万一你记不清寄存器当前值怎么办直接赋值会覆盖掉其他位的状态。正确姿势是“先取反再位与”reg ~0x04。0x04是00000100取反得11111011位与之后reg的bit2被清零其他位保持不变。这个写法的好处是只动目标位配合“读-改-写”流程是寄存器操作的基本功。时间长了你会发现reg | bit用于置位reg ~bit用于清零这是一对黄金组合。2.3 判断奇偶甚至子网掩码的拓展玩法判断一个数是奇数还是偶数最低位是1就是奇数所以n 1比n % 2更直接而且在某些性能敏感场景更快。判断一个数是不是2的幂(n (n-1)) 0是经典写法。这是因为2的幂在二进制里只有一位是1减一之后那一位变成0低位全变1两者位与必然为0。还有网络里的子网掩码本质也是位与运算IP地址与子网掩码做位与得到网络号。所以位与不是只在单片机小圈子里有用它贯穿了整个计算机体系。3. 位或|置位、合并与权限组合位或的规则两位只要有一个为1结果就为1。对应到生活就是并联电路——任何一个开关闭合灯就亮。它的核心价值在于“合并”和“置位”。3.1 用|将指定位置1而不影响其他位还是拿寄存器举例。你要把某个外设的使能位bit3置1同时不打扰另外几个已配置好的位。写法是reg | 0x08。0x08是00001000位或之后bit3变为1其他位保持不变。原理很简单1或上任何数都是10或上任何数保持原样。所以bit3会被强制置1其他位因为或的是0原值不变。这个操作在GPIO输出配置里用得最多。比如STM32的GPIOx_BSRR寄存器置位就是用位或。我也见过新手直接REG 0x08结果把其他位全冲掉了查了半天bug最后发现是这里的问题。所以请记住口诀“置位用|清零用~判断用翻转用^”。这四句话基本覆盖了寄存器操作的全部场景。3.2 用|合并标志位和权限位另一种常见用法是组合多个选项。比如一个系统里定义了这几个权限读权限0x01写权限0x02执行权限0x04。要同时给用户读和执行权限就用0x01 | 0x04结果是0x05二进制是00000101。要判断有没有写权限就permission 0x02。要移除执行权限就permission ~0x04。这套模式在很多API里都能看到比如打开文件时O_RDONLY | O_WRONLY之类的宏定义本质就是位或合并。把这个例子跑一遍位或在权限系统里的价值就一目了然了。它的背后是“一个整数里多个比特位互不干扰”的核心思想——每个bit独立表达一个信息组合起来就是一组标志。3.3 数据拼接把多个小字段打包进一个寄存器协议解析里经常要把多个小字段打包成一个整数。比如一个16位寄存器高4位表示小数部分低12位表示整数部分。你可以用((int_part 0x0FFF) | ((frac_part 0x000F) 12))来拼接。这里的核心是先左移把字段放到正确位置再用位或合并。位或在这里不是“加上”而是“合并空白区域”——每个字段只占据自己那几位不会重叠所以可以直接或。4. 位非~取反的边界与补码陷阱位非的规则0变11变0。它看起来简单但实际出错率非常高原因几乎都出在“操作数的位宽”上。4.1 位非结果取决于数据类型宽度看个经典问题~0等于多少如果你只盯着8位看~00000000会得到11111111也就是255。但在绝大多数现代平台上0是int类型占32位所以~0实际是0xFFFFFFFF按有符号int解读就是-1。如果你用unsigned char类型的变量~(unsigned char)0才是255。这个差异在嵌入式场景里尤其要命。有一次我配置一个8位寄存器想把它全部置1写了REG ~0;编译后寄存器变成0xFFFFFFFF高位全被截断了但截断前已经污染了相邻内存。这种bug非常隐蔽解法是强制类型转换REG (uint8_t)~0;或者干脆REG 0xFF;。所以用位非时一定要清楚操作数是什么类型、位宽多少不然就是埋雷。4.2 位非与位与配合实现“保留位清除”前面说清零特定位用reg ~0x04这里位非的作用就是构建一个“除了目标位为0其他全为1”的掩码。这也是我见过位非最高频的用法。你可以把~0x04理解为“一个挖了洞的挡板”只有目标位那个洞能通过位与被清除其他位都被挡板保护。不用位非也能清但你需要手写一个完整掩码比如8位寄存器要清bit2就得写reg 0xFB。一旦位宽变成16位你还得写成0xFFFB很容易算错。所以用位非取反生成掩码本质是让编译器帮你算而不是人肉算。4.3 位非在总线空闲、信号反向里的应用在一些通信协议里总线空闲状态是高电平激活状态是低电平。如果你拿到一个“低电平有效”的信号量想把它转成“高电平有效”的逻辑值~signal一句话就能反向。硬件上很多引脚是“低有效”比如一些芯片的复位脚RESET_N软件里判断时取反一下逻辑会更直观。不过这属于很小众的应用知道有这么回事就行主场景还是掩码构造。5. 异或^加密、交换与校验异或的规则两位相同为0不同为1。这个运算符在热搜词里出现频率极高因为它有三个让人称奇的特性自反性、可逆性和半加性。说人话就是一个数异或同一个数两次会变回原来的数异或可以做无进位的加法异或可以用于奇偶统计。5.1 异或的自反性最简单的对称加密假设你有明文data密钥key密文就是data ^ key。要解密只需要密文 ^ key由于自反性结果又回到data。用这个思路可以实现一个极简的加解密函数比如uint8_t encrypt_decrypt(uint8_t data, uint8_t key) { return data ^ key; }同样的函数加密和解密都能用。虽然这种算法在安全强度上很弱但在一些资源受限的单片机场景用于隐藏敏感信息、防止别人直接肉眼看内存数据还是够用的。最新热搜词里的“同或和异或逻辑符”也印证了大家在找这类资料。顺便提一句同或XNOR就是异或取反规则是相同为1、不同为0两者互为补集。5.2 不引入第三个变量交换两个数经典面试题来了不借助临时变量怎么交换a和b答案是三连异或a a ^ b; b a ^ b; a a ^ b;推导一下第一行后a a^b第二行b (a^b)^b a^(b^b) a第三行a (a^b)^a b。这个技巧看起来神奇但我得提醒一句在实际工程里直接用一个temp变量更清晰、更安全因为可读性比炫技重要。不过这个例子用来理解异或的自反性可以说是最好的教材。5.3 用异或统计出现奇数次的数字LeetCode经典题一个数组里只有一个数出现奇数次其他都出现偶数次找出它。解法是遍历所有数异或。因为相同的数异或为0所有出现偶数次的数会两两抵消最后剩下的就是那个出现奇数次的数。这个解法是异或“自反性”的直接推论。我在面嵌入式岗位时也很喜欢问这道题因为它能同时考察位运算功底和算法思维。5.4 用异或翻转特定位如果说位与用于清零、位或用于置位异或就是“翻转”。flag ^ 0x01如果bit0原来是0就变1原来是1就变0其他位不受影响。在一些需要切换状态的场景比如按一下按键切换LED亮灭用异或比“读-判断-写”要简洁得多。5.5 校验和里的异或通信协议中异或校验经常用于帧校验。把一帧数据除校验字节外逐个异或得到的结果附加在帧尾接收端把所有字节含校验异或结果应为0。这和CRC比强度弱很多但实现简单在很多低成本设备里仍然大量使用。我之前做一个无线温湿度采集项目用的就是异或校验因为主控FLASH只有1KBCRC表根本装不下。6. 左移乘2的本质与数据的“腾位”左移的规则所有二进制位向左移动右边补0左边溢出丢弃。它最直观的数学意义是“乘以2的n次方”这点几乎所有教程都会讲但实际工程里的用法远不止于此。6.1 左移N位等价于乘以2的N次方以5为例二进制00000101左移一位得00001010即10左移两位得00010100即20。所以5 2等于20也就是5乘以4。原理是二进制位权向左挪一位每个1的位权翻倍整体数值自然翻倍。不过要小心左移可能导致溢出——比如8位无符号数0xFF左移一位变成0xFE但实际应该是510超出8位范围就被截断了。所以用左移做乘法要确保结果在类型范围内。6.2 左移在寄存器字段配置中的核心地位这是左移最重要的工程场景。一个寄存器里可能包含多个字段比如bit7:4是分频系数bit3:0是工作模式。要写入分频系数5到bit7:4就需要两部分先左移把5挪到正确位置(5 4)得到01010000再与其他字段做位或合并(5 4) | (mode 0x0F)。热搜词里“基于单片机的广告灯左移右移控制程序流程图”就是这个应用的外围。广告灯一个一个点亮本质就是用一个不断左移的变量驱动LED端口。比如从0x01开始每循环一次左移一位0x01→0x02→0x04→0x08→0x10接在LED上就是流水灯依次点亮。这是每个学单片机的人都会写的小实验但它把移位和硬件驱动的映射关系演示得明明白白。6.3 左移拼接高低字节数据通信中常遇到“多个字节拼成一个大数”的需求。比如两个8位字节high和low拼成一个16位整数应该是(high 8) | low。high先左移8位腾出低8位空间再用位或把low放进去。如果没有左移直接high | low两个字节会互相重叠数据全乱了。在做MODBUS协议解析、传感器数据读取时这个操作几乎天天要用。7. 右移除法、逻辑右移与算术右移的分岔口右移比左移复杂一档因为涉及到符号位和负数这在热搜词里也能看到大家踩坑的痕迹。右移的规则是所有位向右移动低位溢出丢弃但高位补什么取决于“逻辑右移”还是“算术右移”。7.1 无符号数的逻辑右移高位补0对unsigned类型来说右移就是简单的除以2最高位补0低位溢出丢弃。比如无符号数0xF0240右移一位得0x78120正好是原来的1/2。在做无符号数据处理、DMA缓冲区解析、位图数据调整时逻辑右移是默认操作。7.2 有符号数的算术右移高位补符号位对有符号int来说右移通常是算术右移——左边补的是符号位。正数最高位是0补0负数最高位是1补1。所以-16右移一位是-8右移两位是-4。这样做是为了保持负数的正负号不变同时让右移位在数学上仍然等价于整除2。这个设计很巧妙但对不熟悉的人来说容易被坑一个负数右移结果仍然是负数不会变成正数。7.3 用右移提取指定字段右移和位与经常组合出“提取字段”的操作。假设一个16位寄存器bit15:12是设备IDbit11:0是数据。要拿到设备ID就(reg 12) 0x0F。右移把目标字段挪到最低位位与把其他高位清掉剩余的就是干干净净的字段值。这套“先移位、再掩码”的方法在ADC采样值提取、CAN报文解析、配置文件标志位读取里都是标配。7.4 算术右移与逻辑右移选错导致的经典bug我记得有一次做音频解码需要把16位PCM采样值除以2来降低音量。我用了sample 1结果负半轴波形不对称声音严重失真。原因是sample是int类型负数右移按算术右移补1数值方向对了但正数右移补0两者虽然都是减半却存在取整方向差异。更隐蔽的是如果你把有符号数强制转成无符号再右移负数会变成一个很大的正数整个数据处理就彻底乱了。正确做法是保留有符号类型让编译器做算术右移或者在做归一化、缩放时先明确转换策略再决定是用右移还是除法。8. 移位运算的综合实战从寄存器到Python再到协议前面每个运算符都拆开讲了这里我把它们串起来模拟几个真实项目里的完整处理流程让读者看看位运算在实际代码里是怎么组合使用的。8.1 实战一ARM寄存器配置中的读-改-写假设要配置一个32位的外设控制寄存器bit0是全局使能置1开启bit3:2是模式选择值为2即二进制10bit7:4是分频系数值为5其他位必须保持原值代码如下uint32_t reg READ_REG(CTRL_REG); // 1. 清零目标字段保留其他位 reg ~(0x01 0); // 清除bit0 reg ~(0x03 2); // 清除bit3:2 reg ~(0x0F 4); // 清除bit7:4 // 2. 写入新值 reg | (0x01 0); // 使能 reg | (0x02 2); // 模式2 reg | (0x05 4); // 分频5 // 3. 写回 WRITE_REG(CTRL_REG, reg);这套流程的每一步都是前面讲过的组合左移用于把常量移动到目标bit位位非取反生成清零掩码位与用于清除字段位或用于写入新值。只要每一步的掩码宽度和位置计算正确读-改-写就是零风险的。很多新手第一次写寄存器配置会直接给寄存器赋一个“算好的完整值”但一旦寄存器里有动态变化的位比如中断标志直接赋值的做法就会把这些状态位一并冲掉非常危险。8.2 实战二Python中解析一个传感器数据帧可以用Python模拟一个温湿度传感器数据解析。传感器返回一个4字节帧byte0是设备地址byte1高4位是温度整数byte1低4位是温度小数byte2是湿度byte3是校验字节前面所有字节的异或。frame bytes([0x01, 0xA7, 0x3C, 0x9A]) dev_addr frame[0] temp_int (frame[1] 4) 0x0F temp_dec frame[1] 0x0F humidity frame[2] checksum frame[0] ^ frame[1] ^ frame[2] valid (checksum frame[3]) print(dev_addr, temp_int, temp_dec, humidity, valid)这里涉及的知识点右移提取高4位位与提取低4位异或计算校验。如果你曾经被“如何从一帧数据里把各个字段抠出来”困住过这个例子基本就是标准答案。8.3 实战三1024QAM符号位为什么是10bit最新热搜词里有一个很专业的问题“1024QAM的符号位长为啥是10bit”。用位运算的知识可以把这个事解释透。QAM调制是把多个bit打包成一个符号进行传输。64QAM每个符号能携带6个bit因为2的6次方等于64——6个bit有64种组合。同理1024QAM要有1024种组合就需要10个bit因为2的10次方等于1024。用位运算的视角看调制器做的事情正是“位拼接”把10个独立的bit按顺序拼成一个10位的数再映射到1024个星座点之一解调器则做相反的事用移位和掩码把这个10位数的每个bit提取出来。所以“符号位长”本质就是二进制位数的信息量问题bit数决定了能表达的状态总数状态总数决定了调制阶数。当你把位运算理解透了再看通信原理里的调制阶数、ADC位数、灰度等级这些概念会发现底层逻辑都是2的幂运算。8.4 实战四ADC量化噪声里的“位深”热搜词里还有“理想n位ADC量化噪声解释”“mvs如何修改保存图像的位深”这两者也和位运算强相关。一个n位ADC能把模拟量分成2的n次方个台阶每增加1位台阶数量翻倍量化步长减半量化噪声功率理论上减小6dB。8位ADC有256个台阶10位ADC有1024个台阶16位ADC有65536个台阶。这里的“位”就是二进制位数而把ADC原始采样值转换成物理量时位移和掩码操作几乎必然涉及。图像位深同理8位灰度图每个像素用8bit表示亮度即256级灰度16位图每个像素能表达65536级灰度动态范围更大。你在代码里提取R、G、B通道时(pixel 16) 0xFF这类写法就是位运算在图像处理里的直接应用。9. 写在最后几个我靠踩坑换来的建议回顾这些年用位运算的经历有些经验是文档里不会写的我在这里集中补充一下。第一写位运算表达式时优先加括号。C语言和Python的运算符优先级并不完全一致a 0x0F 0这种写法在C里会先比较再位与结果完全不对。我见过太多这种bug排查起来非常痛苦。我的习惯是移位、位与、位或这些操作一律用括号明确优先级宁可多写几对括号也不赌运算符优先级。第二用常量宏定义代替魔法数字。直接写reg | 0x80没问题但三个月后回来看代码没人记得0x80是什么。建议定义成#define BIT7 (0x80)或者用枚举甚至C语言里可以用BIT(x)宏#define BIT(n) (1U (n))。写reg | BIT(7)可读性瞬间提升一个档次而且不容易算错。第三有符号数右移要格外谨慎。如果你不确定一个变量是否有符号可以用显式转换把处理策略写清楚。在协议解析里推荐先用无符号类型接收原始字节再做后续处理避免负数右移带来的逻辑混乱。第四位运算不是越炫越好。用异或交换两个变量看起来巧妙但实际代码里临时变量更易读、更安全。位运算的价值在于“该用时用”比如寄存器配置、协议解析、性能敏感的算法而不是所有代码都要秀位运算。第五调试位运算时用十六进制打印中间值。不要用十进制去核对掩码。位运算本质上是十六进制的游戏0xA5 0x0F一眼看出是0x05但用十进制算165和15的与很多人会愣一下。我在调试时习惯把每一步的关键变量都打印成0x%02X格式对齐二进制位关系问题立刻暴露。最后说点掏心窝的话位运算这东西光看不练三天就忘。建议你拿一个真实的小项目练手——比如给单片机的广告灯程序加上左移右移控制或者用Python写一个小工具把文件权限用位运算解析成人话。一个下午的实操抵得上十遍理论阅读。等你真的把这些运算符用顺手了你会发现它们不是六个孤立的符号而是一整套思考二进制数据的方式提取、合并、翻转、清零、置位、移位。这六个动作基本能覆盖你在底层开发中遇到的所有数据操作需求。
返回列表