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ì
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ểm | Chi tiết |
|---|---|
| Chỉ số | Bắt đầu từ 0, phần tử cuối có chỉ số n trừ 1 |
| Kích thước | Cố định, phải biết ngay lúc biên dịch trong khóa học này |
| Bố cục | Các phần tử nằm sát nhau, không có khoảng trống ở giữa |
| Chi phí truy cập | Luôn như nhau với mọi chỉ số, tức là O(1) |
| Kiểm tra biên | Không có. C không hề kiểm tra chỉ số của bạn |
#Bố cục trong bộ nhớ
Đ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.
#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;
}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ỉ đầu | Phép tính | Kết quả |
|---|---|---|---|
| &a[0] | 1000 | 1000 + 0 * 4 | 1000 |
| &a[2] | 1000 | 1000 + 2 * 4 | 1008 |
| &a[4] | 1000 | 1000 + 4 * 4 | 1016 |
| &a[5] | 1000 | 1000 + 5 * 4 | 1020, 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.
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--;
}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.
=================================================================
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 variablebai.c:7:9: warning: array subscript 10 is above array bounds of 'int[5]' [-Warray-bounds]
| Công cụ | Bắt được gì | Chi phí |
|---|---|---|
| AddressSanitizer | Vượt biên trên ngăn xếp, vùng cấp phát động và vùng toàn cục | Chạy chậm khoảng hai lần, tốn thêm bộ nhớ |
| Cờ -Warray-bounds | Chỉ những trường hợp trình biên dịch suy ra được lúc dịch | Không tốn gì |
| Valgrind | Chủ yếu là vùng cấp phát động, yếu với mảng trên ngăn xếp | Chạy chậm hàng chục lần |
Tự làm thử
- In địa chỉ của từng phần tử một mảng mười số
intvà một mảng mười sốdouble. So sánh khoảng cách giữa các ô. - 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. - 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ị.
- 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ì saofor (size_t i = n - 1; i >= 0; --i)lặp vô hạn. - Tính bằng tay địa chỉ của
a[7]khiabắ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=addresskhi học và gỡ lỗi.