Suốt sê-ri này tôi lặp đi lặp lại một câu: "đọc assembly để kiểm chứng". Công cụ chính để làm điều đó với một file đã biên dịch là objdump -d — nó tháo (disassemble) mã máy nhị phân trở lại thành assembly mà con người đọc được. Bài này đo chính công cụ ấy: nó cho ta thấy gì, khác gcc -S ra sao, và một cái bẫy khiến tôi suýt đọc sai chính cái mã máy mình định kiểm chứng.

Đọc mã máy bằng objdump

objdump -d tháo byte thành lệnh

objdump -d file in ra mỗi lệnh máy trên một dòng, ba cột: địa chỉ, mã máy thô (các byte), và lệnh (mnemonic + toán hạng). Tôi biên dịch một hàm add(a,b){ return a+b; } rồi tháo nó:

0:  0b010000   add  w0, w0, w1
4:  d65f03c0   ret

Cột giữa là mã máy thật — 0b010000 là bốn byte mã hóa lệnh add w0, w0, w1, d65f03c0 là lệnh ret. Bốn byte đó không phải ngẫu nhiên: aarch64 mã hóa mỗi lệnh theo một khuôn cố định, với các trường bit cho mã thao tác, thanh ghi nguồn và đích. objdump làm việc ngược lại của trình hợp dịch — nó đọc bốn byte, giải mã các trường bit, rồi in ra tên lệnh và toán hạng cho ta đọc. Chú ý địa chỉ: lệnh đầu ở 0, lệnh sau ở 4. Trên aarch64, mọi lệnh đúng 4 byte, nên địa chỉ tăng đều +4 (khác x86, nơi lệnh dài ngắn khác nhau từ 1 tới 15 byte). Đây là điểm khác cốt lõi với gcc -S: gcc -S in ra assembly tượng trưng của trình biên dịch, trước khi liên kết; còn objdump -d tháo ngược byte thật trong file .o hoặc file chạy được — thứ CPU sẽ thực sự nạp. Chúng gần giống nhau, cho tới khi tôi tháo một file chưa liên kết.

Một lần tôi đo hớ: "bl 0" không phải gọi địa chỉ 0

Tôi biên dịch một hello.c (in một chuỗi) thành hello.ochưa liên kết — rồi tháo nó để xem lời gọi hàm trông thế nào:

10:  94000000   bl  0 <puts>

Đích của lệnh nhảy-có-lưu (bl) là 0. Phản xạ đầu tiên của tôi: "nó gọi địa chỉ 0 — chạy lên là crash ngay!". Nhưng đó là đọc sai. Nhìn cột byte: 94000000 — trường offset của lệnh bl toàn số 0. Trong một file .o chưa liên kết, trình biên dịch chưa biết puts sẽ nằm ở địa chỉ nào (nó ở trong libc, sẽ được nối vào lúc liên kết), nên nó để trống offset bằng 0 như một chỗ giữ chỗ. Số 0 không phải đích thật; nó là "chưa điền".

Sự thật nằm ở một cờ khác: objdump -dr (thêm r = relocations, quan hệ tái định vị):

10:  94000000   bl  0 <puts>
     10: R_AARCH64_CALL26   puts

Dòng thứ hai là một quan hệ tái định vị: nó bảo trình liên kết "tại lệnh ở offset 0x10, hãy điền địa chỉ thật của puts vào (kiểu CALL26)". Đây chính là cầu nối giữa biên dịch độc lập và chương trình hoàn chỉnh. Để chứng minh, tôi liên kết hello.o thành a.out rồi tháo lại:

bl  400550 <puts@plt>       ; byte: 97fffff0

Cùng một lệnh bl, giờ đích là 400550 (một stub PLT trỏ tới puts), và byte đã đổi từ 94000000 thành 97fffff0. Trình liên kết đã ghi đè trường offset của lệnh, đúng như quan hệ tái định vị yêu cầu. Bài học đo lường: objdump -d cho thấy chính xác byte đang có, nhưng tháo một .o chưa liên kết thì địa chỉ chưa phải là thật — chúng là chỗ trống chờ điền. Tôi đã nhầm "cái công cụ hiển thị" (bl 0) với "cái chương trình sẽ làm" (gọi puts). Công cụ không nói dối; nó cho tôi thấy đúng byte 0 — nhưng diễn giải byte đó như hành vi cuối cùng mới là chỗ sai. Muốn đọc chương trình thật, phải tháo file đã liên kết; muốn hiểu một .o, phải thêm -dr để thấy chỗ nào đang chờ trình liên kết điền. Đây là cùng một bài học đã gặp nhiều lần trong sê-ri, chỉ khoác dạng mới: công cụ hiển thị đúng thứ nó thấy, nhưng thứ nó thấy phụ thuộc vào việc bạn đưa cho nó artifact nào — một .o giữa chừng hay một chương trình đã hoàn chỉnh là hai câu chuyện khác nhau.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là tháo đúng artifact cho câu hỏi của bạn. Muốn xem trình biên dịch sinh mã gì cho một hàm (tối ưu ra sao, có vector hóa không) — tháo .o hoặc dùng gcc -S, địa chỉ tương đối không quan trọng. Muốn xem chương trình thật sự gọi gì, đi qua PLT ra sao, bố cục thế nào — tháo file thực thi đã liên kết. Nhầm hai cái này dẫn tới những kết luận sai như "nó gọi địa chỉ 0". Và khi thấy đích lệnh nhảy là 0 hay một số nhỏ khả nghi trong một .o, phản xạ đúng là chạy objdump -dr để xem quan hệ tái định vị, chứ không hoảng.

Hệ quả thứ hai là objdump là kính hiển vi để kiểm chứng mọi bài trước của sê-ri. bl puts thay cho printf (bài bốn giai đoạn), lệnh mul bị xóa khi loại mã chết, các lệnh NEON .4s khi vector hóa, bốn ô tích lũy khi mở vòng lặp — tất cả đều đọc được bằng objdump -d trên binary cuối. Khác với gcc -S (chỉ một đơn vị dịch), objdump đọc được cả chương trình đã ghép, kể cả thư viện tĩnh và mã bạn không có nguồn. Nó là cách duy nhất để thấy toàn bộ thứ CPU sẽ chạy.

Hệ quả thứ ba là một góc nhìn khiêm tốn về quy mô: tháo a.out của "hello world", tôi đếm được 116 lệnh toàn file, nhưng main — thứ duy nhất tôi viết — chỉ 8 lệnh. Phần còn lại là mã khởi động của C (_start, thiết lập môi trường), các stub PLT, mã kết thúc. Nói cách khác, tháo mã máy còn là một liều thuốc chống ảo tưởng về quy mô: cái bạn gõ chỉ là phần nổi rất nhỏ, còn cả một guồng máy runtime chạy trước và sau main mà bạn không bao giờ viết một dòng. Con số mang theo: objdump -d tháo byte thật thành ba cột địa chỉ/mã máy/lệnh (aarch64 mỗi lệnh 4 byte); nhưng tháo một .o chưa liên kết cho địa chỉ chưa thật — bl 0 là chỗ trống, dùng objdump -dr để thấy quan hệ tái định vị, và tháo file đã liên kết để đọc chương trình thật. Đây là công cụ để biến "tôi nghĩ trình dịch làm X" thành "tôi thấy nó làm X" — miễn là bạn tháo đúng file và đọc đúng cột.

Thử ba mươi giây

Biên dịch một file C nhỏ có gọi printf: gcc -c file.c -o file.o. Chạy objdump -d file.o và tìm dòng bl (hoặc call trên x86) — bạn sẽ thấy đích là 0 hoặc một offset lạ. Giờ chạy objdump -dr file.o và nhìn ngay dưới dòng đó: một quan hệ tái định vị R_... chỉ tên hàm mà trình liên kết sẽ điền vào. Cuối cùng gcc file.o -o app && objdump -d app, tìm lại lệnh đó: đích giờ là một địa chỉ thật. Chỉ ba lệnh objdump là bạn thấy trọn hành trình một lời gọi từ "chỗ trống" tới "địa chỉ thật" — và hiểu vì sao đọc mã máy phải đọc đúng file.