当前位置:首页 > 嵌入式 > 嵌入式分享
[导读]嵌入式开发中,I2C、SPI、UART是最常用的三种串行通信接口。很多工程师习惯于直接调用厂商的HAL库或LL库,但一旦换了MCU平台,整个驱动就要重写。如果能用纯C语言写一套“零依赖”的裸机驱动,只通过结构体和函数指针抽象硬件操作,那么移植到任何MCU只需要修改底层的寄存器操作函数,上层逻辑完全不变。本文分享一种极简的移植方案。


嵌入式开发中,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),移植时需仔细核对时钟树,否则波特率或频率会算错。



本站声明: 本文章由作者或相关机构授权发布,目的在于传递更多信息,并不代表本站赞同其观点,本站亦不保证或承诺内容真实性等。需要转载请联系该专栏作者,如若文章内容侵犯您的权益,请及时联系本站删除( 邮箱:macysun@21ic.com )。
关闭