【超重要】今日こそ、C言語のおまじないの正体を暴く!!
はじめに
どうも私立YouTube高専校長です。
あなたは、C言語を書いた経験はあるでしょうか? 高専や大学でプログラミングの授業を受けたことがある方は、C言語に触れたことがある方も多いでしょう。
その時、"おまじない"として、ソースコードの先頭に#include<stdio.h>という一行を書くように教わり、 意味が分からないまま、写経していたのではないでしょうか。
そして、いまだに、このたった一行のソースコードをなぜ書く必要があるのかを理解していない、 あるいは曖昧なまま、放置してはいないでしょうか。
実は、この一行には、非科学的なおまじないで例えるには、勿体ないくらい、深くて重要な意味があります。
しかし、実際には、この一行を明確に理解して、C言語のプログラムを書かれている方は、非常に少ないように見受けられます。 教育現場でも、このおまじないの意味についてちゃんと説明できている現場はあまりないと思います。
かくいう私も、この一行の意味を正確に理解するようになったのは、C言語を勉強しはじめて、5年以上経過してからでした。
なぜそのような状況になっているかというと、 このおまじないは単なるプログラムの書き方・作法という次元の話ではなく、 『自分の書いたプログラムがどのような仕組みで、実行ファイルに変換され、どのようにコンピュータによって実行されているのか』という、根源的な問いに関わる話だからです。
そもそも、そんなことを気にしない人も多いでしょう。 あなたも、こんな、細かい事を理解する必要があるのか?と疑問に思っているかもしれません。
確かに、ただ簡単なアプリケーションを、作業として、プログラミングするだけならば、理解する必要はないかもしれません。
しかし、このレベルのことが理解できていないということは、あなたが書いたプログラムが、どのような仕組みで、コンピュータによって実行されているのかを、まったく理解できていないということです。
そして、それを理解していないということは、コンピュータの計算資源を無駄遣いするような、パフォーマンスの低いプログラムを開発してしまう可能性があります。
あるいは、例えばあなたが開発で、オープンソースで入手したソースコードを利用しようとしたとします。しかし大概最初はビルドに失敗して、色々なエラーが出ます。 しかし、ビルドの仕組みを知らないと、ビルドエラーが解決できないので、実行すらできないまま、諦めることになるでしょう。
このようなことは、オープンソースソフトウェアをビルドする際に、頻繁に発生する問題です。
したがって、おまじないの意味を理解することは、 自分の書いたプログラムが、実際にどのような仕組みでコンピュータによって実行されるのかということを理解することにつながるため、 ソフトウェアのパフォーマンスを改善したり、ビルドエラーを解決できるようになる上で、非常によい学習テーマです。
そこで、本日はC言語を勉強中の方向けに、#include<stdio.h>というたった一行のソースコードに隠れた深い意味を、完全に理解できるように、解説したいと思います。
また今回ご紹介する情報以外にも、こういった有益なコンピュータ関連の技術情報を発信しているので、 そういった動画を見逃したくない方は、今のうちにチャンネル登録しておいて貰えればと思います。
それでは、本編どうぞ
いつ必要なの?
まずは、そもそも、どのような時に、#include<stdio.h>を書く必要があるのか、おさらいしましょう。
stdioはstandard input/outputの省略です。その名の通り、標準入出力という意味です。.hというのはヘッダファイルの拡張子になります。
printf関数やscanf関数などの、標準ライブラリで用意されている標準入出力関数を使用する時には、必ず、この#include<stdio.h>が必要です。
しかし、printf関数やscanf関数を使用するのに、なぜ、このinclude文が必要なんでしょうか? その背景と仕組みについて、順を追って、説明したいと思います。
標準ライブラリ
まずは、背景として、そもそも標準ライブラリとは何なのかという事について、少し説明しておきましょう。
先ほどから、"標準"という言葉を使っています。 そもそも、何をもって、"標準"と言っているかというと、実は、この標準ライブラリというのは、C言語の標準規格で定められているライブラリなんです。 したがって、C言語のコンパイラには、gccやclangなど様々な実装がありますが、標準ライブラリについては、コンパイラ間での移植が保証されます。
なぜ、このようなライブラリが用意されているかというと、例えば、標準入力や標準出力は、最終的にターミナルに文字を表示させる必要があります。このようなソフトウェアは、ハードウェアにアクセスする必要があるため、OSとやり取りが必要になります。したがって、システムコールと呼ばれる、OSのAPIに関する深い知識が必要になり、一般的なプログラマーが自前で実装するのは、非常に難しいです。
しかし、標準ライブラリが言語の仕様として、定義されているため、これを使えば、C言語のアプリケーション開発者は標準入力や標準出力を、自分で実装する手間を省くことができます。
あなたも、開発環境の構築時に、glibcという文字列を見たことがあるかもしれません。あれが、まさに標準ライブラリの一つの実装で、GNU C LibraryというGPLの標準ライブラリになります。
ということで、ここまでの話で、ソースコードに、#include<stdio.h>を書くと、自分が実装していない第三者の作った標準ライブラリを呼び出すことが出来るということが分かったと思います。
しかし、あなたは標準ライブラリのソースコードを見たことがあるでしょうか?答えは、NOだと思います。
標準ライブラリは、これから説明するリンクという仕組みによって、関数を巧みに抽象化し、呼び出す側のプログラムは、関数の具体的な定義を一切意識する必要がないようになっています。
ということで、続いて、なぜ、#include<stdio.h>を書くことによって、プログラムから第三者が作った標準ライブラリを呼び出すことが出来るのか、詳しく説明したいと思います。
なぜ必要なの?
分割コンパイル
それでは、なぜ、#include<stdio.h>を書くことによって、プログラムから標準ライブラリを呼び出すことが出来るようになるのかを理解するにあたって、分割コンパイルについて説明したいと思います。
分割コンパイルを、理解すると、#include<stdio.h>を書く必要性が理解できます。
あなたは、プログラムを書いているときに、ファイルが長くなりすぎて、プログラムが読みにくくなったという経験はないでしょうか?
ソースコードは、機能ごとに適切にファイルを分割しないと、ソースコードが読みにくくなります。 そこで、使われるのが、分割コンパイルという手法です。
もしあなたが分割コンパイルを、なんとなく使っているとしたら、是非、この機会に、その仕組みを学んでおいてください。 分割コンパイルは、なんとなく使っていると、不具合の元になるような、落とし穴がたくさん隠れています。仕組みを理解して、落とし穴に落ちないように気を付けましょう。
それでは、サンプルプロジェクトを用意したので、そちらを見てみましょう。
こちらのソースコードは、GitHubにあるので、興味があれば、動画詳細欄のブログのリンクから、飛んでください。
それでは、ソースコードを見ていきましょう。 まず、エントリーポイントであるmain関数は、main.cにあります。ここでは、add関数を使って足し算を計算をしています。
しかし、具体的なadd関数の内容は、ここでは定義されていません。実際に、add関数は、どのような処理を行うのか、このソースコードをいくら見ても、分かりません。 とりあえず、コンパイルして、実行してみましょう。
はい。1+2=3となりました。 正しく計算できましたね。
main.cには、add関数の定義がないにもかかわらず、 一体どのような仕組みでadd関数を呼び出しているんでしょうか? 調べていきましょう。
よく見ると、main.cでは、math.hをincludeしていますね。ここで定義されているんでしょうか? 見てみましょう。
なんと! math.hには、関数の名前と引数しかありません。これは、プロトタイプ宣言と呼ばれるものです。 肝心の定義がありません。一体どうなっているんでしょう?
残りの、math.cを見てみましょう。たしかに、ここでadd関数が定義されているようです。
よく見ると、main.cと同じように、#include<math.h>が書いてあります。
しかし、よく考えると、不思議です。 main.cはmath.hを参照していて、math.cもmath.hを参照しているだけです。 main.cはmath.cを参照していないにもかかわらず、実行ファイルはmath.cのadd関数を実行できています。
一体全体、どういう仕組みで、main.cからmath.cの関数を呼ぶことが出来ているのでしょうか?
これを理解するためには、プリプロセッサとリンカというソフトウェアの処理を理解する必要があるので、それについて説明したいと思います。
プリプロセッサとは
まずは、プリプロセッサについて、説明したいと思います。 C言語において、ソースコードをオブジェクトファイルに変換されるまでの手順を振り返りましょう。
C言語では、まずプリプロセッサが動きます。次に、それをコンパイルし、アセンブラがオブジェクトファイルに変換します。
コンパイラやアセンブラというのは、ご存じの方も多いかもしれませんが、プリプロセッサというのは、これまで、あまり耳にする機会はなかったかもしれません。
しかし、安心してください。プリプロセッサの、処理は非常に単純です。
プリプロセッサは、#から始まる、命令(ディレクティブ)を元に、文字列の展開や、ファイルの展開をしているだけです。
例えば、#include "math.h"は、math.hというヘッダファイルを探して、その行をファイルの内容で展開して、置換するだけです。
#include以外にも、マクロ、条件コンパイル、エラー、pragmaオプションなどのディレクティブがよく使われますが、これらはすべてプリプロセッサが処理しています。
このプリプロセッサが、コンパイルされる前に、処理されるということは、絶対に覚えておいてください。
あくまでコンパイラがコンパイルするのは、プリプロセッサが処理した後のソースコードなので、このことを知らないと、エラーメッセージの意味が分からず、問題解決が出来なくなります。
実際に、プリプロセッサが処理した結果が、.iファイルに出力されているので、それを確認してみましょう。 main-main.iを見てみます。これらは、main.cをプリプロセッサが処理した結果です。
これをみると、たしかに、#include "math.h"が、math.hの内容、つまり、プロトタイプ宣言に、置き換わっていることが確認できます。
それでは、次に、プリプロセッサの出力が、コンパイラによってどのように処理されるのかを見てみましょう。
コンパイラ
それでは、次はプリプロセッサが処理した結果をコンパイラがコンパイルした結果を見てみましょう。アセンブリファイルを見ることで、確認できます。
まずは、main-math.sを見てみましょう。これをみると、コンパイラによって、add関数の定義が、コンパイルされていることが分かります。したがって、このアセンブリを変換した結果であるオブジェクトファイル(main-math.o)にも、正しくadd関数の定義が含まれています。
次に、main-main.sを見てみましょう。これをみると、コンパイラによって、main関数の定義が、コンパイルされていることが分かります。 しかし、add関数を呼び出してはいますが、@PLTと書いており、add関数の定義自体は、コンパイルされていません。
したがって、このアセンブリを変換した結果であるオブジェクトファイル(main-main.o)には、add関数を呼び出しを含みますが、この時点では、add関数の定義はないので、実行できません。
板書でまとめます。main-math.oは、add関数の定義のみを含みます。 一方で、main-maio.oは、add関数の呼び出しのみを含みます。
しかしこの時、重要なことは、関数の具体的な定義がなくても、#include "math.h"がプリプロセッサによって展開されており、プロトタイプ宣言されているので、エラーがなく、コンパイルすることが出来るということです。
プロトタイプ宣言によって、抽象化された関数の定義は、後付けで良いのです。
それでは、誰が、このmain.cのadd関数を、math.cのadd関数と結び付けてくれるのでしょうか? 次は、この謎を解き明かしていきたいと思います。
リンカ
これまでの説明で、main-main.oには、add関数の呼び出しが含まれる一方、add関数の定義自体は、main-math.oに含まれるという事が分かりました。
したがって、この2つのファイルをいい感じに結合して、単一の実行ファイルを作る必要があります。
そこで、登場するのが、リンカです。
リンカは、複数のオブジェクトファイルから、シンボルによる名前解決をして、よしなに結合して、単一の実行ファイルを、実行可能な形で出力してくれます。
分かりやすく書くと、このようになります。 すなわち、コンパイルは、ファイルごとにそれぞれ別で行い、main-main.oとmain-math.oを生成します。 それが終わった後で、リンカが、それらを結合して、最終的に欲しいmainという1つの実行ファイルを生成します。
実際に、mainを逆アセンブルした結果を見てみましょう。 すると、main関数からadd関数が呼ばれており、その関数の定義もしっかり同じファイルに記述されていることが確認できます。
そして、先ほどお見せしたように、このファイルは実際に実行することもできます。
当たり前のようにみえる、分割コンパイルは、このような仕組みで実現しているのです。 少し長くなったので、分割コンパイルの仕組みをまとめます。
分割コンパイルでは、#include "math.h"と書くことで、呼び出し側と呼び出される側で、プロトタイプ宣言をして、 呼び出し側では、『定義は分からないがそれを呼びだす』、呼び出される側では、『どこで呼び出されるかは分からないが、関数を定義する』、という形で、いったん個別のオブジェクトファイルとして、コンパイルします。
そして、最後にリンカが、単一の実行ファイルとして結合することで、分割コンパイルが可能になります。
ヘッダファイルで、プロトタイプ宣言さえしておけば、それをincludeすれば、定義なしでも、コンパイルできるので、関数の呼び出しと定義を分離することが出来るのです。これは、ビルドの仕組みを活かした、非常に優れた抽象化手法です。
そして、includeから始まる一行は、プリプロセッサが指定されたファイルを展開しているだけで、大したことはしていないという事も分かったと思います。
次はいよいよ、本日の本題である、printfやscanfなどの標準ライブラリが、どのような仕組みで呼ばれているのかという事について説明したいと思います。
#include<stdio.h>の正体
それでは、いよいよ#include<stdio.h>の正体を明かしていきたいと思います。
実は、標準ライブラリも、これでまでに説明した、分割コンパイルと、全く同じ仕組みです。stdio.hには、printfやscanf関数のプロトタイプ宣言が行われています。 実際に、main-main.iを見てみましょう。確かにprintfのプロトタイプ宣言がありますね。externは省略可能です。
しかし、ここで一点だけ、注意点があります。 先ほど説明した、分割コンパイルは、ビルド時に、関数の定義がリンクされていました。これは、静的リンクと呼ばれる仕組みです。
しかし、標準ライブラリは多くのアプリケーションから呼び出されるので、プロセスごとに、静的リンクすると、以下のような欠点があります。
. 実行ファイルが大きくなりストレージを無駄に消費する . メモリ展開による実行時間が無駄に増える . 実行時の物理メモリを無駄に消費する
したがって、計算資源が乏しいベアメタルやRTOSにおける開発では、標準ライブラリは今日ご説明したように、静的リンクされますが、 実は、あなたが馴染みのあるLinuxのような汎用OSの環境下では、標準ライブラリは動的リンクという仕組みによって、ビルド時に静的にリンクするのではなく、実行時に初めて、リンクされます。
この動的リンクの仕組みは、今回説明した静的リンクよりも、さらに複雑なので、今回は説明を省略しますが、 いずれにせよ、#include<stdio.h>は、プリプロセッサにより展開される、プロトタイプ宣言であり、 それによって関数の定義は抽象化したまま使うことができるのです。
動的リンクの仕組みも非常に賢く、面白い仕組みなので、いずれ説明したいなと思います。
以上で、#include<stdio.h>というおまじないの説明は終わりです。
このように、おまじないと呼ばれるたった1行のソースコードにも、深い意味があるのです。
まとめ
本日のまとめです。
今回の説明を通して、プリプロセッサ・コンパイラ・リンカの仕組みをなんとなく理解することが出来たと思います。
リンカは、シェルからだと、ldというコマンドから呼び出すことが可能ですが、通常はgcc等が裏で呼んでくれるので、意識することはありません。
したがって、C言語を使って簡単なアプリケーションを開発するだけならば、このような知識は必要ないかもしれません。
しかし、自分の書いているプログラムが実際にどのような仕組みでハードウェアによって実行されるのかを理解していないと、パフォーマンスを追求したり、問題解決をすることは絶対に出来ません。
実際に、計算資源が乏しい、ベアメタルやRTOSでの開発では、アドレスによって、物理的なメモリ自体が変わることもあるので、リンカファイルとよばれるファイルでリンカの設定をいじることは、決して珍しいことではありません。よくあることです。 ちなみに、リンカファイルをいじるのはとても楽しいです(^^♪
しかし、プログラミングを学び始めたばかりのころから、このような奥深いカスタマイズはなかなかできるものではありません。 誰だってそうだと思います。私もかつてはそうでした。
しかし、安心してください。私のチャンネルでは、コンピュータの仕組みを理解して、効率的なソフトウェアを開発するために必要な、コンピュータ関連の知識を原理から分かりやすく解説しています。 この動画を見ているあなたは、効率的なソフトウェアを開発できるようになるかと、不安になる必要はまったくありません。
なぜかというと、こんなたった1行のおまじないの意味を説明しているだけの動画を、20分も聞ける、あなたはポテンシャルの塊だからです。 自信を持って好奇心のままに勉強してください。自信を無くして、投げ出してしまう人が殆どです。 5年後には、天地の差が開いています。
もし、まだなにか分からない事があったら、ぜひコメントで教えてください。
自分もまだまだ知らないことはあるので、一緒に勉強していきましょう。 私が高専・東大・スタートアップ・日系大手メーカー・外資ハイテク企業と、、、人生の大半をコンピュータに投下して、学んできた全てをこのチャンネルにぶつけるので、私の動画を見ることによって、他では得られないコンピュータの原理的な考え方が身に付きます。
絶対にできます!一緒に頑張りましょう!
本日の動画の内容は以上になります。ここまでご視聴ありがとうございました。