📋 專案概述
週日晚上十點,客戶的感測板在我面前攤開:I2C 的 SDA 用萬用表量只有 1.2V。「像沒拉高」——但「像」不能交差。到底是上拉電阻沒焊到、總線被顆粒短路,還是韌體一直在吐位元組所以平均電壓被拉低?三種死因、三種處方,萬用表只看得到同一個 1.2V。那晚我真正需要的東西只有一個:能看到「電壓随時間的形狀」的儀器。
我手邊的選項清單很難看:
- 正經示波器:入門兩頻道機種 NT$4,000 起跳,佔半張書桌,而且我那台體積不小的 USB 示波器當時正在南部出差的行李箱裡。
- 手機 App 示波器:配接頭一千上下,頻寬 9kHz、App 每月訂閱——幫自己修東西還要月費,想都不用想。
- DSO138 套件:三百元是便宜,但那是「焊一個示波器」的專案,不是「拿示波器來焊東西」的工具,而且它沒外殼、旋鈕是裸露洞洞板。
這台 NT$225 的抽屜示波器定位誠實:它不取代正經示波器,它取代「想確認波形時手邊只有電表」的那個瞬間。功能清單:
- 上升緣觸發:AUTO/NORMAL/SINGLE 三模式,遲滯+兩點確認抗雜訊,波形不飄
- 可調 V/D(100mV~20V/div)與 t/D(1ms~200ms/div):旋鈕單擊換參數、旋轉調值
- ×1/×10 被動探棒:長壓旋鈕切換,×10 分壓可看 24V 車用訊號(僅限低壓,見安全警告)
- AC/DC 軟體耦合:去掉直流位準看漣波,不用焊隔直流電容
- 量測直讀:Vpp/Vrms/平均值/頻率,每幀即時更新
- 取樣率螢幕自報:韌體每幀實測自己的取樣率贴在狀態列——這台不吹噓規格
頻段誠實標明:analogRead 迴圈實測約 15 kS/s,有效頻寬約 3 kHz。音訊、PWM 調光、I2C/UART 總線、各種感測器——這些佔桌上除錯的九成。要看 100 MHz?請買真正的示波器,本專案不冒充。
🔬 為什麼選 ESP32-C3(選型說明)
| 需求 | C3 的對應 |
|---|---|
| ADC 品質 | 12-bit SAR ADC1,GPIO0~4 五支可選,避開 strapping 腳還夠用 |
| 成本 | SuperMini 板 ~40 元,做壞不心疼;全部零件塞得進一個保鮮盒 |
| 體積 | 25 × 18 mm,22° 斜 Face 外殼裡還塞得下旋鈕與走線槽 |
| 供電與燒錄 | USB-C 一條線;本專案不用 Wi-Fi,單核跑取樣+繪圖 25 fps 綽綽有餘 |
| 不需要的 | 雙核、PSRAM、摄像头——那些是别的篇的事 |
📐 開發環境:arduino-esp32 core ≥ 3.0,開發板選
ESP32C3 Dev Board;螢幕用 Adafruit GFX / Adafruit_SSD1306 ≥ 2.5(Libraries Manager 直接裝)。本機記憶體全靜態配置,不吃 heap。🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控板 | ESP32-C3 SuperMini | 1 | ~40 元 | 各廠 SuperMini 皆可,GPIO0 為訊號輸入 |
| 螢幕 | SSD1306 0.96" I2C(0x3C) | 1 | ~55 元 | SH1106 版本也能點,預設腳就在 GPIO8/9 |
| 編碼器 | KY-040 旋編(附按鈕) | 1 | ~25 元 | 旋鈕帽 3D 列印(D 型軸) |
| 輸入座 | BNC Panel 母座 | 1 | ~25 元 | 外殼安裝式,屏蔽殼單點接地 |
| 探棒材料 | 9MΩ+1MΩ 金膜電阻、1kΩ、屏蔽線 30cm | 1 組 | ~35 元 | ×10 分壓;9MΩ 用 4 顆 2.2MΩ 串聯分壓負荷 |
| 保護(選配) | BAT54S 鉗位二極體 | 1 | ~8 元 | 把輸入鉗在 3V3/GND 之間,防靜態誤插 |
| 調理板 | 4×6 cm 洞洞板 | 1 | ~30 元 | 分壓與鉗位都焊在這片,可整片換 |
| 列印料 | PLA ~40g | — | ~15 元 | 外殼+旋鈕帽+探棒夾 |
| 合計 | ~225 元 | 約一杯手搖+一顿早餐,換一個抽屜里的波形判官 |
🔌 接線圖
ASCII 對照表
ESP32-C3 SuperMini 週邊 ────────────────── ───────────────────────────── GPIO0 ──────── 調理板 OUT (經 1kΩ;×10 時再串 9MΩ/1MΩ) GPIO5 ──────── KY-040 A 內部上拉 GPIO6 ──────── KY-040 B 內部上拉 GPIO7 ──────── KY-040 SW 內部上拉,單/雙/長壓 GPIO8 ──────── OLED SDA I2C 預設腳 GPIO9 ──────── OLED SCL 3V3 ──────── OLED VCC、KY-040 VCC GND ──────── 全部 GND + BNC 外殼單點 USB-C ──────── 供電 + 燒錄
| ESP32-C3 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO0 (ADC1_CH0) | 調理板 OUT | C3 的 ADC1 在 GPIO0~4;GPIO2 鄰近 strapping 不建議,GPIO3 留作備援改接腳 |
| GPIO5 / GPIO6 / GPIO7 | 編碼器 A / B / SW | 全部啟用內部上拉;消抖由軟體 2ms 視窗處理 |
| GPIO8 / GPIO9 | OLED SDA / SCL | I2C 預設腳;它們同屬 strapping 域,只做 I2C、不開新用途 |
| 3V3 / GND | OLED、KY-040 | OLED 峰值 ~20mA,SuperMini 板載 LDO 撐得住 |
⚠️ 安全警告:本機 ×1 量程上限 3.3V、×10 探棒上限 24V。分壓電阻有工作電壓上限、PCB 爬電距離以毫米計,市電 110V、電源供應器高壓側、顯示器背板一律禁量——那些場景請用隔離式探棒的正經示波器。本專案的探棒設計給低壓電子桌用,不是給電力工程用的。
🎯 看懂這台示波器的螢幕
fs 是韌體每幀實測的取樣率,不是型錄數字——螢幕上寫多少就是多少。🖨️ 3D 列印設計
零件清單
| 零件 | 數量 | 材質建議 | 列印設定 | 備註 |
|---|---|---|---|---|
| 斜 Face 機身(22°) | 1 | PLA | 0.2mm/3 壁/15% | 螢幕窗+旋鈕孔+BNC 孔全預留 0.3mm 間隙 |
| 面板壓框 | 1 | PLA | 0.2mm/4 壁 | 夾住 OLED 模組與 BNC 墊片,免膠可拆修 |
| 旋鈕帽(D 型軸) | 1 | PLA | 0.16mm/4 壁/60% | 側面刻指溝;軸孔 5.9mm+D 面銑平 |
| 探棒收納夾 | 1 | PETG | 0.2mm/3 壁/40% | 夾 3mm 屏蔽線,貼機身側面螺絲孔 |
| USB 應力靴 | 1 | PETG | 0.2mm/3 壁/80% | TPU 更好;防「踩到線扯爆板子」 |
| 高仰腳架(選配) | 1 | PLA | 0.24mm/3 壁/15% | 疊在機背上成 60° 立式,俯視工作台 |
設計要點
- 22° 斜 Face:示波器是「低頭工具」,平躺要俯身、直立要架高,22° 剛好坐着就能看到整屏格線
- 訊號區鐵籠思維:調理板獨立小片、BNC 外殼用銅箔貼片接到機內單點地,杜邦線全部往機背走——訊號路徑和數位線不平行、不捆綁
- 旋鈕軸承裙:旋鈕孔內圈做 0.15mm 過盈波紋,轉起來有段落感又不鬆;KY-040 本體用兩顆 M2 鎖在銅柱上,不靠針腳站樁
- 散熱與屏蔽取捨:全封閉殼對 15 kS/s 的 ADC 沒有熱問題;反而是殼內不要放散熱狂的 USB 集線器——它就在旁邊時取樣雜訊肉眼可見地變大
- 底部四止腳+磁吸片:4 顆 6×2mm 圓形磁鐵埋止腳孔,鐵面工作台隨手一貼就定位
🧵 列印備註:面板 OLED 開孔寧可銑大 0.3mm 再靠壓框夾正,也不要開小硬塞——壓裂玻璃是這殼第一大損工原因。
💻 程式碼
📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
/* ============================================================
* ESP32-C3 迷你桌上型示波器 | c3-mini-scope.ino
* ------------------------------------------------------------
* 功能:單通道軟體示波器 — 上升緣觸發(AUTO/NORMAL/SINGLE)、
* V/D 與 t/D 可調、×1/×10 探棒、AC/DC 軟體耦合、
* Vpp/Vrms/頻率量測、128x64 OLED 繪製、取樣率自報
* 硬體:ESP32-C3 SuperMini + SSD1306(I2C 0x3C) + KY-040 旋編
* 供電:USB-C 5V(本專案不用 Wi-Fi,省掉協定棧的取樣抖動)
* 相依:arduino-esp32 core >= 3.0、Adafruit GFX / SSD1306 >= 2.5
* 頻寬:analogRead 迴圈實測約 15 kS/s,適合 <3kHz 音訊/PWM/總線
* ============================================================ */
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
/* --- 1. 腳位與螢幕 ------------------------------------------- */
#define ADC_PIN 0 // ADC1_CH0。C3 建議 ADC 腳為 GPIO0~4;
// GPIO2 屬 strapping 域不建議、GPIO8/9 留給 I2C
#define ENC_A 5
#define ENC_B 6
#define ENC_SW 7 // 單擊=換參數 雙擊=觸發模式 長壓=暫停/繼續
#define I2C_SDA 8 // C3 的 Wire 預設腳,寫明是為了接手的人好查
#define I2C_SCL 9
#define SCREEN_W 128
#define SCREEN_H 64
#define OLED_ADDR 0x3C
Adafruit_SSD1306 oled(SCREEN_W, SCREEN_H, &Wire, -1);
/* --- 2. 校準常數(見「上機校準」章節,先量再玩)---------------
* 換算式:mV = raw * CAL_K + CAL_B
* CAL_K:輸入穩定的已知電壓(如 1.5V 電池串 1:1 分壓),
* 以「多表電壓 / 螢幕 raw 平均」求得
* CAL_B:輸入端短接 GND 後的殘留讀值(負值補正)
* 註:ADC 高端(>3V)非線性變陡,校準請取中段(1~2V)*/
float CAL_K = 0.805f; // 典型值:3.3V/4096 乘實測誤差後多在 0.79~0.83
int16_t CAL_B = 0;
/* --- 3. 取樣與版面 ------------------------------------------- */
#define N 400 // 取樣緩衝深度(400×(2+4)B ≈ 2.4KB 靜態 RAM)
#define PRE 40 // 觸發點前保留的取樣數:看到觸發「之前」的波形
#define GRID_X0 4
#define GRID_Y0 10 // 波形區頂緣(上方留 1 行狀態列)
#define GRID_W 120
#define GRID_H 44 // 44px = 5.5 大格 × 8px,中線在 GRID_Y0+22
#define PX_PER_DIV 8 // 每大格像素
#define NDIV (GRID_W / PX_PER_DIV) // 一屏 15 大格
const uint16_t VDIV_MV[] = {100, 200, 500, 1000, 2000}; // V/D(×1 範圍)
const uint32_t TDIV_US[] = {1000UL, 2000UL, 5000UL, 10000UL,
20000UL, 50000UL, 100000UL, 200000UL}; // 1ms~200ms/div
/* --- 4. 執行狀態 --------------------------------------------- */
uint16_t buf[N]; // raw ADC 值
uint32_t tUs[N]; // 逐點時間戳:analogRead 間隔不完美等距,
// 用真實時間畫 x 軸才不会「波形被拉伸」
uint8_t iVdiv = 2, iTdiv = 3;
int32_t trigMv = 1500; // 觸發位準(已含探棒比,單位 mV)
uint8_t trigMode = 0; // 0=AUTO 1=NORMAL 2=SINGLE
bool acCouple = false; // 軟體耦合:true=減均值看漣波
bool probeX10 = false; // ×10 探棒(長壓旋鈕切換)
bool running = true; // false=暫停,凍結當前畫面
uint16_t fsHz = 0; // 每幀實測取樣率,狀態列自報
uint8_t param = 0; // 旋鈕當前參數:0=V/D 1=t/D 2=觸發 3=耦合 4=探棒
/* --- 5. 換算工具 --------------------------------------------- */
/* probeToRaw —— 把「探棒換算後的 mV」轉成 raw ADC 值
* 參數:mv = 螢幕定義的電壓(×10 模式下可達 20000)
* 回傳:0~4095 鉗位後的 raw
* 為什麼先除探棒比:ADC 只看得见 GPIO0 上的電壓,
* ×10 時 20V 在腳上其實只有 2V */
int16_t probeToRaw(int32_t mv) {
int32_t pinMv = probeX10 ? mv / 10 : mv;
int32_t raw = (pinMv - CAL_B) / CAL_K;
return (int16_t)constrain(raw, 0, 4095);
}
/* rawToMv —— raw 轉回探棒端 mV(probeToRaw 的反函數) */
int32_t rawToMv(uint16_t raw) {
int32_t pinMv = (int32_t)(raw * CAL_K) + CAL_B;
return probeX10 ? pinMv * 10 : pinMv;
}
/* --- 6. 取樣與觸發 ------------------------------------------- */
/* fillBuffer —— 填滿一緩衝區,記錄逐點時間戳
* 不做任何平均:示波器要「真實的形狀」,平均會把尖峰抹平 */
void fillBuffer() {
for (int i = 0; i < N; i++) {
buf[i] = analogRead(ADC_PIN);
tUs[i] = micros();
}
// fs 自報:(N-1) 個間隔 ÷ 總時長,每幀更新一次
uint32_t span = tUs[N - 1] - tUs[0];
if (span > 0) fsHz = (uint16_t)((uint32_t)(N - 1) * 1000000UL / span / 10); // 單位 10Hz
}
/* findTrigger —— 在緩衝區找第一個通過位準的上升緣
* 做法:前一樣本 < 位準-遲滯、當前與下一樣本 >= 位準
* 遲滯 = max(16, 位準×10%):雜訊反覆穿越位準時,
* 沒遲滯会在同一緣上反覆觸發,畫面抖得比不觸發還難看 */
int findTrigger() {
int16_t lvl = probeToRaw(trigMv);
int16_t hyst = max(16, (int16_t)(lvl / 10));
for (int i = PRE; i < N - 2; i++) {
if (buf[i - 1] + hyst < lvl && buf[i] >= lvl && buf[i + 1] >= lvl) return i;
}
return -1;
}
/* acquire —— 取得一幀
* 回傳:觸發點索引;-1 代表逾時以「自由跑」畫面充數(AUTO 語意)
* NORMAL 模式不逾時:沒有觸發就一直等,這是它的賣點不是 bug */
int acquire() {
uint32_t t0 = millis();
uint32_t timeout = (trigMode == 0) ? 800UL : 60000UL;
while (true) {
fillBuffer();
int idx = findTrigger();
if (idx >= 0) return idx;
if (millis() - t0 > timeout) return -1;
}
}
/* --- 7. 量測(Vpp / Vrms / 平均 / 頻率)---------------------- */
struct Stats { int32_t vpp, vavg, vrms, freq; bool fValid; };
/* 顯示視窗最寬 = GRID_W(120px),每像素最多對應一個取樣,
* 視窗取樣上限 130 點 → 靜態陣列即可,不用 VLA、不吃 heap */
#define VWIN 130
/* calcStats —— 對「顯示視窗內」的取樣做量測
* 參數:from, to = 視窗範圍;couple = 是否 AC 耦合
* 頻率算法:以觸發位準上下各 5% 做遲滯穿越計數,
* 只數「上升穿越」,週期 = 首末上升點距離 / (次數-1) */
int32_t v[VWIN]; // 量測用電壓暫存(mV,已含探棒比)
Stats calcStats(int from, int to, bool couple) {
Stats s = {0, 0, 0, 0, false};
int n = to - from; if (n < 4) n = 4; if (n > VWIN) n = VWIN;
int32_t vmin = 0x7fffffff, vmax = -0x80000000, sum = 0;
for (int i = 0; i < n; i++) { v[i] = rawToMv(buf[from + i]); sum += v[i]; }
int32_t avg = sum / n;
// ×10 模式 30V 訊號平方和可達 1e11:必須 64-bit,long(32-bit) 會迴繞
long long sq = 0;
for (int i = 0; i < n; i++) {
vmin = min(vmin, v[i]); vmax = max(vmax, v[i]);
int32_t a = couple ? (v[i] - avg) : v[i];
sq += (long long)a * a;
}
s.vpp = vmax - vmin;
s.vavg = avg;
s.vrms = (int32_t)sqrt((double)(sq / n)); // AC 耦合時=漣波有效值;DC 時=原始有效值
// 頻率:視窗太短(不足一個週期)直接放棄,比亂跳的數字誠實
int32_t lvl = trigMv;
int32_t h = max(50L, labs(s.vpp) / 10);
int first = -1, last = -1, cnt = 0; bool low = (v[0] < lvl - h);
for (int i = 1; i < n; i++) {
if (low && v[i] > lvl + h) { // 遲滯上升穿越
uint32_t t = tUs[from + i];
if (first < 0) first = t; last = t; cnt++;
low = false;
} else if (!low && v[i] < lvl - h) low = true;
}
if (cnt >= 2) { s.freq = (int32_t)((cnt - 1) * 1000000UL / (last - first)); s.fValid = true; }
return s;
}
/* --- 8. 繪圖 -------------------------------------------------- */
void drawGrid() {
oled.drawRect(GRID_X0 - 1, GRID_Y0 - 1, GRID_W + 2, GRID_H + 2, SSD1306_WHITE);
int16_t mid = GRID_Y0 + GRID_H / 2;
for (int x = GRID_X0; x < GRID_X0 + GRID_W; x += PX_PER_DIV) // 垂直大格(點)
for (int y = GRID_Y0; y < GRID_Y0 + GRID_H; y += 4) oled.drawPixel(x, y, SSD1306_WHITE);
oled.drawLine(GRID_X0, mid, GRID_X0 + GRID_W, mid, SSD1306_WHITE); // 中線(0V/均值)
for (int y = GRID_Y0; y < GRID_Y0 + GRID_H; y += PX_PER_DIV) // 水平中軸刻度
for (int x = GRID_X0; x < GRID_X0 + 6; x += 2) oled.drawPixel(x, y, SSD1306_WHITE);
}
void drawStatus() {
oled.setTextSize(1);
oled.setCursor(0, 0);
oled.print(trigMode == 0 ? "A" : (trigMode == 1 ? "N" : "S"));
oled.print(acCouple ? " AC" : " DC");
oled.print(probeX10 ? " x10" : " x1");
oled.print(" V");
if (VDIV_MV[iVdiv] >= 1000) oled.print(VDIV_MV[iVdiv] / 1000); // V2
else { oled.print("."); oled.print(VDIV_MV[iVdiv] / 100); } // V.5
oled.print(" T");
if (TDIV_US[iTdiv] >= 1000) oled.print(TDIV_US[iTdiv] / 1000); // T20(ms)
else { oled.print("."); oled.print(TDIV_US[iTdiv] / 100); }
// 右上角:實測取樣率(fsHz 以 10Hz 為單位 → 顯示 xx.xk),不是型錄數字
char fs[8]; snprintf(fs, sizeof(fs), "%u.%uk", fsHz / 100, (fsHz / 10) % 10);
oled.setCursor(SCREEN_W - 5 * 6, 0); oled.print(fs);
}
void render(int trigIdx) {
int anchor = (trigIdx >= 0) ? trigIdx : N - 30;
float usPerPx = (TDIV_US[iTdiv] * (float)NDIV) / GRID_W;
float mvPerPx = (VDIV_MV[iVdiv] * (probeX10 ? 10.0f : 1.0f)) / PX_PER_DIV;
int16_t mid = GRID_Y0 + GRID_H / 2;
Stats st = calcStats(constrain(anchor - (int)(GRID_W * usPerPx / 30), PRE, N - 5), N, acCouple);
int32_t dcOff = acCouple ? st.vavg : 0; // AC 耦合:以均值為零線,漣波放大到屏中
// 觸發點放在約 1/3 屏寬處:左側留 pre-trigger 看「出事之前」,右側看後果
uint32_t tStart = tUs[anchor] - (uint32_t)(GRID_W * usPerPx * 0.33f);
int si = PRE; // 由左向右單調掃描:si 只進不退
int16_t prevX = -1, prevY = 0;
for (int px = 0; px < GRID_W; px++) {
uint32_t tWant = tStart + (uint32_t)(px * usPerPx);
while (si < N - 1 && tUs[si + 1] <= tWant) si++; // 找最接近目標時間的取樣
int16_t mv = (int16_t)(rawToMv(buf[si]) - dcOff);
int16_t y = constrain(mid - (int16_t)((float)mv / mvPerPx), GRID_Y0, GRID_Y0 + GRID_H - 1);
if (prevX >= 0) oled.drawLine(prevX, prevY, GRID_X0 + px, y, SSD1306_WHITE);
prevX = GRID_X0 + px; prevY = y;
}
// 觸發位準指示(右緣箭頭)與觸發點(↑)
int16_t lvlY = constrain(mid - (int16_t)((float)(trigMv - dcOff) / mvPerPx),
GRID_Y0, GRID_Y0 + GRID_H - 1);
oled.drawFastHLine(GRID_X0 + GRID_W - 4, lvlY, 4, SSD1306_WHITE);
if (trigIdx >= 0) {
int ax = constrain(GRID_X0 + (int)((tUs[trigIdx] - tStart) / usPerPx), GRID_X0, GRID_X0 + GRID_W - 1);
oled.drawTriangle(ax, GRID_Y0 + GRID_H - 5, ax - 2, GRID_Y0 + GRID_H - 1, ax + 2, GRID_Y0 + GRID_H - 1, SSD1306_WHITE);
}
// 下緣量測列:每 2 秒輪播兩頁——Vpp/頻率 一頁、Vrms/均值 一頁
static uint32_t pageFlip = 0; static bool page = false;
if (millis() - pageFlip > 2000) { pageFlip = millis(); page = !page; }
char line[22];
if (page) {
snprintf(line, sizeof(line), "Rms%ld.%02ld %c%ld.%02ldV",
st.vrms / 1000, (labs(st.vrms) % 1000) / 10,
st.vavg < 0 ? '-' : '+', labs(st.vavg) / 1000, (labs(st.vavg) % 1000) / 10);
} else if (st.fValid) {
snprintf(line, sizeof(line), "Vpp%ld.%02ld f%ld.%01ldk",
st.vpp / 1000, (st.vpp % 1000) / 10, st.freq / 1000, (st.freq % 1000) / 100);
} else {
snprintf(line, sizeof(line), "Vpp%ld.%02ld -Hz", st.vpp / 1000, (st.vpp % 1000) / 10);
}
oled.setCursor(0, SCREEN_H - 8);
oled.print(line);
}
/* --- 9. 人機介面(旋鈕與按鈕)-------------------------------- */
volatile int32_t encPos = 0;
void encA() { // 中斷只記數:4 倍頻解碼在 loop 做,省 ISR 時間
if (digitalRead(ENC_B) != digitalRead(ENC_A)) encPos--; else encPos++;
}
uint8_t btnState = 0; // 0=idle 1=held
uint32_t btnDownMs = 0, lastClickMs = 0;
bool longFired = false;
void handleButton() {
bool now = (digitalRead(ENC_SW) == LOW);
uint32_t ms = millis();
if (now && btnState == 0) { btnState = 1; btnDownMs = ms; longFired = false; }
else if (now && btnState == 1 && !longFired && ms - btnDownMs > 700) {
running = !running; // 長壓=暫停/繼續(凍結畫面好拍照)
longFired = true;
}
else if (!now && btnState == 1) {
btnState = 0;
if (longFired) return;
if (ms - lastClickMs < 300) { // 雙擊:切觸發模式
trigMode = (trigMode + 1) % 3; lastClickMs = 0;
} else { // 單擊:換參數
param = (param + 1) % 5; lastClickMs = ms;
}
}
}
/* applyKnob —— 把旋鈕增量套到當前參數
* 注意:V/D、t/D 是「對數檔位」,旋一格跳一檔;
* 觸發位準是線性的,一格 = 屏上 1 小格(V/D×10),
* 跟真示波器手感一致,不用眼睛對數字 */
void applyKnob(int32_t steps) {
if (steps == 0) return;
int8_t d = (steps > 0) ? 1 : -1;
switch (param) {
case 0: iVdiv = constrain((int)iVdiv + d, 0, 4); break;
case 1: iTdiv = constrain((int)iTdiv + d, 0, 7); break;
case 2: {
int32_t step = (VDIV_MV[iVdiv] / 10) * (probeX10 ? 10 : 1);
trigMv = constrain(trigMv + d * step, probeX10 ? -30000 : -3000,
probeX10 ? 30000 : 3000);
break;
}
case 3: acCouple = (d > 0); break; // 耦合是開關:往哪邊轉=開/關
case 4: probeX10 = (d > 0); break;
}
}
/* --- 10. setup / loop ----------------------------------------- */
void setup() {
Serial.begin(115200);
pinMode(ENC_A, INPUT_PULLUP);
pinMode(ENC_B, INPUT_PULLUP);
pinMode(ENC_SW, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(ENC_A), encA, CHANGE);
analogSetPinAttenuation(ADC_PIN, ADC_11db); // 11dB 衰減=滿量程約 3.1V;
// 用 6dB 會把可讀範圍壓到 2.2V,白白丟垂直解析度
analogSetWidth(12); // 明寫 12-bit,不靠預設
Wire.begin(I2C_SDA, I2C_SCL);
if (!oled.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
while (true) { Serial.println("OLED 不見了:查 SDA8/SCL9/供電/地址 0x3C"); delay(1000); }
}
oled.clearDisplay(); oled.setTextColor(SSD1306_WHITE);
oled.setCursor(0, 0); oled.print("C3 MiniScope boot..."); oled.display();
}
void loop() {
handleButton();
// 旋鈕:中斷記的是 4 倍頻跳變,湊滿一格(4 跳變)才送事件;
// 餘數存起來,半格反轉不會丟步數
static int32_t lastPos = 0, rem = 0;
int32_t p = encPos;
int32_t d = p - lastPos + rem;
lastPos = p; rem = d % 4;
if (running) {
applyKnob(d - rem);
oled.clearDisplay();
int trigIdx = acquire(); // 觸發等待發生在這裡
drawGrid();
drawStatus();
render(trigIdx);
if (trigMode == 2) { // SINGLE:捕獲一幀後凍結,
running = false; // 長壓旋鈕恢復,再捕下一幀
}
oled.display();
} else {
if (d - rem != 0) applyKnob(d - rem); // 暫停時仍可調參數,續播見效果
delay(20); handleButton(); // 暫停時只跑按鍵,省電也省 CPU
}
}
⚠️
acquire() 的 NORMAL 模式最長會死等 60 秒觸發——卡住畫面是設計不是當機。接上訊號或雙擊切回 AUTO 就恢復;等觸發期間螢幕右上 fs 停在最後一幀的數值。🎛️ 上機校準(10 分鐘,不做別上機)
- 零點 CAL_B:BNC 輸入端短接(或探棒夾 GND),讀序列埠/螢幕 raw 平均值,取負填為
CAL_B - 斜率 CAL_K:輸入 1.000~1.500V 已知電壓(多表當裁判),
CAL_K = 多表 mV ÷ raw 平均;校準點取中段,高端(>3V)非線性是 ESP32 ADC 天性 - 探棒比驗證:切 ×10 量同一顆電池,螢幕應與多表差 <5%(1MΩ 公差決定);超過就依實測改
probeToRaw/probeToMv裡的 10 為 9.85 之類的實比值 - 探棒補償(選配):分壓頂端併 10pF 陶瓷電容、1MΩ 兩端併 3-10p 微調電容,對照韌體測試方波(見進階延伸)調到方角平整
🏗️ 組裝步驟
📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
- 列印零件:6 個零件;旋鈕帽與應力靴先用假件試軸
- 焊訊號調理板:先焊 1kΩ 串聯(×1 路徑),再焊 9MΩ/1MΩ 分壓;9MΩ 段用 4 顆 2.2MΩ 串聯分攤電壓應力
- 裝 BNC 與單點地:BNC 外殼銅箔貼片 → 機內單點地,避免接地迴路把 50Hz 攬進來
- 接 OLED 與編碼器:見接線表;面板壓框最後鎖,先裸機驗功能
- 燒錄與校準:Arduino IDE 選
ESP32C3 Dev Board→ 燒錄 → 跑上方「上機校準」四步 - 合殼:旋鈕先卡軸再鎖板;USB 應力靴先穿線再插板
- 驗收:量手機螢幕閃爍(應見 60Hz 鋸齒)、LED 手電筒PWM 調光、手指靠近天線的 50Hz 感應波
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| 波形一直橫向漂移 | 觸發位準落在雜訊區內 | 把位準轉到波形中段,或雙擊切 NORMAL 強制等真觸發 |
| 高頻方波邊角圓了 | 探棒線太長、無補償、杜邦線當訊號線 | 探棒線縮到 20cm 內;訊號路徑加鉗位二極體的小 PCB;這是 15 kS/s 的天花板,別跟 100 MHz 較勁 |
| 頻率讀值亂跳 | 雜訊反覆穿越觸發位準 | 量測已內建 5% 遲滯;仍跳就離遠一點開關電源再量 |
| 讀值比電表高 2~4% | ADC 增益誤差與電源紋波 | 屬正常;照校準流程把 CAL_K 量回來就收斂到 1% 內 |
| 螢幕完全沒反應 | SH1106 模組地址是 0x3C/0x3D 兩種皆有 | 改 OLED_ADDR 0x3D 再試;SDA/SCL 別對調 |
| 旋鈕轉了沒反應/半格丟失 | 跳變去抖吃掉脈衝 | 換高品質 KY-040;或在 encA() 前加 100pF 到 GND |
| 量哪都是 50Hz 毛波 | 探棒地夾未夾、接地迴路 | 探棒地夾就近夾 GND;BNC 屏蔽殼單點接地重做 |
🎯 進階延伸
- 測試方波輸出:多佔一支 GPIO 用
ledcWrite輸出 1kHz 方波回接輸入——校補償、驗頻寬一站到家(代價:這就是「用示波器看自己」) - 改 DMA 連續取樣:換
esp_adc連續模式 + DMA,取樣率可上 100 kS/s、抖動歸零——頻寬約再翻三倍,程式多 80 行(留給你,fs 自報功能會立刻驗證有沒有寫對) - 雙通道偽裝:ADC 加一顆開關多工器分時輪扫兩訊號,兩條曲線同屏(取樣率減半,看兩路 PWM 相位差夠用)
- Wi-Fi 上位機:C3 連自家 AP,把每幀 400 點 POST 到 NAS 網頁繪製大圖——桌上儀器的螢幕變瀏覽器分頁(第 30 篇 Strata 站現成收留)
- 鋰電版:TP4056 + 2000mAh,關 Wi-Fi 關序列埠,待機 40 分鐘後 deep sleep,野外量車用訊號不用拖行動電源
🎯 結語
那顆客戶板的 SDA 最後查出來:上拉電阻焊了,但 4.7k 的位置上焊了一顆 0Ω——總線一直在吐位元組,平均電壓被拉在 1.2V。把示波器探棒搭上去的第三秒就看見了:密密麻麻的位元組方波,中間夾著 STOP 條件的爬升。「萬用表給你一個數字,示波器給你一個故事」——而一個 225 元的故事收集器,讓那晚準點收工。
📦 檔案清單
c3-mini-scope/
├── src/
│ └── c3-mini-scope.ino # 本頁完整韌體
├── stl/
│ ├── scope-body.stl # 22° 斜 Face 機身
│ ├── panel-frame.stl # 面板壓框
│ ├── knob.stl # D 型軸旋鈕帽
│ ├── probe-clip.stl # 探棒收納夾
│ ├── usb-boot.stl # USB 應力靴
│ └── riser-stand.stl # 60° 高仰腳架(選配)
├── f3d/
│ └── c3-mini-scope.f3d # Fusion 360 原始檔
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md