/ PROGRAMMING, RUST, OS

Rust - I/O Management

현대 OS에서 I/O subsystem이 어떻게 동작하고, I/O 리소스가 관리되는지 간략하게 살펴보고, 파일 I/O 및 디바이스 I/O를 수행하기 위한 Rust API 함수를 알아보겠습니다.

Rust 맛보기

I/O Subsystems

OS는 I/O 작업과 관련해 크게 두 가지 역할을 합니다: 1) I/O 디바이스를 관리하고, 2) I/O 작업을 관리합니다.

I/O 디바이스 관리

I/O 디바이스는 그 종류가 매우 많기 때문에, OS는 이를 통제하기 위한 다양한 메소드가 필요합니다. 이런 메소드들은 I/O subsystem of the OS kernel에 속해 있으며, 커널의 나머지 부분을 I/O 장치 관리의 복잡함으로부터 분리합니다.

서로 다른 장치들의 디테일과 oddity를 encapsulate하기 위해, OS 커널은 구조화되어 device-driver 모듈을 사용합니다.

  • Device Driver: I/O 서브시스템에 대한 통일된 device-access 인터페이스입니다. 어플리케이션과 OS 사이의 표준 인터페이스를 제공하는 시스템 콜도 제공합니다.

I/O 작업 관리

I/O operation 또는 transaction은 프로그램이 source(예: 키보드, 마우스, 파일, 센서 등)로부터 바이트를 받거나, destination(예: 모니터, 파일, 네트워크 등)로 바이트를 보낼 때 발생합니다. Unix/Linux를 포함한 대부분의 현대 OS는 I/O 작업을 지원하기 위해 stream model을 사용합니다.

Stream Model

바이트의 흐름(stream)은 source에서 destination으로 이동하는 데이터의 흐름을 나타냅니다. OS는 프로그램에 의해 호출된 함수에 기반하여 stream을 생성하고 관리합니다.

Stream Model

한 개 이상의 CPU(코어)와 디바이스 컨트롤러들은 메모리 액세스를 제공하는 공유된 버스로 연결되어 있습니다. CPU와 디바이스의 동시 실행은 메모리 사이클을 얻기 위해 서로 경쟁 상태에 있습니다. 각 디바이스 컨트롤러가 특정 디바이스 유형을 책임지고, 각각은 로컬 버퍼를 갖고 있습니다. I/O 작업이라는 것은 I/O 디바이스와 컨트롤러 간 데이터 전송을 의미합니다. CPU 역시 데이터를 컨트롤러와 메인 메모리 간에 이동시킵니다.

Programmed I/O (a.k.a. Polling)

CPU는 디바이스 컨트롤러를 통해 I/O 디바이스의 상태를 반복적으로 확인합니다(예: 요청한 I/O 작업이 끝났는지).

I/O 디바이스가 ready state가 되면, CPU는 필요한 데이터를 직접 이동시킵니다.

CPU는 I/O 작업 핸들링에 반드시 관여하고 있어야 합니다. 이는 CPU의 자원이 비효율적으로 사용될 수 있음을 의미합니다.

CPU는 I/O 디바이스가 ready 되기를 기다리기 위해 상당한 시간을 소모할 수 있습니다(busy waiting).

디바이스 컨트롤러는 CPU에게 interrupt를 통해 이벤트 발생을 알립니다. 이 경우는 I/O 디바이스에서 이벤트가 발생했거나, 디바이스 컨트롤러가 I/O 작업을 끝낸 상황입니다.

File I/O in Rust

Stream 사용 단계

std::fsstd::io에 정의된 다양한 파일 I/O 작업을 활용할 수 있습니다.

  • std::fs::File 구조체는 파일 I/O 스트림을 관리하는 데에 사용됩니다.

  • 다음 함수를 통해 stream(File)을 생성할 수 있습니다.

    • File::open(): 파일을 읽기 위한(read-only) stream을 생성합니다. 파일이 존재하지 않으면 에러를 반환합니다.

    • File::create(): 파일을 쓰기 위한(write-only) stream을 생성합니다. 파일이 존재하면 덮어쓰고, 존재하지 않으면 새로 생성합니다.

Rust에서는 스트림이 범위를 벗어나면 자동으로 닫히기 때문에, 수동으로 파일을 닫을 필요가 없습니다.

use std::fs::File;
use std::io::{Write, Result};

fn main() -> Result<()> { // std::io::Result<T>는 std::io::Result<T, E>의 alias입니다.
    let mut file = File::create("hello.txt")?; // Stream 연결 생성
    file.write_all(b"Hello, world!")?; // 바이트들이 stream을 따라 전달됨
    Ok(())
} // Stream 연결 해제

생성된 스트림은 바이트를 주고 받는 하나의 주소로 간주할 수 있습니다. 각 주소는 OS에 의해 관리되는 메모리 영역을 가리키고 있죠.

std::fs::OpenOptions로 파일 권한 설정

std::fs::OpenOptions 구조체를 사용하여 파일을 열 때 권한을 설정하고 appending, truncating 여부도 설정할 수 있습니다.

use std::fs::OpenOptions;
use std::io::{Write, Result};

fn main() -> Result<()> {
    let mut file = OpenOptions::new()
        .read(true)
        .write(true)
        .create(true)
        .open("hello.txt")?;
    file.write_all(b"Hello, world!")?;
    Ok(())
}

파일로부터 바이트 읽기

파일 스트림의 read() 메소드를 사용하면 파일로부터 바이트를 읽을 수 있습니다.

use std::fs::File;
use std::io;
use std::io::prelude::*;

fn main() -> io::Result<()> {
    let mut file = File::open("hello.txt")?; // Read 타입?
    let mut buffer = [0; 5]; // 5바이트 버퍼 생성

    file.read(&mut buffer)?; // 파일로부터 5바이트 읽기

    println!("{:?}", buffer);
    Ok(())
}

read() 메소드의 반환값은 Result 타입입니다. 정상적으로 읽었을 경우 읽어들인 바이트 수 n이 담긴 Ok(n) 를 리턴하는데, n이 argument로 전달된 buffer의 길이인 buf.len()을 초과하지 않음을 보장합니다. 문제가 발생하면 Err를 리턴합니다.

read_to_end() 메소드를 사용하면 파일의 끝(EOF)까지 읽을 수 있습니다.

read_exact() 메소드도 있는데, 이는 argument로 주어진 buffer의 길이만큼의 바이트를 읽어들입니다. 이때 buffer의 길이보다 적은 바이트가 읽히면 에러를 반환합니다.

let mut f = File::open("hello.txt")?;
let mut buffer = Vec::new();

f.read_to_end(&mut buffer)?;

read_to_string() 역시 EOF 까지의 모든 바이트를 읽지만, 이때 이 바이트들은 UTF-8로 인코딩된 텍스트여야 합니다.

let mut f = File::open("hello.txt")?;
let mut buffer = String::new();

f.read_to_string(&mut buffer)?;

Reading Iterator

Read trait의 bytes() 메소드는 Read trait를 구현하는 구조체를 u8 타입의 iterator로 변환합니다. 요소의 타입이 u8이라는 것은 스트림을 바이트 단위로 읽는다는 것을 의미합니다.

반환된 타입은 Iterator trait을 구현하며, Item의 타입은 Result<u8, std::io::Error>입니다. 즉, iterator에 next() 메소드를 호출하여 얻어지는 타입은 Option<Result<u8, std::io::Error>>입니다. 이 iterator가 EOF에 도달하면 None을 반환합니다.

use std::fs::File;
use std::io;
use std::io::prelude::*;

fn main() -> io::Result<()> {
    let mut file = File::open("hello.txt")?;
    let mut buffer = Vec::new();

    for byte in file.bytes() {
        buffer.push(byte?);
    }

    println!("{:?}", buffer);
    Ok(())
}

Reading Adapters

Reading adapter라고 불리는 Read trait의 메소드들에 대해 알아봅시다.

Read trait의 chain() 메소드는 파일 스트림 자기 자신과 argument로 전달된 파일 스트림, 두 개의 파일 스트림을 연결하여 하나의 파일 스트림으로 만들어줍니다. 이는 두 개의 스트림을 하나의 스트림으로 합치는 것과 같습니다.

fn chain<R: Read>(self, next: R) -> Chain<Self, R>
    where Self: Sized,
use std::io;
use std::io::prelude::*;
use std::fs::File;

fn main() -> io::Result<()> {
    let mut file1 = File::open("hello.txt")?;
    let mut file2 = File::open("world.txt")?;

    let mut buffer = Vec::new();
    let mut file = file1.chain(file2);

    file.read_to_end(&mut buffer)?;
    println!("{:?}", buffer);
    Ok(())
}

Read trait의 take() 메소드는 최대 limit 바이트를 읽어들이는 새로운 파일 스트림을 생성합니다.

use std::io;
use std::io::prelude::*;
use std::fs::File;

fn main() -> io::Result<()> {
    let mut file = File::open("hello.txt")?;
    let mut buffer = Vec::new();

    let mut limited = file.take(5); // 최대 5바이트까지만 읽음

    limited.read_to_end(&mut buffer)?;

    println!("{:?}", buffer);
    Ok(())
}

Write Operations on a Stream

std::io::Write trait은 write operation에 대한 다양한 메소드를 제공합니다. File 구조체(스트림)이 이 trait을 구현하고 있습니다.

Write trait은 write()flush()의 두 메소드를 구현하도록 하고 있습니다. Read와 마찬가지로, 이와 관련된 다른 기본 메소드들도 제공하고 있습니다.

flush()는 모든 쓰여진 데이터가 target에 실제로 push됨을 보장합니다. Write operation은 최적화를 위해 버퍼링될 수 있기 때문입니다. 버퍼링된 모든 바이트가 성공적으로 쓰여질 수 없는 경우에는 Err를 리턴합니다.

// Required methods
fn write(&mut self, buf: &[u8]) -> Result<usize>
fn flush(&mut self) -> Result<()>

// 전체 버퍼를 self(스트림)에 쓰기 시도
fn write_all(&mut self, buf: &[u8]) -> Result<()>

// formatted string을 self(스트림)에 쓰기 시도
// 직접 호출하지 말고 write! 매크로 함수 호출 권장
fn write_fmt(&mut self, fmt: Arguments) -> Result<()>

// self(스트림)을 mutable reference로 borrow함
fn by_ref(&mut self) -> &mut Self where Self: Sized
  • write(): buf에 있는 바이트들을 스트림에 쓰려고 시도합니다. 성공적으로 쓰여진 바이트 수(n > 0)를 반환합니다. n > 0이 아닐 경우 Err가 리턴됩니다. read()와 마찬가지로 nbuf.len()을 초과하지 않음을 보장합니다.

  • write_all(): write()와 비슷하지만, buf의 모든 바이트가 쓰여질 때까지 반복적으로 write() 메소드를 호출하며 계속 시도합니다. write()와 달리 nbuf.len()과 같을 때만 성공적으로 쓰여진 것으로 간주합니다.

write!() 매크로 함수

writer(Write trait을 구현한 스트림)를 직접 사용하는 것은 일반적인 어플리케이션 개발에서는 권장되지 않습니다. 특히 출력을 포맷팅해야 할 때는 더욱 그렇습니다.

write_fmt()을 추상화한 write!() 매크로를 사용하면 편리하게 포맷팅한 문자열을 스트림에 쓸 수 있습니다.

use std::fs::File;
use std::io::{self, Write};

fn main() -> io::Result<()> {
    let mut file = File::create("hello.txt")?;

    write!(file, "Hello, {}!", "Rust")?;
    Ok(())
}

std::fs의 파일 I/O 함수들

File 구조체에 정의된 메소드 이외에도, std::fs 모듈에는 파일 I/O 작업을 수행하는 다양한 함수들이 정의되어 있습니다.

use std::fs;
use std::io::Result;

fn main() -> Result<()> {
    // 파일에 데이터를 씁니다. 파일이 존재하지 않으면 생성합니다.
    fs::write("hello.txt", "Hello, world!")?;

    // 파일로부터 데이터를 바이트의 벡터(바이너리 데이터)로 읽어들입니다.
    let data = fs::read("hello.txt")?;

    // 파일로부터 데이터를 UTF-8로 인코딩된 문자열로 읽어들입니다.
    let text = fs::read_to_string("hello.txt")?;

    // 파일을 새 위치로 복사합니다. 복사된 바이트 수를 반환합니다.
    let bytes_copied = fs::copy("hello.txt", "world.txt")?;

    // 파일을 삭제합니다.
    fs::remove_file("hello.txt")?;

    Ok(())
}

이 중 read_to_stringError Handling 게시글에서도 다뤘었죠.

File 구조체의 메소드와 std::fs 모듈이 제공하는 함수의 차이는 다음과 같이 정리할 수 있겠습니다.

File 구조체 메소드 std::fs 모듈 함수
파일을 열 때, 파일 핸들을 사용해 연속적으로 작업을 수행합니다. 파일을 ‘열지’ 않고, 작업을 단일 호출로 수행합니다.
섬세한 조작이 가능하며 다수의 read/write 작업이 있을 때 유용합니다. 간단한 파일 읽기/쓰기 작업에 적합합니다.
파일 핸들을 유지함으로써 연속적인 상호작용에 대해 더욱 정확한 제어를 할 수 있습니다. 빠른 file operation 시 편리합니다.

Standard Streams

우리가 너무 많이 접해와서 이젠 익숙하게 느껴지는 stdin, stdout, stderr는 Rust에서도 사용할 수 있습니다. 이들은 std::io 모듈에 정의되어 있습니다. 일단 간단하게 개념을 짚고 넘어가 봅시다.

프로그램이 실행될 때마다 OS는 자동으로 이하 3개의 스트림을 생성합니다.

  • stdin: 표준 입력 스트림. 키보드로부터 데이터를 읽어들입니다. Rust에서는 std::io::stdin() 함수를 호출하면 std::io::Stdin에 정의된 stdin 스트림을 반환합니다.

  • stdout: 표준 출력 스트림. 모니터로 데이터를 출력합니다. Rust에서는 std::io::stdout() 함수를 호출하면 std::io::Stdout에 정의된 stdout 스트림을 반환합니다.

  • stderr: 표준 에러 스트림. 프로그램을 에러 출력용으로 의도된 디스플레이에 연결시키고, 해당 디스플레이로 에러 메시지를 출력합니다. stdout과 달리 stderr는 프로그램에 대한 backup output stream으로 의도되었습니다. Rust에서는 std::io::stderr() 함수를 호출하면 std::io::Stderr에 정의된 stderr 스트림을 반환합니다.

Stdin 구조체는 Read trait을 구현하고, StdoutStderr 구조체는 Write trait을 구현합니다.

이들 구조체를 stream으로 사용할 수 있다는 뜻이죠.

use std::io::{self, Write};

fn main() {
    let mut input = String::new();

    io::stdout().write_all(b"Enter your name: ").unwrap();

    io::stdin().read_line(&mut input).unwrap();

    io::stdout().write_all(b"Hello, ").unwrap();
    io::stdout().write_all(input.trim().as_bytes()).unwrap();

위와 같이 Read, Write가 지원하는 메소드를 사용하여 표준 스트림을 사용할 수도 있습니다.

하지만, 일반적으로는 println!()print!() 매크로 함수를 사용하는 것이 stdout.write_all()을 사용하는 것보다 훨씬 편리하죠.

StdinLock & StdoutLock

표준 입력에 대한 lock이 의미하는 것은, 여러 스레드가 동시에 표준 입력을 읽지 못하도록 하는 것입니다. 오직 StdIn의 현재 인스턴스만 터미널로부터 입력을 읽을 수 있도록 하는 것이죠. 모든 read method들은 self.lock()을 내부적으로 호출합니다.

Stdin::lock()을 호출함으로써 StdinLock 구조체를 명시적으로 생성할 수도 있습니다. 이때 얻어진 lock은 반환된 lock이 scope를 벗어날 때 해제됩니다.

StdinLock은 또한 Read trait을 구현합니다: Read trait의 메소드들을 사용 가능합니다.

Stdout::lock()으로 표준 출력을 명시적으로 lock 하는 것 역시 마찬가지로 가능합니다. StdoutLockWrite trait을 구현합니다.

let stdin_lock: io::StdinLock = io::stdin().lock();
let stdout_lock: io::StdoutLock = io::stdout().lock();

I/O Buffering

버퍼란 스트림의 바이트에 대한 송수신자 사이에 있는 임시 저장공간을 의미합니다. 버퍼링은 I/O 작업을 최적화하기 위한 추가적인 메모리를 사용하는 기술로, 데이터의 흐름을 부드럽게 처리하기 위해 사용됩니다.

I/O Buffering이 필요한 이유는 다음과 같습니다.

  • Mitigating Speed Differences: CPU, 메모리 vs 네트워크 및 I/O 디바이스 간 속도 차이를 줄입니다. 버퍼를 이용해 일시적으로 데이터를 메모리에 저장함으로써, I/O 디바이스가 데이터를 처리하는 동안 CPU는 다른 작업을 수행할 수 있습니다.

  • Efficiency: 디스크나 네트워크를 모든 I/O 작업 수행 시마다 직접 접근하는 것은 시간이 많이 소요됩니다. 그동안 CPU는 idle 상태가 되죠. 버퍼를 사용하면 작은 데이터 조각들을 큰 batch들로 그룹핑하여 I/O 작업이 수행되는 빈도를 줄일 수 있고, batch processing을 통해 시스템 리소스를 더 효율적으로 사용할 수 있게 합니다.

Three Types of I/O Buffering

임시 저장공간이 어떻게 flushed되는지에 따라 나뉩니다.

  • Block buffering: 버퍼가 받은 데이터가 일정 크기에 도달하면 flush됩니다. 파일 입출력과 같은 큰 크기의 데이터 전송에 사용됩니다.

  • Line buffering: 버퍼가 개행 문자(\n)를 만날 때마다 flush됩니다. 터미널을 통해 사용자와 상호작용할 때와 같이, 일반적으로 텍스트 기반 입출력에 사용됩니다. 대표적으로 stdout은 line buffering을 사용합니다.

  • Unbuffered: 버퍼링이 없습니다. 데이터가 즉시 flush됩니다. 반응성이 매우 중시되는 상황에서 사용됩니다.

Block Buffering in Rust

Rust 표준 라이브러리는 block buffering에 대해 다음과 같은 타입을 제공합니다.

  • BufReader: 입력 스트림에서 미리 데이터를 읽어서 버퍼에 씁니다. 이후 프로그램이 요청할 때 요청된 양만큼의 데이터를 버퍼에서 읽어들여 반환합니다. 이 접근은 여러 개의 작은 read 요청을 버퍼 내에서 처리함으로써 파일이나 네트워크 스트림에서의 실제 I/O 작업을 줄입니다.

  • BufWriter: 출력 스트림에 데이터를 쓰기 전에 버퍼에 씁니다. 이후 버퍼가 꽉 차거나, 프로그램이 명시적으로 flush()를 호출할 때 파일이나 출력 디바이스에 데이터가 써집니다. 마찬가지로, 이 접근은 여러 개의 작은 write 요청을 버퍼 내에서 처리함으로써 파일이나 네트워크 스트림에서의 실제 I/O 작업을 줄입니다.

참고로 기본 버퍼 사이즈는 8KB입니다.

아까 bytes() 메소드를 사용했었던 예제를 다시 살펴봅시다.

bytes()로 얻어지는 iterator는 기본 구현으로, 각 바이트에 대해 read()를 호출합니다. 이는 매우 비효율적일 수 있습니다. BufReader를 사용하면 이를 개선할 수 있습니다.

use std::io;
use std::io::prelude::*;
use std::fs::File;

fn main() -> io::Result<()> {
    let f = File::open("hello.txt")?;
    for byte in f.bytes() {
        println!("{:?}", byte.unwrap());
    }
    Ok(())
}

위 코드는 매번 파일로부터 바이트를 읽어들이기 위해 read()를 호출합니다. 이는 매우 비효율적입니다. BufReader를 사용하면 이를 개선할 수 있습니다.

use std::io;
use std::io::prelude::*;
use std::fs::File;
use std::io::BufReader;

fn main() -> io::Result<()> {
    let f = File::open("hello.txt")?;

    let mut reader = BufReader::new(f);

    for byte in reader.bytes() {
        println!("{:?}", byte.unwrap());
    }
    Ok(())
}

BufReaderRead trait을 구현하고 있기 때문에, 그냥 일반적인 Read 스트림처럼 사용할 수 있습니다.

파일 스트림에서 읽어서 버퍼에 저장한 후, 그 스트림(버퍼)에 대해 bytes()를 호출하면 훨씬 효율적이죠!

BufReader는 또 다른 trait인 BufRead를 구현하고 있습니다. 이 trait은 BufReader에 대해 추가적인 메소드를 제공합니다.

// 데이터를 buf에 저장하면서 명시된 바이트가 발견될 때까지 읽습니다.
fn read_until(&mut self, byte: u8, buf: &mut Vec<u8>) -> io::Result<usize>

// 입력으로부터 한 줄을 읽어 buf에 저장합니다.
fn read_line(&mut self, buf: &mut String) -> io::Result<usize>

buf 변수는 그 버퍼가 아니라 그냥 작은 단위로 데이터를 실제 핸들링할 프로그램 내 collection 변수를 말하는 거니 헷갈리지 마세요!

또 두 개의 추가적인 iterator도 정의하고 있습니다.

// 입력 스트림으로부터 한 줄씩 읽어들이는 iterator를 반환합니다.
fn lines(self) -> Lines<Self> where Self: Sized

// 입력을 명시된 바이트를 기준으로 분리하여, 그 분리된 부분들을 순회하는 iterator를 반환합니다.
fn split(self, byte: u8) -> Split<Self> where Self: Sized

BufWriter

앞서 언급한 바와 같이, BufWriter는 데이터를 캐시하고, 다음 경우에 데이터를 flush합니다.

  • 버퍼가 꽉 찼을 때

  • flush()가 호출되었을 때

  • BufWriter 인스턴스가 drop될 때

use std::io::{self, BufWriter};
use std::io::prelude::*;
use std::fs::File;
use std::process;

fn main() -> io::Result<()> {
    let f = File::create("hello.txt")?;
    let mut writer = BufWriter::new(f);

    writer.write_all(b"Hello, world!")?;
    // writer.flush()?; // 이거 호출하면 버퍼 데이터가 flush될 것임

    // 프로그램 강제 종료
    process::exit(0); // 프로그램이 즉시 종료되기 때문에 `drop()` 함수가 호출되지 않음!
}

Pipes

파이프는 스트림이 다른 source 또는 destination으로 재연결(reconnected)되는 것을 의미합니다. 이러한 연결 과정을 piping 또는 pipelining이라고 합니다. 대표적인 에로, 표준 출력 스트림을 재연결하여 출력 데이터를 특정 파일에 쓰는 것이 있죠. 또한 piping은 inter-process communication(IPC)에서도 사용됩니다.

Piping Symbols

대부분의 shell들은 OS가 제공하는 세 가지 표준 스트림을 리다이렉트하는 기능을 제공하며, 다음과 같은 심볼을 사용합니다.

Symbol Stream reconnection
< 표준 입력이 주어진 파일로부터 들어옵니다.
> 표준 출력이 주어진 파일로 나갑니다.
\| 첫 번째 프로그램의 표준 출력이 두 번째 프로그램의 표준 입력으로 들어갑니다.

File Seek

std::fs::File은 파일 포인터로 사용될 수 있습니다. 스트림 내 위치를 추적하고, 데이터를 읽고 쓰기 위해 사용됩니다.

파일을 열 때, File 인스턴스는 파일의 첫번째 바이트를 가리키는 포인터를 가지고 있습니다.그리고 1바이트가 읽힐 때마다 파일 포인터는 다음 바이트로 자동으로 이동합니다. C, Python 등 다른 언어에서의 파일 포인터와 다름없죠.

FileSeek trait을 구현하여 이러한 파일 포인터 조작을 가능하게 합니다.

// 파일 포인터를 SeekFrom 기반(start, current, end of the file 중 하나를 기준점 삼아 이동)의 새 위치로 이동합니다.
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64>

// 파일 포인터의 현재 위치를 반환합니다.
fn stream_position(&mut self) -> io::Result<u64>
pub enum SeekFrom {
    Start(u64),
    End(i64),
    Current(i64),
}

seek 함수의 호출 예시입니다.

fpt.seek(SeekFrom::Start(10))?; // 파일 포인터를 파일의 10번째 바이트로 이동

File Attributes

Rust에서는 std::fs::metadata() 함수를 사용해 파일의 메타데이터를 포함한 정보를 얻을 수 있습니다. 이 함수는 std::fs::Metadata 구조체를 반환합니다. 이를 통해 파일 크기나 수정된 시간, 권한을 확인할 수 있죠.

use std::fs;

fn main() {
    let file_metadata = fs::metadata("hello.txt").unwrap();
    println!(
        "Len: {}, Last Accessed: {:?}, Last Modified: {:?}, Created: {:?}",
        file_metadata.len(),
        file_metadata.accessed().unwrap(),
        file_metadata.modified().unwrap(),
        file_metadata.created().unwrap()

    );
    println!("Is file: {}", file_metadata.is_file());
    println!("Is directory: {}", file_metadata.is_dir());
    println!("Is Symlink: {}", file_metadata.file_type().is_symlink());

    println!("Permissions: {:?}", file_metadata.permissions());
    println!("Metadata: {:?}", file_metadata);
}

파일의 권한은 설정할 때에는 Permissions 구조체를 쓰거나, fs::set_permissions() 함수를 사용합니다.

use std::fs;

fn main() {
    let mut perms = fs::metadata("hello.txt").unwrap().permissions();
    perms.set_readonly(true);
    fs::set_permissions("hello.txt", perms).unwrap();
}

Directory 접근

Rust에서는 std::fs::read_dir() 함수를 사용해 디렉토리 내 파일 목록을 얻을 수 있습니다. 이 함수는 std::fs::ReadDir 구조체를 반환합니다.

이 구조체는 Iterator trait을 구현하고 있어, next() 메소드를 사용해 파일 목록을 순회할 수 있습니다. next()Option<Result<DirEntry>>를 반환합니다.

스트림과 마찬가지로 디렉토리도 명시적으로 닫을 필요는 없습니다. Rust가 알아서 닫아주죠.

use std::fs;

fn main() {
    let dir = fs::read_dir("src").unwrap();

    for entry in dir {
        let entry = entry.unwrap();
        println!("{:?}", entry.path());
        let file_name_str = entry.file_name().to_string_lossy(); // 파일 이름이 유효한 UTF-8이 아닌 문자를 포함하는 경우 유효한 대체 문자로 변환
        println!("{:?}", file_name_str);
    }
}

디렉토리 생성

std::fs::DirBuilder 구조체는 재귀적으로 디렉토리 구조를 생성할 수 있는 메소드들을 제공합니다.

use std::fs::DirBuilder;

fn main() {
    let dir = DirBuilder::new();
    dir.recursive(true)
    .create("src/new_dir").unwrap();
}

std::fs 가 제공하는 create_dir() 또는 create_dir_all() 함수를 사용해 디렉토리를 생성할 수도 있습니다.

두 함수의 차이는 create_dir()은 하나의 디렉토리만 생성하고, create_dir_all()은 중간 디렉토리도 생성한다는 점입니다.

또한 remove_dir() 또는 remove_dir_all() 함수를 사용해 디렉토리를 삭제할 수 있습니다.

이때도 두 함수의 차이는 remove_dir()은 하나의 디렉토리만 삭제하고, remove_dir_all()은 하위 디렉토리까지 모두 삭제한다는 점입니다.

Device I/O

Unix 기반 OS의 큰 특징 중 하나는 모든 것이 파일이라는 것입니다. 디바이스도 파일로 표현되죠. 이는 디바이스 드라이버가 파일 시스템을 통해 디바이스에 접근할 수 있게 해줍니다. 이런 개념은 종종 file-based I/O 라고 부릅니다. 많은 디바이스들이 파일 시스템에 /dev 또는 /sys 디렉토리에 위치하고 있죠.

덕분에 스트림과 버퍼를 사용할 수 있고, 똑같은 I/O 함수를 디바이스에 대해 사용할 수 있습니다.

예를 들어 시리얼 포트(/dev/ttyUSB0)에 접근하는 소스 코드를 살펴봅시다.

use std::fs::OpenOptions;
use std::io::{Read, Write};

fn main() -> std::io::Result<()> {
    let mut serial_port = OpenOptions::new()
        .read(true)
        .write(true)
        .open("/dev/ttyUSB0")?;

    let mut buffer = [0; 10];
    serial_port.read(&mut buffer)?;

    serial_port.write_all(b"Hello, world!")?;

    Ok(())
}

Device Drivers

디바이스 드라이버는 디바이스에 접근하기 위한 여러 종류의 I/O함수(시스템 콜)들을 제공합니다: open(), read(), write(), close() 등이 그 예시입니다.

디바이스 파일명에 의해 디바이스에 접근할 때 이런 함수들이 실행되는 것입니다. 초반에 설명했던 것처럼 커널의 I/O 서브시스템 내에 위치하고 있습니다.

한편 파일 I/O 함수들만을 이용해서 디바이스의 특징적인 조작을 지원하는 것은 어렵습니다. 파일 I/O 함수들은 오직 데이터를 읽고 쓰는 데에만 기능이 집중되어 있기 때문에 디바이스의 고유 상태나 설정을 변경하는 것은 추가적인 작업이 필요합니다.

ioctl 함수

ioctl 함수는 디바이스 드라이버에 특정 명령을 보내는 시스템 콜입니다. 이 함수는 std::os::unix::io::AsRawFd 트레이트를 구현한 타입에 대해 사용할 수 있습니다.

이 함수의 1번째 argument인 fd는반드시 디바이스 파일의 파일 디스크립터여야 합니다. 2번째 argument인 request는 디바이스 드라이버에 전달할 명령을 나타내는 정수여야 합니다. 3번째 argument인 arg는 명령에 필요한 추가적인 데이터를 전달합니다.

Device-Driver 시스템의 전체적인 구조

Unix 기반 OS들은 모든 입출력 장치들을 3가지의 클래스로 구분합니다.

  • Block devices

    • Disk driver 등

    • 바이트의 블록을 한 단위로 전송

  • Character devices

    • 키보드, 마우스, 시리얼 포트, 프린터, 오디오 보드 등

    • 바이트 단위로 전송

  • Network devices

    • Ethernet, Wi-Fi 등 몇몇 네트워크 인터페이스

    • 소켓 인터페이스를 통해 데이터를 전송


참고 문헌

Rust 맛보기