Menu

printf trong C: chuỗi định dạng, đặc tả và in số thực

printf trong C thật sự hoạt động thế nào: mô hình chuỗi định dạng, các đặc tả bạn dùng hằng ngày, độ rộng và độ chính xác để căn cột, in số thực cho gọn, giá trị trả về, và vì sao printf(user_input) là một lỗ hổng bảo mật.

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.

printf là hàm đầu tiên mọi lập trình viên C học, và cũng là hàm mà phần lớn họ chưa bao giờ đọc hết tài liệu. Bỏ ra mười phút cho nó là đáng, vì chuỗi định dạng (format string) của nó là cả một ngôn ngữ nhỏ, và vì viết sai một đặc tả không phải lỗi gõ nhầm mà là undefined behavior.

Hàm này nằm trong <stdio.h>, nên mọi chương trình dùng nó đều bắt đầu bằng #include <stdio.h>.

Mô hình chuỗi định dạng

printf đi qua chuỗi định dạng từng ký tự một. Mọi thứ không phải % được in ra đúng như đã viết. Một dấu % mở đầu một đặc tả chuyển đổi (conversion specification), lấy đối số tiếp theo và in nó theo dạng được yêu cầu.

Các đối số được ghép với đặc tả hoàn toàn theo vị trí: % đầu tiên lấy count, cái thứ hai lấy name, và cứ thế tiếp. printf không có cách nào kiểm tra bạn đã truyền đúng thứ đã hứa hay chưa, vì nó không nhìn thấy kiểu dữ liệu, nên chuỗi định dạng là một bản cam kết bạn phải giữ. Đó là lý do nên bật -Wall: GCC đọc chuỗi định dạng dạng literal lúc biên dịch và cảnh báo khi một đối số không khớp.

Không có gì tự động xuống dòng. printf("Hello") để con trỏ ngay sau chữ o, vì thế gần như mọi chuỗi định dạng đều kết thúc bằng \n.

Các đặc tả bạn dùng hằng ngày

Năm đặc tả này bao quát phần lớn code:

Đặc tảIn raLời gọi ví dụĐầu ra
%dmột int có dấuprintf("%d", -42)-42
%fmột doubleprintf("%f", 2.5)2.500000
%smột chuỗi (char *)printf("%s", "hi")hi
%cmột ký tựprintf("%c", 'A')A
%pmột địa chỉ con trỏprintf("%p", (void*)&x)0x7ffd...

Đoạn code trên ẩn chứa hai quy tắc. Ký tự % theo nghĩa đen được viết là %%; một dấu % đứng lẻ ở cuối chuỗi định dạng là undefined behavior. Còn %p đòi hỏi một void *, nên hãy ép kiểu con trỏ; truyền thẳng một int * về mặt kỹ thuật là undefined, dù thực tế vẫn in ra bình thường.

Bảng đầy đủ, với mọi đặc tả, mọi bổ từ độ dài và cột dành cho scanf, nằm ở trang đặc tả định dạng. Trang này chỉ nói về những đặc tả bạn hay dùng.

Độ rộng, độ chính xác và căn lề

Giữa dấu % và chữ cái, bạn có thể đặt cờ (flag), độ rộng (width) và độ chính xác (precision). Đó là cách biến một vòng lặp thành một bảng.

Độ rộng là mức tối thiểu, không bao giờ là mức tối đa: %3d với giá trị 123456 vẫn in đủ cả sáu chữ số. Độ chính xác mang nghĩa khác nhau tùy kiểu: số chữ số sau dấu thập phân với %f, số ký tự tối đa với %s, số chữ số tối thiểu với %d.

Cả hai đều có thể truyền vào lúc chạy bằng dấu *, khi đó nó đọc một đối số int:

Đổi width là cả bảng được dàn lại. Đây là cách chuẩn để in các cột thẳng hàng trong C, không cần hàm phụ nào để chèn khoảng trắng vào chuỗi.

In số thực cho gọn

%f không kèm độ chính xác luôn in sáu chữ số sau dấu chấm, điều hiếm khi ai muốn:

Ba lựa chọn, mỗi cái có một nguyên tắc dễ nhớ:

  • %.Nf cho tiền, số đo, bất cứ thứ gì có số chữ số thập phân tự nhiên. %.2f là thứ bạn cần cho tiền tệ.
  • %e cho ký hiệu khoa học, luôn có dạng d.dddddde±dd.
  • %g khi bạn không biết trước độ lớn. Nó chọn %f hoặc %e, cái nào ngắn hơn, và bỏ các số 0 thừa ở cuối: lựa chọn mặc định hợp lý nhất để ghi log một giá trị bạn chưa từng thấy.

Một cái bẫy cần nói rõ: không có %lf trong printf. Đối số float được nâng lên double trước khi printf nhìn thấy, nên %f xử lý được cả hai, và %lf chỉ có ý nghĩa trong scanf, nơi kích thước của biến đích quan trọng. (C99 có chấp nhận %lf trong printf như một cách viết tương đương, nhưng thói quen viết nó khiến người ta viết %f trong scanf, và như thế thì hỏng thật.)

Số nguyên có độ rộng khác

%d dành cho int. Kiểu lớn hơn hoặc không dấu cần một bổ từ độ dài (length modifier), và dùng sai thì là undefined behavior chứ không phải lỗi làm tròn:

%zu cho size_t là cái người ta hay viết sai nhất: sizeof trả về size_t chứ không phải int, nên printf("%d", sizeof x) là một bug tình cờ chạy được trên hệ 32 bit và chạy sai trên hệ 64 bit.

Giá trị trả về

printf trả về số ký tự đã ghi, hoặc một số âm khi thất bại. Giá trị này thường bị bỏ qua, và phần lớn trường hợp thì không sao, nhưng nó thật sự hữu ích khi bạn cần biết đầu ra rộng bao nhiêu:

Các hàm liên quan sprintf và snprintf ghi vào một bộ đệm (buffer) thay vì ra màn hình, và trả về độ dài chúng đã tạo ra. Luôn ưu tiên snprintf, hàm nhận kích thước bộ đệm và không thể tràn:

snprintf trả về độ dài mà toàn bộ chuỗi lẽ ra cần, nên giá trị trả về lớn hơn hoặc bằng kích thước bộ đệm cho bạn biết đầu ra đã bị cắt bớt.

Lỗ hổng chuỗi định dạng

Đây là vấn đề bảo mật duy nhất của printf, và nó nghiêm trọng. Đừng bao giờ truyền dữ liệu không do bạn viết vào làm chuỗi định dạng:

/* DANGEROUS - never do this */
printf(user_input);

/* Correct */
printf("%s", user_input);

Nếu user_input chứa %s, printf sẽ đọc một đối số chưa từng được truyền và đi theo bất kỳ giá trị rác trông giống con trỏ nào nó gặp, thường là crash. Lặp lại %x sẽ đổ nội dung stack ra màn hình, trong đó có thể có mật khẩu hoặc khóa bí mật. Còn %n, đặc tả ghi số ký tự vào một đối số, trong lịch sử đã biến lỗi này thành hỏng bộ nhớ tùy ý và thực thi code từ xa.

Quy tắc này tuyệt đối và không tốn gì: chuỗi định dạng luôn là một literal do bạn viết. Mọi thứ thay đổi được đều đi vào dưới dạng đối số. -Wformat-security của GCC bắt được lỗi này, và nó nằm trong -Wall -Wextra.

Bộ đệm: vì sao đầu ra đôi khi xuất hiện muộn

printf không ghi ra terminal ngay lập tức. Đầu ra được đưa vào bộ đệm và chỉ được đẩy ra khi bộ đệm đầy, khi gặp ký tự xuống dòng trên terminal, hoặc khi chương trình kết thúc bình thường. Có hai hệ quả:

  • Một chương trình bị crash có thể mất phần đầu ra "đã in rồi". Nếu bạn gỡ lỗi bằng printf mà thông báo cuối cùng không bao giờ hiện ra, thì crash xảy ra sau dòng đó chứ không phải trước. Hãy thêm fflush(stdout) hoặc gửi đầu ra gỡ lỗi sang stderr, vốn không có bộ đệm.
  • fprintf(stderr, ...) là cách đúng để in lỗi. Nó không có bộ đệm, và đi vào một luồng riêng mà người dùng có thể chuyển hướng độc lập với đầu ra thông thường.

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

printf hoạt động thế nào trong C?

printf nhận một chuỗi định dạng và quét từng ký tự. Ký tự thường được in ra nguyên vẹn; mỗi dấu % mở đầu một đặc tả chuyển đổi, lấy một đối số kế tiếp và in nó theo dạng được yêu cầu. printf("%d apples\n", 5) in ra 5 apples và một ký tự xuống dòng.

Làm sao in số thực với 2 chữ số thập phân trong C?

Dùng độ chính xác: printf("%.2f\n", 3.14159) in ra 3.14. Con số sau dấu chấm là số chữ số sau dấu thập phân. %f không kèm độ chính xác luôn in 6 chữ số, đó là lý do số thực chưa định dạng trông rối mắt đến vậy.

Vì sao printf in ra giá trị rác hoặc làm chương trình crash?

Hầu như luôn là do đặc tả không khớp với đối số. printf("%d", 3.5) hay printf("%s", 42) là undefined behavior: printf không nhìn thấy kiểu thật, nó tin vào chuỗi định dạng. Hãy biên dịch với -Wall để GCC kiểm tra chuỗi định dạng với các đối số.

Vì sao printf(user_input) nguy hiểm?

Nếu văn bản của người dùng chứa các chuỗi %, printf sẽ làm theo và đọc những đối số chưa từng được truyền vào, làm lộ bộ nhớ stack, và với %n thì còn ghi được vào bộ nhớ. Đây là lỗ hổng chuỗi định dạng kinh điển. Luôn viết printf("%s", user_input) thay thế.

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