Trình biên dịch và quá trình biên dịch chương trình C

Chúng ta sử dụng ngôn ngữ bậc cao để viết chương trình, máy tính lại sử dụng ngôn ngữ máy dạng nhị phân.Vậy, làm thế nào để máy tính có thể hiểu và thực thi chương trình của chúng ta? => Chính nhờ vào quá trình biên dịch – Compilation process.

Quá trình này đóng vai trò chuyển đổi ngôn ngữ bậc cao thành ngôn ngữ máy mà vi xử lý có thể đọc và thực thi.

Tại bài viết này, mình sẽ giới thiệu chi tiết về trình biên dịch (Compiler) và quá trình biên dịch với bốn giai đoạn, hướng dẫn thực hành chạy các lệnh để các bạn hiểu rõ hơn về cơ chế hoạt động của quá trình này.

 1. Trình biên dịch

1.1. Giới thiệu về trình biên dịch

  • Trước hết chúng ta cùng tìm hiểu biên dịch là gì?

=> Biên dịch là quá trình chuyển đổi các dòng lệnh được viết bằng ngôn ngữ bậc cao (C/C++, Python, JavaScript,…) thành ngôn ngữ máy để máy tính có thể hiểu và thực thi chương trình.

  • Muốn biên dịch thì ta cần sử dụng công cụ gì?

=> Sử dụng trình biên dịch để thực hiện quá trình biên dịch một chương trình. Trình biên dịch rất phổ biến được sử dụng để biên dịch chương trình C là GCC (GNU Compiler Collection).

  • Vậy GCC là gì? GNU là gì?

Hình 1. Logo GCC và hệ điều hành GNU

=> Trên website chính thức của gcc-gnu, có định nghĩa sau về GCC:

The GNU Compiler Collection includes front ends for C, C++, Objective-C, Fortran, Ada, Go, D, Modula-2, and COBOL as well as libraries for these languages (libstdc++,…). GCC was originally written as the compiler for the GNU operating system. The GNU system was developed to be 100% free software, free in the sense that it respects the user’s freedom.(Tham khảo tại đây)

 

Chúng ta cùng phân tích chi tiết về định nghĩa trên:

1/ “The GNU Compiler Collection includes front ends for C, C++, Objective-C, Fortran, Ada, Go, D, Modula-2, and COBOL as well as libraries for these languages (libstdc++,…). GCC was originally written as the compiler for the GNU operating system.

→ GCC là viết tắt của “GNU Compiler Collection” – tập hợp các trình biên dịch trên hệ điều hành GNU.

→ front end: là phần phân tích và xử lý mã nguồn đầu vào, chịu trách nhiệm phân tích cú pháp và ngữ nghĩa.

→ libraries: GCC còn cung cấp các thư viện hệ thống cho các ngôn ngữ. Một số thư viện hệ thống như: stdio.h, string.h, stdlib.h (ngôn ngữ C);  iostream, vector, queue (ngôn ngữ C++),…

  • Thời kỳ đầu phát triển, GCC ra đời với phiên bản GCC 1.0 là trình biên dịch chỉ được sử dụng cho ngôn ngữ C. GCC tại thời điểm này là viết tắt của GNU C Compiler.
  • Sau quá trình phát triển, GCC dần được mở rộng cho các ngôn ngữ C++, Objective-C, Fortran, Ada, Go, D, Modula-2, and COBOL. GCC thay đổi tên gọi đầy đủ là GNU Compiler Collection – tập hợp các trình biên dịch trên GNU.
  • GCC được phát triển là trình biên dịch chủ chốt cho dự án GNU. GCC phát hành lần đầu tiên vào năm 1987.

 

2/ “The GNU system was developed to be 100% free software, free in the sense that it respects the user’s freedom.

  • GNU là một dự án phần mềm mã nguồn mở được khởi xướng bởi Richard Stallman. Nhằm tạo ra một hệ điều hành tự do, tương thích với Unix nhưng không sử dụng mã nguồn độc quyền từ Unix. GNU là viết tắt của “GNU’s Not Unix” – ứng dụng đệ quy một cách thú vị trong đặt tên.
  • Các bạn có thể tìm đọc chi tiết về triết lý tôn trọng quyền tự do của người dùng tại đây.

GCC được phát triển dựa trên triết lý mã nguồn mở và quyền tự do người dùng. Nhờ đó các nhà phát triển có thể hiệu chỉnh để tương thích với các hệ điều hành và phần cứng khác nhau như Linux, MacOS, Window. GCC trên các hệ điều hành:

  • Linux và các bản phân phối của Linux hầu hết đã được tích hợp sẵn GCC hoặc cài đặt rất dễ dàng với các câu lệnh.
  • MacOS mặc định sử dụng Clang làm trình biên dịch cho các công cụ. Các câu lệnh gcc hay g++ mà chúng ta sử dụng thực chất là gọi đến Clang. Để sử dụng chính xác phiên bản GCC trên MacOS, một cách phổ biến là cần cài đặt Homebrew.
  • Window là môi trường phát triển mạnh mẽ với các bản phân phối của GCC, phổ biến nhất là MinGW, ngoài ra còn có MSYS2, Cygwin, WSL.

1.2. Trình biên dịch chéo

Hình 2. Trình biên dịch chéo biên dịch mã nguồn trên chip Intel tạo file thực thi trên chip ARM

Một ví dụ thực tế, khi bạn lập trình cho vi điều khiển, bạn viết mã chương trình trên máy tính Window với chip Intel (Machine A), chương trình được biên dịch với mục tiêu chạy trên vi điều khiển chip ARM (Machine B). Vậy thì các trình biên dịch mà chúng ta đã đề cập trên không thể hỗ trợ chúng ta được việc này. Khi đó chúng ta cần sử dụng đến Trình biên dịch chéo, công cụ này sẽ có khả năng biên dịch mã nguồn trên máy tính Intel, rồi tạo file thực thi có thể được nạp và chạy trên chip ARM. Các bạn xem minh hoạ tại hình 2.

Vậy, trình biên dịch chéo là một loại trình biên dịch có thể tạo mã thực thi cho một nền tảng khác với nền tảng mà trình biên dịch đang chạy. Như ví dụ trên, nền tảng bạn đang chạy là Windows/Intel, nền tảng khác chính là ARM.

Một số IDE hỗ trợ các trình biên dịch chéo được sử dụng phổ biến để biên dịch cho các dòng vi điều khiển như:

  • STM32CubeIDE hỗ trợ GNU Arm Embedded Toolchain Compiler, được sử dụng phổ biến bởi các dòng vi điều khiển STM32.
  • Keil MDK (Microcontroller Development Kit) hỗ trợ ARM Compiler, IDE được sử dụng với các vi điều khiển kiến trúc ARM Cortex-M như STM32, NXP LPC, Renesas RX
  • Texas Instruments Code Composer Studio (CCS) hỗ trợ MSP430-CGT Compiler với các vi điều khiển MSP430

2. Quá trình biên dịch một chương trình C

2.1. Tổng quan

Quá trình biên dịch trong C được chia thành nhiều giai đoạn với các nhiệm vụ và mục tiêu cụ thể. Các giai đoạn được thực hiện theo trình tự hiệu quả, kiểm tra cú pháp và tạo ra đầu ra có thể thực thi.

Hình 3. Bốn giai đoạn của quá trình biên dịch

Quá trình biên dịch được chia làm 4 giai đoạn chính:

  • Preprocessor: xử lý các chỉ thị tiền xử lý (#include, #define,…)
  • Compiler: dịch ngôn ngữ bậc cao sang Assembly, kiểm tra cú pháp và tối ưu code (optimize)
  • Assembler: dịch Assembly sang ngôn ngữ máy (object code)
  • Linker: kết hợp object code với thư viện thành file thực thi (excutable code)

2.2. Cách biên dịch và thực thi chương trình C

Mình sẽ hướng dẫn các bạn các bước để biên dịch và thực thi một chương trình C đơn giản.

1/ Tạo source file

Tạo file source với tên tapit.c với nội dung dưới đây.

2/ Biên dịch chương trình

Mở cửa sổ Command prompt (hoặc Terminal) trỏ đến thư mục chứa file tapit.c và chạy command sau:

  • Window: gcc tapit.c -o tapit.exe
  • Linux/MacOS: gcc tapit.c -o tapit

Các giai đoạn của quá trình dịch sẽ được triển khai và file thực thi sẽ được sinh ra: tapit.exe (Windown) và tapit (Linux/MacOS)

3/ Thực thi chương trình

Chạy command sau:

  • Window: ./tapit.exe
  • Linux/MacOS: ./tapit

Chúng ta thu được kết quả tại hình 4 dưới đây:

Hình 4. Biên dịch và thực thi chương trình bằng command

2.3. Phân tích các giai đoạn của quá trình biên dịch

1/ Preprocessor – Tiền xử lý

Giai đoạn này sẽ thực hiện việc nhận mã nguồn (source code, file chương trình có phần mở rộng .c) và mở rộng mã nguồn với các bước:

  • Xóa bỏ comment: đây là phần nội dung không cần thiết trong việc chương trình thực thi nên sẽ được bộ xử lý loại bỏ trước tiên.
    • Comment là các chú thích thường được người lập trình ghi lại để mô tả những đặc điểm, chức năng của dòng code, đoạn code, giúp cho người đọc dễ đọc hiểu được ý nghĩa của chương trình dễ dàng hơn. Chúng ta còn thường gặp các chú thích về quyền sở hữu, chú thích mô tả được các công cụ tự động sinh ra.
  • Xử lý các lệnh gọi macro (Preprocessor directives – #): Các thành phần được xử lý bởi Preprocessor bao gồm:
    • Preprocessor directives – Chỉ thị tiền xử lý: #define, #if, #endif, #include, …
    • Preprocessor operators – Toán tử tiền xử lý: #, #@, ##
    • Predefined macros – Macro được định nghĩa trước
    • Pragmas: #pragma, __pragma, _Pragma

Trong các thành phần trên, chỉ thị tiền xử lý thường được sử dụng phổ biến hơn các thành phần còn lại. Ví dụ như #include, #define, #undef, #if, #else,… 

  • #include: Chỉ thị bao hàm tệp. Quá trình tiền xử lý sẽ tìm kiếm header file và sao chép toàn bộ nội dung của header file vào ngay vị trí câu lệnh include.
  • #define: hoạt động với cơ chế tìm kiếm và thay thế các định danh bởi giá trị / chuỗi ký tự.
  • Ở giai đoạn này, file đầu vào có đuôi .c và đầu ra là file đuôi .i
  • Tham khảo chi tiết tại: https://tapit.vn/preprocessor-bo-tien-xu-ly-preprocessor-directives-chi-thi-tien-xu-ly

Note: Command thực hiện giai đoạn Preprocessor (.c -> .i)

gcc -E <filename>.c -o <filename>.i

Chúng ta thu được kết quả tại hình 5 dưới đây:

Hình 5. Thực hiện giai đoạn Preprocessor với command

2/ Compiler – dịch ngôn ngữ bậc cao sang Assembly.

Tại giai đoạn này, trình biên dịch sẽ:

  • Translation:
    • Phân tích cú pháp (syntax) của mã nguồn ngôn ngữ bậc cao.
    • Sau đó, chuyển đổi chúng sang dạng mã Assembly là một ngôn ngữ bậc thấp (hợp ngữ) gần với tập lệnh của bộ vi xử lý.
  • Code optimization:
    • Là quá trình trình biên dịch tự động thực hiện các thay đổi đối với mã nguồn để cải thiện hiệu suất chương trình. Bản chất của loại tối ưu này là trình biên dịch sẽ loại bỏ đi nhu cầu đọc và ghi của một số biến không cần thiết trong chương trình.
    • Các bạn tham khảo tại đây.
  • File đầu vào giai đoạn này là file có đuôi .i và đầu ra là file đuôi .s.

Note: Command thực hiện giai đoạn Compiler (.i -> .s)

gcc -S <filename>.i -o <filename>.s

Chúng ta thu được kết quả tại hình 6 dưới đây:

Hình 6. Thực hiện giai đoạn Compiler với command

3/ Assembler – dịch Assembly sang ngôn ngữ máy.

  • Trình biên dịch sẽ chuyển đổi chương trình mã Assembly sang mã đối tượng (object code – thường là ngôn ngữ máy, tập nhị phân) thông qua trình hợp dịch Assembler.
  • Một tệp mã đối tượng (mã máy) “.o” sinh ra trong hệ thống ngay sau đó.

Note: Command thực hiện giai đoạn Assembler (.i -> .o)

as <filename>.s -o <filename>.o

Chúng ta thu được kết quả tại hình 7 dưới đây:

Hình 7. Thực hiện giai đoạn Assembler với command

File sinh ra là file object dạng mã máy nhị phân, các bạn có thể sử dụng Git bash trên Window hoặc Terminal trên Linux/MacOS với command sau:

xxd tapit.o

Do sự khác nhau về kiến trúc hệ thống và các quy ước về file object nhị phân, nên khi đọc hai file, ta thấy có sự khác nhau là điều bình thường.

Hình 8. Đọc file object trên Window và MacOS

4/ Linker.

  • Trong giai đoạn này mã máy của một chương trình dịch từ nhiều nguồn (file .c hoặc file thư viện .lib) được liên kết lại với nhau để tạo thành chương trình đích duy nhất.
  • Mã máy của các hàm thư viện gọi trong chương trình cũng được đưa vào chương trình cuối trong giai đoạn này.
  • Chính vì vậy mà các lỗi liên quan đến việc gọi hàm hay sử dụng biến tổng thể mà không tồn tại sẽ bị phát hiện. Kể cả lỗi viết chương trình chính không có hàm main() cũng được phát hiện trong giai đoạn này.
  • Kết thúc quá trình, tất cả các đối tượng được liên kết lại với nhau thành một chương trình có thể thực thi được thống nhất. 
  • File sinh ra có thể thực thi có đuôi .exe với Window (viết tắt của executable) và không có đuôi với Linux/MacOS.

Note: Giai đoạn Linker được thực hiện khi gọi command sinh file thực thi từ file .c

gcc <filename>.c -o <filename>.exe

Chúng ta thu được kết quả tại hình 9 dưới đây:

Hình 9. Biên dịch chương trình bằng command

Qua bài viết này, mình đã giới thiệu đến các bạn về trình biên dịch, bốn giai đoạn của quá trình biên dịch. Thực hành với các lệnh thực hiện các giai đoạn thì việc hiểu được quá trình biên dịch thật đơn giản. Các bạn có thể thực hành nâng cao hơn với việc sử dụng nhiều hơn các comment, macro, optimization, liên kết nhiều file source,…

Video hướng dẫn liên kết 2 file source bằng command, các bạn có thể tham khảo nhé!

Chúc các bạn thành công!

N.T.Nhien

Tìm hiểu thêm:
Fanpage TAPIT: TAPIT – AIoT Learning