Bộ nhớ truy cập ngẫu nhiên (RAM) là thành phần không thể thiếu trong mọi hệ thống nhúng, đóng vai trò là nơi lưu trữ dữ liệu và các biến chương trình cần thiết cho hoạt động tức thời. Tiếp nối Phần 1 mình đã giới thiệu Tổng quan về RAM, cấu trúc và chức năng của phân vùng Data và BSS (xem phần 1 tại đây), Phần 2 này sẽ đi sâu vào hai phân vùng bộ nhớ cực kỳ quan trọng: Heap và Stack. Đây là những thành phần cốt yếu cho việc cấp phát bộ nhớ động và quản lý ngăn xếp chương trình.
Việc nắm vững cách hoạt động của Heap và Stack không chỉ giúp bạn tối ưu hóa hiệu suất ứng dụng mà còn hỗ trợ đắc lực trong quá trình debug, đặc biệt khi gặp phải các lỗi như tràn bộ nhớ (memory overflow) hay xung đột vùng nhớ.
–o–
Phần 2. Cấu trúc và chức năng của phân vùng Heap và Stack.
4. Kiến trúc chung của RAM
4.3. Phân vùng Heap
- Là phân vùng cấp phát bộ nhớ động (dynamic memory allocation). Bộ nhớ động là bộ nhớ được cấp phát trong quá trình chương trình đang chạy, được quản lý bởi các hàm malloc(), calloc(), realloc() và free().
- Trong tiếng Việt, “Heap” mang ý nghĩa là “đống”, giúp bạn dễ dàng hình dung phân vùng Heap như một nơi lưu trữ “đống” dữ liệu. Đặc tính “đống” dữ liệu ở đây thể hiện sự linh hoạt, rời rạc, không tuần tự và không liền kề (về mặt địa chỉ bộ nhớ) trong việc cấp phát và giải phóng bộ nhớ. Cụ thể hơn, khi chương trình yêu cầu cấp phát bộ nhớ cho một đối tượng hay một cấu trúc dữ liệu, địa chỉ bộ nhớ được cấp phát có thể là bất kỳ vị trí nào trên phân vùng Heap, không nhất thiết phải kế tiếp một phân vùng bộ nhớ khác đã được phân bổ. Khi vùng bộ nhớ không còn được sử dụng, nó sẽ được giải phóng bằng hàm free(), và vị trí đó sẵn sàng cho lần cấp phát tiếp theo.
- Thư viện của STM32 không định nghĩa phạm vi tối đa mà Heap có thể cấp phát. Nhưng địa chỉ bắt đầu của Heap, địa chỉ cao nhất của RAM, cũng chính là địa chỉ cao nhất của Stack và địa chỉ tối thiểu của Stack được định nghĩa chi tiết tại file STM32F103XXXX_FLASH.ld ngay trong folder của project.
Hình 1. Định nghĩa địa chỉ và kích thước của các phân vùng bộ nhớ RAM
Kích thước tối đa của Heap = Kích thước tối đa của RAM – Kích thước phân vùng Data – Kích thước phân vùng BSS – Kích thước tối thiểu của Stack
= (1010*102410) – 5416 – 16C16 – 40016 = 224016
Vậy địa chỉ của phân vùng Heap có địa chỉ bắt đầu là 0x200001C0 và địa chỉ kết thúc tại 0x20002400 (0x200001C0 + 0x2240).
- Dữ liệu trong Heap tự động được giải phóng sau khi chương trình kết thúc, một vấn đề có thể xảy ra là tràn cạn kiệt bộ nhớ Heap (Heap Exhaustion) trong quá trình chương trình thực thi do việc cấp phát bộ nhớ động với số lượng lớn nhưng không giải phóng phù hợp. Hậu quả dẫn đến việc không thể cấp phát thành công, chương trình bị treo hay ảnh hưởng đến cả hệ thống.
Ví dụ về Heap Exhaution: Trong ví dụ này, vòng lặp vô hạn liên tục cấp phát bộ nhớ mà không giải phóng, dẫn đến tràn heap.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
int main() { counter = 0; while (1) { int* ptr = (int*)malloc(200 * sizeof(int)); if (ptr == NULL) { __NOP(); break; } for (int i = 0; i < 200; i++) { *(ptr + i) = 0xFFFFFFFF; } counter++; // ... (Không có lệnh free(ptr)) } return 0; } |
Trong đoạn chương trình trên, chúng ta có một vòng lặp vô hạn while (1). Trong mỗi lần lặp, CPU triển khai lệnh malloc cấp phát một khối bộ nhớ 800 Byte (200*4 – Với vi điều khiển STM32, kích thước của int bằng 4) trên Heap.
Vậy điều kiện để thoát khỏi vòng lặp là gì? Với điều kiện ptr == NULL, malloc() sẽ trả về NULL trong trường hợp việc cấp phát bộ nhớ thất bại, cụ thể hơn khi Heap không còn đủ bộ nhớ để cấp phát. Chúng ta đã biết được kích thước tối đa của Heap là 224016 = 876810. Sau mỗi vòng lặp, Heap cấp phát 80010 byte dữ liệu và 8 byte metadata, vậy theo lý việc tính toán, có thể cấp phát thành công 10 lần (8768 / 808 = 10.85).
Mình sẽ debug chương trình trên, chúng ta sẽ quan sát sự thay đổi giá trị của:
- Con trỏ ptr, chính là địa chỉ của khối bộ nhớ được cấp phát từ Heap
- Biến counter, lưu trữ số lượng khối bộ nhớ được cấp phát thành công
Như chúng ta đã biết, địa chỉ bắt đầu của Heap là 0x200001C0. Với mỗi lần cấp phát, Heap dành 8 byte cho Metadata, dữ liệu khối bộ nhớ đầu tiên sẽ bắt đầu từ 0x200001C8.
Hình 2. Địa chỉ và dữ liệu của khối bộ nhớ Heap được cấp phát đầu tiên
Chúng ta đã tính toán với phạm vi lưu trữ tối đa của Heap (với VĐK STM32F103C6T6), Heap có thể cấp phát tối đa 10 khối 800 Byte. Kết quả debug đã chứng thực điều này.
Địa chỉ của khối bộ nhớ thứ 10 = 0x200001C0 + 810 + 910*80010 = 0x20001E30.
Hình 3. Địa chỉ và dữ liệu của khối bộ nhớ Heap được cấp phát cuối cùng
Khi Heap không đủ bộ nhớ để cấp phát, malloc() sẽ trả về NULL, việc cấp phát thất bại và chương trình kết thúc. Quan sát hình ảnh dưới đây, giá trị con trỏ ptr là NULL (tương đương 0x0).
Hình 4. Cấp phát thất bại do Heap không đủ bộ nhớ để cấp phát
Cách giải quyết việc tràn Heap: thêm free + điều kiện cho vòng lặp while.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
#define MAX_LOOP 10 int main() { int counter = 0; while (1) { int* ptr = (int*)malloc(200 * sizeof(int)); if (ptr == NULL || MAX_LOOP == counter) { __NOP(); break; } for (int i = 0; i < 200; i++) { *(ptr + i) = 0xFFFFFFFF; } counter++; free(ptr); } return 0; } |
Tại chương trình trên, chúng ta sử dụng free để giải phóng bộ nhớ không sử dụng nữa. Bên cạnh đó MAX_LOOP là điều kiện để số lượng vòng lặp là tối đa, nhằm đảm bảo tránh trường hợp việc cấp phát thất bại. Các bạn quan sát kết quả Debug ở hình ảnh dưới đây. Các bạn chú ý, việc sử dụng free() đã giải phóng bộ nhớ cấp phát sau mỗi vòng lặp nên giá trị của con trỏ ptr luôn luôn là 0x200001C8.
Hình 5. Bổ sung free() và điều kiện cho việc cấp phát bộ nhớ Heap
4.4. Phân vùng Stack
- Phân vùng stack (hay còn được gọi là ngăn xếp) hoạt động theo kiến trúc LIFO (Last In First Out). Đặc điểm của kiến trúc lưu trữ LIFO là phần tử cuối cùng đưa vào (lưu trữ trên Stack) sẽ là phần tử đầu tiên được lấy ra (giải phóng khỏi Stack). Hầu hết các hệ thống máy tính hiện nay, phân vùng Stack thường phát triển theo hướng giảm dần địa chỉ bộ nhớ, nghĩa là phát triển từ địa chỉ cao xuống địa chỉ thấp (ngược lại so với các phân vùng khác).
- Stack lưu trữ và quản lý các dữ liệu quan trọng trong quá trình thực thi chương trình đó là:
- Tham số đầu vào của hàm
- Biến cục bộ: biến được khởi tạo trong hàm
- Địa chỉ trả về: địa chỉ câu lệnh tiếp theo được thực thi ngay sau khi hàm kết thúc.
- Khi một hàm được gọi, các dữ liệu trên (tham số, địa chỉ trả về, biến cục bộ) sẽ được đẩy (push) vào Stack. Khi hàm kết thúc thực thi, các dữ liệu này sẽ lần lượt được lấy ra (pop) và giải phóng khỏi Stack.
- Có một con trỏ được gọi là con trỏ Stack (SP – Stack Pointer) luôn trỏ đến đỉnh (top) của Stack. Đó là vị trí cuối cùng chứa dữ liệu hợp lệ trong phân vùng Stack. Khi dữ liệu được push (thêm vào) vào Stack, SP sẽ giảm giá trị (trỏ đến địa chỉ nhỏ hơn / phía dưới) và dữ liệu sẽ được lưu ngay tại địa chỉ mà SP trỏ tới, chính là giá trị của SP. Ngược lại, khi dữ liệu được pop khỏi Stack, giá trị SP sẽ tăng và trỏ đến phần tử tiếp theo sau đó.
- Con trỏ SP trong phân vùng Stack có địa chỉ cao nhất trong bộ nhớ RAM. Để tính toán được địa chỉ của SP, chúng ta cần biết được địa chỉ bắt đầu của RAM và kích thước của nó. Các bạn tìm mở file STM32F103XXXX_FLASH.ld ngay trong folder của project. Đối với kit mình đang sử dụng STM32F103C6T6, địa chỉ bắt đầu và kích thước của RAM được định nghĩa dưới đây:
1 2 3 4 5 |
MEMORY { RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 10K FLASH (rx) : ORIGIN = 0x8000000, LENGTH = 32K } |
Địa chỉ của SP sẽ được tính toán như sau:
- Địa chỉ của Stack: 10 KByte ~ 10*1024 = 10.240 Byte (= 0x2800)
- Địa chỉ của SP: 0x20000000 + 0x2800 = 0x20002800
Ví dụ về dữ liệu được lưu trữ trong Stack:
1 2 3 4 5 6 7 8 9 10 11 12 |
int Sum(int a, int b) { int res = a + b; return res; } int main(void) { int value1 = 10; int value2 = 5; int result = Sum(value1, value2); //Todo } |
Dựa theo các kiến thức về Stack mình vừa trình bày, các bạn cùng phân tích ví dụ trên và xác định những dữ liệu nào sẽ được lưu trữ vào Stack khi thực thi chương trình trên nhé!
Chúng ta cùng phân tích với 4 giai đoạn:
- Hàm main() bắt đầu thực thi
- Hàm Sum() được gọi
- Hàm Sum() trả về dữ liệu
- Kết thúc hàm main()
a. Hàm main() bắt đầu thực thi
Dữ liệu trong Stack | Kích thước | Ghi chú |
Địa chỉ trả về của main() | 4 | Thanh ghi lr lưu địa chỉ trả về của main() được push vào Stack |
value1 | 4 | Biến cục bộ của main() |
value2 | 4 | Biến cục bộ của main() |
result | 4 | Sẽ được gán giá trị sau khi sum thực thi xong |
b. Hàm Sum() được gọi
Dữ liệu trong Stack | Kích thước | Ghi chú |
Địa chỉ trả về của main() | 4 | Thanh ghi lr lưu địa chỉ trả về của main() được push vào Stack |
value1 | 4 | Biến cục bộ của main() |
value2 | 4 | Biến cục bộ của main() |
result | 4 | Sẽ được gán giá trị sau khi sum thực thi xong |
a | 4 | Tham số được truyền vào sum() |
b | 4 | Tham số được truyền vào sum() |
Địa chỉ trả về của Sum() | 4 | Thanh ghi lr lưu địa chỉ trả về của Sum() được push vào Stack |
res | 4 | Biến cục bộ của Sum() |
c. Sum() trả về dữ liệu
Dữ liệu trong Stack | Kích thước | Ghi chú |
Địa chỉ trả về của main() | 4 | Thanh ghi lr lưu địa chỉ trả về của main() được push vào Stack |
value1 | 4 | Biến cục bộ của main() |
value2 | 4 | Biến cục bộ của main() |
result | 4 | Được gán với giá trị Sum() trả về |
d.Kết thúc hàm main
Dữ liệu trong Stack | Kích thước | Ghi chú |
Empty | 0 |
Khi chương trình hàm main kết thúc, các biến cục bộ trong main (value1, value2, result) được giải phóng. Địa chỉ trả về của main() được lấy ra và CPU nhảy về tại địa chỉ đó.
Kết quả trên chỉ dựa theo sự phân tích dựa theo lý thuyết mình trình bày. Để có thể thấy được sự hoạt động của Stack trên Vi điều khiển STM32, chúng ta sẽ phân tích chương trình trên dưới dạng kết quả của giai đoạn 2 trong 4 giai đoạn của quy trình biên dịch (phân tích cú pháp và chuyển đổi file về ngôn ngữ bậc thấp assembly). Các bạn cần lưu ý ý nghĩa của một số từ khoá dưới đây:
- sp (stack pointer): thanh ghi chứa địa chỉ của đỉnh hiện tại của stack.
- r7: thanh ghi thường được sử dụng để chứa con trỏ Frame (Frame Pointer) để trỏ đến một vị trí trong khung stack của hàm hiện tại được thực thi.
- lr (link register): thanh ghi được sử dụng để lưu trữ địa chỉ trả về khi một hàm được gọi. Cụ thể hơn, khi hàm main() gọi hàm Sum(), địa chỉ của lệnh ngay sau lời gọi hàm Sum() trong hàm main() sẽ được lưu vào lr, đó là câu lệnh để gán giá trị vào biến result.
- pc (Program Counter): thanh ghi đặc biệt quan trọng, chứa địa chỉ của lệnh tiếp theo mà bộ xử lý sẽ thực thi.
Sau khi áp dụng đoạn chương trình trên và biên dịch trên vi điều khiển STM32F103, trình IDE STM32Cube đã chuyển đổi và sinh ra được chương trình Assembly dưới đây.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
Sum 0800014c: push {r7} ; Lưu giá trị của r7 vào stack 0800014e: sub sp, #20 ; Dịch con trỏ stack xuống 20 bytes (tạo không gian cho biến cục bộ) 08000150: add r7, sp, #0 ; r7 trở thành con trỏ khung stack (frame pointer) 08000152: str r0, [r7, #4] ; Lưu giá trị của r0 (tham số đầu tiên) vào địa chỉ [r7 + 4] trên stack 08000154: str r1, [r7, #0] ; Lưu giá trị của r1 (tham số thứ hai) vào địa chỉ [r7 + 0] trên stack int res = a + b; 08000158: ldr r2, [r7, #4] ; Tải giá trị từ địa chỉ [r7 + 4] (tham số đầu tiên) vào r2 0800015a: ldr r3, [r7, #0] ; Tải giá trị từ địa chỉ [r7 + 0] (tham số thứ hai) vào r3 0800015c: add r3, r2 ; Cộng giá trị của r2 và r3, kết quả lưu vào r3 0800015e: str r3, [r7, #12] ; Lưu kết quả (trong r3) vào địa chỉ [r7 + 12] trên stack return res; 08000160: ldr r3, [r7, #12] ; Tải kết quả từ địa chỉ [r7 + 12] vào r3 08000162: mov r0, r3 ; Di chuyển kết quả từ r3 vào r0 (giá trị trả về của hàm) 08000164: adds r7, #20 ; Khôi phục con trỏ khung stack 08000166: mov sp, r7 ; Khôi phục con trỏ stack 08000168: pop {r7} ; Lấy lại giá trị ban đầu của r7 từ stack 0800016a: bx lr ; Branch and exchange (trở về địa chỉ được lưu trong lr - link register) main: 0800016c: push {r7, lr} ; Lưu giá trị của r7 và lr vào stack 0800016e: sub sp, #16 ; Dịch con trỏ stack xuống 16 bytes (tạo không gian cho biến cục bộ) 08000170: add r7, sp, #0 ; r7 trở thành con trỏ khung stack int value1 = 10; 08000172: movs r3, #10 ; Di chuyển giá trị 10 vào r3 08000174: str r3, [r7, #12] ; Lưu giá trị của r3 (value1) vào địa chỉ [r7 + 12] trên stack int value2 = 5; 08000176: movs r3, #5 ; Di chuyển giá trị 5 vào r3 08000178: str r3, [r7, #8] ; Lưu giá trị của r3 (value2) vào địa chỉ [r7 + 8] trên stack int result = Sum(value1, value2); 0800017a: ldr r1, [r7, #8] ; Tải giá trị của value2 từ [r7 + 8] vào r1 (tham số thứ hai cho Sum) 0800017c: ldr r0, [r7, #12] ; Tải giá trị của value1 từ [r7 + 12] vào r0 (tham số đầu tiên cho Sum) 0800017e: bl 0x800014c <Sum> ; Branch with link (gọi hàm Sum, địa chỉ trả về được lưu vào lr) 08000182: str r0, [r7, #4] ; Lưu giá trị trả về từ Sum (trong r0) vào địa chỉ [r7 + 4] trên stack (biến result) 08000184: movs r3, #0 ; Di chuyển giá trị 0 vào r3 (giá trị trả về của main) } 0800018e: mov r0, r3 ; Di chuyển giá trị trả về của main (0) vào r0 |
Để thực sự hiểu sâu và ghi nhớ cách RAM hoạt động, bạn nên kết hợp lý thuyết với việc debug trực tiếp trên vi điều khiển. Bạn có thể sử dụng bất kỳ dòng vi điều khiển nào, nhưng STM32 là lựa chọn ưu tiên. Lý do là bạn sẽ được làm việc với bộ thư viện đầy đủ và các hướng dẫn sử dụng chi tiết do hãng cung cấp, giúp việc phân tích các định nghĩa giá trị liên quan đến bộ nhớ trở nên dễ dàng hơn rất nhiều.
Chuỗi bài viết này không chỉ cung cấp lý thuyết mà còn hướng dẫn chi tiết cách debug. Dù bạn làm việc trong lĩnh vực lập trình nhúng hay lập trình trong bất kỳ lĩnh vực nào khác, RAM luôn là bộ nhớ cực kỳ quan trọng, chúng ta cần hiểu và biết cách quản lý dữ liệu trên RAM. Mình hy vọng chuỗi bài viết này sẽ giúp bạn có cái nhìn tổng quan về RAM, hiểu rõ các phân vùng, biết cách debug, phân tích và sử dụng RAM hiệu quả.
Chúc các bạn thành công!
N.T.Nhien