嵌入式从零开始(第十篇):位运算的艺术 —— 、|、^、<<、>>

张开发
2026/4/11 4:56:44 15 分钟阅读

分享文章

嵌入式从零开始(第十篇):位运算的艺术 —— 、|、^、<<、>>
前言为什么位运算是嵌入式的基本功如果你跟着本系列一路走到这里你应该已经用 STM32 写过不少代码了。回想一下当你配置 GPIO 模式时是不是写过类似这样的东西GPIO_InitTypeDef GPIO_InitStruct{0};GPIO_InitStruct.PinGPIO_PIN_5;GPIO_InitStruct.ModeGPIO_MODE_OUTPUT_PP;GPIO_InitStruct.PullGPIO_NOPULL;GPIO_InitStruct.SpeedGPIO_SPEED_FREQ_LOW;HAL_GPIO_Init(GPIOA,GPIO_InitStruct);HAL 库帮你封装了底层的寄存器操作看起来似乎不需要位运算。但当你打开stm32f1xx_hal_gpio.c看源码或者需要直接操作寄存器来提升效率、精简代码时你会发现满眼都是、|、^、、。位运算是嵌入式工程师操作寄存器、管理标志位、优化内存的必备技能。它可以让你用一条指令完成原本需要多条语句的工作而且执行效率极高——因为 CPU 硬件本身就支持这些操作。一个事实a % 2 1判断奇偶编译器可能会优化成a 1但如果你直接写a 1不仅更清晰表达“取最低位”的意图而且绝对更快。本文会从最基础的位运算符号讲起然后深入到嵌入式中的典型应用场景最后给出实战案例。即使你已经用过位运算也可以查漏补缺。一、C 语言中的位运算符号C 语言提供了 6 种位运算符号按操作数个数分符号名称类型示例按位与双目a b|按位或双目a | b^按位异或双目a ^ b~按位取反单目~a左移双目a n右移双目a n注意区分逻辑运算符、||、!它们返回的是布尔值0 或 1而位运算返回的是整数值。1.1 按位与规则两位都为 1 时结果为 1否则为 0。aba b000010100111常用场景清零特定位x ~(1 n)取出低 8 位x 0xFF判断奇偶if (x 1)1.2 按位或|规则两位中至少有一位为 1 时结果为 1否则为 0。aba | b000011101111常用场景置位设为 1x | (1 n)合并标志位flags FLAG1 | FLAG21.3 按位异或^规则两位不同时结果为 1相同时为 0。aba ^ b000011101110常用场景翻转特定位x ^ (1 n)不使用临时变量交换两个数a ^ b; b ^ a; a ^ b;判断两个数是否相等(a ^ b) 01.4 按位取反~规则将每一位取反0 变 11 变 0。注意~的结果类型会进行整型提升通常用于构造掩码~(1 n)表示“除了第 n 位为 0其余位为 1”。1.5 左移和右移a n将 a 的二进制表示向左移动 n 位低位补 0。相当于乘以 2 的 n 次方在不溢出的情况下。a n将 a 的二进制表示向右移动 n 位。对于无符号数高位补 0对于有符号数行为与实现相关通常算术右移高位补符号位。建议只对无符号类型进行右移。常用场景快速乘除 2 的幂x 1代替x * 2x 1代替x / 2但现代编译器会自动优化写清楚意图更重要。构造位掩码(1 n) - 1得到低 n 位为 1 的掩码。二、嵌入式中的典型应用2.1 操作寄存器最核心STM32 的外设控制寄存器都是内存映射的每个位或位域有特定含义。HAL 库最终也是通过这些位操作来实现的。直接操作寄存器点亮 LED假设 GPIOC 的 ODR 寄存器地址为0x4001100C// 设置 PC13 为高电平*(uint32_t*)0x4001100C|(113);// 设置 PC13 为低电平*(uint32_t*)0x4001100C~(113);当然实际代码中我们使用 CMSIS 定义的宏GPIOC-ODR|(113);// 置位GPIOC-ODR~(113);// 清零2.2 读取/修改位域很多寄存器是多个字段拼在一个 32 位字里的。例如 STM32 的 SysTick 控制与状态寄存器SYST_CSR位名称含义0ENABLE使能计数器1TICKINT异常使能2CLKSOURCE时钟源选择16COUNTFLAG计数到 0 标志要读取 COUNTFLAG第 16 位可以uint32_tflag(SysTick-CTRL16)1;要修改 ENABLE 和 CLKSOURCE 而不影响其他位SysTick-CTRL(SysTick-CTRL~((10)|(12)))|(10)|(12);这太啰嗦了所以通常会封装成宏#defineSET_BIT(reg,bit)((reg)|(1U(bit)))#defineCLR_BIT(reg,bit)((reg)~(1U(bit)))#defineGET_BIT(reg,bit)(((reg)(bit))1U)SET_BIT(SysTick-CTRL,0);// 使能CLR_BIT(SysTick-CTRL,2);// 时钟源使用系统时钟/8假设2.3 位域结构体谨慎使用C 语言允许定义位域结构体编译器会生成相应的位操作代码struct{uint32_tENABLE:1;uint32_tTICKINT:1;uint32_tCLKSOURCE:1;uint32_tRESERVED:13;uint32_tCOUNTFLAG:1;}*CSR(void*)0xE000E010;但位域的内存布局是编译器和平台相关的大小端、对齐不推荐用于直接映射硬件寄存器除非你非常清楚编译器的行为。更好的做法是用位操作宏或者 CMSIS 已经定义好的结构体如SysTick_Type。2.4 组合标志位很多 API 使用位掩码来传递多个选项#defineOPTION_A(10)#defineOPTION_B(11)#defineOPTION_C(12)voidconfig(uint32_toptions){if(optionsOPTION_A){/* 启用 A */}if(optionsOPTION_B){/* 启用 B */}// ...}// 调用config(OPTION_A|OPTION_C);// 启用 A 和 C2.5 高效的数据打包/解包在通信协议中经常需要将多个小数据打包成一个字节。例如将两个 4 位值0~15合成一个字节uint8_tpack(uint8_thigh,uint8_tlow){return(high4)|(low0x0F);}voidunpack(uint8_tdata,uint8_t*high,uint8_t*low){*highdata4;*lowdata0x0F;}2.6 环形缓冲区索引取模使用按位与代替取模运算但前提是缓冲区大小是 2 的幂#defineBUFFER_SIZE16// 必须是 2 的幂uint8_tbuffer[BUFFER_SIZE];uint8_tidx0;voidpush(uint8_tval){buffer[idx(BUFFER_SIZE-1)]val;idx;}因为idx (BUFFER_SIZE - 1)等价于idx % BUFFER_SIZE但位运算快得多。三、实战实现一个简单的 GPIO 模拟 I2C 的位操作I2C 的起始、停止、发送字节都需要精确控制 SDA 和 SCL 线的电平。用位操作封装会非常干净// 假设使用 GPIOB 的 Pin6(SCL) 和 Pin7(SDA)#defineI2C_SCL_HIGH()(GPIOB-BSRRGPIO_PIN_6)// BSRR 高位置位#defineI2C_SCL_LOW()(GPIOB-BRRGPIO_PIN_6)// BRR 低位置位#defineI2C_SDA_HIGH()(GPIOB-BSRRGPIO_PIN_7)#defineI2C_SDA_LOW()(GPIOB-BRRGPIO_PIN_7)#defineI2C_SDA_READ()((GPIOB-IDR7)1)voidI2C_Start(void){I2C_SDA_HIGH();I2C_SCL_HIGH();delay();I2C_SDA_LOW();delay();I2C_SCL_LOW();delay();}voidI2C_Stop(void){I2C_SDA_LOW();I2C_SCL_HIGH();delay();I2C_SDA_HIGH();delay();}uint8_tI2C_WriteByte(uint8_tdata){for(inti0;i8;i){if(data0x80)I2C_SDA_HIGH();elseI2C_SDA_LOW();data1;delay();I2C_SCL_HIGH();delay();I2C_SCL_LOW();delay();}// 读取 ACKI2C_SDA_HIGH();delay();I2C_SCL_HIGH();delay();uint8_tackI2C_SDA_READ();I2C_SCL_LOW();delay();returnack;// 0 ACK}这里大量使用了移位和位测试是位运算的经典应用。四、常见误区与注意事项4.1 有符号数的右移问题int32_t x -16; x 2;结果是什么C 标准规定有符号负数右移是“实现定义”的大多数编译器会进行算术右移高位补符号位结果可能为-4不是-4-16 2在算术右移下是-4因为-16 0xFFFFFFF0右移 2 位得0xFFFFFFFC -4。但如果你想做逻辑右移高位补 0必须使用无符号类型。原则只对无符号整数使用右移。4.2 1 默认是 signed int1 31在 32 位 int 平台上会产生溢出未定义行为因为 1 是 signed int。正确写法1U 31。同理~(1 7)可能产生意外结果应该用~(1U 7)。4.3 位域的可移植性问题之前提到过不要用 C 位域直接映射硬件寄存器因为编译器对位域的安排从高位还是低位开始是否跨越字节边界不统一。MISRA C 规范甚至建议禁止使用位域。4.4 不必要的优化不要为了“效率”而把x * 10写成(x 3) (x 1)。现代编译器会做常量折叠和强度削弱你只需要写清晰即可。但像x % 16这种明显可以用x 15替代的可以直接用位运算。4.5 忘记括号位运算符优先级低于算术运算符、关系运算符但高于逻辑运算符。例如if (x 0x80 0x80)实际上被解析为if (x (0x80 0x80))永远为真。正确写法if ((x 0x80) 0x80)。建议对位运算表达式总是加括号。五、总结按位与清零、取位。按位或|置位、合并标志。按位异或^翻转、交换。取反~生成掩码。左移、右移快速乘除 2 的幂构造掩码。核心原则无符号类型、加括号、小心有符号右移。典型场景寄存器操作、标志位管理、数据打包、环形缓冲区。系列导航第一篇嵌入式到底是什么含 ARM/C51/STM32 关系第二篇串口江湖 —— UART、RS-232、RS-485番外篇波特率解析第三篇两线走天下 —— I2C 总线精讲第四篇极速先锋 —— SPI 总线精讲第五篇嵌入式大脑 —— 中断与事件驱动第六篇时间管理大师 —— 定时器与系统滴答第七篇存储与地址 —— 大小端、内存映射、4GB 空间之谜第八篇从裸机到 RTOS —— 任务、调度、FreeRTOS第九篇任务间悄悄话 —— 队列、信号量、互斥量第十篇位运算的艺术 —— 、|、^、、本文第十一篇芯片选型 —— STM32 vs ESP32预告

更多文章