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 kernelmmaptừ 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.FileSizvsMemSiz: segmentRWcóFileSiz = 0x258,MemSiz = 0x260. Phần chênh8byte chính là.bss(xemsize 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:
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ỉ
mmapcác segmentLOADvà dựng stack; nếu cóINTERPthì dynamic loader trong user space làm tiếp (nạp.so, relocation). .bsslà phầnMemSiz > FileSizcủa segmentRW; 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.
mainkhô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