Waylandで日本語入力への道: std::string の捏造編

adventar.org

Kernel/VM Advent Calendarの何日目かの記事です.

前回のあらすじ: 関数だけ並べたvtableでさぼっていたら, しっかりdynamic_cast が動かなくて怒られた. bindgenもそうしてるんだけどなあ…. しょうがないので, ちゃんと型情報を入れてあげました.

DISCLAIMER: waylandで日本語入力したい時にこの記事は役に立たないし, RustでC++のbindingをしたい時にもやめた方がいいと思う.

そろそろ忘れられているかもしれませんが, このシリーズはWaylandで日本語入力をするために行われています. 今回は std::string のbinary formatを調べて, それをRustで作りあげて C++ (fcitx5)に返します. 日本語を入力するというのは, こういった作業の先にあるもので, かくも大変なものなのですね. 日本語をしっかり入力していきましょう.

std::string の中身ってどうなってるの?

さっそく std::string のbinary formatを調べましょう. ぐぐってるとこういう記事が見つかりました.

tastycode.dev

これを見ると, std::stringは基本形態では図の左のように3つのフィールド buffer・size・capacityを持つことがわかります. bufferがheap上で文字列を保持するバッファを指し, sizeは文字列の長さ, capacityはbufferのサイズです. (x86_64 linuxでは)

一方, 文字列のサイズが小さい場合には, その管理のために24byteを使うのは無駄があります. そこでSmall String Optimization (SSO)として, 図の右側のように, 構造体の内部に文字列のデータを持っておきます. この時, capacityは32byteで固定ということなのでしょう.

C++のstd::stringのバイナリ表現

gdbで確認じゃ

せっかくなので実際にコードを書いて確認しておきましょう. 以下のようなコードを書いて, "g++ -O0 -ggdb"でコンパイルしてgdbでcoutの行で止めます.

#include <string>
#include <iostream>

std::string f() {
	std::string tmp = "foo";
	tmp.reserve(32);
	return tmp;
}

int main() {
	std::string s = f();
	std::cout << s << std::endl;
	return 0;
}

さて見てみますと, たしかに最初の8byteが文字列のバッファを指していて, その次に文字列サイズの3, そして次にバッファサイズの0x20=32が入っています.

(gdb) n
11              std::string s = f();
(gdb) 
12              std::cout << s << std::endl;
(gdb) x/4xg &s
0x7fffffffd7a0: 0x000055555556c2b0      0x0000000000000003
0x7fffffffd7b0: 0x0000000000000020      0x0000000000000000
(gdb) x/s 0x000055555556c2b0
0x55555556c2b0: "foo"
(gdb) p sizeof(s)
$1 = 32

では, ここで"tmp.reserve()"の行を削除するとどうなるでしょう. Small String Optimizationがきかなくなるので, 文字列"foo"がsの内部に埋め込まれます.

(gdb) n
12              std::cout << s << std::endl;
(gdb) x/4xg &s
0x7fffffffd7a0: 0x00007fffffffd7b0      0x0000000000000003
0x7fffffffd7b0: 0x00000000006f6f66      0x0000000000000000
(gdb) x/s 0x7fffffffd7b0
0x7fffffffd7b0: "foo"

Rustでstd::string(C++)を作ろう

これらをふまえて, Rustでstd::string(ただしC++)を作って返しましょう. Rustのなら一発なのに.

こんな感じで標準タイプのstd::string(C++)の形を作って

#[repr(C)]
struct CxxString {
    ptr: *const u8,
    size: u64,
    capacity: u64,
    pad: [u8; 8],
}

適当に32byteのバッファを作って CxxStringの構造体に持たせて, capacityを32にしておきます. バッファは(多分)C++がstd::stringが死ぬ時に解放してくれる…はずなのでOK.

    let buffer: Box<[u8]> = Box::new([0; 32]);
    CxxString {
        ptr: Box::into_raw(buffer) as *const _,
        size: 0,
        capacity: 32,
        pad: [0; 8],
    }

ということで動かすと, 今度は"sub_mode()"のtodo!()に当たって死にました. こっちは上記の右側の構造でやってみたいところです.

RustでもSSOしよう

として, したかったんですが一度挫折しました. 挫折したコードたち

    let x = Box::new(CxxString {
        ptr: std::ptr::null_mut(),
        size: 0,
        capacity: 0,
        pad: [0; 8],
    });
    let ptr = Box::into_raw(x);
    let buf = (ptr as u64) + 16;
    let mut x = Box::from_raw(ptr);
    x.ptr = buf as *const _;
    let ptr = Box::into_raw(x);
    *ptr
    let mut x = CxxString {
        ptr: std::ptr::null_mut(),
        size: 0,
        capacity: 0,
        pad: [0; 8],
    };
    let ptr = &mut x as *mut _;
    x.ptr = ((ptr as u64) + 16) as *const _;
    x

なにをどうしてもdouble freeぽくなったりしてます.

たとえば下のコードだと関数の出口で

(gdb) p &x
$1 = (*mut tcode::CxxString) 0x7fffffffce80
(gdb) x/4xg $1
0x7fffffffce80: 0x00007fffffffce90      0x0000000000000000
0x7fffffffce90: 0x0000000000000000      0x0000000000000000

こんな感じで, SSOされたバイナリができています. これが返っていって

(gdb) n
fcitx::InstancePrivate::showInputMethodInformation (this=this@entry=0x5555555d2ea0, ic=ic@entry=0x555555e7c700)
    at /dev/shm/portage/app-i18n/fcitx-5.1.5/work/fcitx5-5.1.5/src/lib/fcitx/instance.cpp:391
391             auto subModeLabel = engine->subModeLabel(*entry, *ic);
(gdb) p &subMode
$2 = (std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > *) 0x7fffffffcf10
(gdb) x/4xg &subMode
0x7fffffffcf10: 0x00007fffffffce90      0x0000000000000000
0x7fffffffcf20: 0x0000000000000000      0x0000000000000000

こう, 本当にそのまま値がコピーされてるんですよね. 結果として, std::string(C++)のデスクトラクタがこれはSSOではないなと思って, 0x00007fffffffce90をfree()しにいく. すると, そこはallocした先頭のアドレスではないのでinvalid pointerで死ぬと見えます.

一方C++だとなんだかmainのstack上で直接いじられているように見える.

なんでだろ〜となった時, 困った時は逆アセンブルですよね. ということで

次回: Waylandで日本語入力への道: Return of RVO編

gentoo.hatenablog.com