oms-modbus vs libmodbus vs tokio-modbus: Choosing a Modbus Library

An objective comparison of three Modbus libraries — C's libmodbus, Rust's tokio-modbus, and oms-modbus — across protocols, async support, bus monitoring, licensing, and maturity, with the same read task written all three ways.

OrangeHorse Engineering Team 7 min read

oms-modbus vs libmodbus vs tokio-modbus: Choosing a Modbus Library

If you are integrating Modbus, you have three serious options that cover most of the field: libmodbus (C), tokio-modbus (Rust), and oms-modbus (Rust). This is an objective comparison. We built oms-modbus, so we will be explicit about what it does well and where it is still young — a library you should adopt for the right reason, not because its authors wrote the article.

The honest headline: the right choice depends on your language and runtime more than on any feature list. A C project on an RTOS and a Rust async service are answering different questions. We will work through both.

The three contenders at a glance

libmodbustokio-modbusoms-modbus
LanguageCRustRust
First releasedmid-2000s2017new — v0.2.0 preview
LicenseLGPL-2.1+MIT / Apache-2.0MIT / Apache-2.0
ProtocolsRTU, TCPRTU, TCPRTU, TCP, ASCII
Async (non-blocking)no (blocking API)yes (tokio), optional syncyes (tokio)
Passive bus monitoringnonoyes (WireTap)
Auto-reconnectno (manual)no (manual)yes (built-in)
3.5T bus timingnonoyes
Typical userC/C++, embedded, cross-language FFIRust async servicesRust diagnostics & monitoring

Feature matrix

Capabilitylibmodbustokio-modbusoms-modbus
Master (client)
Slave (server)
Modbus ASCII mode
Passive bus capture (WireTap)
Built-in reconnect
3.5-character silent interval
Synchronous / blocking mode✅ (native)✅ (opt-in feature)❌ (async only)
Memory safetymanual (C)
Zero .unwrap() in library coden/a✅ (design goal)
Runtime dependenciesnonetokio + stack8 crates

A note on the "❌" cells: libmodbus and tokio-modbus are not broken for lacking WireTap or bus timing — those are capabilities we specifically needed to build a diagnostic tool, and neither library aimed at that niche. Most Modbus integrations never need them. They are differentiators, not necessarily advantages for your use case.

Language and runtime: the real decision

The single biggest factor is what your project already runs on.

You are in C or C++ (or need it from another language)

libmodbus is the de-facto standard, and for good reason. It has been in production since the mid-2000s, runs on Linux, Windows, macOS, FreeBSD, QNX, and embedded targets, and — because it is C — can be bound from almost anything: Python, Node.js, Go, Java, and more. If you are writing a microcontroller firmware or a service that must not pull in a Rust toolchain, this is the safe, boring, correct answer.

The costs are what you would expect from C: you own memory management (modbus_new_tcp / modbus_free), the API is synchronous (a thread per connection, or select() loops for many), and the LGPL-2.1+ license is something to read carefully if you link statically. There is a commercial alternative (promodbus) if the LGPL does not fit.

You are in Rust

Both tokio-modbus and oms-modbus are async, tokio-native, and memory-safe. tokio-modbus is the more mature Rust option — it has been around since 2017, has a larger user base, and offers an opt-in synchronous mode. oms-modbus is the newcomer (v0.2.0 preview) with a narrower footprint but a few capabilities neither of the others has.

The same task, three ways

Read three holding registers (addresses 0–2) from unit id 1. All three return the same three values; what differs is how you get there.

libmodbus — synchronous C, blocking:

#include <modbus.h>
#include <stdint.h>

modbus_t *ctx = modbus_new_tcp("127.0.0.1", 1502);
modbus_set_slave(ctx, 1);                     /* unit id 1 */
modbus_connect(ctx);

uint16_t regs[3];
int rc = modbus_read_registers(ctx, 0, 3, regs);   /* address 0, 3 registers */

modbus_close(ctx);
modbus_free(ctx);

tokio-modbus — async Rust, the unit id is fixed when the connection is opened:

use tokio_modbus::client::tcp;
use tokio_modbus::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let socket = "127.0.0.1:1502".parse()?;
    let mut ctx = tcp::connect_slave(socket, Slave(1)).await?;
    let regs = ctx.read_holding_registers(0, 3).await?;
    println!("{regs:?}");
    Ok(())
}

oms-modbus — async Rust, the unit id is passed explicitly per call. This example is self-contained: it starts an in-memory server, so it runs with zero external dependencies or hardware:

use oms_modbus::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // In-memory server with three holding registers — no hardware needed.
    let store = Arc::new(SlaveStore::with_holding_registers(&[(0, 111), (1, 222), (2, 333)]));
    let server = tcp::TcpServer::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)).await?;
    let addr = server.local_addr()?;
    tokio::spawn(async move { server.serve_forever(store).await.ok(); });

    let client = tcp::TcpClient::connect_with_timeout(addr, Duration::from_secs(3)).await?;
    let regs = client.read_holding_registers(1, 0, 3).await?;
    println!("{regs:?}");
    Ok(())
}

Three API-shape differences worth noticing, because they will shape how your code reads:

  • Unit id: oms-modbus passes the slave id in every call (read_holding_registers(1, 0, 3)); tokio-modbus binds it when the connection is opened (connect_slave(…, Slave(1))); libmodbus sets it with modbus_set_slave.
  • Reception: oms-modbus methods take &self, so a client can be shared behind an Arc; tokio-modbus takes &mut self.
  • Testability: oms-modbus's generic transport lets you spin up an in-memory server for tests (as above). The tokio-modbus snippet assumes a real server is listening at 127.0.0.1:1502.

What oms-modbus adds

If the three libraries were otherwise equal, these are the capabilities that tip toward oms-modbus:

  1. ASCII mode. The only one of the three with a Modbus ASCII codec. If you are talking to legacy gear that only speaks ASCII, libmodbus and tokio-modbus cannot help.
  2. WireTap — passive bus monitoring. A read-only observer that attaches at the I/O boundary and records every frame (direction, raw bytes, microsecond timestamps) without participating in the bus — like a logic analyzer clipped onto RS-485. This is oms-modbus's reason for existing, and it powers the field troubleshooting and frame decoding articles.
  3. Built-in auto-reconnect. Fixed-interval reconnection for serial and TCP, including USB serial resilience.
  4. 3.5T bus timing. Enforces the silent interval the Modbus serial spec mandates above 19200 baud — see the 3.5T deep-dive.
  5. Panic-free production paths. Zero .unwrap() in library code; poison-safe mutexes; checked arithmetic.

What oms-modbus is missing — honestly

We will not pretend otherwise:

  • Maturity. It is a v0.2.0 preview. The API may change based on feedback. libmodbus has ~two decades of hardening; tokio-modbus has eight years.
  • Community and ecosystem. A handful of users versus libmodbus's decades of deployments and tokio-modbus's larger Rust footprint. When you hit a problem, there are far more existing answers for the other two.
  • Function-code coverage. No FC 43 (Read Device ID) or FC 17 (Report Server ID) yet — see the function-code reference for the full honest list.
  • No synchronous API. If you need blocking I/O from a non-async context, tokio-modbus offers a sync feature and libmodbus is natively blocking; oms-modbus is async-only.

When to choose which

  • C / C++, embedded / RTOS, or cross-language bindingslibmodbus. It is the battle-tested default and nothing in this article changes that.
  • Rust async service, TCP or RTU, mature ecosystem, no need for ASCII or bus monitoringtokio-modbus. The safe Rust default with the larger community.
  • Rust, and you need ASCII, passive bus monitoring, 3.5T timing, or built-in reconnectoms-modbus. Especially if you are building diagnostic tooling — WireTap was built for exactly that.
  • Building a Modbus diagnostic/analyzer productoms-modbus, full stop; passive capture with microsecond timestamps is the core of that product, and neither of the others offers it.

On performance

We are deliberately not publishing a three-way benchmark table. A fair comparison depends on transport (RTU vs TCP), poll interval, and read/write mix, and honest numbers require the same harness against all three — not something we can assert for libraries we did not write. What we can say, measured on our own hardware: CRC-16 validation runs at 16–668 ns and PDU decode at ~39 ns in oms-modbus (methodology here). In practice all three libraries are CPU-cheap relative to the wire — a 9600-baud RTU frame takes milliseconds to transmit, dwarfing any codec cost — so for most integrations, throughput is not the deciding factor.

The bottom line

They solve different problems. libmodbus is the mature C workhorse. tokio-modbus is the mature Rust async option. oms-modbus is the newcomer that exists to fill a specific gap — passive bus monitoring, ASCII, and protocol-level timing — and it is honest about being young. Choose by your language first, then by whether you actually need the capabilities only oms-modbus has. If you do not need WireTap or ASCII, the mature libraries are the lower-risk choice today; if you do, they cannot give it to you, and that is the reason oms-modbus exists.


This article is part of the oms-modbus tutorial series. oms-modbus is a transport-generic Modbus library in Rust — TCP, RTU, and ASCII through one API — used at OrangeHorse to build diagnostic tools for our Modbus-enabled sensors. MIT/Apache-2.0, on crates.io.

Read next: The 3.5T Rule — the timing requirement oms-modbus enforces that the others leave to you. Why We Built a Modbus Library in Rust — the requirements that ruled out the existing crates.

modbus modbus library modbus rust libmodbus tokio-modbus oms-modbus rust c comparison iiot