如何用C语言构建一个大型项目(c代码,gcc编译器gdb调试器及makefile常用知识的应用)

本文介绍

本文将通过用c语言构建一个大型项目,演示c代码、gcc编译器及makefile的一些常用知识在项目构建中的具体应用,包括:

  • C语言编译的可执行程序具体包含哪些内容
  • C语言源文件及头文件的作用
  • 如何用gcc编译一个exe
  • 如何用make组织大规模软件的编译
  • 如何用gdb调试

C语言编译的可执行程序具体包含哪些内容

计算机程序本质上就是根据输入,经过计算,得到输出
输入、输出本质上是程序的数据(变量),计算本质上是程序的业务逻辑代码/函数
一个可执行程序在内存中的分布如下:
exe的构成
.text段是放程序代码的段,即存放的是程序的业务逻辑代码,在内存中只读。
.data段.bss段都存放的是程序的数据。
其中 .bss段通常是指用来存放程序中未初始化的全局变量的一块内存区域,在程序载入时由内核清0。所以未初始化的全局变量会被操作系统在程序开始运行时置零。
其中 .data段包含静态初始化数据段堆区栈区
静态初始化数据段存放有初值的全局变量和static变量,大小和程序使用到的全局变量,常量数量相关;
栈区(stack)保存函数的局部变量和参数。是一种“后进先出”(Last In First Out,LIFO)的数据结构,这意味着最后放到栈上的数据,将会是第一个从栈上移走的数据。对于那些暂时存贮的信息,和不需要长时间保存的信息来说,LIFO这种数据结构非常理想。在调用函数或过程后,系统通常会清除栈上保存的局部变量、函数调用信息及其它的信息。栈另外一个重要的特征是,它的地址空间“向下减少”,即当栈上保存的数据越多,栈的地址就越低。栈(stack)的顶部在可读写的RAM区的最后。
堆区(heap)保存函数内部动态分配内存,是另外一种用来保存程序信息的数据结构,更准确的说是保存程序的动态变量。堆是“先进先出”(First In first Out,FIFO)数据结构。它只允许在堆的一端插入数据,在另一端移走数据。堆的地址空间“向上增加”,即当堆上保存的数据越多,堆的地址就越高。

C语言中源文件及头文件的介绍

C语言项目最终构建的是一个可执行程序,可执行程序内部主要有变量区,代码区,类似前面展示的可执行程序在内存中的分布。大型C语言项目往往由多个C语言的源文件及头文件构成,最终构建为一个可执行程序时,也是分别将每个C语言源文件编译成类似于可执行程序的二进制文件,即目标文件.o(包括变量区,代码区,符号表),最后链接成一个可执行程序(将多个目标文件.o的变量区,代码区合并,确定各目标文件符号表的具体变量地址)。
由上段的C语言项目编译链接过程可知,相当于编译过程是将每个源文件处理为变量区,代码区,供最后链接时合并成一个最终的可执行程序的变量区,代码区。
首先根据变量的作用范围,可分为全局变量、局部变量。全局变量位于每个源文件的函数体外,局部变量位于每个源文件的函数体内。依据是否有static关键字修饰,又分为静态和非静态,所以总共有四种变量种类:
(非静态)全局变量,静态全局变量,(非静态)局部变量,静态局部变量
(非静态)局部变量位于栈空间,只在该函数被调用时创建,函数结束时变量被注销。所以如果有函数返回值是指针变量,且该指针指向了(非静态)局部变量,则会导致返回值不是期望的效果,因为函数结束之后,到指针所指的地址下已经找不到(非静态)局部变量了,其已经被注销/释放了。这是使用指针变量时一个常见错误。
静态局部变量会被存储在 .data段的静态存储区,只有程序结束时,才会被注销,这种局部变量一般用于函数内部想记录住某个状态信息采用的手段。
静态全局变量只在当前源文件中可被使用,即只在当前源文件中不同的函数中可使用,此时的static修饰相当于限制了全局变量的使用范围,限制了其对外的链接性。
(非静态)全局变量可以在当前源文件中使用,也可以在其他源文件中使用,当在其他源文件中使用时,一般在当前源文件对应的头文件做一次声明,用关键字extern,如extern int nNum; 其他源文件编译的时候需要包含该头文件,做变量声明,就不会报编译错误,最终在链接时,依据各目标文件的符号表,才真正指向该变量。如果另一个源文件也做了同样的变量定义,则会在链接时报重复定义的错误。
上面讲了C语言项目中变量的分类及使用情况,函数也是类似的,需要声明和定义,函数使用前要被声明。
函数定义包含函数名,形参类型,形参名称及函数体,函数声明只要函数名,形参类型即可,形参名称可忽略。一个源文件中定义的函数想要被其他源文件使用,函数声明可用extern关键字,也可以不用,将函数声明放在同名头文件中供调用的源文件引用。
函数根据作用范围也分静态和非静态,由关键字static区分。
静态函数作用范围只在当前源文件中。
常见函数都是 (非静态)函数,(非静态)函数声明只要写在当前源文件对应的头文件中,其他源文件使用时只需要包含该头文件,即可保证编译时不报错,最终链接时指向该函数。
总而言之:C语言项目最终的输出是一个独立的可执行文件,可执行文件内部分数据段,代码段,可执行文件又是由一个个源文件编译的目标文件链接的,每个目标文件内部也分数据段和代码段。由于有多个源文件,它们之间存在公用数据/函数的场景((非静态)全局变量(非静态)函数),这些变量/函数要被其他源文件使用,需要在定义这些变量/函数的源文件对应的头文件中声明一下,变量需要extern关键字,函数可以不用extern关键字。
关于头文件还有一些值得说的:
从上面可以看出头文件的作用在于声明对应的源文件对外有哪些接口(体现为有哪些 (非静态)全局变量(非静态)函数)),头文件最好只做变量及函数的声明,也可以做类型的定义,不限制变量定义和函数定义,如果头文件中有变量或函数的定义,由于大型项目中存在嵌套包含的现象,比如a.h中包含了b.h c.h,b.h中也包含了c.h,在对a.h展开时,c.h中的内容会被在a.h中展开两次。如果c.h中存在变量/函数定义,此时编译时就会报重复定义的错误。由于一般并不限制头文件中做变量/函数定义,大型项目中头文件的嵌套包含无法避免,所以引入了由条件编译控制的头文件展开。比如c.h中有如下框架:
#ifndefine _C_H
#define _C_H
头文件内容…
#endif
这样头文件在第二次被展开时,由于第一次展开时定义了_C_H宏定义变量,则第二次条件编译直接控制跳过展开。那么c.h中定义变量也不会被认为重复定义了。所以上面这种方法归根到底是在保证对一个头文件展开时,或者对一个源文件包含的所有头文件展开时,其最终包含的所有头文件只被展开一次。即使头文件中不存在变量/函数的定义,这种只展开一次头文件的方法,也加快了预编译(含头文件的展开)速度。
但上面这种方式可能引入了另一个头文件问题,比如a.h包含b.h,b.h又包含a.h,可能觉得一般人不会这么傻,直接做两个头文件的互相引用,但多个头文件中极有可能有嵌套包含导致的类似的循环包含,如a.h包含b.h,b.h包含c.h,c.h包含a.h,这样就形成一个循环包含。由于上面的#ifndefine方法,循环包含倒不会导致循环展开,但此时如果有循环包含外的一个头文件d.h包含了循环包含中任一个头文件,如d.h包含了a.h,则会导致对a.h展开后,进入展开b.h过程,b.h不会再展开a.h,但a.h中可能有一些b.h中需要的类型定义,则此时b.h的展开就会报错。这个问题的根源还是引用/包含混乱导致的,头文件其实应该分级/分层,包含只能上下层直接包含,同层之间不要包含,同层包含就会引入循环引用的问题。

如何用gcc编译一个exe

gcc介绍与安装

通常在PC机上运行的软件,用visual stdio完成编译->链接,甚至调试。当前C语言编程不仅要完成PC机上编程,更要完成跨平台编程,这需要用到更通用的编译器。
常用的C/C++编译器为gcc,在windows平台上有mingw-w64,是一个可自由使用和自由发布的带Windows特定头文件及库文件的gcc编译器。(从上一节中可知,头文件一般是源文件的接口,库文件是由源文件编译生成的目标文件,最终供调用方链接成独立exe)
此处提供gcc安装指南路径,可以按照此指南在PC机上安装gcc。

gcc编译过程详解

gcc(GNU Compiler Collection,GNU编译器套件),是一套编译的工具链,常用的gcc.exe只是是编译的前端程序,其调用cpp,cc1,as,ld分别完成从源代码到预编译程序,汇编程序,目标文件,可执行文件的过程。构建一个源文件的独立应用程序,详细流程如下:

gcc -E
预处理
cpp.exe
gcc -S
编译
cc1.exe
gcc -c
汇编
as.exe
gcc
链接
ld.exe
源代码.c
预编译程序.i
汇编代码.s
目标文件.o
可执行文件.exe/.ELF

需要说明的是,一个源文件要能被构建为独立的应用程序,需要该源文件里有main函数,这是操作系统的入口函数,保证应用程序能被操作系统调用。
上述过程也可以由gcc一键完成可执行文件的构建,即:

gcc
源代码.c
可执行文件.exe/.ELF

上面两种情况生成的可执行文件名都为a.out,不符合习惯,也不宜理解,所以一般用 -o选项 重命名输出,比如gcc test.c -o test.exe。
对于源文件很少的工程,可以采用上面这样简单的gcc一键构建,但这样不利于大型项目的管控与构建。大型项目可能有多人多团队在协同开发,这时候常见的编译场景如下:

gcc -c
ar
ar
gcc -c
ar
ld
ld
gcc -c
ld
ar
ld
源代码1.c
目标文件1.o
库文件1.a
...
源代码n.c
目标文件n.o
目标文件.exe
gcc提供的库文件2.a
含main函数的源码.c
目标文件3.o
...
库文件m.a

比如库文件1.a可能是由一个子模块团队提供的,gcc编译工具链一般会提供一些平台的库文件及对应的头文件,类似于Windows平台上提供了libws2_32.a,winsock2.h,即Windows平台上提供的Socket通信的静态库及对应的头文件。对于一个独立的可执行文件,一定需要一个带有main函数的源码文件,其编译的目标文件及最终链接到可执行文件中,构成了整个应用的入口函数。
库文件本身是多个源码编译的多个目标文件的打包文件,所以即使是大项目,发现归根到底,还是编译和链接。
以上是编译的主要过程,其中有一些细节的编译选项,比如设置头文件路径gcc -I(大写i),库文件路径gcc -L,库名称gcc -l(小写L),gcc -o2优化等

使用make及makefile控制编译过程

上节展示了一个大型项目从0到1的构建过程。对于大型项目如果只是从0到1构建,有批处理和gcc就足够了,相当于用批处理把gcc的命令依次执行一遍,但对于大型项目常常存在修改部分源码后,需要重新构建一次工程的场景,这就引入了如何实现增量构建的问题,make就是解决此问题的,make本身是gcc编译工具链的调度器,由makefile文件定义调度(编译依赖)规则,makefile通常由很多个这样的规则:
目标(文件):依赖(文件)
动作
组成,make分析每一条规则的流程如下:

迭代分析
执行依赖文件
的规则后

依赖文件无,目标文件有
也走此路径
开始
是否有依赖
执行依赖文件
的规则
执行动作命令
生成目标文件
结束
依赖文件是否
比目标文件新
目标文件
是否存在

其中目标和依赖最好为确切的文件,因为这样make就可以获得目标和依赖文件的生成/最后修改时间戳,决策是否目标生成后,依赖重新生成过或被修改过,然后决定是否重新执行目标对应的动作,重新生成目标。
make会根据makefile文件,递归解析目标间的依赖关系,依次执行其对应的编译动作,实现只编译依赖链条上目标的动作。如果已经构建过一次,make会根据变更过的源码或目标码,只编译与其相关的目标,实现关联的最小编译集合,从而达到快速增量构建。
对于make工具,makefile提供的信息是编译链条上的依赖关系,make会分析每个目标及依赖,目标和依赖最好是确定的文件,否则就是伪目标,伪依赖,这样会导致使用make二次编译时,make依然找不到这些伪目标,伪依赖,导致所有命令全被执行一遍,达不到使用make增量编译的效果,本质上和用批处理依次调用gcc命令没有区别,每次都是从头开始执行每个gcc命令,生成每个编译文件。
评价makefile写的好坏,可以看是否可以做到严格增量编译,即是否二次编译时,只会重新编译修改过的文件影响的目标。如果发现有问题,可以用make选项,–debug=a,进行调试,其会将make分析编译依赖的过程毫无保留的显示出来。
make也位于gcc安装目录下:.\MinGW32\msys\1.0\bin,该目录下有rm,mv,mkdir,cp,cat等常用文件操作命令的exe,可供规则内动作中使用。
常用的patsubst,notdir函数,vpath %.c,

注意make自动推导的坑:
make自动推导编译时,默认用CC定义的cc编译器进行编译,这可能导致报错,所以需要设置CC=gcc,后续链接就会使用gcc了
vpath是搜索依赖,并且在动作中使用自动变量$<等才有效,不适用其他场景。
编译依赖很有可能并不依赖,还是完整编译,需要注意。
多目标和多依赖间如果需要一一对应,需要用静态模式进行匹配,做到完全一一对应的增量编译,否则就是多依赖中有任何一个变更,导致多个目标被重新构建一次。
make的调用可以通过批处理来启用,做到一键构建,批处理向makefile传参时,set的环境变量必须大写,否则环境变量设置无效,makefile无法访问到该变量。

使用gdb调试可执行程序

常用的gbd相关命令如下:

命令解释
gcc -g如果想用gbd调试,则每次生成目标文件,及最后链接成可执行程序时,必须用gcc -g(gdb)选项,这样生成的exe才可以用来调试。
gbd *.exe启用gdb调试,需要用gdb 被调试程序名,来启动调试。
b/break 源码文件名:行号启用gdb调试后,可以用此命令对某文件内某行代码加断点,文件名不需要带路径。
b/break 函数名对函数加断点。如果函数名有重载,会给不同参数类型的同名函数都加断点,如果只想给某参数类型的函数加断点,需要写为b/break 函数名(形参类型)
b/break +/-偏移量当gbd执行到某断点处,关心断点后某行的情况,可通过b +偏移量设置新断点,如果是循环体中,通过b -偏移量可在前面设置新断点,下个循环就会停在那里
b/break 断点位置 断点条件上面三种相当于b/break 断点位置,有些在循环体中的断点,还希望加断点条件,以使在特定条件下暂停,断点条件格式为 if 变量==变量值
disable 断点编号用上面加断点的方式,每加一个断点,会有一个对应的断点编号,如果调试过程中,不想用/暂时不想关系哪个断点,可以通过这种方式,暂时禁掉某断点
enable 断点编号同理,如果调试过程中想恢复之前禁掉的断点,可以用该方式
r/run开始运行程序,如果执行程序有断点,程序会运行到第一个断点处并暂停。
p/print 变量名当程序暂停在某断点处,此时可以打印作用域内的临时变量和全局变量
s/step单步执行,一般用于断点后或启动后逐步调试。
c/continuous继续执行,直到遇到下一个断点暂停
c/continuous 次数连续跳过多少个断点,一般和循环内加断点配合使用
display 变量名如果在循环中调试,不希望每次通过p 变量名查看变量的值,用这种方式,每次循环就会自动打印该变量
q/quit退出gdb调试
需要说明的是,加断点在run前可以,在暂停时,也可以随时加,继续执行就会生效。

demo工程练习

上述概念虽然看起来挺多,实际用一个demo工程的构建过程便可以囊括这些知识点,并且可以通过demo练习加深对这些概念的掌握,如有需要demo工程,可以通过QQ号:2389586771联系我。

  • 16
    点赞
  • 38
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值