1. 从一张门禁卡说起:ISO14443电子钱包到底在做什么
手里那张能刷公交、能开门、能在食堂打饭的卡片,拆开看其实没什么神秘的。它内部通常是一颗带射频接口的芯片,加上一圈铜线绕成的天线,没有电池,全靠读卡器发射的电磁场取电。这类工作在13.56MHz频段的非接触式卡,绝大多数遵循的就是ISO14443系列标准。而所谓“电子钱包”,就是在这张卡里划出一块受保护的存储区域,用一套约定好的命令去读写余额、扣款、充值,让卡片本身变成一个能离线记账的小账本。
我最早接触这块,是因为一个园区一卡通的小项目。当时的需求很朴素:一张卡既能当门禁,又能当饭卡,还要支持在断网的情况下完成扣费。断网这个条件直接把“查数据库扣钱”的方案毙掉了,只能把余额和交易逻辑下沉到卡片里,这就绕不开ISO14443的电子钱包机制。用Java来做,是因为后台服务、发卡工具、对账程序都跑在JVM上,统一技术栈省事。
这篇文章适合谁看?如果你正在做门禁、考勤、食堂消费、会员储值这类需要非接触卡的项目,或者你手上有读卡器却不知道怎么用Java把余额写进卡里,那这篇内容基本能覆盖你从原理到落地的全过程。我会把ISO14443的通信流程、电子钱包的存储结构、Java侧的APDU收发、密钥认证、扣款原子性这些关键点拆开讲,并且补上很多文档里不会写的实操细节——比如为什么你的扣款偶尔会“扣了钱没记账”,为什么认证总是随机失败。
需要先说明一点:ISO14443本身只规定了物理层、防冲突和传输协议,它并没有规定“钱包”长什么样。真正定义钱包命令的,是卡片芯片厂商在ISO14443之上实现的应用层规范,比如常见的Mifare系列、以及符合ISO14443-4的CPU卡。所以下面讲的内容,会以最典型的“存储卡式电子钱包”为主线,同时说明CPU卡方案的区别,因为这两条路在Java里的写法差别很大。
2. ISO14443的通信链路:从寻卡到能发第一条命令
2.1 读卡器与卡片之间到底发生了什么
很多人写Java代码时,直接调用读卡器SDK的readBlock就完事了,一旦出错就抓瞎。要能排错,得知道这中间经历了什么。读卡器上电后,天线持续发射13.56MHz的载波,形成一个交变磁场。卡片靠近时,卡内天线通过电磁感应产生电压,给芯片供电并复位。复位完成后,卡片进入空闲态,等待读卡器发命令。
接下来是防冲突环节。如果同时有多张卡在场,读卡器需要逐一识别。ISO14443-A用的是REQA(请求命令,0x26)唤醒所有卡,卡片回ATQA(应答,2字节);然后读卡器发防冲突命令,通过UID(唯一标识符)逐位比对,最终锁定一张卡。ISO14443-B则用REQB/WUPB和ATQB。这一步在Java里通常被SDK封装成inventory()或findCards(),但你要知道它存在,因为“多张卡同时放上去只读到一张”或者“读不到卡”的问题,八成出在这里。
锁定单张卡后,读卡器发SELECT命令选中它,卡片返回SAK(选择应答),SAK的值能告诉你这张卡是不是符合ISO14443-4(支持APDU传输)。如果是存储卡,SAK通常指示它是Mifare Classic或Ultralight;如果是CPU卡,SAK会指示它支持ISO14443-4,后续走APDU通道。
2.2 存储卡与CPU卡:两条完全不同的技术路线
这是新手最容易混淆的地方,也是选型时第一个要拍板的决定。
存储卡式电子钱包(以Mifare Classic为代表)把存储区分成若干扇区,每个扇区4个块,每块16字节。扇区的最后一个块是尾块,存放两把密钥(Key A、Key B)和访问控制位。钱包功能通常用其中一个数据块来实现,通过专门的加值/减值命令操作。它的优点是便宜、逻辑简单、读卡器兼容性好;缺点是密钥认证是私有算法(Crypto1),安全性在学术界早已被攻破,只适合对安全要求不高的内部场景。
CPU卡式电子钱包(符合ISO14443-4)内部是一颗真正的微处理器,跑着卡片操作系统(COS)。它通过APDU(应用协议数据单元)与外界交互,钱包逻辑由卡内应用实现,支持DES/3DES/AES等标准加密,还能做动态认证。安全性高得多,但卡片成本高,开发时需要拿到卡片的COS文档,命令格式各家不同。
我那个园区项目最后选的是CPU卡,因为涉及真实金额。但如果只是做个内部积分、门禁联动的小玩意,存储卡足够,成本能省一大截。下面两套代码我都会给,你可以按需取用。
2.3 Java侧怎么和读卡器对话
Java本身不直接操作USB或串口读卡器,中间必须有一层驱动或SDK。常见的有三类:
- 厂商私有SDK:提供Jar包,里面是JNI调用的本地库,API通常是
connect()、transmit(byte[])这种。优点是功能全、稳定;缺点是要装驱动,跨平台麻烦。 - PC/SC标准接口:通过
javax.smartcardio包访问,这是JDK自带的,只要读卡器支持CCID标准就能用,跨平台好。缺点是有些厂商读卡器对PC/SC支持不完整,或者拿不到底层防冲突的细节。 - 串口协议:读卡器当串口设备,自己按协议拼帧。灵活但工作量大,不推荐新手。
我一般优先用javax.smartcardio,因为它不依赖额外Jar,代码干净。下面这段是建立连接的基础代码:
import javax.smartcardio.*; import java.util.List; public class CardReader { public static void main(String[] args) throws Exception { TerminalFactory factory = TerminalFactory.getDefault(); List<CardTerminal> terminals = factory.terminals().list(); if (terminals.isEmpty()) { System.out.println("没有找到读卡器"); return; } CardTerminal terminal = terminals.get(0); System.out.println("使用读卡器: " + terminal.getName()); // 等待卡片进入感应区 terminal.waitForCardPresent(10000); Card card = terminal.connect("*"); // "*"表示自动协商协议 CardChannel channel = card.getBasicChannel(); System.out.println("协议: " + card.getProtocol()); // 后续通过 channel.transmit() 发送APDU card.disconnect(false); } }connect("*")里的星号让系统自动选择T=0或T=1传输协议,这个细节很重要——手动指定协议在某些读卡器上会失败。拿到CardChannel之后,所有命令都通过transmit发送,返回的ResponseAPDU里包含数据和状态字SW1SW2。
3. 电子钱包的存储结构与命令设计
3.1 钱包值在卡片里是怎么存的
以Mifare Classic为例,一个典型的值块(Value Block)结构是这样的:16字节里,4字节存数值(小端序,低字节在前),4字节存数值的按位取反(用于校验),再4字节存一份数值副本,最后1字节是地址字节,重复4次。这种冗余设计是为了防止写入过程中断电导致数据损坏——读的时候三份数据互相校验,对不上就说明块坏了。
钱包的四个核心操作对应四条命令:
| 操作 | 命令码 | 作用 |
|---|---|---|
| 读余额 | 0x30 | 读取整个值块,解析出数值 |
| 充值 | 0xC1 | 把指定值加到块内数值上 |
| 扣款 | 0xC0 | 从块内数值减去指定值 |
| 恢复 | 0xC2 | 把块内数值读回读卡器缓冲区 |
注意扣款和充值都是在卡片内部完成运算的,读卡器只发送“加多少/减多少”,卡片自己算完写回。这个设计很关键:它保证了运算的原子性,不会出现“读出来是100,算完写回去变成90,但中间被别人插了一笔”的情况。
3.2 为什么扣款要先“恢复”再“传输”
这是Mifare钱包操作里最反直觉的一步。完整的扣款流程其实是:
- 用密钥认证扇区;
- 发恢复命令(0xC2),把值块内容读到卡片内部缓冲区;
- 发传输命令(0xB0),把缓冲区内容写到目标块;
- 发扣款命令(0xC0),从目标块减去金额。
等等,顺序好像不对?实际上标准流程是:认证 → 减值(0xC0)→ 传输(0xB0)。减值命令先把结果算在缓冲区,传输命令再落盘。如果中间断电,缓冲区丢失,卡片上的值不变,相当于交易回滚。这就是它保证原子性的方式。
我踩过的坑:有一次扣款代码写成了“先读值→Java里减→再写回”,测试时一切正常,上线后偶尔出现余额错乱。原因就是读和写之间卡片可能被移走,或者被另一台读卡器插了一笔。永远不要在Java侧做余额运算,一定要用卡片原生的加值/减值命令。
3.3 CPU卡的钱包命令长什么样
CPU卡没有固定的钱包命令,全靠COS定义。但常见的模式是:卡内有一个电子钱包应用(AID标识),通过SELECT选中后,用INITIALIZE TRANSACTION、DEBIT、CREDIT、COMMIT这类APDU操作。典型的一条扣款APDU可能是:
00 DE 00 00 04 00 00 00 64 00拆开看:00是CLA(类别),DE是INS(指令,这里假设是扣款),00 00是P1P2参数,04是Lc(数据长度),后面00 00 00 64是金额(100分),最后的00是Le(期望返回长度)。不同厂商的INS码完全不同,必须查对应COS手册。
CPU卡的好处是它支持交易机制:先INITIALIZE开启一个事务,扣款、写日志都在事务里,最后COMMIT才真正生效。中途断电,事务自动回滚。这比存储卡的缓冲区机制更可靠,还能记录交易流水。
4. Java实现钱包读写的完整代码路径
4.1 密钥认证:所有操作的前置门槛
存储卡的每个扇区都有独立密钥,不认证什么都干不了。认证命令是0x60(用Key A)或0x61(用Key B),后面跟块地址和6字节密钥。认证成功后,这个扇区在当前会话内保持解锁状态,直到你切换到别的扇区或卡片移走。
public boolean authenticate(CardChannel channel, byte blockAddr, byte[] key) throws CardException { // 0x60 = 用Key A认证 byte[] apdu = new byte[12]; apdu[0] = 0x60; apdu[1] = blockAddr; System.arraycopy(key, 0, apdu, 2, 6); // 最后4字节是读卡器内部使用的,PC/SC下通常填0 ResponseAPDU resp = channel.transmit(new CommandAPDU(apdu)); // 认证成功返回 90 00 return resp.getSW() == 0x9000; }这里有个大坑:认证命令不是标准APDU,它是读卡器层面的命令,通过PC/SC的transmit发出去时,不同读卡器的封装方式不一样。有些读卡器要求用FF 86 00 00 05 01 00 <块地址> <密钥类型> <密钥>这种伪APDU格式。如果你用javax.smartcardio直接发0x60失败,八成是读卡器不认,得换成厂商SDK或者伪APDU格式。我建议先用厂商提供的调试工具确认认证能过,再写Java代码。
4.2 读余额与扣款的Java实现
假设认证已通过,读值块的代码如下:
public int readBalance(CardChannel channel, byte blockAddr) throws CardException { byte[] readCmd = new byte[]{(byte) 0x30, blockAddr}; ResponseAPDU resp = channel.transmit(new CommandAPDU(readCmd)); byte[] data = resp.getData(); if (data.length < 16) throw new RuntimeException("读取数据长度异常"); // 值块前4字节是小端序的数值 int value = (data[0] & 0xFF) | ((data[1] & 0xFF) << 8) | ((data[2] & 0xFF) << 16) | ((data[3] & 0xFF) << 24); // 校验:第5-8字节应该是value的按位取反 int check = ~value; int storedCheck = (data[4] & 0xFF) | ((data[5] & 0xFF) << 8) | ((data[6] & 0xFF) << 16) | ((data[7] & 0xFF) << 24); if (check != storedCheck) throw new RuntimeException("值块校验失败,数据可能损坏"); return value; }扣款则要发减值命令,注意金额也是小端序:
public boolean debit(CardChannel channel, byte blockAddr, int amount) throws CardException { byte[] cmd = new byte[5]; cmd[0] = (byte) 0xC0; // 减值命令 cmd[1] = blockAddr; cmd[2] = (byte) (amount & 0xFF); cmd[3] = (byte) ((amount >> 8) & 0xFF); cmd[4] = (byte) ((amount >> 16) & 0xFF); // 注意:Mifare减值命令实际是4字节金额,这里简化演示 ResponseAPDU resp = channel.transmit(new CommandAPDU(cmd)); return resp.getSW() == 0x9000; }实际写的时候,减值命令后面还要跟一个传输命令把结果落盘,具体字节序列要严格按芯片手册来。我强烈建议把这几条命令封装成一个WalletService类,把认证、读、扣、充、传输都包进去,上层业务只调deduct(cardId, amount),这样出错时排查范围小。
4.3 交易日志与对账:别让钱对不上
离线扣款最大的风险是“卡片扣了,后台不知道”。解决办法是在卡片里划一个区域存交易流水,每次扣款后追加一条记录(时间戳、金额、终端号)。读卡器在联网时把流水读上来,和后台账目核对。
存储卡空间小,流水一般存最近十几条,写满后循环覆盖。CPU卡可以用STORE DATA命令写日志文件,空间大得多。对账逻辑我一般这样设计:后台记录“应扣总额”,卡片记录“实扣流水”,两者按终端号和时间段比对,差异超过阈值就告警。这个机制帮我抓到过一次读卡器时钟漂移导致的重复扣款,没有流水根本发现不了。
5. 那些文档不会告诉你的坑
5.1 认证随机失败:天线与电源的锅
有段时间认证成功率只有七成,换卡、换读卡器都没用。后来发现是读卡器供电不足——USB口带不动天线满功率发射,卡片取电不稳,认证命令发到一半卡片就复位了。换成带独立电源的读卡器,或者给USB加个带供电的Hub,问题消失。如果你遇到“时好时坏”的认证失败,先怀疑供电和天线距离,别怀疑代码。
5.2 扣款成功但余额没变:传输命令漏了
前面说过,减值命令只算在缓冲区,必须跟传输命令才落盘。我见过有人只发减值不发传输,测试时因为读卡器缓存了状态看起来正常,一换读卡器就露馅。判断标准很简单:扣款后把卡拿开再放回来,重新读余额,变了才算真扣成功。
5.3 多线程下的读卡器争用
后台服务如果同时处理多个读卡请求,而读卡器只有一个,就会出现命令交错。表现是返回数据串了,或者状态字莫名其妙。解决办法是给每个读卡器加一把锁,所有transmit调用串行化。别指望读卡器自己能处理并发,它就是个串行设备。
5.4 金额单位与溢出
卡片里的金额通常是整数,单位是“分”。如果你按“元”存,小数部分会丢。另外Mifare值块是4字节有符号整数,最大约21亿分,也就是2100万元,一般够用,但充值时要防溢出——加之前先判断当前值 + 充值额是否超过上限,超了拒绝,别让卡片自己回绕成负数。
6. 从能跑到好用:几个提升稳定性的工程习惯
第一,所有卡片操作都包重试。非接触通信受环境影响大,一次失败不代表卡坏了。我一般对认证和读操作重试2次,扣款这种写操作重试要谨慎——必须确保幂等,否则可能重复扣。做法是扣款前先读一次流水,确认这笔没扣过再扣。
第二,把卡片操作和业务逻辑分离。WalletService只管和卡片对话,返回“成功/失败/余额”,业务层决定要不要记订单、发消息。这样换卡片类型时只改Service,业务代码不动。
第三,日志要记原始字节。出问题时,把发送和接收的APDU十六进制打出来,比任何文字描述都有用。我习惯在transmit外面包一层,自动记录TX: ... RX: ...,排查效率翻倍。
第四,准备一张“测试卡”专门用来做破坏性测试。反复充值扣款、中途拔卡、写坏值块,看程序怎么反应。真卡上不敢做的操作,测试卡上随便折腾。
这套东西跑通之后,那个园区项目稳定运行了两年多,日均交易几千笔,没出过金额差错。回头看,最难的不是Java代码,而是理解卡片内部那套“先算缓冲区再落盘”的机制,以及接受“离线交易必须靠流水对账”这个现实。如果你也在做类似的东西,希望这些经验能帮你少走点弯路。