My code works, I don’t know why.

國王的耳朵是驢耳朵

尋找"Hello World\n"

| Comments

很多人應該知道學C語言第一個程式

Hello.c
1
2
3
4
5
6
7
8
#include <stdio.h>

int main()
{
  printf("Hello world.\n");

  return 0;
}

不知道有沒有會問,那個"Hello world\n"放在什麼地方?我承認我用了很多年,到最近才開始去想這個問題。我想再加碼討論不同情況的"Hello world\n"執行檔會放在什麼地方?接下來我們一個一個討論吧

共用Makefile

Makefile
1
2
3
4
5
6
7
8
9
10
11
CFLAGS=-Wall -Werror -g
TARGET=hello
OBJS=$(patsubst %, %.o, $(TARGET))

all: $(TARGET)

%.o: %.c
  $(CC) -o $(patsubst %.o, %, $@) $^

clean:
  rm *.o *~ $(TARGET) -f

看不懂語法?唔,我也想不出來為什麼當初這樣寫,應該是剛學patsubst所以到處都想用兩下。不過只有幾行,花個時間估狗一下吧?

版本一

Hello.c
1
2
3
4
5
6
7
8
#include <stdio.h>

int main()
{
  printf("Hello world.\n");

  return 0;
}

要知道"Hello world.\n"放在那邊,可以反組譯一下,組合語言下沒有秘密。

Hello.c
1
2
3
4
5
6
7
8
9
10
11
000000000040052d <main>:
  40052d:       55                      push   %rbp
  40052e:       48 89 e5                mov    %rsp,%rbp
  400531:       bf d4 05 40 00          mov    $0x4005d4,%edi
  400536:       e8 d5 fe ff ff          callq  400410 <puts@plt>
  40053b:       b8 00 00 00 00          mov    $0x0,%eax
  400540:       5d                      pop    %rbp
  400541:       c3                      retq
  400542:       66 2e 0f 1f 84 00 00    nopw   %cs:0x0(%rax,%rax,1)
  400549:       00 00 00
  40054c:       0f 1f 40 00             nopl   0x0(%rax)

我們可以看到沒有printf而是出現puts,目前只能猜測在沒有format string的情況下gcc會把printf換成puts,原因可能和效能有關係。

這邊關於System V X86_64的表格中我們可以看到,參數傳遞使用的暫存器依序為rdi, rsi, rdx, rcx, r8, r9

所以我們可以看到0x4005d4會被傳進puts,那麼0x4005d4在那邊呢?我們可以從symbol table中推測應該在.rodata section。

Hello.c
1
2
3
4
5
$ objdump -t hello
...
00000000004005d0 l    d  .rodata   0000000000000000              .rodata
00000000004005e4 l    d  .eh_frame_hdr    0000000000000000              .eh_frame_hdr
...

接下來我們去看.rodata裏面的內容,果然找到"Hello world.\n"

Hello.c
1
2
3
4
5
6
$ objdump -s -j .rodata hello

hello:     file format elf64-x86-64

Contents of section .rodata:
 4005d0 01000200 48656c6c 6f20776f 726c6400  ....Hello world.

當然這樣是不太夠,再加碼:

Hello.c
1
2
3
4
5
6
$ objdump -h hello | grep ro -n2
32- 13 .fini         00000009  00000000004005c4  00000000004005c4  000005c4  2**2
33-                  CONTENTS, ALLOC, LOAD, READONLY, CODE
34: 14 .rodata       00000010  00000000004005d0  00000000004005d0  000005d0  2**2
35-                  CONTENTS, ALLOC, LOAD, READONLY, DATA
36- 15 .eh_frame_hdr 00000034  00000000004005e0  00000000004005e0  000005e0  2**2

這邊顯示的資料說.rodata是唯讀的。也就是說有人想寫這塊記憶體就會GG。幸運的是這個statement似乎很難去改字串資料。

版本二

Hello.c
1
2
3
4
5
6
7
8
9
#include <stdio.h>

int main()
{
  char *str = "Hello world.\n";
  printf("%s", str);

  return 0;
}

一樣需要先來反組譯一下,組合語言下沒有秘密。直接挑重點。

Hello.c
1
2
3
4
5
6
7
8
9
$ objdump -d hello
...
  400535:       48 c7 45 f8 e4 05 40    movq   $0x4005e4,-0x8(%rbp)
  40053c:       00
  40053d:       48 8b 45 f8             mov    -0x8(%rbp),%rax
  400541:       48 89 c6                mov    %rax,%rsi
  400544:       bf f2 05 40 00          mov    $0x4005f2,%edi
  400549:       b8 00 00 00 00          mov    $0x0,%eax
  40054e:       e8 bd fe ff ff          callq  400410 <printf@plt>

可以看到這次使用了printf,而且傳了兩個參數,第一個是0x4005f2,第二個是0x4005e4。這邊要注意的是,%rsi這邊感覺有點脫褲子放屁,不知道為什麼不直接movq $0x4005e4, %rsi。不管怎樣,如第一版方式看看這兩個位址在那個section。

Hello.c
1
2
3
4
5
$ objdump -t hello
...
00000000004005e0 l    d  .rodata  0000000000000000              .rodata
00000000004005f8 l    d  .eh_frame_hdr    0000000000000000              .eh_frame_hdr
...

看起來又是.rodata,所以我們再看rodata裏面放什麼東西。

Hello.c
1
2
3
4
5
6
7
$ objdump -s -j .rodata hello

hello:     file format elf64-x86-64

Contents of section .rodata:
 4005e0 01000200 48656c6c 6f20776f 726c640a  ....Hello world.
 4005f0 00257300                             .%s.

所以你可以看到,真正的行為是把"%s"Hello World\n這兩個字串的位址傳給printf

前面說.rodata是read only。那麼我們故意改兩下看看。

改看看放在.rodata的值
1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

int main()
{
  char *str = "Hello world\n";
  printf("%s", str);
    *str = 'Q';

  return 0;
}

結果就會出現組裝工最好的朋友:Segmentation fault (core dumped)

改看看放在.rodata的值
1
2
3
$ ./hello
Hello world
Segmentation fault (core dumped)

版本三

Hello3.c
1
2
3
4
5
6
7
8
9
#include <stdio.h>

int main()
{
  char str[] = "Hello world.\n";
  printf("%s", str);

  return 0;
}

這個可以看到是一個有初始值的陣列,而在函數內的變數會放在stack。所以我這樣測,下面結果又臭又長,用力找可以看到(精確的來說,湊到)"Hello world."的字串。

Hello3.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
$ objdump -s -j .text hello

hello:     file format elf64-x86-64

Contents of section .text:
 4004b0 31ed4989 d15e4889 e24883e4 f0505449  1.I..^H..H...PTI
 4004c0 c7c07006 400048c7 c1000640 0048c7c7  ..p.@.H....@.H..
 4004d0 9d054000 e8b7ffff fff4660f 1f440000  ..@.......f..D..
 4004e0 b84f1060 0055482d 48106000 4883f80e  .O.`.UH-H.`.H...
 4004f0 4889e577 025dc3b8 00000000 4885c074  H..w.]......H..t
 400500 f45dbf48 106000ff e00f1f80 00000000  .].H.`..........
 400510 b8481060 0055482d 48106000 48c1f803  .H.`.UH-H.`.H...
 400520 4889e548 89c248c1 ea3f4801 d048d1f8  H..H..H..?H..H..
 400530 75025dc3 ba000000 004885d2 74f45d48  u.]......H..t.]H
 400540 89c6bf48 106000ff e20f1f80 00000000  ...H.`..........
 400550 803df10a 20000075 11554889 e5e87eff  .=.. ..u.UH...~.
 400560 ffff5dc6 05de0a20 0001f3c3 0f1f4000  ..].... ......@.
 400570 48833da8 08200000 741eb800 00000048  H.=.. ..t......H
 400580 85c07414 55bf200e 60004889 e5ffd05d  ..t.U. .`.H....]
 400590 e97bffff ff0f1f00 e973ffff ff554889  .{.......s...UH.
 4005a0 e54883ec 2064488b 04252800 00004889  .H.. dH..%(...H.
 4005b0 45f831c0 48b84865 6c6c6f20 776f4889  E.1.H.Hello woH.
 4005c0 45e0c745 e8726c64 2e66c745 ec0a0048  E..E.rld.f.E...H
 4005d0 8d45e048 89c6bf84 064000b8 00000000  .E.H.....@......
 4005e0 e89bfeff ffb80000 0000488b 55f86448  ..........H.U.dH
 4005f0 33142528 00000074 05e872fe ffffc9c3  3.%(...t..r.....
 400600 41574189 ff415649 89f64155 4989d541  AWA..AVI..AUI..A
 400610 544c8d25 f8072000 55488d2d f8072000  TL.%.. .UH.-.. .
 400620 534c29e5 31db48c1 fd034883 ec08e80d  SL).1.H...H.....
 400630 feffff48 85ed741e 0f1f8400 00000000  ...H..t.........
 400640 4c89ea4c 89f64489 ff41ff14 dc4883c3  L..L..D..A...H..
 400650 014839eb 75ea4883 c4085b5d 415c415d  .H9.u.H...[]A\A]
 400660 415e415f c366662e 0f1f8400 00000000  A^A_.ff.........
 400670 f3c3                                 ..

當然這樣證據不夠,還是反組譯一下好了。一樣挑重點。

Hello3.c
1
2
3
4
5
6
7
8
9
10
11
12
13
$ objdump -d hello
...
  4005b4:  48 b8 48 65 6c 6c 6f   movabs $0x6f77206f6c6c6548,%rax
  4005bb:  20 77 6f
  4005be:  48 89 45 e0              mov    %rax,-0x20(%rbp)
  4005c2:  c7 45 e8 72 6c 64 2e    movl   $0x2e646c72,-0x18(%rbp)
  4005c9:  66 c7 45 ec 0a 00      movw   $0xa,-0x14(%rbp)
  4005cf:  48 8d 45 e0           lea    -0x20(%rbp),%rax
  4005d3:  48 89 c6                 mov    %rax,%rsi
  4005d6:  bf 84 06 40 00           mov    $0x400684,%edi
  4005db:  b8 00 00 00 00           mov    $0x0,%eax
  4005e0:  e8 9b fe ff ff           callq  400480 <printf@plt>
...

先來猜測這一段在做啥:

  • 呼叫printf
  • 所以要帶參數,也就是"%s"和str

str的部份可以看到,其實存在stack中 首先是movabs $0x6f77206f6c6c6548,%rax

請對照ASCII 表 因為x86用little endian,所以請從右到左來看operand 0x6f77206f6c6c6548

  • 0x48H
  • 0x65e
  • 0x6cl
  • 0x6cl
  • 0x6fo
  • 0x20
  • 0x77w
  • 0x77o

接下來是4005c2: c7 45 e8 72 6c 64 2e movl $0x2e646c72,-0x18(%rbp) 一樣,對照表格可以看到 * 0x72r * 0x6cl * 0x64d * 0x2e.

最後是movw $0xa,-0x14(%rbp) * 0xa\n

把資料存到stack後,再把stack address傳給printf,相對動作是

Hello3.c
1
2
  4005cf:       48 8d 45 e0             lea    -0x20(%rbp),%rax
  4005d3:       48 89 c6                mov    %rax,%rsi

"%s"存在.rodata,從objdump -t hello可以看到.rodata位址是0x400680,內容是

Hello3.c
1
2
3
4
5
6
$ objdump -s -j .rodata hello

hello:     file format elf64-x86-64

Contents of section .rodata:
 400680 01000200 257300                      ....%s.

也就是說"%s"放在0x400684,因此直接把該位址當作參數傳給printf

Hello3.c
1
  4005d6:       bf 84 06 40 00          mov    $0x400684,%edi

最後做個總結,這個版本的"Hello world.\n"程式本身"hardcode"到stack內。更簡單的說,"Hello world.\n"放在.text裏面。

版本四

Hello3.c
1
2
3
4
5
6
7
8
9
#include <stdio.h>

int main()
{
  static char str[] = "Hello world.\n";
  printf("%s", str);

  return 0;
}

一樣反組譯一下。直接挑重點。

Hello3.c
1
2
3
4
5
6
7
$ objdump -d hello
...
  400531:       be 40 10 60 00          mov    $0x601040,%esi
  400536:       bf d4 05 40 00          mov    $0x4005d4,%edi
  40053b:       b8 00 00 00 00          mov    $0x0,%eax
  400540:       e8 cb fe ff ff          callq  400410 <printf@plt>
...

0x6010400x4005d4在那邊呢?我們可以看一下section資訊,可以看到這兩個分別落在.data.rodata

Hello3.c
1
2
3
4
$ objdump -t hello
...
00000000004005d0 l    d  .rodata   0000000000000000              .rodata
0000000000601030 l    d  .data    0000000000000000              .data

馬上來看.data.rodata的內容

Hello3.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ objdump -s -j .rodata hello

hello:     file format elf64-x86-64

Contents of section .rodata:
 4005d0 01000200 257300                      ....%s.

$ objdump -s -j .data hello

hello:     file format elf64-x86-64

Contents of section .data:
 601030 00000000 00000000 00000000 00000000  ................
 601040 48656c6c 6f20776f 726c642e 0a00      Hello world...

結論

依照程式的寫法,你的資料會放在不同的section。沒弄好就會把程式搞爛,有興趣的朋友可以自己設計其他實驗看看。

補充 2015-01-29

感謝Scott大大的補充。從上面的反組譯中可以看到在呼叫printf之前都會去把%eax設成零。Scott大大提醒以後才知道這樣設定是有原因的。說明如下

首先printf是一個有趣的函數,有沒有人想過為什麼他的參數的數量可以變動?我先承認我這兩年才去了解,基本上這東西叫作va_arg,細節就不談了,有興趣man va_arg就好了,那天想起來或是有人敲碗再解釋。

回到前面,因為printf是非固定參數數量,而ABI中有又有規範va_arg時候該如何傳遞。在X86的ABI規範3.5.7中提到,在傳遞這樣參數的時候,需要把要傳遞的浮點型態變數數量放在eax暫存器中。而這次範例中的printf恰巧都沒有浮點型態變數,所以將eax設成0。Scott大大沒說我還以為單純是歸零的動作orz。

參考資料

Comments