c++动态调用链接库的问题

2019-09-27 18:09:33 +08:00
 BruceAuyeung
c++完全门外汉向大家问个问题,假如 c++二进制里面没有包含某个符号(对应一个 so 里面的函数),这个二进制能调到外部 so 里面的这个函数吗?我设想的场景:
我要开发一个 c++程序,会调用外部 so/dll 文件(出于描述方便考虑,后文就只提 so,so 可能是 c 开发的,也可能是 c++开发的),但我不知道会有哪些 so,so 里面有哪些函数以及函数入参出参,所以就设计一个 xml 文件,用于描述有哪些 so,各自路径,各自的方法列表和参数说明,我 c++二进制运行的时候加载这个 xml 完成解析并完成 so 方法的注册,这样 c++程序就能调用这些方法啦
请问,这可行吗?
3725 次点击
所在节点    程序员
27 条回复
x1314aq
2019-09-27 21:51:02 +08:00
完全可以,dlopen()系列函数就是为这个而生的
edimetia3d
2019-09-28 01:42:25 +08:00
首先, @ysc3839 说的很对, 楼主应该从根本上描述下自己的需求, 而不是自己想当然的来一个解决方案,一条路走到黑.

然后, LZ 其实就是想做一个 C/C++的简单 addon 系统.
如果做得太玩具, 那可能不如重新编译. 如果做得太全面, 考虑到 LZ 描述的水平, 可能吃不下这个饼,C/C++毕竟是"静态"的.
具体而言:
@chingyat 的路线正确, 但是有点像把大象装进冰箱里. 第三步第四步其实是很麻烦的, 毕竟你在编译期并不知道要 cast 成什么样的函数, 最起码需要考虑参数数量的问题. 这里, 要么按 @HHehr0ow 所说的, 按照 C 的 ABI 直接压栈进行调用. 要么楼主可能就要按照写一个很大的`switch(arg_number)`,每个 case 都先 cast 一下,再触发函数调用, 且不可避免的所有形参都必须是某种`Variant`类型

@iceheart 提到了参数类型的问题, 其实 xml 是不需要描述函数签名的,只要有函数名就够了, dlsym 拿到的只有一个指针,类型信息对函数调用是没有意义的. 重点是序列化 /反序列化的问题,大概就是 protobuf 这样的库做的. 也就是你在 js 中传了一堆东西给某个进程, 这个进程需要把这堆东西变成一串对象`arg1,arg2....argN`作为函数实参,然后进行函数调用. 如果涉及了复杂类型, 那么"在 C++中实现反射"等着你.

当然,这里面还有很多比较细节的问题, 比如调用的约定, IPC 传参的实现,等等等等,LZ 不要再考虑一下开头第一句吗?

发呆比较闲 ,闲扯一下, 所以没有后续 XD
ysc3839
2019-09-28 01:54:27 +08:00
@edimetia3d 看了楼主的回复,说不准真的只是想了解一下底层原理。如果是这样的话楼上的人已经说得差不多了。
iceheart
2019-09-28 07:27:39 +08:00
@BruceAuyeung #20
也就是说 so 库的接口函数声明格式是可控的?
那又何必搞这么复杂,定义一个动态类型参数就行啊,类似 json 的。
实在不会弄就传入个 json,返回个 json,把 so 库当个外部调用的 server 就行了啊。
lspvic
2019-09-28 08:04:43 +08:00
一般提供 dll/so 库的人也会提供头文件的,里面导出函数的签名定义的清清楚楚,直接 include 进去就可以了。ffi 是封装了 dll 的调用,但是还是需要知道函数的签名才能使用。一个 dll/so 库没有头文件或者函数签名文档是没法用的,或者说仅仅从 dll/so 文件是没法看出函数的参数及返回值的。
顺便刚刚用过了 node-ffi,不支持 node12,还得自己写个 native addon 用 LoadLibrary 来调用 dll,只能在 windows 下编译,不能用 ci 了。
Shazoo
2019-09-28 10:18:26 +08:00
1. 解析 dll/so 文件本体,获取 dll/so 的 export 函数表不难,从 readelf 库里面能找到,之前为了调试,用过。
2. 但是,仅只是获取的函数表,重要的函数参数是无法获取的。
3. 你说的 xml 之类的管理方式自然可行。但是,一般来说,都是通过引入 so/dll 的头文件参与编译来实现调用。
4. 如果是为了动态的调用新的 so/dll (不想重新编译),你这方案貌似没问题。不过很难想象应用场景。一般不是写个 bridge library 然后不停更新这个比较好吗……
secondwtq
2019-09-28 13:59:40 +08:00
首先楼主这个问题和 C++ 没关系,其实应该算是 Linker/Loader/ABI 的范畴,传统上算是 C (”学会 C 语言“在现在的语境下已经包含了太多的东西了)。

可以把原问题大致分成两个部分:
第一是定位并载入动态库以及库中的符号,这一步完成之后会给你一个函数代码的指针。而楼主是要运行时动态加载一个库,这个也是没有问题的,OS 提供了相关的 API,很多软件都有类似插件系统一样的东西,最常见的就是各大主流操作系统都支持的驱动和内核扩展——实在不行你自己定义一个格式自己载入也行 ... 做 JIT 都会涉及类似的操作,至于直接载入 native code 的也有,我猜 Chrome 之前搞得什么 NaCl 就是。
只不过现在都去写 JavaScript,扩展需求一般直接写程序解决不编译成 binary (这实际上是更好的方式),才会有楼主的问题——但是讽刺的是,node 里载入 native 库本来就会走同样的一个流程,并且 node 生态是很依赖 native 库的。

(另外用 binary 做扩展还是写代码做扩展,其实更多取决于需求,Solaris DTrace 和 Linux eBPF 这些做 profiling 的工具虽然是内核级的,但都是用户写程序运行时编译然后塞进内核里面跑,甚至可以直接 JIT 成 native code,像 bcc 一类的工具实际上把 eBPF 做成了 kernel 的 WebAssembly,这对于剖析程序行为过程中不断的调试是很有帮助的。做 graphics 的话,调试过程中 shader 都是一个文本塞进去 JIT 的,而且 GPU 并没有统一的 ISA (不仅仅是不同 vendor 之间的区别,同一 vendor 的不同代产品也不一样),不好直接跑 native code。而使用 native code 做扩展,好处是不限语言,性能上限一般更好(虽然会失去做 IPO/PGO 的机会),并且更方便扩展本身代码的保护。
至于 JavaScript 为啥不兴搞 native 扩展,纯属是因为确实需要做 native 扩展的需求太少,就算有点需求,对于大多数人来说做 native 扩展的成本又太高)

第二步是(在上一步已经得到函数指针的情况下)调用对应的函数。这个就涉及到 Data Layout 和 Calling Convention 之类的问题。

需要注意这两步之间的区别,尤其在外部库和程序本身使用同一种语言的情况下,”动态加载动态库“和”动态获取并调用其中的*任意*函数“是两个过程,在讨论实现细节时把它们混在一起说是不合适的。一般使用 C/C++ 作为 host 来实现的插件系统,都是由 host 来寻找并载入一个动态库,之后会固定地调用其中的某个 init 函数(函数名称、签名都是固定的),init 函数再把各种扩展的东西注册到 host 中。这个过程中所有的接口都是 host 早就决定好的,因此不涉及”动态获取并调用其中的*任意*函数“的过程。

为了更好的理解第一步和第二步之间的区别,首先应该理解“一个编程语言调用自己写的库”和“一个编程语言调用 C 写的库”之间的区别。
很多编程语言都有自己钦定的调用(同样是该编程语言写的)外部库的方式——所有那些乱七八糟的模块,包,import 都属于这个范畴。但是它们的共性是都是调用自己写的东西,你在 Java 里面 import 一个包进来,那个包是 JVM Bytecode 格式,你在 Python 里面 import 一个模块,那模块也是 Python 写的。因为是同一个编程语言,同一个编译器实现,所以你能直接用语言原生的方式使用外部库,非常自然,一般这都是最方便的方式。
但是这是静态调用,如果要动态调用外部库,一般需要其他方式。典型的在 Java 里,动态载入 class 需要用反射。(对于解释型语言来说,静态调用和动态调用是有可能统一的,比如 JavaScript 的 require 就是万能的,既可以静态调用也可以动态调用)。不变的是依然需要所调用的模块是使用该语言编写的——这个说法不严格,应该说是符合该语言自身体系标准的,比如在 node 里可以用 require 来调用 native addon,但是这要求 addon 文件自身实现了 addon 的接口,你不能直接编译一个 OpenSSL,然后就在 node 里把它的 .so require 进来,这个需要使用下一种方式。

这里就可以看出来,无论是静态调用和动态调用,你都会使用一种机制来找到对应的库,在库载入进来之后,使用库中函数的过程,就和使用你自己用该语言写的函数是一样的。“找库”和“调函数”是两个正交的东西。

下一种方式,就是在某个编程语言里面调用 C 外部库。
一个编程语言可以不实现任何的模块等调用外部库的方式,却依然是图灵完全的。但是这样的编程语言字啊实际场景中最多只能做到 Hello World,难以回答“JavaScript 也可以写服务器么?”这种问题。所以编程语言要做模块,做包,也即上面所说的“调用同样是该编程语言写的外部库”的机制,并且提供一堆的库,现在你提供了库让 JavaScript 可以写服务器了,但是缺乏常识的萌新们又问出一堆问题:JavaScript 可以写桌面程序么?可以做 IoT 么?可以做韩国女团的人脸识别么?
解决这些问题的终极方案,就是实现一个调用 C 外部库的机制。因为如果一个编程语言能调用 C 库,实际上就相当于 C 有的库它都能有。这样 JavaScript 就不仅能做韩国女团的人脸识别,甚至还能控制火箭发射(以及火箭爆炸),你就直接告诉萌新们“C 能做的我都能做”就可以了。所以任何一个像样的语言,都会有一个调用 C 库的机制。
这个“调用 C 库的机制”也分不同的方式,常见的一种是:用 C 写 binding,和你要调用的库链接到一起,这一般需要依赖于语言实现的具体 API (在 Java 里倒是有标准化的 JNI,但是我觉得不是所有人都把 JNI 当作是自己认知中“Java 语言”这一概念神圣不可分割的一部分)。Python 和 Lua 的 C Binding、Node 的 C++ Binding 都是一个道理。
还有一种是使用语言本身来描述 C 接口,开发者不需要写任何 C 代码。这个可以继续细分,不过区别更加微妙——一些编译型语言可以在编译时静态生成对 C 函数的调用,比如 Go 的 cgo 和 Haskell 的 FFI。编译型语言也可以在运行时进行完全动态的调用,比如 OCaml 的 ctypes。而解释型语言由于没有这个区分,所以这个做得都差不多,典型如 Python 的 cffi,LuaJIT 的 FFI。
(从命名可见,狭义上的 FFI 仅仅指上面最后一段的东西 ...)

在这种场景下,你依然可以选择是“调用其中固定的函数”还是“动态调用任意函数”,对于编译型语言,你还可以选择“编译时链接固定的库”还是“运行时动态载入库”。

这里有两个常见的特例:
一个是 C,对于 C 来说,不存在“调用自己写的库”和“调用 C 写的库”的区别(因为”自己 = C“ ...)。这样所有其他编程语言费了半天功夫实现的编译器接口也好 FFI 接口也好,在 C 里都是 free 的,因为所有 C 库都天然地符合 C 自身的体系,根本不”foreign“。
但是标准 C 仅仅解决了”静态调用外部库“的问题,并没有解决”动态调用“( C 标准根本就和动态库都没关系)。这个就需要用操作系统的动态库 API,结合 libffi。由于 C 的表达能力实在太过捉鸡,所以这个写起来就像在写另外一种语言,尽管调的还是 C 函数。
在这个过程中,操作系统 API 解决的是”动态加载动态库“的问题,libffi 解决的是“动态调用函数的问题”。实际上没人拦着在 c 文件里面写一个函数,然后在同一个 c 文件里面用 libffi 调用它。

另一个是 C++,首先 C++ 与 C 保持了很大的兼容性,也能直接调用 C 库(嘛虽然有个 extern "C"),这样上面说 C 的话,对 C++ 也都成立。C++ 标准也没解决动态调用的问题,所以动态调用 C 库也需要 libffi,但是问题在于动态载入 C++ 库(这个没问题),*并且*动态调用其中的函数 ... 因为
我至今没见过有人做过那么疯狂的事情!
虽然说有无数的语言都不断地打磨自己的 C FFI,但是很少有做“C++ FFI”并且实质上成功的。尽管 C++ 的生态也很丰富,支持调用 C++ 库中的内容(哪怕是个 binding )的好处也很大。
这不仅仅是 C++ 复杂性的问题,很大程度上也是因为 C 实在太过 primitive,几乎相当于所有语言的下限,而 C++ 接口中涉及的概念对于很多编程语言来说就是根本不兼容的(很多语言没有 class ),所以做“C++ FFI”这个事情,很多时候 by definition 就是有问题的(就不说 template 根本没法做)。
所以很多 C++ 库都是库本身用 C++ 写,然后用 C wrap 一下接口,这样所有能调用 C 的语言,也都能调用这个库。
这就是为什么说楼主的问题和 C++ 根本就没有关系。
话又说回来,这里”动态加载 C++ 动态库“其实是没有问题的,问题出在“调用 C++ 动态库中的函数”这一步。

当然上面这些都是上个时代的东西,现在 114 天就可以产生 514 个新的 RPC 框架,而在 polyglot 的世界里,很多 RPC 框架是跨语言的。跨语言的 RPC 框架一般试图提取各种语言数据模型的共性(即:各种语言的下限,或最大公约数),并且使用一个语言无关的 IDL 来描述。比如 COM/DCOM ( literally 也是上个时代的东西 ...)就是使用一个微软扩展过的 IDL,gRPC 使用 Protocol Buffer 等。
IDL 不仅仅 RPC 会用,它可以作为一个抽象层存在于任何跨语言的场景中(只不过现在萌新们只知道 RPC ...)。比如 DOM API 就是语言无关的,DOM API 可以用一种叫 WebIDL 的东西来描述。而浏览器引擎一般都会带这样一份 IDL 定义,然后会实现一个代码生成器,在编译时根据 IDL 生成对应 JavaScript 引擎的 binding,将引擎中的 C++ 接口暴露给 Web 开发者。这个原理和写 node C++ addon 其实是一样的,不过流程要成熟许多,更方便维护。
所以楼主说用 XML 也好 JSON 也罢来描述接口,多少也有点重新造轮子的意思。

@Shazoo 你 assume 了所有代码都是自己控制的,忽略了第三方扩展的需求。并且写 bridge library 并不一定比 FFI 更方便。
很多时候 bridge 需要依赖于编译器的 API,这是编译器的一个实现细节,原则上用户不需要关心。
更别说某些语言的 FFI 只需要一个 C header 就可以帮你把很多事情全做掉,方便的 FFI 恰恰是语言和生态强大的体现,必须写 bridge library,反倒是个问题。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/604915

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX