유닉스 계열에서 ioctl이 무엇이고 왜 필요한지, 그리고 NetBSD의 wsdisplay 예제를 통해 Rust에서 이를 호출하는 세 가지 방법(nix, libc, C FFI)을 비교한다.
유닉스 계열 시스템에서는 “모든 것이 파일이며, 파일이란 open으로 열고, 그로부터 read하고, 그에 write하고, 최종적으로 close하는 바이트 스트림”이라고 하죠… 맞나요? 맞나요? 음, 꼭 그렇지는 않습니다. _파일 디스크립터_가 커널이 제공하는 거의 모든 시스템에 대한 접근을 제공한다고 말하는 편이 더 정확합니다. 하지만 그것들이 모두 같은 네 가지 시스템 콜로 조작될 수 있다거나, 모두가 바이트 스트림처럼 동작한다고 말할 수는 없습니다.
보시다시피: 네트워크 연결은 실제로 파일 디스크립터로 조작하지만, 그것을 open 하지는 않습니다. bind, listen/accept 및/또는 connect를 합니다. 그리고 나서는 네트워크 연결에 read/write하는 대신, 어떤 방식으로든 send하고 recv합니다. 장치 드라이버도 비슷합니다. 하드웨어 장치는 /dev 계층의 “가상 파일”로 표현되고 많은 것들이 read와 write를 지원하지만… 드라이버가 제공하는 방대한 기능에 접근하기에는 이 두 시스템 콜만으로는 충분하지 않습니다. 그렇습니다, ioctl이 필요합니다.
ioctl은 유닉스의 “모든 것은 파일” 패러다임을 깨뜨리는 대표적인 시스템 콜입니다. ioctl은 열린 파일 디스크립터의 커널 측과 대역 외(out-of-band) 통신을 가능하게 하는 API입니다. 멋진 예시는 이전 글을 참고하세요. 그 글에서는 X11 없이 콘솔에서 그래픽스를 구동하는 방법을 보여주었습니다. 그 글에서 우리는 콘솔 디바이스를 open한 뒤 프레임버퍼의 속성을 얻기 위해 ioctl을 사용해야 했고, 이후 직접 접근을 위해 디바이스의 내용을 mmap했습니다. read도 write도 사용하지 않았죠.
이전 글에 보여준 코드는 그래픽스 글의 핵심을 유지하기 위해 C로 작성되었지만, 제가 실제로 작업 중인 코드는 EndBASIC의 일부이며 모두 Rust입니다. 문제는, Rust에서 ioctl을 호출하는 게 쉽지 않다는 점입니다. 사실, Rust를 7년간 써오면서 unsafe 블록을 처음으로 사용해야 했고, ioctl을 다루는 방법에 대한 좋은 문서도 없었습니다. 그래서 이 글에서는 Rust에서 ioctl을 호출하는 여러 가지 방법을 소개하고… 물론 ioctl이 실제로 무엇인지 조금 더 깊이 들어가 보려 합니다.
아래의 모든 예시에서는 NetBSD의 wsdisplay(4) 드라이버가 제공하는 비교적 단순한 ioctl을 사용하겠습니다. 이 API는 보통 /dev/ttyE0인 콘솔 디바이스 파일을 통해 접근할 수 있으며, 이름은 WSDISPLAYIO_GINFO입니다. 매뉴얼 페이지에서는 다음과 같이 설명합니다:
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.
C 프로그램에서 이 API를 호출하는 것은 아주 간단하며 다음과 같이 보입니다:
#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.
이 글에서 WSDISPLAYIO_GINFO를 선택한 이유는 세 가지입니다:
매뉴얼 페이지는 ioctl이 반환하는 데이터 구조의 사본을 친절히 제공하며, BSD 매뉴얼 페이지는 대개 _정말 훌륭_하지만, 코드 조각이 실제로 문서화된 코드와 일치하는지 다시 확인할 가치가 있습니다. 매뉴얼이 안내하는 대로 /usr/include/dev/wscons/wsconsio.h를 들여다보면 다음이 있습니다:
/* 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)
좋습니다, wsdisplay_fbinfo 구조체는 매뉴얼 페이지의 내용과 완벽히 일치합니다. 하지만 더 흥미로운 것은 #define인데, 이 매크로는 WSDISPLAYIO_GINFO가 다음과 같다고 말합니다:
ioctl이다(_IOR).W 클래스(아마 “_W_scons 디바이스 드라이버”의 W일 것입니다)의 함수 번호 65를 호출한다.wsdisplay_fbinfo 타입의 구조체에 담는다(wsdisplayio_fbinfo와 혼동하지 마세요).어느 면에서는, 이것은 다른 함수나 시스템 콜과 비슷하지만, 그렇게 정의되지 않았고 단일 API를 통해 전달된다는 점이 다릅니다. 따라서 ioctl은 “그저” 임의 기능들의 모음이며, 주어진 파일 디스크립터에서 무엇을 호출할 수 있는지는 그 파일 디스크립터가 무엇을 나타내는지에 달려 있습니다.
이런 설계가 나온 이유는 역사적이며, 물론 다른 선택지도 있었을 겁니다.
예를 들어: 일반 파일이 내부 구조를 가진다는 것을 잘 아시죠? 대부분의 파일 포맷은 헤더를 포함하고, 헤더는 파일 내의 다양한 섹션을 지정하며, 섹션은 데이터를 담습니다. 장치 드라이버에도 같은 방식을 적용할 수 있었을 겁니다. 즉, 이들의 가상 파일이 어떤 내부 형식을 미리 정의해서, 예컨대 wsdisplay_fbinfo 구조체가 항상 가상 파일의 오프셋 0x1000에 위치하도록 하는 겁니다. 이 설계에서는 read와 write만으로도 충분했을 텐데, 보다 효율적인 접근을 위해 mmap과 결합하는 것이 거의 필수였을 겁니다.
또 다른 예: 장치 드라이버가 RPC와 유사한 메커니즘을 사용해서, 파일 디스크립터에 대한 각 쓰기가 특정 함수를 요청하는 “메시지”가 되고, 커널이 이에 대한 응답을 보내게 할 수도 있었습니다. 이 설계에서도 read와 write로 충분했을 겁니다.
또 하나의 예: 장치 드라이버에 대한 요청을 데이터 사이에 섞어서, 데이터에 특정 시퀀스가 포함되어 있으면 커널이 데이터를 처리하는 대신 함수를 호출하게 하는 방법도 있었을 겁니다. 미친 것 같나요? 하지만 의사 터미널(pseudo-terminal)이 바로 그렇게 합니다. 색상을 바꾸는 등 다양한 작업을 위한 제어 시퀀스들은 터미널 드라이버에게 특별한 동작을 하라고 지시하는 것입니다.
어쨌든, 이는 모두 대안적 설계들일 뿐이고… 현재 시스템 어딘가에는 이런 것들이 어떤 형태로든 살아있을 겁니다. /dev의 가상 파일들이 동작을 노출하는 방식에는 일관성이 없으며, ioctl은 우리가 다뤄야 하는 선택지 중 하나일 뿐입니다. 그럼 이제 본론으로 들어가 Rust에서 이러한 서비스를 호출하는 세 가지 방법을 살펴보겠습니다.
첫 번째로, Rust에서 ioctl을 호출하는 방법은 멋진 nix 크레이트를 활용하는 것입니다. nix는 유닉스 기본 요소들에 대한 idiomatic(관용적) 접근을 제공합니다. 이 크레이트는 NixOS와 혼동하면 안 됩니다. 둘은 관련이 없습니다.
nix로 ioctl을 호출하려면 두 가지를 해야 합니다.
첫째, ioctl에서 사용하는 데이터 구조를 정의해야 합니다. C에서는 #include <dev/wscons/wsconsio.h>만 하면 되지만, Rust에서는 C 스타일 헤더에 접근할 수 없습니다. 대신 C의 wsdisplay_fbinfo와 동일한 메모리 레이아웃을 갖도록 Rust에서 정의해줘야 합니다:
use std::ffi::c_uint;
#[repr(C)]
struct WsDisplayFbInfo {
height: c_uint,
width: c_uint,
depth: c_uint,
cmsize: c_uint,
}
구조체를 C 표현(repr(C))으로 선언해 C 컴파일러가 동일 구조체에 대해 생성하는 메모리 레이아웃과 일치시키는 것이 _매우 중요_합니다. 커널은 시스템 콜 경계에서 C 의미론을 기대하며, 우리는 그에 맞춰야 합니다. 또한 각 필드의 타입이 C 정의와 일치하도록 해야 합니다. Rust에는 i16, u32와 같은 고정 크기 정수 타입만 있지만, C에는 int, unsigned long과 같은 플랫폼 의존 정수 타입이 있으며 때때로 이러한 타입들이 공개 커널 인터페이스에 사용됩니다(제 생각엔 실수입니다). 걱정 마세요. std::ffi 모듈은 이러한 C 타입에 대한 별칭을 제공합니다.
둘째, nix 크레이트에 특유한 일을 해야 합니다. ioctl을 다른 함수처럼 호출할 수 있도록, 해당 ioctl에 대한 래퍼 함수를 정의해야 합니다. nix는 앞서 보았던 C의 #define 구문을 모방하는 매크로를 제공하므로 이를 매우 쉽게 만들어줍니다:
use nix::ioctl_read;
ioctl_read!(wsdisplayio_ginfo, b'W', 65, WsDisplayFbInfo);
이제 모든 것을 하나로 묶은 완전한 프로그램을 볼 수 있습니다:
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(())
}
그런데 이 코드에는 놀라운 점이 하나 있습니다. WSDISPLAYIO_GINFO에 대한 래퍼 함수를 idiomatic한 nix 크레이트로 정의했으며, idiomatic한 nix 사용은 unsafe 블록을 요구하지 않는데… 왜 wsdisplayio_ginfo 호출을 unsafe 블록으로 감싸야 했을까요? 이유는 아마도 ioctl이 실행 중인 프로세스에 무엇이든 할 수 있고, Rust가 극도로 보수적으로 접근할 수밖에 없기 때문일 겁니다.
어쨌든, 위 코드는 깔끔하고 잘 동작합니다. 그런데… nix를 사용하면 비용이 듭니다:
$ 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
$ █
단지 파일을 열고 ioctl 하나를 호출하기 위해 5개의 크레이트를 프로젝트에 끌어들였습니다. 요즘 세상에 큰일은 아니지만… Rust 생태계가 부풀려진 의존성의 난장판이라는 인식을 강화하긴 합니다. 다른 방법이 있을까요?
nix를 완전히 우회하고 libc를 직접 호출하면 어떨까요? 어차피 nix도 libc에 _의존_하니, nix의 유닉스 인터페이스에 대한 idiomatic 표현을 잃는 대가를 치르더라도 libc를 직접 사용할 수 있습니다.
물론 가능합니다. libc::ioctl 함수를 직접 호출할 수 있는데, 그 프로토타입은 다음과 같습니다:
pub fn ioctl(fd: c_int, request: Ioctl, ...) -> c_int;
좋습니다. 첫 번째 인자로 파일 디스크립터가 필요하고, 그건 있습니다. 두 번째 인자로 Ioctl이 필요한데… 잠깐, 이 Ioctl 타입은 뭔가요? libc 소스에서 정의를 보면, Ioctl은 정수 타입(플랫폼에 따라 unsigned long 또는 int)의 별칭이며 C의 ioctl 정의와 일치합니다. 특별한 것은 없네요.
그렇다면… 두 번째 인자에는 무엇을 전달해야 할까요? C를 작성 중이었다면 WSDISPLAY_GINFO 상수를 사용했겠지만, Rust에서는 C 헤더 파일에 접근할 수 없으니 그게 없습니다. 그렇다면 WSDISPLAY_GINFO는 무엇일까요? 앞서 보았듯이 다음과 같이 정의되어 있습니다:
#define WSDISPLAYIO_GINFO _IOR('W', 65, struct wsdisplay_fbinfo)
… 이 시점에서는 큰 도움이 되지 않습니다. _IOR의 정의를 따라가 /usr/include/sys/ioccom.h에 가보면 다음이 있습니다:
#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))
으음. _IOR의 다양한 인자를 결합해 숫자를 만들어내고 있습니다. 코드를 읽어서는 해독하기 어렵기 때문에, 컴파일러에게 해당 상수의 실제 값을 알려달라고 해보겠습니다:
#include <dev/wscons/wsconsio.h>
#include <stdio.h>
int main(void) {
printf("%x\n", WSDISPLAYIO_GINFO);
}
프로그램을 실행해 보면 WSDISPLAYIO_GINFO는 0x40105741입니다. 이를 알면, libc 크레이트만으로 ioctl을 호출하는 건 사소한 프로그래밍 문제(SMOP)에 불과합니다:
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(())
}
보시다시피 이 코드에서도 커널의 구조체와 일치하도록 WsdisplayFbInfo 구조체를 정의해야 합니다. 즉, nix를 피한다고 해서 일이 더 간단해지지는 않았습니다. 오히려 이제는 원시 C 문자열, 전역 errno 값, ioctl 번호를 위한 불투명한 상수 같은 libc의 특이함을 직접 다뤄야 하므로 더 지저분해졌습니다. 썩 좋지는 않죠.
그렇다면 이런 생각이 듭니다… Rust에서 C 인터페이스를 복제하는 일을 피하고, 시스템이 제공하는 헤더 파일을 그대로 활용할 수 없을까요? 가능합니다. ioctl을 Rust에서 직접 호출하려 하기보다, 약간의 사용자 정의 C 글루 코드를 통해 호출하는 것입니다. 어차피 우리가 시스템 콜을 호출할 때 Rust는 시스템이 제공하는 libc로 어쨌든 들어가니, “조금 더 일찍” C로 전환한다고 보면 됩니다.
이 경우의 아이디어는, 다른 모든 컴퓨팅 문제와 마찬가지로, 추상화 계층을 하나 추가하는 것입니다. 즉, Rust에서 커널이 정의한 데이터 구조를 직접 다루는 대신, 우리만의 구조체와 API를 정의해서 Rust 세계를 C 세계로부터 분리합니다. 예를 들어 보죠:
#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;
}
먼저 wsdisplay_ginfo의 우리 버전인 my_ginfo를 선언하는데, Rust로 전달하고 싶은 몇몇 필드만 포함했습니다. 예제에서는 4개에서 3개로 줄였으니 이 간접화가 전혀 유용해 보이지 않지만, 더 큰 구조체를 반환하는 ioctl들도 있고 그중 일부 값만 필요할 수도 있습니다. 그런 다음, ioctl을 감싸서 그 반환 값을 우리의 구조체로 변환하는 단순한 함수를 정의합니다.
이제 Rust로 가서, 우리의 my_ginfo를 MyGInfo로 다시 정의합니다(두 구조체 모두 우리가 완전히 통제하니 일치 여부를 쉽게 검증할 수 있습니다). 그리고 래핑 함수를 호출합니다:
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(())
}
이제 C 코드와 Rust 코드를 함께 링크해야 하므로 build.rs 스크립트를 만듭니다. 여기서는 cc 크레이트를 사용해 두 코드를 묶습니다. 이는 빌드 시에만 사용되는 추가 의존성입니다:
fn main() {
println!("cargo::rerun-if-changed=src/ffi.c");
cc::Build::new().file("src/ffi.c").compile("ffi");
}
이렇게 하면 됩니다.
자, 볼까요:
$ 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
$ █
바이너리 크기 관점에서는 의미 있는 차이가 없습니다. 예상대로 nix 크레이트를 사용하면 전역 errno 값을 Rust의 Result 타입으로 변환하는 등의 추가 작업이 들어가므로 다른 대안보다 약간 코드가 많아집니다. 하지만 libc와 FFI 대안은 동일해 보입니다.
런타임에서는, 그러나, FFI 옵션이 아주 약간 더 느릴 수 있습니다(측정하기는 어려울 겁니다). 정상 경로에서 커널 구조체와 우리 구조체 사이를 변환해야 하기 때문입니다… 그 이득이 애매한데도 말이죠.
종합하자면, 저는 1번 옵션을 선택하겠습니다. 커널 구조체를 제 코드에서 수동으로 복제해야 한다는 점은 마음에 들지 않으므로, 시간이 된다면 이 정의들을 잘 검증된 libc 크레이트에 업스트림하든가, 그것들만 담은 재사용 가능한 다른 크레이트를 작성해 보려 합니다. 하지만 그럴 수 없다면, idiomatic한 nix 인터페이스 덕에 유닉스 기본 요소 호출이 무척 수월해집니다.