简介:《UR机器人/优遨机器人实时数据交换手册》面向机器人集成、自动化上位机开发与现场总线调试人员,聚焦UR控制器在标准TCP/IP连接下的实时数据同步,讲清RTDE接口如何在不破坏实时特性的前提下完成机器人状态、关节/工具/安全状态、模拟与数字I/O及通用寄存器的读写配置。资源包为1个PDF文件,约226KB,篇幅紧凑,适合按协议章节查阅。正文覆盖字段名称与数据类型、控制器输入/输出、Header,以及协议版本、控制器版本、文本消息、数据包和SETUP/START/PAUSE等控制包,并交代setup与同步循环、recipe和2048字节上限等配置要点。已有926人学习,可作为RTDE联调时核对报文格式、字段顺序与输入输出配方的速查材料。
1. 从「125Hz」这个数字说起:丢帧不是故障,是设计
第一次翻这份手册的人,多半会盯着「125Hz」这个数字,默认它是一条硬实时承诺。事实正好相反:控制器里的实时循环优先级高于 RTDE 接口,算力吃紧时它会把输出包直接跳过,而且跳过的包不补发,控制器只发最新那一份数据。宁可丢帧,也不让同步接口拖慢控制周期,这是整套设计最基础的一条原则。想清楚这一条,后面那些看起来奇怪的规定就都能解释了:为什么同步循环的数据包不需要回执、为什么输入要求在值变化时才发、为什么一个变量只允许一个客户端写。这套机制要解决的是「在不破坏控制器实时性的前提下,让外部程序看到并影响机器人状态」,从端口 30004 上的第一次握手开始,每个决定都在为这个目标让路。
2. 两阶段握手:为什么必须先发「配方」再传数据
连接建立之后,第一件事不是收数据,而是发数据——发一份字段名清单。这套接口被拆成 setup 和 synchronization loop 两个阶段,setup 阶段做的事,本质是让通信双方对「接下来每一帧的字节布局」达成一致。手册把这份布局叫做 recipe,这个词用得很准:它描述的是一段二进制怎么解释,而不是数据本身。
2.1 setup 协商的不是数据,是一份字节布局
客户端通过 SETUP_OUTPUTS 和 SETUP_INPUTS 提交字段名列表,控制器回一份类型列表,或者直接告诉你某个字段名不存在。这里有个细节值得停下来想:返回的是类型列表,不是「名字 + 类型」的完整映射。名字对类型的绑定关系,完全由客户端提交的顺序承担。
这种设计意味着客户端在发送清单的那一刻,就把「第 1 个字段占多少字节、第 2 个字段从哪个偏移开始」这套账算死了。之后每一帧到达,解析过程就是按这张表顺序取值,没有字符串比较,没有哈希查找,甚至连长度前缀都不需要。一个典型的输出包,timestamp 是 8 字节,actual_q 是 48 字节,actual_TCP_pose 是 48 字节,robot_mode 是 4 字节——总长在 setup 完成时就已经确定,收包时先校验长度,长度不对说明要么协议版本不匹配,要么自己的表算错了。
代价也很明显:recipe 是静态的。一旦 START 之后想加一个字段,只能 PAUSE、重新 setup、再 START。运行时动态改字段这件事在这套协议里做不到,也没打算做。
OUTPUT_FIELDS = [ "timestamp", "actual_q", "actual_qd", "actual_TCP_pose", "robot_mode", "safety_status_bits", ] def build_setup_outputs(fields: list[str]) -> bytes: """把字段名列表打包成 setup 载荷。 字段名是 ASCII 字符串,列表总长度不得超过 2048 字节。 具体分隔符以客户端示例为准,这里用换行。 """ payload = "\n".join(fields).encode("ascii") if len(payload) > 2048: raise ValueError(f"字段名列表 {len(payload)} 字节,超过 2048 上限") return payload这段代码只做了拼接和长度校验,没有做别的——因为 setup 阶段本来就不传数据。它唯一的价值是把「客户端想要什么」这件事表达清楚。长度校验不能省:2048 字节是协议硬性上限,超了控制器会拒绝,而拒绝的表现往往是一个看不懂的错误码,与其到那一步再查,不如在客户端先拦住。
值得补充一句的是,setup 全部有回包。SETUP_OUTPUTS 成功返回类型列表,SETUP_INPUTS 成功返回一个唯一的 recipe id。这个回包是同步循环里唯一能确认「我配置对了」的机会,后面 START 之后就没有第二次校验了。
2.2 2048 字节上限与「配方不可变」的代价
2048 字节听起来很少,但算一下就知道了:手册里列出的输出字段总共也就六七十个,平均字段名长度 20 字节左右,全订上也不过一千多字节。这个上限实际上不是瓶颈,它是一条防御线——防止客户端构造一个超大 setup 包,把控制器的解析路径拖进不必要的长循环。
更深的动机在于实时系统的内存模型。控制器需要在 setup 阶段为 recipe 分配一块固定大小的缓冲,之后 125Hz 地往里写。如果 schema 大小不受限,这块缓冲就得动态增长,运行期分配内存对实时线程是很敏感的事。把上限钉死在 2048 字节,等于把最大内存占用在编译期就确定了。
同样地,「成功配置的输入 recipe 会得到唯一 id」这条规定也值得琢磨。手册没写这个 id 具体怎么用,但常见的实现思路是:客户端在每条输入数据包里带上这个 id,控制器据此知道该按哪套布局解释这段字节——因为一个客户端理论上可以配置多条不同的输入配方,光靠长度区分不了。
输入和输出在这里是不对称的:输出 recipe 的布局由控制器在 setup 回包里确认,客户端只要按顺序收;输入 recipe 的布局由客户端自己维护,控制器只记住 id 和类型序列。这个不对称后面还会反复出现。
2.3 顺序即寻址:返回顺序严格等于请求顺序
手册里有一句话,分量比它看起来重得多:「the RTDE interface sends the client the requested data in the same order it was requested by the client」。请求什么顺序,就按什么顺序发回来。
这句话意味着协议里根本没有字段标签。第 3 个字段是什么,只能靠客户端自己记。好处是解析成本极低——整个 payload 可以一次性 unpack 成元组,然后按下标切片,连循环都能省掉。坏处是错一位就全错,而且错得很隐蔽:前几个字段看着正常,从某个位置开始全是垃圾值,你会以为是控制器出了问题。
我一般会把这套关系理解成一个手工维护的 struct 定义:字段名是给人看的注释,真正的契约是「类型序列 + 顺序」。所以客户端代码里,这张表只能有一份,setup 用它、解析也用它,绝不能在两处各写一遍。
import struct # (字段名, struct 格式, 元素个数),顺序必须与 setup 提交的完全一致 RECIPE = [ ("timestamp", "d", 1), ("actual_q", "6d", 6), ("actual_qd", "6d", 6), ("actual_TCP_pose", "6d", 6), ("robot_mode", "i", 1), ("safety_status_bits", "I", 1), ] FMT = ">" + "".join(f for _, f, _ in RECIPE) # '>' 强制大端、无对齐填充 SIZE = struct.calcsize(FMT) def parse_output_payload(payload: bytes) -> dict: if len(payload) != SIZE: raise ValueError(f"长度不符:期望 {SIZE},实际 {len(payload)}") flat = struct.unpack(FMT, payload) out, cursor = {}, 0 for name, _, count in RECIPE: out[name] = flat[cursor] if count == 1 else flat[cursor:cursor + count] cursor += count return out>这个前缀是整段代码里最关键的一个字符,它强制大端并且关闭对齐填充,后者同样重要——如果不加,struct 会按本机对齐规则在字段之间插空洞,算出来的 SIZE 和实际字节数对不上。第三个参数count看起来冗余,其实是为了把6d展开成 6 个独立元素之后还能正确归位,否则 actual_q 会被拆成 6 个散落的浮点数,拼回去还得靠猜。
3. 数据包解剖:header、大端序与一套没有自描述的二进制格式
把 setup 想成编译,同步循环就是运行。运行期只有两种包在流动:控制器往下发的 RTDE_DATA_PACKAGE,和客户端往上发的输入数据包。两者共享同一套外壳——一个 header 加一段 payload,但行为完全不同。
3.1 为什么同步循环里的数据包不需要回执
手册明确写了:setup 阶段的包会产生 return message,同步循环阶段的包不产生。这个区别不是随手定的。
125Hz 意味着每 8 毫秒一帧。如果每帧都要等一个 ACK,控制器就得为每个客户端维护「已发送待确认」的状态,还要处理超时重发。而重发在实时系统里几乎必然导致积压:网络稍微抖一下,队列就涨起来,等恢复时你收到的是一堆过期状态,反而不如只要最新的一帧。
更根本的原因是 TCP 已经把可靠性做完了。字节流有序、不丢、不重,这是传输层保证的。应用层唯一会「丢」的情况,是控制器主动不生成这一帧——那是设计行为,不是错误,用 ACK 也救不回来。
所以同步循环是一条单向流:控制器闷头发,客户端闷头收。这也解释了为什么调试时抓包会看到一个很奇怪的现象——只有一边在说话,另一边长时间沉默。这不是连接断了,是设计如此。
控制包就是另一回事了。REQUEST_PROTOCOL_VERSION、GET_URCONTROL_VERSION、SETUP_OUTPUTS、SETUP_INPUTS、START、PAUSE,这些都是低频操作,一次连接里可能只发生几次,要求确认完全合理。TEXT_MESSAGE 稍微特殊,它两个方向都能发,带一个 warning level 表示严重程度,协议 v1 和 v2 的格式还不一样——如果你的客户端要和不同版本的控制器打交道,这个包的解析得分支处理。
3.2 大端序与 BOOL 占 8 位:那些让原始字节「看起来是乱码」的约定
手册里有一句很短的话:「Data are always sent in network byte order, so big-endian」。这句话的杀伤力,只有在你直接读 socket 原始字节的时候才会体现出来。x86 是小端,如果解析时不显式转换,一个 DOUBLE 会解成完全不着边际的数——timestamp 可能变成 1e-300,也可能变成几亿亿,看着像协议坏了,其实就是字节序反了。
类型宽度这张表值得完整过一遍,因为它决定了偏移量怎么累加:
| 类型 | 位宽 | 字节数 | struct 格式 |
|---|---|---|---|
| BOOL | 8 | 1 | ? |
| UINT8 | 8 | 1 | B |
| UINT32 | 32 | 4 | I |
| UINT64 | 64 | 8 | Q |
| INT32 | 32 | 4 | i |
| DOUBLE | 64 | 8 | d |
| VECTOR3D | 3×64 | 24 | 3d |
| VECTOR6D | 6×64 | 48 | 6d |
| VECTOR6INT32 | 6×32 | 24 | 6i |
| VECTOR6UINT32 | 6×32 | 24 | 6I |
| STRING | 长度×8 | 长度 | 按长度动态 |
表里最反直觉的一行是 BOOL:它占 8 位,整整一个字节。如果按「省空间」的思路,64 个通用布尔寄存器只该占 8 字节,位打包能省 7/8 的空间。但协议没这么做。原因不复杂——位打包之后,任何一个字段的读取都要先定位到字节、再做移位和掩码,字段顺序一变,所有位偏移全得重算。8 位对齐让每个字段至少落在字节边界上,解析退化成简单的偏移加法。
这套取舍在整个协议里是一致的:优先简单和确定性,空间是次要的。2048 字节的 setup 上限、BOOL 的 8 位、固定长度的 VECTOR 类型,都是同一个思路的不同侧面。
注意:actual_TCP_pose 的 rx、ry、rz 是旋转向量表示,不是欧拉角。数值上看着像三个普通浮点数,语义完全不同,直接当欧拉角用会得到错误的姿态。
3.3 recipe id 与偏移表:把字节流翻译回字段名
到了这一步,客户端手里有三样东西:一份按序排列的字段表、一个总长度、一个由 setup 回包确认的 recipe id。把这三样拼起来,就能构造出偏移表——每个字段从第几个字节开始、占多少字节。
偏移表的价值在于它把「解析」变成了一次查表。收包时先比长度,再按表切片,最后按字段名塞进字典。整个过程没有循环依赖,也没有对协议的任何假设。如果哪天要给某个字段加日志,只要在表里多查一次,不影响其他字段。
def build_offset_table(recipe): """把 recipe 编译成一串 (字段名, 起始偏移, 字节数)。""" offset, table = 0, [] for name, fmt, _ in recipe: size = struct.calcsize(">" + fmt) # 注意同样要加 '>' table.append((name, offset, size)) offset += size return table, offset def slice_by_offset(payload: bytes, table): out = {} for name, start, size in table: out[name] = payload[start:start + size] return out这里返回的是原始字节切片,解码交给上层按类型做。多这一步看着累赘,实际很有用:调试时可以直接把某个字段的 8 个字节打印成十六进制,和手册里的类型定义对照,比对着一个解错的浮点数苦思冥想有效得多。我踩过一次坑,actual_TCP_force 一直读数异常,最后发现是 payload 前面少算了一个字段,整个偏移表后移了 8 字节——用切片打印一眼就看出来了。
4. 输入方向的不对称:值变才发、旧值保持、单写者
输出是一条稳定的状态流,输入不是。手册对输入的规定只有三句话,但每一句背后都对应一个具体的工程约束,理解偏了就会写出「看起来在跑、实际没生效」的代码。
4.1 「change of value」模型:输入为什么不需要固定频率
手册的原话是:不需要恒定的更新频率,输入会保持它最后收到的值。这句话把输入从「周期上报」改成了「事件写入」。
对客户端来说,这意味着你可以把一次更新拆成多条消息,也可以把多条更新合并成一条;可以每帧发,也可以只在按钮按下的时候发。控制器的行为是一样的——没收到新值,就继续用旧的。
好处有两个层面。第一是带宽和 CPU:机器人空闲时,客户端可以完全不发输入包,控制器的负载就降下来了。手册里那句「控制器的负载会随收到的输入包数量变化」,说的就是这个。第二是语义更清晰:数字输出从 0 变 1,这是一个事件,用事件表达比用「每 8ms 重申一次我是 1」自然得多。
但「保持旧值」有一个容易被忽略的副作用:值是有记忆的。客户端崩溃重连之后,控制器里还留着上次写的值。如果那个值是「夹爪闭合」或者「速度滑杆 50%」,重连后的第一帧就会继承这个状态。指望它自动复位是不现实的,客户端启动时应该显式把所有自己负责的输入写一遍。
4.2 唯一控制权:为什么一个变量只能被一个客户端写
手册里有一句限定:「Only one RTDE client is allowed to control a specific variable at any time」。同一时刻,一个变量只能被一个客户端控制。
这条规定解决的是一个无法定义的问题:如果两个客户端同时写同一个寄存器,谁赢?按到达顺序?那后到的覆盖先到的,但客户端并不知道自己的写有没有生效。按优先级?协议里根本没有优先级这个概念。加锁?实时线程里加锁是自找麻烦。
所以协议干脆把多写者这个场景排除掉。常见的实现思路是,setup 阶段就拒绝重复订阅同一个输入字段,让冲突在连接时就暴露,而不是等到运行期产生难以复现的玄学现象。
读没这个限制。多个客户端订阅同一批输出字段是允许的,因为它们只是各收各的副本。写才是独占的。
提示:现场调试时如果发现某个输入写不进去,先别怀疑协议,先确认是不是有另一个客户端(或者正在运行的程序、现场总线驱动)占着这个字段。
4.3 掩码字段:为什么写数字输出还要带一个 mask
输入字段表里有一批成对出现的名字:standard_digital_output_mask 配 standard_digital_output,configurable_digital_output_mask 配 configurable_digital_output,speed_slider_mask 配 speed_slider_fraction。mask 的作用是声明「这次我关心哪些位」。
没有 mask 会怎样?假设你要把 3 号输出置 1,而寄存器里 0 到 7 位中,0 号和 5 号是别人设的。你必须先读回当前值,改掉第 3 位,再整体写回去。这个读-改-写序列在两个客户端之间就是经典的竞态:读完之后、写回之前,别人改了第 5 位,你写回去就把它抹了。
mask 把「我关心哪些位」和「这些位是什么值」放进同一个包里,控制器一次原子完成:mask 为 1 的位用新值,mask 为 0 的位保持原样。这样客户端根本不需要知道其他位的状态。
def standard_digital_output_cmd(pin: int, value: bool) -> dict: """只改动 pin 这一位,其余位交给控制器保持。""" if not 0 <= pin <= 7: raise ValueError("标准数字输出只有 0-7") mask = 1 << pin return { # mask 为 1 表示这一位由本次输入决定 "standard_digital_output_mask": mask, # 值为 0 时也必须带上 mask,否则等于什么都没声明 "standard_digital_output": mask if value else 0, }这段代码里最容易写错的是最后一行:值为 False 的时候,standard_digital_output 是 0,但 mask 不能是 0。mask 为 0 的含义是「这次不改任何位」,而不是「把所有位清零」。把 False 写成 mask=0、value=0,现象就是「想关一个输出,结果毫无反应」,排查半天也找不到原因。
模拟输出还多一层:standard_analog_output_type 用位域声明每个通道是电流还是电压,0 表示 mA,1 表示 V。而 standard_analog_output_0 和 _1 本身是比例值,范围 0 到 1,不是直接的 mA 或 V 数值。这个换算关系手册里写在括号里,很容易漏看。
4.4 输入在控制周期内被处理:确定性延迟的来源
输出的行为是「可能跳包」,输入的行为正好相反:「Input packages will always be processed in the control cycle they are received」。收到的包一定会在它到达的那个控制周期内被处理,不会被推迟。
这条差别很关键。输出的实时性靠「宁可少发也不延迟」保证,输入的实时性靠「只要收到就立刻处理」保证。两者方向相反,目标一致:都不让 RTDE 这个接口拖慢控制循环。
对客户端来说,这意味着输入写入的延迟上界是可估算的——大约一个控制周期,前提是包在周期开始前到达。这也解释了为什么有人宁愿用 RTDE 做控制回路的写入端,而不是自己开 socket 直连脚本:延迟是有界的,而且这个界不随负载漂移。
不过「被处理」不等于「立即生效」。值写进寄存器是一回事,脚本里有没有在读这个寄存器是另一回事。这时候 output 字段里的 script_control_line 就有用了——它告诉你当前是哪一行脚本在控制机器人,为 0 表示没有线程持有控制权。排查「输入写了但机器人不动」的时候,先看这个字段,比盲猜快得多。
5. 排查:RTDE 连上了却「看起来不工作」的五种典型翻车
这套协议的坑有个共同特点:连接层一切正常,socket 不报错,但就是没有效果。下面五条是实际调试中最常撞上的,按「现象 → 原因 → 解决」记。
5.1 连上 30004 之后一片安静
现象:TCP 连接成功,recv 一直阻塞,没有任何数据到达,也没有报错。
原因:RTDE 不会主动推数据。必须先 SETUP_OUTPUTS 拿到类型列表和 recipe 信息,再发 START,同步循环才启动。中间少任何一步,控制器就只是在等你。
解决:按控制包顺序逐个确认都有回包——版本请求、setup、start。START 的回包里会明确告诉你成功与否,别跳过这一步直接开始收。
5.2 时间戳解析出一个天文数字
现象:timestamp 解出来是 1e-300,或者几亿亿这种明显不合理的值。
原因:字节序反了。协议规定 network byte order,即大端;而 x86 机器上 struct 默认用本机的小端。
解决:格式串强制加>。检查的时候不要只看 DOUBLE,UINT32 和 INT32 同样会错,只是错得没那么显眼——一个 4 字节整数高低位互换之后,可能变成另一个「看起来合理」的数。
5.3 写了数字输出,机器人没反应
现象:输入包发出去了,控制器也没报错,但 I/O 状态纹丝不动。
原因:两种可能。一是 mask 为 0,等于告诉控制器「这次什么都不改」;二是这个字段已经被另一个客户端或者现场总线驱动占着,唯一控制权规则生效了。
解决:先核对 mask,值为 False 时同样要带 mask;再确认没有第二个写者。两者都排除之后,去看脚本里有没有把它又改回去。
5.4 输出频率不稳,偶尔丢帧
现象:期望 125Hz,实测有时候掉到 100Hz 以下,帧间隔不均匀。
原因:这不是 bug。控制器实时循环优先级更高,资源紧张时 RTDE 直接跳包,跳过的包不补发。
解决:不要用「收到第 N 个包」推算时间,那个 N 已经不对应第 N/125 秒了。时间基准只能用 timestamp 字段。需要判断是否发生了跳包,就看相邻两帧 timestamp 的差值——差值明显大于 8ms,说明中间被跳过。
5.5 字段顺序错一位,后面全是垃圾
现象:前几个字段正常,从某个字段开始突然变成离谱值,而且后面的字段跟着一起错。
原因:解析顺序和 setup 提交的顺序不一致,或者某个类型的宽度算错了。最常见的错法是 BOOL——按 1 位算而不是 8 位,一错就错位一个字节,后面全乱。
解决:两个校验动作。先比对包长,struct.calcsize 算出来的 SIZE 和实际收到的长度不等,立刻停下;再把偏移表打出来,拿几个字段的原始十六进制和手册里的类型定义逐字节对。这一步花五分钟,比盯着一个错值猜半小时有效。
6. 设计取舍:RTDE 为什么故意做得「不完整」
写到这里可以回头看,这套协议缺了很多「现代」特性:没有自描述、没有 ACK、schema 不可变、一个变量只允许一个写者。单看每一条都像是缺陷,放在实时控制的场景里却都是合理选择。
自描述就没有办法省。125Hz 下,如果每帧都带字段名,一个几十字节的 JSON 头就把带宽吃掉一大半,解析还要做字符串匹配和动态分配。recipe 把这些成本提前到 setup 一次付清,运行期只剩定长字节。换来的代价是客户端必须自己维护顺序和偏移——这个代价其实被一张表就覆盖了,可控。
没有 ACK 同理。TCP 已经保证了有序可靠,应用层的 ACK 唯一的作用是让发送方知道对方收到了,而控制器根本不想知道——它只想赶紧发下一帧然后回到控制循环。判断丢包的正确方式不是等确认,是看 timestamp 的跳变。
版本协商是这套设计里少有的人性化部分。REQUEST_PROTOCOL_VERSION 让客户端声明自己讲哪个版本,控制器照着来,这样老客户端不会因为控制器升级就突然解析错位。新特性用不上是代价,但在工业现场,能跑比先进重要得多。
客户端跑在哪里也是一个取舍。跑在控制箱上,没有网络延迟,但会和控制器抢 CPU;跑在外部 PC 上,资源充裕,但多一跳网络。手册的建议很明确:图像处理这类重负载放外部,控制箱上的客户端要保持标准操作系统优先级。这个建议背后是同一个原则——RTDE 可以慢,控制循环不行。
如果要验证自己的理解对不对,我会盯三个字段:timestamp 的单调性告诉你有没有丢帧;script_control_line 告诉你当前谁在控制机器人;robot_status_bits 和 safety_status_bits 的位定义告诉你机器人处在什么状态。这三个看懂了,剩下的字段基本就是查表的事。
血泪经验是这么来的:早些年接这套接口,图省事跳过了包长校验,结果某次控制器升级后新增了一个字段,我的偏移表整个错位,机器人报出的位姿数据看着像真的,实际全是隔壁字段的值——这种错最难受,因为它不崩、不报错,只是安静地给你错数。从那以后我每次接 RTDE,第一件事就是把 recipe 编译成偏移表,收包前强制走一遍长度校验,再拿 timestamp 的差值确认帧率。这份手册把字段名、类型、协议包逐条列全了,配着 Python 客户端示例边改边对,是省掉那类后悔药最快的路。希望帮到你。
本文还有配套的精品资源,点击获取