# 解析 Linux 共享記憶體機制
> 本文改寫自 [宋寶華:世上最好的共享記憶體](https://mp.weixin.qq.com/s?__biz=MzAwMDUwNDgxOA==&mid=2652666589&idx=1&sn=15c6d8a7380d4bce6ef85a91a1c274c5)
> 內容改寫和補充: [jserv](https://wiki.csie.ncku.edu.tw/User/jserv)
共享經濟崛起,各式「共享」詞彙猶如雨後春筍,舉凡共享單車、共享行動電源、共享雨傘等等,世間的「共享」千萬種,筆者唯獨鍾情於共享記憶體 (shared memory)。
早期的共享記憶體,著重於同一區域的主記憶體映射到多個行程 (process) 所處的虛擬定址空間(在相應行程找到一個 [virtual memory area](https://elinux.org/images/b/b0/Introduction_to_Memory_Management_in_Linux.pdf)),以便 CPU 在各個行程存取到這區域的主記憶體。
```graphviz
digraph D {
A [label="行程 A"]
B [label="行程 B"]
C [label="行程 C"]
D [label="行程 D"]
S [label="共享記憶體" shape="rectangle"]
S -> A [label="VMA "];
S -> B [label="VMA "];
S -> C [label="VMA "];
S -> D [label="VMA"];
}
```
現階段廣泛應用在多媒體或是圖形處理的記憶體共享方式,已不再強調映射到行程虛擬定址空間的概念,而更強調以某種**控制代碼** ([handle](https://en.wikipedia.org/wiki/Handle_(computing))) 的形式,讓資源使用者得知某段影音或是圖片資料的存在,並藉由這個**控制代碼**來橫跨各行程存取主記憶體。因此,不同行程所用的加速硬體其實不同,他們更在乎的是可經由一個控制代碼拿到這個區域的主記憶體,而不再去關注 CPU 存取的虛擬位址 (當然仍可映射到行程的虛擬定址空間,供 CPU 存取)。
```graphviz
digraph D {
A [label="行程 A"]
B [label="行程 B"]
C [label="行程 C"]
S [label="共享記憶體" shape="rectangle"]
A -> S [label="handle "];
B -> S [label=" handle "];
C -> S [label=" handle"];
}
```
只要主記憶體的複製 (即 `memcpy`) 仍佔據大量主記憶體頻寬及 CPU 資源,共享記憶體就是作為 Linux 行程間、電腦系統裡各個不同硬體間溝通最有效的方法。至於複製主記憶體會多大程度地佔用 CPU 資源,讀者可嘗試觀察在複製及播放 1080P 60 fps 影片的過程,系統 CPU 使用率飆升的狀況。
共享記憶體的機制玲瑯滿目,現行主流的方式有
* System V shared memory,如 [shmget](http://man7.org/linux/man-pages/man2/shmget.2.html), [shmat](http://man7.org/linux/man-pages/man2/shmop.2.html)
* [POSIX mmap shared memory](http://man7.org/linux/man-pages/man7/shm_overview.7.html)
* 透過 [memfd_create](http://man7.org/linux/man-pages/man2/memfd_create.2.html) 和 file descriptor 進行跨行程共享特定記憶體區域
* 廣泛用於多媒體、影像處理的 [dma-buf](https://01.org/linuxgraphics/gfx-docs/drm/driver-api/dma-buf.html)
## SysV 共享記憶體
SysV 是 [UNIX System V](https://en.wikipedia.org/wiki/UNIX_System_V) 的簡稱,因此該讀作 "System Five" 或 "Sys Five",在 1983 年首度由 AT&T 提出,其中包含共享記憶體機制。至今 SysV shared memory 年代久遠且使用繁瑣,後續已由 [POSIX mmap shared memory](http://man7.org/linux/man-pages/man7/shm_overview.7.html) 取代,但包含 Linux 在內的類似 UNIX 的作業系統仍提供相容的系統呼叫介面。Linux 對應的核心程式碼在 [ipc/shm.c](https://github.com/torvalds/linux/blob/master/ipc/shm.c)。若編譯 Linux 核心時沒開啟 `CONFIG_SYSVIPC` 選項,則系統不具備 SysV 共享記憶體。
![](https://i.imgur.com/xEbiDvd.png)
你在 Linux 終端機中輸入 `ipcs` 命令看到的 shared memory 就是這種共享記憶體:
```shell
$ ipcs
------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00190025 45 jserv 666 4194304 0
```
下面寫一個最簡單的程式來看共享記憶體負責寫入資料這端 `sw.c`: (`sw` 是 shared memory writer 的簡稱)
```cpp
#include <sys/shm.h>
#include <unistd.h>
#include <string.h>
int main(int argc, char **argv)
{
key_t key = ftok("/dev/shm/myshm", 0);
int shm_id = shmget(key, 0x400000, IPC_CREAT | 0666);
char *p = (char *) shmat(shm_id, NULL, 0);
memset(p, 'A', 0x400000);
shmdt(p);
return 0;
}
```
及讀取共享記憶體的彼端 `sr.c`: (`sr` 是 shared memory reader 的簡稱)
```cpp
#include <sys/shm.h>
#include <unistd.h>
#include <stdio.h>
int main(int argc, char **argv)
{
key_t key = ftok("/dev/shm/myshm", 0);
int shm_id = shmget(key, 0x400000, 0666);
char *p = (char *) shmat(shm_id, NULL, 0);
printf("%c %c %c %c\n", p[0], p[1], p[2], p[3]);
shmdt(p);
return 0;
}
```
編譯並執行
```bash
$ gcc -o sw sw.c
$ gcc -o sr sr.c
$ touch /dev/shm/myshm
```
我們先用 `free` 命令查看系統記憶體資訊:
![](https://i.imgur.com/rRIIVj7.png)
再執行 `sw` 和 `sr`:
```shell
$ ./sw
$ ./sr
A A A A
```
發現 `sr` 的輸出結果和 `sw` 寫進去的是一致的。這時再查看 `free`:
![](https://i.imgur.com/AbVm6Cp.png)
可以看到 `used` 明顯變大了 (711632 -> 715908),`shared` 從 2264 增長到 6360,`cached` 也從 326604 增長為 330716。
cached 這個欄位統計的是 file-backed pages 中 page cache 的空間。共享記憶體同屬於匿名分頁 (anonymous page),但由於這裡有個非常特殊的 tmpfs (`/dev/shm` 指向 `/run/shm`, `run/shm` 則掛載為 tmpfs):
```shell
$ file /dev/shm
/dev/shm: sticky, directory
$ mount | grep shm
tmpfs on /dev/shm type tmpfs (rw,nosuid,nodev)
```
匿名分頁缺乏真實檔案的對應,當進行記憶體交換時是與 swap (記憶體置換空間) 交換的。檔案系統裡面的內容在記憶體的副本是 file-backed 的分頁,所以不存在與 swap 置換的議題。但從上述實驗可見,**tmpfs 裡面的內容,被系統歸類於 page cache,卻又非真正的儲存硬體** —— 這與你讀寫硬碟 (或相似的儲存設備) 相對應檔案系統裡頭的各式檔案,過程中所產生的 page cache 有著顯著區別。
我們也可透過 `ipcs` 觀察新建立的 SysV 共享記憶體:
![](https://i.imgur.com/qhvPCNb.png)
## POSIX 共享記憶體
筆者對 POSIX `shm_open()`、`mmap()` API 系列的喜愛遠勝過 SysV 共享記憶體,後者裡頭的 `ftok`, `shmget`, `shmat`, `shmdt` 這類的 API 實在不直覺。
上面的程式若用 POSIX 共享記憶體改寫,更加簡潔 (檔名: `psw.c`):
```cpp
#include <sys/mman.h>
#include <unistd.h>
#include <fcntl.h> /* For O_* constants */
#include <string.h>
int main(int argc, char **argv)
{
int fd = shm_open("posixsm", O_CREAT | O_RDWR, 0666);
ftruncate(fd, 0x400000);
char *p = mmap(NULL, 0x400000,
PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
memset(p, 'A', 0x400000);
munmap(p, 0x400000);
return 0;
}
```
讀取 POSIX 共享記憶體的程式 (檔名: `psr.c`):
```cpp
#include <sys/mman.h>
#include <unistd.h>
#include <fcntl.h> /* For O_* constants */
#include <stdlib.h>
#include <stdio.h>
int main(int argc, char **argv)
{
int fd = shm_open("posixsm", O_RDONLY, 0666);
ftruncate(fd, 0x400000);
char *p = mmap(NULL, 0x400000,
PROT_READ, MAP_SHARED, fd, 0);
printf("%c %c %c %c\n", p[0], p[1], p[2], p[3]);
munmap(p, 0x400000);
return 0;
}
```
編譯並執行:
```shell
$ gcc -o psw psw.c -lrt
$ gcc -o psr psr.c -lrt
$ ./psw
$ ./psr
```
我們可用 `ls` 命令,觀察到一個事實:當上述程式執行後,在 `/dev/shm/` 及 `/run/shm` 這兩個目錄會新增 `posixsm` 檔案。
若不呼叫 `shm_open()` 函式,我們也可改用 `open` 來開啟檔案再呼叫 `mmap`。維基百科的 [mmap 頁面](https://en.wikipedia.org/wiki/Mmap)提到:
> In computing, mmap(2) is a POSIX-compliant Unix system call that maps files or devices into memory. It is a method of memory-mapped file I/O. It implements demand paging, because file contents are not read from disk directly and initially do not use physical RAM at all. The actual reads from disk are performed in a "lazy" manner, after a specific location is accessed. After the memory is no longer needed, it is important to munmap(2) the pointers to it. Protection information can be managed using mprotect(2), and special treatment can be enforced using madvise(2).
POSIX 共享記憶體仍符合前述 tmpfs 的特點。執行 `sw` 和 `sr` 後,再執行 `psw` 和 `psr`,我們發現 free 命令再度出現戲劇性變化:
![](https://i.imgur.com/PxQcf26.png)
與前兩次的結果比較一下:
![](https://i.imgur.com/mUuLuvL.png)
第三次比第二次的 cached 大了這麼多?主要是因為筆者寫這篇文章的同時正在存取硬碟裡面的檔案,不過 POSIX 共享記憶體本身也導致 cached 變大。
## memfd_create
在探討 [memfd_create](http://man7.org/linux/man-pages/man2/memfd_create.2.html) 之前,我們要認知到,Linux 的 [file descriptor](https://en.wikipedia.org/wiki/File_descriptor) (以下簡稱 `fd`) 屬於行程級別的機制。只要掛載 [procfs](https://en.wikipedia.org/wiki/Procfs),每個 Linux 行程在 procfs 都揭露系統資訊,其中 `/proc/PID/fd` 檔案可讓我們查看該行程對應的 fd 列表。舉例來說,下方命令可觀察目前 shell 行程: (`$$` 即目前的 shell 所屬的 PID)
```shell
$ ls -l /proc/$$/fd
0 -> /dev/pts/0
1 -> /dev/pts/0
2 -> /dev/pts/0
```
該行程的 0, 1, 2 和其他行程的 0, 1, 2 對應的裝置其實不相同 —— 否則目前 shell 的輸入 (stdin, fd=0) 和輸出 (stdout, fd=1) 就會影響到其他 shell 行程。
於是我們可繼續思考:是否可在一個行程裡存取另一個行程的 fd?這只是目的,而非手段。例如行程 A 有兩個 fd 指向兩個主記憶體區塊,若行程 B 可拿到這兩個 fd,其實就可透過這兩個 fd 存取到這兩個區塊,從而使這個 fd 充當彼此的媒介。
似乎不難,考慮行程 A 有下方操作:
```cpp
fd = open();
```
`open` 系統呼叫若回傳 `100`,我們則將該 `100` 數值提供給行程 B,行程 B 直接取用 `100`,似乎就達成目的?如果你這樣想,代表你尚未體會到前述「fd 是個行程級別的機制」概念,換言之,fd 數值無法直接跨越不同行程之間分享 —— 行程 A 得到的 `100` 和行程 B 得到的 `100` 很可能是徹底不同的物件。
為此,Linux 提供一個特殊的方法,可將一個行程的 fd 傳遞、分享給另一個行程。但該如何分享出去呢?
在 Linux 中,我們要用到 `cmsg`,後者讓我們得以在 socket 上傳遞控制訊息 (也稱作 ancillary data, 輔助資料)。使用 `SCM_RIGHTS`,行程可透過 UNIX socket 把一個或是多個 fd 傳遞給其他行程。
下面這個函式可透過 socket 把 fds 指向的 n 個 fd 發送給另一個行程:
```cpp
#include <sys/socket.h>
static void send_fd(int socket, int *fds, int n)
{
char buf[CMSG_SPACE(n * sizeof(int))], data;
memset(buf, '\0', sizeof(buf));
struct iovec io = {.iov_base = &data, .iov_len = 1};
struct msghdr msg = {.msg_iov = &io, .msg_iovlen = 1,
.msg_control = buf, .msg_controllen = sizeof(buf)};
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(n * sizeof(int));
memcpy((int *) CMSG_DATA(cmsg), fds, n * sizeof(int));
if (sendmsg(socket, &msg, 0) < 0)
perror("Failed to send message");
}
```
在另一個行程,則可透過下列函式接受這個 fd:
```cpp
static int *recv_fd(int socket, int n)
{
int *fds = malloc(n * sizeof(int));
char buf[CMSG_SPACE(n * sizeof(int))], data;
memset(buf, '\0', sizeof(buf));
struct iovec io = {.iov_base=&data, .iov_len=1};
struct msghdr msg = {.msg_iov = &io, .msg_iovlen = 1,
.msg_control = buf, .msg_controllen = sizeof(buf)};
if (recvmsg(socket, &msg, 0) < 0)
perror("Failed to receive message");
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
memcpy(fds, (int *) CMSG_DATA(cmsg), n * sizeof(int));
return fds;
}
```
這引來一個問題:若行程 A 裡頭有個檔案對應的 fd 是 `100`,在發送給行程 B 之後,fd 還會是 `100` 嗎?每個行程有各自的 fd 列表。例如行程 B 收到行程 A 送來的 fd 之際,行程 B 自身 fd 空間裡前 200 個 fd 都已被佔用,於是行程 B 接收到的 fd 可能就是 `201`。前述 fd 的數值在 Linux 其實沒有特別意涵,除了幾個特殊的 0, 1, 2 這類的數值。同樣地,若你把 `$ cat /proc/interrupts` 顯示的中斷號碼視作硬體裡面的中斷偏移量 (例如 [Arm GIC](https://developer.arm.com/ip-products/system-ip/system-controllers/interrupt-controllers) 裡面某個硬體中斷),你會發現他們完全無關。
```graphviz
digraph {
A [label="行程A" shape="rectangle"];
B [label="行程B" shape="rectangle"];
fd1 [label=<<TABLE cellspacing="0">
<TR><TD> </TD></TR>
<TR><TD > </TD></TR>
<TR><TD PORT="x">100 </TD></TR>
<TR><TD > </TD></TR>
<TR><TD > </TD></TR>
<TR><TD > </TD></TR>
<TR><TD > </TD></TR>
</TABLE>> shape=plaintext xlabel="fd"];
fd2 [label=<<TABLE cellspacing="0">
<TR><TD> </TD></TR>
<TR><TD > </TD></TR>
<TR><TD > </TD></TR>
<TR><TD > </TD></TR>
<TR><TD PORT="x">200</TD></TR>
<TR><TD > </TD></TR>
<TR><TD > </TD></TR>
</TABLE>> shape=plaintext xlabel="fd"];
file [label=" 同個檔案 " shape="rectangle"];
graph [splines=curved]
{rank=same A B};
{rank=same fd1 fd2};
fd1:x->fd2:x [label="socket\nSCM_RIGHTS" shape="plaintext"]
A->fd1 [style=invis]
B->fd2 [style=invis]
fd1:x->file
fd2:x->file
}
```
接著我們來討論 `memfd_create`,fd 是 `memfd_create` 的回傳值。`memfd_create` 函式特別之處,在於會回傳一個**匿名記憶體檔案**的 fd,後者沒有對應**實體**的檔案路徑,典型用法如下:
```cpp
int fd = memfd_create("shma", 0);
ftruncate(fd, size);
void *ptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
strcpy(ptr, "hello A");
munmap(ptr, size);
```
我們透過 `memfd_create` 建立一個**檔案**,但後者實際映射到一個主記憶體區塊,且在檔案系統底下**沒有路徑**!
如此違反我們對檔案認知的設計,有何好處呢?考慮這樣的場景:需要一個 fd 來當成檔案一樣使用,但又不想要該檔案真正在檔案系統中存在。於是就可用 `memfd_create`,使用手冊中這樣描述:
> memfd_create() creates an anonymous file and returns a file descriptor that refers to it. The file behaves like a regular file, and so can be modified, truncated, memory-mapped, and so on. However, unlike a regular file, it lives in RAM and has a volatile backing storage.
這和稍早提及 UNIX Socket 傳遞的 fd 又有什麼關係?我們透過 `memfd_create` 獲得 fd,但在行為上類似平常的 fd,所以也可透過 socket 來傳遞,這樣行程 A 相當於把一塊與 fd 相對應的記憶體區塊分享給行程 B。
以下的的程式碼中,行程 A 透過 `memfd_create` 建立兩個 4MiB 的記憶體區塊,並透過 socket(`/tmp/fd-pass.socket`)發送給行程 B 這兩個記憶體區塊對應的 fd:
```cpp
int main(int argc, char *argv[])
{
int sfd = socket(AF_UNIX, SOCK_STREAM, 0);
if (sfd == -1) perror("Failed to create socket");
struct sockaddr_un addr;
memset(&addr, 0, sizeof(struct sockaddr_un));
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, "/tmp/fd-pass.socket", sizeof(addr.sun_path) - 1);
#define SIZE 0x400000
int fds[2];
fds[0] = memfd_create("shma", 0);
if (fds[0] < 0)
perror("Failed to open file 1 for reading");
else
fprintf(stdout, "Opened fd %d in parent\n", fds[0]);
ftruncate(fds[0], SIZE);
void *ptr0 =
mmap(NULL, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fds[0], 0);
memset(ptr0, 'A', SIZE);
munmap(ptr0, SIZE);
fds[1] = memfd_create("shmb", 0);
if (fds[1] < 0)
perror("Failed to open file 2 for reading");
else
fprintf(stdout, "Opened fd %d in parent\n", fds[1]);
ftruncate(fds[1], SIZE);
void *ptr1 =
mmap(NULL, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fds[1], 0);
memset(ptr1, 'B', SIZE);
munmap(ptr1, SIZE);
if (connect(sfd, (struct sockaddr *) &addr, sizeof(struct sockaddr_un)) ==
-1)
perror("Failed to connect to socket");
send_fd(sfd, fds, 2);
exit(EXIT_SUCCESS);
}
```
與之對應,行程 B 透過相同的 socket 接受這兩個記憶體區塊對應的 fd,再透過 `read` 讀取每個檔案的前 256 個位元組並輸出至 stdout:
```cpp
int main(int argc, char *argv[])
{
char buffer[256];
int sfd = socket(AF_UNIX, SOCK_STREAM, 0);
if (sfd == -1) perror("Failed to create socket");
if (unlink("/tmp/fd-pass.socket") == -1 && errno != ENOENT)
perror("Removing socket file failed");
struct sockaddr_un addr;
memset(&addr, 0, sizeof(struct sockaddr_un));
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, "/tmp/fd-pass.socket", sizeof(addr.sun_path) - 1);
if (bind(sfd, (struct sockaddr *) &addr, sizeof(struct sockaddr_un)) == -1)
perror("Failed to bind to socket");
if (listen(sfd, 5) == -1) perror("Failed to listen on socket");
int cfd = accept(sfd, NULL, NULL);
if (cfd == -1) perror("failed to accept incoming connection");
int *fds = recv_fd(cfd, 2);
for (int i = 0; i < 2; ++i) {
fprintf(stdout, "Reading from passed fd %d\n", fds[i]);
ssize_t nbytes;
while ((nbytes = read(fds[i], buffer, sizeof(buffer))) > 0)
write(1, buffer, nbytes);
*buffer = '\0';
}
if (close(cfd) == -1) perror("Failed to close client socket");
return 0;
}
```
> 上述的程式碼參考 [Passing open file descriptors over UNIX domain sockets](https://openforums.wordpress.com/2016/08/07/open-file-descriptor-passing-over-unix-domain-sockets/) 一文
注意到程式碼中,行程 B 進行 `read(fds[i], buffer, sizeof(buffer))`,這項操作係以 fd 為基礎。透過 socket 發送 `memfd_create` 所得到的 fd,從而落實行程間共享記憶體的方法,哪裡優雅了呢?整個程式模型變得介面簡潔、靈活且通用。
行程間想要共享幾個記憶體區塊,想怎麼共享或是與誰共享,無非是多了幾個 fd 以及 socket 的傳遞過程。比如說,我們從網際網路中收到了 jpeg 的資料流,行程 A 可以建立多個 buffer 來存住(快取)畫面,再透過把每個 buffer 對應的 fd 轉交給另外的行程去解碼等。Avenue to Jane(大道至簡),簡單的才是最好的!
`memfd_create` 的另一個驚艷之處在於支持**封印** (sealing)。sealing 這詞的意思是封條。在這個場景中,筆者傾向翻譯為**封印**。
> 中國傳說的**封印**指採用如五行、太極、八卦等手段,輔以符咒、法器等物品。現指對某個人施加一種力量,使其無法正常運用某些能力的本領。
在 `memfd_create` 的場景,我們同樣可用某種法器來控制共享記憶體的 shrink, grow 及 write。最初的想法可見 2014 年 [File Sealing & memfd_create()](https://lwn.net/Articles/591108/) 一文。
我們如果在共享記憶體上施加上述的封印,即可限制對此的 `ftruncate`, `write` 等動作,並建立某種意義上行程間的相互信任。
* `SEAL_SHRINK`: If set, the inode size cannot be reduced
* `SEAL_GROW`: If set, the inode size cannot be increased
* `SEAL_WRITE`: If set, the file content cannot be modified
[File Sealing & memfd_create()](https://lwn.net/Articles/591108/) 一文舉出的經典使用場景:如果 graphics client (如下圖中的 WebKit,後者是 Chrome 網頁瀏覽器的引擎) 將其與 graphics compositor 共享的主記憶體 (對應下圖的 "shared memory") 交給 compositor 去 render ([算繪](http://breezymove.blogspot.com/2013/12/render.html),一如人類在藝術作品中的描繪,但改用電腦運算),compositor 必須保證可拿到這個記憶體區塊,然而隱藏著一個風險 —— client 可能透過 [ftruncate](http://man7.org/linux/man-pages/man2/truncate.2.html) 裁減記憶體空間,這樣 compositor 就拿不到完整的 buffer 而會造成程式操作的崩潰。為此,compositor 只願接受含有 `SEAL_SHRINK` 封印的 fd。
![](https://i.imgur.com/L97NVss.png)
> Compositing with the GPU process,取自 [GPU Accelerated Compositing in Chrome](https://www.chromium.org/developers/design-documents/gpu-accelerated-compositing-in-chrome)
在 Linux 核心引入 `memfd_create` 後,我們應儘可能地使用此機制,取代傳統 POSIX 和 SysV 共享記憶體,這已是趨勢。比如說我們在 [Wayland](https://en.wikipedia.org/wiki/Wayland_(display_server_protocol)) 專案中,可看到這樣的修改提案 [cursor: use memfd_create or shm_open for anonymous in-memory files](https://patchwork.freedesktop.org/patch/244675/)
> On Linux, try using memfd_create and file sealing. Fallback to shm_open on old kernels.
> On FreeBSD, use shm_open with SHM_ANON.
> Otherwise, use shm_open with a random name, making sure the name isn't already taken.
## dma_buf
考慮如此的場景:攝影裝置取得的影像資料需要送到 GPU 進行運算和再處理。負責資料蒐集和編碼的模組是 Linux 不同的裝置驅動程式,將其中的資料派送資料處理的裝置,勢必需要有一種高效率的方法。最簡單的方法就是進行一次記憶體複製,但我們期待 zero-copy 的通用方法。
[dma_buf](https://01.org/linuxgraphics/gfx-docs/drm/driver-api/dma-buf.html) 定義:
> The DMABUF framework provides a generic method for sharing buffers between multiple devices. Device drivers that support DMABUF can export a DMA buffer to userspace as a file descriptor (known as the exporter role), import a DMA buffer from userspace using a file descriptor previously exported for a different or the same device (known as the importer role), or both.
簡單地來說,dma_buf 可以實現 buffer 在多個裝置 (device) 的共享,應用程式可把一個底層驅動程式 A 的 buffer 導出到 userspace 成為一個 fd,也可將 fd 導入到底層驅動程式 B。當然,如果進行 `mmap` 得到虛擬位址,CPU 也可以在 userspace 存取到已獲得 userspace 虛擬位址的底層 buffer。
```graphviz
digraph {
A [label="行程 A"]
B [label="行程 B\n來自 socket"]
DA [label="裝置 A" shape="rectangle"]
DB [label="裝置 B" shape="rectangle"]
BUF [label="buffer" shape="rectangle"]
subgraph cluster_0 {
{rank=same A B}
A->B
label="userspace"
}
subgraph cluster_1 {
DA->BUF
DB->BUF
label="kernel space"
}
DA->A [label=" 導出 fd"]
B->DB [label=" 導入 fd"]
}
```
上圖中,行程 A 存取裝置 A 並獲得其使用 buffer 的 fd,之後再透過 socket 把 fd 傳送給行程 B,行程 B 再將 fd 導入回裝置 B,獲得對裝置 A 中 buffer 的共享存取。如果 CPU 也需要在 user mode 存取這塊 buffer,則進行 `mmap` 的動作。
為什麼我們要共享 DMA buffer?想像一個場景:你想要把螢幕 framebuffer 的內容透過 gstreamer 的服務,變成 h264 的資料流 (datastream) 發送到網際網路中,變成串流媒體播放。在這個場景中,我們會想盡一切可能的避免**記憶體複製**。
技術上,管理 framebuffer 的驅動程式可將這片 buffer 在底層實作為 dma_buf,graphics compositor 再給這塊 buffer 映射一個 fd,透過 socket 發送 fd 把這個記憶體區塊交給 gstreamer 相關的行程。如果 gstreamer 相關的模組可以透過接收到的 fd 還原出這些 dma_buf 的位址,即可直接進行加速操作。例如 color space 透過接收到的 fd1 還原出 framebuffer 的位址,再把轉化的結果放到另外一塊 dma_buf。之後 fd2 對應的這塊 YUV buffer 被共享給 h264 編碼器,h264 編碼器再透過 fd2 還原出 YUV buffer 的位址。
```graphviz
digraph {
GC [label="graphics\ncompositor" shape=rectangle]
CS [label="color space 轉換" shape=rectangle]
DUMMY1 [style=invis]
H264 [label="h264 編碼" shape=rectangle]
FB [label="frame\nbuffer\n(RGB)" shape=rectangle color=cyan style=filled]
YB [label="YUV\nbuffer" shape=rectangle color=cyan style=filled]
DUMMY2 [style=invis]
DUMMY3 [style=invis]
GC->CS [label=fd1]
CS->DUMMY1 [style=invis]
DUMMY1->H264 [style=invis]
FB->DUMMY2 [style=invis]
DUMMY2->YB [style=invis]
YB->DUMMY3 [style=invis]
FB->GC
CS->YB
YB->H264 [label=fd2]
{rank=same GC CS DUMMY1 H264}
{rank=same FB DUMMY2 YB DUMMY3}
}
```
這裡的重點就是 fd 僅是充當一個**控制代碼**,userspace 行程和驅動程式透過 fd 找到了底層的 dma_buf,實作 buffer 在行程和硬體加速模組之間的 zero-copy —— 其中唯一交換的資料就是 fd。
再比如,如果把方向反過來,gstreamer 從網路上收到影片串流,把他透過一系列的處理轉換為一塊 RGB 的 buffer,那麼這塊 RGB 的 buffer 最後還要在 graphics compositor 裡面 render 到螢幕上,我們也需要透過 dma_buf 實作主記憶體在影片解碼相關模組與 GPU 模組的共享。
Linux 核心的 V4L2 驅動程式 (解碼和編碼多採用這種驅動程式)、DRM (Direct Rendering Manager, framebuffer/GPU 相關)等都支援 dma_buf。比如在 DRM 上,行程可以透過
```cpp
int drmPrimeHandleToFD(int fd,
uint32_t handle,
uint32_t flags,
int *prime_fd
)
```
來獲得底層 framebuffer 對應的 fd。如果這個 fd 被分享給 gstreamer 相關行程影片在 color space 的轉換,而 color space 轉換硬體模組又被實作成一個 V4L2 的驅動程式,我們則可以透過 V4L2 提供的介面,將這塊 buffer 提供給 V4L2 驅動程式:
```cpp
int buffer_queue(int v4lfd, int index, int dmafd)
{
struct v4l2_buffer buf;
memset(&buf, 0, sizeof(buf));
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_DMABUF;
buf.index = index;
buf.m.fd = dmafd;
if (ioctl(v4lfd, VIDIOC_QBUF, &buf) == -1) {
perror("VIDIOC_QBUF");
return -1;
}
return 0;
}
```
如果是 multi plane 的話,則需要匯入多個 fd:
```cpp
int buffer_queue_mp(int v4lfd, int index, int dmafd[], int n_planes)
{
struct v4l2_buffer buf;
struct v4l2_plane planes[VIDEO_MAX_PLANES];
memset(&buf, 0, sizeof(buf));
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE;
buf.memory = V4L2_MEMORY_DMABUF;
buf.index = index;
buf.m.planes = planes;
buf.length = n_planes;
memset(&planes, 0, sizeof(planes));
for (int i = 0; i < n_planes; ++i)
buf.m.planes[i].m.fd = dmafd[i];
if (ioctl(v4lfd, VIDIOC_QBUF, &buf) == -1) {
perror("VIDIOC_QBUF");
return -1;
}
return 0;
}
```
相關的細節可參考 [Streaming I/O (DMA buffer importing)](https://www.linuxtv.org/downloads/v4l-dvb-apis-old/dmabuf.html)
**所有的東西都是檔案!就算不是檔案也要轉化為檔案** —— 這就是 Linux 的世界觀。是否為檔案不重要,關鍵是你操作起來就是個檔案。在 dma_buf 的場景下,fd 純粹就是一個控制代碼而已,方便大家透過這個 fd 對應到最後硬體需要存去的 buffer。所以,透過 fd 的分享以及傳遞可以實做出跨行程、跨裝置(包括 CPU)的記憶體共享。
如果說前面的 SysV, POSIX, memfd_create() 強調記憶體在行程間的共享,那麼 dma_buf 則更加強調記憶體在裝置之間的共享,未必要跨越行程。舉例來說:
```graphviz
digraph {
rankdir=TB
A [label=" 行程 A " shape="rectangle"]
DA [label="裝置 A" shape="rectangle"]
DB [label="裝置 B" shape="rectangle"]
BUF [label="buffer" shape="rectangle"]
DA->A [label="匯出 fd"]
A->DB [label="匯入 fd "]
DA->BUF
DB->BUF
{rank=same DA DB}
}
```
你可能會疑惑,為什麼在同一個行程中,裝置 A 和 B 共享記憶體還需要 fd 的協助呢?直接在裝置 A 上使用一個全域變數儲存 buffer 的實體位址,然後讓裝置 B 存取這個全域變數不就好了嗎?
若你也是這樣想的話,代表你對 Linux 核心**只提供機制不提供策略**,及軟體工程對每個模組各司其職、高度內聚 (high cohesion) 和低度耦合 (low coupling) 的理解不充分。
倘若缺乏 dma_buf 一類的機制,如果 userspace 仍然負責建構策略並連接裝置 A 和 B,人們為追求程式碼的整潔,往往會這樣做:
```graphviz
digraph {
A [label="{行程 A|{<x>→|copy|<y>→}}" shape=record]
DA [label="裝置 A" shape=rectangle]
DB [label="裝置 B" shape=rectangle]
BA [label="buffer" shape=rectangle]
BB [label="buffer" shape=rectangle]
DA->BA
DB->BB
DA->A:x [label=" mmap"]
A:y->DB [label=" mmap"]
{rank=same DA DB}
{rank=same BA BB}
}
```
是否支援 dma_buf 依賴裝置驅動層是否存在相關的 callback 實作。比如說在 v4l2 驅動中,他支援匯出 dma_buf(前面有提到 v4l2 也支援 dma_buf 的匯入),程式碼在 [`drivers/media/common/videobuf2/videobuf2-dma-contig.c`](https://github.com/torvalds/linux/blob/master/drivers/media/common/videobuf2/videobuf2-dma-contig.c#L383) 中看到:
```cpp
static struct dma_buf *vb2_dc_get_dmabuf(void *buf_priv, unsigned long flags)
{
struct vb2_dc_buf *buf = buf_priv;
struct dma_buf *dbuf;
DEFINE_DMA_BUF_EXPORT_INFO(exp_info);
exp_info.ops = &vb2_dc_dmabuf_ops;
exp_info.size = buf->size;
exp_info.flags = flags;
exp_info.priv = buf;
if (!buf->sgt_base)
buf->sgt_base = vb2_dc_get_base_sgt(buf);
if (WARN_ON(!buf->sgt_base))
return NULL;
dbuf = dma_buf_export(&exp_info);
if (IS_ERR(dbuf))
return NULL;
/* dmabuf keeps reference to vb2 buffer */
refcount_inc(&buf->refcount);
return dbuf;
}
```
其中 `vb2_dc_dmabuf_ops` 是一個 dma_buf_ops 結構:
```cpp
static const struct dma_buf_ops vb2_dc_dmabuf_ops = {
.attach = vb2_dc_dmabuf_ops_attach,
.detach = vb2_dc_dmabuf_ops_detach,
.map_dma_buf = vb2_dc_dmabuf_ops_map,
.unmap_dma_buf = vb2_dc_dmabuf_ops_unmap,
.vmap = vb2_dc_dmabuf_ops_vmap,
.mmap = vb2_dc_dmabuf_ops_mmap,
.release = vb2_dc_dmabuf_ops_release,
};
```
當使用者呼叫 `VIDIOC_EXPBUF` 這個 ioctl 可以把 dma_buf 轉為 fd:
```cpp
int ioctl(int fd, VIDIOC_EXPBUF, struct v4l2_exportbuffer *argp);
```
與之對應的驅動層會使用 `dma_buf_fd()`:
```cpp
int vb2_core_expbuf(struct vb2_queue *q, int *fd, unsigned int type,
unsigned int index, unsigned int plane, unsigned int flags)
{
ret = dma_buf_fd(dbuf, flags & ~O_ACCMODE);
return 0;
}
```
應用程式可透過以下的方式拿到底層 `dma_buf` 的 fd:
```cpp
int buffer_export(int v4lfd, enum v4l2_buf_type bt, int index, int *dmafd)
{
struct v4l2_exportbuffer expbuf;
memset(&expbuf, 0, sizeof(expbuf));
expbuf.type = bt;
expbuf.index = index;
if (ioctl(v4lfd, VIDIOC_EXPBUF, &expbuf) == -1) {
perror("VIDIOC_EXPBUF");
return -1;
}
*dmafd = expbuf.fd;
return 0;
}
```
dma_buf 匯入端的裝置驅動程式,會使用到以下這些 API:
* dma_buf_attach()
* dma_buf_map_attachemnt()
* dma_buf_unmap_attachment()
* dma_buf_detach()
## 共享記憶體機制點評
筆者對於本文所及共享記憶體機制的點評:
| | 目標 | API(5 分制)| 喜好程度 |
|-|-----|-------------|--------|
|SysV | 記憶體跨行程共享 | 0 | 討厭 |
|POSIX | 記憶體跨行程共享 | 4 | 喜歡 |
|memfd_create | 記憶體跨行程共享、封印 | 5 | 驚艷 |
|dma_buf | 記憶體跨裝置共享 | 4 | 讚美 |