💡写在前面
大家好! 最近很多刚入门的小伙伴私信我:上节课写的寄存器版流水灯,每次改IO电平都要写GPIOB->ODR |= (1<<n)或者GPIOA->ODR &= ~(1<<n),移位写多了容易搞混位数,还担心写错掩码把其他位改崩了,有没有像51单片机那样sbit LED = P0^0直接操作某一位的方法?
当然有!今天我们就学习STM32专门为简化位操作设计的位带机制,再也不用记复杂的位与、位或逻辑,新手也能写出可读性拉满的寄存器代码~
准备好了吗?坐稳扶好,我们要发车了!🏎️
STM32位带区&位带操作
- 一、先搞懂:什么是位带操作?为什么我们需要它?
-
- 1.1 传统寄存器位操作的痛点
- 1.2 位带操作的通俗解释
- 二、零基础也能算:位带地址的换算公式
-
- 2.1 公式推导说明
- 2.2 举个实际例子算一遍
- 2.3 公式优化(工程实用版)
- 三、一劳永逸:把位带操作封装成通用宏
- 四、实操:用位带操作改写流水灯程序
-
- 4.1 完整代码示例
- 4.2 代码讲解
- 五、位带操作的优点(为什么推荐初学者学?)
- 六、 免费获取资料:
一、先搞懂:什么是位带操作?为什么我们需要它?
1.1 传统寄存器位操作的痛点
我们先回忆一下上节课操作PB7引脚输出电平的代码:
// 熄灭PB7
GPIOB->ODR &= ~(1 << 7);
// 点亮PB7
GPIOB->ODR |= (1 << 7);
这种写法有3个很不友好的点:
- ❌ 容易写错移位位数:比如把7写成5,操作的引脚直接错了
- ❌ 可读性差:新手看代码要反应半天才能知道是操作第7位
- ❌ 风险高:如果掩码写错(比如漏写~),会把ODR寄存器的其他位覆盖,导致同组其他IO电平异常
那有没有办法直接给某一个IO引脚的电平赋值,完全不影响其他位?就像这样:
PB7 = 1; // 直接点亮PC13
PB7 = 0; // 直接熄灭PC13
STM32的位带操作,就是帮我们实现这个功能的底层机制。
1.2 位带操作的通俗解释
你可以把STM32的寄存器当成一个有32个格子的储物柜,每个格子对应1个bit,传统操作是要改某一个格子的东西,得把整个柜子搬出来,找到对应的格子改了再放回去;而位带操作就是给每个格子单独装了一个门,你直接开对应格子的门就能改内容,完全不用碰其他格子。
- STM32的Cortex-M3内核专门划分了两块「位带区」,位带区的每一个bit,都会映射到「位带别名区」的一个独立的32位地址。我们操作这个位带别名地址的最低位,就等价于直接操作原地址对应的那个bit,硬件保证不会影响其他位。
STM32有两个位带区,我们新手只需要重点关注外设位带区就行:
| 外设位带区 | 0x40000000 ~ 0x400FFFFF | 0x42000000 ~ 0x43FFFFFF | 操作GPIO、串口、SPI等外设寄存器的位 |
| SRAM位带区 | 0x20000000 ~ 0x200FFFFF | 0x22000000 ~ 0x23FFFFFF | 操作SRAM变量的某一位 |
- 位带区的每1个比特,都会被“膨胀”成1个32位的字(占4字节),这些膨胀出来的地址集合就是「位带别名区」。
- 比如外设位带区的某一位,对应到别名区就是一个4字节的地址,你给这个地址(4字节)写1/0,就等于给 原位(1个比特)置1/0,完全不用关心整个寄存器的其他位。
二、零基础也能算:位带地址的换算公式
我们不用死记硬背公式,只要理解逻辑就能自己推出来:
我们要操作的寄存器地址为ADDR,要操作的是第n位,那么对应的别名地址计算公式为:
- 别名地址 = 位带别名区基地址 + (ADDR – 位带区基地址) * 32 + n * 4
2.1 公式推导说明
给大家拆解一下每个部分的含义,保证看一遍就懂:
2.2 举个实际例子算一遍
比如我们要操作GPIOB的ODR寄存器的第7位:
先查手册:GPIOB基地址是0x40010C00,ODR寄存器的偏移是0x0C,所以ODR的地址ADDR = 0x40010C00 + 0x0C = 0x40010C0C
外设位带区基地址是0x40000000,别名区基地址是0x42000000
代入公式:
别名地址 = 0x42000000 + (0x40010C0C – 0x40000000) * 32 + 7*4 = 0x4221819C
我们只要往0x4221819C这个地址写1,PB7就会输出高电平;写0就输出低电平,完全不影响GPIO的其他引脚。
2.3 公式优化(工程实用版)
我们要操作的寄存器地址为ADDR,要操作的是第n位,那么对应的别名地址计算公式为:
- 别名地址 =((ADDR & 0xF0000000)+0x02000000+((ADDR & 0x000FFFFF)<<5)+(n<<2)
为什么实际工程采用优化版?
- 因为优化后的版本能一个公式通吃外设/SRAM,采用逻辑运算符,运算速度快,边界兼容性好
公式优化说明:
- ADDR & 0xF0000000是取地址的最高4位,区分当前是SRAM还是外设地址:如果是外设地址,结果就是0x40000000;如果是SRAM地址,结果就是0x20000000,刚好是位带区的基地址
- 两个位带区的别名区和原区差值固定是0x02000000,加完直接得到对应的别名区基地址,不用手动判断是外设还是SRAM
- 外设:0x40000000 + 0x02000000 = 0x42000000(外设别名区起始地址) SRAM:0x20000000 + 0x02000000 = 0x22000000(SRAM别名区起始地址)
- 两个位带区都只有1MB大小,区内偏移只需要低20位就能表示,ADDR & 0x000FFFFF直接抹掉高位前缀,得到的结果完全等价于ADDR – 位带区基地址
- 左移5位等价于乘32,和通用公式的运算完全一致,位运算比减法速度更快,也避免了减法可能出现的边界错误
- 每个位对应别名区4个字节的地址,左移2位等价于乘4,完全等价
三、一劳永逸:把位带操作封装成通用宏
不可能每次操作位都自己算地址对吧?我们可以把换算逻辑写成宏,以后直接调用就行,新手直接抄下面的代码,不用改就能用。
// 核心:位带别名地址换算宏
// addr: 要操作的寄存器地址 bitnum:要操作的位号(0~31)
#define BITBAND(addr, bitnum) ((addr & 0xF0000000)+\\
0x02000000+((addr & 0x00FFFFFF)<<5)+(bitnum<<2))
// 把别名地址转成可操作的指针,实现读写
#define BITADDR(addr, bitnum) *(volatile uint32t *)(BITBAND(addr, bitnum))
BITBAND宏: 这个宏的作用是:输入寄存器的地址和要操作的位序号,输出对应的位带别名区地址。每个部分功能上文已经拆解。
BIT_ADDR宏:怎么操作别名地址? *(volatile uint32_t )(BITBAND(addr, bitnum)) 做了两件事:
- 把BITBAND算出的别名地址,强制转换成uint32_t*类型的指针(因为STM32是32位处理器,操作32位地址更高效)
- 用*对指针做读写操作,本质就是给这个别名地址赋值/取值,等效于操作原寄存器的对应位
- volatile关键字作用是告诉编译器不要优化这个地址的操作,每次读写都要直接访问内存,
四、实操:用位带操作改写流水灯程序
4.1 完整代码示例
typedef unsigned int uint32_t;
typedef unsigned char uint8_t;
// 外设位带区宏定义
#define BITBAND(addr, bitnum) ((addr & 0xF0000000)+\\
0x02000000+((addr & 0x000FFFFF)<<5)+(bitnum<<2))
// 把位带别名区地址转换成指针
#define BIT_ADDR(addr, bitnum) *(volatile uint32_t *)\\
(BITBAND(addr, bitnum))
// 时钟寄存器地址定义
#define AHB_RCC_BASE (uint32_t)0x40021000
#define RCC_CR (AHB_RCC_BASE+0x00)
#define RCC_CFGR (AHB_RCC_BASE+0x04)
#define RCC_CIR (AHB_RCC_BASE+0x08)
#define RCC_APB2RSTR (AHB_RCC_BASE+0x0C)
#define RCC_APB1RSTR (AHB_RCC_BASE+0x10)
#define RCC_AHBENR (AHB_RCC_BASE+0x14)
#define RCC_APB2ENR (AHB_RCC_BASE+0x18)
#define RCC_APB1ENR (AHB_RCC_BASE+0x1C)
#define RCC_BDCR (AHB_RCC_BASE+0x20)
#define RCC_CSR (AHB_RCC_BASE+0x24)
// GPIOB寄存器地址定义
#define GPIOB_BASE (uint32_t)0x40010c00
#define GPIOB_CRL (GPIOB_BASE+0x00)
#define GPIOB_CRH (GPIOB_BASE+0x04)
#define GPIOB_IDR (GPIOB_BASE+0x08)
#define GPIOB_ODR (GPIOB_BASE+0x0C)
#define GPIOB_BSRR (GPIOB_BASE+0x10)
#define GPIOB_BRR (GPIOB_BASE+0x14)
#define GPIOB_LCKR (GPIOB_BASE+0x18)
// 定义PB的位带操作宏
#define PB_OUT(n) BIT_ADDR(GPIOB_ODR,n)
// 延时函数(空循环,新手不用深究,改数值可调整流水速度)
void Delay(uint32_t time)
{
uint32_t i,j;
for(i=0; i<time; i++)
for(j=0; j<1000; j++);
}
int main(void)
{
uint8_t i;
//开启GPIOB口时钟
BIT_ADDR(RCC_APB2ENR,3)=1;
//GPIOB_0~7 配置推挽输出 50mhz
for(i=0;i<8;i++)
{
BIT_ADDR(GPIOB_CRL,(0+4*i))=1;
BIT_ADDR(GPIOB_CRL,(1+4*i))=1;
BIT_ADDR(GPIOB_CRL,(2+4*i))=0;
BIT_ADDR(GPIOB_CRL,(3+4*i))=0;
}
//GPIOB_0~7 led off
for(i=0;i<8;i++)
{
PB_OUT(i) = 0;
}
while(1)
{
// 依次点亮PB0-PB7
for(i=0;i<8;i++)
{
PB_OUT(i) = 1;
Delay(200);
PB_OUT(i) = 0;
}
}
}
void SystemInit(void)
{
}
4.2 代码讲解
代码里有几个典型场景用到了位带操作,我们逐一解释:
// 定义PB的位带操作宏
#define PB_OUT(n) BIT_ADDR(GPIOB_ODR,n)
- PB_OUT(n)完美复刻51单片机sbit的操作逻辑,新手直接用PB_OUT(0)=1就能控制PB0输出高电平,不用记任何移位逻辑
BIT_ADDR(RCC_APB2ENR,3)=1;
- 查手册可知RCC_APB2ENR寄存器的位3对应GPIOB的时钟使能位,直接给该位写1即可,不需要写RCC_APB2ENR |= (1<<3),完全不会误改其他外设的时钟开关
for(i=0;i<8;i++)
{
BIT_ADDR(GPIOB_CRL,(0+4*i))=1;
BIT_ADDR(GPIOB_CRL,(1+4*i))=1;
BIT_ADDR(GPIOB_CRL,(2+4*i))=0;
BIT_ADDR(GPIOB_CRL,(3+4*i))=0;
}
- GPIOB_CRL是GPIOB端口低8位的配置寄存器,每4位控制1个IO口(比如第0-3位控制PB0,第4-7位控制PB1,以此类推) 。要配置PB0-PB7为50MHz推挽输出,对应的位值是0011 。
- 用BIT_ADDR直接操作寄存器的第0+4*i等位,直接赋值1或0,不用先读整个寄存器、修改、再写回,代码更简洁、不容易出错
// 依次点亮PB0-PB7
for(i=0;i<8;i++)
{
PB_OUT(i) = 1;
Delay(200);
PB_OUT(i) = 0;
}
- 流水灯逻辑完全没有复杂的位运算,哪怕是第一次学STM32的新手也能一眼看懂:逐个点亮、延时、逐个熄灭。和51的LED=1写法几乎一样,非常直观 。
五、位带操作的优点(为什么推荐初学者学?)
六、 免费获取资料:
关注下面的 芦苇电子 微信公众号,
在公众号内 私信回复
032
收到后自动发送该仿真资料
如果你能坚持看到这里,说明你已经具备了成为嵌入式大神的潜质!💪 别光看,赶紧打开你的 Keil,新建一个工程,把这段代码跑起来试试吧!
如果觉得这篇文章对你有帮助,欢迎点赞、收藏、转发!——你的认可,是我持续输出嵌入式硬核干货的最大动力!我们下期再见! 🌟
网硕互联帮助中心

评论前必须登录!
注册