简介:近场通信(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 引脚 | 说明 |
|---|---|---|
| VCC | 5V(或 3.3V) | 建议 5V 进模块,模块自带 3.3V LDO |
| GND | GND | 共地 |
| SDA | I2C1_SDA | 开漏,需要上拉 |
| SCL | I2C1_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 返回超时。
某些模块通过跳线可以把地址切到 0x25(8 位形式 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 的命令帧不是裸数据,有一套固定包裹格式:
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| Preamble | 2 | 固定00 00 |
| Start Code | 1 | 固定FF |
| LEN | 1 | TFI + 数据段的长度 |
| LCS | 1 | LEN 的补码,LCS = 0x100 - LEN |
| TFI | 1 | 方向标识,命令为D4,响应为D5 |
| DATA | 0~252 | 具体指令与参数 |
| DCS | 1 | 校验码,DCS = 0x100 - ((TFI + DATA) 低 8 位) |
| Postamble | 1 | 固定00 |
校验和计算方式有严格约定:LCS 让LEN + LCS = 0x100,DCS 让TFI + DATA + DCS的低 8 位等于 0。以读卡命令InListPassiveTarget为例,指令码是0x4A,参数为 MaxTg=1、BaudRate=0(106kbps),完整命令帧为:
00 00 FF 04 FC D4 4A 01 00 E1 00拆开看:04是 LEN,FC是 LCS,D4是命令方向,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 本身是开漏输出,开内部上拉不会出错。为了方便调试,同时启用一个 UART,115200 8N1,把 UID 打印到串口。
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| I2C1 Speed Mode | Fast Mode / Standard Mode | 按线长选择,默认 400kHz |
| I2C1 地址格式 | 7-bit | HAL 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: MaxTg=1, BaudRate=106k */ 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]分别是0x4A(InListPassiveTarget)、0x01(最多检测 1 张卡)、0x00(106 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 取 UID
PN532 的响应帧同样是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 字节。如果代码里写死 4,7 字节卡的 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是 SensRes,04表示 UID 长度 4,紧接着的1A 22 3B C4就是卡号。后面的03 60是 SDD 结果和 SEL_RES,不用管。解析函数从扫描到D5 4B后依次跳过固定字段,正好落到这个位置。
如果你手里是 NTAG213,这个长度字段会变成07,随后是 7 字节 UID。同样一套解析逻辑可以直接复用,不需要为不同卡类型写两套代码。
4. 避坑排查:地址、模式、供电和响应帧的五个常见雷区
这部分内容是我每次换板子都会过一遍的清单,很多问题不是代码写错,而是硬件状态或模块配置没对齐。
4.1 通信层翻车:I2C 地址、总线卡死和上电时序
踩坑 1:HAL 返回 HAL_OK,但模块从不拉低 IRQ。现象是命令发送成功,但 IRQ 永远高,超时退出,读回来的数据也是全 0xFF。原因是 I2C 地址传了 7 位格式 0x24,HAL 按 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 天线线圈的正面,卡片不要放在模块背面。
踩坑 5:7 字节 UID 卡只读出前 4 字节。现象是同一张 NTAG215 卡,换成 MIFARE Classic 后打印的 UID 长度还是 4,后面三位丢了。原因是解析代码写死了NFCID1Len=4,没有读取响应帧里的长度字段,只拷贝了前四个字节。解决方法是按响应里的NFCID1Len(4 或 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 读卡,这套流程可以直接平移到你的工程里,希望帮到你。
本文还有配套的精品资源,点击获取