诡异的bug之cmake

分享一个在项目中遇到的关于cmake使用的bug, 我从源码角度来分享下遇到的问题,平时我们C++项目中大部分都是使用cmake,不过有些用法也是得过且过,编译没毛病就完事大吉,这次我遇到了一个bug,让我重新回去搞明白了一个cmake的用法

问题出现

我们整体项目是由可执行文件,so库等组成,每个业务项目组提供so库,框架组提供可执行文件负责将整个程序串联起来。平常使用ci流水线整体编译整个项目,不过我们平常调试就是修改自己的库单独编译然后替换旧的同名库来查看问题。正是我们自己调试时发生的问题。

代码

我模仿我们整体程序列出来了简单的测试程序,大致文件结构是这样

# tree
├── CMakeLists.txt
├── main.cpp
└── src_lib
    ├── CMakeLists.txt
    ├── interface1
    │   ├── interface1.cpp
    │   └── interface1.h
    ├── interface2
    │   ├── interface2.cpp
    │   └── interface2.h
    ├── interface_api.cpp
    └── interface_api.h

可执行文件就是main.cpp,然后他去链接src_lib的这个库。我们看下最外层的CMakeLists.txt

cmake_minimum_required(VERSION 3.20)
project(target)

set(CMAKE_CXX_STANDARD 14)

add_executable(target main.cpp)

add_subdirectory(src_lib)
target_link_libraries(target PUBLIC
        src_lib
        )

是不是极其简单,就是使用main.cpp,然后去链接src_lib库。
然后再看下src_lib的CMakeLists.txt

cmake_minimum_required(VERSION 3.20)
project(src_lib)

set(CMAKE_CXX_STANDARD 14)

add_library(src_lib SHARED interface_api.cpp)

target_include_directories(src_lib PUBLIC ./)

option(ENABLE_INTERFACE1 "" ON)

if (ENABLE_INTERFACE1 STREQUAL "ON")
    target_sources(src_lib PUBLIC
            interface1/interface1.cpp
            )
    target_compile_definitions(src_lib PUBLIC ENABLE_INTERFACE1)
else()
    target_sources(src_lib PUBLIC
            interface2/interface2.cpp
            )
    target_compile_definitions(src_lib PUBLIC ENABLE_INTERFACE2)
endif()

可以看到src_lib是一个动态库,会将interface_api.cpp编译进来,然后我们程序中可能会根据不同开关来指定要编译不同的文件,这里如果ENABLE_INTERFACE1开关开就去编译interface1.cpp,否则就去编译interface2.cpp。
另外interface_api.cpp这个文件就是很简单的去调用interface1或者interface2:

// interface_api.cpp
#include "interface_api.h"

#ifdef ENABLE_INTERFACE1
#include "interface1/interface1.h"
#else
#include "interface2/interface2.h"
#endif

void createInterface() {

#ifdef ENABLE_INTERFACE1
    interface1 interface;
#else
    interface2 interface;
#endif
}

可以看到如果ENABLE_INTERFACE1那么就创建interface1对象,否则创建interface2对象。我分别在interface1和interface2构造函数中打印了一下。

// interface1.cpp
interface1::interface1() {
    std::cout << "interface1" << std::endl;
}

// interface2.cpp
interface2::interface2() {
    std::cout << "interface2" << std::endl;
}

在main函数中就是调用了一下src_lib的函数:

#include 

#include "src_lib/interface_api.h"

int main() {
    createInterface();
    return 0;
}

代码比较简单,就是main函数调用库中的一个函数,而这个库会根据不同开关编译不同的文件。我们看下输出:

# cmake -DENABLE_INTERFACE1=ON ..
# make -j8
# ./target
interface1

# cmake -DENABLE_INTERFACE1=OFF ..
# make -j8
# ./target
interface2

问题出现

这时在开这个interface2的情况下,我打算改一下interface2中的代码:

// interface2.cpp
interface2::interface2() {
    std::cout << "interface2 yoyo" << std::endl;
}

但是这时我只是编译src_lib这个库,替换之前的src_lib库:

# make src_lib
# ./target
interface2

我改了代码,重编编库,然后替换,输出却还是之前记录。这如果在线上环境,也够排查半天了,首先可以编过,问题不明显,可能一下子都看不出来是改的代码有问题,还是没有执行修改的代码。

问题排查

以下也是我的排查流程:

  • 首先我们并不确定是不是我们改的代码是不是有问题,一连几次替换新的库都没有奏效,我们就打印一条日志确认下是不是执行到这里了。结果发现日志还依然是之前的记录,这里可以得出来结论:程序并未运行最新的代码。
  • 这个结论还是很重要,能正常运行,但是又没有运行新的库的代码,那么可以使用ldd命令来看下可执行文件链接库是不是最新的
# ldd target
  linux-vdso.so.1 (0x00007ffede9a0000)
  libsrc_lib.so => /root/target/build/src_lib/libsrc_lib.so (0x00007fb626fe9000)
  libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007fb626c54000)
  libm.so.6 => /lib64/libm.so.6 (0x00007fb6268d2000)
  libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007fb6266ba000)
  libc.so.6 => /lib64/libc.so.6 (0x00007fb6262f5000)
  /lib64/ld-linux-x86-64.so.2 (0x00007fb6271eb000)

可以发现确实是链接最新的库,有的机器或者板子没有ldd命令,可以使用cat /proc/[pid]/maps查看该可执行链接的所有的库。

  • 排查到确实链接的是我们最新修改的库,到这里确实感觉这是一个很奇怪的bug了。这里还是稍微歇一歇,喝一杯茶,上个厕所,考虑下为啥会出现这样的问题,程序可以正常运行,库也链接了正常的库,但是却没有运行这个库上的代码。那么猜一下,肯定是运行了别的地方的代码,且这个代码没有更新。明确一点说就是整个程序中不只最新的库中有这块代码,别的库或者可执行文件也拥有这块代码。
  • 虽然看着不合理,但也是最合理的解释了,我们去验证下猜想也是很简单的。
# readelf -p .rodata src_lib/libsrc_lib.so | grep interface
[     1]  interface2 yoyo

我们使用readelf 命令来查看libsrc_lib.so中的内容,-p的意思是string输出,.rodata表示输出只读段,关于程序在内存中布局这里不多讲,elf文件和内存段相关可以参考我之前的文章,简单来说就是程序中字面量(这里输出的字符串)会放到只读数据段。那么我们去这个段中去查询就可以了,这里可以看到输出也是最新的打印。我们去看下可执行文件中会输出什么:

# readelf -p .rodata target | grep interface
[    12]  interface2
  • 如此可以看到确实是我们猜想的那样,那么这样一来可以确定问题出现在编译时,只能回去看下cmake的写法了。
  • 经过控制变量等系列操作,发现了target_sources这个指令写错了,当我们写成
 target_sources(src_lib PRIVATE
            interface1/interface1.cpp
            )

就可以了,原来PUBLIC表示依赖这个project的库或者可执行文件也会将interface1.cpp编译进来。

  • 然后我们把CMAKE_VERBOSE_MAKEFILE开关打开,进步确认target是不是会把interface1.cpp编译进来:
# cmake -DCMAKE_VERBOSE_MAKEFILE=ON -DENABLE_INTERFACE1=ON ..
# make -j8
...
[100%] Linking CXX executable target
/usr/bin/cmake -E cmake_link_script CMakeFiles/target.dir/link.txt --verbose=1
/usr/bin/c++ CMakeFiles/target.dir/main.cpp.o
CMakeFiles/target.dir/src_lib/interface1/interface1.cpp.o 
-o target  -Wl,-rpath,/root/target/build/src_lib src_lib/libsrc_lib.so
...

可以看到在链接target时会把interface1.cpp.o 这个文件也链接进来,这就导致了我们遇到的问题,一个工程中有多个代码的副本。

cmake相关

关于对于刚刚cmake的讲解不是很详细,这里再来讲述下:

target_sources(
   [items1...]
  [ [items2...] ...])

中的INTERFACE,PUBLIC,PRIVATE关键字表示的一个范围,target_include_directories,target_compile_definitions等都有同样的概念。

PUBLIC:
当将源文件与目标关联时,PUBLIC关键字会将这些源文件的属性公开给所有依赖于该目标的其他目标。
这意味着其他目标可以访问这些源文件以及与它们相关的编译器选项和编译定义。
PUBLIC属性会传递给依赖于当前目标的目标。

PRIVATE:
PRIVATE关键字用于指定源文件的属性仅在当前目标内部可见,不会传递给依赖于当前目标的其他目标。
这意味着其他目标无法访问与这些源文件相关的属性,这些源文件对当前目标的编译是私有的。

INTERFACE:
INTERFACE关键字用于指定源文件的属性应该在当前目标和依赖于它的其他目标之间共享,
但不在当前目标内部可见。
这允许将属性共享给依赖项,但不会影响当前目标本身。
INTERFACE属性不会传递给当前目标。

这是chatgpt的解释,我再补一句,PUBLIC就是依赖该目标会看到他所定义的属性,PRIVATE则不会,INTERFACE确是就是给依赖自己的目标看到定义的属性,自己看不到。以target_compile_definitions为例,如果是PUBLIC,源项和被依赖的target都会知道定义来哪些宏,PRIVATE则是只有被依赖项知道,INTERFACE则是只有源项知道定义了哪些宏。

总结

本文展示了一下工作中遇到的bug以及bug整体的排查流程,和一些相关的手段,也是比较常用的,大家也可以记录到自己的小本本上。

你可能感兴趣的:(bug,bug,c++)