0x6A Logbook

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

ESP32 紅外線遙控 IR Remote 完整教學:NEC 協定解碼與 RMT 硬體實作

2026 年 6 月 13 日 601點熱度 0人點贊 0條評論

ESP32 紅外線遙控 IR Remote 完整教學封面

先搞懂:紅外線遙控到底在傳什麼

很多人第一次拆紅外線遙控器會嚇一跳:裡面居然沒有什麼神奇的晶片,就是一顆紅外線 LED、一顆按鍵矩陣、一顆小小的驅動 IC。它傳出去的也不是「編碼過的資料」那麼玄,就只是一串會閃的紅外線。

而閃的方式有幾個關鍵:

  • 載波(Carrier):38 kHz 方波(duty cycle 約 1/3),LED 以此頻率快速閃爍
  • 調變:載波「存在」(burst)代表有訊號,載波「停止」代表沒訊號
  • 協定:不同廠商有不同協定(NEC、Sony、RC-5、RC-6),最常見的是 NEC
  • 接收端:整合式接收頭(如 VS1838B)會把 38 kHz 載波解調掉,輸出乾淨的 TTL 電平

最後那一點很重要:接收頭已經幫你把載波處理掉了。所以 MCU 看到的只是「高電位多久、低電位多久」—— 解碼這件事的本質是「量時間」,不是「讀位元」。

紅外線指令的發送與接收鏈路

NEC 協定完全解析

NEC 是市面上最常見的紅外線協定,也是最好懂的。它的設計邏輯是:一個很長的引導碼開頭,然後 32 bits 的資料,每個欄位後面都跟著自己的反相。

NEC 訊框結構與時間預算

引導碼是 9 ms 載波 + 4.5 ms 靜默,總共 13.5 ms —— 這個超長的開頭就是「訊框開始了」的訊號,接收端只要看到這種長度的組合就知道要開始收。

接著的 32 bits 分成四個欄位:位址、反位址、指令、反指令。反相欄位不是冗餘,它是 NEC 內建的錯誤檢測 —— 位址與反位址必須互補,否則這筆資料就是不可信。

NEC 的 32 bits 欄位排法

位元怎麼分辨?跟 WS2812 剛好相反,NEC 的位元高電位長度都一樣(560 µs),差別在靜默多久:

NEC 邏輯 0 與 1 的波形

所以解碼時量的其實是「低電位持續時間」:大約 560 µs 就是 0,大約 1.69 ms 就是 1。這件事直接決定了硬體需求 —— 你需要一個能量到微秒級時間的東西,這正是 RMT 的專長。

ESP32 RMT:把「量時間」交給硬體

RMT 本來是為了紅外線遙控設計的,所以拿它來解 NEC 算是回到本業。它的能力剛好對上需求:

  • 8 個通道,可任意映射到 GPIO
  • 發送模式:硬體自動輸出整串脈衝,CPU 可以同時做別的事
  • 接收模式:硬體自動量測每個脈衝寬度,直接寫進 RAM
  • 解析度 12.5 ns(80 MHz 不分頻),遠比 NEC 需要的精度高
  • 每次最多 256 組脈衝,對 NEC 的 32 bits 綽綽有餘

RMT 接收的解碼流程

流程是這樣:接收頭把解調後的電平送進 RMT,RMT 把每個高/低電位的長度寫進環形緩衝,滿了之後中斷通知主程式;主程式拿到的是一整串數字,再自己判斷「這是不是引導碼、這一位是 0 還是 1」。

換句話說:硬體負責「量」,軟體負責「判」。這個分工讓 CPU 完全不必忙著等波形 —— 這也是軟體輪詢做不到的事。

接收實作一:用 RMT 原生解碼 NEC

這是「自己動手」的版本:設定 RMT 接收通道,讀回脈衝陣列,然後手工判斷引導碼與每個位元。程式碼看起來長,但邏輯很直白。

// ESP32 RMT IR 接收 — NEC 協定解碼
#include <driver/rmt.h>
#include   // 或使用 Arduino IRremote 函式庫

#define IR_RX_PIN   15   // RMT 通道 0 → GPIO 15
#define RMT_RX_CH   0

// NEC 協定解碼結果
typedef struct {
    uint16_t address;
    uint16_t command;
    bool     repeat;
} nec_decode_t;

// RMT 接收配置
rmt_config_t rmt_rx = {
    .rmt_mode = RMT_MODE_RX,
    .channel  = (rmt_channel_t)RMT_RX_CH,
    .gpio_num = (gpio_num_t)IR_RX_PIN,
    .clk_div  = 80,  // 80MHz / 80 = 1MHz → 1μs 解析度
    .mem_block_num = 1,
    .rx_config = {
        .idle_threshold = 10000,  // 10ms 無訊號視為空閒
        .filter_ticks_thresh = 100,  // 濾除 < 100μs 的雜訊
        .filter_en = true,
    }
};

// NEC 協定解碼
bool decodeNEC(rmt_item32_t* items, size_t len, nec_decode_t* result) {
    if (len < 34) return false; // Leader(2) + 32 bits // 檢查 Leader code (9ms HIGH + 4.5ms LOW) if (abs((int)items[0].duration0 - 9000) > 1000) return false;
    if (abs((int)items[0].duration1 - 4500) > 1000) return false;

    // 檢查是否為重複碼
    if (len == 2 && abs((int)items[0].duration0 - 9000) < 1000 &&
        abs((int)items[0].duration1 - 2250) < 500) { result->repeat = true;
        return true;
    }

    // 解碼 32 bits (items[1] ~ items[32])
    uint32_t code = 0;
    for (int i = 0; i < 32; i++) { code >>= 1;
        if (items[i+1].duration1 > 1000) {  // > 1ms = logic '1'
            code |= 0x80000000;
        }
    }

    result->address = (code >> 24) & 0xFF;
    result->command = (code >> 8) & 0xFF;
    result->repeat = false;
    return true;
}

void setup() {
    Serial.begin(115200);

    // 安裝 RMT 接收
    rmt_config(&rmt_rx);
    rmt_driver_install((rmt_channel_t)RMT_RX_CH, 1000, 0);

    Serial.println("IR 接收器就緒,等待紅外線訊號...");
}

void loop() {
    RingbufHandle_t rb = NULL;
    rmt_get_ringbuf_handle((rmt_channel_t)RMT_RX_CH, &rb);

    size_t len = 0;
    rmt_item32_t* items = (rmt_item32_t*)xRingbufferReceive(rb, &len, portMAX_DELAY);

    if (items && len > 0) {
        nec_decode_t result = {0};
        if (decodeNEC(items, len / sizeof(rmt_item32_t), &result)) {
            if (result.repeat) {
                Serial.println("重複碼 (按鍵持續按住)");
            } else {
                Serial.printf("NEC 解碼成功 | 位址: 0x%02X | 命令: 0x%02X\n",
                              result.address, result.command);
            }
        }
        vRingbufferReturnItem(rb, (void*)items);
    }
}

寫這個版本最大的收穫是:你會真的看懂 NEC 的每個位元是怎麼被「量」出來的,而不是把 decode 當黑盒子用。

接收實作二:用 IRremote 函式庫

如果你的目標只是「讀到按鍵碼」,那就別自己寫了。IRremote 這類函式庫已經把傳輸層包好,連協議判斷都幫你做:

// ESP32 IR 接收 — 使用 IRremote 函式庫
#include 

#define IR_RX_PIN 15
IRrecv irrecv(IR_RX_PIN);
decode_results results;

void setup() {
    Serial.begin(115200);
    irrecv.enableIRIn();
    Serial.println("IRremote 就緒,按下遙控器...");
}

void loop() {
    if (irrecv.decode(&results)) {
        Serial.printf("協定: %s | 值: 0x%08X | 位元數: %d\n",
                      results.decode_type == NEC ? "NEC" :
                      results.decode_type == SONY ? "SONY" :
                      results.decode_type == PANASONIC ? "PANASONIC" :
                      results.decode_type == RC5 ? "RC5" :
                      results.decode_type == RC6 ? "RC6" : "UNKNOWN",
                      results.value,
                      results.bits);
        irrecv.resume();
    }
}

代價是你得接受它的取捨:它底層可能用中斷或輪詢而不是 RMT,遇到忙碌的系統(例如同時跑 WiFi)就可能漏碼。原型用函式庫,產品自己寫 RMT,這是很常見的分工。

發送實作一:用 IRremote 模擬遙控器

發送比接收單純 —— 你要做的只是「複製遙控器會發的那串脈衝」:

// ESP32 IR 發送 — 使用 IRremote 函式庫
#include 

#define IR_TX_PIN 4
IRsend irsend(IR_TX_PIN);

// NEC 命令對照表(以電視為例)
#define NEC_CH_MINUS   0x45
#define NEC_CH         0x46
#define NEC_CH_PLUS    0x47
#define NEC_VOL_DOWN   0x15
#define NEC_VOL_UP     0x16
#define NEC_MUTE       0x0D
#define NEC_POWER      0x44
#define NEC_INPUT      0x40

void setup() {
    Serial.begin(115200);
    irsend.begin();
    Serial.println("IR 發送器就緒");
}

void loop() {
    if (Serial.available()) {
        char c = Serial.read();
        switch (c) {
            case 'v': irsend.sendNEC(NEC_VOL_UP, 32);   break;
            case 'V': irsend.sendNEC(NEC_VOL_DOWN, 32); break;
            case 'm': irsend.sendNEC(NEC_MUTE, 32);     break;
            case 'p': irsend.sendNEC(NEC_POWER, 32);    break;
            case '0'...'9':
                // 以 CH+ 範例,實際需查遙控器編碼
                irsend.sendNEC(NEC_CH, 32);
                break;
        }
    }
}

這裡最容易被忽略的是載波頻率必須跟接收頭匹配(多數是 38 kHz)。頻率不對,接收頭就當作沒看到,你會覺得「程式明明有跑,冷氣就是不理我」。

發送實作二:RMT 原生發送

要精確控制脈衝、或者要送一些函式庫不支援的冷門協定,就得自己組脈衝序列:

// ESP32 RMT 原生發送 — 自行組裝 NEC 脈衝
#include <driver/rmt.h>

#define IR_TX_PIN   4
#define RMT_TX_CH   1

rmt_item32_t necItems[34];  // Leader(2) + 32 bits

void buildNECFrame(uint16_t addr, uint16_t cmd, rmt_item32_t* items) {
    // Leader code: 9ms HIGH + 4.5ms LOW
    items[0].duration0 = 9000;
    items[0].level0 = 1;
    items[0].duration1 = 4500;
    items[0].level1 = 0;

    // 32 bits data
    uint32_t code = addr | ((uint32_t)~addr << 8) | (cmd << 16) | ((uint32_t)~cmd << 24);
    for (int i = 0; i < 32; i++) {
        items[i+1].duration0 = 560;
        items[i+1].level0 = 1;
        if (code & (1 << 31)) {
            items[i+1].duration1 = 1690;  // logic '1'
        } else {
            items[i+1].duration1 = 560;   // logic '0'
        }
        items[i+1].level1 = 0;
        code <<= 1;
    }
}

void setup() {
    rmt_config_t rmt_tx = {
        .rmt_mode = RMT_MODE_TX,
        .channel  = (rmt_channel_t)RMT_TX_CH,
        .gpio_num = (gpio_num_t)IR_TX_PIN,
        .clk_div  = 80,
        .mem_block_num = 1,
        .tx_config = {
            .carrier_freq_hz = 38000,  // 38kHz 載波
            .carrier_level = 1,
            .carrier_en = true,         // 自動產生 38kHz 載波!
            .idle_level = 0,
            .loop_en = false,
        }
    };
    rmt_config(&rmt_tx);
    rmt_driver_install((rmt_channel_t)RMT_TX_CH, 0, 0);
}

void sendNEC(uint16_t addr, uint16_t cmd) {
    buildNECFrame(addr, cmd, necItems);
    rmt_write_items((rmt_channel_t)RMT_TX_CH, necItems, 34, true);
    Serial.printf("已發送 NEC: 0x%02X 0x%02X\n", addr, cmd);
}

自己組的好處是完全掌控時序與長度,也能做出「把學到的碼再原樣發出去」這種學習型遙控器的核心功能。

各家協定比一比

NEC 不是唯一,但它的設計(引導碼 + 反相驗證)幾乎是最好解的一種。下表把常見的幾個放一起看:

特性 NEC Sony SIRC Philips RC-5 Philips RC-6
載波頻率 38 kHz 40 kHz 36 kHz 36 kHz
資料長度 32 bits 12/15/20 bits 14 bits 36 bits
引導碼 9ms + 4.5ms 2.4ms + 0.6ms 無 3ms + 0.89ms
位元 '0' 560μs + 560μs 1.2ms + 0.6ms 889μs + 889μs 444μs + 444μs
位元 '1' 560μs + 1.69ms 2.4ms + 0.6ms 889μs + 889μs 444μs + 444μs
重複碼 Leader + 2.25ms 無 Toggle bit Toggle bit
錯誤檢測 反相 + 固定長度 固定長度 無(Toggle 防重複) CRC (RC-6 MCE)

看完這張表大概就能理解:「一個遙控器打天下」有多難。載波從 36 到 40 kHz、位元長度從 444 µs 到 2.4 ms、錯誤檢測方式每家都不一樣。

多協定萬用遙控器怎麼做

實務上的做法不是「寫四個解碼器」,而是先把脈衝序列抓下來,再從特徵判斷是哪個協定:引導碼長度是 9 ms 就猜 NEC,是 2.4 ms 就猜 Sony,沒有引導碼但位元固定長度就猜 RC-5。判斷完再套對應的解碼規則。

各協定的解碼要點與常見坑

// ESP32 萬用遙控器 — 學習 + 發送
#include 
#include 


#define IR_RX_PIN 15
#define IR_TX_PIN 4
#define BTN_LEARN 0   // GPIO 0 (BOOT 按鍵)

IRrecv irrecv(IR_RX_PIN);
IRsend irsend(IR_TX_PIN);
decode_results results;

// 儲存已學習的編碼
std::map<String, decode_results> learnedCodes;

void setup() {
    Serial.begin(115200);
    pinMode(BTN_LEARN, INPUT_PULLUP);
    irrecv.enableIRIn();
    irsend.begin();
    Serial.println("=== 萬用遙控器 ===");
    Serial.println("按住 BOOT 按鍵 + 對準遙控器 = 學習");
    Serial.println("輸入 's <命名>' 來發送已學習的指令");
}

void learnCode(String name) {
    Serial.printf("等待接收 %s 的紅外線訊號...\n", name.c_str());
    while (!irrecv.decode(&results)) delay(10);
    learnedCodes[name] = results;
    irrecv.resume();
    Serial.printf("已學習: %s (協定=%d, 值=0x%08X)\n",
                  name.c_str(), results.decode_type, results.value);
}

void sendCode(String name) {
    if (learnedCodes.find(name) == learnedCodes.end()) {
        Serial.printf("未找到: %s\n", name.c_str());
        return;
    }
    auto& code = learnedCodes[name];
    irsend.send(code.decode_type, code.value, code.bits);
    Serial.printf("已發送: %s\n", name.c_str());
}

void loop() {
    if (digitalRead(BTN_LEARN) == LOW) {
        delay(50);
        if (digitalRead(BTN_LEARN) == LOW) {
            while (digitalRead(BTN_LEARN) == LOW) delay(10);
            Serial.println("輸入指令名稱...");
            while (!Serial.available()) delay(10);
            String name = Serial.readStringUntil('\n');
            name.trim();
            if (name.length() > 0) learnCode(name);
        }
    }

    if (Serial.available()) {
        String cmd = Serial.readStringUntil('\n');
        cmd.trim();
        if (cmd.startsWith("s ")) {
            sendCode(cmd.substring(2));
        }
    }
}

這段的核心其實是「學習」:收到一組新碼就存起來,之後可以原樣重發。比起硬編各家協定,這種做法在面對雜牌家電時實用得多。

實務上的六個坑

程式寫對了,還是可能完全不動作。這些坑幾乎都在實體層:

紅外線遙控實務注意事項

其中我認為最容易被忽略的是接收頭的角度。它的靈敏度集中在正前方大約 ±45°,側面幾乎收不到;很多人以為是程式問題,其實只是接收頭轉了 90 度。

總結

紅外線遙控看起來是很舊的技術,但它是個很好的練習題:

  • 調變:38 kHz 載波 + burst 有無 = 最基本的 OOK
  • 時間編碼:位元不是看電壓高低,而是看持續時間
  • 錯誤檢測:NEC 用反相欄位,RC-6 用 CRC
  • 硬體加速:用 RMT 量時間,CPU 才能去做別的事

把這四件事想通,接下來看任何一種單線通訊協定(包括 WS2812、DHT11、甚至 LoRa 的基礎)都會快很多。

標籤: 教學
最後更新: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