本站为个人非经营性网站,仅用于技术学习与分享,不提供任何商品、服务、报价或收费咨询。 合规信息

IC 卡读写与脱机交易系统:
断电也不能丢的那笔钱

这是我做的规模最大的一次个人实验:不是一块板,而是一组板卡——读卡器、键盘、发卡器、 主控与数据板各自一块 MCU,挂在 RS485 总线上,共同完成“插卡 → 认证 → 加注 → 扣款 → 结算”这条链路。 它真正难的地方不是读卡,而是在没有网络的情况下,怎么保证每一笔钱都不多扣、不少扣、不重复扣。 这篇记录写的是这套系统怎么搭起来、为什么这么设计,以及我在数据一致性上踩过的坑。

CPU 卡 PBOC ED/EP RS485 脱机交易 数据一致性 已发布
已发布 · 个人学习项目

多板卡 IC 卡读写与脱机交易系统

这套系统的目标很具体:让一台离线计费类设备在没有后台连接的情况下,用一张 CPU 卡完成一次 有据可查的扣款。为此我把功能拆到多块板卡上——读卡器只管卡和射频、键盘只管人机交互、 主控负责业务状态机与存储、中控盒子负责总线仲裁与上报,板卡之间全部走 RS485 上的 Modbus 风格帧。卡片侧走 ISO14443A 非接触接口,用 PBOC 的电子存折 / 电子钱包应用。 结论是:功能全部实现过,交易链路也跑通了;但“卡即内存”的抽象缺少事务语义, 断电与拔卡的时序组合只能靠一套兜底机制弥补,这部分我至今仍觉得不够干净。

PLATFORM多 MCU 平台(Cortex-M0+ / M3 / M4)
STACKRS485 + Modbus 扩展 + ISO7816/ISO14443A
TOOLSKeil MDK + 逻辑分析仪 + 读卡调试工具
STATUS个人学习项目 · 功能已实现

一、这套系统由哪几块板组成

我一开始的想法是“一块板全干完”:读卡、显示、按键、存储都放一个 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 驱动——这大概是分层做得最对的一次。

中控盒子居中,总线向下挂:主板、读卡器、键盘、液位板、发卡器,每条支路标注板卡角色
图 1 · 四板卡总线拓扑图

二、卡片技术选型:为什么不只用逻辑加密卡

最常见的 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预留与扩展信息文件二进制 / 记录文件
0100MF 下的记录文件,存放应用目录名记录文件

其中 0018 这个循环记录文件是最省事的一个设计:我只需要在建卡时把这个文件建好, 交易成功后卡内 COS 会自己往里面写一条明细,不需要我组织明细内容再写回去。 这一点对脱机交易非常关键——明细是“卡自己记的账”,比设备单方面记录更可信。

七类密钥,各管一件事

卡里的密钥不是一把,而是按用途分成七类。它们的名字本身就说明了职责:

  • TAC 密钥(TACKey):产生交易认证码。交易完成后卡返回一个 TAC,设备把它写进流水,事后可以拿它验证“这笔交易确实是这张卡认可的”。
  • 线路保护密钥(LineCKey):算 MAC,保护“主机发给卡的这条指令”没被篡改。我做的 MAC 校验用的就是它。
  • 外部认证密钥(ExKey):外部认证用。主机先取随机数,再用这把密钥对随机数做运算交给卡验证,通过之后才允许后续操作。
  • 消费密钥(DPKey):消费扣款专用。
  • 圈存密钥(CZKey):充值(圈存)专用。
  • 口令密钥(PIN):持卡人口令,按 3 字节明文组织、不足部分补齐;它对应的是“卡片使用者的身份”,不是设备身份。
  • 内部认证密钥(INKey):内部认证用,方向与外部认证相反——卡证明自己是真卡。

每一类密钥在装载时都带一个标识符,比如圈存密钥的标识符、消费密钥的标识符、 线路保护密钥的标识符各占一个编号。我的理解是: 标识符的价值在于密钥版本化。卡里可以同时存在同一用途的多把密钥, 用标识符区分版本;换密钥时只要新版本装载进去、交易时选择新标识符, 就不需要把所有卡回收重发。

密钥存在哪三个地方

我后来把密钥分成三层来管理,这三层不能混:

  1. 卡内密钥:由卡内 COS 保存,用于算 MAC/TAC 和验证认证,永远读不出来。
  2. 发卡侧密钥:建卡时装进卡里的同一套密钥,只在发卡器上使用。
  3. 主控与读卡器之间的会话密钥:通过一条专门的“取密钥”命令向读卡器索取, 返回 16 字节,之后所有敏感寄存器读写都用它做加解密。 我特意让会话密钥每次会话从读卡器取,而不是写死在主控固件里—— 这样换密钥不用重新烧主控,也避免密钥散落在多个固件中。

四、APDU 命令:一条交易要经过哪些步骤

CPU 卡对外只有一种交互方式:APDU 命令—应答。命令由 CLA(命令类别)、 INS(指令码)、P1/P2(参数)和数据域组成。这套系统实际用到的命令并不多, 我把它们整理成下面这张表(表中 CLA/INS 为两字节十六进制):

CLAINS含义在流程里的位置
00A4选择文件每次操作前先选 MF 或 DF
0084取随机数外部认证的第一步,通常取 8 字节
0082外部认证用随机数证明终端身份
0020校验口令需要持卡人口令的场合
8050圈存初始化 / 消费初始化交易第一步,卡返回余额与密钥版本等信息
8052圈存(充值)写入金额并校验 MAC
8054消费扣款真正的扣款,带 MAC 与 TAC
805C读余额随时可读,用于起点与终点的对照
00 / 04D6写二进制(不带/带线路保护)写文件内容,两种卡版本各一套
00B2读记录读交易明细与基本信息
00E2追加记录写记录文件
0088内部认证P1 区分加密 / 解密 / MAC 计算三种模式
8424解锁口令密钥口令被锁之后解锁
8418应用解锁应用级解锁
800E删除根目录文件(格式化)建卡流程第一步
80E0建立文件建卡主力命令
80D4装载密钥把七类密钥写进密钥文件

一次完整消费的顺序

把上面的命令串起来,一次消费大致是这样走的:

  1. 选择根目录、再选择应用目录(00A4 两次)。
  2. 取随机数(0084),卡返回 8 字节随机数。
  3. 外部认证(0082):终端用外部认证密钥对随机数做运算后交给卡验证。
  4. 需要口令的场合,再做一次口令校验(0020)。
  5. 消费初始化(8050):卡返回余额、是否允许联机交易、密钥版本等信息。
  6. 主控在本地算出交易金额,用线路保护密钥加随机数算一个 MAC,随扣款指令一起下发。
  7. 消费扣款(8054):卡校验 MAC,通过后扣减余额、返回 TAC,并自动在 0018 明细文件里追加一条记录。
  8. 最后读记录(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加注前信息、加注中数据、未结算标志、扣款错误信息设备侧也留一份,用于对账与人工处理
⑥ 幂等设备内存 + 持久化标志结算状态机、结算发送标志、重复扣款异常码防止同一笔被扣两次或补两次

把这张表按时间顺序读一遍,就是这套机制的全部骨架。这里只写每一步“做什么、为什么”, 不写字段布局与判定条件——后者和卡内记录格式、退卡流程绑在一起,属于项目实现:

  1. 先把基准落到卡上(对应①):加注动作开始之前先写一次起点记录。 放在最前面的理由是:后面所有对账都要拿它当基准,而设备自己的 RAM 变量在拔卡、掉电之后保不住。
  2. 过程里持续落进度(对应②):加注过程中按周期写快照。 这一步是拿总线流量换数据安全——写密了占总线,写疏了断电时丢掉的金额区间就大。
  3. 结束时写终点(对应③):正常结束先写终点记录、把卡状态复位。 它是“这笔交易已经正常收尾”的唯一凭据,所以判定优先级最高。
  4. 只有终点没写成才走兜底(对应④):拔卡、掉线导致终点缺失时, 用最近一次快照补扣并复位卡状态。兜底是异常路径,不是常规路径。
  5. 设备侧同步留一份账(对应⑤):把起点信息、进度数据、未结算标志落到本地存储。 它不参与卡内扣款,只服务于事后对账和人工处理。
  6. 最后用幂等收口(对应⑥):结算这一步必须能回答“这笔到底处理过没有”。 为什么必须这么做、以及我踩过的坑,放在本节最后一条讲。

起点:先把“从哪开始”写进卡里

开始加注之前,设备会先往卡里写一次“加注前操作”:把卡状态改成运行中, 同时写入加注前余额和加注前时间戳(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 参数双区备份与掉电保护》 那篇笔记里写得更细,这里不再重复。

幂等:同一笔钱只能被处理一次

最后一个环节是防止重复处理。这一步的难点不在代码,而在“先想清楚什么算处理过”: 结算这条路有两个独立的触发源(加注结束、拔卡退卡),只要两边都可能发起结算, 就必须有一个共同的判定点来回答“这笔处理过没有”。我当时是按三条来做的:

  1. 给结算一个明确的中间态。设备侧维护一个结算状态机 (未结算 / 结算中 / 结算成功 / 结算失败),而不是用一个布尔量表示“结没结”—— 布尔量表达不了“报文已经在路上”这种中间情况。
  2. 报文发出之后不允许无声退出。发完结算报文,必须等到应答或者明确的超时, 才能离开这个状态。理由是“超时”不等于“失败”:超时的那一刻卡上可能已经扣成功了, 此时正确的动作是去查状态,而不是重发。
  3. 把“重复”变成一条看得见的故障。我加了两个专门的异常码 (设备侧一个、读卡器侧一个)显式表示“重复扣款异常”,一旦出现就停下来报故障, 而不是默默继续。

具体的判定条件与状态迁移表我在这里不写:它和卡内记录格式、退卡流程绑在一起, 属于项目实现。可以公开的是这条经验——凡是“一次操作由两个独立事件源驱动”的地方, 都要先想清楚冲突时谁说了算。

六个步骤横向排列:起点记录、周期快照、终点记录、兜底记录、本地持久化、幂等判定,标注掉电可能发生的时刻与对应的恢复动作
图 2 · 脱机交易的六个环节时序图

七、通信的可靠性:双层 CRC 与异常码分级

先看一条真实的“加注后结算”报文的字段顺序: [从站地址][功能码][寄存器地址][长度][业务数据][内层 CRC16][3DES 密文][整帧 CRC16]。 功能码用的是 Modbus 的“写单个寄存器”,寄存器地址指向上一节那张表里的“交易终点记录”, 长度字段描述业务数据的字节数,随后是业务数据、一段内层 CRC16、加密后的密文, 最后是整帧 CRC16。退卡那种短报文更简单,只有几个字节加两字节 CRC。 这里同样不写出具体的从站地址、寄存器地址与长度取值,它们都是项目参数。

为什么要算两次 CRC

内层 CRC 是对明文算的,随明文一起被加密。它的作用是让接收方在解密之后, 立刻能验证“解出来的这些业务字段是不是完整的”。外层 CRC 是对密文整帧算的, 管的是传输过程有没有被干扰。

少了内层会怎样?如果只有外层 CRC,一旦出现“解密成功但内容已经不对”的情况 (比如加密实现本身有缺陷、或者长度字段被错解),外层 CRC 依然可能是对的, 因为它是按密文算的。有了内层 CRC,解密方对业务数据的完整性有一份独立的证据。 代价是每次写卡要算两次 CRC,代码量和总线开销都增加,但我觉得这个代价值得。

三层加密栈

从卡到总线,敏感数据实际穿过了三层保护:

  1. 卡内 COS 层:用 DES/3DES 算 MAC 与交易认证码,密钥是卡里的线路保护密钥与 TAC 密钥。
  2. 主控与读卡器之间:整段数据先做 3DES,再按会话密钥首字节取模的结果做一次字节循环移位并取反, 作为一层自制加固。
  3. 总线帧层: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 卡相关的故障”,三位数字按区间分段。

显示形态区间含义低位的含义能否自行恢复
C1xxIC 卡故障,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 命令矩阵、密钥分类、故障码分级) 属于公开标准与设计说明,按原样保留。
  • 文中所有偏移地址、错误码与流程均为个人学习项目中的实现整理, 已做精简与脱敏,并按要求去掉了具体偏移地址与产品参数取值; 文中不包含任何真实密钥、口令或卡号,也不涉及任何破解、绕过或攻击方法。