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
| libmodbus | tokio-modbus | oms-modbus | |
|---|---|---|---|
| Language | C | Rust | Rust |
| First released | mid-2000s | 2017 | new — v0.2.0 preview |
| License | LGPL-2.1+ | MIT / Apache-2.0 | MIT / Apache-2.0 |
| Protocols | RTU, TCP | RTU, TCP | RTU, TCP, ASCII |
| Async (non-blocking) | no (blocking API) | yes (tokio), optional sync | yes (tokio) |
| Passive bus monitoring | no | no | yes (WireTap) |
| Auto-reconnect | no (manual) | no (manual) | yes (built-in) |
| 3.5T bus timing | no | no | yes |
| Typical user | C/C++, embedded, cross-language FFI | Rust async services | Rust diagnostics & monitoring |
Feature matrix
| Capability | libmodbus | tokio-modbus | oms-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 safety | manual (C) | ✅ | ✅ |
Zero .unwrap() in library code | n/a | — | ✅ (design goal) |
| Runtime dependencies | none | tokio + stack | 8 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 withmodbus_set_slave. - Reception: oms-modbus methods take
&self, so a client can be shared behind anArc; 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:
- 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.
- 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.
- Built-in auto-reconnect. Fixed-interval reconnection for serial and TCP, including USB serial resilience.
- 3.5T bus timing. Enforces the silent interval the Modbus serial spec mandates above 19200 baud — see the 3.5T deep-dive.
- 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 bindings → libmodbus. 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 monitoring → tokio-modbus. The safe Rust default with the larger community.
- Rust, and you need ASCII, passive bus monitoring, 3.5T timing, or built-in reconnect → oms-modbus. Especially if you are building diagnostic tooling — WireTap was built for exactly that.
- Building a Modbus diagnostic/analyzer product → oms-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.