SDK
ST25R3916B NFC reader
The 13.56 MHz NFC chip: reading tags, emulating a card, measuring a foreign field, and the NDEF, Type 2, Type 4 and Mifare Classic layers above it.
What it is
The ST25R3916B from STMicroelectronics is the NFC front end of a Kode Dot. It does two opposite jobs. As a reader (a poller) it raises a 13.56 MHz radio field and talks to whatever tag or card sits on the antenna. As a card it keeps the field down and answers a reader outside, presenting a UID or a short message that a phone can open.
A third request listens: with the transmitter off, it measures the field another device puts on the antenna, such as a phone or a payment terminal.
The driver is a stack of layers, each doing one thing:
kdrv_st25r3916is the chip driver and the platform port of ST's RFAL library. It raises the field, steps discovery, exchanges bytes and runs the emulation.knfc_tagnames the tag that answered (Type 2, Type 4, Mifare Classic) and reads or writes its NDEF message.knfc_ndefencodes and parses the records a phone understands: a URI or a Text record.knfc_mfc,knfc_mfc_dictandknfc_crypto1authenticate to a Mifare Classic card with a key the system holds.knfc_emu_imgbuilds what an emulated card presents.knfc_fieldandknfc_powerare the arithmetic of the listen window and the rest-and-wake decision.
A reader that is only a poller reads and writes NDEF on NTAG tags (Type 2), on Type-4 cards, and on Mifare Classic cards with a known key. It does not recover a key it does not have.
How it is connected
flowchart LR subgraph p4["ESP32-P4"] ao["AO_Nfc"] --> drv["kdrv_st25r3916"] drv --> rfal["RFAL (ST)"] end subgraph pcb["Antenna PCB"] chip["ST25R3916B"] --> ant["antenna"] end rfal -- "SPI2, 1 MHz, mode 1" --> chip chip -- "IRQ, GPIO29" --> drv class ao ao class chip chip
The chip is on the antenna PCB, on the SPI2 bus: CS is GPIO28, CLK GPIO30, MOSI GPIO31, MISO GPIO32, and the interrupt line GPIO29 (KBSP_PIN_NFC_*). The driver runs the bus at 1 MHz in mode 1 and drives CS by hand, because RFAL chains several transfers under one CS.
The owner
AO_Nfc (sdk/kaos/nfc, priority 19) owns the chip, its interrupt and the SPI2 bus whole (KBSP_RES_NFC). It is the only caller. The field exists only while a request is in flight, and AO_Nfc takes it down when the request ends.
The chip has no enable pin, so it is powered whenever the board is. "Off" means the field is down and the oscillator rests. Absence is a state: a Kode Dot without the antenna PCB, or with the flex unseated, makes kdrv_st25r3916_init() fail. kdrv_st25r3916_present() is then false and every later call returns KODE_DENIED at once, with no retry.
The servicer task
RFAL's st25r3916WaitForInterruptsTimed() spins on a status word that only the interrupt path writes, and it never yields. A caller inside that spin cannot also end it, so the driver owns one FreeRTOS task, the servicer, at priority configMAX_PRIORITIES - 2 (4096-byte stack).
That is above every Active Object, because AO_Nfc is the caller, and below the levels ESP-IDF keeps for itself. The GPIO interrupt only wakes the servicer, and the four register reads that collect the interrupt happen on the task.
Discovery, one step at a time
Nothing blocks on the radio. kdrv_st25r3916_discover_start() wakes the chip, raises the field and arms RFAL's NFC-A discovery. kdrv_st25r3916_worker() advances it one step and returns, so AO_Nfc pumps it from a timer. kdrv_st25r3916_activated() says whether a tag came up, and kdrv_st25r3916_active_tag() returns the anticollision result: UID, ATQA and SAK. knfc_tag decides which kind of tag the SAK means.
Rest between uses
Between requests the chip rests in RFAL's power-down mode: oscillator, transmitter, receiver and field detector all off. It wakes inside discover_start() and listen_begin() and rests again at field_off() and at the end of a listen window. The wake waits for the oscillator's stable interrupt, 700 µs typically and 10 ms at most. Registers keep their values through the rest.
knfc_power_action() decides which transition needs a wake or a rest, with no radio in it, so the host tests run it directly.
Listening with the transmitter off
A listen window measures a foreign field. kdrv_st25r3916_listen_begin() wakes the chip, turns the field off, checks the transmitter really is off, and sets up the receiver once: manual peer detection on the external field detector, both receive channels, the peak detector instead of the mixer, and regulator-shaped AM off.
Each kdrv_st25r3916_listen_step() reads the detector and takes 16 amplitude measurements. Each is one direct command, 13.02 mVpp per count. kdrv_st25r3916_listen_end() puts every changed bit back, rests the chip and returns the statistics.
A conversion that does not finish within 10 ms means the front end stopped answering. The burst adds nothing, the window ends and the step returns KODE_ERROR.
A step costs 5.1 ms measured on a Kode Dot, about half of AO_Nfc's 10 ms tick, and the driver waits in the non-yielding spin for all of it.
The arithmetic is knfc_field: minimum, mean, peak and mod (peak minus minimum), with a sticky flag for "a field appeared in some burst". The accumulator keeps the sum, so the mean is exact however many bursts there are.
A window never runs under a field, from either side. listen_begin() refuses while a field is up, discover_start() refuses while a window is open, and field_off() closes a window first.
Emulating a card
Emulation is the poller turned round. kdrv_st25r3916_emulate_start() loads an identity into the chip's passive-target memory with rfalListenStart(). The chip then answers REQA, anticollision and SELECT in hardware, and the field of the Kode Dot never rises.
The one thing hardware does not answer is the Type-2 READ, which a reader wants within about 87 µs. So a task of its own (priority 5, 4096-byte stack) pumps the listen worker every millisecond and serves READ, FAST_READ and HALT from an image.
knfc_emu_img builds that image from a knfc_emu_card_t. With a coherent UID of 4, 7 or 10 bytes whose ATQA size bits match, the card is presented verbatim, which is what an access-control reader expects. Otherwise the builder makes a 7-byte NFC Forum Type-2 tag (SENS_RES 0x0044, SEL_RES 0x00) carrying your NDEF message, and a phone reads it as a link or as text.
The Type-2 image is 40 pages (160 bytes), with the NDEF area at offset 16. A ready-made message in raw (a vCard, a Wi-Fi credential) is wrapped in the Type-2 TLV as it is and wins over uri and ndef.
kdrv_st25r3916_emulate_stop() clears a run flag and returns at once, so an Active Object may call it, and the task closes the window in its own context.
kdrv_st25r3916_emulate_snapshot() copies the running state under a spinlock: whether the window is open, whether a reader's field is present, whether it holds the card selected, how many sessions it opened and how many READs were served. Many sessions and no reads mean a link that dies before the first data exchange, which is what metal behind the antenna does.
The layers above
knfc_tag reads the SAK in this order:
- Bit 5 means ISO-DEP (Type 4).
- Bit 6 means NFC-DEP, which is not supported.
- Bit 3 with bit 5 clear means Mifare Classic (SAK
0x08is 1K,0x18is 4K). - SAK
0x00means Type 2.
Three tag types hide behind one NDEF read and write:
- Type 2: the capability container in page 3 gives the size and writability; the TLV walk starts at page 4 and steps over the lock and memory-control TLVs. A write is refused before the first page if the message does not fit, because a half-written tag is worse than an untouched one.
- Type 4: it selects the NDEF application
D2760000850101, reads the capability container for the file and its limits, and writes the 2-byte length last, so a reader arriving mid-write sees the old length and never half a message. The layer builds its own APDUs, which is why RFAL's T4T composer is off. - Mifare Classic: it reads the Mifare Application Directory and writes where an NDEF card keeps its message. It does not format a card with no directory, because that rewrites the sector keys with no undo.
knfc_ndef encodes URI and Text records and wraps a message in a TLV (tag 0x03, terminator 0xFE). The URI prefix table turns https:// into one byte. It builds no Smart Poster, because iOS ignores one on a background read. Lengths count bytes, not characters.
knfc_mfc authenticates in three passes over the raw bit-counting exchange. The reader sends AUTH(block) and the card answers a nonce nt. The reader seeds the cipher with the key and uid ^ nt and replies {nr}{ar} encrypted, parity included, and the card replies at. Each side has proved the key.
Reads and writes encrypt the same way. A write refuses a sector trailer and block 0 outright. knfc_mfc_dict holds 2038 public keys from the Proxmark3 project's dictionary, the common ones first. knfc_mfc_auth_any() tries the leading twenty; knfc_mfc_sweep() walks the whole list one batch at a time.
knfc_crypto1 is the Crypto1 cipher: a 48-bit shift register kept as two 24-bit halves, with a non-linear filter. It is written from the published description of the algorithm and from no existing implementation. It holds no key recovery and no attack of any kind. A reader clocks the cipher with zero and adds the keystream by XOR. Feeding the ciphertext back is the card's operation.
The files
The files of the component:
| File | What it does |
|---|---|
include/kdrv_st25r3916.h, src/kdrv_st25r3916.c | The chip driver and RFAL's platform port: init, discovery, field, listen window, emulation, raw exchanges, the field table, the certification carrier |
include/knfc_tag.h, src/knfc_tag.c | Tag identification, the model name, NDEF read and write for the three tag types |
include/knfc_ndef.h, src/knfc_ndef.c | URI and Text records, TLV wrap, parse |
include/knfc_mfc.h, src/knfc_mfc.c | Mifare Classic authentication, block read and write, sector arithmetic |
include/knfc_mfc_dict.h, src/knfc_mfc_dict.c | The 2038-key public dictionary |
include/knfc_crypto1.h, src/knfc_crypto1.c | The Crypto1 cipher |
include/knfc_emulate.h | The card and statistics types of emulation (a header only) |
include/knfc_emu_img.h, src/knfc_emu_img.c | The identity and the 40-page Type-2 image an emulated card presents |
include/knfc_field.h, src/knfc_field.c | Listen-window statistics |
include/knfc_power.h, src/knfc_power.c | Wake, rest or nothing |
components/rfal | STMicroelectronics' RFAL 4.0.2 and the ST25R3916 chip driver, vendored |
RFAL
RFAL is vendored: copied without change from X-CUBE-NFC6 and kept under ST's licence SLA0051, with LICENSE.txt beside it. It is outside the project's MISRA and Doxygen rules. Two files in it are not ST's: port/rfal_platform.h points RFAL's platformXxx() macros at the SDK and chooses the features, and port/rfal_port.h declares the 13 functions the macros call, which kdrv_st25r3916.c defines.
Features on: NFC-A, T2T, ISO-DEP as poller, low-power mode and the listen half. Features off: NFC-B, NFC-F, NFC-V, T1T, ST25TB, NFC-DEP and wake-up mode. That keeps the stack near 29 KB of code against 75 KB with everything on. ST25R_COM_SINGLETXRX stays undefined, because the P4's SPI with DMA cannot use one buffer for both directions and rfalNfcInitialize() would report a hardware mismatch.
Specifications
The chip and the board
| Thing | Value |
|---|---|
| Chip | ST25R3916B, IC_IDENTITY register 0x3F reads 0x31 (type 6, revision 1); init refuses anything else |
| Bus | SPI2, 1 MHz, mode 1, CS by hand. CS GPIO28, IRQ GPIO29, CLK GPIO30, MOSI GPIO31, MISO GPIO32 |
| Field | 13.56 MHz, NFC-A poller. Up only during a request |
| SPI buffers | 300 bytes, rounded to 64-byte multiples, taken once from PSRAM at init; the port owns both |
| Servicer | Priority configMAX_PRIORITIES - 2, 4096-byte stack |
| Listen detector | Manual peer detection: trips at 105 mVpp, releases at 75 mVpp |
| Listen burst | 16 measurements (KNFC_LISTEN_BURST), 10 ms conversion limit, 13.02 mVpp per count |
| Emulation | Task priority 5, 4096-byte stack, 1 ms period. UIDs of 4, 7 or 10 bytes. Messages up to 96 characters (KNFC_EMU_NDEF_MAX) or 140 raw bytes (KNFC_EMU_RAW_MAX) |
| Exchanges | Type-2 READ returns 4 blocks (16 bytes). APDU up to 512 bytes. A bit-counting transceive for Mifare. Reselect waits 50 ms |
The driver
| Call | What it does |
|---|---|
kdrv_st25r3916_init(), _present(), _identity(&id) | Start (SPI, interrupt, RFAL), absence test, the identity register as read at init. Init raises nothing |
_discover_start(), _worker(), _activated(), _active_tag(), _is_iso_dep(), _field_off() | Discovery, one step at a time. field_off() drops the tag, closes any window, rests the chip |
_listen_begin(), _listen_step(), _listen_end(&stats) | The listen window. KODE_DENIED with no chip, under a field, during emulation or when a window is open; KODE_ERROR if the chip would not wake or a register refused |
_emulate_start(&card), _emulate_stop(), _emulate_active(), _emulate_snapshot(&st) | Card emulation. Start gives KODE_DENIED for a held reader or a bad UID length, KODE_NO_RESOURCES when the message does not fit |
_t2t_read(), _t2t_write(), _apdu(), _transceive_bits(), _reselect() | Raw exchanges. reselect() is a full field cycle: the one recovery that works from every Mifare state |
_get(f, &v), _field_info(f) | The field table below |
_cert_carrier(modulated) | A continuous maximum-drive 13.56 MHz carrier for FCC bench use. Compiled only with CONFIG_KODEOS_NFC_CERT |
The field table
Twenty read-only rows. The interrupt-status registers are left out on purpose: reading one clears it, and that would consume an interrupt RFAL is waiting for. A register read works at rest.
| Rows | What they are |
|---|---|
identity, ic_type, ic_rev | Register 0x3F, whole and by its bits |
op_control, en, rx_en, rx_chn, rx_man, tx_en, wu | Register 0x02: oscillator and regulator, receiver, channel, manual channel, the field, wake-up |
mode, om, tr_am | Register 0x03: the operation mode, AM rather than OOK |
bit_rate, txrate, rxrate | Register 0x04; 0 is 106 kb/s |
regulator | Register 0x2B, the regulator display in mV |
field, tags, errors | The driver's own counters: is the field up, tags activated, exchanges that failed |
Gotchas
Example code
Hold a tag flat against the antenna and this example identifies it. It asks for a tag, waits up to three seconds with the field up, and logs the model, the UID and the UID length. With a blank NTAG on the antenna the log reads like NTAG/Ultralight, UID 04A1B2C3D4E5F6 (7 bytes).
/*
* nfc_identify.c - 2026-10-28 - from Kode with love
*
* Looks for an NFC tag for three seconds at a time and logs its model and
* UID. Hold a tag flat against the antenna of the Kode Dot. The log shows
* a line such as "Mifare Classic 1K, UID 3A9F21C4 (4 bytes)".
*/
#include <stdio.h>
#include "kode.h"
#define TAG "nfc_identify"
/* Bring the system up, then read tags forever. */
void app_main(void)
{
knfc_tag_t tag;
char uid[(2 * KNFC_UID_MAX) + 1];
kode_init();
while (1) {
if (kode_nfc_read(&tag, 3000) == KODE_OK) {
for (uint8_t i = 0; i < tag.uid_len; i++) {
snprintf(&uid[2 * i], 3, "%02X", (unsigned)tag.uid[i]);
}
KLOG_I(TAG, "%s, UID %s (%u bytes)",
knfc_tag_model(tag.atqa, tag.sak), uid,
(unsigned)tag.uid_len);
}
/* A refusal returns at once, so this keeps the loop gentle. */
kode_delay_ms(500);
}
}Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. TBD: a run on a Kode Dot.
In the SDK
components/kdrv/nfc: the chip driver and every protocol layer.kdrv_st25r3916.h: discovery, listen, emulation, exchanges and the field table.components/rfal: ST's RFAL, vendored, with its README and licence.components/kaos/nfc: AO_Nfc, the owner (sdk/kaos/nfc).