如果你手里正好有一块ADC模块,又刚好在MicroPython环境里做数据采集,那这篇内容应该能帮你少走不少弯路。我这次整理的是ADS1115这颗16位ADC从硬件接线、I2C通信、寄存器驱动,到采样触发和滤波处理的完整实践记录。文章里所有代码我都在ESP32上实际跑过,遇到的问题也都会写在对应的小节里,方便你直接对照排查。
ADS1115是一颗四通道、16位分辨率、自带可编程增益放大器(PGA)的I2C接口ADC,量程和采样率都能软件配置,非常适合替代单片机内置ADC做高精度测量。MicroPython环境下,它的驱动逻辑不复杂,核心就是搞懂I2C读写、配置寄存器的位定义,以及如何根据信号特征选择合适的采样触发方式和滤波算法。这篇文章适合正在用ESP32、ESP8266、树莓派Pico做传感器采集、电池监测、电压测量或者任何需要稳定ADC读数的开发者,也适合刚入门I2C通信的朋友当作一份上手手册。
1. 方案选型与整体设计思路
1.1 ADS1115这颗芯片到底强在哪
先说结论:在很多场景下,MCU内置ADC被ADS1115替代,不是因为内置ADC不能用,而是因为精度和稳定性差距太明显。
拿ESP32内置的12位ADC举例,实测下来在3.3V供电环境下,采样电池电压或电位器分压时,即使信号很稳定,转换结果仍然会有一两个LSB的跳动,而且ESP32的ADC还受衰减档位、参考电压温漂和引脚串扰影响,在低电压区间存在比较明显的非线性。而ADS1115是16位、内置PGA、内部参考电压的精密ADC,在低速采样模式下有效分辨率能到15到16位,拿来测电压、电流传感器输出、称重传感器信号这类慢变信号,读数稳定很多。
ADS1115另一个实用特性是PGA可编程增益。板载PGA支持±6.144V、±4.096V、±2.048V、±1.024V、±0.512V、±0.256V六档量程。这意味着小信号不需要额外搭运放放大,直接配置寄存器把量程调低,精度就能提上来。比如测0到1V的信号,用±1.024V档位,LSB只有31.25微伏;如果傻乎乎用±6.144V档位,LSB是187.5微伏,精度差距六倍。这个特性对传感器信号采集特别关键。
再加上ADS1115的I2C接口只占两根线,数据速率最高支持3.4MHz(实际我一般跑100k到400k),地址可以通过ADDR引脚配置A0到A3四个地址,一条I2C总线上最多挂四个。低功耗模式待机电流不到1微安,非常适合电池供电的数据采集节点。
1.2 为什么选MicroPython而不是C
选MicroPython主要图两件事:开发效率和交互调试体验。
ESP32跑MicroPython,固件烧好后直接进REPL,可以一行一行测试I2C扫描、看寄存器返回值,不用每次都走编译烧录流程。我在开发采集模块的时候,先用MicroPython脚本验证硬件和寄存器配置,确认逻辑没问题,再决定要不要把同样算法移植到C或者Arduino里。MicroPython虽然运行速度不如C,但ADC采样本身由ADS1115芯片完成,MicroPython只是负责定时发起转换和读取结果。128SPS的采样率下,就算用Python写滤波算法,性能也完全够用。
MicroPython的machine库把I2C操作封装得很简洁。扫描线路上有哪些I2C设备,只需执行一句i2c.scan(),就能拿到所有七位地址。对新手来说,这种交互式排查方式比直接写固件然后上逻辑分析仪友好得多。
1.3 从传感器到数据的完整链路
ADS1115是一个ΔΣ(德尔塔-西格玛)型ADC,它的内部架构决定了采样率和噪声特性。信号从AIN引脚进入后,先经过输入多路复用器(MUX)选择通道,再进PGA放大器,然后被ΔΣ调制器转换成高频率的1比特流,最后经过片内数字抽取滤波器输出16位结果。这个数字抽取滤波器本质上是低通滤波,采样率配置越低,抽取倍数越高,有效分辨率越高。
所以整个信号链可以画成:模拟输入 → MUX选择 → PGA增益 → ΔΣ调制 → 数字抽取滤波 → I2C寄存器 → MCU软件处理。理解了这条链路,后面很多决策就清楚了:PGA决定量程和分辨率,数据速率(DR)决定噪声和响应速度,I2C寄存器负责数据传输,软件滤波负责处理残余噪声。这就是整篇内容的技术骨架。
2. I2C通信基础与硬件接线
2.1 I2C协议速览:地址、时序与上拉电阻
I2C是Philips(现在的NXP)发明的两线制串行总线,只有两根信号线:SCL(时钟)和SDA(数据)。设备分主设备和从设备,ADS1115永远是从设备,ESP32作为主设备发起通信。I2C的特点是所有设备共享两条线,靠地址区分设备,因此所有设备的SDA和SCL都必须工作在开漏模式,通过上拉电阻把线路拉到高电平。
一个完整的I2C传输包含起始条件(SCL高电平期间SDA拉低)、地址字节(7位地址加1位读写标志)、从设备ACK应答、数据字节、停止条件(SCL高电平期间SDA拉高)。地址字节中,最高位MSB先发送。ADS1115的七位地址由ADDR引脚电平决定。
上拉电阻阻值也值得说。常见做法是4.7kΩ上拉到VDD,如果总线上的设备数量和线缆长度增加,可以适当减小到2.2kΩ。上拉电阻过大会导致信号上升沿太慢,影响高速通信;过小则会增加功耗和灌电流。MicroPython初始化I2C时,freq=100000就是100kHz标准模式,freq=400000是快速模式。ADS1115在400kHz下完全没问题,但如果你同时挂了其他I2C传感器,建议先用100kHz排查稳定性,再提频率。
2.2 硬件接线与ADDR地址配置
接线其实就四根线,但很多人第一次就栽在这里。看我的接线表:
| ADS1115引脚 | 连接到 |
|---|---|
| VDD | 3.3V(也可以接5V,芯片支持2.0~5.5V) |
| GND | 电源地 |
| SCL | MCU的SCL引脚 |
| SDA | MCU的SDA引脚 |
| ADDR | GND、VDD、SDA或SCL,决定I2C地址 |
很多人忽略ADDR引脚,模块默认贴片焊盘可能已经把它接GND了,所以地址一般是0x48。完整地址对照如下:
| ADDR引脚连接到 | 七位I2C地址(十六进制) |
|---|---|
| GND | 0x48 |
| VDD | 0x49 |
| SDA | 0x4A |
| SCL | 0x4B |
如果一条总线上要接两个ADS1115,注意SDA和SCL不能同时作为ADDR电平来源,会冲突,一般第二片把ADDR接VDD,地址设为0x49。
接线时还要注意,ADS1115的模拟输入范围不能超过VDD + 0.3V。如果测量对象电压接近或超过供电电压,必须先做分压,否则芯片会损坏。另外,输入信号如果是双极性(比如交流信号),必须使用差分输入模式,而且负输入不能低于GND过多,否则ADC内部ESD保护二极管会导通。
2.3 MicroPython中I2C的初始化与地址扫描
MicroPython里初始化I2C有两种方式,一个是machine.I2C,这是硬件I2C外设,由芯片内部硬件电路控制时序,速度更快,不占用CPU。另一个是SoftI2C,也就是用GPIO模拟时序,可以结合任意两个引脚使用。我推荐优先使用硬件I2C,因为时序稳定,不容易出错。
ESP32开发板上,不同板卡的默认I2C引脚可能不一样。常见的是SCL=22、SDA=21,但ESP32-S3的板子有些默认是SCL=8、SDA=9,甚至有些合宙的板子引脚定义都不一样。所以别照抄别人的程序,先跑一下扫描脚本确认实际引脚。
from machine import Pin, I2C # 根据你的开发板实际情况修改引脚号 i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=100000) devices = i2c.scan() if devices: for addr in devices: print("I2C设备地址:", hex(addr)) else: print("未扫描到任何I2C设备")如果这里返回0x48,说明硬件链路没问题。返回空列表,重点检查四根线、上拉电阻和ADDR引脚电平。这个脚本是最基础的诊断工具,我建议遇到任何I2C问题,第一步都先跑一遍。
2.4 硬件I2C与软件I2C如何选
machine.I2C对应硬件外设,支持中断、时钟拉伸等底层特性,速率精度高。SoftI2C通过GPIO模拟时序,优点是可以指定任意两个GPIO,缺点是时序精度依赖CPU负载,如果主循环里有大量重计算,软件I2C的时序可能被拉长,虽然不会出错,但实时性会变差。
我实际使用中还有个体验:软件I2C在某些涉及高频GPIO翻转的场合,和中断服务程序共用引脚时容易偶发时序错乱,而硬件I2C基本没遇到过。所以如果引脚够用,建议直接用machine.I2C。只有在硬件I2C外设被其他设备如OLED占用,而且你不想软件切换总线的时候,才考虑用SoftI2C跑ADS1115。
3. ADS1115寄存器级驱动实现
3.1 寄存器地图与字节序
ADS1115一共有四个寄存器,都是16位宽度。I2C读写在芯片内部按大端字节序传输,也就是高字节在前,Motorola字节序。四个寄存器如下:
| 寄存器 | 地址 | 读写属性 | 作用 |
|---|---|---|---|
| Conversion | 0x00 | 只读 | 存储最新ADC转换结果,16位有符号数 |
| Config | 0x01 | 读写 | 配置MUX、PGA、模式、数据速率等 |
| Lo_thresh | 0x02 | 读写 | 低阈值,用于比较器 |
| Hi_thresh | 0x03 | 读写 | 高阈值 |
日常使用中,核心操作集中在Conversion和Config两个寄存器。转换结果寄存器读出的是16位有符号数,表示当前输入相对满量程的比例。Lo_thresh和Hi_thresh只有用到比较器功能时才需要设置。
因为MicroPython的int.from_bytes(data, 'big')默认返回无符号整数,所以读取转换结果后需要判断符号位。如果结果大于32767,说明是负数,需要减去65536做符号扩展。这是新手最容易漏掉的一步。
3.2 Config寄存器逐位拆解:OS、MUX、PGA、MODE、DR
Config寄存器是ADS1115的控制中枢,16个位各管一件事。逐个解释比背手册有用。
最高位OS(Bit15)是操作状态/启动转换位。在单次模式下,往这个位写1会启动一次转换,转换完成后硬件自动将该位清零。在连续模式下,OS位读出总是0。所以单次模式下轮询这个位,就能知道转换是否结束。
Bit14到Bit12是MUX输入多路选择字段,决定哪个引脚作为正负输入。最常用的是000,即AIN0对GND单端输入。差分模式下,100表示AIN0和AIN1差分,101表示AIN0和AIN3差分,110表示AIN1和AIN3差分。
Bit11到Bit9是PGA增益字段,决定满量程电压范围。这里给一个常用配置值:
| PGA字段 | 满量程FSR | 对应LSB电压(@16位) |
|---|---|---|
| 000 | ±6.144V | 187.5µV |
| 001 | ±4.096V | 125µV |
| 010 | ±2.048V | 62.5µV |
| 011 | ±1.024V | 31.25µV |
| 100 | ±0.512V | 15.625µV |
| 101/110/111 | ±0.256V | 7.8125µV |
Bit8是MODE位,1表示单次模式,0表示连续模式。这个位容易记混,我刚用的时候也把1当成连续模式,结果程序一直不更新数据,排查半天。
Bit7到Bit5是数据速率(DR)字段,决定ADC每秒转换次数。它同时影响内部数字滤波器的截止频率和有效分辨率。低速率的平均效果更好,噪声更低,响应也更慢。
3.3 单次转换的完整驱动代码
单次转换流程很清晰:写Config触发一次转换,等待转换完成,读取Conversion寄存器。这种模式适合电池供电的场合,因为ADC在两次转换之间自动进入低功耗状态。
from machine import Pin, I2C import time ADS1115_ADDR = 0x48 REG_CONVERSION = 0x00 REG_CONFIG = 0x01 i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=400000) # AIN0对GND,PGA=±4.096V,单次模式,128SPS # 0x0200 = 0b0000 0010 0000 0000 # Bit14-12: 000,MUX=AIN0-GND # Bit11-9: 001,PGA=±4.096V # Bit8: 1,单次模式 # Bit7-5: 100,128SPS config = 0x0200 | (1 << 8) | (0b100 << 5) # 写配置寄存器,OS位 = 1 启动转换 config |= 0x8000 i2c.writeto_mem(ADS1115_ADDR, REG_CONFIG, config.to_bytes(2, 'big')) # 等待转换完成:轮询OS位 while True: cur = i2c.readfrom_mem(ADS1115_ADDR, REG_CONFIG, 2) cur_value = int.from_bytes(cur, 'big') # OS位为1表示转换完成 if cur_value & 0x8000: break time.sleep_ms(5) # 读取转换结果 data = i2c.readfrom_mem(ADS1115_ADDR, REG_CONVERSION, 2) raw = int.from_bytes(data, 'big') if raw > 32767: raw -= 65536 # 计算实际电压,FSR取4.096V voltage = raw * 4.096 / 32768.0 print("原始值:", raw, "电压:", voltage, "V")注意这段代码里,我先把0x0200赋给config,然后单独写0x8000来置OS位。为什么不用一次写0x8200?其实两者效果相同,但拆开写更直观,方便你理解OS位的作用。
等待转换完成有两种方式,轮询OS位和固定延时。固定延时可以用time.sleep_ms(1000 // rate)估算,但轮询OS位更精确,既不会太早读数,也不会空等太久。128SPS下,一次转换大约需要7.8ms,轮询间隔5ms循环顶多两次就出结果。
3.4 连续模式与多通道轮询
单次模式适合低功耗场景,但如果你要连续监控电压变化,每次等轮询就有点浪费时间。连续模式下,ADS1115按照DR字段自动连续转换,主设备随时可以读取最新数据。
# AIN0对GND,PGA=±4.096V,连续模式,250SPS # 这里把MODE位(Bit8)设为0 config = 0x0200 | (0 << 8) | (0b101 << 5) i2c.writeto_mem(ADS1115_ADDR, REG_CONFIG, config.to_bytes(2, 'big')) while True: data = i2c.readfrom_mem(ADS1115_ADDR, REG_CONVERSION, 2) raw = int.from_bytes(data, 'big') if raw > 32767: raw -= 65536 voltage = raw * 4.096 / 32768.0 print("V:", voltage) time.sleep_ms(100)连续模式启用的同时,传感器输入引脚可能存在多路复用的问题。如果要读取AIN0、AIN1、AIN2、AIN3四个通道,连续模式下切换MUX字段后,需要等待至少一个转换周期让新通道的转换结果稳定,否则读到的可能是上一通道的残余数据。
多通道采集更常见的做法是单次模式逐个通道采样:先切MUX到通道0,启动转换,读取;再切MUX到通道1,启动转换,读取。这样逻辑清晰,也不会串通道。缺点是整体吞吐率降低,四个通道轮询一遍的时间大约是单通道的四倍。
3.5 更工程化的驱动类封装
写了很多次ADC逻辑后发现,裸代码虽然易懂,但每次都要算一遍raw * FSR / 32768.0,而且增益字段和量程的映射关系经常记错。所以我建议封装一个简单的驱动类,把寄存器位的计算放到一个地方,调用处只传电压值习惯的接口。
class ADS1115: def __init__(self, i2c, addr=0x48): self.i2c = i2c self.addr = addr self.gain = 1 # 默认PGA=1,对应±4.096V def set_gain(self, gain): """gain: 0~6,对应±6.144V 到 ±0.256V""" self.gain = gain def read_adc(self, channel=0): """单次读取指定通道电压,channel: 0~3""" fsr_map = [6.144, 4.096, 2.048, 1.024, 0.512, 0.256, 0.256] fsr = fsr_map[self.gain] mux = 0x4000 if channel == 0 else 0x5000 | (channel << 12) # 这里只是示意,实际MUX字段映射见我上面表格 config = 0x8000 | (1 << 8) | (self.gain << 9) | (0b100 << 5) self.i2c.writeto_mem(self.addr, 0x01, config.to_bytes(2, 'big')) while True: cur = int.from_bytes(self.i2c.readfrom_mem(self.addr, 0x01, 2), 'big') if cur & 0x8000: break raw = int.from_bytes(self.i2c.readfrom_mem(self.addr, 0x00, 2), 'big') if raw > 32767: raw -= 65536 return raw * fsr / 32768.0封装类的核心价值是把寄存器映射集中起来。后续加滤波、加定时采样,只需要在类外面组合即可,不破坏核心读取逻辑。
4. 采样触发设计:单次、定时中断与ALERT引脚
4.1 采样触发为什么值得单独设计
很多人做ADC采样,主循环里直接while True: read_adc(),表面看也能跑,但仔细想有两个问题。第一,读取频率完全取决于主循环执行速度,如果主循环里还有显示刷新、文件写入、网络请求,采样间隔会忽长忽短,对于需要固定采样间隔的频谱分析或波形重建就是灾难。第二,如果使用连续模式,不断更新的数据会被不规律地读取,无法保证与真实信号的时间关联性。
ADS1115没有外部硬件触发引脚,它的采样触发完全靠I2C寄存器操作和外部MCU的定时配合。所以设计采样触发时,要先明确需求:是低速定点采样(每秒读一次温度),还是中速连续波形采集(250SPS记录电压曲线),还是事件驱动(只在有转换完成信号时才去读)。三种需求对应三种不同触发器设计。
4.2 单次模式:软件触发写OS位
单次模式本身就是一个软触发机制。通过把OS位置1,芯片被唤醒并完成一次转换,转换完成后自动断电。这种模式天然适合低功耗采集节点。代码层面就是在第3.3节里那样,每次启动转换前重新写一次Config寄存器。
这种触发方式的好处是采样时刻可控,主设备说什么时候采就什么时候采。缺点是等待转换完成这段时间MCU不能干别的,除非你采用轮询加延时的方式,否则纯while循环会阻塞主程序。所以单次模式一般配合定时器中断来用。
4.3 定时器中断实现等间隔采样
如果希望采样间隔严格均匀,MicroPython的定时器是首选。以ESP32为例,用machine.Timer创建周期定时器,中断回调里只设置一个标志位,读取和滤波放到主循环里做。
from machine import Timer sample_ready = False def timer_cb(t): global sample_ready sample_ready = True tim = Timer(0) tim.init(period=100, mode=Timer.PERIODIC, callback=timer_cb) while True: if sample_ready: sample_ready = False # 这里读取ADS1115、滤波、显示等耗时操作 v = adc1115.read_voltage() process(v)注意这里的关键设计:回调函数里不要直接执行I2C读取。因为MicroPython的定时器回调运行在中断上下文,I2C操作涉及时钟时序和等待应答,在中断里做会阻塞其他中断,严重时还会导致系统崩溃。正确做法是中断回调里只置标志位,主循环检测到标志后再执行耗时的I2C操作。这样虽然定时精度会受主循环耗时的轻微影响,但对绝大多数传感器采集场景已经足够。
如果系统需要更高精度的固定间隔采样,可以考虑用DMA方式或者直接在中断里做I2C读取。但这需要更底层的开发,MicroPython环境通常不建议,我也没有在实际项目中采用过。
4.4 用ALERT/RDY引脚做硬件触发
ADS1115的ALERT/RDY引脚是个容易被忽略的功能。默认情况下它作为比较器输出使用,但通过配置寄存器,可以让它在每次转换完成时输出一个短暂的低脉冲。把这个引脚接到MCU的外部中断输入,就能实现真正的转换完成事件驱动读取,MCU完全不用轮询等待。
配置方式是把Config寄存器的COMP_QUE字段(Bit1-0)设为00,即禁用比较器,此时ALERT/RDY脚会变为数据就绪输出。每次转换完成,它拉低一个周期,然后恢复高电平。外部中断设置为下降沿触发,在中断回调里读取转换结果。
from machine import Pin aler_pin = Pin(4, Pin.IN, Pin.PULL_UP) def alert_isr(pin): # 快速读取ADS1115,只做数据保存 data = i2c.readfrom_mem(0x48, 0x00, 2) raw = int.from_bytes(data, 'big') if raw > 32767: raw -= 65536 # 将结果放入队列或环形缓冲 buffer.append(raw) aler_pin.irq(trigger=Pin.IRQ_FALLING, handler=alert_isr)中断回调里执行I2C读取虽然和定时器中断的建议相悖,但区别在于这个回调只做一次短暂的读取操作,并且ADS1115的转换结果在ALERT引脚拉低时已经稳定,不会等待时钟拉伸太久。实测在100kHz I2C频率下,读两个字节大约耗时几十微秒,在ESP32上可以接受。但我仍然建议只做数据入队,真正的滤波和显示放主循环。
三种触发方式对比:
| 触发方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 单次模式软触发 | 功耗低,实现简单 | 阻塞等待,需要轮询 | 低功耗定点采集 |
| 定时器中断触发 | 采样间隔可控 | 定时器回调需轻量化 | 周期性数据采集 |
| ALERT/RDY事件触发 | 省CPU,事件驱动 | 需要额外接线和中断支持 | 高速连续采样或低延迟响应 |
5. 滤波处理:硬件降噪与软件算法
5.1 ADC噪声从哪里来
ADS1115毕竟是精密ADC,但噪声不可能完全消失。噪声来源主要有几类:一是电源纹波,如果VDD由MCU的3.3V LDO直接提供,而MCU又在驱动WiFi或LED,电源上会叠加周期性纹波。二是参考电压噪声,ADS1115内部参考电压已经做得不错,但外部电源质量仍会影响实际精度。三是输入引脚的电磁干扰,长导线像天线一样接收环境噪声。四是ΔΣ转换器固有的量化噪声和抖动。
解决噪声的思路是从源头到软件处理做多层过滤。硬件上尽可能减少电源纹波、合理布局布线;软件上根据信号特征选择滤波算法。这就像音频系统先做好电源和屏蔽,再用EQ调音,而不是靠后期拉高音量掩盖底噪。
5.2 利用ADS1115自身特性降噪:PGA与DR的配合
很多人开着默认的128SPS就采集,其实DR选多少直接影响噪声性能。ADS1115的数据手册里给出不同DR下的有效分辨率表格,DR越低,内部数字抽取滤波器的平均次数越多,输出噪声越小。8SPS时有效分辨率可以超过16位,而860SPS时有效分辨率大约只有14位。所以如果信号本身变化很慢,比如温度、电池电压、液位,用8SPS或16SPS就能获得更稳定的读数。
PGA档位的选择同样影响噪声。输入信号接近满量程时,量化噪声占比越小,信噪比越高。比如测量0到100mV的微弱信号,用±0.256V档位,信号可以占满约40%的ADC量程,远比用±6.144V档位得到的信号幅度大得多。所以选择PGA的原则是让目标信号尽量接近满量程,但不能超过满量程,否则输出会饱和。
给电路输入端加一个简单的RC低通滤波器也是性价比很高的方案。比如在AIN引脚串联1kΩ电阻,对地并联100nF电容,构成约1.6kHz的低通截止频率,能有效滤除高频噪声。注意R值不宜过大,ADS1115的输入阻抗虽然高,但输入电流和电阻相乘会引入误差。
5.3 软件滤波算法实战:滑动平均、中值滤波与一阶低通
软件滤波最常用的三种:滑动平均、中值滤波、一阶低通。下面给出可直接使用的MicroPython实现。
滑动平均是N个点取平均,能平滑随机噪声,但会引入滞后。窗口越大越平滑,滞后越大。
class MovingAverage: def __init__(self, size=8): self.size = size self.buf = [] def add(self, value): self.buf.append(value) if len(self.buf) > self.size: self.buf.pop(0) return sum(self.buf) / len(self.buf)中值滤波是对窗口内数据排序取中间值,对尖峰脉冲干扰非常有效,适合剔除传感器偶发毛刺。
def median_filter(values): tmp = sorted(values) return tmp[len(tmp) // 2]一阶低通滤波是递推算法,公式是y = alpha * x + (1 - alpha) * y_prev。alpha越小滤波越强,但响应越慢。这个滤波器实现简单,内存占用小,适合噪声比较均匀的场景。
class LowPassFilter: def __init__(self, alpha=0.2): self.alpha = alpha self.prev = None def add(self, value): if self.prev is None: self.prev = value else: self.prev = self.alpha * value + (1 - self.alpha) * self.prev return self.prev实际使用中,我倾向于把滑动平均和一阶低通组合使用。ADC原始数据先过中值滤掉毛刺,再进滑动平均或低通平滑,效果比单一滤波器好很多。代价是延迟增加,不适合快速动态响应,但对大部分传感采集没影响。
5.4 滤波参数怎么选:结合采样率、信号频率与噪声特征
滤波参数没有万能答案,要根据信号和采样率来定。其中一个重要原则是滤波器截止频率要高于目标信号最高频率,同时低于噪声频率。比如采样率128SPS,信号本身是0.5Hz的慢变电压,那滑动平均窗口可以设16甚至32,因为信号周期只有0.5Hz,窗口32个点覆盖0.25秒,对信号不至于过度平滑。而如果信号是10Hz的振动波形,128SPS下每个周期只有12.8个采样点,滑动平均窗口就不能超过4,否则波形被压扁。
| 信号特征 | 推荐滤波方式 | 参数建议 |
|---|---|---|
| 直流慢信号(温度、电压) | 滑动平均 + 低通 | 窗口16-32,alpha 0.1~0.2 |
| 低频动态波形(1-10Hz) | 滑动平均 | 窗口4-8 |
| 有尖峰脉冲干扰 | 中值滤波 | 窗口3-5 |
| 均匀随机噪声 | 一阶低通 | alpha 0.2~0.4 |
如果你是刚开始调,我建议先把原始数据的波动范围和噪声幅度打出来,再决定滤波手段。不要上来就写高级滤波器,先看噪声是白噪声还是脉冲,脉冲用中值,白噪声用平均。这是最实用的经验。
6. 常见问题排查与实战心得
6.1 地址扫描不到设备:先查线再查地址
扫描不到设备是最常见的问题。我的排查顺序是:第一,确认VDD和GND有电,万用表量一下ADS1115的VDD引脚电压。第二,确认SCL和SDA没有接反,这是新手最容易犯的错误。第三,确认ADDR引脚电平是否和扫描地址一致,如果ADDR悬空,有些模块内部有弱下拉会表现为0x48,但如果悬空状态受干扰,地址也可能不稳定,最好明确接GND或VDD。第四,检查总线上拉电阻是否正常,部分模块需要自己外接4.7kΩ上拉。
如果这些都没问题,可以降低I2C频率试试,我遇到过某些模块在400kHz下不稳定,降到100kHz就正常了。
6.2 读数恒为0或电压值明显偏低
读数恒为0,先看通道配置是否对。特别是用连续模式的时候,如果Config寄存器里MUX字段写错,实际读取的通道和接线不是同一个,自然读不到信号。另一个可能原因是输入信号为负电压而模式是单端,单端模式无法处理负压,输出会钳位到0。如果输入信号超过PGA量程,输出会饱和到0x7FFF,不会显示0,但换算出来的电压会比实际偏低,比如2.5V信号跑到±2.048V档位,就会显示2.048V满量程而不是实际电压。
检查PGA档位是否匹配信号范围,这是偏低问题的关键。尽量先降低PGA增益(增大量程)测量真实信号幅度,再选择合适的档位。
6.3 噪声大、数据跳变:从硬件滤波和DR入手
数据跳动的排查顺序是从硬件到软件。先看VDD电源,如果MCU驱动WiFi或电机,电源纹波会直接串到ADS1115的输入。可以在VDD引脚加一个10µF电解电容和100nF陶瓷电容去耦。再看输入信号线,尽量用屏蔽线或双绞线,避免长距离平行走线。然后把DR调低,比如从250SPS降到16SPS,有效分辨率立刻提升,噪声肉眼可见减少。最后才用软件滤波。
6.4 I2C总线挂死:怎么恢复和预防
MicroPython在调试时经常遇到I2C总线挂死,表现是sda.scan()返回空,或者程序卡在某次读写上。原因常常是某个从设备在通信中途掉线,或者主设备的I2C状态机没复位。解决办法是先让总线复位:把SCL引脚连续输出9个时钟脉冲,很多挂在半途的从设备会被唤醒并释放SDA。在MicroPython里可以手动操作GPIO模拟这个脉冲,或者直接把MCU复位,最省事。
预防挂死的方法是:确保所有I2C设备的VDD先上电再通信,不要在设备没上电的时候发数据;把I2C频率保持在稳定的范围内;不要在设备通信中途插拔线路。另外,如果总线上挂了多个设备,ADS1115地址冲突也会导致通信异常,先用扫描脚本确认所有地址都唯一。
6.5 我个人在实际操作中的体会
最后分享几个零散但实用的经验。第一,千万别在主循环里无条件一直读ADS1115连续模式的数据,至少加个短暂的延时或定时器控制采样节奏,否则MCU可能被I2C读取占满,导致WiFi和定时任务卡顿。第二,用MicroPython做ADC数据采集时,建议给每个采集结果加上时间戳,使用time.ticks_us()获取微秒级时间,后续做曲线分析和数据对比会方便很多。第三,如果你后续想把这套方案扩展成多通道数据记录仪,可以把ADS1115读取逻辑和滤波逻辑彻底分离,读取类只管拿数据,滤波类只做信号处理,这样加OLED显示、SD卡存储都不会互相纠缠。
这套组合我已经在好几个项目里用了,最近还把它扩展成了ESP32-S3加OLED的迷你电压记录仪,实时画曲线并写入CSV文件,核心逻辑仍然是这里介绍的寄存器操作和滤波框架。ADS1115这颗芯片加MicroPython,做中低速精密采样是成本最低、见效最快的方案,值得动手试一次。