前回の第4回の終わりに、こんなコードが登場しました。
*(uint32_t*)0x40020018 = (1UL << 5); // BSRR に直接書く
「ポインタを使っている」とは言いましたが、正直まだ怖いと感じていませんか? 今回はその正体を完全に解き明かします。
ポインタ=型付きアドレス。これが全てです。
この「型付きアドレス」という視点で見ると、GPIOA->BSRR も *(uint32_t*)0x40020018 も、やっていることは全く同じです。CMSISヘッダが何をしているのかも、ポインタを理解すれば一瞬で読み解けます。
📖 前回の記事
📍 連載トップページ
✅ この記事でできるようになること
uint32_t* ptr・&x・*ptrの意味を自分の言葉で説明できる- 宣言の
*と演算子の*を瞬時に見分けられる (uint32_t*)0x40020018というキャスト式を内側から分解して読めるGPIOA->BSRRが内部で何をしているか説明できる- CMSISヘッダを開いて
GPIO_TypeDefの定義を自分で読める - 生ポインタでGPIOを直接操作するLチカコードを書いて動かせる
ポインタでつまずく3つの理由
本題に入る前に、「なぜポインタは難しいと感じるのか」を整理しておきます。原因を知っておくと、説明を読んだときに引っかかりが少なくなります。
理由1:* が2つの顔を持つ
uint32_t* ptr; // 宣言の *:「これはポインタ変数だ」という印
*ptr = 100; // 演算の *:「ptr が指している先を読み書きする」という操作
同じ記号が全く違う意味で使われます。この二重性が最大の混乱源です。この記事では特にここを丁寧に扱います。
理由2:「アドレス」が抽象的に感じる
0x20000000 と言われてもピンとこない――それは当然です。でも「住所」に置き換えると一気に具体的になります。ポインタは「どこに行けばデータがあるか」を覚えている変数です。
理由3:バグが「静かに壊れる」
ポインタの誤用は、コンパイルエラーにならずに実行時に予測不能な動作を引き起こします。「なぜ壊れるか」を知らないと怖いままです。第6回でその「壊れ方」を徹底的に扱います。今回はまず「正しい使い方」を完全に理解します。
📖 前回の記事
📍 連載トップページ
1. ポインタとは何か
1-1. 変数の復習 ― 変数は「名前の付いた箱」
まず変数に立ち返りましょう。
uint32_t x = 42;
このコードは「x という名前の、32ビット幅の箱を作って、中に 42 を入れる」という操作です。
CPUの視点ではこうです:
メモリ(RAM)
アドレス 内容
0x20000000: [ 42 ] ← x の実体はここ
0x20000004: [ 0 ]
0x20000008: [ 0 ]
...
変数 x は、メモリのどこか(例えば 0x20000000)に配置された32ビットの領域に過ぎません。「x」という名前はコンパイラが管理するラベルであり、CPUにとってはただのアドレスです。
1-2. なぜポインタが必要なのか?
「変数に名前があるなら、ポインタなんていらないのでは?」と思いますよね。ポインタが必要になる場面を3つ挙げます。
場面1:2つ以上の値を同時に書き換えたい
「return すればいいじゃないか」と思うかもしれません。でも return は1つの値しか返せません。
たとえば「ADCで読んだ値の最小値と最大値を同時に取得する」関数を考えます:
// return では1つしか返せない → min と max を同時に返せない
uint32_t get_min(uint32_t* buf, int len) { ... } // min だけ
uint32_t get_max(uint32_t* buf, int len) { ... } // max だけ
// 2回呼ぶ羽目になる
// ポインタを使えば1回で両方書き換えられる
void get_min_max(uint32_t* buf, int len, uint32_t* out_min, uint32_t* out_max) {
*out_min = buf[0];
*out_max = buf[0];
for (int i = 1; i < len; i++) {
if (buf[i] < *out_min) *out_min = buf[i];
if (buf[i] > *out_max) *out_max = buf[i];
}
}
// 呼び出し側
uint32_t sensor_buf[4] = {30, 5, 80, 12}; // センサーデータ(例)
uint32_t min_val, max_val;
get_min_max(sensor_buf, 4, &min_val, &max_val);
// 関数が返った後
// min_val → 5 (ポインタ経由で書き換えられた)
// max_val → 80 (ポインタ経由で書き換えられた)
組み込みでは「温度・湿度を一度に読む」「エラーコードと結果を同時に返す」場面でよく登場します。HAL 関数の引数に uint32_t* が多いのもこの理由です。
場面2:大きなデータを関数に渡したい
100個のセンサーデータが入った配列を関数に渡すとき、値渡しでは 100個分のコピーが作られます。ポインタ渡しならアドレス(4バイト)だけを渡せます。組み込みではメモリが限られているため、これが死活問題になります。
// ❌ 構造体の値渡し:100個分(400バイト)がスタックにコピーされる
typedef struct { uint32_t data[100]; } SensorBuf;
void process_copy(SensorBuf buf) {
// buf は元データのコピー。400バイト分のスタックを消費する
}
// ✅ ポインタ渡し:アドレス4バイトだけが渡される
void process_ptr(uint32_t* buf, int len) {
// buf は元の配列の先頭アドレス。コピーは一切発生しない
}
SensorBuf sensor_data;
uint32_t raw_data[100];
process_copy(sensor_data); // スタックに 400バイトのコピーが積まれる
process_ptr(raw_data, 100); // スタックには 4バイト(アドレス)だけ
補足:C言語では生配列は値渡しできない
void f(uint32_t buf[100])と書いても、C言語は配列をそのままコピーしません。内部的にはvoid f(uint32_t* buf)と同じになります。構造体に包むか、ポインタで渡すのが正しい方法です。
STM32F401 の RAM は 96KB しかありません。大きな構造体を関数のたびにコピーしていると、あっという間にスタックが足りなくなります。
場面3:ハードウェアレジスタを操作したい(組み込みの本題)
// 「0x40020018番地に書く」という操作はポインタなしでは書けない
*(volatile uint32_t*)0x40020018 = (1UL << 5);
ハードウェアのレジスタには固定のアドレスがあります。そこに直接書くには、「そのアドレスを指すポインタ」が必要です。これがこの連載でポインタを学ぶ最大の理由です。
1-3. ポインタは「住所を持ち歩く変数」
では、ポインタ変数とは何でしょうか。
ポインタ変数 = アドレス(住所)を格納する変数
普通の変数が「値を入れる箱」なら、ポインタ変数は「住所を入れる箱」です。
変数とポインタの関係。ptr は x の住所を持ち、矢印で指している
宅配便のラベルで考えてみましょう。
| 普通の変数 | ポインタ変数 |
|---|---|
| 箱そのもの(りんごが入っている) | 「この住所に荷物がある」と書いたラベル |
| 中身を取り出すには箱を開ける | 中身を取り出すにはその住所へ行く |
メモリ(RAM)
アドレス 内容 説明
0x20000000: [ 42 ] ← x の実体(りんご)
0x20000004: [0x20000000] ← ptr(「0x20000000に行け」と書いたラベル)
ポインタ変数 ptr 自身もどこかのアドレスに配置されていますが、その中身は「別の場所のアドレス」です。
大事なポイント:ptr と *ptr は別物
ptr = 0x20000000 ← ラベルに書かれた「住所」
*ptr = 42 ← その住所に行ったときに見つかる「中身」
ptr と *ptr は全く別の値です。ポインタを使うときは「今、住所の話をしているのか、それとも中身の話をしているのか」を常に意識してください。
1-4. 「型付きアドレス」という本質
ここが核心です。
ポインタは単なる数値(アドレス)ではありません。型情報が付いたアドレスです。
uint32_t* ptr; // 「uint32_t が置いてある場所を指す」ポインタ
uint8_t* ptr8; // 「uint8_t が置いてある場所を指す」ポインタ
なぜ型が必要なのか? 理由は2つです:
| 理由 | 説明 |
|---|---|
| 読み書きするサイズを決める | uint32_t* なら4バイト、uint8_t* なら1バイト単位でアクセスする |
| ポインタ演算のステップサイズを決める | ptr + 1 は型のサイズ分だけアドレスを進める |
アドレス(住所)だけでは「その場所から何バイト読めばいいか」がわかりません。型があって初めて、「どのくらいの大きさで読み書きするか」が確定します。
0x20000000 番地を「uint32_t として読む」→ 4バイト読む
0x20000000 番地を「uint8_t として読む」→ 1バイト読む
「アドレス + 型」がポインタの正体です。
2. ポインタの宣言・参照・間接参照
2-1. 宣言
uint32_t* ptr;
// ↑ ↑
// 型情報 ポインタであることを示す *
「uint32_t が置いてあるアドレスを保持する変数 ptr」を宣言しています。
⚠️
*の位置問題:これが混乱の温床次の3つは全て同じ意味です:
uint32_t* ptr; // * を型に付ける(本連載スタイル) uint32_t *ptr; // * を変数名に付ける(C言語の古いスタイル) uint32_t * ptr; // * を中央に置くコンパイラはどれも同じコードを生成します。書く人によってスタイルが違うため、他人のコードを読むと「あれ、これどっちだっけ」となりやすいのです。
さらに厄介なのが複数宣言:
uint32_t* a, b; // a はポインタ、b は普通の uint32_t(!) uint32_t *a, *b; // a も b もポインタ
uint32_t*スタイルだと「uint32_t*型の a と b」に見えますが、実際は*は変数aにしかかかりません。この落とし穴にハマった経験のある方は多いはずです。本連載では
uint32_t* ptrに統一します(1変数ずつ宣言するので実害なし)。
🔑 コラム:
*の2つの顔を理解するポインタ最大の混乱ポイントです。
*は場所によって全く違う意味を持ちます。① 宣言に登場する
*(型の一部)uint32_t* ptr; // 「ptr はポインタ変数だ」という型情報の一部これは演算ではなく、「この変数はアドレスを格納する変数ですよ」という宣言の記法です。
uint32_t*という型を「uint32_t ポインタ型」と読みます。② 式の中に登場する
*(間接参照演算子)*ptr = 100; // 「ptr が指している先」を操作する演算子既存のポインタ変数
ptrの前に*を付けると、「ptrが持っているアドレスの場所へ行って、そこを読み書きする」という意味になります。見分け方のコツ:
位置 意味 判断基準 型名の右・変数名の左(宣言時) 「ポインタ型だ」という印 =より左側にある式の中(代入・計算時) 「指した先を操作する」演算子 既存の変数名の前にある 同じ文字でも意味が違う――Cの設計上の都合ですが、慣れれば一瞬で見分けられます。
2-2. アドレスを取得する &(アドレス演算子)
変数 x のアドレスを取得するには &(アンパサンド)を使います。
uint32_t x = 42;
uint32_t* ptr = &x; // ptr に x のアドレスを代入
&x は「x が置いてあるメモリのアドレス(番地)」を返します。
何が起きているか、1行ずつ追ってみましょう:
① uint32_t x = 42;
コンパイラが RAM のどこかに x の領域を確保する
例:0x20000200 番地に 4バイト確保して、42 を書き込む
RAM
0x20000200: [ 42 ] ← x の実体
② &x
「x が置かれている番地を教えてくれ」という式
→ 0x20000200 という数値が返ってくる
③ uint32_t* ptr = &x;
ptr という変数に、その番地(0x20000200)を代入する
RAM
0x20000200: [ 42 ] ← x
0x20000204: [ 0x20000200 ] ← ptr(x の番地を覚えている)
「&」の読み方
&xは「x の アドレス」と読みます。「エックスのアンパサンド」ではなく「アドレス・オブ・エックス(address of x)」と声に出すと意味が頭に入りやすいです。
アドレスの実際の値について
0x20000200はあくまで例です。実際のアドレスはプログラムをビルドするたびに変わります(他の変数の配置状況による)。「具体的な値は実行してみないとわからない」ということ自体は問題なく、デバッガでptrの値を見れば確認できます。
取得したアドレスは何に使うのか?
ptr = &x でアドレスを取得した後、実際にやりたいことは「そのアドレスを別の関数に渡す」です:
uint32_t x = 42;
uint32_t* ptr = &x; // x のアドレスを ptr に入れる
// 使い道①:ptr 経由で x を書き換える(間接参照)
*ptr = 100;
// → x が 100 になる
// 使い道②:ptr を関数に渡して、関数の中から x を書き換えてもらう
void set_value(uint32_t* p) {
*p = 999; // 渡されたアドレスの先を書き換える
}
set_value(&x); // x のアドレスを渡す
// → x が 999 になる
// 使い道③:HAL 関数に渡す(組み込みでの典型的な使い方)
uint32_t adc_result;
HAL_ADC_Start(&hadc1);
HAL_ADC_PollForConversion(&hadc1, 100);
adc_result = HAL_ADC_GetValue(&hadc1);
// ↑ &hadc1 は「hadc1 のアドレスを渡している」
// HAL 関数の中で hadc1 の中身を読み書きするため
&x を単体で使うことはほぼなく、「誰かに渡す」か「ポインタ変数に代入する」 のどちらかです。
なお & は変数にしか使えません。リテラル(数値そのもの)や式には使えません:
uint32_t x = 42;
uint32_t* p1 = &x; // OK:変数のアドレスを取得
uint32_t* p2 = &42; // エラー:42 はどこかに置かれた変数ではない
uint32_t* p3 = &(x+1);// エラー:計算結果は一時的なもので番地がない
2-3. 間接参照 *(デリファレンス)
ポインタが指している先の値を読み書きするには *(アスタリスク)を使います。これを 間接参照(デリファレンス) と呼びます。
uint32_t x = 42;
uint32_t* ptr = &x;
uint32_t val = *ptr; // ptr が指す先(x)の値を読む → 42
*ptr = 100; // ptr が指す先(x)に 100 を書く → x が 100 になる
*ptr は「ptr が持つアドレスの場所に行って、値を読み書きする」という操作です。
ptr = 0x20000000
*ptr とは:
→ 0x20000000 番地に行く
→ uint32_t サイズ(4バイト)で読み書きする
ステップで追ってみましょう:
① uint32_t x = 42; を実行した直後
メモリ
0x20000000: [ 42 ] ← x
② uint32_t* ptr = &x; を実行した直後
メモリ
0x20000000: [ 42 ] ← x
0x20000004: [0x20000000] ← ptr(x のアドレスを持っている)
③ *ptr = 100; を実行した直後
「ptr が持つアドレス(0x20000000)へ行って、そこに 100 を書く」
メモリ
0x20000000: [ 100 ] ← x が 100 に変わった!
0x20000004: [0x20000000] ← ptr 自身は変わっていない
→ x と *ptr は同じ場所を指しているので、x の値も 100 になる
🔩 コラム:
*ptr = 100のとき、CPU は何をしているかC言語で
*ptr = 100;と書くと、コンパイラは CPU命令 STR(Store Register)に変換します。C言語 ARMアセンブリ(Cortex-M) *ptr = 100; → MOV r1, #100 ; 書き込む値を r1 に入れる STR r1, [r0] ; r0 が持つアドレスに r1 の値を書く
r0がポインタの値(アドレス)、r1が書き込む値です。STR 命令がそのアドレスに向けて電気的に書き込みを実行します。逆に
val = *ptr;は LDR(Load Register)命令になります:val = *ptr; → LDR r1, [r0] ; r0 が持つアドレスから r1 に読み込むレジスタへの書き込み
GPIOA->BSRR = (1UL << 5)も、最終的には同じ STR 命令です。C言語からアセンブリ、そして実際の電気信号まで、すべてが一本の線でつながっています。実際に生成されるアセンブリは、第12回で objdump を使って直接確認します。
2-4. 3つの操作をまとめる
| 記法 | 意味 | 例 |
|---|---|---|
uint32_t* ptr |
ポインタ変数の宣言 | 「アドレスを入れる箱を作る」 |
&x |
変数のアドレスを取得 | ptr = &x |
*ptr |
指した先の値を読み書き | val = *ptr / *ptr = 100 |
✅ 2章のチェックポイント
-
uint32_t* ptrの意味を言葉で説明できる -
&xが何を返すか言える -
*ptr = 100が何をするか言える - 宣言時の
*と演算子の*を見分けられる
2-5. デバッガで ptr と *ptr の値を確認する
「本当に同じ場所を指しているのか?」デバッガで目で確認しましょう。
STM32CubeIDE のデバッグモードで、Variables ビューや Expressions(Watch)に以下を入力します:
// デバッグ用コード(main関数内に書く)
uint32_t x = 42;
uint32_t* ptr = &x;
*ptr = 100;
// ここにブレークポイントを置く
| 式 | 期待される値 | 意味 |
|---|---|---|
x |
100 |
x の中身 |
ptr |
0x20000xxx |
ptr が持つアドレス(実行ごとに変わる) |
*ptr |
100 |
ptr が指す先の中身(x と同じ) |
&x |
0x20000xxx |
x のアドレス(ptr と同じ値) |
ptr == &x が成立し、*ptr == x が成立することを実際に確認してください。
Variables ビューで ptr・*ptr・&x の値を確認。ptr と &x が同じアドレスを、*ptr と x が同じ値を示している
3. 型とポインタ演算
3-1. ポインタ + 1 が何を意味するか
まず「もし型がなかったら」を考えてみます。
uint32_t arr[] = {10, 20, 30, 40};
この配列はメモリ上でこう並びます:
アドレス 内容 要素
0x20000000: [ 10 ] ← arr[0] ← 4バイト占有
0x20000001: [ ]
0x20000002: [ ]
0x20000003: [ ]
0x20000004: [ 20 ] ← arr[1] ← 4バイト占有
0x20000005: [ ]
0x20000006: [ ]
0x20000007: [ ]
0x20000008: [ 30 ] ← arr[2]
...
arr[0] が 0x20000000 にあるなら、arr[1] は 0x20000004(4バイト先)にあります。
もし型がなく、+1 が「1バイト先」を意味するなら:
// 型がない世界(架空の話)
ptr + 0 → 0x20000000 arr[0] の1バイト目
ptr + 1 → 0x20000001 arr[0] の2バイト目(!) ← 違う要素に踏み込んでしまう
ptr + 2 → 0x20000002 arr[0] の3バイト目(!!)
ptr + 4 → 0x20000004 やっと arr[1] に届く
次の要素に進むのに +4 と書かなければならない――これでは uint32_t を uint8_t に変えるたびに全コードを書き直す羽目になります。
だからコンパイラは型のサイズを自動で掛けてくれます:
uint32_t arr[] = {10, 20, 30, 40};
uint32_t* ptr = &arr[0];
// コンパイラが自動で「+1 → +4バイト」に換算してくれる
*(ptr + 0) // → 0x20000000 を読む → 10
*(ptr + 1) // → 0x20000004 を読む → 20 (+1 × 4バイト)
*(ptr + 2) // → 0x20000008 を読む → 30 (+2 × 4バイト)
uint8_t* なら +1 が「1バイト先」、uint32_t* なら +1 が「4バイト先」――型が違えば同じ +1 でも進む距離が違う、これがポインタ演算の意味です。
uint8_t* の +1 → 1バイト先(1 × 1)
uint16_t* の +1 → 2バイト先(1 × 2)
uint32_t* の +1 → 4バイト先(1 × 4)
型がなければ「1個分進む」が計算できません。ポインタに型が必要な理由はここにあります――ポインタは「アドレス」だけでなく「型付きアドレス」でなければならないのです。
3-2. 配列の [ ] は実はポインタ演算の糖衣構文
C言語では、配列の添字 arr[i] は *(arr + i) と完全に同じものとして定義されています。
arr[2] ← コンパイラの内部では…
*(arr + 2) ← こう変換される(全く同じ機械語が生成される)
確認してみましょう:
uint32_t arr[] = {10, 20, 30, 40};
// 以下は全て同じ結果になる
uint32_t a = arr[2]; // → 30
uint32_t b = *(arr + 2); // → 30
uint32_t c = *(&arr[0] + 2); // → 30
arr[2] と書いたとき、コンパイラは「先頭アドレスから2個分(=8バイト)進んだ場所を読む」と解釈しています。ポインタ演算と配列アクセスは本質的に同じ操作です。
⚠️ 伏線:アライメント(境界割付)
uint32_tは 4の倍数のアドレス に置く必要があります。OK:0x20000000(4の倍数)← uint32_t をここに置ける OK:0x20000004(4の倍数)← uint32_t をここに置ける NG:0x20000001(4の倍数でない)← uint32_t をここに置いてはいけないこれを アライメント制約 と呼びます。型のサイズと同じバイト境界に揃える必要があるのです(
uint32_tは4バイトなので4バイト境界)。通常のコードでは、コンパイラが自動的に守ってくれます。しかしポインタを使って「特定のアドレスを強制的に指定する」場合、この制約を自分で守る必要があります。
アライメントを無視したポインタキャストを行うと、STM32 では HardFault(CPU例外)が発生します。
次回の第6回「ポインタ事故大全」でこの事故を実際に起こして観察します。今は「
uint32_tは4バイト境界じゃないとまずい」と覚えておいてください。
4. キャストの意味
4-1. 「番地は分かっている、でも型がない」問題
ここまでは &x でポインタを作っていました。&x は「x のアドレス」を返すので、型も自動で付いてきます:
uint32_t x = 42;
uint32_t* ptr = &x; // x は uint32_t なので ptr も自動で uint32_t* になる
しかし組み込みでは、「変数のアドレス」ではなく 「ハードウェアの番地」を直接書くことがあります:
0x40020018 // GPIOA の BSRR レジスタの番地(データシートより)
これはただの整数値です。番地は分かっている、でも型情報がない状態です。
0x40020018 という数値
↑
「ここに何バイトのデータがあるか」コンパイラには分からない
1バイト?2バイト?4バイト?
この「型のない番地」に型情報を与えるのが キャスト です。
4-2. キャストの書き方
(uint32_t*)0x40020018
// ↑
// 「この番地を、uint32_t が置いてある場所として扱え」という指示
(uint32_t*) を頭に付けるだけです。これで「4バイト単位で読み書きする番地」という情報がコンパイラに伝わります。
あとはこれをポインタ変数に代入すれば:
uint32_t* ptr32 = (uint32_t*)0x40020018;
// ↑ここで型を付けてからポインタ変数に入れる
// ptr32 経由で BSRR に書き込む
*ptr32 = (1UL << 5);
なぜ型なしで代入できないのか
uint32_t* ptr = 0x40020018; // コンパイルエラー
0x40020018は整数、uint32_t*はポインタ型――C言語は「整数をそのままポインタに入れる」ことを禁止しています。「何バイト読むか分からないまま操作するな」という安全装置です。キャストは「分かっている、自分で責任を取る」という明示的な宣言です。
4-3. キャストして間接参照する
キャストとデリファレンスを組み合わせると:
*(uint32_t*)0x40020018 = (1UL << 5);
初見では一塊の記号の塊に見えますが、内側から外側へ分解すると読めます:
*(uint32_t*)0x40020018 = (1UL << 5);
↑ ↑
② 外側の * ① 内側のキャスト
① (uint32_t*)0x40020018
└─ 0x40020018 という数値に「uint32_t の場所だ」という型を付ける
→ 「0x40020018 番地を指す uint32_t* ポインタ」になる
② *( ... )
└─ そのポインタが指す先を読み書きする(間接参照)
→ 「0x40020018 番地の 4バイトを読み書きする」
③ = (1UL << 5)
└─ その場所に値を書き込む
「0x40020018 番地を uint32_t として読み書きする」 という操作です。
読み方の練習 この種の式は「カッコの内側から読む」が鉄則です。
*(uint32_t*)アドレス→ 「アドレスを uint32_t ポインタとしてキャストし、その先を読み書きする」と声に出して読む習慣をつけましょう。
前回の BSRR 操作と見比べると:
GPIOA->BSRR = (1UL << 5); // CMSIS スタイル
*(uint32_t*)0x40020018 = (1UL << 5); // 生アドレス スタイル
GPIOA の BSRR のアドレスが 0x40020018 ですから、これは全く同じ操作です。
5. メモリマップI/O ― ポインタでハードウェアを触る
5-1. 周辺機器もメモリ空間にいる
第1回でメモリマップを学んだとき、「周辺機器(GPIO、UART、タイマー)もアドレスで管理されている」という話をしました。
STM32F401のメモリマップ(抜粋):
アドレス範囲 内容
0x00000000 - 0x1FFFFFFF : Flash(プログラム領域)
0x20000000 - 0x3FFFFFFF : SRAM(変数・スタック)
0x40000000 - 0x5FFFFFFF : 周辺機器レジスタ(GPIO、UART、タイマーなど)
0xE0000000 - 0xFFFFFFFF : CPU内部(デバッグ、SysTick)
周辺機器レジスタは 0x40000000 番台のアドレスにあるということは、ポインタで直接アクセスできるということです。
これを メモリマップI/O(Memory-Mapped I/O) と呼びます。
5-2. GPIO のアドレスを手計算する
GPIOA の ODR(Output Data Register)のアドレスを、データシートから手計算してみましょう。
手順:
- STM32F401のAHB1バスベースアドレス:
0x40020000(データシート Table 1 より) - GPIOA のベースアドレス:
0x40020000 + 0x0000 = 0x40020000 - ODR のオフセット:
+0x14(GPIO レジスタマップより) - GPIOA->ODR のアドレス:
0x40020000 + 0x14 = 0x40020014
| レジスタ | オフセット | アドレス |
|---|---|---|
| MODER | 0x00 | 0x40020000 |
| OTYPER | 0x04 | 0x40020004 |
| OSPEEDR | 0x08 | 0x40020008 |
| PUPDR | 0x0C | 0x4002000C |
| IDR | 0x10 | 0x40020010 |
| ODR | 0x14 | 0x40020014 |
| BSRR | 0x18 | 0x40020018 |
前回使った 0x40020018 が BSRR のアドレスだということが、データシートから確認できます。
5-3. ポインタで直接書く
GPIOA の BSRR に生アドレスで書き込む:
// PA5 を HIGH にする(BSRR の bit5 をセット)
*(volatile uint32_t*)0x40020018 = (1UL << 5);
// PA5 を LOW にする(BSRR の bit21 をセット ← bit16+5)
*(volatile uint32_t*)0x40020018 = (1UL << (16 + 5));
volatileについてvolatileは「コンパイラよ、この読み書きを最適化で省略するな」という指示です。 レジスタは、C言語側から書いていなくてもハードウェアが値を変えることがあります。そのため、volatileを付けて「毎回必ず読み書きせよ」と指定します。 → 第3回で詳しく解説しています
6. CMSISが やっていること ― GPIO_TypeDef の解読
6-0. CMSIS とは何か
STM32 のプロジェクトを CubeIDE で作ると、自動的に大量のヘッダファイルが追加されます。その中に CMSIS と呼ばれるファイル群があります:
<プロジェクトルート>/
└── Drivers/
└── CMSIS/
└── Device/
└── ST/
└── STM32F4xx/
└── Include/
└── stm32f401xe.h ← これが CMSIS ヘッダ
相対パスで書くと:
Drivers/CMSIS/Device/ST/STM32F4xx/Include/stm32f401xe.h
CubeIDE のプロジェクトエクスプローラーでこのパスを辿るか、コード中の GPIOA を Ctrl+クリック(または右クリック → Open Declaration)すれば直接ジャンプできます。
CMSIS(Cortex Microcontroller Software Interface Standard)は ARM社が定めた「マイコンの共通インターフェース規格」 です。その主な役割は 「レジスタのアドレスに名前を付けること」 です。
| 生アドレス | CMSIS による名前 |
|---|---|
*(volatile uint32_t*)0x40020018 |
GPIOA->BSRR |
*(volatile uint32_t*)0x40020014 |
GPIOA->ODR |
*(volatile uint32_t*)0x40020000 |
GPIOA->MODER |
CMSIS ヘッダがなければ、すべてのレジスタ操作を生アドレスで書かなければなりません。CMSIS はその作業を肩代わりしてくれるヘッダファイルです。
「GPIOA->BSRR」はマジックワードではない CubeIDE のサンプルや HAL コードを見ると
GPIOA->BSRRが当たり前のように登場します。でもその正体は、CMSIS ヘッダの中でポインタと構造体を使って定義されたものです。この節でその中身を完全に読み解きます。
6-1. 毎回アドレスを手で書くのは辛い
前節のように生アドレスで書くと、コードが読みにくくなります:
// これだと何のレジスタかわからない
*(volatile uint32_t*)0x40020018 = (1UL << 5);
// こっちの方が明確
GPIOA->BSRR = (1UL << 5);
CMSIS ヘッダは、この問題を構造体ポインタで解決しています。
6-2. GPIO_TypeDef の正体
STM32 の CMSIS ヘッダ(stm32f401xe.h)を開くと、こんな定義があります。STM32CubeIDEで Ctrl+クリック(GPIOA を右クリック→「Open Declaration」)で直接ジャンプできます:
stm32f401xe.h を開いた様子。GPIO_TypeDef の構造体定義と #define GPIOA の行が確認できる
typedef struct {
volatile uint32_t MODER; // オフセット 0x00
volatile uint32_t OTYPER; // オフセット 0x04
volatile uint32_t OSPEEDR; // オフセット 0x08
volatile uint32_t PUPDR; // オフセット 0x0C
volatile uint32_t IDR; // オフセット 0x10
volatile uint32_t ODR; // オフセット 0x14
volatile uint32_t BSRR; // オフセット 0x18
volatile uint32_t LCKR; // オフセット 0x1C
volatile uint32_t AFR[2]; // オフセット 0x20-0x24
} GPIO_TypeDef;
第3回で学んだ「構造体のメンバは宣言順にメモリに並ぶ」という知識が、ここで直結します。uint32_t(4バイト)が並んでいるので:
MODER→ +0x00 (先頭から 0 バイト)OTYPER→ +0x04 (先頭から 4 バイト)BSRR→ +0x18 (先頭から 24 バイト)
この構造体のレイアウトが、GPIOレジスタのメモリ配置とピッタリ一致するように設計されているのです。
左:stm32f401xe.h の GPIO_TypeDef 構造体定義。右:STM32CubeIDE デバッガの SFRs ビュー(GPIOA)。構造体のメンバ名・並び順が SFRs のレジスタと完全に一致していることが分かる
第3回で学んだ「構造体メンバは宣言順にメモリに並ぶ」が、ここで生きています。 → 第3回:Cはメモリをどう表現するか
📌 6-2 の要点:
GPIO_TypeDefはただの構造体。メンバの並び順がハードウェアのレジスタ配置と完全一致するよう設計されている。
6-3. GPIOA マクロの定義 ― アドレスに名前を貼る
① GPIOA_BASE でアドレスを定数化する
同じヘッダファイルに、こんな定義もあります:
#define GPIOA_BASE 0x40020000UL
0x40020000 という生アドレスに GPIOA_BASE という名前を付けているだけです。
② GPIOA でポインタとして使えるようにする
#define GPIOA ((GPIO_TypeDef*)GPIOA_BASE)
GPIOA_BASE(= 0x40020000)を GPIO_TypeDef* 型にキャストしています。つまり:
GPIOA = (GPIO_TypeDef*)0x40020000
「0x40020000 番地を GPIO_TypeDef として扱うポインタ」 です。マクロが展開されると、コード中の GPIOA はすべてこのポインタ値に置き換わります。
③ -> でメンバにアクセスするとアドレスが確定する
GPIOA->BSRR とは:
→ 0x40020000 番地を GPIO_TypeDef として解釈
→ BSRR メンバは先頭から 0x18 バイト先
→ 0x40020000 + 0x18 = 0x40020018 番地を uint32_t として読み書き
だから GPIOA->BSRR と *(uint32_t*)0x40020018 は完全に等価なのです。
📌 6-3 の要点:
GPIOAは((GPIO_TypeDef*)0x40020000)というマクロ。ポインタキャストでアドレスに型を付けた「名前付きポインタ」にすぎない。
6-4. -> 演算子の意味
-> は「ポインタ経由で構造体のメンバにアクセスする」演算子です。
まず、ポインタでない変数の場合、構造体メンバへのアクセスは . を使います:
GPIO_TypeDef reg; // 実体(ポインタではない)
reg.BSRR = (1UL << 5); // . でメンバにアクセス
これがポインタになると、一手間増えます。ポインタは「アドレスを持っているだけ」なので、まず * で中身に辿り着いてから . でメンバを読む必要があります:
GPIO_TypeDef* ptr = GPIOA; // ポインタ(アドレスを持っている)
(*ptr).BSRR = (1UL << 5);
//↑ * で「ptr が指す先の実体」を取得してから . でメンバにアクセス
ところが (*ptr).BSRR はカッコが必要で読みにくい。カッコを省いて *ptr.BSRR と書いてしまうと、演算子の優先順位の問題で意味が変わってしまうからです:
*ptr.BSRR // NG:「ptr という構造体の BSRR メンバをポインタとして間接参照」と解釈される
(*ptr).BSRR // OK:「ptr の指す先の実体」→「.BSRR」の順で解釈される
この「(*ptr).メンバ」という頻出パターンを1つの記号にまとめたのが -> です:
// この2つは全く同じ意味・同じ機械語が生成される
(*ptr).BSRR = (1UL << 5); // * と . を使う書き方
ptr->BSRR = (1UL << 5); // -> を使う書き方(こちらが一般的)
-> は「矢印演算子」と呼ばれ、「このポインタが指す先の、このメンバ」 という意味です。
操作の流れをすべて展開すると:
GPIOA->BSRR = (1UL << 5);
① GPIOA = (GPIO_TypeDef*)0x40020000
└─ 0x40020000 番地を指す GPIO_TypeDef* ポインタ
② -> BSRR
└─ GPIO_TypeDef の中の BSRR メンバ(オフセット +0x18)
③ 結果:0x40020000 + 0x18 = 0x40020018 番地に書き込む
つまり GPIOA->BSRR と *(volatile uint32_t*)0x40020018 は完全に同じ操作です。
CMSISヘッダが GPIOA->BSRR という書き方を実現している仕組みが、以上で完全に説明できます。
| 書き方 | 正体 |
|---|---|
GPIOA->BSRR = x |
*(volatile uint32_t*)0x40020018 = x と同義 |
GPIOA->ODR = x |
*(volatile uint32_t*)0x40020014 = x と同義 |
GPIOB->BSRR = x |
*(volatile uint32_t*)0x40020418 = x と同義 |
7. 実践:ポインタでGPIOを直接操作する
7-1. 目標
第4回のLチカを、生ポインタ(CMSIS定義なし)で実装します。データシートのアドレスだけを使って GPIO を制御することで、「CMSISヘッダは便利な名前を付けているだけ」という感覚を体験します。
7-2. 事前準備
第4回と同じく、CubeMX が生成した初期化コード(MX_GPIO_Init())でクロック有効化とピン設定は済んでいる前提にします。
BSRR の仕組みを忘れた場合は 第4回:ビットの世界 に戻って確認してください。
使用ピン:PA0 に接続した外付け LED(第4回と同じ回路) NUCLEO-F401RE のオンボード LED(PA5)でも動きます。その場合はコード中の
0を5に読み替えてください。
回路は第4回と同じです:
PA0 ──[330Ω]──[LED]── GND
7-3. コード
/* USER CODE BEGIN Includes */
#include "main.h"
/* USER CODE END Includes */
/* USER CODE BEGIN PV */
// GPIOA の各レジスタアドレス(データシートより)
#define GPIOA_BASE_ADDR 0x40020000UL
#define GPIOA_ODR_ADDR (GPIOA_BASE_ADDR + 0x14UL) // 0x40020014
#define GPIOA_BSRR_ADDR (GPIOA_BASE_ADDR + 0x18UL) // 0x40020018
// ポインタ定義
volatile uint32_t* const GPIOA_ODR = (volatile uint32_t*)GPIOA_ODR_ADDR;
volatile uint32_t* const GPIOA_BSRR = (volatile uint32_t*)GPIOA_BSRR_ADDR;
/* USER CODE END PV */
/* USER CODE BEGIN 3 */
while (1) {
// PA0 を HIGH(LED ON)
*GPIOA_BSRR = (1UL << 0); // BSRR の BS0 をセット
HAL_Delay(500);
// PA0 を LOW(LED OFF)
*GPIOA_BSRR = (1UL << (16 + 0)); // BSRR の BR0 をセット
HAL_Delay(500);
}
/* USER CODE END 3 */
PA5(オンボード LED)を使う場合は
(1UL << 0)を(1UL << 5)に、(1UL << (16 + 0))を(1UL << (16 + 5))に変えるだけです。
コードを読み解くと:
| コード | 意味 |
|---|---|
(volatile uint32_t*)GPIOA_BSRR_ADDR |
0x40020018 番地を uint32_t として扱うポインタ |
volatile uint32_t* const GPIOA_BSRR |
そのポインタを定数(const)として宣言 |
*GPIOA_BSRR = (1UL << 0) |
0x40020018 に値を書き込む=BSRRに書く |
const は「ポインタ自体の値(アドレス)を変えられない」という意味です。GPIOA_BSRR = 別のアドレス とするのを防ぎます。
7-4. CMSIS版との比較
// CMSIS 版
GPIOA->BSRR = (1UL << 0); // PA0 ON
// 生ポインタ版
*GPIOA_BSRR = (1UL << 0); // PA0 ON
生成されるアセンブリはほぼ同じです。CMSISは「名前と構造体レイアウトを整理してくれる便利なラッパー」に過ぎないことがわかります。
7-5. デバッガで確認する
STM32CubeIDE のデバッガで確認してみましょう。
Watch 式に以下を追加:
(uint32_t*)0x40020014 ← ODR のアドレス
*((uint32_t*)0x40020014) ← ODR の値
LED ON 時と OFF 時で *((uint32_t*)0x40020014) の値を確認すると:
| 状態 | ODR の bit0 |
|---|---|
| LED ON(PA0 HIGH) | 1 |
| LED OFF(PA0 LOW) | 0 |
Watch 式にポインタキャストを使って、任意のアドレスを直接覗けるのもデバッガ活用のテクニックです。
Live Expressions で
*((uint32_t*)0x40020014)の値を監視しながら LED が点滅する様子。ODR の bit0 が 1 → 0 → 1 と変化しているのが分かる
✅ 7章のチェックポイント
- 生ポインタ版のLチカコードを書いて動かせた
-
volatile uint32_t* constの意味を説明できる - CMSIS版と生ポインタ版が同じ動作であることを確認した
8. ポインタの応用 ― 関数引数でのポインタ渡し
8-1. 「値渡し」と「ポインタ渡し」
ポインタが組み込みで重要なもう一つの理由が「大きなデータを効率よく渡す」ことです。
// 値渡し(コピーが作られる)
void setValue_copy(uint32_t data) {
data = 100; // コピーを書き換えても元の変数は変わらない
}
// ポインタ渡し(アドレスを渡す)
void setValue_ptr(uint32_t* ptr) {
*ptr = 100; // ポインタ経由で元の変数を書き換える
}
// 使い方
uint32_t x = 0;
setValue_copy(x); // x はまだ 0
setValue_ptr(&x); // x が 100 になる
構造体のような大きなデータを関数に渡すとき、値渡しではコピーが発生してスタック(RAM)を消費します。ポインタ渡しならアドレス(4バイト)だけを渡すため効率的です。 スタックの仕組みは 第2回:変数が住む場所を見つける で解説しています。
8-2. const ポインタの使い方
「読み取り専用のデータを渡す」場合は const を付けます:
// データを読み取るだけで書き換えない関数
void printConfig(const uint32_t* config) {
// *config = 100; // コンパイルエラー:const なので書けない
uint32_t val = *config; // 読み取りは OK
}
組み込みでは「誤ってレジスタを書き換えることを防ぐ」目的でも使います。
9. よくある疑問(FAQ)
Q1. int* と int * どちらが正しい?
A: どちらも正しいですが、意味は全く同じです。int* a, b; と書いた場合、a はポインタですが b は普通の int になるので要注意。int *a, *b; のように書けば両方ポインタになります。本連載では int* を型に付けるスタイルで統一しています。
Q2. NULL ポインタとは?
A: NULL(= 0)は「どこも指していない」を意味する特別なポインタ値です。
uint32_t* ptr = NULL; // 「まだ何も指していない」
if (ptr != NULL) {
*ptr = 100; // NULL チェックを通してから使う
}
NULL ポインタをデリファレンスすると HardFault(CPU の不正アクセス例外)が発生します。次回の第6回でポインタ事故として詳しく解説します。 HardFault そのものの仕組みが気になる場合は、まず 第1回:マイコンは"アドレスの世界" で不正アドレスアクセスの概念を確認してください。
Q3. void* は何に使う?
A: void* は「型なしポインタ」で、どんな型のポインタでも受け取れます。malloc() の戻り値や memcpy() の引数で使われます。
void* generic_ptr;
uint32_t x = 42;
generic_ptr = &x; // uint32_t* を void* に代入(OK)
// 使うときはキャストが必要
uint32_t val = *((uint32_t*)generic_ptr);
組み込みでは汎用コールバック関数に void* を渡す場面で登場します。
Q4. ポインタのポインタ(uint32_t**)って何?
A: 「ポインタのアドレスを持つ変数」です。uint32_t* ptr 自身もメモリのどこかにあるので、そのアドレスを保持できます。
uint32_t x = 42;
uint32_t* ptr = &x; // x のアドレスを持つポインタ
uint32_t** pp = &ptr; // ptr のアドレスを持つポインタのポインタ
**pp = 100; // pp → ptr → x と辿って x を書き換える
組み込みでは 2 次元配列・コールバックの登録などで使います。今はこういうものがあると把握するだけで十分です。
Q5. GPIOA->MODER の -> を . で書いたらエラーになるのはなぜ?
A: . は「変数の構造体メンバに直接アクセス」する演算子、-> は「ポインタ経由で構造体メンバにアクセス」する演算子です。GPIOA はポインタなので . は使えません。
GPIO_TypeDef reg = *GPIOA; // ポインタを間接参照して変数にコピー
reg.BSRR = (1UL << 5); // 変数なら . でアクセス(ただし実際のレジスタには反映されない!)
GPIOA->BSRR = (1UL << 5); // ポインタ経由で実際のレジスタに書く
第5回のまとめ
今回は、C言語の難関「ポインタ」を「型付きアドレス」という視点で解きほぐしました。
今回学んだこと
✅ ポインタ=型付きアドレス:アドレスと型情報の組み合わせ
✅ 宣言・参照・間接参照:uint32_t* ptr、&x、*ptr の使い方
✅ 型の役割:読み書きサイズとポインタ演算ステップを決める
✅ キャスト:(uint32_t*)0x40020018 で数値をポインタに変換する方法
✅ メモリマップI/O:周辺機器レジスタをポインタで直接操作できる
✅ CMSIS の正体:GPIO_TypeDef 構造体ポインタでアドレスに名前を付けているだけ
✅ 生ポインタLチカ:CMSISなしでGPIOをポインタ直叩きで操作した
次のステップ
ポインタを武器にした次は、「壊れ方を知って強くなる」 回です。
ポインタは便利ですが、使い方を間違えると組み込みシステムが予測不能な動作をします。その「壊れ方」のパターンをデバッガで実際に観察し、事故が起きる仕組みを腹落ちさせます。
次回予告:ポインタ事故大全(なぜ壊れる?)
次回は、現場で実際に起きるポインタ関連のバグを「わざと作って壊す」ことで学びます。
学ぶこと:
- NULL デリファレンス:HardFault の正体
- ダングリングポインタ:消えた変数を指し続ける危険
- スタック寿命:ローカル変数のアドレスを返してはいけない理由
- 配列外アクセス:静かに別の変数を破壊する
- UB(未定義動作):コンパイラが予測不能な動作をする
今回「ポインタの本質」を理解したことで、なぜこれらが危険なのかが明確になります。
連載目次:
- 第0回:なぜ組み込みは難しく見えるのか ― 場所と時間の話 ―
- 第1回:マイコンは"アドレスの世界" ― 座標で読み解くハードウェア ―
- 第2回:変数が住む場所を見つける ― Flash、RAM、Stackの使い分け ―
- 第3回:Cはメモリをどう表現するか ― 配列・構造体・パディングの正体 ―
- 第4回:ビットの世界 ― レジスタ操作の作法とBSRRの思想 ―
- 第5回:ポインタ=住所(型付きアドレス)(本記事)
📍 連載トップページ