Python/Java/JS語言調(diào)用C/C++庫的底層原理詳解
前言
如果你是 Python、Java、JS(在本文中特指 Node.js 場景)等語言的深度用戶,那么你大概率會接觸過多語言混合編程的開發(fā)場景,最典型的就是用這些語言去調(diào)用 C/C ++ 寫的庫。
之所以會有這種多語言混合編程的做法,最主要的原因有以下幾個:
- 性能問題:有時候這些語言在性能上滿足不了我們的需求,對于一些性能敏感的場景,開發(fā)者們往往會考慮用 C/C++ 來寫庫。
- 運(yùn)行時封裝的能力有限:假設(shè)我要用 Node.js 技術(shù)棧開發(fā)一個 Docker 這樣的軟件,這個軟件會需要在拉起子進(jìn)程的時候設(shè)置子進(jìn)程的 namespace,Node.js 就沒有提供這樣的 API,所以我無法用純 JS 腳本開發(fā)出 Docker。如果一定要實(shí)現(xiàn)這樣的需求,我就必須要用 C/C++ 編寫一部分功能,再用 JS 代碼去調(diào)用它們。
- 復(fù)用現(xiàn)有 C/C++ 庫:以 FFmpeg 這個庫為例,假設(shè)我要在這些語言中使用 FFmpeg 的能力,跨語言調(diào)用 C/C++ 版本的 FFmpeg 庫是最省事的方案,也是業(yè)界主流的方案。對于 FFmpeg 這樣復(fù)雜的項(xiàng)目,要是換用這些語言去重寫一遍,不僅面臨性能問題,技術(shù)可行性和工作量也是很大的挑戰(zhàn)。
那么,對于這種多語言混合編程的做法,有沒有人好奇它們的底層技術(shù)實(shí)現(xiàn)呢?
在下面的內(nèi)容中,我將對這塊內(nèi)容做一個科普。并通過“造輪子”的方式,用 100 行代碼自己實(shí)現(xiàn)一個運(yùn)行時,帶大家體會其中的原理。
開發(fā)者的上層視角
假設(shè)我是一個 Python/Java/JS 開發(fā)者,我想在我的業(yè)務(wù)代碼中調(diào)用 C/C++ 庫。
在我的視角中,我大概會看到這樣的一些方案:
| 語言 | 方案 | 編寫 C/C++ 庫的時候 | 調(diào)用 C/C++ 庫的時候 |
|---|---|---|---|
| Python | extension module | 需要把 C/C++ 函數(shù)寫成固定簽名,讓它符合 Python/C API 規(guī)范。 | 在 Python 業(yè)務(wù)代碼中不需要手動聲明函數(shù)簽名,直接給函數(shù)傳值。 |
| Python | ctypes/cffi | 對函數(shù)簽名沒有約束,暴露的接口不需要遵循特定的規(guī)則。 | 在 Python 業(yè)務(wù)代碼中需要手動聲明函數(shù)簽名。 |
| Java | JNI library | 需要把 C/C++ 函數(shù)寫成固定簽名,讓它符合 JNI 規(guī)范。 | 在 Java 業(yè)務(wù)代碼中需要手動聲明函數(shù)簽名。 |
| Java | JNA/jffi | 對函數(shù)簽名沒有約束,暴露的接口不需要遵循特定的規(guī)則。 | 在 Java 業(yè)務(wù)代碼中需要手動聲明函數(shù)簽名。 |
| JS | addon | 需要把 C/C++ 函數(shù)寫成固定簽名,讓它符合 N-API 或 V8 規(guī)范,并按照規(guī)定的寫法進(jìn)行函數(shù)注冊。 | 在 JS 業(yè)務(wù)代碼中不需要手動聲明函數(shù)簽名,直接給函數(shù)傳值。 |
| JS | koffi/ffi-napi | 對函數(shù)簽名沒有約束,暴露的接口不需要遵循特定的規(guī)則。 | 在 JS 業(yè)務(wù)代碼中需要手動聲明函數(shù)簽名。 |
運(yùn)行時的底層行為
從底層來看,這 3 個語言的情況非常相似,針對調(diào)用 C/C++ 庫這種需求,它們的運(yùn)行時實(shí)際上只提供了唯一一套原生擴(kuò)展機(jī)制。我們見到的“多種方案”其實(shí)都是建立于原生擴(kuò)展機(jī)制之上的封裝方案。
以 Node.js 運(yùn)行時為例, Node.js 實(shí)際上只提供了 addon 機(jī)制。而 koffi 和 ffi-napi 是基于 addon 機(jī)制實(shí)現(xiàn)的庫。koffi 和 ffi-napi 這兩個庫,它們本身就是個 addon,只不過這兩個 addon 實(shí)現(xiàn)的業(yè)務(wù)比較特別:它們本身是 C/C++ 庫,同時它們又可以作為一個橋梁,供 JS 側(cè)用來調(diào)用其他的 C/C++ 庫。
在這里還需要提一下 libffi 這個庫,koffi 與 ffi-napi 的底層均基于 libffi。koffi 和 ffi-napi 需要在運(yùn)行時動態(tài)地按照 ABI(應(yīng)用程序二進(jìn)制接口) 規(guī)范,將腳本層的參數(shù)壓入?;蚍湃胩囟ǖ募拇嫫鳎⑻幚矸祷刂?。這個過程被稱為處理 Calling Convention(調(diào)用約定)。在這兩個庫中,這個過程是使用了 libffi 的能力來實(shí)現(xiàn)的。
同樣,CPython 運(yùn)行時 和 JDK 運(yùn)行時也是類似的情況,他們分別提供了 extension module 和 JNI library 作為自己的原生擴(kuò)展機(jī)制,并且在它們的生態(tài)中也有一些 FFI 封裝庫。
我們可以列個表格對比一下
| 語言 | 運(yùn)行時 | 原生擴(kuò)展機(jī)制 | 常見的 FFI 封裝庫 |
|---|---|---|---|
| Python | CPython | extension module | ctypes, cffi |
| Java | JDK | JNI library | JNA, jffi |
| JS | Node.js | addon | koffi, ffi-napi |
共同的基座
無論是 CPython 的 extension module 方案,還是 JDK 的 JNI library 方案,或者是 Node.js 的 addon 方案,在 Linux 環(huán)境下,它們共同的底層實(shí)現(xiàn)都是 dlopen()。(在 Windows 環(huán)境下是 LoadLibrary(),本文不分析 Windows)
當(dāng)我們調(diào)用原生擴(kuò)展的時候,CPython、JDK、Node.js 這些運(yùn)行時會這么做:
- 調(diào)用 libc 里面的
dlopen()函數(shù),把 C/C++ 開發(fā)的動態(tài)鏈接庫加載進(jìn)來。 - 調(diào)用 libc 里面的
dlsym()函數(shù),把函數(shù)名字解析成函數(shù)指針。 - 通過運(yùn)行時自己實(shí)現(xiàn)的一系列業(yè)務(wù)代碼,把函數(shù)指針包裝成腳本層的可調(diào)用對象,并“注入”到你的腳本上下文中,讓你在腳本里面能調(diào)用到那些函數(shù)。

這里我貼出各個運(yùn)行時調(diào)用 dlopen() 的代碼位置(帶行號的 GitHub 鏈接),有興趣者可以順著這個位置看看上下文的實(shí)現(xiàn)。
CPython(按調(diào)用鏈,從上到下):
- https://github.com/python/cpython/blob/v3.14.2/Lib/importlib/_bootstrap_external.py#L1053
- https://github.com/python/cpython/blob/v3.14.2/Python/import.c#L4743
- https://github.com/python/cpython/blob/v3.14.2/Python/importdl.c#L391
- https://github.com/python/cpython/blob/v3.14.2/Python/dynload_shlib.c#L82
JDK(按調(diào)用鏈,從上到下):
- https://github.com/openjdk/jdk/blob/jdk-17-ga/src/hotspot/share/prims/jvm.cpp#L3366
- https://github.com/openjdk/jdk/blob/jdk-17-ga/src/hotspot/os/linux/os_linux.cpp#L1607
- https://github.com/openjdk/jdk/blob/jdk-17-ga/src/hotspot/os/linux/os_linux.cpp#L1799
Node.js(按調(diào)用鏈,從上到下):
- https://github.com/nodejs/node/blob/v24.12.0/lib/internal/modules/cjs/loader.js#L1920
- https://github.com/nodejs/node/blob/v24.12.0/src/node_binding.cc#L477
- https://github.com/nodejs/node/blob/v24.12.0/src/node_binding.cc#L346
造個輪子體會一下
為了幫助大家更好地理解這一過程,我用 AI 生成了 100 行代碼,實(shí)現(xiàn)了一個迷你的運(yùn)行時,它實(shí)現(xiàn)了一門自創(chuàng)的腳本語言。在本文中,我們將這門語言稱為 MyScript 語言,簡稱 MS 語言,并使用 .ms 作為腳本文件的后綴名。
MyScript 語言的代碼長這樣
var a = 1 # 用 var 語句定義變量,僅支持整數(shù)變量 var b = 2 * 3 # 給變量賦值的時候,支持加減乘除表達(dá)式 var c = b + 1 # 賦值表達(dá)式中支持引用其他變量 print c # 有個 print 語句可以用于輸出計(jì)算結(jié)果
在這個運(yùn)行時中,我專門實(shí)現(xiàn)了調(diào)用 C/C++ 庫的功能,用法是這樣的
import ./my-lib.so # 導(dǎo)入動態(tài)鏈接庫 var a = call add 5 7 # 調(diào)用動態(tài)鏈接庫庫里面 的 add 函數(shù),計(jì)算 5 + 7 print a # 輸出結(jié)果
運(yùn)行時的 100 行完整代碼如下
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <dlfcn.h>
#include <ctype.h>
#define MAXVAR 64
char line[256];
char *position;
void *handle = NULL;
char name[MAXVAR][32];
int value[MAXVAR];
int var_count = 0;
static int lookup(const char *s)
{
for (int i = 0; i < var_count; ++i) if (!strcmp(name[i], s)) return i;
return -1;
}
static char *next()
{
while (*position && isspace(*position)) ++position;
if (!*position) return NULL;
char *start = position;
while (*position && !isspace(*position)) ++position;
*position++ = '\0';
return start;
}
int expr()
{
char *token = next();
if (!token) return 0;
int x;
if (strcmp(token, "call") == 0) {
char *sym = next();
int a = expr();
int b = expr();
int (*f)(int, int) = dlsym(handle, sym);
x = f ? f(a, b) : 0;
} else {
int i = lookup(token);
x = (i >= 0) ? value[i] : atoi(token);
}
while ((token = next())) {
if (strcmp(token, "+") == 0) { x += expr(); }
else if (strcmp(token, "-") == 0) { x -= expr(); }
else if (strcmp(token, "*") == 0) { x *= expr(); }
else if (strcmp(token, "/") == 0) { x /= expr(); }
else { position -= strlen(token) + 1; break; }
}
return x;
}
void run(FILE *fp)
{
while (fgets(line, sizeof(line), fp)) {
position = line;
char *token = next();
if (!token || *token == '#') continue;
if (strcmp(token, "import") == 0) {
char *so = next();
handle = dlopen(so, RTLD_LAZY);
if (!handle) { puts(dlerror()); exit(1); }
}
else if (strcmp(token, "print") == 0) {
char *var = next();
int i = lookup(var);
if (i < 0) { printf("undefined: %s\n", var); exit(1); }
printf("%d\n", value[i]);
}
else if (strcmp(token, "var") == 0) {
char *var = next();
next();
if (var_count == MAXVAR) { puts("too many vars"); exit(1); }
strcpy(name[var_count], var);
value[var_count++] = expr();
}
else {
char *var = token;
if (next() && strcmp(next(), "=") == 0) {
int i = lookup(var);
if (i < 0) { printf("undefined: %s\n", var); exit(1); }
value[i] = expr();
}
}
}
}
int main(int argc,char **argv)
{
run(fopen(argv[1], "r"));
return 0;
}
接下來我們在 Linux 系統(tǒng)上把這個運(yùn)行時跑起來,體驗(yàn)一下在腳本里面調(diào)用 C/C++ 庫的效果。
首先把運(yùn)行時給編譯出來
gcc ms.c -ldl -o ms
編寫一個 my-lib.c,并將其編譯成 so
echo "int add(int a,int b){return a+b;}" > my-lib.c
gcc -shared -fPIC my-lib.c -o my-lib.so
編寫一段 MyScript 腳本,將其保存成文件 test.ms
import ./my-lib.so var a = call add 5 7 print a
用運(yùn)行時執(zhí)行這個腳本,能得到結(jié)果 12
./ms test.ms # 12
受限于代碼篇幅,這個運(yùn)行時并沒有做得很完善,是有很多 bug 的。大家不必糾結(jié)于代碼里面的具體細(xì)節(jié),大致看個思路就行。
這個運(yùn)行時之所以能支持腳本調(diào)用 C/C++ 庫,關(guān)鍵就在于 dlopen() 函數(shù)的使用。只要理解了這一思路,自然也就能理解 CPython、JDK、Node.js 等運(yùn)行時的實(shí)現(xiàn)原理了。
當(dāng)然,這里呈現(xiàn)的只是一個最基本的原理。在實(shí)際的工程落地場景中,CPython、JDK、Node.js 的業(yè)務(wù)邏輯是非常復(fù)雜的。
擴(kuò)展知識
1. 這些運(yùn)行時也能調(diào)用其他編譯型語言寫的庫
上文只提到了 C/C++,并未介紹其他編譯型語言的情況。事實(shí)上 rust 和 go 等語言情況也是類似的,它們也支持生成動態(tài)鏈接庫。他們編出來的動態(tài)鏈接庫也是能被 CPython、JDK、Node.js 這些運(yùn)行時加載的。
rust、go 編出來的動態(tài)鏈接庫,只要對外的接口符合相應(yīng)的規(guī)范(Python/C API、JNI、N-API),他們也能被這幾個運(yùn)行時的原生擴(kuò)展機(jī)制(extension module、JNI library、addon)直接識別。
即使不符合相應(yīng)的規(guī)范,那也沒關(guān)系,還有一些封裝出來的 FFI 方案(ctypes、JNA、ffi-napi 等)可以讓你調(diào)用這些不符合規(guī)范的接口。
現(xiàn)在這幾個語言的生態(tài)也在逐漸有人嘗試用 C/C++ 以外的編譯型語言來寫原生擴(kuò)展了,比如 Node.js 生態(tài)里面的 rollup、rolldown 等庫就是用 rust 來實(shí)現(xiàn)的。
2. 調(diào)用動態(tài)鏈接庫也是有性能開銷的
雖然把熱點(diǎn)邏輯用 C/C++ 實(shí)現(xiàn)能提速,但每一次跨語言調(diào)用本身都會帶來額外成本。如果頻繁在兩種語言之間來回切換(例如在循環(huán)里反復(fù)調(diào)用一個輕量級函數(shù)),反而可能比純腳本實(shí)現(xiàn)更慢。
3. 注意符號修飾的問題
如果你的動態(tài)鏈接庫不是純 C 寫的,而是 C++ 或其他語言寫的,那需要注意符號修飾的問題。比如在 C++ 代碼里加 extern "C",避免符號被修飾而產(chǎn)生“找不到符號”之類的錯誤。
總結(jié)
到此這篇關(guān)于Python/Java/JS語言調(diào)用C/C++庫的底層原理的文章就介紹到這了,更多相關(guān)Python/Java/JS調(diào)用C/C++庫內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
用C語言舉例講解數(shù)據(jù)結(jié)構(gòu)中的算法復(fù)雜度結(jié)與順序表
這篇文章主要介紹了講解數(shù)據(jù)結(jié)構(gòu)中的算法復(fù)雜度結(jié)與順序表的C語言版示例,包括對時間復(fù)雜度和空間復(fù)雜度等概念的簡單講解,需要的朋友可以參考下2016-02-02
C++ 使用模板實(shí)現(xiàn)一個List的實(shí)例
這篇文章主要介紹了 C++ 使用模板實(shí)現(xiàn)一個List的實(shí)例的相關(guān)資料,需要的朋友可以參考下2017-05-05
C++數(shù)據(jù)序列化方式(自定義結(jié)構(gòu)體的保存和讀取)
這篇文章主要介紹了C++數(shù)據(jù)序列化方式(自定義結(jié)構(gòu)體的保存和讀取),具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2023-08-08

