組み込み開発において、レジスタはビット集合です。GPIOA->MODER の値が 0x00000400 と言われても、それが何を意味するかを即座に読み取れるようになること――これが今回の目標です。
前回の第3回では、C言語の構造体・配列・パディングというメモリレイアウトの話をしました。今回は、そのメモリ上に配置された「周辺機器レジスタ」を、ビット単位で正確に操作する技術を身につけます。
📍 連載トップページ
1. レジスタはビット集合である
1-1. なぜビット単位で管理するのか?
第1回で、GPIOレジスタには MODER、OTYPER、OSPEEDR といった役割があることを紹介しました。これらのレジスタは、32ビット(4バイト)の整数値ですが、その中身は「複数のビットフィールド(機能ブロック)の集合」です。
GPIOA を例に、主なレジスタをおさらいしておきましょう:
| レジスタ名 | アドレス | 役割 |
|---|---|---|
| MODER | 0x40020000 | ピンのモード設定(入力 / 出力 / 代替機能 / アナログ) |
| OTYPER | 0x40020004 | 出力タイプ設定(プッシュプル / オープンドレイン) |
| OSPEEDR | 0x40020008 | 出力速度設定 |
| PUPDR | 0x4002000C | プルアップ / プルダウン設定 |
| IDR | 0x40020010 | 入力データレジスタ(ピンの状態を読む) |
| ODR | 0x40020014 | 出力データレジスタ(ピンを ON/OFF する) |
今回メインで扱うのは ODR と、後で登場する BSRR です。他のレジスタは初期化時に HAL が設定してくれるので、今は「こういうものがある」程度の認識で大丈夫です。
そもそも「ビット」と「レジスタ」って何?
コンピュータの中では、すべての情報が 0 と 1 の組み合わせ で表されています。この 0 か 1 かの最小単位を ビット(bit) と呼びます。
電球のスイッチをイメージしてください。「OFF = 0」「ON = 1」です。
1ビット = スイッチ1個(0 か 1 かの2択)
OFF ON
[ 0 1 ] ← これが1ビット
マイコンの CPU は 32 本のスイッチを束ねた「32ビット幅」で動作します。つまり、1つのレジスタに 32個のスイッチが詰まっているイメージです。
32ビットのレジスタ = スイッチが32個並んだパネル
bit31 bit0
↓ ↓
[0][0][0][0][0][0][0][0][0][0][0][0][0][1][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0]
この全体の値を 16 進数で書くと 0x00001000 になります。
この 32 個の 0/1 のかたまり、これがそのままレジスタです。 マイコンの周辺機器(GPIO、UART、タイマーなど)は、それぞれ専用のレジスタを持っており、どのビットを 0 にするか・1 にするかでハードウェアの動作が決まります。
例えば、GPIOA->MODER(ピンのモード設定レジスタ)は以下のような構成です:
MODER レジスタ(32ビット)
bit31 bit30 ... bit11 bit10 bit9 bit8 ... bit1 bit0
PA15 PA15 ... PA5 PA5 PA4 PA4 ... PA0 PA0
[1] [0] ... [1] [0] [1] [0] ... [1] [0]
各ピンに2ビットが割り当てられており、その組み合わせでモードを設定します:
| ビット値 | 意味 |
|---|---|
00 |
入力モード(リセット状態) |
01 |
汎用出力モード |
10 |
代替機能モード(UART、SPI など) |
11 |
アナログモード |
つまり、PA5(ピン5番)を出力モードにするには、MODER の bit11〜bit10 を 01 にする必要があります。
「32ビット全部書き換えたら他の設定が壊れない?」
鋭い疑問です!そのとおりで、特定のビットだけを変える技術が必要になります。それが次の章で学ぶ「マスク操作」です。
1-2. 「レジスタ=ビット集合」という世界観
組み込み開発で重要な視点の転換があります。
- PC プログラミングの感覚:変数は「数値を保持するもの」
- 組み込みの感覚:レジスタは「複数の設定スイッチが詰まったもの」
MODER に 0x00000400 という値を書き込むとき、それは「400番という意味のある数値を書く」のではありません。bit11:bit10 = 01 という「スイッチの状態」を設定しているのです。
この「ビット集合として読む・書く」能力こそが、組み込みエンジニアの基礎スキルです。
2. ビット操作の基本:マスクとシフト
2-1. シフト演算(<< と >>)
シフト演算は、ビット列を左右に動かす操作です。
uint32_t value = 1; // 0b00000000000000000000000000000001
value << 1; // 0b00000000000000000000000000000010 (2)
value << 5; // 0b00000000000000000000000000100000 (32)
value << 10; // 0b00000000000000000000010000000000 (1024)
1 << n は「n番目のビットだけが1の数値」を作ります。これを使うと、特定のビット位置を意味のある名前で書けるようになります:
// PA5 に対応するビット位置(MODER は各ピン2ビット)
#define PA5_MODER_POS (5 * 2) // = 10 (bit10 から始まる)
#define PA5_MODER_MASK (0x3UL << PA5_MODER_POS) // 0b11 を bit10 に配置
// 「0x3UL」= 0b11(2ビット幅のマスク)を bit10 位置にシフト
// → 0x00000C00
ULサフィックスとは?3ULのULは「unsigned long(符号なし長整数)」を意味します。シフト操作で符号ビットの扱いを意図しない動作を防ぐため、ビット操作ではこのサフィックスを付けるのが慣習です。
2-2. マスク演算(&、|、~)
マスク演算は、特定のビットだけを操作するための基本技術です。
ビット論理演算の早見表
| 演算 | 記号 | 用途 | 例(8ビットで説明) |
|---|---|---|---|
| AND | & |
特定ビットを クリア(0にする) | 0b1010_1010 & 0b0000_1111 = 0b0000_1010 |
| OR | | |
特定ビットを セット(1にする) | 0b1010_1010 | 0b0000_1111 = 0b1010_1111 |
| NOT | ~ |
ビットを 反転 する | ~0b0000_1111 = 0b1111_0000 |
| XOR | ^ |
特定ビットを トグル(反転) する | 0b1010_1010 ^ 0b0000_1111 = 0b1010_0101 |
具体例:PA5 を出力モードに設定する
// 目標:MODER の bit11:bit10 を 01 にする
// ステップ1:対象ビットをクリア(AND + NOT マスク)
GPIOA->MODER &= ~(0x3UL << (5 * 2));
// ~(0x3 << 10) = ~0x00000C00 = 0xFFFFF3FF
// → bit11:bit10 だけを 0 にして、他のビットは保持
// ステップ2:新しい値をセット(OR)
GPIOA->MODER |= (0x1UL << (5 * 2));
// 0x1 << 10 = 0x00000400
// → bit10 を 1 にセット(bit11 は 0 のまま)
操作後の MODER の bit11:bit10 は 01 になり、PA5 が出力モードに設定されます。
2-3. 操作パターンのまとめ
組み込みで頻繁に使うビット操作パターンを整理します:
uint32_t reg = ...; // 対象レジスタ
// ビットのセット(1にする)
reg |= (1UL << n); // n番目ビットを 1 にする
// ビットのクリア(0にする)
reg &= ~(1UL << n); // n番目ビットを 0 にする
// ビットのトグル(0→1 / 1→0)
reg ^= (1UL << n); // n番目ビットを反転
// ビットの確認(0 か 1 か)
if (reg & (1UL << n)) { // n番目ビットが 1 なら真
// ...
}
// 複数ビットのクリア後にセット(マスク+OR)
reg &= ~(MASK << pos); // 対象フィールドをクリア
reg |= (VALUE << pos); // 新しい値をセット
✅ 2章のチェックポイント
-
1 << 5が何になるか暗算できる - 「ビットをクリア → セット」の2ステップ操作を書ける
-
~がビット反転であることを理解した
3. RMW問題:「読んで・変えて・書く」の落とし穴
3-1. ODR とは何か?
ここで新しいレジスタ ODR(Output Data Register) が登場します。
ODR は「ピンの出力状態を管理するレジスタ」です。16 本分のピンに対応した 16 ビットが並んでいて、そのビットが 1 ならピンが HIGH(3.3V)、0 なら LOW(0V)になります。
ODR レジスタ(下位16ビット部分)
bit15 bit14 ... bit6 bit5 bit4 ... bit1 bit0
PA15 PA14 ... PA6 PA5 PA4 ... PA1 PA0
0 0 ... 0 0 0 ... 0 0 ← 起動直後はすべて 0
PA5 を HIGH にするには bit5 だけを 1 にする 必要があります。そのコードがこれです:
GPIOA->ODR |= (1UL << 5);
| 部分 | 意味 |
|---|---|
GPIOA |
GPIOA ポートのレジスタ群を指すポインタ |
->ODR |
その中の ODR レジスタにアクセス(-> はポインタ経由のアクセス記号) |
1UL << 5 |
1 を 5 ビット左シフト → bit5 だけが 1 の値 |
|= |
現在の ODR 値と OR して書き戻す |
->についての補足GPIOAはアドレスを保持するポインタです。ポインタ経由でメンバにアクセスするときは.でなく->を使います。詳しくは第5回(ポインタ編)で解説します。
コンパイラに最適化で消されない理由 「毎回レジスタを読みに行くのでは?コンパイラが最適化して省略しないのか?」という疑問を持った方は鋭いです。CMSIS のレジスタ構造体(
GPIO_TypeDef)は内部でvolatile修飾子付きで定義されているため、コンパイラはこの読み書きを省略できません。第3回で学んだvolatileがここで効いています。
3-2. RMW(Read-Modify-Write)とは?
上の |= という操作は、裏で3ステップ踏んでいます:
1. Read(読み込み) :現在の ODR 値をCPUに読み込む
2. Modify(変更) :bit5 を 1 にする計算をする
3. Write(書き込み):結果を ODR に書き戻す
これを RMW(Read-Modify-Write) と呼びます。
3-3. RMW の問題点
RMWは3つの独立した操作からなるため、間に別の処理が割り込むと問題が起きます。
典型的なシナリオ:
// メインループで PA5 を操作
GPIOA->ODR |= (1UL << 5); // PA5 をHIGHにしたい
// ↑ 内部は:ODR読み込み → bit5セット → ODR書き込み
// しかし、「ODR読み込み」と「ODR書き込み」の間に
// 割り込みが入って PA6 を操作したら?
Time →
[Main] ODR 読み込み (ODR = 0x0000) ← PA5・PA6 ともに LOW の状態
[IRQ!] ← 割り込み発生
[IRQ ] ODR |= (1 << 6) ← PA6 をHIGHにする (ODR = 0x0040)
[IRQ ] 割り込み終了
[Main] bit5 セット後の値 (0x0000 | 0x0020 = 0x0020) を書き戻す ← PA6 の変更が消える!
割り込みによって PA6 の変更が 上書きされて消えてしまいました。これが RMW 問題です。
諸悪の根源は「古い情報の書き戻し」です。Main 処理は「PA6 がまだ 0」だったときの ODR を読んでしまっており、その古いデータをもとに計算した結果をそのまま書き戻します。割り込みが PA6=1 に更新していても、Main は知らないまま上書き――これがバグの正体です。
だから BSRR が解決策になります。「今の状態を読まなくていい」「セットしたいビット番号だけ書けばいい」という設計により、古い情報を持ち込む余地がそもそもなくなります。
3-4. RMW 問題の深刻さ
GPIOの ODR(Output Data Register)で RMW すると:
- 複数ピンを独立した割り込みで制御するとき、互いの操作が干渉する
- タイミング依存のバグになるため、再現が困難
- 負荷が高いときだけ起きる、という最悪のパターンになることも
組み込みの現場では「なぜか時々ピンがおかしくなる」という現象の原因として、このRMW問題が頻繁に登場します。
補足:volatile との関係 第3回で学んだ
volatileは「毎回メモリを読みに行く」ことを保証しますが、RMW のアトミック性(3ステップが分割されないこと)は保証しません。volatile と RMW は別の問題です。
4. BSRR の思想:STM32 の美しい設計
4-1. ODR の問題点をおさらい
前章で学んだとおり、ODR を使ってピンを操作するとこう書きます:
GPIOA->ODR |= (1UL << 5); // PA5 を HIGH にする
この |= は裏で「読む → 計算 → 書く」の3ステップを踏んでいます。この間に割り込みが入ると、他のピンへの書き込みが消えてしまう(RMW 問題)のでした。
「ODR への書き込みには、読み込みが必ずセットでついてくる」――これが問題の根本です。
4-2. BSRR は「書くだけ」でいい
「GPIOA に ODR と BSRR の両方があるんですか?」
はい、そうです。GPIOA にはピンを制御するためのレジスタが複数まとめて用意されており、ODR も BSRR もその中のひとつです。
GPIOA のレジスタ群(ベースアドレス 0x40020000) アドレス レジスタ名 役割 0x40020000 MODER ピンのモード設定 0x40020004 OTYPER 出力タイプ設定 0x40020008 OSPEEDR 出力速度設定 0x4002000C PUPDR プルアップ/ダウン設定 0x40020010 IDR 入力データ(ピンの状態を読む) 0x40020014 ODR 出力データ(ピンを ON/OFF) 0x40020018 BSRR ビット セット/リセット ← 今回の主役ODR と BSRR はどちらも「ピンを ON/OFF する」ための手段ですが、やり方が違います。その違いがこれから説明する内容です。
STM32 には、この問題を根本から解決した専用レジスタがあります。それが BSRR(Bit Set/Reset Register) です。
BSRR の発想はシンプルです:
「ONにしたいピン番号」と「OFFにしたいピン番号」を、それぞれ決まった場所に書くだけ
BSRR は 32 ビットを上下半分に分けて使います:
BSRR レジスタ(32ビット)
上半分(bit31〜bit16) 下半分(bit15〜bit0)
┌─────────────────────┬────────────────────┐
│ BR15 BR14 ... BR1 BR0 │ BS15 BS14 ... BS1 BS0 │
│ LOW にする │ HIGH にする │
└─────────────────────┴────────────────────┘
BR = Bit Reset(ピンをLOWにする)
BS = Bit Set (ピンをHIGHにする)
- 下半分(bit0〜bit15) に
1を書いたビット番号のピンが HIGH(ON) になる - 上半分(bit16〜bit31) に
1を書いたビット番号のピンが LOW(OFF) になる
「今の状態を読まなくていい」「計算しなくていい」「ただ書くだけ」――だから割り込みに割り込まれる隙がありません。
この「1回の書き込みで完結し、途中で分割されない」操作を アトミック(atomic:分割不可能) と呼びます。RTOS(リアルタイムOS)や複雑な割り込み制御を学ぶ場面で頻繁に登場するキーワードなので、ここで覚えておくと後々の布石になります。
なぜ上半分が Reset なのか? 32ビット1回の書き込みで「ONにするピン」と「OFFにするピン」を同時に指定できるからです。 例えば
GPIOA->BSRR = (1UL << 0) | (1UL << (1 + 16));と書くだけで、PA0 を HIGH にしながら PA1 を LOW にする操作が1命令で完結します。ODR で同じことをやろうとすると2回の RMW が必要になり、その間に状態がずれる危険があります。
4-3. 実際の書き方
PA5(= ピン5番)の操作を例に見てみましょう:
// PA5 を HIGH にする(LED ON)
// → 下半分の bit5 に 1 を書く
GPIOA->BSRR = (1UL << 5); // 0x00000020
// PA5 を LOW にする(LED OFF)
// → 上半分の bit5 に 1 を書く(= 5+16 = bit21)
GPIOA->BSRR = (1UL << (5 + 16)); // 0x00200000
ODR 版と並べると、シンプルさが際立ちます:
// ODR 版(読み込みが必要)
GPIOA->ODR |= (1UL << 5); // PA5 HIGH ← 読む→計算→書く
GPIOA->ODR &= ~(1UL << 5); // PA5 LOW ← 読む→計算→書く
// BSRR 版(書くだけ)
GPIOA->BSRR = (1UL << 5); // PA5 HIGH ← 書くだけ
GPIOA->BSRR = (1UL << (5 + 16)); // PA5 LOW ← 書くだけ
「デバッガで BSRR を確認したら 0 になっている…?」 BSRR は「書き込み専用(Write-Only)」レジスタです。書き込んだ値はハードウェア内部で即座に処理され、読み返しても常に
0x00000000が返ります。これはハードウェアの仕様通りの動作なので、「書き込みが失敗した」わけではありません。現在のピン出力状態を確認したいときは ODR を読んでください。
4-4. ODR が必要な場面
BSRR があれば ODR は不要に見えますが、ODR にしかできないことが 3 つあります。
① 現在の出力状態を読み取りたいとき
BSRR は書き込み専用のため、「今 PA5 は HIGH か LOW か?」を知るには ODR を読む必要があります。
// PA5 の現在の出力状態を確認する
if (GPIOA->ODR & (1UL << 5)) {
// PA5 は現在 HIGH
} else {
// PA5 は現在 LOW
}
② ピンをトグル(反転)したいとき
「今の状態と逆にする」という操作は、現在値を読まないとできません。XOR(^=)を使った ODR への RMW が必要です。
// PA5 を反転する(ON なら OFF、OFF なら ON)
GPIOA->ODR ^= (1UL << 5);
BSRR では「HIGH にする」か「LOW にする」かを明示する必要があるため、トグルには向きません。
③ 複数ピンの状態を一括で決めたいとき
「GPIOA の全ピンをこの状態にリセットする」という初期化には、ODR に直接値を書くほうがシンプルです。
// 全ピンを一括で LOW にする
GPIOA->ODR = 0x0000;
まとめ:使い分けの基準
| 操作 | 使うレジスタ |
|---|---|
| ピンを ON にする | BSRR(BS) |
| ピンを OFF にする | BSRR(BR) |
| 現在の出力状態を確認する | ODR(読み取り) |
| ピンをトグル(反転)する | ODR(^=) |
| 全ピンの状態を一括設定する | ODR(直接書き込み) |
通常の ON/OFF 操作は BSRR を使う。ODR は「読みたいとき」「トグルしたいとき」に使う、と覚えておけば十分です。
4-5. 他のマイコンではどうなのか?
BSRR のような専用レジスタは、STM32 だけの発明ではありません。しかし、すべてのマイコンにあるわけでもありません。
AVR(Arduino の中身)
Arduino で使われている AVR マイコンには、BSRR に相当するレジスタがありません。ピンの制御は PORT レジスタへの RMW で行います:
// AVR の場合(Arduino の digitalWrite の中身に近い)
PORTB |= (1 << PB5); // HIGH ← 読む→計算→書く(RMW)
PORTB &= ~(1 << PB5); // LOW ← 読む→計算→書く(RMW)
これが「Arduino から STM32 に来ると、BSRR が美しく見える」理由のひとつです。
PIC マイコン
PIC では LAT(Latch)レジスタを使います。こちらも RMW が基本で、SET/CLEAR 専用レジスタを持つシリーズは限られています。
ESP32 / RP2040(Raspberry Pi Pico)
近年の人気マイコンは、STM32 と同じ発想を持っています:
| マイコン | SET レジスタ | CLEAR レジスタ |
|---|---|---|
| STM32 | BSRR(下半分) | BSRR(上半分) |
| ESP32 | GPIO_OUT_W1TS_REG |
GPIO_OUT_W1TC_REG |
| RP2040 | SIO_GPIO_OUT_SET |
SIO_GPIO_OUT_CLR |
名前は違いますが、「書くだけで SET/CLEAR できる」という思想は同じです。組み込みの世界でこの設計が標準的になりつつあることがわかります。
なぜ AVR にないのか? AVR は 1990 年代設計の 8 ビットアーキテクチャで、当時は RMW で十分と判断されていました。32 ビット・高速クロック・マルチタスク環境が当たり前になった現代では、アトミック操作の需要が高まり、BSRR のような設計が重視されるようになりました。
コラム:ビットフィールドを使わない理由
第3回で構造体を学んだ方は、こんな疑問を持つかもしれません。
「
struct { uint32_t pin0:1; pin1:1; ... }というビットフィールドを使えば、GPIOA->ODR.pin0 = 1;と直感的に書けるのでは?」
鋭い発想ですが、現場のレジスタ操作でビットフィールドが 避けられる 理由が 2 つあります。
① アトミック性の欠如
GPIOA->ODR.pin0 = 1; という代入も、コンパイラが生成するアセンブリは結局 RMW(読み・変え・書き)です。BSRR のような「1回の書き込みで完結」というハードウェアの利点を活かせません。
② ビット配置がコンパイラ依存
C言語の仕様上、ビットフィールドが「右から詰まるか・左から詰まるか」はコンパイラによって異なります。ハードウェアレジスタは配置が仕様書で厳格に決まっており、コンパイラ依存の構造体では安全に扱えません。
これが「マスク・シフト演算 + BSRR」というスタイルが組み込みの現場で主流である理由です。「読みにくそう」と感じたマスク操作が、実はハードウェアを安全・確実に操作するための 作法 だったことがわかります。
✅ 4章のチェックポイント
- BSRR の上半分と下半分の役割を説明できる
- PA0 を HIGH にする BSRR の書き方を書ける
- ODR 版と BSRR 版の「ステップ数の違い」を説明できる
5. 実践:3つの方法でLチカを体験する
まず「コードなしでデバッガから直接レジスタを叩く」体験をし、次にその操作をコードに落とし込み、最後に HAL 版と比較します。同じ LED が光る動作が、どう実装の違いに対応するかを一気に理解します。
PA5 と PA0 の使い分けについて 本章の実践では PA0(外付けLED) を使います。1章・2章の解説コードは説明の都合で PA5 を例にとっていましたが、PA5 は NUCLEO-F401RE の基板上の内蔵LED(LD2)に接続されているピンです。外付けLEDを用意せずに内蔵LEDで試したい場合は、以降の
PA0をPA5に、(1UL << 0)を(1UL << 5)に、(1UL << (0 + 16))を(1UL << (5 + 16))に読み替えてください。
5-1. 準備:回路とプロジェクトのセットアップ
回路の配線
今回は PA0 ピンに外付け LED を接続します。NUCLEO-F401RE の Arduino コネクタ(A0 端子)から取り出せます。
LEDの向きに注意 LED には極性があります。アノード(長い足)を抵抗側、カソード(短い足)を GND 側に接続してください。逆に挿すと光りません。
CubeMX の設定
CubeMX または CubeIDE で新規プロジェクトを作成し、以下の設定を行います:
- PA0 を GPIO_Output に設定(Pinout & Configuration → PA0 → GPIO_Output)
- クロックはデフォルトのまま(HSI)で可
- コードを生成する
生成される MX_GPIO_Init() は、PA0 を出力モードに設定するレジスタ操作を行っています。この初期化はそのまま使い、LED の ON/OFF 制御だけ自分でやってみます。
5-2. まずデバッガでレジスタを直接書き換えてLチカ(コード不要!)
コードを1行も書かなくても、デバッガでレジスタに値を直打ちするだけで LED が光ります。「レジスタはただのメモリアドレス」という事実を体で覚える実験です。
手順
① 任意のコードをビルドして書き込み、デバッグを開始する
while(1) の中で HAL_Delay() が回っている状態でOKです。Suspend(一時停止)ボタンで任意のタイミングで停止させます。
② SFR ビューを開く
Window → Show View → SFRs
③ GPIOA → ODR を探して値を直接入力する
| やりたいこと | ODR に入力する値 | 動作 |
|---|---|---|
| LED を ON にする | 0x00000001 |
bit0 を 1 にセット → PA0 が HIGH → LED点灯 |
| LED を OFF にする | 0x00000000 |
bit0 を 0 にクリア → PA0 が LOW → LED消灯 |
SFR ビューの ODR 欄をダブルクリックして値を入力し、Enter を押した瞬間に LED が光ります。
「アドレスだけが現実」を体感する瞬間 コードを1行も書いていないのに LED が制御できました。デバッガが「0x40020014 番地に 0x00000001 を書く」という操作をしただけで、ハードウェアが即座に反応しています。レジスタはただのメモリアドレスであり、どこから書き込んでも同じ効果があることが体感できます。
参考:BSRR で同じことをやりたい場合
BSRR は Write-Only のため SFR ビューでは編集できないことがあります。その場合は Expressions ビューでアドレスを直打ちする方法が使えます。
Window → Show View → Expressions
(uint32_t*)0x40020018 を追加し、Value 欄に 0x00000001(LED ON)または 0x00010000(LED OFF)を入力してください。
5-3. コードでレジスタ操作するとこうなる
デバッガで手動でやっていた操作を、そのままコードに書き下ろします。
BSRR を使ったレジスタ直叩き版
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init(); // PA0 を出力モードに設定(HAL に任せる)
while (1)
{
// LED ON:BSRR の bit0(BS0)をセット → PA0 HIGH
GPIOA->BSRR = (1UL << 0);
HAL_Delay(500);
// LED OFF:BSRR の bit16(BR0)をセット → PA0 LOW
GPIOA->BSRR = (1UL << (0 + 16));
HAL_Delay(500);
}
}
デバッガで手打ちしていた値(0x00000001、0x00010000)がそのままコードに現れています。1UL << 0 = 0x00000001、1UL << 16 = 0x00010000 ――デバッガの操作とコードが1対1で対応していることを確認してください。
「PA0 なのに
<< 0でいいの?」 ビット番号は 0 から始まります。PA0 は 0 番目のピンなので bit0、PA1 は bit1、PA5 は bit5 です。「1番目のピンだから<< 1では?」と思いがちですが、コンピュータの数え方は 0 始まりです。1UL << 0は1ULをそのまま使うことと同じですが、「PA0 だから 0 ビットシフト」という対応を明示するために書いています。
プログラムを止めずに実行したまま ODR の値を監視するには、Live Expressions に以下のアドレスを追加してください:
(uint32_t*)0x40020014
追加手順:
- Window → Show View → Live Expressions を選択
- Add new expression をクリック
(uint32_t*)0x40020014を入力
Lチカが動いている最中、ODR の値が 0x00000001 ↔ 0x00000000 とリアルタイムで切り替わる様子が確認できます。BSRR 自体は Write-Only なので、同様に監視しても常に 0x00000000 のままです。
5-4. HAL で書くとこうなる(比較)
同じ Lチカ を HAL 関数で書くと以下になります:
while (1)
{
// HAL 版 LED ON
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET);
HAL_Delay(500);
// HAL 版 LED OFF
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET);
HAL_Delay(500);
}
見た目はすっきりしていますが、内部では何をしているのでしょうか?
HAL の内部実装(stm32f4xx_hal_gpio.c より)
void HAL_GPIO_WritePin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState)
{
if (PinState != GPIO_PIN_RESET)
{
GPIOx->BSRR = (uint32_t)GPIO_Pin; // SET:BS に書く
}
else
{
GPIOx->BSRR = (uint32_t)GPIO_Pin << 16U; // RESET:BR に書く
}
}
HAL も内部では BSRR を使っています。 私たちがレジスタ版で書いたコードと、やっていることはまったく同じです。
3つの実装の比較
| 実装方法 | コード | 実際の操作 |
|---|---|---|
| デバッガ直打ち | なし(手動入力) | 0x40020018 番地に 0x00000001 を書く |
| レジスタ直叩き | GPIOA->BSRR = (1UL << 0) |
同上 |
| HAL 関数 | HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET) |
同上(内部で BSRR に書く) |
すべて同じレジスタへの同じ書き込みです。抽象度が違うだけで、ハードウェアへの操作は一緒です。
速度比較
では HAL 版は遅いのか? 実際に比べてみましょう。
DWT CYCCNT(CPUサイクルカウンタ)を使って、LED ON → OFF の1サイクルにかかるクロック数を計測できます(DWT については第7回で詳しく解説します):
// 計測用マクロ
#define DWT_CYCCNT (*(volatile uint32_t*)0xE0001004)
#define DWT_CTRL (*(volatile uint32_t*)0xE0001000)
// DWT 有効化
DWT_CTRL |= 1;
uint32_t t1, t2, t3, t4, ta, tb;
ta = 0; tb = 0;
// レジスタ直叩き版の計測
t1 = DWT_CYCCNT;
GPIOA->BSRR = (1UL << 0);
t2 = DWT_CYCCNT;
ta = t2 - t1; // → 実測: 11 サイクル
// HAL 版の計測
t3 = DWT_CYCCNT;
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET);
t4 = DWT_CYCCNT;
tb = t4 - t3; // → 実測: 40 サイクル(関数呼び出し・分岐のオーバーヘッド分)
実際にデバッガの Variables ビューで確認すると、ta = 11、tb = 40 という結果が得られます:
| 実装 | 実行サイクル数(実測値) | 備考 |
|---|---|---|
| レジスタ直叩き | 11 サイクル | 命令1つで完結 |
| HAL 関数 | 40 サイクル | 関数呼び出し・分岐のオーバーヘッド |
なぜ 40 サイクルかかるのか?
HAL の内部コードを思い出してください:
void HAL_GPIO_WritePin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState)
{
if (PinState != GPIO_PIN_RESET) // ← ② 比較・分岐
{
GPIOx->BSRR = (uint32_t)GPIO_Pin;
}
else
{
GPIOx->BSRR = (uint32_t)GPIO_Pin << 16U;
}
}
差の29サイクルは、主に以下の原因によるものです:
| 要因 | 内容 |
|---|---|
| ① 関数呼び出し | BL(Branch with Link)命令で関数に飛び、BX LR で返る。この往復だけで数サイクル消費 |
| ② 引数の準備 | GPIOx、GPIO_Pin、PinState の3引数をレジスタにロードしてから呼び出す |
| ③ if 分岐 | PinState != GPIO_PIN_RESET の比較と条件分岐処理 |
| ④ スタック操作 | 関数の入退場時にレジスタを退避・復元する処理(プロローグ:関数開始時のスタック確保、エピローグ:関数終了時の復元) |
最適化レベルの影響 今回の計測は最適化なし(
-O0)のデバッグビルドでの値です。最適化を有効(-O2など)にすると、コンパイラがHAL_GPIO_WritePinをインライン展開し、レジスタ直叩きと同等のコードが生成されることがあります。「HAL は遅い」というのは厳密には「最適化オフのデバッグビルドでは遅い」が正確な表現です。
84MHz 動作では1サイクル ≈ 12ns。11サイクル ≈ 130ns、40サイクル ≈ 476ns なので、差は約350ns です。LED の Lチカ レベルでは全く気にする必要はありません。 ただし、高速な SPI 通信や厳密なタイミング制御が必要な場面では、この差が積み重なって問題になることがあります。
どちらを使うべきか? 開発中の可読性・保守性を重視するなら HAL で十分です。タイミングがシビアな部分だけレジスタ直叩きに切り替えるのが現実的な判断です。重要なのは 「HAL の裏で何が起きているかを知っている」 ことです。
✅ 5章のチェックポイント
- デバッガの SFR ビューから BSRR に値を書き込んで LED を光らせることができた
- レジスタ直叩きと HAL 版で「やっていることが同じ」ことを確認できた
- HAL 関数が内部で BSRR を使っていることを説明できる
- レジスタ直叩き vs HAL の速度差とその使い分けを説明できる
6. 発展:RCC でクロックを有効化する仕組み
6-1. なぜクロックの有効化が必要なのか?
STM32 では、省電力設計のため、デフォルトでは周辺機器へのクロック供給が止まっています。GPIOA を使う前に、RCC(Reset and Clock Control)レジスタでクロックを有効化する必要があります。
MX_GPIO_Init() の中にある __HAL_RCC_GPIOA_CLK_ENABLE() がこの役割を担っています。
6-2. RCC のビット操作を読む
__HAL_RCC_GPIOA_CLK_ENABLE() の実体は以下のような操作です:
// HAL マクロの展開(概略)
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
RCC_AHB1ENR_GPIOAEN は (1UL << 0) として定義されており、AHB1ENR レジスタの bit0 を 1 にする操作です。
AHB1ENR レジスタ(抜粋)
bit7 bit6 bit5 bit4 bit3 bit2 bit1 bit0
GPIOH GPIOG GPIOF GPIOE GPIOD GPIOC GPIOB GPIOA
↑ここを 1 にする
クロックを有効化しないまま GPIOA にアクセスすると、書き込みは無視され(または HardFault ― CPUが不正なメモリアクセスを検知したときに発生するハードウェア例外 ― が起きる可能性があり)、ピンは動作しません。
実験:クロック有効化を省略するとどうなる?
__HAL_RCC_GPIOA_CLK_ENABLE()をコメントアウトして実行してみてください。LED が全く点灯しないか、デバッガでGPIOA->MODERを確認すると値が変化しないことが確認できます(クロックが止まっているため書き込みが届かない)。これは「壊すコードで学ぶ」の精神です。
7. よくある疑問(FAQ)
Q1. なぜ 0x3UL と書くのか? 3 ではだめ?
A: 通常の 3 は int 型(32 ビット符号あり整数)です。ビット演算でシフトする際、符号ビットへの操作や型の不一致による未定義動作を避けるため、UL(unsigned long)を付けるのが慣習です。特に 32 ビット以上のシフトでは重要です。
Q2. BSRR は書き込み専用とのことだが、読んだらどうなる?
A: BSRR を読み取ると常に 0x00000000 が返ります。これはハードウェアの仕様です。現在のピン出力状態を知りたい場合は ODR を読んでください。
Q3. HAL_GPIO_TogglePin() の内部は BSRR を使っている?
A: HAL_GPIO_TogglePin() は内部で ODR を読み取り、XOR 操作でトグルしています。つまり RMW 操作です。割り込みとの競合が懸念される場面では、BSRR を使った明示的なセット・クリアが安全です。
// HAL_GPIO_TogglePin の内部(概略)
uint32_t odr = GPIOx->ODR;
GPIOx->BSRR = ((odr & GPIO_Pin) << 16U) | (~odr & GPIO_Pin);
// ↑実はHALも最終的にBSRRを使っている(HALバージョンによる)
Q4. GPIO_InitStruct.Speed が LOW のままでも問題ない?
A: Lチカ程度であれば問題ありません。Speed 設定は信号の立ち上がり・立ち下がりのスルーレート(LOW→HIGH または HIGH→LOW に変化する速度)を決めます。高速な SPI 通信などでは HIGH にする必要がありますが、LED の点滅では LOW(約2MHz のスルーレート)で十分です。高速設定はノイズ放射が増えるため、必要以上に上げないのがプロの作法です。
Q5. 複数の GPIO ポート(GPIOB、GPIOC)でも同じように使える?
A: はい。GPIOA と同様の構造です。ただし、それぞれのクロック有効化(__HAL_RCC_GPIOB_CLK_ENABLE() など)が必要です。アドレスはメモリマップ上で異なります:
| ポート | ベースアドレス |
|---|---|
| GPIOA | 0x40020000 |
| GPIOB | 0x40020400 |
| GPIOC | 0x40020800 |
| GPIOD | 0x40020C00 |
第4回のまとめ
今回は、レジスタ操作の作法とBSRRの思想を学びました。
今回学んだこと
✅ レジスタ=ビット集合:32 ビットの値を「複数の設定スイッチの集合」として読む
✅ シフト演算:1UL << n で n 番目のビットを特定する
✅ マスク演算:&=~ でクリア、|= でセット、^= でトグル
✅ RMW 問題:Read-Modify-Write の 3 ステップに割り込みが入ると競合が発生する
✅ BSRR の思想:Write-Only のアトミック操作で RMW 問題を根本解決
✅ 実践 Lチカ:BSRR を使ったレジスタ直叩きで LED を点滅させた
次のステップ
ビット操作の作法を身につけた次は、いよいよポインタの本質に踏み込みます。
「ポインタは怖い」という印象をお持ちの方は多いですが、実は今回学んだことと同じです。
*(uint32_t*)0x40020018 = (1UL << 5); // BSRR に直接書く
このコード、実はポインタを使ったレジスタ操作です。「型付きアドレス」というポインタの本質を理解すれば、このコードが何をしているか一目で分かるようになります。
次回予告:ポインタ=住所(型付きアドレス)
次回は、C 言語最大の難関と言われるポインタを、「型付きアドレス」という本質から丁寧に解説します。
学ぶこと:
- ポインタの本質:変数名ではなく「アドレスを格納する変数」
- 型とアドレスの関係:なぜ型が必要なのか
- ポインタで GPIOA を触る:CMSIS ヘッダがやっていることの解読
- キャストの意味:
(uint32_t*)0x40020000が示すもの
今回の GPIOA->BSRR という書き方も、実はポインタ操作です。次回でその繋がりが明確になります。
連載目次:
- 第0回:なぜ組み込みは難しく見えるのか ― 場所と時間の話 ―
- 第1回:マイコンは"アドレスの世界" ― 座標で読み解くハードウェア ―
- 第2回:変数が住む場所を見つける ― Flash、RAM、Stackの使い分け ―
- 第3回:Cはメモリをどう表現するか ― 配列・構造体・パディングの正体 ―
- 第4回:ビットの世界 ― レジスタ操作の作法とBSRRの思想 ―(本記事)