Skip to content

Commit 0381e1d

Browse files
stab
1 parent f74453f commit 0381e1d

File tree

2 files changed

+88
-146
lines changed

2 files changed

+88
-146
lines changed

libcanard/canard.c

Lines changed: 79 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,11 @@ typedef unsigned char byte_t;
6565
#define FOREACH_PRIO(i) for (size_t i = 0; (i) < CANARD_PRIO_COUNT; (i)++)
6666

6767
#if CANARD_IFACE_COUNT <= 2
68-
#define IFACE_INDEX_BIT_LENGTH 1U
68+
#define IFACE_INDEX_BITS 1U
6969
#elif CANARD_IFACE_COUNT <= 4
70-
#define IFACE_INDEX_BIT_LENGTH 2U
70+
#define IFACE_INDEX_BITS 2U
7171
#elif CANARD_IFACE_COUNT <= 8
72-
#define IFACE_INDEX_BIT_LENGTH 3U
72+
#define IFACE_INDEX_BITS 3U
7373
#else
7474
#error "Too many interfaces"
7575
#endif
@@ -424,7 +424,7 @@ typedef struct tx_transfer_t
424424
canard_us_t deadline;
425425
uint64_t seqno;
426426
uint64_t can_id_msb : CAN_ID_MSb_BITS;
427-
uint64_t transfer_id : CANARD_TRANSFER_ID_BIT_LENGTH; // TODO remove, not needed.
427+
uint64_t transfer_id : CANARD_TRANSFER_ID_BITS; // TODO remove, not needed.
428428
uint64_t fd : 1;
429429

430430
// Mutable transmission state. All other fields, except for the index handles, are immutable.
@@ -1189,8 +1189,8 @@ typedef struct
11891189
canard_us_t start_ts;
11901190
uint32_t total_size; // The raw payload size seen before the implicit truncation and CRC removal.
11911191
uint16_t crc;
1192-
byte_t transfer_id : CANARD_TRANSFER_ID_BIT_LENGTH;
1193-
byte_t iface_index : IFACE_INDEX_BIT_LENGTH;
1192+
byte_t transfer_id : CANARD_TRANSFER_ID_BITS;
1193+
byte_t iface_index : IFACE_INDEX_BITS;
11941194
byte_t expected_toggle : 1;
11951195
byte_t payload[]; // Extent-sized.
11961196
} rx_slot_t;
@@ -1209,7 +1209,7 @@ static rx_slot_t* rx_slot_new(const canard_subscription_t* const sub,
12091209
slot->crc = sub->crc_seed;
12101210
slot->transfer_id = transfer_id & CANARD_TRANSFER_ID_MAX;
12111211
slot->expected_toggle = kind_is_v1(sub->kind) ? 1 : 0;
1212-
slot->iface_index = iface_index & ((1U << IFACE_INDEX_BIT_LENGTH) - 1U);
1212+
slot->iface_index = iface_index & ((1U << IFACE_INDEX_BITS) - 1U);
12131213
}
12141214
return slot;
12151215
}
@@ -1229,48 +1229,6 @@ static void rx_slot_advance(rx_slot_t* const slot, const size_t extent, const ca
12291229
slot->expected_toggle ^= 1U;
12301230
}
12311231

1232-
// This value is unreachable even if seqno is incremented by full transfer-ID period every frame at 5k frames/s;
1233-
// at that rate, it would take about 56 years to wrap around.
1234-
#define RX_SEQNO_MAX ((UINT64_C(1) << 48U) - 1U)
1235-
1236-
// A compact representation is needed because we need to store an array of these in dynamic memory.
1237-
typedef struct
1238-
{
1239-
uint16_t limbs[3];
1240-
} rx_seqno_packed_t;
1241-
1242-
static uint64_t rx_seqno_unpack(const rx_seqno_packed_t v)
1243-
{
1244-
return ((uint64_t)v.limbs[0]) | (((uint64_t)v.limbs[1]) << 16U) | (((uint64_t)v.limbs[2]) << 32U);
1245-
}
1246-
static rx_seqno_packed_t rx_seqno_pack(const uint64_t v)
1247-
{
1248-
return (rx_seqno_packed_t){
1249-
{ (uint16_t)(v & 0xFFFFU), (uint16_t)((v >> 16U) & 0xFFFFU), (uint16_t)((v >> 32U) & 0xFFFFU) }
1250-
};
1251-
}
1252-
1253-
// The linearizer logic results in a behavior that differs compared to v4 significantly: v4 used to admit any
1254-
// transfer-ID except the same and the one older compared to the last transfer; in terms of the linearization logic it
1255-
// means that the reference point was biased.
1256-
static uint64_t rx_seqno_linearize(const uint64_t ref_seqno, const byte_t transfer_id)
1257-
{
1258-
CANARD_ASSERT(transfer_id <= CANARD_TRANSFER_ID_MAX);
1259-
const byte_t ref_tid = (byte_t)(ref_seqno & CANARD_TRANSFER_ID_MAX);
1260-
int16_t delta = ((int16_t)transfer_id) - ((int16_t)ref_tid); // NOLINT(*-narrowing-conversions)
1261-
// Select the nearest congruent seqno in the half-open interval [-16, +16) around the reference.
1262-
if (delta > (int16_t)(CANARD_TRANSFER_ID_MAX / 2U)) {
1263-
delta -= (int16_t)CANARD_TRANSFER_ID_MODULO;
1264-
} else if (delta < -((int16_t)(CANARD_TRANSFER_ID_MAX / 2U) + 1)) {
1265-
delta += (int16_t)CANARD_TRANSFER_ID_MODULO;
1266-
}
1267-
// Near the origin the backward representative may underflow, so use the next forward one instead.
1268-
if ((delta < 0) && (ref_seqno < (uint64_t)(-delta))) {
1269-
delta += (int16_t)CANARD_TRANSFER_ID_MODULO;
1270-
}
1271-
return (uint64_t)(((int64_t)ref_seqno) + ((int64_t)delta));
1272-
}
1273-
12741232
// Up to libcanard v4 we used a fixed-capacity array of pointers for per-remote sessions for constant-time lookup,
12751233
// but it was too costly on MCUs: with a 32-bit pointer it took 512 bytes for the array plus overheads,
12761234
// resulting in 1 KiB o1heap blocks per session, very expensive. Here we use a much less RAM-heavy approach with
@@ -1305,9 +1263,10 @@ typedef struct
13051263
canard_us_t last_admission_ts;
13061264
rx_slot_t* slots[CANARD_PRIO_COUNT]; // Indexed by priority level to allow preemption.
13071265
canard_subscription_t* owner;
1308-
rx_seqno_packed_t seqno_frontier[CANARD_PRIO_COUNT];
1309-
byte_t iface_index;
13101266
byte_t node_id;
1267+
byte_t last_admitted_transfer_id : CANARD_TRANSFER_ID_BITS;
1268+
byte_t last_admitted_priority : CANARD_PRIO_BITS;
1269+
byte_t iface_index : IFACE_INDEX_BITS;
13111270
} rx_session_t;
13121271
static_assert((sizeof(void*) > 4) || (sizeof(rx_session_t) <= 120), "too large");
13131272

@@ -1375,109 +1334,87 @@ static size_t rx_session_cleanup(rx_session_t* const ses, const canard_us_t now)
13751334
return n_slots;
13761335
}
13771336

1378-
// Maximum seqno seen from the given highest priority level (numerically lowest, inclusive) and down.
1379-
static uint64_t rx_session_seqno_frontier(const rx_session_t* const ses, const canard_prio_t highest_priority)
1380-
{
1381-
uint64_t seqno = 0;
1382-
for (size_t i = (size_t)highest_priority; i < CANARD_PRIO_COUNT; i++) {
1383-
seqno = max_u64(seqno, rx_seqno_unpack(ses->seqno_frontier[i]));
1384-
}
1385-
return seqno;
1386-
}
1387-
13881337
static void rx_session_record_admission(rx_session_t* const ses,
13891338
const canard_prio_t priority,
1390-
const uint64_t seqno,
1339+
const byte_t transfer_id,
13911340
const canard_us_t ts,
13921341
const byte_t iface_index)
13931342
{
1394-
// Seqno per priority cannot go back. When we reset it on a transfer-ID timeout, we simply bump the seqno state by
1395-
// a multiple of transfer-ID overflow periods to ensure monotonicity. Earlier I tried dumb direct approaches
1396-
// where we erase the seqno states to zero on tid-timeout, or introduce epoch counters, or per-seqno timestamp,
1397-
// but this solution is so much simpler while achieves the same goal: leave older seqnos behind, start new epoch.
1398-
CANARD_ASSERT(seqno > rx_seqno_unpack(ses->seqno_frontier[priority]));
1399-
ses->seqno_frontier[priority] = rx_seqno_pack(seqno); // nothing older than this at this & higher prio from now on
1400-
ses->last_admission_ts = ts;
1401-
ses->iface_index = iface_index;
1402-
}
1403-
1404-
#define RX_SESSION_ADMISSION_REJECTED (UINT64_MAX)
1405-
#define RX_SESSION_ADMISSION_CONTINUATION (UINT64_MAX - 1)
1406-
1407-
// Frame admittance solver. A complex piece, redesigned after v4 to support priority preemption.
1408-
// Key ideas: 1. Separate reassembly state per priority level. 2. TID is linearized into seqno.
1409-
// Once we admit a transfer at some priority with a certain seqno, we know that any older seqno at this or higher
1410-
// priority would be stale, since only higher priority transfers can preempt lower priority ones.
1411-
// On a transfer-ID timeout the seqno is bumped by a full transfer-ID timeout to ensure that it becomes the new
1412-
// frontier matching the current transfer-ID while maintaining monotonicity.
1413-
static uint64_t rx_session_solve_admission(const rx_session_t* const ses,
1414-
const canard_us_t ts,
1415-
const canard_prio_t priority,
1416-
const bool start,
1417-
const bool toggle,
1418-
const byte_t transfer_id,
1419-
const byte_t iface_index)
1343+
ses->last_admission_ts = ts;
1344+
ses->last_admitted_transfer_id = transfer_id & CANARD_TRANSFER_ID_MAX;
1345+
ses->last_admitted_priority = ((byte_t)priority) & CANARD_PRIO_BITS;
1346+
ses->iface_index = iface_index & ((1U << IFACE_INDEX_BITS) - 1U);
1347+
}
1348+
1349+
// Frame admittance solver. A complex piece, redesigned after v4 to support priority preemption & parallel reassembly.
1350+
static bool rx_session_solve_admission(const rx_session_t* const ses,
1351+
const canard_us_t ts,
1352+
const canard_prio_t priority,
1353+
const bool start,
1354+
const bool end,
1355+
const bool toggle,
1356+
const byte_t transfer_id,
1357+
const byte_t iface_index)
14201358
{
14211359
// Continuation frames cannot create new state so their handling is simpler.
14221360
// They are only accepted if there is a slot with an exact match of all transfer parameters.
14231361
// We ignore the transfer-ID timeout to avoid breaking transfers that are preempted for a long time,
14241362
// and especially to allow reassembly of multi-frame transfers even when the transfer-ID timeout is zero.
14251363
if (!start) {
14261364
const rx_slot_t* const slot = ses->slots[priority];
1427-
const bool admit = (slot != NULL) && (slot->transfer_id == transfer_id) && (slot->iface_index == iface_index) &&
1428-
(slot->expected_toggle == toggle);
1429-
return admit ? RX_SESSION_ADMISSION_CONTINUATION : RX_SESSION_ADMISSION_REJECTED;
1365+
return (slot != NULL) && (slot->transfer_id == transfer_id) && (slot->iface_index == iface_index) &&
1366+
(slot->expected_toggle == toggle);
14301367
}
1431-
1432-
// It is best to postpone seqno derivation until the last moment because it is costly.
1433-
// Life would have been so much easier if we could just use normal non-wrapping IDs like we have in Cyphal/UDP!
1434-
const uint64_t frontier_global = rx_session_seqno_frontier(ses, canard_prio_exceptional);
1435-
const uint64_t seqno = rx_seqno_linearize(frontier_global, transfer_id);
1436-
14371368
// Duplicate start frames do not require special treatment because a duplicate frame can only follow the original
14381369
// without any frames belonging to the same transfer in between (see the assumptions). If we get a duplicate start,
14391370
// with a nonzero TID timeout it will be rejected as not-new; even if the timeout is zero, accepting the duplicate
14401371
// will simply restart the slot without loss of information since there were no other frames in between.
14411372
//
1442-
// In the case of a single priority level, the seqno would be considered new if it is greater than last admitted.
1443-
// Priority preemption makes this simple condition insufficient, because a newer higher-priority transfer with
1444-
// a greater seqno may push older lower-priority transfers aside, causing the receiver to observe seqno going
1445-
// backward for new transfers. Our solution is to keep a dedicated seqno frontier per priority level.
1446-
// The lowest bound of a genuinely new seqno would then be located at the lowest priority level, since that level
1447-
// cannot preempt others. To decide if a given seqno is new, one needs to scan all priority levels from the
1448-
// current one (inclusive) down to the lowest level (numerically greater).
1449-
// Higher priority levels (numerically lesser) may have greater seqno frontiers which bear no relevance.
1450-
//
1451-
// The original design had a special case that enabled admittance of a transfer with the next seqno from a
1373+
// The original design had a special case that enabled admittance of a transfer with the next transfer-ID from a
14521374
// different interface if there is no pending reassembly in progress; see details here and the original v4 code:
14531375
// https://github.com/OpenCyphal/libcanard/issues/228. This behavior is no longer present here mostly because we
14541376
// now update the session state only on admittance and not upon completion of a transfer, which changes the logic
14551377
// considerably. One side effect is that even after a timeout (potentially a very long time as long as the session
14561378
// survives) we will still reject a new transfer arriving from a different interface if it happened to roll the
1457-
// same transfer-ID timeout. This is not an issue because we would still accept new transfers on the same iface,
1379+
// same transfer-ID. This is not an issue because we would still accept new transfers on the same iface,
14581380
// and after the RX_SESSION_TIMEOUT the session is destroyed and all new transfers will be accepted unconditionally.
1459-
const uint64_t frontier_priority = rx_session_seqno_frontier(ses, priority);
1460-
const bool seqno_new = seqno > frontier_priority;
1461-
const bool iface_match = ses->iface_index == iface_index;
1462-
const bool timed_out = ts > (ses->last_admission_ts + ses->owner->transfer_id_timeout);
1463-
const bool admit = (seqno_new && iface_match) || (iface_match && timed_out) || (timed_out && seqno_new);
1464-
if (!admit) {
1465-
return RX_SESSION_ADMISSION_REJECTED;
1466-
}
1467-
1468-
// It is vital that seqno is monotonically increasing even across timeouts, otherwise following a transfer-ID
1469-
// timeout further arrivals on higher priority levels may be rejected if their seqnos are greater.
1470-
// Instead of sweeping seqnos across all priority levels, we ensure monotonicity without breaking transfer-ID
1471-
// matching by bumping the seqno by the minimal required number of overflow periods.
1472-
uint64_t admitted_seqno = seqno;
1473-
if (!seqno_new) {
1474-
static const uint64_t mod = CANARD_TRANSFER_ID_MODULO;
1475-
CANARD_ASSERT(frontier_priority >= admitted_seqno);
1476-
const uint64_t periods = ((frontier_priority - admitted_seqno) + mod) / mod;
1477-
admitted_seqno += periods * mod;
1478-
}
1479-
CANARD_ASSERT(admitted_seqno > frontier_priority);
1480-
return admitted_seqno;
1381+
//
1382+
// Merely comparing against the last admitted transfer-ID is not enough because there is a preemption-related edge
1383+
// case. Suppose the transfer-ID modulo is 4, i.e., the values are {0, 1, 2, 3}; a low-priority transfer is sent
1384+
// but preempted by higher-priority peers before it hits the bus as illustrated below.
1385+
//
1386+
// |SENDER |
1387+
// P=low |0 |
1388+
// P=high | 1 2 3 0 | <-- preempt the low-priority frame
1389+
//
1390+
// |RECEIVER |
1391+
// P=low | 0 | <-- arrived late, rejected as duplicate!
1392+
// P=high | 1 2 3 0 |
1393+
//
1394+
// One solution is to unroll the transfer-ID sequence locally to track wraparounds:
1395+
//
1396+
// |RECEIVER (unrolling) |
1397+
// P=low | 5 | <-- thanks to unrolling we observe this as not a duplicate
1398+
// P=high | 1 2 3 4 | <-- unrolled
1399+
//
1400+
// Another solution that is much simpler but yields identical behavior is to track the last admitted priority
1401+
// along with the transfer-ID: preemption alters the admitted priority, which prevents false rejection.
1402+
//
1403+
// However, both solutions have a critical edge case: if a low-priority frame is duplicated (due to the CAN ACK
1404+
// glitch effect) such that at least one higher-priority frame is admitted between the original frame and the
1405+
// duplicate, then the duplicate will be accepted as a new transfer, which is unsound. Even though the ACK glitch
1406+
// is an incredibly rare phenomenon, we prefer the risk of NOT accepting a valid transfer rather than accepting
1407+
// a transfer twice, because failure to deduplicate breaks strong protocol invariants.
1408+
//
1409+
// There is one case though where we can admit a duplicate without breaking invariants, which is simply when the
1410+
// admitted start is part of a multi-frame transfer, i.e., is not the last one. In this case, the worst outcome
1411+
// is that we restart the slot, without affecting the application, so it has no real downsides (see above).
1412+
const bool multiframe = !end;
1413+
const bool fresh = (transfer_id != ses->last_admitted_transfer_id) || // always accept if transfer-ID is different
1414+
((priority != ses->last_admitted_priority) && multiframe); // CAN ack glitch handling improvement
1415+
const bool affine = ses->iface_index == iface_index;
1416+
const bool timeout = ts > (ses->last_admission_ts + ses->owner->transfer_id_timeout);
1417+
return (fresh && affine) || (affine && timeout) || (timeout && fresh);
14811418
}
14821419

14831420
// Returns false on OOM, no other failure modes.
@@ -1514,19 +1451,23 @@ static bool rx_session_update(canard_subscription_t* const sub,
15141451
}
15151452

15161453
// Decide admit or drop.
1517-
const uint64_t seqno = rx_session_solve_admission(
1518-
ses, ts, frame->priority, frame->start, frame->toggle, frame->transfer_id, iface_index);
1519-
if (seqno == RX_SESSION_ADMISSION_REJECTED) {
1454+
const bool admit = rx_session_solve_admission(ses, //
1455+
ts,
1456+
frame->priority,
1457+
frame->start,
1458+
frame->end,
1459+
frame->toggle,
1460+
frame->transfer_id,
1461+
iface_index);
1462+
if (!admit) {
15201463
return true; // Rejection is not a failure.
15211464
}
15221465

15231466
// The frame must be accepted. If this is the start of a new transfer, we must update state.
1524-
if (seqno != RX_SESSION_ADMISSION_CONTINUATION) {
1525-
CANARD_ASSERT(seqno <= RX_SEQNO_MAX);
1526-
CANARD_ASSERT(frame->start);
1467+
if (frame->start) {
15271468
// Animate only when a new transfer is started to manage load. Correctness-wise there is not much difference.
15281469
enlist_tail(&sub->owner->rx.list_session_by_animation, &ses->list_animation);
1529-
// Destroy the old slot if it exists (if we're discarding a stale transfer).
1470+
// Destroy the old slot if it exists, meaning we're discarding stale transfer.
15301471
if (ses->slots[frame->priority] != NULL) {
15311472
rx_slot_destroy(sub, ses->slots[frame->priority]);
15321473
ses->slots[frame->priority] = NULL;
@@ -1541,7 +1482,7 @@ static bool rx_session_update(canard_subscription_t* const sub,
15411482
}
15421483
}
15431484
// Register the new state only after we have a confirmation that we have memory to store the frame.
1544-
rx_session_record_admission(ses, frame->priority, seqno, ts, iface_index);
1485+
rx_session_record_admission(ses, frame->priority, frame->transfer_id, ts, iface_index);
15451486
}
15461487

15471488
// TODO acceptance is not yet implemented.

0 commit comments

Comments
 (0)