RAM MEMORY: Cấu trúc, chức năng và thực hành debug trên VĐK STM32 (Phần 2)

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: HeapStack. Đâ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.

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.

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:

Đị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:

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.

Để 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

Tìm hiểu thêm:
Fanpage TAPIT: TAPIT – AIoT Learning