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 */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
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
| Việc | Công cụ |
|---|---|
| Mở / đóng | fopen("…", "r"/"w"/"a") · fclose |
| Ghi / đọc văn bản | fprintf · fgets (+ sscanf để tách trường) |
| Ghi / đọc nhị phân | fwrite · fread (mode "wb"/"rb") |
| Ép xuống đĩa ngay | fflush (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
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.
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.
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Gọi fprintf(f, "xin chao") xong, dòng chữ đang ở đâu?
- 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.txtbằng editor xem tận mắt.Hoàn thành khi:
diem.txtcó 2 dòng; chương trình đọc in lại đúng "> Toan 9.5" và "> Tin 10.0". - 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
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êmfflushtrước_Exitvà 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
Soi file nhị phân
Chạy chương trình Bước 4 rồi mở
task.binbằng lệnhxxd task.bin(hoặchexdump -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
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_sachghi 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
Đ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.