TCP/IPについてはWeb技術者が、意識するしないにかかわらず利用している、
インターネットに必要不可欠な通信プロトコルの一つです。
また近年はIoTなどの普及もあり、従来のWeb技術以外の分野にも必要不可欠な知識になってきています。
そこで、ネットワークAPIのデファクトスタンダードになっている、
BSDソケットインタフェースをベースとして改めてネットワークの勉強をしていこうと思ってます。
ネットワークの仕組み、特にTCP/IPについて書かれた本やドキュメントはたくさんありますが、私はソケットAPIを使ったC言語のソースコードを読んでみるまで、どんな説明をきいてもイメージがわかず、あまり仕組みが理解できなかったので基本的にC言語のソースコードの流れにそいながら、使われているデータや処理に基づいて動作を回を分けて学んでいくことにします。
もちろん、完璧だとは思っておりませんので、間違っているところがあれば随時指摘して頂けると嬉しいです。
まずはコネクション指向型の馴染み深いソケットAPIによるTCPの通信プログラムを介して、異なるホスト間の通信の仕組みについてみていきます。
第1回目に書いてみたソースコードはTCPのサーバプログラムです。
枯渇しているIPv4限定のレガシーな例ですが、仕組みを知るには単純な方が良いと思いますのでご了承ください。
いずれはIPv6に対応したsockaddr_in6構造体を使った例や、アドレスファミリに依存しないgetaddrinfoなどのAPIを利用した例もみていくつもりです。
追記
getaddrinfoを使った例はC言語で学ぶモダンなソケットAPI入門に書きました。
###実行環境
ざっくりいうと、x86_64のCentOs6.8です。コンパイラは何もオプションを指定していないgccです。
CPUやOSやコンパイラによって違うところもあると思いますが、基本は同じように動くはずです。
##ソースコード
#include <stdio.h> //printf(), fprintf(), perror()
#include <sys/socket.h> //socket(), bind(), accept(), listen()
#include <arpa/inet.h> // struct sockaddr_in, struct sockaddr, inet_ntoa()
#include <stdlib.h> //atoi(), exit(), EXIT_FAILURE, EXIT_SUCCESS
#include <string.h> //memset()
#include <unistd.h> //close()
#define QUEUELIMIT 5
int main(int argc, char* argv[]) {
int servSock; //server socket descriptor
int clitSock; //client socket descriptor
struct sockaddr_in servSockAddr; //server internet socket address
struct sockaddr_in clitSockAddr; //client internet socket address
unsigned short servPort; //server port number
unsigned int clitLen; // client internet socket address length
if ( argc != 2) {
fprintf(stderr, "argument count mismatch error.\n");
exit(EXIT_FAILURE);
}
if ((servPort = (unsigned short) atoi(argv[1])) == 0) {
fprintf(stderr, "invalid port number.\n");
exit(EXIT_FAILURE);
}
if ((servSock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0 ){
perror("socket() failed.");
exit(EXIT_FAILURE);
}
memset(&servSockAddr, 0, sizeof(servSockAddr));
servSockAddr.sin_family = AF_INET;
servSockAddr.sin_addr.s_addr = htonl(INADDR_ANY);
servSockAddr.sin_port = htons(servPort);
if (bind(servSock, (struct sockaddr *) &servSockAddr, sizeof(servSockAddr) ) < 0 ) {
perror("bind() failed.");
exit(EXIT_FAILURE);
}
if (listen(servSock, QUEUELIMIT) < 0) {
perror("listen() failed.");
exit(EXIT_FAILURE);
}
while(1) {
clitLen = sizeof(clitSockAddr);
if ((clitSock = accept(servSock, (struct sockaddr *) &clitSockAddr, &clitLen)) < 0) {
perror("accept() failed.");
exit(EXIT_FAILURE);
}
printf("connected from %s.\n", inet_ntoa(clitSockAddr.sin_addr));
close(clitSock);
}
return EXIT_SUCCESS;
}
###1〜6行目
必要なヘッダを読み込んでいます。
右にコメントで何を使うために読み込んでいるか記述しています。
そこから更にincludeして使えるようになっているものもありますが、そこは省略します。
###12〜17行目
必要なデータ型のメモリ領域を確保します。
例によってC言語ではchar型以外、型のサイズは未定義ですので環境によって違う可能性がありますが、intは4バイト、shortは2バイトの処理系を前提とします。
また、バイトは一般的には8ビットですが、厳密な定義ではないので、必要に応じて通信におけるデータ表現であるオクテットと表現することにします。
###19〜22行目
引数のチェックです。
実行時にソケットに紐付けるポート番号を任意の数字で指定できるようにしています。
###24〜27行目
文字列表現であるポート番号をatoi関数で整数表現に変換する処理をしています。
atoiは整数変換に失敗すると0を返すので、その場合は文字列によってサービス名が指定されたと判断して分岐すると色々と使い勝手が良くなりそうですが、今回は数字のみを受け付けるものとします。
###29から31行目
いよいよソケットAPIの登場です。
socket()はOSにソケットの作成を依頼するシステムコールです。
第1引数にプロトコルファミリを指定しますので、TCP/IPのプロトコルファミリを使うことを意味するPF_INETを指定します。
現状では、後述するAF_INETでも同じ意味になりますが、ソケットAPIの設計思想を尊重してPF_INETとします。(正直、PF_INETが必要になるはずだった世界のイメージがわかないので、どういうプロトコル実装だとわける想定になるのか、理解しているかた教えて頂けると助かります。。)
第2引数にはソケットの種類を指定します。今回は信頼性の高い通信を保証するストリーム型のプロトコルであるTCPを使用するのでSOCK_STREAMを指定します。
第3引数には使用するプロトコルであるTCPを意味するIPPROTO_TCPを指定します。
0を指定すると使用するプロトコルファミリとソケットの種類から自動判別しますが、将来プロトコルが増えた時に備えて明示的に指定しておきます。
それぞれ何が指定できるかは私の環境では、/usr/include/bits/socket.h
や/usr/include/netinet/in.h
に書いてありました。
###34〜37行目
サーバ側のsockaddr_in構造体を初期化しています。
sockaddr_in構造体はTCP/IPソケットにIPアドレスやポート番号などのアドレスを関連づけるために使用されるデータ型です。
私の環境のsockaddr_in構造体は下記のようになっています。
struct sockaddr_in
{
__SOCKADDR_COMMON (sin_);
in_port_t sin_port; /* Port number. */
struct in_addr sin_addr; /* Internet address. */
/* Pad to size of `struct sockaddr'. */
unsigned char sin_zero[sizeof (struct sockaddr) -
__SOCKADDR_COMMON_SIZE -
sizeof (in_port_t) -
sizeof (struct in_addr)];
};
__SOCKADDR_COMMON (sin_);
は私の環境ではプリプロセッサにより
#define __SOCKADDR_COMMON(sa_prefix) \
sa_family_t sa_prefix##family
に置換されますので、sa_family_t sin_familyとなります。
確保したメモリ領域にどんな値が入っているかわからないので、memsetを用いてゼロクリアしてから各フィールドに必要な値を格納していきます。
sin_familyにはインターネットアドレスファミリ(IPv4)であることを示すAF_INETを指定します。
sin_addrにはin_addr構造体を指定します。
in_addr構造体は私の環境では下記のようになっています。
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr;
};
s_addrフィールドにはINADDR_ANYを使用します。これによって、サーバのNICに複数のIPアドレスが割り当てられていた場合でもポート番号に応じてすべてのIPアドレス宛の接続を受付けられるようになります。
x_86はリトルエンディアンのアーキテクチャなので、別のホストにマルチバイトのデータを送信する場合はネットワーク標準であるビッグエンディアンへのバイトオーダーへの変換が必要です。ネットワークのバイトオーダーに変換するためにhtonlやhtonsなどの関数を利用します。
エンディアンについては特にネットワークプログラミングをするにあたって大事な知識なのですが、とりあえず今回はWikipediaのリンクをはるにとどめます。
そのうち構造体のビットフィールドを使うようになったら、また詳しく書きます。
ちなみにINADDR_ANYは私の環境では0x00000000を意味しているようなので変換の影響は受けないと思われますが、コーディングの一貫性をもたせる為にhtonl関数の処理をかけています。これを見る限りもとりあえず指定しておいた方が良さそうです。
htonlはhost to network longの略で自環境の4バイトの整数をネットワークバイトオーダーに変換します。同様にポート番号についてはhtons関数の処理をかけています。
htonsはhost to network shortの略で自環境の2バイトの整数をネットワークバイトオーダーに変換します。
##39から42行目
システムコールbind()で作成したソケットにIPアドレスとポート番号をひも付けています。
ソケットはプロトコルとIPアドレスとポート番号が紐付けられていなければリモートのホストからメッセージを受け付けることができません。
使用するプロトコルについてはソケットが作成された時点で関連づけられていますので、bind関数にさきほど作成したsockaddr_in構造体とその長さを渡してIPアドレスとポート番号をソケットに関連付けます。
ここで注意すべきなのがsockaddr_in構造体がsockaddrという構造体のポインタにキャストされていることです。
sockaddr構造体は私の環境では下記のようになっています。
struct sockaddr
{
__SOCKADDR_COMMON (sa_); /* Common data: address family and length. */
char sa_data[14]; /* Address data. */
};
};
__SOCKADDR_COMMON
の部分はプリプロセッサにより置換されますので、sa_family_t sa_family
となります。
残りのビットがchar型の配列で宣言されているので、何事かと思うかもしれませんが、
要するにsockaddr構造体はアドレスファミリのフィールドと任意の14ビットを格納できる記憶領域で構成されているということです。
sockaddrはソケットAPIの汎用的なデータ型であるのに対し、sockaddr_inはTCP/IPに特化したデータ型という位置づけになっています。
これによって各ソケットAPIはsockaddr構造体のポインタを引数として受け付けるだけでよく、sa_familyを調べることによって、構造体のデータフィールド構成がわかるので、それによって適切に処理を分岐させることができるようになってます。汎用性を意識した実装になっていると言えます。
これはデータを交換する手順と、データの内容を取り決めたプロトコルというものが具体化されているものの1つだと個人的には思っています。
なお、bindが失敗した際は恐らくperrorからもその旨のメッセージが表示されてますが、別のソケットに既にポートが紐付けられていないか確認してみるといいでしょう。
実行と停止を繰り返している場合は、TCPコネクション切断後のソケットのTime-Wait状態を考慮してしばらく待ってみることが必要になる場合があります。netstat
を活用しましょう。
##44から47行目
システムコールlisten()を呼び出し、はじめてクライアントからの接続状態を受け付ける状態になります。それ以前に到達したクライアントからの接続要求は全て拒絶されます。
たとえば実行プログラムがa.outとするとコマンドラインで、
./a.out 8080
と実行してみるとlistenによってクライアントからの接続を受け付ける状態になるので、別の端末でnetstat -tlnなどのコマンドを実行すると
tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN
と出てくるのが確認できます。
これでこのサーバプログラムがクライアントからの接続要求を受け入れられる状態になったということです。
この状態でクライアントから接続要求(SYN)があると、そのパケットに基づいてサーバはクライアントからの情報を格納した新たなソケット構造体を作成します。
そしてそれをもとにサーバ側のTCPモジュールは3ウェイハンドシェイクを行います。
ハンドシェイクが成功すると、新しい構造体はESTABLISHEDな状態になり、acceptで呼び出されるまでリスト構造のキューに格納されます。
ためしにサーバのプログラムを実行したまま、クライアントのWebブラウザでポート番号を8080番に指定してアクセスを繰り返し、サーバ側でnetstat -tnc
などのコマンドを実行するとその状態の経過が確認できます。
余談ですが、最初は適当なポート番号でためしたらブラウザから怪しいポート番号にアクセスするなと怒られたので、各ブラウザのベンダーはリモートホストのポート番号接続を制限しているみたいです。
もちろん、サーバ側では該当のポートは開放しておく必要はあります。
##49行目から58行目
クライアントと実際のデータの送受信を行うため、acceptはキューからESTABLISHEDなソケット構造体を取り出し、それにディスクリプタを割り当て、ユーザープロセスに返す処理を行います。
ソケットのディスクリプタはファイルディスクリプタと同じ整数値でオブジェクトのポインタが格納されている配列のインデックスと一致します。
この配列はカーネルで使用する入出力系オブジェクト共通のポインタ配列であり、入出力における抽象的なインターフェースを利用するために使用されます。
このことについてはLinuxのファイルディスクリプタをハックするにて書きましたので、興味がある方はこちらもあわせてご覧下さい。
本来ならばこれを使っていよいよクライアントとのデータの送受信を行いたいところですが、今回はソケットが使えるようになったところですぐに破棄を行っています。
ちなみに変数clitSockAddrとclitLenにはクライアントの情報が格納されていますので、今回はそれを利用してクライアントのIPアドレスを表示しています。
inet_ntoaはin_addr構造体からドット10進数表記の文字列を生成し、その格納領域の先頭アドレスを返します。
サーバのドメインがhogehoge.comだと仮定するとブラウザでhttp://hogehoge.com:8080
とアクセスするとすぐ接続がcloseするのでChromeにはERR_EMPTY_RESPONSEのような表示がでるのみですが、サーバ上ではconnected from xxx.xxx.xxx.xxx.
のように出力されます。
面白かったのが、ChromeやSafariでは一度のアクセスで上記のメッセージが3行だったのですが、Firefoxは13行もあったことです。
1度のリクエストでどういったやり取りが行われているのか、tcpdumpとかでそのへんの解析をしてみると面白いかもしれないです。
今回はTCPクライアントソフトとしてWebブラウザを使用しましたが、クライアントソフトも自作した方が色々と検証がしやすいので、第2回はTCPクライアントソフトを作ろうと思います。
###参考書籍