戰(zhàn):從聲卡采集到串口接入的完整指南)
簡(jiǎn)介基于Python的虛擬示波器項(xiàng)目面向電子工程、科研與教育領(lǐng)域需要觀察分析信號(hào)波形的人群借助matplotlib、numpy及PyQt等庫(kù)實(shí)現(xiàn)信號(hào)采集、實(shí)時(shí)顯示、濾波分析和多通道對(duì)比無(wú)需依賴實(shí)體示波器即可完成電路與信號(hào)實(shí)驗(yàn)。包內(nèi)共12個(gè)文件壓縮包僅20KB包含4個(gè)Python腳本主程序、波形繪制、數(shù)據(jù)處理等、PyQt界面文件、項(xiàng)目配置xml及pyc編譯緩存結(jié)構(gòu)精簡(jiǎn)。已有1155人學(xué)習(xí)下載。源碼涵蓋GUI控制、matplotlib波形繪制、numpy數(shù)據(jù)處理、自定義濾波、數(shù)據(jù)保存回放與多通道對(duì)比等核心邏輯可直接運(yùn)行或二次擴(kuò)展對(duì)想學(xué)習(xí)Python科學(xué)計(jì)算或希望用軟件模擬示波器進(jìn)行教學(xué)演示、課前驗(yàn)證的開(kāi)發(fā)者具有實(shí)用參考價(jià)值。1. python虛擬示波器用筆記本替代硬件示波器靠什么在嵌入式開(kāi)發(fā)和信號(hào)調(diào)試中示波器是發(fā)現(xiàn)問(wèn)題最快的工具但實(shí)體示波器價(jià)格高、體積大存儲(chǔ)深度還經(jīng)常不夠用。python虛擬示波器要做的事是把電腦自帶的聲卡、串口或 USB 設(shè)備當(dāng)成前端采集硬件再用 PyQtGraph 或 Matplotlib 把信號(hào)實(shí)時(shí)畫(huà)出來(lái)。你真正要自己實(shí)現(xiàn)的不只是畫(huà)線還包括采樣率切換、電壓標(biāo)定、觸發(fā)和測(cè)量。下面內(nèi)容從聲卡采集講到串口接入單片機(jī)最后落到觸發(fā)和性能調(diào)優(yōu)。適合想用筆記本替代示波器看波形、又不想買昂貴硬件的電子工程師和上位機(jī)開(kāi)發(fā)者如果你剛接觸 Python按章節(jié)順序裝包、跑代碼就行。2. 聲卡作為數(shù)據(jù)源python虛擬示波器的采集層與采樣率選擇虛擬示波器第一步是解決模擬信號(hào)的入口。不接外部硬件時(shí)你手里最便宜的高質(zhì)量 ADC 就是筆記本聲卡。聲卡的采集前級(jí)自帶抗混疊濾波器和可調(diào)增益比一塊裸的 ADC 芯片容易驅(qū)動(dòng)。這一章先把聲卡采集鏈路跑通讓后面的波形處理有穩(wěn)定的數(shù)據(jù)來(lái)源。2.1 采樣率與緩沖區(qū)大小的選擇標(biāo)準(zhǔn)聲卡常見(jiàn)的采樣率是 44100 Hz 和 48000 Hz部分設(shè)備能到 192000 Hz。根據(jù)采樣定理采樣率的一半才是理論上能顯示的最高頻率所以聲卡虛擬示波器的有效帶寬通常在 20 kHz 以下。顯示時(shí)還需要把數(shù)據(jù)切成固定大小的幀這就是 blocksize 的作用。采樣率 (Hz)奈奎斯特頻率 (kHz)blocksize1024 時(shí)單幀時(shí)長(zhǎng) (ms)4410022.0523.24800024.0021.319200096.005.3波形刷新率等于采樣率除以 blocksize。48000 Hz 采樣、1024 點(diǎn)一幀時(shí)每秒刷新約 47 幀肉眼已經(jīng)感覺(jué)不到閃跳。如果你一直卡在“畫(huà)面只有一條直線”先別懷疑繪圖代碼多半是設(shè)備枚舉沒(méi)對(duì)上采樣率。還沒(méi)裝環(huán)境的話先從 python.org 拿 3.10 以上的官方安裝包裝的時(shí)候勾上 Add python.exe to PATH然后執(zhí)行pip install sounddevice numpy scipy pyqtgraph pyserial這幾個(gè)包覆蓋了后面所有章節(jié)。2.2 用 sounddevice 打開(kāi)輸入流的最小代碼sounddevice 是 PortAudio 的薄封裝用它做采集比 pyaudio 少寫(xiě)一半代碼。下面的腳本會(huì)打開(kāi)默認(rèn)輸入設(shè)備把每一幀波形放進(jìn)隊(duì)列import sounddevice as sd import numpy as np from queue import Queue, Empty SAMPLERATE 48000 BLOCKSIZE 1024 CHANNELS 1 q Queue(maxsize8) def audio_callback(indata, frames, time_info, status): if status: print(聲卡狀態(tài)變化:, status) q.put(indata[:, 0].copy()) with sd.InputStream( samplerateSAMPLERATE, blocksizeBLOCKSIZE, channelsCHANNELS, dtypefloat32, callbackaudio_callback, ): while True: try: frame q.get(timeout2) except Empty: print(采集線程可能已經(jīng)斷開(kāi)) continue print(幀長(zhǎng)度, len(frame), 峰值, np.abs(frame).max())indata 的形狀是 (blocksize, channels)所以indata[:, 0]取出單通道波形copy 是為了避免復(fù)用緩沖把舊數(shù)據(jù)覆蓋。q 的 maxsize 設(shè)成 8是防止采集速度快于消費(fèi)速度時(shí)內(nèi)存無(wú)限膨脹。status 非空時(shí)通常意味著 underflow 或 overflow出現(xiàn)時(shí)先檢查 blocksize 是否太小。2.3 回調(diào)、隊(duì)列和 UI 線程的職責(zé)這段代碼的關(guān)鍵不是“開(kāi)一個(gè)音頻流”而是把采集和顯示完全解耦。PortAudio 的回調(diào)運(yùn)行在實(shí)時(shí)線程里如果你在這里執(zhí)行 matplotlib 繪圖或文件寫(xiě)入聲卡驅(qū)動(dòng)會(huì)認(rèn)為你處理不過(guò)來(lái)連續(xù)丟幀。采集線程唯一合法的動(dòng)作是把數(shù)據(jù)放入線程安全的隊(duì)列。UI 線程再以固定頻率從隊(duì)列取幀這樣即使繪圖卡頓 100 ms音頻線程也不會(huì)被拖死。提示不要在 callback 里調(diào)用任何繪圖 API。遇到線程崩潰先檢查是不是把 plot.update 放進(jìn) callback。3. numpy 信號(hào)處理電壓標(biāo)定、去除直流與降噪濾波把聲卡拿到的原始 float 數(shù)組直接畫(huà)出來(lái)你會(huì)遇到三個(gè)問(wèn)題波形帶著穩(wěn)定的直流偏移、噪聲比萬(wàn)用表讀數(shù)大、數(shù)值還停留在無(wú)量綱單位上。示波器與錄音軟件的區(qū)別就是顯示前要對(duì)時(shí)間序列做一層校正。這一層用 numpy 寫(xiě)起來(lái)很短。3.1 去除直流分量避免波形整體抬高筆記本內(nèi)置聲卡的輸入端是交流耦合但 ADC 本身會(huì)引入一個(gè)緩慢漂移的直流偏置。直接看原始數(shù)據(jù)波形中心會(huì)隨著溫度或增益變化上下移動(dòng)。第一個(gè)處理就是按幀減均值def remove_dc(frame: np.ndarray) - np.ndarray: return frame - np.mean(frame)均值是整個(gè)數(shù)據(jù)塊的直流分量減掉后波形圍繞 0 V 擺動(dòng)。低頻信號(hào)尤其需要這步因?yàn)樗闹芷诒纫粠瑪?shù)據(jù)長(zhǎng)時(shí)均值并不能代表真正的直流偏置波形會(huì)在幀邊界出現(xiàn)跳變。更穩(wěn)妥的做法是對(duì)連續(xù)數(shù)據(jù)流做一階高通濾波保留 5 Hz 以下的成分用來(lái)跟蹤偏置。濾波不是越強(qiáng)越好虛擬示波器追求的是“看到的波和真實(shí)信號(hào)一致”。一旦引入帶內(nèi)衰減相位就差出來(lái)了后面的觸發(fā)判斷也會(huì)偏移。這也是為什么很多開(kāi)源示波器默認(rèn)不做平滑只做直流移除。3.2 電壓標(biāo)定把無(wú)量綱數(shù)值變成伏特逐一對(duì)應(yīng)FULL_SCALE_V 2.4 # 這個(gè)值由實(shí)測(cè)標(biāo)定得到單位 Vpp volts processed * (FULL_SCALE_V / 2.0)聲卡輸入在 float32 下范圍是 -1.0 到 1.0對(duì)應(yīng) ADC 滿量程。如果滿量程是 2.4 Vpp那么 -1.0 代表 -1.2 V1.0 代表 1.2 V換算系數(shù)就是 2.4 / 2.0。注意這里“滿量程”不是聲卡標(biāo)稱最大輸入而是實(shí)際硬件接上信號(hào)后用萬(wàn)用表或標(biāo)準(zhǔn)信號(hào)源測(cè)出來(lái)的有效范圍不同聲卡差異很大。標(biāo)定過(guò)程也很直接輸入一個(gè)已知峰峰值的 1 kHz 正弦波把采集到的峰值記錄下來(lái)用實(shí)際電壓 / 采樣峰值算出標(biāo)定系數(shù)。把這個(gè)系數(shù)寫(xiě)進(jìn)配置顯示器上的 Y 軸就能標(biāo)注為 V。3.3 降噪濾波移動(dòng)平均與 Savitzky-Golay 的取舍方法適用條件參數(shù)建議簡(jiǎn)單移動(dòng)平均白噪聲為主波形邊緣不急窗口 3~7 點(diǎn)Savitzky-Golay波形本身平緩但疊加噪聲脈沖窗口 31階數(shù) 3from scipy.signal import savgol_filter filtered savgol_filter( frame, window_length31, polyorder3, )window_length 必須是奇數(shù)polyorder 不能大于窗口長(zhǎng)度。savgol_filter 對(duì)波形細(xì)節(jié)的保留比移動(dòng)平均好但高頻方波會(huì)被拉圓。如果你要觀察的還是觸發(fā)沿就別濾波先保證觸發(fā)正確再談降噪。4. PyQtGraph 繪制實(shí)時(shí)波形渲染與曲線顏色定制采集和處理都做完了接下來(lái)是讓波形穩(wěn)定滾動(dòng)起來(lái)。Matplotlib 的 animation 在 50 FPS 下 CPU 占用很高窗口縮放還會(huì)卡頓。pyqtgraph 直接繪制 OpenGL 圖形單曲線 6000 點(diǎn)輕松跑到 120 FPS。這一章的重點(diǎn)是搭建可復(fù)用的畫(huà)布并解決“如何改變曲線顏色”這個(gè)高頻問(wèn)題。4.1 為什么選擇 PyQtGraph不選 Matplotlib 動(dòng)畫(huà)Matplotlib 擅長(zhǎng)出版級(jí)靜態(tài)圖但它的重繪機(jī)制是整體重繪波形移動(dòng)時(shí)鋸齒和殘影明顯。pyqtgraph 把數(shù)據(jù)放在場(chǎng)景圖中調(diào)用setData只更新頂點(diǎn)緩沖響應(yīng)速度完全不在一個(gè)量級(jí)。虛擬示波器要求的是持續(xù)滾動(dòng)、低延遲、能穩(wěn)定縮放這是 pyqtgraph 的主場(chǎng)。4.2 最小實(shí)時(shí)波形窗口下面的腳本默認(rèn)你已經(jīng)有了第 2 章定義的q和SAMPLERATE這里只做 UI 部分import sys import numpy as np import pyqtgraph as pg from PyQt5 import QtCore, QtWidgets from queue import Empty app QtWidgets.QApplication(sys.argv) win pg.GraphicsLayoutWidget(showTrue) plot win.addPlot(titleCH1) plot.showGrid(xTrue, yTrue, alpha0.3) curve plot.plot(penpg.mkPen(#FFD700, width2)) def poll_queue(): try: frame q.get_nowait() except Empty: return y frame[:2048] t np.arange(len(y)) / SAMPLERATE curve.setData(t, y) timer QtCore.QTimer() timer.timeout.connect(poll_queue) timer.start(30) app.exec_()poll_queue 由 Qt 的事件循環(huán)驅(qū)動(dòng)每 30 ms 檢查一次隊(duì)列。取不到幀就直接返回不會(huì)阻塞 UI。setData是 pyqtgraph 增量更新的核心比重新創(chuàng)建 PlotDataItem 節(jié)省大量時(shí)間。把 y 截?cái)嗟?2048 點(diǎn)是為了限制單幀繪制規(guī)模刷屏?xí)r CPU 占用穩(wěn)定在低位。4.3 如何改變曲線顏色QPen 顏色表與刷新邏輯顏色問(wèn)題在示波器里很常見(jiàn)暗色背景下黃線最清楚但保存截圖后黃線變成白色反而看不清。處理思路是把顏色集中管理用函數(shù)切換而不是到處硬編碼。from pyqtgraph import mkPen COLOR_TABLE { yellow: #FFD700, cyan: #00FFFF, green: #00FF7F, red: #FF5500, } def set_curve_color(name: str): pen mkPen(COLOR_TABLE.get(name, #FFD700), width2) curve.setPen(pen) curve.setBrush(None)顏色常用場(chǎng)景黃色 #FFD700暗色背景主通道最醒目青色 #00FFFF第二通道線條細(xì)時(shí)辨識(shí)度高綠色 #00FF7F長(zhǎng)時(shí)間觀察波形時(shí)眼睛疲勞度低setBrush(None) 是為了不填充曲線下方的面積否則滾動(dòng)時(shí)會(huì)殘留彩色色塊。顏色切換與波形縮放獨(dú)立你甚至可以把它綁定到鍵盤(pán)快捷鍵方便調(diào)試時(shí)隨時(shí)換色。4.4 雙通道共用時(shí)間軸多通道時(shí)最怕兩個(gè)通道時(shí)間軸對(duì)不上。pyqtgraph 的做法是讓第二通道共享第一通道的 X 軸plot2 win.addPlot(row1, col0) plot2.setXLink(plot) curve2 plot2.plot(penmkPen(#00FFFF, width1))這樣鼠標(biāo)放大任何一路另一路的時(shí)間范圍跟著同步移動(dòng)。剩下的數(shù)據(jù)分發(fā)只需要在 poll_queue 里把幀拆成多個(gè)通道的數(shù)組分別送入 setData 即可。5. 串口數(shù)字示波器接入解析 Arduino/STM32 的采集幀不是所有信號(hào)都適合走聲卡。傳感器輸出、電機(jī)控制、STM32 調(diào)試引腳這些都直接接在串口上。串口數(shù)字示波器最常見(jiàn)的工作方式是把單片機(jī)當(dāng)成模擬前端。你搜索 serial_digital_scope v2 這類命名時(shí)看到的結(jié)構(gòu)基本也分兩層一個(gè)定時(shí)采集的固件一個(gè)解析串口的 Python 腳本。協(xié)議設(shè)計(jì)沒(méi)有神秘之處難點(diǎn)在幀邊界。5.1 單片機(jī)端的幀格式和協(xié)議選型用文本行傳數(shù)據(jù)最簡(jiǎn)單比如每行發(fā)512,723\n但邊界解析依賴換行符遇到高波特率時(shí)容易出現(xiàn)半個(gè)數(shù)據(jù)包。更穩(wěn)的做法是定義二進(jìn)制幀讓接收方通過(guò)幀頭和校驗(yàn)對(duì)齊。波特率文本行格式 9 字節(jié)二進(jìn)制幀 8 字節(jié)115200約 1280 幀/秒1440 幀/秒921600約 10240 幀/秒11520 幀/秒二進(jìn)制幀的效率和容錯(cuò)都更好。8 字節(jié)一幀在 115200 下等于每幀一次雙通道采集對(duì) 1 kHz 內(nèi)的信號(hào)足夠穩(wěn)定。需要更高采樣率時(shí)把 STM32 接到 USB 虛擬串口波特率不再限制傳輸速度。一個(gè)通用幀格式建議幀頭 2 字節(jié)0xAA 0x55通道 1uint16 little-endian通道 2uint16 little-endian校驗(yàn)通道1 與通道2 相加取低 8 位幀尾0x0D5.2 用 pySerial 讀幀并校驗(yàn)import serial ser serial.Serial(COM16, baudrate115200, timeout0.1) def read_scope_frames(ser, max_len512): buf bytearray() while True: chunk ser.read(64) if not chunk: continue buf.extend(chunk) while len(buf) 8: if buf[0] ! 0xAA or buf[1] ! 0x55: buf.pop(0) continue ch1 int.from_bytes(buf[2:4], little) ch2 int.from_bytes(buf[4:6], little) checksum (buf[2] buf[3] buf[4] buf[5]) 0xFF if checksum ! buf[6] or buf[7] ! 0x0D: buf.pop(0) continue yield ch1, ch2 del buf[:8]buf.pop(0) 在這里是線性操作但單片機(jī)的發(fā)送頻率通常不高可讀性優(yōu)先。int.from_bytes 的 little 參數(shù)必須和固件端一致否則兩通道會(huì)互相交換。校驗(yàn)失敗時(shí)丟掉一個(gè)字節(jié)而不是整幀是為了容錯(cuò)半包后的重同步。5.3 把串口數(shù)據(jù)接進(jìn)第 4 章 UI串口線程和聲卡線程本質(zhì)相同數(shù)據(jù)進(jìn)隊(duì)列UI 定時(shí)取。處理完一幀后把兩個(gè)通道的 numpy 數(shù)組傳給兩個(gè) curvefor ch1, ch2 in read_scope_frames(ser): t np.arange(len(frame)) / SAMPLERATE curve.setData(t, ch1) curve2.setData(t, ch2)如果數(shù)據(jù)源是 MCU時(shí)間軸不再是聲卡的 SAMPLERATE而是實(shí)際采樣間隔。固件發(fā)回一個(gè)時(shí)間戳或按固定次數(shù)標(biāo)記上位機(jī)用幀序號(hào)換算時(shí)間避免波形橫向漂移。6. 觸發(fā)、游標(biāo)與數(shù)據(jù)導(dǎo)出虛擬示波器的三個(gè)實(shí)用技巧6.1 用 NumPy 實(shí)現(xiàn)邊沿觸發(fā)沒(méi)有觸發(fā)的虛擬示波器只是錄音器波形會(huì)左右跳動(dòng)。邊沿觸發(fā)的核心是找信號(hào)穿越閾值的點(diǎn)def edge_trigger(data, level0.0, moderising): sign np.sign(data - level) diff np.diff(sign) idx np.where(diff 0)[0] 1 if mode rising else np.where(diff 0)[0] 1 for i in idx: if i 64: return i return None idx edge_trigger(frame) if idx is not None: seg_start idx - 64 view frame[seg_start:seg_start 1024] if seg_start 0 else frame[:1024]level 是觸發(fā)電平rising 對(duì)應(yīng)上升沿diff 0 表示信號(hào)由下往上穿越。跳過(guò)前 64 點(diǎn)等于預(yù)觸發(fā)區(qū)確保觸發(fā)點(diǎn)在窗口左側(cè)露出波形觸發(fā)前的狀態(tài)。6.2 游標(biāo)與電壓測(cè)量用pg.InfiniteLine加兩條可拖動(dòng)線vline pg.InfiniteLine(angle90, movableTrue, labelt{value:.3f}s) hline pg.InfiniteLine(angle0, movableTrue, labelU{value:.2f}V) plot.addItem(vline) plot.addItem(hline)拖動(dòng)后 label 自動(dòng)顯示所在坐標(biāo)值不需要額外寫(xiě)事件回調(diào)。測(cè)量頻率時(shí)把兩條豎線分別移到兩個(gè)相鄰周期的同相位點(diǎn)時(shí)間差取倒數(shù)就是頻率。6.3 導(dǎo)出 CSV 驗(yàn)證測(cè)量結(jié)果np.savetxt( scope_export.csv, np.column_stack((t, view)), delimiter,, headertime_s,ch1, comments, )導(dǎo)出后用 Excel 或 pandas 打開(kāi)檢查第一列時(shí)間間隔是否等于 1/SAMPLERATE。如果導(dǎo)出的數(shù)據(jù)時(shí)間間隔不對(duì)問(wèn)題一定出在采樣的幀邊界或?yàn)V波延遲回頭查第 3 章的高通濾波參數(shù)。觸發(fā)電平也建議先設(shè)到信號(hào)峰峰值的中間太小會(huì)讓噪聲觸發(fā)抖動(dòng)太大則可能錯(cuò)過(guò)真實(shí)沿這兩種情況在文本界面上都能直接看到觸發(fā)點(diǎn)索引的跳變。本文還有配套的精品資源點(diǎn)擊獲取