
軟體架構:中斷負責時序、主迴圈負責邏輯
硬體把聲音變成了 0 與 1,但這條鏈路每 16 ms 才走完一個 bit —— 意思是,整個通訊的成敗取決於「你什麼時候去讀那支腳」。讀早一點讀到晚一點,收到的就是不同的位元。
這種工作在 8051 上用輪詢是不可能做好的,所以架構一開始就切成兩半:

分工很明確:Timer 0 每 250 µs 中斷一次,在裡面推進發送與接收兩個狀態機;主迴圈只做「慢的事情」—— 解析輸入、組裝訊息、印出統計。ISR 只負責取樣與組裝,像 CRC 檢查、分段重組、ACK 判斷這些都留給主迴圈,免得把中斷拖長。
這樣切的結果是:位元的起訖時間只由 tick 計數決定,抖動被壓在 250 µs 以內,而位元寬是 16 ms。等於有 64 倍的餘裕。
框架格式與 OOK 調變
先定義「一筆訊息」長什麼樣子。v4.0 的 frame 是這樣排的:

LEN 之後是三個位元組的標頭,然後才是真正的資料,最後是 CRC。DST 是目標裝置 ID(0xFF 代表廣播)、SRC 是自己的 ID、FLAG 則身兼三職:bit7 表示「還有後續分段」、bit6 表示「這是一個應答」、剩下的 bit5 到 bit0 是分段序號。
而 frame 裡的每一個位元組,在無線段上都是用 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 的改動有四個,而這四個改動的共同點是:每一個數字都能用物理或時序推出來,不再靠試誤。

#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 水下通訊模組(上)— 項目發想與電路設計)
文章評論