用MCP4725和MicroPython自制可编程信号发生器
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 到 VREFVREF 通常直接接电源。它的输出建立时间很短官方标称约 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 引脚接法VCC3.3V若要 0~5V 输出可接 5V但 MCU 的 SCL/SDA 需要电平转换GNDGNDSCLI2C 时钟线比如 ESP32 的 GPIO22SDAI2C 数据线比如 ESP32 的 GPIO21OUT信号输出接示波器探头或万用表红笔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, sclPin(22), sdaPin(21), freq400_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, vref3.3, addr0x60) gen.set_waveform(sine) gen.set_frequency(100) gen.set_amplitude(0.8) gen.run(loop_callbackyour_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, vref3.3, addr0x60, wavesine, points64): 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, freqNone): 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 波形按上面的示例把points64、频率设为 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 µsMicroPython 的循环开销和 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可得N64时最高稳定输出约5000/64 78 HzN32时最高稳定输出约 156 HzN16时最高稳定输出约 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里的字节格式改一改整个架构完全能继续沿用。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →