最近我在折腾一个小型信号发生器,用来给后端的滤波器电路提供可调模拟输入。手上正好有一片MCP4725,再加上手边的ESP32开发板,直接用MicroPython写了个波形发生器,正弦波、方波、三角波都能出,频率可以通过定时器随时调整。这篇文章把整个思路、代码、调试流程一次讲透,想自己动手做信号源或者学DAC、定时器应用的朋友,可以直接照着抄作业。
MCP4725 的定位很明确:一颗 I2C 接口的单通道 12 位 DAC。配合 MicroPython 的 machine.I2C 和 machine.Timer,不需要额外逻辑芯片,几根杜邦线就能搭出波形发生器。这里我会把核心代码、定时器驱动方式、以及我在调试过程中踩过的坑都梳理一遍,尤其是定时器中断里写 I2C 的时序问题,很多人第一次搞都会在这里翻车。
1. 波形发生器的整体设计与选型思路
1.1 为什么选 MCP4725,而不是直接用 PWM 或开发板自带 DAC
做模拟波形输出,常见的路子无非三种:PWM 加 RC 滤波、MCU 内部 DAC、外部 DAC 芯片。PWM 方案虽然不需要额外硬件,但输出的是脉冲宽度调制信号,要靠低通滤波器把高频分量抹掉,波形频率稍微一高,幅度就开始衰减,相位也会有偏移,想要输出一个干净的正弦波,滤波器的阶数和截止频率都得反复调。另外PWM 的占空比分辨率一般也就是 8 位到 10 位,提升到 12 位需要特殊定时器支持,限制挺多。
开发板自带 DAC 也是一个选择,比如 ESP32 自带两路 8 位 DAC,写起来确实简单,但 8 位分辨率只有 256 级,用来输出精细的正弦波,台阶感会非常明显。而 MCP4725 是 12 位分辨率,也就是 4096 个电压等级,成本不高,体积也小,又是 I2C 接口,任何带 I2C 的板子都能接,通用性很强。选择外部 DAC 芯片是更稳的做法,后续如果想换更高精度的 DAC,比如 16 位的 MCP4728,代码框架也只需要改很少一部分。
还有一个实际因素:我用的是纯 MicroPython 环境,直接操作寄存器去配置 MCU 内部 DAC 其实并不方便,而 I2C 外设驱动在 MicroPython 里封装得已经很好,几分钟就能跑通。做实验性质的项目,开发效率优先,MCP4725 是最合适的选择。
1.2 定时器驱动方案的核心逻辑
波形发生器根本的原理很简单:DAC 输出一个电压值,保持一小段时间,然后迅速切换到下一个电压值,只要切换得够快,外部看起来就是一个连续变化的模拟波形。关键就在于“切换”的节奏由谁来控制,这里我选择用硬件定时器做精准驱动。
MicroPython 的 machine.Timer 可以配置成周期性触发中断,每次中断就取一个波形表里的数值写到 MCP4725。因为是硬件定时器,时间基准由 MCU 内部时钟提供,不占用 CPU 主循环,触发间隔可以做到微秒级别。波形表本质上是一组预先计算好的 DAC 数值,比如一个正弦波周期内均匀取 64 个点,定时器每触发一次就能输出其中一个点,跑完一圈就是一个完整周期。
这里有一个非常核心的公式:输出波形频率 = 定时器触发频率 / 单周期采样点数。比如定时器每 100 微秒触发一次,即 10 kHz 的采样频率,波形表有 50 个点,那么输出频率就是 10k / 50 = 200 Hz。如果想改变输出频率,可以调整定时器触发间隔,也可以动态修改波形表的点数。实际项目里通常固定一个合适的点数,通过修改定时器周期来调频,代码逻辑最清晰,CPU 负担也最小。
2. 硬件接线与环境准备
2.1 需要的材料清单和接线方式
我手头的设备是 ESP32 DevKitC 和一块常见的 MCP4725 模块,这类模块通常已经贴好了 I2C 上拉电阻和输出滤波电容,直接连杜邦线就能用。完整清单如下:
- 支持 MicroPython 的开发板一块,ESP32、ESP8266、树莓派 Pico 都可以
- MCP4725 DAC 模块一块
- 杜邦线若干
- USB 供电线一条
- 示波器或万用表,用于验证输出波形
接线方式非常固定,MCP4725 的 SCL、SDA 分别接开发板的 I2C 时钟和数据引脚。以 ESP32 为例,硬件 I2C 的默认引脚是 GPIO 22(SCL)和 GPIO 21(SDA),实际可以在初始化时任意指定引脚。VCC 接 3.3V,GND 接 GND,OUT 引脚接示波器探头。
有一点要注意,MCP4725 模块上通常有一个 A0 焊盘,用于设置 I2C 地址。A0 接 GND 时器件地址是 0x60,接 VCC 时地址变成 0x61。默认情况下模块的 A0 应该是接地,所以地址为 0x60。如果后续要在一片 I2C 总线上挂多个 DAC,可以通过 A0 引脚区分,最多能挂 8 片。
2.2 MicroPython 固件与驱动准备
MicroPython 的安装这里简单带过,去官网下载对应开发板的固件,用 esptool 或者官方烧录工具写进 Flash 就行。我建议选最新稳定固件,ESP32 系列的话一般选带 SPIRAM 的版本,内存管理会宽松一些。注意要选支持你板子的固件,如果板子需要 USB Host 之类特殊功能,还得找特定分支,不过做波形发生器用不到这个。
固件烧好后,第一步不是直接写波形程序,而是先扫描一下 I2C 总线,确认能发现 MCP4725。用 MicroPython 自带的 I2C.scan() 方法,几行代码就能完成验证:
from machine import Pin, I2C i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=400_000) devices = i2c.scan() print([hex(dev) for dev in devices])如果返回[0x60],说明 MCP4725 已经在总线上了。如果返回空列表,先检查接线,再确认 A0 地址,最后确认上拉电阻是否正常。这一步能过滤掉八成硬件问题,强烈建议别跳过。
3. 代码实现:从 DAC 驱动到完整波形发生器
3.1 MCP4725 基础驱动:写一个可用的 DAC 值
MCP4725 的 I2C 写操作分几种模式:快速写、写 DAC 寄存器、写 EEPROM。波形发生器里我们不需要保存掉电值,所以用快速写模式,一次只发 3 个字节,更新速度最快。快速写格式是:控制字节 + 数据高 8 位 + 数据低 4 位。控制字节里需要把写命令位和 PD 位配置对,PD 位为 00 表示正常模式,控制字节就是 0x40。
12 位 DAC 数值范围是 0 到 4095,输出电压计算公式是Vout = (DAC_value / 4096) * VREF。VREF 通常等于 VCC,也就是 3.3V。所以输出 1.65V 需要 DAC 值约为 2048。基础写函数如下:
def dac_write(i2c, addr, value): if value > 4095: value = 4095 elif value < 0: value = 0 buf = bytearray(3) buf[0] = 0x40 buf[1] = (value >> 8) & 0xFF buf[2] = value & 0xFF i2c.writeto(addr, buf)这段代码把 12 位数值拆成高 8 位和低 4 位,注意低 4 位是存在 buf[2] 的高 4 位里,也就是value & 0xFF放在了低字节,因此 D7-D4 是 DAC 位 3-0,D3-D0 是无效位。这种拆分方式是 MCP4725 的标准写法。i2c.writeto(addr, buf)一次把 3 个字节发出去,I2C 硬件会自动处理起始停止位。
用万用表量 OUT 引脚,应该能看到对应电压。写 0 时输出 0V,写 4095 时输出接近 VCC。
3.2 波形表生成:正弦波、方波、三角波、锯齿波
波形发生器的核心是波形表。因为定时器中断里需要快速取值,最好在程序初始化阶段就把所有点计算好,存放成整数数组。MicroPython 里用 array 模块的array('H')存储无符号短整型,比 Python 列表省内存,访问速度也更快。
正弦波的生成需要把 0 到 2π 分成 N 个点,每个点算 sin 值,再映射到 0 到 4095 的 DAC 区间。这里有个偏移问题:DAC 只能输出正电压,所以正弦波需要加直流偏置,让波形在 1.65V 上下摆动。公式为dac_i = int((sin(angle) + 1) / 2 * 4095),这样输出范围就是 0 到 4095,中间值约 2048,对应电压约 1.65V。
生成代码如下:
import math from array import array def make_sine_wave(points=64): table = array('H', [0] * points) for i in range(points): angle = 2 * math.pi * i / points table[i] = int((math.sin(angle) + 1) * 0.5 * 4095) return table def make_square_wave(points=64): table = array('H', [0] * points) half = points // 2 for i in range(points): table[i] = 4095 if i < half else 0 return table def make_triangle_wave(points=64): table = array('H', [0] * points) for i in range(points): ratio = i / points if ratio < 0.25: table[i] = int(ratio * 4 * 4095) elif ratio < 0.75: table[i] = int((1 - (ratio - 0.25) * 2) * 4095) else: table[i] = int(((ratio - 0.75) * 4) * 4095) return table def make_sawtooth_wave(points=64): table = array('H', [0] * points) for i in range(points): table[i] = int((i / points) * 4095) return table三角波那个计算我第一次写的时候搞反了方向,出来的波形往一边倒,后来仔细捋了一遍比例关系才调对。实现的时候可以先在电脑上用脚本画出来看形状,再烧到板子里。如果嫌麻烦,也可以用在线波形计算工具生成,但我觉得自己写一遍更清楚。
3.3 定时器驱动:让 DAC 按频率输出波形
波形表和 DAC 驱动都备齐后,最后一步就是用定时器把两者串起来。MicroPython 的 Timer 类用法很直接:
from machine import Timer timer = Timer(0) def wave_update(timer): global table_index dac_write(i2c, DAC_ADDR, wave_table[table_index]) table_index = (table_index + 1) % len(wave_table) timer.init(period=100, mode=Timer.PERIODIC, callback=wave_update)period单位是毫秒,表示每隔多少毫秒触发一次回调。如果波形表是 64 点,period 为 1 毫秒,那么输出频率就是 1 / (0.001 * 64) ≈ 15.625 Hz。想输出 1 kHz 正弦波,64 点采样率需要 64 kHz 的更新频率,也就是定时器周期约 15.6 微秒。这里会遇到一个现实瓶颈:MicroPython 定时器回调有一定的执行开销,再加上 I2C 写 3 个字节需要约 30 到 60 微秒,所以实际能稳定的最高更新率大概在 10 kHz 到 20 kHz 之间,对应的最高波形频率在 150 Hz 到 300 Hz 左右。
想要提高输出频率,可以减波形点数,比如只保留 16 点,最高波形频率能提升到 1kHz 以上,但波形会带有明显的台阶。这个权衡要看你后端电路的滤波器能力。
完整的波形发生器我封装成了一个简单类,结构清晰一些:
class WaveformGenerator: def __init__(self, i2c, dac_addr=0x60, points=64): self.i2c = i2c self.addr = dac_addr self.points = points self.table = make_sine_wave(points) self.index = 0 self.timer = Timer(1) def set_waveform(self, wave_type): if wave_type == 'sine': self.table = make_sine_wave(self.points) elif wave_type == 'square': self.table = make_square_wave(self.points) elif wave_type == 'triangle': self.table = make_triangle_wave(self.points) elif wave_type == 'sawtooth': self.table = make_sawtooth_wave(self.points) def set_frequency(self, freq_hz): # 更新定时器周期:period_ms = 1000 / (freq_hz * points) if freq_hz <= 0: return period_ms = 1000 / (freq_hz * self.points) self.timer.init(period=max(1, int(period_ms * 1000) / 1000), mode=Timer.PERIODIC, callback=self._update) def _update(self, timer): dac_write(self.i2c, self.addr, self.table[self.index]) self.index = (self.index + 1) % self.points注意set_frequency里的周期单位是毫秒,但定时器初始化时内部会转换成微秒级的 tick,实际传入period的单位是毫秒,浮点数会被截断成整数微秒精度。所以如果 period 太小,误差会很大,我一般在频率高于 500 Hz 时改用更低的点数来保证整数周期。
4. 调试流程与常见问题实录
4.1 我走过的调试路线:从静态电压到示波器观察
调试这块一开始我就吃过亏,直接把整个波形程序烧进去,拿示波器一看,输出一条直线,完全不知道问题出在哪。后来我养成一个习惯:分步骤拆解验证,每跑通一步再往下走。
第一步验证 DAC 基础功能。先把波形发生代码全部注释掉,只写一个dac_write(2048),用万用表量 OUT 引脚,看电压是不是在 1.6V 左右。这一步通过,说明 I2C 通信正常,地址正确,接线无误。
第二步验证波形表。固定写一个正弦波表,但不用定时器,手动循环遍历表里的值,每遍历一个点就写一次 DAC,并在循环里加上print查看遍历是否正常。这一步通过,说明波形表的数据是合理的,能覆盖 0 到 4095。
第三步接上定时器,先用很低的频率,比如 20 ms 周期,观察波形是否按照预期变化。然后把周期逐步调小,每次调整后看波形是否出现毛刺或失真。这样能快速定位是波形表的问题、定时器的问题还是 I2C 速度瓶颈的问题。
最后才接示波器细看波形质量。没有示波器的话,用万用表只能看到平均值,也可以临时在 OUT 引脚并联一个 RC 低通滤波器,配合万用表判断频率变化是否生效,但反应会慢很多。
4.2 问题排查速查表:频率、失真、I2C 总线等
我整理了实际调试中遇到的几类典型问题,可以当成一个快速排查表用。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 输出一直为 0V | I2C 地址不对或接线错误 | 用i2c.scan()确认设备地址 |
| 输出固定高电平 | 波形表全部为 4095 | 检查波形表生成逻辑和索引更新 |
| 无波形但电压在跳变 | 定时器周期过大 | 用print打印定时器回调次数,确认触发频率 |
| 波形有台阶感 | 点数太少或更新率不够 | 增加波形表点数,或提高 I2C 频率到 400kHz/800kHz |
| 波形毛刺多 | 电源噪声或接线过长 | 在 VCC 和 GND 之间加 0.1uF 电容,缩短杜邦线 |
| 频率与设定值偏差大 | 定时器周期被取整 | 使用更低的点数,让周期在整数范围内 |
| I2C 偶发卡死 | 总线被中断占用或被主循环阻塞 | 在写操作前后加try-except,必要时重新初始化 I2C |
| 定时器回调里加 print 后波形抖动 | print 阻塞时间过长 | 去掉回调里的 print,改用标志位在主循环输出 |
有两个特别想提醒大家的点。
第一个是 I2C 的时钟频率。很多教程默认freq=100_000,也就是标准模式,但波形发生器对更新率要求比较高,建议直接调到 400 kHz 甚至 800 kHz。MCP4725 最高支持 3.4 MHz 高速模式,但 MicroPython 的软件实现不一定能稳定跑上去,我实测 400 kHz 最稳,800 kHz 偶尔会出现 NACK,加个重试机制可以规避。
第二个是定时器回调里的操作要尽量短。MicroPython 虽然封装了定时器,但回调本质上还是中断上下文,在里边做复杂的浮点运算、字符串格式化、打印,都会严重影响实时性。我的做法是:所有波形表预先生成好,回调里只做一次查表和一次 I2C 写,绝不计算sin。如果需要多路波形同时输出,可以用两个定时器,也可以在一个回调里依次写多个 DAC,但要注意总耗时不能超过触发周期。
另外还遇到过一个非常刁钻的问题:MCP4725 模块用的是 3.3V 供电,但 ESP32 的某些 GPIO 在初始化 I2C 时默认是开漏,如果没有外部上拉电阻,总线信号会特别慢,导致 scan 偶尔失败。我这款模块自带上拉电阻,所以没问题,如果是自己焊的芯片,记得在 SCL 和 SDA 上各加一个 4.7k 欧姆电阻到 VCC。
再分享一个测频率的小技巧:不需要示波器,用万用表测 DAC 输出的平均电压。比如 64 点正弦波,如果设定 10 Hz 输出,万用表读数应该在约 1.65V 附近缓慢跳动,这个跳动频率大约就是 10 Hz。如果是 100 Hz 以上,万用表读数为稳定的 1.65V,因为低通效应已经把高频分量抹掉了。用这个现象可以粗略判断定时器是否在工作。
波形发生器用 MicroPython 写起来确实快,但如果想追求更高的输出频率和波形质量,建议把核心输出循环改成 C 语言实现,或者直接用 ESP32 的 I2C DMA 功能。MCP4725 本身也支持把 DAC 值预先写入 EEPROM,掉电后下次上电自动输出,这个特性在做固定偏压电路时很好用,做动态波形反而会拖慢写入速度,我建议在波形模式里不要碰 EEPROM。