简介:面向C#开发者的NFC开发资源,基于libNFC类库实现近场通信相关操作。资源包内是完整的Visual Studio工程,包含32个cs源码文件、34个dll依赖库、编译生成的pdb调试文件以及json、txt等配置说明,共172个文件,压缩包仅2.22MB,轻量易用。代码通过P/Invoke封装libNFC底层C接口,覆盖nfc_init初始化、设备列表枚举、nfc_open建连及nfc_initiator_transceive_bytes数据收发等核心流程,并给出NDEF消息构造与解析、资源释放和异常处理的示范代码,可帮助.NET开发者快速掌握跨语言调用的实现思路。资源适合需要为门禁、支付、标签读写等场景集成NFC能力的开发人员使用,既能作为工程模板直接参考,也可从源码中学习P/Invoke声明与回调事件封装的细节。已有1303人学习下载。
1. 项目概述:为什么我选了C#加libNFC的组合
先交代一下背景。我最近在做一套智能巡检终端,设备端是个跑Windows的工控机,需要把员工工牌上的NFC卡片数据读出来,再和后台系统联动。刚开始的方案是直接用读卡器厂商提供的SDK,结果发现不同品牌的读卡器SDK用法差别很大,项目里一旦换硬件,代码就得跟着大改。后来我改用了libNFC这个跨平台开源库,再通过C#的P/Invoke机制封装一层,问题一下就清爽了。
libNFC是什么?它是目前开源社区里比较成熟的NFC协议栈实现,底层直接通过驱动的抽象层访问USB读卡器,支持ISO 14443A/B、ISO 15693、FeliCa等常见卡片类型,也能模拟NFC标签或者和Mifare Classic卡片做交互。C#这边其实没有官方NFC库,最省事的做法就是用DllImport把libnfc的C接口导入进来,自己写一个托管封装类。这个组合最大的好处,就是你在C#里写业务逻辑,底层驱动的事全部交给libNFC。
这篇文章适合谁看?如果你正准备在C#上位机里接入NFC读卡器,或者公司里遇到多品牌读卡器兼容问题,再或者你想搞懂P/Invoke在硬件交互上到底怎么落地,这篇文章都能给你一个可以直接抄作业的路线。我会把libNFC的安装、C#封装、卡片UID读取、Mifare Classic扇区读写、以及我踩过的坑全部讲明白。
2. 环境准备:先把libNFC跑起来
2.1 读卡器选型和驱动安装
不是所有USB读卡器都能被libNFC识别。libNFC官方支持列表里比较常见的有ACS ACR122U、SCM SCL3711、Identive等,其中ACR122U因为价格便宜、资料多,几乎是国内开发者用得最多的一款。我手头用的就是ACR122U,另外还试过PN532的USB模块,libNFC也能通过PN53x系列芯片的驱动支持。
实际操作时,驱动要分成两层看。第一层是USB芯片本身的驱动,在Windows上通常装好读卡器自带的PC/SC驱动就能被系统识别。第二层是libNFC自己的驱动,它默认会通过WinUSB或者libusb方式访问设备。我的建议是直接装Zadig工具,把读卡器的驱动从系统自带的usbccid切换到WinUSB,这样libNFC在Windows下才能稳定枚举到设备。注意,切换驱动后Windows自带的NFC功能就无法使用这个读卡器了,所以测试机上要做好心理准备。
我手头有一台笔记本和一个台式机,实测下来Win10 21H2和Win11都能正常跑。如果你用ACR122U,厂商还有一个PC/SC驱动包,装完后最好再用Zadig强制切换一次USB驱动,因为libNFC在Windows下面不依赖PC/SC接口,它直接走USB层。
2.2 编译libNFC与C#封装的前置说明
libNFC官方源码提供CMake编译方式。如果你不想自己编译,也可以在Release页面找到Windows的预编译二进制,不过版本可能旧一些。我的做法是自己编译,步骤如下:
- 安装CMake和Visual Studio 2022的C++开发组件。
- 克隆libnfc仓库,进入目录后执行cmake生成VS工程。
- 在VS里编译出nfc.dll和nfc.dll的导入库nfc.lib。
- 把生成的nfc.dll放到C#项目的输出目录。
这里有个重点:libNFC的C接口需要的是nfc.dll,而C#通过DllImport导入时,直接写"nfc.dll"就行。但Windows下DLL的依赖问题很头疼,nfc.dll还依赖libusb-1.0.dll,我一般直接把libusb-1.0.dll也复制到项目目录,省得运行时报“找不到指定的程序”。
C#封装层的命名空间我建议叫NfcLib,里面放一个静态类NativeMethods专门放DllImport,再放一个负责高层操作的NfcDevice类。开发时,如果你用的是.NET Framework 4.7.2,DllImport的默认行为是直接找当前目录的DLL;如果是.NET 6以上的项目,反而要注意平台目标要选x64或x86与DLL一致,AnysCPU时会踩坑。
3. C#层封装:从P/Invoke到业务对象
3.1 核心数据结构与初始化流程
libNFC的C接口里有几个核心类型:nfc_context、nfc_device、nfc_target。其中nfc_context是全局上下文,每个进程只需要一个;nfc_device对应一个读卡器设备;nfc_target保存目标卡片的协议和标识信息。
对应到C#里,我们需要定义如下结构:
[StructLayout(LayoutKind.Sequential)] public struct NfcContext { public IntPtr ptr; } [StructLayout(LayoutKind.Sequential)] public struct NfcDevice { public IntPtr ptr; }严格来说,C的nfc_context和nfc_device都是不透明结构体,所以在C#里用IntPtr即可,不必完整拷贝结构体。真正需要完整定义的是nfc_target:
[StructLayout(LayoutKind.Sequential)] public struct NfcTarget { public NfcModulation nm; public byte[] abtUid; public uint uiUidLength; public bool bIsPresent; }这里NfcModulation又是一个枚举组合,包含NmtIso14443A、NmtIso14443B、NmtFelica等。实际开发中,你只需要关心abtUid和uiUidLength,因为多数场景都是读UID。
初始化流程是这样的:
IntPtr contextPtr; NfcNative.nfc_init(out contextPtr); IntPtr devicePtr = NfcNative.nfc_open(contextPtr, null); // null表示打开第一个读卡器nfc_open的第二个参数是连接字符串,比如"pn532_uart:/dev/ttyUSB0"这种,但是USB读卡器传null就会自动打开第一个找到的设备。如果打开失败,多半是驱动没切好,或者读卡器被其他进程占用。
3.2 封装NfcDevice类,屏蔽底层细节
为了让上层业务代码更清爽,我封装了一个NfcDevice类,把初始化、释放、寻卡、读UID这几个操作收敛在一起。大致结构如下:
public class NfcDevice : IDisposable { private IntPtr _context; private IntPtr _device; public void Open() { NfcNative.nfc_init(out _context); _device = NfcNative.nfc_open(_context, null); if (_device == IntPtr.Zero) throw new Exception("无法打开NFC设备"); } public string ReadCardUid() { var target = new NfcTarget(); NfcNative.nfc_initiator_select_passive_target( _device, NfcModulation.NmtIso14443A, 0, 0, ref target); if (target.uiUidLength == 0) return null; return BitConverter.ToString(target.abtUid, 0, (int)target.uiUidLength) .Replace("-", ":"); } public void Dispose() { if (_device != IntPtr.Zero) NfcNative.nfc_close(_device); if (_context != IntPtr.Zero) NfcNative.nfc_exit(_context); } }这个类已经够我日常使用了,如果你想支持更高级的Mifare读写,可以在里面再加一个ConnectMifare方法,专门处理nfc_initiator_mifare_cmd调用。
3.3 编码注意事项:byte数组与字符串转换
很多初学者在把UID转字符串时容易栽跟头。abtUid是byte[],直接Encoding.ASCII.GetString是会把不可打印字符转成乱码的。正确的做法是用十六进制格式输出。上面例子里的BitConverter.ToString再替换分隔符,是最稳妥的写法。
另外StructLayout里的byte[]需要注意:C语言里的byte[]是固定长度数组,而C#结构体里的byte[]默认是按引用传的,必须用MarshalAs(UnmanagedType.ByValArray, SizeConst=16)]声明长度,否则结构体尺寸不对,后续调用会读错内存。我的经验是把nfc_target里的UID数组定义为:
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] public byte[] abtUid;这样和C结构体在内存布局上完全一致。
4. 核心功能实现:从读UID到Mifare读写
4.1 寻卡与非接触通信原理
NFC读卡器寻卡的过程,本质上是读卡器向外发射射频场,然后监听卡片对请求的应答。libNFC中nfc_initiator_select_passive_target函数做的就是这件事,它的参数里有一个NfcModulation,指定的是通信协议类型。
ISO 14443A是多数门禁卡和公交卡采用的协议,包括Mifare Classic、Mifare DESFire、NTAG系列等。手机上的NFC模拟卡也走这个协议。所以我在ReadCardUid里默认使用NmtIso14443A。如果你的卡片是ISO 15693(例如图书馆防盗标签),就要改成NmtIso15693。
关于单次寻卡和轮询寻卡的区别:nfc_initiator_select_passive_target是主动选卡,它每次只能选中一张卡。如果卡片贴在读卡器上一次没读到,通常是因为调度太快,卡片没来得及进入稳定场。我的经验是寻卡间隔至少给100毫秒,可以加一个定时器每200毫秒轮询一次,这样既不会漏卡,也不会让CPU空转。
4.2 读取Mifare Classic扇区数据
只读UID在很多场景够用了,但有些项目需要把卡内数据也读出来,比如工牌里存了员工编号、姓名等。Mifare Classic 1K卡的存储结构分16个扇区,每个扇区4个块,每个块16字节。第0扇区的第0块是厂商数据,里面就有UID,这个块只能读不能写。
用libNFC读Mifare数据需要经过这几步:
- 选中卡片。
- 调用
nfc_initiator_mifare_cmd发送认证命令。 - 如果认证通过,再发送读块命令。
认证时默认的密钥在libNFC的示例里叫default_keys,就是FF FF FF FF FF FF或者A0 A1 A2 A3 A4 A5。很多门禁卡出厂后不换密钥,所以用这些默认密钥能直接读出来。如果换过密钥,就需要知道对应扇区的Key A或Key B。
C#封装里我写了这样一个方法:
public byte[] ReadMifareBlock(byte blockNumber, byte[] key) { var keyPtr = Marshal.AllocHGlobal(6); Marshal.Copy(key, 0, keyPtr, 6); int result = NfcNative.nfc_initiator_mifare_cmd( _device, NfcNative.MifareCmd.MFC_AUTH_A, blockNumber, keyPtr); Marshal.FreeHGlobal(keyPtr); if (result < 0) return null; var data = new byte[16]; result = NfcNative.nfc_initiator_mifare_cmd( _device, NfcNative.MifareCmd.MFC_READ, blockNumber, data); if (result < 0) return null; return data; }注意nfc_initiator_mifare_cmd这个函数签名比较特殊:虽然它叫cmd,但在读块时传入的最后一个参数pbtData既是输入也是输出,认证时传密钥指针,读块时传数据缓冲区。这个细节文档里没写清楚,我看源码里才发现。
4.3 写入Mifare区块的完整实现
写入比读取更危险,因为一旦写错位置,卡可能就直接废了。我实现写功能的思路是先备份原数据,再做写操作。写单个块代码如下:
public bool WriteMifareBlock(byte blockNumber, byte[] data, byte[] key) { if (data.Length != 16) throw new ArgumentException("数据长度必须为16字节"); var keyPtr = Marshal.AllocHGlobal(6); Marshal.Copy(key, 0, keyPtr, 6); int result = NfcNative.nfc_initiator_mifare_cmd( _device, NfcNative.MifareCmd.MFC_AUTH_A, blockNumber, keyPtr); Marshal.FreeHGlobal(keyPtr); if (result < 0) return false; var dataPtr = Marshal.AllocHGlobal(16); Marshal.Copy(data, 0, dataPtr, 16); result = NfcNative.nfc_initiator_mifare_cmd( _device, NfcNative.MifareCmd.MFC_WRITE, blockNumber, dataPtr); Marshal.FreeHGlobal(dataPtr); return result >= 0; }写入前务必检查目标块是不是数据块,而不要写到扇区末尾的“尾部块”。尾部块存的是密钥A、访问位、密钥B,贸然写入会改变扇区权限甚至锁死卡片。判断方法很简单:块号取模4,结果为3的就是尾部块。比如第0扇区的块3、第1扇区的块7都不能随便写。
我实际项目里,给每一张卡只写第1扇区第4块和第5块,这两个块是普通数据块,钥匙放在第0扇区尾部块不动。同时我会先读一次原数据,保存到数据库,再执行写入,这样万一写坏还能恢复。
5. 实战中的问题排查与避坑经验
5.1 设备打不开、DLL加载失败这类经典问题
好几个同事在我之后也照着我的方案搭环境,遇到最多的问题是“初始化时nfc_open返回零,找不到设备”。排查顺序我总结成了下面这个表:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| nfc_init成功,nfc_open返回0 | USB驱动不是WinUSB | 用Zadig把读卡器切换到WinUSB |
| DllNotFoundException | nfc.dll依赖的libusb.dll缺失 | 把libusb-1.0.dll复制到exe目录 |
| BadImageFormatException | C#平台位数和DLL不一致 | 项目平台目标改为x64或x86 |
| 读卡器插上系统有声音但libNFC枚举不到 | 读卡器被PC/SC服务占用 | 关闭Smart Card服务后重试 |
其中“读卡器被PC/SC服务占用”这个问题最容易忽略。Windows的Smart Card服务会默认占用符合条件的读卡器,即使你切换了WinUSB,偶尔也会冲突。我开发的电脑上直接把服务停了,命令是sc stop SCardSvr,需要管理员权限。如果你是生产环境,最好用一台专用工控机,别装一堆卡片服务。
5.2 卡片读取不稳定,常出现偶发失败
排除了设备问题后,我遇到最多的反而是物理层面的问题。比如读卡器天线位置不对,卡片放得太偏;或者读卡器与金属物体靠太近,射频场被干扰。这个不属于代码问题,但很影响体验。
代码层面能优化的点有三个:第一,寻卡失败后不要立即报错,而是重试3次,每次间隔150毫秒;第二,在读取UID后对UID长度做个判断,正常ISO14443A的UID有4字节、7字节、10字节三种,如果长度不是这三种,大概率是误读;第三,多张卡片同时靠近时,读卡器可能反复标定目标,我做的处理是连续读到同一个UID三次才认为成功,否则就一直轮询。
另外一个容易踩的坑是ReadCardUid里的target.abtUid数组内容在调用后会被后续操作覆盖。如果你拿到UID后不立刻转成字符串,而是先存入某个集合,再去读下一张卡,集合里存的就全是数组的引用,最后会变成重复数据。解决办法是每次拿到UID后立刻用Clone()方法拷贝一份:
byte[] uid = (byte[])target.abtUid.Clone();5.3 自定义密钥读取的权限陷阱
Mifare Classic的认证模型是每个扇区可以有独立的Key A和Key B。如果只是用默认密钥读设备出厂卡,那很简单。但很多客户在发卡时会用专用发卡器改写扇区密钥。这时你的读卡程序如果没有对应密钥,认证一定会失败。
我在做某个项目时,客户给了一张密钥表,里面是每个扇区的Key A,都是16进制字符串。我在程序里做一个字典,把扇区号映射到对应密钥。认证某个扇区前先查表。如果查不到密钥,就不要尝试读取,直接显示“无权限”。另外,针对同一张卡,连续认证失败多次会被卡片锁定,需要把卡片移开再重新放上,才能继续尝试。所以我建议在实现时控制认证次数,失败一次后就放弃当前卡,等下一次轮询再试。
5.4 与扫码枪联动时的协作细节
我的项目里还有一个扫码枪,用来扫描设备上的二维码。扫码枪走串口,NFC走USB,两个外设同时工作,问题就出现在事件顺序上。最开始我在扫码枪的DataReceived事件里直接调用了NFC读取函数,结果因为扫码枪线程和NFC轮询线程同时在访问同一个NfcDevice实例,导致读卡器句柄冲突,NFC偶尔会崩。
解决办法是给NFC访问加锁,或者把所有NFC操作都放在同一个线程里。我用了一个最简单的SemaphoreSlim只允许一个线程进入NFC操作区,代码大致是这样:
private static SemaphoreSlim _nfcLock = new SemaphoreSlim(1, 1); public async Task<string> ReadUidSafeAsync() { await _nfcLock.WaitAsync(); try { return _device.ReadCardUid(); } finally { _nfcLock.Release(); } }如果你的项目里有多线程同时操作读卡器,这个锁一定要加,否则高频率调用时很容易遇到Unknown error,而且复现还不稳定。
6. 性能优化与多设备扩展思路
6.1 轮询频率和CPU占用平衡
刚做完NFC读取功能时,我用了一个200ms的定时器无限轮询,结果任务管理器里CPU占用一直稳定在20%以上。后来改成睡眠等待方式,读卡器在没有卡的时候其实每次调用也会消耗一点时间做射频监听。最直接的优化方式是降低轮询频率,比如读卡放在300ms间隔,人对卡片的接触本来就不是毫秒级的,300ms完全够用。
我实测过,200ms和500ms间隔在用户体验上感知不到区别,但CPU占用能从20%降到5%以下。如果还要再极致一点,可以在检测到设备上有卡片存在后,连续快速读取3次,读取间隔设置50ms,因为卡片已经被选中了,不需要再走完整寻卡流程。
6.2 同时接入多个读卡器的思路
在巡检场景里,有时候一个工位需要同时刷入和刷出,就需要两个读卡器。libNFC在nfc_list_devices接口中会返回设备列表,C#里可以写一个List<IntPtr>存所有设备句柄。但实际操作中,我建议不要在一个进程里打开多个读卡器,因为Windows下多个USB读卡器的驱动状态经常不稳定。
我最后采用的是“一个读卡器一个进程”方案,叫两个上位机程序,分别对应两个读卡器,再通过进程间通信把数据汇总到主界面。如果你非要在同一个进程里做,至少要确保每个读卡器实例都用一个独立的NfcContext,并且不要跨线程共享句柄。
6.3 libNFC与PC/SC的取舍
很多人会纠结到底用libNFC还是PC/SC。PC/SC是Windows系统自带的智能卡接口标准,很多读卡器SDK也是基于PC/SC开发。如果你只需要读UID,PC/SC其实也行,但它的缺点是不能直接访问Mifare Classic的扇区读写指令,很多底层控制被系统屏蔽了。libNFC在这方面更自由,它能直接发送APDU指令,适合做破解测试、卡片模拟、自定义协议开发。
代价就是libNFC的API比PC/SC更底层,封装工作量更大。我自己在有ACR122U和PN532两个设备的情况下,只能选择libNFC,因为PC/SC对PN532的支持很差。如果你的设备只面向ACR122U,并且只需要读UID,用一些厂商的封装库反而更快。
7. 个人实操心得与后续扩展建议
这套C#加libNFC的方案我已经用了快半年,最大的体会是:不要一开始就追求功能完整,先把“打开设备—读UID—关闭设备”这个最小闭环跑通,后面再一步步加扇区读写、密钥管理、多线程安全。因为libNFC和C#之间隔着一层P/Invoke,出问题时往往很难分清是DLL没找到、结构体定义错了,还是设备驱动不对。我调试时经常使用的技巧是,先写一个非常简单的Console应用,只调用nfc_init和nfc_open,打印返回值,确认环境没问题后再加后面的逻辑。
第二个心得是关于日志记录的。所有NFC调用都要记录时间戳、操作类型、返回码和卡片UID。看似麻烦,但出问题时查日志非常高效。我在NfcDevice类里加了几个Trace.WriteLine,调试时用DebugView看,发布时用log4net写文件,排查线上问题省了不少时间。
如果你想在这个项目基础上继续扩展,我推荐三个方向。第一是把NFC读取能力做成Windows服务,后台常驻,任何前台程序都能通过管道或HTTP接口来读取卡片,这样多个应用可以共享同一个读卡器而不产生冲突。第二是结合数据库做用户身份绑定,刷卡的瞬间自动调摄像头拍照,形成完整的出入记录。第三是研究libNFC的卡模拟模式,让工控机模拟成一张NFC卡,和手机交互,这个玩法很有趣,但是涉及技术细节更多,需要单独开一篇来讲。
本文还有配套的精品资源,点击获取