📋 專案概述

週日晚上十點,客戶的感測板在我面前攤開:I2C 的 SDA 用萬用表量只有 1.2V。「像沒拉高」——但「像」不能交差。到底是上拉電阻沒焊到、總線被顆粒短路,還是韌體一直在吐位元組所以平均電壓被拉低?三種死因、三種處方,萬用表只看得到同一個 1.2V。那晚我真正需要的東西只有一個:能看到「電壓随時間的形狀」的儀器。

我手邊的選項清單很難看:

  1. 正經示波器:入門兩頻道機種 NT$4,000 起跳,佔半張書桌,而且我那台體積不小的 USB 示波器當時正在南部出差的行李箱裡。
  2. 手機 App 示波器:配接頭一千上下,頻寬 9kHz、App 每月訂閱——幫自己修東西還要月費,想都不用想。
  3. DSO138 套件:三百元是便宜,但那是「焊一個示波器」的專案,不是「拿示波器來焊東西」的工具,而且它沒外殼、旋鈕是裸露洞洞板。

這台 NT$225 的抽屜示波器定位誠實:它不取代正經示波器,它取代「想確認波形時手邊只有電表」的那個瞬間。功能清單:

頻段誠實標明: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 SuperMini1~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Ω、屏蔽線 30cm1 組~35 元×10 分壓;9MΩ 用 4 顆 2.2MΩ 串聯分壓負荷
保護(選配)BAT54S 鉗位二極體1~8 元把輸入鉗在 3V3/GND 之間,防靜態誤插
調理板4×6 cm 洞洞板1~30 元分壓與鉗位都焊在這片,可整片換
列印料PLA ~40g—~15 元外殼+旋鈕帽+探棒夾
合計~225 元約一杯手搖+一顿早餐,換一個抽屜里的波形判官

🔌 接線圖

ESP32-C3 SuperMini USB-C(供電+燒錄) GPIO0 GPIO5/6/7 GPIO8/9 3V3 GND BNC 輸入 1kΩ 串聯 9MΩ/1MΩ ×10 OUT→GPIO0 KY-040 旋編+按鍵 A B SW V0.5 T10ms Vpp 2.18V 1.9k SSD1306 0.96"(SDA8 SCL9) 紅 OUT→GPIO0(ADC1_CH0) 黃 A/B/SW → GPIO5/6/7 藍 SDA/SCL → GPIO8/9(I2C 預設腳) 黑 3V3/GND → OLED 與 KY-040(全系統共地)
▲ 全機就這四組線。訊號路徑(紅)盡量短、貼地平面走,是這台機器唯一的「 layout 學問」。

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)調理板 OUTC3 的 ADC1 在 GPIO0~4;GPIO2 鄰近 strapping 不建議,GPIO3 留作備援改接腳
GPIO5 / GPIO6 / GPIO7編碼器 A / B / SW全部啟用內部上拉;消抖由軟體 2ms 視窗處理
GPIO8 / GPIO9OLED SDA / SCLI2C 預設腳;它們同屬 strapping 域,只做 I2C、不開新用途
3V3 / GNDOLED、KY-040OLED 峰值 ~20mA,SuperMini 板載 LDO 撐得住
⚠️ 安全警告:本機 ×1 量程上限 3.3V、×10 探棒上限 24V。分壓電阻有工作電壓上限、PCB 爬電距離以毫米計,市電 110V、電源供應器高壓側、顯示器背板一律禁量——那些場景請用隔離式探棒的正經示波器。本專案的探棒設計給低壓電子桌用,不是給電力工程用的。

🎯 看懂這台示波器的螢幕

A ● R fs 14.8k Vpp 2.18V f 1.04k V/D:每大格電壓 t/D:每大格時間(15 大格 = 一屏) 觸發位準 ↑ 觸發點 Vpp 峰對峰
▲ 狀態列的 fs 是韌體每幀實測的取樣率,不是型錄數字——螢幕上寫多少就是多少。

🖨️ 3D 列印設計

零件清單

零件數量材質建議列印設定備註
斜 Face 機身(22°)1PLA0.2mm/3 壁/15%螢幕窗+旋鈕孔+BNC 孔全預留 0.3mm 間隙
面板壓框1PLA0.2mm/4 壁夾住 OLED 模組與 BNC 墊片,免膠可拆修
旋鈕帽(D 型軸)1PLA0.16mm/4 壁/60%側面刻指溝;軸孔 5.9mm+D 面銑平
探棒收納夾1PETG0.2mm/3 壁/40%夾 3mm 屏蔽線,貼機身側面螺絲孔
USB 應力靴1PETG0.2mm/3 壁/80%TPU 更好;防「踩到線扯爆板子」
高仰腳架(選配)1PLA0.24mm/3 壁/15%疊在機背上成 60° 立式,俯視工作台

設計要點

  1. 22° 斜 Face:示波器是「低頭工具」,平躺要俯身、直立要架高,22° 剛好坐着就能看到整屏格線
  2. 訊號區鐵籠思維:調理板獨立小片、BNC 外殼用銅箔貼片接到機內單點地,杜邦線全部往機背走——訊號路徑和數位線不平行、不捆綁
  3. 旋鈕軸承裙:旋鈕孔內圈做 0.15mm 過盈波紋,轉起來有段落感又不鬆;KY-040 本體用兩顆 M2 鎖在銅柱上,不靠針腳站樁
  4. 散熱與屏蔽取捨:全封閉殼對 15 kS/s 的 ADC 沒有熱問題;反而是殼內不要放散熱狂的 USB 集線器——它就在旁邊時取樣雜訊肉眼可見地變大
  5. 底部四止腳+磁吸片: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 分鐘,不做別上機)

  1. 零點 CAL_B:BNC 輸入端短接(或探棒夾 GND),讀序列埠/螢幕 raw 平均值,取負填為 CAL_B
  2. 斜率 CAL_K:輸入 1.000~1.500V 已知電壓(多表當裁判),CAL_K = 多表 mV ÷ raw 平均;校準點取中段,高端(>3V)非線性是 ESP32 ADC 天性
  3. 探棒比驗證:切 ×10 量同一顆電池,螢幕應與多表差 <5%(1MΩ 公差決定);超過就依實測改 probeToRaw/probeToMv 裡的 10 為 9.85 之類的實比值
  4. 探棒補償(選配):分壓頂端併 10pF 陶瓷電容、1MΩ 兩端併 3-10p 微調電容,對照韌體測試方波(見進階延伸)調到方角平整

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
  1. 列印零件:6 個零件;旋鈕帽與應力靴先用假件試軸
  2. 焊訊號調理板:先焊 1kΩ 串聯(×1 路徑),再焊 9MΩ/1MΩ 分壓;9MΩ 段用 4 顆 2.2MΩ 串聯分攤電壓應力
  3. 裝 BNC 與單點地:BNC 外殼銅箔貼片 → 機內單點地,避免接地迴路把 50Hz 攬進來
  4. 接 OLED 與編碼器:見接線表;面板壓框最後鎖,先裸機驗功能
  5. 燒錄與校準:Arduino IDE 選 ESP32C3 Dev Board → 燒錄 → 跑上方「上機校準」四步
  6. 合殼:旋鈕先卡軸再鎖板;USB 應力靴先穿線再插板
  7. 驗收:量手機螢幕閃爍(應見 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 屏蔽殼單點接地重做

🎯 進階延伸

🎯 結語

那顆客戶板的 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
← 上一篇:ESP32-S3 家庭對講機 📚 系列索引 下一篇:ESP32-S3 桌上型縮時攝影機 →