JOVANA
Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

匯流排與多工:UART、I²C、SPI 與即時作業系統

一顆孤零零的微控制器,不過是泡在罐子裡的大腦——它真正的力量,要等它能與其他晶片交談、同時應付多項工作的那一刻才會浮現。本篇帶你走過每位嵌入式工程師閉著眼都能接出來的三條串列匯流排——UART、I²C 與 SPI——並給你一條一眼就能挑對的法則。接著我們跨過第 4 階那個裸機迴圈的界線,進入即時作業系統的世界:在那裡,具名的任務、排程器與優先權,把一團糾纏的中斷,變成一套能被證明準時達成截止期限的軟體。

為何晶片需要交談——又為何兩條線勝過五十條

拆開一支智慧手錶,你找到的不是一顆晶片,而是一座小鎮:中央一顆微控制器,這邊一個心率感測器,那邊一個量海拔的微型氣壓計,一顆存著你音樂的快閃記憶體,一個螢幕驅動晶片,一具藍牙無線電。它們必須不斷閒聊——「新的一次心跳」、「畫這個像素」、「電量 41 %」——可是整塊板子只有一張郵票大。你無法在每對晶片之間都拉十六條並行線;空間不夠,而且每多一支接腳都要花錢、要焊接。答案幾乎放諸四海皆準:把位元一個接一個地送過單一條線——這就是串列通訊。

串列的麻煩在於時序。如果我把位元組 0110 1001 化成一串高低電位送給你,你怎麼知道一個位元在哪裡結束、下一個從哪裡開始?這裡有兩種策略,而它們把整個匯流排世界一刀剖成兩半。同步匯流排在資料線旁另拉一條時脈線:時脈每跳一下就說「*現在*讀資料線」,於是發送端與接收端完美同步前進。非同步匯流排則完全不送時脈;雙方事先講好一個速度,再各自信任自己內部的計時器,在一則短訊息的長度內保持對齊。這個單一抉擇——多一條時脈線,或一條都不要——就是這條路上最深的分岔,而下面三條匯流排,正分站在它的兩側。

UART——兩條線、一份信任的握手、沒有時脈

UART 是板子上最古老、最簡單的連結,也是你往後職涯都用來印偵錯訊息的那一個。它只需要兩條交叉的訊號線:我的 TX(傳送)接到你的 RX(接收),反之亦然——再加一條共用接地。沒有時脈線。取而代之,雙方事先約定一個鮑率(baud rate)——每秒的位元槽數,經典的有 9600、115200 等等。線路閒置時維持高電位。要送出一個位元組,傳送端先把線拉低一個位元時間(起始位元),這就是接收端開始計數的暗號;接著以約定的節奏依序送出 8 個資料位元,最後以一個高電位的停止位元收尾。

UART frame, 8N1, sending byte 0x69 = 0110 1001  (LSB first)

        start  D0 D1 D2 D3 D4 D5 D6 D7  stop
idle ___       __    __ __       __    ___ idle
        |     |  |  |     |     |  |  |
        |__ __|  |__|     |_____|  |__|
         0   1  0  0  1  0  1  1  0   1
          \__/
        receiver sees the falling edge here,
        then samples each bit in the MIDDLE of its slot,
        timed by its OWN internal clock at the agreed baud.

  No shared clock wire — only a promise about speed.
一個 UART 字元:起始位元觸發接收端,接收端隨後用自己的計時器,在每個資料位元時間槽的正中央取樣。

由於兩邊各用自己的振盪器計時,整個訊框(約 10 個位元)內,兩個時脈的漂移不能超過約半個位元——也就是雙方必須對準到百分之幾以內。這正是為何 UART 只容得下短小、有框的爆發傳輸,並採用事先約定的固定速度;把一邊設成 9600、另一邊設成 115200,你會得到一串流暢的亂碼。它的回報是優美的簡潔,以及它是全雙工:TX 與 RX 各走各的線,所以雙方能同時開口。

I²C——一條共線電話,用位址取代新線

假設你的手錶有十幾個慢速感測器,而你拒絕為它們花上二十幾支接腳。1982 年由飛利浦發明的 I²C,以一種老式鄉間共線電話的優雅解決了這件事:每戶人家共用同一對電話線,你想找哪一家,就先報出它的號碼。無論掛上多少顆晶片,I²C 整條匯流排都只用兩條線——SDA(串列資料)與 SCL(串列時脈)。由單一控制端驅動時脈;每個周邊晶片出廠時都燒入一個獨一無二的 7 位元位址(最多可達 128 個裝置)。

一次交易讀起來像一通禮貌的電話。控制端先發出 START(趁 SCL 仍為高電位時把 SDA 拉低——這是正常資料中刻意設計的非法樣式,所以人人都認得它是「注意」)。接著它送出 7 位元位址外加一個讀/寫位元。位址相符的那一顆晶片,會把 SDA 拉低一個時脈以回覆——這是 ACK,「我在」——而其他所有晶片聽到不是自己的號碼,便保持沉默。資料位元組接著流動,每個都被確認,直到一個 STOP 條件掛斷線路。由於眾多晶片共用一條 SDA 線,這些線採開汲極(open-drain):任何裝置都只能把線往*低*拉,再由電阻把它拉回高,於是兩顆晶片同時說話時會安全地相讓,而非短路。

I2C: ONE bus, MANY devices, sorted by address

      +3V3
       |   |       (pull-up resistors, ~4.7k)
      [R] [R]
       |   |
  SDA  o---+---+-------+-------+-----  (data)
       |   |   |       |       |
  SCL  o---|---+---+---+---+---+-----  (clock)
       |   |   |   |   |   |   |
     [MCU] [Temp] [Accel] [RTC] ...
           0x48   0x68     0x51   <- unique addresses

  Add a 4th sensor? Tap the SAME two wires.
  Give it an address nobody else has. Done.
I²C 把每個裝置掛在同一對 SDA/SCL 上;新增一顆晶片不花新的接腳,只需一個沒人用過的位址。

SPI——四條線、一個時脈,與純粹的速度

當你必須*快*——刷新一塊彩色顯示器、從 SD 卡串流、把一顆高速 韌體驅動的 ADC 樣本一口口吸進來——UART 那種無時脈的猜測,與 I²C 那套禮貌的點名,都跟不上。於是登場的是 SPI,這頭速度惡魔。SPI 拋棄位址,找回一條真正的時脈線,再加上一個絕妙的點子:兩邊各放一個移位暫存器,串成一個大迴圈。想像兩個人各握著一串串在線上的珠子;每跳一下時脈,各自把一顆珠子推進對方手裡。八下之後,他們就完全交換了彼此的位元組。這就是 SPI:資料在 MOSI(控制端→周邊)上送出、在 MISO(周邊→控制端)上送回,*就在同一個個時脈邊緣上*,所以它天生就是全雙工

SPI 沒有位址,又怎麼定址多顆晶片?靠蠻力接線。每個周邊共用同樣三條線——SCLK(時脈)、MOSIMISO——但各自獨享一條私有的晶片選擇線(CSSS),只有輪到那顆晶片時才被拉低。CS 為低,意思是「這則訊息是給*你*的;其他人都別動,無視時脈」。控制端親自驅動時脈,能把它催得兇猛地快——幾十兆赫家常便飯,好板子上 50~100 MHz——因為有真正的時脈線,沒人需要猜時序。

SPI: shared clock + data, ONE chip-select per device

            SCLK  >----+-------+-------+----  clock
            MOSI  >----+-------+-------+----  out
            MISO  <----+-------+-------+----  in
  [ MCU ]   CS0   >---->|       |       |     (display)
            CS1   >------------>|       |     (flash)
            CS2   >-------------------->|     (ADC)

  Pull ONE CS low -> that chip listens, others ignore clock.
  Cost: every extra device burns one more CS pin.

  One 8-bit exchange (full-duplex):
  SCLK  _|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_
  MOSI   D7  D6  D5  D4  D3  D2  D1  D0    (we send)
  MISO   d7  d6  d5  d4  d3  d2  d1  d0    (we receive, same edges)
SPI 共用時脈與資料線,卻給每個周邊各自的晶片選擇線;送出一位元組與收回一位元組共乘同樣的時脈邊緣。

從裸機迴圈,到同時跑許多任務

在第 4 階,你用裸機的方式打造韌體:單一個 `while(1)` 的超級迴圈輪流做每件事,再靠中斷觸發來處理緊急事件。對一兩項工作而言這完美無瑕——小巧、可預測、無額外負擔。但看著它崩潰吧。想像一個迴圈必須(a)每 10 ms 透過 I²C 讀一次感測器、(b)每 30 ms 透過 SPI 刷新一次顯示器、(c)閃爍一顆狀態 LED、以及(d)解析從 UART 進來的指令。一旦顯示器刷新花了 12 ms,你那 10 ms 的感測讀取就已經遲到。你開始東撒一個計時器、西撒一個旗標、再加半成品的狀態機,迴圈很快變成一團 `if (timeToDoX)` 子句,連你自己在內,沒人理得清。

即時作業系統——RTOS——就是解藥。核心概念是任務(task):你把每項工作寫成它自己一支獨立、自成一體的小程式,一個獨立的 `while(1)` 迴圈,*以為自己獨占整顆 CPU*。感測任務永遠迴圈讀感測器;顯示任務永遠迴圈繪圖;UART 任務永遠迴圈解析。彼此都不知道對方存在。RTOS 裡一個叫做排程器(scheduler)的部件接著施展障眼法:它把一個任務凍結在半途——存下它的暫存器與堆疊——再解凍另一個,在它們之間切換得極快(常常每 1 ms 一次),快到在單核心上它們看起來全都*同時*在跑。這個把戲叫做情境切換(context switch),是多工的核心。

優先權、截止期限,以及「即時」的真義

讓它成為*即時*、而不只是*多工*的,是優先權加上達成截止期限的承諾。在一支跑 Linux 的手機上,「多工」意味著每件事都分到公平的一片時間、終究都會做完——對網頁瀏覽器很好,對安全氣囊則致命。RTOS 反過來讓你給每個任務蓋上一個優先權數字,而它的排程器是冷酷地可搶占的(pre-emptive):在每一個瞬間,它都跑*已就緒的最高優先權任務*;一旦有更重要的任務忽然就緒,它便搶占——當場、就在某一行的中途,把 CPU 從正在跑的較低任務手中奪走。馬達安全任務永遠位階高於記錄任務,所以遲到的記錄絕不可能拖慢馬達的停機。

  1. 定義任務。把韌體切成獨立的工作——`SensorTask`、`DisplayTask`、`CommsTask`——每個都是無窮迴圈,並各給它一個優先權與一塊堆疊。
  2. 要阻塞,別空轉。一個在等資料的任務會呼叫類似 `vTaskDelay()` 的東西,或在佇列/號誌上等待;排程器於是去跑*別的*任務,而不是讓 CPU 無謂地空轉。
  3. 讓排程器來挑。在每一個時脈滴答——以及每一次中斷——它都挑出已就緒的最高優先權任務並切過去,不打招呼就搶占任何較低的任務。
  4. 安全地溝通。任務透過 RTOS 提供的佇列傳遞資料,並用互斥鎖(mutex)守護任何共享資源,使兩個任務不會在更新到一半時破壞同一個變數。