0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

社内勉強会 第1回:コンパイラなしで、HelloWorld を世界的なキャラクターに昇華する

0
Last updated at Posted at 2026-10-03

はじめに

「社内勉強会」は、弊社の社長(組み込みのスペシャリスト)が書いた資料をもとに、学んだことを記録していくシリーズです。

第1回のテーマは「コンパイラが使えないとき、バグをどう直すか」。
その答えは、機械語の直接書き換えでした。

コードの編集もコンパイルもなしに、バグを直してしまう。
普段 Visual Studio で作業している私には、理解しがたい世界です。

この記事でやること

  • 完成したプログラムの文字を、コンパイルなしで書き換える
  • ループの回数を、1バイトの書き換えで変える
  • 空きのない場所へ、あとから処理を割り込ませる

補足

資料は社長の了承を得たうえで、図や例は転載せず、私の理解でまとめました。
資料と同じ現象を小さなサンプルで再現し、実際の機械語も載せています。
低レイヤーは専門外のため、誤りがあればコメントで教えていただけると幸いです。

資料の内容:コンパイラなしでバグを直す

資料が扱っていたのは、1人1台のPCがなかった時代の話です。
バグを直したくても、コンパイラを使えない場面があったそうです。
そんなときは、機械語(CPU が直接読む命令の数字の列)を直接書き換えて、その場を乗り切ったといいます。

こうした、完成したプログラムの直接書き換えは、「バイナリパッチ」と呼ばれることがあるようです。

サンプルプログラム

資料の内容を再現するため、小さなサンプルを用意しました。
文字を出し、* を10個並べ、最後に Bye. と出すだけのプログラムです。

#include <unistd.h>

/* 実験用の空き領域(何もしない NOP 命令を16個並べておく) */
__attribute__((noinline)) void cave(void)
{
    __asm__ volatile(".rept 16\n nop\n .endr");
}

int main(void)
{
    write(1, "Hello, World!\n", 14);

    for (int i = 0; i != 10; i++) {
        write(1, "*", 1);
    }
    write(1, "\n", 1);
    write(1, "Bye.\n", 5);
    return 0;
}

関数 cave は、あとで処理を書き込むための空き領域として置いています。
ARM64 の Linux で、次のようにビルドして実行します。

gcc -O0 -no-pie -fno-pic -o hello hello.c
./hello
Hello, World!
**********
Bye.

動作確認の環境
ARM64(aarch64)向けに GCC 13.3 でクロスビルドし、エミュレータ(QEMU)上で動かして確認しました。
機械語やアドレスは、CPU の種類やコンパイラのバージョンで変わります。
お試しの際は、objdump -d hello で、環境に応じた数値を確かめてください。

このサンプルでは、アドレスから 0x400000 を引くと、ファイル先頭からの位置になります。
たとえば、アドレス 0x400738 の命令は、ファイルの 0x738 バイト目にあります。

機械語を書き換えてみる

実験1:HelloWorld は、CPU からどう見えているのか

資料では、実際の機械語を並べたうえで、「ここが HelloWorld の出力にあたる部分」と示されていました。
ソースコードで見慣れた "Hello, World" も、プログラムのファイルの中では、数字の列として入っています。

サンプルで、ファイル内の位置を探してみます。

strings -t x hello | grep Hello
xxd -s 0x790 -l 14 hello
    790 Hello, World!
00000790: 4865 6c6c 6f2c 2057 6f72 6c64 210a       Hello, World!.

左端がファイル先頭からの位置(0x790)、真ん中が中身の16進数、右端が文字です。
48 が H、65 が e、末尾の 0a が改行にあたります。文字もプログラムの中では、文字コードという数字で並んでいるわけです。

実験2:HelloWorld を書き換える

World の W は、先ほどの 0x790 から7バイト進んだ 0x797 にあります。
そこから5バイトを、文字コード(4b 69 74 74 79)に書き換えます。

cp hello hello_kitty
echo "00000797: 4b69 7474 79" | xxd -r - hello_kitty
./hello_kitty

これで何がどう変わるのかというと…

Hello, Kitty!
**********
Bye.

コードの編集もコンパイルもしていないのに、とっても可愛くなりました!
書き換え前後の中身は、次のとおりです。

(前)00000797: 576f 726c 64    World
(後)00000797: 4b69 7474 79    Kitty

ソースコードもコンパイラも、一度も登場していません。
World と Kitty はどちらも5文字なので、ほかの部分がずれることなく入れ替えられます。

実験3:ループを 10 回から 15 回に

繰り返しの回数を決めている数字を探して、書き換えます。
objdump -d hello でループの部分を見ると、次の2行が見つかります。

400738:	7100281f 	cmp	w0, #0xa
40073c:	54fffec1 	b.ne	400714 <main+0x24>

cmp w0, #0xa は「カウンターが 10(0xa)か比べる」命令、b.ne は「10 でなければループの先頭に戻る」命令です。

この cmp の機械語 7100281f は、ファイルの中では 1f 28 00 71 の順に並んでいます。
このうち 28 が 10 を表す部分で、位置は 0x738 から1バイト進んだ 0x739 です。
命令の中では、数字が2ビット左にずれた形で入っているため、10 は 0x28、15 は 0x3c になります(今回は w0 との比較で、数字が63以下の場合)。

なぜ「2ビット左にずれる」のか(クリックで補足を見る)

cmp w0, #10 の機械語 0x7100281f を、2進数で書くと次のとおりです。

0111 0001 0000 0000 0010 1000 0001 1111

この32ビットは、役割ごとに、次のように区切られています。

ビット   31-23      22    21-10       9-5      4-0
中身     命令の種類   0     数字(10)    Rn(w0)   Rd

数字を入れる欄は、ビット10から始まります(ビットは右端を0番として数えます)。
一方、ファイルの中では、32ビットが8ビットずつのバイトに区切られて、後ろのバイトから順に並びます。そのため、71 00 28 1f が、1f 28 00 71 の順になります。

0x28 のバイト(ビット15〜8)の中身は、次のとおりです。

ビット   15 14 13 12 11 10 |  9  8
値        0  0  1  0  1  0 |  0  0
         └ 数字の下位6ビット(=10) ┘ └ Rn の上位2ビット
                                      (w0 なので 0)

数字の欄はビット10から、バイトの区切りはビット8から始まります。
その差が2ビットなので、バイトの中では、数字が2ビット左にずれて、4倍の値になります。

10 × 4 = 40 = 0x28
15 × 4 = 60 = 0x3c

この説明が成り立つのは、次の2つの条件を満たすときです。

  • 比べる相手が w0 で、バイトの下位2ビットが 0 であること
  • 数字が63以下で、このバイトの6ビットに収まること

64以上になると、数字が次のバイトにはみ出します。

28 を 3c に、1バイトだけ書き換えます。

cp hello hello_15
echo "00000739: 3c" | xxd -r - hello_15
./hello_15
Hello, World!
***************
Bye.

* が15個に増えました。
書き換え後の objdump では、cmp w0, #0xf に変わっています。

ソースコードの 10 を 15 に直すのと同じことを、完成したプログラムの上で、たった1バイトの書き換えで行ったわけです。

実験4:隙間のないところに、処理を割り込ませる

資料の中で、いちばんオモシロかった部分です。
既存のプログラムには、処理を書き足す余白がありません。それでも資料では、次の手順で、あとから処理を割り込ませていました。

  1. 割り込ませたい場所の命令を、別の場所へ飛ぶ命令(ジャンプ)に書き換える
  2. 飛んだ先の空き領域に、追加したい処理を書く
  3. ジャンプで上書きして消した、元の命令も、空き領域で実行する
  4. 元の場所の続きへ、ジャンプで戻る

追加した処理のあとで元の流れに戻るので、もともとの動きは壊れません。
飛び先の空き領域は、「コードケイブ」と呼ばれることがあるようです。

サンプルでやってみます。
Bye. を出す直前に、Inserted! と出す処理を割り込ませます。

Bye. を出す処理の先頭は、アドレス 0x400754 にあります。

400754:	d28000a2 	mov	x2, #0x5

この命令を、空き領域(アドレス 0x4006a8)へ飛ぶ命令に書き換えます。

400754:	17ffffd5 	b	4006a8 <cave>

ジャンプ命令(b)には、飛び先までの距離を4で割った数が入っています。
今回は 0x4006a8 と 0x400754 の差 -172 を4で割った -43 が入り、機械語は d5 ff ff 17 になります。

空き領域には、次の処理を書き込みます。

4006a8:	d2800142 	mov	x2, #0xa           // 書く文字数(10)
4006ac:	100000a1 	adr	x1, 4006c0         // 文字列の場所
4006b0:	52800020 	mov	w0, #0x1           // 標準出力
4006b4:	97ffff9f 	bl	400530 <write@plt> // 文字を出す
4006b8:	d28000a2 	mov	x2, #0x5           // ← 上書きして消した、元の命令
4006bc:	14000027 	b	400758             // 元の場所の続きへ戻る
4006c0: "Inserted!\n"                       // 出す文字列(10バイト)

4006b8 の mov x2, #0x5 は、ジャンプで上書きして消してしまった命令です。
これを空き領域でもう一度実行してから、続きへ戻ります。

ジャンプと空き領域の中身を、まとめて書き込みます。

cp hello hello_cave
xxd -r - hello_cave <<'EOF'
00000754: d5ff ff17
000006a8: 4201 80d2 a100 0010 2000 8052 9fff ff97
000006b8: a200 80d2 2700 0014 496e 7365 7274 6564
000006c8: 210a
EOF
./hello_cave
Hello, World!
**********
Inserted!
Bye.

main の中には1バイトの空きもなかったのに、Inserted! を割り込ませられました。
なお、書き換え後の空き領域を objdump で見ると、末尾の文字列は命令ではなく文字なので、意味のない命令として表示されます。

おわりに

普段はコンパイラに任せきりで、低レイヤーに触れる機会はまずありません。
それだけに、「コンパイラという通訳を介さず、CPU に直接話しかける世界」をのぞけたのは、本当に貴重な時間でした。

この記事は、仕組みを学ぶためのものです。
他人が作ったソフトウェアを書き換えることは、利用規約やライセンスで禁止されていたり、法律に触れたりすることがあります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?