🌱 Embedded C: Function Pointer và Callback Function trong Ứng Dụng Thực Tế

🌱 Embedded C: Function Pointer và Callback Function trong Ứng Dụng Thực Tế

    Bài viết trước mình đã giới thiệu về khái niệm Pointer (Con trỏ), mong rằng bạn đọc đã hiểu về Pointer sử dụng với Variable (int, float, struct, void pointers, ...). Còn một khái niệm nữa mình đã đề cập đến đó là Function Pointer. Bài viết này mình sẽ cùng bạn tìm hiểu khái niệm Function Pointer (Con trỏ hàm) và ứng dụng của nó trong Embedded System.


Mục Lục


Vấn đề: Tại sao cần sử dụng Function Pointer?

Vấn đề 1. Xây dựng một hệ thống với hai Firmware song song.

    Mình sẽ lấy ví dụ trên máy tính, có thể cài song song Firmware của hai hệ điều hành Window và Ubuntu để chạy song song.

Embedded C - Choose two Operating Systems

    Giả sử 2 Firmware này đều viết bằng ngôn ngữ C, và cả 2 sẽ đều có hàm main() của riêng nó. Vậy câu hỏi là Firmware nào đang chạy trước khi chạy 2 Firmware này? Và khi chọn Window hay Ubuntu thì Firmware đó làm sao để có thể call đến Firmware tương ứng?

Vấn đề 2. FOTA / Bootloader

    Vấn đề này đã được đề cập trong phần Bootloader/FOTA, khi chúng ta cần gọi đếm một Firmware khác, cụ thể từ Bootloader Firmware gọi lên App Firmware! (Vấn đề này tương tự như vấn đề 1).

Vấn đề 3. Quản lý State Machine

    Một số bài toán sử dụng State Machine với rất nhiều trạng thái, với cách thông thường chúng ta có thể sử dụng switch-case để quản lý. Tuy nhiên, trong một vài trường hợp, số lượng trạng thái = số lượng case tăng lên quá nhiều, và trong các trạng thái đó lại chia thành những trạng thái nhỏ hơn nữa! Điều này dẫn đến việc code sẽ rất dài và khó đọc, khó maintain!

    Một vi điều khiển STM32 giao tiếp với module WiFi ESP01 qua giao thức UART để nhận các bản tin từ Internet. Mỗi bản tin có cấu trúc:

  • Header Byte (1 byte): Byte mở đầu ví dụ 0xAA.
  • Packet Length (1 byte): Kích thước bản tin.
  • FrameID (1 byte): Xác định loại bản tin.
  • Payload: Nội dung bản tin, tùy thuộc vào FrameID.
  • Checksum (1 byte): Tổng kiểm tra để đảm bảo tính toàn vẹn.
  • End Byte (1 byte): Byte kết thúc ví dụ 0xBB.

    Dưới đây là ví dụ nếu cần xử lý một vài Frame dữ liệu như điều khiển LED, yêu cầu giá trị cảm biến, notify lỗi, ...

  1. #include "stm32f1xx_hal.h"
  2. #include <stdio.h>
  3. #include <stdint.h>
  4. #define IDLE 0
  5. #define RECV_HEADER 1
  6. #define RECV_PAYLOAD 2
  7. #define RECV_CHECKSUM 3
  8. #define PROCESSING 4
  9. #define ERROR 5
  10. #define FRAME_LED_GREEN 0x01
  11. #define FRAME_SENSOR_REQUEST 0x02
  12. #define FRAME_LED_FREQ 0x03
  13. #define FRAME_ERROR_NOTIF 0x04
  14. UART_HandleTypeDef huart1; // UART1 for ESP01
  15. GPIO_TypeDef* GPIOA; // PA5
  16. uint8_t process_frame(int state, uint8_t *buffer, int *buf_index, uint8_t new_byte, int *checksum, int *payload_len) {
  17. switch (state) {
  18. case IDLE:
  19. printf("Idle: Waiting for FrameID\n");
  20. if (new_byte == FRAME_LED_GREEN || new_byte == FRAME_SENSOR_REQUEST ||
  21. new_byte == FRAME_LED_FREQ || new_byte == FRAME_ERROR_NOTIF) {
  22. buffer[0] = new_byte;
  23. *buf_index = 1;
  24. *checksum = new_byte;
  25. *payload_len = (new_byte == FRAME_SENSOR_REQUEST) ? 0 : 1; // Sensor request without payload
  26. return (*payload_len == 0) ? RECV_CHECKSUM : RECV_PAYLOAD;
  27. }
  28. return IDLE;
  29. case RECV_PAYLOAD:
  30. printf("Receiving Payload: byte %d\n", *buf_index);
  31. if (*buf_index < *payload_len + 1) {
  32. buffer[*buf_index] = new_byte;
  33. *checksum += new_byte;
  34. (*buf_index)++;
  35. return RECV_PAYLOAD;
  36. }
  37. return RECV_CHECKSUM;
  38. case RECV_CHECKSUM:
  39. printf("Receiving Checksum\n");
  40. if (new_byte == (uint8_t)(*checksum & 0xFF)) {
  41. return PROCESSING;
  42. }
  43. return ERROR;
  44. case PROCESSING:
  45. printf("Processing FrameID: 0x%02X\n", buffer[0]);
  46. switch (buffer[2]) {
  47. case FRAME_LED_GREEN:
  48. printf("LED Green: %s\n", buffer[1] ? "ON" : "OFF");
  49. HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, buffer[1] ? GPIO_PIN_SET : GPIO_PIN_RESET);
  50. break;
  51. case FRAME_SENSOR_REQUEST:
  52. printf("Requesting Temp/Humidity\n");
  53. // Simulate Sensor Data
  54. uint8_t response[] = {0x02, 25, 60, 87}; // FrameID, Temp, Humidity, Checksum
  55. HAL_UART_Transmit(&huart1, response, 4, 100);
  56. break;
  57. case FRAME_LED_FREQ:
  58. printf("Set LED Frequency: %d Hz\n", buffer[1]);
  59. // Simulate LED Frequency
  60. break;
  61. case FRAME_ERROR_NOTIF:
  62. printf("Error Notification: Code 0x%02X\n", buffer[1]);
  63. break;
  64. }
  65. *buf_index = 0;
  66. *checksum = 0;
  67. return IDLE;
  68. case ERROR:
  69. printf("Error: Invalid packet\n");
  70. *buf_index = 0;
  71. *checksum = 0;
  72. return IDLE;
  73. default:
  74. printf("Invalid state\n");
  75. return IDLE;
  76. }
  77. }
  78. int main() {
  79. uint8_t buffer[10] = {0};
  80. int buf_index = 0, checksum = 0, payload_len = 0, state = IDLE;
  81. // Simulate UART Data from ESP01
  82. uint8_t input[] = {0x01, 0x01, 0x02, 0x02, 0x02, 0x03, 0x02, 0x05, 0x04, 0xFF, 0x03};
  83. int input_index = 0;
  84. for (int i = 0; i < 11; i++) {
  85. state = process_frame(state, buffer, &buf_index, input[input_index++], &checksum, &payload_len);
  86. msleep(10);
  87. }
  88. printf("System stopped\n");
  89. return 0;
  90. }

    Nhìn cái hàm process_frame bạn có hơi "sốc" không? Ở đây mình chỉ giả lập nội dung các case cho ngắn gọn, còn thực tế sẽ dài hơn! Và nếu thêm nhiều trạng thái hơn nữa, thì hàm này sẽ ngày càng lớn hơn, nhiều 'case' và khó đọc hơn rất nhiều!

Vấn đề 4. SW Layer tầng dưới có thể gọi hàm ở tầng trên

    Trong Embedded System, rất nhiều hệ thống xây dựng Software theo kiểu nhiều Layer (tham khảo. Embedded Software Layer). Các Layer ở tầng dưới như Driver không thể biết các Layer trên như Application sẽ sử dụng hàm tên gì, và cũng không được gọi hàm ở Layer trên!

    Vậy giờ một hàm như hàm Interrupt của tầng Driver sẽ cần làm gì? Ví dụ, trong đó sẽ có các câu lệnh của Application dùng để điều khiển LED, Driver Interrupt Function không được gọi những hàm này (vì nó dùng chung cho nhiều bài toán).

Embedded C - Callback Situation

    Khi ngắt xảy ra, CPU sẽ gọi đến EXTI_ISR, và cần làm gì đó để thực hiện User_Function, thay vì viết cụ thể trong ngắt (Vì hàm Ngắt này có thể dùng cho nhiều bài toán, nhiều trường hợp nên phải generic nhất).

    ↪ Vậy, để giải quyết những vấn đề trên, chúng ta cần một cơ chế để có thể gọi đến các hàm mà không cần biết tên hàm đó là gì!

  1. Với vấn đề 1 và 2, bắt buộc vì hai Firmware khác nhau sẽ không thể biết tên hàm của nhau, nên muốn gọi đến hàm của nhau thì bắt buộc phải biết địa chỉ!
  2. Với vấn đề 3, nên có một cơ chế router để tự động gọi đến từng hàm xử lý theo FrameID, để tránh việc phải viết switch-case phức tạp, và để giúp code có thể dễ dàng maintain hơn với các bài toán lớn!

    ↪ Và câu trả lời chính là sử dụng Function Pointer - Con trỏ hàm!

Function Pointer là gì và cách sử dụng

    Giống như Variable Pointer, Function Pointer cũng là một biến, dùng để lưu trữ địa chỉ. Nhưng thay vì lưu trữ địa chỉ của một biến, nó lưu trữ địa chỉ của một hàm, với mục đích để gọi được hàm đó mà không thông qua tên hàm.

    🔻Cú pháp khai báo Function Pointer, tương tự như Variable Pointer, chỉ có một số lưu ý:

Embedded C - Function Pointer
Function Pointer Syntax

    Function Pointer sẽ lưu trữ địa chỉ của một hàm, nên cách khai báo Function Pointer cũng cần tương thích với Prototype của Function đó.

  • Kiểu dữ liệu của Function Pointer cần giống với kiểu dữ liệu trả về của hàm mà nó trỏ tới.
  • Cú pháp Function Pointer yêu cầu có dấu ngoặc tròn, để phân biệt giữa Fucntion Pointer và Function trả về kiểu Pointer. Nếu không có dấu ngoặc, cú pháp sẽ trở thành một Function Prototype của một hàm trả về kiểu dữ liệu Pointer như bên dưới!
    int *func(int, int);    // Function return pointer type
  • Phần phía sau cần đáp ứng số lượng và kiểu dữ liệu của các Tham số tương ứng của hàm mà nó trỏ đến.

    Ví dụ cơ bản việc khai báo và gọi một hàm thông qua Function Pointer,

#include <stdio.h>

int sum(int a, int b) { return a + b; }

void main()
{
    int (*pfunc)(int, int) = &sum;

    int val = func(4, 5);
    printf("%d", val);    // 9
}

    pfunc là một function pointer chứa địa chỉ của hàm sum. Thực chất, địa chỉ của hàm là địa chỉ của câu lệnh đầu tiên trong hàm, tên của hàm cũng đại diện cho địa chỉ của hàm (không nhất thiết sử dụng toán tử &).

Embedded C - Function Address
Function Address

Một số lưu ý và hạn chế khi sử dụng Function Pointer

  1. Đảm bảo khớp Prototype hàm
    Function Pointer phải có kiểu dữ liệu trả về và danh sách tham số khớp hoàn toàn với hàm mà nó trỏ tới. Sai lệch có thể gây lỗi biên dịch hoặc hành vi không xác định (undefined behavior).
  2. Kiểm tra Null Pointer
    Trước khi gọi Function Pointer, luôn kiểm tra xem nó có phải NULL hay không để tránh crash hệ thống.
    if (pFunc != NULL) {
        pFunc(arg1, arg2);
    }
  3. Quản lý địa chỉ hàm
    Đảm bảo địa chỉ hàm được gán cho Function Pointer là hợp lệ và tồn tại trong bộ nhớ chương trình. Trong hệ thống nhúng, hàm từ Firmware khác hoặc vùng bộ nhớ không được ánh xạ có thể gây lỗi.
  4. Tối ưu tài nguyên
    Function Pointer tăng chi phí bộ nhớ và thời gian thực thi do truy cập gián tiếp. Trong các vi điều khiển có tài nguyên hạn chế, cân nhắc số lượng Function Pointer để tránh lãng phí.
  5. Cẩn thận khi debug
    Function Pointer làm tăng độ phức tạp khi gỡ lỗi, đặc biệt khi hàm được gọi không rõ nguồn gốc. Sử dụng công cụ debug (như JTAG/SWD) và logging để theo dõi luồng thực thi.
  6. Với các trường hợp đơn giản, dùng switch-case hoặc gọi hàm trực tiếp để giảm độ phức tạp.
  7. Thread-safety trong hệ thống đa luồng
    Nếu hệ thống nhúng sử dụng RTOS, đảm bảo Function Pointer được bảo vệ trong các tác vụ đồng thời để tránh race condition khi gán hoặc gọi.

Một số case sử dụng Function Pointer trong Embedded System

Call đến Function ở một Firmware khác

    Đây là trường hợp bắt buộc sử dụng Function Pointer với việc hàm từ một Firmware sẽ cần gọi hàm từ một Firmware khác, giả sử main() từ Bootloader gọi hàm main() của Application. Không thể sử dụng việc gọi main(); vì khi build ra thì Bootloader sẽ tự gọi main() của nó!

    Trường hợp sử dụng Function Pointer trong Bootloader!

Quản lý FSM (Finite State Machine) với Function Pointer Array

    Tiếp tục với bài toán FSM sử dụng cho bài toán sử dụng STM32 giao tiếp với module WiFi ESP01 qua UART để nhận các bản tin từ Internet ở trên. Giờ đây, khi số lượng state (FrameID) tăng lên rất nhiều, chúng ta hoàn toàn có thể sử dụng Function Pointer để gọi đến các Function Parsing FrameID và kết hợp với khái niệm Array để quản lý dễ hơn!

    Đó chính là kỹ thuật sử dụng kỹ thuật Function Pointer Array! Mỗi trạng thái (FrameID) sẽ là một phần tử của Array đó, và có thể dùng Macro hoặc Enum để đặt tên cho các FrameID đó. Dưới đây là ví dụ triển khai!

  1. // Define FrameIDs
  2. #define FRAME_LED_GREEN (0x01U)
  3. #define FRAME_SENSOR_REQUEST (0x02U)
  4. #define FRAME_LED_FREQ (0x03U)
  5. #define FRAME_ERROR_NOTIF (0x04U)
  6. // Function pointer type for frame handlers
  7. typedef void (*FrameHandler)(Frame_t *frame);
  8. // Frame handler functions
  9. void handle_led_green(Frame_t *frame) {
  10. printf("LED Green: %s\n", frame->payload[0] ? "ON" : "OFF");
  11. HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, frame->payload[0] ? GPIO_PIN_SET : GPIO_PIN_RESET);
  12. }
  13. void handle_sensor_request(Frame_t *frame) {
  14. printf("Requesting Temp/Humidity\n");
  15. uint8_t response[] = {0x02, 25, 60, 87}; // FrameID, Temp, Humidity, Checksum
  16. HAL_UART_Transmit(&huart1, response, 4, 100);
  17. }
  18. void handle_led_freq(Frame_t *frame) {
  19. printf("Set LED Frequency: %d Hz\n", frame->payload[0]);
  20. }
  21. void handle_error_notif(Frame_t *frame) {
  22. printf("Error Notification: Code 0x%02X\n", frame->payload[0]);
  23. }
  24. // Frame handler mapping table
  25. typedef struct {
  26. uint8_t frame_id;
  27. FrameHandler handler;
  28. } FrameHandlerMap_t;
  29. FrameHandlerMap_t frame_handlers[] = {
  30. { FRAME_LED_GREEN, handle_led_green},
  31. { FRAME_SENSOR_REQUEST, handle_sensor_request},
  32. { FRAME_LED_FREQ, handle_led_freq},
  33. { FRAME_ERROR_NOTIF, handle_error_notif},
  34. { 0, NULL} // End of table
  35. };
  36. // State machine to process incoming bytes
  37. uint8_t process_frame(uint8_t state, Frame_t *frame, uint8_t new_byte, int *buf_index) {
  38. switch (state) {
  39. case IDLE:
  40. if (new_byte == 0xAA) { // Check for header byte
  41. *buf_index = 0;
  42. frame->checksum = 0;
  43. return RECV_LENGTH;
  44. }
  45. return IDLE;
  46. case RECV_LENGTH:
  47. frame->payload_len = new_byte; // Store packet length
  48. frame->checksum += new_byte;
  49. return RECV_FRAMEID;
  50. case RECV_FRAMEID:
  51. frame->frame_id = new_byte; // Store FrameID
  52. frame->checksum += new_byte;
  53. *buf_index = 0;
  54. return (frame->payload_len == 0) ? RECV_CHECKSUM : RECV_PAYLOAD;
  55. case RECV_PAYLOAD:
  56. if (*buf_index < frame->payload_len) { // Collect payload bytes
  57. frame->payload[*buf_index] = new_byte;
  58. frame->checksum += new_byte;
  59. (*buf_index)++;
  60. return RECV_PAYLOAD;
  61. }
  62. return RECV_CHECKSUM;
  63. case RECV_CHECKSUM:
  64. if (new_byte == (uint8_t)(frame->checksum & 0xFF)) { // Verify checksum
  65. return RECV_END;
  66. }
  67. return ERROR;
  68. case RECV_END:
  69. if (new_byte == 0xBB) { // Check for end byte
  70. return PROCESSING;
  71. }
  72. return ERROR;
  73. case PROCESSING:
  74. for (int i = 0; frame_handlers[i].frame_id != 0; i++) { // Find and call handler
  75. if (frame_handlers[i].frame_id == frame->frame_id) {
  76. frame_handlers[i].handler(frame);
  77. break;
  78. }
  79. }
  80. *buf_index = 0;
  81. frame->checksum = 0;
  82. return IDLE;
  83. case ERROR:
  84. printf("Error: Invalid packet\n");
  85. *buf_index = 0;
  86. frame->checksum = 0;
  87. return IDLE;
  88. default:
  89. return IDLE;
  90. }
  91. }

    Sau khi triển khai theo cách mới sử dụng Function Pointer Array, nếu muốn thêm một FrameID, giờ Dev sẽ chỉ cần thêm macro cho FrameID, Function xử lý và add chúng vào mảng frame_handlers[].

Callback Function

    Để giải quyết Vấn đề 4. SW Layer tầng dưới có thể gọi hàm ở tầng trên, chúng ta hoàn toàn có thể dùng Function Pointer, và kỹ thuật này gọi là Callback Function.

    Theo Wikipedia, “In computer programming, a callback is a reference to executable code, or a piece of executable code, that is passed as an argument to other code. This allows a lower-level software layer to call a subroutine (or function) defined in a higher-level layer.”.

    Nói theo cách đơn giản, là có thể cho phép Lower Layer Function gọi đến Higher Layer Function. Để làm được điều đó,

Embedded C - Callback Function

  1. Higher Layer Function sẽ truyền địa chỉ của hàm cần gọi xuống Lower Layer Function (Register Callback).
  2. Khi cần gọi từ Lower Layer Function, sử dụng Function Pointer để gọi lên Function đã đăng ký.

    Ví dụ code sử dụng Callback giải quyết vấn đề ở đầu bài:

  1. /************* Application Layer (Higher Layer) **************/
  2. void User_Function(void)
  3. {
  4. // Toggle GPIOA pin 5
  5. GPIOA->ODR ^= (1U << 5);
  6. }
  7. // Register Callback Function
  8. DRV_Register_Callback(&User_Function);
  9. /*************** Driver Layer (Lower Layer) *****************/
  10. // Define callback function pointer type
  11. typedef void (*Callback_t)(void);
  12. // Global variable to store the registered callback
  13. static Callback_t pCallback = NULL_PTR;
  14. // Register a callback function
  15. void DRV_Register_Callback(Callback_t callback)
  16. {
  17. // Store the callback function pointer
  18. pCallback = callback;
  19. }
  20. // EXTI Interrupt Service Routine
  21. void EXTI_ISR(void)
  22. {
  23. // Check & Call the registered callback function
  24. if (pCallback != NULL_PTR)
  25. {
  26. pCallback();
  27. }
  28. }

    Ở trên mình có dùng thêm từ khóa typedef, typedef void (*Callback_t)(void); tạo ra một kiểu dữ liệu mới có tên là Callback_t, từ đó đơn giản hơn cho việc sử dụng.


Kết luận

    Function Pointer trong Embedded C là một công cụ mạnh mẽ, mở ra khả năng giải quyết các bài toán phức tạp trong hệ thống nhúng với độ linh hoạt và hiệu quả cao. Chúng cho phép gọi hàm giữa các Firmware mà không cần biết tên hàm cụ thể, quản lý Finite State Machine (FSM) thông qua mảng con trỏ hàm để giảm độ phức tạp của mã nguồn, và triển khai Callback Function để kết nối các tầng phần mềm một cách liền mạch. Những kỹ thuật này không chỉ tối ưu hóa việc sử dụng tài nguyên hạn chế trên vi điều khiển mà còn nâng cao tính mô-đun, khả năng bảo trì và tái sử dụng code trong các dự án thực tế như Bootloader, FOTA hay giao tiếp thời gian thực.

    Tuy nhiên, việc sử dụng Function Pointer đòi hỏi sự cẩn trọng để tránh các lỗi tiềm ẩn như trỏ đến địa chỉ hàm không hợp lệ hoặc gây khó khăn trong việc gỡ lỗi. Khi được áp dụng đúng cách, Function Pointer không chỉ là một khái niệm kỹ thuật mà còn là nền tảng để xây dựng các hệ thống nhúng mạnh mẽ, dễ dàng mở rộng và thích ứng với các yêu cầu phức tạp. Với lập trình viên Embedded C, việc làm chủ Function Pointer là bước tiến quan trọng để tạo ra các giải pháp phần mềm tinh gọn, hiệu suất cao và đáng tin cậy trong môi trường nhúng khắc nghiệt.

>>>>>> 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
//