第3章 位运算与宏定义:硬件操控的“原子弹”

说实话,很多初学者觉得位运算就是些“与或非”的小把戏。但在我眼里,位运算和宏定义,是嵌入式工程师手里最锋利的刀。你想想看,一个32位的寄存器,你要控制其中某一位,不改动其他位——这不就是硬件编程的日常吗?

我个人习惯把位运算比作“手术刀”。你不能拿大砍刀去修手表,对吧?位运算就是那把精细的手术刀。

3.1 位运算基础:五个“兵器”

嵌入式里常用的位运算就五种:与、或、异或、取反、移位。每个都有它的脾气。

运算符 C语言符号 作用 我常用的场景
位与 & 清零某位 关闭外设、清除标志位
位或 | 置位某位 开启外设、设置功能
位异或 ^ 翻转某位 LED闪烁、状态切换
取反 ~ 按位取反 配合与运算做掩码
左移/右移 << / >> 移动位位置 构造位掩码、乘除2的幂

核心口诀:与清零,或置位,异或翻转,取反配合,移位定位。

3.2 位与(&):精准“清零”

位与的规则很简单:两个都是1才为1,否则为0。但它的真正威力在于——你可以保留想要的位,清除不想要的位。

我在项目中遇到过这样一个坑:有个传感器中断标志位,读完后必须软件清零。如果我用赋值语句直接写0,会把其他位也清掉,导致系统崩溃。后来我用位与操作,只清这一位:

// 假设寄存器地址 0x40020000,第3位是中断标志
#define REG_ADDR  (*(volatile uint32_t *)0x40020000)

// 只清除第3位,其他位不变
REG_ADDR &= ~(1 << 3);

看到了吗?~(1 << 3) 构造了一个“除了第3位是0,其他都是1”的掩码。与运算后,只有第3位被清零。

我的小技巧:写位操作时,先把掩码算出来,再去做运算。别在脑子里硬算,容易出错。

3.3 位或(|):快速“置位”

位或的规则:只要有一个是1,结果就是1。说白了,就是用来“打开”某个功能的。

比如你要配置GPIO的推挽输出模式,通常需要设置某几位的值。用位或操作,不会影响其他已经配置好的位:

// 设置GPIOA的第5脚为输出模式(假设MODER寄存器每2位控制一个引脚)
#define GPIOA_MODER  (*(volatile uint32_t *)0x40020000)

// 设置第5脚为输出(01),其他引脚保持原样
GPIOA_MODER |= (1 << 10);  // 第5脚对应位10-11,设置位10为1
GPIOA_MODER &= ~(1 << 11); // 确保位11为0

嗯,这里要注意:置位和清零经常是成对出现的。先或后与,或者先与后或,顺序很重要。

3.4 位异或(^):一键“翻转”

异或的规则:相同为0,不同为1。它的妙用在于——你不需要知道当前状态,直接异或就能翻转。

我曾经写过一个LED呼吸灯程序,用异或来切换状态,代码简洁到不行:

// 翻转第7位(LED控制位)
GPIO_ODR ^= (1 << 7);

为什么不用取反?因为取反会把所有位都翻转,而异或只翻转你指定的位。这就是区别。

3.5 左移右移:构造“掩码”的利器

左移右移本身不复杂,但它是构造掩码的基础。我见过太多新手直接写 0x00000080 这种硬编码,看得我头皮发麻。

正确的做法是用移位:

// 不好的写法:可读性差
#define BIT7_MASK 0x80

// 好的写法:一目了然
#define BIT7_MASK (1 << 7)

// 多位的掩码
#define BITS_3_5_MASK ((1 << 3) | (1 << 4) | (1 << 5))

你想想看,如果代码里到处都是 0x400、0x8000,三个月后你自己都看不懂。用移位,别人一看就知道操作的是第几位。

3.6 宏定义封装:让寄存器操作“说人话”

宏定义是C语言给嵌入式工程师的礼物。没有宏定义,你写的代码就是一堆魔法数字。有了宏定义,代码就像在读说明书。

我个人习惯把寄存器操作封装成三类宏:

  1. 位操作宏:设置、清除、读取、翻转
  2. 寄存器地址宏:把物理地址映射成可读的名字
  3. 功能组合宏:把多个位操作组合成一个功能
// 1. 位操作宏
#define SET_BIT(reg, bit)     ((reg) |= (1 << (bit)))
#define CLR_BIT(reg, bit)     ((reg) &= ~(1 << (bit)))
#define READ_BIT(reg, bit)    (((reg) >> (bit)) & 1)
#define TOGGLE_BIT(reg, bit)  ((reg) ^= (1 << (bit)))

// 2. 寄存器地址宏
#define GPIOA_BASE      0x40020000
#define GPIOA_MODER     (*(volatile uint32_t *)(GPIOA_BASE + 0x00))
#define GPIOA_OTYPER    (*(volatile uint32_t *)(GPIOA_BASE + 0x04))
#define GPIOA_OSPEEDR   (*(volatile uint32_t *)(GPIOA_BASE + 0x08))
#define GPIOA_PUPDR     (*(volatile uint32_t *)(GPIOA_BASE + 0x0C))
#define GPIOA_IDR       (*(volatile uint32_t *)(GPIOA_BASE + 0x10))
#define GPIOA_ODR       (*(volatile uint32_t *)(GPIOA_BASE + 0x14))

// 3. 功能组合宏
#define GPIOA_PIN5_OUTPUT() \
  do { \
    CLR_BIT(GPIOA_MODER, 10); \
    SET_BIT(GPIOA_MODER, 11); \
    CLR_BIT(GPIOA_OTYPER, 5); \
    CLR_BIT(GPIOA_PUPDR, 10); \
    CLR_BIT(GPIOA_PUPDR, 11); \
  } while(0)

我曾经踩过的坑:宏定义里的参数一定要加括号!不加括号,遇到复杂表达式时,运算符优先级会把你坑到怀疑人生。比如 SET_BIT(reg+1, 3) 如果不加括号,就变成了 reg+1 |= (1<<3),编译直接报错。

3.7 实战:用宏实现GPIO配置

好了,理论说完了,咱们来点真格的。下面是一个完整的GPIO输出配置示例,用宏封装得干干净净:

// gpio_config.h
#ifndef __GPIO_CONFIG_H__
#define __GPIO_CONFIG_H__

#include <stdint.h>

/* 位操作宏 */
#define BIT(n)              (1 << (n))
#define SET_BIT(reg, n)     ((reg) |= BIT(n))
#define CLR_BIT(reg, n)     ((reg) &= ~BIT(n))
#define READ_BIT(reg, n)    (((reg) >> (n)) & 1)
#define TOGGLE_BIT(reg, n)  ((reg) ^= BIT(n))

/* GPIO模式定义 */
#define GPIO_MODE_INPUT     0
#define GPIO_MODE_OUTPUT    1
#define GPIO_MODE_AF        2
#define GPIO_MODE_ANALOG    3

/* 输出类型 */
#define GPIO_OTYPE_PP       0  // 推挽
#define GPIO_OTYPE_OD       1  // 开漏

/* 速度 */
#define GPIO_SPEED_LOW      0
#define GPIO_SPEED_MEDIUM   1
#define GPIO_SPEED_HIGH     2
#define GPIO_SPEED_VERYHIGH 3

/* 上下拉 */
#define GPIO_PUPD_NONE      0
#define GPIO_PUPD_UP        1
#define GPIO_PUPD_DOWN      2

/* GPIO配置宏 */
#define GPIO_CONFIG_OUTPUT(base, pin) \
  do { \
    /* 设置模式为输出 */ \
    volatile uint32_t *moder = (volatile uint32_t *)((uint32_t)(base) + 0x00); \
    *moder = (*moder & ~(0x3 << ((pin) * 2))) | (GPIO_MODE_OUTPUT << ((pin) * 2)); \
    /* 设置推挽输出 */ \
    volatile uint32_t *otyper = (volatile uint32_t *)((uint32_t)(base) + 0x04); \
    CLR_BIT(*otyper, pin); \
    /* 设置速度为中等 */ \
    volatile uint32_t *ospeedr = (volatile uint32_t *)((uint32_t)(base) + 0x08); \
    *ospeedr = (*ospeedr & ~(0x3 << ((pin) * 2))) | (GPIO_SPEED_MEDIUM << ((pin) * 2)); \
    /* 无上下拉 */ \
    volatile uint32_t *pupdr = (volatile uint32_t *)((uint32_t)(base) + 0x0C); \
    *pupdr = (*pupdr & ~(0x3 << ((pin) * 2))) | (GPIO_PUPD_NONE << ((pin) * 2)); \
  } while(0)

/* 写引脚 */
#define GPIO_WRITE(base, pin, val) \
  do { \
    volatile uint32_t *odr = (volatile uint32_t *)((uint32_t)(base) + 0x14); \
    if(val) SET_BIT(*odr, pin); \
    else    CLR_BIT(*odr, pin); \
  } while(0)

/* 读引脚 */
#define GPIO_READ(base, pin) \
  READ_BIT(*(volatile uint32_t *)((uint32_t)(base) + 0x10), pin)

#endif /* __GPIO_CONFIG_H__ */

使用起来就很简单了:

// main.c
#include "gpio_config.h"

#define GPIOA_BASE  0x40020000

int main(void) {
  // 配置PA5为输出
  GPIO_CONFIG_OUTPUT(GPIOA_BASE, 5);
  
  // 写高电平
  GPIO_WRITE(GPIOA_BASE, 5, 1);
  
  // 延时后写低电平
  for(volatile int i = 0; i < 1000000; i++);
  GPIO_WRITE(GPIOA_BASE, 5, 0);
  
  while(1);
}

核心思想:宏定义不是为了让代码变短,而是为了让代码变“聪明”。好的宏定义,读起来就像在说人话,而不是在猜谜语。

3.8 本章知识体系

下面这张图,是我自己梳理的位运算与宏定义的知识脉络。你看一眼,就能明白它们之间的关系:

位运算与宏定义知识体系 硬件寄存器操作 位运算基础 位与 & 位或 | 异或 ^ 取反 ~ 左移/右移 宏定义封装 位操作宏 地址映射宏 功能组合宏 参数化宏 实战:GPIO配置宏封装

这张图把位运算和宏定义的关系讲得很清楚。左边是“术”——具体的位操作技巧;右边是“法”——如何用宏定义把这些技巧封装成可复用的代码。两者结合,就是硬件编程的“道”。

3.9 避坑指南

最后,分享几个我这些年踩过的坑,希望能帮你省点调试时间:

  • volatile关键字不能少:寄存器地址一定要用volatile修饰,否则编译器优化会把你坑哭。我见过有人调试一整天,结果发现是编译器把寄存器读操作优化掉了。
  • 宏参数加括号:前面说过了,这是血泪教训。不加括号,运算符优先级会让你怀疑人生。
  • do-while(0)包裹多语句宏:如果你在宏里写了多条语句,一定要用do-while(0)包起来。否则在if-else里用这个宏,编译会报错。
  • 位宽大于1的寄存器用掩码+移位:比如配置GPIO模式需要2位,不要用SET_BIT和CLR_BIT组合,直接用 (reg & ~mask) | (value << shift) 更安全。

我的习惯:每次写完宏定义,我都会手动展开一遍,看看会不会有优先级问题。虽然麻烦,但能省下后面调试的无数时间。

好了,位运算和宏定义就讲到这里。这些东西看着简单,但用好了,你的代码会变得既高效又优雅。记住:好的嵌入式代码,不是写给人看的,就是写给硬件看的。而宏定义,就是这两者之间的翻译官。

可可精品资料库