1. 程序的基本概念

1.1. 程序和编程语言

程序(Program)告诉计算机应如何完成一个计算任务,这里的计算可以是数学运算,比如解方程,也可以是符号运算,比如查找和替换文档中的某个单词。从根本上说,计算机是由数字电路组成的运算机器,只能对数字做运算,程序之所以能做符号运算,是因为符号在计算机内部也是用数字表示的。此外,程序还可以处理声音和图像,声音和图像在计算机内部必然也是用数字表示的,这些数字经过专门的硬件设备转换成人可以听到、看到的声音和图像。

程序由一系列基本操作组成,基本操作有以下几类:

输入(Input)
从键盘、文件或者其他设备获取数据。
输出(Output)
把数据显示到屏幕,或者存入一个文件,或者发送到其他设备。
基本运算
最基本的数据访问和数学运算(加减乘除)。
测试和分支
测试某个条件,然后根据不同的测试结果执行不同的后续操作。
循环
重复执行一系列操作。

你曾用过的任何一个程序,不管它有多么复杂,都是按这几类基本操作一步一步执行的。程序是那么的复杂,而编写程序可以用的基本操作却只有这么简单的几种,这中间巨大的落差就要由程序员去填补了,所以编写程序理应是一件相当复杂的工作。 编写程序可以说是这样一个过程:把复杂的任务分解成子任务,把子任务再分解成更简单的任务,层层分解,直到最后简单得可以用以上几种基本操作来完成。

编程语言(Programming Language)分为低级语言(Low-level Language)和高级语言(High-level Language)。机器语言(Machine Language)和汇编语言(Assembly Language)属于低级语言,直接用计算机指令(Instruction)编写程序。而C、C++、Java、Python等属于高级语言,用语句(Statement)编写程序,语句是计算机指令的抽象表示。

举个例子,同样一个语句用机器语言、汇编语言和C语言分别表示如下:

一个语句的三种表示(32位x86平台)
编程语言 表示形式
机器语言
a1 18 a0 04 08
83 c0 01
a3 1c a0 04 08
汇编语言
mov    0x804a018,%eax
add    $0x1,%eax
mov    %eax,0x804a01c
C语言 a = b + 1;

计算机只能对数字做运算,符号、声音、图像在计算机内部都要用数字表示,指令也不例外,上表中的机器语言完全由十六进制数字组成。最早的程序员都是直接用机器语言编程,但是很麻烦,需要查大量的表格来确定每个数字表示什么意思,编写出来的程序很不直观,而且容易出错,于是有了汇编语言,把机器语言中一组一组的数字用助记符(Mnemonic)表示,直接用这些助记符写出汇编程序,然后让汇编器(Assembler)去查表把助记符替换成数字,也就把汇编语言翻译成了机器语言。

从上面的例子可以看出,汇编语言和机器语言的指令是一一对应的,汇编语言有三条指令,机器语言也有三条指令,汇编器就是做一个简单的替换工作。例如在第一条指令中,把 mov ?,%eax 这种格式的指令替换成机器码 a1 ? ,?表示一个地址,在汇编指令中是 0x804a018 ,转换成机器码之后是 18 a0 04 08 (这是指令中十六进制数的小端表示,小端表示将在 CPU 介绍)。

从上面的例子还可以看出,C语言的语句和低级语言的指令之间不是简单的一一对应关系,一条 a = b + 1; 语句要翻译成三条汇编或机器指令,这个过程称为编译(Compile),由编译器(Compiler)来完成,显然编译器的功能比汇编器要复杂得多。

编写、编译和执行一个C程序的步骤如下:

  1. 用文本编辑器写一个C程序,然后保存成一个文件,例如 program.c (通常C程序的文件名后缀是 .c ),这称为源代码(Source Code)或源文件。
  2. 运行编译器对它进行编译,编译的过程并不执行程序,而是把源代码全部翻译成机器指令,再加上一些描述信息,生成一个新的文件,例如 a.out ,这个文件称为可执行文件(Executable)。
  3. 可执行文件可以被操作系统(Operating System)加载执行,计算机执行该文件中由编译器生成的指令。
../_images/intro.compile.png

编译执行的过程

现在你就可以跳到本章后面的 第一个程序 ,按照书上的步骤自己动手试试。

有些高级语言写的程序不需要经过编译这个步骤,而是以解释的方式执行,解释执行(Interpret)的程序通常又叫做脚本(Script),解释执行的过程和C语言的编译执行过程很不一样。例如编写一个Shell脚本 script.sh ,内容如下:

1
2
3
4
#! /bin/sh
VAR=1
VAR=$(($VAR+1))
echo $VAR

这个脚本的第一行表明它是个Shell脚本,后面三行的意思分别是:定义变量 VAR 的初始值是1,然后自增1,最后打印 VAR 的值。用Shell程序 /bin/sh 解释执行这个脚本,结果如下:

$ /bin/sh script.sh
2

这里的 /bin/sh 称为解释器(Interpreter),解释器本身是一个可执行文件,而我们写的脚本 script.sh 却不是一个真正的可执行文件。解释器 /bin/sh 也是由C程序经过编译得到的包含机器指令的可执行文件,它被操作系统加载执行时,它所包含的机器指令指示它做这样的事情:把我们写的脚本 script.sh 当成数据文件读取,理解我们所写的每一行程序的意思,并一行一行地执行相应的操作。

../_images/intro.interpret.png

解释执行的过程

理解了这些概念之后,我们在编译型高级语言、解释型高级语言和低级语言之间做个比较。用高级语言写的程序不能直接被计算机执行,需要经过编译之后变成可执行文件才能执行,或者需要通过一个解释器来解释执行。但用高级语言写程序有很多优点:首先,用高级语言写程序更简便,写出来的代码更紧凑,可读性更强,出了错也更容易改正;其次,高级语言是可移植的(Portable),或者称为平台无关的(Platform Independent)、跨平台的(Cross-platform)。

平台(Platform)这个词可以指计算机体系结构(Architecture),也可以指操作系统(Operating System)。有些程序只能在某个特定平台上执行,而有些程序拿到各种不同的平台上都可以执行,后者就称为平台无关的程序。下面我们来具体分析:

  1. 操作系统相同,计算机体系结构不同会怎么样?

    不同的计算机体系结构有不同的指令集(Instruction Set),可以识别的机器指令格式是不同的,直接用某种体系结构的汇编或机器指令写出来的程序只能在这种体系结构的计算机上执行。

    不同体系结构的计算机有各自的C编译器,可以把C程序编译成相应的机器指令,这意味着用C语言写的程序要想在各种不同体系结构的计算机上执行,只需用相应的编译器编译过即可。比如上面 a = b + 1; 的例子是在32位x86平台下编译的得到的结果,如果在ARMv4平台上编译则得到完全不同的结果(编译生成的可执行文件也完全不同):

    一个语句的三种表示(ARMv4平台)
    编程语言 表示形式
    机器语言
    e59f2018
    e59f3018
    e5933000
    e2833001
    e5823000
    
    汇编语言
    ldr     r2, [pc, #24]
    ldr     r3, [pc, #24]
    ldr     r3, [r3]
    add     r3, r3, #1
    str     r3, [r2]
    
    C语言 a = b + 1;

    同样道理,不同体系结构的计算机有各自的Shell解释器,一个Shell脚本要想在不同体系结构的计算机上执行,只需运行相应的Shell解释器来解释执行即可。

  2. 体系结构相同,操作系统不同会怎么样?

    同样是32位x86平台,把一个Windows下的可执行文件(通常扩展名是 .exe )拷到Linux下能执行吗?答案是不能。虽然这个Windows下的可执行文件包含的是32位x86指令,但其文件格式和Linux下的可执行文件有很大差别,换句话说,能被Windows操作系统加载执行的程序不能被Linux操作系统加载执行,因为这两种操作系统加载执行程序的机制很不一样。

    那么,把一个Windows下的C程序的源代码拷到Linux下,还能用C编译器编译执行吗?答案是不一定。如果这个C程序只用到了C标准库,是可以跨平台的;如果这个C程序用到了Windows操作系统提供而Linux操作系统没有提供的库和接口,那么在Linux下是编译不了的。

    把一个在Windows下的Cygwin环境中能正常执行的Shell脚本拷到Linux下,还能正常执行吗?答案是能执行,但结果不一定正确。如果这个脚本访问了Windows下特有的资源(比如 C:\a.txt 这样的文件路径是Windows特有的,Linux的路径格式完全不同),则不能在Linux下正确执行。

用解释型语言写的程序执行起来一定比编译型语言慢,因为用解释型语言写的程序每次执行时解释器都要把源代码分析一遍,理解程序员写这些代码是想要做什么,再去执行相应的操作,而对于编译型语言来说,这个步骤只需要做一次,就是编译器把源代码分析一遍生成可执行文件,而之后可执行文件在每次执行时就不需要再分析源代码了。用解释型语言写的程序也有它的优点:换个平台就可以直接执行,而不需要先编译一遍,此外,解释型语言写的程序调试起来比编译型语言方便得多。

既然解释型语言和编译型语言各有各的优点,有一些高级语言就把两者的优点结合起来,采用编译和解释相结合的方式执行。Java、Python、Perl等编程语言都采用这种方式。以Python为例,程序员写的源代码文件(扩展名为 .py )在首次执行时被编译成字节码(Byte Code)文件(扩展名为 .pyc ),以后每次执行该程序时Python解释器直接解释执行字节码文件,而不再编译源代码。字节码文件中也包含指令,但并非机器指令,而是Python语言定义的一种虚拟机(Virtual Machine)的指令。Python语言在各种平台上都实现这种虚拟机,因此字节码文件从一种平台拷到另一种平台上仍然能被该平台的Python解释器解释执行。

../_images/intro.bytecode.png

虚拟机执行的过程

编程语言仍在发展演化。以上介绍的机器语言称为第一代编程语言(1GL,1st Generation Programming Language),汇编语言称为第二代编程语言(2GL),C、C++、Java、Python等可以称为第三代编程语言(3GL)。目前已经有了4GL和5GL的概念。3GL的编程语言虽然是用语句编程而不直接用指令编程,但语句也分为输入、输出、基本运算、测试分支和循环等几种,和指令有直接的对应关系。而4GL以后的编程语言更多是描述要做什么(Declarative)而不描述具体一步一步怎么做(Imperative),具体一步一步怎么做完全由编译器或解释器决定,例如SQL语言(SQL,Structured Query Language,结构化查询语言)就是这样的例子。

1.2. 自然语言和形式语言

自然语言(Natural Language)就是人类讲的语言,比如汉语、英语和法语。这类语言不是人为设计(虽然有人试图强加一些规则)而是自然进化的。形式语言(Formal Language)是为了特定应用而人为设计的语言。例如数学家用的数字和运算符号、化学家用的分子式等。编程语言也是一种形式语言,是专门设计用来表达计算过程的形式语言。

形式语言有严格的语法(Syntax)规则,例如,3+3=6是一个语法正确的数学等式,而3=+6$则不是,H2O是一个正确的分子式,而 2Zz则不是。语法规则是由符号(Token)和结构的规则所组成的。Token的概念相当于自然语言中的单词和标点、数学式中的数和运算符、化学分子式中的元素名和数字,例如3=+6$的问题之一在于$不是一个合法的数也不是一个事先定义好的运算符,而 2Zz的问题之一在于没有一种元素的缩写是Zz。结构是指Token的排列方式,3=+6$还有一个结构上的错误,虽然加号和等号都是合法的运算符,但是不能在等号之后紧跟加号,而 2Zz的另一个问题在于分子式中必须把下标写在化学元素名称之后而不是前面。关于Token的规则称为词法(Lexical)规则,而关于结构的规则称为语法(Grammar)规则 [1]

[1]很不幸,Syntax和Grammar通常都翻译成“语法”,这让初学者非常混乱,Syntax的含义其实包含了Lexical和Grammar的规则,还包含一部分语义的规则(例如在C程序中变量应先声明后使用)。即使在英文的文献中Syntax和Grammar也经常混用,在有些文献中Syntax的含义不包括Lexical规则,只要注意上下文就不会误解。另外,本书在翻译容易引起混淆的时候通常直接用英文名称,例如Token没有十分好的翻译,直接用英文名称。

当阅读一个自然语言的句子或者一种形式语言的语句时,你不仅要搞清楚每个词(Token)是什么意思,而且必须搞清楚整个句子的结构是什么样的(在自然语言中你只是没有意识到,但确实这样做了,尤其是在读外语时你肯定也意识到了)。这个分析句子结构的过程称为解析(Parse)。例如,当你听到“The other shoe fell.”这个句子时,你理解the other shoe是主语而fell是谓语动词,一旦解析完成,你就搞懂了句子的意思,如果知道shoe是什么东西,fall意味着什么,这句话是在什么上下文(Context)中说的,你还能理解这个句子主要暗示的内容--这属于语义(Semantic)的范畴。

虽然形式语言和自然语言有很多共同之处,包括Token、结构和语义,但是也有很多不一样的地方。

歧义性(Ambiguity)
自然语言充满歧义,人们通过上下文的线索和自己的常识来解决这个问题。形式语言的设计要求是清晰的、毫无歧义的,这意味着每个语句都必须有确切的含义而不管上下文如何。
冗余性(Redundancy)
为了消除歧义减少误解,自然语言引入了相当多的冗余。结果是自然语言经常说得啰里啰唆,而形式语言则更加紧凑,极少有冗余。
与字面意思的一致性
自然语言充斥着成语和隐喻(Metaphor),我在某种场合下说“The other shoe fell”,可能并不是说谁的鞋掉了。而形式语言中字面(Literal)意思基本上就是真实意思,也会有一些例外,例如下一章要讲的C语言转义序列,但即使有例外也会明确规定哪些字面意思不是真实意思,它们所表示的真实意思又是什么。

说自然语言长大的人(实际上没有人例外),往往有一个适应形式语言的困难过程。某种意义上,形式语言和自然语言之间的不同正像诗歌和说明文的区别:

诗歌
词语的发音和意思一样重要,全诗作为一个整体创造出一种效果或者表达一种感情。歧义和非字面意思不仅是常见的而且是刻意使用的。
说明文
词语的字面意思显得更重要,并且结构能传达更多的信息。诗歌只能看一个整体,而说明文更适合逐字逐句分析,但仍然充满歧义。
程序
计算机程序是毫无歧义的,字面和本意高度一致,能够完全通过对Token和结构的分析加以理解。

这里给出一些关于阅读程序(包括其他形式语言)的建议:首先请记住形式语言远比自然语言紧凑,所以要多花点时间来读;其次,结构很重要,从上到下从左到右读往往不是一个好办法,而应该学会在大脑里解析--识别Token,分解结构;最后,请记住细节的影响,诸如拼写错误和标点错误这些在自然语言中可以忽略的小毛病会把形式语言搞得面目全非。

1.3. 程序的调试

只要是人做的事情就难免会出错,何况编程还是一件这么复杂的工作。据说有这样一个典故:早期的计算机体积都很大,有一次一台计算机不能正常工作,工程师们找了半天原因最后发现是一只虫子(Bug)钻进计算机中造成的。从此以后,程序中的错误被叫做Bug,而找到这些Bug并加以纠正的过程就叫做调试(Debug)。有时候调试是一件非常复杂的工作,要求程序员概念明确、逻辑清晰、性格沉稳,还需要一点运气。调试的技能我们在后续的学习中慢慢培养,但首先我们要区分清楚程序中的Bug分为哪几类。

编译时错误
编译器只能翻译语法正确的程序,否则将导致编译失败,无法生成可执行文件。对于自然语言来说,一点语法错误不是很严重的问题,因为我们仍然可以读懂句子。而编译器就没那么宽容了,只要有哪怕一个很小的语法错误,编译器就会输出一条错误提示信息然后罢工,你就得不到你想要的结果。虽然大部分情况下编译器给出的错误提示信息能够指出错误代码的位置,但也有个别时候编译器给出的错误提示信息帮助不大,甚至会误导你。在开始学习编程的前几个星期,你可能会花大量的时间来纠正语法错误。等到有了一些经验之后,还是会犯这样的错误,不过会少得多,而且你能更快地发现错误原因。等到经验更丰富之后你就会觉得,语法错误是最简单最低级的错误,编译器的错误提示也就那么几种,即使错误提示是有误导的也能够立刻找出真正的错误原因是什么。相比下面两种错误,语法错误解决起来要容易得多。
运行时错误
编译器检查不出这类错误,仍然可以生成可执行文件,但在运行时会出错而导致程序崩溃。对于我们接下来的几章将编写的简单程序来说,运行时错误很少见,到了后面的章节你会遇到越来越多的运行时错误。读者在以后的学习中要时刻 注意区分编译时和运行时(Run-time)这两个概念 ,不仅在调试时需要区分这两个概念,在学习C语言的很多语法和规则时都需要区分这两个概念,有些事情在编译时做,有些事情则在运行时做。
逻辑错误和语义错误
第三类错误是逻辑错误和语义错误。如果程序里有逻辑错误,编译和运行都会很顺利,看上去也不产生任何错误信息,但是程序没有干它该干的事情,而是干了别的事情。当然不管怎么样,计算机只会按你写的程序去做,问题在于你写的程序不是你真正想要的,这意味着程序的意思(即语义)是错的。找到逻辑错误在哪需要十分清醒的头脑,要通过观察程序的输出回过头来判断它到底在做什么。

通过本书你将掌握的最重要的技巧之一就是调试。调试的过程可能会让你感到一些沮丧,但调试也是编程中最需要动脑的、最有挑战和乐趣的部分。从某种角度看调试就像侦探工作,根据掌握的线索来推断是什么原因和过程导致了你所看到的结果。调试也像是一门实验科学,每次想到哪里可能有错,就修改程序然后再试一次。如果假设是对的,就能得到预期的正确结果,就可以接着调试下一个Bug,一步一步逼近正确的程序;如果假设错误,只好另外再找思路再做假设。“当你把不可能的全部剔除,剩下的——即使看起来再怎么不可能——就一定是事实。”(即使你没看过福尔摩斯也该看过柯南吧)

也有一种观点认为,编程和调试是一回事,编程的过程就是逐步调试直到获得期望的结果为止。你应该总是从一个能正确运行的小规模程序开始,每做一步小的改动就立刻进行调试,这样的好处是总有一个正确的程序做参考:如果正确就继续编程,如果不正确,那么一定是刚才的小改动出了问题。例如,Linux操作系统包含了成千上万行代码,但它也不是一开始就规划好了内存管理、设备管理、文件系统、网络等等大的模块,一开始它仅仅是Linus Torvalds用来琢磨Intel 80386芯片而写的小程序。据Larry Greenfield说,“Linus的早期工程之一是编写一个交替打印AAAA和BBBB的程序,这玩意儿后来进化成了Linux。”(引自The Linux User’s Guide Beta1版)在后面的章节中会给出更多关于调试和编程实践的建议。

1.4. 第一个程序

在开始写程序之前首先要搭建开发环境,安装编译器、头文件、库文件、开发文档等。在Linux系统下如何安装软件包和搭建开发环境不是本书的重点,这些问题需要读者自己解决,但我在这里简单列出需要安装的软件包供参考(假定你用的是Debian或Ubuntu发行版):

  • gcc: The GNU C compiler
  • libc6-dev: GNU C Library: Development Libraries and Header Files
  • manpages-dev: Manual pages about using GNU/Linux for development
  • manpages-posix-dev: Manual pages about using a POSIX system for development
  • binutils: The GNU assembler, linker and binary utilities
  • gdb: The GNU Debugger
  • make: The GNU version of the “make” utility

本书所有代码都在Ubuntu 12.04 LTS(32位x86平台)发行版上编译测试通过。读者如果用其他Linux发行版,或者不使用发行版提供的软件包而是用自己从源代码编译出的软件包,则编译运行本书的代码得到的结果会有些不同,但不影响学习。

通常一本教编程的书中第一个例子都是打印Hello world,这个传统源自 [K&R]_ ,用C语言写这个程序可以这样写:

1
2
3
4
5
6
7
8
9
#include <stdio.h>

/* main: generate some simple output */

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

将这个程序保存成主目录下的 main.c ,然后编译运行:

$ gcc main.c
$ ./a.out
Hello, world.

gcc 是Linux平台的C编译器,编译后在当前目录下生成可执行文件 a.out [2] ,直接在命令行输入这个可执行文件的路径就可以执行它。如果不想把文件名叫 a.out ,可以用 gcc-o 参数自己指定文件名:

$ gcc main.c -o main
$ ./main
Hello, world.
[2]“a.out”是“Assembler Output”的缩写,实际上一个C程序要先被编译器翻译成汇编程序,再被汇编器翻译成机器指令,最后还要经过链接器的处理才能成为可执行文件,详见 main函数、启动例程和退出状态

虽然这只是一个很小的程序,但我们目前暂时还不具备相关的知识来完全理解这个程序,比如程序的第一行,还有程序主体的 int main(void){...return 0;} 结构,这些部分我们暂时不详细解释,读者现在只需要把它们看成是每个程序按惯例必须要写的部分(Boilerplate)。但要注意 main 是一个特殊的名字,C程序总是从 main 里面的第一条语句开始执行的,在这个程序中是指 printf 这条语句。

第3行的 /* ... */ 结构是一个注释(Comment),其中可以写一些描述性的话,解释这段程序在做什么。注释只是写给程序员看的,编译器会忽略从 /**/ 的所有字符,所以写注释没有语法规则,爱怎么写就怎么写,并且不管写多少都不会被编译进可执行文件中。

printf 语句的作用是把消息打印到屏幕。注意语句的末尾以;号(Semicolon)结束,下一条语句 return 0; 也是如此。

C语言用{}括号(Brace或Curly Brace)把语法结构分成组,在上面的程序中 printfreturn 语句套在 main 的{}括号中,表示它们属于 main 的定义之中。我们看到这两句相比 main 那一行都缩进(Indent)了一些,在代码中可以用若干个空格(Blank)和Tab字符来缩进,缩进不是必须的,但这样使我们更容易看出这两行是属于 main 的定义之中的,要写出漂亮的程序必须有整齐的缩进, 缩进和空白 将介绍推荐的缩进写法。

正如前面所说,编译器对于语法错误是毫不留情的,如果你的程序有一点拼写错误,例如第一行写成了 stdoi.h ,在编译时会得到错误提示:

$ gcc main.c
main.c:1:19: fatal error: stdoi.h: No such file or directory
compilation terminated.

这个错误提示非常紧凑,初学者不容易看明白出了什么错误,即使知道这个错误提示说的是第1行有错误,很多初学者对照着书看好几遍也看不出自己这一行哪里有错误,因为他们对符号和拼写不敏感(尤其是英文较差的初学者),他们还不知道这些符号是什么意思又如何能记住正确的拼写?对于初学者来说,最想看到的错误提示其实是这样的:“在main.c程序第1行的第19列,您试图包含一个叫做stdoi.h的文件,可惜我没有找到这个文件,但我却找到了一个叫stdio.h的文件,我猜这个才是您想要的,对吗?”可惜没有任何编译器会友善到这个程度,大多数时候你所得到的错误提示并不能直接指出谁是犯人,而只是一个线索,你需要根据这个线索做一些侦探和推理。

有些时候编译器的提示信息不是 error 而是 warning ,例如把上例中的 printf("Hello, world.\n"); 改成 printf(1); 然后编译运行:

$ gcc main.c
main.c: In function ‘main’:
main.c:7:9: warning: passing argument 1 of ‘printf’ makes pointer from integer without a cast [enabled by default]
/usr/include/stdio.h:363:12: note: expected ‘const char * __restrict__’ but argument is of type ‘int’
main.c:7:9: warning: format not a string literal and no format arguments [-Wformat-security]
$ ./a.out
Segmentation fault (core dumped)

这个警告信息是说类型不匹配,但勉强还能配得上。警告信息不是致命错误,编译仍然可以继续,如果整个编译过程只有警告信息而没有错误信息,仍然可以生成可执行文件。但是,警告信息也是不容忽视的。出警告信息说明你的程序写得不够规范,可能有Bug,虽然能编译生成可执行文件,但程序的运行结果往往是不正确的,例如上面的程序运行时出了一个段错误(Segmentation fault),段错误是程序崩溃(Crash)的一种表现,这属于运行时错误。

各种警告信息的严重程度不同,像上面这种警告几乎一定表明程序中有Bug,而另外一些警告只表明程序写得不够规范,一般还是能正确运行的。有些不重要的警告信息 gcc 默认是不提示的,但这些警告信息也有可能表明程序中有Bug, 一个好的习惯是打开gcc的-Wall选项,让gcc提示所有的警告信息--不管是严重的还是不严重的--然后把这些问题从代码中全部消灭 。比如把上例中的 printf("Hello, world.\n"); 改成 printf(0); 然后编译运行:

$ gcc main.c
$ ./a.out

编译既不报错也不报警告,一切正常,但是运行程序什么也不打印。如果打开 -Wall 选项编译就会报警告了:

$ gcc -Wall main.c
main.c: In function ‘main’:
main.c:7:9: warning: null argument where non-null required (argument 1) [-Wnonnull]

如果 printf 后面的 0 是你不小心写上去的(例如错误地使用了编辑器的查找替换功能),这个警告就能帮助你发现错误。虽然本书的命令行为了突出重点通常省略 -Wall 选项,但是强烈建议你写每一个编译命令时都加上 -Wall 选项。

习题

  1. 尽管编译器的错误提示不够友好,但仍然是学习过程中一个很有用的工具。你可以像上面那样,从一个正确的程序开始每次改动一小点,然后编译看是什么结果,如果出错了,就尽量记住编译器给出的错误提示并把改动还原。因为错误是你改出来的,你已经知道错误原因是什么了,所以能很容易地把错误原因和错误提示信息对应起来记住,这样下次你在毫无防备的情况下撞到这个错误提示时就会很容易想到错误原因是什么了。这样反复练习,有了一定的经验积累之后面对编译器的错误提示就会从容得多了。