用C语言实现I2C/SPI/UART裸机驱动:零依赖库的极简移植方案
嵌入式开发中,I2C、SPI、UART是最常用的三种串行通信接口。很多工程师习惯于直接调用厂商的HAL库或LL库,但一旦换了MCU平台,整个驱动就要重写。如果能用纯C语言写一套“零依赖”的裸机驱动,只通过结构体和函数指针抽象硬件操作,那么移植到任何MCU只需要修改底层的寄存器操作函数,上层逻辑完全不变。本文分享一种极简的移植方案。
一、设计思路:硬件抽象层(HAL)
核心思想是将每个外设的操作封装成一个结构体,包含初始化、发送、接收等函数指针。不同的MCU只需提供不同的函数实现,驱动代码本身不依赖任何库。
// 通用UART接口定义
typedef struct {
void (*init)(uint32_t baud);
void (*putc)(char c);
char (*getc)(void);
uint8_t (*available)(void);
} uart_t;
// 通用SPI接口定义
typedef struct {
void (*init)(uint32_t freq, uint8_t mode);
uint8_t (*transfer)(uint8_t tx);
void (*cs_select)(uint8_t enable);
} spi_t;
// 通用I2C接口定义
typedef struct {
void (*init)(uint32_t freq);
uint8_t (*write)(uint8_t addr, uint8_t reg, uint8_t *data, uint16_t len);
uint8_t (*read)(uint8_t addr, uint8_t reg, uint8_t *data, uint16_t len);
} i2c_t;
使用时,只需声明一个uart_t变量,并赋值为当前平台的实现函数。例如在STM32上:
extern void stm32_uart_init(uint32_t baud);
extern void stm32_uart_putc(char c);
extern char stm32_uart_getc(void);
extern uint8_t stm32_uart_available(void);
uart_t console = {
.init = stm32_uart_init,
.putc = stm32_uart_putc,
.getc = stm32_uart_getc,
.available = stm32_uart_available,
};
上层代码(如printf重定向、传感器驱动)只操作console.putc,完全不关心底层是什么MCU。
二、UART裸机驱动实现(以STM32为例)
#include "stm32f1xx.h" // 仅依赖寄存器定义
void stm32_uart_init(uint32_t baud) {
RCC->APB2ENR |= RCC_APB2ENR_USART1EN;
// GPIO配置略...
USART1->BRR = 72000000 / baud; // 假设72MHz
USART1->CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE;
}
void stm32_uart_putc(char c) {
while(!(USART1->SR & USART_SR_TXE));
USART1->DR = c;
}
char stm32_uart_getc(void) {
while(!(USART1->SR & USART_SR_RXNE));
return USART1->DR;
}
uint8_t stm32_uart_available(void) {
return (USART1->SR & USART_SR_RXNE) ? 1 : 0;
}
移植到GD32或AT32时,只需修改寄存器名称和时钟配置,函数签名保持不变。
三、SPI裸机驱动实现
void stm32_spi_init(uint32_t freq, uint8_t mode) {
RCC->APB2ENR |= RCC_APB2ENR_SPI1EN;
SPI1->CR1 = SPI_CR1_MSTR | SPI_CR1_SSI | SPI_CR1_SSM; // 主机模式
SPI1->CR1 |= (mode & 0x03) << 0; // CPOL, CPHA
SPI1->CR1 |= (uint16_t)(72000000 / freq) << 3; // 波特率分频
SPI1->CR1 |= SPI_CR1_SPE; // 使能
}
uint8_t stm32_spi_transfer(uint8_t tx) {
while(!(SPI1->SR & SPI_SR_TXE));
SPI1->DR = tx;
while(!(SPI1->SR & SPI_SR_RXNE));
return SPI1->DR;
}
void stm32_spi_cs(uint8_t enable) {
GPIOA->BSRR = enable ? GPIO_BSRR_BR4 : GPIO_BSRR_BS4; // PA4片选
}
四、I2C裸机驱动实现
I2C相对复杂,但核心也是操作寄存器:
void stm32_i2c_init(uint32_t freq) {
RCC->APB1ENR |= RCC_APB1ENR_I2C1EN;
I2C1->CR2 = 36; // 36MHz APB1
I2C1->CCR = 180; // 100kHz
I2C1->TRISE = 37;
I2C1->CR1 |= I2C_CR1_PE;
}
uint8_t stm32_i2c_write(uint8_t addr, uint8_t reg, uint8_t *data, uint16_t len) {
// 发送起始位+设备地址+写位
I2C1->CR1 |= I2C_CR1_START;
while(!(I2C1->SR1 & I2C_SR1_SB));
I2C1->DR = addr << 1; // 写地址
while(!(I2C1->SR1 & I2C_SR1_ADDR));
(void)I2C1->SR2; // 清除ADDR
// 发送寄存器地址
while(!(I2C1->SR1 & I2C_SR1_TXE));
I2C1->DR = reg;
// 发送数据
for(uint16_t i=0; i<len; i++) {
while(!(I2C1->SR1 & I2C_SR1_TXE));
I2C1->DR = data[i];
}
// 停止
I2C1->CR1 |= I2C_CR1_STOP;
return 0;
}
五、移植到其他MCU
当需要移植到GD32、AT32、NXP等平台时,只需新建一个hal_gd32.c文件,按照相同的函数签名实现底层寄存器操作。上层所有传感器驱动、通信协议(如DS18B20、OLED、MPU6050)都通过spi_t、i2c_t指针调用,一行代码不改。
六、三个翻车高发点
函数指针未初始化:如果忘记给结构体赋值就调用,会跳转到0地址导致HardFault。建议在声明时用= {0}清零,并增加非空检查。
中断冲突:如果底层用了中断接收,而结构体中的getc是阻塞轮询,会导致数据丢失。建议将中断收到的数据放入环形缓冲区,getc从缓冲区读取。
时钟配置不一致:不同MCU的外设时钟来源不同(APB1/APB2),移植时需仔细核对时钟树,否则波特率或频率会算错。





