C# 通过 P/Invoke 封装 libNFC:实现 NFC 读写与 MIFARE Classic 操作

发布时间:2026/10/18 14:51:19
C# 通过 P/Invoke 封装 libNFC:实现 NFC 读写与 MIFARE Classic 操作
简介本资源面向希望在C#项目中集成NFC功能的开发者围绕libNFC开源类库展开解决C#调用C语言NFC库的跨平台互操作问题。内容涵盖P/Invoke声明、设备发现与连接、标签读写、NDEF消息解析、事件回调及资源释放等关键环节适合具备一定C#基础、需要实现近场通信读写与数据交换的中高级开发者参考。压缩包共172个文件约2.22MB以32个cs源码、34个dll动态库、18个pdb调试符号、7个json配置及若干csproj、nuspec、targets等项目文件为主同时包含cache缓存与conf配置构成可直接编译调试的完整工程结构。目前已有1305人学习下载。通过该资源读者可获取libNFC在C#中的封装示例、项目依赖组织方式与异常处理思路便于快速搭建NFC读写环境并对照调试。1. 从一张门禁卡说起C# 调用 libNFC 到底能做什么上周有个做智能硬件的朋友找我说他们新项目要在一台 Windows 工控机上读员工工牌工牌是 ISO14443-A 的 MIFARE Classic问我能不能用 C# 直接搞定别上什么读卡器厂商的闭源 SDK。我第一反应就是 libNFC——一个跨平台的开源 NFC 底层库C 写的支持 PN532、ACR122U、PN533 这些常见芯片Windows 下也能跑。问题是它原生是 C 接口C# 要调就得走 P/Invoke中间涉及结构体封送、指针传递、回调线程坑不少。这份资源就是围绕「C# 通过 P/Invoke 封装 libNFC」展开的包含封装层代码、设备初始化、寻卡、读写块、UID 解析这些核心操作。适合两类人一是做门禁、考勤、会员卡系统的 C# 开发者二是想用 .NET 生态接 NFC 硬件但不想被厂商 SDK 绑死的工程师。下面我按实际拆包顺序把能跑通的路径和翻车点都摊开讲。2. 环境搭建与 libNFC 原生库的获取别在第一步就卡住2.1 为什么选 libNFC 而不是厂商 SDK市面上做 NFC 读写的方案大致三种厂商自带 SDK、libNFC、自己写串口协议。厂商 SDK 的问题是绑定硬件型号换一批读卡器就得重写自己写串口协议对 PN532 这类芯片来说帧格式、校验、超时重传全要手撸调试成本高。libNFC 的好处是抽象层做得干净nfc_init、nfc_open、nfc_initiator_select_passive_target这几个函数把设备差异屏蔽掉了同一套 C# 封装代码换设备只需要改连接字符串。常见做法是先用 libNFC 的命令行工具nfc-list验证硬件能通再上 C# 封装这样能把「硬件问题」和「代码问题」分开排查。2.2 Windows 下编译或获取 libNFC 的实操路径libNFC 官方主要面向 LinuxWindows 下要么用 MSYS2 编译要么找预编译的 DLL。我一般推荐用 MSYS2 走一遍因为后面调试符号齐全。步骤如下# 在 MSYS2 MINGW64 终端中执行 pacman -S mingw-w64-x86_64-toolchain mingw-w64-x86_64-cmake mingw-w64-x86_64-libusb git clone https://github.com/nfc-tools/libnfc.git cd libnfc mkdir build cd build cmake -G MSYS Makefiles -DCMAKE_INSTALL_PREFIX/mingw64 .. make -j4 make install编译完成后/mingw64/bin下会有libnfc-*.dll/mingw64/etc/nfc下是配置文件。这里的关键参数是-DCMAKE_INSTALL_PREFIX它决定安装路径C# 加载 DLL 时要按这个路径去找。另一个容易忽略的是 libusb 依赖PN532 走 USB 转串口时libNFC 底层调的是 libusb如果系统里 libusb 版本不对nfc_open会直接返回 NULL而且不报具体原因。2.3 C# 项目侧的准备平台目标与 DLL 搜索路径C# 项目里第一件事是把平台目标设成 x64因为编译出来的 libNFC 是 64 位的AnyCPU 在 64 位系统上默认跑 64 位进程没问题但一旦有人改成 x86 就会DllNotFoundException。第二件事是 DLL 搜索路径我习惯在代码里显式设置using System.Runtime.InteropServices; public static class NativeLoader { [DllImport(kernel32.dll, CharSet CharSet.Unicode, SetLastError true)] private static extern bool SetDllDirectory(string lpPathName); public static void Init(string nativeDir) { // 把 libnfc.dll 所在目录加入搜索路径避免依赖系统 PATH SetDllDirectory(nativeDir); } }SetDllDirectory的参数是 libNFC DLL 所在目录的绝对路径。这样做的好处是不用改系统环境变量部署时把 native 目录跟着 exe 一起拷就行。注意这个函数是进程级的调用一次即可重复调用会覆盖。如果 native 目录里还有 libusb-1.0.dll也要一并放进去否则加载 libnfc.dll 时会因为找不到依赖而失败。3. P/Invoke 封装核心 API结构体封送与设备初始化3.1 nfc_context 与 nfc_device 的句柄管理libNFC 的两个核心句柄是nfc_context和nfc_device在 C# 里都用IntPtr接收。nfc_init负责初始化全局上下文nfc_open打开具体设备。这里有个血泪经验nfc_init传的指针必须是一个指向IntPtr的指针不能直接传IntPtr否则封送会出错。[DllImport(libnfc.dll, CallingConvention CallingConvention.Cdecl)] public static extern void nfc_init(out IntPtr context); [DllImport(libnfc.dll, CallingConvention CallingConvention.Cdecl)] public static extern IntPtr nfc_open(IntPtr context, string connstring); [DllImport(libnfc.dll, CallingConvention CallingConvention.Cdecl)] public static extern void nfc_close(IntPtr device); [DllImport(libnfc.dll, CallingConvention CallingConvention.Cdecl)] public static extern void nfc_exit(IntPtr context);nfc_init的out IntPtr对应 C 里的nfc_context **context这是 libNFC 的固定写法。nfc_open的第二个参数是连接字符串传null表示自动探测第一台设备也可以传pn532_uart:/dev/ttyUSB0或 Windows 下的pn532_uart:COM3来指定。nfc_close和nfc_exit必须成对调用顺序是先 close device 再 exit context反了会内存泄漏。我一般把这两个句柄包在一个NfcSession类里实现IDisposable用using保证释放。3.2 nfc_modulation 结构体的字段对齐寻卡函数nfc_initiator_select_passive_target需要一个nfc_modulation结构体C 里定义是两个字段nfc_modulation_type和nfc_baud_rate都是枚举底层是 int。C# 侧要保证字段顺序和大小一致[StructLayout(LayoutKind.Sequential)] public struct nfc_modulation { public int nmt; // 调制类型ISO14443A1, ISO14443B2, FELICA3 public int nbr; // 波特率1060, 2121, 4242, 8483 } [DllImport(libnfc.dll, CallingConvention CallingConvention.Cdecl)] public static extern int nfc_initiator_select_passive_target( IntPtr device, nfc_modulation modulation, byte[] baudRate, uint baudRateLen, byte[] targetInfo);LayoutKind.Sequential保证字段按声明顺序排列不加这个特性默认也是 Sequential但显式写出来更稳。nmt和nbr用 int 而不是 enum是为了避免封送时枚举底层类型不一致的问题。targetInfo是一个输出缓冲区用来接收卡片的 UID 和 ATQA/SAK 信息长度一般给 64 字节够用。返回值是卡片数量0 表示没找到卡负数表示错误。常见错误是-6对应NFC_E_TIMEOUT通常是天线没对准或者卡片类型不匹配。3.3 寻卡与 UID 解析的完整调用链把上面的封装串起来一个完整的寻卡流程如下public byte[] PollUid(IntPtr device, int timeoutMs) { var mod new nfc_modulation { nmt 1, nbr 0 }; // ISO14443A, 106kbps var targetInfo new byte[64]; int ret nfc_initiator_select_passive_target( device, mod, null, 0, targetInfo); if (ret 0) return null; // targetInfo 前两字节是 ATQA接着是 SAK再后面是 UID 长度和 UID int uidLen targetInfo[2]; byte[] uid new byte[uidLen]; Array.Copy(targetInfo, 3, uid, 0, uidLen); return uid; }targetInfo的内存布局是 libNFC 内部定义的偏移 0-1 是 ATQA偏移 2 是 SAK偏移 3 开始是 UID 长度偏移 4 开始是 UID 数据。这里我写的是简化版实际偏移要看 libNFC 版本建议用nfc_initiator_target_is_present配合nfc_target结构体来解析更稳。timeoutMs参数在这个函数里其实没用到因为nfc_initiator_select_passive_target是阻塞的超时由 libNFC 内部配置控制要改超时得用nfc_device_set_property_int设置NP_TIMEOUT_COMMAND。这个点很多人踩坑以为传个 timeout 就能控制结果发现卡不在天线区时程序卡死。4. 读写 MIFARE Classic 块数据认证、读块、写块的封装4.1 密钥认证 nfc_initiator_mifare_cmd 的参数细节MIFARE Classic 的读写必须先过密钥认证libNFC 提供nfc_initiator_mifare_cmd一个函数搞定认证、读、写。这个函数的参数比较多封送时容易错[StructLayout(LayoutKind.Sequential)] public struct nfc_iso14443a_info { public byte[] abtAtqa; // 2 字节 public byte btSak; public uint szUidLen; public byte[] abtUid; // 10 字节 public uint szAtsLen; public byte[] abtAts; // 254 字节 } [DllImport(libnfc.dll, CallingConvention CallingConvention.Cdecl)] public static extern bool nfc_initiator_mifare_cmd( IntPtr device, int cmd, // MC_READ0x30, MC_WRITE0xA0, MC_AUTH_A0x60, MC_AUTH_B0x61 byte block, ref nfc_iso14443a_info info);nfc_iso14443a_info里的数组字段必须用ByValArray或者MarshalAs指定大小否则封送时会变成指针。我一般用[MarshalAs(UnmanagedType.ByValArray, SizeConst 2)]这种写法。cmd参数用 int 传对应 libNFC 的nfc_mifare_cmd枚举。认证时block传要访问的块号密钥通过info.abtUid后面的abtAts区域传不对密钥是单独传的在nfc_iso14443a_info里没有密钥字段实际密钥是通过nfc_initiator_mifare_cmd的info参数里的abtAts前 6 字节传的这是 libNFC 的一个设计容易搞混。更稳妥的做法是用nfc_initiator_transceive_bytes自己拼认证命令但那样代码量大。我一般建议先用nfc_initiator_mifare_cmd跑通遇到密钥传递问题时再降级到 transceive。4.2 读块与写块的实际代码读一个块的代码如下public byte[] ReadBlock(IntPtr device, byte block, byte[] key) { var info new nfc_iso14443a_info(); info.abtAtqa new byte[2]; info.abtUid new byte[10]; info.abtAts new byte[254]; Array.Copy(key, 0, info.abtAts, 0, 6); // 密钥放在 abtAts 前 6 字节 // 先认证 if (!nfc_initiator_mifare_cmd(device, 0x60, block, ref info)) throw new Exception(认证失败); // 再读块 if (!nfc_initiator_mifare_cmd(device, 0x30, block, ref info)) throw new Exception(读块失败); byte[] data new byte[16]; Array.Copy(info.abtAts, 0, data, 0, 16); // 读到的数据回填在 abtAts return data; }写块类似把cmd换成0xA0数据先填到info.abtAts前 16 字节。注意 MIFARE Classic 的块 0 是厂商块不可写块 3、7、11 等是尾块存密钥和访问位写错了卡就废了。我一般会在写之前先读一遍尾块确认访问位允许写才动手。另一个坑是认证和读写必须用同一个info结构体实例因为认证成功后 libNFC 会在info里记录会话状态换一个实例就丢了。4.3 超时与重试策略NFC 读写受天线耦合影响很大卡片稍微偏一点就超时。我一般会在 C# 层包一层重试public byte[] ReadBlockWithRetry(IntPtr device, byte block, byte[] key, int maxRetry 3) { for (int i 0; i maxRetry; i) { try { return ReadBlock(device, block, key); } catch (Exception) when (i maxRetry - 1) { Thread.Sleep(50); // 等天线稳定 } } throw new Exception(重试耗尽); }Thread.Sleep(50)这个值不是随便定的PN532 在认证失败后需要一点时间复位射频场50ms 是实测比较稳的下限再短容易连续失败。maxRetry设 3 次再多也没意义卡不在就是不在。如果三次都失败先检查天线是否接好再检查密钥是否正确最后才怀疑代码。5. 避坑与排查C# 调 libNFC 最常见的五个翻车点5.1 DllNotFoundException 但 DLL 明明在目录里现象是运行时抛DllNotFoundException提示找不到 libnfc.dll但你去输出目录看文件确实在。原因通常是依赖链断了libnfc.dll 依赖 libusb-1.0.dlllibusb 又依赖 msvcr100.dll 之类的运行时缺一个就报找不到主 DLL。解决方法是把 libnfc.dll 拖进 Dependencies 工具或者用dumpbin /dependents看依赖把缺的 DLL 一并拷到输出目录。另一个可能是位数不匹配x64 进程加载了 32 位 DLL这种用 CorFlags 看下进程位数就能确认。5.2 nfc_open 返回 NULL 且无错误信息现象是nfc_open返回IntPtr.Zero程序没法继续。原因是 libNFC 默认不输出错误日志nfc_open失败时只返回 NULL。解决方法是先调nfc_init之后设置日志回调或者直接用命令行nfc-list看报错。常见原因有三个连接字符串写错COM 口号不对、驱动没装PN532 需要装 libusb 驱动不是系统自带串口驱动、权限不够Linux 下要 udev 规则Windows 下一般没这问题。我一般先用nfc-list确认硬件层能通再回 C# 调。5.3 寻卡成功但读块一直认证失败现象是nfc_initiator_select_passive_target返回 1说明卡找到了但nfc_initiator_mifare_cmd认证总是 false。原因是密钥不对或者密钥传递位置错了。MIFARE Classic 默认密钥是FF FF FF FF FF FF但很多工牌改过密钥。另外就是我前面说的密钥要放在info.abtAts前 6 字节放错位置认证必失败。解决方法是先用nfc-mfclassic命令行工具试密钥确认密钥正确后再查 C# 封送。如果命令行能读 C# 不能读那就是封送问题重点查nfc_iso14443a_info的数组字段有没有加ByValArray。5.4 程序退出时崩溃或内存泄漏现象是程序关闭时偶尔崩溃或者长时间运行内存持续上涨。原因是nfc_close和nfc_exit没调或者调用顺序反了。libNFC 的 context 里持有设备列表和线程资源不释放就会泄漏。解决方法是用IDisposable模式包住在Dispose里先nfc_close(device)再nfc_exit(context)并且保证只调一次。如果用了多线程还要加锁因为 libNFC 不是线程安全的多个线程同时调nfc_initiator_select_passive_target会出玄学问题。5.5 卡片类型不匹配导致超时现象是nfc_initiator_select_passive_target返回 -6超时但卡片确实放在天线上了。原因是调制类型设错了比如卡片是 ISO14443-B 但你设了 ISO14443-A。解决方法是先用nfc-list看卡片返回的 ATQA 和 SAK根据 SAK 判断卡类型。ISO14443-A 的 SAK 常见值是 0x08、0x18、0x28ISO14443-B 的 SAK 是 0x00 但 ATQA 不同。如果拿不准就把nmt依次设成 1、2、3 各试一次哪个能通就用哪个。这个排查方法虽然笨但比查文档快。6. 进阶把 NFC 操作封装成可复用的 .NET 服务6.1 用依赖注入管理 NfcService 生命周期在实际项目里我不会把IntPtr到处传而是封一个INfcService接口用 DI 容器注册成单例。这样做的原因是 libNFC 的 context 初始化开销大而且多线程不安全单例加锁是最简单的线程安全方案。接口定义大概是这样public interface INfcService : IDisposable { byte[] PollUid(int timeoutMs); byte[] ReadBlock(byte block, byte[] key); void WriteBlock(byte block, byte[] key, byte[] data); }实现类里持有一个object _lock每个公开方法都lock (_lock)。PollUid内部用轮询而不是阻塞因为阻塞式寻卡在 UI 线程上会卡界面。轮询的实现是设一个短超时循环调用直到找到卡或外部取消。NP_TIMEOUT_COMMAND设成 100ms 比较合适太短会频繁超时太长响应慢。6.2 用事件驱动替代轮询轮询的缺点是 CPU 占用高而且响应有延迟。更好的做法是用 libNFC 的nfc_initiator_poll_target配合回调但那个函数在 C# 里封送比较麻烦涉及函数指针。我一般用Task.Run在后台线程跑轮询找到卡后触发CardDetected事件UI 线程订阅事件更新界面。这样既避免了 UI 卡顿又不用碰函数指针。事件参数里带上 UID 和卡片类型订阅方根据 UID 做业务逻辑。6.3 验证封装是否正确的三个测试写完封装后我一般跑三个测试确认没问题。第一个是空场测试不放假卡调PollUid应该返回 null 而不是抛异常确认超时处理正确。第二个是读 UID 测试放一张已知 UID 的卡确认读出来的 UID 和nfc-list输出一致。第三个是读写一致性测试往一个可写块写 16 字节随机数再读回来比对确认读写链路完整。这三个测试跑通基本就能上业务了。从那以后我每次封装新的 native 库都强制走一遍「空场-已知输入-读写回环」这三步能省掉大量联调时的扯皮。希望帮到你。本文还有配套的精品资源点击获取