基于STM32和MFRC522的RFID充值扣款函数实现详解
搞过嵌入式刷卡项目的人应该都有同感一套系统拿来读卡号、显示卡号五分钟就能通但真正要做到能充值、能扣款、余额不丢、重复刷不崩就得在读写流程和数据处理上下不少功夫。前段时间我刚好在一个基于STM32的模拟消费终端上把MFRC522的充值与扣款函数完整调通了一版期间踩了不少坑也总结出一套能直接用的实现思路。这篇文章就把从硬件接线、驱动要点到充值扣款的具体代码和边界处理原原本本拆开来讲给准备做一卡通、会员储值卡或者食堂消费机的朋友作参考。1. 项目整体设计与需求拆解1.1 这个项目到底要做什么项目标题写得很明确基于STM32和MFRC522的充值与扣款函数。落到实际场景里就是一台小型的RFID消费终端用户拿一张Mifare Classic 1K卡靠近读写器系统能完成两件核心事往卡里存钱充值和从卡里扣钱消费扣款。很多人觉得这不就是把数字改一下再写回卡里嘛实际上没那么简单。Mifare卡虽然便宜但它的存储方式是按扇区分块每个块16字节没有现成的余额数据库。你需要在扇区里自己设计一套余额存储格式定义好金额放哪、卡号放哪、校验码怎么算。充值扣款操作也不只是读出修改写回还要考虑卡突然拿走怎么办、金额溢出怎么办、同一张卡连刷两次会不会重复扣款以及硬件级的数据完整性怎么保证。这个项目的直接价值是给你一套可以复制的、经过实际验证的充值与扣款函数原型它是所有RFID消费类应用的业务核心。往大了说食堂刷卡机、公交充值终端、超市储值卡、健身房门禁扣费底层逻辑跟这套东西是通的。1.2 方案选型为什么是STM32加MFRC522选STM32是因为它的硬件资源非常适合这类小终端。SPI接口、GPIO、定时器、串口、蜂鸣器控制都有主频几十兆赫兹足够跑读卡协议和业务逻辑成本也压得住。一套完整的消费终端除了读卡模块往往还要接显示屏、按键、蜂鸣器、串口通信STM32的外设资源刚好够用不用额外加逻辑器件。MFRC522是13.56MHz高频读卡芯片方案里的常客市面上模块很成熟支持SPI、I2C、UART三种接口这里用SPI方式最省事速率快、代码也好写。Mifare Classic 1K卡片是目前用得最广泛的低频成本卡淘宝上几毛钱到一两块钱一张容量1024字节分成16个扇区每扇区4个块其中1个块是扇区尾部存放KeyA、访问位、KeyB。做储值卡系统一个扇区就够存余额和必要信息了。这块组合的合理性在于不是最强的方案却是性价比和上手难度最平衡的方案。相比用带NFC功能的专用SoCSTM32MFRC522可以按项目需求自由扩展业务功能相比用ArduinoSTM32的工程化程度更高也更贴近工业产品开发。1.3 功能拆分与关键指标把需求拆开整个系统除了基础的寻卡、选卡、认证、读写业务层核心就三个动作功能输入输出关键异常充值充值金额操作结果、充值后余额卡未放好、金额非法、余额超上限扣款扣款金额操作结果、扣款后余额卡未放好、余额不足、重复刷卡查询余额无余额卡认证失败、数据被破坏技术指标方面我给自己定的目标是单次充值或扣款操作从刷卡到完成不超过200ms读卡稳定距离不低于3cm连续刷不同卡不出现串卡同一张卡重复靠近不能重复扣款。这些指标看着简单实际调的时候每一条都能逼出问题来。2. 硬件基础与MFRC522驱动关键点2.1 硬件连接与引脚分配MFRC522模块和STM32之间用SPI通信常用连接是六根线SCK、MOSI、MISO、CS、RST、GND另外必须接3.3V电源。以我常用的某款开发板为例引脚分配如下MFRC522模块STM32引脚说明SDAPB12SPI2_NSS片选SCKPB13SPI2_SCK时钟MOSIPB15SPI2_MOSI主发从收MISOPB14SPI2_MISO主收从发RSTPB10复位控制由GPIO控制VCC3.3V模块供电绝对不能接5VGNDGND共地两个特别值得强调的点第一供电问题。MFRC522芯片本身是3.3V器件直接接5V电源大概率烧芯片。有些模块板载了稳压电路板上会有个AMS1117之类的芯片那种可以接5V但很多廉价模块没有你只能接3.3V。保险起见拿到模块先看板子上有没有稳压芯片没有就老实接3.3V。第二SPI速率。MFRC522的最高SPI时钟是10MHz超过之后通信会不稳定。STM32的SPI外设分频系数是2、4、8、16这样的如果你的系统时钟是72MHz选2分频就是36MHz远超芯片上限。所以初始化SPI时我建议选8分频或16分频也就是9MHz或4.5MHz实测下来最稳。后文还会详细展开这个坑。2.2 寄存器读写与初始化MFRC522的寄存器是8位的SPI读写时有一个特殊规则地址字节中bit7表示读写方向1读0写bit6到bit1是寄存器地址bit0固定为0。所以实际操作中地址要先左移1位再按读写方向加标志。读寄存器函数和写寄存器函数的代码可以这样写基于HAL库// 读寄存器reg是实际寄存器地址 uint8_t MFRC522_ReadReg(uint8_t reg) { uint8_t tx ((reg 1) 0x7E) | 0x80; // 左移1位置bit7为1表示读 uint8_t rx 0x00; uint8_t dummy 0xFF; HAL_GPIO_WritePin(RC522_CS_Port, RC522_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi2, tx, 1, 10); HAL_SPI_TransmitReceive(hspi2, dummy, rx, 1, 10); // 读出数据 HAL_GPIO_WritePin(RC522_CS_Port, RC522_CS_Pin, GPIO_PIN_SET); return rx; } // 写寄存器 void MFRC522_WriteReg(uint8_t reg, uint8_t value) { uint8_t tx ((reg 1) 0x7E); // 写方向bit7为0 HAL_GPIO_WritePin(RC522_CS_Port, RC522_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi2, tx, 1, 10); HAL_SPI_Transmit(hspi2, value, 1, 10); HAL_GPIO_WritePin(RC522_CS_Port, RC522_CS_Pin, GPIO_PIN_SET); }初始化部分核心是复位芯片、配置定时器、配置发送接收模式、打开天线void MFRC522_Init(void) { // 复位 MFRC522_WriteReg(MFRC522_REG_COMMAND, PCD_RESETPHASE); // 定时器配置用于超时判断 MFRC522_WriteReg(MFRC522_REG_TMODE, 0x8D); // 自动启动定时器 MFRC522_WriteReg(MFRC522_REG_TPRESCALER, 0x3E); // 预分频值 MFRC522_WriteReg(MFRC522_REG_TRELOAD_L, 0x1E); MFRC522_WriteReg(MFRC522_REG_TRELOAD_H, 0x00); // 发送与接收模式 MFRC522_WriteReg(MFRC522_REG_TXMODE, 0x00); MFRC522_WriteReg(MFRC522_REG_RXMODE, 0x00); MFRC522_WriteReg(MFRC522_REG_MODWIDTH, 0x26); // 开启天线 MFRC522_WriteReg(MFRC522_REG_TXCONTROL, 0x80); // 配置发射功率0x70基本是最大档 MFRC522_WriteReg(MFRC522_REG_RFCFG, 0x70); }注意不同来源的库初始化参数可能略有差异但基本就是这些寄存器。如果你发现读卡距离特别近或者完全读不到卡优先查RFCfgReg的发射功率配置和天线驱动部分后面问题排查会细说。2.3 读卡操作的标准五步流程MFRC522读一张Mifare卡标准流程是五个步骤寻卡、防碰撞、选卡、认证、读写块。任何业务操作都建立在这五步之上。第一寻卡。发送REQALL或REQIDL指令卡回应ATQA表示有卡进入射频场。uint8_t PICC_Request(uint8_t reqMode, uint8_t *tagType) { // reqMode 取 PICC_REQIDL(0x26) 或 PICC_REQALL(0x52) // 返回卡类型到tagType成功返回MI_OK }第二防碰撞。当两张以上卡同时进入射频场时用防碰撞指令拿到唯一序列号。如果拿到的是卡号说明只有一张卡在场。uint8_t PICC_Anticoll(uint8_t *serNum) { // 通过SEL0x93, NVB0x20发起防碰撞 // 成功后serNum[0..3]是4字节卡号serNum[4]是校验 }第三选卡。拿到卡号后发送SEL和完整序列号选中这张卡进入激活状态。uint8_t PICC_Select(uint8_t *serNum) { // 返回卡容量类型选中后后续读写才有效 }第四认证。对某个扇区进行认证用KeyA或KeyB验证读写权限。Mifare Classic 1K默认的KeyA是FF FF FF FF FF FF出厂都是这个。uint8_t PCD_Authenticate(uint8_t authMode, uint8_t blockAddr, uint8_t *key, uint8_t *serNum) { // authMode: PICC_AUTHENT1A(0x60) 或 PICC_AUTHENT1B(0x61) }第五读写块。认证通过后用MIFARE_READ和MIFARE_WRITE对扇区内的块进行16字节的读和写。uint8_t MIFARE_Read(uint8_t blockAddr, uint8_t *buffer); uint8_t MIFARE_Write(uint8_t blockAddr, uint8_t *buffer);这套五步流程是所有卡片操作的地基。做充值扣款之前建议先单独写一个测试程序把这五步全跑通确认能稳定地读取卡号再往下做业务逻辑排查起来会容易得多。3. 充值与扣款函数的核心实现3.1 余额存储块的数据结构设计充值和扣款本质上是对卡上某个数据块的读-改-写。Mifare Classic 1K每个扇区有4个块其中块0是厂商数据块1、块2是数据块块3是扇区尾部存放密钥和访问位。为了安全业务数据我放在扇区1的块4也就是blockAddr 4。那16字节怎么用我一开始随便塞存了个余额就完事后来测试发现太容易出问题了。卡上数据不像单片机Flash有完善校验Mifare块的写入偶尔会因为卡移动或者射频干扰产生异常没有校验机制根本发现不了。最后我设计了一套带校验和绑定的存储格式字节偏移长度字段说明0~34余额有符号32位整数小端序单位分4~74卡号4字节卡序列号绑定验证8~114交易序号每笔充值/扣款递增12~132CRC16对前14字节做校验14~152保留固定填充0xFF这个设计有三个关键考虑卡号绑定是为了防止刷错卡。虽然寻卡时已经拿到了卡号但读块之后如果再校验一次块内存储的卡号和当前卡号是否一致就能防止因为射频串扰或者异常流程导致把A卡的余额写到B卡上。CRC16校验是整个设计的灵魂。因为Mifare块写入偶尔会静默出错如果没有校验你读到的是一个错误余额然后在这个错误值上进行加减最后又写回卡里数据就彻底歪了。有了CRC16每次读块后先校验数据不对就报错让上层重试或提示用户重新刷卡安全性完全不一样。交易序号的作用是审计和防重放。如果只是余额增减很难判断一笔操作是否重复执行了。加一个单调递增的序号每次充值扣款都加1即使系统日志丢了也能通过序号连续性发现异常。如果只需要最简单方案那第4~7字节可以省掉但卡号绑定和CRC强烈建议保留。这两样东西在排查问题的时候能救你命。3.2 余额的解析与封装读写块拿到的都是裸字节需要一个解析函数和一个封装函数把我们定义的格式转成C语言里的结构体。定义结构体typedef struct { int32_t balance; // 余额单位分 uint32_t cardNo; // 卡号 uint32_t transNo; // 交易序号 } WalletData;解析函数读块后把16字节缓冲区中的余额、卡号、序号解出来同时做CRC校验。// 从块数据中解析钱包数据返回0成功-1校验失败 int parse_wallet(uint8_t *buffer, WalletData *wallet) { uint16_t crcCalc; uint16_t crcStored; if (buffer NULL || wallet NULL) { return -1; } wallet-balance (int32_t)(buffer[0] | (buffer[1] 8) | (buffer[2] 16) | (buffer[3] 24)); wallet-cardNo (uint32_t)(buffer[4] | (buffer[5] 8) | (buffer[6] 16) | (buffer[7] 24)); wallet-transNo (uint32_t)(buffer[8] | (buffer[9] 8) | (buffer[10] 16) | (buffer[11] 24)); crcStored (uint16_t)(buffer[12] | (buffer[13] 8)); crcCalc cal_crc16(buffer, 14); if (crcCalc ! crcStored) { return -1; } return 0; }封装函数把钱包数据转换成16字节数组并填好CRC供MIFARE_Write使用。特别注意要把余额、卡号、序号按小端序填入。// 将钱包数据打包成16字节块数据 void pack_wallet(uint8_t *buffer, WalletData *wallet) { buffer[0] wallet-balance 0xFF; buffer[1] (wallet-balance 8) 0xFF; buffer[2] (wallet-balance 16) 0xFF; buffer[3] (wallet-balance 24) 0xFF; buffer[4] wallet-cardNo 0xFF; buffer[5] (wallet-cardNo 8) 0xFF; buffer[6] (wallet-cardNo 16) 0xFF; buffer[7] (wallet-cardNo 24) 0xFF; buffer[8] wallet-transNo 0xFF; buffer[9] (wallet-transNo 8) 0xFF; buffer[10] (wallet-transNo 16) 0xFF; buffer[11] (wallet-transNo 24) 0xFF; buffer[12] crc16_val 0xFF; buffer[13] (crc16_val 8) 0xFF; buffer[14] 0xFF; buffer[15] 0xFF; }CRC16的实现可以用标准查表法也可以写一个简单的逐位计算函数。MCU上跑查表法更快我这里给的是一版对初学者友好的逐位算法移植简单uint16_t cal_crc16(uint8_t *ptr, uint8_t len) { uint16_t crc 0xFFFF; uint8_t i, j; for (i 0; i len; i) { crc ^ ptr[i]; for (j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }这里用的是Modbus CRC16没有用特别复杂的多项式够用就行。如果你有现成的硬件CRC外设也可以把CRC计算换成硬件单元不过我测试下来软件算14个字节的CRC是微秒级操作对整体性能毫无压力。3.3 充值函数实现充值函数的核心流程可以概括为寻卡选卡 - 认证 - 读取并校验余额 - 增加金额 - 写回 - 读回验证。直接看完整实现。前提底层已经封装好了以下函数这些是任何MFRC522驱动库都有的PICC_Request(reqMode, tagType)寻卡PICC_Anticoll(serNum)防碰撞取卡号PICC_Select(serNum)选卡PCD_Authenticate(authMode, blockAddr, key, serNum)扇区认证MIFARE_Read(blockAddr, buffer)读块MIFARE_Write(blockAddr, buffer)写块PICC_Halt()令卡停止工作充值函数#define WALLET_BLOCK 4 // 余额存储块 #define MAX_BALANCE 1000000 // 余额上限单位分即1万元 // 默认KeyA uint8_t defaultKey[6] {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; // 充值函数addAmount是充值金额单位分执行成功返回0 int wallet_recharge(uint32_t addAmount) { uint8_t status; uint8_t tagType[2]; uint8_t serNum[5]; uint8_t buffer[16]; WalletData wallet; // 1. 寻卡只找空闲状态卡 status PICC_Request(PICC_REQIDL, tagType); if (status ! MI_OK) { return -1; // 没有卡片 } // 2. 防碰撞取得卡号 status PICC_Anticoll(serNum); if (status ! MI_OK) { return -2; // 防碰撞失败可能多卡或卡异常 } // 3. 选卡 status PICC_Select(serNum); if (status ! MI_OK) { return -3; } // 4. 校验卡号合理性 if (serNum[0] 0 serNum[1] 0 serNum[2] 0 serNum[3] 0) { return -4; // 空卡号拒绝操作 } // 5. 用KeyA认证扇区1块4所在的扇区 status PCD_Authenticate(PICC_AUTHENT1A, WALLET_BLOCK, defaultKey, serNum); if (status ! MI_OK) { return -5; // 认证失败多半是密钥不对 } // 6. 读取当前余额块 status MIFARE_Read(WALLET_BLOCK, buffer); if (status ! MI_OK) { return -6; } // 7. 解析并校验数据 if (parse_wallet(buffer, wallet) ! 0) { return -7; // CRC校验失败数据损坏 } // 8. 校验卡号与块内存储卡号一致 uint32_t uid (uint32_t)(serNum[0] | (serNum[1] 8) | (serNum[2] 16) | (serNum[3] 24)); if (wallet.cardNo ! uid) { return -8; // 卡号不匹配数据异常 } // 9. 充值金额合法性检查 if (addAmount 0) { return -9; // 充值金额为0 } if (addAmount MAX_BALANCE - wallet.balance) { return -10; // 余额超上限 } // 10. 修改余额并递增交易序号 wallet.balance (int32_t)addAmount; wallet.transNo; // 11. 打包并写回 pack_wallet(buffer, wallet); status MIFARE_Write(WALLET_BLOCK, buffer); if (status ! MI_OK) { return -11; // 写块失败 } // 12. 读回验证可选但推荐 status MIFARE_Read(WALLET_BLOCK, buffer); if (status ! MI_OK || parse_wallet(buffer, wallet) ! 0) { return -12; // 写后读回异常 } // 13. 令卡停止工作防止重复操作 PICC_Halt(); return 0; }你仔细看会发现我这里把每一步的失败都返回了不同的错误码。这在调试阶段非常有用屏幕或者串口直接打印错误码能立刻知道卡在哪个环节。实际生产代码里可以精简为少数几个错误码但开发期建议保留完整分支。3.4 扣款函数实现扣款和充值很像最大的区别是必须做余额充足性检查。这步做不好用户卡里没钱也能消费系统就出大事故了。// 扣款函数deductAmount是扣款金额单位分扣款成功返回0 int wallet_deduct(uint32_t deductAmount) { uint8_t status; uint8_t tagType[2]; uint8_t serNum[5]; uint8_t buffer[16]; WalletData wallet; // 1. 寻卡 status PICC_Request(PICC_REQIDL, tagType); if (status ! MI_OK) { return -1; } // 2. 防碰撞 status PICC_Anticoll(serNum); if (status ! MI_OK) { return -2; } // 3. 选卡 status PICC_Select(serNum); if (status ! MI_OK) { return -3; } // 4. 认证 status PCD_Authenticate(PICC_AUTHENT1A, WALLET_BLOCK, defaultKey, serNum); if (status ! MI_OK) { return -5; } // 5. 读取当前余额 status MIFARE_Read(WALLET_BLOCK, buffer); if (status ! MI_OK) { return -6; } // 6. 解析并校验 if (parse_wallet(buffer, wallet) ! 0) { return -7; } // 7. 卡号一致性检查 uint32_t uid (uint32_t)(serNum[0] | (serNum[1] 8) | (serNum[2] 16) | (serNum[3] 24)); if (wallet.cardNo ! uid) { return -8; } // 8. 检查扣款金额合法性 if (deductAmount 0) { return -9; } // 9. 关键余额是否足够 if (wallet.balance (int32_t)deductAmount) { return -13; // 余额不足 } // 10. 扣款并递增序号 wallet.balance - (int32_t)deductAmount; wallet.transNo; // 11. 写回 pack_wallet(buffer, wallet); status MIFARE_Write(WALLET_BLOCK, buffer); if (status ! MI_OK) { return -11; } // 12. 读回验证 status MIFARE_Read(WALLET_BLOCK, buffer); if (status ! MI_OK || parse_wallet(buffer, wallet) ! 0) { return -12; } // 13. 停止卡片 PICC_Halt(); return 0; }从代码结构能看出来充值和扣款的80%流程是重合的。如果想精简代码可以把寻卡-防碰撞-选卡-认证-读块-解析-校验抽成一个公共函数OpenWallet(uint32_t *uid, WalletData *wallet)把写回-读回-停止抽成SaveWallet(WalletData *wallet)。我之所以没有在文中强行合并是为了让第一次接触这套逻辑的人能一条线看完整流程等你自己上手后再根据项目规范抽象封装不迟。3.5 防重复与边界处理充值扣款最大的隐患是重复操作。用户刷卡消费系统扣款成功但用户手没拿开卡片还在射频场内系统如果再次触发扣款就会重复扣一次。MFRC522的寻卡指令REQIDL只寻找空闲状态的卡而执行完写块后如果调用PICC_Halt()卡片会进入Halt状态射频场内的同一张卡就不会再被REQIDL找到。所以每次充值/扣款成功后必须执行Halt操作这是第一道防重复锁。第二道锁是状态机。在设计上层应用时我习惯加一个全局业务状态位比如BUSY_IDLE、BUSY_PROCESSING。刷卡后立刻进入BUSY_PROCESSING处理完或异常退出后恢复BUSY_IDLE。即使底层寻卡漏掉了Halt状态上层状态机也能拦住重复触发。第三道锁是交易序号。即使前两道都被绕过发生了二次扣款序号也会暴露异常。比如上一笔交易序号是10下一笔跳到了11但如果日志显示收到两次序号10和一次11就能发现是同一笔操作的重复执行。这个在联调审计阶段特别好用。边界条件处理也不能忽略。金额溢出、负数金额、超大金额、空卡号这些都要在代码里提前拦截。金额字段我选的是int32_t单位用分最大可以表示约2100万元实际业务中一般会设置一个上限我上面写的MAX_BALANCE是1000000分也就是1万元超过就拒绝充值。这样可以避免因为数值溢出导致余额变成负数之类的低级错误。4. 完整代码框架与集成示例4.1 业务层与驱动层如何分层实际工程里我不会把充值和扣款的业务逻辑和底层MFRC522驱动揉在一个文件里。推荐的分层方式是这样驱动层负责SPI通信、寄存器读写、寻卡、防碰撞、选卡、认证、读写块。这一层只做协议不关心业务。中间层负责钱包数据的解析、封装、CRC校验。也就是parse_wallet、pack_wallet、cal_crc16这些函数。业务层负责充值和扣款流程调用驱动层的API和中间层的数据函数返回带业务含义的错误码。应用层负责交互比如读取按键状态、接收串口指令、驱动显示屏和蜂鸣器。这样分层的好处是换一颗MCU或者换一个读卡芯片你只需要换驱动层业务层完全不用动。换数据格式你只需要改中间层。排查问题的时候哪一层出问题就盯哪一层不会到处都是散落的逻辑。4.2 主流程调用示例一个最简单的消费终端主流程可以这样组织以按键触发扣款、上位机串口输入金额为例// 假设串口收到一条扣款指令D:50 表示扣款50分 void ParseAndExecuteCommand(char *cmd) { if (cmd[0] R) { // R:100 表示充值100分 uint32_t amount atoi(cmd 2); int ret wallet_recharge(amount); ReportResult(ret); } else if (cmd[0] D) { // D:50 表示扣款50分 uint32_t amount atoi(cmd 2); int ret wallet_deduct(amount); ReportResult(ret); } else if (cmd[0] Q) { // 查询余额 int ret wallet_query(); ReportResult(ret); } }在实际的消费终端上扣款通常还需要一个待扣金额确认环节避免用户把卡放上去瞬间就被扣掉一笔默认消费。我见过不少项目做成了收银员在键盘上输入金额用户刷卡终端读卡后显示扣款XX元确认请按OK确认后才执行扣款函数。这个流程可以大大减少误刷和纠纷。4.3 蜂鸣器反馈与用户提示读卡成功、充值成功、扣款成功、余额不足这些状态都应该有不同的用户提示。最常见的是蜂鸣器加LED指示灯。我这里简单封装了一个反馈函数业务函数返回错误码后应用层根据错误码给出不同提示void ReportResult(int ret) { switch (ret) { case 0: // 成功响两声绿灯闪 Buzzer_Beep(2); LED_SetGreen(); break; case -13: // 余额不足响三声长音红灯闪 Buzzer_BeepLong(3); LED_SetRed(); break; case -1: case -2: case -3: // 读卡失败响一声短音 Buzzer_Beep(1); break; default: // 其他异常响四声短音 Buzzer_Beep(4); break; } }这个小细节看着不起眼实际使用体验差别很大。尤其是余额不足的场景如果没有蜂鸣器区分用户刷了卡不知道成功没有会反复刷很容易刷出重复扣款事故。5. 常见问题与排查技巧实录5.1 读卡失败、距离太近或完全没反应这是我接手MFRC522项目后碰到最多的一类问题常见原因有这么几个。供电问题。模块接5V导致稳压芯片发烫或者供电电流不够都会让读卡距离缩到1cm以内甚至完全读不到。换一根粗一点的USB线或者改用独立3.3V供电问题立刻消失。这个现象特别典型值得记在脑子里。SPI速率太高。MFRC522的SPI时钟上限是10MHz很多新手直接套用STM32默认的8分频甚至4分频危险很大。之前我在某块72MHz主频的开发板上用8分频就是9MHz勉强能跑换到另一块64MHz主频的板子8分频还是8MHz手一抖改成了4分频之后就彻底读不到卡。后来全部统一用8分频或16分频再没有因为SPI速率出过问题。天线配置不对。MFRC522模块的天线驱动配置在寄存器里尤其是TxControlReg和RFCfgReg。有些模块出厂后默认配置不是最佳参数需要自己调。我在一块模块上测试过把RFCfgReg从0x60改成0x70读卡距离从2cm提升到了4cm左右。如果你确认供电和SPI都没问题但距离就是很近优先查这里。5.2 认证失败返回错误码-5认证失败的原因九成是密钥不对。Mifare Classic 1K出厂默认KeyA是FF FF FF FF FF FF如果你改过扇区密钥或者卡之前被别人写过密钥就不是默认值了。处理办法很简单重新读一下扇区尾部的数据或者直接把卡恢复出厂密钥。如果用的是复合卡或者写入了门禁数据的卡密钥被改过很正常不要默认所有卡都是出厂密钥。还有一个坑是认证的块地址问题。PCD_Authenticate认证的是某个扇区但实际传参时需要传你准备读写的那个数据块的地址而不是扇区起始地址。例如要读块4认证时也要传块4很多初学者传成扇区地址1结果认证一直失败。5.3 充值后再次读卡余额没变这个问题的典型原因是写块失败了但上层没检查。MFRC522的写块并不是百分百成功卡在写入瞬间离开射频场、供电波动、SPI干扰都会导致写入失败。如果代码里没检查MIFARE_Write的返回值就会以为写成功了其实卡里数据根本没变。我的经验是所有充值扣款成功后强制做一次读回验证。也就是写完之后立即重新读一遍再解析一次确认余额确实变成了预期值。虽然这会让单次操作多花几十毫秒但能拦截掉绝大部分静默失败。在消费场景里多花几十毫秒换数据可靠是绝对划算的。5.4 同一张卡反复刷出现重复扣款前面在防重复设计里已经提过重复扣款的根因通常是卡片没有进入Halt状态或者上层状态机没有锁住。排查方法是先确认底层代码在写块后有没有执行PICC_Halt()。如果执行了卡会进入Halt态不会再响应REQIDL。然后检查上层是不是用了REQALL而不是REQIDLREQALL会唤醒Halt状态下的卡导致重复寻卡。如果这些都没问题就在应用层加一个刷新延时比如扣款成功后2秒内不响应新的刷卡操作给用户一个物理拔卡的时间窗口。5.5 常见问题快速排查表现象可能原因处理方案完全读不到卡供电、SPI连线、模块损坏查3.3V供电用示波器或万用表量SPI波形读卡距离只有1cm天线驱动配置、发射功率太低调整RFCfgReg靠近线圈调整天线匹配寻卡时好时坏SPI速率超上限、线材过长降低SPI分频缩短杜邦线认证失败密钥错误、块地址传错确认密钥确认传块地址不传扇区地址扣款成功但余额没变写块失败未检查返回值增加写后读回校验重复扣款未Halt、用了REQALL、状态机未锁补Halt改用REQIDL加应用层锁充值后余额为负数溢出、金额逻辑错误加MAX_BALANCE限制检查int32溢出6. 改进方向与个人经验6.1 更安全的做法使用Mifare值块指令前面用的余额存储方案是读-改-写虽然加了CRC和读回验证但在极端情况下仍然存在写一半被中断的风险。Mifare Classic 1K其实提供了专门的值块Value Block指令包括INCREMENT、DECREMENT、TRANSFER这些指令由卡片在硬件层面执行可以在一定意义上避免读改写过程中的逻辑断裂。值块是有固定格式的16字节中前4字节是有符号值中间4字节是值的取反后4字节是值的再次重复最后4字节是地址字节及取反。读卡的时候要检查值和取反是否一致如果一致才判断数据有效。使用DECREMENT指令扣款的流程是认证 - DECREMENT(扣款值) - TRANSFER(将结果写入目标块)。数值的加减在卡片内部完成不回MCU因此如果卡在操作中途离开射频场卡片内部操作要么完成要么不完成不会出现MCU读了一个值、改了、但写不回的情况。这在安全性上确实更优。我实际测试过这种方案缺点是值块格式固定你没法在同一块里存卡号和流水号业务扩展性差一些。如果项目对防现场断单有硬性要求值块方案值得认真考虑如果只是普通demo或内部使用读改写方案更灵活也更容易理解。6.2 断电保护与交易流水充值扣款这种涉及金额的操作系统整体可靠性不能只靠卡上数据。Mifare卡是射频非接触卡存在被强行拔开、掉电、干扰等不可控因素。如果你的项目真的用于生产环境强烈建议在MCU外扩一片EEPROM或Flash记录每一笔交易的流水日志。流程变成先读日志区当前写位置然后写入一条交易记录卡号、操作类型、金额、交易序号、时间戳写完日志后再去操作卡片。如果卡片操作失败通过日志可以判断这笔交易到底完成没有不会出现钱扣了但日志没了的糊涂账。这块成本很低一个几毛钱的EEPROM就能存几百上千条流水但带来的保障是质变。很多消费系统验收时审计和追溯是硬指标到时候你就能体会到流水日志的价值了。6.3 说在最后的一些体会把充值和扣款这两个函数写稳我的体会是千万别急着写业务代码先把MFRC522的五个底层步骤跑得滚瓜烂熟再把数据格式和校验设计好最后才是写业务逻辑。很多人上来就写充值函数写完之后发现数据老丢、认证老失败回头又去补底层来回折腾的时间反而更多。还有一个心得是关于测试的。我调这套函数的时候把充值、扣款、查询余额写成了串口命令电脑上一行一行地发指令做了差不多上千次循环压测。这个环节特别重要可以快速暴露重复扣款、数据丢失这类只在长时间运行后才会出现的问题。等压测稳定了再接到真正的消费终端程序里基本一次通过。这套思路和代码适配的不仅仅是一个充值扣款Demo。你把数据格式换成积分、门禁次数、停车时长业务函数改改判断条件就是一套完整的储值类系统内核。RFID这种东西难不是难在硬件本身难的是把业务稳定地跑在廉价卡片上。希望这篇内容能帮你少走点弯路。