Tại sao một chương trình không thể chạy trên nhiều platform khác nhau?

Maxwell Nguyen
SotaTek
Published in
15 min readNov 5, 2018

Một chương trình máy tính, thường được định nghĩa là một tập hợp các lệnh, nhằm mục đích thực hiện một nhiệm vụ cụ thể khi được thực thi bởi máy tính.

Tất nhiên, các chương trình lưu dưới dạng source code như PHP, Python, Javascript,… của chúng ta hoàn toàn có thể chạy trên nhiều platform rất khác nhau, chỉ cần có engine tương ứng. Source code Java thì không thể thực thi, nhưng chương trình được biên dịch ra Java bytecode thì cũng có khả năng thực thi xuyên platform với khác biệt gần như không đáng kể (Hoặc thỉnh thoảng thì khác biệt ấy là rất đáng kể, vậy nên khẩu hiệu Write once, run anywhere của Sun thường được chế thành Write once, debug everywhere).

Ngoại lệ duy nhất là các chương trình dưới dạng machine code (assembly nữa, dĩ nhiên rồi). Và vì machine language trên thực tế là thứ ngôn ngữ duy nhất mà máy tính có thể hiểu và thực thi, cũng có thể coi là đúng khi diễn đạt rằng một chương trình không thể chạy trên nhiều platform khác nhau.

Nếu đã từng có kinh nghiệm (tức là đã từng chạy được Hello World, theo ngôn ngữ của Salers và HR) với C++, ta biết rằng nếu muốn code chạy trên một platform khác, ta phải dùng cross-compiler, chỉ định một target platform, để biên dịch một file machine code hoàn toàn mới từ mã nguồn.

Theo như định nghĩa thông thường, ngày nay, một platform thường được hiểu là một sự kết hợp giữa một kiến trúc phần cứng và một phiên bản hệ điều hành. Vậy cụ thể thì từ định nghĩa này, một platform ảnh hưởng đến machine code của chúng ta như thế nào? Tác giả xin chia sẻ quan điểm về câu hỏi này dựa trên kinh nghiệm làm việc tại https://sotatek.com.

CPU

Trước hết, machine code được viết bằng machine language, nhưng machine language không phải là một ngôn ngữ duy nhất. Machine language là ngôn ngữ mà một CPU có khả năng đọc hiểu và thực thi, và machine language thì không giống nhau với mỗi thế hệ CPU. Mỗi dòng CPU có Instruction Set Architecture (ISA) khác nhau, vậy nên, yếu tố đầu tiên của một platform khiến machine code bị phụ thuộc là kiến trúc CPU.

https://en.wikipedia.org/wiki/X86_instruction_listings

Hãy cùng nhìn qua tập instructions của các dòng CPU x86, ta sẽ thấy có ít nhiều sự khác biệt, không chỉ giữa các processor của các hãng khác nhau Intel hay AMD, mà còn giữa mỗi thế hệ processor trong cùng một hãng. Tập chỉ lệnh này tương tự như keywords trên những ngôn ngữ bậc cao, nó là thứ trực quan nhất cho ta thấy một machine code không thể chạy trên nhiều ISA khác nhau. Đây mới là list instructions của các CPU cùng họ x86, sự khác biệt giữa các CPU khác họ, ví dụ như giữa dòng x86 với các CPU dòng ARM thì càng xa hơn nữa.

Nhưng đó chưa phải là tất cả. ISA của mỗi CPU còn quy định sự khác biệt về supported data types, byte order, word size, số lượng, tên gọi và mục đích của các registers, cơ chế quản lý và sử dụng main memory, mô hình I/O, cơ chế lan truyền và xử lý exception, cơ chế xử lý interrupting,… Tất cả những khác biệt này khiến cho một machine code không thể nào thực thi thành công trên 2 CPU có ISA khác nhau, dù cho giống hệt về intructions set.

May mắn là AMD và Intel tương thích khá tốt với nhau, một chương trình trên CPU của hãng này có thể chạy khá tốt trên CPU của hãng khác. Sự khác biệt chủ yếu nằm ở tầng thấp hơn, nơi implement sự thực thi cho từng instruction. Các CPU thường cũng cung cấp opcode CPUID giúp xác định kiến trúc ở runtime, nhằm quản lý những hành vi riêng của từng kiến trúc. Nhờ những yếu tố này, hiếm khi nào chúng ta phải biên dịch source code cho từng kiến trúc CPU khác nhau.

https://en.wikipedia.org/wiki/CPUID

Vậy, machine code của chúng ta có thể thoải mái chạy trên tất cả các máy có chung ISA?

Câu trả lời là CÓ, nếu chúng ta load chương trình trực tiếp từ boot sector. Và CÓ THỂ KHÔNG, nếu chương trình được load bằng hệ điều hành.

Boot sector là một vùng nhớ thứ cấp, được CPU’s built-in firmware load vào RAM ngay sau khi máy được khởi động thành công. Thông thường, nơi đây sẽ chứa phần đầu tiên của boot loader — giúp tải và khởi động chương trình đầu tiên của gần như tất cả hệ thống hiện đại: Hệ Điều Hành.

Một chương trình được boot trực tiếp từ boot sector thường được gọi là bare-metal program, sẽ phải tự chuyển CPU mode (Ví dụ với các chip x86–64, là chuyển từ Real Mode mặc định sang Protected Mode, hỗ trợ cơ chế bảo vệ tài nguyên và có khả năng xử dụng nhiều main memory hơn, hoặc từ Protected Mode sang Long Mode support 64 bit), phải có phần code tự quản lý tất cả tài nguyên hệ thống như cài đặt cơ chế paging cho main memory, tự cấp phát và quản lý main memory, cài đặt handlers xử lý I/O, interrupting và exceptions,… Những công việc này rất phức tạp và dễ sai sót, OS sẽ nhận lấy phần trách nhiệm này, giúp chúng ta tối giản hoá công việc cần làm và tập trung vào từng nghiệp vụ cụ thể của bản thân.

Operation System

Việc đẩy quyền hạn và trách nhiệm quản lý tài nguyên hệ thống cho OS dẫn đến kết quả là trừ khi chương trình của chúng ta không sử dụng các tài nguyên này, còn nếu không thì chương trình sẽ phải bị lệ thuộc vào interface của OS. Một chương trình không sử dụng các tài nguyên như thế, đồng nghĩa với việc không đọc/ghi files, viết output lên console, không sử dụng các hardware devices, không sử dụng internet,… Nói cách khác, một chương trình như vậy là một chương trình hoàn toàn vô dụng.

Và với mong muốn viết một chương trình ít nhiều có tác dụng gì đó (chí ít là in ra console Hello World), chúng ta sẽ phải làm quen với yếu tố tiếp theo bên cạnh yếu tố computer hardware, cũng là yếu tố cuối cùng trong định nghĩa thông thường của một platform: OS.

Platform ABI

Sự khác biệt dễ nhận thấy nhất về ảnh hưởng của OS là định dạng tệp thực thi. Executable file của Window thường có extension .exe, của Linux thì không có extension, MacOS thì tương tự Linux,… Tất cả những file này không phải là raw machine code mà có thêm rất nhiều thành phần khác, tuỳ thuộc vào yêu cầu của OS.

Ngoài phần raw machine code, executable file còn chứa thêm các thông tin như:
- Static variables mà chương trình định nghĩa
- Entry point của chương trình (Ví dụ địa chỉ phần khai báo của hàm main)
- Target architecture
- Target ABI version
- Relocation information
- Vân vân…

Không phải executable file format nào cũng support toàn bộ các tính năng trên, hãy cùng xem qua bảng so sánh những executable file format phổ biến:

https://en.wikipedia.org/wiki/Comparison_of_executable_file_formats

Ví dụ về cấu trúc một executable file theo định dạng ELF của Linux:

https://en.wikipedia.org/wiki/Executable_and_Linkable_Format

Nếu không có kinh nghiệm lập trình hệ thống, khả năng cao là bạn sẽ xa lạ với khái niệm ABI nhưng quen thuộc với một khái niệm liên quan là API.

API — Application Program Interface là một đặc tả định nghĩa giao diện giao tiếp giữa các thành phần phần mềm ở mức source code. Tức là các hàm và tham số trong một chương trình. Ở mức cao hơn, API có thể định nghĩa các giao diện giao tiếp giữa các process trên cùng một máy thông qua các Interprocess Communication (IPC), hoặc thậm chí giữa các process khác nhau qua mạng nội bộ, mạng internet.

Nếu API định nghĩa giao diện ở mức source code, ABI — Application Binary Interface định nghĩa giao diện ở một cấp thấp hơn: Binary. Diễn đạt theo một cách đơn giản hơn, API chứa định nghĩa những function có thể gọi và các tham số cần thiết, ABI chứa định nghĩa cách gọi những function ấy và cơ chế truyền đối số sử dụng machine language. Đặc tả quy ước phương pháp thực hiện lời gọi hàm của một ABI được gọi là calling conventions. Đây có thể coi như là một trong những đặc tả quan trọng nhất của một ABI.

Bản thân CPU có hiểu biết rất nghèo nàn về function. Thông thường, lời gọi một function được CPU thực hiện đơn giản bằng cách di chuyển con trỏ IP (Hoặc EIP trên kiến trúc 32 bit) - con trỏ lưu địa chỉ ô nhớ trên main memory của lệnh tiếp theo cần thực thi - đến địa chỉ khai báo của hàm. Việc tạo stack frame lưu trữ các giá trị nội bộ của hàm bao gồm cả các đối số được truyền vào, việc xoá các trá trị này để giải phóng bộ nhớ sau khi thực hiện xong hàm, cùng với việc lưu trữ địa chỉ của caller để di chuyển con trỏ IP trở lại sau khi hàm return được quy ước bởi ABI. Cụ thể, một calling conventions ít nhất cần quy ước những điều sau:

  • Cách các parameters được truyền vào function (lưu parameters vào stack hoặc các thanh registers được định sẵn, hoặc lưu parameters vào cả registers lẫn stack. Nếu lưu parameters vào registers thì cần backup các giá trị hiện tại trên các thanh register đó và restore sau khi return)
  • Cơ chế clean các parameters và biến nội bộ (caller hay callee sẽ là người clean, thao tác xoá và restore các data trở về trạng thái trước đó ra sao)
  • Giá trị trả về sẽ được lưu vào đâu, caller sẽ đọc giá trị trả về như thế nào
  • Các exceptions nếu có sẽ được lan truyền và xử lý ra sao

Một số ví dụ về calling conventions:

https://en.wikibooks.org/wiki/X86_Disassembly/Calling_Convention_Examples#Example:_C_Calling_Conventions

Do đặc tả giao diện ở tầng source code, API rất ít phụ thuộc vào platform. Ngược lại, vì là đặc tả ở tầng binary, ABI phụ thuộc chặt chẽ vào từng platform khác nhau. Vậy nên, về cơ bản, khi cần chương trình có cần được chạy trên các platform khác nhau, ta chỉ cần biên dịch lại với target platform phù hợp để compiler thay thế ABI tương ứng mà không cần sửa đổi mã nguồn.

Nhìn vào header của một file ELF, ta thấy xuất hiện field e_ident lưu phiên bản ABI mong muốn của executable file. Giá trị mặc định 0x00 là phiên bản thường dùng của các Linux distributions: SystemV.

System V Application Binary Interface định nghĩa các quy ước gọi hàm bằng machine language, định dạng tệp thực thi, cơ chế liên kết thư viện,… Bản thân cấu trúc ELF là một phần của System V Application Binary Interface.

Chúng ta đều biết rằng, để có thể thực thi, mã của chương trình phải được load vào main memory. Phiên bản trên main-memory của một chương trình được load trực tiếp từ boot sector sẽ giống hệt như file machine code lưu trên secondary-memory. Lệnh đầu tiên của chương trình sẽ được nạp vào địa chỉ 0x00000000 trên main memory và tăng dần đến hết. Register IP (Hoặc EIP) cũng sẽ được khởi tạo giá trị là 0x00000000. Nơi đây được gọi là entry point của chương trình, tức là dòng lệnh đầu tiên sẽ được thực thi khi khởi chạy.

Khi sử dụng OS, chương trình được load bằng một thành phần gọi là loader. Loader sẽ đọc thông tin từ executable file, kiểm tra quyền hạn và kiến trúc đích của executable file, khởi tạo sẵn các giá trị static, truyền các đối số từ lời gọi vào chương trình, thay đổi giá trị của EIP bằng giá trị của field e_entry trong file header. Điều này cho phép hàm main của chương trình có thể được khai báo ở một vị trí bất kỳ trong machine code thay vì vị trí đầu tiên.

Cùng với đó, OS có thể thay đổi địa chỉ cơ sở 0x00000000 của chương trình, relocation information của file sẽ giúp OS cập nhật các địa chỉ được tham chiếu trong machine code tương ứng với sự thay đổi của địa chỉ cơ sở. Nhờ tính năng relocate address, chương trình có thể được load vào bất kỳ vị trí nào trên memory thay vì 0x00000000, giảm sự phụ thuộc vào trạng thái địa chỉ của main memory, đồng thời tạo khả năng chạy nhiều instance của chương trình trên memory.

Trên thực tế, do sự phổ biến của memory paging, tính năng cho phép ánh xạ địa chỉ logic với tới một địa chỉ vật lý riêng biệt theo một cơ chế được OS định nghĩa, chương trình hiện đại không cần relocate address để có thể được load vào vị trí bất kỳ trên main memory nữa. Mỗi địa chỉ logic mà chương trình sử dụng sẽ được OS ánh xạ đến một địa chỉ vật lý riêng biệt mà bản thân chương trình không cần quan tâm và không thể biết trước. Điều này cho phép mỗi instance của chương trình được nạp và thực thi trong một memory sandbox riêng mà bản thân nó không tự ý thức được, mỗi chương trình đều tưởng bản thân là process duy nhất trên main memory với địa chỉ cơ sở là 0x00000000.

Nhưng không phải vì thế mà tính năng relocate address không còn quan trọng. Bằng việc sử dụng loader để nạp chương trình, phiên bản sau khi nạp trên main memory của chương trình không nhất thiết phải là bản sao của phần raw machine code trong executable file nữa. Các OS ngày nay cung cấp một cơ chế gọi là shared library, hay dynamic linking library. Chương trình của bạn có thể sử dụng các thư viện OS hỗ trợ mà không cần phải thêm đoạn code của thư viện vào executable file như cơ chế sử dụng static linking library. Bản thân thư viện là một object file - một file có cấu trúc tương tự như executable file nhưng không có quyền thực thi. Mã của thư viện sẽ được nạp vào memory tại thời điểm mà một executable file được load, hoặc trong runtime của một process. Phiên bản mã chương trình trên memory của chúng ta sẽ trở thành một bản hợp nhất gồm raw machine code trong executable file và machine code của các library liên quan. Nếu có nhiều process cùng yêu cầu một thư viện, mã của thư viện sẽ chỉ được load một lần duy nhất và được chia sẽ giữa memory sandbox của các process khác nhau. Cần lưu ý là địa chỉ ô nhớ của thư viện sẽ giống nhau trên mọi process và có thể khác với địa chỉ mong muốn của thư viện trong raw machine code, do đó relocation information sẽ có tác dụng giúp cập nhật địa chỉ thư viện trong raw machine code của executable file.

Trở lại với calling convention, thực tế nếu chương trình chỉ tự gọi các function trong nội bộ bản thân nó, nó có thể tự định nghĩa calling conventions của riêng mình. Nhưng khi có ý định sử dụng các shared libraries, các thư viện và chương trình bắt buộc phải tuân thủ theo cùng một calling convention để các thành phần binary này có thể giao tiếp với nhau.

Từ những lý do trên, ta có thể thấy rằng chương trình bị phụ thuộc chặt chẽ vào ABI của từng platform. Nếu không tuân thủ ABI, OS loader không thể load chương trình vào memory hoặc chương trình sẽ không thể tương tác với các thư viện bên ngoài.

Operating System API

Không như một chương trình bare-metal, các chương trình được load bởi OS không chứa những đoạn code quản lý, xử lý tài nguyên hệ thống. Thực tế là dù có cố đưa những đoạn code sử dụng tài nguyên hệ thống vào chương trình thì nó cũng không thể được thực thi. CPU Intel các phiên bản sau này cung cấp một chế độ thực thi gọi là Protected Mode, kết hợp với khả năng phân quyền vùng nhớ thông qua cài đặt paging được support bởi Memory Management Unit (MMU), giúp giới hạn quyền truy cập và sử dụng các port I/O, interrupting, main memory,… CPU thường cung cấp 4 mức quyền hạn cho từng phân vùng code trên main memory, tương ứng với Privilege Ring từ 0 đến 3, trong đó ring 0 có nhiều quyền hạn nhất và ring 3 có ít quyền hạn nhất. Mỗi yêu cầu thực thi, truy cập tài nguyên hệ thống đều có thể được cài đặt một mức yêu cầu quyền hạn tối thiểu.

Ngay sau khi được nạp từ bootloader, OS đã tiến hành tạo một phân vùng memory lưu trữ kernel code và data, gọi là kernel space với ring 0, sau đó cài đặt phân quyền và dành quyền quản lý tài nguyên cho kernel space. Tất cả các phần code sau này được OS load vào một phân vùng memory có quyền hạn thấp nhất (ring 3), gọi là user space. Mọi thao thác tới tài nguyên hệ thống đều phải xin phép kernel space thông qua API mà OS cung cấp.

Có một lầm tưởng khá phổ biến về trạng thái tồn tại của kernel space rằng kernel chạy như một process độc lập trong hệ thống. Như đã biết, mỗi lõi CPU chỉ có thể thực thi duy nhất 1 chương trình tại một thời điểm. Nếu một CPU đơn lõi nạp và thực thi một chương trình của người dùng, một khi chương trình người dùng không chủ động trả quyền thực thi lại cho kernel mà tiếp tục giữ mãi CPU, kernel code sẽ không có cách nào can thiệp buộc user process dừng lại. Thực tế, tương tự như shared libraries, kernel space được load vào memory ngay tại thời điểm load user program, và trở thành một phần trong mã-trên-memory của chương trình. Tại bất kỳ thời điểm nào, luôn luôn có những phần code kernel sẵn sàng handle các interrupting, exception và chiếm quyền sử dụng CPU của user space.

Khi kernel space trở thành một phần của chương trình bên cạnh user space tương tự shared libraries, cách user space gọi một API từ kernel space tương tự như việc gọi một function trong shared libraries: Thông qua một calling convention.

Các API mà OS cung cấp để user space yêu cầu kernel space thực hiện một nhiệm vụ, mà bản thân user space không đủ thẩm quyền, được gọi là system calls. Quy ước gọi system call được gọi là system call calling convention. Quy ước này có sự khác biệt nhất định với các calling conventions khác, giúp tách bạch hoàn toàn phần code, stack và data của 2 spaces. Đồng thời quy ước này còn có tác dụng thay đổi current privilege level từ ring 3 lên ring 0 và từ ring 0 xuống ring 3.

Quy ước này, một lần nữa lại khác nhau tuỳ thuộc platform. Không những vậy, system calls mà mỗi phiên bản OS cung cấp cũng đều khác nhau, cả về số lượng, chức năng, số lượng tham số và thứ tự tham số.

Đây là một ví dụ về system call table thông thường của một Linux distribution hiện tại và system call tables của các kernel Windows NT. Có thể thấy rõ sự khác biệt là rất lớn và kể cả có thoả mãn được OS loader lẫn ABI, chẳng thể nào một chương trình compile cho Linux có thể chạy bình thường trên Window và ngược lại.

https://syscalls.kernelgrok.com/

https://github.com/j00ru/windows-syscalls

Trên thực tế, có một dạng chương trình gọi là compatibility layer cố gắng cung cấp một giao diện cho phép thực thi executable file của một plarform khác trên host platform. Nổi tiếng nhất trong đó là project WINE, cho phép thực thi định dạng file PE của Windows trên Linux system. WINE ban đầu là viết tắt của Windows Emulator, sau này được đổi thành viết tắt của Wine Is Not an Emulator do thực tế WINE thực thi file PE native thay vì thông qua một emulator hay một lớp ảo hoá nào.

Để thực thi được file PE native, WINE dịch các system calls của Window sang các system callls tương ứng trên Linux, cung cấp các system libraries, dựng một virtual file system mới,… Tất nhiên, sự khác biệt giữa các platform là quá lớn để các compatibility layer có thể đảm bảo chương trình hoạt động hoàn toàn chính xác trên cả foreign lẫn host platform.

Tổng kết

Sau khi lướt qua những yếu tố mà một platform tác động đến machine code ở trên, ta có thể tổng kết lại, một chương trình machine code, tức một tập hợp các lệnh được viết bằng machine language sẽ phụ thuộc vào một platform cụ thể thông qua các yếu tố sau, dẫn đến kết quả là một chương trình đã biên dịch sang machine code không thể được thực thi trên một platform khác:

  • Instruction Set Architecture (ISA)
  • Platform’s Application Binary Interface (ABI)
  • Operating System’s Application Program Interface (API)

Những yếu tố được đưa vào đánh giá trong bài viết có thể chưa đầy đủ hoặc chính xác, vậy nên bài viết chống chỉ định với người đọc quá dễ tin tưởng các bài viết trên mạng. Hẹn gặp lại các bạn trong một bài viết chưa đầy đủ và thiếu chính xác khác.

Các references trong bài:

--

--