简介:面向具备基础C Sharp能力的开发者,资源内容讲解如何借助LibNFC开源库实现近场通信(NFC)操作,适用于标签读写与数据交换等典型场景,同时可学会P/Invoke跨平台调用C库的方法,为接入其他硬件提供借鉴。压缩包共172个文件、2.22MB,核心为32个C Sharp源码、34个动态链接库以及解决方案与工程文件,另含调试符号、说明文档和配置信息,可在Visual Studio中直接打开调试。已有1303人学习下载。内容覆盖平台调用互操作声明、设备发现与初始化打开、数据收发、NDEF消息结构解析,以及异步事件处理和资源释放,并给出了安装配置和测试调试建议,遇到常见错误时可按提示排查。解决方案集成托管封装层、Crapto1Sharp加密算法和NfcDotNet示例工程,既适合刚入门的开发者按图索骥,也为进阶者理解非接触式通信底层机制、开展二次封装与高级应用提供了现成基础。 有阵子接了个上位机的需求,要在电脑上插一个USB NFC读卡器,识别员工卡、门禁卡,把卡号实时显示在WPF界面上。技术栈本来是C#,但一搜libNFC,官方示例全是C语言,能直接用的C#封装大多年久失修。硬着头皮折腾了一轮,踩完坑之后把能用得上的东西整理出来。本文适合有C#基础、第一次接NFC读卡器、需要在Windows桌面应用里读卡号或标签数据的开发者;如果你只是想用手机App调NFC,那这篇帮不上忙。
libNFC本身是个底层开源库,用C语言实现,负责和NFC读卡器设备打交道,支持USB、串口、I2C、SPI多种接入方式。C#里虽然没有官方绑定,但Windows下可以靠P/Invoke直接把libNFC的动态库拿来调。文章会从环境准备讲起,到完整读出一张ISO 14443-A卡的UID,再用底层命令与NTAG标签交互,最后把几个容易踩的坑单独列出来。
1. 为什么在C#里接NFC要用libNFC:几条路线对比后的选择
1.1 三条技术路线,各自的适用场景
接触这个需求之前,我一度以为有Windows原生API就够了,实际上差别很大。这里把桌面端常见的三条路线放在一起对比,方便你按场景选:
| 路线 | 底层能力 | 适用场景 | 主要限制 |
|---|---|---|---|
| Windows.Devices.NearFieldProximity | 只封装了NDEF和Proximity相关功能 | UWP、Win10/11自带NFC的简单标签读写 | 处理不了MIFARE Classic这类非NDEF卡片,底层命令也基本碰不到 |
| PC/SC(WinSCard.dll) | 走智能卡标准接口,很多读卡器原生支持 | 接触式IC卡、部分非接触式智能卡 | 接口抽象太“智能卡化”,直接发NFC交换命令反而绕 |
| libNFC | 直接控制读卡器芯片,能自己组APDU/交换命令 | 各种非接触卡、标签、读卡器兼容层 | 需要自己处理DLL引用,官方主要面向C语言 |
如果你的项目只是“手机贴一下,读一个URL文本”,Windows自带API确实够用。但门禁、仓储、工位打卡这类场景,卡片通常是MIFARE Classic、MIFARE Ultralight或者NTAG系列,只靠NDEF接口远远不够。libNFC能让你直接看到场上的卡片、主动发起寻卡、发送底层字节命令,这种“设备级”控制能力是Windows自带API给不了的。
1.2 用NuGet封装库,还是自己写P/Invoke
网上搜C# libNFC,会看到几个NuGet包,比如LibNfcSharp、NfcLib之类。我一开始也图省事引了一个,结果发现几类问题:一是多年不更新,新版本libNFC的API签名对不上;二是很多包把结构体封装得很死,想传自定义调制参数或目标结构体时反而束手束脚;三是出了问题没法自己排查,因为中间隔着别人的封装。
后来干脆自己写P/Invoke封装。好处很明显:底层结构体是自己定义的,每个API的参数含义都清楚,出问题能直接对着libNFC的C头文件核对。代价也不大,实际核心接口就那么十来个,一次封装好,后面项目都能复用。如果你后面要换读卡器品牌,比如从ACR122U换到SCL3711,只要驱动路径对上,C#代码基本不用动。
2. 环境准备:驱动、DLL与nfc-list验证这一步别跳过
2.1 读卡器差异:ACR122U、SCL3711、PN532
libNFC号称跨平台、跨设备,但不同读卡器在Windows下的驱动逻辑差很多。我手里有三类常见设备,整理成一张表方便对照:
| 读卡器 | 接入方式 | Windows驱动情况 | 注意事项 |
|---|---|---|---|
| ACR122U | USB | 官方驱动默认可能是厂商私有HID模式 | 需要改成CCID模式,否则libNFC在PC/SC层找不到它 |
| SCL3711 | USB | 标准CCID驱动 | 基本即插即用,兼容性最好 |
| PN532模块 | 串口/I2C/SPI | 串口模块需要装USB转串口驱动 | 先确认固件工作在UART模式,libNFC这边用pn532_uart接入 |
ACR122U是最容易出问题的。它出厂时既能走HID私有协议,也能走CCID标准协议,厂商工具一装可能就把驱动切成私有模式,导致libNFC扫不到设备。处理方法是在设备管理器里找到读卡器,右键更新驱动,手动选择“USB CCID”相关驱动,或者卸载厂商工具后重新安装标准驱动。SCL3711就省心很多,标准CCID驱动一接就认。PN532模块常用于嵌入式原型验证,走串口时还要注意电源稳定性,供电不足会导致寻卡时灵时不灵。
2.2 拿到libnfc.dll并跑通nfc-list
很多教程会让你从源码自己编译libNFC,我建议Windows下直接下载官方发布包,里面有编译好的DLL。搜索“libnfc windows release”就能找到,一般包含libnfc.dll、nfc-list.exe、nfc-poll.exe这些工具,以及依赖的libusb等运行库。
拿到后先把bin目录加到系统PATH,打开命令行跑一句:
nfc-list.exe如果读卡器正常,会输出类似这样:
NFC device: ACS / ACR122U PICC Interface opened 1 ISO14443A passive target(s) found: ATQA (SENS_RES): 00 04 UID (NFCID1): 3a 12 34 56 SAK (SEL_RES): 08如果这一步都不过,先别急着写C#代码,问题多半出在驱动或设备占用上。值得提醒的是,官方Windows下的DLL大部分是32位编译的,这直接影响后续Visual Studio工程的目标平台怎么设。我习惯直接把C#工程目标平台设成x86,后面P/Invoke调用时就不会出现位数不匹配导致的DllNotFoundException。
3. P/Invoke封装从哪开始:设备枚举与初始化状态机
3.1 libNFC的调用顺序,就是一个状态机
libNFC的调用流程比想象中固定,官方示例看多了会发现永远是这一套顺序:
nfc_init -> nfc_open -> nfc_initiator_init -> 选卡/收发数据 -> nfc_close -> nfc_exit可以用一个生活化类比来理解:nfc_init是初始化整个运行环境,相当于给库“通电”;nfc_open是打开具体某个读卡器设备,相当于从插线板上拿起一个充电器;nfc_initiator_init则是把设备设置为“主动读写器”模式,告诉读卡器芯片“你现在是主机,要去搜索卡片”,这一步漏掉的话后面所有寻卡命令都会失败。
这个状态机还意味着:设备句柄不要随便跨函数到处传,最好封装成一个类,Open时完成前两步,属性里保留设备指针,Close时统一做收尾。真实的陷阱是很多人只调了nfc_open,没有调nfc_initiator_init,然后命令返回一个莫名其妙的状态码,排查半天。
3.2 最小DllImport声明与设备枚举代码
下面的代码是一个最小可用封装,只覆盖设备初始化和枚举:
using System; using System.Runtime.InteropServices; using System.Text; public static class LibNfcNative { private const string DllName = "libnfc.dll"; [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] public static extern void nfc_init(out IntPtr context); [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] public static extern void nfc_exit(IntPtr context); [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr nfc_open(IntPtr context, string connstring); [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] public static extern void nfc_close(IntPtr device); [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] public static extern int nfc_initiator_init(IntPtr device); }然后枚举设备:
IntPtr context; nfc_init(out context); if (context == IntPtr.Zero) { Console.WriteLine("libNFC初始化失败"); return; } IntPtr device = nfc_open(context, null); // null表示自动选择第一个识别到的设备 if (device == IntPtr.Zero) { Console.WriteLine("没有找到读卡器设备"); nfc_exit(context); return; } int ret = nfc_initiator_init(device); Console.WriteLine(ret >= 0 ? "设备已初始化为主动模式" : $"初始化失败,返回码 {ret}"); // 用完记得释放 nfc_close(device); nfc_exit(context);注意nfc_open的第二个参数,传null通常是自动打开第一个设备,如果想精确指定某个读卡器,可以从设备连接字符串里拿,libNFC的设备连接字符串类似usb:072f:2200。实际项目里如果电脑上插了多个读卡器,就一定要显式指定,否则很容易打开错设备。
4. 读卡实战:轮询获取ISO 14443-A卡的UID
4.1 选卡API:select_passive_target与poll_target
对着一张卡读UID,有两个API都可以用,但语义不同:
nfc_initiator_select_passive_target:一次性主动选卡,场上有一张卡就返回,适合“放一张卡读一次”的交互。nfc_initiator_poll_target:反复轮询寻卡,适合门禁闸机这种“卡随时可能放上来”的持续监控场景。
两个API都需要一个NfcModulation结构体,它告诉读卡器要按什么协议去找卡。我用的是ISO 14443-A,也就是日常最常见的MIFARE、NTAG这类卡走的协议:
[StructLayout(LayoutKind.Sequential)] public struct NfcModulation { public byte nmt; // 调制类型,0表示ISO14443A public byte nbr; // 波特率,0表示106kbps }对应libNFC的宏,NMT_ISO14443A就是0,NBR_106也是0。很多C#例子把这两个字段写错位,导致寻卡一直失败,这里专门标一下。
4.2 从返回缓冲区里解析UID
libNFC的nfc_target结构体在C语言里包含一个union,C#里直接用StructLayout去套union会非常痛苦。我实际工程里的做法是:把返回数据直接接进一个byte[]缓冲区,然后按ISO 14443-A信息结构的字段偏移去解析。这个方法比较绕,但胜在稳定,不用关心C#和C之间的内存对齐差异。
先看nfc_target在内存里开头一段的字段布局:
偏移 0-1 : ATQA (2字节) 偏移 2 : SAK (1字节) 偏移 3 : UID长度 (1字节) 偏移 4-13 : UID值 (最多10字节) 偏移 14 : ATS长度 偏移 15+ : ATS数据所以解析UID的完整代码可以写成这样:
[StructLayout(LayoutKind.Sequential)] public struct NfcModulation { public byte nmt; public byte nbr; } public static byte[] ReadUid(IntPtr device) { NfcModulation modulation = new NfcModulation(); modulation.nmt = 0; // NMT_ISO14443A modulation.nbr = 0; // NBR_106 byte[] targetBuffer = new byte[512]; int ret = nfc_initiator_select_passive_target( device, ref modulation, null, 0, targetBuffer); if (ret < 1) return null; int uidLen = targetBuffer[3]; byte[] uid = new byte[uidLen]; Array.Copy(targetBuffer, 4, uid, 0, uidLen); return uid; }调用后把字节数组打出来,就能得到类似3A:12:34:56这样的卡号:
byte[] uid = ReadUid(device); if (uid != null) { Console.WriteLine($"UID: {BitConverter.ToString(uid).Replace('-', ':')}"); }这里有个细节:不同读卡器返回的UID长度不一样,MIFARE Classic是4字节,MIFARE Ultralight或者NTAG有的是7字节。所以千万不要写死偏移和长度,必须先用targetBuffer[3]拿到真正的UID长度,再拷贝。我见过不少C#示例把UID写死读4字节,换张NTAG标签就出乱码。
5. 更进一步:用transceive_bytes直接和NTAG标签对话
5.1 为什么需要自己组命令
select_passive_target能拿到UID,但拿不到标签的存储内容。如果你想读NTAG标签的文本、URL数据,或者往用户区写数据,就必须自己构造NFC交换命令发送给卡片。
卡片本质上是个外设,它接收的是字节命令。NTAG、MIFARE Ultralight这类标签符合Type 2规范,其中读取页面数据的命令格式非常简单:
命令: 0x30 [块号] 返回: 16字节数据(如果读到最后一块,还会带CRC尾巴)以NTAG213为例,第0块里就存着UID和厂商数据,正好可以用来验证链路是否打通。
5.2 READ命令构造与返回数据解析
先声明transceive_bytes这个核心API:
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] public static extern int nfc_initiator_transceive_bytes( IntPtr device, byte[] txBuffer, int txLength, byte[] rxBuffer, int rxLength, int timeout);然后发送读取块0的命令:
byte[] tx = new byte[] { 0x30, 0x00 }; // READ命令,读第0块 byte[] rx = new byte[256]; int received = nfc_initiator_transceive_bytes(device, tx, tx.Length, rx, rx.Length, 300); if (received < 0) { Console.WriteLine("收发失败"); return; } // 前16字节是真正的数据 byte[] pageData = new byte[16]; Array.Copy(rx, pageData, 16); Console.WriteLine($"第0块数据: {BitConverter.ToString(pageData).Replace('-', ' ')}");运行之后,pageData的前几个字节就是UID,后面跟着校验字节和厂商数据。这里有个非常容易踩的坑:transceive_bytes返回的字节数有时会比你预期的多几个,因为通信层可能把CRC也带回接收缓冲区。所以判断返回数据时,不要死板地认为“发一个READ命令就一定会返回正好16字节”,而是先取前16字节,多余的CRC可以忽略。
如果想继续读用户数据区,比如NTAG213的用户存储块,把命令里的块号换成对应的块地址就行。比如读第4块,tx就是new byte[] { 0x30, 0x04 }。写数据则是另一个命令,建议先去读相关芯片数据手册,不要在FM11RF08S这类兼容芯片上直接套用标准命令。
6. 排错经验与性能优化:位数、驱动冲突、轮询频率
6.1 三个最容易卡住新手的坑
整理一下我实际踩过、也在网上看到最多的几个问题:
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| DllNotFoundException弹窗 | C#工程是x64或AnyCPU,但libnfc.dll是32位 | 把工程目标平台改成x86,或者单独准备x64版本DLL |
| nfc-list能识别,但C#代码打开设备返回空 | 厂商工具或另一个进程先占用了读卡器句柄 | 关闭厂商自带软件,确保读卡器只有一个进程在访问 |
| 调用寻卡永远超时,但命令行工具一切正常 | 调制参数NfcModulation字段赋值错误 | 确认nmt=0(ISO14443A)、nbr=0(106kbps),两个字段别写反 |
位数不匹配这个问题最隐蔽。官方Windows版libnfc的DLL编译成32位的情况很常见,你在VS里默认创建一个控制台项目,目标平台是AnyCPU,在x64系统上实际以64位进程运行,P/Invoke去加载32位DLL就直接失败。解决办法不是去网上找一个不确定可靠性的x64版本,而是老老实实把项目平台改成x86。桌面应用跑32位进程没有任何影响,但省掉了一堆麻烦。
驱动冲突则和每个读卡器的厂商驱动有关。ACR122U被厂商工具装成私有HID模式后,libNFC在PC/SC层就找不到它。处理完驱动后,记得重新插拔一次读卡器,让系统重新枚举设备。
6.2 连续读卡与多线程调用的几个建议
如果你要做的是门禁签到这类场景,不能每次读卡前都手动等用户放卡,得让程序在一个后台线程里持续轮询。poll_target很适合这件事。轮询参数里的uiPeriod和轮询次数直接决定响应速度和CPU占用,我的经验是不要把周期设得太长,不然卡片放上去要等一两秒才有反应;也不要太短,毕竟一个高频轮询循环在Windows上会把CPU顶上去。
我用下来的配置是:轮询次数设为3,单次周期设为2,读卡循环放在一个Task里跑。伪代码如下:
private async Task PollLoop() { while (!_cancellationToken.IsCancellationRequested) { byte[] targetBuffer = new byte[512]; int ret = nfc_initiator_poll_target(device, ref modulation, 3, 2, targetBuffer); if (ret > 0) { string uid = ParseUidFromTargetBuffer(targetBuffer); OnCardDetected?.Invoke(uid); } await Task.Delay(100); // 给UI线程喘息机会 } }多线程还有一点必须注意:同一个nfc_device指针不要在多线程里同时调用libNFC函数。如果两个线程同时操作同一个设备句柄,轻则命令错乱,重则读卡器直接失去响应。常见做法是每个线程单独nfc_open一个新句柄,或者用一个SemaphoreSlim把设备操作串行化。如果读卡器硬件本身不支持多路并发,就算你开十个线程,实际吞吐也上不去。
还有个小细节:如果你之前写过扫码枪触发事件那种上位机逻辑,会发现连续读卡的模式特别像——设备自己不来通知你,你得主动轮询、主动触发事件。区别在于扫码枪走串口或HID,数据是一个字符一个字符进来;NFC读卡器走的是“命令-应答”模式,一次收发就是完整一帧数据。理解了这一点,把读卡逻辑封装成事件驱动就不难了。我的建议是先封装一个NfcReader公共类,把Open、ReadUid、Transceive、Close四个方法对外暴露,UI层只订阅卡片事件,别直接摸设备句柄。这样后面换读卡器品牌、改协议参数,都只动封装层,不影响界面代码。
本文还有配套的精品资源,点击获取