Architecture
August 25, 2026 · View on GitHub
How the SoftSIM is put together on the nRF91: who calls whom in which context, and how SIM data is persisted and protected.
The SIM itself — specification behaviour, no platform assumptions — is onomondo-uicc, vendored as a submodule. This repository is the nRF91 port: the modem transport plus implementations of the four port interfaces (storage, crypto, memory, logging) on Zephyr and TF-M. Replace that bottom layer and the SIM core runs elsewhere.
Request lifecycle
The modem drives everything. When it needs its SIM it issues requests through the Modem
library's SoftSIM interface, and the glue in
lib/nrf_softsim.c answers them:
-
The Modem library invokes the handler registered with
nrf_modem_softsim_req_handler_set(). This callback runs in an interrupt service routine, so it must not block. -
The handler allocates a request node, puts it on a FIFO, and submits work to a dedicated work queue (own thread, 10 kB stack,
SOFTSIM_STACK_SIZE). -
The worker drains the FIFO and dispatches on the request type. Every case answers with
nrf_modem_softsim_res():Request What the worker does NRF_MODEM_SOFTSIM_INITCreate the SIM context ( ss_new_ctx()) if needed; unless suspended, reset it and prime the filesystem (ss_init_fs()). Answers with the ATR fromss_atr().NRF_MODEM_SOFTSIM_APDUFeed the command APDU to ss_application_apdu_transact(). Answers with the response APDU + status words.NRF_MODEM_SOFTSIM_RESETss_reset()— warm reset of the SIM state. The modem issues this when a request became unresponsive, so answer promptly.NRF_MODEM_SOFTSIM_DEINITUnless suspended: free the context and ss_deinit_fs(), committing cached writes to flash. -
Request payloads handed over by the Modem library are released with
nrf_modem_softsim_data_free().
APDU buffers are sized SIM_HAL_MAX_LE (260) bytes: the modem may request the full
256-byte short-APDU payload, plus room for the status words.
The modem uses the software SIM only when selected with AT%CSUS=2. Selection is
accepted only while the modem is deactivated, and is committed to modem NVM on
AT+CFUN=0; AT%CSUS=0 reverts to the physical SIM. The rest of the contract is in the
SoftSIM interface documentation.
Threading
APDU and context processing runs on the work queue only, so the SIM core needs no
locking of its own. The filesystem is not confined to that thread:
nrf_softsim_init() calls ss_init_fs() and ss_new_ctx() in its caller's context,
and nrf_softsim_provision() runs on the application thread and mutates the same cache
(port_provision() in lib/ss_fs.c). Provision before the modem is
activated, not concurrently with it.
Failure behavior
Recovery lives one layer up: since no SIM-core request or response is checked inside the
worker, it's the modem's own response-timeout and RESET protocol — not per-call error
codes — that actually recovers from a failure.
nrf_modem_softsim_err() has one call site: the ISR, when the request node cannot be
allocated. Return values from ss_init_fs()/ss_deinit_fs() are discarded, ss_reset()
is void, and the lengths from ss_atr() and ss_application_apdu_transact() go
straight into the response — so a SIM-core failure is still answered, just with a
possibly empty or bogus payload. Only nrf_modem_softsim_res() itself is checked; when
it fails the error is logged and the request goes unanswered, again falling back to
the modem's timeout and RESET.
Suspend
The modem can suspend the SIM (UICC SUSPEND, enabled by
CONFIG_SOFTSIM_UICC_USE_EXPERIMENTAL_SUSPEND_COMMAND). While suspended, DEINIT keeps
the SIM context and filesystem alive so the follow-up INIT resumes instead of
cold-booting.
Boot flow
With CONFIG_SOFTSIM_AUTO_INIT=y (the default) everything is wired up before main():
SYS_INIT(nrf_softsim_init, APPLICATION, 0)primes the filesystem, provisions the static profile if one is configured (and the device is not already provisioned), registers the modem request handler, and starts the work queue.- An
NRF_MODEM_LIB_ON_INIThook issuesAT%CSUS=2as soon as the Modem library initializes, selecting the software SIM. - When the application activates the modem (
AT+CFUN=1/lte_lc_connect()), the modem sendsINITand the APDU exchange begins.
With CONFIG_SOFTSIM_AUTO_INIT=n both the SYS_INIT and the AT%CSUS=2 hook are
compiled out — they live in the same #ifdef. The application then owns both halves:
- Call
nrf_softsim_init()before any other SoftSIM API;nrf_softsim_provision()andnrf_softsim_check_provisioned()need the filesystem it initializes. - Send
AT%CSUS=2itself afternrf_modem_lib_init().
That is what runtime SIM selection needs: bring SoftSIM up and select it only when a
profile is actually provisioned, so an unprovisioned device falls back to a physical SIM
without a separate firmware build. The sample builds this way with
-DEXTRA_CONF_FILE=overlay-manual-init.conf.
What the modem actually asks for
Most of what a SIM does is filesystem access with access control. Activating the SIM
(AT+CFUN=41) starts a long run of SELECT followed by READ BINARY or READ RECORD
— 00a408040000022fe20168 selects EF.ICCID, 00b000000a reads it, and so on for tens of
files. The exception is AUTHENTICATE, which runs MILENAGE, derives session keys and
validates that the network is not an imposter.
The filesystem
The SIM core addresses files by hierarchical path (/3f00/7ff0/6f07 — MF, then
ADF.USIM, then EF.IMSI). Reaching flash from there runs through four pieces:
SIM core ── storage.h ── storage_compact.c ── fs.h ── ss_fs.c / ss_cache.c ── NVS
(port) (submodule) (shim) (this repo)
storage_compact.c is the submodule's embedded backend
(CONFIG_SOFTSIM_UICC_COMPACT_STORAGE=y); it does no hardware I/O itself, but calls a
stdio-like shim declared in fs.h (ss_fopen, ss_fread, ss_fwrite, ss_fclose, …).
That shim is what this repository implements, on top of Zephyr's
NVS in a dedicated
32 kB nvs_storage flash partition. Either boundary is a valid cut point for a port.
NVS is a uint16 id → blob store, so a translation layer is needed:
-
Directory entry. NVS record 1 holds the map from paths to NVS ids as consecutive
[path_len (1 byte), id (2 bytes big-endian), path]records, ordered roughly by access frequency so lookups terminate early. At boot it is parsed into a linked list (generate_dir_table_from_blob()inss_cache.c).00a408040000022fe20168 → open("/3f00/2fe2") → nvs_read(id=14) -
Id flags. The upper byte of each id is the flags field (
_flags = (id & 0xFF00) >> 8).FS_COMMIT_ON_CLOSE(1 << 7) is the one in use. -
Cache. File content is cached in RAM once read. Writes normally stay in the cache and are flushed on
DEINIT/ss_deinit_fs(); files flaggedFS_COMMIT_ON_CLOSE(the MILENAGE sequence-number files) are committed to flash on every close, trading wear for integrity.
Every fresh SIM shares the same file tree — only identity and keys differ — so the module
ships that tree as a prebuilt template
(lib/profile/template.bin), flashed to nvs_storage
alongside the firmware. Provisioning personalizes a handful of records. See
Provisioning and
the filesystem template.
Security model
- Authentication keys. Provisioning imports K/Ki, KIC and KID through the PSA Crypto
API as persistent keys with ids 10/11/12 (
lib/ss_crypto.h), inside TF-M — henceCONFIG_BUILD_WITH_TFMis a hard dependency. They are imported withoutPSA_KEY_USAGE_EXPORT, so the application can use them by id but never read them back; MILENAGE and OTA integrity/confidentiality run by key reference (lib/ss_crypto.c). The key file in the filesystem (A001) carries only a one-byte key tag in place of each key — but the MILENAGE operator constant OPc stays inA001on flash, so the filesystem's confidentiality still matters. - Replay protection. The MILENAGE sequence-number files are flagged
FS_COMMIT_ON_CLOSE, so a power loss cannot roll them back; OTA counters follow the normal cache lifecycle and are committed onDEINIT. - Storage partition isolation. TF-M configures the
nvs_storagerange as non-secure via the SPU, driven by the devicetreestorage_partitionlabel (see Flash partitioning), so the application can reach its own SIM filesystem while the keys stay in the secure domain.
Ports
onomondo-uicc reaches the platform through four interfaces, declared in the submodule's
include/onomondo/softsim/:
| Port | Header | nRF91 implementation |
|---|---|---|
| Storage | storage.h (+ the fs.h shim) | ss_fs.c + ss_cache.c: Zephyr NVS + RAM cache |
| Crypto | crypto.h | ss_crypto.c: PSA Crypto, keys by reference (AES/AES-CMAC only — the 3DES entry points are stubs) |
| Memory | mem.h | ss_heap.c: k_malloc() / k_free() |
| Logging | log.h | ss_logp_zephyr.c: Zephyr log module |
CONFIG_SOFTSIM_UICC_EXTERNAL_CRYPTO_IMPL=y (the nRF91 default) is what swaps the
submodule's software AES/3DES for the PSA port; CONFIG_SOFTSIM_UICC_EXTERNAL_KEY_LOAD
is the alternative for platforms keeping software crypto with keys from a secure element.
The rest of the tree
Beyond the port implementations above and
lib/nrf_softsim.c (modem glue, work queue, init, provisioning):
| Path | Contents |
|---|---|
lib/include/nrf_softsim.h | The application API, documented at the source |
lib/build_asserts.c | Compile-time layout checks (32 kB nvs_storage, heap floor, settings-partition clash, A001/A004 layout) |
dts/softsim/ | Devicetree partition layouts |
sysbuild/ | Template-hex generation and merging |
samples/softsim_external_profile/ | The reference application |
tests/ | Twister suites run on native_sim — west twister -T tests/ |