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ả
%[cờ][độ rộng][.độ chính xác][bổ từ]ký_tự_kiểuBố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ĩa | Ví dụ | Kết quả |
|---|---|---|---|
| - | Căn trái | printf("[%-8d]", 42) | [42 ] |
| + | Luôn hiện dấu | printf("[%+d]", 42) | [+42] |
| khoảng trắng | Chèn khoảng trắng nếu không âm | printf("[% d]", 42) | [ 42] |
| 0 | Đệm bằng số 0 | printf("[%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ới | Dấu chấm rồi số nghĩa là |
|---|---|
| %d %i %o %u %x %X | Số chữ số TỐI THIỂU, thiếu thì đệm số 0 phía trước |
| %f %F %e %E | Số chữ số SAU dấu chấm thập phân, mặc định là 6 |
| %g %G | Tổng số chữ số CÓ NGHĨA |
| %s | Số ký tự TỐI ĐA được in ra, phần dư bị cắt |
| %a %A | Số 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ểu | Ví dụ |
|---|---|---|
| hh | signed char và unsigned char | %hhd %hhu |
| h | short và unsigned short | %hd %hu |
| không có | int và unsigned int | %d %u |
| l | long và unsigned long | %ld %lu |
| ll | long long và unsigned long long | %lld %llu |
| z | size_t | %zu |
| t | ptrdiff_t, hiệu của hai con trỏ | %td |
| j | intmax_t | %jd |
| L | long 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
| Mã | In ra | Ví dụ kết quả |
|---|---|---|
| %d hoặc %i | Số nguyên có dấu, hệ mười | 255 |
| %u | Số nguyên không dấu | 255 |
| %o | Hệ tám | 377 |
| %x và %X | Hệ mười sáu chữ thường và chữ hoa | ff và FF |
| %f và %F | Số thực dạng thập phân | 3.140000 |
| %e và %E | Dạng khoa học | 3.140000e+00 |
| %g và %G | Tự chọn dạng ngắn hơn, bỏ số 0 thừa | 3.14 |
| %a và %A | Số thực hệ mười sáu, chính xác tuyệt đối | 0x1.91eb86p+1 |
| %c | Một ký tự | A |
| %s | Chuỗi kết thúc bằng ký tự null | hello |
| %p | Con trỏ, phải ép sang void sao | 0x7ffd1a2b |
| %n | GHI số ký tự đã in vào con trỏ int | nguy 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ợ.
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 ý.
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.
#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.20svừ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.%-4zudùng đúng bổ từzchosize_t.%.*sdùng để in đường kẻ có độ dài chính xác, không cần đếm tay.
Tự làm thử
- In
3.14159265với 0, 2, 4 và 8 chữ số thập phân, rồi in lại bằng%e,%gvà%a. - 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.
- Viết hàm
void hexdump(const void *p, size_t n)in giống lệnhxxd: đị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. - 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.
- 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 %xvà quan sát kết quả. Sau đó sửa lại thànhprintf("%s", buf). - In cùng một số
doublebằng%.17gvà%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.
%.*slà 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
%zuchosize_tvà các macroPRIu64cho 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.