
先搞懂:紅外線遙控到底在傳什麼
很多人第一次拆紅外線遙控器會嚇一跳:裡面居然沒有什麼神奇的晶片,就是一顆紅外線 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 的資料,每個欄位後面都跟著自己的反相。

引導碼是 9 ms 載波 + 4.5 ms 靜默,總共 13.5 ms —— 這個超長的開頭就是「訊框開始了」的訊號,接收端只要看到這種長度的組合就知道要開始收。
接著的 32 bits 分成四個欄位:位址、反位址、指令、反指令。反相欄位不是冗餘,它是 NEC 內建的錯誤檢測 —— 位址與反位址必須互補,否則這筆資料就是不可信。

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

所以解碼時量的其實是「低電位持續時間」:大約 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 把每個高/低電位的長度寫進環形緩衝,滿了之後中斷通知主程式;主程式拿到的是一整串數字,再自己判斷「這是不是引導碼、這一位是 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
這段的核心其實是「學習」:收到一組新碼就存起來,之後可以原樣重發。比起硬編各家協定,這種做法在面對雜牌家電時實用得多。
實務上的六個坑
程式寫對了,還是可能完全不動作。這些坑幾乎都在實體層:

其中我認為最容易被忽略的是接收頭的角度。它的靈敏度集中在正前方大約 ±45°,側面幾乎收不到;很多人以為是程式問題,其實只是接收頭轉了 90 度。
總結
紅外線遙控看起來是很舊的技術,但它是個很好的練習題:
- 調變:38 kHz 載波 + burst 有無 = 最基本的 OOK
- 時間編碼:位元不是看電壓高低,而是看持續時間
- 錯誤檢測:NEC 用反相欄位,RC-6 用 CRC
- 硬體加速:用 RMT 量時間,CPU 才能去做別的事
把這四件事想通,接下來看任何一種單線通訊協定(包括 WS2812、DHT11、甚至 LoRa 的基礎)都會快很多。
文章評論