0x6A Logbook

0x6A Logbook
Shi6a的筆記本
  1. 首頁
  2. 程式開發
  3. 正文

USLink 水下通訊模組(中):OOK 軟體設計與除錯歷程

2026 年 9 月 30 日 12點熱度 0人點贊 0條評論

USLink 水下通訊模組(中)封面

軟體架構:中斷負責時序、主迴圈負責邏輯

硬體把聲音變成了 0 與 1,但這條鏈路每 16 ms 才走完一個 bit —— 意思是,整個通訊的成敗取決於「你什麼時候去讀那支腳」。讀早一點讀到晚一點,收到的就是不同的位元。

這種工作在 8051 上用輪詢是不可能做好的,所以架構一開始就切成兩半:

軟體架構

分工很明確:Timer 0 每 250 µs 中斷一次,在裡面推進發送與接收兩個狀態機;主迴圈只做「慢的事情」—— 解析輸入、組裝訊息、印出統計。ISR 只負責取樣與組裝,像 CRC 檢查、分段重組、ACK 判斷這些都留給主迴圈,免得把中斷拖長。

這樣切的結果是:位元的起訖時間只由 tick 計數決定,抖動被壓在 250 µs 以內,而位元寬是 16 ms。等於有 64 倍的餘裕。

框架格式與 OOK 調變

先定義「一筆訊息」長什麼樣子。v4.0 的 frame 是這樣排的:

Frame 格式

LEN 之後是三個位元組的標頭,然後才是真正的資料,最後是 CRC。DST 是目標裝置 ID(0xFF 代表廣播)、SRC 是自己的 ID、FLAG 則身兼三職:bit7 表示「還有後續分段」、bit6 表示「這是一個應答」、剩下的 bit5 到 bit0 是分段序號。

而 frame 裡的每一個位元組,在無線段上都是用 10 個位元送出去的:

一個 byte 的 10 個位元

start 是載波、stop 必須是靜默。這個結構跟 UART 幾乎一樣,而這正是整個軟體設計最重要的一個決定 —— 後面會看到,它是用一次架構重寫換來的。

把這些參數寫進程式裡,看起來就是一段自帶說明的設定:

/* ==================== v4.0 協議層 (多節點/分段/ACK) ====================
 * frame payload 格式 (在 LEN 之後、CRC 之前):
 *   [HDR: DST 目標ID][HDR: SRC 源ID][HDR: FLAG][DATA...]
 *   LEN = 3 + DATA 長度 (DATA 上限 = US_MAX_PAYLOAD - 3 = 13)
 *   FLAG: bit7=MORE(還有續段) bit6=ACK(應答 frame) bit5..0=分段序號
 *   DST=0xFF 表示廣播: 所有設備收, 但不回 ACK (避免廣播風暴)
 *   ACK frame: DST=原SRC, SRC=原DST, FLAG=0x40|原序號, DATA=回應文字(可空)
 * 分段: 訊息 >13B 自動切成多 frame, 接收端依序號重組。
 * ==================================================== */
#define US_DEV_ID        1       /* 本機設備 ID (0~254; 255=廣播保留) */
#define US_TARGET_DEF    0xFF    /* 預設目標 = 廣播 */
#define US_HDR_LEN       3       /* HDR 長度 */
#define US_MAX_DATA      (US_MAX_PAYLOAD - US_HDR_LEN)   /* 每 frame 資料上限 13B */
#define US_FLAG_MORE     0x80    /* 還有後續分段 */
#define US_FLAG_ACK      0x40    /* 這是 ACK frame */
#define US_FLAG_SEQ_MASK 0x3F    /* 分段序號 (0~63) */
#define US_ACK_TIMEOUT_MS 3000   /* 指定目標發送後等 ACK 的超時 */
#define US_RX_MSG_MAX    64      /* 接收端重組緩衝大小 */

接收流程:四個狀態,每 250 µs 推一格

接收端是一個 tick 級的狀態機。每次中斷進來,就看一眼 P1.0,然後推進一格:

接收流程與狀態轉移

四個狀態各有明確的職責:

  • RX_IDLE:等 start bit。這裡的條件特別嚴格 —— 必須「先靜默 2 ms,再連續 2 ms 有載波」,也就是只承認真正的上升邊緣。這個條件擋掉了持續載波與各種雜訊的誤觸發
  • RX_SYNC:確認 start 之後再等 2 ms,讓下一次取樣剛好落在位元中心
  • RX_BITS:每 16 ms 取樣一次,收 8 個資料位,最後檢查 stop 是否為靜默
  • RX_LOCK:出錯時的冷卻區,等連續靜默 150 ms 才回到 IDLE

狀態定義在程式裡就是一組列舉:

/* 接收狀態機 (tick 級: 每 250µs 由 T0 ISR 推進一次) */
typedef enum
{
    RX_IDLE = 0,    /* 等 start bit (需先有靜默, 再連續高 ticks = 真載波上升) */
    RX_SYNC,        /* start 確認後等一小段 (US_START_HALF), 讓採樣點落到位元中心 */
    RX_BITS,        /* 每 12ms 位元中心採樣: 8 資料位 + stop bit */
    RX_LOCK         /* stop 失敗/LEN 無效後的冷卻: 等 frame 結束 (連續靜默 25ms)
                     * 再恢復 — 終止 frame 內誤觸發循環 */
} rx_state_t;

這裡可以看到一件很真實的事:註解裡還留著 12 ms 與 25 ms 這些舊數字。程式碼註解往往是最後才更新的東西,實際生效的參數定義在 config.h,v3.3 之後已經改成 16 ms 與 150 ms —— 為什麼要改,後面會說。

傳送端:非同步發送與半雙工

傳送端的狀態機比接收端單純得多:把要送的 frame 轉成位元串,每個位元 16 ms,開頭補一段 preamble 讓對面有時間同步。送完之後就回到 IDLE。

真正需要注意的是半雙工。發射的時候自己的載波會把接收鏈路灌爆,所以傳送期間接收端必須停用;送完還要留一段緩衝,等換能器的餘振衰減掉再開回接收。這段「餘振」的時間在後面的除錯裡會變成主角。

另外,訊息佇列是 async 的:呼叫發送之後立刻返回,實際的位元輸出由中斷慢慢完成。這樣主迴圈就不會被 2 秒多的傳輸時間卡住。

第一章:v2.x 的滑動視窗為什麼失敗

第一版(v2.x)的同步方式很直覺:在 frame 開頭放一段 preamble,接收端看到 preamble 之後等一段連續靜默(gap)來確認邊界,然後開始收位元。這個做法在紙上很合理,在實測上很慘。

症狀是:訊息收得到,但開頭總是少一個位元組。最常出現的結果是 LEN 被解成 0xE6 —— 那是「柴」這個字的第一個位元組。也就是說,接收端慢了一個 byte 才開始對齊,把第一個資料位元組當成了長度欄位。

根因有兩個,都不是演算法問題,而是硬體現實:

  • 換能器餘振:停止驅動之後,換能器還會響約 2 ms
  • 包絡放電與比較器滯回:包絡檢波是 RC 電路,掉下來需要時間;LM393 的滯回也讓它不會立刻翻回低電位

這些加起來,讓「載波停止之後 P1.0 什麼時候真的變低」變成一個浮動的數字。只要殘留電平超過幾毫秒,接收端就等不到 preamble 後面那個 gap,只好一路等到下一個 byte 前面的 gap 才對齊 —— 於是起點整整慢了一個位元組。

 * v3.x UART 式的核心:
 *   - 每 byte 由 start bit 的「載波上升沿」重新同步 → 不依賴任何 gap 錨定
 *   - 位元中心採樣: 偵測到 start 後等 US_START_HALF (2ms), 之後每 12ms
 *     採樣一次 (位元中心, 距邊緣 ≥4ms >> 換能器餘震 2ms + 包絡延遲 1-2ms)
 *   - stop bit 必須是靜默 → byte 有效; 否則丟棄重等 (下一 start 再同步)
 *   - 對「載波停止後 P1.0 殘留」的容忍度 = 位元時間 - 餘震 - 採樣點偏移,
 *     12ms 位元容錯極大; 殘留 <5ms 完全無感
 *
 * 速率: 12ms/bit, 12B payload frame = 14B × 10bit × 12ms ≈ 1.68s
 * ==================================================== */

v2.5 曾試著把 gap 長度參數化,結果只是把症狀往後推:參數調到能收某種訊息,換另一種就壞。當一個方案需要不斷調參才能工作,通常代表架構錯了。於是有了一次重寫。

第二章:v3.x 改用 UART 式重同步

為什麼要放棄滑動視窗

重寫的核心想法很簡單:不要再依賴 gap。把每個位元組都當成獨立事件,用它的 start bit 重新同步一次。

具體的流程是:偵測到載波上升緣(而且必須是「先靜默 2 ms 再連續高 2 ms」的真邊緣)之後,等 2 ms 讓下一次取樣正好落到位元中心,接著每 16 ms 採樣一次,收 8 個資料位,最後檢查 stop 位元是不是靜默。

這樣一改,性質就完全不同了:

  • 錯一個位元組不會拖垮整個 frame —— 下一個 start bit 就重新對齊了
  • 不再需要猜 gap 要多長,參數只剩有物理意義的那幾個
  • 對「載波停止後 P1.0 殘留」的容忍度變成「位元時間 − 餘振 − 採樣點偏移」,16 ms 的位元寬讓殘留 5 ms 完全無感

第三章:v3.3 把每個參數都綁回物理事實

架構對了之後,剩下的調參還是踩了一次坑。v3.2 到 v3.3 的改動有四個,而這四個改動的共同點是:每一個數字都能用物理或時序推出來,不再靠試誤。

v3.2 到 v3.3 的參數推理

#define US_TICK_US       250UL   /* T0 中斷週期 (µs) — 每 tick 採樣一次 P1.0 */
#define US_BIT_TICKS     64      /* 1 bit = 64 ticks = 16ms
                                  * (v3.3 加大: 採樣容差 ±8ms, 同步點抖動
                                  *  δ≈3-6ms 完全安全; 12ms 容差只有 ±6ms 太緊) */
#define US_START_HALF    8       /* start 偵測後到第一採樣窗口的等待 = 2ms
                                  * 採樣窗口中心 = δ+2ms(HALF)+16ms = δ+20ms
                                  * TX bit7 中心 = 16+8 = 24ms
                                  * δ=4ms → 正中; δ 2~6ms → 22~26ms (±2ms) */
#define US_START_HI      8       /* start 確認: 連續高 ≥8 ticks (2ms) = 載波上升 */
#define US_IDLE_SILENT   8       /* IDLE 前置: 觸發 start 前需連續靜默 ≥8 ticks (2ms)
                                  * — 只認「靜默→載波」的真邊緣 */

其中最有意思的是 LOCK 冷卻時間。這個值的用途是:當一個位元組的 stop 檢查失敗、或 LEN 不合理時,接收端進入冷卻狀態,等一段時間再重新收 —— 目的是不要在 frame 的中段一直誤觸發。

問題是「等多久」。太小,會在 frame 還沒結束時就解除冷卻,然後再度誤觸發(形成循環);太大,則會把後面的正常 frame 也一起擋掉。所以正確的下界是「frame 內最大連續靜默長度」:

stop 16 ms + 8 個連續 0-bit(8 × 16 ms = 128 ms)= 144 ms

答案就出來了:冷卻門檻必須大於 144 ms。舊值 25 ms 遠低於這個數字,所以會被 data 裡「兩個以上的 0-bit 加上 stop」騙開;改成 150 ms 之後問題消失。上界也有對應的物理量 —— 冷卻上限必須大於整個 frame 的長度 2.24 s,否則會在 frame 中段被強制解除。最後選了 2.5 s。

#define US_LOCK_SILENT_TICKS 600 /* RX_LOCK: 連續靜默 600 ticks (150ms) → 解除
                                  * (stop 失敗/LEN 無效後等 frame 結束再收)。
                                  * ★ 150ms > frame 內最大連續靜默
                                  *   (stop 16ms + 8×0bit 16ms = 144ms) →
                                  *   LOCK 絕不在 frame 內提前解除!
                                  *   舊值 25ms 會被 DATA 的「≥2 連續 0 bit +
                                  *   stop」騙過, 在 frame 中段解除 → 誤觸發循環 */
#define US_LOCK_MAX_TICKS 10000  /* RX_LOCK 上限 2.5s, 防持續載波鎖死;
                                  * 必須 > frame 長度 (2.24s=8960 tick),
                                  * 否則 frame 中段強制解除 → 又誤觸發 */

而位元取樣也從「單點取樣」改成了三點多數決:在位元中心附近連續取三個 tick,兩個以上是載波就判定為 1。這是為了濾掉 250 µs 等級的毛刺與餘振抖動。

    case RX_BITS:
        /* 每 US_BIT_TICKS (16ms) 位元中心採樣: 8 資料位 + stop。
         * ★ v3.3 採樣 = 3-tick 多數決窗口 (中心 ±1ms):
         *   在 rx_tick_cnt = BIT-1, BIT, BIT+1 收集 3 個連續 tick,
         *   ≥2 個載波 = 1 — 過濾 250µs 級毛刺/餘振抖動, 不再單點採樣。 */
        rx_tick_cnt++;
        if (rx_tick_cnt >= US_BIT_TICKS - 1)
        {
            rx_vote_buf = (uint8_t)((rx_vote_buf << 1) | (s ? 1 : 0));
            if (++rx_vote_cnt >= 3)
            {
                rx_vote_cnt = 0;
                rx_tick_cnt = 0;
                /* 3 tick 中 ≥2 載波 = 1 (0b011/101/110/111) */
                b = ((rx_vote_buf & 0x03) == 0x03 ||
                     (rx_vote_buf & 0x05) == 0x05 ||
                     (rx_vote_buf & 0x06) == 0x06) ? 1 : 0;

                /* RSSI: 載波 bit 中 3:0 全高 = 訊號乾淨, 2:1 = 邊緣/衰弱 */
                if (b)
                {

把「收不到」變成可讀的訊息

這個專案最花時間的部分,其實不是寫功能,而是搞清楚為什麼收不到。超聲波鏈路看不到波形(沒有示波器在旁邊),唯一的觀測窗口就是那支腳的高低電平,所以診斷資訊必須自己設計出來。

三個反直覺的教訓

最後做出來的診斷機制包含三樣東西:

  • 失敗分類:把失敗分成 LEN 無效、CRC 不合、超時三類,每一類都印出收到的實際位元組、期望值、以及當時落在 frame 的哪個時間窗
  • 位元直方圖:印出最近 8 個位元的採樣結果(0 到 7,代表靜默到飽和),如果這 8 個數字看起來像某個資料位元組的樣式,就知道是起點快了或慢了
  • RSSI:從三點多數決的投票結果順便推估訊號品質 —— 三個 tick 全高代表訊號乾淨,二比一則代表位在邊緣

這三樣加起來,失敗訊息就不再是「收不到」,而是一句可以診斷的話:

static void report_fail(void)
{
    uint8_t fd1, fd2, fd3, fk, i;

    fk = usonic_poll_fail(&fd1, &fd2, &fd3);
    if (fk == 1)
    {
        usonic_get_grp_hist(gh_buf);
        printf("[RX-FAIL] LEN 無效: 收到 0x%02X (期望 12) @frame窗%u(~%ums) — %s\r\n",
               fd1, fd3, (uint16_t)fd3 * 2,
               fd1 == 0 ?
               "載波位元全丟! 可能是 TX 送 0x00 / 訊號太弱 / 沒同步到 start" :
               "位元解錯 (沒同步到 start 或干擾)");
        printf("  位元採樣 [");
        for (i = 0; i < 8; i++)
            printf("%u%s", gh_buf[i], i < 7 ? "," : "");
        printf("] (0=靜默 7=載波) — 若像某個資料 byte 的樣式 = 起點慢/快\r\n");
    }
    else if (fk == 2)
        printf("[RX-FAIL] CRC 不合: 收 0x%02X ≠ 期 0x%02X @frame窗%u — 資料有 bit 錯 (閾值邊緣或干擾)\r\n",
               fd1, fd2, fd3);
    else if (fk == 3)
        printf("[RX-FAIL] 超時: 卡在 %s @frame窗%u — 雜訊誤觸發 start 或訊號中斷\r\n",
               rx_sname(fd1), fd3);
}

回頭看,前面那三個「直覺做法反而是錯的」的坑(閾值往上加、冷卻時間縮短、加大 preamble),全部是靠這些診斷資訊才找出來的。沒有診斷,就只會一直在猜。

協議層:從「能傳一個字」到「能傳一段話」

實體層與資料連結層穩定之後,剩下的就是把功能往上疊。v4.0 加了多節點與分段:

  • 定址:DST/SRC 兩個欄位,讓多台裝置可以互相指定,0xFF 為廣播
  • 分段與重組:訊息超過 13 個位元組就自動切成多個 frame,用 FLAG 的低 6 位當序號,接收端依序重組
  • ACK:指定目標發送之後等應答,超時 3 秒則回報失敗;廣播則不回 ACK,避免多台裝置同時應答造成廣播風暴

這部分的複雜度不在演算法,而在狀態管理 —— 尤其是那顆只剩 3 bytes 連續空間的 IRAM。後來幾個緩衝區都搬到了 XRAM,才勉強騰出空間。

小結:這條通道教了我什麼

回頭看整個軟體設計,真正的收穫不是「學會寫狀態機」,而是三件更基本的判斷:

  • 當一個方案需要一直調參才能動,先懷疑架構,不要懷疑參數。v2.x 的滑動視窗調了三個版本,不如 v3.x 直接換同步方式
  • 參數要有物理依據。144 ms 這個數字不是試出來的,是算出來的;算得出來,就不會在下一個場景又壞掉
  • 先做診斷,再談除錯。沒有可觀測性,除錯只是在猜

還有一個更樸素的結論:這個專案從 v2.x 到 v4.0 的每一版,都是在「實測打臉理論」之後才推進的。水下聲通道不會照著課本走,而這正是它值得做的原因。

(系列上集:USLink 水下通訊模組(上)— 項目發想與電路設計)

標籤: 8051 水下通訊 超聲波 通訊協定 除錯
最後更新:2026 年 9 月 30 日

shi6a

這個人很懶,什麼都沒留下

點贊
< 上一篇

文章評論

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
取消回覆

COPYRIGHT © 2026 0x6A Logbook. ALL RIGHTS RESERVED.

Theme Kratos Made By Seaton Jiang