例源碼:硬件通信與協(xié)議解析實(shí)戰(zhàn))
簡介這份C# IC卡讀寫實(shí)例源碼面向具備一定C#基礎(chǔ)的開發(fā)者聚焦通過PC/SC接口與讀卡器硬件交互解決智能卡數(shù)據(jù)讀取與寫入的實(shí)際問題。資源包共49個文件約1023KB以13個cs源碼文件為核心配合9個dll依賴庫、5個resx與5個resources資源文件以及sln解決方案、csproj工程、mdb數(shù)據(jù)庫和exe可執(zhí)行程序構(gòu)成一套可直接編譯運(yùn)行的完整示例工程。源碼圍繞ISO 7816協(xié)議展開演示了查找讀卡器、建立連接、選卡、發(fā)送APDU命令、解析響應(yīng)及斷開連接等關(guān)鍵環(huán)節(jié)并涉及SELECT、READ BINARY、UPDATE BINARY等典型指令的調(diào)用方式。目前已有737人學(xué)習(xí)下載適合希望快速理解智能卡通信流程、掌握PCSC-sharp庫用法或需要硬件讀寫排錯思路的開發(fā)者參考也可作為企業(yè)員工IC卡類項(xiàng)目的起步模板。1. C# IC卡讀寫實(shí)例源碼從“能讀到卡號”到“能穩(wěn)定跑在現(xiàn)場”很多人第一次做 C# IC卡讀寫卡在的不是 C# 語法而是“讀卡器到底在跟誰說話”。我見過太多上位機(jī)項(xiàng)目界面做得漂漂亮亮結(jié)果現(xiàn)場一插 USB 讀卡器要么找不到端口要么讀出來一堆亂碼要么今天能讀明天就翻車。這個標(biāo)題——C# IC卡讀寫 實(shí)例源碼硬件讀寫——說的就是一件很具體的事用 C# 寫一個上位機(jī)程序通過讀卡器硬件把 IC 卡里的數(shù)據(jù)讀出來、寫進(jìn)去并且這套代碼能直接拿去改、拿去用。它適合兩類人一類是剛接手上位機(jī)項(xiàng)目的 C# 開發(fā)者需要一份能跑通的硬件讀寫骨架另一類是做門禁、考勤、消費(fèi)機(jī)、會員系統(tǒng)的工程師想把讀卡這一環(huán)從“玄學(xué)”變成“可控”。核心不是背 API而是搞清楚三件事讀卡器走什么協(xié)議、卡是什么類型、C# 這邊怎么把字節(jié)流翻譯成業(yè)務(wù)數(shù)據(jù)。把這三件事理順實(shí)例源碼才有意義否則復(fù)制過去也只是換個地方報(bào)錯。2. 先分清讀卡器和卡選錯協(xié)議代碼寫得再漂亮也白搭2.1 常見 IC 卡讀寫硬件與通信方式做 C# IC卡讀寫之前先確認(rèn)你手里那臺讀卡器屬于哪一類。市面上常見的硬件讀寫方案按通信方式大致分四種USB 免驅(qū) HID、USB 轉(zhuǎn)串口虛擬 COM、串口 RS232/RS485、網(wǎng)絡(luò) TCP。它們對 C# 代碼的影響完全不同。USB 免驅(qū) HID 類讀卡器插上電腦后系統(tǒng)識別成鍵盤或 HID 設(shè)備C# 側(cè)往往不需要打開串口而是監(jiān)聽鍵盤輸入或調(diào)用廠商 DLL。這種最省事但也最受限因?yàn)閿?shù)據(jù)格式被廠商固化了。USB 轉(zhuǎn)串口和原生串口是 C# 上位機(jī)里最常見的方案用SerialPort類就能打開波特率、數(shù)據(jù)位、校驗(yàn)位這些參數(shù)必須和讀卡器手冊一致。網(wǎng)絡(luò)讀卡器則走 TCPC# 用TcpClient或TcpListener收發(fā)字節(jié)。提示不要憑感覺猜通信方式。先看設(shè)備管理器里讀卡器顯示成什么再看廠商手冊里的“通信協(xié)議”章節(jié)這一步省不得??ㄟ@邊也要分清。標(biāo)題里說的是 IC 卡通常指符合 ISO/IEC 7816 或 ISO/IEC 14443 的卡片。常見的有 M1 卡Mifare Classic、CPU 卡、NFC 標(biāo)簽。M1 卡讀寫相對簡單有扇區(qū)和密鑰的概念CPU 卡更復(fù)雜涉及 APDU 指令。很多“IC卡讀寫實(shí)例源碼”之所以跑不起來就是因?yàn)樵创a針對的是 M1 卡而讀者手里拿的是 CPU 卡指令集根本不一樣。2.2 用 C# 打開串口讀卡器的最小可運(yùn)行骨架假設(shè)你手里是一臺 USB 轉(zhuǎn)串口的讀卡器廠商手冊寫明波特率 96008 位數(shù)據(jù)位1 位停止位無校驗(yàn)主動讀卡時返回 10 字節(jié)數(shù)據(jù)前 2 字節(jié)是幀頭最后 1 字節(jié)是校驗(yàn)。下面這段代碼就是一個最小可運(yùn)行骨架先保證能打開端口、收到數(shù)據(jù)。using System; using System.IO.Ports; class IcCardReader { private SerialPort _port; public bool Open(string portName) { _port new SerialPort(portName) { BaudRate 9600, // 必須與讀卡器手冊一致 DataBits 8, StopBits StopBits.One, Parity Parity.None, ReadTimeout 500, // 讀超時避免界面卡死 WriteTimeout 500 }; _port.DataReceived OnDataReceived; _port.Open(); return _port.IsOpen; } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { int len _port.BytesToRead; byte[] buffer new byte[len]; _port.Read(buffer, 0, len); // 這里先原樣打印確認(rèn)硬件確實(shí)在發(fā)數(shù)據(jù) Console.WriteLine(BitConverter.ToString(buffer)); } public void Close() { if (_port ! null _port.IsOpen) _port.Close(); } }這段代碼的邏輯很直白構(gòu)造SerialPort時把波特率、數(shù)據(jù)位、停止位、校驗(yàn)位設(shè)成手冊值綁定DataReceived事件打開端口。收到數(shù)據(jù)后先不解析直接以十六進(jìn)制打印。參數(shù)里ReadTimeout和WriteTimeout很關(guān)鍵現(xiàn)場調(diào)試時如果讀卡器沒響應(yīng)沒有超時設(shè)置界面就會一直等用戶以為死機(jī)了。注意DataReceived事件在后臺線程觸發(fā)如果你要更新 WinForm 或 WPF 界面必須用Invoke或Dispatcher切回 UI 線程否則會報(bào)跨線程訪問異常。2.3 幀解析把字節(jié)流變成卡號能收到數(shù)據(jù)只是第一步接下來要把字節(jié)流按協(xié)議拆開。假設(shè)手冊定義幀頭固定0x02 0x00第 3 到第 6 字節(jié)是卡號小端序第 10 字節(jié)是前面 9 字節(jié)的異或校驗(yàn)。解析代碼可以這樣寫private byte[] _frameBuffer new byte[10]; private int _frameIndex 0; private void ParseFrame(byte[] data) { foreach (byte b in data) { // 先找?guī)^再攢滿一幀 if (_frameIndex 0 b ! 0x02) continue; _frameBuffer[_frameIndex] b; if (_frameIndex 10) { if (CheckXor(_frameBuffer)) { // 小端序拼卡號 uint cardId (uint)(_frameBuffer[2] | (_frameBuffer[3] 8) | (_frameBuffer[4] 16) | (_frameBuffer[5] 24)); Console.WriteLine($卡號: {cardId}); } _frameIndex 0; // 無論校驗(yàn)是否通過都重置避免卡死 } } } private bool CheckXor(byte[] frame) { byte xor 0; for (int i 0; i 9; i) xor ^ frame[i]; return xor frame[9]; }這里有幾個容易翻車的點(diǎn)。第一串口數(shù)據(jù)不是一幀一幀來的可能一次收到半幀也可能一次收到兩幀所以必須用緩沖區(qū)攢幀不能假設(shè)Read一次就是完整幀。第二幀頭判斷要放在攢幀之前否則數(shù)據(jù)錯位后永遠(yuǎn)對不上。第三校驗(yàn)失敗也要重置索引不然緩沖區(qū)會一直卡在錯誤狀態(tài)。參數(shù)上_frameBuffer長度必須和協(xié)議幀長一致卡號字節(jié)序要按手冊確認(rèn)有的廠商用大端序拼出來就是另一個數(shù)。3. 硬件讀寫實(shí)例源碼從讀卡號到寫數(shù)據(jù)把流程跑通3.1 讀卡號、讀扇區(qū)、寫扇區(qū)的代碼分層一份能用的 C# IC卡讀寫實(shí)例源碼不應(yīng)該把所有邏輯塞在一個Form1.cs里。我一般會分成三層通信層負(fù)責(zé)打開端口、收發(fā)字節(jié)協(xié)議層負(fù)責(zé)組幀、解幀、校驗(yàn)業(yè)務(wù)層負(fù)責(zé)卡號、扇區(qū)、密鑰這些業(yè)務(wù)概念。這樣換一款讀卡器時只需要改通信層和協(xié)議層業(yè)務(wù)層不動。以 M1 卡為例讀卡號通常是一條指令讀扇區(qū)是另一條指令寫扇區(qū)又是另一條。下面是一個協(xié)議層的指令封裝示例假設(shè)手冊定義讀卡號指令為0x02 0x10 0x00 0x00 0x12讀扇區(qū)指令為0x02 0x20 扇區(qū)號 密鑰類型 0x00 校驗(yàn)。public static class CardCommand { // 讀卡號指令固定幀 public static byte[] ReadCardId() { return new byte[] { 0x02, 0x10, 0x00, 0x00, 0x12 }; } // 讀扇區(qū)sector 0-15keyType 0KeyA 1KeyB public static byte[] ReadSector(byte sector, byte keyType) { byte[] cmd new byte[6]; cmd[0] 0x02; cmd[1] 0x20; cmd[2] sector; cmd[3] keyType; cmd[4] 0x00; cmd[5] ComputeXor(cmd, 5); return cmd; } private static byte ComputeXor(byte[] data, int len) { byte xor 0; for (int i 0; i len; i) xor ^ data[i]; return xor; } }這段代碼的價(jià)值在于把“指令”和“通信”分開。ReadCardId返回固定幀ReadSector根據(jù)扇區(qū)和密鑰類型動態(tài)組幀最后一位校驗(yàn)用異或算出來。參數(shù)sector的范圍取決于卡片容量M1 卡通常是 0 到 15寫錯會返回錯誤碼。keyType決定用 KeyA 還是 KeyB 驗(yàn)證默認(rèn)密鑰通常是FF FF FF FF FF FF但現(xiàn)場卡往往改過密鑰讀不到就要先問清楚。3.2 寫數(shù)據(jù)時的密鑰驗(yàn)證與塊地址寫扇區(qū)比讀扇區(qū)多一步必須先驗(yàn)證密鑰再寫數(shù)據(jù)。很多實(shí)例源碼只貼了寫指令沒貼驗(yàn)證流程結(jié)果讀者一跑就返回“密鑰錯誤”。正確的順序是選卡、驗(yàn)證密鑰、寫塊、斷電或復(fù)位。下面是一個寫塊的封裝示例。public static byte[] WriteBlock(byte block, byte[] data16) { if (data16.Length ! 16) throw new ArgumentException(M1 卡每塊必須 16 字節(jié)); byte[] cmd new byte[22]; cmd[0] 0x02; cmd[1] 0x30; // 寫塊指令 cmd[2] block; // 塊地址不是扇區(qū)號 Array.Copy(data16, 0, cmd, 3, 16); cmd[21] ComputeXor(cmd, 21); return cmd; }這里最容易踩的坑是“塊地址”和“扇區(qū)號”混用。M1 卡每個扇區(qū)有 4 個塊扇區(qū) 0 對應(yīng)塊 0 到 3扇區(qū) 1 對應(yīng)塊 4 到 7以此類推。如果你把扇區(qū)號直接當(dāng)塊地址傳進(jìn)去寫的就是錯誤位置輕則寫不進(jìn)去重則把廠商塊寫壞。參數(shù)data16必須是 16 字節(jié)不足要補(bǔ)零超過要截?cái)噙@個校驗(yàn)不能省。提示寫操作之前最好先讀一次目標(biāo)塊確認(rèn)當(dāng)前內(nèi)容再決定是否覆蓋?,F(xiàn)場調(diào)試時我習(xí)慣把讀到的原始十六進(jìn)制打印出來方便對照。3.3 用狀態(tài)機(jī)管理“選卡—驗(yàn)證—讀—寫”流程硬件讀寫不是單次調(diào)用而是一個有順序的流程。如果直接在按鈕事件里寫一長串Send和Sleep代碼會很難維護(hù)而且容易因?yàn)樽x卡器響應(yīng)慢而翻車。我一般用一個簡單的狀態(tài)機(jī)來管理空閑、等待選卡、等待驗(yàn)證、等待讀、等待寫。每個狀態(tài)收到響應(yīng)后切換到下一個狀態(tài)超時就回到空閑。enum CardState { Idle, WaitSelect, WaitAuth, WaitRead, WaitWrite } private CardState _state CardState.Idle; private void OnFrameReceived(byte[] frame) { switch (_state) { case CardState.WaitSelect: if (frame[1] 0x10 frame[2] 0x00) _state CardState.WaitAuth; break; case CardState.WaitAuth: if (frame[1] 0x20 frame[2] 0x00) _state CardState.WaitRead; break; case CardState.WaitRead: // 處理讀到的塊數(shù)據(jù) _state CardState.Idle; break; } }狀態(tài)機(jī)的好處是每個狀態(tài)只關(guān)心自己該等的響應(yīng)收到不符合預(yù)期的幀就忽略或報(bào)錯不會因?yàn)橐淮蝸y序數(shù)據(jù)把整個流程帶偏。參數(shù)上每個狀態(tài)最好配一個超時定時器比如 500 毫秒沒收到響應(yīng)就復(fù)位到Idle并提示“讀卡超時”?,F(xiàn)場經(jīng)驗(yàn)是讀卡器偶爾會因?yàn)榭ㄆ苿犹於鴣G幀有超時復(fù)位比死等強(qiáng)得多。4. 避坑與排查C# IC卡讀寫現(xiàn)場最常見的 5 個翻車點(diǎn)4.1 現(xiàn)象串口打開成功但一條數(shù)據(jù)都收不到原因通常有三個一是波特率或校驗(yàn)位和讀卡器不一致雖然端口能打開但數(shù)據(jù)全是亂碼或直接丟棄二是讀卡器其實(shí)走 HID 模式被系統(tǒng)識別成鍵盤根本不往串口發(fā)數(shù)據(jù)三是DataReceived事件沒綁定或者綁定了但Read時BytesToRead為 0。解決方法是先用廠商自帶的調(diào)試工具確認(rèn)硬件能正常讀卡再用 C# 打開同一個端口把DataReceived里收到的原始字節(jié)打印出來。如果廠商工具能讀、C# 讀不到優(yōu)先檢查波特率和事件綁定。如果廠商工具也讀不到那就是硬件或驅(qū)動問題跟 C# 代碼無關(guān)。4.2 現(xiàn)象卡號讀出來是反的或者多兩位少兩位原因是字節(jié)序和幀偏移沒對齊。有的讀卡器返回的卡號是大端序有的是小端序有的幀頭占 2 字節(jié)有的占 3 字節(jié)。手冊上寫“第 3 到第 6 字節(jié)為卡號”但實(shí)際抓包發(fā)現(xiàn)第 2 字節(jié)也是數(shù)據(jù)。解決方法是不要猜直接抓一幀原始數(shù)據(jù)對照手冊逐字節(jié)標(biāo)注。如果手冊和實(shí)際不符以實(shí)際抓包為準(zhǔn)但要在代碼注釋里寫清楚。卡號拼接時先確認(rèn)字節(jié)序再用BitConverter.ToUInt32或手動移位不要兩種混用。4.3 現(xiàn)象讀扇區(qū)返回“密鑰錯誤”但密鑰明明是對的原因可能是密鑰類型選錯KeyA 和 KeyB 是兩套獨(dú)立密鑰也可能是卡片已經(jīng)被其他系統(tǒng)改過密鑰默認(rèn)的FF FF FF FF FF FF已經(jīng)失效還有可能是驗(yàn)證指令的幀格式不對比如校驗(yàn)位算錯。解決方法是先用廠商工具驗(yàn)證密鑰確認(rèn)能讀再回到 C#。如果廠商工具也讀不到說明密鑰確實(shí)被改了需要找發(fā)卡方要密鑰。如果廠商工具能讀對比兩者發(fā)送的指令幀重點(diǎn)看校驗(yàn)位和密鑰類型字節(jié)。4.4 現(xiàn)象寫數(shù)據(jù)成功返回但重新讀出來還是舊數(shù)據(jù)原因是寫的是緩存塊沒有真正落卡或者寫塊地址算錯寫到了別的塊。M1 卡的寫操作有的讀卡器會返回“寫成功”但實(shí)際需要斷電再上電才生效。解決方法是寫完以后立刻讀同一塊對比數(shù)據(jù)。如果讀出來是舊數(shù)據(jù)檢查塊地址是否算錯扇區(qū)和塊的換算關(guān)系是否搞反。另外有些讀卡器寫完后需要發(fā)送復(fù)位指令手冊里通常會寫別漏掉。4.5 現(xiàn)象程序跑一段時間后串口報(bào)“訪問被拒絕”或“端口已關(guān)閉”原因是串口沒有正確釋放或者DataReceived事件里拋異常導(dǎo)致端口狀態(tài)異?!,F(xiàn)場長時間運(yùn)行的程序還可能是 USB 轉(zhuǎn)串口線松動系統(tǒng)重新枚舉了設(shè)備。解決方法是在Close里先解綁事件再關(guān)閉端口異常處理里加try-catch并在捕獲到IOException時嘗試重新打開端口。如果是 USB 松動建議在代碼里加一個定時檢測發(fā)現(xiàn)端口消失就提示用戶檢查硬件而不是直接崩潰。5. 進(jìn)階技巧用日志和模擬器把硬件讀寫變成可回歸的工程硬件讀寫最麻煩的地方在于“不可重復(fù)”同一張卡今天讀得到明天可能因?yàn)閿[放位置不同就讀不到。我的習(xí)慣是給每個項(xiàng)目加兩層保護(hù)一層是原始日志一層是模擬器。原始日志不是簡單寫個文本文件而是把每次收發(fā)的字節(jié)、時間戳、當(dāng)前狀態(tài)都記下來。這樣現(xiàn)場出問題時你可以讓用戶把日志發(fā)回來直接看到底是沒收到數(shù)據(jù)還是收到了但解析錯了。下面是一個簡單的日志封裝private static readonly object _logLock new object(); private void LogFrame(string direction, byte[] data) { string line ${DateTime.Now:HH:mm:ss.fff} {direction} BitConverter.ToString(data); lock (_logLock) { File.AppendAllText(card_reader.log, line Environment.NewLine); } }direction用TX和RX區(qū)分發(fā)送和接收BitConverter.ToString輸出十六進(jìn)制方便對照手冊。lock是為了避免多線程同時寫文件導(dǎo)致內(nèi)容交錯。日志文件不要無限增長可以按天切分或者超過一定大小就滾動。模擬器則是把協(xié)議層抽出來用一個假的響應(yīng)源替代真實(shí)串口。這樣你可以在沒有硬件的情況下測試解析邏輯、狀態(tài)機(jī)、異常分支。做法很簡單定義一個ICardTransport接口真實(shí)串口和模擬器都實(shí)現(xiàn)它業(yè)務(wù)層只依賴接口。public interface ICardTransport { event Actionbyte[] FrameReceived; void Send(byte[] frame); void Open(); void Close(); }真實(shí)實(shí)現(xiàn)里Send調(diào)用SerialPort.Write模擬器里Send根據(jù)指令返回預(yù)置的響應(yīng)幀。這樣單元測試可以覆蓋“幀頭錯位”“校驗(yàn)失敗”“超時”這些現(xiàn)場很難復(fù)現(xiàn)的場景。參數(shù)上模擬器的響應(yīng)延遲可以設(shè)成 0 到 200 毫秒隨機(jī)更接近真實(shí)硬件。最后說一個我自己的習(xí)慣每次接手新的讀卡器先不寫業(yè)務(wù)代碼而是花半小時用廠商工具抓三幀數(shù)據(jù)——讀卡號、讀扇區(qū)、寫扇區(qū)——把原始字節(jié)抄在紙上再開始寫 C#。這個習(xí)慣幫我省掉了無數(shù)次“代碼沒問題但硬件不配合”的扯皮。C# IC卡讀寫這件事代碼只是冰山一角水面下的是協(xié)議、硬件和現(xiàn)場環(huán)境。把日志和模擬器做扎實(shí)后面換卡、換讀卡器、換現(xiàn)場你都有后悔藥可吃。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取