Skip to content

Từ executable đến process

Bài giới thiệu

Bài này tóm tắt những gì xảy ra từ lúc gõ ./hello đến khi main chạy, và bố cục memory của process. Các chủ đề (PLT/GOT, ASLR, ld.so) chỉ được nêu ở mức nền tảng, đủ để đọc hiểu output của readelf, /proc/<pid>/maps và gdb.

Executable trên đĩa chỉ là một file ELF. Process là thứ kernel tạo ra từ file đó. Cầu nối giữa hai khái niệm là các segment (program header) mà bài 2 nhắc tới.

Chuỗi sự kiện

shell: fork() + execve("./hello")
   │
   ▼
kernel: đọc ELF header & program header
   ├─ map các segment PT_LOAD vào memory
   ├─ thấy PT_INTERP → map thêm dynamic loader (ld-linux)
   └─ dựng stack (argv, envp, auxv), nhảy tới entry point của loader
   │
   ▼
ld.so: nạp các DT_NEEDED (libc.so.6, ...), relocation, chạy init của library
   │
   ▼
_start (crt1.o) ──▶ __libc_start_main ──▶ main(argc, argv) ──▶ exit()

strace cho thấy phần đầu chuỗi này (rút gọn):

$ strace -f -e trace=execve,openat,mmap ./hello
execve("./hello", ["./hello"], 0x7ffe93f48888 /* 169 vars */) = 0
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
mmap(0x7fca5ce28000, 1609728, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x28000) = ...
...
area = 12.566360
+++ exited with 0 +++

Chỉ có một execve của kernel; mọi openat/mmap sau đó của ld.so.cache và libc.so.6 là do dynamic loader chạy trong user space thực hiện. Kernel không biết gì về libc.

Program header: kernel đọc gì

readelf -lW hello (rút gọn):

$ readelf -lW hello
Program Headers:
  Type      Offset   VirtAddr  FileSiz  MemSiz   Flg Align
  INTERP    0x000318 0x...0318 0x00001c 0x00001c R   0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD      0x000000 0x...0000 0x000628 0x000628 R   0x1000
  LOAD      0x001000 0x...1000 0x0001b5 0x0001b5 R E 0x1000
  LOAD      0x002000 0x...2000 0x00012c 0x00012c R   0x1000
  LOAD      0x002db8 0x...3db8 0x000258 0x000260 RW  0x1000
  DYNAMIC   0x002dc8 0x...3dc8 0x0001f0 0x0001f0 RW  0x8
  GNU_STACK 0x000000 0x...0000 0x000000 0x000000 RW  0x10
  GNU_RELRO 0x002db8 0x...3db8 0x000248 0x000248 R   0x1

 Segment Sections...
   02     .interp .note... .gnu.hash .dynsym .dynstr .rela.dyn .rela.plt
   03     .init .plt .plt.got .plt.sec .text .fini
   04     .rodata .eh_frame_hdr .eh_frame
   05     .init_array .fini_array .dynamic .got .data .bss
  • LOAD: các vùng kernel mmap từ file vào memory. Ba loại quyền: R (header, symbol động), R E (code), R (.rodata), RW (.data, .bss). Quyền được gán theo segment, nên section cùng quyền được gom chung segment: đây là lý do linker sắp xếp section theo thứ tự như trên.
  • FileSiz vs MemSiz: segment RW có FileSiz = 0x258, MemSiz = 0x260. Phần chênh 8 byte chính là .bss (xem size hello: bss = 8): kernel cấp thêm memory và xoá về 0 mà không cần đọc từ file.
  • INTERP: đường dẫn dynamic loader. Nếu có, kernel nạp loader và chuyển quyền điều khiển cho nó trước. Với executable static, không có INTERP, kernel nhảy thẳng vào entry point.
  • GNU_STACK: cờ RW (không có E) nghĩa là stack không được thực thi (NX).
  • GNU_RELRO: sau khi loader điền xong relocation, vùng này được chuyển thành read-only, giảm nguy cơ bị ghi đè GOT/init array.

Memory layout của process

Chương trình in địa chỉ của vài loại biến, rồi đọc /proc/self/maps:

m.c
int g_init = 42;
int g_bss;
const char *s = "rodata";
int main(void) {
    int local;
    void *h = malloc(16);
    /* in địa chỉ main, &g_init, &g_bss, s, h, &local */
}
$ ./m
text   0x557c32dfb1e9     # main
data   0x557c32dfe010     # g_init
bss    0x557c32dfe02c     # g_bss
rodata 0x557c32dfc008     # chuỗi "rodata"
heap   0x557c5dcdd2a0
stack  0x7ffcc31e020c
địa chỉ cao   ┌───────────────────────────┐
              │ stack  (local, return addr)│  ← lớn xuống thấp
              ├───────────────────────────┤
              │ ld.so, [vdso]              │
              │ libc.so.6 (text/rodata/data)│  ← mmap region
              ├───────────────────────────┤
              │ heap   (malloc)            │  ← nhỏ lên cao (brk)
              ├───────────────────────────┤
              │ .bss                       │
              │ .data                      │
              │ .rodata                    │
              │ .text                      │
địa chỉ thấp  └───────────────────────────┘

Khớp với /proc/self/maps (rút gọn, bỏ tên file dài):

557c32dfa000-557c32dfb000 r--p  .../m               ← header, .rodata đầu
557c32dfb000-557c32dfc000 r-xp  .../m               ← .text
557c32dfc000-557c32dfd000 r--p  .../m               ← .rodata
557c32dfe000-557c32dff000 rw-p  .../m               ← .data, .bss
557c5dcdd000-557c5dcfe000 rw-p  [heap]
7f3fc5e00000-7f3fc5e28000 r--p  libc.so.6
7f3fc5e28000-7f3fc5fb1000 r-xp  libc.so.6           ← code của libc
...
7f3fc60d9000-7f3fc60db000 r-xp  [vdso]
7f3fc60db000-7f3fc60dc000 r--p  ld-linux-x86-64.so.2
7ffcc31c0000-7ffcc31e3000 rw-p  [stack]

Có thể đối chiếu: main (...b1e9) nằm trong dòng r-xp; g_init và g_bss (...e010, ...e02c) nằm trong dòng rw-p; local (0x7ffc...) nằm trong [stack]. Hai biến .data và .bss có địa chỉ gần nhau vì chung một segment RW.

Địa chỉ thay đổi mỗi lần chạy: PIE và ASLR

file hello báo pie executable (readelf: Type: DYN). Executable được biên dịch dạng Position-Independent Executable, nên kernel có thể chọn base address ngẫu nhiên mỗi lần chạy (ASLR), thay vì cố định 0x400000 như các executable -no-pie truyền thống. Vì thế các địa chỉ 0x557c... ở trên sẽ khác ở lần chạy sau, còn offset trong file thì không đổi. Khi debug bằng gdb, địa chỉ trong objdump (như 0x1149 của main) là offset so với base này.

PLT và GOT: gọi hàm trong shared library

Lệnh call printf@PLT không biết printf nằm đâu, vì địa chỉ này chỉ xác định khi libc.so được nạp. Giải pháp là một tầng trung gian:

  • GOT (Global Offset Table): bảng các ô 8 byte chứa địa chỉ thật của symbol, được loader điền.
  • PLT (Procedure Linkage Table): các đoạn code nhỏ, mỗi đoạn jmp *GOT[n].
$ gcc -no-pie hello.c math.c -o np
$ objdump -d np | grep -A3 "<printf@plt>:"
0000000000401040 <printf@plt>:
  401040:   endbr64
  401044:   jmp    *0x2fb6(%rip)        # 404000 <printf@GLIBC_2.2.5>
$ readelf -r np | grep printf
000000404000  R_X86_64_JUMP_SLO ... printf@GLIBC_2.2.5 + 0
main ──call──▶ printf@plt ──jmp *GOT[printf]──▶ printf (trong libc.so)
                                  ▲
                      ld.so điền lúc nạp (BIND_NOW) hoặc lúc gọi lần đầu (lazy)

Entry R_X86_64_JUMP_SLOT nói với loader: "điền địa chỉ printf vào ô GOT tại 0x404000". Có hai chế độ:

  • Lazy binding: ô GOT ban đầu trỏ về resolver của loader; lần gọi đầu mới tra symbol và ghi đè, các lần sau nhảy thẳng. Khởi động nhanh hơn, nhưng GOT phải để ghi được.
  • BIND_NOW (-Wl,-z,now): loader resolve tất cả ngay lúc khởi động, rồi GOT được chuyển sang read-only nhờ GNU_RELRO (full RELRO), khó bị ghi đè hơn.

Ubuntu bật BIND_NOW mặc định trong gcc, nên các executable build trên máy đó (kể cả hello) đã ở chế độ này:

$ readelf -d hello | grep FLAGS
 0x000000000000001e (FLAGS)              BIND_NOW
 0x000000006ffffffb (FLAGS_1)            Flags: NOW PIE

Muốn thử lazy binding thì thêm -Wl,-z,lazy. Cross toolchain, Yocto, Buildroot có thể mặc định khác, nên kiểm tra bằng readelf -d thay vì giả định.

Từ _start đến main

Entry point của executable không phải main mà là _start (từ crt1.o):

$ readelf -h hello | grep -E "Type|Entry"
  Type:                              DYN (Position-Independent Executable file)
  Entry point address:               0x1060

_start lấy argc/argv/envp từ stack do kernel dựng, gọi __libc_start_main. Hàm này khởi tạo libc, chạy các constructor trong .init_array (và hàm có __attribute__((constructor))), gọi main, rồi truyền giá trị trả về của main vào exit(). exit() chạy atexit handler và .fini_array, flush stdio, rồi gọi syscall exit_group.

Vì vậy return 0 trong main và exit(0) gần như tương đương, còn _exit(0) thì không flush stdio.

Tóm tắt

  • Kernel chỉ mmap các segment LOAD và dựng stack; nếu có INTERP thì dynamic loader trong user space làm tiếp (nạp .so, relocation).
  • .bss là phần MemSiz > FileSiz của segment RW; stack, heap, vùng mmap của library đều do kernel/loader cấp lúc chạy.
  • PIE + ASLR làm địa chỉ đổi mỗi lần chạy; PLT/GOT cho phép gọi hàm library có địa chỉ biết muộn.
  • main không phải entry point: _start → __libc_start_main → main → exit.

Tiếp theo: Build nhiều file.

References

  • man 2 execve, man ld.so, man proc (mục /proc/[pid]/maps)
  • man ld.so: lazy binding và LD_BIND_NOW
  • Linux x86-64 ABI
  • Randal E. Bryant, David R. O'Hallaron - Computer Systems: A Programmer's Perspective, chương 7 (Linking), mục 7.9-7.12