STM32通过I2C与PN532读取NFC UID的工程实践
发布时间:2026/10/13 13:10:17 锦皓数字建站

简介近场通信NFC在门禁、支付和身份识别中广泛应用每张NFC卡拥有唯一的UID标识读取UID是许多物联网项目的基础需求。实现该需求通常需要一颗NFC读卡芯片如PN532与主控MCU协同工作。PN532支持I2C/SPI/HSU多种接口其中I2C因接线简单且可直接挂接STM32的I2C外设成为小数据量读卡场景的优选。其核心原理是通过I2C发送ISO14443A寻卡命令帧等待IRQ中断信号后读取响应从回帧中解析出UID长度和卡号。这种方案响应快、代码轻量广泛适用于门禁模拟、设备认证等场景。该工程基于STM32 HAL库结合PN532 I2C接口提供了一个最小可跑的UID读取示例涵盖硬件接线、帧格式计算、代码实现与常见踩坑排查可直接复用于新板卡验证。1. 用 I2C 把 NFC 卡号搬到 STM32一套刚够用的读 UID 工程某次在模拟项目X里要给手头的门禁板加 NFC 识别翻遍资料后发现 PN532 不止能做 NDEF 读写最简单的用法其实是把卡片的 UID 当 ID 用。stm32-PN532-i2c-read-uid 就是这样一个最小可跑工程STM32 通过 I2C 接口控制 PN532主动发起 ISO14443A 寻卡几毫秒内返回卡号并打印到串口。它没有 P2P、没有钱包文件适合刚接触 NFC 的开发者先跑通链路也适合熟手在一个新板子上快速验证 PN532 模块是否正常。下面按硬件、帧格式、代码和实际踩坑来拆。2. 硬件与帧格式PN532 的 I2C 接线、地址和命令帧怎么对齐很多人第一步就选错接口。PN532 有三种对外接口HSU类 UART、SPI、I2C。I2C 的优势在于连线少SCL/SDA 两根数据线 IRQ 一根事件线且 STM32 的硬件 I2C 外设直接支持缺点是速率上限 400kbps但读 UID 这种小数据量完全够用。如果你后面要做大段 NDEF 读写才需要认真考虑 SPI。我一般在这类只读卡号的项目里固定用 I2C。2.1 接线和供电别只看那四根杜邦线PN532 模块常见引脚包括 VCC、GND、SDA、SCL、IRQ、RSTO 等。要特别注意的是供电PN532 射频部分启动瞬间电流比单片机外设大个别模块光靠 STM32 板上的 3.3V 引脚会掉电压导致寻卡不稳定。稳妥的做法是给模块单独供 5V板载稳压再转成 3.3V 给芯片和天线匹配网络。PN532 引脚STM32 引脚说明VCC5V或 3.3V建议 5V 进模块模块自带 3.3V LDOGNDGND共地SDAI2C1_SDA开漏需要上拉SCLI2C1_SCL开漏需要上拉IRQ任意 GPIO 输入低电平表示 PN532 有数据待读RSTO任意 GPIO 输出可选拉低复位软件恢复用接线部分还要留意模块背面的模式选择跳线。市面上多数 PN532 模块默认 I2C但也有人买到手发现模块工作在 SPI 或 HSU 模式I2C 扫描完全没有 ACK。建议第一次拿到模块时先看板载丝印和原理图SEL 相关跳线要确认组合对应的是 I2C不要盲目照抄别人的接线。我的习惯是先用一个 2 秒的 I2C 扫描程序把 0x00~0x7F 扫一遍确认 ACK 出现在哪个地址再继续。扫描代码很简单就是用HAL_I2C_Master_Transmit对每个地址发一个空字节看返回的是 HAL_OK 还是 HAL_ERROR这一步能排除一半的硬件问题。2.2 设备地址0x24 和 0x48 的坑先从这说清PN532 的 I2C 7 位地址默认是 0x24在 STM32 HAL 里HAL_I2C_Master_Transmit()的DevAddress参数是 8 位地址格式所以传给 HAL 的是0x24 1 0x48。这是第一个高频翻车点有人直接把 0x24 填进 HAL导致模块不应答HAL 返回超时。某些模块通过跳线可以把地址切到 0x258 位形式 0x4A。因此代码里我一般这样定义#define PN532_I2C_ADDR7BIT (0x24u) /* 7-bit 默认地址 */ #define PN532_I2C_ADDR8BIT (PN532_I2C_ADDR7BIT 1) /* HAL 用 0x48 */如果扫描发现 ACK 在 0x4A再改成(0x25u 1)。地址错误的表现很有迷惑性命令发送返回 HAL_OK但 PN532 一直不拉 IRQ或者读回来的全是 0xFF。原因在于 I2C 从机不应答时HAL 一般会在超时后返回错误但某些底层驱动做了重试掩盖问题。所以接收不到数据时先确认地址对不对再去查时序。2.3 PN532 帧格式LEN、LCS、DCS 三个字节算明白PN532 的命令帧不是裸数据有一套固定包裹格式字段长度字节说明Preamble2固定00 00Start Code1固定FFLEN1TFI 数据段的长度LCS1LEN 的补码LCS 0x100 - LENTFI1方向标识命令为D4响应为D5DATA0~252具体指令与参数DCS1校验码DCS 0x100 - ((TFI DATA) 低 8 位)Postamble1固定00校验和计算方式有严格约定LCS 让LEN LCS 0x100DCS 让TFI DATA DCS的低 8 位等于 0。以读卡命令InListPassiveTarget为例指令码是0x4A参数为 MaxTg1、BaudRate0106kbps完整命令帧为00 00 FF 04 FC D4 4A 01 00 E1 00拆开看04是 LENFC是 LCSD4是命令方向4A是指令码01 00是参数E1是 DCS结尾00收尾。E1的计算是0x100 - ((0xD4 0x4A 0x01 0x00) 0xFF)括号内和的低字节是0x1F补码就是0xE1。在代码里不要手写这些常量用函数算不然换指令时很容易算错。2.4 ACK 与 IRQ响应流程决定了你的等待策略PN532 收到完整命令帧后会先回一个 6 字节 ACK 帧00 00 FF 00 FF 00表示命令已受理。随后在处理完成时把 IRQ 引脚拉低表示主机可以读取结果帧。I2C 模式下不太适合用固定延时代替 IRQ因为不同命令耗时差异大例如寻卡命令在卡片不在场时会经历完整的射频轮询周期延时不准确就可能导致读到不完整帧或读到上一条命令的残留数据。我使用的策略是发送命令后先轮询 IRQ超时 300ms 再放弃。这个 300ms 不是拍脑袋定的PN532 一轮寻卡在无卡场景下通常需要几十毫秒到一百多毫秒给 300ms 足够覆盖大部分异常情况又不至于让主循环卡太久。注意IRQ 是低有效拉低表示“有数据待读”。如果模块的 IRQ 引脚悬空则必须靠延时等待稳定性会差很多。3. STM32 代码实现I2C 命令收发、IRQ 等待与 UID 解析硬件链路通了以后代码就可以分成三层CubeMX 外设初始化、PN532 命令收发工具函数、UID 解析逻辑。下面按这三个层次给出一套可直接照搬进工程的写法代码基于 STM32 HAL 库不依赖第三方库。3.1 CubeMX 初始化I2C、串口和 IRQ 引脚的配置在 CubeMX 里把 I2C1 开启速率按模块能力设成 400kHz 或保守的 100kHz。PN532 支持到 400kbps但如果你手上的模块线比较长走 100kHz 更不容易受干扰。IRQ 引脚配成 GPIO 输入内部上拉可开可不开——模块 IRQ 本身是开漏输出开内部上拉不会出错。为了方便调试同时启用一个 UART115200 8N1把 UID 打印到串口。配置项推荐值说明I2C1 Speed ModeFast Mode / Standard Mode按线长选择默认 400kHzI2C1 地址格式7-bitHAL DevAddress 仍传 8 位格式IRQ GPIO输入无上下拉低电平有效UART 波特率115200日志输出用我习惯把 PN532 相关引脚和常量集中放在一个pn532.h头文件里管理避免在业务代码里散落魔数。/* pn532.h */ #ifndef __PN532_H #define __PN532_H #include stm32f1xx_hal.h #define PN532_I2C_ADDR8BIT (0x24u 1) /* HAL 需要 8 位地址 */ #define PN532_IRQ_TIMEOUT_MS 300u /* 等待 PN532 响应超时 */ #define PN532_CMD_INLIST_PASSIVE_TARGET 0x4Au /* 寻卡指令 */ int PN532_ReadUID(uint8_t *uid, uint8_t *uid_len); void PN532_PrintUID(const uint8_t *uid, uint8_t uid_len); #endifPN532_IRQ_TIMEOUT_MS是给 IRQ 轮询设的上限。如果超时时间太短卡片不在场时命令还没走完一轮天线寻卡就会被放弃太长则无卡时会拖慢主循环。300ms 是一个均衡值实际调试时可以先用 500ms 观察一轮再收紧。3.2 命令帧拼装与 I2C 收发发一帧、等一个低电平、读一块先写两个校验计算函数。不要把 0xE1 这类常量和命令写死在一起换成参数计算后续加 P2A、读 NDEF 命令时可以直接复用。static uint8_t PN532_CalcLCS(uint8_t len) { return (uint8_t)(0x100u - len); } static uint8_t PN532_CalcDCS(uint8_t tfi, const uint8_t *data, uint8_t len) { uint32_t sum tfi; for (uint8_t i 0; i len; i) { sum data[i]; } return (uint8_t)(0x100u - (sum 0xFFu)); }LEN是 TFI 加数据段的字节数LCS 只针对 LEN在 DCS 里要把 TFI 和数据段一起累加取低 8 位的补码。这两段逻辑不能混用否则校验永远错。发送命令的代码如下static uint8_t PN532_Buf[16]; int PN532_WriteCommand(void) { uint8_t i; uint8_t len 0x04u; /* TFI(1) 参数(3) */ uint8_t data[3] {0x4Au, 0x01u, 0x00u}; /* InListPassiveTarget: MaxTg1, BaudRate106k */ PN532_Buf[0] 0x00u; /* Preamble */ PN532_Buf[1] 0x00u; PN532_Buf[2] 0xFFu; /* Start Code */ PN532_Buf[3] len; /* LEN 0x04 */ PN532_Buf[4] PN532_CalcLCS(len); /* LCS 0xFC */ PN532_Buf[5] 0xD4u; /* TFI: 命令方向 */ for (i 0; i 3; i) { PN532_Buf[6 i] data[i]; /* 指令码 参数 */ } PN532_Buf[9] PN532_CalcDCS(0xD4u, data, 3); PN532_Buf[10] 0x00u; /* Postamble */ /* HAL 的 DevAddress 是 8 位0x48 */ return HAL_I2C_Master_Transmit(hi2c1, PN532_I2C_ADDR8BIT, PN532_Buf, 11, 100); }这里data[3]分别是0x4AInListPassiveTarget、0x01最多检测 1 张卡、0x00106 kbps 的 ISO14443A。如果你遇到的是某些国产模块手册里 BaudRate 定义可能反过来0x00无效时改成0x01再做一次 DCS 计算即可不要只改参数不改校验。发送完命令后PN532 会先回 ACK。ACK 帧虽然可以不读但读掉它能让后续响应帧的字节对齐更干净。我一般用 IRQ 引脚作为“数据可读”信号static int PN532_WaitIRQ(uint32_t timeout_ms) { uint32_t tick HAL_GetTick(); while (HAL_GPIO_ReadPin(IRQ_GPIO_Port, IRQ_Pin) GPIO_PIN_SET) { if ((HAL_GetTick() - tick) timeout_ms) { return -1; /* 超时无响应 */ } } return 0; }GPIO_PIN_SET表示 IRQ 空闲高电平循环等到它变低再返回。超时后不要立刻重发命令可以先读一下总线看是否有残留数据避免造成帧错位。3.3 读取响应帧跳帧头、找 TFI、按 NFCID1Len 取 UIDPN532 的响应帧同样是00 00 FF LEN LCS D5 ...开头其中 TFI 为0xD5。对于寻卡命令对应的指令应答是0x4B。如果卡片不在场NbTg为 0表示本次寻卡没结果不是错误应用层应当区分这两种情况。static uint8_t PN532_RxBuf[64]; int PN532_ReadResponse(void) { return HAL_I2C_Master_Receive(hi2c1, PN532_I2C_ADDR8BIT, PN532_RxBuf, 32, 200); }这里按 PN532 I2C 接口常见限制一次最多读 32 字节读 UID 场景足够。如果后续要读 NDEF 大数据块不能简单放大这个 32需要做分段接收或改 SPI 接口否则 PN532 会按自己的节奏把剩余数据留在内部缓冲造成后续时序错乱。响应解析我采用“扫描 0xD5 0x4B”的方式而不是固定偏移这样即使前面多了 ACK 或其他帧头也不会解析错位int PN532_ParseUID(const uint8_t *buf, int len, uint8_t *uid, uint8_t *uid_len) { int i; for (i 0; i len - 1; i) { if (buf[i] 0xD5u buf[i 1] 0x4Bu) { int p i 2; uint8_t nbtg buf[p]; /* 检测到的卡数量 */ if (nbtg ! 1u) { return -1; /* 无卡或大于1张 */ } p 2; /* 跳过 SensRes 2 字节 */ uint8_t nfcid1_len buf[p]; /* UID 长度通常 4 或 7 */ if (nfcid1_len 7u || nfcid1_len 0u) { return -2; } memcpy(uid, buf[p], nfcid1_len); *uid_len nfcid1_len; return 0; } } return -3; }ParseUID里的p是动态游标不硬编码偏移主要原因是不同 PN532 固件版本响应帧可能有细微差异。nfcid1_len是卡上 UID 的真实长度MIFARE Classic 系列为 4 字节MIFARE Ultralight / NTAG21x 为 7 字节。如果代码里写死 47 字节卡的 UID 会只显示前 4 字节卡号不完整。把前面几步串成一个完整读卡接口int PN532_ReadUID(uint8_t *uid, uint8_t *uid_len) { int ret PN532_WriteCommand(); if (ret ! HAL_OK) return -1; if (PN532_WaitIRQ(PN532_IRQ_TIMEOUT_MS) ! 0) return -2; ret PN532_ReadResponse(); if (ret ! HAL_OK) return -3; return PN532_ParseUID(PN532_RxBuf, 32, uid, uid_len); }PN532_ReadUID内部按“发命令 → 等 IRQ → 读响应 → 解析”的流程组织调用方不需要关心帧格式细节。主循环里只需要处理返回值uint8_t uid[7]; uint8_t uid_len 0; while (1) { if (PN532_ReadUID(uid, uid_len) 0) { printf(UID:); for (uint8_t i 0; i uid_len; i) { printf( %02X, uid[i]); } printf(\r\n); } HAL_Delay(1000); }这里的空卡检查也别省略无卡时ReadUID返回 -1主循环应当跳过打印。如果返回 -2 或 -3说明模块通信不正常可以重启模块或检查总线。3.4 一张真卡片的完整响应从字节到卡号的解码样例假设你放上去的是一张 4 字节 UID 的 MIFARE Classic 卡PN532 可能返回如下数据示意帧实际卡片 UID 不同00 00 FF 0C F4 D5 4B 01 01 00 04 1A 22 3B C4 03 60 00其中D5 4B是寻卡应答标识01是卡数量01 00是 SensRes04表示 UID 长度 4紧接着的1A 22 3B C4就是卡号。后面的03 60是 SDD 结果和 SEL_RES不用管。解析函数从扫描到D5 4B后依次跳过固定字段正好落到这个位置。如果你手里是 NTAG213这个长度字段会变成07随后是 7 字节 UID。同样一套解析逻辑可以直接复用不需要为不同卡类型写两套代码。4. 避坑排查地址、模式、供电和响应帧的五个常见雷区这部分内容是我每次换板子都会过一遍的清单很多问题不是代码写错而是硬件状态或模块配置没对齐。4.1 通信层翻车I2C 地址、总线卡死和上电时序踩坑 1HAL 返回 HAL_OK但模块从不拉低 IRQ。现象是命令发送成功但 IRQ 永远高超时退出读回来的数据也是全 0xFF。原因是 I2C 地址传了 7 位格式 0x24HAL 按 8 位地址去匹配从机总线 NACK 后被底层当成“暂无数据”。解决方法是把地址统一改成0x24 1 0x48并在初始化后做一个HAL_I2C_Master_Receive(hi2c1, 0x48, tmp, 1, 100)探测看返回是否为 HAL_OK。如果返回 HAL_ERROR多半是接线或模块模式问题。踩坑 2长时间运行后 I2C 卡死SCL 一直被拉低。现象是程序跑几小时后第一次读卡正常之后所有 I2C 传输超时复位 MCU 也无效。原因是总线上有设备把 SCL/SDA 锁死常见于两根线没有上拉电阻或模块接线端子松动导致某根线虚接。解决方法是先测静态电平SCL、SDA 都应当是高电平如果其中一根被拉低断掉 PN532 供电再测。我从那以后在硬件上强制加了两个 4.7k 上拉电阻再没遇到这种问题。踩坑 3发送命令后立刻读读到的是上次的残留帧。现象是第一次读卡结果正确第二次换一张卡后打印的还是上一张卡的 UID。原因是没有等 PN532 处理完就把整条命令发出两帧数据在总线上重叠。解决方法是发送命令前先确认 IRQ 为高电平如果仍为低说明 PN532 内还有残留数据调用一次PN532_ReadResponse()清空再发当前命令。每次发命令之间保持至少 5ms 间隔也能减少这种错位。4.2 读卡层玄学天线场、卡类型和响应帧解析对不上踩坑 4卡片明明贴在天线上但NbTg一直为 0。现象是命令执行成功返回帧也正常就是检测不到卡。原因绕了一圈发现是供电不足PN532 天线场强不够3.3V 供电路径上线压降太大。解决方法是把模块 VCC 改到 5V并在卡片贴近时观察模块的 LED/天线指示灯。此外天线区和卡片也要对齐PN532 的感应区在 PCB 天线线圈的正面卡片不要放在模块背面。踩坑 57 字节 UID 卡只读出前 4 字节。现象是同一张 NTAG215 卡换成 MIFARE Classic 后打印的 UID 长度还是 4后面三位丢了。原因是解析代码写死了NFCID1Len4没有读取响应帧里的长度字段只拷贝了前四个字节。解决方法是按响应里的NFCID1Len4 或 7动态决定拷贝长度并确认uid[7]的缓冲区放得下。在工程里我还会加一条告警当长度小于 2 或大于 7 时直接丢弃数据帧不参与业务逻辑。5. 验证与加分项同一张卡读出同一个号才算过关工程能出数后别急着接业务先做一次稳定性验证固定一张卡连续刷卡二十次UID 的字节序列必须逐位一致然后用读卡器的显示和手机 NFC 读取工具对比Type A 卡的 UID 应当完全匹配。这一步能帮你区分“协议栈正常”和“模块天线时好时坏”。我还会在主循环里加一个去重状态机避免卡片放在天线区时业务逻辑被反复触发enum { WAIT_CARD, WAIT_RELEASE } nfc_state WAIT_CARD; while (1) { uint8_t uid[7], uid_len 0; int ret PN532_ReadUID(uid, uid_len); if (nfc_state WAIT_CARD) { if (ret 0) { printf(CARD %02X%02X%02X%02X\r\n, uid[0], uid[1], uid[2], uid[3]); nfc_state WAIT_RELEASE; } } else { if (ret ! 0) { nfc_state WAIT_CARD; /* 卡片已拿开允许下一次 */ } } HAL_Delay(200); }WAIT_CARD状态下读到 UID 后立刻进入WAIT_RELEASE之后即使卡片一直放在天线上也不会重复刷卡直到读不到卡才回到WAIT_CARD。这个结构配合 200ms 轮询间隔比在业务侧做时间戳过滤简单得多。最后补一个容易被忽略的细节PN532 模块的 IRQ 引脚如果长期闲置I2C 轮询会变成盲等。我把 IRQ 超时时间做成了可配置调试新板子时先用 500ms 跑一轮确认稳定后再缩到 300ms。从那以后我每次搭 NFC 读卡工程都会强制走一遍“先扫描地址、再验 ACK、最后做连续读卡一致性测试”的流程这套流程帮我把翻车概率压低了很多。如果你也要在 STM32 上做 NFC 读卡这套流程可以直接平移到你的工程里希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。