首页   注册   登录
V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
V2EX  ›  程序员

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

  •  
  •   BruceAuyeung · 73 天前 via Android · 1795 次点击
    这是一个创建于 73 天前的主题,其中的信息可能已经有所发展或是发生改变。
    c++完全门外汉向大家问个问题,假如 c++二进制里面没有包含某个符号(对应一个 so 里面的函数),这个二进制能调到外部 so 里面的这个函数吗?我设想的场景:
    我要开发一个 c++程序,会调用外部 so/dll 文件(出于描述方便考虑,后文就只提 so,so 可能是 c 开发的,也可能是 c++开发的),但我不知道会有哪些 so,so 里面有哪些函数以及函数入参出参,所以就设计一个 xml 文件,用于描述有哪些 so,各自路径,各自的方法列表和参数说明,我 c++二进制运行的时候加载这个 xml 完成解析并完成 so 方法的注册,这样 c++程序就能调用这些方法啦
    请问,这可行吗?
    第 1 条附言  ·  73 天前
    https://github.com/node-ffi-napi/node-ffi-napi
    下面是一段 JS 代码,ffi-napi 是用 C++写的 nodejs addon。你看 ffi-napi 编译的时候是不知道要调用 ceil 方法的,ffi-napi 是通过 ffi.Library 方法才知道有个 libm 外部动态库,里面有 ceil 方法。这样做的好处是当外部 libm 新增导出方法时,ffi-napi 无需重新编译,只需要修改前段 JS 代码就可以直接使用 ceil 方法了。
    ~~~
    var ffi = require('ffi-napi');

    var libm = ffi.Library('libm', {
    'ceil': [ 'double', [ 'double' ] ]
    });
    libm.ceil(1.5); // 2

    // You can also access just functions in the current process by passing a null
    var current = ffi.Library(null, {
    'atoi': [ 'int', [ 'string' ] ]
    });
    current.atoi('1234'); // 1234
    ~~~
    由于确实是完全门外汉,所以暂时还不理解 node-ffi-napi 是怎么实现的。
    27 回复  |  直到 2019-09-28 13:59:40 +08:00
        1
    wbing   73 天前 via iPhone
    “但我不知道会有哪些 so,so 里面有哪些函数以及函数入参出参,所以就设计一个 xml 文件,用于描述有哪些 so,各自路径,各自的方法列表和参数说明”
    —————-
    感觉这有点前后矛盾啊,你都不知道会有哪些 so,那这个 xml 文件是怎么生成的。
        2
    wevsty   73 天前
    没有导出的函数是不能从外部调用的。

    编译 dll/so 的时候必须导出外部需要的函数,但是你可以使用自己的方法来描述导出的函数。比如你说的通过加载 xml 来确定导出了什么函数,然后动态加载 dll/so 再来调用是可以的。
        3
    ysc3839   73 天前 via Android
    怀疑这是个 X-Y Problem https://coolshell.cn/articles/10804.html
    建议你直接说你要实现什么。
        4
    yrand   73 天前
    我猜是想实现插件之类的功能。必须要定好接口才行
        5
    Foreverdxa   73 天前
    lib
    dll
    头文件 这些不好使???
        6
    BruceAuyeung   73 天前 via Android
    @wbing 是指编译我的 c++程序时,我是不知道有哪些 so 的
        7
    BruceAuyeung   73 天前 via Android
    @wevsty 外部 so 我们假定是已经导出了的
        8
    BruceAuyeung   73 天前 via Android
    @ysc3839 就是我希望我的 c++编译完之后,能调用任意外部 so 里面的任意方法
        9
    BruceAuyeung   73 天前 via Android
    @Foreverdxa 这个在编译的时候已经知道会调哪些 so 的哪些方法了吧
        10
    across   73 天前
    不是脑筋急转弯吧,没想到什么方法,如果能解,大概就是加入个 Lua 脚本之类的进行粘合?
        11
    pursuer   73 天前 via Android
    dlopen RTLD_GLOBAL 可以让加载的 so 的导出对后续加载的 so 可见,但这和 xml 啥的没什么关系,不过这个方式可能动态库卸载不了吧,我也不确定。你说的注册机制倒有点像 windows com 的设计。
        12
    BruceAuyeung   73 天前
    https://github.com/node-ffi-napi/node-ffi-napi
    下面是一段 JS 代码,ffi-napi 是用 C++写的 nodejs addon。你看 ffi-napi 编译的时候是不知道要调用 ceil 方法的,ffi-napi 是通过 ffi.Library 方法才知道有个 libm 外部动态库,里面有 ceil 方法。这样做的好处是当外部 libm 新增导出方法时,ffi-napi 无需重新编译,只需要修改前段 JS 代码就可以直接使用 ceil 方法了。
    ~~~
    var ffi = require('ffi-napi');

    var libm = ffi.Library('libm', {
    'ceil': [ 'double', [ 'double' ] ]
    });
    libm.ceil(1.5); // 2

    // You can also access just functions in the current process by passing a null
    var current = ffi.Library(null, {
    'atoi': [ 'int', [ 'string' ] ]
    });
    current.atoi('1234'); // 1234
    ~~~
        13
    HHehr0ow   73 天前   ♥ 1
    是可行的。
    以 Windows 下 DLL 为例,使用 LoadLibrary 加载 module 之后获得 handle,再使用 GetProcAddress 获得目标函数指针 pFooTarget。
    之后神奇的部分就发生了,假定 FooTarget 使用了 x86 cdecl calling convention,可以根据 xml 中描述的变量信息,在调用 pFooTarget 之前自行按照 cdecl 的规则进行参数压栈,最后一句汇编 CALL,即可完成函数的调用。
    同样,调用完毕后,需要自行到寄存器或者栈上取回返回值,比如 eax。
        14
    missdeer   73 天前
    13 楼正解。
    Windows 上有个叫 rundll32.exe 的程序就是这个功能。
        15
    iceheart   73 天前 via Android   ♥ 1
    可以实现,但是复杂程度不是你能接受的。
    举个例子: 一个 so 库的某个导出函数,需要一个复杂的结构体指针作为参数。
    你要构造一个什么样的 xml 来描述这个参数的全部信息?
    再假设你做到了,你如何构建数据的存储结构呢?
    你不是为这一个特殊结构构建实现,而是为所有可能出现的描述做解析做处理。你觉得最初要的灵活性,还能实现么?
        16
    chingyat   73 天前   ♥ 1
    1. dlopen 打开动态库
    2. dlsym 找到符号
    3. cast 为相应的函数指针
    4. 调用

    这样不行吗?
        17
    zealot0630   73 天前 via Android
    可以,搜索 dlopen/dlsym
        18
    402124773   73 天前
    搜索 windows 下 com 机制调用,好像有类似的情况。
        19
    BruceAuyeung   73 天前
    @chingyat 根据你们的提示,我搜索了下
    https://github.com/node-ffi-napi/node-ffi-napi/search?q=dlopen&unscoped_q=dlopen

    好像 node-ffi-napi 就是这么实现的
        20
    BruceAuyeung   73 天前
    @iceheart 谢谢。参数类型可以做出约束,不需要过于复杂。
        21
    x1314aq   73 天前
    完全可以,dlopen()系列函数就是为这个而生的
        22
    edimetia3d   73 天前   ♥ 1
    首先, @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
        23
    ysc3839   73 天前 via Android
    @edimetia3d 看了楼主的回复,说不准真的只是想了解一下底层原理。如果是这样的话楼上的人已经说得差不多了。
        24
    iceheart   72 天前 via Android
    @BruceAuyeung #20
    也就是说 so 库的接口函数声明格式是可控的?
    那又何必搞这么复杂,定义一个动态类型参数就行啊,类似 json 的。
    实在不会弄就传入个 json,返回个 json,把 so 库当个外部调用的 server 就行了啊。
        25
    lspvic   72 天前 via Android
    一般提供 dll/so 库的人也会提供头文件的,里面导出函数的签名定义的清清楚楚,直接 include 进去就可以了。ffi 是封装了 dll 的调用,但是还是需要知道函数的签名才能使用。一个 dll/so 库没有头文件或者函数签名文档是没法用的,或者说仅仅从 dll/so 文件是没法看出函数的参数及返回值的。
    顺便刚刚用过了 node-ffi,不支持 node12,还得自己写个 native addon 用 LoadLibrary 来调用 dll,只能在 windows 下编译,不能用 ci 了。
        26
    Shazoo   72 天前   ♥ 1
    1. 解析 dll/so 文件本体,获取 dll/so 的 export 函数表不难,从 readelf 库里面能找到,之前为了调试,用过。
    2. 但是,仅只是获取的函数表,重要的函数参数是无法获取的。
    3. 你说的 xml 之类的管理方式自然可行。但是,一般来说,都是通过引入 so/dll 的头文件参与编译来实现调用。
    4. 如果是为了动态的调用新的 so/dll (不想重新编译),你这方案貌似没问题。不过很难想象应用场景。一般不是写个 bridge library 然后不停更新这个比较好吗……
        27
    secondwtq   72 天前   ♥ 4
    首先楼主这个问题和 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,反倒是个问题。
    关于   ·   FAQ   ·   API   ·   我们的愿景   ·   广告投放   ·   感谢   ·   实用小工具   ·   1047 人在线   最高记录 5043   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.3 · 29ms · UTC 22:52 · PVG 06:52 · LAX 14:52 · JFK 17:52
    ♥ Do have faith in what you're doing.