Part3 通信プロトコルを実現するプログラミング

最終更新: 1998-10-02

3.1 BPL

B+ に関しては、CompuServe社からドキュメントが公開され、Cで記述したライブラリ関数も配布されている。しかし、BPLは、このライブラリ関数を一切使わずに書き下ろしたものだ。なぜそうしたか、理由はいくつかある。まず、もちろん私自身がプログラミングを趣味と言い切っている以上、自分で書かなければ気がすまなかった、というのが本質的な理由である。もう一つの大きな理由はresume upload等の処理の組み込みをどうするかという問題と、イニシエータをどのように設計するか、という問題である。

BPLを書き始めた時点では、最初の版はCompuServe社から配布されているソースプログラムを多少修正したものを基盤とし、処理を追加することにしていた。しかし、これが結局破綻することになる。既存のプログラムを修正する作業は、同じ機能のプログラムを新規に開発するより手間がかかる場合があるのだ。そこで、BPLは途中の版から、全部プログラミングをやり直して、完全オリジナルにした。 その後、resume処理の追加、イニシエータモードの追加、アペンドモードの追加等を行うのだが、さらにもう一度全部プログラムをやり直している。この時の修正は、B+に関する関数を一つのソースファイルにまとめるという方針に基づいていた。最初、設計方針としては、機能別にファイルを分類しようと考えていたのだが、あえて逆らったのは、必要最低限の関数以外は外部からアクセスできないようにしたかったからである。 C言語では、関数は同一ファイル内でのみアクセスできるものと、どこからでもアクセスできるものの2種類しか用意されていない。もし複数ファイルに分けてBPLを設計すれば、相互に呼び出す関数は、その他のどこからでも見えてしまう。BPLは、手持ちの通信ソフトに勝手に組み込んで使えるように設計したかった。手持ちの通信ソフトからは、B+を実行する関数だけが見えればよいのであり、それ以外の関数は見えても意味がないのである。そこで、外部から呼び出す関数以外をstaticとし、隠蔽するために一つのファイルにまとめたのだ。

しかし結果としてはB+の処理を記述するソースファイルが90KBにもなってしまい、あまり面白くない。さらに検討が必要であると言えよう。

BPLは現在最新バージョンが50であるが、細かい変更により不規則に更新されている。最新のソースプログラムはNIFTY SERVEのプログラマーズフォーラムで公開されているので、あまり面白くないプログラムであるが、参照していただければ幸いである。

B+のプロトコルの仕様概略は既に述べたので、以下は、ポイントとなる処理がどのように記述されているかを中心に説明をする。

3.1.1 パケット処理(受信)

言うまでもなく最も重要な処理がパケットの送受信の処理である。BPLの中では、wait_for_acknowledgeという関数がパケット受信処理になる。B+の処理の中で最もややこしい処理なのだが、ドキュメントを参考にして、できるだけ忠実に書いたつもりのものがリスト2である。しかし、Cのプログラムとして見ると、あまりきれいな構造とは言えないようである。

構造としては、whileループの中で、データを受信して、それによってパケットが完成したり、ACKや、その他コントロールコードを受け取れば、受信データに応じた処理を行うという感じである。stsという変数が終了条件になっている。

あまりきれいでない点を指摘すると、まず、チェックサムを計算する処理のために、checksumという変数がアドレス渡しとして使われていること。ここは、checksumという変数を外に見せたくないために、このような処理にしたのであるが、実際はB+の仕様上、パケット受信処理がネストすることはあり得ない。すなわち、あるパケットを受信中に別のパケットが送られてくることはないのである。従って、checksumという変数はグローバルに1つあれば十分で、関数外に出しても不都合はないのだ。

そして、実際外に出ている変数がある。acknowledge_statusである。これは関数の外で定義されており、wait_for_acknowledgeから呼び出されているそれぞれの関数内で変化する可能性のある状態を保持する。安易に考えると、FSAを実現するのだから、ある状態で関数を呼び出した時に、次の状態が何になるかを戻せばよさそうなものだが、ここではなぜか外で定義されている変数に値を入れているのだ。これはstsの使い方が甘いのである。見直せる箇所である。
------------------------------------------------------------------------
リスト2
wait_for_acknowledge

/*--------------------------------------------------------------------*/
/* ACK 待ち状態の FSA
 * ack が得られたら、0 を戻す。
 * packet を受け取った場合は、1 を戻す。内容は current_packet。
 * error の場合は < 0
 */
STATIC int wait_for_acknowledge(int receive)
{
    UWORD checksum;
    int sts = 0;

    after_enq = 0;
    while (sts >= 0) {
        switch (acknowledge_status) {
        case S_Get_DLE:
            sts = s_get_dle(receive);
            break;

        case S_DLE_Seen:
            sts = s_dle_seen(receive);
            break;

        case S_DLE_B_Seen:
            sts = s_dle_b_seen();
            break;

        case S_Get_Data:
            sts = s_get_data();
            break;

        case S_Get_Check:
            sts = s_get_check(&checksum);
            break;

        case S_Get_CRC:
            sts = s_get_crc(&checksum);
            break;

        case S_Verify_CRC:
            s_verify_crc(checksum);
            sts = 0;
            break;

        case S_Veify_CKS:
            s_verify_cks(checksum);
            sts = 0;
            break;

        case S_Verify_Packet:
            sts = s_verify_packet();
            break;

        case S_Send_NAK:
            sts = s_send_nak();
            break;

        case S_Send_ACK:
            s_send_ack();
            sts = 0;
            break;

        case S_Send_ENQ:
            s_send_enq();
            sts = 0;
            break;

        case S_Resend_Packets:
            sts = s_resend_packets();
            break;
        }
    }

    if (sts == FINISH) {
        sts = acknowledge_status == S_Verify_Packet ? 1: 0;
    }
    return sts;
}
------------------------------------------------------------------------

3.1.2 パケット処理(送信)

パケットの送信処理は、データを作ってただ送るだけなので簡単だ。リスト3を見れば分かると思う。データ送信時には、必要ならクオートして1バイトを送る関数、mask_sendを呼び出している。DLEやETXは、com_sendを呼び出し、クオートと無関係にそのまま送信されている。

この処理でわかりにくいのは、パケットのシーケンス増加の処理を送信時に行っていることである。
------------------------------------------------------------------------
リスト3
パケット送信処理

/*===== Send a packet ================================================*/
/* 実際にパケット内容を送信する。Send Ahead の確認後に呼び出すこと。
 */
STATIC int send_packet(PACKET *pac)
{
    int i;

    pac->direction = DIRECTION_IS_UPLOAD;
    /* Lead-in */
    com_send(DLE);
    com_send('B');

    /* Sequence */
    if (pac->sequence > '9' || pac->sequence < '0') {
        pac->sequence = next_sequence(packet_no);
    }
    com_send(pac->sequence);

    check_value(CLEAR_CHECK_VALUE, pac->sequence);
    packet_no = pac->sequence;

    /* Type */
    /* Body */
    for (i = 0; i < pac->size; i++) {
        UCHAR data;

        data = pac->body[i];
        mask_send(data);
        check_value(ADD_CHECK_VALUE, data);
    }

    /* Trailer */
    com_send(ETX);
    check_value(ADD_CHECK_VALUE, ETX);
    i = (int) check_value(ASK_CHECK_VALUE, 0);
    /* 次の箇所は、check_method が nz の場合には CRC と断定しているので
     * 注意
     */
    if (check_method) {
        mask_send(i >> 8); /* mask_send は、常に下位 8 ビットのみ送る */
    }
    mask_send(i);
    return 0; /* error check は? */
}
------------------------------------------------------------------------

3.1.3 タイムアウト処理

この処理を実際に行なっている例として、Listを見て欲しい。自分で言うのも何だが、あまりエレガントでない処理である。もう少し奇麗に書きようがありそうなものだが、あまりよくないプログラムの例としてあえて解読しよう。内部で使っているstatic変数は、秒を数えるためのものである。値は呼び出し毎ではなく継続的に使いたいのでstaticとしてあるが、他の関数からは見える必要はないので、関数内で宣言されている。

kbd_tcheck()は、キーボードからの入力を調べてその時点での値をただちに戻す関数で、何も入力がない時には、-1が戻ってくる。これが戻って来た時には、second()を呼び出して、秒数の経過を調べ、INTERVAL_LIMITより大きくなったら、<DLE>;を送信して、変数time_startを0にリセットする。second()は、秒数を0〜60で割った余りしか戻さない。従って、この関数は少なくとも60秒以内に何度か呼ばれるという前提で使われている。

以上の処理は、kbd_tcheck()の引数に0を与えた時である。0以外の値を与えると、単にタイマーの変数をリセット(その時点の時刻に合わせる)するだけで、ただちにリターンする。従って、文字入力を行なう上位の関数は、まずkbd_tcheck(1)のように呼び出しておき、それ以後実際に文字を入力する所でkbd_tcheck(0)を呼び出すことになる。これはあまり美しい仕様とは言えないので、どうせならタイマーをリセットする関数を明確に分ける方がよいかもしれない。
------------------------------------------------------------------------
リスト4

STATIC int kbd_tcheck(int init)
{
    static int time_start;
    int c;

    if (init) {
        time_start = second();
        return 0;
    }

    while ((c = kbd_check()) == -1) {
        int now;
        int passed;

        /* Y/N の判断を待たせる場合に、DLE + ; を一定時間ごとに送る */
        now = second() - time_start;
        passed = now - time_start;
        if (passed < 0)
            passed += 60;
        if (passed > INTERVAL_LIMIT) {
            com_ssend(&quot;\020;&quot;);
            time_start = now;
        }
    }
    return c;
}

------------------------------------------------------------------------

3.1.4 クオート処理

B+の場合は、クオートを意味するキャラクタとして、<DLE>(0x10)を用いる。これに続く文字は、変換されたものであるから、受信側は元に戻して処理しなければならない。コントロール文字としては、0x00〜0x1fと、0x80〜0x9fの、2つの範囲のいずれに属するかによって処理が微妙に異なる。0x00〜0x1fの場合には、0x40を加算する。0x80〜0x9fの場合には、0x20を減ずる。 送信するデータの中にControl-S(0x13)が含まれていたとする。送信側は、これを0x10 0x53 という2バイトに置き換える。0x10、0x53はControl-Sのように特別な解釈をされずに、そのまま受信側のプログラムに引き渡されるとする。受信側のプログラムは、逆に、データの中に0x10 0x53という並びが出現したら、これを0x13に置き換えるのである。これで、0x13というコントロール文字を転送できる。 B+では、デフォルトで表11に示す文字がクオートの対象になる。0x10自身を送信したい場合には、自分自身をクオートしなければならないので、0x10 0x50という2バイトに置き換えられることになる。 XMODEMにはクオートの機能がない。バイナリのデータをそのまま送信する仕様である。このようなプロトコルでは、通信時の設定を完全にスルーにしておかなければならない。従って、XMODEM実行時には、XON/XOFF制御を無効にしなければならない。 B+のようにクオートすることによって、コントロール文字を気にしないでバイナリのデータを転送することが可能になる。ただし、クオートされる文字は、元のデータ1バイトに対して2バイトに拡張されることになるので、スループットはその分低下する。
表11 クオートの対象となる文字
<ETX>0x03
<ENQ>0x05
<DLE>0x10
<DC1>0x11
<DC3>0x13
<NAK>0x15

BPL.EXEでは、リスト5のように受信文字のクオートの処理を行っている。bp_com_recv()は、通信ポートから1バイトをそのまま受け取る関数である。エラーの時には負の値を戻すので、intの変数にこれを受け取る。もしこの文字がDLE(0x10)であれば、すかさずもう1バイト読む。この文字が0x60より小さいならば、0x00〜0x1fをクオートした結果であると解釈して、0x40を引いた文字が元の文字であると解釈する。0x60以上であれば、0x80〜0x9fをクオートしたと解釈して、0x20を加えた文字が元の文字であると解釈する。

ここの処理では、もう一つ余計なことを検査している。本来クオートされるべき文字が、単独で送られて来た場合には、どう解釈するべきだろうか? 解釈は後回しにしよう。しかし、それがクオートされたものか、直接送られて来たものかを判断できるようにしておくべきである。そこで、この関数では、クオートされるべきコントロール文字を直接受け取った場合には、0x100を加えた値を戻すことにしている。
------------------------------------------------------------------------
リスト5

/*--------------------------------------------------------------------*/
/* DLE を読み取った場合には、quote 解除の処理を行った結果を戻す。
 * これ以外の、quote されるべき文字が単独で現れた場合は、区別のために、
 * 本来のコードに 256 を加えた、256 .. 511 の値を戻す。
 * quote 解除は直接演算する。テーブルを参照していないため、予期しない文字が
 * quote された場合の戻り値は保証しない。
 */
STATIC int mask_recv()
{
    int c;

    c = bp_com_recv();
    if (c >= 0) { /* ok */
        if (c == DLE) { /* quote */
            c = bp_com_recv();
            if (c >= 0) { /* ok */
                c += (c < 0x60) ? -0x40 : 0x20;
            }
        } else if (quotetable[c]) {
            /* quote されるべき文字が単独で送られてきた場合 */
            c |= 0x100;
        }
    }
    return c;
}

------------------------------------------------------------------------

送信時のクオート処理はリスト6の通りである。com_sendは、コミュニケーションポートに1バイトの文字を送信する関数。与えられた文字cがクオート必要かどうかをquotetableという配列で確認する。もしクオートが必要な文字ならば、クオートした結果の値が既に配列に入っているので、DLEを先に送っておき、cにその値を入れ替えている。
------------------------------------------------------------------------
リスト6
STATIC int mask_send(int c)
{
    int q;
    int sts;

    c &= 0xff;
    q = quotetable[c];
    if (q) {
        sts = com_send(DLE);
        if (sts < 0)
            return sts;
        c = q;
    }
    return com_send(c);
}

------------------------------------------------------------------------


3.2 Flying-XMODEM

これはNIFTY SERVEにまだB+がなかった頃の物語である。

既に述べたように、XMODEMとパケット通信の相性はよくない。 NIFTY SERVEは当時は東京直通回線といってFENICSを経由しないアクセスルートがあったのだが、それでも特に、MNPのモデムを使う場合には、モデム間の処理がパケット化されるために、処理は極めて低速になってしまう。 俗にXMODEMとMNPのモデムは相性が悪いと言われている通りである。NIFTY SERVEで実測したデータでは、2400bpsの回線を使った場合のアップロード、ダウンロードの速度が共に100〜120バイト/秒程度だから、半分以下という結果になる。

今はNIFTY SERVEがB+をサポートしているため、XMODEMを使う機会は少ないのだが、B+サポート以前はバイナリのデータを扱うにはXMODEMを選択する他になかったので、速度低下は深刻な問題だった。特にMNPモデムが急速に普及しつつあった頃なので、普段電子会議を読んでいる時に比べてあまりに遅いので、何とかならないかと思案したものである。NIFTY SERVEは当時から一貫して分いくらの時間単位課金だったので、これは切実な問題なのだ。

おおざっぱだが、これを回避するメジャーな方法が、当初2種類あった。一つは、テキストファイルにしたものを登録する方法である。ISH形式にしてテキストで登録すればよい。 データを表示する速度は2400bpsの回線において150〜200バイト/秒程度である。 この速度は、バイナリのデータをテキスト化した時に増加するサイズを考慮しても、MNPでXMODEMを使うよりは十分短時間といえる速度だ。 実際、一部のフォーラムでは、この方法を推奨していたようである。

もう一つの方法は、XMODEMを使う時にはMNPを使わないという、コロンブスの卵のような発想である。確かにMNPを使えばエラーフリーになる。しかしXMODEMを使うのにエラーフリーである必要はないのだ。エラーが発生した時のリトライはXMODEMの処理がやってくれるからである。 MNPを殺しておけば、モデム間のパケット化の処理が省かれるので、レスポンスは改善される。この場合は、150バイト/秒程度の速度が期待できたと思う。

ところで、当時はそれで皆さん満足していたかというと、そうでもない。経験の浅い人なら、発言を表示する時の速度の半分というのを見るとモデムが故障したか設定を間違えた、と思うこともあったようだ。また、CompuServeではQuick BやB+が先にサポートされていたため、NIFTY SERVEでも早くサポートを、という声があった。通常の発想は当然そうなるわけで、XMODEMとMNPは相性が悪いのだから、XMODEM以外のプロトコルを、ということである。

そこに切り込むべく第3の方法として現れ、時代とともに去っていったのが、これから述べるFlying-XMODEMという方法である。

この頃、私は通信ソフトを自作して使っていたので、それにXMODEMのプロトコルを組み込む時に、XMODEMが高速にならないか、と少し考えてみたのである。普通の発想ではこういうことは思い付かないのだが、どこか変だったようである。このアイデアの背景には、普段XMODEMを使っていて、リトライの経験がなかったことがあり、これが重要である。MNPというのはエラーフリーが建前の規格だから、XMODEMが遅くなる反面、リトライは発生しないわけだ。

XMODEM の最も基本的なプロトコルを説明する。まず送信側は受信側が NAK を送ってくるのを待ち、NAK が来たらハンドシェイクが開始する。(図26)

------------------------------------------------------------------------------
図26

    受信側              送信側
            --- NAK -->

            <-- SOH --- (ブロック開始)
            <--  01 --- (ブロック番号)
            <--  FE --- (ブロック番号を反転させたもの)
            <--  .. --- (実際のデータ 128 bytes)
                 ..
            <-- sum --- (checksum .. ここまでで1つのブロックになる)

            --- ACK -->

------------------------------------------------------------------------------

ここで、ACK を受信側が返すのは、ブロックを正常に受け取ることができた場合である。checksum が合わなかったりすると、ACK ではなく NAK を返す。すると送信側は今送ったブロックを再度送信する。これを繰り返して、最後に受信側が ACK を返し、送信側が送るデータがなくなったら図27のように EOT を返す。

------------------------------------------------------------------------------
図27

    受信側              送信側
            --- ACK -->
            <-- EOT ---
            --- ACK -->

------------------------------------------------------------------------------

これで1つのファイルが転送できるという仕組みだ。

Flying-XMODEMの発端は、手抜きの発想だった。 自作の通信ソフトだと、そういうことができるのである。MNP で接続した場合には受信の時の checksum の計算は省略してもいいのじゃないか、と考えたのだ。 MNPだ。どうせエラーになる筈がない、だからchecksumは合っているに決まっている、と信じるのである。だから、計算しないで無条件でACKを送ってしまえばいい。

もっとも、当時でも既にパソコンのパワーは十二分だったので、チェックサムの計算程度の省略では速度は上がらない。プログラムを書く手間が少なくなるだけである。といっても、チェックサムのプログラムなんて、ほいさっさ、で簡単すぎる程なのだが。CRCを計算する処理だと、若干ややこしくなるかな、という程度かもしれないが、CRCの場合は既知の関数がごろごろしているので、PDSのソースを参考にすれば手間はかからないのである。 しかも、こんな計算を省いた所で、MNP と XMODEM の相性の問題は全く解決していないので、実際は全く速度が変わらないと思ってよかろう。

次がひらめきである。「どうせ ACK を送るんだから、ブロックを全部受け取る前に見込みで送ってしまえばいいじゃないか」と考えたのだ。 普通の XMODEM は図28のようにハンドシェイクする。

------------------------------------------------------------------------------
図28

    受信側              送信側
        ---   NAK    -->
        <-- ブロック ---
        ---   ACK    -->
        <-- ブロック ---
        ---   ACK    -->

            ...

        <-- ブロック ---
        ---   ACK    -->
        <--   EOT    ---
        ---   ACK    -->

------------------------------------------------------------------------------

MNP と XMODEM の相性の悪さは、モデム間の通信でタイムアウトが発生するまで半端なデータが送られないことにある。例えば、ACK を 1 文字送信する時には、しばらく待たないとそれが送信されないのである。 そこで、パケットのタイムアウトに0.1秒かかるのなら、0.1秒先にACKを送っておけば、見掛け上はタイムアウトなしにACKを送ることができるのではないか、と考えてみたのだ。 とりあえず、無謀にも図29のようなタイミングでACKを送ってみた。

------------------------------------------------------------------------------
図29

    受信側                          送信側
        <-- ブロック送り始め     ---
        ---         ACK          -->
        <-- ブロックを送っている ---

------------------------------------------------------------------------------

この処理はどう見てもフライングなのでFlying-XMODEM と名付けたのである。 最初のバージョンでは、28バイトデータを受け取った所でACKを送るようにした。これにより、120バイト/秒から160バイト/秒程度に速度が向上した。ところが、回線によっては、まだこれでも100バイト/秒程度にしかならなかった。そこで、途中ではなく、いきなりACKを先に送ってみた。

------------------------------------------------------------------------------
図30

    受信側              送信側
        ---   NAK    -->
        ---   ACK    -->
        <-- ブロック ---
        ---   ACK    -->
        <-- ブロック ---

            ...

        ---   ACK    -->
        <-- ブロック ---
        <--   EOT    ---
        ---   ACK    -->
------------------------------------------------------------------------------

ブロックを受け取る前に ACK を返しているのだが、こういうのもハンドシェィクと言うのだろうか…。 ここまでやるとさらに悪ノリして、最後にEOTの後で送るACKも受信開始の時に送ってしまうことにした。数が合えばいいという考えなのである。

------------------------------------------------------------------------------
図31

    受信側              送信側
        ---   NAK    -->
        ---   ACK    -->
        ---   ACK    -->
        <-- ブロック ---
        ---   ACK    -->
        <-- ブロック ---

            ...

        ---   ACK    -->
        <-- ブロック ---
        <--   EOT    ---

------------------------------------------------------------------------------

恐ろしいことに、これがちゃんと動作して、速度が210バイト/秒程度になったので、テキストを表示する時の速度とほぼ同じである。なお、これは現在NIFTY SERVEでB+を使う速度と殆ど同じである。とにかく、当時は速度が倍=電話代+課金が半額、ということになるから、一部のユーザーにはバカウケだったのである。

送信時にはどう処理するか。まず、XMODEMのハンドシェイクは図32の通りである。

------------------------------------------------------------------------------
図32

    受信側              送信側
        ---   NAK    -->
        <-- ブロック ---
        ---   ACK    -->
        <-- ブロック ---
        ---   ACK    -->

            ...

        <-- ブロック ---
        ---   ACK    -->
        <--   EOT    ---
        ---   ACK    -->

------------------------------------------------------------------------------

MNPを使うことにより、ACKが必ず戻ってくると思えば、ACKを待たずに次のブロックを送ればよい。

------------------------------------------------------------------------------
図33

    受信側              送信側
        ---   NAK    -->
        <-- ブロック ---
        <-- ブロック ---
        ---   ACK    -->
            ...

        <-- ブロック ---
        ---   ACK    -->
        <--   EOT    ---
        ---   ACK    -->
        ---   ACK    -->

------------------------------------------------------------------------------

ここで問題は、あまり先にデータを送りすぎると、途中どこかでバッファがオーバフローするのではないかということだ。Flying-XMODEMは、エラーフリーを大前提としているために、途中失敗すると通常のXMODEMのようにリトライして続行という訳にいかないのである。実際はACKの数に対して2つ先送りした状態になったら待ちに入るようになっている。 今から考えると、エラーフリーという前提があればデータをたれ流し状態にする、というのは当然の発想で、もしYMODEM-gを知っていたら、わざわざ頭を使わなくてもこの程度のことはやったに違いないと思う。ただ、この当時はまだ私もヒョッ子で、単にsend/receiveできるだけの通信ソフトが動かないと騒いでいたような感じだった。実際、その頃作ろうとしていた通信ソフトは、一応は動いたのだが、仕上げすることなく挫折している。ついに断念を決意して、最初からやり直そうという試みが、既にプログラマーズフォーラムでは始まっているが、今度はできるだけ他の人にコードを書いてもらおうという他力本願になってしまっている。

とにかく、当時はYMODEM-gもスライディングウィンドウも知らなかったのだし、パケット回線だと待ち時間が…という理屈もあまり理解していなかったのだ。ただ、MNPのモデムを使っていると、何か入力した時に、ほんの一瞬応答が遅れるような気が実感としてあったので、じゃあ一瞬早く送ってみようかな、程度の考えだったのである。


(フィンローダ NIFTY SERVE FPROG SYSOP)

(C) 1992,1998 Phinloda, All rights reserved
無断でこのページへのリンクを貼ることを承諾します。問い合わせは不要です。
内容は予告なく変更することがあります。

[Home]