Skip to content
Draft
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
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ idf_component_register(
REQUIRED_IDF_TARGETS
"esp32"
"esp32s3"
"esp32s31"
"esp32c2"
"esp32c3"
"esp32c5"
Expand Down
63 changes: 62 additions & 1 deletion Kconfig
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,65 @@ config NIMBLE_CPP_DEBUG_ASSERT_ENABLED
Enabling this option will add debug asserts to the NimBLE CPP library.
This will use approximately 1kB of flash memory.

endmenu
config NIMBLE_CPP_L2CAP_SDU_BUFFER_COUNT
int "L2CAP SDU buffer count."
range 2 32
default 5
help
Number of full-SDU mbufs allocated per NimBLEL2CAPChannel instance for
local CoC send/receive work. Each buffer is sized to the channel MTU, so
increasing this directly increases per-channel heap consumption.

Higher values improve tolerance for bursty traffic, overlapping send and
receive work, deferred read callback lag, and other short periods where
the channel needs more than one full SDU buffer ready at once.

Lower values reduce memory footprint, but make the channel more fragile
under load. If this value is too small for the negotiated MTU and the way
the application consumes callbacks, local allocations may fail and large
SDUs can start timing out or disconnecting even though CoC MTU negotiation
itself succeeded.

The default value of 5 preserves the currently validated behavior. Reduce
it only when memory is tight and the application traffic pattern is known
to be light; increase it if callbacks may lag or if multiple large SDUs
must be in flight locally.

config NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS
bool "Defer L2CAP read callbacks to a worker task."
default "n"
help
When enabled, NimBLEL2CAPChannel rearms receive first on the NimBLE host task
and then dispatches onConnect() and onRead() callbacks on a shared worker task.
Disconnect callbacks always run on that worker, outside the NimBLE host lock.
This reduces the risk of doing heavy application work directly on the NimBLE host path.

if NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS

config NIMBLE_CPP_L2CAP_CALLBACK_TASK_STACK_SIZE
int "L2CAP callback worker stack size."
range 2048 32768
default 4096
help
Stack size in bytes for the shared worker task used to invoke deferred
L2CAP read callbacks.

config NIMBLE_CPP_L2CAP_CALLBACK_TASK_PRIORITY
int "L2CAP callback worker task priority."
range 1 24
default 5
help
FreeRTOS priority for the shared worker task used to invoke deferred
L2CAP read callbacks.

config NIMBLE_CPP_L2CAP_CALLBACK_QUEUE_LENGTH
int "L2CAP callback queue length."
range 4 128
default 16
help
Number of queued deferred L2CAP read callbacks. Overflow aborts the
connection; additional reserved slots guarantee lifecycle delivery.

endif

endmenu
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ When using this library along with Arduino and compiling with *CMake* you must a
in your project/CMakeLists.txt after the line `include($ENV{IDF_PATH}/tools/cmake/project.cmake)` to prevent Arduino from releasing BLE memory.
<br>

## L2CAP measurement

The standalone [CoC benchmark](examples/L2CAP/L2CAP_Bench/README.md) for ESP-IDF
6.1 supports ESP32-S3/S31, ESP and macOS peers, request/response and streaming,
and repeated reconnects. Its [serial runner](tools/l2cap-bench/README.md)
retains accepted link parameters and validates complete per-frame evidence.

# Sponsors
Thank you to all the sponsors who support this project!

Expand Down
5 changes: 5 additions & 0 deletions examples/L2CAP/L2CAP_Bench/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
build*/
sdkconfig
sdkconfig.old
dependencies.lock
managed_components/
7 changes: 7 additions & 0 deletions examples/L2CAP/L2CAP_Bench/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
cmake_minimum_required(VERSION 3.16)
get_filename_component(NIMBLE_CPP_ROOT "../../.." ABSOLUTE)
set(EXTRA_COMPONENT_DIRS "${NIMBLE_CPP_ROOT}")
set(COMPONENTS main)
set(SUPPORTED_TARGETS esp32s3 esp32s31)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(l2cap_bench)
118 changes: 118 additions & 0 deletions examples/L2CAP/L2CAP_Bench/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# L2CAP CoC benchmark

Standalone ESP-IDF 6.1 firmware for ESP32-S3 and ESP32-S31, with `echo`,
small-request/large-response `request`, `stream-tx`, `stream-rx` and `duplex`.
It can be the DUT or the ESP reflector. Wi-Fi/Ethernet are not initialized.
The ESP drives the application protocol independently of its GAP role.

## Build and connect

```sh
# Use the matching ESP-IDF 6.1 environment.
idf.py --version
IDF_TARGET=esp32s3 idf.py -B build-s3 -D SDKCONFIG=build-s3/sdkconfig \
-D 'SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.defaults.esp32s3' build
idf.py -B build-s3 -p /dev/serial/by-id/<board> flash
```

For S31 use `IDF_TARGET=esp32s31`, `idf.py --preview`, `build-s31` and
`sdkconfig.defaults.esp32s31`. CPU defaults are 240/320 MHz respectively.
The USB Serial/JTAG console accepts LF-terminated commands at 115200 baud.

An ESP peer uses the same firmware and `role server`. For macOS use the
[`l2cap-reflect` companion](https://github.com/mickeyl/core-bluetooth-tool)
and its `tools/run-l2cap-reflector-app.sh`; macOS must grant Bluetooth access.
The companion shares Foundation-only framing/codecs for a future iPhone peer;
an iPhone peer is not included here. Start the Mac with
`--connect L2CAP-Bench-ESP` for Apple-Central/ESP-Peripheral roles.

```text
params 15 2
listen
await 30
run echo 64 64 64
run request 8 4096 64
run duplex 4096 4096 128
disconnect
```

For ESP-Central mode use `connect auto` instead of `listen`/`await`. Automatic
selection requires exactly one benchmark service; a name or BLE address can be
selected explicitly. `role client` restores DUT mode after using the ESP as a
reflector. A running reflector has a separate application TX worker.

The Python runner automates this over USB; see
[`tools/l2cap-bench`](../../../tools/l2cap-bench/README.md). Use
`--require-params` for controlled comparisons. Link setup retries a refused
interval after one second, up to three times, outside callbacks and timing.
The peer still chooses the accepted parameters: requested values are not proof.

## Wire contract, version 2

Frames are `B7 type length:u16 payload`, with at most 4996 payload bytes.
Multibyte fields are big endian except the GATT PSM's little-endian `u16`.
The parser accepts split and coalesced frames; SDUs are not message boundaries.
The service UUID is `e7100001-61b3-4b3d-8b4d-7e3b51e2c001`; its PSM read
characteristic is `e7100002-61b3-4b3d-8b4d-7e3b51e2c001`.

| Type | Payload |
|---|---|
| `01 HELLO` / `81 reply` | Version `02` followed by UTF-8 identity. |
| `02 CONFIG` / `82 reply` | Pattern `u8`, request/response sizes `u16`, count `u32`; reply status `00` means accepted. Patterns: 1 echo, 2 request, 3 stream-tx, 4 stream-rx, 5 duplex. |
| `03 PING` / `83 reply` | Sequence/token `u32`, then deterministic data. Reply adds reflector turnaround `u32` microseconds after token. |
| `06 START` / `86 READY` | Run token `u32`; receive state is armed before READY. |
| `04 DATA` | Sequence/token `u32` followed by deterministic data. |
| `05 END` | Token/count `u32`. |
| `85 RECEIPT` | Token/count `u32`, received bytes/first timestamp/last timestamp `u64`, errors `u32` (36 bytes). |
| `07 FINISH` / `87 finished` | Token `u32`; confirmation requires all enabled directions and receipts complete. |
| `7F ABORT` | Optional UTF-8 reason. |

CLI sizes include message metadata and exclude the four-byte frame header.
Echo/response/stream sizes are 12..4996 bytes; an asymmetric request may use 8. CONFIG count is 1..4096. Data byte `i` is
`(sequence + token + i) & 255`, starting after sequence/token (and turnaround in
a reply). Streaming has no per-DATA application ACK. A failed/incomplete write
or validation aborts the run. There is a benchmark-only 10-second receive
no-progress watchdog; this is not an ECU diagnostic timeout.

## Metrics and interpretation

`hello` identifies Git/IDF bases, SDK-config and ELF hashes, CPU, buffer counts
and options. Per-run parameters include actual interval, PHY, MTU and observed
DLE. Records use blocking USB writes after timing; all timed per-frame DUT
samples are retained in memory and emitted afterwards. Reflector writes never
run in a native receive callback. Fast streaming validation runs in that
callback without console output or writes.

Echo/request RTT ends at complete reply parsing on the DUT clock. The first
callback timestamp means delivery of a complete SDU, not first RF byte. Peer
turnaround is measured on its own clock and its subtraction still leaves a
round trip. `tx_submit_us` measures blocking host submission, not remote use.

Stream wall goodput divides useful pattern bytes in all enabled directions by
START-through-FINISH duration, including control round trips. Arrival goodput
uses each receiver's own first/last timestamps, excludes the first frame, and
never compares clocks across peers. Neither metric is physical BLE throughput.

Reconnect cycles close CoC/GAP, allow 100 ms for RTOS cleanup and report total/
internal free heap, largest internal block and tasks. The runner excludes five
initial cycles from heap trends. Parser capacity may retain a bounded high-water
allocation; compare settled cycles within one build/role. Short runs do not
prove day-long leak freedom. Failed files remain failed evidence.

## Known resource limit

With very large peer credits, unpaced large SDUs can exhaust native HCI/MSYS
buffers. CoC credit availability does not bound local controller backlog.
The library aborts the incomplete write instead of replaying a partial SDU.
This benchmark deliberately reports that failure; it does not silently pace or
retry the run. Experimental resource-window, alignment and native diagnostic
changes are outside this submission. Use echo/request as a bounded-load
baseline and preserve failed streaming captures when investigating the limit.

The default pool profile also failed an ESP-S3→ESP-S31 single-request echo at
4996 payload bytes plus framing (one 5000-byte SDU, negotiated MTU 5000). An
error-only instrumented check returned code 6 (ENOMEM) at the first write. That
identifies a resource failure, not the exact limiting pool; it is retained as a
failed case and is not claimed fixed here. Use 4096-byte payloads for the bounded
baseline. The parser's maximum frame size is not a guarantee that the default
host/controller buffers can sustain every transfer geometry.
6 changes: 6 additions & 0 deletions examples/L2CAP/L2CAP_Bench/main/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
idf_component_register(SRCS "main.cpp" INCLUDE_DIRS "." REQUIRES esp-nimble-cpp console esp_driver_usb_serial_jtag esp_timer espressif__cjson app_update)
execute_process(COMMAND git -C "${CMAKE_CURRENT_LIST_DIR}/../../../.." rev-parse HEAD OUTPUT_VARIABLE BENCH_LIBRARY_COMMIT OUTPUT_STRIP_TRAILING_WHITESPACE)
execute_process(COMMAND git -C "$ENV{IDF_PATH}" rev-parse HEAD OUTPUT_VARIABLE BENCH_IDF_COMMIT OUTPUT_STRIP_TRAILING_WHITESPACE)
file(SHA256 "${SDKCONFIG}" BENCH_CONFIG_HASH)
configure_file(bench_build.hpp.in "${CMAKE_CURRENT_BINARY_DIR}/bench_build.hpp" @ONLY)
target_include_directories(${COMPONENT_LIB} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}")
4 changes: 4 additions & 0 deletions examples/L2CAP/L2CAP_Bench/main/bench_build.hpp.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#pragma once
#define BENCH_LIBRARY_COMMIT "@BENCH_LIBRARY_COMMIT@"
#define BENCH_IDF_COMMIT "@BENCH_IDF_COMMIT@"
#define BENCH_CONFIG_HASH "@BENCH_CONFIG_HASH@"
126 changes: 126 additions & 0 deletions examples/L2CAP/L2CAP_Bench/main/bench_protocol.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>

namespace bench {
constexpr uint8_t magic = 0xB7;
constexpr size_t maxPayload = 4996;
constexpr uint8_t hello = 1, config = 2, ping = 3, abort = 0x7F;
constexpr uint8_t helloReply = 0x81, configReply = 0x82, pingReply = 0x83;
constexpr uint8_t version = 2;
constexpr uint8_t stream = 4, streamEnd = 5, streamStart = 6, streamFinish = 7;
constexpr uint8_t streamEndReply = 0x85, streamStartReply = 0x86, streamFinishReply = 0x87;
struct Frame { uint8_t type; std::vector<uint8_t> payload; int64_t receivedUs = 0; uint32_t generation = 0; };
inline uint32_t u32(const uint8_t* p) {
return (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) | (uint32_t(p[2]) << 8) | p[3];
}
inline void put32(std::vector<uint8_t>& p, uint32_t v) {
p.push_back(v >> 24); p.push_back(v >> 16); p.push_back(v >> 8); p.push_back(v);
}
inline uint64_t u64(const uint8_t* p) { return (uint64_t(u32(p)) << 32) | u32(p + 4); }
inline void put64(std::vector<uint8_t>& p, uint64_t v) { put32(p, v >> 32); put32(p, uint32_t(v)); }
inline uint8_t pattern(uint32_t sequence, uint32_t token, size_t offset) {
return static_cast<uint8_t>(sequence + token + offset);
}
inline std::vector<uint8_t> encode(uint8_t type, const std::vector<uint8_t>& payload) {
std::vector<uint8_t> result{magic, type, uint8_t(payload.size() >> 8), uint8_t(payload.size())};
result.insert(result.end(), payload.begin(), payload.end());
return result;
}
class Parser {
std::vector<uint8_t> bytes;
bool failed = false;
public:
void reset() { bytes.clear(); failed = false; }
template<typename Emit> bool feed(const std::vector<uint8_t>& incoming, Emit emit) {
if (failed) return false;
// Process incrementally: a read may contain multiple complete frames.
for (auto byte : incoming) {
bytes.push_back(byte);
if (bytes.size() == 1 && byte != magic) { failed = true; return false; }
if (bytes.size() < 4) continue;
const size_t length = (size_t(bytes[2]) << 8) | bytes[3];
if (length > maxPayload) { failed = true; return false; }
if (bytes.size() == length + 4) {
emit(Frame{bytes[1], {bytes.begin() + 4, bytes.end()}});
bytes.clear();
}
}
return true;
}
};
struct Config {
uint8_t pattern = 0;
uint16_t request = 0, response = 0;
uint32_t count = 0;
bool parse(const std::vector<uint8_t>& p) {
if (p.size() != 9) return false;
Config candidate{p[0], uint16_t((uint16_t(p[1]) << 8) | p[2]),
uint16_t((uint16_t(p[3]) << 8) | p[4]), u32(p.data() + 5)};
if (candidate.pattern < 1 || candidate.pattern > 5 || candidate.request < 8 || candidate.request > maxPayload ||
candidate.response < 12 || candidate.response > maxPayload || candidate.count == 0 || candidate.count > 4096 ||
(candidate.pattern == 1 && candidate.request != candidate.response)) return false;
*this = candidate;
return true;
}
std::vector<uint8_t> encode() const {
std::vector<uint8_t> p{pattern, uint8_t(request >> 8), uint8_t(request), uint8_t(response >> 8), uint8_t(response)};
put32(p, count); return p;
}
};

// These are application stream frames, independent of SDU boundaries.
inline std::vector<uint8_t> streamPayload(uint32_t seq, uint32_t token, uint16_t size) {
std::vector<uint8_t> p; p.reserve(size); put32(p, seq); put32(p, token);
for (size_t i = 8; i < size; ++i) p.push_back(pattern(seq, token, i - 8));
return p;
}
struct StreamSummary {
uint32_t token = 0, frames = 0;
uint64_t bytes = 0, firstUs = 0, lastUs = 0;
uint32_t errors = 0;
std::vector<uint8_t> encode() const {
std::vector<uint8_t> p; put32(p, token); put32(p, frames); put64(p, bytes);
put64(p, firstUs); put64(p, lastUs); put32(p, errors); return p;
}
bool parse(const std::vector<uint8_t>& p) {
if (p.size() != 36) return false;
*this = {u32(p.data()), u32(p.data() + 4), u64(p.data() + 8),
u64(p.data() + 16), u64(p.data() + 24), u32(p.data() + 32)};
return firstUs <= lastUs;
}
bool matches(uint32_t expectedToken, uint32_t count, uint16_t size) const {
return token == expectedToken && frames == count && bytes == uint64_t(count) * size &&
errors == 0 && firstUs <= lastUs;
}
};
struct StreamReceiver {
StreamSummary summary;
uint32_t count = 0;
uint16_t size = 0;
bool ended = false;
std::vector<uint64_t> arrivals;
void reset(uint32_t token, uint32_t expectedCount, uint16_t expectedSize) {
summary = {}; summary.token = token; count = expectedCount; size = expectedSize;
ended = false; arrivals.clear(); arrivals.reserve(expectedCount);
}
bool receive(const std::vector<uint8_t>& p, uint64_t now) {
bool valid = !ended && summary.frames < count && p.size() == size && p.size() >= 8 &&
u32(p.data()) == summary.frames && u32(p.data() + 4) == summary.token;
for (size_t i = 8; valid && i < p.size(); ++i)
valid = p[i] == pattern(summary.frames, summary.token, i - 8);
if (!valid) { ++summary.errors; return false; }
if (!summary.frames) summary.firstUs = now;
summary.lastUs = now; ++summary.frames; summary.bytes += p.size(); arrivals.push_back(now);
return true;
}
bool end(const std::vector<uint8_t>& p) {
const bool valid = !ended && p.size() == 8 && u32(p.data()) == summary.token &&
u32(p.data() + 4) == count && summary.matches(summary.token, count, size);
ended = true;
if (!valid) ++summary.errors;
return valid;
}
};
} // namespace bench
Loading
Loading