在使用图形时,使用操作系统来简化我们的工作。在我们的实例中,除了其他方面。在本文中,我们将使用VirtIO规范编写GPU(图形处理单元)驱动程序。在这里,我们将允许用户应用程序将屏幕的一部分作为RAM -通常称为帧缓冲区。
我们通过将某些命令发送到主机(设备)来命令虚拟GPU(virtio-gpu)。来宾(操作系统驱动程序)具有分配为帧缓冲区的RAM。然后,驱动程序告诉设备:“嘿,这是我们将用于存储像素信息的RAM。”
RAM在我们的操作系统中是连续的,但是根据规范,这并不是严格要求的。我们将给驱动程序一个矩形。属于该矩形的所有内容都将复制到主机。我们不想一遍又一遍地复制整个缓冲区。
我们将在此处使用用于块驱动程序的virtio协议,因此我不会重新介绍通用的virtio协议。但是,特定于设备的结构有些不同,因此我们将更深入地介绍该部分。
帧缓冲区必须足够大以存储\(\ text {width} \ times \ text {height} \ times \ text {pixel size} \)个字节数。有\(\ text {width} \ times \ text {height} \)个像素。每个像素都有一个1字节的红色,绿色,蓝色和alpha通道。因此,按照我们要指定的配置,每个像素正好是4个字节。
我们的初级GPU驱动程序的帧缓冲区将支持\(640 \ times 480 \)的固定分辨率。如果您是90年代的孩子,那么您会经常看到这种分辨率。实际上,我的第一台计算机Laser Laser 386装有16色监视器,分辨率为640像素,高480像素。
红色,绿色和蓝色像素之间的距离非常近,因此可以通过更改这三个通道的强度来更改颜色。我们离显示器越近,像素越容易看到。
您可以看到这些小方块。如果斜视一下,您会发现它们不是纯白色的。相反,您可以看到红色,蓝色和绿色。这是因为这些小方块中的每一个都细分为三种颜色:是,红色,绿色和蓝色!为了产生白色,这些像素最多调到11(开个玩笑?)。要变黑,请关闭该像素的所有三个通道。
分辨率是指我们的显示器上有多少个正方形。这是1920×1080的显示器。这意味着从左到右有1920个正方形,从上到下有1080个正方形。总共,我们有\(1920 \ times 1080 = 2,073,600 \)像素数。这些像素中的每个像素都使用4个字节表示在帧缓冲区中,这意味着我们需要\(2,073,600 \ times 4 = 8,294,400 \)个字节来存储像素信息。
您可以看到为什么我将分辨率限制为640×480,它只需要\(640 \ times 480 \ times 4 = 1,228,800 \)个字节-超过了一个兆字节。
GPU设备要求我们阅读更新的VirtIO规范。我将从1.1版开始阅读,您可以在此处获得副本:https://docs.oasis-open.org/virtio/virtio/v1.1/virtio-v1.1.html。具体来说,第5.7节“ GPU设备”。这是一个未加速的2D设备,这意味着我们必须使用CPU实际形成帧缓冲区,然后将CPU制定的内存位置传输到主机GPU,然后由它负责将其绘制到屏幕上。
该设备使用请求/响应系统,在该系统中,我们的驱动程序发出命令以从主机(GPU)请求某些内容。我们在请求中添加了一些额外的内存,以便主机可以制定其响应。当GPU中断我们时,我们可以查看此响应内存位置,以了解GPU告诉我们的内容。这很像块驱动程序上的状态字段,块设备在其中告诉我们上一个请求的状态。
标头对于所有请求和所有响应都是通用的。我们可以通过CtrlType枚举来区分,即:
#[repr(u32)]枚举CtrlType {/ * 2d命令* / CmdGetDisplayInfo = 0x0100,CmdResourceCreate2d,CmdResourceUref,CmdSetScanout,CmdResourceFlush,CmdTransferToHost2d,CmdResourceAttachBacking,CmdResourceDetachBacking,CmdGetCapsetd,CmdGetCapsetd,CmdGetCapsetd,CmdGetCapsetd CmdMoveCursor,/ *成功响应* / RespOkNoData = 0x1100,RespOkDisplayInfo,RespOkCapsetInfo,RespOkCapset,RespOkEdid,/ *错误响应* / RespErrUnspec = 0x1200,RespErrOutOfMemory,RespErrInvalidScanoutId,RespErrInvalidValidResp
我直接从规范中获取了此信息,但是Rust对其名称进行了修饰,以避免被短绒毛大喊大叫。
回想一下,帧缓冲区只是内存中的一堆字节。我们需要在帧缓冲区后面放置一个结构,以便主机(GPU)知道如何解释字节序列。格式有几种,但总的来说,它们只是重新排列红色,绿色,蓝色和Alpha通道。全部都是4个字节,这使得步幅相同。跨度是从一个像素到另一个4字节的间距。
#[repr(u32)]枚举格式{B8G8R8A8Unorm = 1,B8G8R8X8Unorm = 2,A8R8G8B8Unorm = 3,X8R8G8B8Unorm = 4,R8G8B8A8Unorm = 67,X8B8G8R8Unorm = 68,A8R8Unorm = 68,A8R8Unorm = 68,A8R8Unorm = 68,A8
类型unorm是从0到255的8位(1字节)无符号值,其中0表示无强度,255表示全强度,其间的数字是no和全强度之间的线性插值。由于存在三种颜色(和一种Alpha),因此可以为我们提供(256×256乘以256×16,776,216 \)种不同的颜色或颜色级别。
在本教程中,我选择了R8G8B8A8Unorm = 67,它的第一位为红色,第二位为绿色,第三位为蓝色,第四位为alpha。这是常见的排序方式,因此我将其选中以使其易于遵循。
回想一下,每个单独的分量R,G,B和A均为一个字节,因此(x,y)所指的每个Pixel为4字节。这就是为什么我们的内存指针是Pixel结构而不是字节的原因。
像所有其他virtio设备一样,我们首先设置虚拟队列,然后进行设备特定的初始化。在我的代码中,我只是直接从块驱动程序复制并粘贴到了gpu驱动程序中。我添加到设备结构的唯一一件事是帧缓冲区和帧缓冲区的尺寸。
pub struct Device {队列:* mut队列,开发人员:* mut u32,idx:u16,ack_used_idx:u16,帧缓冲区:* mut像素,宽度:u32,高度:u32,}
规范告诉我们执行以下操作以初始化设备并准备绘制东西。我对某些内容进行了锈化处理以匹配我们的枚举。
从来宾ram分配一个帧缓冲区,并使用CmdResourceAttachBacking将它作为后备存储附加到刚创建的资源上。
回想一下,我们的请求和响应是打包在一起的。我们将它们放在单独的描述符中,但是只要我们从设备获得响应,只要释放一次以释放请求和响应,就会变得更加容易。因此,在Rust中,我创建了Request结构来支持此操作。
struct Request {请求:RqT,响应:RpT,} impl Request {pub fn new(request:RqT)-> * mut Self {let sz = size_of :: ()+ size_of :: ();让ptr = kmalloc(sz)作为* mut Self;不安全{(* ptr).request =请求; } ptr}}
let rq = Request :: new(ResourceCreate2d {hdr:CtrlHeader {ctrl_type:CtrlType :: CmdResourceCreate2d,flags:0,fence_id:0,ctx_id:0,padding:0,},resource_id:1,format:Formats :: R8G8B8A8Unorm, width:dev.width,height:dev.height,});让desc_c2d =描述符{addr:不安全{&(* rq).request as * const ResourceCreate2d as u64},len:size_of :: ()as u32,标志:VIRTIO_DESC_F_NEXT,下一个:(dev.idx + 1)%VIRTIO_RING_SIZE as u16,}; let desc_c2d_resp = Descriptor {addr:unsafe {&(* rq).response as * const CtrlHeader as u64},len:size_of: :()为u32,标志:VIRTIO_DESC_F_WRITE,下一个:0,};不安全{让head = dev.idx; (* dev.queue).desc [dev.idx asize] = desc_c2d; dev.idx =(dev.idx +1)%VIRTIO_RING_SIZE as u16; (* dev.queue).desc [dev.idx asize] = desc_c2d_resp; dev.idx =(dev.idx +1)%VIRTIO_RING_SIZE as u16; (* dev.queue).avail.ring [(* dev.queue).avail.idx,使用率%VIRTIO_RING_SIZE] = head; (* dev.queue).avail.idx =(* dev.queue).avail.idx.wrapping_add(1);}
我们真正要告诉GPU的是分辨率和帧缓冲区的格式。当我们创建它时,主机将进行自我配置,例如分配相同的缓冲区以从操作系统进行传输。
let rq = Request3 :: new(AttachBacking {hdr:CtrlHeader {ctrl_type:CtrlType :: CmdResourceAttachBacking,flags:0,fence_id:0,ctx_id:0,padding:0,},resource_id:1,nr_entries:1,},MemEntry {addr:dev.framebuffer as u64,length:dev.width * dev.height * size_of :: ()as u32,padding:0,});让desc_ab =描述符{addr:不安全{&(* rq ).request为* const AttachBacking as u64},len:size_of :: ()as u32,标志:VIRTIO_DESC_F_NEXT,下一个:(dev.idx + 1)%VIRTIO_RING_SIZE as u16,}; let desc_ab_mementry =描述符{addr :不安全{&(* rq).mementries为* const MemEntry as u64},len:size_of :: ()为u32,标志:VIRTIO_DESC_F_NEXT,下一个:(dev.idx + 2)%VIRTIO_RING_SIZE为u16,} ; let desc_ab_resp =描述符{addr:不安全{&(* rq)。响应为* const CtrlHeader as u64},len:size_of :: ()as u32,flags:VIRTIO_DESC_F_WRITE,next:0,};不安全{让head = dev.idx; (* dev.queue).desc [dev.idx asize] = desc_ab; dev.idx =(dev.idx +1)%VIRTIO_RING_SIZE as u16; (* dev.queue).desc [dev.idx as usize] = desc_ab_mementry; dev.idx =(dev.idx +1)%VIRTIO_RING_SIZE as u16; (* dev.queue).desc [dev.idx asize] = desc_ab_resp; dev.idx =(dev.idx +1)%VIRTIO_RING_SIZE as u16; (* dev.queue).avail.ring [(* dev.queue).avail.idx,使用率%VIRTIO_RING_SIZE] = head; (* dev.queue).avail.idx =(* dev.queue).avail.idx.wrapping_add(1);}
支持通过MemEntry结构暴露给GPU。这实际上是来宾RAM中的物理地址。除了填充,MemEntry只是一个指针和一个长度。
注意,我创建了一个名为Request3的新结构。这是因为此步骤需要三个单独的描述符:(1)标头,(2)内存,(3)响应,而通常我们只需要两个描述符。我们的结构很像普通的Request,但是它包含了内存。
struct Request3 {请求:RqT,内存:RmT,响应:RpT,} impl Request3 {pub fn new(请求:RqT,meminfo: RmT)-> * mut Self {let sz = size_of :: ()+ size_of :: ()+ size_of :: ();让ptr = kmalloc(sz)作为* mut Self;不安全{(* ptr).request =请求; (* ptr).mementries = meminfo; } ptr}}
let rq = Request :: new(SetScanout {hdr:CtrlHeader {ctrl_type:CtrlType :: CmdSetScanout,flags:0,fence_id:0,ctx_id:0,padding:0,},r:Rect :: new(0,0, dev.width,dev.height),resource_id:1,scanout_id:0,}); let desc_sso =描述符{addr:不安全{&(* rq).request as * const SetScanout as u64},len:size_of :: ()为u32,标志:VIRTIO_DESC_F_NEXT,下一个:(dev.idx + 1)%VIRTIO_RING_SIZE为u16,}; let desc_sso_resp =描述符{addr:不安全{&(* rq)。以* const CtrlHeader as u64响应} ,len:size_of :: ()as u32,标志:VIRTIO_DESC_F_WRITE,下一个:0,};不安全{让head = dev.idx; (* dev.queue).desc [dev.idx asize] = desc_sso; dev.idx =(dev.idx +1)%VIRTIO_RING_SIZE as u16; (* dev.queue).desc [dev.idx asize] = desc_sso_resp; dev.idx =(dev.idx +1)%VIRTIO_RING_SIZE as u16; (* dev.queue).avail.ring [(* dev.queue).avail.idx,使用率%VIRTIO_RING_SIZE] = head; (* dev.queue).avail.idx =(* dev.queue).avail.idx.wrapping_add(1);}
当我们要写入缓冲区时,将通过其扫描编号来引用它。如果我们有两个扫描,我们可以画一个,而另一个显示在屏幕上。这称为双重缓冲,但是出于我们的目的,我们不这样做。相反,我们使用相同的帧缓冲区,然后为GPU传输某些部分以更新显示。
在通知QueueNotify信号后,virtio注册“ GO”按钮,然后GPU将在内部创建一个新缓冲区,设置后备存储,并将扫描输出号设置为此缓冲区。现在,我们有了一个初始化的帧缓冲区!
现在,我们有了包含像素的内存。但是,我们有自己的内存,而GPU也有自己的内存。因此,要使用我们的GPU,必须先将其转移。我们在初始化期间设置了后备存储,因此我们现在只需要通过其扫描输出编号来引用我们要更新的内容。
无效很重要,因为每次更改时都要更新整个屏幕,这非常昂贵。实际上,如果我们传输整个屏幕,则需要传输\(640 \ times 480 \ times 4 = 1,228,800 \)个字节。对于每秒20或30帧的帧速率,我们需要每秒传输此字节数20或30次!
而不是传输所有内容,我们使帧缓冲区的某些部分无效,GPU只会复制落在无效区域内的那些像素,这些像素的坐标由Rect结构定义。
#[repr(C)]#[derive(Clone,Copy)] pub struct Rect {pub x:u32,pub y:u32,pub width:u32,pub height:u32,} impl Rect {pub const fn new(x :u32,y:u32,宽度:u32,高度:u32)-> Self {Self {x,y,width,height}}}
请注意,此Rect由左上角坐标(x,y)然后由宽度和高度定义。矩形可以通过其坐标(x 1,y 1),(x 2,y 2)或初始坐标以及宽度和高度来定义。我在规范中看不到任何关于前者的内容,但是当我尝试使之无效并转移时,似乎将矩形视为后者。哦,我想还有更多测试……
无效只是将数据从来宾(驱动程序)传输到主机(GPU)。这只是复制内存,以更新帧缓冲区,我们执行刷新命令。
pub fn transfer(gdev:usize,x:u32,y:u32,width:u32,height:u32){如果让Some(mut dev)=不安全{GPU_DEVICES [gdev-1] .take()} {让rq = Request :: new(TransferToHost2d {hdr:CtrlHeader {ctrl_type:CtrlType :: CmdTransferToHost2d,flags:0,fence_id:0,ctx_id:0,padding:0,},r:Rect :: new(x,y,width,height ),偏移量:0,resource_id:1,填充:0,}); let desc_t2h =描述符{addr:不安全{&(* rq).request为* const TransferToHost2d as u64},len:size_of :: ()as u32,标志:VIRTIO_DESC_F_NEXT,下一个:(dev.idx + 1) %VIRTIO_RING_SIZE如u16,}; let desc_t2h_resp =描述符{addr:不安全{&(* rq).response as * const CtrlHeader as u64},len:size_of :: ()as u32,flags:VIRTIO_DESC_F_WRITE,next:0,};不安全的{让head = dev.idx; (* dev.queue).desc [dev.idx asize] = desc_t2h; dev.idx =(dev.idx +1)%VIRTIO_RING_SIZE as u16; (* dev.queue).desc [dev.idx as usize] = desc_t2h_resp; dev.idx =(dev.idx +1)%VIRTIO_RING_SIZE as u16; (* dev.queue).avail.ring [(* dev.queue).avail.idx,使用率%VIRTIO_RING_SIZE] = head; (* dev.queue).avail.idx =(* dev.queue).avail.idx.wrapping_add(1); } //步骤5:刷新let rq = Request :: new(ResourceFlush {hdr:CtrlHeader {ctrl_type:CtrlType :: CmdResourceFlush,flags:0,fence_id:0,ctx_id:0,padding:0,},r:Rect: :new(x,y,width,height),resource_id:1,padding:0,}); let desc_rf =描述符{addr:不安全{&(* rq).request为* const ResourceFlush as u64},len:size_of :: ()as u32,标志:VIRTIO_DESC_F_NEXT,下一个:(dev.idx + 1) %VIRTIO_RING_SIZE如u16,}; let desc_rf_resp =描述符{addr:不安全{&(* rq).response为* const CtrlHeader as u64},len:size_of :: ()as u32,标志:VIRTIO_DESC_F_WRITE,下一个:0,};不安全的{让head = dev.idx; (* dev.queue).desc [dev.idx asize] = desc_rf; dev.idx =(dev.idx +1)%VIRTIO_RING_SIZE as u16; (* dev.queue).desc [dev.idx asize] = desc_rf_resp; dev.idx =(dev.idx +1)%VIRTIO_RING_SIZE as u16; (* dev.queue).avail.ring [(* dev.queue).avail.idx,使用率%VIRTIO_RING_SIZE] = head; (* dev.queue).avail.idx =(* dev.queue).avail.idx.wrapping_add(1); } //运行队列不安全{dev.dev .add(MmioOffsets :: QueueNotify.scale32()).write_volatile(0); GPU_DEVICES [gdev-1] .replace(dev); }}
因此,我们的传输首先告诉主机我们已经更新了帧缓冲区的特定部分,指定为x,y,宽度和高度。然后,我们进行所谓的资源刷新,以使GPU将所有传输提交到屏幕。
这是一个相当简单的部分。大多数设备响应都以NODATA的形式出现,这只是对它发出请求的确认。另外,请注意,与块驱动程序不同,我们这里没有观察者。这使我们可以异步更新屏幕。
这样做的全部目的是使用户空间应用程序将内容绘制到屏幕上。通常,我们不会将完整的帧缓冲区提供给任何需要它的用户空间应用程序,但是出于我们的目的,我们现在可以使用它。相反,我们需要一个窗口管理器将帧缓冲区的某些矩形委托给不同的应用程序。窗口管理器还将负责处理事件并将适当的事件发送到GUI应用程序。
为了允许我们的用户空间应用程序使用GPU,我们需要两个系统调用。一个获得指向帧缓冲区的指针。回想一下,我们首先必须将帧缓冲区映射到用户空间的MMU表。这就是为什么我们分配页面而不使用kmalloc的原因。
let dev =(* frame).regs [以usize为单位的Registers :: A0];(* frame).regs [以usize的形式Registers :: A0 assize] = 0; if dev> 0 && dev > 60!= 0 {让p = get_by_pid((* frame).pid as u16); let table =((** p..get_table_address()as * mut Table).as_mut().unwrap();让num_pages =(p.get_width()* p.get_height()* 4)作为usize / PAGE_SIZE;对于0..num_pages中的i {让vaddr = 0x3000_0000 +(i << 12);令paddr = ptr +(i << 12); map(table,vaddr,paddr,EntryBits :: UserReadWrite为i64,0); }}(* frame).regs [Registers :: A0 as usize] = 0x3000_0000; }}
如您在上面看到的,我们从GPU设备获取帧缓冲区并将其映射到0x3000_0000。当前,我计算帧缓冲区的页数为\(\ frac {640 \ times 480 \ times 4} {4,096} = 300 \)。因此,此分辨率我们需要300页。
因此,现在我们有了一个帧缓冲区,以便用户空间应用程序可以将所需内容写入此内存位置。但是,写入操作不会立即更新屏幕。回想一下,我们必须先传输然后刷新才能将结果写入屏幕。这是我们的第二个系统调用起作用的地方。
让dev =(* frame).regs [寄存器:: A0作为usize];让x =(* frame).regs [寄存器:: A1作为usize]为u32;让y =(* frame).regs [寄存器: :A2 as usize]作为u32; let width =(* frame).regs [Registers :: A3 as usize] as u32; let height =(* frame).regs [Registers :: A4 as usize] as u32; gpu: :transfer(dev,x,y,width,height);
我在上面显示了传递函数,它仅发出两个请求:(1)CmdTransferToHost2d和(2)CmdResourceFlush。当用户空间应用程序进行此系统调用时,结果将刷新到屏幕上,因此对用户可见。我不会在系统调用本身中进行错误检查。传递函数将错误检查设备,并且设备将错误检查x,y,宽度和高度。因此,如果这是不正确的,则传输功能将静默失败,并且屏幕上将不会更新任何内容。
要查看屏幕上显示的内容,我们需要能够绘制出简单
......