☰
C# RFID读卡器上位机开发:串口指令解析与M1扇区读写实战
2026/10/12 2:36:02 网站建设 项目流程

简介:一套基于C#的RFID卡识别与读写源码包,面向需要掌握硬件交互与串口通信的中级C#开发者,适合门禁、物流跟踪、商品防伪等场景。源码包共29个文件,压缩后约66KB,核心包含6个.cs源码文件,如Form1.cs、Program.cs等,覆盖窗体设计、RFID读写逻辑;另含项目配置文件(csproj、sln、suo)、资源文件(resx、resources)及编译输出(exe、pdb)等,整体结构完整,可直接打开调试。项目围绕RFID读写器展开,演示了SerialPort串口连接、事件驱动监听、EPC Gen2数据解析、标签读写操作及异常处理等关键环节,并将硬件交互封装为可维护模块,便于后续替换设备或扩展业务。已有4660人学习下载,适合作为C#与RFID结合开发的入门参考和项目模板,帮助快速理解从设备通信到界面展示的完整链路。

1. C#与RFID卡读写:串口背后不是读卡号,是一整套射频协议

做C#的RFID卡识别和读写上位机,最容易踩空的是期望打开串口立刻读到卡号,实际收到的是必须按帧拆解的字节流。射频场里同时出现两张卡时,冲突位还会返回特殊状态码,这跟C#语法没有半点关系。我在某公司的资产盘点需求中,第一周的时间几乎全耗在指令帧格式和卡片结构上,界面只花了一个下午。这个标题真正的重心,恰恰是串口通讯、指令封装和扇区读写的组织方式。

这套方案能解决的问题很具体:门禁授权、产线工位绑定、设备巡检打卡、会员储值卡读写、物资出入库登记。读写器通过USB虚拟串口与PC连接,C#上位机按厂商协议发送指令,读写器返回卡号或扇区数据。低频125kHz只读卡负责识别身份,高频13.56MHz的M1卡则是一块可读写的存储介质。

适合已经有C#基础、刚接触设备通讯的开发者,也适合被RFID厂商文档里的命令字和块地址绕晕的熟手。文章不依赖特定厂商SDK,用通用串口指令就能跑通完整流程。

2. 弄懂RFID卡和读写器:先定卡片类型,再定串口参数

这一步最容易被跳过。标题里的RFID是一类技术的统称,不同频段和卡片类型对应完全不同的指令集和存储能力。先在纸上确认卡片类型,再决定读写器与C#侧的串口参数,后面返工的概率会小很多。我见过不少项目代码写到一半才发现卡买错了,只能整套换硬件。

2.1 低频ID卡与高频M1卡:先搞清楚你要读的还是写的

把卡片分成两大类:低频125kHz只读卡,业内通常叫ID卡;高频13.56MHz可读写卡,也就是最常见的M1卡。ID卡的芯片里没有可重新规划的用户数据区,读出来的就是一个出厂固化的序列号,通常4字节,转换成十六进制或十进制后就是门禁系统里的卡号。M1卡内部有多个扇区,每个扇区又分成数据块和控制块,C#程序可以往数据块里写入余额、有效期、巡检点编号这些业务数据。

对比项低频ID卡高频M1卡
工作频率125kHz13.56MHz
存储能力只读序列号,约4字节可读写,常见1024字节
典型场景门禁、考勤、工牌储值、巡检、防伪、资产标签
C#侧工作复杂度读卡号即可需要处理扇区、密钥、块地址

选型依据很清楚:只要识别身份,低频就够,成本低、兼容性也好;要往卡里存数据,必须上高频M1。后面涉及写卡的指令、密钥和块地址,全部建立在M1卡的架构上。确定类型后再买读写器,否则读卡器频率不匹配,代码写得再对也没有射频响应。

2.2 读写器串口参数:波特率、数据位与虚拟COM口对齐

市面上的PC端读写器大多是USB转串口方案,插上后系统里出现一个虚拟COM口,C#程序使用SerialPort类操作它。少数网口读写器则用TcpClient处理,但串口更常见。启动前把波特率、数据位、停止位、校验位四项参数与读写器固件配置对齐,否则收发一直超时。我自己在应急时拿通用波特率扫描器探出过正确值,反复试了好几天,这个教训不便宜。

using System.IO.Ports; public class RfidSerialPort { private SerialPort _port; public bool Connect(string portName, int baudRate = 9600) { _port = new SerialPort { PortName = portName, BaudRate = baudRate, DataBits = 8, Parity = Parity.None, StopBits = StopBits.One, ReadTimeout = 500, WriteTimeout = 500 }; _port.Open(); return _port.IsOpen; } }

这段代码把常用项设成了默认值:数据位8、停止位1、无校验,绝大多数读卡器固件使用这个组合。波特率不是固定的,有的读写器默认9600,有的默认19200或115200,必须对照读写器配置说明,在代码里写成配置项而不是写死。ReadTimeout与WriteTimeout各500毫秒,为了防止读写器长时间无响应时程序卡死。

连接成功后把端口名、波特率、卡片类型记入配置文件,下次启动直接读取,减少人为输错的可能。USB转串口的驱动装好后,还要去设备管理器确认实际分配的COM号,拔插顺序变化会导致COM号漂移,程序要提供手动刷新端口列表的入口。

2.3 C#串口通信骨架:缓冲接收、拼帧与超时控制

串口数据是按字节流到达的,一次DataReceived事件的缓冲区里可能只有半个帧,也可能包含好几个帧。直接在事件里按固定长度解析,是很多新手翻车的地方。我会维护一个List 作为接收缓冲,每次有数据就追加进去,再按帧头、长度字段尝试切出完整帧。

private readonly List<byte> _buffer = new List<byte>(); private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { int available = _port.BytesToRead; byte[] chunk = new byte[available]; _port.Read(chunk, 0, available); _buffer.AddRange(chunk); TryParseFrames(); } private void TryParseFrames() { while (_buffer.Count >= 6) { int headIndex = _buffer.IndexOf(0xAA); // 定位帧头 if (headIndex < 0) { _buffer.Clear(); return; } if (headIndex > 0) _buffer.RemoveRange(0, headIndex); // 丢弃帧头前的噪声 int length = _buffer[1]; // 第二个字节通常表示长度 if (_buffer.Count < length + 2) return; // 数据不足,等下一包 byte[] frame = _buffer.Take(length + 2).ToArray(); if (VerifyFrame(frame)) { ProcessFrame(frame); _buffer.RemoveRange(0, length + 2); } else { _buffer.RemoveAt(0); // 校验失败,去掉一个字节重试 } } }

这是整个上位机最值得先封装好的骨架。IndexOf(0xAA)用于跳过干扰字节,length字段用来判断帧是否完整,VerifyFrame负责末字节校验。常见错误是省略缓冲、直接在事件里按偏移解析,遇到连续数据包时大概率错位。拼帧逻辑跑通后,后面所有读卡、写卡、认证指令都复用这一个入口。

提示:不同读写器的帧头、长度字段位置和校验算法都不一样。接到新读写器时,先用串口助手抓一条完整返回帧,对照文档把帧头索引和长度偏移改掉,再套用上述骨架。

另一个容易忽略的点是传输层之外的状态字节。很多读写器在固定偏移处放一个状态字,表示本次命令是否执行成功。解析卡号前必须先判断状态字,否则会把错误码当成卡号处理。

3. 从串口到卡号:指令封装、防碰撞与返回帧解析

读取卡号本质上是一个问答过程:C#发送一条寻卡指令,读写器进入射频场执行防碰撞,然后把一张卡的序列号返回。防碰撞由读写器硬件或固件完成,但C#侧要理解冲突状态码的含义,否则批量刷卡场景里会出现偶发读不到卡的现象。

3.1 寻卡指令的构造:从命令字到Byte数组

读写器指令帧是私有协议,但封装思路一致:帧头、长度、命令字、数据、校验。命令字决定操作类型,例如0x25代表寻卡,0x27代表读块。以一套简化示例协议为例,构造寻卡指令的C#代码如下。

public static byte[] BuildFindCardCommand() { // 简化协议示例,实际命令字以读写器文档为准 byte[] frame = new byte[6]; frame[0] = 0xAA; // 帧头 frame[1] = 0x03; // 数据段长度,示例固定为3 frame[2] = 0x25; // 寻卡命令字 frame[3] = 0x00; // 预留参数,通常填0 frame[4] = 0x01; // 连续寻卡标志,0=单次 1=循环 frame[5] = (byte)(frame[0] ^ frame[1] ^ frame[2] ^ frame[3] ^ frame[4]); // 校验 return frame; }

参数说明:frame[1]是长度字段,并非恒为3,需要按实际数据长度计算;frame[2]是命令字,不同厂商用不同值;frame[5]是简化异或校验,部分读写器使用CRC16。写代码时把帧结构定义成常量,别在调用处散落裸的数字。

代码里只体现一个通用套路:先把数据段拼出来,再把长度、校验补齐。这样后续增加写块、认证等命令时,只需更换命令字和数据段,不需要重写帧构造逻辑。

拿到真机后,第一件事是用官方演示工具执行一次读卡,用串口助手抓取请求与返回的十六进制字节,和文档逐位核对。这套习惯能省掉一半以上的调试时间。

3.2 解析返回帧:确认状态、提取卡号并转成Hex字符串

寻卡成功后读写器返回状态字与卡号。常见布局是:帧头、长度、状态字、4字节卡号、校验。C#解析时先做三次检查:帧头是否正确、长度是否等于预期、校验是否一致,全部通过后才提取数据。

public static string ParseCardNumber(byte[] frame) { byte status = frame[2]; // 状态字位置,按实际协议调整 if (status != 0x00) return string.Empty; // 非成功状态,交给上层处理 if (frame.Length < 8) throw new InvalidDataException("帧长度不足,无法解析卡号"); byte[] cardNumber = new byte[4]; Array.Copy(frame, 3, cardNumber, 0, 4); // 卡号起始偏移按文档调整 return Convert.ToHexString(cardNumber); }

Convert.ToHexString是.NET 5以后提供的方法,返回大写十六进制字符串,避免手写StringBuilder拼接时出现大小写不一致。卡号字节序因厂商而异,有的返回正序,有的反序。如果读出来的卡号和发卡器显示不一致,调换字节顺序即可。

状态字不等于卡号内容,很多协议里状态字留在固定偏移处。如果你发现返回卡号首字节有时候是0x00,而状态字节被塞进了相同位置,大概率是偏移没有对齐。解析逻辑写好后,留一个打印原始帧的开关,排查时能直接看到收发字节。

3.3 多卡同场:防碰撞与冲突状态码在C#侧的处理

射频场里同时出现多张卡时,读写器会处于冲突状态。高频M1卡通常有防碰撞流程,由读写器固件或RFID芯片完成;低频ID卡部分读写器返回冲突错误码。C#侧需要做的不是实现防碰撞算法,而是识别状态码并重试。

for (int retry = 0; retry < 3; retry++) { byte[] response = SendReceive(BuildFindCardCommand()); if (!HasValidFrame(response)) { Thread.Sleep(20); continue; } if (response[2] == 0x00) // 状态字成功 return ParseCardNumber(response); if (response[2] == 0x04) // 冲突标志 { // 提示操作者移开遮挡卡片或分批次刷卡 Thread.Sleep(50); continue; } } return string.Empty;

这里的0x04只是示例,真实状态字定义看厂商文档。重点是重试机制:防碰撞冲突是概率性事件,射频场内卡片位置和角度都会影响,重试两三次通常就能拿到卡号。

还有一个隐蔽点:批量巡检时同一张卡可能连续触发多次读卡,如果不做去重,巡检记录会重复。判断逻辑可以在卡号读取后加一层最近30秒缓存,重复卡号直接跳过。

4. 用C#写数据:M1卡扇区认证、块地址与数据缓冲区设计

识别卡号只是第一步。把业务数据写进M1卡,需要先弄懂扇区、块和密钥之间的关系,然后把数据块映射成C#字节数组,按块对齐写入并回读验证。这部分最容易产生返工,我见过因为块地址算错而把扇区锁死的案例,所以会先把存储结构讲清楚再给代码。

4.1 M1卡存储结构:扇区、块地址、密钥A/B与控制块

M1卡常见容量是1024字节,分成16个扇区,每个扇区4个块,每个块16字节。扇区编号0到15,块编号0到3。扇区内的第3块是控制块,存放密钥A、访问控制位、密钥B。数据只能写在块0、块1、块2,块3一旦写错可能永久改变扇区访问权限。

扇区号绝对块地址块类型可写性
00-30是厂商块,1-2数据,3控制厂商块建议只读
1-154-630-2数据,3控制数据块可读写,控制块谨慎

M1的绝对块地址公式是 sector * 4 + block,其中控制块地址为 sector * 4 + 3。C#里把块地址计算封装成函数,避免在业务代码里反复写乘法。

public static int CalcBlockAddress(int sector, int block) { if (sector < 0 || sector > 15) throw new ArgumentOutOfRangeException(nameof(sector), "扇区范围0-15"); if (block < 0 || block > 3) throw new ArgumentOutOfRangeException(nameof(block), "块范围0-3"); return sector * 4 + block; }

这个函数的价值在于强制校验参数。楼层号、卡号等业务字段很容易被写进块3,一旦控制块被写入错误内容,扇区权限结构就被破坏,轻则读不回数据,重则认为整张卡报废。

提示:不要把扇区控制信息写进业务文档。写卡程序启动时先读一次控制块内容,确认访问控制位是可读可写,再执行写数据操作。

4.2 扇区认证与读块:用默认密钥把数据读回来

M1卡出厂默认密钥是六个字节的全F,即FFFFFFFFFFFF,扇区认证通过后才有权限访问数据块。常见的认证流程是C#发送认证指令,携带扇区号和密钥,等待成功响应后再发送读块指令。

public byte[] ReadBlock(int sector, int block) { int blockAddr = CalcBlockAddress(sector, block); byte[] authCmd = BuildAuthCommand(sector, DefaultKeyA); // 认证指令 byte[] authResp = SendReceive(authCmd); if (authResp[2] != 0x00) // 认证失败 throw new InvalidOperationException("扇区认证失败,请检查密钥"); byte[] readCmd = BuildReadCommand(blockAddr); byte[] readResp = SendReceive(readCmd); byte[] data = new byte[16]; Array.Copy(readResp, 5, data, 0, 16); return data; }

这个函数把两步操作合并成一个业务接口。参数说明:sector和block是逻辑编号,BuildAuthCommand内部完成扇区到密钥对应关系的映射;返回值固定16字节,数组复制后即为块内容。认证指令的响应必须先行处理,不能跳过。

注意:读取扇区0的块0会得到厂商信息,卡号一般在这块的前4字节。部分项目把它当成默认卡号来源,但写入时绝不能用块0,厂商信息区在多数读写器上限制写入。

4.3 写数据块:块对齐、密钥管理与写后读回验证

写块与读块流程类似,先认证后写,写成功后回读对比。M1块长度固定16字节,业务数据不足16字节时补0x00,超出时拆成多块。C#侧用字节数组组织数据,Buffer.BlockCopy比循环赋值更高效。

public bool WriteBlock(int sector, int block, byte[] payload) { if (payload.Length != 16) throw new ArgumentException("块数据必须为16字节"); // 1. 认证扇区,确保有写权限 byte[] authResp = SendReceive(BuildAuthCommand(sector, DefaultKeyA)); if (authResp[2] != 0x00) return false; // 2. 发送写块指令 byte[] writeResp = SendReceive(BuildWriteCommand(CalcBlockAddress(sector, block), payload)); if (writeResp[2] != 0x00) return false; // 3. 回读验证 byte[] readBack = ReadBlock(sector, block); return readBack.SequenceEqual(payload); }

参数说明:payload必须是16字节数组,多字节字段按业务约定顺序放到固定偏移。比如日期字段占前4字节、余额字段占后8字节,这样的布局方便后续用BitConverter转换。

数据缓冲区规划建议:在项目里维护一张块地址分配表,例如扇区1的块0存卡号索引、块1存最近巡检时间、块2存巡检次数。同一张卡复用多个项目时,分配表能避免不同业务互相覆盖。

密钥管理是我特别想提醒的事:默认密钥全F在生产环境中尽快改掉。修改密钥需要写控制块,中间涉及访问控制位,建议先用一张测试卡验证整条流程,再批量操作正式卡。写严格一点,可以把密钥放在配置文件中加密存储,代码里不要出现硬编码明文密钥的长期方案。

5. 避坑排查:RFID上位机的六条翻车记录与解决

这个章节来自实际调试中最常翻车的场景,按现象、原因、解决三段式记录。如果你在某一条上卡了很久,大概率能在这里对上号。

5.1 写卡操作翻车:认证失败、块地址错位与控制块写坏

第一条,现象是写块返回成功,回读数据却全部是0。原因通常是块地址算错,把数据写到了未认证的其它扇区;或者写卡指令里块地址字节顺序颠倒。解决方法是先打印完整指令帧和响应帧,核对块地址是否等于sector*4+block,再把写卡后的读回结果和原始payload逐字节对比。不要只看写卡命令返回的单个状态字,回读才是真正的检验。

第二条,现象是扇区认证一直返回错误码。原因多半是密钥不对,部分卡片出厂时密钥被修改过,或者程序配置的密钥与卡内实际密钥不一致。解决方法是先用默认全F尝试,失败后尝试读写器厂商支持的二次密钥,或者直接换一张新卡测试。批量购买卡片时要求供货商统一出厂密钥,避免一张张修改。

第三条,现象是卡还能读,但数据再写不进去,读写器返回权限错误。原因是误写了控制块,把访问控制位改成了只读。这个操作目前没有后悔药,唯一办法是换卡。程序层面做保护:写数据前检查块地址除以4的余数,余数等于3时直接抛出异常,禁止写控制块。

5.2 串口与协议翻车:帧分片、字节符号与波特率错配

第四条,现象是数据接收正确率低,经常解析出错,偶尔正确。原因是直接在DataReceived事件里按固定长度读取,把分片数据当成完整帧处理。解决方法是使用前面给出的缓冲拼帧逻辑,数据不足时等待下一次事件,不做强制解析。串口是流不是消息,这个认知差异是大部分解析问题的根源。

第五条,现象是帧校验代码结果总是在预期值附近跳动。原因是把byte转成int后做了有符号运算,导致校验字节变成负值。解决方法是校验计算全程使用byte与ushort,异或或加法都在无符号域完成。C#里数组元素本来就是byte,保持类型不变即可避免隐式转换。

第六条,现象是程序收发完全无响应,等半天没有返回。原因是波特率与读写器不一致,读卡器收不到可识别起始位。解决方法是先用官方演示工具确认端口和波特率,或者用串口助手扫描常用波特率,不要在没有抓包的情况下重复调试代码。硬件链路没通,协议写得再对也白费。

6. 从Demo到可靠:CRC校验、冲突重试与全扇区回读

前面章节把读卡和写卡的主流程跑通了,但生产环境还差一道可靠性加固。关键是三件事:帧校验、冲突重试和全扇区回读验证。

6.1 CRC16帧校验的C#实现

前文示例用的异或校验适合调试环境,生产环境建议换成CRC16,尤其高频M1卡数据损坏时,帧校验能及早发现。以下是CRC16-CCITT的实现,多项式0x1021,初值0xFFFF。

public static ushort Crc16(byte[] buffer) { ushort crc = 0xFFFF; foreach (byte b in buffer) { crc ^= (ushort)(b << 8); for (int bit = 0; bit < 8; bit++) { crc = (crc & 0x8000) != 0 ? (ushort)((crc << 1) ^ 0x1021) : (ushort)(crc << 1); } } return crc; }

参数说明:buffer是除CRC字段外的帧字节,返回值通常追加在帧尾。不同读写器对CRC字节顺序要求可能不同,抓一条官方助手返回的完整帧对比后,把高字节或低字节放到对应位置即可。

6.2 冲突重试与全扇区回读

批量读写场景里,我在读卡命令外层加统一的重试与日志层。重试次数3次、间隔50毫秒,日志记录每条指令的收发明文,回读失败时能快速定位是射频问题还是协议问题。全扇区回读是最终的验证手段:把16个扇区全部读一遍,对比已分配数据块是否和预期一致,未分配块检查是否全0或全0xFF。

我现在接手RFID上位机项目,第一件事不是写代码,而是拿官方的PC测试工具把波特率、卡片类型、扇区密钥全部核对一遍,再抓一条读卡指令的原始字节存档。这个习惯帮我省掉了大量查错时间。项目从Demo到可交付,差别就在这些细节里,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询