位运算基础应用(一)

一、位运算概述

在计算机系统中,数据最终都以二进制 形式存储。无论是一个普通整型变量,还是一个寄存器值,处理器看到的本质都是由若干个 0 和 1 组成的二进制位序列。位运算就是直接针对这些二进制位进行操作的一类运算。

位运算和加减乘除这类算术运算不同,位运算并不优先关注"数值大小"的数字意义,而是直接关注一个数据在二进制层面的每一位状态。例如,某一位是 1 还是 0,某几位组成的字段代表什么含义,某一位是否需要被置位或清零,这些都属于位运算处理的范畴。

在C语言中,位运算主要包括以下几类:

运算符 名称 说明
& 按位与 两位都为 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