Function Pointer - Con trỏ hàm và ứng dụng

Pointer Function - Con trỏ hàm và ứng dụng

 

Trong lập trình nhúng, khả năng tổ chức chương trình linh hoạt, giảm phụ thuộc giữa các module và tối ưu tài nguyên là rất quan trọng. Một trong những kỹ thuật giúp thực hiện các yếu tố trên là con trỏ hàm (function pointer), stack hầu như không thể thiếu khi tổ chức ứng dụng nhúng ở cấp độ nâng cao.

Bài viết này sẽ giúp hiểu rõ bản chất con trỏ hàm, cách dùng, những sai lầm thường gặp, và đặc biệt là các ứng dụng thực tế trong lập trình vi điều khiển.

1. Con trỏ hàm là gì?

Ôn bài lại xíu !

Thao tác trong lập trình C thông thường, con trỏ là biến lưu địa chỉ của một vùng nhớ. Thay vì lưu giá trị trực tiếp như biến thông thường, con trỏ lưu địa chỉ của giá trị đó, khi muốn truy xuất dữ liệu của biến, truy cập thông qua con trỏ trỏ tới biến đó.

Tương tự, con trỏ hàm là biến lưu địa chỉ của một hàm. Nhờ đó, có thể gọi hàm thông qua biến đó giống như gọi hàm bình thường.

Ví dụ với code sau:

    uint8_t a = 10; /* create a variable of type uint8_t */

    uint8_t* p = &a; /* create a pointer variable that stores the address of a */

    printf("%d\n", a); /* print the value of a */

    printf("%p\n", p); /* print the address stored in p */

    printf("%d\n", *p); /* print the value at the address stored in p */

Trong đó:

a là biến được khai báo có giá trị 10

&a là địa chỉ của a

p là con trỏ, lưu &a

*p là thao tác giải tham chiếu, truy cập giá trị tại địa chỉ mà p đang trỏ vào

Trong khi đó, con trỏ hàm không thao tác dữ liệu, bản chất của nó là lưu địa chỉ của hàm và khi được gọi, CPU sẽ jump/load PC đến địa chỉ này để thực thi mã lệnh, theo đúng pattern (prototype) đã được typedef khai báo trước.

Hình dung sơ bộ với code sau:

    void hello() {

        printf("Hello Function Pointer :D\n");

    }

    void (*func_ptr)() = hello; /* store the address of hello() */

    func_ptr(); /* call hello() using the function pointer */

 

2. Cú pháp khai báo và sử dụng

Cú pháp tổng quát:

Cú pháp khai báo một con trỏ hàm có dạng:

    return_type (*function_pointer)(parameter_types);

Trong đó:

    return_type : kiểu dữ liệu trả về của hàm

    function_pointer: tên con trỏ hàm

    parameter_types: tham số truyền vào hàm

 

Ví dụ:

    uint8_t (*fp)(uint16_t, uint16_t);

Con trỏ fp có thể trỏ tới bất kỳ hàm nào trả về uint8_t và nhận 2 tham số kiểu uint16_t.

 

Gán địa chỉ hàm cho con trỏ:

Khi đã khai báo, ta gán địa chỉ hàm cho con trỏ bằng tên hàm:

    uint8_t add(uint16_t a, uint16_t b) {

        return a + b;

    }

    fp = add;

Lưu ý:

  • Không dùng dấu () khi gán
  • add chính là địa chỉ của hàm add

 

Gọi hàm thông qua con trỏ:

Sau khi gán, có thể gọi hàm thông qua con trỏ như sau:

    uint16_t result = fp(3, 4);

Cách gọi này tương đương với:

    uint16_t result = add(3, 4);

 

Cú pháp khai báo với typedef

Trong thực tế, đặc biệt là lập trình vi điều khiển, con trỏ hàm thường được khai báo bằng typedef để code dễ đọc, gọn và dễ bảo trì.

    typedef uint8_t (*math_func_t)(uint16_t, uint16_t);

    math_func_t fp = add;

Khi đó, việc sử dụng giống như biến thông thường:

    uint8_t result = fp(2, 3);

 

Con trỏ hàm không tham số và không trả về

Dạng con trỏ hàm phổ biến trong viết các hàm callback hoặc driver.

    typedef void (*callback_t)(void);

    void hello(void) {

        printf("Hello Function Pointer :D\n");

    }

    callback_t cb = hello;

    cb();

 

3. Các lỗi thường gặp:

Khai báo sai prototype:

    void hello(int a);

    void (*fp)(void);

    fp = hello;

Hậu quả:

  • Compile có thể không báo lỗi

  • Khi chạy có thể dính hard-fault exception / undefined behavior

 

Gọi con trỏ hàm khi chưa được gán (NULL pointer)

    typedef void (*callback_t)(void);

    callback_t cb;

    cb();

Hậu quả:

  • Jump tới địa chỉ rác
  • Exception hard-fault

 

4. Ứng dụng trên vi điều khiển:

(*) Driver cho ngoại vi:

Function Pointer - Con trỏ hàm và ứng dụng

 

Trong ứng dụng với vi điều khiển, việc sử dụng ngoại vi (UART, SPI, I2C, GPIO, …) thường phụ thuộc rất nhiều vào phần cứng cụ thể. Nếu viết driver theo cách truyền thống, code ứng dụng sẽ bị phụ thuộc chặt vào từng ngoại vi, dẫn đến khó bảo trì và mở rộng.

Con trỏ hàm cho phép ta tách phần giao tiếp (interface) và phần hiện thực (implementation) của driver, giúp xây dựng driver linh hoạt và dễ tái sử dụng.

Ý tưởng thiết kế: 

  • Tạo con trỏ hàm (interface) cho ngoại vi tương ứng (I2C, UART, SPI, ...).
  • Viết code hiện thực tương ứng của driver, xem như đã kết nối với phần cứng cụ thể.
  • Khi porting, gán con trỏ hàm cho phần cứng trên vi điều khiển tương ứng.

Để hình dung rõ hơn, mình ví dụ mẫu xây dựng một driver giao tiếp với IC thời gian thực PCF8563 có giao tiếp là chuẩn I2C.

Bước 1: Định nghĩa đối tượng

Khai báo các con trỏ hàm vai trò là hardware interface:

    typedef uint8_t (*pf_write)(uint8_t address, uint8_t reg, uint8_t reg_size, uint8_t* data, uint16_t len);

    typedef uint8_t (*pf_read)(uint8_t address, uint8_t reg, uint8_t reg_size, uint8_t* data, uint16_t len);

Khai báo đối tượng driver

    typedef struct {

        uint8_t addr;

        pf_write write;

        pf_read read;

    } pcf8563_t;

 

Bước 2: Triển khai các hàm bên trong driver

Hàm khởi tạo phần cứng, sử dụng khi gắn driver với một vi điều khiển cụ thể:

    void pcf8563_init(uint8_t address, pf_write write, pf_read read) {

        pcf8563.addr = address;

        pcf8563.write = write;

        pcf8563.read = read;

    }

Các hàm ứng dụng của driver:

    uint8_t pcf8563_set_time(struct tm *time) {

        CHECK_ARG(time);

        /* set date/time data */

        /* TODO: implement */

        /* i2c write */

        if (pcf8563.write(PCF8563_ADDR, PCF8563_ADDR_TIME, 1(uint8_t*)&datasizeof(data)) != PCF8563_OK) {

            return PCF8563_NG;

        }

        return PCF8563_OK;

    }

 

    uint8_t pcf8563_get_time(struct tm* time) {

        CHECK_ARG(time);

        uint8_t data[7];

        /* i2c read */

        if (pcf8563.read(PCF8563_ADDR, PCF8563_ADDR_TIME, 1, (uint8_t*)&datasizeof(data)) != PCF8563_OK) {

            return PCF8563_NG;

        }

        /* get date/time data */

        /* TODO: implement */

        return PCF8563_OK;

    }

 

    uint8_t pcf8563_reset() {

        int8_t data[2];

        data[0] = 0;

        data[1] = 0;

        if (pcf8563.write(PCF8563_ADDR, PCF8563_ADDR_STATUS1, 1, (uint8_t*)&data, sizeof(data)) != PCF8563_OK) {

            return PCF8563_NG;

        }

        return PCF8563_OK;

    }

 

    uint64_t pcf8563_get_timestamp() {

        /* get current time */

        struct tm timeinfo;

        if (pcf8563_get_time(&timeinfo) != PCF8563_OK) {

            return 0;

        }

        /* convert to timestamp */

        timeinfo.tm_year -= 1900;

        time_t ts = mktime(&timeinfo);

        if (ts < 0) {

            return 0;

        }

        return ((uint64_t)ts * 1000); /* miliseconds */

    }

Các hàm được viết và hình dung như đã kết nối với phần cứng cụ thể. Khi cần giao tiếp với phần cứng, driver gọi giao tiếp thông qua các hardware interface (con trỏ hàm).

Bước 3: Sử dụng

Ví dụ sử dụng trên thư viện I2C HAL của STM32:

    #include "stdio.h"

    #include "time.h"

    #include "pcf8563.h"

    I2C_HandleTypeDef hi2c1;

    /* pcf8563 i2c bsp */

    uint8_t pcf8563_i2c_write(uint8_t address, uint8_t reg, uint8_t reg_size, uint8_tdata, uint16_t len) {

        if (HAL_I2C_Mem_Write(&hi2c1, address, reg, reg_size, data, len, 100) == HAL_OK) {

            return PCF8563_OK;
        }

        return PCF8563_NG;

    }

 

    uint8_t pcf8563_i2c_read(uint8_t address, uint8_t reg, uint8_t reg_size, uint8_tdata, uint16_t len) {

        if (HAL_I2C_Mem_Read(&hi2c1, address, reg, reg_size, data, len, 100) == HAL_OK) {

            return PCF8563_OK;

        }

        return PCF8563_NG;

    }

 

    int main() {

        /* peripheral init */

        /* ... */

 

        /* pcf8563 init */

        pcf8563_init(PCF8563_ADDR, pcf8563_i2c_write, pcf8563_i2c_read);

        

        /* pcf8563 set time */

        struct tm current_time;

        current_time.tm_year = 2025 - 1900,

        current_time.tm_mon  = 5 - 1,

        current_time.tm_mday = 15,

        current_time.tm_hour = 10,

        current_time.tm_min  = 30,

        current_time.tm_sec  = 0,

        if (pcf8563_set_time(&current_time) != PCF8563_OK) {

            printf("failed to set time\n");

        }

 

        /* pcf8563 get time */

        struct tm read_time;

        if (pcf8563_get_time(&read_time) == PCF8563_OK) {

            printf("current time: %04d-%02d-%02d %02d:%02d:%02d\n",

                                                read_time.tm_year + 1900,   \

                                                read_time.tm_mon + 1, read_time.tm_mday,    \

                                                read_time.tm_hour, read_time.tm_min,    \

                                                read_time.tm_sec);  \

        }

        else {

            printf("failed to get time\n");

        }

 

        while (1) {

        }

        return 0;

    }

Tham khảo xây dựng driver tại: https://github.com/Gao-Den/lite-driver

(*) State-machine

 

Function Pointer - Con trỏ hàm và ứng dụng

 

Trong thiết kế ứng dụng nhúng, state-machine (máy trạng thái) được dùng rất nhiều để quản lý các luồng xử lý như: giao tiếp protocol, điều khiển thiết bị, xử lý nút nhấn, menu LCD, thiết bị IoT, …
Nếu cài state-machine bằng if/else hoặc switch-case, code sẽ nhanh chóng trở nên dài, khó đọc và khó mở rộng. Đặc biệt là khi các case xử lý có thể đẻ ra thêm trong quá trình phát triển hoặc mở rộng khi có yêu cầu phát sinh từ người dùng.

Ý tưởng thiết kế:

  • Mỗi state là một hàm xử lý
  • Con trỏ hàm luôn trỏ tới state hiện tại, khi chạy chỉ cần gọi con trỏ hàm chứa state.
  • Khi cần chuyển state, gán sang hàm khác.

Ví dụ: Thiết kế state-handler quản lý 3 state của máy (Idling, Running, Error).

Bước 1: Định nghĩa con trỏ hàm cho state

    #define STATE_NULL          ((state_func_t)0)

    typedef void (*state_func_t)();

 

Bước 2: Khai báo các state trong hệ thống

    static state_func_t current_state;

    static void state_idle();

    static void state_running();

    static void state_error();

 

    void state_idle() {

        /* TODO: state error process */

    }

 

    void state_running() {

        /* TODO: state running process */

    }

 

    void state_error() {

        /* TODO: state error process */

    }

 

Bước 3: Hiện thực thao tác chuyển đổi quản lý state

    void state_machine_init(state_func_t initial_state) {

        current_state = initial_state;

    }

 

    void state_machine_trans(state_func_t target_state) {

        current_state = target_state;

    }

 

    void state_machine_run() {

        if (current_state != STATE_NULL) {

            current_state();

        }

    }

 

Chạy thử nhé ^^ !

    int main() {

        /* system init */

        system_init();

 

        /* state machine init */

        state_machine_init(state_idle);

 

        while (1) {

            state_machine_run();

        }

    }

 

Với ví dụ trên, các trạng thái là những hàm thực thi những tác vụ cụ thể. Mặc khác, có thể mở rộng để nhận được các tín hiệu (message) từ bên ngoài gửi đến state_handler, dùng trong các tác vụ: Quản lý chuyển màn hình (Screen manager), Trạng thái máy hữu hạn (Finite state-machine),...
Tham khảo code mẫu quản lý màn hình: Screen manager

(*) Event callback

Khi phát triển ứng dụng, có nhiều ngữ cảnh với các sự kiện xen ngang giữa ứng dụng chính, cụ thể như:

  • Gói tin trong network vừa gửi bị time-out
  • Nút nhấn vừa được ấn từ người dùng
  • Mạng LAN vừa bị mất kết nối

Đây là các sự kiện ngẫu nhiên và cần được xử lý. Để tổ chức ứng dụng trong các trường hợp này, thường được xử lý thông qua event callback handler.

Ý tưởng thiết kế:

  • Khởi tạo con trỏ hàm đại diện cho event callback handler
  • Khai báo và định nghĩa hàm xử lý sự kiện với giả định các sự kiện có thể sinh ra

Ví dụ: Thiết kế event callback để xử lý các sự kiện từ nút nhấn: Nhấn, Nhả, Nhấn giữ.

Hiện thực code: Button driver

Với ví dụ này, nút nhấn (button) sẽ được polling định kỳ (qua timer tick,...) và xác định hành động cụ thể (nhấn, nhả, giữ), khi hoạt động được xác định, nó sẽ gọi callback handler được đăng ký trước đó để gửi sự kiện (BUTTON_STATE_PRESSED, BUTTON_STATE_RELEASED, BUTTON_STATE_LONG_PRESSED) và xử lý các tác vụ với sự kiện này. Thao tác đăng ký hàm callback tương tự với đăng ký hardware driver đã đề cập ở ví dụ trên.

Các bạn tham khảo các chủ đề thú vị khác về Lập trình nhúng Vi điều khiển tại: AK Embedded Software

Các thắc mắc các bạn gửi về:

Bình luận