Crate rmodbus

source ·
Expand description

rmodbus - Modbus for Rust page page GitHub Actions CI

A framework to build fast and reliable Modbus-powered applications.

What is rmodbus

rmodbus is not a yet another Modbus client/server. rmodbus is a set of tools to quickly build Modbus-powered applications. Consider rmodbus is a request/response codec, plus context manager.

rmodbus is a part of EVA ICS v4 industrial automation stack.

Why yet another Modbus lib?

  • rmodbus is transport- and protocol-independent
  • rmodbus is platform independent (no_std is fully supported!)
  • can be easily used in blocking and async (non-blocking) applications
  • tuned for speed and reliability
  • provides a set of tools to easily work with Modbus context
  • supports client/server frame processing for Modbus TCP/UDP, RTU and ASCII
  • server context can be easily managed, imported and exported

So no server is included?

Yes, there is no server included. You build the server by your own. You choose the transport protocol, technology and everything else. rmodbus just process frames and works with Modbus context.

Here is an example of a simple TCP blocking server:

use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;

use lazy_static::lazy_static;

use std::sync::RwLock;

use rmodbus::{
    server::{context::ModbusContextFull, ModbusFrame},
    ModbusFrameBuf, ModbusProto,

lazy_static! {
    pub static ref CONTEXT: RwLock<ModbusContextFull> = RwLock::new(ModbusContextFull::new());

pub fn tcpserver(unit: u8, listen: &str) {
    let listener = TcpListener::bind(listen).unwrap();
    println!("listening started, ready to accept");
    for stream in listener.incoming() {
        thread::spawn(move || {
            println!("client connected");
            let mut stream = stream.unwrap();
            loop {
                let mut buf: ModbusFrameBuf = [0; 256];
                let mut response = Vec::new(); // for nostd use FixedVec with alloc [u8;256]
                if buf).unwrap_or(0) == 0 {
                let mut frame = ModbusFrame::new(unit, &buf, ModbusProto::TcpUdp, &mut response);
                if frame.parse().is_err() {
                    println!("server error");
                if frame.processing_required {
                    let result = if frame.readonly {
                    } else {
                        frame.process_write(&mut CONTEXT.write().unwrap()),
                    if result.is_err() {
                        println!("frame processing error");
                if frame.response_required {
                    println!("{:x?}", response.as_slice());
                    if stream.write(response.as_slice()).is_err() {

There are also examples for Serial-RTU, Serial-ASCII and UDP in examples folder (if you’re reading this text somewhere else, visit rmodbus project repository.

Launch the examples as:

cargo run --example app
cargo run --example tcpserver

Modbus context

The rule is simple: one standard Modbus context per application. 10k+10k 16-bit registers and 10k+10k coils are usually more than enough. This takes about 59Kbytes of RAM.

rmodbus server context is thread-safe, easy to use and has a lot of functions.

The context must be protected with a mutex/rwlock and every time Modbus context is accessed, a context mutex must be locked. This slows down performance, but guarantees that the context always has valid data after bulk-sets and after writes of long data types. So make sure your application locks context only when required and only for a short period time.

A simple PLC example:

use std::error::Error;
use std::fs::File;
use std::io::{Read, Write};

use rmodbus::server::context::ModbusContextFull;

#[path = "servers/"]
mod srv;

// put 1 to holding register 1500 to save current context to /tmp/plc1.dat
// if the file exists, context will be loaded at the next start

fn looping() {
    println!("Loop started");
    loop {
        let ctx =;
        let _param1 = ctx.get_holding(1000).unwrap();
        let _param2 = ctx.get_holdings_as_f32(1100).unwrap(); // ieee754 f32
        let _param3 = ctx.get_holdings_as_u32(1200).unwrap(); // u32
        let cmd = ctx.get_holding(1500).unwrap();
        if cmd != 0 {
            println!("got command code {}", cmd);
            let mut ctx = srv::CONTEXT.write().unwrap();
            ctx.set_holding(1500, 0).unwrap();
            match cmd {
                1 => {
                    println!("saving memory context");
                    let _ = save("/tmp/plc1.dat", &ctx).map_err(|_| {
                        eprintln!("unable to save context!");
                _ => println!("command not implemented"),
        // ==============================================
        // DO SOME JOB
        // ..........
        // WRITE RESULTS
        let mut ctx = srv::CONTEXT.write().unwrap();
        ctx.set_coil(0, true).unwrap();
        ctx.set_holdings_bulk(10, &(vec![10, 20])).unwrap();
        ctx.set_inputs_from_f32(20, 935.77).unwrap();

fn save(fname: &str, ctx: &ModbusContextFull) -> Result<(), Box<dyn Error>> {
    let config = bincode::config::standard();
    let mut file = File::create(fname)?;
    file.write(&bincode::encode_to_vec(ctx, config)?)?;

fn load(fname: &str, ctx: &mut ModbusContextFull) -> Result<(), Box<dyn Error>> {
    let config = bincode::config::standard();
    let mut file = File::open(fname)?;
    let mut data: Vec<u8> = Vec::new();
    file.read_to_end(&mut data)?;
    (*ctx, _) = bincode::decode_from_slice(&data, config)?;

fn main() {
    // read context
    let unit_id = 1;
        let mut ctx = srv::CONTEXT.write().unwrap();
        let _ = load(&"/tmp/plc1.dat", &mut ctx).map_err(|_| {
            eprintln!("warning: no saved context");
    use std::thread;
    thread::spawn(move || {
        srv::tcpserver(unit_id, "localhost:5502");

To let the above program communicate with outer world, Modbus server must be up and running in the separate thread, asynchronously or whatever is preferred.


rmodbus supports no_std mode. Most of the library code is written the way to support both std and no_std.

For no_std, set the dependency as:

rmodbus = { version = "*", default-features = false }

Small context

The full Modbus context has 10000 registers of each type, which requires 60000 bytes total. For systems with small RAM amount there is a pre-defined small context with 1000 registers:

use rmodbus::server::context::ModbusContextSmall;

Custom-sized context

Starting from the version 0.7 it is allowed to define context of any size using generic constants. The generic constants order is: coils, discretes, inputs, holdings.

E.g. let us define a context for 128 coils, 16 discretes, 0 inputs and 100 holdings:

use rmodbus::server::context::ModbusContext;

let context = ModbusContext::<128, 16, 0, 100>::new();


Some of rmodbus functions use vectors to store result. Different vector types can be used:

  • When the std feature is enabled (default), std::vec::Vec can be used.

  • With the fixedvec feature, fixedvec::FixedVec can be used.

  • With the heapless feature, heapless::Vec can be used.

  • When the alloc feature is enabled, Rust core allocation alloc::vec::Vec can be used in no-std mode. E.g cargo build --no-default-features --features alloc builds in no-std mode, and supports using core allocation alloc::vec::Vec. When std feature is enabled, the alloc feature is ignored.

Modbus client

Modbus client is designed with the same principles as the server: the crate gives frame generator / processor, while the frames can be read / written with any source and with any required way.

TCP client Example:

use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;

use rmodbus::{client::ModbusRequest, guess_response_frame_len, ModbusProto};

fn main() {
    let timeout = Duration::from_secs(1);

    // open TCP connection
    let mut stream = TcpStream::connect("localhost:5502").unwrap();

    // create request object
    let mut mreq = ModbusRequest::new(1, ModbusProto::TcpUdp);
    mreq.tr_id = 2; // just for test, default tr_id is 1

    // set 2 coils
    let mut request = Vec::new();
    mreq.generate_set_coils_bulk(0, &[true, true], &mut request)

    // write request to stream

    // read first 6 bytes of response frame
    let mut buf = [0u8; 6];
    stream.read_exact(&mut buf).unwrap();
    let mut response = Vec::new();
    let len = guess_response_frame_len(&buf, ModbusProto::TcpUdp).unwrap();
    // read rest of response frame
    if len > 6 {
        let mut rest = vec![0u8; (len - 6) as usize];
        stream.read_exact(&mut rest).unwrap();
    // check if frame has no Modbus error inside

    // get coil values back
    mreq.generate_get_coils(0, 2, &mut request).unwrap();
    let mut buf = [0u8; 6];
    stream.read_exact(&mut buf).unwrap();
    let mut response = Vec::new();
    let len = guess_response_frame_len(&buf, ModbusProto::TcpUdp).unwrap();
    if len > 6 {
        let mut rest = vec![0u8; (len - 6) as usize];
        stream.read_exact(&mut rest).unwrap();
    let mut data = Vec::new();
    // check if frame has no Modbus error inside and parse response bools into data vec
    mreq.parse_bool(&response, &mut data).unwrap();
    for i in {
        println!("{} {}", i, data[i]);

About the authors

Bohemia Automation / Altertech is a group of companies with 15+ years of experience in the enterprise automation and industrial IoT. Our setups include power plants, factories and urban infrastructure. Largest of them have 1M+ sensors and controlled devices and the bar raises higher and higher every day.



  • ModbusContext has become a struct with custom context sizes with generic constants
  • Use ModbusContextSmall for small contexts and ModbusContextFull for big ones
  • Removed built-in dump/restore methods (use serde, bincode or custom ones)


  • Moved modbus constants into a consts module.
  • Reworked the crate dependency and cargo-feature structure. Features are now additive only. Instead of the nostd feature, a std one can be opted-out by specifying default-features = false. Similarly, instead of the smallcontext feature, there is now a fullcontext feature which is also enabled by default.
  • Crate structure cleanup.


  • guess_request_frame_len function now supports TCP (and perhaps UDP)
  • huge code refactoring, fixed and formatted for the nowadays Rust standards
  • majority of functions correctly check overflows and report errors instead of invalid values/panics


  • Common functions and structures moved to main crate module
  • Modbus client


  • Modbus context is no longer created automatically and no mutex guard is provided by default. Use ModbusContext::new() to create context object and then use it as you wish - protect with any kind of Mutex, with RwLock or just put into UnsafeCell.
  • Context SDK changes: all functions moved inside context, removed unnecessary ones, function args optimized.
  • FixedVec support included by default, both in std and nostd.
  • Added support for 64-bit integers





Type Aliases