Blog
Feb 15, 2026 - 10 MIN READ
NFC Tag Emulation on nRF52840 with Rust — No SDK Required

NFC Tag Emulation on nRF52840 with Rust — No SDK Required

How to turn a bare nRF52840 into an NFC Type 2 Tag using only PAC registers and Embassy async, running alongside BLE SoftDevice. A practical walkthrough with full code.

Mariusz Smenzyk

Mariusz Smenzyk

AI Developer ✨ MusicTech ✨ SportTech

Most NFC tutorials start with Arduino and a one-liner: NFC.start(). That's fine for a demo. But what if you're running Rust, async Embassy, and SoftDevice BLE on the same chip — and you need NFC alongside all of that?

That's the situation I found myself in while building BeatBuddy, a wearable device on the Seeed XIAO nRF52840 Sense. I wanted a phone tap to trigger an action — pair via BLE, show device info, whatever. The nRF52840 has an NFC-A peripheral built in. The problem? No Rust driver exists for it. The nrf-softdevice crate doesn't expose NFC APIs. Embassy doesn't have an NFCT HAL driver.

So I wrote one from scratch, using only PAC (Peripheral Access Crate) registers. Here's how.


The Good News: NFCT is Independent of BLE

The first thing I had to verify: can NFC and BLE coexist? On the nRF52840, the answer is yes. Here's why:

PeripheralUsed byRadioFrequency
RADIOSoftDevice (BLE)2.4 GHz transceiver2402–2480 MHz
NFCTOur NFC driverNFC-A tag emulator13.56 MHz

They're completely separate hardware blocks. NFCT doesn't touch the RADIO peripheral, and SoftDevice doesn't claim NFCT. The only shared resource is the CPU — and with Embassy's cooperative async scheduler, that's easy to manage.


Hardware: You Need an Antenna

The nRF52840 chip has NFC built in, but the XIAO board doesn't include an antenna. The NFC pins are:

  • P0.09 (NFC1) — 13.56 MHz differential pair
  • P0.10 (NFC2) — 13.56 MHz differential pair

You need to solder an external coil to these pads on the bottom of the board. Your options, from free to fancy:

OptionCostRangeNotes
DIY wire coil (4-5 turns, ~25mm)Free~1-2 cmAny thin copper wire works
Generic NFC flex antenna (AliExpress)~$1~2-3 cmSearch "NFC antenna 13.56MHz FPC"
MOLEX 1462360051$0.67~3-4 cm15mm adhesive sticker, clean
TAOGLAS FXR.07.A.DG~$4~4-5 cmBest range, confirmed on XIAO

For prototyping, a DIY coil is enough. Wind 4-5 turns of enameled copper wire around a pen cap, solder the ends to the NFC pads, done. Two minutes, zero cost.

The UICR Trap

One gotcha: the nRF52840 has a UICR (User Information Configuration Register) that controls whether P0.09/P0.10 are NFC pins or regular GPIO. Factory default is NFC mode (bit 0 = 1). But some bootloaders or debug tools change this to GPIO.

If NFC doesn't work after flashing, this is probably why. The fix is a mass erase (which resets UICR to factory defaults) followed by reflashing.


NFC-A Type 2 Tag: The Protocol

NFC-A (ISO 14443-3A) Type 2 Tag is the simplest NFC tag type. When your phone taps the tag, here's what happens:

Phone (Reader)              nRF52840 NFCT Hardware         Our Code
     │                              │                          │
     │── RF field ─────────────────>│                          │
     │                              │  FIELDDETECTED event     │
     │                              │─────────────────────────>│
     │                              │  TASKS_ACTIVATE          │
     │                              │<─────────────────────────│
     │<── SENS_RES (ATQA) ────────│  (automatic)             │
     │── anti-collision ──────────>│  (automatic)             │
     │<── NFCID1 (UID) ───────────│  (automatic)             │
     │── SELECT ──────────────────>│  SELECTED event          │
     │                              │─────────────────────────>│
     │── READ page 0 ────────────>│  RXFRAMEEND event        │
     │                              │─────────────────────────>│
     │                              │  (parse, prepare data)   │
     │<── 16 bytes + CRC ─────────│  TASKS_STARTTX           │
     │                              │<─────────────────────────│
     │── READ page 4 ────────────>│  ...repeat...            │
     │                              │                          │
     │── (phone removed) ─────────>│  FIELDLOST event         │

The beauty of the nRF52840 NFCT peripheral is that it handles the entire anti-collision protocol automatically. We only need to:

  1. Configure the UID and tag parameters
  2. Wait for the SELECTED event
  3. Handle READ commands by serving data pages

The T2T Memory Layout

A Type 2 Tag is just a sequence of 4-byte "pages". The phone reads these pages to find your data. Here's what our tag memory looks like:

Page  Offset  Content                   Purpose
────  ──────  ─────────────────────────  ──────────────
 0    0x00    BB 5F 00 E4              UID0-2 + BCC0
 1    0x04    BE A7 BD 01              UID3-6
 2    0x08    71 48 00 00              BCC1 + Internal + Lock
 3    0x0C    E1 10 08 00              Capability Container
 4    0x10    03 10 D1 01              NDEF TLV start
 5    0x14    0C 54 02 65              Payload len + "Te"
 6    0x18    6E 42 65 61              "nBea"
 7    0x1C    74 42 75 64              "tBud"
 8    0x20    64 79 FE 00              "dy" + Terminator

The NDEF (NFC Data Exchange Format) message is a text record saying "BeatBuddy" in English. When your phone reads this, it displays the text — or you can use a URL record to open a webpage, or a custom MIME type to launch your app.

Building it in Rust

const NFCID1: [u8; 7] = [0xBB, 0x5F, 0x00, 0xBE, 0xA7, 0xBD, 0x01];

fn build_tag_memory() -> [u8; 80] {
    let mut m = [0u8; 80];

    // Pages 0-2: UID header (must match NFCID1 registers)
    m[0] = NFCID1[0];
    m[1] = NFCID1[1];
    m[2] = NFCID1[2];
    m[3] = 0x88 ^ NFCID1[0] ^ NFCID1[1] ^ NFCID1[2]; // BCC0

    // Page 3: Capability Container
    m[12] = 0xE1; // NDEF magic
    m[13] = 0x10; // Version 1.0
    m[14] = 0x08; // Data size: 8 * 8 = 64 bytes
    m[15] = 0x00; // Read/write access

    // Pages 4+: NDEF text record "BeatBuddy"
    let ndef = [
        0x03, 16,                           // TLV: NDEF message, 16 bytes
        0xD1, 0x01, 0x0C, b'T',            // Record: Well-Known, Text
        0x02, b'e', b'n',                   // UTF-8, English
        b'B', b'e', b'a', b't',
        b'B', b'u', b'd', b'd', b'y',
        0xFE,                               // Terminator TLV
    ];
    m[16..16 + ndef.len()].copy_from_slice(&ndef);

    m
}

The BCC (Block Check Character) bytes are XOR checksums used during anti-collision. The Capability Container tells the phone "this is an NDEF tag, here's how big the data area is."


Configuring the NFCT Peripheral

This is where it gets interesting. Without a HAL driver, we talk directly to hardware registers through the PAC. The embassy-nrf crate with the unstable-pac feature gives us access:

use embassy_nrf::pac;

let nfct = pac::NFCT;  // Singleton — the NFCT register block

NFCID1 (The Tag's UID)

The 7-byte UID is split across two registers:

// Bytes 0-2 in NFCID1_2ND_LAST
nfct.nfcid1_2nd_last().write(|w| {
    w.set_nfcid1_t(0xBB);  // UID byte 0
    w.set_nfcid1_u(0x5F);  // UID byte 1
    w.set_nfcid1_v(0x00);  // UID byte 2
});

// Bytes 3-6 in NFCID1_LAST
nfct.nfcid1_last().write(|w| {
    w.0 = 0xBE | (0xA7 << 8) | (0xBD << 16) | (0x01 << 24);
});

SENSRES and SELRES

These configure the NFC-A anti-collision response:

// SENSRES (ATQA): 7-byte UID, default SDD pattern
nfct.sensres().write(|w| {
    w.set_nfcidsize(Nfcidsize::NFCID1DOUBLE); // 7-byte UID
    w.set_bitframesdd(Bitframesdd::SDD00001);
});

// SELRES (SAK): Type 2 Tag — no ISO-DEP, no NFC-DEP
nfct.selres().write_value(Selres(0x00));

Frame Configuration

The NFCT hardware can automatically handle parity bits, Start-of-Frame symbols, and CRC generation/checking:

RegisterValueBits enabled
TXD.FRAMECONFIG0x17Parity + DiscardStart + SoF + CRC16TX
RXD.FRAMECONFIG0x15Parity + SoF + CRC16RX

This means we never touch CRC bytes in software — the hardware appends them on TX and strips/checks them on RX.


The Main Loop: An Embassy Async Task

Here's the core pattern. The NFC task is a standard Embassy async task that runs forever:

#[embassy_executor::task]
pub async fn nfc_task() {
    let nfct = pac::NFCT;
    let tag_mem = build_tag_memory();
    let mut rx_buf = [0u8; 16];
    let mut tx_buf = [0u8; 16];

    // ... register configuration (shown above) ...

    loop {
        // 1. Start field sensing
        nfct.tasks_sense().write_value(1);

        // 2. Wait for phone (relaxed polling — other tasks run freely)
        while nfct.events_fielddetected().read() == 0 {
            Timer::after(Duration::from_millis(50)).await;
        }

        // 3. Activate — hardware handles anti-collision
        nfct.tasks_activate().write_value(1);

        // 4. Wait for selection
        // ... (poll events_selected with 1ms Timer::after) ...

        // 5. Handle T2T commands until field lost
        loop {
            nfct.packetptr().write_value(rx_buf.as_mut_ptr() as u32);
            nfct.tasks_enablerxdata().write_value(1);

            // Wait for RXFRAMEEND or FIELDLOST
            // ...

            if rx_buf[0] == 0x30 {  // READ command
                let page = rx_buf[1] as usize;
                // Fill tx_buf with 4 pages (16 bytes), wrap-around
                for i in 0..16 {
                    tx_buf[i] = tag_mem[((page * 4) + i) % tag_mem.len()];
                }
                nfct.packetptr().write_value(tx_buf.as_ptr() as u32);
                nfct.txd().amount().write(|w| {
                    w.set_txdatabytes(16);
                    w.set_txdatabits(0);
                });
                nfct.tasks_starttx().write_value(1);
                // Wait for TXFRAMEEND...
            }
        }

        nfct.tasks_disable().write_value(1);
    }
}

The Polling Trade-off

During the "waiting for phone" phase, we poll every 50ms and yield to Embassy — BLE, IMU, and other tasks run normally.

During active NFC communication (phone held to antenna), we use tighter polling (200us yields between frames). Each phone tap session is brief — about 100-200ms, during which the phone sends 5-10 READ commands. The FRAMEDELAYMAX register gives us a 4.8ms window to respond to each command, which is plenty even with async yields.

For production, you'd want interrupt-driven NFCT handling. But for a prototype, polling works fine.


The nrf-pac Gotcha: It's Not svd2rust

If you've used older nRF52 PAC crates, you might expect this pattern:

// OLD svd2rust style — DOES NOT WORK with nrf-pac 0.1.0
nfct.events_fielddetected.write(|w| unsafe { w.bits(0) });

The nrf-pac crate used by Embassy 0.3 is generated by chiptool, not svd2rust. The API is different:

// Chiptool style — correct
nfct.events_fielddetected().write_value(0);          // u32 registers
nfct.sensres().write(|w| w.set_nfcidsize(val));      // struct registers
nfct.errorstatus().read().0                           // raw u32 from struct

Key differences:

Patternsvd2rustchiptool (nrf-pac)
Access registernfct.reg (field)nfct.reg() (method call)
Write raw u32w.bits(val)*w = val or write_value(val)
Write struct regw.field().bits(val)w.set_field(val)
Read raw.read().bits().read() (u32) or .read().0 (struct)
Peripheral accessunsafe { &*PAC::ptr() }pac::NFCT (const singleton)

This tripped me up for about an hour. The compiler errors are unhelpful — "type annotations needed" everywhere — because the closure parameter type can't be inferred when you use the wrong API.


Running NFC + BLE + IMU Simultaneously

The beautiful thing about this approach is that NFC is just another Embassy task. Here's the spawn in main():

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    // ... HAL init, SoftDevice, BLE server ...

    spawner.spawn(ble_task(sd, server)).unwrap();
    spawner.spawn(sensor_task(/* IMU pins */)).unwrap();
    spawner.spawn(pdm_task(/* mic pins */)).unwrap();
    spawner.spawn(nfc::nfc_task()).unwrap();  // Just another task!
}

The interrupt priority setup matters:

PriorityUsed by
P0-P1SoftDevice (reserved)
P2Embassy (GPIOTE, RTC1 time driver)
P3PDM microphone
NFCT uses polling, no interrupt needed

Since NFCT doesn't need interrupts for our polling approach, there are no priority conflicts to worry about.


What's Next

This foundation gives us a working NFC tag that any phone can read. The next steps:

  • Dynamic NDEF content — serve the current BLE address or a deep link URL so the phone can connect to BeatBuddy directly from the NFC tap
  • NFC-triggered BLE pairing — detect the NFC tap and start BLE advertising immediately
  • Interrupt-driven NFCT — replace polling with a proper async interrupt handler for zero-cost NFC
  • WRITE command support — let the phone write configuration data to the tag

The full source code is in the beatbuddy-nrf52840 repository.


Key Takeaways

  1. NFCT and RADIO are independent — NFC works alongside SoftDevice BLE without conflict
  2. No Rust NFC driver exists — but the PAC gives you everything you need in ~200 lines
  3. The antenna is just a coil — you can literally wind one from spare wire
  4. chiptool PAC != svd2rust PAC — watch out for the different register access patterns
  5. Embassy makes it trivial — NFC is just another async task, no special integration needed

The nRF52840 is a remarkably capable chip. BLE, NFC, PDM microphone, I2C sensors, and a neural network — all running concurrently in async Rust on a board the size of a thumb.