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

Bản đồ bộ nhớ

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

  • Vẽ lại đầy đủ bản đồ bộ nhớ của một tiến trình
  • Xem bản đồ thật bằng công cụ hệ điều hành
  • Đoán được vùng nhớ từ dạng của một địa chỉ
  • Hiểu quyền đọc ghi chạy của từng vùng

Bốn bài trước đã đi qua từng vùng riêng. Bài này ghép chúng lại thành một bản đồ hoàn chỉnh, rồi dạy bạn một kỹ năng gỡ lỗi rất thực dụng: nhìn một địa chỉ là đoán được nó thuộc vùng nào, và từ đó đoán được loại lỗi.

#Sáu vùng của một tiến trình

Ngăn xếp ở địa chỉ cao và lớn xuống dưới. Vùng cấp phát động ở dưới và lớn lên trên. Khoảng trống ở giữa cho cả hai chỗ để phát triển.
VùngChứa gìAi cấpVòng đời
Ngăn xếpBiến cục bộ, tham số, khung hàmTrình biên dịchTheo khối lệnh
Vùng cấp phát độngThứ bạn xin bằng mallocBạnTừ malloc tới free
.bssBiến tĩnh bằng 0Hệ điều hành lúc nạpSuốt chương trình
.dataBiến tĩnh khác 0Nạp từ file thực thiSuốt chương trình
.rodataHằng chuỗi, bảng chỉ đọcNạp từ file thực thiSuốt chương trình
.textMã máy của mọi hàmNạp từ file thực thiSuốt chương trình
ban-do.c
#include <stdio.h>
#include <stdlib.h>

int         g_data = 1;         /* .data */
int         g_bss;              /* .bss */
const char *g_chuoi = "abc";    /* con trỏ ở .data, nội dung ở .rodata */

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

    printf(".text   : %p   (dia chi ham main)\n", (void *)main);
    printf(".rodata : %p   (noi dung hang chuoi)\n", (void *)g_chuoi);
    printf(".data   : %p   (bien khac 0)\n", (void *)&g_data);
    printf(".bss    : %p   (bien bang 0)\n", (void *)&g_bss);
    printf("heap    : %p   (malloc)\n", heap);
    printf("stack   : %p   (bien cuc bo)\n", (void *)&cuc_bo);

    free(heap);

    return 0;
}
terminal
./ban-do
.text   : 0x5581e4a01189   (dia chi ham main)
.rodata : 0x5581e4a02008   (noi dung hang chuoi)
.data   : 0x5581e4a04010   (bien khac 0)
.bss    : 0x5581e4a04018   (bien bang 0)
heap    : 0x5581e57b52a0   (malloc)
stack   : 0x7ffe3b2c1e44   (bien cuc bo)

#Quyền đọc, ghi và chạy

VùngĐọcGhiChạyVi phạm thì sao
.textKhôngGhi vào mã: tín hiệu phân đoạn
.rodataKhôngKhôngGhi vào hằng chuỗi: sập
.data và .bssKhôngChạy dữ liệu: bị chặn
Vùng cấp phát độngKhôngChạy dữ liệu: bị chặn
Ngăn xếpKhôngChạy mã trên ngăn xếp: bị chặn
vi-pham.c
#include <stdio.h>

int main(void)
{
    char *hang = "abc";      /* trỏ vào .rodata */

    hang[0] = 'X';           /* ghi vào vùng chỉ đọc */

    printf("%s\n", hang);

    return 0;
}
terminal
gcc -std=c17 -Wall -Wwrite-strings vi-pham.c -o t
vi-pham.c:5:18: warning: initialization discards 'const' qualifier from pointer target type [-Wdiscarded-qualifiers]
./t
Segmentation fault (core dumped)
gcc -fsanitize=address -g vi-pham.c -o t && ./t
ERROR: AddressSanitizer: SEGV on unknown address 0x5581e4a02008
The signal is caused by a WRITE memory access.

#Xem bản đồ thật

terminal
# Trên Linux: bản đồ đầy đủ của một tiến trình đang chạy
cat /proc/self/maps
5581e4a00000-5581e4a01000 r--p 00000000 08:02 1234  /usr/bin/cat
5581e4a01000-5581e4a04000 r-xp 00001000 08:02 1234  /usr/bin/cat
5581e4a04000-5581e4a06000 r--p 00004000 08:02 1234  /usr/bin/cat
5581e4a06000-5581e4a07000 rw-p 00005000 08:02 1234  /usr/bin/cat
5581e57b5000-5581e57d6000 rw-p 00000000 00:00 0     [heap]
7f2a1c000000-7f2a1c028000 r--p 00000000 08:02 5678  /usr/lib/libc.so.6
7ffe3b2a1000-7ffe3b2c2000 rw-p 00000000 00:00 0     [stack]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
CộtNghĩa là
5581e4a00000-5581e4a01000Khoảng địa chỉ, từ đầu tới ngay sau cuối
r-xpQuyền đọc, ghi, chạy, và p là riêng tư
00001000Độ lệch trong file được ánh xạ
08:02Thiết bị chứa file
1234Số hiệu nút của file
/usr/bin/catFile được ánh xạ, hoặc tên vùng đặc biệt
terminal
# Trong chương trình của bạn, tự đọc bản đồ của chính mình
man 5 proc
# Trên macOS
vmmap $(pgrep chuong-trinh)
# Trên Windows, dùng công cụ VMMap của Sysinternals
vmmap.exe -p chuong-trinh.exe
# Trong gdb, xem vùng của tiến trình đang gỡ lỗi
(gdb) info proc mappings
tu-doc.c
#include <stdio.h>
#include <string.h>

/* Tìm xem một địa chỉ nằm ở vùng nào, bằng cách đọc bản đồ của chính mình. */
static void tim_vung(const void *dia_chi)
{
    FILE *f = fopen("/proc/self/maps", "r");

    if (f == NULL) { perror("maps"); return; }

    char dong[512];
    unsigned long muc_tieu = (unsigned long)dia_chi;

    while (fgets(dong, sizeof dong, f) != NULL) {
        unsigned long dau, cuoi;

        if (sscanf(dong, "%lx-%lx", &dau, &cuoi) == 2
            && muc_tieu >= dau && muc_tieu < cuoi) {
            printf("%p nam trong: %s", dia_chi, dong);
            break;
        }
    }

    fclose(f);
}

#Đọc địa chỉ để đoán loại lỗi

Đây là kỹ năng thực dụng nhất của bài. Khi chương trình sập, thông báo lỗi luôn kèm một địa chỉ. Nhìn dạng của nó là bạn thu hẹp được nguyên nhân ngay lập tức.

Địa chỉ trông nhưVùng nàoNguyên nhân nhiều khả năng nhất
0x0 hoặc dưới 0x1000Không được ánh xạCon trỏ rỗng. Thường do quên kiểm tra malloc hoặc fopen
Số nhỏ như 0x8 hoặc 0x10Không được ánh xạCon trỏ rỗng cộng độ lệch trường struct, ví dụ p->truong với p là NULL
0x55... hoặc 0x40....text, .rodata, .dataGhi vào hằng chuỗi, hoặc ghi vào biến const
0x55... cao hơn một chútVùng cấp phát độngDùng sau khi giải phóng, hoặc ghi ra ngoài khối
0x7f...Thư viện độngGhi đè dữ liệu của thư viện, thường do tràn bộ đệm
0x7ff... rất caoNgăn xếpTrả về địa chỉ biến cục bộ, hoặc tràn ngăn xếp
0x4141414141414141Không được ánh xạĐã bị ghi đè bằng dữ liệu. Chuỗi 41 là ký tự A lặp lại
Giá trị lẻ như 0x7fff...45Bất kỳCon trỏ bị lệch, thường do ép kiểu sai hoặc đọc rác

Quy trình gỡ lỗi khi chương trình sập

terminal
# Bước 1: biên dịch lại với thông tin gỡ lỗi và trình dò lỗi
gcc -std=c17 -Wall -Wextra -g -fsanitize=address,undefined bai.c -o bai
# Bước 2: chạy lại, thường là đã có câu trả lời ngay
./bai
# Bước 3: nếu vẫn chưa rõ, chạy trong gdb
gdb -q ./bai -ex run -ex bt -ex 'info locals'
# Bước 4: bật lưu ảnh bộ nhớ để phân tích sau
ulimit -c unlimited && ./bai && gdb ./bai core

#Thư viện động cũng nằm trong bản đồ

terminal
# Xem chương trình của bạn dùng những thư viện nào
ldd ./chuong-trinh
linux-vdso.so.1 (0x00007ffe3b3f2000)
libc.so.6 => /usr/lib/libc.so.6 (0x00007f2a1c000000)
/lib64/ld-linux-x86-64.so.2 (0x00007f2a1c2a1000)
Thành phầnLà gì
libc.so.6Thư viện chuẩn C, chứa printf, malloc, strlen
ld-linux-x86-64.so.2Trình nạp động, nó chạy trước cả mã của bạn
linux-vdso.so.1Thư viện ảo do nhân cung cấp, tăng tốc vài lời gọi hệ thống

Liên kết tĩnh gộp mọi thứ vào một file

terminal
gcc bai.c -o dong && ls -l dong && ldd dong
-rwxr-xr-x 1 an an 16064 dong
  libc.so.6 => /usr/lib/libc.so.6
gcc -static bai.c -o tinh && ls -l tinh && ldd tinh
-rwxr-xr-x 1 an an 892456 tinh
  not a dynamic executable
Liên kết độngLiên kết tĩnh
Kích thước fileNhỏ, vài chục kilobyteLớn, gần một megabyte
Bộ nhớ khi nhiều tiến trình chạyDùng chung một bản thư việnMỗi tiến trình một bản riêng
Cập nhật vá lỗi thư việnChỉ cần thay file thư việnPhải biên dịch lại chương trình
Thời gian khởi độngChậm hơn chút, phải nạp và nốiNhanh hơn
Mang sang máy khácCần đúng phiên bản thư việnChạy được ngay

Chương 39 sẽ dạy cách tự tạo thư viện tĩnh và thư viện động, cùng cách chọn giữa hai kiểu.

Tự làm thử

  1. Chạy chương trình in địa chỉ sáu vùng, sắp xếp kết quả theo địa chỉ tăng dần và đối chiếu với bản đồ.
  2. Đọc /proc/self/maps trên máy bạn, đếm xem có bao nhiêu vùng và phân loại chúng.
  3. Cài hàm tim_vung trong bài, dùng nó để xác định vùng của năm địa chỉ khác nhau.
  4. Ghi vào hằng chuỗi để gây lỗi, ghi lại địa chỉ trong thông báo và đối chiếu với bảng đoán lỗi.
  5. Tạo struct nhiều trường, gán qua con trỏ rỗng vào trường thứ ba, xác nhận địa chỉ lỗi bằng đúng độ lệch của trường đó.
  6. Biên dịch cùng một chương trình theo cả hai kiểu liên kết, so kích thước file và kết quả của ldd.

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

  • Sáu vùng theo thứ tự địa chỉ tăng dần: .text, .rodata, .data, .bss, vùng cấp phát động, rồi ngăn xếp ở rất cao.
  • Mỗi vùng có quyền hạn riêng. Vùng mã chạy được nhưng không ghi được, vùng dữ liệu ghi được nhưng không chạy được.
  • Trên Linux, /proc/self/maps cho bạn thấy bản đồ thật với đầy đủ khoảng địa chỉ và quyền hạn.
  • Nhìn dạng của địa chỉ trong thông báo lỗi là đoán được vùng, và từ đó đoán được loại lỗi.
  • Địa chỉ nhỏ bằng đúng độ lệch của một trường struct nghĩa là con trỏ rỗng cộng độ lệch.