前言

在嵌入式系统开发中,传感器数据采集是连接硬件与实际应用的核心环节,而I2C总线协议凭借其接口简单、占用引脚少的优势,成为短距离外设通信的主流选择,广泛应用于温湿度传感器、EEPROM、OLED显示屏等器件的互联。

本次实践以STM32F103C8T6最小系统板为核心,聚焦I2C协议的实战应用,目标是完成AHT20温湿度传感器的数据采集与串口输出。

AHT20作为一款高精度、低功耗的数字温湿度传感器,采用I2C接口进行数据交互,非常适合嵌入式场景下的环境监测需求。本次作业将围绕两大核心展开:一是深入理解“软件I2C”与“硬件I2C”的本质区别及实现逻辑,夯实协议理论基础;二是基于HAL库与STM32CubeMX,完成传感器初始化、每隔2秒采集温湿度数据,并通过串口将数据上传至Win10上位机的全流程开发。

通过本次实践,不仅能掌握I2C协议的通信时序、传感器数据解析方法,还能深化STM32外设配置与多模块(I2C+串口)协同开发的能力,为后续复杂嵌入式系统(如环境监测终端、智能家居节点)的设计积累关键经验。


以下I2C协议的解释参考了野火资料,想要进一步了解,可以跳转到链接:

野火

一、了解I2C总线协议

1. 协议简介

I2C 通讯协议(Inter-Integrated Circuit)是由Phiilps公司开发的,由于它引脚少,硬件实现简单,可扩展性强, 不需要USART、CAN等通讯协议的外部收发设备,现在被广泛地使用在系统内多个集成电路(IC)间的通讯。

在计算机科学与嵌入式开发领域,多数复杂问题都能通过 “分层思想” 拆解简化。比如芯片设计中,会明确划分内核层与片上外设层,前者负责核心运算,后者承担外设控制,职责清晰;STM32 的标准库则更具代表性,它作为寄存器操作与用户应用代码之间的中间软件层,无需开发者直接操控寄存器,大幅降低了开发门槛。
对于通讯协议,分层思想同样是核心理解逻辑,最基础的划分便是物理层与协议层。物理层负责定义通讯系统的机械结构(如引脚布局)、电子特性(如电平标准、传输速率),核心作用是搭建 “物理通道”,保障原始数据能在导线等物理介质上稳定传输。协议层则聚焦 “逻辑规则”,统一收发双方的数据打包格式、解包规则,确保数据传递后能被正确解读。
简单类比:物理层好比约定 “用嘴巴说话” 还是 “用手势比划” 的交流方式,协议层则是约定 “说中文” 还是 “说英文” 的沟通语言,二者结合才能实现高效、准确的通讯。

2. 物理层

I2C 物理层主要定义了硬件连接方式和电气特性,核心是 “两根线实现多设备通信”:

  • 线路组成:仅需两根双向信号线 ——SDA(数据线,传输数据)和 SCL(时钟线,同步数据传输节奏)。
  • 连接方式:总线上所有设备(主设备和从设备)的 SDA、SCL 分别并联,支持 “一主多从” 或 “多主多从”(实际常用一主多从)。
  • 电气特性:设备接口多为开漏输出,需通过外部上拉电阻将 SDA、SCL 拉到高电平(通常接 3.3V 或 5V),实现 “线与逻辑”(任意设备拉低总线,总线即低;所有设备释放,总线由上拉电阻拉高)。

I2C 通讯设备常用连接方式(引用野火资料中的图)
在这里插入图片描述

3. 协议层

I2C 协议层主要规定了数据传输的 “逻辑规则”,解决 “怎么传、传什么、如何确认” 的问题,核心包括以下关键机制:

  • 通信发起与结束:主设备通过发送 “起始信号”(SCL 高电平时,SDA 从高拉低)启动通信;通过 “停止信号”(SCL 高电平时,SDA 从低拉高)结束通信。
    在这里插入图片描述
  • 数据有效性:
    I2C使用SDA信号线来传输数据,使用SCL信号线进行数据同步。见图 数据有效性。 SDA数据线在SCL的每个时钟周期传输一位数据。传输时,SCL为高电平的时候SDA表示的数据有效,即此时的SDA为高电平时表示数据“1”, 为低电平时表示数据“0”。当SCL为低电平时,SDA的数据无效,一般在这个时候SDA进行电平切换,为下一次表示数据做好准备。
    在这里插入图片描述
    每次数据传输都以字节为单位,每次传输的字节数不受限制。
  • 寻址机制:主设备先发送 “从设备地址”(7 位或 10 位,常用 7 位),后跟 1 位 “读写位”(0 表示写,1 表示读),指定要通信的从设备及操作方向。
  • 数据传输格式:

在这里插入图片描述
每次传输 1 字节(8 位),字节后紧跟 1 位 “应答位(ACK)”—— 接收方需拉低 SDA 表示已收到,无应答则视为传输结束。

  • 读写流程:写操作时,主设备连续发送数据;读操作时,从设备向主设备返回数据,主设备通过应答位控制是否继续读取。

4. 软件I2C与硬件I2C

在嵌入式开发中,“软件 I2C” 和 “硬件 I2C” 是实现 I2C 通信的两种方式,核心区别在于时序控制的实现主体

  1. 软件 I2C(bit-banging I2C)
    通过通用 GPIO 引脚(任意可配置为输入输出的引脚),用软件代码模拟 I2C 协议的时序(起始信号、停止信号、数据收发、应答位等)。
  • 实现方式:开发者手动编写代码,通过控制 GPIO 的高低电平变化,严格遵循 I2C 协议的时序要求(如 SCL 时钟线的高低电平持续时间、SDA 数据线在时钟信号下的跳变时机等)。例如:用HAL_GPIO_WritePin()函数控制 SCL 和 SDA 的电平,用HAL_Delay()或延时函数控制时序间隔。
  • 特点
    • 灵活性高:可使用任意 GPIO 引脚,不受硬件 I2C 外设引脚限制;
    • 可控性强:时序细节(如速率)可通过代码精确调整;
    • 缺点:占用 CPU 资源(需持续执行软件延时和电平控制),通信速率较低(通常不超过 100kHz),且代码复杂度稍高(需严格模拟时序)。
  1. 硬件 I2C(peripheral I2C)
    利用单片机内部集成的 I2C 硬件外设(如 STM32 的 I2C1、I2C2),通过配置外设寄存器(或 HAL 库函数),由硬件自动生成 I2C 协议时序。
  • 实现方式:只需配置 I2C 外设的时钟频率、地址模式等参数(如通过 STM32CubeMX 配置 I2C 外设),然后调用库函数(如HAL_I2C_Master_Transmit()、HAL_I2C_Master_Receive())即可完成数据收发,时序(如起始 / 停止信号、应答位)由硬件自动处理。
  • 特点
    效率高:硬件自动生成时序,不占用 CPU 资源,可同时处理其他任务;
    速率快:支持更高的通信速率(如标准模式 100kHz、快速模式 400kHz);
    缺点:引脚固定(受硬件 I2C 外设引脚限制),灵活性较低;若时序异常,调试难度稍高(需排查硬件配置)。

总结
简单说,软件 I2C 是 “用代码模拟电线的通断节奏”,硬件 I2C 是 “让专用电路自动控制节奏”。实际开发中,若引脚紧张或需灵活调整时序,可选软件 I2C;若追求效率和高速通信,优先用硬件 I2C。


二、I2C协议的温湿度传感器的数据采集(通过串口)

以下是基于 STM32F103C8T6 和 HAL 库实现 AHT20 温湿度采集的详细配置与代码实现,包含 STM32CubeMX 配置、硬件连接、核心代码及测试流程:

1. 硬件准备与连接

  1. 硬件清单
  • STM32F103C8T6 最小系统板
  • AHT20 (或UHT20) 温湿度传感器模块(自带 I2C 接口,通常已内置上拉电阻)
  • USB-TTL模块(用于串口通信)
  • 杜邦线若干
  1. 接线说明(硬件 I2C 为例,使用 I2C1)
STM32 引脚 AHT20 说明
PB6 SCL I2C 时钟线(硬件 I2C1_SCL)
PB7 SDA I2C 数据线(硬件 I2C1_SDA)
3.3V VCC 传感器供电(需 3.3V,不可接 5V)
GND GND 共地
PA9 TX(USB-TTL) 串口发送(USART1_TX)
PA10 RX(USB-TTL) 串口接收(USART1_RX)

2. STM32CubeMX 配置步骤

  1. 基础配置(RCC、SYS)
  • RCC 配置:勾选HSE(外部高速时钟),配置时钟树为 72MHz(APB1 分频设为 2,I2C 时钟频率由 APB1 提供,最高 400kHz)。
    在这里插入图片描述
    在这里插入图片描述
  • SYS 配置:Debug 选择Serial Wire(方便调试)。

在这里插入图片描述

  1. I2C 配置(硬件 I2C1)
  • 外设选择:在 Pinout 视图中,找到 I2C1,将 PB6 配置为I2C1_SCL,PB7 配置为I2C1_SDA。
  • 参数配置:
    • 模式:I2C(默认)
    • 时钟频率:100kHz(AHT20 支持标准模式,100kHz 足够)
    • 地址模式:7-bit(AHT20 的 I2C 地址为 0x38,7 位)
    • 其余保持默认(无需使能中断,采用轮询方式)。
      在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

  1. 串口配置(USART1)
  • 模式:Asynchronous(异步通信)
  • 参数:波特率115200,无奇偶校验,1 停止位,无硬件流控。
  • 引脚自动分配为 PA9(TX)、PA10(RX)。

在这里插入图片描述
在这里插入图片描述

  1. 生成工程
    选择 IDE(如 MDK-ARM V5),勾选 “Generate peripheral initialization as a pair of .c/.h files per peripheral”,生成代码。

在这里插入图片描述
在这里插入图片描述

3. 配置keil5的代码

以下是基于 HAL 库的 AHT20 驱动整合代码:

  • 宏定义与全局变量(main.c 中)
/* USER CODE BEGIN Header */
/**
  ******************************************************************************
  * @file           : main.c
  * @brief          : Main program body
  ******************************************************************************
  * @attention
  *
  * Copyright (c) 2025 STMicroelectronics.
  * All rights reserved.
  *
  * This software is licensed under terms that can be found in the LICENSE file
  * in the root directory of this software component.
  * If no LICENSE file comes with this software, it is provided AS-IS.
  *
  ******************************************************************************
  */
/* USER CODE END Header */
/* Includes ------------------------------------------------------------------*/
#include "main.h"
#include "i2c.h"
#include "usart.h"
#include "gpio.h"

/* Private includes ----------------------------------------------------------*/
/* USER CODE BEGIN Includes */
#include <stdio.h>
/* USER CODE END Includes */

/* Private typedef -----------------------------------------------------------*/
/* USER CODE BEGIN PTD */

/* USER CODE END PTD */

/* Private define ------------------------------------------------------------*/
/* USER CODE BEGIN PD */
// AHT20设备地址(7位地址左移1位,HAL库要求)
#define AHT20_ADDR         0x70  // 0x38 << 1

// 命令定义
#define AHT20_CMD_INIT     0xBE  // 初始化命令
#define AHT20_CMD_MEASURE  0xAC  // 测量命令
#define AHT20_CMD_RESET    0xBA  // 软复位命令

// 状态位定义
#define AHT20_STATUS_BUSY  0x80  // 忙状态位
#define AHT20_STATUS_CAL   0x08  // 校准状态位


/* USER CODE END PD */

/* Private macro -------------------------------------------------------------*/
/* USER CODE BEGIN PM */

/* USER CODE END PM */

/* Private variables ---------------------------------------------------------*/

/* USER CODE BEGIN PV */
float humidity = 0.0f;    // 湿度值(%)
float temperature = 0.0f; // 温度值(℃)
/* USER CODE END PV */

/* Private function prototypes -----------------------------------------------*/
void SystemClock_Config(void);
/* USER CODE BEGIN PFP */
uint8_t AHT20_Init(void);
uint8_t AHT20_ReadData(void);
uint8_t AHT20_Reset(void);
/* USER CODE END PFP */

/* Private user code ---------------------------------------------------------*/
/* USER CODE BEGIN 0 */
/**
  * @brief  AHT20软复位
  * @retval 0:成功 其他:失败
  */
uint8_t AHT20_Reset(void) {
  uint8_t cmd = AHT20_CMD_RESET;
  HAL_StatusTypeDef status;
  
  status = HAL_I2C_Master_Transmit(&hi2c1, AHT20_ADDR, &cmd, 1, 100);
  HAL_Delay(20);  // 复位后等待20ms
  return (status == HAL_OK) ? 0 : 1;
}

/**
  * @brief  初始化AHT20传感器
  * @retval 0:成功 1:复位失败 2:初始化命令失败 3:未校准
  */
uint8_t AHT20_Init(void) {
  uint8_t status;
  uint8_t init_buf[3] = {AHT20_CMD_INIT, 0x08, 0x00};  // 初始化命令+参数
  
  // 软复位
  if (AHT20_Reset() != 0) {
    return 1;
  }
  
  // 发送初始化命令
  status = HAL_I2C_Master_Transmit(&hi2c1, AHT20_ADDR, init_buf, 3, 100);
  if (status != HAL_OK) {
    return 2;
  }
  
  HAL_Delay(10);  // 等待初始化完成
  
  // 检查校准状态
  uint8_t status_reg;
  if (HAL_I2C_Master_Receive(&hi2c1, AHT20_ADDR, &status_reg, 1, 100) != HAL_OK) {
    return 3;
  }
  if ((status_reg & AHT20_STATUS_CAL) == 0) {
    return 3;  // 未校准
  }
  
  return 0;
}

/**
  * @brief  读取AHT20温湿度数据
  * @retval 0:成功 1:测量命令失败 2:读取数据失败 3:数据无效
  */
uint8_t AHT20_ReadData(void) {
  uint8_t measure_buf[3] = {AHT20_CMD_MEASURE, 0x33, 0x00};  // 测量命令+参数
  uint8_t data[6];  // 接收缓冲区
  uint32_t hum_raw, temp_raw;
  uint8_t i;
  
  // 发送测量命令
  if (HAL_I2C_Master_Transmit(&hi2c1, AHT20_ADDR, measure_buf, 3, 100) != HAL_OK) {
    return 1;
  }
  
  // 等待测量完成(最多等待100ms)
  for (i = 0; i < 100; i++) {
    HAL_Delay(1);
    if (HAL_I2C_Master_Receive(&hi2c1, AHT20_ADDR, data, 1, 10) == HAL_OK) {
      if ((data[0] & AHT20_STATUS_BUSY) == 0) {
        break;  // 测量完成
      }
    }
  }
  if (i >= 100) {
    return 3;  // 测量超时
  }
  
  // 读取完整数据(6字节)
  if (HAL_I2C_Master_Receive(&hi2c1, AHT20_ADDR, data, 6, 100) != HAL_OK) {
    return 2;
  }
  
  // 解析湿度(20位数据)
  hum_raw = ((uint32_t)data[1] << 12) | ((uint32_t)data[2] << 4) | (data[3] >> 4);
  humidity = (hum_raw / (float)(1 << 20)) * 100.0f;
  
  // 解析温度(20位数据)
  temp_raw = ((uint32_t)(data[3] & 0x0F) << 16) | ((uint32_t)data[4] << 8) | data[5];
  temperature = (temp_raw / (float)(1 << 20)) * 200.0f - 50.0f;
  
  return 0;
}

// 重定向printf到USART1
int fputc(int ch, FILE *f) {
  HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF);
  return ch;
}




/* USER CODE END 0 */

/**
  * @brief  The application entry point.
  * @retval int
  */
int main(void)
{
  /* USER CODE BEGIN 1 */
  uint8_t init_status;
  /* USER CODE END 1 */

  /* MCU Configuration--------------------------------------------------------*/

  /* Reset of all peripherals, Initializes the Flash interface and the Systick. */
  HAL_Init();

  /* USER CODE BEGIN Init */

  /* USER CODE END Init */

  /* Configure the system clock */
  SystemClock_Config();

  /* USER CODE BEGIN SysInit */

  /* USER CODE END SysInit */

  /* Initialize all configured peripherals */
  MX_GPIO_Init();
  MX_I2C1_Init();
  MX_USART1_UART_Init();
  MX_I2C2_Init();
  /* USER CODE BEGIN 2 */
  printf("AHT20 Test Start...\r\n");
  
  // 初始化AHT20
  init_status = AHT20_Init();
  if (init_status != 0) {
    printf("AHT20 Init Failed! Error: %d\r\n", init_status);
  } else {
    printf("AHT20 Init Success!\r\n");
  }
  /* USER CODE END 2 */

  /* Infinite loop */
  /* USER CODE BEGIN WHILE */
  while (1)
  {
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
    if (init_status == 0) {
      uint8_t read_status = AHT20_ReadData();
      if (read_status == 0) {
        printf("Humidity: %.2f %%  Temperature: %.2f ℃\r\n", humidity, temperature);
      } else {
        printf("Read Data Failed! Error: %d\r\n", read_status);
      }
    }
    HAL_Delay(1000);  // 1秒读取一次
  }
  /* USER CODE END 3 */
}

/**
  * @brief System Clock Configuration
  * @retval None
  */
void SystemClock_Config(void)
{
  RCC_OscInitTypeDef RCC_OscInitStruct = {0};
  RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};

  /** Initializes the RCC Oscillators according to the specified parameters
  * in the RCC_OscInitTypeDef structure.
  */
  RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE;
  RCC_OscInitStruct.HSEState = RCC_HSE_ON;
  RCC_OscInitStruct.HSEPredivValue = RCC_HSE_PREDIV_DIV1;
  RCC_OscInitStruct.HSIState = RCC_HSI_ON;
  RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON;
  RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE;
  RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9;
  if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK)
  {
    Error_Handler();
  }

  /** Initializes the CPU, AHB and APB buses clocks
  */
  RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK|RCC_CLOCKTYPE_SYSCLK
                              |RCC_CLOCKTYPE_PCLK1|RCC_CLOCKTYPE_PCLK2;
  RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
  RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1;
  RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2;
  RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1;

  if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_2) != HAL_OK)
  {
    Error_Handler();
  }
}

/* USER CODE BEGIN 4 */

/* USER CODE END 4 */

/**
  * @brief  This function is executed in case of error occurrence.
  * @retval None
  */
void Error_Handler(void)
{
  /* USER CODE BEGIN Error_Handler_Debug */
  /* User can add his own implementation to report the HAL error return state */
  __disable_irq();
  while (1)
  {
  }
  /* USER CODE END Error_Handler_Debug */
}

#ifdef  USE_FULL_ASSERT
/**
  * @brief  Reports the name of the source file and the source line number
  *         where the assert_param error has occurred.
  * @param  file: pointer to the source file name
  * @param  line: assert_param error line source number
  * @retval None
  */
void assert_failed(uint8_t *file, uint32_t line)
{
  /* USER CODE BEGIN 6 */
  /* User can add his own implementation to report the file name and line number,
     ex: printf("Wrong parameters value: file %s on line %d\r\n", file, line) */
  /* USER CODE END 6 */
}
#endif /* USE_FULL_ASSERT */


注意:这部分代码需检查 printf 重定向是否生效(HAL 库关键)
代码中虽有 fputc 重定向,但 Keil 需开启「微库」才能支持 printf,否则函数无法调用:
打开 Keil 工程,点击菜单栏「Options for Target」(魔法棒图标)。
切换到「Target」标签,在「Code Generation」下勾选 Use MicroLIB(微库)。
点击「OK」,重新编译烧录。
原理:标准 C 库的 printf 依赖文件系统,嵌入式环境需用微库简化实现,否则重定向无效。

  1. 测试验证
  • 编译烧录:将代码编译后,通过 ST-Link 或串口下载到 STM32F103C8T6。
  • 上位机配置:打开串口助手(如 SSCOM),选择 USB-TTL 对应的 COM 口,波特率 115200,无奇偶校验。
  • 现象观察:串口助手每隔 2 秒收到一行数据,格式为:
    Humidity: 45.32 %,Temperature: 22.94 C
    用手触摸 AHT20,温度和湿度值应随环境变化。

实验结果如下:

在这里插入图片描述

温湿度


总结

本次实践围绕STM32F103C8T6与AHT20温湿度传感器的I2C通信展开,从协议理论到实战开发形成完整闭环,成功实现了每隔1秒采集温湿度数据并通过串口输出的核心需求。

在理论层面,通过分层思想清晰拆解了I2C协议的物理层与协议层:物理层的“两线制+上拉电阻”设计简化了硬件连接,协议层的起始/停止信号、寻址机制、应答逻辑则保障了数据传输的准确性;同时明确了软件I2C与硬件I2C的差异,为实际开发中选择合适的通信方式提供了依据。

实战环节中,基于STM32CubeMX完成了高效配置——HSE时钟72MHz的精准设定、I2C1(100kHz标准模式)与USART1(115200波特率)的参数配置,大幅降低了底层开发复杂度。代码实现上,通过AHT20的软复位、初始化校准、测量命令发送与20位原始数据解析,精准计算出温湿度物理量;串口重定向功能的实现(需开启Keil微库)则确保了数据能实时上传至上位机。

测试结果表明,串口助手可稳定接收格式规范的温湿度数据,且数据随环境变化(如触摸传感器导致温度上升),验证了方案的可行性。本次实践不仅夯实了I2C协议与STM32外设配置的基础,更积累了多模块协同开发的经验,为后续拓展OLED显示、多传感器组网等复杂功能提供了可靠的技术支撑。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐