🌱 Embedded C - Bộ Tiền xử lý (Preprocessor), Macro và Cách sử dụng

🌱 Embedded C - Bộ Tiền xử lý (Preprocessor), Macro và Cách sử dụng

    Trong lập trình C, chắc hẳn bạn thường xuyên bắt gặp các dấu #, như #include, #ifdef, #define, ... Mỗi chỉ thị lại có một tác dụng khác nhau. Tuy nhiên điểm chung là chúng đều có dấu #, đều được xử lý tại thời điểm chung gọi là Tiền xử lý - Preprocessing, tức là thực hiện trước quá trình Compile.

    Bài viết này sẽ giới thiệu tổng quan về quá trình Preprocessing và các chỉ thị tiền xử lý thường xuyên sử dụng trong Embedded.


Mục Lục


Tổng quan về quá trình Tiền xử lý (Preprocessing)

    Quá trình Preprocessing - tiền xử lý là quá trình chuẩn bị  cho quá trình Compile, giai đoạn này sẽ được thực hiện bởi bộ Preprocessor, với các công việc sau:

  • Xóa các comment khỏi source code.
  • Xử lý các Preprocessor Directives.

Các Preprocessor Directives - Chỉ thị Tiền xử lý là các lệnh cho bộ tiền xử lý (preprocessor - là một phần của compiler) thực hiện một số tác vụ như thay thế văn bản, macro, include thư viện và nhiều tác vụ khác trước khi compile code ra binary. Tất cả các chỉ thị tiền xử lý này đều bắt đầu bằng ký hiệu '#'.

    Để thực hiện chỉ riêng quá trình Preprocessing, bạn có thể chạy riêng câu lệnh sau (sử dụng compiler GCC) để tạo ra file extended (.i) từ source code (.c) (Tham khảo Build Process with Command Line)

gcc -E main.c -o main.i

    Bên dưới bài viết sẽ đề cập đến các Preprocessor Directives chính và thường xuyên sử dụng trong chương trình C.

#include - thêm header file vào source code

    Directives quen thuộc nhất chắc hẳn là #include, mục đích dùng để thêm các file khác (header file, library, ...) vào các file source code.

#include <file_name>
#include "filename"

    Bộ tiền xử lý sẽ thay thế trực tiếp nội dung của file trong lệnh include vào các vị trí chứa #include.

    Dấu ngoặc '<' và '>' yêu cầu Preprocessor tìm kiếm file được include trong thư mục cài thư viện chuẩn trong khi dấu ngoặc kép (" ") yêu cầu Preprocessor tìm kiếm file được include trong thư mục chứa file source code.

  1. // Includes the standard I/O Library
  2. #include <stdio.h>

  3. // Inlucde User Library
  4. #include "lib.h"

  5. int main() {
  6. printf("Hello World");

  7. return 0;
  8. }

Macro #define

    Macro được sử dụng để định nghĩa một Token thay thế cho một số, hằng hoặc các hàm mà sẽ được bộ Preprocessor thay thế trước khi code được compile. Macro sử dụng directives là #define với cú pháp như sau:

#define TOKEN     value

    Trong đó phần value có thể là một số, hằng, biểu thức, function, .... Quá trình Preprocessor sẽ thay thế các TOKEN trong code bằng phần value trong quá trình Preprocessing. Với chức năng này, macro sẽ thường sử dụng để đặt tên đại diện cho các thành phần trong code, giúp code dễ đọc hơn, dễ maintain hơn (ví dụ khi cần sửa giá trị macro thì chỉ cần sửa một lần ở vị trí define code).

    ↪ C chia macro thành 2 loại:

Object-like Macro

    Là các macro mà phần value là các object (hằng, biến, biểu thức, ...).

#define PI_VALUE    3.1416

    Cái tên PI_VALUE sẽ được sử dụng thay thế cho số 3.1416 trong hình, #define những số như vậy mình gọi là các Macro Constant (Tham khảo một số tài liệu nước ngoài).

    Nếu trong chương trình cần gọi nhiều lần số pi, thì tất nhiên việc dùng cái tên thay thế là PI_VALUE sẽ giúp ích nhiều cho việc ghi nhớ và việc sửa đổi sau này. Chẳng hạn cần sửa PI thành 3.14 thay vì 3.1416 thì người lập trình chỉ cần thay đổi 1 lần duy nhất ở #define. 

Function-like Macro

    Cũng sử dụng từ khóa #define giống như trường hợp ở trên, nhưng trường hợp này ta sử dụng từ khóa có thể truyền vào những tham số, tức là có dạng giống với các hàm - Functions. Ví dụ hàm tính diện tích hình tròn như bên dưới:

  1. #define CIRCLE_AREA(R) (3.1416*(R)*(R))

  2. void main(void)
  3. {
  4. printf("Area of circle with r=5 is %d", CIRCLE_AREA(5));
  5. }

    Người dùng có thể truyền vào tham số r - bán kính hình tròn để tính diện tích hình tròn. Vậy nó khác gì Function không? Tại sao không dùng Function?

    Điểm khác biệt nằm ở chỗ Function Like-Macro được thay thế trong quá trình Preprocessor, tức là khi mà chưa Compile, thì chỗ nào có CIRCLE_AREA(R) sẽ được thay thế tương ứng trong chương trình của chúng ta rồi, không giống như Function được gọi khi run.

    Vì vậy Function Like-Macro có một số ưu điểm lớn nhất so với Function đó là tốc độ. Nó được thay thế trong quá trình Preprocessor nên không cần lời gọi hàm giống như Funtion. Vì vậy, khi dùng Function Like-Macro, tốc độ chương trình sẽ nhanh hơn so với Function.

    Tuy nhiên, sử dụng Function Like-Macro cũng có một số nhước điểm. 

  •  Đầu tiên, đó là việc replace code như nói trên, điều này dẫn đến nếu Function Like-Macro được sử dụng quá nhiều sẽ dẫn đến code size của chương trình tăng lên, gây tốn bộ nhớ.
  • Tiếp theo là việc Function Like-Macro chỉ là việc copy paste nên các parameters không có kiểu dữ liệu như Function. Việc sử dụng những biến đếm trong trường hợp này là không khả thi. 
  • Được sử dụng trong quá trình Preprocessor nên các tham số của Function Like-Macro sẽ không được kiểm tra lỗi trong quá trình Compile.

    Vì vậy, hãy cân nhắc trước khi sử dụng Function Like-Macro!

Conditional Compilation

    Cái này gần giống như Conditional Statement if - else, dùng để kiểm tra xem đoạn code nào có được chạy trong chương trình hay không. Lợi ích mà chỉ thị tiền xử lý này mang lại:

  • Chọn các đoạn code khác nhau phù hợp với chip, hệ điều hành, ...
  • Biên dịch cùng một source file trong các chương trình khác nhau (Giống như các thư viện vi điều khiển có thể sử dụng cho nhiều chip khác nhau).

    Sử dụng Conditional Compilation, ta có các chỉ thị: #if, #ifdef, #ifndef, #defined, #else, #elseif, ...

Các từ khóa này sử dụng đúng như ý nghĩa tên của chúng, chẳn hạn #if (expression) ...  #else ... dùng để kiểm tra expression, nếu nó đúng, thì ta sẽ thêm đoạn code trong phần if vào chương trình để Compile, lúc này đoạn code trong #else sẽ bị xóa đi. Còn nếu không đúng, ta sẽ thêm đoạn code trong #else vào chương trình. 

    ↪ Ví dụ sử dụng Conditional Compilation:

  1. #if (MCU == STM32F1)
  2. // Conditional Code with macro MCU is defined as STM32F1
  3. #elif (MCU == STM32F4)
  4. // Conditional Code with macro MCU is defined as STM32F4
  5. #elif (MCU == STM32F7)
  6. // Conditional Code with macro MCU is defined as STM32F7
  7. #else
  8. // Conditional Code with other MCU
  9. #endif

Include Guard

    Một ví dụ kinh điển của việc sử dụng preprocessing đó là trong các header file. Giả sử nếu bạn include một header file nhiều lần, mà trong các header file đó chứa các định nghĩa user-defined datatype thì nó sẽ bị duplicate và lỗi compile.

    Vì vậy, cần có cơ chế đề nếu đã include một header file rồi thì những lần include tiếp theo sẽ không ảnh hưởng gì! Để làm được điều đó, các header file sử dụng 2 directive là #ifndef và #define, để "Nếu chưa define thì define, còn nếu chưa define thì define lần đầu!". Và cú pháp này được gọi là include guard, được tạo mỗi lần tạo file header (.h).

  1. #ifndef __MAIN_H__
  2. #define __MAIN_H__

  3. // Header File Contents

  4. #endif /* __MAIN_H__ */

Một số preprocessor directives khác

#pragma

    #pragma cung cấp các lệnh cụ thể cho compiler để kiểm soát hành vi của nó, ví dụ tắt các warning, thiết lập alignment, set section, v.v. Cú pháp:

#pragma directive

    ↪ Ví dụ sử dụng #pragma để set alignment:

  1. // Căn chỉnh struct theo boundary 4 bytes
  2. #pragma pack(4)
  3. struct sensor_data {
  4. uint8_t id;
  5. uint16_t value;
  6. uint32_t timestamp;
  7. };
  8. #pragma pack() // Reset về default

  9. // Hoặc sử dụng push/pop
  10. #pragma pack(push, 1) // Lưu setting hiện tại, set pack = 1
  11. struct uart_frame {
  12. uint8_t header;
  13. uint16_t data;
  14. uint8_t checksum;
  15. } __attribute__((packed)); // GCC specific
  16. #pragma pack(pop) // Khôi phục setting cũ

    ↪ Ví dụ sử dụng #pragma để đặt biến vào các section trong linker

  1. // Đặt biến vào section cụ thể trong memory
  2. #pragma section(".noinit")
  3. uint8_t backup_data[256]; // Không khởi tạo khi reset

  4. // Đặt function vào RAM để thực thi nhanh
  5. #pragma section(".ramfunc")
  6. void critical_interrupt_handler(void) {
  7. // Code chạy từ RAM
  8. }

  9. // Đặt constant vào Flash
  10. #pragma section(".rodata")
  11. const uint8_t lookup_table[256] = {0x00, 0x01, ...};

    ↪ Ví dụ sử dụng #pragma để tắt warning khi compile

  1. // Tắt warning cụ thể
  2. #pragma GCC diagnostic push
  3. #pragma GCC diagnostic ignored "-Wunused-variable"
  4. void test_function() {
  5. int unused_var = 42; // Không có warning
  6. }
  7. #pragma GCC diagnostic pop

  8. // Tắt tất cả warnings cho một đoạn code
  9. #pragma GCC diagnostic push
  10. #pragma GCC diagnostic ignored "-Wall"
  11. // Code có thể có warnings
  12. #pragma GCC diagnostic pop

#error và #warning Directives

    Là các directive để báo lỗi hoặc wanring khi compile. Ví dụ sử dụng:

  1. // Tạo compile-time error
  2. #if BUFFER_SIZE < 64
  3. #error "Buffer size must be at least 64 bytes"
  4. #endif

  5. // Tạo compile-time warning
  6. #ifndef RTOS_ENABLED
  7. #warning "RTOS not enabled, some features may not work"
  8. #endif

  9. // Kiểm tra version compiler
  10. #if __GNUC__ < 4
  11. #error "GCC version 4.0 or higher required"
  12. #endif

Stringification và Token Pasting

    Có thể nối các chuỗi macro với nhau bằng stringification và token pasting, tuy nhiên cái này ít dùng.

  1. // Stringification operator (#)
  2. #define STRINGIFY(x) #x
  3. #define TOSTRING(x) STRINGIFY(x)

  4. #define VERSION_MAJOR 2
  5. #define VERSION_MINOR 1
  6. #define VERSION_STRING TOSTRING(VERSION_MAJOR) "." TOSTRING(VERSION_MINOR)
  7. // Kết quả: "2.1"

  8. // Token pasting operator (##)
  9. #define CONCAT(a, b) a##b
  10. #define GPIO_PIN(port, pin) CONCAT(GPIO, port)->PIN##pin

  11. // Sử dụng: GPIO_PIN(A, 5) -> GPIOA->PIN5

Variadic Macros (C99)

    Cái này giống Variadic Functions - có thể truyền số lượng tham số khác nhau vào các Function-Like Macros!

  1. // Debug macro với variable arguments
  2. #define DEBUG_PRINTF(format, ...) \
  3. do { \
  4. if (DEBUG_ENABLED) { \
  5. printf("[DEBUG] " format "\n", ##__VA_ARGS__); \
  6. } \
  7. } while(0)

  8. // Sử dụng:
  9. DEBUG_PRINTF("Temperature: %d°C", temp);
  10. DEBUG_PRINTF("System started");

  11. // Macro cho RTOS logging
  12. #define LOG_INFO(tag, format, ...) \
  13. log_write(LOG_LEVEL_INFO, tag, __FILE__, __LINE__, format, ##__VA_ARGS__)

>>>>>> Follow ngay <<<<<<<

Để nhận được những bài học miễn phí mới nhất nhé 😊
Chúc các bạn học tập tốt 😊

Nguyễn Văn Nghĩa

Mình là một người thích học hỏi và chia sẻ các kiến thức về Nhúng IOT.

Đăng nhận xét

Mới hơn Cũ hơn
//