C语言编程魔法书:基于C11标准
上QQ阅读APP看书,第一时间看更新

第一篇 预备知识篇

1
C魔法概览

本章内容主要对C编程语言(以下简称C语言)进行大体介绍,包括它的历史以及C语言标准的演化进程。然后介绍一下C语言编程思想,当前主流C语言编译器以及GNU语法扩展。最后简单介绍一下从用C语言编写程序到编译、构建一个可执行程序的大致过程。

计算机编程语言从对计算机硬件底层的抽象程度进行分类,可分为:机器语言、汇编语言以及高级语言。下面由底层到高层分别介绍这几种类别的编程语言。

1.1 例说编程语言

1)机器语言是直接通过十六进制数表示当前处理器架构的机器指令码。指令码包含了当前指令的功能(比如算术逻辑运算、移位、分支、中断、I/O等)、寄存器、立即数等多种元素。每种处理器架构所对应的机器码的字节长度也各不相同,有些是固定长度的(比如ARM、MIPS等架构),有些是可变长度的(比如x86架构)。

2)汇编语言(Assembly Language)通过简单的指令助记符(memonics)来表示对应机器指令的功能、寄存器编号、立即数(immediates)等元素。汇编语言是对机器指令的简单抽象,通过汇编器(assembler)可以将汇编语句翻译成对应的机器指令码。

3)高级语言的表达形式更为抽象且贴近我们日常的语言表述。而且,高级语言比起汇编语言往往更具有表达力,且拥有更加丰富的语法特性,以便将程序进行结构化和模块化。比如,高级语言具有自定义变量标识符、自定义数据结构、分支与循环、更形象自然的表达式等。高级语言一般通过编译器(compiler)可直接将表达式翻译为对应的机器指令码;也可以将高级语言先翻译为中间语言(类似于汇编,但可能比汇编适用范围更广、更利于跨平台的字节码),最后将中间语言翻译为最终的机器指令码。

当然,有些书中还介绍了第四代语言,它基于高级语言,比高级语言更抽象,只需要一些简单的描述语句就能让计算机做比较复杂的工作。比如SQL(结构化查询语言,用于数据库查询)算是一种第四代语言。

下面,为了能让大家对这三种层次的编程语言有一个感性的认识,这里将列举ARMv8架构处理器下的机器语言、汇编语言,加上它们相应的C语言。读者如果手头有Xcode,并且有包含Apple A7或更高版本处理器的iOS设备的话,可以直接编译运行,并能看到最终效果。

下面首先列出一个文件名为my_sub.s的汇编源文件,其中包含了机器语言和汇编语言。见代码清单1-1:

代码清单1-1 机器语言与汇编语言

.text
.align 4

#ifdef __arm64__

.globl _my_sub_machine
.globl _my_sub_assembly

// 用机器语言实现减法操作
_my_sub_machine:

    .long 0x4b010000

    .long 0xd65f03c0

// 用汇编语言实现减法操作
_my_sub_assembly:

    sub w0, w0, w1

    ret

#endif

在代码清单1-1中,_my_sub_machine程序片段中的两条.long语句即为机器指令。这两条机器指令正好与_my_sub_assembly中的两条汇编指令相对应。也就是说,“0x4b010000”这串32位的十六进制代码意思就是“sub w0, w0, w1”,表示将寄存器w0与寄存器w1的值进行相减,然后将结果写回w0寄存器中。而“0xd65f03c0”指令码对应于“ret”(更确切地说是ret x30),表示返回当前过程(procedure)。在汇编语言中,一般会使用过程或者例程(routine)来表示一个可执行的程序片段。在C语言中一般都用函数(function)表示。我们在这里能够明显看到,汇编语言采用指令助记符的方式比写机器指令码要直观得多,而且也不容易出错。“sub”指令的功能从助记符上就能知道是“减法”功能;而w0、w1也明确指明了使用的寄存器是w0和w1。这些在“0x4b010000”这种机器指令码上都无法直观地表现出来。

代码清单1-2列出C语言是如何表达一个减法操作的。

代码清单1-2 减法操作对应的C语言

static int my_sub_c(int a, int b)
{
    return a - b;
}

代码清单1-2所列出的C语言代码与代码清单1-1中的机器指令码和汇编语言完全对应,意思一目了然——将参数变量a的值与参数变量b的值进行相减,然后将结果返回。从这里我们就能看到机器语言、汇编语言以及以C语言为代表的高级语言之间在表达力上的差距了。高级语言的目的就是为了给程序员提供更良好的编程工具,更简洁、更富有表达力的语言,使得我们程序员能提升生产力,并且能构思出更多精彩炫酷的应用,而不是把太多的精力都投入在如何让计算机执行的细节上。

代码清单1-3能让我们在主函数或其他函数中测试上述已经编写好的函数。

代码清单1-3 展示减法操作的结果

#ifdef __arm64__

extern int my_sub_machine(int a, int b);
extern int my_sub_assembly(int a, int b);

int result_machine = my_sub_machine(10, 2);
int result_assembly = my_sub_assembly(5, 3);
int result_c = my_sub_c(6, 2);

printf("Three results: %d, %d, %d\n", result_machine, result_assembly, result_c);

#endif

执行了上述代码之后,我们最后能在控制台看到输出结果:“Three results: 8, 2, 4”。可见,上述三种不同的编程语言,计算功能是完全一致的,都是对两个输入参数做减法操作,然后返回差值。然而就可读性、可理解性以及编程便利性而言,显然C语言比起其他两者要强得多。而可读性最差的无疑就是机器指令码了。

1. C语言的类别与产生

对于高级语言来说,从表达上又可分为命令式编程语言(imperative programming language)和陈述型编程语言(declarative programming language)。命令式语言主要包括过程式(procedural)、结构化(structured)以及面向对象(object-oriented)的编程语言;陈述型编程语言主要包括函数式(functional)以及逻辑型(logical)编程语言。而C语言则属于结构化的命令式编程语言。不过现在很多命令式编程语言也包含了一些函数式编程语言的特征。在本书中,后面第18章中谈到的Blocks语法就是一个很典型的函数式编程语言的语法。

C语言最初由Dennis Ritchie于1969年到1973年在AT&T贝尔实验室里开发出来,主要用于重新实现Unix操作系统。此时,C语言又被称为K&R C。其中,K表示Kernighan的首字母,而R则是Ritchie的首字母。K&R C语言与后来标准化的C语言有很大差异。比如,如果函数返回类型为int,则int可省:int my_function() { },也可以写成my_function(){ }。编译器不会有任何警告,更不会报错。另外,还有现在看来比较奇葩的函数定义,像我们现在定义这么一个函数——void my_function(int a, char *p){ },如果是用K&R C语法定义的话要写成:void my_function(a, p) int a; char *p; { }。K&R的C语法中,定义一个函数时,其形参列表先列出形参的标识符,然后在函数声明的后面紧跟着对形参标识符的完整声明,最后是函数体。这在现行标准中已经被逐步废弃使用了。另外,当时的第一本C语言专业书《The C Programming Language》也并非一个正式的编程语言规范,但被用了许多年。

2. C90标准

由于C语言被各大公司所使用(包括当时处于鼎盛时期的IBM PC),因此到了1989年,C语言由美国国家标准协会(ANSI)进行了标准化,此时C语言又被称为ANSI C。而仅过一年,ANSI C就被国际标准化组织ISO给采纳了。此时,C语言在ISO中有了一个官方名称——ISO/IEC 9899:1990。其中,9899是C语言在ISO标准中的代号,像C++在ISO标准中的代号是14882。而冒号后面的1990表示当前修订好的版本是在1990年发布的。对于ISO/IEC 9899:1990的俗称或简称,有些地方称为C89,有些地方称为C90,或者C89/90。不管怎么称呼,它们都指代这个最初的C语言国际标准。这个版本的C语言标准作为K&R C的一个超集(即K&R C是此标准C的一个子集),把后来引入的许多非官方特性也一起整合了进去。其中包括了从C++借鉴的函数原型(Function Prototypes),指向void的指针,对国际字符集以及本地语言环境的支持。在此标准中,尽管已经将函数定义的方式改为现在我们常用的那种方式,不过K&R的语法形式仍然兼容。

3. C99标准

在随后的几年里,C语言的标准化委员会又不断地对C语言进行改进,到了1999年,正式发布了ISO/IEC 9899:1999,简称为C99标准。C99标准引入了许多特性,包括内联函数(inline functions)、可变长度的数组、灵活的数组成员(用于结构体)、复合字面量、指定成员的初始化器、对IEEE754浮点数的改进、支持不定参数个数的宏定义,在数据类型上还增加了long long int以及复数类型。毫不夸张地说,即便到目前为止,很少有C语言编译器是完整支持C99的。像主流的GCC以及Clang编译器都能支持高达90%以上,而微软的Visual Studio 2015中的C编译器只能支持到70%左右。

4. C11标准

2007年,C语言标准委员会又重新开始修订C语言,到了2011年正式发布了ISO/IEC 9899:2011,简称为C11标准。C11标准新引入的特征尽管没C99相对C90引入的那么多,但是这些也都十分有用,比如:字节对齐说明符、泛型机制(generic selection)、对多线程的支持、静态断言、原子操作以及对Unicode的支持。本书将主要针对C11标准为大家详细讲解C编程语言。关于C语言历史与演化进程的详细介绍可参考维基百科:https://en.wikipedia.org/wiki/C_%28programming_language%29

笔者近两年也是在不断地了解C语言标准委员会的最新动态(可参见:http://www.open-std.org/jtc1/sc22/wg14/),其中看到有人提出想为C语言添加面向对象的特性,包括增加类、继承、多态等已被C++语言所广泛使用的语法特性,但是最终被委员会驳回了。因为这些复杂的语法特性并不符合C语言的设计理念以及设计哲学,况且C++已经有了这些特性,C语言无需再对它们进行支持。笔者将在第19章给大家谈谈C语言设计理念与发展方向。

1.2 用C语言编程的基本注意事项

C语言的发明其实基于Unix操作系统。当时在C语言未面世之前,Dennis Ritchie所在的AT&T贝尔实验室用的Unix系统是完全用汇编语言写的。汇编语言的优势是直接面向处理器本身,能直接对底层硬件进行控制,充分发挥处理器的硬件能力。然而,它的缺陷也是显而易见的。

1.汇编语言的不足

首先,不可移植性。每种处理器,其指令集都大相径庭,比如ARM有ARM的指令集架构(ISA),Intel x86有x86的ISA,还有MIPS、Power(原来为PowerPC),Motorola 68000等;再加上各类微控制器单元(Micro-Controller Unit, MCU)、各类数字信号处理器(Digital Signal Processor, DSP),每种ISA都有其相应的汇编语言。那么多处理器如果对每一种都使用不同的汇编语言来实现同一个操作系统,那操作系统的开发人员真要崩溃了……而且即便实现出来,可能各个处理器上的实现也会有所不同,标准也很难被统一起来。

其次,汇编语言本身要比高级语言精密。因为汇编语言面对的都是寄存器、存储器以及各类底层硬件,而不是一种抽象的数据模型,所以代码编写时需要非常谨慎,而且调试程序也十分麻烦,且非常容易出错。所以,如果有一种既能面向底层硬件,又能对数据以及程序进行抽象的高级语言出现,那势必既能不太影响程序执行效率,又能大大提升程序的可执行性、可读性以及编写的效率,这将是非常伟大的贡献。C语言也就是在这种背景下诞生的。

如果说,汇编语言面向的是底层硬件、一种过程化的编程风格的话,那么C语言就是面向数据流和算法、一种结构化的编程风格。C语言是一种结构化的、静态类型的编译型编程语言。也就是说,用C语言编写了源代码之后,需要通过C语言编译器进行编译,构建为相应的处理器能直接执行的机器码,然后处理器可以对生成出来的机器码进行执行。所以在各个处理器上,处理器厂商或第三方只需要为当前处理器写一个对应的C语言编译器即可。然后任何符合C语言标准的程序都能在上面编译后执行,除了需要支持某些机器特定的功能和特性外(后面会介绍)。

2. C语言编写程序要注意什么

那么我们在用C语言写程序的时候应该注意哪些方面呢?

1)可移植性:C语言被设计出来的一大初衷就是为了能将同一个源代码放到各个不同的平台上编译运行。因此,如果我们的代码要在多种不同架构的处理器上运行的话,我们就得注意C语言标准规定了哪些特性是编译器必须遵守的,哪些特性是平台或编译器自己实现的。我们要尽量使用标准中已明文规定的编程规范,尽可能避免在不同平台可能会产生不同行为的语法特性。当然,由于上面提到的处理器种类太过多样,尤其在嵌入式开发领域,很多MCU用的还都是8位处理器,这种情况下C源代码就很难被移植到32位或64位系统下了。本书后面将会指出大部分主流平台对C语言标准中所提到的“实现定义”行为的区别。另外,也会提到一些技巧来应对不同的平台特性。

2)可维护性:可维护性在实际工程项目的研发中非常重要。它体现在最初工程架构的设计、对各个功能模块的划分、相应的开发人员安排,还有后期的测试。一般来说,现在一个工程如果是从无到有进行开发的话会采用螺旋式开发模型。也就是说,一个项目启动后,可以先做一个功能简单但能正常工作的产品原型。然后在此基础上不断地为它增加更多功能,或对之前的功能进行修改。在此期间,我们如何对整个工程进行模块化划分,从而能安排不同开发人员针对不同功能模块进行开发就变得尤为重要。另外,在工程开发过程中,如果有人员流动,那么如何将即将离职的开发人员手中的工作交付给新人也关系到整个项目的进展。因此,一个良好的C语言代码应该具有可读性、良好的文档化注释风格,以及较详细的设计文档。对于一个较大的工程项目来说,开发人员不仅仅需要把自己的代码写好,而且要写得能让别人看懂,并且要做好详细的设计文档,这样才能把项目风险降低。

3)可延展性:大家或许已经知道,像微软的Windows操作系统由数千名工程师合作研发;Linux操作系统对外开源,参与其中的研发人员也有数百上千人。如果我们在一个开发团队中负责一个需要由多人合作开发的工程项目,那么我们写的功能模块需要与其他人写的功能模块进行对接。所以,我们在开发一个较大工程项目时,需要协调好各自对外的模块接口(Application Program Interface, API)。由于C语言没有全局名字空间(namespace)这个概念,所以命名一个对外接口也是非常重要的,否则可能会与其他功能模块的接口名发生冲突。本书后面会对C语言函数命名以及符号连接做进一步介绍。

4)性能:性能是提升程序使用者效率和生产力的体现。一个应用程序的性能越高,那么计算一个任务所花费的时间越短,也越节省计算机的耗电。而对于如何提升性能,一方面需要程序员对处理器架构、硬件特性有一定了解;另一方面需要程序员拥有比较丰富的算法知识,能针对实际需求灵活采用高效的算法。而像C语言这种十分接近硬件底层的高级编程语言,能极大限度地发挥处理器的特长,从而达到高效的运行性能。

1.3 主流C语言编译器介绍

对于当前主流桌面操作系统而言,可使用Visual C++、GCC以及LLVM Clang这三大编译器。其中,Visual C++(简称MSVC)只能用于Windows操作系统;其余两个,除了可用于Windows操作系统之外,主要用于Unix/Linux操作系统。像现在很多版本的Linux都默认使用GCC作为C语言编译器。而像FreeBSD、macOS等系统默认使用LLVM Clang编译器。由于当前LLVM项目主要在Apple的主推下发展的,所以在macOS中,Clang编译器又被称为Apple LLVM编译器。MSVC编译器主要用于Windows操作系统平台下的应用程序开发,它不开源。用户可以使用Visual Studio Community版本来免费使用它,但是如果要把通过Visual Studio Community工具生成出来的应用进行商用,那么就得好好阅读一下微软的许可证和说明书了。而使用GCC与Clang编译器构建出来的应用一般没有任何限制,程序员可以将应用程序随意发布和进行商用。不过由于MSVC编译器对C99标准的支持就十分有限,加之它压根不支持任何C11标准,所以本书的代码例子不会针对MSVC进行描述。所幸的是,Visual Studio Community 2017加入了对Clang编译器的支持,官方称之为——Clang with Microsoft CodeGen,当前版本基于的是Clang 3.8。也就是说,应用于Visual Studio集成开发环境中的Clang编译器前端可支持Clang编译器的所有语法特性,而后端生成的代码则与MSVC效果一样,包括像long整数类型在64位编译模式下长度仍然为4个字节,所以各位使用的时候也需要注意。为了方便描述,本书后面涉及Visual Studio集成开发环境下的Clang编译器简称为VS-Clang编译器。

而在嵌入式系统方面,可用的C语言编译器就非常丰富了。比如用于Keil公司51系列单片机的Keil C51编译器;当前大红大紫的Arduino板搭载的开发套件,可用针对AVR微控制器的AVR GCC编译器;ARM自己出的ADS(ARM Development Suite)、RVDS(RealView Development Suite)和当前最新的DS-5 Studio; DSP设计商TI(Texas Instruments)的CCS(Code Composer Studio);DSP设计商ADI(Analog Devices, Inc.)的Visual DSP++编译器,等等。通常,用于嵌入式系统开发的编译工具链都没有免费版本,而且一般需要通过国内代理进行购买。所以,这对于个人开发者或者嵌入式系统爱好者而言是一道不低的门槛。不过Arduino的开发套件是可免费下载使用的,并且用它做开发板连接调试也十分简单。Arduino所采用的C编译器是基于GCC的。还有像树莓派(Raspberry Pi)这种迷你电脑可以直接使用GCC和Clang编译器。此外,还有像nVidia公司推出的Jetson TK系列开发板也可直接使用GCC和Clang编译器。树莓派与Jetson TK都默认安装了Linux操作系统。在嵌入式领域,一般比较低端的单片机,比如8位的MCU所对应的C编译器可能只支持C90标准,有些甚至连C90标准的很多特性都不支持。因为它们一方面内存小,ROM的容量也小;另一方面,本身处理器机能就十分有限,有些甚至无法支持函数指针,因为处理器本身不包含通过寄存器做间接过程调用的指令。而像32位处理器或DSP,一般都至少能支持C99标准,它们本身的性能也十分强大。而像ARM出的RVDS编译器甚至可用GNU语法扩展。

图1-1展示了上述C语言编译器的分类。

图1-1 C语言编译器的分类

1.4 关于GNU规范的语法扩展

GNU是一款能用于构建类Unix操作系统的计算机软件合集,由自由软件之父Richard Stallman开创,于1983年9月27日对外发布。GNU完全由自由软件(free software)构成。GNU语法扩展源自于GCC编译器,在1987年发布1.0版本,称为GNU C Compiler。随后,GCC编译器前端 源代码编译流程请见1.5节图1-2。支持了C++、Objective-C/C++、Fortran、Ada、Java以及最近跃升的Go等编程语言,因此现在GCC被称为GNU Compiler Collection。由于在20世纪90年代,GNU C编译器就对C90标准做了相当多的语法扩展,包括复合字面量、匿名结构体和数组、可指定的初始化器等,这些语法扩展被广泛使用,尤其是大量用于Linux内核代码中,因此C99标准将这些语法特性全都列入标准之中。

正因为GCC本身是开源自由软件,因此很多商用编译器也基于GCC进行扩展。像ARM的RVCT(RealView Compiler Toolkit)本身就支持GNU扩展。还有不少开发平台本身就直接使用GCC编译工具。由于有不少大公司顶级开发人员的参与,因此GCC编译器的目标代码优化能力相当高,而且还支持许多不同的处理器。所以,GCC当前被广泛使用并博得开发者的好评。像Linux操作系统基本默认使用GCC作为默认编译器,包括Android的NDK开发工具一开始也是如此。

然而,由于GCC基于比较严格的GPL许可证,许多大型商业开发商对它望而却步。该许可证允许使用者免费使用软件,但是要求不能随意对它进行篡改并重新发布。如果开发者对它进行篡改,然后发布自己修改之后的软件,那么必须要把自己修改的那部分也开源出来。因此,在2003年诞生了一个LLVM开源项目,基于更为宽松的BSD许可证,其编译器称为Clang。BSD许可证允许开发者随意对软件进行修改并重新发布,甚至可以将修改过的版本作为自主版权,因而这个许可证深受大公司的欢迎。现在Apple对LLVM项目的投入非常大。macOS上的开发工具Xocde从4.0版本起就开始使用Clang编译工具链,随后Apple将自己改写的Clang编译器称为Apple LLVM。当前最新的Xcode 8所使用的Apple LLVM版本为8.x。而当前Android NDK也支持了Clang编译器工具链。Clang编译器并非基于GCC,它是从头开始写的。但是它的目标是尽量与GCC编译器兼容,所以Clang编译器包含大部分GNU语法扩展,除此之外还含有它自己特有的C语言扩展。当然也有一些特性是GCC含有而Clang不具备的,不过这些特性一般很少使用。

我们现在可以看到GNU语法扩展适用性十分广泛。如果读者当前在做Linux/Unix或Windows上的C语言编程开发,或者是在开发macOS/iOS应用,又或者是在开发Android应用,那么完全可以毫无顾忌地使用GNU语法扩展。本书最后几个章节会分别介绍GCC编译器特定的语法扩展以及Clang编译器特定的语法扩展。由于Clang编译器已经包含了大部分GNU语法扩展,因此在介绍GCC语法扩展的时候,如果当前特性Clang不支持,则会指明。

1.5 用C语言构建一个可执行程序的流程

从用C语言写源代码,然后经过编译器、连接器到最终可执行程序的流程图大致如图1-2所示。

图1-2 C语言源代码编译流程图

从图1-2中我们可以清晰地看到C语言编译器的大致流程。首先,我们先用C语言把源代码写好,然后交给C语言编译器。C语言编译器内部分为前端和后端。前端负责将C语言代码进行词法和语法上的解析,然后可以生成中间代码。中间代码这部分不是必须的,但是它能够为程序的跨平台移植带来诸多好处。比如,同样的一份C语言源代码在一台计算机上编译完之后,生成一套中间代码。然后针对不同的目标平台(比如要将这一套代码分别编译成ARM处理器的二进制机器码、MIPS处理器的二进制机器码以及x86处理器的二进制机器码),只需要编写相应目标平台的编译器后端即可。所以,这么做就可以把编译器的前端与后端剥离开来(这在软件工程上又可称为解耦合),不同处理器厂商可以针对自家的处理器特性,对中间代码生成到目标二进制代码的过程再度进行优化。接下来,由C语言编译器后端生成源文件相应的目标文件。目标文件在Windows系统上往往是.obj文件;而在Unix/Linux系统上往往是.o文件。C语言的源文件在所有平台上都统一用.c文件表示。最后,对于各个独立的目标文件,通过连接器将它们合并成一个最终可执行文件。连接器与C语言编译器是完全独立的。所以,只要最终目标代码的ABI(应用程序二进制接口)一致,我们可以把各个编译器生成的目标代码都放在一起,最后连接生成一个可执行文件。比如,有些源代码可用GCC编译,有些使用Clang编译,还有些汇编语言源文件可直接通过汇编器生成目标代码,最后将所有这些生成出来的目标代码连接为可执行文件。最终用户可以在当前的操作系统上加载可执行文件进行执行。操作系统利用加载器将可执行文件中相关的机器码存放到内存中来执行应用程序。

1.6 本章小结

本章简要地介绍了计算编程语言的分类,描述了C语言的历史及演化,以及C语言的编程思想。此外还介绍了GNU的来龙去脉以及C语言编译器将C语言代码翻译成最终机器码的大致流程。

C语言作为一门更接近硬件底层的高级编程语言具有良好的抽象力、表达力和灵活性。此外,它具有非常高效的运行时性能。当前的C语言编译器最终翻译成的机器指令码与我们手工写汇编语言所得到的性能在大部分情况下相差无几。C语言基本能达成我们对性能的要求,而在某些对性能要求十分严苛的热点(hotspot)上,我们可以对这些功能模块手工编写汇编代码。C语言与汇编语言的ABI是完全兼容的,而且大部分C语言编译器还支持直接内联汇编语言。因此,C语言从1970年直到现在都是系统级编程的首要编程语言。