🌱 Embedded C: Address và Pointer (Con trỏ) theo cách dễ hiểu

🌱 Embedded C - Address và Pointer (Con trỏ) theo cách dễ hiểu

    Trong lập trình Embedded C, con trỏ (Pointer) là một công cụ thiết yếu để quản lý bộ nhớ, truy cập phần cứng, và xử lý dữ liệu hiệu quả. Có vẻ như đối với nhiều bạn mới học về embedded, thì khái niệm con trỏ có vẻ khá khó tiếp cận.

    Bài viết này sẽ giải thích một cách dễ hiểu về các khái niệm liên quan đến Pointer như: địa chỉ (Address), cách sử dụng pointer, và ứng dụng thực tế của con trỏ trong Embedded C.


Vấn đề: Truy cập thanh ghi và truyền tham số vào hàm

    Trong lập trình nhúng, hai tình huống phổ biến là truy cập thanh ghi phần cứng và truyền tham số vào hàm. Nếu không sử dụng con trỏ, những thao tác này có thể trở nên phức tạp, khó đọc, và kém hiệu quả. Hãy xem xét hai ví dụ sau:

  1. Truy cập thanh ghi
    Các thanh ghi ngoại vi của MCU được đặt tại một ô địa chỉ cố định, giả sử với Vi điều khiển STM32, để set giá trị chân GPIO0 cần truy cập địa chỉ 0x40020000
    Với những kiến thức hiện có ngoài con trỏ, không có cách nào để ta có thể truy cập vào địa chỉ này!
  2. Truyền tham số vào hàm
    Đối với việc truyền tham số vào hàm, bạn có thể tham khảo bài viết về Function Call, thực tế việc call hàm sẽ copy phần giá trị của các biến vào stack (Pass by Value). Vì vậy việc thay số giá trị của các biến đầu vào là bất khả thi, ví dụ hàm swap() bên dưới.

    void swap(int a, int b) {
        int temp = a;
        a = b;
        b = temp; // Chỉ thay đổi bản sao cục bộ, không ảnh hưởng đến biến gốc
    }
    
    int main() {
        int x = 10, y = 20;
        swap(x, y); // x vẫn là 10, y vẫn là 20
        return 0;
    }
    Hơn nữa, với các tham số có kích thước lớn như kiểu dữ liệu Struct, việc copy value lên stack sẽ gây tốn bộ nhớ và có thể gây tràn stack!

    ↪ Để giải quyết 2 vấn đề trên, chúng ta cần một cơ chế để có thể truy cập vào một ô địa chỉ, hoặc địa chỉ của một biến, và C cung cấp cho ta khái niệm con trỏ - pointer để giải quyết vấn đề này!


Mục Lục


Giải pháp: Sử dụng Pointer trong Embedded C

Con trỏ (Pointer) trong Embedded C là công cụ mạnh mẽ để truy cập bộ nhớ, thao tác với phần cứng, và xử lý dữ liệu hiệu quả. Bằng cách lưu trữ địa chỉ bộ nhớ, con trỏ cho phép lập trình viên thao tác trực tiếp với dữ liệu hoặc thanh ghi, giảm lãng phí tài nguyên và tăng tính dễ đọc của code.

Address là gì?

    Address (Địa chỉ): Là vị trí cụ thể trong bộ nhớ nơi dữ liệu được lưu trữ. Mỗi đối tượng như biến, hàm trong chương trình có một địa chỉ bộ nhớ duy nhất, có thể lấy bằng toán tử &. Ví dụ, nếu biến value được lưu tại địa chỉ 0x20000000, &value sẽ trả về giá trị này.

    Đối với một đối tượng lưu trữ trên nhiều byte ô nhớ (ví dụ uint32_t var = 0x0A0B0C0D có 4 bytes), thì mỗi ô có một địa chỉ, nhưng địa chỉ của biến này là địa chỉ nhỏ nhất trong các ô nhớ đó. Điều tương tự cũng xảy ra với function.

Embedded C - Address of Variable

Địa chỉ của Biến trên bộ nhớ

Lưu ý: Địa chỉ này hoàn toàn không phụ thuộc và đặc tính Endianness - Endian chỉ ảnh hưởng đến thứ tự sắp xếp các byte trên bộ nhớ. Còn địa chỉ của biến vẫn luôn là địa chỉ nhỏ nhất (Ví dụ trên là 0x100).

Pointer là gì?

    Pointer (Con trỏ) hiểu đơn giản là một biến, nhưng thay vì chứa một giá trị integer, float, character, ... thì pointer chứa giá trị là một địa chỉ (Thực tế địa chỉ vẫn là một số nguyên, nhưng ý nghĩa của nó là Address).

    Hãy nhớ khái niệm này, vì nó sẽ ảnh hưởng tới toàn bộ việc bạn hiểu và sử dụng Pointer, ở đây có một số đặc điểm quan trọng:

Pointer cũng là một biến

    Pointer cũng là một biến, vậy nó cũng sẽ có những đặc điểm như:

  1. Identifier, tuân thủ quy tắc đặt tên biến;
  2. Nó cũng nằm trên bộ nhớ và cũng có địa chỉ;
  3. Nếu không gán giá trị gì cho pointer, nó cũng mang giá trị nào đó giống như biến (Nếu để local thì nó mang giá trị địa chỉ bất kỳ)
  4. Với biến (Global) không được khởi tạo, hoặc chưa dùng, dev gán nó bằng 0, thì với pointer, có thể gán nó bằng NULL. NULL được định nghĩa là (void *)0 và biểu diễn "không trỏ đến đâu cả"
  5. Pointer cũng có kích thước - Pointer mục tiêu là lưu trữ địa chỉ, vậy chip cung cấp bao nhiêu bit để đánh địa chỉ thì đó chính là kích thước của Pointer. Giả sử CPU vi điều khiển STM32 có kiến trúc 32-bits (thanh ghi core có kích thước 32-bits, Memory Model 32-bits, thì kích thước của pointer sẽ là 32-bits (4 bytes);
    Vậy có thể kết luận, kích thước của pointer sẽ phần lớn phụ thuộc vào Kiến trúc CPU (ngoài ra còn là Memory Model, Compiler, OS, ..., hầu hết các thành phần này cũng xây dựng theo Kiến trúc CPU nên hiếm khi có ngoại lệ về kích thước).
  6. Các toán tử sử dụng với Pointer (Cộng/trừ) sẽ đề cập trong Video bên dưới!

Pointer dùng để cung cấp cơ chế truy xuất đến vùng nhớ

    Như ví dụ trên, giả sử tạo ra một pointer tên là ptr = &var; (0x100), vậy mục đích là có thể sử dụng ptr để tác động (đọc/ghi) đến biến var. Nhưng CPU làm sao biết được nó cần lấy 1 bytes, 2 bytes hay 4 bytes từ địa chỉ 0x100? Chính vì thế, bản thân Pointer cần xác định kiểu dữ liệu mà nó trỏ đến!

Cú pháp khai báo:

data_type *pointer_name = &var;
*pointer_name = 10;    // same as var = 10;

Trong đó:

  • data_type: Kiểu dữ liệu của biến mà con trỏ trỏ đến (ví dụ: uint8_tuint32_t).
  • pointer_name: Tên của con trỏ.
  • &: Lấy địa chỉ của biến.
  • *: Truy cập giá trị tại địa chỉ mà con trỏ trỏ đến (dereference).

    Ở đây, data_type là sẽ quyết định việc pointer sẽ trỏ đến kiểu dữ liệu gì? khi dùng toán tử dereference với pointer, thì sẽ lấy theo kiểu dữ liệu nào. Giả sử cùng là địa chỉ 0x100 như trên:

  • uint8_t * ptr = 0x100; thì *ptr sẽ chỉ tác động vào 1 byte 0x100 (char)
  • uint16_t * ptr = 0x100; thì *ptr sẽ chỉ tác động vào 2 byte 0x100 và 0x101 (short)
  • uint32_t * ptr = 0x100; thì *ptr sẽ tác động vào 4 byte từ 0x100 đến 0x103 (int)
  • function * ptr = 0x100; thì *ptr sẽ call hàm tại địa chỉ 0x100 (chạy đoạn mã lệnh đầu tiên của hàm đó tại địa chỉ 0x100) ➜ Cú pháp này là giả định, khái niệm và cú pháp sử dụng Function Pointer mình sẽ đề cập ở bài viết sau!
  • void * ptr = 0x100. Ops~ Khá lạ phải không, như này thì *ptr sẽ không biết sẽ tác động vào bao nhiêu bytes ➤ Chính vì vậy *ptr bị báo lỗi khi compile.

VOID pointer

    void pointer được sử dụng để lưu trữ một giá trị địa chỉ, nhưng chưa xác định kiểu dữ liệu mà nó trỏ tới, khi muốn sử dụng toán tử dereference (*), dev cần ép kiểu pointer đó thành các kiểu dữ liệu pointer cụ thể, ví dụ int*;

int a = 10;
void * ptr = &a;
printf("%d", *ptr);         // Compiler báo lỗi
printf("%d", *(int*)ptr);   // 10

    void pointer thường dùng trong các hàm thao tác chung, ví dụ như trong thư viện qsort, memcpy, hoặc callback functions, ...

void* memcpy(void* dest, const void* src, size_t n);

↪ Oh, vậy thì có vẻ như 2 vấn đề ở đầu bài đã được giải quyết với khái niệm Pointer này!

Vấn đề 1: Truy cập thanh ghi

Giả sử có một thanh ghi ngoại vi GPIO (GPIOA - thanh ghi MODER) của vi điều khiển STM32, chúng ta tra tài liệu (STM32F401xx Reference Manualvà thấy nó nằm ở địa chỉ sau:

Embedded C - Pointer access MCU Registers

    Có địa chỉ thì dễ rồi! Giả sử cần cấu hình chân GPIOA-5 với chế độ output mode - thì cần ghi vào bit [11:10] của thanh ghi GPIOA_MODER giá trị là 01.

    Chúng ta tra tài liệu và thấy địa chỉ thanh ghi GPIOA_MODER là 0x4002.0000. Bây giờ, với khái niệm Pointer, chúng ta có thể tạo một pointer để access đến địa chỉ thanh ghi đó, ghi giá trị mong muốn vào là xong.

uint32_t *GPIOA_MODER = (uint32_t*)0x40020000;    // Create a pointer to the register
*GPIOA_MODER |= (1U << 10);    // Use Dereference Operator (*) to access to the register
// Use the bitwise to set bit 10 as 1

    Tuyệt vời, từ đó bạn hoàn toán có thể viết một chương trình MCU mà không cần bất cứ thư viện hỗ trợ nào!

Lập trình Thanh Ghi Ngoại vi GPIO STM32 - Blinked LED

Vấn đề 2: Pass by Reference

    Như đã đề cập ở phần đặt vấn đề, việc truyền tham số vào hàm, nếu không sử dụng pointer, sẽ gây ra 2 vấn đề:

  • Không thể thay đổi giá trị biến/tham số đầu vào, do tham số chỉ được copy giá trị từ biến thực tế - Pass by Value.
  • Giá trị của tham số cần được copy lên stack frame của hàm được gọi, gây tốn thời gian và bộ nhớ nếu kích thước tham số lớn (Ví dụ như struct).

Để giải quyết cả 2 vấn đề, câu trả lời là truyền địa chỉ của biến vào hàm, thay vì chỉ truyền giá trị.

➥ Truyền địa chỉ, và từ địa chỉ đó sử dụng toán tử Dereference (*) có thể truy xuất và thay đổi trực tiếp biến được truyền vào!

Ví dụ bài toán swap 2 biến đầu vào ở trên:

  1. void swap(int *addr_a, int *addr_b) {
  2. int temp = *addr_a; // Same as: temp = a
  3. *addr_a = *addr_b; // Same as: a = b
  4. *addr_b = temp; // Same as: b = temp
  5. }
  6. int main() {
  7. int x = 10, y = 20;
  8. swap(&x, &y); // Pass Address instead of Value
  9. return 0;
  10. }

> Đọc kỹ hơn về việc chênh lệnh bộ nhớ giữa 2 cách truyền tham số: Truyền tham trị (Pass Value) và Tham chiếu (Pass Reference) trong C

➥ Truyền địa chỉ, đối với các biến có kích thước lớn như struct, thay vì truyền giá trị, dev có thể truyền địa chỉ của struct đó vào hàm, việc copy sẽ chỉ copy địa chỉ (kích thước cố định), thay vì copy cả struct lớn, giúp tiết kiệm thời gian và bộ nhớ stack.
Việc này cực kỳ hữu ích và sử dụng thường xuyên trong các thư viện Driver của Vi điều khiển!

  1. #include <stdint.h>
  2. // Ví dụ struct cấu hình ngoại vi GPIO có rất nhiều thông số như thế này
  3. typedef struct
  4. {
  5. uint8_t GPIO_PinNumber; // Chân số 0..15
  6. uint8_t GPIO_PinMode; // 0: Input, 1: Output, 2: Alternate, 3: Analog
  7. uint8_t GPIO_PinSpeed; // 0: Low, 1: Medium, 2: Fast, 3: High
  8. uint8_t GPIO_PinInputMode; // 0: No Pull, 1: Pull-up, 2: Pull-down
  9. uint8_t GPIO_PinOutputMode; // 0: Push-pull, 1: Open-drain
  10. uint8_t GPIO_PinAlternate; // AF0 - AF15 (nếu dùng Alternate Function)
  11. uint8_t EXTI_Mode; // 0: Không dùng EXTI, 1: Rising, 2: Falling, 3: Both
  12. } GPIO_PinConfigType;
  13. // Hàm Init để cấu hình cho GPIO, tham số thứ 2, bạn sẽ luôn thấy nó truyền Pointer của bộ config thay vì Pass by Value!
  14. void GPIOA_Init(const GPIO_PinConfigType *pUserConfig)
  15. {
  16. uint8_t pin = pUserConfig->GPIO_PinNumber;
  17. // MODER - Offset 0x00
  18. volatile uint32_t *GPIOA_MODER = (uint32_t*)0x40020000;
  19. *GPIOA_MODER &= ~(0x3 << (pin * 2)); // Clear
  20. *GPIOA_MODER |= (pUserConfig->GPIO_PinMode << (pin * 2));
  21. // OTYPER - Offset 0x04
  22. volatile uint32_t *GPIOA_OTYPER = (uint32_t*)0x40020004;
  23. *GPIOA_OTYPER &= ~(1 << pin);
  24. *GPIOA_OTYPER |= (pUserConfig->GPIO_PinOutputMode << pin);
  25. // OSPEEDR - Offset 0x08
  26. volatile uint32_t *GPIOA_OSPEEDR = (uint32_t*)0x40020008;
  27. *GPIOA_OSPEEDR &= ~(0x3 << (pin * 2));
  28. *GPIOA_OSPEEDR |= (pUserConfig->GPIO_PinSpeed << (pin * 2));
  29. // PUPDR - Offset 0x0C
  30. volatile uint32_t *GPIOA_PUPDR = (uint32_t*)0x4002000C;
  31. *GPIOA_PUPDR &= ~(0x3 << (pin * 2));
  32. *GPIOA_PUPDR |= (pUserConfig->GPIO_PinInputMode << (pin * 2));
  33. // (Bỏ qua Alternate & EXTI vì PA5 chỉ cần output LED)
  34. }
  35. // Khi sử dụng:
  36. int main(void)
  37. {
  38. GPIO_PinConfigType ledPinConfig;
  39. ledPinConfig.GPIO_PinNumber = 5; // PA5
  40. ledPinConfig.GPIO_PinMode = 1; // Output
  41. ledPinConfig.GPIO_PinSpeed = 2; // Fast speed
  42. ledPinConfig.GPIO_PinInputMode = 0; // No pull-up/pull-down
  43. ledPinConfig.GPIO_PinOutputMode = 0; // Push-pull
  44. ledPinConfig.GPIO_PinAlternate = 0; // Not used
  45. ledPinConfig.EXTI_Mode = 0; // Not used
  46. // Cấu hình Enable Clock cho GPIOA
  47. volatile uint32_t *RCC_AHB1ENR = (uint32_t*)0x40023830;
  48. *RCC_AHB1ENR |= (1 << 0); // Enable Clock for GPIOA
  49. GPIOA_Init(&ledPinConfig); // Truyền bằng con trỏ
  50. // Bật LED tại PA5
  51. volatile uint32_t *GPIOA_ODR = (uint32_t*)0x40020014;
  52. *GPIOA_ODR |= (1 << 5);
  53. while (1);
  54. }

struct pointer

    Struct pointer là con trỏ trỏ tới một struct trong C/C++. Nó cho phép truy cập và thao tác dữ liệu trong struct thông qua con trỏ, rất hữu ích khi làm việc với hàm, dynamic memory, linked list, hoặc embedded systems.

    Như ở ví dụ trên, chúng ta sử dụng: pUserConfig->GPIO_PinNumber, thực tế, ta có thể viết (*pUserConfig).GPIO_PinNumber. Với *pUserConfig để lấy struct mà pointer trỏ đến, sau đó dùng toán tử (.) để truy cập đến từng phần tử của struct. C cung cấp toán tử (➜) để truy cập từ struct pointer đến từng phần tử bên trong.

    Tương tự như biến hay hàm, struct pointer cũng trỏ đến địa chỉ thấp nhất trong struct mà nó trỏ đến.

Video về Pointer


Pointer và Array

    Trong C, mảng và con trỏ có mối quan hệ chặt chẽ: tên mảng là con trỏ đến phần tử đầu tiên của mảng. Điều này cho phép sử dụng số học con trỏ để truy cập các phần tử mảng một cách hiệu quả, đặc biệt trong Embedded C khi cần tiết kiệm bộ nhớ.

Ví dụ: Tính tổng mảng

  1. #include <stdio.h>
  2. int sum_array(int *arr, int size) {
  3. int sum = 0;
  4. for (int i = 0; i < size; i++) {
  5. sum += *(arr + i); // Số học con trỏ
  6. }
  7. return sum;
  8. }
  9. int main() {
  10. int data[] = {1, 2, 3, 4, 5};
  11. int size = sizeof(data) / sizeof(data[0]);
  12. int total = sum_array(data, size); // total = 15
  13. printf("total = %d", total);
  14. return 0;
  15. }

Giải thích:

  • arr là con trỏ đến phần tử đầu tiên của mảng data.
  • *(arr + i) sử dụng số học con trỏ để truy cập phần tử thứ i.
  • Truyền mảng qua con trỏ tránh sao chép toàn bộ mảng, tiết kiệm bộ nhớ, đặc biệt quan trọng trong hệ thống nhúng.

Kết luận

Pointer là công cụ không thể thiếu trong Embedded C, mang lại các lợi ích:

  • Truy cập trực tiếp vào thanh ghi phần cứng và quản lý bộ nhớ hiệu quả.
  • Xử lý mảng và truyền tham chiếu linh hoạt, tiết kiệm tài nguyên.
  • Tăng tính dễ đọc và bảo trì mã nguồn khi sử dụng đúng cách.

Tuy nhiên, cần sử dụng con trỏ cẩn thận để tránh lỗi như null pointer, truy cập bộ nhớ không hợp lệ, hoặc vượt giới hạn mảng. Kết hợp với các kỹ thuật như volatile và kiểm tra địa chỉ, con trỏ sẽ giúp bạn xây dựng mã nguồn mạnh mẽ và đáng tin cậy trong các dự án nhúng.


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