Skip to content

基础 IO:printf / scanf 的格式化江湖与那些静悄悄的坑

引言:<stdio.h> 与三个标准流

到目前为止我们写了好几个 printf、却一直没正经讲过它。这一章就把 C 的基础输入输出一次说清。它们全在 <stdio.h>(standard I/O)里,最常用的就是 printf(打印)和 scanf(读入)这对搭档。它们俩有个共同的关键身份——都是变参函数(参数个数不固定):第一个参数永远是那个格式串(format string),里面混着普通字符和「转换说明符」(以 % 开头,像 %d%f),后面再跟着数量不定的「待转换参数」。函数就照着格式串里的转换说明符,一个一个地去消费后面的参数。这个机制强大,但编译器通常不会替你检查「格式串里的说明符个数、类型」和「后面实际给的参数」是不是对得上——对不上就是未定义行为,而这正是这一章所有坑的源头。

C 程序一启动就自动开了三个标准流(§7.21):stdin(标准输入,默认接键盘)、stdout(标准输出,默认接屏幕)、stderr(标准错误,也接屏幕、但不缓冲)。printfstdout 写、scanfstdin 读。我们这一章聚焦 printf/scanf 这两个「格式化」IO,把转换说明符、宽度精度、返回值、以及那些静悄悄的坑都真跑一遍。

printf:转换说明符与格式控制

printf 的格式串里,% 后面跟的字母决定「这个参数以什么形态打印」。同一份值换个说明符就是完全不同的样子——真跑一遍比念表管用:

c
#include <stdio.h>

int main(void) {
    int i = 42;
    double pi = 3.14159265;
    char* s = "hi";

    /* 同一个值,不同转换说明符 */
    printf("整数: %d(十进制) %u(无符号) %x(十六进制) %o(八进制)\n",
           i, (unsigned)i, (unsigned)i, (unsigned)i);
    printf("浮点: %f %.2f %e %g\n", pi, pi, pi, pi);
    printf("字符/串: %c %s\n", 'A', s);

    /* 宽度、对齐、补零、精度 */
    printf("宽度对齐: [%5d] [%-5d] [%05d] [%.3f]\n", 42, 42, 42, pi);
    return 0;
}
text
$ gcc -std=c11 -Wall printf_formats.c -o pf && ./pf
整数: 42(十进制) 42(无符号) 2a(十六进制) 52(八进制)
浮点: 3.141593 3.14 3.141593e+00 3.14159
字符/串: A hi
宽度对齐: [   42] [42   ] [00042] [3.142]

第一行把同一个 42 用四种整数说明符打:%d 是有符号十进制(42)、%u 是无符号十进制(42)、%x 是十六进制(2a = 2×16+10)、%o 是八进制(52 = 5×8+2)。注意 %u%x%o 要求对应的参数是无符号的,所以这里我们 (unsigned)i 转了一下再传(不转的话对正数没影响,但负数就会被解释成一个巨大的无符号值,是个坑)。第二行是浮点的四种:%f 默认六位小数(3.141593)、%.2f 精度两位(3.14)、%e 科学计数法(3.141593e+00)、%g 自动选 %e%f 里更紧凑的那个(3.14159)。第三行 %c 打一个字符(A)、%s 打一个字符串(hi,第 11 章见过的「\0 结尾的 char 数组」)。

最后一行是格式控制里的「标志 + 宽度 + 精度」,这是排版对齐的关键。%5d 表示「最少占 5 个字符宽、默认右对齐」,所以 42 前面补三个空格得 [ 42]%-5d 加了 - 标志变成左对齐,空格挪到后面得 [42 ]%05d 加了 0 标志,用 0 填充而不是空格,得 [00042]%.3f 是「小数点后保留 3 位」的精度,得 [3.142]。这些写在 % 和字母之间,完整顺序是 %[标志][宽度][.精度]长度修饰字母,工程里做表格对齐、金额格式化全靠它。

%% 顺便提一下——它打印一个字面的 % 本身(因为 % 被拿去当说明符开头了,想真打一个就得 %%)。还有个常用的长度修饰:%ldlong%lldlong long%zusize_t(第 3 章见过)——类型不同就得换对应说明符,这点马上在「类型不匹配」那里专门真跑它的坑。

printf 的返回值:被忽略的「打印了几个字符」

printf 不只是「打印」,它还有返回值——返回「成功打印了多少个字符」(§7.21.6.1)。这个返回值经常被忽略(毕竟你写 printf(...) 从不接它),但它有用:

c
#include <stdio.h>

int main(void) {
    int n = printf("hello\n"); /* printf 返回打印的字符数(含 \n) */
    printf("上一行打印了 %d 个字符\n", n);
    return 0;
}
text
$ gcc -std=c11 -Wall printf_return.c -o pr && ./pr
hello
上一行打印了 6 个字符

printf("hello\n") 返回 6——hello 是 5 个字符、再加上 \n 一个、共 6 个(注意 \n 算一个字符、不是两个)。scanf 也有对应返回值、而且比 printf 的更重要,下面专门讲。顺带一提,printf 如果写出错(比如往一个已关闭的流写),会返回一个负数表示出错——这就是为什么检查「输出是否真的成功」要靠它的返回值。

scanf:一个 pattern-matching 函数

scanf(§7.21.6.2)的机制比 printf 更容易让人栽跟头。它本质上是个模式匹配函数:拿着格式串里的转换说明符,去输入流里一个个地「找匹配的项」。对于数值类说明符(%d%f 等),它会先跳过空白(空格、制表符、换行都算)、然后读符合该类型语法的字符、直到遇到不能属于该项的字符就停。先看最普通的一次读取:

c
#include <stdio.h>

int main(void) {
    int i, j;
    double x;
    int got = scanf("%d %d %lf", &i, &j, &x); /* 返回成功匹配的项数 */
    printf("读到 i=%d j=%d x=%f, 返回值=%d\n", i, j, x, got);
    return 0;
}
text
$ gcc -std=c11 -Wall scanf_basic.c -o sb && printf '1 -20 3.14\n' | ./sb
读到 i=1 j=-20 x=3.140000, 返回值=3

输入 1 -20 3.14scanf 把它解析成 i=1j=-20x=3.140000。三个说明符全部匹配成功,返回值 got3——这是 scanf 的返回值惯例:返回成功匹配并赋值的项数。注意三个细节:第一,读 int&i(取地址),因为 scanf 得知道「往哪个地址写」;第二,读 double 要用 %lf(不是 %f%fscanf 里读的是 float、而 float 在变参里会提升成 double……但 scanf 不一样,它按指针写、%f 写的是 float*%lf 写的是 double*,所以读 double 必须 %lf,这是 printf/scanf 不对称的一个经典点);第三,格式串里的空格只是「跳过任意空白」的意思,所以输入用空格还是换行分隔都行。

scanf 的返回值是「检查输入是否合法」的唯一可靠手段,工程里每次 scanf 都该检查它。给坏输入看看它怎么报:

c
#include <stdio.h>

int main(void) {
    int n = -999;
    int got = scanf("%d", &n); /* 输入不是数字 → 匹配失败;EOF → 返回 EOF */
    printf("返回值=%d, n=%d\n", got, n);
    return 0;
}
text
$ gcc -std=c11 -Wall scanf_fail.c -o sf && printf 'abc\n' | ./sf
返回值=0, n=-999
$ printf '' | ./sf
返回值=-1, n=-999

输入 abc(不是数字)时,scanf("%d") 一个数字都没匹配上、立刻返回 0,而且 n 保持原值 -999 没被改(匹配失败就不会赋值)。输入为空(直接 EOF)时,scanf 返回 -1——也就是宏 EOF 的值,表示「读到输入结尾了」。所以判断 scanf 结果的标准写法是 if (scanf(...) == 期望项数) { /* 成功 */ },千万别假设「输入一定符合预期」——用户敲歪一个字母,你的程序就拿到一个没赋值的变量,后面拿它算就是垃圾。

scanf 最容易踩的两个坑,一个在「%c」、一个在「&」。先说 %c——它和数值类说明符不一样,不跳过前导空白

c
#include <stdio.h>

int main(void) {
    char c;
    scanf("%c", &c);          /* %c 不跳前导空白:读到的是第一个字符(哪怕是空格) */
    printf("读到字符码 %d ('%c')\n", c, c);
    return 0;
}
text
$ gcc -std=c11 -Wall scanf_char.c -o sc && printf '  A' | ./sc
读到字符码 32 (' ')

输入 A(前面两个空格),你以为读到的是 A?不——%c 老老实实读了第一个字符,也就是空格(字符码 32)。这跟 %d 截然不同(%d 会跳过前导空白找数字)。所以当你在 scanf("%d", &n) 之后紧接着 scanf("%c", &c),那个 c 往往会吃到上一次输入留下的换行符 \n——这是新手「明明只问了一个字符、程序却像跳过了」的头号原因。想让 %c 也跳空白,写 scanf(" %c", &c)(格式串里 %c 前加个空格,表示「先跳任意空白再读一个字符」)。

再说「&」——读基本类型变量时漏了 &,是 scanf 里另一个高频灾难。好在现代 gcc 能在编译期就抓到:

c
#include <stdio.h>

int main(void) {
    int n;
    scanf("%d", n); /* 漏了 &:编译警告,运行是 UB */
    return 0;
}
text
$ gcc -std=c11 -Wall scanf_amp.c -o sa
scanf_amp.c:5:13: warning: format '%d' expects argument of type 'int *', but argument 2 has type 'int' [-Wformat=]
    5 |     scanf("%d", n); /* 漏了 &:编译警告,运行是 UB */
      |            ~^   ~
scanf_amp.c:5:5: warning: 'n' is used uninitialized [-Wuninitialized]
    5 |     scanf("%d", n); /* 漏了 &:编译警告,运行是 UB */
      |     ^~~~~~~~~~~~~~

gcc 的 -Wformat= 直接点穿:「%d 期望 int *(指针)、但你传了个 int」,外加一句 'n' is used uninitialized——因为漏了 &scanf 会把 n(一个未初始化的垃圾整数)当成地址去写,运行起来不是段错误就是乱写一通别人的内存,是教科书级的 UB。所以记住:scanf 读基本类型(intdoublechar 等),变量前一律加 &(读字符串 %s 传数组名本身就是地址、不用加,第 11 章见过数组名退化成指针)。要不是 gcc 的 -Wformat= 救场,这种 bug 能让人调一晚上——所以 -Wall 一定开上。

格式串与参数类型不匹配:静悄悄的 UB

printf/scanf 是变参函数,编译器默认不知道格式串里要的是什么类型——它没法像普通函数那样在调用点检查参数类型(变参的参数类型在函数签名里是 ...)。于是「格式串说要 int、你却传了个 double」就成了未定义行为,而且往往**不报错、给个垃圾结果继续跑」,最阴险的那种:

c
#include <stdio.h>

int main(void) {
    double d = 3.14;
    printf("%d\n", d); /* 类型不匹配:%d 期望 int,却传了 double → UB */
    return 0;
}
text
$ gcc -std=c11 -Wall mismatch.c -o mm
mismatch.c:5:14: warning: format '%d' expects argument of type 'int', but argument 2 has type 'double' [-Wformat=]
    5 |     printf("%d\n", d); /* 类型不匹配:%d 期望 int,却传了 double → UB */
      |             ~^     ~
$ ./mm
-879985752

%d 期望一个 intprintf 就按 int 的方式去读那个参数;可实际传进来的是个 double(8 字节、浮点编码),printf 把它的前 4 个字节当成 int 解释,得到一个莫名其妙的 -879985752。这个数字毫无意义、换个机器或编译选项就变,是 UB 的典型表现。又是 gcc 的 -Wformat= 在编译期就警告了「%d expects int but argument has type double」、还贴心地建议你改成 %f。所以这里也要靠 -Wall 把这类静悄悄的 UB 揪出来——-Wformat= 是它能奏效的关键,它专门理解 printf/scanf 的格式串语义。

最后说一个更严肃的安全坑——格式化字符串漏洞。如果你把用户输入直接当成 printf 的格式串:printf(user_input);,那用户在输入里塞 %x %x %x %s 就能让 printf 去读栈上、甚至往内存里写(%n),这能泄露敏感数据、甚至被用来劫持控制流,是历史上真实的安全漏洞大类。正确写法永远是 printf("%s", user_input);——把用户输入当成被 %s 打印的数据、而不是格式串本身。这个坑在 Effective C 第 7 章专门强调了,它和上面「类型不匹配」是同一条根:别让外部数据控制你的格式串

小结

C 的基础 IO 在 <stdio.h>printf/scanf 都是变参函数:第一个参数是格式串(普通字符 + % 转换说明符),后面是待转换参数,而编译器默认不检查说明符与参数是否匹配(变参的 ... 拿不到类型信息),不匹配就是 UB。printf(§7.21.6.1)的说明符里 %d/%u/%x/%o 是有符号/无符号/十六进制/八进制整数、%f/%e/%g 是浮点(默认六位小数 / 科学计数 / 自动紧凑)、%c 字符、%s 字符串,% 与字母之间可写 [标志][宽度][.精度] 控制 %5d 右对齐宽 5、%-5d 左对齐、%05d 补零、%.3f 小数三位;%% 打印字面 %;它的返回值是「成功打印的字符数」(含 \n,真跑得 6)。scanf(§7.21.6.2)是 pattern-matching:对数值说明符先跳空白再读、返回「成功匹配并赋值的项数」(正常得匹配数、匹配失败返回 0、EOF 返回 -1),所以每次都该检查它的返回值;读 int&i(取地址,漏了是 UB、gcc -Wformat= 抓「expects int* but got int」)、读 double%lf(不是 %f,这是 printf/scanf 的不对称点)、读字符串 %s 传数组名不用 &。两个高频坑:%c 不跳前导空白scanf("%d",&n) 后接 scanf("%c",&c) 会吃到残留的 \n,要跳空白写 " %c");格式串与参数类型不匹配是静悄悄的 UB(%ddouble 真打出垃圾 -879985752,靠 -Wformat= 编译期抓)。更严肃的是格式化字符串漏洞:别把用户输入直接当 printf 的格式串(printf(user_input) 危险),永远写 printf("%s", user_input)——「别让外部数据控制你的格式串」是这一章到第 11 章一脉相承的安全底线。-Wall(尤其其内的 -Wformat=)是这两个 IO 函数的救命稻草,务必常开。到这里,阶段 1 的 C 语言基底就拼完了——从程序结构、类型、运算符、控制流、函数、作用域、数组、字符串到 IO,你已经具备了写出一段「能跑且知道自己为什么这么写」的 C 所需的全部地基,下一步该是指针与内存的深水区了。

参考资源

  • ISO/IEC 9899:2011 §7.21(<stdio.h>、标准流 stdin/stdout/stderr)、§7.21.6.1(printf,含 p7 转换说明符与标志/宽度/精度表、返回值为打印字符数)、§7.21.6.2(scanf,返回值为成功赋值项数或 EOF)、§7.16(<stdarg.h> 变参机制,解释为何 printf/scanf 无法在调用点检查参数类型)
  • K. N. King《C Programming: A Modern Approach》第 3 章 Formatted Input/Output(printf 格式控制、scanf 的 pattern-matching 机制与「How scanf Works」、漏 & 的后果、混淆 printfscanf
  • Robert C. Seacord《Effective C》第 7 章(printf/puts、格式化字符串漏洞:别让外部数据当格式串)
  • 第 3 章:整型提升、溢出与回绕(size_t%zu)、第 10 章:数组(数组名退化、越界 UB)、第 11 章:C 字符串与不安全 libc(%s 与 char 数组、缓冲区溢出、gets 已删)
  • 阶段 0·第 9 章:警告旗标进阶(-Wall/-Wformat= 怎么救命)、阶段 5:系统编程(文件 IO:fopen/fread/fwrite/fprintfstdin/stdout 本质是文件流)