做嵌入式这么久,如果让我推荐一个性价比最高、知识密度最大、新手最容易获得成就感的入门实验,我会毫不犹豫地选这个:STM32F103C8T6 + 光敏电阻 + OLED屏幕。一块几块钱的蓝板,一颗几毛钱的光敏电阻,一块十几块的0.96寸OLED,加在一起就能把环境光强度实时显示在屏幕上。这个项目看着简单,实际上把嵌入式开发最核心的几个环节全串起来了——模拟信号的采集、ADC的量化转换、I2C通信协议、外设驱动的初始化、数据处理和界面刷新。
更关键的是,这个实验里踩到的坑和积累的经验,可以直接迁移到温湿度采集、电池电压监测、循迹小车、智能灯光控制等一票实际项目里。这篇文章不写虚的,也不打算复述一遍参考手册,就按我自己从选型、画电路、配CubeMX、写代码到调试完成的完整过程来讲,把每个环节为什么要这么做、踩过的坑、最后怎么解决的说清楚。
1. 从需求出发:为什么这个实验值得认真做一遍
1.1 光敏传感器是整个ADC体系的完美教学载体
很多新手第一个接触传感器的项目是按键,第二个可能是LED,但这两个都只涉及数字量——要么是0,要么是1。真实世界里的信号几乎都是连续的模拟量,温度是逐渐变化的,光线是逐渐变化的,电池电量也是逐渐变化的。如果只会处理数字量,碰到这类需求就无从下手。
光敏电阻恰恰是最友好的一类模拟传感器。它结构简单到只有一个可变电阻,阻值随光照强度变化——光照强时阻值降低,光照弱时阻值升高。它不需要复杂的时序协议,不需要I2C或SPI通信,不需要驱动电路,一个分压电路就能把电阻变化变成电压变化,直接送进STM32的ADC引脚。这就把“模拟信号采集”这件事的难点隔离在了最核心的部分:如何把电压准确变成数字量。
相比之下,如果一上来就搞心率传感器或者气体传感器,协议复杂、信号调理困难,出了问题根本分不清是传感器的问题还是ADC的问题。光敏电阻则不同,你拿万用表就能验证当前阻值,拿电压表就能验证分压点的电压,整个链路每一级都能手动测量,这对建立排查问题的信心极其重要。
1.2 整体方案与器件选型逻辑
这个项目我用的核心器件就四样,全部都是最容易买到的入门级元件:
| 器件 | 型号/规格 | 参考价格 | 作用 |
|---|---|---|---|
| 主控板 | STM32F103C8T6最小系统板 | 8-15元 | 数据采集与处理核心 |
| 光敏电阻 | GL5528(亮阻10-20KΩ) | 0.5-1元 | 将光照强度转换为电阻值 |
| 固定电阻 | 10KΩ 1/4W 直插/贴片 | 几分钱 | 与光敏电阻构成分压电路 |
| OLED | 0.96寸 SSD1306 I2C接口 | 10-20元 | 实时显示ADC值与光照状态 |
选STM32F103C8T6而不是其他芯片,理由是明确的:F103C8T6有2个12位ADC(ADC1和ADC2),最多可以采集10个外部通道(PA0-PA7、PB0-PB1),64KFlash和20K RAM的容量足够折腾各种显示方案和滤波算法,芯片价格在8-10元区间还供货充足。更实际的原因是网上资料极多,遇到问题随便一搜就能找到参考。
OLED选I2C接口版本的0.96寸屏,而不是SPI版本,是我特意为之的选择。I2C版本接线只需要两根信号线,加上电源和地一共四根线,对新手来说接线出错概率低得多。FSMC驱动的彩屏虽然显示效果华丽,但接线上百个脚,根本不合适入门项目。这个项目里I2C通信本身就是一个值得掌握的核心技能,用OLED练手刚刚好。
固定电阻选择10KΩ是有依据的,不是随便拿一个凑数。后面我会专门讲这个取值怎么算出来的,这也是很多教程从来不讲的地方。
2. 硬件接线:分压电路的计算与实际连接
2.1 光敏电阻分压电路的原理与计算
光敏电阻是可变电阻,但不能直接接到ADC引脚——ADC引脚要采集的是电压信号,而光敏电阻单独接在电路里只能改变电流,电压要么是VCC要么是GND,没法形成一个“随光照连续变化”的量。所以必须用一个固定电阻与光敏电阻串联,组成分压电路,把电阻变化转换为中间节点的电压变化。
我常用的接法是:VCC(3.3V) → 光敏电阻 → 中间节点 → 10K固定电阻 → GND,中间节点接STM32的PA1引脚。
这样接的原因很直观的考虑:光越强,光敏电阻阻值越小,分到的电压就越小,中间节点电压越低;光越弱,中间节点电压越高。有人喜欢反着接,让电压随光照增加而增大,从物理直觉上反人性,调试时会别扭,我个人不推荐。
10K固定电阻的取值逻辑是这样的:GL5528光敏电阻的亮阻(10Lux光照下)约为10-20KΩ,暗阻(0Lux完全黑暗)可达1MΩ以上。分压电路里,中间节点电压 = VCC × R固定 / (R固定 + R光敏)。当R固定=10K时:
- 强光下,R光敏≈5-15K,Vout ≈ 3.3 × 10 / (10+10) ≈ 1.65V左右,ADC读到的值大约在2048附近;
- 普通室内光下,R光敏可能到几十K,Vout接近2.5V以上,ADC值在3000多;
- 用手完全遮挡时,R光敏达到1M以上,Vout ≈ 3.3 × 10 / (10+1000) ≈ 0.0327V,ADC值接近0。
如果把R固定换成1K:强光时中间点电压能拉开,但暗光时输出几乎等于VCC,ADC全量程大部分区间被浪费在“弱光变化”上,而强光变化则不明显。如果把R固定换成100K:整个电压摆动区间明显收窄,ADC动态范围变小,分辨率下降。所以10K是兼顾动态范围和灵敏度的合适取值。在绝大多数入门教程里这个电阻几乎从不解释为什么取10K,但它的选择直接决定了你的ADC读数最大变化范围。
2.2 OLED模块的接线与I2C地址确认
0.96寸I2C OLED模块一般引出四个引脚:GND、VCC(接3.3V,不要接5V)、SCL(时钟线)、SDA(数据线)。
STM32F103C8T6的I2C1外设默认映射在PB6(SCL)和PB7(SDA)这两个引脚上。有些最小系统板上OLED的例程喜欢用PB8、PB9来软件模拟I2C,那是因为硬件I2C在多型号芯片上出现过兼容性问题,很多作者选择软件模拟来绕开。但这个项目里我用的是硬件I2C,跑400KHz快速模式,实测稳定。
接线对应关系:
| OLED引脚 | STM32引脚 | 备注 |
|---|---|---|
| GND | GND | 共地,必须接 |
| VCC | 3.3V | 不要接5V,可能烧模块 |
| SCL | PB6 | I2C1_SCL |
| SDA | PB7 | I2C1_SDA |
不知道你的OLED模块地址是0x3C还是0x3D时,最稳妥的办法是写一个I2C地址扫描程序,把所有地址都扫一遍,看哪个地址有ACK应答。虽然多数SSD1306模块的地址是0x3C(SA0接地),但个别板子SA0接了上拉电阻,地址会变成0x3D。如果你在OLED上电初始化后屏幕毫无反应,第一件事就该怀疑地址,而不是怀疑代码逻辑。
2.3 供电与共地:新手最容易忽略的细节
我见过好几个人做这个实验做不出来,最后发现是接线时OLED模块的VCC接在了5V上——模块上虽然印着5V输入也能工作,但SSD1306核心控制器的工作电压其实只有3.3V,长期供电5V有可能导致模块发热甚至损坏。
更隐蔽的一个问题是“共地”。STM32开发板、光敏电阻分压电路、OLED模块,无论电源从哪里来,所有模块的GND必须可靠接到同一个参考地。如果开发板用USB供电,而光敏电阻电路用另一块电源供电,两块电源的GND不连在一起,ADC引脚测量到的电压就是悬浮的,读出来的数完全是乱的,这个现象会让不少新手误以为是代码写错了。
还有一个需要提醒的:常见的“光敏传感器模块”(蓝色板子,带LM393比较器和电位器的那种),输出的是数字信号,不是模拟信号,不能直接用来做ADC采集实验。它内部已经用比较器把光照阈值转成了高低电平,你要做ADC采集用的是那种裸露的光敏电阻元件或者模拟量输出版模块。这两种模块长得有点像但完全不是一回事。
3. ADC采样的底层逻辑:12位数字是怎么从电压里算出来的
3.1 STM32的SAR型ADC量化原理
STM32F103内部集成的12位ADC,结构是逐次逼近型(SAR,Successive Approximation Register)。用最通俗的方式理解这个转换过程:ADC内部有一个比较器和一个可以精确控制的DAC参考电压源。它先从最高位(即参考电压的一半)猜起,把猜测值送给DAC输出一个模拟电压,和输入电压比较,如果输入电压比猜测值高,就保留该位并继续猜下一位;如果低,就清零改猜更小值。如此反复进行12次比较,最终得到12位二进制结果。
这就好比猜一个人的年龄,先猜50岁,对方说太大,再猜25岁,对方说太小,再猜37岁……每猜一次,范围缩小一半,一共猜12次,最后就能精确到年龄是不是25岁还是26岁这个精度。SAR ADC的“12位”指的就是这个二分查找做了12轮,最终得到的数字量范围是0到4095。
具体到数值计算:ADC输入电压与转换结果的对应关系是严格的线性映射。
[ ADC值 = \frac{V_{in}}{V_{REF+}} \times 4095 ]
反过来,已知ADC值时:
[ V_{in} = \frac{ADC值}{4095} \times V_{REF+} ]
F103的ADC参考电压VREF+默认接在VDDA上,而最小系统板的VDDA通常就是3.3V电源。所以当ADC读到2048时,输入电压大约是1.65V。这个换算关系是项目显示的核心,光强不能直接显示成“电压”和更直观的“亮度等级”、“暗/正常/亮”等状态都靠这个公式。
3.2 采样周期与建立时间到底会影响什么
这是一段很多教程直接跳过的内容,但这里真正决定了你读到的ADC值是“稳定的精确值”还是“每次都乱跳的骗人数据”。
ADC原理里,每次开始转换前,采样开关会闭合一段时间,让内部的采样保持电容充电到输入电压。这段时间就是采样周期。如果采样时间太短,电容还没充到输入电压就开始转换了,那么后面所有比较都是基于一个错误的电压,结果自然是偏小的、不准确的。
F103手册里给出的采样周期可以配置为1.5周期到239.5周期。注意这里的“周期”指的是ADCCLK时钟周期,不是系统主频周期。ADCCLK最大允许14MHz(由PCLK2经过分频得到)。采样周期为1.5周期时,采样时间约为107ns;提高到239.5周期时,约为17.1μs。
工程上一个非常重要的原则:信号源阻抗越高,需要的采样时间越长。信号源不是理想电压源,它自身有内阻。内阻越大,给采样电容充电就越慢。计算采样误差的近似公式是:
[ V(t) = V_{final} \times (1 - e^{-t / (R_{source} \times C_{sample})}) ]
其中Csample是ADC内部采样电容,约8pF;Rsource是外部信号源等效内阻。光敏电阻分压电路里,中间节点的戴维南等效电阻是两个电阻并联值。固定电阻10K、光敏电阻10K时,等效源阻抗约5KΩ;暗环境下光敏电阻1M时,等效源阻抗约9.9KΩ。在这么高的源阻抗下,为了把充电误差控制在0.5 LSB以内(12位ADC的1 LSB = 3.3V/4096 ≈ 0.8mV),需要的采样时间往往是几十微秒级别。
这就是为什么实际项目中我用55.5周期(约4μs)都会偶尔觉得不够稳,直接上239.5周期最省心。在光敏电阻这种低频缓慢变化信号的场景里,采样时间拉长不会带来任何负面影响,却能显著提高读数稳定性。你看到很多人说ADC数据跳得厉害,排除硬件问题之后,大部分原因其实就是采样时间设短了。
3.3 参考电压与反推公式的精度边界
F103的ADC参考电压是VREF+引脚,在C8T6最小系统板上直接连到3.3V。这里有个隐患:USB供电的3.3V往往不是精确的3.3V,实际可能是3.29V或3.31V。如果你用0.99作为LCD显示等需要精确电压的场景,误差就出来了。
一种更严谨的做法是接外部精密基准源到VREF+引脚,但这类最小系统板往往没有引出VREF引脚(内部已经连了),所以在这个实验中不必追求绝对精度。做一个光强指示器,误差0.05V完全不影响使用。
但你应该知道界限在哪:这个项目里显示的电压是相对参考电压的比值,不是绝对的物理电压。如果你要做的项目需要精确测量电压(比如电池电量监测),就不能简单按3.3V去反推,要对参考电压做实际测量或校准。在STM32F103这类芯片上,还可以用内部参考电压通道(VREFINT,一般是1.2V左右)来反推VDDA的实际值,从而修正读数,这也是脱离初级玩法、进阶到实用级ADC应用的标志性技能。
4. CubeMX工程配置与代码生成的完整过程
4.1 基本工程的创建和时基配置
打开STM32CubeMX,选择芯片型号时直接搜“STM32F103C8”,选定LQFP48封装的那个,就是经典的Blue Pill板型。在“Pinout & Configuration”界面里,先处理系统时钟——点开RCC,在High Speed Clock(HSE)栏选择Crystal/Ceramic Resonator,对应板载8MHz晶振。然后切到Clock Configuration页面,配出系统主频72MHz:HSE 8MHz经过PLL倍频到72MHz,APB1最大36MHz,APB2最大72MHz。
别小看时钟树的配置,ADC的采样时间和I2C的通信速率全都依赖这里的时钟频率。如果有人在ADC配置时发现采样时间选项异常或者I2C速率上不去,多半是时钟树没配好。配成72MHz主频后,PCLK1(APB1)默认36MHz,PCLK2(APB2)为72MHz。ADC挂在APB2上,所以ADC外设的输入时钟就是72MHz,需要分频到不大于14MHz,所以ADC Prescaler选6分频,得到ADCCLK = 72/6 = 12MHz。
4.2 ADC1的引脚与参数配置
在Pinout界面中,左侧Categories里找到ADC1,展开Enable ADC1。然后把芯片视图里PA1引脚(对应通道1,ADC1_IN1)用鼠标点击旋转到GPIO_ADC1_IN1模式。
进入ADC1的Parameter Settings,逐项设置:
| 参数项 | 设置值 | 理由 |
|---|---|---|
| Resolution | 12 bits | 默认就是12位,全范围0-4095 |
| Scan Conversion Mode | Disabled | 只有单通道,不需要扫描 |
| Continuous Conversion Mode | Disabled | 用单次转换+轮询或定时器触发,对慢变光信号足够 |
| Discontinuous Conversion Mode | Disabled | 单通道无需断续模式 |
| DMA Continuous Requests | Disabled | 不用DMA |
| End of Conversion Selection | EOC flag at end of single conversion | 单通道完成标志 |
| Number of Conversion | 1 | 只有1个转换序列项目 |
| External Trigger Source | Regular Conversion launched by software | 软件触发,代码里启动转换 |
| Rank 1 Channel | ADC_CHANNEL_1 | PA1对应通道1 |
| Sampling Time | 239.5 cycles | 配合高阻抗信号源,确保内部采样电容充足充电 |
这里需要强调一下:Continuous Conversion Mode我特意选了Disabled。连续转换模式下,ADC会不断自动启动下一次转换,CPU需要定时去读数据寄存器,反而容易造成数据错乱感。对于光照这种慢变量,软件触发单次转换、配合简单滤波,逻辑更清晰可控,也方便理解整个ADC工作流程。
4.3 I2C1的配置与OLED通信参数
在Categories里找到I2C1,选择I2C模式(能选I2C就选)。Parameter Settings里:
- I2C Speed Mode:Fast Mode(400KHz)
- I2C Clock Speed:400000 Hz
- Rising Time:默认即可(CubeMX会根据时钟自动计算)
使用硬件I2C需要理解一个关键点:SSD1306OLED模块是支持400KHz快速模式的,所以可以直接到这个速率。如果你用的OLED排线特别长(超过10厘米且没有屏蔽),400KHz下信号边沿可能变形,此时降低到100KHz标准模式往往就能解决问题。这个坑我实际遇到过,排线靠近电机驱动时尤其明显。
生成代码的选择上,勾选“Generate peripheral initialization as a pair of .c/.h files per peripheral”,会让每个外设单独生成一个.c和.h文件,比把所有初始化都塞在main.c里清爽得多。工程用MDK-ARM V5,Toolchain选择Keil即可。
5. 显示与采集的核心代码:从裸数据到界面呈现
5.1 SSD1306驱动的最小初始化序列
OLED驱动代码可以自己手写,也可以直接用网上成熟的驱动。自己手写一遍SSD1306的初始化序列对理解显示原理很有帮助,但实际项目里直接用现成封装好的驱动文件效率更高。这里我给出一个最小可用的SSD1306 I2C初始化序列,理解它的每一个指令含义比背代码重要:
/* SSD1306 初始化序列 - 基于I2C */ static void SSD1306_InitSequence(SSD1306_t *dev) { /* 关闭显示 */ SSD1306_WriteCommand(dev, 0xAE); /* 设置显示时钟分频/振荡频率 */ SSD1306_WriteCommand(dev, 0xD5); SSD1306_WriteCommand(dev, 0x80); /* 设置复用率 - 64行 */ SSD1306_WriteCommand(dev, 0xA8); SSD1306_WriteCommand(dev, 0x3F); /* 显示偏移 - 0 */ SSD1306_WriteCommand(dev, 0xD3); SSD1306_WriteCommand(dev, 0x00); /* 起始行 - 0 */ SSD1306_WriteCommand(dev, 0x40); /* 电荷泵开启 */ SSD1306_WriteCommand(dev, 0x8D); SSD1306_WriteCommand(dev, 0x14); /* 内存地址模式 - 水平 */ SSD1306_WriteCommand(dev, 0x20); SSD1306_WriteCommand(dev, 0x00); /* 列地址范围 */ SSD1306_WriteCommand(dev, 0x21); SSD1306_WriteCommand(dev, 0x00); SSD1306_WriteCommand(dev, 0x7F); /* 页地址范围 0-7 */ SSD1306_WriteCommand(dev, 0x22); SSD1306_WriteCommand(dev, 0x00); SSD1306_WriteCommand(dev, 0x07); /* 对比度 */ SSD1306_WriteCommand(dev, 0x81); SSD1306_WriteCommand(dev, 0xCF); /* 扫描方向 */ SSD1306_WriteCommand(dev, 0xC8); /* 列扫描方向 */ SSD1306_WriteCommand(dev, 0xA1); /* 正常显示 */ SSD1306_WriteCommand(dev, 0xA6); /* 预充电周期 */ SSD1306_WriteCommand(dev, 0xD9); SSD1306_WriteCommand(dev, 0xF1); /* VCOMH 消隐级别 */ SSD1306_WriteCommand(dev, 0xDB); SSD1306_WriteCommand(dev, 0x40); /* 解复用电平 */ SSD1306_WriteCommand(dev, 0xAD); SSD1306_WriteCommand(dev, 0x02); /* 开启显示 */ SSD1306_WriteCommand(dev, 0xAF); }初始化之后,SSD1306内置了128×64的显存(GRAM),总共1024字节(128列×64行/8位每页)。写入数据时,按列地址和页地址定位,每写8个点(一个字节)表示一列上连续的8个像素。所以显示一张文字或图形的逻辑是:先在MCU侧维护一个同样1024字节的缓冲区,绘制内容修改缓冲区,最后把整块缓冲区整体刷到OLED的GRAM里。
5.2 显示刷新策略:不要每帧全量刷新
新手最常犯的错是显示一个数字就全屏重新写一次显存。100多毫秒刷一次,屏幕明显闪烁,而且毫无必要。
正确做法是分区域刷新:修改哪个区域,就把哪个区域的字节发给屏幕。比如只改变ADC数值那一行,就只刷新那一页、对应列区域的数据。如果你手头的驱动库提供了设置窗口API(SetColumnAddress和SetPageAddress),就能精确控制刷新范围。
我在这个项目里还加了一个简单的判断:只有当ADC值相对上次变化超过阈值(比如20个LSB)时才刷新,否则保持屏幕静止。这样不仅省了MCU开销,还让显示看起来更稳定不跳动。
if (abs(adc_value - last_adc_value) > 20) { OLED_ClearPage(2); /* 清空第2行区域 */ OLED_ShowString(0, 2, "ADC: "); /* 显示文本 */ OLED_ShowNumber(40, 2, adc_value, 4); /* 显示数值 */ OLED_UpdatePage(2); /* 只刷新第2页 */ last_adc_value = adc_value; }5.3 从ADC原始值到光照状态的完整代码
主循环的核心逻辑可以这样组织:定时或延时后启动一次ADC转换,等待转换完成,读出12位数据,做滤波处理,然后换算电压和光照状态,更新屏幕。
/* 读取ADC并做滑动平均滤波,返回滤波后的原始值 */ uint16_t GetLightADCValue(void) { uint32_t sum = 0; uint8_t i; uint16_t buf[5]; for (i = 0; i < 5; i++) { HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 50); buf[i] = (uint16_t)HAL_ADC_GetValue(&hadc1); } /* 简单去极值:去掉最大和最小,取中间三次的平均 */ uint16_t max = buf[0], min = buf[0]; for (i = 1; i < 5; i++) { if (buf[i] > max) max = buf[i]; if (buf[i] < min) min = buf[i]; } for (i = 0; i < 5; i++) { if (buf[i] != max && buf[i] != min) { sum += buf[i]; } } return (uint16_t)(sum / 3); } float ADC_to_Voltage(uint16_t adc_value) { return (float)adc_value * 3.3f / 4095.0f; } const char* GetLightLevel(float voltage) { if (voltage > 2.5f) return "Dark"; else if (voltage > 1.5f) return "Normal"; else if (voltage > 0.8f) return "Bright"; else return "Strong"; }需要注意的是,HAL_ADC_GetValue返回的是32位无符号整数,但F103的ADC只有12位有效数据,所以强制转换成16位即可。HAL_ADC_PollForConversion的超时参数给50毫秒,正常情况下单次转换只需要几十微秒,但如果外设出错,这个超时能避免程序死等。
整体工程结构就三部分:OLED驱动负责显示,ADC读取负责采集,main循环负责组织逻辑。这已经是轻量级嵌入式项目很标准的架构分层了,以后加传感器、加其他显示,都在这个骨架上扩展。
6. 真实踩坑记录:这些坑我替你们踩过了
6.1 现象一:ADC读数跳变严重,忽高忽低
现象描述:在固定光照下,ADC读数波动超过正负80个LSB,显示的光照状态在“正常”和“明亮”之间反复横跳。
排查过程:
第一步,我用万用表实测分压点的电压,发现电压本身是稳定的(波动只有几个毫伏),说明问题不在传感器端,而在ADC转换端。
第二步,检查采样时间配置。发现初始工程里用的默认采样时间是1.5周期,ADCCLK是12MHz,采样窗口只有125ns。光敏电阻分压电路等效源阻抗5-10KΩ,要让内部8pF采样电容在125ns内充到稳定电压,理论上需要的源阻抗不超过700Ω。远不够。
第三步,把采样时间从1.5周期改为239.5周期后,跳动幅度从±80 LSB降到±10 LSB以内。这个现象和理论完全吻合。这是ADC采集跳变最容易忽略的原因。
修复后效果:稳定光下ADC值跳动范围控制在10个LSB左右,换算成电压约8mV,完全可以接受。
6.2 现象二:OLED上电后无显示或显示乱码
现象描述:OLED背光亮起但屏幕全黑,或者显示不规则的白色杂点。有时候程序一运行屏幕能亮一下,但内容全是乱的。
排查过程:
先排查初始化指令是否正常发送。我在SSD1306初始化序列后面加了一个回读命令(0x00模式读取状态寄存器),如果读到的值不为0就说明I2C通信链路有问题。实测发现读不到有效状态,怀疑I2C通信不正常。
然后又排查一个最容易犯的低级错误——SCL和SDA接反了。重新检查接线后确认没有接反。
最后用示波器看I2C波形,发现SDA线上有无规律的毛刺。追查下来发现是OLED模块的VCC接到了5V,SSD1306控制器本身是3.3V逻辑,5V供电虽然能亮,但I2C引脚电平与STM32的3.3V逻辑不完全兼容,导致通信数据错误。
修复方案:把OLED的VCC从5V改到3.3V,故障消失。
至于显示乱码,基本上是显存地址错乱。检查SetColumnAddress和SetPageAddress这些指令中地址是否超出了0-127列、0-7页的范围。超出范围后SSD1306会把写入的地址回卷到开头,表现为画面错位。
6.3 现象三:I2C总线卡在BUSY状态
现象描述:程序跑一段时间后,OLED屏幕停止更新,调试发现HAL_I2C_Mem_Write函数返回HAL_BUSY。
排查过程:
这是STM32硬件I2C最经典的问题,网上说法叫做“I2C外设卡死”。本质是I2C总线上出现错误时序(比如从设备在主机发送中途释放总线、通信过程中被中断打断),导致I2C硬件状态机的BUSY位被置1,且不会自动清除。后续所有I2C操作都会因为检测到BUSY而拒绝执行。
解决手段有两种:
第一种,不用硬件I2C,改用GPIO模拟I2C。虽然浪费了一个硬件外设,但GPIO模拟时序完全由CPU控制,任何情况下都可以通过拉高拉低来复位总线,不存在状态机卡死的问题。网上很多例程选这条路,就是因为能彻底回避这个坑。
第二种,继续使用硬件I2C,但增加错误恢复机制。检测到HAL_BUSY时,执行外设复位:
void I2C_Recover(I2C_HandleTypeDef *hi2c) { __HAL_I2C_DISABLE(hi2c); /* 软件复位 */ __HAL_RCC_I2C1_FORCE_RESET(); __HAL_RCC_I2C1_RELEASE_RESET(); MX_I2C1_Init(); /* 重新初始化 */ /* 手动翻转SCL 9个时钟脉冲,释放从机 */ for (int i = 0; i < 9; i++) { HAL_GPIO_WritePin(I2C1_SCL_GPIO_Port, I2C1_SCL_Pin, GPIO_PIN_SET); HAL_Delay(1); HAL_GPIO_WritePin(I2C1_SCL_GPIO_Port, I2C1_SCL_Pin, GPIO_PIN_RESET); HAL_Delay(1); } }我最终两个方案都验证过,实际稳定性上做得好的恢复机制和软件I2C差距不大。你如果赶进度不想纠结这个问题,直接上软件模拟I2C也是完全合理的工程决策。不要被“用硬件外设才专业”这种想法束缚,能把项目做完做稳定的方案就是好方案。
6.4 现象四:ADC值接近4095满量程
现象描述:光照变化时,ADC值经常打到4095,变化灵敏度看起来不高。
排查过程:这个现象说明输入电压经常超过参考电压附近,原因通常是分压电路里固定电阻阻值太大和光敏电阻亮阻太小。比如某些光敏电阻在强光下亮阻只有1-2KΩ,如果用100K固定电阻分压,中间节点电压几乎就是3.3V,ADC值一直在3900-4095区间顶格。
修复方案:换回10K或4.7K固定电阻,让分压区间回到ADC的中间区域。如果你想在特定光强区间获得最高分辨率,需要用实际电阻计算期望电压摆幅,再把固定电阻调到合适值。这个调试思路比直接问“推荐多少K”更有意义。
7. 滤波策略与数据平滑:从能用走向好用
7.1 为什么直接显示原始ADC值体验不佳
即使采样时间设置正确,完全稳定的光照下ADC值依然会有几个LSB的随机跳动,这部分来源包括:电源纹波、外部电磁干扰、参考电压起伏、内部量化噪声。几个LSB对于开关灯检测这种应用无所谓,但如果你要做一个渐变的夜间灯(根据环境光自动调节亮度),原始数据直接参与PWM输出,LED亮度会出现轻微闪烁,观感非常差。
这时候滤波算法的价值就体现出来了。嵌入式里最常用也最有效的滤波组合是:中值滤波 + 滑动平均。
中值滤波抵抗突发干扰:如果某个采样值因为瞬间干扰跳变到异常值,中值滤波可以直接剔除。滑动平均负责平滑:把多次采样结果平均,减少随机噪声。两者级联后,效果远好于单独任何一种。
以我这次项目为例,每轮读取5次ADC,去掉最大值和最小值,取剩余3个值的平均值,这已经在上一节代码里实现了。对于光照这种低频信号,这个处理量极低,一轮滤波读完只需要几百微秒,完全不影响系统实时性。
7.2 更平滑的递推平均滤波
如果觉得“去极值+平均”后的曲线还是有点生硬,可以在显示端再加一层递推平均:
#define FILTER_LEN 8 static uint16_t filter_buf[FILTER_LEN]; static uint8_t filter_index = 0; static uint32_t filter_sum = 0; uint16_t MovingAverage(uint16_t new_value) { filter_sum -= filter_buf[filter_index]; filter_sum += new_value; filter_buf[filter_index] = new_value; filter_index = (filter_index + 1) % FILTER_LEN; return (uint16_t)(filter_sum / FILTER_LEN); }这相当于一个长度为8的滑动窗口,每来一个新值,淘汰一个旧值,计算窗口内平均值。响应速度快,实现简单,是实际项目里最常用的平滑方案。用的时候根据你的信号变化速度调整窗口长度——信号变化快就短一点,信号本身就平滑就可以长一点。
7.3 光照状态判断的迟滞设计
还有一个做状态判断时必须了解的技巧:阈值迟滞。如果你用单个阈值判断“天黑了开灯”,光线在阈值附近波动会导致灯频繁开关——这个体验极其糟糕,专业术语叫“临界抖动”。
解决办法是设计两个阈值:亮度的开启阈值和关闭阈值不重合,中间留一个迟滞区间。例如:
- 当电压低于1.2V时判定为“亮”,启动关灯逻辑;
- 当电压高于1.8V时才判定为“暗”,启动开灯逻辑;
- 处于1.2V到1.8V之间时,保持上一个状态不变。
这样光线抖动就不会引起状态反复切换。这个迟滞思想不仅适用于光敏电阻,温控、湿度控制、按键消抖里全都用得到,属于嵌入式逻辑判断里必备的基本功。
8. 从这块蓝板出发还能往哪走
做完这个基础实验,其实你已经摸到了ADC采集类应用的共同骨架:传感器产生模拟信号 → 分压/调理电路 → ADC采样 → 数据处理 → 显示/响应。接下来顺着这个骨架,可以自然扩展出好几个方向。
方向一:多通道ADC采集。F103C8T6的ADC1支持最多10个外部通道,把温湿度传感器、土壤湿度传感器也接到其他引脚,按顺序扫描采集,就能做成一个迷你环境监测站。此时需要启用的就是之前我说Disabled的Scan Conversion Mode和DMA,一次转换后DMA自动把多个通道的数据搬到内存,效率大幅提升。
方向二:用定时器触发ADC做固定采样率采集。稳定间隔采样之后,可以进一步做FFT频谱分析(用ARM DSP库),或者基于时间的曲线记录。这时候你能做的就不再是简单的“读个数”,而是真正的数据采集系统了。
方向三:PWM联动闭环控制。把ADC采集的光强值映射到PWM占空比,控制LED亮度与环境光匹配。这就构成一个最简单的闭环控制系统,理解了这个反馈链路,后面做恒温控制、电机调速、平衡车都是同一个思路。
我建议你在完成基本实验后,不要急着换芯片换板子,先把上面第一和第三个方向各做一版。这两个方向用到的技术(DMA、定时器触发、PWM、闭环控制)比基础实验上了一个台阶,但还是全部落在F103C8T6这颗芯片上,资料好查、难度可控。等跑通了之后再考虑上FreeRTOS、接ESP8266做物联网上报,那时候你的工程能力已经完全够用了。
做这个实验最庆幸的是,整个链路每个环节都能用万用表、示波器、调试器验证,一旦出错能快速定位。这种“每一步都可验证”的项目,恰恰是最能积累真实经验的。上手吧。