本文是《STM32 HAL 库实战》系列第二篇,承接上一篇(GPIO、外部中断、串口中断与 DMA)。本文从 IIC 通信出发,完成 AHT20 温湿度传感器实战,然后介绍状态机编程思想、OLED 驱动原理、ADC 多通道采集(含 NTC 测温)和 RTC 实时时钟(含自定义时间函数),最后补充外设寄存器与寻址空间。全程基于 STM32F103 + CubeMX + HAL 库。

一、IIC 通信原理
1.1 什么是 IIC
除共地线外,IIC 只用两根线:SDA(数据,双向)和 SCL(时钟)。同一时刻只能一端发送,所以是半双工通信。特点:

主从模式:主机先发起通信;
多设备总线:每个设备有唯一地址;
空闲时 SDA / SCL 被上拉电阻拉高。
1.2 一次 IIC 通信过程
启动信号(SCL 高时 SDA 下拉) → 主机产生时钟脉冲(SCL 低时主机设数据线,SCL 高时从机读) → 主机发设备地址(7 位地址左移 1 位凑成 1 字节,末位 0=写、1=读) → 对应从机回 ACK → 交换数据 → 结束信号(SCL 高时 SDA 拉高)

地址示例:AHT20 的地址是 0x38(二进制 0111000),左移一位后:写地址 = 0x70、读地址 = 0x71。HAL 库会自动处理末位,一般传地址左移前的值、默认末位 0 即可。

1.3 IIC 主机读写函数
c // 主机读:阻塞式 HAL_StatusTypeDef HAL_I2C_Master_Receive(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size, uint32_t Timeout); // 主机写:阻塞式 HAL_StatusTypeDef HAL_I2C_Master_Transmit(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size, uint32_t Timeout); // hi2c:I2C 句柄;DevAddress:从机地址;pData:数据缓冲区;Size:字节数;Timeout:毫秒(HAL_MAX_DELAY 无限) // 返回值:HAL_OK / HAL_ERROR(如从机 NACK)/ HAL_BUSY(并发冲突)/ HAL_TIMEOUT

重点注意:这两个函数只负责收发数据,不会自动发送内部寄存器地址。没有内部地址的设备(如 AHT20)可以直接调用;有内部寄存器的设备(EEPROM、MPU6050 等)必须分两步:先发寄存器地址、再读写数据。HAL 库提供合并两步的 HAL_I2C_Mem_Read / HAL_I2C_Mem_Write,推荐优先使用。

1.4 位运算与 sprintf 小贴士
C 语言字面量默认是有符号 int,做位移/位操作时最安全的是加后缀而不是强转:1U、1UL、1ULL;
sprintf 注意事项:Keil MDK MicroLIB 模式下默认不支持 %f(需勾选 Use MicroLIB 或开启浮点支持);函数不检查缓冲区大小,有溢出风险;结尾自动补 ‘\0’,缓冲区要留出该字节;单片机端避免直接输出中文字符串(不同环境汉字字节数不同)。
二、实战:AHT20/DHT20 温湿度传感器
2.1 测量流程
AHT20 触发一次测量:发送测量命令 0xAC 0x33 0x00 → 等待约 75~80ms → 读取 6 字节数据 → 解析出温度和湿度。

2.2 数据解析(20 位原始数据的拆分)


// 湿度:Byte1(高8位) -> Byte2(中8位) -> Byte3(高4位);RH = (RawData / 2^20) * 100% uint32_t data = ((uint32_t)readBuffer[1] << 12) + ((uint32_t)readBuffer[2] << 4) + ((uint32_t)readBuffer[3] >> 4); *Humidity = data * 100.0f / (1 << 20);

// 温度:Byte3(低4位) -> Byte4(中8位) -> Byte5(低8位);T = (RawData / 2^20) * 200 - 50 data = (((uint32_t)readBuffer[3] & 0x0F) << 16) + ((uint32_t)readBuffer[4] << 8) + (uint32_t)readBuffer[5]; *Temperature = data * 200.0f / (1 << 20) - 50; ```

2.3 中断/DMA 模式下必须用 static 缓冲区
中断模式发送时,函数只发出第一个字节就会往下执行。如果发送缓冲区是函数内的局部变量,函数返回后内存被释放、地址被复用,数据会被改掉。因此发送缓冲区必须定义为 static:

c static uint8_t sendBuffer[3] = {0xAC, 0x33, 0x00}; // 必须 static!

同时把功能拆成三个函数:AHT20_Measure()(发命令)、AHT20_Get()(收 6 字节)、AHT20_Analysis()(解析数据),配合状态机使用。

三、状态机编程:把"等待"变成"流水线"
3.1 为什么需要状态机
裸机写延时等待(HAL_Delay)会阻塞 CPU,多任务场景(同时要读传感器、刷 OLED、响应按键)就做不到了。状态机把一次完整操作拆成多个状态,每个状态只做一小步,用"时间判断"代替"死等",让主循环每轮都能"雨露均沾"3.2 状态机核心三要素
状态:系统当前所处的特定阶段;
事件:触发状态转移的外部/内部条件;
动作:进入/处于/退出某状态时执行的操作。
3.3 C 语言实现:枚举 + Switch-Case
```c typedef enum { STATE_IDLE, STATE_SENDING_CMD, STATE_WAITING_MEASURE, STATE_READING_DATA } AHT20_State_t; static AHT20_State_t currentState = STATE_IDLE; static uint32_t startTime = 0;

void AHT20_Task(void) { switch (currentState) { case STATE_IDLE: AHT20_Measure(); // 发测量命令 currentState = STATE_SENDING_CMD; break; case STATE_SENDING_CMD: break; // 等 I2C 发送完成回调,切到下一状态 case STATE_WAITING_MEASURE: if (HAL_GetTick() - startTime >= 80) // 非阻塞延时 80ms currentState = STATE_READING_DATA; break; case STATE_READING_DATA: break; // 中断读取,回调中计算并切回 STATE_IDLE } } ```

3.4 关键:用 HAL_GetTick() 代替 HAL_Delay()
```c __weak uint32_t HAL_GetTick(void); // 返回自 HAL_Init() 以来经过的毫秒数(SysTick 每 1ms 中断使全局变量 uwTick 自增)

// 非阻塞延时写法: if (currentState == STATE_WAITING && (HAL_GetTick() - lastTime >= 80)) { lastTime = HAL_GetTick(); currentState = STATE_NEXT; } ```

3.5 状态机注意事项
禁止在状态内用 while() 或 HAL_Delay 死等,否则状态机退化成"单次循环",失去多任务能力;
动作只执行一次——执行完就要切换状态,不能每轮重复执行;
main 的 while(1) 里,状态机是"流水线"不是"跳楼机":每轮只执行一个状态的逻辑,然后进入下一轮循环;
更进一步的方向:层次状态机、事件驱动架构。
四、OLED 驱动原理与取模
4.1 OLED 核心原理
MCU 不能直接点亮 OLED 像素,只能修改驱动芯片内部的 DDRAM(显存)。0.96128×64 OLED 有 8192 个像素、每像素 1bit,显存只有 128×64÷8 = 1024 字节(1KB)。流程:

MCU 内存维护 1KB 显存数据 → 通过 IIC/SPI 传给 OLED 显存 → 驱动芯片按数据点亮像素

I2C 接口(4 针:SDA/SCL,400kHz 或 1MHz):刷一屏约几毫秒;
SPI 接口(7 针:MOSI/SCLK/DC/CS,10MHz+):适合动画;
关键细节:I2C 传输先发 0x00 表示后面跟的是命令,发 0x40 表示后面跟的是显示数据——发错会全屏乱码!
4.2 页地址模式
128×64 屏幕分成 8 个 page(页),每页 128 列 × 8 行;一个字节表示某一页某一列的 8 个像素(10 灭)。

以 CH116 驱动芯片为例:IIC 地址 0x7A;设置页地址指令为 0xBn(0xB0~0xB7);设置列地址分两次(先 0x0n 低四位、再 0x1n 高四位)。方便特性:写完一个字节后列地址自动 +1——所以只需设置页地址 0、列地址 0,连续发 128 字节即可画满一页,页地址递增循环 8 次画满全屏。

4.3 oled.c 例程(显存缓冲 + 整体刷新)
```c

define OLED_ADDRESS 0x7A
void OLED_SendCmd(uint8_t cmd) { uint8_t sendBuffer[2]; sendBuffer[0] = 0x00; // 命令前缀 sendBuffer[1] = cmd; HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDRESS, sendBuffer, 2, HAL_MAX_DELAY); }

uint8_t GRAM[8][128]; // 定义显存

void OLED_NewFrame(void) { // 显存清空 memset(GRAM, 0, sizeof(GRAM)); }

void OLED_ShowFrame(void) { // 显存内容发到屏幕 uint8_t sendBuffer[129]; sendBuffer[0] = 0x40; // 数据前缀 for (uint8_t i = 0; i < 8; i++) { memcpy(&sendBuffer[1], GRAM[i], sizeof(GRAM[i])); OLED_SendCmd(0xB0 + i); // 页地址 OLED_SendCmd(0x02); // 列地址低四位 OLED_SendCmd(0x10); // 列地址高四位 HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDRESS, sendBuffer, sizeof(sendBuffer), HAL_MAX_DELAY); } }

void OLED_SetPixel(uint8_t x, uint8_t y) { // 点亮像素(画进显存,不直接上屏) if (x >= 128 || y >= 64) return; GRAM[y / 8][x] |= 0x01 << (y % 8); } ```

4.4 memset / memcpy 注意事项
c void *memset(void *ptr, int value, size_t num); // 前 num 个字节全部设为 value void *memcpy(void *dest, const void *src, size_t n); // src 的 n 字节原封复制到 dest

memset 按字节填充:对 int/float 赋非零值不可用(清零可用);
memcpy 的 n 是字节数不是元素个数;
源与目标地址不能有交集(有交集用 memmove)。
4.5 取模与二值化
取模 = 把字符/数字/图像转成单片机可识别的点阵数据:

字模:16×16 汉字 = 256 像素 = 32 字节;
图模:原理相同,尺寸更大;
注意事项:取模方式需匹配驱动芯片;单色屏要先二值化——设阈值,亮度大于阈值记白(1)、否则记黑(0);显存只有 1KB,UI 设计要控制图模尺寸。
4.6 驱动库的"三明治"结构
顶层:功能 API 接口(用户用,与硬件无关) 中间层:算法与状态管理(库核心,与硬件无关) 底层:硬件抽象层(用户按 MCU 填写,实现跨 MCU 移植)

理解了"三明治",就能看懂任何驱动库的组织方式,也能自己写出可移植的驱动。

五、ADC:模拟量采集
5.1 核心概念
ADC 把连续模拟电压转为离散数字量。F103 的 ADC 是 12 位(0~4095,对应 0V~参考电压 3.3V),分辨率 = 量程 / 2^12。逐次逼近法(二分比较):内置 DAC 先输出量程中值 1.65V(对应 2048)与采样电压比较,确定第 12 位;再输出 10241536 逐位确定,直到最低位。

工作流程:选择通道(多路选择开关)→ 采样保持(开关 + 电容充电后断开)→ 逐次比较 → 产生 EOC 标志 → 结果存入结果寄存器。

5.2 规则组与注入组
F103 有 16 个外部通道 + 2 个内部通道(温度传感器、内部参考电压),ADC1/ADC2 两个模块:

规则组	注入组
定位	常规任务队列	高优先级"插队"队列
通道数	最多 16 个	最多 4 个
结果存储	共用 DR 寄存器	独立 JDR1~4 寄存器
DMA	多通道必须配 DMA 防覆盖	无需 DMA,读取方便
适用	常规顺序采样	定时器触发精确测量电源电流/电压
5.3 使用流程与函数
上电先校准 → 启动 ADC → 等待采样&转换 → 获取结果&计算

```c // 校准(初始化之后、启动之前调用,ADC 须处于禁用状态;阻塞式) HAL_StatusTypeDef HAL_ADCEx_Calibration_Start(ADC_HandleTypeDef *hadc, uint32_t SingleDiff); // 注:F1 系列仅 1 个参数,L4/H7 等支持差分输入的系列为 2~3 个参数

// 软件触发启动转换(不涉及中断和 DMA) HAL_StatusTypeDef HAL_ADC_Start(ADC_HandleTypeDef *hadc);

// 阻塞等待转换完成(读 EOC 标志,置 1 或超时才继续;超时时间要大于转换时间) HAL_StatusTypeDef HAL_ADC_PollForConversion(ADC_HandleTypeDef *hadc, uint32_t Timeout);

// 读取转换结果(读取操作通常会清除 EOC,为下次转换做准备) uint32_t HAL_ADC_GetValue(ADC_HandleTypeDef *hadc);

// DMA 模式启动:每次转换完成自动把结果搬运到内存缓冲区,全程无需 CPU 干预 HAL_StatusTypeDef HAL_ADC_Start_DMA(ADC_HandleTypeDef hadc, uint32_t pData, uint32_t Length); // pData:结果缓冲区指针;Length:DMA 传输数据个数 ```

5.4 连续转换
关闭连续转换:单次转换模式,触发一次完成一次(或一个规则组序列)就停止,再转需再启动;
开启连续转换:一轮接一轮永不停歇,直到软件叫停。连续转换中 12 位结果同步更新,不用担心读到转换一半的错误值;不要求每次发送结果时,可以不使用等待转换完成函数。
5.5 多通道扫描 + DMA
扫描模式按预设顺序自动完成所有通道转换,无需 CPU 切换通道。但转换极快、内部只有一个规则数据寄存器,后完成的数据会覆盖前面的——所以多通道必须配 DMA:DMA 从硬件层面立即把数据转入指定数组(数组大小 = 通道数量,一一对应);DMA 全部传完触发传输完成中断,可对整轮多通道数据做处理;扫描模式还可以叠加连续转换,实现循环采样。

5.6 内部参考电压反推 VDDA
STM32 内部参考电压是一个稳定的、已知电压值(1.2V)的内部信号通道,用于校准和检测:

VDDA = 1.2V / (VREFINT读数 / 4096)

两个典型应用:

电源电压未知或不稳定时(如电池供电):ADC 测量结果是相对于供电电压 VDDA 的,读已知 1.2V 内部参考即可反推出当前 VDDA(供电电压相对稳定,一般只算一次);
高精度绝对测量:用出厂校准数据做单点线性校准,消除 ADC 线性度误差。
5.7 NTC 热敏电阻测温
NTC(Negative Temperature Coefficient)热敏电阻:阻值随温度升高明显减小。ADC 只能测电压,所以用分压电路把阻值变化转为电压变化,再配合内部参考电压提高精度。

六、RTC 实时时钟
6.1 结构与原理:VBAT 与后备区域
STM32 的 VBAT 引脚专为芯片内的"后备区域"供电(一般接一颗纽扣电池)。VDD 掉电后,后备区域依靠 VBAT 继续运行,而 RTC 就在后备区域中,所以 VDD 掉电后 RTC 依然计时。

RTC 有三种时钟源,只有 LSE 在 VDD 掉电后还能继续提供时钟,因此 RTC 一般使用 LSE。RTC 还有简单闹钟功能:闹钟寄存器存一个时间戳,计数器与之相等时触发闹钟中断。

6.2 F1 与 F4 的计数器差异(重要)
HAL 库的 RTC 函数把"时间与日期"分开处理(秒分时、日月年各自独立计数器,满 60 自动进位)——这是 F4 等高级芯片的硬件设计:

F1 的计数器是全周期计数器:存"从创世纪开始的总秒数",日期编码在里面,读一个数得到全部信息;
F4 的计数器是短周期计数器:只存"今天从 0 点开始的秒数",日期在另一个寄存器,只读计数器日期信息会"丢失"。
所以 HAL 库的 RTC 时间函数是为 F4 设计的,使用 F1 系列时通常需要自定义 RTC 函数。

6.3 时间戳与 time.h
Unix 时间戳 =1970-01-01 00:00:00 UTC 起经过的总秒数。time.h 核心内容:

类型/函数	作用
time_t	本质是整数,存时间戳
struct tm	拆成年月日时分秒等人类可读字段
time()	获取当前时间戳
gmtime() / localtime()	时间戳转 struct tm(UTC / 本地时间)
mktime()	反向操作,struct tm 转回时间戳
strftime()	把时间格式化成字符串
difftime()	计算两个时间戳的差值
6.4 自定义 RTC 函数(F1 系列)
初始化(CubeMX 可直接配置):使能电源和备份域时钟 → 选择并开启 LSE → 配置预分频器产生 1Hz → 等待 RTC 同步。

设置时间:用 mktime() 把年月日时分秒换算成时间戳(注意 tm_year 从 1900 计、tm_mon 从 0 计),再写入计数器:

```c

include
struct tm t; t.tm_year = 2026 - 1900; // 年份从 1900 计 t.tm_mon = 7; // 8 月,月份从 0 开始 t.tm_mday = 1; t.tm_hour = 10; t.tm_min = 30; t.tm_sec = 0; time_t timestamp = mktime(&t); // 得到 UTC 时间戳 // 写入计数器(SPL 四步): RTC_EnterConfigMode(); // 1. 进入配置模式:设置 CRL 的 CNF 位,解锁 RTC 核心 RTC_SetCounter(timestamp); // 2. 写入时间戳到 RTC_CNT RTC_ExitConfigMode(); // 3. 退出配置模式:清除 CNF 位,恢复计数 RTC_WaitForLastTask(); // 4. 等待写操作完成(轮询 RTOFF 标志) ```

读取时间:直接读计数器 → localtime()/gmtime() 转日历时间:

c time_t unixTime = RTC_ReadTimeCounter(&hrtc); // HAL 库定义可直接移植;需 #include "time.h" struct tm *local_t = localtime(&unixTime); printf("%04d-%02d-%02d %02d:%02d:%02d\n", local_t->tm_year + 1900, local_t->tm_mon + 1, local_t->tm_mday, local_t->tm_hour, local_t->tm_min, local_t->tm_sec);

封装成自己的 API:

c void MyRTC_Init(void); // 初始化(只在第一次或断电后调用) void MyRTC_SetTime(uint16_t year, uint8_t month, uint8_t day, uint8_t hour, uint8_t min, uint8_t sec); uint32_t MyRTC_GetTimestamp(void); // 获取时间戳 void MyRTC_GetTimeStr(char *buf); // 获取格式化时间字符串

6.5 RTC 的两个经典坑
坑一:掉电不延续。每次复位/重新上电都会重新设定时间 → 用备份寄存器方案:初始化时向备份寄存器写入一个标志数据,每次启动先检查该寄存器是否已有标志,有则跳过设置时间:

c void PR_RTC_Init(void) { uint32_t initFlag = HAL_RTCEx_BKUPRead(&hrtc, RTC_BKP_DR1); // 读备份寄存器 if (initFlag == RTC_INIT_FLAG) return; // 已设过时间,直接跳过 // …… 设置时间,并写入 RTC_INIT_FLAG 到备份寄存器 }

坑二:HAL 库初始化让 RTC 停表。CubeMX 生成的 MX_RTC_Init() 中的 HAL_RTC_Init() 初始化过程会使 RTC 停止运行一小会,导致每次复位时间变慢 → 解决:RTC 只初始化一次,把 HAL_RTC_Init() 移入自定义的 PR_RTC_Init()(仅首次调用),在 MX_RTC_Init() 注释对中把 HAL_RTC_Init() 改成 PR_RTC_Init() 后直接 return 跳出。

七、STM32 外设寄存器与寻址空间
7.1 "操作单片机的本质就是操作寄存器"
HAL 库函数做的就是:把寄存器地址用可读性更高的指针指向,把复杂指令封装成函数。各外设的寄存器一览:

外设	主要寄存器
GPIO	输入数据、配置高/低、输出数据、位设置/清除寄存器
串口	状态、数据、波特比率、控制、保护时间和预分频寄存器
定时器 TIM	预分频器、状态、事件产生、自动重装载、DMA/中断使能、计数器、数据寄存器
I2C	自身地址、时钟控制、控制、数据、状态寄存器
ADC	规则序列、规则数据、控制、状态、采样时间寄存器
7.2 4GB 寻址空间
STM32 是 32 位 ARM Cortex-M 内核,地址总线 32 根线,最多组合出 2^32 = 4GB 个地址。注意:4GB 是 CPU 查找地址能力的上限(寻址空间),不是 STM32 有 4GB 内存。外设寄存器、SRAM、Flash 等各占寻址空间中的一段范围。

八、总结
IIC:两根线、半双工、主从多设备;分清"无内部地址""有内部寄存器"两类设备的读写方式;
状态机:三要素(状态/事件/动作)+ 枚举 + switch-case + HAL_GetTick() 非阻塞延时,是裸机"伪多任务"的利器;
OLED:显存 1KB、页地址模式、0x00/0x40 前缀、取模二值化;"三明治"驱动库结构让驱动可移植;
ADC:规则组/注入组、校准四步走、多通道必须配 DMA、内部参考电压反推 VDDA、NTC 分压测温;
RTC:后备区域 + LSE 保证掉电计时,F1 要自定义函数,备份寄存器解决"掉电不延续",只初始化一次解决"复位时间变慢"。
至此,从 GPIO 到串口、从传感器到显示、从模拟采集到实时时钟,一套完整的 STM32 HAL 库开发体系就串起来了。下一篇将进入 RTOS 与 FreeRTOS 多任务开发,欢迎继续阅读。

如果这篇文章对你有帮助,欢迎点赞收藏;如有错误或疑问,欢迎在评论区交流。
Logo

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

更多推荐