简介:这份资源面向希望在C#项目中集成NFC功能的开发者,围绕libNFC类库展开,解决.NET环境下调用C语言NFC库、实现标签读写与数据交换的问题。内容涵盖P/Invoke声明、设备发现与连接、NDEF消息解析、事件回调及资源释放等关键环节,适合具备一定C#基础、想切入近场通信开发的中级读者。压缩包共172个文件,约2.22MB,以32个cs源码、34个dll动态库、18个pdb调试符号、3个csproj工程文件为主,另含json配置、nuspec打包描述与sln解决方案,构成可直接编译运行的完整工程结构。目前已有1305人学习下载。通过这份资料,读者可参考现成的P/Invoke封装与项目组织方式,理解libNFC在C#中的调用链路,并借助调试符号与工程配置快速复现标签读写、数据交换等操作,减少自行摸索跨平台调用的成本。
1. C# 调用 libNFC 做读写:为什么一线项目还在用这套组合
工控机、门禁终端、自助设备上跑着 .NET 程序,旁边插一个 USB 读卡器,要求读卡号、写块数据、做卡片识别——这是很多做 C# 上位机的工程师都会碰到的场景。标题里的「C# 实现 NFC 相关操作,利用 libNFC 类库」,说的就是这条路线:底层用 libNFC 这个 C 库去驱动读卡器,上层用 C# 通过 P/Invoke 把它的接口包出来,在自己的业务代码里调用。它解决的核心问题是:不想为每种读卡器写一套驱动,也不想把整个业务逻辑塞进 C 或 C++ 里,而是让 C# 负责界面和业务,libNFC 负责和硬件打交道。
适合谁看?做 Windows 上位机、工控 HMI、自助终端、实验室数据采集的 C# 开发者,手上有 PN532 一类的读卡模块或者 ACR122U 这类 PC/SC 读卡器,需要读写 Mifare Classic、NTAG 系列卡片。如果你只是想读一下手机模拟的卡号,那用现成的 PC/SC 封装可能更省事;但只要涉及块级读写、扇区密钥、ISO14443-A 的底层交互,libNFC 的接口粒度就体现出价值了。下面按「先跑通、再讲参数、再排坑」的顺序展开。
2. 环境搭建与最小可跑通链路:从 libNFC 动态库到 C# 第一个读卡调用
2.1 为什么选 libNFC 而不是直接写 PC/SC
Windows 上做 NFC,常见做法有三条:一是直接用 WinSCard(PC/SC)API,二是用厂商自带的 SDK,三是用 libNFC。PC/SC 的优点是系统自带、稳定,缺点是它抽象层次偏高,拿到的是 APDU 层面的东西,做 Mifare Classic 的扇区密钥认证、块读写时,你得自己拼 APDU 指令,不同读卡器对指令的支持还有差异。厂商 SDK 的问题是绑定硬件,换一个读卡器就要重写。
libNFC 的定位是「跨平台的 NFC 底层库」,它把读卡器的差异封装在 driver 层,上层提供nfc_init、nfc_open、nfc_initiator_transceive_bytes这类统一接口。对 C# 来说,它的价值在于:接口是纯 C 的,导出符号清晰,P/Invoke 包起来相对直接;而且它同时支持 PN532 串口/USB 和 PC/SC 读卡器,换硬件时上层代码改动小。代价是它本身不提供 .NET 绑定,需要自己写 interop 层,这就是本文要解决的主要工作量。
2.2 拿到 libNFC 动态库并确认导出符号
第一步不是写 C#,而是先把 libNFC 在目标机器上跑起来。Windows 下常见做法是拿预编译的libnfc.dll,或者自己用 MSYS2/MinGW 编译。放好之后,用dumpbin确认导出符号,这一步能省掉后面大量「EntryPointNotFoundException」的排查时间。
# 在 Windows 开发者命令行里查看 dll 导出函数 dumpbin /exports libnfc.dll | findstr nfc_ # 典型会看到的符号(不同版本略有差异) # nfc_init # nfc_open # nfc_close # nfc_exit # nfc_initiator_init # nfc_initiator_select_passive_target # nfc_initiator_transceive_bytes # nfc_device_get_name逻辑说明:dumpbin /exports列出 DLL 的所有导出符号,findstr nfc_过滤出我们关心的函数。参数说明:/exports是 dumpbin 的子命令,不需要额外参数;如果提示找不到 dumpbin,需要从 Visual Studio 的「开发者命令提示符」里运行,或者手动把 MSVC 的 bin 目录加进 PATH。确认符号存在,才能保证后面DllImport里的EntryPoint名字对得上——libNFC 在不同编译选项下,函数名可能带下划线前缀或者__cdecl修饰,这一步是排雷。
2.3 用 P/Invoke 包出第一个可调用的 C# 接口
确认符号后,写 C# 的 interop 层。核心是把 libNFC 的几个关键结构体和函数签名翻译过来。下面是最小可用的一组声明。
using System; using System.Runtime.InteropServices; internal static class LibNfcNative { private const string Dll = "libnfc.dll"; // nfc_context 是不透明指针,C# 侧只当句柄用 [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] internal static extern void nfc_init(out IntPtr context); [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] internal static extern IntPtr nfc_open(IntPtr context, string connstring); [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] internal static extern void nfc_close(IntPtr device); [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] internal static extern void nfc_exit(IntPtr context); [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] internal static extern int nfc_initiator_init(IntPtr device); // 目标信息结构体,字段顺序必须和 C 头文件一致 [StructLayout(LayoutKind.Sequential)] internal struct NfcTarget { [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)] public byte[] nti; // 目标类型信息 public byte ntiLength; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)] public byte[] nbr; // 波特率信息 public byte nbrLength; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 12)] public byte[] abtUid; // 卡片 UID public byte szUidLen; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 40)] public byte[] abtAtr; public byte szAtrLen; } [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] internal static extern int nfc_initiator_select_passive_target( IntPtr device, uint baudrate, byte[] initiatorData, uint initiatorDataLen, ref NfcTarget target); [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] internal static extern int nfc_initiator_transceive_bytes( IntPtr device, byte[] txData, uint txDataLen, byte[] rxData, uint rxDataLen, int timeout); }逻辑说明:nfc_init初始化全局上下文,nfc_open打开设备,nfc_initiator_init把设备设成主动模式,nfc_initiator_select_passive_target寻卡,nfc_initiator_transceive_bytes收发原始字节。参数说明:CallingConvention.Cdecl必须显式指定,libNFC 默认是 cdecl,用默认的 stdcall 会导致栈不平衡,表现为调用后程序直接崩或者返回值乱掉。NfcTarget结构体的字段顺序和大小必须和 C 头文件严格一致,SizeConst写错会导致内存越界,这是 interop 里最容易翻车的地方。connstring传null表示自动选择第一个可用设备,也可以传"pn532_uart:COM3:115200"这类字符串指定设备。
2.4 跑通一次寻卡并打印 UID
有了上面的声明,写一个控制台程序验证链路。
class Program { static void Main() { IntPtr ctx; LibNfcNative.nfc_init(out ctx); if (ctx == IntPtr.Zero) { Console.WriteLine("nfc_init 失败"); return; } IntPtr dev = LibNfcNative.nfc_open(ctx, null); if (dev == IntPtr.Zero) { Console.WriteLine("未找到设备"); return; } if (LibNfcNative.nfc_initiator_init(dev) < 0) { Console.WriteLine("initiator 初始化失败"); return; } var target = new LibNfcNative.NfcTarget(); // 106 kbps,ISO14443-A int res = LibNfcNative.nfc_initiator_select_passive_target( dev, 0x0100, null, 0, ref target); if (res > 0) { Console.Write("UID: "); for (int i = 0; i < target.szUidLen; i++) Console.Write(target.abtUid[i].ToString("X2")); Console.WriteLine(); } else Console.WriteLine("未检测到卡片"); LibNfcNative.nfc_close(dev); LibNfcNative.nfc_exit(ctx); } }逻辑说明:初始化上下文、打开设备、设为主动模式、寻卡、打印 UID、释放资源。参数说明:0x0100是波特率常量,对应 106 kbps,ISO14443-A 卡片用这个值;initiatorData传null、长度传 0 表示不做额外的初始化数据。返回值大于 0 表示找到卡片,等于 0 表示超时没找到,小于 0 是错误。nfc_close和nfc_exit必须成对调用,否则反复打开设备会失败——这是血泪经验,调试时频繁重启程序很容易踩到。
3. 块读写与密钥认证:Mifare Classic 操作的参数怎么设
3.1 认证流程和 transceive 的字节拼装
寻卡只是拿到 UID,真正读写 Mifare Classic 的块,要先做密钥认证。libNFC 本身不提供「认证」这个高层函数,它给的是nfc_initiator_transceive_bytes,你需要自己拼认证指令。Mifare Classic 的认证指令格式是60(Key A)或61(Key B)+ 块号 + 6 字节密钥。认证通过后,才能发读写指令:读块是30+ 块号,写块是A0+ 块号 + 16 字节数据。
// 认证块 4,使用 Key A,默认密钥 FF FF FF FF FF FF byte[] authCmd = new byte[12]; authCmd[0] = 0x60; // Key A authCmd[1] = 0x04; // 块号 for (int i = 0; i < 6; i++) authCmd[2 + i] = 0xFF; byte[] resp = new byte[16]; int r = LibNfcNative.nfc_initiator_transceive_bytes( dev, authCmd, (uint)authCmd.Length, resp, (uint)resp.Length, 1000); // r >= 0 表示认证成功逻辑说明:拼出 12 字节认证指令,通过 transceive 发给卡片,返回非负即认证通过。参数说明:timeout单位是毫秒,认证一般给 1000 足够;resp缓冲区大小给 16 字节,认证成功时卡片返回的字节数很少,但缓冲区给大一点避免溢出。注意authCmd[1]是块号,Mifare Classic 1K 的块 0 是厂商块,只读,块 3、7、11 等是扇区尾块存密钥和权限,不要随便写。
3.2 读块和写块的完整调用
认证通过后,读块和写块就是再发一次 transceive。
// 读块 4 byte[] readCmd = new byte[] { 0x30, 0x04 }; byte[] readResp = new byte[16]; int rr = LibNfcNative.nfc_initiator_transceive_bytes( dev, readCmd, 2, readResp, 16, 1000); if (rr == 16) { /* readResp 里就是 16 字节块数据 */ } // 写块 4 byte[] writeCmd = new byte[18]; writeCmd[0] = 0xA0; writeCmd[1] = 0x04; for (int i = 0; i < 16; i++) writeCmd[2 + i] = 0x00; // 要写入的数据 byte[] writeResp = new byte[16]; int wr = LibNfcNative.nfc_initiator_transceive_bytes( dev, writeCmd, 18, writeResp, 16, 1000); // wr == 1 且 writeResp[0] == 0x0A 表示写成功逻辑说明:读指令 2 字节,返回 16 字节块数据;写指令 18 字节,成功时卡片返回 1 字节0x0A。参数说明:读的返回长度判断用rr == 16,写成功判断用wr == 1 && writeResp[0] == 0x0A,这两个判断条件写错会导致「明明写进去了却报失败」的玄学问题。timeout写操作可以适当放大到 1500,某些卡片写入慢。
3.3 关键参数速查表
| 参数 | 含义 | 常用值 | 备注 |
|---|---|---|---|
| baudrate | 寻卡波特率 | 0x0100 | 106 kbps,ISO14443-A |
| authCmd[0] | 认证类型 | 0x60 / 0x61 | 0x60 用 Key A,0x61 用 Key B |
| authCmd[1] | 块号 | 0~63 | 1K 卡共 64 块 |
| readCmd[0] | 读指令 | 0x30 | 固定 |
| writeCmd[0] | 写指令 | 0xA0 | 固定 |
| timeout | 超时 | 1000~1500 | 单位毫秒 |
| connstring | 设备连接串 | null 或 pn532_uart:COM3:115200 | null 自动选 |
这张表建议直接抄进代码注释,调参时不用反复翻文档。
4. 避坑与排查:interop 和硬件层最容易翻车的五个点
4.1 现象:调用 nfc_open 返回 0,但设备明明插着
原因通常是 connstring 没指定,而系统里有多个读卡器,libNFC 自动选择时选到了不可用的那个;或者驱动没装好,设备管理器里能看到但 libNFC 认不到。解决:先用 libNFC 自带的命令行工具(如果有)确认能列出设备,再在 C# 里显式传 connstring,比如"pn532_uart:COM3:115200"。串口号和波特率要和实际一致,PN532 的 UART 默认 115200,改过就要跟着改。
4.2 现象:程序调用后直接闪退,没有异常信息
这是 CallingConvention 写错或者结构体布局不对的典型表现。libNFC 是 cdecl,C# 默认是 stdcall,栈不平衡会直接崩。解决:所有DllImport显式加CallingConvention = CallingConvention.Cdecl。结构体方面,NfcTarget里的数组字段必须用[MarshalAs(UnmanagedType.ByValArray, SizeConst = N)],N 要和 C 头文件里的数组长度完全一致,差一个字节都可能越界。
4.3 现象:认证一直失败,但密钥确认是对的
先确认块号对不对。Mifare Classic 1K 的扇区结构是每 4 块一个扇区,块 3、7、11……是尾块,存的是密钥和访问位,不是数据块。如果你拿块 3 当数据块去认证和读写,行为会很奇怪。解决:数据块用 0~2、4~6、8~10 这类,尾块单独处理。另外,有些卡片出厂时密钥不是默认的 FF,需要先知道正确密钥。
4.4 现象:写块返回成功,但读回来数据没变
可能是写到了尾块,或者卡片的访问位不允许写。Mifare Classic 的每个扇区尾块里有访问位,控制该扇区数据块的读写权限。如果访问位设成了只读,写指令会返回成功但数据不落盘。解决:先读尾块看访问位,确认目标块可写;必要时用正确密钥改访问位,但改访问位有把卡片锁死的风险,操作前先备份密钥。
4.5 现象:反复打开关闭设备后,nfc_open 开始失败
资源没释放干净。nfc_close和nfc_exit必须成对调用,而且要在同一个线程里。如果程序里有多处打开设备的逻辑,很容易漏掉某次 close。解决:把设备句柄包在一个IDisposable类里,用using保证释放;调试阶段可以在 close 之后加一句日志,确认每次打开都有对应的关闭。
5. 进阶:把 interop 层封装成可复用组件并做稳定性验证
走到这里,最小链路已经通了。但一线项目里不会把 P/Invoke 散落在业务代码里,常见做法是封一个NfcReader类,把打开、寻卡、认证、读写、释放都收进去,对外只暴露业务语义的方法。下面是一个封装骨架。
public sealed class NfcReader : IDisposable { private IntPtr _ctx; private IntPtr _dev; private bool _disposed; public bool Open(string connString = null) { LibNfcNative.nfc_init(out _ctx); if (_ctx == IntPtr.Zero) return false; _dev = LibNfcNative.nfc_open(_ctx, connString); if (_dev == IntPtr.Zero) return false; return LibNfcNative.nfc_initiator_init(_dev) >= 0; } public byte[] ReadUid() { var target = new LibNfcNative.NfcTarget(); int res = LibNfcNative.nfc_initiator_select_passive_target( _dev, 0x0100, null, 0, ref target); if (res <= 0) return null; var uid = new byte[target.szUidLen]; Array.Copy(target.abtUid, uid, target.szUidLen); return uid; } public bool Authenticate(byte block, byte[] key, bool useKeyB = false) { var cmd = new byte[12]; cmd[0] = (byte)(useKeyB ? 0x61 : 0x60); cmd[1] = block; Array.Copy(key, 0, cmd, 2, 6); var resp = new byte[16]; return LibNfcNative.nfc_initiator_transceive_bytes( _dev, cmd, 12, resp, 16, 1000) >= 0; } public void Dispose() { if (_disposed) return; if (_dev != IntPtr.Zero) LibNfcNative.nfc_close(_dev); if (_ctx != IntPtr.Zero) LibNfcNative.nfc_exit(_ctx); _disposed = true; } }逻辑说明:把资源生命周期收进IDisposable,业务层用using调用,避免漏释放。参数说明:Open的connString默认 null,需要指定设备时传入;Authenticate的useKeyB控制用 Key A 还是 Key B,密钥必须是 6 字节。封装之后,业务代码里就只剩reader.ReadUid()和reader.Authenticate(...)这类调用,interop 的细节被隔离在一处,换 libNFC 版本或者换读卡器时改动面小。
封装完还要做稳定性验证。我一般会写一个循环测试:连续寻卡、认证、读写 500 次,统计失败次数和失败时的返回值。这个测试能暴露两类问题:一是资源泄漏,跑几百次后nfc_open开始失败;二是超时设置不合理,偶发返回负值。测试时把每次的返回码记下来,负值对应 libNFC 的错误码,查错误码表能定位到具体原因。另外,多线程场景下 libNFC 的设备句柄不是线程安全的,如果业务里有并发读卡需求,要么加锁,要么每个线程独立打开设备——这一点在封装时就要想清楚,别等上线后才发现偶发崩溃。
最后说个习惯:每次改完 interop 声明,先用一个只做寻卡的控制台程序验证,确认没问题再往业务里集成。interop 的问题一旦混进业务逻辑,排查成本会翻好几倍。这套组合不新,但在工控和自助设备场景里足够稳,值不值得投入取决于你的硬件是否固定、是否需要块级操作——如果只是读个 UID,用现成的 PC/SC 封装更省事;一旦涉及扇区密钥和块读写,自己包一层 libNFC 是划算的。希望帮到你。
本文还有配套的精品资源,点击获取