YAOTU INSIGHTS

STM32多通道ADC与I2C OLED驱动:温度电压电流检测报警器仿真设计

STM32多通道ADC与I2C OLED驱动:温度电压电流检测报警器仿真设计
1. 从一块OLED屏说起这个项目到底在做什么如果你手头正好有一块STM32最小系统板、一个OLED屏、几个传感器想做一个能同时盯着温度、电压、电流三个指标超限就报警的小装置那这个项目就是为你准备的。它不复杂但麻雀虽小五脏俱全——涉及模拟量采集、ADC多通道扫描、I2C驱动OLED、阈值判断与报警输出还要在仿真环境里把整套逻辑跑通。很多人第一次接触STM32的ADC和I2C就是通过类似的小项目做完之后对单片机怎么跟真实世界打交道这件事会有完全不一样的理解。先说清楚这个项目最终呈现的形态系统上电后STM32周期性采集三路模拟信号——一路来自温度传感器另外两路分别对应电压和电流的采样电路。采集到的原始值经过换算变成有物理意义的数值实时刷新到OLED屏幕上。同时程序里预设了三个指标各自的安全范围一旦某个值越界蜂鸣器响、LED闪屏幕上对应的数值也会做出视觉提示。整个逻辑在Proteus之类的仿真软件里先跑通确认无误后再落到实物上。为什么值得用仿真先做一遍因为这类项目涉及模拟前端实物调试时如果电路有问题你很难判断到底是代码写错了还是硬件接错了。仿真环境里电路是理想的可以先把软件逻辑彻底验证清楚再去对付真实世界里的噪声、压降和接触不良。这个思路我在后面会反复提到它是做这类混合信号项目最省时间的路径。关键词里提到的温度电压电流检测OLED显示报警器仿真设计其实对应了四个相对独立又必须串起来的技术模块。接下来我会按实际动手的顺序把每个模块拆开讲透包括那些教程里通常不会写、但你不注意就会卡半天的细节。2. 三路模拟量采集ADC配置里藏着最多坑2.1 为什么温度、电压、电流要用不同的采集策略表面上看三个量都是模拟信号接上ADC采就完了。但实际做的时候你会发现这三路的信号特性差别很大处理方式也必须区别对待。温度这一路如果你用的是LM35这类模拟温度传感器它的输出是线性的每摄氏度对应10mV0度时输出0V。这个信号变化缓慢对采样速率要求极低但要求分辨率够——因为10mV对应1度如果ADC的参考电压是3.3V、12位分辨率那么一个LSB大约是0.8mV理论上能分辨0.08度完全够用。问题在于LM35的输出电压范围很小常温下也就几百毫伏直接采的话有效位数浪费严重。我一般会在软件里做一次滑动平均把噪声压下去等效提高精度。电压检测这一路通常是对一个较高的直流电压做分压比如测12V电源用两个电阻分压到ADC能接受的范围。这里的关键是分压电阻的选值阻值太大ADC采样时的输入阻抗会影响精度阻值太小又会有持续的功耗。我一般取上电阻10k、下电阻2k左右把12V分到2V上下既在量程内又有足够的裕量。分压比的计算必须精确因为后面换算回真实电压全靠这个比例。电流检测相对麻烦一些。常见方案是串一个采样电阻测电阻两端的压降再根据欧姆定律算电流。采样电阻的选值是个权衡阻值大压降大、信噪比好但功耗和压降损失也大阻值小则相反。比如测1A以内的电流用0.1欧的采样电阻满量程压降才0.1V这个信号直接给ADC就太小了通常需要加一级运放放大。仿真里可以理想化处理但实物上这一步不能省。2.2 STM32的ADC多通道扫描该怎么配STM32的ADC支持多通道扫描模式配合DMA可以实现三路信号自动轮流采集、结果自动搬到内存CPU完全不用干预。这个配置是项目里最值得花时间搞明白的部分。以常见的STM32F103为例ADC1有多个外部通道假设温度接在PA0通道0、电压接在PA1通道1、电流接在PA2通道2。配置的大致流程是使能ADC1和DMA1的时钟配置PA0-PA2为模拟输入模式设置ADC为独立模式、扫描模式使能、连续转换模式使能设置规则组的通道数和每个通道的采样顺序最后配置DMA把ADC的DR寄存器搬到内存数组使能DMA和ADC。这里有几个容易翻车的点。第一模拟输入引脚必须配置成GPIO_Mode_AIN如果误配成浮空输入或上拉输入采出来的值会莫名其妙地飘。第二采样时间要设够STM32的ADC采样时间可调信号源内阻越大需要的采样时间越长我一般统一设成55.5个周期稳妥。第三DMA的缓冲区大小要跟通道数一致且要保证DMA在ADC之前使能否则第一次转换可能丢数据。// ADC多通道DMA配置核心片段基于标准外设库 #define ADC_CH_NUM 3 uint16_t adc_buf[ADC_CH_NUM]; void ADC_DMA_Init(void) { // 时钟、GPIO配置略 DMA_InitTypeDef dma; dma.DMA_PeripheralBaseAddr (uint32_t)ADC1-DR; dma.DMA_MemoryBaseAddr (uint32_t)adc_buf; dma.DMA_DIR DMA_DIR_PeripheralSRC; dma.DMA_BufferSize ADC_CH_NUM; dma.DMA_PeripheralInc DMA_PeripheralInc_Disable; dma.DMA_MemoryInc DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; dma.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; dma.DMA_Mode DMA_Mode_Circular; DMA_Init(DMA1_Channel1, dma); DMA_Cmd(DMA1_Channel1, ENABLE); ADC_InitTypeDef adc; adc.ADC_Mode ADC_Mode_Independent; adc.ADC_ScanConvMode ENABLE; adc.ADC_ContinuousConvMode ENABLE; adc.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; adc.ADC_DataAlign ADC_DataAlign_Right; adc.ADC_NbrOfChannel ADC_CH_NUM; ADC_Init(ADC1, adc); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 3, ADC_SampleTime_55Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); ADC_SoftwareStartConvCmd(ADC1, ENABLE); }这段代码跑起来之后adc_buf[0]、adc_buf[1]、adc_buf[2]就会自动循环更新分别对应三个通道的最新值。你只需要在主循环里读这个数组就行不用管转换什么时候完成。2.3 从原始值到物理量的换算别在这里偷懒采到的是0到4095之间的整数要变成温度、电压、电流必须做换算。这一步很多人随手写个系数就完事结果数值总是不对回头查半天。正确的做法是先把ADC值转成引脚电压V_pin adc_value * 3.3f / 4095.0f。注意这里用4095而不是4096因为12位ADC满量程对应的是4095。然后根据各路的电路关系反推物理量。温度如果是LM35温度 V_pin * 100.0f因为10mV对应1度1V对应100度。电压如果是分压实际电压 V_pin * (R_up R_down) / R_down。电流如果是采样电阻加分压实际电流 V_pin / 放大倍数 / 采样电阻。这些系数最好在代码里用宏定义写清楚别直接写魔法数字。我见过有人把分压比算错一位测12V显示成1.2V查了一下午才发现是电阻值抄错了。仿真里电阻值是你自己设的更要仔细核对。3. OLED显示驱动I2C通信的时序与显存管理3.1 为什么OLED比LCD更适合这个小项目这个项目里显示的内容不多——三行数值加几个状态标识但要求实时刷新、体积小、接线简单。OLED在这几点上完胜传统的1602 LCD。1602需要至少6根线OLED用I2C只需要两根1602只能显示字符OLED可以自由画点、画线、显示任意字体OLED自发光视角和对比度都好得多。常见的0.96寸OLED模块用的是SSD1306驱动芯片分辨率128x64I2C接口。它的显存是一块128x64位的区域分成8页每页8行共1024字节。你往显存里写什么屏幕上就显示什么。理解这个显存结构是驱动它的关键。3.2 软件I2C还是硬件I2C这是个问题STM32有硬件I2C外设但用过的人都知道某些型号的硬件I2C有已知的稳定性问题调试起来很折磨。对于OLED这种对速率要求不高的场景我强烈建议用软件模拟I2C——就是自己控制两个GPIO口的电平来产生I2C时序。软件I2C的好处是完全可控时序清晰不会被外设的奇怪状态卡住。代价是占用CPU时间但OLED刷新频率撑死几十赫兹这点开销完全可以接受。实现上需要写几个基本函数起始条件、停止条件、发送一个字节、接收应答。SCL和SDA的拉高拉低之间要加短延时具体延时多久取决于你的主频一般几个微秒就够。// 软件I2C基本时序简化示意 void I2C_Start(void) { SDA_H; SCL_H; delay_us(2); SDA_L; delay_us(2); SCL_L; delay_us(2); } void I2C_WriteByte(uint8_t data) { for(uint8_t i 0; i 8; i) { if(data 0x80) SDA_H; else SDA_L; data 1; delay_us(2); SCL_H; delay_us(2); SCL_L; delay_us(2); } // 读应答 SDA_H; delay_us(2); SCL_H; delay_us(2); // 忽略应答位 SCL_L; delay_us(2); }SSD1306的I2C从机地址通常是0x78写或0x7A读具体看模块上的电阻配置。发数据前要先发一个控制字节0x00表示后面跟的是命令0x40表示后面跟的是显存数据。这个细节如果搞错屏幕要么不亮要么显示乱码。3.3 显存怎么组织字符怎么显示SSD1306的显存按页组织页0对应屏幕最上面8行页1对应接下来8行以此类推。每个字节的8个位对应一列中的8个像素低位在上。比如你要在左上角画一个点就是往页0的第一个字节的最低位写1。显示字符需要字模。常用的做法是用取模软件生成ASCII字符的点阵数据比如8x16的字体每个字符占16个字节。显示时根据字符的ASCII码从字模数组里取出对应数据逐字节写入显存。写入前要先设置好页地址和列地址SSD1306支持自动地址递增连续写就行。我一般会封装几个函数OLED_Clear()清屏、OLED_ShowChar()显示单个字符、OLED_ShowString()显示字符串、OLED_ShowNum()显示数字。显示数字时要注意位数对齐比如温度显示25.3如果实际是5.3就要在前面补空格否则数字会跳动看起来很不专业。刷新策略上不要每次只更新变化的部分——那样逻辑复杂还容易出错。简单粗暴的做法是在内存里维护一个完整的显存缓冲区所有绘制操作先改缓冲区然后一次性把整个缓冲区刷到OLED。这样屏幕不会闪烁代码也好写。代价是每次刷新要传1024字节软件I2C下大概几毫秒完全可以接受。4. 报警逻辑与阈值管理不只是if-else那么简单4.1 报警的触发条件该怎么定报警逻辑听起来简单——超过阈值就响。但实际做的时候要考虑几个问题阈值是固定的还是可调的报警后是持续响还是间歇响多个指标同时越界怎么处理恢复正常后怎么解除报警对于这个小项目我建议阈值用宏定义在代码里方便修改。三个指标各设上下限比如温度上限60度、下限0度电压上限14V、下限10V电流上限1.5A。判断逻辑放在主循环里每次采集完新数据就检查一遍。报警输出用蜂鸣器加LED。蜂鸣器接一个GPIO高电平响LED接另一个GPIO报警时闪烁。闪烁的实现不要用delay阻塞主循环而是用计数器——主循环每转一圈计数器加一达到某个值就翻转LED状态。这样主循环始终在跑采集和显示不受影响。// 非阻塞报警指示 static uint16_t led_tick 0; static uint8_t led_state 0; void Alarm_Handler(void) { uint8_t alarm 0; if(temp TEMP_MAX || temp TEMP_MIN) alarm 1; if(volt VOLT_MAX || volt VOLT_MIN) alarm 1; if(curr CURR_MAX || curr CURR_MIN) alarm 1; if(alarm) { BUZZER_ON; led_tick; if(led_tick 500) { led_tick 0; led_state !led_state; if(led_state) LED_ON; else LED_OFF; } } else { BUZZER_OFF; LED_OFF; led_tick 0; led_state 0; } }4.2 阈值判断里的迟滞设计直接比较有个问题如果测量值正好在阈值附近波动报警会频繁地触发和解除蜂鸣器滴滴答答响个不停很烦人。解决办法是加迟滞——报警触发后要等测量值回到阈值以内一定范围才解除。比如温度上限设60度迟滞设2度。超过60度触发报警之后要降到58度以下才解除。这样即使温度在60度附近小幅波动报警状态也是稳定的。迟滞量根据信号的噪声水平来定噪声大就设大一点。这个技巧在工业控制里很常见但很多教程里不讲导致新手做出来的报警器太灵敏反而不好用。我在第一次做类似项目时就踩过这个坑后来加了迟滞才解决。4.3 仿真环境里怎么验证报警逻辑在Proteus里仿真时你可以用信号发生器或者电位器来模拟传感器输出手动调节输入值观察屏幕显示和报警输出是否按预期变化。重点验证几个边界情况刚好在阈值上、刚超过阈值、从超限状态回到正常、多个指标同时超限。仿真里还可以用虚拟终端或者示波器观察GPIO的输出波形确认蜂鸣器和LED的控制信号正确。这些在实物上也能测但仿真里更方便不用接一堆探头。需要注意的是仿真里的传感器模型是理想的没有噪声和延迟。所以仿真通过不代表实物一定没问题但仿真不通过实物肯定有问题。仿真主要用来验证逻辑正确性实物调试才是解决信号质量问题的阶段。5. 仿真环境搭建Proteus里的电路与联调5.1 元件选型与电路连接在Proteus里搭建这个项目的电路需要以下元件STM32F103系列单片机选一个具体型号比如STM32F103R6、OLED显示屏找SSD1306的模型没有的话可以用12864图形液晶代替但驱动代码要改、电位器若干模拟传感器输出、蜂鸣器、LED和限流电阻。温度传感器可以用电位器分压来模拟因为仿真里没有真实的温度变化。电压和电流同理用可调电阻分压产生0到3.3V之间的信号接到ADC引脚。这样你转动电位器就能改变测量值方便测试。OLED模型如果找不到I2C接口的可以用并口的12864代替但那样就失去了练习I2C的意义。我建议尽量找SSD1306的仿真模型实在没有就先用串口打印数据来验证采集和报警逻辑显示部分留到实物上做。5.2 仿真与实物的差异心里要有数仿真跑通之后转到实物通常会遇到几个问题。第一ADC采到的值比仿真里跳得多因为实物有噪声。解决办法是加软件滤波最简单的就是连续采多次取平均或者用中值滤波。第二OLED可能不亮先检查I2C地址对不对、上拉电阻有没有、供电是否正常。第三蜂鸣器可能不响检查是三极管驱动还是直接GPIO驱动前者要注意基极电阻。我在实物调试时还遇到过一个情况OLED显示正常但一开蜂鸣器屏幕就花。原因是蜂鸣器电流大拉低了电源电压导致OLED复位。解决办法是给蜂鸣器单独供电或者在电源上加足够的滤波电容。这种问题仿真里永远遇不到但实物上很常见。5.3 代码结构的组织建议整个项目的代码建议分成几个模块adc.c负责采集和换算oled.c负责显示驱动alarm.c负责报警逻辑main.c负责调度。每个模块提供清晰的接口函数主循环里依次调用。这样代码可读性好调试时也容易定位问题。主循环的节奏要控制好。太快了没必要还增加功耗太慢了显示会卡顿。我一般设成100ms左右一轮采集、换算、判断报警、刷新显示。这个周期对温度这种慢变量绰绰有余对电压电流的监测也够用。如果后续想扩展功能比如加串口输出、加数据记录、加多级报警模块化的结构会让扩展容易很多。这也是我建议一开始就把代码分模块写的原因——多花十分钟整理结构后面省几个小时的重构时间。6. 几个容易翻车的细节与我的实操心得6.1 ADC参考电压的选择与稳定性STM32的ADC参考电压默认是VDDA也就是3.3V。但这个3.3V如果来自普通的LDO精度可能只有百分之几直接导致测量误差。如果对精度有要求要么用外部基准芯片提供精确的参考电压要么在软件里做校准——用万用表测出实际的VDDA把真实值写进代码里参与换算。仿真里VDDA是理想的3.3V所以换算系数直接用3.3就行。但实物上如果你发现测出来的值总是偏一点先别怀疑代码拿万用表量一下VDDA实际是多少。我遇到过一块板子VDDA只有3.15V导致所有测量值都偏低约5%校准之后就准了。6.2 OLED的初始化序列不能少SSD1306上电后需要一系列初始化命令才能正常工作包括设置对比度、显示模式、扫描方向、时钟分频等。这些命令的顺序和参数在数据手册里有但手册很长很多人懒得看直接抄别人的初始化代码。抄可以但要注意不同厂家的模块可能默认扫描方向不同导致显示上下颠倒或左右镜像。如果显示方向不对调整0xA0/0xA1和0xC0/0xC8这两个命令即可。初始化里还有一个容易忽略的是显示开启命令0xAF没有它屏幕永远是黑的。我见过有人调了半天以为屏幕坏了结果只是忘了发这个命令。6.3 报警输出的驱动能力STM32的GPIO输出电流有限一般单个引脚不超过20mA。蜂鸣器如果是无源的工作电流可能超过这个值直接接GPIO可能驱动不起来或者长期工作损坏引脚。稳妥的做法是用三极管驱动GPIO只提供基极电流。LED也要串限流电阻一般1k到2k根据亮度和电流要求调整。仿真里这些都不是问题因为仿真模型不关心电流限制。但实物上必须考虑否则可能烧引脚或者蜂鸣器声音很小。这个坑我在早期项目中踩过蜂鸣器直接接GPIO声音小得几乎听不见加了三极管之后才正常。6.4 数值显示的格式与刷新显示数值时小数点后的位数要固定否则数字会跳动。比如温度显示一位小数电压显示两位电流显示两位。显示前先做四舍五入再格式化成字符串。不要直接显示浮点数因为浮点转字符串在单片机上开销大而且可能显示出一长串无意义的小数位。刷新时如果每次都清屏再重画屏幕会闪。用显存缓冲区的方式可以避免这个问题。如果嫌1024字节的缓冲区占内存也可以只刷新变化的部分但逻辑会复杂一些。对于这个项目1024字节的缓冲区完全负担得起STM32F103有20K RAM用5%都不到。6.5 仿真文件与代码的版本管理做这类项目会产生很多文件Proteus工程、Keil工程、各种源文件、字模数据。建议用一个文件夹统一管理按功能分子文件夹。每次修改前备份一下尤其是仿真电路图改坏了想恢复很麻烦。我习惯用日期加简短说明来命名备份比如backup_0512_alarm_fixed虽然土但管用。代码里那些可调参数——阈值、迟滞量、换算系数、刷新周期——集中放在一个头文件里用宏定义。这样调参时不用满工程找改一个地方就行。这个习惯在项目变复杂之后会越来越有价值。