以下の内容はhttps://lpha-z.hatenablog.com/entry/2025/10/05/231500より取得しました。


効率的な多倍長演算をC言語の範囲で書きたい(clang向け)

x86命令セットには、adc(キャリーつき加算)命令やsbb(ボローつき減算)命令があり、多倍長演算の際にはこれを使うと効率的です。 一方で、コンパイラにこれらの命令を出力してもらうのは、C言語の範囲での記述ではなかなか簡単ではありません。 なお、ここでは、「C言語の範囲」というのは、以下の要件を満たすことにします。

  • __uint128_t__int128_tは、加減算をadc命令やsbb命令にコンパイルするためには使わない
    • いわゆるgcc拡張
    • 二倍長乗算命令(x86imul命令やRISC-Vのmulh命令など)を出力するのは、現状のコンパイラではどうやっても無理っぽいので、そのためにのみ使う
    • __int128_t__uint128_tがない環境への移植コストの最小化が目的。二倍長乗算命令をエミュレートできることはすでに示した(64bit乗算の上位部分の計算 - よーる)ので、必要であればこれで差し替えればいいはず。一方で加減算は結構いろいろな落とし穴があるので非標準な拡張機能を使ったブラックボックスにしたくない
    • 192bitの計算がしたくなったりしたときは結局自分で書かないといけない、つまりキャリーやボローを取り扱う手法を勉強する必要がある
  • _BitIntも使わない
    • BigIntかと見間違えた
    • C23で標準化された任意ビット数整数
    • 最大幅BITINT_MAXWIDTHが大きいかは処理系定義
    • 新しいので未対応な処理系も多い
    • C++は導入議論中みたい
  • int64_tuint64_tはあるとする
    • 実装されるかは処理系定義
    • x86_64向けの処理系でこれが定義されないということはないはず
    • これが定義されない処理系への移植では、int_least64_tuint_least64_tをいちいちマスクしてエミュレートすればいいはず
    • _BitInt(64)は常に使えるので、それが最も移植性が高い……のかな?

また、x86向けのコンパイラは、かしこい最適化コンパイラであるclangを前提にします。

多語と一語の積(筆算の一行分)

以下のように書けば、意図通りキャリーつき加算になります。

void mul_256_64_320( uint64_t* A, uint64_t B, uint64_t* C) {
    __uint128_t p0 = (__uint128_t)A[0] * B;
    __uint128_t p1 = (__uint128_t)A[1] * B;
    __uint128_t p2 = (__uint128_t)A[2] * B;
    __uint128_t p3 = (__uint128_t)A[3] * B;
    uint64_t p0hi = p0 >> 64;
    uint64_t p0lo = p0;
    uint64_t p1hi = p1 >> 64;
    uint64_t p1lo = p1;
    uint64_t p2hi = p2 >> 64;
    uint64_t p2lo = p2;
    uint64_t p3hi = p3 >> 64;
    uint64_t p3lo = p3;

    uint64_t t4 =        p3lo;
    uint64_t t3 = p3hi + p2lo; p2hi += t3 < p3hi;
    uint64_t t2 = p2hi + p1lo; p1hi += t2 < p2hi;
    uint64_t t1 = p1hi + p0lo; p0hi += t1 < p1hi;
    uint64_t t0 = p0hi       ;

    C[0] = t0;
    C[1] = t1;
    C[2] = t2;
    C[3] = t3;
    C[4] = t4;
}

まず、z = x + yみたいな加算でキャリーが生じるかは、z < xで判定できます。 キャリーが生じるというのは要するに符号なしオーバーフローが発生するということです。 オーバーフローの発生は、「足したのに元の数よりも小さくなった」という事象と同値なので、これで判定できるわけです。 もちろん、加算は対称なので、z < yでも同じです。

さて、このコードで重要なのは、乗算結果の上位部にキャリーを足している(t2 = p2hi + p1lo; p1hi += t2 < p2hi)ことです。 この部分は、t1 < p2hi + (carry)はキャリー伝搬とみなされるのに対し、t2 < p1loはキャリー伝搬とみなされない点を考慮に入れたコードになっています。 「加算は対称なのでどちらでも同じ」と言っていましたが、これはキャリー生成の時だけのようで、キャリー伝搬の時は非対称のようです。 つまり、交換則は成り立っても結合則が成り立たないようです。 なお、下位部を最後以外で足す順番(c2 + p0lo + p1hip0lo + p1hi + c2)もやはりキャリー伝搬とみなされません。

一応、t1 = p1hi + c2 + p0lo; uint64_t c1 = t1 < p1hi + c2;のように書けば、変数の破壊的変更自体はなくせます。 ただ、同じ式が二回現れたり、比較の片方が加算結果になっていたりで、あまりきれいな形ではありません。 なお、変数の破壊的変更を許容する場合でも、乗算結果の下位にキャリーを足しこむ方式は、その時点でオーバーフローする可能性があってさらに記述が複雑化するのでうまくいきません。

多語と多語の和(多倍長加算)

乗算の場合は「乗算結果の上位は、1足してもオーバーフローしない」という性質をうまく利用することでうまくいったような雰囲気がありました。 したがって、多倍長加算はそれより難しいのかと思いましたが、意外と簡単に最適な命令列を出力させることができました。 コードを書いている時は気づきませんでしたが、加算の場合は「二つのuint64_tを足した結果」が「オーバーフローしたなら、さらに1足したときにオーバーフローしない数」になっている点をうまく活用するようになっているのかもしれません。

void add_256_256_256( uint64_t* A, uint64_t* B, uint64_t* C) {
    uint64_t a3 = A[3];
    uint64_t b3 = B[3];
    uint64_t a2 = A[2];
    uint64_t b2 = B[2];
    uint64_t a1 = A[1];
    uint64_t b1 = B[1];
    uint64_t a0 = A[0];
    uint64_t b0 = B[0];

    uint64_t t3 = a3 + b3;
    uint64_t s2 = a2 + b2;
    uint64_t t2 = s2 + (t3 < a3);
    uint64_t s1 = a1 + b1;
    uint64_t t1 = s1 + (t2 < s2 | s2 < a2);
    uint64_t s0 = a0 + b0;
    uint64_t t0 = s0 + (t1 < s1 | s1 < a1);

    C[0] = t0;
    C[1] = t1;
    C[2] = t2;
    C[3] = t3;
}

キャリーの部分は、OR(|)でも和(+)でも計算結果は同じになります。 これは、t2 < s2が成り立つときはs2 == 0xffffffffffffffffかつ(t3 < a3) == 1t2 == 0になる場合のみであり、この時にs2 < a2は絶対成り立たないので、1OR1になることがないからです。 99999+99999+1=199999みたいなのを思い出せば、キャリー入力があったとしても、ある位から出力されるキャリーは高々1であり、上の位に2を足さないといけないパターンはあり得ない、という説明も可能です。

しかしながら、ORでないとclangはキャリー伝搬であると認識しません。 ORだとコードを局所的に眺めただけで上記推論ができますが、和で書かれていると大局的な推論の連鎖が必要であきらめてしまうということでしょうか。

変数の破壊的変更を許容する場合、以下のような短い書き方ができますが、まぁあまりやるべきではないタイプの書き方でしょう。

    uint64_t t3 = (b3 += a3);
    uint64_t t2 = (b2 += a2) + (b3 < a3);
    uint64_t t1 = (b1 += a1) + (b2 < a2 | t2 < b2);
    uint64_t t0 = (b0 += a0) + (b1 < a1 | t1 < b1);

多倍長加算の方式を利用すると、筆算一行分に相当する乗算は以下のように書けますが、これも特別見やすいというわけでもなさそうです。

    uint64_t t4 = p3lo;
    uint64_t t3 = p2lo + p3hi;
    uint64_t s2 = p1lo + p2hi;
    uint64_t t2 = s2 + (t3 < p2lo);
    uint64_t s1 = p0lo + p1hi;
    uint64_t t1 = s1 + (t2 < s2 | s2 < p1lo);
    uint64_t t0 = p0hi + (t1 < s1 | s1 < p0lo);

多語同士の乗算(多倍長乗算)

多倍長整数同士の乗算を効率よく記述する方法として、adcx命令とadox命令を使う方法が知られています。

zenn.dev

adcx命令は、キャリーつき加算を行いつつも他のフラグ、特にOF(オーバーフローフラグ)を書き換えない命令です。 一方、adox命令は、OFをキャリー入力として扱うキャリーつき加算を行い、生じたキャリーをOFに出力し、他のフラグは書き換えない命令です。 「他のフラグは書き換えない」みたいな命令はいかにもパーシャルレジスタの呪いの影響を受けそうですが、実はうまい設計になっているようです。 EFLAGSレジスタは、実質的にCFとそれ以外のフラグを分けて管理されているようです(最大公約数をもっと高速に求める(その2)【cmova命令は遅い】 - よーるで見たように、CFとそれ以外のフラグ(ZF)を両方読むcmova命令が遅くなるあたりから透けて見えます。EコアやZen系のコアではそのような性能低下は見られないので、「当時はそうだった」というのが適切かもしれません)。 そこで、adcx命令はCFだけ読み書きする、adox命令は「それ以外のフラグ」を全部読んでOFだけ書き換える、という実装が可能で、うまくやりたいことの実現と実装しやすさを両立させています。

この二つの命令は、Broadwell世代(後述のmulx命令が導入されたHaswellの次の世代。シュリンクするTick世代なのに命令が増えている……)で導入されたものですが、その価値は、キャリー用に使える“レジスタ”を二つ提供することにあります。 キャリー用の“レジスタ”が二つあれば、mulx命令(64bit×64bitを128bitで計算する、フラグを書き換えない命令)の出力として得られる二つの64bitレジスタをアキュムレータに即座に足しこんで、それらの64bitレジスタを解放することができます。 一方でキャリー用の“レジスタ”が一つしかなければ、最上位桁まで足しあげた後、また下の位に戻って足しあげる、という順番でやらないと、命令数が増えてしまいます。 そのような方法だと、乗算結果の片方を汎用64bitレジスタに保持しておく必要があります(メモリに追い出すならその時点で命令数が増えてしまいます)。 汎用64bitレジスタの数には限りがあるため、長い多倍長整数同士の乗算だとこの方法が取れなくて、キャリーをいったん汎用レジスタに出力する、みたいなことが必要になります。 なので、キャリー用の“レジスタ”が二つ提供されることが重要で、逆に三つ以上は特に必要ないのです。

さて、clangはadox命令を出力することはおそらくありません。 adcx命令も、adox命令と組み合わせていいかんじの多倍長乗算を実装するといった本来の目的に使用することはなさそうです(もしかするとフラグ保持の兼ね合いで出力することがなくはないのかもしれません)。

とりあえず、それほど長くない多倍長同士の(または、片方が長くない多倍長との)乗算であれば、上の二つを組み合わせることでおおよそ最適な命令列が生成されます。

void mul_192_192_384( uint64_t* A, uint64_t* B, uint64_t* C) {
    uint64_t pp[3][6] = {};
    for( int i = 0; i < 3; ++i ) {
        uint64_t hi = 0;
        for( int j = 2; j >= 0; --j ) {
            __uint128_t pij = (__uint128_t)B[j] * A[i];
            uint64_t lo = pij;
            pp[i][i+j+1] = hi + lo;
            hi = (pij >> 64) + (hi + lo < hi);
        }
        pp[i][i] = hi;
    }

    uint64_t sum[6] = {};
    for( int i = 2; i >= 0; --i ) {
        uint64_t carry = 0;
        for( int k = 5; k >= 0; --k ) {
            uint64_t t = pp[i][k];
            uint64_t u = t + sum[k];
            uint64_t v = u + carry;
            carry = (v < u | u < t);
            sum[k] = v;
        }
    }

    for( int k = 5; k >= 0; --k ) {
        C[k] = sum[k];
    }
}

これは次の命令列になったようです。

mul_192_192_384:
        push    rbp
        push    r15
        push    r14
        push    r13
        push    r12
        push    rbx
        mov     rax, rdx
        mov     r11, qword ptr [rsi + 16]
        mov     rdx, qword ptr [rdi]
        mov     rcx, qword ptr [rdi + 8]
        mulx    r10, r8, r11
        mov     rbx, qword ptr [rsi]
        mov     r14, qword ptr [rsi + 8]
        mulx    r15, r9, r14
        add     r9, r10
        mulx    rsi, r10, rbx
        mov     rdx, rcx
        mulx    r12, r13, r11
        adc     r10, r15
        adc     rsi, 0
        mulx    r15, rbp, r14
        add     rbp, r12
        mulx    rcx, r12, rbx
        adc     r12, r15
        adc     rcx, 0
        mov     rdx, qword ptr [rdi + 16]
        mulx    r15, rdi, r11
        mulx    r14, r11, r14
        add     r11, r15
        mulx    rbx, rdx, rbx
        adc     rdx, r14
        adc     rbx, 0
        add     r11, r13
        adc     rdx, rbp
        adc     rbx, r12
        adc     rcx, 0
        setb    bpl
        add     rdx, r8
        adc     rbx, r9
        adc     rcx, r10
        setb    r8b
        movzx   r8d, r8b
        add     bpl, 255
        adc     r8, rsi
        mov     qword ptr [rax], r8
        mov     qword ptr [rax + 8], rcx
        mov     qword ptr [rax + 16], rbx
        mov     qword ptr [rax + 24], rdx
        mov     qword ptr [rax + 32], r11
        mov     qword ptr [rax + 40], rdi
        pop     rbx
        pop     r12
        pop     r13
        pop     r14
        pop     r15
        pop     rbp
        ret

adc命令を二回やればいいだけなのに、キャリーをsetbで汎用レジスタに出してから、movzxしてから足したり、add 255でCFに戻したりしていて最適ではありませんが、頑張った方だと思います。 うまくいっていないのは最上位部分で、他の部分と形が異なる r64 + CF + CF みたいな形になっています。 おそらく、こういった形に対するパターンマッチがなくて万能な方法でやっているのでしょう。

なお、ソースコード上で変な書き方をしている部分とその理由は以下の通りです。

  • 素直じゃないB[j] * A[i]の順番。これは、乗算の右オペランドの方がrdxmulxの暗黙オペランド)に入りがちで、ループ内ではそれが固定の方がmov命令が減るからです。逆順だと、mulxのたびに毎回mov rdx, XXXみたいな命令が入ってしまいます。
  • iのループが昇順だったり降順だったりする。これは、p22loに相当する部分(C[5]にストアされる)がなぜかスピルされてしまう問題を解決するためです。早くC[5]にストアすればいいのに、なぜか一回スタックにスピルしてから戻してストアする、みたいな命令列が出がちです。

これ以上長い乗算は、スピルなしでは難しいです。

ボローつきの符号付き引き算

以下の引き算を行いたいとします。

void sub_s128_s64( int64_t ah, uint64_t al, int64_t b, int64_t* ch, uint64_t* cl ) {
    __int128_t a = (__int128_t)ah << 64 | al;
    __int128_t c = a - b;
    *ch = c >> 64;
    *cl = c;
}

これをx86_64向けにコンパイルすると、次のような命令列になります。

sub_s128_s64:
        mov     rax, rdx
        sar     rax, 63
        sub     rsi, rdx
        sbb     rdi, rax
        mov     qword ptr [rcx], rdi
        mov     qword ptr [r8], rsi
        ret

これを__int128_tに頼らずに実装しようと思います。

void sub_s128_s64( int64_t ah, uint64_t al, int64_t b, int64_t* ch, uint64_t* cl ) {
    *cl = al - b;
    *ch = ah - (*cl > al) + (b < 0);
}

上のように実装しましたが、以下のように余分な命令が出てしまいます。 三項の足し引きの順番を変えてみても、うまくいく気配がありませんでした。 なお、ボローが出る条件である(*cl > al)(引いたはずなのに大きくなった)は(al < b)(小さい数から大きい数を引く)でも同じ結果になりました。 また、+ (b < 0)- (b >> 63)+ ((uint64_t)b >> 63)でも同じになりました(b >> 63は処理系定義動作で算術シフトになることを意図しています)。 この部分は、「bが負の時、bを(暗黙変換で)uint64_tにすると意図している値よりも 2^{64}大きくなるので、全体として 2^{64}引きすぎになる」のを補正しています。 - (b >> 63)で考えたほうがより直観的で、「bを符号拡張した時の上位部(bh相当)をahから引く」という操作に対応しています。

※ボローが出るというのは、x86においてはCFが1になる状況を意味します。記事の末尾に簡単な説明を付けました。

sub_s128_s64:
        mov     rax, rsi
        mov     rcx, rdx
        shr     rcx, 63
        add     rcx, rdi
        sub     rax, rdx
        sbb     rcx, 0
        mov     rdx, rcx
        ret

これは、キャリー/ボローに推論されないというよりは、(b < 0)を足す部分の問題のようです。

最終演算はボロー付きの命令(ボローが出ていたら1減る命令)にならないといけなくて、x86の範囲だとそのような命令はsbb命令しかありません。 したがって、最終演算は引き算にしたいです。 そこで、sar命令で-1/0を作ってそれを引くのがうまい方法になります。 実際、__int128_tを使った場合はそのような命令列にコンパイルされています。 しかしながら、+ (b < 0)- (b >> 63)+ ((uint64_t)b >> 63)はいずれも、clangが気を利かせたのか、shr命令で作った1/0の加算に変えられてしまうようです。 加算の方がなんとなくわかりやすいような気もしますし、コンパイラ制作者も混乱しないためにそのような正規化を行っているのかなと思いますが、残念ながら今回はそれが裏目に出てしまっています。

C言語の範囲で符号付き多倍長整数の演算を書くのは、clangの最適化能力をもってしてもなかなか難しいようです。 x86には符号なし整数と符号付き整数の積(の上位)を求める命令がなく(RISC-VにはMULHSU命令がある)、符号付き多倍長整数の乗算が難しいこともありますし、x86では素直に符号なしでやったほうがよさそうです。

二倍長乗算の最適化

キャリーつき加算を実現する方法が分かったので、__uint128_tを全く使わない二倍長乗算のフォールバック(64bit乗算の上位部分の計算 - よーる)を改善してみました。 実はキャリーつき加算はあまり関係なく、そもそも足してオーバーフローしない部分があるようです(コンパイラの出力を見て気づきました)。 かなりいいかんじに最適化されています。

uint64_t MULHUU( uint64_t x, uint64_t y ) {
    uint64_t xl = x & 0xffffffff;
    uint64_t xh = x >> 32;
    uint64_t yl = y & 0xffffffff;
    uint64_t yh = y >> 32;

    uint64_t z0 = xl * yl;
    uint64_t z1 = xl * yh;
    uint64_t z2 = xh * yl;
    uint64_t z3 = xh * yh;

    uint64_t z0h = z0 >> 32;
    uint64_t s = z0h + z1; // no overflow
    uint64_t t = s + z2;
    return z3 + (t >> 32) + ((uint64_t)(t < s) << 32);
}

ここで、z1は最大でも (2^{32}-1)^2 = 2^{64} − 2^{33} + 1であるため、 2^{32}-2以下の数であるz0hを加えてもオーバーフローしません。 あるいは、96ビットあるxl * yの上位64ビット分であると考えても、オーバーフローしないことがわかります。

この関数は、以下のようにコンパイルされます。無駄な命令は一つもなさそうです。 movのうち2つは零拡張用に使われているので意味ある命令です。 もう1つのmovも消せそうにありません。 というのも、2×2で4回の乗算をやる必要がありますが、最初の1回だけは両オペランドともまだ後で使うので、破壊に備えてコピーが必要だからです(残りの3つの乗算は以降で使わないオペランドを破壊する形で実行されていることもわかります)。 32bit版の二倍長乗算命令を使えば>>32を一個消せる気がしますが、使用頻度の低そうな変な命令なので遅いのかもしれません(実際、uops.infoによれば、AlderLake-Pだとマイクロ命令数が増えるようです)。

MULHUU:
        mov     eax, edi
        shr     rdi, 32
        mov     ecx, esi
        shr     rsi, 32
        mov     rdx, rcx
        imul    rdx, rax
        imul    rax, rsi
        imul    rcx, rdi
        imul    rsi, rdi
        shr     rdx, 32
        add     rdx, rax
        xor     eax, eax
        add     rdx, rcx
        setb    al
        shr     rdx, 32
        add     rdx, rsi
        shl     rax, 32
        add     rax, rdx
        ret

xorでゼロ埋めしたあとsetbでキャリーを拾っているようです。xorでゼロ埋めした後adcでもいいですが、今回の目的ではこれで問題ないですね。 なんかたまにshld(ファネルシフト)命令にコンパイルされることがあるようですが、きまぐれでしょうか。

まとめ

なるべく移植性の高い方法で多倍長演算を記述しつつ、特定のコンパイラと特定の命令セット(clangでx86向け)を仮定した時に最適な命令列が出るような記述方法を模索しました。

おまけ:キャリーとボローの関係

キャリーは、次の桁への繰り上がり(0または1)です。 次の桁を計算する時、通常の加算はa + bですが、キャリーつき加算はa + b + carryとなります。 これにより、キャリーがあるときは1多く加算することができて、キャリーがない時は通常と同じ加算になって、いずれもOKです。

ボローは、次の桁からの繰り下がり(0または1)です。 次の桁を計算する時、通常の減算はa - bですが、ボローつき減算はa - b - borrowとなります。 これにより、ボローがあるときは1多く減算することができて、ボローがない時は通常と同じ減算になって、いずれもOKです。

これを二進加算器で実装することを考えます。 まず、キャリーつき加算の方は、二進加算器の最下位桁の余っているキャリー入力をキャリーフラグ読み出し口につなぐ&二進加算器の最上位桁のキャリー出力をキャリーフラグ書き込み口につなぐ、とすれば実現できることはいいでしょう。 問題は、ボローにどう対応するかです。 まず一つの問題は、ボローフラグを作るかです。 これを作ってしまえばわかりやすく作ることができますが、キャリーフラグとボローフラグを同時に使う機会があるとは思えず、できれば共有したいです。 もう一つの問題は、どのように「追加で1引く」を実現するかです。 二進加算器には、「追加で1足す」を実現する入力信号(キャリー入力)はありますが、「追加で1引く」を実現できそうな入力信号は見当たりません。

この二つを同時に解決する、全くうまい方法があります。 二進加算器の性質をよく考えればある意味当たり前なのですが、命令セット定義と回路設計の境目みたいな話なのであまり考えたことがないかもしれません。

まず、引き算を二進加算器でどう実現していたかを思い出します。 a - bというのは、実はa + ~b + 1を実行していたのでした(ここで1はキャリー入力から入れています)。 したがって、a - bを実行する時、「追加で1を引く」は、このキャリー入力から1を入れているのをやめれば実現できることがわかります。 つまり、ボローが出ていれば二進加算器のキャリー入力に0を、ボローが出ていなければ(通常通り)1を、それぞれ入れたうえで、a~bの加算を実行すればよいことになります。

次に、生成されるボローについて考えます。 ボローが出る時、つまりa - bの結果が符号なしオーバーフローしてaよりも大きくなる時、二進加算器のキャリー出力からは0が出ます。 逆にボローが出ない時、つまりa - bの結果がa以下になるとき、二進加算器のキャリー出力からは1が出ます。

この二つを合わせてみると、ボローが出るというのは「二進加算器のキャリー出力から0が出てきている&次の桁では二進加算器のキャリー入力に0を入れたい」という状況であり、逆にボローが出ないというのは、「二進加算器のキャリー出力から1が出てきていて、次の桁では二進加算器のキャリー入力に1を入れたい」という状況です。 簡潔に言い換えると、ボローが出るというのは0を次回に受け渡したい、ボローが出ないというのは1を次回に受け渡したい、という状況です。

このことを考えると、ボローフラグを独立に作る必要はないことがわかります。 なぜなら、次回に受け渡したい値を保存するという、まさにそのものの役割を果たすキャリーフラグが既にあるからです。 この時、キャリーフラグが0=ボローが出ている、キャリーフラグが1=ボローが出ていない、という対応付けだと解釈することになります。 この方式では、追加回路は全く不要です。

ただ、このような極性の反転したフラグ方式は、デバッグ時に直観的に理解するのが困難という問題があります。 したがって、x86などではこのような方式はとられておらず、キャリーが出たときにCF=1、ボローが出た時もCF=1になります。 一方で、マイコンなどの回路を切り詰めたい用途向けの命令セットでは、極性反転方式が使われている印象です。 身近(?)なところでは、ARMのCフラグが、carry/no-borrowの時に1が立つという極性反転方式です。 とりあえず、今となっては、こんな細かいところでケチれるトランジスタ数などたかが知れており、二大派閥のどちらに属しているのかを覚える手間だけが余計に増えてややこしくなっているという感じがします……。




以上の内容はhttps://lpha-z.hatenablog.com/entry/2025/10/05/231500より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14