plan9 assembly 入門
眾所周知,Go 使用了 Unix 老古董 (誤 們發明的 plan9 彙編。就算你對 x86 彙編有所瞭解,在 plan9 裡還是有些許區別。說不定你在看程式碼的時候,偶然發現程式碼裡的 SP 看起來是 SP,但它實際上不是 SP 的時候就抓狂了哈哈哈。
本文將對 plan9 彙編進行全面的介紹,同時解答你在接觸 plan9 彙編時可能遇到的大部分問題。
本文所使用的平臺是 linux amd64,因為不同的平臺指令集和暫存器都不一樣,所以沒有辦法共同探討。這也是由彙編本身的性質決定的。
基本指令
棧調整
在 intel 或 AT&T 彙編中有 push 和 pop 指令,這些在 plan9 是沒有的,棧的調整通過對硬體 SP 暫存器進行運算來實現的,例如:
SUBQ $0x18, SP // 對 SP 做減法,為函式分配函式棧幀
ADDQ $0x18, SP // 對 SP 做加法,清除函式棧幀
通用的指令和 IA64 平臺差不多,比如:
資料搬運
常數在 plan9 彙編用 $num 表示,可以為負數,預設情況下為十進位制。可以用 $0x123 的形式來表示十六進位制數。
MOVB $1, DI // 1 byte
MOVW $0x10, BX // 2 bytes
MOVD $1, DX // 4 bytes
MOVQ $-10, AX // 8 bytes
可以看到,搬運的長度是由 MOV 的字尾決定的,這一點與 intel 彙編稍有不同,看看類似的 IA64 彙編:
mov rax, 0x1 // 8 bytes
mov eax, 0x100 // 4 bytes
mov ax, 0x22 // 2 bytes
mov ah, 0x33 // 1 byte
mov al, 0x44 // 1 byte
plan9 的彙編的運算元的方向是和 intel 彙編相反的,與 AT&T 類似。
MOVQ $0x10, AX ===== mov rax, 0x10
| |------------| |
|------------------------|
常見計算指令
ADDQ AX, BX // BX += AX
SUBQ AX, BX // BX -= AX
IMULQ AX, BX // BX *= AX
類似資料搬運指令,同樣可以通過修改指令的字尾來對應不同長度的運算元。例如 ADDQ/ADDW/ADDL/ADDB。
條件跳轉/無條件跳轉
// 無條件跳轉
JMP addr // 跳轉到地址,地址可為程式碼中的地址,不過實際上手寫不會出現這種東西
JMP label // 跳轉到標籤,可以跳轉到同一函式內的標籤位置
JMP 2(PC) // 以當前指令為基礎,向前/後跳轉 x 行
JMP -2(PC) // 同上
// 有條件跳轉
JNZ target // 如果 zero flag 被 set 過,則跳轉
指令集
可以參考原始碼的 arch 部分。
額外提一句,Go 1.10 新增了大量的 SIMD 指令支援,所以在該版本以上的話,不像之前寫那樣痛苦了,也就是不用人肉填 byte 了。
暫存器
通用暫存器
amd64 的通用暫存器:
(lldb) reg read
General Purpose Registers:
rax = 0x0000000000000005
rbx = 0x000000c420088000
rcx = 0x0000000000000000
rdx = 0x0000000000000000
rdi = 0x000000c420088008
rsi = 0x0000000000000000
rbp = 0x000000c420047f78
rsp = 0x000000c420047ed8
r8 = 0x0000000000000004
r9 = 0x0000000000000000
r10 = 0x000000c420020001
r11 = 0x0000000000000202
r12 = 0x0000000000000000
r13 = 0x00000000000000f1
r14 = 0x0000000000000011
r15 = 0x0000000000000001
rip = 0x000000000108ef85 int`main.main + 213 at int.go:19
rflags = 0x0000000000000212
cs = 0x000000000000002b
fs = 0x0000000000000000
gs = 0x0000000000000000
在 plan9 彙編裡都是可以使用的,應用程式碼層面會用到的通用暫存器主要是: rax, rbx, rcx, rdx, rdi, rsi, r8~r15 這 14 個暫存器,雖然 rbp 和 rsp 也可以用,不過 bp 和 sp 會被用來管理棧頂和棧底,最好不要拿來進行運算。
plan9 中使用暫存器不需要帶 r 或 e 的字首,例如 rax,只要寫 AX 即可:
MOVQ $101, AX = mov rax, 101
下面是通用通用暫存器的名字在 IA64 和 plan9 中的對應關係:
IA64 | rax | rbx | rcx | rdx | rdi | rsi | rbp | rsp | r8 | r9 | r10 | r11 | r12 | r13 | r14 | rip |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Plan9 | AX | BX | CX | DX | DI | SI | BP | SP | R8 | R9 | R10 | R11 | R12 | R13 | R14 | PC |
偽暫存器
Go 的彙編還引入了 4 個偽暫存器,援引官方文件的描述:
>- FP
: Frame pointer: arguments and locals.
>- PC
: Program counter: jumps and branches.
>- SB
: Static base pointer: global symbols.
>- SP
: Stack pointer: top of stack.
官方的描述稍微有一些問題,我們對這些說明進行一點擴充:
- FP: 使用形如
symbol+offset(FP)
的方式,引用函式的輸入引數。例如arg0+0(FP)
,arg1+8(FP)
,使用 FP 不加 symbol 時,無法通過編譯,在彙編層面來講,symbol 並沒有什麼用,加 symbol 主要是為了提升程式碼可讀性。另外,官方文件雖然將偽暫存器 FP 稱之為 frame pointer,實際上它根本不是 frame pointer,按照傳統的 x86 的習慣來講,frame pointer 是指向整個 stack frame 底部的 BP 暫存器。假如當前的 callee 函式是 add,在 add 的程式碼中引用 FP,該 FP 指向的位置不在 callee 的 stack frame 之內,而是在 caller 的 stack frame 上。具體可參見之後的 棧結構 一章。 - PC: 實際上就是在體系結構的知識中常見的 pc 暫存器,在 x86 平臺下對應 ip 暫存器,amd64 上則是 rip。除了個別跳轉之外,手寫 plan9 程式碼與 PC 暫存器打交道的情況較少。
- SB: 全域性靜態基指標,一般用來宣告函式或全域性變數,在之後的函式知識和示例部分會看到具體用法。
- SP: plan9 的這個 SP 暫存器指向當前棧幀的區域性變數的開始位置,使用形如
symbol+offset(SP)
的方式,引用函式的區域性變數。offset 的合法取值是 [-framesize, 0),注意是個左閉右開的區間。假如區域性變數都是 8 位元組,那麼第一個區域性變數就可以用localvar0-8(SP)
來表示。這也是一個詞不表意的暫存器。與硬體暫存器 SP 是兩個不同的東西,在棧幀 size 為 0 的情況下,偽暫存器 SP 和硬體暫存器 SP 指向同一位置。手寫彙編程式碼時,如果是symbol+offset(SP)
形式,則表示偽暫存器 SP。如果是offset(SP)
則表示硬體暫存器 SP。務必注意。對於編譯輸出 (go tool compile -S / go tool objdump) 的程式碼來講,目前所有的 SP 都是硬體暫存器 SP,無論是否帶 symbol。
我們這裡對容易混淆的幾點簡單進行說明:
- 偽 SP 和硬體 SP 不是一回事,在手寫程式碼時,偽 SP 和硬體 SP 的區分方法是看該 SP 前是否有 symbol。如果有 symbol,那麼即為偽暫存器,如果沒有,那麼說明是硬體 SP 暫存器。
- SP 和 FP 的相對位置是會變的,所以不應該嘗試用偽 SP 暫存器去找那些用 FP + offset 來引用的值,例如函式的入參和返回值。
- 官方文件中說的偽 SP 指向 stack 的 top,是有問題的。其指向的區域性變數位置實際上是整個棧的棧底 (除 caller BP 之外),所以說 bottom 更合適一些。
- 在 go tool objdump/go tool compile -S 輸出的程式碼中,是沒有偽 SP 和 FP 暫存器的,我們上面說的區分偽 SP 和硬體 SP 暫存器的方法,對於上述兩個命令的輸出結果是沒法使用的。在編譯和反彙編的結果中,只有真實的 SP 暫存器。
- FP 和 Go 的官方原始碼裡的 framepointer 不是一回事,原始碼裡的 framepointer 指的是 caller BP 暫存器的值,在這裡和 caller 的偽 SP 是值是相等的。
以上說明看不懂也沒關係,在熟悉了函式的棧結構之後再反覆回來檢視應該就可以明白了。個人意見,這些是 Go 官方挖的坑。。
變數宣告
在彙編裡所謂的變數,一般是儲存在 .rodata 或者 .data 段中的只讀值。對應到應用層的話,就是已初始化過的全域性的 const、var、static 變數/常量。
使用 DATA 結合 GLOBL 來定義一個變數。DATA 的用法為:
DATA symbol+offset(SB)/width, value
大多數引數都是字面意思,不過這個 offset 需要稍微注意。其含義是該值相對於符號 symbol 的偏移,而不是相對於全域性某個地址的偏移。
使用 GLOBL 指令將變數宣告為 global,額外接收兩個引數,一個是 flag,另一個是變數的總大小。
GLOBL divtab(SB), RODATA, $64
GLOBL 必須跟在 DATA 指令之後,下面是一個定義了多個 readonly 的全域性變數的完整例子:
DATA age+0x00(SB)/4, $18 // forever 18
GLOBL age(SB), RODATA, $4
DATA pi+0(SB)/8, $3.1415926
GLOBL pi(SB), RODATA, $8
DATA birthYear+0(SB)/4, $1988
GLOBL birthYear(SB), RODATA, $4
正如之前所說,所有符號在宣告時,其 offset 一般都是 0。
有時也可能會想在全域性變數中定義陣列,或字串,這時候就需要用上非 0 的 offset 了,例如:
DATA bio<>+0(SB)/8, $"oh yes i"
DATA bio<>+8(SB)/8, $"am here "
GLOBL bio<>+0(SB), RODATA, $16
大部分都比較好理解,不過這裡我們又引入了新的標記 <>
,這個跟在符號名之後,表示該全域性變數只在當前檔案中生效,類似於 C 語言中的 static。如果在另外檔案中引用該變數的話,會報 relocation target not found
的錯誤。
本小節中提到的 flag,還可以有其它的取值:
>- NOPROF
= 1
(For TEXT
items.) Don't profile the marked function. This flag is deprecated.
>- DUPOK
= 2
It is legal to have multiple instances of this symbol in a single binary. The linker will choose one of the duplicates to use.
>- NOSPLIT
= 4
(For TEXT
items.) Don't insert the preamble to check if the stack must be split. The frame for the routine, plus anything it calls, must fit in the spare space at the top of the stack segment. Used to protect routines such as the stack splitting code itself.
>- RODATA
= 8
(For DATA
and GLOBL
items.) Put this data in a read-only section.
>- NOPTR
= 16
(For DATA
and GLOBL
items.) This data contains no pointers and therefore does not need to be scanned by the garbage collector.
>- WRAPPER
= 32
(For TEXT
items.) This is a wrapper function and should not count as disabling recover
.
>- NEEDCTXT
= 64
(For TEXT
items.) This function is a closure so it uses its incoming context register.
當使用這些 flag 的字面量時,需要在彙編檔案中 #include "textflag.h"
。
函式宣告
我們來看看一個典型的 plan9 的彙編函式的定義:
// func add(a, b int) int
// => 該宣告定義在同一個 package 下的任意 .go 檔案中
// => 只有函式頭,沒有實現
TEXT pkgname·add(SB), NOSPLIT, $0-8
MOVQ a+0(FP), AX
MOVQ a+8(FP), BX
ADDQ AX, BX
MOVQ BX, ret+16(FP)
RET
為什麼要叫 TEXT ?如果對程式資料在檔案中和記憶體中的分段稍有了解的同學應該知道,我們的程式碼在二進位制檔案中,是儲存在 .text 段中的,這裡也就是一種約定俗成的起名方式。實際上在 plan9 中 TEXT 是一個指令,用來定義一個函式。除了 TEXT 之外還有前面變數宣告說到的 DATA/GLOBL。
定義中的 pkgname 部分是可以省略的,非想寫也可以寫上。不過寫上 pkgname 的話,在重新命名 package 之後還需要改程式碼,所以推薦最好還是不要寫。
中點 ·
比較特殊,是一個 unicode 的中點,該點在 mac 下的輸入方法是 option+shift+9
。在程式被連結之後,所有的中點·
都會被替換為句號.
,比如你的方法是 runtime·main
,在編譯之後的程式裡的符號則是 runtime.main
。嗯,看起來很變態。簡單總結一下:
引數及返回值大小
|
TEXT pkgname·add(SB),NOSPLIT,$32-32
| | |
包名 函式名 棧幀大小(區域性變數+可能需要的額外呼叫函式的引數空間的總大小,但不包括呼叫其它函式時的 ret address 的大小)
棧結構
下面是一個典型的函式的棧結構圖:
-----------------
current func arg0
----------------- <----------- FP(pseudo FP)
caller ret addr
+---------------+
| caller BP(*) |
----------------- <----------- SP(pseudo SP,實際上是當前棧幀的 BP 位置)
| Local Var0 |
-----------------
| Local Var1 |
-----------------
| Local Var2 |
----------------- -
| ........ |
-----------------
| Local VarN |
-----------------
| |
| |
| temporarily |
| unused space |
| |
| |
-----------------
| call retn |
-----------------
| call ret(n-1)|
-----------------
| .......... |
-----------------
| call ret1 |
-----------------
| call argn |
-----------------
| ..... |
-----------------
| call arg3 |
-----------------
| call arg2 |
|---------------|
| call arg1 |
----------------- <------------ hardware SP 位置
| return addr |
+---------------+
圖上的 caller BP,指的是 caller 的 BP 暫存器值,有些人把 caller BP 叫作 caller 的 frame pointer,實際上這個習慣是從 x86 架構沿襲來的。Go 的 asm 文件中把偽暫存器 FP 也稱為 frame pointer,但是這兩個 frame pointer 根本不是一回事。
此外需要注意的是,caller BP 是在編譯期由編譯器插入的,使用者手寫程式碼時,計算 frame size 時是不包括這個 caller BP 部分的。是否插入 caller BP 的主要判斷依據是:
- 函式的棧幀大小大於 0
- 下述函式返回 true
func Framepointer_enabled(goos, goarch string) bool {
return framepointer_enabled != 0 && goarch == "amd64" && goos != "nacl"
}
如果編譯器在最終的彙編結果中沒有插入 caller BP(原始碼中所稱的 frame pointer) 的情況下,偽 SP 和偽 FP 之間只有 8 個位元組的 caller 的 return address,而插入了 BP 的話,就會多出額外的 8 位元組。也就說偽 SP 和偽 FP 的相對位置是不固定的,有可能是間隔 8 個位元組,也有可能間隔 16 個位元組。並且判斷依據會根據平臺和 Go 的版本有所不同。
圖上可以看到,FP 偽暫存器指向函式的傳入引數的開始位置,因為棧是朝低地址方向增長,為了通過暫存器引用引數時方便,所以引數的擺放方向和棧的增長方向是相反的,即:
FP
high ----------------------> low
argN, ... arg3, arg2, arg1, arg0
假設所有引數均為 8 位元組,這樣我們就可以用 symname+0(FP) 訪問第一個 引數,symname+8(FP) 訪問第二個引數,以此類推。用偽 SP 來引用區域性變數,原理上來講差不多,不過因為偽 SP 指向的是區域性變數的底部,所以 symname-8(SP) 表示的是第一個區域性變數,symname-16(SP) 表示第二個,以此類推。當然,這裡假設區域性變數都佔用 8 個位元組。
圖的最上部的 caller return address 和 current func arg0 都是由 caller 來分配空間的。不算在當前的棧幀內。
因為官方文件本身較模糊,我們來一個函式呼叫的全景圖,來看一下這些真假 SP/FP/BP 到底是個什麼關係:
caller
+------------------+
| |
+----------------------> --------------------
| | |
| | caller parent BP |
| BP(pseudo SP) --------------------
| | |
| | Local Var0 |
| --------------------
| | |
| | ....... |
| --------------------
| | |
| | Local VarN |
--------------------
caller stack frame | |
| callee arg2 |
| |------------------|
| | |
| | callee arg1 |
| |------------------|
| | |
| | callee arg0 |
| ----------------------------------------------+ FP(virtual register)
| | | |
| | return addr | parent return address |
+----------------------> +------------------+--------------------------- <-------------------------------+
| caller BP | |
| (caller frame pointer) | |
BP(pseudo SP) ---------------------------- |
| | |
| Local Var0 | |
---------------------------- |
| |
| Local Var1 |
---------------------------- callee stack frame
| |
| ..... |
---------------------------- |
| | |
| Local VarN | |
SP(Real Register) ---------------------------- |
| | |
| | |
| | |
| | |
| | |
+--------------------------+ <-------------------------------+
callee
PS 這張圖在 gocn 上渲染有點問題,想看正常版本去文末找我 github 的連結。
argsize 和 framesize 計算規則
argsize
在函式宣告中:
TEXT pkgname·add(SB),NOSPLIT,$16-32
前面已經說過 $16-32 表示 $framesize-argsize。Go 在函式呼叫時,引數和返回值都需要由 caller 在其棧幀上備好空間。callee 在宣告時仍然需要知道這個 argsize。argsize 的計算方法是,引數大小求和 + 返回值大小求和,例如入參是 3 個 int64 型別,返回值是 1 個 int64 型別,那麼這裡的 argsize = sizeof(int64) * 4。
不過真實世界永遠沒有我們假設的這麼美好,函式引數往往混合了多種型別,還需要考慮記憶體對齊問題。
如果不確定自己的函式簽名需要多大的 argsize,可以通過簡單實現一個相同簽名的空函式,然後 go tool objdump 來逆向查詢應該分配多少空間。
framesize
函式的 framesize 就稍微複雜一些了,手寫程式碼的 framesize 不需要考慮由編譯器插入的 caller BP,要考慮:
- 區域性變數,及其每個變數的 size。
- 在函式中是否有對其它函式呼叫時,如果有的話,呼叫時需要將 callee 的引數、返回值考慮在內。雖然 return address(rip) 的值也是儲存在 caller 的 stack frame 上的,但是這個過程是由 CALL 指令和 RET 指令完成 PC 暫存器的儲存和恢復的,在手寫彙編時,同樣也是不需要考慮這個 PC 暫存器在棧上所需佔用的 8 個位元組的。
- 原則上來說,呼叫函式時只要不把區域性變數覆蓋掉就可以了。稍微多分配幾個位元組的 framesize 也不會死。
- 在確保邏輯沒有問題的前提下,你願意覆蓋區域性變數也沒有問題。只要保證進入和退出彙編函式時的 caller 能正確拿到返回值就行。
示例
add/sub/mul
math.go:
package main
import "fmt"
func add(a, b int) int // 彙編函式宣告
func sub(a, b int) int // 彙編函式宣告
func mul(a, b int) int // 彙編函式宣告
func main() {
fmt.Println(add(10, 11))
fmt.Println(sub(99, 15))
fmt.Println(mul(11, 12))
}
math.s:
#include "textflag.h" // 因為我們宣告函式用到了 NOSPLIT 這樣的 flag,所以需要將 textflag.h 包含進來
// func add(a, b int) int
TEXT ·add(SB), NOSPLIT, $0-24
MOVQ a+0(FP), AX // 引數 a
MOVQ b+8(FP), BX // 引數 b
ADDQ BX, AX // AX += BX
MOVQ AX, ret+16(FP) // 返回
RET
// func sub(a, b int) int
TEXT ·sub(SB), NOSPLIT, $0-24
MOVQ a+0(FP), AX
MOVQ b+8(FP), BX
SUBQ BX, AX // AX -= BX
MOVQ AX, ret+16(FP)
RET
// func mul(a, b int) int
TEXT ·mul(SB), NOSPLIT, $0-24
MOVQ a+0(FP), AX
MOVQ b+8(FP), BX
IMULQ BX, AX // AX *= BX
MOVQ AX, ret+16(FP)
RET
// 最後一行的空行是必須的,否則可能報 unexpected EOF
把這兩個檔案放在任意目錄下,執行 go build
並執行就可以看到效果了。
偽暫存器 SP 、偽暫存器 FP 和硬體暫存器 SP
來寫一段簡單的程式碼證明偽 SP、偽 FP 和硬體 SP 的位置關係。 spspfp.s:
#include "textflag.h"
// func output(int) (int, int, int)
TEXT ·output(SB), $8-48
MOVQ 24(SP), DX // 不帶 symbol,這裡的 SP 是硬體暫存器 SP
MOVQ DX, ret3+24(FP) // 第三個返回值
MOVQ perhapsArg1+16(SP), BX // 當前函式棧大小 > 0,所以 FP 在 SP 的上方 16 位元組處
MOVQ BX, ret2+16(FP) // 第二個返回值
MOVQ arg1+0(FP), AX
MOVQ AX, ret1+8(FP) // 第一個返回值
RET
spspfp.go:
package main
import (
"fmt"
)
func output(int) (int, int, int) // 彙編函式宣告
func main() {
a, b, c := output(987654321)
fmt.Println(a, b, c)
}
執行上面的程式碼,可以得到輸出:
987654321 987654321 987654321
和程式碼結合思考,可以知道我們當前的棧結構是這樣的:
------
ret2 (8 bytes)
------
ret1 (8 bytes)
------
ret0 (8 bytes)
------
arg0 (8 bytes)
------ FP
ret addr (8 bytes)
------
caller BP (8 bytes)
------ pseudo SP
frame content (8 bytes)
------ hardware SP
本小節例子的 framesize 是大於 0 的,讀者可以嘗試修改 framesize 為 0,然後調整程式碼中引用偽 SP 和硬體 SP 時的 offset,來研究 framesize 為 0 時,偽 FP,偽 SP 和硬體 SP 三者之間的相對位置。
本小節的例子是為了告訴大家,偽 SP 和偽 FP 的相對位置是會變化的,手寫時不應該用偽 SP 和 >0 的 offset 來引用資料,否則結果可能會出乎你的預料。
彙編呼叫非彙編函式
output.s:
#include "textflag.h"
// func output(a,b int) int
TEXT ·output(SB), NOSPLIT, $24-8
MOVQ a+0(FP), DX // arg a
MOVQ DX, 0(SP) // arg x
MOVQ b+8(FP), CX // arg b
MOVQ CX, 8(SP) // arg y
CALL ·add(SB) // 在呼叫 add 之前,已經把引數都通過物理暫存器 SP 搬到了函式的棧頂
MOVQ 16(SP), AX // add 函式會把返回值放在這個位置
MOVQ AX, ret+16(FP) // return result
RET
output.go:
package main
import "fmt"
func add(x, y int) int {
return x + y
}
func output(a, b int) int
func main() {
s := output(10, 13)
fmt.Println(s)
}
擴充套件話題
這部分內容在 blog 裡就不寫了,之後慢慢總結在 github 上: > https://github.com/cch123/golang-notes/blob/master/assembly.md
特別鳴謝
研究過程基本碰到不太明白的都去騷擾卓巨巨了,就是這位 https://mzh.io/ 大大。特別感謝他,給了不少線索和提示。
參考資料
- https://quasilyte.github.io/blog/post/go-asm-complementary-reference/#external-resources
- http://davidwong.fr/goasm
- https://www.doxsey.net/blog/go-and-assembly
- https://github.com/golang/go/files/447163/GoFunctionsInAssembly.pdf
- https://golang.org/doc/asm
參考資料 [4] 需要特別注意,在該 slide 中給出的 callee stack frame 中把 caller 的 return address 也包含進去了,個人認為不是很合適。
- 加微信實戰群請加微信(註明:實戰群):gocnio
相關文章
- 智慧合約從入門到精通:Solidity AssemblySolid
- Solidity:assemblySolid
- Profiling an Assembly Program
- Dynamics CRM Plug-in assembly does not contain the required types or assembly content cannot be...AIUI
- 淺談 C# Assembly 與 IL (一):C# Assembly 與 ReflectionC#
- sbt的assembly外掛
- web assembly 初體驗Web
- [譯][A crash course in WebAssembly] assemblyWeb
- CS 0447 Computer Organization and Assembly
- 入門入門入門 MySQL命名行MySql
- Go plan9 彙編:說透函式棧Go函式
- 008 Web Assembly之效能分析Web
- Could not load file or assembly or one of its dependencies 試圖載入格式不正確的程式
- 何入CTF的“門”?——所謂入門就是入門
- 如何入CTF的“門”?——所謂入門就是入門
- pwn.college Fundementals Assembly Crash Course
- 006 Web Assembly之除錯方法Web除錯
- Apache Maven Assembly Plugin 的介紹ApacheMavenPlugin
- scala 從入門到入門+
- makefile從入門到入門
- gRPC(二)入門:Protobuf入門RPC
- 【小入門】react極簡入門React
- Android入門教程 | RecyclerView使用入門AndroidView
- Go plan9 彙編:記憶體對齊和遞迴Go記憶體遞迴
- Assembly.CreateInstance()與Activator.CreateInstance()方法
- 009 Web Assembly學習結束篇Web
- 新手入門,webpack入門詳細教程Web
- Android入門教程 | Kotlin協程入門AndroidKotlin
- 《Flutter 入門經典》之“Flutter 入門 ”Flutter
- MyBatis從入門到精通(一):MyBatis入門MyBatis
- Solidity之旅(十八)內聯彙編 [inline assembly]Solidinline
- Architecture 1001: x86-64 Assembly 彙編
- web assembly是什麼,能幹什麼Web
- 004 Web Assembly康威遊戲之優化Web遊戲優化
- 007 Web Assembly康威遊戲新增互動Web遊戲
- Tableau入門
- angular入門Angular
- lodash入門