使用图形时,操作系统使我们的工作变得更容易。在我们的情况下,除了其他一切。在这篇文章中,我们将使用VirtIO规范编写GPU(图形处理器)驱动程序。在这里,我们将允许用户应用程序将屏幕的一部分作为RAM,即通常所说的帧缓冲区。
我们通过向主机(设备)发送某些命令来命令虚拟GPU(virtio-GPU)。来宾(操作系统驱动程序)分配的RAM成为帧缓冲区。然后驱动程序告诉设备,“嘿,这是我们要用来存储像素信息的RAM。”
在我们的操作系统中,RAM是连续的,但根据规范,这不是严格要求。我们会给司机一个矩形。该矩形内的所有内容都将复制到主机。我们不想一遍又一遍地复制整个缓冲区。
我们将使用我们在这里用于块驱动程序的virtio协议,所以我不会重新散列通用的virtio协议。但是,特定于设备的结构略有不同,因此我们将更深入地讨论这一部分。
帧缓冲区必须足够大,才能存储\(\text{width}\times\text{height}\times\text{pixel大小}\)字节数。有\(\text{width}\次\text{Height}\)个像素数。每个像素都有一个1字节的红、绿、蓝和Alpha通道。因此,每个像素正好是4个字节,符合我们将要指定的配置。
我们初级GPU驱动程序的帧缓冲区将支持\(640\乘以480\)的固定分辨率。如果你是90年代的孩子,你会经常看到这个决议。事实上,我的第一台电脑是一台激光Pal386,它是一台分辨率为640像素、高480像素的16色显示器。
红色、绿色和蓝色像素非常接近,通过改变这三个通道的强度,我们可以改变颜色。我们离显示器越近,就越容易看到像素。
你可以看到这些小方块。如果你眯着眼睛看得够多,你就会发现它们不是纯白的。取而代之的是,你可以看到红色、蓝色和绿色的点点。这是因为这些小方块中的每一个都被细分为三种颜色:是的,红色、绿色和蓝色!为了生成白色,这些像素被调到11(明白其中的笑话了吗?)。要使其变黑,我们需要关闭该像素的所有三个通道。
分辨率指的是我们的显示器上有多少个这样的方块。这是一台1920×1080的监视器。这意味着从左到右有1920个这样的方块,从上到下有1080个这样的方块。总而言之,我们有\(1920\乘以1080=2,073,600\)个像素数。这些像素中的每一个都使用帧缓冲区中的4个字节表示,这意味着我们需要RAM中的\(2,073,600\x 4=8,294,400\)字节来存储像素信息。
您可以理解为什么我将分辨率限制为640×480,这只需要\(640\乘以480\乘以4=1,228,800\)字节-略高于1兆字节。
GPU设备要求我们阅读更新的VirtIO规范。我将从V1.1中阅读,您可以在此处获得副本:https://docs.oasis-open.org/virtio/virtio/v1.1/virtio-v1.1.html.。具体来说,第5.7章“GPU设备”。这是一个未加速的2D设备,这意味着我们必须使用CPU来实际形成帧缓冲区,然后我们将CPU规定的内存位置转移到主机GPU,然后由主机GPU负责将其绘制到屏幕上。
该设备使用请求/响应系统,在该系统中,我们驱动程序发出命令,向主机(GPU)请求一些东西。我们在请求中添加了一些额外的内存,以便主机可以制定其响应。当GPU打断我们时,我们可以查看这个响应内存位置,看看GPU告诉了我们什么。这非常类似于块驱动程序上的Status字段,其中块设备告诉我们上次请求的状态。
标头对于所有请求和所有响应都是通用的。我们可以通过CtrlType枚举进行区分,它是:
#[repr(U32)]enum CtrlType{/*2d命令*/CmdGetDisplayInfo=0x0100,CmdResourceCreate2d,CmdResourceUref,CmdSetScanout,CmdResourceFlush,CmdTransferToHost2d,CmdResourceAttachBack,CmdResourceDetachBacking,CmdGetCapsetInfo,CmdGet。
这是我直接从说明书上拿来的,但为了避免被林特大喊大叫,我把名字生锈了。
回想一下,帧缓冲区只是内存中的一串字节。我们需要在帧缓冲区后面放置一个结构,以便主机(GPU)知道如何解释您的字节序列。有几种格式,但总而言之,它们只是重新排列红色、绿色、蓝色和Alpha通道。所有这些都恰好是4个字节,这使得步幅相同。步长是从一个像素到另一个像素的间距-4个字节。
#[repr(U32)]枚举格式{B8G8R8A8Unorm=1,B8G8R8X8 Unorm=2,A8R8G8B8B8 Unorm=3,X8R8G8B8B8 Unorm=4,R8G8B8A8Unorm=67,X8B8G8R8 Unorm=68,A8B8G8R8 Unorm=121,R8G8B8X8 Unorm=4。
类型unorm是从0到255的8位(1字节)无符号值,其中0表示无强度,255表示全强度,介于两者之间的数字是无强度和全强度之间的线性插值。由于有三种颜色(和一种Alpha),这给了我们\(256\乘以256\乘以256=16,776,216\)不同的颜色或颜色级别。
对于本教程,我选择了R8G8B8A8Unorm=67,它首先是红色的,其次是绿色的,第三是蓝色的,第四是Alpha。这是一种常见的排序,因此我将选择它以便于后续操作。
回想一下,每个单独的分量R、G、B和A都是一块一个字节,因此(x,y)引用的每个像素是4个字节。这就是为什么我们的内存指针是像素结构而不是字节。
就像所有其他virtio设备一样,我们首先设置virtqueue,然后进行特定于设备的初始化。在我的代码中,我只是直接从块驱动程序复制粘贴到GPU驱动程序中。我在设备结构中添加的唯一内容是帧缓冲区和帧缓冲区的大小。
发布结构设备{queue:*mut queue,dev:*mut u32,idx:u16,ack_use_idx:u16,frame buffer:*mut Pixel,width:u32,Height:u32,}。
规范告诉我们执行以下操作,以便初始化设备并准备好绘制。我生锈了一些内容以符合我们的列举。
使用CmdResourceAttachBacking从来宾RAM分配一个帧缓冲区,并将其作为后备存储附加到刚刚创建的资源。
回想一下,我们的请求和响应打包在一起。我们将把它们放在不同的描述符中,但是每当我们从设备得到响应时,如果我们只释放一次来释放请求和响应,将会更容易。因此,在Rust中,我创建了请求结构来支持这样做。
结构请求<;RqT,Rpt>;{请求:RqT,响应:Rpt,}实施<;RqT,Rpt>;请求<;RqT,Rpt>;{pub FN new(Request:RqT)->;*mut self{let sz=size_of::<;rqt>;()+size_of::<;Rpt&Gt;{pub FN new(Request:RqT)->;*mut self{let sz=size_of::<;rqt>;()+size_of::<;Rpt&Gt;
让rq=request::new(ResourceCreate2d{hdr:CtrlHeader{ctrl_type:CtrlType::CmdResourceCreate2d,标志:0,栅栏_id:0,ctx_id:0,填充:0,},resource_id:1,format:format::R8G8B8A8Unorm,width:dev.width,Height:dev.Height,});让desc_C2d=Desptor。()AS u32,标志:VIRTIO_DESC_F_NEXT,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::<;CtrlHeader>;()AS u32,标志:VII。(*dev.queue).desc[作为usize的dev.idx]=desc_C2d;dev.idx=(dev.idx+1)%VIRTIO_RING_SIZE AS u16;(*dev.queue).desc[dev.idx作为usize]=desc_C2d_resp;dev.idx=(dev.idx+1)%VIRTIO_RING_SIZE AS u16;(*dev.queue).avail.ring[(*dev.queue).avail.idx AS uSIZE%VIRTIO_RING_SIZE。(*dev.queue).avail.idx=(*dev.queue).avail.idx.wrapping_add(1);}。
我们在这里真正告诉GPU的是我们的分辨率和帧缓冲区的格式。当我们创建它时,主机可以自行配置,例如分配一个相同的缓冲区来从我们的操作系统进行传输。
让rq=Request3::New(AttachBacking{hdr:CtrlHeader{ctrl_type::CmdResourceAttachBacking,FLAGS:0,FARCH_ID:0,CTX_ID:0,PADDING:0,},RESOURCE_ID:1,},MemEntry{addr:dev.frame buffer as U64,Length:dev.width*dev.high*size_of:):(*rq).request as*const AttachBack as U64},len:size_of::<;AttachBacking>;()as u32,标志:VIRTIO_DESC_F_NEXT,NEXT:(dev.idx+1)%VIRTIO_RING_SIZE AS u16,};let desc_ab_mementry=description ptor{addr:unsafe{&;(*rq).mementry as*const。()AS u32,标志:VIRTIO_DESC_F_NEXT,NEXT:(dev.idx+2)%VIRTIO_RING_SIZE AS u16,};let desc_ab_resp=Descriptor{addr:unsafe{&;(*rq).response as*Const CtrlHeader as U64},len:size_of::<;CtrlHeader>;()AS u32,标志:VIRTIO。(*dev.queue).desc[dev.idx as usize]=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 as usize。(*dev.queue).avail.ring[(*dev.queue).avail.idx as usize%VIRTIO_RING_SIZE]=Head;(*dev.queue).avail.idx=(*dev.queue).avail.idx.wrapping_add(1);}。
支持通过MemEntry结构向GPU公开。这实质上是访客RAM中的物理地址。除了填充之外,MemEntry只是一个指针和一个长度。
请注意,我创建了一个名为Request3的新结构。这是因为此步骤需要三个单独的描述符:(1)头、(2)mementry、(3)响应,而我们通常只需要两个描述符。我们的结构与普通请求非常相似,但它包含了mementry。
Struct Request3<;RqT,rmt,Rpt>;{request:rqt,mementry:rmt,}Impll<;RqT,rmt,Rpt>;Request3<;RqT,rmt,rpt>;{pub FN new(request:rqt,meminfo:rmt)->;*mut self{let sz=size_of:让ptr=kmalloc(Sz)as*mut self;unsafe{(*ptr).request=request;(*ptr).mementry=meminfo;}ptr}}
让rq=request::new(SetScanout{hdr:CtrlHeader{ctrl_type:CtrlType::CmdSetScanout,FLAGS:0,FARCH_ID:0,CTX_ID:0,PADDING:0,},r:RECT::NEW(0,0,dev.width,dev.high),resource_id:1,scanout_id:0,});让desc_sso=description ptor{addr:un。()AS u32,标志:VIRTIO_DESC_F_NEXT,NEXT:(dev.idx+1)%VIRTIO_RING_SIZE AS u16,};let desc_sso_resp=Descriptor{addr:unsafe{&;(*rq).response as*Const CtrlHeader as U64},len:size_of::<;CtrlHeader>;()AS u32,标志:VIRTHeader。(*dev.queue).desc[作为usize的dev.idx]=desc_sso;dev.idx=(dev.idx+1)%VIRTIO_RING_SIZE AS u16;(*dev.queue).desc[dev.idx作为usize]=desc_sso_resp;dev.idx=(dev.idx+1)%VIRTIO_RING_SIZE AS u16;(*dev.queue).avail.ring[(*dev.queue).avail.idx AS usize%VIRTIO_RING_SIZE]=。(*dev.queue).avail.idx=(*dev.queue).avail.idx.wrapping_add(1);}。
当我们想要写入缓冲区时,我们将通过它的扫描输出号来引用它。如果我们有两个放大镜,我们可以在其中一个上绘制,而另一个则显示在屏幕上。这称为双缓冲,但出于我们的目的,我们不这样做。取而代之的是,我们在相同的帧缓冲区上绘制,然后传输某些部分以供GPU更新显示。
在我们发出信号QueueNotify(virtio寄存器“GO”按钮)之后,GPU将在内部创建一个新的缓冲区,设置后备存储,并将扫描输出数设置为该缓冲区。我们现在有了一个初始化的帧缓冲区!
现在我们有了包含像素的内存。然而,我们有自己的内存,而GPU也有自己的内存。所以,要把我们的东西送到GPU上,就需要把它转移到GPU上。我们在初始化期间设置了后备存储,因此现在我们只需引用我们希望通过其扫描输出编号进行更新的内容。
失效很重要,因为我们每次进行更改都要更新整个屏幕,这是非常昂贵的。事实上,如果我们传输整个屏幕,我们需要传输\(640\乘以480\乘以4=1,228,800\)字节。对于帧速率,例如每秒20或30帧,我们需要每秒传输此数量的字节20或30次!
我们不会传输所有内容,而是使帧缓冲区的某些部分无效,并且GPU将只复制那些落在无效区域内的像素,这些像素的坐标由RECT结构定义。
#[repr(C)]#[派生(克隆,复制)]pub struct rect{pub x:u32,pub y:u32,pub width:u32,pub Height:u32,}Impll RECT{pub Const FN new(x:u32,y:u32,width:u32,high:u32)->;self{self{x,y,width,Height}。
请注意,此矩形由左上角坐标(x,y)定义,然后由宽度和高度定义。矩形可以由它们的坐标(x1,y1)、(x2,y2)或初始坐标以及宽度和高度定义。我在规范中看不到关于前者的任何内容,但是当我试图使其无效并进行传输时,它似乎将矩形视为后者。哦,好吧,我想还有更多的测试,…
无效只是将数据从来宾(驱动程序)传输到主机(GPU)。这只是复制内存,为了更新帧缓冲区,我们执行一个flush命令。
Pub FN Transfer(gdev:usize,x:u32,y:u32,width:u32,Height:u32){if let ome(Mut Dev)=unsafe{gpu_devices[gdev-1].Take()}{let rq=request::new(hdr:CtrlHeader{ctrl_type:CtrlType::CmdTransferToHost2d,flag:0,fence_填充:0,});让desc_t2h=description ptor{addr:unsafe{&;(*rq).request as*const TransferToHost2d as u64},len:size_of::<;TransferToHost2d>;()as u32,标志:VIRTIO_DESC_F_NEXT,NEXT:(dev.idx+1)%VIRTIO_RING_SIZE AS u16,};让desc_T2H_ress。()作为u32,标志:VIRTIO_DESC_F_WRITE,NEXT:0,};不安全{let head=dev.idx;(*dev.queue).desc[dev.idx as usize]=desc_T2H;dev.idx=(dev.idx+1)%VIRTIO_RING_SIZE AS u16;(*dev.queue).desc[dev.idx as usize]=desc_T2H_resp;dev.。(*dev.queue).avail.ring[(*dev.queue).avail.idx as usize%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,FARCH_ID:0,CTX_ID:0,PADDING:0,},r:RECT::NEW(x,y,width,Height),resource_id:1,padding0,});让desc_rf=description ptor{addr:un(*rq).request as*const ResourceFlush as U64},len:size_of::<;ResourceFlush>;()AS u32,标志:VIRTIO_DESC_F_NEXT,NEXT:(dev.idx+1)%VIRTIO_RING_SIZE AS u16,};let desc_RF_resp=Descriptor{addr:unsafe{&;(*rq).response as*Const CtrlHeader as U64},len:size_of::<;CtrlHeader>;()AS u32,标志:VIRTIO。(*dev.queue).desc[dev.idx as usize]=desc_rf;dev.idx=(dev.idx+1)%VIRTIO_RING_SIZE AS u16;(*dev.queue).desc[dev.idx as usize]=desc_rf_resp;dev.idx=(dev.idx+1)%VIRTIO_RING_SIZE AS u16;(*dev.queue).avail.ring[(*dev.queue).avail.idx as usize%VIRTIO_RING_SIZE]=Head;(*dev.queue).avail.idx=(*dev.queue).avail.idx.wrapping_add(1);}//运行队列不安全{dev.dev.add(MmioOffsets::QueueNotify.scale32()).write_volatie(0);GPU_Devices[gdev-1].place(Dev);}}。
因此,我们的传输首先告诉主机我们已经更新了帧缓冲区的特定部分,该部分指定为x、y、width和high。然后,我们进行所谓的资源刷新,让GPU将所有传输提交到屏幕上。
这是一个相当简单的部分。大多数设备响应都以NODATA的形式出现,这只是对它发出请求的确认。另外,请注意,与块驱动程序不同,我们这里没有观察者。这允许我们异步更新屏幕。
这样做的全部目的是让用户空间应用程序将内容绘制到屏幕上。通常,我们不会将整个帧缓冲区提供给任何需要它的用户空间应用程序,但是出于我们的目的,我们现在可以接受它。相反,我们会让窗口管理器将帧缓冲区的某些矩形委托给不同的应用程序。窗口管理器还将负责处理事件并将适当的事件发送到GUI应用程序。
要允许我们的用户空间应用程序使用GPU,我们需要两个系统调用。一个用于获取指向帧缓冲区的指针。回想一下,我们首先必须将帧缓冲区映射到用户空间的MMU表。这就是我们分配页面而不是使用kmalloc的原因。
让dev=(*frame).regs[Registers::A0 as usize];(*frame).regs[Registers::A0 as usize]=0;if dev>;0&;&;dev<;=8{if let ome(P)=gpu::gpu_Devices[dev-1].Take(){let ptr=p.get_frame buffer()as usize;gpu::gpu_device[dev。60!=0{let p=get_by_pid((*frame).pid as u16);let table=((*p).get_table_address()as*mut Table).as_mut().unwork();let num_ages=(p.get_width()*p.get_Height()*4)as usize/page_size;for i in 0.num_ages{let vaddr=0x3000_0000+(i<;&设paddr=ptr+(i<;<;12);map(table,vaddr,paddr,EntryBits::UserReadWrite as i64,0);}}(*frame).regs[Registers::A0 as usize]=0x3000_0000;}}。
正如您在上面看到的,我们从GPU设备获取帧缓冲区并将其映射到0x3000_0000。目前,我计算帧缓冲区的页数是\(\frac{640\x 480\x 4}{4,096}=300\)。所以,这份决议正好需要300页。
因此,现在我们有了一个帧缓冲区,这样用户空间应用程序就可以将它想要的写入到这个内存位置。但是,写入操作不会立即更新屏幕。回想一下,我们必须传输然后刷新才能将结果写入屏幕。这就是我们的第二个系统调用发挥作用的地方。
设dev=(*frame).regs[Registers::a0 as usize];设x=(*frame).regs[Registers::a1 as usize]as u32;令y=(*frame).regs[Registers::a2 as usize]as u32;let width=(*frame).regs[Registers::a3 as usize]as u32;let Height=(*frame).regs[Registers::a4 as usize]as u32。
我展示了上面的传递函数,它只发出两个请求:(1)CmdTransferToHost2d和(2)CmdResourceFlush。当用户空间应用程序进行此系统调用时,结果将刷新到屏幕上,因此,用户可以看到结果。我不会在系统调用本身中进行错误检查。传递函数将对设备进行错误检查,并且设备将对x、y、宽度和高度进行错误检查。因此,如果这是不正确的,传递函数将静默失败,并且屏幕上不会有任何更新。
要查看屏幕上显示的内容,我们需要能够绘制最简单的。
.