oh!!!想起来了,刚好可以写一篇关于深度使用 micropython 的文章!会有一些额外的知识讲解哒。
起因
这次的起因是需要一个 C 与 Python 层面共用的配置模块,就是想在 micropython 中准备一个配置项的功能,刚好也讨论到了不妨直接使用 ujson 的模块功能。(ujson 就是 json),然后这个模块的 Python 代码通常是这样的。
#!/usr/bin/python3
import json
# Python 字典类型转换为 JSON 对象
data1 = {
'no' : 1,
'name' : 'Runoob',
'url' : 'http://www.runoob.com'
}
json_str = json.dumps(data1)
print ("Python 原始数据:", repr(data1))
print ("JSON 对象:", json_str)
# 将 JSON 对象转换为 Python 字典
data2 = json.loads(json_str)
print ("data2['name']: ", data2['name'])
print ("data2['url']: ", data2['url'])
这个代码,在 MicroPython 中本来就实现了,也同样可以直接使用。
但有个问题,我要如何在 C 语言的层面调用它呢?我 Python 代码的层面和 C 代码的层面,它们彼此间是如何共通的呢?
这个就涉及到对 Python 解释器的理解了,刚好上次介绍了 MicroPython 后,说好要给源码分析的一直没给,那趁着这次机会,稍微提及提及。
理解 MicroPython 的 bytecode (bc) 结构
事实上,很多人都理不清 Python 的解释执行的机制,但实际上我们只需要理清几个节点就可以帮助我们构造整个 MicroPython 的架构了。
说点 micropython 的接口调用关系来说明内部的调用关系,但事实上在调用各类模块的过程中是动态加载到内存的,并非直接的函数调用过程,而是动态的从 globals dict 中提取模块的指针和参数进行执行,这个部分就不能在静态分析中表达出来了。
(以后把图放上来,现在生成的图基本没法看,诺,就跟下面这个一样)
我还是简单画个图说明一下吧。
这是第一层,当我们使用 execute_from_lexer 进行执行的载体可能有 file repl str 这三类输入源,mp_parse_compile_execute 进行 Python 代码的解释(Python)、编译(bytecode)、执行(bytecode)。
其中,我们平时所认知的层次只在解释这一层,这次我就多说一点了。
在代码中是这样的关系。
基本上你看到这里已经算是知道一点基础的内容了,现在我们直接开始看代码。
MicroPython 中的 C 函数关系
自上次介绍添加 C 层面的看门狗模块,我就没有再提及和 MicroPython 核心有关的内容,这里不讲解 QSTR 的编译生成,我假设你已经具备一些基本的 MicroPython 基础。
我们要知道 Python 层面可以实现的功能,在 C 层面一定也可以实现,它实际上就是一堆结构体的传递和对象的类型的执行,为此我直接代码举例说明吧。
对 import ujson 调用实例
我们知道 json 是 Python 的一个内置模块,其定义如下。
而它被添加到 mp_builtin_module_table 这个静态定义的内置模块表上,方便 load 函数的查找和加载。
我们知道这是在编译器决定的操作,同时因为有这种动态表,所以代码中不会存在直接调用的方法去对其执行,所以我们要在 C 层面执行如下 Python 代码操作。
import josn
nresult = josn.loads({"a":1,"b":2,"c":3,"d":4,"e":"helloworld"})
print(result)
为了调用该 Json 模块的 C 函数功能,我们可以拆分成如下三个操作。
- call
import josn
获取 json 模块,得到该对象的结构,对该对象结构调用其成员函数 josn.loads ,那么如何获取 json 模块呢?
需要注意的是,如果存在头文件的接口,那我们可以直接调用目标 C 函数即可完成对 Json 功能模块的调用,但事实上并没有这样的头文件接口引出,要修改源码才能暴露接口出来,假设在确保代码最小改动的情况下,我们可以走 micropython 核心的接口去获取我们想要的函数指针了,看如下代码。
mp_obj_t module_obj = (mp_obj_t)mp_module_get(MP_QSTR_ujson);
if (module_obj != MP_OBJ_NULL) {
// import josn
}
通过 mp_module_get 查询表中的 MP_QSTR_ujson 模块,这样就可以得到这个 json 模块的变量。
- call
josn.loads
得到了 json 模块后,我们就要进一步判断该模块是否有我们想要的函数 josn.loads ,有如下代码。
mp_obj_t dest[3];
mp_load_method_maybe(module_obj, MP_QSTR_loads, dest);
if (dest[0] != MP_OBJ_NULL) {
// get josn.loads
}
假设存在,则从这里我们可以获取到 json 模块的 loads 函数(method),同时还在 dest 变量中拥有了 loads 的函数指针,以及可以填充的形参列表。
接着我们构造函数实参如 {"a":1,"b":2,"c":3,"d":4,"e":"helloworld"}
通过 mp_call_method_n_kw 执行对应函数功能,接收其返回值到 result 中,代码则如下。
const char json[] = "{"a":1,"b":2,"c":3,"d":4,"e":"helloworld"}";
dest[2] = mp_obj_new_str(json, sizeof(json) - 1);
mp_obj_t result = mp_call_method_n_kw(1, 0, dest);
实际上在这里我们需要知道的就是填入参数的方式,假设调用的是 json.loads() 是需要一个 str 的对象传入的函数,则它对应的调用方法为 mp_call_method_n_kw(1, 0, dest),事实上可以可以直接用内部的接口,但这个接口是可以传递任意参数的函数,所以使用它就足够了。
其函数原型如下。
// args contains: fun self/NULL arg(0) ... arg(n_args-2) arg(n_args-1) kw_key(0) kw_val(0) ... kw_key(n_kw-1) kw_val(n_kw-1)
// if n_args==0 and n_kw==0 then there are only fun and self/NULL
mp_obj_t mp_call_method_n_kw(size_t n_args, size_t n_kw, const mp_obj_t *args) {
DEBUG_OP_printf("call method (fun=%p, self=%p, n_args=" UINT_FMT ", n_kw=" UINT_FMT ", args=%p)
", args[0], args[1], n_args, n_kw, args);
int adjust = (args[1] == MP_OBJ_NULL) ? 0 : 1;
return mp_call_function_n_kw(args[0], n_args + adjust, n_kw, args + 2 - adjust);
}
具体你想如何去调用函数,就需要你自己去查阅 runtime.h 中提供的接口了,事实上这里还要分清 function 和 method 的关系。
通常我们说的 function 指的是独立的函数或方法,而 method 虽然也翻译成同样的意思,但它是特指有所属模块(module)或对象(self)的,这也就造就了在调用和理解的时候存在不同的意义。
- print
dict = josn.loads()
调用 mp_obj_print_helper 打印 mp_obj 对象内容,代码只需要 mp_obj_print_helper(&mp_plat_print, result, PRINT_STR);
即可执行 print(result)
的 Python 代码,但这个方法会去判断和识别该对象的字符串化的方法,从而调用对应的 print 回调函数去打印内部的内容出来。
至此,以后你想要对其他功能模块进行操作,是不是就知道怎么做了?
当然,你也可以像我前面所说的,修改头文件暴露 C 的接口函数 直接调用,而非我这样的动态查找调用,前提是代码足够的解耦,毕竟解释器的输入接口就是这样设计的。
对 nlr (no local return)的使用
在前面的代码示例中有对代码进行一个异常机制的保护,而事实上 json 模块是会抛出 mp_raise_ValueError("syntax error in JSON") 异常的,表示字符串解析成 json 失败。
如果我们期望代码是写成下面这样,以提高代码的对异常的抵抗性。
import josn
try:
nresult = josn.loads({"a":1,"b":2,"c":3,"d":4,"e":"helloworld"})
print(result)
except Exception as e:
print(e)
那我们就要在执行的代码之前载入 try 的地址,以便发生异常的时候转移到 except 语句块当中,则 C 代码如下。
if (nlr_push(&nlr) == 0) {
mp_obj_t result = mp_call_method_n_kw(1, 0, dest);
mp_printf(&mp_plat_print, "print(result)
");
mp_obj_print_helper(&mp_plat_print, result, PRINT_STR);
mp_printf(&mp_plat_print, "
");
nlr_pop();
}
else {
mp_obj_print_exception(&mp_plat_print, (mp_obj_t)nlr.ret_val);
}
当它在 if (nlr_push(&nlr) == 0) {} (try)语句块中触发异常的时候 通过 nlr_raise(nlr_jump) 直接跳转到 esle {} 语句块当中,也就是所谓的不在此处返回(no-local-return),从而用户在此处进行 except 的后续处理。
但我把这个过程说的详细一点就是,这里 nlr 是借用了 setjump 和 longjump 的思想,在 nlr_push 的时候将当前的语句位置存储下来,然后通过 setjmp 记录地址,并默认返回结果为 0 ,进入保护范围({),如果期间出现了 nlr_raise(nlr_jump) 则回到 setjmp 处返回 1 ,从而回到进入前的入口,并离开现场,此时就可以实现其他操作,值得注意的是 nlr_pop 表示异常捕获边界结束(})。
关于这个的 nlr 设计思想,中文的资料讲得不是很好,我在 wiki 找个标准 C 实现的说明给放这了 http://web.eecs.utk.edu/~mbeck/classes/cs560/360/notes/Setjmp/lecture.html 。
最后我们只需要知道,我们把这个 nlr 的使用就看作是 C 层面的异常机制就可以了。
对 gc 回收内存的机制重建缓存
设计了这个存取 json 配置的模块以后,为了更好的结合到 micropython 环境当中,我就把存储的结果 dict 对象的节点(mp_obj_t)保存起来了,但事实上这些节点(mp_obj_t)是有可能因为 Python 层面上没有做出标记而被回收,因为是直接从 C 层面产生的。
在 MicroPython 这种设计为具备回收内存的语言当中,大多数时候的对象都存储着对内存的标记,这个在 C# CLR 中也是一样的设计,所以我们应当在使用前,判断当前的 mp_obj_t cache; 对象是否还可以继续使用。
就像下面的代码这样。
if (false == mp_obj_is_type(config_obj->cache, &mp_type_dict)) {
// maybe gc.collect()
if (mp_const_false == maix_config_cache()) {
return def_value;
}
}
如果是内置的结构则可以使用类似于 mp_obj_is_type 这样的接口,而我所用的是用来处理非 micropytho 核心的结构判断。
这样的好处就是,假设不用了就放心的回收,也不用担心数据是否还占用着内存,反正用的时候发现没有了就重建。
同样的,你也不必害怕,这个变量到底还能不能用了,如果出现了什么 core dump 的情况,多半要仔细检查检查有没有可能被回收了。
对 dict 对象(map)的操作
由于 micropython 没有将这个 dict 对象相关的功能函数暴露出来,导致在遍历接口的时候,只能跳过 Py 核心层的接口,直接将目标对象当作 map 对象执行 C 的 mp_map_lookup 操作。
如果想要获取 dict 中 key 为 your_find_key 的对象,就如下 C 代码操作。
const char goal[] = "your_find_key";
mp_obj_dict_t *self = MP_OBJ_TO_PTR(result);
mp_map_elem_t *elem = mp_map_lookup(&self->map, mp_obj_new_str(goal, sizeof(goal) - 1), MP_MAP_LOOKUP);
mp_obj_t value;
if (elem == NULL || elem->value == MP_OBJ_NULL) {
// not exist
}
else {
value = elem->value;
//mp_check_self(mp_obj_is_str_type(value));
mp_printf(&mp_plat_print, "print(result.get('%s'))
", goal);
mp_obj_print_helper(&mp_plat_print, value, PRINT_STR);
mp_printf(&mp_plat_print, "
");
}
如果想要遍历 mp_obj_dict_t 对象,则使用如下原型函数进行迭代器遍历操作。
mp_map_elem_t *dict_iter_next(mp_obj_dict_t *dict, size_t *cur) {
size_t max = dict->map.alloc;
mp_map_t *map = &dict->map;
for (size_t i = *cur; i < max; i++) {
if (mp_map_slot_is_filled(map, i)) {
*cur = i + 1;
return &(map->table[i]);
}
}
return NULL;
}
mp_obj_dict_t *self = MP_OBJ_TO_PTR(tmp);
size_t cur = 0;
mp_map_elem_t *next = NULL;
bool first = true;
while ((next = dict_iter_next(self, &cur)) != NULL) {
if (!first) {
mp_print_str(&mp_plat_print, ", ");
}
first = false;
mp_obj_print_helper(&mp_plat_print, next->key, PRINT_STR);
mp_print_str(&mp_plat_print, ": ");
mp_obj_print_helper(&mp_plat_print, next->value, PRINT_STR);
}
这样操作实际上就直接跳过了对 DICT 对象的函数操作,也不需要像前面 json 模块那样去请求一个模块和调用模块函数,直接操作指针就行,但那样代码会呈现冗余,看个人的选择吧。
对接 K210 的特定功能函数
这是由于我们在编写 MicroPython 函数的时候,实际芯片的移植可能会和我们所期待的接口有所差异导致的,运气好的是,我需要的 String 对象可以通过 fs_info_t *cfg = vfs_internal_open("/flash/config.json", "rb", &err);
得到。
这个 json.load 函数是允许用户传递一个 StringIO 的对象(mp_obj_stringio_t)进来直接代理执行 read 操作的 C 函数,StringIO 函数通常会提供 read 操作,就像下面这样。
只要我们能够提供一个同类对象进去即可,而 open 得到的 file 对象就是一个底层具备 StringIO 基础协议的对象(_mp_stream_p_t)。
所以我们可以直接将读取的文件对象直接导入 json.load 函数当作,类似于如下 Python 代码。
# 写入 JSON 数据
with open('data.json', 'w') as f:
json.dump(data, f)
# 读取数据
with open('data.json', 'r') as f:
data = json.load(f)
在 k210 实现的特殊 file 读写函数如下
这时将与 json 模块当作的操作对象保持一致
即可实现上述 Python 代码同样效果的 C 代码。
后记
看完后是不是更理解了 MicroPython 了呢?是不是没有想象的那么难呢?
这里提及的完整代码已经合并入 MaixPy 仓库,以后有兴趣的可以自己去了解。
这篇文章以后会单独拆分的,因为最后写完,发现信息量过大,而且一些不同类型的内容,也不应该出现在一篇文章当中。
留个痕迹 junhuanchen@qq.com 2020年6月10日。