C言語①:つまずきそうな小石を蹴飛ばしながら覚えるC言語

C言語はプログラミング言語ではあるものの、どちらかというと電子工作の延長である。電線に電流を流し過ぎると溶けるというような当たり前のことが、C言語では起こる。

アルちゃん

ここにある文章は普通に間違っている場合があります。


まず実行

2022年度現在
Windows環境(ここではMinGW)において

単純なプログラム

#include<stdio.h>
int main()
{
  printf("Helloworld\n");
}

を実行することを考える。
ここで、書いても良いが不要なもの:仮引数の型void, return 0;

#include<stdio.h>
int main(void)
{
  printf("Helloworld\n");
  return 0;
}

上記のようなコードの差は、環境によってはひょっとしたら咎められるかもしれないが、だいたい動く。
これらを.cファイルで保存。ここではtest.cとすると、このtest.cファイルをコンパイルすることを考える。

すなわちコマンドラインからgcc(GNU Compiler Collection)を用いて実行ファイル(Windows環境では.exe)を作成する。

ただしこの時、gcc.exeが置いてあるフォルダに対してパスが通ってなければならない。パスが通るとは、例えばWindowsならコントロールパネルから環境変数にたどり着き、ユーザーの環境変数ないしシステムの環境変数なりにgcc.exeが置いてあるフォルダの場所を追加するということである。

コマンドプロンプトなりPowerShellなりでなんらかの実行ファイルを実行しようと試みる時、Windowsはこの環境変数に登録してある場所から実行ファイルを探してみるし、探して見つからなかったら実行してくれない。

gcc -o file test.c

-oオプションによりfile.exeが作成される。
-oオプションを用いなければ作成されるのはWindows環境(ここではMinGW)ではa.exe
Linuxではa.out


main関数に戻り値の型intを指定しなかった場合のgcc警告

#include<stdio.h>
main()
{
  printf("Helloworld\n");
}
warning: return type defaults to 'int' [-Wimplicit-int]

宣言と定義

変数には宣言と定義がある。
関数にも宣言と定義がある。

宣言は主にコンパイラに向けて行われる。コンパイラに型情報を知らせる作業である。

型とは、メモリ領域をどれだけ確保すべきか、あるいはそこに確保された0と1とのビットデータをどのように解釈するかの情報である。

解釈とは、例えば適当な01001101なる情報があったとしても、それを符号なし整数とみなすか、単なる整数とみなすか、浮動小数点とみなすかみないな話である。これは最終的には、CPUにほりこむ場所に関わったりする。

変数の宣言は以下のようなヤツ

int n;

変数の定義は実際にメモリ上に領域を確保する作業。例えば

int n = 0;

関数の宣言は以下のようなヤツ(プロトタイプ宣言)

void func();

関数の定義は、波括弧つけたりして実際の処理を記述したもの

void func()
{
  //処る
}


いけるパターン(関数の使用前に関数の定義)

#include<stdio.h>
void func()
{
    printf("hello world");
}
int main()
{
    func();
}

ダメなパターン(関数を定義する前に使用)

#include<stdio.h>
int main()
{
    func();
}
void func()
{
    printf("hello world");
}

コンソールに出力される警告

test.c:4:5: warning: implicit declaration of function 'func' [-Wimplicit-function-declaration]
    4 |     func();
      |     ^~~~
test.c: At top level:
test.c:6:6: warning: conflicting types for 'func'
    6 | void func()
      |      ^~~~
test.c:4:5: note: previous implicit declaration of 'func' was here
    4 |     func();
      |     ^~~~

関数を暗黙的に宣言すな(4行目のやつ)

warning: implicit declaration of function 'func' [-Wimplicit-function-declaration]

関数の型が衝突しとる(6行目のやつ)

warning: conflicting types for 'func'

暗黙宣言した関数と衝突しとる、ここや(6行目のやつが4行目のやつと衝突しとる)

note: previous implicit declaration of 'func' was here

つまり4行で実行したつもりの関数が宣言とみなされ、後の関数実装がその暗黙宣言とみなされているやつと型が競合しているよという警告。

いけるパターン(関数の使用前に関数を宣言(プロトタイプ宣言))

プロトタイプ宣言の追加によっていけるパターンとなる(関数の使用前に関数を宣言)

#include<stdio.h>
void func();
int main()
{
    func();
}
void func()
{
    printf("hello world");
}

暗黙の関数宣言とは
関数はプロトタイプ宣言によって明示的に宣言される必要がある。
明示的に宣言とは、戻り値の型と引数の型をきっちり決めとけということである。

問題は
プログラマとしては関数を実行しているつもりなのに、コンパイラは『いやいや、関数の暗黙宣言は認めてませんやん』みたいに言ってくることである。プログラマはそもそも関数の暗黙宣言などしたつもりはないので、お互いに『何言ってんだお前は』となっている。

コンパイラは、初めて発見する関数には戻り値と引数の型がセットであると思っている。ゆえにそれは常に宣言、あるいは定義である。ゆえにコンパイラから見れば、戻り値の無い関数の実行は戻り値のない暗黙的な関数の宣言である。その後に関数の定義を発見したとて(6行目)、発見された関数は先に暗黙的に宣言された関数と型が衝突している。ゆえにコンフリクトがなんちゃらかんちゃら。

とはいえ以下のコードでも出てくるのは暗黙宣言警告である。

#include<stdio.h>
int main()
{
    int n = func();
    printf("%d", n);
}
int func()
{
    return 0;
}
test.c:4:13: warning: implicit declaration of function 'func' [-Wimplicit-function-declaration]
    4 |     int n = func();

その理由
関数は初めて発見されると、それが式中であっても宣言とみなされる。その時、関数はintを返すものだと仮定される。(K&R p88参照)

#include<stdio.h>

#つきはコンパイラに対する命令である。プリプロセッサと称される。
.hはヘッダファイル。このファイル自体がコンパイラに渡されるわけではないので.cファイルとは区別される。総じて#include<stdio.h>は”ヘッダファイルに書いてある内容をこの場所にコピペせよ”である。

ヘッダには基本的には関数のプロトタイプ宣言が記述してある。printfの宣言はstdio.hファイルに記述してあって、だいたいの場合使用するコンパイラの近くにあるから、探せば中身も普通に読める。
ヘッダは基本的には宣言が記述してあるが、インクルード命令はただたんにコピペするだけであるので、場合によっては関数の定義がそのまま書き込まれていることもある。

MinGWのstdio.hのprintf

int printf (const char *__format, ...)
{
  register int __retval;
  __builtin_va_list __local_argv; 
  __builtin_va_start( __local_argv, __format );
  __retval = __mingw_vprintf( __format, __local_argv );
  __builtin_va_end( __local_argv );
  return __retval;
}

こっちはVisual Studio近辺にあったやつ

    _Check_return_opt_
    _CRT_STDIO_INLINE int __CRTDECL printf(
        _In_z_ _Printf_format_string_ char const* const _Format,
        ...)
    #if defined _NO_CRT_STDIO_INLINE
    ;
    #else
    {
        int _Result;
        va_list _ArgList;
        __crt_va_start(_ArgList, _Format);
        _Result = _vfprintf_l(stdout, _Format, NULL, _ArgList);
        __crt_va_end(_ArgList);
        return _Result;
    }
    #endif

gccの方の__builtin_va_listはマクロでたらいまわされる。
例えばstdarg.hには以下のようにある。
置いてある場所の例
C:\MinGW\lib\gcc\mingw32\9.2.0\include

#ifndef __GNUC_VA_LIST
#define __GNUC_VA_LIST
typedef __builtin_va_list __gnuc_va_list;
#endif

//略//

typedef __gnuc_va_list va_list;

printfの挙動をまじめに追いかけたい人は以下の書を参照。

ハロー“Hello, World” OSと標準ライブラリのシゴトとしくみ 
坂井弘亮 (著)

以下、調査中の個人の感想

おそらくva_listの具体的な実装は、stdarg.h内で各システムに応じてたらい回される。そののち、システムに応じたアセンブラなり機械語なりに置き換えられる。

gccは多分機械語?
最適化されたビルトイン関数の構築を試みて、
だめだったら機械語使う感じっぽいが。

https://gcc.gnu.org/onlinedocs/gcc/Other-Builtins.html


Historically, compilers for many languages, including C++ and Fortran, have been implemented as “preprocessors” which emit another high level language such as C. None of the compilers included in GCC are implemented this way; they all generate machine code directly. This sort of preprocessor should not be confused with the C preprocessor, which is an integral feature of the C, C++, Objective-C and Objective-C++ languages.

訳:GCCに含まれるコンパイラは直接機械語に翻訳しますで。

この文脈における組み込み、builtinは、既に0と1の機械語に翻訳済みのデータであって、つまり実際にva_listなりが存在するのは.aとか.oとかのどっかのファイルである(感想です)。その機械語ファイルはABI側から提供されているかもしれないし、ABIの規格にのっとってgcc側の手によってCで実装され、その後機械語に翻訳されたものであるかもしれない。

va_listは、後述するが可変長引数を扱うためのCの規格である。そのため規格さえ満たせば実装は自由である。
実装するのはこの場合コンパイラ作る人、OS作る人、CPU作る人らであろう。ひょっとしたらABI作る人達もやってるのかもしれない。

コンパイラ作る人、この場合gccにはva_listをC言語として実装するなりアセンブラとして実装するなり機械語として実装するなりの選択肢があるにはある。

その具体的な実装は、配列なり線形リストであろう。キューやらスタックでも良い。結局のところ、問題とするのは与えられた可変長の引数を格納するためのデータ構造である。引数の取り出し方はva_argなどで問われる。

実際検索すると、charポインタ、voidポインタ、構造体(次の引数に対するオフセットをもつ、つまりリスト構造)などの実装が見られる。ただし単純なスタック構造を前提とすると移植性が損なわれるみたいな記述もある。

これは、実際の実装がABIに縛られるということである。

3.5.7 Variable Argument Lists
Some otherwise portable C programs depend on the argument passing scheme, implicitly assuming that all arguments are passed on the stack, and arguments appear in increasing order on the stack. Programs that make these assumptions never have been portable, but they have worked on many implementations. However, they do not work on the AMD64 architecture because some arguments are passed in registers. Portable C programs must use the header file <stdarg.h>in order to handle variable argument lists.
When a function taking variable-arguments is called, %al must be set to the
total number of floating point parameters passed to the function in vector registers. When __m256 or __m512 is passed as variable-argument, it should always be passed on stack. Only named __m256 and __m512 arguments may be passed in register as specified in section 3.2.3.

System V Application Binary Interface
AMD64 Architecture Processor Supplement
(With LP64 and ILP32 Programming Models)
Version 1.0

訳:あンさー、なんかったりめーみてーに引数の受け渡しに単純なスタックを前提としてっけどさー、AMD64はンっなもんみとめねーよ? ウチはレジスタつかっから。

例えばIntel 80386では関数の引数はスタックにあるものを全部渡す。ゆえにva_listの実装はcharポインタで足りる。
スタックとはメモリ上に確保された領域で、CPUはこの領域を指すアドレスを格納するスタックポインタと呼ばれるレジスタを持つ。CPUが関数を処理する場合、引数や戻り値はどっかに保存しておく必要があるが、その保存場所が具体的にどこであるのか(どのレジスタなのか、どのメモリ領域なのか)はあらかじめ約束事として決めておかなければならない。そういうのが呼び出し規約。
Intel 80386は関数の処理中に引数を使用する場合は、スタックポインタと呼ばれるレジスタを参照し、そこにある値をアドレスとみなした上でそれを辿ってメモリ上を見に行って、そこに置いてある値を引数とみなして引っ張ってくる。

AMD64は引数の受け渡しにいくらかのそれ用のレジスタを使用するため、ススタックを前提としたcharポインタではva_listを実装できない。

こんなような事柄から、コンパイラがC言語としてCの規格を実装してしまうとCPU作る人の自由を侵害するというか、まともに動かない場合すらあるため、コンパイラ側は基本的にはいったんアーキテクチャに問い合わせてみて、それに応じた実装をし、それに応じて選択するみたいなことをするか、あるいはアーキテクチャ側に丸投げするのではあるまいか。

それはつまりABIに従うということである。

従った結果、
gcc側がABIにのっとって作った機械語ライブラリを用いるのか、
あるいはABIが用意したものを利用するのか、
そんな感じになるかと思われる。

va_list

va_listはC言語で可変長引数を扱う場合に使用する型。
vaはおそらくvariable

va_listを作り
va_startしてva_listに引数を格納
va_argでva_listから引数を取り出して
va_endでva_listをクリアする。

ABI(Application Binary Interface)

ABIはAPIのバイナリバージョンである。
あるシステムが標榜しているABIにのっとった機械語は、その機械語に至るまでの過程がどうであれ、システム上で動作する。
システムとはwikipediaにも書いてある通りOSやライブラリである。勢力状況によってはCPUも関わるであろう。ABI発行の責任者はふんわりしている。

機械語と命令セット

コンパイラにはソースコードを直接機械語に変換するもの、アセンブリ言語に変換するもの、一回C言語に変換するもの、その他の中間コードに変換するものなんかがあり、gccは直接機械語に変換する方式をとる。

アセンブリ言語は機械語と1対1で対応する。機械語による命令形式はCPUに実行可能な最終形であり、CPUはプログラムカウンタと呼ばれるレジスタにアドレスを置いておくと、それをたどった先に置いてある0と1の羅列を命令だと解釈して実行する。つまりCPUは、最初からCPUにできることに番号を付けて命令セットとして用意してあるのであって、やれと言われ番号の動作をするだけである。

例えばMIPSアーキテクチャの場合

参考
コンピュータの構成と設計 第5版 上 単行本 – 2014/12/6
ジョン・L. ヘネシー (著), デイビッド・A. パターソン (著), 成田 光彰 (翻訳)

命令の長さが32ビットであるとして、
6ビットが命令の番号(オペコード、おおまかな命令の種類)
5ビットが第一ソース(source)のレジスタ
5ビットが第二ソース(target)のレジスタ
5ビットが出力格納先(destination)のレジスタ
5ビットがシフト量(shamt シフト演算用)
6ビットが命令の詳細(funct 例えば加算、減算などの区別)

例えばアセンブリ

add $s1, $s2, $s3

はレジスタ$s2と$s3の加算結果を$s1に格納

機械語では
000000 10010($s2) 10011($s3) 10001($s1) 00000 100000 
10進数だと
0 18 19 17 0 32
順番は入れ替わっているが対応関係は見て取れる

ここで例えば

sub $s1, $s2, $s3

はレジスタ$s2と$s3の減算結果を$s1に格納

機械語では
000000 10010($s2) 10011($s3) 10001($s1) 00000 100010 
10進数だと
0 18 19 17 0 34

違いはfunct領域のみである。


この記事が気に入ったらサポートをしてみませんか?