STM32驱动PN532:NFC读卡串口帧格式与校验和实战

发布时间:2026/10/20 3:00:56
STM32驱动PN532:NFC读卡串口帧格式与校验和实战
简介面向采用 STM32 系列微控制器的嵌入式开发者这份驱动解决了 NFC 模块与主控芯片之间通过串口通信的问题尤其适用于物联网终端、门禁识别、移动支付等需要近距离无线通信的场景。压缩包为 RAR 格式大小约 3.7MB共包含 211 个文件其中 43 个 C 头文件与 41 个 C 源文件构成完整嵌入式工程源码32 个汇编启动文件用于处理器初始化25 个目标文件和链接映射文件等则是编译产物方便直接对照烧录与调试同时附有少量说明文档和配置文件便于快速理清工程结构。已有 1804 人学习下载。从内容预览看资源以 stm32f10x 标准外设库为底座包含 USART、RCC、TIM 等驱动模块该驱动适配 STM32F103C8T6 等常见型号可直接基于 HAL 库或标准库迁移。开发者可从中提取 PN532 的串口初始化和中断接收逻辑学习命令帧的封装与解析、NDEF 数据读写函数的实现以及超时和 CRC 错误处理方式从而缩短 NFC 功能开发周期。1. 为什么一块PN532放到STM32上驱动的核心不是串口而是0x00开头那帧数据STM32 配合 PN532 做 NFC 串口驱动是我接过最多人问串口通了但读不到卡的一类项目。MCU 侧 UART 收发完全正常波形也对可 PN532 就是不回 ACK问题几乎都出在串口之外PN532 的 HSU 模式虽然物理上是 UART但传输内容不是裸数据流而是带前导码、长度、校验和的状态帧。驱动这类的核心是把命令帧怎么组、校验和怎么算、响应怎么解析搞清楚而不是调波特率和引脚。这份驱动资源适合正在做 NFC 门禁、消费机、巡检终端以及用 STM32 对接非接触卡读写的嵌入式开发者看完能直接抄走命令通道的代码。2. 读懂PN532的HSU模式串口只是管道帧结构才是协议的关键2.1 别把PN532当读卡器它是NFC控制器我第一次拿到 PN532 时想当然地把它当成 RC522 的升级版发个读卡指令芯片就把卡号吐回来。实际跑通后才发现两者层级完全不同。RC522 是读卡器芯片它只处理 MIFARE 类的模拟前端防碰撞、认证、读写这些协议步骤要主机自己拼。PN532 定位是 NFC 控制器内部固件把 ISO/IEC 14443A/B、FeliCa、MIFARE Classic 的协议栈全吞掉了主机只需要通过串口或 I2C 发送高层命令比如列出当前场里的卡片交换 APDU认证并读写某块芯片自己跟卡片完成底层交互再返回结果。这个差异决定了驱动的写法。PN532 侧的命令集是面向任务的而不是面向寄存器的。你不需要关心它内部怎么调 CRC、怎么处理 REQA只需要在 UART 上按帧格式封装命令码和参数。但也正因为中间隔了一层协议栈PN532 变成了一个黑匣子命令帧错了它不会给你报错原因只是在物理层回一个 NACK 甚至不回响应帧超时了也不告诉你丢在哪个环节。所以驱动调试的第一步不是接天线、贴卡而是先把命令通道两端说同一种话这件事验证掉。选 PN532 而不是更便宜的 RC522本质是拿 MCU 的算力和 Flash 换开发时间。NFC 的防碰撞、多协议支持自己写一遍非常痛苦PN532 把这块固件化了。代价是命令链路更长、帧格式更严格调试时最忌讳拿串口助手的字符串凭感觉拼。2.2 HSU 模式的选脚与电气参数PN532 支持 I2C、SPI、HSU高速 UART三种主机接口HSU 模式走的就是标准 UART 电气信号默认波特率 1152008 数据位、1 停止位、无校验、无流控。这是数据手册明确写的但很多模块出厂默认不是 HSU需要看丝印或者模块背面的模式拨码。我一般用 DIP 开关或者电阻拉选。HSU 模式下的相关引脚如下引脚作用说明RXD接收主机数据接 STM32 的 TXTXD向主机发送数据接 STM32 的 RXIRQ中断输出芯片有响应时拉高建议接外部中断或 GPIO 轮询RSTO复位输出可接到 STM32 GPIO 控制复位时序SEL0/SEL1模式选择具体组合看模块丝印HSU 通常是一组特定电平电气上要注意两点。第一PN532 是 3.3V 逻辑如果 STM32 板子是 5V 供电串口引脚必须确认电平兼容否则 long 时间跑会概率性收发错位第二IRQ 引脚在调试阶段非常关键响应帧到达前它会拉高用轮询 IRQ 而不是盲等串口超时可以少踩很多坑。后面第 4 章的超时重试就是围绕 IRQ 设计的。2.3 PN532 的帧格式前导码、长度、校验、后序码PN532 虽然物理走 UART但一个完整命令帧的结构有点像一个简化版 HDLC 帧和普通串口发字节流完全是两码事。发送给 PN532 的帧格式如下字段长度内容前导码10x00起始码20x00 0xFFLEN1TFI PD0..PDn 的字节数LCS1LEN 的反码加一即 0x100 - LENTFI1主机发芯片是 0xD4芯片回主机是 0xD5PD0..PDn0~253命令码 参数DCS1TFIPD0..PDn 之和的反码加一后序码10x00最坑的就是 LCS 和 DCS 这两个校验字节。它们是对和取补码不是简单异或或者 CRC。网上很多现成例程里直接写死一串十六进制比如00 00 FF 02 FE D4 02 2A 00你抄过去能跑但想自己加一个命令就不会算了。所以驱动里一定要封装一个pn532_build_frame()函数动态算 LEN、LCS、DCS而不是靠手算硬编码。命令响应帧的格式相同区别是 TFI 从 0xD4 变成 0xD5命令码从 0x02 变成 0x03 这类对应关系。收到 ACK 帧时整个帧是00 00 FF 00 FF 00LEN 为 0、LCS 为 0xFF没有命令字节。提示PN532 的命令响应分为 ACK 和正常响应两层。任何命令合法到达芯片都会先回一个 ACK 帧然后再回数据帧。如果连 ACK 都没有说明帧格式或校验有问题根本不用往下排查。3. 打通命令通道串口初始化、唤醒序列与固件版本验证3.1 硬件连接与 USART 初始化参数先给一组我常用的接线方式。STM32 这边我用 USART2PA2 做 TX、PA3 做 RXIRQ 接一个普通 GPIO比如 PB0配置成输入模式用于查询。STM32 引脚PN532 引脚说明PA2 (USART2_TX)RXD主机发送PA3 (USART2_RX)TXD主机接收PB0 (GPIO_IN)IRQ查询响应状态3.3VVCC供电GNDGND共地初始化代码用 HAL 库写逻辑很直接void MX_USART2_UART_Init(void) { huart2.Instance USART2; huart2.Init.BaudRate 115200; huart2.Init.WordLength UART_WORDLENGTH_8B; huart2.Init.StopBits UART_STOPBITS_1; huart2.Init.Parity UART_PARITY_NONE; huart2.Init.Mode UART_MODE_TX_RX; huart2.Init.HwFlowCtl UART_HWCONTROL_NONE; huart2.Init.OverSampling UART_OVERSAMPLING_16; HAL_UART_Init(huart2); GPIO_InitTypeDef gpio {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); gpio.Pin GPIO_PIN_2 | GPIO_PIN_3; gpio.Mode GPIO_MODE_AF_PP; gpio.Pull GPIO_PULLUP; gpio.Speed GPIO_SPEED_FREQ_HIGH; gpio.Alternate GPIO_AF7_USART2; HAL_GPIO_Init(GPIOA, gpio); }代码里最关键的是波特率和硬件流控。PN532 的 HSU 对波特率误差容忍度不算高如果用内部 RC 振荡器跑 115200有些板子会出现偶发包错误建议用外部晶振或者把误差控制在 2% 以内。HwFlowCtl必须是NONEPN532 的 HSU 接口没有 RTS/CTS 信号线开流控反而会让芯片一直卡在等待状态。另外IRQ 引脚不需要做成中断查询就够用。PN532 响应很快绝大多数命令在 10ms 内返回轮询 IRQ 的损耗完全可接受。用外部中断的话还得处理响应帧跨中断的问题反而增加复杂度。3.2 帧封装函数别手算校验和驱动里第一个要写的工具函数是帧封装。输入命令码和参数字节输出完整 PN532 帧。static uint8_t pn532_frame[64]; static int pn532_build_frame(uint8_t cmd, const uint8_t *param, uint8_t param_len) { uint8_t len 1 param_len; // TFI 参数 uint8_t lcs 0x100 - len; uint16_t dcs_sum 0xD4 cmd; for (int i 0; i param_len; i) { dcs_sum param[i]; } uint8_t dcs (uint8_t)(0x100 - (dcs_sum 0xFF)); pn532_frame[0] 0x00; pn532_frame[1] 0x00; pn532_frame[2] 0xFF; pn532_frame[3] len; pn532_frame[4] lcs; pn532_frame[5] 0xD4; // TFI 方向主机到芯片 pn532_frame[6] cmd; if (param_len 0) { memcpy(pn532_frame[7], param, param_len); } pn532_frame[7 param_len] dcs; pn532_frame[8 param_len] 0x00; // 后序码 return 9 param_len; }dcs_sum累加时注意溢出处理(dcs_sum 0xFF)是取低 8 位然后0x100 -得到补码。这个逻辑和 PN532 数据手册里DCS ~(TFIPD0..PDn)1等价。很多人的错误是在累加时用了uint8_t加着加着溢出后取反再1结果在边界值上偏一位命令偶尔能通、偶尔 NACK非常玄学。pn532_frame[3]的 LEN 只统计 TFI 加参数不含前导码、LCS、DCS 和后序码这一点最容易搞混。3.3 发送 GetFirmwareVersion 验证链路通不通拿到驱动先别急着去读卡第一步是发GetFirmwareVersion命令码 0x02把固件版本号读回来。这条命令没有参数用封装函数发出去后的完整帧是00 00 FF 02 FE D4 02 2A 00发送函数我这样写static int pn532_send_frame(const uint8_t *frame, int len) { HAL_UART_Transmit(huart2, (uint8_t *)frame, len, 100); uint32_t start HAL_GetTick(); // 先等 ACK 帧 while (HAL_GetTick() - start 200) { if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0) GPIO_PIN_SET) { uint8_t ack[6]; if (HAL_UART_Receive(huart2, ack, 6, 20) HAL_OK) { if (ack[0] 0x00 ack[1] 0x00 ack[2] 0xFF ack[3] 0x00 ack[4] 0xFF) { return 0; // ACK 已收到 } } } } return -1; }这个函数的核心是IRQ 先拉高再收 ACK。PN532 收到合法帧后会把 IRQ 拉高表示有数据要发给主机这时读串口才稳妥。如果直接HAL_UART_Receive阻塞等遇到芯片没被唤醒的情况会干等超时。ACK 帧的格式是00 00 FF 00 FF 00长度固定 6 字节识别特征就是LEN0且LCS0xFF。这里没校验后序码因为 ACK 帧太短校验后序码意义不大。GetFirmwareVersion的响应帧大概是00 00 FF 08 FE D5 03 xx xx xx xx xx 00其中D5是芯片回主机的 TFI03是命令码的响应值后面几个字节分别代表固件版本、修订号、支持协议。能收到这帧说明 UART 参数、帧封装、校验逻辑全对了后面再做 SAMConfig 和读卡才有意义。4. 让驱动真正能干活DCS校验、超时重试与SAMConfig缺一不可4.1 校验和计算的歧义几种常见算错方式网上能找到的 PN532 例程里校验和部分错误率极高。我见过最典型的是直接抄一个六字节串号称官方帧里面 DCS 是错的但因为部分模组固件不校验 DCS居然也能跑。等你换一个批次的模块命令就全部 NACK还以为是硬件坏了。LCS 和 DCS 的正确算法在 3.2 节的代码里已经体现但有几个边界情况值得再说。第一LEN 包含了 TFI所以 GetFirmwareVersion 的 LEN 是 2 而不是 1第二DCS 计算时要把 TFI 算进去不是只算命令码和参数第三如果求和低 8 位恰好是 0x000x100 - 0x00得到 0x100截断成 uint8_t 后变成 0x00这个结果是合法的不要人为去改。最常见的翻车写法是uint8_t dcs 0x100 - (sum 0xFF); // 如果 sum0xFF 是 0这里得到 0x00截断没问题但有人会写成dcs ~sum 1在 sum 是 int 型时~sum 是反码结果等于补码但有人顺手写成dcs ~(sum 0xFF) 1就会在 sum 低 8 位是 0 时得到 0x100再截断成 0x00反而对。这种错错得对的代码在网络上到处都是很坑。所以驱动里我坚持用减法写法语义和手册一致不容易被优化掉。另一个容易出错的是响应帧的校验。主机收到 PN532 返回的帧后也要验 LCS 和 DCS。很多驱动只验 LEN 和 TFI不验 DCS这在干扰大的环境里会造成响应帧解析错位读卡命令间歇性失败。static int pn532_verify_frame(const uint8_t *buf, int len) { int data_len buf[3]; if (buf[4] ! (uint8_t)(0x100 - data_len)) return -1; uint16_t sum buf[5]; // TFI for (int i 0; i data_len - 1; i) { sum buf[6 i]; } if (buf[6 data_len - 1] ! (uint8_t)(0x100 - (sum 0xFF))) return -1; return 0; }参数说明buf[3]是 LENbuf[4]是 LCSbuf[5]是 TFIbuf[6]开始是命令响应码和参数。这个函数返回 0 表示响应帧结构合法。注意 LEN 已经包含 TFI所以循环取data_len - 1个字节最后一个字节就是 DCS别把 DCS 当成数据。4.2 超时重试与 IRQ 配合命令通道的可靠性设计PN532 在空闲时会进入省电状态第一帧发过去有时候芯片还没来得及完全唤醒IRQ 迟迟不拉高。这跟串口发出去就完了完全不同。所以我一般在发送函数里做两层超时第一层等 ACK第二层等数据帧两层独立超时。一个经过多次验证的发送-接收流程int pn532_exchange(uint8_t cmd, const uint8_t *param, uint8_t param_len, uint8_t *resp, uint8_t max_resp_len, uint32_t timeout_ms) { int frame_len pn532_build_frame(cmd, param, param_len); HAL_UART_Transmit(huart2, pn532_frame, frame_len, 50); uint32_t start HAL_GetTick(); while (HAL_GetTick() - start timeout_ms) { if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0) GPIO_PIN_SET) { uint8_t ack[6]; if (HAL_UART_Receive(huart2, ack, 6, 20) HAL_OK) { // 读取数据帧 uint8_t header[6]; if (HAL_UART_Receive(huart2, header, 6, 50) ! HAL_OK) return -1; int resp_len header[3] 1; if (resp_len max_resp_len) return -2; memcpy(resp, header, 6); HAL_UART_Receive(huart2, resp[6], resp_len - 1, 50); if (pn532_verify_frame(resp, resp_len 6) ! 0) return -3; return resp_len 6; } } } return -1; }这个函数有几点设计取舍。收到 ACK 后立刻读 6 字节 header其中header[3]是 LEN整个数据帧长度是 LEN 6多了前导码、起始码、LEN、LCS 和后序码。不直接按最大长度收是为了适应不同命令的响应长度差异InListPassiveTarget 的响应长度和卡片数量有关写死长度容易把下一帧的起始字节吞掉。超时参数的选取要平衡。等 ACK 用 200ms等数据帧用 50ms完整交换超时建议放到 500ms。NFC 读卡过程有寻卡、防碰撞、认证、读写每一步都是独立的pn532_exchange单步超时太长会导致整体响应变慢。4.3 SAMConfig 是大部分人忽略的第一步PN532 上电后不会自动进入读卡状态必须先发送SAMConfig命令码 0x14把安全访问模块配置好然后才能执行寻卡命令。这一步漏掉后面所有InListPassiveTarget都会返回超时或状态错误。常见配置是模式 0x01、超时 0x14、IRQ 使能 0x01uint8_t sam_param[3] {0x01, 0x14, 0x01}; pn532_exchange(0x14, sam_param, 3, resp, sizeof(resp), 200);参数说明0x01是普通模式0x14是 50ms 左右的寻卡超时这个值和具体固件版本有关我一般配 0x14没有出过问题0x01表示允许芯片用 IRQ 引脚通知主机。这里要注意SAMConfig 的响应只是确认不包含数据读resp时主要看返回码是不是 0x15SAMConfig 的响应命令码。我把 SAMConfig 放在系统初始化的末尾每次上电都执行一遍。即使之前配置过重新配置也无害但能避免某些复位时序导致芯片状态异常的情况。5. 常见问题排查从没响应到认证失败的四个实操坑5.1 现象串口发出去数据芯片完全无 ACK发任何命令IRQ 始终不拉高示波器看 TX 引脚有波形但 PN532 就是不理你。原因有几种按概率排序PN532 没进入 HSU 模式默认跑在 I2C 或 SPIUART 引脚根本没使能帧格式里前导码或起始码以0x00 0x00 0xFF开头但发送太快芯片还在复位LCS 算错芯片直接丢弃帧。解决先看模块丝印把模式切到 HSU然后手工把波特率降到 9600 试一下排除晶振频偏再用逻辑分析仪抓发送波形确认帧头确实以00 00 FF开头。最后如果还不行用万用表量 PN532 的 3.3V 供电有些模块天线电路对电源纹波敏感供电不足时芯片URT不工作。注意别一上来就怀疑芯片坏了。PN532 坏的概率远低于接线和配置出错的概率先检查模式引脚再检查帧头最后才是硬件。5.2 现象收到 ACK但没有响应帧命令发出去能收到00 00 FF 00 FF 00但之后 IRQ 保持低电平永远等不来数据帧。原因是命令参数不合法或被芯片认为是无效命令。比如在没配置 SAMConfig 时直接发InListPassiveTarget芯片会回一个错误状态帧但如果你超时设置太短这个帧可能还没读完就被判定超时了。另外某些命令要求参数长度固定多传一个字节或少传一个字节芯片都不响应。解决先把响应超时拉到 500ms排除时序问题然后逐条命令对比数据手册检查参数数量和含义最后用官方上位机软件连同一块 PN532 执行相同命令对比返回帧是否一致。如果官方软件能返回说明命令本身没问题问题在驱动封装。5.3 现象能读到 UID但 MIFARE 认证和读写失败寻卡正常UID 能拿到但发认证命令0x60失败或者读写块返回错误码。这个问题最容易让人怀疑卡坏了其实大多是两个原因第一MIFARE Classic 每个扇区有独立的密钥 A 和密钥 B不是全卡一个密钥默认密钥0xFFFFFFFFFFFF只对部分新卡有效旧卡片可能被改过密钥第二认证状态不能跨扇区操作扇区 0 成功后接着操作扇区 1必须重新对扇区 1 的某个块做认证不重新认证直接读写会失败。解决先用空卡确认默认密钥可用然后在驱动里设计成每次换扇区都重新认证不要缓存认证状态。读写命令之间不要插入其他命令每一条 MIFARE 操作都走认证 - InDataExchange 读/写的完整流程。// 示例读 MIFARE Classic 某一块 uint8_t key[6] {0xFF,0xFF,0xFF,0xFF,0xFF,0xFF}; uint8_t auth_cmd[10] {0x01, 0x60, block, key[0], key[1], key[2], key[3], key[4], key[5]}; // auth_cmd 实际是 InDataExchange 的参数帧封装由 pn532_exchange 完成这里0x60是认证命令block是要认证的块号认证时传入该扇区的任意块号即可。注意认证和随后读写的目标编号0x01是同一个也就是 InListPassiveTarget 返回的卡片逻辑编号。5.4 现象IRQ 一直高电平驱动死循环IRQ 引脚收到 ACK 后一直为高程序在等待数据帧时卡死或者收到一帧后 IRQ 仍为高连续读串口读到乱码。原因是 IRQ 拉高的条件不止有数据可读芯片内部状态异常时也会拉高 IRQ。尤其在上电后立刻发命令、复位时序紊乱时芯片可能主动上报错误事件而这些事件不是你请求的响应帧。解决多读一帧并检查 TFI。PN532 的上报帧也是00 00 FF开头但 TFI 可能是0xD5也可能是错误码0x7F。驱动里收完一帧后如果 IRQ 还是高的就继续读下一帧直到 IRQ 变低再恢复主流程。同时确认 RSTO 引脚在上电后保持至少 10ms 低电平再拉高确保芯片完成上电复位。6. 进阶从读UID到写MIFARE Classic 1K的完整时序当你把读 UID 跑通之后真正的应用场景往往需要写卡比如初始化白卡、写入扇区数据。MIFARE Classic 1K 的写流程比单纯寻卡复杂一个量级我建议按下面这个顺序来写稳很多。先做 SAMConfig再InListPassiveTarget找卡拿到卡号和卡片逻辑编号然后对目标块认证认证通过后用InDataExchange发送块写命令0xA0跟着块号和 16 字节数据。每写一个块理论上可以复用同一个认证状态但实际操作中我一般每写一个块都重新认证一遍成本只是多一次命令交换换来的是确定性和稳定性尤其在天线匹配不太好的板子上。// 写数据块 6 uint8_t block_data[16] {0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08, 0x09,0x0A,0x0B,0x0C,0x0D,0x0E,0x0F,0x10}; uint8_t auth[10]; auth[0] 0x01; // 卡片逻辑编号 auth[1] 0x60; // 认证命令 auth[2] 6; // 块号 memcpy(auth[3], key, 6); // 扇区密钥 pn532_exchange(0x40, auth, 9, resp, sizeof(resp), 300); // 认证通过后写块 uint8_t write_cmd[19]; write_cmd[0] 0x01; // 同一张卡 write_cmd[1] 0xA0; // 写块命令 write_cmd[2] 6; // 目标块号 memcpy(write_cmd[3], block_data, 16); pn532_exchange(0x40, write_cmd, 18, resp, sizeof(resp), 300);0x40是 InDataExchange 命令码auth数组的第一个字节是卡片逻辑编号这个编号由InListPassiveTarget的响应返回通常是 0x01。每次写卡前检查resp里的状态字节不为0x00就打印出来定位不要静默忽略。有一个跨度很大的坑是块号与扇区的关系。MIFARE Classic 1K 有 16 个扇区每扇区 4 块块号直接编号从 0 到 63。第 0 扇区的第 0 块是厂商信息理论上不可写扇区尾部块块号 % 4 3是存放密钥和访问位的直接写数据会破坏卡片结构。所以写数据时只写每个扇区的前 3 块。最后再说一个跟串口驱动本身相关的经验调这种东西日志里必须能看到完整帧。我从那以后每次调 NFC 驱动都强制把发送帧和响应帧逐字节打印出来哪怕看着乱也打核对校验和、核对状态码省下的查错时间远超打印占用的开销。写卡失败的时候也先怀疑日志而不是怀疑天线。希望这篇笔记里的帧格式、校验逻辑和那几个典型坑能帮你在自己的板子上少走几轮弯路。本文还有配套的精品资源点击获取