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
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 first thing I had to verify: can NFC and BLE coexist? On the nRF52840, the answer is yes. Here's why:
| Peripheral | Used by | Radio | Frequency |
|---|---|---|---|
| RADIO | SoftDevice (BLE) | 2.4 GHz transceiver | 2402–2480 MHz |
| NFCT | Our NFC driver | NFC-A tag emulator | 13.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.
The nRF52840 chip has NFC built in, but the XIAO board doesn't include an antenna. The NFC pins are:
You need to solder an external coil to these pads on the bottom of the board. Your options, from free to fancy:
| Option | Cost | Range | Notes |
|---|---|---|---|
| DIY wire coil (4-5 turns, ~25mm) | Free | ~1-2 cm | Any thin copper wire works |
| Generic NFC flex antenna (AliExpress) | ~$1 | ~2-3 cm | Search "NFC antenna 13.56MHz FPC" |
| MOLEX 1462360051 | $0.67 | ~3-4 cm | 15mm adhesive sticker, clean |
| TAOGLAS FXR.07.A.DG | ~$4 | ~4-5 cm | Best 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.
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 (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:
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.
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."
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
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);
});
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));
The NFCT hardware can automatically handle parity bits, Start-of-Frame symbols, and CRC generation/checking:
| Register | Value | Bits enabled |
|---|---|---|
| TXD.FRAMECONFIG | 0x17 | Parity + DiscardStart + SoF + CRC16TX |
| RXD.FRAMECONFIG | 0x15 | Parity + SoF + CRC16RX |
This means we never touch CRC bytes in software — the hardware appends them on TX and strips/checks them on RX.
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);
}
}
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.
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:
| Pattern | svd2rust | chiptool (nrf-pac) |
|---|---|---|
| Access register | nfct.reg (field) | nfct.reg() (method call) |
| Write raw u32 | w.bits(val) | *w = val or write_value(val) |
| Write struct reg | w.field().bits(val) | w.set_field(val) |
| Read raw | .read().bits() | .read() (u32) or .read().0 (struct) |
| Peripheral access | unsafe { &*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.
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:
| Priority | Used by |
|---|---|
| P0-P1 | SoftDevice (reserved) |
| P2 | Embassy (GPIOTE, RTC1 time driver) |
| P3 | PDM 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.
This foundation gives us a working NFC tag that any phone can read. The next steps:
The full source code is in the beatbuddy-nrf52840 repository.
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.
Mirroring a Wearable OLED Display to a Projector for Live Demos
How to stream a tiny OLED screen from an embedded device to a laptop and projector in real-time using UART and Python. A practical guide for hardware demos and presentations.
A quick introduction to profit sharing implementation
My company does not offer any employee benefits. Learn how we implemented a simple profit sharing system and whether it's worth considering for your organization.