工程报错的时候,不少程序员会陷入遇到问题,反复搜索各种博客,问答论坛,长时间无法解决的情况,尤其是涉及到一些外部代码,静态库,动态库等概念。本文会尝试介绍C/C++必备的工程基础,以及解决问题的思路,工具等。

工程基础

首先C/C++下有四种类型的工程:控制台应用程序,窗口应用程序,静态链接库,动态链接库。前两种都属于应用程序,所以在写代码的时候,边界不会很明确,比如一个控制台应用程序类型的项目,也是可以调用SDL等窗口库函数跑起来的。

重点是后两种类型理解,对于库类型的项目,它的初衷是把某些特定的常用功能封装起来,以符号(函数或者类)的方式提供给上层,这样子上层写代码就可以专注在业务逻辑上。从实际写代码来说,如果所有功能都是源码的形式编译在一起,会因为语言版本升级等情况,维护所有的代码。库的出现,使得应用程序可以使用不同语言版本生成的库文件,项目版本更新可以跟库版本更新分离。

在C/C++中,库又分为静态库和动态库两种类型。使用静态库,需要库的头文件以及相应的静态库文件;使用动态库,除了需要库的头文件以及相应的静态库文件,还有额外的动态库文件。

另外C和C++的符号生成规则是不一样的,对于函数来说,C函数符号就是函数名本身,再带有返回值类型,参数入栈的先后顺序,参数类型。而C++的符号,因为函数重载的支持,所以单纯使用函数名无法区分,这个时候会有一个名称重整的步骤,把它拼接上一些奇怪的“乱码”。所以导出符号的时候,如果是其他语言要使用,通常会在C++代码里写明extern “C”按C风格导出符号。比如C#就不支持C++符号的使用。

静态的意思是在编译上层应用代码的时候,上层代码就会开始查找库的符号并集成到最后的exe中,所以只链接静态库的应用程序是不需要任何非系统dll就能启动的;而动态的意思是,程序在启动了以后才会开始从磁盘的动态库搜索路径下加载动态库文件,并查找符号,进行调用。这里注意,动态加载的时机点有两种选择,一个是程序自动在调用该库功能的时候,一个是由代码中通过系统API进行主动加载。加载后的动态库,也可以主动从程序中卸载。

所以两种库类型的优缺点也很明显,静态库因为程序生成后不需要附带任何文件,所以发布程序的时候需要安装的文件数量少,也没有动态库加载的io开销,但是程序本体集成了所有使用的符号会大一点。

动态库则是发布的时候需要把依赖的动态库文件放在程序同级目录下可以搜索到,或者安装到系统盘中,程序第一次跑到某些功能的时候,会有磁盘IO加载文件,好处是程序本体会小一些,同时支持卸载,比如3ds Max,Maya的插件就是一个动态库文件。

工程通用配置

无论是以上何种类型的工程,搭建完后,常见的一些配置有:

  • 目标CPU硬件架构
    • x86
    • x64
    • arm
  • 头文件包含目录, includedirs
    • 本工程的某些模块文件夹结构比较深,用于简化#include编写
    • 依赖库存放在其他目录,需要包含头文件得知API声明
  • 库文件包含目录, libdirs
    • 指定依赖的静态库文件的搜索路径
  • 链接库名称, libs
    • 指定依赖的所有静态库文件名称各自是什么
  • 预处理器宏,defines
    • 会在编译开始的时候作为该项目全局生效的宏
    • 可以控制一些#ifdef做判断的宏来开关一些特定功能
    • 常见的比如当前平台的类型,某些模块是否需要跳过
  • 编译选项, flags
    • 优化等级,比如Debug版本关闭优化,Release打开优化
      • 内联优化
      • 返回值优化
      • 短字符串优化
      • 移动语义优化
      • 未使用变量优化
      • 循环内变量优化
    • RTTI开关,指的是Runtime Type Identification,运行时类型识别,其实就是从对象反推类型是什么
      • 相关特性
        • dynamic_cast
        • typeid
        • type_info
    • CLR
      • 托管C/C++,微软官方的C#交互方案,非p/invoke
    • Edit and continue
      • 可以在调试模式下改完代码即时生效
    • Just my code
      • 调试时自动跳过库代码的单步,折叠相关调用堆栈。这个功能需要对方库有开这个简称JMC的编译开关
    • 浮点数运算
      • 更快还是更精确

API and ABI

除了API之外,还有一个应用程序员很少关心的概念,ABI。我们知道API是Application Programing Interface的缩写,也就是面向文本编程的接口。而ABI类似的,是Application Binary Interface的缩写,指的是二进制层面的接口。而程序与程序互相沟通,协作,也是通过编译后的二进制数据来沟通。所以API之于程序员,ABI之于应用程序。

为什么要关心ABI的问题,也类似为什么要关心API。程序员A和程序员B协作,A给B了一个API,B在一开始调用失败了,发现是函数声明没对上,需要符合A的所有参数要求;然后B使用成功了,但遇到一个小bug,希望A进行修复,而A呢,在修复的时候给API的声明多加了一个参数。这个时候就需要A主动告诉B,B也需要改一下调用处的代码,一起把修改应用上去。

同理,库X和应用程序Y协作,X给了Y一个ABI,那么Y就应该符合X的要求来给好参数,但注意这个是编译过后的,所以一个常见的问题是某些库使用了非内建类型来作为函数参数,很有可能会因为跨编译器等情况而引起问题,比如STL的实现每个编译器是有差别的,所以尽量使用内建类型作为库API的设计,以便生成后的ABI更加兼容。在库进行版本迭代的时候,也需要注意不要轻易修改已经存在的API代码,如果实在要改,也需要测试改动前后的ABI是否是一致的,或者是兼容的。

另外,有兴趣也可以进一步了解DirectX用到的COM技术,但因为COM编程的需求特别少,所以沦为了了解COM设计模式有助于理解某些代码,比如class类型的GUID,查询COM接口等。

库调试

在开发C/C++应用程序或者库的时候,有一些很麻烦的问题,比如编译代码的时候Link Error,程序启动以后找不到动态库崩溃,加载动态库失败崩溃。

对于Link Error来说,常见的情况是找不到xxx符号。解决方案是,我们需要根据报错定位以下几个信息:这个符号的名称空间,类名/函数名,返回值,参数列表是什么,在项目文件里是否能搜索到该符号的调用。

如果项目确实是需要调用又找不到的情况,那通常需要检查库工程的编译,是否项目工程的头文件与库的头文件已经不对应了,或者说库工程编译的时候跳过了该符号的编译。如果是项目没有使用,但还是抛出符号找不到的情况,这个通常是编译链接选项没有开好优化,不需要的符号是不应该查找的,或者也可以去库工程里屏蔽掉这个符号相应的使用逻辑。

对于找不到动态库崩溃或者加载动态库崩溃的情况,有时候会比较恶心。因为动态库本身也会对其他动态库有依赖,如果对系统动态库产生依赖,这个是很正常的,通常不会造成问题,除非是比如Windows SDK不一致且两者不兼容,但对于非系统动态库的依赖,则有可能出现循环依赖,依赖过深等难以排查的噩梦。

所以,排查动态库相关问题,通常会借助工具来告诉我们某个动态库的依赖树状结构是什么样的,有没有形成环。推荐的依赖树查看工具是 https://github.com/CatDogEngine/Dependencies , 相对于DependencyWalker会在某些新系统下误判循环依赖的bug,它对Win10,Win11的支持更好。另外,我们也可以通过监视进程启动加载dll的过程来定位问题,工具是微软提供的Process Monitor,https://docs.microsoft.com/en-us/sysinternals/downloads/procmon