serialport-rs: one blocking serial API across POSIX and Windows
A cross-platform serial port library in Rust. Provides a blocking I/O interface and port enumeration including USB device information.
At a glance
- What is it?
- The serialport crate wraps TTY, COM and POSIX termios handling behind a single blocking SerialPort trait. It stays deliberately small, but its Linux enumeration depends on a system library, its macOS listing duplicates every device, and its blocking design pushes async users to other crates.
- Who is it for?
- serialport-rs fits Rust programs that talk to a serial device from a normal thread and want one API across Linux, macOS and Windows. It does not fit async runtimes, Linux builds that cannot ship libudev, or anyone who needs a stable release cadence, since the crate has no GitHub releases and the Cargo.toml carries an alpha version.
- Can I use it commercially?
- Check first. The repository uses a licence we do not classify automatically, so read its LICENSE file before any commercial use.
- Is it still maintained?
- Yes. The repository last received commits 47 days ago.
- What is it written in?
- Mainly Rust, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 25, 2026, and from our analysis. They are not legal advice.
Editorial analysis
One trait over TTYPort and COMPort
The whole public surface of serialport-rs is the SerialPort trait plus a builder that produces it. The README describes the design intent plainly: the library is structured to be the simplest API to use, so that cross-platform development is the default rather than something you opt into. The practical consequence is that you end up holding a Box<dyn SerialPort>, and the README recommends working with that type.
Behind the trait, the implementation is genuinely different per platform. On POSIX systems the concrete struct is TTYPort, on Windows it is COMPort, and both are available directly when you need platform-specific behaviour that the trait does not expose. The Cargo.toml shows how the split is carried: unix targets pull in nix with fs, ioctl, poll, signal and term features, macOS and iOS targets pull in core-foundation, io-kit-sys and mach2, and Windows targets pull in windows-sys with a long feature list covering device installation, file system, registry and process creation.
For anyone writing firmware tools, bench rigs or industrial device controllers in Rust, that split is the interesting part. The crate itself declares rust-version 1.59.0 and edition 2021, which is a useful floor if your toolchain policy is conservative.
Enumeration on Linux leans on a system library
Listing serial ports is the feature most projects want first, and on Linux it is where the operational friction sits. The Linux implementation using glibc relies on libudev, an external dynamic library that has to be present on the system where the final binary runs. It is a default Cargo feature, and the Cargo.toml confirms that with default = ["libudev"], alongside a separate hardware-tests feature and an optional serde dependency.
The README is direct about the trade-off. If you disable the libudev feature, enumeration still works, but it exposes less information and may return ports that do not exist physically. That second clause is the one to plan around: your UI can show a port that is not there, and the failure moves from enumeration time to open time.
Building without the dependency is a single feature flag change:
$ cargo build --no-default-featuresThat command drops libudev from the build. For a Linux distribution package, this is the difference between a crate that links against a system library you must declare as a dependency and one that does not. Note also that the README documents an MSRV dependency-pinning problem tracked as issue #324, so builds on older Rust versions can fail to resolve dependencies until you pin them with cargo update --precise.
Listing ports with available_ports
The enumeration API is a single free function that returns the ports it can find, and the README example is short enough to memorise. The port type carries a port_name field along with the other information the platform was able to supply, which is where the difference between the libudev and non-libudev builds shows up.
let ports = serialport::available_ports().expect("No ports found!");
for p in ports {
println!("{}", p.port_name);
}Enumeration is not available everywhere. The platform support table lists Android targets arm-linux-androideabi and armv7-linux-androideabi with no serial enumeration, and the same restriction for x86_64-unknown-netbsd. macOS is the opposite case and deserves its own warning. On that platform both the Callout ports under /dev/cu.* and the Dial-in ports under /dev/tty.* are enumerated, so a single connected device appears as two entries. A program that shows a picker to the user has to handle the duplicate itself, and a program that assumes one entry per physical device will misreport.
The examples directory reinforces that enumeration is a first-class concern: there is a list_ports example for listing available serial ports, alongside a loopback example and a hardware_check example that checks port and driver functionality for a single port or for a pair of ports connected to each other.
Opening a port, raising DTR and reaching native handles
Opening a port goes through a builder. You pass the device path and the baud rate, then chain settings before calling open.
let port = serialport::new("/dev/ttyUSB0", 115_200)
.timeout(Duration::from_millis(10))
.open()
.expect("Failed to open port");The timeout set here is the read and write timeout for the port, and it interacts with the blocking behaviour described further down. If a device waits for the DTR line to be asserted before it sends anything, the builder can raise DTR at open time, which matters for a lot of USB serial adapters and microcontroller boards.
let port = serialport::new("/dev/ttyUSB0", 115_200)
.dtr_on_open(true)
.open()
.expect("Failed to open port");On platforms that expose more than the trait covers, open_native returns the platform-specific port type instead of the boxed trait object, so you get TTYPort or COMPort and the extra methods that come with it.
let port = serialport::new("/dev/ttyUSB0", 115_200)
.open_native()
.expect("Failed to open port");One detail worth flagging: /dev/ttyUSB0 is the path used throughout the README examples, which is the conventional Linux name for a USB serial adapter. That path is a convention, not a guarantee. Nothing in the documentation promises it exists on your machine, so enumerate first and take the port_name you actually found.
Reads block, and closing is a drop
The port operates in blocking mode with a default timeout of 0 ms. That default matters more than it first appears. A zero timeout in a blocking read means the call returns what it has rather than waiting indefinitely, and it is the setting to change if your application needs to sit idle between frames.
let mut serial_buf: Vec<u8> = vec![0; 32];
port.read(serial_buf.as_mut_slice()).expect("Found no data!");Writing follows the same shape: you hand the port a byte slice and unwrap the result.
let output = "This is a test. This is only a test.".as_bytes();
port.write(output).expect("Write failed!");Closing has no dedicated call. The crate uses the Resource Acquisition Is Initialization paradigm, so the port is closed when the SerialPort object is dropped, either implicitly at the end of its scope or explicitly with std::mem::drop(port). That removes a category of bug where an early return leaves a file descriptor open, and it makes ports safe to hold inside structures that own other resources.
The cost of that design is that lifetime control now happens through Rust ownership rather than through an explicit close call. If you keep a Box<dyn SerialPort> alive in a long-lived struct, the handle stays open until that struct goes away. A duplex example exists in the repository to test that a port can be successfully cloned, which is worth running before you build anything that assumes a single owner.
The crate asks for maintainers and ships an alpha version
The repository is not archived and the last push was on 2026-09-08, so the code is still moving. That is the whole of the good news, because the README carries an explicit request: this project is looking for maintainers, especially for Windows. The project points contributors at a Matrix channel and at discussions.
Versioning is the other signal. The repository has no GitHub releases, and the Cargo.toml lists the package version as 4.10.2-alpha0. That means there is no tagged release to pin in production, only a crate version on crates.io and a source tree. Anyone building for a long-lived product should plan around that, and the CHANGELOG.md and NOTES.md files in the repository root are the places to look for what changed between versions.
Licensing is worth a look before you commit. The Cargo.toml declares license = "MPL-2.0" and the repository ships a LICENSE.txt file, while the repository metadata classifies the licence as a custom one that GitHub cannot categorise. Read LICENSE.txt yourself for the obligations that attach to a modified file, and check it against your organisation's licence policy rather than assuming the short identifier covers everything.
Async callers need mio-serial or tokio-serial
The library deliberately does not do asynchronous I/O, and the README points you elsewhere for that. The mio-serial crate and the tokio-serial crate are named directly as the options for async work.
The difference in approach is the point. serialport-rs gives you a blocking I/O interface, where a read with a timeout is a synchronous call that returns whatever arrived. mio-serial integrates with the mio event loop so a port can be polled for readiness inside an async task, and tokio-serial exposes the port as an async reader and writer for code already running on the Tokio runtime. Adopting serialport-rs inside a Tokio application means either blocking the runtime worker on a port read or wrapping the handle in a dedicated thread, and the second option is what most people end up doing.
So the choice depends on the surrounding program rather than on the device. If your program is a synchronous command line tool, a test harness or a service with a plain worker loop, the blocking trait is the smaller dependency and the simpler code. If your program already lives inside an async runtime and reads from several ports concurrently, take one of the other two crates instead.
The library also does not stop at POSIX and Windows. CI builds and runs some tests, those not needing real hardware, for the listed targets, including aarch64 and musl Linux builds, FreeBSD, Illumos, NetBSD, Android without enumeration, and Windows msvc and gnu targets. The README says no guarantees are made for targets outside that list, so if you are shipping on something unusual, the hardware_check example is the honest first step.
Editorial conclusion
serialport-rs fits Rust programs that talk to a serial device from a normal thread and want one API across Linux, macOS and Windows. It does not fit async runtimes, Linux builds that cannot ship libudev, or anyone who needs a stable release cadence, since the crate has no GitHub releases and the Cargo.toml carries an alpha version. Before adopting, run the hardware_check example on your target platform and confirm whether the default libudev feature is acceptable for your distribution model.
Frequently asked questions
How do I list serial ports with serialport-rs?
Call serialport::available_ports(), which returns the ports the platform can see, then read the port_name field of each entry. On Linux the default build uses libudev, and disabling that feature returns less information and may list ports that do not exist physically.
Does serialport-rs support async I/O on Rust?
No. The crate provides a blocking I/O interface, and the README points to the mio-serial and tokio-serial crates for async functionality.
What Rust version and system packages do I need for serialport-rs?
The library supports Rust 1.59.0 and higher and declares edition 2021. On GNU/Linux you need pkg-config headers and, unless you disable the default libudev feature, libudev headers.
How do I close a serial port in serialport-rs?
There is no explicit close call. The port is closed when the SerialPort object is dropped, either implicitly or explicitly with std::mem::drop(port), following the Resource Acquisition Is Initialization pattern.
Why does macOS show each serial device twice?
Both the Callout ports under /dev/cu.* and the Dial-in ports under /dev/tty.* are enumerated, so one connected device shows up as two entries. Your code has to handle the duplicate when it presents a port picker.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/serialport-serialport-rs)Was this useful?