Edit online

动态加载

4 Dec 2024
Read time: 13 minute(s)

动态模块(Dynamic Module,简称 dm-app)机制允许开发者在运行时动态加载和卸载应用程序,而无需重新编译整个系统。目前该特性仅在 Luban-Lite 内核为 RT-Thread 时支持。

Luban-Lite 原生开发的映像为 Kernel ,使用 Dynamic Module 机制开发的应用为 dm-app。本节介绍 Dynamic Module 功能的配置流程。

原理说明

在 Linux/Windows 等大型系统中,应用和驱动通常可以独立开发并编译成 ELF/EXE 文件,然后在目标系统上执行。Luban-Lite 使用动态模块 (Dynamic Module) 机制来支持应用程序独立开发的需求,核心就是实现了 ELF 的链接和加载。基本原理如下:


dm_design

1. Dynamic Module 实现原理

执行以下步骤,可以实现对动态模块的有效调试,确保能够在产品开发过程中灵活地进行二次开发和调试:

  1. 编译链接:使用 GCC 工具链(使用 -fPIC -shared 选项),将应用源文件 main.c 编译链接成 ET_DYN 格式的 ELF 文件,即 hello.mo 文件。
    注: 必须使用 riscv-none-embed-gcc 工具链,否则会导致编译不成功。

    hello.mo 是一个标准的 ET_DYN 格式 ELF 文件,位置无关且可动态链接。

  2. 文件加载:在运行时,首先会把 hello.mo 文件的数据段代码段拷贝到内存当中。

    由于代码是是位置无关代码 (PIC),可以从 Heap 中动态分配内存再进行代码拷贝。此时代码中还存在很多对系统函数的调用,需要重新定位重新链接后才能运行。

  3. 动态链接:遍历 hello.mo 中的可重定位段,在内核的导出符号表 rtmsymtab 中查询需要重定位的符号,并将查询到的绝对地址回填到可重定位符号的位置。至此完成动态链接,可以跳转到程序入口处执行。

Edit online

Kernel 配置

如需使用 Dynamic Module 功能,需要在内核中打开以下配置:
  1. 启用 POSIX 文件系统和 I/O
    Rt-Thread options --->
        RT-Thread Components --->
            C/C++ and POSIX layer --->
                POSIX (Portable Operating System Interface) layer --->
                    [*] Enable POSIX file system and I/O
    
  2. 启用动态模块 API:
    Rt-Thread options --->
        RT-Thread Components --->
            C/C++ and POSIX layer --->
                POSIX (Portable Operating System Interface) layer --->
                    [*] Enable dynamic module APIs, dlopen()/dlsym()/dlclose() etc
    
此外,还可以选择 dm-app 动态加载时使用的内存堆 (heap)。具体的可选堆会随不同平台的配置有所不同:
Rt-Thread options  --->
    RT-Thread Components  --->
        C/C++ and POSIX layer  --->
            POSIX (Portable Operating System Interface) layer  --->
                [*] Enable dynamic module APIs, dlopen()/dlsym()/dlclose() etc
                    Select dynamic module use mem (Prsam CMA heap)  --->
                        (X) Sys Heap
                        ( ) Prsam CMA heap

符号导出

Kernel 中被 dm-app 访问到的符号需要使用 RTM_EXPORT() 宏来进行声明,类似 Linux 中的 EXPORT_SYMBOL() 宏。

  • 对于一些标准的 C 库函数,Kernel 已经定义好了 RTM_EXPORT() 声明,dm-app 可以直接使用,例如 luban-lite/kernel/rt-thread/components/libc/posix/libdl/dlsyms.c
    RTM_EXPORT(strcpy);
    RTM_EXPORT(strncpy);
    RTM_EXPORT(strlen);
    RTM_EXPORT(strcat);
    RTM_EXPORT(strstr);
    RTM_EXPORT(strchr);
    RTM_EXPORT(strcmp);
    RTM_EXPORT(strtol);
    RTM_EXPORT(strtoul);
    RTM_EXPORT(strncmp);
    ...
  • 对于 RT-Thread API 函数,Kernel 已经定义好了 RTM_EXPORT() 声明,dm-app 可以直接使用。例如 luban-lite/kernel/rt-thread/src/thread.c
    RTM_EXPORT(rt_thread_create);
    RTM_EXPORT(rt_thread_yield);
    RTM_EXPORT(rt_thread_startup);
    RTM_EXPORT(rt_thread_detach);
    ...
  • 用户可以使用下列命令查看当前系统中使用 RTM_EXPORT() 声明的符号:
    list_symbols
    系统输出结果示例如下:
    rt_critical_level=> 0x40013cc0
    rt_exit_critical=> 0x40014090
    rt_enter_critical=> 0x40013ce0
    rt_device_set_tx_complete=> 0x40014220
    rt_device_set_rx_indicate=> 0x40014200
    rt_device_control=> 0x400141f0
    rt_device_write=> 0x40014370
    rt_device_read=> 0x40014330
    rt_device_close=> 0x400143b0
    重要:

    未使用 RTM_EXPORT() 声明的 Kernel 函数无法在 dm-app 中使用。如需在 dm-app 中使用自定义的 Kernel 函数,必须使用 RTM_EXPORT() 声明。

GCC low-level 函数的符号导出

在加载 dm-app 时,可能会遇到找不到某些 GCC low-level runtime library 中的函数符号的情况,如 __floatdidf __umoddi3__udivdi3__fixdfdi。这些函数是 GCC 基础运算库的一部分,用于实现 CPU 原生硬件指令无法完成的操作。例如,在 32bit 系统中实现 64bit (long long) 类型数据的除法,GCC 就会自动调用软件函数 __umoddi3 来实现。具体原理可以参考The GCC low-level runtime library

执行以下操作步骤,可解决这种问题:

  1. 使用以下命令,在工具链中找出函数的原型声明:
    $ grep -r -A1 __umoddi3 luban-lite/toolchain/
    toolchain/share/info/gccint.info: -- Runtime Function: unsigned long __umoddi3 (unsigned long A,
    toolchain/share/info/gccint.info-          unsigned long B)
  2. 在 Kernel 中使用 RTM_EXPORT() 给函数加上符号导出声明:
    extern unsigned long __umoddi3 (unsigned long a, unsigned long b);
    RTM_EXPORT(__umoddi3);
    extern unsigned long __udivdi3 (unsigned long a, unsigned long b);
    RTM_EXPORT(__udivdi3);
    extern double __floatdidf (long i);
    RTM_EXPORT(__floatdidf);
    extern long __fixdfdi (double a);
    RTM_EXPORT(__fixdfdi);
Edit online

DM-APP 开发

dm-app 的开发目录位于 luban-lite/packages/artinchip/aic-dm-apps。首先确保 luban-lite/ 根目录下的 Kernel 工程被正确配置且编译通过后,生成对应的 dm-app SDK:

$ cd luban-lite/packages/artinchip/aic-dm-apps
$ scons --target=sdk

示例结果如下:

$ cd luban-lite/packages/artinchip/aic-dm-apps
$ scons --target=sdk
scons: Reading SConscript files ...
Copy rtconfig.py...
Copy rtua.py...
Copy rt-thread/tools/...
Copy project .h files...
Copy tools/env/...
Copy tools/scripts/...
Copy onestep.sh...
Copy win_env.bat...
Copy win_cmd.bat...
Build local sdk succeed!

dm-app SDK 创建完成后,aic-dm-apps 文件夹可以被拷贝到任意 Linux/ Windows 路径进行开发和编译, 脱离 Luban-Lite SDK 进行开发操作 。

使用以下命令可清理 SDK:

$ cd luban-lite/packages/artinchip/aic-dm-apps
$ scons --target=c

目录结构

aic-dm-apps 的目录结构如下所示:

├── hello                   // hello 实例
│   ├── hello.mo            // 'scons --app=hello' 命令生成的可执行文件
│   ├── hello.so            // 'scons --lib=hello' 命令生成的库文件
│   ├── main.c              // 可执行文件的 main 函数入口
│   ├── rtt_api_test.c      // dm 中调用 rt-thread api 的函数实例
│   └── SConscript
├── LICENSE
├── README.md
├── SConstruct
├── toolchain               // 自动解压后的工具链
├── tools
│   ├── env
│   ├── host
│   ├── onestep.sh
│   ├── scripts
│   ├── sdk                 // 所有的工程头文件
│   ├── toolchain
│   ├── ua.def
│   ├── ua.py
│   └── ua.pyc
├── win_cmd.bat
└── win_env.bat             // 启动 windows 下的命令行
Edit online

编译和运行

dm-app 可以被编译成两种类型:可执行文件 (.mo) 和库文件 (.so)。本质上两者都是 ET_DYN 类型的 ELF 文件,唯一的不同是可执行文件指定了 main 函数作为执行入口,而库文件指定了 0 作为执行入口。

编译和运行可执行文件.mo

  1. 编译 dm-app 为可执行文件
    • 在 Windows 系统上:双击 win_env.bat 打开命令行运行环境。
    • 在 Linux 系统上:直接使用 Shell 命令行即可。
      $ cd luban-lite/packages/artinchip/aic-dm-apps
      $ scons --app=hello             // 编译
      $ ls hello/hello.mo             // 查看目标文件
      $ scons --app=hello -c          // 清理
      
  2. 运行 hello.mo
    将生成的 hello.mo 文件拷贝到单板存储介质的文件系统中,在 Shell 下直接运行:
    aic /> /sdcard/hello.mo
    [AIC-DM-APP] init!              // DM 初始化函数 module_init()
    [AIC-DM-APP] Hello, world!      // DM 主函数 main()
    index => 0                      // my_thread_init() 调用 rt-thread API 创建的线程
    index => 1
    index => 2
    index => 3

编译和运行库文件 .so

  1. 编译 dm-app 为库文件

    具体步骤如下:

    $ cd luban-lite/packages/artinchip/aic-dm-apps
    $ scons --lib=hello             // 编译
    $ ls hello/hello.so             // 查看目标文件
    $ scons --lib=hello -c          // 清理
    
  2. 运行 test_dm_lib
    在 Kernel 中使能 test_dm_lib 测试命令:
    Drivers options  --->
        Drivers examples  --->
            [*] Enable DM Lib test command

    将生成的 hello.so 拷贝到单板存储介质的文件系统中,并使用 test_dm_lib 命令来动态加载:

    aic /> test_dm_lib              // dlopen() 动态加载 /sdcard/hello.so
    [AIC-DM-APP] init!              // DM 初始化函数 module_init()
    index => 0                      // my_thread_init() 调用 rt-thread API 创建的线程
    index => 1
    index => 2
Edit online

实例代码分析

使用可执行文件 hello.mo 和库文件 hello.so 的源码时,需要注意以下特殊事项:

  • 可执行文件 .mo
    1. RT-Thread API 的调用
      hello.mo 中,可以调用如 rt_thread_creatert_thread_startup 等 RT-Thread API。这些 API 默认已经使用 RTM_EXPORT() 声明,可以直接调用 luban-lite/packages/artinchip/aic-dm-apps/hello/rtt_api_test.c。例如:
      #include <rtthread.h>
      
      void my_thread_entry(void* parameter)
      {
          int index = 0;
      
          while (1)
          {
              rt_kprintf("index => %d\n", index ++);
              rt_thread_delay(RT_TICK_PER_SECOND);
          }
      }
      
      int my_thread_init(void)
      {
          rt_thread_t tid;
      
          tid = rt_thread_create("tMyTask", my_thread_entry, RT_NULL,
              2048, 20, 20);
      
          if (tid != RT_NULL)
              rt_thread_startup(tid);
      
          return 0;
      }
    2. 模块初始化和退出函数
      如果 dm-app 定义了 module_init()module_cleanup() 函数,它们会在模块初始化和退出时被自动调用 luban-lite/packages/artinchip/aic-dm-apps/hello/main.c。例如:
      void module_init(struct rt_dlmodule *module)
      {
          printf("[AIC-DM-APP] init!\n");
      }
      
      void module_cleanup(struct rt_dlmodule *module)
      {
          printf("[AIC-DM-APP] exit!\n");
      }

      用户可以利用该机制来做一些初始化和清理的工作。如果不需要,则无需实现这两个函数。

    3. 查看 hello.mo 创建的子线程

      从代码可知,运行 hello.mo 后会创建 tMyTask 线程,但使用 ps 或者 list_thread 命令无法看到该线程。这是因为该方式下启动的线程会被链接到模块本身的进程链表 module->object_list ,而上述命令只能查看全局链表 information->object_list 中的线程。

      目前模块本身的进程链表 module->object_list 不支持命令查看,在模块退出时会停止掉 module->object_list 中模块启动的所有子进程。

    4. 后台进程保活
      hello.momain() 函数返回后,系统马上会执行模块退出动作,main() 函数创建的所有子进程也会被全部清理。为了让模块的子进程作为后台进程继续运行,可以为 hello.momain() 函数定义一个特殊返回值 RT_DLMODULE_DEAMON。如果返回该值,则 main() 函数返回后系统不会执行模块退出动作。例如 luban-lite/packages/artinchip/aic-dm-apps/hello/main.c
      #define RT_API_TEST
      
      int main(int argc, char *argv[])
      {
          printf("[AIC-DM-APP] Hello, world!\n");
      
      #ifdef RT_API_TEST
          my_thread_init();
          return RT_DLMODULE_DEAMON;
      #endif
      
          return 0;
      }
  • 库文件 .so
    1. dlopen()dlsym() 实例
      test_dm_lib 命令的基本原理是使用 dlopen() 函数动态加载 hello.so 到系统内存,再使用 dlsym() 函数查找到 hello.so 中的 my_thread_init() 函数并调用 luban-lite/bsp/examples/test-dm-lib/test_dm_lib.c。例如:
      #define DM_LIB_PATH "/sdcard/hello.so"
      #define DM_LIB_FUNC "my_thread_init"
      #define DEAMON_THREAD
      
      static void cmd_test_dm_lib(int argc, char **argv)
      {
          struct rt_dlmodule *module = NULL;
          int (*func)(void) = NULL;
      
          module = dlopen(DM_LIB_PATH, 0);
          if (!module) {
              printf("dlopen %s fail!\n", DM_LIB_PATH);
              return;
          }
      
          func = dlsym(module, DM_LIB_FUNC);
          if (!func) {
              printf("dlsym %s fail!\n", DM_LIB_FUNC);
              return;
          }
      
          func();
      
      #ifndef DEAMON_THREAD
          dlclose(module);
      #endif
      }
    2. 查看 hello.so 创建的子线程

      通过 test_dm_lib 命令动态加载 hello.so 并调用 my_thread_init() 函数,同样会会创建 tMyTask 线程。上述示例中,使用 ps 或者 list_thread 命令可以看到该线程,是因为系统通过 dlmodule_self() 判断当前进程非模块执行进程,对应的进程链表就加入到了全局链表 information->object_list 中。

    3. 后台进程保活

      当调用 my_thread_init() 函数返回后,常规情况是执行 dlclose(module) 来清理动态加载的模块。如果 my_thread_init() 函数创建的所有子进程希望作为后台进程运行,则不能调用 dlclose(module) 来卸载动态库。因为一旦调用 dlclose(module),动态库就会被卸载,而此时后台进程可能仍在运行并需要访问动态库中的数据。一旦有新的内存分配就会覆盖原动态模块的数据,触发 CPU 异常。

      为了避免这种情况,可以在不调用 dlclose(module) 的情况下让程序持续运行,或者在程序结束时手动清理动态库占用的资源。

Edit online

GDB 调试

使用 JTAG 调试器连接 SoC,并通过 DebugServer 提供的 GDB 调试接口来,可以调试 dm-app。以 test_dm_lib 命令动态加载 hello.so 为例,整个调试过程如下所示:

保留 ELF 调试信息

为了能够调试动态模块,需要在编译时保留原始 ELF 文件中的调试信息。

hello.mohello.so 原始 ELF 文件中含有 debug 信息,为了减少动态加载时的内存大小,通过临时注释掉 rtconfig.py 文件中的 strip 动作 M_POST_ACTION 来实现:

# M_POST_ACTION = M_STRIP + ' -R .hash $TARGET\n' + M_SIZE + ' $TARGET \n'
  • 如果 rtconfig.py 文件位于 Luban-Lite\bsp\artinchip\sys\dm\ 目录下,修改该文件;
  • 如果 rtconfig.py 文件位于 aic-dm-apps 目录中,则修改该文件。

重新运行 scons --app=hello 或者 scons --lib=hello ,编译出来的 hello.mo 或者 hello.so ELF 文件就会带 debug 调试信息。

计算动态加载地址

动态模块的基地址会在加载函数 dlmodule_load() 加载完动态模块后打印出来。通过 readelf 命令读取 ELF 文件 .text 段的偏移,可以计算出 add-symbol-file 命令中的基地址。

  1. 模块动态加载基地址
    aic /> test_dm_lib              // '0x404f8c80' 即模块动态加载的基地址
    01-01 10:05:30 I/NO_TAG: Module: load /sdcard/hello.so to 0x404f8c80 succeed.
    [AIC-DM-APP] init!
  2. .text 偏移
    通过 readelf 命令读取 ELF 文件 .text 段的偏移:
    $ readelf -S hello/hello.so     // .text 的 Address 字段为 '0x550'
    Section Headers:
    [Nr] Name              Type             Address           Offset
        Size              EntSize          Flags  Link  Info  Align
        ...
    [7] .text             PROGBITS         000000000000055000000550
        000000000000008e  0000000000000000  AX       002
        ...
    
  3. GDB 加载符号表

    在 GDB 中加载动态模块的符号表。假设已经获得了动态加载地址和 .text 段的偏移,可以使用 add-symbol-file 命令来加载符号表。

    示例如下:
    0x404f8c80 + 0x550 = 0x404F91D0

完整 GDB 调试过程

  1. 在 GDB 中给系统函数 dlmodule_load() 打上断点:
    (gdb) b dlmodule_load
    
  2. 在串口 Shell 中执行 test_dm_lib 命令启动测试:
    test_dm_lib
    
  3. GDB 中断在 dlmodule_load() 入口以后,输入 finish 命令让 dlmodule_load() 执行完成:
    (gdb) finish
    
  4. 记录串口 Shell 上打印出来的模块动态加载基地址:
    aic /> test_dm_lib
    01-01 10:05:30 I/NO_TAG: Module: load /sdcard/hello.so to 0x404f8c80 succeed.
    [AIC-DM-APP] init!
    
  5. 通过 readelf 命令读取 ELF 文件 .text 段的偏移:
    $ readelf -S hello/hello.so     // .text 的 Address 字段为 '0x550'
    Section Headers:
    [Nr] Name              Type             Address           Offset
        Size              EntSize          Flags  Link  Info  Align
        ...
    [7] .text             PROGBITS         000000000000055000000550
        000000000000008e  0000000000000000  AX       002
        ...
    
  6. 计算出 .text 的动态基地址:
    0x404f8c80 + 0x550 = 0x404F91D0
  7. GDB 中加载 hello.so 符号表:
    (gdb) add-symbol-file aic-dm-apps/hello/hello.so 0x404F91D0
    
  8. 操作 GDB 跳转到 hello.so 中的 my_thread_init() 函数后,可以开始调试:
    (gdb) n
    30      func();(gdb) s
    my_thread_init () at hello/rtt_api_test.c:18
    18tid= rt_thread_create("tMyTask", my_thread_entry, RT_NULL
    

创建用户 DM App

用户可以基于 hello 实例来开发自己的 DM app ,把 hello 文件夹复制并改名成自己的应用:

  1. 进入 luban-lite/packages/artinchip/aic-dm-apps 目录。
  2. 复制 hello 文件夹并重命名为自己的应用名称,例如 xxxapp
  3. 在终端中运行 scons --app=xxxapp,编译应用程序。
  4. 运行 ls xxxapp/xxxapp.mo 查看目标文件。
  5. 运行 scons --app=xxxapp -c 清理编译产物。
$ cd luban-lite/packages/artinchip/aic-dm-apps
$ cp -r hello xxxapp
$ scons --app=xxxapp            // 编译
$ ls xxxapp/xxxapp.mo           // 目标文件
$ scons --app=xxxapp -c         // 清理

把用户源文件拷贝到 xxxapp 文件夹,编辑 xxxapp/SConscript 文件让所有源文件能被 SCons 编译。关于 SConscript 的语法和修改方法,参考 SConstruct