简介:本资源是一套面向C#开发者与NFC应用工程师的NDEF标签读写实战源码,聚焦智能海报、URI跳转、小程序唤起等高频NFC业务场景,解决跨类型NFC标签(如Type2/Type4/Type5、NTAG2x、ISO15693、Mifare Classic)统一读写与NDEF数据封装难题。压缩包共45个文件,含12个核心C#源码文件(如Form1.cs、Program.cs)、5个可执行程序(exe)、4个配置文件(config)、3个说明文本(txt)及若干调试符号(pdb)、资源文件(resx/resources)和项目工程文件(sln/csproj),整体仅1.6MB,轻量易集成。已有235人学习下载,代码结构清晰,主窗体界面完整,配套重要说明文档,开箱即可运行并快速验证文本写入、地图坐标、电话呼叫、WiFi/蓝牙连接、APP启动及小程序跳转等10余种NDEF记录类型,是NFC物联网终端开发与教学实践的实用参考。
1. 智能海报用NFC:先搞清楚这个工具到底解决什么问题
1.1 NFC标签与智能海报的典型应用场景
在展会、景区、橱窗、产品包装上,我们现在见过不少“碰一碰”的场景:手机靠近某个区域,自动弹出一个网页、一段视频,甚至直接跳转到一个微信小程序。这个体验背后就是NFC标签。与二维码相比,NFC标签不需要打开相机对准,不需要在光线差的环境下寻找对焦,只要手机解锁靠近就能触发。这种交互在B端布展、智慧门店、文创周边里越来越常见。
但“贴上标签就能用”只是表象。真正到了落地环节,有几个很现实的问题会冒出来:
- 标签里到底写什么数据格式,手机识别为“文本”还是“网址”还是“智能海报”?
- 怎么做到手机碰一下自动跳转小程序,而不是先弹出一个文本提示再让用户手动复制?
- 如果要做批量生产,几十上百张标签怎么高效写入、怎么校验写对了没有?
- 写进标签的数据是UTF-8编码还是UTF-16?语言码写
en还是zh?为什么有的手机能读出中文,有的就读出乱码?
这些问题,用现成的“NFC Tag Writer”App也能对付,但碰到批量写入、二次开发、与自己的业务系统对接时,还是要写代码。C#在这类场景里是非常顺手的选择——尤其当你的整体系统已经是Windows平台、采用C#上位机框架时,直接扩展一个NFC读写模块最省事。这篇文章就是围绕“C#读写NDEF文本、URI网址、小程序跳转等NDEF智能海报文本源码”这条主线,把原理和代码一次讲透。
1.2 为什么这件事用C#做最顺手
先说结论:C#做NFC读写本身并不复杂,复杂的是PC/SC底层通信和NDEF编码规范这两块。而这两块恰恰都有成熟的库和足够多的参考资料。
NFC读卡器在PC端的通信基本都遵循PC/SC协议,Windows自带winscard.dll,通过SCardEstablishContext、SCardConnect、SCardTransmit这一套API就能和读卡器对话。C#里可以用P/Invoke直接调用这套API,也可以用现成的开源库PCSCSharp封装好,省掉大部分DllImport的重复劳动。如果不想引入第三方库,直接用System.IO和P/Invoke也能写出一套足够稳定的读写工具。
另外,C#在字节处理、编码转换、UI快速搭建方面非常成熟。NDEF的本质就是“按规范组织的二进制字节流”,用byte[]和BinaryWriter操作是最自然的方式,比脚本语言直接、比C++省心。做批量标签读写工具时,WinForms或WPF的列表控件、进度条、日志窗口几天就能搭完。这也是为什么很多NFC终端工具、上位机软件都是C#做的。
1.3 一个完整的读写工具要包含哪些模块
如果以“智能海报文本源码”为参考来规划功能,一个能真正用于生产的工具至少要有这几个模块:
- 读卡器连接模块:枚举PC/SC读卡器、建立连接、断开连接、重连。
- 标签操作模块:发送APDU指令读取标签数据区、写入数据区、锁定标签。
- NDEF编码模块:把文本、URI、智能海报消息编码成字节流。
- NDEF解码模块:把从标签读出的字节流解析成可读的文本、网址、标题等。
- 业务封装模块:比如写入时自动组装“标题 + 网址 + 小程序跳转参数”,读取时自动判断记录类型并展示。
理解了整体结构后,我们进入最核心的部分——NDEF数据格式。这里我必须强调,NDEF不是某种厂商私有协议,而是NFC Forum定义的统一数据交换格式。不管是NTAG213、NTAG216,还是Mifare Ultralight,只要支持NDEF,数据区里放的字节结构都是同一个规范。所以搞懂NDEF格式,就等于搞懂了所有NFC标签的读写基础。
2. NDEF数据格式拆解:读写之前必须先弄懂二进制结构
2.1 NDEF消息与Record的基础结构
NDEF全称是NFC Data Exchange Format。一次交换的内容叫做一个NDEF消息(NDEF Message),一个消息里可以包含一条或多条记录(NDEF Record)。比如“智能海报”这种应用,一个消息里就包含标题文本记录、URI网址记录、Action动作记录等多个Record。
每条NDEF Record的头部结构如下:
| 偏移 | 字段 | 长度 | 说明 |
|---|---|---|---|
| 0 | Flags | 1字节 | 包含MB、ME、CF、SR、IL、TNF共8位 |
| 1 | Type Length | 1字节 | 类型名长度 |
| 2 | Payload Length | 1字节或4字节 | 取决于SR位;SR=1时为1字节,SR=0时为4字节 |
| 3+ | ID Length | 1字节(可选) | 仅当IL=1时存在 |
| 4+ | Type | Type Length字节 | 记录类型,如T表示文本、U表示URI |
| 5+ | ID | ID Length字节(可选) | 记录ID,一般用不到 |
| 6+ | Payload | Payload Length字节 | 具体的记录内容 |
这里有个关键点要提醒各位:第一个字节的Bits排列经常让人犯迷糊。它从高位到低位依次是:
- bit 7:MB(Message Begin),1表示这是消息的第一条记录
- bit 6:ME(Message End),1表示这是消息的最后一条记录
- bit 5:CF(Chained Flag),分块标志,一般不用
- bit 4:SR(Short Record),1表示Payload Length只用1字节表示
- bit 3:IL(ID Length present),1表示后面有ID Length字段
- bit 2~0:TNF(Type Name Format),表示Type字段的类型格式
TNF的值决定了Type字段怎么理解。常见的几个值:
| TNF值 | 含义 | Type字段内容 |
|---|---|---|
| 0x00 | Empty | 空记录 |
| 0x01 | NFC Forum well-known type | T、U、Sp、act等RTD类型 |
| 0x02 | Media-type | 如text/plain、application/json |
| 0x03 | Absolute URI | 完整URI,不以缩写方式存储 |
| 0x04 | External type | 厂商自定义类型,格式类似example.com:type |
| 0x05 | Unknown | 未知类型 |
2.2 文本记录(RTD Text)的字节级编码
文本记录是NDEF里最基础的记录类型,TNF=0x01,Type字段的值是字母T,也就是ASCII码0x54。
它的Payload结构是三段:
- 状态字节(Status Byte):1字节。
- bit 7表示编码格式,0=UTF-8,1=UTF-16。
- bit 6必须为0。
- bit 5~0表示语言码长度(0~63)。
- 语言码(Language Code):长度由状态字节的低6位决定,内容是ISO 639-1标准语言码,如
en、zh。 - 实际文本内容:按前面指定的编码方式存储的文本字节。
举个例子,要存储中文文本“你好”,语言码用zh,状态字节应该是:bit7=0(UTF-8),bit6=0,低6位=2(zh长度),所以状态字节为0x02。整条NDEF Record如下:
- Flags = 0xD1(MB=1,ME=1,SR=1,TNF=0x01)
- Type Length = 0x01
- Payload Length = 0x09(1字节状态 + 2字节语言码 + 6字节“你好”的UTF-8)
- Type = 0x54(即
T) - Payload = 0x02 'z' 'h' 0xE4 0xBD 0xA0 0xE5 0xA5 0xBD
这就是一条完整可被手机识别的NDEF文本记录。用十六进制表示就是:
D1 01 09 54 02 7A 68 E4 BD A0 E5 A5 BD这个结构很简单,但是很多初学者会漏掉语言码。如果不写语言码,或者语言码长度和实际内容对不上,部分Android手机会直接忽略这条记录。
2.3 URI记录(RTD URI)与URI标识码
URI记录的TNF同样是0x01,Type字段是字母U(ASCII 0x55)。它的Payload分为两段:
- URI标识码(URI Identifier Code):1字节,表示后面URI的前缀缩写。
- URI内容:去掉前缀之后的URI字符串。
URI标识码是NFC Forum定义好的。实际开发中常用的几个:
| 标识码 | 对应前缀 |
|---|---|
| 0x00 | 无前缀,URI原样保存 |
| 0x01 | http://www. |
| 0x02 | https://www. |
| 0x03 | http:// |
| 0x04 | https:// |
| 0x05 | tel: |
| 0x06 | mailto: |
| 0x0B | sms: |
| 0x0C | smsto: |
如果你想写入https://www.example.com/page,用0x02,URI内容部分就只需要存example.com/page,能省下不少字节。如果一个标签容量只有137字节,这个省法还是很可观的。
整条URI记录的十六进制类似这样:
D1 01 0C 55 02 65 78 61 6D 70 6C 65 2E 63 6F 6D 2F 70 61 67 65Flags=0xD1,Type Length=0x01,Payload Length=0x0C(1字节标识码 + 11字节example.com/page),Type=0x55,Payload以0x02开头。
2.4 智能海报Sp记录的嵌套结构
智能海报(Smart Poster)是NFC Forum定义的另一种RTD类型,Type字段是Sp,也就是ASCII序列0x53 0x70。它的Payload不再是一段简单的文本,而是一个完整的NDEF消息,里面至少包含一条URI记录,还可以包含标题文本记录、Action记录等。
嵌套结构示意:
- Smart Poster Record(外层)
- 内部NDEF消息
- URI Record(必须,指定海报指向的网址)
- Title Record(可选,文本标题,如“扫码了解更多”)
- Action Record(可选,推荐动作,如打开链接)
- 内部NDEF消息
手机上检测到一条Sp记录时,会把内部的URI记录取出来执行打开操作,同时把Title记录用于界面展示。这样做的好处是:智能海报既能在系统层面被识别为“一个可打开的链接”,又能附带文字标题,比单独存一条URI记录信息更丰富。
所以,一个完整的“C#读写NDEF智能海报文本源码”方案里,最核心的工作量实际上有两个:一是按规范正确编码T、U、Sp这几种记录,二是把这些记录组合成单条或嵌套的NDEF消息,再写入标签。接下来我把读取和写入两端的C#代码都展开。
3. C#读取NDEF文本:从PC/SC通信到字段解析
3.1 连接读卡器与激活NFC标签
先说读卡器的选型。做开发调试,我推荐用ACR122U或者兼容CCID协议的读卡器,这类读卡器在Windows下会被识别为标准PC/SC设备,免驱或自动安装驱动,C#可以直接对接。成本也不高,一两百元的设备性能足够用了。
C#侧最简单的封装是用PCSCSharp,NuGet里直接搜PCSC,安装PCSCSharp包即可。如果不想用第三方包,也可以用DllImport引入winscard.dll的四个核心方法:SCardEstablishContext、SCardListReaders、SCardConnect、SCardTransmit。下面代码先以PCSCSharp为例,代码更短,更容易看懂:
using PCSC; using PCSC.Iso7816; public class NfcReaderService { private readonly ISCardContext _context; public NfcReaderService() { // 建立PC/SC上下文 _context = ContextFactory.Instance.Establish(SCardScope.System); } public List<string> ListReaders() { var readerNames = _context.GetReaders(); return readerNames?.ToList() ?? new List<string>(); } public byte[] ReadNdefBytes(string readerName, int blockStart, int blockCount) { using var reader = _context.ConnectReader(readerName, SCardShare.Shared, SCardProtocol.Any); // 激活标签:对于ISO 14443-A类型标签,通常用GetData命令获取UID后即激活 var apdu = new CommandApdu(IsoCase.Case2Short, reader.ActiveProtocol) { CLA = 0xFF, Instruction = InstructionCode.GetData, P1 = 0x00, P2 = 0x00, Le = 0x00 }; var response = reader.Transmit(apdu.ToArray()); if (response.Length >= 2 && response[^2] == 0x90 && response[^1] == 0x00) { var uid = response.Take(response.Length - 2).ToArray(); // 拿到UID之后,再循环读取块数据 } var blocks = new List<byte>(); for (int i = 0; i < blockCount; i++) { var readApdu = BuildReadBlockApdu(blockStart + i); var readResp = reader.Transmit(readApdu); // 每个块通常是4字节,响应中除了状态码外就是块内容 if (readResp.Length >= 6) { blocks.AddRange(readResp.Skip(2).Take(4)); } } return blocks.ToArray(); } }这里有几个实操细节要注意:NDEF数据在标签里是以“块”为单位存储的。NTAG213的数据区从第4块开始,每个块4字节,NDEF数据通常从第4块开始,但实际偏移可能因标签厂商和存储布局略有不同。更稳妥的做法是读取完整数据区后,搜索NDEF消息的起始位置,或者直接读取Capability Container(CC区)来获取NDEF区域的起始地址和容量。
3.2 读取标签原始数据区的实现
对NTAG系列标签来说,Capability Container(CC区)通常在数据区前固定位置,它描述了NDEF消息的起始地址和可用大小。NTAG213的CC区在第3块。CC区前3个字节一般是E1 10开头,后面的字节表示NDEF Maximum Data Size等信息。读取逻辑可以这样写:
public NdefReadResult ReadNdefMessage(string readerName) { using var reader = _context.ConnectReader(readerName, SCardShare.Shared, SCardProtocol.Any); // 读取CC区,假设CC区在第3块 byte[] ccData = ReadBlock(reader, 3); if (ccData[0] != 0xE1 || ccData[1] != 0x10) { throw new InvalidOperationException("读到的不是NTAG标签,或当前标签数据区已损坏。"); } // 根据CC区解析NDEF最大长度,NTAG213通常为137字节 int ndefCapacity = ccData[2]; // 单位是字节 int ndefStartBlock = 4; // NTAG数据区起始块 // 依次读取数据区所有块 byte[] ndefArea = new byte[ndefCapacity]; int offset = 0; int blocksToRead = (ndefCapacity + 3) / 4; for (int i = 0; i < blocksToRead; i++) { byte[] blockContent = ReadBlock(reader, ndefStartBlock + i); Array.Copy(blockContent, 0, ndefArea, offset, blockContent.Length); offset += blockContent.Length; } return ParseNdefBytes(ndefArea); }这里需要说明的是,NDEF数据区不是整块都有内容,标签出厂时未写入的区域通常是0x00或0xFF,空标签的CC区会明确写出NDEF消息长度。所以解析时一定要以“NDEF消息长度”为准来截取有效字节,而不是把整个数据区全解析一遍。
3.3 解析NDEF消息并提取文本内容
有了字节流,下一步就是按照第2节的结构解析。我建议把解析器写成一个独立的静态类,方便复用。下面是一个能处理单条文本记录和URI记录的解析函数:
public static List<NdefRecord> Parse(byte[] messageBytes) { var records = new List<NdefRecord>(); int index = 0; while (index < messageBytes.Length) { byte flags = messageBytes[index]; byte tnf = (byte)(flags & 0x07); bool isShortRecord = (flags & 0x10) != 0; bool hasId = (flags & 0x08) != 0; int typeLength = messageBytes[index + 1]; int payloadLength; int idLength = 0; if (isShortRecord) { payloadLength = messageBytes[index + 2]; index += 3; } else { payloadLength = BitConverter.ToInt32(new byte[] { messageBytes[index + 2], messageBytes[index + 3], messageBytes[index + 4], messageBytes[index + 5] }, 0); index += 6; } if (hasId) { idLength = messageBytes[index]; index += 1; } byte[] typeBytes = messageBytes.Skip(index).Take(typeLength).ToArray(); index += typeLength; if (hasId) { byte[] idBytes = messageBytes.Skip(index).Take(idLength).ToArray(); index += idLength; } byte[] payload = messageBytes.Skip(index).Take(payloadLength).ToArray(); index += payloadLength; string recordType = System.Text.Encoding.ASCII.GetString(typeBytes); records.Add(new NdefRecord(tnf, recordType, payload)); // 单条记录且ME=1时结束 if ((flags & 0x40) != 0) { break; } } return records; }解析到记录后,根据类型分别处理:
public static string DecodeTextPayload(byte[] payload) { if (payload == null || payload.Length == 0) return string.Empty; byte status = payload[0]; bool isUtf16 = (status & 0x80) != 0; int langLength = status & 0x3F; // 语言码之后的字节才是文本内容 int textStart = 1 + langLength; byte[] textBytes = payload.Skip(textStart).ToArray(); return isUtf16 ? System.Text.Encoding.Unicode.GetString(textBytes) : System.Text.Encoding.UTF8.GetString(textBytes); } public static string DecodeUriPayload(byte[] payload) { if (payload == null || payload.Length == 0) return string.Empty; byte prefixCode = payload[0]; string prefix = UriPrefixTable.ContainsKey(prefixCode) ? UriPrefixTable[prefixCode] : string.Empty; string rest = System.Text.Encoding.UTF8.GetString(payload.Skip(1).ToArray()); return prefix + rest; }这里要注意的是,payload.Skip(1)的前提是URI内容用UTF-8编码。实际中URI内容通常就是ASCII字符,UTF-8兼容ASCII,所以这样处理没问题。如果你遇到特殊的非ASCII域名(比如中文域名),记得统一用UTF-8处理,否则会乱码。
3.4 解析结果的可视化与容错处理
读到的记录肯定要显示到界面上。我的做法是定义一个视图模型:
public class NdefReadResult { public string TagType { get; set; } // 例如 NTAG213 public string Uid { get; set; } public string MessageType { get; set; } // Text / Uri / SmartPoster public string TextContent { get; set; } public string UriContent { get; set; } public List<string> AllRecords { get; set; } = new List<string>(); }解析完成后,把消息里的每条记录都转成可读文本。如果消息里同时包含T记录和U记录,说明这是一条智能海报消息,可以在界面上同时展示标题和链接。如果只有一条T记录,就直接显示文本内容。多出一个信息往往意味着格式有问题,或者标签被其他工具写入过。
容错方面有三个常见情况:一是数据区全为0x00或全是0xFF,说明标签从未写过NDEF内容;二是解析时遇到格式错误,比如类型长度超过剩余字节数,应该抛出明确异常而不是堆栈崩溃;三是读到空记录(TNF=0x00),直接跳过。这些在代码里都可以用简单的判断解决。
4. C#写入NDEF:文本、URI、小程序跳转的编码实现
4.1 构造文本记录的代码实现
写入的核心有两步:先把数据编码成NDEF字节流,再通过读卡器把字节流写到标签数据区。编码这部分写好了,写入就是“复制字节”的机械操作。
文本记录编码函数:
public static byte[] EncodeTextRecord(string text, string langCode = "zh") { byte[] langBytes = System.Text.Encoding.ASCII.GetBytes(langCode); byte[] textBytes = System.Text.Encoding.UTF8.GetBytes(text); List<byte> payload = new List<byte>(); byte status = (byte)(0x00 | (langBytes.Length & 0x3F)); payload.Add(status); payload.AddRange(langBytes); payload.AddRange(textBytes); List<byte> record = new List<byte>(); byte flags = 0xD1; // MB=1, ME=1, SR=1, TNF=0x01 record.Add(flags); record.Add(0x01); // Type Length record.Add((byte)payload.Count); // Payload Length record.Add(0x54); // 'T' record.AddRange(payload); return record.ToArray(); }需要注意几个点:
- 这里的Flags是0xD1,表示单条记录同时是消息的开始和结束。如果消息里有多条记录,第一条的Flags要保留MB位、去掉ME位,中间记录MB和ME都置0,最后一条保留ME位、去掉MB位。
- Payload Length不能超过255字节,因为SR=1时只有1字节长度。如果文本很长,要么关闭SR位用4字节长度,要么拆分成多个记录。但标签容量有限,实际中一般碰不到那么长的文本。
- 语言码长度最多63字节,正常写
zh、en就够了。
4.2 构造URI记录并写入网址
URI记录编码函数:
public static byte[] EncodeUriRecord(string fullUri) { byte prefixCode = 0x00; string remainUri = fullUri; // 查找最合适的URI前缀 foreach (var pair in UriPrefixTable.OrderByDescending(kv => kv.Value.Length)) { if (fullUri.StartsWith(pair.Value, StringComparison.OrdinalIgnoreCase)) { prefixCode = pair.Key; remainUri = fullUri.Substring(pair.Value.Length); break; } } byte[] uriBytes = System.Text.Encoding.UTF8.GetBytes(remainUri); List<byte> payload = new List<byte>(); payload.Add(prefixCode); payload.AddRange(uriBytes); List<byte> record = new List<byte>(); byte flags = 0xD1; record.Add(flags); record.Add(0x01); // Type Length record.Add((byte)payload.Count); record.Add(0x55); // 'U' record.AddRange(payload); return record.ToArray(); }这里有个性能小技巧:OrderByDescending每次调用都会重新排序。如果写入频繁,建议把前缀表做成静态只读数组,按长度从长到短排列,避免重复排序。前缀表本身放在一个字典里:
public static readonly Dictionary<byte, string> UriPrefixTable = new Dictionary<byte, string> { { 0x00, "" }, { 0x01, "http://www." }, { 0x02, "https://www." }, { 0x03, "http://" }, { 0x04, "https://" }, { 0x05, "tel:" }, { 0x06, "mailto:" }, { 0x0B, "sms:" }, { 0x0C, "smsto:" } };关于写入网址时是否用前缀压缩,我的建议是:能压缩就压缩。https://www.比https://多了4个字节,对NTAG213这种只有137字节NDEF空间的标签来说,每条记录省几十字节,意味着可以多塞一条标题记录。但压缩也要注意:如果完整域名是https://www.example.com,用0x02后保存的内容是example.com/...,手机解析时自动拼成https://www.example.com/...,没问题。如果是https://wwww.example.com这种域名就千万别用0x02,因为http://www.和https://www.只匹配固定的www.,拼出来反而会错。
4.3 小程序跳转的数据方案:URL Link、URL Scheme与落地页中转
NDEF标签本身并不知道什么是“小程序”,它只认识URI记录。所以“小程序跳转”这个需求,本质上是要在URI记录里写入一个“能触发小程序跳转的网址”。
微信生态目前有几种可行方案:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| URL Link | 微信后台生成的加密链接,打开后直接拉起小程序 | 官方支持,体验好 | 链接有有效期(默认30天,可配置最长1年),需要小程序认证,有生成配额限制 |
| URL Scheme | 微信后台生成的scheme,用于App拉起小程序 | 适合App调用,不直接适合NFC | 新版URL Scheme同样有有效期,且只支持Android端打开 |
| 落地页中转 | 写一个普通HTTPS网址,页面根据浏览器UA判断,跳转小程序或下载页 | 链接永久有效,兼容度高,内容可更新 | 需要自建页面,有一步中转 |
对“智能海报”这种线下物料来说,标签一旦贴出去,最好就不要再频繁重写。所以我个人最推荐的是落地页中转方案:把标签里的URI写成自己的业务域名下的落地页URL。比如https://yourdomain.com/p/12345,用户手机碰一碰打开这个链接,落地页脚本检测到微信环境时,引导用户点击打开小程序或者直接跳转URL Link;在非微信环境时,显示网页版介绍。这样你随时可以调整落地页里跳转的小程序参数,而不需要重新贴标签。
如果你一定要在NDEF里直接写死URL Link,要注意两个问题:一是URL Link生成时选的“有效期类型”决定了链接何时失效,普通类型是30天,长期类型是365天,对长期摆放的物料来说需要提前规划;二是URL Link生成接口通常有每日量级限制,大批量标签生成前务必先确认配额。
public static byte[] EncodeWechatMiniProgramUri(string urlLinkOrLandingUrl) { return EncodeUriRecord(urlLinkOrLandingUrl); }从NDEF编码的角度来说,小程序跳转和普通网址没有区别,只是一个长链接而已。真正的技术含量在业务侧:链接的有效期、配额、安全域名校验。写代码时把URL做成可配置项,不要硬编码在源码里。
4.4 组合成智能海报并写回标签的完整流程
如果要写入的是一条完整智能海报消息(标题 + 网址),需要把两个Record放在同一个NDEF Message里。第一条T记录和第二条U记录要调整Flags:
- 第一条
T记录:MB=1,ME=0,SR=1,TNF=0x01,所以Flags = 0x91 - 第二条
U记录:MB=0,ME=1,SR=1,TNF=0x01,所以Flags = 0x51
编码函数:
public static byte[] EncodeSmartPoster(string title, string uri, string langCode = "zh") { List<byte> message = new List<byte>(); // Title记录 byte[] titleRecord = EncodeTextRecordCore(title, langCode, 0x91); message.AddRange(titleRecord); // URI记录 byte[] uriRecord = EncodeUriRecordCore(uri, 0x51); message.AddRange(uriRecord); return message.ToArray(); } private static byte[] EncodeTextRecordCore(string text, string langCode, byte flags) { byte[] langBytes = System.Text.Encoding.ASCII.GetBytes(langCode); byte[] textBytes = System.Text.Encoding.UTF8.GetBytes(text); List<byte> payload = new List<byte>(); payload.Add((byte)(langBytes.Length & 0x3F)); payload.AddRange(langBytes); payload.AddRange(textBytes); List<byte> record = new List<byte>(); record.Add(flags); record.Add(0x01); record.Add((byte)payload.Count); record.Add(0x54); record.AddRange(payload); return record.ToArray(); } private static byte[] EncodeUriRecordCore(string fullUri, byte flags) { // 逻辑同EncodeUriRecord,但flags由外部传入 byte prefixCode = 0x00; string remainUri = fullUri; foreach (var pair in UriPrefixTable) { if (fullUri.StartsWith(pair.Value, StringComparison.OrdinalIgnoreCase)) { prefixCode = pair.Key; remainUri = fullUri.Substring(pair.Value.Length); break; } } byte[] uriBytes = System.Text.Encoding.UTF8.GetBytes(remainUri); List<byte> payload = new List<byte>(); payload.Add(prefixCode); payload.AddRange(uriBytes); List<byte> record = new List<byte>(); record.Add(flags); record.Add(0x01); record.Add((byte)payload.Count); record.Add(0x55); record.AddRange(payload); return record.ToArray(); }写回标签的APDU命令,对不同标签略有差异。以NTAG213为例,通常是先写CC区,再写NDEF数据区。如果CC区已经正确,只需要更新NDEF数据区。写入时每块4字节,最后一组不足4字节要用0x00补齐。核心代码如下:
private void WriteNdefToTag(ICardReader reader, byte[] ndefMessage, int startBlock) { int capacity = 137; // NTAG213的NDEF容量 if (ndefMessage.Length > capacity) throw new InvalidOperationException("NDEF数据超过标签容量,请压缩内容或换用NTAG215/216。"); // NDEF消息长度占前2字节,按NFC Forum规范写入 byte[] data = new byte[capacity]; data[0] = (byte)(ndefMessage.Length >> 8); data[1] = (byte)(ndefMessage.Length & 0xFF); Array.Copy(ndefMessage, 0, data, 2, ndefMessage.Length); int blockCount = (capacity + 3) / 4; for (int i = 0; i < blockCount; i++) { byte[] block = new byte[4]; Array.Copy(data, i * 4, block, 0, Math.Min(4, capacity - i * 4)); WriteBlock(reader, startBlock + i, block); } }这里写入的NDEF长度是2字节大端序,这是NFC Forum定义的TLV结构里的T=0x03(NDEF消息)规定的。正确写入后,手机靠近标签就能识别出“智能海报”消息。
5. 批量读写的工程化细节与踩坑记录
5.1 不同型号标签的容量与数据区规划
做批量工具前,先确认你手里标签的具体型号。贴在产品包装上的标签,常见的有三种:
| 标签型号 | NDEF可用空间 | 典型块数 | 适用场景 |
|---|---|---|---|
| NTAG213 | 137字节 | 45块 | 名片、简单网址跳转 |
| NTAG215 | 486字节 | 135块 | 智能海报、信息较多 |
| NTAG216 | 852字节 | 231块 | 大容量电子价签、多媒体信息 |
我踩过第一次批量写入的坑:厂家发过来的标签是NTAG213,我却按NTAG215的容量去写数据。结果写入时表面成功,但手机怎么都读不出来。后来逐个块打印数据才发现,后大半部分根本没写进去。所以写代码时第一件事,是读CC区确认实际容量,再决定数据怎么组装。不要假设标签型号,只要硬件支持,一律以CC区读取结果为准。
5.2 编码格式、长度字段和字节序的坑
NDEF的坑主要集中在这几个地方:
- 状态字节的低6位是语言码长度,不是语言码本身。很多人直接把
0x04当成UTF-8的标志,结果语言码长度为4,解析时把后面的文本字节吞掉了。正确写法是0x00 | langCode.Length。 - 多记录消息的Flags要分别设置。如果你把所有记录都写成0xD1,手机只会解析第一条记录,后面的全部丢失。这是编码逻辑里最常见的错误。
- Payload Length不能多算也不能少算。尤其是URI记录,很多人忘记算前缀标识码那一字节,或者把完整URI字节数当成Payload Length,导致记录边界错位。建议写一个十六进制dump工具,每写一条记录都把整个NDEF字节流打出来检查一遍。
- NDEF消息长度字段是大端序。写入标签数据区时,
data[0]是长度的最高字节。对小端习惯的程序员来说,这个顺序写错就会导致手机无法解析。
分享一个调试技巧:在写入前,把待写入的byte[]用HexDump打印到日志窗口;写入后再读取一遍,对比两者是否一致。我见过不少“写入失败”其实是日志窗口显示乱码造成的误判。十六进制对比是最可靠的。
5.3 手机读取兼容性为什么有差异
同一个标签写同样内容,在不同手机上表现可能完全不一样。iPhone和大多数Android手机对NDEF的支持都比较完善,但差异确实存在:
- iPhone的NFC读取对标签格式要求更严格。如果你写入了格式不规范的NDEF消息,iPhone可能直接弹“不支持的标签”,而不是像部分Android手机那样显示原始内容。所以做智能海报类的公开物料,一定要以iPhone的兼容性为验收标准。
- Android手机对URI前缀的解析存在差异。某些国产ROM对
tel:、sms:这类特殊URI前缀处理得很奇怪,有时会直接忽略。如果海报的主要目标是唤起电话,最好用落地页中转而不是直接写tel:。 - 有些手机需要屏幕解锁才能识别NFC标签,有些手机在息屏状态下也能识别。这个属于系统设置层面的差异,跟NDEF编码无关,但做现场演示前最好提前测试,避免尴尬。
还有一点很有意思:同一张标签被多次写入后,部分旧数据可能残留在末尾。手机解析时会先识别NDEF消息长度字段,长度字段正常的情况下,后面的残留数据不会影响。但如果写入时长度字段写错,手机就可能把残留数据也当作记录的一部分去解析,产生“读出来是乱码”的假象。所以再次写入前,最好先将数据区清零,或者确认写入内容完全覆盖了旧数据区域。
5.4 批量写入时的稳定性优化
批量写几十上百张标签时,稳定性是大问题。我最早写批量工具时,每张标签都手动插卡、手动确认,效率低而且容易错。后来优化成“脚踩的方式”并不现实,最靠谱的办法是流程化设计:
- 线程池并发写入:PC/SC读卡器本身是串行设备,一张卡同时只能响应一个命令。我用的是“主线程控制队列 + 后台线程处理单卡”的方式,一张一张来,但操作员可以一边贴卡一边走。
- 写入校验:每写一张标签,立即读回数据区,和原始字节流做比对。比对通过才在界面上显示“成功”,否则标记为“失败”并重试。
- 超时重试:读卡器偶尔会卡住,APDU指令超时后要自动重发。对ACR122U来说,如果连续3次超时,基本可以认定是硬件层面的接触问题,需要人工干预。
- 记录日志:把每条标签的UID、写入时间、写入数据Hash、校验结果记录到本地日志文件或数据库。出问题时能回溯是哪一批的哪张卡出了问题。
我实际做的一个检查流程是这样:
public bool WriteAndVerify(byte[] ndefBytes, string readerName, int startBlock = 4) { // 写入 WriteNdefToTag(ndefBytes, readerName, startBlock); // 回读 byte[] readBack = ReadNdefRawBytes(readerName, startBlock, ndefBytes.Length + 2); // 写入镜像:前2字节长度+数据 byte[] expected = new byte[ndefBytes.Length + 2]; expected[0] = (byte)(ndefBytes.Length >> 8); expected[1] = (byte)(ndefBytes.Length & 0xFF); Array.Copy(ndefBytes, 0, expected, 2, ndefBytes.Length); return StructuralComparisons.StructuralEqualityComparer.Equals(expected, readBack); }每条NFC标签的UID是唯一的,这个UID不只是用来展示,还是后续业务系统里做“标签-内容-设备”关联的天然主键。你可以在写入数据时,顺便把UID和写入内容记录到数据库里,这样以后做溯源、做数据统计都方便。这算是做NFC项目比较隐形但很实用的一个点。
从工程化角度看,整个智能海报NFC方案落地到最后,最耗时间的往往不是编码本身,而是不同标签、不同读卡器、不同手机之间的兼容性适配。C#在这个生态里的优势是可以快速做出一套可视化的诊断工具,把字节流、解析结果、错误日志都展示在界面上,遇到现场问题能快速定位是硬件、编码还是手机端的问题。这套工具的骨架,就是上面这些代码组合起来的。
本文还有配套的精品资源,点击获取