Skip to main content

Rust 设备 I/O

本章主要讲:

  • Linux 的设备 I/O 基础
  • 带缓冲的读写
  • 使用标准输入输出
  • 函数式编程: 链式和迭代式 I/O
  • 错误处理和返回值
  • 编写一个程序获取已连接 USB 设备的信息

理解设备 I/O

操作系统除了管理 CPU 和内存外, 还需要负责对系统中其他硬件的管理, 比如键盘鼠标, 硬盘, 显卡声卡网卡, 其他的 USB 设备等.

操作系统通过设备驱动来进行设备 I/O, 通过设备驱动来和设备进行交互.

什么是设备驱动?

设备驱动是一些加载到内核的共享库, 包含用于控制底层硬件的相关功能. 它们利用总线或其他的通信渠道和连接到系统中的硬件进行通信. 设备驱动根据设备的不同, 设备类型的不同以及操作系统的不同都各有不同.

设备驱动将硬件有关的细节都抽象出去了, 从而方便上层软件使用硬件. 比如磁盘驱动可能会将读写请求中的 block number 转换为磁道/扇区进行读写, 并控制设备的初始化, 判断设备是否正在被使用, 处理设备中断信号, 实现硬件协议等.

操作系统(特指内核)从用户空间的程序收到设备控制或访问的系统调用, 然后使用特定的设备驱动对设备进行物理层面的访问和控制.

Rust 程序使用标准库的图示如下:

在 Unix 系统中有"万物皆文件"的理念, 放到设备 I/O 这里也适用. 即外部设备的访问也会通过 open, close, readwrite 这些系统调用去访问, 设备被抽象为块设备, 字符设备等. 由于存在操作系统和设备驱动提供的抽象, 意味着用户空间中的开发者可以在"设备文件"这个抽象层上对设备进行访问. 并且 Rust 标准库还提供了一层设备无关的软件抽象层, 更便于开发了.

设备类型和设备驱动详解

这里的设备类型指的是"设备文件"的抽象类型:

  • 字符设备(character device): 发送和接受字节流的设备, 比如终端, 键盘, 鼠标, 打印机, 声卡等. 字符设备的特殊之处在于它不支持随机访问, 只能顺序访问.
  • 块设备(block device): 将信息保存为固定大小块的设备, 支持块的随机访问. 文件系统, 硬盘, 磁带, USB 摄像头都属于这一类(文件系统会被装载为块设备).
  • 网络设备(network device): 和字符设备类似, 数据被顺序读, 但也有一些区别. 数据会通过网络协议以变长的包(packet)发送. 比如网络适配器是一种提供对某个网络的接口的硬件设备.

操作系统通过设备类型(type)和设备号(device number)标识设备, 而设备号又被分为主(major)和从(minor)设备号.

当一个设备和系统连接后, 操作系统内核需要用到一个和这个设备兼容的且可操作这个设备的控制器的设备驱动. 设备驱动一般是需要特权的, 且可以动态加载的共享库, 它加载后作为内核的一部分运行(比如 macOS 的内核扩展或系统扩展).

当某个程序尝试访问设备时, 内核会查找并使用对应的驱动, 将控制权转交给设备驱动, 设备驱动执行完任务后, 会将控制权重新转交给内核.(其中查找过程用到了系统中的一个驱动表, 其中块设备和字符设备是分别记录到不同表.)

可以将设备驱动理解为内核和设备控制器之间的接口实现, 设备驱动实现了设备控制器所需的协议.

设备驱动通常是在内核空间运行的, 但也可以有用户空间驱动, 这样可以避开内核访问.

Rust 进行带缓冲的设备读写

Rust 标准库提供了基础的 ReadWrite trait(位于 std::io module 中), 它们表示设备 I/O 的通用接口(除了 openclose 外的), 它们有许多的实现, 比如文件 I/O, Tcp 流, 标准输入输出流等.

ReadWrite 是基于字节的接口(需要自己指定读写缓冲区长度), 这样可能导致多次系统调用从而影响性能, 因此会需要使用带缓冲的读写. Rust 提供了两个 struct 实现这样的功能: BufReader, BufWriter, 它们内置了缓冲区, 可以有效减少系统调用次数(各自实现了 BufReadBufWrite trait).

使用标准输入和标准输出

Unix 中每个进程启动后, 都自动打开了三个流文件: 标准输入, 标准输出, 标准错误. 指的是一种双端的结构, 一端连接到程序, 另外一端连接到某个系统资源. 比如标准输入可以用于从键盘或另外一个进程读取字符. 同理, 标准输出可用于将字符输出到终端或某个文件. 通常情况下, 标准错误都会默认连接到某个 log 文件.

Rust 提供了 Stdin, Stdout 结构用于和标准输入输出进行交互, 比如 Stdin 代表一个可以操作本进程对应的标准输入流的 handle, 实现了 Write.

同时提供的标准输入输出或错误 handle 中都是全局可访问的缓冲, 并发环境下可以在 handle 上调用 lock 来加锁.

函数式编程: 链式和迭代式的 I/O

处理错误并返回值

实战: USB 设备的信息获取

下面利用 libusb 库在用户空间中检测并输出已连接到电脑上的 USB 设备信息.

Rust 有一个 crate 是 libusb, 它封装了 libusb 的 C 实现.

工程设计

检测并输出 USB 设备的信息时, 步骤如下:

  • 当 USB 设备插入到电脑后, 会触发 USB 控制器的响应
  • USB 控制器发送一个中断到 CPU, CPU 会执行已注册到内核的对应中断处理程序
  • 当 Rust 程序通过 libusb crate 执行某个调用, 该调用会被转发到 libusb 的 C 实现, 而 C 实现又会进行一个系统调用, 从而读取该设备对应的设备文件.
  • 当系统调用从内核返回后, libusb 库会将调用结果返回给 Rust 程序

使用 libusb 的原因是如果从头开始写 USB 设备驱动的话, 需要实现 USB 协议, 要大量的时间去做.

工程中定义如下结构:

  • USBList: 所有已检测到的 USB 设备列表
  • USBDetails: USB 的详情列表
  • USBError: 自定义的错误处理

工程中实现如下函数:

  • get_device_information: 传入设备引用和设备句柄, 获取设备详情
  • write_to_file: 将设备详情写入到文件
  • main: 程序入口. 在其中初始化一个 libusb::Context, 获取已连接的设备列表, 通过 get_device_information 获取列表中每个设备的详情, 再通过 write_to_file 写入信息到文件中

实现所需的数据结构和函数

  1. 创建工程:

    cargo new usb && cd usb
  2. 添加 libusb 依赖:

    cargo add libusb
  3. 如果出现 libusb 的 buildscript 错误, 原因是本地没有安装 libusb C 的 library, 安装上即可:

    brew install libusb
  4. 简单的实现: 获取设备信息

    use std::error::Error;

    fn main() -> Result<(), Box<dyn Error>> {
    let context = libusb::Context::new()?;
    let list = context.devices()?;

    list.iter()
    .filter_map(|e| e.device_descriptor().ok())
    .for_each(|elem| {
    println!("{:?}", elem);
    });
    Ok(())
    }
  5. 其中转换数据信息结构和错误转换的过程略去.

  6. 在使用 libusb 时安装后默认是进行动态链接的, 如何生成一个直接静态链接 libusb 的 Rust 程序呢?