多板卡 IC 卡读写与脱机交易系统
这套系统的目标很具体:让一台离线计费类设备在没有后台连接的情况下,用一张 CPU 卡完成一次 有据可查的扣款。为此我把功能拆到多块板卡上——读卡器只管卡和射频、键盘只管人机交互、 主控负责业务状态机与存储、中控盒子负责总线仲裁与上报,板卡之间全部走 RS485 上的 Modbus 风格帧。卡片侧走 ISO14443A 非接触接口,用 PBOC 的电子存折 / 电子钱包应用。 结论是:功能全部实现过,交易链路也跑通了;但“卡即内存”的抽象缺少事务语义, 断电与拔卡的时序组合只能靠一套兜底机制弥补,这部分我至今仍觉得不够干净。
一、这套系统由哪几块板组成
我一开始的想法是“一块板全干完”:读卡、显示、按键、存储都放一个 MCU 上。 真画出来才发现行不通——读卡芯片要贴着卡片插座放,键盘和屏幕要走面板, 两者之间的排线又长又容易被干扰。最后改成按物理位置和职责拆板, 每块板一颗 MCU,板间只用一对差分线(RS485)。
拆完之后结构反而清楚了。整个系统是一主多从的总线拓扑:中控盒子是主站, 主板、读卡器、键盘、液位小板都是从站,各自有固定地址;中控再向上位机上报数据, 另外挂一路 4G 模组做远程上报。
| 板卡 | 主控 | 职责 | 总线地址 |
|---|---|---|---|
| 中控盒子 | GD32F303VC(Cortex-M4) | 三路 RS485 主站、轮询与广播、维护 32 个设备的在线位图与数据索引 | 0x00(广播)/PC 侧 0x66 |
| 主板(每路加注通道一块) | GD32F303VC | 计量与阀控、按键与显示、IC 卡会话状态机、本地存储、远程上报 | 1—32(通道号即地址) |
| 读卡器 | HC32F030F8TA(Cortex-M0+) | 射频寻卡、CPU 卡 APDU 交互、退卡与弹卡、临时金额与结算 | 0x68 |
| 发卡器 | HC32F005C6PA(Cortex-M0+) | 建卡、格式化、装载密钥、圈存与读回验证 | 0x67 |
| 键盘 | HC32L136K8TA(Cortex-M0+) | 20 路独立按键扫描 + 段码/点阵显示 | 0x02 |
| 液位小板 | HC32F030F8TA | 电压、温度采集与四路继电器输出 | 0x41 |
这里有一个我后来才意识到的细节:读卡器的地址从 0x02 改成了 0x68。 早期版本里读卡器和键盘都挤在低地址段,后来设备种类变多,低地址留给了主板, 读卡器、发卡器各自挪到高位地址。地址分配这件事,一开始不定规矩,后面就得改代码。
读卡器一侧是射频前端加一颗卡片 COS 芯片的组合。射频前端负责 ISO14443A 的 寻卡、防冲突、选卡和扇区认证,读写芯片内部有一小块 EEPROM 用来存逻辑加密卡的密钥; 卡片的文件系统与交易逻辑则全部由卡内的 COS 负责。这两层的分工我在第三节展开。
顺带说一句:这套东西是同一个架构在几年里迭代出来的。最早的原型用的是 Cortex-M3 的主板配一颗 32 KB Flash 的读卡器,后来整体迁到 Cortex-M4 平台, 读卡器换成 Cortex-M0+。迁移时状态机、存储布局、协议帧几乎没动, 改的都是时钟、GPIO 和 Flash 驱动——这大概是分层做得最对的一次。
二、卡片技术选型:为什么不只用逻辑加密卡
最常见的 13.56 MHz 卡片是逻辑加密卡(M1/S50 这一类)。它的用法非常简单: 用 KeyA 认证某个扇区,认证通过之后整个扇区可读可写。我最开始的“ID 卡”就是这么做的—— 认证第 0 扇区,取出 4 字节 UID 当卡号,业务数据一个字节都不往卡里放。
逻辑加密卡的扇区结构本身也说明了它的定位:每个扇区 16 字节一块, 尾块的前 6 字节是密钥 A、中间 4 字节是访问控制字、后 6 字节是密钥 B, 其中密钥 A 安全不可读出、密钥 B 可以读出。这套模型适合“存一小段标识数据”, 不适合“存一笔钱”。
为什么钱包不能用逻辑加密卡
因为一次扣款在逻辑加密卡上根本没有“语义”。它只有“认证通过后可以改这个块”, 没有消费初始化、没有交易序号、没有金额上限校验、没有 MAC 校验、也没有交易明细文件。 卡里的余额就是一个能被任意改写的计数值;如果设备本地没有一份可信账本, 出了问题无法判断“卡里的余额到底是被谁改的”。
而这套系统的场景恰恰是脱机的:设备不联网、后台数据库摸不到, 唯一的账本就在卡里。这就要求卡片自己能维护交易过程——于是换成 CPU 卡, 走 PBOC 的电子存折(ED)与电子钱包(EP)应用。
| 对比项 | 逻辑加密卡(只做 ID) | CPU 卡(ED / EP 应用) | 只读 ID 卡 |
|---|---|---|---|
| 卡内是否存业务数据 | 不存(只用 UID) | 存余额与交易明细 | 不存 |
| 认证方式 | 扇区密钥认证 | 随机数 + 外部认证 + 线路保护 MAC | 无认证,只读序列号 |
| 扣款是否有交易语义 | 没有(直接改数据块) | 消费初始化 → 扣款 → 返回 TAC | 不涉及 |
| 脱机是否可追溯 | 不可追溯 | 卡内明细循环记录文件自动记账 | 不涉及 |
| 我这套系统里的用途 | 取 UID 当身份识别用 | 真正的钱包与扣款载体 | 最简单的一类识别卡 |
我在系统里三种卡都留了入口,因为它们的成本与场景不同: 门禁式的身份识别用只读卡号就够了,会员身份识别用逻辑加密卡的 UID, 只有涉及金额的场合才走 CPU 卡。这个“分流”让读卡器一侧的状态机变得复杂, 但省掉了很多没必要的开销。
三、CPU 卡的文件结构与密钥体系
CPU 卡本质上是一台带文件系统的微型计算机。它的目录结构是两级:
根目录 MF 的文件标识符是 3F00,其下有一个应用目录 DF,
标识符是 3F01;应用目录下才是真正的数据文件。
选择应用时下发的是 AID,这套 PBOC 应用用的 AID 是
A0 00 00 00 03 86 98 07 01(规范的注册应用提供商标识加上扩展字节)。
| 文件标识 | 用途 | 文件类型 |
|---|---|---|
0000 | 密钥文件(后续所有密钥都装在这里) | 密钥文件 |
0001 | 电子存折 ED,余额与透支限额 | 二进制文件(0x2F) |
0002 | 电子钱包 EP | 二进制文件 |
0015 | 公共应用基本数据 | 带线路保护的二进制文件(0xA8) |
0016 / 0017 | 持卡人基本信息 / 发卡人基本信息 | 二进制文件 |
0018 | 交易明细循环记录文件 | 循环记录文件(0x2E) |
0019—001f | 预留与扩展信息文件 | 二进制 / 记录文件 |
0100 | MF 下的记录文件,存放应用目录名 | 记录文件 |
其中 0018 这个循环记录文件是最省事的一个设计:我只需要在建卡时把这个文件建好,
交易成功后卡内 COS 会自己往里面写一条明细,不需要我组织明细内容再写回去。
这一点对脱机交易非常关键——明细是“卡自己记的账”,比设备单方面记录更可信。
七类密钥,各管一件事
卡里的密钥不是一把,而是按用途分成七类。它们的名字本身就说明了职责:
- TAC 密钥(TACKey):产生交易认证码。交易完成后卡返回一个 TAC,设备把它写进流水,事后可以拿它验证“这笔交易确实是这张卡认可的”。
- 线路保护密钥(LineCKey):算 MAC,保护“主机发给卡的这条指令”没被篡改。我做的 MAC 校验用的就是它。
- 外部认证密钥(ExKey):外部认证用。主机先取随机数,再用这把密钥对随机数做运算交给卡验证,通过之后才允许后续操作。
- 消费密钥(DPKey):消费扣款专用。
- 圈存密钥(CZKey):充值(圈存)专用。
- 口令密钥(PIN):持卡人口令,按 3 字节明文组织、不足部分补齐;它对应的是“卡片使用者的身份”,不是设备身份。
- 内部认证密钥(INKey):内部认证用,方向与外部认证相反——卡证明自己是真卡。
每一类密钥在装载时都带一个标识符,比如圈存密钥的标识符、消费密钥的标识符、 线路保护密钥的标识符各占一个编号。我的理解是: 标识符的价值在于密钥版本化。卡里可以同时存在同一用途的多把密钥, 用标识符区分版本;换密钥时只要新版本装载进去、交易时选择新标识符, 就不需要把所有卡回收重发。
密钥存在哪三个地方
我后来把密钥分成三层来管理,这三层不能混:
- 卡内密钥:由卡内 COS 保存,用于算 MAC/TAC 和验证认证,永远读不出来。
- 发卡侧密钥:建卡时装进卡里的同一套密钥,只在发卡器上使用。
- 主控与读卡器之间的会话密钥:通过一条专门的“取密钥”命令向读卡器索取, 返回 16 字节,之后所有敏感寄存器读写都用它做加解密。 我特意让会话密钥每次会话从读卡器取,而不是写死在主控固件里—— 这样换密钥不用重新烧主控,也避免密钥散落在多个固件中。
四、APDU 命令:一条交易要经过哪些步骤
CPU 卡对外只有一种交互方式:APDU 命令—应答。命令由 CLA(命令类别)、 INS(指令码)、P1/P2(参数)和数据域组成。这套系统实际用到的命令并不多, 我把它们整理成下面这张表(表中 CLA/INS 为两字节十六进制):
| CLA | INS | 含义 | 在流程里的位置 |
|---|---|---|---|
00 | A4 | 选择文件 | 每次操作前先选 MF 或 DF |
00 | 84 | 取随机数 | 外部认证的第一步,通常取 8 字节 |
00 | 82 | 外部认证 | 用随机数证明终端身份 |
00 | 20 | 校验口令 | 需要持卡人口令的场合 |
80 | 50 | 圈存初始化 / 消费初始化 | 交易第一步,卡返回余额与密钥版本等信息 |
80 | 52 | 圈存(充值) | 写入金额并校验 MAC |
80 | 54 | 消费扣款 | 真正的扣款,带 MAC 与 TAC |
80 | 5C | 读余额 | 随时可读,用于起点与终点的对照 |
00 / 04 | D6 | 写二进制(不带/带线路保护) | 写文件内容,两种卡版本各一套 |
00 | B2 | 读记录 | 读交易明细与基本信息 |
00 | E2 | 追加记录 | 写记录文件 |
00 | 88 | 内部认证 | P1 区分加密 / 解密 / MAC 计算三种模式 |
84 | 24 | 解锁口令密钥 | 口令被锁之后解锁 |
84 | 18 | 应用解锁 | 应用级解锁 |
80 | 0E | 删除根目录文件(格式化) | 建卡流程第一步 |
80 | E0 | 建立文件 | 建卡主力命令 |
80 | D4 | 装载密钥 | 把七类密钥写进密钥文件 |
一次完整消费的顺序
把上面的命令串起来,一次消费大致是这样走的:
- 选择根目录、再选择应用目录(
00A4两次)。 - 取随机数(
0084),卡返回 8 字节随机数。 - 外部认证(
0082):终端用外部认证密钥对随机数做运算后交给卡验证。 - 需要口令的场合,再做一次口令校验(
0020)。 - 消费初始化(
8050):卡返回余额、是否允许联机交易、密钥版本等信息。 - 主控在本地算出交易金额,用线路保护密钥加随机数算一个 MAC,随扣款指令一起下发。
- 消费扣款(
8054):卡校验 MAC,通过后扣减余额、返回 TAC,并自动在0018明细文件里追加一条记录。 - 最后读记录(
00B2)把明细读回来,和设备本地流水对一次账。
第 6 步的 MAC 是我认为整个设计里最值得学的一点:它让卡能够确认 “这条扣款指令确实来自持有线路保护密钥的终端,而且金额、时间这些字段一个字节都没被改过”。 没有它,卡就只能盲信总线上送来的任何一条扣款命令。
返回码方面,成功的应答是 0x9000;有一个需要单独分支处理的是
0x6A86——它表示“文件已存在”。建卡时如果不管这个返回码,
重复建卡就会直接报失败;我在建卡流程里对它做了单独处理,遇到就跳过继续建下一个文件。
另外我定义了一个内部错误常量 ST_ERR,用来表示“没有返回 APDU 数据或者数据格式不对”,
和卡片明确返回的错误码区分开——前者是链路或时序问题,后者是卡片业务拒绝,处理方式完全不同。
两套写二进制命令:卡版本兼容
我保留了两个功能几乎一样的函数:一个用 04 D6(带线路保护)写文件,
另一个用 00 D6(不带线路保护)。原因很朴素——手里的卡不是同一批 COS 版本,
老卡不认带线路保护的写法。这不是设计冗余,而是“现场存在两种卡”这个事实逼出来的兼容层。
如果只维护一套代码,就要在升级时把老卡全部换掉,代价更大。
86 条诊断码其实是一份建卡 SOP
发卡器那侧的诊断码有八十多条,我一开始觉得太多了,直到自己照着它排查过一次建卡失败。 它把建卡流程拆成了很细的步骤,每一步都有独立编号:
- 格式化阶段:打开文件失败、取随机数失败、外部认证失败、删除数据失败各有编号;
- 建密钥文件与装载七类密钥:装外部认证密钥、TAC 密钥、线路保护密钥、 消费密钥、圈存密钥、口令密钥、内部密钥,失败各自有编号;
- 建数据文件:公共应用基本数据、持卡人信息、发卡人信息以及一串扩展文件, 建失败或写失败都有编号;
- 建电子存折与电子钱包文件、建交易明细文件,各自一个编号;
- 最后是读回验证阶段:打开文件、取随机数、外部认证、读余额、逐个文件读回。
换句话说,这张错误码表本身就是“建一张卡需要做哪些事”的清单。 我后来写任何分步骤的流程,都会顺手给每一步编个号——排查的时候能一眼看出卡在哪一步, 比打印一堆十六进制有用得多。
五、“卡即内存”:把读卡器状态映射成寄存器空间
主控如果能直接发 APDU 会最灵活,但那意味着主控固件里要有整套卡片 COS 逻辑、 要持有密钥、要处理各种卡版本差异——主控代码会变得非常难维护。 我最后采用的方案是:让读卡器把“卡 + 自己”整体抽象成一段带偏移的内存空间, 主控只做普通的寄存器读写。
| 信息类别 | 用途 | 读/写属性 |
|---|---|---|
| 持卡与卡片状态 | 表示“当前有没有卡”,以及卡处在准备、运行中、未正常扣款这些状态里的哪一个 | 读;状态可写 |
| 交易起点记录 | 加注开始前写入:把卡状态置为运行中,并记下起点余额与起点时间戳 | 写 |
| 周期快照 | 加注过程中按周期写入的实时金额;另有一份上一拍的快照用于比对 | 写 |
| 交易终点记录 | 正常结束时写入:卡状态复位、交易类型、金额、时间 | 写 |
| 兜底扣款 | 终点没写成时,按快照扣上一笔并把卡状态复位 | 写 |
| 无卡与查询入口 | 判断“无卡”;以及一次读回等待状态的全部信息(下面细说) | 读 |
| 余额与卡号 | 卡余额、ID 卡卡号 | 读 |
| 口令与外部认证 | 卡密码输入错误次数、外部认证剩余次数 | 读/写 |
| 会话密钥 | 向读卡器索取主控与读卡器之间使用的会话密钥 | 读 |
| 维护类动作 | 退卡、远程充值、加注中错误扣款 | 写 |
这张表只写“这段空间暴露了哪几类信息”,不写具体偏移数值: 偏移量属于项目实现,换一代读卡器就可能整体重排,真正稳定的约定是“有哪几类信息、谁读谁写”。 在主控这一侧,所有卡片操作最后都被收敛成两类动作——往某个入口写一次数据, 或者从某个入口读一次数据;入口后面发生的射频寻卡、APDU 交互、卡片认证,主控完全不参与。
/* "卡即内存"在主控侧的样子:卡片操作只剩"写一次 / 读一次"两个入口
—— 按个人理解重写的最小片段,非工程原码;入口编号按各项目的映射表定义 */
static int card_op_write(uint16_t entry, const uint8_t *buf, uint16_t len)
{
return bus_write(DEV_ADDR_READER, 0x06, entry, buf, len); /* 0x06:写单个寄存器 */
}
static int card_op_read(uint16_t entry, uint8_t *buf, uint16_t len)
{
return bus_read(DEV_ADDR_READER, 0x03, entry, len, buf); /* 0x03:读保持寄存器 */
}
功能码只用三个:0x03 读保持寄存器、0x06 写单个寄存器、
0x10 写多个寄存器。读卡器还额外实现了两个扩展功能码
0x41(读)与 0x42(写),帧里带两字节寄存器地址和两字节长度,
用来调试和访问遥测点。这套扩展帧后来被复用到液位小板上,
地址表另起一段独立区间,读电压、温度、继电器输出都是同一套读法。
一次“查询全部”读回来的 24 字节是有固定顺序的,主控按顺序解析: 用户号 2 字节、卡序号 2 字节、补卡数 1 字节、 卡类型 / 密码开关 / 密码错误次数 1 字节、卡密码 6 字节、卡状态 1 字节、 卡余额 4 字节、卡 UID 4 字节,合计 24 字节。这个“一次读完”的设计很实用: 它把原来五次零散读合并成一次,既省总线时间,也避免了“读到一半卡被拔走”导致的字段不一致。
这套抽象的代价
代价是真实存在的:这些“寄存器”并不具备 Modbus 意义上的原子性。 真正的 Modbus 从站里,寄存器和设备状态是一体的;而这里的“寄存器”背后是 射频通信、卡片认证、卡内文件系统,一次写在时间上可能横跨几十毫秒, 中间随时可能被拔卡、掉线、掉电打断。所以这套抽象必须有东西兜底—— 这就是下一节和第七节要讲的两件事:应用层的交易一致性机制, 以及协议层的双层校验。
还有个有意思的观察:这套偏移表在几年里被收敛了。 最早的版本有二十多个偏移和二十个命令码,把“查卡归属、查卡状态、查是否需要密码、 查密码、查余额”拆成五条独立命令;后来合并成一次“查询全部”, 命令码从二十个减到十三个,再后来又加了“保持连接、定额消费、重启”等几条。 接口不是越加越好,能合并的合并掉,主控的状态机才会简单。
六、脱机交易的数据一致性设计
这是我在这套系统里花时间最多、也最有收获的一部分。问题可以一句话说清: 钱在卡里,账在设备里,两者之间的同步随时可能被拔卡或断电打断。 我最后总结出一套六个环节的骨架,每一环解决一类时序:
| 环节 | 承载位置 | 关键数据 | 断电/拔卡后起什么作用 |
|---|---|---|---|
| ① 起点 | 卡内 · 起点记录区 | 卡状态置为运行中、加注前余额、加注前时间戳 | 提供“这笔交易从多少余额开始”的基准 |
| ② 周期快照 | 卡内 · 临时金额区与历史快照区 | 加注过程中的实时金额 | 提供“已经加到多少”的进度,用于补扣 |
| ③ 终点 | 卡内 · 终点记录区 | 卡状态复位、交易类型、金额、时间 | 标志交易正常结束,卡侧账面已更新 |
| ④ 兜底 | 卡内 · 兜底扣款入口 | 按快照扣上一笔并把卡状态复位 | 终点没写成时,用快照把账补齐 |
| ⑤ 本地持久化 | 设备 EEPROM + SPI Flash | 加注前信息、加注中数据、未结算标志、扣款错误信息 | 设备侧也留一份,用于对账与人工处理 |
| ⑥ 幂等 | 设备内存 + 持久化标志 | 结算状态机、结算发送标志、重复扣款异常码 | 防止同一笔被扣两次或补两次 |
把这张表按时间顺序读一遍,就是这套机制的全部骨架。这里只写每一步“做什么、为什么”, 不写字段布局与判定条件——后者和卡内记录格式、退卡流程绑在一起,属于项目实现:
- 先把基准落到卡上(对应①):加注动作开始之前先写一次起点记录。 放在最前面的理由是:后面所有对账都要拿它当基准,而设备自己的 RAM 变量在拔卡、掉电之后保不住。
- 过程里持续落进度(对应②):加注过程中按周期写快照。 这一步是拿总线流量换数据安全——写密了占总线,写疏了断电时丢掉的金额区间就大。
- 结束时写终点(对应③):正常结束先写终点记录、把卡状态复位。 它是“这笔交易已经正常收尾”的唯一凭据,所以判定优先级最高。
- 只有终点没写成才走兜底(对应④):拔卡、掉线导致终点缺失时, 用最近一次快照补扣并复位卡状态。兜底是异常路径,不是常规路径。
- 设备侧同步留一份账(对应⑤):把起点信息、进度数据、未结算标志落到本地存储。 它不参与卡内扣款,只服务于事后对账和人工处理。
- 最后用幂等收口(对应⑥):结算这一步必须能回答“这笔到底处理过没有”。 为什么必须这么做、以及我踩过的坑,放在本节最后一条讲。
起点:先把“从哪开始”写进卡里
开始加注之前,设备会先往卡里写一次“加注前操作”:把卡状态改成运行中, 同时写入加注前余额和加注前时间戳(Unix 时间)。 这一步的意义是把交易基准落在卡上,而不是留在设备的内存变量里。 拔卡之后设备重启,RAM 里的显示变量早没了,但卡里还留着“这笔交易开始时余额是多少”。
周期快照:用总线流量换数据安全
加注过程中,设备按周期把当前的实时金额写进读卡器的临时金额存储区。 这个“周期”是个取舍:写得太密会占满总线(读卡器还要同时响应主控的其他查询), 写得太疏则断电时丢失的金额区间变大。我在读卡器一侧还保留了“历史临时金额存储区”, 专门存上一份快照,用于对比判断快照是不是被写坏了。
终点与兜底:谁更可信
正常结束时写入“加注后操作”:卡状态复位、交易类型、加注金额(4 字节大端)、 加注时间(7 字节,年/月/日/时/分/秒,前面有前导填充字节)。 如果这条没写成——比如拔卡、掉线——就要靠兜底命令“扣除上一笔费用”, 它按快照扣款并把卡状态复位,让卡从“运行中”回到可用状态。
这里我踩过一个很典型的坑,写在第九节:如果读卡器返回的快照报文被设备当成“交易已经结束”, 金额就会倒退。所以判定上必须以终点为准,而不是“收到快照就当结算完成”—— 快照回答的是“加到多少了”,不是“这笔完了没有”。
本地持久化:设备侧也留一份账
设备侧我留了四块数据:加注前信息 64 字节、加注过程中数据 160 字节 (4 组 × 2 通道 × 20 条,正好覆盖两个加注通道的进度曲线)、 一个“IC 卡加注未结算标志”(1 字节)、以及“加注中扣款错误信息”120 字节。 上电时先看那个未结算标志:它置位就说明上一笔没走完,需要走一次对账。 对账的判定条件(差多少算对得上、能不能自动补)我在这里不展开,只说结论: 能自动补的自动补,补不了的落到异常记录里等人处理,绝不悄悄放过。
存储布局上我用的是定长记录加独立索引指针再加循环覆盖:所有索引都是 32 位
(早期是 16 位,吃过回绕的亏之后统一加宽),记录结构体用 #pragma pack(1)
严格控制字节对齐,保证不同固件版本读到的二进制布局是一致的。
介质是 EEPROM 加 SPI Flash 双份:索引与累计值放 EEPROM,
明细放 SPI Flash(每笔 64 字节)。另外两个加注通道的分区方式也从
“EEPROM 对半划分”改成了固定偏移按需分配——因为两个通道的记录量本来就不相等,
对半划分必然一边浪费一边不够用。
关于参数与记录的双区备份、CRC 校验和磨损均衡,我在 《Flash 参数双区备份与掉电保护》 那篇笔记里写得更细,这里不再重复。
幂等:同一笔钱只能被处理一次
最后一个环节是防止重复处理。这一步的难点不在代码,而在“先想清楚什么算处理过”: 结算这条路有两个独立的触发源(加注结束、拔卡退卡),只要两边都可能发起结算, 就必须有一个共同的判定点来回答“这笔处理过没有”。我当时是按三条来做的:
- 给结算一个明确的中间态。设备侧维护一个结算状态机 (未结算 / 结算中 / 结算成功 / 结算失败),而不是用一个布尔量表示“结没结”—— 布尔量表达不了“报文已经在路上”这种中间情况。
- 报文发出之后不允许无声退出。发完结算报文,必须等到应答或者明确的超时, 才能离开这个状态。理由是“超时”不等于“失败”:超时的那一刻卡上可能已经扣成功了, 此时正确的动作是去查状态,而不是重发。
- 把“重复”变成一条看得见的故障。我加了两个专门的异常码 (设备侧一个、读卡器侧一个)显式表示“重复扣款异常”,一旦出现就停下来报故障, 而不是默默继续。
具体的判定条件与状态迁移表我在这里不写:它和卡内记录格式、退卡流程绑在一起, 属于项目实现。可以公开的是这条经验——凡是“一次操作由两个独立事件源驱动”的地方, 都要先想清楚冲突时谁说了算。
七、通信的可靠性:双层 CRC 与异常码分级
先看一条真实的“加注后结算”报文的字段顺序:
[从站地址][功能码][寄存器地址][长度][业务数据][内层 CRC16][3DES 密文][整帧 CRC16]。
功能码用的是 Modbus 的“写单个寄存器”,寄存器地址指向上一节那张表里的“交易终点记录”,
长度字段描述业务数据的字节数,随后是业务数据、一段内层 CRC16、加密后的密文,
最后是整帧 CRC16。退卡那种短报文更简单,只有几个字节加两字节 CRC。
这里同样不写出具体的从站地址、寄存器地址与长度取值,它们都是项目参数。
为什么要算两次 CRC
内层 CRC 是对明文算的,随明文一起被加密。它的作用是让接收方在解密之后, 立刻能验证“解出来的这些业务字段是不是完整的”。外层 CRC 是对密文整帧算的, 管的是传输过程有没有被干扰。
少了内层会怎样?如果只有外层 CRC,一旦出现“解密成功但内容已经不对”的情况 (比如加密实现本身有缺陷、或者长度字段被错解),外层 CRC 依然可能是对的, 因为它是按密文算的。有了内层 CRC,解密方对业务数据的完整性有一份独立的证据。 代价是每次写卡要算两次 CRC,代码量和总线开销都增加,但我觉得这个代价值得。
三层加密栈
从卡到总线,敏感数据实际穿过了三层保护:
- 卡内 COS 层:用 DES/3DES 算 MAC 与交易认证码,密钥是卡里的线路保护密钥与 TAC 密钥。
- 主控与读卡器之间:整段数据先做 3DES,再按会话密钥首字节取模的结果做一次字节循环移位并取反, 作为一层自制加固。
- 总线帧层:Modbus 的 CRC16(初值
0xFFFF、多项式0xA001、低字节在前)。
另外系统里还有一套 AES(CBC 模式)用于远程上报,那条链路与卡交易链路是分开的: 上报报文是一个带编号的 JSON 外壳,业务数据是 AES 密文。 两套密码学并存看起来乱,但它们的信任边界不同——一套保护“卡与终端之间”, 一套保护“设备与平台之间”,混用反而更容易出错。
需要说明的是:中间那层“循环移位加取反”只是我自己加的加固层, 它不能替代标准算法,也不应该被当成安全设计的主体。 当时这么做的现实原因是那颗 Cortex-M0+ 上跑标准算法已经比较吃力, 代码空间只有 64 KB。这一点我在最后一节还会回到。
异常码:区分值得重试和不值得重试
协议层的错误必须分类返回,否则上层的重试策略没法写。我在读卡器与主板之间沿用
Modbus 的异常码约定:非法功能码返回 1、非法地址返回 2、
CRC 校验错误返回 17。这三者的处理方式完全不同:
17(CRC 错误)属于“这一次没收到”,值得重发;1(功能码不支持)说明请求本身不合法,重发没有意义;2(地址越界)说明两端地址规划不一致,应该改配置而不是重试。
重试的实现上我留了几个计数器:无回应重发计数、掉线重发、超时重发次数上限, 以及一个数据校验结果标志。超时值本身也调过好几轮:早期定得偏大, 总线节奏变快之后,那个等待时间会把整个轮询周期拖垮;但也不能一味调小—— 中控那侧的注释里明确写着一条硬约束:超时值必须大于最长报文的传输时间, 否则长度较大的报文会在传输途中就被判超时。具体取值属于项目参数,这里不写。
| 异常来源 | 典型码值 | 性质 | 上层应该怎么做 |
|---|---|---|---|
| 总线 CRC 错误 | 17 | 传输被干扰 | 有限次重发 |
| 功能码不支持 | 1 | 请求不合法 | 不重发,检查固件版本 |
| 地址越界 | 2 | 配置不一致 | 不重发,修正地址表 |
| 寻卡无应答 / 应答错误 | 业务码 | 卡片或射频问题 | 提示重新插卡,不要死循环 |
| 外部认证错误 | 业务码 | 密钥或卡状态问题 | 停止本次交易并记录剩余认证次数 |
| 数据加密校验失败 | 业务码 | 会话密钥不同步 | 重新取会话密钥后重来一次 |
| 超时(无应答) | 内部标志 | 链路断开或从站忙 | 按计数上限重发,超限报掉线 |
关于 RS485 半双工的方向控制与帧边界判断,我在 《Modbus RTU 从站实现笔记:寄存器表、CRC 与异常码》 里已经写过一次,这套系统里遇到的问题和那篇里几乎一样: 方向脚切换后必须留出建立时间,帧尾要靠 3.5 个字符时间的静默判定, 这些在 115200 bps 下比 9600 bps 更敏感。
八、故障码怎么分级(C1xx / C2xx 的设计)
设备面板只有几位数码管,能显示的字符很有限,但又要让现场人员一眼分辨
“是卡的问题、是认证的问题、还是通信的问题”。我最后采用的是
一个字母加三位数字的显示格式:Cxxx,
其中前缀字母表示“这是 IC 卡相关的故障”,三位数字按区间分段。
| 显示形态 | 区间含义 | 低位的含义 | 能否自行恢复 |
|---|---|---|---|
C1xx | IC 卡故障,xx 为具体故障类型 | 故障类型编号 | 视类型而定,多数要换卡或重新插卡 |
C2xx | 外部认证故障 | 剩余外部认证次数,不是故障类型 | 换正确的卡,次数用尽就要找发卡方 |
这套编码其实是改过一次的。早期版本里 IC 卡故障的前缀是 C0xx,
外部认证故障是 C9xx;后来统一改成卡片类故障走 C1xx、
认证类故障走 C2xx。为什么要改?因为中途插进来一个“通道号重复”的新故障,
它既不属于卡也不属于认证,需要独立编号;原来的 C0xx 段被占满之后,
索性把两个大类各挪一段,留出中间的空段给将来的新类别。
把计数器塞进故障码
C2xx 这个设计我觉得挺巧:它的低两位不是故障类型,而是“还剩几次机会”。
外部认证失败几次之后卡会被锁定,把剩余次数直接编进故障码,
现场人员不用翻菜单就能知道“这张卡还能再试几次”。
代价是它和 C1xx 的语义不一致——一个是“类型”,一个是“计数”。
这种“低位复用”的做法在嵌入式里很常见(我在另一处也见过把索引的最高位拿去区分通道号的),
它省了显示位,但要求所有读这段码的程序都记得特例,属于典型的
用可读性换资源。
卡片故障码的清单是怎么变长的
卡片类故障码从最早的十几条扩到了 25 条。新增的那些大多来自真实的现场问题, 我挑几条印象最深的:
- 物理层校验失败:射频层的奇偶校验或帧校验没过,说明是接触/耦合问题而不是卡的问题。
- 读卡器消费写卡失败:扣款指令发出去了但卡没写成功,钱和账此时是不一致的,必须单独报出来。
- 结算同步失败:设备认为结算完成了,读卡器认为没有,两边状态不一致。
- 卡内金额异常:读回来的余额超出合理范围,通常意味着卡被非正常使用过。
- 重复扣款异常:同一笔被处理了两次,第六节讲过它的由来。
- 卡状态 4(未正常扣款):卡停在一个“扣款没走完”的中间状态,需要专门处理。
与之配套的是卡状态本身也从两个扩到四个:准备、消费失败、运行中、未正常扣款。 早期只有“准备”和“运行中”两个状态,遇到失败就没有地方落—— 只能停在“运行中”,下一次插卡时看起来像是“上次没结束”,很难区分“正在加注”和“卡住了”。
这里还有一个跨版本兼容的坑值得记一笔:卡状态的编码在代际之间变过。 早期原型里“正在运行”的编码是 2,后来改成 3。如果两块板卡的固件版本不一致, 对同一个字节的解释就不同——表现是“卡插进去显示状态异常”,但卡本身完全正常。 所以我在协议里坚持把这类编码写成有名字的常量而不是魔法数字, 至少让不兼容在编译期能被看见。
设备侧还有一层“本地名单校验”:卡号超过本地可存卡号上限(早期是 2000, 后来扩到 10000)就判为卡号错误,本地卡状态与卡内状态不一致也单独报一个码。 这一层不是安全机制,而是“设备本地只维护了一小部分卡的白名单”这个事实的显式表达—— 与其让它悄悄出错,不如报出来。
九、问题与解决
这一节按“现象 → 原因 → 办法”逐条记录。前八条是我在数据一致性上踩过的坑, 最后一张表里是其余零散但同样值得记的问题。
1. 结算过程中拔卡,扣款金额不对
现象:加注结束、结算报文正在总线上传输时拔掉卡片,
结算出来的金额和屏幕上显示的不一致,有时多有时少。
原因:结算这条链路被两个独立的事件源驱动——一个是“加注结束”触发的结算流程,
另一个是“拔卡”触发的退卡流程。两者在时间上重叠时,后到的流程改掉了先到流程的中间数据。
办法:把读卡器的运行中逻辑整体重构,明确“结算期间不接受退卡”,
并新增一次同步中断让两边的状态可见;同时在设备侧把结算拆成独立的状态机,
报文发出后必须等应答或明确超时才允许退出。
2. 快速拔卡与反复拔电
现象:快速插拔卡片、或者反复上电断电,会出现卡状态残留、下一次插卡直接报错。
原因:读卡等待与掉电处理的时间常数没有拉开差距。
源码注释里留了一句很关键的实测结论,大意是:
“未锁住卡之后要等一小段才退出读卡等待状态;掉电处理的时间常数必须是它的若干倍”——
否则掉电瞬间的电压跌落会被误判成“卡被拔了”。
办法:把两组时间常数按倍数关系重新取值(具体倍数属于项目参数,不写在这里),
并在掉电检测路径上单独走一套判定。
3. 卡内 EEPROM 的循环写
现象:同一张卡反复使用一段时间后,出现“某一位写 0 失败”——
设备侧 EEPROM 上也出现过同样的问题(清班累时某一位写 0 失败)。
原因:EEPROM 单元有擦写寿命,而且写失败往往是位级的,
不是整块失效。每次交易都往同一个地址写,最先到寿命的就是那几个字节。
办法:改成循环写:把同一条数据在若干个位置轮流写,
每次写下一个位置并递增一个序号,读的时候取序号最新的那份。
这一步后来在发卡器和读卡器上是同一天改的——因为两边都碰到了同一个问题。
4. 加注中拔卡导致金额倒退
现象:加注过程中拔卡,屏幕上的金额会往回跳。
原因:设备把读卡器返回的“临时金额快照”当成了权威值,
收到就用它覆盖本地显示值;而拔卡瞬间读卡器可能返回一份较旧的快照,
于是一覆盖金额就倒退了。源码里当时的注释就是这么写的:
“加注中拔卡接收到了读卡器返回的报文或者拔读卡器,此处会导致金额倒退”。
办法:明确数据来源的优先级:加注过程中的金额以设备计量侧为权威,
快照只用于“补扣计算”,不参与显示回写;后来还专门加了重复扣款异常码来兜住残余情况。
5. 重复扣款异常码的由来
现象:偶发一笔被扣两次。
原因:结算报文发出后没有等应答,或者应答丢了但设备认为成功,
于是状态机回退又走了一遍结算。
办法:加“结算报文已发送”标志 + 结算状态机(未结算 / 结算中 / 成功 / 失败),
并新增两个显式的重复扣款异常码。这里的关键认知是:
“超时”不等于“失败”。超时时交易可能已经在卡上成功了,
这时正确动作是去查状态,而不是重发。
6. 状态切换时的按键竞态
现象:从一个界面切到另一个界面,如果手还没松开按键,
新界面会把这次按键当成一次输入,凭空多出一个数字或者直接跳进设置项。
原因:状态机的进入函数、执行函数、退出函数在同一个节拍里连续跑完,
按键扫描与状态切换共用同一个键值变量。
办法:加键值锁。我在不同模块里一共写过三种:切换期锁(切态时禁止消费键值)、
值锁(进入新状态后第一次键值丢弃)、故障态专用锁。
三个补丁本质是同一件事,各自写一遍是因为它们分属不同文件——
这也说明这套状态机框架缺一个统一的“输入门控”机制。
7. 最大值被“复用高位”限制
现象:明细笔数的上限突然变小了。
原因:为了让设备能从索引里直接看出“这条明细属于哪个加注通道”,
有人把索引的最高位拿去当通道标志位。索引是 16 位时,
最高位被占掉之后可用范围直接减半。于是最大加注次数从六万降到三万。
办法:接受这个上限(三万笔对单台设备足够),
同时连锁修改了中控读取余额的逻辑——因为余额字段里也藏了通道信息。
这件事给我的教训是:复用高位省下的那点存储,代价往往是一连串隐式约定,
而且这些约定会散落在多个模块里,改一处就要想起来其余几处。
8. 掉电误判加滤波的三次迭代
现象:电源稍有波动,设备就重启;现场电压不稳时尤其明显。
原因:掉电检测直接读 ADC 值做阈值比较,没有滤波,
瞬时跌落就会被当成“掉电”,设备赶紧保存参数并复位;而复位之后电压已经恢复,
看起来就是“无缘无故重启”。
办法:三次迭代同一件事:先给掉电判定加滤波(屏蔽瞬时掉电);
再优化电源不稳时的处理逻辑;最后把滤波时间进一步加长。
这件事让我明白,掉电判定不是一个阈值问题,而是一个时间问题——
真正要区分的是“电压掉下去还会回来”和“电压掉下去就不回来了”,
前者应该等,后者必须马上保存。
| 现象 | 原因 | 办法 |
|---|---|---|
| 加注时长记录为 1 时实际是 16.6 分钟 | 单位换算:把毫秒按秒换算后又除了 1000 | 去掉多余的换算,明确按秒记录,并回头处理历史数据 |
| 采集电压总比输入电压低一个固定量 | ADC 通道存在系统性偏差 | 按实测偏差做软件补偿(补偿量属于标定参数,不写在页面里),并把欠压门限做成可设置项 |
| 语音播报卡住导致状态退不出来 | 等待外设忙标志时没有超时 | 给忙等待加超时退出路径 |
| 同一笔数据被打印两次 | 缺少幂等标志 | 打印前上锁,打完整理标志 |
| 同串口上两台设备用同一地址时 CRC 频繁出错 | 总线地址冲突,两站同时应答 | 靠地址分配规避,并在组网时做地址唯一性检查 |
| 长度较大的报文偶尔出错 | 通信超时设得比最长报文传输时间还短 | 把超时下限提到“比最长报文传输时间更长”,具体值属于项目参数 |
| 运行中偶发栈溢出 | 任务栈分配偏小 | 栈从 400 字节扩到 1000 字节,并打开栈溢出检测钩子 |
| 调试打印里一个死循环导致整机死机 | 调试代码进了发布版本 | 发布版本关闭全部串口打印与调试功能 |
最后一条是我觉得最应该写进流程的一条:发布版本要打开芯片保护与看门狗、 关闭所有串口打印与调试功能。这句话在两代工程的说明文件里被原样重复, 说明它是硬性检查项——而它之所以是硬性的,正是因为有人忘了关。
十、局限与还没做完的部分
这套系统的功能都实现过,但我不认为它是一个“可以直接拿去用”的方案。 下面这些局限是我自己清楚的:
- 自制加固层不等于标准算法。主控与读卡器之间那层“3DES + 字节循环移位 + 取反” 是当时的权衡,不是安全设计。真正对密码学有要求的场合,应该使用经过验证的加密库与安全芯片, 并且密钥的生成、分发、销毁都要有流程,而不是靠“每次会话取一次”这种工程手段。
- “卡即内存”缺少事务语义。远端内存映射这种抽象很好用, 但它没有原子性、没有回滚,一次业务操作横跨多条寄存器读写。 我是在应用层硬造了一套事务机制(起点、快照、终点、兜底、幂等), 代价是状态机变得很大,而且要覆盖各种中断时序的组合。
- 掉电与拔卡的时序组合没有穷举验证。我验证过的是“加注中拔卡”“结算中拔卡” “结算中掉电”“反复拔电”这几类典型场景,还有一批更刁钻的组合 (比如读卡器与主板同时掉电、总线上还有别的板卡在发送)没有系统覆盖。
- 掉电滤波的时间常数是经验值,没有做成参数,也没有在宽压宽温范围内 做过完整验证。换一种电源方案,这个值大概率要重新标定。
- 卡内 EEPROM 的循环写只是缓解。它能显著拉长寿命, 但没有做写次数统计与预警,也没法回答“这张卡还能用多久”。
- 还有一些已知问题没彻底解决:设备侧 EEPROM 清数据时偶发的“某一位写 0 失败” 只是被规避,没有找到根因;总线同号冲突只能靠组网规范规避, 代码层面无法自动识别;状态机框架里至今留着“有问题、待修改”的注释。
如果重做一遍,我会做三件不一样的事:一是把“输入门控”和“结算事务” 做成框架级的机制,而不是每个状态文件里各写一遍补丁; 二是把掉电与拔卡的时序画成一张明确的状态迁移图,让每个边都有定义好的动作; 三是给整套存储加一层统一的记录头(序号 + 长度 + CRC), 而不是让每个模块自己决定怎么落盘——这一点可以参考我在 双区备份那篇笔记里的做法。 另外,那篇讲 液位与温度采集小板的实验记录里, 我用的是完全相反的策略(结构简单、单区存储、先跑通再说), 两篇对照着看,大概能看出“什么时候该上机制、什么时候不该”。
同一时期我还在做另一台完全不同的手持设备,用光电容积脉搏波测心率和血氧、 用差压传感器判断吸气动作。那台设备在参数存储上犯了一个更典型的错误, 我把它写进了《手持血氧与吸氧差压监测仪》, 可以当作这一篇的反面补充。
十一、参考资料与说明
- PBOC 系列公开标准:中国金融集成电路(IC)卡规范中关于电子存折 / 电子钱包应用、 终端与卡片接口、安全报文(MAC/TAC)的公开规定,本文的文件标识、AID 结构、 密钥分类与 APDU 命令语义均来自这套公开规范与个人实践对照。
- ISO/IEC 7816 系列(识别卡·接触式集成电路卡):第 3 部分电气接口与传输协议、 第 4 部分交换用行业间命令,是 APDU 结构的来源。
- ISO/IEC 14443 系列(识别卡·非接触式集成电路卡):第 3 部分初始化和防冲突、 第 4 部分传输协议,本文非接触接口的寻卡、防冲突与选卡流程依据这一系列。
- Modbus 应用协议规范(Modbus Application Protocol Specification)中关于
功能码
0x03/0x06/0x10与异常码的定义。 - 射频读写芯片与卡片 COS 的命令集属于芯片厂商公开的数据手册内容, 本文只引用命令码与流程,不涉及任何厂商内部资料。
- 文中涉及的芯片型号(GD32F303VC、HC32F030F8TA、HC32L136K8TA、HC32F005C6PA 等) 仅用于说明技术方案,与相关厂商无隶属或授权关系。
- 关于代码披露程度:出于对个人项目实现细节的保护,本文只公开方法与设计取舍: “卡即内存”的映射只给信息类别表,不列具体偏移地址; 离线交易的结算顺序与幂等判定等核心实现,一律以步骤说明代替代码, 不给出可以直接照搬的判定条件与字段布局;页面里的代码是按个人理解重写的通用驱动骨架, 不代表任何产品或交付代码。文中涉及的设计契约(APDU 命令矩阵、密钥分类、故障码分级) 属于公开标准与设计说明,按原样保留。
- 文中所有偏移地址、错误码与流程均为个人学习项目中的实现整理, 已做精简与脱敏,并按要求去掉了具体偏移地址与产品参数取值; 文中不包含任何真实密钥、口令或卡号,也不涉及任何破解、绕过或攻击方法。