动态加载
动态模块(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 的链接和加载。基本原理如下:
执行以下步骤,可以实现对动态模块的有效调试,确保能够在产品开发过程中灵活地进行二次开发和调试:
-
编译链接:使用 GCC 工具链(使用 -fPIC -shared 选项),将应用源文件 main.c 编译链接成 ET_DYN 格式的 ELF 文件,即 hello.mo 文件。注: 必须使用 riscv-none-embed-gcc 工具链,否则会导致编译不成功。
hello.mo 是一个标准的 ET_DYN 格式 ELF 文件,位置无关且可动态链接。
-
文件加载:在运行时,首先会把 hello.mo 文件的数据段代码段拷贝到内存当中。
由于代码是是位置无关代码 (PIC),可以从 Heap 中动态分配内存再进行代码拷贝。此时代码中还存在很多对系统函数的调用,需要重新定位重新链接后才能运行。
-
动态链接:遍历 hello.mo 中的可重定位段,在内核的导出符号表 rtmsymtab 中查询需要重定位的符号,并将查询到的绝对地址回填到可重定位符号的位置。至此完成动态链接,可以跳转到程序入口处执行。
Kernel 配置
- 启用 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
- 启用动态模块
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
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。
执行以下操作步骤,可解决这种问题:
-
使用以下命令,在工具链中找出函数的原型声明:
$ 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)
-
在 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);
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 下的命令行
编译和运行
dm-app 可以被编译成两种类型:可执行文件 (.mo) 和库文件 (.so)。本质上两者都是 ET_DYN 类型的 ELF 文件,唯一的不同是可执行文件指定了 main 函数作为执行入口,而库文件指定了 0 作为执行入口。
编译和运行可执行文件.mo
- 编译
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 // 清理
- 运行 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
- 编译
dm-app 为库文件
具体步骤如下:
$ cd luban-lite/packages/artinchip/aic-dm-apps $ scons --lib=hello // 编译 $ ls hello/hello.so // 查看目标文件 $ scons --lib=hello -c // 清理
- 运行 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
实例代码分析
使用可执行文件 hello.mo 和库文件 hello.so 的源码时,需要注意以下特殊事项:
-
可执行文件 .mo
-
RT-Thread API 的调用在 hello.mo 中,可以调用如 rt_thread_create、 rt_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; }
-
模块初始化和退出函数如果 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"); }
用户可以利用该机制来做一些初始化和清理的工作。如果不需要,则无需实现这两个函数。
-
查看 hello.mo 创建的子线程
从代码可知,运行 hello.mo 后会创建
tMyTask
线程,但使用 ps 或者 list_thread 命令无法看到该线程。这是因为该方式下启动的线程会被链接到模块本身的进程链表module->object_list
,而上述命令只能查看全局链表information->object_list
中的线程。目前模块本身的进程链表
module->object_list
不支持命令查看,在模块退出时会停止掉module->object_list
中模块启动的所有子进程。 -
后台进程保活当 hello.mo 的 main() 函数返回后,系统马上会执行模块退出动作,main() 函数创建的所有子进程也会被全部清理。为了让模块的子进程作为后台进程继续运行,可以为 hello.mo 的 main() 函数定义一个特殊返回值 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
-
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 }
-
查看 hello.so 创建的子线程
通过 test_dm_lib 命令动态加载 hello.so 并调用 my_thread_init() 函数,同样会会创建
tMyTask
线程。上述示例中,使用 ps 或者 list_thread 命令可以看到该线程,是因为系统通过 dlmodule_self() 判断当前进程非模块执行进程,对应的进程链表就加入到了全局链表information->object_list
中。 -
后台进程保活
当调用 my_thread_init() 函数返回后,常规情况是执行 dlclose(module) 来清理动态加载的模块。如果 my_thread_init() 函数创建的所有子进程希望作为后台进程运行,则不能调用 dlclose(module) 来卸载动态库。因为一旦调用 dlclose(module),动态库就会被卸载,而此时后台进程可能仍在运行并需要访问动态库中的数据。一旦有新的内存分配就会覆盖原动态模块的数据,触发 CPU 异常。
为了避免这种情况,可以在不调用 dlclose(module) 的情况下让程序持续运行,或者在程序结束时手动清理动态库占用的资源。
-
GDB 调试
使用 JTAG 调试器连接 SoC,并通过 DebugServer 提供的 GDB 调试接口来,可以调试 dm-app。以 test_dm_lib 命令动态加载 hello.so 为例,整个调试过程如下所示:
保留 ELF 调试信息
为了能够调试动态模块,需要在编译时保留原始 ELF 文件中的调试信息。
hello.mo 和 hello.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 命令中的基地址。
-
模块动态加载基地址
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!
.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 ...
-
GDB 加载符号表
在 GDB 中加载动态模块的符号表。假设已经获得了动态加载地址和
示例如下:.text
段的偏移,可以使用 add-symbol-file 命令来加载符号表。0x404f8c80 + 0x550 = 0x404F91D0
完整 GDB 调试过程
-
在 GDB 中给系统函数 dlmodule_load() 打上断点:
(gdb) b dlmodule_load
-
在串口 Shell 中执行 test_dm_lib 命令启动测试:
test_dm_lib
-
GDB 中断在 dlmodule_load() 入口以后,输入 finish 命令让 dlmodule_load() 执行完成:
(gdb) finish
-
记录串口 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!
-
通过 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 ...
-
计算出
.text
的动态基地址:0x404f8c80 + 0x550 = 0x404F91D0
-
GDB 中加载 hello.so 符号表:
(gdb) add-symbol-file aic-dm-apps/hello/hello.so 0x404F91D0
-
操作 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 文件夹复制并改名成自己的应用:
- 进入 luban-lite/packages/artinchip/aic-dm-apps 目录。
- 复制 hello 文件夹并重命名为自己的应用名称,例如 xxxapp。
- 在终端中运行 scons --app=xxxapp,编译应用程序。
- 运行 ls xxxapp/xxxapp.mo 查看目标文件。
- 运行 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。