Hiểu sâu hơn về bộ nhớ, con trỏ, lỗi truy cập vùng nhớ cấm, ....

Hiểu sâu hơn về bộ nhớ, con trỏ, lỗi truy cập vùng nhớ cấm, ....

1. Giới thiệu:

Ở 1 bài khác đã nói, học C++ khá là khó. Một trong các vấn đề khó hiểu nhất của C/C++ chính là con trỏ. Bài viết hôm nay diễn giải sâu hơn, chi tiết hơn về con trỏ cho mọi người
Các ngôn ngữ khác ko phải ko có con trỏ, vấn đề là nó đc ngôn ngữ ẩn giấu đi, ko cho người dùng thao tác trực tiếp và can thiệp trực tiếp vào bộ nhớ. Việc này đôi khi giúp người dùng quên đi các nỗi lo như quên ko giải phóng bộ nhớ, kiểm tra bộ nhớ có đc cấp phát hay không, nhưng đôi khi làm tốc độ xử lí của chương trình chậm. 
Nói chung, con trỏ khó, nhưng cũng là 1 điểm mạnh của C++. Biết và hiểu sâu về nó, bạn sẽ làm đc rất nhiều bài toán tối ưu
Bài viết này giải thích 1 số khái niệm, kiến thức để học tốt hơn C++. Nhưng mình nghĩ nó cũng rất là hữu ích cho những bạn muốn hiểu sâu hơn về bộ nhớ, cách quản lý bộ nhớ của các ngôn ngữ khác

2. Bộ nhớ:

Trước khi tìm hiểu bộ nhớ, hãy tìm hiểu kiến thức về bộ nhớ nói chung

Ai cũng biết đơn vị nhỏ nhất để biểu diễn kích thước bộ nhớ là byte (bỏ qua bit). Chúng ta tưởng tượng mỗi 1 byte là 1 ô nhớ. Như vậy, với máy tính có 4GB ram chúng ta có 2^32 ~ 4 tỷ cái ô nhớ (mỗi ô 1 byte) như vậy xếp liền nhau

Để các bộ phận khác của máy tính (như CPU) có thể truy xuất vào bộ nhớ, tất nhiên chúng ta cần đánh địa chỉ cho các ô nhớ. Tưởng tượng, 4 tỷ ô nhớ nói trên đc đánh số từ 0 đến 3 999 999 999 (như kiểu đánh số nhà ấy)

Thỉnh thoảng, các bạn nhìn thấy các xâu ký tự kiểu như 0x008888ff, 0x001002fe ... thì đây chính là địa chỉ của các ô nhớ đc biểu diễn dưới cơ số 16 (hexa). Khi biết đc địa chỉ các ô nhớ này, các phần cứng máy tính khác, hay chương trình phần mềm của bạn có thể truy xuất và lấy ra đc dữ liệu đang lưu trong ô nhớ. Đó chính là nguyên tắc lưu trữ và truy xuất biến, mà tí nữa mình sẽ giải thích sâu hơn

     

Vậy bạn có biết rằng, windows 32 bít hồi xưa chỉ nhận đc tối đa 4GB ram hay chưa? (kể cả cắm 8GB ram vào thì vẫn chỉ dùng đc 4GB ram). Đó chính là vì win 32 bit đánh số địa chỉ bộ nhớ từ 0 đến 2^32, mà 2^32 ~ 4 tỷ nên ko thể đánh địa chỉ cho các ô nhớ có giá trị cao hơn 4 tỷ đc nữa

Đối vs win 64 bit, bộ nhớ đc đánh địa chỉ từ 0 -> 2^64 đủ để biểu diễn và sử dụng bộ nhớ lớn hơn rất nhiều 

3. Biến và địa chỉ của biến:

Trong bất cứ ngôn ngữ nào, khi học và biến bạn đều biết biến gồm tên và kiểu (có 1 số ngôn ngữ ko cần khai báo kiểu của biến, nhưng thực ra đó là do trình biên dịch tự hiểu biến theo 1 kiểu gì đó)

Giống như int a, float f thì af là tên, intfloat là kiểu

Bình thường chúng ta dùng tên để truy cập và thao tác vs biến, đúng ko? Nhưng thực ra máy tính ko dùng tên đâu, nó sẽ dùng địa chỉ

Khi bạn khai báo 1 biến a kiểu int 4 bytes, máy tính sẽ tự động cấp phát 1 vùng nhớ 4 bytes (vùng nhớ đang rảnh và ko bị sử dụng bởi chương trình khác) và lưu lại cái địa chỉ của biến này ví dụ là 0x0000fffe. Khi đó, mỗi lần bạn gọi tới biến a, thì hệ thống sẽ dùng cái địa chỉ 0x0000fffe vừa lưu ở bước trên để tính toán

Máy tính sẽ có 1 bảng để đối chiếu, ánh xạ giữa tên biến và địa chỉ. Kiểu như thế này này:

Tên biến Địa chỉ
name 0x0000ffef
age 0x0f001ffff
x 0x01020304
i 0x040203ff

Khi đó, khi bạn truy cập vào biến thông qua tên, máy tính sẽ hiểu và lấy đc biến ở đúng địa chỉ đó. 

Với C/C++, bạn có thể biết đc địa chỉ của 1 biến đc cấp phát trong bộ nhớ thông qua toán tử &. Mã code như thế này, hãy chạy thử và xem thử kết quả nhé

#include <iostream>

using namespace std;

int main()
{
    int a;
    float b;
    cout << "Addr of a:" << &a << endl;
    cout << "Addr of b:" << &b << endl;
}

4. Con trỏ trong C++?

Đọc xong phần 3, bạn hiểu là mỗi biến cần có 1 địa chỉ rồi phải ko?

Con trỏ thật ra rất dễ hiểu, nó cũng là một kiểu dữ liệu, dữ liệu này không phải số hay kí tự, nó là kiểu dữ liệu địa chỉ

Hãy xem qua đoạn code sau:

#include <iostream>

using namespace std;

int main()
{
    char a = 2;
    char *p = &a;
    cout << "addr of a:" << std::hex << (int) &a << endl;
    cout << "addr of p:" << std::hex << &p << endl;
    cout << "value of p" << std::hex << (int) p << endl;
    cout << "value in value of p:" << (int) *p << endl;
}

Chạy thử bạn thấy sao. Hãy chú ý các điểm sau:

  • Giá trị của p chính là địa chỉ của a (giả sử là 0x00aabbcc)
  • *p chính là giá trị của a (giả sử là 2)
  • p cũng có địa chỉ của chính nó

Vậy bạn hiểu hơn rồi chứ? Nếu chưa thì hãy tiếp tục nhìn hình minh họa dưới đây:

    

Tóm lại, con trỏ là 1 loại dữ liệu đặc biệt. Giá trị đc lưu trong biến con trỏ luôn có giá trị là địa chỉ của 1 ô nhớ, biến nào đó. Vì giá trị của địa chỉ là 1 số khá lớn, có giá trị cao hơn 4 tỷ (với 4GB ram) hoặc 8 tỷ (8GB ram), nên để biểu diễn giá trị là địa chỉ của ô nhớ thì biến con trỏ có kích thước là 4 bytes với các chương trình 32 bit (đánh địa chỉ từ 0 tới 2^32-1) hoặc 8 bytes với các chương trình 64 bit (đánh địa chỉ từ 0 tới 2^64-1)

Nhiều chương trình mặc dù được biên dịch trên hệ điều hành 64 bit, nhưng nếu bạn để chế độ biên dịch thành chương trình 32 bit thì kích thước con trỏ cũng chỉ là 4 bytes mà thôi

Để biết kích thước của biến kiểu con trỏ, hãy thử dùng hàm sizeof như sau (với C, C++ tự convert nhé):

 printf("The size of pointer value : %d", sizeof(ptr));

Toán tử * chính là toán tử để lấy ra giá trị của ô địa chỉ mà con trỏ đang trỏ đến

Ví dụ p trỏ tới a (p lưu địa chỉ của a), thì *p = a

5. Con trỏ trỏ vào con trỏ:

Với đoạn code mẫu như trên, bạn đã hiểu, giá trị của con trỏ chính là địa chỉ của 1 ô nhớ/biến nào khác

Và bản thân mỗi con trỏ cũng có 1 địa chỉ riêng của chính nó?

Vậy con trỏ B trỏ vào con trỏ A, thì giá trị của B chính là địa chỉ của con trỏ A

#include <stdio.h>
#include <iostream>

using namespace std;

int main()
{
    char a = 2;
    char *p = &a;
    char **p2;
    p2 = &p;

    cout << "addr of a:" << std::hex << (int) &a << endl;
    cout << "addr of p:" << std::hex << (int) &p << endl;
    cout << "value of p:" << std::hex << (int)p << endl;
    cout << "value in value of p:" << (int) *p << endl;

    cout << "value of p2:" << std::hex << (int)*p2 << endl;
}

Với code mẫu trên, bạn sẽ thấy giá trị trong p2 chính là địa chỉ p, giá trị trong p chính là địa chỉ a

Như vậy, **p2 = *p1 = a. Bạn thử xem có đúng không?

Kỳ diệu không?

6. Vùng nhớ tự cấp phát và vùng nhớ cấp phát động (con trỏ):

Để truy cập biến, như đã giải thích, chúng ta dùng tên, hệ thống dùng địa chỉ. Và bộ nhớ này sẽ đc cấp phát, thu hồi bởi hệ thống

Đối vs C++, có 2 kiểu cấp phát chính. Loại 1 là loại đc cấp phát và thu hồi tự động (bạn và chương trình của bạn ko cần chú ý tới việc cấp phát và thu hồi), loại 2 là loại đc cấp phát và thu hồi bởi chính chương trình bạn đang viết thông qua các lệnh cấp phát và thu hồi 

Với C++, cấp phát vùng nhớ dùng thông qua toán tử new, với C là malloc. Để thu hồi, với C++ là deleteCfree

Ví dụ nhé, nếu bạn khai báo int a, float b, char c, bool x (không có dấu *), đây là các biến đc cấp phát và thu hồi tự động bởi hệ thống. Khi dùng xong biến này (tuỳ vào phạm vi hoạt động), hệ thống sẽ tự động xoá biến này, vùng nhớ bị sử dụng bởi các biến này đc coi như free (rảnh rỗi)

Với biến kiểu con trỏ, ví dụ như int *a, float *b, ... đây được coi là các biến/vùng nhớ đc cấp phát và thu hồi động bởi chính chương trình của các bạn. Nếu bạn cấp phát bộ nhớ nhưng quên ko thu hồi (quên ko dùng free/delete), thì vùng nhớ này sẽ tồn tại vĩnh viễn cho đến khi chương trình của bạn còn đang chạy)

Nếu bạn từng nghe các thuật ngữ như leak ram, hay memory leak chính là hiện tượng bạn tạo ra rất nhiều vùng nhớ, nhưng quên ko giải phóng. 

Về bản chất, khi bạn khai báo một biến kiểu con trỏ như int *a, thì hệ thống chỉ cấp phát 4 bytes hoặc 8 bytes để lưu địa chỉ của vùng nhớ nào đó. Nếu bạn ko gán, ko cấp phát bộ nhớ cho vùng nhớ mới này, tức là chương trình của bạn sẽ truy cập vào 1 vùng nhớ ngẫu nhiên không xác định (xem thêm mục 8)

Chính vì có thể dễ dàng cấp phát, thu hồi vùng nhớ này, nên vùng nhớ kiểu này gọi là vùng nhớ động (linh động bởi chương trình của bạn). Đây cũng chính là 1 điểm mạnh của C/C++ khi so vs các ngôn ngữ khác

7. Lợi ích và sự nguy hiểm của con trỏ:

Chính vì có thể gán và trỏ trực tiếp vào địa chỉ của vùng nhớ/biến khác, nên phép toán này là con dao hai lưỡi, bạn có thể dùng con trỏ trỏ tới bất cứ đâu, bất cứ biến nào trong chương trình. Lưu ý rằng con trỏ không thể trỏ tới vùng nhớ của chương trình khác vì mỗi một chương trình sẽ có một vùng nhớ ảo riêng và con trỏ của bạn sẽ chỉ có thể trỏ tới vùng nhớ ảo này. Hơn nữa địa chỉ của biến mà bạn nhìn thấy không phải là địa chỉ vật lý nên bạn không thể thay đổi được vùng nhớ của chương trình khác thông qua con trỏ.

Hại đó là, nếu bạn ko hiểu rõ con trỏ, truyền bộ nhớ và địa chỉ lung tung beng, thì chương trình bạn chạy sai toán loạn và ko thể nào debug đc (ko biết ô nhớ đó bị thay đổi giá trị từ khi nào)

Do làm việc trực tiếp vs bộ nhớ, nhiều bạn cũng rất hay bị crash. Vậy bản chất của vấn đề crash do truy cập bộ cấm nhớ nó ntn?

8. Sâu hơn về lỗi truy cập bộ nhớ cấm:

Khi tạo ra con trỏ mà không khởi tạo hay gán nó với 1 địa chỉ là biến của bạn, giá trị trong biến con trỏ này là 1 giá trị vô định

Lấy vị dụ bạn khai báo int *p; và quên không khởi tạo, thì p sẽ trở tới 1 vùng nhớ lung tung (giả sử là vùng nhớ 0x0000ffff)

Khi đó nếu bạn thao tác kiểu lấy giá trị chứa trong p, như là *p, thì chương trình bạn sẽ access vào 0x0000ffff để lấy giá trị. Có 2 trường hợp xảy ra:

  • Nếu vùng nhớ 0x0000ffff chưa đc thằng nào dùng (ko có chương trình nào trong cùng máy tính dùng), thì bạn sẽ lấy đc 1 giá trị ngẫu nhiên, lung tung nằm trong vùng nhớ đó (sai logic)
  • Nếu vùng nhớ 0x0000ffff đc 1 thằng khác dùng và chương trình của bạn ko đc quyền cao hơn (ví dụ quyền admin), thì bạn sẽ bị crash ngay lập tức

Đây chính là giải thích cho vấn đề, vì sao có những lúc bạn truy cập vào 1 mảng ko đc cấp phát hoặc vào index quá độ dài của mảng, nhưng có lúc bạn crash và có lúc thì không

9. Tổng kết:

Bài viết cũng khá dài rồi, sợ anh em đọc dài khó hiểu, nên tạm dừng tại đây. Đại ý là sơ sơ miêu tả về bộ nhớ, con trỏ cho đỡ loạn

Những thứ hay ho khác như cấp phát bộ nhớ, xóa bộ nhớ, các lưu ý khác xin dành vào 1 bài tiếp theo

A em thấy hay nhớ chia sẻ và để lại comment để mình có động lực cải tiến và viết tiếp các bài khác nữa nhé