巧用位运算
预计阅读时间: 4 分钟
“位运算有啥用,看看这个就知道了”
负数
左移:低位补 0
右移:高位补 1
左移数大于变量位数,都为 0
右移数大于变量位数,都为 1
正数
左移:低位补 0
右移:高位补 0
左移数大于变量位数,都为 0
右移数大于变量位数,都为 0
测试平台:STM32F104RGT6
• & 按位与运算符:两位同时为 1,结果才为 1,否则为 0
• | 按位或运算符:两位中有一个为 1,结果就为 1
• ^ 异或运算符:两位值不同,结果为 1,否则为 0
• ~ 取反运算符:将 0 变 1,1 变 0,就是反着来
• << 左移运算符:各二进制位全部左移若干位,左边丢弃,右边补 0
• >> 右移运算符:各二进制位全部右移若干位,正数左补 0,负数左补 1, 右边丢弃
两个不同长度的数据进行位运算时,系统会将二者按右端对齐,然后进行位运算。短的那个数据如果是负数,左边补 1,否则补 0
现在看看使用位运算的一些使用技巧。
对于刚学习开发的人来说,最常使用的位运算应该是 & 和 | ,对于经常和 I/O 打交道的嵌入式开发人员来说,它们确实给我们的操作带极大的方便,那么它们有什么实际应用呢?
用的最多的就是 I/O 操作,以它为例进行说明。
对于单片机来说,有很多端口,如 A、B、C、D……每个端口中又分为很多位,对于八位单片机来说,每个端口是 8 bit,即有 8 个引脚,对于 stm32 来说,每个端口是 16 bit,即有 16 个引脚。每个引脚有两种电平状态:高电平或低电平(所谓高低电平就如名字一样,电的水平,超过了一个水平线就是高电平,低于这个水平线就是低电平,所以说数字电路比模拟电路简单,并且容错性更好,只要在一个电压范围内都可以认为是一个电平状态,不容易因为多一点电压少一点电压得到的结果就不一样)。一般来说,认为高电平寄存器的状态位为 1,低电平寄存器的状态位为 0,其实从单片机开发的角度来看就是寄存器的值。比如端口 A,它对应的寄存器的地址是 0x20000004(随便写的一个地址,实际地址需要看单片机对应的手册查找),假设这个端口 A 有 16 个引脚,也就是说需要一个 16 bit 空间进行表示。如下:
现在可以看到每一个引脚的电平状态,bit0 高电平,bit1 低电平,bit2 高电平……如果我们需要改变一个 bit 或多个 bit,应该如何改变呢?对于 51 单片机来说,它可以直接对某一个 bit 操作,那么只需要找到某一个引脚地址并赋值即可,而 stm32 单片机有一种称之为位带操作的方法也可以对其中的某一个位进行操作。但是对于那些不能使用这些方法的单片机来说又当如何呢?我们不可能因为这个单片机没有位操作而换一种单片机开发。这个时候就可以使用 & 和 | 了。
看到这里希望各位同学别一脸懵逼啊(虽然当时我也是如此,囧)。其实只要理解了其中的含义,就很容易写出这样的表达式了,也很容易看懂。借此稍微讲讲指针的知识,该部分会在指针小节中进行比较详细的说明。
首先将 0x20000004 转化为指针,因为编译器可不知道 0x20000004 它是一个地址,而可能会认为这是个值,所以我们要告诉编译器,别犯浑,这是一个地址,是一个指针,别弄错了。现在已经确定了这是一个地址,现在往这个地址赋值,指针的赋值就用*,这样编译器就知道应该讲 C 语言该如何编译成汇编语言了。现在看右边部分,这里使用了 ~ ,就是将 0x01 每一位取反。那为什么要使用 0x01,而不直接使用 0xff fe 呢,那你说对于开发人员来说 0x01 更直观还是 0xff fe 更直观呢?另外再说一说为什么要使用强制转化。因为 0x01 在编译器看来,可以是 0x01,可以是 0x0001,也可以是 0x0000 0001,所以一旦取反,编译器就不知道该取反成 0xfe,还是 0xfffe,还是 0xffff fffe,这个时候编译器就会按照默认的情况进行取反了,所以这个值有可能不正确,所以必须强制转化,让编译器知道到底 0x01 是一个几位的值。其实所谓的强制转化在汇编语言里面是没有这个功能的,这是 C 语言用来告诉编译器该如何用汇编语言去实现这条 C 语言语句,这样汇编器就会按照你的 C 语言要求用对应的汇编指令去实现。
然后再看看这次的主角 & ,这里面是 &=,这里就涉及到 C 语言的简写了。实际上分开写应该是这样的:
但是明显这样写的很长,不是很方便。(其实对于初学者来说,直接使用指针操作端口的方法也很少见,更多的是使用写好的头文件,利用头文件中定义去写代码,比如 #define PORTA *(short unsigned int*) 类似的,然后 PORTA &= (~(short unsigned int)0x01),这样又更简单明了了,复杂的活交给编译器就行了,并且这种写法也不会降低操作效率,直观又不降低效率,何乐而不为呢,所以你看到很多大神写的代码,移植性很强,并且修改的时候会非常方便,但是对于初学者就苦逼了,明明一个数值直接写在语句里就行了,非要跳来跳去,跳了很久才找到这个值是哪个,但是等你真正理解了这种写法的好处时,你就不会抱怨了,而是自然而然成为其中的一员,扯远了,扯回来),但是这条语句其实可以进一步拆分,可能更好理解一些:
这里的 temp 是一个 short unsigned int 中间变量,首先将地址 0x20000004 处的值存储到 temp 中,再和 (~(short unsigned int)0x01) 位与(还有一个叫逻辑与,即 &&),之后再存储到地址 0x20000004 处。这样根据位与的特点,就可以将 bit0 清零,其它 bit 原来是什么就是什么。还有在说一点,为什么可以使用 *(short unsigned int*)(0x20000004) =*(short unsigned int*)(0x20000004) & (~(short unsigned int)0x01); 来操作,而不需要中间变量 temp,其实不是说没有中间变量,而是这个中间变量对于我们来说不可见,是透明的,在相应的汇编语句里面,你可以看到,其实它使用了一个寄存器作为中间变量的。
同样的道理,通过 | 就可以对其中某一位置 1,而不用担心其它位的变化。并且这个操作也可以同时对多个 bit 进行操作。比如对 bit0、bit1 清零:
这里只是将某些位置 1,但是如果要想同时让 bit0 = 0,bit1=1,bit2=0 呢?因为不知道原来的值是多少,不能根据原来的状态进行来对需要改变的位进行操作,所以就需要对 bit0、bit1、bit3 全部清零,再按照要求置位:
这样就实现了要求。
说了优点,是时候说一说使用这种方法的潜在的风险了。从 C 语言的角度来看,这里只是一条语句,但是实际的对应汇编指令却由很多条指令组成,并且存在读取-改写-存储三个步骤,这样一旦在读取完数据后,如果某一个引脚状态发生改变,那么势必引起错误操作。比如:
读取数据之前端口引脚是 0x0011,读取后保存到中间变量的值是 0x0011,之后引脚状态改变,变成 0x0001,那么因为中间变量存储的之前的是 0x0011,通过和 0x0002 位或,变成了 0x0013,那么通过赋值语句,端口状态就变成了 0x0013,但是实际上它应该是 0x0003,因为此时 bit4 已经变成了 0,而不是之前的 1。
这样就违背了通过操作不改变其它位的初衷了。所以为了防止打断这个读取 - 改写 - 存储的连续操作,最简单方法就是禁止中断。在使用这个方法时一定要慎重,普通的变量进行 &、 | 操作也是如此,要考虑这个变量是否除了在此处改变,还有没有可能在其他地方改变。不过还好,很多单片机都提供了类似位带的操作。
现在再看 ^ 异或操作,这个操作有什么好处呢?翻转。比如说要一个 LED 灯进行闪烁,此时就可以使用这个 ^ 来实现。让一个数在 0、1、0、1 之间变化,就可以使用这个异或了。当然也可直接自加,然后通过 &0x01 提取最低位即可。
---------------------------------------------------------------------------------------------------------------------------------------------2018/10/14 Osprey
-THE END-
如果觉得文章对你有帮助,欢迎转发、分享给朋友,感谢你的支持!
微信公众号「鱼鹰谈单片机」
每周一更单片机知识
长按后识别图中二维码关注
如果对你有帮助,在这里点个好看再走呗