概念
串口 (Serial Port)一种用于设备间串行通信的接口。常见的串口标准包括 RS-232、RS-422 和 RS-485。
RS232
概念
常用DB9接口,通常只使用三根线:
2号脚:RXD(接收数据)
3号脚:TXD(发送数据)
5号脚:GND(信号地)
属于单端信号传输,全双工,发送和接收使用独立线路,可以同时进行,点对点通信,只能连接两台设备。
大传输距离约15米,速率一般不超过20kbps。抗干扰能力弱,不适合长距离和复杂环境。
接线
设备A的TXD → 设备B的RXD
设备A的RXD ← 设备B的TXD
设备A的GND ↔ 设备B的GND
RS485
概念
常采用两线制(也有四线制但较少),常用A/B线:
A(或+):数据正
B(或-):数据负
GND(信号地)
属于差分信号传输,抗干扰能力更强,半双工,发送和接收共用两根线,同一时间只能单向传输。点对多点通信,一条总线上可挂载多达128个设备,适合组建工业网络。
最大传输距离可达1200米(9600bps时),速率最高可达10Mbps。
采用差分信号,抗干扰能力强,适合工业环境。
差分信号(Differential Signal)是指在两根导线上分别传输一个正向信号和一个反向信号(例如 +V 和 -V),接收端通过比较两根线之间的电压差(Vdiff = V+ - V-)来识别逻辑状态(0或1)。这种传输方式不同于单端信号(一根信号线 + 地线),而是用一对线同时传输信号。
RS-485之所以抗干扰强,根本原因就是它使用差分信号传输,而不是像RS-232那样使用相对于地的单端信号。
接收端不关心A或B线的绝对电平,只关心两根线之间的电压差(VA - VB)。
只要干扰是共模的(即同时影响两根线),它就不会影响最终的电压差判断。这就是所谓的共模抑制比,RS-485收发器通常有非常高的CMRR,能有效抑制高达数十伏的共模干扰。
RS-485通过将信号承载于两根线的“差值”上,使得那些同时影响两根线的“共模干扰”在数学上被相互抵消,从而极大地提高了信号在恶劣工业环境中的完整性和可靠性。
接线
设备A的A → 设备B的A
设备A的B → 设备B的B
(所有设备A/A相连,B/B相连,GND可单点接地)
接收和发送
在RS-485编程中,必须非常明确地区分接收端和发送端,尤其是在软件逻辑层面。这主要是由RS-485的半双工特性决定的。
在RS-485总线上,每个节点(无论是主机还是从机)都连接在同一对差分线(A、B)上。
任何一个节点,在物理上都可以发送数据,也可以接收数据。
关键点:它不能同时发送和接收。在某一时刻,整个总线上只能有一个设备处于发送状态,其他所有设备都必须处于接收状态。
编程时,收发的对象决定,由用户代码控制,你的代码必须清楚地知道当前设备在做什么:
作为发送端(主机)时:
将收发器控制引脚(DE_RE_PIN)设置为高电平(发送模式)。
通过串口外设发送数据。
等待数据完全从硬件缓冲区发送出去。
立即、必须将DE_RE_PIN设置为低电平(接收模式),以释放总线。
作为接收端(从机)时:
确保DE_RE_PIN处于低电平(接收模式)。
通过串口外设接收数据。
根据接收到的数据(如地址)决定是否需要响应。
传输数据协议
各个485设备,遵循的是工业标准协议(自定其实也可以),即Modbus RTU。
在RTU模式下,帧与帧之间必须有至少3.5个字符时间的静默间隔,用于标识一帧的结束和下一帧的开始。
Modbus是严格的“一问一答”模式,Modbus协议的设计哲学是主从式、查询/响应,同一时刻,只能单个发送,单个应答,无法同一时刻所有485同时应答数据,这个需要程序在设计时考虑好。
Modbus RTU
Modbus RTU数据帧由地址域、功能码、数据域、CRC校验四部分组成:
[地址域: 1字节] [功能码: 1字节] [数据域: N字节] [CRC校验: 2字节]
地址域:1字节,标识目标从机地址,有效范围1~247。
功能码:1字节,指示操作类型(如读/写寄存器)。
数据域:长度可变,包含寄存器地址、数量、值等。
CRC校验:2字节,对整帧数据进行循环冗余校验,低字节在前,高字节在后。
规范定义的功能码:
0x01 读线圈状态 读取输出位状态
0x02 读离散输入 读取输入位状态
0x03 读保持寄存器 读取输出寄存器值
0x04 读输入寄存器 读取输入寄存器值
0x05 写单个线圈 写入单个输出位
0x06 写单个寄存器 写入单个寄存器值
0x0F 写多个线圈 写入多个输出位
0x10 写多个寄存器 写入多个寄存器值
每个功能码对应的数据域也不一样,如0x03定义为:
起始地址(2字节) + 寄存器数量(2字节)
1个 Modbus 寄存器存储 2 个字节(16位)
示例:
发送:
01 03 00 00 00 02 CRC
01: 设备地址
03: 功能码(读保持寄存器)
00 00: 起始地址(从地址0开始)
00 02: 寄存器数量(读取2个寄存器)
这个请求的完整意思是:“喂,地址为01的设备,请把你从地址0开始的2个寄存器(也就是地址0和地址1)的数据,通过03功能码返回给我。”
响应:
假设设备上:
地址0的寄存器里存的值是 1234 (十六进制为 0x04D2)
地址1的寄存器里存的值是 5678 (十六进制为 0x162E)
那么设备会返回:
01 03 04 04 D2 16 2E CRC
我们来解析这个响应:
01: 设备地址
03: 功能码
04: 字节数。这里最关键!
你要了 2个寄存器。
每个寄存器是 2字节。
所以总共返回 2 × 2 = 4 字节的数据。这个 04 就是告诉你后面跟着4个字节。
04 D2: 第一个寄存器的值(1234)
16 2E: 第二个寄存器的值(5678)
Java示例
//核心通信类
import com.fazecast.jSerialComm.SerialPort;
public class RS485Comm {
private SerialPort serialPort;
// 假设我们用一个虚拟的GPIO引脚来控制DE/RE
// 在真实嵌入式Java(如Raspberry Pi)中,这会是一个真实的GPIO操作
private boolean isSendingMode = false;
public RS485Comm(String portName) {
this.serialPort = SerialPort.getCommPort(portName);
// 常用配置: 9600, 8, N, 1
serialPort.setBaudRate(9600);
serialPort.setNumDataBits(8);
serialPort.setParity(SerialPort.NO_PARITY);
serialPort.setNumStopBits(SerialPort.ONE_STOP_BIT);
if (serialPort.openPort()) {
System.out.println("Port opened successfully.");
// 默认处于接收模式
setReceiveMode();
} else {
System.err.println("Failed to open port.");
}
}
// 切换到发送模式 (拉高DE/RE)
private void setTransmitMode() {
if (!isSendingMode) {
System.out.println("[CTRL] Switching to TRANSMIT mode.");
// 在真实硬件上,这里会是 GPIO.output(DE_RE_PIN, GPIO.HIGH);
isSendingMode = true;
}
}
// 切换到接收模式 (拉低DE/RE)
private void setReceiveMode() {
if (isSendingMode) {
System.out.println("[CTRL] Switching to RECEIVE mode.");
// 在真实硬件上,这里会是 GPIO.output(DE_RE_PIN, GPIO.LOW);
isSendingMode = false;
}
}
// 发送数据包
public void sendPacket(byte[] data) {
setTransmitMode();
serialPort.writeBytes(data, data.length);
// 等待数据发送完成
try {
Thread.sleep(50); // 简单延时,确保硬件缓冲区已清空
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
setReceiveMode(); // 发送完毕,立即切换回接收模式
}
// 接收数据包 (带超时)
public byte[] receivePacket(int timeoutMs) {
byte[] buffer = new byte[256];
long startTime = System.currentTimeMillis();
while (System.currentTimeMillis() - startTime < timeoutMs) {
if (serialPort.bytesAvailable() > 0) {
int numRead = serialPort.readBytes(buffer, buffer.length);
byte[] receivedData = new byte[numRead];
System.arraycopy(buffer, 0, receivedData, 0, numRead);
return receivedData;
}
try {
Thread.sleep(10); // 短暂休眠,避免CPU占用过高
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
return null; // 超时
}
public void close() {
serialPort.closePort();
System.out.println("Port closed.");
}
}
// 主机示例
public class Master {
public static void main(String[] args) {
// 假设RS-485适配器连接在COM3 (Windows) 或 /dev/ttyUSB0 (Linux)
RS485Comm comm = new RS485Comm("COM3");
byte slaveAddress = 1;
byte[] query = {slaveAddress, 0x01, 0x55}; // [地址, 功能码, 数据]
System.out.println("[MASTER] Sending query to slave " + slaveAddress);
comm.sendPacket(query);
System.out.println("[MASTER] Waiting for response...");
byte[] response = comm.receivePacket(1000); // 等待1秒
if (response != null && response.length > 0) {
System.out.printf("[MASTER] Received from %02X: %s\n", response[0], bytesToHex(response));
} else {
System.out.println("[MASTER] No response.");
}
comm.close();
}
// 辅助函数:字节数组转十六进制字符串
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02X ", b));
}
return sb.toString();
}
}
//从机示例
public class Slave {
private static final byte MY_ADDRESS = 1;
private static RS485Comm comm;
public static void main(String[] args) {
comm = new RS485Comm("COM3"); // 从机也连接在同一个串口
System.out.printf("[SLAVE %02X] Listening for commands...\n", MY_ADDRESS);
while (true) {
byte[] receivedData = comm.receivePacket(0); // 无限等待
if (receivedData != null && receivedData.length > 0) {
System.out.printf("[SLAVE %02X] Received: %s\n", MY_ADDRESS, bytesToHex(receivedData));
// 检查地址是否匹配
if (receivedData[0] == MY_ADDRESS) {
System.out.printf("[SLAVE %02X] Address matched. Sending response.\n", MY_ADDRESS);
byte[] response = {MY_ADDRESS, 0x02, (byte) 0xAA}; // [我的地址, 功能码, 数据]
comm.sendPacket(response);
}
}
}
}
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02X ", b));
}
return sb.toString();
}
}