ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

嵌入式Linux下手写Modbus RTU主站:串口配置、CRC16与传感器读取实战

嵌入式Linux下手写Modbus RTU主站:串口配置、CRC16与传感器读取实战 最近在接手一个嵌入式Linux项目时遇到一个很典型的活板子要通过串口挂一路Modbus RTU传感器把这些数据读回来用在业务逻辑里。很多人一听到Modbus就要去找现成库其实在嵌入式Linux里做Modbus RTU读写并不复杂串口配置、报文拼装、CRC校验这些核心点全搞明白之后手写一个不依赖任何第三方库的主站代码也就两百行左右。这篇文章会从串口配置开始把RS485接线、termios参数、Modbus协议帧、功能码选择、CRC16计算、传感器数据解析完整过一遍。不管你是刚开始接触嵌入式Linux应用开发还是已经在用现成库但被协议细节卡住这套思路都能直接用。顺便说下这次项目里的传感器是环境监测用的温湿度一体探头从站地址可设置寄存器内容按手册来正好能用Modbus RTU最常用的04功能码读取。后面所有例子都以这个场景展开换其他传感器只需改寄存器映射和换算系数。1. 项目整体思路与技术选型1.1 为什么是Modbus RTU而不是Modbus TCP做嵌入式Linux的人对串口都不陌生但遇到Modbus时往往会先纠结一个问题用RTU还是TCP这次的场景很明确传感器在设备旁边通过一根RS485总线挂到板卡的串口上没有网络环境也不适合在每个传感器上都接网线。Modbus RTU作为工业现场最常见的串口协议报文紧凑线路简单传感器厂商几乎默认支持所以选它是最稳妥的。Modbus RTU走的是半双工RS485一个主站下面可以挂多个从站靠地址区分。相比Modbus TCPRTU省掉了TCP/IP协议栈的开销在窄带宽、强干扰的现场反而更直接。RS485总线在没有中继的情况下能跑1200米左右普通传感器仪表、PLC、电力监控设备基本都带这个接口兼容性非常好。如果你只是做板卡和传感器之间的点对点通信RTU的开发和调试成本比TCP更低。1.2 硬件与软件方案选型板卡选型上这次用的是常见ARM Linux开发板主控带原生UART外接了一块RS485转接板。在Linux系统里原生串口通常显示为/dev/ttyS0、/dev/ttyS1USB转出来的串口显示为/dev/ttyUSB0或/dev/ttyACM0。两种设备节点在应用层的操作方式基本一样都是通过termios接口配置串口参数。软件层面我选择直接用C语言写主站逻辑不引第三方库。原因很简单主站只读取一两个传感器寄存器地址固定协议帧格式完全可控自己写反而更好维护。libmodbus这类库功能全但会带来额外的依赖和抽象层在一些裁剪过的嵌入式系统里装起来也麻烦。手写协议栈的另一个好处是能精确控制超时、重试这些行为方便和项目里已有的线程模型对接。调试阶段我建议先装一个mbpoll命令行工具用来验证从站等确认了寄存器地址和字节序之后再写正式代码能省很多时间。2. 硬件接线与系统识别串口设备2.1 RS485接线的几个关键点RS485是差分信号通常用两根线A和B传输注意这跟RS232的TX/RX概念不一样。很多传感器或仪表会把A标成D把B标成D-接反了不会烧设备但就是收不到正常数据。我的习惯是先把设备的说明书翻出来确认A/B定义再动手接线。除此之外还有几个点容易踩坑。第一是共地。RS485虽然理论上不要求共地但在现场环境中如果不把主站和从站的工作地连起来共模电压一高通信就会异常甚至损坏收发器。我做长距离连接时会额外拉一根地线把两端的地连起来。第二是终端电阻。如果总线上只挂了两个设备且距离很短可以不加但如果距离超过几十米或者挂了好几个从站就需要在总线最远端的两个设备上并联120欧姆匹配电阻否则信号反射会让数据出现偶发错乱。第三是传感器供电要满足要求很多温湿度探头是12V或者24V供电不能直接拿板卡的3.3V去带。2.2 确认Linux识别到了哪一个串口接线完成之后先别急着写代码确认系统有没有识别到串口设备。我一般按这几步走插上USB转485模块后执行dmesg | tail -20看有没有出现ch341、ftdi或者cp210x相关的信息。执行ls -l /dev/ttyS* /dev/ttyUSB*确认设备节点存在。如果是USB转串口执行udevadm info -a -n /dev/ttyUSB0能看到厂家ID和产品ID方便后续写udev规则固定设备名。用串口回环测试确认节点可用把485转接板的A和B临时短接然后往串口发数据同时再读这个串口能收到自己发的内容说明驱动和节点正常。在项目里我建议直接写一个udev规则把USB转485设备固定成/dev/ttyrs485之类的名字避免重启后设备名变成ttyUSB1导致程序找不到设备。规则内容很简单按设备的VID/PID匹配Symlink到固定名字就行。设备节点确认没问题后下一步进入串口参数配置。3. 串口配置实操从stty到termios3.1 动手之前先用stty把参数跑通Linux下配置串口有两种方式命令行用stty代码里用termios。我强烈建议先在终端里用stty把串口参数确认一遍因为这样能最快排除硬件和参数问题。执行stty -F /dev/ttyUSB0 9600 cs8 -cstopb -parenb raw -echo这条命令的含义是把/dev/ttyUSB0设置为9600波特率、8个数据位、1个停止位-cstopb表示不使用2个停止位、无校验-parenb同时设置raw模式和关闭回显。配置完之后可以开一个接收窗口cat /dev/ttyUSB0 然后在另一个终端用printf往串口写几个字节比如printf \x01\x04\x00\x00\x00\x02\x70\x15 /dev/ttyUSB0如果传感器正常响应cat窗口应该会出现一串十六进制字节其中包括从站地址、功能码、字节数和寄存器数据。这里用到的\x70\x15就是前6个字节的Modbus CRC16低字节在前后面会详细讲怎么算。stty能跑通说明接线、设备节点和波特率都没问题接下来再写C代码就心里有底了。3.2 C语言里标准的串口配置流程C代码配置串口的核心是termios结构体。直接上代码逻辑我都写进注释里#include stdio.h #include stdlib.h #include string.h #include fcntl.h #include unistd.h #include termios.h int open_serial(const char *dev, speed_t baud) { int fd open(dev, O_RDWR | O_NOCTTY); if (fd 0) { perror(open serial); return -1; } struct termios opts; memset(opts, 0, sizeof(opts)); tcgetattr(fd, opts); /* 波特率输入输出都要设置 */ cfsetispeed(opts, baud); cfsetospeed(opts, baud); /* 使能接收无视线路控制信号 */ opts.c_cflag | (CLOCAL | CREAD); /* 8数据位、1停止位、无校验 */ opts.c_cflag ~CSIZE; opts.c_cflag | CS8; opts.c_cflag ~CSTOPB; opts.c_cflag ~PARENB; /* 关闭硬件和软件流控 */ opts.c_cflag ~CRTSCTS; opts.c_iflag ~(IXON | IXOFF | IXANY); /* raw模式不处理特殊字符、不启用作弊处理 */ opts.c_lflag ~(ICANON | ECHO | ECHOE | ISIG); opts.c_iflag ~(INLCR | ICRNL | IGNCR); opts.c_oflag ~OPOST; /* 超时设置VMIN0VTIME10表示最多等1秒 */ opts.c_cc[VMIN] 0; opts.c_cc[VTIME] 10; tcsetattr(fd, TCSANOW, opts); tcflush(fd, TCIOFLUSH); return fd; }这里要重点理解raw模式。如果不关掉ICANON串口会按行处理数据读到换行符才返回Modbus帧里又经常会出现0x0A这种字节很容易把逻辑搞乱。关闭ECHO和流控也是同样道理Modbus是主从通信不需要本地回显和硬件握手。3.3 串口配置高频坑位第一个坑是只设置了输入波特率没设置输出波特率。很多例子只调用cfsetispeed结果数据发送速度还是默认的对端解析全乱。用上面的代码就不会出错输入输出必须成对设置。第二个坑是打开串口后缓冲区里可能有脏数据。上电瞬间收发芯片可能产生一些杂波所以配置完串口后执行一次tcflush很关键。我不止一次遇到程序一启动就收到一个字节的乱码其实就是没清缓冲区导致的。第三个坑是非阻塞读取。上面代码用的是VMIN0、VTIME10的方式read最多阻塞1秒后返回这个策略对Modbus轮询已经够用。如果你的程序里需要更精细的超时可以用select或者给fd设置O_NONBLOCK但要注意read返回-1且errno为EAGAIN时属于正常情况不能当成串口错误处理。第四个坑是发送和接收的切换时间。RS485是半双工总线发送完一帧后不能立刻切到接收状态收发器有个切换延时。我在代码里会在write之后usleep几百微秒到1毫秒再进入读等待否则容易丢掉从站回来的第一个字节。4. Modbus RTU协议核心细节4.1 一帧报文到底长什么样Modbus RTU的报文结构非常固定从站地址、功能码、数据和CRC16校验。所有字段都是高位字节在前只有CRC16是低字节先发送。举个例子读从站1的输入寄存器从地址0x0000开始读2个寄存器请求帧就是01 04 00 00 00 02 70 15拆开看01是从站地址04是功能码00 00是起始寄存器地址00 02是寄存器数量70 15是CRC16低字节在前。共8个字节。正常响应帧是这样的01 04 04 00 64 00 C8 xx xx其中01是从站地址原样返回04是功能码原样返回04表示后面数据有4个字节00 64是第一个寄存器数据十六进制10000 C8是第二个寄存器数据十六进制200最后两个字节是CRC。从站的地址域和功能码必须和请求帧一致如果从站返回的地址对不上说明总线上的设备可能地址配置有冲突。4.2 功能码怎么选寄存器地址怎么理解Modbus协议里功能码很多这次只需要记住最常用的几个03读保持寄存器一般用于读写型数据PLC里叫40001区。04读输入寄存器一般用于只读型测量值PLC里叫30001区。06写单个寄存器用于设参数。160x10写多个寄存器用于批量设置。很多传感器把温度、湿度这些测量值放在输入寄存器里所以04功能码用得最多。一个寄存器是16位也就是2字节。如果传感器数据是32位浮点数那就要连续读2个寄存器然后自己拼成4字节float。寄存器地址在软件里一般从0开始编号但有些仪表说明书写的是40001这种PLC地址换算时要减1这个非常容易搞错我在后面排查部分会专门讲。4.3 CRC16计算原理和实现Modbus RTU的CRC16和普通CRC16不一样多项式是0xA001初始值是0xFFFF计算完后低字节在前发送。算法本身不复杂逐位计算即可uint16_t modbus_crc16(const uint8_t *buf, int len) { uint16_t crc 0xFFFF; for (int i 0; i len; i) { crc ^ buf[i]; for (int j 0; j 8; j) { if (crc 1) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }计算请求帧01 04 00 00 00 02得到的CRC是0x1570发送时先写低字节0x70再写高字节0x15所以完整的发送缓冲是01 04 00 00 00 02 70 15。接收方的校验也一样把收到的整帧不含CRC重新算一遍CRC和收到的两个字节比对如果不一致就说明传输过程中有干扰直接丢弃这帧。5. 读取传感器数据的完整代码实现5.1 构建请求帧并发送把上面的知识串成一个实际的读取函数。这个函数接受串口fd、从站地址、功能码、起始寄存器和寄存器数量自动拼装请求帧并发送int modbus_read_request(int fd, int slave, uint8_t fn, uint16_t start, uint16_t count) { uint8_t tx[8]; uint16_t crc; tx[0] slave; tx[1] fn; tx[2] start 8; tx[3] start 0xFF; tx[4] count 8; tx[5] count 0xFF; crc modbus_crc16(tx, 6); tx[6] crc 0xFF; tx[7] crc 8; if (write(fd, tx, 8) ! 8) return -1; /* 留出RS485收发切换时间 */ usleep(1000); return 0; }这里有个细节Modbus RTU要求帧与帧之间有至少3.5个字符时间的间隔主站发完请求后也要等一段时间才能发下一帧。9600波特率下1个字符大约1毫秒3.5个字符就是3.5毫秒。所以轮询循环里两次请求之间最好加一点延时或者确保读响应加处理逻辑本身就超过了这个时间不要连续狂发。5.2 等待响应并按帧解析发送完请求后用带超时的read读取从站响应。下面是解析函数重点做三件事检查从站地址和功能码、检验CRC、返回数据长度int modbus_read_response(int fd, uint8_t slave, uint8_t fn, uint8_t *data, int max_len) { uint8_t buf[256]; int len 0; struct timeval tv; fd_set rdfs; int ret; /* 用select实现100ms超时 */ FD_ZERO(rdfs); FD_SET(fd, rdfs); tv.tv_sec 0; tv.tv_usec 100000; ret select(fd 1, rdfs, NULL, NULL, tv); if (ret 0) return -1; len read(fd, buf, sizeof(buf)); if (len 5) return -1; if (buf[0] ! slave) return -1; /* 异常响应功能码最高位置1 */ if (buf[1] 0x80) { printf(slave error code: 0x%02X\n, buf[2]); return -1; } if (buf[1] ! fn) return -1; uint16_t crc_calc modbus_crc16(buf, len - 2); uint16_t crc_recv buf[len - 2] | (buf[len - 1] 8); if (crc_calc ! crc_recv) return -1; memcpy(data, buf 3, buf[2]); return buf[2]; }select超时这个做法比较常用100毫秒对大多数RS485传感器都够。如果现场从站多、响应慢可以把这个超时值做成参数。校验CRC这步一定不能省现场电磁干扰会让偶发错帧混进来不校验的话一次错数据就可能触发误动作。5.3 把寄存器数据换算成物理量传感器返回的寄存器值通常是原始整数需要根据手册换算。比如这次用的温湿度传感器返回的寄存器值是16位有符号整数温度值除以10就是摄氏度湿度值除以10就是百分比。解析代码很直观int16_t raw (int16_t)((data[0] 8) | data[1]); float temp raw / 10.0f;如果是32位浮点数比如一些风速、气压传感器数据分布在两个连续寄存器里这时要处理字节序。不同厂商的浮点存储顺序不一样常见的有两种一种是高字在前大端ABCD一种是字序交换CDAB还有极少数低字在前BADC。我提供一个通用转换函数float regs2float(uint16_t r0, uint16_t r1, int order) { uint32_t u; switch (order) { case 0: /* ABCD高位寄存器在前 */ u ((uint32_t)r0 16) | r1; break; case 1: /* CDAB低位字在前字节不交换 */ u ((uint32_t)r1 16) | r0; break; case 2: /* BADC寄存器内字节交换 */ u ((uint32_t)((r0 8) | (r0 8)) 16) | ((r1 8) | (r1 8)); break; default: u 0; break; } float f; memcpy(f, u, sizeof(f)); return f; }新项目拿到传感器后建议先用mbpoll按4:float方式读一下再用这个函数试不同的order哪个读出来是合理物理量就用哪个然后把结果固化成配置项不要每个版本都猜。5.4 超时重试与多从站轮询思路单次读取容易受干扰失败所以正式业务里要有重试机制。我常用的模式是失败后隔50毫秒重试连续重试3次都失败就报一次错误然后把该从站标记为暂离等下一轮轮询再试。这个策略对环境类数据足够但如果是设备连锁控制重试逻辑要更谨慎避免重复动作。多从站轮询的思路也简单遍历从站地址依次调用modbus_read_request和modbus_read_response。但要注意总线空闲时要等3.5个字符时间再发起下一帧请求否则有些从站会认为上一帧还没结束。轮询周期可以设置成500毫秒一轮这样既不会给总线太大压力也能及时拿到数据变化。6. 常见问题排查与经验备忘6.1 现象对照排查表我把自己在调试过程中遇到的典型问题整理成一张表每次现场出问题都能快速对照现象可能原因排查方法收不到任何响应A/B接反、波特率不对、从站地址错误先检查接线再用stty确认波特率用mbpoll读地址偶尔收到乱码没加终端电阻、共地不良、干扰大加120欧姆电阻补共地线缩短接线距离CRC校验总失败波特率不匹配、从站返回帧超时被截断逻辑分析仪抓波形确认完整帧读到数值明显不对寄存器地址偏移、字节序或换算系数错误查手册比对mbpoll读出的原始值程序一启动就多读一个字节串口缓冲区有上电脏数据打开后立即tcflush功能码异常错误从站不支持该功能码或地址越界用03和04分别试确认有效寄存器范围6.2 用mbpoll和逻辑分析仪定位问题每次写代码我都先不稳定先把协议验证清楚。mbpoll是一个非常好用的Modbus主站调试工具在Linux下可以快速读寄存器mbpoll -a 1 -t 4:x -r 1 -c 2 -b 9600 /dev/ttyUSB0这条命令表示读取从站1、功能码04、起始寄存器1、连续2个寄存器波特率9600。-t 4:x表示按十六进制显示想看浮点可以换成-t 4:float。如果mbpoll能读出来说明从站配置、接线和参数都没问题问题只会在我们自己代码里。如果mbpoll也读不出来就要上逻辑分析仪或者示波器。逻辑分析仪可以接在485收发器芯片的RO和DI引脚上也就是UART电平的TX和RX直接抓波形。从抓到的数据能清楚看到请求帧有没有发出去、从站有没有回、波形时序对不对。这个方法尤其在排查收发切换时间不够的时候特别管用。6.3 几个值得记住的实操心得做嵌入式Linux的Modbus RTU开发有些经验是文档里不写的。我吃了不少亏后总结了几条。第一寄存器地址别直接用仪表说明书上的40001这种PLC地址软件里通常要减1也就是40001对应地址0。第二很多传感器恢复出厂设置后从站地址、波特率都变成默认值如果调试一直不通先看是不是地址和波特率被改过。第三发送之前先检查485转接板的供电和地线好多程序没问题但通信就是不通的情况最后都是硬件接触不良。第四代码里把超时和重试次数做成可配置项现场调试时能省很多重新编译的功夫。最后再分享一个小技巧我在串口打开函数里会故意先发一帧广播式的无效请求比如从站地址0xFF然后清空缓冲区。这样上电瞬间产生的杂波和从站可能主动上报的残留数据都会被清掉之后业务轮询的第一帧数据就是干净的。看似多余但能避免很多奇奇怪怪的首帧异常。嵌入式Linux下的Modbus开发其实并不神秘把串口配置、协议帧、CRC、超时重试这几块理顺了后续接任何RS485传感器都只是一个参数修改的活。
返回列表