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

Format specifier

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

  • Đọc và viết được đủ năm thành phần của một đặc tả định dạng
  • Phân biệt ý nghĩa của độ chính xác theo từng nhóm kiểu
  • Chọn đúng bổ từ độ dài cho từng kiểu dữ liệu
  • Giải thích vì sao sai đặc tả là hành vi không xác định chứ không phải in sai
  • Nhận diện và phòng tránh lỗ hổng chuỗi định dạng

Đây là bài dài nhất Chương 3, và đáng để đọc kỹ. Xâu định dạng là thứ bạn dùng hằng ngày suốt cả khóa học, mà lại là nơi sinh ra hai loại lỗi nghiêm trọng: hành vi không xác định do sai kiểu, và một lỗ hổng bảo mật cho phép kẻ tấn công ghi vào bộ nhớ tùy ý.

#Giải phẫu đặc tả

Thứ tự các phần là cố định. Chỉ dấu phần trăm và ký tự kiểu là bắt buộc.
%[cờ][độ rộng][.độ chính xác][bổ từ]ký_tự_kiểu

Bốn phần ở giữa đều tùy chọn, nên %d là một đặc tả hoàn chỉnh. Nhưng khi cần in bảng cho đẹp hoặc cần cắt chuỗi cho an toàn, bạn sẽ dùng tới cả năm phần.

#1. Cờ

Có thể dùng nhiều cờ cùng lúc, theo thứ tự bất kỳ.

CờÝ nghĩaVí dụKết quả
-Căn tráiprintf("[%-8d]", 42)[42 ]
+Luôn hiện dấuprintf("[%+d]", 42)[+42]
khoảng trắngChèn khoảng trắng nếu không âmprintf("[% d]", 42)[ 42]
0Đệm bằng số 0printf("[%08.2f]", 3.1)[00003.10]
#Dạng thay thếprintf("%#x %#o", 255, 8)0xff 010

#2. Độ rộng

Độ rộng là số cột tối thiểu. Nếu giá trị ngắn hơn thì được đệm thêm. Nếu dài hơn thì không bao giờ bị cắt. Đây là điểm khác hẳn độ chính xác.

printf("[%5d]\n",  42);       /* [   42]  đệm bên trái */
printf("[%-5d]\n", 42);       /* [42   ]  căn trái */
printf("[%05d]\n", 42);       /* [00042]  đệm số 0 */
printf("[%5d]\n", 1234567);   /* [1234567]  KHÔNG cắt */

printf("[%*d]\n", 8, 42);     /* [      42]  độ rộng lấy từ đối số */

#3. Độ chính xác

Đây là phần gây nhầm lẫn nhất, vì ý nghĩa của nó thay đổi hoàn toàn theo nhóm kiểu.

Dùng vớiDấu chấm rồi số nghĩa là
%d %i %o %u %x %XSố chữ số TỐI THIỂU, thiếu thì đệm số 0 phía trước
%f %F %e %ESố chữ số SAU dấu chấm thập phân, mặc định là 6
%g %GTổng số chữ số CÓ NGHĨA
%sSố ký tự TỐI ĐA được in ra, phần dư bị cắt
%a %ASố chữ số hệ mười sáu sau dấu chấm
printf("[%.5d]\n", 42);            /* [00042]  đệm cho đủ 5 chữ số */
printf("[%.2f]\n", 3.14159);       /* [3.14]   hai chữ số thập phân */
printf("[%.3g]\n", 3.14159);       /* [3.14]   ba chữ số có nghĩa */
printf("[%.3s]\n", "abcdef");      /* [abc]    CẮT còn ba ký tự */

printf("[%.*s]\n", 3, "abcdef");   /* [abc]    độ dài lấy từ đối số */
printf("[%10.3f]\n", 3.14159);     /* [     3.142]  kết hợp cả hai */
printf("[%-10.3s|]\n", "abcdef");  /* [abc       |] */

Kết hợp độ rộng và độ chính xác trên chuỗi cho ra thứ cực kỳ hữu ích khi in bảng: %-20.20s vừa căn trái vừa cắt, nên cột không bao giờ vỡ dù dữ liệu dài tới đâu.

#4. Bổ từ độ dài

Phần này bắt buộc phải đúng. Nó nói cho printf biết đối số chiếm bao nhiêu byte trên ngăn xếp. Sai bổ từ thì hàm đọc sai vị trí và mọi giá trị phía sau đều lệch.

Bổ từDùng cho kiểuVí dụ
hhsigned char và unsigned char%hhd %hhu
hshort và unsigned short%hd %hu
không cóint và unsigned int%d %u
llong và unsigned long%ld %lu
lllong long và unsigned long long%lld %llu
zsize_t%zu
tptrdiff_t, hiệu của hai con trỏ%td
jintmax_t%jd
Llong double%Lf

In kiểu kích thước cố định cho khả chuyển

Với các kiểu trong <stdint.h>, dùng %lld là sai trên một số nền tảng. Header <inttypes.h> cung cấp các macro sinh ra đúng đặc tả cho từng hệ thống.

#include <inttypes.h>
#include <stdint.h>

uint64_t  u   = 123456789012345;
int32_t   i   = -5;
size_t    n   = 9;
ptrdiff_t d   = 0;

printf("%" PRIu64 "  %" PRId32 "  %zu  %td\n", u, i, n, d);

/* Đọc vào thì dùng SCNu64, SCNd32 và tương tự */

#5. Ký tự chuyển đổi

In raVí dụ kết quả
%d hoặc %iSố nguyên có dấu, hệ mười255
%uSố nguyên không dấu255
%oHệ tám377
%x và %XHệ mười sáu chữ thường và chữ hoaff và FF
%f và %FSố thực dạng thập phân3.140000
%e và %EDạng khoa học3.140000e+00
%g và %GTự chọn dạng ngắn hơn, bỏ số 0 thừa3.14
%a và %ASố thực hệ mười sáu, chính xác tuyệt đối0x1.91eb86p+1
%cMột ký tựA
%sChuỗi kết thúc bằng ký tự nullhello
%pCon trỏ, phải ép sang void sao0x7ffd1a2b
%nGHI số ký tự đã in vào con trỏ intnguy hiểm
%%Chính dấu phần trăm%

#Sai đặc tả là UB

Đây là điểm cần khắc sâu: dùng sai đặc tả không phải là in sai. Nó là hành vi không xác định, và hậu quả có thể là chương trình dừng đột ngột hoặc rò rỉ dữ liệu.

printf("%d\n", 3.14);        /* SAI: truyền double, đọc như int */
printf("%f\n", 3);           /* SAI: truyền int, đọc như double */
printf("%s\n", 42);          /* SAI: đọc số 42 như một địa chỉ, thường crash */

long v = 5;
printf("%d\n", v);           /* SAI trên Linux 64 bit, long là 8 byte */

size_t n = 10;
printf("%d\n", n);           /* SAI: phải dùng %zu */

int *p = &v;
printf("%p\n", p);           /* SAI về nguyên tắc: %p cần void sao */
printf("%p\n", (void *)p);   /* đúng */

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

Đây là một lớp lỗ hổng bảo mật thật, từng gây ra nhiều sự cố nghiêm trọng. Nguyên nhân đơn giản tới mức đáng sợ.

Không nên
char buf[128];

fgets(buf, sizeof buf, stdin);
printf(buf);        /* NGUY HIỂM */

Người dùng nhập %x %x %x thì in ra nội dung ngăn xếp. Nhập %s thì đọc bộ nhớ tùy ý. Nhập %n thì ghi vào bộ nhớ tùy ý.

Nên
char buf[128];

fgets(buf, sizeof buf, stdin);
printf("%s", buf);  /* đúng */
fputs(buf, stdout); /* đúng và nhanh hơn */

Chuỗi định dạng là hằng do bạn viết. Dữ liệu người dùng chỉ là đối số, không bao giờ được diễn giải.

#Ví dụ tổng hợp

Bài toán in bảng dữ liệu dùng gần như mọi thứ đã học trong bài này.

bang.c
#include <stdio.h>

int main(void)
{
    struct {
        const char *ten;
        int         so_luong;
        double      don_gia;
    } ds[] = {
        { "Bàn phím cơ",        12,  1250000.0  },
        { "Chuột không dây",   130,   349000.5  },
        { "Màn hình 27 inch",    3, 10990000.75 },
    };

    size_t n = sizeof ds / sizeof ds[0];
    double tong = 0;

    printf("%-4s %-20s %6s %14s\n", "STT", "TÊN HÀNG", "SL", "THÀNH TIỀN");
    printf("%.*s\n", 47, "===============================================");

    for (size_t i = 0; i < n; ++i) {
        double thanh_tien = ds[i].so_luong * ds[i].don_gia;

        printf("%-4zu %-20.20s %6d %14.2f\n",
               i + 1, ds[i].ten, ds[i].so_luong, thanh_tien);

        tong += thanh_tien;
    }

    printf("%.*s\n", 47, "===============================================");
    printf("%-31s %14.2f\n", "TỔNG CỘNG", tong);

    return 0;
}

Ba chi tiết đáng chú ý trong đoạn trên:

  • %-20.20s vừa căn trái vừa cắt, nên cột không bao giờ vỡ dù tên hàng dài tới đâu.
  • %-4zu dùng đúng bổ từ z cho size_t.
  • %.*s dùng để in đường kẻ có độ dài chính xác, không cần đếm tay.

Tự làm thử

  1. In 3.14159265 với 0, 2, 4 và 8 chữ số thập phân, rồi in lại bằng %e, %g%a.
  2. In bảng cửu chương từ 2 tới 9, các cột căn thẳng hàng hoàn hảo, mỗi ô rộng 4 cột.
  3. Viết hàm void hexdump(const void *p, size_t n) in giống lệnh xxd: địa chỉ 8 chữ số hệ mười sáu, 16 byte mỗi dòng, kèm cột ký tự bên phải, ký tự không in được thì thay bằng dấu chấm.
  4. In bảng lương 5 nhân viên: tên căn trái và cắt ở 15 ký tự, lương căn phải 12 cột với 2 chữ số thập phân.
  5. Chứng minh lỗ hổng: viết chương trình đọc một dòng rồi gọi printf(buf), nhập vào %x %x %x %x và quan sát kết quả. Sau đó sửa lại thành printf("%s", buf).
  6. In cùng một số double bằng %.17g%a, giải thích vì sao hai cách này khôi phục được giá trị gốc chính xác tới từng bit.

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

  • Đặc tả có năm phần: cờ, độ rộng, độ chính xác, bổ từ độ dài, ký tự kiểu.
  • Độ rộng là tối thiểu và không bao giờ cắt. Độ chính xác trên chuỗi thì cắt.
  • Ý nghĩa của độ chính xác đổi theo kiểu: số chữ số tối thiểu, số chữ số thập phân, số chữ số có nghĩa, hoặc số ký tự tối đa.
  • %.*s là cách duy nhất in an toàn chuỗi không có ký tự kết thúc.
  • Bổ từ phải đúng với kiểu. Dùng %zu cho size_t và các macro PRIu64 cho kiểu kích thước cố định.
  • Sai đặc tả là hành vi không xác định, không phải chỉ là in sai.
  • Chuỗi định dạng không bao giờ được đến từ dữ liệu người dùng.