块设备驱动开发:从gendisk到blk-mq的实战指南
块设备和字符设备是Linux驱动的两大阵营,字符设备大家写得多了,块设备却很少有人碰。原因很简单——块设备驱动门槛高,gendisk、request_queue、bio、blk-mq一堆概念,加上6.8内核里传统请求队列已经基本被blk-mq取代,网上老文章的代码跑不通,新手更懵。本文基于Linux 6.8内核,从块设备和字符设备的区别讲起,把核心数据结构过一遍,再用完整RAM磁盘驱动把blk-mq框架串起来,最后给调试方法和常见坑点。
一、块设备vs字符设备
| 维度 | 块设备 | 字符设备 |
|---|---|---|
| 数据访问 | 按扇区(512B)寻址 | 按字节流访问 |
| 缓冲机制 | 经过页缓存(page cache) | 不经过页缓存 |
| 随机访问 | 支持,可跳到任意扇区 | 一般不支持 |
| 最小寻址单位 | 扇区(512字节) | 字节 |
| 典型设备 | 硬盘、SSD、U盘、RAM磁盘 | 串口、按键、LED |
块设备的核心特征就是按扇区寻址。一个扇区(512字节),不管你个字节还是4096个字节,底层都是按扇区对齐操作。另一个关键区别是页缓存——块设备的读写默认经过页缓存,同一个数据块多次读取时直接从内存返回,不用反复访问硬件,字符设备没有这层缓存,每次read/write都直达驱动。
二、核心数据结构
块设备驱动打交道最多的五个结构体:gendisk、block_device_operations、request_queue、bio、bio_vec。
gendisk:磁盘设备表示
// linux/genhd.h(简化)
struct gendisk {
int major; // 主设备号
int first_minor; // 起始次设备号
int minors; // 次设备号数量(分区数+1。 const struct block_device_operations *fops; // 操作函数集 struct request_queue *queue; // 请求队列
char disk_name[DISK_NAME_LEN]; // 设备名,如sda
unsigned int flags; // 标志位,如GENHD_FL_NO_PART
// ... 更多字段
};
major:主设备号,向内核申请或动态分支-first_minor/minors:次设备号范围,minors至少为1(整个盘),要支持分区就多分配几。-fops:操作函数集,类似字符设备的file_operations,但简单得多。-queue:请求队列,6.8中必须是blk-mq队列
block_device_operations:块设备操作集
// linux/blkdev.h(简化)
struct block_device_operations {
int (*open)(struct gendisk *disk, blk_mode_t mode);
void (*release)(struct gendisk *disk);
int (*ioctl)(struct block_device *bdev, blk_mode_t mode,
unsigned cmd, unsigned long arg);
int (*getgeo)(struct block_device *bdev, struct hd_geometry *geo);
// 6.8中owner字段已移除,模块引用计数由内核自动管理};
注意6.8的变化:open的第二个参数从fmode_t改成了blk_mode_t,owner字段被移除,块设备的读写操作不在fops里——数据传输由请求队列处理,fops只管open/release/ioctl这些控制操作集
request_queue:请求队列
// linux/blk-mq.h(简化)
struct request_queue {
struct blk_mq_tag_set *tag_set; // blk-mq标签。 struct request *rq; // 当前请求
// ... 大量内部字段,驱动一般不直接操作
};
6.8内核中,传统request_queue已经完全被blk-mq取代。驱动不再直接操作request_queue,而是通过blk_mq_tag_set和blk_mq_ops来管理队列。
bio:块I/O请求
// linux/blk_types.h(简化)
struct bio {
struct block_device *bi_bdev; // 目标块设。 struct bvec_iter bi_iter; // 迭代器,含bi_sector
unsigned short bi_vcnt; // bio_vec数量
unsigned short bi_idx; // 当前bio_vec索引
struct bio_vec *bi_io_vec; // bio_vec数组
unsigned int bi_opf; // 操作标志(读/。flush等)
// ... 更多字段
};
struct bvec_iter {
sector_t bi_sector; // 起始扇区。 unsigned int bi_size; // 剩余字节。 unsigned int bi_idx; // 当前bio_vec索引
unsigned int bi_bvec_done; // 当前bio_vec内已处理字节。};
bio是块层发给驱动的I/O请求。一个bio描述一次连续的I/O操作,起始扇区在bi_iter.bi_sector,数据存放在bi_io_vec数组里。bio和request的关系:bio是块层提交的原始请求,request是blk-mq调度后包装的结构,一个request可能合并了多个bio。
bio_vec:段描述
// linux/bvec.h
struct bio_vec {
struct page *bv_page; // 数据所在的内存。 unsigned int bv_len; // 数据长度(字节)
unsigned int bv_offset; // 页内偏移
};
bio_vec描述bio中的一个段。一个bio可以包含多个bio_vec(即多段),每段指向一个内存页的不同区域。块设备驱动遍历bio_vec来实际搬运数据。
三、块设备驱动开发流程
6.8内核中推荐使用blk-mq框架,流程比传统方式多一步blk_mq_alloc_tag_set,但性能和扩展性更好。关键顺序:先分配gendisk,再初始化blk-mq,最后add_disk。add_disk之后设备就对用户空间可见了,这之前的顺序不能乱。
四、简单块设备驱动示例
下面是一个基于Linux 6.8的RAM磁盘驱动,用内存模拟磁盘,支持读写,使用blk-mq框架。
// my_blkdev.c - Linux 6.8 RAM磁盘驱动
#include <linux/module.h>
#include <linux/blk-mq.h>
#include <linux/blkdev.h>
#include <linux/genhd.h>
#include <linux/hdreg.h>
#include <linux/vmalloc.h>
#define MY_DISK_NAME "myram"
#define MY_DISK_MINORS 1
#define MY_DISK_SIZE (4 * 1024 * 1024) // 4MB
#define SECTOR_SIZE 512
#define NUM_SECTORS (MY_DISK_SIZE / SECTOR_SIZE)
// 驱动私有数据
struct my_blkdev {
struct gendisk *disk; // 磁盘设备
struct blk_mq_tag_set tag_set; // blk-mq标签。 void *data; // 模拟磁盘的内存 spinlock_t lock; // 保护数据访问
};
static struct my_blkdev *my_dev;
// ---- blk-mq操作回调 ----
// 处理单个请求
static blk_status_t my_queue_rq(struct blk_mq_hw_ctx *hctx,
const struct blk_mq_queue_data *bd)
{
struct request *rq = bd->rq;
struct my_blkdev *dev = rq->q->queuedata;
struct bio_vec bvec;
struct req_iterator iter;
sector_t sector = blk_rq_pos(rq);
loff_t offset = sector << SECTOR_SHIFT;
loff_t remaining = blk_rq_bytes(rq);
// 标记请求开始处。 blk_mq_start_request(rq);
// 检查越。 if (offset + remaining > MY_DISK_SIZE) {
blk_mq_end_request(rq, BLK_STS_IOERR);
return BLK_STS_IOERR;
}
// 遍历请求中的每个bio。 rq_for_each_segment(bvec, rq, iter) {
void *buf = kmap_local_page(bvec.bv_page);
unsigned int len = bvec.bv_len;
loff_t pos = offset + bvec.bv_offset;
if (req_op(rq) == REQ_OP_READ) {
// 读:从内存拷贝到bio
memcpy(buf + bvec.bv_offset,
dev->data + pos, len);
} else if (req_op(rq) == REQ_OP_WRITE) {
// 写:从bio拷贝到内存 memcpy(dev->data + pos,
buf + bvec.bv_offset, len);
}
kunmap_local(buf);
offset += len;
remaining -= len;
}
blk_mq_end_request(rq, BLK_STS_OK);
return BLK_STS_OK;
}
// 初始化硬件上下文
static int my_init_hctx(struct blk_mq_hw_ctx *hctx, void *data,
unsigned int index)
{
return 0;
}
// blk-mq操作集static const struct blk_mq_ops my_mq_ops = {
.queue_rq = my_queue_rq,
.init_hctx = my_init_hctx,
};
// ---- block_device_operations ----
static int my_blk_open(struct gendisk *disk, blk_mode_t mode)
{
struct my_blkdev *dev = disk->private_data;
if (!dev)
return -ENODEV;
return 0;
}
static void my_blk_release(struct gendisk *disk)
{
// RAM磁盘不需要特殊释放操。}
static int my_blk_ioctl(struct block_device *bdev, blk_mode_t mode,
unsigned int cmd, unsigned long arg)
{
struct my_blkdev *dev = bdev->bd_disk->private_data;
switch (cmd) {
case HDIO_GETGEO: {
struct hd_geometry geo;
geo.cylinders = NUM_SECTORS / (4 * 16);
geo.heads = 4;
geo.sectors = 16;
geo.start = 0;
if (copy_to_user((void __user *)arg, &geo, sizeof(geo)))
return -EFAULT;
return 0;
}
default:
return -ENOTTY;
}
}
static const struct block_device_operations my_blk_fops = {
.open = my_blk_open,
.release = my_blk_release,
.ioctl = my_blk_ioctl,
};
// ---- 模块初始化与退。----
static int __init my_blkdev_init(void)
{
int ret;
// 分配私有数据
my_dev = kzalloc(sizeof(*my_dev), GFP_KERNEL);
if (!my_dev)
return -ENOMEM;
spin_lock_init(&my_dev->lock);
// 分配模拟磁盘的内存 my_dev->data = vzalloc(MY_DISK_SIZE);
if (!my_dev->data) {
ret = -ENOMEM;
goto fail_data;
}
// 分配gendisk
my_dev->disk = blk_alloc_disk(NULL, NUMA_NO_NODE);
if (IS_ERR(my_dev->disk)) {
ret = PTR_ERR(my_dev->disk);
goto fail_disk;
}
// 设置gendisk字段
my_dev->disk->major = 0; // 动态分配主设备。 my_dev->disk->first_minor = 0;
my_dev->disk->minors = MY_DISK_MINORS;
my_dev->disk->fops = &my_blk_fops;
my_dev->disk->private_data = my_dev;
snprintf(my_dev->disk->disk_name, DISK_NAME_LEN, MY_DISK_NAME);
// 初始化blk-mq标签。 my_dev->tag_set.ops = &my_mq_ops;
my_dev->tag_set.nr_hw_queues = 1;
my_dev->tag_set.queue_depth = 128;
my_dev->tag_set.numa_node = NUMA_NO_NODE;
my_dev->tag_set.cmd_size = 0;
my_dev->tag_set.flags = BLK_MQ_F_SHOULD_MERGE;
my_dev->tag_set.driver_data = my_dev;
ret = blk_mq_alloc_tag_set(&my_dev->tag_set);
if (ret)
goto fail_tag_set;
// 创建请求队列
my_dev->disk->queue = blk_mq_init_queue(&my_dev->tag_set);
if (IS_ERR(my_dev->disk->queue)) {
ret = PTR_ERR(my_dev->disk->queue);
goto fail_queue;
}
my_dev->disk->queue->queuedata = my_dev;
// 设置队列参数
blk_queue_logical_block_size(my_dev->disk->queue, SECTOR_SIZE);
blk_queue_physical_block_size(my_dev->disk->queue, SECTOR_SIZE);
blk_queue_max_hw_sectors(my_dev->disk->queue, 256);
set_capacity(my_dev->disk, NUM_SECTORS);
// 注册磁盘,对用户空间可见
ret = add_disk(my_dev->disk);
if (ret)
goto fail_add_disk;
pr_info("my_blkdev: RAM磁盘已注册,大小 %d MB\n",
MY_DISK_SIZE / 1024 / 1024);
return 0;
fail_add_disk:
blk_cleanup_queue(my_dev->disk->queue);
fail_queue:
blk_mq_free_tag_set(&my_dev->tag_set);
fail_tag_set:
put_disk(my_dev->disk);
fail_disk:
vfree(my_dev->data);
fail_data:
kfree(my_dev);
return ret;
}
static void __exit my_blkdev_exit(void)
{
// 删除磁盘
del_gendisk(my_dev->disk);
// 清理请求队列
blk_cleanup_queue(my_dev->disk->queue);
// 释放标签。 blk_mq_free_tag_set(&my_dev->tag_set);
// 释放gendisk引用
put_disk(my_dev->disk);
// 释放模拟磁盘内存
vfree(my_dev->data);
kfree(my_dev);
pr_info("my_blkdev: RAM磁盘已卸载\n");
}
module_init(my_blkdev_init);
module_exit(my_blkdev_exit);
MODULE_AUTHOR("linuxros");
MODULE_DESCRIPTION("Linux 6.8 RAM磁盘块设备驱动);
MODULE_LICENSE("GPL");
关键点说明
blk_mq_ops是blk-mq框架的核心,6.8中必须实现.queue_rq。
- queue_rq:处理I/O请求的回调,这是数据搬运的主战场
- init_hctx:初始化硬件队列上下文,简单驱动可以空实现
queue_rq里的处理流程。
1. blk_mq_start_request(rq)标记请求开始,必须在处理前调用
2. blk_rq_pos(rq)获取起始扇区,blk_rq_bytes(rq)获取总字节数
3. rq_for_each_segment遍历请求中的每个bio。4. kmap_local_page/kunmap_local_page访问页内数据。.8推荐替代kmap_atomic。5. req_op(rq)判断读还是写
6. blk_mq_end_request结束请求,通知块层处理完成
6.8 API变化:kmap_atomic/kunmap_atomic已被kmap_local_page/kunmap_local_page取代,新API不禁止抢占,更安全。blk_alloc_disk替代了旧的alloc_disk,配合动态主设备号分配更方便。
五、bio操作API
遍历bio。
// 遍历bio中的每个段
struct bio_vec bvec;
struct bvec_iter iter;
bio_for_each_segment(bvec, bio, iter) {
void *buf = kmap_local_page(bvec.bv_page);
// 访问 buf + bvec.bv_offset,长bvec.bv_len
kunmap_local(buf);
}
bio_for_each_segment是遍历bio段的标准宏,每次迭代得到一个bio_vec,包含页指针、偏移和长度。注意bv_offset——数据不一定从页的起始位置开始,尤其在页内对齐场景下。
访问段内数据
// 6.8推荐方式
void *buf = kmap_local_page(bvec.bv_page);
memcpy(dst, buf + bvec.bv_offset, bvec.bv_len);
kunmap_local(buf);
// 旧方式(6.8仍可用但不推荐)
void *buf = kmap_atomic(bvec.bv_page);
memcpy(dst, buf + bvec.bv_offset, bvec.bv_len);
kunmap_atomic(buf);
kmap_local_page比kmap_atomic更灵活:不禁止抢占,可以睡眠(虽然块设备驱动中一般不会),嵌套调用更安全。唯一限制是不能跨CPU迁移——但在块设备驱动的queue_rq回调里这不是问题。
完成bio请求
// 直接完成bio(不经过blk-mqbio_endio(bio);
// 通过blk-mq完成request
blk_mq_end_request(rq, BLK_STS_OK); // 成功
blk_mq_end_request(rq, BLK_STS_IOERR); // IO错误
blk_mq_end_request(rq, BLK_STS_NOSPC); // 空间不足
blk-mq驱动中一般用blk_mq_end_request而不是bio_endio,因为request可能合并了多个bio,blk_mq_end_request会一次性完成所有bio。
bio数据拷贝辅助函数
// 从bio拷贝数据到迭代器(读操作集size_t bio_copy_to_iter(struct bio *bio, struct iov_iter *iter);
// 从迭代器拷贝数据到bio(写操作集size_t bio_copy_from_iter(struct bio *bio, struct iov_iter *iter);
这两个函数在需要把bio数据和用户空间缓冲区之间拷贝时使用,内核内部处理页映射和偏移计算。不过块设备驱动中更常见的做法是直接在queue_rq里用memcpy操作,像上面示例那样。
六、请求队列与blk-mq
传统request_queue vs blk-mq
| 维度 | 传统request_queue | blk-mq |
|---|---|---|
| 队列模型 | 单队列,全局锁 | 多硬件队列,每队列独立锁 |
| 请求映射 | 请求→单硬件队列 | 请求→软件队列→硬件队列 |
| 标签管理 | tag_set管理请求标签 | |
| 并行 | 受全局锁限制 | 硬件队列数×队列深度 |
| 6.8状态 | 已废弃 | 唯一推荐方式 |
传统单队列模型在高并发场景下全局锁是瓶颈,NVMe SSD动辄几十万IOPS,单队列根本扛不住。blk-mq把队列拆成多个硬件队列,每个队列独立锁,并行度大幅提升。6.8内核中所有块设备都必须走blk-mq路径。
blk_mq_tag_set结构建
// linux/blk-mq.h(简化)
struct blk_mq_tag_set {
const struct blk_mq_ops *ops; // 操作回调试 unsigned int nr_hw_queues; // 硬件队列。 unsigned int queue_depth; // 每个队列深度
int numa_node; // NUMA节点
unsigned int cmd_size; // 驱动私有数据大小
unsigned int flags; // 标志。 void *driver_data; // 驱动私有数据
// ... 内部字段
};
关键字段说明。
- nr_hw_queues:硬件队列数,RAM磁盘这种虚拟设备1就行,NVMe驱动通常设为CPU核心。- queue_depth:每个硬件队列能同时处理的请求数,默认128,高性能设备可以设到1024
- cmd_size:每个请求附带的驱动私有数据大小,不需要就。
- flags:BLK_MQ_F_SHOULD_MERGE允许请求合并,BLK_MQ_F_BLOCKING允许queue_rq里睡。
blk_mq_ops回调试
// linux/blk-mq.h(简化)
struct blk_mq_ops {
blk_status_t (*queue_rq)(struct blk_mq_hw_ctx *hctx,
const struct blk_mq_queue_data *bd);
int (*init_hctx)(struct blk_mq_hw_ctx *hctx, void *data,
unsigned int index);
void (*exit_hctx)(struct blk_mq_hw_ctx *hctx, unsigned int index);
int (*init_request)(struct blk_mq_tag_set *set, struct request *rq,
unsigned int hctx_idx, unsigned int numa_node);
void (*cleanup_rq)(struct request *rq);
// ... 更多可选回调};
必须实现的是queue_rq,其余按需实现。
| 回调 | 用途 | 是否必须 |
|:-----|:-----|:---------|
| queue_rq | 处理I/O请求 | 必须 |
| init_hctx | 初始化硬件队列上下文 | 推荐 |
| exit_hctx | 清理硬件队列上下文 | 可 |
| init_request | 初始化请求私有数据 | 可 |
| cleanup_rq | 清理请求 | 可 |
多硬件队列并。
blk-mq的并行模型:每个CPU有独立的软件队列(无锁),软件队列通过映射关系把请求分发到硬件队列。硬件队列之间完全独立,各自有自旋锁,互不干扰。NVMe驱动把硬件队列和CPU一一绑定,实现真正的无锁并行。
七、调试技。
lsblk/blkid/fdisk查看块设。
# 列出所有块设备的树形视图
lsblk
# 查看设备UUID和文件系统类blkid /dev/myram
# 查看分区
fdisk -l /dev/myram
# 查看块设备详细信息cat /proc/partitions
lsblk最常用,一眼看清设备层次和分区关系。blkid能看到文件系统类型和UUID,挂载时要用。fdisk -l看分区表,新设备如果没分区会显示"Doesn't contain a valid partition table"。
/sys/block/查看队列参数
# 查看块设备队列参数ls /sys/block/myram/queue/
# 查看调度器
cat /sys/block/myram/queue/scheduler
# 查看队列深度
cat /sys/block/myram/queue/nr_requests
# 查看最大扇区数
cat /sys/block/myram/queue/max_hw_sectors_kb
# 查看逻辑块大小
cat /sys/block/myram/queue/logical_block_size
# 查看物理块大小
cat /sys/block/myram/queue/physical_block_size
sysfs里的队列参数是blk-mq驱动的窗口,能看到调度器、队列深度、块大小等配置。如果性能不正常,先看这里的参数是否合理。
blktrace:块层跟。
# 安装blktrace
# Ubuntu/Debian: apt install blktrace
# CentOS/RHEL: yum install blktrace
# 跟踪块设备的所有I/O事件
blktrace -d /dev/myram -o - | blkparse -i -
# 只跟。。timeout 5 blktrace -d /dev/myram -o - | blkparse -i -
# 输出到文件后分析
blktrace -d /dev/myram -o myram_trace
blkparse -i myram_trace -o myram_parsed.txt
blktrace能看到I/O从提交到完成的每个阶段:
| 事件 | 含义 |
|---|---|
Q |
请求排队 |
G |
获取请求 |
I |
插入调度队列 |
D |
发给驱动 |
C |
驱动完成 |
M |
请求合并 |
从Q到C的时间差就是一次I/O的端到端延迟。如果D到C之间间隔长,说明驱动处理慢;如果Q到D间隔长,说明调度器在攒请求。
fio:块设备性能测试
# 安装fio
# Ubuntu/Debian: apt install fio
# CentOS/RHEL: yum install fio
# 顺序读测试
fio --name=seqread --filename=/dev/myram \
--rw=read --bs=4k --size=1M --numjobs=1 --time_based --runtime=10
# 随机写测试
fio --name=randwrite --filename=/dev/myram \
--rw=randwrite --bs=4k --size=1M --numjobs=4 --time_based --runtime=10 \
--iodepth=32
# 混合读写测试
fio --name=mixed --filename=/dev/myram \
--rw=randrw --rwmixread=70 --bs=4k --size=1M \
--numjobs=2 --time_based --runtime=10 --iodepth=16
fio参数说明。
| 参数 | 含义 |
|:-----|:-----|
| --rw=read/write/randread/randwrite/randrw | 读写模式 |
| --bs=4k | 块大 |
| --iodepth=32 | I/O深度,模拟并发请求数 |
| --numjobs=4 | 并发线程 |
| --runtime=10 | 测试时长(秒 |
测试RAM磁盘时注意:数据全在内存里,IOPS会非常高,不要拿这个数据跟真实SSD比。fio测真实硬件才有参考价值
八、常见问题
Q1:add_disk后看不到设备。
lsblk和/dev/下都找不到设备,最常见的原因:
- 主设备号冲突:用
major=0让内核动态分配,避免和已有设备冲。2. *fops没设:gendisk->fops必须指向有效的block_device_operations,哪怕只实现open/release - *add_disk返回值没检。.8中
add_disk会返回错误码,老版本返回void,升级代码时容易忽略 - 容量:
set_capacity没调用或参数错误,内核不会注册容量的磁盘。
Q2:bio数据处理越界。
读写操作返回BLK_STS_IOERR或数据错乱,排查方向。
1. sector偏移计算:sector << SECTOR_SHIFT才是字节偏移,别直接用sector当偏。2. bv_offset没加:kmap_local_page后要加bvec.bv_offset,不是从页起始位置读
3. 大小没对*:块设备操作必须512字节对齐,blk_queue_logical_block_size设对
4. 越界检*:offset + remaining > MY_DISK_SIZE必须检查,否则内核可能panic
Q3:blk-mq性能不如预期。
队列参数没调对,几个调整方向。
1. nr_hw_queues:虚拟设备设1就行,真实NVMe设为CPU核心。2. queue_depth:默认128可能不够,高性能SSD可以。56。12
3. BLK_MQ_F_SHOULD_MERGE:没加这个flag请求不会合并,小IO性能。4. *调度:cat /sys/block/xxx/queue/scheduler看当前调度器,NVMe用none,SATA用mq-deadline
九、总结
块设备驱动开发速查看
| 操作 | API | 注意事项 |
|---|---|---|
| 分配gendisk | blk_alloc_disk(NULL, NUMA_NO_NODE) |
6.8推荐,替代alloc_disk |
| 设置操作集 | disk->fops = &my_blk_fops |
必须实现open/release |
| 初始化blk-mq | blk_mq_alloc_tag_set(&tag_set) |
先设置ops/depth/hw_queues |
| 创建队列 | blk_mq_init_queue(&tag_set) |
返回后设置queuedata |
| 设置块大 | blk_queue_logical_block_size(q, 512) |
必须和硬件匹 |
| 设置容量 | set_capacity(disk, num_sectors) |
单位是扇区,不是字节 |
| 注册磁盘 | add_disk(disk) |
之后设备对用户空间可 |
| 删除磁盘 | del_gendisk(disk) |
卸载时调试 |
| 清理队列 | blk_cleanup_queue(q) |
del_gendisk之后调用 |
| 释放标签 | blk_mq_free_tag_set(&tag_set) |
cleanup_queue之后调用 |
| 释放gendisk | put_disk(disk) |
最后调试 |
| 遍历请求 | rq_for_each_segment(bvec, rq, iter) |
配合kmap_local_page |
| 完成请求 | blk_mq_end_request(rq, BLK_STS_OK) |
成功OK,失败IOERR |
| 获取扇区 | blk_rq_pos(rq) |
返回sector_t |
| 获取字节 | blk_rq_bytes(rq) |
返回unsigned int |
| 判断操作类型 | req_op(rq) == REQ_OP_READ |
READ/WRITE/FLUSH |
本文首发于linuxros.cn,转载请注明出处。