
接手这套图像采集中间件的时候传感器板卡还在车间打样算法组的同事却已经催着要原始图像流来调模型了。采集线程的代码早就按块设备读写的思路写好了每个相机对应一个/dev/节点上层只调open、read、ioctl根本不关心底层是什么介质。硬件拿不到整条链路又不能空等我花了两天时间把图像数据源做成了一个虚拟块设备让上层软件像读磁盘一样从/dev/imgblk上源源不断地拿到模拟图像帧。这篇文章就把整个实现思路、核心代码和踩过的坑完整记录下来正在做机器视觉平台、无传感器环境联调或者想深入理解块设备 I/O 栈的开发者可以直接参考。1. 虚拟图像块设备解决的现实问题硬件未到、链路先通1.1 典型的项目困境我第一次遇到这个需求是在一个多路相机采集项目里。系统设计很简单每路相机一个采集线程线程从设备节点读原始帧然后交给内存池、算子库、推流模块。问题在于相机模组和采集板卡是定制件周期最长而软件团队的联调计划排得比硬件到货早了两个星期。没有硬件的时候最常见的临时方案是读取预录的 RAW 文件再从文件里把帧喂给上层。这个方案有两个难言之隐一是文件系统路径和块设备路径在代码里往往不是同一套为了模拟需要把读取逻辑重写一遍联调完再改回来等于多维护一份代码二是文件 I/O 的性能特征和块设备相去甚远做并发压力测试的时候结果完全不可信。1.2 块设备封装的收益边界把图像数据源伪装成块设备本质上是在数据生产者和消费者之间加了一个标准的存储接口。上层应用不需要知道数据是来自真实传感器、预录文件还是动态算法生成它面对的就是一个带容量的设备读某个扇区拿到数据。这个思路能解决的问题包括采集驱动和上层算法的独立联调两边可以并行推进用dd、fio、iostat这些现成工具做吞吐和延迟摸底测试结果和真实块设备场景可比可以模拟坏扇区、随机 I/O 错误、掉帧等异常提前验证容错逻辑。也要说清楚边界虚拟块设备模拟的是数据通道不是传感器本身。它模拟不了 I2C 寄存器配置、曝光时序、触发同步这些硬件行为。如果你的联调目标是验证驱动对硬件的控制逻辑那还是得等真板子或者用硬件在环的方案。但如果目标是验证数据能否稳定从设备送到算法手里虚拟块设备是性价比极高的替代品。2. 三种实现路线内核块驱动、Device Mapper、用户态映射2.1 路线对比表先说结论可行方案不止一个但适合模拟图像数据源这个场景的我最后选了内核 blk-mq 驱动。为了让你少走弯路先把几条路线的差别摆出来。路线实现难度数据生成灵活性性能适用场景内核 blk-mq 块驱动高极高每个扇区读写都能自定义接近真实设备精模仿真、错误注入、压力测试Device Mapper target中一般现有 target 无法自定义图案高快速验证容量、分区行为NBD 网络块设备中取决于用户态服务实现中有网络开销多机共享、远程模拟FUSE 文件系统回环低数据在用户态生成灵活低上下文切换严重追求开发速度、性能要求不高Device Mapper 的zerotarget 能创建一块读出来全是零的块设备errortarget 能模拟 I/O 错误但都无法按图像内容逐扇区生成数据。NBD 倒是可以在用户态服务里写图像生成逻辑但每次 I/O 都要经过网络协议栈按扇区伪造时序细节比较费劲。FUSE 就更不划算了它在用户态文件系统场景下表现不错当块设备用延迟会高到让人怀疑人生。2.2 为什么我最终选择内核 blk-mq 驱动选内核驱动首先是性能。我们后续要拿这个模拟设备做 8 路并发采集测试每路 25fps、每帧 768KB合计吞吐大约 150MB/s。这个量级用 FUSE 基本达不到NBD 也要看网络环境只有内核态直接生成数据最稳。其次是数据生成的精确控制。图像帧的每个字节在磁盘上的偏移是确定的内核驱动在处理READ请求时可以根据请求的起始扇区号算出对应的是哪一帧的哪个像素从而生成渐变图、彩条图、棋盘格、随机噪声等任意图案。这个能力对验证算法特别重要你读出来的数据如果不合预期能立刻判断是数据源问题还是上层算法问题。当然代价也很直观内核开发调试效率低一个写错的指针就可能让整个系统重启。我自己的习惯是先在虚拟机里把驱动打磨稳定再搬到实验室环境跑长时间压力测试。这个工作方式后面还会反复提到。3. 最小可用的 blk-mq 块设备驱动实现3.1 驱动框架与关键数据结构从零写一个块设备驱动绕不开几个核心对象gendisk代表一个磁盘设备request_queue是 I/O 请求排队的地方blk_mq_tag_set负责管理多队列请求标签。blk-mq 框架下你的主要任务就是实现一个queue_rq回调内核把读写请求通过这个回调交给驱动处理。下面是一个精简但完整的驱动框架基于 Linux 5.15 到 6.x 的目标 API我用一个名为imgblk的虚拟设备做演示容量设计为 16 帧 1024x768 灰度图。#include linux/module.h #include linux/kernel.h #include linux/blkdev.h #include linux/blk-mq.h #include linux/hdreg.h #include linux/init.h #include linux/vmalloc.h #define IMG_SECTOR_SIZE 512 #define IMG_WIDTH 1024 #define IMG_HEIGHT 768 #define IMG_FRAME_BYTES (IMG_WIDTH * IMG_HEIGHT) #define IMG_FRAME_SECTORS (IMG_FRAME_BYTES / IMG_SECTOR_SIZE) #define IMG_FRAMES 16 #define IMG_CAPACITY (IMG_FRAME_SECTORS * IMG_FRAMES) struct imgblk_dev { sector_t capacity; struct blk_mq_tag_set tag_set; struct request_queue *queue; struct gendisk *disk; }; static struct imgblk_dev *imgblk_dev;这里有个非常重要的点IMG_CAPACITY的单位是扇区数不是字节数。我们一帧 786432 字节正好是 512 的整数倍所以不需要处理跨帧边界这个对齐设计在第 4 章会详细展开。如果图像尺寸不能被 512 整除块设备驱动处理起来会非常痛苦因为一个扇区可能横跨两帧的内容。3.2 请求处理把扇区读写映射为像素填充queue_rq回调的核心逻辑不复杂收到READ请求就遍历请求里的每个bio_vec段根据段的扇区位置生成图像数据并填到对应 page收到WRITE请求因为是模拟只读数据源直接丢弃并返回成功。static void img_generate(void *buf, sector_t sector, unsigned int len) { u8 *dst buf; u8 *end buf len; loff_t disk_off (loff_t)sector 9; loff_t pos; for (pos disk_off; dst end; pos, dst) { loff_t frame_no pos / IMG_FRAME_BYTES; loff_t in_frame pos % IMG_FRAME_BYTES; int y in_frame / IMG_WIDTH; int x in_frame % IMG_WIDTH; /* 竖向灰度渐变直接从像素位置生成数据 */ *dst (u8)(y * 255 / (IMG_HEIGHT - 1)); /* 用 x 做些修饰方便肉眼确认扇区边界 */ if ((x / 128) % 2 0) *dst *dst 128 ? 64 : 192; } } static blk_status_t img_queue_rq(struct blk_mq_hw_ctx *hctx, const struct blk_mq_queue_data *bd) { struct request *rq bd-rq; struct bio_vec bvec; struct req_iterator iter; sector_t sector blk_rq_pos(rq); blk_status_t sts BLK_STS_OK; blk_mq_start_request(rq); if (rq_data_dir(rq) READ) { rq_for_each_segment(bvec, rq, iter) { void *kaddr; unsigned int len bvec.bv_len; kaddr kmap_local_page(bvec.bv_page); img_generate(kaddr bvec.bv_offset, sector, len); kunmap_local(kaddr); sector len 9; } } /* WRITE 请求直接丢弃这里不做任何存储 */ blk_mq_end_request(rq, sts); return BLK_STS_OK; } static const struct blk_mq_ops img_mq_ops { .queue_rq img_queue_rq, };这段代码有几个细节值得说。kmap_local_page是短时映射高内存页的安全方式不能和旧的kmap混用rq_for_each_segment遍历的是一个请求中所有 bio 段真实世界的块设备请求可能是多段、多 bio 的简单起见这里假设段间扇区连续生产环境建议逐 bio 取bi_sector更严谨。blk_mq_start_request必须最先调用否则请求状态管理会出问题。3.3 注册与卸载代码有了queue_rq剩下就是标准流程分配 tag set、初始化队列、分配 gendisk、设置容量、添加到系统。卸载时反向释放。static int img_alloc_disk(struct imgblk_dev *dev) { struct gendisk *disk; memset(dev-tag_set, 0, sizeof(dev-tag_set)); dev-tag_set.ops img_mq_ops; dev-tag_set.nr_hw_queues 1; dev-tag_set.queue_depth 64; dev-tag_set.numa_node NUMA_NO_NODE; dev-tag_set.flags BLK_MQ_F_SHOULD_MERGE; dev-tag_set.driver_data dev; if (blk_mq_alloc_tag_set(dev-tag_set)) return -ENOMEM; dev-queue blk_mq_init_queue(dev-tag_set); if (IS_ERR(dev-queue)) { blk_mq_free_tag_set(dev-tag_set); return PTR_ERR(dev-queue); } blk_queue_logical_block_size(dev-queue, IMG_SECTOR_SIZE); disk blk_alloc_disk(dev-queue, 0); if (!disk) { blk_cleanup_queue(dev-queue); blk_mq_free_tag_set(dev-tag_set); return -ENOMEM; } disk-first_minor 0; disk-minors 1; snprintf(disk-disk_name, DISK_NAME_LEN, imgblk); set_capacity(disk, dev-capacity); dev-disk disk; return 0; } static int __init img_init(void) { int ret; imgblk_dev kzalloc(sizeof(*imgblk_dev), GFP_KERNEL); if (!imgblk_dev) return -ENOMEM; imgblk_dev-capacity IMG_CAPACITY; ret img_alloc_disk(imgblk_dev); if (ret) goto out_free; ret add_disk(imgblk_dev-disk); if (ret) goto out_cleanup; pr_info(imgblk: capacity %lld sectors (%lld bytes)\n, imgblk_dev-capacity, imgblk_dev-capacity 9); return 0; out_cleanup: put_disk(imgblk_dev-disk); blk_cleanup_queue(imgblk_dev-queue); blk_mq_free_tag_set(imgblk_dev-tag_set); out_free: kfree(imgblk_dev); return ret; } static void __exit img_exit(void) { if (!imgblk_dev) return; del_gendisk(imgblk_dev-disk); put_disk(imgblk_dev-disk); blk_cleanup_queue(imgblk_dev-queue); blk_mq_free_tag_set(imgblk_dev-tag_set); kfree(imgblk_dev); pr_info(imgblk: removed\n); } module_init(img_init); module_exit(img_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Virtual Block Image Source Demo); MODULE_DESCRIPTION(Simulate image data source as a virtual block device);注册流程里的add_disk之后设备节点就会出现在/dev/下主设备号由内核自动分配不需要手动指定register_blkdev这是新内核推荐的做法。容量设置用的是set_capacity(disk, sectors)传字节数进去是常见错误后面会在踩坑部分专门讲。4. 图像帧到扇区的布局设计与数据生成策略4.1 帧对齐与寻址计算虚拟块设备模拟图像数据源最核心的布局问题就是一张图怎么放进一串扇区。我的做法是让帧长严格对齐到 512 的整数倍。以 1024x768 灰度图为例帧字节数 1024 × 768 786432 帧扇区数 786432 ÷ 512 1536 磁盘总扇区数 1536 × 16 24576于是第 n 帧的起始扇区是n * 1536扇区 s 对应的磁盘字节偏移是s * 512再对786432取模就能得到帧内偏移。由于一帧的字节数正好是 512 的整数倍扇区永远不会跨帧每个扇区的内容都完整属于某一帧。这个对齐还有一个额外好处如果你想模拟按帧读取上层只需要按固定大小read1536 个扇区就能获得完整一帧不需要处理拼接逻辑。很多真实传感器的输出也是整帧整块地交给主控所以这个模拟方式在行为上与硬件接近。4.2 测试图案生成让输出肉眼可验模拟数据源最怕数据看起来正常实际却错位了。为了快速排查问题我设计的生成函数不只是随机噪声而是带有方向性的渐变图案。具体逻辑是竖向渐变像素值y * 255 / 767y 是行号。这样从上到下是黑到白的自然过渡如果读出数据的行顺序错了用工具转成图片后立刻能看出来。横向条带偶数x / 128块反相让每一行产生有规律的明暗交替。如果扇区边界错位、块顺序调换条纹的连续性会被明显打断。生成的原始数据可以直接用 ImageMagick 或 FFmpeg 转成 PNG 检查。这个视觉验证手段在调试初期比任何日志都管用一次dd读取再转图数据源对不对一目了然。如果说有什么要优化的那就是img_generate里逐字节做除法和取模性能太差。实测下来一个 4KB 的读请求要执行 4096 次 64 位除法在慢一点的开发机上吞吐会掉到几十 MB/s 以下。工程化的做法是改成分行填充每行 1024 字节行内用memset和少量乘法先算出行号再填充整行性能能提升一个数量级。演示代码保持逐字节是为了逻辑直白真上线模拟器建议按行改写。5. 实测从 insmod 到图像还原5.1 模块编译、加载与设备节点检查驱动放到内核源码树外编译需要准备一个 Makefile我习惯用内核源码目录直接编obj-m : imgblk.o KDIR : /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean加载步骤sudo insmod imgblk.ko lsblk | grep imgblk fdisk -l /dev/imgblk正常输出应该能看到一个 12MB 的磁盘设备24576 扇区 × 512 字节 12582912 字节。lsblk的大小如果对不上优先检查set_capacity里传的值是不是扇区数。5.2 用 dd 和标准工具验证数据正确性读取第 3 帧完整数据dd if/dev/imgblk of/tmp/frame3.raw bs512 count1536 skip$((2 * 1536))然后转成 PNGffmpeg -f rawvideo -pix_fmt gray -s 1024x768 -i /tmp/frame3.raw /tmp/frame3.png打开图片应该能看到从黑到白的竖向渐变且带有横向方形纹理。如果图片出现错位、分裂、重复说明布局计算或者请求处理里的扇区累加有 bug。我第一次运行时就因为sector len 9写成了sector len / 512而在这里翻车图片被横向切开排查了半小时。写入测试也不可少dd if/dev/zero of/dev/imgblk bs4096 count16因为写请求被直接丢弃dd会正常返回成功但不会产生任何效果。这个行为符合预期也顺便验证了写路径不会导致内核崩溃。5.3 性能表现与调优方向用dd做大块顺序读观察到的吞吐大约在 130MB/s 左右虚拟机环境数据仅供参考。这个数字说明逐字节除法版本的性能瓶颈很明显换成分行填充之后同一台虚拟机可以跑到 320MB/s 以上。blk-mq 的队列深度对性能影响也很大。tag_set.queue_depth设成 64 时并发读的吞吐比 16 时高出约 40%但请求延迟也略微增加。模拟真实传感器流时建议把队列深度调小一些因为真实传感器往往是单线程轮询读取队列太深反而会让调度器做更多合并工作偏离实际行为。要观察设备在系统层面的表现可以用iostat -x 1看imgblk的r/s、wkB/s、await。注意虚拟块设备没有真正的硬件中断await代表的是内核请求处理到完成的时间评估重点应该是吞吐和并发稳定性而不是延迟绝对值。6. 遇到的坑和后续扩展思路6.1 调试中容易翻车的三个细节从头到尾调这个驱动我主要踩了三类坑每一类都值得单独记录。第一个坑是capacity单位错误。第一次加载模块后lsblk显示设备只有 16KB数据完全没法用。查了代码发现set_capacity被我传成了字节数内核 API 要求的是扇区数。这个错误不报任何警告只会在容量上表现得非常反常。排查方法很简单算一算期望容量除以 512 再对照lsblk。第二个坑是queue_rq里用了可能睡眠的函数。最初的版本里我在请求处理路径调用了printk和msleep模拟处理延迟结果在内核日志里看到scheduling while atomic的 BUG 信息。blk-mq 的queue_rq运行在原子上下文里不能睡眠、不能获取普通信号量。要模拟延迟应该用工作队列把实际数据处理派发出去而不是直接在回调里睡。第三个坑是请求结束处理不一致。有一版代码我在READ路径里正确调用了blk_mq_end_request但WRITE路径忘调了结果系统在多次写操作后出现请求卡死块设备层超时最后虚拟机直接卡住。每一个从queue_rq返回的分支都必须保证调用了blk_mq_end_request否则请求就像借了钱不还迟早出大问题。6.2 从模拟到真机的扩展路径这个虚拟块设备跑通之后我顺着它往外延伸了几个方向都很有实用价值。方向一是把图案生成逻辑改成可配置。通过 sysfs 暴露几个属性比如pattern、frame_index、error_inject就能在不重新编译模块的情况下切换灰度渐变、彩条、棋盘格、随机噪声或者让某些扇区固定返回BLK_STS_IOERR。做容错测试的时候让设备随机掉几帧观察算法端会不会花屏比手工造异常数据方便太多。方向二是注册多个设备节点模拟多路相机。每个设备实例拥有独立的帧计数器上层开多线程分别读取再汇总到推流模块。我在测试机上开过 8 路并行读总吞吐约 1.2GB/s验证了采集服务的并发调度逻辑没有问题。这个能力在真实硬件上反而不容易测因为硬件通道数量有限。方向三是把数据源从生成换成重放。算法组给了一批标注过的实拍 RAW 文件我把文件按帧切好映射成只读块设备这样上层在读磁盘的时候实际上是在读一份带标准答案的数据集。对算法验证来说这比纯合成图案更有说服力也免去了写文件解析代码的额外工作。我这里用的案例都是自己实验室里做过的事方法本身有很强的普适性。块设备模拟图像数据源说到底就是一件事把存储接口当作数据通道的通用语言让硬件缺席的时候软件照样能练兵。个人体会是这类调试工具一开始看起来像是绕远路但当你需要快速验证并发、异常、性能这些真实问题时它比任何临时的 mock 方案都靠得住。