2. GPIO 基础:从寄存器到实战
各位同学,欢迎来到第二章。上一章我们把 CH32V203 的开发环境搭好了,也跑了个点灯程序。但说实话,那时候你可能只是复制粘贴,心里没底。
这一章,咱们就把 GPIO 彻底搞明白。我做了十几年嵌入式,GPIO 是打交道最多的外设,没有之一。你想想看,一个芯片再强大,最后不还是得靠那几个引脚跟外界通信?
好,咱们开始。
2.1 GPIO 工作原理:引脚背后的电路
GPIO,全称 General-Purpose Input/Output,通用输入输出口。说白了,就是芯片上那些可以编程控制的金属引脚。
每个 GPIO 引脚内部,其实是一套完整的电路。我画个简化的框图,你一看就懂。
从图里你能看到,GPIO 不是一根导线那么简单。它内部有寄存器、驱动电路、保护电路。输出时,CPU 往寄存器写数据,驱动电路再把信号送到引脚。输入时,引脚电平经过施密特触发器整形,然后 CPU 去读寄存器。
2.2 寄存器映射:操作硬件的钥匙
好,现在你知道 GPIO 内部长什么样了。但怎么控制它?答案是:操作寄存器。
寄存器,就是芯片内部一个个"小房间",每个房间有特定地址。CPU 通过读写这些地址,就能控制硬件。这就是所谓的"寄存器映射"——把硬件功能映射到内存地址空间。
CH32V203 的 GPIO 寄存器,起始地址是 0x4001 0800(GPIOA)。每个 GPIO 组有 7 个主要寄存器,我列个表:
| 寄存器 | 偏移地址 | 功能描述 | 宽度 |
|---|---|---|---|
| CFGLR | 0x00 | 配置低 8 个引脚的模式/速度 | 32 位 |
| CFGHR | 0x04 | 配置高 8 个引脚的模式/速度 | 32 位 |
| INDR | 0x08 | 输入数据寄存器(只读) | 32 位 |
| OUTDR | 0x0C | 输出数据寄存器 | 32 位 |
| BSHR | 0x10 | 位设置/清除寄存器 | 32 位 |
| BCR | 0x14 | 位清除寄存器 | 32 位 |
| LCKR | 0x18 | 配置锁定寄存器 | 32 位 |
举个例子,如果你想操作 GPIOA 的第 0 脚(PA0),那么:
- 配置模式:操作
GPIOA_BASE + 0x00(CFGLR)的 bit[3:0] - 输出高电平:操作
GPIOA_BASE + 0x0C(OUTDR)的 bit 0 - 读取电平:读
GPIOA_BASE + 0x08(INDR)的 bit 0
2.3 点亮 LED:第一个实战
理论讲完了,咱们动手。开发板上通常有个 LED 接在 PA0 上(具体看你的板子原理图)。点亮它,需要三步:
- 开启 GPIOA 时钟——CH32V203 的外设时钟默认是关闭的,省电嘛。
- 配置 PA0 为推挽输出——设置 CFGLR 寄存器。
- 设置 PA0 输出高电平——写 OUTDR 或 BSHR 寄存器。
看代码:
// 步骤1:开启 GPIOA 时钟
// RCC 基地址 0x4002 1000,APB2PCENR 偏移 0x18
// GPIOA 对应 bit 2
*(volatile uint32_t *)0x40021018 |= (1 << 2);
// 步骤2:配置 PA0 为推挽输出,50MHz
// GPIOA 基地址 0x4001 0800,CFGLR 偏移 0x00
// PA0 对应 bit[3:0],模式 = 0011 (推挽输出 50MHz)
*(volatile uint32_t *)0x40010800 &= ~(0x0F << 0); // 先清零
*(volatile uint32_t *)0x40010800 |= (0x03 << 0); // 设置模式
// 步骤3:PA0 输出高电平
// 使用 BSHR 寄存器(偏移 0x10),低 16 位是设置位
// 写 bit 0 为 1,PA0 输出高电平
*(volatile uint32_t *)0x40010810 |= (1 << 0);
这段代码直接操作物理地址,看起来很原始。但这就是底层原理。你理解了它,再用库函数就知其所以然了。
OUTDR |= 0x01,这是一个"读-改-写"操作,在多任务环境下可能出问题。BSHR 寄存器是专门为原子操作设计的——写 1 置位,写 0 无影响。我曾经在一个产品中因为没注意这个,导致两个任务同时操作 GPIO 时出现毛刺,排查了两天才找到原因。
2.4 按键输入检测:从读到消抖
LED 能亮了,接下来让按键控制它。按键检测比输出复杂一点,因为涉及:
- 输入模式配置
- 电平读取
- 软件消抖
假设按键接在 PA1,按下为低电平(外部上拉)。配置代码:
// 配置 PA1 为输入模式(浮空输入)
// CFGLR 中 PA1 对应 bit[7:4],模式 = 0100 (浮空输入)
*(volatile uint32_t *)0x40010800 &= ~(0x0F << 4);
*(volatile uint32_t *)0x40010800 |= (0x04 << 4);
// 读取 PA1 电平
uint8_t key_val = (*(volatile uint32_t *)0x40010808 >> 1) & 0x01;
但直接读不行,按键有抖动。按下和松开时,电平会跳变几十毫秒。我的做法是:
// 按键消抖函数
uint8_t Key_Scan(void)
{
if((*(volatile uint32_t *)0x40010808 >> 1) & 0x01) // 读 PA1
{
return 0; // 高电平,按键未按下
}
// 延时消抖,大约 20ms
for(volatile uint32_t i = 0; i < 200000; i++);
// 再次确认
if((*(volatile uint32_t *)0x40010808 >> 1) & 0x01)
{
return 0; // 抖动,不是真的按下
}
return 1; // 确实按下了
}
2.5 位带操作:让 GPIO 像 51 一样简单
说到位带操作,我得先感慨一句。很多从 51 单片机转过来的同学,最不习惯的就是 STM32/CH32 的 GPIO 操作——不能直接写 PA0 = 1。位带操作就是来解决这个问题的。
位带操作的原理:把 1 个 bit 映射到 1 个 32 位的字地址。你操作这个字地址,就等于操作那个 bit。CH32V203 支持两个位带区:
| 位带区 | 地址范围 | 别名区地址 |
|---|---|---|
| SRAM 位带 | 0x2000 0000 ~ 0x200F FFFF | 0x2200 0000 ~ 0x23FF FFFF |
| 外设位带 | 0x4000 0000 ~ 0x400F FFFF | 0x4200 0000 ~ 0x43FF FFFF |
计算公式很简单:
别名区地址 = 位带区基地址 + (字节偏移 * 32) + (位号 * 4)
举个例子,GPIOA 的 OUTDR 寄存器地址是 0x4001 080C,我们要操作 bit 0(PA0):
// 计算别名地址
// 字节偏移 = 0x4001080C - 0x40000000 = 0x1080C
// 别名地址 = 0x42000000 + (0x1080C * 32) + (0 * 4)
// = 0x42000000 + 0x210180
// = 0x42210180
#define PA0_OUT (*(volatile uint32_t *)0x42210180)
// 现在可以像 51 一样操作了
PA0_OUT = 1; // PA0 输出高电平
PA0_OUT = 0; // PA0 输出低电平
是不是很爽?但注意,位带操作不是所有芯片都支持。CH32V203 是支持的,我确认过。
#define LED_PIN PA0_OUT
#define KEY_PIN PA1_IN
这样代码可读性极高,而且修改引脚时只需要改一个宏。
2.6 综合实战:按键控制 LED
最后,把今天学的串起来。用位带操作实现:按键按下,LED 翻转。
// 位带地址定义
#define PA0_OUT (*(volatile uint32_t *)0x42210180)
#define PA1_IN (*(volatile uint32_t *)0x42210184)
// 按键消抖
uint8_t Key_Scan(void)
{
if(PA1_IN) return 0; // 高电平,未按下
for(volatile uint32_t i = 0; i < 200000; i++); // 延时
if(PA1_IN) return 0; // 抖动
return 1;
}
int main(void)
{
// 开启 GPIOA 时钟
*(volatile uint32_t *)0x40021018 |= (1 << 2);
// PA0 推挽输出
*(volatile uint32_t *)0x40010800 &= ~(0x0F << 0);
*(volatile uint32_t *)0x40010800 |= (0x03 << 0);
// PA1 浮空输入
*(volatile uint32_t *)0x40010800 &= ~(0x0F << 4);
*(volatile uint32_t *)0x40010800 |= (0x04 << 4);
while(1)
{
if(Key_Scan())
{
PA0_OUT = !PA0_OUT; // 翻转 LED
while(!PA1_IN); // 等待按键松开
}
}
}
这段代码虽然简单,但包含了 GPIO 的全部核心操作:时钟使能、模式配置、输入输出、位带操作、消抖处理。你把它吃透了,后面学 SPI、I2C、UART 这些外设,思路是一样的——无非是配置更多寄存器,操作更复杂的数据。