单片机和外围设备的接口和驱动

2 阅读15分钟

大家好,我是良许。

在嵌入式开发中,单片机与外围设备的接口和驱动是我们日常工作中最常接触的内容。

无论是简单的 LED 灯控制,还是复杂的传感器数据采集,都离不开对接口和驱动的深入理解。

今天,我就结合自己多年的嵌入式开发经验,和大家聊聊单片机与外围设备之间是如何"对话"的。

1. 单片机接口基础概念

1.1 什么是接口

接口,简单来说就是单片机与外部世界交流的"窗口"。

就像我们人与人之间交流需要语言一样,单片机与外围设备之间也需要一套约定好的通信规则。

这个规则包括硬件层面的电气特性(比如电压电平、引脚定义等),也包括软件层面的通信协议(比如数据格式、时序要求等)。

在我刚入行做单片机开发的时候,最常接触的就是 GPIO(通用输入输出)接口。

当时项目需要控制一个继电器,我就是通过 GPIO 口输出高低电平来实现的。

后来随着项目复杂度的增加,逐渐接触到了串口、SPI、I2C 等各种通信接口。

1.2 常见的接口类型

单片机的接口按照数据传输方式可以分为并行接口和串行接口。

并行接口一次可以传输多个比特的数据,速度快但占用引脚多;串行接口一次只传输一个比特,速度相对较慢但节省引脚资源。

在实际项目中,我们最常用的串行接口包括:

  • UART(通用异步收发器):用于串口通信,调试时最常用
  • SPI(串行外设接口):高速同步通信,常用于 Flash、SD 卡等
  • I2C(集成电路总线):两线式总线,常用于传感器、EEPROM 等
  • CAN(控制器局域网):汽车电子中的标准通信协议
  • USB(通用串行总线):现代设备的标配接口

2. 驱动程序的本质

2.1 驱动是什么

驱动程序就是帮助单片机"理解"外围设备的软件代码。

它封装了与硬件交互的底层细节,向上层应用提供简洁的 API 接口。

一个好的驱动程序应该具备良好的可移植性、可维护性和稳定性。

我在做汽车电子项目的时候,经常需要为各种传感器编写驱动。

比如一个温度传感器,底层可能使用 I2C 通信,但我会把读取 I2C 数据、解析温度值、进行误差校准等操作都封装在驱动里,上层应用只需要调用一个 GetTemperature() 函数就能获取温度值,完全不需要关心底层是怎么实现的。

2.2 驱动的分层架构

一个完整的驱动通常采用分层设计:

  • 硬件抽象层(HAL):直接操作寄存器,屏蔽硬件差异
  • 设备驱动层:实现具体设备的功能逻辑
  • 应用接口层:向应用程序提供 API

这种分层设计的好处是,当我们更换芯片平台时,只需要修改 HAL 层的代码,设备驱动层和应用层基本不需要改动。

这在我从 51 单片机转到 STM32 开发时体会特别深刻。

3. GPIO 接口及驱动实现

3.1 GPIO 基本原理

GPIO 是最基础也是最重要的接口。

每个 GPIO 引脚都可以配置为输入或输出模式,输出模式下可以输出高电平或低电平,输入模式下可以读取外部信号的状态。

在 STM32 中,GPIO 还支持多种工作模式:推挽输出、开漏输出、上拉输入、下拉输入、浮空输入等。

不同的模式适用于不同的应用场景。比如 I2C 总线就需要配置为开漏输出模式,而普通的 LED 控制则使用推挽输出即可。

3.2 GPIO 驱动示例

下面是一个基于 STM32 HAL 库的 LED 控制驱动示例:

// led.h
#ifndef __LED_H
#define __LED_H#include "stm32f4xx_hal.h"// LED引脚定义
#define LED_PIN GPIO_PIN_13
#define LED_PORT GPIOC// LED初始化
void LED_Init(void);
​
// LED控制函数
void LED_On(void);
void LED_Off(void);
void LED_Toggle(void);
​
#endif
// led.c
#include "led.h"
​
void LED_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    // 使能GPIO时钟
    __HAL_RCC_GPIOC_CLK_ENABLE();
    
    // 配置GPIO引脚
    GPIO_InitStruct.Pin = LED_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;  // 推挽输出
    GPIO_InitStruct.Pull = GPIO_NOPULL;          // 无上下拉
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; // 低速
    HAL_GPIO_Init(LED_PORT, &GPIO_InitStruct);
    
    // 初始状态设为熄灭
    LED_Off();
}
​
void LED_On(void)
{
    HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_RESET);
}
​
void LED_Off(void)
{
    HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_SET);
}
​
void LED_Toggle(void)
{
    HAL_GPIO_TogglePin(LED_PORT, LED_PIN);
}

这个驱动虽然简单,但体现了驱动设计的基本思想:初始化、功能函数、硬件抽象。

应用层只需要调用 LED_On() 就能点亮 LED,完全不需要知道具体是哪个引脚、什么电平。

4. UART 串口接口及驱动

4.1 UART 通信原理

UART 是异步串行通信接口,只需要两根线(TX 发送、RX 接收)就能实现全双工通信。

所谓异步,是指通信双方没有共同的时钟信号,而是通过约定好的波特率来同步数据。

在我的开发经历中,串口是调试程序最常用的工具。

通过串口打印日志信息,可以快速定位问题。

同时,很多外围设备如 GPS 模块、蓝牙模块等都使用串口通信。

4.2 UART 驱动实现

下面是一个带接收缓冲区的 UART 驱动示例:

// uart.h
#ifndef __UART_H
#define __UART_H#include "stm32f4xx_hal.h"
#include <stdint.h>#define UART_RX_BUFFER_SIZE 256// UART初始化
void UART_Init(void);
​
// UART发送函数
void UART_SendByte(uint8_t data);
void UART_SendString(const char *str);
void UART_SendData(uint8_t *data, uint16_t len);
​
// UART接收函数
uint16_t UART_GetRxCount(void);
uint8_t UART_ReadByte(void);
uint16_t UART_ReadData(uint8_t *buffer, uint16_t len);
​
#endif
// uart.c
#include "uart.h"
#include <string.h>
​
UART_HandleTypeDef huart1;
​
// 接收缓冲区
static uint8_t rx_buffer[UART_RX_BUFFER_SIZE];
static uint16_t rx_write_index = 0;
static uint16_t rx_read_index = 0;
​
void UART_Init(void)
{
    // UART配置
    huart1.Instance = USART1;
    huart1.Init.BaudRate = 115200;
    huart1.Init.WordLength = UART_WORDLENGTH_8B;
    huart1.Init.StopBits = UART_STOPBITS_1;
    huart1.Init.Parity = UART_PARITY_NONE;
    huart1.Init.Mode = UART_MODE_TX_RX;
    huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE;
    huart1.Init.OverSampling = UART_OVERSAMPLING_16;
    
    if (HAL_UART_Init(&huart1) != HAL_OK)
    {
        // 初始化错误处理
        Error_Handler();
    }
    
    // 使能接收中断
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_RXNE);
}
​
void UART_SendByte(uint8_t data)
{
    HAL_UART_Transmit(&huart1, &data, 1, HAL_MAX_DELAY);
}
​
void UART_SendString(const char *str)
{
    HAL_UART_Transmit(&huart1, (uint8_t*)str, strlen(str), HAL_MAX_DELAY);
}
​
void UART_SendData(uint8_t *data, uint16_t len)
{
    HAL_UART_Transmit(&huart1, data, len, HAL_MAX_DELAY);
}
​
uint16_t UART_GetRxCount(void)
{
    if (rx_write_index >= rx_read_index)
    {
        return rx_write_index - rx_read_index;
    }
    else
    {
        return UART_RX_BUFFER_SIZE - rx_read_index + rx_write_index;
    }
}
​
uint8_t UART_ReadByte(void)
{
    uint8_t data = 0;
    
    if (rx_read_index != rx_write_index)
    {
        data = rx_buffer[rx_read_index];
        rx_read_index = (rx_read_index + 1) % UART_RX_BUFFER_SIZE;
    }
    
    return data;
}
​
// UART接收中断回调函数
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1)
    {
        uint8_t data;
        HAL_UART_Receive_IT(&huart1, &data, 1);
        
        // 将数据存入环形缓冲区
        rx_buffer[rx_write_index] = data;
        rx_write_index = (rx_write_index + 1) % UART_RX_BUFFER_SIZE;
    }
}

这个驱动实现了一个环形缓冲区来存储接收到的数据,避免了数据丢失的问题。

在实际项目中,我经常使用这种方式来处理串口数据。

5. I2C 接口及驱动

5.1 I2C 通信协议

I2C 是一种两线式串行总线,只需要 SCL(时钟线)和 SDA(数据线)两根线就能连接多个设备。

它采用主从模式,主机负责产生时钟信号并发起通信,从机响应主机的请求。

I2C 的一个重要特点是支持多主机、多从机,每个从机都有唯一的 7 位或 10 位地址。

在我做传感器采集项目时,经常在一条 I2C 总线上挂载多个传感器,比如温湿度传感器、加速度传感器、气压传感器等,通过不同的设备地址来区分。

5.2 I2C 驱动实现

下面是一个 MPU6050 六轴传感器的 I2C 驱动示例:

// mpu6050.h
#ifndef __MPU6050_H
#define __MPU6050_H#include "stm32f4xx_hal.h"// MPU6050设备地址
#define MPU6050_ADDR 0xD0// MPU6050寄存器地址
#define MPU6050_REG_PWR_MGMT_1   0x6B
#define MPU6050_REG_ACCEL_XOUT_H 0x3B
#define MPU6050_REG_GYRO_XOUT_H  0x43// 数据结构
typedef struct
{
    int16_t accel_x;
    int16_t accel_y;
    int16_t accel_z;
    int16_t gyro_x;
    int16_t gyro_y;
    int16_t gyro_z;
} MPU6050_Data_t;
​
// 函数声明
uint8_t MPU6050_Init(void);
uint8_t MPU6050_ReadData(MPU6050_Data_t *data);
​
#endif
// mpu6050.c
#include "mpu6050.h"extern I2C_HandleTypeDef hi2c1;
​
// 写寄存器
static uint8_t MPU6050_WriteReg(uint8_t reg, uint8_t data)
{
    uint8_t buf[2] = {reg, data};
    return HAL_I2C_Master_Transmit(&hi2c1, MPU6050_ADDR, buf, 2, HAL_MAX_DELAY);
}
​
// 读寄存器
static uint8_t MPU6050_ReadReg(uint8_t reg, uint8_t *data, uint16_t len)
{
    return HAL_I2C_Mem_Read(&hi2c1, MPU6050_ADDR, reg, 
                            I2C_MEMADD_SIZE_8BIT, data, len, HAL_MAX_DELAY);
}
​
uint8_t MPU6050_Init(void)
{
    uint8_t check;
    
    // 检测设备是否存在
    if (HAL_I2C_IsDeviceReady(&hi2c1, MPU6050_ADDR, 3, HAL_MAX_DELAY) != HAL_OK)
    {
        return 1;  // 设备不存在
    }
    
    // 退出睡眠模式
    if (MPU6050_WriteReg(MPU6050_REG_PWR_MGMT_1, 0x00) != HAL_OK)
    {
        return 2;  // 初始化失败
    }
    
    HAL_Delay(100);
    return 0;  // 初始化成功
}
​
uint8_t MPU6050_ReadData(MPU6050_Data_t *data)
{
    uint8_t buffer[14];
    
    // 读取加速度和陀螺仪数据(连续14个字节)
    if (MPU6050_ReadReg(MPU6050_REG_ACCEL_XOUT_H, buffer, 14) != HAL_OK)
    {
        return 1;  // 读取失败
    }
    
    // 解析数据(大端模式)
    data->accel_x = (int16_t)(buffer[0] << 8 | buffer[1]);
    data->accel_y = (int16_t)(buffer[2] << 8 | buffer[3]);
    data->accel_z = (int16_t)(buffer[4] << 8 | buffer[5]);
    data->gyro_x = (int16_t)(buffer[8] << 8 | buffer[9]);
    data->gyro_y = (int16_t)(buffer[10] << 8 | buffer[11]);
    data->gyro_z = (int16_t)(buffer[12] << 8 | buffer[13]);
    
    return 0;  // 读取成功
}

这个驱动封装了 MPU6050 的初始化和数据读取功能。

应用层只需要调用 MPU6050_ReadData() 就能获取传感器数据,不需要关心 I2C 通信的细节。

6. SPI 接口及驱动

6.1 SPI 通信协议

SPI 是一种高速同步串行通信接口,采用主从模式,需要四根线:MOSI(主出从入)、MISO(主入从出)、SCK(时钟)、CS(片选)。

SPI 的速度通常比 I2C 快得多,可以达到几十 MHz 甚至上百 MHz。

在我做的项目中,SPI 常用于连接 Flash 存储器、SD 卡、LCD 显示屏等需要高速数据传输的设备。

比如一个彩色 LCD 屏幕,如果用 I2C 来传输图像数据会非常慢,而用 SPI 就能达到流畅的刷新率。

6.2 SPI 驱动实现

下面是一个 W25Q128 Flash 存储器的 SPI 驱动示例:

// w25qxx.h
#ifndef __W25QXX_H
#define __W25QXX_H#include "stm32f4xx_hal.h"// W25Q128容量定义
#define W25Q128_FLASH_SIZE      0x1000000  // 16MB
#define W25Q128_SECTOR_SIZE     4096       // 4KB
#define W25Q128_PAGE_SIZE       256        // 256字节// 指令定义
#define W25X_WriteEnable        0x06
#define W25X_WriteDisable       0x04
#define W25X_ReadStatusReg      0x05
#define W25X_WriteStatusReg     0x01
#define W25X_ReadData           0x03
#define W25X_PageProgram        0x02
#define W25X_SectorErase        0x20
#define W25X_ChipErase          0xC7
#define W25X_PowerDown          0xB9
#define W25X_ReleasePowerDown   0xAB
#define W25X_DeviceID           0xAB
#define W25X_ManufactDeviceID   0x90// 函数声明
uint8_t W25QXX_Init(void);
uint16_t W25QXX_ReadID(void);
void W25QXX_Read(uint8_t *buffer, uint32_t addr, uint16_t len);
void W25QXX_Write(uint8_t *buffer, uint32_t addr, uint16_t len);
void W25QXX_EraseSector(uint32_t addr);
​
#endif
// w25qxx.c
#include "w25qxx.h"
​
extern SPI_HandleTypeDef hspi1;
​
// 片选引脚定义
#define W25QXX_CS_PIN  GPIO_PIN_4
#define W25QXX_CS_PORT GPIOA
​
#define W25QXX_CS_LOW()  HAL_GPIO_WritePin(W25QXX_CS_PORT, W25QXX_CS_PIN, GPIO_PIN_RESET)
#define W25QXX_CS_HIGH() HAL_GPIO_WritePin(W25QXX_CS_PORT, W25QXX_CS_PIN, GPIO_PIN_SET)
​
// SPI读写一个字节
static uint8_t W25QXX_ReadWriteByte(uint8_t data)
{
    uint8_t rx_data;
    HAL_SPI_TransmitReceive(&hspi1, &data, &rx_data, 1, HAL_MAX_DELAY);
    return rx_data;
}
​
// 等待空闲
static void W25QXX_WaitBusy(void)
{
    W25QXX_CS_LOW();
    W25QXX_ReadWriteByte(W25X_ReadStatusReg);
    while ((W25QXX_ReadWriteByte(0xFF) & 0x01) == 0x01);
    W25QXX_CS_HIGH();
}
​
// 写使能
static void W25QXX_WriteEnable(void)
{
    W25QXX_CS_LOW();
    W25QXX_ReadWriteByte(W25X_WriteEnable);
    W25QXX_CS_HIGH();
}
​
uint8_t W25QXX_Init(void)
{
    uint16_t id = W25QXX_ReadID();
    if (id == 0xEF17)  // W25Q128的ID
    {
        return 0;  // 初始化成功
    }
    return 1;  // 初始化失败
}
​
uint16_t W25QXX_ReadID(void)
{
    uint16_t id = 0;
    
    W25QXX_CS_LOW();
    W25QXX_ReadWriteByte(W25X_ManufactDeviceID);
    W25QXX_ReadWriteByte(0x00);
    W25QXX_ReadWriteByte(0x00);
    W25QXX_ReadWriteByte(0x00);
    id |= W25QXX_ReadWriteByte(0xFF) << 8;
    id |= W25QXX_ReadWriteByte(0xFF);
    W25QXX_CS_HIGH();
    
    return id;
}
​
void W25QXX_Read(uint8_t *buffer, uint32_t addr, uint16_t len)
{
    W25QXX_CS_LOW();
    W25QXX_ReadWriteByte(W25X_ReadData);
    W25QXX_ReadWriteByte((addr >> 16) & 0xFF);
    W25QXX_ReadWriteByte((addr >> 8) & 0xFF);
    W25QXX_ReadWriteByte(addr & 0xFF);
    
    for (uint16_t i = 0; i < len; i++)
    {
        buffer[i] = W25QXX_ReadWriteByte(0xFF);
    }
    
    W25QXX_CS_HIGH();
}
​
void W25QXX_EraseSector(uint32_t addr)
{
    W25QXX_WriteEnable();
    W25QXX_WaitBusy();
    
    W25QXX_CS_LOW();
    W25QXX_ReadWriteByte(W25X_SectorErase);
    W25QXX_ReadWriteByte((addr >> 16) & 0xFF);
    W25QXX_ReadWriteByte((addr >> 8) & 0xFF);
    W25QXX_ReadWriteByte(addr & 0xFF);
    W25QXX_CS_HIGH();
    
    W25QXX_WaitBusy();
}

这个驱动实现了 Flash 的基本读写操作。

在实际应用中,我们可以用 Flash 来存储配置参数、日志数据、固件升级包等。

7. 驱动开发的最佳实践

7.1 模块化设计

每个外设驱动应该是独立的模块,包含独立的.h 和.c 文件。

驱动之间尽量减少依赖,通过回调函数或消息队列来实现模块间通信。

这样做的好处是代码结构清晰,便于维护和移植。

7.2 错误处理机制

驱动函数应该有明确的返回值来指示操作是否成功。

对于可能失败的操作(如 I2C 通信、Flash 写入等),要有超时机制和重试机制。

在我的项目中,通常会定义统一的错误码,方便上层应用进行错误处理。

7.3 资源管理

要注意对硬件资源的管理,比如 GPIO 引脚、定时器、DMA 通道等。初始化时要正确配置,使用完毕后要释放资源。

对于共享资源(如 SPI 总线),要做好互斥保护,避免多个任务同时访问造成冲突。

7.4 性能优化

在保证功能正确的前提下,要考虑性能优化。

比如使用 DMA 来传输大量数据,使用中断而不是轮询来处理事件,合理设置通信波特率等。

在我做汽车电子项目时,对 CAN 总线的实时性要求很高,就必须使用中断 +DMA 的方式来处理数据。

8. 总结

单片机与外围设备的接口和驱动是嵌入式开发的核心内容。

掌握好各种通信接口的原理和驱动编写方法,是成为一名合格嵌入式工程师的必备技能。

从我多年的开发经验来看,理解硬件原理、熟悉通信协议、编写规范的驱动代码,这三点是最重要的。

在实际项目中,我们要根据具体需求选择合适的接口和驱动实现方式。

简单的应用可以直接调用 HAL 库函数,复杂的应用则需要自己封装更高级的驱动。

无论哪种方式,代码的可读性、可维护性和稳定性都应该是我们追求的目标。

希望这篇文章能帮助大家更好地理解单片机接口和驱动的相关知识。

更多编程学习资源