:從API到踩坑全解析)
簡介一個基于C MFC類庫的RS485串口通信完整演示工程面向工業(yè)自動化、物聯(lián)網(wǎng)設(shè)備調(diào)試以及串口通信初學者適合在Visual Studio 2015下直接編譯學習。壓縮包共22個文件以頭文件.h、C源文件.cpp和VS工程文件.sln/.vcxproj為主另含對話框界面資源、圖標和說明文檔整體僅52KB結(jié)構(gòu)清晰便于按模塊查閱。已有3197人學習下載。工程實現(xiàn)了串口打開、波特率/數(shù)據(jù)位/停止位/校驗位配置、數(shù)據(jù)讀寫與關(guān)閉等核心流程并展示MFC對話框程序如何響應(yīng)串口事件幫助理解RS485半雙工通信和MFC消息映射機制代碼注釋完整解壓后導入Visual Studio 2015即可運行可動手修改參數(shù)觀察效果也可作為二次開發(fā)的基礎(chǔ)框架。 最近在做一個老設(shè)備的改造項目工控機用的是Windows MFC老框架下位機是好幾臺分布在現(xiàn)場不同位置的儀表距離遠、干擾大串口通信這塊最后定下來走RS485總線。網(wǎng)上翻了一圈關(guān)于C MFC做RS485串口通信的demo要么年頭太久、要么只有片段真正能直接拿來改的不多。所以我把自己最后落地的完整版代碼和踩坑過程整理出來給后面接類似活兒的兄弟一個參考。這篇文章適合兩類人一是剛接觸MFC串口開發(fā)、需要快速做出一個能收發(fā)數(shù)據(jù)的上位機demo的學生或轉(zhuǎn)行開發(fā)者二是已經(jīng)寫了幾年代碼、但總在485通信的邊角問題上反復折騰的工程師。內(nèi)容會覆蓋RS485的物理層要點、MFC里串口通信的方案取舍、完整代碼拆解以及我在調(diào)試中遇到的一堆真實問題。1. 三類串口的本質(zhì)區(qū)別為什么項目必須用RS485很多人上來就寫代碼結(jié)果連硬件選型都搞錯了。RS232、RS422、RS485雖然長得像但電氣特性完全不同直接決定了你的通信距離、節(jié)點數(shù)量和抗干擾能力。1.1 從RS232到RS485核心變化在物理層RS232是單端信號傳輸信號地共用電壓差只有±3V到±15V抗干擾能力弱理論傳輸距離也就15米左右。RS485用的是差分信號A、B兩根線之間的電壓差來表示邏輯1和0共模干擾被抵消掉傳輸距離能到1200米而且同一總線上可以掛32個節(jié)點標準負載下。表RS232和RS485關(guān)鍵參數(shù)對比項目RS232RS485信號方式單端差分A、B兩線傳輸距離≤15米≤1200米節(jié)點數(shù)1對1最多32個標準負載抗干擾弱強通信模式全雙工半雙工常見那現(xiàn)場為什么不用以太網(wǎng)或者CAN設(shè)備是老儀表只有485接口換總線等于換設(shè)備成本完全不可接受。在工業(yè)改造場景里RS485依然是存量設(shè)備的絕對主力上位機這邊做適配是最省事的路徑。1.2 半雙工和方向控制485項目最容易翻車的地方RS485最BT的點在于它是半雙工同一時刻只能收或者只能發(fā)。普通RS232串口的TX、RX是獨立物理通道收發(fā)同時進行沒問題但485的A、B線就一對發(fā)數(shù)據(jù)的時候必須把驅(qū)動器使能拉高發(fā)完再拉低切回接收模式。這個方向切換如果時序不對就會出現(xiàn)一種很詭異的現(xiàn)象發(fā)出去的命令對端能收到但收不到對方的回包或者回包收了一半突然斷了。因為方向切換太快最后一個字節(jié)還沒從移位寄存器里完全發(fā)出去就把驅(qū)動器關(guān)掉了。實踐里的處理方式發(fā)送完數(shù)據(jù)后必須等發(fā)送緩沖區(qū)徹底清空再切換方向。串口API里頭需要檢查發(fā)送隊列是否為空或者延時一個字節(jié)的時間再切。// 發(fā)送方向控制示例根據(jù)485轉(zhuǎn)換器的控制引腳翻轉(zhuǎn)電平 // 假設(shè)使用RTS作為方向控制常見的RS485轉(zhuǎn)接器方案 void SetRS485Mode(BOOL bSend) { // bSend TRUE: 發(fā)送模式拉高RTS // bSend FALSE: 接收模式拉低RTS EscapeCommFunction(m_hCom, bSend ? SETRTS : CLRRTS); }1.3 組網(wǎng)拓撲菊花鏈總線不是星型連接485組網(wǎng)是手拉手的菊花鏈每臺設(shè)備的A接A、B接B在總線兩端各接一個120Ω終端電阻。星型連接或者漏接終端電阻會在高速波特率下出現(xiàn)信號反射表現(xiàn)就是偶發(fā)亂碼。很多人在實驗室調(diào)試沒問題一到現(xiàn)場就出亂碼80%是這兩件事沒做對終端電阻沒接全、或者線接成了星型。如果你要接的設(shè)備數(shù)量多比如超過10臺還要注意總線上每個節(jié)點的地址規(guī)劃協(xié)議層得做從站地址區(qū)分。2. MFC串口通信方案取舍MSComm控件還是Windows APIMFC做串口通信方案其實就兩大派ActiveX控件MSComm和直接用Win32通信API。網(wǎng)上demo一半以上用的是MSComm因為它封裝好拖個控件、設(shè)幾個屬性、綁定事件就完事。但我最終選擇了純API方案原因后面細說。2.1 MSComm控件的問題打包分發(fā)和回調(diào)線程坑MSComm是ActiveX控件依賴mscomm.ocx文件注冊到系統(tǒng)。做工程的人都知道控件注冊這步在開發(fā)機上沒事?lián)Q個部署機器就各種報錯未注冊、組件未找到、x64與x86位數(shù)不匹配。MFC項目本身帶系統(tǒng)庫都很麻煩再拖個OCX就是給自己找事。另外MSComm的OnComm事件其實是在工作線程里回調(diào)的你在事件處理函數(shù)里直接去更新界面控件十有八九要閃退或者死鎖。網(wǎng)上教程根本沒提這茬新手就卡在這里。2.2 純API方案的優(yōu)勢可控性強、部署簡單、資料透明CreateFile打開串口、GetCommState/SetCommState配置DCB、ReadFile/WriteFile收發(fā)、WaitCommEvent等事件——這套東西不依賴任何第三方組件msvcrt和kernel32搞定部署到哪臺機器都不會因為缺組件掛掉。我在實際項目里還因為一個需求不得不放棄MSComm需要精確控制485的方向引腳時序要能直接操作串口的RTS/DTR線。如果用MSComm這個操作要繞一大圈才能實現(xiàn)而API方案一個EscapeCommFunction函數(shù)就搞定了。// 打開串口注意dwShareMode必須為0 HANDLE hCom CreateFileA(COM3, GENERIC_READ | GENERIC_WRITE, 0, // 獨占模式不允許共享 NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); if (hCom INVALID_HANDLE_VALUE) { AfxMessageBox(_T(串口打開失敗請檢查設(shè)備是否被占用)); return FALSE; }這里有個細節(jié)dwShareMode必須傳0表示獨占串口。如果不獨占同一個COM口被另一個進程打開之后讀寫行為會變得不可預期。之前有人圖方便用了FILE_SHARE_READ | FILE_SHARE_WRITE結(jié)果兩個程序同時打開同一串口數(shù)據(jù)直接亂掉。2.3 工作線程與超時機制界面不卡頓的保證MFC界面程序里絕對不能把ReadFile放在按鈕點擊事件里同步等待。串口讀數(shù)據(jù)如果沒設(shè)置超時ReadFile會一直阻塞界面直接假死。正確的姿勢是搞一個后臺通信線程死循環(huán)讀數(shù)據(jù)解析完成之后通過PostMessage把數(shù)據(jù)丟回主界面線程。線程的核心邏輯是串口的事件驅(qū)動 超時控制。開發(fā)中最常用的是在監(jiān)聽線程中WaitCommEvent等待數(shù)據(jù)到達結(jié)合等待超時防止線程卡死線程退出時用事件信號通知。這里超時機制的配置很多人忽略了COMMTIMEOUTS結(jié)構(gòu)體設(shè)置不當會導致讀取阻塞。// 配置串口參數(shù)9600, 8, N, 1 DCB dcb { 0 }; dcb.DCBlength sizeof(DCB); GetCommState(hCom, dcb); dcb.BaudRate CBR_9600; dcb.ByteSize 8; dcb.Parity NOPARITY; dcb.StopBits ONESTOPBIT; SetCommState(hCom, dcb); // 設(shè)置超時讀操作最多等待200ms COMMTIMEOUTS timeouts { 0 }; timeouts.ReadIntervalTimeout 50; timeouts.ReadTotalTimeoutMultiplier 10; timeouts.ReadTotalTimeoutConstant 200; SetCommTimeouts(hCom, timeouts);3. 完整代碼拆解從打開串口到CRC校驗一條龍這一節(jié)把demo的核心模塊一個個拆開講。完整的工程文件結(jié)構(gòu)大概是CComPort類串口底層封裝、CMy485Dlg類界面交互、通信協(xié)議解析庫幀格式封裝。我這里挑了最核心的幾個代碼段貼出來的都是能直接編譯的部分。3.1 通信幀協(xié)議設(shè)計不能靠裸字節(jié)混日子RS485總線上多設(shè)備共享線路如果在協(xié)議層不把消息幀邊界定清楚接收端根本不知道一幀數(shù)據(jù)從哪開始、到哪結(jié)束。最常用的幀設(shè)計是幀頭地址功能碼數(shù)據(jù)長度數(shù)據(jù)域校驗0xAA1字節(jié)1字節(jié)1字節(jié)N字節(jié)1字節(jié)異或校驗幀頭0xAA、地址01代表主機本身、功能碼0x03是讀寄存器、0x06是寫寄存器這些都是照搬Modbus-RTU的思路只不過我做了一個輕量簡化版。校驗我用的BCC異或校驗數(shù)據(jù)量大的項目建議直接上CRC16。3.2 接收緩沖區(qū)的環(huán)形隊列設(shè)計串口數(shù)據(jù)是字節(jié)流一幀數(shù)據(jù)可能分好幾次到達。寫接收代碼時最忌諱的做法是讀一個字節(jié)處理一個字節(jié)因為第二次讀到的可能是上一幀的尾巴。正確方式是把讀到的字節(jié)先丟進一個環(huán)形接收緩沖區(qū)再從緩沖區(qū)里按幀頭、地址、長度、校驗依次解析。// 環(huán)形緩沖區(qū)定義 #define RX_BUF_SIZE 4096 BYTE g_RxBuf[RX_BUF_SIZE]; int g_nRxHead 0; // 寫入位置 int g_nRxTail 0; // 讀取位置 // 往緩沖區(qū)寫入一字節(jié)滿了則丟棄 void PushRxByte(BYTE bData) { int nNext (g_nRxHead 1) % RX_BUF_SIZE; if (nNext ! g_nRxTail) { g_RxBuf[g_nRxHead] bData; g_nRxHead nNext; } } // 從緩沖區(qū)讀取一字節(jié) BOOL PopRxByte(BYTE* pData) { if (g_nRxTail g_nRxHead) return FALSE; *pData g_RxBuf[g_nRxTail]; g_nRxTail (g_nRxTail 1) % RX_BUF_SIZE; return TRUE; }環(huán)形隊列的好處是不用頻繁搬移內(nèi)存讀寫雙方只用維護各自的游標。這個結(jié)構(gòu)你要用得很熟因為任何一個工業(yè)串口項目基本都跑不掉這套東西。3.3 接收線程事件驅(qū)動防止CPU空轉(zhuǎn)接收線程用WaitCommEvent加超時等待而不是死循環(huán)ReadFile。死循環(huán)讀串口是惡搞CPU占用率直接給你干到30%電腦風扇呼呼轉(zhuǎn)。UINT CComPort::ReceiveThread(LPVOID pParam) { CComPort* pPort (CComPort*)pParam; OVERLAPPED ov { 0 }; ov.hEvent CreateEvent(NULL, TRUE, FALSE, NULL); DWORD dwMask 0; while (pPort-m_bThreadRunning) { // 等待數(shù)據(jù)到達事件超時500ms用于檢查線程退出標記 if (!WaitCommEvent(pPort-m_hCom, dwMask, ov)) { if (GetLastError() ERROR_IO_PENDING) { WaitForSingleObject(ov.hEvent, 500); // 等待500ms } } if (!pPort-m_bThreadRunning) break; if (dwMask EV_RXCHAR) { // 有數(shù)據(jù)到達讀取 BYTE buf[512] { 0 }; DWORD dwRead 0; ReadFile(pPort-m_hCom, buf, sizeof(buf), dwRead, NULL); for (DWORD i 0; i dwRead; i) { PushRxByte(buf[i]); } // 通知主界面處理接收數(shù)據(jù) ::PostMessage(pPort-m_hWnd, WM_RX_DATA, 0, 0); } } CloseHandle(ov.hEvent); return 0; }注意這個設(shè)計接收線程只負責把字節(jié)塞進環(huán)形隊列、通知界面線程有新數(shù)據(jù)了真正的解析幀、更新界面全部在主線程完成。這樣不會出現(xiàn)界面假死也避免線程不同步導致的內(nèi)存競爭。3.4 數(shù)據(jù)發(fā)送和485方向切換精確到字節(jié)的操作發(fā)送部分要結(jié)合RS485半雙工特性。發(fā)送流程是置發(fā)送模式拉高RTS- 寫數(shù)據(jù) - 等待發(fā)送緩沖區(qū)清空 - 切回接收模式。切換的時機控制不對通信就一抽一抽的。BOOL CComPort::SendData(BYTE* pData, int nLen) { if (!m_hCom || !pData || nLen 0) return FALSE; // 485方向切換為發(fā)送模式 SetRS485Mode(TRUE); DWORD dwWritten 0; BOOL bRet WriteFile(m_hCom, pData, nLen, dwWritten, NULL); // 等待數(shù)據(jù)完全發(fā)出 FlushFileBuffers(m_hCom); // 切回接收模式注意這里不能太快 Sleep(10); SetRS485Mode(FALSE); return bRet (dwWritten (DWORD)nLen); }有個細節(jié)要提WriteFile返回只說明數(shù)據(jù)進了系統(tǒng)發(fā)送緩沖區(qū)不代表已經(jīng)發(fā)出去了。所以必須FlushFileBuffers等待物理發(fā)送完成再切方向。加的那個Sleep(10)是為了兜底波特率9600下一字節(jié)大約是1.04ms10ms足夠發(fā)完幾十個字節(jié)。3.5 發(fā)送數(shù)據(jù)的協(xié)議打包按幀格式組裝發(fā)送時要把原始命令按協(xié)議格式封裝。Modbus風格的讀寫指令核心就是把地址、功能碼、寄存器地址、數(shù)據(jù)長度、校驗值算好拼接。我封裝了一個BuildWriteCommand函數(shù)輸入?yún)?shù)是設(shè)備地址、寄存器地址和值輸出一個標準報文// 計算BCC異或校驗 BYTE CalcBCC(BYTE* pData, int nLen) { BYTE bBCC 0; for (int i 0; i nLen; i) bBCC ^ pData[i]; return bBCC; } // 組裝讀命令 int BuildReadCommand(BYTE bAddr, WORD wRegAddr, WORD wRegCount, BYTE* pOut) { int nIndex 0; pOut[nIndex] 0xAA; // 幀頭 pOut[nIndex] bAddr; // 設(shè)備地址 pOut[nIndex] 0x03; // 功能碼讀 pOut[nIndex] HIBYTE(wRegAddr); // 寄存器地址高字節(jié) pOut[nIndex] LOBYTE(wRegAddr); // 寄存器地址低字節(jié) pOut[nIndex] HIBYTE(wRegCount); // 寄存器數(shù)量高字節(jié) pOut[nIndex] LOBYTE(wRegCount); // 寄存器數(shù)量低字節(jié) pOut[nIndex] CalcBCC(pOut, nIndex); // 校驗 nIndex; return nIndex; }4. 實測中的大坑亂碼、丟幀、USB轉(zhuǎn)485的隱形問題代碼寫完之后調(diào)試才是真正大戰(zhàn)。我在這個項目里踩了一連串坑有些坑如果沒人提前講自己排查估計要耗掉一整天。4.1 數(shù)據(jù)位校驗和停止位不匹配從設(shè)備參數(shù)不統(tǒng)一查起第一個坑就出在串口參數(shù)上?,F(xiàn)場有一臺設(shè)備是9位數(shù)據(jù)位8位數(shù)據(jù)校驗位當數(shù)據(jù)用另外幾臺是標準8N1。我用統(tǒng)一參數(shù)打開串口結(jié)果其中一臺設(shè)備回的數(shù)據(jù)全亂。查了半天發(fā)現(xiàn)是設(shè)備廠家出廠設(shè)置的校驗位是Even偶校驗串口配置不匹配讀上來的數(shù)據(jù)自然不對。這個問題的處理方式比較直接先把所有設(shè)備的串口參數(shù)摸清統(tǒng)一配置。485總線上所有設(shè)備的波特率、校驗位、數(shù)據(jù)位、停止位必須完全一致否則整個總線都不能正常通信。所以做項目第一步不是寫代碼而是找設(shè)備廠家要參數(shù)表。4.2 USB轉(zhuǎn)RS485模塊的隱患自動收發(fā)電路導致第一個字節(jié)丟實驗室測試用的是USB轉(zhuǎn)485模塊代碼調(diào)通了拿到現(xiàn)場接工控機原本的COM口主板原生串口就出問題。第一條命令發(fā)出去設(shè)備不響應(yīng)第二條開始正常。后來查資料才明白很多USB轉(zhuǎn)485模塊用的是自動收發(fā)切換電路靠檢測數(shù)據(jù)線上的電平變化來切換收發(fā)方向這沒問題。但工控機原生串口沒有這個電路我代碼里用手動RTS控制方向時序上第一個字節(jié)還沒穩(wěn)定就發(fā)出去了設(shè)備沒收到。手動切向發(fā)送模式時在置位RTS之后加一點延時再WriteFile。別小看這幾毫秒對RS485這種電平型信號來說方向刀閘沒打開就放信號等于把數(shù)據(jù)扔進了空氣里。// 置位RTS之后留出方向切換穩(wěn)定時間 SetRS485Mode(TRUE); Sleep(5); // 根據(jù)實際模塊調(diào)整5-20ms都試過 WriteFile(m_hCom, pData, nLen, dwWritten, NULL);4.3 120Ω終端電阻不在現(xiàn)場你根本想不到的坑設(shè)備端A、B線之間短路或者總線兩端沒接終端電阻高速率下數(shù)據(jù)會因信號反射而亂碼?,F(xiàn)場設(shè)備已經(jīng)預埋了10年不可能拆開去接終端電阻只能在主機端想辦法。在主機接線端子上直接并聯(lián)了一個特制的信號適配板它內(nèi)含120Ω終端電阻和TVS保護。信號質(zhì)量肉眼可見地改善原來偶發(fā)的亂碼徹底消失。如果你碰到遠端和主機端通信不穩(wěn)定先檢查兩端的終端電阻這個最容易被忽略但影響卻是致命的。4.4 丟幀和粘幀問題協(xié)議解析必須要有狀態(tài)機在調(diào)試中發(fā)現(xiàn)一個典型的丟幀場景主機發(fā)送讀取指令后設(shè)備回包長度50字節(jié)但接收線程只收到了30字節(jié)剩下的20字節(jié)晚了幾十毫秒才到。這種拆包情況如果用一次ReadFile去讀整幀必然丟數(shù)據(jù)。解決方案就在前面的環(huán)形隊列設(shè)計上。接收線程無腦讀、無腦塞隊列解析層按狀態(tài)機從隊列里一節(jié)一節(jié)啃出幀頭、地址、長度、數(shù)據(jù)、校驗。每個字節(jié)都走一遍這個狀態(tài)機直到校驗通過才認為一幀完整。狀態(tài)機這一步必須做扎實上電第一位不是幀頭就跳過收到幀頭后下一個字節(jié)不是本機地址就重置長度字段超出數(shù)據(jù)域上限直接丟棄重置。這些都是防御性處理在總線上有其他噪聲干擾時能擋住99%的無效數(shù)據(jù)。幀解析狀態(tài)機流轉(zhuǎn) IDLE → 收到0xAA → 已接收幀頭 → 接收地址 → 匹配則繼續(xù) → 接收功能碼 → 接收長度 → 接收數(shù)據(jù)域 → 接收校驗 → 校驗通過則一幀完成 任意一步不匹配 → 回到IDLE丟棄當前累積5. 工程化細節(jié)從demo到能交付的代碼還差什么網(wǎng)上很多demo能跑通但拿到現(xiàn)場一用就露餡。因為demo停留在讀取串口數(shù)據(jù)打印到文本框這個層面而真正的工程代碼要解決日志、異常恢復、設(shè)備斷線重連等一堆破事。5.1 串口異常斷開后的自動重連機制現(xiàn)場調(diào)試會頻繁拔插USB轉(zhuǎn)485線或者設(shè)備斷電重啟。這時串口句柄可能已經(jīng)失效任何讀寫操作都會失敗。一個健壯的通信模塊必須做異常檢測和重連。我的方案是在工作線程里每次讀寫操作檢查返回值連續(xù)失敗超過3次就關(guān)閉串口句柄并通知界面串口異常斷開界面彈提示并啟動一個定時器嘗試重連直到重新打開成功。定時器重連邏輯void CMy485Dlg::OnTimerReconnect() { // 嘗試重新打開串口 if (m_pComPort-Open(m_nComPort)) { KillTimer(m_nReconnectTimerId); SetStatusText(_T(串口重連成功)); m_pComPort-StartReceiveThread(); } else { SetStatusText(_T(等待重連...)); } }5.2 通信日志排障的照妖鏡項目上線后的第一訴求基本是出問題時能查日志。尤其是485這種總線上掛著多臺設(shè)備的場景沒有日志你根本不知道哪臺設(shè)備沒回包、哪個字節(jié)是錯的。我在demo里加了一個日志模塊所有發(fā)送和接收的字節(jié)都按十六進制寫進文本文件帶時間戳。這玩意成本極低但價值極高現(xiàn)場排障全靠它。void LogData(const CString strDir, BYTE* pData, int nLen) { CString strLine; strLine.Format(_T([%s] %s: ), CTime::GetCurrentTime().Format(_T(%H:%M:%S)), strDir); for (int i 0; i nLen; i) { CString strByte; strByte.Format(_T(%02X ), pData[i]); strLine strByte; } // 寫入文件 CStdioFile file; if (file.Open(_T(comm_log.txt), CFile::modeCreate | CFile::modeNoTruncate | CFile::modeWrite)) { file.SeekToEnd(); file.WriteString(strLine _T(\n)); file.Close(); } }5.3 打包部署MFC運行庫和驅(qū)動要一起帶走最后交付時跑demo的機器要裝MFC運行庫才能運行。Debug版本還得帶一堆調(diào)試動態(tài)庫Release版本的運行庫文件要一并帶上。用VS2013以上版本做的MFC程序目標機器大概率裝了更新版的VC運行庫但保險起見還是把對應(yīng)版本的vcredist_x86安裝包隨程序一起交付。另外如果是USB轉(zhuǎn)485模塊USB驅(qū)動也要一起打包?,F(xiàn)場經(jīng)常有人插上新機器發(fā)現(xiàn)沒驅(qū)動臨時找驅(qū)動盤找不到這個坑屬于高頻。把驅(qū)動的exe安裝文件放進部署包的驅(qū)動目錄下能省去售后一堆電話。寫代碼容易調(diào)通現(xiàn)場才是本事做這類項目到最后你會發(fā)現(xiàn)寫代碼其實是最快的一步真正花時間的全在現(xiàn)場排查設(shè)備接地不良導致的共模電壓、A/B線接線反了、總線上兩臺設(shè)備地址沖突……每一個都是文檔里不會寫、但實際必然遇到的坑。給你們留一個實際檢驗過的經(jīng)驗如果新接到一個485通信的需求先別急著寫代碼花半天把現(xiàn)場設(shè)備的電氣參數(shù)、通信協(xié)議、接線拓撲摸清楚這個時間花得絕對值。協(xié)議文檔拿到手之后按上面這套代碼骨架去改最多一天能把基礎(chǔ)通信跑通。后面就算遇到玄學問題只要協(xié)議解析和方向時序這兩塊做扎實基本都能在日志里浮出水面。本文還有配套的精品資源點擊獲取