前阵子做测试工装,需要一个小型波形信号源。手头没有现成的 DDS 模块,倒是翻出来几片吃灰的 MCP4725。MCP4725 是 Microchip 的 12 位 I2C 接口 DAC,市场上很常见,价格也就几块钱,配上一块支持 MicroPython 的开发板,再用定时器做精准的节拍输出,完全可以做一个入门级的多波形发生器。这篇文章就把整个链路完整梳理一遍:为什么选 MCP4725、波形发生的原理、MicroPython 代码怎么写、定时器驱动有哪些坑,以及最终怎么把调试流程走通。内容面向正在备赛电赛的同学、刚接触嵌入式信号处理的开发者,以及任何想把“DAC 芯片”变成“可编程信号源”的爱好者。
1. 为什么选 MCP4725 做波形发生器:方案取舍与性能边界
1.1 一台“极简信号源”的需求拆解
做信号源,无论多简单,本质需求只有三条:能产生周期性波形、能调节频率、能调节输出幅度。频率和幅度通常由 MCU 端软件和外部电路配合完成,而“波形怎么变成电压”这一步,核心就是 DAC 或者等效的 PWM 方式。
我之前把需求定得很明确:低成本的测试工具,输出正弦波、方波、三角波和锯齿波,频率大概在几十赫兹到几百赫兹,幅度 0 到 3.3V 可调,最好用 MicroPython 快速开发。这样一来,选型范围就非常清晰了。
1.2 几种输出方案的横向对比
我在早期选型时对比过四个方向:MCU 内置 DAC、PWM 加 RC 滤波、SPI 接口外部 DAC、I2C 接口外部 DAC(也就是 MCP4725 这类)。
MCU 内置 DAC 最省事,但不是每个开发板都有,很多 ESP32 板子虽然有 8 位 DAC,线性度和输出电压稳定性一般,而且想同时用多个波形通道时,资源非常有限。PWM 加 RC 滤波看起来便宜,但 PWM 的分辨率和滤波器的相位延迟是天生短板,要输出平滑正弦波非常困难,尤其在不同频率下需要切换滤波截止频率,基本等于劝退。SPI 接口 DAC(比如 MCP4801/4821)速度快,波形频率上限更高,但对线材和代码要求都高一些,微控制器引脚占用也不占优势。
MCP4725 最吸引我的地方在于:12 位分辨率、I2C 通信只需要 SCL/SDA 两根线、板子背面有三根地址跳线可以切换地址,而且价格不到十块钱。它输出的是真正连续可调的电压,不是 PWM 的近似值。
我整理了一个对比表,方便后面做方案决策时参考:
| 方案 | 分辨率 | 采样率上限 | 引脚占用 | 软件复杂度 | 成本 |
|---|---|---|---|---|---|
| MCU 内置 DAC(如 ESP32) | 8 bit | 与 MCU 相关 | 1 个 IO | 低 | 无额外成本 |
| PWM + RC 滤波 | 受 PWM 位数限制 | 由 PWM 频率决定 | 1-2 个 IO | 中 | 几个电阻电容 |
| SPI DAC(如 MCP4821) | 12 bit | 较高(µs 级写入) | 4-5 个 IO(含片选) | 中 | 10 元左右 |
| I2C DAC(MCP4725) | 12 bit | 受 I2C 速率限制 | 2 个 IO | 低 | 3-8 元 |
结论很简单:如果目标波形频率在数百赫兹以内,MCP4725 是性价比非常高的选择。如果以后要做更高频率的信号源,再考虑切到 SPI 接口 DAC 或者专用 DDS 芯片。
1.3 MCP4725 的引脚、寄存器与运行边界
MCP4725 是 SOT-23-6 封装,很小,引脚不多:VDD、GND、SCL、SDA、VOUT、A0。这个 A0 引脚配合 I2C 地址的 bit1(A2 和 A1 在芯片内部固定为 0),可以让三个不同器件?严格来说,MCP4725 的地址位只有 A0 一个可配置位,所以同一总线上最多只能挂两块默认地址不同的 MCP4725,0x60 和 0x61。如果包含 A0A1A2 全配置的版本(比如 MCP4726/4727),可以挂更多。这里我们按常用的 MCP4725A0 来写。
它的输出电压计算公式很简单:
Vout = (DAC值 / 4096) × VDD
需要注意,VDD 本身就是基准电压。也就是说,供电电压决定了输出满量程。若用 3.3V 供电,输出范围就是 0 到 3.3V;如果希望输出 0 到 5V,就要给芯片供 5V,同时确保 I2C 引脚电平匹配。部分开发板的 I2C 引脚是 3.3V 电平,接 5V 供电的 MCP4725 时需要确认是否加电平转换,否则长期使用有风险。
另外一个容易被忽略的点是 MCP4725 内部集成了输出放大器,可以直接驱动一些阻性负载,但驱动能力有限。在数据手册中,它驱动 100kΩ 左右负载的线性度较好,若输出端直接接低阻负载(比如耳机),波形会被拉偏。所以后面我会建议加一级运放缓冲。
2. 波形生成的三板斧:查表法、定时器中断与 I2C 时序预算
2.1 模拟波形的离散化输出过程
DAC 的输入是数字值,输出是模拟电压。想让 DAC 输出一个连续变化的正弦波,就必须按时间顺序连续给 DAC 写入不同的数字值。这个操作本质上就是在做“采样信号的零阶保持重建”:每经过一个固定的时间步长 Δt,输出一个电压,保持一段时间,然后再跳到下一个电压。
由于输出不是无限点数的,我们看到的就是一格格台阶,台阶足够密时肉眼和耳朵会“自动平滑”,就像显示器像素一样。因此,波形的质量就取决于两个因素:每个周期的采样点数足够多、采样间隔足够均匀。
我把这个过程拆成了三个部分:波形表负责“每个时间点该输出什么电压”,定时器负责“每个时间点什么时候到来”,I2C 写入负责“把电压值真正送进 DAC”。
2.2 查表法:把一个周期的数据提前算好
查表法,英文里叫 look-up table,是最容易理解的波形生成方式。事先把一个周期的波形均匀切分成 N 个点,计算每个点对应的 DAC 值,存入数组。运行时每隔一个固定时间,从数组里取出一个点写入 DAC,循环往复。
举个例子,生成 64 点正弦波:
- 第 i 个点的相位是
2π × i / 64 - DAC 值 =
(sin(相位) + 1) / 2 × 4095
这样计算得到的是一串 0 到 4095 之间的整数。方波更简单,前半周期输出 4095,后半周期输出 0。三角波和锯齿波也都是线性计算。
提前算好的好处是:定时器中断里只需要查表,不需要做 sin 之类的浮点运算。MicroPython 的浮点运算虽然不慢,但在高频中断里做三角函数,还是会显著增加抖动和 CPU 占用。所以工程上一定要把“计算”和“输出”分离。
2.3 为什么必须用定时器中断,而不是 time.sleep()
很多初学者首先想到的写法是:
while True: for v in wave_table: i2c.writeto(addr, bytes) time.sleep_us(interval)这个写法早期我也用过,它的最大问题不是慢,而是时间不准确。time.sleep_us的精度依赖底层调度,如果在循环中有任何垃圾回收、浮点运算或者被其他中断打断,间隔就会漂移。慢还好说,问题是示波器上看到的波形周期会抖,这在信号源里是很致命的。
定时器中断的优势在于:它由硬件定时器触发,MicroPython 会在特定时间点调用你注册的回调函数。虽然回调本身执行也需要时间,但触发节拍是稳定的。波形表输出频率主要由定时器频率决定,和主循环里在做什么关系不大。
2.4 采样率、点数与输出频率的计算关系
波形输出频率、采样率和每周期点数之间的关系非常直接:
波形频率 = 采样率 / 每周期点数
假如定时器频率是 10000Hz,每周期 64 个点,那么输出正弦波频率就是 10000 / 64 ≈ 156Hz。如果每周期减少到 16 个点,同样的采样率下频率可以到 625Hz,但波形阶梯感会很严重。
所以做波形发生器时,要先定一个目标:你是要波形平滑,还是要输出频率高。这两者在给定采样率下是矛盾的。DDS 技术可以部分解决这个矛盾,后面第 6 章我再展开。
2.5 I2C 传输耗时到底是多少
这部分是决定性能上限的关键,必须算明白。MCP4725 的 I2C 写入,哪怕是快写模式,也需要依次发送:
- 起始信号
- 7 位设备地址 + 写位
- ACK
- 8 位控制字节(快写指令 + PD 位)
- ACK
- 8 位 DAC 数据高字节
- 8 位 DAC 数据低字节
- ACK
- 停止信号
一次完整的传输大约 27 个 bit?因为还有 ACK 位,实际算上起始和停止,总共有大约 35 个时钟周期。在 400kHz 的 I2C 速率下,每个 bit 周期是 2.5µs,一次写入耗时约为 85~90µs。如果提高到固件支持的 800kHz 或 1MHz,耗时可以压缩到 35~45µs。
这意味着什么?在 400kHz 下,MicroPython 每个采样点能被 I2C 写占用约 90µs,采样率理论上最多 11k 左右。如果你希望一个正弦周期用 64 个点,那么最高输出频率大约就是 11k / 64 ≈ 170Hz。这个数字并不惊艳,但对于低频测试、音频范围内低端信号、电赛的基础演示,完全够用。
下面是我实测的参考表,也给出理论估算值:
| I2C 速率 | 单点写耗时(估算) | 理论采样率上限 | 64 点/周期时最高波形频率 |
|---|---|---|---|
| 100 kHz | 约 350 µs | 约 2.8k | 约 44 Hz |
| 400 kHz | 约 90 µs | 约 11k | 约 170 Hz |
| 1 MHz | 约 40 µs | 约 25k | 约 390 Hz |
如果你想把正弦波做到 1kHz,除了提高 I2C 速率,还可以把每周期点数降到 16~24,适当牺牲波形平滑度。
3. MicroPython 驱动代码实战:从点亮 DAC 到多波形切换
3.1 环境准备:固件、接线与 I2C 地址
先到 MicroPython 官网的下载页面,找到你的开发板对应的固件,烧录好。这一步没什么神秘,选对板型就行。我用的是带有 STM32 的板子,I2C 引脚灵活,Timer 资源也多;ESP32 同理也可以跑,只需要改一下引脚映射。
接线非常简单:
| MCP4725 引脚 | 开发板引脚 |
|---|---|
| VDD | 3.3V(或 5V,看实际需要) |
| GND | GND |
| SCL | I2C SCL(例如 STM32 的 PB8) |
| SDA | I2C SDA(例如 STM32 的 PB9) |
| VOUT | 示波器/负载 |
| A0 | GND(默认地址 0x60) |
A0 接 GND 时 I2C 地址是 0x60,接 VDD 时是 0x61。如果你发现扫描不到设备,第一个要查的就是这个地址位。
3.2 三种寄存器写入方式与选择原则
MCP4725 的写入方式不只是“发两个字节”那么简单,控制字节里藏着不少信息。常见的三种写入模式:
- 快写(Fast Write):控制字节是
0x00开头的一组,只写 DAC 寄存器,不写 EEPROM,效率最高,适合连续波形输出。 - 写 DAC 寄存器:控制字节
0x40开头,同样只改当前输出电压,不写 EEPROM。 - 写 DAC 寄存器并写 EEPROM:控制字节
0x60开头,会同时把数据固化到 EEPROM,下次上电自动恢复。这个操作的坏处是慢,而且 EEPROM 有擦写寿命,通常按 100 万次算,虽然不少,但没必要在波形输出时用。
我的建议是:正常波形输出一律使用 Fast Write 或写 DAC 寄存器,只有在需要保存“上电默认电压”时才写 EEPROM,并且写完要加延迟。
3.3 单点输出的最小代码
先把最基本的一步跑通:让 MCP4725 输出一个固定电压。
from machine import I2C, Pin I2C_ADDR = 0x60 i2c = I2C(0, scl=Pin(8), sda=Pin(9), freq=400_000) def set_dac(value): if value > 4095: value = 4095 elif value < 0: value = 0 # 0x40 表示写 DAC 寄存器 buf = bytearray([0x40, (value >> 8) & 0xFF, value & 0xFF]) i2c.writeto(I2C_ADDR, buf) # 输出半量程电压,约 1.65V(3.3V 供电时) set_dac(2048)这段代码里,(value >> 8) & 0xFF是取 12 位 DAC 值的高 4 位,放到数据高字节的低 4 位;value & 0xFF是低 8 位。12 位 DAC 值被拆成“两个字节”发出去,其中高字节的高 4 位必须为 0。
第一次跑通后,用万用表量 VOUT,应该能读到约 1.65V。如果电压完全不对,先回来看 I2C 扫描是否成功。
3.4 波形表的批量生成
用array模块而不是 Python list 来存波形表,因为array('H')每个元素只占 2 字节,而且读取速度更快、不产生额外对象引用。
import math from array import array def make_wave_table(wave_type, points=64, amplitude=4095, offset=0): table = array('H', [0]) * points for i in range(points): phase = 2.0 * math.pi * i / points if wave_type == 'sine': value = (math.sin(phase) + 1.0) / 2.0 elif wave_type == 'triangle': # 先产生 -1 到 1 之间的三角波 t = 4.0 * i / points value = 1.0 - abs(1.0 - (t % 4.0)) if (t % 4.0) < 2.0 else -1.0 + abs(1.0 - (t % 4.0)) elif wave_type == 'square': value = 1.0 if i < points // 2 else 0.0 elif wave_type == 'sawtooth': value = i / points else: value = 0.0 dac_value = int(offset + amplitude * value) if dac_value > 4095: dac_value = 4095 elif dac_value < 0: dac_value = 0 table[i] = dac_value return table sine_table = make_wave_table('sine', 64) square_table = make_wave_table('square', 64) triangle_table = make_wave_table('triangle', 64) sawtooth_table = make_wave_table('sawtooth', 64)这里三角波的算法写出来有点绕,实际用的时候也可以直接用abs分段计算,关键是理解这些表本质上都是“一个周期内电压的离散采样”。你甚至可以手动输入任意形状的表,比如自定义的包络波形。
3.5 定时器驱动的完整代码
接下来就是核心环节:把定时器中断和波形表输出拼起来。我建议用预分配的 3 字节缓冲区,避免每次中断都新建 bytearray,否则内存分配会严重影响中断稳定性。
from machine import Timer CURRENT_TABLE = sine_table POS = 0 # 预分配写缓冲区,每次只在回调里修改后两字节 wave_buf = bytearray(3) wave_buf[0] = 0x40 # 写 DAC 寄存器指令 def wave_tick(timer): global POS value = CURRENT_TABLE[POS] wave_buf[1] = (value >> 8) & 0xFF wave_buf[2] = value & 0xFF i2c.writeto(I2C_ADDR, wave_buf) POS += 1 if POS >= len(CURRENT_TABLE): POS = 0 def start_wave(freq): timer.init(freq=freq, mode=Timer.PERIODIC, callback=wave_tick) def stop_wave(timer): timer.deinit()启动波形:
timer = Timer(0) start_wave(10000) # 定时器频率 10kHz如果你在定时器回调里看到内存分配错误或异常,可以在程序初始化最开头加上:
import micropython micropython.alloc_emergency_exception_buf(256)这段代码的作用是预留中断处理时的异常缓冲区,让回调中即使发生异常,也不会因为内存不足而直接崩溃。算是一个微控制器上 MicroPython 调试的标准保险动作。
切换波形时我建议先停掉定时器,再换表,最后重启:
def switch_wave(new_table): global CURRENT_TABLE, POS stop_wave(timer) CURRENT_TABLE = new_table POS = 0 start_wave(10000)4. 定时器驱动与实时性能调优:实测数据与抖动排查
4.1 MicroPython 定时器回调的隐藏限制
MicroPython 的定时器回调看起来像普通 Python 函数,但它运行在中断上下文里。这意味着你不能在回调里做任何可能被垃圾回收器打断的操作,比如创建 list、分配字符串,甚至某些隐式创建临时对象的操作。
我还见过有人在回调里写print调试,结果整个波形直接卡住。原因就是 print 涉及的缓冲区和输出操作在中断里太重了。正确做法是回调里只做最核心的工作:查表、改缓冲区数据、I2C 写入;调试信息放到主循环里打印。
另外,定时器频率并不是可以无限拉高。对 MicroPython 来说,回调函数本身有解释执行开销,加上 I2C 写入的阻塞时间,频率一旦超过某个阈值,回调执行时间会超出定时器周期,导致定时器事件堆积,波形节拍彻底乱掉。这就是为什么前面一定要做时序预算的原因:知道每写一个点要多久,才知道定时器频率能不能调到 10k 以上。
4.2 不同 I2C 速率下的采样率实测
我在 400kHz 和 1MHz 两种 I2C 速率下做过对比。400kHz 时,定时器频率设在 8kHz,输出 64 点正弦波,波形稳定,周期轮廓清晰,放大后有明显台阶但排列均匀。1MHz 时,定时器频率可以拉到 20kHz 左右,但已经能感受到边缘抖动,如果回调里有任何额外计算,抖动会更明显。
实际数据如下:
| I2C 速率 | 定时器频率 | 每周期点数 | 输出波形频率 | 观察效果 |
|---|---|---|---|---|
| 400 kHz | 8 kHz | 64 | 125 Hz | 稳定,台阶均匀 |
| 400 kHz | 11 kHz | 64 | 171 Hz | 接近 I2C 耗时上限,偶发抖动 |
| 1 MHz | 16 kHz | 64 | 250 Hz | 整体稳定,毛刺略增 |
| 1 MHz | 20 kHz | 32 | 625 Hz | 有明显台阶但波形周期清晰 |
结论是:如果你对波形纯净度有要求,不要让定时器频率超过 I2C 理论写入速率的一半,留出余地给 MicroPython 的解释执行开销。
4.3 抖动来源:阻塞、分配、中断嵌套
示波器上看到的抖动不是无缘无故出现的,主要有三个来源。
第一是 I2C 写入阻塞。i2c.writeto()是同步阻塞的,它不会在后台传输。在定时器回调里调用它,整个回调执行时间基本等于 I2C 传输时间,这段时间里如果 MCU 有更高优先级中断进来,后续操作会受到影响。
第二是内存分配。MicroPython 定时器回调里如果触发垃圾回收,时间不可控。最常见的是循环中隐式创建整数对象或列表元素,虽然 Python 的整数在 MicroPython 中可能直接复用,但凡是走对象堆的操作都应该避免。
第三是中断嵌套。某些开发板的硬件定时器中断优先级不是最高,比如被 SysTick 或其他外设中断抢占,回调执行就会延迟。这属于平台差异,需要实测。
要定位抖动,最好的工具是示波器,把触发边沿设在波形上,看晃动程度;其次是逻辑分析仪抓 I2C 的每个 ACK 间隔,看两次写入之间时间间隔是否均匀。
4.4 优化手段:预分配、禁 GC、统一表长与双缓冲
针对上面的抖动来源,我整理了几条在 MicroPython 里很有效的优化手段。
第一,预分配所有可变对象。写缓冲区和波形表在初始化时就创建好,回调中不新建任何对象。这是最重要的一条。
第二,在追求稳定输出时,可以临时关闭垃圾回收。在创建完波形表后执行gc.disable(),可以让 MicroPython 在运行中不触发自动 GC。但是如果你的代码有动态内存需求,谨慎使用,否则可能出现内存耗尽,而且 MicroPython 有可能无法正常报错定位。推荐只在定时器启动期间临时关闭,停止时再gc.enable()。
第三,统一不同波形的表长。比如全部用 64 点,切换波形时就不需要重置 cur_index 的逻辑同步问题。如果表格长度不一致,那么切换时不仅要把当前位置归零,还要让定时器回调里的长度判断条件保持全局一致,否则很容易越界或产生跳变。
第四,双缓冲。如果你需要在运行中动态修改波形表(比如把用户传入的自定义数据做实时叠加),主循环写表时,中断读表可能读到“写了一半”的中间状态。常见解决方案是准备两份表,一份用于中断读,一份用于主循环写,写完后一次性切换指针。这个思路和图形渲染里的双缓冲一模一样。
5. 调试流程全景记录:从 I2C 扫描到示波器稳定输出
5.1 调试工具与接线确认
调试之前先把工具备齐:一个 USB 转 I2C 逻辑分析仪(或者开发板自己的 REPL 配合扫描脚本)、一块数字万用表、一台示波器,最好还有一个可调电源。
逻辑分析仪用来确认协议层数据正确,示波器用来确认最终模拟信号质量,万用表做直流电压的粗验证。没有逻辑分析仪也能调,但遇到 I2C 问题会比较痛苦。很多问题上,总线时序一抓,比猜半天有效率得多。
另外强烈建议先在不接 MCP4725 的情况下,用万用表量一下开发板 I2C 引脚的电压是否正常,上拉电阻是否已经存在。很多开发板内部已经有 I2C 上拉,但少数模块需要自己外接 4.7kΩ 上拉,否则 SCL/SDA 电压会被 DAC 芯片拉低,导致通信失败。
5.2 第一关:I2C 地址扫描与空载读数
先用最稳妥的 I2C 扫描脚本确认设备出现在总线上:
from machine import I2C, Pin i2c = I2C(0, scl=Pin(8), sda=Pin(9), freq=400_000) devices = i2c.scan() print([hex(d) for d in devices])正常输出应该包含['0x60']。如果扫描结果为空,排查顺序是:
- 接线是否接反(SCL/SDA 交叉是头号错误)
- VDD 是否正常供电
- 地址引脚是否悬空(最好明确接 GND)
- I2C 上拉电阻是否存在
- 开发板 I2C 引脚是不是硬件 I2C 引脚
扫描到设备后,再读一次芯片的 ID 寄存器。MCP4725 的 ID 寄存器在地址 0x00,可以这样读:
data = i2c.readfrom_mem(0x60, 0x00, 2) print(hex(data[0]), hex(data[1]))正常值通常能读到一些非零数据。如果读到全 0 或者全 1,可能是地址不对或总线上有多个设备冲突。
5.3 第二关:直流输出电压标定
通信正常后,先不要急着跑波形,用单点输出功能把几个关键电压点测出来:
| DAC 值 | 理论输出(3.3V 供电) | 实际测试值 |
|---|---|---|
| 0 | 0 V | 接近 0 V |
| 1365 | 约 1.1 V | 接近 1.1 V |
| 2048 | 约 1.65 V | 接近 1.65 V |
| 4095 | 约 3.3 V | 接近 3.3 V |
如果实测值和理论值相差过大,先看供电电压是不是稳定。如果接近满量程时输出被“削平”,说明电压基准可能被负载拉低,需要检查输出端是否短路或接了过小的电阻负载。
这一步很关键,因为它能帮你排除“代码看似正确但芯片本身接线或损坏”的问题。很多后续波形异常,追根溯源都是直流基准没对好。
5.4 第三关:方波与斜坡的初步验证
直流没问题后,先跑一个在技术上最容易判断的方波。方波只有高和低两个电压,用示波器一看就知道周期对不对、幅值对不对。方波稳定后,说明定时器中断、I2C 写入和波形表循环的基本链路已经通了。
接下来跑斜坡或者锯齿波。锯齿波的特点是从 0 线性增长到满量程,然后瞬间跳回 0。这个波形适合用来检查 DAC 单调性。如果示波器上能看到均匀上升且回跳干净,那么 12 位的逐点输出就没有大问题。
5.5 常见异常现象与排查对照表
我在调试过程中把常见现象整理成了一张表,遇到问题对照着查,效率高很多。
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 输出完全为 0 | 未使能定时器 / DAC 供电异常 / 地址错误 | 检查 scan,跑单点输出 |
| 输出固定在某电压不再变化 | 定时器未启动或回调被异常阻塞 | 停掉主循环,单独测定时器回调 |
| 波形有周期性缺坑 | 漏点 / I2C 写入失败 / 波形表越界 | 用逻辑分析仪抓 ACK,确认每次写入成功 |
| 波形周期缓慢漂移 | I2C 速率或定时器频率不匹配 | 降低定时器频率或提高 I2C 速率 |
| 波形带明显毛刺 | 电源噪声 / 接线过长 / 未加滤波 | 加 0.1µF 电源去耦电容,缩短飞线 |
| 波形幅值偏低很多 | VDD 供电不足 / 输出负载过大 | 测量 VDD 电压,避免低阻负载 |
| 切换波形瞬间输出乱码 | 切换时未停定时器 / 表长不一致 | 切换前停定时器并重置位置 |
5.6 一个真实故障案例:漏点导致的正弦缺口
印象最深的一次是输出正弦波时,示波器上每隔一段时间就会出现一个明显的“缺口”,波形不是平滑的正弦,而是像被人用橡皮擦掉了一个点。
一开始我怀疑是 I2C 写入偶尔失败。用逻辑分析仪抓总线,发现每个 ACK 都是正常的,没有 NACK。然后我开始怀疑定时器回调,于是在回调里数一个全局计数器,主循环每秒打印一次数值,看和理论触发次数对不对得上。结果发现频率低时完全正常,频率拉到 12kHz 后,计数偶尔会比预期少几次,说明定时器回调没有被全部执行。
进一步排查发现,问题出在定时器回调执行时间超过定时器周期,造成事件丢失。因为 I2C 写入大约 90µs,而 12kHz 的周期约 83µs,理论上就超了。部分定时器实现不会在超时后继续排队,而是直接丢弃回调。解决办法是降低定时器频率到 8kHz,或者把 I2C 从 400kHz 调到 1MHz,给回调腾出时间。
这个案例也验证了前面第 2 章时序预算的必要性。写嵌入式代码,不能只看代码逻辑,还要算“每个操作到底花多少时间”。
6. 继续玩的三个方向与我的个人经验
6.1 从查表到 DDS:频率分辨率瓶颈的解法
查表法虽然直观,但存在一个明显的痛点:输出频率只能以“采样率 / 表长”的整数倍变化,想微调频率非常麻烦。DDS(直接数字频率合成)是更进阶的做法:引入相位累加器,每次中断给相位累加器加上一个频率控制字,高若干位用于查表,低位被自然截断。
这样做的好处是输出频率可以做到非常精细,只要调整频率控制字就行。代价是每周期采样点不再是严格等间距整数个点,波形会有轻微相位噪声,但对绝大多数应用都不是问题。在 MicroPython 里实现 DDS 也只需要多做一次整数加法,回调里仍然很轻量。
6.2 波形数据外部化:USB Host 与文件系统配合
小程序调试阶段,波形表写在代码里没有问题。但如果你想把波形做成“用户可上传”的功能,比如从电脑导出一段自定义数据到 U 盘,再让开发板读取并输出,就需要文件系统和存储支持。
部分平台已经提供了支持 USB Host 的 MicroPython 固件,比如某些 STM32 移植版或带有 USB Host 能力的开发板。在这种固件下,你可以接 U 盘读取 CSV 格式的波形数据,然后用 MCP4725 输出。这个思路很适合做教学演示或测量仪器的前端,不需要为每种波形重新烧录固件。
如果固件不支持 USB Host,也可以用 TF 卡模块存入波形表,原理是一样的。核心在于:波形表不一定非要在内存里现算,可以从文件加载。这能让整个系统变得更通用。
6.3 输出质量再提升:滤波、缓冲与基准
MCP4725 直接输出的正弦波,台阶会比较明显,尤其是每周期点数少的时候。最直接的改善方法是加一级 RC 低通滤波器,截止频率选在基频和采样频率之间。比如一个 100Hz 的正弦波,用 64 点输出,采样率约 6.4kHz,RC 截止频率可以取 1kHz 左右,衰减采样台阶,保留基频。
另一个很容易忽视的问题是输出驱动能力。MCP4725 的功耗和驱动有限,如果后面要接示波器以外的负载,建议加一级运放电压跟随器。轨到轨运放(比如 MCP6001)用起来很顺手,可以把 DAC 输出缓冲一下,隔离负载影响。
再讲究一点,可以给 MCP4725 的 VDD 加一个低噪声基准芯片(如 REF3033),因为输出直接跟随 VDD,如果 VDD 上有纹波,波形上就会直接体现出来。对实验演示来说,一块好的 LDO 加去耦电容已经够用。
6.4 我的个人经验
回过头来总结,用 MCP4725 做波形发生器,最值得的投资不是买高端工具,而是先把 I2C 时序和定时器频率的关系算清楚。很多人一上来就把定时器频率拉到 10kHz 以上,然后波形一塌糊涂,还以为是芯片坏掉,其实只是回调执行时间超过周期。
我个人的习惯是:先跑最慢的链路,确认电压正确,再逐步加速;每调高一档频率,都用示波器或逻辑分析仪记录一次波形,比较台阶均匀度和抖动。遇到问题先从时序预算入手,而不是盲目改代码。
另外,买 MCP4725 模块时要留意是不是有地址跳线,有些模块把 A0 固定接地,有些可以短接。如果以后想扩展双通道,就选支持地址跳线的模块,把两块芯片分别设为 0x60 和 0x61,这样同一总线上能输出两个独立的波形,很多基础控制应用就够用了。