【9割が躓く】C言語ポインタを攻略せよ

校長
校長
Cover Image for 【9割が躓く】C言語ポインタを攻略せよ

はじめに

どうも、私立YouTube高専校長です。

あなたは、C言語を学習した経験はあるでしょうか?

C言語は、50年以上歴史のある言語ですが、現在でもバリバリ現役のプログラミング言語です。 そして同時に、コンピュータサイエンスを学ぶ上では、避けられない言語でもあります。

しかし、C言語を学習した多くの人は、一度は、ポインタという概念に躓きます。 これは、C言語における重要概念ですが、ここで挫折して、留年・退学していく友人を、私は何人も見送ってきました。

留年しないまでも、実は、ポインタの理解を、曖昧なままにしている、という方も少なくないと思います。 私も、かつてはそうでした。

しかし、ポインタの仕組みを理解しないと、C言語のプログラムを書くことはおろか、読むことすら、ままならないです。 なぜなら、C言語というのは、言語の機能がそれほど多くないので、ポインタを工夫して使うことによって、技巧的な手法で実装されているということが少なくないからです。 そして、それらを理解しないまま、プログラムを書いていると、意図しない動作を起こし、バグを作り込む可能性もあります。 あるいは、やりたい処理が、記述出来ないということも、ありえます。

そこで本日は、C言語学習者がポインタで躓いてしまう理由と、ポインタを理解するために必要な知識についてご紹介したいと思います。 また、ポインタをそもそも何のために使うのかという問いにもお答えできるように、プログラムを効率的に書くための、様々な応用があるので、それについても軽くご紹介したいと思います。

既に、ポインタを解説している教材は大量にあるので、この動画を見た後で、改めてポインタを勉強すれば、ポインタをより深く、正確に、理解できるようになるはずです。

また今回ご紹介する情報以外にも、こういった有益なコンピュータ関連の技術情報を発信しているので、 そういった動画を見逃したくない方は、今のうちにチャンネル登録しておいて貰えればと思います。

それでは、本編どうぞ。

ポインタで躓く理由

それでは、まず、学習者がポインタで躓く理由についてご紹介したいと思います。 それはずばり、あなたが、プログラムが動く仕組みを理解していないからです。

C言語は、その難易度にも関わらず、最初に学ばれることが多いです。 しかし、ポインタを理解するためには、プログラムがコンパイラによって、どのような実行ファイルに変換され、どのようにハードウェアによって実行されるのかを理解する必要があります。

これらを、初学者がいきなり理解するのは、あなたがどんな天才であろうとも、無理だと思います。 ノイマンでも無理でしょう。

そこで、本日はポインタで躓いた方でも、なんとなく理解できるように、細かい話は置いておいて、できるだけ、優しく解説してみようと思います。 サンプルプログラムも用意しているので、ぜひ、自分でプログラムを動かしたり、いじったりして、遊んでみてください。 手を動かし、修練を積むことが、一番の近道です。

それでは、ポインタを理解するにあたって、 まず、プログラムがどのような仕組みで計算しているのか、おさらいしましょう。

コンピュータには、CPUという演算器があります。 例えば、皆さんがパソコンを扱っているのならば、おそらくintelかAMDのCPUが搭載されていて、そこで足し算や引き算などの計算が行われています。 しかし、複雑な計算をするためには、単純な演算処理だけではなく、中間結果を保持するための、メモリが必要です。 例えば、あなたが、1から10まで、順番に足すとき、その中間結果を、どこかで、覚えていないと、最終的な結果を計算する事はできません。

そこで、コンピュータでは演算の中間結果をメモリに保持して、計算を行っています。 具体的に言うと、DRAMというコンデンサを集積した半導体に、電荷として、データを保持しながら、計算しています。

この図では、DRAMに置かれた、xとyという変数をCPUで足し算している様子を表しています。 これは、あなたがなにげなく、C言語で変数を定義したときに、実際にハードウェアで起きていることです。

つまり、あなたがプログラムで宣言した変数は、物理的にメモリの一定の領域が割り当てられて、そこに2進数として、変数の値が保持されます。 それを、CPUで読み書きすることで初めて、様々な計算が可能となります。 これは、AIだろうと、Webだろうと、ロボットだろうと、どんな発達したソフトウェアでも例外はありません。

そして、この時、変数がメモリのどの領域を割り当てられたかをソフトウェア目線で表すために、アドレスというものがあります。 アドレスというのは、日本語で住所という意味で、ここでは、メモリ上の住所というようなニュアンスで理解して頂いて、大丈夫です。

アドレスというのは、単純に1バイト(8bit)を単位に、記憶素子に0から連番で数字をつけただけのもので、まったく難しく考える必要はありません。 おそらく小学生でも、理解できるくらい単純な概念です。

この図では、xとyという変数がメモリの、1番地と2番地に保持されている様子を表しています。

実際に、C言語のソースコードをコンパイルして、マップファイルというものを見ると、変数が、メモリ上の特定のアドレスに配置されていることが確認できます。 マップファイルは、デバッグでたまに見ることがあって、色々ノウハウがあるので、リクエストがあれば別の機会に紹介したいと思います。

ここまでの説明で、メモリのアドレスという概念が理解できたと思います。

変数がメモリに配置されていて、配置されている場所をアドレスと呼ぶということさえ、理解していれば、ポインタはまったく新しい概念ではありません。 アドレスが理解できたら、既にポインタの半分を理解したと言っても過言ではないでしょう。

それでは、続いては、本日の本題である、ポインタについて説明していきたいと思います。

ポインタ

先ほど説明した、メモリのアドレスというのは、メモリの住所を数字で表したものです。つまり、ハードウェアの概念でした。 一方で、ポインタというのは、C言語特有の概念です。

例えば、uint32_tの型の変数hogeが、あったとします。 先程お話したように、変数は、物理的にメモリの一定の領域が割り当てられて、そこに2進数として、変数の値が保持されます。この変数は、32ビットの変数なので、4バイト割り当てられます。 先程ご説明したように、このアドレスはマップファイルで確認できますが、 プログラム中では、&hogeで参照することができます。

しかし、ここで、一点、注意点があります。 この&hogeで得られる値は、プログラムをOSなしのベアメタルで実行している場合には、先程ご紹介したマップファイルの値と完全に一致します。 しかし、パソコンのような汎用OS上でプログラムを実行している場合は、仮想アドレスが表示されるので、一致しないので、そこで戸惑わないように気をつけてください。 ここは、ローダーというソフトウェアや、MMUという別のハードウェアが絡んでくるところで、非常に発展的な内容になるので、リクエストがあれば別の機会に紹介したいと思います。

いずれにせよ、ここで、重要なことは、C言語は、プログラム中で、変数のアドレスを参照できるという点です。 そして、この&hogeで参照したアドレスは、変数で保持することができます。 このアドレスを保持するための変数を、ポインタと呼びます。

ポインタは、特別新しい概念ではなく、変数にすぎません。 アドレスを保持するための変数を特別に、ポインタと呼んでいるだけです。

ここは、文字を赤くしていますが、本日ご紹介する内容で、最重要ポイントです。 今日、ここだけは、覚えて帰ってください。

ポインタの説明は、以上なので、 ここからは、実際にポインタの使い方を説明していきます。

まずポインタの宣言方法ですが、ご覧のように、uint32_t *ptr_hogeのようにアスタリスクを付けると、ポインタを宣言できます。

サンプルプログラムを見てみましょう。サンプルプログラムは、GitHubで公開しているので、動画詳細欄のブログリンクにリンクがあるので、そこから飛んでください。

まず、get_val_32bit関数は、与えられたポインタに対して、ポインターが保持しているアドレスのデータを返す関数です。 ポインタptrに対して、*をつけることで、ポインタが保持しているアドレスにアクセスすることができます。例えば、ptrの値が0x1なら、メモリの0x1番地にアクセスします。 この関数では、値を読んでいるだけですが、値を書くこともできます。

main関数では、hogeに256を代入してから、hogeを表示して、 hogeのアドレスをptr_hogeに代入してから、get_val_32bit関数で、ptr_hogeにアクセスして、変数の値を読んで、表示しています。 そして、次に、hogeに1足してから、再び、ptr_hogeにアクセスして、変数の値を読んで、表示しています。

実際に実行してみましょう。 1つ目の表示は、すぐに理解できるかと思います。hogeを表示しているだけです。 2つ目は、ptr_hogeがhogeのアドレスを保持しているので、もう一度、hogeの中身、つまり、256が表示されます。 3つ目も、ptr_hogeがhogeのアドレスを保持しているので、hogeの中身、つまり、257が表示されます。

つまり、1つ目のprintfも、2つ目のprintfも、3つ目のprintfも、本質的には同じことをしていて、メモリのhogeにアクセスして表示をしているだけです。違いはポインタを経由しているかどうかという点だけです。

これが、最も基本的なポインタの説明になります。そして、これが全てでもあります。

しかし、これだけだと、なぜこんなポインタなんてものが必要なのか、いまいち分からないと思います。 ポインタには、様々な便利な応用方法があるので、次は、それを紹介しようと思います。

ポインタの応用

それでは、本日はキャスト、関数の抽象化、ダブルポインタ、メモリマップドI/Oと、4つの応用をご紹介したいと思います。

キャスト

まず、1つめの応用は、ポインタのキャストです。

先程の説明で、目ざとい人は、一つ疑問が湧いたかもしれません。 アドレスを保持するのに、なぜ、uint32_t *というように、変数のサイズを指定する必要があるのでしょうか? アドレスというのは、変数のサイズに依存せずに、同じはずなので、uint32_tというように変数のサイズを指定する必要がないのではないかと思うかもしれません。

実は、CPUがメモリに読み書きのアクセスをするためには、変数のサイズを指定する必要があります。 例えば、同じアドレスであっても、CPUがそれを8bitの変数としてみるのか、32bitの変数として見るのかで、データの内容が変わるからです。

分かりにくいと思うので、実際に、検証してみましょう。 次の、サンプルプログラムは、pointer_castブランチで、先程とほとんど同じ、プログラムです。

違いは、ptr_hoge_8bitという変数です。先程は、uint32_t *で宣言していたポインタをuint8_t *で宣言して、hogeのアドレスを代入しています。このとき、hogeは元来uint32_tなので、キャストしています。

そして、get_val_8bitの実装は、先程の、get_val_32bit関数と型以外、全く同じです。

一見、先程とまったく同じ結果が出力されそうですが、実際に実行してみましょう。 今回は、2つ目と3つ目のprintfで、256と257ではなく、0と1が表示されました。 これはなぜでしょうか?

図で解説します。 メモリ上には、32bitの変数hogeは、256で、このように配置されています。 32bitなので、4バイトです。 get_val_32bit関数は、uint32_t *でポインタを受け取るので、hogeのアドレスに対して、32bit読み取って値を返します。したがって、256という想定通りの結果が、表示されます。

一方で、get_val_8bit関数は、ポインタが保持するアドレス自体はhogeのアドレスなので、先程と全く同じですが、 ポインタの型がuint8_t *なので、8bitしか、アクセスしません。 ご覧のように、256の下位8bitはすべて0なので、0と表示されてしまいます。

そして、最後に、hoge += 1とインクリメントすると、hogeの値は、257になって、257の下位8bitは1となるので、1が表示される、という振る舞いになります。

このように、同じアドレスを保持するポインタでも、型を変更することによって、ハードウェアのアクセスの仕方が変わります。 ポインタのキャストは、構造体のポインタのアクセスで非常によく用いられる手法で、非常に便利です。 C言語の仕様として、非常に秀逸だなと個人的に感じる機能です。

ちなみに、余談ですが、今の説明は、プログラムを実行している環境がリトルエンディアンであるという前提で話しています。エンディアンというのは、バイトごとのデータの並びのことで、ビッグエンディアンの場合は、並びが逆になります。 したがって、256という数字は、ご覧のような、配置になり、上位8bitが表示されます。

これは、C言語の規格で定められている事ではなく、CPU、つまりハードウェアに依存して変わります。 現在は、なかなかビッグエンディアンのCPUに遭遇する機会はないかと思います。

続いて、関数の抽象化のご紹介です。

関数の抽象化

あなたは、フォンノイマン型アーキテクチャ​という言葉を聞いたことはあるでしょうか?

これは、コンピュータ科学上、非常に重要な概念で、 今や、当たり前ですが、皆さんが、パソコンやスマートフォンで、好きなアプリを自由にインストールして、 容易に自分だけのデバイスにカスタマイズできるのは、このアーキテクチャに則ってシステムが設計されているおかげです。

そして、現在のコンピュータは、ほぼほぼ例外なく、フォンノイマン型アーキテクチャ​を採用しています。

では、フォンノイマン型アーキテクチャとは、具体的になんなのかと言うと、プログラムをある種のデータとみなし、 プログラムをメモリに展開し、実行するという事です。

さらに、これが、ポインタにどういう影響を及ぼすのかというと、 ポインタが、変数だけでなく、関数のアドレスを保持することも可能になり、それは関数ポインタと呼ばれます。

これを使うことによって、関数を抽象化して、呼び出すことが可能になります。

具体的には、コールバック関数の登録や、関数テーブルが実装できるようになります。 関数ポインタの詳細な使い方までは、ここでは説明しませんが、リクエストがあれば別の機会に紹介したいと思います。

次は、ダブルポインタのご紹介です。

ダブルポインタ

C言語では、ポインタのポインタを定義することも可能で、ダブルポインタと呼びます。

通常のポインタを理解しているのなら、なにも難しいことではありません。 ポインタは、アドレスが格納された変数に過ぎないので、そのアドレスを格納する変数です。

ダブルポインタを使用すると、プログラムをシンプルに書くことが出来るケースがあります。

しかし、ダブルポインタは、頭が混乱するので、不具合の元になりがちなので、使用する際は、気をつけましょう。 私も実際に去年、オープンソースでダブルポインタの参照方法を間違えているバグを発見しました。

また、言語の仕様的には、トリプルポインタ、クアッドポインタも再帰的に定義可能ですが、トリプルポインタ以降は実務上、現れないと思います。

次は、メモリマップドI/Oのご紹介です。

メモリマップドI/O

先程、メモリは、DRAMというコンデンサを集積した半導体に、電荷としてデータを保持しているというお話をしましたが、実際には、メモリ空間の全てがDRAMという訳ではありません。 アドレスによって、物理的な実体が変わります。

もっと言うと、特定のアドレスの物理的な実体は、メモリですらなく、PCI Expressのような周辺機器のレジスタに対応していることが、一般的です。 このようにすることで、メモリアクセスによって周辺機器を制御する事ができます。 これが、メモリマップドI/Oと呼ばれる、CPUの機能です。

この機能を使うためには、特定のアドレスに、特定のデータを読み書きする必要があるので、ポインタの出番になります。

もし、ロボット開発やドライバ開発のような、ハードウェアを制御するプログラムが開発したい場合は、 必ず、このメモリマップドI/Oとポインタの知識を抑えておいてください。

はい、以上で、簡単にですが、ポインタの応用例を4つ紹介しました。 C言語で、ある程度高度な処理を書こうとすると、必ずポインタが必要になります。 様々な面で使用されるので、絶対に、理解しておきましょう。

かといって、本日ご紹介した内容を、今日初めて聞いた人が、いきなりすべて理解するのは、酷な話です。 ノイマンでも無理でしょう。

プログラムを書くにつれ、徐々にコンパイラやハードウェアの動きを理解できるようになり、理解が深まっていくものです。 重要なのは、失敗と継続です。

しかし、もしかしたら、今日の話を聞いても、ポインタ難しすぎるよ、もうだめだ、ここまでだ、そう思うかもしれません。 誰だって最初は、そうです。僕の、友人も、何人もC言語が理解できず、留年して、そして退学していきました。

しかし、安心してください。今喋っているのは、何人もの友人を、留年から救い、卒業させてきた人物でもあります。

私のチャンネルでは、ポインタやコンピュータの仕組みを理解するために必要な、知識を原理から分かりやすく解説しています。 この動画を見ているあなたは、ポインタやコンピュータの仕組みを理解できるようになるかと、不安になる必要はまったくありません。

なぜかというと、こんなポインタを解説しているだけの動画を、20分も聞ける、あなたはポテンシャルの塊だからです。 自信を持って好奇心のままに勉強してください。自信を無くして、そのまま投げ出してしまう人が殆どです。 5年後には、天地の差が開いています。

もし、まだポインタが分からなくて、お悩みであれば、是非コメント欄で質問してください。

自分もまだまだ知らないことはあるので、一緒に勉強していきましょう。 私が高専・東大・スタートアップ・日系大手メーカー・外資ハイテク企業と、、、人生の大半をコンピュータに投下して、学んできた全てをこのチャンネルにぶつけるので、私の動画を見ることによって、他では得られないコンピュータの原理的な考え方が身に付きます。

絶対にできます!一緒に頑張りましょう!

本日の動画の内容は以上になります。ここまでご視聴ありがとうございました。

リンク

サンプルプログラム