ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

故意改错Linux内核QEMU图形驱动:四个实验看透VGA显示链路

故意改错Linux内核QEMU图形驱动:四个实验看透VGA显示链路 平时我们写内核驱动都怕把代码改坏。这次我反着来故意在Linux内核的QEMU图形驱动里埋了几处代码改动然后逐个观察VGA像素显示会怎么崩、怎么花、怎么变色。这个实验是我自己调试虚拟显卡驱动时常用的方法与其拿着文档猜寄存器含义不如先改错一处再看画面表现反而能把整条显示链路吃透。这篇文章面向的是想研究Linux内核DRM/KMS框架、嵌入式虚拟化图形栈或者对QEMU设备模拟感兴趣的读者。我会用QEMU默认的标准VGA设备Linux内核里对应的是bochs-drm驱动做实验对象从环境搭建、显示链路原理、四次故意改错到最后的翻车排查一次性讲完方便你直接在自己的机器上复现。1. 实验定位与环境准备1.1 为什么拿QEMU的VGA驱动下手Linux内核里图形驱动有不少NVIDIA、AMD的闭源驱动太复杂i915又依赖具体硬件都不适合拿来练手。QEMU的标准VGA是个特殊存在它是一个纯虚拟设备不需要真实显卡Linux内核专门用bochs-drm驱动去适配它。这个驱动代码量不大文件数量也就四五个依赖的寄存器接口非常标准很适合做“故意改错”的实验。而且QEMU的VGA设备在用户态层面就是一个普通PCI设备通过标准VBE接口与驱动握手。你在驱动代码里改一个寄存器写入值马上就能反映到虚拟机屏幕上反馈链路短现象直接。对比真实显卡驱动改错一个时序参数往往要重启整机才知道结果而QEMU这边改完驱动重新加载模块几秒就能看到像素变化。我还特意选了一个中等配置的虚拟显卡环境2GB内存、4核CPU、默认的-vga std这个配置跑起来不卡又能完整呈现VBE模式下的像素显示行为。如果你用更低端的配置某些时序异常可能直接被模拟器吞掉反而观察不到细节。1.2 从零搭一个可控的图形驱动实验环境整个实验的核心是编译自定义内核所以先把QEMU和内核源码准备好。我用的内核版本是Linux 6.6 LTS如果你手头版本不同文件名和函数位置可能有出入但整体思路完全一样。# 准备工作 sudo apt install build-essential flex bison libncurses-dev libssl-dev qemu-system-x86 wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.6.y.tar.xz tar xf linux-6.6.y.tar.xz cd linux-6.6.y配置内核时重点是确保DRM和BOCHS驱动被编译成模块方便我们改完代码后单独重新编译.ko文件而不是每次全量重建整个内核。make ARCHx86_64 defconfig scripts/config --module CONFIG_DRM scripts/config --module CONFIG_DRM_BOCHS scripts/config --enable CONFIG_FB scripts/config --enable CONFIG_FRAMEBUFFER_CONSOLE make ARCHx86_64 olddefconfig make ARCHx86_64 -j$(nproc) bzImage modules启动虚拟机时我在QEMU参数里故意同时开启了串口和VGA framebuffer控制台。串口是最后一道保命通道即使图形画面完全花了还能通过串口登录进去排查问题。qemu-system-x86_64 \ -machine q35,accelkvm \ -m 2048 -smp 4 \ -kernel linux-6.6.y/arch/x86/boot/bzImage \ -initrd initramfs.cpio.gz \ -append consolettyS0,115200 consoletty0 vga0x343 drm.debug0x1f loglevel7 \ -vga std \ -display sdl,gloff \ -serial mon:stdio \ -monitor unix:/tmp/qemu.sock,server,nowait这里vga0x343代表VBE模式下的1024x768x32consoletty0让内核的tty文本直接显示到VGA画面上。这样就算不启动任何图形桌面你也能在屏幕上看到一个个字符字符一旦变形就说明帧缓冲的内容乱了。1.3 抓屏幕状态的工具箱观察像素显示异常不能只靠眼睛盯窗口。QEMU给了两个非常实用的工具monitor的screendump和trace事件。# 通过monitor socket截图 socat - UNIX-CONNECT:/tmp/qemu.sock (qemu) screendump /tmp/before.ppm (qemu) screendump /tmp/after.ppmscreendump保存的是虚拟机当前帧缓冲的完整内容无论SDL窗口是不是被遮挡、缩放这个文件都忠实记录像素。相比拿手机拍屏幕这种方式对排查颜色通道互换这类问题特别有用。如果需要更深层的检测比如想确认驱动有没有真的往IO端口写寄存器可以给QEMU加tracecat /tmp/trace-events EOF vga_io_read vga_io_write vga_mem_read vga_mem_write EOF qemu-system-x86_64 ... -trace events/tmp/trace-events启动后虚拟机里访问一次寄存器QEMU侧就会打印对应的IO读或IO写事件。这个工具在实验四“错写IO访问方向”时会发挥关键作用能直接看到驱动发出的到底是写周期还是读周期。2. VGA显示链路的关键细节2.1 VBE接口与驱动初始化流程你可能听过VGA但QEMU的标准VGA并不等同于1987年的原始VGA硬件它实现的是后来扩展出来的VBE接口。VBE全称VESA BIOS Extensions目的是在保留旧VGA兼容性的同时提供线性的高分辨率帧缓冲。QEMU的内存里这套VBE寄存器通过两个IO端口访问0x1CE是索引端口0x1CF是数据端口。Linux内核的bochs-drm驱动就是围绕这套接口写的。驱动的初始化链路大致是PCI探测到QEMU的VGA设备后读取PCI配置空间拿到BAR0显存映射基址和BAR1MMIO寄存器区域然后通过bochs_hw_set_mode把目标分辨率和位深写入VBE寄存器最后注册DRM的CRTC、Encoder、Connector和Plane。其中最关键的一个操作是配置寄存器VBE_DISPI_INDEX_ENABLE它的bit0为1时启用VBE模式bit1为1时启用LFB线性帧缓冲。只有这两个位同时置位虚拟机屏幕才会从传统VGA文本模式切换到线性缓冲模式。如果你故意只置位其中一个就会出现黑屏或者文本残留的现象。2.2 显存映射、像素格式、时序寄存器VBE模式下显存是一个连续线性区域地址由PCI BAR0暴露。对用户态程序来说它把BAR0映射到进程地址空间直接写内存就能改变屏幕像素。但这里有一个前提驱动和QEMU必须对“一个像素占用几个字节”达成一致。QEMU的标准VGA在32bpp模式下每个像素占4字节内存布局是XRGB8888也就是第一个字节是保留位后面依次是蓝、绿、红三个通道。DRM框架里用DRM_FORMAT_XRGB8888这个fourcc来标识这个布局。如果你把格式换成DRM_FORMAT_BGRX8888字节序就完全反了硬件读取时把本来是B的通道当成R整个画面色调会立即扭曲。时序相关的寄存器主要是VBE_DISPI_INDEX_XRES、VBE_DISPI_INDEX_YRES和VBE_DISPI_INDEX_BPP。QEMU收到这些值后会重新计算显存里的行字节数stride然后开始扫描输出。这意味着驱动里写入的XRES和YRES必须和用户态绘图时使用的尺寸完全匹配任何单侧不一致都会导致画面错位。2.3 哪一行代码能决定屏幕上的某个像素从最底层的角度说决定屏幕上某个像素的代码路径有两段。第一段是驱动初始化时把帧缓冲基址暴露给用户态用户态应用往这个基址偏移处写像素数据第二段是KMS提交画面时驱动把shadow buffer里的内容同步到BAR0映射的显存中。在bochs-drm驱动中bochs_primary_plane_update负责第二段同步。它内部有一个memcpy把绘制好的shadow数据整体拷贝到真正的显存位置。这行代码一旦出问题屏幕就会停在上一帧。所以如果要“精准控制异常像素”你可以修改三类东西一是写入VBE寄存器的分辨率参数二是像素格式fourcc三是帧同步拷贝逻辑。下面四个实验分别演示了这三种思路外加一个更底层的IO端口读写方向篡改。3. 故意修改驱动代码的四个实验3.1 实验一篡改分辨率参数导致横向条纹打开drivers/gpu/drm/bochs/bochs_hw.c找到bochs_hw_set_mode函数。正常代码是把用户态请求的xres直接写入VBE寄存器static void bochs_hw_set_mode(struct bochs_device *bochs, unsigned xres, unsigned yres, unsigned bpp) { /* ... */ bochs_dispi_write(bochs, VBE_DISPI_INDEX_XRES, xres); bochs_dispi_write(bochs, VBE_DISPI_INDEX_YRES, yres); bochs_dispi_write(bochs, VBE_DISPI_INDEX_BPP, bpp); bochs_dispi_write(bochs, VBE_DISPI_INDEX_ENABLE, VBE_DISPI_ENABLED | VBE_DISPI_LFB_ENABLED); /* ... */ }我故意把XRES减了1变成xres - 1。重新编译模块并加载后屏幕立刻出现横条纹右侧有杂色噪点字符显示错乱。原理其实不复杂QEMU收到XRES1023后把显存里每一行的有效像素设成了1023个但用户态绘图的缓冲还是按照1024像素一行来写。于是每一行最后一个像素实际上被当成了下一行的第一个像素整个画面逐行错位。这个实验给我最大的启发是分辨率不只是“宽高”这两个数字它背后还关联stride、显存起始偏移等一连串参数。你只改驱动一侧而不改用户态绘图侧QEMU和驱动就会对帧缓冲布局产生分歧。3.2 实验二调换像素通道观察颜色反转这次改的是drivers/gpu/drm/bochs/bochs_kms.c中plane初始化里的像素格式format DRM_FORMAT_XRGB8888; /* 改成 */ format DRM_FORMAT_BGRX8888;为了观察效果我提前准备了一张纯色测试图左侧半屏为纯红色RGB255,0,0右侧半屏为纯蓝色RGB0,0,255。正常情况下加载后左边是红、右边是蓝。修改格式后重新加载驱动屏幕变成左边蓝、右边红纯绿部分保持不变。这个现象说明像素格式不是“颜色名称”的问题而是内存里的字节顺序。XRGB8888的内存布局是[保留, 蓝, 绿, 红]BGRX8888的内存布局是[蓝, 绿, 红, 保留]。同一个像素值用不同格式解释RGB通道会错位。对只画黑白内容的场景可能无所谓但只要涉及彩色内容格式错误一眼就能看出来。调试这类问题时强烈建议不要只用渐变图纯色的左右分屏图最直观。因为颜色通道互换后纯色区域的变化一目了然不会受到渐变算法干扰。3.3 实验三破坏帧同步拷贝导致画面冻结bochs-drm驱动没有硬件支持脏区追踪所以它用一个shadow buffer保存用户态画好的内容每次Plane更新时整体拷贝到显存。找到bochs_primary_plane_update函数里面有一行类似下面的拷贝逻辑memcpy(bochs-fb_base 0, bochs-shadow, bochs-stride * bochs-height);我故意把这行注释掉重新加载驱动后屏幕停在开机后的第一帧画面上。之后无论里面怎么刷新终端、跑什么程序屏幕纹丝不动但串口终端里一切正常说明系统本身没死只是VGA驱动没有再向显存同步数据。如果你不想让画面完全冻结还可以把 0改成 32意思是从第32字节开始拷贝。这样屏幕上所有内容会整体向右偏移8个像素32字节除以4字节每像素左侧则留出8个像素宽的黑边。这个偏移量很直观能帮你验证显存寻址的字节粒度。这个实验的价值在于它把“KMS提交”这条链路单独拎了出来。以后再遇到“画面不刷新”的问题时你就知道先怀疑plane update的同步逻辑而不是一上来就查像素格式或模式设置。3.4 实验四错写IO访问方向导致黑屏前三个实验都在驱动层面动手这个实验我把手伸到最底层——IO端口读写。bochs-drm访问VBE寄存器通过bochs_dispi_write完成它内部会调用outw向0x1CE和0x1CF端口写入索引和数据。我把数据端口的outw改成了inw/* 原代码 */ outw(val, bochs-ioport 1); /* 改后 */ val inw(bochs-ioport 1);结果屏幕直接黑屏dmesg里出现模式设置失败的报错。原因是outw会产生一个IO写周期QEMU的VGA设备在写周期里才会更新寄存器而inw产生的是IO读周期QEMU把它当成读取VGA状态来处理根本不会触发模式切换。于是VBE从未启用显卡停留在传统VGA文本模式或者干脆未初始化状态。打开QEMU的vga_io_write和vga_io_readtrace事件后现象更清楚改之前能抓到连续的写事件改之后一个写事件都看不到取而代之的是读事件。这一步直接印证了IO读写方向就是设备握手协议本身写反了就是黑屏。3.5 四个修改点的结果对照把四次实验放在一起做成对照表后面排查问题可以直接对应修改点关键代码观察到的像素异常根因分析XRES减1VBE_DISPI_INDEX_XRES写入值横条纹、右侧杂色噪点驱动与QEMU的stride不一致像素格式互换DRM_FORMAT_XRGB8888改BGRX红蓝通道互换fourcc字节序不匹配注释帧同步memcpybochs_primary_plane_update画面冻结不刷新缺失shadow到显存的拷贝outw改成inwbochs_dispi_write数据端口黑屏、模式设置失败IO写周期被替换成读周期这四个现象外行人看起来都叫“花屏”但内行人能从中看出驱动内部哪一层出了问题第一类是模式设置层第二类是format设计第三类是plane更新层第四类是最底层的IO总线交互。这正是故意改错的精髓现象相同但通过修改点的差异你能学会区分不同层的责任边界。4. 常见翻车现场与排查思路4.1 问题速查表我做这个实验时翻过不少车很多问题不是因为“改错”本身而是实验环境没弄好导致现象被掩盖。这里整理几个高频问题现象可能原因处理方法加载模块后系统直接panic分辨率或模式设置远超显存容量启动参数加vganormal从串口看dmesg屏幕黑屏串口正常VBE未启用或IO访问方向改错检查QEMU的trace事件确认写周期修改格式后颜色没变化用户态还用自己的格式在画用纯色测试图先确认底色通道屏幕花成一片无法分辨规律同时改了两处以上一次只改一个点保留diff记录fbcon没注册看不到字符内核没开CONFIG_FRAMEBUFFER_CONSOLE重新配置内核并加上vga0x343启动参数QEMU窗口无图screendump有图SDL/GTK窗口缩放问题以screendump保存的PPM为准4.2 从dmesg和QEMU侧定位问题遇到异常我习惯先分两头排查虚拟机内部看dmesg虚拟机外部看QEMU的trace和monitor。虚拟机内部dmesg | grep drm dmesg | grep bochs cat /sys/kernel/debug/dri/0/state cat /proc/iomem | grep -i vgadrm.debug0x1f会让DRM打印几乎所有调试信息。模式设置失败时这里会出现bochs_hw_set_mode相关的错误码如果plane update出问题也能看到drm_atomic_commit时的警告。虚拟机外部# monitor里查看PCI设备信息和显存映射 (qemu) info pci (qemu) info ramblock (qemu) xp 0xfd000000 # dump BAR0对应的物理内存16进制查看帧缓冲如果怀疑驱动没写寄存器直接用xp查看BAR0内容看里面到底是全零、全FF还是能隐约看到字符形状。这一步能把问题定位到“驱动没写”还是“写了但格式不对”。4.3 让实验更可控的几条经验第一个经验每次只改一个变量。四个实验我都是单独改、单独编译、单独截图。如果你同时改了分辨率和像素格式画面全花你根本分不清是哪一步导致的。内核调试也是这样变量越多归因越难。第二个经验全程用串口作为备份通道。我把consolettyS0,115200放在启动参数第一位这样就算VGA画面完全完蛋照样能登录进系统执行rmmod、modprobe、reboot。没有这条保命通道你只能眼睁睁看着黑屏窗口然后强行重启虚拟机。第三个经验编译模块比编译整个内核快得多。bochs-drm是模块化编译改完代码后只需要make ARCHx86_64 -j$(nproc) drivers/gpu/drm/bochs/bochs.ko然后把新的bochs.ko拷贝进虚拟机rmmod bochs_drm modprobe bochs_drm就行。实在不想做initramfs也可以直接把整个内核重新编译但每次全量编译至少几分钟实验节奏会慢很多。第四个经验截图文件要及时归档。给每个改动建独立目录保存before.ppm、after.ppm以及对应的diff。要做A/B对比时这些截图比记忆可靠得多。5. 个人体会故意改错反而学得快之前我总想着一次把驱动代码写对后来发现那是很理想化的状态。驱动这层代码离硬件太近寄存器语义、字节顺序、同步时机稍有偏差各种莫名其妙的现象就来了。与其抱着文档死啃不如主动制造错误用异常现象反向建立直觉。这四个实验做完我对VBE接口、DRM的plane更新流程、IO端口读写周期的理解比之前看一个月文档都深刻。后续你还可以把同样的方法用在QEMU的virtio-gpu驱动上或者干脆上手cirrus显卡对应的内核驱动。改错-观察-归因这个循环对任何图形驱动都适用。再进一步配合KASAN和KMSAN这类内存检测工具你甚至能从“故意改错”转向“自动发现错误”那就完全是内核fuzzing的路子了。
返回列表