2026年10月22日 星期四

ESP32 雙核分工:OTA 服務跑 Core 0、應用跑 Core 1——實作紀錄與踩坑筆記

緣起

這片 ESP32 D0WD(WROOM-32,4MB flash)之前的 OTA 韌體(v1.1)已經跑通:ArduinoOTA(UDP 3333 協定)、雙 AP 自動切換(家裡 AP1 / 公司 AP2)、狀態網頁。

但下一個問題很實際:以後要寫別的應用(感測、馬達、MCU 通訊)到這片板子,如果新應用沒把 OTA 帶進去,就不能免 USB 更新了。

所以定了一個架構原則:OTA 是「服務」,不是某一顆韌體的功能 — 讓它常駐在一個核心、永遠在線;應用放在另一個核心,隨便換。

架構

ESP32 是雙核 Xtensa,FreeRTOS 可以把任務釘(pin)在指定核心:

核心 角色 內容
Core 0 常駐服務 WiFi 雙 AP 連線 + 30 秒重試、ArduinoOTA(UDP 3333)、狀態網頁(:80)
Core 1 應用 你的業務邏輯(範例:藍燈 1Hz + 計數)

檔案구조(share-folder 與 Pi5 各一份,保持同步):

ESP32/
├── common/wifi_config.h       # 共用 WiFi/OTA 設定(AP1 家 / AP2 公司 / OTA 密碼)
├── esp32_ota/                 # 純 OTA 韌體 v1.1(首次 USB 刷機可用)
└── esp32_app_template/        # 雙核應用模板 v2.0
    ├── esp32_app_template.ino # 主程式:啟服務 + 建應用任務
    ├── ota_service.h          # 對外只暴露 startOtaService()
    ├── ota_service.cpp        # Core-0 常駐:WiFi/OTA/狀態頁
    └── README.txt

新應用做法:複製整個 esp32_app_template/ 為專案名,只改 .ino 裡標了 ★★★ 的區塊與 LED 腳位,服務檔不動。

核心程式

// esp32_app_template.ino — 先啟服務,再起應用
void setup() {
  Serial.begin(115200);
  startOtaService();   /* Core 0:WiFi + OTA + 狀態頁 */
  /* Core 1:你的應用 */
  xTaskCreatePinnedToCore(appTaskFunc, "app", 8192, nullptr, 2, nullptr, 1);
}

// ota_service.cpp — tryConnect 關鍵段
for (size_t i = 0; i < 2; i++) {
  WiFi.disconnect(true, aps[i].ssid);
  WiFi.setMinSecurity(WIFI_AUTH_WPA2_PSK);   /* WPA2 地板,可向上協商 WPA3 */
  WiFi.begin(aps[i].ssid, aps[i].pass);      /* ★ 這行漏掉會災難(見踩坑 #1) */
  unsigned long t0 = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 12000) delay(250);
  if (WiFi.status() == WL_CONNECTED) return (uint8_t)(i + 1);
}

更新流程:

  1. 首次:USB 或 IDE 刷(板子還沒 WiFi 時)。
  2. 之後:python3 ota_flash.py <bin> --ip <板子IP>(或 --ip esp32.local)。
  3. 或用 Arduino IDE:Tools → Port → IP Network,直接 Upload。

驗證結果(2026-10-22)

  • 公司 AP2 1.3 秒關聯成功,IP 192.168.1.170,RSSI -38
  • 狀態頁 :80 全綠:韌體 2.0.0、連線 AP、核配置「Core 0=OTA/WiFi/網頁 | Core 1=應用」
  • OTA 自我刷機(模板 963KB)成功,自動重啟、自動重連
  • 應用任務(core 1)1Hz 藍燈 + tick 計數正常

踩坑筆記(重點)

1. tryConnect 漏了 WiFi.begin(浪費一整天)

v1.1 的試連函式有 WiFi.begin(ssid, pass),移植到任務裡的版本漏了這一行 — 變成「每 12 秒乾等一次狀態」,任何 AP 都「失敗」。症狀非常騙人:

  • 掃描看得到 SSID
  • 關聯每次都 12 秒超時
  • 另一台裝置(筆電)在同一個 AP 上正常連線
  • 斷電重開、換 2.4G 熱點、換密碼都沒用

最後逐行 diff v1.1 vs v2.0 才發現就缺這一行。教訓:看到「掃得到、關聯超時、別台裝置正常」,第一步先看自己的韌體有沒有真的下 WiFi.begin,別急著往 AP 端 / WPA3 / 5GHz / 供電 / 射頻硬體推。

2. esp32 core 3.3.12 的 API 差異

慣用寫法 3.3.12 實際
xTaskCreatePinnedToTask(...) 是 xTaskCreatePinnedToCore
LED_BUILTIN 不存在,自定義(本板藍燈 = GPIO2)
WiFi.encryptionType() 不存在
ArduinoOTA.getType()/getErrorString() 不存在(error 碼 0=AUTH…4=END)
Client c = server.available() Client 是抽象(要用 WiFiClient);available() 已被 accept() 取代(警告)
WiFi.disconnect(true, ssid) 簽名 (bool, unsigned long),第二參不是 SSID
WiFi.disconnectReason() 只有 disconnectReasonName(code),code 自己帶
mDNS 掛 arduino 服務 MDNS.enableArduino(port, auth)(與 ArduinoOTA 自註冊重複會打「Service already exists」ERROR,無害)

3. ArduinoOTA 協定(3.3.x)不是常見的 TCP 推流

流程:傳控制資料包到板子 UDP 3333 → 板子回 OK(或 AUTH nonce 走 PBKDF2 認證)→ 板子主動 TCP 連回你的聽取埠 → 你送出 .bin 原始位元組 → 板子定期回十進位 ack → 驗完 MD5 回 OK 並重啟。ota_flash.py(純標準函式庫,任何有 Python 3 的機器都能跑)就是照這個實作。

4. OTA 密碼

WIFI_OTA_PASS 留空 = 無認證,同一網路任何人可刷你的板。公司/對外網路建議填密碼(腳本 --password 參數配合)。

5. 板子 IP 與網路綁定

IP 是 DHCP 給的,換網路(家↔公司)會變。找不到就:--ip esp32.local(mDNS)、開狀態頁、或 ping 掃。

6. 腳位紅線(這片 D0WD)

  • GPIO 6–11:內部 flash,禁用
  • 34/35/36/39:只讀
  • 32/33 是 ADC1,WiFi 開著仍可量;25/26/27 是 ADC2,WiFi 一開就讀不到
  • 0/2/15 是 strapping,2/15 當輸出要注意上電狀態
  • 藍燈 = GPIO2

7. 小工具坑

cp 來源檔進目錄時,目標若只寫到目錄沒帶檔名,檔案會以來源檔名掉進目錄 — 我們因此製造出一個 os.cpp,連結時報「multiple definition」,花了一輪才找到。(同步檔案時目標永遠寫全路徑。)

結論

一個可以直接照抄的配方:

OTA 服務 = Core 0 常駐任務(WiFi + ArduinoOTA + 狀態頁);應用 = Core 1 可替換任務;兩方共享一份 wifi_config.h — 以後每個新應用都是「生來就有 OTA」,USB 只在首次亮相。

整組原始碼在 Arduino/Source/FW/ESP32/(share-folder 與 Pi5 各一份),模板說明在 esp32_app_template/README.txt。

2026年10月21日 星期三

[OpenAI API] 用免費額度:從 Tier 確認到 Hermes 接入的完整流程 + 踩坑記錄

前言:為什麼要把 Hermes 接上 OpenAI

Hermes 預設跑本機 Ollama 模型(qwen3.8:27b、gpt-oss:20b),優點是免費、不花錢;缺點是本機 ARM 機上 tok/s 不高、長 context 慢。想讓某一條 profile(例如 designer)用 OpenAI 的快模型,只要把 API key 接上、把模型指過去——這篇文章整理完整流程、實際踩到的坑,以及「免費額度」到底怎麼算。

一、先搞清楚:OpenAI 的「免費額度」

開啟 OpenAI API「ChatGPT 的每日免費額度(Data Sharing Free Tier)」,該功能在後台頁面設定 OpenAI API Rate limits。
以下是完整的設定步驟教學:
步驟 1:確認您的帳戶狀態
從您的圖片中可以看到,您目前是 Pay-as-you-go(按量計費) 的付費帳戶,且 API 餘額(API credit balance)為 $X.XX。
  • 💡 這代表您已經符合解鎖免費額度的第一個門檻(帳戶必須有至少 $5 的儲值紀錄,扣除目前餘額後符合資格)。

步驟 2:前往資料控制頁面(Data Controls)
您目前的畫面是「帳務中心 (Billing)」,此設定並不在這裡。請依照以下步驟切換頁面:
  1. 點擊網頁左側選單: 在瀏覽器左側的導覽列中,點擊最下方的 「Settings」(設定,齒輪圖示)。
  2. 進入組織設定: 在 Settings 展開的子選單中,點擊 「Organization」。
  3. 找到資料控制: 接著在組織選單內,點擊 「Data controls」 頁籤。

步驟 3:開啟資料共享
進入 Data controls 頁面後,您會看到一個關於模型訓練與隱私的區塊。
  1. 找到名為 「Share inputs and outputs with OpenAI」(與 OpenAI 共享輸入和輸出)的切換開關。
  2. 將這個開關手動調整為 「開啟 (ON)」。

💡 設定完成後的注意事項

  1. 免費額度如何生效: 開啟共享後,OpenAI 系統每天會自動重置並存入固定的免費 Token 額度


確認目前帳號層級(實測步驟)

  1. 開 https://platform.openai.com(要登進 API 平台,不是 ChatGPT)
  2. 上方選單 Settings → Limits / Billing
  3. 會看到:
  4. Current usage tier:Tier 1 / 2 / 3
  5. Credits remaining:剩多少免費額度
  6. 各模型的 rate limit(RPM / TPM)— 以該頁表格為準,各模型、各 tier 數字不同

小提示:同一個 API key 對應的 org 才有 tier;如果你有多個帳號,先確認你要用哪個,key 也是那個帳號發的。

二、建立 API Key

  1. platform.openai.com → API keys → Create new secret key
  2. 記下 key(以 sk- 開頭)。離開頁面後就再也看不到完整 key——存進檔案或密碼管理器
  3. 權限建議依需求最小化;別用超級管理員 key
  4. 這個 key 就是帳單歸屬:之後 Hermes 每打一個 token 都算在你這把 key 上

三、把 OpenAI 接到 Hermes(實測流程)

以下每個指令都在 designer profile 實測過(2026-10-21):

1. 把 key 放進 .env(金鑰絕不過 chat)

# 用你的 profile 的 .env(預設在 ~/.hermes/.env,profile 各有獨立一份)
echo 'OPENAI_API_KEY=***' >> ~/.hermes/profiles/designer/.env
chmod 600 ~/.hermes/profiles/designer/.env

Hermes 的約定:secret 放 .env,一般設定放 config.yaml。放對了,log / 輸出 / 回應都會自動把 key 遮蔽成 ***。

2. 切換 provider 和模型

hermes --profile designer config set model.provider openai
hermes --profile designer config set model.default gpt-4o-mini

gpt-4o-mini 是「聰明 / 便宜 / 快」三者平衡的常見選擇;要更強可以換更高階模型,但單次成本會差几十倍,先跑 mini 驗證通話再升。

3. 清掉 Ollama 殘留(最容易漏)

如果這個 profile 之前指向本機 Ollama,config 裡會有兩個欄位蓋過你的 OpenAI 設定:

model:
  base_url: http://127.0.0.1:11434/v1/    # 這行會讓所有請求打到本機
  api_key: ollama                          # 這行會讓 OpenAI 401

必須清:

hermes --profile designer config unset model.base_url
hermes --profile designer config unset model.api_key

沒清的話,你會看到「OpenAI API 401 / 找不到模型」的假象,其實是請求被打回 127.0.0.1:11434。

4. 重啟 gateway

每支 profile 有獨立的 systemd user service,例如 designer 是 hermes-gateway-designer。重啟指令要在 gateway 外面的 terminal 跑(gateway 內部會擋住自我重啟):

systemctl --user restart hermes-gateway-designer

5. 驗證(兩層)

# 層 1:CLI 直跑,確認 OpenAI 真的回
hermes --profile designer chat -q 'Reply with exactly: OK'

層 2:在 designer 的 Telegram bot 發 /status,應看到:

Model: gpt-4o-mini

四、真正的坑:舊 session 還鎖著舊模型

改完 config、重啟完 gateway,發 /status 卻看到 gpt-oss:20b(本機模型)——那是這個 session 之前用 /model 切過的舊值。Hermes 允許「session-level model override」,會蓋過 config 預設。

解法(二擇一):

  1. 在這個 bot 對話發 /new — 開新 session,吃 config 預設 = gpt-4o-mini
  2. 或在現 session 發 /model gpt-4o-mini — 本 session 直接切

/new 是乾淨做法;/model 是「保留這個 session 的記憶 / context」的做法。

五、注意事項(成本、Tier、安全)

1. 成本:context 比你以為的大

Hermes 每一回合都帶 system prompt + tools + 歷史 context,一次 API call 進到 10 萬 token 很平常。gpt-4o-mini 單 turn 大概 $0.01~0.03;更高階模型可能一個 turn $3~15。建議:

  • 開 Billing → Set budget alert,把月預算鎖在 $5~$20,超了會收到 mail
  • 跑長任務前先估:每回合平均 input token × 單價 × 預估回合數

2. Tier 卡住(429 Too Many Requests)

Tier 1 的 RPM 很緊,連發幾條就 429。解法:降頻、換更便宜的模型、累積消費升 Tier(門檻以 Billing 頁顯示為準)、或用 hermes fallback 接備援 provider。

3. API key 安全

  • 永遠不要把 key 貼在 chat、Telegram、GitHub 或任何公開處
  • Hermes 的 secret-redaction 會遮蔽輸出,不代表你可以亂放
  • 如果 key 洩漏:立刻回平台 Revoke,重發一把、改 .env、重啟 gateway

4. .env 權限

chmod 600 ~/.hermes/profiles/designer/.env

只有 owner 可讀;多 user 主機上尤其重要。

5. 別混 OpenAI 和 Ollama

同一台機器上,OpenAI 打 api.openai.com,Ollama 打 127.0.0.1:11434。不要指望 .env 同時開兩個 provider 然後自動切——那要用 model_aliases + key_env 明確指定。

6. 本地模型仍是備援

OpenAI 掛、429、斷網時,fallback 回本機 gpt-oss:20b 或 qwen3.8:27b 能繼續跑(慢但可用)。用 hermes fallback add 設好。

小結

步驟 一句話
看 Tier platform.openai.com → Billing / Limits
建 key API keys → Create,離開頁面就看不到了
.env OPENAI_API_KEY=sk-…(600 權限、不入 chat)
config set model.provider openai + set model.default gpt-4o-mini
清 Ollama 殘留 unset model.base_url + unset model.api_key(漏掉最悲)
重啟 gateway 外面的 terminal 跑重啟指令
驗證 CLI chat -q + bot /status
舊 session /status 還舊模型 → 發 /new
控成本 Billing 設 budget alert,鎖 $5~$20/月

接上 OpenAI 之後,Hermes 的 designer profile 就能跑雲端模型,成本可預期、Tier 可觀察、本地模型留當備援。這套流程在 coder、tester 或任何 profile 都可以重用。

2026年10月19日 星期一

[DIY專案] 4x4x4 LED 立方體:用 Arduino Mega2560 + 麥克風 + 光感做出「會呼吸的音箱」

這一個 DIY 專案是「4×4×4 64 顆 LED 的互動式立方體」:用 Arduino Mega2560 做控制核心,外接麥克風模組與光感(LDR),讓立體光譜「聽得到、看得見」——音量越大,底層越亮;環境越暗,頂層抬頭;中層隨時間流動,像一盂會呼吸、會應答的水晶球。


為什麼要「多工」而不能直接 64 腳

64 顆 LED 若各自一個 IO,需要 64 腳——任何 Arduino 都給不起。解法是矩陣掃描(multiplexing):陣列在製作時就把 16 顆 LED 的同一面焊在一起(陽極共接=「行」),另一面也共接(陰極共接=「列」)。任一瞬間只有「1 行 × 1 列」交叉的那一顆 LED 有電流通過;但把這 16×16=256 個交點每秒掃幾百遍,人眼殘像就讀成「64 顆同時亮」——和螢幕的「逐行刷新」是同一个道理。

本專案用 Mega2560 剛好:16 行 + 16 列 = 32 個數位腳位,一個不少。


料件清單

料件 數量/規格 說明
4x4x4 LED 陣列 64 顆 3mm LED(本例清/藍)、16pin+16pin 排針 陣列陽極共接成 16 行、陰極共接成 16 列
Arduino Mega2560 1 片 32 個數位腳位剛好吃滿:16 行 + 16 列
聲音感測器(咪頭模組) 1 個(電子元件店的藍板電容咪) 接 A0,模擬量随聲音大小跳動
光感 sensor(LDR) 1 個(10kΩ 光電分壓) 接 A1,亮度 → 電阻 → 模擬值
ULN2003(或 P-MOS 陣列) 1~2 片 列(陰極)需要大電流拉低,IO 直接拉不動 64 顆
電阻 1kΩ × 16 行(陽極)限流,每行串一顆
杜邦線 / 麵包板或 PCB — 引腳 32+2,麵包板要够大
5V 供電 3A 以上 64 顆全亮峰值 ≈ 1.5~2A,USB 口未必穩,建議獨立 5V

接線總覽

接線總覽

物件 目標
陣列 16 行(陽極) 各串 1kΩ → Mega D0~D15
陣列 16 列(陰極) → ULN2003 輸入 → 輸出 → Mega D16~D31
咪頭模組 VCC/GND/OUT 5V / GND / A0
LDR 分壓(10kΩ 下拉) 節點 → A1
GND 全部共地(最重要,浮地 = 亂亮)

控制端程式(重點:掃描)

核心邏輯三件事:(1) 感測 每 30ms 讀一次咪頭/光感,把「音量→底 2 層,暗→頂層」寫進 cube[4][4][4] 模型;(2) 掃描 每幀只點亮「一行」該亮的列,16 次輪巡;(3) 幀間隔 120µs 左右,64 顆全亮的亮度 ≈ 實際的 1/16,所以限流電阻要比一般直驅稍小,或接受稍暗——這是多工的公定代價:

/*
 * 4x4x4 LED 立方體 — 聲音+光度互動版
 * 硬體: Arduino Mega2560
 *   16 行(陽極) → D0..D15   (經 1kΩ x16 限流)
 *   16 列(陰極) → D16..D031 (經 ULN2003 拉低)
 *   麦克风模块 AIN  → A0  (模擬)
 *   光感 (LDR 分壓) → A1
 */

const uint8_t ROWS = 16;
const uint8_t COLS = 16;
const uint16_t ROW_PIN[16]  = {0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15};
const uint16_t COL_PIN[16]  = {16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31};

// cube[x][y][z] → 行 = y*4+z (層內 z 從下到上), 列 = x*4+y
bool cube[4][4][4] = {0};

void setup() {
  for (uint8_t i = 0; i < 16; i++) {
    pinMode(ROW_PIN[i], OUTPUT);
    pinMode(COL_PIN[i], OUTPUT);
  }
  for (uint8_t i = 0; i < 16; i++) { digitalWrite(ROW_PIN[i], HIGH); digitalWrite(COL_PIN[i], HIGH); }
  Serial.begin(115200);
}

uint16_t scanRow = 0;

void loop() {
  /* ---------- 1. 讀感測 → 更新 cube ---------- */
  static uint32_t lastSense = 0;
  uint32_t now = millis();
  if (now - lastSense > 30) {          // 30ms 感測一次
    lastSense = now;

    int16_t mic  = analogReadA0avg(20);   // 平均20次降噪
    int16_t light= analogReadA1avg(20);
    uint16_t micDB   = map(mic,   200, 800, 0, 1023);  // 依你的音量範圍校正
    uint16_t darkPct = map(light, 1023, 0, 0, 1023);   // 越暗越大

    /* 規則: 底層=音量, 頂層=光線, 中層=呼吸 */
    uint8_t vol  = (uint8_t)(micDB   / 1024.0 * 4);      // 0..3(音量4層)
    uint8_t lig  = (uint8_t)(darkPct/ 1024.0 * 4);       // 0..3
    uint32_t t = now / 200;                                // 呼吸節拍
    for (uint8_t x = 0; x < 4; x++)
      for (uint8_t y = 0; y < 4; y++)
        for (uint8_t z = 0; z < 4; z++) {
          if      (z < 2) cube[x][y][z] = (z < vol);                          // 音量填底2層
          else if (z == 3) cube[x][y][z] = (lig <= 1);                        // 暗 → 頂層亮
          else            cube[x][y][z] = ((t + x + y) % 4 == 0);             // 中層流動
        }
  }

  /* ---------- 2. 掃描:一次一行,快掃讓人眼殘像為「64 顆同時亮」 ---------- */
  for (uint8_t i = 0; i < 16; i++) digitalWrite(ROW_PIN[i], HIGH);   // 全熄
  uint8_t r = scanRow;
  scanRow = (scanRow + 1) & 0b1111;

  uint8_t y = r >> 2, z = r & 3;
  digitalWrite(ROW_PIN[r], LOW);                          // 選行 (common-anode: 拉低 = 供電)
  for (uint8_t c = 0; c < 16; c++) {
    uint8_t x = c >> 2, y2 = c & 3;
    bool on = (y2 == y) && cube[x][y][z];
    digitalWrite(COL_PIN[c], on ? HIGH : LOW);            // 拉高 = 通電
  }
  delayMicroseconds(120);                                  // 16x120us ≈ 1.9ms/幀 → ~500 fps
}

/* 20 次類比取平均, 擋住模組的抖動 */
int16_t analogReadA0avg(uint8_t n){ long s=0; for(uint8_t i=0;i<n;i++){s+=analogRead(A0); delayMicroseconds(60);} return s/n; }
int16_t analogReadA1avg(uint8_t n){ long s=0; for(uint8_t i=0;i<n;i++){s+=analogRead(A1); delayMicroseconds(60);} return s/n; }

調試時最常遇到的三個坑

  • 浮地=亂亮:陣列、ULN2003、感測模組的 GND 沒有共地,會看到「隨機一兩顆閃」——不是程式錯,是地線錯。共地是最優先的一條線。
  • 電流不夠,亮一半:USB 直供的 500mA 撐不住 64 顆峰值,會看到「上半截亮、下半截暗」或 Mega 重新開機。獨立 5V/3A 一接就好。
  • 掃描太慢,閃爍肉眼可見:16 幀總時間 > 50ms 就会看到「一行一行跑」。把 delayMicroseconds 收到 80~150µs 區間,或降低亮層數。

玩法建議(感測→效果)

  • 音量柱:底層亮 = 音量高;拍手/說話 = 立方體「長高」,最直觀的音頻視覺化。
  • 暗室模式:LDR 越暗,頂層越亮 + 掃描頻率越慢,變成一盂「夜燈」;手遮光 → 瞬間熄滅。
  • 風暴模式:音量 > 閾值 → 随机閃爍 2 秒(類似警報燈),閾值在 map() 裡調。
  • 呼吸模式:中層随 sin(t) 慢速起伏,不用感測也好看,做「常亮桌面小物」用。

結語

這個專案把「多工、電流、時序」三個電子學核心一次走完:64 顆 LED 靠「快」而不是「多」跑起來——人眼看不見的是 500fps 的掃描,感覺到的是 64 顆同時亮。做完它,LED 矩陣、OLED、甚至 VGA framebuffer 的「逐行刷新」就全部通了。

[學習FPGA] DE10-Lite + VHDL:從長出第一條電路開始

這一次要學的是「最底層」的語言——不是寫在 CPU 上的指令,而是直接長出電路的語言:VHDL(VHSIC Hardware Description Language)。載體是手上這塊 Terasic DE10-Lite 開發板:

FPGA 與 CPU:同一塊矽,兩種玩法

CPU 是「通用引擎」:同一顆晶片靠指令流跑任何程式。FPGA(Field Programmable Gate Array)則是「白地」:一整片由數萬個可配置閘(LUT + 觸發器)組成的矩陣,你的程式碼會被翻譯成一條條真實的電路——寫完、編譯、燒錄之後,計數器就真的「存在」晶片的矽上了。沒有「取指令、解譯」這回事:每個時脈週期,所有電路同時在動。

這就是為什麼 50MHz 的時脈可以直接餵給計數器:VHDL 寫的不是「程式」,而是硬體描述——你描述的是「電路長什麼樣、每個時脈週期發生什麼」,不是「依序做哪些事」。

DE10-Lite 上有哪些資源

  • Altera MAX 10 FPGA(10M02SAE144I7G):約 1.2 萬個 logic element,學完所有基礎專題綽綽有餘
  • 4 顆 7 段顯示器(HEX0~3):16 進位,是看「內部數字」最直接的方法
  • 10 顆開關 SW、10 顆 LED、4 顆按鍵 KEY0~3(KEY0 = Reset、KEY1 = Clock,注意低有效)
  • 50MHz 時脈(CLOCK_50):所有入門專題的標準心跳
  • SDRAM、USB、VGA、100-pin 排針——進階專題(framebuffer、UART、影像)留給後面

工具鏈:Quartus Prime

Altera(現 Intel)自家的 Quartus Prime 是這塊板子的「編譯器」:VHDL → 綜合(synthesis)→ 佈線(placement & routing)→ 產生 .sof 設定檔 → 燒進 FPGA。Lite 版編 MAX 10 免費可用,學生完全夠。固定流程:

  1. Project → Add File 放進 .vhd,Device 選 10M02SAE144I7G
  2. Pin Planner 把 port 對到板子腳位:CLOCK_50、LEDR[9..0]、SW[7..0]、HEX0..3
  3. Compilation 全套編譯,看 timing 報告——50MHz 要求 20ns 週期,入門設計通常裕量很大
  4. Programmer 走 USB 燒錄;上電後你的「電路」才會真正跑起來

第一個電路:LED 閃爍

50MHz 的時脈,人眼完全看不見(一秒翻轉 5000 萬次)。所以第一步永遠是時脈分頻:數到 2500 萬再翻一次位 = 2Hz,眼睛就看得見了。

library IEEE;
use IEEE.std_logic_1164.ALL;

entity led_blink is
    Port (
        clk : in  std_logic;                          -- 50MHz
        led : out std_logic_vector(9 downto 0)        -- LEDR
    );
end entity;

architecture behave of led_blink is
    signal cnt    : integer range 0 to 24_999_999 := 0;
    signal toggle : std_logic := '0';
begin
    process(clk)
    begin
        if rising_edge(clk) then
            if cnt = 24_999_999 then      -- 50MHz / 2 * 25M = 2Hz
                cnt    <= 0;
                toggle <= not toggle;
            else
                cnt <= cnt + 1;
            end if;
        end if;
    end process;

    led <= (others => toggle);
end architecture;

三個重點:(1) 所有邏輯都寫在 process(clk) 的 rising_edge 裡——這是「同步電路」的鐵律;(2) 計數器數到上限歸零、同時翻一位;(3) (others => toggle) 一次把十顆 LED 綁到同一個信號——看到「十顆齊閃」的那一刻,FPGA 和 CPU 的差別就有了身體感。

第二個電路:計數 + 7 段顯示

這是一道分水嶺:三個功能(分頻、計數、解碼顯示)在同一個檔案協作——已經有「模塊化」的味道了:

library IEEE;
use IEEE.std_logic_1164.ALL;
use IEEE.numeric_std.ALL;

entity counter is
    Port (
        clk   : in  std_logic;                  -- 50MHz
        rst   : in  std_logic;                  -- KEY[0] Reset(低有效)
        sw    : in  std_logic_vector(7 downto 0);
        seg   : out std_logic_vector(3*7 downto 0);  -- HEX3..HEX0
        an    : out std_logic_vector(3 downto 0);    -- HEEn 低有效
        led   : out std_logic_vector(7 downto 0)
    );
end entity;

architecture behave of counter is
    signal clk_1k  : std_logic := '0';   -- 50MHz 分頻到 1kHz
    signal cnt1k   : integer range 0 to 49_999 := 0;
    signal count   : unsigned(15 downto 0) := (others => '0');
    type t7seg is array (7 downto 0) of std_logic_vector(6 downto 0);
    constant SEGTAB : t7seg := (
        "1000000",  -- 0  (a~g)
        "1100001",  -- 1
        "0100100",  -- 2
        "0100001",  -- 3
        "0110000",  -- 4
        "0010011",  -- 5
        "0010111",  -- 6
        "1000000",  -- 7
        "0000000",  -- 8
        "0010000",  -- 9
        "0001000",  -- A
        "0011111",  -- b
        "0111011",  -- C
        "1111001",  -- d
        "0111111",  -- E
        "0011111");-- F
begin
    -- 1kHz 心跳
    process(clk)
    begin
        if rising_edge(clk) then
            if cnt1k = 49_999 then
                cnt1k <= 0;
                clk_1k <= not clk_1k;
            else
                cnt1k <= cnt1k + 1;
            end if;
        end if;
    end process;

    -- 計數(每 1kHz +1)
    process(clk_1k)
    begin
        if rising_edge(clk_1k) then
            if rst = '1' then
                count <= (others => '0');
            else
                count <= count + 1;
            end if;
        end if;
    end process;

    led <= sw;

    -- 四顆7段顯示
    process(clk_1k, count)
        variable nib : unsigned(3 downto 0);
    begin
        if rising_edge(clk_1k) then
            for i in 0 to 3 loop
                nib := count((i*4+3) downto i*4);
                seg((i*7+6) downto i*7) <= SEGTAB(conv_integer(nib));
            end loop;
        end if;
        an <= "0111";  -- 全亮(簡單版;動態掃描見進階篇)
    end process;
end architecture;

7 段解碼表建議自己手推一遍:0~9 每個數字,七段 a~g 哪些點亮、哪些熄,推完再對照常數表——比死背快三倍。這裡先用「四顆同時常亮」的簡接法;真正的動態掃描(一顆一輪詢、用 1kHz 換「四顆都亮」的錯覺)留給進階篇——那裡會第一次遇到「時序錯配」的真實問題。

學習路線(由淺入)

  1. LED 閃爍 → 雙計數器 → 交通號誌(Finite State Machine 的入口)
  2. 7 段動態掃描 → 分頻樹 → timing 約束(timing constraint)
  3. 同步 vs 非同步 Reset 的差別(這塊板 KEY0 低有效,極性寫反不報錯、但會亂動)
  4. VGA framebuffer(SDRAM)→ 第一次碰「外部介面」
  5. 進階:UART / SPI / I²C 軟硬體協定 → 與微控制器協作

新手最容易摔的三坑

  • 把 VHDL 當 C 寫:預設是「併行」,C 語言順敘寫法在硬體上會變成組合邏輯迴路。先想電路、再寫程式,是唯一解法。
  • 用 50MHz 餵組合邏輯:計數、解碼、顯示都要掛在 rising_edge 上,否則編譯出一堆 timing violation,燒錄成功但行為亂跳。
  • 7 段選通(HEEn)是低有效:拉低才亮——接反了 = 全滅。新手最容易懷疑「晶片壞了」,其實只是極性。

結語

從 DE10-Lite 上電的那刻起,學習的主題就從「寫程式」變成「長電路」。這塊板夠小、夠耐玩——先把 LED 閃爍跟 7 段計數跑通,往後的路就順了。