Menu

Lỗi thường gặp trong C: lỗi của người mới và cách sửa

Những lỗi mà lập trình viên C nào cũng mắc ít nhất một lần: thiếu dấu chấm phẩy, = thay cho ==, khai báo ngầm định, scanf thiếu &, so sánh chuỗi bằng ==, chia số nguyên, biến chưa khởi tạo và lệch một đơn vị. Mỗi lỗi gồm triệu chứng, nguyên nhân và cách sửa.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Lập trình viên C nào cũng gặp cùng một danh sách lỗi ngắn, thường là trong tuần đầu tiên và thỉnh thoảng cả mười năm sau. Điều khiến chúng đáng được liệt kê là phần lớn tạo ra những thông báo lỗi không mô tả đúng vấn đề, hoặc tệ hơn, chẳng có thông báo nào.

Mỗi mục dưới đây gồm triệu chứng, nguyên nhân đằng sau và cách sửa.

Thiếu dấu chấm phẩy và chuỗi lỗi dây chuyền

Triệu chứng: cả một loạt lỗi, tất cả đều được báo ở những dòng trông hoàn toàn đúng.

program.c:6:5: error: expected ';' before 'printf'
program.c:7:5: error: expected declaration specifiers before 'return'

Nguyên nhân: C kết thúc câu lệnh bằng ;. Thiếu một dấu là trình biên dịch dính dòng tiếp theo vào dòng hiện tại, rồi báo lỗi ở chỗ đoạn văn bản ghép lại không còn hợp lý nữa, thường là ở dòng kế tiếp.

int main(void) {
    int x = 5      /* missing semicolon */
    printf("%d\n", x);
    return 0;
}

Cách sửa: khi một loạt lỗi xuất hiện, chỉ sửa lỗi đầu tiên rồi biên dịch lại. Mọi lỗi sau đó có thể chỉ là hệ quả. Và luôn kiểm tra dòng trước dòng mà trình biên dịch chỉ ra.

Chuỗi lỗi dây chuyền tương tự cũng đến từ một ngoặc nhọn chưa đóng hay một chú thích /* chưa kết thúc, khi đó lỗi có thể xuất hiện cách xa hàng chục dòng.

Thêm một lưu ý: không đặt dấu chấm phẩy sau phần đầu của if, for, while, hay sau ngoặc nhọn đóng của một định nghĩa hàm. if (x > 0); biên dịch bình thường và không làm gì cả: dấu ; chính là toàn bộ thân lệnh.

Phép gán thay cho phép so sánh

Triệu chứng: một điều kiện luôn đúng, hoặc một biến bị thay đổi một cách bí ẩn.

int x = 5;
if (x = 10) {              /* assigns 10, then tests 10 -> true */
    printf("x is ten\n");  /* always prints; x is now 10 */
}

Nguyên nhân: = là phép gán, == là phép so sánh. Giá trị của phép gán chính là giá trị được gán, nên if (x = 10) kiểm tra 10, một giá trị khác 0, tức là đúng. Trình biên dịch chấp nhận vì đôi khi người viết thật sự muốn như vậy.

Cách sửa: dùng == trong mọi điều kiện, và bật -Wall để trình biên dịch cảnh báo:

warning: suggest parentheses around assignment used as truth value

Nếu bạn thật sự muốn gán bên trong điều kiện, thường gặp với while ((c = getchar()) != EOF), cặp ngoặc thêm vào sẽ nói rõ điều đó và tắt cảnh báo.

Khai báo hàm ngầm định

Triệu chứng:

warning: implicit declaration of function 'printf'
warning: implicit declaration of function 'malloc'

...đôi khi kèm theo kết quả lạ lúc chạy hoặc lỗi liên kết (link error).

Nguyên nhân: trình biên dịch gặp lời gọi tới một hàm mà nó không có khai báo. Trong các phiên bản trước C99, nó mặc định hàm trả về int rồi làm tiếp; trên hệ thống 64 bit, giả định đó cắt con trỏ trả về xuống còn 32 bit, và đó là cách thiếu <stdlib.h> biến malloc thành một vụ crash.

Cách sửa: include đúng header.

HàmHeader
printf, scanf, fopen<stdio.h>
malloc, free, atoi, rand, exit<stdlib.h>
strlen, strcpy, strcmp<string.h>
sqrt, pow, fabs<math.h>
isdigit, toupper<ctype.h>
time<time.h>

Với hàm của chính bạn, cùng cảnh báo đó nghĩa là bạn đã gọi hàm trước khi định nghĩa nó. Hãy đặt một nguyên mẫu hàm phía trên main:

scanf thiếu dấu &

Triệu chứng: chương trình crash khi nhập dữ liệu, hoặc không đọc được gì và biến vẫn giữ nguyên.

int age;
scanf("%d", age);     /* missing & : passes the value, not the address */

Nguyên nhân: scanf ghi vào biến của bạn, nên nó cần địa chỉ của biến. Truyền age là đưa cho nó giá trị rác đang nằm trong biến, và scanf coi đó là chỗ để ghi, thường dẫn tới segfault.

Cách sửa: thêm & trước biến, với mọi kiểu trừ mảng, vì mảng vốn đã là một địa chỉ:

Hai cái bẫy cùng họ nằm ngay cạnh: dùng %lf cho double trong scanf (ở đó %f nghĩa là float, và ghi lượng byte của một float vào một double sẽ làm sai giá trị), và luôn giới hạn %s bằng độ rộng, ví dụ %49s cho bộ đệm 50 byte, nếu không một từ dài sẽ tràn bộ đệm.

Tốt hơn nữa: đọc cả dòng bằng fgets rồi phân tích, cách này không thể tràn bộ đệm và không để lại đầu vào thừa trong bộ đệm. Xem thêm về cả hai ở bài scanf.

So sánh chuỗi bằng ==

Triệu chứng: hai chuỗi rõ ràng giống nhau nhưng khi so sánh lại khác nhau.

char a[] = "hello";
char b[] = "hello";

if (a == b) {              /* comparing two addresses: false */
    printf("same\n");
}

Nguyên nhân: chuỗi trong C không phải là một giá trị, mà là con trỏ tới ký tự đầu tiên. == so sánh các con trỏ. Hai mảng chứa văn bản giống hệt nhau nằm ở hai địa chỉ khác nhau, nên phép kiểm tra cho kết quả sai. (Khó hiểu hơn nữa, so sánh hai chuỗi hằng giống nhau đôi khi cho kết quả đúng, vì trình biên dịch có thể chỉ lưu một bản, khiến bug lúc có lúc không.)

Cách sửa: dùng strcmp, và nhớ rằng nó trả về 0 khi bằng nhau:

Cách đọc ngược cũng hay làm người ta vấp: if (strcmp(a, b)) đúng khi hai chuỗi khác nhau, vì kết quả khác 0 nghĩa là "không bằng". Luôn viết rõ == 0.

Chia số nguyên

Triệu chứng: trung bình bằng 0, phần trăm lúc nào cũng là 0 hoặc 100, một tỷ lệ bị mất phần thập phân.

int correct = 7, total = 10;
double score = correct / total;      /* 0.0, not 0.7 */

Nguyên nhân: cả hai toán hạng đều là int, nên C chia số nguyên và cắt bỏ phần thập phân trước khi kết quả được gán vào double. 7 / 10 là 0; chuyển 0 sang double thì được 0.0.

Cách sửa: biến một toán hạng thành số thực trước phép chia:

Ép kiểu một toán hạng sẽ tự động nâng kiểu toán hạng còn lại. Ép kiểu kết quả thì đã quá muộn: phép cắt đã xảy ra rồi. Xem ép kiểu.

Cái bẫy tương tự ẩn trong các biểu thức như (a + b) / 2 để tính điểm giữa, và 1 / 2 * x, luôn bằng 0 bất kể x là gì.

Thiếu return hoặc return sai

Triệu chứng: một hàm trả về một con số sai nhưng trông có vẻ hợp lý, khác nhau ở mỗi lần chạy hay mỗi bản build.

int add(int a, int b) {
    int sum = a + b;
    /* no return statement */
}

Nguyên nhân: chạy tới cuối một hàm không phải void mà không return sẽ cho ra một giá trị không xác định, trên thực tế là bất cứ thứ gì đang nằm trong thanh ghi trả về. Nếu nơi gọi dùng giá trị đó thì đó là undefined behavior.

Dạng khó phát hiện hơn là return ở một số nhánh và không return ở các nhánh khác:

int classify(int n) {
    if (n > 0) return 1;
    if (n < 0) return -1;
    /* n == 0 falls off the end */
}

Cách sửa: return trên mọi nhánh, và biên dịch với -Wall: cảnh báo "control reaches end of non-void function" của GCC bắt được cả hai dạng.

Biến chưa khởi tạo

Triệu chứng: đầu ra là giá trị rác, hoặc kết quả thay đổi giữa các lần chạy và giữa các mức tối ưu hóa.

int total;                       /* contains whatever was on the stack */
for (int i = 1; i <= 5; i++) {
    total += i;                  /* adding to garbage */
}
printf("%d\n", total);           /* some enormous number */

Nguyên nhân: biến cục bộ không được đặt về 0. Biến toàn cục hay biến static được tự động đặt bằng 0; biến cục bộ bắt đầu với bất kỳ byte nào đang có sẵn ở địa chỉ stack đó.

Cách sửa: khởi tạo ngay tại chỗ khai báo. Việc này không tốn gì và loại bỏ cả một nhóm bug:

-Wall -Wextra cảnh báo nhiều trường hợp như vậy ("may be used uninitialized"), còn -fsanitize=memory hoặc valgrind bắt được phần còn lại. Lỗi này đặc biệt nguy hiểm vì một con trỏ chưa khởi tạo dẫn thẳng tới segmentation fault.

Lệch một đơn vị (off-by-one)

Triệu chứng: phần tử cuối bị bỏ sót, hoặc chạm tới thừa một phần tử và chương trình chạy sai về sau.

int arr[5];
for (int i = 0; i <= 5; i++) {   /* touches arr[5], which does not exist */
    arr[i] = i;
}

Nguyên nhân: một mảng n phần tử có các chỉ số từ 0 đến n - 1. <= chạy thừa một lượt.

Cách sửa: dùng mẫu i < n, và tính n từ chính mảng thay vì viết con số hai lần:

Phiên bản với chuỗi, quên chừa chỗ cho '\0', cũng là lỗi đó dưới một hình dạng khác, và char word[5] chứa "hello" là một vụ tràn bộ đệm.

Hai lỗi nhỏ hơn đáng biết

Dấu chấm phẩy sau phần đầu vòng lặp. for (int i = 0; i < 10; i++); đi kèm một khối trong ngoặc nhọn sẽ chạy vòng lặp mười lần mà không làm gì, rồi chạy khối lệnh đúng một lần. Code vẫn biên dịch trơn tru.

sizeof trên con trỏ. Bên trong hàm, tham số kiểu mảng là một con trỏ, nên sizeof(arr) là kích thước con trỏ (8 byte), không phải của mảng. Hãy truyền độ dài như một đối số riêng:

Thói quen ngăn được phần lớn các lỗi này

Biên dịch với cảnh báo được bật, ngay từ chương trình đầu tiên:

gcc -Wall -Wextra -g program.c -o program

-Wall -Wextra bắt được phép gán trong điều kiện, thiếu return, đọc biến chưa khởi tạo, biến không dùng tới, và đặc tả định dạng của printf không khớp với đối số. Thêm -fsanitize=address,undefined trong lúc phát triển sẽ bắt gần như mọi lỗi còn lại ngay khi chúng xảy ra.

Hãy coi mỗi cảnh báo là một lỗi bạn chưa gặp phải. Một chương trình C biên dịch không có cảnh báo nào chưa chắc đã đúng, nhưng gần như mọi chương trình C bị crash đều đã cảnh báo về điều gì đó từ trước.

Câu hỏi thường gặp

'implicit declaration of function' trong C nghĩa là gì?

Trình biên dịch gặp lời gọi tới một hàm mà nó chưa từng thấy khai báo. Gần như lúc nào cũng là do bạn quên một lệnh #include: printf cần <stdio.h>, malloc cần <stdlib.h>, strlen cần <string.h>. Nó cũng có thể nghĩa là bạn gọi hàm của mình trước khi định nghĩa, và một nguyên mẫu hàm đặt phía trên main sẽ sửa được.

Vì sao chương trình C báo lỗi ở một dòng trông hoàn toàn bình thường?

Thường là vì lỗi thật nằm ở dòng phía trước. Thiếu dấu chấm phẩy, thiếu ngoặc nhọn đóng hay một chú thích chưa đóng khiến trình biên dịch đọc hai dòng của bạn thành một, nên nó báo lỗi ở chỗ cuối cùng không phân tích được nữa. Luôn kiểm tra dòng ngay trên dòng được báo lỗi trước.

Vì sao không so sánh chuỗi bằng == được trong C?

Vì chuỗi trong C là một char *, tức một con trỏ, nên == so sánh hai địa chỉ chứ không phải các ký tự mà chúng trỏ tới. Hai chuỗi giống hệt nhau nằm ở hai nơi khác nhau sẽ cho kết quả false. Hãy dùng strcmp(a, b) == 0 trong <string.h>, hàm này trả về 0 khi nội dung giống nhau.

Vì sao 5 / 2 cho ra 2 trong C?

Vì cả hai toán hạng đều là số nguyên, nên C thực hiện phép chia số nguyên và bỏ phần thập phân. Hãy biến một vế thành số thực để có 2.5: 5.0 / 2, hoặc (double)a / b khi làm việc với biến. Ép kiểu kết quả thì đã quá muộn: (double)(5 / 2) là 2.0.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu