14 KiB
wolfCrypt on TI C2000 C28x (LAUNCHXL-F28P55X)
Bare-metal wolfCrypt test/benchmark for the TI LAUNCHXL-F28P55X board (TMS320F28P550SJ, C2000 C28x DSP, 150 MHz, 133 KB RAM, 1088 KB flash). The C28x is a word-addressed DSP where CHAR_BIT == 16; this example exercises the wolfSSL CHAR_BIT != 8 support and is validated on hardware with known-answer tests (KATs).
What this build includes
The default build runs a KAT suite plus wolfcrypt_test and (optionally) benchmark. Optional algorithms are enabled with make toggles (see Build options). Everything below is validated on hardware:
- SHA-224/256, SHA-384/512, SHA-512/224, SHA-512/256
- SHA3-224/256/384/512, SHAKE128, SHAKE256 (split-64 Keccak permutation, ~53% faster than the generic C path on C28x)
- ML-DSA-87 (Dilithium level 5) verify, and the full keygen+sign+verify round-trip (
SIGN=1) - ML-KEM-768 (FIPS 203) keygen/encap/decap round-trip (
MLKEM=1) - AES-128/192/256 CBC/CTR/CFB/GCM (
AES=1); AES-CMAC, AES-CCM, AES-GMAC (AESEXTRA=1); hardware-accelerated AES-ECB/CBC/CTR on the on-chip AESA block (HWAES=1) - HMAC-SHA256 + HKDF (
HKDF=1) - ChaCha20-Poly1305 AEAD + Poly1305 (
CHACHA=1) - X25519 + Ed25519 (
X25519=1) - ECDSA + ECDH over SECP256R1 via SP math (
ECC=1) - RSA-2048 PKCS#1 v1.5 verify via SP math (
RSA=1)
The 16-bit-byte (CHAR_BIT == 16) story
On the C28x a char/unsigned char (wolfSSL's byte) holds 16 bits, the minimum addressable unit is 16 bits, and (byte)x masks to 16 bits, not 8. wolfCrypt octet handling that assumed an 8-bit byte was made CHAR_BIT-agnostic behind the WOLFSSL_WIDE_BYTE gate (auto-enabled for CHAR_BIT != 8 and known 16-bit-char TI toolchains), with zero change on 8-bit targets. The fixes fall into a few recurring classes:
- Byte/word aliasing: serializing a
word32/word64by casting it tobyte*reads/writes cells, not octets. Replaced with explicit shift-based octet I/O (shared helpers inwolfcrypt/src/misc.c:WordsFromBytesBE32,BytesFromWordsBE32,BytesFromWordsLE32, the 64-bit variants, and octet-correctreadUnalignedWord32/64). (byte)xnot truncating to an octet: masked withWC_OCTET(x)((byte)((x) & 0xFF)), a no-op on 8-bit.- Integer-promotion bugs:
1U << nis 16-bit on C28x (use1UL);sizeof(t) * 8for a bit width is wrong whenCHAR_BIT != 8(useCHAR_BIT * sizeof(t)). sizeofcounting cells, not octets: e.g.CHACHA_CHUNK_BYTESmust be16 * 4, not16 * sizeof(word32)(which is 32 on C28x and halves the ChaCha block).
These changes live in the wolfSSL tree (see IDE/C2000/README.md there for the full list); this example is the on-hardware harness that validates them.
Toolchain
- C2000Ware (driverlib + device support). Assumed at
~/ti/C2000Ware_26_01_00_00; override withC2000WARE=. - TI C2000 codegen (
cl2000, the classic CGT). Ships with Code Composer Studio or standalone. PointCGT_ROOTat the dir containingbin/cl2000. - For flashing: DSLite (bundled with CCS) or UniFlash, driving the onboard XDS110. A ready
ccxml/F28P550SJ.ccxmlis included.
Build
Headless, from this directory:
make CGT_ROOT=/path/to/ti-cgt-c2000_xx.y.z
Output: out/wolfcrypt_f28p55x.out (load image) and out/wolfcrypt_f28p55x.map. Point WOLFROOT at a wolfSSL checkout with the CHAR_BIT != 8 support if it is not the default ../../../wolfssl-alt.
Build options
Each is make <NAME>=1 (default 0 unless noted), additive on top of the default SHA + ML-DSA-verify build:
| Option | Adds |
|---|---|
ECC=1 |
ECDSA/ECDH over SECP256R1 (SP math) |
MLKEM=1 |
ML-KEM-768 (FIPS 203) |
AES=1 |
AES-CBC/CTR/CFB/GCM (table-driven, GCM_SMALL) |
AESEXTRA=1 |
AES-CMAC, AES-CCM, AES-GMAC (implies the AES core) |
HWAES=1 |
Offload AES-ECB/CBC/CTR to the on-chip AESA accelerator via crypto callbacks (implies the AES core). See "Hardware AES" below |
X25519=1 |
Curve25519 (X25519) + Ed25519 |
HKDF=1 |
HMAC + HKDF (RFC 2104 / RFC 5869) |
CHACHA=1 |
ChaCha20-Poly1305 AEAD (RFC 8439) |
ENTROPY=1 |
Real oscillator-jitter entropy source (DCC/INTOSC vs PLL) in place of the dev-only test seed. See "RNG and entropy" below |
ENTROPY_PROBE=1 |
Raw entropy characterization image: dumps unconditioned samples over SCI for host analysis, runs no crypto |
RSA=1 |
RSA-2048 verify (SP math, 2048-only, verify/public-only) |
SIGN=1 |
Full ML-DSA-87 keygen+sign+verify demo (dedicated linker script, 32 KW heap, no test/bench harness) |
BENCH=1 |
Run only benchmark instead of wolfcrypt_test (they need separate images on this RAM-limited part) |
Other knobs: CGT_ROOT (required), C2000WARE, WOLFROOT, and CIO=1 (route printf to the CCS/JTAG console via CIO instead of the default SCI/UART).
CCS GUI alternative: create an empty F28P550SJ project, add the WC_SRCS subset from the Makefile, Source/wolf_main.c, the device-support device.c + f28p55x_codestartbranch.asm, link driverlib.lib, set the predefines (_LAUNCHXL_F28P55X, _FLASH, WOLFSSL_USER_SETTINGS) and include paths, and use 28p55x_wolf_flash_lnk.cmd.
Console output
printf goes to the SCI/UART by default: stdout is redirected to SCIA on GPIO28/29, the XDS110 virtual COM, at 115200 8N1, so output appears on a serial terminal (or uart-monitor) with no debugger attached. Build with CIO=1 to route output to the CCS console via CIO over JTAG instead (useful under an active debug session, and required by the DSS capture scripts). Every stdout byte is also mirrored to the g_log[] RAM buffer, readable over JTAG even when the UART is not.
Flash and run
Boot-mode switch S3 default (GPIO24 + GPIO32 both high) boots from flash. With CCS DSLite:
DSLite flash -c ccxml/F28P550SJ.ccxml -e -f -v out/wolfcrypt_f28p55x.out
For clean UART output the JTAG probe must be RELEASED while the target runs: an active debug session garbles the XDS110 backchannel, and DSLite leaves the CPU halted after flashing. So flash, then reset+run and DETACH the debugger (leave it running) -- the board free-runs from flash and loops main(). Then read the serial port (e.g. uart-monitor tail /dev/ttyACM<N> at 115200 8N1). The ti-c2000-c28x skill automates this: run_uart.sh [CHACHA=1 ...] builds (SCI default), flashes, resets+runs+detaches (run_detach.js), and prints the clean UART. For JTAG-console capture instead, build CIO=1 and use run_capture.js (holds JTAG, reads CIO).
Benchmark results
Measured at 150 MHz (SHA-256 Hash-DRBG via WOLFSSL_GENSEED_FORTEST; build with BENCH=1). Representative throughput:
| Primitive | Throughput |
|---|---|
| SHA-256 | ~284 KiB/s |
| SHA-384 / SHA-512 (+512-224/256) | ~166 KiB/s |
| SHA3-224 / 256 / 384 / 512 | ~279 / 264 / 206 / 146 KiB/s |
| SHAKE128 / SHAKE256 | ~319 / 264 KiB/s |
| RNG (Hash-DRBG) | ~122 KiB/s |
ML-DSA-87 (asymmetric, @150 MHz): verify ~225 ms/op; keygen and signing also run (SIGN=1). ML-DSA-87 verify memory: ~10.7 KB total (key/workspace struct + ~2 KB stack, zero heap) with WOLFSSL_MLDSA_VERIFY_SMALLEST_MEM + WOLFSSL_MLDSA_ASSIGN_KEY.
Memory notes
- Clock: 150 MHz from the 20 MHz crystal Y2 (
device.hDEVICE_SETCLOCK_CFG), flashRWAIT=3. - The C28x stack pointer is 16-bit, so the stack must live below address
0x10000.28p55x_wolf_flash_lnk.cmdputs a 16 KW stack in RAMLS0-7, RAM-resident hot code (BlockSha3+ the ML-DSA NTTs) in RAMGS0, an 8 KW heap in RAMGS1, and.bss/.datain the high RAMGS2-3 / RAMLS8-9 blocks. Flash output sections span all five banks. - Big SP/
*_NO_MALLOCstructs (ecc_key, RsaKey, ChaChaPoly_Aead) belong in.bss/static, not on the stack: the SP point/modexp call tree plus a stack-allocated key can overflow the 16 KW stack. wc_RsaSSL_Verifyin theRSA_VERIFY_ONLY/SP_NO_MALLOCconfig runs the modexp in place in the caller's buffer, so the output buffer must be at least the key size (256 B for RSA-2048).
Hardware AES (HWAES=1)
The F28P550SJ has an on-chip AES accelerator ("AESA", a TI EIP-120t at 0x00042000) that C2000Ware exposes through driverlib/f28p55x/driverlib/aes.h. HWAES=1 offloads AES-ECB/CBC/CTR to it via the wolfCrypt crypto-callback framework (wolfcrypt/src/port/ti/ti-c2000-aes.c in the wolfSSL tree, gated on WOLFSSL_C2000_AES). driverlib.lib is already linked by this example, so no extra build plumbing is needed.
Software AES stays compiled in. A context opts into hardware with wc_AesInit(&aes, NULL, WOLFSSL_C2000_DEVID); one initialised with INVALID_DEVID runs pure software. wolf_aes_hw_test() uses both and compares them, which is the point: on a 16-bit-byte target the octet marshalling into the accelerator's 32-bit registers is the highest-risk part of the port, and a mismatch is exactly what you want to see. The harness prints 13 lines covering ECB/CBC/CTR at 128/192/256 bits, multi-block, split calls, in-place decrypt and a non-block-aligned CTR split, each checked against software and (for the first block of each mode) against the published NIST SP800-38A vector.
HWAES=1 also defines WC_USE_DEVID=0x2000 so wolfcrypt_test and benchmark exercise the device too -- without it they init every context with INVALID_DEVID and silently measure software.
Measured at 150 MHz (make HWAES=1 BENCH=1, which prints paired SW/HW rows):
| Operation | Software | AESA | Speedup |
|---|---|---|---|
| AES-128-ECB encrypt | 471 KiB/s | 2.39 MiB/s | 5.2x |
| AES-256-ECB encrypt | 377 KiB/s | 2.34 MiB/s | 6.3x |
| AES-128-CBC encrypt | 405 KiB/s | 2.37 MiB/s | 6.0x |
| AES-128-CBC decrypt | 388 KiB/s | 2.36 MiB/s | 6.2x |
| AES-256-CBC encrypt | 333 KiB/s | 2.32 MiB/s | 7.1x |
| AES-256-CBC decrypt | 322 KiB/s | 2.31 MiB/s | 7.3x |
| AES-128-CTR | 408 KiB/s | 1.45 MiB/s | 3.6x |
| AES-256-CTR | 335 KiB/s | 1.45 MiB/s | 4.4x |
AES-GCM barely moves (~32 to ~34 KiB/s): only its internal ECB calls reach the accelerator and the GCM_SMALL byte-wise GHASH dominates. Using the block's own GCM mode is future work. CFB, CCM, CMAC and everything else stay in software -- the callback returns CRYPTOCB_UNAVAILABLE and wolfCrypt falls through transparently.
Two hardware quirks are documented in IDE/C2000/README.md in the wolfSSL tree and worth knowing before touching this code: driverlib expects little-endian octets within each 32-bit word (not a raw cast of a byte*), and the block's CTR counter increment does not match wolfCrypt's big-endian 128-bit IncrementAesCounter() once an increment carries across an octet boundary -- so the port drives the accelerator in ECB mode and keeps the counter in software. Both quirks produce a correct first block, which is why the multi-block cases in the harness matter.
RNG and entropy
The F28P550SJ has no hardware TRNG. It does have three independent oscillators -- INTOSC1 and INTOSC2 (on-chip ~10 MHz RC) and the external crystal behind SYSCLK/PLLRAWCLK -- and two Dual-Clock Comparators that can count one against another. ENTROPY=1 uses that: a DCC counts PLLRAWCLK edges inside a window of INTOSC cycles, and the LSB of the count is one noise bit carrying the relative phase drift of two physically distinct oscillators. The raw stream is oversampled well past its measured min-entropy, health-tested per SP800-90B 4.4, SHA-256 conditioned, and fed to the SP800-90A Hash-DRBG.
Measured on this board with ENTROPY_PROBE=1 (262144 raw bits per source, LSB extraction, host analysis):
| Source | Hmin/bit | bias | max |acf| | chi-square p |
|---|---|---|---|---|
| INTOSC1 window / PLL counted (DCC1) | 0.932 | -0.0000 | 0.005 | 0.623 |
| INTOSC2 window / PLL counted (DCC0) | 0.843 | -0.0027 | 0.005 | 0.000 |
| ADC LSB, floating input | 0.834 | -0.0086 | 0.073 | 0.000 |
Only INTOSC1 is credited toward the entropy budget; INTOSC2 is hashed in as defence-in-depth but fails a chi-square uniformity check decisively, and the ADC source is off by default because it also fails chi-square and depends on a spare analog pin being left floating. The port assumes 0.5 bits per raw bit and oversamples 2x on top, about a 4x cushion. This is a most-common-value estimate with bias and correlation screening, not a full SP800-90B non-IID assessment. Read 0.932 against the estimator's ceiling rather than 1.0: at this sample count a synthetic uniform stream estimates to 0.930, so the credited source is statistically indistinguishable from uniform. These are single-run measurements of a physical source and move slightly between runs (an earlier capture gave 0.924 / 0.775 / 0.865), but the pass/fail conclusions have been identical in every run.
Only the DCC measurement itself is C2000 code. The SP800-90B startup and continuous health tests, the entropy budget, the SHA-256 conditioner and the latched fail-closed state come from wolfSSL's generic wc_NoiseSrc_* layer in wolfcrypt/src/random.c, which WOLFSSL_C2000_ENTROPY configures. See IDE/C2000/README.md in the wolfSSL tree.
ENTROPY_PROBE=1 builds the measurement image itself: it dumps unconditioned samples over the SCI console for host analysis, and runs no crypto. tools/entropy_analyze.py (numpy only) consumes that capture and reproduces the table above:
make CGT_ROOT=<cgt> ENTROPY_PROBE=1
# flash, run, capture the console to probe.log, then:
python3 tools/entropy_analyze.py probe.log
Min-entropy is the SP800-90B 6.3.1 most-common-value estimate over the 8-bit octet alphabet at the 99% upper confidence bound, divided by 8 to express it per bit. The octet alphabet is used rather than the bit alphabet because it also catches structure across adjacent bits that a per-bit estimate cannot see. Run python3 tools/entropy_analyze.py --selftest to check the estimators against synthetic streams with known properties; that also calibrates the ceiling, since at this sample count a genuinely uniform stream estimates to about 0.93 rather than 1.0 -- so the credited source's 0.92 is at the estimator's practical maximum, not 8% short of ideal.
Without ENTROPY=1 the build falls back to WOLFSSL_GENSEED_FORTEST (random.c's incrementing test seed feeding the real Hash-DRBG) -- it exercises the DRBG path but is development-only and not cryptographically secure.