🌱 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:
- 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! - 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
- Vấn đề 1: Truy cập thanh ghi
- Vấn đề 2: Pass by Reference
- Pointer và Array
- Kết luận
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.
![]() |
Địa chỉ của Biến trên bộ nhớ |
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ư:
- Có Identifier, tuân thủ quy tắc đặt tên biến;
- Nó cũng nằm trên bộ nhớ và cũng có địa chỉ;
- 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ỳ)
- 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ả"
- 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). - 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_t
,uint32_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 Manual) và thấy nó nằm ở địa chỉ sau:
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:
- void swap(int *addr_a, int *addr_b) {
- int temp = *addr_a; // Same as: temp = a
- *addr_a = *addr_b; // Same as: a = b
- *addr_b = temp; // Same as: b = temp
- }
- int main() {
- int x = 10, y = 20;
- swap(&x, &y); // Pass Address instead of Value
- return 0;
- }
> Đọ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!
- #include <stdint.h>
- // Ví dụ struct cấu hình ngoại vi GPIO có rất nhiều thông số như thế này
- typedef struct
- {
- uint8_t GPIO_PinNumber; // Chân số 0..15
- uint8_t GPIO_PinMode; // 0: Input, 1: Output, 2: Alternate, 3: Analog
- uint8_t GPIO_PinSpeed; // 0: Low, 1: Medium, 2: Fast, 3: High
- uint8_t GPIO_PinInputMode; // 0: No Pull, 1: Pull-up, 2: Pull-down
- uint8_t GPIO_PinOutputMode; // 0: Push-pull, 1: Open-drain
- uint8_t GPIO_PinAlternate; // AF0 - AF15 (nếu dùng Alternate Function)
- uint8_t EXTI_Mode; // 0: Không dùng EXTI, 1: Rising, 2: Falling, 3: Both
- } GPIO_PinConfigType;
- // 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!
- void GPIOA_Init(const GPIO_PinConfigType *pUserConfig)
- {
- uint8_t pin = pUserConfig->GPIO_PinNumber;
- // MODER - Offset 0x00
- volatile uint32_t *GPIOA_MODER = (uint32_t*)0x40020000;
- *GPIOA_MODER &= ~(0x3 << (pin * 2)); // Clear
- *GPIOA_MODER |= (pUserConfig->GPIO_PinMode << (pin * 2));
- // OTYPER - Offset 0x04
- volatile uint32_t *GPIOA_OTYPER = (uint32_t*)0x40020004;
- *GPIOA_OTYPER &= ~(1 << pin);
- *GPIOA_OTYPER |= (pUserConfig->GPIO_PinOutputMode << pin);
- // OSPEEDR - Offset 0x08
- volatile uint32_t *GPIOA_OSPEEDR = (uint32_t*)0x40020008;
- *GPIOA_OSPEEDR &= ~(0x3 << (pin * 2));
- *GPIOA_OSPEEDR |= (pUserConfig->GPIO_PinSpeed << (pin * 2));
- // PUPDR - Offset 0x0C
- volatile uint32_t *GPIOA_PUPDR = (uint32_t*)0x4002000C;
- *GPIOA_PUPDR &= ~(0x3 << (pin * 2));
- *GPIOA_PUPDR |= (pUserConfig->GPIO_PinInputMode << (pin * 2));
- // (Bỏ qua Alternate & EXTI vì PA5 chỉ cần output LED)
- }
- // Khi sử dụng:
- int main(void)
- {
- GPIO_PinConfigType ledPinConfig;
- ledPinConfig.GPIO_PinNumber = 5; // PA5
- ledPinConfig.GPIO_PinMode = 1; // Output
- ledPinConfig.GPIO_PinSpeed = 2; // Fast speed
- ledPinConfig.GPIO_PinInputMode = 0; // No pull-up/pull-down
- ledPinConfig.GPIO_PinOutputMode = 0; // Push-pull
- ledPinConfig.GPIO_PinAlternate = 0; // Not used
- ledPinConfig.EXTI_Mode = 0; // Not used
- // Cấu hình Enable Clock cho GPIOA
- volatile uint32_t *RCC_AHB1ENR = (uint32_t*)0x40023830;
- *RCC_AHB1ENR |= (1 << 0); // Enable Clock for GPIOA
- GPIOA_Init(&ledPinConfig); // Truyền bằng con trỏ
- // Bật LED tại PA5
- volatile uint32_t *GPIOA_ODR = (uint32_t*)0x40020014;
- *GPIOA_ODR |= (1 << 5);
- while (1);
- }
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
- #include <stdio.h>
- int sum_array(int *arr, int size) {
- int sum = 0;
- for (int i = 0; i < size; i++) {
- sum += *(arr + i); // Số học con trỏ
- }
- return sum;
- }
- int main() {
- int data[] = {1, 2, 3, 4, 5};
- int size = sizeof(data) / sizeof(data[0]);
- int total = sum_array(data, size); // total = 15
-
- printf("total = %d", total);
-
- return 0;
- }
Giải thích:
arr
là con trỏ đến phần tử đầu tiên của mảngdata
.*(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 😊