ST25R3916 实战测评与开发教程:从芯片启动到多协议读卡与卡模拟
1. 为什么选择 ST25R3916ST25R3916 不是一颗“发个命令就能读 UID”的简单 NFC 芯片。它更接近一套完整的 13.56 MHz 射频前端:主机需要负责模式切换、FIFO、中断、定时器以及上层协议,但换来的能力也明显强于只支持 ISO14443-A 的入门读卡芯片。 这次工程实现并验证了以下功能路径:
从使用体验看,ST25R3916 的优点和代价都很明确。
如果项目只需要偶尔读取 MIFARE Classic UID,ST25R3916 会显得偏重;如果需要一套可以继续扩展到 NFC-B、FeliCa、ISO15693 和卡模拟的前端,它的价值就体现出来了。 2. 工程结构该项目的驱动库移植自M5stack的Unit NFC驱动,STM32原生的X-Cube-NFC目前移植调通但是使用上还有点小bug,所以暂时先用ESP32进行调试。
代码分层如下。
这种分层的好处是,上层 NFC-A 防冲突代码不需要知道 I2C 寄存器帧怎么编码;底层驱动也不需要理解 NDEF 文本记录。出现问题时,可以按“应用逻辑 → 协议状态机 → 芯片寄存器 → 硬件射频”逐层定位。 主要类和职责可以直接对应到代码:
3. 硬件连接与基本条件当前工程的默认连接如下。
配置位于:
普通扫描使用 100 kHz,卡模拟使用 400 kHz。这样安排不是为了追求总线带宽数字,而是因为卡模拟需要在手机外场出现后快速启动振荡器、接收器并处理目标模式中断。 400 kHz 下建议使用外部上拉:
除了数字接口,以下硬件条件更关键:
4. 编译宏:一个固件只做一种角色扫描器和卡模拟器使用同一套底层驱动,但运行角色相反:
工程没有把两种角色硬塞进同一个运行循环,而是通过编译宏三选一。
4.1 通用扫描器
依次扫描 A、B、F、V。 4.2 NFC-A 卡模拟
模拟 MIFARE Ultralight / NFC Forum Type 2 Tag。 4.3 NFC-F 卡模拟
模拟 FeliCa Lite-S。 编译期互斥的好处是:扫描代码不会在卡模拟期间误开射频场,卡模拟状态机也不会被 A/B/F/V 模式切换打断。 5. ST25R3916 主机接口实现ST25R3916 的串行接口把操作类型编码进首字节高位。驱动中的基础操作码如下:
Space-A 寄存器采用 6 位地址。例如读取 IC Identity 寄存器
完整读取代码:
5.1 Space-B 访问ST25R3916 还有一组 Space-B 扩展寄存器。访问前需要发送
驱动内部用
Space-B 中包含接收相关器、过冲/下冲保护、静噪定时器和调制优化参数。自制板上如果 Space-B 某些寄存器不响应,基础功能可能仍能运行,但 NFC-B/F/V 和弱信号接收稳定性会受影响。 工程提供两种策略:
调试阶段可以先警告后继续;量产固件更适合改成 6. 芯片身份检查ST25R3916 的 IC Identity 寄存器位于
其中:
初始化阶段会重试 5 次,每次间隔 20 ms。这是为了处理 USB 下载后 MCU 已重启、但外部 NFC 芯片没有完全掉电的情况。
这一层检查比单纯 I2C ACK 更可靠:ACK 只能证明总线上有设备,Identity 检查才能确认地址、重复起始、读取方向和芯片型号都正确。 7. 上电初始化流程ST25R3916 初始化不是简单的
grectgrect
defsfilterfedropshadow defsfilterfedropshadow lineargradient g grectgrect
grectgrect
defsfilterfedropshadowdefsfilterfedropshadowlineargradientgrectgrect
grectgrect
grectgrect
defsfilterfedropshadowdefsfilterfedropshadowlineargradient### 7.1 防御性停止旧状态 USB 下载程序时,ST25R3916 不一定同步掉电。上一个固件可能留下 TX、RX 或定时器状态。因此初始化先执行:
7.2 Set Default 与保护序列
随后通过 Test Access 写入数据手册要求的保护序列:
这里不能省略。它不是协议功能配置,而是芯片上电后的保护要求。 7.3 IO Configuration驱动会根据 I2C 时钟选择阈值位:
再合并接口电平和供电信息:
8. 晶振启动:先看
|
| 字段 | 含义 |
|---|---|
Operation Control.en |
振荡器和稳压器使能 |
Auxiliary Display.osc_ok |
晶振已经稳定 |
I_osc |
晶振稳定中断 |
只有 en=1 不代表时钟已经可用。驱动必须等到 osc_ok=1,否则后续 NFC 定时可能完全失真。
TX Driver 寄存器高半字节是 ASK 调制参数,低半字节是输出电阻:
const uint8_t tx_driver =
((tx_am_modulation & 0x0F) << 4)
| (tx_driver_resistance & 0x0F);
当前配置:
#define ST25R3916_CFG_TX_AM_MODULATION 7U
#define ST25R3916_CFG_TX_DRIVER_RESISTANCE 0U
因此启动日志为:
TX Driver: 0x70, d_res=0
d_res=0 对应较强的驱动能力。调试自制天线时,不建议一开始就追求最大场强。更稳妥的方法是:
d_res 启动;d_res。需要特别说明:卡模拟模式不主动产生载波,手机读取模拟卡时主要依赖负载调制路径,因此 d_res 不是卡模拟无法检测手机外场的首要原因。
读卡器模式下,工程采用分级开启 TX 和 RX:
const uint8_t ready_operation = en;
writeOperationControl(ready_operation);
const uint8_t tx_operation = ready_operation | tx_en;
writeOperationControl(tx_operation);
delay(ST25R3916_CFG_RF_STAGE_DELAY_MS);
const uint8_t active_operation = tx_operation | rx_en;
writeOperationControl(active_operation);
对应状态:
Ready OP=0x80
TX OP=0x88
Active OP=0xC8

分级启动有两个实际好处:
这些寄存器阶段日志目前降为 DEBUG。正常扫描只保留错误和卡片结果;需要查看时,将 M5_LOG_LEVEL 改为 4。
ST25R3916 有一组不经过普通寄存器写入路径的直接命令。常用命令包括:
CMD_SET_DEFAULT = 0xC1;
CMD_STOP_ALL_ACTIVITIES = 0xC2;
CMD_TRANSMIT_WITH_CRC = 0xC4;
CMD_TRANSMIT_WITHOUT_CRC = 0xC5;
CMD_TRANSMIT_REQA = 0xC6;
CMD_TRANSMIT_WUPA = 0xC7;
CMD_NFC_INITIAL_FIELD_ON = 0xC8;
CMD_GO_TO_SENSE = 0xCD;
CMD_GO_TO_SLEEP = 0xCE;
CMD_RESET_RX_GAIN = 0xD5;
CMD_ADJUST_REGULATORS = 0xD6;
CMD_CLEAR_FIFO = 0xDB;
CMD_REGISTER_SPACEB_ACCESS = 0xFB;
CMD_TEST_ACCESS = 0xFC;
驱动统一入口:
bool UnitST25R3916::writeDirectCommand(
uint8_t cmd,
const uint8_t* data,
uint32_t length)
{
TRANSACTION_GUARD();
return writeRegister(cmd, data, length, true);
}
TRANSACTION_GUARD() 用来保证复合操作不会被另一个任务插入总线事务。
ST25R3916 的中断分成四组:
驱动把它们组合成一个 uint32_t:
31 24 23 16
+--------------------+--------------------+
| Main Interrupt | Timer/NFC Interrupt|
+--------------------+--------------------+
| Error/Wakeup | Passive Target |
+--------------------+--------------------+
15 8 7 0
value = (static_cast<uint32_t>(main_nfc) << 16)
| (static_cast<uint32_t>(error) << 8)
| passive;
中断寄存器具有读清除语义,因此读取顺序和软件缓存很重要。轮询模式下:
void UnitST25R3916::update()
{
if (!_using_irq) {
uint32_t value{};
readInterrupts(value);
_stored_irq |= value & _enabled_irq;
}
}
如果连接了物理 IRQ,引脚中断服务函数只置位标志,不在 ISR 中访问 I2C:
void IRAM_ATTR UnitST25R3916::on_irq(void* arg)
{
auto* unit = static_cast<UnitST25R3916*>(arg);
unit->_interrupt_occurred = true;
}
这是正确的嵌入式处理方式:I2C 事务留在任务上下文完成,ISR 只做最小工作。
ST25R3916 内部 FIFO 最大容量为 512 字节:
constexpr uint16_t MAX_FIFO_DEPTH{512};
发送流程通常是:
<pre class="mermaid" id="mermaid-2fed1150" data-vscode-context="{"webviewSection":"mermaid","mermaidSource":"flowchart LR\n A[Clear FIFO] --> B[写入发送数据]\n B --> C[写 Number of Transmitted Bytes]\n C --> D[Transmit with/without CRC]\n D --> E[等待 TXE 中断]","preventDefaultContextMenuItems":true}"><svg id="dmermaid-2fed1150" width="100%" xmlns="http://www.w3.org/2000/svg" class="flowchart" viewBox="0 0 1069.09375 65.5" role="graphics-document document" aria-roledescription="flowchart-v2"><g><marker id="dmermaid-2fed1150_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath"></path></marker><marker id="dmermaid-2fed1150_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath"></path></marker><marker id="dmermaid-2fed1150_flowchart-v2-pointEnd-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="11.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="10.5" markerHeight="14" orient="auto"><path d="M 0 0 L 11.5 7 L 0 14 z" class="arrowMarkerPath"></path></marker><marker id="dmermaid-2fed1150_flowchart-v2-pointStart-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="1" refY="7" markerUnits="userSpaceOnUse" markerWidth="11.5" markerHeight="14" orient="auto"><polygon points="0,7 11.5,14 11.5,0" class="arrowMarkerPath"></polygon></marker><marker id="dmermaid-2fed1150_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath"></circle></marker><marker id="dmermaid-2fed1150_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath"></circle></marker><marker id="dmermaid-2fed1150_flowchart-v2-circleEnd-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refY="5" refX="12.25" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath"></circle></marker><marker id="dmermaid-2fed1150_flowchart-v2-circleStart-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-2" refY="5" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath"></circle></marker><marker id="dmermaid-2fed1150_flowchart-v2-crossEnd" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="12" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath"></path></marker><marker id="dmermaid-2fed1150_flowchart-v2-crossStart" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="-1" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath"></path></marker><marker id="dmermaid-2fed1150_flowchart-v2-crossEnd-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="17.7" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath"></path></marker><marker id="dmermaid-2fed1150_flowchart-v2-crossStart-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="-3.5" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath"></path></marker><g class="root"><g class="clusters"></g><g class="edgePaths"><path d="M126.656,32.75L130.823,32.75C134.99,32.75,143.323,32.75,150.99,32.75C158.656,32.75,165.656,32.75,169.156,32.75L172.656,32.75" id="dmermaid-2fed1150-L_A_B_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" data-edge="true" data-et="edge" data-id="L_A_B_0" data-points="W3sieCI6MTI2LjY1NjI1LCJ5IjozMi43NX0seyJ4IjoxNTEuNjU2MjUsInkiOjMyLjc1fSx7IngiOjE3Ni42NTYyNSwieSI6MzIuNzV9XQ==" data-look="classic" marker-end="url(#dmermaid-2fed1150_flowchart-v2-pointEnd)"></path><path d="M314.656,32.75L318.823,32.75C322.99,32.75,331.323,32.75,338.99,32.75C346.656,32.75,353.656,32.75,357.156,32.75L360.656,32.75" id="dmermaid-2fed1150-L_B_C_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" data-edge="true" data-et="edge" data-id="L_B_C_0" data-points="W3sieCI6MzE0LjY1NjI1LCJ5IjozMi43NX0seyJ4IjozMzkuNjU2MjUsInkiOjMyLjc1fSx7IngiOjM2NC42NTYyNSwieSI6MzIuNzV9XQ==" data-look="classic" marker-end="url(#dmermaid-2fed1150_flowchart-v2-pointEnd)"></path><path d="M608.594,32.75L612.76,32.75C616.927,32.75,625.26,32.75,632.927,32.75C640.594,32.75,647.594,32.75,651.094,32.75L654.594,32.75" id="dmermaid-2fed1150-L_C_D_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" data-edge="true" data-et="edge" data-id="L_C_D_0" data-points="W3sieCI6NjA4LjU5Mzc1LCJ5IjozMi43NX0seyJ4Ijo2MzMuNTkzNzUsInkiOjMyLjc1fSx7IngiOjY1OC41OTM3NSwieSI6MzIuNzV9XQ==" data-look="classic" marker-end="url(#dmermaid-2fed1150_flowchart-v2-pointEnd)"></path><path d="M870.906,32.75L875.073,32.75C879.24,32.75,887.573,32.75,895.24,32.75C902.906,32.75,909.906,32.75,913.406,32.75L916.906,32.75" id="dmermaid-2fed1150-L_D_E_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" data-edge="true" data-et="edge" data-id="L_D_E_0" data-points="W3sieCI6ODcwLjkwNjI1LCJ5IjozMi43NX0seyJ4Ijo4OTUuOTA2MjUsInkiOjMyLjc1fSx7IngiOjkyMC45MDYyNSwieSI6MzIuNzV9XQ==" data-look="classic" marker-end="url(#dmermaid-2fed1150_flowchart-v2-pointEnd)"></path></g><g class="edgeLabels"><g class="edgeLabel"><g class="label" data-id="L_A_B_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_B_C_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_C_D_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_D_E_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg"><span class="edgeLabel"></span></div></foreignObject></g></g></g><g class="nodes"><g class="node default" id="dmermaid-2fed1150-flowchart-A-0" data-look="classic" transform="translate(67.328125, 32.75)"><rect class="basic label-container" x="-59.328125" y="-24.75" width="118.65625" height="49.5"></rect><g class="label" transform="translate(-29.328125, -9.75)"><rect></rect><foreignObject width="58.65625" height="19.5"><div xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"><p>Clear FIFO</p></span></div></foreignObject></g></g><g class="node default" id="dmermaid-2fed1150-flowchart-B-1" data-look="classic" transform="translate(245.65625, 32.75)"><rect class="basic label-container" x="-69" y="-24.75" width="138" height="49.5"></rect><g class="label" transform="translate(-39, -9.75)"><rect></rect><foreignObject width="78" height="19.5"><div xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"><p>写入发送数据</p></span></div></foreignObject></g></g><g class="node default" id="dmermaid-2fed1150-flowchart-C-3" data-look="classic" transform="translate(486.625, 32.75)"><rect class="basic label-container" x="-121.96875" y="-24.75" width="243.9375" height="49.5"></rect><g class="label" transform="translate(-91.96875, -9.75)"><rect></rect><foreignObject width="183.9375" height="19.5"><div xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"><p>写 Number of Transmitted Bytes</p></span></div></foreignObject></g></g><g class="node default" id="dmermaid-2fed1150-flowchart-D-5" data-look="classic" transform="translate(764.75, 32.75)"><rect class="basic label-container" x="-106.15625" y="-24.75" width="212.3125" height="49.5"></rect><g class="label" transform="translate(-76.15625, -9.75)"><rect></rect><foreignObject width="152.3125" height="19.5"><div xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"><p>Transmit with/without CRC</p></span></div></foreignObject></g></g><g class="node default" id="dmermaid-2fed1150-flowchart-E-7" data-look="classic" transform="translate(991, 32.75)"><rect class="basic label-container" x="-70.09375" y="-24.75" width="140.1875" height="49.5"></rect><g class="label" transform="translate(-40.09375, -9.75)"><rect></rect><foreignObject width="80.1875" height="19.5"><div xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"><p>等待 TXE 中断</p></span></div></foreignObject></g></g></g></g></g><defs><filter id="dmermaid-2fed1150-drop-shadow" height="130%" width="130%"><fedropshadow dx="4" dy="4" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"></fedropshadow></filter></defs><defs><filter id="dmermaid-2fed1150-drop-shadow-small" height="150%" width="150%"><fedropshadow dx="2" dy="2" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"></fedropshadow></filter></defs><linearGradient id="dmermaid-2fed1150-gradient" gradientUnits="objectBoundingBox" x1="0%" y1="0%" x2="100%" y2="0%"></linearGradient></svg></pre>
代码示例:
clearInterrupts();
writeDirectCommand(CMD_CLEAR_FIFO);
writeFIFO(tx, tx_len);
writeNumberOfTransmittedBytes(tx_len, 0);
writeDirectCommand(CMD_TRANSMIT_WITH_CRC);
接收时先读取 FIFO Status 1/2,得到完整字节数和最后一个字节的有效位数,再按调用方缓冲区容量取数据。
对于 NFC-A 7 位 REQA、防冲突碰撞位和 MIFARE 4 位 ACK,最后一个字节的有效位数不能忽略。
ST25R3916 的定时器基于 13.56 MHz 载波周期。工程避免浮点运算,使用整数向上取整。
uint16_t calculate_nrt(uint32_t ms, bool step4096)
{
constexpr uint32_t FC_HZ = 13560000;
const uint64_t step = step4096
? 4096ULL * 1000000ULL
: 64ULL * 1000000ULL;
const uint64_t us = static_cast<uint64_t>(ms) * 1000U;
const uint64_t nrt = (us * FC_HZ + step - 1) / step;
return clamp(nrt, 1, 0xFFFF);
}
uint8_t calculate_mrt(uint32_t us, bool step512)
{
constexpr uint32_t FC_HZ = 13560000;
const uint32_t step = step512
? 512U * 1000000U
: 64U * 1000000U;
uint32_t mrt = (us * FC_HZ + step - 1) / step;
return clamp(mrt, 4, 0xFF);
}
使用向上取整的原因很简单:等待时间短于协议要求通常比稍长更危险。
通用扫描器不是同时打开四种协议,而是不断重配 ST25R3916:

核心入口:
size_t scan_all_technologies()
{
size_t detected = 0;
#if ST25R3916_CFG_SCAN_NFC_A
detected += scan_nfca();
#endif
#if ST25R3916_CFG_SCAN_NFC_B
detected += scan_nfcb();
#endif
#if ST25R3916_CFG_SCAN_NFC_F
detected += scan_nfcf();
#endif
#if ST25R3916_CFG_SCAN_NFC_V
detected += scan_nfcv();
#endif
return detected;
}
每次进入模式都会执行:
g_reader.configureNFCMode(mode);
vTaskDelay(kModeSettleDelay);
g_units.update();
相关参数:
#define ST25R3916_CFG_TECH_SCAN_TIMEOUT_MS 120U
#define ST25R3916_CFG_MODE_SETTLE_DELAY_MS 5U
#define ST25R3916_CFG_MAX_CARDS_PER_TECHNOLOGY 4U
#define ST25R3916_CFG_CARD_SCAN_INTERVAL_MS 250U
如果只需要 A 和 V,可以关闭另外两种技术,缩短一轮扫描时间:
#define ST25R3916_CFG_SCAN_NFC_A 1
#define ST25R3916_CFG_SCAN_NFC_B 0
#define ST25R3916_CFG_SCAN_NFC_F 0
#define ST25R3916_CFG_SCAN_NFC_V 1
NFC-A 检测分两层:
detect():发现卡片并获得 UID、ATQA、SAK;identify():使用 ATS、GetVersion 和厂商信息进一步识别。std::vector<m5::nfc::a::PICC> cards;
if (g_nfc_a.detect(cards, timeout_ms)) {
for (auto& card : cards) {
const bool identified = g_nfc_a.identify(card);
print_nfca_card(card, identified);
}
}

驱动支持 4、7、10 字节 UID 的级联选择。发生碰撞时,会读取碰撞位置并逐位选择分支,直到获得完整 UID 和 BCC。
** SAK 无法精确区分所有卡片。例如 MIFARE Plus SL1 可能表现得像 MIFARE Classic。工程会进一步尝试:
所以“检测到 NFC-A”与“可以读取卡内数据”是两件事。MIFARE Classic 需要 Key A/Key B,DESFire 和 Plus 还可能需要 AES 或安全消息。
NFC-B 先发送 REQB,卡片返回 ATQB:
std::vector<m5::nfc::b::PICC> cards;
g_nfc_b.detect(
cards,
0x00, // AFI:全部
kMaxCardsPerTechnology,
kTechnologyScanTimeoutMs
);
ATQB 包含:
PUPI 4 字节
Application 4 字节
Protocol Info 3 字节
需要进一步通信时,发送 ATTRIB:
const bool activated = g_nfc_b.select(card);
激活后进入 ISO-DEP,可以继续交换 APDU。但应用文件结构、AID 和访问权限没有统一答案,因此通用扫描器只做基础激活,不对未知 NFC-B 卡盲目发送业务 APDU。
NFC-F 使用 IDm 和 PMm 标识卡片。扫描器根据最大卡片数选择轮询时隙:
constexpr m5::nfc::f::TimeSlot configured_nfcf_time_slot()
{
#if ST25R3916_CFG_MAX_CARDS_PER_TECHNOLOGY <= 1
return TimeSlot::Slot1;
#elif ST25R3916_CFG_MAX_CARDS_PER_TECHNOLOGY <= 2
return TimeSlot::Slot2;
#elif ST25R3916_CFG_MAX_CARDS_PER_TECHNOLOGY <= 4
return TimeSlot::Slot4;
#elif ST25R3916_CFG_MAX_CARDS_PER_TECHNOLOGY <= 8
return TimeSlot::Slot8;
#else
return TimeSlot::Slot16;
#endif
}
扫描后还会检查:
最终输出 IDm、PMm、类型、格式标志和用户区容量。
NFC-V 对应 ISO15693。检测流程:

Stay Quiet 用于让已经枚举到的卡片暂时停止响应,便于继续发现其他标签。
代码会读取:
picc.dsfID;
picc.afi;
picc.icRef;
picc.blocks;
picc.block_size;
总容量:
uint16_t totalSize() const
{
return blocks * block_size;
}
ST25DV 等大容量标签还需要扩展 System Information 和扩展块地址命令,驱动中已经做了对应分支。
同一张卡一直放在天线上,如果每轮都打印,串口很快会被淹没。工程使用“协议前缀 + 卡片标识”作为去重键:
A:<UID>
B:<PUPI>
F:<IDm>
V:<UID>
bool should_report_card(const std::string& signature)
{
const TickType_t now = xTaskGetTickCount();
for (auto& state : g_card_reports) {
if (state.signature == signature) {
if ((now - state.reported_at)
< kSameCardReportInterval) {
return false;
}
state.reported_at = now;
return true;
}
}
// 新卡加入缓存
...
}
重复报告间隔:
#define ST25R3916_CFG_SAME_CARD_REPORT_INTERVAL_MS 2000U
射频前端解决的是“如何可靠收发一帧”,真正的数据访问还要经过上层协议。工程中的 nfc/ 目录大致分为四块。
nfc/ndef/ndef.cpp
nfc/ndef/ndef_record.cpp
nfc/ndef/ndef_tlv.cpp
nfc/layer/ndef_layer.cpp
Type 2、Type 3、Type 5 标签常见的 NDEF 数据最终都会转换成消息和记录对象。TLV 层负责处理:
Null TLV
Lock Control TLV
Memory Control TLV
NDEF Message TLV
Proprietary TLV
Terminator TLV
典型读取接口:
std::vector<m5::nfc::ndef::TLV> tlvs;
if (g_nfc_a.ndefRead(tlvs)) {
for (const auto& tlv : tlvs) {
if (!tlv.isMessageTLV()) {
continue;
}
for (const auto& record : tlv.records()) {
// 根据 TNF、Type 和 Payload 解释文本、URI 等记录
}
}
}
通用扫描器默认不自动读 NDEF,是为了避免对需要认证、写保护或专用文件系统的卡片发送不合适的命令。确定卡片类型后,再进入对应读取分支更可靠。
nfc/isoDEP/isoDEP.cpp
nfc/isoDEP/file_system.cpp
nfc/isoDEP/nfc_b_file_system.cpp
nfc/isoDEP/desfire_file_system.cpp
NFC-A Type 4、NFC-B 和 DESFire 等卡片会使用 ISO-DEP。该层处理:
应用层不应该直接把长 APDU 塞进 FIFO,而应交给 ISO-DEP 层根据双方最大帧长度拆分。
nfc/apdu/apdu.cpp
APDU 层负责组织 ISO/IEC 7816-4 风格命令:
CLA | INS | P1 | P2 | Lc | Data | Le
响应通常由数据和状态字组成:
Data | SW1 | SW2
只有知道目标应用的 AID、文件结构和访问规则,APDU 才有实际意义。通用 NFC-B 扫描只做到 ATTRIB/ISO-DEP 激活,避免对未知证件或 CPU 卡发送猜测命令。
nfc/crypto/mifare_classic_crypto1.hpp
nfc/crypto/aes.cpp
nfc/crypto/secure_zero.hpp
MIFARE Classic 使用 Crypto1,MIFARE Plus、DESFire 等安全等级会用到 AES。认证流程不仅是调用一个加密函数,还要维护随机数、密码流状态、消息计数器和 MAC。
工程中的 secure_zero 用于在认证完成或失败后清理临时密钥和会话数据。对于长期运行的嵌入式设备,这比让密钥一直留在栈内存中更稳妥。
这些上层模块与 ST25R3916 的关系可以概括为:
---
NFC-A 模拟模式默认模拟 MIFARE Ultralight / Type 2 Tag。
constexpr uint8_t kNfcaUid[7] = {
0x04, 0x34, 0x56, 0x78,
0x9A, 0xBC, 0xDE
};
内存镜像位于:
main/st25r3916_emulation_data.hpp
m5::nfc::a::PICC picc{};
picc.emulate(
m5::nfc::a::Type::MIFARE_Ultralight,
kNfcaUid,
sizeof(kNfcaUid)
);
emulate() 会根据类型配置:
Ultralight/NTAG 的厂家页中包含两级 BCC:
nfca_memory[0] = uid[0];
nfca_memory[1] = uid[1];
nfca_memory[2] = uid[2];
nfca_memory[3] = 0x88 ^ uid[0] ^ uid[1] ^ uid[2];
nfca_memory[4] = uid[3];
nfca_memory[5] = uid[4];
nfca_memory[6] = uid[5];
nfca_memory[7] = uid[6];
nfca_memory[8] = uid[3] ^ uid[4] ^ uid[5] ^ uid[6];
如果只修改 UID 数组而不更新厂家页,手机可能完成防冲突,却读取到不一致的卡片内存。

各状态含义:
| 状态 | 含义 |
|---|---|
| Off | 低功耗等待外部场 |
| Idle | 已检测到外场,等待或处理轮询命令 |
| Ready | 防冲突/选择过程中 |
| Active | 已被手机选中,可读写模拟内存 |
| Halt | 收到 HLTA,等待 WUPA |
进入 Off 前,命令执行瞬间必须满足:
en=1
rx_en=1
然后执行:
writeDirectCommand(CMD_GO_TO_SENSE);
最后关闭主振荡器和接收器,只保留自动外场检测:
modify_bit_register8(
REG_OPERATION_CONTROL,
en_fd_manual_auto,
en | rx_en | tx_en | wu | en_fd_mask
);
外场出现后,正常路径是 I_eon 中断。考虑到 I2C 轮询可能先读清中断,状态机同时检查 Auxiliary Display 的 efd_o:
const bool irq_field =
(get_irq(I_eon32) & I_eon32) != 0;
const bool level_field = is_extra_field();
if (irq_field || level_field) {
return goto_idle();
}
这项兜底很重要:边沿事件可能丢失,实时电平只要仍存在就能恢复状态机。
卡模拟模式每秒输出一次低频诊断:
NFC-A Off诊断:OP=0x03 en_fd=3 AUX=0x00 efd_o=0 osc_ok=0
正常等待手机时:
en_fd=3
efd_o=0
osc_ok=0
手机靠近后应看到:
efd_o=1
NFC-A emulation: external field detected
NFC-A 模拟状态:Idle
NFC-A 模拟状态:Ready
NFC-A 模拟状态:Active
如果 en_fd=3 但手机靠近时 efd_o 始终为 0,应优先检查天线、匹配网络和 RFI 输入,而不是继续修改 UID 或 NDEF 数据。
NFC-F 模拟默认使用 FeliCa Lite-S:
constexpr uint8_t kNfcfIdm[8] = {
0x01, 0x2E, 0x50, 0xE5,
0x3C, 0x4B, 0x4F, 0x29
};
constexpr uint8_t kNfcfPmm[8] = {
0x00, 0xF1, 0x00, 0x00,
0x00, 0x01, 0x43, 0x00
};
创建模拟卡:
m5::nfc::f::PICC picc{};
picc.emulate(
m5::nfc::f::Type::FeliCaLiteS,
kNfcfIdm,
kNfcfPmm
);
系统码当前为:
0x88B4
启动前需要把 IDm 和 PMm 同步到 Lite-S 内存镜像中的 ID、D_ID 块:
constexpr uint32_t id_offset = 17U * 16U;
constexpr uint32_t did_offset = 18U * 16U;
for (uint_fast8_t i = 0; i < 8; ++i) {
memory[id_offset + i] = idm[i];
memory[did_offset + i] = idm[i];
memory[did_offset + 8 + i] = pmm[i];
}
NFC-F 模拟状态比 NFC-A 少:
Off → Communicated → Selected
状态机支持 Polling、Read Without Encryption 和 Write Without Encryption 等基础命令。
扫描器主动控制通信节奏,100 kHz I2C 通常还能工作。卡模拟不同:手机产生外场后,ST25R3916 和主控必须在规定时间内完成唤醒、模式确认和响应。
工程按应用模式选择 I2C 速度:
#if ST25R3916_CFG_APP_MODE == ST25R3916_APP_MODE_SCANNER
constexpr uint32_t kI2cFrequencyHz =
ST25R3916_CFG_I2C_FREQUENCY_HZ;
#else
constexpr uint32_t kI2cFrequencyHz =
ST25R3916_CFG_EMULATION_I2C_FREQUENCY_HZ;
#endif
卡模拟循环不能加入常规的 10~100 ms 延时:
for (;;) {
g_units.update();
g_emulation_a.update();
feed_idle_task_periodically();
}
为了让 ESP32-C3 单核 Idle 和看门狗任务运行,工程每 2 秒短暂让出 5 ms,而不是每轮延时:
#define ST25R3916_CFG_EMULATION_IDLE_FEED_INTERVAL_MS 2000U
#define ST25R3916_CFG_EMULATION_IDLE_FEED_DELAY_MS 5U
驱动和应用统一使用 M5_LIB_LOGx,避免 ESP_LOGx 与 vprintf 两套后端输出交叉。
当前编译等级:
-DM5_LOG_LEVEL=3
等级建议:
| 等级 | 用途 |
|---|---|
| 2 | 只关注错误和警告 |
| 3 | 日常使用:启动结果、卡片结果、异常 |
| 4 | 调试模式切换、射频场和协议状态 |
| 5 | 跟踪寄存器、FIFO 和完整协议过程 |
通用扫描每轮都会重配射频场,因此以下正常阶段日志已经降为 DEBUG:
RF field: initial
RF field: Ready
RF field: TX
RF field: Active
NFC-A configuration complete
这样 INFO 级别不会被重复寄存器状态淹没。
0x50检查顺序:
0x50;观察:
OP
AUX.osc_ok
I_osc
检查 27.12 MHz 晶体、负载电容、焊接和 PCB 走线。
优先检查:
d_res 是否过小;这通常不代表芯片不支持,而是不同协议对接收器和时序更敏感。检查:
先看:
en_fd
efd_o
en_fd != 3:Operation Control 配置失败;en_fd=3,efd_o=0:手机外场没有到达 RFI 检测路径;efd_o=1:软件应进入 Idle,若没有则检查状态机和 I2C;检查:
0xFE;totalSize()。适合:
不太适合:
ST25R3916 只能处理 13.56 MHz NFC/HF RFID,不能读取 EM4100、T5577、HID Prox 等 125 kHz 卡片。
ST25R3916 的特点不是“上手简单”,而是“可控范围大”。本工程把最容易出问题的部分都拆开处理:
osc_ok 确认晶振真正稳定;I_eon 和efd_o;真正决定最终体验的,仍然是三件事:
把 ST25R3916 当成“高级寄存器版读卡器”会觉得它很繁琐;把它当成一颗可以搭建完整 NFC 前端的芯片,很多复杂设计就变得合理了。
代码后续等我调通STM32部分再放上来 如果有需要评估这颗芯片的朋友可以私信 我打包工程()
测试效果视频,检测时按照1S轮询,所以可能显得比较慢
<iframe src="https://player.bilibili.com/player.html?isOutside=true&aid=117064920467499&bvid=BV1aUum67EJm&cid=40747404313&p=1" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"></iframe>
[/i][/i][/i]
ST25R210高性能NFC读卡器:四大核心技术打造长距离、低功耗、强抗干扰体验(文末福利)
【STEVAL-25R200SA评测】DPO动态功率输出的性能
【STEVAL-25R200SA评测】1.环境搭建
ST ST25R系列高性能NFC读卡器新增车规产品,目标应用主打汽车数字钥匙
【STEVAL-25R200SA 测评】低功耗检卡测试
【STEVAL-25R200SA 测评】不同模式的读写距离测试
【STEVAL-25R200SA评测】测评总结
【STEVAL-25R200SA评测】在不同工作负载下的功耗表现,如DPO动态功率输出的性能
【STEVAL-25R200SA评测】天线开发注意事项
【STEVAL-25R200SA评测】多天线切换性能测试
微信公众号
手机版
图标CV到论坛就莫名乱码()
duandijun/ST25R3916_ESP32
代码仓库以及readme源文件 STM32的x-cube-nfc正在移植中
可太全面了