本文
主要介绍如何为Apollo快速适配一个新的惯导,我这里用的是导远电子的INS570D组合惯导。驱动可以在我的github仓库里找到,Ins570d repo。
关于INS570D
我们主要关注其物理接口与输出数据格式定义,一个关系到惯导驱动里的stream节点,另一个则是parser节点。下图是这款惯导的接口图
可以看出我们需要从422串口读取惯导原始数据,跟图上稍有不同的是,我这边用422转USB的线接到了测试电脑上。
然后是发过来的数据格式定义,一般都是NMEA格式,但是导远发的是解算好的十六进制数据,所以后续解析我们需要根据下图这个手册来。
方案选择
由于Apollo本身自带有Novatel的惯导驱动,所以适配新惯导就有了两种方案
- 把新惯导解析出来的数据对接到Apollo自带的驱动上
- 重头写一个惯导解析驱动
从可行性角度分析,两者都OK,方案二会好点。但显然本文选择了方案一,仅仅出于工作量的考虑。
适配流程
接下来就跟我一步步适配吧,大致分为认识代码结构、修改对应接口、实际测试三部分。
惯导目录
首先,我们一起来看下惯导驱动所在的工程目录(
DIR/FILE | Description |
---|---|
conf(*) | 存放惯导解析驱动的配置文件 |
dag/launch | 启动惯导解析程序的文件 |
parser(*) | 解析惯导数据的源码文件 |
proto(*) | 存放消息定义的文件 |
stream(*) | 读取串口原始惯导数据的源码文件 |
test/test_data | 单元测试文件 |
util | 存放一些公共头文件 |
BUILD(*) | 相当于CmakeList文件,用于编译gnss驱动 |
gnss_component.cc/h | 惯导驱动的程序入口 |
注:文件目录末尾带(*)的,是我们适配新的惯导需要着重关注进行修改的文件。
输出通道
以下通道后带(*)的数据是必须要输出的,因为localization模块需要用到这些消息,其他下表列出来的通道也尽可能输出出来,方便调试。
Channels | Description |
---|---|
/apollo/sensor/gnss/raw_data | 组合惯导原始数据 |
/apollo/sensor/gnss/imu | imu原始数据 |
/apollo/sensor/gnss/best_pose | 组合惯导最佳定位数据 |
/apollo/sensor/gnss/odometry(*) | 组合惯导的位姿和线速度 |
/apollo/sensor/gnss/corrected_imu(*) | 校正IMU,即原始IMU数据去除了重力和bias |
/apollo/sensor/gnss/ins_stat(*) | 组合惯导的定位状态,决定最终定位的状态 |
然后我们只要着重把惯导解析出来的数据尽量接到这些通道上,这里用‘尽量’,是因为有些消息我也没法对上,但是 it work 了,所以尽量就行。
另外由于导远本身发过来的数据就是矫正后的定位信息,所以rtk_xx这些通道的数据就不用管了,相应的parser目录里的那些带有rtk的文件也不用去动它了。
Stream
我们需要从串口中读数据,首先就得创建这个stream,我们需要关注的代码在raw_stream.cc
的RawStream::Init()
这个成员函数里
1 | // Creates streams. |
create_stream
就是创建stream的函数,他的原型定义在Stream *create_stream(const config::Stream &sd)
下,里面也包含了RTK数据流的创建,我们主要关注如下这部分代码。
1 | case config::Stream::kSerial: |
上面这段代码主要读取gnss_conf_pb.txt
中data
字段中的serial
配置,要适配新的惯导就需要将下面这个配置文件内容与设备对应上。
1 | data { |
如我这里用的usb串口在linux下挂载在/dev/ttyUSB0路径上,惯导波特率设置的是115200。另外format这个配置其实改不改都不影响,但是为了方便跟源惯导区分,我就改成INS570D_BINARY
了。主要串口路径
与波特率
设置好就行。
然后就到RawStream::DataSpin()
这个函数下了,它只是个负责读取串口数据的线程,所以我们主要关注这个while()
循环里的内容。
1 | // Origin Code |
这段代码的逻辑很清晰,就是通过read()方法从数据流中读取BUFFER_SIZE个数据到buffer_中,有数据就写到相应的通道里,然后就给到Parser节点做数据解析。那我们的话就只要做如下修改。
- 首先在
raw_stream.h
头文件中将static constexpr size_t BUFFER_SIZE = 2048;
这里的2048改成63。因为我用的惯导每次发送(56+7)Byte的数据,其中56Byte的数据位,7Byte的校验位; - 其次可以将PushGpgga()那段逻辑删了,因为导远INS570D设备完全不会发送这数据,所以最终修改如下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// Modified
while (cyber::OK()) {
size_t length = data_stream_->read(buffer_, BUFFER_SIZE);
if (length == BUFFER_SIZE) {
std::shared_ptr<RawData> msg_pub = std::make_shared<RawData>();
if (!msg_pub) {
AERROR << "New data sting msg failed.";
continue;
}
msg_pub->set_data(reinterpret_cast<const char *>(buffer_), length);
raw_writer_->Write(msg_pub);
data_parser_ptr_->ParseRawData(msg_pub->data());
}
StreamStatusCheck();
}
Parser
接下来就到正式的Parser解析部分了。
首先来看data_parser.cc
文件中的DataParser::DataParser()
构造函数,其中wgs84到utm的投影设置是我们需要关注的内容。
1 | std::string utm_target_param; |
根据上面这段代码,我们可以在gnss_conf_pb.txt
文件中找到下面这行配置,这里+zone=10
需要修改成我们使用惯导所在的区域。
1 | proj4_text: "+proj=utm +zone=10 +ellps=WGS84 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs" |
然后继续往下,可以看到DataParser::Init()
这个初始化函数里,有创建Parser的代码。
1 | data_parser_.reset(CreateParser(config_, false)); |
这个CreateParser()
的定义如下。
1 | // Origin Code |
很明显这需要跟我在配置文件里修改的参数对应上,所以修改如下。
1 | // Modified |
接着看CreateNovatel()
这个函数,它在novatel_parser.cc
文件里,定义如下。
1 | // Origin Code |
到这里我才发现自己干了件挺awful的事,为了把NovatelParser
换成Ins570dParser
,我需要把novatel_parser.cc
文件里所有的NovatelParser
域改成Ins570dParser
,不过也有必要,这样毕竟之后使用就不会有歧义了。
1 | // Modified |
那索性把novatel_XXX
的.cc
和.h
文件名也改成了ins570d_XXX
。
这样在ins570d_parser.cc
文件里需要把头文件的路径也改下,还有ins570d_messages.h
里的namespace需要把novatel
改成Ins570d
,还有parser.h
里CreateNovatel()
也需要改下。最后这些修改完之后,别忘了修改当前路径下的BUILD
文件。
改完可以在Apollo的docker环境中,用下面这条命令编译下gnss驱动,如果报错按提示及时修改,因为只是改了名字不至于出什么大问题。
./apollo.sh build_opt drivers/gnss
然后重新回到data_parser.cc
文件看到ParseRawData()
这个函数,他主要读取一次串口数据就用GetMessage()
和DispatchMessage()
方法解析、发布消息。
这里有个问题在while()
代码段下,它解析到一条有用数据就会return出来然后publish消息,这样程序完全是串行的。如果本身串口消息发送频率为50Hz的话,10种类型的消息每种就5Hz的频率了。这对定位模块来说肯定是不合理的。
1 | data_parser_->Update(msg); |
不过不急一步步来,先看GetMessage()
函数,他现在定义在ins570d_parser.cc
文件下,主要是对原始数据Header稍微分析下,然后根据Header来读数据,读完给PrepareMessage()
做数据解析。因为Novatel惯导会发两种不同长度的消息,但导远的数据长度是固定的,所以只要header对应上就行。
1 | while (data_ < data_end_) { |
然后还需要对ins570d_parser.cc
和ins570d_messages.h
这两个文件做以下修改。
1 | // ins570d_parser.cc的Ins570dParser类定义下 |
接下来看PrepareMessage()
这个函数,这里就到正式的解析流程了。
首先有两个设计目标需要确认下
- ins570d数据格式的声明与定义放哪
- ins570d解析逻辑如何设计
关于第一点,因为novatel_messages.h
(现在为ins570d_messages.h
)已经定义了许多消息结构体,再把ins570d的数据格式声明在这里会很乱,所以我外面加了个INSD_Define.h
文件放它的声明以及需要用到的一些变量定义都放这了,现在parser
目录结构如下。
1 | ./parser/ |
但是ins570d解析数据的定义还是放ins570d_messages.h
里了,然后在ins570d_messages.h
里include新加的INSD_Define.h
文件即可。
1 | // under ins570d_messages.h |
这里INS_STATUS
结构体是根据惯导能解析出来哪些数据定义的,由于比较长就没展开放进上面代码块里了,不过完整代码可在我的github仓库里找到。
然后关于第二点,我是这么考虑的,INS570D每次发一次数据,buffer_
就会包含我们所有想要的信息。那我们只要一次性把它从buffer_
里全部解析出来即可。
这样当然不错,但是PrepareMessage()
这个函数原本逻辑是解析到某一类型消息就调用HandleXXX()
做对应的解析然后就return到DataParser::ParseRawData()
里执行DispatchMessage()
函数,他会根据PrepareMessage()
返回的消息类型选择对应的PublishXXX()
函数完成消息的发布。
就导远的惯导来说,程序这样设计是比较合理的,在PrepareMessage()
函数里
1 | HandleXXX(); |
然后在PrepareMessage()
函数里
1 | PublishXXX(); |
这就需要把原来解析好的数据定义给这两个函数共享,好在两者都include了parser.h
头文件,那就对这个文件添加如下内容即可,注意变量定义需要放在apollo::drivers::gnss
namespace下。
1 |
|
修改完我编译了下没过,由于把变量定义在全局域下跟test
里的代码冲突了,索性把test
都删了,编译通过。数据对象确定了,然后就是算法逻辑修改了。
先是到data_parser.cc
文件下,对ParseRawData()
跟DispatchMessage()
函数做如下修改。
1 | void DataParser::ParseRawData(const std::string &msg) { |
然后到ins570d_parser.cc
文件下对PrepareMessage()
函数做如下修改。
1 | void Ins570dParser::PrepareMessage() { |
其中HandleXXX()
函数上面部分是ins570d解析代码,HandleXXX()
负责对接原有消息类型,为此我也对其做了重构,以HandleHeading()
为例来看下。
1 | bool Ins570dParser::HandleHeading() { |
我取消了原有的传入参数,通过我解析出来的数据直接赋值,然后没有的消息类型直接注释掉不管即可。
至此
适配工作大致完成,内容有点多,所以剩下测试部分内容放到下篇文章讲了。
参考资料
- apollo官方repo里提供的how_to_add_a_gps_receiver指南
- apollo官方repo里提供的离线仿真demo,跑起来可以用Cyber_monitor对下gnss相关消息的数据格式