位运算基础应用(一)如何巧妙融入?

摘要:本文对 C 语言中的常见位运算进行了系统整理与说明,包括按位与、按位或、按位异或、按位取反以及左移和右移运算。通过结合二进制示例,介绍了各类位运算的基本规则、运算过程及其数学含义,并分析了移位运算在发生溢出或涉及有符号类型时可能出现的行为差
一、位运算概述   在计算机系统中,数据最终都以二进制形式存储。无论是一个普通整型变量,还是一个寄存器值,处理器看到的本质都是由若干个 0 和 1 组成的二进制位序列。位运算就是直接针对这些二进制位进行操作的一类运算。   位运算和加减乘除这类算术运算不同,位运算并不优先关注“数值大小”的数字意义,而是直接关注一个数据在二进制层面的每一位状态。例如,某一位是 1 还是 0,某几位组成的字段代表什么含义,某一位是否需要被置位或清零,这些都属于位运算处理的范畴。   在C语言中,位运算主要包括以下几类: 运算符名称说明 & 按位与 两位都为 1,结果才为 1 | 按位或 两位任一位为1,结果为1 ^ 按位异或 两位不同结果为 1,相同为 0 ~ 按位取反 将每一位 0/1 反转 << 左移 所有位整体左移若干位 >> 右移 所有位整体右移若干位   位运算在嵌入式开发中是非常基础的一项能力,因为底层硬件配置本身就是按位定义的。比如:   这个是STM32F4xx 的GPIO位定义,从中我们可以看出 31:16 是预留位,而 15:0 则是每个GPIO引脚的输出类型,每个位分别对应引脚 Px0、Px1、Px2 ··· Px14、Px15,这种情况下,我们就可以通过位运算对 Px0 ~ Px15 中的特定位进行读取、修改和组合。   从这里我们也可以看出位运算比较核心的价值: 可以精确控制单个 bit 或一组 bit; 节省存储空间,可以用一个字节表达多个状态; 非常适合寄存器、协议、标志位这类底层数据结构的处理。   所以位运算不是“语法技巧”,而是底层开发中的常规工具。 二、位运算基础原理 (一)如何按位看数据   给一个8位十进制数据 uint8_t a = 13,转换成二进制为 0000 1101(13=1x20+0x21+1x22+1x23),如果按 bit 编号,一般从低位到高位编号为 bit0 ~ bit7: bit7 bit6 bit5 bit4 bit3 bit2 bit1 bit0 0 0 0 0 1 1 0 1   在这里可以看到 bit0=1、bit1=0、bit2=1、bit3=1。通过这也可以看出位运算本质上就是对这样的位序列逐位进行逻辑处理。 (二)按位与 &   运算规则:两个操作数对应位都为1,结果才为1。 0 & 0 = 0 0 & 1 = 0 1 & 0 = 0 1 & 1 = 1   下面举个具体示例来进行说明: uint8_t a = 0b1101; uint8_t b = 0b1011; uint8_t c = a & b;   对 a 与 b 逐位对比: a: 1 1 0 1 b: 1 0 1 1 a&b: 1 0 0 1   所以最终 c 的结果就是 0b1001,这就是按位与的执行逻辑。在实际应用中,按位与最典型的用途不是“求值”,而是屏蔽不关心的位,也就是后面常说的 mask 操作。 (三)按位或 |   运算规则:对应位只要有一个是1,结果就是1。 0 | 0 = 0 0 | 1 = 1 1 | 0 = 1 1 | 1 = 1   继续按照按位与中的示例来进行说明: a: 1 1 0 1 b: 1 0 1 1 a|b: 1 1 1 1   通过按位或运算后,最终 c 的结果变为了 0b1111,在实际使用中,我们也经常通过按位或去达到对某一bit进行置位的目的 (四)按位异或 ^   运算规则:对应位不同为1,相同为0。 0 ^ 0 = 0 0 ^ 1 = 1 1 ^ 0 = 1 1 ^ 1 = 0   继续按照按位与中的示例进行说明: a: 1 1 0 1 b: 1 0 1 1 a^b: 0 1 1 0   通过按位异或运算后,最终 c 的结果变成了 0b0110。所以对按位异或可以总结出以下通式: //x表示任意整数变量 x ^ 0 = x x ^ x = 0 (五)按位取反 ~   运算规则:每一位都翻转。 1 = 0000 0001 ~1 = 1111 1110 0 = 0000 0000 ~0 = 1111 1111   从这个示例可以看出,对 1 或 0 进行 ~ 时是针对其数据类型的整个类型宽度进行取反的,而不是只关心显式的那几位bit,比如: //uint8_t 1 = 0000 0001 ~1 = 1111 1110 0 = 0000 0000 ~0 = 1111 1111 //uint16_t 1 = 0000 0000 0000 0001 ~1 = 1111 1111 1111 1110 0 = 0000 0000 0000 0000 ~0 = 1111 1111 1111 1111 (六)左移 <<   运算规则:将所有 bit 整体向左移动 n 位,高位溢出的bit丢弃,低位补0。 1010 1010 << 1 = 0101 0100   对于无符号数,在不溢出的前提下: x << n ≈ x * 2^n   比如: 5 << 1 = 10 5 << 2 = 20 5 << 3 = 40   这个并不是所有场景都可以将左移等同乘法,只要发生溢出,高位被丢弃,结果就不再等价。比如: //二进制1010 1010 = 十进制170 1010 1010 << 1 /* 按照左移乘法的公式来算,8位整数只能表示0~255, 所以但从范围边界来看,移位后的结果也一定发生 了截断,即大于0小于255 */ 170 * 2 = 340 /* 移位后可以得到0101 0100,再转 十进制84,而84与340不等,所以若 发生溢出就无法再使用<<去做乘法。 */ 1010 1010 << 1 = 0101 0100   而在C标准中,如果有符号数左移后溢出导致结果不能表示,就会产生未定义行为。所以在实际工程中,一般更好是对 unsigned 类型的数据进行左移操作。 (七)右移 >>   运算规则:将所有 bit 整体向右移动 n 位,低位移除的 bit 被丢弃。 0000 1101 >> 1 = 0000 0110   可以看到,0000 1101 最右边的 1 被移出丢弃后变成了 0000 0110。   对于无符号数: //x是任意无符号整数 x >> n ≈ x / 2^n   需要注意右移与左移不同,右移有两种情况,一种是逻辑右移,一种是算术右移,而左移则只有一种情况,并没有做区分。那么为什么右移会出现这两种情况呢,主要还是因为右移涉及到了有符号整形中的符号变化问题。   在无符号数(unsigned)中,右移整形只需要在高位补0即可,比如 unsigned char a = 0b10101010; a >> 1 = 0b0101 0101;   这是逻辑右移。   在有符号(signed)中,右移整形大多数编译器会在高位补符号位,比如: 1110 0000 >> 1 //若是负数,高位补1 = 1111 0000 //若是正数,高位补0 = 0111 0000   这是算术右移。   针对左移和右移可做如下总结: 运算补位特殊情况 << 补0 可能溢出 >> unsigned 补0 逻辑右移 >> signed 补符号位 算术右移   补充一句:在补码系统中,左移相当于乘2,右移相当于除2,但只有在不发生溢出且使用算术右移时才能保持符号正确。所以在实际开发中,更推荐使用无符号数(unsigned)进行位运算。 (八)运算符优先级   位运算符在整体运算符体系中的优先级如下: 优先级顺序运算符名称 高 ~ 按位取反 << >> 移位 & 按位与 ^ 按位异或 低 | 按位或   位运算符与其他运算符的优先级关系如下: 优先级 运算符 名称 高 ~ ! 单目运算符 * / % 乘除 + - 加减 << >> 移位 < <= > >= 比较 == != 相等 & 按位与 ^ 按位异或 | 按位或 && 逻辑与 低 || 逻辑或   所以总体看下来就是位运算优先级低于算术运算,但高于逻辑运算。 三、位运算常用操作   这一部分时位运算在工程中最常用的四类基本动作:置位、清零、读取、翻转。 (一)置位   在对寄存器进行操作时,我们通常需要将寄存器的第 n 个 bit 置1,此时我们可以 reg |= (1U << n);   比如现在我想把 reg 的 bit3 置1,其他位保持不变,就可以 reg |= (1U << 3);   下面我会对这个 reg 的 bit3 置位操作进行拆解。   首先 1U << 3 可以得到: 1U = 0000 0001 -> 1U << 3 -> 0000 0001 << 3 -> 0000 1000   然后再和原值做按位或(对应位只要有一个是1,结果就是1): 原值: 1010 0010 掩码: 0000 1000 结果: 1010 1010   从运算结果来看,实现了将 bit3 强制置1的效果。 (二)清零   清零,即将第 n 个 bit 清0。 reg &= ~(1U << n);   比如我现在想把 reg 的 bit3 清零,其他位保持不变,就可以 reg &= ~(1U << 3);   下面我会依据这个 reg 的 bit3 清零操作进行拆解:   首先 1U << 3 可以得到: 1U = 0000 0001 -> 1U << 3 -> 0000 0001 << 3 -> 0000 1000   然后对(1U << 3)进行取反(将每一位0/1反转): ~(1U << 3) -> ~0000 1000 -> 1111 0111   最后再和原值做按位与(两位都为 1,结果才为 1): 原值:1010 1111 掩码:1111 0111 结果:1010 0111   只有 bit3 被清0。 (三)读取   读取,即判断第 n 个 bit 是否为1。 if(reg & (1U << n)) { //bit n 为1 }   比如: if(reg & (1U << 5)) { //第五位标志有效 }   意思是如果 bit5 原本是1,那么与之后结果非0;但是若 bit5 原本为0,那么结果为0。 (四)翻转   翻转,即将第 n 个 bit 翻转。 reg ^= (1U << n);   比如: reg ^= (1U << 3);   下面我将把 reg 的 bit3 翻转操作进行拆解。   首先是 1U << 3 可以得到: 1U << 3 -> 0000 0001 << 3 -> 0000 1000   然后和原值做按位异或(两位不同结果为 1,相同为 0): 情况1(bit3 为1): 原值:1010 1010 掩码:0000 1000 结果:1010 0010 情况2(bit3 为0): 原值:1010 0010 掩码:0000 1000 结果:1010 1010   可以看到情况1中原值的 bit3 原来是1,然后被翻转为了0;情况2原值的 bit3 为0,然后被翻转为了1。 (五)多位同时操作   不是只有单 bit 才能操作,一组 bit 同样可以使用位运算进行处理。比如:   (1)设置低四位为1: reg |= 0x0F;   (2)清低四位: reg &= ~0x0F;   (3)读取低四位: value = reg & 0x0F; 【注:这里的 0x0F 本质上就是一个多 bit 掩码,即 0000 1111】