AimRT从入门到精通 - 01实现一个helloworld
一、什么是AimRT?
背景(为什么出现AimRT)
在目前机器人中间件领域,ROS1 以及 ROS2 处于一家独大的地位。ROS 最早在 2010 年发布 1.0 版本,但在后续的发展中暴露出各种问题,最终 ROS 官方于 2017 发布全新 ROS2 的第一个版本,并于 2022 年发布第一个长周期支持版 ROS2 Humble。同时官方也逐渐停止对 ROS1 的维护,全面转向 ROS2。
除了 ROS2,近几年很火的自动驾驶领域也产生了不少中间件,可以在一定程度上复用到机器人领域,例如百度的 Apollo,最早也是基于 ROS 作为底层,但最终放弃 ROS,自研了 CyberRT 作为底层通信组件。不过这些毕竟不是专为机器人领域设计的,在生态、工具链、使用率等方面与 ROS2 还是有较大差距。
但在目前以 ROS2 为主流的机器人中间件领域,也存在不少问题。一个比较突出的点是,ROS2 是为了传统机器人控制领域设计的,对运动控制、Slam 等领域支持的较好,但对面向未来的机器人+ AI、机器人+云等都不太友好。
除此之外,ROS2 较为冗重、官方支持平台较少、迭代较慢、不太稳定等各种问题制约着他的产业落地,很少有量产型产品直接使用 ROS2 作为自身产品的通信框架。
AimRT 是智元公司于成立之初(2023 年 2 月)就决定要去自行研发的一套通信中间件,其对标的是ROS的通信框架机制,具体来说AimRT 是一套用于现代机器人领域的基础运行时框架,基于现代 C++ 开发,轻量易部署,在资源管控、异步编程、部署配置等方面都有更现代化的设计。但是想比于ROS其目前的生态建设还需要很长一段时间;
具体有关AimRT的介绍可以参考智元的微信公众号;
目前,AimRT 主要有以下几大核心功能:Configuration、Log、Executor、Parameter、RPC、Channel。
这里我们可以通过aimrt::CoreRef进行查看,可以发现其也是包含上面对应的6个模板的:(具体解释我们以后再讲)
namespace aimrt {class CoreRef {public:ModuleInfo Info() const;configurator::ConfiguratorRef GetConfigurator() const;executor::ExecutorManagerRef GetExecutorManager() const;logger::LoggerRef GetLogger() const;rpc::RpcHandleRef GetRpcHandle() const;channel::ChannelHandleRef GetChannelHandle() const;parameter::ParameterHandleRef GetParameterHandle() const;
};} // namespace aimrt
二、AimRT的下载
这里我们可以参考AimRT的官方手册,按照其步骤在GitHub下载对应的连接;
环境配置
目前AimRT支持的环境如下所示:
- Ubuntu22.04
- CMake版本大于等于3.24
- GCC版本大于等于11.4
这里我们可以分别通过:
gcc --version
cmake --version
查看自己的gcc/cmake对应的版本,如果版本不够自己进行升级即可;
编译AimRT
git clone https://github.com/AimRT/AimRT
通过git获取源码;
接下来进入AimRT目录,直接执行build.sh进行编译:
cd AimRT
./build.sh
构建完成后,进入build目录,其中列出了所有的example:
cd build
ls -l
上面列举了AimRT官方自带的例子对应的运行脚本;
这里我们尝试运行一下helloworld对应的脚本:
sh ./start_examples_cpp_helloworld.sh
此时会显示下面的画面:
这里我们调用ctrl + c发送信号即可退出;
上面即可说明我们的程序下载编译没有问题;
三、AimRT的整体框架
问题:如果我们想使用AimRT实现hellowrold,该如何实现?
即使用AimRT写代码的框架应该是怎样的?
下面我们以helloworld为例子
helloworld
├── build.sh
├── cmake
│ └── GetAimRT.cmake
├── CMakeLists.txt
└── src├── app│ ├── helloworld_app_create_mode│ │ ├── CMakeLists.txt│ │ └── main.cc│ └── helloworld_app_registration_mode│ ├── CMakeLists.txt│ └── main.cc├── CMakeListst.txt├── install│ ├── cfg│ └── start.sh├── moudle│ ├── CMakeLists.txt│ ├── helloworld.cc│ └── helloworld.h└── pkg├── CMakeLists.txt└── main.cc
接下来我们依次对上面的文件进行解释:
- build.sh是用来编译的脚本;
- cmake文件夹下存放的是用来调用AimRT的代码(这里调用需要通过cmake的FetchContent,具体的语法后面讲,只需要记住这里是为了调用AimRT)
- CMakeLists.txt:用于设置Cmake编译时的代码(设置可执行程序生成的路径);
- src: 里面存放了我们写的一些源码程序;
- install:这里我们cfg目录用于存放对应的配置文件;start.sh用于启动程序,启动后生成的可执行程序会存放在build目录下(与源代码环境进行隔离)
除此之外,我们可以发现还有3个模块,分别是moudle、app和pkg!
这三个模块是AimRT特有的!具体详情大家可以看官方的文档解释:
AimRT 中的基本概念 — AimRT v0.10.0 documentation
这里我用自己的话解释一下:
- moudle:这里是具体实现的某一项功能,一个单体独立的部分,例如我们在实现机器人导航的时候,可能需要建图功能、导航功能等,而moudle可以将他们独立分开,从而实现解耦;
- app:我认为就是具体的应用功能,因此app一般来说是生成的对应的可执行程序.exe;
- pkg:这里实际是就是将对应的代码生成动态库.so(目前只支持cpp接口);
当我们编写项目代码的时候,例如内存池,线程池等我们就可以将其编译成对应的动态库,而有的项目需要我们调用可执行程序,因此这两个模块也很好理解;
所以如果我们需要可执行程序,就生成对应的app格式;如果需要程序以动态库的形式,就生成pkg模型;
四、helloworld程序编写
接下来我们尝试通过AimRT实现helloworld,这里我们依次按照上面的框架依次实现:
1. 主项目的CMakeLists.txt
首先我们编写整个项目的CMakeLists.txt文件:
cmake_minimum_required(VERSION 3.24) # 设置cmake的版本project(helloworld LANGUAGES C CXX) # 设置项目名并支持C/C++set(CMAKE_CXX_STANDARD 20) # 设置C++的编译版本
set(CMAKE_CXX_STANDARD_REQUIRED ON) # 编译器必须支持C++20,否则报错
set(CMAKE_CXX_EXTENSIONS OFF) # 禁用编译器特有的扩展(如GNU的 -std=gnu++20)set(CMAKE_POLICY_DEFAULT_CMP0077 NEW) # 将策略 CMP0077 的默认行为设置为 NEW。include(cmake/GetAimRT.cmake) # 调用AimRTadd_subdirectory(src) # 将src目录下的CMakeLists.txt也加入构建系统中
上面代码都带上了对应的注释了;
这里我们补充一点 set(CMAKE_POLICY_DEFAULT_CMP0077 NEW):
问题:什么是CMake的策略?
CMake 策略是 CMake 用来管理版本兼容性的一种机制。随着 CMake 的更新,某些旧功能的行为可能被修改或废弃。为了保持向后兼容性,CMake 引入了“策略”的概念,每个策略对应一个特定的行为变更,并通过编号(如 CMP0077
)标识。开发者可以通过策略选择使用“旧行为”或“新行为”,从而控制项目在不同 CMake 版本下的构建方式。
策略的核心作用:
-
向后兼容:允许旧项目在不修改代码的情况下使用新版本 CMake。
-
行为可控:开发者可以显式指定使用某个版本的行为,避免意外错误。
set(CMAKE_POLICY_DEFAULT_CMP0077 NEW)这行代码的作用是什么?
实际上是通过策略 CMP0077 控制 option() 命令的行为,具体涉及以下场景:
- 当项目中通过 option(NAME ...) 定义一个选项时,
- 如果已经存在一个同名的普通变量(例如通过 set(NAME ...) 定义),
- 该策略决定 option() 是否使用已存在的变量值初始化选项。
例如下面这种情况:
如果没有设置这段话:
-
如果已存在同名变量,
option()
会忽略该变量,直接使用option()
中定义的默认值。 -
示例:
set(MY_OPTION "123") # 普通变量
option(MY_OPTION "测试选项" ON) # 定义选项
此时MY_OPTION 的值会被强制设为 ON(忽略已存在的变量值 "123")
如果加上这个设置:
-
如果已存在同名变量,
option()
会优先使用该变量的值初始化选项。 -
示例:
set(MY_OPTION "123")
option(MY_OPTION "测试选项" ON)
加上这段代码之后,MY_OPTION 的值会被设为 "123"(保留变量的值)。
2. /cmake/GetAimRT.cmake
接下来我们编写/cmake/GetAimRT.cmake这个文件代码
include(FetchContent) # 引入 FetchContent 模块,提供从远程仓库(如 Git)下载和管理依赖项的功能。# 声明依赖项信息
FetchContent_Declare(aimrt # 依赖项的名称GIT_REPOSITORY https://github.com/AimRT/aimrt.git # 依赖项的github的URL地址GIT_TAG v1.x.x) # 依赖项的版本# 检测依赖项是否被下载
FetchContent_GetProperties(aimrt)
# 如果依赖项被下载,此时会生成变量 aimrt_POPULATED用来标记if(NOT aimrt_POPULATED)FetchContent_MakeAvailable(aimrt) # 实际执行下载操作
endif()
其具体的代码和注释信息如上所示,如果我们要调用这个AimRT,都需要这样定义;
3. /src/CMakeLists.txt
add_subdirectory(module/helloworld_module)
add_subdirectory(app/helloworld_app_create_mode)
add_subdirectory(pkg)
这里我们引用 src 下的各个子目录;
4. /src/module/helloworld_module/CMakeLists.txt
file(GLOB_RECURSE src ${CMAKE_CURRENT_SOURCE_DIR}/*.cc) # 递归遍历源文件到srcadd_library(helloworld_module STATIC) # 创建静态库目标# 为helloworld_module 创建一个命名空间化的别名 helloworld::helloworld_module
add_library(helloworld::helloworld_module ALIAS helloworld_module) # 将 src 变量中存储的所有 .cc 文件添加到 helloworld_module 的源文件列表中。
target_sources(helloworld_module PRIVATE ${src})# 头文件的搜索路径设置为当前目录的父目录
target_include_directories(helloworld_modulePUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..)# 为helloworld_module添加对应的依赖
target_link_libraries(helloworld_modulePRIVATE yaml-cpp::yaml-cppPUBLIC aimrt::interface::aimrt_module_cpp_interface)
这里需要注意的是我们将moudle编译成了静态库;
-
add_library(helloworld_module STATIC)
仅定义了库的名称和类型(静态库),但未指定哪些源文件参与构建。 -
target_sources()
将变量${src}
(包含所有.cc
文件路径)添加到库的源文件列表中,使得 CMake 知道需要编译哪些文件来生成helloworld_module
库。
5. /src/app/helloworld_app/CMakeLists.txt
cmake_minimum_required(VERSION 3.5)
project(helloworld_app)# 将当前目录及子目录下的源文件保存到src当中
file(GLOB_RECURSE src ${CMAKE_CURRENT_SOURCE_DIR}/*.cc)# 将生成的可执行文件输出到构建目录下的 bin 文件夹,build/bin
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)add_executable(helloworld_app ${src})# 包含头文件目录
target_include_directories(helloworld_appPRIVATE ${CMAKE_CURRENT_SOURCE_DIR}
)
# 链接外部库
target_link_libraries(helloworld_appPRIVATE aimrt::runtime::corehelloworld::helloworld_module
)add_custom_target(config_copy ALLCOMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/binCOMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/src/install ${CMAKE_BINARY_DIR}/binDEPENDS helloworld_appCOMMENT "Copying install files to bin directory"
)
- CMAKE_BINARY_DI:这里是cmake运行时所在的路径;
- 这里要注意的是最后的add_custom_target这个部分的作用:这里我们将
src/install
文件夹内容复制到bin
目录,此后我们执行项目脚本都在build下的bin目录进行执行,将环境进行隔离;
这里我们是要生成对应的可执行程序,其他的与上面的类似;
6./src/module/helloworld_module/helloworld_module.h
接下来我们尝试编写业务代码,这里业务代码需要继承Moudle库来实现:
#pragma once#include "aimrt_module_cpp_interface/module_base.h"class HelloWorldModule : public aimrt::ModuleBase {
public:HelloWorldModule() = default;~HelloWorldModule() override = default;aimrt::ModuleInfo Info() const override {return aimrt::ModuleInfo{.name = "HelloWorldModule"};}bool Initialize(aimrt::CoreRef core) override;bool Start() override;void Shutdown() override;private:aimrt::CoreRef core_;
};
这里其中核心是aimrt::CoreRef
句柄的使用;
其中,我们具体要进行的步骤就是:继承ModuleBase类型实现自己的Module,在Initialize方法中,AimRT 框架会传入一个aimrt::CoreRef句柄;
这里的CoreRef实际上就是核心句柄,我们可以通过这个句柄对Moudle进行管理,在其内部提供了以下的功能函数:
namespace aimrt {class CoreRef {public:ModuleInfo Info() const;configurator::ConfiguratorRef GetConfigurator() const;executor::ExecutorManagerRef GetExecutorManager() const;logger::LoggerRef GetLogger() const;rpc::RpcHandleRef GetRpcHandle() const;channel::ChannelHandleRef GetChannelHandle() const;parameter::ParameterHandleRef GetParameterHandle() const;
};} // namespace aimrt
这里实际上就是对moudle提供了六种核心功能,也就是我们最初上面提到的;
除此之外,还提供了Info这个函数,这个函数接口获取其所属的模块的信息;
如果我们要看懂上面的代码,我们还要知道MoudleBase是什么,接下来我们可以看一下官方文档中的介绍:
ModuleBase类型是一个模块基类类型,开发者可以继承ModuleBase类型来实现自己的Module,它定义了业务模块所需要实现的几个接口:
namespace aimrt {class ModuleBase {public:virtual ModuleInfo Info() const = 0;virtual bool Initialize(CoreRef core) = 0;virtual bool Start() = 0;virtual void Shutdown() = 0;
};} // namespace aimrt
这里Info函数的返回值是MoudleInfo,其结构如下所示:
namespace aimrt {struct ModuleInfo {std::string_view name; // Requireuint32_t major_version = 0;uint32_t minor_version = 0;uint32_t patch_version = 0;uint32_t build_version = 0;std::string_view author;std::string_view description;
};} // namespace aimrt
详细介绍我们可以看官方文档,这里我们只是简单总结以下Info的用法:
ModuleInfo Info()
:用于 AimRT 获取模块信息,包括模块名称、模块版本等;-
ModuleInfo
结构中除name
是必须项,其余都是可选项;
因此我们总结以下,如果我们想要实现一个moudle,此时我们应该怎么做?
- 继承对应的Moudle_base库,自己对应的子moudle要对这些函数进行重写;
- 对于构造、析构函数,我们可以使用默认的构造函数;
- 主要是进行初始化、开始、结束三个成员函数;
- 获取模块信息时需要传入模块名;
7. /src/module/helloworld_module/helloworld_module.cc
#include "helloworld_module/helloworld_module.h"#include "yaml-cpp/yaml.h"bool HelloWorldModule::Initialize(aimrt::CoreRef core) {core_ = core;// 记录初始化日志AIMRT_HL_INFO(core_.GetLogger(), "Init.");try {// 读取配置文件auto file_path = core_.GetConfigurator().GetConfigFilePath();if (!file_path.empty()) {// 加载YAML文件YAML::Node cfg_node = YAML::LoadFile(file_path.data());for (const auto& itr : cfg_node) {std::string k = itr.first.as<std::string>();std::string v = itr.second.as<std::string>();AIMRT_HL_INFO(core_.GetLogger(), "cfg [{} : {}]", k, v);}}} catch (const std::exception& e) {// 异常处理:记录错误并返回失败AIMRT_HL_ERROR(core_.GetLogger(), "Init failed, {}", e.what());return false;}// 初始化成功日志AIMRT_HL_INFO(core_.GetLogger(), "Init succeeded.");return true;
}bool HelloWorldModule::Start() {AIMRT_HL_INFO(core_.GetLogger(), "Start succeeded.");return true;
}void HelloWorldModule::Shutdown() {AIMRT_HL_INFO(core_.GetLogger(), "Shutdown succeeded.");
}
这些代码看起来很多,实际上很简单;这里代码的整体的框架就是对我们.h文件中的初始化、开始和结束三个函数进行实现;
接下来我们对上面的文件进行关键分析:
-
#include "yaml-cpp/yaml.h"可以用来接下来我们定义的配置文件进行解析
- 在上面我们提到CoreRef里面提供的有GetConfigurator(),即我们可以通过模块的核心句柄从而获取到管理配置的句柄,其内部会提供对管理模块的一些相关函数;
例如如果我们想要如果配置文件的路径,此时就需要通过GetConfigurator()获取到管理模块的句柄,再从管理模块的句柄获取到读取路径的函数,其结构如下所示:
namespace aimrt::configurator {class ConfiguratorRef {public:std::string_view GetConfigFilePath() const;
};} // namespace aimrt::configurator
需要注意的是:
- std::string_view GetConfigFilePath()接口:用于获取模块配置文件的路径。
- 请注意,此接口仅返回一个模块配置文件的路径,模块开发者需要自己读取配置文件并解析。
OK,此时我们就完成了模块功能的编写,然后我们就可以进行app/pkg的开发,从而将项目编译成以可执行程序或者动态库的方式;
8./src/app/helloworld_app/main.cc
#include <csignal>
#include <iostream>#include "core/aimrt_core.h"
#include "helloworld_module/helloworld_module.h"using namespace aimrt::runtime::core;AimRTCore *global_core_ptr_ = nullptr;void SignalHandler(int sig) {if (global_core_ptr_ && (sig == SIGINT || sig == SIGTERM)) {global_core_ptr_->Shutdown();return;}raise(sig);
};int32_t main(int32_t argc, char **argv) {signal(SIGINT, SignalHandler);signal(SIGTERM, SignalHandler);std::cout << "AimRT start." << std::endl;try {AimRTCore core;global_core_ptr_ = &core;// register moduleHelloWorldModule helloworld_module;core.GetModuleManager().RegisterModule(helloworld_module.NativeHandle());AimRTCore::Options options;options.cfg_file_path = argv[1];core.Initialize(options);core.Start();core.Shutdown();global_core_ptr_ = nullptr;} catch (const std::exception &e) {std::cout << "AimRT run with exception and exit. " << e.what() << std::endl;return -1;}std::cout << "AimRT exit." << std::endl;return 0;
}
要看懂上面的代码,首先我们要知道:再APP模式下,此时如果我们要生成可执行程序,我们需要初始化AimRT核心框架!
如何初始化AimRT这个框架呢?
这里我们需要定义AimRTCore变量,然后调用其中的成员函数进行初始化:
namespace aimrt::runtime::core {class AimRTCore {public:struct Options {std::string cfg_file_path;};public:void Initialize(const Options& options);void Start();std::future<void> AsyncStart();void Shutdown();// ...module::ModuleManager& GetModuleManager();// ...
};} // namespace aimrt::runtime::core
可以看出这个整体的框架还是包括三部分:初始化、开始和结束;
其中:
- 初始化的时候,我们需要向其中传入options,也就是配置,配置里面包含了配置文件的路径,这里我们需要传入配置文件的路径,然后再进行初始化;
- 开始部分这里分为两种:同步和异步,默认的Start是同步的,此时如果该线程执行Start模块,则该线程会一直阻塞;
- 如果采用AsyncStart(),即异步执行,此时会返回
std::future<void>
句柄,需要自己在调用Shutdown
方法后调用该句柄的wait
方法阻塞等待结束。 - 除此之外,我们可以发现,AimRTCore这个核心是在aimrt::runtime::core这个命名空间下,所以我们调用的时候需要展开命名空间或者指定命名空间;
除此之外,对于AimRT这个核心框架管理,在APP的开发下,由于模块管理有下面两种方式:
- 注册模块方式;(RegisterModule)
- 创建模块方式;(CreateModule)
如何调用这两种方式对模块进行管理呢?
其实在上面我们可以发现:AimRTCore里面还有一个GetModuleManager()这个成员函数,我们可以获取模块管理这一句柄;
namespace aimrt::runtime::core::module {class ModuleManager {public:void RegisterModule(const aimrt_module_base_t* module);const aimrt_core_base_t* CreateModule(std::string_view module_name);
};} // namespace aimrt::runtime::core::module
而返回这个MoudleManger里面就提供了两种模块管理的方式:注册和创建!
需要区分的是:注册和创建分别对应的框架的初始化和开始的顺序是不一样的!
对于模块的两种管理方式,我是这样理解的:
- 注册模块是发生在框架进行初始化之前,此时我们内部向框架发送模块的注册信息,模块随着框架一起进行初始化;
- 而对于创建模块,此时是我们的AimRT框架已经被搭建好了,但是正式的启动Start前,我们需要在已经初始化好的AimRT框架上创建一个模块!
上面我们示例的代码就是注册模块!发生在AimRTCore框架初始化之前!
此时我们再看上面的代码,我们就可以理解其整个结构:
- 首先进行信号的捕捉,当我们2号信号和9号信号的时候,此时会调用Shutdown(),关闭整个AimRT框架,因此需要保证指针不能为空;
- 然后在主函数部分,就依次对框架进行初始化、开始和结束;
- 注册模块发生在初始化之前;
- 这里我们传入的配置文件的路径是argv[1],也就是在命令行上输入的第二个参数;
9. /src/install/cfg/helloworld_cfg.yaml
接下来我们尝试写一个示例的简单的配置文件,其结构如下所示:
aimrt:log: # log配置core_lvl: INFO # 内核日志等级,可选项:Trace/Debug/Info/Warn/Error/Fatal/Off,不区分大小写backends: # 日志后端- type: console # 控制台日志# 模块自定义配置,框架会为每个模块生成临时配置文件,开发者通过Configurator接口获取该配置文件路径
HelloWorldModule:key1: val1key2: val2
我们可以看到,这个配置文件中分为两个顶层键:aimrt / HelloWorldMoudle;
其中aimrt是给整个AimRT框架进行的配置文件,这里我我们只对其中的log子健进行配置,在aimrt下面还有很多其他的子健模块,其配置我们以后再讲;
10. 启动和测试
接下来我们可以通过编译一些shell脚本对程序进行编译和运行,为了方便管理,这里我们分别对build.sh和start.sh来实现管理
start.sh文件的格式如下所示:
./HelloWorld ./cfg/helloworld_cfg.yaml
这里我们指定运行的app文件的名称和对应的配置路径(用于初始化AimRTCore)
build.sh文件的格式如下所示:
# cd to root path of project
cmake -B build
cd build
make -j
运行后的格式如下所示:
这里我们可以看到,由于上面的文件我们只对aimrt下的log进行了配置,其他部分没有配置,显示的是默认的格式;
如果我们想退出,由于上面我们进行已经对框架的信号进行了捕捉,所以直接ctrl+c退出即可;
截止到这里我们对AimRT的Helloworld模块的相关介绍到此为止;