Cách công khai thư viện tĩnh bên thứ ba
Như đã đề cập ở trên, việc liên kết một thư viện tĩnh hoặc static framework vào một dự án sử dụng framework liên kết động (dynamically linked) đòi hỏi thêm công sức để đảm bảo thực hiện đúng cách.
Phần quan trọng nhất là đảm bảo rằng thư viện tĩnh chỉ được liên kết ở duy nhất một nơi. Có hai lựa chọn:
- Liên kết vào một dynamic framework, hoặc
- Liên kết trực tiếp vào app target.
Nếu được liên kết vào một dynamic framework, thư viện tĩnh có thể được công khai (expose) thông qua umbrella file và từ đó sử dụng ở bất cứ đâu framework đó được liên kết.
Nếu được liên kết vào app target, thư viện tĩnh hoặc static framework sẽ không thể được công khai trực tiếp ở nơi nào khác, nhưng có thể truyền sang các framework khác thông qua code, sử dụng một lớp trừu tượng (abstraction) nào đó. Điều tương tự cũng áp dụng cho static framework.
Ví dụ về một umbrella file dùng để công khai thư viện GoogleMaps đã được liên kết vào framework có thể là như sau:
Việc import file header của GoogleMaps vào trong umbrella file của framework sẽ công khai toàn bộ các header public của GoogleMaps, vì file GoogleMaps.h
đã bao gồm tất cả các header public của thư viện GoogleMaps:
Thư viện sẽ khả dụng ngay khi việc import MyFramework
diễn ra trước GoogleMaps
:
Trong trường hợp sử dụng framework GoogleMaps dạng static, cần phải sao chép bundle của nó vào trong ứng dụng vì GoogleMaps binary sẽ tìm kiếm các tài nguyên (như bản dịch, hình ảnh, v.v.) tại đó.
Kiểm tra thư viện
Hãy cùng xem một số lệnh hữu ích khi xử lý các vấn đề liên quan đến lỗi biên dịch hoặc khi nhận được một framework động (dynamic framework) đóng mã nguồn đã biên dịch sẵn, hoặc một thư viện tĩnh (static library). Để bắt đầu một cách nhanh chóng, chúng ta sẽ xem xét một binary rất quen thuộc: UIKit. Đường dẫn tới UIKit.framework
là:
Apple cung cấp nhiều công cụ khác nhau để khám phá các thư viện và framework đã biên dịch. Trong ví dụ với UIKit
, tôi sẽ chỉ trình bày một số lệnh thiết yếu mà tôi thấy rất hữu ích.
Định dạng tập tin Mach-O
Trước khi bắt đầu, điều quan trọng là bạn cần biết chúng ta sắp khám phá điều gì. Trong hệ sinh thái của Apple, định dạng tập tin của bất kỳ tệp nhị phân (binary) nào được gọi là Mach-O (viết tắt của Mach Object). Mach-O có một cấu trúc được định nghĩa trước, bắt đầu bằng Mach-O header (phần đầu), tiếp theo là segments (đoạn), sections (phần), load commands (lệnh tải), và các phần khác.
Vì bạn chắc chắn là một người đọc tò mò, nên lúc này bạn có thể đặt ra nhiều câu hỏi như: Mọi thứ này đến từ đâu? Câu trả lời rất đơn giản. Vì tất cả đều là một phần của hệ thống, bạn có thể mở Xcode và tìm kiếm tệp tại đường dẫn toàn cục:
/usr/include/mach-o/loader.h
Trong tệp loader.h
, ví dụ như, cấu trúc của Mach-O header được định nghĩa rõ ràng tại đó:
Khi trình biên dịch tạo ra tệp thực thi cuối cùng, Mach-O header sẽ được đặt tại một vị trí byte cụ thể trong tệp đó. Vì vậy, các công cụ làm việc với tệp thực thi biết chính xác nơi cần tìm để lấy thông tin mong muốn. Nguyên tắc tương tự cũng được áp dụng cho tất cả các phần khác của định dạng Mach-O.
Để khám phá thêm về tệp Mach-O, bạn nên đọc bài viết sau đây (không có đường dẫn trong đoạn gốc, nhưng thường là một bài hướng dẫn chuyên sâu).
Fat headers
Trước tiên, hãy xem tệp nhị phân có thể được liên kết với các kiến trúc nào (gọi là fat headers). Để làm điều đó, chúng ta sẽ sử dụng công cụ otool
— một tiện ích được tích hợp sẵn trong mọi bản macOS.
Để liệt kê các fat headers của một tệp nhị phân đã được biên dịch, ta sẽ dùng cờ -f
, và để tạo ra đầu ra dễ đọc hơn (hiển thị dạng biểu tượng), ta thêm cờ -v
.
otool -fv ./UIKit
Không có gì ngạc nhiên khi đầu ra cho thấy hai kiến trúc. Một là kiến trúc chạy trên máy Mac dùng chip Intel (x86_64) khi triển khai trên trình giả lập (simulator), và một là kiến trúc chạy trên iPhone cũng như trên các máy Mac dùng chip M1 mới ra mắt gần đây (arm64):
Khi lệnh được thực thi thành công mà không in ra bất kỳ đầu ra nào, điều đó đơn giản có nghĩa là tập tin nhị phân không chứa phần fat header. Nói cách khác, thư viện chỉ có thể chạy trên một kiến trúc duy nhất, và để xem đó là kiến trúc nào, ta cần in ra phần header Mach-O của tập tin thực thi.
otool -hv ./UIKit
Từ đầu ra của phần header Mach-O, chúng ta có thể thấy rằng cputype
là X86_64
. Chúng ta cũng có thể thấy thêm một số thông tin bổ sung như các flag
được sử dụng khi biên dịch thư viện, loại tệp (filetype), và các thông tin khác:
Loại tệp thực thi
Thứ hai, hãy xác định loại thư viện mà chúng ta đang làm việc. Để làm điều đó, chúng ta sẽ tiếp tục sử dụng công cụ otool
như đã đề cập ở trên. Phần header của Mach-O chỉ định loại tệp (filetype). Vì vậy, khi chạy lại otool
trên UIKit.framework
với các cờ -hv
, ta sẽ nhận được đầu ra như sau:
Từ loại tệp trong đầu ra, ta có thể thấy rằng đây là một thư viện liên kết động (dynamically linked library). Dựa vào phần mở rộng của nó, ta có thể xác định rằng đây là một framework được liên kết động. Như đã mô tả trước đó, một framework có thể được liên kết động hoặc tĩnh.
Ví dụ điển hình của một framework được liên kết tĩnh là GoogleMaps.framework. Khi chạy cùng một lệnh trên tệp nhị phân của GoogleMaps, từ đầu ra ta có thể thấy rằng tệp nhị phân KHÔNG được liên kết động vì loại của nó là OBJECT (tức là tệp đối tượng), điều này có nghĩa là thư viện đó là thư viện tĩnh và được liên kết vào tệp thực thi kèm theo trong quá trình biên dịch:
Lý do để gói thư viện tĩnh vào một framework là do cần phải bao gồm GoogleMaps.bundle, vì gói này cần được sao chép vào target để thư viện hoạt động đúng với các tài nguyên của nó.
Bây giờ, hãy thử chạy cùng một lệnh trên một thư viện tĩnh dạng archive. Ví dụ, ta có thể sử dụng một trong các thư viện của Xcode nằm tại đường dẫn:
/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphoneos/libswiftCompatibility50.a
Dựa vào phần mở rộng .a
của thư viện, ta có thể lập tức nhận ra đây là một thư viện tĩnh. Việc chạy lệnh otool -hv libswiftCompatibility50.a
chỉ xác nhận thêm rằng loại tệp (filetype) là OBJECT:
Mặc dù thư viện tĩnh dạng archive có phần mở rộng .a
rõ ràng là thư viện tĩnh, nhưng đối với framework, để chắc chắn rằng thư viện được liên kết động (dynamically linked), ta cần kiểm tra tệp nhị phân của nó để xác định loại tệp (filetype) trong phần tiêu đề Mach-O.
Phụ thuộc (Dependencies)
Thứ ba, hãy xem thư viện đang liên kết với những gì. Để làm điều đó, công cụ otool
cung cấp cờ -L
.
otool -L ./UIKit
Kết quả đầu ra liệt kê tất cả các phụ thuộc (dependencies) của framework UIKit. Ví dụ, bạn có thể thấy rằng UIKit đang liên kết với Foundation. Đó là lý do tại sao không còn cần phải import Foundation
khi đã import UIKit
vào một tệp mã nguồn:
Bảng ký hiệu (Symbols table)
Thứ tư, cũng rất hữu ích khi biết những ký hiệu (symbols) nào được định nghĩa trong framework. Để làm điều đó, tiện ích nm
có thể được sử dụng. Để in ra tất cả các ký hiệu bao gồm cả những ký hiệu dùng cho gỡ lỗi (debug), chúng ta thêm cờ -a
, và thêm cờ -C
để in ra các ký hiệu đã được “demangled”.
Name mangling (mã hóa tên) là một kỹ thuật thêm thông tin bổ sung về kiểu dữ liệu của ngôn ngữ (class, struct, enum, …) vào ký hiệu trong thời gian biên dịch, nhằm truyền đạt thêm thông tin cho trình liên kết (linker). Với một ký hiệu đã bị mã hóa tên, linker sẽ biết rằng ký hiệu đó thuộc về class, getter, setter, v.v… và có thể xử lý tương ứng.
nm -Ca ./UIKit
Thật không may, đầu ra ở đây rất hạn chế vì các ký hiệu được liệt kê là những ký hiệu định nghĩa chính framework động đó. Sự hạn chế này là do Apple phân phối các tệp nhị phân đã được làm rối (obfuscated), và khi thực hiện phân tích ngược nhị phân (ví dụ bằng bộ disassembler Radare2), tất cả những gì chúng ta thấy chỉ là một vài lệnh hợp ngữ kiểu add byte.
Tuy vẫn có thể trích xuất danh sách ký hiệu, nhưng để làm điều đó, chúng ta cần sử dụng lldb
và nạp framework UIKit vào bộ nhớ, hoặc trích xuất dấu vết bộ nhớ (memory footprint) của framework rồi phân tích khi đã giải mã. Tuy nhiên, việc này không nằm trong phạm vi của cuốn sách này.
Để minh hoạ cách các ký hiệu (symbols) sẽ trông như thế nào, tôi đã in ra các ký hiệu trong framework Realm đã biên dịch bằng cách chạy lệnh:
nm -Ca ./Realm
Có vẻ như Realm được phát triển bằng C++, nhưng ta vẫn có thể thấy rõ những loại symbol nào có trong binary. Hãy cùng xem thêm một ví dụ nữa, lần này với Swift và Alamofire. Trong trường hợp này, đáng tiếc là nm
không thể giải mã (demangle) được các symbol:
Để giải mã (demangle) Swift một cách thủ công, có thể sử dụng lệnh sau:
Lệnh này sẽ tạo ra tên biểu tượng đã bị mã hóa kèm theo phần giải mã (demangled) giải thích ý nghĩa của nó:
Chuỗi (Strings)
Cuối cùng nhưng không kém phần quan trọng, việc liệt kê tất cả các chuỗi (strings) mà tệp nhị phân chứa cũng có thể rất hữu ích. Điều này có thể giúp phát hiện các sai sót của lập trình viên như việc không mã hóa (obfuscate) các thông tin bí mật, cũng như một số chuỗi khác không nên xuất hiện trong tệp nhị phân. Để thực hiện điều đó, chúng ta sẽ sử dụng tiện ích strings
một lần nữa trên tệp nhị phân của Alamofire.
strings ./Alamofire
Kết quả đầu ra là danh sách các chuỗi văn bản thuần (plain text) được tìm thấy trong tệp nhị phân:
Hệ thống xây dựng (Build system)
Mảnh thông tin cuối cùng còn thiếu là cách mọi thứ được liên kết với nhau. Là các lập trình viên Apple, chúng ta sử dụng Xcode để phát triển các ứng dụng cho các sản phẩm của Apple, sau đó được phân phối qua App Store hoặc các kênh phân phối khác. Dưới nền, Xcode sử dụng Xcode Build System để tạo ra các tệp thực thi cuối cùng có thể chạy trên kiến trúc bộ xử lý X86 và ARM.
Hệ thống build của Xcode bao gồm nhiều bước phụ thuộc lẫn nhau. Nó hỗ trợ các ngôn ngữ dựa trên C (C, C++, Objective-C, Objective-C++) được biên dịch bằng clang, cũng như ngôn ngữ Swift được biên dịch bằng swiftc.
Hãy cùng điểm nhanh những gì Xcode thực hiện khi quá trình build được kích hoạt:
Tiền xử lý (Preprocessing)
Tiền xử lý thực hiện việc xử lý macro, loại bỏ chú thích, nhập (import) các tệp và v.v… Nói ngắn gọn, nó chuẩn bị mã nguồn cho trình biên dịch. Bộ tiền xử lý cũng xác định trình biên dịch nào sẽ được sử dụng cho từng tệp mã nguồn. Không có gì ngạc nhiên khi các tệp mã nguồn Swift sẽ được biên dịch bởi swiftc, còn các tệp thuộc họ C sẽ dùng clang.
Trình biên dịch (Compiler: swiftc, clang)
Như đã đề cập, hệ thống build của Xcode sử dụng hai trình biên dịch: clang và swiftc. Trình biên dịch gồm hai phần: phần front-end và phần back-end. Cả hai trình biên dịch này sử dụng cùng một phần back-end là LLVM (Low-Level Virtual Machine) và phần front-end riêng biệt tùy ngôn ngữ.
Nhiệm vụ của trình biên dịch là biên dịch các tệp mã nguồn sau khi đã được tiền xử lý thành các tệp đối tượng (object files) chứa mã đối tượng (object code). Mã đối tượng đơn giản là các chỉ thị dạng hợp ngữ có thể được CPU hiểu và thực thi.
Trình hợp dịch (Assembler – asm)
Trình hợp dịch nhận đầu ra từ trình biên dịch (mã hợp ngữ) và tạo ra mã máy có thể di dời (relocatable machine code). Mã máy là ngôn ngữ được bộ xử lý cụ thể (như ARM, X86) nhận biết.
Khác với mã máy có thể di dời, mã máy tuyệt đối (absolute machine code) có vị trí được cố định trong bộ nhớ. Trong khi đó, mã có thể di dời có thể được loader đặt ở bất kỳ vị trí nào trong bộ nhớ khi chạy chương trình.
Trình liên kết (Linker – ld)
Bước cuối cùng của hệ thống build là liên kết (linking). Trình liên kết là một chương trình dùng để lấy các tệp đối tượng (được biên dịch từ nhiều tệp mã nguồn) và liên kết (kết hợp) chúng lại với nhau, dựa trên các symbol mà các tệp này sử dụng cũng như các thư viện tĩnh và động nếu cần.
Để liên kết được với các thư viện, linker cần biết đường dẫn đến nơi chứa chúng.
Kết quả cuối cùng của bước này là một tệp đơn duy nhất: tệp thực thi Mach-O.
Trình nạp (Loader)
Sau khi tệp thực thi được tạo ra, loader chịu trách nhiệm tải tệp thực thi vào bộ nhớ và khởi chạy chương trình.
Loader là một chương trình hệ thống hoạt động ở cấp độ nhân hệ điều hành (kernel level). Nó phân bổ không gian bộ nhớ và tải tệp Mach-O vào đó.
Giờ thì bạn đã có cái nhìn tổng quan về các giai đoạn chính mà hệ thống build của Xcode thực hiện khi bạn bắt đầu quá trình build.
Kết luận
Tôi hy vọng chương này đã mang lại cho bạn cái nhìn rõ ràng về sự khác biệt cốt lõi giữa thư viện tĩnh và thư viện động, cũng như cung cấp một số ví dụ minh họa cụ thể để kiểm tra chúng.
Đó là một lượng kiến thức khá lớn cần tiếp thu, nên bây giờ có lẽ là lúc để bạn thưởng thức một ly espresso đôi hoặc bất kỳ loại thức uống giải khát yêu thích nào.
Tôi thực sự khuyến khích bạn đào sâu hơn nữa vào chủ đề này. Dưới đây là một số tài liệu tôi đề xuất:
Exploring iOS-es Mach-O Executable Structure
Difference in between static and dynamic library from our beloved StackOverflow
Dynamic Library Programming Topics
Xcode Build System : Know it better
Behind the Scenes of the Xcode Build Process
Used binaries: