
Rust+Slint 实现ModbusRTU从机调试助手源码分享
- 一、效果展示
- 二、源码分享
- 三、实现原理
-
- 1、整体架构
- [2、Modbus 协议帧结构](#2、Modbus 协议帧结构)
- [3、CRC16 校验算法](#3、CRC16 校验算法)
- [4、RTU 从机服务实现](#4、RTU 从机服务实现)
- 5、寄存器数据模型与多格式解析
- 6、通信日志与统计
- [7、UI 与业务层的数据绑定](#7、UI 与业务层的数据绑定)
- 8、异步任务调度
一、效果展示



二、源码分享
1、工程结构

2、comm_log.rs
rust
use chrono::Local;
/// 通信日志条目
#[derive(Debug, Clone)]
pub struct LogEntryData {
pub timestamp: String,
pub direction: i32, // 0=TX, 1=RX, 2=ERROR
pub raw_text: String,
}
/// 通信日志管理器
#[derive(Clone)]
pub struct CommLog {
entries: Vec<LogEntryData>,
max_entries: usize,
version: u64,
}
impl Default for CommLog {
fn default() -> Self {
Self::new()
}
}
impl CommLog {
pub fn new() -> Self {
Self {
entries: Vec::new(),
max_entries: 5000,
version: 0,
}
}
/// 记录发送数据
pub fn log_tx(&mut self, description: &str) {
self.add_entry(0, description);
}
/// 记录接收数据
pub fn log_rx(&mut self, description: &str) {
self.add_entry(1, description);
}
/// 记录错误
pub fn log_error(&mut self, description: &str) {
self.add_entry(2, description);
}
fn add_entry(&mut self, direction: i32, text: &str) {
let entry = LogEntryData {
timestamp: Local::now().format("%H:%M:%S%.3f").to_string(),
direction,
raw_text: text.to_string(),
};
self.entries.push(entry);
self.version = self.version.wrapping_add(1);
// 限制日志条目数
if self.entries.len() > self.max_entries {
self.entries.drain(0..self.entries.len() - self.max_entries);
}
}
/// 获取所有条目
pub fn entries(&self) -> &[LogEntryData] {
&self.entries
}
/// 获取版本号(每次修改递增)
pub fn version(&self) -> u64 {
self.version
}
/// 清空日志
pub fn clear(&mut self) {
self.entries.clear();
self.version = self.version.wrapping_add(1);
}
/// 格式化原始字节为十六进制字符串
pub fn format_bytes(data: &[u8]) -> String {
data.iter()
.map(|b| format!("{:02X}", b))
.collect::<Vec<_>>()
.join(" ")
}
}
5、data_models.rs
rust
use crate::modbus_types::{FunctionCode, ReadResult};
/// 寄存器数据模型
#[derive(Clone)]
pub struct RegisterModel {
/// 原始值
pub raw_values: Vec<u16>,
/// 起始地址
pub start_address: u16,
/// 当前功能码
pub function_code: FunctionCode,
/// 线圈数据(用于线圈/离散输入模式)
pub coil_values: Vec<bool>,
}
impl RegisterModel {
pub fn new() -> Self {
Self {
raw_values: Vec::new(),
start_address: 0,
function_code: FunctionCode::ReadHoldingRegisters,
coil_values: Vec::new(),
}
}
/// 从读取结果更新
pub fn update_from_result(&mut self, result: &ReadResult, start_addr: u16, fc: FunctionCode) {
self.start_address = start_addr;
self.function_code = fc;
match result {
ReadResult::Registers(regs) => {
self.raw_values = regs.clone();
self.coil_values.clear();
}
ReadResult::Coils(coils) => {
self.coil_values = coils.clone();
self.raw_values.clear();
}
}
}
/// 转换为 Slint 显示模型
pub fn to_slint_rows(&self) -> Vec<slint::ModelRc<slint::SharedString>> {
// 这个函数返回的是每列的数据
// 但更好的方式是返回 RegisterRow 结构体数组
// 实际上我们应该直接生成 Slint 的 RegisterRow
Vec::new()
}
/// 生成 Slint RegisterRow 数组
pub fn to_register_rows(&self) -> Vec<slint::SharedString> {
// 这个函数只是为了辅助,实际逻辑在 generate_rows 中
Vec::new()
}
/// 生成用于 Slint 的行数据
/// 返回 Vec<(address, unsigned, signed, hex, binary, float, ascii)>
pub fn generate_rows(&self) -> Vec<(i32, String, String, String, String, String, String)> {
if self.function_code.is_coil() {
// 线圈模式
return self.coil_values.iter().enumerate().map(|(i, &coil)| {
let addr = self.start_address + i as u16;
let val = if coil { 1u16 } else { 0u16 };
(
addr as i32,
val.to_string(),
val.to_string(),
format!("{:04X}", val),
format!("{:016b}", val),
"-".to_string(),
if coil { "ON" } else { "OFF" }.to_string(),
)
}).collect();
}
// 寄存器模式
self.raw_values.iter().enumerate().map(|(i, &raw)| {
let addr = self.start_address + i as u16;
let unsigned_val = raw;
let signed_val = raw as i16;
let hex_val = format!("{:04X}", raw);
let binary_val = format!("{:016b}", raw);
// IEEE 754 浮点(需要两个寄存器)
let float_val = if i + 1 < self.raw_values.len() {
let next_raw = self.raw_values[i + 1];
let bits = ((raw as u32) << 16) | (next_raw as u32);
let float = f32::from_bits(bits);
if float.is_finite() && float.abs() < 1e10 && (float == 0.0 || float.abs() >= 1e-10) {
format!("{:.4}", float)
} else {
format!("{:e}", float)
}
} else {
"-".to_string()
};
// ASCII
let ascii_val = {
let hi = (raw >> 8) as u8;
let lo = (raw & 0xFF) as u8;
let hi_char = if hi >= 0x20 && hi <= 0x7E { hi as char } else { '.' };
let lo_char = if lo >= 0x20 && lo <= 0x7E { lo as char } else { '.' };
format!("{}{}", hi_char, lo_char)
};
(
addr as i32,
unsigned_val.to_string(),
signed_val.to_string(),
hex_val,
binary_val,
float_val,
ascii_val,
)
}).collect()
}
/// 清空数据
pub fn clear(&mut self) {
self.raw_values.clear();
self.coil_values.clear();
self.start_address = 0;
}
}
/// 格式转换工具
pub mod format_utils {
/// u16 转无符号字符串
pub fn to_unsigned(value: u16) -> String {
value.to_string()
}
/// u16 转有符号字符串
pub fn to_signed(value: u16) -> String {
(value as i16).to_string()
}
/// u16 转十六进制
pub fn to_hex(value: u16) -> String {
format!("{:04X}", value)
}
/// u16 转二进制
pub fn to_binary(value: u16) -> String {
format!("{:016b}", value)
}
/// 两个 u16 转 IEEE 754 浮点
pub fn to_float(high: u16, low: u16) -> f32 {
let bits = ((high as u32) << 16) | (low as u32);
f32::from_bits(bits)
}
/// f32 转两个 u16 寄存器
pub fn from_float(value: f32) -> (u16, u16) {
let bits = value.to_bits();
let high = (bits >> 16) as u16;
let low = bits as u16;
(high, low)
}
/// u16 转 ASCII
pub fn to_ascii(value: u16) -> String {
let hi = (value >> 8) as u8;
let lo = (value & 0xFF) as u8;
let hi_char = if hi >= 0x20 && hi <= 0x7E { hi as char } else { '.' };
let lo_char = if lo >= 0x20 && lo <= 0x7E { lo as char } else { '.' };
format!("{}{}", hi_char, lo_char)
}
/// 解析字符串为 u16(支持十进制和十六进制)
pub fn parse_value(s: &str) -> Result<u16, String> {
let s = s.trim();
if s.starts_with("0x") || s.starts_with("0X") {
u16::from_str_radix(&s[2..], 16).map_err(|e| format!("无效的十六进制: {}", e))
} else if s.starts_with("0b") || s.starts_with("0B") {
u16::from_str_radix(&s[2..], 2).map_err(|e| format!("无效的二进制: {}", e))
} else {
s.parse::<u16>().map_err(|e| format!("无效的十进制: {}", e))
}
}
}
6、modbus_client.rs
rust
use std::net::SocketAddr;
use std::str::FromStr;
use tokio_modbus::prelude::*;
use tokio_modbus::client::{self, Context};
use crate::modbus_types::*;
/// 统一的 Modbus 客户端封装
pub struct ModbusClientWrapper {
ctx: Context,
mode: String,
}
impl ModbusClientWrapper {
/// 连接 TCP
pub async fn connect_tcp(ip: &str, port: u16, slave_id: u8) -> Result<Self, ModbusAppError> {
let addr_str = format!("{}:{}", ip, port);
let socket_addr = SocketAddr::from_str(&addr_str)
.map_err(|e| ModbusAppError::ConnectionError(format!("无效的地址: {}", e)))?;
let slave = Slave(slave_id);
let ctx = client::tcp::connect_slave(socket_addr, slave)
.await
.map_err(|e| ModbusAppError::ConnectionError(e.to_string()))?;
Ok(Self {
ctx,
mode: format!("TCP {}:{}", ip, port),
})
}
/// 连接 RTU
pub async fn connect_rtu(
port_name: &str,
baud_rate: u32,
data_bits: u8,
stop_bits: u8,
parity: ParityMode,
slave_id: u8,
) -> Result<Self, ModbusAppError> {
let parity_mode = match parity {
ParityMode::None => tokio_serial::Parity::None,
ParityMode::Even => tokio_serial::Parity::Even,
ParityMode::Odd => tokio_serial::Parity::Odd,
};
let data_bits_mode = match data_bits {
5 => tokio_serial::DataBits::Five,
6 => tokio_serial::DataBits::Six,
7 => tokio_serial::DataBits::Seven,
_ => tokio_serial::DataBits::Eight,
};
let stop_bits_mode = match stop_bits {
1 => tokio_serial::StopBits::One,
_ => tokio_serial::StopBits::Two,
};
let builder = tokio_serial::new(port_name, baud_rate)
.data_bits(data_bits_mode)
.stop_bits(stop_bits_mode)
.parity(parity_mode);
let serial_port = tokio_serial::SerialStream::open(&builder)
.map_err(|e| ModbusAppError::ConnectionError(format!("串口打开失败: {}", e)))?;
let slave = Slave(slave_id);
let ctx = client::rtu::attach_slave(serial_port, slave);
Ok(Self {
ctx,
mode: format!("RTU {} {}-{}{}{}", port_name, baud_rate, data_bits, stop_bits, parity),
})
}
/// 读取线圈 (FC 01)
pub async fn read_coils(&mut self, addr: u16, cnt: u16) -> Result<Vec<bool>, ModbusAppError> {
self.ctx
.read_coils(addr, cnt)
.await
.map_err(|e| ModbusAppError::ProtocolError(e.to_string()))?
.map_err(|e| ModbusAppError::ProtocolError(format!("异常: {:?}", e)))
}
/// 读取离散输入 (FC 02)
pub async fn read_discrete_inputs(&mut self, addr: u16, cnt: u16) -> Result<Vec<bool>, ModbusAppError> {
self.ctx
.read_discrete_inputs(addr, cnt)
.await
.map_err(|e| ModbusAppError::ProtocolError(e.to_string()))?
.map_err(|e| ModbusAppError::ProtocolError(format!("异常: {:?}", e)))
}
/// 读取保持寄存器 (FC 03)
pub async fn read_holding_registers(&mut self, addr: u16, cnt: u16) -> Result<Vec<u16>, ModbusAppError> {
self.ctx
.read_holding_registers(addr, cnt)
.await
.map_err(|e| ModbusAppError::ProtocolError(e.to_string()))?
.map_err(|e| ModbusAppError::ProtocolError(format!("异常: {:?}", e)))
}
/// 读取输入寄存器 (FC 04)
pub async fn read_input_registers(&mut self, addr: u16, cnt: u16) -> Result<Vec<u16>, ModbusAppError> {
self.ctx
.read_input_registers(addr, cnt)
.await
.map_err(|e| ModbusAppError::ProtocolError(e.to_string()))?
.map_err(|e| ModbusAppError::ProtocolError(format!("异常: {:?}", e)))
}
/// 写单个线圈 (FC 05)
pub async fn write_single_coil(&mut self, addr: u16, value: bool) -> Result<(), ModbusAppError> {
self.ctx
.write_single_coil(addr, value)
.await
.map_err(|e| ModbusAppError::ProtocolError(e.to_string()))?
.map_err(|e| ModbusAppError::ProtocolError(format!("异常: {:?}", e)))
}
/// 写单个寄存器 (FC 06)
pub async fn write_single_register(&mut self, addr: u16, value: u16) -> Result<(), ModbusAppError> {
self.ctx
.write_single_register(addr, value)
.await
.map_err(|e| ModbusAppError::ProtocolError(e.to_string()))?
.map_err(|e| ModbusAppError::ProtocolError(format!("异常: {:?}", e)))
}
/// 写多个线圈 (FC 15)
pub async fn write_multiple_coils(&mut self, addr: u16, values: &[bool]) -> Result<(), ModbusAppError> {
self.ctx
.write_multiple_coils(addr, values)
.await
.map_err(|e| ModbusAppError::ProtocolError(e.to_string()))?
.map_err(|e| ModbusAppError::ProtocolError(format!("异常: {:?}", e)))
}
/// 写多个寄存器 (FC 16)
pub async fn write_multiple_registers(&mut self, addr: u16, values: &[u16]) -> Result<(), ModbusAppError> {
self.ctx
.write_multiple_registers(addr, values)
.await
.map_err(|e| ModbusAppError::ProtocolError(e.to_string()))?
.map_err(|e| ModbusAppError::ProtocolError(format!("异常: {:?}", e)))
}
/// 执行读取请求
pub async fn execute_read(&mut self, req: &ReadRequest) -> Result<ReadResult, ModbusAppError> {
match req.function_code {
FunctionCode::ReadCoils => {
let coils = self.read_coils(req.start_address, req.quantity).await?;
Ok(ReadResult::Coils(coils))
}
FunctionCode::ReadDiscreteInputs => {
let inputs = self.read_discrete_inputs(req.start_address, req.quantity).await?;
Ok(ReadResult::Coils(inputs))
}
FunctionCode::ReadHoldingRegisters => {
let regs = self.read_holding_registers(req.start_address, req.quantity).await?;
Ok(ReadResult::Registers(regs))
}
FunctionCode::ReadInputRegisters => {
let regs = self.read_input_registers(req.start_address, req.quantity).await?;
Ok(ReadResult::Registers(regs))
}
_ => Err(ModbusAppError::InvalidParam("不是读取功能码".to_string())),
}
}
/// 执行写入请求
pub async fn execute_write(&mut self, req: &WriteRequest) -> Result<(), ModbusAppError> {
match req.function_code {
FunctionCode::WriteSingleCoil => {
let value = req.values.first().copied().unwrap_or(0) != 0;
self.write_single_coil(req.address, value).await
}
FunctionCode::WriteSingleRegister => {
let value = req.values.first().copied().unwrap_or(0);
self.write_single_register(req.address, value).await
}
FunctionCode::WriteMultipleCoils => {
let values: Vec<bool> = req.values.iter().map(|&v| v != 0).collect();
self.write_multiple_coils(req.address, &values).await
}
FunctionCode::WriteMultipleRegisters => {
self.write_multiple_registers(req.address, &req.values).await
}
_ => Err(ModbusAppError::InvalidParam("不是写入功能码".to_string())),
}
}
/// 获取连接描述
pub fn connection_description(&self) -> &str {
&self.mode
}
}
7、modbus_server.rs
rust
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::Mutex as TokioMutex;
use tokio_serial::SerialStream;
use crate::comm_log::CommLog;
use crate::statistics::CommStatistics;
/// CRC16 Modbus 计算
fn crc16(data: &[u8]) -> u16 {
let mut crc: u16 = 0xFFFF;
for &byte in data {
crc ^= byte as u16;
for _ in 0..8 {
if crc & 1 != 0 {
crc = (crc >> 1) ^ 0xA001;
} else {
crc >>= 1;
}
}
}
crc
}
/// 寄存器组
#[derive(Clone)]
pub struct RegisterBank {
pub coils: Vec<bool>,
pub discrete_inputs: Vec<bool>,
pub holding_registers: Vec<u16>,
pub input_registers: Vec<u16>,
pub slave_id: u8,
pub version: u64,
}
impl RegisterBank {
pub fn new(slave_id: u8, reg_count: usize) -> Self {
Self {
coils: vec![false; reg_count],
discrete_inputs: vec![false; reg_count],
holding_registers: vec![0; reg_count],
input_registers: vec![0; reg_count],
slave_id,
version: 0,
}
}
pub fn resize(&mut self, count: usize) {
self.coils.resize(count, false);
self.discrete_inputs.resize(count, false);
self.holding_registers.resize(count, 0);
self.input_registers.resize(count, 0);
}
}
/// 从机服务端
pub struct ModbusSlaveServer {
serial: SerialStream,
bank: Arc<TokioMutex<RegisterBank>>,
log: Arc<TokioMutex<CommLog>>,
stats: Arc<TokioMutex<CommStatistics>>,
}
impl ModbusSlaveServer {
pub fn new(
serial: SerialStream,
bank: Arc<TokioMutex<RegisterBank>>,
log: Arc<TokioMutex<CommLog>>,
stats: Arc<TokioMutex<CommStatistics>>,
) -> Self {
Self { serial, bank, log, stats }
}
/// 运行从机服务循环
pub async fn run(&mut self) -> std::io::Result<()> {
let mut buf = vec![0u8; 256];
let mut frame_buf = Vec::with_capacity(256);
let mut last_activity = std::time::Instant::now();
loop {
let n = match tokio::time::timeout(
std::time::Duration::from_millis(100),
self.serial.read(&mut buf),
).await {
Ok(Ok(0)) => return Ok(()),
Ok(Ok(n)) => n,
Ok(Err(e)) => return Err(e),
Err(_) => {
// 超时 - 检查帧间隔(RTU 3.5字符时间)
if !frame_buf.is_empty() && last_activity.elapsed() > std::time::Duration::from_millis(50) {
self.process_frame(&frame_buf).await;
frame_buf.clear();
}
continue;
}
};
last_activity = std::time::Instant::now();
frame_buf.extend_from_slice(&buf[..n]);
// 帧结束检测:超过 50ms 无新数据视为一帧
// 这里简化处理,收到数据后立即尝试处理完整帧
if frame_buf.len() >= 4 {
// 最小 Modbus RTU 帧长度 = 地址(1) + 功能码(1) + CRC(2) = 4
let len = frame_buf.len();
let received_crc = u16::from_le_bytes([frame_buf[len - 2], frame_buf[len - 1]]);
let calculated_crc = crc16(&frame_buf[..len - 2]);
if received_crc == calculated_crc {
self.process_frame(&frame_buf).await;
frame_buf.clear();
} else if len > 256 {
// 帧过长,丢弃
frame_buf.clear();
}
}
}
}
async fn process_frame(&mut self, frame: &[u8]) {
if frame.len() < 4 {
return;
}
let slave_addr = frame[0];
let function_code = frame[1];
// 检查是否是本从机的地址(或广播地址0)
let bank = self.bank.lock().await;
if slave_addr != bank.slave_id && slave_addr != 0 {
return;
}
let my_slave_id = bank.slave_id;
drop(bank);
// 记录接收
let rx_hex: String = frame.iter().map(|b| format!("{:02X}", b)).collect::<Vec<_>>().join(" ");
{
let mut log = self.log.lock().await;
log.log_rx(&format!("← {}", rx_hex));
}
{
let mut stats = self.stats.lock().await;
stats.record_rx();
}
// 解析并处理请求
let response = match function_code {
0x01 => self.handle_read_coils(frame).await,
0x02 => self.handle_read_discrete_inputs(frame).await,
0x03 => self.handle_read_holding_registers(frame).await,
0x04 => self.handle_read_input_registers(frame).await,
0x05 => self.handle_write_single_coil(frame).await,
0x06 => self.handle_write_single_register(frame).await,
0x0F => self.handle_write_multiple_coils(frame).await,
0x10 => self.handle_write_multiple_registers(frame).await,
_ => {
// 不支持的功能码 - 异常响应
Some(vec![my_slave_id, function_code | 0x80, 0x01])
}
};
if let Some(resp) = response {
// 计算CRC并发送
let crc = crc16(&resp);
let mut frame = resp;
frame.extend_from_slice(&crc.to_le_bytes());
let tx_hex: String = frame.iter().map(|b| format!("{:02X}", b)).collect::<Vec<_>>().join(" ");
{
let mut log = self.log.lock().await;
log.log_tx(&format!("→ {}", tx_hex));
}
{
let mut stats = self.stats.lock().await;
stats.record_tx();
}
if let Err(e) = self.serial.write_all(&frame).await {
let mut log = self.log.lock().await;
log.log_error(&format!("发送失败: {}", e));
}
let _ = self.serial.flush().await;
}
}
/// FC01: 读线圈
async fn handle_read_coils(&self, frame: &[u8]) -> Option<Vec<u8>> {
if frame.len() < 8 { return None; }
let bank = self.bank.lock().await;
let start = u16::from_be_bytes([frame[2], frame[3]]) as usize;
let count = u16::from_be_bytes([frame[4], frame[5]]) as usize;
if start + count > bank.coils.len() {
return Some(vec![bank.slave_id, 0x81, 0x02]); // 异常:地址越界
}
let byte_count = (count + 7) / 8;
let mut data = vec![0u8; byte_count];
for i in 0..count {
if bank.coils[start + i] {
data[i / 8] |= 1 << (i % 8);
}
}
let mut resp = vec![bank.slave_id, 0x01, byte_count as u8];
resp.extend_from_slice(&data);
Some(resp)
}
/// FC02: 读离散输入
async fn handle_read_discrete_inputs(&self, frame: &[u8]) -> Option<Vec<u8>> {
if frame.len() < 8 { return None; }
let bank = self.bank.lock().await;
let start = u16::from_be_bytes([frame[2], frame[3]]) as usize;
let count = u16::from_be_bytes([frame[4], frame[5]]) as usize;
if start + count > bank.discrete_inputs.len() {
return Some(vec![bank.slave_id, 0x82, 0x02]);
}
let byte_count = (count + 7) / 8;
let mut data = vec![0u8; byte_count];
for i in 0..count {
if bank.discrete_inputs[start + i] {
data[i / 8] |= 1 << (i % 8);
}
}
let mut resp = vec![bank.slave_id, 0x02, byte_count as u8];
resp.extend_from_slice(&data);
Some(resp)
}
/// FC03: 读保持寄存器
async fn handle_read_holding_registers(&self, frame: &[u8]) -> Option<Vec<u8>> {
if frame.len() < 8 { return None; }
let bank = self.bank.lock().await;
let start = u16::from_be_bytes([frame[2], frame[3]]) as usize;
let count = u16::from_be_bytes([frame[4], frame[5]]) as usize;
if start + count > bank.holding_registers.len() {
return Some(vec![bank.slave_id, 0x83, 0x02]);
}
let byte_count = count * 2;
let mut resp = vec![bank.slave_id, 0x03, byte_count as u8];
for i in 0..count {
let val = bank.holding_registers[start + i];
resp.extend_from_slice(&val.to_be_bytes());
}
Some(resp)
}
/// FC04: 读输入寄存器
async fn handle_read_input_registers(&self, frame: &[u8]) -> Option<Vec<u8>> {
if frame.len() < 8 { return None; }
let bank = self.bank.lock().await;
let start = u16::from_be_bytes([frame[2], frame[3]]) as usize;
let count = u16::from_be_bytes([frame[4], frame[5]]) as usize;
if start + count > bank.input_registers.len() {
return Some(vec![bank.slave_id, 0x84, 0x02]);
}
let byte_count = count * 2;
let mut resp = vec![bank.slave_id, 0x04, byte_count as u8];
for i in 0..count {
let val = bank.input_registers[start + i];
resp.extend_from_slice(&val.to_be_bytes());
}
Some(resp)
}
/// FC05: 写单个线圈
async fn handle_write_single_coil(&self, frame: &[u8]) -> Option<Vec<u8>> {
if frame.len() < 8 { return None; }
let mut bank = self.bank.lock().await;
let addr = u16::from_be_bytes([frame[2], frame[3]]) as usize;
let value = u16::from_be_bytes([frame[4], frame[5]]);
if addr >= bank.coils.len() {
return Some(vec![bank.slave_id, 0x85, 0x02]);
}
bank.coils[addr] = value == 0xFF00;
bank.version = bank.version.wrapping_add(1);
// 回显请求
Some(vec![bank.slave_id, 0x05, frame[2], frame[3], frame[4], frame[5]])
}
/// FC06: 写单个寄存器
async fn handle_write_single_register(&self, frame: &[u8]) -> Option<Vec<u8>> {
if frame.len() < 8 { return None; }
let mut bank = self.bank.lock().await;
let addr = u16::from_be_bytes([frame[2], frame[3]]) as usize;
let value = u16::from_be_bytes([frame[4], frame[5]]);
if addr >= bank.holding_registers.len() {
return Some(vec![bank.slave_id, 0x86, 0x02]);
}
bank.holding_registers[addr] = value;
bank.version = bank.version.wrapping_add(1);
Some(vec![bank.slave_id, 0x06, frame[2], frame[3], frame[4], frame[5]])
}
/// FC0F: 写多个线圈
async fn handle_write_multiple_coils(&self, frame: &[u8]) -> Option<Vec<u8>> {
if frame.len() < 9 { return None; }
let mut bank = self.bank.lock().await;
let start = u16::from_be_bytes([frame[2], frame[3]]) as usize;
let count = u16::from_be_bytes([frame[4], frame[5]]) as usize;
let byte_count = frame[6] as usize;
if start + count > bank.coils.len() || frame.len() < 7 + byte_count {
return Some(vec![bank.slave_id, 0x8F, 0x02]);
}
for i in 0..count {
let byte_idx = 7 + i / 8;
let bit_idx = i % 8;
if byte_idx < frame.len() {
bank.coils[start + i] = (frame[byte_idx] >> bit_idx) & 1 != 0;
}
}
bank.version = bank.version.wrapping_add(1);
Some(vec![bank.slave_id, 0x0F, frame[2], frame[3], frame[4], frame[5]])
}
/// FC10: 写多个寄存器
async fn handle_write_multiple_registers(&self, frame: &[u8]) -> Option<Vec<u8>> {
if frame.len() < 9 { return None; }
let mut bank = self.bank.lock().await;
let start = u16::from_be_bytes([frame[2], frame[3]]) as usize;
let count = u16::from_be_bytes([frame[4], frame[5]]) as usize;
let byte_count = frame[6] as usize;
if start + count > bank.holding_registers.len() || frame.len() < 7 + byte_count {
return Some(vec![bank.slave_id, 0x90, 0x02]);
}
for i in 0..count {
let offset = 7 + i * 2;
if offset + 1 < frame.len() {
bank.holding_registers[start + i] = u16::from_be_bytes([frame[offset], frame[offset + 1]]);
}
}
bank.version = bank.version.wrapping_add(1);
Some(vec![bank.slave_id, 0x10, frame[2], frame[3], frame[4], frame[5]])
}
}
8、modbus_types.rs
rust
use thiserror::Error;
/// Modbus 功能码
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum FunctionCode {
ReadCoils = 0x01,
ReadDiscreteInputs = 0x02,
ReadHoldingRegisters = 0x03,
ReadInputRegisters = 0x04,
WriteSingleCoil = 0x05,
WriteSingleRegister = 0x06,
WriteMultipleCoils = 0x0F,
WriteMultipleRegisters = 0x10,
}
impl FunctionCode {
pub fn from_u8(v: u8) -> Option<Self> {
match v {
0x01 => Some(Self::ReadCoils),
0x02 => Some(Self::ReadDiscreteInputs),
0x03 => Some(Self::ReadHoldingRegisters),
0x04 => Some(Self::ReadInputRegisters),
0x05 => Some(Self::WriteSingleCoil),
0x06 => Some(Self::WriteSingleRegister),
0x0F => Some(Self::WriteMultipleCoils),
0x10 => Some(Self::WriteMultipleRegisters),
_ => None,
}
}
pub fn is_read(&self) -> bool {
matches!(
self,
Self::ReadCoils
| Self::ReadDiscreteInputs
| Self::ReadHoldingRegisters
| Self::ReadInputRegisters
)
}
pub fn is_coil(&self) -> bool {
matches!(self, Self::ReadCoils | Self::ReadDiscreteInputs | Self::WriteSingleCoil | Self::WriteMultipleCoils)
}
pub fn display_name(&self) -> &'static str {
match self {
Self::ReadCoils => "01 读线圈 (Read Coils)",
Self::ReadDiscreteInputs => "02 读离散输入 (Read Discrete Inputs)",
Self::ReadHoldingRegisters => "03 读保持寄存器 (Read Holding Registers)",
Self::ReadInputRegisters => "04 读输入寄存器 (Read Input Registers)",
Self::WriteSingleCoil => "05 写单个线圈 (Write Single Coil)",
Self::WriteSingleRegister => "06 写单个寄存器 (Write Single Register)",
Self::WriteMultipleCoils => "15 写多个线圈 (Write Multiple Coils)",
Self::WriteMultipleRegisters => "16 写多个寄存器 (Write Multiple Registers)",
}
}
/// 用于 ComboBox 显示
pub fn short_name(&self) -> &'static str {
match self {
Self::ReadCoils => "01 - Read Coils",
Self::ReadDiscreteInputs => "02 - Read Discrete Inputs",
Self::ReadHoldingRegisters => "03 - Read Holding Registers",
Self::ReadInputRegisters => "04 - Read Input Registers",
Self::WriteSingleCoil => "05 - Write Single Coil",
Self::WriteSingleRegister => "06 - Write Single Register",
Self::WriteMultipleCoils => "15 - Write Multiple Coils",
Self::WriteMultipleRegisters => "16 - Write Multiple Registers",
}
}
}
/// 连接模式
#[derive(Debug, Clone)]
pub enum ConnectionMode {
Tcp {
ip: String,
port: u16,
},
Rtu {
port: String,
baud_rate: u32,
data_bits: u8,
stop_bits: u8,
parity: ParityMode,
},
}
/// 校验模式
#[derive(Debug, Clone, Copy)]
pub enum ParityMode {
None,
Even,
Odd,
}
impl std::fmt::Display for ParityMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParityMode::None => write!(f, "N"),
ParityMode::Even => write!(f, "E"),
ParityMode::Odd => write!(f, "O"),
}
}
}
/// Modbus 错误类型
#[derive(Debug, Error)]
pub enum ModbusAppError {
#[error("连接错误: {0}")]
ConnectionError(String),
#[error("通信超时")]
Timeout,
#[error("协议错误: {0}")]
ProtocolError(String),
#[error("IO 错误: {0}")]
IoError(#[from] std::io::Error),
#[error("未连接")]
NotConnected,
#[error("参数错误: {0}")]
InvalidParam(String),
}
/// 读取结果
#[derive(Debug, Clone)]
pub enum ReadResult {
Registers(Vec<u16>),
Coils(Vec<bool>),
}
/// 读取请求参数
#[derive(Debug, Clone)]
pub struct ReadRequest {
pub function_code: FunctionCode,
pub slave_id: u8,
pub start_address: u16,
pub quantity: u16,
}
/// 写入请求参数
#[derive(Debug, Clone)]
pub struct WriteRequest {
pub function_code: FunctionCode,
pub slave_id: u8,
pub address: u16,
pub values: Vec<u16>,
}
9、statistics.rs
rust
/// 通信统计
#[derive(Clone, Default)]
pub struct CommStatistics {
pub tx_count: i32,
pub rx_count: i32,
pub error_count: i32,
pub last_error: String,
}
impl CommStatistics {
pub fn new() -> Self {
Self {
tx_count: 0,
rx_count: 0,
error_count: 0,
last_error: String::new(),
}
}
pub fn record_tx(&mut self) {
self.tx_count += 1;
}
pub fn record_rx(&mut self) {
self.rx_count += 1;
}
pub fn record_error(&mut self, error: &str) {
self.error_count += 1;
self.last_error = error.to_string();
}
pub fn reset(&mut self) {
self.tx_count = 0;
self.rx_count = 0;
self.error_count = 0;
self.last_error.clear();
}
}
10、main.rs
rust
slint::include_modules!();
mod modbus_types;
mod modbus_client;
mod modbus_server;
mod data_model;
mod comm_log;
mod statistics;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::Mutex as TokioMutex;
use tokio::task::JoinHandle;
use modbus_types::*;
use modbus_server::{RegisterBank, ModbusSlaveServer};
use data_model::RegisterModel;
use comm_log::CommLog;
use statistics::CommStatistics;
/// 应用全局状态
struct AppState {
server_handle: Arc<TokioMutex<Option<JoinHandle<()>>>>,
bank: Arc<TokioMutex<RegisterBank>>,
log: Arc<TokioMutex<CommLog>>,
stats: Arc<TokioMutex<CommStatistics>>,
cancel: Arc<AtomicBool>,
}
impl AppState {
fn new() -> Self {
Self {
server_handle: Arc::new(TokioMutex::new(None)),
bank: Arc::new(TokioMutex::new(RegisterBank::new(1, 100))),
log: Arc::new(TokioMutex::new(CommLog::new())),
stats: Arc::new(TokioMutex::new(CommStatistics::new())),
cancel: Arc::new(AtomicBool::new(false)),
}
}
}
/// 将寄存器数据更新到 UI(默认显示保持寄存器)
fn update_ui_registers(ui: &MainWindow, bank: &RegisterBank) {
update_ui_registers_with_type(ui, bank, 2); // 2 = 保持寄存器
}
/// 根据寄存器类型更新 UI
fn update_ui_registers_with_type(ui: &MainWindow, bank: &RegisterBank, type_index: i32) {
let rows: Vec<RegisterRow> = match type_index {
0 => {
// 线圈 (Coils)
bank.coils.iter().enumerate().map(|(i, &val)| {
let raw = if val { 1u16 } else { 0u16 };
RegisterRow {
address: i as i32,
value_unsigned: raw.to_string().into(),
value_signed: raw.to_string().into(),
value_hex: format!("{:04X}", raw).into(),
value_binary: format!("{:016b}", raw).into(),
value_float: "-".into(),
value_ascii: if val { "ON" } else { "OFF" }.into(),
}
}).collect()
}
1 => {
// 离散输入 (Discrete Inputs)
bank.discrete_inputs.iter().enumerate().map(|(i, &val)| {
let raw = if val { 1u16 } else { 0u16 };
RegisterRow {
address: i as i32,
value_unsigned: raw.to_string().into(),
value_signed: raw.to_string().into(),
value_hex: format!("{:04X}", raw).into(),
value_binary: format!("{:016b}", raw).into(),
value_float: "-".into(),
value_ascii: if val { "ON" } else { "OFF" }.into(),
}
}).collect()
}
2 => {
// 保持寄存器 (Holding Registers)
bank.holding_registers.iter().enumerate().map(|(i, &raw)| {
let unsigned_val = raw;
let signed_val = raw as i16;
let hex_val = format!("{:04X}", raw);
let binary_val = format!("{:016b}", raw);
// IEEE 754 浮点(需要两个寄存器)
let float_val = if i + 1 < bank.holding_registers.len() {
let next_raw = bank.holding_registers[i + 1];
let bits = ((raw as u32) << 16) | (next_raw as u32);
let float = f32::from_bits(bits);
if float.is_finite() && float.abs() < 1e10 && (float == 0.0 || float.abs() >= 1e-10) {
format!("{:.4}", float)
} else {
format!("{:e}", float)
}
} else {
"-".to_string()
};
// ASCII
let ascii_val = {
let hi = (raw >> 8) as u8;
let lo = (raw & 0xFF) as u8;
let hi_char = if hi >= 0x20 && hi <= 0x7E { hi as char } else { '.' };
let lo_char = if lo >= 0x20 && lo <= 0x7E { lo as char } else { '.' };
format!("{}{}", hi_char, lo_char)
};
RegisterRow {
address: i as i32,
value_unsigned: unsigned_val.to_string().into(),
value_signed: signed_val.to_string().into(),
value_hex: hex_val.into(),
value_binary: binary_val.into(),
value_float: float_val.into(),
value_ascii: ascii_val.into(),
}
}).collect()
}
3 => {
// 输入寄存器 (Input Registers)
bank.input_registers.iter().enumerate().map(|(i, &raw)| {
let unsigned_val = raw;
let signed_val = raw as i16;
let hex_val = format!("{:04X}", raw);
let binary_val = format!("{:016b}", raw);
let float_val = if i + 1 < bank.input_registers.len() {
let next_raw = bank.input_registers[i + 1];
let bits = ((raw as u32) << 16) | (next_raw as u32);
let float = f32::from_bits(bits);
if float.is_finite() && float.abs() < 1e10 && (float == 0.0 || float.abs() >= 1e-10) {
format!("{:.4}", float)
} else {
format!("{:e}", float)
}
} else {
"-".to_string()
};
let ascii_val = {
let hi = (raw >> 8) as u8;
let lo = (raw & 0xFF) as u8;
let hi_char = if hi >= 0x20 && hi <= 0x7E { hi as char } else { '.' };
let lo_char = if lo >= 0x20 && lo <= 0x7E { lo as char } else { '.' };
format!("{}{}", hi_char, lo_char)
};
RegisterRow {
address: i as i32,
value_unsigned: unsigned_val.to_string().into(),
value_signed: signed_val.to_string().into(),
value_hex: hex_val.into(),
value_binary: binary_val.into(),
value_float: float_val.into(),
value_ascii: ascii_val.into(),
}
}).collect()
}
_ => vec![],
};
ui.set_registers(slint::ModelRc::new(slint::VecModel::from(rows)));
}
/// 将日志更新到 UI
fn update_ui_log(ui: &MainWindow, log: &CommLog) {
let entries: Vec<LogEntry> = log
.entries()
.iter()
.map(|e| LogEntry {
timestamp: e.timestamp.clone().into(),
direction: e.direction,
raw_text: e.raw_text.clone().into(),
})
.collect();
ui.set_log_entries(slint::ModelRc::new(slint::VecModel::from(entries)));
}
fn main() {
env_logger::init();
log::info!("Modbus RTU 从机调试助手启动");
let runtime = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
let _enter = runtime.enter();
let ui = MainWindow::new().expect("Failed to create main window");
let state = AppState::new();
// 串口参数选项
let baud_rates: Vec<slint::SharedString> = vec![
"1200".into(), "2400".into(), "4800".into(), "9600".into(),
"19200".into(), "38400".into(), "57600".into(), "115200".into(),
];
let data_bits_list: Vec<slint::SharedString> = vec!["5".into(), "6".into(), "7".into(), "8".into()];
let stop_bits_list: Vec<slint::SharedString> = vec!["1".into(), "2".into()];
let parity_list: Vec<slint::SharedString> = vec!["None".into(), "Even".into(), "Odd".into()];
// 枚举可用串口
let available_ports = serialport::available_ports().unwrap_or(vec![]);
let port_names: Vec<slint::SharedString> = if available_ports.is_empty() {
vec!["COM1".into(), "COM2".into(), "COM3".into()]
} else {
available_ports.iter().map(|p| p.port_name.as_str().into()).collect()
};
// ===== 注册回调 =====
register_cell_edit_callback(&ui, &state);
register_type_change_callback(&ui, &state);
// 设置寄存器类型列表
// ui.set_register_types(slint::ModelRc::new(slint::VecModel::from(register_types)));
// 初始化显示寄存器表格(默认显示保持寄存器)
{
let bank_snapshot = state.bank.try_lock().unwrap().clone();
update_ui_registers_with_type(&ui, &bank_snapshot, 2);
}
let baud_rates_for_dialog = baud_rates.clone();
let data_bits_for_dialog = data_bits_list.clone();
let stop_bits_for_dialog = stop_bits_list.clone();
let parity_for_dialog = parity_list.clone();
let ports_for_dialog = port_names.clone();
// 启动按钮回调 → 显示从机连接对话框
{
let ui_weak = ui.as_weak();
let state_handle = state.server_handle.clone();
let state_bank = state.bank.clone();
let state_log = state.log.clone();
let state_stats = state.stats.clone();
let state_cancel = state.cancel.clone();
let ports = ports_for_dialog.clone();
let baud_rates = baud_rates_for_dialog.clone();
let data_bits_list = data_bits_for_dialog.clone();
let stop_bits_list = stop_bits_for_dialog.clone();
let parity_list = parity_for_dialog.clone();
ui.on_on_connect(move || {
show_connection_dialog(
&ui_weak,
&state_handle,
&state_bank,
&state_log,
&state_stats,
&state_cancel,
&ports,
&baud_rates,
&data_bits_list,
&stop_bits_list,
&parity_list,
);
});
}
// 停止按钮
{
let ui_weak = ui.as_weak();
let state_handle = state.server_handle.clone();
let state_log = state.log.clone();
let state_cancel = state.cancel.clone();
ui.on_on_disconnect(move || {
let ui_weak = ui_weak.clone();
let handle = state_handle.clone();
let log = state_log.clone();
let cancel = state_cancel.clone();
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak.upgrade() {
cancel.store(true, Ordering::SeqCst);
// 终止服务器任务
let handle_clone = handle.clone();
tokio::spawn(async move {
let mut guard = handle_clone.lock().await;
if let Some(h) = guard.take() {
h.abort();
}
});
ui.set_connected(false);
ui.set_connection_mode("".into());
ui.set_connection_info("".into());
if let Ok(mut log_guard) = log.try_lock() {
log_guard.log_error("已停止从机服务");
let snapshot = (*log_guard).clone();
drop(log_guard);
update_ui_log(&ui, &snapshot);
}
}
})
.ok();
});
}
// 清空日志
{
let ui_weak = ui.as_weak();
let state_log = state.log.clone();
ui.on_on_clear_log(move || {
let ui_weak = ui_weak.clone();
let log = state_log.clone();
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak.upgrade() {
if let Ok(mut log_guard) = log.try_lock() {
log_guard.clear();
let snapshot = (*log_guard).clone();
drop(log_guard);
update_ui_log(&ui, &snapshot);
}
}
})
.ok();
});
}
log::info!("UI 初始化完成,启动事件循环");
ui.run().expect("Failed to run event loop");
}
// ============================================================
// 寄存器类型切换回调
// ============================================================
fn register_type_change_callback(ui: &MainWindow, state: &AppState) {
let ui_weak = ui.as_weak();
let state_bank = state.bank.clone();
ui.on_on_register_type_changed(move |type_index| {
let ui_weak = ui_weak.clone();
let bank = state_bank.clone();
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak.upgrade() {
let bank_snapshot = bank.try_lock().unwrap().clone();
update_ui_registers_with_type(&ui, &bank_snapshot, type_index);
}
})
.ok();
});
}
// ============================================================
// 单元格编辑回调
// ============================================================
fn register_cell_edit_callback(ui: &MainWindow, state: &AppState) {
let ui_weak = ui.as_weak();
let state_bank = state.bank.clone();
ui.on_on_cell_edit(move |address, value| {
let ui_weak = ui_weak.clone();
let bank = state_bank.clone();
let value_str = value.to_string();
slint::invoke_from_event_loop(move || {
let addr = address as usize;
let parsed = data_model::format_utils::parse_value(&value_str);
let val = match parsed {
Ok(v) => v,
Err(_) => return,
};
// 获取当前寄存器类型
let type_index = ui_weak.upgrade().map(|ui| ui.get_register_type_index()).unwrap_or(2);
// 根据类型更新对应的寄存器
{
let mut guard = bank.try_lock().unwrap();
match type_index {
0 => {
// 线圈
if addr < guard.coils.len() {
guard.coils[addr] = val != 0;
guard.version = guard.version.wrapping_add(1);
}
}
1 => {
// 离散输入
if addr < guard.discrete_inputs.len() {
guard.discrete_inputs[addr] = val != 0;
guard.version = guard.version.wrapping_add(1);
}
}
2 => {
// 保持寄存器
if addr < guard.holding_registers.len() {
guard.holding_registers[addr] = val;
guard.version = guard.version.wrapping_add(1);
}
}
3 => {
// 输入寄存器
if addr < guard.input_registers.len() {
guard.input_registers[addr] = val;
guard.version = guard.version.wrapping_add(1);
}
}
_ => return,
}
}
// 刷新UI
if let Some(ui) = ui_weak.upgrade() {
let bank_snapshot = bank.try_lock().unwrap().clone();
update_ui_registers_with_type(&ui, &bank_snapshot, type_index);
}
})
.ok();
});
}
// ============================================================
// 连接对话框
// ============================================================
fn show_connection_dialog(
ui_weak: &slint::Weak<MainWindow>,
server_handle: &Arc<TokioMutex<Option<JoinHandle<()>>>>,
bank: &Arc<TokioMutex<RegisterBank>>,
log: &Arc<TokioMutex<CommLog>>,
stats: &Arc<TokioMutex<CommStatistics>>,
cancel: &Arc<AtomicBool>,
port_names: &[slint::SharedString],
baud_rates: &[slint::SharedString],
data_bits_list: &[slint::SharedString],
stop_bits_list: &[slint::SharedString],
parity_list: &[slint::SharedString],
) {
let dialog = RtuSetupDialog::new().expect("Failed to create RTU dialog");
dialog.set_port_names(slint::ModelRc::new(slint::VecModel::from(port_names.to_vec())));
dialog.set_baud_rates(slint::ModelRc::new(slint::VecModel::from(baud_rates.to_vec())));
dialog.set_data_bits_list(slint::ModelRc::new(slint::VecModel::from(data_bits_list.to_vec())));
dialog.set_stop_bits_list(slint::ModelRc::new(slint::VecModel::from(stop_bits_list.to_vec())));
dialog.set_parity_list(slint::ModelRc::new(slint::VecModel::from(parity_list.to_vec())));
dialog.set_port_index(0);
dialog.set_baud_index(3); // 9600
dialog.set_data_bits_index(3); // 8
dialog.set_stop_bits_index(0); // 1
dialog.set_parity_index(0); // None
dialog.set_slave_id(1);
dialog.set_register_count(100);
let ui_weak = ui_weak.clone();
let server_handle = server_handle.clone();
let bank = bank.clone();
let log = log.clone();
let stats = stats.clone();
let cancel = cancel.clone();
let port_names = port_names.to_vec();
let baud_rates = baud_rates.to_vec();
let data_bits_list = data_bits_list.to_vec();
let stop_bits_list = stop_bits_list.to_vec();
let parity_list = parity_list.to_vec();
dialog.on_on_confirm({
let dialog = dialog.as_weak();
let ui_weak = ui_weak.clone();
let server_handle = server_handle.clone();
let bank = bank.clone();
let log = log.clone();
let stats = stats.clone();
let cancel = cancel.clone();
move || {
if let Some(dlg) = dialog.upgrade() {
let port_index = dlg.get_port_index() as usize;
let baud_index = dlg.get_baud_index() as usize;
let data_bits_index = dlg.get_data_bits_index() as usize;
let stop_bits_index = dlg.get_stop_bits_index() as usize;
let parity_index = dlg.get_parity_index() as usize;
let slave_id = dlg.get_slave_id() as u8;
let register_count = dlg.get_register_count() as usize;
let port_name = port_names.get(port_index).map(|s| s.to_string()).unwrap_or_else(|| "COM1".to_string());
let baud_rate: u32 = baud_rates.get(baud_index).map(|s| s.parse().unwrap_or(9600)).unwrap_or(9600);
let data_bits: u8 = data_bits_list.get(data_bits_index).map(|s| s.parse().unwrap_or(8)).unwrap_or(8);
let stop_bits: u8 = stop_bits_list.get(stop_bits_index).map(|s| s.parse().unwrap_or(1)).unwrap_or(1);
let parity = match parity_index {
1 => ParityMode::Even,
2 => ParityMode::Odd,
_ => ParityMode::None,
};
let ui_weak = ui_weak.clone();
let server_handle = server_handle.clone();
let bank = bank.clone();
let log = log.clone();
let stats = stats.clone();
let cancel = cancel.clone();
let dialog_weak = dialog.clone();
tokio::spawn(async move {
// 打开串口
let parity_mode = match parity {
ParityMode::None => tokio_serial::Parity::None,
ParityMode::Even => tokio_serial::Parity::Even,
ParityMode::Odd => tokio_serial::Parity::Odd,
};
let data_bits_mode = match data_bits {
5 => tokio_serial::DataBits::Five,
6 => tokio_serial::DataBits::Six,
7 => tokio_serial::DataBits::Seven,
_ => tokio_serial::DataBits::Eight,
};
let stop_bits_mode = match stop_bits {
1 => tokio_serial::StopBits::One,
_ => tokio_serial::StopBits::Two,
};
let builder = tokio_serial::new(&port_name, baud_rate)
.data_bits(data_bits_mode)
.stop_bits(stop_bits_mode)
.parity(parity_mode);
let serial_result = tokio_serial::SerialStream::open(&builder);
let serial = match serial_result {
Ok(s) => s,
Err(e) => {
let err_msg = format!("串口打开失败: {}", e);
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak.upgrade() {
ui.set_last_error(err_msg.clone().into());
if let Ok(mut log_guard) = log.try_lock() {
log_guard.log_error(&err_msg);
let snapshot = (*log_guard).clone();
drop(log_guard);
update_ui_log(&ui, &snapshot);
}
}
}).ok();
return;
}
};
// 设置寄存器数量
{
let mut bank_guard = bank.lock().await;
bank_guard.slave_id = slave_id;
bank_guard.resize(register_count);
}
cancel.store(false, Ordering::SeqCst);
// 启动从机服务
let mut server = ModbusSlaveServer::new(serial, bank.clone(), log.clone(), stats.clone());
let handle = tokio::spawn(async move {
let _ = server.run().await;
});
{
let mut guard = server_handle.lock().await;
*guard = Some(handle);
}
// 启动UI定时刷新
let ui_weak2 = ui_weak.clone();
let bank2 = bank.clone();
let stats2 = stats.clone();
let log2 = log.clone();
let cancel2 = cancel.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_millis(500));
let mut last_bank_version: u64 = 0;
let mut last_log_version: u64 = 0;
let mut last_stats_tx: i32 = 0;
let mut last_stats_rx: i32 = 0;
let mut last_stats_err: i32 = 0;
loop {
interval.tick().await;
if cancel2.load(Ordering::SeqCst) {
break;
}
// 只读版本号判断是否需要更新
let (bank_version, log_version, stats_snapshot) = {
let bv = { let b = bank2.try_lock(); b.as_ref().map(|g| g.version).unwrap_or(0) };
let lv = { let l = log2.try_lock(); l.as_ref().map(|g| g.version()).unwrap_or(0) };
let ss = stats2.try_lock().map(|s| s.clone()).unwrap_or_default();
(bv, lv, ss)
};
let bank_changed = bank_version != last_bank_version;
let log_changed = log_version != last_log_version;
let stats_changed = stats_snapshot.tx_count != last_stats_tx
|| stats_snapshot.rx_count != last_stats_rx
|| stats_snapshot.error_count != last_stats_err;
if !bank_changed && !log_changed && !stats_changed {
continue;
}
// 只在需要时克隆数据
let bank_snap = if bank_changed {
Some(bank2.lock().await.clone())
} else {
None
};
let log_snap = if log_changed {
Some(log2.try_lock().map(|l| l.clone()).unwrap_or_default())
} else {
None
};
last_bank_version = bank_version;
last_log_version = log_version;
last_stats_tx = stats_snapshot.tx_count;
last_stats_rx = stats_snapshot.rx_count;
last_stats_err = stats_snapshot.error_count;
let ui_weak = ui_weak2.clone();
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak.upgrade() {
if let Some(bank) = bank_snap {
let type_index = ui.get_register_type_index();
update_ui_registers_with_type(&ui, &bank, type_index);
}
if let Some(log) = log_snap {
update_ui_log(&ui, &log);
}
if stats_changed {
ui.set_tx_count(stats_snapshot.tx_count);
ui.set_rx_count(stats_snapshot.rx_count);
ui.set_error_count(stats_snapshot.error_count);
ui.set_last_error(stats_snapshot.last_error.clone().into());
}
}
}).ok();
}
});
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak.upgrade() {
ui.set_connected(true);
ui.set_connection_mode("RTU Slave".into());
ui.set_connection_info(
format!("{} {}-{}{}{} 从站:{}", port_name, baud_rate, data_bits, stop_bits, parity, slave_id).into(),
);
ui.set_slave_id(slave_id as i32);
if let Ok(mut log_guard) = log.try_lock() {
log_guard.log_tx(&format!(
"RTU 从机启动 {} {}-{}{}{} 从站ID:{} 寄存器数:{}",
port_name, baud_rate, data_bits, stop_bits, parity, slave_id, register_count
));
let snapshot = (*log_guard).clone();
drop(log_guard);
update_ui_log(&ui, &snapshot);
}
}
}).ok();
// 关闭对话框
if let Some(dlg) = dialog_weak.upgrade() {
let _ = dlg.hide();
}
});
}
}
});
dialog.on_on_cancel({
let dialog = dialog.as_weak();
move || {
if let Some(dlg) = dialog.upgrade() {
let _ = dlg.hide();
}
}
});
dialog.on_on_refresh_ports({
let dialog = dialog.as_weak();
move || {
if let Some(dlg) = dialog.upgrade() {
let available_ports = serialport::available_ports().unwrap_or(vec![]);
let new_port_names: Vec<slint::SharedString> = if available_ports.is_empty() {
vec!["COM1".into(), "COM2".into(), "COM3".into()]
} else {
available_ports.iter().map(|p| p.port_name.as_str().into()).collect()
};
dlg.set_port_names(slint::ModelRc::new(slint::VecModel::from(new_port_names)));
dlg.set_port_index(0);
}
}
});
dialog.show().expect("Failed to show RTU dialog");
}
11、build.rs
rust
fn main() {
// 编译 Slint UI
slint_build::compile("ui/main.slint").unwrap();
}
12、main.slint
rust
import { Button, LineEdit, SpinBox, ComboBox, ScrollView } from "std-widgets.slint";
// ==================== 主题 ====================
export global Theme {
in property <color> bg-primary: #0d1117;
in property <color> bg-secondary: #161b22;
in property <color> bg-tertiary: #1c2333;
in property <color> bg-elevated: #21283b;
in property <color> bg-hover: #292e3e;
in property <color> bg-active: #333a4d;
in property <color> border: #2a3040;
in property <color> border-focus: #58a6ff;
in property <color> text-primary: #e6edf3;
in property <color> text-secondary: #8b949e;
in property <color> text-muted: #636c76;
in property <color> accent: #58a6ff;
in property <color> success: #3fb950;
in property <color> warning: #d29922;
in property <color> error: #f85149;
in property <color> info: #58a6ff;
in property <color> tx-color: #58a6ff;
in property <color> rx-color: #3fb950;
in property <color> table-header-bg: #1c2333;
in property <color> table-row-even: #0d1117;
in property <color> table-row-odd: #111820;
in property <length> spacing-xs: 4px;
in property <length> spacing-sm: 8px;
in property <length> spacing-md: 12px;
in property <length> spacing-lg: 16px;
in property <length> spacing-xl: 24px;
in property <length> radius-sm: 4px;
in property <length> font-size-sm: 12px;
in property <length> font-size-md: 13px;
in property <length> font-size-lg: 14px;
}
// ==================== 数据结构 ====================
// 寄存器类型切换按钮
export component RegisterTypeButton inherits Rectangle {
in property <string> label;
in property <bool> active: false;
callback clicked;
width: 120px;
height: 28px;
border-radius: 6px;
border-width: 1px;
border-color: active ? Theme.accent : Theme.border;
background: active ? Theme.accent : (ta.has-hover ? Theme.bg-hover : Theme.bg-tertiary);
HorizontalLayout {
alignment: center;
Text {
text: label;
color: active ? #ffffff : Theme.text-secondary;
font-size: Theme.font-size-sm;
font-weight: active ? 600 : 400;
vertical-alignment: center;
horizontal-alignment: center;
}
}
ta := TouchArea {
clicked => { root.clicked(); }
mouse-cursor: pointer;
}
}
export struct RegisterRow {
address: int,
value-unsigned: string,
value-signed: string,
value-hex: string,
value-binary: string,
value-float: string,
value-ascii: string,
}
export struct LogEntry {
timestamp: string,
direction: int,
raw-text: string,
}
// ==================== 基础组件(先定义,后使用) ====================
component Sep inherits Rectangle {
width: 1px;
background: Theme.border;
}
component HeaderCell inherits Rectangle {
in property <string> text;
background: transparent;
border-width: 1px;
border-color: Theme.border;
Text { text: root.text; color: Theme.text-secondary; font-size: Theme.font-size-sm; font-weight: 600; horizontal-alignment: center; vertical-alignment: center; }
}
component CellText inherits Rectangle {
in property <string> text;
in property <color> cell-color: Theme.text-primary;
in property <length> cell-font-size: Theme.font-size-md;
background: transparent;
border-width: 1px;
border-color: Theme.border;
Text { text: root.text; color: root.cell-color; font-size: root.cell-font-size; horizontal-alignment: center; vertical-alignment: center; font-family: "monospace"; }
}
component EditableCell inherits Rectangle {
in property <string> text;
callback edited(string);
private property <bool> editing;
background: transparent;
border-width: 1px;
border-color: Theme.border;
if editing == false : Text {
text: root.text; color: Theme.text-primary; font-size: Theme.font-size-md;
horizontal-alignment: center; vertical-alignment: center;
}
if editing : LineEdit {
text: root.text;
accepted(value) => { editing = false; root.edited(value); }
}
TouchArea {
visible: editing == false;
clicked => { editing = true; }
mouse-cursor: pointer;
}
}
component StatusItem inherits Rectangle {
in property <string> label;
in property <string> value;
in property <color> value-color: Theme.text-primary;
HorizontalLayout {
spacing: 4px;
Text { text: root.label + ":"; color: Theme.text-muted; font-size: Theme.font-size-sm; vertical-alignment: center; }
Text { text: root.value; color: root.value-color; font-size: Theme.font-size-sm; font-weight: 600; vertical-alignment: center; }
}
}
component ToolButton inherits Rectangle {
in property <string> label;
in property <bool> enabled: true;
in property <color> btn-color: Theme.accent;
callback clicked();
private property <bool> is-hovered;
height: 32px;
width: 100px;
background: is-hovered && enabled ? Theme.bg-hover : Theme.bg-tertiary;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: enabled ? btn-color : Theme.border;
Text { text: root.label; color: root.enabled ? Theme.text-primary : Theme.text-muted; font-size: Theme.font-size-sm; horizontal-alignment: center; vertical-alignment: center; }
TouchArea {
mouse-cursor: root.enabled ? pointer : default;
clicked => { root.clicked(); }
moved => { is-hovered = self.mouse-x >= 0 && self.mouse-x <= root.width && self.mouse-y >= 0 && self.mouse-y <= root.height; }
}
}
// ==================== 复合组件 ====================
component ToolBar inherits Rectangle {
in property <bool> connected;
in property <string> connection-mode;
callback on-connect();
callback on-disconnect();
callback on-clear-log();
background: Theme.bg-secondary;
height: 48px;
border-width: 1px;
border-color: Theme.border;
HorizontalLayout {
padding-left: Theme.spacing-md; padding-right: Theme.spacing-md;
padding-top: Theme.spacing-sm; padding-bottom: Theme.spacing-sm;
spacing: Theme.spacing-sm; alignment: start;
ToolButton { label: "启动"; enabled: root.connected == false; btn-color: Theme.accent; clicked => { root.on-connect(); } }
ToolButton { label: "停止"; enabled: root.connected; btn-color: Theme.error; clicked => { root.on-disconnect(); } }
Sep {}
ToolButton { label: "清空日志"; btn-color: Theme.text-secondary; clicked => { root.on-clear-log(); } }
Rectangle { horizontal-stretch: 1; }
Rectangle {
Rectangle {
x: 0; y: parent.height / 2 - 4px;
width: 8px; height: 8px; border-radius: 4px;
background: root.connected ? Theme.success : Theme.text-muted;
}
Text {
x: 14px; y: parent.height / 2 - self.height / 2;
text: root.connected ? root.connection-mode : "未连接";
color: root.connected ? Theme.success : Theme.text-muted;
font-size: Theme.font-size-sm;
}
}
}
}
component RegisterTable inherits Rectangle {
in property <[RegisterRow]> registers;
callback on-cell-edit(int, string);
background: Theme.bg-primary;
VerticalLayout {
Rectangle {
height: 36px; background: Theme.table-header-bg; border-width: 1px; border-color: Theme.border;
HorizontalLayout {
padding-left: Theme.spacing-sm; padding-right: Theme.spacing-sm;
HeaderCell { text: "地址"; width: 80px; }
HeaderCell { text: "无符号"; width: 90px; }
HeaderCell { text: "有符号"; width: 90px; }
HeaderCell { text: "十六进制"; width: 90px; }
HeaderCell { text: "二进制"; width: 150px; }
HeaderCell { text: "浮点数"; width: 130px; }
HeaderCell { text: "ASCII"; width: 60px; }
}
}
ScrollView {
viewport := VerticalLayout {
spacing: 0;
for row[idx] in root.registers : Rectangle {
height: 30px;
background: Math.mod(idx, 2) == 0 ? Theme.table-row-even : Theme.table-row-odd;
HorizontalLayout {
padding-left: Theme.spacing-sm; padding-right: Theme.spacing-sm;
CellText { text: row.address + ""; width: 80px; cell-color: Theme.accent; }
EditableCell { text: row.value-unsigned; width: 90px; edited(value) => { root.on-cell-edit(row.address, value); } }
CellText { text: row.value-signed; width: 90px; cell-color: Theme.text-secondary; }
CellText { text: row.value-hex; width: 90px; cell-color: Theme.warning; }
CellText { text: row.value-binary; width: 150px; cell-color: Theme.info; cell-font-size: Theme.font-size-sm; }
CellText { text: row.value-float; width: 130px; cell-color: Theme.success; }
CellText { text: row.value-ascii; width: 60px; cell-color: Theme.text-primary; }
}
}
}
}
}
}
component StatusBar inherits Rectangle {
in property <bool> connected;
in property <string> connection-info;
in property <int> slave-id;
in property <int> tx-count;
in property <int> rx-count;
in property <int> error-count;
in property <string> last-error;
height: 28px; background: Theme.bg-secondary; border-width: 1px; border-color: Theme.border;
HorizontalLayout {
padding-left: Theme.spacing-md; padding-right: Theme.spacing-md; spacing: Theme.spacing-lg;
Rectangle {y:parent.height/2 - self.height/2; width: 8px; height: 8px; border-radius: 4px; background: root.connected ? Theme.success : Theme.error; }
Text { text: root.connected ? root.connection-info : "未连接"; color: root.connected ? Theme.text-primary : Theme.text-muted; font-size: Theme.font-size-sm; vertical-alignment: center; }
if root.connected : Text { text: "从站:" + root.slave-id; color: Theme.text-secondary; font-size: Theme.font-size-sm; vertical-alignment: center; }
Sep {}
StatusItem { label: "TX"; value: root.tx-count + ""; value-color: Theme.tx-color; }
StatusItem { label: "RX"; value: root.rx-count + ""; value-color: Theme.rx-color; }
StatusItem { label: "错误"; value: root.error-count + ""; value-color: root.error-count > 0 ? Theme.error : Theme.text-secondary; }
Rectangle { horizontal-stretch: 1; }
if root.last-error != "" : Text { text: root.last-error; color: Theme.error; font-size: Theme.font-size-sm; vertical-alignment: center; overflow: elide; max-width: 300px; }
}
}
component LogPanel inherits Rectangle {
in property <[LogEntry]> log-entries;
height: 180px;
VerticalLayout {
Rectangle {
height: 32px; background: Theme.bg-tertiary; border-width: 1px; border-color: Theme.border;
HorizontalLayout {
padding-left: Theme.spacing-md; padding-right: Theme.spacing-md;
Text { text: "通信日志"; color: Theme.text-secondary; font-size: Theme.font-size-sm; font-weight: 600; vertical-alignment: center; }
Rectangle { horizontal-stretch: 1; }
Text { text: root.log-entries.length + " 条记录"; color: Theme.text-muted; font-size: Theme.font-size-sm; vertical-alignment: center; }
}
}
log-scroll := ScrollView {
viewport := VerticalLayout {
spacing: 0; padding: Theme.spacing-xs;
for entry in root.log-entries : Rectangle {
height: 22px;
background: entry.direction == 2 ? #1a0a0a : transparent;
HorizontalLayout {
padding-left: Theme.spacing-sm; spacing: Theme.spacing-sm;
Text { text: entry.timestamp; color: Theme.text-muted; font-size: 11px; font-family: "monospace"; vertical-alignment: center; width: 100px; }
Rectangle {
width: 28px; height: 16px; border-radius: 3px;
background: entry.direction == 0 ? Theme.tx-color : (entry.direction == 1 ? Theme.rx-color : Theme.error);
Text { text: entry.direction == 0 ? "TX" : (entry.direction == 1 ? "RX" : "ERR"); color: white; font-size: 10px; font-weight: 700; horizontal-alignment: center; vertical-alignment: center; }
}
Text { text: entry.raw-text; color: entry.direction == 0 ? Theme.tx-color : (entry.direction == 1 ? Theme.rx-color : Theme.error); font-size: 11px; font-family: "monospace"; vertical-alignment: center; overflow: elide; }
}
}
changed height => {
if self.height > parent.height {
// 内容溢出:滚动到底部
parent.viewport-y = parent.height - self.height;
} else {
// 内容未溢出:置顶
parent.viewport-y = 0px;
}
}
}
}
}
}
// ==================== 主窗口 ====================
export component MainWindow inherits Window {
title: "Modbus RTU 从机调试助手";
preferred-width: 1200px; preferred-height: 800px;
min-width: 960px; min-height: 640px;
background: Theme.bg-primary;
in property <bool> connected: false;
in property <string> connection-mode: "";
in property <string> connection-info: "";
in property <int> slave-id: 1;
in property <int> tx-count: 0;
in property <int> rx-count: 0;
in property <int> error-count: 0;
in property <string> last-error: "";
in property <[RegisterRow]> registers;
in property <[LogEntry]> log-entries;
in property <int> function-code: 3;
in-out property <int> register-type-index: 0;
callback on-connect();
callback on-disconnect();
callback on-clear-log();
callback on-cell-edit(int, string);
callback on-register-type-changed(int);
// 当寄存器类型改变时触发刷新
changed register-type-index => { root.on-register-type-changed(register-type-index); }
VerticalLayout {
ToolBar {
connected: root.connected; connection-mode: root.connection-mode;
on-connect => { root.on-connect(); } on-disconnect => { root.on-disconnect(); }
on-clear-log => { root.on-clear-log(); }
}
VerticalLayout {
vertical-stretch: 1;
// 寄存器类型选择器
Rectangle {
height: 36px;
background: Theme.bg-secondary;
border-width: 1px;
border-color: Theme.border;
HorizontalLayout {
padding-left: Theme.spacing-md;
padding-right: Theme.spacing-md;
spacing: Theme.spacing-sm;
alignment: start;
Text { text: "寄存器类型:"; color: Theme.text-secondary; font-size: Theme.font-size-sm; vertical-alignment: center; }
RegisterTypeButton {
y:parent.height / 2 - self.height / 2;
label: "线圈 (0x)";
active: root.register-type-index == 0;
clicked => { root.register-type-index = 0; }
}
RegisterTypeButton {
y:parent.height / 2 - self.height / 2;
label: "离散输入 (1x)";
active: root.register-type-index == 1;
clicked => { root.register-type-index = 1; }
}
RegisterTypeButton {
y:parent.height / 2 - self.height / 2;
label: "保持寄存器 (4x)";
active: root.register-type-index == 2;
clicked => { root.register-type-index = 2; }
}
RegisterTypeButton {
y:parent.height / 2 - self.height / 2;
label: "输入寄存器 (3x)";
active: root.register-type-index == 3;
clicked => { root.register-type-index = 3; }
}
Rectangle { horizontal-stretch: 1; }
}
}
RegisterTable { vertical-stretch: 1; registers: root.registers; on-cell-edit(a, v) => { root.on-cell-edit(a, v); } }
Rectangle { height: 1px; background: Theme.border; }
LogPanel { log-entries: root.log-entries; }
}
StatusBar {
connected: root.connected; connection-info: root.connection-info;
slave-id: root.slave-id; tx-count: root.tx-count; rx-count: root.rx-count;
error-count: root.error-count; last-error: root.last-error;
}
}
}
// ==================== RTU 从机 串口连接对话框 ====================
export component RtuSetupDialog inherits Window {
title: "RTU 从机 连接设置";
preferred-width: 460px; preferred-height: 440px;
background: Theme.bg-primary;
in property <[string]> port-names;
in property <[string]> baud-rates; in property <[string]> data-bits-list;
in property <[string]> stop-bits-list; in property <[string]> parity-list;
in-out property <int> port-index <=> port-combo.current-index;
in-out property <int> baud-index <=> baud-combo.current-index;
in-out property <int> data-bits-index <=> data-combo.current-index;
in-out property <int> stop-bits-index <=> stop-combo.current-index;
in-out property <int> parity-index <=> parity-combo.current-index;
in-out property <int> slave-id <=> slave-input.value;
in-out property <int> register-count <=> reg-count-input.value;
callback on-confirm(); callback on-cancel(); callback on-refresh-ports();
VerticalLayout {
padding: Theme.spacing-xl; spacing: Theme.spacing-lg;
Text { text: "Modbus RTU 从机 连接设置"; color: Theme.text-primary; font-size: 18px; font-weight: 700; horizontal-alignment: center; }
Rectangle { height: 1px; background: Theme.border; }
VerticalLayout {
spacing: Theme.spacing-md;
HorizontalLayout {
spacing: Theme.spacing-sm; alignment: start;
Text { text: "串口:"; color: Theme.text-secondary; vertical-alignment: center; width: 100px; }
port-combo := ComboBox { model: root.port-names; current-index: 0; horizontal-stretch: 1; }
Button {
text: "刷新";
width: 60px;
clicked => { root.on-refresh-ports(); }
}
}
HorizontalLayout { spacing: Theme.spacing-md; alignment: start; Text { text: "波特率:"; color: Theme.text-secondary; vertical-alignment: center; width: 100px; } baud-combo := ComboBox { model: root.baud-rates; current-index: 3; horizontal-stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing-md; alignment: start; Text { text: "数据位:"; color: Theme.text-secondary; vertical-alignment: center; width: 100px; } data-combo := ComboBox { model: root.data-bits-list; current-index: 3; horizontal-stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing-md; alignment: start; Text { text: "停止位:"; color: Theme.text-secondary; vertical-alignment: center; width: 100px; } stop-combo := ComboBox { model: root.stop-bits-list; current-index: 0; horizontal-stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing-md; alignment: start; Text { text: "校验位:"; color: Theme.text-secondary; vertical-alignment: center; width: 100px; } parity-combo := ComboBox { model: root.parity-list; current-index: 0; horizontal-stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing-md; alignment: start; Text { text: "从站 ID:"; color: Theme.text-secondary; vertical-alignment: center; width: 100px; } slave-input := SpinBox { value: 1; minimum: 1; maximum: 247; horizontal-stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing-md; alignment: start; Text { text: "寄存器数量:"; color: Theme.text-secondary; vertical-alignment: center; width: 100px; } reg-count-input := SpinBox { value: 100; minimum: 1; maximum: 10000; horizontal-stretch: 1; } }
}
Rectangle { vertical-stretch: 1; }
HorizontalLayout { spacing: Theme.spacing-md; alignment: end; Button { text: "取消"; clicked => { root.on-cancel(); } } Button { text: "连接"; primary: true; clicked => { root.on-confirm(); } } }
}
}
// ==================== 读取设置对话框 ====================
export component ReadSetupDialog inherits Window {
title: "读取设置";
preferred-width: 400px; preferred-height: 320px;
background: Theme.bg-primary;
in property <[string]> function-codes;
in-out property <int> function-index <=> func-combo.current-index;
in-out property <int> start-address <=> addr-input.value;
in-out property <int> quantity <=> qty-input.value;
in-out property <int> slave-id <=> slave-input.value;
callback on-confirm(); callback on-cancel();
VerticalLayout {
padding: Theme.spacing-xl; spacing: Theme.spacing-lg;
Text { text: "读取参数设置"; color: Theme.text-primary; font-size: 18px; font-weight: 700; horizontal-alignment: center; }
Rectangle { height: 1px; background: Theme.border; }
VerticalLayout {
spacing: Theme.spacing-md;
HorizontalLayout { spacing: Theme.spacing-md; alignment: start; Text { text: "功能码:"; color: Theme.text-secondary; vertical-alignment: center; width: 100px; } func-combo := ComboBox { model: root.function-codes; current-index: 2; horizontal-stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing-md; alignment: start; Text { text: "从站 ID:"; color: Theme.text-secondary; vertical-alignment: center; width: 100px; } slave-input := SpinBox { value: 1; minimum: 1; maximum: 247; horizontal-stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing-md; alignment: start; Text { text: "起始地址:"; color: Theme.text-secondary; vertical-alignment: center; width: 100px; } addr-input := SpinBox { value: 0; minimum: 0; maximum: 65535; horizontal-stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing-md; alignment: start; Text { text: "数量:"; color: Theme.text-secondary; vertical-alignment: center; width: 100px; } qty-input := SpinBox { value: 10; minimum: 1; maximum: 125; horizontal-stretch: 1; } }
}
Rectangle { vertical-stretch: 1; }
Text { text: "寄存器地址从 0 开始, 数量最大 125"; color: Theme.text-muted; font-size: Theme.font-size-sm; horizontal-alignment: center; }
HorizontalLayout { spacing: Theme.spacing-md; alignment: end; Button { text: "取消"; clicked => { root.on-cancel(); } } Button { text: "读取"; primary: true; clicked => { root.on-confirm(); } } }
}
}
// ==================== 写入设置对话框 ====================
export component WriteSetupDialog inherits Window {
title: "写入设置";
preferred-width: 420px; preferred-height: 320px;
background: Theme.bg-primary;
in property <[string]> write-function-codes;
in-out property <int> function-index <=> func-combo.current-index;
in-out property <int> slave-id <=> slave-input.value;
in-out property <int> start-address <=> addr-input.value;
in-out property <string> write-value <=> value-input.text;
callback on-confirm(); callback on-cancel();
VerticalLayout {
padding: Theme.spacing-xl; spacing: Theme.spacing-lg;
Text { text: "写入参数设置"; color: Theme.text-primary; font-size: 18px; font-weight: 700; horizontal-alignment: center; }
Rectangle { height: 1px; background: Theme.border; }
VerticalLayout {
spacing: Theme.spacing-md;
HorizontalLayout { spacing: Theme.spacing-md; alignment: start; Text { text: "功能码:"; color: Theme.text-secondary; vertical-alignment: center; width: 100px; } func-combo := ComboBox { model: root.write-function-codes; current-index: 1; horizontal-stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing-md; alignment: start; Text { text: "从站 ID:"; color: Theme.text-secondary; vertical-alignment: center; width: 100px; } slave-input := SpinBox { value: 1; minimum: 1; maximum: 247; horizontal-stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing-md; alignment: start; Text { text: "地址:"; color: Theme.text-secondary; vertical-alignment: center; width: 100px; } addr-input := SpinBox { value: 0; minimum: 0; maximum: 65535; horizontal-stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing-md; alignment: start; Text { text: "写入值:"; color: Theme.text-secondary; vertical-alignment: center; width: 100px; } value-input := LineEdit { text: "0"; horizontal-stretch: 1; } }
}
Rectangle { vertical-stretch: 1; }
Text { text: "线圈: 0=断开 1=闭合 | 寄存器: 0~65535"; color: Theme.text-muted; font-size: Theme.font-size-sm; horizontal-alignment: center; }
HorizontalLayout { spacing: Theme.spacing-md; alignment: end; Button { text: "取消"; clicked => { root.on-cancel(); } } Button { text: "写入"; primary: true; clicked => { root.on-confirm(); } } }
}
}
13、Cargo.toml
rust
[package]
name = "modbus-debug-assistant"
version = "0.1.0"
edition = "2024"
[dependencies]
slint = "1.16.1"
tokio = { version = "1", features = ["full"] }
chrono = "0.4"
hex = "0.4"
tokio-modbus = "0.17"
serialport = "4"
tokio-serial = "5"
anyhow = "1"
thiserror = "2"
log = "0.4"
env_logger = "0.11"
[build-dependencies]
slint-build = "1.16.1"
三、实现原理
1、整体架构
本工具基于 Rust + Slint 构建,采用 Tokio 异步运行时 驱动 Modbus 通信,整体分为三层:
- UI 层(main.slint):负责界面渲染与用户交互,包括连接设置、寄存器表格、通信日志、状态栏等组件。
- 业务逻辑层(main.rs):负责界面与通信模块之间的数据流转,处理用户操作事件,维护寄存器数据模型与通信统计。
- 通信层(modbus_client.rs / modbus_server.rs) :封装 Modbus TCP/RTU 客户端与 RTU 从机服务,基于
tokio-modbus与tokio-serial实现异步收发。
三者通过 Slint 的 callback 与 in/out property 机制解耦:UI 触发回调 → 业务层调用通信层 → 结果写回 UI 属性,实现单向数据流。
2、Modbus 协议帧结构
Modbus RTU 报文由四部分组成:
| 字段 | 长度 | 说明 |
|---|---|---|
| 从站地址 | 1 字节 | 0x01 ~ 0xF7,标识目标从站 |
| 功能码 | 1 字节 | 如 0x03 读保持寄存器、0x06 写单个寄存器 |
| 数据区 | N 字节 | 寄存器地址、数量、数据值等 |
| CRC16 | 2 字节 | 低字节在前,校验整个报文 |
以 读保持寄存器(FC 03) 为例,请求帧为:
01 03 00 00 00 0A C5 CD
│ │ │ │ └── CRC16
│ │ │ └──────── 数量 = 10
│ │ └────────────── 起始地址 = 0x0000
│ └───────────────── 功能码 = 0x03
└──────────────────── 从站地址 = 0x01
从站响应帧为:
01 03 14 00 01 00 02 ... 9A 3F
│ │ │ └──────────────┘ └── CRC16
│ │ │ 数据区(14 字节 = 7 个寄存器)
│ │ └───────────────── 字节数 = 0x14(20 字节)
│ └──────────────────── 功能码 = 0x03
└─────────────────────── 从站地址 = 0x01
3、CRC16 校验算法
Modbus RTU 使用 CRC16-IBM 算法,多项式为 0xA001(反转后的 0x8005)。核心实现如下:
rust
fn crc16(data: &[u8]) -> u16 {
let mut crc: u16 = 0xFFFF;
for &byte in data {
crc ^= byte as u16;
for _ in 0..8 {
if crc & 0x0001 != 0 {
crc = (crc >> 1) ^ 0xA001;
} else {
crc >>= 1;
}
}
}
crc
}
发送时 CRC 低字节在前、高字节在后;接收时对整帧(含 CRC)重新计算,结果为 0x0000 则校验通过。
4、RTU 从机服务实现
modbus_server.rs 中通过 tokio_serial::SerialStream 打开串口,在独立异步任务中循环读取数据:
rust
// 伪代码示意
loop {
let mut buf = [0u8; 256];
let n = serial.read(&mut buf).await?;
let frame = &buf[..n];
// 1. 校验 CRC
if crc16(frame) != 0 { continue; }
// 2. 解析从站地址,不匹配则忽略
if frame[0] != slave_id { continue; }
// 3. 按功能码分发处理
let response = match frame[1] {
0x01 => handle_read_coils(frame),
0x03 => handle_read_holding_registers(frame),
0x06 => handle_write_single_register(frame),
_ => build_exception(frame[1], 0x01), // 非法功能码
};
// 4. 回写响应帧
serial.write_all(&response).await?;
}
关键点:
- 每个请求帧必须完整接收,通过帧间隔(3.5 字符时间)判断帧边界;
- 从站地址不匹配时直接丢弃,不产生响应;
- 异常时返回异常响应帧:
从站地址 + 0x80|功能码 + 异常码 + CRC。
5、寄存器数据模型与多格式解析
data_models.rs 中的 RegisterModel 负责将原始 u16 值转换为多种展示格式:
| 格式 | 说明 | 示例(0x1234) |
|---|---|---|
| 无符号 | 直接十进制 | 4660 |
| 有符号 | 按 i16 解释 | 4660 |
| 十六进制 | 4 位 HEX | 1234 |
| 二进制 | 16 位 BIN | 0001001000110100 |
| 浮点 | 两个寄存器拼 IEEE 754 | 见下 |
| ASCII | 高/低字节转字符 | 12 |
IEEE 754 浮点解析:将相邻两个寄存器拼接为 32 位:
rust
let bits = ((high as u32) << 16) | (low as u32);
let float = f32::from_bits(bits);
ASCII 解析 :将寄存器高字节、低字节分别映射为可打印字符,不可打印的显示为 .。
6、通信日志与统计
- CommLog :环形缓冲结构,最多保留 5000 条记录,每条包含时间戳、方向(TX/RX/ERR)和原始数据,通过
version字段通知 UI 增量刷新。 - CommStatistics:维护 TX 帧数、RX 帧数、错误计数,每次收发后更新,并通过 Slint 属性绑定实时刷新状态栏。
7、UI 与业务层的数据绑定
Slint 通过 callback 将 UI 事件传递给 Rust 业务层,业务层处理完成后通过 in property 回写数据:
rust
// main.rs 中绑定回调
ui.on_connect({
let ui_handle = ui.as_weak();
move || {
// 打开连接对话框,建立 RTU 从机服务
}
});
// 更新寄存器表格
ui.set_registers(model_rc);
寄存器表格支持双击单元格直接编辑 ,编辑值通过 on-cell-edit 回调写回寄存器模型,并触发写操作。
8、异步任务调度
整个应用基于 Tokio 运行时,核心异步任务包括:
- 串口监听任务:持续读取串口数据,解析并响应 Modbus 请求;
- UI 事件循环 :Slint 的
run_event_loop驱动界面刷新; - 定时刷新任务:周期性更新寄存器表格与通信统计。
通过 tokio::spawn 创建独立任务,任务间使用 Arc<Mutex<>> 共享寄存器数据与统计信息,避免数据竞争。
