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

Các lỗi nhập liệu thường gặp

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

  • Xử lý ký tự xuống dòng còn sót lại trong bộ đệm
  • Tránh vòng lặp vô hạn khi scanf thất bại
  • Giải thích vì sao không nên trộn scanf với fgets
  • Chọn đúng cách nhập cho từng loại dữ liệu

Năm lỗi trong bài này chiếm phần lớn số câu hỏi mà người học C đăng lên các diễn đàn. Chúng có chung một nguyên nhân gốc: bộ đệm nhập liệu là một dòng chảy liên tục, không phải một loạt ô nhập riêng biệt như bạn tưởng tượng.

#1. Ký tự xuống dòng còn sót

Khi bạn gõ 20 rồi bấm phím Enter, bộ đệm nhận được ba ký tự: 2, 0, và ký tự xuống dòng. Đặc tả %d chỉ lấy hai ký tự số, ký tự xuống dòng vẫn nằm nguyên đó.

loi1.c
#include <stdio.h>

int main(void)
{
    int  tuoi;
    char gioi_tinh;

    printf("Nhập tuổi: ");
    scanf("%d", &tuoi);

    printf("Giới tính (M/F): ");
    scanf("%c", &gioi_tinh);      /* đọc ngay ký tự xuống dòng còn sót */

    printf("Tuổi %d, giới tính %c\n", tuoi, gioi_tinh);

    return 0;
}
terminal
./loi1
Nhập tuổi: 20
Giới tính (M/F): Tuổi 20, giới tính 
# Chương trình không hề dừng lại chờ nhập giới tính

Ba cách sửa

/* Cách 1: thêm dấu cách trước %c, gọn nhất */
scanf(" %c", &gioi_tinh);

/* Cách 2: dọn sạch phần còn lại của dòng, chắc chắn nhất */
int c;

while ((c = getchar()) != '\n' && c != EOF) {
    /* nuốt hết tới cuối dòng */
}

/* Cách 3: không dùng scanf nữa, dùng fgets cho tất cả */

#2. Trộn scanf với fgets

Đây là hệ quả trực tiếp của lỗi thứ nhất, nhưng khó nhận ra hơn nhiều vì fgets trông có vẻ an toàn.

Không nên
int  tuoi;
char ten[64];

printf("Tuổi: ");
scanf("%d", &tuoi);          /* để lại ký tự xuống dòng */

printf("Họ tên: ");
fgets(ten, sizeof ten, stdin);
/* fgets đọc ngay ký tự xuống dòng và trả về chuỗi rỗng.
   Chương trình không chờ người dùng nhập tên. */
Nên
int  tuoi;
char ten[64];
char dong[128];

printf("Tuổi: ");
fgets(dong, sizeof dong, stdin);
sscanf(dong, "%d", &tuoi);   /* phân tích trên chuỗi, bộ đệm đã sạch */

printf("Họ tên: ");
fgets(ten, sizeof ten, stdin);
ten[strcspn(ten, "\n")] = '\0';

#3. Tràn bộ đệm

char buf[10];

scanf("%s", buf);
/* Người dùng nhập 50 ký tự.
   40 byte bị ghi ra NGOÀI vùng nhớ của buf,
   đè lên biến khác hoặc lên địa chỉ trở về của hàm. */

Điều nguy hiểm là chương trình thường không dừng ngay. Nó chạy tiếp với dữ liệu đã bị hỏng, và lỗi xuất hiện ở một chỗ hoàn toàn khác, rất khó lần ra nguyên nhân.

/* Ba cách an toàn, theo thứ tự ưu tiên */
fgets(buf, sizeof buf, stdin);      /* tốt nhất */
scanf("%9s", buf);                  /* chấp nhận được, nhớ trừ 1 */
scanf("%s", buf);                   /* không bao giờ */
terminal
# Công cụ phát hiện tràn bộ đệm ngay khi xảy ra
gcc -g -fsanitize=address bai.c -o bai && ./bai
==ERROR: AddressSanitizer: stack-buffer-overflow
WRITE of size 1 at 0x7ffd... 
    #0 in main bai.c:9

#4. Vòng lặp vô hạn

Khi scanf gặp dữ liệu không khớp định dạng, nó dừng lại và không tiêu thụ ký tự gây lỗi. Ký tự đó vẫn nằm trong bộ đệm, nên lần lặp sau lại gặp đúng nó và lại thất bại.

int n;

while (1) {
    printf("Nhập số: ");

    if (scanf("%d", &n) == 1) {
        break;
    }

    printf("Sai rồi\n");
    /* Người dùng gõ "abc".
       scanf trả về 0, ký tự 'a' vẫn nằm trong bộ đệm.
       Vòng lặp chạy lại, gặp 'a', lại trả về 0.
       In "Sai rồi" vô hạn lần. */
}
Không nên
while (scanf("%d", &n) != 1) {
    printf("Sai rồi\n");
}
/* Vòng lặp vô hạn khi nhập chữ */
Nên
while (scanf("%d", &n) != 1) {
    printf("Sai rồi\n");

    int c;

    while ((c = getchar()) != '\n' && c != EOF) {
        /* phải dọn dữ liệu hỏng */
    }
}

#5. Quên bỏ ký tự xuống dòng

Lỗi này chỉ xảy ra với fgets, và biểu hiện của nó thường là so sánh chuỗi không bao giờ đúng.

char lenh[32];

while (1) {
    printf("> ");

    if (!fgets(lenh, sizeof lenh, stdin)) {
        break;
    }

    if (strcmp(lenh, "thoat") == 0) {   /* KHÔNG BAO GIỜ đúng */
        break;
    }

    printf("Lệnh không hiểu: %s", lenh);
}

Chuỗi thật sự nằm trong lenhthoat kèm ký tự xuống dòng, nên nó không bằng thoat. Thêm một dòng là xong:

lenh[strcspn(lenh, "\n")] = '\0';

#Bảng tổng kết

Cần nhậpCách đúng
Số nguyênfgets rồi strtol, kiểm tra end errno
Số thựcfgets rồi strtod
Một từfgets rồi sscanf(dong, "%49s", tu)
Cả dòng có khoảng trắngfgets rồi s[strcspn(s, "\n")] = 0
Một ký tựfgets rồi lấy dong[0]
Nhiều trường một dòngfgets rồi sscanf, kiểm tra số mục trả về

Khuôn mẫu nhập liệu hoàn chỉnh

mau-nhap.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

/* Đọc một dòng, bỏ ký tự xuống dòng, dọn phần thừa nếu dòng quá dài. */
static int doc_dong(char *buf, size_t n)
{
    if (!fgets(buf, (int)n, stdin)) {
        return -1;
    }

    size_t len = strcspn(buf, "\n");

    if (buf[len] != '\n') {
        int c;

        while ((c = getchar()) != '\n' && c != EOF) {
        }
    }

    buf[len] = '\0';

    return 0;
}

/* Đọc số nguyên trong khoảng cho trước, hỏi lại tới khi đúng. */
static long doc_so(const char *nhac, long min, long max)
{
    char dong[128];

    for (;;) {
        printf("%s", nhac);

        if (doc_dong(dong, sizeof dong) != 0) {
            fprintf(stderr, "\nHết dữ liệu vào\n");
            exit(EXIT_FAILURE);
        }

        errno = 0;

        char *end;
        long v = strtol(dong, &end, 10);

        if (end == dong || *end != '\0') {
            printf("Phải nhập một số nguyên.\n");
            continue;
        }

        if (errno == ERANGE || v < min || v > max) {
            printf("Giá trị phải nằm trong khoảng %ld đến %ld.\n", min, max);
            continue;
        }

        return v;
    }
}

int main(void)
{
    long tuoi = doc_so("Nhập tuổi (0 đến 150): ", 0, 150);

    printf("Tuổi của bạn là %ld\n", tuoi);

    return 0;
}

Tự làm thử

  1. Tái hiện lỗi số 1: viết chương trình nhập tuổi rồi nhập giới tính bằng %c không có dấu cách. Sau đó sửa bằng cả ba cách.
  2. Tái hiện lỗi số 2: nhập số bằng scanf rồi nhập tên bằng fgets. Quan sát rồi sửa.
  3. Tái hiện lỗi số 4 và đếm xem chương trình in ra bao nhiêu dòng trước khi bạn phải bấm tổ hợp phím dừng.
  4. Viết chương trình dùng bộ đệm 8 byte, nhập một dòng 30 ký tự, gọi fgets ba lần và in kết quả từng lần.
  5. Chép khuôn mẫu nhập liệu ở trên vào file riêng, rồi viết chương trình menu dùng nó để nhập lựa chọn.
  6. Kiểm chứng lỗi tràn bộ đệm bằng -fsanitize=address và đọc kỹ báo cáo.

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ộ đệm nhập liệu là một dòng chảy liên tục, phím Enter cũng là một ký tự nằm trong đó.
  • Sau scanf("%d"), ký tự xuống dòng vẫn còn, nên %c hoặc fgets tiếp theo sẽ đọc phải nó.
  • Không bao giờ gọi fflush(stdin), đó là hành vi không xác định.
  • scanf thất bại thì không tiêu thụ ký tự gây lỗi, nên phải tự dọn nếu không muốn vòng lặp vô hạn.
  • Chọn một cách nhập và dùng nhất quán. Trộn scanf với fgets chắc chắn sinh bug.
  • fgets kèm strtol hoặc sscanf xử lý được cả năm lỗi trong bài.