news 2026/9/11 20:05:57

用MCP4725和MicroPython自制可编程信号发生器

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用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 到 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 引脚接法
VCC3.3V(若要 0~5V 输出可接 5V,但 MCU 的 SCL/SDA 需要电平转换)
GNDGND
SCLI2C 时钟线,比如 ESP32 的 GPIO22
SDAI2C 数据线,比如 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 循环里几乎不可能稳定做到。

所以设计类的时候,pointsfreq是两个需要一起考虑的变量。我的经验是:低频时用 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_addtime.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 Hz
  • N=32时,最高稳定输出约 156 Hz
  • N=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里的字节格式改一改,整个架构完全能继续沿用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 20:05:02

基于PyTorch的建筑物识别:从语义分割到掩膜矢量化

简介&#xff1a;基于Python和PyTorch的建筑物识别器源码&#xff0c;面向计算机视觉入门者及城市规划、灾害管理等行业应用&#xff0c;采用MaskRCNN模型对卫星或航拍图像进行建筑物自动识别与定位。资源共16个文件&#xff0c;以13个Python脚本为主体&#xff0c;涵盖数据预处…

作者头像 李华
网站建设 2026/9/11 20:03:20

16类农作物目标检测数据集与YOLOv8迁移学习实践

简介&#xff1a;面向农业智能化监测与精准农业管理的YOLO格式农作物检测数据集&#xff0c;覆盖香蕉、豆类、茄子、辣椒、黄瓜、玉米、水稻、小麦等16类主要经济作物&#xff0c;适合农田巡检机器人、智能除草设备及作物分布分析等视觉模块开发。压缩包共2000个文件&#xff0…

作者头像 李华
网站建设 2026/9/11 19:58:16

深度学习模型复现:随机种子与确定性计算实践

1. 随机种子与模型复现的世纪难题 第一次跑模型准确率90%&#xff0c;第二次跑变成85%&#xff0c;第三次又变成92%——这种让人抓狂的经历&#xff0c;相信每个深度学习从业者都遇到过。上周隔壁组的小王就因为论文实验结果无法复现&#xff0c;被导师要求重做了整整三周实验。…

作者头像 李华
网站建设 2026/9/11 19:55:32

基于Python与OpenCV的人脸识别景区票务系统设计

简介&#xff1a;基于人脸识别的景区票务系统毕业设计源码&#xff0c;面向需要完成Python课程设计或毕业设计的在校学生&#xff0c;也适合希望学习DjangoMySQL开发流程的初级开发者。系统采用前台后台双模式设计&#xff0c;前台支持用户注册、公告须知、票务查看与在线购票&…

作者头像 李华
网站建设 2026/9/11 19:53:55

深入理解人工智能 chatGPT的软件架构

ChatGPT是一个复杂的系统&#xff0c;其软件架构可以清晰地划分为几个核心的功能模块&#xff0c;它们协同工作以提供流畅、智能的对话体验。这些模块的交互流程&#xff0c;可以理解为用户请求从进入到响应返回所经过的一条“流水线”。&#x1f9e9; 核心软件模块与功能1. 客…

作者头像 李华
网站建设 2026/9/11 19:50:32

LangChain+ChatGLM-6B本地知识库问答实战:从RAG构建到调优

简介&#xff1a;面向计算机、通信、人工智能、自动化等相关专业学生及从业者的一套完整毕业设计项目&#xff0c;基于LangChain和ChatGLM-6B等主流LLM&#xff0c;解决针对本地知识库的自动问答问题。该项目为个人毕业设计&#xff0c;代码经调试测试确保可运行&#xff0c;作…

作者头像 李华