☰
STM32与MFRC522钱包读写:充值扣款函数设计与掉电保护方案
2026/10/14 1:07:36 网站建设 项目流程

项目做了一段时间嵌入式开发,尤其是接触过好几套 RFID 刷卡方案之后,我越来越觉得:STM32 配 MFRC522 这种组合,真正难的不是“读卡号”,而是后面的“业务逻辑”——充值和扣款。网上大部分例程都停在“能读到 UID”这一步,但实际项目里,你要的是卡片余额能加能减,还要防止误操作、掉电、数据写坏。这篇就以“基于 STM32 的 MFRC522 充值与扣款函数”为题,把设计思路、实现细节、调试踩坑一次讲透,适合已经能跑通基础读卡、准备做门禁消费、洗衣房扣费、自助充电桩这类场景的开发者参考。

1. 项目拆解:充值与扣款函数到底解决什么问题

1.1 核心场景:为什么用 RFID 做钱包

很多智能硬件项目里,用 IC 卡代替现金或手机扫码,不是因为技术上多前沿,而是因为离线场景太需要了。比如校园洗衣房、园区自助售卖机、单位食堂的临时消费柜,这些地方网络不稳定,甚至现场根本没有联网条件。如果用后台数据库来判定余额,一旦断网,整个设备就废了。更好的方案是让卡片本身存余额,设备只负责读、改、写,终端不需要联网也能工作。

这种“金额跟着卡片走”的模型就叫电子钱包。STM32 负责业务处理和外围控制,MFRC522 负责和 Mifare 系列卡片通信。充值和扣款函数是这个模型的核心:充值就是往钱包里加钱,扣款就是判断余额够不够再从钱包里减钱。看起来简单,但真要在嵌入式环境下做稳定,必须解决三个问题:

  1. 卡片数据区的存储格式怎么组织,才能避免一次写坏整块数据;
  2. 扣款和充值过程中掉电怎么办,会不会出现“扣了钱但余额没减”;
  3. 多张卡靠太近、重复刷卡、余额为负数等边界情况怎么拦住。

1.2 为什么选 STM32 + MFRC522,而不是其他方案

MFRC522 是一颗非常经典的 13.56MHz 非接触式读写芯片,支持 ISO/IEC 14443A 和 Mifare 协议,价格便宜、驱动成熟,几乎每个嵌入式开发者的元件盒里都有一块。STM32 作为主控,性能完全够用,SPI 接口跑 2Mbps 通信,读写卡片一秒钟能操作几十次;同时 STM32 有丰富的定时器、中断、串口资源,方便做蜂鸣器提示、继电器控制、显示屏刷新这些配套功能。

有朋友会问:直接用读卡模块自带的串口指令不就行吗?部分串口模块确实封装了“读卡号”和“扣款”指令,但它把逻辑写死在模块固件里,你没法自定义余额格式,也没法做防拆校验和日志。自己做的好处是每张卡片的数据结构完全可控,加密方式、校验算法、扇区规划都能随项目调整。STM32 又是自己熟悉的平台,加功能、修 Bug 都顺手。

2. 硬件与驱动准备:先保证能稳定读到卡

2.1 接线与供电细节

我这里用的主控是 STM32F103C8T6 核心板,MFRC522 模块通过 SPI 连接。连线方式如下:

引脚MFRC522 模块STM32F103C8T6
SDANSS/SDAPA4 (CS)
SCKSCKPA5 (SCK)
MOSIMOSIPA7 (MO)
MISOMISOPA6 (MI)
RSTRSTPB0
IRQIRQ暂不连接
3.3VVCC3.3V
GNDGNDGND

注意几个容易踩的坑:

  • MFRC522 必须接 3.3V,不能接 5V。接 5V 虽然模块上可能有稳压芯片,但如果你的模块是精简版,长期 5V 供电容易发热甚至烧毁。
  • SPI 引脚不要随便换。MFRC522 库在初始化的时候一般会写死 SPI 的引脚,如果换了引脚,要同步改库里的硬件定义。
  • IRQ 引脚可以先不接,我们用轮询方式读卡,开发阶段更简单;后期要搞低功耗或需要卡片靠近立即唤醒再考虑中断。

供电方面,STM32 核心板从 USB 取电,MFRC522 直接从 3.3V 引脚取电。如果项目里还要驱动继电器、蜂鸣器、显示屏,建议把外设供电和读卡模块供电分开,至少保证 MFRC522 电源纹波不要太大。天线匹配是模块出厂就调好的,所以正常情况下一张标准 IC 卡的读卡距离在 3~5 厘米左右。如果读卡距离特别近,先检查供电,再检查卡片是不是损坏或天线附近有大面积金属。

2.2 驱动移植与初始化验证

先直接使用现成的 MFRC522 驱动库(Arduino 风格的封装在 STM32 上也能通过移植跑起来)。把库文件和头文件加入工程,初始化代码如下:

MFRC522 mfrc522(SPI_Handle); void RFID_Init(void) { SPI_Init(); mfrc522.PCD_Init(); mfrc522.PCD_SetAntennaGain(mfrc522.RxGain_max); }

如果你是用 CubeMX 生成的工程,SPI 初始化代码已经由代码生成器帮你处理了,只需要设置 SPI 速率在 2MHz 左右。速率太高容易出现通信不稳定,太低则刷卡响应慢。调用PCD_Init()之后,可以通过PCD_ReadRegister读取版本寄存器,返回 0x92 或 0x92 之外的值都正常,但是返回 0x00 或者一直读不到,就要回头检查接线了。

初始化完成后,先做读卡测试:

if ((mfrc522.PICC_IsNewCardPresent() != 0) && (mfrc522.PICC_ReadCardSerial() != 0)) { // 读到了卡,uid 存在 mfrc522.uid 结构体里 }

能稳定读到 UID 之后再进入下一步,否则后续开发全是空中楼阁。这里建议做一个串口调试命令:把卡的 UID 打印出来,顺便打印 PICC 类型。Mifare Classic 1K、4K 和 Mifare Ultralight 的数据存储格式不一样,本项目的实现默认使用 Mifare Classic 1K 卡。

3. 核心函数实现:充值与扣款的基本盘

3.1 数据区划分与校验设计

Mifare Classic 1K 卡有 16 个扇区,每个扇区 4 个块,每个块 16 字节。扇区 0 的块 0 是厂商数据区,存着 UID 和厂商信息,一般不要改动。我们需要规划一个“钱包数据区”和备用区。我常用的规划如下:

区域扇区块地址用途
钱包主区1块 4、块 5余额主副本
钱包备份区1块 6余额备份副本
交易流水区2块 8~11每次充扣记录
系统配置区3块 12卡类型/版本号

块地址计算规则:第 n 个扇区,起始块是n * 4,扇区里的四个块地址分别是n*4、n*4+1、n*4+2、n*4+3,其中n*4+3是扇区尾块,用来存放密钥 A、访问位、密钥 B。我们读写普通数据块时,不要碰扇区尾块。

余额用整数存储,单位用“分”,比如余额 12.34 元就存1234。选int32_t类型足够覆盖常用场景,单张卡余额上限可以设成 10000000 分,也就是 10 万元,安全边界足够。单块 16 字节的存储规划我这样设计:

typedef struct { uint32_t magic; // 固定标志 0xA5A55A5A,用来辨别块是否初始化 int32_t balance; // 余额,单位分 uint16_t version; // 版本号,每次修改 +1 uint8_t status; // 0x01=正常,0x00=作废 uint8_t checksum; // 简单校验字节 uint8_t reserved[4];// 预留 } WalletBlock;

总共 4+4+2+1+1+4 = 16 字节,刚好一块。checksum 计算公式可以用“对前 15 个字节累加求和取低 8 位”,虽然强度不高,但对于离线小系统足够防止偶发数据跳变:

uint8_t CalChecksum(uint8_t *buf, uint8_t len) { uint8_t sum = 0; for (uint8_t i = 0; i < len; i++) { sum += buf[i]; } return sum; }

每次写入前计算校验字节,读取后先校验再解析,这是最基本的容灾手段。

3.2 充值函数:完整流程与代码骨架

充值函数的目标:把卡片旧余额读出来,加上外部传入的充值金额,再写回卡片。这里我把整个流程拆成六个阶段:

  1. 搜索卡片并选卡;
  2. 对钱包扇区进行密钥认证;
  3. 读取钱包主块;
  4. 检查校验、检查 magic;
  5. 计算新余额并生成新钱包块;
  6. 写入卡片并结束通信。

先看完整代码骨架,然后逐段解释:

#define WALLET_BLOCK_ADDR 4 #define BACKUP_BLOCK_ADDR 6 #define DEFAULT_KEY_A 0xFF typedef struct { uint32_t magic; int32_t balance; uint16_t version; uint8_t status; uint8_t checksum; uint8_t reserved[4]; } WalletBlock; static uint8_t walletBlockToBytes(WalletBlock *wb, uint8_t *outBuf) { uint8_t *p = outBuf; memcpy(p, &wb->magic, 4); p += 4; memcpy(p, &wb->balance, 4); p += 4; memcpy(p, &wb->version, 2); p += 2; *p++ = wb->status; *p++ = 0; // checksum 位置先占住 memset(p, 0, 4); // reserved outBuf[15] = CalChecksum(outBuf, 15); return 16; } static void bytesToWalletBlock(uint8_t *buf, WalletBlock *wb) { memcpy(&wb->magic, buf, 4); memcpy(&wb->balance, buf+4, 4); memcpy(&wb->version, buf+8, 2); wb->status = buf[10]; wb->checksum = buf[11]; } static uint8_t verifyWalletBlock(uint8_t *buf) { WalletBlock wb; bytesToWalletBlock(buf, &wb); if (wb.magic != 0xA5A55A5A) return 0; if (wb.status != 0x01) return 0; if (CalChecksum(buf, 15) != buf[11]) return 0; return 1; }

下面是 RFID 操作主要函数:

uint8_t RFID_IsCardPresent(void) { if (!mfrc522.PICC_IsNewCardPresent()) return 0; if (!mfrc522.PICC_ReadCardSerial()) return 0; return 1; } uint8_t RFID_Authenticate(uint8_t blockAddr) { MFRC522::MIFARE_Key key; for (uint8_t i = 0; i < 6; i++) key.keyByte[i] = DEFAULT_KEY_A; MFRC522::StatusCode status = mfrc522.PCD_Authenticate( MFRC522::PICC_CMD_MF_AUTH_KEY_A, blockAddr, &key, &(mfrc522.uid)); return (status == MFRC522::STATUS_OK) ? 1 : 0; } int32_t RFID_ReadBalance(void) { uint8_t buffer[18]; uint8_t size = sizeof(buffer); if (!RFID_Authenticate(WALLET_BLOCK_ADDR)) return -1; MFRC522::StatusCode status = mfrc522.MIFARE_Read(WALLET_BLOCK_ADDR, buffer, &size); if (status != MFRC522::STATUS_OK) return -1; if (!verifyWalletBlock(buffer)) return -2; WalletBlock wb; bytesToWalletBlock(buffer, &wb); return wb.balance; } uint8_t RFID_WriteBalance(int32_t newBalance, uint16_t version) { WalletBlock wb; wb.magic = 0xA5A55A5A; wb.balance = newBalance; wb.version = version; wb.status = 0x01; uint8_t bytes[16]; walletBlockToBytes(&wb, bytes); // 先写备份区,再写主区,降低写坏风险 if (!RFID_Authenticate(BACKUP_BLOCK_ADDR)) return 0; if (mfrc522.MIFARE_Write(BACKUP_BLOCK_ADDR, bytes, 16) != MFRC522::STATUS_OK) return 0; if (!RFID_Authenticate(WALLET_BLOCK_ADDR)) return 0; if (mfrc522.MIFARE_Write(WALLET_BLOCK_ADDR, bytes, 16) != MFRC522::STATUS_OK) return 0; return 1; }

有了这几个底层函数,充值就非常清晰了:

uint8_t Card_Recharge(int32_t amount) { if (amount <= 0) return 0; if (!RFID_IsCardPresent()) return 0; int32_t oldBalance = RFID_ReadBalance(); if (oldBalance < 0) return 0; int32_t newBalance = oldBalance + amount; if (newBalance > MAX_BALANCE) { printf("余额超上限\r\n"); return 0; } uint16_t newVersion = getCurrentVersion() + 1; if (!RFID_WriteBalance(newBalance, newVersion)) return 0; printf("充值成功,新余额=%d分\r\n", newBalance); mfrc522.PICC_HaltA(); mfrc522.PCD_StopCrypto1(); return 1; }

眼尖的朋友会发现一个细节:RFID_WriteBalance先写备份区,再写主区。这是为了防止写主区时掉电。如果主区刚写完新值、备份区还是旧值,虽然两个副本不一致,但我们可以靠 version 和状态字来判断哪个是最新的。恢复规则是:启动或读卡时,同时读主区和备份区,如果两个都能通过校验,选 version 大的那个;如果有一个校验失败,用另一个恢复。这就是“双备份 + 版本号”的经典方案。

充值金额从哪来?我在实际项目里用串口输入:

int32_t amount = 0; printf("请输入充值金额(分): "); scanf("%d", &amount); Card_Recharge(amount);

如果是带按键的设备,可以改成按键输入或菜单选择;如果对接了上位机,就通过串口帧协议传给 STM32。业务层只需要拿到一个正整数,下面的事就交给充值函数。

3.3 扣款函数:余额判断与防负余额

扣款函数和充值逻辑类似,但多了一个关键环节:余额检查。如果卡上只有 5 块钱,你要扣 10 块,必须直接拒绝,不能出现负余额。扣款函数骨架:

uint8_t Card_Deduct(int32_t amount) { if (amount <= 0) return 0; if (!RFID_IsCardPresent()) return 0; int32_t oldBalance = RFID_ReadBalance(); if (oldBalance < 0) return 0; if (oldBalance < amount) { printf("余额不足,当前余额=%d分,需要=%d分\r\n", oldBalance, amount); return 0; } int32_t newBalance = oldBalance - amount; uint16_t newVersion = getCurrentVersion() + 1; if (!RFID_WriteBalance(newBalance, newVersion)) return 0; printf("扣款成功,新余额=%d分\r\n", newBalance); mfrc522.PICC_HaltA(); mfrc522.PCD_StopCrypto1(); return 1; }

这里有一个非常重要的细节:扣款动作应该在“操作执行成功”之后才返回成功,不能只判断“读出来的余额够扣”就返回。比如门禁扣费、洗衣房扣钱,如果扣款函数返回成功但卡片没写进去,用户白扣了钱,后台结算时候很容易扯皮。所以我习惯在RFID_WriteBalance返回成功后才点亮 LED、拉继电器,否则直接报错并提示用户重新刷卡。

还有一个并发问题:如果用户把卡放在感应区,主循环又反复检测到卡片,同一个扣款操作会被重复执行。解决办法就是“状态机 + 停留检测”:完成一次扣款后,先PICC_HaltA(),让卡片进入 HALT 状态;同时在软件里加一个防重复窗口,比如扣款成功后的 1 秒内不再接受同一张卡的扣款请求。后者就需要记录卡的 UID 和上次操作时间。

4. 状态机、并发与数据安全

4.1 应用层状态机设计

充值和扣款虽然体现在两个函数里,但实际设备一般还有待机、放卡、交易中、交易完成、暂停服务等状态。如果只用一个 if 函数包住所有流程,代码后边会越来越乱。我用一个简单状态机管理:

typedef enum { STATE_IDLE, STATE_WAIT_CARD, STATE_VERIFY, STATE_TRADE, STATE_DONE, STATE_ERROR } SystemState;

在STATE_WAIT_CARD状态下,不断检测卡片;检测到卡片后进入STATE_VERIFY,做密钥认证和数据校验;校验通过进入STATE_TRADE,根据当前模式(充值/扣款)执行对应函数;执行完成后进入STATE_DONE,点亮指示灯、播放蜂鸣器,然后回到STATE_IDLE。这个状态机的价值在于:每一步都可以单独加超时,如果卡片中途拿走,STATE_VERIFY或STATE_TRADE超时后强制回STATE_IDLE,不会卡死在半路。

实际项目中,我还在STATE_ERROR里做过自动重试:扣款写卡失败时,自动重新读一次主区和备份区,如果备份区可用就用备份区恢复主区,再尝试扣款。重试两三次还不行,就报“卡片故障”并写日志。

4.2 防重放、防掉电与卡片数据安全

嵌入式 RFID 和手机支付最大的不同是:卡片可以被任意读卡器访问,密钥一旦泄露,里面的数据就能被改写。所以基于 MFRC522 的充值扣款系统,本质上做不到银行级安全。但我们可以在应用层做几道防护:

  1. 认证必须使用 KEY A 或 KEY B,不要使用默认全 F 的钥匙生产上线。出厂前把每个扇区的密钥改成随机值,并且备份在自己数据库里。否则别人拿个默认密钥匙就能给任意卡片充值。
  2. 数据块加 magic 和校验字节,避免把未初始化或写了一半的块当作有效余额。我在调试中见过很多次:新卡拿到手,块数据全是 0,如果不加 magic 判断,系统会把 0 当成余额 0 分,然后扣款失败。有了 magic,空卡可以直接引导去充值。
  3. 余额修改必须带版本号,版本号只增不减。双副本校验选高版本,这样即使主区写入失败,备份区还能兜底。
  4. 记录流水日志。每张卡在扇区 2 保留最近若干条交易记录,数据内容包括操作类型(充值/扣款)、金额、操作后的余额、时间戳、操作流水号。流水可以让对账有据可查,也方便排查异常卡。

流水块设计示例:

typedef struct { uint32_t seq; // 流水号,每次交易+1 uint8_t opType; // 0x01=充值 0x02=扣款 int32_t amount; // 本次金额 int32_t postBalance;// 交易后余额 uint32_t timestamp; // 可选,来自设备 RTC 或计次器 uint8_t checksum; } TradeRecord;

一个块 16 字节刚好放一条记录。扇区 2 的四个块可以存四条记录,写的时候采用“找最旧覆盖”的策略。当然,卡片存储空间有限,流水只能作为临时凭证,真正完整流水还是要靠设备端日志或服务端汇总。

5. 调试实录与常见问题排查

5.1 常见问题速查表

我把自己做这类项目时碰到的问题和解决办法整理成下面这张表,很多坑是文档里找不着的:

现象可能原因处理方式
读不到卡供电不足、SPI 接线错、模块天线断线先量 3.3V 电压;核对 SDA/SCK/MOSI/MISO;换卡测试
能读 UID 但认证失败密钥不对、扇区尾块被改过打印 PCD_Authenticate 返回状态;确认默认密钥是否被修改
读余额返回 -1 或 -2认证失败或校验失败确认扇区地址,检查卡片数据区是否初始化
充值后写卡失败卡片被重复选中、Halt 状态异常检查 SPI 时序,降低 SPI 速率到 1MHz 测试
扣款后重启余额没变只写了主区没写备份区,掉电后回滚必须双写,靠 version 和校验恢复最高版本
卡片放感应区反复触发扣款缺少状态机或防重复机制交易后执行 HaltA,并加 1 秒禁止窗口
余额出现乱码或负值写卡中途掉电、校验函数没生效数据块加 CRC/累加校验,检测到异常块主动恢复备份

5.2 调试中的几个技术细节

我在调试过程中用过示波器量 SPI 信号,也用过串口打印整个 16 字节块内容。最有效的高速定位手段其实是“把 MFRC522 库的所有返回码串口输出”。MIFARE_Write返回STATUS_OK不代表数据 100% 正确,如果之后读回来和写进去不一致,八成是 SPI 速度过快,或者模块和天线距离太远、卡片放在了感应区边缘。

另外,MIFARE_Read读取一个块时,库的 buffer 长度通常会多留两字节,因为 MFRC522 在 READ 命令应答时会多返回两个 CRC 字节。我刚开始写代码时 buffer 只声明了 16 字节,结果库函数往里写到第 17 字节,导致紧挨着的变量被覆盖。虽然罪魁祸首是库的内存设计,但最好的规避办法是 buffer 声明为uint8_t buffer[18],传给它的大小也写成sizeof(buffer)。

还有一个我特别想提醒的细节:密钥认证是“针对扇区”的,不是针对单个块。你只要对扇区内任意一个数据块做过认证,同一个扇区内的其他块操作就不再需要重复认证。但如果你在读写同一个扇区的不同块之间调用了PCD_StopCrypto1,那么后面的读写必须重新认证。我在RFID_WriteBalance里先写备份区、再写主区,两个块虽然都位于扇区 1,但我为了保证代码逻辑独立,仍然在每次写之前都调用了一次RFID_Authenticate。实际跑下来没问题,就是因为重复认证同一个扇区是允许的,代价只是多花几十毫秒。

5.3 掉电与低功耗处理

如果设备用电池供电,充值和扣款瞬间的写卡电流会拉低电压,导致 STM32 复位、卡片写操作中断。我的做法是:在写卡之前判断一次电压(ADC 采样),如果电压低于阈值,不进入写卡流程;写卡期间禁止看门狗复位干扰。还有,在真正写卡前先把要写入的数据准备好、校验字节算好,尽量缩短写卡操作时间。

低功耗场景下,MFRC522 的 RF field 是可以关闭的。平时处于PCD_SoftPowerDown状态,需要刷卡时通过外部按键或定时唤醒重新初始化。这里要注意唤醒后必须重新执行PCD_Init,否则模块可能不太稳定。如果你们项目对功耗很敏感,建议详细看手册里软掉电和硬复位的时序差异。

个人经验:一套能复用的“钱包底层”

做完这几个 RFID 项目之后,我把充值和扣款函数沉淀成了一套可以复用的“钱包底层”。不管是洗衣机控制器、闸机扣费,还是小卖部结算,只要改一下外部输入金额的方式,底层读写函数基本不用大动。这套底层的核心价值不在那两三个函数,而在数据块设计和双备份验证机制上。这也是为什么我一直强调:不要把目光只盯在MIFARE_Write和MIFARE_Read上,更重要的是给这些读写函数套上一层业务壳,保证壳内数据在不同断电、干扰、重复刷卡情况下都能自洽。

如果你也想快速上手,建议第一步不要直接写代码,先把芯片手册里关于数据块、扇区尾块、密钥的部分翻一遍,再用我的规划在卡片上划好钱包区,然后跑一个“读余额-加钱-写回-再读”的串口测试。等这一条链路完全稳定了,再往上加复杂的业务状态机。最后留个建议:正式上线之前,至少做一次“故意在写卡中途断电”的实验,把恢复逻辑调到万无一失,后面运维能省下很多事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询