4、外设驱动开发(上):GPIO、UART、I2C、SPI的裸机驱动实现,中断优先级配置。
好,咱们进入正题。这一章聊的是嵌入式开发里最基础、也最绕不开的东西——外设驱动。说白了,就是让芯片跟外面的世界“说话”。GPIO、UART、I2C、SPI,这四个家伙你肯定都打过照面。但裸机下怎么写驱动?中断优先级怎么配?这里头有不少门道。
我个人习惯,写驱动之前,先翻数据手册。别急着敲代码,先把寄存器摸清楚。你想想看,寄存器就是芯片的“开关”和“仪表盘”,不知道开关在哪,你怎么开车?
4.1 GPIO:最基础,也最容易翻车
GPIO,通用输入输出。听起来简单,不就是拉高拉低吗?但我在项目中遇到过,一个GPIO配置错了,整块板子都起不来。
裸机下,GPIO驱动核心就三件事:时钟使能、模式配置、电平读写。
核心要点: 很多新手会漏掉时钟使能。芯片上电后,外设时钟默认是关闭的。你不开时钟,写再多寄存器都没用。嗯,这里要注意。
来个简单的例子,配置一个LED灯(PA0)输出高电平:
// 1. 使能GPIOA时钟
RCC->AHB1ENR |= (1 << 0);
// 2. 配置PA0为输出模式
GPIOA->MODER &= ~(0x3 << (0 * 2)); // 先清零
GPIOA->MODER |= (0x1 << (0 * 2)); // 设置为01(通用输出)
// 3. 设置输出类型为推挽
GPIOA->OTYPER &= ~(1 << 0);
// 4. 设置输出速度
GPIOA->OSPEEDR |= (0x2 << (0 * 2)); // 高速
// 5. 拉高电平
GPIOA->ODR |= (1 << 0);
你看,就这么几行。但每一行都有讲究。比如MODER寄存器,每个引脚占2位,你得先清零再赋值。我曾经见过有人直接赋值,结果把相邻引脚的模式也改了,查了半天。
小技巧: 调试GPIO时,可以用逻辑分析仪抓波形。别光靠万用表点,那只能看静态电平,看不到毛刺和时序。
4.2 UART:串口通信,调试利器
UART,串口。做嵌入式的人,谁没跟它打过交道?调试信息、日志输出、甚至固件升级,都靠它。
裸机UART驱动,我一般分三步走:波特率配置、数据格式设置、收发使能。
波特率怎么算?公式在数据手册里:
USART_BRR = fCK / (16 * BaudRate)
举个例子,系统时钟84MHz,要配115200波特率:
USART1->BRR = 84000000 / (16 * 115200); // 结果约45.57,取整45
这里有个坑。你算出来的值可能不是整数,取整后会有误差。误差超过3%,通信就可能不稳定。我建议你算一下实际波特率,确认误差在可接受范围内。
收发代码也很直接:
// 发送一个字节
void uart_send_byte(USART_TypeDef *USARTx, uint8_t data) {
while (!(USARTx->SR & (1 << 7))); // 等待发送缓冲区空
USARTx->DR = data;
}
// 接收一个字节
uint8_t uart_recv_byte(USART_TypeDef *USARTx) {
while (!(USARTx->SR & (1 << 5))); // 等待接收数据就绪
return USARTx->DR;
}
嗯,这里要注意。轮询方式在裸机里够用,但如果你要做复杂应用,最好用中断或DMA。不然CPU就卡死在等待循环里了。
4.3 I2C:两根线,一堆设备
I2C,两根线(SCL、SDA),能挂一堆设备。传感器、EEPROM、RTC,很多都用它。
裸机I2C驱动,核心是起始条件、停止条件、数据收发、应答检测。我习惯用状态机来写,清晰又好维护。
来个起始条件的代码:
void i2c_start(I2C_TypeDef *I2Cx) {
// 设置起始位
I2Cx->CR1 |= (1 << 8); // START = 1
// 等待起始条件发送完成
while (!(I2Cx->SR1 & (1 << 0)));
}
发送一个字节:
void i2c_send_byte(I2C_TypeDef *I2Cx, uint8_t data) {
// 等待发送缓冲区空
while (!(I2Cx->SR1 & (1 << 7)));
// 写入数据
I2Cx->DR = data;
// 等待发送完成
while (!(I2Cx->SR1 & (1 << 2)));
}
注意: I2C的时序要求很严格。SCL时钟频率不能超过400kHz(快速模式)。我曾经在项目里把时钟配快了,结果从设备不响应,查了两天才发现是时序问题。
还有一个容易忽略的点——应答检测。每次发送完地址或数据后,都要检查从设备是否应答。如果没应答,说明设备不在线或出错了。
4.4 SPI:高速,全双工
SPI,四根线(SCK、MOSI、MISO、CS),速度比I2C快得多。适合显示屏、SD卡、ADC这类高速设备。
裸机SPI驱动,关键参数有四个:时钟极性(CPOL)、时钟相位(CPHA)、数据顺序(MSB/LSB)、波特率。
配置代码示例:
// 配置SPI1为主机模式
SPI1->CR1 = (1 << 2) | // 主模式
(1 << 3) | // CPOL = 1
(1 << 4) | // CPHA = 1
(1 << 5) | // LSBFIRST = 0 (MSB先)
(0x3 << 3); // 波特率分频
// 使能SPI
SPI1->CR1 |= (1 << 6);
收发一个字节:
uint8_t spi_transfer_byte(uint8_t data) {
while (!(SPI1->SR & (1 << 1))); // 等待发送缓冲区空
SPI1->DR = data;
while (!(SPI1->SR & (1 << 0))); // 等待接收数据就绪
return SPI1->DR;
}
你发现没有?SPI的收发是同时进行的。你发一个字节,同时也会收到一个字节。所以如果你只想收数据,也得发一个空字节(通常是0xFF)。
经验之谈: 调试SPI时,先确认CS片选信号是否正常。很多问题都是CS没拉低导致的。我习惯用示波器同时抓SCK和CS,看时序对不对。
4.5 中断优先级配置:别让中断“打架”
外设驱动写好了,接下来就是中断。裸机下,中断优先级配置是个技术活。配不好,系统就乱套了。
ARM Cortex-M内核使用NVIC(嵌套向量中断控制器)。优先级分两种:抢占优先级和子优先级。抢占优先级高的可以打断低的,子优先级只在同抢占优先级时起作用。
配置步骤:
- 设置优先级分组:决定抢占优先级和子优先级各占几位。
- 设置每个中断的优先级:在NVIC中配置。
- 使能中断:在NVIC中使能对应的中断通道。
代码示例:
// 设置优先级分组:4位抢占优先级,0位子优先级
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);
// 配置USART1中断优先级为2
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
避坑指南: 我曾经在项目里把两个中断的抢占优先级设成一样,结果它们互相抢占,导致系统卡死。后来才意识到,同优先级的中断不会互相打断,必须等一个处理完才能处理另一个。所以,关键中断(比如系统心跳)要设高优先级,普通外设中断设低一些。
还有一个原则:中断服务函数要短。别在中断里做复杂运算或延时。我一般只在中断里设标志位,具体处理放到主循环里。
4.6 小结
这一章我们聊了GPIO、UART、I2C、SPI的裸机驱动实现,还有中断优先级配置。说白了,外设驱动就是“读手册、配寄存器、调时序”这三板斧。但每一板斧都有细节,细节决定成败。
下一章,我们会继续聊外设驱动开发的下半部分——定时器、PWM、ADC和DMA。这些家伙更复杂,但也更有意思。到时候见。
课后思考: 如果你现在要驱动一个I2C接口的温湿度传感器,你会先配置GPIO还是先配置I2C外设?为什么?