简介:这套PPT以公交卡充值为切入点,面向物联网、嵌入式或射频识别课程的师生,系统讲解RFID读写器与公交卡之间的无线通信流程。内容从系统初始化、LCD显示准备、阅读器使能与天线开启,到卡片识别、蜂鸣器响铃提示、卡片UID读取,再到充值前余额显示、充值金额写入与充值后余额更新,九个步骤环环相扣,并给出存储块1首字节余额变更的程序设计思路与核心代码,适合教学演示或自学入门。资源包仅1个pptx文件,大小2.17MB,幻灯片层次清晰,配有流程框图和主要函数说明,便于直接用于课堂讲解或复习整理。目前已有92人浏览学习,是快速理解RFID在公共交通中应用逻辑的轻量而完整的学习材料。
1. 射频识别技术下的RFID公交卡充值:表面是“写金额”,实际是一场安全事务
RFID公交卡充值这个题目,课程设计、硬件入门和毕设里都很常见,但多数人把它理解成“把卡里的钱改大一点”。真正动手做一遍就会发现,射频识别技术负责的是“怎么把数据安全写进卡里”,而充值系统负责的是“怎么让这笔账不会多也不会少”。我第一次做这块时,充值一次卡片,余额看着正确,拿去消费发现扣款记录全乱——问题不在读卡器,在于我把充值当成单次写操作,而不是一个需要回滚校验的事务。
这篇笔记面向两类读者:一是要用射频识别技术做课程项目或原型验证的学生,二是要在业务系统里接读写器的开发者。我会从卡片的存储结构讲到充值代码的最小实现,最后给出几个实际项目里高频翻车的位置。核心结论先放在前面:公交卡充值的难点从来不是“写不进”,而是“写成功了但账不平”。
2. 先把卡和读写器看明白:M1卡的四层结构与充值模型
2.1 ISO14443 Type A与M1卡:公交卡“藏钱”的存储结构
公交卡这类非接触卡,绝大多数走 ISO14443 Type A 协议,工作频率 13.56MHz,通信距离通常只有几厘米。协议从物理层到应用层可以拆成四层:射频唤醒、防冲突、命令传输、数据读写。日常说的“滴一下”,本质是读卡器把卡片从休眠态唤醒,然后通过一串命令与卡片内的芯片建立安全通道。
常见卡型是 M1 卡(MIFARE Classic 1K),存储空间 1KB,划分为 16 个扇区,每个扇区 4 个块,每个块 16 字节。第 0 扇区第 0 块比较特殊,里面存放卡片序列号 UID 和厂商数据,出厂即锁定,一般不允许改写。真正的“钱包文件”通常在某个指定扇区的数据块里,比如把第 1 扇区的块 0、块 1 作为余额区和备份区。注意,每个扇区的第 3 块是尾块,里面放 KeyA、KeyB 和访问控制位,直接决定这个扇区能不能读、能不能写、用哪把钥匙验证。写代码时最容易犯的错误之一,就是拿数据块的偏移去算尾块地址,结果把控制字节写坏。
理解这块存储结构,是后面所有充值逻辑的地基。你不需要把 16 个扇区全部背下来,但必须记住三个数字:每扇区 4 块、每块 16 字节、第 3 块是尾块。后面写地址计算公式时,这三个数字会反复出现。
2.2 充值不是写钱,是“读值-计算-写回”的三步事务
M1 卡的块写入每次 16 字节,卡本身没有“金额”这个概念。所谓余额,是应用层约定的一种数据格式,常见做法是取其中 4 个字节做小端序整数,单位是分或角,少数卡用 BCD 编码。充值一次,本质上要完成三个操作:读出当前余额块,按字节序转成数值;把充值金额加进去;再把新值写回同一个块。
难点在这三步之间的窗口期。如果读出来之后、写回之前,卡片被从读写器上拿走,余额块还停留在旧值,用户付了钱却没加上;如果写回过程被中断,可能出现半个块写入成功,金额变成一个未知值,这种情况最麻烦,因为卡里不是“旧值”也不是“新值”,而是一个逻辑上可能非法的数。
所以较严谨的实现会把充值做成一个事务:先备份旧值,写回后立即读回校验,失败就重新写回旧值。这一个流程常被当成“多写一行代码”,实际是充值项目里决定成败的部分。很多线上充值事故,追到最后都不是命令发错,而是少了读回校验这一步。
2.3 选型:读写器、SDK 和卡,先匹配协议再谈功能
我一般把读写器选型分三个维度:协议层支持、主机接口、SDK 完整度。协议层必须支持 ISO14443A 和 MIFARE Classic,否则读不到 M1 卡。有些读写器主打 CPU 卡也能读,但读不了 M1 卡,采购前要确认。主机接口常见 USB 虚拟串口、串口 TTL、I2C/SPI,做原型最常见的是 USB 虚拟串口,因为 Windows 上直接映射成 COM 口,不需要额外写驱动通信层。
| 接口类型 | 典型形态 | 开发方式 | 适用阶段 |
|---|---|---|---|
| USB 虚拟串口 | 桌面读卡器 | 串口收发命令 | 课程设计、原型验证 |
| 串口 TTL | 嵌入式模块 | 单片机直接发指令 | 集成到收费终端 |
| I2C/SPI | 贴片模块 | 寄存器读写 | 量产设备、低功耗 |
SDK 的选择有个容易被忽视的点:厂商提供的动态库封装度高,但遇到跨平台或换读卡器时会很被动;命令手册反而更适合项目后期维护,因为底层本质上是一串串口指令。选型前先跟厂商确认两件事:命令集是否开放、文档里是否写明每个命令的超时和重试策略。这两个信息缺失的项目,后面通常要返工。
3. 搭一套能跑通的最小充值环境:驱动、串口与第一条寻卡命令
3.1 最小硬件和软件栈
硬件上只需要三样:一台电脑、一个 USB 接口的 13.56MHz 读写器、一张空白 M1 卡。软件上我推荐用 Python 加串口库,原因很简单:串口指令在 Windows、Linux、macOS 上通用,换读卡器时改动最小,而且命令行的调试过程肉眼可见。
# 创建虚拟环境并安装 pyserial python -m venv venv source venv/bin/activate # Windows 下为 venv\Scripts\activate pip install pyserial逻辑说明:这里没有直接用厂商 SDK,是因为串口这一层把“读卡器型号”和“业务逻辑”隔开了。哪怕后面对接的是另一家读卡器,只要命令集类似,改动的只是命令字节,不涉及业务代码。参数说明:pyserial 是串口通信的事实标准库,安装后可以用serial.tools.list_ports枚举设备,方便确认读卡器的 COM 口号。
装好之后插上读卡器,先用下面的脚本确认系统认到了设备:
import serial.tools.list_ports ports = serial.tools.list_ports.comports() for p in ports: print(p.device, p.description)跑出来能看到类似COM3 USB Reader这样的输出,就说明驱动已经生效。如果列表里空荡荡,优先检查 USB 线是不是只有供电没有数据线,这种情况在廉价读卡器上很常见。
3.2 用 PC/SC 发出第一条寻卡命令:从“滴”到拿到卡号
串口调试是最底层的做法,但日常开发里大部分桌面读卡器都支持 PC/SC 标准。我建议直接走 PC/SC 这一层,因为它的 APDU 指令跨厂商通用,不需要为每一款读卡器重新查命令表。
from smartcard.System import readers from smartcard.util import toHex # 获取读卡器列表 r = readers() print("可用读卡器:", r) # 连接第一台读卡器 conn = r[0].createConnection() conn.connect() # 发送寻卡指令:PC/SC 标准 APDU FF CA 00 00 00 表示“获取 UID” data, sw1, sw2 = conn.transmit([0xFF, 0xCA, 0x00, 0x00, 0x00]) print("UID:", toHex(data), "状态:", hex(sw1), hex(sw2))逻辑说明:FF CA是 PC/SC 标准的 GetUID 指令,后面三个00分别表示参数和期望返回长度,操作时把卡放在天线区就会返回 4 字节(或 7 字节)的 UID。sw1/sw2是状态码,0x9000代表成功,0x6300代表没寻到卡,先把卡放上去再执行。参数说明:readers()[0]选的是第一台读卡器,如果电脑上插了多台,顺序可能随插拔变化,不能写死不换。
这条命令能通,说明从 PC 到读卡器再到天线这条链路是好的。卡号 UID 也是后面日志系统的主键,每一笔充值都要关联这张卡的 UID,否则账目没法核对。有些项目把 UID 当成“卡号”直接存起来,注意 UID 可以被部分工具修改,真正做清结算时一般还要绑定应用层卡号。
3.3 为什么读卡器功率越大,有时候反而读不到卡
有一个现象很反直觉:读卡器发射功率调大,卡片贴近天线中心反而没反应。原因在于 13.56MHz 的非接触通信靠电磁场耦合供电,场强过强会让卡片芯片进入过压复位状态,表现就是“放上去滴一声都没有”。我见过不少同学对着代码调半天,最后发现是卡片放太正了。
这类问题的排查顺序是:先把卡片从天线中心挪开一点,让耦合场弱一些;再看读卡器软件里有没有功率档位可调;最后检查天线区域是不是被金属物体覆盖。实际做充值台时,卡片悬空 1 到 2 毫米比紧贴台面更稳定,这一点和直觉相反,但在项目里很常见。
另外还要区分“读不到卡”和“验证失败”。读不到卡是寻卡命令直接返回超时,验证失败是已经拿到 UID,但后续密钥验证返回错误码。这两个问题定位方向完全不同,前者查射频链路,后者查密钥和访问控制位。调试时先把这两类现象分开,能省下大量时间。
4. 充值流程落地:验证密钥、余额块读写与金额回写
4.1 把充值拆成五步状态机
写充值代码前,我习惯先把流程画成状态机,因为每一步都有失败分支,没有状态机的代码最后会变成一团乱麻。整个充值过程可以拆成五步:寻卡获取 UID、选择卡片、验证扇区密钥、读取余额块、计算新余额并写回。
| 步骤 | 操作 | 失败特征 | 处理方式 |
|---|---|---|---|
| 1 | 寻卡 | 无响应 | 检查卡片放置位置 |
| 2 | 选卡 | 防冲突失败 | 移走其他卡片再试 |
| 3 | 验证密钥 | SW 返回错误码 | 确认密钥与访问控制位 |
| 4 | 读取余额块 | 数据长度异常 | 打印原始字节排查格式 |
| 5 | 写回新余额 | 写后读不一致 | 重新验证密钥后再次写回 |
每一步失败都要有明确的返回码而不是只打印“error”。例如验证密钥失败时,PC/SC 返回的sw1通常是0x63,后面跟的具体值还能区分是密钥错还是块地址越界。把状态码映射成可读文案,是充值系统上线前必须做的功课。
这里有一个关键点:充值金额的计算放在第 5 步之前,但金额合法性校验要放在第 1 步之后。如果连卡都没读到就计算新余额,等于在本地算了一笔假账。正确顺序是先拿到 UID,再读余额,再算新值,最后写回。
4.2 核心代码:充值事务的最小实现
下面这段代码基于 PC/SC 标准 APDU,用的是默认密钥FF FF FF FF FF FF。如果卡片被公交公司做过个性化,密钥不是默认值,需要换成业务方提供的密钥。
from smartcard.System import readers from smartcard.util import toHex import struct # 扇区与密钥参数 SECTOR = 1 # 余额所在扇区,不同卡商约定不同 BLOCK = SECTOR * 4 # 扇区1的第一个数据块,绝对块地址=扇区号*4 KEY = [0xFF] * 6 # 默认 KeyA: FF FF FF FF FF FF conn = readers()[0].createConnection() conn.connect() def load_key(key): # 加载认证密钥到读卡器:FF 82 00 00 06 + 6字节密钥 data, sw1, sw2 = conn.transmit([0xFF, 0x82, 0x00, 0x00, 0x06] + list(key)) return sw1 == 0x90 def authenticate(block): # 扇区认证:FF 86 00 00 05 01 00 <块号> <60=KeyA> <00> data, sw1, sw2 = conn.transmit([0xFF, 0x86, 0x00, 0x00, 0x05, 0x01, 0x00, block, 0x60, 0x00]) return sw1 == 0x90 def read_block(block): # 读二进制块:FF B0 00 <块号> <长度> data, sw1, sw2 = conn.transmit([0xFF, 0xB0, 0x00, block, 0x10]) return bytes(data) def write_block(block, data): # 写二进制块:FF D6 00 <块号> <长度> + 16字节数据 data, sw1, sw2 = conn.transmit([0xFF, 0xD6, 0x00, block, 0x10] + list(data)) return sw1 == 0x90 if not load_key(KEY): print("密钥加载失败") exit(1) if not authenticate(BLOCK): print("扇区验证失败,卡片可能已被其他密钥保护") exit(1) raw = read_block(BLOCK) print("原始数据:", toHex(raw)) balance = struct.unpack('<I', raw[:4])[0] # 余额按小端序存于前4字节 print("当前余额(单位: 分):", balance) recharge_amount = 1000 # 充值10.00元 = 1000分 new_balance = balance + recharge_amount new_raw = bytearray(raw) new_raw[:4] = struct.pack('<I', new_balance) if write_block(BLOCK, bytes(new_raw)): ver = read_block(BLOCK) ver_balance = struct.unpack('<I', ver[:4])[0] if ver_balance == new_balance: print("充值成功,新余额:", ver_balance) else: print("读回校验不一致,请重新写入旧值") else: print("写入失败,余额块保持旧值")逻辑说明:load_key把密钥先装进读卡器,authenticate对指定块发起扇区认证,认证的是该块所在的整个扇区,不是单块。read_block返回 16 字节,余额格式按前面约定是小端 4 字节,所以只取前 4 个字节做解析。写回时用bytearray保留原块其他字节,只替换前 4 字节,避免把别的数据覆盖掉。
参数说明:SECTOR和BLOCK的关系是绝对块地址 = 扇区号 × 4 + 块内偏移,代码里BLOCK = SECTOR * 4是扇区 1 的块 0。KEY如果写错,sw1会返回0x63,后面那一位用来区分具体原因。金额单位用分而不是元,是为了避免浮点数精度问题。读回校验不是可选项,写卡这种操作不校验等于没有操作。
4.3 金额上限、写入次数与“充值失败”到底由谁决定
M1 卡每个块 16 字节,金额字段常用前 4 字节存储,理论上最大值可以到 40 亿分,但实际业务里单次充值金额上限通常只有几百元。这个上限不是存储层决定的,而是应用层风控决定的。充值系统要在写卡前校验单次金额、当日累计金额,甚至卡片余额上限,这些校验放在读卡器外部,属于账务逻辑。
写入次数是另一个隐形边界。M1 卡的 EEPROM 擦写寿命大约 10 万次,正常用户一天充一次根本用不完,但现在很多测试脚本会在同一块上循环写几千次压测,这类测试会让卡片提前老化。我一般会在测试代码里加一个计数器,限制单张测试卡的写次数,并且把测试卡和正式卡分开管理。
很多人把“充值失败”归咎于写卡命令没发出去,实际上大量失败发生在写卡前的校验阶段:余额超出上限、金额格式错误、密钥验证超时。所以调试时要先确认是哪一层拦截了操作,读卡器返回0x9000只代表命令执行成功,不代表业务上允许这笔记账。
5. 避坑指南:RFID公交卡充值最容易翻车的五个位置
5.1 尾块被当普通数据块写入,扇区直接报废
现象:充值时算错块地址,把某个扇区的第 3 块当成数据块写入,写入后该扇区再也无法验证,读写全部报错。
原因:M1 卡每个扇区 4 个块,第 3 块存放密钥和访问控制位,不是数据区。很多人直接用“扇区号 + 3”算地址,或者拿扇区号当块号,写坏了访问控制条件,导致密钥验证永远失败。
解决:统一用绝对块地址公式扇区 × 4 + 块内偏移,并在写之前打印目标块号。如果目标块号除以 4 余 3,必须停下来确认是不是尾块。测试脚本里加一个断言,防止误写。
5.2 密钥验证一直失败,但 Key 确实是对的
现象:同一张卡,别人的工具能读,自己的程序验证不过,换一台读卡器也一样。
原因:很可能不是密钥本身错了,而是用了错误的验证方式。M1 卡可以用 KeyA 或 KeyB 两种方式验证,认证命令里0x60表示用 KeyA,0x61表示用 KeyB。如果访问控制位设置成只允许通过 KeyB 验证,而你的代码一直用0x60,就会一直失败。
解决:先读取扇区尾块前 6 字节确认 KeyA,再读访问控制字节判断密钥使用策略。访问控制位的位含义比较绕,简单办法是拿一张已知正常的卡做对比。若尾块数据读不出来,说明该扇区的访问控制把读操作也禁了,这种情况只能换卡或找发卡方处理。
5.3 充值成功但账目对不上:字节序和单位在打架
现象:充 10 元后读回来变成 2560 分,或者充 1 元显示 0.1 元,卡里数据看着“合法”但金额完全不对。
原因:金额字段的字节序和单位没有对齐。常见格式是小端 4 字节整数,但也有卡用大端,或者用 BCD 编码;单位有的是分,有的是角。另外部分卡片会加校验字节,金额字段并不是裸数据。
解决:调试时先打印原始 16 字节十六进制,不要只看解析结果。对照卡商文档确认字节序、单位和是否有校验字节。代码里用struct.unpack时,<I是小端无符号,>I是大端,struct.pack的规则要和解包保持一致。
5.4 读卡器返回“成功”但卡上余额没变
现象:写命令返回0x9000,紧接着读回余额,发现还是旧值,没有报任何错误。
原因:部分读卡器在连续进行多次读写后,扇区认证状态会失效,写命令实际没有被卡片接受。还有一类情况是写入数据长度不足 16 字节,读卡器按短写处理,只覆盖了部分内容。
解决:每次写操作前重新执行一次authenticate,不要复用前面的认证状态。写完成后必须读回校验,读回的字节和写入的字节逐一对比。如果发现读卡器对短写有特殊处理,就在代码里强制补齐 16 字节再发。
5.5 天线区“玄学”:卡片放上去没反应,拿远一点反而成功
现象:卡片贴在天线中心没有任何响应,抬高几毫米或者往边上挪一点就能正常读卡,位置非常敏感。
原因:耦合场强过强时,卡片芯片会进入过压复位状态。这种问题在低价读卡器上特别常见,天线尺寸小、驱动电路简单,导致近场区场强不均匀。
解决:优先调整卡片位置,让它偏离天线中心;其次看读卡器或模块是否支持降低发射功率,很多嵌入式模块有功率配置寄存器。做充值台或闸机时,把卡片读写区设计成有一定厚度的壳,让卡离天线保持固定距离,能有效减少这类问题。
6. 从演示到可用:充值日志、防重放与“最后读一次”的校验习惯
一个充值功能从“能跑”到“能用”,差的往往不是写卡成功率,而是账务上的严谨程度。我见过最典型的翻车:卡里余额写对了,但后台流水里没有这笔充值记录,因为代码先写卡后写日志,写卡成功但日志落库时服务重启了。正确的顺序是先落日志再写卡,日志状态先标“待完成”,写卡成功后再更新为“已完成”。
防重放是另一个容易被忽略的点。同一个充值请求如果被重复执行,链路层没有任何机制阻止第二次写入同样的金额,用户可能被重复扣款。常见的做法是生成充值序号,写在本地账本或服务端流水里;再次收到相同序号时直接拒绝。这个序号不需要加密,但必须保证单卡递增。
我的习惯是任何时候写完卡,都强制再读一次,而不是只看写命令的返回码。读回的数据和写入的数据按字节对比,而不是只对比解析后的金额值,因为校验位、保留字节也可能被改坏。这套“写前备份、写后读回、读回对比”的习惯,帮我避免过好几次线上事故。
之前某次做充值演示,卡片余额正确但流水对不上,查到最后是日志里的 UID 读的是缓冲区旧值,卡片已经换了一张,UID 还是上一张的。从那以后,我每次读 UID 都强制清空接收缓冲区,并且把 UID、块地址、旧余额、新余额、时间戳一起写进一条日志。技术方案可以迭代,但这个“先日志、后写卡、最后读回”的顺序,我一直保留着。做射频识别技术相关项目,最贵的不是读卡器,是排查账目问题的时间。希望这篇笔记能帮你把这块时间省下来。
本文还有配套的精品资源,点击获取