10.2. Dynamic Module 使用指南

10.2.1. 配置

我们简称 Luban-Lite 原生开发的映像为 kernel ,使用 Dynamic Module 机制开发的应用为 dm-app。目前仅在 Luban-Lite 内核为 rt-thread 时支持该特性。

10.2.1.1. kernel

10.2.1.1.1. 配置

要使用 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

还可以选择 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

10.2.1.1.2. 符号导出

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

对于一些标准的 c 库函数,kernel 已经定义好了 RTM_EXPORT() 声明,dm-app 可以直接使用。例如:

列表 10.1 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 可以直接使用。例如:

列表 10.2 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);
...

可以在 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

重要

没有使用 RTM_EXPORT() 声明的 kernel 函数是不能在 dm-app 中使用的。如果用户有自定义的 kernel 函数需要在dm-app 中使用,必须使用 RTM_EXPORT() 声明。

10.2.1.2. DM-APP

10.2.1.2.1. 生成 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!

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

10.2.1.2.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 下的命令行

10.2.2. 编译和运行

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

10.2.2.1. 可执行文件 .mo

10.2.2.1.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          // 清理

10.2.2.1.2. 运行

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

10.2.2.2. 库文件 .so

10.2.2.2.1. 编译

编译 dm-app 为库文件。具体步骤如下:

$ cd luban-lite/packages/artinchip/aic-dm-apps
$ scons --lib=hello             // 编译
$ ls hello/hello.so             // 查看目标文件
$ scons --lib=hello -c          // 清理

10.2.2.2.2. 运行

在 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

10.2.3. 实例代码分析

可执行文件 hello.mo 和 库文件 hello.so 的源码在非常简单基本一看即懂,但是其中有一些特殊的点需要特别说明:

10.2.3.1. 可执行文件 .mo

10.2.3.1.1. rt-thread API 的调用:

rt_thread_creatert_thread_startup 类似的 API 默认已经使用 RTM_EXPORT() 声明,可以直接调用:

列表 10.3 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;
}

10.2.3.1.2. 模块初始化和退出函数:

如果 dm-app 定义了 module_init() 和 module_cleanup() 函数,会在模块初始化和退出时被自动调用:

列表 10.4 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");
}

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

10.2.3.1.3. 查看 hello.mo 创建的子线程:

从代码可知我们运行 hello.mo 以后,会创建 tMyTask 线程。但是我们使用 ps 或者 list_thread 命令,却无法看到该线程。这是为什么呢?

因为该方式下启动的线程会被链接到模块本身的进程链表 module->object_list ,而上述命令只能查看全局链表 information->object_list 中的线程。

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

10.2.3.1.4. 后台进程保活:

承接上面话题, hello.mo 的 main() 函数返回后,系统马上会执行模块退出动作,main() 函数创建的所有子进程也会被全部清理。怎么样能让模块的子进程作为后台进程继续运行呢?

我们给 hello.mo 的 main() 函数定义了一个特殊返回值 RT_DLMODULE_DEAMON ,如果返回该值,则 main() 函数返回后系统不会执行模块退出动作:

列表 10.5 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;
}

10.2.3.2. 库文件 .so

10.2.3.2.1. dlopen()、dlsym() 实例:

test_dm_lib 命令的基本原理是使用 dlopen() 函数动态加载 hello.so 到系统内存,再使用 dlsym() 函数查找到 hello.so 中的 my_thread_init() 函数并调用:

列表 10.6 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
}

10.2.3.2.2. 查看 hello.so 创建的子线程:

我们通过 test_dm_lib 命令动态加载 hello.so 并调用 my_thread_init() 函数,同样会会创建 tMyTask 线程。但是我们使用 ps 或者 list_thread 命令,这次却可以看到该线程。这是为什么呢?

这是因为系统通过 dlmodule_self() 判断当前进程非模块执行进程,对应的进程链表就加入到了全局链表 information->object_list 中。

10.2.3.2.3. 后台进程保活:

同样的问题,当我们调用 my_thread_init() 函数返回后,常规情况是执行 dlclose(module) 来清理动态加载的模块。如果 my_thread_init() 函数创建的所有子进程希望作为后台进程运行,则不能调用 dlclose(module)

这种情况下如果调用 dlclose(module) ,后台进程还能短暂执行,但是一旦有新的内存分配就会覆盖原动态模块的数据,触发 CPU 异常。

10.2.4. gdb 调试

使用 JTAG 调试器连接 SOC,通过 DebugServer 提供的 gdb 调试接口来调试 dm-app。

调试动态模块需要两方面的信息:

  • 动态模块的符号表。

  • 动态模块的动态加载地址。

test_dm_lib 命令动态加载 hello.so 为例,描述整个调试过程。

10.2.4.1. 保留 elf 调试信息

hello.mohello.so 原始 elf 文件中是有 debug 信息的,为了减少动态加载时的内存大小,把这些信息 strip 掉了。

所以在调试的时候,我们需要临时把 rtconfig.py 文件中这个 strip 动作 M_POST_ACTION 注释掉:

列表 10.7 rtconfig.py
# 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 调试信息了。

10.2.4.2. 计算动态加载地址

10.2.4.2.1. 模块动态加载基地址

动态模块的基地址会在加载函数 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!

10.2.4.2.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         0000000000000550  00000550
    000000000000008e  0000000000000000  AX       0     0     2
    ...

10.2.4.2.3. gdb 加载符号表

通过上两节的地址可以计算出 add-symbol-file hello.so xxx 的基地址:

0x404f8c80 + 0x550 = 0x404F91D0

可以通过 gdb 命令 add-symbol-file aic-dm-apps/hello/hello.so 0x404F91D0 来加载动态模块的符号表了。

10.2.4.2.4. 完整 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!
  • 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
    ...
  • 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

10.2.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         // 清理

把用户源文件拷贝到 xxxapp 文件夹,编辑 xxxapp/SConscript 文件让所有源文件能被 scons 编译。 SConscript 的语法和修改方法请参考 Luban-Lite 的相关文档。

10.2.6. 原理说明

在 Linux/Windows 等大型系统中应用和驱动是可以独立开发,应用独立编译成 elf/exe 文件,然后在目标系统上执行。毫无疑问这种动态加载的方式是需要开销的,一般嵌入式系统都精简了该功能。但是在实际产品开发的过程中,特别是需要二次开发的场景,独立开发和编译应用程序有强烈需求。

Luban-Lite 使用动态模块 (Dynamic Module) 机制来支持应用程序独立开发的需求。基本原理如下:

../../../_images/dm_design.png

图 10.77 Dynamic Module 实现原理

核心就是实现了 ELF 的链接和加载。具体步骤分解如下:

  1. 编译链接:使用 gcc 工具链将应用源文件 main.c 使用 -fPIC -shared 选项编译链接成 ET_DYN 格式的 ELF 文件 hello.mohello.mo 是一个标准的 ET_DYN 格式ELF 文件,位置无关且可动态链接。注意这里需要使用 riscv-none-embed-gcc 工具链,否则会编译不成功。

  2. 文件加载:在运行的时候,首先会把 hello.mo 文件的数据段代码段拷贝到内存当中。因为我们编译的是位置无关代码 PIC,代码可以被加载到任意位置,所以可以从 Heap 中动态分配内存再进行代码拷贝。此时代码还是不能运行,因为代码中还存在很多对系统函数的调用,需要重新定位重新链接。

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