In this tutorial, you will build a compact SoftSIM architecture prototype on the nRF9151 using Zephyr RTOS Shell as a transport layer.
This is not a production SIM implementation. It is a focused engineering demo that shows how a SoftSIM-like APDU state machine can be integrated into an embedded system cleanly and honestly.
The goal is simple:
- keep the SIM logic isolated from hardware-specific code
- accept APDU commands as raw bytes
- maintain a small virtual SIM state
- return proper response bytes and status words
By the end, you will be able to send commands like:
sim send 00A40000023F00
sim send 00A40000026F07
sim send 00B0000000and observe your SoftSIM prototype respond like a tiny virtual SIM engine.
What is a SoftSIM, and how does it relate to eSIM, iSIM, nuSIM, Onomondo, and Monogoto?A traditional SIM is a small secure computer that stores identity-related data and answers standardized commands.
In smart-card systems, those commands are usually exchanged as APDUs. That is why APDU parsing is such a useful learning tool in this project: even a simple demo starts to resemble the command/response behavior of a real SIM-like system.
When people say SoftSIM, they usually mean a SIM implementation that runs as software rather than as a separate physical SIM chip.
For example, Onomondo describes its SoftSIM as a software-based SIM for IoT, and positions it alongside removable SIMs and eSIMs in its connectivity portfolio. Monogoto also presents SoftSIM, eSIM, and iSIM as different form factors that can all be used for IoT connectivity.
For a beginner, the easiest mental model is:
- Physical SIM = removable card with embedded chip
- eSIM / eUICC = embedded chip soldered into the device, remotely provisioned
- iSIM = SIM functionality integrated into the SoC or secure area of the chipset
- SoftSIM = software-based SIM approach with no separate SIM chip hardware
- nuSIM = a very lightweight integrated SIM approach designed mainly for low-power IoT use cases, especially NB-IoT and LTE-M
eSIM is the best-known mainstream concept. In the IoT world, it usually means an embedded SIM that can receive operator profiles remotely. eSIM manufacturers in most cases explicitly tie IoT eSIM to GSMA’s SGP.31/32/33 standards.
iSIM pushes integration even further. Kigen explains that iSIM places SIM functionality into a protected part of the SoC like Arm TrustZone, which can reduce size and power consumption compared with separate SIM hardware.
nuSIM is a special case worth mentioning bnecause many beginners confuse it with generic iSIM. Deutsche Telekom presents nuSIM as an integrated SIM solution for IoT that removes the separate SIM hardware and omits many features unnecessary for simple low-power devices. It is aimed at devices like meters and trackers, where cost, battery life, and production simplicity matter more than full classic-SIM flexibility.
Where do Onomondo and Monogoto fit in?
Onomondo and Monogoto are not just “SIM card companies” in the old sense. They are IoT connectivity platforms that support different SIM form factors and help devices connect globally.
Onomondo offers global IoT connectivity and supports removable SIM, eSIM, and SoftSIM. It also highlights SoftSIM integrations for modules such as Quectel and has Github repo about Nordic nRF91-series related SoftSIM work.
Monogoto presents its platform as supporting multiple form factors, including eSIM, iSIM, and SoftSIM, through one connectivity layer. It also publishes material around modern IoT remote provisioning such as SGP.32.
So, from a beginner’s perspective:
- Onomondo = IoT connectivity provider/platform with strong public messaging around SoftSIM and eSIM
- Monogoto = IoT connectivity provider/platform supporting multiple SIM form factors, including iSIM and SoftSIM
- Kigen = one of the best-known companies focused on eSIM/iSIM enablement technology
- Deutsche Telekom nuSIM = a practical low-power IoT-oriented integrated SIM path, not the same thing as a general-purpose SoftSIM
Why this matters for this project
This demo is not a commercial SoftSIM, eSIM, iSIM, or nuSIM product. It is a learning project inspired by that world.
Its goal is to teach the reader how a SoftSIM-like core can be structured:
- APDU-driven state machines
- separation between pure core logic and platform-specific code
- clean state management without global variables
- Zephyr Shell integration for visibility and testing
- embedded architecture that is small, understandable, and honest
I wanted to build my tutorial the way when it teaches the foundations that a beginner actually needs first: how commands are parsed, how state changes are tracked, and how a pure C core can be connected to a Zephyr-based application.
Wait, does a SIM have a computer in it?A SIM is not just plastic plus metal contacts.
The metal pads you see are only the electrical contacts. Under them, inside the card, there is a tiny integrated circuit. In a classic SIM/UICC, that chip usually includes:
- a small CPU / microcontroller
- memory for keys (Ki), files, and identifiers
- a tiny operating system may be Java Card
- often cryptographic hardware
That is why a SIM can do real work, for example:
- store your IMSI and secret authentication key (Ki)
- answer APDU commands
- participate in network authentication
- enforce security rules
- run small applications in some cases
So conceptually, a SIM is closer to a very small secure computer than to a passive ID card.
A simple way to picture it:
metal pads = the connectorchip underneath = the actual secure device
That is also why people compare SIMs to smart cards. They are not just memory cards. They actively process commands.
Why this project mattersBefore writing my demo code for Zephyr, I conducted some deep research and came across open-source repository, onomondo-uicc. I read the Osmocom mailing list and saw that Harald Welte’s team from Sysmocom worked on it.
I looked into their implementation of the hierarchical file system, the BER-TLV parser, the MILENAGE algorithm integration, and the OTA RFM support. It is an impressive project.
However, since I only had a couple of days to prepare the demo, I didn't attempt to port extensive repository into my Zephyr project. Instead, I wrote my own lightweight APDU dispatcher from scratch, which emulates a flat file system and performs mock authentication.
My goal was to demonstrate the core of ISO 7816-4 and how a software agent should exchange bytes with a terminal or modem.
By the way, it was interesting to read in Harald’s announcement that Onomondo's code overlaps with Tomasz Lisowski’s swsim project, which I have also studied!
I wanted to demonstrate, that I can replace piece of plastic not even with eSIM, but completely remove any specific hardware.
The tutorial implements:
- APDU-driven state machines
- separation between core logic and platform code
- Zephyr Shell integration
- embedded software architecture
Also, I wanted to show:
- how to structure a SoftSIM-like core
- how to parse and react to basic APDU commands
- how to manage state without global variables
- how to plug a pure C core into Zephyr cleanly
- a proof-of-concept SoftSIM architecture prototype
- a virtual APDU-driven state machine
- a minimal embedded demo for nRF9151
- a clean example of hardware-independent logic wrapped by Zephyr
It supports:
SELECT(0xA4)READ BINARY(0xB0)- mock
AUTHENTICATE(0x88)
- a real replacement for the physical UICC
- a production-ready SoftSIM
- a real LTE/3GPP authentication implementation
- an implementation of AKA or MILENAGE
- a modem-integrated SIM replacement for the nRF9151 stack
The purpose here is to demonstrate architecture and reasoning, not to fake a commercial telco product.
Architecture overviewThe main design rule is this:
Zephyr is only the shell and transport layer.
The SoftSIM core stays hardware-agnostic.
That means:
- Zephyr Shell receives a command from the terminal
- the command is converted from HEX text into raw bytes
- those raw bytes are passed into the SoftSIM core
- the core processes the APDU and returns raw response bytes
- Zephyr prints the response back to the user
Architecturally, this demo combines a Ports-and-Adapters structure with a small request-processing pipeline.
1. The Zephyr Shell acts as an external adapter that accepts user input, converts it into raw APDU bytes, and passes it into the SoftSIM application core.
2. The core processes the command against explicit state and returns response bytes, which are then formatted and printed by the Shell adapter.
This keeps the domain logic isolated from transport details, while the internal data path remains a simple pipeline of transformations.
[Primary Adapter]
Zephyr Shell / CLI
↓
[Input Parsing Filter]
HEX string → raw APDU bytes
↓
[Application Core]
SoftSIM state machine / command handler
↓
[Output Formatting Filter]
response bytes → hexdump representation
↓
[Primary Adapter Output]
console / shellData flow diagram (simple)shell input ->hex bytes ->APDU parser ->state update -> response bytes-> shell output
Project structureFor this MVP, I keep it intentionally small.
Inside src/, you will have only three files:
src/
├── softsim.h
├── softsim_core.c
└── main.cYou will also need a prj.conf file for Zephyr configuration.
You can create a new nRF application by following my older tutorial here, specifically Step 3 and the early part of Step 5, to set up the nRF9151 board. Actually, you can use any board, because we do not have real modem interaction and are only mocking it.
If you don't have any Zephyr boardFrankly speaking, you can even turn it into a desktop console C program to make the MVP even easier to replicate, because we do not have real SIM modem interaction and are only mocking it. Just replace Zephyr UART interface to receive input from the desktop console terminal window.
Step 1 — Create the SoftSIM interfaceCreate src/softsim.h.
This file defines:
- status words
- the virtual SIM context
- public API functions
#ifndef SOFTSIM_H
#define SOFTSIM_H
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
// Status Words (SW)
#define SW_OK 0x9000
#define SW_WRONG_LENGTH 0x6700
#define SW_FILE_NOT_FOUND 0x6A82
#define SW_INS_NOT_SUPPORTED 0x6D00
// Context of our virtual SIM (no global variables)
typedef struct {
uint16_t current_file_id;
bool is_authenticated;
} softsim_context_t;
// Initialization
void softsim_init(softsim_context_t *ctx);
// Main processing function (raw bytes in, raw bytes out)
void softsim_process_apdu(softsim_context_t *ctx,
const uint8_t *rx_buf, size_t rx_len,
uint8_t *tx_buf, size_t *tx_len);
#endif // SOFTSIM_HWhy this header mattersThere are two important design ideas here.
1. No hidden global state
Instead of storing the selected file or authentication state in global variables, I keep everything inside:
softsim_context_tThat makes the code cleaner and easier to reason about.
2. A raw-byte interface
The main API accepts:
- raw input bytes
- input length
- output buffer
- output length
That is useful because it keeps the core independent of:
- UART
- Shell
- USB
- sockets
- operating system details
Any transport layer can call this function later.
Step 2 — Implement the SoftSIM coreCreate src/softsim_core.c.
This file contains all SoftSIM logic in one place, but still organized into small internal handlers.
#include "softsim.h"
#include <string.h>
// SIM data
#define FILE_MF 0x3F00
#define FILE_IMSI 0x6F07
static const uint8_t dummy_imsi[] = {0x08, 0x29, 0x43, 0x05, 0x12, 0x62, 0x71, 0x08, 0x50};
void softsim_init(softsim_context_t *ctx) {
ctx->current_file_id = FILE_MF;
ctx->is_authenticated = false;
}
// Internal command handlers
static uint16_t handle_select(softsim_context_t *ctx, const uint8_t *data, uint8_t lc) {
if (lc != 2) return SW_WRONG_LENGTH;
uint16_t target_id = (data[0] << 8) | data[1];
// Honest file existence check
if (target_id == FILE_MF || target_id == FILE_IMSI) {
ctx->current_file_id = target_id;
return SW_OK;
}
return SW_FILE_NOT_FOUND;
}
static uint16_t handle_read_binary(softsim_context_t *ctx, uint8_t *out_data, size_t *out_len) {
if (ctx->current_file_id == FILE_IMSI) {
memcpy(out_data, dummy_imsi, sizeof(dummy_imsi));
*out_len = sizeof(dummy_imsi);
return SW_OK;
}
return SW_FILE_NOT_FOUND;
}
static uint16_t handle_mock_auth(softsim_context_t *ctx, const uint8_t *data, uint8_t lc, uint8_t *out_data, size_t *out_len) {
// Demo authentication: simply invert the input bytes
for (int i = 0; i < lc; i++) {
out_data[i] = data[i] ^ 0xFF;
}
*out_len = lc;
ctx->is_authenticated = true;
return SW_OK;
}
// --- Main dispatcher ---
void softsim_process_apdu(softsim_context_t *ctx,
const uint8_t *rx_buf, size_t rx_len,
uint8_t *tx_buf, size_t *tx_len) {
*tx_len = 0;
if (rx_len < 4) {
// Build wrong length response
tx_buf[0] = (SW_WRONG_LENGTH >> 8) & 0xFF;
tx_buf[1] = SW_WRONG_LENGTH & 0xFF;
*tx_len = 2;
return;
}
// Extract APDU fields
uint8_t ins = rx_buf[1];
uint8_t lc = (rx_len > 4) ? rx_buf[4] : 0;
const uint8_t *data = (rx_len > 5) ? &rx_buf[5] : NULL;
uint16_t sw = SW_INS_NOT_SUPPORTED;
size_t payload_len = 0;
// Routing
switch (ins) {
case 0xA4: // SELECT
sw = handle_select(ctx, data, lc);
break;
case 0xB0: // READ BINARY
sw = handle_read_binary(ctx, tx_buf, &payload_len);
break;
case 0x88: // AUTHENTICATE (Mock)
sw = handle_mock_auth(ctx, data, lc, tx_buf, &payload_len);
break;
}
// Build response: payload + 2-byte Status Word
*tx_len = payload_len + 2;
tx_buf[payload_len] = (sw >> 8) & 0xFF;
tx_buf[payload_len + 1] = sw & 0xFF;
}Understanding the SoftSIM coreLet’s walk through what this file actually does.
Virtual SIM files
I defined two file identifiers:
#define FILE_MF 0x3F00
#define FILE_IMSI 0x6F07FILE_MFis the Master File. According to the ISO 7816-4 standard (the general standard for smart cards, banking cards, and SIM cards), the root of the file system (analogous toC:\or/) always has the address 3F00. This is the industry-standard term for the root directory in a smart card hierarchy.FILE_IMSIis a simulated IMSI file. It is Elementary File - EF_IMSI. Per the 3GPP TS 31.102 telecom standard, the file containing the IMSI always has the address 6F07. This refers to the actual files containing data (as opposed to DF or Dedicated Files, which are folders).
Mocked IMSI payload
I provided a hardcoded test payload:
static const uint8_t dummy_imsi[] = {0x08, 0x29, 0x43, 0x65, 0x87, 0x09, 0x21, 0x43, 0x65};I guess you need to understand how real SIM data is represented according to 3GPP standard (page 24, 4.2.2).
LONG READ, VERY SCARY!!!
At first glance, it may seem like you could simply store the IMSI like this:
"234502126178005"But that is not how a SIM card stores it internally.
Inside the SIM file system, the IMSI is stored in the EF_IMSI file using a compact binary representation called Packed BCD (Binary Coded Decimal). On top of that, the digits are arranged in a slightly unusual way: they are packed into bytes using swapped nibbles.
So instead of storing the IMSI as readable ASCII text, the SIM stores it as a sequence of encoded bytes.
That is exactly the kind of detail that makes this demo interesting from a systems and protocol point of view.
For this demo I use my real Onomondo IMSI from the previous tutorial.
234502126178005That is a 15-digit IMSI.
Now let’s convert it step by step into the byte array that a real SIM-style structure would return.
Add the Length Byte
The first byte is not part of the IMSI digits themselves. It tells us the length of the IMSI payload in bytes.
So the first byte is: 08
Encode the First Digit and Odd/Even Information
The IMSI has 15 digits, which means it has an odd number of digits.
In the encoded format, the first IMSI digit is stored together with an odd/even indicator in the next byte.
- First digit =
2 - Odd-length indicator nibble =
9
That gives: 29
Pack the Remaining Digits in Swapped-Nibble BCD
Now we take the remaining digits in pairs and pack them into bytes.
The twist is that the digits are stored with the nibbles swapped, which means the second digit of the pair appears in the high nibble, and the first digit appears in the low nibble.
Let’s go through it:
- Digits
3and4->43 - Digits
5and0->05 - Digits
2and1->12 - Digits
2and6->62 - Digits
1and7->71 - Digits
8and0->08 - Digits
0and5->50
So the IMSI:
234502126178005becomes:
08 29 43 05 12 62 71 08 50There is no black magic here.
- One byte has 2 halves
- One IMSI digit fits into one half
- When we write a byte in hex, we show the left half first
Because of point 3, the digits look “reversed”.
Take the easiest example: digits 3 and 4.
A byte has two halves: [left half][right half]
But for IMSI storage, they are packed like this:
- first digit goes into the right half
- second digit goes into the left half
So for 3 4:
3goes to the right half4goes to the left half
That gives: [4][3] -> 43
So the original digits were 3 4, but the byte is shown as 43. That is the whole trick.
Why does IMSI use this format?
Because IMSI is not stored as normal text like:
234502126178005
It is stored in a packed format:
- 1 digit = 4 bits
- 2 digits = 1 byte
So one byte is just a small box that can hold two decimal digits.
Why does 34 become 43?
Because of how the two half-bytes are arranged:
- first digit of the pair goes into the low half
- second digit of the pair goes into the high half
But when we as humans read hex, computers write:
- high half first
- low half second
So:
- stored internally as: low=
3, high=4 - shown in hex as:
43
Why is the second byte 29?
Because the second byte is special.
It does not contain two normal IMSI digits.
It contains:
- the first IMSI digit =
2 - a special flag in the other half =
9
So it becomes: 29
That 9 is not part of your IMSI number.It is control information.
Why specifically 9?
Because your IMSI has 15 digits, which is an odd number.
The standard needs to mark that the number of digits is odd, so it uses a special parity flag in that byte.
That is why the second byte becomes 29.
To conclude, my dummy IMSI: 2 3 4 5 0 2 1 2 6 1 7 8 0 0 5
Stored as:
08= length29= special byte: first digit2+ flag9to notify that IMSI has 15 digits, odd number.43= digits3 405= digits5 012= digits2 162= digits2 671= digits1 708= digits8 050= digits0 5
The most important thing to remember
If you see byte 43, it does not mean the original digits were 4 3.
It means:
- right half =
3 - left half =
4
So the real digit pair was: 3 4
IMSI digits are packed in pairs, but when the byte is written in hex, the pair looks reversed.
ICCID vs IMSI DifferenceICCID (Integrated Circuit Card Identifier) — “the hardware serial number”
What it is:A unique serial number of the actual SIM chip itself — the physical piece of plastic or hardware module on which the SIM runs.
Format:Usually 19 or 20 digits, typically starting with 89..., which is the telecom industry identifier.
Analogy:It is like a car’s VIN number, or the serial number of your passport booklet as a physical document.
What it is used for:Billing, inventory tracking, and SIM activation in a shop. The mobile network itself does not use the ICCID for radio access.
Special detail:On a regular physical SIM card, it is often laser-printed directly onto the plastic.
IMSI (International Mobile Subscriber Identity) — “the subscriber’s network identity”
What it is:A unique identifier of your mobile subscription — your contract identity inside the operator’s network.
Format:15 digits. It includes the country code (MCC), operator code (MNC), and your personal subscriber number in the operator’s database (MSIN). This is the same byte array we were packing earlier.
Analogy:It is like your full name and registered identity written inside the passport.
What it is used for:This is what the network tower uses to determine whether you are allowed onto the network. Authentication is based on the IMSI, including mechanisms such as the MILENAGE algorithm.
C RepresentationIn code, that will look like this:
static const uint8_t ef_imsi_data[] = {
0x08, 0x29, 0x43, 0x05, 0x12, 0x62, 0x71, 0x08, 0x50
};.InitializationThe function:
void softsim_init(softsim_context_t *ctx)starts the virtual SIM in a known state:
- selected file = Master File
- not authenticated
This handler processes SELECT APDUs.
For the purpose of a one-day prototype, I intentionally implemented a Flattened File System. My handle_select function acts as a simple State Machine, allowing specific IDs to be selected directly without traversing the entire directory tree.
In a production version of the SoftSIM agent, I would implement a full Virtual File System (VFS) manager, where each EF (Elementary File) contains a pointer to its parent DF (Dedicated File). In that case, the SELECT command would verify not only the existence of file 6F07, but also the current context. Specifically, whether we are currently within the DF_GSMor ADF_USIM directory.
// Internal command handlers
static uint16_t handle_select(softsim_context_t *ctx, const uint8_t *data, uint8_t lc)
{
if (lc == 2)
{
uint16_t target_id = (data[0] << 8) | data[1];
// Honest file existence check
if (target_id == FILE_MF || target_id == FILE_IMSI)
{
ctx->current_file_id = target_id;
return SW_OK;
}
else
{
return SW_FILE_NOT_FOUND;
}
}
else
{
return SW_WRONG_LENGTH;
}
}It checks that the APDU data length is exactly two bytes, which is required here because we expect a 16-bit file ID.
In code I have line:
uint16_t target_id = (data[0] << 8) | data[1];It kind of "glues" two bytes into one 16-bit digit.
I need that because, in Application Protocol Data Unit (APDU) file is send as 2 bytes: 3F 00
but in demo code, it is more convenient to work with: 0x3F00 It is one digit, and not two bytes. To achive that target_id uses byte "magic" above.
Step-by-step explanation
Assume:
data[0] = 0x3F;
data[1] = 0x00;Step 1 — shift left
data[0] << 8Binary view:
0x3F = 0011 1111Shift left by 8 bits:
0011 1111 0000 0000 = 0x3F00This moves the first byte into the high (upper) part of a 16-bit value
Step 2 — combine with second byte
| data[1]If:
data[1] = 0x00;Then:
0x3F00 | 0x00 = 0x3F00Then it extracts the file ID and checks whether the file exists.
That means :
3F00-> valid6F07-> valid9999-> invalid, returns6A82
This handler returns the contents of the selected file.
In my MVP:
- if the currently selected file is
EF_IMSI, it copies the IMSI payload into the output buffer - otherwise, it returns
SW_FILE_NOT_FOUND
That creates a basic stateful interaction:
- select the file
- read the selected file
This function handles a very simple version of a READ BINARY operation in my SoftSIM demo.
In a real SIM card, after the terminal selects a file, it can request the contents of that file.My demo follows the same idea: it checks which file is currently selected, and if that file is the International Mobile Subscriber Identity (IMSI file, it returns the stored dummy IMSI bytes.
static uint16_t handle_read_binary(softsim_context_t *ctx, uint8_t *out_data, size_t *out_len)
{
if (ctx->current_file_id == FILE_IMSI)
{
memcpy(out_data, dummy_imsi, sizeof(dummy_imsi));
*out_len = sizeof(dummy_imsi);
return SW_OK;
} else if (ctx->current_file_id == FILE_MF)
{
// Handle FILE_MF case, that is accessing MF without selecting a specific file first
return SW_COMMAND_NOT_ALLOWED;
}
else
{
return SW_FILE_NOT_FOUND;
}
}What this function does
At a high level, this function answers one question:
“What data should be returned for the file that is currently selected?”
It uses the current_file_id stored in the SoftSIM context to decide what to do.
Function inputs and outputs
softsim_context_t *ctx
This is the SoftSIM state object.
It remembers things like:
- which file is currently selected
- whether authentication has happened
- any other state we may add later
In this function, we mainly use:
ctx->current_file_idThat tells us which file the terminal selected earlier.
uint8_t *out_data
This is the output buffer where the function will place the file contents.
If the selected file is the International Mobile Subscriber Identity file, the function copies the stored dummy International Mobile Subscriber Identity bytes into this buffer.
size_t *out_len
This tells the caller how many bytes were written into out_data.
That way, the caller knows how much response data is valid.
Return value: uint16_t
The function returns a SIM-style status word.
For example:
SW_OK-> command succeededSW_COMMAND_NOT_ALLOWED-> command is not valid in the current stateSW_FILE_NOT_FOUND-> selected file does not exist in this demo
This is useful because SIM-style communication usually returns:
- response data
- a status word
This is not real SIM authentication.
static uint16_t handle_mock_auth(softsim_context_t *ctx, const uint8_t *data, uint8_t lc, uint8_t *out_data, size_t*out_len)
{
if (lc == 2)
{
// Demo authentication: simply invert the input bytes
// you can put any code here to simulate a more complex authentication process
for (int i = 0; i < lc; i++)
{
out_data[i] = data[i] ^ 0xFF;
}
*out_len = lc;
ctx->is_authenticated = true;
return SW_OK;
}
else
{
return SW_WRONG_LEN;
}
}What it actually does:
for (int i = 0; i < lc; i++)
{
out_data[i] = data[i] ^ 0xFF;
}This means:
- it reads each input byte
- applies
XOR 0xFF - that flips every bit
Example:
- input byte:
0x3C-> binary00111100 0xFF-> binary11111111- result -> binary
11000011=0xC3
It is enough to show how an AUTHENTICATE-style handler might plug into the architecture. In the second part of this tutorial, I’ll show you a more real-world authentication process. For now, though, we’ll stick with this approach.
This is the core entry point.
This snippet of code is the very heart of your emulator. If main.c represents the "hands and ears" (the Zephyr terminal), then softsim_process_apdu is the "brain."
In software architecture, this pattern is known as a Dispatcher or a Router. Its primary objective is simple: receive a raw stream of incoming bytes, figure out what is being requested, hand the task over to the appropriate "specialist" (an internal function), and then neatly package the response to send it back.
It performs four steps:
1. Validate minimal length(foolproofing)
if (rx_len < 4) {
tx_buf[0] = (SW_WRONG_LEN >> 8) & 0xFF;
tx_buf[1] = SW_WRONG_LEN & 0xFF;
*tx_len = 2;
return;
}What it does: According to the ISO 7816 standard, any APDU command must consist of at least 4 bytes: CLA (Class), INS (Instruction), P1, and P2.
There are two ways to return a function's result:
- Via the return value (e.g.,
int result = my_func();). - Via Output Parameters (using pointers).
In our softsim_process_apdu function, the return type is void. This means the function technically returns nothing via the standard return statement.
So, how do we "return" the SW_WRONG_LEN error?
We use the output buffers passed to us by reference: tx_buf (a pointer to the response array) and *tx_len (a pointer to the variable storing the response length).
Here is what happens inside that if block:
tx_buf[0] = (SW_WRONG_LEN >> 8) & 0xFF;I take the SW_WRONG_LEN constant (defined in softsim.h as 0x6700). I shift it to extract the high byte (0x67) and write it into the first index (index 0) of the output array.
The >> 8 operator takes all 16 bits and physically shifts them 8 positions to the right. The bits that were on the far right (the zeros) "fall off the cliff" and disappear. New zeros fill in the empty spaces from the left.
Before:
[0110 0111] [0000 0000] (0x6700)AfterShifting >>8:
[0000 0000] [0110 0111] (0x0067)Applying the Mask (& 0xFF)
The & (Bitwise AND) operator acts like a stencil or a template. The number 0xFF in binary is 1111 1111 (eight ones). In a 16-bit context, this is 0000 0000 1111 1111.
The rule for Bitwise AND is: 1 & 1=1, everything else is 0.
I overlay our shifted result onto this mask:
0000 0000 0110 0111 (Our shifted 0x0067)
& 0000 0000 1111 1111 (The 0x00FF Mask)
---------------------
0000 0000 0110 0111 (Result: 0x0067)Why use a mask if the left side is already zeros?
This is a matter of Safe Coding and "best practices" in C. If the SW_WRONG_LEN variable were signed (signed int), a right shift might cause the compiler to fill the left side with ones instead of zeros to preserve the sign—this is known as Sign Extension. Applying the & 0xFF mask forcefully "chops off" everything to the left of the 8th bit, guaranteeing we get a clean byte without any "garbage" bits.
Now, the part we need (0x67) has moved to the right side, making it easy to extract.
tx_buf[1] = SW_WRONG_LEN & 0xFF;I took the low byte (0x00) and write it into the second index (index 1) of the output array. Now, tx_buf contains two bytes: [0x67, 0x00]. Here we are no longer shifting anything (>>). We simply take the original number 0x6700 ([0110 0111] [0000 0000]), apply a mask to the rightmost 8 bits, and get a clean zero 0x00, which we place into tx_buf[1].
0110 0111 0000 0000 (this is 0x6700)
& 0000 0000 1111 1111 (mask 0x00FF is equal to 0xFF)
---------------------
0000 0000 0000 0000 (result: 0x00)The Golden Rule for C Developers
What I just walked through is a classic pattern (an idiom) in the C language for splitting a 16-bit number into two 8-bit bytes. In many libraries (including Zephyr), there are pre-written macros for this so that you don't have to manually write out the bit shifts every single time. They typically look like this:
#define GET_HIGH_BYTE(val) (((val) >> 8) & 0xFF)
#define GET_LOW_BYTE(val) ((val) & 0xFF)
*tx_len = 2;I notified the caller (in our case, the terminal function in main.c) that I have placed exactly 2 bytes into the output buffer.
return;I terminated the dispatcher's execution and exit the function.
The subsequent switch (ins) logic is skipped because parsing a malformed or "short" command is pointless and dangerous. It could lead to a buffer overflow(out-of-bounds memory access).
I used the Output Parameters pattern pointers to tx_buf and tx_len. For the APDU protocol, the response is always a single binary packet (R-APDU) in which the Payload and Status Words are concatenated within a single array.
If I were to return the Status Word as a uint16_t and the Payload via tx_buf, the caller (e.g., the modem) would have to manually stitch them together every time before transmission. Here my approach encapsulates this logic within the dispatcher: the kernel itself assembles the complete binary packet (whether it is a simple 67 00 error or payload + 90 00), leaving the modem to simply transmit the prepared buffer over the air.
If fewer than 4 bytes are received, it’s considered "garbage" data. I reject it with error 0x6700 (Wrong Length).
If there are fewer than 4 bytes, the APDU is malformed and the core returns:
SW_WRONG_LENGTH2. Parsing basic APDU fields(Data Extraction)
The code reads:
- instruction byte
- Lc
- data pointer
uint8_t ins = rx_buf[1];
uint8_t lc = (rx_len > 4) ? rx_buf[4] : 0;
const uint8_t *data = (rx_len > 5) ? &rx_buf[5] : NULL;What it does: This "slices" the raw array into meaningful variables:
rx_buf[1]is the second byte, which always contains the Instruction (INS).rx_buf[4]is the fifth byte, Lc (Length of Data). It specifies how many bytes of payload follow.&rx_buf[5]is a pointer to the actual data payload.
3. Safe Default State (Fail-Safe)
uint16_t sw = SW_INS_NOT_SUPPORTED;
size_t payload_len = 0;What it does: Proactively assume the command is unknown.
If the switch statement below doesn't find a match, the function automatically returns 6D 00. This follows the Fail-Safe principle.
4. Dispatch based on INS(Routing)
switch (ins) {
case 0xA4: sw = handle_select(ctx, data, lc); break;
case 0xB0: sw = handle_read_binary(ctx, tx_buf, &payload_len); break;
// ...
}What it does: This is the "switchboard." Depending on the instruction byte (A4, B0, 88), it calls the relevant handler function and passes the necessary data. The handler performs the work and returns a status word (e.g., 0x9000), which is stored in the sw variable.
The instruction byte determines which internal handler is called.
5. Build the response(Building the R-APDU)
The response consists of:
- optional payload bytes
- a 2-byte status word at the end
*tx_len = payload_len + 2;
tx_buf[payload_len] = (sw >> 8) & 0xFF;
tx_buf[payload_len + 1] = sw & 0xFF;What it does: Per the standard, every SIM card response must end with two status bytes (SW1 and SW2).
- If
payload_len = 0(e.g., aSELECTcommand just saying "OK"), the status is written totx_buf[0]andtx_buf[1]. - If
payload_len = 9(e.g., we just read an IMSI), the status is appended to the very end at indices[9]and[10].
Bit-shifting Magic:sw is a 16-bit number (e.g., 0x9000).
The nRF9151 microcontroller uses a Little-Endian architecture. To correctly split this into two separate bytes for the transmission buffer (where SW1 should be 90 and SW2 should be 00), we use a >> 8 shift for the high byte and a & 0xFF mask for the low byte.
I deliberately designed softsim_process_apdu so that its signature is completely hardware-agnostic.
It accepts only the state context and raw pointers to memory buffers (In/Out byte arrays) as inputs. It knows nothing about Zephyr, the modem, or the UART. Thanks to this design, the code is as portable as possible. Tomorrow, IoT connectivity provider might say: 'We are moving our software sim into TrustZone (TF-M) and will communicate with it viac or Shared Memory.'
I won’t have to change a single line of this core logic. I will simply take this file, compile it within a Secure Partition, and it will process byte arrays exactly as it currently does from the terminal.
Step 3 — Integrate the core with Zephyr ShellCreate src/main.c.
This file connects the platform side to the core side.
#include <zephyr/kernel.h>
#include <zephyr/shell/shell.h>
#include <zephyr/logging/log.h>
#include <zephyr/sys/util.h>
#include <stdlib.h>
#include <string.h>
#include "softsim.h"
LOG_MODULE_REGISTER(main, LOG_LEVEL_INF);
// Create an instance of our context
static softsim_context_t sim_ctx;
static int cmd_sim_send(const struct shell *sh, size_t argc, char **argv) {
uint8_t rx[128];
uint8_t tx[128];
size_t tx_len;
// use Zephyr's built-in function for parsing HEX
size_t hex_len = strlen(argv[1]);
size_t rx_len = hex2bin(argv[1], hex_len, rx, sizeof(rx));
if (rx_len == 0 && hex_len > 0) {
shell_error(sh, "Invalid HEX string.");
return -EINVAL;
}
shell_print(sh, "--> [APDU RX]");
shell_hexdump(sh, rx, rx_len);
// Call our logic while passing the context
softsim_process_apdu(&sim_ctx, rx, rx_len, tx, &tx_len);
shell_print(sh, "<-- [APDU TX]");
shell_hexdump(sh, tx, tx_len);
return 0;
}
SHELL_STATIC_SUBCMD_SET_CREATE(sub_sim,
SHELL_CMD_ARG(send, NULL, "Send APDU. Usage: sim send <hex_string>", cmd_sim_send, 2, 0),
SHELL_SUBCMD_SET_END
);
SHELL_CMD_REGISTER(sim, &sub_sim, "SoftSIM Emulator CLI", NULL);
int main(void) {
softsim_init(&sim_ctx);
LOG_INF("Onomondo SoftSIM Demo started! Waiting for commands...");
return 0;
}This file does only three platform-specific jobs:
- receives text input from the terminal
- converts HEX text to bytes
- prints the response bytes back
It does not contain SoftSIM logic.
That is the architectural point of the tutorial.
The command handler:
sim send <hex_string>acts as a simple user-facing transport.
The hex2bin() helperThe shell receives APDU input as a human-readable HEX string like:
00A40000023F00hex2bin() converts it into a raw byte array so it can be passed to the SoftSIM core.
That lets you interact with the engine easily from a terminal without needing a custom host tool.
Step 4 — Enable the required Zephyr featuresIn prj.conf, add:
CONFIG_SHELL=y
CONFIG_LOG=y
CONFIG_SHELL_HEXDUMP=yThese options enable:
- Zephyr Shell
- logging
- convenient hexdump output in the shell
Build the project for your nRF9151 board using your standard Zephyr or nRF Connect SDK workflow.
Once the firmware is flashed, open a serial terminal connected to the board.
You should see a log message similar to:
Onomondo SoftSIM Demo started! Waiting for commands...Step 6 — Try the APDU commandsNow for the fun part. Open your terminal and test the state machine.
1. Select the Master File
sim send 00A40000023F00Expected response: 90 00
Explanation: 00 = CLA, A4 = SELECT instruction. 3F00 is the Master File ID. The response 90 00 means success.
2. Select a non-existent file (Negative Test)
sim send 00A40000029999Expected response: 6A 82
Explanation: 9999 is not supported in our virtual file table. The core correctly returns 6A 82, which means "File not found".
3. Select the IMSI file
sim send 00A40000026F07Expected response: 90 00
Explanation: 6F07 is the standard 3GPP file ID for EF_IMSI. The core updates its internal state context to point to this file.
4. Read the IMSI file content
sim send 00B0000000Expected response: 08 29 43 05 12 62 71 08 50 90 00
Explanation: B0 = READ BINARY. Since EF_IMSI is currently selected, the core returns the IMSI payload followed by 90 00. The payload (08 29 43...) is the real IMSI 234502126178005 encoded in the 3GPP TBCD format (Telephony Binary Coded Decimal) as per TS 31.102!
5. Run mock authentication
You can also send a mock authentication APDU. For example:
sim send 008800000411223344
sim send 008800000411223344Expected output payload:
each input byte will be XORed with FF
so:
11->EE22->DD33->CC44->BB
The response payload should therefore be followed by:
EE DD CC BB 90 00This is not real cryptography. It only demonstrates how an authentication-style command can be plugged into the engine.
How the APDU flow works in this demoLet’s map the logic to the shell commands.
When you type:
sim send 00A40000026F07the following happens:
- Zephyr Shell receives the string
hex2bin()converts it into bytessoftsim_process_apdu()receives the raw APDU- it identifies
INS = 0xA4 - it calls
handle_select() - the context updates
current_file_id - the status word
9000is appended - the response is printed back to the terminal
That is a complete end-to-end APDU-driven embedded interaction.
Explicit state managementThe SoftSIM state is stored in a context structure rather than global variables.
Raw-byte processingThe core behaves like a reusable protocol engine.
Easy extensibilityYou can extend it with:
- more APDUs
- a richer file system
- stronger response validation
- unit tests
- a different transport interface
Once the MVP is running, you can evolve it in several useful directions.
Add more files
For example:
- ICCID
- a fake subscriber profile
- access-controlled files
Add more APDUs
Such as:
GET RESPONSESTATUSUPDATE BINARY
Tighten APDU parsing
Right now the parser is intentionally simple. You can make it stricter and more spec-aware.
Add access control
Use the is_authenticated flag to control which files can be read.
Add tests
Even a few tests for:
- valid
SELECT - invalid file ID
- unsupported INS
- malformed APDU
would make the project stronger.
Split the core into modules
For a small MVP, one core file is fine. For a more mature version, you can split it into:
- parser
- dispatcher
- file system
- auth module
“Invalid HEX string.”
Make sure:
- the string length is even
- only hexadecimal characters are used
- the buffer size is not exceeded
6A 82 when reading
This usually means the selected file is not valid for READ BINARY.
Try selecting the IMSI file first:
sim send 00A40000026F07then:
sim send 00B0000000No shell output
Check that Shell is enabled in prj.conf:
CONFIG_SHELL=y
CONFIG_LOG=y
CONFIG_SHELL_HEXDUMP=yand verify the serial terminal is connected to the correct interface.


_UoqlmTWtmc.png?auto=compress%2Cformat&w=48&h=48&fit=fill&bg=ffffff)

Comments