块 I/O 层
嵌入式学习Day4
1.块设备
系统中能够随机(不需要按顺序)访问固定大小的数据片,称之为块设备 常见的块设备有:
- 硬盘
- 软盘驱动器
- 蓝光光驱
- 闪存
- 块设备在硬件层面的最小寻址和传输单位是扇区。扇区的大小必须是2的幂次方,传统上最常见的扇区大小为512字节,而现代硬盘的物理扇区大小通常已经发展为4KB。
- 内核与文件系统进行数据交互的最小逻辑寻址单位为块,它是对底层硬件的软件抽象。块的大小必须是物理扇区大小的2的幂次方倍,并且不能超过系统内存页的大小。常见的块大小一般为512字节、1KB或4KB。
内核不直接用硬件的“扇区”作为最小单位?
-
屏蔽底层硬件差异(统一标准): 市面上有各种各样的存储设备,有的老硬盘扇区是 512 字节,有的新硬盘是 4KB,还有 U 盘、SD 卡等。如果内核直接按扇区操作,那文件系统就得为每一种硬件写一套代码。 内核通过引入“块”这个逻辑概念,相当于制定了一个统一的标准。文件系统只需要说:“我要读第 100 号块”,底层的驱动程序再去负责把“第 100 号块”翻译成“第 x 到第 y 号物理扇区”。这就实现了解耦。
-
提升读写效率: 如果用户读 1 个字节,内核就去硬盘读 1 个字节,硬盘的机械臂或者芯片会被频繁唤醒,效率极低(也就是常说的 I/O 开销太大)。以“块”(比如 4KB)为单位,内核一次性把一整块数据读进内存中缓存起来。下次用户如果正好要读这 1 个字节后面的数据,内核直接从内存里给它就行了,不用再找硬盘,极大提升了效率。
2.缓冲区和缓冲头
2.1为什么会有“缓冲区”
内核如果读硬盘中的数据时,都需要访问硬盘就会很慢,所以内核第一次访问硬盘时就会将读取的块数据保存到内存中,下次如果还要访问同一个块,就先看这个缓冲区里有没有。
2.2什么是缓冲头 buffer_head
光有一块内存还不够,内核还得知道:
- 这块内存对应哪个设备
- 对应哪个逻辑块号
- 数据是否有效
- 是否被修改过
- 是否正在做 I/O
- 是否锁住了 所以内核用一个结构体来描述这块缓冲区,这就是 buffer_head。
2.3buffer_head结构体
2.3.1b_state —— 状态位,最核心
它记录这个缓冲区当前的状态,比如常见的有:
- 这块数据是否有效(uptodate)
- 是否是脏的(dirty)
- 是否被锁住(lock)
- 是否正在做 I/O
- 是否已经映射到实际磁盘块
2.3.2b_blocknr —— 对应的块号
sector_t b_blocknr;
这个字段表示:这个 buffer_head 描述的是块设备上的哪一个块。
2.3.3b_bdev —— 属于哪个块设备
struct block_device *b_bdev;
光有块号还不够,因为不同设备上都可能有第 100 号块。
2.3.4b_size —— 这块缓冲区有多大
size_t b_size;
2.3.5b_data —— 真正的数据指针
char *b_data;
它指向缓冲区里真正存放块数据的位置。
2.3.5b_this_page —— 同一页中的 buffer 串起来
struct buffer_head *b_this_page;
把同一个 page 里的多个 buffer_head 串成一个链。
- page 大小 4KB
- 每块 1KB
- 一个页里 4 个 buffer 那么这 4 个 buffer_head 会通过 b_this_page 连起来。
2.3.6b_count —— 引用计数
atomic_t b_count;
3.BIO
可以。bio 是现代 Linux 块层里比 buffer_head 更重要的一个概念。
如果说 buffer_head 更像“描述一个块”,那 bio 更像“描述一次 I/O 请求”。
3.1bio解决的是什么问题
块设备 I/O 不再只是“单块单块”做。 现代内核更希望:
- 一次提交更大的 I/O
- 把多个内存片段拼起来一起发
- 提高吞吐
- 减少请求数量
- 更方便和块层调度、驱动对接
所以 bio 的目标就是: 把一次 I/O 需要的内存片段和设备位置信息组织起来,交给块层往下送。
3.2bio中重要的结构体
3.2.1 I/O 目标
bi_bdev——对哪个块设备做 I/O
struct block_device *bi_bdev;
bi_sector—— 从磁盘哪个扇区开始
sector_t bi_sector;
3.2.2 I/O 性质
bi_rw —— 这是读还是写
bi_flags —— 状态和控制标志
- 是否完成/是否需要特殊处理/是否克隆来的/是否属于某类特殊 I/O
3.2.3 描述内存数据
bi_io_vec —— 最重要的数据描述字段
struct bio_vec *bi_io_vec;
它指向一组 bio_vec,而每个 bio_vec 描述一个内存片段。
bio_vec 可以简单理解为:
- 一个页
page - 页内偏移
offset - 长度
len
这就是 bio 为什么适合现代块层:
- 它可以一次描述多个内存段
- 不必要求所有数据都在一块连续内存里
bi_vcnt —— 有多少个 bio_vec
unsigned short bi_vcnt;
bi_idx —— 当前处理到第几个段
unsigned short bi_idx;
当前这个 bio 已经处理到 bio_vec 数组里的第几个元素。
3.2.4 描述总长度/分段情况
bi_size —— 这次 I/O 还剩多少数据
unsigned int bi_size;
这个字段通常表示:
- 这个 bio 当前还剩多少字节需要处理。
- 它和 bi_sector、bi_idx 一样,都是随着 I/O 推进可能变化的量。
bi_end_io —— I/O 完成回调
bio_end_io_t *bi_end_io;
这个 bio 完成之后,调用哪个函数做收尾。
bi_private —— 上下文私有数据
void *bi_private;
这个字段通常用来挂: 拥有这个 bio 的上层私有上下文。 因为回调 bi_end_io 被调用时,往往还需要知道:
- 这是哪个子系统发的
- 对应哪个 page / inode / 请求上下文
- 完成后要通知谁
这时就把一些私有信息塞进 bi_private。
4. request和bio、 bio_vec、page之间的关系
page 是数据真正待在内存里的地方,bio_vec 是对某一段 page 的描述,bio 是把多个 bio_vec 组织成“一次 I/O”,request 是块层把一个或多个 bio 再整理后交给驱动的“设备请求”。
5.I/O 调度程序
I/O 调度程序(I/O scheduler),也叫块 I/O 调度器,是在块层里专门负责“怎么把一堆 I/O 请求排队、合并、重排,再交给设备”的那一层。
I/O 调度程序的任务,不是产生 I/O,而是决定“已有的 I/O 请求按什么顺序、以什么方式发给设备”,从而兼顾吞吐量、延迟和公平性。
5.1为什么需要 I/O 调度
假设系统里同时有很多 I/O 请求:
- 进程 A 在顺序读大文件
- 进程 B 在随机写日志
- 进程 C 在读数据库索引 系统线程在回写脏页
这些请求如果“谁先来就谁先发”,通常效果不好,因为块设备有自己的物理特性。 尤其是机械硬盘:
- 磁头移动有开销
- 相邻扇区一起读写更高效
- 随机 I/O 很慢
所以 I/O 调度程序需要做几件事: 合并相邻请求/调整顺序,减少磁头来回跳/避免某个进程长期饿死/让交互型 I/O 不要延迟太大
5.2I/O 调度程序处于读取内存的哪一个部分
page -> bio_vec -> bio -> request -> I/O scheduler -> driver -> device
- 上层把 I/O 组织成 bio
- 块层把 bio 变成 request
- request 进入请求队列
- I/O scheduler 在队列里做:
- 插入
- 合并
- 排序
- 选择下一个要发的请求 然后驱动把请求交给设备 所以 I/O 调度器主要面对的是:request queue 里的 request
5.3“电梯算法”是什么
讲 I/O 调度,常会提到电梯算法(elevator algorithm)。
它的想法很像电梯:
-
磁头沿一个方向扫
-
把沿路请求尽量处理掉
-
到另一端再返回
这样比“每次都跳到最近到达的请求”更高效、更平稳。
这类思想的目标是:
- 减少磁头大幅来回移动
- 提高总体吞吐量
- 所以早期很多 I/O 调度器本质上都带有“电梯”味道。
5.4常见 I/O 调度器思想
5.4.1NOOP
- 简单 FIFO
- 简单合并
5.4.2Deadline
- 平时尽量保持顺序性,提高吞吐
- 但如果某个请求等太久了,就优先处理,防止饿死
5.4.3CFQ(Complete Fair Queuing)
- 给不同进程/线程维护各自队列
- 轮流分配时间片或服务机会
- 避免某个进程独占 I/O