Một file .o không chỉ chứa mã máy — nó còn mang một bảng ký hiệu (symbol table): danh sách những thứ có tên (hàm, biến toàn cục) mà file cung cấp hoặc cần. Đây chính là thứ trình liên kết đọc để ghép các mảnh lại (bài đọc mã máy đã thấy nó qua các quan hệ tái định vị). Lệnh nm liệt kê bảng đó. Bài này đọc bảng ký hiệu bằng nm, so cách C và C++ đặt tên ký hiệu — và tôi vấp một lỗi liên kết mà chỉ nm mới giải thích được.
nm liệt kê ký hiệu và loại của chúng
Chạy nm trên một .o biên dịch từ C cho ra danh sách tên kèm một mã loại một chữ cái:
0000... D global_var (biến toàn cục có giá trị)
0000... T helper (hàm toàn cục)
0000... d private_thing (biến static — nội bộ)
Quy ước rất gọn: chữ hoa = ký hiệu toàn cục (thấy được từ file khác), chữ thường = nội bộ (chỉ trong file, như static). Còn chữ cái nói phần nào của file: T/t là hàm (.text), D/d là dữ liệu có giá trị khởi tạo (.data), B/b là dữ liệu zero (.bss), R/r là hằng chỉ đọc (.rodata), U là chưa định nghĩa (file này dùng nhưng không cung cấp — chờ liên kết điền), W là ký hiệu yếu (weak). Đây là bản đồ cho biết một file "có gì" và "cần gì". Trình liên kết chỉ cần bảng này, không cần mã nguồn: nó ghép ký hiệu U (cần) của file này với ký hiệu toàn cục (cung cấp) của file kia, đúng theo tên. Trong C, tên ký hiệu chính là tên hàm bạn viết — helper ra helper. Sang C++ thì khác hẳn.
Đo: C++ nhét chữ ký vào tên
Tôi viết một file C++ có hai hàm add nạp chồng (overload — cùng tên, khác kiểu tham số) và một lời gọi tới helper, rồi chạy nm:
T _Z3addii (add nhận hai int)
T _Z3adddd (add nhận hai double)
U _Z6helperi (dùng helper nhận một int, chưa định nghĩa)
Các tên bị biến dạng — gọi là name mangling. C++ cho phép hai hàm cùng tên add khác kiểu tham số, mà trình liên kết chỉ phân biệt được các hàm qua tên ký hiệu, nên trình biên dịch phải nhét chữ ký vào tên: _Z3addii (ii = int, int), _Z3adddd (dd = double, double) là hai ký hiệu khác nhau cho hai hàm khác nhau. Công cụ c++filt giải mã ngược:
_Z3addii -> add(int, int)
_Z3adddd -> add(double, double)
_Z6helperi -> helper(int)
Chữ ký nằm ngay trong tên. Đây là cơ chế khiến nạp chồng hàm hoạt động ở tầng liên kết. Nhưng chính nó cũng khiến tôi vấp một lỗi khi trộn C với C++.
Một lần tôi đo hớ: gọi hàm C từ C++ không liên kết được
Tôi định nghĩa helper trong một file C (int helper(int x){ return x+1; }), khai báo int helper(int); trong một file C++ rồi gọi nó, và tưởng chuyện này hiển nhiên liên kết được — chỉ là một lời gọi hàm. Nhưng khi liên kết:
undefined reference to `helper(int)'
Lạ ở chỗ thông báo lỗi ghi cả chữ ký helper(int). Chạy nm là hiểu ngay: file C++ mangling lời gọi thành U _Z6helperi (nó tìm một hàm C++ tên helper(int)), nhưng file C chỉ cung cấp ký hiệu thô T helper. Hai cái tên khác nhau cho cùng một hàm tôi nghĩ, nên trình liên kết không nối được. Cách sửa là nói với trình biên dịch C++ rằng hàm này theo quy ước C (đừng mangling): extern "C" int helper(int);. Đo lại, file C++ giờ tham chiếu U helper (tên thô), khớp T helper của file C, liên kết chạy trả về đúng kết quả.
Bài học đo lường: một lỗi "undefined reference" khó hiểu giữa C và C++ hầu như luôn là chuyện mangling, và nm là công cụ phơi bày nó — nó cho thấy hai bên đang gọi/cung cấp hai tên ký hiệu khác nhau. Tôi đã đem trực giác "cùng tên hàm thì cùng ký hiệu" (đúng trong C) áp lên C++, nơi tên hàm và tên ký hiệu là hai thứ khác nhau.
Và có một mặt hay bất ngờ: vì mangling nhét kiểu tham số vào tên ký hiệu, C++ bắt được lệch kiểu xuyên file ngay lúc liên kết. Nhớ cái bug im lặng ở bài đơn vị dịch: trong C, một file khai int shared còn file kia khai long shared vẫn liên kết trót lọt (trình liên kết khớp tên, bỏ qua kiểu) rồi cho ra giá trị rác lúc chạy. Trong C++, nếu chữ ký lệch nhau, tên ký hiệu mangling ra khác nhau, nên bạn nhận một lỗi undefined reference dứt khoát thay vì một bug âm thầm. Mangling không chỉ để nạp chồng — nó biến bảng ký hiệu thành một tầng kiểm tra kiểu ở mức liên kết.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là luôn bọc extern "C" khi C++ cần gọi/được gọi bởi C. Mọi hàm dùng qua ranh giới C/C++ — một thư viện C dùng trong C++, một hàm C++ muốn C gọi tới — phải khai extern "C" để có tên ký hiệu thô ổn định. Đây là lý do mọi header C có ý định dùng được từ C++ đều có đoạn #ifdef __cplusplus / extern "C" {. Quên nó là nhận ngay "undefined reference" với một chữ ký demangle trong thông báo.
Hệ quả thứ hai là nm là công cụ chẩn đoán liên kết đầu tay. Khi gặp "undefined reference" hay "multiple definition" khó hiểu, nm trên các .o liên quan cho thấy ai cung cấp (T/D) và ai cần (U) một ký hiệu, và tên chính xác của nó. Với C++, thêm nm -C (hoặc | c++filt) để đọc tên đã demangle. Rất nhiều lỗi liên kết "bí ẩn" tan biến trong một phút khi bạn thấy tên ký hiệu thật hai bên đang dùng không khớp — sai chữ ký, thiếu extern "C", hay một static vô tình giấu ký hiệu.
Hệ quả thứ ba là hiểu bảng ký hiệu là giao diện thật giữa các file. Không phải mã nguồn, không phải header — mà là các ký hiệu trong .o. Header chỉ giúp trình biên dịch tạo ra đúng ký hiệu; chính sự khớp ký hiệu mới quyết định liên kết thành hay bại. Con số mang theo: nm liệt kê ký hiệu với mã loại (T hàm, D dữ liệu, U chưa định nghĩa, chữ hoa=toàn cục, thường=nội bộ); C dùng tên thô, C++ mangling nhét chữ ký vào tên để cho phép nạp chồng — nên phải extern "C" khi bắc cầu C/C++, và mangling còn biến lệch kiểu xuyên file thành lỗi liên kết dứt khoát thay vì bug im lặng như C. Khi liên kết trục trặc, đừng đoán — chạy nm và đọc chính cái tên hai bên đang tìm.
Thử ba mươi giây
Biên dịch một hàm bằng C (gcc -c f.c) và cùng hàm đó bằng C++ (g++ -c f.cpp), rồi chạy nm f.o trên cả hai. File C cho tên thô (T ten_ham); file C++ cho tên bị biến dạng (T _Z...). Chạy nm f.o | c++filt (hoặc nm -C) trên file C++ để thấy tên gốc kèm chữ ký. Rồi thêm hai phiên bản nạp chồng của hàm trong C++ và xem nm: bạn sẽ thấy hai ký hiệu mangling khác nhau cho hai chữ ký. Chỉ vài lệnh nm là bạn thấy vì sao C++ gọi được hàm nạp chồng còn C thì không — tất cả nằm ở cái tên trong bảng ký hiệu.