liteos动态加载(十三)
1. 概述
1.1 基本概念
动态加载是一种程序加载技术。
静态链接是在链接阶段将程序各模块文件链接成一个完整的可执行文件,运行时作为整体一次性加载进内存。动态加载允许用户将程序各模块编译成独立的文件而不将它们链接起来,在需要使用到模块时再动态地将其加载到内存中。
静态链接将程序各模块文件链接成一个整体,运行时一次性加载入内存,具有代码装载速度快等优点。但当程序的规模较大,模块的变更升级较为频繁时,会存在内存和磁盘空间浪费、模块更新困难等问题。
动态加载技术可以较好地解决上述静态链接中存在的问题,在程序需要执行所依赖的模块中的代码时,动态地将外部模块加载链接到内存中,不需要该模块时可以卸载,可以提供公共代码的共享以及模块的平滑升级等功能。Huawei LiteOS提供支持OBJ目标文件和SO共享目标文件的动态加载机制。
Huawei LiteOS的动态加载功能需要SO共享目标文件(或OBJ目标文件)、基础符号表elf_symbol.so、系统镜像bin文件配合使用。
1.2 动态加载相关概念
符号表
符号表在表现形式上是记录了符号名及其所在内存地址信息的数组,符号表在动态加载模块初始化时被载入到动态加载模块的符号管理结构中。在加载用户模块进行符号重定位时,动态加载模块通过查找符号管理结构得到相应符号所在地址,对相应重定位项进行重定位。
2. 开发指导
接口名 | 描述 |
---|---|
LOS_LdInit | 初始化动态加载模块 |
LOS_LdDestroy | 销毁动态加载模块 |
LOS_SoLoad | 动态加载一个so模块 |
LOS_ObjLoad | 动态加载一个obj模块 |
LOS_FindSymByName | 在模块或系统符号表中查找符号地址 |
LOS_ModuleUnload | 卸载一个模块 |
LOS_PathAdd | 添加一个相对路径 |
2.1 开发流程
动态加载主要有以下几个步骤:
- 编译环境准备
- 基础符号表elf_symbol.so的获取及镜像编译
- 动态加载接口使用
- 系统环境准备
2.2 编译环境准备
步骤1 添加.o和.so模块编译选项
- .o模块的编译选项中需要添加-mlong-calls -nostdlib选项
- .so模块的编译选项中需要添加-nostdlib -fPIC -shared选项
IPC的动态加载需要用户保证所提供的模块文件中所有LD_SHT_PROGBITS、LD_SHT_NOBITS类型节区起始地址都4字节对齐,否则拒绝加载该模块
.o和.so模块编译选项添加示例如下:
RM = -rm -rf
CC = arm-hisiv500-linux-gcc
SRCS = $(wildcard *.c)
OBJS = $(patsubst %.c,%.o,$(SRCS))
SOS = $(patsubst %.c,%.so,$(SRCS))
all: $(SOS)
$(OBJS): %.o : %.c
@$(CC) -mlong-calls -nostdlib -c $< -o $@
$(SOS): %.so : %.c
@$(CC) -mlong-calls -nostdlib $< -fPIC -shared -o $@
clean:
@$(RM) $(SOS) $(OBJS)
.PHONY: all clean
步骤2 系统镜像
系统镜像bin文件编译makefile必须include根目录下config.mk文件,并使用其中的LITEOS_CFLAGS或LITEOS_CXXFLAGS编译选项,示例如下:
LITEOSTOPDIR ?= ../..
SAMPLE_OUT = .
include $(LITEOSTOPDIR)/config.mk
RM = -rm -rf
LITEOS_LIBDEPS := --start-group $(LITEOS_LIBDEP) --end-group
SRCS = $(wildcard sample.c)
OBJS = $(patsubst %.c,$(SAMPLE_OUT)/%.o,$(SRCS))
all: $(OBJS)
clean:
@$(RM) *.o sample *.bin *.map *.asm
$(OBJS): $(SAMPLE_OUT)/%.o : %.c
$(CC) $(LITEOS_CFLAGS) -c $< -o $@
$(LD) $(LITEOS_LDFLAGS) -uinit_jffspar_param --gc-sections -Map=$(SAMPLE_OUT)/sample.map -o $
(SAMPLE_OUT)/sample ./$@ $(LITEOS_LIBDEPS) $(LITEOS_TABLES_LDFLAGS)
$(OBJCOPY) -O binary $(SAMPLE_OUT)/sample $(SAMPLE_OUT)/sample.bin
$(OBJDUMP) -d $(SAMPLE_OUT)/sample >$(SAMPLE_OUT)/sample.asm
2.3 基础符号表 elf_symbol.so 的获取及镜像编译
请严格按如下步骤进行编译。
步骤1 编译.o和.so模块,并将系统运行所需的所有.o和.so文件拷贝到一个目录下,例如
/home/wmin/customer/out/so
说明
- 如果a.so需要调用b.so中的函数,或者a.so引用到了b.so中的数据,则称a.so依赖b.so。
- 当用户中a.so模块依赖b.so,且需要在加载a.so时自动将b.so也加载进来,则在编译a.so时需要将b.so作为编译参数。
步骤2 进入Huawei_LiteOS/tools/scripts/dynload_tools目录执行如下脚本命令
$ ./ldsym.sh /home/wmin/customer/out/so
- “$”是linux shell提示符,下同
- ldsym.sh脚本只需传入系统运行所需的所有.o和.so文件所在的那个目录绝对路径即可。
- 目录路径必须是绝对路径。
- 必须要在Huawei_LiteOS/tools/scripts/dynload_tools目录下执行该命令
步骤3 编译系统镜像bin文件, 同时生成镜像文件,例如该目录在/home/wmin/customer/out/bin/,编译后在该目录下生成了sample镜像文件和用于烧写flash的sample.bin文件。
步骤4 进入Huaw_LiteOS/tools/scripts/dynload_tools目录执行sym.sh脚本得到基础符号表elf_symbol.so文件,示例如下:
$ ./sym.sh /home/wmin/customer/out/so arm-hisiv500-linux- /home/wmin/customer/out/bin/vs_server
- sym.sh的三个参数分别是:系统运行所需的所有.o和.so文件所在的那个目录绝对路径,编译
器类型,系统镜像文件(不是烧写flash用的bin文件)。- 所有参数中路径都必须是绝对路径。
- 第三个参数必须是系统镜像文件,不是烧写flash用的bin文件。
- 基础符号表elf_symbol.so文件生成在系统镜像文件同一路径下。
- 注意每次系统镜像的重新编译,都要将基础符号表elf_symbol.so重新生成并更新。
- 必须要在Huawei_LiteOS/tools/scripts/dynload_tools目录下执行该命令。
步骤5 进入Huawi_LiteOS/tools/scripts/dynload_tools目录执行failed2reloc.py脚本得到用户模块中无法完成重定位的符号:
$ ./failed2reloc.py /home/wmin/customer/out/bin/vs_server /home/wmin/customer/out/so
- failed2reloc.py的两个参数分别是:系统镜像文件(不是烧写flash用的bin文件),系统运行所
需的所有.o和.so文件所在的那个目录绝对路径。- 所有参数中路径都必须是绝对路径。
- 第一个参数必须是系统镜像文件,不是烧写flash用的bin文件。
- 该脚本输出用户模块中无法完成重定位的符号信息。
2.4 动态加载接口
步骤1 初始化动态加载模块
- 在使用动态加载特性前,需要调用LOS_LdInit接口初始化动态加载模块:
if (LOS_OK != LOS_LdInit("/yaffs/bin/dynload/elf_symbol.so", NULL, 0)) {
printf("ld_init failed!!!!!!\n");
return 1;
}
LOS_LdInit函数第一个参数是基础符号表文件路径,第二个参数是动态加载模块进行内存分配的堆起始地址,第三个参数是这块作为堆使用的内存的长度,在LOS_LdInit接口中会将这段内存初始化为堆;如果用户希望动态加载模块从系统堆上分配内存,第二个参数传入NULL,第三个参数被忽略。
- 动态加载模块的初始化只需要在业务启动时调用一次即可,重复初始化动态加载模块会返回失败。
上面这段代码演示使用系统堆,如下代码演示自定义堆:
#define HEAP_SIZE0x8000
INT8 usrHeap[HEAP_SIZE];
if (LOS_OK != LOS_LdInit("/yaffs/bin/dynload/elf_symbol.so, usrHeap,sizeof(usrHeap)")) {
printf("ld_init failed!!!!!!\n")
return 1;
}
- 用户不需要对自定义的这块内存进行初始化动作。
- 动态加载所需分配的堆内存大小视要加载的模块而定,因此如果用户需要指定自定义堆时,需要保证堆长度足够大,否则建议使用系统堆。
步骤2 加载用户模块
- IPC的动态加载模块支持对.o以及.so模块的动态加载,对于obj文件的动态加载使用LOS_ObjLoad接口:
if ((handle = LOS_ObjLoad("/yaffs/bin/dynload/foo.o")) == NULL){
printf("load module ERROR!!!!!!\n");
return 1;
}
- 对于so文件的动态加载使用LOS_SoLoad接口:
if ((handle = LOS_SoLoad("/yaffs/bin/dynload/foo.so")) == NULL){
printf("load module ERROR!!!!!!\n");
return 1;
}
对于so文件的动态加载,如果一个模块A需要另一个模块B,也就是存在模块A依赖于模块B的关系,则在加载A模块时会将模块B也加载进来。
步骤3 获取用户模块中的符号地址
- 在特定模块中查找符号
需要在某个特定模块中查找用户模块的符号地址时,调用LOS_FindSymByName接口,并将LOS_FindSymByName的第一个参数置为需要查找的用户模块的句柄。
if ((ptr_magic = LOS_FindSymByName(handle, "os_symbol_table")) == NULL) {
printf("symbol not found\n");
return 1;
}
- 在全局符号表中查找符号
需要在全局符号表(即OS模块,包括本模块和所有其他用户模块)中查找某个符号的地址时,调用LOS_FindSymByName接口,并将LOS_FindSymByName的第一个参数置NULL。
if ((pFunTestCase0 = LOS_FindSymByName(NULL, "printf")) == NULL) {
printf("symbol not found\n");
return 1;
}
步骤4 使用获取到的符号地址: LOS_FindSymByName返回一个符号的地址(VOID *指针),用户在拿到这个指针之后可以做相应的类型转换来使用该符号,下面举两个例子说明,一个针对数据类型符号,一个针对函数类型符号。
- 结构体数组类型符号(演示说明数据类型的符号使用)现有结构体KERNEL_SYMBOL定义如下:
typedef struct KERNEL_SYMBOL {
UINT32 uwAddr;
INT8 *pscName;
} KERNEL_SYMBOL;
"/bin/dynload/elf_symbol.so"模块中定义了结构体数组:
KERNEL_SYMBOL los_elf_symbol_table[LENGTH_ARRAY] = {
{0x83040000, "__exception_handlers"},
{0x8313b864, "cmd_version"},
…
{0, 0},
};
通过如下代码遍历los_elf_symbol_table中的各项:
const char *g_pscOsOSSymtblFilePath = "/yaffs/bin/dynload/elf_symbol.so";
const char *g_pscOsSymtblInnerArrayName = "los_elf_symbol_table";
typedef KERNEL_SYMBOL (*OS_SYMTBL_ARRAY_PTR)[LENGTH_ARRAY];/* 结构体数组指针类型声明 */
OS_SYMTBL_ARRAY_PTR pstSymtblPtr = (OS_SYMTBL_ARRAY_PTR)NULL;
VOID *pPtr = (VOID *)NULL;
UINT32 uwIdx = 0;
UINT32 uwAddr;
INT8 *pscName = (INT8 *)NULL;
if ((pOSSymtblHandler = LOS_SoLoad(g_pscOsOSSymtblFilePath)) == NULL) {
return LOS_NOK;
}
if ((pPtr = LOS_FindSymByName(pOSSymtblHandler, g_pscOsSymtblInnerArrayName)) == NULL) {
printf("os_symtbl not found\n");
return LOS_NOK;
}
pstSymtblPtr = (OS_SYMTBL_ARRAY_PTR)pPtr;/* 强制类型转换成真实的指针类型 */
uwAddr = (*pstSymtblPtr)[0].uwAddr;
pscName = (*pstSymtblPtr)[0].pscName;
while (uwAddr != 0 && pscName != 0) {
++uwIdx;
uwAddr= (*pstSymtblPtr)[uwIdx].uwAddr;
pscName= (*pstSymtblPtr)[uwIdx].pscName;
}
- 函数类型符号
foo.c中定义了一个无参的函数test_0和一个有两个参数的函数test_2,编译生成foo.o,代码演示在demo.c中获取foo.o模块中的函数并调用。
foo.c:
int test_0(void) { return 0; }
int test_2(int i, int j) { return 0; }
demo.c
typedef unsigned int (* TST_CASE_FUNC)();/* 无形参函数指针类型声明 */
typedef unsigned int (* TST_CASE_FUNC1)(UINT32); /* 单形参函数指针类型声明 */
typedef unsigned int (* TST_CASE_FUNC2)(UINT32, UINT32); /* 双形参函数指针类型声明 */
TST_CASE_FUNC pFunTestCase0 = NULL;/* 函数指针定义 */
TST_CASE_FUNC2 pFunTestCase2 = NULL;
handle = LOS_ObjLoad("/yaffs/bin/dynload/foo.o");
pFunTestCase0 = NULL;
pFunTestCase0 = LOS_FindSymByName(handle, "test_0");
if (pFunTestCase0 == NULL){
printf("can not find the function name\n");
return 1;
}
uwRet = pFunTestCase0();
pFunTestCase2 = NULL;
pFunTestCase2 = LOS_FindSymByName(NULL, "test_2");
if (pFunTestCase2 == NULL){
printf("can not find the function name\n");
return 1;
}
uwRet = pFunTestCase2(42, 57);
步骤5 卸载模块
当要卸载一个模块时,调用LOS_ModuleUnload接口,将需要卸载的模块句柄作为参数传入该接口。对于已被加载过的obj或so文件的句柄,卸载时统一使用LOS_ModuleUnload接口。
uwRet = LOS_ModuleUnload(handle);
if (uwRet != LOS_OK) {
printf("unload module failed");
return 1;
}
步骤6 销毁动态加载模块
不再需要动态加载功能时,调用LOS_LdDestroy接口,卸载动态加载模块。
销毁动态加载模块时会自动卸载掉所有已被加载的模块。
uwRet = LOS_LdDestroy();
if (uwRet != LOS_OK) {
printf("destroy dynamic loader failed");
return 1;
}
在业务不再需要动态加载模块时销毁动态加载模块,该接口是与LOS_LdInit配对的接口。在销毁动态加载模块后,如果业务后续再需要动态加载必须再调用LOS_LdInit重新初始化动态加载模块。
步骤7 使用相对路径
用户在使用动态加载接口时,如果想使用相对路径,也即使用类似环境变量的机制时,需要通过LOS_PathAdd接口添加相对路径:
uwRet = LOS_PathAdd("/yaffs/bin/dynload");
if (uwRet != LOS_OK) {
printf("add relative path failed");
return 1;
}
添加相对路径后,用户在调用LOS_LdInit、 LOS_SoLoad、 LOS_ObjLoad接口时传入文件名即可,而无需再传入完整的绝对路径,动态加载会在用户添加的相对路径下查找相应模块。
如果用户传入的多个路径下有相同文件名的模块,则动态加载在加载模块时按照添加的先后依次在所有相对路径中查找,且只加载第一个查找到的文件。
- 只有在调用LOS_PathAdd接口添加相对路径后,才能在调用动态加载接口时使用相对路径。
- 用户可以通过多次调用LOS_PathAdd接口添加多个相对路径
2.5 系统环境准备
SO共享目标文件(或OBJ目标文件)、基础符号表elf_symbol.so、系统镜像bin文件配合使用。
其中SO共享目标文件(或OBJ目标文件)、基础符号表elf_symbol.so必须放置在文件系统中,例如jffs2、 yaffs、 fat等文件系统。
建议操作顺序:
- 烧写系统镜像bin文件到flash中,该镜像默认不启动动态加载功能
- 如果SO共享目标文件(或OBJ目标文件)、基础符号表elf_symbol.so路径为可热拔插的SD卡设备,则可在电脑更新SO共享目标文件(或OBJ目标文件)、基础符号表elf_symbol.so到SD卡指定路径
- 如果SO共享目标文件(或OBJ目标文件)、基础符号表elf_symbol.so路径为jffs2、 yaffs文件系统,则可通过如下两种方式更新:
- 烧写文件系统镜像
- 系统启动后tftp命令下载SO共享目标文件(或OBJ目标文件)、基础符号表elf_symbol.so,示例命令如下:
tftp -g -l /yaffs0/foo.so -r foo.so 10.67.211.235
tftp -g -l / yaffs0/elf_symbol.so -r elf_symbol.so 10.67.211.235
- 启动系统动态加载功能,进行验证
2.6 Shell 调试
在Shell里我们封装了一系列与动态加载有关的命令,方便用户进行调试。
具体的Shell命令详细说明参见命令参考。
- 初始化动态加载模块
当用户需要在Shell中调试动态加载特性的时候,需要首先初始化动态加载模块。
Shell命令: ldinit
Huawei LiteOS# ldinit /yaffs/bin/dynload/elf_symbol.so
Huawei LiteOS#
动态加载过程中发现符号重定义只作为一个warning而不作为error处理,所以仅反馈符号重定义而不返回其他错误信息表示动态加载模块初始化成功.
- 加载一个模块
Shell命令: mopen
Huawei LiteOS# mopen /yaffs/bin/dynload/foo.o
module handle: 0x80391928
Huawei LiteOS#
(1)模块路径必须要用绝对路径
(2)必须要先初始化动态加载模块再加载模块。
- 查找一个符号
Shell命令: findsym
Huawei LiteOS# findsym 0 printf
symbol address:0x8004500c
Huawei LiteOS#
Huawei LiteOS# findsym 0x80391928 test_0
symbol address:0x8030f241
Huawei LiteOS#
- 调用一个符号
Shell 命令: call
Huawei LiteOS# call 0x8030f241
test_0
Huawei LiteOS#
- 卸载一个模块
Shell命令: mclose
Huawei LiteOS# mclose 0x80391928
Huawei LiteOS#
3. 编程实例
3.1 实例描述
foo.c:
int test_0(void) { printf("test_0\n"); return 0; }
int test_2(int i, int j) { printf("test_2: %d %d\n", i, j); return 0; }
demo.c:
typedef int (* TST_CASE_FUNC)(); /* 无形参函数指针类型声明 */
typedef int (* TST_CASE_FUNC1)(UINT32); /* 单形参函数指针类型声明 */
typedef int (* TST_CASE_FUNC2)(UINT32, UINT32); /* 双形参函数指针类型声明 */
if (LOS_OK != LOS_LdInit("/yaffs/bin/dynload/elf_symbol.so", NULL, 0)) {
printf("ld_init failed!!!!!!\n");
return 1;
}
unsigned int uwRet;
TST_CASE_FUNC pFunTestCase0 = NULL;/* 函数指针定义 */
TST_CASE_FUNC2 pFunTestCase2 = NULL;
handle = LOS_ObjLoad("/yaffs/bin/dynload/foo.o");
pFunTestCase0 = NULL;
pFunTestCase0 = LOS_FindSymByName(handle, "test_0");
if (pFunTestCase0 == NULL){
printf("can not find the function name\n");
return 1;
}
uwRet = pFunTestCase0(); /* 调用该函数指针 */
pFunTestCase2 = NULL;
pFunTestCase2 = LOS_FindSymByName(NULL, "test_2");
if (pFunTestCase2 == NULL){
printf("can not find the function name\n");
return 1;
}
uwRet = pFunTestCase2(42, 57); /* 调用该函数指针 */
uwRet = LOS_ModuleUnload(handle);
if (uwRet != LOS_OK) {
printf("unload module failed");
return 1;
}
uwRet = LOS_LD_Destroy();
if (uwRet != LOS_OK) {
printf("destroy dynamic loader failed");
return 1;
}
3.2 结果验证
编译运行得到的结果为:
Huawei LiteOS#
*****************************
*****************************
test_0
test_2:42 57
*****************************
*****************************
liteos动态加载(十三)的更多相关文章
- geotrellis使用(二十三)动态加载时间序列数据
目录 前言 实现方法 总结 一.前言 今天要介绍的绝对是华丽的干货.比如我们从互联网上下载到了一系列(每天或者月平均等)的MODIS数据,我们怎么能够对比同一区域不同时间的数据情况,采用 ...
- HTML5学习笔记(二十三):DOM应用之动态加载脚本
同步加载和执行JS的情况 在HTML页面的</body>表情之前添加的所有<script>标签,无论是直接嵌入JS代码还是引入外部js代码都是同步执行的,这里的同步执行指的是在 ...
- Cesium中Clock控件及时间序列瓦片动态加载
前言 前面已经写了两篇博客介绍Cesium,一篇整体上简单介绍了Cesium如何上手,还有一篇介绍了如何将Cesium与分布式地理信息处理框架Geotrellis相结合.Cesium的强大之处也在于其 ...
- js动态加载css和js
之前写了一个工具类点此链接里面含有这段代码,感觉用处挺多,特意提出来 var loadUtil = { /* * 方法说明:[动态加载js文件css文件] * 使用方法:loadUtil.loadjs ...
- Ext JS 如何动态加载JavaScript创建窗体
JavaScript不需要编译即可运行,这让JavaScript构建的应用程序可以变得很灵活.我们可以根据需要动态从服务器加载JavaScript脚本来创建和控制UI来与用户交互.下面结合Ext JS ...
- Ext动态加载Toolbar
在使用Ext的GridPanel时候,有时候需要面板不用重新加载而去更新Store或者Toolbar,Store的方法有很多,例如官方api给我们提供的Store.load(),Store.reLoa ...
- Android动态加载框架汇总
几种动态加载的比较 1.Tinker 用途:热修复 GitHub地址:https://github.com/Tencent/tinker/ 使用:http://www.jianshu.com/p/f6 ...
- 为不同分辨率单独做样式文件,在页面头部用js判断分辨率后动态加载定义好的样式文件
为不同分辨率单独做样式文件,在页面头部用js判断分辨率后动态加载定义好的样式文件.样式文件命名格式如:forms[_屏幕宽度].css,样式文件中只需重新定义文本框和下拉框的宽度即可. 在包含的头文件 ...
- html中的图像动态加载问题
首先要说明下文档加载完成是什么概念 一个页面http请求访问时,浏览器会将它的html文件内容请求到本地解析,从窗口打开时开始解析这个document,页面初始的html结构和里面的文字等内容加载完成 ...
随机推荐
- 利用Azure虚拟机安装Dynamics 365 Customer Engagement之一:准备工作
我是微软Dynamics 365 & Power Platform方面的工程师罗勇,也是2015年7月到2018年6月连续三年Dynamics CRM/Business Solutions方面 ...
- ABP入门教程10 - 展示层实现增删改查-控制器
点这里进入ABP入门教程目录 创建控制器 在展示层(即JD.CRS.Web.Mvc)的Controllers下新建一个控制器CourseController.cs using Abp.Applicat ...
- 数据结构笔记2(c++)_跨函数使用内存的问题
预备知识 1.所有的指针变量只占4个子节 用第一个字节的地址表示整个变量的地址 //1.cpp 所有的指针变量只占4个子节 用第一个字节的地址表示整个变量的地址 # include <stdi ...
- CUDA 编程相关;tensorflow GPU 编程;关键知识点记录;CUDA 编译过程;NVCC
本文章主要是记录,cuda 编程过程中遇到的相关概念,名字解释和问题:主要是是用来备忘: cuda PTX :并行线程执行(Parallel Thread eXecution,PTX)代码是编译后的G ...
- Mybatis的动态sql以及分页
mybatis动态sql If.trim.foreach <select id="selectBooksIn" resultType="com.jt.model.B ...
- python的imread、newaxis
一:imread 用来读取图片,返回一个numpy.ndarray类型的多维数组,具有两个参数: 参数1 filename, 读取的图片文件名,可以使用相对路径或者绝对路径,但必须带完整的文件扩展名( ...
- layUI学习第三日:layUI模块化开发
layui 定义为「经典模块化」,具备早前 AMD 的影子,又并非受限于 CommonJS 的那些条条框框, BootStrap 的不同在于:layui 糅合了自身对经典模块化的理解. 除了 layu ...
- Paper | Non-local Neural Networks
目录 1. 动机 2. 相关工作 3. Non-local神经网络 3.1 Formulation 3.2 具体实现形式 3.3 Non-local块 4. 视频分类模型 4.1 2D ConvNet ...
- Python连载53-UDP、TCP、FTP编程实例
一.服务器程序要求永远运行,一般用死循环来处理 1.服务器改造版本V03(主程序 原封不动,这里只修改了运行的程序) if __name__ == "__main__": whil ...
- 从零开始的微信小程序入门教程(一)
之前说要和同事一起开发个微信小程序项目,现在也在界面设计,功能定位等需求上开始实施了.所以在还未正式写项目前,打算在空闲时间学习下小程序.本意是在学习过程中结合实践整理出一个较为入门且不是很厚的教程, ...