TeraTermソースコード解説
本文書では、TeraTermのソースコードについて解説をします。解説対象とするソースコードはバージョン"4.58"(2008年2月現在)のものをベースとしています。
TeraTermのパッケージに含まれるほとんどのプログラムは、C言語で記述されています。一部のコードはC++言語で、MFC(Microsoft Foundation Class)が利用されています。Windows特有の処理を行うために、Win32 APIが多用されているため、APIの知識が必要となってきます。
ソースコードをビルドするためには、Microsoft Visual Studio 2005 Standard Edition以上が必要です。Express EditionではMFCが利用できないため、ビルドができません。また、C++BuilderやTurbo C++ Explorer、gccなどのコンパイラにおいても、ビルドすることはできません。
Windowsプログラミングに関する情報の源は、Microsoftが提供する「MSDNライブラリ」にあります。開発を行う際は、MSDNライブラリを頻繁に参照することになります。
ただし、CygTermのみはCygwinのgccでコンパイルをします。ゆえに、CygTermはgccの機能を使った実装になっています。言語はC++です。
TeraTermのメインエンジンはC++で実装されていますが、C言語的なコーディングがなされているため、TeraTermのソースコードを読み解くには、C言語に関する基礎知識があれば問題ないと言えます。ただし、Microsoft Visual C++(VC++)はANSI C準拠(C89)とはいえ、C99には未対応であるために、本来のC99相当の機能が独自に拡張されている部分もあります。そうした独自拡張された関数には、頭文字にアンダースコア(_)が付いているために、区別が付けやすくなっています。たとえば、VC++の_snprintf()は、ANSI C(C99)のsnprintf()とは似て非なるものです。
TeraTermパッケージに含まれる実行モジュール(.exeと.dll)の関連図を以下に示します。実行ファイルの拡張子は".exe"になっており、必要に応じてDLLが動的リンクされます。いずれも32ビットプログラム(x86)であるために、x86-64やIA-64といった64ビット環境ではそのまま動作するかどうかは評価されていません。
通常、ユーザがデスクトップやスタートメニューからアプリケーションを起動するときに、呼び出される実行ファイルは"ttermpro.exe"になります。実行ファイルはさらに5つのDLLとダイナミックリンクしています。静的リンクを行い、単一のEXEファイルにしていないのは、1つのプロセスのメモリ占有率を抑えるためです。TeraTermでは多数の起動が行われることが想定されるため、初期設計段階からDLLに分割されています。一度読み込まれたDLLは、複数のプロセス間で共有することができます。
マクロスクリプトを実行する際は、"ttpmacro.exe"というまったく別のプロセスが呼び出されます。"ttermpro.exe"とプロセス単位で分けられているのは、マクロを単体で実行できるようにするためです。両プロセス間で、データのやりとりを行うためには、プロセス間通信が必要です。TeraTermでは、DDE(Dynamic Data Exchange)と呼ばれる現在ではレガシーとなってしまったしくみが採用されています。将来のWindowsではDDEがサポートされなくなる可能性があり、その場合TeraTerm上でマクロを実行することは一切できなくなります。
TTSSHやTTProxy、TTXKanjiMenuといったプラグイン形式のDLLは、TeraTermの起動時に明示的に LoadLibrary() を使ってダイナミックロードされます。ロード対象となるDLLのファイル名は、TTXInit()#ttplug.c において、"TTX*.DLL"というパターンにマッチしたものとなります。
"keycode.exe"と"ttpmenu.exe"、"LogMeTT.exe"は単体アプリケーションです。
Cygwin接続のしくみについては、別の節で説明します。
高度な機能を実現するために、フルスクラッチで実装することは効率がいいとは言えません。TeraTermでは開発効率化を図るために、オープンソースのライブラリを積極的に利用しています。ただし、オープンソース製品のライセンスによる競合には注意を払う必要があります(特にGPL)。
下図に、オープンソースのライブラリをリンクしているモジュールと、そのリンク状況を示します。TeraTermマクロプログラムにおいて、"waitregex"や"sprintf"コマンドにおいて正規表現を利用するために、Onigurumaと呼ばれる正規表現ライブラリをリンクしています。TeraTerm本体では、バージョンダイアログにOnigurumaのバージョンを表示するためだけにリンクをしています。
SSHモジュールである"TTSSH"は、暗号処理を行うためにOpenSSLを利用しています。"OpenSSL"というネーミングからWebアクセスに使われるSSL(Secure Socket Layer)プロトコル専用のライブラリかと思われがちですが、基本的な暗号アルゴリズムをサポートしていることから、TTSSHではOpenSSLに含まれる低レイヤのルーチンを利用するだけに留まっています。このことは、すなわちOpenSSLライブラリにセキュリティホールが発見されたとしても、TTSSHへの影響は極めて低いということです。
zlibライブラリは、SSHパケットの圧縮を行うために利用しています。ただし、ダイヤルアップ回線などの低速度なネットワークにおいては、パケット圧縮は有効ですが、昨今の高速回線ではむしろ速度低下を招く足かせとなります。ゆえに、デフォルトではパケット圧縮機能は無効化されています。
PuTTYは世界標準であるフリーのターミナルエミュレータです。PuTTYに含まれるPageantと呼ばれるSSH認証エージェントがあるのですが、TTSSHでPageantによる公開鍵認証をサポートするために、PuTTYのソースコードを利用しています。
なお、いずれのライブラリも静的リンク(static link)としています。ライブラリのコンパイルオプションには"/MT"を付加しています。動的リンク(dynamic link)を行うと、一部のユーザ環境でTeraTermが起動できないという現象が発生したために、現在では動的リンクは行っていません。
Windowsではアプリケーションのデータ保存のために、レジストリが伝統的に利用されていますが、TeraTermではその誕生がWindows 3.1までに遡るために、.iniファイルによるローカルディレクトリへの保存方法が標準となっています。
パッケージに同梱されるCollectorやLogMeTT、CygTermに関してもローカルディレクトリへデータが保存されます。
例外として、TeraTerm Menuはデフォルトでレジストリへ保存をします。カレントディレクトリに"ttpmenu.ini"(0バイトで可)を設置することで、レジストリの代わりに.iniファイルを使うようにすることもできます。
teraterm.iniファイルにエントリを追加した場合は、ReadIniFile()#ttset.cに設定を読み込みするようにします。
ts->ConfirmChangePaste =
GetOnOff(Section, "ConfirmChangePaste", FName, TRUE);
WriteIniFile()#ttset.c に設定を書き込みするようにします。
WriteOnOff(Section, "ConfirmChangePaste", FName,
ts->ConfirmChangePaste);
エントリに文字列を設定する場合は、Win32APIのGetPrivateProfileString()とWritePrivateProfileString()を使います。数値を扱いたい場合は、GetPrivateProfileInt()とWriteInt()を使います。
WindowsのデフォルトアカウントはAdministrator権限を保持するために(ただし、Windows Vistaには当てはまらない)、アプリケーションにバッファオーバーフローの不具合があると、管理者権限を第三者に奪取されてしまう危険性があります。
従来、C言語の文字列処理は開発者のミスにより、バッファオーバーフローが発生しやすいという状況にありました。そこで、MicrosoftはVisual Studio 2005から文字列処理関数のセキュリティ強化バージョンを提供するようになりました。
TeraTermではセキュリティ強化を図るため、文字列操作のほとんどをセキュリティ強化バージョンに置き換えています。以下に代替関数を示します。
旧 |
新 |
sprintf(), _snprintf() |
_snprintf_s() |
strcat(), strncat() |
strncat_s() |
strcpy(), strncpy() |
strncpy_s() |
デフォルトのロケールが適用されると、期待する動作とならないケースにおいては、_snprintf_s_l()を使用しています。
いずれの関数においても、_s("secure")という接尾辞が付くため、見た目に区別が付きやすくなっています。当然のことながら、これらの関数はANSI C非互換です。
なお、これらの関数を利用する際、Count引数(格納する最大文字数)には"_TRUNCATE"マクロを指定しており、バッファオーバーフローが発生する場合は、強制的にバッファの切り詰めを行っています。
Windowsアプリケーションでは printf() が使えません。標準出力がどこにも割り当てられていないからです。AllocConsole()とfreopen()を使えば、Windowsアプリケーションにおいても printf() を利用することができます。
OutputDebugString()というAPIがあります。これは Visual Studio のデバッグコンソールにメッセージ出力することができる関数です。当該APIは、"Debug build"および"Release build"に関係なく、デバッガが存在すれば、メッセージを送信します。ゆえに、 Visual Studioがなくとも、DBConのようなツールを使えば、アプリケーションの単体起動においても、OutputDebugString()によるメッセージを拾うことができます。
TeraTermでは、可変長引数を扱えるようにラッパー関数を用意しています。
void OutputDebugPrintf(char *fmt, ...) {
char tmp[1024];
va_list arg;
va_start(arg, fmt);
_vsnprintf(tmp, sizeof(tmp), fmt, arg);
OutputDebugString(tmp);
}
Windowsのアプリケーションはマルチスレッドで設計されることがほとんどですが、Windows 3.1から95の時代ではあまり一般的ではありませんでした。そのため、元々TeraTermはマルチスレッド化されていません。ソースコードを見ると分かるように、グローバル変数が多用されているため、ほとんどの処理がスレッドセーフではありません。
ただし、一部の処理においては _beginthreadex() API を使ってスレッドが生成されています。以下にスレッド生成箇所を示します。
TeraTerm
生成箇所 |
ソースファイル |
シリアル接続 |
CommStart()#commlib.c |
TELNETキープアライブ |
TelStartKeepAliveThread()#telnet.c |
IPv4/v6ソケットの生成 |
WSAAsyncGetAddrInfo()#WSAAsyncGetAddrInfo.c |
TTSSH
生成箇所 |
ソースファイル |
SSHキープアライブ |
start_ssh_heartbeat_thread()#ssh.c |
SCP送信処理 |
SSH2_scp_tolocal()#ssh.c |
SCP受信処理 |
SSH2_scp_fromremote()#ssh.c |
すでに説明したとおり、TeraTerm(TTSSH含む)の内部処理はスレッドセーフではないため、シンプルにスレッドを生成し、スレッド内から送受信処理等を行おうとすると、不具合が発生してしまいます。
TELNETやSSHのキープアライブ(ハートビート)処理を実現するためには、定期的にパケットの送信処理を行う必要があります。また、SCPによるファイル送受信を行う際においても、ファイルの送信処理中に、ユーザの端末操作のレスポンスを落とさないために、スレッドの使用が不可欠です。
そこで、マルチスレッドを使う場合は、モードレスダイアログを非表示で作成したあとに、_beginthreadex() APIでスレッドを生成し、実際の処理はモードレスダイアログに行わせるという手段を使用しています。このしくみにより、マルチスレッドを使いながら、スレッドセーフを保つことができます。以下に、コード例を示します。
#define WM_SEND_HEARTBEAT (WM_USER + 1)
static LRESULT CALLBACK telnet_heartbeat_dlg_proc(HWND hWnd, UINT msg, WPARAM wp, LPARAM lp)
{
switch (msg) {
case WM_INITDIALOG:
return FALSE;
case WM_SEND_HEARTBEAT:
TelSendNOP();
return TRUE;
break;
case WM_COMMAND:
break;
case WM_CLOSE:
return TRUE;
case WM_DESTROY:
return TRUE;
default:
return FALSE;
}
return TRUE;
}
static unsigned _stdcall TelKeepAliveThread(void *dummy) {
static int instance = 0;
if (instance > 0)
return 0;
instance++;
while (cv.Open && nop_interval > 0) {
if (time(NULL) >= cv.LastSendTime + nop_interval) {
SendMessage(keepalive_dialog, WM_SEND_HEARTBEAT, 0, 0);
}
Sleep(100);
}
instance--;
return 0;
}
void TelStartKeepAliveThread() {
unsigned tid;
if (ts.TelKeepAliveInterval > 0) {
nop_interval = ts.TelKeepAliveInterval;
keepalive_dialog = CreateDialog(hInst, MAKEINTRESOURCE(IDD_BROADCAST_DIALOG),
HVTWin, (DLGPROC)telnet_heartbeat_dlg_proc);
keepalive_thread = (HANDLE)_beginthreadex(NULL, 0, TelKeepAliveThread, NULL, 0, &tid);
if (keepalive_thread == (HANDLE)-1) {
nop_interval = 0;
}
}
}
概要
DDE(Dynamic Data Exchange)の誕生は、1987年のWindows 2.0までに遡ります。DDEはプロセス間通信を行うためのしくみですが、現在ではレガシーな方式であり、ほとんどのアプリケーションでは利用されていません。Windowsにおけるプロセス間通信といえば、メールスロットや名前付きパイプ、OLEなどが定番です。
かつては、DDEによるプロセス間の通信データをキャプチャすることができる「DDEスパイ」(DDESPY.EXE)というツールがVisual Studioに付属していましたが、現在のVisual Studioにはもはや含まれていません。
DDEに関するリファレンスはMSDNライブラリから参照することができます。
DDEは、TCPによるネットワーク通信と似ており、サーバとクライアント間を一対一で接続し、通信を行います。アプリケーションがDDEによる通信を行うために、DDEML(Dynamic Data Exchange Management Library)と呼ばれるライブラリをWin32 APIとして提供されています。
DDE通信を行うために、一方がサーバとなり、他方がクライアントになる必要があります。また、通信のセッションをシステム全体でユニークとするために、識別情報が必要です。TCP通信ではIPアドレスとポート番号が使われますが、DDE通信では「サービス名」と「トピック名」の組み合わせが使われます。TeraTermではサービス名は"TERATERM"という文字列が使われ、トピック名はTeraTerm本体のウィンドウハンドル(HVTWin)の16進数値を文字列化したものが使われています。
このようなしくみになっているために、マクロスクリプトからまったく別のTeraTermへコマンドを送ることはできません。
上図に示すように、TeraTerm本体("ttermpro.exe")がDDEサーバとなり、マクロプログラム("ttpmacro.exe")がDDEクライアントとなります。DDEでは、やりとりするデータの塊のことを「トランザクション」と呼びます。トランザクションには以下に示すような何種類かがあります。タイプは"ddeml.h"でマクロ定義されています。
タイプ |
意味 |
XTYP_ADVREQ |
DDEサーバがクライアントへデータを送るために、DDEサーバが自分自身に送るメッセージ。 |
XTYP_POKE |
DDEクライアントからサーバへデータを送る。 |
XTYP_ADVSTART |
DDEサーバに対してアドバイズループの開始を指示する。 |
XTYP_ADVDATA |
DDEクライアントにデータを定期的に送る。 |
XTYP_EXECUTE |
DDEサーバに文字列を送り、何らかの処理をサーバに指示する。 |
DDE通信の特徴として、アドバイズループ(advise loop)という概念があります。DDEサーバがアドバイズループに入ると、クライアントはサーバから定期的にデータを受け取り続けることができます。TeraTermでは、リモートホストからの受信データを、マクロプログラムへ渡すために、アドバイズループが使われています。
ライブラリ
TeraTermで使われているDDEMLについて、以下に示します。
関数名 |
機能 |
DdeInitialize |
DDEを初期化し、コールバック関数を登録する。初期化できるとインスタンスを返す。 |
DdeCreateStringHandle |
文字列リテラルからハンドルを作成する。ハンドルはサーバとクライアントの通信用に使われる。 |
DdeNameService |
インスタンスとサービス名("TERATERM")をサーバに登録する。登録後、XTYP_REGISTERトランザクションがクライアントへ送られる。登録解除する際にも使われる。 |
DdeCmpStringHandles |
2つの文字列ハンドルを比較する。 |
DdeClientTransaction |
クライアントからサーバへトランザクションを送ることができる。トランザクションタイプとして、XTYP_REQUEST・XTYP_EXECUTE・XTYP_ADVSTART・XTYP_POKEなどが指定できる。サーバからのACKを待つまでのタイムアウト時間を指定することができ、TeraTermではほとんど"1000ミリ秒(1秒)"が指定されている。ただし、ACKを確認するケースにおいては"5000ミリ秒(5秒)"が指定されている。 |
DdeAccessData |
DDEハンドルから実際のデータへのポインタを取得する。データの取り出しが終わったら、DdeUnaccessData()を呼び出すこと。 |
DdeCreateDataHandle |
DDEオブジェクトを作成し、ハンドルを返す。DDEサーバのアドバイズループや、XTYP_REQUESTトランザクション受信時に、DDEクライアントへデータを送るために使われている。 |
DdeGetData |
DDEオブジェクトからバッファへコピーする。 |
DdeDisconnect |
DDE通信を終了する |
DdePostAdvise |
DDEサーバ側で使われる関数で、自分自身に XTYP_ADVREQ トランザクションを送る。 |
実装
DDEサーバ側の実装について見ていきます。TeraTerm本体("ttermpro.exe")がDDEサーバとなり、かならずDDEサーバから起動されます。マクロプログラム("ttpmacro.exe")から直接マクロスクリプトが実行されるケースにおいても、"connect"マクロによりDDE接続をしないと、通信が開始できません。
TeraTermのControlメニューからMacroを呼び出した場合、RunMacro()#ttdde.c がコールされます。
HVTWinウィンドウハンドルからトピック名(8バイト)を作成し、DDEの初期化とサーバの登録を行います。また、このタイミングでDDEバッファ(1KB)を作成しています。その後、"ttpmacro.exe"を /D= オプションでトピック名を渡しつつ、起動をします。
SetTopic();
if (! InitDDE()) return;
strncpy_s(Cmnd, sizeof(Cmnd),"TTPMACRO /D=", _TRUNCATE);
strncat_s(Cmnd,sizeof(Cmnd),TopicName,_TRUNCATE);
DDEサーバに、DDEクライアントからトランザクションが送られてきたときは、DdeCallbackProcコールバック関数が呼び出されます。コールバック関数は、DdeInitialize()でDDEの初期化を行うときに登録されます。
次に、DDEクライアントについて見てみましょう。マクロプログラムの起動時、InitDDE()#ttmdde.c が呼び出され、DDEクライアントとして初期化が行われます。DDEの初期化は、DdeInitialize()で行われ、同時にDdeCallbackProcコールバック関数が登録されます。DDEサーバから届いたトランザクションは、コールバック関数で処理されます。
DDE通信を始めるためには、DdeConnect()を呼び出し、サーバと接続する必要があります。次に、"ttpmacro.exe"のウィンドウハンドル(HWin)をサーバへ通知するために、XTYP_EXECUTEトランザクションで送ります。最後に、XTYP_ADVSTARTトランザクションをサーバへ送り、アドバイズループを開始します。
ConvH = DdeConnect(Inst, Service, Topic, NULL);
if (ConvH == 0) return FALSE;
Linked = TRUE;
Cmd[0] = CmdSetHWnd;
w = HIWORD(HWin);
Word2HexStr(w,&(Cmd[1]));
w = LOWORD(HWin);
Word2HexStr(w,&(Cmd[5]));
DdeClientTransaction(Cmd,strlen(Cmd)+1,ConvH,0,
CF_OEMTEXT,XTYP_EXECUTE,1000,NULL);
DdeClientTransaction(NULL,0,ConvH,Item,
CF_OEMTEXT,XTYP_ADVSTART,1000,NULL);
バッファの管理
マクロプログラムでは"wait"コマンド等で、リモートホストから送られてきたデータを監視するための機能が用意されています。この機能を実現するためには、TeraTerm本体とマクロプログラムのそれぞれにおいて、バッファを用意する必要があり、プロセス間通信(DDEトランザクション)により、TeraTerm本体からマクロプログラムへリモートホストからの受信データを送らなければなりません。
まず、TeraTerm本体におけるリモートホストからのTCPパケット受信は、アイドルループ OnIdle()#teraterm.cpp にて行われます。OnIdle()から呼び出される CommReceive()#commlib.c において、TCPパケットデータをバッファ(cv->InBuff[])に格納します。このバッファは 1KB の大きさを持ちます。また、リングバッファではないため、バッファフルになった場合は、TCPパケットの受信をしません。ただし、バッファフル状態が長く続くと、Windowsカーネル内にTCPパケットが溜まっていき、いずれはリモートホストからのパケットを受信できなくなる可能性があります。
エスケープシーケンスの解析処理を行う過程で、「ログ採取」か「マクロ実行」を行っている場合は、LogPut1()が呼び出され、DDEバッファ(cv.LogBuf[])へ受信データが格納されます。このバッファは1KBの大きさを持つリングバッファであり、バッファフルになった場合は、最古のデータから上書きされてゆきます。
TeraTerm本体のDDEバッファのデータは、エスケープシーケンスの解析処理が完了後、DDEAdv()#ttdde.c がすぐに呼び出され、自分自身(DDEサーバ)へ XTYP_ADVREQ トランザクションを送ります。XTYP_ADVREQを受け取ったら、DDEコールバック関数 DdeCallbackProc() が呼び出され、マクロプログラムへのデータ送信を行います。ここでアドバイズループが使われています。
アドバイズループによりDDEサーバよりデータが送られてくると、DDEクライアントであるマクロプログラムにおいては、XTYP_ADVDATAトランザクションがDDEコールバック関数 DdeCallbackProc()#ttmdde.c により処理されます。
なお、TeraTerm本体において、DDE通信用のバッファと、ログ採取用のバッファは cv.LogBuf[] で共有されています。バッファの先頭とデータサイズを表すインデックスは、DDE通信の場合は"DStart"と"Dcount"、ログ採取の場合は"LStart"と"Lcount"と区別されています。実際には、1つのバッファを共有しているわけなので、それぞれのインデックスが食い違うと、誤動作する原因となるため、常に同期を取っておくことになります。