生器)
1. 為什么信號發(fā)生器不選 PWM而選 MCP4725 這類 DAC你可能已經(jīng)在無數(shù) LED 調(diào)光例程里用過 PWM也知道拿 PWM 加一級 RC 低通濾波后理論上能湊出一個模擬正弦波。但我必須說如果你的目標(biāo)是做一個能測硬件環(huán)路響應(yīng)、音頻小信號路徑、或者給外圍電路注入連續(xù)可調(diào)電壓包絡(luò)的信號發(fā)生器別繞這個彎路。MCP4725 是 MicroPython 生態(tài)里最常出現(xiàn)的 12 位 I2C DAC它把數(shù)字值直接變成真實(shí)的模擬電壓配合自定義類封裝后一個幾十行的腳本就能變成一臺微型信號源。1.1 PWM 做波形的天生缺陷PWM 的本質(zhì)是固定頻率下的脈沖寬度調(diào)制它輸出的只有高電平和低電平兩種狀態(tài)。要變成正弦波必須依靠外部濾波器把方波里的高頻分量濾掉而這會帶來兩個麻煩濾波器的截止頻率和波形頻率需要匹配一旦你要輸出 1 Hz 到 1000 Hz 范圍可調(diào)的信號模擬濾波器的設(shè)計(jì)就很頭疼。PWM 分辨率上升時載波頻率通常也要跟著上升否則你會看到明顯的階梯紋波。即便你用 RP2040 的 16 位 PWM 硬件占空比能細(xì)到 1/65536它輸出的仍然是一長串?dāng)?shù)字脈沖而不是真正的連續(xù)電壓。對于模擬電路測試來說這相當(dāng)于偽模擬。1.2 MCP4725 的指標(biāo)適合什么場景MCP4725 是一個單通道、12 位、I2C 接口的 DAC輸出范圍從 GND 到 VREFVREF 通常直接接電源。它的輸出建立時間很短官方標(biāo)稱約 6 μs這意味著每個 DAC 值寫下去引腳上會快速穩(wěn)定到對應(yīng)電壓。它非常適合這類場景慢變傳感器信號模擬、電機(jī)控制器的給定電壓、音頻幅度包絡(luò)、0~3.3V 可編程參考電壓、最高幾百赫茲的小信號測試源。注意我這里強(qiáng)調(diào)幾百赫茲因?yàn)?12 位 I2C DAC 的更新速率不是無限快后面我會專門展開計(jì)算這個瓶頸。選它的另一個現(xiàn)實(shí)原因是模塊極其便宜3.3V/5V 兼容版本很多且是標(biāo)準(zhǔn) I2C 器件地址默認(rèn) 0x60也能通過 A0/A1 引腳改到 0x61。對 MicroPython 玩家來說machine.I2C配兩行代碼就能驅(qū)動幾乎沒有學(xué)習(xí)成本。2. 搭建最小硬件環(huán)境I2C 接線、上拉與電平匹配在寫類之前先把物理鏈路搞定。我見過太多人代碼寫得沒問題結(jié)果波形不對最后發(fā)現(xiàn)是接線問題。2.1 你需要的東西MicroPython 開發(fā)板一塊。ESP32、RP2040、pyboard 都可以這里以 ESP32 為例。MCP4725 模塊一塊至少在 3.3V 下工作。面包板、杜邦線若干。萬用表用來校準(zhǔn)輸出精度有條件再上示波器。接線非常簡單MCP4725 引腳接法VCC3.3V若要 0~5V 輸出可接 5V但 MCU 的 SCL/SDA 需要電平轉(zhuǎn)換GNDGNDSCLI2C 時鐘線比如 ESP32 的 GPIO22SDAI2C 數(shù)據(jù)線比如 ESP32 的 GPIO21OUT信號輸出接示波器探頭或萬用表紅筆GND同時作為測量參考地2.2 上拉電阻和電平坑I2C 總線的 SCL/SDA 是開漏結(jié)構(gòu)必須有上拉電阻。大部分 MCP4725 模塊會自帶 10K 上拉到 VCC所以你可能不需要外部額外接。但要注意如果模塊供電是 5V而上拉電阻也接到了 5V那 3.3V 的 MCU 引腳會吃不消輕則通信不穩(wěn)定重則燒 IO。穩(wěn)妥做法是給模塊也供 3.3V把量程固定在 0~3.3V這樣不需要任何電平轉(zhuǎn)換。如果你確實(shí)需要 0~5V 輸出那就得用 5V 供電并加一個 I2C 電平轉(zhuǎn)換板這是另一套方案本文不展開。2.3 驗(yàn)證 I2C 通信把腳本塞進(jìn)板子之前先用一個最簡掃描腳本確認(rèn)能發(fā)現(xiàn)設(shè)備from machine import Pin, I2C i2c I2C(0, sclPin(22), sdaPin(21), freq400_000) print(i2c.scan())如果打印結(jié)果里有[96]恭喜地址 0x60 的 MCP4725 已經(jīng)在線。如果沒有先查供電、共地、上拉再查 SCL/SDA 是否反了。這里順便提一句很多人搜索 MicroPython 固件時會看到支持 USB host 的 micropython 固件這類版本但那主要面向 USB 外設(shè)擴(kuò)展和 I2C DAC 無關(guān)。官方針對你芯片型號的穩(wěn)定版固件就直接夠用不要被額外功能帶偏。3. 自定義類的骨架從寫寄存器到抽象信號源剛開始玩 MCP4725 時我也是在代碼里直接寫i2c.writeto(...)。直到某次項(xiàng)目里既要輸出正弦波又要輸出鋸齒波和方波才發(fā)現(xiàn)程序亂成一團(tuán)。這時候最合理的做法就是寫一個自定義類把DAC 寄存器寫入和波形的邏輯拆開。3.1 MCP4725 的寫入命令到底在寫什么MCP4725 每幀至少 3 字節(jié)第一個字節(jié)是命令字0x40代表寫入 DAC 寄存器但不動 EEPROM后面兩個字節(jié)是 12 位數(shù)值的拆分。12 位的 DAC 值叫value范圍 0~4095。它被塞進(jìn)兩個字節(jié)時要進(jìn)行比特拼接高字節(jié)(value 4) 0xFF因?yàn)?12 位右移 4 位后高 8 位落到這個字節(jié)。低字節(jié)(value 0x0F) 4因?yàn)榈?4 位要放到低字節(jié)的高半字節(jié)里低半字節(jié)填 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]) )這段代碼是后面所有波形輸出的基石。不同驅(qū)動庫可能讓你直接寫 12 位數(shù)值但底層做的工作就是上面這個移位拼接。3.2 SignalGenerator 類的字段設(shè)計(jì)自定義類的目標(biāo)是讓上層使用者不關(guān)心寄存器。我希望代碼里能這樣調(diào)用gen SignalGenerator(i2c, vref3.3, addr0x60) gen.set_waveform(sine) gen.set_frequency(100) gen.set_amplitude(0.8) gen.run(loop_callbackyour_function)那么類里至少需要維護(hù)這些狀態(tài)波形類型、期望頻率、振幅比例、直流偏置、查找表、當(dāng)前相位、I2C 對象和地址。振幅和偏置非常重要。MCP4725 只能輸出 0 到 VREF 的電壓沒有負(fù)壓輸出所以任何波形都要做平移處理。比如正弦波默認(rèn)以 VREF/2 為中心振幅 1.0 就是滿幅擺動 0~VREF振幅 0.5 則是 0.25VREF~0.75VREF。這種可調(diào)性在模擬電路測試?yán)飿O其有用。3.3 查找表應(yīng)該加在這個類里一個最常見的錯誤是在定時中斷里臨時調(diào)用math.sin()計(jì)算每個輸出值。千萬別這么干MicroPython 的浮點(diǎn)三角函數(shù)慢得嚇人定時器回調(diào)里做這運(yùn)算會把波形頻率拖垮。正確做法在類初始化時根據(jù)波形類型生成一張查找表lookup table把每個采樣點(diǎn)對應(yīng)的 12 位 DAC 值預(yù)先算好。運(yùn)行時只需要查表、維護(hù)索引、寫寄存器。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 的幅度。后續(xù)要加振幅、偏置就在那里做映射code int((offset (norm - 0.5) * amplitude) * 4095)。這樣同一個查找表生成函數(shù)能復(fù)用到任意波形。4. 波形生成核心查找表 分頻器 輪詢循環(huán)類框架建好之后核心問題變成了多快更新一個點(diǎn)。信號發(fā)生器的頻率不是直接寫在硬件寄存器里的而是靠每秒輸出的采樣點(diǎn)數(shù)和查找表長度共同決定。4.1 頻率與采樣率的關(guān)系假設(shè)查找表有N個點(diǎn)每個點(diǎn)輸出后等待dt秒再輸出下一個那么走完一輪表的時間是N * dt這就是信號的周期。因此輸出頻率是f 1 / (N * dt)同理如果目標(biāo)頻率是f采樣周期dt 1 / (N * f)舉個例子你有 64 點(diǎn)正弦表想輸出 100 Hz那么每個點(diǎn)的更新間隔是1 / (64 * 100) 156.25 μs。這個間隔對 MicroPython 來說還算能跑。如果換成 1000 Hz間隔就變成 15.6 μs在純 Python 循環(huán)里幾乎不可能穩(wěn)定做到。所以設(shè)計(jì)類的時候points和freq是兩個需要一起考慮的變量。我的經(jīng)驗(yàn)是低頻時用 256 點(diǎn)保證波形光滑高頻時降到 32 點(diǎn)甚至 16 點(diǎn)優(yōu)先保證頻率可達(dá)。4.2 用輪詢代替硬實(shí)時中斷MicroPython 的machine.Timer能做定時中斷但中斷回調(diào)里執(zhí)行 I2C 寫入存在兩個隱患第一不同開發(fā)板的 Timer 精度和回調(diào)開銷不同第二如果回調(diào)執(zhí)行時間超過定時周期波形會明顯抖動甚至丟失采樣點(diǎn)。我更推薦在主循環(huán)里用time.ticks_us()做非阻塞輪詢。代碼更直觀也更容易調(diào)試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: # 溢出說明輸出速度跟不上需要減少點(diǎn)數(shù)或降頻 deadline time.ticks_us()tick()做的事情就是查表、寫 DAC、推進(jìn)相位def tick(self): self.write_raw(self.values[self._phase]) self._phase (self._phase 1) % self.points用time.ticks_add和time.ticks_diff的好處是自動處理 32 位計(jì)數(shù)器回繞長時間運(yùn)行也不會出 bug。4.3 為什么自定義類里要區(qū)分設(shè)置頻率和啟動輸出頻率設(shè)定影響dt_us的計(jì)算所以類里提供set_frequency()它只改參數(shù)不立刻改變輸出。真正開始輸出時run()里才根據(jù)最新參數(shù)計(jì)算間隔。這種設(shè)計(jì)能避免在輸出過程中修改參數(shù)導(dǎo)致相位跳變。如果你希望在運(yùn)行中改頻率可以在設(shè)置頻率后重新計(jì)算dt_us甚至平滑地改變讓信號從當(dāng)前頻率漸變到目標(biāo)頻率——這個進(jìn)階功能也很適合放在類里。5. 實(shí)測示波器看到的波形和代碼里的參數(shù)是怎么對應(yīng)的寫完類最興奮的一步是接上示波器看波形。我建議按以下順序驗(yàn)證能少踩很多坑。5.1 先測直流電壓再測波形先別急著出正弦波。調(diào)用一次write_raw(0)測量 OUT 對 GND 電壓正常是接近 0V。再調(diào)用write_raw(4095)測量電壓應(yīng)該接近 VREF。這里第一次暴露的問題往往是模塊 VCC 實(shí)際不是 3.3V而是 3.28V 或 3.4V輸出值和理論值有小偏差。這時就需要校準(zhǔn)把實(shí)際測得的滿量程電壓存到類里的vref字段不要硬編碼 3.3。后續(xù)代碼里的電壓-編碼換算會更準(zhǔn)。5.2 用 64 點(diǎn)正弦表觀察 100 Hz 波形按上面的示例把points64、頻率設(shè)為 100 Hz示波器上應(yīng)該看到一個明顯的階梯狀正弦波。每個階梯高度相等這是 DAC 在量化的結(jié)果不是壞了。階梯越多波形越光滑階梯少時可以看到折線。可以用示波器的頻率測量功能驗(yàn)證輸出頻率。如果你設(shè)置的 100 Hz 實(shí)際讀到 98 Hz多半是輪詢循環(huán)里time.sleep_us的精度誤差累積。這種情況下可以適當(dāng)減小points或者換用ticks絕對時間調(diào)度來校正。5.3 方波的上升沿和 I2C 更新速率方波是最考驗(yàn)更新速率的波形。它只需要兩個值0 和 4095但跳變時機(jī)必須精確。我把方波表設(shè)為 2 點(diǎn)即[0, 4095]此時周期輸出就是兩個點(diǎn)交替。如果目標(biāo)是 500 Hz每個點(diǎn)間隔 1ms輸出非常干凈。但如果追求更高的方波頻率比如 5 kHz那么一個周期只有 200 μs兩個點(diǎn)各 100 μsMicroPython 的循環(huán)開銷和 I2C 寫入時間接近這個量級上升沿會變得不穩(wěn)定。此時你其實(shí)應(yīng)該考慮用 PWM 或?qū)S妹}沖發(fā)生器芯片而不是硬磕 DAC。6. 限制到底在哪I2C 速率、采樣率與波形質(zhì)量的平衡這部分是很多人最容易忽略的。MCP4725 的理論最大更新速率和 MicroPython 實(shí)際能達(dá)到的有效更新速率差了一個數(shù)量級。6.1 一幀 I2C 寫入的時間預(yù)算一次完整寫入至少發(fā)送 1 個地址字節(jié)加 3 個命令數(shù)據(jù)字節(jié)命令 高字節(jié) 低字節(jié)共 4 個字節(jié)。I2C 總線上每字節(jié)要額外帶一個 ACK 位所以實(shí)際是 9 個 bit。如果總線跑 400 kHz那么傳輸 4 字節(jié)理論時間是4 * 9 / 400000 90 μs這還沒算machine.I2C.writeto的 MicroPython 調(diào)用開銷、對象屬性和字節(jié)列表構(gòu)造時間。實(shí)測下來ESP32 上每次write_raw()大概要 150~250 μs。也就是說你能穩(wěn)定輸出的采樣點(diǎn)頻率大約在 4~6 kHz。結(jié)合查找表長度N可得N64時最高穩(wěn)定輸出約5000/64 78 HzN32時最高穩(wěn)定輸出約 156 HzN16時最高穩(wěn)定輸出約 312 Hz這個上限不是你代碼寫得不好而是 I2C Python 解釋器的物理限制。6.2 用表格對比不同輸出方案方案實(shí)用更新速率波形質(zhì)量實(shí)現(xiàn)難度MCP4725 MicroPython I2C幾千次/秒低頻平滑高頻階梯明顯低SPI 接口 DAC如 MCP4921幾萬次/秒能輸出幾十 kHz中硬件 PWM RC 濾波取決于 PWM 載波有紋波低FPGA/DDS 專用芯片遠(yuǎn)高于 MCU高質(zhì)量寬頻高如果你看到網(wǎng)上有人用 FPGA 做信號發(fā)生器比如搜索熱詞里常見的 ego1 開發(fā)板那是完全不同路子FPGA 用并行邏輯直接驅(qū)動高速 DAC采樣率可以到幾十兆。普通 MicroPython 開發(fā)板不需要和它較勁MCP4725 解決的是輕量、慢速、可在腳本層面改波形的問題。6.3 實(shí)在要提速怎么辦想榨干 I2C DAC 的潛力可以從三處優(yōu)化把i2c.writeto的每次調(diào)用都盡量減少字節(jié)分配。預(yù)分配一個bytearray([0x40, 0, 0])寫入時只改第二、第三字節(jié)避免每次新建 bytes。不要讓tick()里做任何if判斷和日志打印。輸出循環(huán)內(nèi)只做查表和寫寄存器。將查找表從 list 改為array(H)減少內(nèi)存訪問開銷也節(jié)省 RAM。即便如此你依然跑不過硬件方案。所以我對這個項(xiàng)目的定位是一個可編程波形源而不是高性能測量儀器。放在自動化測試臺上作為輔助信號激勵完全夠用。7. 擴(kuò)展把信號發(fā)生器變成可交互的測試工具類已經(jīng)能穩(wěn)定跑波形了但你大概率不想每次改頻率都改代碼重刷固件。給它加一個人機(jī)接口才算真正從代碼到項(xiàng)目。7.1 用電位器直接調(diào)頻率MCP4725 是 DAC但開發(fā)板上通常還有 ADC。用一個 10K 電位器、一個 ADC 輸入引腳就能實(shí)現(xiàn)頻率粗調(diào)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 刷新一次頻率設(shè)定值不影響波形更新的定時循環(huán)。因?yàn)閟et_frequency()只更新參數(shù)不用打斷當(dāng)前正在輸出的相位。7.2 用命令行菜單切換波形把一個簡單的 REPL 菜單掛進(jìn)主程序按自然語言切換波形def prompt(gen): cmd input(wave? [sine/square/triangle/saw] ) gen.set_waveform(cmd.strip()) print(ok, now:, gen.wave)這樣配合 Thonny 或串口終端在調(diào)試現(xiàn)場就能改輸出形狀。若在局域網(wǎng)環(huán)境還可以啟用 WebREPL手機(jī)上就能控制不用連串口線。7.3 把類文件保存成可復(fù)用模塊最后將SignalGenerator類存成signalgen.py放到開發(fā)板/lib目錄下。項(xiàng)目腳本里只需要from machine import Pin, I2C from signalgen import SignalGenerator這種模塊化的好處是以后做任何帶 DAC 的項(xiàng)目都能直接引包不用到處復(fù)制粘貼寄存器操作代碼。也方便寫自動化測試腳本按預(yù)設(shè)的波形序列依次輸出給被測電路做掃頻。我自己在實(shí)際項(xiàng)目里的習(xí)慣是把輸出 0.5V 直流校準(zhǔn)電壓和輸出 1 kHz 正弦波做成兩個預(yù)設(shè)命令每次測試前先跑一遍自檢。因?yàn)?MCP4725 是單通道 DAC沒有同步多路需求所以給每個測試點(diǎn)掛一塊成本也很低。MCP4725 不是萬能的但它把用代碼產(chǎn)生模擬信號這件事的入門門檻拉得非常低。這篇流程走完你已經(jīng)擁有了一個可控制、可校準(zhǔn)、可復(fù)用的微型信號發(fā)生器類。之后同樣的思路換到 SPI DAC 上也只是把write_raw里的字節(jié)格式改一改整個架構(gòu)完全能繼續(xù)沿用。