fiord Advent Calendar 2025 1 日目の内容となります。
Arduinoで電子工作を始めるとき、最初に「Lチカ(LEDをチカチカ点滅させる)」というものを実装することが多いです。しかし、同じLチカでも、その実現方法によってプログラムの柔軟性や精度は劇的に変わります。
この記事では、Arduino UNO (ATmega328P) を使用し、以下の3つのレベルでタイマー処理の実装方法を解説します。
-
delay()による実装(直感的だが、処理が止まる) -
millis()による実装(ノンブロッキング処理) - タイマーレジスタ直接操作(高精度・割り込み処理)
原則として、今回は LED_BUILTIN から 1 秒周期で LED の ON/OFF を切り替えるようにします。
1. delay() による実装
最も基本的で、教科書通りの実装方法です。delay() 関数は、指定した時間(ミリ秒)だけプログラムを「一時停止」させます。
実装コード
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
}
void loop() {
digitalWrite(LED_BUILTIN, HIGH); // 点灯
delay(1000); // 1000ミリ秒(1秒)待機
digitalWrite(LED_BUILTIN, LOW); // 消灯
delay(1000); // 1000ミリ秒(1秒)待機
}
この方法の問題点:メインスレッドが待機状態に陥り、その間他の処理が出来ない
このコードは非常にシンプルですが、致命的な弱点があります。それは、delay() を実行している間、Arduinoは基本的に他の処理を行えないということです。
これを「ブロッキング処理」と呼びます。例えば、delay(1000) の間にスイッチが押されても、Arduinoはメインループ内でその入力を検知できません。
- メリット: コードが簡単で直感的。
- デメリット: LED点滅中にセンサーの値を読んだり、ボタン入力を受け付けたりできない(マルチタスクが困難)。
2. millis() による実装
次に、delay() の問題を解決する方法です。millis() 関数は、Arduinoが起動してからの経過時間(ミリ秒)を返します。これを利用して、「前回の処理から何秒経ったか?」を計算し、時間が来たときだけ処理を行うようにします。
実装コード
int ledState = LOW; // LEDの状態を保持する変数
unsigned long previousMillis = 0; // 前回の時間を記録
const long interval = 1000; // 点滅間隔(1000ミリ秒)
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
}
void loop() {
// 現在の時間を取得
unsigned long currentMillis = millis();
// 前回の更新から interval 以上経過したかチェック
if (currentMillis - previousMillis >= interval) {
// 時間を更新
previousMillis = currentMillis;
// LEDの状態を反転
if (ledState == LOW) {
ledState = HIGH;
} else {
ledState = LOW;
}
digitalWrite(LED_BUILTIN, ledState);
}
// ここに他の処理を書いても、LED点滅に影響されない!
// 例: checkButtonPress();
}
delay() の問題点が解決できた理由
このコードは「ノンブロッキング処理」と呼ばれます。
if 文の条件が満たされない場合、プログラムはすぐに次の行へ進みます。つまり、LEDの点滅タイミングを待っている間も、loop() 内で他の処理(センサー読み取りや通信など)を高速に回し続けることができます。
3. タイマーレジスタを直接利用する方法
millis() は非常に便利ですが、万能ではありません。以下のような限界があります。
分解能の限界
最小単位が 1 ミリ秒であり、マイクロ秒単位の厳密な制御には向かない。一応 micros() という関数がありますが、メインループにかかる時間が 1 マイクロ秒を超えることが想定されます。
具体的に、2 のコードに「1 秒間何回メインループを処理したか」をカウントする機構を加えると、下記のようになりました。
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
Serial.begin(9600);
}
void loop() {
// 現在の時間を取得
unsigned long currentMillis = millis();
// 開始 1 秒後(setup の処理時間を考慮)から 2 秒後までに何回ループしたかをカウント
static unsigned long loopCnt = 0;
if (1000 <= currentMillis && currentMillis < 2000) {
loopCnt++;
}
if (currentMillis > 2000 && loopCnt > 0) {
Serial.println(loopCnt);
loopCnt = 0;
}
...(以降同じ)
結果:
224116
少なくとも私の環境では 1 秒に約 24 万回、つまり 4.2 マイクロ秒に 1 回ループが回っていることになります。これ以上の精度が求められる場合、後述する 16MHz の限度はありますが、他の方法を考える必要があります。
ジッター(ゆらぎ)
先ほどの例では loop の中身は比較的軽量で、実際のコード実行においては L チカのコードもほぼ millis() による時間計測および if 文による条件分岐のみが行われています。
ただし、loop() 内の他の処理が重くなると、正確な周期が保てなくなる懸念があります。
これらを解決し、ハードウェアレベルで正確なタイミングを作るために、AVRマイコン(ATmega328P)の「タイマー割り込み」機能を使います。今回は、16ビットタイマーである「Timer1」を使用し、CPUの処理状況に関係なく正確に1ミリ秒ごとに割り込みを発生させます。
1 ミリ秒ごとに割り込みを発生させ、今回の場合、カウンターの値を 1 ずつ増やしていきます。1,000 になったタイミングで 1 秒になりますので、そこで LED の状態を切り替えます。
この手法のメリットは、正確な「1ミリ秒という基本リズム(タイムベース)」を作れる点です。 loop() 内で時間を管理するのではなく、この割り込みの中で「300ms経過したら処理A」「500ms経過したら処理B」のようにカウンターを使って管理(ソフトウェアタイマー)することで、メインループの処理遅延に影響されない、擬似的なマルチタスク環境を構築しやすくなります。
計算方法(CTCモード)
Arduino UNO のシステムクロックは 16MHz です。これを分周(Prescaler)し、ターゲットとなるカウント値を計算します。
$$\text{Target Count} = \frac{\text{Clock Frequency}}{\text{Prescaler} \times \text{Target Frequency}} - 1$$
今回は割り込み発生周波数は 1kHz(1 秒に 1,000 回割り込み)とします。
- Clock: 16MHz
- Target Frequency: 1,000 Hz
- Prescaler: 64
$$\text{Target Count} = \frac{16,000,000}{64 \times 1000} - 1 = 249$$
この 249 という値を比較レジスタにセットします。
実装コード(レジスタ操作)
volatile bool ledState = false; // 割り込み内で変更するためvolatileが必要
// 経過時間をカウントする変数
volatile unsigned int timerCount = 0;
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
// --- タイマー設定開始 ---
noInterrupts(); // 設定中は割り込みを停止
// Timer1のリセット
TCCR1A = 0;
TCCR1B = 0;
TCNT1 = 0;
// 比較一致レジスタ(OCR1A)の設定
// 計算式: (16MHz / 64分周 / 1000Hz) - 1 = 249
OCR1A = 249;
// CTCモードに設定 (WGM12ビットを1にする)
TCCR1B |= (1 << WGM12);
// 64 分周に設定 (CS11とCS10ビットを1にする)
TCCR1B |= (1 << CS11) | (1 << CS10);
// タイマー比較一致割り込みを許可 (OCIE1A ビットを1にする)
TIMSK1 |= (1 << OCIE1A);
interrupts(); // 割り込み再開
// --- タイマー設定終了 ---
}
// Timer1 比較一致割り込みサービスルーチン (ISR)
// 1秒ごとに自動的にこの関数が呼ばれる
ISR(TIMER1_COMPA_vect) {
// カウンターを進める
timerCount++;
// 1 秒経過したかチェック
if (timerCount >= 1000) {
timerCount = 0; // カウンターリセット
ledState = !ledState; // 状態を反転
digitalWrite(LED_BUILTIN, ledState); // digitalWrite は遅いので、ポートを直接操作することを推奨します
}
// カウンターリセットが上で行われていますが、例えば 300ms 周期で処理を行う内容があれば、別途ここに記載し、最後に 3,000ms に到達していたらカウンターリセットすればよいです。
}
void loop() {
// loopの中身は完全に空っぽ!
// ここでどれだけ重い処理(例えば無限ループに近い計算)をしても
// 割り込みによってLEDは正確に1秒ごとに点滅し続ける。
}
解説とメリット
- 高精度: ハードウェアカウンタが自動でカウントアップするため、ソフトウェアの処理速度に依存しません。
-
バックグラウンド処理:
ISR(割り込みサービスルーチン)は、メインのloop()とは独立して実行されます。メイン処理がフリーズしていても、タイマーが生きている限りLEDは点滅し続けます。
まとめ
| 実装方法 | 特徴 | 向いている用途 |
|---|---|---|
| delay() | 簡単だが他の処理を止める | 単純なテスト、教育用、シーケンシャルな動作 |
| millis() | 他の処理と並行可能 | 一般的な電子工作、センサー監視と表示の並行処理 |
| レジスタ操作 | 超高精度・割り込み駆動 | 高速PWM、正確な波形生成、リアルタイム制御 |
Lチカ一つとっても、その奥には深い技術の世界が広がっています。用途に合わせて最適なタイマー処理を選べるようになりましょう。
また、今回は割り込みに関する詳細な話を割愛しました。ちょっと興味あるよ!という方は 【Arduino UNO】タイマーレジスタ―を使いこなす も読んでみてください。