gps fiddling

This commit is contained in:
liquidraver committed 2026-08-04 16:04:05 +02:00
1 parent 788e4dd48b
commit f83ce0bed4
21 files changed
+1988 -224

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
# SPDX-License-Identifier: MIT
# ZephCore crypto benchmark — is our crypto worth accelerating?
#
# Times every crypto primitive the mesh uses (SHA-256, AES-128-ECB,
# HMAC-SHA256, Ed25519, X25519), then divides the per-packet total by LoRa
# airtime. See HANDOVER_crypto_hw_accel.md — this tool is the "measure first"
# step that gates every option in that document.
#
# It compiles src/Utils.cpp, src/Identity.cpp and src/Packet.cpp straight from
# the main tree, so what is measured is the shipping implementation, not a
# copy of it.
#
# Build:
# west build -b rak4631 zephcore/tools/crypto_bench --pristine
# west build -b xiao_esp32s3/esp32s3/procpu zephcore/tools/crypto_bench --pristine
#
# Iteration counts:
# west build ... zephcore/tools/crypto_bench -- -DCRYPTO_BENCH_ITERS=1000
# west build ... zephcore/tools/crypto_bench -- -DCRYPTO_BENCH_ITERS_SLOW=100
#
# Group-channel worst case (default 40 = MAX_CHANNELS):
# west build ... zephcore/tools/crypto_bench -- -DCRYPTO_BENCH_CHANNELS=8
#
# Flash: nRF52 — drag build/zephyr/zephyr.uf2 onto the UF2 drive
# ESP32 — west flash --esp-device COMx
#
# The tool never reads or writes stored identity, and never brings up the
# radio, flash or BLE. Reflash normal firmware afterwards.
cmake_minimum_required(VERSION 3.20.0)
# Reuse the main project's custom board definitions
list(APPEND BOARD_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../..)
# Strip the board qualifier (e.g. "/esp32s3/procpu") to get the base name
string(REPLACE "/" ";" BOARD_PARTS ${BOARD})
list(GET BOARD_PARTS 0 BOARD_BASE)
# Tool-local board overlay ONLY — deliberately not the node's board.overlay,
# which declares /fstab, radio, sensor and partition nodes this tool does not
# enable (CONFIG_FLASH=n, SPI=n, I2C=n) and would fail on an undefined
# lfs_partition label. Same arrangement as tools/rng_selftest.
set(_BENCH_OVERLAY
"${CMAKE_CURRENT_SOURCE_DIR}/boards/${BOARD_BASE}/board.overlay")
if(EXISTS "${_BENCH_OVERLAY}")
set(EXTRA_DTC_OVERLAY_FILE "${_BENCH_OVERLAY}" CACHE STRING "" FORCE)
message(STATUS "crypto_bench overlay: ${_BENCH_OVERLAY}")
else()
message(STATUS "crypto_bench: using upstream board console (no overlay)")
endif()
# Per-board conf (UF2 output, code partition) — nRF52 needs it, ESP32 must not
# have it. See boards/rak4631/board.conf.
set(_BENCH_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/${BOARD_BASE}/board.conf")
if(EXISTS "${_BENCH_CONF}")
set(EXTRA_CONF_FILE "${_BENCH_CONF}" CACHE STRING "" FORCE)
message(STATUS "crypto_bench conf: ${_BENCH_CONF}")
endif()
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(zephcore_crypto_bench)
# The point of this tool is to time the SHIPPING code. These three files are
# compiled from the main tree, at the same optimisation level the node uses,
# so the numbers transfer.
#
# ZephyrRNG.cpp is deliberately NOT linked: nothing here needs entropy (all
# test vectors are fixed, on purpose — see src/main.cpp), and pulling it in
# would drag CONFIG_ENTROPY_GENERATOR and hwinfo into a config that otherwise
# has no reason for them. The one thing it does that this tool needs —
# psa_crypto_init() — main.cpp calls itself.
target_sources(app PRIVATE
src/main.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../../src/Utils.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../../src/Identity.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../../src/Packet.cpp
)
# Ed25519 / X25519 backend — the real one Identity.cpp uses.
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../../lib/monocypher monocypher)
target_link_libraries(app PRIVATE monocypher)
target_include_directories(app PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_SOURCE_DIR}/../../include
${CMAKE_CURRENT_SOURCE_DIR}/../../lib/monocypher
)
# Iteration counts. The headline figure is the MINIMUM over N, so N mainly
# buys confidence that no run escaped interference; 200 is plenty for the
# symmetric primitives.
if(NOT DEFINED CRYPTO_BENCH_ITERS)
set(CRYPTO_BENCH_ITERS 200)
endif()
# Ed25519 is ~1000x slower than SHA-256. 20 iterations keeps the total runtime
# in seconds; the variance on an operation that long is negligible anyway.
if(NOT DEFINED CRYPTO_BENCH_ITERS_SLOW)
set(CRYPTO_BENCH_ITERS_SLOW 20)
endif()
# Worst-case group-channel loop (Mesh.cpp:398 tries every configured channel).
# Default matches MAX_CHANNELS.
if(NOT DEFINED CRYPTO_BENCH_CHANNELS)
set(CRYPTO_BENCH_CHANNELS 40)
endif()
target_compile_definitions(app PRIVATE
CRYPTO_BENCH_ITERS=${CRYPTO_BENCH_ITERS}
CRYPTO_BENCH_ITERS_SLOW=${CRYPTO_BENCH_ITERS_SLOW}
CRYPTO_BENCH_CHANNELS=${CRYPTO_BENCH_CHANNELS}
)
message(STATUS "crypto_bench: N=${CRYPTO_BENCH_ITERS} slow=${CRYPTO_BENCH_ITERS_SLOW} channels=${CRYPTO_BENCH_CHANNELS}")
@@ -0,0 +1,16 @@
# RAK4631 — crypto benchmark board config
#
# UF2 output lives here rather than in prj.conf: it is nRF52-with-bootloader
# specific, and on ESP32 uf2conv fails outright (no UF2 family for that SoC).
#
# CONFIG_FLASH is enabled ONLY so the devicetree flash partitions resolve —
# uf2conv derives its base address from CONFIG_FLASH_LOAD_OFFSET, which
# USE_DT_CODE_PARTITION reads from the `zephyr,code-partition` chosen node.
# With flash off the offset comes out empty and uf2conv aborts with
# "argument -b/--base: expected one argument".
#
# This does NOT let the tool touch stored data: CONFIG_FILE_SYSTEM stays off,
# nothing mounts /lfs, and the tool never reads or writes an identity.
CONFIG_FLASH=y
CONFIG_USE_DT_CODE_PARTITION=y
CONFIG_BUILD_OUTPUT_UF2=y
@@ -0,0 +1,49 @@
/*
* RAK4631 — crypto benchmark console overlay
* SPDX-License-Identifier: MIT
*
* Minimal ON PURPOSE. The node's own board.overlay is NOT reused here: it
* declares an /fstab entry, radio, sensors and partitions, none of which this
* tool enables (CONFIG_FLASH=n, CONFIG_SPI=n ...) — pulling it in fails the
* build on an undefined lfs_partition label. Same reasoning as the LR1110
* updater's per-board overlays.
*
* RAK4631 connects USB straight to the nRF52840, so the console has to ride
* USB CDC ACM; uart0's physical pins are not reachable on a plain WisBlock
* base board.
*/
/* Same flash layout as the node build.
*
* NOT just for UF2 packaging: RAK4631 boots through the Adafruit nRF52
* bootloader, so the application must be LINKED at the bootloader's app
* offset. Without a code partition the image links at 0 and would not run even
* if uf2conv managed to package it (it does not — the missing
* CONFIG_FLASH_LOAD_OFFSET is what produces "argument -b/--base: expected one
* argument"). SoftDevice v6 layout, matching boards/nrf52840/rak4631.
*
* The delete-nodes are required by that dtsi's own contract (see its header):
* it redefines partitions the upstream board DTS already labels, and without
* removing those first the build fails with a duplicate 'storage_partition'
* label. */
/delete-node/ &boot_partition;
/delete-node/ &slot0_partition;
/delete-node/ &slot1_partition;
/delete-node/ &storage_partition;
#include "../../../../boards/common/nrf52_partitions_sdv6.dtsi"
/ {
chosen {
zephyr,code-partition = &code_partition;
zephyr,console = &cdc_acm_uart;
zephyr,shell-uart = &cdc_acm_uart;
};
};
&zephyr_udc0 {
cdc_acm_uart: cdc_acm_uart {
compatible = "zephyr,cdc-acm-uart";
};
};
+82
View File
@@ -0,0 +1,82 @@
# ZephCore crypto benchmark — minimal config.
#
# Mirrors the crypto block of boards/common/zephcore_common.conf EXACTLY, so
# what is timed is the same PSA backend selection the node builds. Any
# divergence here (a different PSA_WANT set, a different mbedTLS config) would
# change which implementation mbedTLS picks and silently invalidate the whole
# measurement.
# ========== Crypto (PSA) — copied verbatim from zephcore_common.conf ==========
CONFIG_MBEDTLS=y
CONFIG_MBEDTLS_PSA_CRYPTO_C=y
CONFIG_PSA_WANT_ALG_SHA_256=y
CONFIG_PSA_WANT_ALG_ECB_NO_PADDING=y
CONFIG_PSA_WANT_KEY_TYPE_AES=y
CONFIG_PSA_WANT_ALG_HMAC=y
CONFIG_PSA_WANT_KEY_TYPE_HMAC=y
# Portable CPU cycle counter — the measurement instrument. Same symbol the
# node enables, so enabling it here does not perturb anything relative to the
# node's own timing.
CONFIG_TIMING_FUNCTIONS=y
# sys_reboot() — Utils::cryptoPanicReboot() calls it. Kept live rather than
# stubbed: if a benchmarked primitive ever panics, the tool should reboot the
# way the node would, not quietly print a number that means nothing.
CONFIG_REBOOT=y
# C++ — Utils, Identity and Packet are C++
CONFIG_CPP=y
CONFIG_STD_CPP17=y
CONFIG_REQUIRES_FULL_LIBC=y
# Console only, no log subsystem. All output is printk, which is synchronous —
# important here, because an asynchronous log backend would do work between
# timed regions and inflate the mean.
CONFIG_SERIAL=y
CONFIG_CONSOLE=y
CONFIG_UART_CONSOLE=y
CONFIG_PRINTK=y
CONFIG_LOG=n
# No floating-point printf: every figure this tool prints is integer math on
# purpose (see print_result / airtime_us). Leaving FP support off keeps the
# image small and rules out an FP-emulation call inside a timed region.
CONFIG_CBPRINTF_FP_SUPPORT=n
# CONFIG_ASSERT stays off: on ESP32 the Espressif blob trips false kswap.h
# assertions, and this tool has no reason to differ from the node.
CONFIG_ASSERT=n
# Main stack: Ed25519 verify through Monocypher plus the static test vectors
# and the ~200-byte scratch buffers in Utils::encrypt. 4096 is generous; the
# node runs 8192.
CONFIG_MAIN_STACK_SIZE=4096
# NOTE: no CONFIG_BUILD_OUTPUT_UF2 here — it is nRF52-with-bootloader
# specific and fails outright on ESP32. It lives in boards/rak4631/board.conf.
# USB CDC ACM console — nRF52840 boards (RAK4631) route console over USB
# because the UART pins are not reachable on a plain base board. Harmless on
# ESP32, which uses its own console and ignores these.
CONFIG_USB_DEVICE_STACK_NEXT=y
CONFIG_UART_LINE_CTRL=y
CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT=y
CONFIG_CDC_ACM_SERIAL_ENABLE_AT_BOOT=y
# Explicitly off — this tool must not touch the radio, storage or BLE. In
# particular it must never mount /lfs: it does not read or write any stored
# identity, so a device under test keeps whatever it already had.
#
# Turning BT off matters for a second reason on nRF: CONFIG_CRYPTO_NRF_ECB
# depends on !HAS_BT_CTLR. If anyone later extends this tool to compare a
# hardware AES backend, that comparison is only valid against a build with the
# same BT setting the target role actually uses — see
# HANDOVER_crypto_hw_accel.md, "Traps and constraints".
CONFIG_BT=n
CONFIG_FLASH=n
CONFIG_FILE_SYSTEM=n
CONFIG_SPI=n
CONFIG_I2C=n
CONFIG_SENSOR=n
CONFIG_GNSS=n
+524
View File
@@ -0,0 +1,524 @@
/*
* ZephCore crypto benchmark
* SPDX-License-Identifier: MIT
*
* Answers one question: does ZephCore's crypto cost enough to be worth
* accelerating? See HANDOVER_crypto_hw_accel.md — every option in that
* document (a second Zephyr-crypto-API backend in Utils.cpp, nrfxlib PSA
* drivers in west.yml, or the free software wins) is gated on numbers that
* nobody has ever taken.
*
* WHY A SEPARATE IMAGE rather than a CLI command on a live node:
* the measurement has to be free of mesh-loop, BLE and LoRa-RX interference,
* and it runs each primitive hundreds of times back to back — which would
* starve exactly those subsystems. Same reasoning as tools/rng_selftest.
*
* WHAT IT MEASURES, and why the answer is a RATIO not a number:
* the absolute cost of SHA-256 is uninteresting on its own. What decides the
* question is crypto time as a fraction of packet AIRTIME — at SF11/250 kHz a
* packet occupies the channel for hundreds of milliseconds, so a per-packet
* crypto bill in the tens of microseconds is noise, and the whole hardware
* exploration closes with a documented "no". The final table does that
* division for you, per LoRa preset, using the SAME airtime formula the node
* uses (LoRaRadioBase::getAirtimeMillis) so the comparison is apples to
* apples.
*
* It measures the SHIPPING code: src/Utils.cpp, src/Identity.cpp and
* src/Packet.cpp are compiled straight from the main tree, not copied. What
* is timed here is byte-for-byte what the node runs.
*
* The tool never reads or writes stored identity, and never touches the
* radio, flash or BLE. A device under test keeps whatever key it had.
*/
#include <zephyr/kernel.h>
#include <zephyr/sys/printk.h>
#include <zephyr/timing/timing.h>
#include <psa/crypto.h>
#include <string.h>
#include <mesh/Utils.h>
#include <mesh/Identity.h>
#include <mesh/Packet.h>
using namespace mesh;
#ifndef CRYPTO_BENCH_ITERS
#define CRYPTO_BENCH_ITERS 200
#endif
/* Ed25519 is ~3 orders of magnitude slower than the symmetric primitives.
* At the default iteration count a full sweep would take minutes and tell us
* nothing extra — the variance on an operation that long is negligible. */
#ifndef CRYPTO_BENCH_ITERS_SLOW
#define CRYPTO_BENCH_ITERS_SLOW 20
#endif
/* Payload sizes. 184 is MAX_PACKET_PAYLOAD; calculatePacketHash hashes
* 1 + payload_len, so the hash input tops out at 185. 60 is a typical text
* message, 100 a typical advert. */
#define PAYLOAD_TYPICAL 60
#define PAYLOAD_ADVERT 100
#define PAYLOAD_MAX MAX_PACKET_PAYLOAD
/* Worst case for the group-channel loop at Mesh.cpp:398. */
#ifndef CRYPTO_BENCH_CHANNELS
#define CRYPTO_BENCH_CHANNELS 40
#endif
/* Anti-elision. Without a visible consumer the optimiser is entitled to
* delete calls whose output is unused, and at -Os it does.
*
* It reads only the first and last byte, NOT the whole buffer: the sink runs
* INSIDE the timed region, and summing 176 bytes would add real work to every
* AES measurement. Two bytes is enough — the compiler cannot prove the rest
* of the buffer was unnecessary to produce them. */
static volatile uint32_t g_sink;
static inline void sink(const uint8_t *p, size_t n)
{
g_sink += (uint32_t)p[0] + (uint32_t)p[n - 1];
}
/* ---------------------------------------------------------------- timing */
/* Zephyr's portable cycle counter (DWT on Cortex-M, CCOUNT on Xtensa) — the
* same mechanism ZephyrRNG's two-clock beat uses. See
* memory/zephyrrng-entropy.md for the platform traps.
*
* MIN, not mean, is the headline figure: every perturbation (interrupt,
* cache miss, flash wait state) can only ADD time, so the minimum over N runs
* is the closest estimate of the true cost. Mean is printed alongside because
* a large min/mean gap is itself information — it says the operation is being
* interfered with, which matters on a node where it shares a CPU with the
* radio ISR. */
struct BenchResult {
const char *name;
uint64_t min_ns;
uint64_t mean_ns;
int iters;
};
static uint64_t cycles_to_ns(uint64_t cycles)
{
return timing_cycles_to_ns(cycles);
}
/* Parameter names are underscore-prefixed on purpose: the preprocessor
* substitutes macro arguments after `.` too, so a parameter called `iters`
* would rewrite the `(dst).iters` member access into `(dst).200`. */
#define BENCH(dst, _label, _n, stmt) \
do { \
uint64_t _min = UINT64_MAX, _sum = 0; \
for (int _i = 0; _i < (_n); _i++) { \
timing_t _t0 = timing_counter_get(); \
{ stmt; } \
timing_t _t1 = timing_counter_get(); \
uint64_t _c = timing_cycles_get(&_t0, &_t1); \
if (_c < _min) _min = _c; \
_sum += _c; \
} \
(dst).name = (_label); \
(dst).min_ns = cycles_to_ns(_min); \
(dst).mean_ns = cycles_to_ns(_sum / (uint64_t)(_n)); \
(dst).iters = (_n); \
} while (0)
/* us with 3 decimals, integer math — no CBPRINTF_FP_SUPPORT needed. */
static void print_result(const BenchResult *r)
{
uint64_t mn = r->min_ns, mu = r->mean_ns;
printk(" %-34s %6llu.%03llu %8llu.%03llu %4d\n",
r->name, mn / 1000u, mn % 1000u, mu / 1000u, mu % 1000u, r->iters);
}
static void print_header(const char *section)
{
printk("\n%s\n", section);
printk(" %-34s %10s %12s %4s\n", "operation", "min us", "mean us", "N");
printk(" ---------------------------------- ---------- ------------ ----\n");
}
/* --------------------------------------------------------------- airtime */
/* Integer port of LoRaRadioBase::getAirtimeMillis(), in microseconds.
* Deliberately mirrors that function line for line — including the
* preamble-length rule and the LDRO threshold tied to symbol time (not to
* SF), because a divergence here would silently distort the only comparison
* this tool exists to make. */
static uint32_t preamble_syms(uint8_t sf)
{
return (sf <= 8) ? 32u : 16u; /* PR #1954 parity */
}
static uint64_t airtime_us(uint8_t sf, uint32_t bw_hz, uint8_t cr, uint32_t len)
{
/* Tsym in ns, to keep the LDRO comparison exact at narrow BW. */
uint64_t tsym_ns = ((uint64_t)(1u << sf) * 1000000000ull) / bw_hz;
/* 4.25 preamble symbols -> multiply by 425, divide by 100. */
uint64_t t_pre_ns = ((uint64_t)preamble_syms(sf) * 100ull + 425ull) * tsym_ns / 100ull;
int de = (tsym_ns > 16380000ull) ? 1 : 0; /* > 16.38 ms */
int64_t num = 8ll * (int64_t)len - 4ll * sf + 28 + 16;
int64_t den = 4ll * ((int64_t)sf - 2ll * de);
if (den < 1) den = 4;
int64_t ceil_div = (num + den - 1) / den;
int64_t n_pay = 8 + (ceil_div * (int64_t)cr > 0 ? ceil_div * (int64_t)cr : 0);
return (t_pre_ns + (uint64_t)n_pay * tsym_ns) / 1000ull;
}
struct Preset {
const char *name;
uint8_t sf;
uint32_t bw_hz;
uint8_t cr; /* 5..8, used as (cr-4+4) == cr in the formula */
};
static const Preset presets[] = {
{ "SF7 BW62.5k CR4/5", 7, 62500, 5 }, /* EmpireMesh, since 2026-07-25 */
{ "SF8 BW62.5k CR4/8", 8, 62500, 8 }, /* EmpireMesh, before that */
{ "SF10 BW125k CR4/5", 10, 125000, 5 },
{ "SF11 BW250k CR4/5", 11, 250000, 5 }, /* MeshCore default */
};
/* ------------------------------------------------------------------ main */
int main(void)
{
k_msleep(2000); /* USB CDC enumerates after boot on some boards */
timing_init();
timing_start();
/* Utils.cpp assumes PSA is initialised. On a node that happens inside
* ZephyrRNG (adapters/rng/ZephyrRNG.cpp:285), which this tool
* deliberately does not link — so do it here, and fail loudly rather
* than silently benchmarking a pile of PSA_ERROR returns. */
psa_status_t ps = psa_crypto_init();
if (ps != PSA_SUCCESS) {
printk("FATAL: psa_crypto_init failed: %d\n", (int)ps);
return 0;
}
printk("\n");
printk("========================================================\n");
printk(" ZephCore crypto benchmark\n");
printk(" board: %s N=%d (slow ops N=%d)\n",
CONFIG_BOARD, CRYPTO_BENCH_ITERS, CRYPTO_BENCH_ITERS_SLOW);
printk("========================================================\n");
/* Test vectors. Fixed, not random: this is a timing measurement and a
* deterministic input makes runs comparable across boards and builds.
* Ed25519/X25519 timing is data-dependent at the margins, which the
* min-over-N already absorbs. */
static uint8_t buf[1 + MAX_PACKET_PAYLOAD + CIPHER_BLOCK_SIZE];
static uint8_t out[MAX_PACKET_PAYLOAD + CIPHER_BLOCK_SIZE * 2];
static uint8_t key[PUB_KEY_SIZE];
static uint8_t hash[MAX_HASH_SIZE];
static uint8_t sig[SIGNATURE_SIZE];
static uint8_t secret[CIPHER_KEY_SIZE * 2];
for (size_t i = 0; i < sizeof(buf); i++) buf[i] = (uint8_t)(i * 7 + 3);
for (size_t i = 0; i < sizeof(key); i++) key[i] = (uint8_t)(i * 11 + 5);
uint8_t seed[SEED_SIZE];
for (size_t i = 0; i < sizeof(seed); i++) seed[i] = (uint8_t)(i * 13 + 1);
LocalIdentity self;
self.fromSeed(seed);
uint8_t seed2[SEED_SIZE];
for (size_t i = 0; i < sizeof(seed2); i++) seed2[i] = (uint8_t)(i * 17 + 9);
LocalIdentity peer;
peer.fromSeed(seed2);
self.sign(sig, buf, PAYLOAD_ADVERT);
if (!self.verify(sig, buf, PAYLOAD_ADVERT)) {
printk("FATAL: sign/verify self-check failed — results would be "
"meaningless\n");
return 0;
}
BenchResult r;
/* ---- instrument baseline ---------------------------------------- */
print_header("Instrument baseline — subtract this from everything below");
/* The cost of the two timing_counter_get() calls and the sink, with no
* crypto between them. Every figure in this run carries it. If it is
* not small compared to the fastest operation measured, the fastest
* rows are measuring the ruler, not the thing. */
BENCH(r, "empty (timer pair + sink)", CRYPTO_BENCH_ITERS,
sink(buf, 16));
print_result(&r);
uint64_t overhead = r.min_ns;
/* ---- primitives: SHA-256 ---------------------------------------- */
print_header("SHA-256 (PSA one-shot) — the dedup hash");
BENCH(r, "sha256, 16 B", CRYPTO_BENCH_ITERS,
Utils::sha256(hash, MAX_HASH_SIZE, buf, 16); sink(hash, MAX_HASH_SIZE));
print_result(&r);
uint64_t sha_16 = r.min_ns;
BENCH(r, "sha256, 61 B (typical msg pkt)", CRYPTO_BENCH_ITERS,
Utils::sha256(hash, MAX_HASH_SIZE, buf, 1 + PAYLOAD_TYPICAL); sink(hash, MAX_HASH_SIZE));
print_result(&r);
uint64_t sha_typical = r.min_ns;
BENCH(r, "sha256, 101 B (typical advert)", CRYPTO_BENCH_ITERS,
Utils::sha256(hash, MAX_HASH_SIZE, buf, 1 + PAYLOAD_ADVERT); sink(hash, MAX_HASH_SIZE));
print_result(&r);
BENCH(r, "sha256, 185 B (max payload)", CRYPTO_BENCH_ITERS,
Utils::sha256(hash, MAX_HASH_SIZE, buf, 1 + PAYLOAD_MAX); sink(hash, MAX_HASH_SIZE));
print_result(&r);
uint64_t sha_max = r.min_ns;
BENCH(r, "sha256, 2-frag (32+32 B)", CRYPTO_BENCH_ITERS,
Utils::sha256(hash, MAX_HASH_SIZE, buf, 32, buf + 32, 32); sink(hash, MAX_HASH_SIZE));
print_result(&r);
/* ---- primitives: AES ------------------------------------------- */
print_header("AES-128-ECB (PSA, key imported+destroyed per call)");
BENCH(r, "encrypt, 16 B (1 block)", CRYPTO_BENCH_ITERS,
int n = Utils::encrypt(key, out, buf, 16); sink(out, n > 0 ? n : 1));
print_result(&r);
uint64_t aes_1blk = r.min_ns;
BENCH(r, "encrypt, 60 B (4 blocks)", CRYPTO_BENCH_ITERS,
int n = Utils::encrypt(key, out, buf, PAYLOAD_TYPICAL); sink(out, n > 0 ? n : 1));
print_result(&r);
BENCH(r, "encrypt, 176 B (11 blocks)", CRYPTO_BENCH_ITERS,
int n = Utils::encrypt(key, out, buf, 176); sink(out, n > 0 ? n : 1));
print_result(&r);
uint64_t aes_max = r.min_ns;
/* decrypt needs valid ciphertext-shaped input; ECB accepts anything
* block-aligned, and the timing does not depend on the plaintext. */
BENCH(r, "decrypt, 176 B (11 blocks)", CRYPTO_BENCH_ITERS,
int n = Utils::decrypt(key, out, buf, 176); sink(out, n > 0 ? n : 1));
print_result(&r);
/* ---- the per-call key import ------------------------------------ */
print_header("PSA key handling — the per-call import/destroy overhead");
/* Isolates what src/Utils.cpp:86,101 and :149,159 spend on key
* lifecycle ALONE, with no cipher or MAC work attached. This is the
* number that decides whether caching a key handle per channel secret
* is worth changing Utils.cpp for: compare it against "AES encrypt,
* 16 B" above. If import+destroy dominates a one-block encrypt, the
* group-channel loop is paying for key management, not crypto. */
BENCH(r, "psa_import_key + psa_destroy_key", CRYPTO_BENCH_ITERS, {
psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT;
psa_set_key_type(&attr, PSA_KEY_TYPE_AES);
psa_set_key_bits(&attr, CIPHER_KEY_SIZE * 8);
psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_ENCRYPT);
psa_set_key_algorithm(&attr, PSA_ALG_ECB_NO_PADDING);
psa_key_id_t kid;
if (psa_import_key(&attr, key, CIPHER_KEY_SIZE, &kid) == PSA_SUCCESS) {
g_sink += (uint32_t)kid;
psa_destroy_key(kid);
}
});
print_result(&r);
uint64_t key_import = r.min_ns;
/* ---- HMAC, by subtraction --------------------------------------- */
print_header("HMAC-SHA256 (via encryptThenMAC / MACThenDecrypt)");
/* compute_hmac_truncated() is static in Utils.cpp, so it cannot be
* called directly and is NOT worth un-staticing for a benchmark. It is
* derived instead: encryptThenMAC = encrypt + HMAC, and the failing
* MACThenDecrypt path is HMAC + a constant-time compare and nothing
* else (it returns before decrypt on MAC mismatch) — so the mismatch
* case below is very nearly a direct HMAC measurement. */
BENCH(r, "encryptThenMAC, 60 B (TX path)", CRYPTO_BENCH_ITERS,
int n = Utils::encryptThenMAC(key, out, buf, PAYLOAD_TYPICAL); sink(out, n > 0 ? n : 1));
print_result(&r);
uint64_t etm_typical = r.min_ns;
BENCH(r, "encryptThenMAC, 176 B (TX path)", CRYPTO_BENCH_ITERS,
int n = Utils::encryptThenMAC(key, out, buf, 176); sink(out, n > 0 ? n : 1));
print_result(&r);
/* A real ciphertext, so the success path actually reaches decrypt. */
static uint8_t ct[MAX_PACKET_PAYLOAD + CIPHER_BLOCK_SIZE * 2];
int ct_len = Utils::encryptThenMAC(key, ct, buf, PAYLOAD_TYPICAL);
if (ct_len <= 0) {
printk("FATAL: encryptThenMAC produced nothing\n");
return 0;
}
BENCH(r, "MACThenDecrypt, MATCH (RX ours)", CRYPTO_BENCH_ITERS,
int n = Utils::MACThenDecrypt(key, out, ct, ct_len); sink(out, n > 0 ? n : 1));
print_result(&r);
uint64_t mtd_match = r.min_ns;
/* Wrong key -> MAC mismatch -> returns before AES. This is the cost of
* ONE non-matching channel in the Mesh.cpp:398 loop. */
static uint8_t wrong_key[PUB_KEY_SIZE];
for (size_t i = 0; i < sizeof(wrong_key); i++) wrong_key[i] = (uint8_t)(i * 3 + 200);
BENCH(r, "MACThenDecrypt, MISS (wrong chan)", CRYPTO_BENCH_ITERS,
int n = Utils::MACThenDecrypt(wrong_key, out, ct, ct_len); g_sink += (uint32_t)n);
print_result(&r);
uint64_t mtd_miss = r.min_ns;
/* ---- asymmetric -------------------------------------------------- */
print_header("Ed25519 / X25519 (Monocypher, software only)");
BENCH(r, "Ed25519 verify (advert RX)", CRYPTO_BENCH_ITERS_SLOW,
bool ok = self.verify(sig, buf, PAYLOAD_ADVERT); g_sink += ok ? 1 : 0);
print_result(&r);
uint64_t ed_verify = r.min_ns;
BENCH(r, "Ed25519 sign (advert TX)", CRYPTO_BENCH_ITERS_SLOW,
self.sign(sig, buf, PAYLOAD_ADVERT); sink(sig, SIGNATURE_SIZE));
print_result(&r);
BENCH(r, "X25519 shared secret (contact)", CRYPTO_BENCH_ITERS_SLOW,
self.calcSharedSecret(secret, peer); sink(secret, sizeof(secret)));
print_result(&r);
/* ---- composites: real call paths -------------------------------- */
print_header("Per-packet totals — the real RX/TX call paths");
/* Dedup as implemented today. SimpleMeshTables::wasSeen() and
* markSeen() each call packet->calculatePacketHash() independently,
* and since the 2026-07-02 hasSeen split every caller invokes markSeen
* right after wasSeen returns false — so a NEW packet hashes twice.
* The linear memcmp scan those functions also do is not crypto and is
* not timed here; it is a 160-entry 8-byte compare, nowhere near the
* hash. Packet.cpp is compiled from the main tree, so the buffer
* assembly (the memcpy into a stack buffer) is included, as it is on a
* node. */
/* Not `static`: a function-local static with a non-trivial constructor
* emits __cxa_guard_acquire/release, which Zephyr's minimal C++ runtime
* does not provide. ~260 bytes on the stack instead. */
Packet pkt;
pkt.header = (PAYLOAD_TYPE_TXT_MSG << PH_TYPE_SHIFT) | ROUTE_TYPE_FLOOD;
pkt.payload_len = PAYLOAD_TYPICAL;
memcpy(pkt.payload, buf, PAYLOAD_TYPICAL);
BENCH(r, "dedup, 1x calculatePacketHash", CRYPTO_BENCH_ITERS,
pkt.calculatePacketHash(hash); sink(hash, MAX_HASH_SIZE));
print_result(&r);
uint64_t dedup_1 = r.min_ns;
BENCH(r, "dedup, 2x (wasSeen + markSeen)", CRYPTO_BENCH_ITERS,
pkt.calculatePacketHash(hash); sink(hash, MAX_HASH_SIZE);
pkt.calculatePacketHash(hash); sink(hash, MAX_HASH_SIZE));
print_result(&r);
uint64_t dedup_2 = r.min_ns;
/* Group packet that matches no configured channel: the loop runs every
* channel and every one costs a full HMAC. */
BENCH(r, "group RX, " STRINGIFY(CRYPTO_BENCH_CHANNELS) " channel misses",
CRYPTO_BENCH_ITERS / 4,
for (int c = 0; c < CRYPTO_BENCH_CHANNELS; c++) {
int n = Utils::MACThenDecrypt(wrong_key, out, ct, ct_len);
g_sink += (uint32_t)n;
});
print_result(&r);
uint64_t group_miss_all = r.min_ns;
/* ---- the verdict table ------------------------------------------ */
printk("\n");
printk("========================================================\n");
printk(" PER-PACKET CRYPTO BUDGET vs AIRTIME\n");
printk("--------------------------------------------------------\n");
struct Scenario {
const char *name;
uint64_t ns;
uint32_t pkt_bytes;
};
/* Packet byte counts are the on-air length the airtime formula wants:
* 2 header bytes + path + payload. Path is assumed 3 hops x 1-byte
* hash, a common flood. */
const uint32_t hdr_path = 2 + 3;
const Scenario scen[] = {
{ "flood not for us (dedup only)",
dedup_2, hdr_path + PAYLOAD_TYPICAL },
{ "direct msg for us (dedup+MAC+AES)",
dedup_2 + mtd_match, hdr_path + PAYLOAD_TYPICAL },
{ "group msg, no channel matches",
dedup_2 + group_miss_all, hdr_path + PAYLOAD_TYPICAL },
{ "advert (dedup + Ed25519 verify)",
dedup_2 + ed_verify, hdr_path + PAYLOAD_ADVERT },
{ "our TX (encryptThenMAC + dedup)",
dedup_1 + etm_typical, hdr_path + PAYLOAD_TYPICAL },
};
for (size_t p = 0; p < ARRAY_SIZE(presets); p++) {
printk("\n %s\n", presets[p].name);
printk(" %-36s %9s %9s %8s\n",
"scenario", "crypto us", "air us", "share");
printk(" ------------------------------------ --------- --------- --------\n");
for (size_t s = 0; s < ARRAY_SIZE(scen); s++) {
uint64_t air = airtime_us(presets[p].sf, presets[p].bw_hz,
presets[p].cr, scen[s].pkt_bytes);
uint64_t cry_us = scen[s].ns / 1000u;
/* share in hundredths of a percent (basis points) */
uint64_t bp = air ? (scen[s].ns * 10000ull) / (air * 1000ull) : 0;
printk(" %-36s %9llu %9llu %3llu.%02llu%%\n",
scen[s].name, cry_us, air, bp / 100u, bp % 100u);
}
}
/* ---- what the software wins would buy --------------------------- */
printk("\n");
printk("--------------------------------------------------------\n");
printk(" HEADROOM OF THE FREE SOFTWARE WINS (no hardware needed)\n");
printk("--------------------------------------------------------\n");
printk(" single-hash dedup saves : %llu us/packet\n",
(dedup_2 - dedup_1) / 1000u);
printk(" cached key handle would save up to: %llu us/packet\n",
((uint64_t)CRYPTO_BENCH_CHANNELS * key_import) / 1000u);
printk(" (%d channels x %llu.%03llu us import/destroy)\n",
CRYPTO_BENCH_CHANNELS, key_import / 1000u, key_import % 1000u);
printk(" key import as share of a 1-block AES: %llu%%\n",
aes_1blk ? (key_import * 100u) / aes_1blk : 0);
printk("\n");
printk("--------------------------------------------------------\n");
printk(" HOW TO READ THIS\n");
printk("--------------------------------------------------------\n");
printk(" The 'share' column is the whole question. Crypto competes\n");
printk(" with airtime, not with itself. If every scenario sits well\n");
printk(" under 1%%, hardware acceleration cannot buy throughput the\n");
printk(" radio would ever notice, and the honest answer to\n");
printk(" HANDOVER_crypto_hw_accel.md is Option C (do nothing) --\n");
printk(" regardless of how large the microsecond figures look.\n");
printk(" Only a scenario in the double-digit percent range, or one\n");
printk(" whose absolute cost approaches the inter-packet gap,\n");
printk(" justifies going further.\n");
printk(" Power is NOT measured here. A CPU-bound millisecond also\n");
printk(" costs battery; if the share numbers are borderline, measure\n");
printk(" current draw before deciding.\n");
printk("\n");
printk("========================================================\n");
printk(" reference: sha16=%llu.%03llu us sha185=%llu.%03llu us\n",
sha_16 / 1000u, sha_16 % 1000u, sha_max / 1000u, sha_max % 1000u);
printk(" reference: aes1blk=%llu.%03llu us aes176=%llu.%03llu us\n",
aes_1blk / 1000u, aes_1blk % 1000u, aes_max / 1000u, aes_max % 1000u);
printk(" reference: hmac_miss=%llu.%03llu us ed_verify=%llu.%03llu us\n",
mtd_miss / 1000u, mtd_miss % 1000u, ed_verify / 1000u, ed_verify % 1000u);
printk(" instrument overhead=%llu ns (already included in every row)\n",
overhead);
printk(" (sha_typical=%llu ns, sink=%u — ignore, anti-elision)\n",
sha_typical, (unsigned)g_sink);
printk("========================================================\n");
printk("\n[crypto_bench] done — halting. Reflash normal firmware.\n");
timing_stop();
return 0;
}