Part 14 of 14
ROS 2 adaptation
ROS 2 does not talk to DDS directly. It talks to a middleware interface, and an implementation of that interface maps ROS concepts onto DDS ones. Most of that mapping is mechanical — a ROS topic is a DDS topic, a ROS publisher is a DataWriter.
Three places are not mechanical, and all three come from the same constraint: ROS hands over type information at run time, as data, rather than at compile time as types. Everything a generated DDS type would know statically has to be discovered by walking a description instead.
Serialization
Serializing a ROS message
a publisher or subscription is created, and then on every message
Plan built once per endpoint; the plan is executed per message.
A ROS message type arrives as a description that can be walked to find its fields. The direct approach walks that description every time a message is published: for each field, ask what type it is, ask where it is, copy it.
That is a lot of indirection per message, and it is the same answer every time — the type does not change between messages. So the walk happens once, when the endpoint is created, and produces a flat list of offsets and sizes. Publishing then runs the list.
The plan only works for types whose layout does not vary between messages: scalars, fixed-size nested messages, and fixed-size arrays of those. Anything whose size depends on the value — a string, a sequence, a variable-length array — cannot be reduced to a fixed offset, so those types take the walking path instead.
That is a real split rather than an optimisation with a fallback. Both paths must produce identical bytes, because the reader on the other side has no idea which one produced them.
Build-Plan(desc)
desc the ROS type description, walked recursively
- plan = 〈〉; offset = 0
- for each field f in desc
- if f is a string or sequence or variable array
- return unsupported // this type uses the walking path
- if f is a nested message
- sub = Build-Plan(f.type)
- if sub == unsupported
- return unsupported
- Append-Shifted(plan, sub, f.offset) // flattened, not nested
- else
- Append(plan, f.offset, Size(f), Align(f))
- return plan
Line 9 flattens rather than nesting. A plan that kept its structure would recurse per message, which is most of what the walk was costing in the first place; flattening means a nested message of three fields inside one of two contributes five entries to one list.
Taking a message applies the plan in reverse, and checks the encapsulation header and the exact payload size before writing anything into the destination. The size is known exactly, so a payload of the wrong length is a rejection rather than a partial fill.
Cost
Building is O(F) in the flattened field count, once per endpoint. Publishing and taking are O(F) with no walk of the description and no grow-and-retry allocation. A type that also ships a compile-time description skips the walk entirely and produces the same flat plan. The walking path stays O(F + B), adding the bytes of its variable-length fields.
Services
Requests and replies
a ROS client calls a service, or a node waits
Once per call in each direction; once per wait.
A ROS service is two topics. A client owns a writer for requests and a reader for replies; the service owns the mirror. Part 5 covers matching a reply to its request; what the ROS layer adds is that several clients share the reply topic and each numbers its own requests.
Two clients therefore both send request number 1. Numbering alone cannot tell the replies apart, so a reply carries the identity of the writer that made the request as well as its number. A client matches on both, and a reply for another client fails the writer comparison however familiar its number looks.
Waiting without probing
A node waits on many things at once — subscriptions, clients, services, timers — and wakes when any has work. The naive check asks every outstanding request whether its reply has arrived, which is work proportional to outstanding requests on every wake, most of it finding nothing.
Instead the replies queued for each request-writer are indexed. A wait first asks whether anything is queued for that writer at all; only if something is does it look for a matching request. A client with fifty outstanding requests and no replies queued does no per-request work.
Cost
Sending a request is O(R) over the matched service readers. Taking a reply scans the replies queued for that request writer until the first match. Waiting is O(S + C · Rready + G) over services, clients, their queued replies, and guards — the outstanding requests themselves are not probed when nothing is queued.
Filtering
Filters at the ROS layer
a subscription installs a filter, and then per sample
Compiled once; evaluated per sample.
ROS exposes the content filters of Part 4 through its own call. The expression is the same language, and it is compiled when installed rather than parsed per sample — the same reasoning as the serialization plan above.
The compiled form here is disjunctive normal form: a list of alternatives, each a list of conditions that must all hold. Any expression can be written that way, and the shape makes evaluation a pair of loops that stop early — the first alternative that holds accepts, and the first failing condition abandons its alternative.
Evaluate-Dnf(groups, v)
groups the alternatives, each a list of conditions · v the decoded sample
- for each group g in groups
- all = true
- for each condition c in g
- if not Holds(c, v)
- all = false; break // this alternative is out
- if all
- return true // one alternative is enough
- return false
Membership conditions get the treatment from Part 4: the operand set is sorted when the filter is compiled, so testing a value is a binary search rather than a walk. A condition testing membership in a hundred values costs about seven comparisons per sample instead of a hundred, and the sorting happens once.
Cost
Compiling is O(E + V log V) for expression size E and the largest membership set V. Evaluating touches only the conditions the two early exits reach, at O(log V) for each membership test. Both the alternatives and the conditions within them are bounded by configuration.
Back to the start
The series follows a sample from two programs that have never met to a bounded, authenticated, protected system, and out through the ROS 2 interface.
These descriptions follow the implementations in Tin DDS and Retina. Evaluation access and the full algorithm notes are available from engineering@tinverse.com.