不同模块如何集成到系统中去?
模块的编译和链接
一个C语言项目划分成不同的模块,通常由多个文件来实现。在项目编译过程中,编译器是以C源文件为单位进行编译的,每一个C源文件都会被编译器翻译成对应的一个目标文件。链接器对每一个目标文件进行解析,将文件中的代码段、数据段分别组装,生成一个可执行的目标文件。
如果程序调用了库函数,则链接器也会找到对应的库文件,将程序中引用的库代码一同链接到可执行文件中。
在链接过程中,如果多个目标文件定义了重名的函数或全局变量,就会发生符号冲突,报重定义错误。这时候链接器就要对这些重复定义的符号做符号决议,决定哪些留下,哪些丢弃。
在一个多文件项目中,不允许有多个强符号。
若存在一个强符号和多个弱符号,则选择强符号。
若存在多个弱符号,则选择占用空间最大的那一个。
初始化的全局变量和函数是强符号,未初始化的全局变量。默认属于弱符号。
可以通过__attribute__属性声明显式更改符号的属性,将一个强符号显式转换为弱符号。
整个项目编译过程中,可以通过编译控制参数来控制编译流程:预处理、编译、汇编、链接,也可以指定多个文件的编译顺序。
通常使用自动化编译工具make来编译项目,make自动编译工具依赖项目的Makefile文件。
Makefile文件主要用来描述各个模块文件的依赖关系,要生成的可执行文件,需要编译哪些源文件。
make在编译项目时,会首先解析Makefile,分析需要编译哪些源文件,构建一个完整的依赖关系树,然后调用具体的命令一步步去生成各个目标文件和最终的可执行文件。
系统模块划分
根据系统的目标、实现的功能进行划分;当系统比较复杂时,对系统进行分层。
面向对象编程和系统的模块化设计侧重点不同。模块化设计的思想
内核是分而治之,重点在于抽象的对象之间的关联,而不是内容;面向对象编程思想主要是为了代码复用,重点在于内容实现。两者还有一个重要的区别是:两者不在同一个层面上。模块化设计是最高原则,先有系统定义,然后有模块和模块的实现,最后才有代码复用。一个系统不仅仅是模块的实现,还有各个模块之间的相互作用、相互关联,以及由它们构成的一个有机整体。面向对象编程,通过类的封装和继承实现了代码复用,减少了开发工作量,这是面向对象编程的长处。
模块的封装
在C语言中一个模块一般对应一个C文件和一个头文件。模块的实现在C源文件中,头文件主要用来存放函数声明,留出模块的API,供其他模块调用。
头文件深度剖析
编译器在编译各个C源文件的过程中,如果该C文件引用了其他文件中定义的函数或变量,编译器也不会报错,链接器在链接的时候会到这个文件里查找你引用的函数,如果没有找到才会报错。但是编译器为了检查你的函数调用格式是否存在语法错误,形参实参的类型是否一致,会要求程序员在引用其他文件的全局符号之前必须先声明,如变量的类型、函数的类型等,编译器会根据你声明的类型对编写的程序语句进行语法、语义上的检查。为了方便,将函数的声明直接放到头文件里,作为本模块封装的API,供其他模块使用。程序员在其他文件中如果想引用这些API函数,则直接#include这个头文件,然后直接调用。
变量的声明和定义的区别:是否分配内存是区分定义和声明的唯一标准。
一个变量的定义最终会生成与具体平台相关的内存分配汇编指令。
变量的声明则告诉编译器,该变量可能在其他文件中定义,编译时先不要报错,等链接的时候可以到指定的文件里去看看有没有,如果有就直接链接,如果没有则再报错也不迟。一个变量只能定义一次,即只能分配一次存储空间,但是可以多次声明。一般变量的定义要放到C文件中,不放到头文件中,因为头文件可能被多人使用,被多个文件包含,头文件经过预处理器多次展开之后也就变成了多次定义。
头文件重复包含
如果在一个项目中多次包含相同头文件,编译器也不会报错,因为预处理器在预处理阶段已经将头文件展开了:一个变量或函数可以有多次声明,这是编译器允许的。如果在头文件里定义了宏或一种新的数据类型,头文件再多次包含展开,编译器在编译时可能会报重定义错误。为了防止这种错误产生,可以在头文件中使用条件编译来预防头文件的多次包含。
隐式声明
(ANSI C标准支持,但C99/C11/C++标准已禁止)
如果一个C程序引用了在其他文件中定义的函数而没有在本文件中声明,编译器也不会报错,编译器会认为这个函数可能会在其他文件中定义,等链接的时候找不到其定义才会报错,而是会给一个警告信息并自动添加一个默认的函数声明。这个声明我们称为隐式声明。
函数的隐式声明可能与自定义函数冲突,如果我们引用库函数而没有包含对应的头文件,也有可能与库函数发生类型冲突。这些函数类型冲突虽然不影响程序的正常运行,但是会给程序带来很多无法预料的深层次bug,在不同的编译环境下,函数的运行结果甚至可能都不一样。为了编写高质量稳定运行的程序,我们要养成“先声明后使用”的良好编程习