In Unix-like systems, “everything is a file and a file is defined as a byte stream you can open
, read
from, write
to, and ultimately close
”… right? Right? Well, not quite. It’s better to say file descriptors provide access to almost every system that the kernel provides, but not that they can all be manipulated with the same quartet of system calls, nor that they all behave as byte streams.
Because you see: network connections are manipulated via file descriptors indeed, but you don’t open
them: you bind
, listen
/accept
and/or connect
to them. And then you don’t read
from and write
to network connections: you somehow send
to and recv
from them. Device drivers are similar: yes, hardware devices are represented as “virtual files” in the /dev
hierarchy and many support read
and write
… but these two system calls are not sufficient to access the breath of functionality that the hardware drivers provide. No, you need ioctl
.
ioctl
is the poster child of the system call that breaks Unix’s “everything is a file” paradigm. ioctl
is the API that allows out-of-band communication with the kernel side of an open file descriptor. To see cool examples, refer back to my previous article where I demonstrated how to drive graphics from the console without X11: in that post, we had to open
the console device, but then we had to use ioctl
to obtain the properties of the framebuffer, and then we had to mmap
the device’s content for direct access: no read
s nor write
s involved.
All the code I showed you in that earlier post was written in C to keep the graphics article to-the-point, but the code I’m really working on is part of EndBASIC, and thus it is all Rust. And the thing is, ioctl
s are not easy to issue from Rust. In fact, after 7 years of Rust-ing, it’s the first time I’ve had to reach for unsafe
code blocks, and there was no good documentation on how to deal with ioctl
. So this posts aims to fix that by presenting what ways there are to call ioctl
s from Rust… and, of course, diving a bit deeper into what ioctl
s actually are.
Our target
For all examples below, I’ll be using a relatively simple ioctl
from NetBSD’s wsdisplay(4) driver. This API is available via the console device file, typically /dev/ttyE0
, and is named WSDISPLAYIO_GINFO
. Here is what the manual page has to say:
The following ioctl(2) calls are provided by the wsdisplay driver or by
devices which use it. Their definitions are found in
<dev/wscons/wsconsio.h>.
...
WSDISPLAYIO_GINFO (struct wsdisplay_fbinfo)
Retrieve basic information about a framebuffer display.
The returned structure is as follows:
struct wsdisplay_fbinfo {
u_int height;
u_int width;
u_int depth;
u_int cmsize;
};
The height and width members are counted in pixels. The
depth member indicates the number of bits per pixel, and
cmsize indicates the number of color map entries accessible
through WSDISPLAYIO_GETCMAP and WSDISPLAYIO_PUTCMAP. This
call is likely to be unavailable on text-only displays.
Calling this API from a C program would be trivial and look like this:
#include <dev/wscons/wsconsio.h>
#include <ioctl.h>
// ... open `/dev/ttyE0` as fd ...
struct wsdisplay_fbinfo fbi;
if (ioctl(fd, WSDISPLAYIO_GINFO, &fbi) == -1) {
// Handle error.
}
// fbi now contains the data returned by the kernel.
The reason I’m picking WSDISPLAYIO_GINFO
specifically to talk about ioctl
s in this article is three-fold:
- it returns a small structure with platform-dependent integers, so the sample code in the article will be relatively short;
- it relies on platform-specific integer types, so we’ll have to account for that in Rust; and
- it is “rare enough” (we are talking about NetBSD after all) that it is not going to be supported by any of the common Rust crates like libc or nix, so we’ll have to do extra work to call it.
What is an ioctl anyway?
The manual page helpfully provides us a copy of the data structure returned by the ioctl
, and the BSD manual pages are typically awesome, but it’s worth double-checking that the code snippet actually matches the code it documents. Peeking into /usr/include/dev/wscons/wsconsio.h
as the manual page directs us, we find:
/* Basic display information. Not applicable to all display types. */
struct wsdisplay_fbinfo {
u_int height; /* height in pixels */
u_int width; /* width in pixels */
u_int depth; /* bits per pixel */
u_int cmsize; /* color map size (entries) */
};
#define WSDISPLAYIO_GINFO _IOR('W', 65, struct wsdisplay_fbinfo)
OK, great, the wsdisplay_fbinfo
structure perfectly aligns with the manual page contents. But what’s more interesting is the #define
, which says that WSDISPLAYIO_GINFO
is:
- an ioctl that reads from the kernel (
_IOR
), - that invokes function number 65 from the
W
class (which probably stands for “the Wscons device driver”), and - that places the returned data into a structure of type
wsdisplay_fbinfo
(not to be confused withwsdisplayio_fbinfo
).
In a way, this is just like any other function or system call, except that it’s not defined as such and is instead funneled through a single API. ioctl
is, therefore, “just” a grab bag of arbitrary functionality, and what can be invoked on a given file descriptor depends on what the file descriptor represents.
The reasons for this design are historical and, of course, there could have been other options.
For example: you know how regular files have an internal structure, right? The vast majority of file formats out there contain a header, which then specifies various sections within the file, which then contain data. The same could have been done with device drivers: their virtual files could have predefined some internal format such that, e.g. the wsdisplay_fbinfo
structure always appeared at offset 0x1000 of the virtual file. read
and write
would have been sufficient for this design, albeit you’d almost-certainly wanted to combine it with mmap
for more efficient access.
Or another example: device drivers could have used an RPC-like mechanism where each write to the file descriptor is a “message” that requests a specific function, and that the kernel responds to with an answer. read
and write
would have been sufficient for this design.
Or yet another example: the requests to the device driver could have been intermixed with the data, such that if the data contained a specific sequence, the kernel would invoke a function instead of processing data. Sounds crazy, right? But that’s what pseudo-terminals do: all those control sequences to change colors and the like are telling the terminal driver to do something special.
In any case, these are all alternate designs and… I’m sure they all live in some form or another in current systems. There is no consistency in how /dev
pseudo-files expose their behavior, and ioctl
s are just one of the options we have to deal with. So without further ado, let’s look at three different ways of calling these services from Rust.
Option 1: nix
The first option to call an ioctl
from Rust is to leverage the neat nix crate, which provides idiomatic access to Unix primitives. This crate is not to be confused with NixOS, with which it has no relation.
To use nix to invoke ioctl
s, we need to do two things.
First, we need to define the data structure used by the ioctl
. In C, we would just #include <dev/wscons/wsconsio.h>
, but in Rust we don’t have access to the C-style headers. Instead, we have to do extra work to define the same memory layout of the C wsdisplay_fbinfo
structure, but in Rust:
use std::ffi::c_uint;
#[repr(C)]
struct WsDisplayFbInfo {
height: c_uint,
width: c_uint,
depth: c_uint,
cmsize: c_uint,
}
It is very important to declare the structure as having a C representation so that its memory layout matches what the C compiler produces for the same structure. The kernel expects C semantics in its system call boundary, and we must adhere to that. Additionally, we must ensure that the types of each field match the C definitions. Rust only has fixed-size integer types like i16
and u32
, but C provides platform-dependent integer types like int
or unsigned long
and these are sometimes used in public kernel interfaces (a mistake, if you ask me). Fear not, though: the std::ffi
module provides aliases for those C types.
And second, we have to do something unique to the nix crate: we have to define a wrapper function for the ioctl
so that we can invoke the ioctl
as if it were any other function. nix makes this very easy by providing macros that mimic the syntax of the C #define
we saw earlier on:
use nix::ioctl_read;
ioctl_read!(wsdisplayio_ginfo, b'W', 65, WsDisplayFbInfo);
And with that, we are ready to put everything together in a fully-fledged program:
use std::ffi::c_uint;
use std::io;
use std::mem;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use nix::fcntl;
use nix::ioctl_read;
use nix::sys::stat;
#[repr(C)]
#[derive(Debug)]
#[allow(unused)]
struct WsDisplayFbInfo {
height: c_uint,
width: c_uint,
depth: c_uint,
cmsize: c_uint,
}
ioctl_read!(wsdisplayio_ginfo, b'W', 65, WsDisplayFbInfo);
fn main() -> io::Result<()> {
let mut oflag = fcntl::OFlag::empty();
oflag.insert(fcntl::OFlag::O_RDWR);
oflag.insert(fcntl::OFlag::O_NONBLOCK);
oflag.insert(fcntl::OFlag::O_EXCL);
let fd = {
let raw =
fcntl::open("/dev/ttyE0", oflag, stat::Mode::empty())?;
unsafe { OwnedFd::from_raw_fd(raw) }
};
let mut fbi: WsDisplayFbInfo;
unsafe {
fbi = mem::zeroed();
wsdisplayio_ginfo(fd.as_raw_fd(), &mut fbi).unwrap();
}
eprintln!("fbinfo: {:?}", fbi);
Ok(())
}
There is one surprising detail in this code though: if we went through the hassle of defining a wrapper function for WSDISPLAYIO_GINFO
via the idiomatic nix crate, and idiomatic nix usage doesn’t require unsafe
blocks… why did we have to wrap the call to wsdisplayio_ginfo
in an unsafe
block? The reason may be that ioctl
can do whatever to the running process and Rust needs to be over-conservative.
In any case, the above is clean and it works. But… using nix comes with a cost:
$ cargo build
Compiling libc v0.2.169
Compiling cfg_aliases v0.2.1
Compiling bitflags v2.8.0
Compiling cfg-if v1.0.0
Compiling nix v0.29.0
Compiling ioctls-rust-nix v0.1.0 (/home/jmmv/os/homepage/static/src/ioctls-rust/nix)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.71s
$ █
We have pulled 5 crates into the project just to open a file and invoke an ioctl
. Not a huge deal in this day and age but… this contributes to the perception that the Rust ecosystem is a mess of bloated dependencies. Can we do differently?
Option 2: libc
What if we bypassed nix altogether and invoked libc directly? After all, we can see that nix depends on libc anyway, so we might as well use it at the expense of losing nix’s idiomatic representation of Unix’s interfaces.
Sure, we can do that: we can invoke the libc::ioctl
function directly, which has this prototype:
pub fn ioctl(fd: c_int, request: Ioctl, ...) -> c_int;
Alright then: we need a file descriptor as the first argument, which we have. And then we need an Ioctl
as the second argument, which we… wait, what is this Ioctl
type? If we look for its definition in the libc source code, we find that Ioctl
is an alias for an integer type (unsigned long
or int
depending on the platform), and this matches the C definition of ioctl
. OK, nothing special.
But then… what do we pass in this second argument? If we were writing C, we would use the WSDISPLAY_GINFO
constant, but we don’t have that in Rust because we don’t get access to the C header files. So what is WSDISPLAY_GINFO
? Remember that we previously saw that it is defined as such:
#define WSDISPLAYIO_GINFO _IOR('W', 65, struct wsdisplay_fbinfo)
… which doesn’t help us much at this point. But we can chase the definition of _IOR
, end up in /usr/include/sys/ioccom.h
, and see:
#define _IOC(inout, group, num, len) \
((inout) | (((len) & IOCPARM_MASK) << IOCPARM_SHIFT) | \
((group) << IOCGROUP_SHIFT) | (num))
#define _IOR(g,n,t) _IOC(IOC_OUT, (g), (n), sizeof(t))
Ugh. We are combining the various arguments to _IOR
into a number. This is hard to decipher by just reading the code, so we can ask the compiler to tell us the actual value of the constant instead:
#include <dev/wscons/wsconsio.h>
#include <stdio.h>
int main(void) {
printf("%x\n", WSDISPLAYIO_GINFO);
}
And if we run the program, we get that WSDISPLAYIO_GINFO
is 0x40105741
. Knowing that, it’s an SMOP to call the ioctl
using the libc crate alone:
use std::ffi::{c_char, c_uint};
use std::io;
use std::mem;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
const WSDISPLAYIO_GINFO: u64 = 0x40105741;
#[repr(C)]
#[derive(Debug)]
#[allow(unused)]
struct WsDisplayFbInfo {
height: c_uint,
width: c_uint,
depth: c_uint,
cmsize: c_uint,
}
fn main() -> io::Result<()> {
let fd = {
let result = unsafe {
libc::open(
"/dev/ttyE0\0".as_ptr() as *const c_char,
libc::O_RDWR | libc::O_NONBLOCK | libc::O_EXCL,
0,
)
};
if result == -1 {
return Err(io::Error::last_os_error());
}
unsafe { OwnedFd::from_raw_fd(result) }
};
let mut fbi: WsDisplayFbInfo;
unsafe {
fbi = mem::zeroed();
let result = libc::ioctl(
fd.as_raw_fd(),
WSDISPLAYIO_GINFO,
&mut fbi as *mut WsDisplayFbInfo,
);
if result == -1 {
return Err(io::Error::last_os_error());
}
}
eprintln!("fbinfo: {:?}", fbi);
Ok(())
}
As you can see from this code snippet, we also have to define the WsdisplayFbInfo
structure to match the kernel’s, so avoiding nix didn’t really make things simpler for us—and in fact, it made them uglier because now we have to deal with libc’s oddities like raw C strings, global errno
values, and an opaque constant for the ioctl
number. Not great.
Option 3: FFI
Which makes one wonder… can we avoid replicating the C interfaces in Rust and instead leverage the system-provided header files? Yes we can. Instead of trying to invoke the ioctl
s from Rust, we can invoke them via some custom C glue code. Rust is going to call into the system-provided libc anyway when we invoke a system call, so we might as well switch to C a bit “earlier”.
The idea in this case, as in any other computing problem, is to add a layer of abstraction: instead of dealing with the kernel-defined data structures from Rust, we define our own structures and APIs to detach the Rust world from the C world. Here, look:
#include <dev/wscons/wsconsio.h>
#include <sys/ioctl.h>
struct my_ginfo {
unsigned int height;
unsigned int width;
unsigned int depth;
};
int get_ginfo(int fd, struct my_ginfo* gi) {
struct wsdisplay_fbinfo fbi;
int result = ioctl(fd, WSDISPLAYIO_GINFO, &fbi);
if (result == -1) {
return result;
}
gi->height = fbi.height;
gi->width = fbi.width;
gi->depth = fbi.depth;
return 0;
}
We start by declaring our own version of wsdisplay_ginfo
, which I’ve called my_ginfo
, that only includes the few fields we want to propagate to Rust. Yes, in this example the indirection is utterly pointless because we go from 4 to 3 fields so we haven’t made our lives easier, but there are ioctl
s that return larger structures from which we might only need a few values. Then we define a trivial function to wrap the ioctl
and transform its return value into our own structure.
Then, we go to Rust, re-define our my_ginfo
structure as MyGinfo
(both of which we fully control so we can easily verify that they match) and we call the wrapping function:
use std::ffi::{c_char, c_int, c_uint};
use std::io;
use std::mem;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
#[repr(C)]
#[derive(Debug)]
#[allow(unused)]
struct MyGInfo {
height: c_uint,
width: c_uint,
depth: c_uint,
}
extern "C" {
fn get_ginfo(fd: c_int, gi: *mut MyGInfo) -> c_int;
}
fn main() -> io::Result<()> {
let fd = {
let result = unsafe {
libc::open(
"/dev/ttyE0\0".as_ptr() as *const c_char,
libc::O_RDWR | libc::O_NONBLOCK | libc::O_EXCL,
0,
)
};
if result == -1 {
return Err(io::Error::last_os_error());
}
unsafe { OwnedFd::from_raw_fd(result) }
};
let mut gi: MyGInfo;
unsafe {
gi = mem::zeroed();
let result = get_ginfo(fd.as_raw_fd(), &mut gi as *mut MyGInfo);
if result == -1 {
return Err(io::Error::last_os_error());
}
}
eprintln!("my_ginfo: {:?}", gi);
Ok(())
}
The trick now is to link the C code with the Rust code together, and for that, we create a build.rs
script. In here, we leverage the cc crate to put things together, which is an extra dependency that is only used at build time:
fn main() {
if cfg!(target_os = "netbsd") {
println!("cargo::rerun-if-changed=src/ffi.c");
cc::Build::new().file("src/ffi.c").compile("ffi");
} else {
println!("cargo::rerun-if-changed=src/dummy.c");
cc::Build::new().file("src/dummy.c").compile("ffi");
}
}
And with that, we are done.
Which option wins?
Well, let’s see:
$ cargo build --release
...
Compiling ioctls-rust-ffi v0.1.0 (/home/jmmv/ioctls-rust/ffi)
Compiling ioctls-rust-libc v0.1.0 (/home/jmmv/ioctls-rust/libc)
Compiling ioctls-rust-nix v0.1.0 (/home/jmmv/ioctls-rust/nix)
Finished `release` profile [optimized] target(s) in 1.89s
$ ls -lh target/release/ioctls-rust-* | grep -v \\.d
-rwxr-xr-x 2 jmmv jmmv 455K Feb 12 08:04 target/release/ioctls-rust-ffi
-rwxr-xr-x 2 jmmv jmmv 455K Feb 12 08:04 target/release/ioctls-rust-libc
-rwxr-xr-x 2 jmmv jmmv 464K Feb 12 08:04 target/release/ioctls-rust-nix
$ strip -s target/release/ioctls-rust-*
$ ls -lh target/release/ioctls-rust-* | grep -v \\.d
-rwxr-xr-x 2 jmmv jmmv 353K Feb 12 08:05 target/release/ioctls-rust-ffi
-rwxr-xr-x 2 jmmv jmmv 353K Feb 12 08:05 target/release/ioctls-rust-libc
-rwxr-xr-x 2 jmmv jmmv 358K Feb 12 08:05 target/release/ioctls-rust-nix
$ █
From a binary size perspective, there are no meaningful differences. As expected, the usage of the nix crate results in slightly more code than the other alternatives because nix has to do extra work to translate global errno
values into Rust Result
types and the like. But the libc and FFI alternatives seem identical.
At runtime, however, we should expect the FFI option to perform a teeny tiny bit worse (though good luck measuring that) than the libc option because we have to convert between the kernel structure and our own structure in the happy path… all for dubious benefit.
All in all, I’ll take option 1. I do not like the fact of having to manually replicate the kernel structures in my own code so, if I had the time, I’d try to upstream the definitions to the well-tested libc crate or write another reusable crate with just those. But, barring that, the idiomatic nix interfaces make calling Unix primitives a breeze.