直到今天,我抽屉里还躺着一块被我换下来的ESP32开发板。当时想做一个12V电池电压监测,用分压电阻把电压压到3.3V以内,再用MicroPython直接读内置ADC,结果读数来回跳,波动能到±80mV。后来换上外置16位ADC芯片ADS1115,走I2C总线读取,同一路信号采回来稳定得让我怀疑自己是不是读错了变量。这篇笔记就是我当时边翻数据手册边调驱动,把I2C通信、驱动封装、采样触发和滤波处理全走通之后攒下来的一份MicroPython + ADS1115实战手册。
它适合谁?用MicroPython做传感器采集、觉得内置ADC不够用的开发者,或者想在ESP32、ESP8266、RP2040这类板子上稳定采集多路模拟信号的玩家。ADS1115这颗芯片很便宜,常见模块十几块钱,四路单端输入、一路差分输入,16位分辨率,八百多种采样速率可选,还能靠I2C一条总线上挂四个,拿来当通用模拟量采集前端非常合适。整篇文章不绕弯子,从硬件接线一路讲到驱动代码、触发方式和滤波算法,你跟着做基本能直接抄作业。
1. 为什么我最终还是绕不开 ADS1115:选型逻辑与芯片关键参数
1.1 ESP32 内置 ADC 不够用吗
不是不能用,是分场景。ESP32 内置 ADC 是 12 位,听起来还行,但实际有效位数通常打折扣,尤其在接近量程两端时非线性非常明显。做电池电压监测这种需要一定绝对精度的场景,你会发现同一个电压,昨天读出来 3.26V,今天换了一个供电方式变成 3.31V,数据本身就是飘的。而且 ESP32 的 ADC 参考电压在不同批次芯片之间存在差异,手册里也写得比较保守,你要是想做校准,得逐个板子标定,麻烦。
另外 ESP32 内置 ADC 的输入阻抗有限,直接接高内阻的信号源会分压,读数偏低。最典型的就是你拿一个 10k 电位器分压去测,采集端一接上去,电压就掉一截,怎么算都不对。ADS1115 的输入阻抗则高得多,对信号源的影响小,这点在测电池电压、电化学传感器这类信号时特别关键。
当然不是说内置 ADC 没用,做简单的按键检测、粗略的电量显示,它完全够。但当你需要可重复、可标定、多通道、稳定采集时,外置 ADC 是更省心的选择。
1.2 ADS1115 在看家本领上到底强在哪里
ADS1115 是德州仪器的 16 位 ΔΣ ADC,带内部基准电压源、可编程增益放大器(PGA)和 I2C 接口。几个关键特性直接决定了它能干多少活:
- 16 位分辨率,双极性输出,输出格式是二进制补码,范围 -32768 到 +32767。
- 可编程增益 PGA 共 8 档,从 ±6.144V 到 ±0.256V,小信号可以直接放大,不用外部再加运放。
- 数据速率 8 SPS 到 860 SPS 可调,低速率下内部数字滤波器能压制更多噪声。
- 四路模拟输入 AIN0 ~ AIN3,支持四路单端或者两路差分,用寄存器配置切换。
- 内置比较器,可以设置高/低阈值,超过阈值时 ALERT/RDY 引脚输出信号,适合做低功耗报警。
- 工作电压 2.0V ~ 5.5V,连续转换模式电流约 150µA,单次模式更低,电池供电也能接受。
- I2C 地址由 ADDR 引脚决定,0x48 到 0x4B 四个可选地址,一条 I2C 总线上最多挂四个。
不同 PGA 档位对应的量程和每个 LSB 代表的电压如下表:
| PGA 设置 | 量程(双极) | 1 LSB 电压 |
|---|---|---|
| 2/3 | ±6.144V | 187.5µV |
| 1 | ±4.096V | 125µV |
| 2 | ±2.048V | 62.5µV |
| 4 | ±1.024V | 31.25µV |
| 8 | ±0.512V | 15.625µV |
| 16 | ±0.256V | 7.8125µV |
注意量程标注的是双极范围,也就是说输入允许正负电压。测单端信号时更关心的是正向满量程,比如 PGA=1 时,单端输入范围实际是 0 到 4.096V。
1.3 引脚接线和最小系统
ADS1115 本身有 TSSOP 或 QFN 封装,市面上买到的大多是带引脚的小模块,模块上一般已经把上拉电阻、电源滤波电容都画好了,直接插面包板就行。但如果你是用的裸片,接线要仔细。
标准接法是这样的:
- VDD 接 3.3V,如果你的主控是 5V 逻辑,VDD 接 5V 也行,但要注意 SDA/SCL 电平匹配,最好还是让主控和 ADC 供电一致。
- GND 接 GND,注意模拟地和数字地最终要共地,信号源的地也要和 ADC 共地。
- SCL 接主控的 I2C 时钟引脚,SDA 接主控的 I2C 数据引脚。
- ADDR 接地,地址就是 0x48。如果接 VDD 是 0x49,接 SDA 是 0x4A,接 SCL 是 0x4B。
- AIN0 ~ AIN3 为模拟输入,接被测信号。
- SDA、SCL 各接一个 4.7k 上拉电阻到 3.3V。模块上一般已经有,裸片必须自己加,否则 I2C 通信不稳定甚至完全不通。
- ALERT/RDY 引脚可选,做比较器报警或转换完成指示时用。
硬件上最容易踩的坑是 SDA 和 SCL 接反、忘记共地、以及上拉电阻缺失。这三件事占了 I2C 通信失败的绝大多数原因。
2. I2C 通信的第一性原理与 MicroPython 寄存器读写
2.1 I2C 为什么不需要片选引脚
I2C 最妙的设计是省掉了片选。SPI 每个设备要一根 CS 线,设备多了引脚不够用,而 I2C 总共只要两根线:SCL 时钟线、SDA 数据线。所有设备都挂在同一条总线上,靠 7 位地址来点名通信。
它的工作原理可以类比成一条走廊里大家共用一部电话,SCL 是振铃节奏,SDA 是说话内容。总线空闲时 SDA 和 SCL 都是高电平,主机发送一个起始条件(SCL 为高时 SDA 从高变低),然后发出设备地址加读写位。地址匹配的从设备会拉低 SDA 回应一个 ACK,表示"我在,请继续"。之后每个字节的传输,SDA 数据在 SCL 高电平期间必须保持稳定,SCL 低电平期间才允许改变。通信结束时主机发送停止条件(SCL 为高时 SDA 从低变高)。
这里有个反直觉的点:地址是 7 位,比如 ADS1115 的地址 0x48,其实是 7 位的二进制 1001000,左移一位加上读写位之后才是线上传输的那个字节。你在 MicroPython 里写地址时直接写 0x48 就行,库帮你做了移位,不需要自己处理。
2.2 MicroPython 的 I2C API 和总线初始化
MicroPython 的machine.I2C把上面这些时序细节全部封装了,你只需要告诉它用哪个 GPIO 当 SCL、哪个当 SDA、频率多少。
以 ESP32 为例:
from machine import Pin, I2C i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=400000) print(i2c.scan())i2c.scan()会扫描总线上所有设备,返回一个地址列表。如果 ADS1115 地址是 0x48,这里会输出[72],因为 0x48 的十进制就是 72。
有几个细节需要注意:
freq=400000是快速模式,实测稳定。但如果你的连接线比较长、或者上拉电阻偏大,把频率降到 100000 会更稳。- ESP32-S3 上 I2C 引脚不固定,不同开发板的默认引脚可能不一样,最好先看板子丝印或者原理图确认。
- 如果你的 MicroPython 版本不支持
I2C(0, ...)这种写法,可以试试SoftI2C(scl=..., sda=..., freq=...)。 - 总线上如果同时挂了 OLED、温湿度传感器和 ADS1115,scan 会把所有地址都列出来,这是排查连线问题的最快方式。
2.3 用寄存器原语直接读写 ADS1115
ADS1115 的寄存器就那么几个,用 MicroPython 的writeto_mem和readfrom_mem就可以直接操作:
REG_CONVERSION = 0x00:转换结果寄存器,16 位,只读。REG_CONFIG = 0x01:配置寄存器,16 位,可读可写。REG_LO_THRESH = 0x02:低阈值寄存器,供比较器用。REG_HI_THRESH = 0x03:高阈值寄存器,供比较器用。
先读一次配置寄存器看看默认值:
cfg_raw = i2c.readfrom_mem(0x48, 0x01, 2) cfg = int.from_bytes(cfg_raw, 'big') print(hex(cfg))再写配置寄存器触发一次单次转换,比如设成:单次模式、PGA=±4.096V、128SPS、AIN0 对 GND、禁用比较器。对应配置值 0x8583:
i2c.writeto_mem(0x48, 0x01, (0x8583).to_bytes(2, 'big'))写完之后等一段时间,再读转换结果:
raw = i2c.readfrom_mem(0x48, 0x00, 2) value = int.from_bytes(raw, 'big') if value & 0x8000: value -= 0x10000 # 转成带符号数 print(value)这一步的带符号处理很重要。ADS1115 输出的是 16 位二进制补码,比如0xFFFF表示 -1,如果不做符号扩展会变成 65535。判断最高位是否为 1,是的话减去 65536,就得到了正确的负数。
读到的value要乘上 1 LSB 对应的电压,才是真正的电压值。PGA=±4.096V 时 LSB 是 125µV,所以voltage = value * 0.000125。
2.4 设备扫不到怎么办
用i2c.scan()后返回空列表,这是新手最常遇到的问题。按顺序排查:
- 确认 ADDR 引脚的接法,确定地址。ADDR 悬空时内部有下拉,一般默认还是 0x48,但建议明确接 GND。
- SDA 和 SCL 是不是接反了,这是最高频的失误。
- 有没有上拉电阻。模块上有,面包板上裸片必须自己加 4.7k 上拉到 VDD。
- 供电是否正常,用万用表量 VDD 和 GND 之间电压,芯片不发烫。
- 把 I2C 频率降到 100kHz 再试,线长或者干扰大时 400kHz 可能不稳定。
- 换一对 GPIO 引脚做软件 I2C,排除硬件引脚损坏或者引脚被占用的可能。
- 多设备总线冲突时,逐个断开其余设备,只留 ADS1115 再 scan。
3. 驱动代码怎么写:从裸寄存器到可复用的类
3.1 配置寄存器的 16 个 Bit 逐一拆解
ADS1115 的配置寄存器只有 16 位,但每一位都有明确含义,完全搞懂它,驱动代码就是填数游戏。位分布如下:
| Bit | 字段 | 作用 |
|---|---|---|
| 15 | OS | 单次转换启动/状态:写 1 启动一次转换,读置 1 表示转换中,读 0 表示转换完成 |
| 14-12 | MUX | 输入多路复用器选择,决定用哪一路通道 |
| 11-9 | PGA | 可编程增益放大器的增益设置 |
| 8 | MODE | 0 为连续转换模式,1 为单次模式 |
| 7-5 | DR | 数据速率选择 |
| 4 | COMP_MODE | 比较器模式选择,默认 0 |
| 3 | COMP_POL | 比较器输出极性,默认 0 |
| 2 | COMP_LAT | 比较器锁存功能,默认 0 |
| 1-0 | COMP_QUE | 比较器触发次数设置,写 11 表示禁用比较器 |
MUX 的取值对照:
| MUX 设置 | 输入通道 | 测量方式 |
|---|---|---|
| 000 | AIN0-AIN1 | 差分 |
| 001 | AIN0-AIN3 | 差分 |
| 010 | AIN1-AIN3 | 差分 |
| 011 | AIN2-AIN3 | 差分 |
| 100 | AIN0-GND | 单端 |
| 101 | AIN1-GND | 单端 |
| 110 | AIN2-GND | 单端 |
| 111 | AIN3-GND | 单端 |
PGA 和 DR 的取值在前面已经列过,这里不再重复。综合起来,一个完整的配置值就是把这些字段按位或起来。比如:单端 AIN0、PGA=4.096、单次模式、128SPS、禁比较器,就是0x4000 | 0x0200 | 0x0100 | 0x0080 | 0x0003 = 0x4583,如果要启动转换再加0x8000,得到0xC583。这个值就是给配置寄存器写入的内容。
3.2 把寄存器操作封装成类
直接用writeto_mem裸操作没问题,但工程上还是封装成类更舒服,尤其多通道、多速率切换的时候。下面这个类我实测过,可以直接复用:
from machine import I2C, Pin import time class ADS1115: REG_CONV = 0x00 REG_CONFIG = 0x01 # 通道选择 MUX_01 = 0x0000 # 差分 AIN0-AIN1 MUX_03 = 0x1000 MUX_13 = 0x2000 MUX_23 = 0x3000 MUX_0G = 0x4000 # 单端 AIN0-GND MUX_1G = 0x5000 MUX_2G = 0x6000 MUX_3G = 0x7000 # 增益 PGA_6144 = 0x0000 PGA_4096 = 0x0200 PGA_2048 = 0x0400 PGA_1024 = 0x0600 PGA_0512 = 0x0800 PGA_0256 = 0x0A00 # 数据速率 RATE_8 = 0x0000 RATE_16 = 0x0020 RATE_32 = 0x0040 RATE_64 = 0x0060 RATE_128 = 0x0080 RATE_250 = 0x00A0 RATE_475 = 0x00C0 RATE_860 = 0x00E0 MODE_CONT = 0x0000 # 连续转换 MODE_SINGLE = 0x0100 # 单次模式 COMP_DIS = 0x0003 # 禁用比较器 def __init__(self, i2c, addr=0x48, pga=PGA_4096, rate=RATE_128, mode=MODE_SINGLE, mux=MUX_0G): self.i2c = i2c self.addr = addr self._mux = mux self._pga = pga self._rate = rate self._mode = mode # 1 LSB 对应的电压 = 满量程 / 32768 full_scale = { self.PGA_6144: 6.144, self.PGA_4096: 4.096, self.PGA_2048: 2.048, self.PGA_1024: 1.024, self.PGA_0512: 0.512, self.PGA_0256: 0.256, }[pga] self._lsb = full_scale / 32768.0 self._base_cfg = mux | pga | rate | mode | self.COMP_DIS self._write_config(self._base_cfg) def _write_config(self, cfg): self.i2c.writeto_mem(self.addr, self.REG_CONFIG, cfg.to_bytes(2, 'big')) def _read_config(self): raw = self.i2c.readfrom_mem(self.addr, self.REG_CONFIG, 2) return int.from_bytes(raw, 'big') def start_conversion(self): # 单次模式下写 OS 位启动转换 self._write_config(self._base_cfg | 0x8000) def is_ready(self): # OS 位为 0 表示转换完成 return (self._read_config() & 0x8000) == 0 def read_raw(self): raw = self.i2c.readfrom_mem(self.addr, self.REG_CONV, 2) value = int.from_bytes(raw, 'big') if value & 0x8000: value -= 0x10000 return value def read_voltage(self): return self.read_raw() * self._lsb def read_single(self): self.start_conversion() while not self.is_ready(): time.sleep_ms(1) return self.read_voltage() def set_mux(self, mux): self._mux = mux self._base_cfg = mux | self._pga | self._rate | self._mode | self.COMP_DIS if self._mode == self.MODE_SINGLE: self._write_config(self._base_cfg | 0x8000) else: self._write_config(self._base_cfg) def set_rate(self, rate): self._rate = rate self._base_cfg = self._mux | self._pga | rate | self._mode | self.COMP_DIS self._write_config(self._base_cfg)使用起来非常直观:
i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=400000) adc = ADS1115(i2c) while True: print(adc.read_single()) time.sleep_ms(500)3.3 为什么 LSB 要用满量程除以 32768,而不是 65536
这是很多人第一次接触时会困惑的点。ADS1115 是双极性输出,16 位补码里,正数范围是 0 到 +32767,负数范围是 -1 到 -32768。整个量程跨了 65536 个码,但如果我们用满量程电压 4.096V 除以 32768,得到的 125µV 才是每个码代表的电压。
验证一下:正满量程 32767 乘 125µV 是 4.095875V,接近 4.096V;负满量程 -32768 乘 125µV 是 -4.096V。所以用满量程电压除以 32768 是对的。千万不要除以 65536,那样算出来电压会差一倍。
3.4 多通道采集的正确打开方式
四路单端输入时会做通道巡检。核心要点是:切换 MUX 后必须重新触发转换,然后等待转换完成再读。
def read_all_channels(adc): channels = { 'AIN0': ADS1115.MUX_0G, 'AIN1': ADS1115.MUX_1G, 'AIN2': ADS1115.MUX_2G, 'AIN3': ADS1115.MUX_3G, } result = {} for name, mux in channels.items(): adc.set_mux(mux) result[name] = adc.read_single() return result这里有个容易忽略的坑:写配置寄存器本身就是一次 I2C 操作,写入后如果立刻去读转换结果,芯片可能还没开始转换,读到的是旧值。所以要么用is_ready()轮询 OS 位,要么 sleep 到转换时间以上。128SPS 下转换周期约 7.8125ms,我习惯等 10ms,稳妥。
4. 采样触发:单次模式和连续模式到底怎么选
4.1 单次模式完整流程
单次模式的英文叫 single-shot,意思是每次只转换一次,转换完自动进入低功耗状态。流程是这样的:
- 配置寄存器里 MODE 位置 1,OS 位置 1,写入配置寄存器。
- ADC 开始转换,此时读配置寄存器,OS 位为 1。
- 转换完成,OS 位自动清 0,转换结果写入转换寄存器。
- 读转换寄存器,得到结果。
- 下一次转换需要重新写 OS 位触发,否则读到的一直是上一次的值。
代码里对应的就是start_conversion()加is_ready()轮询:
adc.start_conversion() while not adc.is_ready(): time.sleep_ms(1) voltage = adc.read_voltage()单次模式最大的优势是省电。8SPS 下,每 125ms 只工作几毫秒,其余时间芯片电流极低。这对电池供电的采集节点很重要。但是需要注意,MicroPython 在等待转换的时候如果只是time.sleep_ms,主控并没有休眠,整体功耗还要看你主控的策略。
4.2 连续模式:数据就绪判断与轮询策略
连续模式下,配置寄存器 MODE 位置 0,芯片上电或配置完成后一直循环转换,每隔 1/rate 秒刷新一次转换寄存器。读取时直接读转换寄存器就行,不用等 OS 位。
adc = ADS1115(i2c, mode=ADS1115.MODE_CONT, rate=ADS1115.RATE_128) while True: voltage = adc.read_voltage() print(voltage) time.sleep_ms(10)连续模式要注意数据同步问题。如果读的速度跟不上转换速度,你会跳着读数。比如 860SPS 下,约 1.16ms 就更新一次,MicroPython 里一次 I2C 读取加上 print 打印可能就要几十毫秒,这期间转换寄存器已经被刷新很多次了,你读到的只是某个瞬间的值。
还有一种情况是读到的值和上次完全一样,这不是芯片坏了,而是转换太快,你两次读取之间芯片已经完成了一轮转换,但结果恰好相同,或者你读寄存器时正好赶在芯片写寄存器之前。
判断新数据最可靠的方式是用 ALERT/RDY 引脚。ADS1115 可以配置成在每次转换完成时,在 ALERT/RDY 引脚上输出一个低电平脉冲。把这个引脚接到主控 GPIO,用外部中断或轮询 GPIO 判断数据就绪,就能精确采到每个点。
4.3 工程选型建议
不同场景用什么模式,我列一张表:
| 场景 | 推荐模式 | 原因 |
|---|---|---|
| 电池供电的温湿度采集节点 | 单次 | 功耗最低,每秒采一次足够 |
| 连续波形监测,如振动信号 | 连续 + ALERT/RDY 中断 | 不丢点,时序准确 |
| 多通道巡检 | 单次 + 切换 MUX | 连续模式切通道也会打断转换,不如单次灵活 |
| 超限报警 | 连续 + 比较器阈值 | ALERT/RDY 引脚硬件输出,主控可以睡觉 |
| 低速环境监测 | 单次 + 长时间睡眠 | 配合主控 deep sleep 可以把功耗做到极低 |
ADS1115 的数据速率上限是 860SPS,做音频、振动这类需要上 kHz 采样的项目不适合它,应该换 ADS1015(3.3kSPS)或者更高速的 ADC。但做温度、压力、电池电压、光照强度这些缓变信号,它的性能完全够用。
4.4 ALERT/RDY 引脚的"准中断"读法
想彻底摆脱轮询等待,可以把 ALERT/RDY 配置成转换完成指示。做法是在配置寄存器里把比较器相关位设置为特定模式,让 ALERT/RDY 引脚在每次转换完成后输出低电平脉冲,主控 GPIO 检测到下降沿时再发起 I2C 读取。这样:
- 主控不需要反复读配置寄存器查 OS 位。
- 转换完成的时刻和读取操作之间的延迟最小。
- 连续模式下不漏点。
MicroPython 里可以用Pin的外部中断来响应:
from machine import Pin rdy = Pin(25, Pin.IN, Pin.PULL_UP) def on_rdy(pin): voltage = adc.read_voltage() print(voltage) rdy.irq(trigger=Pin.IRQ_FALLING, handler=on_rdy)注意:中断回调里执行 I2C 读取是有讲究的,有些平台的 I2C 在中断上下文里可能不稳定,如果遇到问题,建议在中断里只置标志位,主循环里处理读取。
5. 滤波处理:没有好信号,后期全是白忙
5.1 芯片自带的数字滤波器:数据速率本身就是低通滤波器
ADS1115 内部有一个数字滤波器,数据速率越低,滤波效果越明显。很多人以为 860SPS 就比 8SPS 高级,实际上对慢变信号来说,8SPS 的输出往往比 860SPS 干净得多。原因是低数据速率下,芯片会对内部过采样结果做更多平均,等效带宽更窄,带外噪声被削掉更多。
所以调 ADS1115 的第一条建议是:根据信号的带宽选择数据速率。工业变送器输出、电池电压、环境温度,这些信号变化很慢,64SPS 或 128SPS 就是很好的平衡点。如果你需要 860SPS 去捕捉快速变化的信号,那就要接受它的噪声水平。
一个简单的对应关系:数据速率 128SPS,理论可测信号带宽上限是 64Hz,实际还要打折扣。对大多数传感器应用来说,这个带宽足够了。
5.2 软件滤波三种选型
信号进了主控之后,软件还可以再做一道防护。三种最常用的滤波方式:
滑动平均对随机噪声抑制效果好,实现简单,适合温度等缓变信号。缺点是会引入延迟,窗口越长延迟越大:
def moving_average(samples): return sum(samples) / len(samples)中值滤波对尖峰脉冲非常有效,比如继电器动作、电机启动产生的毛刺干扰。缺点是会丢失一些细节,对连续变化快的信号不合适:
def median_filter(samples): s = sorted(samples) return s[len(s) // 2]实际使用中,我建议中值和滑动平均结合:先取 5 个点做中值,滤掉尖峰,再对这 5 个中值结果做滑动平均,平滑输出。这个组合在工业现场能顶很大用。
一阶低通滤波是递归的,内存占用极小,实时性好,适合对每个采样点做处理:
def low_pass(new_value, last_value, alpha=0.2): return last_value + alpha * (new_value - last_value)alpha 怎么定?如果采样率是 128SPS,想要截止频率大概 10Hz,alpha 大约取 0.2。如果你更偏向平滑,就取 0.1;更跟手,就取 0.3。这个值可以根据实际波形调。
5.3 实测:50Hz 工频干扰怎么压
有次我把 ADS1115 接到一个开关电源供电的传感器输出上,读数里明显带着 50Hz 工频纹波,峰峰值大约 ±20 LSB。不算大,但做精确计量就不合格。
我当时做了两步:
用 100ms 滑动平均。50Hz 工频周期是 20ms,100ms 正好是 5 个完整周期。滑动平均窗口取工频周期的整数倍,理论上能把 50Hz 及其整数倍谐波陷波掉,效果立竿见影。如果你用的采样率是 128SPS,10 个点的滑动平均窗口约 78ms,不是 20ms 整数倍,效果就没那么理想。更好的做法是取 100ms 窗口,也就是采样率设为 100SPS 或 50SPS,让 10 个点正好覆盖 100ms,或者干脆用 20ms 的整数倍来设计窗口。
再用一阶低通把截止频率降到 10Hz 左右。工频是 50Hz,截止频率 10Hz 对它衰减超过 20dB,加上前面的滑动平均,读数基本稳定在 ±1 LSB 以内。
这两步都是软件措施,更重要的一步是硬件上把信号线的屏蔽层单独接地、在 ADC 的 VDD 和 GND 之间加一个 100nF 退耦电容、尽量缩短模拟信号线长度。软件滤波是补救,硬件设计才是根本。
5.4 过采样提升等效分辨率:4 倍采样换来 1 位精度
16 位 ADC 在低速率下已经不错了,可如果你想要更高的有效分辨率,可以用过采样技术。原理不复杂:假设输入信号叠加了随机白噪声,把 4 个连续采样点平均,噪声功率降为原来的 1/4,相当于信噪比提升 6dB,也就是有效分辨率提升 1 位。每做 4 倍过采样,等效分辨率多 1 位。
比如 128SPS 下采集 8 个点求平均,等效采样率降到 32SPS,但在满足条件的情况下有效位数大约从 16 位提升到 17 位。代码很简单:
def read_oversampled(adc, n=8): total = 0 for _ in range(n): total += adc.read_single() return total / n但有一条必须强调:过采样的前提是噪声是白噪声,且噪声幅度至少要覆盖一个 LSB。如果信号本身很干净,采样结果一直是一个固定数字,平均一万次也提不了分辨率。此时真正限制你的是系统失调和参考电压精度,要从校准和硬件上下功夫。
6. 实测校准与常见问题排查
6.1 一台万用表搞定校准
ADS1115 芯片本身精度很高,但实际测量值还会受分压电阻精度、模块上参考电压、供电电压等因素影响。尤其是某些模块上用的板载参考电压芯片,精度可能只有 1% 到 2%,这时候必须校准。
我的校准步骤很简单:
- 用一个已知电压源,或者精密稳压器输出一个标准电压,接到 AIN0。
- 用 ADS1115 读一次原始码,记为 raw。
- 用万用表实际测量这个电压,记为 v_real。
- 计算实际 LSB:
lsb_actual = v_real / raw。 - 记下这个值和理论 LSB 的偏差,后续代码里做线性修正。
更通用的是做两点校准,接两个已知电压,拟合一条直线:
def read_calibrated(adc, scale=1.0, offset=0.0): return adc.read_voltage() * scale + offsetscale和offset用最小二乘法或直接两点法算出来。校准后整体精度能提升一个数量级,尤其当你关注的是绝对电压而不是相对变化时,这一步省不了。
6.2 电源质量、共地与输入保护
ADS1115 的模拟性能和它的供电质量强相关。VDD 上的纹波会直接耦合到采样结果里,尤其是连续模式、高数据速率下更明显。给 ADC 单独供电,或者在 VDD 引脚就近加 100nF 和 10µF 电容,是成本最低且效果最明显的改善。
共地问题也很基础。信号源是外部独立供电的设备时,信号源的 GND 必须和 ADC 的 GND 连在一起,否则测量的是一个浮空电压,读数毫无意义。
输入保护方面,ADS1115 输入电压不能超过 VDD + 0.3V,也不能低于 GND - 0.3V。做工业采集时,如果信号线可能短路到 12V 或 24V,前端一定要加限流电阻和钳位二极管,否则芯片很容易烧。
6.3 常见错误与修复对照表
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
scan()返回空列表 | 接线错误 / 地址不对 / 无上拉 | 按 2.4 节逐项排查 |
| 读数固定为满量程 | PGA 档位太小,输入超量程 | 换更大的 PGA 档位 |
| 读数一直为 0 | 输入通道没选对 / 输入悬空 | 确认 MUX 设置,短接输入到 GND 测试 |
| 单次模式读数不更新 | 忘了重新触发转换 | 每次读之前调用start_conversion() |
| 多通道读数串扰 | MUX 切换后没等转换完成 | 切换后先等一个转换周期再读 |
| 读数波动大 | 电源纹波 / 信号线太长 / 接地不好 | 加退耦电容、用屏蔽线、软件滤波 |
| 高数据速率下数据跳变 | 读的速度跟不上转换速度 | 用 ALERT/RDY 引脚同步读取 |
| I2C 总线死锁 | SDA 被从设备拉低 | 复位从设备 / 检查协议时序 |
6.4 从 I2C 到成品固件的调度建议
到了这一步,你可能已经拿到了稳定数据,但的项目还在裸循环里跑。MicroPython 的能力有限,但做简单的调度还是够的。
建议用定时器或者asyncio来驱动采集,而不是在主循环里一次次sleep。原因很简单:主循环里一旦有网络操作、文件写入、LCD 刷新这类耗时操作,采样节奏就被打乱了。我常用的做法是将采集函数放进machine.Timer回调,采集结果放进一个环形队列,主循环只负责消费这个队列:
from machine import Timer sample_queue = [] def sample_cb(timer): voltage = adc.read_single() if len(sample_queue) < 100: sample_queue.append(voltage) timer = Timer(0) timer.init(period=10, mode=Timer.PERIODIC, callback=sample_cb)这样 I2C 只集中在定时器回调里执行,主循环可以做显示、上报等逻辑,不会被采集阻塞。需要注意 MicroPython 的定时器回调尽量保持短,不要在回调里做文件写入或长时间打印。
最后补一句我的实操体会。ADS1115 难的不是采集,而是"你以为你采到了真值"。我踩过的最大一个坑,是在单次模式下忘了重新触发转换,结果所有读数都是第一轮的缓存值,排查了两个小时才发现问题。拿到芯片的第一件事,不是调滤波,也不是写花里胡哨的类,而是先把"触发 → 等待 → 读取 → 再触发"这条链路跑通,确认每一个读数都是新的,然后再去做任何高级处理。这个顺序对了,后面的路就顺了。