本文通过“用一个普通文件提供一块虚拟磁盘”的例子,浅谈 ublk(用户态块设备框架)。

为便于理解,下面只讨论基本读写路径,假设设备和后端正常工作,不展开部署命令、故障恢复和性能调优。

Background

Architecture

ublk is a generic framework for implementing block device logic from userspace. The motivation behind it is that moving virtual block drivers into userspace, such as loop, nbd and similar can be very helpful. It can help to implement new virtual block device such as ublk-qcow2 (there are several attempts of implementing qcow2 driver in kernel).

What

先把最容易混淆的一点说清楚:

1
2
3
应用使用一个标准的 Linux 块设备
≠
具体的存储逻辑必须全部写在内核中

ublk 是 Linux 的一种通用框架:由内核提供块设备接口,将具体的 I/O 请求交给用户态程序处理。

这个用户态程序通常称为 ublk server(ublk 服务程序)。两边可以这样分工:

1
2
3
内核侧:提供块设备,转交请求,接收完成结果

用户态:决定数据从哪里读、往哪里写,完成具体操作

所以:

“用户态块设备”不是说它绕过了内核,而是把具体的存储处理逻辑放到了用户态。

Why

假设要提供一种新的虚拟磁盘:数据可能保存在普通文件中,也可能来自远端存储,或者需要解析特殊的镜像格式。

如果将这些逻辑写成专用的内核驱动,开发、调试和更新都需要在内核环境下进行。

ublk 提供了另一种方式:

1
2
3
通用的块设备接口 → 由 ublk 内核驱动提供

具体的存储逻辑 → 用用户态程序实现

这样,可以复用用户态已有的库和调试工具,也可以独立于内核更新后端实现。

因此,它的目的可以概括为:

保留标准块设备的使用方式,同时让存储逻辑更容易开发和维护。

基础 Example

假设已有一个普通文件 disk.img,现在通过一个简单的用户态后端,将它作为虚拟磁盘的数据来源。

本例采用最简单的一一对应关系:

1
2
3
虚拟磁盘偏移 0      ↔ disk.img 偏移 0
虚拟磁盘偏移 4 KiB ↔ disk.img 偏移 4 KiB
虚拟磁盘偏移 8 KiB ↔ disk.img 偏移 8 KiB

Linux 对外提供的块设备名假设为:

1
/dev/ublkb0

ublksrv 项目的 loop 后端,就提供了这种由文件或其他块设备支撑的使用方式。

读取一段数据

假设上层向这块设备提交请求:

1
从偏移 8 KiB 开始,读取 4 KiB

处理过程可以简化为:

1
2
3
4
5
6
7
8
9
上层读取 /dev/ublkb0
↓
ublk 内核驱动把读请求交给用户态程序
↓
用户态程序读取 disk.img 的对应位置
↓
交回读取的数据和完成结果
↓
内核完成原来的块设备请求

对上层来说,它在读取一块磁盘;对用户态后端来说,它在读取一个文件。

写入一段数据

写入时,道理相同:

1
2
3
4
5
6
7
上层向虚拟磁盘某个位置写入数据
↓
用户态程序接到请求
↓
将数据写入 disk.img 的对应位置
↓
向内核报告处理结果

这里的一一对应是本例后端的实现方式,不是 ublk 对所有后端的要求。 换成其他后端,同一个磁盘位置也可以映射到远端数据或分层镜像中的某个区域。

How

只看三个配合的动作。

1. 创建一个上层能够使用的块设备

服务程序通过 ublk 的控制接口,配置设备容量等参数,并启动设备,由内核暴露 /dev/ublkb0 这样的块设备。

上层可以在这块设备上创建文件系统,再挂载使用。

1
2
3
4
5
应用按路径访问文件
↓
文件系统组织文件和目录
↓
向 ublk 块设备提交读写请求

ublk 不是文件系统;文件系统仍然可以运行在内核中。

2. 借助 io_uring,把请求交给用户态

io_uring 是 Linux 的异步 I/O 接口,使用内核与用户态共享的队列组织请求和完成通知。

ublk 利用 io_uring 的命令机制,在内核驱动和服务程序之间传递请求通知与完成结果。

对一次基本读写,服务程序需要知道的是:

1
2
3
4
5
做什么:读取还是写入?

在哪里:从磁盘的哪个位置开始?

做多少:需要处理多长的数据?

传到这里的是块设备请求,不是“打开哪个路径的文件”这样的文件系统操作。

这里使用 io_uring 的是 ublk 驱动与服务程序之间的交互路径,不要求上层普通应用也改用 io_uring。

3. 用户态完成后端操作,再提交结果

服务程序收到请求后,按照自己的后端逻辑执行:本例访问 disk.img,其他实现也可以访问远端存储。

处理结束后,再把完成结果交给 ublk 内核驱动,由驱动完成对应的块请求。

1
收到请求 → 处理后端 I/O → 提交结果 → 内核完成原请求

所以:

“请求已经交给用户态”,不等于“这个请求已经完成”。

记忆图

下面省略后端自身的 I/O 路径,只展示关键分工:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  应用:按路径访问文件
│
▼
内核:文件系统(如 ext4)
│
▼
内核:块层 + ublk 驱动
/dev/ublkb0
│
│ io_uring:请求通知与完成结果
▼
用户态:ublk 服务程序
│
▼
存储后端
本地文件 / 远端存储 / 分层镜像

可以简单记成:

1
2
3
4
5
对上:提供标准块设备

中间:传递请求和完成结果

对下:由用户态程序实现具体存储逻辑

一句话概括:

内核提供一块“能正常使用的磁盘”,用户态程序负责决定这块磁盘的数据怎样读写。


参考资料:

  1. Linux Kernel Documentation: Userspace Block Device Driver(ublk)
  2. ublksrv:ublk 用户态服务程序与开发库
  3. io_uring(7):异步 I/O 接口说明
  4. Linux ublk 接口定义:ublk_cmd.h
  5. ublk : virtual block devices in user space
  6. ublk virtual block devices in user space - DevConf.CZ 2023
  7. An io_uring-based user-space block driver
  8. How to use ublk on Oracle Linux 8