RFID公交卡充值开发:M1卡存储、PC/SC读写与事务校验要点
简介这套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 块号 60KeyA 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表示用 KeyA0x61表示用 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、块地址、旧余额、新余额、时间戳一起写进一条日志。技术方案可以迭代但这个“先日志、后写卡、最后读回”的顺序我一直保留着。做射频识别技术相关项目最贵的不是读卡器是排查账目问题的时间。希望这篇笔记能帮你把这块时间省下来。本文还有配套的精品资源点击获取