
看到这个标题你可能想说C# 的和|不是基础语法吗有啥可讲的但在我这些年写协议解析、上位机通信和权限系统的项目里按位与和按位或恰恰是翻车率最高的两个操作符——不是不会用是优先级、符号扩展、掩码选取这些细节平时不碰真记不住。这篇就把我的实战理解完整梳理一遍从二进制直觉一直到工业设备数据解析适合刚学完基础语法想往项目里踩的读者也适合已经写了几年业务代码但一碰位运算就发怵的同学。1. 按位运算的地基先建立位的直觉1.1 一排开关最直观的二进制模型计算机里的整数本质就是内存里的一串二进制位。C# 的int是 32 位long是 64 位short是 16 位byte是 8 位。按位运算就是在这串 0 和 1 上直接操作不涉及进位、借位这种十进制运算概念。我特别喜欢用一个类比把每一位想象成墙壁上的一排开关。8 个开关正好对应一个byte每个开关的开/关状态就代表这一位是 1 还是 0。按位与相当于两个开关串联——必须两个同时打开电路才通按位或|相当于并联——只要有一个打开电路就通。这一排开关一旦建立起来后面所有掩码、置位、组合标志位的操作都会变得非常自然。和|的运算真值表其实就四行表达式结果0 000 101 001 110 | 000 | 111 | 011 | 11记忆技巧很简单与是严格或是宽松。买手机要性价比高且电池大是 AND必须同时满足点外卖奶茶或咖啡是 OR有一个就行。1.2 别把和搞混短路行为完全不同很多新手第一次看到出现在条件判断里会懵这跟有什么区别核心区别有两点。第一只能操作bool而既能操作bool也能操作整数。对整数来说是逐位运算对bool来说是逻辑与但不会短路。什么叫短路if (condition1 condition2)中如果condition1为 falsecondition2根本不会执行但如果写成if (condition1 condition2)两边的表达式都会执行。我见过有人把当用结果右边方法被多调用了一次状态被莫名改掉排查了半天。所以在写布尔逻辑时默认用和||不要为了显摆我知道按位运算而用和|。第二和||的优先级低于和|这一点留到第 4 章展开是实际开发中最大的坑源。1.3 优先级今天不背表明天写 bugC# 的运算符优先级排序中与位运算相关的关键结论是这么几点从高到低括号()、一元~、算术运算符移位运算符和关系运算和相等判断、!按位与按位异或^按位或|逻辑与、逻辑或||三元运算符?:赋值运算符包括|、、这种复合赋值也就是说的优先级高于^而^高于|三者都高于逻辑运算符但都低于。看一段代码int result 1 | 2 3; // 因为 高于 |实际是 1 | (2 3) 1 | 2 3如果没意识到优先级会误以为从左往右算(1 | 2) 3 3 3 3。碰巧结果一样还看不出问题。但换个值就翻车了int result 1 | 4 3; // 实际4 3 0再 1 | 0 1 // 如果从左往右(1 | 4) 3 5 3 1 // 碰巧还是对那再来 int result 2 | 4 3; // 实际4 3 0再 2 | 0 2 // 错误理解(2 | 4) 3 6 3 2 // 又碰巧因为 和 | 的分配律性。但如果加异或就不会碰巧了这种碰巧一样是最迷惑人的。真正的教训写位运算表达式时一律用括号明确分组。第 4 章会专门讲优先级翻车的实战案例。2. 按位与()提取、清零、判断一把数据手术刀2.1 掩码思维把整数切成几段按位与最有用的场景就是掩码。所谓掩码就是一个二进制模式用它去过滤另一个数中感兴趣的部分。比如一个 32 位整数raw 0xABCD1234我想取低 16 位int raw 0xABCD1234; int low16 raw 0xFFFF; // 结果 0x00001234原理很简单 0xFFFF会让高 16 位每一位都跟 0 相与强制清零低 16 位跟 1 相与原样保留。这就是提取字段的本质。想取高 16 位需要先右移再掩码int high16 (raw 16) 0xFFFF; // 结果 0x0000ABCD先右移把高 16 位挪到低位再用掩码把原来的高位现在被移出去了清掉。注意这里 16之后如果原最高位是 1算术右移会在左边补 1如果没有 0xFFFF这一下结果会变成一个诡异的负数。这就是后面要反复强调的符号扩展问题。2.2 实战从设备寄存器里读出扭矩值热词里有一条c#读power focus 6000扭矩值这种场景我做上位机时太熟了。很多工业设备返回的 32 位整数往往把扭矩、状态位、报警位打包在一个寄存器里。比如某设备数据格式约定bit0~bit15 是扭矩值16 位无符号bit16~bit23 是状态标志bit24 是故障位。int raw ReadDeviceRegister(0x10); // 从设备读原始 32 位数据 int torque raw 0xFFFF; // 提取低 16 位扭矩 int statusBits (raw 16) 0xFF; // 提取 bit16~bit23状态 bool hasFault (raw (1 24)) ! 0; // 检查 bit24故障每一行都值得解释raw 0xFFFF是纯提取得到 0~65535 之间的值。(raw 16) 0xFF为什么还要再 0xFF因为如果raw的最高位 bit31 是 1右移 16 位后左边补进来的是 1整个raw 16会变成一个负数这时 0xFF把高 24 位统统清零只保留需要的 8 位状态。没有这个掩码你拿到的状态值会在某些设备数据下突然变负数非常邪门。(raw (1 24)) ! 0是经典的检查某一位是否为 1。1 24就是在 bit24 位置 1其他位为 0按位与之后如果这一位原来是 1结果非零。如果设备协议规定扭矩是 16 位有符号数比如负扭矩表示反转光 0xFFFF还不够得手动把无符号转有符号int torque raw 0xFFFF; if ((torque 0x8000) ! 0) // bit15 为 1 表示负数 { torque - 0x10000; // 把 32768~65535 映射到 -32768~-1 }这种补码转换我在解析电批、拧紧枪、传感器数据时用过无数次。很多国产设备文档写得含糊只有你自己动手验证才知道协议里到底是有符号还是无符号。再补充一个和|配合的场景。如果设备数据是分字节传输的一个大端序的 16 位扭矩值可能分别是byte[2]和byte[3]int torque (bytes[2] 8) | bytes[3];这里 8把高字节移到高位然后用|把低字节合并进来。一个位运算组合拳数据就拼回来了。2.3 奇偶判断与 2 的幂检测两个字节能做的事还能写出面试高频、实际也好用的两个判断。判断奇数偶数(x 1) 0。因为二进制最低位是 1 就是奇数否则是偶数。比x % 2直观但现代 JIT 编译器通常会把x % 2优化成同样的位运算所以性能上差距不大主要胜在语义清晰前提是读者懂位运算。判断一个正整数是否是 2 的整数次幂(x (x - 1)) 0。原理是2 的幂的二进制只有一个 1比如4 0b1004 - 1 3 0b0114 3 0。如果x不是 2 的幂二进制里至少有两位是 1减 1 后借位不会把高位全部消掉与运算结果必然非零。static bool IsPowerOfTwo(int x) x 0 (x (x - 1)) 0; Console.WriteLine(IsPowerOfTwo(8)); // True Console.WriteLine(IsPowerOfTwo(10)); // False注意我用括号把x (x - 1)包起来了。如果你写成x 0 x (x - 1) 0C# 会给你编译错误还是一脸懵因为高于编译器会尝试解析成x ((x - 1) 0)int bool类型不匹配直接报错。所以遇到位运算跟比较运算混写老老实实加括号。3. 按位或(|)置位、组合、合并一把数据拼接钳3.1 把指定位置 1其他位不动按位或最直接的使用就是置位想让某个二进制位变成 1而又不想动其他位用|。byte options 0b0000_0000; options | 0b1000_0000; // 把 bit7 置 1结果 0b1000_0000 options | 0b0000_1000; // 再置 bit3结果 0b1000_1000每一个|操作只会把对应位强制改成 1已经为 1 的位保持原样。这跟的清零正好相反是手术刀|是焊枪。这种打包方式在参数传递中特别有用。比如一个设备配置项有 8 个布尔开关与其开 8 个字段不如用一个byte传进去接收方用提取。通信帧越短效率越高这在串口、蓝牙低功耗、Modbus 这类按字节计费的场景里非常实际。3.2 [Flags] 枚举权限系统和事件订阅的标配C# 里|最经典的搭档是[Flags]枚举。看一个完整的权限示例[Flags] public enum Permissions { None 0, // 0 Read 1 0, // 1 Write 1 1, // 2 Delete 1 2, // 4 All Read | Write | Delete // 7 }这里每个枚举成员的值必须是 2 的幂因为它们对应二进制里互不重叠的位。用1 0、1 1、1 2的写法比直接写 1、2、4 更不容易出错也更能提醒自己我在定义位标志。组合权限用|Permissions user Permissions.Read | Permissions.Write;判断权限用bool canRead (user Permissions.Read) ! 0; bool canDelete (user Permissions.Delete) ! 0; // False判断是否同时拥有一组权限要用等值判断bool isFullAccess (user Permissions.All) Permissions.All; // False这里必须讲清楚一个容易踩的逻辑误区(user Permissions.All) ! 0和(user Permissions.All) Permissions.All完全不同。前者只要user拥有 All 中的任意一个位哪怕只有 Read就会返回 true后者要求 Read、Write、Delete 三个位全部为 1。我在代码评审里见过不少人把这两种写法混用导致越权漏洞。还有一个白送的好东西加了[Flags]后user.ToString()会输出Read, Write而不是整数值打日志的时候简直不要太方便。3.3 | 和 ~ 一套完整的标志位增删查热词里有一条按位或赋值运算说的应该就是|。它等价于state state | value通常用来给状态集合追加一个标志。配合 ~可以移除标志。[Flags] enum MachineState { None 0, Initializing 1 0, Ready 1 1, Running 1 2, Error 1 3 } MachineState state MachineState.None; state | MachineState.Initializing; // 进入初始化 state ~MachineState.Initializing; // 退出初始化 state | MachineState.Ready; // 进入就绪 bool isReady (state MachineState.Ready) ! 0; ~的原理稍微绕一点~MachineState.Ready是对枚举取反得到除了 Ready 位之外全是 1的掩码再跟state按位与只有 Ready 位被清零其他位原样保留。这就是删除单个标志而不影响其他标志的标准做法。这套增删查操作在状态机里几乎是每天都要用的。我做一个工控上位机时用一组[Flags]标志表示设备的初始化中、参数已加载、通信正常、允许启动等状态主控逻辑每轮循环只要用几个判断就能路由到正确的处理分支比嵌套一堆bool变量清爽得多。4. 移位运算、优先级陷阱和反直觉的坑4.1 移位和 | 配合时的字节序重组移位运算符和虽然不属于、|但在实际位运算中几乎总是成对出现。左移n位相当于乘2^n右移n位相当于整除2^n对负数要注意舍入行为。一个大端字节序组包场景byte b1 0x12; byte b2 0x34; byte b3 0x56; byte b4 0x78; int value (b1 24) | (b2 16) | (b3 8) | b4; // value 0x12345678每个byte在参与运算时会自动提升为int所以b1 24没问题。但如果你的结果要放进long就得小心byte 24只能得到int位数不够时高 8 位会被丢掉必须写成((long)b1 32) | ((long)b2 24) ...才能正确拼出 64 位整数。这种细节在解析 RFID、相机配置、运动控制卡数据时特别常见。再看一个解包的例子int raw 0x12345678; byte b1 (byte)(raw 24); // 0x12 byte b2 (byte)(raw 16); // 0x34 byte b3 (byte)(raw 8); // 0x56 byte b4 (byte)(raw); // 0x78raw 24得到0x12转byte没损失raw 8得到0x123456转byte时直接截断正好留下低 8 位0x56。所以不需要额外 0xFF强转byte本身就是截断。但如果你要的是int结果而不是byte就必须自己加 0xFF否则0x123456会整个留在int里。4.2 优先级翻车现场等号、比较、位运算混写我在 1.3 说过优先级现在看真实案例。这句代码很常见if ((flags Flag) ! 0) { ... }这个写法是安全的。但有人会为了省括号写成if (flags Flag ! 0) { ... }/!的优先级高于所以实际解析为if (flags (Flag ! 0)) { ... }Flag ! 0的结果是boolflags bool类型不匹配C# 编译器会直接报错。比起 C 语言bool 会被当成 1 或 0 参与运算错误更隐蔽C# 的编译器反而帮我们挡住了一部分坑。但如果你用的是dynamic或者某些类型强转后能隐式转换还是会绕过编译检查。所以我的铁律是只要位运算和比较运算符出现在同一个表达式里全部加括号不要考验读者甚至不要考验自己半年后的记忆。另一个更隐蔽的优先级坑在移位和加法之间int a 1 2 3; // 实际1 (2 3) 32 // 很多人以为(1 2) 3 7因为算术加减法的优先级高于移位。C# 的语法里加法、移位、比较的优先级差异并不直观。遇到这种一字不差的表达式除了加括号没有任何可靠的直觉可以依赖。4.3 优先级速查一张表保住发量优先级分组运算符示例最高括号、一元运算(a算术*/%-1 2 3先算加法移位b1 8关系!(flags Mask) Mask按位与flags Mask按位异或^a ^ b按位或|a | b逻辑与/或||a b三元与赋值?:|几乎最低这张表不需要背需要的是形成条件反射写出一个包含位运算的表达式之后先在关键位置补上括号再考虑可读性。我把这个习惯刻进肌肉记忆之后代码评审中关于运算符优先级的 discussion 几乎降为零。5. 常见翻车现场与排查技巧实录5.1 符号扩展负数读出来永远不是你想要的 0xFF上位机和协议解析里最经典的坑。假设串口发来一个字节0xFF你把它存在byte里是 255但如果赋值给intbyte b 0xFF; int x b; // 255正常因为 byte 是无符号类型那什么时候出问题如果你从设备读到的是有符号字节sbytesbyte sb -1; // 内存里也是 0xFF int y sb; // 结果是 -1而不是 255-1在 32 位int里是0xFFFFFFFF而你期望的可能是0x000000FF。这就是符号扩展从窄的有符号类型转宽类型时符号位会复制填充到高位。在合成数据时这个坑会以另一种形式出现byte high 0xFF; byte low 0xFE; int bad (high 8) | low; // (0xFF 8) | 0xFE 0xFFFE 65534 // 如果协议是无符号16位这没错 // 但如果协议把这俩字节解读成有符号16位应该是 -2。判断标准很简单协议说无符号就用无符号类型说补码就要手动转换。我在 2.2 节已经给过扭矩从无符号转有符号的代码那是处理这类问题的标准解法。另一个高频坑在右移int raw 0xFFFF1234; int shifted raw 8; // 结果是 0xFFFFFF12而不是 0x00FFFF12因为int右移是算术右移最高位是 1 时左边补 1。想得到逻辑右移的效果要么用无符号类型uint u (uint)raw 8; // 结果是 0x00FFFF12要么在右移之后立刻 0xFF之类的掩码。我在 2.2 节强调的 0xFF防的就是这个。5.2 位运算并不一定比取模快别为了炫技牺牲可读性网上常有人说位运算比算术快所以要疯狂用位运算优化。这个说法在现代 .NET 里没那么绝对。JIT 编译器对x % 2、x * 2这类常见模式已经能自动生成高效的位运算指令。我在一个图像处理程序里实测过手写位运算和普通算术运算在 Release 下性能差距往往在误差范围内。但这不代表位运算不重要。协议解析、状态标志、权限控制、寄存器读写这些场景里位运算不是性能优化而是数据结构本身的设计需要。你用提取字段不是因为比除法快而是因为数据就是这么打包的。我的建议是业务代码里优先写清楚意图比如用枚举、用[Flags]、用带注释的掩码常量只有在 Profile 证明某个热点确实慢并且位运算能带来明显提升时才值得把可读性换成速度。5.3 一行代码看懂别人的位操作速查表面试或接手旧项目时经常要快速读懂一串位运算。下面这张表是我自己整理的高频模式注释里说的第 n 位最低位从 0 开始操作表达式说明取低 N 位x ((1 N) - 1)掩码常数式取第 n 位(x n) 1结果为 0 或 1第 n 位是否为 1(x (1 n)) ! 0判断用第 n 位置 1x(1 n)第 n 位清零x ~(1 n)清位保留最低的 1x (-x)LowBit树状数组用清除最低的 1x (x - 1)统计 1 的个数基础无符号打印二进制Convert.ToString(unchecked((uint)x), 2).PadLeft(32, 0)调试神器这套速查表我直接贴在项目文档里当注释团队里新同学写协议解析时对照着用少走很多弯路。5.4 排查手段把二进制打出来一眼定位位运算出问题时心里模拟几个字节的 0/1 变化非常费劲最有效的排查方式是直接打印二进制。我在现场调试时经常写一个小工具方法static string Bits(int value) { return Convert.ToString(unchecked((uint)value), 2).PadLeft(32, 0); } static string Bits(byte value) { return Convert.ToString(value, 2).PadLeft(8, 0); }用unchecked((uint)value)把int转成无符号是为了让负数也能以完整的 32 位补码形式显示避免Convert.ToString(-1, 2)输出带符号的数字字符串那样根本看不出位排列。那次调试一个设备寄存器读值异常我连续打印了原始值、掩码后值、移位后值和最终结果Console.WriteLine($raw: {Bits(raw)}); Console.WriteLine($mask16: {Bits(raw 0xFFFF)}); Console.WriteLine($shifted: {Bits((raw 16) 0xFF)});打出来就一目了然掩码选错了把状态位里的一部分也带出来了。这比盯着变量窗口里的十进制数字想半天要高效得多。最后再分享一个我个人养成的习惯凡是位运算无论多简单我都在旁边加一行注释写清楚bit0~bit15 是扭矩、bit24 是故障这类协议约定。因为位运算的可读性天然就比命名良好的方法差两个月后再看自己写的代码如果没有注释和速查表基本要靠猜。你可以把这段总结记下来按位与是把数据切开按位或是把数据拼起来而决定切得准不准的永远是对协议和掩码的敬畏。