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