Mở đầu

Có lẽ khi lập trình không nhiều người quan tâm tới cách bộ nhớ tổ chức lưu trữ và thao tác với biến như thế nào (vì nó hơi khó) nhưng khi nắm được nó bạn sẽ thấy được nhiều vấn đề rất tường minh, nó liên quan tới phần cứng của máy tính nên nắm được nó cũng giúp cho bạn có nền tảng vững hơn khi học sâu và rộng hơn về sau này.

Ngôn ngữ phù hợp nhất để mô tả và giải thích cho việc lưu trữ biến trong bộ nhớ chính là C và C++, nên những ví dụ phía dưới mình sẽ lấy ví dụ từ C và C++.

#include <iostream>
#include <string>

int main()
{
  int a = 3;
  int b = a;  // gán biến thông thường
  int &r = a;  // tạo tham chiếu (reference) của biến a (tạo alias cho biến a)
  int *p = &a; // tạo con trỏ (pointer) tới biến a
  
  printf("địa chỉ của biến (variable) a: %d\n", &a);
  printf("giá trị của biến a: %d\n\n", a);
  
  printf("địa chỉ của tham chiếu (reference) r: %d\n", &r);
  printf("giá trị của tham chiếu r: %d\n\n", r);
  
  printf("địa chỉ của con trỏ (pointer) p: %d\n", &p);
  printf("giá trị của con trỏ p: %d\n", p);
  printf("giá trị của biến mà con trỏ p đang trỏ tới: %d\n", *p);
}

Mô tả bộ nhớ lưu trữ chương trình

Có một số thứ dưới đây bạn phải nắm được:

  • Khi source code của bạn được biên dịch thì các định danh (identifier) như tên biến sẽ không được lưu trữ trong stack (stack là nơi bộ nhớ lưu trữ chương trình sau khi được biên dịch để cho CPU đọc).

Tất nhiên chỉ là khi biên dịch thôi, còn trước khi biên dịch thành công thì các identifier phải được lưu trữ trong bộ nhớ. Các identifier này được lưu trữ trong một bảng gọi là symbol table, bảng này được tạo và quản lý bởi chính trình biên dịch compiler.

  • Nhắc lại nguyên lý 2 Von Neumann.
Nguyên lý 2 Von Neumann: Chương trình máy tính chỉ truy cập tới dữ liệu thông qua địa chỉ.

Nghĩa là khi CPU lấy dữ liệu, thao tác với dữ liệu, tất cả đều phải thông qua địa chỉ (address) của dữ liệu chứ không phải chính bản thân giá trị (value) được lưu trữ tại địa chỉ đó.

Symbol table

Giả sử có một câu lệnh như sau:

int a = 3;

Bảng symbol table sẽ lưu trữ biến a như sau:

Address Identifier Value
xxxx1 a ssss1 

Cột Address đại diện cho địa chỉ nơi dòng (row) được lưu trữ trong bộ nhớ (nó không phải là đỉa chỉ của biến a nhé).

Cột Value có giá trị là địa chỉ mà giá trị của biến a được đặt trong stack, giả sử ở đây là ssss1 (Bạn phải tìm hiểu thêm về compiler hoặc chương trình dịch để hiểu thêm về cách xây dựng stack).

Đến lúc biên dịch xong thì trong stack chỉ còn mỗi số 3 được lưu trữ tại địa chỉ ssss1 của stack mà thôi, tất cả những thứ còn lại stack sẽ không biết và không cần biết.

Thế khi phần code phía dưới có truy cập tới biến a nữa thì làm sao stack biết được:

int main(void)
{
    int a = 3;
    return a + 5;
}

Khi gặp a tiếp thì thứ compiler cần biết là ssss1, nó sẽ tìm trong symbol table để lấy ra ssss1, từ đó biết được giá trị của a đang lưu tại ssss1.

Compile

Đoạn code trên khi biên dịch xong ra mã assembly sẽ như sau:

// Mã assembly, dấu ";" là comment

main:
    ; {
    pushq   %rbp
    movq    %rsp, %rbp

    ; int a = 3
    movl    $3, -4(%rbp)

    ; return a + 5
    movl    -4(%rbp), %eax
    addl    $5, %eax

    ; }
    popq    %rbp
    ret

rbp, rsp là các con trỏ (pointer) trỏ tới một địa chỉ nhất định trong stack, eax là một thanh ghi để lưu trữ dữ liệu của CPU. Bạn chỉ cần quan tâm tới các câu lệnh:

  • movl $3, -4(%rbp): đẩy giá trị 3 vào địa chỉ rbp - 4 (chính là ssss1 ở VD trên).
  • movl -4(%rbp), %eax: đẩy giá trị tại địa chỉ rbp - 4 (chính là 3) vào thanh ghi eax.
  • addl $5, %eax: cộng thêm 5 vào giá trị đang được lưu ở thanh ghi eax

Như bạn thấy trong stack không cần lưu trữ a mà vẫn có thể lấy được giá trị của a (thông qua địa chỉ ssss1).

Đoạn mã assembly trên chính là thứ được lưu trên stack và là thứ được CPU của máy tính đọc.

Phân biệt Pointer, Reference và Address

Lưu ý: Pointer mình nói ở đây là khái niệm pointer trong C và C++ chứ không phải là con trỏ của phần cứng máy tính.

Bộ nhớ (khái niệm phần cứng) có 2 thành phần là địa chỉ (address) và nội dung (value) của ô nhớ.

Address không nói làm gì, nó chính là địa chỉ của ô nhớ trong bộ nhớ.

Xét ví dụ sau để phân biệt pointer và reference:

#include <iostream>
#include <string>

int main()
{
  int a = 3;
  int b = a;  // gán biến thông thường
  int &r = a;  // tạo tham chiếu (reference) của biến a (tạo alias cho biến a)

  int c = 4;
  int *p = &c; // tạo con trỏ (pointer) tới biến c
}

Với đoạn code trên thì bảng symbol table sẽ như sau:

Identifier  Địa chỉ tại stack Giá trị trong stack
a ssss1 3
b ssss2 3
r ssss1 3
c ssss4 4
p ssss5 ssss4

Pointer p là một biến như bao biến thông thường nhưng giá trị của nó là địa chỉ của ô nhớ nó trỏ tới (ssss4).

Còn reference r thực chất là một alias của biến a, cả 2 biến này đều có chung một địa chỉ stack là ssss1. Điều này có nghĩa là khi biên dịch thì cả a và r đều như nhau. Nếu nó như nhau thì vì sao C++ phải sinh ra thằng reference làm gì? (Mình sẽ nói ở bài khác).

 (Pointer) và tham chiếu (Reference) khác nhau như sau:

  • Pointer là mutable còn reference là immutable (Đọc thêm về mutable và immutable tại đây).

Một pointer có thể được gán lại nhưng reference thì không thể gán lại, chỉ được khởi tạo một lần duy nhất.

Với đoạn code trên nếu bạn gán lại pointer và reference như sau:

// Đoạn code này vẫn giữ nguyên giá trị và địa chỉ của a, c, r, p như bảng trên.
#include <iostream>
#include <string>

int main()
{
  int a = 3;
  int &r = a;
  r = 10;

  int c = 4;
  int *p = &c;
  int d = 5;
  p = &d;
}

r có cùng địa chỉ với a nên khi gán r thì a cũng sẽ thay đổi theo, ở trên thì r và a sẽ đều có giá trị là 10.

Việc gán lại biến r ở trên có mâu thuẫn với việc nói reference là immutable không? Không vì immutable ở đây là r vẫn luôn trỏ tới a và giá trị địa chỉ của r vẫn giữ nguyên là ssss1.

Còn với pointer p khi gán lại bằng p = &d thì giá trị của c vẫn giữ nguyên là 4, thứ thay đổi ở đây chính là p giờ đã trỏ tới d chứ không phải là c nữa (mutable).

Identifier  Địa chỉ tại stack Giá trị trong stack
a ssss1 3
b ssss2 3
r ssss1 3
c ssss4 4
p ssss5

ssss6

d ssss6

5

 

  • Pointer có thể trỏ tới NULL còn reference thì không, reference luôn phải trỏ tới một đối tượng nào đó.
  • Bạn không thể lấy được địa chỉ của riêng thằng reference vì lý do reference không có địa chỉ riêng, mà là địa chỉ của biến a (nói cách khác r là alias của a), nói bạn lấy địa chỉ của reference thực chất là bạn đang lấy địa chỉ của biến nó tham chiếu tới.

 

Bạn có thể kiểm chứng những điều trên bằng đoạn code sau:

#include <iostream>
#include <string>

int main()
{
  int a = 3;
  int b = a;  // gán biến thông thường
  int &r = a;  // tạo tham chiếu (reference) của biến a (tạo alias cho biến a)
  int *p = &a; // tạo con trỏ (pointer) tới biến a
  
  printf("địa chỉ của biến (variable) a: %d\n", &a);
  printf("giá trị của biến a: %d\n\n", a);

  printf("địa chỉ của biến (variable) b: %d\n", &b);
  printf("giá trị của biến b: %d\n\n", b);
  
  printf("địa chỉ của tham chiếu (reference) r: %d\n", &r);
  printf("giá trị của tham chiếu r: %d\n\n", r);
  
  printf("địa chỉ của con trỏ (pointer) p: %d\n", &p);
  printf("giá trị của con trỏ p: %d\n", p);
  printf("giá trị của biến mà con trỏ p đang trỏ tới: %d\n", *p);
}


/* Output:
địa chỉ của biến (variable) a: -983214544
giá trị của biến a: 3

địa chỉ của biến (variable) b: -983214540
giá trị của biến b: 3

địa chỉ của tham chiếu (reference) r: -983214544
giá trị của tham chiếu r: 3

địa chỉ của con trỏ (pointer) p: -983214536
giá trị của con trỏ p: -983214544
giá trị của biến mà con trỏ p đang trỏ tới: 3
*/

 

Link tham khảo:

https://cs.stackexchange.com/questions/64578/how-do-computers-remember-where-they-store-things

https://stackoverflow.com/questions/1950779/is-there-any-way-to-find-the-address-of-a-reference

https://stackoverflow.com/questions/36779395/where-do-local-variables-get-stored-at-compile-time

https://stackoverflow.com/questions/57483/what-are-the-differences-between-a-pointer-variable-and-a-reference-variable-in