
1. 从光敏电阻到数字世界ADC采集实验到底在做什么很多刚上手STM32的朋友第一次看到ADC采集这四个字脑子里冒出来的往往是示波器上那条上下跳动的曲线或者实验室里那种带一堆旋钮的台式万用表。其实说白了ADC采集就是让单片机学会读刻度——现实世界里的光、热、压力、声音全都是连续变化的模拟量而单片机只认0和1。中间这座桥就是ADC模数转换器。这次要做的光敏传感器实验本质上是一条完整的信号链路光敏电阻感知环境光强把光强变化转换成电阻变化再通过一个分压电路把电阻变化转换成电压变化STM32内部的ADC外设把这个电压采回来变成0到4095之间的一个数字最后通过I2C接口把数值送到OLED屏幕上实时显示。整条链路走通你就掌握了嵌入式开发里最经典的一类传感器应用范式。为什么选光敏传感器作为入门因为它足够直观。你拿手电筒照一下屏幕上的数字立刻往上跳用手遮住数字马上掉下来。这种所见即所得的反馈比串口打印一堆十六进制数要友好得多。而且光敏电阻便宜、好买、电路简单不需要额外的信号调理芯片特别适合刚接触ADC的开发者建立信心。这篇文章适合谁看如果你已经点亮过LED、跑通过串口但对ADC还停留在知道有这么个东西的阶段那这篇就是为你准备的。我会从电路原理讲到寄存器配置从HAL库函数讲到实际调试中遇到的坑最后把I2C显示也串起来。全程不跳步每个参数都告诉你为什么这么设。提示本文基于STM32F103系列展开其他系列如F4、G0、L4的ADC架构略有差异但核心逻辑相通我会在关键处标注区别。2. 光敏电阻分压电路模拟前端的每一个电阻都有讲究2.1 光敏电阻的工作特性与选型光敏电阻的核心材料通常是硫化镉CdS它的阻值随光照增强而减小。暗环境下阻值可以到1MΩ以上强光下可能降到几百欧姆。这个变化范围非常大跨越了三到四个数量级。很多新手拿到光敏电阻直接往电路里一接发现ADC读数要么满量程要么接近零就是因为没有处理好这个宽动态范围。选型的时候要注意两个参数亮电阻和暗电阻。常见的GL5528亮电阻10lux光照下大约在10kΩ到20kΩ之间暗电阻无光照在1MΩ左右。这个参数决定了你分压电阻该选多大。如果你选的分压电阻是1kΩ那暗环境下分压点电压几乎全落在光敏电阻上ADC读到接近满量程强光下光敏电阻降到10kΩ分压点电压又变得很低。整个动态范围被压缩在很小的区间里分辨率浪费了。2.2 分压电阻的计算与选择分压电路的结构很简单光敏电阻和固定电阻串联接在VCC和GND之间中间节点接到ADC输入引脚。假设光敏电阻接在VCC侧固定电阻接在GND侧那么ADC读到的电压是V_adc VCC × R_fixed / (R_photocell R_fixed)为了让ADC读数在光照变化时跨度最大固定电阻的阻值应该选在光敏电阻变化范围的几何平均值附近。GL5528的亮电阻约10kΩ暗电阻约1MΩ几何平均值是√(10k×1M) ≈ 100kΩ。所以选100kΩ的固定电阻能让ADC读数在暗环境和强光下都有不错的分辨率。但实际测试中我发现100kΩ在高光照下读数变化不够灵敏因为此时光敏电阻阻值远小于100kΩ分压点电压趋近于VCC变化量很小。如果你更关注室内光照范围100lux到1000lux光敏电阻阻值大概在几kΩ到几十kΩ之间这时候选10kΩ的固定电阻反而更合适。所以固定电阻的选择取决于你的应用场景没有绝对的最优值。固定电阻阻值暗环境ADC读数室内光ADC读数强光ADC读数适用场景10kΩ接近40952000-3000500-1000室内光照检测47kΩ接近40953000-35001000-2000通用场景100kΩ接近40953500-38002000-3000宽范围检测2.3 滤波电容与抗干扰设计分压点直接接到ADC引脚中间最好加一个0.1μF的陶瓷电容到地。这个电容的作用是低通滤波把电源噪声和空间耦合的高频干扰滤掉。我实测过不加这个电容的时候ADC读数末位会跳动十几到几十个LSB加上之后跳动范围能压到3到5个LSB以内。另外光敏电阻的引线尽量短远离电机、继电器这类感性负载。如果实在避不开可以考虑用屏蔽线或者在ADC输入端加一个RC低通滤波截止频率设在几十赫兹就够了因为光照变化本身是很慢的信号。注意分压电阻的精度直接影响测量一致性。普通碳膜电阻误差5%批次之间差异明显。如果要做多通道一致性要求高的项目建议用1%精度的金属膜电阻。3. STM32的ADC外设配置从时钟树到采样时间的完整推导3.1 ADC时钟源与分频系数STM32F103的ADC时钟来自APB2总线最高不能超过14MHz。系统主频72MHz时APB2通常是72MHz所以ADC预分频系数至少要设为6分频得到12MHz。这个参数在CubeMX里叫ADC Prescaler在标准库函数里通过RCC_ADCCLKConfig()设置。为什么不能超过14MHz因为ADC内部的逐次逼近寄存器需要稳定的时钟来完成比较和移位操作时钟太快会导致转换结果不准确。我见过有人为了追求采样率把分频设成4结果读数跳得厉害还以为是传感器问题其实是ADC时钟超限了。3.2 采样时间与信号源阻抗的关系采样时间是ADC配置里最容易被忽视的参数。STM32的ADC前端有一个采样保持电容采样阶段这个电容通过外部信号源阻抗充电。如果采样时间太短电容还没充到信号电压就开始转换了结果必然偏低。采样时间的选择和信号源阻抗直接相关。数据手册里有一个公式T_sample ≥ (R_source R_adc) × C_adc × ln(2^12)其中R_adc约1kΩC_adc约8pF。对于光敏分压电路信号源阻抗是光敏电阻和固定电阻的并联值最大可能到几十kΩ。代入公式算一下采样时间至少需要几微秒。STM32F103提供的最长采样时间是239.5个ADC时钟周期在12MHz时钟下约20μs足够覆盖大多数传感器应用。我一般直接选最长的239.5周期除非对采样率有硬性要求。因为光敏信号变化慢采样率根本不需要很高用最长采样时间换取稳定性是划算的。3.3 单次转换与连续转换模式的选择STM32的ADC支持单次转换、连续转换、扫描等多种模式。对于光敏传感器这种单通道应用单次转换模式就够了需要读数的时候启动一次转换转换完成中断里读结果然后等下一次需要的时候再启动。连续转换模式适合需要高速采样的场景比如音频信号采集。但连续转换会一直占用ADC资源功耗也更高。如果你的项目是电池供电单次转换加定时器触发的方案更省电。配置的时候还有一个细节ADC的校准。上电后先执行一次ADC校准把内部电容的偏差补偿掉。HAL库提供了HAL_ADCEx_Calibration_Start()函数标准库是ADC_ResetCalibration()和ADC_StartCalibration()。不校准的话不同芯片之间的读数可能差几十个LSB。// ADC初始化关键代码HAL库 hadc1.Instance ADC1; hadc1.Init.ScanConvMode ADC_SCAN_DISABLE; hadc1.Init.ContinuousConvMode DISABLE; hadc1.Init.DiscontinuousConvMode DISABLE; hadc1.Init.ExternalTrigConv ADC_SOFTWARE_START; hadc1.Init.DataAlign ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion 1; HAL_ADC_Init(hadc1); // 配置通道 sConfig.Channel ADC_CHANNEL_0; sConfig.Rank ADC_REGULAR_RANK_1; sConfig.SamplingTime ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_ConfigChannel(hadc1, sConfig); // 校准 HAL_ADCEx_Calibration_Start(hadc1);4. 从原始值到光照强度数据处理与标定方法4.1 原始ADC值的读取与滤波ADC读出来的是0到4095的整数对应0到3.3V的电压。但直接读一次就用数据会跳。我一般做两次处理先做多次采样取平均再做滑动滤波。多次采样取平均很简单连续读16次累加后除以16。这样能把随机噪声压下去代价是16次转换的时间。在239.5周期采样时间下一次转换约21μs16次约336μs对于光照检测完全够用。滑动滤波是维护一个长度为N的数组每次新数据进来就替换最旧的数据然后求平均。N一般取8或16。这样既有滤波效果又不会引入太大延迟。我试过N32发现光照突变时屏幕数字要过好一会儿才稳定体验不好后来改成N8就平衡了。#define FILTER_SIZE 8 uint16_t filter_buf[FILTER_SIZE]; uint8_t filter_idx 0; uint16_t get_filtered_adc(void) { uint32_t sum 0; filter_buf[filter_idx] read_adc_once(); filter_idx (filter_idx 1) % FILTER_SIZE; for (int i 0; i FILTER_SIZE; i) { sum filter_buf[i]; } return sum / FILTER_SIZE; }4.2 电压换算与光照强度的非线性映射ADC值转电压很简单V ADC值 × 3.3 / 4095。但电压转光照强度就没那么简单了因为光敏电阻的阻值和光照强度之间是对数关系不是线性的。严格来说光敏电阻的阻值R和光照强度L的关系是R A × L^(-γ)其中γ在0.7到0.9之间。要精确换算成lux值需要查表或者做对数运算。但对于大多数应用我们不需要精确的lux值只需要一个相对值来表示亮和暗的程度。我的做法是把ADC值映射到0到100的等级0表示最暗100表示最亮。映射的时候用实测的暗环境ADC值和强光ADC值作为端点做线性插值。虽然中间不是完全线性但作为相对指示足够了。光照条件典型ADC值映射等级实际用途完全黑暗4000-40950-5夜间检测弱光3000-40005-30黄昏判断室内照明1500-300030-70自动调光强光直射500-150070-95日光检测手电筒直照0-50095-100过曝保护4.3 阈值判断与迟滞比较做自动控制的时候经常需要判断天黑了就开灯。最简单的做法是设一个阈值ADC值高于阈值就开灯低于就关灯。但这样有个问题如果ADC值刚好在阈值附近波动灯会频繁开关继电器咔咔响。解决办法是加迟滞。比如开灯阈值设为3000关灯阈值设为2500。ADC值超过3000才开灯低于2500才关灯中间2500到3000之间保持当前状态。这样即使有几十个LSB的波动也不会导致频繁切换。迟滞窗口的大小取决于你的信号噪声水平。我一般设500个LSB左右对应约0.4V的电压差。如果噪声特别大可以适当加大。5. I2C OLED显示把ADC数据实时呈现出来5.1 I2C硬件连接与上拉电阻OLED屏幕用I2C接口只需要两根线SCL和SDA。但这两根线是开漏输出必须接上拉电阻才能输出高电平。很多OLED模块自带上拉电阻但阻值通常是10kΩ在高速模式下可能上升沿不够陡。如果通信不稳定可以把上拉电阻换成4.7kΩ试试。STM32的I2C引脚要配置成复用开漏模式。以F103为例I2C1的SCL是PB6SDA是PB7。配置的时候注意GPIO模式设为GPIO_Mode_AF_OD速度设为GPIO_Speed_50MHz。I2C的时钟频率建议设在100kHz到400kHz之间。OLED刷新率要求不高100kHz足够。设太高反而容易受干扰特别是杜邦线连接的时候。5.2 OLED驱动移植与显示刷新策略市面上常见的0.96寸OLED驱动芯片多是SSD1306。驱动代码网上很多移植的时候注意几个点I2C地址通常是0x78写地址有些模块是0x7A初始化命令序列要匹配你的屏幕分辨率128×64和128×32的初始化不一样。显示刷新策略上不要每次ADC采样都刷屏。OLED刷新一次要传1KB的数据在100kHz I2C下需要约100ms。如果ADC每10ms采一次刷屏根本跟不上。我的做法是ADC每100ms采一次OLED每500ms刷一次这样屏幕数字变化流畅又不闪烁。显示内容我一般放三行第一行显示原始ADC值第二行显示电压值第三行显示光照等级。这样调试的时候一眼就能看出问题出在哪一环。// OLED显示ADC数据的简化代码 void display_adc_data(uint16_t adc_val) { char buf[20]; float voltage adc_val * 3.3f / 4095.0f; uint8_t level map_adc_to_level(adc_val); OLED_Clear(); sprintf(buf, ADC: %4d, adc_val); OLED_ShowString(0, 0, (uint8_t*)buf); sprintf(buf, Volt: %.2fV, voltage); OLED_ShowString(0, 2, (uint8_t*)buf); sprintf(buf, Level: %3d%%, level); OLED_ShowString(0, 4, (uint8_t*)buf); OLED_Refresh(); }5.3 实际调试中遇到的I2C通信问题调试I2C的时候最常见的问题是屏幕不亮或者显示乱码。我总结了几种情况第一种屏幕完全不亮。先查电源3.3V有没有到位再查I2C地址对不对用逻辑分析仪抓一下波形看有没有ACK回应。如果地址不对整个通信从一开始就失败了。第二种屏幕亮但显示乱码。这通常是初始化序列不对或者I2C速率太高导致数据错位。把速率降到100kHz试试如果好了就是速率问题。第三种显示内容偶尔跳变。这是I2C通信受干扰了检查上拉电阻和走线尽量远离电源线和电机。还有一种情况是屏幕用着用着就卡死需要重新上电。这可能是I2C总线锁死了SCL被从机拉低不放。解决办法是在初始化的时候发9个时钟脉冲强制从机释放总线。6. 完整工程搭建与实测验证6.1 工程文件结构与模块划分一个清晰的工程结构能让后续维护省很多事。我一般这样组织main.c主循环调度各个任务adc.c / adc.hADC初始化、采样、滤波oled.c / oled.hOLED驱动、显示刷新sensor.c / sensor.h光敏数据处理、等级映射delay.c / delay.h延时函数模块之间通过头文件暴露接口避免在main.c里直接操作寄存器。这样以后换传感器或者换屏幕只需要改对应模块不用动主逻辑。主循环的调度策略是用SysTick做时间基准每100ms采一次ADC每500ms刷一次OLED。不用操作系统简单的标志位轮询就够了。6.2 实测数据与曲线分析我在室内环境下做了一组实测用手遮住传感器ADC读数稳定在4050左右正常室内光照读数在2200到2800之间波动用手机手电筒直照读数降到300左右。整个动态范围约3750个LSB分辨率足够。响应时间方面从暗到亮ADC读数在200ms内达到稳定值的90%从亮到暗因为光敏电阻的暗电阻恢复较慢需要约500ms。这个响应速度对于光照检测应用完全够用但如果要做光通信或者快速光强变化检测光敏电阻就不合适了得换光电二极管。线性度方面在中间段ADC 1000到3000线性还不错两端明显非线性。所以如果要做精确测量建议分段标定或者直接用查表法。6.3 常见问题排查清单现象可能原因排查方法ADC读数始终为0通道配置错误检查ADC_CHANNEL_x是否对应实际引脚ADC读数始终为4095引脚悬空或接错万用表量分压点电压读数跳动大未加滤波电容分压点对地加0.1μF电容读数偏差大未校准ADC上电后执行校准函数OLED不亮I2C地址错误用逻辑分析仪抓波形OLED显示乱码初始化序列不匹配确认屏幕驱动芯片型号屏幕偶尔卡死I2C总线锁死初始化时发9个时钟脉冲提示调试ADC的时候先用万用表量分压点电压和ADC读数换算出的电压对比。如果差得多问题在ADC配置如果一致问题在传感器电路。7. 从这次实验延伸出去的几个方向光敏传感器加ADC加OLED这条链路走通之后其实可以玩出很多花样。比如把阈值判断和继电器结合起来做一个光控开关把数据通过串口传到上位机画光照曲线或者加一个RTC记录一天中光照的变化规律。如果想提高精度可以把光敏电阻换成BH1750这类数字光照传感器直接输出lux值省去标定的麻烦。但那样就绕过了ADC少了一次学习机会。我的建议是先用光敏电阻把ADC的各个环节都摸一遍理解了采样时间、参考电压、分辨率这些概念之后再换数字传感器就是降维打击。另外STM32的ADC还支持DMA传输。多通道采集的时候用DMA把转换结果自动搬到内存CPU不用干预效率高很多。这次单通道实验没用到DMA但如果你要同时采集光敏、热敏、电位器好几个模拟量DMA就是必备技能了。我在实际项目中还遇到过一个情况ADC读数受MCU自身功耗影响。当CPU全速运行的时候电源纹波变大ADC读数末位跳动明显增加。后来在ADC采样前把CPU降频或者采样时让CPU进入低功耗模式读数就稳多了。这个细节在数据手册里不会写但实际调试中很常见。最后说一个我踩过的坑有一次调了半天ADC读数就是不对换了芯片、换了电路都没用。最后发现是杜邦线接触不良分压点电压时有时无。所以调试的时候先怀疑连线再怀疑代码能省很多时间。