最近在折腾STM32F103C8T6最小系统板的时候,发现入门阶段最有成就感的组合之一,就是用ADC采集光敏电阻的电压,再通过I2C驱动一块0.96寸OLED把数据实时显示出来。这个实验其实包含了嵌入式开发里最常用的几条主线:ADC模拟量采集、传感器分压电路、I2C通信、OLED驱动、数据处理与滤波。把一个实验串通,基本就等于把STM32的常用外设轮了一遍。
我最早做这个项目时是在一个阴天的下午,当时我手里刚好有一块几块钱的光敏电阻模块,屏幕是最常见的SSD1306驱动0.96寸OLED,板子就是蓝板的STM32F103C8T6最小系统板。从CubeMX建工程到OLED显示出实时亮度值,整个过程大概花了两个小时。这里把完整的操作过程、原理拆解和踩坑经验都记录下来,给正在学STM32的朋友参考。
我在这个项目里最终用的是PA1作为ADC输入引脚,接了光敏电阻和10K电阻的分压电路,用I2C1连接OLED的SCL、SDA,整个系统的实物接线一共只用了四根杜邦线加一个面包板。装置简单,但是涉及的知识点一点都不少,非常适合新手在学完GPIO和定时器之后作为第二个练手项目。
1. 项目整体设计与硬件接线方案
1.1 为什么选STM32F103C8T6做这个实验
STM32F103C8T6是一颗非常经典的Cortex-M3内核单片机,主频最高72MHz,Flash有64KB,RAM有20KB。这个配置放在今天也不算弱,关键是这颗芯片的资料极其丰富,几乎你能想到的任何外设用法,都能在网上找到参考代码。
更重要的原因是它的ADC配置非常灵活:12位分辨率、多个通道、支持扫描模式和DMA传输,而且它有多个ADC外设,可以同时采集多个通道。虽然我们这里只采集一个光敏电阻,但后续如果要做环境光照监测、电池电压检测、多路传感器采集,这颗芯片都能直接顶上来。
对于新手来说,用这板子的另一个好处是价格很低。一块最小系统板加一块ST-Link下载器,整体成本也就几十块钱。就算接线接错把芯片烧了,换一片的成本也远低于其他开发板。加上F103系列的寄存器映射和HAL库代码高度统一,你在这个项目里学会的东西,以后换到F407、F4系列也基本能无缝迁移。
1.2 光敏电阻分压电路原理与参数计算
光敏电阻是一种阻值随光照强度变化的元件,但它不是线性的,也不是直接输出电压。要把阻值变化变成单片机可以读取的电压信号,最常用的方式就是搭建一个分压电路。
我用的是这样一个接法:3.3V电源经过光敏电阻,再串联一个10K的固定电阻到GND,从光敏电阻和固定电阻的中间节点引出信号接到PA1。这个节点电压的计算公式就是标准分压公式:
Vout = 3.3V x R2 / (R_ldr + R2)
其中R2是接地侧固定电阻,我选的10K;R_ldr是光敏电阻当前阻值。光照越强,光敏电阻阻值越低,分压点在同样电源电压下得到的Vo值越高,ADC读数也就越大。
选10K这个值不是拍脑袋决定的,而是基于常见光敏电阻在普通室内光照下的阻值范围。市面上常见的5528光敏电阻,在强光下阻值可以降到1K到2K,在黑暗环境下阻值可以升到几百K甚至几兆欧。用10K做分压电阻,可以让ADC读数在“黑暗到明亮”这个区间内有一个较明显的变化梯度,不会出现长时间停留在满量程或者零值的现象。
注意:一定要用3.3V给这个分压电路供电,不要用5V。STM32F103C8T6的ADC参考电压默认是VDDA,通常就是3.3V,如果输入电压超过3.3V,轻则ADC读数饱和失真,重则损伤引脚。
1.3 OLED模块与I2C总线的连接
这个项目用的OLED是0.96寸、128x64分辨率、SSD1306控制器的I2C版本。这个模块只有四个引脚:VCC、GND、SCL、SDA。和STM32的接线非常简洁:
- OLED VCC -> 3.3V
- OLED GND -> GND
- OLED SCL -> PB8(I2C1_SCL)
- OLED SDA -> PB9(I2C1_SDA)
之所以用硬件I2C1而不是软件模拟I2C,是因为CubeMX配置起来更简单,代码也更稳定,不占用CPU执行时间。STM32F103C8T6的I2C1在PB6/PB7或者PB8/PB9都有映射,我习惯选PB8/PB9,因为这个组合和PA1不在同一个区,接线时不会互相干扰。
OLED模块的I2C地址默认是0x3C,但也有些模块的地址是0x3D,这取决于模块背面SA0引脚的电平。代码里通常会把地址定义成宏,如果屏幕没有反应,第一件事就是检查这个地址是否和你的模块匹配。在HAL库中,I2C1的时钟频率可以配置到400KHz的快速模式,SSD1306完全支持这个速率。
2. 基于CubeMX的工程配置与应用逻辑
2.1 时钟树与调试接口配置
打开STM32CubeMX,选择芯片型号STM32F103C8Tx,然后开始配置时钟、调试接口、GPIO和ADC。这个步骤比较关键,尤其对于刚接触HAL库的初学者,因为CubeMX生成的初始化代码无论是否完全理解,只要配置对了,后面写应用代码就少了很多低级错误。
我习惯的顺序是先配SYS里的Debug选项。把Debug选为Serial Wire,否则你如果用ST-Link下载器的话,下载一次之后SWD接口可能会被禁用,导致无法再次连接。这是我早期踩过的一个坑,后来每次新建工程都先把这个选上,变成肌肉记忆了。
接着是RCC配置,把HSE选为Crystal/Ceramic Resonator。如果最小系统板上没有外部晶振,也可以直接用HSI内部时钟,但既然板子上都有8MHz晶振,就直接用外部晶振,在时钟树里把系统主频拉满到72MHz。这里注意,F103的最高主频就是72MHz,超过这个值芯片会不稳定甚至无法启动,时钟树配置界面里也能看到频率的上限提示。
APB1和APB2的总线时钟不用特别改,保持默认的36MHz和72MHz即可,因为这会影响ADC、I2C、USART这些外设的时钟来源,默认值对大部分应用都是合适的。
2.2 ADC1单通道配置与采样参数选择
在Analog栏中找到ADC1,勾选IN1通道。因为光敏电阻分压信号接在PA1上,ADC1的通道1对应这个引脚。如果接线接在PA0,那就选IN0;接在PA4就选IN4。这里的通道编号一定要和实际接线引脚一致,否则读出来的值是另一个引脚的,而且很可能是悬空引脚,数据完全随机。
然后打开ADC1的配置面板,把扫描模式设为Disable,连续转换模式设为一个很关键的选择:如果你打算在主循环里用HAL_ADC_PollForConversion配合HAL_ADC_GetValue来读取数据,那么连续转换模式可以关闭,用软件触发单次转换即可。这样每次读到的数据都是你调用读取函数那一刻的采样结果,逻辑更清晰。
采样时间这一项,我直接拉到最大值239.5周期。原因后文会展开,但这里先说结论:对于光敏电阻这种变化缓慢、信号源内阻又比较高的传感器,采样时间越长,ADC采样电容充电就越充分,结果越稳定。不需要追求高速采样,我们这个实验的物理量变化是很慢的。
ADC分辨率选12位,这是F103的ADC能提供的最大分辨率。12位意味着ADC读数的范围是0到4095,3.3V被均分成4096份,每一份对应的电压大约是0.806mV。通过这个关系,我们可以从ADC值反推出电压,再从电压反推出光敏电阻的阻值。
2.3 I2C外设的参数配置
在Connectivity栏中找到I2C1,打开后开启I2C功能。引脚会自动分配到PB8和PB9,也可以通过引脚视图手动确认。I2C的模式选择I2C,速度模式选Fast Mode,时钟频率设400000Hz。
有一点需要注意:SSD1306这类OLED模块工作时需要的电流并不大,但I2C总线的通信质量取决于上拉电阻和线路长度。大部分市售OLED模块板上已经集成了4.7K左右的上拉电阻,所以直接用杜邦线连接就能正常工作。如果屏幕出现乱码或者无响应,可以检查一下信号线是不是太长或者接触不良。
CubeMX配置完成后,在Project Manager里选择Toolchain/IDE为MDK-ARM,生成工程代码。生成后用Keil打开,接下来所有逻辑代码都在main.c和自定义的.c/.h文件中编写。
3. 核心代码实现:从ADC原始值到稳定显示
3.1 ADC采样的基础代码
CubeMX生成的代码已经帮我们初始化了ADC,但在使用前,需要在main.c中编写实际的采样函数。我习惯单独写一个adc.h和adc.c,把采样逻辑从main.c中抽离,这样代码结构更清晰。
基础的ADC读取函数非常简单:
uint16_t ADC_GetValue(void) { HAL_ADC_Start(&hadc1); // 启动一次转换 if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) // 等待转换完成,超时10ms { return (uint16_t)HAL_ADC_GetValue(&hadc1); // 读取12位转换结果 } else { return 0; } }这个函数每次调用执行一次单次转换,返回值范围是0到4095。用10ms超时是因为在72MHz主频下,ADC的一次转换周期即使开到最长的239.5周期,也不过几十微秒,10ms的等待完全足够。如果超时后返回错误,说明ADC配置可能有误,或者通道没有正确使能。
但直接把这个裸值拿去显示,会发现数字跳得很厉害。尤其在室内日光灯环境下,50Hz工频干扰会叠加在模拟信号上,导致ADC读数上下跳动几十甚至一百多。这就是为什么必须对采样值做滤波处理。
3.2 中值滤波加滑动平均的组合方案
我在这个项目里实际采用的是两级滤波:先用中值滤波去除偶发的脉冲干扰,再用滑动平均平滑输出数据。
中值滤波的思路很简单:连续采样N个值,排序后取中间那个数作为有效值。这种方法对毛刺类的随机干扰有极强的抑制作用。比如光线被手指短暂遮挡,或者电源有一瞬间的抖动,这些异常采样值不会对中值结果产生多大影响。
第一级代码可以这样写:
uint16_t ADC_GetMedianValue(void) { uint16_t buf[5]; for (int i = 0; i < 5; i++) { buf[i] = ADC_GetValue(); HAL_Delay(1); // 每次采样间隔1ms } // 冒泡排序,取中位数 for (int i = 0; i < 4; i++) { for (int j = 0; j < 4 - i; j++) { if (buf[j] > buf[j+1]) { uint16_t tmp = buf[j]; buf[j] = buf[j+1]; buf[j+1] = tmp; } } } return buf[2]; // 排序后取中间值 }排序方法这里直接用冒泡排序,虽然效率不是最高的,但只有5个数据,浪费的时钟周期完全可以忽略。
第二级用滑动平均,把最近16次中值滤波的结果存入一个环形数组,每次求平均输出:
#define FILTER_BUFFER_SIZE 16 static uint16_t filter_buf[FILTER_BUFFER_SIZE]; static uint8_t filter_index = 0; uint16_t ADC_GetFilteredValue(void) { uint32_t sum = 0; filter_buf[filter_index] = ADC_GetMedianValue(); filter_index = (filter_index + 1) % FILTER_BUFFER_SIZE; for (int i = 0; i < FILTER_BUFFER_SIZE; i++) { sum += filter_buf[i]; } return (uint16_t)(sum / FILTER_BUFFER_SIZE); }两层滤波叠加后,正常情况下最终读数非常稳定,波动范围能控制在正负5以内。屏幕上的数字不再乱跳,观感好了很多,更重要的是后续的亮度判断逻辑不会因为数据抖动而频繁误触发。
3.3 数据换算:ADC值、电压和光照的对应关系
拿到稳定的ADC原始值之后,下一步就是换算成有意义的物理量。最简单的方式是显示ADC值本身,但对使用者来说不够直观,所以我在这个项目里做了两层换算。
先把ADC原始值换算成电压值:
float ADC_GetVoltage(void) { uint16_t adc_val = ADC_GetFilteredValue(); return (float)adc_val * 3.3f / 4095.0f; }这个公式的原理很简单:12位ADC把3.3V的参考电压量化为4096个等级,所以每个ADC单位对应3.3/4095伏特。如果参考电压不是标准的3.3V,比如VREF接的是外部参考电压芯片,那这个系数就要改成实际参考电压值。
如果需要从电压反推光敏电阻阻值,可以利用分压公式反算。假设光敏电阻在电源侧、固定10K电阻在接地侧,反推公式是:
R_ldr = 10K x (3.3V - Vout) / Vout
这个阻值对于理论分析很有用,但实际显示时,我更喜欢直接显示一个“亮度百分比”的概念,就是根据ADC值在黑暗和强光两个极端值之间的位置映射出0到100的数字。
uint8_t ADC_GetLightPercent(void) { uint16_t adc_min = 80; // 黑暗环境实测值 uint16_t adc_max = 3900; // 强光环境实测值 uint16_t adc_val = ADC_GetFilteredValue(); if (adc_val <= adc_min) return 0; if (adc_val >= adc_max) return 100; return (uint8_t)((adc_val - adc_min) * 100U / (adc_max - adc_min)); }这里80和3900不是我拍脑袋随便填的,而是我实际在暗室和强光手电下分别测量的结果。每个人的环境和器件参数不同,这个阈值最好自己在测试时修改。这种线性映射虽然不算严格的物理光照强度单位Lux,但作为日常使用已经足够直观,而且能让用户一眼看出当前环境的明暗程度。
4. OLED显示驱动与界面设计
4.1 SSD1306初始化与基础显示函数
这个项目里OLED的驱动代码,我是直接基于SSD1306的标准驱动改的。这里有一个很重要的建议:如果是第一次接触OLED,不要自己从寄存器级别写初始化序列,直接用现成的、验证过的驱动库,然后在此基础上修改自己的界面显示逻辑。等整个项目跑通了,再回头研究初始化序列里每条命令的含义,效率会高很多。
SSD1306驱动核心其实就两个部分:一是初始化序列,通过I2C向控制器写入一堆配置命令;二是显存刷新,把一块128x64字节的缓冲区数据写入OLED的GRAM。SSD1306内部的RAM是1KB,每一位对应屏幕上的一个像素点,写1就亮写0就灭。
初始化时,CubeMX生成的I2C初始化已经保证了I2C外设正常,OLED驱动里通常会有这样一个发送命令的函数:
void OLED_WriteCmd(uint8_t cmd) { uint8_t buf[2] = {0x00, cmd}; // 0x00表示后续字节是命令 HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR, buf, 2, 100); }写数据类似,只是控制字节换成0x40,表示后面是显存数据。
初始化完成后,所有的绘制操作还是Hit在缓冲区上进行。比如设置光标位置、写入一个字符、绘制一条直线,最终调用一次全屏刷新函数,把整个缓冲区推送到屏幕。这样设计可以有效避免闪烁,因为你不会在屏幕上看到一个个字符逐个画出来的过程。
4.2 实验界面布局与刷新策略
显示界面的布局,我设计的是四行内容:
- 第一行显示标题:Light Sensor
- 第二行显示ADC原始值:ADC: 3075
- 第三行显示电压值:Volt: 2.48V
- 第四行显示亮度百分比:Light: 75%
刷新策略很关键。SSD1306是8页结构,每页8个像素高度,全屏刷新需要把1024字节数据全部通过I2C发送出去,在400KHz速率下大约需要20到30毫秒。如果主循环里每轮都全屏刷新,一方面占用了大量CPU时间,另一方面屏幕会以肉眼可见的频率闪烁。
我的做法是:只有固定显示内容变化时才刷新对应区域。比如ADC值的数字变了,我只更新第一行的数字区域,调用局部刷新函数,把这一行对应的页重新发一遍。对于标题这种不变的静态内容,只在初始化时绘制一次,之后不参与任何刷新。这样主循环的负担大大降低,屏幕显示也更加稳定干净。
OLED驱动里面我封装了一个简单的六号字体和十二号字体,用于显示ASCII字符。实际的显示代码大致是这样:
OLED_ShowString(8, 0, "Light Sensor", 1); OLED_ShowString(8, 2, "ADC:", 1); OLED_ShowString(8, 4, "Volt:", 1); OLED_ShowString(8, 6, "Light:", 1); OLED_Refresh();然后在主循环里更新每一行的数值:
sprintf(str, "%d ", adc_val); OLED_ShowString(48, 2, str, 1);注意数字后面加了几个空格,这是为了清除上一次显示时残留的旧数字位。OLED是点亮式的显示,不亮的像素不会自动擦除,所以显示可变数字时,必须先把旧内容覆盖掉。这是我刚开始做显示界面时长遇到的一个问题:数字从999变成1000时,屏幕上的高位数字会留下残影,后来在数字后面补空格才解决。
4.3 显示汉字与扩展思考
如果你想把界面的英文换成中文,比如显示“光照强度”四个字,事情会稍微复杂一点。SSD1306本身不会直接显示汉字,需要先把汉字做成字模,再按16x16或者12x12的点阵数据逐位绘制。
字模的做法我展开讲一下。取模软件生成的汉字字模通常是一串十六进制数组,每个字节的每一位对应一个像素点。生成时选择纵向取模还是横向取模,一定要和驱动代码的绘制逻辑保持一致。我见过很多朋友字模取出来了,但显示是乱码或者镜像翻转的样子,绝大多数都是取模方式和代码不匹配导致的。
如果工程里只需要显示有限的几个汉字,建议直接把字模数组做成常量表放在头文件里,不要挂在文件系统或者外置Flash上,方便简单。但如果界面要频繁切换不同的文本,那就需要考虑字库方案,常见做法是把汉字库放在外部EEPROM或SPI Flash中,按GB2312编码索引读取字模。
我自用的OLED驱动库里还放了一个12x12的汉字表,能覆盖温度、湿度、光照等传感器实验常用的几个字。这样换不同传感器项目时,不用重新取模,直接调用显示函数就能完成中文界面。
5. 常见问题排查与避坑经验分享
5.1 问题速查表:按照现象找原因
我把做这个项目过程中遇到的问题整理成一个速查表,方便你对着现象找原因:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| OLED完全不亮 | VCC/GND接反,I2C地址不对,接线虚接 | 检查电源极性,确认地址是0x3C还是0x3D,重新插拔杜邦线 |
| OLED亮度异常或显示残影 | 刷新方式不对,显存未完全更新 | 检查是否只更新了局部而未覆盖旧数据,数字后加空格清除残影 |
| ADC读数一直为0 | 光敏电阻分压点没接对,通道号选错 | 用万用表量分压点电压,确认CubeMX里ADC通道和实际引脚一致 |
| ADC读数一直为4095 | 分压点电压接近3.3V,可能接错到电源 | 检查接线,确认不是直接连到VCC |
| ADC读数跳动严重 | 电源纹波,信号干扰,采样时间不足 | 提高采样时间到239.5周期,加中值滤波和滑动平均 |
| I2C通信失败,HAL_I2C_Transmit返回超时 | SCL/SDA接反,上拉电阻缺失,地址错误 | 交换SCL/SDA线,确认模块板和I2C1引脚对应,核对地址 |
| 下载程序后无法再次下载 | SWD调试引脚被复用或禁用 | 按住复位键的同时点击下载,下载瞬间松手,或使用串口ISP擦除Flash |
这张表里的坑我基本都踩过一遍。特别是第一条OLED不亮,我到现在还记得第一次接OLED时的情景:模块怎么都不亮,检查了一圈发现模块上有一个0欧电阻需要焊接来选择I2C或SPI模式,出厂默认是SPI。所以购买模块时一定问清楚卖家发的这个版本是I2C还是SPI接口,引脚不同的。
5.2 提高ADC采样稳定性的三个细节
ADC的稳定性是这个项目的关键。硬件上,光敏电阻分压点出来的信号源阻抗相对较高,这会让ADC内部采样电容的充电时间变长。CubeMX里ADC的采样时间选项从1.5周期到239.5周期都有,理论上采样时间越长,内部采样电容充电越充分,转换结果越准确。
我给光敏电阻这种高内阻信号源用的是239.5周期,前面代码里也体现出来了。如果你使用的是低阻抗信号源,比如运放输出,那可以适当缩短采样时间以提升采样率。但对于这个项目,稳定优于速度,所以直接拉满。
第二个细节是电源问题。STM32F103C8T6的ADC参考电压就是VDDA,如果VDDA上有噪声,ADC结果自然会有波动。我用USB供电时发现读数跳动明显,后来把3.3V输出端加了一个10uF和一个100nF的电容,并且在光敏电阻分压点也放了一个100nF的电容到地,数据明显好很多。
第三个细节是PCB和接线的布局。杜邦线虽然方便,但长距离布线会引入天线效应,50Hz的工频噪声会耦合进来。如果项目最终要长时间运行,建议把光敏电阻和10K电阻焊在一块小洞洞板上,再用屏蔽线连接到单片机,这样数据会更稳定。
5.3 后续扩展方向:DMA采样、多点校准与串口联调
这个实验做完之后,整个学习曲线的后半段也基本打通了。如果想继续深入,我有几个明确的扩展方向推荐。
第一个方向是ADC多通道加DMA。现在只采光敏电阻一个通道,但很多场景下需要同时采集多个模拟量。把ADC配置成扫描模式,使能DMA搬运,CPU可以完全解放出来,这在做采集系统时是必须要掌握的技能。可以自己尝试增加一个电位器或者NTC热敏电阻,做成双通道采集显示实验。
第二个方向是标定和校准。前面我把亮度百分比映射成0到100,用的线性关系其实非常粗糙。光敏电阻的阻值-光照关系是对数近似的,精确换算成Lux需要用到规格书里的γ系数。你可以找一支照度计作为参考标准,记录不同光照下ADC的值,然后做多点拟合或者查表插值,这个校准方法同样适用于其他传感器。
第三个方向是加串口输出,配合上位机绘制光照变化曲线。STM32F103C8T6的USART1串口可以非常方便地输出采集数据,我常用的方式是以CSV格式发送时间戳和ADC值,然后用串口助手录制成Excel表格,在电脑上做进一步分析。串口通信在调试阶段也是利器,比如OLED上显示的数据每次只能看几行,串口可以一次打印完整的调试信息。
动手验证的几点启发
整个项目从CubeMX配置、硬件接线、代码编写到最终屏幕显示出流畅的光照数值,是一个完整的学习闭环。实际动手过一遍以后,对ADC的采样原理、I2C时序、传感器信号链路都会形成具象的认知,而不只是停留在概念层面上。
我在做这个实验时还有一个额外收获:原本以为光敏电阻模块买来直接读数字量就行,后来才发现便宜的光敏电阻模块输出的是模拟电压,真要精确判断光照强度,必须走ADC这条路。这个认知让我在做后续传感器项目时养成了一个习惯——先看模块是数字输出还是模拟输出,再决定用哪个外设去采集,拿到物料后第一件事永远是翻原理图而不是写代码。
代码方面,建议你完成基础功能后,花一点时间把滤波算法、显示接口这些独立模块抽出来做好封装。这个实验的数据处理逻辑虽然是入门级的,但模块化编程的习惯会伴随整个嵌入式开发生涯。把adc和oled这两个模块沉淀下来,下次做温湿度采集、火焰传感器检测之类的项目,只需要重写一小部分逻辑就能快速上线,这才是这个实验最值得留下的东西。