1. 为什么信号发生器不选 PWM,而选 MCP4725 这类 DAC
你可能已经在无数 LED 调光例程里用过 PWM,也知道拿 PWM 加一级 RC 低通滤波后,理论上能"凑"出一个模拟正弦波。但我必须说,如果你的目标是做一个能测硬件环路响应、音频小信号路径、或者给外围电路注入连续可调电压包络的信号发生器,别绕这个弯路。MCP4725 是 MicroPython 生态里最常出现的 12 位 I2C DAC,它把"数字值"直接变成真实的模拟电压,配合自定义类封装后,一个几十行的脚本就能变成一台微型信号源。
1.1 PWM 做波形的天生缺陷
PWM 的本质是固定频率下的脉冲宽度调制,它输出的只有高电平和低电平两种状态。要变成正弦波,必须依靠外部滤波器把方波里的高频分量滤掉,而这会带来两个麻烦:
- 滤波器的截止频率和波形频率需要匹配,一旦你要输出 1 Hz 到 1000 Hz 范围可调的信号,模拟滤波器的设计就很头疼。
- PWM 分辨率上升时,载波频率通常也要跟着上升,否则你会看到明显的阶梯纹波。
即便你用 RP2040 的 16 位 PWM 硬件,占空比能细到 1/65536,它输出的仍然是一长串数字脉冲,而不是真正的连续电压。对于模拟电路测试来说,这相当于"伪模拟"。
1.2 MCP4725 的指标适合什么场景
MCP4725 是一个单通道、12 位、I2C 接口的 DAC,输出范围从 GND 到 VREF,VREF 通常直接接电源。它的输出建立时间很短,官方标称约 6 µs,这意味着每个 DAC 值写下去,引脚上会快速稳定到对应电压。
它非常适合这类场景:慢变传感器信号模拟、电机控制器的给定电压、音频幅度包络、0~3.3V 可编程参考电压、最高几百赫兹的小信号测试源。注意我这里强调"几百赫兹",因为 12 位 I2C DAC 的更新速率不是无限快,后面我会专门展开计算这个瓶颈。
选它的另一个现实原因是:模块极其便宜,3.3V/5V 兼容版本很多,且是标准 I2C 器件,地址默认 0x60,也能通过 A0/A1 引脚改到 0x61。对 MicroPython 玩家来说,machine.I2C配两行代码就能驱动,几乎没有学习成本。
2. 搭建最小硬件环境:I2C 接线、上拉与电平匹配
在写类之前,先把物理链路搞定。我见过太多人代码写得没问题,结果波形不对,最后发现是接线问题。
2.1 你需要的东西
- MicroPython 开发板一块。ESP32、RP2040、pyboard 都可以,这里以 ESP32 为例。
- MCP4725 模块一块,至少在 3.3V 下工作。
- 面包板、杜邦线若干。
- 万用表(用来校准输出精度),有条件再上示波器。
接线非常简单:
| MCP4725 引脚 | 接法 |
|---|---|
| VCC | 3.3V(若要 0~5V 输出可接 5V,但 MCU 的 SCL/SDA 需要电平转换) |
| GND | GND |
| SCL | I2C 时钟线,比如 ESP32 的 GPIO22 |
| SDA | I2C 数据线,比如 ESP32 的 GPIO21 |
| OUT | 信号输出,接示波器探头或万用表红笔 |
| GND | 同时作为测量参考地 |
2.2 上拉电阻和电平坑
I2C 总线的 SCL/SDA 是开漏结构,必须有上拉电阻。大部分 MCP4725 模块会自带 10K 上拉到 VCC,所以你可能不需要外部额外接。但要注意:如果模块供电是 5V,而上拉电阻也接到了 5V,那 3.3V 的 MCU 引脚会吃不消,轻则通信不稳定,重则烧 IO。稳妥做法是给模块也供 3.3V,把量程固定在 0~3.3V,这样不需要任何电平转换。
如果你确实需要 0~5V 输出,那就得用 5V 供电并加一个 I2C 电平转换板,这是另一套方案,本文不展开。
2.3 验证 I2C 通信
把脚本塞进板子之前,先用一个最简扫描脚本确认能发现设备:
from machine import Pin, I2C i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=400_000) print(i2c.scan())如果打印结果里有[96],恭喜,地址 0x60 的 MCP4725 已经在线。如果没有,先查供电、共地、上拉,再查 SCL/SDA 是否反了。
这里顺便提一句,很多人搜索 MicroPython 固件时会看到"支持 USB host 的 micropython 固件"这类版本,但那主要面向 USB 外设扩展,和 I2C DAC 无关。官方针对你芯片型号的稳定版固件就直接够用,不要被额外功能带偏。
3. 自定义类的骨架:从写寄存器到抽象信号源
刚开始玩 MCP4725 时,我也是在代码里直接写i2c.writeto(...)。直到某次项目里既要输出正弦波,又要输出锯齿波和方波,才发现程序乱成一团。这时候最合理的做法就是写一个自定义类,把"DAC 寄存器写入"和"波形的逻辑"拆开。
3.1 MCP4725 的写入命令到底在写什么
MCP4725 每帧至少 3 字节:第一个字节是命令字,0x40代表"写入 DAC 寄存器但不动 EEPROM";后面两个字节是 12 位数值的拆分。
12 位的 DAC 值叫value,范围 0~4095。它被塞进两个字节时要进行比特拼接:
- 高字节:
(value >> 4) & 0xFF,因为 12 位右移 4 位后,高 8 位落到这个字节。 - 低字节:
(value & 0x0F) << 4,因为低 4 位要放到低字节的高半字节里,低半字节填 0。
def write_raw(self, value): value = max(0, min(4095, int(value))) self.i2c.writeto( self.addr, bytes([0x40, (value >> 4) & 0xFF, (value & 0x0F) << 4]) )这段代码是后面所有波形输出的基石。不同驱动库可能让你直接写 12 位数值,但底层做的工作就是上面这个移位拼接。
3.2 SignalGenerator 类的字段设计
自定义类的目标是让上层使用者不关心寄存器。我希望代码里能这样调用:
gen = SignalGenerator(i2c, vref=3.3, addr=0x60) gen.set_waveform('sine') gen.set_frequency(100) gen.set_amplitude(0.8) gen.run(loop_callback=your_function)那么类里至少需要维护这些状态:波形类型、期望频率、振幅比例、直流偏置、查找表、当前相位、I2C 对象和地址。
振幅和偏置非常重要。MCP4725 只能输出 0 到 VREF 的电压,没有负压输出,所以任何波形都要做"平移"处理。比如正弦波默认以 VREF/2 为中心振幅 1.0 就是满幅摆动 0~VREF;振幅 0.5 则是 0.25VREF~0.75VREF。这种可调性在模拟电路测试里极其有用。
3.3 查找表应该加在这个类里
一个最常见的错误是在定时中断里临时调用math.sin()计算每个输出值。千万别这么干,MicroPython 的浮点三角函数慢得吓人,定时器回调里做这运算会把波形频率拖垮。
正确做法:在类初始化时根据波形类型生成一张查找表(lookup table),把每个采样点对应的 12 位 DAC 值预先算好。运行时只需要查表、维护索引、写寄存器。
import math class SignalGenerator: def __init__(self, i2c, vref=3.3, addr=0x60, wave='sine', points=64): self.i2c = i2c self.addr = addr self.vref = vref self.wave = wave self.points = points self.values = [] self._phase = 0 self.set_waveform(wave) def set_waveform(self, wave): self.wave = wave self.values = [] for i in range(self.points): phase = i / self.points if wave == 'sine': norm = 0.5 + 0.5 * math.sin(2 * math.pi * phase) elif wave == 'square': norm = 1.0 if phase < 0.5 else 0.0 elif wave == 'triangle': norm = 1.0 - abs(4.0 * phase - 2.0) elif wave == 'sawtooth': norm = phase else: norm = phase code = int((norm * 4095.0)) self.values.append(max(0, min(4095, code)))上面的norm是归一化到 0.0~1.0 的幅度。后续要加振幅、偏置,就在那里做映射:code = int((offset + (norm - 0.5) * amplitude) * 4095)。这样同一个查找表生成函数能复用到任意波形。
4. 波形生成核心:查找表 + 分频器 + 轮询循环
类框架建好之后,核心问题变成了"多快更新一个点"。信号发生器的频率不是直接写在硬件寄存器里的,而是靠"每秒输出的采样点数"和"查找表长度"共同决定。
4.1 频率与采样率的关系
假设查找表有N个点,每个点输出后等待dt秒再输出下一个,那么走完一轮表的时间是N * dt,这就是信号的周期。因此输出频率是:
f = 1 / (N * dt)同理,如果目标频率是f,采样周期:
dt = 1 / (N * f)举个例子:你有 64 点正弦表,想输出 100 Hz,那么每个点的更新间隔是1 / (64 * 100) = 156.25 µs。这个间隔对 MicroPython 来说还算能跑。如果换成 1000 Hz,间隔就变成 15.6 µs,在纯 Python 循环里几乎不可能稳定做到。
所以设计类的时候,points和freq是两个需要一起考虑的变量。我的经验是:低频时用 256 点保证波形光滑,高频时降到 32 点甚至 16 点优先保证频率可达。
4.2 用轮询代替硬实时中断
MicroPython 的machine.Timer能做定时中断,但中断回调里执行 I2C 写入存在两个隐患:第一,不同开发板的 Timer 精度和回调开销不同;第二,如果回调执行时间超过定时周期,波形会明显抖动甚至丢失采样点。
我更推荐在主循环里用time.ticks_us()做非阻塞轮询。代码更直观,也更容易调试:
def run(self, freq=None): if freq: self.set_frequency(freq) dt_us = int(1_000_000 / (self.points * self.freq)) deadline = time.ticks_us() while self.running: self.tick() deadline = time.ticks_add(deadline, dt_us) delay = time.ticks_diff(deadline, time.ticks_us()) if delay > 0: time.sleep_us(delay) else: # 溢出说明输出速度跟不上,需要减少点数或降频 deadline = time.ticks_us()tick()做的事情就是查表、写 DAC、推进相位:
def tick(self): self.write_raw(self.values[self._phase]) self._phase = (self._phase + 1) % self.points用time.ticks_add和time.ticks_diff的好处是自动处理 32 位计数器回绕,长时间运行也不会出 bug。
4.3 为什么自定义类里要区分"设置频率"和"启动输出"
频率设定影响dt_us的计算,所以类里提供set_frequency(),它只改参数,不立刻改变输出。真正开始输出时run()里才根据最新参数计算间隔。这种设计能避免在输出过程中修改参数导致相位跳变。如果你希望在运行中改频率,可以在设置频率后重新计算dt_us,甚至平滑地改变,让信号从当前频率渐变到目标频率——这个进阶功能也很适合放在类里。
5. 实测:示波器看到的波形和代码里的参数是怎么对应的
写完类,最兴奋的一步是接上示波器看波形。我建议按以下顺序验证,能少踩很多坑。
5.1 先测直流电压,再测波形
先别急着出正弦波。调用一次write_raw(0),测量 OUT 对 GND 电压,正常是接近 0V。再调用write_raw(4095),测量电压,应该接近 VREF。这里第一次暴露的问题往往是模块 VCC 实际不是 3.3V,而是 3.28V 或 3.4V,输出值和理论值有小偏差。
这时就需要校准:把实际测得的满量程电压存到类里的vref字段,不要硬编码 3.3。后续代码里的电压-编码换算会更准。
5.2 用 64 点正弦表观察 100 Hz 波形
按上面的示例,把points=64、频率设为 100 Hz,示波器上应该看到一个明显的"阶梯状"正弦波。每个阶梯高度相等,这是 DAC 在量化的结果,不是坏了。阶梯越多波形越光滑,阶梯少时可以看到折线。
可以用示波器的频率测量功能验证输出频率。如果你设置的 100 Hz 实际读到 98 Hz,多半是轮询循环里time.sleep_us的精度误差累积。这种情况下可以适当减小points或者换用ticks绝对时间调度来校正。
5.3 方波的上升沿和 I2C 更新速率
方波是最考验更新速率的波形。它只需要两个值:0 和 4095,但跳变时机必须精确。我把方波表设为 2 点,即[0, 4095],此时周期输出就是两个点交替。如果目标是 500 Hz,每个点间隔 1ms,输出非常干净。
但如果追求更高的方波频率,比如 5 kHz,那么一个周期只有 200 µs,两个点各 100 µs,MicroPython 的循环开销和 I2C 写入时间接近这个量级,上升沿会变得不稳定。此时你其实应该考虑用 PWM 或专用脉冲发生器芯片,而不是硬磕 DAC。
6. 限制到底在哪:I2C 速率、采样率与波形质量的平衡
这部分是很多人最容易忽略的。MCP4725 的"理论最大更新速率"和 MicroPython 实际能达到的"有效更新速率"差了一个数量级。
6.1 一帧 I2C 写入的时间预算
一次完整写入至少发送 1 个地址字节加 3 个命令数据字节(命令 + 高字节 + 低字节),共 4 个字节。I2C 总线上每字节要额外带一个 ACK 位,所以实际是 9 个 bit。如果总线跑 400 kHz,那么传输 4 字节理论时间是:
4 * 9 / 400000 = 90 µs这还没算machine.I2C.writeto的 MicroPython 调用开销、对象属性和字节列表构造时间。实测下来,ESP32 上每次write_raw()大概要 150~250 µs。
也就是说,你能稳定输出的采样点频率大约在 4~6 kHz。结合查找表长度N,可得:
N=64时,最高稳定输出约5000/64 = 78 HzN=32时,最高稳定输出约 156 HzN=16时,最高稳定输出约 312 Hz
这个上限不是你代码写得不好,而是 I2C + Python 解释器的物理限制。
6.2 用表格对比不同输出方案
| 方案 | 实用更新速率 | 波形质量 | 实现难度 |
|---|---|---|---|
| MCP4725 + MicroPython I2C | 几千次/秒 | 低频平滑,高频阶梯明显 | 低 |
| SPI 接口 DAC(如 MCP4921) | 几万次/秒 | 能输出几十 kHz | 中 |
| 硬件 PWM + RC 滤波 | 取决于 PWM 载波 | 有纹波 | 低 |
| FPGA/DDS 专用芯片 | 远高于 MCU | 高质量宽频 | 高 |
如果你看到网上有人用 FPGA 做信号发生器,比如搜索热词里常见的 ego1 开发板,那是完全不同路子:FPGA 用并行逻辑直接驱动高速 DAC,采样率可以到几十兆。普通 MicroPython 开发板不需要和它较劲,MCP4725 解决的是"轻量、慢速、可在脚本层面改波形"的问题。
6.3 实在要提速怎么办
想榨干 I2C DAC 的潜力,可以从三处优化:
- 把
i2c.writeto的每次调用都尽量减少字节分配。预分配一个bytearray([0x40, 0, 0]),写入时只改第二、第三字节,避免每次新建 bytes。 - 不要让
tick()里做任何if判断和日志打印。输出循环内只做查表和写寄存器。 - 将查找表从 list 改为
array('H'),减少内存访问开销,也节省 RAM。
即便如此,你依然跑不过硬件方案。所以我对这个项目的定位是:一个可编程波形源,而不是高性能测量仪器。放在自动化测试台上作为辅助信号激励,完全够用。
7. 扩展:把信号发生器变成可交互的测试工具
类已经能稳定跑波形了,但你大概率不想每次改频率都改代码重刷固件。给它加一个人机接口,才算真正"从代码到项目"。
7.1 用电位器直接调频率
MCP4725 是 DAC,但开发板上通常还有 ADC。用一个 10K 电位器、一个 ADC 输入引脚,就能实现频率粗调:
from machine import ADC, Pin pot = ADC(Pin(34)) pot.atten(ADC.ATTN_11DB) # ESP32 上使能 0~3.3V 输入 while gen.running: val = pot.read() freq = 1 + (val / 4095) * 200 gen.set_frequency(freq) time.sleep(0.05)这里每 50ms 刷新一次频率设定值,不影响波形更新的定时循环。因为set_frequency()只更新参数,不用打断当前正在输出的相位。
7.2 用命令行菜单切换波形
把一个简单的 REPL 菜单挂进主程序,按自然语言切换波形:
def prompt(gen): cmd = input("wave? [sine/square/triangle/saw] > ") gen.set_waveform(cmd.strip()) print("ok, now:", gen.wave)这样配合 Thonny 或串口终端,在调试现场就能改输出形状。若在局域网环境,还可以启用 WebREPL,手机上就能控制,不用连串口线。
7.3 把类文件保存成可复用模块
最后,将SignalGenerator类存成signalgen.py,放到开发板/lib目录下。项目脚本里只需要:
from machine import Pin, I2C from signalgen import SignalGenerator这种模块化的好处是以后做任何带 DAC 的项目,都能直接引包,不用到处复制粘贴寄存器操作代码。也方便写自动化测试脚本:按预设的波形序列依次输出,给被测电路做扫频。
我自己在实际项目里的习惯是:把"输出 0.5V 直流校准电压"和"输出 1 kHz 正弦波"做成两个预设命令,每次测试前先跑一遍自检。因为 MCP4725 是单通道 DAC,没有同步多路需求,所以给每个测试点挂一块,成本也很低。
MCP4725 不是万能的,但它把"用代码产生模拟信号"这件事的入门门槛拉得非常低。这篇流程走完,你已经拥有了一个可控制、可校准、可复用的微型信号发生器类。之后同样的思路换到 SPI DAC 上,也只是把write_raw里的字节格式改一改,整个架构完全能继续沿用。