
搞嵌入式Linux也有幾年了每次碰到Modbus相關需求說到底就是兩件事把串口打通把報文處理好。最近剛完成一個用RK3568開發板通過RS485總線讀取多路Modbus RTU傳感器數據的項目從串口設備適配到協議解析攢了不少經驗。這篇文章就把完整過程拉通講一遍從串口配置、RTU報文構造到實際讀寫代碼附帶我踩過的坑和調試工具用法適合接著Linux串口開發入門的朋友也適合剛上手工控傳感器采集的嵌入式工程師參考。1. 項目整體思路與方案選型1.1 為什么選Modbus RTU而不是其他協議工業現場最常見的傳感器通信協議就是Modbus它分RTU和TCP兩種形態。TCP適合走網絡但現場傳感器大多是RS485總線接口用Modbus RTU更直接。RTU傳輸的是二進制幀一幀報文短、解析快在9600波特率下也能穩定跑幾十米工業里很多溫濕度、壓力、液位傳感器都原生支持RTU。這次項目里傳感器分布在車間不同位置布線用兩芯雙絞線接RS485主控端用一個USB轉485模塊接到Linux開發板的串口省去單獨拉電源線的麻煩總線供電由傳感器側提供。RTU還有一個好處是主從結構清晰。總線上只有主機主動發請求傳感器作為從機應答不會出現多設備同時搶總線的問題。對Linux應用層來說無非就是“打開串口-寫請求-讀應答”這一套循環沒有復雜的連接態管理配合簡單的輪詢邏輯就能控制幾十個節點。1.2 硬件連接與系統環境梳理硬件上最需要注意的就是RS485方向控制。RS485是半雙工總線收發共用電線必須保證主機在發送時關閉接收接收時關閉發送。市面上很多USB轉485模塊內置了自動方向切換電路比如CH340T加MAX13487的方案USB插上就能用方向切換由芯片完成Linux側不用改GPIO。但如果用SP3485這種不帶自動收發切換的芯片就需要額外接一個GPIO控制RE/DE引腳發送前拉高發送完拉低否則會出現自己發自己收、總線沖突之類的怪現象。系統環境我用的是Buildroot編譯的嵌入式Linux鏡像內核需要啟用8250串口驅動和USB-serial驅動。如果板子是ARM架構的通常默認串口節點是/dev/ttyS0、/dev/ttyS1這類USB轉485設備一般是/dev/ttyUSB0。上電后先插上USB轉485模塊用dmesg | grep tty確認設備節點是否識別出來再用ls -l /dev/ttyUSB*看看權限很多情況下還需要把當前用戶加入dialout組或者直接chmod 666不然open串口會報Permission denied。2. 串口配置先讓數據通路打通2.1 排查串口占用與控制臺沖突嵌入式Linux上串口最隱蔽的坑是板子的調試串口和Modbus通信串口共用同一個tty節點。很多開發板默認把/dev/ttyS0作為內核日志輸出口也就是getty登錄終端這時候應用層打開串口要么失敗要么收到一堆亂碼日志。解決辦法是在內核啟動參數里加上consoletty1或者直接移出consolettyS0,115200把調試串口讓出來。如果只是臨時測試也可以先用systemctl stop serial-gettyttyS0.service停掉登錄進程再接程序。可以先運行cat /proc/tty/driver/serial查看串口占用情況也可以lsof /dev/ttyS3看誰在占用設備。確認沒有占用之后再繼續配參數。實際項目里我用的是/dev/ttyS3不是常見的ttyS0因為ttyS0被我保留給調試用了這樣應用日志和傳感器通信互不干擾。2.2 用stty驗證與termios代碼配置Linux下串口配置的標準接口是termios。雖然可以直接用stty -F /dev/ttyS3 9600 raw -echo這種命令行方式快速驗證但最終還是要寫C代碼。先給出一段我封裝好的串口初始化函數經過多次項目驗證跟著用就行#include stdio.h #include string.h #include fcntl.h #include unistd.h #include termios.h int set_serial(int fd, int baud, int databits, char parity, int stopbits) { struct termios tty; memset(tty, 0, sizeof(tty)); if (tcgetattr(fd, tty) ! 0) { perror(tcgetattr); return -1; } cfsetospeed(tty, baud); cfsetispeed(tty, baud); tty.c_cflag ~PARENB; tty.c_cflag ~CSTOPB; tty.c_cflag ~CSIZE; tty.c_cflag ~CRTSCTS; tty.c_cflag | CLOCAL | CREAD; if (databits 8) tty.c_cflag | CS8; else if (databits 7) tty.c_cflag | CS7; if (parity E || parity e) tty.c_cflag | PARENB; if (parity O || parity o) tty.c_cflag | PARENB | PARODD; if (stopbits 2) tty.c_cflag | CSTOPB; tty.c_iflag ~(IXON | IXOFF | IXANY); tty.c_lflag ~(ICANON | ECHO | ECHOE | ISIG); tty.c_oflag ~OPOST; tty.c_cc[VMIN] 1; tty.c_cc[VTIME] 10; tcsetattr(fd, TCSANOW, tty); tcflush(fd, TCIOFLUSH); return 0; }關鍵點我都注釋在代碼里了。CLOCAL和CREAD必須同時打開前者避免程序意外變成“掛斷”狀態后者是開啟接收。如果不開CREAD波特率即使配對了也收不到數據。VMIN1表示讀函數等到至少收到1個字節才返回VTIME10表示最多等1秒超時這兩個配合起來可以防止無限阻塞。注意baud參數這里傳的是termios波特率宏調用的時候要寫成int fd open(/dev/ttyS3, O_RDWR | O_NOCTTY | O_NONBLOCK); if (fd 0) { perror(open); return -1; } set_serial(fd, B9600, 8, N, 1);用O_NONBLOCK打開再配合后續poll讀取比直接阻塞讀更優雅。如果打開時用了O_NONBLOCK后面tcsetattr之后記得把文件描述符重新設置回阻塞模式或者始終用select/poll做超時控制。2.3 關鍵參數背后的邏輯波特率、數據位、校驗位Modbus RTU最常見的參數組合是9600, 8, N, 1波特率9600、8個數據位、無校驗、1個停止位。不一定所有傳感器都這樣有些設備默認是19200或者帶偶校驗所以拿到設備第一件事翻開說明書確認從站地址、波特率和校驗方式。這里解釋下為什么校驗位會影響RTU報文的字節格式Modbus RTU對“字符校驗”只考慮奇偶校驗但它同時在幀尾還有獨立的CRC校驗。很多論文里會強調即使使用無校驗8N1CRC依然能檢測錯誤所以工控場合用8N1完全沒問題。如果設備設置成8E1數據位實際是7個有效數據位加1位偶校驗這在讀ASCII碼字符時更常見RTU里很少用但沒有特殊需求就統一按8N1跑。我習慣統一把所有傳感器設置為相同的波特率比如全部9600然后寫一個全局配置數組每個從站節點包含地址、功能碼、寄存器起始地址和數量。這樣輪詢邏輯和串口參數解耦后面換傳感器只需改數組。3. Modbus RTU報文拆解與CRC16實現3.1 報文幀結構與功能碼Modbus RTU一幀報文由“從站地址功能碼數據CRC校驗”組成。地址占1字節功能碼1字節數據長度由功能碼決定CRC16占2字節低字節在前。幀數據之間沒有分隔符完全靠時間間隔區分幀邊界標準規定幀內字符間隔不超過1.5個字符時間幀間隔至少3.5個字符時間。在9600波特率下一個字符大約1ms所以接收一幀后至少要等約4ms才能處理下一幀。實際項目里我在發送請求后等待20ms再讀避免把傳感器應答的“后半截”吞掉。功能碼里最常用的是0x03讀保持寄存器、0x04讀輸入寄存器、0x06寫單個寄存器、0x10寫多個寄存器。傳感器數據大多是保持寄存器也就是可以用Modbus工具通過修改寄存器值來校準零點。如果只需要讀傳感器用0x03和0x04就夠。這里有個容易搞混的點保持寄存器是一般意義上的可讀寫RAM區輸入寄存器是只讀輸入量。很多傳感器手冊會明確寫“讀取溫度使用功能碼03”照做就行。3.2 CRC16計算原理與C語言實現CRC校驗是整個RTU報文最容易寫錯的地方。Modbus的CRC16是基于多項式0x8005反轉形式是0xA001初始值是0xFFFF。計算時對每個字節先異或到CRC低字節再右移8次每次檢測最低位如果是1就異或0xA001。最終得到的16位值在發送時先放低字節再放高字節。直接給一份能跑的按位實現邏輯簡單、好調試uint16_t modbus_crc16(uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ buf[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }如果數據量大、輪詢節點多建議改成查表法。查表其實就是把每個字節在所有余數狀態下的結果預先算成256項的表格運行時一次異或加查表速度能快不少。但對低速串口來說按位版本已經綽綽有余因為瓶頸在串口傳輸而不是CRC計算。驗證CRC的一個標準方法是把整個請求幀包括地址、功能碼、數據、CRC低字節、CRC高字節重新喂給CRC函數計算得到的結果必須是0x0000。這一點可以用來排查收發兩端CRC算法是否一致如果收到的應答數據里從機返回的CRC跟自己在主機側重新算的CRC值對不上說明要么數據在傳輸中被干擾要么你的CRC算法實現有誤。3.3 報文組裝實例假設要從站地址1讀起始寄存器0x0000連續讀2個寄存器對應請求報文是字段值從站地址0x01功能碼0x03起始地址高字節0x00起始地址低字節0x00寄存器數量高字節0x00寄存器數量低字節0x02CRC低字節0xC4CRC高字節0x0B這里的CRC值是我用上面的函數算出來的。組裝代碼uint8_t request[8]; request[0] 0x01; // slave addr request[1] 0x03; // function code request[2] 0x00; // start addr high request[3] 0x00; // start addr low request[4] 0x00; // reg count high request[5] 0x02; // reg count low uint16_t crc modbus_crc16(request, 6); request[6] crc 0xFF; request[7] crc 8; write(fd, request, sizeof(request));正常收到應答幀格式是地址、功能碼、字節數、數據、CRC。比如返回4個字節數據整體長度是111429字節。如果返回的是異常功能碼比如0x83那接下來一個字節是異常碼常見的有01非法功能、02非法地址、03非法數據值收到這些基本可以確定是主站發的請求字段有問題而不是傳感器壞了。4. 讀寫傳感器數據的完整代碼邏輯4.1 主站發送請求與接收應答主站程序的核心是“發請求-等應答-校驗CRC-解析數據”。我在實際項目里寫了一個讀寄存器的通用函數輸入從站地址、寄存器起始地址和數量輸出解析好的寄存器數組。int modbus_read_regs(int fd, uint8_t slave, uint16_t start_reg, uint16_t count, uint16_t *regs_out) { uint8_t req[8]; uint8_t rsp[256]; uint8_t crc_calc[256]; req[0] slave; req[1] 0x03; req[2] start_reg 8; req[3] start_reg 0xFF; req[4] count 8; req[5] count 0xFF; uint16_t crc modbus_crc16(req, 6); req[6] crc 0xFF; req[7] crc 8; // 清空串口緩沖防止讀到上次殘留數據 tcflush(fd, TCIOFLUSH); if (write(fd, req, 8) ! 8) { perror(write); return -1; } // 等待應答用poll做超時 struct pollfd pfd; pfd.fd fd; pfd.events POLLIN; int ret poll(pfd, 1, 200); if (ret 0) { printf(poll timeout, slave %d no response\n, slave); return -1; } int n read(fd, rsp, sizeof(rsp)); if (n 5) { printf(response too short: %d bytes\n, n); return -1; } // 校驗CRC memcpy(crc_calc, rsp, n - 2); uint16_t rsp_crc modbus_crc16(crc_calc, n - 2); if (rsp_crc ! 0) { printf(CRC error\n); return -1; } // 檢查地址和功能碼 if (rsp[0] ! slave || rsp[1] ! 0x03) { printf(addr or function code mismatch\n); return -1; } // rsp[2]是字節數數據區緊跟著 int byte_count rsp[2]; if (byte_count ! count * 2) { printf(byte count mismatch: %d\n, byte_count); return -1; } for (int i 0; i count; i) { uint8_t hi rsp[3 i * 2]; uint8_t lo rsp[4 i * 2]; regs_out[i] (hi 8) | lo; } return 0; }這里有個容易被忽略的細節tcflush必須在發送前調用而不是發送后。如果發送后再清串口緩沖可能把從機已經發來的應答也清掉導致永遠收不到數據。我一開始就是在write之后tcflush折騰了半天。正確的順序是清緩沖-發送-等待-讀取。4.2 解析寄存器數據并轉成浮點讀回來的寄存器是16位無符號整數具體怎么變成物理量要看傳感器手冊。有些傳感器一個寄存器就是一個小數比如溫度值25.6直接編碼成256這時候只需要除以10。但很多專業的傳感器用兩個寄存器拼成32位IEEE754浮點數比如溫濕度、壓力傳感器常用這個格式。32位浮點數在Modbus里的字節序特別亂常見的大端模式是把高16位放在起始寄存器低16位放在第二個寄存器但也有反過來放的。寫了個通用轉換函數float regs_to_float(uint16_t high, uint16_t low) { uint32_t tmp ((uint32_t)high 16) | low; float f; memcpy(f, tmp, sizeof(f)); return f; }如果讀出來的數值明顯不對比如溫度變成幾千萬多半是寄存器高低字順序反了把high和low對調再試。還有一種情況是兩個寄存器分別表示整數部分和小數部分比如整數寄存器25、小數寄存器6表示25.6這時就要用整數加小數除以精度來算。總之所有數據解析必須以設備手冊為準這個環節沒有“通用萬能公式”。我習慣在調試階段先把讀到的寄存器原始值打印出來人工帶一兩個傳感器的零點值算一遍確認公式無誤后再固化代碼。千萬別一上來就封裝得特別復雜否則排查問題時會多很多噪音。4.3 多傳感器輪詢與超時處理一個RS485總線上通常掛多個傳感器主站需要周期性地挨個查詢。最簡單的輪詢就是for循環typedef struct { uint8_t addr; uint16_t start_reg; uint16_t count; float value; } sensor_node_t; sensor_node_t sensors[] { {0x01, 0x0000, 2, 0}, {0x02, 0x0000, 2, 0}, {0x03, 0x0002, 1, 0}, }; while (1) { for (int i 0; i 3; i) { uint16_t regs[2] {0}; int ret modbus_read_regs(fd, sensors[i].addr, sensors[i].start_reg, sensors[i].count, regs); if (ret 0) { sensors[i].value regs_to_float(regs[1], regs[0]); // 記錄時間戳或入庫 } else { // 記錄錯誤計數連續失敗多次可以告警 } usleep(50000); // 輪詢間隔50ms } sleep(1); // 整體周期1秒 }這個方案看起來簡單但有兩個坑一是每個從站之間必須留足時間間隔不能發完請求立刻發下一幀RS485從機處理和返回需要時間二是要用usleep控制總周期不然串口全速循環會占用大量CPU雖然Linux系統能扛住但在低配板子上還是省著用比較好。如果從站超過10個建議把輪詢拆成兩個線程采集線程負責讀傳感器數據業務線程負責使用最新數據中間用環形緩沖區或共享內存傳遞。不要在一個線程里既采集又做復雜業務一旦業務卡住傳感器數據就會斷層。如果串口讀放在單獨的線程主線程崩潰或重啟時不會影響串口接收。5. 調試工具、常見坑與實測心得5.1 用命令行工具輔助驗證在嵌入式板子上可以通過modpoll這個命令行工具先驗證傳感器地址和寄存器地址是否配置正確。modpoll是一個主站模擬工具可以指定串口設備、波特率、從站地址和寄存器地址直接讀數據modpoll -m rtu -b 9600 -p none -a 1 -r 0 -t 4 /dev/ttyS3-t 4表示讀取保持寄存器-t 3表示讀取輸入寄存器。如果傳感器是浮點可以加-f參數。這種先人工驗證一遍的方式能快速判斷串口和傳感器是不是正常避免自己代碼還沒寫完就陷入排查循環。反向工具是開一個Modbus從站模擬器比如socat配合mbpoll或者直接在PC上跑Modbus Slave工具再讓嵌入式板子當主站去讀。我調試時常用PC上的Modbus Slave模擬傳感器把寄存器值設成固定數據然后板子程序去讀這樣即使手頭沒有真實傳感器也能把協議棧調通。5.2 常見問題與排查速查表根據最近幾年做Modbus項目的經驗把碰到的典型問題整理成了表格方便遇到問題直接對號入座。現象可能原因排查方式open串口失敗設備節點被占用或權限不足lsof /dev/ttyS3加入dialout組發請求無應答從站地址不對、波特率不匹配、接線錯誤modpoll工具交叉驗證示波器看波收到數據但CRC報錯串口被噪聲干擾、收發方向切換太慢降低波特率改用屏蔽雙絞線增加幀間延時應答只有部分字節讀取函數超時太短、VMIN/VTIME配置不當增加超時時間使用poll等待首字節正確后續錯位幀邊界判斷錯誤上一幀殘留發送前tcflush按3.5字符時間切分幀多個從站互相干擾從站地址重復、485總線沒有終端電阻檢查地址唯一性在總線末端加120Ω電阻電壓不足導致無應答傳感器供電不足用獨立電源給傳感器供電避免共地干擾5.3 實測心得與建議最后分享幾個屬于“文檔里不會寫但實際很關鍵”的小經驗。一是不要相信傳感器默認地址一定是1很多設備出廠地址是1但有些廠商默認是255必須先讀說明書或用工具掃描一下。二是串口波特率在9600時很穩定但在115200時如果RS485線太長或屏蔽不好非常容易出CRC錯誤能用9600就別追求高速。三是在嵌入式Linux上如果發現串口接收偶爾“吞字節”優先檢查內核的8250驅動是否啟用了FIFO和硬件流控很多開發板的設備樹里默認把流控引腳配置錯了導致收發異常。實際項目里我還遇到過一個很隱蔽的問題傳感器應答幀長度不定有的設備在異常時會返回5字節短幀我一開始read用固定長度讀取結果每次都卡在讀超時。后面改成先讀前三個字節地址、功能碼、字節數根據字節數再讀剩余長度才徹底解決。這個“二次讀取”的思路在處理工業設備時特別實用。做嵌入式Linux的Modbus開發本質上不是協議有多難而是串口細節和數據處理夠不夠細心。把這套流程跑熟之后再換其他支持Modbus RTU的設備基本就是改寄存器地址的事。我也還在繼續完善這套代碼后面打算加入自動識別從站地址和異常重傳機制讓輪詢邏輯更健壯。