← Lập trình C nâng cao

Bài 13 · Nâng cao · 24 phút· Cập nhật 11/06/2026

Tệp & luồng dữ liệu (FILE *)

Biên soạn bởi Nguyễn Anh Tuấn

Đọc ghi tệp trong C với FILE *: fopen/fclose, fgets/fprintf, văn bản vs nhị phân (fread/fwrite), buffering và fflush - để dữ liệu sống qua mỗi lần chạy.

Mọi chương trình bạn viết tới giờ đều quên sạch khi thoát - dữ liệu nằm trong RAM, mà RAM mất điện là trắng tay (khoá "Máy tính hoạt động thế nào?" đã chỉ ra vì sao). Muốn dữ liệu sống qua mỗi lần chạy, phải gửi nó xuống tệp trên đĩa. Cửa ngõ của C là FILE * - mở bằng fopen, dùng xong đóng bằng fclose:

ghi.c - tạo tệp và ghi hai dòng

#include <stdio.h>

int main(void) {
    FILE *f = fopen("diem.txt", "w");
    if (f == NULL) {
        printf("Khong mo duoc file!\n");
        return 1;
    }
    fprintf(f, "Toan 9.5\n");
    fprintf(f, "Tin 10.0\n");
    fclose(f);
    printf("Da ghi xong diem.txt\n");
    return 0;
}

Kết quả khi chạy

Da ghi xong diem.txt
  • fopen(tên, mode) trả về FILE * - hoặc NULL khi thất bại (file không tồn tại, hết quyền…): LUÔN kiểm tra.
  • Ba mode chính: "r" đọc · "w" ghi mới (CẮT SẠCH nội dung cũ ngay lúc mở!) · "a" ghi nối vào cuối.
  • fprintf giống hệt printf, chỉ thêm đích f ở đầu - cả họ hàm in bạn đã biết dùng lại được nguyên.

Chiều ngược lại: fgets đọc một dòng vào buffer, trả về NULL khi hết tệp - thành vòng while kinh điển:

doc.c - đọc lại diem.txt vừa ghi

#include <stdio.h>

int main(void) {
    FILE *f = fopen("diem.txt", "r");
    if (f == NULL) {
        printf("Chua co diem.txt!\n");
        return 1;
    }
    char dong[100];
    while (fgets(dong, sizeof dong, f) != NULL) {
        printf("> %s", dong);
    }
    fclose(f);
    return 0;
}

Kết quả khi chạy

> Toan 9.5
> Tin 10.0
  • fgets(buf, size, f): không bao giờ ghi quá size byte - an toàn tràn bộ nhớ (khác gets đã bị xoá khỏi chuẩn).
  • fgets GIỮ ký tự xuống dòng trong chuỗi - vì thế printf ở trên không cần thêm \n.
  • Khuôn while (fgets(…) != NULL) là cách đọc-từng-dòng chuẩn mực của C.

Đây là điều mà rất nhiều người viết C lâu năm vẫn vấp: fprintf không ghi xuống đĩa ngay. Chữ vào một buffer trong RAM trước; chỉ khi fflush/fclose (hoặc buffer đầy) nó mới thật sự xuống đĩa. Chạy từng bước 5 kịch bản - để ý kịch bản nào mất dữ liệu:

FILE *f = fopen("nhat-ky.txt", "w");
fprintf(f, "dong 1\n");
fprintf(f, "dong 2\n");
fclose(f);                /* tu flush roi dong */
0/4 bước
Buffer (RAM - bay hơi)
(rỗng)
Đĩa - nhat-ky.txt (bền)
(file rỗng - 0 byte)
Bấm "Bước ▶" - theo dõi chữ đi từ fprintf → buffer → đĩa.

Kịch bản "chết trước fclose" không phải chuyện mô phỏng - máy thật y hệt:

mat-buffer.c - chạy xong, nhat-ky.txt nặng ĐÚNG 0 BYTE (đã kiểm thật; thêm fflush trước _Exit thì dòng chữ sống sót)

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    FILE *f = fopen("nhat-ky.txt", "w");
    fprintf(f, "dong quan trong\n");   /* moi nam trong BUFFER (RAM) */
    _Exit(1);                          /* chet tuc thi - khong flush */
}

Trung thực: mô hình đã giản lược

Buffer thật còn tự đẩy khi đầy (thường vài KB) và stdout ra terminal thì đẩy theo từng dòng - mô phỏng trên chỉ giữ phần quyết định bug: flush tường minh vs chết đột ngột. Và abort() thì tuỳ hệ thống (macOS lại flush trước khi chết - xem FAQ), nên demo dùng _Exit.

fprintf ghi chữ. Còn muốn ghi nguyên biểu diễn byte của dữ liệu - như mảng struct của app Todo - thì dùng cặp fwrite/fread với mode "wb"/"rb":

nhiphan.c - ghi 2 bản ghi Task xuống đĩa rồi đọc lại nguyên vẹn

#include <stdio.h>
#include <stdbool.h>

typedef struct {
    int  id;
    char ten[32];
    bool xong;
} Task;

int main(void) {
    Task ds[2] = { { 1, "tuoi cay", true }, { 2, "hoc bai", false } };

    FILE *f = fopen("task.bin", "wb");          /* wb: ghi NHI PHAN */
    fwrite(ds, sizeof(Task), 2, f);
    fclose(f);

    Task doc[2];
    f = fopen("task.bin", "rb");
    size_t n = fread(doc, sizeof(Task), 2, f);
    fclose(f);

    printf("Doc lai %zu ban ghi:\n", n);
    for (size_t i = 0; i < n; i++)
        printf("[%c] #%d %s\n", doc[i].xong ? 'x' : ' ', doc[i].id, doc[i].ten);
    printf("File nang %zu byte (2 x sizeof(Task) = 2 x %zu)\n",
           2 * sizeof(Task), sizeof(Task));
    return 0;
}

Kết quả khi chạy

Doc lai 2 ban ghi:
[x] #1 tuoi cay
[ ] #2 hoc bai
File nang 80 byte (2 x sizeof(Task) = 2 x 40)

Trung thực: file nhị phân không khả chuyển

80 byte kia chứa cả padding của struct, và số nguyên nằm theo thứ tự byte của máy bạn (bài Union, biểu diễn byte & alignment). Đem file này sang máy khác kiến trúc - hoặc chỉ cần struct đổi một trường - là đọc sai hết. Nhị phân hợp cho cache nội bộ; dữ liệu cần trao đổi/bền lâu thì ưu tiên văn bản (mỗi dòng một bản ghi), như cách dự án cuối khoá làm.
ViệcCông cụ
Mở / đóngfopen("…", "r"/"w"/"a") · fclose
Ghi / đọc văn bảnfprintf · fgets (+ sscanf để tách trường)
Ghi / đọc nhị phânfwrite · fread (mode "wb"/"rb")
Ép xuống đĩa ngayfflush (fclose tự làm)
  • Quy trình tử tế: fopen → kiểm tra NULL → làm việc → fclose. Mỗi fopen một fclose, như malloc với free.
  • Chọn định dạng: văn bản cho dữ liệu cần đọc được/trao đổi; nhị phân cho cache nội bộ cần nhanh gọn.
  • Buffer là bạn (gom nhiều lần ghi nhỏ thành một lần to) - chỉ phản chủ khi chết đột ngột mà chưa flush.

Bài tiếp theo

Bài này mới "kiểm tra NULL rồi in một câu" - quá thô cho phần mềm thật: ai báo lỗi gì, dọn dẹp tài nguyên đã mở thế nào? Bài kế - Xử lý lỗi kiểu C - trả lời bằng errno, hợp đồng hàm và khuôn goto cleanup.

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

Một con trỏ tới cấu trúc do thư viện chuẩn quản lý - bên trong có buffer, vị trí đọc/ghi hiện tại, cờ lỗi… Bạn không cần (và không nên) chọc vào trong: cứ đưa nó cho họ hàng nhà f (fprintf, fgets, fclose…) - đúng tinh thần opaque type của bài Đóng gói & thư viện.

Bài Đóng gói & thư viện →

File descriptor (số nguyên 0, 1, 2…) là handle cấp thấp của hệ điều hành; FILE * là lớp bọc của thư viện chuẩn C phía trên - thêm buffer và các hàm tiện. Lập trình hệ thống với open/read/write trực tiếp là chuyện của khoá C Thực Chiến · Hệ thống; viết app bình thường thì FILE * là đủ và khả chuyển.

gets không có cách nào giới hạn độ dài - chuỗi nhập dài hơn mảng là tràn bộ nhớ. Nó nguy hiểm đến mức bị XOÁ khỏi chuẩn từ C11. fgets nhận kích thước buffer nên an toàn; đổi lại nó giữ cả ký tự xuống dòng, cần thì tự cắt.

Vì muốn mô phỏng "chết không kịp dọn dẹp" một cách tin cậy: _Exit kết thúc tức thì, không flush. Còn abort thì tuỳ hệ thống - trên macOS nó lại flush các stream trước khi chết (bài đã thử thật!), nên không minh hoạ được chuyện mất buffer. Mất điện hay bị kill -9 thì giống _Exit.

Được về tính đúng, nhưng phí: mỗi lần flush là một lần xuống hệ điều hành, mất luôn lợi ích của buffer (gom nhiều lần ghi nhỏ thành một lần ghi to). Quy tắc thực dụng: fclose tử tế khi xong; chỉ fflush tại các "điểm an toàn" thật sự cần (vd trước thao tác rủi ro, hoặc file log cần xem ngay).

Dấu + nghĩa là vừa đọc vừa ghi trên cùng một FILE *; chữ b là chế độ nhị phân (trên Linux/macOS b không đổi gì, trên Windows nó tắt việc tự đổi \n thành \r\n - cứ giữ thói quen viết "rb"/"wb" cho file nhị phân để code khả chuyển). Bộ ba r/w/a của bài phủ hầu hết nhu cầu.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Gọi fprintf(f, "xin chao") xong, dòng chữ đang ở đâu?

  1. 1

    Ghi rồi đọc lại

    Chạy đúng hai chương trình ở Bước 1 và Bước 2 trên máy. Mở diem.txt bằng editor xem tận mắt.

    Hoàn thành khi: diem.txt có 2 dòng; chương trình đọc in lại đúng "> Toan 9.5" và "> Tin 10.0".

  2. 2

    "w" vs "a" bằng tay

    Chạy chương trình ghi 2 lần liên tiếp với mode "w", rồi đổi sang "a" và chạy 2 lần nữa. Đếm số dòng trong file sau mỗi lần.

    Hoàn thành khi: "w": vẫn 2 dòng (lần sau cắt sạch lần trước); "a": tăng dần 4, 6 dòng.

  3. 3

    Tái hiện vụ mất buffer

    Chép chương trình _Exit ở Bước 3 chạy thật, xem kích thước file (wc -c). Rồi thêm fflush trước _Exit và chạy lại.

    Hoàn thành khi: Không fflush: 0 byte. Có fflush: file giữ đúng dòng đã đẩy - khớp với mô phỏng.

  4. 4

    Soi file nhị phân

    Chạy chương trình Bước 4 rồi mở task.bin bằng lệnh xxd task.bin (hoặc hexdump -C). Tìm chữ "tuoi cay" và các byte đệm.

    Hoàn thành khi: Thấy chuỗi tên nằm giữa các byte 00 - nhận ra cả padding của struct (bài Union & alignment) trong file.

  5. 5

    Cho Todo biết nhớ

    Lấy app Todo (bài Struct khoá cơ bản hoặc dự án cuối khoá): viết luu_danh_sach ghi mỗi việc một dòng "id|xong|ten", và nap_danh_sach đọc lại lúc mở.

    Hoàn thành khi: Thêm việc, thoát, mở lại - danh sách còn nguyên. Đây chính là lớp Storage của dự án cuối khoá.

  6. 6

    Đoán trước khi chạy

    Không chạy máy: chương trình mở "a" ghi 1 dòng, fflush, ghi 1 dòng nữa, rồi _Exit. File đang có sẵn 1 dòng cũ. Sau khi chạy, file có mấy dòng? Suy bằng mô hình buffer của Bước 3, rồi viết chương trình kiểm trên máy thật.

    Hoàn thành khi: Đoán 2 dòng (dòng cũ + dòng đã flush; dòng cuối bay theo buffer) - cả mô phỏng lẫn máy thật xác nhận.