2026年9月1日技术文章

别把 RFID 的 16 字节数据块当字符串

一个看似普通的 %s 输出问题,暴露了 RFID 开发中数据边界、存储格式、读写校验与权限设计之间的深层联系。本文从 MIFARE Classic 的 16 字节数据块出发,逐步分析 RC522 通信、写后回读、错误处理以及门禁系统的可信边界。

想交流这篇内容?评论区在文末。
写评论
目 录(20 节)

别把 RFID 的 16 字节数据块当字符串

从一行 %s 出发,看懂 RC522、MIFARE Classic、数据契约与门禁系统的安全边界

很多 RFID 程序都有一个看起来极其自然的动作:读出一个数据块,然后用 %s 打印。

printf("%s", block_data);

这行代码短得几乎不会引起警觉。卡里写入的是 "123456",读出来也确实能正常显示,于是“读写成功”似乎已经得到证明。

但我越来越倾向于把这行代码看成一条分界线:分界线的一边是“功能跑通”,另一边是“系统可信”。跨过这条线之后,问题不再只是能不能读到数据,而是数据有没有边界、有没有版本、能不能校验、能不能抵抗重放、写入失败时如何恢复,以及门锁究竟在相信什么。

一个 16 字节数据块很小,却足以把 RFID 系统从射频链路一直展开到业务安全。

一、%s 为什么可能是第一个隐患

C 语言里的字符串不是“若干字符”,而是“以 \0 结尾的一段内存”。printf("%s", p) 会从 p 指向的位置持续读取,直到遇到零字节。

RFID 数据块却是另一种对象:它是一段固定长度的二进制数据。以 MIFARE Classic 1K 为例,最小读写单位是 16 字节。读卡器返回 16 字节,不会额外赠送第 17 个字节作为字符串结束符。

于是会出现两类相反的问题:

  • 16 字节中没有 0x00:%s 会越过块边界继续读,直到在后续内存中偶然遇到零字节。轻则打印乱码,重则越界访问。
  • 有效数据中包含 0x00:%s 会提前停止,后半段数据看起来像“丢失”了。

测试字符串之所以经常表现正常,是因为数组初始化时剩余位置恰好被补零。例如 uint8_t data[16] = {"123456"}; 在第 7 个字节之后都是零。这个偶然条件掩盖了接口语义不匹配:卡块是二进制缓冲区,字符串只是二进制缓冲区的一种特殊解释。

正确做法首先是承认长度:

#define RFID_BLOCK_SIZE 16u

static void dump_hex(const uint8_t block[RFID_BLOCK_SIZE])
{
    for (size_t i = 0; i < RFID_BLOCK_SIZE; ++i) {
        printf("%02X%s", block[i], i + 1 == RFID_BLOCK_SIZE ? "\n" : " ");
    }
}

如果某段数据被明确设计成文本,还需要显式携带文本长度,或者复制到 17 字节缓冲区并手动补 \0。边界不能靠运气获得。

我的第一个结论是:RFID 的“读写接口”传递的是字节和长度,不是字符串。只要这个抽象没有立稳,后面的校验、升级和安全设计都会变得含糊。

二、16 字节不是容量限制,而是数据契约的起点

MIFARE Classic 1K 常被简称为 S50。它的 1KB 存储空间被分成 16 个扇区,每个扇区 4 个块,每块 16 字节。

扇区 1 的绝对块地址是 4、5、6、7:

绝对块地址 扇区内位置 常见用途
4 数据块 0 业务数据
5 数据块 1 业务数据
6 数据块 2 业务数据
7 扇区尾块 Key A、访问控制位、Key B

扇区 0 的块 0 是制造商块,常见 4 字节 UID、校验字节和厂商数据都在这里。每个扇区最后一块则是安全边界,不能当普通数据块覆盖。写错扇区尾块,可能直接改变密钥或访问条件,让整个扇区失去可访问性。

这套结构给了我一个很实用的设计提醒:块地址本身不是业务模型,块内格式才是。

只规定“用户编号写在块 4”远远不够。至少还要回答:

  • 这 16 字节属于哪一种记录?
  • 格式升级后,旧卡如何兼容?
  • 多字节整数采用大端还是小端?
  • 数据是否完整,是否来自一次完整写入?
  • 记录是否过期,是否已经被撤销?
  • 同一张卡反复刷取时,如何识别旧版本或重放数据?

我会把一个块设计成自描述的固定格式,而不是随手塞入字符串:

字节偏移 长度 字段 含义
0 1 magic 固定标识,例如 0xA5
1 1 version 数据格式版本
2 1 type 凭证或记录类型
3 1 flags 状态位、权限位
4 4 subject_id 主体编号,规定为大端序
8 4 valid_until 有效期时间戳
12 2 sequence 单调递增的发行序号
14 2 crc16 业务数据的误码校验

这 16 字节没有可读的姓名,却比一串姓名更适合门禁。姓名是展示信息,主体编号才适合参与授权;有效期和序号让控制器可以拒绝过期凭证与旧版本;magic 和 version 让解析器知道自己面对的是什么。

序列化时,我也不会直接把 C 结构体强制转换成字节数组。结构体可能有填充字节,CPU 还存在端序差异。更稳妥的方式是逐字段编码:

static void put_u32_be(uint8_t *p, uint32_t value)
{
    p[0] = (uint8_t)(value >> 24);
    p[1] = (uint8_t)(value >> 16);
    p[2] = (uint8_t)(value >> 8);
    p[3] = (uint8_t)value;
}

bool encode_credential(uint8_t out[16], uint32_t subject_id,
                       uint32_t valid_until, uint16_t sequence)
{
    memset(out, 0, 16);
    out[0] = 0xA5;
    out[1] = 0x01;
    out[2] = 0x01;
    out[3] = 0x00;
    put_u32_be(&out[4], subject_id);
    put_u32_be(&out[8], valid_until);
    out[12] = (uint8_t)(sequence >> 8);
    out[13] = (uint8_t)sequence;
    uint16_t crc = crc16(out, 14);
    out[14] = (uint8_t)(crc >> 8);
    out[15] = (uint8_t)crc;
    return true;
}

这里的 CRC 只负责发现传输或存储错误,不负责证明数据是谁写的。完整性与真实性是两件事,后面还会回到这个区别。

三、读到块之前,已经走过一条协议状态机

刷卡常被描述成一个动作,实际上它是一连串状态转换:

寻卡 Request
    ↓
防碰撞 Anticollision
    ↓
选卡 Select
    ↓
扇区认证 Authenticate
    ↓
读块 / 写块 Read or Write
    ↓
停止 Halt

1。寻卡:确认场内存在可响应的卡

阅读器发出请求命令,卡片返回类型信息。此时只能说明射频场内存在兼容标签,不能说明它是谁,更不能说明它有开门权限。

2。防碰撞:从多个响应者中分离出一个目标

多张卡同时响应会让信号叠加。防碰撞算法的任务,是在共享信道中逐步识别 UID,并把某一张卡从“共同发言”推进到“单独对话”。

这个过程解决的是通信秩序,不是安全认证。它像会议主持人点名:能让一个人单独发言,却不能证明发言者拥有某项权限。

3。选卡:建立本轮对话对象

阅读器带着 UID 和校验信息发出选择命令。卡片成功响应后,后续操作才指向这个明确目标。

4。扇区认证:证明持有相应密钥

MIFARE Classic 的访问控制以扇区为单位。读取或写入扇区 1 前,需要使用 Key A 或 Key B 对该扇区认证。切换到另一个扇区,通常要重新认证。

5。数据操作:读写的是块,不是“变量”

读命令返回 16 字节数据和链路层校验;写命令则是一个两阶段交互:先发送写命令、块地址和 CRC,收到 4 bit ACK(低四位为 0xA)后,再发送 16 字节数据和 CRC,随后等待第二次 ACK。

这说明“写卡”不是简单的 SPI 内存拷贝。STM32 先和 RC522 的寄存器、FIFO 打交道,RC522 再负责 ISO/IEC 14443 Type A 空中接口上的调制、解调、编码和校验,卡片还要执行认证与 EEPROM 写入。一个返回值背后叠着多层状态。

6。停止:让已经处理的卡退出本轮响应

Halt 能避免同一张卡持续占用当前盘点过程,也让后续寻卡重新从明确状态开始。状态管理不完整时,最常见的现象不是“永远失败”,而是偶发重复、偶发漏读和难以复现。

四、ACK 只能证明链路走到了某一步

写函数返回成功时,我会追问:成功的是哪一层?

我把“写入成功”拆成四层:

  1. 传输成功:命令发送完成,卡片返回协议 ACK。
  2. 存储成功:重新读取目标块,16 字节与期望值完全一致。
  3. 语义成功:magic、版本、字段范围、有效期和 CRC 都合法。
  4. 授权成功:控制器结合人员、区域、时间、撤销状态和策略,最终允许本次操作。

这四层不能互相替代。ACK 不是业务提交,回读一致也不是权限合法,CRC 正确更不是防伪证明。

一个更可靠的写入流程应当是:

读取旧记录
  → 校验旧记录并确认目标版本
  → 写入新记录
  → 立即回读
  → 逐字节比对
  → 再做语义校验
  → 更新后台状态与审计日志

如果一个业务记录跨越多个块,还要考虑掉电或移卡造成的“半更新”。我更喜欢 A/B 槽位设计:两个数据块轮流保存记录,每份记录带版本号、序号和校验;读取时选择最新且合法的一份,写入时先写非活动槽,回读验证后再切换活动标记。

这种方法的价值不在于形式漂亮,而在于它承认射频交互可能在任意时刻中断。用户把卡片移开、电磁场波动、天线失谐、认证状态丢失,都不应该把系统推入无法解释的中间状态。

五、防碰撞解决“谁先说”,认证才回答“能不能做”

RFID 里最容易被混淆的三件事是 UID、认证和授权。

  • UID 用于识别与防碰撞,是一个通信层标识。
  • 扇区认证用于证明读写器持有访问该扇区的密钥。
  • 业务授权用于判断某个主体在此时、此地、对这扇门是否有权限。

把 UID 直接放进白名单就开门,系统虽然简单,却等于把“知道卡号”当成“拥有身份”。UID 可能被读取、记录或复制;默认密钥 FF FF FF FF FF FF 更只适合初始联调,不适合承担真实安全责任。

MIFARE Classic 的 Crypto1 已经属于旧式安全机制。我的工程判断是:它仍可用于教学、低风险识别或存量兼容,但不应把“卡片能认证”直接等同于“系统足够安全”。对较高安全等级的场景,更合适的方向包括:

  • 使用支持 AES、双向认证和安全消息传输的卡片,例如 DESFire 或合规 CPU 卡。
  • 为不同卡片或不同应用派生密钥,避免全系统共用一个静态密钥。
  • 让卡片保存最少的凭证数据,核心权限由控制器或后台策略决定。
  • 对离线凭证使用 MAC 或数字签名,CRC 只保留为误码检测。
  • 引入有效期、发行序号、撤销列表和重放检测。
  • 对连续认证失败、异常频率刷卡和越权访问进行限速与审计。

我的第二个结论是:防碰撞建立的是通信唯一性,密钥建立的是扇区访问权,门禁策略建立的才是业务许可。三层合并成一个“卡号正确”判断,风险就会被隐藏。

六、读卡器不应该成为权限数据库

一个完整门禁系统至少包含身份识别单元、控制单元、电锁执行单元、门磁与出门按钮,还要考虑消防联动、日志、网络和离线运行。

我更愿意把它理解成一条信任链:

卡片或生物特征
    ↓  提供凭证
读卡器 / 识别终端
    ↓  传递标准化身份事件
门禁控制器
    ↓  执行本地策略与安全联动
权限服务 / 管理平台
    ↓  下发权限、撤销和时间策略
电锁 + 门磁 + 报警
    ↓  形成物理世界的闭环反馈
审计日志

这条链里,读卡器负责“读”,控制器负责“判”,电锁负责“做”,门磁负责“证实门是否真的按预期变化”。如果读卡器直接驱动电锁,或者把所有决策塞进前端一体机,设备被拆、通信线被短接、配置丢失时,系统的可信边界会非常脆弱。

联网也不意味着把所有判断交给服务器。门禁需要 7×24 小时工作,网络中断时控制器仍应保存必要的本地权限和事件缓存;网络恢复后再同步日志。与此同时,消防场景下的开门策略、断电后的失效安全或失效保护选择,必须按场所风险单独设计,不能被普通的“刷卡成功”逻辑覆盖。

七、把一个块写稳,需要哪些工程约束

我会给 RFID 数据层设定以下底线。

数据边界

  • 所有读写 API 同时传递指针和长度。
  • 固定块统一使用 uint8_t block[16],不隐式当成字符串。
  • 文本字段显式规定编码和长度;二进制展示统一使用十六进制。
  • 禁止对未确认终止符的缓冲区使用 %s、strlen、strcpy。

格式契约

  • 每种记录包含 magic、版本和类型。
  • 明确端序、时间单位、空值和保留位语义。
  • 解码器先验证再使用,未知版本默认拒绝或降级处理。
  • 不直接序列化 C 结构体,不依赖编译器填充规则。

读写可靠性

  • 每一步都检查状态码,不能只检查循环最后一次操作。
  • 写后立即回读并逐字节比对。
  • 多块更新使用版本号、双槽或提交标记恢复中断。
  • 超时、移卡、认证丢失和重复刷卡都作为正常异常路径设计。

安全边界

  • 不写制造商块,不把扇区尾块当数据块。
  • 不在正式环境使用默认密钥或全卡通用密钥。
  • UID 不直接等价于用户身份。
  • CRC 不等价于 MAC,链路加密不等价于业务授权。
  • 高风险开门使用更强卡片、后台撤销与多因子认证。

可观测性

  • 日志区分“未寻到卡、碰撞失败、选卡失败、认证失败、写 ACK 失败、回读不一致、策略拒绝”。
  • 记录耗时、重试次数和读卡信号质量,便于定位天线、供电与环境干扰。
  • 日志中避免明文输出密钥、完整生物模板和不必要的个人信息。

八、一个容易忽略的代码细节:循环成功不代表整体成功

清空三个数据块时,常见写法是连续覆盖同一个 status:

for (uint8_t i = 0; i < 3; ++i) {
    status = write_block(base + i, zero_block);
}
if (status == OK) {
    puts("clear success");
}

如果块 4 写失败、块 5 和块 6 成功,最终 status 仍可能是成功,系统会宣布“全部清除”。这和 %s 的问题很像:代码看似完成了任务,但把边界和整体语义丢掉了。

我会在第一次失败时停止,并保留失败地址;如果业务要求三个块共同成功,则把它们当作一个事务处理:

for (uint8_t i = 0; i < 3; ++i) {
    status = write_block(base + i, zero_block);
    if (status != OK) {
        report_write_failure(base + i, status);
        return status;
    }
}
return verify_all_three_blocks(base, zero_block);

错误处理不是附属逻辑,它定义了“成功”这个词究竟意味着什么。

九、从 16 字节回到物联网:真正重要的是语义连续性

RFID 系统横跨多个尺度:天线上的电磁耦合、RC522 的寄存器与 FIFO、ISO/IEC 14443 Type A 的帧、卡片的扇区权限、STM32 的缓冲区、门禁控制器的策略,以及后台的权限生命周期。

这些层之间最危险的地方,不是某一层完全不工作,而是每一层都“差不多能工作”,却对同一个数据拥有不同理解:

  • 射频层认为 16 字节已经收到;
  • 驱动层认为 ACK 已经返回;
  • 应用层把二进制当成字符串;
  • 控制器把 UID 当成身份;
  • 门锁把一次输出电平当成完整授权。

系统性问题往往就藏在这些“差不多”之间。

我最终得到的判断很简单:一个可靠的 RFID 系统,不是把卡读出来,而是让身份语义从卡片到门锁始终连续。

这也是为什么我会从 %s 这样一行小代码开始。它迫使我先承认 16 字节的边界;承认边界之后,就必须定义格式;定义格式之后,就必须讨论校验、版本和事务;再往外走,便会遇到密钥、授权、离线策略、消防联动和审计。

小小的数据块不是故事的终点,它是整个可信系统的缩影。

写评论

读完想说的,写在这里。

等待第一条评论

加载评论中…