ROS教程08:从 TransportTCP::connect() 追到 TCPROS Connection Header、序列化与 Socket 数据传输
摘要:沿 ROS1 Noetic roscpp 的 TCPROS 数据面源码,追踪连接握手、Header 校验、消息序列化、发送队列、Socket 收发与接收侧组帧。
@[toc]
第 07 章已经完成 ROS1 Topic 的控制面与发现流程:Publisher/Subscriber 向 Master 注册,Subscriber 通过 registerSubscriber 返回值或 publisherUpdate 得到 Publisher Node API URI,再通过 requestTopic 与 Publisher 协商 TCPROS,最终进入:
1 | Subscription::pendingConnectionDone() |
第 08 章从这里继续。为了避免 pendingConnectionDone() 像一个凭空出现的入口,本章先保留第 07 章到 TCPROS 数据面的最小桥接链:Subscription::pubUpdate() 怎样汇合两种发现路径、requestTopic 为什么是异步完成、以及 PendingConnection::check() 怎样最终进入 pendingConnectionDone()。Master/XML-RPC 的注册发现细节不再重复展开。随后只回答一个问题:
双方已经知道 TCPROS 地址以后,一条 ROS 消息怎样完成 TCP 建链、Connection Header 握手、序列化、Socket 发送、接收组帧,并最终进入 Subscriber 的消息处理链?
当前实验仍使用:
1 | ros_ws/src/ros1_hello/src/hello_node.cpp |
源码版本固定为 ROS1 Noetic noetic-devel。ROS1 Noetic 已于 2025-05-31 EOL,ros/ros_comm 上游仓库也已归档;本章讨论的是 Noetic 的实际实现,不把第三方 fork 的行为混入主线。
1. 从 Subscription::pubUpdate() 到 pendingConnectionDone():先补齐 07→08 的异步桥接
第 07 章已经确认,Subscriber 得到 Publisher 的 Node XML-RPC URI 后,会进入:
1 | Subscription::pubUpdate() |
但这里还需要补清两个问题:
Subscription::pubUpdate()是谁调用到的?requestTopic发出以后,为什么最后会进入Subscription::pendingConnectionDone()?
这两个问题决定了第 08 章的 TCPROS 入口是否连续。
1.1 Subscription::pubUpdate() 有两个入口,但最终汇合到同一个函数
Publisher 与 Subscriber 的启动顺序不同,第一次拿到 Publisher URI 的方式也不同。
Publisher 已经先存在时,Subscriber 调用 Master API:
1 | registerSubscriber(/chatter) |
Master 的返回 payload 中已经包含当前 Publisher 的 Node XML-RPC URI 列表。TopicManager::registerSubscriber() 解析该列表后直接调用:
1 | s->pubUpdate(pub_uris); |
因此第一条路径是:
1 | registerSubscriber 返回 Publisher URI |
Subscriber 已经先存在时,后启动的 Publisher 执行 registerPublisher() 后,Master 会调用 Subscriber Node API:
1 | publisherUpdate |
Subscriber 进程在 TopicManager::start() 阶段已经绑定该 XML-RPC 方法。调用进入:
1 | TopicManager::pubUpdateCallback() |
所以两种发现路径最终都汇入同一个 Subscription::pubUpdate():
1 | flowchart TD |
Subscription::pubUpdate() 的职责不是“处理 Master 消息”这么简单,而是:
把当前已知的 Publisher URI 集合与本地已有连接、待完成连接进行同步,决定哪些远端需要新增连接、哪些旧连接需要删除。
因此它会得到类似:
1 | additions |
对新增 Publisher URI,再调用:
1 | negotiateConnection(xmlrpc_uri); |
从这里才进入传输协议协商。
1.2 Master 返回的是 Publisher Node API URI,不是 TCPROS 数据端口
这一层最容易把三个网络入口混在一起:
| 入口 | 典型形式 | 谁提供 | 作用 |
|---|---|---|---|
| Master XML-RPC URI | http://master-host:11311/ |
ROS_MASTER_URI / Master |
registerPublisher、registerSubscriber 等注册发现 |
| Publisher Node XML-RPC URI | http://publisher-host:<xmlrpc-port>/ |
Publisher XMLRPCManager |
requestTopic 等 Node API |
| Publisher TCPROS host/port | publisher-host:<tcpros-port> |
Publisher 对 requestTopic 的响应 |
真正建立 Topic TCP 数据连接 |
因此实际顺序不是:
1 | Master |
而是:
1 | Master |
Master 负责发现与联系信息,不进入后续 Topic payload 数据通路。
1.3 requestTopic 是异步完成的,pendingConnectionDone() 不是紧跟着同步调用
Subscription::negotiateConnection() 创建 XML-RPC client 后执行:
1 | c->executeNonBlock("requestTopic", params); |
随后创建:
1 | PendingConnection |
并交给:
1 | XMLRPCManager::instance()->addASyncConnection(conn); |
这一步只是把异步请求加入 XMLRPCManager 管理,不会在当前调用栈中等待 Publisher 返回。
XMLRPCManager::serverThreadFunc() 后台线程会把异步连接加入自己的 dispatch,并在循环中执行:
1 | if ((*it)->check()) |
PendingConnection::check() 再检查:
1 | XmlRpc::XmlRpcValue result; |
因此真实调用顺序是:
1 | Subscription::pubUpdate() |
这里还有一个线程边界,而且 pubUpdate() 自身的执行线程取决于它从哪条入口到达:
1 | Publisher 已经存在: |
所以不能把:
1 | requestTopic |
理解成普通同步函数调用。
1.4 pendingConnectionDone():从控制面协商切换到 TCPROS 数据连接
对应源码:
1 | ros_comm/clients/roscpp/src/libros/subscription.cpp |
Subscription::pendingConnectionDone() 首先验证 requestTopic 的 XML-RPC 返回值,并取出:
1 | std::string pub_host = proto[1]; |
随后才创建 TCPROS 所需对象:
1 | TransportTCPPtr transport( |
因此 pendingConnectionDone() 可以看成一个明确的阶段边界:
1 | XML-RPC requestTopic 协商完成 |
这里第一次同时出现三个关键对象:
1 | TransportTCP |
它们不是同一层。
| 对象 | 当前职责 |
|---|---|
TransportTCP |
直接封装 TCP socket、connect/recv/send、PollSet 读写事件 |
Connection |
在 Transport 之上提供定长异步 read/write、Connection Header 读写和 drop 生命周期 |
TransportPublisherLink |
Subscriber 进程中“指向远端 Publisher”的 Topic 连接对象 |
这里有一个容易读反的命名规则:
1 | Subscriber 进程 |
因此 TransportPublisherLink 并不运行在 Publisher 进程里;它运行在 Subscriber 这一侧。
2. TransportTCP::connect():先建立 non-blocking socket,再交给 PollSet
源码:
1 | ros_comm/clients/roscpp/src/libros/transport/transport_tcp.cpp |
TransportTCP::connect() 的主干可以压缩为:
1 | sock_ = socket(..., SOCK_STREAM, 0); |
2.1 为什么 connect() 返回 true 时 TCP 可能仍在连接
当前 TransportTCP 默认不是同步模式:
1 | SYNCHRONOUS flag 未设置 |
socket 已经被设置成 non-blocking,因此 Linux 上:
1 | ::connect(...) |
可能返回:
1 | 0 |
roscpp 把第二种情况也视为“连接流程已成功启动”。后续第一次 read() 或 write() 时,TransportTCP 会再次检查异步连接是否真正完成。
所以:
1 | TransportTCP::connect() == true |
不能机械理解成:
1 | TCP 三次握手此刻已经百分之百完成 |
更准确的含义是:
1 | socket 已创建 |
2.2 TransportTCP 为什么要持有 PollSet*
创建对象时已经传入:
1 | &PollManager::instance()->getPollSet() |
因此当前 TCP socket 后续可以注册到 roscpp 的 PollSet 中。
上一章已经确认 Poll 线程循环调用:
1 | poll_signal_() |
当 socket 变成:
1 | 可读 |
PollSet 再通过 TransportTCP::socketUpdate() 触发 Transport 层回调。
因此 TCPROS 并不是给每条 Topic TCP 连接创建一个“永久阻塞在 recv() 上的独立线程”。Noetic roscpp 的核心网络 I/O 由 PollManager/PollSet 驱动。
3. Connection::initialize():把 Transport 的事件转换成 Connection 的 read/write 状态机
Subscriber 侧创建 Connection 后执行:
1 | connection->initialize( |
源码:
1 | ros_comm/clients/roscpp/src/libros/connection.cpp |
核心实现:
1 | void Connection::initialize( |
Subscriber 侧这里传入:
1 | is_server = false |
因此 Connection::initialize() 此时不会立即等待远端 Header。
真正的 Header 握手由下一步:
1 | pub_link->initialize(connection); |
发起。
Connection 解决了 Transport 层没有解决的问题
TransportTCP::read() 本质上只是:
1 | ::recv(sock_, ...) |
一次 recv() 并不能保证把应用层想要的 N 字节一次全部读完。
TCP 是字节流。即使发送端一次 send() 了 100 字节,接收端也可能经历:
1 | recv() -> 24 bytes |
Connection::read(size, callback) 在这一层维护:
1 | read_size_ |
只有累计到目标 size 后,才调用上层 callback。
写方向同理:
1 | write_size_ |
所以 Connection 是非常关键的一层:
TransportTCP面向“socket 本次能读写多少”;Connection面向“上层要求完整读写多少字节”。
4. TransportPublisherLink::initialize():Subscriber 先发送 TCPROS Connection Header
源码:
1 | ros_comm/clients/roscpp/src/libros/transport_publisher_link.cpp |
核心代码:
1 | bool TransportPublisherLink::initialize( |
Subscriber 发给 Publisher 的 Topic TCPROS Header 至少包含当前这些字段:
| 字段 | 当前 /chatter 中的意义 |
|---|---|
topic |
/chatter |
md5sum |
Subscriber 期望的消息 MD5 |
callerid |
例如 /hello_listener |
type |
std_msgs/String |
tcp_nodelay |
是否请求 Publisher 对该 TCP socket 设置 TCP_NODELAY |
这里需要注意:
1 | requestTopic |
只完成:
1 | 传输协议 + TCP 地址协商 |
真正的数据类型兼容性检查并不靠 Master 完成,而是在 TCP socket 建立以后,通过 Connection Header 再校验。
5. Connection Header 在 TCP 流中到底长什么样
Connection::writeHeader() 先调用:
1 | Header::write(key_vals, buffer, len); |
随后又额外分配:
1 | uint32_t msg_len = len + 4; |
因此整个 TCPROS Connection Header 外层是:
1 | +----------------------------+ |
而 Header::write() 内部每个字段又编码成:
1 | +---------------------+ |
所以完整结构是:
1 | uint32 total_header_length |
字段来自 M_string,在 roscpp 中本质是字符串 map。理解协议时不要依赖“某个字段一定排第几个”;应该按 key=value 语义解析。
更重要的是,Connection Header 本身还不是 ROS message payload。此时双方只是在确认:
1 | 对端 callerid 是什么? |
6. Publisher 接受 TCP socket:ConnectionManager 创建 server-side Connection
Publisher 进程早在 ros::start() 阶段已经执行:
1 | ConnectionManager::start() |
当 Subscriber 的 TCP 连接到来后:
1 | ConnectionManager::tcprosAcceptConnection( |
这次:
1 | is_server = true |
因此 Connection::initialize() 会立刻执行:
1 | read(4, ... onHeaderLengthRead ...); |
也就是先读 Subscriber Header 最前面的:
1 | 4-byte total_header_length |
然后:
1 | onHeaderLengthRead() |
这已经形成一个很典型的网络协议解析模式:
1 | 先读定长帧头 |
这个模式与很多 MCU/UART/TCP 自定义协议的“长度字段 + payload”非常接近,只是这里的 Transport 是 TCP 字节流。
7. Publisher 收到 Header 后,为什么会创建 TransportSubscriberLink
Connection::onHeaderRead() 解析成功后执行:
1 | transport_->parseHeader(header_); |
Publisher 侧的 header_func_ 就是:
1 | ConnectionManager::onConnectionHeaderReceived() |
它根据 Header 中的字段区分 Topic 与 Service:
1 | if (header.getValue("topic", val)) |
因此同一个 TCPROS 监听入口接到 TCP 连接以后,要先看 Connection Header 才知道:
1 | 这是 Topic Subscriber 连接 |
还是:
1 | 这是 Service TCP 连接 |
本章只继续 Topic 分支。
上图最重要的边界是:
requestTopic已经结束,Master 与 XML-RPC 不再参与当前 TCP payload;- Subscriber 主动建立 TCP 连接,并先发 Topic Connection Header;
- Publisher 收到 Header 后才创建
TransportSubscriberLink; - Publisher 校验成功后再回自己的 Header;
- Subscriber 收到 Publisher Header 后才进入持续读取消息长度的状态。
8. TransportSubscriberLink::handleHeader():真正检查 Topic 与消息兼容性
Publisher 侧源码:
1 | ros_comm/clients/roscpp/src/libros/transport_subscriber_link.cpp |
首先取出:
1 | std::string topic; |
再寻找本进程对应的 Publication:
1 | PublicationPtr pt = |
如果 Publisher 已经不再发布该 Topic:
1 | lookupPublication() -> null |
当前 TCP 连接不能继续。
随后:
1 | if (!pt->validateHeader(header, error_msg)) |
Publication::validateHeader() 至少要求:
1 | md5sum |
并检查 Publisher 与 Subscriber 的 MD5 是否兼容。普通强类型 Topic 中,如果双方 MD5 不一致,就会拒绝连接,而不是“先收下来再尝试解析”。
这解释了 ROS1 中很常见的一类错误:
1 | Topic 名字相同 |
消息契约还包括:
1 | datatype / md5sum |
其中真正用于兼容性校验的关键标识是 MD5。
9. Publisher 回 Header,同时 tcp_nodelay 在哪里真正生效
校验通过后,Publisher 构造返回 Header:
1 | M_string m; |
Subscriber 随后收到 Publisher Header,并进入:
1 | TransportPublisherLink::onHeaderReceived() |
到这里 Connection Header 握手才真正结束。
tcp_nodelay 为什么是 Subscriber 请求、Publisher socket 执行
Subscriber 发出的 Header 中:
1 | header["tcp_nodelay"] = |
Publisher 侧 Connection::onHeaderRead() 会先执行:
1 | transport_->parseHeader(header_); |
对于 TransportTCP:
1 | void TransportTCP::parseHeader(const Header& header) |
最终:
1 | setsockopt( |
因此:
1 | ros::TransportHints().tcpNoDelay() |
不是抽象的 ROS 调度参数。它最终会影响对应 TCP socket 的 TCP_NODELAY 选项。
Nagle 算法的目标是减少大量小 TCP segment:当连接上已经存在尚未确认的数据时,新的小数据可以被暂存,等待 ACK 或更多数据到来后再发送,从而提高网络利用率。Linux tcp(7) 对 TCP_NODELAY 的定义则很直接:设置后禁用 Nagle,使少量数据也尽可能及时发送。
两者关系可以压缩成:
1 | 默认 TCP 行为 |
TransportHints::tcpNoDelay() 的形参默认值是 true,但一个普通默认构造的 TransportHints 并没有设置 tcp_nodelay 选项,getTCPNoDelay() 会返回 false。也就是说:调用 .tcpNoDelay() 时默认请求开启,但 roscpp 并不是默认对所有 Topic 连接开启 TCP_NODELAY。
另外,TCP_NODELAY 只改变 TCP 小数据发送策略,不改变 TCP 是字节流协议这一事实,也不保证“一条 ROS message 对应一个 TCP packet”。具体使用场景与工程取舍放在后文单独收敛。
10. Header 握手完成后,TCPROS 数据帧变成“4 字节消息长度 + 序列化消息”
Subscriber 侧握手完成后:
1 | connection_->read( |
onMessageLength():
1 | uint32_t len = *((uint32_t*)buffer.get()); |
消息读取形成固定循环:
1 | read(4) |
这里必须把两个“4 字节长度”分开:
| 阶段 | 4 字节长度代表什么 |
|---|---|
| TCPROS Connection Header | 后续整个 Header body 的长度 |
| 正常 Topic message | 后续这一条 serialized message body 的长度 |
两者都采用长度前缀,但 body 的内部格式完全不同。
11. serializeMessage():为什么 SerializedMessage 本身已经包含最外层 4 字节长度
第 07 章已经确认:
1 | Publisher::publish() |
本章继续进入:
1 | roscpp_serialization/serialization.h |
核心模板:
1 | template<typename M> |
所以跨进程 Topic 发送时,SerializedMessage::buf 并不是只包含 .msg 字段本身。
它已经是:
1 | +-----------------------+ |
这也解释了 Publisher 侧 TransportSubscriberLink 最终为什么可以直接:
1 | connection_->write(m.buf, m.num_bytes, ...); |
不需要在发送前再额外拼一次消息长度。
12. std_msgs/String 的字节到底怎样排布
当前消息:
1 | std_msgs/String |
定义只有:
1 | string data |
生成的 std_msgs::String Serializer 最终会:
1 | stream.next(m.data) |
而 std::string 的 roscpp serialization 规则是:
1 | uint32 string_length |
假设为了抓包观察,临时把 Publisher 的消息固定成:
1 | std_msgs::String msg; |
字符串 abc 的长度是 3,因此 .msg body 的序列化结果是 7 字节:
1 | 03 00 00 00 61 62 63 |
在当前 x86_64 Ubuntu/Noetic 环境中,uint32_t 长度字段表现为小端字节序,因此 3 为:
1 | 03 00 00 00 |
但 TCPROS 外层还要再加一层整条 ROS message body 的长度。
body 长度是:
1 | 4 + 3 = 7 bytes |
因此最终交给 Connection::write() 的完整 SerializedMessage::buf 是:
1 | 07 00 00 00 03 00 00 00 61 62 63 |
这 11 个字节非常适合作为本章的抓包锚点。
为什么这里会出现两个长度字段
因为它们属于不同层:
1 | TCPROS message framing |
因此以后看到复杂消息时,不要把:
1 | TCPROS frame length |
和:
1 | 数组/string 字段自己的 length |
混为一谈。
这和嵌入式协议中:
1 | 帧长度 |
是同一个分层思想。
13. Publication::publish_queue_ 与 TransportSubscriberLink::outbox_ 不是同一条队列
第 07 章已经看到:
1 | Publisher::publish() |
Poll 线程随后执行:
1 | TopicManager::processPublishQueues() |
到了 TransportSubscriberLink::enqueueMessage(),又出现第二层队列:
1 | boost::mutex::scoped_lock lock(outbox_mutex_); |
所以 Publisher 数据面至少要区分:
1 | Publication::publish_queue_ |
第二条尤其重要。
如果一个 Publisher 同时连接:
1 | Subscriber A:网络正常 |
每个远端 Subscriber 都有自己的 TransportSubscriberLink,也就有自己的 outbox_ 状态。
Publisher 的 queue_size=10 在跨进程发送路径中约束什么
当前代码:
1 | nh.advertise<std_msgs::String>("chatter", 10); |
10 被存进 Publication::max_queue_,TransportSubscriberLink 再通过:
1 | parent->getMaxQueue() |
取得它。
当某个 Subscriber 的发送 outbox_ 已经达到上限时:
1 | 丢弃最旧的一条 |
因此这里的 queue_size 不是:
1 | Linux TCP send buffer = 10 bytes |
也不是:
1 | 整个 ROS 系统最多缓存 10 条消息 |
它是 roscpp Publisher 针对 Subscriber 发送积压所使用的消息队列上限。
这与 Subscriber 端:
1 | nh.subscribe("chatter", 10, chatterCallback); |
的 10 也不是同一条队列。
Subscriber 的 queue_size 对应:
1 | SubscriptionQueue |
也就是消息已经从网络收到以后,等待业务 callback 消费时的队列容量。
所以至少要区分:
| 位置 | 队列 | 慢在什么地方会积压 |
|---|---|---|
| Publisher 内部 | Publication::publish_queue_ |
publish 侧到 Poll 分发侧 |
| Publisher -> 某 Subscriber | TransportSubscriberLink::outbox_ |
网络发送/远端接收跟不上 |
| Subscriber 业务侧 | SubscriptionQueue |
callback 处理跟不上 |
第 09 章会继续专门处理第三条队列与 Spinner/线程模型;本章只需要把网络发送队列边界建立清楚。
14. startMessageWrite():一条消息怎样从 outbox 交给 Connection
TransportSubscriberLink::enqueueMessage() 把消息加入 outbox_ 后会触发发送。
核心函数:
1 | void TransportSubscriberLink::startMessageWrite( |
这里有两个重要门槛:
1 | header_written_ == true |
说明 TCPROS Connection Header 还没回给 Subscriber 之前,不会开始正常消息发送。
以及:
1 | writing_message_ == false |
说明同一个 Connection 不会同时挂多次独立 write 操作。
上一条发送完成后:
1 | Connection write finished |
于是每条 TCPROS 连接形成一个串行发送状态机。
15. Connection::write():为什么一次 send() 不完整也不会破坏消息
Connection::write() 不是直接假设:
1 | send(buffer, size) 一次就一定写完 size 字节 |
它先保存:
1 | write_buffer_ = buffer; |
然后:
1 | transport_->enableWrite(); |
如果允许立即发送,还会先执行一次:
1 | writeTransport(); |
Connection::writeTransport() 中:
1 | uint32_t to_write = |
直到:
1 | write_sent_ == write_size_ |
才调用 write finished callback。
而最底层:
1 | TransportTCP::write(...) |
最终就是:
1 | ::send(sock_, ...) |
如果 non-blocking socket 当前不能继续写:
1 | EAGAIN / EWOULDBLOCK |
TransportTCP::write() 返回 0,不把这次情况当作永久错误关闭连接。
等 PollSet 再次报告 socket 可写:
1 | TransportTCP callback |
继续从:
1 | write_sent_ |
的位置发送剩余字节。
因此:
TCP 的 partial write 由
Connection层吸收,上层TransportSubscriberLink看到的是“整条 SerializedMessage 什么时候写完”。
这和常见 MCU/Linux socket 驱动中维护:
1 | tx_buffer + tx_offset + remaining_length |
本质完全一致。
16. Subscriber 接收:也是先读 4 字节,再按长度读完整 message body
Publisher 发送的 SerializedMessage::buf 是:
1 | uint32 message_length |
Subscriber 侧 TransportPublisherLink 已经在握手完成时调用:
1 | connection_->read(4, onMessageLength); |
当 4 字节收齐后:
1 | void TransportPublisherLink::onMessageLength(...) |
消息 body 收齐后:
1 | void TransportPublisherLink::onMessage(...) |
这条逻辑有两个值得注意的点。
第一,Subscriber 收到 body 后创建的:
1 | SerializedMessage(buffer, size) |
这里的 buffer 已经不再包含最外面的那 4 字节 TCPROS message length,因为那 4 字节在 onMessageLength() 阶段已经单独消费掉了。
第二,消息处理完成后立刻再次:
1 | read(4, ...) |
为下一条 TCPROS message 等待长度。
因此 TCP 字节流虽然没有天然“消息边界”,roscpp 通过每条消息前面的 4 字节长度重新建立了 framing。
17. 从 onMessage() 到 SubscriptionQueue:反序列化为什么不是网络线程直接调用业务函数
TransportPublisherLink::onMessage() 最终进入父类/Subscription 的消息处理链:
1 | TransportPublisherLink::onMessage() |
第 07 章已经看过 Subscription::handleMessage() 的后半段。本章只补上数据面的连接关系:
1 | TCP socket bytes |
Subscription::handleMessage() 会创建或复用:
1 | MessageDeserializer |
再把它放进:
1 | SubscriptionQueue |
业务 callback 并不是在 TransportTCP::read() 或 Connection::readTransport() 里直接执行。
真正的:
1 | 反序列化出 std_msgs::String 对象 |
会随着 SubscriptionQueue 被 Spinner 从 CallbackQueue 中取出而发生。
到这里应该明确分开:
1 | Poll/network 执行面 |
这也是下一章 09:CallbackQueue、Spinner 与 roscpp 并发模型 的正式入口。本章不继续展开 MultiThreadedSpinner、AsyncSpinner 和自定义 CallbackQueue。
18. 把完整数据面重新串起来
完成前面的源码主线后,可以把第 07 章末尾到当前消息入队的位置统一看成两阶段:
1 | 阶段 1:TCPROS 建链 |
这张图中要特别区分四层:
1 | ROS Topic 层 |
Connection 之所以值得单独理解,是因为它把 Topic 和底层 TCP 解耦:上层不需要自己处理 partial read/write、Connection Header 长度和 transport callback。
19. 用 tcpdump/Wireshark 把源码与 TCP 字节对应起来
源码已经说明了帧格式,下一步要让网络证据与代码对应。
当前 Docker 使用:
1 | network_mode: host |
因此 ROS Node 与 Host 共用网络 namespace。抓包最直接的方式是在 Ubuntu Host 执行。
19.1 先确认 TCPROS 连接
启动:
1 | roscore |
查看 listener:
1 | rosnode info /hello_listener |
再结合 Host:
1 | ss -tnp |
找到 hello_node 与 hello_listener 之间已建立的 TCPROS 连接和端口。
不要把:
1 | 11311 |
当成 TCPROS port。11311 是 Master XML-RPC;Topic TCPROS 使用的是 ConnectionManager 的 TCP 监听端口。
19.2 捕获 TCPROS 流
假设已经确认 Publisher TCPROS port 为:
1 | TCPROS_PORT |
Host 执行:
1 | sudo tcpdump -i any -nn -s 0 \ |
捕获几条消息后 Ctrl+C。
也可以直接查看十六进制:
1 | sudo tcpdump -i any -nn -s 0 -X \ |
其中 TCPROS_PORT 需要替换为实际数字,不能原样执行。
19.3 为了逐字节确认,临时把消息固定成 abc
在 hello_node.cpp 的实验分支中临时改成:
1 | std_msgs::String msg; |
重新构建并运行后,在 Wireshark 中对该 TCP 连接使用:
1 | Follow |
并切换到十六进制视图。
在 Connection Header 完成之后,正常消息 payload 中应能找到与下面结构一致的连续字节:
1 | 07 00 00 00 03 00 00 00 61 62 63 |
对应:
1 | 07 00 00 00 |
需要注意,TCP 是字节流协议:一个 ROS message 不保证对应一个单独 TCP packet。抓包时可能看到:
1 | 一个 ROS frame 被 TCP 分段 |
或:
1 | 多个小段在抓包显示中组合 |
因此判断 ROS message 边界应该依据 TCP 字节流中的长度字段,而不是依据“Wireshark 一行 packet 就是一条 ROS 消息”。
实验完成后恢复原来的 hello_node.cpp 消息构造逻辑并重新构建,避免把临时固定 payload 留在正式示例中。
20. TCP_NODELAY 的使用场景与工程选择
前面已经从源码确认:Subscriber 通过 TransportHints().tcpNoDelay() 把 tcp_nodelay=1 放入 TCPROS Connection Header,Publisher 收到后最终在对应 socket 上执行:
1 | setsockopt(..., TCP_NODELAY, ...) |
因此问题不再是“这个选项有没有生效”,而是:什么 Topic 值得用,什么 Topic 没必要用。
20.1 判断依据不是“频率高不高”,而是延迟与吞吐目标
可以先用下面的工程维度判断:
| ROS Topic 场景 | 倾向 | 原因 |
|---|---|---|
cmd_vel、小型控制命令 |
倾向开启 | 单条消息较小,通常更关心最新命令尽快到达 |
| 控制回路中的 setpoint / 小型 feedback | 可考虑开启 | latency / jitter 往往比减少小包更重要,应结合实际周期测量 |
| 高频、小消息、明确 latency-sensitive | 可考虑开启 | Nagle 的等待可能成为额外延迟来源 |
Image、PointCloud2 等大消息 |
通常保持默认 | payload 本身已较大,Nagle 通常不是主要瓶颈 |
| 普通日志、监控、低优先级 telemetry | 通常保持默认 | 没必要为潜在的微小延迟收益增加更多小包 |
| 带宽受限网络或大量小 Topic 并发 | 谨慎开启 | 小 segment 数量增加后,协议头、内核处理和网络负担可能上升 |
因此更实用的原则是:
控制链优先考虑
tcpNoDelay();吞吐型数据链默认保持 TCP 默认行为。没有实际延迟问题时,不把TCP_NODELAY当成全局性能优化开关。
20.2 TCP_NODELAY 解决不了 ROS 队列和 callback 阻塞
即使 TCP socket 已经关闭 Nagle,消息仍然可能在其它层产生延迟,例如:
1 | Publication::publish_queue_ |
所以看到“Topic 延迟大”时不能直接推导:
1 | 打开 TCP_NODELAY 就能解决 |
TCP_NODELAY 只针对 TCP 小数据发送策略;queue_size、发送积压、Subscriber 消息队列和 Spinner/业务线程属于不同层次。
20.3 抓包时不要用 packet 数量判断 ROS message 语义
开启 TCP_NODELAY 后,抓包中可能出现更多小 TCP segment,但不能据此建立:
1 | TCP_NODELAY = 1 |
TCP 始终提供字节流。实际 packet/segment 的边界仍受 MSS、TCP 栈调度、拥塞控制、GSO/TSO/GRO 等因素影响。
分析 ROS message 边界仍应回到 TCPROS framing:
1 | 4-byte message length |
如果要决定某个驱动 Topic 是否开启 tcpNoDelay(),真正有价值的指标是端到端 latency / jitter 与系统整体网络负载,而不是抓包中“包看起来更多还是更少”。
21. 与驱动开发的关系:Topic 数据面问题应该从哪一层开始判断
到了这一章,看到:
1 | rostopic echo /sensor_data |
没有输出时,问题已经不能只粗略归类成“ROS Topic 坏了”。
至少可以继续拆成:
1 | 发现层是否完成? |
这对 CAN/UART/Ethernet 驱动 Node 尤其重要。
例如设备线程已经正确读到 CAN 数据,但 ROS 上层偶发“旧数据”或“延迟越来越大”,不能只检查:
1 | CAN RX 是否正常 |
还要继续判断:
1 | 驱动 publish 频率 |
第 08 章建立的是网络数据面;第 09 章再继续进入 callback 执行面。两者拼起来后,才能完整分析:
1 | 设备数据什么时候被读到 |
22. 本章源码主线
按下面顺序阅读即可:
1 | Subscription::pubUpdate() |
完成这条链以后,ROS1 Topic 已经从:
1 | API |
一路追到:
1 | 真实 TCP socket bytes |
并重新回到 Subscriber 的消息队列。
下一章进入:
09:CallbackQueue、Spinner 与 roscpp 并发模型——从“消息已经入队”继续追到“哪个线程在什么时候真正执行 callback”。
重点将不再重复 spin() 的基本作用,而是比较 SingleThreadedSpinner、MultiThreadedSpinner、AsyncSpinner、自定义 CallbackQueue,以及 callback 阻塞、共享状态、锁和驱动 I/O 线程之间的关系。
参考源码与资料
本章实现以 ROS1 Noetic noetic-devel 源码为主:
subscription.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/subscription.cppconnection_manager.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/connection_manager.cppconnection.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/connection.cpptransport_publisher_link.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/transport_publisher_link.cpptransport_subscriber_link.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/transport_subscriber_link.cpptransport_tcp.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/transport/transport_tcp.cpppublication.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/publication.cpppublisher_link.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/publisher_link.cppheader.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/header.cpp- roscpp_serialization
serialization.h:https://github.com/ros/roscpp_core/blob/noetic-devel/roscpp_serialization/include/ros/serialization.h - Noetic roscpp API:
https://docs.ros.org/en/noetic/api/roscpp/html/ - Noetic
ros::TransportHints:https://docs.ros.org/en/noetic/api/roscpp/html/classros_1_1TransportHints.html - Linux
tcp(7)/TCP_NODELAY:https://man7.org/linux/man-pages/man7/tcp.7.html - RFC 896(Nagle 原始算法背景):
https://www.rfc-editor.org/info/rfc896/ - Noetic
std_msgs/String:https://docs.ros.org/en/noetic/api/std_msgs/html/msg/String.html - ROS1 Noetic EOL:
https://www.ros.org/blog/noetic-eol/












