Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions GOTCHAS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@
- **Fix:** on-target code compares `HyphaIpIPv4Address_t` directly with a local
`std::memcmp`/`==` helper (the struct is exactly `sizeof(uint32_t)`).

## 2026-08-09 — Bare-metal firmware link fails: `memmove` undefined (udpard uses it)

- **Symptom:** M7 (nucleo-cyphal) link error `undefined reference to memmove` when adding the
GetInfo client, even though `memcpy`/`memset` link fine. Fails at link, not compile.
- **Root cause:** `udpardGather` (libudpard) calls standard `memmove` for overlap-safe copies,
but the bare-metal `modules/memory` only supplied `memset` + `memcpy` (both wrapped under
`#if defined(__arm__)` as `extern "C"`).
- **Fix:** added `memory::move` (overlap-safe: forward loop when `dst < src`, backward when
`dst > src`) plus a `void*` overload, and an `extern "C" void *memmove(...)` wrapper in
`modules/memory/source/memmove.cpp`. Registered only for cross builds in
`modules/memory/CMakeLists.txt` (same `$<IF:$<BOOL:${BUILD_CROSS_TARGET}>...>` gate as
memset/memcpy) so host tests don't collide with libc's memmove.
- **Gotcha:** the `memory::move` template must branch on `dst < src`/`dst > src` to remain
overlap-safe; a plain forward `copy` loop corrupts overlapping ranges.

## 2026-08-08 — `on-host-native-gcc` preset cannot build on macOS/Darwin

- `g++-13` (homebrew) does not provide `cstddef`/libc++ headers, so any `module-core` TU fails
Expand Down
1 change: 1 addition & 0 deletions applications/nucleo-cyphal/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ add_firmware(NAME nucleo-cyphal
SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/source/GlobalContext.cpp
${CMAKE_CURRENT_SOURCE_DIR}/source/CyphalApp.cpp
${CMAKE_CURRENT_SOURCE_DIR}/source/GetInfoScanner.cpp
${CMAKE_CURRENT_SOURCE_DIR}/source/O1HeapPool.cpp
INCLUDES
${CMAKE_CURRENT_SOURCE_DIR}/include
Expand Down
10 changes: 10 additions & 0 deletions applications/nucleo-cyphal/include/CyphalApp.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#define APP_CYPHAL_APP_HPP

#include "BoardContext.hpp"
#include "GetInfoScanner.hpp"
#include "core/Allocator.hpp"
#include "jarnax/Loopable.hpp"
#include "jarnax/Ticker.hpp"
Expand All @@ -28,6 +29,10 @@ class CyphalApp final : public jarnax::Loopable, public jarnax::net::ethernet::D
// Cyphal/UDP binds the node-ID to the last octet of the node's source IP.
static constexpr UdpardNodeID NodeId = 103U;

// GetInfo client scan window (server node-IDs, inclusive).
static constexpr UdpardNodeID ScanFirstNode = 2U;
static constexpr UdpardNodeID ScanLastNode = 10U;

CyphalApp(jarnax::Ticker& ticker, jarnax::BoardContext& board_context);

bool Execute() override;
Expand All @@ -51,6 +56,8 @@ class CyphalApp final : public jarnax::Loopable, public jarnax::net::ethernet::D
void ProcessTransmitQueue();
void ServiceDispatcherInit();
void ServiceResponseHandler(struct UdpardRxRPCTransfer const& transfer);
void GetInfoResponseHandler(struct UdpardRxRPCTransfer const& transfer);
void SendGetInfoRequest(UdpardNodeID server_node_id);

static UdpardMicrosecond NowUs(jarnax::Ticker const& ticker);

Expand All @@ -77,7 +84,9 @@ class CyphalApp final : public jarnax::Loopable, public jarnax::net::ethernet::D
struct UdpardRxMemoryResources rx_memory_;
struct UdpardRxRPCDispatcher service_dispatcher_;
struct UdpardRxRPCPort get_info_service_port_;
struct UdpardRxRPCPort get_info_response_port_;
HyphaIpIPv4Address_t service_group_address_;
GetInfoScanner get_info_scanner_;

bool udpard_initialized_;
bool arp_announced_;
Expand All @@ -87,6 +96,7 @@ class CyphalApp final : public jarnax::Loopable, public jarnax::net::ethernet::D
size_t stats_print_counter_;
UdpardTransferID heartbeat_transfer_id_;
jarnax::Ticks last_heartbeat_ticks_;
jarnax::Ticks last_getinfo_scan_ticks_;
};

} // namespace cyphal
Expand Down
81 changes: 81 additions & 0 deletions applications/nucleo-cyphal/include/GetInfoScanner.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#ifndef APP_CYPHAL_GET_INFO_SCANNER_HPP
#define APP_CYPHAL_GET_INFO_SCANNER_HPP

#include <cstddef>
#include <cstdint>

namespace nucleo {
namespace cyphal {

/// Sequential scanner over a window of server node-IDs for uavcan.node.GetInfo client queries.
///
/// The scanner owns:
/// - the scan window [first, last] (inclusive);
/// - a cursor advancing around the window so each Execute() scan tick queries the next node;
/// - a per-server-node transfer-ID counter (Cyphal requires a separate transfer-ID per
/// (service-ID, server node-ID) pair);
/// - the identity of the node with a query currently in flight.
///
/// It has no dependency on the target, libudpard, or hypha, so it is fully host-testable.
class GetInfoScanner {
public:
static constexpr std::size_t MaxWindowSize = 32U;

/// Constructs a scanner over [first_node, last_node] inclusive.
/// Both bounds must be in range and first_node <= last_node.
GetInfoScanner(std::uint16_t first_node, std::uint16_t last_node);

/// Re-arms the scanner to query first_node next, with no pending query.
void Reset();

/// True when node lies within the scan window.
bool IsInRange(std::uint16_t node) const;

/// The next node-ID to query without advancing the cursor.
std::uint16_t PeekNext() const;

/// Returns the next node-ID to query and advances the cursor (wrapping at last).
std::uint16_t TakeNext();

/// True while a query is awaiting a response.
bool HasPending() const;

/// The node-ID with a query in flight.
std::uint16_t PendingNode() const;

/// Records that a request was just sent to node.
/// Precondition: IsInRange(node) and not HasPending().
void SetPending(std::uint16_t node);

/// Clears the in-flight query (response received or timed out).
void ClearPending();

/// Returns the current transfer-ID for node and increments it.
/// A fresh counter is created on first use for a node in the window.
std::uint64_t NextTransferId(std::uint16_t node);

/// Number of distinct server node-IDs currently tracked for transfer-IDs.
std::size_t TidCount() const;

private:
struct TidEntry {
std::uint16_t node_id;
std::uint64_t transfer_id;
};

TidEntry* FindTid(std::uint16_t node);
TidEntry const* FindTid(std::uint16_t node) const;

std::uint16_t first_{0U};
std::uint16_t last_{0U};
std::uint16_t next_{0U};
std::uint16_t pending_{0U};
bool has_pending_{false};
std::size_t tid_count_{0U};
TidEntry tids_[MaxWindowSize];
};

} // namespace cyphal
} // namespace nucleo

#endif // APP_CYPHAL_GET_INFO_SCANNER_HPP
104 changes: 100 additions & 4 deletions applications/nucleo-cyphal/source/CyphalApp.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "CyphalApp.hpp"

#include "GetInfoScanner.hpp"
#include "O1HeapPool.hpp"
#include "board.hpp"
#include "core/Conversions.hpp"
Expand Down Expand Up @@ -112,7 +113,9 @@ CyphalApp::CyphalApp(jarnax::Ticker& ticker, jarnax::BoardContext& board_context
, rx_memory_{}
, service_dispatcher_{}
, get_info_service_port_{}
, get_info_response_port_{}
, service_group_address_{}
, get_info_scanner_{ScanFirstNode, ScanLastNode}
, udpard_initialized_{false}
, arp_announced_{false}
, initialized_{false}
Expand Down Expand Up @@ -222,6 +225,18 @@ bool CyphalApp::Execute() {
PublishHeartbeat();
}

// GetInfo client scan: query the next server node in the window each 5 seconds.
if ((current_ticks.value() - last_getinfo_scan_ticks_.value()) >= 5U * ticker_.GetTicksPerSecond().value()) {
last_getinfo_scan_ticks_ = current_ticks;
if (!get_info_scanner_.HasPending()) {
SendGetInfoRequest(get_info_scanner_.TakeNext());
} else {
// A previously issued request never got a response; advance anyway.
get_info_scanner_.ClearPending();
SendGetInfoRequest(get_info_scanner_.TakeNext());
}
}

++stats_print_counter_;
if (stats_print_counter_ >= 200U) {
stats_print_counter_ = 0U;
Expand Down Expand Up @@ -331,9 +346,19 @@ void CyphalApp::ServiceDispatcherInit() {
// The service request port for uavcan.node.GetInfo (request direction = is_request=true).
int_fast8_t const listen =
udpardRxRPCDispatcherListen(&service_dispatcher_, &get_info_service_port_, GetInfoServiceId, true,
uavcan_node_GetInfo_Response_1_0_EXTENT_BYTES_);
uavcan_node_GetInfo_Request_1_0_EXTENT_BYTES_);
if (listen < 0) {
jarnax::print("CyphalApp: udpardRxRPCDispatcherListen failed with %d\r\n", static_cast<int>(listen));
jarnax::print("CyphalApp: udpardRxRPCDispatcherListen (request) failed with %d\r\n", static_cast<int>(listen));
return;
}

// The service response port for uavcan.node.GetInfo (client direction = is_request=false).
int_fast8_t const listen_response =
udpardRxRPCDispatcherListen(&service_dispatcher_, &get_info_response_port_, GetInfoServiceId, false,
uavcan_node_GetInfo_Response_1_0_EXTENT_BYTES_);
if (listen_response < 0) {
jarnax::print("CyphalApp: udpardRxRPCDispatcherListen (response) failed with %d\r\n",
static_cast<int>(listen_response));
return;
}

Expand Down Expand Up @@ -483,13 +508,18 @@ HyphaIpStatus_e CyphalApp::OnReceiveUdp(HyphaIpExternalContext_t context, HyphaI
payload.size = payload_size;

if (SameIPv4Address(metadata->destination_address, self->service_group_address_)) {
// Service multicast datagram (e.g. a GetInfo request addressed to this node).
// Service multicast datagram (e.g. a GetInfo request addressed to this node, or a
// GetInfo response addressed to us from a scanned server).
if (self->service_initialized_) {
struct UdpardRxRPCTransfer transfer{};
int_fast8_t const result = udpardRxRPCDispatcherReceive(
&self->service_dispatcher_, NowUs(self->ticker_), payload, 0U, nullptr, &transfer);
if (result > 0) {
self->ServiceResponseHandler(transfer);
if (transfer.is_request) {
self->ServiceResponseHandler(transfer);
} else {
self->GetInfoResponseHandler(transfer);
}
udpardRxFragmentFree(transfer.base.payload, self->rx_memory_.fragment, self->rx_memory_.payload);
}
} else {
Expand Down Expand Up @@ -584,6 +614,72 @@ void CyphalApp::ServiceResponseHandler(struct UdpardRxRPCTransfer const& transfe
);
}

void CyphalApp::SendGetInfoRequest(UdpardNodeID server_node_id) {
// The GetInfo request is sealed; its serialized payload is always empty.
struct UdpardPayload const payload = {
.size = 0U,
.data = nullptr,
};

UdpardTransferID const transfer_id = get_info_scanner_.NextTransferId(server_node_id);
int32_t const result = udpardTxRequest(&tx_, NowUs(ticker_) + 1000000U, UdpardPriorityNominal, GetInfoServiceId,
server_node_id, transfer_id, payload, this);
if (result > 0) {
get_info_scanner_.SetPending(server_node_id);
} else {
// The request was not accepted (e.g. temporary TX capacity); try this node again next cycle.
get_info_scanner_.ClearPending();
}
jarnax::print("CyphalApp: GetInfo request sent=%d to node %u tid=%llu\r\n", static_cast<int>(result),
static_cast<unsigned>(static_cast<uint16_t>(server_node_id)),
static_cast<unsigned long long>(transfer_id));
}

void CyphalApp::GetInfoResponseHandler(struct UdpardRxRPCTransfer const& transfer) {
// Only handle GetInfo *responses* for our service from a node we are scanning.
if (transfer.is_request || transfer.service_id != GetInfoServiceId) {
return;
}
if (!get_info_scanner_.HasPending() || transfer.base.source_node_id != get_info_scanner_.PendingNode()) {
return;
}
get_info_scanner_.ClearPending();

// The transfer payload may be fragmented; gather it into a contiguous buffer.
uint8_t buffer[uavcan_node_GetInfo_Response_1_0_EXTENT_BYTES_]{};
size_t const payload_size = udpardGather(transfer.base.payload, sizeof(buffer), buffer);
if (payload_size == 0U) {
return;
}

// Memset instead of the generated initialize_(): the response deserializer reads the
// full payload, and all GetInfo fields have zero-valued defaults.
uavcan_node_GetInfo_Response_1_0 info{};
std::memset(&info, 0, sizeof(info));

size_t deserialized_size = payload_size;
int8_t const err = uavcan_node_GetInfo_Response_1_0_deserialize_(&info, buffer, &deserialized_size);
if (err < 0) {
jarnax::print("CyphalApp: GetInfo response deserialize failed with %d for node %u\r\n", static_cast<int>(err),
static_cast<unsigned>(static_cast<uint16_t>(transfer.base.source_node_id)));
return;
}

// Print the node identity as reported by the server. The name is ASCII text but stored in
// a uint8_t array, so copy it into a char buffer for printing.
char const* const name = reinterpret_cast<char const*>(info.name.elements);
jarnax::print("CyphalApp: GetInfo from node %u: proto=%u.%u hw=%u.%u sw=%u.%u vcs=%llu unique_id=",
static_cast<unsigned>(static_cast<uint16_t>(transfer.base.source_node_id)),
static_cast<unsigned>(info.protocol_version.major), static_cast<unsigned>(info.protocol_version.minor),
static_cast<unsigned>(info.hardware_version.major), static_cast<unsigned>(info.hardware_version.minor),
static_cast<unsigned>(info.software_version.major), static_cast<unsigned>(info.software_version.minor),
static_cast<unsigned long long>(info.software_vcs_revision_id));
for (size_t i = 0U; i < 16U; ++i) {
jarnax::print("%02X", static_cast<unsigned>(info.unique_id[i]));
}
jarnax::print(" name=%.*s\r\n", static_cast<int>(info.name.count), name);
}

void CyphalApp::PublishHeartbeat() {
uavcan_node_Heartbeat_1_0 heartbeat{};
uavcan_node_Heartbeat_1_0_initialize_(&heartbeat);
Expand Down
Loading
Loading