1. 从一次坐标变换的调试说起:TF到底在解决什么问题
如果你正在学ROS,大概率已经写过订阅激光雷达数据、发布里程计话题、控制小车移动这类代码。单个节点跑起来都不难,但一旦涉及多个传感器和多个坐标系,问题就来了:激光雷达看到的一个障碍物,怎么换算到地图坐标系里?机械臂末端的抓取点,怎么从相机坐标系转到基座坐标系?这些问题背后都是同一件事——坐标系之间的变换。
TF(Transform Library)就是ROS里专门干这个的。它不是一个具体的功能包,而是一套维护坐标系树、管理变换关系、支持任意时刻查询的基础设施。你可以把它理解成一个“坐标翻译官”:任何两个坐标系之间的相对位置和姿态,只要它们在同一棵TF树里,你都能在任意时间点问它要答案。
我第一次接触TF的时候,觉得它概念多、API杂,broadcast、lookup、static_transform_publisher、tf2_ros、tf和tf2的区别……一堆东西糊在一起。后来真正在项目里用起来才发现,TF的设计其实非常克制:核心就两件事——发布变换和查询变换。剩下的都是围绕这两件事的工程化封装。
这篇笔记适合谁看?如果你已经能跑通基本的ROS节点,但对TF的理解还停留在“照着教程抄代码”的阶段,那这篇内容会帮你把TF的来龙去脉理清楚。我会从TF的底层逻辑讲起,然后拆解它的核心组件、常用工具、实操步骤,最后分享几个我在实际项目中踩过的坑。不追求面面俱到,但求把关键概念讲透。
2. TF的底层逻辑:一棵树、两个方向、三种查询方式
2.1 坐标系树:为什么TF不允许出现闭环
TF最核心的数据结构是一棵树。每个坐标系是树上的一个节点,节点之间的边代表变换关系。比如一个典型的移动机器人系统,坐标系树可能是这样的:
map→odom→base_link→laserbase_link→camera_link→camera_optical_frame
这棵树有一个硬性约束:不能有闭环。也就是说,从任意一个坐标系到另一个坐标系,路径必须唯一。为什么?因为如果存在两条路径,两条路径算出来的变换可能不一致,TF就不知道该信谁了。
这个约束在实际使用中经常被忽略。我见过有同学同时发布了map→odom和odom→map的变换,结果TF直接报错。还有人把两个传感器之间的变换重复发布,导致树里出现冗余边。记住一句话:TF树是一棵有向树,每条边只能有一个父节点和一个子节点,方向由父到子。
那如果两个坐标系之间确实存在“互相依赖”的关系怎么办?比如两个移动机器人各自有自己的里程计,但它们又在同一个地图里。正确的做法是选一个公共的父坐标系(比如map),让两个机器人的odom都挂到map下面,而不是让它们互相挂。
2.2 变换的数学本质:平移加旋转
TF里一个变换(Transform)包含两部分:平移(translation)和旋转(rotation)。平移就是一个三维向量(x, y, z),旋转可以用四元数、旋转矩阵或欧拉角表示。TF内部统一用四元数存储旋转,因为四元数没有万向锁问题,插值也方便。
这里有一个容易混淆的点:geometry_msgs/TransformStamped消息里,transform.translation和transform.rotation描述的是子坐标系在父坐标系中的位姿。换句话说,如果你要发布base_link到laser的变换,你填的是激光雷达相对于机器人底盘的位置和姿态。
我刚开始学的时候总搞反方向,后来总结了一个口诀:“父看子,填子的位姿”。比如map是父,base_link是子,那你就填base_link在map里的位置。TF在查询的时候会自动帮你算反向变换,所以你不需要手动发布两个方向。
2.3 时间维度:TF不是静态的
TF和普通的坐标变换库最大的区别在于:它带时间戳。每个变换都有一个时间戳,表示这个变换在哪个时刻有效。查询的时候,你可以指定一个时间点,TF会从缓冲区里找到对应时刻的变换。
这个设计是为了处理动态系统。比如机器人在移动,odom→base_link的变换每时每刻都在变。TF会把这些变换按时间顺序存起来,形成一个缓冲区(默认缓存10秒)。当你查询某个时刻的变换时,TF会在缓冲区里做插值。
这里有一个关键参数:tf2_ros::Buffer的缓存时长。默认是10秒,但在一些低频更新的场景下(比如某些传感器只有1Hz),10秒可能不够。你可以通过构造函数调整:
tf2_ros::Buffer buffer(ros::Duration(30.0)); // 缓存30秒但缓存也不是越长越好。缓存越长,内存占用越大,查询时的插值计算也越慢。一般建议缓存时长至少是传感器更新周期的3到5倍。
3. TF2的组件拆解:谁负责发布,谁负责监听
3.1 TransformBroadcaster:变换的发布者
tf2_ros::TransformBroadcaster是发布变换的入口。它的用法很直接:
#include <tf2_ros/transform_broadcaster.h> tf2_ros::TransformBroadcaster br; geometry_msgs::TransformStamped transformStamped; transformStamped.header.stamp = ros::Time::now(); transformStamped.header.frame_id = "base_link"; transformStamped.child_frame_id = "laser"; transformStamped.transform.translation.x = 0.1; transformStamped.transform.translation.y = 0.0; transformStamped.transform.translation.z = 0.2; transformStamped.transform.rotation.x = 0.0; transformStamped.transform.rotation.y = 0.0; transformStamped.transform.rotation.z = 0.0; transformStamped.transform.rotation.w = 1.0; br.sendTransform(transformStamped);这段代码看起来简单,但有几个细节值得注意。第一,header.stamp的时间戳必须和你的数据时间戳一致。如果你用ros::Time::now(),但传感器数据是100ms之前的,那查询的时候就会出现“ extrapolation into the future”或者“extrapolation into the past”的错误。第二,frame_id和child_frame_id不能相同,也不能形成闭环。第三,四元数必须是归一化的,否则TF会报错。
我在实际项目里习惯把变换发布封装成一个函数,每次收到传感器数据就调用一次。这样能保证时间戳对齐。如果你用static_transform_publisher发布静态变换,那就不需要关心时间戳,因为它只发一次。
3.2 TransformListener:变换的查询者
tf2_ros::TransformListener负责监听TF树上的变换,并把它们存到Buffer里。用法如下:
#include <tf2_ros/transform_listener.h> tf2_ros::Buffer buffer; tf2_ros::TransformListener listener(buffer); geometry_msgs::TransformStamped transform; try { transform = buffer.lookupTransform("map", "laser", ros::Time(0), ros::Duration(1.0)); } catch (tf2::TransformException &ex) { ROS_WARN("%s", ex.what()); return; }lookupTransform的参数值得展开说。第一个参数是目标坐标系,第二个是源坐标系,第三个是时间点,第四个是超时时间。ros::Time(0)表示查询最新可用的变换。超时时间表示如果变换还没准备好,最多等多久。
这里有一个常见的误区:很多人以为lookupTransform是“把源坐标系下的点转到目标坐标系”。实际上它返回的是目标坐标系到源坐标系的变换。如果你要把一个点从laser转到map,你需要用返回的变换做一次矩阵乘法。TF2提供了tf2::doTransform函数来简化这个操作:
geometry_msgs::PointStamped point_in_laser; point_in_laser.header.frame_id = "laser"; point_in_laser.point.x = 1.0; point_in_laser.point.y = 0.0; point_in_laser.point.z = 0.0; geometry_msgs::PointStamped point_in_map; tf2::doTransform(point_in_laser, point_in_map, transform);doTransform会自动处理坐标系方向,你不需要手动算逆变换。这个函数在tf2_geometry_msgs包里,用之前记得在package.xml和CMakeLists.txt里加上依赖。
3.3 Buffer:变换的存储与插值
tf2_ros::Buffer是TF2的核心数据结构。它内部维护了一个按时间排序的变换缓存,支持按时间查询和插值。Buffer的查询接口主要有三个:
lookupTransform:查询两个坐标系之间的变换canTransform:检查两个坐标系之间是否可变换getFrames:获取所有已知的坐标系名称
canTransform在写代码时很有用。比如你要处理激光数据,但不确定map→laser的变换是否已经准备好,可以先调canTransform检查一下:
if (buffer.canTransform("map", "laser", ros::Time(0), ros::Duration(0.1))) { // 变换可用,继续处理 } else { ROS_WARN("TF not ready"); }Buffer的插值策略是线性插值。对于平移,直接对x, y, z做线性插值;对于旋转,用球面线性插值(slerp)。插值的前提是两个时间点的变换都存在。如果查询的时间点超出了缓存范围,或者两个时间点之间没有变换,就会抛异常。
4. 静态变换与动态变换:什么时候用哪个
4.1 静态变换:一次发布,永久有效
静态变换指的是两个坐标系之间的相对位姿不随时间变化。比如激光雷达固定在机器人底盘上,base_link→laser的变换就是静态的。对于静态变换,ROS提供了static_transform_publisher工具:
rosrun tf2_ros static_transform_publisher 0.1 0 0.2 0 0 0 1 base_link laser参数依次是:x y z yaw pitch roll frame_id child_frame_id。注意这里的旋转用的是欧拉角,顺序是yaw pitch roll(绕Z、Y、X轴)。如果你有四元数,也可以用--quaternion参数:
rosrun tf2_ros static_transform_publisher 0.1 0 0.2 0 0 0 1 base_link laser静态变换的好处是:只发布一次,不占用带宽,查询时也不需要插值。但有一个坑:静态变换的时间戳是ros::Time::now(),但TF会把它当作“永远有效”。如果你在launch文件里启动static_transform_publisher,但系统时间还没同步好,可能会出问题。我一般会在launch文件里加一个<node>标签,确保它在其他节点之前启动。
4.2 动态变换:每帧数据都要发布
动态变换指的是随时间变化的变换。比如odom→base_link,机器人每移动一点,这个变换就变一次。动态变换必须每收到一帧数据就发布一次,而且时间戳要和数据时间戳一致。
这里有一个性能问题:如果你有多个传感器,每个传感器都发布自己的变换,TF树会变得很大,查询时的计算量也会增加。我的经验是:能合并的变换尽量合并。比如base_link→laser和base_link→camera都是静态的,可以用两个static_transform_publisher分别发布,也可以用一段代码一次性发布多个静态变换。
另外,动态变换的发布频率不要太高。有些同学在while循环里以1000Hz的频率发布变换,结果TF缓冲区很快就被填满了,查询时反而变慢。一般来说,变换的发布频率和传感器数据频率一致就行,不需要额外提高。
4.3 静态与动态的混合使用
实际项目中,静态变换和动态变换往往是混在一起的。比如一个移动机器人:
map→odom:动态,由定位算法发布odom→base_link:动态,由里程计发布base_link→laser:静态,由static_transform_publisher发布base_link→camera:静态,由static_transform_publisher发布
这样TF树就是一棵完整的树,任意两个坐标系之间都可以查询变换。但要注意:静态变换和动态变换不能同时发布同一个边。如果你既用static_transform_publisher发布base_link→laser,又在代码里用TransformBroadcaster发布同一个变换,TF会报“TF_OLD_DATA”或者“TF_REPEATED_DATA”错误。
5. 实操:从零搭建一个TF树并验证
5.1 环境准备与依赖安装
假设你已经装好了ROS(以Noetic为例),先确认TF2相关的包是否齐全:
sudo apt install ros-noetic-tf2 ros-noetic-tf2-ros ros-noetic-tf2-geometry-msgs ros-noetic-tf2-toolstf2-tools里包含了一些命令行工具,比如tf2_echo、tf2_monitor、view_frames,调试的时候很有用。
然后创建一个新的工作空间和功能包:
mkdir -p ~/tf_demo/src cd ~/tf_demo/src catkin_create_pkg tf_demo roscpp tf2 tf2_ros tf2_geometry_msgs geometry_msgs cd ~/tf_demo catkin_make source devel/setup.bash5.2 编写一个发布动态变换的节点
在tf_demo/src下新建dynamic_tf_publisher.cpp:
#include <ros/ros.h> #include <tf2_ros/transform_broadcaster.h> #include <geometry_msgs/TransformStamped.h> #include <tf2/LinearMath/Quaternion.h> int main(int argc, char** argv) { ros::init(argc, argv, "dynamic_tf_publisher"); ros::NodeHandle nh; ros::Rate rate(10.0); tf2_ros::TransformBroadcaster br; double angle = 0.0; while (ros::ok()) { geometry_msgs::TransformStamped ts; ts.header.stamp = ros::Time::now(); ts.header.frame_id = "base_link"; ts.child_frame_id = "rotating_link"; ts.transform.translation.x = 0.5; ts.transform.translation.y = 0.0; ts.transform.translation.z = 0.0; tf2::Quaternion q; q.setRPY(0, 0, angle); ts.transform.rotation.x = q.x(); ts.transform.rotation.y = q.y(); ts.transform.rotation.z = q.z(); ts.transform.rotation.w = q.w(); br.sendTransform(ts); angle += 0.1; if (angle > 6.28) angle -= 6.28; rate.sleep(); } return 0; }这个节点发布了一个base_link→rotating_link的动态变换,rotating_link绕着base_link的Z轴旋转。编译后在CMakeLists.txt里加上:
add_executable(dynamic_tf_publisher src/dynamic_tf_publisher.cpp) target_link_libraries(dynamic_tf_publisher ${catkin_LIBRARIES})5.3 用命令行工具验证TF树
启动节点后,用view_frames生成TF树的PDF:
rosrun tf2_tools view_frames.py它会生成一个frames.pdf,打开就能看到当前的TF树结构。如果树里缺少某个坐标系,或者出现了闭环,view_frames会给出警告。
然后用tf2_echo查询两个坐标系之间的变换:
rosrun tf2_ros tf2_echo base_link rotating_link它会持续输出rotating_link相对于base_link的平移和旋转。如果你看到旋转角度在变化,说明动态变换发布成功了。
还有一个工具叫tf2_monitor,可以监控TF树的更新频率和延迟:
rosrun tf2_ros tf2_monitor base_link rotating_link它会输出平均更新频率、最大延迟、最坏延迟等统计信息。如果延迟很大,说明变换发布不及时,需要检查发布频率和时间戳。
5.4 在代码中查询变换并转换点坐标
新建tf_listener.cpp:
#include <ros/ros.h> #include <tf2_ros/transform_listener.h> #include <tf2_geometry_msgs/tf2_geometry_msgs.h> #include <geometry_msgs/PointStamped.h> int main(int argc, char** argv) { ros::init(argc, argv, "tf_listener"); ros::NodeHandle nh; tf2_ros::Buffer buffer; tf2_ros::TransformListener listener(buffer); ros::Rate rate(1.0); while (ros::ok()) { geometry_msgs::PointStamped point_in; point_in.header.frame_id = "rotating_link"; point_in.header.stamp = ros::Time(0); point_in.point.x = 1.0; point_in.point.y = 0.0; point_in.point.z = 0.0; try { geometry_msgs::PointStamped point_out; buffer.transform(point_in, point_out, "base_link"); ROS_INFO("Point in base_link: (%.2f, %.2f, %.2f)", point_out.point.x, point_out.point.y, point_out.point.z); } catch (tf2::TransformException &ex) { ROS_WARN("%s", ex.what()); } rate.sleep(); } return 0; }这个节点把rotating_link下的一个点转换到base_link下。由于rotating_link在旋转,转换后的坐标也会跟着变。编译运行后,你应该能看到坐标值在周期性变化。
6. 那些年我踩过的TF坑:排查思路与修复方案
6.1 “Lookup would require extrapolation into the future”
这是最常见的TF错误之一。原因是你查询的时间戳比TF缓冲区里最新的变换还要新。比如你查询ros::Time::now(),但变换的发布有延迟,缓冲区里最新的变换是50ms之前的。
排查思路:先用tf2_monitor看一下变换的延迟。如果延迟很大,说明发布频率不够或者时间戳不对。修复方法有两个:一是把查询时间改成ros::Time(0),让TF返回最新可用的变换;二是把查询时间改成数据的时间戳,而不是ros::Time::now()。
我一般推荐第二种,因为数据的时间戳和变换的时间戳对齐后,转换结果更准确。如果你用ros::Time(0),TF会返回最新变换,但最新变换可能对应的是旧数据,转换结果会有偏差。
6.2 “Lookup would require extrapolation into the past”
这个错误和上面相反:你查询的时间戳比缓冲区里最旧的变换还要旧。原因通常是缓冲区太小,或者变换发布中断了。
排查思路:检查Buffer的缓存时长。默认是10秒,如果你的传感器更新频率很低(比如1Hz),10秒可能只存了10个变换。如果你查询的时间戳是20秒之前的,就会报这个错。修复方法是增大缓存时长:
tf2_ros::Buffer buffer(ros::Duration(60.0));另外,如果变换发布节点崩溃了,缓冲区里的变换会逐渐过期,也会导致这个错误。用rosnode list检查一下发布节点是否还在运行。
6.3 “TF_REPEATED_DATA”和“TF_OLD_DATA”
这两个错误通常一起出现。TF_REPEATED_DATA表示同一个时间戳的变换被发布了多次,TF_OLD_DATA表示发布的时间戳比缓冲区里最新的还旧。
原因可能是:你在多个地方发布了同一个变换,或者发布频率太高导致时间戳重复。修复方法是确保每个变换只在一个地方发布,并且发布频率不要超过传感器数据频率。
我遇到过一次比较隐蔽的情况:两个节点分别发布了odom→base_link,一个用ros::Time::now(),一个用传感器时间戳。结果两个时间戳交替出现,TF就报TF_REPEATED_DATA。后来统一用一个节点发布,问题就解决了。
6.4 坐标系命名不规范导致的查询失败
TF对坐标系名称是大小写敏感的。base_link和Base_Link是两个不同的坐标系。如果你在发布的时候写base_link,查询的时候写Base_Link,TF会报“frame does not exist”。
另外,坐标系名称不要以/开头。虽然ROS1里有些地方允许/base_link,但TF2推荐不加前导斜杠。我见过有同学在URDF里写/base_link,在代码里写base_link,结果TF树里出现了两个节点,查询的时候总是失败。
还有一个坑:坐标系名称里不要有空格或特殊字符。TF只接受字母、数字、下划线和斜杠。如果你从URDF导入模型,检查一下link和joint的名称是否符合规范。
6.5 静态变换和动态变换冲突
前面提到过,同一个边不能同时用静态和动态方式发布。但有时候你可能会忘记自己已经用static_transform_publisher发布过某个变换,然后在代码里又发布了一次。TF会报“TF_OLD_DATA”或者直接忽略其中一个。
排查方法:用view_frames生成TF树,看看有没有重复的边。或者用rosrun tf2_tools tf2_monitor查看每个变换的发布者。如果发现同一个变换有多个发布者,把其中一个关掉就行。
7. TF2与TF1的区别:为什么新项目应该用TF2
如果你看的教程比较老,可能会看到tf::TransformBroadcaster和tf::TransformListener,这是TF1的API。TF2是TF1的升级版,主要改进有几点:
第一,TF2把核心逻辑和ROS解耦了。tf2包本身不依赖ROS,可以在非ROS环境里使用。tf2_ros包才提供ROS相关的接口。这样设计的好处是代码更清晰,测试也更容易。
第二,TF2支持更多的消息类型。TF1只支持geometry_msgs里的几种消息,TF2通过tf2_geometry_msgs包支持了PointStamped、PoseStamped、Vector3Stamped等更多类型。
第三,TF2的API更统一。TF1里查询变换用waitForTransform和lookupTransform两个函数,TF2合并成了lookupTransform一个函数,通过超时参数控制等待行为。
第四,TF2的性能更好。TF2的缓冲区实现更高效,查询时的插值计算也更快。在大规模TF树(几百个坐标系)的场景下,TF2的优势很明显。
迁移的时候注意:TF1和TF2的消息类型是兼容的,都是geometry_msgs/TransformStamped。但API不兼容,需要把tf::换成tf2_ros::,把tf2::换成tf2::(数学库部分)。另外,TF1的tf::TransformListener会自动订阅/tf和/tf_static,TF2的tf2_ros::TransformListener需要你手动创建Buffer。
8. 进阶技巧:用Python快速验证TF逻辑
C++编译慢,调试TF的时候我经常用Python快速验证。tf2_ros提供了Python接口:
import rospy import tf2_ros import geometry_msgs.msg from tf.transformations import quaternion_from_euler rospy.init_node('py_tf_broadcaster') br = tf2_ros.TransformBroadcaster() t = geometry_msgs.msg.TransformStamped() t.header.stamp = rospy.Time.now() t.header.frame_id = "base_link" t.child_frame_id = "py_link" t.transform.translation.x = 0.3 t.transform.translation.y = 0.0 t.transform.translation.z = 0.1 q = quaternion_from_euler(0, 0, 0.5) t.transform.rotation.x = q[0] t.transform.rotation.y = q[1] t.transform.rotation.z = q[2] t.transform.rotation.w = q[3] rate = rospy.Rate(10) while not rospy.is_shutdown(): t.header.stamp = rospy.Time.now() br.sendTransform(t) rate.sleep()Python版的TransformBroadcaster用法和C++几乎一样,但不需要编译,改完直接跑。调试的时候我一般先用Python把TF树搭起来,确认逻辑没问题,再移植到C++。
查询的时候用tf2_ros.Buffer和tf2_ros.TransformListener:
import rospy import tf2_ros rospy.init_node('py_tf_listener') buffer = tf2_ros.Buffer() listener = tf2_ros.TransformListener(buffer) rate = rospy.Rate(1) while not rospy.is_shutdown(): try: trans = buffer.lookup_transform('base_link', 'py_link', rospy.Time(0), rospy.Duration(1.0)) rospy.loginfo("Translation: (%.2f, %.2f, %.2f)", trans.transform.translation.x, trans.transform.translation.y, trans.transform.translation.z) except (tf2_ros.LookupException, tf2_ros.ConnectivityException, tf2_ros.ExtrapolationException) as e: rospy.logwarn(e) rate.sleep()Python版的异常类型和C++略有不同,但逻辑是一样的。用Python调试的好处是你可以快速修改参数、打印中间结果,不用每次都catkin_make。
9. 实际项目中的TF设计经验
9.1 坐标系命名要有层次感
我习惯用<robot_name>/<link_name>的格式,比如robot1/base_link、robot2/base_link。这样在多机器人系统里不会冲突。但要注意:TF树里所有坐标系必须在同一个命名空间下,不能有孤立的子树。如果两个机器人各自有独立的TF树,需要用map或world把它们连起来。
9.2 变换发布频率要匹配数据频率
不要为了“保险”而提高发布频率。变换发布频率高于数据频率并不会让结果更准,反而会增加TF缓冲区的负担。一般来说,变换发布频率等于传感器数据频率就行。如果是静态变换,用static_transform_publisher发布一次即可。
9.3 时间戳同步是重中之重
TF查询失败十有八九是时间戳问题。我的经验是:发布变换时用数据的时间戳,查询变换时也用数据的时间戳。不要混用ros::Time::now()和传感器时间戳。如果传感器数据没有时间戳,那就统一用ros::Time::now(),但要确保发布和查询在同一个时间基准上。
9.4 用tf2_tools做定期检查
项目跑起来之后,定期用view_frames和tf2_monitor检查TF树的健康状态。看看有没有孤立的坐标系、有没有延迟过大的变换、有没有重复发布的边。这些检查花不了几分钟,但能提前发现很多潜在问题。
9.5 不要在回调函数里做复杂的TF查询
TF查询本身很快,但如果在高频回调函数里做复杂的TF查询(比如查询多个坐标系、做多次插值),可能会拖慢回调的处理速度。我的做法是:在回调函数里只做必要的TF查询,把结果存下来,复杂的计算放到单独的线程或定时器里做。
10. 关于TF学习路径的一点个人建议
TF的概念不多,但细节很多。我建议的学习路径是:先理解坐标系树和变换的数学含义,然后动手写一个最简单的发布者和监听者,跑通之后再去看static_transform_publisher和tf2_tools的用法。最后找一个实际的机器人模型(比如TurtleBot3的URDF),把它的TF树打印出来,对照着看每个坐标系代表什么。
不要一开始就啃TF2的源码,那样容易陷进去。先把API用熟,遇到问题再去看源码里的实现。TF2的源码其实很清晰,BufferCore类里的lookupTransform函数就是核心逻辑,等你有了一定使用经验之后再看,会豁然开朗。
另外,ROS2里的TF2和ROS1的TF2 API基本一致,但有一些细节差异(比如时间类型从ros::Time变成了builtin_interfaces::msg::Time)。如果你打算迁移到ROS2,TF这部分的学习成本很低,大部分代码改改类型就能用。
我在实际项目里最深的体会是:TF不是“学会了再用”的东西,而是“用着用着就学会了”的东西。一开始可能会被各种报错搞得头大,但每解决一个报错,你对坐标系变换的理解就会深一层。等你能够闭着眼睛画出系统的TF树,并且知道每个变换由哪个节点发布、频率是多少、时间戳怎么对齐,那TF这一关就算过了。