Visual Studio CodeのC/C++拡張とgdbでのデバッグで、変数の中のUTF-8の文字列を文字化けさせずにうまく表示させる

WindowsのVisual Studio Codeにて、C/C++拡張とgdbでデバッグをしているときに、変数の中身のUTF-8の文字列が文字化け(数字の羅列)になって見られない、という問題がありました。それを解消するパッチ(スクリプト)を作りました。

その紹介、問題の検討過程、及び作業で学んだPythonの教訓について記します。

はじめに

ふた月ほどJUMAN、JUMAN++をいじって来ました。(コードを。機能は全然いじれていません。)

Visual Studio Code(以下vscode)のC/C++拡張とgdbでデバッグしていたのですが、変数の中身のUTF-8の文字列が数字の羅列で見られないことが気になっていました。

これについて検討してみました。

結果

問題は(たぶん)解消できました。

keyという変数にカーソルを当てたところです。ポップアップに「外国人参政権」と表示されています。

「外」の字はUTF-8で「E5 A4 96」(16進)です。*keyの値が8進で345のことから、UTF-8だと分かります。

ちなみに文字化けの状態は以下です。同じ場所、同じタイミングの同じ変数です。

あと「char * hoge」の文字列は見えても、「char hoge[12]」のような配列の文字列は見えない謎仕様なので、それも表示されるようにしました。

必要な方は、こちらの右上の「Clone or download」ボタンからダウンロードしてください。日本語の説明も書きました。

今日にも行われるかもしれない、次のアップデートで解決して必要なくなるかもしれませんけどね。(謎仕様は治らなそうな…。)

Pythonのインストールについては「WindowsでTensorFlowの環境作成」に書きました「4.1. Minicondaのインストール」をご参照下さい。

問題の検討

需要があるかはわかりませんが、この問題に対しての検討とパッチの作成過程について書こうと思います。

(パッチと表現していますが、コードのdiffのことではありません。アプリに継ぎを当てたことには変わりあるまい、とパッチと表現しています。)

Pythonの教訓は次章に記しますので、そちらに興味のある方はスキップをお願いいたします。

正当にlaunch.jsonの設定する

最初に行ったことは、launch.jsonのデバッガーのconfigurationに、表示がうまく行われるようgdbに対する設定を追加することです。

抜き出すと、以下のような感じです。

"MIMode": "gdb",
"miDebuggerPath": "B:/msys64/mingw64/bin/gdb.exe",
"setupCommands": [
	{
		"description": "UTF-8",
		"text": "set target-charset UTF-8",
		"ignoreFailures": false
	},
	{
		"description": "CP932",
		"text": "set host-charset CP932",
		"ignoreFailures": false
	},
	{
		"description": "8bit",
		"text": "set print sevenbit-strings off",
		"ignoreFailures": false
	},
	{
		"description": "Enable pretty-printing for gdb",
		"text": "-enable-pretty-printing",
		"ignoreFailures": false
	}
]

setupCommandsの各要素は”text”がコマンドですので、そこだけ抜き出すと以下になります。

set target-charset UTF-8
set host-charset CP932
set print sevenbit-strings off

参考:Hard Disk Dive (和訳文書とソフトウェア):Debugging with GDB:「10.19 文字セット

これだけ設定すれば良さそうですが、この結果が冒頭の文字化けの方の画像です。

「host-charsetもUTF-8なのでは?」とも思いましたが、そうしても文字化けです。

手で叩いてみる

次に行ったことは、コマンドプロンプトからgdbを起動し、先のコマンドを叩いてみることです。

その結果は、変数内のUTF-8文字列は正しく表示されました。

とすると、C/C++拡張の側の問題か?

作戦を考える

すぐにもアップデートされて解決される可能性がありますので、あまり手はかけたくありません。

考えたアイデアは以下。

  • 数字をコピペすれば文字列に変換する別アプリ。
  • vscodeの表面のHTMLの一部分(例えばtitle属性のみ)を書き換える拡張機能。
  • vscodeとgdbの間に入って、通信を盗聴して別口に表示するラッパー。

1番目はコピペが面倒なので却下。

2番目は拡張機能の作成に関するページを見ても作れそうにない。

ということで3番目が候補になりました。

gdbとvscodeの間の通信が標準入出力であれば、割って入れそうです。セキュリティ用語でいう「中間者攻撃」ですね。(もし別に通信を開いていたならあきらめよう。)

Pythonにしたのは、ラッパーとは別の、送られてきた文字列(盗聴内容)を表示するだけのサーバーが作りやすそうだから。

スクリプトを作成しながらの検討

盗聴者スクリプトを作成しながら気がついたことは以下です。

  • gdb起動時は「--interpreter mi」を指定してMIモードで起動している。(launch.jsonにもMIって書いてあるね。)つまり手で叩くのとは別のコマンドと戻り。
  • set print host-charset UTF-8」と設定して、gdbからPythonに上げられる文字列はUTF-8だと期待しても、すでに一部が数字に変換されて化けている。

なので、MIモードのときのgdbが正しく文字列を返していない可能性も出てきました。(Pythonの入り口で化けている可能性も考えられなくもありませんが。)

スクリプト内で文字列を組み立てる

次なる作戦は、もうスクリプト内で文字列を組み立ててあげることです。

中途半端に数字に化けた文字をもらっても組み立てにくいので、gdbに対して「set print sevenbit-strings on」と設定して、7bitのASCII以外の文字は全て8進数の「\ddd」にエスケープされた形で上がって来るようにします。

スクリプト内では、文字列と思われる範囲を正規表現でひっかけて、エスケープからバイト列へ、バイト列から文字列へデコードすればOK。

CP932でvscodeに上げる

ここまでで、サーバースクリプトのウィンドウに、変数内の日本語文字列が正しく表示されるようになりました。

しかしまだvscodeのポップアップやwatchの文字列は化けています。

その化けた文字をコピーしテキストエディタでいじってみると、UTF-8であげた文字をShift_JISとして受け取ったときの化け方だとわかります。

それまでは盗聴スクリプトからvscodeへの出力(標準出力)は意識していませんでしたが、これを明示的にCP932(Shift_JISとほとんど共通のコードセット)にしてあげれば解決です。

以上で目的は達成しました。

本来の原因と対策

以上の検討から、原因はgdbがMIモードときに変数内の文字列を適切にhost-charsetで出力できていないから、と推測しています。

前節のように、Pythonスクリプト内で組み立てCP932に変換した文字列は、vscodeで正しく表示されています。

ということで、C/C++拡張のバグではないといえそうです。(gdbとの間でMIの通信仕様の齟齬はあるかもしれません。これを調べようとはもう思いませんが。)

しかしこの盗聴スクリプトの処理をC/C++拡張に入れることは難しくないですから、将来C/C++拡張のアップデートで解決される可能性も考えられます。

ただC/C++拡張もgdbも開発者が英語圏の人だったらあまり逼迫しないかも。

 

C/C++拡張とgdbでUTF-8文字列が正しく表示されない問題については以上です。

このパッチもまだバグがあるでしょうから、もし見つけたら解決して教えて下さい。(たぶん正規表現か、関数oct_decodeか、或いはどこかの例外発生だと思います。)

Pythonの教訓

ここからはPythonに関する教訓(Lessons Learned)を記します。

全てPython3についての記述です。

以降のサンプルのコードは要素の書き下しですので、このままでは動かないかもしれません。実際の実装はwrapper.pypserver.pyをご参照ください。

WindowsAPIのモジュール

盗聴スクリプトと表示サーバーの間の通信は名前付きパイプで行っています。

どうやらWindows上のPythonでは、os.pipeもos.forkも動かないようです。selectもソケット以外では無理そう。(Cygwin上のPythonはわかりませんが。)

最近MinGWを弄っているので、納得できる仕様ではあります。

パイプはos.pipeでは作れませんが、win32pipe、win32fileというモジュールを使えばこのように名前付きパイプが作れます。

文字列を受け取って表示する側:

import win32pipe, win32file
H_PIPE = win32pipe.CreateNamedPipe(r'\\.\pipe\wrapper_pipe',
    win32pipe.PIPE_ACCESS_INBOUND,
    win32pipe.PIPE_TYPE_BYTE | win32pipe.PIPE_WAIT,
    1, 4096, 4096, 500, None)
win32pipe.ConnectNamedPipe(H_PIPE, None)
(rc, buff) = win32file.ReadFile(H_PIPE, 4096)
string = buff.decode("UTF-8", "ignore")
sys.stdout.write(string)
H_PIPE.Close()

文字列を送る側:

import win32file
H_PIPE = win32file.CreateFile(r'\\.\pipe\wrapper_pipe',
    win32file.GENERIC_WRITE, 0, None,
    win32file.OPEN_EXISTING, 0, None)
win32file.WriteFile(H_PIPE, "Hello!\n".encode(“UTF-8”))
H_PIPE.Close()

バイナリでやり取りしていますが、最初にパイプを開くときに、win32pipe.PIPE_TYPE_BYTEの代わりにwin32pipe.PIPE_TYPE_MESSAGEを指定すればstr型をやり取りできるかも。(試していません。)

 

さらにはwin32apiとかmsvcrtとかいうモジュールもあって、PythonでWindowsプログラミングができそうな勢い。(しないけど)

Minicondaをインストールしている方は、「Miniconda3\Lib\site-packages\win32\Demos」あたりをご覧ください。

subprocessモジュール

盗聴スクリプトからgdbを起動するのにsubprocessモジュールを使っています。

参考:Python3.6.2ドキュメント:「17.5. subprocess

subprocessには幾つかのインタフェイスが実装されていますが、基本的な思想は「行って来い。」起動→相手の標準入力に書き込み→相手の標準出力から読み込み→終了、が一連の動作です。

その中でもプリミティブなインタフェイスであるPopenを使います。

ドキュメントには「stdin、stdoutで間違えるとデッドロックするよ。communicate()を使うといいよ」とありますが、communicateも一回の「行って来い」を想定しているようです。

なので、このパッチのようにサブプロセスが長い時間生きていて、標準入出力で通信するならstdinとstdoutメンバーを使いましょう。

具体的には以下のような感じです。

import sys, subprocess
target_proc = subprocess.Popen(
    "target.exe", stdin=subprocess.PIPE, stdout=subprocess.PIPE, universal_newlines=True)
while 1:
    cmd = sys.stdin.readline() 
    target_proc.stdin.write(cmd)
    target_proc.stdin.flush ()
    line = target_proc.stdout.readline()
    sys.stdout.write(line)

2行でtarget.exeをサブプロセスで起動し、オブジェクトをtarget_procに設定しています。

7行のflush()がないと、6行で書いたコマンドがバッファに溜まるだけでサブプロセスに送られず、ここでロックします。

サブプロセスが複数行を返す、あるいはプロンプトで入力待ちをするプログラムであれば、8行のような読み込みではうまくいきません。もう少し複雑になります。

wapper.pyでは、Popenの生成に「universal_newlines=True」は指定せず、stdin, stdoutをバイナリにしています。

threadingモジュール

wapper.pyでは、サブプロセスであるgdbに対して、前節の例のようなピンポンゲーム、書き込みと読み込みの切り替えは行っていません。

サブプロセスのstdinへの書き込みは別のスレッドで、stdoutからの読み込みはメインのスレッドで行っています。

参考:Python3.6.2ドキュメント:「17.1. threading

スレッドを立てる部分を書き下すと以下のようになります。

import threading
threading.Thread(target=input_loop, args=(target_proc,)).start()

簡単ですね。

関数input_loopに引数にtarget_procを渡し、別スレッドで起動しています。

 

複数のスレッドから呼ばれる関数はスレッドセーフにする必要があります。

関数内でスレッドアウトになりそうなリソースを触る部分は、threading.Lockオブジェクトでクリティカルセクションにします。

こんな感じ:

LOCK = threading.Lock()
def log(data):
    LOCK.acquire()
    # リソースの操作
    LOCK.release()

標準出力のエンコーディングの変更

前章で書きましたように、スクリプトからの標準出力のエンコードは明示的にCP932にしました。

以下のようなコードです。

import io
STDOUT_ENCODER = io.TextIOWrapper(sys.stdout.buffer, encoding="CP932")
STDOUT_ENCODER.write(“こんにちは\n”)

参考:Python3.6.2ドキュメント「16.2.3.4. テキスト I/O

Stack overflowに「俺はこうやってるよ!」というページがあり、その中の一つです。

sys.stdoutの代わりにSTDOUT_ENCODERを使います。

最初はsys.stdoutを上書きしていたのですが、write以外のメソッドを使おうとしてハマりそうな予感がしましたので、やっぱり専用の変数にしました。

 

以上Python3のLessons Learnedでした。

まとめ

以上、Windows でのvscodeのC/C++拡張とgdbでのデバッグでUTF-8の文字列の変数の中身が見られない問題の対策についてでした。(タイトルもですが、キーワードを全て入れてきれいな一文にするのは難しいですね。)

明日にもC/C++拡張かgdbがアプデートして必要なくなればそれで良し。

そうでなければ、同様な問題で効率が悪い人に役立てば良し。

またこの盗聴スクリプト「quitコマンド(或はvscodeが送るlogout)で終了する」というところだけ変えれば、ほかのアプリの盗聴にも使えるかもしれません。

コメントを残す