先看看性能
io_uring 需要內核版本在5.1 及以上才支持,liburing的編譯安裝 很簡單,直接clone 官方的代碼,sudo make && sudo make install 就好了,本文是在內核5.12版本 上測試的。
在描述io_uring 的性能之前,我們先直接看一組實測數據:
這組數據是在3D XPoint 介質的硬盤 :optane-5800上測試的,optane5800 能夠提供(randread100% 150w/s , randwrite 100% 150w/s)的性能。
進行測試的fio 腳本如下:
[global]
ioengine=libaio
direct=0
randrepeat=1
threads=8
runtime=15
time_based
size=1G
directory=../test-data
group_reporting
[read256B-rand]
bs=4096
rw=randread
numjobs=1
iodepth=128
# io_uring
[global]
ioengine=io_uring
sqthread_poll=1 #開啟io_uring sq_poll模式
direct=1
randrepeat=1
threads=8
runtime=15
time_based
size=1G
directory=../test-data
group_reporting
[read256B-rand]
bs=4096
rw=randread
numjobs=1
iodepth=128
通過上面的測試,我們能夠得到如下幾個結論:
- 這種高隊列深度的測試下,可以看到io_uring 在開啟sq_poll之后的性能 相比于aio 的高隊列深度的處理能力好接近一倍;
- 在較低隊列深度 以及不開啟 sq_poll 模式的情況下,io_uring 整體沒有太大的優勢,或者說一樣的性能。
- 在buffer I/O (direct=0) 下,io_uring 也不會有太大的優勢,因為都得通過 os-cache 來操作。
需要注意的是,如果aio和io_uring 在高并發下(jobs 的數目不斷增加),都是可以達到當前磁盤的性能瓶頸的。
AIO 的基本實現
那有這樣的測試現象,我們可能會有一些疑問,就這性能?我們在nvme上做軟件,希望發揮的是整個磁盤的性能,而不是比拼誰的隊列深度大,誰的優勢更大。。。我用aio 做batch 也能達到磁盤的性能瓶頸,為什么要選擇 對于數據庫/存儲 領域來說 好像“如日中天”的io_uring呢。
我們先來看看aio 的大體實現,沒有涉及到源代碼。aio 主要提供了三個系統調用:
- io_setup 初始化一些內核態的數據結構
- io_submit 用于用戶態提交io 請求
- io_getevents 用于io 請求處理完成之后的io 收割
大體的IO調度過程如下:
- io_setup 完成一些內核數據結構的初始化(包括內核中的 aio 調度隊列,aio_ring_info 的ring-buffer緩沖區)
- 用戶態構造一批io請求,通過io_submit 拷貝請求到內核態io 隊列(文件系統之上,上圖沒有體現出來)之后返回到用戶態。
- 內核態繼續通過內核i/o 棧處理io請求,處理完成之后 通過 aio_complete 函數將處理完成的請求放入到 aio_ring_info,每一個io請求是一個io_event。
- 用戶態通過 io_getevents 系統調用 從 aio_ring_info(ring-buffer) 的head 拿處理完成的io_event,如果head==tail,則表示這個ring-buffer是空的。拿到之后,將拿到的io_event 一批從內核態拷貝到用戶態。
如果單純看 誰能將磁盤性能完整發揮出來,那毋庸置疑,大家都可以;那為什么做存儲的對io_uring 的出現如此熱衷呢?我們就得結合實際的應用場景來看看兩者之間的差異了:
- 使用AIO的話,請求調度都需要直接由通用塊層來調度處理,所以需要O_DIRECT標記。這就意味著,使用AIO的應用都無法享受os cache,這對與存儲應用來說并不友好,cache都得自己來維護,而且顯然沒有os page-cache性能以及穩定性有優勢。而使用io_uring 則沒有這樣的限制,當然,io_uring在 buffer I/O下顯然沒有太大的優勢。
- 延時上的開銷。AIO 提交用戶請求的時候 通過io_submit調用,收割用戶請求的時候通過io_getevents,正常應用的時候每一個請求都意味著至少兩次系統調用(I/O提交和I/O收割),而對于io_uring來說,I/O 提交和I/O收割都可以 offload 給內核。這樣相比于AIO 來說,io_uring能夠極大得減少 系統調用引入的上下文切換。
- io_uring 能夠支持針對submit queue的polling,啟動一個內核線程進行polling,加速請求的提交和收割;對于aio來說,這里就沒有這樣的機制。
總的來說,io_uring 能夠保證上層應用 對系統資源(cache)正常使用的同時 ,降低應用 下發的請求延時和CPU的開銷,在單實例高隊深下,能夠顯著優于同等隊深下的AIO性能。
io_ring 使用
io_uring 基本接口
io_uring的用戶態API 提供了三個系統調用,io_uring_setup,io_uring_enter,io_uring_register。
- int io_uring_setup(u32 entries, struct io_uring_params *p); 這個接口 用于創建 擁有 entries 個請求的 提交隊列(SQ) 和 完成隊列(CQ),并且返回給用戶一個fd。這個fd可以用做在同一個uring實例上 用戶空間和內核空間共享sq和cq 隊列,這樣能夠避免在請求完成時不需要從完成隊列拷貝數據到用戶態了。io_uring_params 主要是根據用戶的配置來設置uring 實例的創建行為。包括 單不限于開啟 IORING_SETUP_IOPOLL 和 IORING_SETUP_SQPOLL 兩種 poll 模式。后面會細說。
- int io_uring_register(unsigned int fd, unsigned int opcode, void *arg, unsigned int nr_args);這個接口主要用于注冊用戶態和內核態共享的緩沖區,即將 setup 返回的fd中的數據結構 映射到共享內存,從而進一步減少用戶I/O 提交到uring 隊列中的開銷。
- int io_uring_enter(unsigned int fd, unsigned int to_submit, unsigned int min_complete, unsigned int flags, sigset_t *sig);這個接口既能夠提交 新的I/O請求 ,又能夠支持I/O收割。
liburing 的使用
可以從上面的幾個系統調用能夠簡單看到 用戶在自主使用這三個系統調用來調度 I/O請求時 還是比較麻煩的,像io_uring_setup 之后的fd,我們用戶層想要使用創建好的sq/cq ,則需要自主進行mmap,并且維護用戶態的sq/cq 數據結構,并在后續的 enter 中自主進行用戶態的sq 的填充。這個過程相對來說還是比較麻煩的。更不要說用三個系統調用中數十個的flags的靈活配置,如果全部結合起來,對于剛接觸io_uring的用戶來說還是需要較大的學習成本。
比如,我想啟動io_uring,并初始化好用戶態的sq/cq 數據結構,就需要寫下面這一些代碼:
struct app_io_sq_ring *sring = &s->sq_ring;
struct app_io_cq_ring *cring = &s->cq_ring;
struct io_uring_params p;
void *sq_ptr, *cq_ptr;
/*
* We need to pass in the io_uring_params structure to the io_uring_setup()
* call zeroed out. We could set any flags if we need to, but for this
* example, we don't.
* */
memset(&p, 0, sizeof(p));
s->ring_fd = io_uring_setup(QUEUE_DEPTH, &p);
if (s->ring_fd < 0) {
perror("io_uring_setup");
return 1;
}
/*
* io_uring communication happens via 2 shared kernel-user space ring buffers,
* which can be jointly mapped with a single mmap() call in recent kernels.
* While the completion queue is directly manipulated, the submission queue
* has an indirection array in between. We map that in as well.
* */
int sring_sz = p.sq_off.array + p.sq_entries * sizeof(unsigned);
int cring_sz = p.cq_off.cqes + p.cq_entries * sizeof(struct io_uring_cqe);
/* In kernel version 5.4 and above, it is possible to map the submission and
* completion buffers with a single mmap() call. Rather than check for kernel
* versions, the recommended way is to just check the features field of the
* io_uring_params structure, which is a bit mask. If the
* IORING_FEAT_SINGLE_MMAP is set, then we can do away with the second mmap()
* call to map the completion ring.
* */
if (p.features & IORING_FEAT_SINGLE_MMAP) {
if (cring_sz > sring_sz) {
sring_sz = cring_sz;
}
cring_sz = sring_sz;
}
/* Map in the submission and completion queue ring buffers.
* Older kernels only map in the submission queue, though.
* */
sq_ptr = mmap(0, sring_sz, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_POPULATE,
s->ring_fd, IORING_OFF_SQ_RING);
if (sq_ptr == MAP_FAILED) {
perror("mmap");
return 1;
}
if (p.features & IORING_FEAT_SINGLE_MMAP) {
cq_ptr = sq_ptr;
} else {
/* Map in the completion queue ring buffer in older kernels separately */
// 放置內存被page fault
cq_ptr = mmap(0, cring_sz, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_POPULATE,
s->ring_fd, IORING_OFF_CQ_RING);
if (cq_ptr == MAP_FAILED) {
perror("mmap");
return 1;
}
}
/* Save useful fields in a global app_io_sq_ring struct for later
* easy reference */
sring->head = sq_ptr + p.sq_off.head;
sring->tail = sq_ptr + p.sq_off.tail;
sring->ring_mask = sq_ptr + p.sq_off.ring_mask;
sring->ring_entries = sq_ptr + p.sq_off.ring_entries;
sring->flags = sq_ptr + p.sq_off.flags;
sring->array = sq_ptr + p.sq_off.array;
/* Map in the submission queue entries array */
s->sqes = mmap(0, p.sq_entries * sizeof(struct io_uring_sqe),
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
s->ring_fd, IORING_OFF_SQES);
if (s->sqes == MAP_FAILED) {
perror("mmap");
return 1;
}
/* Save useful fields in a global app_io_cq_ring struct for later
* easy reference */
cring->head = cq_ptr + p.cq_off.head;
cring->tail = cq_ptr + p.cq_off.tail;
cring->ring_mask = cq_ptr + p.cq_off.ring_mask;
cring->ring_entries = cq_ptr + p.cq_off.ring_entries;
cring->cqes = cq_ptr + p.cq_off.cqes;
return 0;
}
所以Jens Axboe將三個系統調用做了一個封裝,形成了liburing,在這里面我想要初始化一個uring實例,并完成用戶態的數據結構的映射,只需要調用下面io_uring_queue_init這個接口:
struct io_uring_params p = { };
int ret;
ret = io_uring_queue_init(IORING_MAX_ENTRIES, &ring, IORING_SETUP_IOPOLL);
關于liburing的使用,可以看下面這個100行的小案例:
大體的功能就是利用io_uring 去讀一個用戶輸入的文件,每次讀請求的大小是4K,讀完整個文件結束。
#include
#include
#include
#include
#include
#include
#include "liburing.h"
#define QD 4
int main(int argc, char *argv[])
{
struct io_uring ring;
int i, fd, ret, pending, done;
struct io_uring_sqe *sqe;
struct io_uring_cqe *cqe;
struct iovec *iovecs;
struct stat sb;
ssize_t fsize;
off_t offset;
void *buf;
if (argc < 2) {
printf("%s: filen", argv[0]);
return 1;
}
// 初始化io_uring,并拿到初始化的結果,0是成功的,小于0 是失敗的
ret = io_uring_queue_init(QD, &ring, 0);
if (ret < 0) {
fprintf(stderr, "queue_init: %sn", strerror(-ret));
return 1;
}
// 打開用戶輸入的文件
fd = open(argv[1], O_RDONLY | O_DIRECT);
if (fd < 0) {
perror("open");
return 1;
}
// 將文件屬性放在sb中,主要是獲取文件的大小
if (fstat(fd, &sb) < 0) {
perror("fstat");
return 1;
}
// 拆分成 設置的 io_uring支持的最大隊列深度 個請求,4個
fsize = 0;
iovecs = calloc(QD, sizeof(struct iovec));
for (i = 0; i < QD; i++) {
if (posix_memalign(&buf, 4096, 4096))
return 1;
iovecs[i].iov_base = buf;
iovecs[i].iov_len = 4096;
fsize += 4096;
}
// 構造請求,并存放在 seq中
offset = 0;
i = 0;
do {
sqe = io_uring_get_sqe(&ring);
if (!sqe)
break;
io_uring_prep_readv(sqe, fd, &iovecs[i], 1, offset);
offset += iovecs[i].iov_len;
i++;
if (offset > sb.st_size)
break;
} while (1);
// 提交請求sqe 中的請求到內核
ret = io_uring_submit(&ring);
if (ret < 0) {
fprintf(stderr, "io_uring_submit: %sn", strerror(-ret));
return 1;
} else if (ret != i) {
fprintf(stderr, "io_uring_submit submitted less %dn", ret);
return 1;
}
done = 0;
pending = ret;
fsize = 0;
// 等待內核處理完所有的請求,并由用戶態拿到cqe,表示請求處理完成
for (i = 0; i < pending; i++) {
ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) {
fprintf(stderr, "io_uring_wait_cqe: %sn", strerror(-ret));
return 1;
}
done++;
ret = 0;
if (cqe->res != 4096 && cqe->res + fsize != sb.st_size) {
fprintf(stderr, "ret=%d, wanted 4096n", cqe->res);
ret = 1;
}
fsize += cqe->res;
io_uring_cqe_seen(&ring, cqe);
if (ret)
break;
}
// 最后輸出 提交的請求的個數(4k),完成請求的個數,總共處理的請求大小
printf("Submitted=%d, completed=%d, bytes=%lun", pending, done,
(unsigned long) fsize);
close(fd);
io_uring_queue_exit(&ring);
return 0;
}
編譯: gcc -O2 -D_GNU_SOURCE -o io_uring-test io_uring-test.c -luring
運行: ./io_uring-test test-file.txt
io_uring 非poll 模式下 的實現
接下來記錄一下io_uring的實現,來填之前說到的一些小坑,當然…這里描述的內容也是站在前人的肩膀 以及 自己經過一些測試驗證總體來看的。
io_uring 能夠支持其他多種I/O相關的請求:
- 文件I/O:read, write, remove, update, link,unlink, fadivse, allocate, rename, fsync等
- 網絡I/O:send, recv, socket, connet, accept等
- 進程間通信:pipe
- …
還是以 上面案例中 io_uring 處理read 請求為例, 通過io_uring_prep_readv來填充之前已經創建好的sqe。
const struct iovec *iovecs,
unsigned nr_vecs, __u64 offset)
{
// 調度讀請求,將構造好的iovecs 中的內容填充到sqe中。
io_uring_prep_rw(IORING_OP_READV, sqe, fd, iovecs, nr_vecs, offset);
}
static inline void io_uring_prep_rw(int op, struct io_uring_sqe *sqe, int fd,
const void *addr, unsigned len,
__u64 offset)
{
sqe->opcode = (__u8) op;
...
sqe->fd = fd;
sqe->off = offset;
sqe->addr = (unsigned long) addr;
sqe->len = len;
...
sqe->__pad2[0] = sqe->__pad2[1] = 0;
}
那我們需要先回到最開始的io_uring_setup以及 后續的mmap setup返回的結果 之后 用戶態和內核態共享的數據結構內容。
數據結構 在內存中的分布 如上圖:
1.io_uring_setup 之后,會將內核中創建好的一塊內存區域 用 fd標識 以及各個數據結構在這個內存區域中的偏移量存放在io_uring_params中, 通過mmap 來將這部分內存區域的數據結構映射到用用戶空間。
其中io_uring_params 中的 關鍵數據結構如下:
__u32 sq_entries; // sq 隊列的個數
__u32 cq_entries; // cq 隊列的個數
__u32 flags; // setup設置的一些標識,比如是否開啟內核的io_poll 或者 sq_poll等
__u32 sq_thread_cpu; // 設置sq_poll 模式下 輪詢的cpu 編號
__u32 sq_thread_idle;
__u32 features;
__u32 wq_fd;
__u32 resv[3];
struct io_sqring_offsets sq_off; // sq的偏移量
struct io_cqring_offsets cq_off; // cq的偏移量
};
2.Mmap 之后的內存形態就是上圖中的數據結構形態,mmap的過程就是填充用戶態可訪問的sq/cq。
- SQ ,submission queue,保存用戶空間提交的請求的地址,實際的用戶請求會存放在io_uring_sqe的sqes中。
unsigned *khead;
unsigned *ktail;
...
struct io_uring_sqe *sqes; // 較為復雜的數據結構,保存請求的實際內容
unsigned sqe_head;
unsigned sqe_tail;
...
};
用戶空間的sq更新會追加到SQ 的隊尾部,內核空間消費 SQ 時則會消費隊頭。
- CQ, complete queue,保存內核空間完成請求的地址,實際的完成請求的數據會存放在io_uring_cqe的cqes中。
unsigned *khead;
unsigned *ktail;
...
struct io_uring_cqe *cqes;
...
};
內核完成IO 收割之后會將請求填充到cqes 中,并更新cq 的隊尾,用戶空間則會從cq的隊頭消費 處理完成的請求。
3.在前面的read 案例代碼中,調用的liburing 的函數 io_uring_get_sqe 就是在用戶空間更新sq的隊尾部。
{
struct io_uring_sq *sq = &ring->sq;
unsigned int head = io_uring_smp_load_acquire(sq->khead);
unsigned int next = sq->sqe_tail + 1;
struct io_uring_sqe *sqe = NULL;
// 當前sq的 tail 和 head之間的容量滿足sq的大小,則將當前請求的填充到sqe中
// 并更新sq 的隊尾,向上移動
if (next - head <= *sq->kring_entries) {
sqe = &sq->sqes[sq->sqe_tail & *sq->kring_mask];
sq->sqe_tail = next;
}
return sqe;
}
后續,內核處理完成之后,用戶空間從cq中獲取 處理完成的請求時則會調用io_uring_wait_cqe_nr進行收割。
io_uring 中的ring就是 上圖中的io 鏈路,從sq隊尾進入,最后請求從cq 隊頭出來,整個鏈路就是一個環形(ring)。而sq和cq在數據結構上被存放在了 io_uring 中。加了uring 中的u 猜測是指用戶態(userspace)可訪問的,目的是好的,不過讀起來的單詞諧音就讓一些人略微尷尬(urine。。。)
非poll 模式下的內核火焰圖調用棧如下:
io_uring poll模式下的實現
我們在最開始的性能測試過程中可以看到在開啟 poll 之后,io_uring的性能才能顯著提高。
我們從前面 io_uring 內存分布圖 中可以看到在內核調度兩個隊列請求的過程中 可以通過異步輪詢的方式進行調度的,也就是io_uring的 poll模式。
io_uring 在io_uring_setup的時候可以通過設置flag 來開啟poll模式,io-uring 支持兩種方式poll模式。
- IORING_SETUP_IOPOLL,這種方式是由nvme 驅動支持的 io_poll。即用戶態通過io_uring_enter提交請求到內核的文件讀寫隊列中即可,nvme驅動會不斷得輪詢文件讀寫隊列進行io消費,同時用戶態在設置IORING_ENTER_GETEVENTS得flag之后,還需要不斷得調用io_uring_enter 通過io_iopoll_check 調用內核接口查看 nvme的io_poll 是否完成任務調度,從而進行填充 cqes。
如果使用nvme驅動,則需要單獨開啟io_poll 才能真正讓 IORING_SETUP_IOPOLL 配置生效。開啟的話,直接嘗試 root 用戶操作:echo 1 > /sys/block/nvme2n1/queue/io_poll,成功則表示開啟。如果出現bash: echo: write error: Invalid argument ,則表示當前nvme驅動還不支持,需要通過驅動層打開這個配置才行,可以嘗試執行如下步驟:如果執行之前,通過modinfo nvme 查看當前設備是否有nvme驅動失敗,則需要先編譯當前內核版本的nvme驅動才行,否則下面的操作沒有nvme驅動都是無法進行的。
- umount fs , 卸載磁盤上掛載的文件系統
- echo 1 > /sys/block/nvme0n1/device/device/remove , 將設備從當前服務器移除
- rmmod nvme
- modprobe nvme poll_queues=1, 重新加載nvme驅動,來支持io_poll的隊列深度為1
- echo 1 > /sys/bus/pci/rescan ,重新將磁盤加載回來
- IORING_SETUP_SQPOLL,這種模式的poll則是我們fio測試下的 sqthread_poll開啟的配置。開啟之后io_uring會啟動一個內核線程,用來輪詢submit queue,從而達到不需要系統調用的參與就可以提交請求。用戶請求在用戶空間提交到SQ 之后,這個內核線程處于喚醒狀態時會不斷得輪詢SQ,也就可以立即捕獲到這次請求。(我們前面的案例中會先在用戶空間構造指定數量的SQ放到ring-buffer中,再由io_uring_enter一起提交到內核),這個時候有了sq_thread 的輪詢,只要用戶空間提交到SQ,內核就能夠捕獲到并進行處理。如果sq_thread 長時間捕獲不到請求,則會進入休眠狀態,需要通過調用io_uring_enter系統調用,并設置IORING_SQ_NEED_WAKEUP來喚醒sq_thread。
大體的調度方式如下圖:
這種sq_thread 內核對SQ的輪詢模式能夠極大得減少請求在submit queue中的排隊時間,同時減少了io_uring_enter系統調用的開銷。
開啟sq_thread之后的輪詢模式可以看到 用戶提交請求 對CPU消耗僅僅只占用了一小部分的cpu。
io_uring 在 rocksdb 中的應用
Rocksdb 針對io_uring的調用大體類似前面提到的使用liburing 接口實現的一個read 文件的案例,同樣是調用io_uring_prep_readv 來實現對文件的讀寫。
Io_uring 的特性決定了在I/O層 的批量讀才能體現它的優勢,所以rocksdb 將io_uring集成到了 MultiGet 中的 MultiRead 接口之中。
需要注意的是 rocksdb 設置的 io_uring的SQ 隊列深度大小是256,且setup的時候并沒有開啟sq_poll模式,而是默認開啟io_poll,即flag是0;如果想要開啟sq_poll模式,則需要變更這個接口的flags配置,比如將0設置為IORING_SETUP_SQPOLL,然后重新編譯源代碼即可。
struct io_uring* new_io_uring = new struct io_uring;
int ret = io_uring_queue_init(kIoUringDepth, new_io_uring, 0);
if (ret) {
delete new_io_uring;
new_io_uring = nullptr;
}
return new_io_uring;
}
大家在使用db_bench測試io_uring的時候 如果不變更rocksdb這里的io_uring_queue_init接口的話,需要保證自己的磁盤支持io_poll模式,也就是通過上一節說的那種查看/修改 nvme 驅動配置來支持io_poll。
在io_poll模式下,對MultiGet的接口測試性能數據大概如下:
我的環境不支持io_poll,大體收益應該和fio的poll模式下的性能收益差不了太多
圖片來自官方
db_bench的配置可以使用,直接用rocksdb的master, CMakeList.txt 默認會開啟io_uring:
生成數據:
--benchmarks=fillrandom,stats
--num=3000000000
--threads=32
--db=./db
--wal_dir=./db
--duration=3600
-report_interval_seconds=1
--stats_interval_seconds=10
--key_size=16
--value_size=128
--max_write_buffer_number=16
-max_background_compactions=32
-max_background_flushes=7
-subcompactions=8
-compression_type=none
io_uring 測試MultiGet,不使用block_cache:
--benchmarks=multireadrandom,stats
--num=3000000000
--threads=32
--db=./db
--wal_dir=./db
--duration=3600
-report_interval_seconds=1
--stats_interval_seconds=10
--key_size=16
--value_size=128
-compression_type=none
-cache_size=0
-use_existing_db=1
-batch_size=256 # 每次MultiGet的 請求總個數
-multiread_batched=true # 使用 MultiGet 的新的API,支持MultiRead,否則就是逐個Get
-multiread_stride=0 # 指定MultiGet 時生成的key之間的跨度,本來是連續的隨機key,現在可以讓上一個隨機key和下一個隨機key之間間隔指定的長度。
總結
總的來說,io_uring能夠在內核的各個組件都能夠正常運行的基礎上進一步提升了性能,提升的部分包括 減少系統調用的開銷,減少內核上下文的開銷,以及支持io_poll和sq_poll 這樣的高速輪詢處理機制。而且相比于libaio 僅能夠使用direct-io來調度,那這個限制本身就對存儲應用軟件不夠友好了。
可見的未來,存儲系統是內核的直接用戶,隨著未來硬件介質的超高速發展,互聯網應用對存儲系統的高性能需求就會反作用于內核,那內核的一些I/O鏈路的性能也需要不斷得跟進提升,然而每一項on-linux kernel的更改都因為內核精密復雜高要求 的 標準都會比普通的應用復雜很多,io_uring 能夠合入5系內核的upstream,那顯然證明了其未來的發展潛力 以及 內核社區 對其潛力的認可。
-
硬盤
+關注
關注
3文章
1308瀏覽量
57283 -
代碼
+關注
關注
30文章
4780瀏覽量
68527 -
編譯
+關注
關注
0文章
657瀏覽量
32852 -
組件
+關注
關注
1文章
512瀏覽量
17813
發布評論請先 登錄
相關推薦
評論