【C言語|組み込み】ビット演算の基本とレジスタ操作【Arduino(Wokwi)で実演】

C言語の入門書でビット演算(&|)が出てきたけど、これ、何に使うんだろう?

と思ったことはありませんか?

普通のアプリを作っている分にはあまり出番がないのですが、組み込みの世界ではビット演算は主役級です。マイコンのレジスタ(ハードウェアを制御するためのメモリ領域)は1ビットごとに意味が割り当てられていることが多く、「特定のビットだけを操作する」場面が頻繁に出てくるからです。

この記事では、ビット演算の基本をおさらいした上で、Arduinoのレジスタを直接操作してLEDを制御する実演をWokwi(ブラウザ上のシミュレーター)でやってみます。後半では、組み込みならではの落とし穴であるRMW問題(割り込みとの干渉)についても図解付きで解説します。

この記事で分かること
  • ビット演算(AND/OR/XOR/NOT/シフト)の基本
  • 組み込み定番の「ビットセット/クリア/トグル/確認」の書き方
  • digitalWrite()を使わずレジスタ直接操作でLEDを制御する方法
  • 割り込みとビット操作が干渉する「RMW問題」と対策
目次

レジスタとPORTBについて

ビット演算の話に入る前に、レジスタについて簡単に説明します。

  • マイコンには、ハードウェアを直接制御するための特別なメモリ領域「レジスタ」がいくつも用意されている
  • 普通の変数と違い、レジスタは1~数ビットごとに意味が決まっている
  • Arduino Unoでは、デジタルピン8〜13の出力ON/OFFをまとめて管理しているのがPORTBというレジスタ

PORTBと各ピンの対応は以下の通りです。

ビット対応するピン
bit0ピン8
bit1ピン9
bit2ピン10
bit3ピン11
bit4ピン12
bit5ピン13
bit6, bit7(未使用)

つまり、普段使っているdigitalWrite(9, HIGH)というポート出力を切り替える関数は、内部的にはPORTBのbit1を1にしているだけです。今回はその内部処理を、直接ビット演算で書いてみます。

digitalWrite(ピン番号, HIGH または LOW)は、指定したピンの出力をON(HIGH)/OFF(LOW)に切り替える、おなじみのArduino関数です。

ビット演算の基本

まずは基本の演算子をおさらいします。

演算子名前動き例(4ビット)
&AND両方1なら11100 & 10101000
|ORどちらかが1なら11100 | 10101110
^XOR違うときだけ11100 ^ 10100110
~NOT全ビット反転~11000011
<<左シフトビットを左にずらす0001 << 20100
>>右シフトビットを右にずらす0100 >> 20001

組み込み定番の4つの書き方

この基本を組み合わせると、「レジスタの特定のビットだけを操作する」定番パターンが作れます。ビット5を例にします。

/* ①ビットセット: ビット5だけを1にする(他のビットは変えない) */
PORTB |= (1 << 5);

/* ②ビットクリア: ビット5だけを0にする(他のビットは変えない) */
PORTB &= ~(1 << 5);

/* ③ビットトグル: ビット5だけを反転させる */
PORTB ^= (1 << 5);

/* ④ビット確認: ビット5が1かどうか調べる */
if (PORTB & (1 << 5)) {
    /* ビット5は1 */
}

(1 << 5)は「1をビット5の位置までずらした値」つまり00100000のことです。

この値とOR(|)すればその位置だけ1になり、反転(~)してAND(&)すればその位置だけ0になる、という仕組みです。

「他のビットは変えない」がポイントです。レジスタでは1つのビットがそれぞれ別の機能(別のピン等)に割り当てられているため、単純な代入(PORTB = 0x20;)だと他の機能まで巻き込んで書き換えてしまいます。

実演:レジスタ直接操作でLEDを制御してみる

ここからはWokwiで実際に動かしてみます。Arduino Unoでは、デジタルピン8〜13がレジスタPORTBのビット0〜5に対応しています。

今回はピン8(ビット0)とピン9(ビット1)にLEDを繋ぎました。(線が重複しているので分かりづらいですが…)

いつもの書き方(digitalWrite)

void setup() {
  pinMode(8, OUTPUT);
  pinMode(9, OUTPUT);
}

void loop() {
  digitalWrite(8, HIGH);
  digitalWrite(9, HIGH);
  delay(500);
  digitalWrite(8, LOW);
  digitalWrite(9, LOW);
  delay(500);
}

レジスタ直接操作の書き方

void setup() {
  DDRB |= (1 << 0) | (1 << 1);    /* ピン8,9を出力に設定(pinMode相当) */
}

void loop() {
  PORTB |= (1 << 0) | (1 << 1);   /* ピン8,9を同時にHIGH */
  delay(500);
  PORTB &= ~((1 << 0) | (1 << 1)); /* ピン8,9を同時にLOW */
  delay(500);
}

動作確認結果

digitalWrite、レジスタ直接操作、どちらも同じ動きになりました!

DDRBが「入出力の方向を決めるレジスタ」(pinMode相当)、
PORTBが「出力のON/OFFを決めるレジスタ」(digitalWrite相当)です。

レジスタ直接操作のメリットは、複数のピンを本当に「同時に」切り替えられることと、関数呼び出しのオーバーヘッドがなく高速なことです。

実際の組み込み開発では、このようにレジスタを直接操作するコードが日常的に登場します。

Godboltで確認:1行のビット操作は何命令になっている?

ここで、volatileの記事でも使ったGodbolt(Compiler Explorer)で、ビット操作が実際に何個の機械語命令になっているかを見てみます。コンパイラは「Arduino Uno (1.8.9)」、最適化オプションは-Osです。

アセンブリ確認用のCプログラム

本来は実際に使用しているプログラムをそのまま使用するべきなのですが、今回は説明用にプログラムを絞って確認してみます。

元のプログラムから影響のある個所のみ抜き出し&加筆し、以下のプログラムを用意しました。

#include <avr/io.h>

void demo_registers(void) {
    DDRB |= (1 << 0) | (1 << 1);              /* ピン8,9を出力に設定 */

    PORTB |= (1 << 0);              /* ピン8のみをHIGH */
    PORTB &= ~(1 << 0);             /* ピン8のみをLOW */

    PORTB |= (1 << 0) | (1 << 1);              /* ピン8,9を同時にHIGH */
    PORTB &= ~((1 << 0) | (1 << 1));           /* ピン8,9を同時にLOW */

}

1ビットだけの操作

実行コード

    PORTB |= (1 << 0);              /* ピン8のみをHIGH */
    PORTB &= ~(1 << 0);             /* ピン8のみをLOW */

実行コードのアセンブリ

実はAVRマイコンには「特定のビットだけを直接セット/クリアする専用命令(sbi/cbi)」があり、定数1ビットだけの操作はこの1命令に変換されます。

2ビット同時の操作

実行コード

    PORTB |= (1 << 0) | (1 << 1);              /* ピン8,9を同時にHIGH */
    PORTB &= ~((1 << 0) | (1 << 1));           /* ピン8,9を同時にLOW */

実行コードのアセンブリ

こちらは専用命令では表せないため、

  1. 読む(in)
  2. 書き換える(ori)
  3. 書き戻す(out)

という3命令に分かれます。C言語では1行でも、実際には3段階の処理になっているわけです。

この「3段階に分かれている」ことが、次に説明する問題の入り口になります。

【発展】割り込みとの干渉(RMW問題)

「読む→書き換える→書き戻す」という3段階の処理は、英語のRead-Modify-Writeの頭文字を取ってRMWと呼ばれます。

問題は、この3段階の途中に割り込みが入り込む余地があることです。

たとえば、

  1. メイン処理がビット5をセットしようとしてPORTBを読み込む
  2. メイン処理がPORTBを書き戻す前に割り込みが発生
  3. 割り込み処理がPORTBのビット3をセット

上記のようなケースで、何が起きるかを図にしました。

メイン処理は「①で読んだ古い値」をベースに書き戻すため、割り込みが変更したビット3が上書きで消えてしまいます

このバグの怖いところは、割り込みが絶妙なタイミングで入ったときしか発生しないことです。テストでは何百回も正常に動くのに、たまにLEDが点かない・設定が消える、といった再現困難な不具合になります。

【対策】割り込み禁止で囲む

対策はシンプルで、RMWになる操作の間だけ割り込みを禁止します。

noInterrupts();                     /* 割り込み禁止 */
PORTB |= (1 << 5) | (1 << 3);
interrupts();                       /* 割り込み許可に戻す */

これで「読む→書き換える→書き戻す」の途中に割り込みが入れなくなり、上書き事故を防げます。

なお、先ほどGodboltで見た通り、AVRでは定数1ビットだけの操作はsbi/cbiの1命令になるため、途中に割り込みが入る隙がそもそもありません。

ただし、これはAVRのこの条件に限った話です。複数ビットの操作や、他のマイコンでは1ビットでもRMWになることが普通なので、「割り込みとメイン処理の両方から同じレジスタ(や変数)を書き換えるときは、割り込み禁止で守る」を原則として覚えておくのが安全です。

まとめ

  • ビット演算は、レジスタの「特定のビットだけ」を操作するための組み込み必須テクニック
  • セット|=、クリア&= ~、トグル^=、確認&の4パターンが定番
  • レジスタ直接操作なら複数ピンを同時に、高速に制御できる
  • C言語で1行のビット操作も、機械語では「読む→書き換える→書き戻す」(RMW)の複数命令に分かれることがある
  • RMWの途中に割り込みが入ると変更が消える事故が起きるため、共有するレジスタの操作は割り込み禁止で守る

最後まで読んでいただき、ありがとうございました!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

目次