C#读写NDEF智能海报:字节解析与读卡器实战
简介这是一份面向C#开发者的NFC NDEF标签读写源码工程适合需要实现智能海报、网址跳转、WiFi连接、蓝牙配对等近场通信应用的软件工程师。工程围绕NDEF智能海报场景完整覆盖Forum Type2/Type4/Type5、Ntag2x、15693、MifareClassIc等常见NFC标签类型并提供写入NDEF纯文本、地图坐标、呼叫电话、电子名片、启动APP应用以及读取标签信息等多种封装接口。压缩包共45个文件包含12个C#核心代码文件、2个DLL库、4个配置文件及编译生成的EXE/PDB等压缩后仅1.6MB便于直接下载、修改与二次编译。工程附带标准Visual Studio解决方案sln/csproj与界面资源可快速定位Form1主窗体与Program入口。已有236人学习浏览适合正在开发NFC读写器上位机或NDEF标签制作工具的开发者借鉴可作为标签格式封装、类型适配与界面交互的参考实现。1. 拿 C# 读写 NDEF 智能海报文本先把卡里的字节流拆明白把一张已经写好内容的 NFC 智能海报标签贴在展架上手机一碰就能弹出网址或小程序——这个场景不陌生但真到要自己用 C# 写一套读写程序时很多人第一反应是找现成库结果发现 .NET 生态里能直接用、又不依赖特定厂商 SDK 的 NDEF 库少得可怜。这篇笔记就是基于一套 C# 读写文本、URI、小程序跳转等 NDEF 智能海报文本的源码完整走一遍从字节流解析到 PC/SC 读卡器读写的流程。适合三种人正在做 NFC 标签管理工具的 C# 上位机开发者、需要批量写入海报标签的运营人员、以及被“读卡器读出来是乱码”这种问题折磨过的同学。这里不会只讲怎么调库而是把 NDEF 记录怎么在字节里组织、C# 怎么写字节解析器、写卡时为什么容易翻车一次性讲透。2. NDEF 格式拆解海报内容在卡里到底长什么样2.1 记录头从第一个字节读出 TNF 和长度分布NDEFNFC Data Exchange Format是 NFC Forum 定义的数据封装格式一张智能海报标签对应的是一整条 NDEF 消息Message消息由一条或多条 Record 组成。每条 Record 以记录头Record Header开头C# 解析时的第一步就是把 Header 里那几个 bit 拆出来。一个 NDEF 记录头固定是 1 个字节bit 分布如下Bit含义说明7MBMessage Begin本记录是否是该消息的第一条6MEMessage End本记录是否是该消息的最后一条5CFChunk Flag是否分块4SRShort Recordpayload 长度是否只有 1 字节3ILID Length 是否存在2~0TNFType Name Format类型名格式C# 里读这个字节最直接的做法是位运算byte header ndefBytes[offset]; bool mb (header 0x80) ! 0; bool me (header 0x40) ! 0; bool cf (header 0x20) ! 0; bool sr (header 0x10) ! 0; bool il (header 0x08) ! 0; byte tnf (byte)(header 0x07);这段代码把记录头的每一位拆开存成独立变量sr决定 payload 长度字段占 1 字节还是 4 字节il决定后面是否跟着 ID 字段。拿到这些标记后才能继续往下一个字段走。很多新手直接跳过 Header 就去读 payload读出来全是偏移量错乱后的乱码。2.2 TNF 类型名格式与 C# 解析的对应关系TNF 是一个 3 bit 的值它告诉解析器“这条记录的类型名是用什么方式表达的”。常见的 TNF 值有 0Empty、1NFC Forum well-known type、2Media-type比如 MIME、3绝对 URI、4外部类型。智能海报里的文本和 URI 记录TNF 基本都是 1也就是 well-known type此时 Type Name 字段存的是 T 或 U 这样的短类型名。C# 读取 Type Name 的流程是跳过 Header 之后先按typeLength读取类型名。Type Length 是一个字节紧跟在 Header 后面。SR 标记会影响 payload length 的长度但不影响 typeLength 的位置。完整读字段顺序是Header - Type Length - ID Length如果 IL1- Type Name - ID如果 IL1- Payload Length - Payload。int offset startIndex; byte tnf (byte)(ndefBytes[offset] 0x07); bool sr (ndefBytes[offset] 0x10) ! 0; bool il (ndefBytes[offset] 0x08) ! 0; int typeLength ndefBytes[offset 1]; int idLength il ? ndefBytes[offset 2] : 0; int payloadLength sr ? ndefBytes[offset 2 (il ? 1 : 0)] : BitConverter.ToInt32(ndefBytes, offset 2 (il ? 1 : 0));这里的关键点是payloadLength的读取位置会受il影响不能写死偏移量。实际项目里我见过多个解析器在这块把 ID Length 字段漏掉导致整条消息的字节偏移错位读出来的 payload 长度和真实数据对不上。解析前建议先把整条消息的字节数组用Debug.Assert校验长度再走解析逻辑。2.3 Smart Poster 记录一条消息里塞进多条子记录智能海报Smart Poster本身是一个 NFC Forum 定义的 well-known type Sp。一条 SP 记录内部可以包含若干条子记录常见的有标题文本type T、URItype U、推荐动作type act值是 0x01 表示 Do Action。外层 SP 记录会出现在标签的 CCCapability Container之后的数据区开头。C# 解析 Smart Poster 时不能只解析到第一层记录就停必须递归解析嵌套结构。我的做法是写一个ParseNdefMessage方法返回一个ListNdefRecordInfo然后在判断出当前记录 type 为 Sp 时把 payload 部分再次当作一条 NDEF 消息传入解析函数递归调用。public ListNdefRecordInfo ParseNdefMessage(byte[] payload) { var records new ListNdefRecordInfo(); int offset 0; while (offset payload.Length) { int start offset; byte header payload[offset]; bool mb (header 0x80) ! 0; bool me (header 0x40) ! 0; bool sr (header 0x10) ! 0; bool il (header 0x08) ! 0; byte tnf (byte)(header 0x07); offset; int typeLength payload[offset]; int idLength il ? payload[offset] : 0; string typeName Encoding.ASCII.GetString(payload, offset, typeLength); offset typeLength; offset idLength; int payloadLength sr ? payload[offset] : BitConverter.ToInt32(payload, offset); offset sr ? 0 : 4; byte[] content new byte[payloadLength]; Array.Copy(payload, offset, content, 0, payloadLength); offset payloadLength; records.Add(new NdefRecordInfo { TNF tnf, TypeName typeName, MB mb, ME me, Payload content }); } return records; }这段代码的核心是一个 while 循环按记录头的元数据逐条切分字节。Array.Copy之前已经把offset精确移到了 payload 起始位置所以切出来的content是干净的数据。特别说明一下sr为 false 时payloadLength占 4 字节大端序有的移植代码直接用BitConverter.ToInt32会拿到反序的结果因为 C# 默认是小端序需要先做Array.Reverse。这是 C# 写 NDEF 解析最容易踩的坑。3. 用 PC/SC 读卡器读海报从 APDU 到完整 NDEF 报文3.1 C# 连接读卡器SCard 上下文的建立与卡连接要读 NDEF 标签C# 端通常走 PC/SC 接口。常见读卡器是 ACR122U 这类 CCID 设备Windows 会把它识别为智能卡读卡器。C# 里通过SCardEstablishContext建立资源管理器上下文再列出可用读卡器之后用SCardConnect连接卡。IntPtr hContext; int ret SCardEstablishContext(2, IntPtr.Zero, IntPtr.Zero, out hContext); if (ret ! 0) throw new Exception(SCardEstablishContext 失败: ret); uint pcchReaders 0; ret SCardListReaders(hContext, null, null, ref pcchReaders); byte[] readersBuf new byte[pcchReaders]; ret SCardListReaders(hContext, null, readersBuf, ref pcchReaders); string readers Encoding.ASCII.GetString(readersBuf).TrimEnd(\0); string readerName readers.Split(\0)[0]; IntPtr hCard; ret SCardConnect(hContext, readerName, 0, 3, out hCard);这里第一个参数2表示 SCARD_SCOPE_USER第三个参数是共享模式3是 SCARD_SHARE_SHARED。SCardConnect成功之后拿到hCard句柄后续所有 APDU 交换都走这个句柄。注意SCardListReaders第一次调用传 null 是为了获取缓冲区大小第二次才真正填充数据这两个调用不能合并否则缓冲区不够会报 0x80100008。3.2 发送 READ BINARY 读取 NDEF 数据区连接成功之后要读取标签里的 NDEF 报文。NFC Tag 通常用 Type 2 Tag 或 Type 4 Tag 规范。Type 4 Tag 的数据访问服务是 NDEF Tag Application通过 SELECT 指令选中然后用 READ BINARY 按块读取。Type 2 Tag 则直接用 READ 指令读块。下面是 ACR122U 配合 Type 4 Tag 的读取流程byte[] selectApdu { 0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00 }; byte[] resp Transmit(hCard, selectApdu); // 预期返回 0x9000 byte[] readApdu { 0x00, 0xB0, 0x00, 0x00, 0x10 }; resp Transmit(hCard, readApdu);Transmit方法内部封装了SCardTransmitAPDU 的组成是 CLA0x00INS0xB0READ BINARYP1/P2 表示起始块位置最后那个 0x10 是读取长度。真正实现中不能只读一次要循环读取直到拿到完整 NDEF 报文每次读完后判断响应中是否包含结束标记多为 0xFE否则继续读取下一块并拼接。Listbyte ndefBytes new Listbyte(); int block 0; while (true) { byte[] apdu { 0x00, 0xB0, (byte)(block 8), (byte)(block 0xFF), 0x10 }; byte[] resp Transmit(hCard, apdu); if (!IsSuccess(resp)) break; byte[] data resp.Take(resp.Length - 2).ToArray(); ndefBytes.AddRange(data); if (data.Contains(0xFE)) { ndefBytes.Remove(0xFE); break; } block 0x10; }这段循环代码的关键是每次都从上次结束的位置继续读block递增步长是 0x1016 字节与 Type 4 Tag 的块尺寸保持一致。读取过程中遇到 0xFE 要移除因为它是 NDEF 消息的结束标记不是有效业务数据。IsSuccess判断的是响应尾部两个字节是否为 0x90 0x00如果返回 0x6A82说明已经读到标签末尾之外了。3.3 解析结果验证把字节流还原成可读文本拿到完整 NDEF 字节流之后用第 2 章的ParseNdefMessage方法解析。这里有个经验不要把 CC 文件头里的头部信息当成 NDEF 消息的一部分。CCCapability Container里保存的是标签容量和读写属性Type 4 Tag 的 CC 从偏移 0x00 开始NDEF 报文实际在 0x0000 之后的 NLNDEF Length字段开始。很多 C# 新手直接把ReadBinary全部返回值丢给解析器结果把 CC 的长度字节也当成记录头解析第一层就错位。var records ParseNdefMessage(ndefBytes.ToArray()); foreach (var rec in records) { if (rec.TypeName Sp) { var innerRecords ParseNdefMessage(rec.Payload); foreach (var inner in innerRecords) { if (inner.TypeName T) { string lang Encoding.ASCII.GetString(inner.Payload, 0, 2); string text DecodeTextPayload(inner.Payload); Console.WriteLine($[文本] {lang}: {text}); } else if (inner.TypeName U) { string url DecodeUriPayload(inner.Payload); Console.WriteLine($[URI] {url}); } } } }DecodeTextPayload那一步要处理文本编码标记payload 第一个字节高三位是编码格式0 表示 UTF-81 表示 UTF-16低五位是语言码长度。这个位置的常见问题是直接把 payload 从头按 UTF-8 解码如果遇到 UTF-16 编码的记录中文内容会变成夹杂空字节的乱码。文本解码在后面的避坑章节还会展开。4. 写入海报文本、URI 与小程序跳转的三种录法4.1 构造文本记录与 URI 记录的 payload写卡本质上是对 NDEF 消息做反向组装。文本记录的 payload 结构是状态字节编码格式 语言码长度 语言码如 zh 实际文本内容。URI 记录的 payload 则是协议标识符字节URI Identifier Code 去掉前缀的 URL 内容。协议标识符的值在 NFC Forum 的 URI Record Type Definition 里有定义比如 0x01 表示http://www.0x02 表示https://0x03 表示http://0x04 表示https://www.。C# 构造记录的核心代码如下public byte[] BuildTextRecord(string text, string langCode zh, bool isUtf8 true) { byte langLen (byte)langCode.Length; byte header (byte)((isUtf8 ? 0 : 1 7) | langLen); byte[] langBytes Encoding.ASCII.GetBytes(langCode); byte[] textBytes isUtf8 ? Encoding.UTF8.GetBytes(text) : Encoding.Unicode.GetBytes(text); using var ms new MemoryStream(); ms.WriteByte(header); ms.Write(langBytes, 0, langBytes.Length); ms.Write(textBytes, 0, textBytes.Length); return ms.ToArray(); } public byte[] BuildUriRecord(string url) { byte idCode 0x00; string body url; if (url.StartsWith(https://www.)) { idCode 0x04; body url.Substring(12); } else if (url.StartsWith(http://www.)) { idCode 0x03; body url.Substring(11); } else if (url.StartsWith(https://)) { idCode 0x02; body url.Substring(8); } else if (url.StartsWith(http://)) { idCode 0x01; body url.Substring(7); } using var ms new MemoryStream(); ms.WriteByte(idCode); byte[] bodyBytes Encoding.UTF8.GetBytes(body); ms.Write(bodyBytes, 0, bodyBytes.Length); return ms.ToArray(); }构造 URI 记录时有一个容易忽略的点URI Identifier Code 的作用是压缩长度让标签有限的空间能存更多内容。如果你传入的 URL 已经带了https://前缀就不要再多加一层前缀否则会出现双前缀。我一般会在构造前统一把所有 URL 转成小写再判断前缀避免大小写不一致导致前缀识别失败。4.2 小程序跳转AAR 记录与 androidapp:// 前缀的取舍智能海报里“小程序跳转”在 C# 源码中一般有两种实现路径。第一种是直接在 URI 记录里写入微信小程序生成的链接形如https://wxaurl.cn/...手机碰一碰后浏览器打开该链接微信再根据链接规则唤起小程序。第二种是写入 Android Application RecordAARtype 是 NFC Forum 外部类型TNF4Type Name 是android.com:pkgpayload 是包名。写 AAR 记录的核心代码如下public byte[] BuildAarRecord(string packageName) { byte[] type Encoding.UTF8.GetBytes(android.com:pkg); byte[] payload Encoding.UTF8.GetBytes(packageName); using var ms new MemoryStream(); ms.WriteByte(0x04); // TNF External Type ms.WriteByte((byte)type.Length); ms.WriteByte(0x00); // ID Length 0 ms.Write(type, 0, type.Length); ms.Write(payload, 0, payload.Length); return ms.ToArray(); }但这里要说清楚AAR 的作用是强制 Android 手机打开指定 App它并不是微信小程序的标准唤起方式。小程序跳转的场景里最常见做法还是把微信提供的小程序链接直接写成 URI 记录因为 AAR 的语义是“打开某个原生应用”如果手机没装对应 App行为反而失控。C# 源码里把 AAR 和 URI 都作为可选项我的建议是单存小程序链接用 URI如果要实现“打开 App 且 App 内直达小程序”则同时写两条记录一条 URI 带链接、一条 AAR 带包名并把 URI 记录放在前面。4.3 UPDATE BINARY 回写处理写错后的后悔药写卡操作比读卡多一个风险写入失败可能把原有内容抹掉一半。所以回写前一定要先把原始字节保留到内存写失败时能恢复。回写 Type 4 Tag 的 NDEF 区时先更新 NL 字段NDEF 消息长度再写 NDEF 报文内容最后更新 CC 里的写保护位。顺序不能乱否则设备读到一半的长度字段是旧的。public bool WriteNdef(IntPtr hCard, byte[] ndefMessage) { byte[] nlen BitConverter.GetBytes(ndefMessage.Length); if (BitConverter.IsLittleEndian) Array.Reverse(nlen); byte[] updateNlen new byte[5] { 0x00, 0xD6, 0x00, 0x00, (byte)nlen[3] }; byte[] resp Transmit(hCard, updateNlen); if (!IsSuccess(resp)) return false; int offset 0; while (offset ndefMessage.Length) { int chunkSize Math.Min(0x10, ndefMessage.Length - offset); byte[] apdu new byte[5 chunkSize]; apdu[0] 0x00; apdu[1] 0xD6; apdu[2] (byte)(offset 8); apdu[3] (byte)(offset 0xFF); apdu[4] (byte)chunkSize; Array.Copy(ndefMessage, offset, apdu, 5, chunkSize); resp Transmit(hCard, apdu); if (!IsSuccess(resp)) { // 写失败尝试用读回的数据恢复 return false; } offset chunkSize; } return true; }0x00 0xD6是 UPDATE BINARY 指令后面跟起始地址和长度。回写时一次写一块块大小 0x10 是标准安全值不要为了减少指令次数改成一次写 0xFF很多 Type 4 Tag 实现不支持跨块连续写。写失败的表现通常是返回 0x6581写入失败此时不要反复重试同一条指令先读取当前块内容对比判断是硬件问题还是地址越界。5. NDEF 读写避坑清单编码、长度与响应状态码5.1 中文文本读出来乱码现象用手机 App 能正常显示中文但自己写的 C# 程序读出来是一串乱码或者只有第一个字正常、后面全是问号。原因文本记录的状态字节里bit 7 表示编码1 表示 UTF-160 表示 UTF-8。很多写卡工具默认写入 UTF-8但部分旧工具会写 UTF-16。另外语言码是双字节如果解析时把语言码长度读错或直接把状态字节当成语言码的一部分文本整体偏移一个字节中文全部错位。解决解析时先读取 payload[0] 的高三位判断编码再取低五位作语言码长度。然后从 payload[1 langLen] 开始解码文本。如果读出来的首字符是空字符或\0立刻检查是不是把编码位当成了语言码的一部分。我后来在解析入口统一打了日志每次解析都输出 payload 前 8 个字节的 HEX肉眼比对数据重装现场效率比盲改快很多。5.2 URI 记录解析出来双前缀现象读出来 URL 是http://https://example.com或者https://http://example.com。原因写卡时构造 payload 没有用 URI Identifier Code 压缩前缀把完整 URL 写进了 body 区域同时又把 idCode 写成了 0x01 或 0x02。读取端会把 idCode 映射成前缀拼在 body 前面于是出现双前缀。0x00 表示不使用任何前缀body 必须包含完整协议头。解决构造 URI 记录时检测到 URL 前缀后要把前缀从 body 里摘除再写入。摘除逻辑要放在 StartsWith 判断之后、实际写入之前并且必须同步把 idCode 改成对应值。建议写完之后立刻调用DecodeUriPayload做一次回读自检字符串比对不一致就抛异常。5.3 写卡成功后手机碰标签毫无反应现象电脑端 C# 程序报告写入成功用 NFC 手机去碰标签手机没有弹出任何内容甚至提示“不支持此标签”。原因写入时只写了 NDEF 报文没有同步更新 NLNDEF Length字段。Type 4 Tag 的数据结构是CC 区 - NL2 字节消息长度- NDEF 报文。部分手机读取时会先读 NL 再按长度读报文如果 NL 还是旧值读到一半就截断或者直接判定数据无效。解决每次写完报文后强制重写 NL 为当前报文长度。这步不能省。代码里我把它放在WriteNdef的末尾用独立 APDU 完成更新。如果写完 NL 后仍然无效检查 CC 文件的写保护字节是否被置为只读个别标签出厂时 NDEF 区写保护是开启的需要用厂商工具先解锁。5.4 NDEF 区容量值与实际可写长度不一致现象标签标称 1KBCC 文件里也写着 1KB但写到约一半时返回写入失败读回内容发现后半段全是 0。原因CC 文件里的容量值是标签可寻址空间不一定是 NDEF 应用可写区。Type 4 Tag 有多个文件文件标识 0x0000 是 CC0x0001 是 NDEF实际可写长度由文件控制信息决定不是 CC 里的总容量。有的厂商标签把 NDEF 文件大小设置为 512 字节尽管 CC 声称有 1024。解决写卡前先读取文件控制信息 TLV 里 NDEF 文件的长度字段按这个值做写入长度上限校验。C# 程序里要有一个GetMaxNdefSize方法专门解析 TLV避免在运行时因为越界写导致响应码异常。5.5 状态码 0x6A82 与 0x6700 的区分现象读取时一切正常写入时返回 0x6A82文件未找到或 0x6700长度错误换一张卡又正常。原因0x6A82 通常是因为 SELECT 指令选错了文件标识比如读取用 0x0001写入时不小心选了 0x0002。0x6700 常见于 UPDATE BINARY 的 Le 字段与块大小不匹配或者 P1/P2 地址加上长度后超出文件边界。解决把 APDU 指令封装成带前后断言的方法统一检查响应码。0x6A82 就要回查 SELECT 流程0x6700 就要检查块大小计算是否溢出。有一个血泪经验ACR122U 在 Windows 上通过 PC/SC 发送 APDU 时响应码是 2 字节如果代码里resp.Length - 2拿不到数据部分问题多半出在SCardTransmit的SendPci协议参数上不是指令本身的问题。6. 用安卓手机验证写卡结果一份 1 分钟的验收流程写卡和解析都完成之后一定要做一次真机验证。C# 工具写了十几个读卡逻辑最后真正暴露问题的往往是 NFC 手机而不是读卡器。我的习惯是在写完标签后马上用自己的安卓手机碰一下不看 C# 程序的返回纯看手机系统表现。具体验证分三步先打开手机的设置里“NFC 标签读取”选项默认不勾选时部分手机会弹“是否读取标签”的确认框这会干扰判断然后用系统自带的 NFC 标签读取功能扫一遍确认文本和 URI 能正常显示最后用微信扫小程序码链接确认跳转正常。这三步对应三类故障系统能读出文本但 C# 程序读不出来说明问题在解析层两端都读不出来问题在写入层文本 URI 都正常只有小程序跳转异常问题在小程序链接本身——注意微信的小程序链接有些带时效性写入标签前需要先确认链接是否已过期。整个验证流程不会超过一分钟但能过滤掉 80% 的写卡翻车现场。如果验证发现 C# 程序读出来的内容和手机读出来的内容不一致不要急着改代码。先把标签内容用 C# dump 成 HEX 字符串再用手机端任意一个 NFC 读取 App 也 dump 一份逐字节对比。两份数据一致说明标签物理写入没问题差异在解析逻辑不一致则问题在写入流程。这种方法比对着代码猜快得多是解决“为何 C# 读和安卓读结果不同”这类玄学问题的捷径。从那以后我每次写完 NDEF 读写工具都会强制在发布前走一遍“写入 - C# 回读 - 安卓真机读 - 字节级对比”的完整链路任何一个环节不一致就不放行。这个习惯救过我很多次尤其是批量生产海报标签的时候第一批写错了能当场拦住不用等到贴上展架才发现。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →