ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32F103ADC采集光敏电阻OLED显示实战教程

STM32F103ADC采集光敏电阻OLED显示实战教程 最近在折腾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内核单片机主频最高72MHzFlash有64KBRAM有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是接地侧固定电阻我选的10KR_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.3VOLED GND - GNDOLED SCL - PB8I2C1_SCLOLED SDA - PB9I2C1_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到40953.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[j1]) { uint16_t tmp buf[j]; buf[j] buf[j1]; buf[j1] 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可能接错到电源检查接线确认不是直接连到VCCADC读数跳动严重电源纹波信号干扰采样时间不足提高采样时间到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这两个模块沉淀下来下次做温湿度采集、火焰传感器检测之类的项目只需要重写一小部分逻辑就能快速上线这才是这个实验最值得留下的东西。
返回列表