学习嵌入式硬件开发(二):MCU外设驱动实战 —— 从GPIO到高级定时器
为什么外设驱动是嵌入式开发的核心?
MCU的真正价值不在于跑C语言代码,而在于通过外设与物理世界交互。一个裸的ARM Cortex-M3内核只能做数学计算,加上GPIO才能控制LED,加上ADC才能采集温度,加上UART才能与电脑通信。外设驱动能力直接决定你能做出什么样的产品。
本文以STM32F103C8T6为例,手把手带你写出生产级别的外设驱动代码。不讲空洞的理论,每一行代码都来自真实项目的血泪教训。
GPIO:一切的起点
寄存器级操作
你以为HAL_GPIO_WritePin()很底层?来看看它做了什么:
// 点亮PC13的LED(STM32蓝色小板上的板载LED)
// 1. 使能GPIOC时钟(挂在APB2总线上)
RCC->APB2ENR |= RCC_APB2ENR_IOPCEN;
// 2. 配置PC13为推挽输出,最大速度50MHz
GPIOC->CRH &= ~(0xF << 20); // 清除PC13配置位
GPIOC->CRH |= (0x3 << 20); // 50MHz推挽输出
// 3. 输出低电平点亮LED(板载LED是低电平驱动)
GPIOC->BSRR = (1 << (13 + 16)); // 复位PC13 = 低电平
关键点:BSRR寄存器可以原子操作,高16位清零,低16位置1。比操作ODR寄存器更安全——ODR存在读-修改-写的竞争风险。这个细节在很多教材里都被忽略了,但在量产代码中至关重要。
开漏输出与上拉电阻
I²C总线的SDA和SCL必须配置为开漏输出+外部上拉电阻。为什么?因为开漏输出只能拉低不能拉高,谁来拉高?上拉电阻!这种设计允许多个设备共享一根总线——任何设备都可以拉低总线,但不会因为输出高电平而与其他设备冲突。
如果你错误地把I²C引脚配置为推挽输出,当从机试图拉低SDA发送ACK时,主机强行输出高电平,产生短路电流,严重的会烧坏IO口。这个坑几乎每个初学者都会踩一次。
UART:嵌入式调试的第一利器
printf重定向的完整实现
// 重定向fputc到USART1
int fputc(int ch, FILE *f) {
while (!(USART1->SR & USART_SR_TXE));
USART1->DR = (uint8_t)ch;
return ch;
}
// 初始化USART1:115200波特率,8N1
void uart1_init(void) {
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_USART1EN;
GPIOA->CRH |= (0xB << 4) | (0x4 << 8);
USART1->BRR = (39 << 4) | 1;
USART1->CR1 |= USART_CR1_TE | USART_CR1_RE | USART_CR1_UE;
}
真实项目中,波特率不一定精确。115200的误差要求小于2%,STM32的72MHz时钟源经过PLL倍频后实际频率可能有误差。如果两个设备之间的波特率偏差累积超过半位时间,就会产生帧错误。这就是为什么工业环境推荐使用带外部晶振的CAN总线而非UART。
接收中断与环形缓冲区
裸机开发中,你绝对不能在主循环里轮询UART接收——万一数据在两次查询之间到达,就会丢包。
#define RX_BUF_SIZE 128
volatile uint8_t rx_buf[RX_BUF_SIZE];
volatile uint8_t rx_head = 0, rx_tail = 0;
void USART1_IRQHandler(void) {
if (USART1->SR & USART_SR_RXNE) {
rx_buf[rx_head] = USART1->DR;
rx_head = (rx_head + 1) % RX_BUF_SIZE;
}
}
uint8_t uart_read(void) {
while (rx_head == rx_tail);
uint8_t data = rx_buf[rx_tail];
rx_tail = (rx_tail + 1) % RX_BUF_SIZE;
return data;
}
I²C:最常用的传感器接口
软件模拟I²C的时序实现
为什么不用硬件I²C?STM32F103的硬件I²C外设有一个著名的BUG:在特定条件下会进入BUSY状态且无法恢复。很多量产项目干脆改用软件模拟I²C。
#define I2C_SCL_H() GPIOB->BSRR = (1 << 6)
#define I2C_SCL_L() GPIOB->BSRR = (1 << (6 + 16))
#define I2C_SDA_H() GPIOB->BSRR = (1 << 7)
#define I2C_SDA_L() GPIOB->BSRR = (1 << (7 + 16))
#define I2C_SDA_IN() (GPIOB->IDR & (1 << 7))
void i2c_start(void) {
I2C_SDA_H(); delay_us(1);
I2C_SCL_H(); delay_us(1);
I2C_SDA_L(); delay_us(1);
I2C_SCL_L();
}
SPI:高速通信的正确打开方式
SPI四种模式的本质区别
| 模式 | CPOL | CPHA | SCK空闲电平 | 数据采样沿 |
|---|---|---|---|---|
| 0 | 0 | 0 | 低电平 | 第一个边沿(上升沿) |
| 1 | 0 | 1 | 低电平 | 第二个边沿(下降沿) |
| 2 | 1 | 0 | 高电平 | 第一个边沿(下降沿) |
| 3 | 1 | 1 | 高电平 | 第二个边沿(上升沿) |
大多数SPI Flash使用模式0或模式3。如果你配置错了,数据会整体偏移半个位,读取到的全是乱码。别问我们怎么知道的。
定时器:PWM的精确控制
呼吸灯的精髓
// TIM2 CH1(PA0)输出PWM,频率1kHz,占空比0-100%
void pwm_init(void) {
RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
TIM2->PSC = 72 - 1; // 72MHz / 72 = 1MHz
TIM2->ARR = 1000 - 1; // 1MHz / 1000 = 1kHz
TIM2->CCR1 = 0; // 初始占空比0%
TIM2->CCMR1 |= (6 << 4);
TIM2->CCER |= TIM_CCER_CC1E;
TIM2->CR1 |= TIM_CR1_CEN;
}
PSC和ARR共同决定了PWM频率:f = 72MHz / (PSC+1) / (ARR+1)。如果要1kHz,PSC=72,ARR=1000。如果要20kHz(电机控制常用频率),PSC=36,ARR=100。注意:ARR和CCR都是16位寄存器(0-65535),如果频率太低需要更大的PSC值。
ADC:从物理世界到数字世界
真实场景:采集NTC热敏电阻测温度。NTC的阻值-温度曲线是非线性的,可以用Steinhart-Hart方程或查表法。工程上查表法最常用——预先计算好温度-ADC值对应表,查表比数学计算快10倍。
// 滑动平均滤波
uint16_t adc_filter(void) {
static uint16_t buf[8] = {0};
static uint8_t idx = 0;
buf[idx] = ADC1->DR;
idx = (idx + 1) % 8;
uint32_t sum = 0;
for (int i = 0; i < 8; i++) sum += buf[i];
return sum / 8;
}
滑动平均滤波的窗口大小选择有讲究:窗口越大滤波效果越好,但响应越慢。8个采样点对温度测量场景刚好——既能滤掉50Hz工频干扰,又不至于让温度变化响应超过1秒。