一句话:仓库里 src/capi/ 那套 C ABI(不透明句柄 + extern "C" 函数 + C 函数
指针回调)在这里被逐条镜像成 C# 的 DllImport。拿上下面两个 .cs 文件,再
配一份 libuvcpp 动态库,C# 工程就能直接用这个库了 —— 不需要 C++ 编译器,也不
需要任何 NuGet 包。
这一层量的是什么、C 面的契约(错误码、回调表 size 规则、数据所有权、线程规
则)都写在 doc/capi-guide.md 里。这份 README 只讲
C# 这一侧的事。
| 文件 | 对应 C 头 | DllImport 条数 |
|---|---|---|
UvcppNative.cs |
uvcpp_c_common.h / _net.h / _web.h / _webapp.h |
183 |
UvcppNative.Protocols.cs |
uvcpp_c_http2.h / _quic.h / _http3.h |
138 |
examples/QuicEcho/ |
—— | 一个能跑的例子(回显服务端 + 自带客户端) |
每份预编译发布包(libuvcpp-<版本>-<平台>.zip)里也有一个 bindings/csharp/
目录,内容就是这五份文件(bin/、obj/ 是本机 dotnet build 的产物,刻意不发)
—— 换句话说,C# 侧要的东西在发布包里是齐的,不必再回仓里捞。
两份 .cs 合起来 321 条,与 tests/tools/capi_symbols.lock 里的 321 个 C 符号
一一对应(怎么自己核,见 §七)。它们是同一个 UvcppNative partial 类的两半:
Lib 这个 DllImport 名字、UVCPP_C_ABI_VERSION、UvcppError / UvcppCException /
UvcppErrorInfo 都只在第一半里声明,第二半直接用。
镜像的规矩是不增不减:C# 方法名就是 C 里的名字(uvcpp_c_quic_server_bind
就叫这个名字),参数与返回值的映射见 §五。
1. 把两个 .cs 丢进你的工程。 它们只用 System.Runtime.InteropServices 和
基础 BCL —— 没有 unsafe、没有 NativeLibrary、没有源生成器。工程文件里显式
列上即可(也可以直接放进目录让它被默认 glob 收走):
<ItemGroup>
<Compile Include="bindings/csharp/UvcppNative.cs" />
<Compile Include="bindings/csharp/UvcppNative.Protocols.cs" />
</ItemGroup>2. 让运行时找得到动态库。 名字、放在哪、Windows 上那个坑,全在 §三。
3. 第一件事:比对 ABI 版本。 这是 P/Invoke 最常见的故障模式 —— 头(也就是
这两个 .cs)与 .so / .dll 不是一次编出来的。不比对的话,症状通常不是一句
"版本不对",而是某个结构体少一格、某次回调把栈写坏:
if (UvcppNative.uvcpp_c_abi_version() != UvcppNative.UVCPP_C_ABI_VERSION)
throw new InvalidOperationException("这份绑定和这份动态库不是一套");examples/QuicEcho/Program.cs 的 CheckAbi() 就是这一段,可以直接抄。
DllImport 用的名字是 uvcpp,各平台由运行时自己补前后缀:
| 平台 | 运行时实际会找 | 发布包里那份文件 |
|---|---|---|
| Linux | uvcpp → libuvcpp → uvcpp.so → libuvcpp.so |
libuvcpp.so |
| macOS(自行构建) | uvcpp → libuvcpp → uvcpp.dylib → libuvcpp.dylib |
—— |
| Windows + MSVC 包 | uvcpp.dll → uvcpp.exe |
uvcpp.dll ✅ 名字正好对上 |
| Windows + MinGW 包 | uvcpp.dll → uvcpp.exe |
libuvcpp.dll |
libuvcpp.dll,
而 Windows 的探测不试 lib 前缀(lib 前缀是 Linux/macOS 的规矩),所以
拿 MinGW 包的人直接 DllImport("uvcpp") 会得到 DllNotFoundException。三个办法,
任选一个:
- 用 MSVC 包(
uvcpp-x.y.z-msvc-x64.zip里的就是uvcpp.dll,开箱即用); - 把文件复制/改名为
uvcpp.dll,放在 exe 同目录(探测顺序里有应用目录); - 自己装一个解析器(.NET Core 3.0+ / net5+ 有
NativeLibrary):
using System.Reflection;
using System.Runtime.InteropServices;
// 放在 Main 的第一句。
NativeLibrary.SetDllImportResolver(typeof(UvcppNative).Assembly, (name, asm, paths) =>
{
if (name != "uvcpp") return IntPtr.Zero; // 只管自己这一枚
foreach (string cand in new[] { "uvcpp", "libuvcpp" })
if (NativeLibrary.TryLoad(cand, asm, paths, out IntPtr h)) return h;
return IntPtr.Zero; // 交回默认探测
});Linux 上让运行时找到它:LD_LIBRARY_PATH=<包里的 lib 目录>,或者把 libuvcpp.so
放到 exe 旁边(同时记得 rpath/patchelf 或者干脆用同目录)。Windows 上就是
PATH 或 exe 同目录。
三方依赖不用管:发布包的动态库把 libuv / llhttp / zlib / OpenSSL / nghttp2 /
ngtcp2 / nghttp3 / pugixml 都静态链进去了 —— 三条腿各有各的断言在发布流程里把这件事钉着,
Linux 上 ldd libuvcpp.so 只剩 libc 与 ld-linux,Windows 上导入表只剩系统
dll。MSVC 包额外带三份 VC++ 运行库(msvcp140.dll / vcruntime140.dll /
vcruntime140_1.dll),一起拷过去即可。(发布包没有 macOS 那一档,六个平台
是 linux-x64 / linux-arm64 / mingw-x64 / mingw-arm64 / msvc-x64 / msvc-arm64。)
前四条是 C ABI 那一层的契约,第五条是 C# 侧的独立坑。
1. 线程亲和。 除了少数几个(*_post() / *_stop() 之类),所有函数都只
能在事件循环所在线程上调用,否则返回 UVCPP_C_E_WRONG_THREAD。清单在
doc/capi-guide.md,判定规则很简单:回调是在哪条线程上进来的,就在那条线程上
调回去。
2. 委托必须自己保活。 回调是函数指针,托管委托一旦被 GC 回收,C 侧那个指针
就是悬垂的 —— 症状是随机的访问违例,而且往往离出事点很远。所以:每个装进 C 侧
的委托,都要有一个活得比它长的引用(static 字段、或跟着宿主对象一起活)。
examples/QuicEcho/Program.cs 顶部那一排 static 字段就是干这个的。
3. 回调里的指针只在回调期间有效。 uvcpp_c_read_result.data、HTTP body 那
一类指针出了回调就作废。要留就当场拷:Marshal.Copy(data, buf, 0, n)。反过
来,传进去的参数(byte[]、string)在函数返回后随便处置 —— C 层当场拷。
4. 回调期的句柄不要带出去。 uvcpp_c_req / uvcpp_c_resp / uvcpp_c_next
这类只在回调执行期间有效;能带出去的是 uvcpp_c_deferred(uvcpp_c_req_defer()
拿到的那个),而且 defer 之后原来那两个句柄作废,要用
uvcpp_c_deferred_resp() 拿的那个。带出去再用会拿到 UVCPP_C_E_STALE —— 不崩,
但那是一句明确的"你越界了"。
5. 不许让异常逃出回调。 回调是 C 侧调进来的,托管异常要穿过 uv_run 那几层
native 帧。运行时的处置是打印 Unhandled exception 然后 abort —— 本机实测:
进程以 134 退出并留下 core。所以每个回调体自己 try/catch,把失败记进字段,
回到主线程再判;examples/QuicEcho/Program.cs 的 Note() 就是这个模式。
这一条不在上面五条里,因为它是 QUIC 独有的,而且最容易写错:一个"带数据和
FIN 的 STREAM 帧"会先报一条 DATA、紧接着再报一条 PEER_CLOSED,而那条
DATA 的 fin 也是非 0 —— 它是"这块就是最后一块"的信息位(留给 HTTP/3 那种
要当场分帧的调用方),不是"回调报完了"的意思。
所以流的结束判据是 event == PEER_CLOSED,不是 fin。拿 fin 当结束判据
会把同一段收尾做两遍(这个例子的第一版就是这么写的,于是回显了两次,第二次时写
方向已经关了)。TCP 那边不存在这个问题(真的是两次回调,fin 恒 0)。
| C | C# | 说明 |
|---|---|---|
uvcpp_c_xxx*(不透明句柄) |
IntPtr |
文件级 using 别名,名字与 C 里同名;不要解引用 |
int 返回码 |
int |
负数 = 失败;0 或正数 = 成功 |
size_t |
nuint |
长度、容量、计数 |
int64_t |
long |
流号等 |
const char*(借出的) |
[MarshalAs(UnmanagedType.LPUTF8Str)] string |
UTF-8 |
const char* + size_t(二进制) |
byte[] + nuint |
二进制安全,允许含 NUL |
char* buf + size_t cap(填缓冲区) |
byte[] + nuint |
返回真实长度;cap 不够时不写、只报长度 |
| 函数指针 | [UnmanagedFunctionPointer(Cdecl)] 委托 |
见 §四第 2 条 |
以 uint32_t size 打头的结构体 |
struct + size = (uint)Marshal.SizeOf<T>() |
必须最先填,C 层按它决定读到哪一格 |
一处必须知道的取舍:C 头里那些写着"可以为 NULL / NULL = 默认"的参数
(uvcpp_c_quic_tls_client_new(NULL) 的不校验、uvcpp_c_quic_server_bind(s, NULL, 0)
的通配地址、uvcpp_c_msg_set_content_type(NULL) 的摘掉……),在这个绑定里一律
声明成非空 string / byte[] —— 因为 C# 的默认可空性要一个个显式标 ?,
而"哪个 C 参数允许 NULL"是头里的文档行,不是签名能表达的。要传 NULL 就写
null!,运行期完全合法,只是编译器会提醒你一句:
IntPtr tls = UvcppNative.uvcpp_c_quic_tls_client_new(null!); // NULL = 不校验对端证书examples/QuicEcho 是一个 QUIC 回显服务端 + 一个自带客户端:默认做一次回环往
返(服务端收、回显、客户端收到同样的字节),自带判据、退出码 0 = 通过。
cd bindings/csharp/examples/QuicEcho
# 让运行时找到动态库(Linux 例;Windows 见 §三)
export LD_LIBRARY_PATH=/path/to/libuvcpp/lib
dotnet run # 回环往返
dotnet run -- --serve # 只当服务端,监听 4433(--port 改)本机实测输出(Linux x64 / .NET 8.0.11 / 发布档 libuvcpp.so,1.5.0):
libuvcpp 1.5.0(C ABI 1)
服务端在 127.0.0.1:36547 上收 QUIC(ALPN uvcpp-echo/1)
服务端:新连接进来了
客户端:握手完成,流 0 上发了 20 字节
服务端:流 0 收到 20 字节,回显并 FIN
客户端:流 0 收到 20 字节并收到 PEER_CLOSED
协商出的 ALPN:uvcpp-echo/1
服务端 on_close 来过:False;客户端 on_close 来过:False
回环通过:20 字节逐字节相等(63 ms)
收尾后活句柄数:0
(端口每次都是内核挑的,所以那一行每次都不同;(63 ms) 也随机器变。)
on_close 来过:False 那两行不是可有可无的:它说明这条回环从头到尾没有关过连接,
例子最后是带着活连接把两端释放掉的 —— 而"带着活连接释放端点"正是
uvcpp_c_quic_server_free() 曾经 SIGSEGV 的那个形状(这个例子第一次跑就是它撞出来的;
修复、回归用例与变异 M21 都在 1.5.0 里)。所以这两行同时也是那条回归的一次真实验证。
最后那行 活句柄数:0 不是装饰:它是这个例子自己带的一条断言 —— 跑完把两端与
两个 TLS 上下文都 _free() 之后,uvcpp_c_live_handle_count() 必须回到 0。
例子里的注释标出了三处照抄时要保留的形状:委托保活、回调里的指针当场拷走、
回调体自己 try/catch。--serve 那一支是 uvcpp_c_quic_server_run() 阻塞到
Ctrl+C,真做服务端程序时把每一端放到自己的线程上用 *_run() 跑即可(别在两个
线程上泵同一个端点)。
tests/tools/capi_symbols.lock 是 C 面的符号面锁,tests/tools/check_capi_symbols.py
拿它与构建出来的库对账。锁是分片记的(#@ module <名>,见
doc/capi-guide.md §3.5):今天八片、402 条,而这两份 .cs 覆盖其中
七片、321 条。
db 那一片(81 条)这一批没有绑 —— 这条缺口如实写在 doc/capi-guide.md §5。
绑定是手写 + 逐条核过的产物(理由见 §八),跟着一批新片一起做才能保持"条条都有
人读过",所以它不是自动的;下面那个脚本会把它打印成 db 0/81 ← 没绑,那是
预期的,不是脚本坏了。
# C 头 → 库(需要一份开着 CAPI 与 DB 的构建树;CI 跑的就是这一条)
python3 tests/tools/check_capi_symbols.py --tree build-capi
# 绑定 → 锁(不需要库,纯文本对账;给你在本地核这两份 .cs 用)
python3 - <<'PY'
import re
# 锁按 `#@ module <名>` 分片。绑定今天覆盖前七片;db **没绑**(见上)。
BOUND = {"common", "net", "web", "webapp", "http2", "quic", "http3"}
slices, cur = {}, None
for line in open("tests/tools/capi_symbols.lock", encoding="utf-8"):
line = line.strip()
m = re.match(r"#@ module (\S+)", line)
if m:
cur = m.group(1)
slices[cur] = set()
elif line.startswith("uvcpp_c_") and cur:
slices[cur].add(line)
src = "".join(open(p, encoding="utf-8").read()
for p in ("bindings/csharp/UvcppNative.cs",
"bindings/csharp/UvcppNative.Protocols.cs"))
decl = set(re.findall(r'EntryPoint\s*=\s*"([^"]+)"', src))
for name in sorted(slices):
got = len(slices[name] & decl)
print("%-8s %3d/%3d %s" % (name, got, len(slices[name]),
"" if got == len(slices[name]) else "← 没绑"))
bound = set().union(*(slices[n] for n in BOUND))
print("已绑的那七片,锁里有、绑定没声明:", sorted(bound - decl))
print("已绑的那七片,绑定声明了、锁里没有:", sorted(decl - set().union(*slices.values())))
PY那两行都要是空的(逐片那张表里只有 db 那一行写着 ← 没绑)。加了新的
C 函数而没同步绑定,第一行会当场说出来 —— 别等到运行期才发现某个
EntryPointNotFoundException。
P/Invoke 的声明没有任何编译器替你核对:DllImport 写错一个参数,编得过、
跑得起来、行为全错 —— 或者更糟,C 侧拿你的垃圾值当地址写。下面每一行都是拿本
仓真实头文件对出来的。"手写容易写成"那一列不是假想的:它是反复出现的形状
(模型、搜索结果、IDE 补全都给得出这些)。
| 手写容易写成 | src/capi/ 里实际是 |
会怎么坏 |
|---|---|---|
void uvcpp_c_app_free(app)、void uvcpp_c_resp_text(r, s) |
int(错误码) |
返回码被丢掉 —— E_STALE / E_WRONG_THREAD 这类"你写错了"的失败静默消失 |
bool uvcpp_c_app_running(app) |
int |
最危险的一格,本机实测过:托管 bool 默认按 Win32 BOOL 封送(非 0 即 true)。拿一个已经 _free() 掉的句柄问它,int 声明读到 -20002(E_STALE),bool 声明读到 True —— "出错了"被读成"正在运行" |
int uvcpp_c_app_connection_count(app) |
size_t |
64 位下读到的是低 32 位(连接数上 2³¹ 才露),但 size_t 一律写 nuint 是零成本的 |
uvcpp_c_tcp_server_listen(server, onClientPtr) |
(server, int backlog) |
凭空多一个回调参数:多出来的那个被忽略,C 侧把指针的低 32 位当 backlog 用(多半是个巨大的数)。cdecl 下调用方清栈,所以连崩都不崩 |
IntPtr uvcpp_c_req_path(req)(以为它借出一枚指针) |
int uvcpp_c_req_path(req, char* buf, size_t cap) |
少了两个参数:真正被调的是 (req, <栈上垃圾>, <栈上垃圾>),C 侧往那块地址写。req / resp 这一族没有"借出字符串"的形态,一律是"你给缓冲区、它还把长度" |
uvcpp_c_req_header(req, key) |
(req, name, char* buf, size_t cap) |
同上 |
uvcpp_c_resp_send_file(resp, path, size, cb) |
(resp, path, done_fn, user_data) —— 没有 size,done 是三个参数的回调 |
参数位整排错开 |
uvcpp_c_app_get(app, path, cbPtr) |
(app, pattern, cb, user_data) —— 四个 |
同上 |
Marshal.PtrToStringAnsi |
头里写的是 UTF-8 | 非 ASCII 当场乱码(PtrToStringUTF8 要 .NET 5+;绑定里走的是 LPUTF8Str) |
GC.KeepAlive(handler) 写在 Main 末尾 |
委托要活得比每一次 native 回调长 | KeepAlive 只保证"到这一句为止还活着";这句之后进来的回调随时可能踩到已回收的委托。要的是 static 字段(见 §四第 2 条) |
(顺带一条不是坑、但同属"照抄头的"这条规矩:uvcpp_c_abi_version() 在头里是
unsigned int,不是 int。两个都是 4 字节,正常值下能对上 —— 写在这里只是说明
那条规矩没有例外。)
这就是这两份 .cs 存在的理由,也是它们为什么是对着锁生成、再逐条对账过
的:321 条声明 ↔ tests/tools/capi_symbols.lock 里被绑的那七片那 321 个符号
(锁今天 402 条,多出来的 81 条是 db,见 §七),名字、参数个数、size_t→nuint
的映射全在 §七 那个脚本的射程里。要加一个 C 函数,是"改头 → 补锁 → 补绑定"三步,
每一步都有东西看着;手写一份则是没有任何东西看着。
db 那一片(81 个函数)这里一行都没有 —— 不是"没验过",是没做,理由与 影响在 §七。所以这一节下面的"真跑过"那句话,说的都是前七片那些声明。
已经真跑过的是:Linux x64、.NET 8(net8.0)、发布档 libuvcpp.so、
examples/QuicEcho 的整个回环。编译门槛是量出来的:
| 目标 | 结果 |
|---|---|
net8.0(C# 10+) |
0 警告 0 错误,而且真跑过 |
netstandard2.1 + C# 10 |
0 警告 0 错误(只编过,没跑过) |
netstandard2.0 |
编不过:参考程序集里 UnmanagedType 没有 LPUTF8Str |
| C# 9 及更早 | 编不过:文件范围命名空间要 C# 10 |
没有在 Windows / macOS 上跑过这个绑定(本机只有 Linux)。Windows 那条路要
小心的就是 §三 表里 MinGW 那一格,以及 nuint / 结构体布局在 64 位下的对齐
(两份 .cs 里的 size 都是 Marshal.SizeOf<T>() 算的,不写死数字 —— 就是为了
不让对齐假设藏进代码里)。
也没在 Mono / Unity 上验过:Unity 2022 还是 C# 9,namespace Uvcpp; 这种写法
它不认。真要在那上面用,得把那两处改成块形式(要重新缩进整个文件),那一档
没验过,别照抄。