第4章 · TF编程基础(C++)
Broadcaster · Listener · TransformListener · TransformBroadcaster API
这一章我们正式进入 C++ 层面的 TF 编程。说实话,命令行工具虽然方便,但真正要把 TF 集成到你的导航节点、控制节点里,还是得靠 API。我个人习惯先把广播器和监听器分开讲,因为它们在工程里各司其职——一个负责“告诉世界我在哪”,一个负责“世界你告诉我他在哪”。
4.1 创建 TF 广播器 (TransformBroadcaster)
广播器说白了就是发布坐标变换的节点。你想想看,机器人每个关节、每个传感器都有自己的坐标系,你得不停地告诉 TF 树:base_link 相对于 odom 的位置和姿态是多少。在 C++ 里,我们用 tf2_ros::TransformBroadcaster 这个类。
- 包含头文件
#include <tf2_ros/transform_broadcaster.h> - 创建广播器对象
tf2_ros::TransformBroadcaster broadcaster(node); - 构造
geometry_msgs::msg::TransformStamped消息 - 填充 header (frame_id, child_frame_id, timestamp)
- 调用
broadcaster.sendTransform(transform);
我刚开始写的时候,老是把 frame_id 和 child_frame_id 搞反。记住:frame_id 是父坐标系,child_frame_id 是子坐标系。比如你要发布 base_link 在 odom 下的位姿,那么 frame_id = "odom",child_frame_id = "base_link"。
// 示例:发布 odom → base_link 变换
#include "rclcpp/rclcpp.hpp"
#include "tf2_ros/transform_broadcaster.h"
#include "geometry_msgs/msg/transform_stamped.hpp"
class OdomBroadcaster : public rclcpp::Node {
public:
OdomBroadcaster() : Node("odom_broadcaster") {
broadcaster_ = std::make_shared<tf2_ros::TransformBroadcaster>(this);
timer_ = this->create_wall_timer(
std::chrono::milliseconds(50),
std::bind(&OdomBroadcaster::publish_tf, this));
}
private:
void publish_tf() {
geometry_msgs::msg::TransformStamped t;
t.header.stamp = this->now();
t.header.frame_id = "odom";
t.child_frame_id = "base_link";
t.transform.translation.x = 1.0;
t.transform.translation.y = 0.5;
t.transform.translation.z = 0.0;
t.transform.rotation.x = 0.0;
t.transform.rotation.y = 0.0;
t.transform.rotation.z = 0.0;
t.transform.rotation.w = 1.0;
broadcaster_->sendTransform(t);
}
std::shared_ptr<tf2_ros::TransformBroadcaster> broadcaster_;
rclcpp::TimerBase::SharedPtr timer_;
};
4.2 创建 TF 监听器 (TransformListener)
监听器就是“偷听”广播器发出来的变换。你写导航节点、规划节点时,经常需要知道当前机器人相对于地图的位姿,或者激光雷达相对于基座的偏移。这时候 tf2_ros::TransformListener 就派上用场了。
注意,监听器需要配合 tf2_ros::Buffer 使用。Buffer 会缓存所有收到的 TF 数据,你可以查询任意两个坐标系之间的变换。我习惯把 Buffer 和 Listener 声明成类的成员变量,这样整个节点都能用。
// 示例:监听 base_link 在 map 下的位姿
#include "rclcpp/rclcpp.hpp"
#include "tf2_ros/transform_listener.h"
#include "tf2_ros/buffer.h"
#include "geometry_msgs/msg/transform_stamped.hpp"
class TfListenerNode : public rclcpp::Node {
public:
TfListenerNode() : Node("tf_listener_node") {
tf_buffer_ = std::make_unique<tf2_ros::Buffer>(this->get_clock());
tf_listener_ = std::make_shared<tf2_ros::TransformListener>(*tf_buffer_);
timer_ = this->create_wall_timer(
std::chrono::milliseconds(100),
std::bind(&TfListenerNode::lookup_tf, this));
}
private:
void lookup_tf() {
try {
geometry_msgs::msg::TransformStamped transformStamped =
tf_buffer_->lookupTransform("map", "base_link", tf2::TimePointZero);
RCLCPP_INFO(this->get_logger(),
"base_link 在 map 下的位置: (%.2f, %.2f, %.2f)",
transformStamped.transform.translation.x,
transformStamped.transform.translation.y,
transformStamped.transform.translation.z);
} catch (tf2::TransformException &ex) {
RCLCPP_WARN(this->get_logger(), "无法获取变换: %s", ex.what());
}
}
std::unique_ptr<tf2_ros::Buffer> tf_buffer_;
std::shared_ptr<tf2_ros::TransformListener> tf_listener_;
rclcpp::TimerBase::SharedPtr timer_;
};
lookupTransform 时,时间戳参数如果传 tf2::TimePointZero 表示获取最新可用的变换。但如果你传了一个具体的历史时间,而 buffer 里没有缓存那么久的数据,就会抛出异常。所以生产代码里一定要 try-catch,不然节点会崩溃。
4.3 TransformListener 与 TransformBroadcaster API 详解
这两个 API 是 TF2 的基石。我整理了一个常用方法表格,方便你快速查阅。
| 类 / 方法 | 说明 | 常用参数 |
|---|---|---|
TransformBroadcaster() | 构造广播器,绑定节点 | rclcpp::Node 共享指针 |
sendTransform(TransformStamped) | 发布单个变换 | 填充好的 TransformStamped 消息 |
sendTransform(vector<TransformStamped>) | 批量发布(效率更高) | 变换列表 |
TransformListener(buffer) | 构造监听器,绑定 buffer | tf2_ros::Buffer 引用 |
buffer.lookupTransform(target, source, time) | 查询 target→source 变换 | 帧ID字符串 + 时间戳 |
buffer.canTransform(target, source, time) | 检查变换是否可用 | 返回 bool,避免异常 |
buffer.waitForTransform(...) | 等待变换可用(可设超时) | 帧ID + 时间 + 超时时间 |
这里我想多说一句 canTransform。很多新手直接调用 lookupTransform 不做检查,一旦 TF 树还没建好或者帧名写错,节点就抛异常。我个人习惯在循环里先用 canTransform 判断一下,如果返回 false 就 skip 这次查询,等下一帧。这样代码更健壮。
lookupTransform 的结果缓存起来,设置一个有效期(比如 100ms),避免每毫秒都去 buffer 里查,降低 CPU 开销。我在做多机器人项目时就是这么干的,效果很明显。
4.4 时间戳与 TF 的“时间哲学”
TF2 里时间戳是个微妙的东西。广播器发布变换时,header.stamp 应该填当前时间还是传感器时间?我建议:如果是里程计或仿真数据,用 now() 没问题;但如果你在处理传感器数据(比如相机图像时间戳),最好用图像对应的时间,这样监听器才能做时间对齐。
另外,监听器 buffer 默认缓存 10 秒的数据。如果你需要更长的历史,可以在构造 Buffer 时设置 cache_time 参数。嗯,这里要注意:缓存时间越长,内存占用越大,别设成 100 年。
node->now(),监听器查询时用 tf2::TimePointZero 获取最新变换。只有在做数据回放或离线分析时,我才会用到具体的历史时间戳。
4.5 完整示例:广播 + 监听 联合工作
下面是一个极简的联合示例,一个节点同时广播 odom→base_link,并监听 base_link→laser 的变换。你可以把它当作模板,快速集成到自己的项目里。
// 联合示例 (伪代码结构)
class CombinedTfNode : public rclcpp::Node {
// 1. 创建 broadcaster 和 buffer + listener
// 2. 定时器1: 发布 odom→base_link (模拟里程计)
// 3. 定时器2: 查询 base_link→laser 并打印
// 注意: 两个定时器频率可以不同
};
实际代码我就不贴完整了,上面两个小节已经给出了核心片段。你只需要把广播器和监听器放在同一个节点里,注意不要搞混 frame_id 就行。
rclcpp::executors::MultiThreadedExecutor 才解决。记住:监听器的回调是异步的,别在主线程里做阻塞查询。
好了,这一章的内容就是这些。C++ 的 TF API 其实不复杂,多写几遍就熟了。