最近需要用c++写一些项目。以前c或者c++以及c#的主要使用范围都是:作业、小项目、刷题、面试考试,基本上都是单个文件或者单个项目就能解决的,也很少有链接外部库等比较工程的操作。但是现在要做开发就不能这样了,就得有体系。

一开始是打算在windows上做的,但是遇到诸多麻烦,所以最后还是老老实实在linux(比如ubuntu)上进行的开发,由于可以使用VMWare加NAT转发,所以可以在windows上远程连接,还是比较方便的。

找不到头文件

发生此问题时,环境位于windows。虽然最后还是没有在windows上开发,但是还是记录一下具体过程。

使用vscode需要配置对应的编译器,例如mingw32的gcc或者g++.

如果已经配置了但还是找不到,就顺藤摸瓜找到最底层的那个依赖的头文件是哪个:

  • 如果是一个自己写的头文件,那说明include path有问题,修改vscode配置中的这一项。
  • 如果是一个系统提供的头文件,那说明对应的依赖可能没安装。

比如我看到最终报错是pthread.h没安装,如果是使用的Visual studio那就应该不会有这个问题,因为它应该安装时自己配了;但是如果使用vscode,使用自己下载的mingw,那么这个库可能没下载。打开MinGW Installer(它的exe名称是guimain.exe),选择对应的pthread库,然后再在左上角的installation里面选择apply changes就安装好了。需要重启vscode,就能看到没有红色波浪线了。

总的来说,如果要在windows做长期的项目开发,还是Visual Studio这种比较重的IDE更好一点,java开发其实也是一样的,也得靠IDEA。

CMake与Make

这两个东西基本上绕不开。众所周知c++是一种编译型的语言,不像python之类的是一边解释一边执行。如果是使用Visual Studio之类的IDE,点一下小三角就能运行了,此时自然不用关心细节,并且这些都有自己的解决方案管理,比如Visual Studio的sln文件,或者Qt项目自己的等等。然而,vscode则没有这些。以本次做的TsFile项目为例,他是使用CMake来管理的。

gcc/g++

要编译c++文件,基本绕不开gcc/g++之类的编译器。他们会对文件进行“翻译”和“链接”,这里就不过多阐述原理了。但是一句一句指定应该编译哪些文件是很麻烦的,所以需要一些管理工具。

Make

可以使用Makefile来指定Make应该如何操作。如果是在windows,make这玩意一般是MinGW32-make或者其他之类的可执行文件,在linux就直接是make就行。make能够把gcc/g++的编译过程进行进一步梳理,这样就可以简化很多流程。但是,在Makefile中仍然还是需要指定o文件编译之类的过程,因此还是不够优雅。此时就需要CMake出场。

CMake

CMake实际上就是把编译过程进行了进一步管理。比如如下的文件就能很好的定义一个项目的编译:

# CMakeLists.txt
cmake_minimum_required(VERSION 3.10)
project(examples)
add_executable(examples examples.cpp)

这个代码实际上就是指定了要求什么CMake版本、项目的名字是什么、需要把什么文件编译为可执行文件,很简洁吧。而CMake在实际编译过程中,会在build文件夹下自动生成Makefile文件,具体的编译实际上是通过make命令根据Makefile文件进行编译的。

使用vscode调试

写代码,总不能不会调试吧。只会用命令行编译,然后运行可执行文件,那很可能无法定位bug位于哪里。在调试的时候,知道错误出现在哪一行代码、出错原因是什么,都是很重要的。既然不想用Visual Studio那么重的IDE,选择了vscode,那就得忍受手动配置这些玩意的痛苦。

首先,要解释一下C++插件和CMake插件是两个不同的玩意。

C/C++插件
CMake插件

C++插件

这些插件主要是用于高亮语法之类的。对于单个文件,他们尚且还能配置成比较好用的模样,但是对于较大的项目就比较麻烦。比如launch.json配置的很多gdb调试任务,并不会关心所执行的文件是否为最新的。比如看看这段:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "(gdb) examples.cpp启动",
            "type": "cppdbg",
            "request": "launch",
            "program": "${workspaceFolder}/build/examples",
            "args": [],
            "stopAtEntry": false,
            "cwd": "${workspaceFolder}",
            "environment": [],
            "externalConsole": false,
            "MIMode": "gdb",
            "setupCommands": [
                {
                    "description": "为 gdb 启用整齐打印",
                    "text": "-enable-pretty-printing",
                    "ignoreFailures": true
                },
                {
                    "description": "将反汇编风格设置为 Intel",
                    "text": "-gdb-set disassembly-flavor intel",
                    "ignoreFailures": true
                }
            ]
        }
    ]
}

其中比较重要的其实就俩:"program"字段指定了要用gdb运行哪个文件,"cwd"指定了程序在哪个目录运行。乍一看他能调试,但是如果对源文件有修改,直接点小三角它可不会重新编译哦,而是还在使用旧文件进行调试,这能忍吗?

这个文件,c_cpp_properties.json很重要,它配置了一些外部的头文件和源代码,以及使用的c++标准。如果头文件标红,就好好检查一下"includePath"吧。

{
    "configurations": [
        {
            "name": "Linux",
            "includePath": [
                "${workspaceFolder}/**",
                "/home/ubuntu/Documents/GitHub/tsfile/cpp/src",
                "/home/ubuntu/Documents/GitHub/tsfile/cpp/third_party/antlr4-cpp-runtime-4/runtime/src/"
            ],
            "defines": [],
            "compilerPath": "/usr/bin/gcc",
            "cStandard": "c17",
            "cppStandard": "gnu++17",
            "intelliSenseMode": "linux-gcc-x64"
        }
    ],
    "version": 4
}

CMake插件

说了这么多,还只是看起来好看,并不能实际优雅地运行。而CMake插件就能做到这一点:当你修改了某些代码,按一个按钮就会自动编译,然运行起来供你测试;而且如果出现了问题,还定定位到是源代码的哪个位置。

首先介绍的是右键配置的功能:右键一个CMakeLists.txt,会显示“生成所有项目”和“配置所有项目”,这两个功能...emm还在研究这么用,总之很难用,建议不要碰。

刚刚不是说能够一键调试吗?这个入口比较隐蔽,看下面的状态栏(没错,这么常用的功能居然做在这么小的地方太可恶了)

CMake状态栏

好吧我猜你肯定看不清,它就是这么小,这是放大之后的图:

CMake状态栏放大

这里就是CMake插件的核心功能了。

  • CMake:[Debug],这个按钮指的是编译类型,你可以选择Debug或者Release
  • 工具包,这玩意就是g++配置什么的,一般不用管
  • 生成,这玩意你点一下他就开始编译项目,应该和右键菜单的“生成所有项目”类似
  • [all],这玩意是指生成的目标。
    • all自然就是指的生成全部目标
    • 如果你有一些子项目、模块什么的,可能会有子CMakeLists,可以单独生成对应的
  • 小虫虫,就是最常用的调试按钮啦
    • 点一下就进入调试了和c++插件的点小三角调试类似
    • 不同之处在于,它在调试前,会先进行编译,保证源文件更改反映到程序上
  • 小三角,就是直接运行可执行程序啦。其他注意事项类似调试。
  • [examples],这个是项目名称
  • 其他功能,例如运行CPack、运行工作流还没用过,学会了再回来填坑

重要注意事项

尽量不要项目套项目。比如TsFile项目是有一些样例的,但是它的examples子项目直接就在tsfile/cpp项目里面,如果你打开的文件夹不对,上述调试步骤很可能出现找不到源文件的问题。所以最好确保自己的vscode工作区就只有这一个项目,然后在CMakeLists.txt以及c_cpp_properties.json里面指定外部依赖。

交叉编译

没错,这是非常恶心的事情。。。交叉编译!!!虽然上面推荐了需要使用linux系统进行编译,比如使用ubuntu作为平时的开发环境,但是还不够哇!!!这是因为最终程序是需要在嵌入式硬件的arm架构上运行的,而无论是自己的windows还是ubuntu都是在x86_64上的,两者的指令集不同。所以,必须要使用交叉编译。

注意,交叉编译的实际过程都是在主机(host)完成的,此时还并没有把程序传输到嵌入式硬件。

准备工作

想要运行自己的项目?它依赖于TsFile。那么,就首先要交叉编译TsFile。此处,ChatGPT还是用处非常大,他给出了较为详实的步骤。需要如下准备:

  • 工具链,toolchain。你需要这个工具包来完成该操作。他是运行在host的x86环境上的,但是输出的程序却是arm的。
  • 配置cmake。你需要告诉cmake使用上述工具链进行编译,为此需要走超级长的弯路。

交叉编译工具链

工具链下载

在这里下载工具链:https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads

文件非常之多,这里需要解释一下应该下载哪个文件。需要注意以下几点:

  • 学会读架构名称和操作系统名称。
  • 知道host类型。也就是你用来做开发的平台类型。
  • 知道target类型。也就是你目标程序运行的平台类型。

如下是开发平台名称(host类型):

开发平台名称/host类型具体含义
Windows (mingw-w64-i686)32位windows操作系统
Windows (mingw-w64-x86_64) 64位windows操作系统
x86_64 Linux使用x86架构的Linux系统
aarch64 Linux使用aarch64架构的Linux系统
macOS (x86_64) 使用x86芯片的Mac
macOS (Apple silicon)使用苹果芯片的Mac

如下是目标运行平台的名称(target类型):

标运行平台的名称/target类型具体含义
AArch32 bare-metal target (arm-none-eabi)没有操作系统支持的裸机编程目标。
AArch32 GNU/Linux target with hard float (arm-none-linux-gnueabihf)基于 GNU/Linux 系统的 ARM 目标,且支持硬件浮点运算(gnueabihf)。
AArch64 bare-metal target (aarch64-none-elf)64 位的裸机目标,不依赖任何操作系统。
AArch64 GNU/Linux target (aarch64-none-linux-gnu)64 位的 ARM 架构,运行 GNU/Linux 系统。
AArch64 GNU/Linux big-endian target (aarch64_be-none-linux-gnu)使用大端法的64 位的 ARM 架构,运行 GNU/Linux 系统。

所以最后我的选择是:arm-gnu-toolchain-13.3.rel1-x86_64-arm-none-linux-gnueabihf.tar.xz,其含义是:我的host是x86_64的Linux主机(Ubuntu),而目标是一个支持硬件浮点运算的GNU/Linux 系统。

如果不知道目标是什么样的,可以使用uname -a进行查看或者使用cat /proc/cpuinfo查看。

工具链安装

解压它,添加对应的执行路径即可。其实主要在CMake内配置,所以不加到path问题也不太大。注意下面的命令要换成自己的路径,加入到~/.bashrc中。

export PATH=/home/ubuntu/software/arm-gnu-toolchain-13.3.rel1-x86_64-arm-none-linux-gnueabihf/bin:$PATH

配置CMake

在项目目录下新建toolchain-arm.cmake,它的具体路径是:tsfile/cpp/toolchain-arm.cmake,这个文件用来配置toolchain。

# toolchain-arm.cmake
# 配置交叉编译工具链,使用 ARM 编译器
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)

# 自己下载的, 最终使用的是13.3.rel1
set(tools /home/ubuntu/software/arm-gnu-toolchain-13.3.rel1-x86_64-arm-none-linux-gnueabihf)
set(CMAKE_C_COMPILER ${tools}/bin/arm-none-linux-gnueabihf-gcc)
set(CMAKE_CXX_COMPILER ${tools}/bin/arm-none-linux-gnueabihf-g++)
set(CMAKE_FIND_ROOT_PATH ${tools}/lib)
include_directories(${tools}/include)

在所使用的cmake命令中通过参数指定所使用的工具链。这里我是编译TsFile项目,它有一个写好的自动build脚本。所以我可以直接修改build.sh(最好是复制一份并且使用不同的build路径把x86编译和arm编译分开)。所使用的参数如下:

-DCMAKE_TOOLCHAIN_FILE=toolchain-arm.cmake

使用该参数之后就可以使用指定的工具链进行编译了。所以,我这里使用的是直接利用命令行编译,没有配置vscode脚本。其实也可以通过vscode的json配置文件指定cmake进行交叉编译。

交叉编译依赖

如上所述,交叉编译需要输出arm架构的结果,那么它自然也就需要一些arm架构的依赖。比如说你在一台ubuntu(x86)上交叉编译一个嵌入式设备(arm7)的可执行文件,而它依赖libuuid这个库,那么libuuid这个库就需要是arm版本的而不是x86版本的。

安装x86版本的uuid库很简单:sudo apt-get install uuid-dev。但是arm版本的就需要自己下载编译才行了,非常坏!

在这里下载libuuid源码:libuuid 项目

或者直接下载链接是:http://nchc.dl.sourceforge.net/project/libuuid/libuuid-1.0.3.tar.gz

下载好之后,先编译(make)再安装(make install)。具体命令如下:

./configure CC=/home/ubuntu/software/arm-gnu-toolchain-13.3.rel1-x86_64-arm-none-linux-gnueabihf/bin/arm-none-linux-gnueabihf-gcc --host=arm-linux --prefix=/home/ubuntu/software/arm-gnu-toolchain-13.3.rel1-x86_64-arm-none-linux-gnueabihf
make
make install

此处CC指定的是gcc编译器路径,--host指定了目标是arm架构的linux,而--prefix指定了它的输出目录。它会在输出目录下输出libinclude两个文件夹。

然而,如果你只是复制了输出的libinclude两个文件夹到工具链目录下,很可能编译出错,会由ld提示“没有那个文件或目录”。这其实就是没能找到文件。这其实和ld程序寻找库地址的方式相关。

如下是我的交叉编译工具链目录,注意,省去了其他与该问题不太相关的文件夹:

/home/ubuntu/software/arm-gnu-toolchain-13.3.rel1-x86_64-arm-none-linux-gnueabihf/
├── 13.3.rel1-x86_64-arm-none-linux-gnueabihf-manifest.txt
├── arm-none-linux-gnueabihf
│   ├── bin
│   ├── include
│   ├── lib
│   └── libc
├── bin/
├── include
│   ├── gdb
│   └── uuid
├── lib
│   ├── bfd-plugins
│   ├── gcc
│   ├── libuuid.a
│   ├── libuuid.la
│   ├── libuuid.so -> libuuid.so.1.0.0
│   ├── libuuid.so.1 -> libuuid.so.1.0.0
│   ├── libuuid.so.1.0.0
│   └── pkgconfig

注意到问题所在了吗?工具链的根目录含有lib和include文件夹,而文件夹arm-none-linux-gnueabihf下同样含有lib和include文件夹。犯错的原因就在于只把交叉编译的uuid相关文件复制到了工具链根目录下,而没有同时复制到文件夹arm-none-linux-gnueabihf下。

链接库

某些时候,你可能需要链接一个外部库,例如sqlite3. 这类库通常有简便的安装方法。需要注意,使用sudo apt install sqlite3只会安装该应用,但是不会安装依赖库,需要安装libsqlite3-dev才能正常引用相关库。

在安装完毕之后,你可能发现VSCode中,相应的红色波浪线消失了,并且Ctrl+点击该文件能够跳转到该文件了,觉得大功告成了——然而编译失败了。这是因为你在使用CMake编译。

请时刻注意:VSCode的高亮表现和实际编译的过程是分离的、不相关的。VSCode配置的界面依赖于c_cpp_properties.json以及全局配置之类的来找到对应的库文件,找到就能实现函数跳转等方便的功能——但这个和CMake的编译无关。换句话说,CMake编译时可能仍然找不到该文件,因此需要特别注意。

在遇到类似问题时,我搜索网上的相关问题,比如这篇,发现都是这么说的:在使用g++编译器时加上-lsqlite3参数,例如g++ main.cpp -lsqlite3。换句话说,它使用一个参数指定了该项目依赖于sqlite3这个库。类似的,使用CMakeLists时也需要这样指定,只是形式不同:

target_link_libraries(main sqlite3)

这样就把sqlite3这个库链接到可执行程序main上了。

静态编译

有时,目标的嵌入式平台不包含所依赖的so链接文件,这很常见。然而,你又不想一个个看缺少哪些文件,甚至平台不支持ldd命令以及file命令,让你的排查很困难。此时,你可以使用静态编译。静态编译的原理非常简单:输出的可执行文件包含了所有依赖的库。

在CMAKELists中使用如下命令告诉编译器调用g++时加上-static参数,达到静态编译的目的:

# 强制使用静态链接
set(CMAKE_EXE_LINKER_FLAGS "-static")

注意,这样编译出的文件大小会大非常多。一个简单的helloworld程序使用动态编译,大小可能只有100KB左右;而使用静态编译,其大小可以达到10MB,翻了一百倍。

CMakeLists编写

编写时有一点需要特别注意,那就是调用顺序。project命令会自动寻找编译器,因此,请务必确保toolchain的指定位于该命令之前,否则会发现使用了错误的g++导致不能正常编译。

此处列出一些常用命令,以及推荐的执行顺序顺序。

1. project 命令

project() 命令用于设置 CMake 项目的基本信息,如项目的名称、版本、支持的编译语言等。这是 CMake 配置过程中必须要调用的命令,通常是 CMake 文件的第一条命令之一。但是,类似于交叉编译的情景,指定工具链应该在project命令之前。

2. set 命令

set() 用于设置 CMake 变量的值。这些变量可以是编译器的路径、库的路径、选项等。

set(CMAKE_TOOLCHAIN_FILE ${CMAKE_SOURCE_DIR}/toolchain-arm.cmake)
  • 此处CMAKE_TOOLCHAIN_FILE 是 CMake 的一个标准变量,它指定了工具链文件的路径。
  • 这个文件告诉 CMake 如何配置交叉编译环境,包括使用哪个编译器、链接器等。
  • 如果需要交叉编译,必须在 project() 之前或至少在编译器选择之前设置工具链文件。

3. include_directories 命令

include_directories() 用于添加额外的头文件目录到 CMake 项目的编译器搜索路径中。CMake 会将此目录添加到编译器的 -I 选项中,确保编译时可以找到你指定的头文件。在交叉编译时,通常会使用工具链的头文件,因此这条命令对确保工具链工作至关重要。

  • 在 CMake 中,通常会把系统和库的头文件目录放在 include_directories() 中。
  • include_directories() 应该尽量避免放在 add_executable()target_link_libraries() 后面,因为这些命令才会进行实际的编译链接。

4. find_library 命令

find_library() 用于搜索库文件,并将其路径存储到变量中。

find_library(my_tsfile_lib NAMES tsfile PATHS ${SDK_LIB_DIR_RELEASE} NO_DEFAULT_PATH REQUIRED)
  • my_tsfile_lib 是一个变量,存储找到的库的路径。
  • NAMES tsfile 表示要查找名为 tsfile 的库。
  • PATHS ${SDK_LIB_DIR_RELEASE} 指定搜索库的路径。
  • NO_DEFAULT_PATH 表示不要使用 CMake 默认的库搜索路径。
  • REQUIRED 表示如果找不到库文件,CMake 将报错。

find_library() 应该放在链接库之前,确保库路径被正确找到。

5. add_subdirectory 命令

add_subdirectory() 用于将一个子目录添加到当前项目中,并且会在该子目录中执行 CMake 配置。它们允许你组织项目代码,将大型项目分成多个模块或子项目。它可以让你将项目划分为多个子模块或子目录,进行独立管理。

6. add_executable 命令

add_executable() 用于定义一个可执行文件,并指定源文件。例如:add_executable(main main.cpp). 这个命令定义了一个名为 main 的可执行文件,源文件是 main.cpp。注意,add_executable() 应该在定义可执行文件之前进行。如果需要链接外部库,必须在这之后使用 target_link_libraries()

7. target_link_libraries 命令

target_link_libraries() 用于将库文件链接到指定的目标(如可执行文件或库)。

target_link_libraries(main ${my_tsfile_lib})
target_link_libraries(main cpp_examples_obj multi_flush_exp_obj util_obj)
target_link_libraries(main sqlite3_exp_obj sqlite3)
  • 第一个命令链接了外部的 tsfile 库。
  • 第二个命令链接了 cpp_examples_objmulti_flush_exp_objutil_obj 目标,它们是在 add_subdirectory() 添加的子项目中定义的目标。
  • 第三个命令链接了 sqlite3 相关的库。

合理的调用顺序

为了避免此前遇到的编译器未正确设置问题,推荐按照如下顺序来组织 CMakeLists.txt

  1. 设置工具链和交叉编译器(如果需要): 这应该是 CMake 配置中的第一步,确保 CMake 在任何其他配置或编译操作之前已经了解如何使用交叉编译器。
  2. 调用 project() 命令: 在设置工具链后,调用 project() 来设置项目的基本信息。
  3. 包含目录和库路径设置: 使用 include_directories()find_library() 来指定头文件和库文件路径。
  4. 添加子目录: 使用 add_subdirectory() 来将子项目添加到当前构建中。
  5. 创建可执行文件: 使用 add_executable() 创建可执行文件。
  6. 链接库: 使用 target_link_libraries() 将所需的库文件链接到目标可执行文件。