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:

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 NFC reader

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:

  1. Bit 5 means ISO-DEP (Type 4).
  2. Bit 6 means NFC-DEP, which is not supported.
  3. Bit 3 with bit 5 clear means Mifare Classic (SAK 0x08 is 1K, 0x18 is 4K).
  4. SAK 0x00 means Type 2.

Three tag types hide behind one NDEF read and write:

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:

FileWhat it does
include/kdrv_st25r3916.h, src/kdrv_st25r3916.cThe 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.cTag identification, the model name, NDEF read and write for the three tag types
include/knfc_ndef.h, src/knfc_ndef.cURI and Text records, TLV wrap, parse
include/knfc_mfc.h, src/knfc_mfc.cMifare Classic authentication, block read and write, sector arithmetic
include/knfc_mfc_dict.h, src/knfc_mfc_dict.cThe 2038-key public dictionary
include/knfc_crypto1.h, src/knfc_crypto1.cThe Crypto1 cipher
include/knfc_emulate.hThe card and statistics types of emulation (a header only)
include/knfc_emu_img.h, src/knfc_emu_img.cThe identity and the 40-page Type-2 image an emulated card presents
include/knfc_field.h, src/knfc_field.cListen-window statistics
include/knfc_power.h, src/knfc_power.cWake, rest or nothing
components/rfalSTMicroelectronics' 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

ThingValue
ChipST25R3916B, IC_IDENTITY register 0x3F reads 0x31 (type 6, revision 1); init refuses anything else
BusSPI2, 1 MHz, mode 1, CS by hand. CS GPIO28, IRQ GPIO29, CLK GPIO30, MOSI GPIO31, MISO GPIO32
Field13.56 MHz, NFC-A poller. Up only during a request
SPI buffers300 bytes, rounded to 64-byte multiples, taken once from PSRAM at init; the port owns both
ServicerPriority configMAX_PRIORITIES - 2, 4096-byte stack
Listen detectorManual peer detection: trips at 105 mVpp, releases at 75 mVpp
Listen burst16 measurements (KNFC_LISTEN_BURST), 10 ms conversion limit, 13.02 mVpp per count
EmulationTask 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)
ExchangesType-2 READ returns 4 blocks (16 bytes). APDU up to 512 bytes. A bit-counting transceive for Mifare. Reselect waits 50 ms

The driver

CallWhat 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.

RowsWhat they are
identity, ic_type, ic_revRegister 0x3F, whole and by its bits
op_control, en, rx_en, rx_chn, rx_man, tx_en, wuRegister 0x02: oscillator and regulator, receiver, channel, manual channel, the field, wake-up
mode, om, tr_amRegister 0x03: the operation mode, AM rather than OOK
bit_rate, txrate, rxrateRegister 0x04; 0 is 106 kb/s
regulatorRegister 0x2B, the regulator display in mV
field, tags, errorsThe 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