NDEF 报文格式详解:记录头、TNF 与常见记录解析
NDEF(NFC Data Exchange Format,NFC 数据交换格式)是 NFC Forum 定义的标签数据存储标准。无论你用手机碰一下 NTAG 贴纸,还是用读卡器读取 Type 4 卡片,应用层读到的都是一条 NDEF 报文(NDEF Message)——由一条或多条NDEF 记录(Record)串联而成的二进制序列。本文从原始字节层面完整拆解 NDEF:记录头五个标志位、TNF 类型名格式、Text/URI/Smart Poster 等常见记录的载荷结构、分块记录的重组规则,以及实际解析中最容易踩的坑。
一、NDEF 报文与记录的整体结构
一条 NDEF 报文就是若干条记录首尾相接。每条记录由三段组成:记录头 + 类型/ID + 载荷。记录头固定 2 字节起步:
- 记录头第 1 字节:编码 5 个标志位 + 3 位 TNF(Type Name Format);
- Type Length:1 字节,Record Type 字段的长度;
- Payload Length:SR=1 时 1 字节(最长 255),SR=0 时 4 字节大端序;
- ID Length:仅当 IL=1 时出现,1 字节;
- Record Type:TNF 约定的类型名(如 "T"、"U"、"Sp");
- Record ID:可选,IL=1 时存在;
- Payload:真正的业务数据。
判断报文边界的规则很简单:第一条记录 MB=1,最后一条记录 ME=1。解析器读到 ME=1 的记录即宣告报文结束。
二、逐位拆解记录头第 1 字节
记录头的第 1 字节是全部解析逻辑的起点,8 个位的含义如下:
Bit7 Bit6 Bit5 Bit4 Bit3 Bit2 Bit1 Bit0 MB ME CF -- SR IL TNF(3位)
- MB(Message Begin,0x80):本报文第一条记录置 1;
- ME(Message End,0x40):本报文最后一条记录置 1;
- CF(Chunk Flag,0x20):载荷被分块时置 1(见第六节);
- SR(Short Record,0x10):载荷长度 ≤255 字节时置 1,Payload Length 占 1 字节;
- IL(ID Length,0x04):记录携带 ID 时置 1;
- TNF(Bit2-0):类型名格式,决定 Record Type 字段如何解释。
用 Python 提取这些标志位只需几行:
def parse_header(byte):
mb = (byte >> 7) & 1 # Message Begin
me = (byte >> 6) & 1 # Message End
cf = (byte >> 5) & 1 # Chunk Flag
sr = (byte >> 3) & 1 # Short Record
il = (byte >> 2) & 1 # ID Length 存在
tnf = byte & 0x07 # Type Name Format
return mb, me, cf, sr, il, tnf
大多数市售标签上的简单内容(一条 URI 或一条文本)都是 SR=1、IL=0 的单记录报文,头部仅 3 字节。
三、TNF:类型名格式的 7 个取值
TNF 占 3 位,告诉解析器"Record Type 字段是什么体系"。NFC Forum RTD 规范定义了 7 个值:
| TNF 值 | 名称 | 含义 | 典型示例 |
|---|---|---|---|
| 0x00 | Empty | 空记录,无类型无载荷 | 分块记录的首块占位 |
| 0x01 | Well-Known | NFC Forum RTD 定义的知名类型 | "T" 文本、"U" URI、"Sp" 智能海报 |
| 0x02 | MIME | 互联网媒体类型 | "text/plain"、"application/json"、"image/png" |
| 0x03 | Absolute URI | 绝对 URI 型类型名 | "urn:nfc:ext:example.com:mytype" |
| 0x04 | External | 厂商自定义外部类型 | "android.com:pkg"(Android 应用包名) |
| 0x05 | Unknown | 未知类型的不透明数据 | 私有协议载荷 |
| 0x06 | Unchanged | 分块记录的中间块与末块 | 仅用于 Chunked 场景 |
实践中 90% 以上的标签只用到 TNF=0x01(Well-Known)与 TNF=0x02(MIME)两类;android.com:pkg 是 Android 应用启动记录(AAR)的 External 类型代表,写入后碰标签可直达指定应用。
四、Text 记录:状态字节 + 语言码 + 文本
Text 记录(TNF=0x01、Type="T")的载荷第一字节是状态字节:最高位表示编码(0=UTF-8,1=UTF-16),低 6 位是语言码长度(通常为 2,如 "en"、"zh")。状态字节之后依次是语言码与正文:
# Text 载荷结构:
# [状态字节] [语言码 (2-5 字节)] [正文 (UTF-8 或 UTF-16)]
def parse_text_payload(payload):
status = payload[0]
utf16 = (status >> 7) & 1 # 0=UTF-8, 1=UTF-16
lang_len = status & 0x3F # 语言码长度
lang = payload[1:1+lang_len] # 如 b"zh"
text_bytes = payload[1+lang_len:]
encoding = 'utf-16-be' if utf16 else 'utf-8'
text = text_bytes.decode(encoding)
return lang, text
# 示例:中文"你好NFC"(UTF-8, 语言码 zh)
# Hex: 02 7A 68 E4 BD A0 E5 A5 BD 4E 46 43
# │ └zh┘ └── 你好NFC 的 UTF-8 字节 ──┘
# 状态字节 0x02 = UTF-8 + 语言码长度 2
注意 UTF-16 分支用的是UTF-16BE(大端),不是平台默认字节序——这是中文开发者用 Java 处理时最常见的编码错位点。
五、URI 记录:前缀缩写表
URI 记录(TNF=0x01、Type="U")载荷第一字节是前缀代码(0x00-0x24),把 "https://" 等常见 scheme 压缩成 1 字节,剩余部分是 URI 后缀。常用前缀如下:
| 代码 | 前缀 | 代码 | 前缀 |
|---|---|---|---|
| 0x00 | (无缩写) | 0x04 | https:// |
| 0x01 | http://www. | 0x05 | tel: |
| 0x02 | https://www. | 0x06 | mailto: |
| 0x03 | http:// | 0x23 | https://eid.me/ (示例) |
def parse_uri_payload(payload):
code = payload[0]
prefix = PREFIXES.get(code, "")
suffix = payload[1:].decode('utf-8')
return prefix + suffix
# 示例:0x04 + "cupass.com" → "https://cupass.com"
# 完整报文(含头):
# D1 01 0C 55 04 63 75 70 61 73 73 2E 63 6F 6D
# │ │ │ └U─┘ └cupass.com──────┘
# │ │ └载荷长 12
# │ └类型长 1
# └D1 = MB+ME+SR, TNF=1
这个前缀缩写机制在容量紧张的 NTAG 213 上很有价值——每条 URI 记录可省 6-7 字节。
六、分块记录(Chunked Record)的重组
当单条记录载荷超过标签单次写入能力时,可用分块机制把载荷拆到多条记录中:
- 首块:MB=1、ME=0、CF=1,携带完整 TNF 与 Type;
- 中间块:MB=0、ME=0、CF=1,TNF 固定为 Unchanged(0x06);
- 末块:MB=0、ME=1、CF=0,TNF=Unchanged(0x06)。
def reassemble_chunks(records):
"""把分块记录重组成完整载荷。"""
payload = bytearray()
record_type = None
for rec in records:
if rec.cf:
if rec.mb: # 首块:记录类型信息
record_type = rec.type
payload.extend(rec.payload)
else: # 末块
payload.extend(rec.payload)
return record_type, bytes(payload)
实战建议:除非目标设备强制要求,尽量避免产生分块记录。许多手机 NDEF 栈对分块的支持并不完善,重组失败会表现为"标签读取为空"。
七、Smart Poster:报文里套报文
Smart Poster(Type="Sp")是唯一"载荷本身又是一条完整 NDEF 报文"的常见记录。它的载荷中至少包含一条 URI 记录,可再嵌可选的 Title(Text)、Icon(MIME)、Action 等记录:
def parse_smart_poster(payload):
"""Smart Poster 载荷本身是一条 NDEF 报文。"""
records = parse_ndef_message(payload)
uri, titles = None, {}
for rec in records:
if rec.type == b'U':
uri = parse_uri_payload(rec.payload)
elif rec.type == b'T':
lang, text = parse_text_payload(rec.payload)
titles[lang] = text
return uri, titles
解析时注意递归:Smart Poster 内部的记录同样遵守 MB/ME 边界规则,与外层报文相互独立。
八、常见解析陷阱排查表
| 现象 | 原因 | 处理 |
|---|---|---|
| 第一条记录没有 MB 标志 | 标签写入被中断,数据损坏 | 原始 dump 场景可按"首记录默认 MB=1"容错处理 |
| 载荷 >255 字节但 SR=1 | 写入方编码错误 | 按 SR=0 的 4 字节长度重新解析 |
| 文本解码出现乱码 | 状态字节 UTF-16 位与实际编码不符 | 解码前校验状态字节 bit7;UTF-16 必须用大端 |
| 分块重组后类型丢失 | 中间块 TNF 不是 Unchanged | 校验首块 TNF,其余块必须为 0x06 |
| 手机读不到内容 | NDEF TLV 外壳缺失或长度错误 | 检查数据区是否以 0x03 [长度] [NDEF] 0xFE 包裹 |
九、动手验证与相关工具
读到这里,建议拿一条真实标签的 hex dump 亲手拆一遍。本站工具可以全程辅助:编码转换工具箱 做字节级十六进制转换与 Base64 对照,APDU 状态码速查 处理 Type 4 卡读取 NDEF 时的状态字,TLV 解析器 拆解 NDEF 外层的 TLV 结构。
想继续深入 NFC 主题,推荐阅读本知识库的《NFC 标签类型识别:ATQA、SAK、ATS 完全解读》、《如何向 NFC 标签写入 NDEF》与《MIFARE Classic 存取控制位详解》。
卡智通