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

Mảng là gì

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

  • Giải thích vì sao truy cập a[i] luôn tốn thời gian như nhau
  • Tính được địa chỉ của một phần tử bất kỳ
  • Nhận ra mọi tình huống truy cập ngoài biên
  • Dùng công cụ dò lỗi địa chỉ để bắt lỗi vượt biên

Tới giờ mỗi biến của bạn giữ đúng một giá trị. Muốn lưu điểm của một trăm sinh viên, bạn không thể khai báo một trăm biến. Mảng giải quyết chuyện đó, và cách nó nằm trong bộ nhớ giải thích cả sức mạnh lẫn cái bẫy lớn nhất của C.

#Mảng là gì

Mảng
Một dãy phần tử cùng kiểu, nằm liên tiếp trong bộ nhớ, truy cập qua một chỉ số nguyên đếm từ 0.
int  diem[10];              /* 10 phần tử kiểu int, chỉ số 0 tới 9 */
double gia[100];            /* 100 số thực */
char   ten[64];             /* 64 ký tự */
Đặc điểmChi tiết
Chỉ sốBắt đầu từ 0, phần tử cuối có chỉ số n trừ 1
Kích thướcCố định, phải biết ngay lúc biên dịch trong khóa học này
Bố cụcCác phần tử nằm sát nhau, không có khoảng trống ở giữa
Chi phí truy cậpLuôn như nhau với mọi chỉ số, tức là O(1)
Kiểm tra biênKhông có. C không hề kiểm tra chỉ số của bạn

#Bố cục trong bộ nhớ

Năm phần tử liền nhau, mỗi ô rộng đúng bằng kích thước kiểu. Không có phần đầu, không có thẻ mô tả, không có thông tin về độ dài.

Điểm cần khắc sâu: mảng không mang theo độ dài của nó. Trong bộ nhớ chỉ có đúng dữ liệu. Không có ô nào ghi rằng mảng này dài năm phần tử. Trình biên dịch biết điều đó lúc biên dịch, nhưng thông tin ấy không tồn tại lúc chạy.

lien-tiep.c
#include <stdio.h>

int main(void)
{
    int a[5] = { 10, 20, 30, 40, 50 };

    for (int i = 0; i < 5; ++i)
        printf("a[%d] = %2d  tại địa chỉ %p\n", i, a[i], (void *)&a[i]);

    printf("sizeof a = %zu byte\n", sizeof a);
    printf("số phần tử = %zu\n", sizeof a / sizeof a[0]);

    return 0;
}
terminal
./lien-tiep
a[0] = 10  tại địa chỉ 0x7ffd8c2a1e30
a[1] = 20  tại địa chỉ 0x7ffd8c2a1e34
a[2] = 30  tại địa chỉ 0x7ffd8c2a1e38
a[3] = 40  tại địa chỉ 0x7ffd8c2a1e3c
a[4] = 50  tại địa chỉ 0x7ffd8c2a1e40
sizeof a = 20 byte
số phần tử = 5

Các địa chỉ cách nhau đúng 4, tức sizeof(int). Đó là điều kiện để mảng hoạt động. Nếu các phần tử nằm rải rác, máy sẽ phải tra bảng để biết phần tử thứ i ở đâu, và truy cập sẽ không còn là O(1).

#Công thức địa chỉ

&a[i]  ==  (char *)a + i * sizeof a[0]

Đây là toàn bộ phép thuật của mảng. Muốn lấy a[837], máy không phải duyệt qua 837 phần tử. Nó làm đúng một phép nhân và một phép cộng, rồi đọc ô nhớ đó. Thời gian để lấy phần tử đầu tiên và phần tử cuối cùng là như nhau.

Biểu thứcĐịa chỉ đầuPhép tínhKết quả
&a[0]10001000 + 0 * 41000
&a[2]10001000 + 2 * 41008
&a[4]10001000 + 4 * 41016
&a[5]10001000 + 5 * 41020, ngoài mảng

Một địa chỉ được phép nằm ngoài: ngay sau phần tử cuối

Chuẩn C cho phép tính &a[n], tức địa chỉ ngay sau phần tử cuối cùng, và cho phép so sánh nó. Nhưng đọc hay ghi vào đó là hành vi không xác định. Quy tắc này tồn tại để viết được vòng lặp bằng con trỏ:

for (int *p = a; p < a + 5; ++p)     /* a + 5 hợp lệ để so sánh */
    printf("%d ", *p);

int x = *(a + 5);                    /* nhưng đọc nó là lỗi */

#C không kiểm tra biên

Vì sao C lại thiết kế như vậy

  • Không có chỗ để lưu độ dài. Mảng chỉ là dữ liệu thuần. Muốn kiểm tra biên, mọi mảng phải mang thêm một trường độ dài, tốn bộ nhớ và làm mảng không còn tương thích với con trỏ.
  • Kiểm tra tốn thời gian. Mỗi lần truy cập phải thêm hai phép so sánh và một nhánh rẽ. Trong vòng lặp chạy hàng tỷ lượt, chi phí đó rất lớn.
  • Triết lý của C là tin người lập trình. Ngôn ngữ cho bạn tốc độ tối đa và giao trách nhiệm lại cho bạn.
Bốn lỗi vượt biên kinh điển
int a[5];

for (int i = 1; i <= 5; ++i)     /* 1. chạy tới a[5] */
    a[i] = i;

for (int i = 0; i <= 5; ++i)     /* 2. dấu bằng thừa */
    a[i] = i;

int n = 5;
a[n] = 0;                        /* 3. dùng luôn n làm chỉ số */

size_t i = 0;
while (i >= 0) {                 /* 4. size_t không bao giờ âm, lặp vô hạn */
    a[i] = 0;
    i--;
}
Cách viết đúng
enum { N = 5 };
int a[N];

for (size_t i = 0; i < N; ++i)   /* luôn dùng dấu bé hơn với n */
    a[i] = (int)i;

for (size_t i = N; i-- > 0; )    /* duyệt ngược an toàn với size_t */
    a[i] = 0;

#Công cụ bắt lỗi vượt biên

Vì trình biên dịch không kiểm tra, bạn phải nhờ công cụ. Đây là ba công cụ nên biết ngay từ bài đầu về mảng.

terminal
# 1. Trình dò lỗi địa chỉ, chính xác và dễ đọc nhất
gcc -std=c17 -g -fsanitize=address bai.c -o bai && ./bai
=================================================================
ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffd...
WRITE of size 4 at 0x7ffd... thread T0
    #0 0x... in main bai.c:7

Address is located in stack of thread T0 at offset 52 in frame
    #0 0x... in main bai.c:4
  This frame has 1 object(s):
    [32, 52) 'a' (line 5) <== Memory access at offset 52 overflows this variable
terminal
# 2. Trình biên dịch tự bắt được vài trường hợp đơn giản
gcc -std=c17 -Wall -Wextra -O2 bai.c -o bai
bai.c:7:9: warning: array subscript 10 is above array bounds of 'int[5]' [-Warray-bounds]
terminal
# 3. Chạy dưới valgrind, bắt được lỗi trên vùng cấp phát động
valgrind --leak-check=full ./bai
Công cụBắt được gìChi phí
AddressSanitizerVượt biên trên ngăn xếp, vùng cấp phát động và vùng toàn cụcChạy chậm khoảng hai lần, tốn thêm bộ nhớ
Cờ -Warray-boundsChỉ những trường hợp trình biên dịch suy ra được lúc dịchKhông tốn gì
ValgrindChủ yếu là vùng cấp phát động, yếu với mảng trên ngăn xếpChạy chậm hàng chục lần

Tự làm thử

  1. In địa chỉ của từng phần tử một mảng mười số int và một mảng mười số double. So sánh khoảng cách giữa các ô.
  2. Viết chương trình cố tình ghi vào a[10] của mảng năm phần tử, chạy thử không có và có -fsanitize=address.
  3. Khai báo hai mảng liền nhau, ghi tràn mảng thứ nhất và quan sát mảng thứ hai bị đổi giá trị.
  4. Viết vòng lặp duyệt ngược mảng dùng size_t đúng cách, và giải thích vì sao for (size_t i = n - 1; i >= 0; --i) lặp vô hạn.
  5. Tính bằng tay địa chỉ của a[7] khi a bắt đầu ở 0x1000 và phần tử là double, sau đó kiểm chứng bằng chương trình.

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

  • Mảng là dãy phần tử cùng kiểu nằm liên tiếp, chỉ số đếm từ 0 tới n trừ 1.
  • Địa chỉ phần tử tính bằng đầu mảng + i * sizeof(phần tử), nên truy cập luôn tốn thời gian như nhau.
  • Mảng không mang theo độ dài. Lúc chạy, trong bộ nhớ chỉ có dữ liệu thuần.
  • C không kiểm tra biên. Truy cập ngoài biên là hành vi không xác định và là nguồn lỗ hổng bảo mật số một.
  • Luôn biên dịch kèm -fsanitize=address khi học và gỡ lỗi.