1C程序设计语言(第2版)

C语言入门

Hello World的故事

程序结构编译过程基本语法

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第1章 C语言入门

Hello World的故事

一、导读

1.1 本章学习目标

第1章是整本《C程序设计语言》的开篇之作,由C语言的创造者 Dennis Ritchie 亲自执笔。本章以一个经典的 hello, world 程序为起点,带领读者走进C语言的世界。这不是一段普通的代码——它是理解C语言程序结构、编译模型和开发流程的钥匙。

本章的核心学习目标包括:

  • 理解C程序的基本结构:main 函数、预处理指令、语句和表达式如何组成一个完整的程序。
  • 掌握编译与运行的基本流程:从源文件(.c)到可执行文件,经历了哪些阶段?
  • 熟悉 `printf` 函数:格式化输出的基本用法,以及转义字符的含义。
  • 理解 `#include` 的作用:预处理指令如何将标准库引入程序。
  • 认识C语言的简洁性:为什么C语言只用少量关键字就能表达复杂的逻辑?
  • 建立开发环境的基本认知:编译器、源文件、可执行文件之间的关系。

1.2 为什么第一章如此重要

很多初学者急于跳入具体的语法细节,觉得第一章"太简单"。这种想法是极其危险的。正如学习任何一门语言都需要先掌握字母表和最基本的问候语,学习C语言也需要从最基础的结构开始。第一章虽然简短,但它展示了C程序的全部骨架——后续所有章节的内容,最终都要回到这个骨架上来。

K&R 在第一章就展示了C语言最核心的设计哲学:程序由函数组成,函数由语句组成,语句操作数据和值。理解了这个基本框架,后续学习类型、控制流、指针等概念时,你就知道它们在这个框架中处于什么位置。

更重要的是,hello, world 程序是每一个C程序员的起点。从Brian Kernighan在1972年第一次写下这个程序至今,它已经成为全世界程序员的共同语言。当你写下 printf("hello, world\n"); 的那一刻,你正在延续半个世纪的传统。

1.3 核心问题引导

在阅读本章时,请带着以下问题思考:

一个最小的C程序需要哪些组成部分?缺少其中任何一个会怎样?

#include <stdio.h> 到底做了什么?如果不写这行会怎样?

main 函数的返回值有什么意义?为什么需要 return 0?

\n 和直接写换行有什么区别?为什么需要转义字符?

C程序是如何从源代码变成可执行文件的?

为什么说C语言是"小而强大"的?它的关键字为什么这么少?


二、核心概念详解

2.1 第一个C程序:hello, world

让我们从C语言最经典的程序开始:

c
#include <stdio.h>

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

这个程序虽然只有四行有效代码,但包含了C程序的几乎所有基本要素。让我们逐行分析。

2.1.1 预处理指令 #include

c
#include <stdio.h>

#include 是一条预处理指令,它在编译之前由预处理器处理。<stdio.h> 是C标准库中的标准输入输出头文件(Standard Input/Output Header),它声明了 printf、scanf、getchar、putchar 等函数的原型。

预处理指令以 # 开头,它们不是C语句,不需要分号结尾。预处理器在处理 #include 时,会将指定头文件的内容原封不动地插入到当前源文件中。这意味着,经过预处理后,你的源文件会变大——包含了 stdio.h 中所有的函数声明和宏定义。

头文件有两种包含方式:

c
#include <stdio.h>    // 尖括号:从系统目录查找
#include "myheader.h" // 双引号:先从当前目录查找,再到系统目录

常见误区:很多初学者认为 #include 是"导入"了一个库。实际上,它只是将头文件的文本复制到了源文件中。真正的库代码是在链接阶段才被引入的。

2.1.2 main 函数

c
int main() {
    ...
    return 0;
}

main 函数是每个C程序的入口点。当操作系统运行一个C程序时,它实际上调用的是 main 函数。无论你的程序有多少个函数,执行总是从 main 开始。

main 前面的 int 表示这个函数返回一个整数值。括号中的参数列表为空(在ANSI C中也可以写成 int main(void)),表示 main 函数不接受参数。实际上,main 函数可以接受命令行参数,这将在后面讨论。

return 0 表示程序正常退出。返回值会被操作系统接收:在UNIX系统中,返回值0表示成功,非零值表示某种错误。你可以通过 $? 变量查看上一个程序的退出状态:

bash
./hello
echo $?    # 输出 0,表示成功

2.1.3 printf 函数

c
printf("hello, world\n");

printf 是C标准库中最常用的函数之一,用于格式化输出到标准输出(通常是终端屏幕)。它接受一个格式字符串作为第一个参数,后面可以跟若干个要输出的值。

在这个简单的例子中,格式字符串是 "hello, world\n"。其中 \n 是一个转义字符,代表换行符(newline)。C语言中的转义字符都以反斜杠 \ 开头,常见的包括:

转义字符含义ASCII码
\n换行0x0A
\t水平制表符0x09
\\反斜杠本身0x5C
\"双引号0x22
\0空字符(字符串终止符)0x00
\a响铃0x07

为什么需要 `\n`? 在终端中,输出是行缓冲的。如果没有 \n,输出可能会停留在缓冲区中,不会立即显示在屏幕上。加上 \n 后,缓冲区会被刷新,内容立即显示。

2.2 C程序的基本组成

通过 hello, world 程序,我们可以总结出C程序的基本组成:

C程序
├── 预处理指令(#include, #define 等)
├── 函数定义
│   ├── main 函数(必须,且只有一个)
│   └── 其他函数(可选)
├── 变量声明(全局变量)
└── 类型定义(typedef, struct 等)

一个C程序可以由一个或多个源文件组成。每个源文件(.c 文件)独立编译成目标文件(.o 文件),然后由链接器将所有目标文件和库文件合并成最终的可执行文件。

2.2.1 语句与表达式

C程序的基本执行单位是语句(statement)。每条语句以分号 ; 结尾。printf("hello, world\n"); 就是一条函数调用语句。

表达式(expression) 是能产生值的代码片段。2 + 3 是一个表达式,值为 5;printf(...) 也是一个表达式,它的值是成功输出的字符数。

c
int x = 2 + 3;          // 表达式 2+3 的值为5
int n = printf("hi");   // printf的返回值为输出的字符数,n=2

2.2.2 注释

C语言支持两种注释方式:

c
/* 这是C89风格的块注释
   可以跨越多行 */

// 这是C99风格的行注释(K&R第二版基于C89,但现代编译器都支持)

好的注释习惯:注释应该解释"为什么"而不是"做什么"。代码本身应该足够清晰地表达"做什么"。

c
/* 不好的注释 */
i++;  // i加1

/* 好的注释 */
i++;  // 跳过表头行,数据从第二行开始

2.3 编译过程详解

当你输入以下命令时:

bash
gcc hello.c -o hello

编译器实际上执行了四个阶段的工作:

2.3.1 预处理(Preprocessing)

bash
gcc -E hello.c -o hello.i

预处理器处理所有以 # 开头的指令:

  • #include:将头文件内容插入源文件
  • #define:展开宏定义
  • #ifdef / #ifndef / #endif:条件编译

预处理后的 hello.i 是一个纯文本文件,包含了所有展开后的代码。你可以用 head 命令查看它的前几行,会发现文件比原始源文件大得多。

2.3.2 编译(Compilation)

bash
gcc -S hello.c -o hello.s

编译器将C源代码翻译成汇编语言。生成的 hello.s 文件是人类可读的文本,但已经是面向特定处理器架构的低级指令了。

asm
.file   "hello.c"
    .section .rodata
.LC0:
    .string "hello, world"
    .text
    .globl  main
    .type   main, @function
main:
    pushq   %rbp
    movq    %rsp, %rbp
    leaq    .LC0(%rip), %rdi
    call    printf@PLT
    movl    $0, %eax
    popq    %rbp
    ret

2.3.3 汇编(Assembly)

bash
gcc -c hello.s -o hello.o

汇编器将汇编语言翻译成机器指令,生成目标文件 hello.o。这是一个二进制文件,包含了 main 函数的机器编码,但 printf 函数的地址还没有解析——它需要链接器来完成。

2.3.4 链接(Linking)

bash
gcc hello.o -o hello

链接器将目标文件与标准库(libc)合并,解析所有外部符号引用(如 printf 的地址),生成最终的可执行文件 hello。

链接分为两种:

  • 静态链接:将库代码直接复制到可执行文件中,文件体积大但自包含
  • 动态链接:运行时从系统加载共享库(.so 文件),文件体积小但依赖系统环境

2.4 变量与算术

让我们看一个稍微复杂一点的程序:

c
#include <stdio.h>

int main() {
    int fahr, celsius;
    int lower, upper, step;

    lower = 0;
    upper = 300;
    step = 20;

    fahr = lower;
    while (fahr <= upper) {
        celsius = 5 * (fahr - 32) / 9;
        printf("%d\t%d\n", fahr, celsius);
        fahr = fahr + step;
    }

    return 0;
}

这个程序打印华氏温度与摄氏温度的对照表。它引入了几个新概念:

2.4.1 变量声明

c
int fahr, celsius;
int lower, upper, step;

在C语言中,所有变量必须先声明后使用。int 表示这些变量是整型(integer)。声明告诉编译器:这些名字代表什么类型的数据,需要分配多少内存空间。

C语言的基本数据类型包括:

类型关键字典型大小取值范围
字符char1字节-128 ~ 127
整型int4字节-2^31 ~ 2^31-1
短整型short2字节-2^15 ~ 2^15-1
长整型long4/8字节取决于平台
单精度浮点float4字节~7位有效数字
双精度浮点double8字节~15位有效数字

2.4.2 赋值语句

c
lower = 0;
upper = 300;

赋值运算符 = 将右边的值赋给左边的变量。注意,C语言中的 = 是赋值,不是相等判断(相等判断用 ==)。这是C语言中最常见的错误来源之一。

2.4.3 while 循环

c
while (fahr <= upper) {
    ...
    fahr = fahr + step;
}

while 循环先判断条件,再执行循环体。只要条件为真(非零),循环就会继续执行。

2.4.4 printf 的格式化输出

c
printf("%d\t%d\n", fahr, celsius);

printf 的格式字符串中,%d 是格式说明符,表示输出一个十进制整数。\t 是制表符。printf 会按照格式字符串中的说明符,依次用后面的参数替换。

常用的格式说明符:

说明符含义
%d十进制整数
%f浮点数(小数形式)
%e浮点数(科学计数法)
%c单个字符
%s字符串
%x十六进制整数
%o八进制整数
%%百分号本身

2.5 浮点数版本的温度转换

上面的温度转换程序有一个问题:使用整数运算会导致精度损失。例如,华氏0度对应的摄氏温度应该是 -17.78°C,但整数运算给出的结果是 -17。

让我们用浮点数来改进:

c
#include <stdio.h>

int main() {
    float fahr, celsius;
    float lower, upper, step;

    lower = 0;
    upper = 300;
    step = 20;

    fahr = lower;
    while (fahr <= upper) {
        celsius = (5.0 / 9.0) * (fahr - 32.0);
        printf("%3.0f %6.1f\n", fahr, celsius);
        fahr = fahr + step;
    }

    return 0;
}

注意几个变化:

  • 变量类型从 int 改为 float
  • 常量 5.0 / 9.0 使用浮点除法,而不是整数除法 5 / 9(后者结果为0)
  • 格式说明符 %3.0f 表示至少3个字符宽度、0位小数的浮点数

整数除法的陷阱:在C语言中,两个整数相除的结果仍然是整数(向零取整)。5 / 9 的结果是 0,而不是 0.555...。这是初学者最常犯的错误之一。要得到浮点数结果,至少有一个操作数必须是浮点数:5.0 / 9.0 或 (float)5 / 9。

2.6 for 语句

上面的 while 循环可以用更简洁的 for 语句来重写:

c
#include <stdio.h>

int main() {
    int fahr;

    for (fahr = 0; fahr <= 300; fahr += 20) {
        printf("%3d %6.1f\n", fahr, 5.0 * (fahr - 32) / 9.0);
    }

    return 0;
}

for 语句将循环的三个要素集中在了一起:

  • 初始化:fahr = 0
  • 条件判断:fahr <= 300
  • 步进:fahr += 20

这种写法更加紧凑,也更不容易出错(因为不容易忘记更新循环变量)。for 语句是C语言中最常用的循环结构,在后续章节中我们会看到更多复杂的用法。

2.7 符号常量

在上面的程序中,300、20 等数字直接出现在代码中,这被称为魔术数字(magic number)。更好的做法是使用符号常量:

c
#include <stdio.h>

#define LOWER 0
#define UPPER 300
#define STEP 20

int main() {
    int fahr;

    for (fahr = LOWER; fahr <= UPPER; fahr += STEP) {
        printf("%3d %6.1f\n", fahr, 5.0 * (fahr - 32) / 9.0);
    }

    return 0;
}

#define 是预处理指令,它在编译之前将所有的 LOWER 替换为 0,UPPER 替换为 300,STEP 替换为 20。这种简单的文本替换称为宏替换。

使用符号常量的好处:

  • 可读性:UPPER 比 300 更有意义
  • 可维护性:修改上限只需要改一处
  • 一致性:避免同一个数字在代码中出现多次但含义不同

按照惯例,符号常量的名字使用大写字母。

2.8 字符输入/输出

C语言的标准库提供了一组简单的字符输入/输出函数:getchar() 和 putchar()。

c
#include <stdio.h>

int main() {
    int c;

    while ((c = getchar()) != EOF) {
        putchar(c);
    }

    return 0;
}

这个程序将输入原封不动地复制到输出——它是一个最简单的文件复制程序。

几个关键点:

c 的类型是 int 而不是 char。这是因为 getchar() 的返回值不仅仅是字符,还包括 EOF(End Of File,文件结束标志)。EOF 通常定义为 -1,它不是一个有效的字符值,所以 c 必须用 int 类型才能区分字符和 EOF。

while 条件中的括号是必须的。因为 != 的优先级高于 =,如果不加括号:

```c

while (c = getchar() != EOF) // 错误!等价于 while (c = (getchar() != EOF))

```

这会将比较结果(0或1)赋给 c,而不是将 getchar() 的返回值赋给 c。

EOF 是一个符号常量,定义在 <stdio.h> 中。在UNIX系统中,你可以通过 Ctrl+D 发送 EOF 信号。

2.9 字符数组与字符串

C语言没有内置的字符串类型。字符串是用字符数组来表示的:

c
#include <stdio.h>

int main() {
    char message[] = "Hello, C!";

    printf("%s\n", message);

    int i = 0;
    while (message[i] != '\0') {
        printf("%c", message[i]);
        i++;
    }
    printf("\n");

    return 0;
}

C语言中的字符串以空字符 `\0` 结尾。"Hello, C!" 在内存中实际存储为 'H', 'e', 'l', 'l', 'o', ',', ' ', 'C', '!', '\0'——共10个字节,最后一个 \0 是字符串终止标志。

这个看似简单的设计决定了C语言字符串处理的所有特性:

  • 计算字符串长度需要遍历到 \0(strlen 函数的时间复杂度为 O(n))
  • 字符串拼接需要确保目标数组有足够的空间容纳 \0
  • 忘记分配 \0 的空间是缓冲区溢出的常见原因

三、实战案例

3.1 案例一:多功能计算器

c
#include <stdio.h>

int main() {
    double num1, num2;
    char op;

    printf("Simple Calculator\n");
    printf("Enter expression (e.g., 3.5 + 2.1): ");
    scanf("%lf %c %lf", &num1, &op, &num2);

    printf("Result: ");
    switch (op) {
        case '+':
            printf("%.2f\n", num1 + num2);
            break;
        case '-':
            printf("%.2f\n", num1 - num2);
            break;
        case '*':
            printf("%.2f\n", num1 * num2);
            break;
        case '/':
            if (num2 == 0) {
                printf("Error: Division by zero\n");
            } else {
                printf("%.2f\n", num1 / num2);
            }
            break;
        default:
            printf("Error: Unknown operator '%c'\n", op);
            break;
    }

    return 0;
}

运行示例:

Simple Calculator
Enter expression (e.g., 3.5 + 2.1): 3.5 + 2.1
Result: 5.60

代码解析:

  • scanf 用于格式化输入,%lf 表示读取 double 类型
  • switch 语句根据运算符选择不同的计算分支
  • 除法运算需要检查除数是否为零
  • break 语句用于跳出 switch,防止"穿透"到下一个分支

3.2 案例二:文本统计工具

c
#include <stdio.h>

int main() {
    int c;
    long num_chars = 0;
    long num_words = 0;
    long num_lines = 0;
    int in_word = 0;

    while ((c = getchar()) != EOF) {
        num_chars++;

        if (c == '\n') {
            num_lines++;
        }

        if (c == ' ' || c == '\n' || c == '\t') {
            in_word = 0;
        } else if (!in_word) {
            in_word = 1;
            num_words++;
        }
    }

    printf("Lines:   %ld\n", num_lines);
    printf("Words:   %ld\n", num_words);
    printf("Chars:   %ld\n", num_chars);

    return 0;
}

运行示例(通过管道输入):

bash
echo "Hello World\nThis is C" | ./wordcount
Lines:   2
Words:   4
Chars:   22

代码解析:

  • 使用状态机思想:in_word 变量记录当前是否在单词内部
  • 遇到空白字符时切换到"单词外"状态
  • 遇到非空白字符且当前在"单词外"状态时,发现了一个新单词
  • 这与UNIX的 wc 命令功能类似

3.3 案例三:带表头的温度转换表

c
#include <stdio.h>

#define LOWER   0
#define UPPER   300
#define STEP    20

int main() {
    printf("============= Temperature Conversion =============\n");
    printf("%5s | %10s | %10s\n", "Fahrenheit", "Celsius", "Kelvin");
    printf("------+------------+----------\n");

    for (int fahr = LOWER; fahr <= UPPER; fahr += STEP) {
        float celsius = 5.0 * (fahr - 32) / 9.0;
        float kelvin = celsius + 273.15;
        printf("%5d | %10.2f | %10.2f\n", fahr, celsius, kelvin);
    }

    printf("==================================================\n");

    return 0;
}

运行结果:

============= Temperature Conversion =============
Fahrenheit |    Celsius |     Kelvin
------+------------+----------
    0 |     -17.78 |     255.37
   20 |      -6.67 |     266.48
   40 |       4.44 |     277.59
   60 |      15.56 |     288.71
   80 |      26.67 |     299.82
  100 |      37.78 |     310.93
  ...

代码解析:

  • %5s 表示至少5个字符宽度的字符串,右对齐
  • %10.2f 表示至少10个字符宽度、2位小数的浮点数
  • 使用 | 和 - 字符绘制简单的表格边框
  • 同时输出摄氏度和华氏度,增加实用性

四、本章小结

第1章通过经典的 hello, world 程序,展示了C语言的基本面貌:

程序结构:C程序由函数组成,每个程序必须有且只有一个 main 函数。函数由语句组成,语句以分号结尾。

预处理指令:#include 引入头文件声明,#define 定义符号常量。它们在编译之前由预处理器处理。

编译流程:源代码经过预处理、编译、汇编、链接四个阶段,最终生成可执行文件。

数据类型:C语言提供了 char、int、float、double 等基本数据类型,所有变量必须先声明后使用。

控制流:while、for 循环和 if-else 条件语句是程序逻辑控制的基础。

标准I/O:printf 和 scanf 提供格式化输入输出,getchar 和 putchar 提供字符级I/O。

C语言的设计哲学是简洁、高效、接近硬件。它的关键字很少(C89标准只有32个关键字),但通过组合这些基本元素,可以表达出任意复杂的逻辑。这种"小而强大"的设计,正是C语言历经半个世纪仍然充满生命力的原因。

下一章将深入讲解C语言的类型系统——这是编写正确、高效C程序的基础。


五、思考与练习

思考题

如果去掉 #include <stdio.h>,程序还能编译通过吗?为什么?

main 函数的返回值如果不写 return 0,而是直接省略,会发生什么?(提示:C89和C99的行为不同)

为什么 getchar() 的返回值要赋给 int 类型的变量,而不是 char 类型?

5 / 9 和 5.0 / 9.0 的结果有什么不同?为什么?

编程练习

修改温度转换程序:将温度转换表倒序输出(从300度到0度)。

实现一个简单的回显程序:读取用户输入的每一行,在前面加上行号后输出。

编写一个程序:统计输入中空格、制表符和换行符的个数。

编写一个程序:将输入中的大写字母转换为小写,小写字母转换为大写(提示:ASCII码的规律)。

1/8C语言入
0%