
有阵子给 ESP32-S3 做一个小型环境数据记录仪需要同时采集两路温度传感器电流环和一路电池电压。ESP32 自带的 ADC 虽然能用但 12 位分辨率加上参考电压不够准读数飘得让人心里没底。换 ADS1115 之后情况完全不一样16 位分辨率、内置可编程增益放大器、4 个单端输入或 2 组差分输入挂在 I2C 总线上就能读。这篇就围绕 MicroPython ADS1115 这套组合把 I2C 通信、寄存器配置、驱动实现、采样触发和滤波处理讲透内容全部以我实际改过的代码和踩过的坑为基础适合正在用 MicroPython 做模拟量采集的嵌入式开发者和创客参考。1. ADS1115 硬件连接选型理由、引脚与地址跳线1.1 为什么外接 ADS1115 而不是直接用内置 ADC很多玩 MicroPython 的朋友第一反应是用 MCU 自带 ADC在 ESP32 上就是 GPIO 支持的那几个通道。这个方案对演示项目够用但真要拿去做多点采集或者稍微讲究一点精度就会暴露问题。ESP32 内置 ADC 标称 12 位实际有效位数经常只有 9~10 位而且在不同增益档位和衰减设置下线性度不一致相邻通道之间还有串扰采集结果受温度影响也明显。ADS1115 吸引我的地方是几个很实在的特性。第一是 16 位分辨率虽然最后有效位达不到 16 位整但比 MCU 内置 ADC 高出一个量级。第二是内置 PGA也就是可编程增益放大器可以测量 ±0.256V 到 ±6.144V 的多个量程小信号可以直接放大再采样。第三是 4 个输入通道同时支持单端和差分模式在很多工业传感器场景下非常灵活。第四是 I2C 接口只占用两根线就能扩展一条总线上最多可以挂 8 个 ADS1115地址通过 ADDR 引脚组合出来。1.2 引脚连接与常见模块差异ADS1115 的引脚不算多但每次画原理图或者接线之前我都会重新确认一遍特别是 ADDR 和 ALERT/RDY 这两个新手特别容易忽略。引脚作用接线建议VDD电源正极接 3.3V注意不要超过 5.5VGND电源地与 MCU 共地不要悬空SCLI2C 时钟线接 MCU 的 SCL 引脚需要上拉电阻SDAI2C 数据线接 MCU 的 SDA 引脚需要上拉电阻ADDR地址选择接 GND/SDA/SCL/VDD决定 I2C 地址ALERT/RDY比较器输出 / 转换完成指示不需要时悬空或接一个 GPIO 做事件检测接线时有个容易被坑的点是模块电平转换。市面上很多 ADS1115 模块是 5V 供电设计板上带了稳压和电平转换SDA/SCL 可以直接接 3.3V 的 MCU。但也有些模块是裸片设计没有电平转换5V 上拉会把 3.3V 的 GPIO 打坏。我自己的习惯是统一用 3.3V 给 ADS1115 供电I2C 上拉电阻也接到 3.3V这样最稳妥不需要担心电平兼容问题。1.3 I2C 地址跳线与总线上拉ADDR 引脚决定了设备地址它不只是一个简单的 GND/VDD 选择而是可以接四种不同的电平分别对应四个地址。ADDR 接法I2C 地址接 GND0x48接 VDD0x49接 SDA0x4A接 SCL0x4B如果只是用单个模块ADDR 接 GND 拿 0x48 就行。需要多个模块时每个模块的 ADDR 接不同的电平避免冲突。我在一个项目里挂过两个 ADS1115一个接 GND一个接 VDD扫描的时候分别出现 0x48 和 0x49很干净。I2C 总线的上拉电阻要单独说一句这是很多人第一次调不通的总原因。I2C 是开漏结构SCL 和 SDA 必须外接上拉电阻才能输出高电平。多数成品模块上已经贴了 4.7kΩ 或 10kΩ 上拉直接能用。如果你用的是自己焊接的裸片千万别漏掉上拉电阻否则大概率扫不到设备。上拉阻值选择上400kHz 快速模式常用 4.7kΩ低速 100kHz 用 10kΩ 也可以线长或者总线上设备多时适当减小阻值但不要低于 2.2kΩ否则灌电流偏大。2. MicroPython 下 I2C 总线初始化的两个关键选择2.1 硬件 I2C 还是软件 I2CMicroPython 的machine模块提供了I2C和SoftI2C两种方式。前者走 MCU 硬件外设后者是 GPIO 模拟时序。硬件 I2C 的优点是速度快、时序稳定、不额外占用 CPU适合在数据量较大或者采样频率较高的场景使用。缺点是引脚不是完全自由虽然 ESP32-S3 这类芯片在 MicroPython 支持下可以在不少 GPIO 上复用 I2C但不同板卡固件支持的引脚集合有差异玩之前最好查一下板子的引脚映射。软 I2C 则灵活很多任意两个支持输入输出的 GPIO 都能模拟 SCL/SDA。缺点是时序完全靠程序翻转电平在系统繁忙或者定时器中断较多时波形可能会有抖动可能导致通信失败。另外软 I2C 的最高速度受指令周期限制跑不了太高的频率。我的选择逻辑很简单硬件 I2C 能用就用遇到引脚限制再退回软 I2C。ESP32-S3 上这个项目最终用的是硬件 I2C0SCL 接 GPIO9SDA 接 GPIO8。from machine import I2C, Pin i2c I2C(0, sclPin(9), sdaPin(8), freq400_000) print(i2c)如果你的板子上这两个引脚不方便也可以改用 SoftI2Cfrom machine import Pin, SoftI2C i2c SoftI2C(sclPin(5), sdaPin(4), freq400_000)这里freq400_000表示 400kHz 快速模式MicroPython 会按照这个目标去配置外设。如果总线上设备较多或者线缆比较长可以降回100_000稳定性更好代价是每次通信时间翻倍。2.2 设备扫描与通信自检初始化的第一步永远是扫描总线确认 ADS1115 的地址对不对、通信是否正常。MicroPython 的scan()方法会返回总线上所有从设备的地址列表。devices i2c.scan() print(I2C devices:, [hex(dev) for dev in devices])正常接好一个 ADDR 接 GND 的 ADS1115输出应该是I2C devices: [0x48]如果返回空列表或者出现两个地址就先别往下写驱动了先把硬件层面理顺。2.3 扫不到设备时的常规排查链路扫不到设备是一个出现频率极高的问题我把它固定成一个排查顺序每次照着走很快能找到问题所在。确认供电。用万用表量 ADS1115 的 VDD 和 GND 之间是否有 3.3V很多模块上有电源指示灯亮了不代表电压正常最好实际量一下。确认共地。ADS1115 的 GND 必须和 MCU 的 GND 连在一起如果两个系统各供各的I2C 永远不工作。检查 SCL/SDA 有没有接反。这个错误看着低级但在杜邦线比较多的时候很容易发生。接反后扫描不到设备而且不会烧东西所以很多人会在这个问题上折腾半天。检查上拉电阻。模块有没有自带上拉用万用表量 SCL 对 VDD 的电阻如果接近无穷大基本就是没上拉。确认地址。如果系统里还挂了其他 I2C 设备比如 OLED 屏幕先把其他设备断开只留一个 ADS1115排除地址冲突。降速测试。把频率从 400kHz 降到 100kHz排除高速模式下的信号质量问题。这六步走完还没解决的话再考虑程序层面的问题比如引脚对不对、固件是否支持。3. 寄存器级驱动ADS1115 的配置位与读数换算3.1 寄存器地图与指针寄存器的用法ADS1115 在 I2C 总线上访问的寄存器只有几个控制逻辑很清晰。第一个是转换寄存器 0x00存放最新一次 ADC 转换结果16 位。第二个是配置寄存器 0x01控制采样模式、输入选择、增益、数据速率等。第三个和第四个分别是低阈值寄存器 0x02 和高阈值寄存器 0x03主要供比较器模式使用。MicroPython 的readfrom_mem和writeto_mem方法会自动处理寄存器指针所以操作起来比裸 I2C 方便很多。读转换值就是一个readfrom_mem(addr, 0x00, 2)写配置就是一个writeto_mem(addr, 0x01, value.to_bytes(2, big))。3.2 配置寄存器位拆解ADS1115 的配置寄存器是 16 位每一位都有明确的含义下表是我每次写配置时对照的速查表。位名称含义bit 15OS单次模式下写 1 启动转换读时 1 表示转换完成bit 14-12MUX输入多路选择单端或差分bit 11-9PGA增益设置决定满量程范围bit 8MODE1 为单次模式0 为连续转换模式bit 7-5DR数据速率从 8 SPS 到 860 SPSbit 4COMP_MODE比较器模式单次还是迟滞bit 3COMP_POL比较器输出极性bit 2COMP_LAT比较器锁存bit 1-0COMP_QUE比较器触发后输出11 表示禁用比较器MUX 的取值有几个常用组合000表示 AIN0 和 AIN1 差分输入100表示 AIN0 对 GND 单端101是 AIN1 对 GND 单端110是 AIN2111是 AIN3。如果你只需要单端测量MUX 配置就是0b100 | 通道号非常直观。PGA 增益和满量程的对应关系如下所示PGA 配置满量程000±6.144V001±4.096V010±2.048V011±1.024V100±0.512V101±0.256V110±0.256V111±0.256V需要注意的是满量程不等于实际能达到的线性范围比如 ±6.144V 档对应 LSB 是 187.5μV但芯片手册建议在使用中尽量让信号落在量程的 2/3 以上才能发挥出较高信噪比。3.3 最小可用的 MicroPython 驱动类下面这个驱动类是我项目里实际运行的版本去掉了不常用的分支保留了单端读取、增益设置和电压换算这些核心能力。from machine import I2C, Pin import time class ADS1115: def __init__(self, i2c, addr0x48, gain1): self.i2c i2c self.addr addr self.gain gain self._pga { 0: 0b000, 1: 0b001, 2: 0b010, 3: 0b011, 4: 0b100, 5: 0b101, 6: 0b110, 7: 0b111 } self._scale { 0: 6.144, 1: 4.096, 2: 2.048, 3: 1.024, 4: 0.512, 5: 0.256, 6: 0.256, 7: 0.256 } def write_config(self, cfg): self.i2c.writeto_mem(self.addr, 0x01, cfg.to_bytes(2, big)) def read_config(self): return int.from_bytes(self.i2c.readfrom_mem(self.addr, 0x01, 2), big) def read_conv(self): raw int.from_bytes(self.i2c.readfrom_mem(self.addr, 0x00, 2), big) if raw 0x8000: raw - 0x10000 return raw def read_single(self, ch0): mux 0b100 | (ch 0x03) cfg 0x8000 | (mux 12) | (self._pga[self.gain] 9) cfg | 0x0100 # MODE 1单次模式 cfg | (0b100 5) # DR 128 SPS cfg | 0x03 # COMP_QUE 11禁用比较器 self.write_config(cfg) while not (self.read_config() 0x8000): time.sleep_ms(2) return self.read_conv() def read_voltage(self, ch0): raw self.read_single(ch) return raw * self._scale[self.gain] / 32768.0使用方式i2c I2C(0, sclPin(9), sdaPin(8), freq400_000) adc ADS1115(i2c) raw adc.read_single(0) voltage adc.read_voltage(0) print(raw , raw, voltage , voltage)这段代码的核心逻辑是写配置时把 OS 位置 1告诉 ADS1115 开始一次单次转换然后循环读取配置寄存器直到 OS 位变为 1表示转换完成再读取转换结果。3.4 增益、满量程与负电压换算ADS1115 的转换结果是 16 位有符号数SPI 和 I2C 读取时都是二进制补码格式。如果是差分输入可能出现负值此时raw 0x8000的情况表示负数需要减去 0x10000 才能得到正确的有符号整数。电压计算公式是电压 原始码值 × 满量程 / 32768为什么除 32768 而不是 65536因为 16 位有符号数的正半轴只有 32768 个刻度满量程对应的是从负满量程到正满量程单端输入时 0V 对应码值 0满量程正电压对应 32767负电压对应 -32768。所以在单端模式下直接拿码值乘满量程再除以 32768 就能得到电压。举一个例子PGA 配置为001满量程 ±4.096V如果读取的原始码值是 20000那么电压就是20000 × 4.096 / 32768 ≈ 2.5V这里 2.5V 正好是一个常用的基准电压我用这个方式多次验证过驱动代码的正确性。4. 采样触发单次模式、连续模式与定时节奏4.1 单次触发的实现和适用场景单次触发模式是 ADS1115 功耗最低的工作方式。每写一次配置芯片只转换一次转换完成后回到待机状态。这种方式非常适合电池供电的设备或者本身采样频率就不高、每次只读一两个点的场景。MicroPython 下的实现就是驱动类里read_single做的事情配置寄存器写入时把 OS 位置 1然后轮询等待转换完成标志。轮询的过程很短暂在 128 SPS 下大概 7.8ms 就能完成。我早期犯过一个错误以为写配置后立刻读转换寄存器就能拿到数据结果经常读到上一次的旧值。后来仔细看数据手册才发现单次模式下启动转换后必须等待转换周期结束不能急着读数值。这一点在 MicroPython 这种解释执行的环境下特别容易踩坑因为readfrom_mem执行速度比较快如果你在启动转换后立刻读很可能读到的是上一次的结果。4.2 连续模式下的数据就绪判断如果采样频率要求比较高或者希望 ADC 自动连续出数可以把配置寄存器的 MODE 位写 0。连续模式下 ADS1115 按照设置的数据速率持续转换转换结果不断写入转换寄存器。连续模式的好处是不需要每次采样都发启动转换的命令只需要在需要时读转换寄存器即可。但这里有个关键问题你怎么知道当前读到的数据是新数据方法有两个。第一种是读配置寄存器检查 OS 位。在连续模式下每次转换完成后 OS 位会被硬件置 1读取后会清零因此可以通过读两次配置寄存器判断是否有新数据。第二种是固定延时直接等待一个转换周期以上再读保证数据已经刷新。固定延时的方法最简单也最不容易出错。如果你设置的数据速率是 128 SPS转换周期约 7.8ms每次读之前至少等 8ms 再读就能确保拿到的是新数据。不过要注意单纯靠延时无法精确对齐转换边界实际延迟会略大一些。4.3 定时器控制采样节奏在实际项目里我更推荐用定时器来管理采样节奏而不是在死循环里不停轮询。用machine.Timer可以做到比较稳定的周期性采样同时 CPU 还有余量去做其他事。下面这段代码实现每 100ms 触发一次采样把电压值存到一个全局字典里主循环可以随时取用。from machine import Timer adc_state {voltage: 0.0, raw: 0} def sample_tick(t): adc_state[raw] adc.read_single(0) adc_state[voltage] adc_state[raw] * 4.096 / 32768.0 timer Timer(0) timer.init(period100, modeTimer.PERIODIC, callbacksample_tick)这段代码在大多数 MicroPython 固件上都能正常工作但要注意一点如果主程序里也在操作 I2C比如同时读写 OLED 屏幕定时器回调里执行 I2C 通信就可能产生冲突。这个问题放在后面踩坑部分细说。4.4 不同数据速率下的等待时间参考ADS1115 支持的数据速率从 8 SPS 到 860 SPS不同速率的转换周期差别很大直接影响采样等待时间。DR 配置数据速率 (SPS)转换周期 (ms)00081250011662.50103231.250116415.6251001287.812510125041104752.11118601.16驱动代码里我使用了0b100也就是 128 SPS这个速率比较均衡。如果需要滤除 50Hz 工频干扰可以考虑 16 SPS 或者 64 SPS 这类速率因为在这个速度下 ADS1115 内部滤波器对工频信号有更好的抑制效果。5. 滤波处理从原始码值到稳定读数5.1 原始数据抖动的来源ADS1115 虽然是 16 位 ADC但不要幻想每次读出来的数值完全稳定。在我实测中给一个稳定的 2.5V 基准电压128 SPS 下连续读取 20 次原始码值可能在几十个 LSB 范围内波动。这个波动来源是多方面的片内参考电压噪声、I2C 读取时机的偏差、外部电磁干扰耦合、电源纹波等。以 4.096V 满量程计算1 个 LSB 约等于 0.125mV几十个 LSB 的抖动就是几毫伏。如果你的系统最终要显示到小数点后三位这个抖动就会直接影响读数稳定性。所以滤波不只是可有可无的优化项而是把 ADC 数据变成可用测量结果的关键一步。5.2 滑动平均滤波最常用的滤波方式是滑动平均。思路是维护一个固定长度的队列每来一个新采样值就丢弃最老的、加入最新的然后取平均值。from collections import deque class MovingAverage: def __init__(self, window8): self.window window self.buf deque(maxlenwindow) def update(self, value): self.buf.append(value) return sum(self.buf) / len(self.buf)窗口长度是核心参数。窗口越长平滑效果越好但响应越慢。以 128 SPS 为例8 点滑动平均引入的延迟大约为 3.5 个采样周期也就是约 28ms对大多数慢变信号来说可以接受。如果信号变化很快比如音频或者振动监测就需要缩小窗口甚至不用平均直接用原始值。5.3 中值滤波剔除毛刺滑动平均对随机噪声有很好的抑制效果但对偶发性的脉冲干扰不太友好。一个尖峰会被平均到多个输出值上导致一段时间内读数都偏高或偏低。这时候中值滤波更合适。中值滤波的核心思想是取一组数值中间位置的值而不是平均值从而把异常的毛刺直接丢掉。def median_filter(values): sorted_values sorted(values) n len(sorted_values) mid n // 2 if n % 2 1: return sorted_values[mid] return (sorted_values[mid - 1] sorted_values[mid]) / 2.0使用的时候维护一个滑动窗口每次取 5 个或 9 个点做排序。窗口 9 点意味着至少 5 个点被污染才可能严重影响输出这个抗干扰能力已经很强了。代价是计算量增大不过在 MicroPython 这种环境下只要采样率不是特别高纯 Python 实现也扛得住。5.4 一阶低通 IIR 滤波如果你不想维护队列也不想要明显的窗口延迟一阶低通 IIR 滤波器是另一个常用的轻量级方案。数学形式很简单y[n] y[n-1] alpha × (x[n] - y[n-1])其中alpha取值在 0 到 1 之间。alpha 越大滤波越弱响应越快alpha 越小滤波越强输出越平滑但滞后也越明显。class LowPassFilter: def __init__(self, alpha0.2): self.alpha alpha self.smoothed None def update(self, value): if self.smoothed is None: self.smoothed value else: self.smoothed self.alpha * (value - self.smoothed) return self.smoothed我习惯把 alpha 设置在 0.1 到 0.3 之间。如果采样率是 128 SPS、alpha 为 0.2时间常数大约在 40ms 左右平滑效果和实时性的平衡比较好。需要更强平滑时降到 0.05但这时候响应会明显变慢尤其在信号发生阶跃变化时输出要几百毫秒才能跟上。5.5 多种滤波组合的实测效果单独一种滤波往往不够理想。我的组合策略是先用中值滤波去掉毛刺再用一阶低通或滑动平均做平滑。这样既不会让毛刺渗透到平滑结果里又能把随机噪声压得很低。实测效果对比大概是这样采集对象是一个稳定的 2.5V 基准源128 SPS10 次读数的观察结果处理方式结果波动范围 (LSB)响应速度原始值±30 左右最快8 点滑动平均±4 左右约 28ms 延迟中值 9 点 滑动平均 8 点±2 左右约 60ms 延迟alpha 0.15 一阶低通±5 左右约 40ms 时间常数不要试图做一个滤波效果非常强、响应又非常快的组合那在物理上不可能。ADC 滤波本质上是用延迟换精度你需要根据自己系统的实际需求做取舍。6. 实战踩坑记录与排查链路6.1 I2C 总线卡死后的软复位I2C 总线有一个经典的卡死场景从设备在通信过程中异常掉电或者主设备在发送过程中被复位导致 SDA 被某个从设备拉死总线进入锁死状态。这时i2c.scan()会一直返回空或者直接报错。遇到这种情况可以尝试对 SCL 引脚做连续脉冲让从设备释放 SDA 线然后重新初始化 I2C。import time from machine import Pin def reset_i2c_bus(scl_pin, sda_pin): scl Pin(scl_pin, Pin.OUT) sda Pin(sda_pin, Pin.OUT) sda.value(1) for _ in range(10): scl.value(0) time.sleep_ms(1) scl.value(1) time.sleep_ms(1) i2c I2C(0, sclPin(scl_pin), sdaPin(sda_pin), freq400_000) return i2c这个操作的实际原理是在没有时钟信号的情况下通过翻转 SCL 来推进总线状态机直到从设备释放 SDA。虽然不一定能保证 100% 恢复但在大多数情况下能解决 I2C 锁死的问题。最有效的办法还是从源头避免I2C 通信前检查设备在线状态、通信失败时增加重试机制、不要在设备上电初始化之前就疯狂轮询。6.2 读数异常为 0x7FFF 或 0x8000 的分析如果你读到的原始值常年稳定在 0x7FFF 或者 0x8000说明设备其实在工作只是输入信号有问题。0x7FFF 表示正满量程也就是输入电压超过了你设置的 PGA 量程。比如你想测 3.3V 的电池电压却把 PGA 配置成了 ±2.048V 档那结果就会封顶在 0x7FFF换算出来的电压只有 2.048V明显不对。解决方法是根据实际输入电压选择合适的满量程档。0x8000 是负数满量程在差分输入模式下经常出现。常见原因是输入悬空也就是 AIN 和 AIN- 都没有接确定的电压芯片读到的是噪声和偏置电流形成的随机值。我曾经在测试差动模式时忘记接传感器输出一直在正负满量程之间跳变就是这个道理。差分输入的 AIN- 不要悬空要接到系统的参考地或者传感器负端。6.3 在中断回调里做 I2C 通信引发的掉坑MicroPython 的定时器回调本质上仍然运行在中断上下文。如果在回调里执行 I2C 通信而主循环同时也在操作同一根 I2C 总线就会出现总线竞争。轻则读到错误数据重则 I2C 状态机被打乱通信直接卡死。我在采集程序里走过一次这个坑。定时器每 100ms 触发一次回调回调里读 ADS1115主循环每 200ms 刷新一次 OLED。看起来两个任务时间上是错开的但 MicroPython 的定时器回调并不精确主循环里执行 I2C 通信的瞬间定时器中断正好插入两个 I2C 操作就打架了。后来改成在回调里只置一个标志位主循环检测到标志后再执行 I2C 读取问题就消失了。def sample_tick(t): global pending_sample pending_sample True def main_loop(): global pending_sample while True: if pending_sample: pending_sample False raw adc.read_single(0) # 更新显示或存储6.4 关于 ALERT/RDY 引脚的一个提醒很多教程会提到用 ALERT/RDY 引脚做中断触发节省轮询时间。这个概念本身没毛病但在 ADS1115 上这个引脚的行为取决于配置寄存器里的比较器设置并不是简单的“转换完成就拉高拉低”。当你把 COMP_QUE 配置为11禁用比较器后ALERT/RDY 在连续模式下会输出转换完成指示脉冲。但如果你修改了比较器相关位它又变成了报警输出用来判断电压是否超过阈值。两种模式的时序和电平关系完全不同搞混了容易得出“引脚不工作”的错误结论。我的建议是如果你只是做常规采样轮询配置寄存器完全够用不需要依赖 ALERT/RDY。如果确实要用务必仔细阅读数据手册中关于 ALERT/RDY 的具体时序描述并且用逻辑分析仪或示波器确认波形再写进业务流程里。最后说一点个人习惯。ADC 不是采样率越高越好我最终把 ADS1115 固定在 128 SPS加了 8 点滑动平均读取间隔控制在 100ms 左右。这样 CPU 不会被 I2C 读写占用太狠读数稳定在 ±2~3 个 LSB 以内。如果你的应用场景是慢速环境监测这套参数可以直接抄如果是需要捕捉快速变化的波形那还是老老实实降低滤波强度、提高数据速率再用逻辑分析仪去验证时序。硬件调试就是这种不断缩小怀疑范围的过程ADS1115 本身不难难的是把 I2C 通信和采样节奏这两个基础问题彻底吃透后面就顺了。