0x6A Logbook

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

ESP32 WS2812 RGB LED 控制完整教學:從單線協定到 RMT DMA 實作

2026 年 6 月 14 日 825點熱度 0人點贊 0條評論

ESP32 WS2812 RGB LED 控制完整教學封面

先講結論:這條燈條好用到有點離譜

WS2812 這顆東西第一次碰到會覺得有點神奇 —— 一條線、串一百顆 LED、每顆都能獨立指定顏色,而且不用額外的時脈線、不用解碼晶片、不用位址設定。你只要把資料從頭丟進去,它自己會一顆傳一顆。

但它的脾氣也很明確:時序容差只有 ±150 ns。這個數字是什麼概念?在 240 MHz 的 ESP32 上,一條指令大約 4.17 ns,所以你只有大約 36 條指令的誤差空間。用軟體硬幹的話,一個中斷進來就爆了。

所以這篇的順序是:先搞懂它到底在傳什麼(協定),再看四種寫法的取捨,最後用 RMT 硬體把它做穩。

一顆 WS2812 裡面裝了什麼

很多人以為 WS2812 只是「一顆 RGB LED」。不是,它是一顆 LED 加上一顆微型控制器,兩者封裝在一起。

WS2812 內部結構與級聯

這顆內建控制器做的事有三件:接收序列資料、把屬於自己的 24 bits 留下來、把剩下的往後傳。聽起來很簡單,但這三個動作加起來就解決了「怎麼知道哪顆是哪顆」這個大問題 —— 不需要位址,因為每顆 LED 都知道自己是第幾顆(靠它前面已經吃掉多少 bits)。

單線協定:位元不是看電壓高低,而是看「高電位持續多久」

WS2812 用 800 kHz 的單線(One-Wire)協定,但它不是 UART 那種「一個位元一個固定週期」的做法,而是用脈衝寬度編碼:

WS2812 邏輯 0 與 1 的編碼波形

看圖就懂了:邏輯 0 和邏輯 1 的週期幾乎一樣長(約 1.25 µs),差別只在高電位要撐多久。0 是「短高、長低」,1 是「長高、短低」。

這設計的好處是接收端只需要量測高電位寬度,不需要跟發射端共享時脈 —— 又回到同一件事:省線。但代價就是那些數字必須非常準:

WS2812 關鍵時序參數

順帶一提 RESET:280 µs 的低電位代表「這一格畫面送完了,請更新」。如果你送完資料卻發現燈條沒反應,八成是 RESET 時間不夠,或者後面馬上又接了下一批資料。

24 bits 的排法是 G、R、B,不是 RGB

每顆 LED 要吃掉 24 bits,但順序跟你想的不一樣:

WS2812 的 24-bit 資料順序

第一次玩的人幾乎都會踩這個坑:明明設了紅色,燈卻亮綠色。WS2812 要的是 GRB 順序,而且先送高位(MSB first)。送 RGB 的結果就是紅色跑到綠色去、藍色跑到紅色去。

至於級聯就很簡單了:資料是連續的位元流。第 1 顆吃掉前 24 bits,第 2 顆吃掉第 25 到 48 bits,依此類推。所以你要驅動 N 顆,就是準備 N × 24 bits,一次從頭送出去。

四種寫法,先決定你要哪一種

網路上教的 WS2812 寫法至少有四種,它們都能動,但適用的場合差很多:

WS2812 四種實作方式對照

一句話總結:前兩種靠軟體保證時序,後兩種靠硬體。要拿來做正式專案,請直接跳到 RMT。但如果你的目的是搞懂它怎麼運作,前面兩種值得先寫一遍。

方法一:用 delayMicroseconds() 硬幹

最直覺的寫法,也是最不準的寫法:算好高低電位要延遲多少微秒,然後一個位元一個位元地吐出去。

// ESP32 WS2812 — 最簡方式(delayMicroseconds)
#include <Adafruit_NeoPixel.h>

#define PIN        18
#define NUM_LEDS   64

Adafruit_NeoPixel strip(NUM_LEDS, PIN, NEO_GRB + NEO_KHZ800);

void setup() {
    strip.begin();
    strip.show();  // 初始化後全部熄滅
}

void loop() {
    // 彩虹漸變
    for (int i = 0; i < NUM_LEDS; i++) {
        int hue = (i * 360 / NUM_LEDS + millis() / 10) % 360;
        strip.setPixelColor(i, strip.gamma32(strip.ColorHSV(hue)));
    }
    strip.show();
    delay(20);
}

這個版本在「沒有中斷、沒有 WiFi、燈數很少」的情況下勉強能動。但只要 WiFi 一開、Timer 一中斷,時序就歪掉,畫面就會隨機閃爍。它適合當作理解協定的第一步,不適合當成品。

方法二:直接操作 GPIO(想搞懂原理就看這段)

第二種寫法不呼叫延遲函式,而是直接寫 GPIO 暫存器,用指令週期來湊出精確的脈寬。ESP32 在 240 MHz 下一條指令約 4.17 ns,所以理論上可以做出夠準的波形。

// ESP32 WS2812 — 直接 GPIO 操作(理解協定原理)
#define WS2812_PIN  18
#define NUM_LEDS    16

// 使用 ESP32 GPIO 輸出暫存器(高速操作)
#define GPIO_OUT_REG (GPIO.out_w1ts.val)   // 設 HIGH
#define GPIO_CLR_REG (GPIO.out_w1tc.val)   // 設 LOW
#define BIT_MASK     (1ULL << WS2812_PIN)

// 精確延時(使用 ESP32 的 esp_timer 或 Xtensa 指令週期)
// ESP32 時脈 240MHz → 1 個指令 ≈ 4.17ns
// 但需考慮函式呼叫和 GPIO 操作的開銷

static inline void write_ws2812_byte(uint8_t byte) {
    for (int i = 7; i >= 0; i--) {  // MSB first
        if (byte & (1 << i)) {
            // 送出 '1': HIGH 0.70μs → LOW 0.60μs
            GPIO_OUT_REG = BIT_MASK;
            __asm__ __volatile__("nop; nop; nop; nop; nop; nop; nop;");  // ~0.7μs
            GPIO_CLR_REG = BIT_MASK;
            __asm__ __volatile__("nop; nop; nop; nop; nop;");           // ~0.6μs
        } else {
            // 送出 '0': HIGH 0.35μs → LOW 0.90μs
            GPIO_OUT_REG = BIT_MASK;
            __asm__ __volatile__("nop; nop; nop; nop;");                // ~0.35μs
            GPIO_CLR_REG = BIT_MASK;
            __asm__ __volatile__("nop; nop; nop; nop; nop; nop; nop; nop; nop; nop;");  // ~0.9μs
        }
    }
}

void ws2812_show(uint8_t leds[][3], int count) {
    // 關閉中斷,確保時序精確
    taskDISABLE_INTERRUPTS();

    for (int i = 0; i < count; i++) {
        write_ws2812_byte(leds[i][0]);  // G
        write_ws2812_byte(leds[i][1]);  // R
        write_ws2812_byte(leds[i][2]);  // B
    }

    taskENABLE_INTERRUPTS();

    // RESET ≥ 280μs
    GPIO_CLR_REG = BIT_MASK;
    delayMicroseconds(300);
}

問題是「理論上」跟「實際上」的差距:函式呼叫開銷、快取、中斷,都會把誤差放大到幾百 ns,剛好落在容差邊緣。這個版本最大的價值是讓你看見時序到底有多緊,寫過一次之後就會心甘情願改用硬體。

方法三:ESP32 RMT 外設(正式專案請用這個)

RMT 是 ESP32 內建的「遙控/精準脈衝產生器」,本來是為了紅外線遙控設計的,特性剛好完全符合 WS2812 的需求:硬體計時、可以設定每個脈衝的高低位準寬度、送完自動結束、不佔 CPU。

關鍵是把時序換算成 RMT 的 tick。設定 80 MHz 除以 4 等於 20 MHz,一個 tick 剛好 50 ns,於是:

  • 邏輯 0:T0H = 7 ticks(350 ns)+ T0L = 18 ticks(900 ns)
  • 邏輯 1:T1H = 14 ticks(700 ns)+ T1L = 12 ticks(600 ns)
// ESP32 WS2812 — 使用 RMT 外設(專業做法)
#include <driver/rmt.h>

#define RMT_TX_CH  0
#define WS2812_PIN 18
#define NUM_LEDS   64

// 編碼 WS2812 的 '0' (T0H=0.35μs, T0L=0.90μs)
void setupRMT() {
    rmt_config_t config = {
        .rmt_mode = RMT_MODE_TX,
        .channel = (rmt_channel_t)RMT_TX_CH,
        .gpio_num = (gpio_num_t)WS2812_PIN,
        .clk_div = 4,  // 80MHz / 4 = 20MHz → 1 tick = 50ns
        .mem_block_num = 1,
        .tx_config = {
            .carrier_freq_hz = 0,     // 無載波
            .carrier_en = false,
            .idle_level = 0,
            .loop_en = false,
        }
    };
    rmt_config(&config);
    rmt_driver_install((rmt_channel_t)RMT_TX_CH, 0, 0);
}

// 用 RMT item 建構 WS2812 資料
// 邏輯 '0': T0H=7 ticks(350ns) + T0L=18 ticks(900ns)
// 邏輯 '1': T1H=14 ticks(700ns) + T1L=12 ticks(600ns)

#define T0H_TICKS  7
#define T0L_TICKS  18
#define T1H_TICKS  14
#define T1L_TICKS  12

void ws2812_rmt_show(rmt_item32_t* items, int num_leds,
                     uint8_t leds[][3]) {
    int idx = 0;
    for (int i = 0; i < num_leds; i++) {
        // G (MSB first)
        for (int b = 7; b >= 0; b--) {
            items[idx].level0 = 1;
            items[idx].level1 = 0;
            if (leds[i][0] & (1 << b)) {
                items[idx].duration0 = T1H_TICKS;
                items[idx].duration1 = T1L_TICKS;
            } else {
                items[idx].duration0 = T0H_TICKS;
                items[idx].duration1 = T0L_TICKS;
            }
            idx++;
        }
        // R
        for (int b = 7; b >= 0; b--) {
            items[idx].level0 = 1;
            items[idx].level1 = 0;
            if (leds[i][1] & (1 << b)) {
                items[idx].duration0 = T1H_TICKS;
                items[idx].duration1 = T1L_TICKS;
            } else {
                items[idx].duration0 = T0H_TICKS;
                items[idx].duration1 = T0L_TICKS;
            }
            idx++;
        }
        // B
        for (int b = 7; b >= 0; b--) {
            items[idx].level0 = 1;
            items[idx].level1 = 0;
            if (leds[i][2] & (1 << b)) {
                items[idx].duration0 = T1H_TICKS;
                items[idx].duration1 = T1L_TICKS;
            } else {
                items[idx].duration0 = T0H_TICKS;
                items[idx].duration1 = T0L_TICKS;
            }
            idx++;
        }
    }

    // 寫入 RESET (≥ 280μs = 5600 ticks)
    items[idx].duration0 = 0;
    items[idx].level0 = 0;
    items[idx].duration1 = 6000;
    items[idx].level1 = 0;
    idx++;

    rmt_write_items((rmt_channel_t)RMT_TX_CH, items, idx, true);
}

// 全域緩衝區
rmt_item32_t ledItems[NUM_LEDS * 24 + 1];
uint8_t ledData[NUM_LEDS][3] = {0};

void loop() {
    // 製作彩虹效果
    for (int i = 0; i < NUM_LEDS; i++) {
        int hue = (i * 256 / NUM_LEDS + millis() / 5) & 0xFF;
        ledData[i][0] = hue;        // G
        ledData[i][1] = 255 - hue;   // R
        ledData[i][2] = (hue * 2) & 0xFF;  // B
    }
    ws2812_rmt_show(ledItems, NUM_LEDS, ledData);
    delay(30);
}

一旦開始發送,CPU 就完全自由了 —— 這就是為什麼 RMT 版本可以一邊跑 WiFi 一邊更新燈條畫面。

方法四:Adafruit NeoPixel(開發最快)

如果你只是想把燈條點亮、跑個彩虹效果,不必自己碰 RMT。NeoPixel 函式庫底層就是替你做了上面那些事,還內建 gamma 校正與顏色轉換。

// ESP32 WS2812 — Adafruit NeoPixel 函式庫
#include <Adafruit_NeoPixel.h>

#define PIN        18
#define NUM_LEDS   256  // 8×8 矩陣或 4 條 64 燈燈條

Adafruit_NeoPixel strip(NUM_LEDS, PIN, NEO_GRB + NEO_KHZ800);

uint32_t Wheel(byte WheelPos) {
    WheelPos = 255 - WheelPos;
    if (WheelPos < 85) {
        return strip.Color(255 - WheelPos * 3, 0, WheelPos * 3);
    }
    if (WheelPos < 170) {
        WheelPos -= 85;
        return strip.Color(0, WheelPos * 3, 255 - WheelPos * 3);
    }
    WheelPos -= 170;
    return strip.Color(WheelPos * 3, 255 - WheelPos * 3, 0);
}

void theaterChase(uint32_t color, int wait) {
    for (int a = 0; a < 10; a++) {
        for (int b = 0; b < 3; b++) {
            strip.clear();
            for (int c = b; c < strip.numPixels(); c += 3) {
                strip.setPixelColor(c, color);
            }
            strip.show();
            delay(wait);
        }
    }
}

void rainbow(int wait) {
    for (long firstPixelHue = 0; firstPixelHue < 5 * 65536; firstPixelHue += 256) {
        for (int i = 0; i < strip.numPixels(); i++) {
            int pixelHue = firstPixelHue + (i * 65536L / strip.numPixels());
            strip.setPixelColor(i, strip.gamma32(strip.ColorHSV(pixelHue)));
        }
        strip.show();
        delay(wait);
    }
}

void setup() {
    strip.begin();
    strip.setBrightness(50);
    strip.show();
}

void loop() {
    rainbow(10);
    theaterChase(strip.Color(255, 0, 0), 50);
    theaterChase(strip.Color(0, 255, 0), 50);
    theaterChase(strip.Color(0, 0, 255), 50);
}

代價是彈性:你很難插手底層時序,遇到特殊的燈條型號(例如順序是 RGB 而非 GRB、或時序參數不同)就得改函式庫。先用它做原型,之後要壓榨效能再自己寫 RMT。

進階:RMT + DMA + 雙緩衝

當 LED 數量到幾千顆,或者你要做動畫時,單一緩衝會出現一個問題:發送期間 CPU 不能改資料,於是畫面會有空窗。

RMT 加 DMA 雙緩衝

解法是準備兩塊緩衝:硬體發 A 的同時,CPU 填 B;送完之後交換。只要填寫時間小於發送時間,畫面就永遠不會開天窗。

// ESP32 WS2812 — RMT + DMA + 雙緩衝(大型專案)
#include <driver/rmt.h>

#define RMT_TX_CH  0

static void rmt_tx_done_cb(rmt_channel_t channel, void* arg) {
    // DMA 傳輸完成回呼
    xSemaphoreGiveFromISR((SemaphoreHandle_t)arg, NULL);
}

void setupRMT_DMA() {
    rmt_config_t config = {
        .rmt_mode = RMT_MODE_TX,
        .channel = (rmt_channel_t)RMT_TX_CH,
        .gpio_num = (gpio_num_t)WS2812_PIN,
        .clk_div = 4,
        .mem_block_num = 8,
        .tx_config = {
            .loop_en = false,
            .idle_level = 0,
            .carrier_en = false,
        }
    };
    rmt_config(&config);
    rmt_driver_install((rmt_channel_t)RMT_TX_CH, 4096, 0);
    rmt_register_tx_end_callback(rmt_tx_done_cb, NULL);
}

硬體上的幾個坑

軟體寫對了,還是可能看到閃爍、顏色亂跳、甚至燈條整段不亮。這些通常是硬體問題:

  • 電源:每顆 LED 全亮約 60 mA,300 顆就是 18 A。5V 供電與線徑要算,不要用開發板的 5V 腳硬撐
  • 去耦電容:每顆 LED 旁邊放 0.1 µF,燈條兩端加大電容(1000 µF 等級),抑制瞬間電流造成的電壓突降
  • 位準:ESP32 輸出 3.3 V,WS2812 規格上要 5 V 邏輯。多數情況能動,但不穩時要加位準轉換
  • 接地:資料線與電源必須共地,長線建議在末端並聯 33 到 100 Ω 的阻尼電阻

ESP32 跟 Arduino Uno 差在哪

如果你還在選平台,這張表大概是最實用的一段:

特性 Arduino Uno ESP32
最高 LED 數 ~300(軟體 PWM) ~3000(RMT + DMA)
更新率(300 LEDs) ~30 fps ~100 fps
中斷影響 中斷時畫面閃爍 RMT 硬體發送,不受影響
同時驅動燈條數 1 條 8 條(RMT 8 通道)
WiFi 並行 不支援 RMT 背景發送,CPU 可跑 WiFi
Gamma 校正 需軟體實作 內建 gamma32() 硬體加速

差距的來源其實只有一句話:Uno 沒有硬體脈衝產生器。它只能靠軟體計時,所以中斷一來畫面就閃、燈數一多更新率就掉、更不可能同時跑 WiFi。ESP32 的 RMT 把這件事變成硬體工作,順便外帶 8 個通道與 gamma 硬體加速。

ESP32 與 Arduino Uno 對比

實戰:用手機控制燈條

把前面學到的接起來,做一個「手機開網頁就能改顏色」的燈條。架構是 ESP32 開一個小網頁伺服器,收到顏色之後更新緩衝,RMT 在背景把畫面送出去。

// ESP32 WS2812 — WiFi + Web Server 控制
// 結合之前介紹的 ESP32 Web Server,用手機控制 LED 顏色

#include <WiFi.h>
#include <WebServer.h>
#include <Adafruit_NeoPixel.h>

#define NUM_LEDS 64
#define PIN      18

Adafruit_NeoPixel strip(NUM_LEDS, PIN, NEO_GRB + NEO_KHZ800);
WebServer server(80);

// HTML + JavaScript 從 R"rawliteral(...)rawliteral" 取得

void handleRoot() { server.send(200, "text/html", html); }

void handleSet() {
    String color = server.arg("color");
    int bright = server.arg("bright").toInt();
    long rgb = strtol(color.c_str(), NULL, 16);
    uint8_t r = (rgb >> 16) & 0xFF;
    uint8_t g = (rgb >> 8) & 0xFF;
    uint8_t b = rgb & 0xFF;
    strip.setBrightness(bright);
    for (int i = 0; i < NUM_LEDS; i++) {
        strip.setPixelColor(i, strip.Color(r, g, b));
    }
    strip.show();
    server.send(200, "text/plain", "OK");
}

void setup() {
    WiFi.begin("SSID", "PASSWORD");
    while (WiFi.status() != WL_CONNECTED) delay(500);
    strip.begin(); strip.show();
    server.on("/", handleRoot);
    server.on("/set", handleSet);
    server.begin();
}

void loop() { server.handleClient(); }

這個範例故意把「網路」與「燈條」放在一起,因為它正好是 RMT 的價值證明:如果燈條是用軟體時序驅動的,這段程式根本不可能穩。

總結

回到最開頭那句話:WS2812 好用到有點離譜,但它用 ±150 ns 的時序容差換來「一條線驅動整條燈條」的便利。所以結論很簡單:

  • 理解協定:知道 0 與 1 靠的是脈寬,順序是 GRB
  • 正式專案用 RMT:把時序交給硬體,CPU 拿去做別的事
  • 要快就上雙緩衝:邊送邊算,畫面才不會開天窗
  • 硬體別省:電源、電容、接地與位準,這四件事決定你看到的是「效果」還是「閃爍」
標籤: 教學
最後更新: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