計算機底層是如何訪問顯卡的?

1. 顯卡驅動是怎麼控制顯卡的, 就是說, 使用那些指令控制顯卡, 通過埠么?
2. DirectX 或 OpenGL 或 CUDA 或 OpenCL 怎麼找到顯卡驅動, 顯卡驅動是不是要為他們提供介面的實現, 如果是, 那麼DirectX和OpenGL和CUDA和OpenCL需要顯卡驅動提供的介面都是什麼, 這個文檔在哪能下載到? 如果不是, 那麼DirectX, OpenGL, CL, CUDA是怎麼控制顯卡的?
3. 顯卡中的流處理器具體是做什麼的, 是執行某些特殊運算么, 還是按某些順序執行一組運算, 具體是什麼, 光柵單元呢, 紋理單元呢?
4. 顯卡 ( 或其他設備 ) 可以訪問內存么? 內存地址映射的原理是什麼, 為什麼 B8000H 到 C7FFFH 是顯存的地址, 向這個地址空間寫入數據後, 是直接通過匯流排寫入顯存了么, 還是依然寫在內存中, 顯卡到內存中讀取, 如果直接寫到顯存了, 會出現延時和等待么?
5. 以上這些知識從哪些書籍上可以獲得?


其實你可以把顯卡想像成另外一台機器。那麼控制另外一台機器的辦法,就是往它的內存裡面寫指令和數據。往一塊內存裡面寫東西的辦法無非就幾種,1, 用CPU去做,那麼就是用MMIO(Memory Mapped IO)把"顯存" map到CPU定址空間,然後去讀寫,2, 用DMA控制器去做,這裡面有系統自帶的DMA控制器或者顯卡帶的,不管哪種你可以把DMA控制器再一次看作另外一台機器,那麼其實就是向DMA控制器寫指令讓它幫你傳一些東西到顯存去,傳的這些東西就是顯卡要執行的命令和數據。顯卡上的內存控制器,原來AGP的時候叫GART,現在不知道叫啥名了,另外SoC裡面也有類似的概念,不過大多數SoC只有一個內存控制器,所以不分顯存和內存。

把顯卡想像成另外一台機器。它要工作,無非也是「程序存儲」原理,上電之後,從特定的內存(顯存)地址去取指,然後執行指令。顯卡的工作邏輯比CPU簡單多了,它一般就從一個環形buffer不斷的取指令,然後執行,CPU就不斷的去往環形buffer填指令。

很多時候同一個動作既可以用MMIO,也可以用DMA,比如flip framebuffer。只要把flip framebuffer的指令正確傳到環形buffer就好了。但是MMIO需要CPU參與,傳大數據的時候,打亂CPU GPU並行性,划不來。

驅動程序其實也是圍繞著這件事情來做的,Vista以前,顯卡的驅動全都是kernel mode執行的,因為只有kernel mode才能訪問的物理地址,但是kernel mode的壞處是一旦有問題,系統就崩潰,而且kernel mode有很多局限性,比如沒有C庫支持,浮點運算很難,代價很大等等。所以Vista之後,顯卡驅動都分兩部分,kmd負責需要訪問物理地址的動作,其他事情都放到umd去做,包括API支持等等。所以一個3D程序執行的過程是這樣的,app generate command, call D3D runtime,D3D runtime call driver umd, driver umd system call driver kmd, kmd send command to ring buffer, graphic card exeute.

至於顯卡驅動要完成什麼部分,這個就是所謂HAL(hardware abstraction layer)層,也就是說HAL以下由廠商提供,以上就是操作系統自帶,在HAL層以上,所有的操作都是統一的,比如畫一個點,畫一條線,驅動來對應具體的某一款晶元生成真正的命令,比如畫點,需要0x9指令,把絕對坐標放到地址0x12345678(舉例)。微軟管的比較寬,umd, kmd都有HAL層,意思是即使kmd你也不能亂寫,能統一的盡量統一,比如CPU GPU external fence讀寫同步機制就是微軟統一做的。

流處理器就是說,那些處理器可以執行很多的指令,而不是就幾個固定的功能,比如原來我把幾個矩陣的乘法固定成一個操作(比如TL單元),現在我把這個操作拆了,改成更基本的指令,比如,取矩陣元素,加乘,這樣更靈活。不過你就得多費心思去組合這些指令了,組合這些指令有個高大上的名字,shader。至於為什麼叫shader,越來越長了,不說了。


關於 Jet Chen 對Belleve的點評,我多說幾句。

1. B5在他的回答中,其實是提到了「地址線」的。
2. D3D 的 Driver 模型和Linux上以DRI為主的驅動模型不太一樣。但是我只做過User Mode部分的開發所以也說不多了。
3. 邏輯視圖上,對非UMA的架構來說,Graphics RAM的部分和System RAM的部分對於用戶視圖來說,是Isolated的。但是在物理視圖上,Jet Chen 和 谷俊 都提到了,GPU和CPU可能使用相同或不同的內存控制器,訪問相同的物理內存頁。GPU所訪問到的部分,可能是映射,也可能是拷貝。

------------------------------------

1. 是。
2. Windows上提供了DDK,你可以根據DDK上規定的API和約束來開發顯卡驅動。(具體來說是WDDM)然後OS就可以調用到你的驅動了。Linux上也有DRI之類的驅動程序介面。
3. 流處理器主要作用是計算,和CPU的ALU和FPU的作用是相同的。光柵化以及光柵化之前的三角形生成(Triangle Setup)和後面的插值、以及更後面的深度/Alpha/Stencil測試都是很特殊的操作,所以有專門的硬體來加速這一過程。
4. 現有硬體上不可以。未來的硬體可以。內存映射是匯流排的仲裁器(Arbitrator)和CPU的定址單元協同提供的功能。在硬體的某個地方保存了一張表,你訪問一個地址,硬體會查詢一下這個地址是GPU還是CPU的,如果是屬於GPU的,那就把數據或者請求發送過去。
5. 一般的體系結構知識可以參考《計算機組成原理》。儘管沒有專門說GPU,但是GPU也只是PCI-E匯流排上的一種普通設備而已。


在這裡回答一下第一個,第二個,第四個和第五個問題。
在回答這個問題之前,必須要有一些限定。因為顯卡是有很多種,顯卡所在平台也很多種,不能一概而論。我的回答都是基於Intel x86平台下的Intel自家的GEN顯示核心單元(也就是市面上的HD 4000什麼的)。操作系統大多數以Linux為例。

&>&>&> Q1. 顯卡驅動是怎麼控制顯卡的, 就是說, 使用那些指令控制顯卡, 通過埠么?

目前的顯卡驅動,不是單純的一個獨立的驅動模塊,而是幾個驅動模塊的集合。用戶態和內核態驅動都有。以Linux桌面系統為例,按照模塊劃分,內核驅動有drm/i915模塊, 用戶驅動包括libdrm, Xorg的DDX和DIX,3D的LibGL, Video的Libva等等,各個用戶態驅動可能相互依賴,相互協作,作用各不相同。限於篇幅無法一一介紹。如果按照功能劃分的話,大概分成5大類,display, 2D, 3D, video, 以及General Purpose Computing 通用計算。Display是關於如何顯示內容,比如解析度啊,刷新率啊,多屏顯示啊。2D現在用的很少了,基本就是畫點畫線加速,快速內存拷貝(也就是一種DMA)。3D就複雜了,基本現在2D的事兒也用3D干。3D涉及很多計算機圖形學的知識,我的短板,我就不多說了。Video是指硬體加速的視頻編解碼。通用計算就是對於OpenCL,OpenCV,CUDA這些框架的支持。

回到問題,驅動如何控制顯卡。
首先,操作硬體的動作是敏感動作,一般只有內核才有許可權。個別情況會由用戶態操作,但是也是通過內核建立寄存器映射才行。
理解驅動程序最重要的一句話是,寄存器是軟體控制硬體的唯一途徑。所以你問如何控制顯卡,答案就是靠讀寫顯卡提供的寄存器。
通過什麼讀寫呢?據我所知的目前的顯卡驅動,基本沒有用低效的埠IO的方式讀寫。現在都是通過MMIO把寄存器映射的內核地址空間,然後用內存訪問指令(也就是一般的C語言賦值語句)來訪問。具體可以參考」內核內存映射,MMIO「的相關資料。

&>&>&>Q2.2. DirectX 或 OpenGL 或 CUDA 或 OpenCL 怎麼找到顯卡驅動, 顯卡驅動是不是要為他們提供介面的實現, 如果是, 那麼DirectX和OpenGL和CUDA和OpenCL需要顯卡驅動提供的介面都是什麼, 這個文檔在哪能下載到? 如果不是, 那麼DirectX, OpenGL, CL, CUDA是怎麼控制顯卡的?

這個問題我僅僅針對OpenGL和OpenCL在Linux上的實現嘗試回答一下。
a.關於如何找到驅動?首先這裡我們要明確一下驅動程序是什麼,對於OpenGL來說,有個用戶態的庫叫做LibGL.so,這個就是OpenGL的用戶態驅動(也可以稱之為庫,但是一定會另外再依賴一個硬體相關的動態庫,這個就是更狹義的驅動),直接對應用程序提供API。同樣,OpenCL,也有一個LibCL.so.。這些so文件都依賴下層更底層的用戶態驅動作為支持(在Linux下,顯卡相關的驅動,一般是一個通用層驅動.so文件提供API,然後下面接一個平台相關的.so文件提供對應的硬體支持。比如LibVA.so提供視頻加速的API,i965_video_drv.so是他的後端,提供Intel平台對應libva的硬體加速的實現)。 下面給你一張大圖:

如圖可見,最上層的用戶態驅動向下依賴很多設備相關的驅動,最後回到Libdrm這層,這一層是內核和用戶態的臨界。一般在這裡,想用顯卡的程序會open一個/dev/dri/card0的設備節點,這個節點是由顯卡內核驅動創建的。當然這個open的動作不是由應用程序直接進行的,通常會使用一些富足函數,比如drmOpenByName, drmOpenByBusID. 在此之前還會有一些查詢的操作,查詢板卡的名稱或者Bus ID。然後調用對應的輔助函數打開設備節點。打開之後,他就可以根據DRI的規範來使用顯卡的功能。我說的這一切都是有規範的,在Linux里叫DRI(Direct Rendering Infrastructure)。
所有這些圖片文檔都可以Direct Rendering Infrastructure 和 freedesktop上的頁面DRI wiki找到DRI Wiki
顯卡驅動的結構很複雜,這裡有設計原因也有歷史原因。
b.關於介面的定義,源代碼都可以在我上面提供的鏈接里找到。這一套是規範,有協議的。
c.OpenGL, OpenCL或者LibVA之類的需要顯卡提供點陣運算,通用計算,或者編解碼服務的驅動程序,一般都是通過兩種途徑操作顯卡。第一個是使用DRM提供的ioctl機制,就是系統調用。這類操作一般包括申請釋放顯存對象,映射顯存對象,執行GPU指令等等。另一種是用戶態驅動把用戶的API語意翻譯成為一組GPU指令,然後在內核驅動的幫助下(就是第一種的執行GPU指令的ioctl)把指令下達給GPU做運算。具體細節就不多說了,這些可以通過閱讀源代碼獲得。

&>&>&>Q4. 顯卡 ( 或其他設備 ) 可以訪問內存么? 內存地址映射的原理是什麼, 為什麼 B8000H 到 C7FFFH 是顯存的地址, 向這個地址空間寫入數據後, 是直接通過匯流排寫入顯存了么, 還是依然寫在內存中, 顯卡到內存中讀取, 如果直接寫到顯存了, 會出現延時和等待么?

a..可以訪問內存。如果訪問不了,那顯示的東西是從哪兒來的呢?你在硬碟的一部A片,總不能自己放到顯卡里解碼渲染吧?
b.顯卡訪問內存,3種主要方式。
第一種,就是framebuffer。CPU搞一塊內存名叫Framebuffer,裡面放上要顯示的東西,顯卡有個部件叫DIsplay Controller會掃描那塊內存,然後把內容顯示到屏幕上。至於具體如何配置成功的,Long story, 這裡不細說了。
第二種,DMA。DMA懂吧?就是硬體設備直接從內存取數據,當然需要軟體先配置,這就是graphics driver的活兒。在顯卡驅動里,DMA還有個專用的名字叫Blit。
第三種,內存共享。Intel的平台,顯存和內存本質都是主存。區別是CPU用的需要MMU映射,GPU用的需要GPU的MMU叫做GTT映射。所以共享內存的方法很簡單,把同一個物理頁既填到MMU頁表裡,也填到GTT頁表裡。具體細節和原理,依照每個人的基礎不同,需要看的文檔不同。。。
c.為什麼是那個固定地址?這個地址學名叫做Aperture空間,就是為了吧顯存映射到一個段連續的物理空間。為什麼要映射,就是為了顯卡可以連續訪問一段地址。因為內存是分頁的,但是硬體經常需要連續的頁。其實還有一個更重要的原因是為了解決叫做tiling的關於圖形內存存儲形勢和不同內存不一致的問題(這個太專業了對於一般人來說)。
這地址的起始地址是平台相關,PC平台一般由固件(BIOS之流)統籌規劃匯流排地址空間之後為顯卡特別劃分一塊。地址區間的大小一般也可以在固件里指定或者配置。
另外,還有一類地址也是高位固定劃分的稱為stolen memory,這個是x86平台都有的,就是竊取一塊物理內存專門為最基本的圖形輸出使用,比如終端字元顯示,framebuffer。起始地址也是固件決定,大小有默認值也可以配置。
d. 剛才說了,Intel的顯存內存一回事兒。至於獨立顯卡有獨立顯存的平台來回答你這個問題是這樣的:任何訪存都是通過匯流排的,直接寫也是通過匯流排寫,拷貝也是通過匯流排拷貝;有時候需要先寫入臨時內存再拷貝一遍到目標區域,原因很多種;寫操作都是通過PCI匯流排都有延遲,寫誰都有。匯流排就是各個設備共享的資源,需要仲裁之類的機制,肯定有時候要等一下。

&>&>&>Q5. 以上這些知識從哪些書籍上可以獲得?

Intel Graphics for Linux*, 從這裡看起吧少年。這類過於專業的知識,不建議在一般經驗交流的平台求助,很難得到準確的答案。你這類問題,需要的就是準確答案。不然會把本來就不容易理解的問題變得更複雜。


樓上說了很多了,補充點具體的,以前 DOS下做遊戲,操作系統除了磁碟和文件管理外基本不管事情,所有遊戲都是直接操作顯卡和音效卡的,用不了什麼驅動。

雖然沒有驅動,但是硬體標準還是放在那裡,VGA, SVGA, VESA, VESA2.0 之類的硬體標準,最起碼,你只做320x200x256c的遊戲,或者 ModeX 下 320x240x256c 的遊戲的話,需要用到VGA和部分 SVGA標準,而要做真彩高彩,更高解析度的遊戲的話,就必須掌握 VESA的各項規範了。

翻幾段以前寫的代碼演示下:

例子1: 初始化 VGA/VESA 顯示模式

基本是參考 VGA的編程手冊來做:

INT 10,0 - Set Video Mode
AH = 00
AL = 00 40x25 B/W text (CGA,EGA,MCGA,VGA)
= 01 40x25 16 color text (CGA,EGA,MCGA,VGA)
= 02 80x25 16 shades of gray text (CGA,EGA,MCGA,VGA)
= 03 80x25 16 color text (CGA,EGA,MCGA,VGA)
= 04 320x200 4 color graphics (CGA,EGA,MCGA,VGA)
= 05 320x200 4 color graphics (CGA,EGA,MCGA,VGA)
= 06 640x200 B/W graphics (CGA,EGA,MCGA,VGA)
= 07 80x25 Monochrome text (MDA,HERC,EGA,VGA)
= 08 160x200 16 color graphics (PCjr)
= 09 320x200 16 color graphics (PCjr)
= 0A 640x200 4 color graphics (PCjr)
= 0B Reserved (EGA BIOS function 11)
= 0C Reserved (EGA BIOS function 11)
= 0D 320x200 16 color graphics (EGA,VGA)
= 0E 640x200 16 color graphics (EGA,VGA)
= 0F 640x350 Monochrome graphics (EGA,VGA)
= 10 640x350 16 color graphics (EGA or VGA with 128K)
640x350 4 color graphics (64K EGA)
= 11 640x480 B/W graphics (MCGA,VGA)
= 12 640x480 16 color graphics (VGA)
= 13 320x200 256 color graphics (MCGA,VGA)
= 8x EGA, MCGA or VGA ignore bit 7, see below
= 9x EGA, MCGA or VGA ignore bit 7, see below

- if AL bit 7=1, prevents EGA,MCGA VGA from clearing display
- function updates byte at 40:49; bit 7 of byte 40:87
(EGA/VGA Display Data Area) is set to the value of AL bit 7

轉換成代碼的話,類似這樣:

// enter standard graphic mode
int display_enter_graph(int mode)
{
short hr = 0;
union REGS r;
memset(r, 0, sizeof(r));
if (mode &< 0x100) { r.w.ax = (short)mode; int386(0x10, r, r); r.h.ah = 0xf; int386(0x10, r, r); if (r.h.al != mode) hr = -1; } else { r.w.ax = 0x4f02; r.w.bx = (short)mode; int386(0x10, r, r); if (r.w.ax != 0x004f) hr = -1; } return hr; }

基本就是通過中斷指令,調用 INT 0x10的 0x00 方法,初始化VGA顯示模式,如果模式號大於256,那麼說明是一個 VESA顯示模式,調用 VESA的中斷函數來進行。

例子2: 畫點

如果你初始化成功了 320 x 200 x 256 c 模式(INT 0x10, AX=0x13),那麼畫點就是象顯存地址 0xA00000L 裡面寫一個位元組(8位色彩深度):

我們使用 DOSBOX (DOS開發調試神器)來演示,啟動 DOSBOX以後,運行

debug

然後寫兩條進入圖形模式的指令:

mov ax, 13 ; 設置 ah=0(0號函數上面有說明), al=0x13(0x13模式,320x200)
int 10 ; 調用顯卡中斷
int 20 ; DOS命令:退出程序

輸入空行後退出編輯模式,然後使用 "g" 命令運行剛才的這個小程序:

可以看到,顯示模式初始化成功了,現在你已經進入了 320x200x256c的顯示模式,大量的 DOS遊戲是使用這個模式開發出來的(仙劍奇俠傳,軒轅劍1/2,CC)。

接下來我們編輯顯存,使用 e命令,進行內存編輯(0xa00000L),注意這裡我們還是實模式,顯存需要拆分成段地址:0xa000,和偏移0000 來訪問:

-e a000:0000

用了e 命令,寫入了一連串位元組,值都是「4」,點擊放大上面的窗口,可以看到左上角已經被我寫了幾個點了,默認調色板下顏色 「4」 是紅色。

接著在 A000:0300處(坐標第3行第128列)寫入更多顏色,這次更明顯些,注意上面中間:

放大些:

這次寫入了更多顏色,而且是在第三行中間部分,沒挨著dosbox的窗口邊緣,看起來更清晰了,是吧?好了,有了上面的試驗後,我們可以寫代碼了,大概類似這樣:

void putpixel(int x, int y, unsigned char color)
{
static unsigned char far *videobuf = (unsigned char far*)0xa0000000;
if (x &>= 0 y &>= 0 x &< 320 y &< 200) { videobuf[y * 320 + x] = color; } }

上面代碼可用 TurboC++2.0, Borland C++ 3.1, TurboC2 來編譯,當然,當年這麼寫是不行的,硬體慢的要死,各種 trick當然是能上則上,正確的寫法是:

void putpixel(int x, int y, unsigned char color)
{
static unsigned char far *videobuf = (unsigned char far*)0xa0000000;
if (((unsigned)x) &< 320 ((unsigned)y) &< 200) { videobuf[(y &<&< 6) + (unsigned)(y &<&< 8) + x] = color; } }

優化了兩處,範圍判斷改用 unsigned以後,少了兩次 &>= 0的判斷,同時乘法變成了移位和加法,舊式 cpu計算乘法總是那麼慢。有了畫點,寫一個畫線畫圓畫矩形就容易了,再返照寫一個圖塊拷貝(BitBlt)也很容易,有了這些,應該夠開發一個傳統遊戲。

舊遊戲裡繪製一般都是在系統內存中進行的,在內存中開闢一塊模仿顯存的區域,進行畫點畫線,貼圖,繪製好以後,一個memcpy,直接拷貝到顯存。但實模式下線性地址只有64KB,可用總內存只有差不多640KB,要存儲大量的圖元是很困難的,稍不注意就內存不夠了,因此 DOS下開發遊戲,最好都是上 Watcom C++ 或者 Djgpp(dos下的gcc導出)。

Watcom C++ 可以在dos下實現4g內存訪問,現在可以下載 OpenWatcom 來編譯,我不太喜歡 Djgpp,編譯太慢,加上一大堆著名遊戲都是 Watcom C++寫成的,導致我更加鄙視 Djgpp 因此我之前主要是在 Watcom C++下開發,除去上面的畫點外,後面翻到的代碼片段基本都是 Watcom C++的。

例子3:設置調色板

看到這裡,也許你不禁要發問:除了直接寫顯存外,好像各種初始化工作都是調用 BIOS 里預先設置好的 INT 10h中斷來完成啊,這 INT 10h 又是具體怎麼操作顯卡的呢?

其實 INT10h 也可以畫點(AH=0C, AL=顏色, BH=0, DX=縱坐標,CX=橫坐標),BIOS的 INT 10h中畫點實現其實也是直接寫顯存,但是執行的很慢,基本沒人這麼用,都是直接寫顯存的,操作顯卡除了訪問顯存外,有些功能還需要訪問埠來實現。

接下來以初始化調色盤為例,256色同屏每個點只有0-255的調色板索引,具體顯示什麼顏色需要查找一個:256 x 3 = 768 位元組的調色板(每個索引3個位元組:RGB)。設置一個顏色的調色盤需要先向 0x03c8埠寫入顏色編號,接著在 0x03c9埠依次寫入R,G,B三個分量的具體數值,具體指令為:

mov edx, 0x03c7
mov al, color
out dx, al
inc dx
mov al, R
out dx, al
mov al, G
out dx, al
mov al, B
out dx, al

我們可以使用 Watcom C++ 的 outp 函數來實現 out指令調用:

void display_set_palette(unsigned char color, char r, char g, char b)
{
short port = 0x03c8;
outp(port, color);
port++;
outp(port, r);
outp(port, g);
outp(port, b);
}

這個例子用到的是 OUT指令寫埠,x86架構下 OUT 可以向特定埠寫入數據,埠你可以理解為和數據匯流排並立的另外一個 I/O 控制匯流排,通過北橋南橋映射到各個硬體的 I/O 數據引腳,x86 下通過埠可以方便的操作顯卡,軟盤,硬碟,8254計時器,鍵盤緩存,DMA 控制器等周邊硬體,後面我們還會頻繁使用。

而其他硬體下,並沒有埠這樣一個控制匯流排存在,那 GBA / NDS 里沒有埠這樣的存在,他們是用何種方法訪問各種外設呢?答案是內存地址映射,GBA / NDS 下有一段低端內存地址被映射給了 I/O RAM,通過直接讀寫這些地址,就可以跟x86的 out/in 指令一樣控制各硬體,完成:顯示模式設置,顯示對象,圖層,時鐘,DMA,手柄,音樂等控制。

好了,現可以通過埠讀寫調色板了,我們調用一下上面這個函數:display_set_palette (4, 0, 63, 0); 就可以把上面通過直寫顯存畫在左上角的一串紅色點(顏色4)改變成綠色而不用重新寫顯存了,注意,vga的調色盤是 6位的,RGB的最大亮度為63。

傳統256色的遊戲中需要正確設置調色板才能讓圖像看起來正確,否則就是花的,使用調色板還有很多用法,比如遊戲中常見的 fade in / fade out 效果,就是定時,每次把所有顏色的調色板讀取出來,R,G,B各-1,然後保存回去,就淡出了,還有一些類似調色板流動等用法,可以很方便的製作波浪流動(圖片不變,只改變調色板),傳統遊戲中用它來控制海水效果,不需要每次重繪,比如老遊戲中天上閃爍的星星,大部分都是調色板變動一下。

例子4:初始化 ModeX

傳統標準顯卡有三種顯示模式:VGA模式(模式號低於256),VESA模式(模式號高於256,提供更高解析度,真彩高彩等顯示模式,以及線性地址訪問)此外還有著名的 ModeX,Michael Abrash 提出 ModeX 以後,由於對比其他標準模式具備更好的色彩填充性能,因此在不少遊戲中也得到了廣泛使用,但是其初始化非常的 trick,主體代碼為:

outpw(0x3C4, 0x0100); /* synchronous reset */
outp(0x3D4, 0x11); /* enable crtc regs 0-7 */
outp(0x3D5, inp(0x3D5) 0x7F);
outpw(0x3C4, 0x0604); /* disable chain-4 */

for (reg=mode-&>regs; reg-&>port; reg++) { /* set the VGA registers */
if (reg-&>port == 0x3C0) {
inp(0x3DA);
outp(0x3C0, reg-&>index | 0x20);
outp(0x3C0, reg-&>value);
}
else if (reg-&>port == 0x3C2) {
outp(reg-&>port, reg-&>value);
}
else {
outp(reg-&>port, reg-&>index);
outp(reg-&>port + 1, reg-&>value);
}
}

if (mode-&>hrs) {
outp(0x3D4, 0x11); outp(0x3D5, inp(0x3D5) 0x7F);
outp(0x3D4, 0x04); outp(0x3D5, inp(0x3D5) + mode-&>hrs);
outp(0x3D4, 0x11); outp(0x3D5, inp(0x3D5) | 0x80);
}

if (mode-&>shift) {
outp(0x3CE, 0x05);
outp(0x3CF, (inp(0x3CF) 0x60) | 0x40);
inp(0x3DA);
outp(0x3C0, 0x30);
outp(0x3C0, inp(0x3C1) | 0x40);
for (c=0; c&<16; c++) { outp(0x3C0, c); outp(0x3C0, c); } outp(0x3C0, 0x20); } if (mode-&>repeat) {
outp(0x3D4, 0x09);
outp(0x3D5, (inp(0x3D5) 0x60) | mode-&>repeat);
}
outp(0x3D4, 0x13); /* set scanline length */
outp(0x3D5, width / 8);
outpw(0x3C4, 0x0300); /* restart sequencer */

是不是有點天書的感覺?直接控制硬體就是這麼瑣碎,前面初始化顯示模式都是用 int 10h中斷完成的,其實 int 10h中斷本身也是通過各種寫埠來重置垂直掃描頻率,水平掃描頻率,顯存映射方式,開始地址等達到具體設置某一個解析度的目的,也就是說其實你可以繞開int 10h用自己的方式設置出一個新的顯示模式來,ModeX 就是這樣初始化的。

不得不說一句今天有驅動程序真幸福,費了我九牛二虎之力才初始化成功的 ModeX,今天一個函數調用就完成了,大家也發現了使用 int 10h中斷,調用 BIOS 裡面的預設程序控制顯卡,只是初級用法,現在基本只用在 grub 等操作系統載入程序上了,進入了操作系統後,就再也不會調用 int 10h,而是赤裸裸的直接和顯卡打交到。

例子5:顯存分段映射

早期顯卡的顯存只能按 64KB 大小分成若干個 bank 來映射到特定的物理地址,也就是說你使用 640 x 480 x 32bits 的顯示模式時,全屏幕總共需要 1200KB 的顯存來表示屏幕上面的每一個點,而由於顯存每次只能分段映射一個 64KB 大小的 bank,所以每次寫屏前都要把對應位置的顯存先映射過來才能寫,我們使用下面代碼來切換 bank:

int display_vesa_switch(int window, int bank)
{
union REGS r;
r.x.eax = 0x4f05;
r.x.ebx = window;
r.x.edx = bank;
int386(0x10, r, r);
return 0;
}

注意這裡還有個窗口概念,一個窗口可以映射一個bank,大部分顯卡只有一個窗口,則只能同時映射一段64KB的顯存給cpu訪問,而有的顯卡有兩個窗口(一個讀,一個寫)。

這個是標準做法,訪問中斷很慢,在繪製過程中頻繁的訪問中斷是要命的,故 Trident 系列的顯卡提供直接訪問埠的方法來切換頁面(Trident只有一個窗口,同時只能映射一個64KB的bank):

int display_trident_switch(unsigned char bank)
{
outp(0x3c4, 0x0e);
outp(0x3c5, bank ^ 0x2);
return 0;
}

而如果你使用支持 VESA2.0 標準的顯卡,在保護模式下,VESA2的介面提供了一系列函數入口供你調用,你可以直接在 Watcom C++ 下面調用這些函數完成頁面切換,比調用中斷的開銷小多了。

聽起來十分美妙,但是你想導出這些函數的入口地址來調用的話,你將需要:

1. 分配一塊物理內存並鎖定地址。
2. 調用vesa中斷,向這個物理地址寫入這些函數的代碼。
3. 為該物理內存分配一個 selector 段地址,才能讀取這些代碼並拷貝到 Watcom C++默認段。
4. 按照入口表,初始化 Watcom C++ 裡面的函數指針,並釋放物理內存。
5. 然後你才可以開心的調用這些函數。

這個函數表,可以理解成就是 VESA 2.0 的一個初級階段的驅動程序了。

簡單一個頁面切換,上面就提到了三種做法,你可以選擇最保險也是性能最差的中斷調用,也可以根據顯卡支持選擇寫埠或者直接調用導出函數。

好在遊戲基本都是二級緩存來繪製的,主要的繪製工作在系統內存的二級緩存裡面完成,最後只需要在 memcpy搬運到顯存顯示出來的時候,再去設置頁面映射,然後整個 bank一次性拷貝,然後再切換到下一個 bank,這樣「設置頁面映射」 這個操作的調用次數就會比較少了。

這就是早年訪問多於 64KB 顯存的基本方法,多用在解析度超過 320x200 的模式中,如果你繼續使用流行的 320x200 顯示模式,你將不需要考慮這個事情,因為全屏幕只需要 62.5 KB的顯存,沒有切換 BANK 的需要。

但是早期缺乏統一的編程介面,今天這個顯卡擴充一點功能,明天那個品牌又多兩個效果,弄得你疲於奔命,因此 Windows 以後,這些工作都統一交給顯卡驅動來完成了。

例子6:線性顯存映射

由於解析度越來越高,越來越多的軟體用到了 640x480x256以上的顯示模式,傳統的 bank 映射方式已經顯得越來越落後了,因此90年代中期的顯卡紛紛開始支持 VESA 2.0 中的 「線性地址映射」,通過一些列初始化工作,將顯示模式設置為 「線性地址」,這樣在保護模式下,你就可以一整塊的訪問連續顯存而用不著切換 bank了。

這是一種十分簡單高效的方式,只要你的遊戲用 Watcom C++ / Djgpp 開發,跑在保護模式下,這可以說是最美妙方方式了,可惜,當年並不是所有顯卡都支持這樣的方式,碰到不兼容的顯卡你還得繞回去使用 bank 切換。

所以為了在 640x480x256c 下面正確繪製圖形,一共有四種顯存訪問方式(bank切換3種+線性地址映射),應用程序寫的好的話,需要把訪問顯存統一封裝一下,並提供類似這樣的介面:

//---------------------------------------------------------------------
// Framebuffer Access
//---------------------------------------------------------------------

// copy rect from memory to video frame buffer
void display_bits_set(int sx, int sy, const void *src, long pitch,
int x, int y, int w, int h);

// get rect from frame buffer to memory
void display_bits_get(int sx, int sy, void *dst, long pitch,
int x, int y, int w, int h);

// write row to video frame buffer
void display_row_write(int x, int y, const void *buffer, int npixels);

// read row from video frame buffer
void display_row_read(int x, int y, void *buffer, int npixels);

背後則需要判斷顯卡的特性,普通顯卡使用兼容性最好的的方式,而好點的顯卡使用更快速的方式,並為上層提供統一的訪問 framebuffer 的介面,由於早年的 C++ 編譯器優化十分有限,這部分基本都是上千行的彙編代碼直接實現,於是你又得捧起486、奔騰優化手冊來,慢慢調試一點點計算 u,v 流水線的開銷並安排好指令讓它們最大程度並行執行。

一直到了 DirectX 時代,整個事情才簡單了很多,微軟一句話,所有 DirectX 兼容顯卡必須支持線性地址映射,因此 DirectX 下面 Lock 一個 Surface 後可以毫無拘束連續訪問顯存,這樣一個簡單的操作,對比前面的實現,簡直是一件十分幸福的事情。

例子7:DMA控制器訪問

CPU對大部分基礎周邊設備,都是通過寫內存或埠I/O來控制的,比如前面很多顯卡控制,比如操作 8254為CPU提供時鐘中,比如操作 8237 DMA控制器來實現直接內存訪問。

比如使用 DMA CHANNEL 1 傳送數據到周邊設備的代碼類似:

outp(0x0a, 0x05); // 禁用 dma channel 1
outp(0x0b, 0x45); // 設置讀取模式
outp(0x0c, 0); // 準備設置地址
outp(0x02, addr 0xff); // 物理地址第一個位元組
outp(0x02, (addr &>&> 8) 0xff); // 物理地址第二個位元組
outp(0x83, (addr &>&> 16) 0xff); // 物理地址第三個位元組
outp(0x0c, 0); // 結束設置地址
outp(0x03, (size - 1) 0xff); // 設置長度
outp(0x03, ((size - 1) &>&> 8) 0xff); // 設置長度高位
outp(0x0a, 0x01); // 開始傳送 channel 1

傳統 DMA控制器有 8個通道式對應不同周邊設備,且只能訪問低端16MB地址空間,保護模下的程序需要在16MB的空間內分配連續的物理內存頁面,並映射到當前的進程默認地址空間中,傳輸 DMA時需要將物理地址(非虛擬地址)傳送給 DMA 控制器。

具體埠代表的意思,一般是需要查看硬體手冊,並按說明調用。早期部分顯卡已經開始支持 DMA傳送數據,同時也支持顯存傳送,PC中 DMA傳送數據未必有 CPU快,但是卻能和 CPU保持非同步,解放 CPU 去做更多的事情。

不同的 DMA 通道對應不同的外設,比如硬碟,音效卡,顯卡,傳輸是否完成可以通過中斷的方式或者 CPU 查詢 DMA控制器埠得知,DMA控制器會用類似鎖存器的方式保證你分兩次讀出的高低位元組表示同一個變數。

標準 PC/DMA 無法實現 內存-&> 內存的 DMA 非同步傳輸,用起來沒 ARM 的 DMA 那麼爽,比如 GBA / NDS 下有非同步 memcpy 函數 DmaArrayCopy,原理是在特定的物理地址(被映射成 I/O RAM部分)寫入數據: *((u32*)0x40000D4) 寫入源地址,*((u32*)0x40000D8) 寫入目標地址,*((u32*)0x40000DC) 寫入長度後,非同步拷貝就開始了,十分飄逸。

連續通過 DMA傳輸數據的話,一般需要開闢雙緩存,一塊傳輸著,一塊準備著,交替進行。

----------------------
Windows後,不能讓我讀寫埠曾讓我鬱悶了很久,但時代變遷,看著今天各種規範的 API 介面,統一的硬體規範,對比以前繁瑣的實現,突然有種淡淡的幸福。

先寫這麼多吧,主要是上補充下其他答案,提供一點具體的感受,順便也和大家一起懷舊一下。

--


好多啊……我拋磚吧……(我感覺馮東會來回答這個問題……但我一向不認同他在這些問題下的回答。)
(話說回來,我的回答也不太靠譜,很多是靠記憶的,因為這些知識不是在一本書內看到的,而是看了很多書融會貫通得來的。)
首先聲明,這裡只討論PC!

  1. 先靠讀寫I/O地址空間與顯卡溝通,切換顯卡的模式,並為顯卡設定一個地址映射,將顯存地址映射到內存的物理地址上。接下來的就是long-term的顯卡控制了,是通過改寫顯存以及繼續讀寫I/O地址實現。
  2. 顯卡驅動需要實現這些介面。OpenGL的底層介面我從未了解過,也許馮東比較清楚。我說一下DirectX的。DirectX是通過COM控制項來暴露介面的,也就是說API其實就是一些函數地址(對應著C++的虛函數),顯卡驅動廠商需要實現Direct3D的COM對象叫做Direct3D設備對象(這方面的記憶比較模糊,也許是錯的。待我再確認一下才好取信)。假如的確是這樣,那麼直接看D3D文檔即可。驅動本身與用戶態的COM控制項可以通過內存映射或者IRP的擴展內存或者管道通信,至於最佳實踐是怎樣的就不知道了。
  3. 流處理器就是GPU上的處理核心,因為GPU是大規模並行化、超長流水線的,所以有很多的處理核心,每個處理核心就是一個流處理器(按照解釋方式的不同,有時候每個處理核心有多個流處理器、有時候多個處理核心被稱為是一個流處理器)。光柵單元用於將空間坐標繫上的頂點數據換算到平面坐標繫上,不過過程可能跟你想像的不太一樣,基本上真正的工作在於確定z-index。紋理單元用於貼圖。
  4. 顯卡不能訪問內存,但是CPU通過前面說的內存映射可以訪問顯存。當然更直接的做法是利用I/O空間。內存與CPU、顯存、顯卡的數據交互都需要通過數據匯流排來間接進行,這就是內存映射以及B8000H開始的那段空間的映射的原理。每次匯流排進行數據交換時你都必須確保匯流排上的地址是你想訪問的地址(不過這是硬體工程師考慮的事,連繫統編程人員都不需要深入了解),這個地址可能對應顯存、對應I/O空間或者對應內存等等。所以向B8000H開頭的那段內存物理地址寫入時直接寫到了顯存中。關於延時和等待這是一個偽命題,因為讀寫內存也存在延時和等待。十年前Intel處理器在讀寫內存時每次大約等待140納秒,現在的水平是30多納秒(考慮到CPU的時鐘周期是0.5納秒左右,這個延時是相當長的)。所以答案是,是的,寫顯存時會延時,而且一般比寫內存的延時要大的多。
  5. 關於在哪些書籍可以獲得這些知識,當務之急是學通一本《微機原理》。在圖書館你可以看到多達幾十種的各種名叫《微機原理》的書……

顯卡雖然看起來干很多很複雜的工作,可是真正與CPU打交道的功能並不多。就是設置一些狀態,同步一下,DMA下內存。驅動把複雜的工作比如shader指令等翻譯為顯卡的指令,然後再把指令流和數據流傳給顯卡,可能只是傳個地址,也可能需要dma到顯存。然後顯卡就是根據指令流不斷的執行就好了。


上面的答主都是從軟體方面在說,我試著從另外個角度說說
由於沒接觸過中文材料,可能有些用詞不準確,見諒

5. 以上這些知識從哪些書籍上可以獲得?
題主應該是那種硬體發燒友,但是對真正的系統原理不是很了解。建議看到書有(都是大學教材級別)

  • Digital system 數字系統

他會回答你內存映射、DMA、匯流排原理等

  • Operating System 操作系統

操作系統會解決關於驅動程序以及硬體控制、內存管理的問題

想了解PCIe的話可以讀PCI Express Base Specification

再說說第1個問題
1. 顯卡驅動是怎麼控制顯卡的, 就是說, 使用那些指令控制顯卡, 通過埠么?
高票答主提到了

理解驅動程序最重要的一句話是,寄存器是軟體控制硬體的唯一途徑。所以你問如何控制顯卡,答案就是靠讀寫顯卡提供的寄存器。

這是正確的,我就從更底層說說。

現在的顯卡都是通過PCIe連接。PCIe是一個分為3層的串列硬體協議,從下而上分別是

  • 物理以及MAC層(PHY/MAC)

控制基礎的電信息,比如emphasize/deemphasize,link training以及128/130b轉碼等等。最底層是1~16條全雙工差分對。兩個PCIe設備之間的連接就是在這一層最底下。

  • 數據鏈路層(Data Link Layer)

主要控制數據鏈,包括接受緩存的大小、流控制、重複緩存(replay buffer),Ack/Nak等

  • 交換層 (Transaction Layer)

這一層主要是和應用層交換數據

操作系統的最底層就是負責和交換層聯繫。這個就是題主問的介面了。操作系統通過寫入數據到CPU的專用寄存器(Intel的QPI匯流排格式),CPU通過這個信息產生TLP(Transaction Layer Packet,應該叫交換層數據包吧)通過PCIe匯流排發送到顯卡。

顯卡會接著告訴CPU,我收沒收到正確的信息(Ack/Nak),如果沒有的話,Replay Buffer(重複緩存)會重新發送這個包。接受到有效的包之後顯卡就會做出相應的操作。相應的操作就大致包含了

  • CfgWr (配置空間寫)
  • CfgRd (配置空間讀)
  • MemWr (內存寫)
  • MemRd (內存讀)
  • Cpl (完成)
  • CplD (帶數據的完成)
  • etc..

當操作完成的時候顯卡會發送Cpl或者CplD回CPU,從而告訴CPU這個操作我已經做完了。需要數據的操作例如MemRd會讓顯卡返回CplD,反之不需要數據的操作例如CfgWr會返回Cpl。

這就是怎麼控制顯卡。

至於寄存器的細節,題主有興趣的話再寫吧


顯卡就是一個內存條。把要畫的數據寫進顯存,顯示器就把它們畫出來了。

學過彙編的都差不多知道,顯存是某個地址區域,寫進數據就在dos上顯示顏色和文字。


第五個問題。pci spec,可以部分解釋地址空間相關問題。顯卡spec,可以解決絕大多數問題,據amd,intel都有公開,google一把即可。


推薦閱讀:

如何把一款單機遊戲做的讓人覺得並沒有在玩單機?
做遊戲學啥?
為什麼市面上沒有出現玩法類似《精靈寶可夢》遊戲?
IT 開發人員工作三年後如何規劃自己的職業生涯?
如何評價 2017 年國內遊戲行業的發展?

TAG:遊戲開發 | C編程語言 | 彙編語言 | 圖形處理器GPU |