STM32与PN532串口NFC读卡驱动开发实战
简介:面向STM32平台NFC功能开发的PN532串口驱动工程,适合嵌入式开发者、物联网与移动支付方向的学习者参考。内容围绕STM32的UART外设配置、PN532主机初始化、通信帧格式与校验和解析、NFC卡片读写及超时和错误码处理展开,并给出中断方式优化通信实时性的实现思路,可帮助读者理解从底层串口收发到上层读写卡片的完整链路。工程还保留STM32F10x标准外设库相关源文件与示例应用,可据此对照串口初始化、命令收发和卡片数据交互流程。压缩包内共211个文件,整体约3.7MB,以43个h头文件、41个c源文件、32个s汇编文件及工程编译产物为主,另有doc说明、hex固件、uvprojx工程文件等,便于在Keil环境下查看、编译与复用。已有1805人学习下载,适合作为STM32F103C8T6等平台调试PN532、搭建NFC读写原型的参考工程。
1. 给 STM32 接上 PN532:一个串口就能跑通的 NFC 读卡方案
手里有一块 STM32 最小系统板,又翻出一片 PN532 模块,想把门禁卡、公交卡、NFC 标签读出来,最省事的做法不是上 SPI 也不是 I2C,而是走串口。这个项目就是围绕「STM32 + PN532 串口通信」做的一套完整驱动:MCU 通过 UART 发指令帧给 PN532,PN532 完成寻卡、防冲突、选卡、读写块,结果再原样回传。它适合正在做毕设、做门禁考勤 Demo、做 NFC 标签读写的嵌入式开发者,也适合刚学完 UART 中断、想找一个真实外设练手的新手。整套逻辑不依赖 RTOS,裸机轮询加中断接收就能跑,移植到 STM32F103、F407 这类常见型号改动量很小。下面按「先跑通、再拆链路、最后改」的顺序把它讲透。
2. 架构与目录:串口帧、命令层、业务层怎么分层
2.1 整体链路与三个层次
这套驱动的本质是一条很短的链路:业务代码调用「读卡」函数,命令层把请求打包成 PN532 能识别的帧,串口驱动把字节一个个发出去,PN532 处理完回一帧,串口中断把字节收进缓冲区,命令层解析出数据域,再交回业务层。整条链路里最容易出问题的是帧格式和时序,所以代码分层也基本围绕这两点切。
我一般把它分成三层:
| 层次 | 职责 | 典型文件 |
|---|---|---|
| 硬件抽象层 | UART 初始化、收发字节、超时、GPIO 复位 | pn532_uart.c/usart.c |
| 协议命令层 | 组帧、解帧、校验、命令封装 | pn532.c/pn532_cmd.c |
| 业务应用层 | 寻卡、读卡、写卡、UID 处理 | main.c/app_nfc.c |
硬件抽象层只关心「一个字节怎么出去、怎么进来」,协议命令层只关心「这一帧对不对」,业务层只关心「卡号是什么」。这样分层的好处是,后面换成 SPI 接口时,只需要重写硬件抽象层的四个函数(发字节、收字节、复位、等待就绪),协议层和业务层几乎不动。
2.2 目录结构长什么样
裸机工程常见的目录组织方式大概是这样,用 Keil 或 CubeIDE 建工程都能直接对应:
Project/ ├── Core/ │ ├── Inc/ │ │ ├── pn532.h # 协议层对外接口 │ │ ├── pn532_uart.h # 串口抽象接口 │ │ └── app_nfc.h # 业务层接口 │ └── Src/ │ ├── main.c # 初始化 + 主循环 │ ├── pn532.c # 组帧/解帧/命令 │ ├── pn532_uart.c # UART 收发与超时 │ └── app_nfc.c # 寻卡读卡业务 ├── Drivers/ │ └── STM32F1xx_HAL_Driver/ # HAL 库 └── PN532_Notes.md # 帧格式与命令备忘这个结构里最关键的是pn532_uart.c和pn532.c的边界。很多人写崩,是把组帧逻辑塞进了串口发送函数里,导致换波特率、加调试打印时全乱。我的习惯是串口层只做「发一个 buffer、收一个 buffer」,帧头帧尾、长度、校验全部放在协议层。
2.3 协议层的数据结构
协议层对内要维护一个接收状态机,对外暴露统一接口。核心结构体大致如下:
typedef struct { uint8_t tx_buf[64]; // 发送帧缓冲 uint8_t rx_buf[64]; // 接收帧缓冲 uint16_t rx_len; // 已接收字节数 uint8_t frame_ready; // 一帧收完标志 uint32_t timeout_ms; // 单次命令超时 } pn532_dev_t; // 对外接口 int pn532_init(pn532_dev_t *dev); int pn532_cmd(pn532_dev_t *dev, uint8_t cmd, const uint8_t *data, uint8_t len, uint8_t *resp, uint8_t *resp_len); int pn532_read_passive_uid(pn532_dev_t *dev, uint8_t *uid, uint8_t *uid_len);pn532_cmd是整份代码的中枢,所有业务命令都走它。resp传出去的是数据域,长度由resp_len带回,调用方不用关心帧头帧尾。这样设计之后,业务层写起来就很短,寻卡逻辑基本十行以内。
3. 环境搭建与跑通:从接线到读出第一张卡的 UID
3.1 硬件接线与 PN532 模式切换
PN532 模块一般支持 UART、I2C、SPI 三种模式,靠板上的拨码开关或电平选择。串口模式要特别注意两点:模块上的 DIP 开关要拨到 UART/HSU 一侧,另外模块默认波特率常见是 115200,但部分批次出厂是 9600,第一次上电建议先用 115200 试,不通再降到 9600 逐个排。
接线按交叉原则,MCU 的 TX 接 PN532 的 RX,MCU 的 RX 接 PN532 的 TX,共地一定要接。供电看模块标注,多数模块支持 3.3V 逻辑,但天线驱动部分电流不小,如果读卡距离明显偏近,先怀疑供电而不是代码。
// USART1 初始化,115200 8N1,开接收中断 void pn532_uart_init(uint32_t baud) { huart1.Instance = USART1; huart1.Init.BaudRate = baud; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; HAL_UART_Init(&huart1); // 单字节接收,靠中断把字节塞进环形缓冲 HAL_UART_Receive_IT(&huart1, &rx_byte, 1); }这段初始化的关键参数只有三个:波特率要和模块一致,字长必须是 8 位,校验必须关掉。PN532 的帧里有自己的校验字节,串口层再加奇偶校验只会让接收端多解析一层,属于给自己找麻烦。中断里只做一件事:把rx_byte存进缓冲并重新挂接收,不要在中断里解析帧,那是血泪经验。
3.2 唤醒与固件版本自检
PN532 上电后不一定立刻响应,常见做法是先发一个唤醒或获取固件版本的命令,能正常回帧就说明链路通了。这一步比直接寻卡更值得先做,因为它只验证通信,不依赖天线和卡片。
// 获取固件版本,用于验证串口链路是否正常 int pn532_check_link(pn532_dev_t *dev) { uint8_t resp[8] = {0}; uint8_t len = 0; // 0x02 = GetFirmwareVersion int ret = pn532_cmd(dev, 0x02, NULL, 0, resp, &len); if (ret != 0 || len < 4) { return -1; // 链路不通,先查接线和波特率 } // resp[0] 为 IC 型号,正常是 0x32;resp[1..3] 为版本号 if (resp[0] != 0x32) { return -2; // 回帧格式对但内容不对,怀疑波特率漂移 } return 0; }返回值设计成三种状态很实用:-1表示根本没收到帧,问题在硬件或波特率;-2表示收到了帧但对不上,问题多半在帧解析;0才是真的通了。实际调试时,先把这三个状态区分清楚,能省掉大量盲猜。
3.3 寻卡与读 UID 的完整调用
链路通了之后,寻卡的流程是固定的三步:寻卡(InListPassiveTarget)、防冲突、选卡。很多模块的固件在寻卡命令里已经顺带返回了 UID,所以简单场景下直接取返回值即可。
// 寻一张 ISO14443A 卡并取出 UID int nfc_poll_uid(pn532_dev_t *dev, uint8_t *uid, uint8_t *uid_len) { uint8_t cmd_data[2] = {0x01, 0x00}; // 寻 1 张卡,106kbps uint8_t resp[32] = {0}; uint8_t len = 0; // 0x4A = InListPassiveTarget if (pn532_cmd(dev, 0x4A, cmd_data, sizeof(cmd_data), resp, &len) != 0) { return -1; } if (len < 6 || resp[0] != 0x01) { // resp[0] 为检测到的卡数量 return -2; } // resp[5] 为 UID 长度,随后紧跟 UID 字节 *uid_len = resp[5]; if (*uid_len > 10) return -2; for (uint8_t i = 0; i < *uid_len; i++) { uid[i] = resp[6 + i]; } return 0; }cmd_data两个字节的含义要记牢:第一个是期望寻到的卡数量,一般填 1;第二个是波特率编码,0x00对应 106kbps,绝大多数门禁卡和标签都是这个速率。返回数据里resp[5]是 UID 长度,resp[6]开始才是 UID 本体,中间那几个字节分别是目标编号、应答码和卡类型,不要整体当成 UID 拷走,这是新手最常见的翻车点。
3.4 主循环里的调用节奏
裸机主循环不需要太复杂,轮询加延时就够了。但延时不能省,两次寻卡之间至少留几十毫秒,否则 PN532 内部还没复位完,会连续返回失败。
int main(void) { HAL_Init(); SystemClock_Config(); pn532_uart_init(115200); pn532_dev_t dev = {0}; dev.timeout_ms = 100; if (pn532_check_link(&dev) != 0) { while (1) { /* 链路不通,停在这里方便调试器查看 */ } } uint8_t uid[10], uid_len = 0; while (1) { if (nfc_poll_uid(&dev, uid, &uid_len) == 0) { // 这里换成你自己的业务:串口打印、比对白名单、开锁 nfc_on_card_detected(uid, uid_len); } HAL_Delay(100); } }timeout_ms建议先给 100ms,调试稳定后再压缩到 50ms 左右。给太短会在卡片靠近的瞬间频繁超时,给太长则整体响应变慢,读卡体验会明显发木。
4. 核心模块拆解:一帧数据从组包到解析的完整链路
4.1 PN532 的帧格式长什么样
要改这份代码,帧格式必须吃透。PN532 的串口帧结构是固定的:
| 位置 | 内容 | 说明 |
|---|---|---|
| 0 | 0x00 | 前导码 |
| 1 | 0x00 | 起始码 1 |
| 2 | 0xFF | 起始码 2 |
| 3 | LEN | 数据长度 |
| 4 | LCS | 长度校验,LEN + LCS = 0x00 |
| 5 | TFI | 方向标识,0xD4 主机发,0xD5 模块回 |
| 6..n | DATA | 命令字 + 参数 |
| n+1 | DCS | 数据校验,TFI+CMD+DATA+DCS = 0x00 |
| n+2 | 0x00 | 帧尾 |
长度校验和数据校验都是「加起来等于 0」,实现上用补码最快。这套帧格式看着繁琐,但一旦封装好,上层就再也不用碰它。
4.2 组帧函数的实现
// 组一帧主机命令,返回帧总长度 static uint16_t pn532_build_frame(pn532_dev_t *dev, uint8_t cmd, const uint8_t *data, uint8_t len) { uint8_t *p = dev->tx_buf; uint8_t dlen = len + 1; // DATA 域 = 命令字 + 参数 p[0] = 0x00; p[1] = 0x00; p[2] = 0xFF; p[3] = dlen; p[4] = (uint8_t)(0x00 - dlen); // LCS:与 LEN 相加为 0 p[5] = 0xD4; // 主机 -> 模块 p[6] = cmd; for (uint8_t i = 0; i < len; i++) { p[7 + i] = data[i]; } uint8_t sum = 0xD4 + cmd; for (uint8_t i = 0; i < len; i++) { sum += data[i]; } p[7 + len] = (uint8_t)(0x00 - sum); // DCS p[8 + len] = 0x00; // 帧尾 return 9 + len; }这段代码里两个细节值得留意。LCS用0x00 - dlen直接算出补码,比写成~dlen + 1更直观;DCS的累加范围必须包含 TFI 和命令字,漏掉 TFI 是最隐蔽的错误,因为短帧时可能碰巧对得上,长帧必错。帧尾那个0x00也常被忘,忘了之后模块会一直等帧结束,表现为「发出去没反应」。
4.3 解帧状态机
接收端不能用「收够固定长度就停」的思路,因为返回帧长度是变化的。稳妥做法是状态机逐字节判断:
// 每收到一个字节调用一次,返回 1 表示一帧完整 uint8_t pn532_rx_byte(pn532_dev_t *dev, uint8_t byte) { static uint8_t state = 0; static uint8_t len = 0, idx = 0, sum = 0; switch (state) { case 0: // 找前导码 if (byte == 0x00) state = 1; break; case 1: // 找起始码 1 state = (byte == 0x00) ? 2 : 0; break; case 2: // 找起始码 2 if (byte == 0xFF) { state = 3; } else if (byte == 0x00) { state = 2; } else { state = 0; } break; case 3: // 收 LEN len = byte; idx = 0; sum = 0; state = 4; break; case 4: // 收 LCS if ((uint8_t)(len + byte) != 0x00) { state = 0; break; } state = 5; break; case 5: // 收 TFI + DATA + DCS dev->rx_buf[idx++] = byte; if (idx <= len) { sum += byte; } else { if ((uint8_t)(sum + byte) != 0x00) { state = 0; break; } dev->rx_len = idx - 1; // 去掉 DCS 本身 state = 6; } break; case 6: // 收帧尾 if (byte == 0x00) { dev->frame_ready = 1; state = 0; return 1; } state = 0; break; } return 0; }状态机的好处是抗干扰:中间只要有一个字节对不上,立刻回到状态 0 重新找帧头,不会把垃圾数据当有效帧解析。sum的累加条件idx <= len要仔细看,它的意思是在收满 LEN 个字节之前都参与校验累加,最后一个字节(DCS)单独和sum相加判断是否为 0。这段逻辑写错一次就得调半天,建议第一次移植时用示波器或串口助手对着实际回帧核一遍。
4.4 阻塞式命令接口
把组帧、发送、等待、解帧串起来,就是业务层看到的那个pn532_cmd:
int pn532_cmd(pn532_dev_t *dev, uint8_t cmd, const uint8_t *data, uint8_t len, uint8_t *resp, uint8_t *resp_len) { uint16_t flen = pn532_build_frame(dev, cmd, data, len); dev->rx_len = 0; dev->frame_ready = 0; HAL_UART_Transmit(&huart1, dev->tx_buf, flen, 50); uint32_t start = HAL_GetTick(); while (!dev->frame_ready) { if (HAL_GetTick() - start > dev->timeout_ms) { return -1; // 超时 } } // 返回帧的 TFI 应为 0xD5,DATA[0] 为 cmd+1 if (dev->rx_buf[0] != 0xD5 || dev->rx_buf[1] != (uint8_t)(cmd + 1)) { return -2; // 应答不匹配 } uint8_t dlen = dev->rx_len - 2; // 去掉 TFI 和应答命令字 if (resp && resp_len) { for (uint8_t i = 0; i < dlen; i++) { resp[i] = dev->rx_buf[2 + i]; } *resp_len = dlen; } return 0; }这个接口的两个判断很关键。rx_buf[0]必须是0xD5,如果是0xD4说明收到的是自己发出去的帧被回环了,多出现在 TX 和 RX 短接或者模块未就绪的场景。rx_buf[1]要是cmd + 1,PN532 的应答命令字统一是请求命令字加一,这个规律要记住,它对不上基本意味着上一个命令的应答还没清掉。
5. 避坑与排查:串口驱动最容易翻车的几个点
5.1 收到 0xD4 回环帧,命令全部超时
现象:发任何命令都超时,串口助手上能看到数据发出去了,但接收缓冲里只有自己发的帧。
原因:MCU 的 TX 和 RX 在硬件上被短接,或者 PN532 没进串口模式,模块的 TX 一直高阻。
解决:先断开模块,单测 MCU 端,发字节看是否自收;再确认模块拨码开关在 UART 位;最后量模块 TX 引脚静态电平,正常应是高电平,一直接地说明模块没启动。
5.2 首帧能通,连续命令后逐渐失败
现象:上电后第一次寻卡正常,跑十几秒后开始间歇性返回错误,重启又恢复。
原因:接收缓冲没有清零,上一帧的残留字节混进了下一帧,状态机被带偏。
解决:每次调用pn532_cmd前把rx_len、frame_ready和内部状态机变量全部复位;更彻底的做法是给状态机加一个「帧间超时」,超过 20ms 没收到新字节就强制回到状态 0。
5.3 逻辑分析仪看波形正常,代码却解析失败
现象:抓到的字节流完全符合帧格式,手工对照校验也对,但程序就是判帧失败。
原因:中断里重挂接收的时机不对,HAL_UART_Receive_IT在回调里调用时,如果串口还在收下一个字节,会丢字节或者触发溢出错误。
解决:改用环形缓冲加空闲中断的方式收数据,中断回调里只入队,解析放到主循环;或者在回调里先关中断、清标志、再重挂。
5.4 读卡距离短、偶发寻不到卡
现象:贴着模块能读,稍微远一点就失败,或者卡片要放好几次才成功。
原因:天线供电不足或地线太长,也可能寻卡间隔太短导致模块内部未复位。
解决:检查供电电压和去耦电容,缩短模块与 MCU 的地线;把主循环里的HAL_Delay从 100ms 提到 150ms 观察是否改善;必要时在寻卡失败后主动发一次复位或重新配置天线增益。
5.5 波特率对不上时的静默失败
现象:改成 9600 后完全没反应,也没有任何错误日志。
原因:模块实际波特率和代码不一致,收到的字节全部错位,状态机永远找不到帧头。
解决:不要靠猜,先用串口助手以两个波特率分别发00 00 FF 02 FE D4 02 2A 00这条固件版本命令,哪个波特率有回帧就用哪个;确认后把代码里的baud参数同步改掉。
6. 二次开发:把读卡 Demo 改成能落地的业务模块
真正要上项目,读 UID 只是起点。改造的第一步是把业务回调抽出来,别在main.c里直接写判断逻辑。我一般定义一个卡片事件接口,业务层实现它,驱动层只管触发:
// 业务层需要实现的回调,驱动层收到卡就调用 __weak void nfc_on_card_detected(const uint8_t *uid, uint8_t len) { // 默认空实现,具体项目里覆写 }有了这个接口,门禁项目就在回调里比对白名单,考勤项目就在回调里记录时间戳,标签读写项目就在回调里做块读写。驱动和业务彻底解耦,后面加第二张卡、加蜂鸣器提示都不用动协议层。
第二步是加一层轻量的卡片缓存。同一张卡在感应区停留时,PN532 会持续返回同一个 UID,直接触发回调会导致开锁指令连发几十次。常见做法是保存上一次的 UID 和时间戳,若 UID 相同且间隔小于 1 秒就直接丢弃。这段逻辑不长,但能挡掉大量莫名其妙的业务 bug:
| 改造点 | 位置 | 改动量 |
|---|---|---|
| 去重缓存 | 业务层 | 约 20 行 |
| 白名单比对 | 业务层 | 按需 |
| 接口换成 SPI | 硬件抽象层 | 重写 4 个函数 |
| 增加写块命令 | 协议层 | 新增一个封装 |
第三步如果是标签读写,就在协议层补InDataExchange(命令字0x40)的封装,参数是目标编号、读写标志和块地址。写块前务必确认标签不是只读的,出厂锁死的标签写失败时返回的错误码和通信错误很像,容易误判成串口问题。我吃过一次亏,调了一下午串口,最后换了一张可写标签立刻就好,从那以后每次读写失败都先把卡换掉验证一遍,再决定动代码。这套流程希望帮到你,先跑通读 UID,再按业务往上叠,基本不会走偏。
本文还有配套的精品资源,点击获取