戰(zhàn):Modbus RTU鎖板與避坑指南)
1. 項(xiàng)目緣起與整體設(shè)計(jì)思路1.1 為什么要在 Android 上折騰 485 串口先說(shuō)背景。我手上有個(gè)工業(yè)場(chǎng)景的項(xiàng)目需求很明確一臺(tái) Android 平板作為上位機(jī)通過(guò) RS485 總線去控制一塊伺服驅(qū)動(dòng)板走 Modbus RTU 協(xié)議。聽起來(lái)不復(fù)雜對(duì)吧但真正動(dòng)手之后才發(fā)現(xiàn)Android 平臺(tái)上的串口通信跟我們?cè)?STM32 或者 PC 上玩串口完全是兩碼事。在 STM32 上搞 485你直接操作 UART 寄存器配好波特率、數(shù)據(jù)位、停止位再控制一個(gè) GPIO 做收發(fā)切換就完事了。PC 上更簡(jiǎn)單插個(gè) USB 轉(zhuǎn) 485 模塊用 Modbus Poll 或者自己寫個(gè) Python 腳本就能跑。但 Android 呢系統(tǒng)本身對(duì)串口設(shè)備節(jié)點(diǎn)的權(quán)限管得很死/dev/ttyS*這些節(jié)點(diǎn)普通應(yīng)用根本碰不到除非你有 root 權(quán)限或者系統(tǒng)簽名。這就導(dǎo)致了一個(gè)很尷尬的局面——你明明看到設(shè)備節(jié)點(diǎn)在那里但就是打不開。我選擇android-serialport-api這個(gè)庫(kù)原因很實(shí)際它足夠輕量核心就是幾個(gè) JNI 文件加一個(gè) Java 封裝層沒(méi)有引入額外的依賴編譯出來(lái)的 so 庫(kù)體積很小。市面上也有一些其他的方案比如usb-serial-for-android那個(gè)走的是 USB Host 通道需要設(shè)備支持 OTG 并且要處理 USB 權(quán)限彈窗對(duì)于嵌入式設(shè)備固定安裝的場(chǎng)景來(lái)說(shuō)反而更麻煩。而android-serialport-api直接操作設(shè)備節(jié)點(diǎn)只要權(quán)限到位打開就能用延遲也更低。這個(gè)項(xiàng)目適合誰(shuí)參考呢如果你正在做 Android 工控平板、智能終端、自助設(shè)備這類需要跟下位機(jī)通過(guò) 485 通信的場(chǎng)景或者你單純想搞清楚 Android 串口通信的底層邏輯那這篇內(nèi)容應(yīng)該能幫你省下不少踩坑的時(shí)間。1.2 整體方案架構(gòu)與選型考量整個(gè)通信鏈路的架構(gòu)是這樣的Android 平板通過(guò) OTG 或者板載的 UART 接口外接一個(gè) TTL 轉(zhuǎn) 485 模塊然后通過(guò) A/B 差分線接到伺服驅(qū)動(dòng)板的 485 接口上。軟件層面Android 端用android-serialport-api打開串口設(shè)備節(jié)點(diǎn)按照 Modbus RTU 的幀格式組包發(fā)送然后等待從站響應(yīng)。這里有個(gè)關(guān)鍵選型點(diǎn)為什么不直接用 Modbus TCP因?yàn)楝F(xiàn)場(chǎng)設(shè)備只支持 RTU而且 485 總線在工業(yè)環(huán)境下的抗干擾能力和布線成本優(yōu)勢(shì)是實(shí)打?qū)嵉摹R粭l雙絞線可以掛多個(gè)從站布線簡(jiǎn)單成本低。當(dāng)然代價(jià)就是速率相對(duì)慢一些但對(duì)于伺服控制這種場(chǎng)景波特率 9600 或者 19200 完全夠用。另一個(gè)選型點(diǎn)是關(guān)于 485 收發(fā)切換電路。我用的 TTL 轉(zhuǎn) 485 模塊是帶自動(dòng)收發(fā)切換的也就是說(shuō)模塊內(nèi)部會(huì)根據(jù)發(fā)送數(shù)據(jù)的狀態(tài)自動(dòng)控制 DE/RE 引腳不需要 Android 端額外操作 GPIO。這一點(diǎn)很重要因?yàn)?Android 應(yīng)用層根本沒(méi)法直接控制 GPIO如果模塊不帶自動(dòng)切換那就必須用帶使能控制的電路通過(guò)串口的 RTS 引腳來(lái)控制收發(fā)方向而android-serialport-api對(duì) RTS 的支持并不完善這就很容易出問(wèn)題。注意選 TTL 轉(zhuǎn) 485 模塊的時(shí)候一定要確認(rèn)是否支持自動(dòng)收發(fā)切換。如果不支持你需要在 Android 端通過(guò) RTS 控制收發(fā)方向而很多 Android 設(shè)備的串口驅(qū)動(dòng)對(duì) RTS 的支持并不好會(huì)導(dǎo)致發(fā)送完成后無(wú)法及時(shí)切回接收狀態(tài)從而丟失從站的響應(yīng)數(shù)據(jù)。2. android-serialport-api 的兩個(gè)深坑詳解2.1 第一個(gè)坑設(shè)備節(jié)點(diǎn)權(quán)限與路徑適配android-serialport-api這個(gè)庫(kù)的原理很簡(jiǎn)單它通過(guò) JNI 調(diào)用 Linux 的open()系統(tǒng)調(diào)用來(lái)打開串口設(shè)備節(jié)點(diǎn)然后通過(guò)read()和write()進(jìn)行數(shù)據(jù)收發(fā)。問(wèn)題就出在這個(gè)“打開”上。在標(biāo)準(zhǔn)的 Linux 系統(tǒng)里串口設(shè)備節(jié)點(diǎn)通常是/dev/ttyS0、/dev/ttyS1這種。但在 Android 上不同廠商的設(shè)備節(jié)點(diǎn)命名完全不一樣。我手上這臺(tái)平板串口節(jié)點(diǎn)是/dev/ttyMT0而另一臺(tái)設(shè)備上是/dev/ttyHSL0。更坑的是有些設(shè)備的節(jié)點(diǎn)權(quán)限是root:root 660普通應(yīng)用根本打不開。我試過(guò)幾種方案。第一種是直接在代碼里硬編碼節(jié)點(diǎn)路徑但換一臺(tái)設(shè)備就廢了。第二種是遍歷/dev/目錄下的所有tty*節(jié)點(diǎn)逐個(gè)嘗試打開但這樣效率低而且容易誤操作。最后我采用的方案是在應(yīng)用啟動(dòng)時(shí)通過(guò)讀取/proc/tty/drivers文件來(lái)獲取當(dāng)前系統(tǒng)可用的串口驅(qū)動(dòng)信息然后結(jié)合設(shè)備型號(hào)做映射。具體來(lái)說(shuō)/proc/tty/drivers文件里會(huì)列出所有已注冊(cè)的 tty 驅(qū)動(dòng)包括驅(qū)動(dòng)名稱、節(jié)點(diǎn)路徑、主設(shè)備號(hào)等信息。我可以從中篩選出類型為serial的驅(qū)動(dòng)然后拿到對(duì)應(yīng)的節(jié)點(diǎn)路徑。但這里還有個(gè)問(wèn)題——即使拿到了路徑權(quán)限不夠還是打不開。關(guān)于權(quán)限如果你的設(shè)備是 root 過(guò)的可以在應(yīng)用啟動(dòng)時(shí)通過(guò)su命令去修改節(jié)點(diǎn)權(quán)限比如chmod 666 /dev/ttyMT0。但這種方式依賴 root而且每次重啟都要重新執(zhí)行。另一種方案是讓系統(tǒng)集成商在init.rc或者ueventd.rc里配置好節(jié)點(diǎn)權(quán)限讓應(yīng)用可以直接訪問(wèn)。我最終采用的是后者因?yàn)轫?xiàng)目是定制設(shè)備系統(tǒng)層可以配合修改。實(shí)操心得在調(diào)試階段可以先用adb shell進(jìn)入設(shè)備用ls -l /dev/tty*查看節(jié)點(diǎn)權(quán)限然后用cat /proc/tty/drivers確認(rèn)驅(qū)動(dòng)信息。如果權(quán)限不對(duì)臨時(shí)用chmod 666改一下確認(rèn)通信沒(méi)問(wèn)題后再去推動(dòng)系統(tǒng)層做永久配置。這樣可以把軟件問(wèn)題和權(quán)限問(wèn)題分開排查效率高很多。2.2 第二個(gè)坑JNI 層的文件描述符泄漏與線程安全這個(gè)坑更隱蔽也更致命。android-serialport-api的 JNI 層在打開串口時(shí)會(huì)創(chuàng)建一個(gè)文件描述符fd這個(gè) fd 在 Java 層對(duì)應(yīng)一個(gè)FileDescriptor對(duì)象。問(wèn)題在于如果你在代碼里多次打開同一個(gè)串口而沒(méi)有正確關(guān)閉fd 就會(huì)泄漏。Linux 系統(tǒng)對(duì)每個(gè)進(jìn)程的 fd 數(shù)量是有限制的一般是 1024 個(gè)。泄漏到一定程度后open()就會(huì)返回 -1串口再也打不開。我第一次遇到這個(gè)問(wèn)題的時(shí)候現(xiàn)象是應(yīng)用運(yùn)行了大概兩三天后串口突然就通信不上了重啟應(yīng)用又恢復(fù)正常。當(dāng)時(shí)排查了很久一開始懷疑是 485 總線干擾后來(lái)懷疑是 Modbus 幀格式問(wèn)題最后用lsof命令查看進(jìn)程的 fd 列表才發(fā)現(xiàn)有一大堆/dev/ttyMT0的 fd 沒(méi)有被釋放。原因是我在代碼里每次發(fā)送數(shù)據(jù)前都會(huì)調(diào)用SerialPort.open()但發(fā)送完成后沒(méi)有調(diào)用close()。當(dāng)時(shí)的想法是保持串口常開避免頻繁開關(guān)帶來(lái)的開銷。但實(shí)際上android-serialport-api的SerialPort類并沒(méi)有實(shí)現(xiàn)Closeable接口也沒(méi)有 finalizer 來(lái)保證 fd 被釋放。如果你不顯式調(diào)用close()fd 就會(huì)一直掛著。修復(fù)方案很簡(jiǎn)單把SerialPort對(duì)象做成單例在應(yīng)用啟動(dòng)時(shí)打開一次在應(yīng)用退出時(shí)關(guān)閉。同時(shí)在SerialPort的封裝類里加一個(gè)isOpen標(biāo)志位避免重復(fù)打開。另外我還加了一個(gè) fd 泄漏檢測(cè)機(jī)制定期讀取/proc/self/fd目錄下的文件數(shù)量如果超過(guò)閾值就報(bào)警。線程安全是另一個(gè)問(wèn)題。android-serialport-api的write()和read()方法本身不是線程安全的。如果你在一個(gè)線程里寫數(shù)據(jù)同時(shí)在另一個(gè)線程里讀數(shù)據(jù)就有可能出現(xiàn)數(shù)據(jù)錯(cuò)亂。我采用的方案是加一把讀寫鎖寫操作和讀操作互斥。但這樣又帶來(lái)了新的問(wèn)題——如果讀操作阻塞時(shí)間太長(zhǎng)寫操作就會(huì)被卡住。后來(lái)我改成了讀寫分離的方案寫操作走一個(gè)單獨(dú)的線程通過(guò)一個(gè)阻塞隊(duì)列來(lái)緩沖待發(fā)送的數(shù)據(jù)讀操作走另一個(gè)線程持續(xù)讀取串口數(shù)據(jù)并放入接收緩沖區(qū)。兩個(gè)線程通過(guò)synchronized塊來(lái)保護(hù)對(duì)SerialPort對(duì)象的訪問(wèn)但讀寫操作本身不互斥。這樣既保證了線程安全又避免了讀寫互相阻塞。注意android-serialport-api的read()方法是阻塞的如果你在 UI 線程里調(diào)用會(huì)直接 ANR。一定要在子線程里做讀寫操作然后通過(guò) Handler 或者 LiveData 把數(shù)據(jù)傳回 UI 層。3. Modbus RTU 鎖板通信實(shí)戰(zhàn)3.1 Modbus RTU 幀格式與組包細(xì)節(jié)Modbus RTU 的幀格式是這樣的從站地址1 字節(jié) 功能碼1 字節(jié) 數(shù)據(jù)域N 字節(jié) CRC 校驗(yàn)2 字節(jié)。幀與幀之間需要至少 3.5 個(gè)字符時(shí)間的靜默間隔來(lái)區(qū)分。在波特率 9600 的情況下一個(gè)字符時(shí)間是 10 位1 起始位 8 數(shù)據(jù)位 1 停止位也就是大約 1.04 毫秒3.5 個(gè)字符時(shí)間就是 3.64 毫秒。我用的功能碼是 0x06寫單個(gè)寄存器和 0x03讀保持寄存器。寫單個(gè)寄存器的幀格式是從站地址 0x06 寄存器地址高字節(jié) 寄存器地址低字節(jié) 數(shù)據(jù)高字節(jié) 數(shù)據(jù)低字節(jié) CRC 低字節(jié) CRC 高字節(jié)。讀保持寄存器的幀格式是從站地址 0x03 起始地址高字節(jié) 起始地址低字節(jié) 寄存器數(shù)量高字節(jié) 寄存器數(shù)量低字節(jié) CRC 低字節(jié) CRC 高字節(jié)。CRC 校驗(yàn)是 Modbus RTU 里最容易出錯(cuò)的地方。它的計(jì)算方式是初始化 CRC 為 0xFFFF然后對(duì)每個(gè)字節(jié)進(jìn)行處理每個(gè)字節(jié)與 CRC 的低字節(jié)異或然后右移 8 次每次如果最低位為 1 就與 0xA001 異或。最終得到的 CRC 需要低字節(jié)在前高字節(jié)在后。我一開始就是搞反了字節(jié)序?qū)е聫恼疽恢辈豁憫?yīng)。public static int calculateCRC(byte[] data, int length) { int crc 0xFFFF; for (int i 0; i length; i) { crc ^ (data[i] 0xFF); for (int j 0; j 8; j) { if ((crc 0x0001) ! 0) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; }組包的時(shí)候先把從站地址、功能碼、數(shù)據(jù)域拼成一個(gè)字節(jié)數(shù)組然后計(jì)算 CRC把 CRC 的低字節(jié)和高字節(jié)追加到數(shù)組末尾。發(fā)送的時(shí)候通過(guò)SerialPort的write()方法把整個(gè)字節(jié)數(shù)組寫出去。3.2 鎖板通信的超時(shí)與重試機(jī)制“鎖板”這個(gè)詞在工業(yè)場(chǎng)景里指的是從站設(shè)備處于一種鎖定狀態(tài)只響應(yīng)特定地址的請(qǐng)求。我遇到的場(chǎng)景是伺服驅(qū)動(dòng)板被配置成了地址 0x01只響應(yīng)這個(gè)地址的 Modbus 請(qǐng)求。如果地址不對(duì)從站直接忽略不會(huì)返回任何響應(yīng)。這就帶來(lái)了一個(gè)問(wèn)題如果 Android 端發(fā)送了請(qǐng)求但沒(méi)收到響應(yīng)怎么區(qū)分是從站沒(méi)收到、從站收到了但地址不對(duì)、還是從站響應(yīng)了但 Android 端沒(méi)收到我的做法是設(shè)置一個(gè)響應(yīng)超時(shí)時(shí)間比如 200 毫秒。發(fā)送請(qǐng)求后開始計(jì)時(shí)如果在 200 毫秒內(nèi)沒(méi)有收到完整的響應(yīng)幀就認(rèn)為本次通信失敗觸發(fā)重試。重試策略我采用的是指數(shù)退避第一次重試等待 100 毫秒第二次等待 200 毫秒第三次等待 400 毫秒最多重試 3 次。如果 3 次都失敗就上報(bào)通信故障。這樣做的原因是485 總線在工業(yè)環(huán)境下可能會(huì)受到瞬時(shí)干擾偶爾一兩次通信失敗是正常的通過(guò)重試可以恢復(fù)。但如果連續(xù)多次失敗那就說(shuō)明是硬件或者配置問(wèn)題了再重試也沒(méi)用。實(shí)操心得超時(shí)時(shí)間不要設(shè)得太短。我一開始設(shè)了 50 毫秒結(jié)果發(fā)現(xiàn)從站響應(yīng)經(jīng)常超時(shí)。后來(lái)用示波器抓了一下波形發(fā)現(xiàn)從站從收到請(qǐng)求到發(fā)出響應(yīng)之間有大概 80 毫秒的處理時(shí)間。所以超時(shí)時(shí)間至少要設(shè)成從站最大處理時(shí)間的兩倍以上我最終設(shè)的是 200 毫秒實(shí)測(cè)很穩(wěn)。3.3 485 總線布線與隔離電路要點(diǎn)485 總線的布線有很多講究。首先必須用雙絞線因?yàn)殡p絞線可以抵消共模干擾。A 和 B 兩根線要絞在一起不能分開走。其次總線的兩端需要接終端電阻一般是 120 歐姆。終端電阻的作用是匹配總線阻抗消除信號(hào)反射。如果總線長(zhǎng)度小于 50 米可以不接終端電阻但超過(guò) 50 米就一定要接。隔離電路是另一個(gè)關(guān)鍵點(diǎn)。工業(yè)現(xiàn)場(chǎng)的地電位差可能很大如果 Android 設(shè)備和伺服驅(qū)動(dòng)板不共地485 總線上會(huì)有很大的共模電壓輕則通信不穩(wěn)定重則燒毀收發(fā)器。我用的 TTL 轉(zhuǎn) 485 模塊是帶光耦隔離的隔離電壓 2500V。這樣即使兩邊地電位差很大也不會(huì)損壞設(shè)備。還有一個(gè)細(xì)節(jié)是 485 總線的接地。很多人以為 485 只需要 A 和 B 兩根線其實(shí)還需要一根地線。這根地線不是用來(lái)傳輸信號(hào)的而是用來(lái)給總線上的所有設(shè)備提供一個(gè)共同的參考地。如果設(shè)備之間地電位差太大就需要用隔離模塊來(lái)切斷地環(huán)路。注意485 總線的 A 和 B 不要接反。雖然有些收發(fā)器有極性自適應(yīng)功能但大部分標(biāo)準(zhǔn) 485 收發(fā)器是不支持的。接反了要么完全通信不上要么通信極不穩(wěn)定。我一般會(huì)在接線端子旁邊標(biāo)注 A 和 B避免插錯(cuò)。4. 常見問(wèn)題與排查技巧實(shí)錄4.1 通信失敗排查速查表現(xiàn)象可能原因排查方法解決方案完全無(wú)響應(yīng)串口節(jié)點(diǎn)打不開檢查/dev/tty*權(quán)限修改節(jié)點(diǎn)權(quán)限或推動(dòng)系統(tǒng)層配置完全無(wú)響應(yīng)A/B 線接反交換 A/B 線測(cè)試糾正接線完全無(wú)響應(yīng)從站地址不對(duì)用 Modbus Poll 掃描地址修改從站地址或請(qǐng)求地址偶爾超時(shí)485 總線干擾示波器抓波形加終端電阻、改用屏蔽雙絞線偶爾超時(shí)超時(shí)時(shí)間太短增大超時(shí)時(shí)間設(shè)為從站處理時(shí)間的 2 倍以上數(shù)據(jù)錯(cuò)亂讀寫線程沖突檢查線程同步邏輯加讀寫鎖或讀寫分離運(yùn)行一段時(shí)間后失敗fd 泄漏lsof查看 fd 數(shù)量確保串口正確關(guān)閉CRC 校驗(yàn)失敗CRC 字節(jié)序錯(cuò)誤檢查 CRC 計(jì)算代碼低字節(jié)在前高字節(jié)在后4.2 獨(dú)家避坑技巧與調(diào)試心得第一個(gè)技巧是關(guān)于調(diào)試工具的。我強(qiáng)烈建議在開發(fā)階段同時(shí)用 Modbus Poll 和 Modbus Slave 這兩個(gè)工具。Modbus Poll 作為主站可以模擬 Android 端發(fā)送請(qǐng)求Modbus Slave 作為從站可以模擬伺服驅(qū)動(dòng)板響應(yīng)請(qǐng)求。這樣你可以先把 Android 端的代碼放一邊用 PC 工具確認(rèn) 485 硬件鏈路和 Modbus 協(xié)議沒(méi)問(wèn)題然后再去調(diào) Android 端的代碼。這樣可以避免同時(shí)排查硬件和軟件問(wèn)題效率高很多。第二個(gè)技巧是關(guān)于日志的。Android 端的串口日志一定要打全包括發(fā)送的原始字節(jié)、接收的原始字節(jié)、CRC 校驗(yàn)結(jié)果、超時(shí)時(shí)間等。我一般會(huì)用十六進(jìn)制格式打印方便對(duì)比。另外日志要寫到文件里不要只打在 Logcat 里因?yàn)?Logcat 緩沖區(qū)有限時(shí)間長(zhǎng)了會(huì)被沖掉。第三個(gè)技巧是關(guān)于 485 收發(fā)切換的。如果你的 TTL 轉(zhuǎn) 485 模塊不帶自動(dòng)切換需要用 RTS 控制收發(fā)方向那一定要注意切換時(shí)機(jī)。發(fā)送數(shù)據(jù)前先把 RTS 拉高發(fā)送模式發(fā)送完成后等最后一個(gè)字節(jié)完全移出移位寄存器再拉低接收模式。如果拉低太早最后一個(gè)字節(jié)會(huì)丟失如果拉低太晚從站的響應(yīng)可能已經(jīng)開始發(fā)送了會(huì)沖突。我一般會(huì)在發(fā)送完成后延時(shí) 1 個(gè)字符時(shí)間再切換。第四個(gè)技巧是關(guān)于電源的。Android 平板和 485 模塊的電源一定要共地否則 485 通信會(huì)不穩(wěn)定。如果 Android 平板用電池供電485 模塊用外部電源供電那一定要把兩者的地線連在一起。我遇到過(guò)好幾次通信不穩(wěn)定的情況最后發(fā)現(xiàn)都是地線沒(méi)接好。4.3 性能優(yōu)化與穩(wěn)定性提升在穩(wěn)定性方面我做了幾件事。第一把串口讀寫放在單獨(dú)的服務(wù)里跟 UI 生命周期解耦。這樣即使 UI 退到后臺(tái)串口通信也不會(huì)中斷。第二加了看門狗機(jī)制定期檢查串口是否還在正常工作如果發(fā)現(xiàn)異常就自動(dòng)重啟串口服務(wù)。第三加了通信質(zhì)量統(tǒng)計(jì)記錄每次通信的成功率和平均響應(yīng)時(shí)間方便后期分析。在性能方面主要是減少不必要的內(nèi)存分配。Modbus 幀的組包和解包都在字節(jié)數(shù)組上操作避免頻繁創(chuàng)建對(duì)象。接收緩沖區(qū)用固定大小的環(huán)形緩沖區(qū)避免動(dòng)態(tài)擴(kuò)容帶來(lái)的開銷。發(fā)送隊(duì)列用ArrayBlockingQueue容量設(shè)為 64足夠緩沖短時(shí)間內(nèi)的突發(fā)請(qǐng)求。還有一個(gè)細(xì)節(jié)是關(guān)于read()方法的。android-serialport-api的read()方法會(huì)阻塞直到有數(shù)據(jù)可讀但如果你設(shè)置的超時(shí)時(shí)間太長(zhǎng)線程會(huì)一直卡在那里。我一般會(huì)把read()放在一個(gè)循環(huán)里每次讀取前先檢查一下是否有數(shù)據(jù)可讀如果沒(méi)有就sleep一小段時(shí)間再試。這樣雖然稍微增加了 CPU 占用但可以避免線程長(zhǎng)時(shí)間阻塞。實(shí)操心得如果你的應(yīng)用需要長(zhǎng)時(shí)間運(yùn)行建議定期重啟串口服務(wù)。我實(shí)測(cè)下來(lái)連續(xù)運(yùn)行 72 小時(shí)后串口通信的成功率會(huì)從 99.9% 降到 99% 左右雖然看起來(lái)降得不多但在工業(yè)場(chǎng)景下1% 的失敗率已經(jīng)很高了。后來(lái)我加了一個(gè)定時(shí)重啟機(jī)制每 24 小時(shí)重啟一次串口服務(wù)成功率就穩(wěn)定在 99.9% 以上了。5. 從踩坑到填坑的完整復(fù)盤5.1 那些讓我熬夜的瞬間第一個(gè)讓我熬夜的問(wèn)題是 fd 泄漏。當(dāng)時(shí)應(yīng)用跑了兩天突然通信不上我以為是硬件問(wèn)題換了模塊、換了線、換了設(shè)備折騰了一整天。最后用lsof一看fd 數(shù)量已經(jīng)到 900 多了離 1024 的上限就差一點(diǎn)。那一刻的心情真的是又氣又笑。第二個(gè)讓我熬夜的問(wèn)題是 CRC 校驗(yàn)。我按照網(wǎng)上的代碼寫了 CRC 計(jì)算但從站一直不響應(yīng)。我用 Modbus Poll 抓了正確的請(qǐng)求幀跟我的請(qǐng)求幀對(duì)比發(fā)現(xiàn) CRC 字節(jié)序反了。Modbus RTU 的 CRC 是低字節(jié)在前高字節(jié)在后而我寫成了高字節(jié)在前。改過(guò)來(lái)之后一次就通了。第三個(gè)讓我熬夜的問(wèn)題是線程沖突。我在一個(gè)線程里寫數(shù)據(jù)在另一個(gè)線程里讀數(shù)據(jù)結(jié)果偶爾會(huì)出現(xiàn)數(shù)據(jù)錯(cuò)亂。后來(lái)加了讀寫鎖問(wèn)題解決了但讀操作經(jīng)常被寫操作阻塞導(dǎo)致響應(yīng)變慢。最后改成了讀寫分離才徹底解決。5.2 最終穩(wěn)定運(yùn)行的方案總結(jié)最終穩(wěn)定運(yùn)行的方案是這樣的串口服務(wù)作為前臺(tái)服務(wù)運(yùn)行保證不被系統(tǒng)殺掉。串口對(duì)象做成單例應(yīng)用啟動(dòng)時(shí)打開應(yīng)用退出時(shí)關(guān)閉。讀寫操作分別走獨(dú)立的線程通過(guò)阻塞隊(duì)列和環(huán)形緩沖區(qū)來(lái)交換數(shù)據(jù)。Modbus 組包和解包都在字節(jié)數(shù)組上操作避免頻繁創(chuàng)建對(duì)象。超時(shí)時(shí)間設(shè)為 200 毫秒重試 3 次指數(shù)退避。485 模塊用帶光耦隔離和自動(dòng)收發(fā)切換的型號(hào)總線兩端接 120 歐姆終端電阻用屏蔽雙絞線。這套方案在我手上的設(shè)備上連續(xù)運(yùn)行了一個(gè)月通信成功率穩(wěn)定在 99.9% 以上沒(méi)有出現(xiàn)過(guò) fd 泄漏或者線程沖突的問(wèn)題。當(dāng)然不同的設(shè)備和場(chǎng)景可能需要微調(diào)但整體思路應(yīng)該是通用的。5.3 后續(xù)可以擴(kuò)展的方向這個(gè)項(xiàng)目后續(xù)還可以往幾個(gè)方向擴(kuò)展。第一支持 Modbus TCP這樣可以通過(guò)網(wǎng)絡(luò)遠(yuǎn)程控制不局限于 485 總線。第二加一個(gè) Web 配置界面方便現(xiàn)場(chǎng)調(diào)試和參數(shù)修改。第三加數(shù)據(jù)記錄和分析功能把通信日志和伺服運(yùn)行數(shù)據(jù)存到數(shù)據(jù)庫(kù)里方便后期分析。第四支持多從站輪詢一條 485 總線上掛多個(gè)伺服驅(qū)動(dòng)板Android 端輪流跟每個(gè)從站通信。我個(gè)人在實(shí)際操作中的體會(huì)是Android 串口通信的難點(diǎn)不在協(xié)議本身而在 Android 系統(tǒng)的權(quán)限管理和資源管理。只要你把權(quán)限問(wèn)題解決了把 fd 泄漏和線程安全問(wèn)題處理好了剩下的就是標(biāo)準(zhǔn)的 Modbus 協(xié)議實(shí)現(xiàn)跟在其他平臺(tái)上沒(méi)什么區(qū)別。但恰恰是這兩個(gè)問(wèn)題最容易讓人掉坑里。希望這篇內(nèi)容能幫你少走一些彎路。