Arduino 赤外線リモコンの使い方|ライブラリなしで信号を解析して送る 練習問題7問
この記事では、赤外線リモコンの信号を、専用ライブラリを使わずに解析して、こちらから信号を送信するところまでやります。リモコンの中身は「決まった長さのパルスの並び」です。前回のDHT11で身につけたパルス幅の測り方と似たような感じです。
受信はIRremoteのようなライブラリを使えば数行で動きます。ただ、エアコンのリモコンのように信号が長い機器では、ライブラリが対応していなかったり、うまく解釈できなかったりすることがあります。そのときに頼れるのは、信号そのものを読み解く力。このドリルでは、受信=パルス幅を測ってフレームを解析する、送信=38kHzのキャリアをタイマのレジスタ設定から自作する、の両方を自分の手で書きます。
<自己紹介>
筆者は現役の車載組込みソフトエンジニアです。転職前にArduinoで「温度が上がったらエアコンを赤外線で自動起動する装置」を自作しました。そのとき一番時間がかかったのが、この赤外線信号の解析です。だからこそ、どこから手を付ければいいかを順番に示しながら進めます。
目次
赤外線リモコンで学ぶこと
リモコンは、目に見えない赤外線のLEDを高速に点滅させてデータを送っています。テレビでもエアコンでも仕組みは同じで、点滅の「ある・なし」の時間の並びが0と1を表します。
- リモコンは38kHzで点滅する赤外線(キャリア)を、決まった長さで出したり止めたりしてデータを送る
- 受信モジュールがキャリアの検出までやってくれるので、マイコン側はパルス幅を測るだけで解析できる
- 送信側は、その38kHzのキャリアをTimer2のレジスタ設定(分周と比較一致)で自作する
- フレームを自力で読む練習は、実務の通信ドライバ(CANなど)を読む練習につながる
本記事のコードは、Arduino Uno(ATmega328P)向けにPlatformIOでコンパイル確認しました。実際に信号が読めるか・家電が反応するかは、手元のリモコンと部品で確認しながら進めてください。ボードはこれまでと同じくUno R3(ATmega328P搭載)前提です。
準備するもの
| 使うもの | 用途 |
|---|---|
| Arduino Uno R3またはR3互換ボード | 本記事はUno R3(ATmega328P)を前提にする |
| 赤外線受信モジュール(VS1838B・TL1838など) | リモコンの信号を受けて復調する。スターターキットによく入っている |
| リモコン | 解析する信号源。キット付属のリモコンでも、家のテレビ・エアコンのリモコンでもよい |
| 赤外線LED+抵抗(100〜220Ω) | 送信用。キットに入っていないことがある(後述) |
| ジャンパ線・USBケーブル | 配線と、シリアルでの解析結果の確認に使う |
受信モジュールとリモコンは、定番のスターターキットにはほぼ入っています。注意したいのは送信用の赤外線LEDで、受信モジュールはあってもこちらは入っていないキットがあります。見た目は透明の砲弾型LEDなので、キットの部品表で「IR LED」「赤外線LED」の記載を確認してください。無くても1個数十円で買えるので、まず受信の解析から始めて、送信は部品が届いてからでも大丈夫です。
配線する
受信モジュールは3本つなぐだけです。VCCを5V、OUTをD2、GNDをGNDへつなぎます。DHT11と同じく、端子の並びは製品によって違うので、位置ではなく基板の印字を確認してください。

送信側の赤外線LEDは、D11から抵抗を通してつなぎます。あとで見るとおり、38kHzを出すTimer2の出力ピン(OC2A)がD11に固定されているためで、他のピンには変更できません。LEDには極性があり、足の長いほう(アノード)を抵抗側につなぎます。

まずライブラリで受信してみる
最初に、定番のIRremoteライブラリを使った受信を見ておきます。数行でリモコンのコードが取れます。
#include <IRremote.hpp>
void setup() {
Serial.begin(115200);
IrReceiver.begin(2); // 受信モジュールのOUTをD2へ
}
void loop() {
if (IrReceiver.decode()) {
IrReceiver.printIRResultShort(&Serial); // プロトコル名とコードを表示
IrReceiver.resume();
}
}
これで多くのリモコンは読めます。ただし、プロトコル名とコードが表示されるだけで、信号がどんな形をしているかは見えません。エアコンのような長い信号だと、ライブラリのバッファに収まらず取りこぼすこともあります。ここからは、この中身を自分で書いてみます。
次にライブラリなしで受信する
自力で受信するには、まず「受信モジュールが何をどこまでやってくれるか」を知る必要があります。
リモコンの赤外線は、太陽光や照明にも含まれる赤外線と区別するために、38kHzで高速に点滅させたキャリア(搬送波)として送られます。受信モジュール(TL1838/VS1838データシートでは動作電圧2.7〜5.5V・中心周波数38kHz)は、この38kHz成分だけを取り出して復調し、キャリアがある間はLow、ない間はHighのきれいなデジタル信号にしてOUTへ出してくれます。
つまりマイコン側の仕事は、DHT11のときと同じです。LowとHighが続いた時間をmicros()で測り、その並びを解釈する。これだけです。
NECフォーマットを読み解く
信号の並び方(フォーマット)にはいくつか種類があり、代表格がNECフォーマットです。多くの解説や機器で使われている基準的な形式なので、まずこれを読み解きます。仕様の数値はSB-ProjectsのNECプロトコル解説と赤外線リモコンの通信フォーマット(ELM)で確認できます。

| 部分 | キャリアあり(mark) | キャリアなし(space) |
|---|---|---|
| リーダ(開始合図) | 9ms | 4.5ms |
| ビット0 | 562.5µs | 562.5µs |
| ビット1 | 562.5µs | 1687.5µs |
| ストップ | 562.5µs | — |
データ部は32ビット固定で、アドレス8bit → アドレスの反転8bit → コマンド8bit → コマンドの反転8bitの順に、各バイトの最下位ビット(LSB)から送られます。反転バイトは受信側の誤り確認用で、元のバイトと反転バイトを足すと必ず0xFFになります。DHT11のチェックサムと同じ発想です。
ではコードにします。信号の始まりを待って、LowとHighが続いた時間を配列へ記録していきます。
#define IR_RX_PIN 2
#define MAX_EDGES 200
#define FRAME_END_US 15000UL
uint16_t durations[MAX_EDGES]; // 偶数番=mark、奇数番=spaceの長さ(µs)
uint16_t edge_count = 0;
void ir_record(void) {
while (digitalRead(IR_RX_PIN) == HIGH) {
// 受信モジュールはアイドルHigh。最初のLow(受信開始)を待つ
}
uint8_t level = LOW;
edge_count = 0;
while (edge_count < MAX_EDGES) {
unsigned long t0 = micros();
while (digitalRead(IR_RX_PIN) == level) {
if (micros() - t0 > FRAME_END_US) {
return; // 15ms変化なし=フレーム終了
}
}
durations[edge_count] = (uint16_t)(micros() - t0);
edge_count++;
level = (level == LOW) ? HIGH : LOW;
}
}
記録したdurationsをシリアルへそのまま表示すれば、リモコンの生の波形が数値で見えます。ボタンを押して「9000前後、4500前後、そのあと560前後と560/1690前後の繰り返し」が並べば、それはNECフォーマットです。
並びが確認できたら、デコードまで書けます。リーダを確認し、スペースの長さで0と1を判定して32ビットへ積みます。
// 幅が目安に近いか(±25%)を判定する
bool near_us(uint16_t value, uint16_t target) {
uint16_t low = target - target / 4;
uint16_t high = target + target / 4;
if (value >= low && value <= high) {
return true;
} else {
return false;
}
}
bool ir_decode_nec(unsigned long *result) {
if (edge_count < 2 + 64) {
return false; // リーダ+32bit分のエッジがない
}
if (!near_us(durations[0], 9000) || !near_us(durations
, 4500)) {
return false; // リーダが9ms+4.5msでない
}
unsigned long value = 0;
for (uint8_t i = 0; i < 32; i++) {
uint16_t mark = durations
;
uint16_t space = durations
;
if (!near_us(mark, 560)) {
return false;
}
if (near_us(space, 1690)) {
value |= (1UL << i); // LSBファーストで積む
} else if (!near_us(space, 560)) {
return false;
}
}
*result = value;
return true;
}
±25%の幅を持たせているのは、実際のパルス幅が理論値どおりには来ないためです。リモコン側の精度、受信モジュールの応答遅れ、micros()の分解能(4µs単位)が全部少しずつずれを作ります。仕様値ぴったりの一致判定にすると、まともに動きません。
エアコンのリモコンはもっと長い
家のエアコンのリモコンだと上述とは異なります。日本の家電の多くはNECではなく家製協(AEHA)フォーマット系で、単位時間T=350〜500µs(代表425µs)、リーダは8T+4T、ビット0は1T+1T、ビット1は1T+3Tという構成です(ELMの解説)。しかも長さが可変で、エアコンは温度・モード・風量など設定を丸ごと毎回送るため、フレームが数百ビットになることがあります。
ただし、解析の考え方は変わりません。リーダを見つけて、パルス幅で0と1を区別する。それだけです。実用上は、デコードして意味を理解しなくても、記録したmark/spaceの長さの列をそのまま覚えて、そのまま再生する方法が確実です。この「生データの再生」は次の送信でやります。バッファ(MAX_EDGES)は、エアコンの長いフレームに合わせて増やしてください。Unoでは1エッジ2バイトなので、たとえば400エッジで800バイトです。
次にライブラリなしで送る
送信は受信の逆です。38kHzのキャリアを「出す・止める」を、決まった長さで繰り返すことでフレームを作ります。問題は、その38kHzをどう作るかです。
digitalWriteとdelayMicrosecondsの手作業でも作れなくはないですが、38kHzは周期26.3µs、半周期13µs強で、ソフトの手動制御では精度が出ません。ここでタイマのドリルで学んだ考え方の出番です。タイマの比較一致でピンを自動で反転(トグル)させれば、CPUの手を借りずに正確な38kHzが出続けます。Timer1は周期処理で使っているので、今回は8ビットのTimer2を使います。
データシートからTimer2の設定を読み解く
① まず、どのピンに出るかを確認します。Timer2の比較一致出力はOC2Aという機能ピンで、ATmega328Pの端子ではPB3です。データシートのポートB代替機能表に、こう書かれています。
PB3: MOSI (SPI bus master output/slave input), OC2A (Timer/Counter2 output compare match A output), PCINT3 (pin change interrupt 3)
(訳:PB3端子は、SPIのMOSI、Timer/Counter2の比較一致A出力(OC2A)、ピン変化割り込み3を兼ねる。)出典:ATmega328P Datasheet(Microchip, 7810D–AVR–01/15)Table 13-3 Port B Pins Alternate Functions, p.65 より原文引用(© Microchip Technology)
| Arduinoのピン | ATmega328Pの端子 | Timer2の機能 |
|---|---|---|
| D11 | PB3(Port Bの3番ビット) | OC2A=比較一致A出力 |
D11とPB3の対応は、Lチカのドリルでも使ったArduino UNO R3データシート(A000066)のピン配置図で確認できます。ハードウェアがこの組み合わせで配線されているため、キャリア出力はD11から変更できません。
② 次に、制御レジスタTCCR2Aのビット構成を見ます。8ビットそれぞれに役割があります(該当ビットを太字にしています)。
| bit7 | bit6 | bit5 | bit4 | bit3 | bit2 | bit1 | bit0 |
|---|---|---|---|---|---|---|---|
| COM2A1 | COM2A0 | COM2B1 | COM2B0 | − | − | WGM21 | WGM20 |
COM2Aビットの役割は、データシートにこう書かれています。
These bits control the output compare pin (OC2A) behavior. If one or both of the COM2A1:0 bits are set, the OC2A output overrides the normal port functionality of the I/O pin it is connected to. However, note that the data direction register (DDR) bit corresponding to the OC2A pin must be set in order to enable the output driver.
(訳:これらのビットはOC2Aピンの動作を制御する。COM2A1:0のどちらかを立てると、OC2A出力がそのI/Oピンの通常のポート機能を上書きする。ただし、出力ドライバを有効にするには、OC2Aピンに対応するデータ方向レジスタ(DDR)のビットをセットしておく必要がある。)出典:ATmega328P Datasheet(Microchip, 7810D–AVR–01/15)17.11.1 TCCR2A – Bits 7:6 – COM2A1:0, p.127 より原文引用(© Microchip Technology)
③ 使うビットの設定値を決めます。非PWMモードでCOM2A1:0=01にすると「Toggle OC2A on compare match」=比較一致のたびにOC2Aが反転します(Table 17-2, p.128)。動作モードはWGM21=1でCTCモード(比較一致でカウンタが0へ戻る。Table 17-8, p.130)。分周はTCCR2BのCS22:0=001で分周なしです(Table 17-9, p.131)。
| レジスタ・ビット | 設定値 | 役割 |
|---|---|---|
| TCCR2AのWGM21 | 1 | CTCモード(OCR2Aの値まで数えたら0へ戻る) |
| TCCR2AのCOM2A0 | 1(キャリアON時のみ) | 比較一致のたびにOC2A(D11)を反転 |
| TCCR2BのCS20 | 1 | 分周なし(16MHzのまま数える) |
| OCR2A | 209 | 比較一致までのカウント数=周波数を決める |
折り返しの値を入れるOCR2Aの役割も、原文で確認しておきます。
The output compare register A contains an 8-bit value that is continuously compared with the counter value (TCNT2). A match can be used to generate an output compare interrupt, or to generate a waveform output on the OC2A pin.
(訳:出力比較レジスタAは8ビットの値を保持し、カウンタ値(TCNT2)と常に比較される。一致は出力比較割り込みの発生にも、OC2Aピンへの波形出力にも使える。)出典:ATmega328P Datasheet(Microchip, 7810D–AVR–01/15)17.11.4 OCR2A – Output Compare Register A, p.131 より原文引用(© Microchip Technology)
OCR2A=209の根拠は計算で出ます。比較一致1回でピンが1回反転するので、出力1周期には反転2回が必要です。タイマのドリルでやった分周の計算と同じ形で、
出力周波数 = 16MHz ÷ (2 × 分周比 × (OCR2A + 1)) = 16,000,000 ÷ (2 × 1 × 210) ≒ 38,095Hz
となり、38kHzにほぼ一致します。受信モジュールの中心周波数には多少の幅があるので、この誤差0.25%は問題になりません。
コードにします。キャリアのON/OFFは、COM2A0ビットを立てる・落とすで切り替えるのがコツです。タイマは回しっぱなしにして、ピンへの接続だけを切り替えます。
void ir_tx_init(void) {
DDRB |= (1 << DDB3); // D11(PB3)を出力に
TCCR2A = (1 << WGM21); // CTCモード。OC2Aはまだ切断
TCCR2B = (1 << CS20); // 分周なし
OCR2A = 209; // 16MHz/(2*1*(209+1)) = 38.095kHz
}
void carrier_on(void) {
TCCR2A |= (1 << COM2A0); // 比較一致でOC2Aをトグル=38kHz出力
}
void carrier_off(void) {
TCCR2A &= (uint8_t)~(1 << COM2A0); // OC2Aを切断して通常ポートに戻す
PORTB &= (uint8_t)~(1 << PORTB3); // 出力をLowに固定(LED消灯)
}
void ir_mark(uint16_t us) {
carrier_on();
delayMicroseconds(us);
}
void ir_space(uint16_t us) {
carrier_off();
delayMicroseconds(us);
}
// NECフォーマットで1フレーム送る
void ir_send_nec(uint8_t addr, uint8_t cmd) {
uint8_t bytes
;
bytes[0] = addr;
bytes
= (uint8_t)~addr;
bytes
= cmd;
bytes
= (uint8_t)~cmd;
ir_mark(9000); // リーダ
ir_space(4500);
for (uint8_t i = 0; i < 4; i++) {
for (uint8_t b = 0; b < 8; b++) {
ir_mark(560);
if ((bytes[i] >> b) & 1) { // LSBファースト
ir_space(1690);
} else {
ir_space(560);
}
}
}
ir_mark(560); // ストップビット
carrier_off();
}
// 記録した生のパルス幅列をそのまま再生する(エアコン等の長いフレーム用)
void ir_send_raw(const uint16_t *buf, uint16_t len) {
for (uint16_t i = 0; i < len; i++) {
if (i % 2 == 0) {
ir_mark(buf[i]);
} else {
ir_space(buf[i]);
}
}
carrier_off();
}
最後のir_send_raw()が、エアコン対策の本命です。受信で記録したdurationsをそのまま渡せば、フォーマットの意味を完全に理解していなくても、同じ信号を再現できます。市販の「学習リモコン」と同じ原理ですね。エアコンの完全な制御(温度を自由に変えるなど)はフレームの中身の解読が必要ですが、「記録したボタンと同じ操作をさせる」ならこれで足ります。
IrSender.sendNEC()がやっているのは、まさにこのタイマ設定とmark/spaceの切り替えです。ライブラリではTimer2のPWMモードでキャリアを作る実装ですが、「タイマでキャリアを作り、接続を切り替えて変調する」という骨格は同じです。
練習問題(基礎5+発展2)
基礎5問は、信号の仕組みと受信モジュールの動きを確認します。発展2問は、キャリアの計算とraw再生を扱います。まず自分で考えてから回答を開いてください。
基礎 Q1
リモコンの赤外線は、なぜ38kHzで点滅させた光(キャリア)として送りますか。
解答・解説を見る
解答:太陽光や照明に含まれる赤外線と区別するため。
解説:受信モジュールは38kHz付近で点滅する光だけを通すフィルタを内蔵しています。ただ光るだけの赤外線はノイズとして無視されるので、離れた場所からでも安定して通信できます。
基礎 Q2
受信モジュール(VS1838B系)のOUTは、リモコンの信号を受けている間、HighとLowのどちらになりますか。
解答・解説を見る
解答:Low(何も受けていないときはHigh)。
解説:キャリアを検出している間だけOUTがLowへ落ちます。記録コードで最初に「Lowになるのを待つ」のはこのためです。リモコンの点滅そのものではなく、復調済みの信号が出てくる点も確認しておいてください。
基礎 Q3
NECフォーマットで、ビット0とビット1は何の違いで区別されますか。
解答・解説を見る
解答:markのあとに続くspace(キャリアなし区間)の長さ。0は562.5µs、1はその3倍の1687.5µs。
解説:markの長さはどちらも562.5µsで同じです。DHT11が「Highの長さ」で0/1を分けていたのと同じ発想で、パルス幅を測れば判定できます。
基礎 Q4
NECフレームの「反転アドレス」「反転コマンド」は何のために送られますか。
解答・解説を見る
解答:受信側の誤り確認のため。元のバイトと反転バイトを足して0xFFにならなければ、受信に失敗している。
解説:赤外線は外乱を受けやすいので、データの半分を検証用に使う設計になっています。DHT11のチェックサム、実務の通信のCRCと同じ「受信データを疑う」仕組みです。
基礎 Q5
デコードのパルス幅判定で、仕様値ぴったりではなく±25%の幅を持たせているのはなぜですか。
解答・解説を見る
解答:実際のパルス幅は、リモコンの精度・受信モジュールの応答遅れ・micros()の分解能でずれるから。
解説:通信の受信処理では、仕様値からの許容範囲を決めて判定するのが基本です。厳しすぎると正常な信号を捨て、緩すぎると別のフォーマットを誤認します。
発展 Q6
SONY系のリモコンはキャリアが40kHzです。分周なしのまま、OCR2Aをいくつにすれば40kHzになりますか。
解答・解説を見る
解答:OCR2A = 199。
// 出力周波数 = 16MHz / (2 * 分周比 * (OCR2A + 1))
// 40000 = 16000000 / (2 * 1 * (OCR2A + 1))
// OCR2A + 1 = 16000000 / 80000 = 200
OCR2A = 199;
解説:式を変形してOCR2A+1について解くだけです。タイマのドリルでやった「目標の時間から逆算して設定値を決める」流れが、周波数でも同じように使えます。
発展 Q7
ir_send_raw()では、配列の偶数番目をmark、奇数番目をspaceとして再生しています。この前提はどこから来ていますか。
解答・解説を見る
解答:受信側のir_record()が、必ずmark(最初のLow)から記録を始めるから。
解説:記録はLowの検出から始まるので、durations[0]は必ずmarkの長さです。以降は交互に並ぶため、偶数=mark、奇数=spaceが保証されます。送信と受信でデータの並びの約束(フォーマット)を共有するのは、あらゆる通信の基本です。
例:実務で実際に書くコード
「フレームの生データを記録して、リーダを見つけて、決められた幅で0/1を切り出す」。この作業は、私の実務でもほぼ同じ形で出てきます。車の中では、ECU同士がCAN通信というプロトコルで常にデータをやり取りしていて、その1フレームはIDとデータ8バイトの決まった構造をしています。
/* 受信フレームのIDを見て、担当するデータだけ取り出す */
if (frame.id == CAN_ID_ENGINE_TEMP) {
/* 仕様書どおりのバイト位置・ビット位置から値を切り出す */
temp = (int16_t)((frame.data[0] << 8) | frame.data
);
if (check_crc(&frame) != CRC_OK) {
return ERR_CRC; /* 壊れたフレームは捨てる */
}
}
フレームのどこに何のビットがあるかは通信仕様書に定義されていて、その仕様書と受信データを突き合わせながら解析します。リモコンの解析でやった「数値の羅列を仕様と見比べて意味を見つける」経験は、この仕事の入り口の練習になります。私が転職時の面接で製作物を語れたのも、この解析を自力でやり切った経験があったからです。
最後につまずきポイントをおさらい
受信側でつまずくのは、リモコンによってフォーマットが違うことです。NECのつもりでデコードして失敗するときは、まず生データの表示に戻って、リーダの長さを見てください。9ms前後ならNEC系、3.4ms前後(8T)なら家製協系の可能性が高いです。あとは、ボタンを押し続けたときに送られるリピートフレーム(NECでは9ms+2.25ms+ストップだけの短い信号)をデータ本体と混同しないことも大事です。
もう一つ、Timer2を使う本記事の送信コードは、D3・D11のanalogWrite(Timer2のPWM)と同時には使えません。タイマは有限の共有資源で、どの機能がどのタイマを使うかを把握して割り当てる。これも実務のマイコン開発で毎回やる調整です。
よくある質問(FAQ)
ライブラリを使ってはいけないのですか?
実際の工作ではIRremoteを使ってかまいません。このドリルの目的は、ライブラリが中で何をしているかを理解することです。中身が分かっていれば、ライブラリが対応していないリモコンや、うまく動かないときにも生データへ立ち返って対処できます。
エアコンが操作できません
エアコンのフレームは長いので、記録バッファ(MAX_EDGES)に収まりきっていない可能性をまず疑ってください。記録したエッジ数を表示して、フレームの途中で切れていないかを確認します。また、機種によっては1回の操作で複数フレームを間隔を空けて送るものがあり、その場合はフレーム間の待ち時間も含めて再現する必要があります。
何も受信されません
配線(VCC・OUT・GNDの印字とピンの対応)と、リモコンの電池を確認してください。受信モジュールのOUTをそのままdigitalReadして、リモコンを近づけてボタンを押したときに値が変わるかを見るのが最小の切り分けです。太陽光が受光部へ直接当たる場所では誤動作することもあります。
次に読む
これで、温湿度を読む入力(DHT11)と、エアコンを動かす出力(赤外線)の両方の部品がそろいました。次は、電源が切れても設定や状態を失わないための保存の仕組みへ進みます。総合課題の「自動温湿度調整システム」に、また一歩近づきます。
- Arduino EEPROM保存のドリル(#6):状態や異常の記録を残し、あとから解析できる設計を学ぶ
- Arduino タイマのドリル(#3):分周と比較一致の考え方を復習する
- Arduino DHT11のドリル(#4):パルス幅計測の受信側を復習する