第2章 GPIO基础与寄存器映射:模式配置、推挽与开漏、寄存器实战
各位同学,欢迎来到第二章。上一章我们搭好了开发环境,点亮了第一颗LED。但说实话,那个点灯程序你大概率没看懂——为什么往某个地址写个1,灯就亮了?
这一章,我们就彻底把GPIO的底裤扒干净。从寄存器映射到模式配置,从推挽输出到开漏输出,最后手写一段寄存器操作代码。学完这章,你才算真正入了嵌入式开发的门。
2.1 GPIO模块的寄存器映射
CH32V307的GPIO挂在APB2总线上。每个GPIO端口(PA、PB、PC...)都有一组固定的寄存器,控制着该端口的所有引脚。
我个人习惯把寄存器映射理解成“门牌号”。芯片设计者给每个寄存器分配了一个唯一的地址,CPU通过地址总线找到它,然后读写数据。就这么简单。
以GPIOA为例,它的基地址是0x40010800。从这个地址开始,偏移0x00是配置寄存器(CFGLR),偏移0x04是配置寄存器高(CFGHR),偏移0x0C是输出数据寄存器(OUTDR),偏移0x10是输入数据寄存器(INDR),等等。
下面这张图展示了GPIOA的寄存器映射结构,我建议你把它存下来,写代码时经常要回头看的。
2.2 GPIO的四种工作模式
CH32V307的每个GPIO引脚都可以独立配置为四种模式之一。你想想看,一个引脚既要能输出控制信号,又要能读取传感器状态,还要能复用成串口、SPI——没有模式切换根本做不到。
| 模式 | CNF[1:0] | 典型用途 | 内部结构 |
|---|---|---|---|
| 通用推挽输出 | 00 | 驱动LED、继电器、普通IO | PMOS+NMOS互补输出 |
| 通用开漏输出 | 01 | I²C总线、电平转换、线与逻辑 | 只有NMOS,高阻需上拉 |
| 复用功能推挽 | 10 | SPI、USART、PWM等外设输出 | 输出由外设控制 |
| 复用功能开漏 | 11 | I²C的SCL/SDA、某些特殊协议 | 外设控制+开漏结构 |
这里有个细节:配置寄存器CFGLR和CFGHR每个引脚占用4位,其中高2位是CNF(模式),低2位是MODE(速度)。MODE只在输出模式下有效,输入模式下MODE必须写00。
2.3 推挽输出 vs 开漏输出
这两个概念,我当年学的时候也迷糊了好一阵。说白了,就是输出级的结构不同。
推挽输出:内部有PMOS和NMOS两个管子。输出1时PMOS导通、NMOS截止,引脚直接连VCC;输出0时PMOS截止、NMOS导通,引脚直接连GND。所以推挽输出能主动拉高和拉低,驱动能力强。
开漏输出:内部只有NMOS,没有PMOS。输出0时NMOS导通,引脚拉低;输出1时NMOS截止,引脚处于高阻态(浮空)。要想输出高电平,必须在外部接一个上拉电阻到VCC。
我在项目中遇到过一个问题:用推挽输出驱动I²C总线,结果通信死活不正常。后来发现,I²C协议要求“线与”特性——多个设备可以同时拉低总线,但推挽输出会强行拉高,导致冲突。换成开漏输出加4.7kΩ上拉电阻,问题立刻解决。
下面这个表格总结了推挽和开漏的核心区别,写代码前看一眼,能省很多调试时间。
| 特性 | 推挽输出 | 开漏输出 |
|---|---|---|
| 输出高电平 | 主动拉高到VCC | 高阻态,需外部上拉 |
| 输出低电平 | 主动拉低到GND | 主动拉低到GND |
| 驱动能力 | 强(源电流+灌电流) | 仅灌电流,源电流靠上拉 |
| 线与能力 | 不支持 | 支持 |
| 电平转换 | 需电平转换芯片 | 上拉到目标电压即可 |
| 功耗 | 静态无电流 | 上拉电阻有持续电流 |
2.4 寄存器操作实战:手写GPIO驱动
好了,理论讲完了,咱们直接上代码。下面这段程序,用寄存器操作的方式,把PA0配置为推挽输出,然后循环闪烁。
为什么不用库函数?因为你要理解底层。库函数封装得再好,出了问题你还是要看寄存器。我面试嵌入式工程师时,第一个问题就是“GPIO的配置寄存器地址是多少”。答不上来的,基本就凉了。
// 寄存器地址定义
#define GPIOA_BASE 0x40010800
#define GPIOA_CFGLR (*(volatile uint32_t *)(GPIOA_BASE + 0x00))
#define GPIOA_OUTDR (*(volatile uint32_t *)(GPIOA_BASE + 0x0C))
#define GPIOA_BSHR (*(volatile uint32_t *)(GPIOA_BASE + 0x14))
// RCC寄存器:使能GPIOA时钟
#define RCC_APB2PCENR (*(volatile uint32_t *)0x40021018)
void GPIO_Init(void)
{
// 1. 使能GPIOA时钟(APB2总线)
RCC_APB2PCENR |= (1 << 2); // 第2位置1,使能GPIOA时钟
// 2. 配置PA0为推挽输出,速度50MHz
// PA0对应CFGLR的[3:0]位
// 先清零,再写入:CNF=00(推挽),MODE=11(50MHz)
GPIOA_CFGLR &= ~(0xF << 0); // 清零低4位
GPIOA_CFGLR |= (0x3 << 0); // MODE=11, CNF=00 → 0x3
}
void GPIO_SetPin(uint8_t pin, uint8_t level)
{
if (level)
GPIOA_BSHR = (1 << pin); // 置位:输出高
else
GPIOA_BSHR = (1 << (pin + 16)); // 清除:输出低
}
// 主循环
int main(void)
{
GPIO_Init();
while (1)
{
GPIO_SetPin(0, 1); // PA0输出高
Delay(500000);
GPIO_SetPin(0, 0); // PA0输出低
Delay(500000);
}
}
volatile关键字告诉编译器不要优化这个变量,每次都要从内存读取。没有它,优化后的代码可能永远读不到外设的最新状态。- BSHR寄存器是“宝贝”——低16位写1置位,高16位写1清除。同时写0无影响。这样一条指令就能完成原子操作,不会被中断打断。
- 时钟使能是第一步。CH32V307的外设默认时钟是关闭的,你不开时钟,写寄存器等于白写。我见过太多新手卡在这一步。
2.5 开漏输出实战:模拟I²C起始信号
为了让你彻底理解开漏,咱们写一个模拟I²C起始信号的代码。SCL和SDA都配置为开漏输出,外部接4.7kΩ上拉到3.3V。
// 配置PB6(SCL)和PB7(SDA)为开漏输出
#define GPIOB_CFGLR (*(volatile uint32_t *)(GPIOB_BASE + 0x00))
#define GPIOB_OUTDR (*(volatile uint32_t *)(GPIOB_BASE + 0x0C))
#define GPIOB_BSHR (*(volatile uint32_t *)(GPIOB_BASE + 0x14))
void I2C_GPIO_Init(void)
{
RCC_APB2PCENR |= (1 << 3); // 使能GPIOB时钟
// PB6: CFGLR[27:24], PB7: CFGLR[31:28]
// 开漏输出:CNF=01, MODE=11 → 0b0111 = 0x7
GPIOB_CFGLR &= ~(0xFF << 24); // 清零PB6和PB7的配置位
GPIOB_CFGLR |= (0x77 << 24); // 同时配置两个引脚
}
void I2C_Start(void)
{
// 起始信号:SCL高电平时,SDA从高变低
GPIOB_BSHR = (1 << 6); // SDA输出高(实际靠上拉)
GPIOB_BSHR = (1 << 7); // SCL输出高
Delay(10);
GPIOB_BSHR = (1 << (6 + 16)); // SDA拉低
Delay(10);
GPIOB_BSHR = (1 << (7 + 16)); // SCL拉低
Delay(10);
}
注意看,开漏模式下,我们“输出高”其实只是让引脚变成高阻态,真正的高电平来自上拉电阻。这就是为什么开漏输出能实现电平转换——上拉电阻接3.3V就是3.3V电平,接5V就是5V电平。
2.6 本章小结
这一章我们干了三件事:
- 搞懂了寄存器映射——基地址+偏移量,查手册就能找到所有寄存器
- 分清了推挽和开漏——推挽主动拉高拉低,开漏只拉低、高电平靠上拉
- 手写了寄存器操作代码——从时钟使能到配置寄存器,再到BSHR原子操作