别把 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 只能证明链路走到了某一步
写函数返回成功时,我会追问:成功的是哪一层?
我把“写入成功”拆成四层:
- 传输成功:命令发送完成,卡片返回协议 ACK。
- 存储成功:重新读取目标块,16 字节与期望值完全一致。
- 语义成功:
magic、版本、字段范围、有效期和 CRC 都合法。 - 授权成功:控制器结合人员、区域、时间、撤销状态和策略,最终允许本次操作。
这四层不能互相替代。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 字节的边界;承认边界之后,就必须定义格式;定义格式之后,就必须讨论校验、版本和事务;再往外走,便会遇到密钥、授权、离线策略、消防联动和审计。
小小的数据块不是故事的终点,它是整个可信系统的缩影。
写评论
读完想说的,写在这里。
加载评论中…