前言
最近项目中遇到一个需求:将 C++ 程序 (不是 ROS node,只是普通的 C++ 程序)中的变量发布到 ROS topic 上,以便 ROS 中的其他 node 进行后续处理。
原 C++ 程序比较复杂,我们希望尽量少修改原程序,只要输出其中某些变量的值即可,不要大规模改写成 ROS node 的形式,不要新建 ROS package。
在以往使用 ROS 的过程中,我们一般是借助 catkin 来编译 ROS node C++ 程序。这可以看成是将 C++ 程序放入 ROS 框架中,以 ROS 的标准形式来编译。现在这个项目需求正好相反,我们要将 ROS 的相关库(library)嵌入到普通 C++ 程序中,采用 C++ 标准的 cmake 方式来编译。这就要求我们对 cmake 和 catkin 的关系有比较深入的了解。
在查找资料的过程中,我们发现了一篇讲解 catkin 编译系统的文章(20210401网页失效),从最基本的命令行编译方式,到 makefile 文件编译,再到 catkin 编译,每一步发展的必要性都讲解的很清楚,看完之后,我们对 catkin 有了更深入的认识。
环境
系统环境
Distributor ID: Ubuntu
Description: Ubuntu 18.04.4 LTS
Release: 18.04
Codename: bionic
Linux version : 5.3.0-46-generic ( buildd@lcy01-amd64-013 )
Gcc version: 7.5.0 ( Ubuntu 7.5.0-3ubuntu1~18.04 )
软件信息
version :
None
正文
预安装
原文例子中使用了 hydro 版本的 ROS,现在看来比较古老了,这里替换为 melodic 版本。
- Ubuntu 18.04
- ROS melodic(base 即可)
从最简单的例子开始
首先创建一个文件夹 hello_world_tutorial,存放我们的程序
mkdir hello_world_tutorial
cd hello_world_tutorial
创建 C++ 源文件,名为 hello_world_node.cpp
:
// 为了与 ROS 交互,需要调用 ROS C++ APIs
#include <ros/ros.h>
// 标准的 C++ main 函数
int main(int argc, char** argv) {
// 该命令告诉 ROS 初始化了一个 node,名为 hello_world_node
ros::init(argc, argv, "hello_world_node");
// 在一般的 ROS node 程序中,我们会用 ros::NodeHandle nh 来启动 node 程序,
// ros::NodeHandle nh 默认会调用 ros::start() 函数,程序关闭时也会自动调用 ros::shutdown() 函数。
// 我们也可以直接通过 ros::start() 和 ros::shutdown() 来手动控制 node 的开启和关闭
ros::start();
// 显示 hello, world! 信息
ROS_INFO_STREAM("Hello, world!");
// 用 ros::spin() 保持该程序运行,一直等待处理 subscribe 的数据
// 由于该程序并没有 sub,所以就是简单的保持程序不退出而已, 直到接受到终止信号 SIGINT (ctrl-c)
ros::spin();
// 关闭 node 程序
ros::shutdown();
// 结束主程序
return 0;
}
下边将 C++ 源文件编译成可执行文件
g++ hello_world_node.cpp -o hello_world_node -I/opt/ros/melodic/include -L/opt/ros/melodic/lib -Wl,-rpath,/opt/ros/melodic/lib -lroscpp -lrosconsole
各参数含义
-I<dir>
指定头文件的搜索路径-L<dir>
指定静态库的搜索路径-Wl,-rpath,/opt/ros/melodic/lib
指定共享库的搜索路径-lroscpp -lrosconsole
指定需要链接的具体的库文件
编译之后,生成 hello_world_node 可执行文件。由于程序中生成了 ROS node,而 ROS node 需要与 ROS master 进行通讯注册,否则会报错。因此为了正常运行程序,需要先开启 ROS master
roscore
然后运行 hello_world_node
./hello_world_node
# 成功显示
[ INFO] [1561908777.116073864]: Hello, world!
上述编译方式扩展性很差,对于如此简单的 hello_world 程序,需要设置的参数已经这么多了。而且在 terminal 中书写比较麻烦,修改也不方便。
改进:使用Makefile文件进行编译
Makefile 编译方式是将上述编译命令和参数设置放入一个文件中,然后基于该文件,完成编译过程。Makefile 文件有自己的一套语法规则,可以实现批量、相对自动化的编译。
与前述 hello world 程序对应的 Makefile 文件内容如下:
# 声明要使用的编译器
CC=g++
# 声明一些变量,实际上就是对应上述搜索路径设置
CFLAGS=-I/opt/ros/melodic/include
LDFLAGS=-L/opt/ros/melodic/lib -Wl,-rpath,/opt/ros/melodic/lib -lroscpp -lrosconsole
# % 作为通配符,代表对一类满足条件的文件进行操作
# 这是由源文件 *.cpp 编译成目标文件 *.o 的操作
%.o: %.cpp
$(CC) -c -o $@ $< $(CFLAGS)
# 也可以不用通配符,具体写出要编译的文件
# 这是由目标文件 *.o 通过链接 (linking) 操作生成最终的可执行文件
hello_world_node: hello_world_node.o
$(CC) -o hello_world_node hello_world_node.o $(LDFLAGS)
对于 Makefile 的介绍,可以参考这里。
Makefile 文件的基本格式是
target: pre-req
command
即,希望生成 target 文件,依赖 pre-req 文件,通过 command 命令实现。
需要注意的是,Makefile 要求 command 那一行开头用 TAB 键缩进,不能用空格,如果出现如下报错:
makefile:...: *** missing separator. Stop
说明误用了空格键。如果你跟我一样用的是 vs code 编辑器,可以在右下角选择 Indent Using Tabs
。
将上述 Makefile 文件放在与 hello_world_node.cpp 同一路径下,然后编译
make # 或者指明某个 target 编译任务,如: make hello_world_node
Makefile 编译方式相比于刚才的命令行编译方式有如下优点:
- 在设置好 Makefile 的前提下,编译命令更简单,只需要
make
,不必每次都输入一长串命令 - Makefile 中将编译和链接分开进行,如果项目中包含多个 c++ 源文件,改动了其中的一个,只需要重新生成改动文件的目标文件 (*.o) 即可,其他源文件不需要重新编译,然后基于更新之后的目标文件,生成新的可执行文件。也就是说,如果源文件没有改变,就不会浪费时间更新目标文件。
在书写上边的 Makefile 文件时,我们依然要明确设定头文件和 library 的搜索路径。为了进一步简化这个过程,我们可以在 Makefile 中使用 pkg-config 设置搜索路径。
改进:在Makefile 中使用pkg-config设置搜索路径
实际上,library 对应的搜索路径包含在与该 library 对应的.pc
文件中,例如
roscpp library 对应的 .pc
文件为 /opt/ros/melodic/lib/pkgconfig/roscpp.pc
,里面内容如下
prefix=/opt/ros/melodic
Name: roscpp
Description: Description of roscpp
Version: 1.12.14
Cflags: -I/opt/ros/melodic/include -I/usr/include
Libs: -L/opt/ros/melodic/lib -lroscpp -lpthread /usr/lib/x86_64-linux-gnu/libboost_chrono.so ...
Requires: cpp_common message_runtime rosconsole roscpp_serialization ...
可以看出,这个 .pc
文件里面的 Cflags
和 Libs
条目就是调用 roscpp 时要设置的路径信息。我们可以通过 pkg-config 这个工具查找 roscpp.pc
文件,然后提取其中的路径信息,放入 Makefile 中,这样就避免了手动输入。例如
$ pkg-config --cflags roscpp
-I/opt/ros/melodic/include
$ pkg-config --libs roscpp
-L/opt/ros/melodic/lib -lroscpp -lpthread /usr/lib/x86_64-linux-gnu/libboost_chrono.so ...
因此,我们可以改写 Makefile 文件如下:
CC=g++
# 通过 pkg-config 设置相应的路径信息
CFLAGS=$(shell pkg-config --cflags roscpp)
LDFLAGS=$(shell pkg-config --libs roscpp)
%.o: %.cpp
$(CC) -c -o $@ $< $(CFLAGS)
hello_world_node: hello_world_node.o
$(CC) -o hello_world_node hello_world_node.o $(LDFLAGS)
然后依然用 make
命令编译文件,与前边编译方式相同,最终也是生成 hello_world_node 可执行文件。
在使用 pkg-config 时需要确保它能够找到相应的 library。pkg-config 有自己的搜索 library 的路径,存放在环境变量 PKG_CONFIG_PATH
中,可以通过 echo 命令查看
echo $PKG_CONFIG_PATH
如果我们安装完 ROS,并且运行了source /opt/ros/melodic/setup.bash
, ROS 相关的 library 对应的 .pc
文件就被加入了 pkg-config 的搜索路径。通过 pkg-config <library> 就可以搜到相应的信息。
尽管 pkg-config 简化了 Makefile 中设置头文件和 library 路径的过程,但是 Makefile 文件中后续的编译过程依然需要手动设置。另外这里手动书写的编译命令是与操作系统平台相关的,Linux 中的编译命令不能在 Windows 中使用,这就导致 Makefile 不能跨平台使用。
改进:Cmake跨平台编译方式
CMake 的一个功能是自动生成 Makefile 文件。另外,CMake 可以在 Linux 、Windows 和 Mac OS 上使用。
要使用 CMake,首先要创建一个 CMakeLists.txt 文件,包含必要的编译设置。 与上述 hello_world_node 例子对应的 CMakeLists.txt 内容如下:
# 声明 CMake API 版本
cmake_minimum_required(VERSION 2.8)
# 声明项目名称
project(hello_world_tutorial)
# 搜索依赖 library (即 roscpp) 的信息
# 与 pkg-config 功能类似,但可以跨平台使用
# pkg-config 查找 .pc 配置文件,而 find_package 查找 .cmake 配置文件
find_package(roscpp REQUIRED)
# 搜索 roscpp 中调用的头文件
include_directories(${roscpp_INCLUDE_DIRS})
# 设置待生成的可执行文件名字
add_executable(hello_world_node hello_world_node.cpp)
# 设置编译过程中 linking library
target_link_libraries(hello_world_node ${roscpp_LIBRARIES})
其中 find_package(roscpp REQUIRED)
会自动定义几个变量,包括 roscpp_INCLUDE_DIRS
,roscpp_LIBRARY_DIRS
,roscpp_LIBRARIES
。在 CMakeLists.txt 中可以直接使用这些变量。REQUIRED
参数的作用是在找不到相应 library 时停止并报错,提示
-- Configuring incomplete, errors occurred!
See also ".../CMakeFiles/CMakeOutput.log".
如果不加 REQUIRED
,则只会提示找不到 library,整个过程并不会停止,显示信息如下:
-- Configuring done
-- Generating done
-- Build files have been written to: ...
尽管显示各种 done
,由于没有找到必要的 library ,后续的编译肯定会不成功。
通过 CMakeLists.txt 进行编译时会产生一些中间文件,如果都放在 .cpp 源文件目录下,会显得很杂乱。最好单独建一个文件夹,存放这些编译文件。例如在 .cpp 源文件和 CMakeLists.txt 同一路径下新建 build 文件夹。新的路径结构如下:
├── build
├── CMakeLists.txt
└── hello_world_node.cpp
CMakeLists.txt 中的 find_package 之所以能找到相应的 library,是因为已经设置了搜索路径,存放在环境变量 CMAKE_PREFIX_PATH
中,通过 echo $CMAKE_PREFIX_PATH
可以显示当前 find_package 使用的搜索路径。在安装完 ROS 之后,source 命令会自动将 ROS 相关的 library 加入上述搜索路径中。
通过 cmake
和 CMakeLists.txt
自动生成编译文件 Makefile:
cd build # 进入刚才创建的 build 文件夹
cmake .. # 运行 cmake,它会调用上一层路径中的 CMakeLists.txt 文件
运行完上述命令以后,产生了一些新文件,路径结构如下:
├── build
│ ├── CMakeCache.txt
│ ├── CMakeFiles
│ ├── cmake_install.cmake
│ └── Makefile
├── CMakeLists.txt
└── hello_world_node.cpp
可以看到自动产生了 Makefile ,此时就可以用 make
命令编译文件了。
这里借张图展示一下 CMake 编译方式跨平台的能力 ( From: cmake-can)
到了这里,我们就已经解决了最初的项目需求:让 C++ 程序将内部变量以 ros topic 的形式发布出来。基本步骤:
改写 C++ 程序,加入 ROS 元素,如 ros 头文件,msg 头文件等,设置 ros::init, ros::NodeHandle ,pub msg 等,这些 ROS 元素可以使 C++ 程序在 ROS master 中以 ROS node 的形式注册。
- 我们原来的 C++ 程序有自己的 CMakeLists.txt 文件,在其中添加依赖的 ROS library。
- 用基本的 cmake 方式编译即可。
改进:针对ROS系统的Catkin编译方式
ROS 的 Catkin 编译系统的一个特点是将程序做成 package (称为 catkin package 或者 ROS package) 的形式,可以理解成模块化。典型的 ROS workspace 中包含 src, build, devel 三个文件夹,在分享时只需要分享 src 中的某个 package 即可,所有的编译信息都在此 package 中。一个 package 在编译时可以指定依赖于另一个 package。 另外,由于 ROS 中程序以及 library 变动比较频繁,不太适合在整个系统层面安装编译之后的文件,通过 source devel 文件中的 setup.bash 文件可以告知系统去哪里查找相应的文件,避免了系统级的安装 。
要构造 ROS package,我们首先要修改 CMakeLists.txt 文件如下:
cmake_minimum_required(VERSION 2.8)
project(hello_world_tutorial)
# 要用到 catkin
find_package(catkin REQUIRED)
# 声明该项目为一个 catkin package
catkin_package()
find_package(roscpp REQUIRED)
include_directories(${roscpp_INCLUDE_DIRS})
add_executable(hello_world_node hello_world_node.cpp)
target_link_libraries(hello_world_node ${roscpp_LIBRARIES})
另外,还需要添加一个 package.xml 文件,指明该 package 在编译和运行时依赖于哪些其他 package,同时也包含该 package 的一些描述信息,如作者、版本等。内容如下:
<package>
<name>hello_world_tutorial</name>
<maintainer email="you@example.com">Your Name</maintainer>
<description>
A ROS tutorial.
</description>
<version>0.0.0</version>
<license>BSD</license>
<!-- Required by Catkin -->
<buildtool_depend>catkin</buildtool_depend>
<!-- Package Dependencies -->
<build_depend>roscpp</build_depend>
<run_depend>roscpp</run_depend>
</package>
现在路径结构如下:
├── build
├── CMakeLists.txt
├── hello_world_node.cpp
└── package.xml
跟之前一样,进入 build 文件夹中,用 cmake + make 方式编译
cd build
cmake ..
make
编译结束之后会发现,并没有在 build 根目录下生成可执行文件。与普通的 cmake 编译不同,catkin 编译会生成一个 devel
文件夹,这里包含了生成的可执行文件,以及作为 library 使用的配置文件 .pc
,.cmake
。
对于我们的 hello_world_node package 来说,上述文件路径如下:
- 可执行文件:
devel/lib/hello_world_tutorial/hello_world_node
.pc
配置文件:devel/lib/pkgconfig/hello_world_tutorial.pc
.cmake
配置文件:devel/share/hello_world_tutorial/cmake/hello_world_tutorialConfig.cmake
当作为 library 使用时,只需要将路径 .../devel/lib/pkgconfig
添加到 PKG_CONFIG_PATH
环境变量中,或者将 .../devel
添加到 CMAKE_PREFIX_PATH
变量中。实际上,我们不需要手动设置这些环境变量,只需要通过 source devel 文件夹下的 setup.bash 文件即可,source setup.bash 不仅添加了以上两个环境变量,还有诸如 ROS_PACKAGE_PATH
,PYTHONPATH
等。
source 之后,由于该 package 加入了 ROS_PACKAGE_PATH
,此时可以通过 ROS 相关的命令对该 package 进行操作,如 rospack find ...
, rosrun <package> <exe>
, roscd <package>
等。
为了更有条例地存放不同类型的文件,可以建立三个文件夹 src
, build
, devel
,其中 src
存放源文件,源文件又以 package 为单位分别存放,build
存放编译过程中的中间文件,devel
存放最终生成的可执行文件和配置文件。这就是所谓的 out-of-source 编译方式。在分享、发布程序时,我们可以很清楚的知道哪些是必要的源文件,哪些是最终生成的可执行文件和 library,哪些是作为副产品存在的中间文件。
路径结构如下:
├── build
├── devel
└── src
└── hello_world_tutorial
├── CMakeLists.txt
├── hello_world_node.cpp
└── package.xml
在做了以上路径设置之后,在编译时,我们就需要特别指定各类文件对应的路径:
cd build
cmake ../src/hello_world_tutorial -DCATKIN_DEVEL_PREFIX=../devel
cmake ../src/hello_world_tutorial -DPythonInterp_FIND_VERSION_MAJOR=3 -DPythonInterp_FIND_VERSION_EXACT=ON -DCATKIN_DEVEL_PREFIX=../devel## error
cmake ../src/hello_world_tutorial -DCATKIN_DEVEL_PREFIX=../devel -DPYTHON_EXECUTABLE=/usr/bin/python3 ## sucess
make
catkin 的特点还体现在编译多个 package 中。
我们可以在 src 文件夹中再添加一个 catkin package,这里我们就直接从网上下载一个简单的 package:
git clone https://github.com/ros/robot_state_publisher.git -b melodic-devel
现在路径结构如下:
├── build
├── devel
└── src
├── hello_world_tutorial
│ ├── CMakeLists.txt
│ ├── hello_world_node.cpp
│ └── package.xml
└── robot_state_publisher
├── CHANGELOG.rst
├── CMakeLists.txt
├── doc.dox
├── include
├── package.xml
├── src
└── test
上述两个 package 各有一个 CMakeLists.txt 文件,按照普通的 cmake 方法,我们不能同时编译它们。catkin 为我们提供了一个更高层的 CMakeLists.txt 文件,可以从 ROS 安装文件夹中以超链接的形式复制过来,放在更高层的 src 目录下:
cd src
ln -s /opt/ros/melodic/share/catkin/cmake/toplevel.cmake CMakeLists.txt
实际上,ROS 为我们提供了专门的命令,实现上述操作:
cd src
catkin_init_workspace src
此时,路径结构如下:
├── build
├── devel
└── src
├── CMakeLists.txt -> /opt/ros/melodic/share/catkin/cmake/toplevel.cmake
├── hello_world_tutorial
└── robot_state_publisher
这就是典型的 ROS workspace 的结构。
此时就可以使用 cmake
同时编译 src 中的所有 package 了,命令如下:
cd build
cmake ../src -DCATKIN_DEVEL_PREFIX=../devel -DPYTHON_EXECUTABLE=/usr/bin/python3
make
将以上三个命令合并在一起就是 ROS 中的 catkin_make 命令。