Dynamic Module 方案
配置
我们简称 Luban-Lite 原生开发的映像为 Kernel
,使用 Dynamic Module
机制开发的应用为 dm-app
。目前仅在 Luban-Lite 内核为 RT-Thread 时支持该特性。
Kernel
配置
要使用 Dynamic Module
功能,内核需要打开以下两项配置:
Rt-Thread options --->
RT-Thread Components --->
C/C++ and POSIX layer --->
POSIX (Portable Operating System Interface) layer --->
[*] Enable POSIX file system and I/O
[*] Enable dynamic module APIs, dlopen()/dlsym()/dlclose() etc
2
3
4
5
6
还可以选择 dm-app 动态加载时使用的内存 heap。具体的可选 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
2
3
4
5
6
7
8
符号导出
Kernel 中被 dm-app 访问到的符号需要使用 RTM_EXPORT()
宏来进行声明,类似 Linux 中的 EXPORT_SYMBOL()
宏。
对于一些标准的 C 库函数,Kernel 已经定义好了 RTM_EXPORT()
声明,dm-app 可以直接使用。例如:
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);
...
2
3
4
5
6
7
8
9
10
11
对于 RT-Thread API 函数,Kernel 已经定义好了 RTM_EXPORT()
声明,dm-app 可以直接使用。例如:
RTM_EXPORT(rt_thread_create);
RTM_EXPORT(rt_thread_yield);
RTM_EXPORT(rt_thread_startup);
RTM_EXPORT(rt_thread_detach);
...
2
3
4
5
可以在 Luban-Lite 的命令行下,使用 list_symbols
命令查看当前系统已经使用 RTM_EXPORT()
声明的符号:
aic /> 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
2
3
4
5
6
7
8
9
10
- 没有使用
RTM_EXPORT()
声明的 Kernel 函数是不能在 dm-app 中使用的。如果用户有自定义的 Kernel 函数需要在 dm-app 中使用,必须使用 RTM_EXPORT()
声明。 GCC low-level 函数的符号导出
在加载 dm-app 时,有时会碰到找不到__floatdidf
、 __umoddi3
、 __udivdi3
、 __fixdfdi
等函数符号的情况。
这些函数是 GCC low-level runtime library
库中定义的函数,这些函数的目的是用软件的方法实现 CPU 原生硬件指令实现不了的 GCC 基础运算。比如在 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)
2
3
在 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);
2
3
4
5
6
7
8
DM-APP
生成 SDK
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
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!
2
3
4
5
6
7
8
9
10
11
12
13
dm-app SDK 创建完成以后, aic-dm-apps
就可以脱离 luban-lite sdk
进行开发了。 aic-dm-apps
文件夹可以被拷贝到任意 Linux/Windows 路径进行开发和编译。
也支持清理 SDK:
$ cd luban-lite/packages/artinchip/aic-dm-apps
$ scons --target=c
2
目录结构
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 下的命令行
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
编译和运行
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 // 清理
2
3
4
运行
把 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
2
3
4
5
6
7
库文件 .so
编译
编译 dm-app 为库文件。具体步骤如下:
$ cd luban-lite/packages/artinchip/aic-dm-apps
$ scons --lib=hello // 编译
$ ls hello/hello.so // 查看目标文件
$ scons --lib=hello -c // 清理
2
3
4
运行
在 Kernel 中使能 test_dm_lib
测试命令:
Drivers options --->
Drivers examples --->
[*] Enable DM Lib test command
2
3
把 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
2
3
4
5
实例代码分析
可执行文件 hello.mo
和 库文件 hello.so
的源码在非常简单基本一看即懂,但是其中有一些特殊的点需要特别说明:
可执行文件 .mo
RT-Thread API 的调用:
rt_thread_create
、 rt_thread_startup
类似的 API 默认已经使用 RTM_EXPORT()
声明,可以直接调用:
#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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
模块初始化和退出函数:
如果 dm-app 定义了 module_init() 和 module_cleanup() 函数,会在模块初始化和退出时被自动调用:
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");
}
2
3
4
5
6
7
8
9
用户可以利用该机制来做一些初始化和清理的工作。如果不需要就不用实现这两个函数。
查看 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() 函数返回后系统不会执行模块退出动作:
#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;
}
2
3
4
5
6
7
8
9
10
11
12
13
库文件 .so
dlopen()、dlsym()
实例:
test_dm_lib
命令的基本原理是使用 dlopen()
函数动态加载 hello.so
到系统内存,再使用 dlsym()
函数查找到 hello.so
中的 my_thread_init()
函数并调用:
#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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
查看 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 异常。
GDB 调试
使用 JTAG 调试器连接 SoC,通过 DebugServer 提供的 GDB 调试接口来调试 dm-app。
调试动态模块需要两方面的信息:
- 动态模块的符号表。
- 动态模块的动态加载地址。
以 test_dm_lib
命令动态加载 hello.so
为例,描述整个调试过程。
保留 ELF 调试信息
hello.mo
和 hello.so
原始 ELF 文件中是有 debug 信息的,为了减少动态加载时的内存大小,把这些信息 strip 掉了。
所以在调试的时候,我们需要临时把rtconfig.py
文件中这个 strip 动作 M_POST_ACTION
注释掉:
# M_POST_ACTION = M_STRIP + ' -R .hash $TARGET\n' + M_SIZE + ' $TARGET \n'
rtconfig.py
文件的路径:如果 aic-dm-apps
还在 Luban-Lite 目录当中,修改 luban-lite\bsp\artinchip\sys\dxxx\rtconfig.py
;如果 aic-dm-apps
已经从 Luban-Lite 拷贝出来, 修改 aic-dm-apps\tools\sdk\rtconfig.py
。
重新运行 scons --app=hello
或者 scons --lib=hello
,编译出来的 hello.mo
或者 hello.so
ELF 文件就是带有 debug 调试信息了。
计算动态加载地址
模块动态加载基地址
动态模块的基地址会在加载函数dlmodule_load()
加载完动态模块后,打印出当前模块的基地址。
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
3
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 0000000000000550 00000550
000000000000008e 0000000000000000 AX 0 0 2
...
2
3
4
5
6
7
8
GDB 加载符号表
通过上两节的地址可以计算出 add-symbol-file hello.so xxx
的基地址:
0x404f8c80 + 0x550 = 0x404F91D0
可以通过 GDB 命令 add-symbol-file aic-dm-apps/hello/hello.so 0x404F91D0
来加载动态模块的符号表了。
完整 GDB 调试过程
- Step 1:在 GDB 中给系统函数
dlmodule_load()
打上断点:
(gdb) b dlmodule_load
- Step 2:在串口 Shell 中启动
test_dm_lib
测试:
aic /> test_dm_lib
- Step 3:GDB 中断在
dlmodule_load()
入口以后,输入finish
命令让dlmodule_load()
执行完成:
(gdb) finish
- Step 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!
2
3
- Step 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 0000000000000550 00000550
000000000000008e 0000000000000000 AX 0 0 2
...
2
3
4
5
6
7
8
- Step 6:计算出 .text 的动态基地址:
0x404f8c80 + 0x550 = 0x404F91D0
- Step 7:GDB 中加载
hello.so
符号表:
(gdb) add-symbol-file aic-dm-apps/hello/hello.so 0x404F91D0
- Step 8:操作 GDB 跳转到
hello.so
中的my_thread_init()
函数后,可以像普通程序一样调试了:
(gdb) n
30 func();
(gdb) s
my_thread_init () at hello/rtt_api_test.c:18
18 tid = rt_thread_create("tMyTask", my_thread_entry, RT_NULL
2
3
4
5
创建用户 dm-app
用户开发自己的 dm-app 可以基于 hello
实例来开发,把 hello
文件夹复制并改名成自己的应用:
$ cd luban-lite/packages/artinchip/aic-dm-apps
$ cp -r hello xxxapp
$ scons --app=xxxapp // 编译
$ ls xxxapp/xxxapp.mo // 目标文件
$ scons --app=xxxapp -c // 清理
2
3
4
5
把用户源文件拷贝到 xxxapp
文件夹,编辑xxxapp/SConscript
文件让所有源文件能被 SCons 编译。 SConscript
的语法和修改方法请参考 Luban-Lite 的相关文档。
原理说明
在 Linux/Windows 等大型系统中应用和驱动是可以独立开发,应用独立编译成 ELF/EXE 文件,然后在目标系统上执行。毫无疑问这种动态加载的方式是需要开销的,一般嵌入式系统都精简了该功能。但是在实际产品开发的过程中,特别是需要二次开发的场景,独立开发和编译应用程序有强烈需求。
Luban-Lite 使用动态模块 (Dynamic Module) 机制来支持应用程序独立开发的需求。基本原理如下:
核心就是实现了 ELF 的链接和加载。具体步骤分解如下:
1.编译链接:使用 GCC
工具链将应用源文件 main.c
使用 -fPIC -shared
选项编译链接成 ET_DYN
格式的 ELF 文件 hello.mo
。 hello.mo
是一个标准的 ET_DYN 格式 ELF 文件,位置无关且可动态链接。注意这里需要使用 riscv-none-embed-gcc
工具链,否则会编译不成功。
2.文件加载:在运行的时候,首先会把 hello.mo
文件的数据段代码段拷贝到内存当中。因为我们编译的是位置无关代码 PIC,代码可以被加载到任意位置,所以可以从 Heap 中动态分配内存再进行代码拷贝。此时代码还是不能运行,因为代码中还存在很多对系统函数的调用,需要重新定位重新链接。
3.动态链接:遍历 hello.mo
中的可重定位段,对需要重定位的符号,在内核的导出符号表rtmsymtab
中查询,将查询到的绝对地址回填到可重定位符号的位置。至此完成动态链接,可以跳转到程序入口处执行了。