ARTICLE DETAIL

资讯详情

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

C语言移位运算符详解:左移右移、补码与未定义行为避坑

C语言移位运算符详解:左移右移、补码与未定义行为避坑 刚学 C 语言那会儿我第一次看到移位运算符左移、右移这种写法脑子里全是问号——两个尖括号怼在一起既不像比较也不像括号到底想干嘛后来做嵌入式项目天天跟寄存器打交道才发现这两个操作符是 C 语言里性价比极高的一类工具一行代码既能顶替乘除法又能把十几个布尔状态塞进同一个整数里。这篇就从位模式到底怎么挪讲起一路讲到工程里真正用得上的场景顺带把新手最容易翻车的几个边界条件掰开揉碎。不管你是刚啃 C 语言基础、正在刷翁恺老师练习题的同学还是已经写过一阵子代码、但看到1 31心里发虚的人看完应该都能有点收获。1. 想看懂移位先把二进制位模式摆在桌面上1.1 计算机眼里的整数到底长什么样先明确一件事移位运算符操作的是内存里的位模式bit pattern不是我们在代码里写的那个十进制数。你写13编译器存进去的是 32 位int的常见宽度二进制0000 0000 0000 0000 0000 0000 0000 1101现代计算机普遍用补码表示有符号整数。补码的规则可以概括成三条正数的补码等于原码负数的补码是对应正数按位取反再加 1最高位是符号位0 表示非负、1 表示负。拿-13举例。先写130000 0000 0000 0000 0000 0000 0000 1101按位取反1111 1111 1111 1111 1111 1111 1111 0010再加 11111 1111 1111 1111 1111 1111 1111 0011这就是-13在内存里的样子。记住这个结构后面解释为什么负数右移补的是 1 而不是 0就顺理成章了。补码这套设计的妙处在于加减法可以统一用同一套电路实现0只有一种表示不会出现正零负零的尴尬。C 语言标准并没有强制要求必须用补码但现实中你能接触到的平台基本都是补码所以后面讨论都以补码为准。1.2 移位运算符在表达式里的位置和都是二元运算符左边是待移位的操作数右边是移位的位数a n // a 的位模式整体左移 n 位 a n // a 的位模式整体右移 n 位它们的优先级低于加减乘除高于关系运算符和相等运算符赋值运算符最低。结合性是从左到右。优先级这块有个经典陷阱先记结论int a 1, b 2; printf(%d\n, a b 1); // 等价于 a (b 1)结果是 8 printf(%d\n, a b 1); // 等价于 (a b) 1结果是 6再看一个新手常写的int x 5; if (x 1 4) { /* ... */ } // 别这么写因为的优先级高于这里是x (1 4)也就是5 16结果是 0。如果你的本意是先取x的第 5 位得改成(x 4) 1。凡是混合了位运算和算术运算的表达式一律加括号——这不是风格洁癖是正确性问题。1.3 移位表达式本身不改变原变量有个细节值得单独点一下int a 1; int b a 3; // b 是 8a 仍然是 1 a 3; // 复合赋值a 变成 8a 3这个表达式求值后产生的是一个新值a本身不动。只有用、这种复合赋值运算符才会把结果写回左操作数。这和a 3不改a是同一个道理很多初学阶段的人会把表达式求值和赋值混在一起结果调试时盯着变量值发懵。2. 左移 高位直接丢低位无脑补 02.1 规则一句话就能说完左移的规则简单到没有歧义所有位向左挪 n 位左边移出去的位直接丢弃右边空出来的 n 位全部补 0。用 8 位演示方便肉眼观察a 0b0000_1010十进制 10a 1 - 0b0001_0100 (20) a 2 - 0b0010_1000 (40) a 3 - 0b0101_0000 (80)看出规律没每左移一位数值翻倍。放到 32 位的真实环境里代码是这样的#include stdio.h int main(void) { unsigned int a 10u; printf(%u\n, a 1); // 20 printf(%u\n, a 2); // 40 printf(%u\n, a 3); // 80 return 0; }2.2 为什么左移等于乘以 2 的 n 次方从位权角度看这件事几乎是显然的。一个二进制数... b3 b2 b1 b0它的值等于b0*2^0 b1*2^1 b2*2^2 b3*2^3 ...。整体左移一位后原来的b0跑到了 2 的位置原来的b1跑到了 4 的位置以此类推多项式里每一项都乘了 2。所以a n a * 2^n 前提结果没溢出且 a 的符号没问题这条性质在工程里被大量用来做快速乘 2 的幂。在主流 CPU 上移位指令通常只要 1 个时钟周期而乘法指令要 3 到 5 个周期甚至更多所以老代码里经常能看到手写移位代替乘法的写法int n 1; n 1; // n * 2 n 2; // n * 4不过这里要泼一盆冷水现代编译器早就比人聪明了。你写n * 8开-O2之后编译器大概率直接生成移位指令甚至用 LEA 之类的指令一次搞定。所以不要为了性能把手写的硬塞进普通业务代码里那样只会牺牲可读性。真正需要手写移位的地方是语义上本来就是按位操作的场景比如寄存器、掩码、哈希。2.3 左移溢出高位说没就没了左移最容易被忽略的一点是高位会被静默丢掉任何编译器都不会给你警告。unsigned char c 0x80; // 0b1000_0000 c c 1; // 0b0000_0000最高位被丢出 8 位之外 printf(%u\n, c); // 0同理unsigned int a 0x80000000u; a 1;结果是 0。这种静默丢弃是位运算的常态写代码时心里必须有数你只关心低若干位就别指望高位还在。反过来如果你想用左移生成某个高位为 1 的值比如第 31 位写法一定要挑对类型unsigned int mask 1u 31; // 安全 int mask2 1 31; // 危险见第 4 节这里把左移在三类操作数上的行为归一下类操作数类型行为是否可移植无符号unsigned结果按模 2^位宽 处理标准丢弃高位完全可移植有符号非负且结果可表示等于乘 2^n完全可移植有符号负数或结果不可表示未定义行为不可移植不能依赖3. 右移 逻辑右移还是算术右移这是个分岔口3.1 无符号右移高位一律补 0无符号数的右移规则最干净低位丢弃高位补 0这叫逻辑右移logical shift。unsigned int a 0x0000000Au; // 10 printf(%u\n, a 1); // 5 printf(%u\n, a 2); // 2 printf(%u\n, a 3); // 1 printf(%u\n, a 4); // 0 printf(%u\n, a 5); // 0注意最后两行10 4是 0因为 10 除以 16 的整数部分是 0。这条规律可以总结成无符号 a n a / 2^n 整数除法向下取整因为无符号数全都非负向下取整和向零取整在这里是一回事所以无符号右移和整除完全等价可以放心替换。3.2 有符号右移高位补什么由实现决定有符号数的右移是 C 标准里最划水的一块如果被移位的操作数是负值结果由实现定义implementation-defined。也就是说标准没规定高位补 0 还是补 1各个编译器自己决定。现实中几乎所有主流编译器GCC、Clang、MSVC都采用算术右移arithmetic shift高位补符号位——原来是负数就补 1原来是正数就补 0。这样右移之后符号不会突然翻转。int x -8; printf(%d\n, x 1); // 常见实现输出 -4 printf(%d\n, x 2); // 常见实现输出 -2为什么算术右移要补符号位从数学上看-8 / 2 -4。如果补 0-8的补码1111...1000右移一位会变成0111...1100那可是个巨大的正数语义直接崩了。两种右移的差别用表格列出来更清楚类型名称高位补什么典型场景无符号右移逻辑右移一律补 0位掩码、哈希、数据拆包有符号右移负值算术右移常见实现补符号位需要保住符号的算术场景3.3 负数右移和除法结果可能差 1这是最值得记住的一个细节算术右移等价于向下取整的除法而 C 语言的/是向零取整。遇到负数时两者会差 1。int x -7; printf(%d\n, x 1); // 常见实现输出 -4 printf(%d\n, x / 2); // -3差在哪里-7 / 2数学上是-3.5。C 的/向零取整得-3算术右移相当于向下取整floor得-4。手算验证一下-7的 32 位补码是1111...1111_1001右移一位补 1 得1111...1111_1100这就是-4。所以在需要除以 2 的幂、而且操作数可能是负数时老老实实写/ 2^n别图快写 n。做数据处理、坐标换算、财务计算时差一个 1 是很要命的。3.4 一条我自己的判断标准实操里什么时候敢用右移我的判断标准是三条全部满足才用操作数类型是无符号或者我能明确证明它非负语义就是取高位或除以 2 的幂不涉及负数的取整方向位数是编译期常量而且严格小于类型位宽。只要有一条不满足就换成除法或者先做显式类型转换。这套规则看着保守但能挡掉绝大多数诡异 bug。4. 那些把程序炸掉的边界条件4.1 移位数大于等于位宽直接未定义行为int a 1; int b a 32; // 未定义行为 int c a 32; // 未定义行为 int d a 33; // 未定义行为C 标准规定如果右操作数为负或者大于等于左操作数类型的位宽行为未定义。注意是大于等于不是大于。32 位int只能移 0 到 31 位。为什么这么规定因为底层硬件大多只取移位数的低 5 位32 位平台x86 的移位指令就是这么干的所以a 32实际执行的是a 0结果还是a。但别的平台可能直接给 0。结果因平台而异标准干脆定为未定义让你别依赖它。那移位数为负呢int e a -1; // 未定义行为同样是 UB实际执行时移位数会被当成一个很大的无符号数结果完全不可预测。4.2 有符号左移溢出int a 1; int b a 31; // 未定义行为1 * 2^31 超出 int 的表示范围int的范围是-2147483648到21474836471 31等于2147483648超出最大值属于有符号溢出UB。正确写法是用无符号unsigned int mask 1u 31; // 完全没问题这里多说一句后缀。1是int1u是unsigned int1UL是unsigned long1ULL是unsigned long long。写位掩码的时候养成在参与移位的最低位操作数上加u/UL/ULL后缀的习惯能省掉一堆麻烦unsigned long long good 1ULL 63; // 正确 unsigned long long bad 1 63; // 若 int 是 32 位移位 63 位是 UB4.3 运算符优先级埋的雷前面提过一次这里集中列几个容易出错的组合表达式实际等价于你可能以为a b 1a (b 1)(a b) 1a 1 4a (1 4)(a 1) 4a 1 b(a 1) b别的意思ab 2a一元运算符*、、!、~的优先级高于移位所以*p 1是(*p) 1这个没问题。但a 0xFF 8就是a (0xFF 8)不是你以为的(a 0xFF) 8。我踩过最典型的坑是这句data[c] 24 | data[c1] 16 | data[c2] 8 | data[c3]本意是把 4 个字节拼成一个 32 位整数结果data[c]如果是char且值为0x80而char恰好在你的平台上是有符号的移位前会整型提升成负的int或起来就全乱了。正确做法unsigned int val ((unsigned int)data[c] 24) | ((unsigned int)data[c1] 16) | ((unsigned int)data[c2] 8) | (unsigned int)data[c3];4.4 整型提升小类型身上的隐藏动作C 语言在求值前会把char、short提升为int前提是int能表示它的所有值。这意味着unsigned char c 0xFF; printf(%d\n, c 24); // 结果是什么c先提升为int值 255正数再左移 24 位得到0xFF000000也就是 4278190080但int表示不了这个数UB。所以又是那个结论小类型做位运算前先显式转成宽度足够的无符号类型。printf(%u\n, (unsigned int)c 24); // 4278190080安全同一段逻辑加一个(unsigned int)就稳了。这类问题在解析二进制协议、处理图像像素、读写网络包的时候特别常见值得养成习惯。5. 工程里真正用得上的移位写法5.1 位标志与权限掩码一个int有 32 位可以当 32 个开关使比定义 32 个bool省内存也省事。#include stdio.h #define FLAG_READ (1u 0) #define FLAG_WRITE (1u 1) #define FLAG_EXEC (1u 2) #define FLAG_HIDDEN (1u 3) int main(void) { unsigned int perm 0; perm | FLAG_READ; // 打开读 perm | FLAG_WRITE; // 打开写 if (perm FLAG_READ) puts(can read); // 判断某位是否为 1 perm ~FLAG_WRITE; // 关掉写 perm ^ FLAG_EXEC; // 翻转执行位 printf(%u\n, perm); return 0; }这套写法在权限系统、状态机、配置项里到处都是。关键是1u n的定义方式——每一位对应一个独立开关加开关只要加一行#define不占额外空间也不会互相干扰。标准库里的open()用到的O_RDONLY、O_CREAT就是同一套路。四个常用操作记牢|置位、 ~清位、^翻转、判断。5.2 嵌入式里的寄存器读写做单片机的朋友对这套代码应该不陌生#define GPIO_BASE 0x40020000u #define GPIO_MODER (*(volatile unsigned int *)(GPIO_BASE 0x00)) #define GPIO_ODR (*(volatile unsigned int *)(GPIO_BASE 0x14)) #define PIN5_MASK (3u (5 * 2)) // 每个引脚占 2 个模式位 #define PIN5_OUT (1u (5 * 2)) void led_on(void) { GPIO_MODER ~PIN5_MASK; // 先清零那两位 GPIO_MODER | PIN5_OUT; // 再写入输出模式 GPIO_ODR | (1u 5); // 输出高电平 }这里有个非常经典的读改写三步法先 ~mask清零目标位再| value写入新值。为什么不能直接|因为那些位可能本来就有值直接或上去会和旧值混在一起读改写保证了目标位被你完全掌控。注意 ~mask里的~mask是取反会把目标位变 0、其余位变 1这样与运算只清目标位、保住其他位。忘了取反写成 mask是新手最常见的低级错误效果完全相反。5.3 哈希函数里的移位字符串哈希经典算法 djb2unsigned int djb2(const char *str) { unsigned int hash 5381u; int c; while ((c (unsigned char)*str) ! 0) { hash ((hash 5) hash) c; // hash * 33 c } return hash; }hash 5就是hash * 32再加hash得到hash * 33。为什么用 33 而不是随便一个数经验值——33 的二进制是100001只有两个 1用移位加法实现极快而且分布效果不错。很多哈希表的实现都用类似手法。顺便说(hash 5) hash这种写法要保证hash是无符号的这样溢出只是正常回绕不会触发 UB。5.4 位图、popcount 和二进制调试打印位图用一个unsigned int数组存成千上万个布尔值#define BITMAP_SET(arr, i) ((arr)[(i) 5] | (1u ((i) 31))) #define BITMAP_CLR(arr, i) ((arr)[(i) 5] ~(1u ((i) 31))) #define BITMAP_GET(arr, i) (((arr)[(i) 5] ((i) 31)) 1u)i 5定位到第几个int相当于除以 32i 31定位到该int里的第几位相当于模 32。这套运算比/和%快而且写出来一看就知道是位操作。统计二进制里 1 的个数最漂亮的技巧是x x - 1int popcount(unsigned int x) { int n 0; while (x) { x x - 1; // 每次消掉最低位的那个 1 n; } return n; }为什么x (x-1)能消掉最低位的 1假设x最低的 1 在第k位那么x - 1会把第k位变 0、更低的位置全变 1两者的与运算就把第k位及以下一起清了。这个技巧在算法题里出现频率极高。调试时打印二进制void print_bits(unsigned int x) { for (int i 31; i 0; i--) { putchar(((x i) 1u) ? 1 : 0); if (i % 8 0) putchar( ); } putchar(\n); }(x i) 1u就是把第 i 位移到最低位再取出来。写位运算相关代码时这个函数我基本常备出问题第一件事就是打印位模式比盯着十进制数瞎猜强一万倍。6. 几道自测题检验是不是真懂了6.1 判断 2 的幂与向上取整判断一个数是不是 2 的幂int is_pow2(unsigned int x) { return x ! 0 (x (x - 1)) 0; }原理还是那个x (x-1)2 的幂的二进制只有一位是 1减 1 之后变成这一位变 0、后面全 1与运算就归零了。注意必须排除x 0否则0 (0-1)也算 0会误判。求一个数向上取整到最近的 2 的幂unsigned int next_pow2(unsigned int x) { x--; x | x 1; x | x 2; x | x 4; x | x 8; x | x 16; return x 1; }这段代码在内存分配器里很常见比如各种 malloc 实现思路是不断右移做或运算把最高位 1 后面的所有位都填成 1最后加 1 就进位到下一个 2 的幂。看着神奇把位模式画出来就一目了然。6.2 异或交换面试练手业务别用a ^ b; b ^ a; a ^ b;不用临时变量就能交换两个数原理是利用异或的自反性a ^ b ^ b a。但从工程角度说这招在真实项目里几乎没有价值——可读性差、编译器反正会优化、如果a和b是同一个变量还会把它清零。面试题里答一答就行业务代码里老老实实用临时变量。6.3 大小端检测int is_little_endian(void) { unsigned int x 1u; return *(unsigned char *)x 1; }1u在内存里的最低字节如果是 1说明低地址存低位是小端。虽然这里没直接用移位但理解它必须建立在位模式在内存里怎么排的基础上跟本篇主题是同一套底子。我个人在项目里养成的一个习惯是凡是出现或的地方先扫一眼三点——左边是不是无符号、移位数是不是常量、移位数有没有可能大于等于位宽。这三点其实花不了几秒钟但能挡掉绝大部分让人查半天的诡异 bug。还有一个私藏小技巧调试位运算代码时别急着用printf(%d)看十进制直接写个print_bits把 32 位打出来两串 01 一对比问题基本当场现形比在脑子里做补码运算快得多。
返回列表