在这一章中,您将开发您的第一个C程序:传统的“Hello, world!”程序。我将带您了解这个简单程序的各个方面,以及如何编译和运行C程序。
然后,我将讨论一些编辑器和编译器选项,并列出您在编写C代码时将快速熟悉的常见可移植性问题。
开发您的第一个C程序
学习C编程的最佳方式是开始编写C程序,传统上,我们从“Hello, world!”开始。要编写这个程序,您需要一个文本编辑器或集成开发环境(IDE),你可以在网上找到相关信息。
在您的文本编辑器中,输入程序:
1 | //程序清单1-1 |
我们稍后将详细讨论这个程序的每一行。现在,请将这个文件保存为hello.c。
文件扩展名.c表示该文件包含C语言源代码。
编译和运行您的程序
接下来,我们需要编译和运行程序,这涉及到两个单独的步骤。您可以选择众多的C编译器,而编译程序的命令取决于您使用的编译器。
在Linux和其他类似Unix的操作系统上,您可以使用cc命令调用系统编译器。要编译您的程序,请在命令行中输入cc,然后跟上要编译的文件的名称:
1 | cc hello.c |
如果您正确输入了程序,编译命令将在与您的源代码相同的目录中创建一个名为a.out的新文件。使用ls命令检查您的目录,您应该会看到以下内容:
1 | ls |
a.out文件是可执行程序,您现在可以在命令行上运行它:
1 | ./a.out |
如果一切正常,程序应该会在终端窗口中打印出Hello, world!。如果没有打印出来,请将清单1-1中的程序文本与您的程序进行比较,以确保它们相同。 cc命令有许多标志和编译器选项。例如,-o file标志允许您为可执行文件指定一个易记的名称,而不是a.out。以下编译器调用将可执行文件命名为hello:
1 | cc -o hello hello.c |
现在我们将逐行检查hello.c程序。
预处理指令
hello.c程序的前两行使用了#include预处理指令,它的行为相当于在它所在的位置替换了它所指定的文件内容。
我们引入了<stdio.h>和<stdlib.h>头文件,以便访问这些头文件中声明的函数,然后就可以从程序中调用这些函数。
puts函数在<stdio.h>中声明,EXIT_SUCCESS宏在<stdlib.h>中定义。正如文件名所示:<stdio.h>包含了C标准I/O函数的声明,而<stdlib.h>包含了通用实用函数的声明。
您需要引入您在程序中使用的任何库函数的声明。
主函数
程序的主要部分,如前面的清单1-1所示,从以下部分开始:
1 | int main(void) { |
这行定义了在程序启动时调用的main函数。main函数定义了程序的主入口点,在命令行或另一个程序中调用程序后,程序会在托管环境中开始执行。
C定义了两种可能的执行环境:独立环境和托管环境。独立环境可能不提供操作系统,通常用于嵌入式编程。这些实现提供了一组最小的库函数,并且在程序启动时调用的函数的名称和类型是由实现定义的。本书主要假设使用托管环境。
我们定义main函数返回int类型的值,并在括号内放置void,以指示该函数不接受参数。int类型是一种带符号的整数类型,可以用来表示正数、负数以及零。
与其他过程式语言类似,C程序由可以接受参数并返回值的过程(称为函数)组成。每个函数都是可重复使用的工作单元,在程序中可以根据需要多次调用。
在这种情况下,main函数返回的值表示程序是否成功终止。这个特定函数执行的实际工作是打印出Hello, world!这一行:
1 | puts("Hello, world!"); |
puts函数是C标准库中的一个函数,它将一个字符串参数写入stdout,通常表示控制台或终端窗口,并在输出中附加一个换行字符。"Hello, world!"是一个字符串字面量,它的行为类似于一个只读字符串。这个函数调用将Hello, world!输出到终端。
一旦您的程序完成,您会希望它退出。您可以使用return语句在main函数中返回一个整数值给主机环境或调用脚本来退出程序:
1 | return EXIT_SUCCESS; |
EXIT_SUCCESS是一个类似对象的宏,通常扩展为0,并且通常定义如下:
1 |
每个EXIT_SUCCESS都会被替换为0,然后从main返回给托管环境。调用程序的脚本可以检查其状态,以确定调用是否成功。
main函数给托管环境返回一个值的过程相当于调用C标准库的exit函数,并将其参数设置为main函数的返回值。
这个程序的最后一行是一个右花括号 },它关闭了我们从main函数的声明开始的代码块:
1 | int main(void) { |
您可以将左花括号放在同一行上,也可以独立成行,如下所示:
1 | int main(void) |
这个决定严格来说是一种风格上的选择,因为空白字符(包括换行符)通常在语法上没有意义。在本书中,我通常将左花括号放在与函数声明同一行上,因为这样在风格上更加紧凑。
检查函数返回值
函数通常会返回一个计算结果或表示函数是否成功完成其任务的值。例如,我们在“Hello, world!”程序中使用的puts函数接受一个要打印的字符串,并返回一个int类型的值。如果发生写入错误,puts函数将返回宏EOF的值(一个负整数);否则,它将返回一个非负整数值。
虽然puts函数对于我们的简单程序来说可能不太可能失败并返回EOF,但这是可能的。因为puts调用可能会失败并返回EOF,这意味着你的第一个C程序存在一个bug,或者至少可以改进如下:
1 |
|
这个修改版的“Hello, world!”程序检查puts调用是否返回EOF,表示写入错误。如果函数返回EOF,程序将返回EXIT_FAILURE宏的值(非零)。否则,函数成功,程序将返回EXIT_SUCCESS(必须为0)。调用程序的脚本可以检查程序的状态以确定是否成功。
return语句后面的代码是永远不会执行的死代码,这在修订后的程序中由单行注释表示。//后面的所有内容都会被编译器忽略。
格式化输出
puts函数是将字符串写入stdout的一种简单方式,但最终您可能需要使用printf函数来打印格式化的输出,例如,以打印字符串之外的参数。
printf函数接受一个格式字符串,该字符串定义了输出的格式,然后是可变数量的参数,这些参数是您要打印的实际值。
例如,如果您想要使用printf函数来打印Hello, world!,可以这样写:
1 | printf("%s\n", "Hello, world!"); |
第一个参数是格式字符串"%s\n"。
%s是一个转换说明,指示printf函数读取第二个参数(一个字符串字面值)并将其打印到stdout。
\n是一个字母转义序列,用于表示非图形字符,并告诉函数在字符串之后包含一个换行符。如果没有换行序列,下一个被打印的字符(可能是命令提示符)将出现在同一行上。
这个函数调用输出以下内容:
1 | Hello, world! |
要小心不要将用户提供的数据作为printf函数的第一个参数的一部分传递,因为这样做可能会导致格式化输出的安全漏洞(Seacord 2013)。
输出字符串的最简单方法是使用puts函数,如前所示。如果您在修订版的“Hello, world!”程序中使用printf而不是puts,您会发现它不再起作用,因为printf函数返回的状态与puts函数不同。如果成功,printf函数返回打印的字符数,如果发生输出或编码错误,则返回负值。您可以尝试修改“Hello, world!”程序以使用printf函数作为练习。
编辑器和集成开发环境
可以使用各种编辑器和集成开发环境(IDE)来开发C程序。可用的确切工具取决于您使用的系统。
编译器
许多C编译器可供选择,因此我不会在这里讨论它们所有。不同的编译器实现不同版本的C标准。许多嵌入式系统的编译器仅支持C89/C90。流行的Linux和Windows编译器更努力地支持C标准的现代版本,包括对C2x的支持。
可移植性
每个C编译器实现都会有一些不同。编译器不断发展,因此,例如,像GCC这样的编译器可能会全面支持C17,但正在努力支持C2x,因此它可能已经实现了一些C2x的特性,但还没有实现其他特性。因此,编译器支持各种C标准版本(包括中间版本)。C实现的整体演进较慢,许多编译器明显落后于C标准。
如果一个程序只使用标准规范中指定的语言和库的特性,那么可以认为它是严格符合规范的。这些程序旨在实现最大的可移植性。然而,由于不同编译器的实现行为差异,没有现实世界的C程序是严格符合规范的,也永远不会是(可能也不应该是)。相反,C标准允许你编写符合规范的程序,这些程序可能依赖于非可移植的语言和库特性。
通常的做法是针对单个参考实现编写代码,或者有时根据计划部署代码的平台编写多个实现。C标准确保这些实现不会有太大的差异,并允许你同时面向多个实现,而无需每次都学习一种新的语言。
C标准文档的附录J列举了五种可移植性问题,它们包括:
实现定义行为
实现定义行为(Implementation-defined behavior)是指C语言标准未明确定义的程序行为,这些行为在不同的C语言实现中可能产生不同的结果,但在同一实现中有一致的、有文档记录的行为。例如,一个实现定义行为是字节中的位数。实现定义行为通常是无害的,但在移植到不同的实现时可能导致问题。因此,在编写代码时,尽量避免依赖于可能在不同的C实现中有所不同的实现定义行为。C语言标准的附录J.3中列举了一份完整的实现定义行为清单,程序员可以通过使用static_assert声明来记录对这些实现定义行为的依赖关系。
在本书中,会在代码中有实现定义行为的情况下进行说明,以帮助程序员了解潜在的问题和可移植性考虑。
未指定行为
未指定行为(Unspecified behavior)是指C语言标准为某些程序行为提供了两种或更多的选项,但标准不规定在任何情况下选择哪个选项。这意味着同一表达式的不同执行可能会产生不同的结果或产生不同的值。例如,函数参数的存储布局就是一种未指定行为,它在同一程序内的不同函数调用之间可以变化。在编写代码时,应避免依赖于C语言标准附录J.1中列举的未指定行为,因为这些行为可能在不同的C语言实现中表现不同。未指定行为通常是为了给编译器和实现提供一定的灵活性,以便进行性能优化或其他目的。因此,在编写代码时,最好不要依赖于未指定行为,以确保代码的可移植性和稳定性。
未定义行为
未定义行为(Undefined behavior)是指C语言标准未定义的行为,或者更具体地说,是指在使用非可移植或错误的程序构造或错误的数据时,标准不对其行为做出任何规定的情况。未定义行为的示例包括有符号整数溢出和解引用无效指针值。具有未定义行为的代码通常是错误的,但情况比较复杂。标准中标识了未定义行为,如下所示:当违反了“shall”(应当)或“shall not”(不得)的要求,且该要求出现在约束之外时,行为是未定义的。
当行为被明确指定为“undefined behavior”(未定义行为)时
对某个行为没有明确的定义
前两种未定义行为通常被称为显式未定义行为,而第三种被称为隐式未定义行为。这三者之间在强调上没有区别;它们都描述了未定义的行为。C标准的附录J.2,“未定义行为”,列举了C中明确定义为显式未定义行为的行为。开发人员经常误解未定义行为为C标准中的错误或遗漏,但将某种行为分类为未定义是有意而慎重的决定。C标准委员会将行为分类为未定义,以实现以下目标:
给予实现者不捕获难以诊断的程序错误的许可
避免定义模糊的边缘情况,以免偏向某种实现策略而不是另一种
确定可能的符合性语言扩展领域,在这些领域中,实现者可以通过提供官方未定义行为的定义来增强语言
这三个原因实际上非常不同,但都被视为可移植性问题。随着本书的进行,我们将检查这三种情况的示例。编译器(实现)有以下自由:
完全忽略未定义行为,导致不可预测的结果
以环境特性的文档方式行为(无论是否发出诊断)
终止翻译或执行(并发出诊断)
这些选项都不是很好(特别是第一个选项),因此最好避免未定义行为,除非实现明确指定了这些行为被定义为允许您调用语言扩展。(编译器有时会有一个严格模式,可以帮助通知程序员这些可移植性问题。)
地区特定行为和常见扩展
“地区特定行为”依赖于每个C语言实现所记录的国籍、文化和语言的本地习惯。而”常见扩展”则是许多系统中广泛使用但不一定可在所有实现中移植的功能或行为。
总结
在本章中,您学习了如何编写一个简单的C语言程序,编译它并运行它。
我们在本章中还讨论了C语言程序的可移植性。
接下来的章节将详细探讨C语言和库的特定特性,从下一章开始介绍对象、函数和类型。