
最近我在折腾一个小型信号发生器用来给后端的滤波器电路提供可调模拟输入。手上正好有一片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 22SCL和 GPIO 21SDA实际可以在初始化时任意指定引脚。VCC 接 3.3VGND 接 GNDOUT 引脚接示波器探头。有一点要注意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, sclPin(22), sdaPin(21), freq400_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-0D3-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(points64): 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(points64): 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(points64): 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(points64): 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(period100, modeTimer.PERIODIC, callbackwave_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_addr0x60, points64): 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(periodmax(1, int(period_ms * 1000) / 1000), modeTimer.PERIODIC, callbackself._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 总线等我整理了实际调试中遇到的几类典型问题可以当成一个快速排查表用。现象可能原因排查方法输出一直为 0VI2C 地址不对或接线错误用i2c.scan()确认设备地址输出固定高电平波形表全部为 4095检查波形表生成逻辑和索引更新无波形但电压在跳变定时器周期过大用print打印定时器回调次数确认触发频率波形有台阶感点数太少或更新率不够增加波形表点数或提高 I2C 频率到 400kHz/800kHz波形毛刺多电源噪声或接线过长在 VCC 和 GND 之间加 0.1uF 电容缩短杜邦线频率与设定值偏差大定时器周期被取整使用更低的点数让周期在整数范围内I2C 偶发卡死总线被中断占用或被主循环阻塞在写操作前后加try-except必要时重新初始化 I2C定时器回调里加 print 后波形抖动print 阻塞时间过长去掉回调里的 print改用标志位在主循环输出有两个特别想提醒大家的点。第一个是 I2C 的时钟频率。很多教程默认freq100_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。