Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 12.118 phút đọc

Bộ nhớ, địa chỉ và giá trị

Sau bài này bạn sẽ làm được

  • Mô tả bộ nhớ như một dãy byte có địa chỉ
  • Phân biệt địa chỉ với giá trị chứa tại địa chỉ đó
  • Giải thích vì sao địa chỉ ảo khác nhau giữa các lần chạy
  • Đọc được bản đồ bộ nhớ của tiến trình

Con trỏ là chỗ nhiều người bỏ cuộc với C. Lý do gần như luôn là bắt đầu từ cú pháp thay vì từ bộ nhớ. Bài này không có một dấu sao nào. Nó chỉ xây cho bạn mô hình bộ nhớ đúng, và khi mô hình đó rõ, cú pháp ở các bài sau trở thành chuyện nhỏ.

#Mô hình bộ nhớ phẳng

Bộ nhớ
Một dãy rất dài các ô, mỗi ô đúng một byte, mỗi ô có một số thứ tự duy nhất gọi là địa chỉ. Đánh số bắt đầu từ 0 và tăng dần.
địa chỉ:   0      1      2      3      4      5      6      7   ...
nội dung: [??]   [2A]   [00]   [00]   [00]   [??]   [??]   [??]
Tính chấtChi tiết
Đơn vị nhỏ nhất có địa chỉMột byte, tức tám bit
Địa chỉ là số nguyênTrên máy sáu tư bit, rộng 64 bit, tức 8 byte
Số ô tối đa về lý thuyếtHai mũ sáu tư, khoảng 18 tỷ tỷ, nhiều hơn mọi máy thật
Ô nhớ không có kiểuMỗi ô chỉ là tám bit. Kiểu là do trình biên dịch áp lên
Một biến chiếm nhiều ô liền nhauSố ô đúng bằng sizeof của kiểu

#Địa chỉ và giá trị

Đây là phân biệt quan trọng nhất của cả chương. Mỗi biến có hai thứ hoàn toàn khác nhau gắn với nó.

Địa chỉGiá trị
Là gìVị trí trong bộ nhớNội dung tại vị trí đó
Lấy thế nàoToán tử và, viết là &xViết thẳng tên biến, x
Đổi được khôngKhông, trong suốt vòng đời biến
Kích thước8 byte trên máy sáu tư bitBằng sizeof của kiểu
Ví dụ0x7ffd3a1042
dia-chi.c
#include <stdio.h>

int main(void)
{
    int    n = 42;
    double d = 3.14;
    char   c = 'A';

    printf("n: gia tri %d,    dia chi %p, rong %zu byte\n",
           n, (void *)&n, sizeof n);
    printf("d: gia tri %.2f,  dia chi %p, rong %zu byte\n",
           d, (void *)&d, sizeof d);
    printf("c: gia tri %c,    dia chi %p, rong %zu byte\n",
           c, (void *)&c, sizeof c);

    return 0;
}
terminal
./dia-chi
n: gia tri 42,    dia chi 0x7ffd3a2c1e4c, rong 4 byte
d: gia tri 3.14,  dia chi 0x7ffd3a2c1e40, rong 8 byte
c: gia tri A,     dia chi 0x7ffd3a2c1e3f, rong 1 byte

Một biến nhiều byte thì địa chỉ là byte nào

Địa chỉ của một biến là địa chỉ của byte đầu tiên nó chiếm. Kiểu quyết định số byte tiếp theo cũng thuộc về nó. Đó là toàn bộ ý nghĩa của việc con trỏ có kiểu.

double d;      /* chiếm 8 byte, từ &d tới &d + 7 */
char   c;      /* chiếm 1 byte, đúng tại &c */

#Địa chỉ ảo

Địa chỉ bạn thấy trong chương trình không phải địa chỉ trên thanh nhớ thật. Hệ điều hành cho mỗi tiến trình một không gian địa chỉ riêng, gọi là địa chỉ ảo, rồi tự dịch sang địa chỉ vật lý.

Lợi ích của địa chỉ ảoGiải thích
Cách ly tiến trìnhChương trình của bạn không đọc được bộ nhớ chương trình khác
Mỗi tiến trình thấy bộ nhớ liên tụcDù bộ nhớ vật lý bị phân mảnh khắp nơi
Bảo vệ theo vùngVùng mã chỉ đọc và chạy được, vùng dữ liệu không chạy được
Bộ nhớ ảo lớn hơn bộ nhớ thậtPhần ít dùng được đẩy ra đĩa
terminal
# Chạy cùng một chương trình ba lần
./dia-chi; ./dia-chi; ./dia-chi
n: dia chi 0x7ffd3a2c1e4c
n: dia chi 0x7ffe91b40dcc
n: dia chi 0x7ffc5e8a2f9c

#Bản đồ bộ nhớ của tiến trình

Sáu vùng chính. Biết một địa chỉ nằm ở vùng nào giúp bạn đoán ngay nguyên nhân của rất nhiều lỗi.
terminal
# In địa chỉ của bốn loại dữ liệu khác nhau
./ban-do
ma ham        : 0x55d3f4a01169
hang chuoi    : 0x55d3f4a02004
bien toan cuc : 0x55d3f4a04010
cap phat dong : 0x55d3f52b62a0
bien cuc bo   : 0x7ffd3a2c1e4c
ban-do.c
#include <stdio.h>
#include <stdlib.h>

int g_toan_cuc = 1;

int main(void)
{
    int   cuc_bo = 2;
    char *heap   = malloc(16);

    printf("ma ham        : %p\n", (void *)main);
    printf("hang chuoi    : %p\n", (void *)"xin chao");
    printf("bien toan cuc : %p\n", (void *)&g_toan_cuc);
    printf("cap phat dong : %p\n", (void *)heap);
    printf("bien cuc bo   : %p\n", (void *)&cuc_bo);

    free(heap);

    return 0;
}
terminal
# Xem toàn bộ bản đồ bộ nhớ của một tiến trình đang chạy trên Linux
cat /proc/self/maps | head -8
55d3f4a00000-55d3f4a01000 r--p  /usr/bin/cat
55d3f4a01000-55d3f4a05000 r-xp  /usr/bin/cat
55d3f4a05000-55d3f4a07000 r--p  /usr/bin/cat
55d3f52b6000-55d3f52d7000 rw-p  [heap]
7ffd3a2ab000-7ffd3a2cc000 rw-p  [stack]

Cột quyền hạn rất đáng chú ý: r-xp nghĩa là đọc và chạy được nhưng không ghi được, đó là vùng mã. rw-p nghĩa là đọc ghi được nhưng không chạy được. Sự tách bạch này là một lớp bảo vệ quan trọng chống lại việc chạy mã do kẻ tấn công đưa vào.

#Vì sao con trỏ là tất yếu

Nếu địa chỉ chỉ là số, thì con trỏ chỉ là biến chứa số. Câu hỏi thật là: vì sao ta cần biến như vậy. Có năm lý do, và mỗi lý do sẽ thành một chương sau này.

  • Hàm sửa được dữ liệu của người gọi. C truyền theo giá trị, nên cách duy nhất là đưa cho hàm địa chỉ. Bài 12.6.
  • Không phải sao chép dữ liệu lớn. Truyền địa chỉ của một struct một megabyte tốn 8 byte thay vì một triệu. Bài 12.5.
  • Cấp phát bộ nhớ lúc chạy. Khi chưa biết trước cần bao nhiêu, malloc trả về một địa chỉ. Chương 14.
  • Cấu trúc dữ liệu động. Danh sách liên kết và cây được nối với nhau bằng con trỏ. Chương 21 tới 24.
  • Truyền hành vi như dữ liệu. Con trỏ hàm cho phép quyết định gọi hàm nào lúc chạy. Bài 13.5.

Tự làm thử

  1. In địa chỉ và kích thước của năm biến kiểu khác nhau, sắp xếp kết quả theo địa chỉ tăng dần.
  2. Chạy chương trình in địa chỉ năm lần liên tiếp, ghi lại chênh lệch và giải thích.
  3. Viết chương trình in địa chỉ của hàm, hằng chuỗi, biến toàn cục, vùng cấp phát động và biến cục bộ. Sắp xếp theo địa chỉ.
  4. Dùng memcpy để xem bốn byte thô của một số int, thử với các giá trị 1, 256, 65536 và âm một.
  5. Trên Linux, đọc /proc/self/maps từ trong chương trình của bạn và in ra vùng nào chứa biến cục bộ.
  6. Khai báo mảng int a[3], in địa chỉ ba phần tử và xác nhận chúng cách nhau đúng bốn byte.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Bộ nhớ là một dãy byte, mỗi byte có một địa chỉ là số nguyên đếm từ 0.
  • Địa chỉ và giá trị là hai thứ khác nhau. Địa chỉ không đổi trong vòng đời biến, giá trị thì đổi được.
  • Địa chỉ của một biến là địa chỉ byte đầu tiên nó chiếm, và kiểu quyết định nó chiếm mấy byte.
  • Địa chỉ bạn thấy là địa chỉ ảo, đổi mỗi lần chạy do cơ chế ngẫu nhiên hóa bố cục.
  • Nhìn dạng của một địa chỉ thường đoán được nó thuộc vùng nào, và từ đó đoán được loại lỗi. Ép về (void *) khi in bằng %p.