跳转到内容

Lesson 02: Hello World 与 printf CNB

练习任务

编写一个 C 程序,在屏幕上输出 hello, world.。这是编程世界中最经典的入门程序——每一个 C 程序员的旅程,都从这里开始。

提示:你需要使用标准库中的输出函数 printf,它声明在 stdio.h 头文件中。别忘了在字符串末尾加上换行符 \n

核心知识点

  • 预处理指令 #include — 将头文件内容复制到源文件中,是编译前文本替换
  • 头文件 stdio.h — 标准输入输出函数的声明集合,区别于库文件
  • printf 函数签名与返回值 — int printf(const char *format, ...),返回打印字符数
  • 格式字符串 — 普通字符原样输出,格式说明符 % 被替换为参数值
  • 基本格式说明符 — %d %f %c %s %% %x %o %u %e %X
  • 格式化修饰符 — width precision - + 0 # 控制输出宽度、精度和对齐
  • 转义字符 — \n \t \\ \" \' \r \b \a \0 在字符串中表示特殊字符
  • putchar / puts — 更简单的输出函数,与 printf 对比
  • stdout 标准输出流 — 文件描述符 1,行缓冲机制
  • return 0 与退出状态 — 程序向操作系统报告执行结果的契约
  • 编译与链接 — gcc -o,预处理、编译、汇编、链接四阶段
  • Hello World 文化 — 从 BCPL 到 K&R C 的历史演变
  • ASCII 字符编码 — 字符到数字的映射,'H' 如何变成 0x48
  • 字符串字面量 — "Hello" 在内存中是以 NUL 结尾的字符数组
  • printf 返回值 — 返回成功写入的字符数,可用于错误检测
  • 常见错误 — 缺少 \n、格式说明符错误、缺少分号、/n\n 混淆
  • 输出缓冲 — 行缓冲(终端)vs 全缓冲(文件),fflush 强制刷新
  • 变量引入 — int x = 42; printf("%d", x); 初识变量与格式化输出
  • sizeof 与数据类型预览 — 查看各类型在内存中的大小
  • 历史演进 — 从 BCPL 的 WRITEF 到 C 的 printf,K&R 第一版
  • printf 调用 — 连续调用输出自动拼接

代码框架

hello_world.c
c
#include <需要包含的头文件>

int main(void)
{
    // 在这里调用输出函数,输出 "hello, world.\n"
    return 0;
}

请尝试填充以上框架,写出你自己的版本。注意:头文件名称、函数名称、字符串内容的拼写都要准确。

TIP

先不要往下翻看参考解答,尝试自己动手写出完整的程序。如果卡住了,可以回顾 Lesson 01 中学到的 main 函数结构。


深度讲解

1. 预处理指令与 #include 机制

#include <stdio.h> 是每个 C 程序员写下的第一行有意义的代码。它看似简单——一行指令、一对尖括号、一个文件名——但背后连接着 C 语言编译模型的核心设计思想。

1.1 C 编译的四阶段流程

C 语言编译过程分为四个阶段,理解这个流水线是理解 #include 的前提:

源文件 (.c)
 1. 预处理(Preprocessing)— 处理 #include、#define、条件编译等
预处理后的翻译单元 (.i)
 2. 编译(Compilation)— 生成汇编代码
汇编文件 (.s)
 3. 汇编(Assembly)— 生成目标代码
目标文件 (.o)
 4. 链接(Linking)— 链接库函数,生成可执行文件
可执行文件

#include <stdio.h>预处理阶段被处理:预处理器(cpp)会找到 stdio.h 文件,将其内容原样复制#include 所在的位置。你可以用 gcc -E 只运行预处理阶段来亲眼验证:

preprocess_demo.sh
bash
$ gcc -E hello.c -o hello.i    # 只做预处理,输出 hello.i
$ wc -l hello.i                 # 可能超过 800 行!stdio.h 内容被完整复制进来
$ head -30 hello.i              # 查看预处理后的前 30 行

预处理后的 .i 文件包含了 stdio.h 的全部内容——函数声明、类型定义、宏定义——全部被「摊平」到你的源代码中。这就是为什么一个只有 6 行的 hello.c 预处理后会有 800 多行。

1.2 预处理器的本质:文本替换引擎

C 预处理器(C Preprocessor, cpp)本质上是一个文本替换引擎。它不理解 C 语法,只做模式匹配和文本替换。#include 就是其中最简单的指令:把指定文件的内容读进来,替换掉这行指令。

你可以把预处理器想象成一个自动复制粘贴工具

你的代码:                         预处理后:
#include <stdio.h>    →    extern int printf(const char *, ...);
                            extern int putchar(int);
int main(void)            typedef unsigned long size_t;
{                          ...(800+ 行)...
    printf("hi");         int main(void)
}                           {
                                printf("hi");
                            }

除了 #include,预处理器还处理:

  • #define — 宏定义(文本替换)
  • #if / #ifdef / #ifndef — 条件编译
  • #error / #warning — 编译时错误/警告
  • #pragma — 编译器特定指令
  • #line — 修改行号信息

1.3 #include 的两种搜索形式

#include 有两种语法,区别在于搜索路径

形式搜索路径适用场景
#include <stdio.h>仅在系统头文件目录中搜索(如 /usr/include系统提供的标准库头文件
#include "myheader.h"先在当前目录搜索,找不到再搜索系统目录项目自定义的头文件

系统头文件目录通常包括:

  • /usr/include — 标准 C 库头文件
  • /usr/local/include — 本地安装的第三方库头文件
  • GCC 内置目录(如 /usr/lib/gcc/x86_64-linux-gnu/*/include

你可以用 gcc -v 查看编译器实际搜索了哪些目录:

include_paths.sh
bash
$ echo '#include <stdio.h>' | gcc -v -E -x c - 2>&1 | grep "^ "
# 输出类似:
#  /usr/lib/gcc/x86_64-linux-gnu/11/include
#  /usr/local/include
#  /usr/lib/gcc/x86_64-linux-gnu/11/include-fixed
#  /usr/include/x86_64-linux-gnu
#  /usr/include

NOTE

<>"" 的区别仅在于搜索顺序,不是文件内容的区别。习惯上,标准库用 <>,项目自有头文件用 ""。在 "" 形式下,可以通过相对路径或绝对路径精确控制包含的文件。

1.4 为什么需要 #include?

C 语言的设计哲学之一是分离编译(separate compilation):每个 .c 文件独立编译为 .o 目标文件,最后链接在一起。这种设计意味着编译器在处理当前文件时,看不到其他文件的内容

如果 hello.c 中调用了 printf,编译器必须知道:

  1. printf 是一个函数(不是变量)
  2. printf 接受什么类型的参数
  3. printf 返回什么类型的值

#include <stdio.h> 就是告诉编译器这些信息的途径——它把 printf声明(declaration)引入当前翻译单元。

c
// stdio.h 中类似这样的声明:
int printf(const char *format, ...);
//  ^^^                         ^^^
//  返回类型                    可变参数标记

有了这个声明,编译器就能在编译时检查你的 printf 调用是否正确——参数数量、参数类型是否匹配。

1.5 如果不写 #include <stdio.h> 会怎样?

这是一个经典的「不同 C 标准不同行为」案例:

C89/C90:允许隐式函数声明(implicit function declaration)——如果编译器遇到一个未声明的函数调用,它会假定该函数返回 int,并接受任意参数。代码可能编译通过并正常运行(因为 printf 确实返回 int),但这是不安全的,编译器无法检查参数类型是否匹配。

C99 及之后:隐式函数声明已被正式移除,编译器会报错

no_include.c
c
// 没有 #include <stdio.h>
int main(void) {
    printf("hello\n");  // error: implicit declaration of function 'printf'
    return 0;
}

CAUTION

始终包含你使用的库函数对应的头文件,不要依赖隐式声明。即使编译器不报错(C89 模式),没有头文件声明就无法获得参数类型检查。printf("hello %s", 42) 这样的错误在隐式声明下编译器无法检测,将导致未定义行为。

如果确实不想包含整个头文件,可以手动写声明(极不推荐):

c
int printf(const char *format, ...);  // 手动声明
int main(void) {
    printf("hello\n");
    return 0;
}

但这样做的风险很高——如果你写错了签名,编译器不会帮你纠正。例如写成 int printf(char *format);,行为就是未定义的。


2. 头文件 vs 库文件

这是初学者最容易混淆的概念之一。#include <stdio.h> 包含的是函数声明(declaration),而非函数的实现代码(definition)。

2.1 声明与定义的本质区别

概念含义例子
声明告诉编译器「这个东西存在,类型是什么」int printf(const char *, ...);
定义告诉编译器「这是这个东西的实际内容」printf 的实现代码(机器码)
头文件包含声明、类型定义、宏定义stdio.h
库文件包含编译后的二进制机器码(函数的实际实现)libc.a / libc.so

一个形象的类比:

  • 头文件 = 餐厅的菜单(告诉你有什么菜,菜名和价格)
  • 库文件 = 厨房(实际的做菜过程)
  • 你看菜单点菜,不需要知道厨房怎么做菜

2.2 编译链接流程中的角色分工

源文件 (.c) → 预处理 → 编译 → 汇编 → 目标文件 (.o)

                            链接器 静态库 (.a) 或动态库 (.so)

                                      可执行文件

printf 的实现在 libc(C 标准库)中。在 Linux 上通常是:

  • 静态库:/usr/lib/x86_64-linux-gnu/libc.a
  • 动态库:/usr/lib/x86_64-linux-gnu/libc.so

链接器默认会自动链接 libc,所以你不需要手动指定 -lc。但其他库(如数学库 libm)需要显式指定:gcc program.c -lm

2.3 验证:头文件只是声明

你可以直接查看预处理后的文件来验证这一点:

verify_declaration.sh
bash
$ cat > test.c << 'EOF'
#include <stdio.h>
int main(void) { printf("hi\n"); return 0; }
EOF
$ gcc -E test.c | grep "printf" | head -5
# 输出类似:
# extern int printf (const char *__restrict __format, ...);
# 注意:这只是声明,不是实现!

预处理后的文件中只有 printf 的声明(一行),没有实现代码。真正的实现代码在链接阶段才从 libc 中合并进来。

IMPORTANT

头文件 = 声明(接口),库文件 = 实现(代码)#include 只是把声明复制进来,让编译器知道函数的参数类型和返回类型;真正的执行代码在链接阶段才被合并进来。理解这个区别是理解 C 语言编译模型的关键。


3. printf 函数深度解析

printf 是 C 语言中使用最频繁的函数之一。它的名字来源于 print formatted(格式化打印),是 C 标准库中最强大的输出函数。

3.1 函数签名

c
int printf(const char *format, ...);

逐部分解析:

  • int — 返回值类型:成功时返回写入的字符总数(不含 NUL),失败时返回负值
  • printf — 函数名
  • const char *format — 第一个参数:格式控制字符串的指针,const 表示函数不会修改这个字符串
  • ... — 可变参数标记(ellipsis):表示可以传入任意数量和类型的额外参数

3.2 格式字符串的工作原理

printf 的核心工作机制是遍历格式字符串,逐字符处理

格式字符串: "x = %d, y = %d\n"
             ^    ^       ^
             |    |       |
普通字符 --+      |       |
格式说明符 --------+       |
格式说明符 ----------------+

算法伪代码:

对于格式字符串中的每个字符 c:
    如果 c != '%':
        输出 c
    如果 c == '%':
        读取 % 后面的字符,确定格式说明符类型
        从可变参数列表中取出下一个参数
        根据说明符将参数转换为字符串
        输出转换后的字符串

这就是为什么 printf 中的 % 有特殊含义——它是格式说明符的触发字符

3.3 基本格式说明符速查表

格式说明符以 % 开头,告诉 printf 如何将参数转换为输出文本:

说明符含义参数类型示例
%d%i有符号十进制整数intprintf("%d", 42)42
%u无符号十进制整数unsigned intprintf("%u", 42U)42
%x小写十六进制unsigned intprintf("%x", 255)ff
%X大写十六进制unsigned intprintf("%X", 255)FF
%o八进制unsigned intprintf("%o", 8)10
%f浮点数(十进制)doubleprintf("%f", 3.14)3.140000
%e科学计数法(小写 e)doubleprintf("%e", 314.0)3.140000e+02
%E科学计数法(大写 E)doubleprintf("%E", 314.0)3.140000E+02
%g自动选 %f%edoubleprintf("%g", 3.14)3.14
%c单个字符int(实际是 char 提升)printf("%c", 'A')A
%s字符串const char *printf("%s", "hello")hello
%p指针地址void *printf("%p", ptr)0x7fff1234
%%百分号本身无参数printf("%%")%
%n写入已输出字符数int *printf("abc%n", &count) → count = 3

WARNING

%n 是特殊的——它不输出任何内容,而是将当前已输出的字符数写入指针指向的 int 变量。这个说明符可能被用于格式化字符串攻击(format string attack),在生产代码中应谨慎使用。

3.4 为什么 %f 接受 double 而不是 float?

这涉及默认参数提升(default argument promotion)机制。当参数传递给可变参数函数时:

  • charshort 自动提升为 int
  • float 自动提升为 double

所以 %f 实际接收的是 double 类型。这也意味着在 printf 中,floatdouble 用同一个说明符 %f

float_double_demo.c
c
float  f = 3.14f;
double d = 3.141592653589793;
printf("%f\n", f);   // float 自动提升为 double,正确
printf("%f\n", d);   // double 直接传递,正确
printf("%.10f\n", d); // 精度控制:显示 10 位小数

3.5 格式化修饰符

完整的转换说明格式为:%[flags][width][.precision][length]specifier

Flags(标志)

标志含义示例
-左对齐printf("%-10d", 42)42
+总是显示正负号printf("%+d", 42)+42
空格正数前加空格(与 + 互斥)printf("% d", 42) 42
0用 0 填充printf("%05d", 42)00042
#替代形式(八进制加 0,十六进制加 0x)printf("%#x", 255)0xff

Width(宽度)

用法含义示例
%Nd最小宽度 Nprintf("%10d", 42) 42
%*d宽度由参数指定printf("%*d", 10, 42) 42

Precision(精度)

用法含义示例
%.Nf浮点数保留 N 位小数printf("%.2f", 3.14159)3.14
%.Ns字符串最多输出 N 个字符printf("%.5s", "hello world")hello
%.Nd整数最少 N 位(不足补 0)printf("%.5d", 42)00042

Length(长度修饰符)

修饰符含义示例
llongprintf("%ld", 123456789L)
lllong longprintf("%lld", 123456789012345LL)
hshortprintf("%hd", (short)42)
zsize_tprintf("%zu", sizeof(int))
tptrdiff_tprintf("%td", ptr2 - ptr1)

3.6 格式说明符的组合实战

format_demo.c
c
#include <stdio.h>

int main(void)
{
    int a = 42;
    double pi = 3.1415926535;
    char *name = "Alice";

    // 宽度与对齐
    printf("|%10d|\n", a);     // |        42|  右对齐,宽度 10
    printf("|%-10d|\n", a);    // |42        |  左对齐,宽度 10

    // 精度
    printf("pi = %.2f\n", pi);  // pi = 3.14       保留 2 位小数
    printf("pi = %.10f\n", pi); // pi = 3.1415926535  保留 10 位小数

    // 组合
    printf("|%+010d|\n", a);   // |+000000042|  总是显示符号 + 0 填充 + 宽度 10

    // 字符串精度
    printf("%.3s\n", name);    // Ali            只输出前 3 个字符

    // 替代形式
    printf("%#x\n", 255);      // 0xff           自动加 0x 前缀
    printf("%#o\n", 8);        // 010            自动加 0 前缀

    return 0;
}

TIP

格式化修饰符在调试和对齐输出时非常有用。例如打印表格时用 %4d 对齐,打印内存地址时用 %p,输出财务数据时用 %.2f


4. 转义字符

转义字符(escape sequence)以反斜杠 \ 开头,用于在字符串和字符常量中表示无法直接键入或具有特殊含义的字符。

4.1 转义字符速查表

转义序列含义ASCII 值(十进制)ASCII 值(十六进制)
\n换行(Newline)100x0A
\t水平制表符(Tab)90x09
\r回车(Carriage Return)130x0D
\\反斜杠本身920x5C
\'单引号390x27
\"双引号340x22
\0空字符(NUL,字符串终止符)00x00
\a响铃(Alert/Bell)70x07
\b退格(Backspace)80x08
\f换页(Form Feed)120x0C
\v垂直制表符110x0B
\?问号(避免三字符组歧义)630x3F

4.2 为什么需要转义字符?

考虑以下场景——你想在屏幕上输出 He said "hello"

escape_why.c
c
printf("He said "hello"\n");   // 编译器认为字符串在 "He said " 就结束了!
printf("He said \"hello\"\n"); // 正确:用 \" 表示字符串中的双引号

双引号 " 既是字符串的边界标记,也可能出现在字符串内容中。如果没有转义机制,编译器无法区分 " 是「字符串结束」还是「字符串中的一个引号字符」。转义字符解决了这个歧义——\" 明确表示「这是一个双引号字符,不是字符串的边界」。

同理,反斜杠 \ 本身作为转义字符的触发符,也需要转义才能表示一个字面的反斜杠:

c
printf("C:\\Users\\name\\file.txt\n");  // 输出 C:\Users\name\file.txt
//        ^^      ^^     ^^
//        每个 \\ 输出一个字面反斜杠

4.3 \n 与 \r 的区别:历史视角

\n(换行)和 \r(回车)的区别源于电传打字机(Teletype)时代:

  • 回车(Carriage Return, \r):将打印头移回行首(水平复位)
  • 换行(Line Feed, \n):将纸张上移一行(垂直移动)

在不同操作系统中,行尾标记不同:

操作系统行尾标记说明
Unix / Linux\n只用换行
Windows\r\n回车 + 换行
经典 Mac OS\r只用回车(现代 macOS 使用 \n
newline_demo.c
c
#include <stdio.h>

int main(void)
{
    printf("Line 1\nLine 2\nLine 3\n");     // Unix 风格
    printf("Line 1\r\nLine 2\r\nLine 3\r\n"); // Windows 风格(在 Unix 终端上可能显示异常)
    return 0;
}

在 C 语言层面,当你用文本模式打开文件时,\n 会被自动转换为平台对应的行尾标记。但在 printf 输出到终端时,\n 就表示换行。

4.4 \0 的特殊性:字符串的终止符

\0(NUL 字符)是 C 语言字符串的终止标记。每个字符串常量末尾都隐含一个 \0

c
char s[] = "abc";  // 实际存储:'a' 'b' 'c' '\0',共 4 字节
printf("%d\n", sizeof(s));  // 输出 4,包含 '\0'

\0'0' 完全不同:

  • '\0' — ASCII 值为 0,空字符,字符串终止符
  • '0' — ASCII 值为 48,数字字符零
c
printf("%d\n", '\0');  // 输出 0
printf("%d\n", '0');   // 输出 48

4.5 常见陷阱

escape_pitfalls.c
c
// 1. 混淆 \n 和 /n
printf("hello/n");   // /n 是斜杠 + n,不是换行符!
printf("hello\n");   // \n 才是换行符

// 2. 混淆 \0 和 '0'
printf("%d\n", '\0'); // 输出 0(空字符的 ASCII 值)
printf("%d\n", '0');  // 输出 48(数字字符零的 ASCII 值)

// 3. 忘记转义反斜杠
printf("C:\new\test");  // \n 被解释为换行,\t 被解释为制表符
printf("C:\\new\\test"); // 正确

// 4. 字符串中的 \ 后跟非法字符
printf("hello \x");  // \x 需要后跟十六进制数字

5. putchar / puts 与 printf 对比

printf 不是 C 语言唯一的输出函数。标准库还提供了更简单、更轻量的输出函数。

5.1 三个输出函数的对比

函数功能自动换行格式化支持性能
int putchar(int c)输出单个字符最快
int puts(const char *s)输出字符串 + 自动追加换行
int printf(...)格式化输出较慢(解析格式串)

5.2 何时使用哪个函数?

output_comparison.c
c
#include <stdio.h>

int main(void)
{
    // putchar:输出单个字符,最简单最快
    putchar('H');
    putchar('i');
    putchar('\n');

    // puts:输出字符串,自动追加换行
    puts("Hello, World!");  // 等价于 printf("Hello, World!\n");

    // printf:需要格式化时使用
    int x = 42;
    printf("The answer is %d\n", x);  // 没有更简单的替代方案

    // puts 不需要 \n——它会自动加
    puts("No need for \\n");  // 输出后自动换行
    puts("See?");

    return 0;
}

输出:

Hi
Hello, World!
The answer is 42
No need for \n
See?

5.3 性能考量

printf 在每次调用时需要解析整个格式字符串,找到 % 并做相应处理。如果只需要输出一个固定字符串,puts 更高效:

output_perf.c
c
// 效率较低:printf 仍然会解析格式字符串寻找 %
printf("Hello, World!\n");

// 效率更高:puts 直接输出,不需要解析格式
puts("Hello, World!");  // 注意:puts 自动追加 \n

TIP

简单输出固定字符串时用 puts(自动加换行)或 fputs(str, stdout)(不自动加换行),需要格式化时用 printf。不要因为「只会用 printf」而忽视更合适的工具。


6. 标准输出流 stdout

每次调用 printf,输出内容并不是直接送到屏幕上——它经过了一个重要的抽象层:标准输出流(standard output stream, stdout)。

6.1 三个标准流

Unix/Linux 系统中,每个程序启动时,操作系统会自动打开三个标准流:

流名称缩写文件描述符用途C 中对应的 FILE *
标准输入stdin0读取用户输入stdin
标准输出stdout1输出正常结果stdout
标准错误stderr2输出错误信息stderr

printf(...) 实际上等价于 fprintf(stdout, ...)——默认向标准输出写入。

three_streams.c
c
#include <stdio.h>

int main(void)
{
    printf("This goes to stdout\n");         // 等价于 fprintf(stdout, "...")
    fprintf(stdout, "Also to stdout\n");     // 显式指定 stdout
    fprintf(stderr, "This goes to stderr\n"); // 输出到标准错误

    return 0;
}

6.2 stdout vs stderr:何时分流?

redirect_demo.sh
bash
$ ./program > output.txt        # 只重定向 stdout,stderr 仍输出到终端
$ ./program > output.txt 2>&1   # 将 stdout 和 stderr 都重定向到同一个文件
$ ./program 2> /dev/null        # 丢弃 stderr

典型的使用模式:

stdout_stderr_demo.c
c
#include <stdio.h>

int main(void)
{
    // 正常输出 → stdout
    printf("Processing...\n");

    // 错误信息 → stderr
    fprintf(stderr, "Error: file not found\n");

    // 进度信息也适合 stderr(这样即使重定向 stdout,用户也能看到进度)
    fprintf(stderr, "Progress: 50%% complete\n");

    return 0;
}

6.3 printf 的缓冲机制

printf 并不是每收到一个字符就立刻输出——它使用了缓冲区(buffer)来批量处理,以提高效率。缓冲策略取决于输出目标:

输出目标缓冲模式行为
终端(交互)行缓冲遇到 \n 时刷新缓冲区
文件(重定向)全缓冲缓冲区满(通常 4096 或 8192 字节)时才刷新
stderr无缓冲立即输出,确保错误信息不丢失

这意味着一个微妙的差异:

buffer_demo.c
c
#include <stdio.h>
#include <unistd.h>  // for sleep()

int main(void)
{
    // 没有 \n:在终端上可能看不到输出(缓冲区未刷新)
    printf("Hello");
    sleep(2);  // 等待 2 秒——"Hello" 可能还没显示!
    printf(" World\n");  // \n 触发刷新,之前的内容一起显示

    // 正确做法:及时刷新
    printf("Processing");
    fflush(stdout);  // 强制刷新,确保立即显示
    sleep(2);
    printf(" done.\n");

    return 0;
}

当你把输出重定向到文件时(./program > output.txt),即使有 \nprintf 也会使用全缓冲模式,直到缓冲区满才写入文件。如果需要即时写入,使用 fflush(stdout)

IMPORTANT

理解缓冲机制对于调试输出非常重要。如果你的 printf 调试语句在程序崩溃前没有输出,很可能是缓冲区还没刷新。两种解决方案:① 在调试输出后加 \n;② 使用 fprintf(stderr, ...)(stderr 无缓冲)。


7. return 0 与程序退出状态

Lesson 01 已经介绍了 main 函数必须返回 int 以及退出状态码的基本概念。本节在此基础上深入展开 printf 与退出状态的关联。

7.1 return 0 表示什么?

hello_with_return.c
c
#include <stdio.h>

int main(void)
{
    printf("hello, world.\n");
    return 0;  // 告诉操作系统:程序正常结束
}

return 0; 是程序与操作系统之间的成功信号。在 Unix/Linux 中:

  • 0 表示成功
  • 非零值(1~255)表示失败,具体值可用于传递错误信息

你可以在 shell 中用 $? 查看上一个程序的退出码:

exit_status.sh
bash
$ gcc hello.c -o hello
$ ./hello
hello, world.
$ echo $?
0    # 程序返回 0,表示成功

7.2 C99 的隐式 return 0

从 C99 标准开始,main 函数有一个特殊规则:如果执行到 main 的结尾 } 而没有显式的 return 语句,编译器会自动插入 return 0;

implicit_return.c
c
#include <stdio.h>

int main(void)
{
    printf("hello, world.\n");
    // C99+:自动 return 0;
}

WARNING

这个「自动 return 0」规则仅适用于 main 函数。其他函数如果声明了非 void 返回类型却没有 return 语句,行为是未定义的。为了代码清晰性和可移植性,建议始终显式写 return 0;

7.3 退出状态码的用途

退出状态码在脚本和自动化中非常重要——它是程序之间通信的基本方式:

exit_usage.sh
bash
# Shell 脚本中使用退出状态码
gcc program.c -o program
if [ $? -eq 0 ]; then
    echo "编译成功"
    ./program
    if [ $? -eq 0 ]; then
        echo "运行成功"
    else
        echo "运行失败,错误码: $?"
    fi
else
    echo "编译失败"
fi

# 使用 && 和 || 链式判断
gcc program.c -o program && ./program && echo "全部成功" || echo "某步失败"

8. 编译与链接:gcc -o 的幕后

当你执行 gcc hello.c -o hello 时,实际上发生了什么?这一节深入展开 Lesson 01 中简要介绍的四阶段编译流程。

8.1 四阶段的详细解析

阶段 1:预处理(Preprocessing)

preprocess.sh
bash
$ gcc -E hello.c -o hello.i
$ wc -l hello.i
856 hello.i    # 6 行源代码变成 856 行!
$ head -5 hello.i
# 1 "hello.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4

预处理阶段做的事情:

  1. 展开所有 #include(复制头文件内容)
  2. 展开所有 #define(宏替换)
  3. 处理条件编译(#if#ifdef 等)
  4. 删除注释
  5. 添加行号标记(#line 指令,用于后续阶段的错误定位)

阶段 2:编译(Compilation)

compile.sh
bash
$ gcc -S hello.i -o hello.s
$ cat hello.s

生成的汇编代码类似:

hello.s
asm
        .file   "hello.c"
        .text
        .section        .rodata
.LC0:
        .string "hello, world."
        .text
        .globl  main
        .type   main, @function
main:
        pushq   %rbp
        movq    %rsp, %rbp
        leaq    .LC0(%rip), %rdi    # 将字符串地址放入 rdi(第一个参数)
        call    puts@PLT            # 调用 puts(编译器将 printf 优化为 puts)
        movl    $0, %eax            # 返回值 0 放入 eax
        popq    %rbp
        ret

有趣的是:当 printf 的格式字符串没有 % 且以 \n 结尾时,GCC 会将它优化为 puts——因为 puts 更简单、更快,效果相同。

阶段 3:汇编(Assembly)

assemble.sh
bash
$ gcc -c hello.s -o hello.o
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
$ objdump -d hello.o  # 查看机器码

汇编器将汇编代码转换为机器码,生成目标文件(object file)。目标文件包含:

  • 机器指令
  • 数据(如字符串常量)
  • 符号表(记录函数名、变量名及其地址)
  • 重定位信息(记录哪些地址需要在链接时修正)

阶段 4:链接(Linking)

linking.sh
bash
$ gcc hello.o -o hello
$ file hello
hello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, ...

链接器(ld)的工作:

  1. 合并多个目标文件和库文件
  2. 解析符号引用(如 puts → libc 中的实现)
  3. 重定位(修正地址引用)
  4. 生成可执行文件

8.2 一键编译 vs 分步编译

compile_flow.sh
bash
# 一键编译(四步合并)
gcc hello.c -o hello

# 分步编译(查看中间产物)
gcc -E hello.c -o hello.i     # 预处理
gcc -S hello.i -o hello.s     # 编译
gcc -c hello.s -o hello.o     # 汇编
gcc hello.o -o hello          # 链接

# 常用编译选项
gcc -Wall -Wextra -o hello hello.c    # 开启所有常用警告
gcc -g -o hello hello.c               # 包含调试信息
gcc -O2 -o hello hello.c              # 优化级别 2
gcc -std=c99 -o hello hello.c         # 指定 C 标准

8.3 printf 是如何被找到的?

当编译器看到 printf("hello, world.\n") 时:

  1. 编译时#include <stdio.h> 提供了 printf 的声明,编译器知道它的参数类型和返回类型,生成一个对 printf 符号的调用指令
  2. 链接时:链接器在 libc 中找到 printf 的实现,将调用指令的目标地址修正为 printf 在内存中的实际地址
symbol_check.sh
bash
$ nm hello.o | grep printf
                 U printf    # U = undefined,需要在链接时解析

$ nm hello | grep printf
                 U printf@@GLIBC_2.2.5  # 动态链接到 glibc 中的 printf

U 表示「未定义」(Undefined)——在目标文件中 printf 只是一个符号名,链接器负责将它解析为实际地址。


9. Hello World 的文化意义与历史

「Hello, World」是编程世界中最著名的短语。它不仅仅是一段代码——它是一种仪式,一个传统,一种编程文化的象征。

9.1 起源:B 语言与 BCPL

Hello World 的传统可以追溯到 Brian Kernighan 在 1972 年编写的 B 语言教程。B 语言是 C 语言的前身,由 Ken Thompson 设计。在 B 语言中,输出函数叫 putchar

c
main( ) {
    extrn a, b, c;
    putchar(a); putchar(b); putchar(c);
    putchar('!*n');
}
a 'hell';
b 'o, w';
c 'orld';

这段代码展示了 B 语言的一个独特特性:字符串常量被拆分成 4 字符一组存储在变量中。'!*n' 中的 *n 就是后来的 \n(B 语言使用 * 作为转义前缀,C 语言改用 \)。

9.2 C 语言的 Hello World:K&R 第一版

1978 年,Brian Kernighan 和 Dennis Ritchie 出版了 《The C Programming Language》第一版(K&R C)。书中的第一个完整程序就是:

c
#include <stdio.h>

main()
{
    printf("hello, world\n");
}

注意与今天写法的差异:

  • main() 而非 int main(void) — K&R C 时代 main 默认返回 int,参数列表不写 void 表示「未指定」
  • 没有 return 0; — 在 K&R C 中,main 函数可以省略 return 语句

这个程序选择了 hello, world(全小写,逗号后有空格)作为输出内容,简单、友好、有代表性——它足以验证编译器、链接器和运行时环境是否正常工作。

9.3 Hello World 为什么重要?

Hello World 程序的价值远远超出「输出一行文本」:

  1. 验证开发环境:如果你能成功编译运行 Hello World,说明编译器、链接器、标准库都已正确安装和配置
  2. 建立信心:对初学者而言,看到第一个程序成功运行是重要的心理激励
  3. 学习最小程序结构:Hello World 包含了一个完整 C 程序的所有基本元素——#includemain 函数、函数调用、字符串常量、返回值
  4. 测试和调试的起点:当出现奇怪的问题时,先回到 Hello World 确认基本环境是否正常,然后逐步增加代码定位问题

"The only way to learn a new programming language is by writing programs in it." — Dennis Ritchie


10. 字符编码:ASCII 与内存中的字符

当你写下 printf("Hello"),字符串 "Hello" 在内存中到底是什么?这涉及到字符编码——将人类可读的字符映射为计算机可存储的数字。

10.1 ASCII 编码基础

ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)是最基础的字符编码方案,使用 7 位(0~127)表示 128 个字符:

十进制  十六进制  字符      含义
------  --------  ------    ----
0       0x00      NUL       空字符(字符串终止符)
7       0x07      BEL       响铃
8       0x08      BS        退格
9       0x09      TAB       水平制表符
10      0x0A      LF        换行 (\n)
13      0x0D      CR        回车 (\r)
32      0x20      SPACE     空格
48      0x30      0         数字零
49      0x31      1
...
57      0x39      9         数字九
65      0x41      A         大写 A
66      0x42      B
...
90      0x5A      Z         大写 Z
97      0x61      a         小写 a
98      0x62      b
...
122     0x7A      z         小写 z
127     0x7F      DEL       删除

关键规律:

  • '0' ~ '9' 连续排列(48 ~ 57)
  • 'A' ~ 'Z' 连续排列(65 ~ 90)
  • 'a' ~ 'z' 连续排列(97 ~ 122)
  • 大小写字母相差 32('a' - 'A' = 32

10.2 字符在内存中的实际存储

ascii_demo.c
c
#include <stdio.h>

int main(void)
{
    char c = 'H';
    printf("Character: %c\n", c);        // H
    printf("ASCII value (decimal): %d\n", c);  // 72
    printf("ASCII value (hex): 0x%x\n", c);    // 0x48

    // 验证:'H' 在内存中就是数字 72
    printf("Is 'H' == 72? %d\n", c == 72);  // 1(true)

    // 字符与整数的关系
    printf("'A' + 1 = %c\n", 'A' + 1);      // B
    printf("'Z' - 'A' = %d\n", 'Z' - 'A');  // 25

    // 小写转大写
    char lower = 'h';
    char upper = lower - 32;  // 或 lower - ('a' - 'A')
    printf("'%c' to upper: '%c'\n", lower, upper); // h → H

    return 0;
}

输出:

Character: H
ASCII value (decimal): 72
ASCII value (hex): 0x48
Is 'H' == 72? 1
'A' + 1 = B
'Z' - 'A' = 25
'h' to upper: 'H'

NOTE

在 C 语言中,char 本质上是一个 1 字节的整数类型。字符常量 'H' 的值就是该字符的 ASCII 码值(72)。这就是为什么 'A' + 1 等于 'B'——它只是在做整数加法。

10.3 扩展:ASCII 的局限性

ASCII 只有 128 个字符,只能覆盖英文字母、数字和基本标点符号。对于中文、日文、韩文等,需要更强大的编码方案:

  • Latin-1 / ISO 8859-1:在 ASCII 基础上扩展到 256 字符(8 位),覆盖西欧语言
  • GB2312 / GBK / GB18030:中文字符编码
  • UTF-8:Unicode 的变长编码,兼容 ASCII,能表示全球所有文字

在 C 语言中处理多字节字符需要 wchar_t<wchar.h>,这超出了本课范围,但理解 ASCII 是理解所有字符编码的起点。


11. 字符串字面量:内存中的存储方式

11.1 字符串是字符数组 + NUL 终止符

C 语言没有专门的「字符串类型」。字符串以字符数组的形式存储,以 \0(NUL 字符)标记结尾。这种设计被称为 NUL-terminated string(空字符结尾字符串)。

string_memory.c
c
#include <stdio.h>

int main(void)
{
    char *s = "Hello";

    printf("String: %s\n", s);
    printf("Length (strlen): %d\n", (int)strlen(s));  // 5(不含 \0)
    printf("Size (sizeof): %d\n", (int)sizeof("Hello")); // 6(含 \0)

    // 逐字符打印,包括 \0
    printf("Memory dump:\n");
    for (int i = 0; i < 6; i++) {
        printf("  s[%d] = '%c' (ASCII: %d, hex: 0x%02x)\n",
               i, s[i] ? s[i] : '?', s[i], (unsigned char)s[i]);
    }

    return 0;
}

内存中的实际布局:

字符:  H   e   l   l   o   \0
索引:  0   1   2   3   4   5
十六进制: 0x48 0x65 0x6C 0x6C 0x6F 0x00

11.2 "hello, world.\n" 的内存布局

本课的 Hello World 字符串在内存中的完整表示:

字符:  h   e   l   l   o   ,       w   o   r   l   d   .   \n  \0
索引:  0   1   2   3   4   5   6   7   8   9  10  11  12  13  14
十六进制:
      68  65  6C  6C  6F  2C  20  77  6F  72  6C  64  2E  0A  00
  • 可见字符 13 个(h e l l o , 空格 w o r l d . \n)
  • NUL 终止符 1 个
  • 总共 14 字节

strlen("hello, world.\n") 返回 13(不计 NUL),sizeof("hello, world.\n") 返回 14(包含 NUL)。

11.3 字符串常量的只读性

string_readonly.c
c
#include <stdio.h>

int main(void)
{
    char *s = "Hello";  // s 指向只读内存中的字符串常量
    // s[0] = 'h';      // 未定义行为!不能修改字符串常量

    // 正确做法:用字符数组(在栈上分配,可以修改)
    char t[] = "Hello";  // 栈上分配 6 字节,初始化为 "Hello"
    t[0] = 'h';          // 正确:数组内容可以修改
    printf("%s\n", t);   // hello

    return 0;
}

字符串常量存储在程序的只读数据段.rodata 段),修改它会导致段错误(Segmentation Fault)。如果需要可修改的字符串,使用字符数组。

CAUTION

字符串常量是只读的。指针 char *s = "Hello" 指向只读内存,修改 s[0] 会导致未定义行为。如果需要修改字符串内容,使用字符数组:char s[] = "Hello";


12. printf 返回值

很多初学者忽略了 printf 的返回值——反正输出已经到屏幕上了,返回值有什么用?但在实际工程中,这个返回值非常重要。

12.1 返回值含义

printf 成功时返回写入的字符总数(不包括 NUL),失败时返回负值

printf_return.c
c
#include <stdio.h>

int main(void)
{
    int n1 = printf("Hello");
    printf(" → wrote %d chars\n", n1);  // Hello → wrote 5 chars

    int n2 = printf("Hello, world.\n");
    printf(" → wrote %d chars\n", n2);  // 14 chars(13 个可见字符 + \n)

    int n3 = printf("x = %d, y = %d\n", 10, 20);
    printf(" → wrote %d chars\n", n3);

    // 空字符串
    int n4 = printf("");
    printf(" → wrote %d chars\n", n4);  // 0 chars

    return 0;
}

12.2 返回值的使用场景

场景 1:错误检测

error_detection.c
c
#include <stdio.h>

int main(void)
{
    int written = printf("Hello, %s!\n", "World");
    if (written < 0) {
        // 输出失败(如磁盘满、管道断裂、文件描述符无效)
        fprintf(stderr, "Output error!\n");
        return 1;
    }
    printf("Successfully wrote %d characters.\n", written);
    return 0;
}

场景 2:格式化对齐计算

alignment.c
c
#include <stdio.h>

int main(void)
{
    int value = 42;
    int len = printf("%d", value);  // len = 2
    // 右对齐到 40 列
    printf("%*s\n", 40 - len, "");
    // 等价于 printf("%38s\n", "");

    return 0;
}

场景 3:snprintf 缓冲区大小计算

snprintf_return.c
c
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int value = 12345;
    // 先查询需要的缓冲区大小
    int needed = snprintf(NULL, 0, "Value: %d", value);
    // needed = 12("Value: 12345" 共 12 个字符)

    // 分配正好大小的缓冲区
    char *buf = malloc(needed + 1);  // +1 给 NUL
    snprintf(buf, needed + 1, "Value: %d", value);
    printf("Buffer: \"%s\", size needed: %d\n", buf, needed);

    free(buf);
    return 0;
}

TIP

大多数简单程序会忽略 printf 的返回值,但在健壮的生产代码中,检查返回值是好习惯。特别是涉及文件 I/O、网络通信和管道操作时,输出失败的可能性不可忽视。


13. 常见错误与调试

13.1 错误类型速查

错误现象原因
缺少 \n输出和 shell 提示符挤在同一行没有换行,光标不移动
/n 而非 \n输出字面的 /n 而不是换行正斜杠和反斜杠混淆
printf("hello, world.\n") 缺少分号编译错误:expected ';' before ...语句必须以分号结尾
格式说明符与实际参数类型不匹配输出乱码或崩溃%d 要求 int,传入了 double
格式说明符数量多于参数输出随机值或崩溃(未定义行为)缺少参数,printf 读取栈上垃圾数据
格式说明符数量少于参数多余参数被忽略printf 只处理格式串中的 % 数量
忘记 #include <stdio.h>C99+:编译错误;C89:可能编译通过但危险缺少 printf 声明
字符串常量被修改段错误(Segmentation Fault)试图修改只读内存中的字符串常量
printf("hello") 后无 \n 且无 fflush输出不显示(缓冲未刷新)行缓冲在没有 \n 时不刷新

13.2 典型错误示例

common_errors.c
c
#include <stdio.h>

int main(void)
{
    // 错误 1:混淆 /n 和 \n
    printf("hello/n");   // 输出 hello/n,不会换行

    // 错误 2:格式说明符数量 > 参数数量(未定义行为!)
    printf("%d %d %d\n", 1, 2);  // 缺少第三个参数

    // 错误 3:格式说明符类型不匹配
    printf("%d\n", 3.14);  // %d 期望 int,传入了 double

    // 错误 4:%s 传入了非字符串
    int x = 42;
    printf("%s\n", x);  // %s 期望 char *,传入了 int

    // 错误 5:忘记分号
    // printf("hello\n")  // 缺少分号!

    return 0;
}

13.3 如何调试 printf 问题

debug_printf.sh
bash
# 1. 启用编译警告(捕获格式字符串错误)
gcc -Wall -Wextra -Wformat -Wformat-security program.c

# 2. 检查返回值
# 如果 printf 返回负值,说明输出失败

# 3. 使用 strace 追踪系统调用
strace -e write ./program
# 可以看到 write(1, "...", N) 的实际调用

# 4. 将输出重定向到文件检查
./program > output.txt 2>&1
cat -A output.txt  # -A 显示不可见字符(\n 显示为 $,\t 显示为 ^I)

14. 变量引入与 sizeof 预览

在 Hello World 中,我们只输出了固定的字符串。但 printf 的真正威力在于和变量配合使用。

14.1 变量的定义与输出

variable_intro.c
c
#include <stdio.h>

int main(void)
{
    int x = 42;               // 定义整型变量 x,初始化为 42
    double pi = 3.14159;      // 定义双精度浮点变量 pi
    char letter = 'A';        // 定义字符变量 letter
    char *name = "Alice";     // 定义字符串指针 name

    printf("x = %d\n", x);           // x = 42
    printf("pi = %f\n", pi);         // pi = 3.141590
    printf("letter = %c\n", letter);  // letter = A
    printf("name = %s\n", name);      // name = Alice

    // 一个 printf 输出多个变量
    printf("x = %d, pi = %.2f, letter = %c\n", x, pi, letter);

    return 0;
}

14.2 sizeof 运算符:查看类型大小

sizeof 是 C 语言的内置运算符(不是函数),返回类型或变量在内存中占用的字节数

sizeof_demo.c
c
#include <stdio.h>

int main(void)
{
    printf("Size of char:      %zu bytes\n", sizeof(char));       // 1
    printf("Size of short:     %zu bytes\n", sizeof(short));      // 2
    printf("Size of int:       %zu bytes\n", sizeof(int));        // 4(通常)
    printf("Size of long:      %zu bytes\n", sizeof(long));       // 8(64 位系统)
    printf("Size of long long: %zu bytes\n", sizeof(long long));  // 8
    printf("Size of float:     %zu bytes\n", sizeof(float));      // 4
    printf("Size of double:    %zu bytes\n", sizeof(double));     // 8
    printf("Size of pointer:   %zu bytes\n", sizeof(void *));     // 8(64 位系统)

    // 变量也可以用 sizeof
    int x = 42;
    printf("Size of x:         %zu bytes\n", sizeof(x));  // 4

    // 表达式也可以
    printf("Size of 1+2:       %zu bytes\n", sizeof(1 + 2));  // 4(int)

    return 0;
}

NOTE

%zu 是专门用于打印 size_t 类型(sizeof 的返回类型)的格式说明符。使用 %d 打印 sizeof 结果虽然通常能工作,但不够规范。%zu 是 C99 引入的。

14.3 数据类型速查表

类型最小大小(标准规定)典型大小(64 位 Linux)说明
char1 字节1 字节字符或小整数
short2 字节2 字节短整数
int2 字节4 字节默认整数类型
long4 字节8 字节长整数
long long8 字节8 字节C99 引入的超长整数
float4 字节4 字节单精度浮点
double8 字节8 字节双精度浮点
void *实现定义8 字节指针类型

15. 多 printf 调用与输出拼接

15.1 连续 printf 调用的行为

多个连续的 printf 调用会将输出自然地拼接在一起——它们都写入同一个 stdout 流:

multi_printf.c
c
#include <stdio.h>

int main(void)
{
    printf("Hello");
    printf(", ");
    printf("World");
    printf("!\n");

    // 输出:Hello, World!

    return 0;
}

输出效果等价于 printf("Hello, World!\n")。这种特性在以下场景非常有用:

场景 1:逐步构建输出

build_output.c
c
#include <stdio.h>

int main(void)
{
    printf("Step 1: Initializing");
    // ... 实际初始化代码 ...
    printf(" [OK]\n");

    printf("Step 2: Loading data");
    // ... 实际加载代码 ...
    printf(" [OK]\n");

    printf("Step 3: Processing");
    // ... 实际处理代码 ...
    printf(" [OK]\n");

    printf("Done.\n");
    return 0;
}

场景 2:条件输出

conditional_output.c
c
#include <stdio.h>

int main(void)
{
    int score = 85;

    printf("Result: ");
    if (score >= 90)      printf("Excellent");
    else if (score >= 75) printf("Good");
    else if (score >= 60) printf("Pass");
    else                  printf("Fail");
    printf(" (%d points)\n", score);

    // 输出:Result: Good (85 points)

    return 0;
}

15.2 与 putchar / puts 混合使用

printfputcharputs 都向 stdout 写入,可以混合使用:

mixed_output.c
c
#include <stdio.h>

int main(void)
{
    putchar('[');
    printf("INFO");
    putchar(']');
    putchar(' ');
    puts("Program started.");
    // 输出:[INFO] Program started.

    return 0;
}

16. printf 的可变参数机制

printf 能够接受可变数量和类型的参数,这依赖于 C 语言的可变参数(variadic functions)机制。

16.1 可变参数的实现原理

variadic_principle.c
c
#include <stdio.h>
#include <stdarg.h>  // 可变参数所需的头文件

// 一个简化的 my_printf,只支持 %d 和 %s
void my_printf(const char *fmt, ...)
{
    va_list args;
    va_start(args, fmt);  // args 指向 fmt 之后的第一个参数

    for (int i = 0; fmt[i] != '\0'; i++) {
        if (fmt[i] == '%') {
            i++;  // 看 % 后面的字符
            switch (fmt[i]) {
            case 'd': {
                int val = va_arg(args, int);
                printf("%d", val);  // 借用真正的 printf
                break;
            }
            case 's': {
                char *str = va_arg(args, char *);
                printf("%s", str);
                break;
            }
            case '%':
                putchar('%');
                break;
            default:
                putchar(fmt[i]);
                break;
            }
        } else {
            putchar(fmt[i]);
        }
    }

    va_end(args);
}

int main(void)
{
    my_printf("Hello, %s! The answer is %d.\n", "Alice", 42);
    my_printf("100%% sure.\n");
    return 0;
}

16.2 可变参数的核心宏

作用
va_list声明参数列表变量
va_start(ap, last)初始化 ap,使其指向 last 后的第一个可变参数
va_arg(ap, type)type 类型取下一个参数,并推进 ap
va_end(ap)清理资源
va_copy(dest, src)C99 新增,复制参数列表

16.3 默认参数提升

调用可变参数函数时,某些类型的参数会自动提升(promotion):

  • charshortint
  • floatdouble

这就是为什么 printf%c 实际接受 int(因为 char 被提升了),%f 实际接受 double(因为 float 被提升了)。

promotion_demo.c
c
#include <stdio.h>

int main(void)
{
    char c = 'A';
    float f = 3.14f;

    // %c 实际上取的是 int(char 被提升)
    printf("char: %c (size: %zu → promoted to int: %zu)\n",
           c, sizeof(c), sizeof('A'));  // 注意:'A' 在 C 中是 int 类型!

    // %f 实际上取的是 double(float 被提升)
    printf("float: %f (size: %zu → promoted to double: %zu)\n",
           f, sizeof(f), sizeof(3.14));  // 3.14 是 double

    return 0;
}

WARNING

最危险的陷阱:格式字符串中的转换说明与实际参数类型不匹配会导致未定义行为printf("%d", 3.14)printf("%s", 42) 看似编译通过,但运行结果不可预测——可能输出垃圾值、可能崩溃、可能看似正常。始终确保格式说明符与参数类型匹配。启用 gcc -Wall -Wextra -Wformat 可以帮助捕获这类错误。


17. printf 的历史:从 BCPL 到 C

17.1 语言的演进链

BCPL (1966) → B (1969) → C (1972) → ANSI C (1989) → C99 → C11 → C17
  • BCPL(Basic Combined Programming Language):Martin Richards 设计,第一个使用 // 注释的语言
  • B 语言:Ken Thompson 设计,将 BCPL 精简以适应 PDP-7 小型机。B 语言没有类型系统——所有数据都是机器字(word)
  • C 语言:Dennis Ritchie 在 B 语言基础上添加了类型系统,设计了 C 语言

17.2 printf 的前身

在 BCPL 中,输出格式化文本使用 WRITEF

c
WRITEF("The answer is %N*N", answer)

B 语言引入了 printf 的雏形。到了 K&R C 第一版(1978 年),printf 已经成为标准库的一部分,其基本设计延续至今。

17.3 K&R 第一版的 printf

1978 年的 K&R C 中,printf 的使用方式与现代几乎一致:

c
printf("hello, world\n");
printf("%d %f\n", i, x);

主要的演进在于:

  • C89/ANSI C:标准化了函数原型(function prototype),printf 的签名被正式写入标准
  • C99:增加了 %zu(size_t)、%lld(long long)等新的格式说明符
  • C11/C17:基本保持稳定,printf 的设计已经非常成熟

NOTE

printf 的设计经受住了半个世纪的考验,这证明了它最初设计的优越性。虽然现代语言(Python 的 f-string、Rust 的 println!)提供了更安全的替代方案,但在 C 语言中,printf 仍然是格式化输出的基石。


参考解答

Hello World
hello_world.c
c
#include <stdio.h>

int main(void)
{
    printf("hello, world.\n");
    return 0;
}

这段程序只有四行核心代码,却涉及了预处理器、标准库、字符串常量、函数调用和输出流等多个概念。#include <stdio.h> 让编译器知道 printf 的声明;printf("hello, world.\n") 将字符串输出到标准输出;\n 确保输出后换行。

对照检查:你的代码中 #include 用的是 <> 还是 ""?字符串末尾有没有 \nprintf 中的字符串是否用双引号包裹?最后是否有 return 0;

格式化输出:%d 和 %x
format_print.c
c
#include <stdio.h>

int global = 200;

int main(void)
{
    int local = 100;
    printf("local = %d\n", local);
    printf("global = 0x%x\n", global);
    return 0;
}

%d 将整数以十进制形式输出,%x 将整数以十六进制形式输出。200 的十六进制表示为 c8(12 × 16 + 8 = 200),所以 0x%x 输出 0xc8

对照检查:你的代码中 #include 用的是 <> 还是 ""?字符串末尾有没有 \nprintf 中的字符串是否用双引号包裹?


课堂讨论

  1. 包含头文件 stdio.h 就是包含我们所说的库函数吗?
  2. %x%p 在打印变量地址时,有何区别?
  3. 如果 printf 的参数多于或少于 % 的个数,会怎么样?
  4. 如果不包含 stdio.h 头文件,会出错吗?如何解决?
  5. 全局变量和局部变量没有初始化值,打印的结果会怎样?
  6. 为什么 printf 返回写入的字符数?什么场景下会用到这个返回值?
  7. printf("hello")puts("hello") 有什么区别?

讨论答案

Q1: 包含头文件 stdio.h 就是包含我们所说的库函数吗?

不是。 #include <stdio.h> 包含的是函数声明(declaration),而非函数的实现代码(definition)。

头文件 vs 库文件

文件类型包含内容例子
头文件 .h函数原型、类型定义、宏定义int printf(const char *, ...);
库文件 .a / .so编译后的二进制机器码printf 的实际实现

编译链接流程

源文件 (.c) → 预处理 → 编译 → 汇编 → 目标文件 (.o)

                            链接器 静态库 (.a) 或动态库 (.so)

                                      可执行文件

printf 的实现在 libc(C 标准库)中。链接器默认会自动链接 libc。

类比:头文件是菜单(告诉你有什么菜、菜名和价格),库文件是厨房(实际的做菜过程)。你看菜单点菜,不需要知道厨房怎么做菜。

验证方法

bash
$ gcc -E hello.c | grep printf
# 只能看到 extern int printf(...) 的声明,看不到实现代码
Q2: %x 和 %p 在打印变量地址时,有何区别?
特性%x%p
用途打印整数的十六进制表示专门打印指针地址
参数类型unsigned intvoid *(需要强制转换)
输出格式纯十六进制数字(如 ffa3c实现定义,通常带 0x 前缀
可移植性差——64 位系统上地址可能被截断好——自动适配指针大小
px_vs_xp.c
c
#include <stdio.h>

int main(void)
{
    int x = 42;

    // 正确:使用 %p
    printf("Address: %p\n", (void *)&x);  // 输出类似 0x7ffc1234abcd

    // 错误(在 64 位系统上):地址被截断为 32 位
    printf("Address: %x\n", (unsigned int)&x);  // 仅显示低 32 位

    return 0;
}

关键区别

  • %p 是为指针设计的,会自动处理 32/64 位差异
  • %x 期望 unsigned int(通常 32 位),在 64 位系统上直接转换会截断高 32 位
  • %p 的输出格式是实现定义的(implementation-defined),但通常以 0x 开头

最佳实践:打印地址始终用 %p 并强制转换为 (void *)

Q3: 如果 printf 的参数多于或少于 % 的个数,会怎么样?

参数多于转换说明:多余的参数会被忽略,程序正常运行。

c
printf("%d %d\n", 1, 2, 3, 4);  // 只使用前两个参数,输出 "1 2"

参数少于转换说明未定义行为(UB)!

c
printf("%d %d %d\n", 1, 2);  // 缺少第三个参数,UB!
// 可能输出随机值、崩溃、或看似正常——一切皆有可能

为什么多余参数安全,缺少参数危险?

  • 多余参数:printf 解析格式字符串,遇到 %d 就从可变参数列表中取一个 int。取完所有 % 后停止,多余的参数留在栈上,无人访问。
  • 缺少参数:printf 尝试取第三个参数时,实际读取的是栈上的垃圾数据,且可能越界访问内存。

编译器保护

bash
gcc -Wall -Wextra -Wformat program.c  # 启用格式字符串警告

现代编译器(GCC/Clang)会检查 printf 的格式字符串与参数是否匹配,并给出警告。

Q4: 如果不包含 stdio.h 头文件,会出错吗?如何解决?

取决于 C 标准版本和编译器设置。

C89/C90:允许隐式函数声明——编译器假定未声明的函数返回 int。代码可能编译通过并正常运行(因为 printf 确实返回 int),但这是不安全的,编译器无法检查参数类型。

C99 及之后:隐式函数声明已被移除,编译器会报错

c
// 没有 #include <stdio.h>
int main(void) {
    printf("hello\n");  // error: implicit declaration of function 'printf'
    return 0;
}

解决方案:始终包含对应的头文件。

c
#include <stdio.h>  // 必须!

特殊情况:如果你确实不想 include,可以手动声明(不推荐):

c
int printf(const char *format, ...);  // 手动声明,容易写错签名

但这样做容易出错——如果你写错了函数签名(如写成 int printf(char *format)),编译器不会帮你检查,行为将是未定义的。

Q5: 全局变量和局部变量没有初始化值,打印的结果会怎样?

全局变量(未初始化):打印结果为 0(或对应类型的零值)。

global_uninit.c
c
#include <stdio.h>

int global;  // 未初始化,自动为 0

int main(void)
{
    printf("global = %d\n", global);  // 输出 global = 0
    return 0;
}

原因:全局变量存储在 BSS 段,操作系统加载程序时会清零。

局部变量(未初始化):打印结果为不确定的垃圾值。每次运行可能不同!

local_uninit.c
c
#include <stdio.h>

int main(void)
{
    int local;  // 未初始化,值不确定!
    printf("local = %d\n", local);  // 可能输出任意值
    return 0;
}

原因:局部变量在栈上分配,栈内存保留了之前使用后的残留数据。

编译器警告

bash
gcc -Wall program.c
# warning: 'local' is used uninitialized [-Wuninitialized]

规则:始终显式初始化局部变量。全局变量未初始化时保证为 0,但局部变量未初始化时是垃圾值——这是未定义行为。

Q6: 为什么 printf 返回写入的字符数?什么场景下会用到这个返回值?

printf 的返回值是成功写入输出流的字符总数(不包括结尾的 NUL)。

常见使用场景

  1. 错误检测
error_check.c
c
int written = printf("Hello, %s!\n", "World");
if (written < 0) {
    // 输出失败(如磁盘满、管道断裂)
    fprintf(stderr, "Output error!\n");
}
  1. 格式化对齐——知道输出长度后可以做对齐计算:
c
int len = printf("%d", value);
printf("%*s\n", 40 - len, "");  // 右对齐填充
  1. 日志记录——记录实际写入的字节数:
c
int bytes = fprintf(logfile, "[%s] %s\n", timestamp, message);
total_bytes += bytes;  // 统计日志总量
  1. 网络编程——使用 snprintf 时,返回值告诉你需要多大的缓冲区:
c
int needed = snprintf(NULL, 0, "Value: %d", 12345);
char *buf = malloc(needed + 1);
snprintf(buf, needed + 1, "Value: %d", 12345);

大多数简单程序会忽略 printf 的返回值,但在健壮的生产代码中,检查返回值是好习惯。

Q7: printf("hello") 和 puts("hello") 有什么区别?
特性printf("hello")puts("hello")
输出内容hello(无换行)hello\n(自动追加换行)
格式化支持支持(%d%s 等)不支持
性能较慢(需解析格式字符串)较快(直接输出)
返回值输出的字符数(成功)非负值(成功),EOF(失败)
适用场景需要格式化输出时简单输出字符串并换行时
puts_vs_printf.c
c
#include <stdio.h>

int main(void)
{
    // puts 自动加 \n
    puts("Hello");  // 输出 "Hello\n"

    // printf 不自动加 \n
    printf("Hello");  // 输出 "Hello"(无换行)

    // puts 不能格式化
    int x = 42;
    // puts("The answer is %d", x);  // puts 不接受格式参数
    printf("The answer is %d\n", x);  // 正确

    return 0;
}

建议:如果只是输出一个固定字符串并换行,用 puts 更简洁高效。需要格式化或不需要自动换行时,用 printf


课后练习

练习 1:打印变量地址与内存布局

修改代码,打印连续 3 个全局变量和 3 个局部变量的地址,观察有何规律。

知识点提示:全局变量在数据段/BSS段(低地址),局部变量在栈上(高地址)。相邻 int 变量的地址差通常为 4(sizeof(int))。

参考解答
memory_layout.c
c
#include <stdio.h>

int g1, g2, g3;  // 3 个未初始化的全局变量

int main(void)
{
    int l1, l2, l3;  // 3 个局部变量

    printf("Global variables:\n");
    printf("  g1 at %p\n", (void *)&g1);
    printf("  g2 at %p\n", (void *)&g2);
    printf("  g3 at %p\n", (void *)&g3);

    printf("Local variables:\n");
    printf("  l1 at %p\n", (void *)&l1);
    printf("  l2 at %p\n", (void *)&l2);
    printf("  l3 at %p\n", (void *)&l3);

    return 0;
}

观察规律

  • 全局变量:地址连续且紧密相邻(每个 int 占 4 字节,地址差值为 4),位于 BSS 段
  • 局部变量:地址在栈上排列,地址值远大于全局变量(栈在高地址,数据段在较低地址)
  • 相邻局部变量的地址差通常也是 4,但可能递减(取决于栈增长方向)

示例输出

Global variables:
  g1 at 0x404030
  g2 at 0x404034
  g3 at 0x404038
Local variables:
  l1 at 0x7fffe1a4
  l2 at 0x7fffe1a0
  l3 at 0x7fffe19c

练习 2:指针算术与 &global + 1

修改代码,通过强制类型转换,打印 &global + 1 的值是多少?地址增加了多少字节?

知识点提示:指针算术中,ptr + 1 增加的字节数等于 sizeof(*ptr)&global 的类型是 int *,所以 &global + 1 增加 sizeof(int) = 4 字节。

参考解答
pointer_arithmetic.c
c
#include <stdio.h>

int global = 100;

int main(void)
{
    printf("&global     = %p\n", (void *)&global);
    printf("&global + 1 = %p\n", (void *)(&global + 1));

    // 地址差
    printf("Difference  = %ld bytes\n",
           (char *)(&global + 1) - (char *)&global);

    // 不同类型的指针算术
    printf("(char*)&global + 1 = %p\n", (void *)((char *)&global + 1));   // +1 字节
    printf("(long*)&global + 1 = %p\n", (void *)((long *)&global + 1));   // +8 字节

    return 0;
}

关键理解

  • &global 的类型是 int *&global + 1 的地址 = &global + sizeof(int) = +4 字节
  • (char *)&global + 1 只增加 1 字节(sizeof(char) = 1)
  • (long *)&global + 1 增加 8 字节(sizeof(long) = 8)

指针算术的核心规则:ptr + N 的地址偏移 = N × sizeof(*ptr)

练习 3:不同类型的全局变量

global 分别定义为 charshortfloat 类型,用 printf 打印它们的值、大小和地址,观察有何不同。

知识点提示sizeof(char) = 1,sizeof(short) = 2,sizeof(float) = 4。编译器可能为了内存对齐在变量之间插入填充字节。

参考解答
type_sizes.c
c
#include <stdio.h>

char  gc = 'A';
short gs = 100;
float gf = 3.14f;

int main(void)
{
    printf("char  gc: value = %c, size = %zu, addr = %p\n",
           gc, sizeof(gc), (void *)&gc);
    printf("short gs: value = %d, size = %zu, addr = %p\n",
           gs, sizeof(gs), (void *)&gs);
    printf("float gf: value = %f, size = %zu, addr = %p\n",
           gf, sizeof(gf), (void *)&gf);

    // 打印相邻地址的差值
    printf("\nAddress differences:\n");
    printf("&gs - &gc = %ld bytes\n", (char *)&gs - (char *)&gc);
    printf("&gf - &gs = %ld bytes\n", (char *)&gf - (char *)&gs);

    return 0;
}

观察要点

  • 不同类型的大小不同:char 1 字节,short 2 字节,float 4 字节
  • 编译器可能为了内存对齐在变量之间插入填充字节(padding)
  • 地址差可能不等于 sizeof 之和——这是对齐的结果
  • 不同编译器和优化级别下,变量排列顺序可能不同

注意:C 标准不保证全局变量的声明顺序与内存布局顺序一致。

练习 4:格式化输出乘法表

使用宽度和精度修饰符格式化输出一张九九乘法表,确保每列对齐。

知识点提示%4d 表示最小宽度 4,默认右对齐。%-4d 表示左对齐。

参考解答
multiplication_table.c
c
#include <stdio.h>

int main(void)
{
    // 打印表头
    printf("     ");
    for (int col = 1; col <= 9; col++)
        printf("%4d", col);
    printf("\n");

    // 打印分隔线
    printf("     ");
    for (int col = 1; col <= 9; col++)
        printf("----");
    printf("\n");

    // 打印乘法表
    for (int row = 1; row <= 9; row++)
    {
        printf("%2d | ", row);
        for (int col = 1; col <= 9; col++)
            printf("%4d", row * col);
        printf("\n");
    }

    return 0;
}

格式化要点

  • %4d:最小宽度 4,默认右对齐,确保每列对齐
  • %-4d:左对齐
  • %04d:用 0 填充到宽度 4(如 0006
  • %2d |:行号宽度 2 + 分隔符

输出效果

        1   2   3   4   5   6   7   8   9
     ------------------------------------
 1 |    1   2   3   4   5   6   7   8   9
 2 |    2   4   6   8  10  12  14  16  18
 3 |    3   6   9  12  15  18  21  24  27
 ...

练习 5:printf 返回值实验

编写程序,调用 printf 输出不同内容,并用 printf 的返回值统计总共输出了多少字符。比较 printf 返回值与 strlen 的结果。

知识点提示printf 返回的是实际输出的字符数(包括 \n),strlen 返回的是字符串长度(不包括 NUL 和 \n)。

参考解答
printf_return_test.c
c
#include <stdio.h>
#include <string.h>

int main(void)
{
    int total = 0;

    total += printf("Hello");
    total += printf(", ");
    total += printf("World");
    total += printf("!\n");

    printf("Total characters written: %d\n", total);

    // 对比 strlen 和 printf 返回值
    char *msg = "Hello, World!";
    int written = printf("%s\n", msg);
    printf("printf returned: %d, strlen: %zu\n",
           written, strlen(msg) + 1);  // +1 for \n

    return 0;
}

观察printf("Hello, World!\n") 返回 14(13 个字符 + \n),而 strlen("Hello, World!") 返回 13(不含 NUL)。printf 的返回值是「输出到设备的字符数」,strlen 返回的是「字符串中的字符数」。


参考资料


"The most effective debugging tool is still careful thought, coupled with judiciously placed print statements." — Brian Kernighan

Released under the MIT License.