在 ESP-IDF 环境下,使用标准 C 扩展 Micropython 模块

发布时间:2024年01月10日

在 ESP-IDF 环境下,使用标准 C 扩展 Micropython 模块

源码地址 : https://gitee.com/Mars.CN/micropython_extend_example

一、 安装 ESP-IDF 环境

在其他课程中讲过,这里不再赘述,有机会再出教程吧,但需要注意的是,截止到 2024年1月初,最稳定的 micropython 开发环境是 ESP-IDF_4.4.6,最新的 5.x 对 ESP32-S3 不是很友好,反正我是没有搞成功过,在 git 上提问也没有能得到满意的回答,建议大家还是用 idf 4.x + micropython 1.19.1开发,本教程也是围绕这两个版本讲解的。

本教程都是在 Ubuntu 上开发的,Window 搭建的 ESP-IDF 开发环境开发普通的程序还可以,但开发 micropython 的环境始终没有建好过,编译各种不通过。本人才疏学浅,希望有能力的大佬补充如何在 Window 开发 micropython。

二、 下载 micropython 源码

micropython 的源码是从 github 上直接克隆下来的,目前最新的代码是 1.23,但这个版本我下载试过了,对 ESP32-S3 不是很友好,各种编译失败,我试验最稳定的版本是 1.19.1,但这个版本需要配合 ESP-IDF 4.x 开发,1.20 以后的版本可以用 ESP-IDF 5.x,ESP32 可以编译通过, S3 各种报错,所以不建议使用。

克隆 1.19.1 可以使用下面的命令:

git clone -b v1.19.1 https://github.com/micropython/micropython.git

按照官方文档的要求,第一次使用的时候先要对 micropython 进行核心的交叉编译:

$ cd mpy-cross
$ make

第二步需要解决的是单片机相关的子模块依赖。

在 micropython/ports 文件夹中,就是所有支持的单片机和开发板了,我们用到的是 esp32 这个文件夹,不要对其进行直接修改,需要把 esp32 复制一份,我们起名为 moopi (这个名字大家随心,随便起啥都行,不要用中文),今后所有的教程我们都从这里开始。

进入到这个文件夹,并下载所有子模块的依赖:

cd ports
cp -r esp32 moopi
cd moopi
make submodules

make submodules 不是每次都需要运行,针对一个开发板,运行一次即可,后面在对 esp32 进行复制,就不必要重复执行了。

准备完毕后,用 VSCode 打开项目目录,然后 Ctrl+Shift+P,或者单击菜单的 查看 -> 命令面板 选项打开命令面板,在其中输入 ESP-IDF: Add vscode configuration folder ,这样会在项目目录中增加一个 .vscode 的文件夹,项目中用到的 ESP-IDF 的开发环境及头文件都会配置好,这样项目中就不会出现头文件红线的问题了。

接下来需要修改一下 micropython 头文件的路径问题,打开 .vscode/c_cpp_properties.json 文件,在 includePaht 项中增加以下内容:

"${workspaceFolder}/../../"

此时大部分 micropython 的头文件都不会飘红了(但仍然有一小部分需要解决)。

三、 编译原始 micropython 代码

再开始编译代码之前,必须要对 menuconfig 进行修改。

首先修改编译目标为 esp32-s3 ,可以在 VSCode 中单击 ESP-IDF Set Espressif device target,选择 esp32s3,然后再弹出的下拉选项中选择 ESP32-S3 chip (via ESP USB Bridge),或者使用命令行:idf.py set-target esp32s3 进行配置。

然后单击打开 ESP-IDF 的配置项,单击 ESP-IDF SDK Configuration Editor (menuconfig),稍等片刻会打开 menuconfig 配置项,或者使用命令行 idf.py menuconfig 打开 menuconfg 进行配置

根据官方的 ESP32-S3 开发板,需要配置如下项:

  1. 选中 Serial flasher config -> Enable Octal Flash 选项,因为在官方的 ESP32-S3 开发板上,用的是 8 线 Flash ,如果不启用这个选项,启动的时候会出问题;
  2. Serial flasher config -> Flash Sampling Mode 设置为 DTR Mode,这是设置 Flash 的取样模式,DTR 比 STR 模式要块一倍,我理解是 STR 模式下, SPI-Flash 只会在时钟的下降沿或上升沿做数据取样,而如果使用了 DTR,则会在上升沿和下降沿各取样一次,所以速度会比原来快一倍;
  3. Serial flasher config -> Flash Spi speed 设置为 80MHz,之前我们自己做过一个产品,用的和官方 Flash 是同一个型号的,但是只支持 40MHz,这个取决于具体用的 Flash 型号是什么,官方开发板设置 80MHz 完全没问题;
  4. Serial flasher config -> Flash size 设置为 32M,我用的开发板是 N32R8V 这个型号的,板载的是 32M Flash
  5. 修改分区表,micropython 官方代码中给的分区表最大是 16M 的 Flash 支持,这里可以选择不修改,或者修成官方的 16M ,或者修改成我下面给出的 32M 的都可以,不太影响我们本次可成讲到的东西,如果需要配置自定义的分区表,则需要修改 Partition Table -> Partition Table 选择 Custom partition table CVS即可,此时会多出 Custom partition CSV file,这里我们可以选择文件夹下任意一个分区表配置文件(.cvs文件)即可,记得名称要写对,否则编译会报错,这里我选择的是我自己写的 32M 分区表 partitions-32MiB.csv;
  6. 添加扩展 PSRAM,ESP32-S3 内置的 SRAM 大小是 512K,虽然已经不小了,但是对于我们上位机程序员来说,这就是个渣渣,而 N32R8V 开发板已经很贴心的给我们内置了 8M 的 PSRAM,但默认情况下是没有挂载的,需要我们通过 menuconfig 挂载,只需要选中 Component config -> ESP32S3-Specific -> Support for external, SPI-connected RAM 即可打开;
    1. 选中后再本级菜单中会增加 SPI RAM config 选项;
    2. 进入选项 将 Mode (QUAD/OCT) of SPI RAM chip in use 选项选择为 Octal Mode PSRAM,因为在这块板子中,用的扩展 PSRAM 也是八线的。
    3. 关于 Cache fetch instructions from SPI RAM 和 Cache load read only data from SPI RAM 是否需要选中视情况而定,如果我们在代码运行过程中有可能操作 Flash 的代码区域或者数据区域,这两块最好选中,大概的意思是命令或数据的缓存预处理一类的;
    4. Set RAM clock speed 设置为 80MHz ,40MHz 也能用,就是慢,无他

以上都设置完毕后,保存退出,最后一步,需要修改一下 ESP32-S3 的外设配置,这个芯片哪哪都好,就是缺少 DAC ,所以在编译的时候,需要把 DAC 外设关闭。
打开 mpconfigport.h 文件,大概在 103 行左右的地方,有个 MICROPY_PY_MACHINE_DAC 的配置,默认值是 1 ,改为 0 即可。

最后单击 ESP-IDF Build project 按钮,或者在命令行中执行 idf.py build 即可编译整个工程,理论上是不会出现任何错误的,如果有,则看看前面的配置是否正确。

编译完成后,单击 ESP-IDF Build,Flash and Monitor 下载查看工程,或者执行命令:

idf.py flash
idf.py monitor

此时已经可以正常进入 micropython 的 REPL 环境了。

此时如果出现

The filesystem appears to be corrupted. If you had important data there, you
may want to make a flash snapshot to try to recover it. Otherwise, perform
factory reprogramming of MicroPython firmware (completely erase flash, followed
by firmware programming).

的错误,则有可能是 Flash 的 FAT 分区出现了问题,只需要把 Flash 擦除一下在烧录就行了,Flash 擦除命令是idf.py erase_flash,等一两分钟就能擦除完毕了,然后再重新烧录一次即可。

我们可以尝试以一下输入 help("modules"),即可看到已经加载的 python 模块

_boot             gc                ubinascii         urandomrm
_onewire          inisetup          ubluetooth        ure
_thread           math              ucollections      uselect
_uasyncio         micropython       ucryptolib        usocket
_webrepl          neopixel          uctypes           ussl
apa106            network           uerrno            ustruct
btree             ntptime           uhashlib          usys
builtins          onewire           uheapq            utime
cmath             uarray            uio               utimeq
dht               uasyncio/__init__ ujson             uwebsocket
ds18x20           uasyncio/core     umachine          uzlib
esp               uasyncio/event    uos               webrepl
esp32             uasyncio/funcs    upip              webrepl_setup
flashbdev         uasyncio/lock     upip_utarfile     websocket_helper
Plus any modules on the filesystem

后面的操作中,我们最后使用 Thonny 软件,这个天然支持 ESP32 开发板,非常好用,我们自己的 IDE 也在开发中,功能设计是在 Thonny 基础上增加了更多支持 ESP32 和我们自己开发平台的功能。

四、 添加自定义的 micropython 模块

我们本次课程的目的是教会大家如何在 micropython 环境中扩展自定义的模块,所以具体 micropython 如何使用,我们会放到其他课程中展示,这里就不多说了。

如果我们是做平台开发的,或者说我们的产品需要给另外一些同行做二次开发的,我们就需要把我们自己的一些功能封装起来,提供给第三方使用。最长见的方法是就是写 python 脚本,提供给客户 .pyc 的二进制文件,或者 .py 的源码文件。但对于一些保密性要求高的,或者说直接操作特有硬件的,或者是要求执行效率的代码,使用 python 写就不是那么舒服了,所以我们就需要使用扩展 micropython 类库的方式来做,这也是 python 高级编程的一部分。简单来说,就是使用 C/C++ 扩展 python 类库。

在 micropython 环境中,一切都会被封装到 模块中,也就是 module,module 又包含了方法和类,以及常量,类中又可以包含方法、属性和常量等等。

本小节,我们就从创建一个 module 开始,能够通过在 REPL 环境中执行 help("modules") 看到我们的模块。

我们知道,在python 中写一个 .py 的文件就是一个 module ,就可以使用 import 导入,但在 C 中开发,相对来说要复杂一些,好处就是可以提高执行效率。

创建模块一共分五步:

  1. 定义模块的全局字典 gloabls table;
  2. 将全局字典转换为 micropython 对象;
  3. 定义模块原型;
  4. 注册模块;
  5. 将模块添加到编译的配置文件中。

在使用 C 扩展 micropython 之前,需要先引入几个头文件

#include "py/builtin.h"
#include "py/runtime.h"
#include "py/obj.h"
#include "py/binary.h"

这些都是扩展 micropython 必要的。

在项目根目录新建一个 moopi_mod 文件夹(名字随意,不要用中文),用于存放我们接下来的课程代码。

在这个文件夹下新建一个文件,我的名字叫 modmoopi.c

4.1 定义全局字典

每个模块或者类都应该有一个全局字典,这个字典中定义了模块或者类中的所有成员,包括方法、常量、子类等,在扩展 micropython 中,用于全局自定用以下代码:

STATIC const mp_rom_map_elem_t moopi_globals_table[] = {
    {MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_moopi)},
};

这行代码中需要修改的是 moopi 的部分,这是我模块的名字,你们自己的可以自行定义。

这个字典是一个 mp_rom_map_elem_t 类型的数组,每个成员有两个元素,分别是成员的名称和对应的对象。其中类似 MP_ROM_QSTR 这些宏定义其实是 micropython 内部做对象转换用的,我们不必深究是什么意思,拿过来用就行了,有机会我们再拆开讲。

在写字典的时候必须注意以下几点:

  1. 字典中每个元素必须用 {} 包围,而且每个元素中包含 key 和 value 两个元素;
  2. 每个元素的 key 值必须是 QSTR 类型的,也就是必须通过 MP_ROM_QSTR 对其进行转译;
  3. key 的值必须使用 MP_QSTR_前缀加key的真实名称,MP_ROM_QSTR 宏会自动加入到注册表中(这个比扩展 python 省事的多,免去了我们计算字符串Hash的步骤);
  4. 对象的值必须是通过转译的 micropython 对象,也就是通过 MP_ROM_XXX 转译的对象
  5. 字典第一行必须是一个字符串类型的对象名,key 的值必须是 __name__,这是 python中规定的,也就是第一个成员的 key 必须是 MP_ROM_QSTR(MP_QSTR___name__),name 前面是三个下划线,别写错了,value 的值是 QSTR 类型的字符串,这里可以直接写,不要过多考虑注册表的问题,会自动完成注册。

如果我们想改变模块的名称,只需要改变成员第一行中 value 的 MP_QSTR_ 后面的内容即可,这个值其实并不是 import 指令导入的名称,而是模块的实际名称,import 导入名称将在第四步中介绍,但这个值是区分大小写的。

4.2 将全局字典转换为 micropython 对象

这一步比较简单,只需要调用 micropython 开发环境事先定义好的宏即可 MP_DEFINE_CONST_DICT

STATIC MP_DEFINE_CONST_DICT(moopi_globals, moopi_globals_table);

这个宏会传入两个参数,第一个是转后的 micropython 对象指针,这不变量不需要我们自己定义,环境会帮我们定义好,第二个参数是上面刚刚定义的对象成员列表0这一步的转换是为下一步定义对象原型做准备。

4.3/4.4 定义模块原型并注册

上面两步我们完成了模块成员的定义,下面完成对象原型的定义,代码如下:

const mp_obj_module_t mp_mod_moopi = {
    .base = {&mp_type_module},
    .globals = (mp_obj_dict_t *)&moopi_globals,
};
MP_REGISTER_MODULE(MP_QSTR_moopi, mp_mod_moopi);

第一部分,首先定义一个 mp_obj_module_t 类型的对象,该对象只有两个成员,第一个是 .base 这个在后面的代码中我们会多次遇到,每个 micropython 对象的第一个成员总是他,第二 .globals 个是模块的成员字典表,这个表就是我们上面所定义的数组,可以在 micropython 环境中通过 dir(moopi) 查看到。

模块原型定义完成后,通过 micropython 开发环境提供的 MP_REGISTER_MODULE 宏将模块注册到列表中,该宏有两个参数,第一个是模块的导入名称,也就是通过 import 指令导入时候的名称,区分大小写,第二个就是上面创建的原型。

4.5 将模块添加到编译的配置文件中

此时我们的代码已经写完,但无论这个文件的代码中是否有错误,编辑器都不会报错,因为我们写的这个代码压根就没有参与编译,如果需要自己的代码起作用,还必须修改 CMakeLists.txt 文件,确切的说是要修改 man/CMakeLists.txt 文件,把我们的代码加入进去。

4.5.1 加入源文件列表

我们这里的源文件可能会分为两部分,分别是普通 c 代码,和参与 micropython 扩展的代码,所以在添加源文件的时候最好是能够将两部分代码分开。

在源文件大概 50 行左右(位置无所谓,只要是在 set(MICROPY_SOURCE_PORT 之前均可)添加我们自己的代码集合,可以使用以下两种方式任意添加

暴力方式

set(MOOPI_DIR ../moopi_mod)
file(GLOB_RECURSE MOOPI_MOD_SRCS ${MOOPI_DIR}/*.c)

这种方式我们会添加这个文件夹里的所有 .c 的文件,不便于区分,但如果这个文件夹内所有文件都属于这一组的,用这种方式添加最为省心。

细心方式

set(MOOPI_DIR ../moopi_mod)

set(MOOPI_MOD_SRCS
    ${MOOPI_DIR}/modmoopi.c
)

这种方式是按照单个文件添加的,在增加一个 .c 文件的时候需要记得修改这个变量,否则不会参与编译。

不论用那种方式,这两行代码都是一个意思,定义 MOOPI_DIR 变量,值是 …/moopi_mod 指向了 moopi_mod 文件夹;定义 MOOPI_MOD_SRCS 变量,内容是所有参与编译的源文件。

如果还有其他不参与 micropython 编译的源文件,建议再增加一个 MOOPI_SRCS 变量单独存放。

变量定义完毕后,需要在后面 MICROPY_SOURCE_PORT 变量定义的最后面,加上我们的源文件变量指向 ${MOOPI_MOD_SRCS}

MICROPY_SOURCE_PORT 存放的是参与 micropython 环境编译的代码,如果源文件中不存在与 micropython 扩展相关的代码,没必要放在这里,修改后如下:

set(MICROPY_SOURCE_PORT
    ${PROJECT_DIR}/main.c
    ${PROJECT_DIR}/uart.c
    ...
    ${PROJECT_DIR}/machine_rtc.c
    ${PROJECT_DIR}/machine_sdcard.c
    ${MOOPI_MOD_SRCS}
)

再往下,找到 idf_component_register 部分,这里才是真正注册编译代码的部分,不论是否参与 micropython 的编译,我们的源码变量必须放在这里,头文件变量也必须加在这里。

在 SRCS 目录下,加入 ${MOOPI_MOD_SRCS},在 INCLUDE_DIRS 目录下,加入 ${MOOPI_DIR},如下:

idf_component_register(
    SRCS
        ${MICROPY_SOURCE_PY}
        ${MICROPY_SOURCE_EXTMOD}
        ${MICROPY_SOURCE_SHARED}
        ${MICROPY_SOURCE_LIB}
        ${MICROPY_SOURCE_DRIVERS}
        ${MICROPY_SOURCE_PORT}
        ${MICROPY_SOURCE_BOARD}
        ${MOOPI_MOD_SRCS}
    INCLUDE_DIRS
        ${MICROPY_INC_CORE}
        ${MICROPY_INC_USERMOD}
        ${MICROPY_PORT_DIR}
        ${MICROPY_BOARD_DIR}
        ${CMAKE_BINARY_DIR}
        ${MOOPIDIR}
    REQUIRES
        ${IDF_COMPONENTS}
)

添加完毕后编译代码,无错误,烧录进开发板,进入 REPL 环境,输入 help("modules"),即可打印出我刚刚添加的 moopi 模块,通过 import moopi 指令可以正常导入模块,通过dir(moopi) 可以打印出这个模块的所有成员,目前只有默认的两个 ['__class__', '__name__']

执行 moopi.__name__ 即可看到模块的名字,这个名字就是在上面第一部中定义成员字典时填入的名称。
执行 moopi.__class__ 可以看到,这个对象的类型是 module。

到此为止,第一步,创建模块已经成功。

五、 为模块添加方法

在上面一节中,我们已经在 micropython 环境中完成了自定义模块的添加,但此时模块中空空如也,什么也没有,这一节我们就为其添加一个方法。

模块中可以包含 方法、常量、类这些东西,最基础的就是方法,方法又分为无参数、有参数、可变参数(重载)、有返回值、无返回值这些,接下来的部分,会对其进行一一介绍。

5.1 添加一个无参无返回值方法

在 micropython 方法原型中,不存在无返回值类型方法,所有方法的定义必须是一个包含有 mp_obj_t 类型返回值的,无参方法运行定义如下:

STATIC mp_obj_t func_name(){
    return mp_const_none;
}

如果在 micropython 环境不需要返回值,在 C 扩展的时候,返回值恒定为 mp_const_none;
再此,我们定一个 say_hello 的方法:

STATIC mp_obj_t moopi_say_hello(){
    printf("Hello Micropython !\n");
    return mp_const_none;
}

方法定义完成之后,还需要将方法转换为 micropython 对象才可以使用,这个转换可以通过 micropython 开发环境提供的宏进行:

MP_DEFINE_CONST_FUN_OBJ_0(moopi_say_hello_obj, moopi_say_hello);

这个宏有两个参数,第一个参数值被转换出来的 micropython 对象指针,第二个对象是要转换的方法指针,这行宏定义调用完之后,将会生成一个 moopi_say_hello_obj 的 micropython 对象,最后,将这个对象写入到上一节定义的成员字典中:

STATIC const mp_rom_map_elem_t moopi_globals_table[] = {
    {MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_moopi)},
    {MP_ROM_QSTR(MP_QSTR_sayHello), MP_ROM_PTR(&moopi_say_hello_obj)},
};

成员的 key 格式不变,仍然转换为 QSTR 类型的,在环境中使用的名称是 sayHello,而在转换 value 的时候,使用的是 MP_ROM_PTR,内容是对转换出来的 micropython 方法对象地址。

此时,第一个方法已经构建完成,编译烧录运行,进入 REPL 环境,执行:

import moopi
moopi.sayHello()

即可验证方法有效性。

5.2 带参数的方法

上一小节中,我们为模块创建了一个不带参数的方法,细心的小盆友可能已经注意到了,在调用 MP_DEFINE_CONST_FUN_OBJ_0 宏的时候,还有一堆相似的宏:

MP_DEFINE_CONST_FUN_OBJ_0
MP_DEFINE_CONST_FUN_OBJ_1
MP_DEFINE_CONST_FUN_OBJ_2
MP_DEFINE_CONST_FUN_OBJ_3
MP_DEFINE_CONST_FUN_OBJ_VAR
MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN
MP_DEFINE_CONST_FUN_OBJ_KW

这就为我们创建不同数量参数的方法提供了多种可能性:

  • MP_DEFINE_CONST_FUN_OBJ_0 : 创建一个没有参数的方法
  • MP_DEFINE_CONST_FUN_OBJ_1 : 创建一个带有1个参数的方法
  • MP_DEFINE_CONST_FUN_OBJ_2 : 创建一个带有2个参数的方法
  • MP_DEFINE_CONST_FUN_OBJ_3 : 创建一个带有3个参数的方法
  • MP_DEFINE_CONST_FUN_OBJ_VAR : 创建一个带有指定个最少参数的方法
  • MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN : 创建一个带有从 x 到 y 个参数的方法
  • MP_DEFINE_CONST_FUN_OBJ_KW : 创建一个使用字典传值的方法
5.2.1 MP_DEFINE_CONST_FUN_OBJ_1

在 micropython 的方法扩展开发中,所有传入的参数类型必须是 mp_obj_t 类型的,在 micropython 代码环境中,无论传入的是什么类型的数据,都将会被封装为 mp_obj_t 类型,进入函数后,可以通过一些方法从其中将真实数据转换出来。

方法转换类型
mp_obj_str_get_strconst char *
mp_obj_get_intint32
mp_obj_get_floatfloat

另外,还可以通过 mp_obj_get_type_str 和 mp_obj_get_type 查看是什么类型的,前者返回一个类型的字符串表达形式,后者则是返回一个 mp_obj_type_t 类型的对象用于内部比较用。

为代码增加一个 sayHi 的方法,传入一个字符串型参数,并打印输出

STATIC mp_obj_t moopi_say_hi(mp_obj_t name){
    const char *n = mp_obj_str_get_str(name);
    printf("Hi %s\n", n);
    return mp_const_none;
}
MP_DEFINE_CONST_FUN_OBJ_1(moopi_say_hi_obj, moopi_sya_hi);

(记得把方法添加到字典中)
调用这个方法的时候,使用 moopi.sayHi("Mars.CN") 即可在控制台进行输出,但如果传入一个非字符串值,则会报错。

import moopi
moopi.sayHi(1)
raceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can't convert 'int' object to str implicitly

所以,我最好在方法中对值的内容进行校验,当值类型不对的时候直接告诉用户要比报错有好得多。

STATIC mp_obj_t moopi_say_hi(mp_obj_t name){
    if(mp_obj_get_type(name) == &mp_type_str){
        const char *n = mp_obj_str_get_str(name);
        printf("Hi %s\n", n);
    }else{
        printf("Please enter a value of string type !\n");
    }
    return mp_const_none;
}
MP_DEFINE_CONST_FUN_OBJ_1(moopi_say_hi_obj, moopi_sya_hi);
5.2.2 MP_DEFINE_CONST_FUN_OBJ_2 以及返回值

知道一个参数方法如何定义了,接下来是 2 个参数或多个参数的,使用方法一样,只不过是在定义方法是MP_DEFINE_CONST_FUN_OBJ_2,参数表中多几个变量罢了。

而对于返回值,同样,进入的时候是 mp_obj_t 类型的,返回的时候也必须是 mp_obj_t 类型的,如果需要将 C 对象转换为 micropython 对象,则需要调用以下方法:

C数据类型转换函数
intmp_obj_new_int
boolmp_obj_new_bool
floatmp_obj_new_float
doublemp_obj_new_float
char *mp_obj_new_str

转换后可以直接返回但最好通过 micropython 开发环境提供的宏在进行一次封装转换 MP_OBJ_FROM_PTR, 这个宏的意思其实就是强制转换类型为 mp_obj_t 类型,没有其他别的意思,所以带不带都问题不大,带上之后在一些特殊场合不会报警告。

STATIC mp_obj_t moopi_add(mp_obj_t va, mp_obj_t vb){
    int32_t a = mp_obj_get_int(va);
    int32_t b = mp_obj_get_int(vb);
    int32_t c = a+b;
    return MP_OBJ_FROM_PTR(mp_obj_new_int(c));
}
MP_DEFINE_CONST_FUN_OBJ_2(moopi_add_obj, moopi_add);

(记得把方法添加到字典中)

5.2.3 MP_DEFINE_CONST_FUN_OBJ_VAR

在上面提供的转换宏中,只提供了 0~3 个参数的转换,对于 3 个以上参数的方法,不可能每一个都定义一个宏,那太啰嗦了,所以就有了 MP_DEFINE_CONST_FUN_OBJ_VAR ,对于3个以上方法的转换方式,而这个转换宏对应的函数定义格式也发生了改变:

STATIC mp_obj_t func_name(size_t n_args, const mp_obj_t *args);

这个函数原型中有两个参数,第一个参数表示了函数传入真实参数的数量,第二个参数表示参数的列表。
在使用 MP_DEFINE_CONST_FUN_OBJ_VAR 对函数进行对象化转换的时候,需要输入三个参数,第一个参数仍然是转换后的 micropython 对象变量名,第二个参数是这个函数要求最小传入的参数数量(最少0个参数),最后一个参数是方法的名称。

利用这一类的函数,可以创造出参数数量可变的函数,以下是定义的一个求和函数的举例:

STATIC mp_obj_t moopi_sum(size_t n_args, const mp_obj_t *args){
    int32_t sum = 0;
    for(int i=0;i<n_args;i++){
        sum += mp_obj_get_int(args[i]);
    }
    return MP_OBJ_FROM_PTR(mp_obj_new_int(sum));
}
MP_DEFINE_CONST_FUN_OBJ_VAR(moopi_sum_obj,0,moopi_sum);

(记得把方法添加到字典中)

5.2.4 MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN 和 异常

如有想写一个函数,这个函数要求最少输入 2 个参数,最多可接受 5 个参数,又应该如何写呢?

一种最直接的方式就是在函数中对 n_args 进行判断,因为这个参数说明了传入参数的数量,只要判断这个参数值是否小于等于 5 即可,最小传入 2 个参数在函数对象化转换的时候已经规定了,这里就用做过多判断了。

当参数数量在 2~5 之间的时候计算所有参数的平均值,但如果参数数量大于 5 时候将抛出一个异常,但这个有个问题, 之前函数中抛出异常用的都是 printf ,这个这样会破坏 micropython 的封装,即便抛出了异常,作为二次开发者的用户,也是无法捕获的,所以我们需要一种能够让二开人员捕获到的异常方式,就像是如果参数数量小于 2 时的那种抛异常式。

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: function missing 1 required positional arguments

这就要用到了 micropython 的通知机制,micropython 开发环境中,已经贴心的为我们提供了一组专门用于向外抛出异常的函数:

  • mp_raise_TypeError : 类型异常
  • mp_raise_ValueError : 值异常
  • mp_raise_OSError : 系统异常
  • mp_raise_NotImplementedError : 功能异常

抛出异常的时候需使用 MP_ERROR_TEXT 宏对字符串进行类型转换。

STATIC mp_obj_t moopi_average(size_t n_args, const mp_obj_t *args)
{
    
    if (n_args <= 5)
    {
        int32_t sum = 0;
        for (int i = 0; i < n_args; i++)
        {
            sum += mp_obj_get_int(args[i]);
        }
        float average = sum * 1.0f / n_args;
        return MP_OBJ_FROM_PTR(mp_obj_new_float(average));
    }
    else
    {
        mp_raise_TypeError(MP_ERROR_TEXT("function expected at most 5 arguments"));
    }
}
MP_DEFINE_CONST_FUN_OBJ_VAR(moopi_average_obj, 2, moopi_average);

(记得把方法添加到字典中)

另外一种方式就是通过mp_arg_check_num 函数对参数进行检测,当不满足要求的时候他会自动抛出错误,这个函数原型如下:

static inline void mp_arg_check_num(size_t n_args, size_t n_kw, size_t n_args_min, size_t n_args_max, bool takes_kw) 
参数含义
n_args实际传入参数数量
n_kw实际用字典传入的参数数量
n_args_min最小要求传入的参数数量
n_args_max最大要求传入的参数数量
takes_kw是否支持字典传值

最后一种方案,也是最为正规的方案,就是使用官方提供的 MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN 宏做函数类型转换,这个宏一共接收四个参数,依次是: 转换后的 micropython 对象变量名,最小接受参数数量,最大接受参数数量,函数名称。

STATIC mp_obj_t moopi_max(size_t n_args, const mp_obj_t *args)
{
    int32_t max = 0x80000000;
    for (int i = 0; i < n_args; i++)
    {
        int32_t num = mp_obj_get_int(args[i]);
        max = num>max?num:max;
    }
    return MP_OBJ_FROM_PTR(mp_obj_new_int(max));
}
MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(moopi_max_obj, 2, 5, moopi_max);

(记得把方法添加到字典中)

以上三种方式具体使用哪种方式,需要根据具体环境决定。

5.2.5 MP_DEFINE_CONST_FUN_OBJ_KW

python 开发中,除了可以通过按位传值之外,还可以按照字典传值,这也是 python 特有的传值方式,现在很多开发语言争相效仿。

moopi.achieve(name="Mars.CN",score=100);
Mars.CN's score is 100 .

对于此类函数,micropython 开发环境也已经设置好了注册方法。首先,函数原型是:

STATIC mp_obj_t func_name(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args)

参数依次是,传入参数的数量,按位传值的参数列表,按字典传值的参数列表
注册使用:

MP_DEFINE_CONST_FUN_OBJ_KW(obj_name, n_args_min, fun_name)

按参数传值相对于按位置传值的取值方式要复杂很多,但大概分为五步:

  1. 定于字典 key 的枚举
  2. 定义字典取值模板
  3. 声明字典数组
  4. 利用 mp_arg_parse_all 函数解析字典
  5. 从字典中取值

首先需要将可能按字典传入的 key 做一个枚举序列:

enum {ARG_name, ARG_score};

这里需要注意的是,如果函数在 python 环境中被调用是,如果参数列表中没有这个 key ,会抛出一个extra keyword arguments given 的异常。

第二步,构建字典取值模板,取值模板其实是一个 mp_arg_t 类型的数组,该结构体中共三个值:

  1. qst : 第一个值,表示字典中 key 的字符串表新形势(这个字符串是经过转码的, 具体怎么转的不需要关心)
  2. flags : 第二个值,值的类型,这里只支持 bool 和 int 另种基础数据类型,其他类型的都用 mp_obj_t 类型代替
  3. defval : 都三个值,默认值,是一个结构体,根据第二个参数(flags)的不同,设置不同的值类型。
static const mp_arg_t allowed_args[] = {
    { MP_QSTR_name, MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} },
    { MP_QSTR_score, MP_ARG_INT, {.u_int = 0} },
};

这里需要注意的是,字典模板中元素的排列方式要严格和第一步中枚举的顺序一致,否则有可能出错。

第一个 key 值前缀必须是 MP_QSTR_;第二个参数类型可以选择 MP_ARG_OBJ 、MP_ARG_INT、MP_ARG_BOOL,除了 bool 和 int 之外,都归为 OBJ 类型;第三个参数根据第二个参数不同,可以选择.u_int,.u_bool,.u_obj三个选项,另外还有个 .u_rom_obj 应该表示的是常来常量对象,比如方法等(具体没有研究过,可能理解有误)。

对于 int 和 bool 类型,可以直接只用 C 类型常量写,但如果是其他类型的,则需要使用类型转换方式获取,MP_OBJ_NULL 和 C 中的 NULL 不同,和 mp_const_none 也不同,他表示是一个空的 micropython 对象,mp_const_none 表示的是一个空值,NULL 表示的是空指针。

第三步,声明一个参数接收数组

mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)];

数组类型是 mp_arg_val_t 格式,使用 MP_ARRAY_SIZE 测量数组的大小。

最后,通过内置的 mp_arg_parse_all 函数将参数从列表中解析出来。

mp_arg_parse_all(n_args, pos_args, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args);
// 原型
void mp_arg_parse_all(size_t n_pos, const mp_obj_t *pos, mp_map_t *kws, size_t n_allowed, const mp_arg_t *allowed, mp_arg_val_t *out_vals);

这个函数传入的参数表示如下:

参数含义
n_pos位置参数的数量
pos指向位置参数的指针
kws指向关键字参数指针
n_allowed允许的参数的数量
allowed一个指向 mp_arg_t 结构体数组的指针,它描述了每个参数的类型和默认值
out_vals一个指向 mp_arg_val_t 结构体的指针数组,它将包含解析后的参数值

解析完毕之后,就可以通过之前定义的 args 从中取值了,但取值的时候需要注意,要根据值的类型获取 args 结构体不同的成员。

该部分的代码如下:

STATIC mp_obj_t moopi_achieve(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args){
    enum {ARG_name, ARG_score};
    static const mp_arg_t allowed_args[] = {
        { MP_QSTR_name, MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} },
        { MP_QSTR_score, MP_ARG_INT, {.u_int = 0} },
    };
    mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)];
    mp_arg_parse_all(n_args, pos_args, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args);

    char res[200];
    sprintf(res,"%s's score is %d .", args[ARG_name].u_obj==MP_OBJ_NULL?"":mp_obj_str_get_str(args[ARG_name].u_obj), args[ARG_score].u_int);
    return MP_OBJ_FROM_PTR(mp_obj_new_str(res,strlen(res)));
}
MP_DEFINE_CONST_FUN_OBJ_KW(moopi_achieve_obj, 0, moopi_achieve);

(记得把方法添加到字典中)

代码中,name 的默认值是空对象,在打印的过程中,如果对象为空,则输出空串,而对于数值类型的,则可以直接从对象的 .u_int 中获取值。
最后通过 MP_DEFINE_CONST_FUN_OBJ_KW 转换函数为 micropython 对象,第二个参数指的是按位置传值的最少参数个数。

5.3 函数的重载(Overload)

在面向对象开发的过程中,经常会遇到函数重载的操作,就是一个函数名称,根据参数类型不同所执行的操作不同,在标准 C 语言开发中是不支持函数重载的,但是重载却是 Python 语言的一大特性。所以在使用 C 扩展 micropython 的过程中,我们可以巧妙的利用函数传值的数量、类型等对函数实现重载。

比如有一个需求:有一个函数名称为 size,当直接调用这个参数的时候,返回对象的宽高,但其可以携带一个参数,如果携带一个参数的时候,则同时设置对象的宽高,如果携带两个参数时候分别设置对象的宽高。

分析可知:

  1. 参数名成为 size
  2. 可能携带 0~2 个参数,可变的
  3. 返回值为元祖类型

所以,我们设计函数的时候需要根据传入参数的数量进行判断,如果没有传入参数,什么都不做,如果 n_args ==1,同时设置对象的宽和高,如果 n_args ==2 这表示要分别设置对象的宽和高。
最后,不论是否传入参数,都返回一个元祖对象(Tuple),元祖中有两个数字值,表示对象的宽和高。

对于判断参数数量的方式,前面的代码中已经讲过,而之前函数中,我们返回的都是基础数据类型,对于 micropython 内置数据类型的返回是第一次遇到。

元组(Tuple)是一个不可变的序列,可以包含任意类型的数据,用圆括号 () 包围起来,简单来说,元祖其实就是一个不可变的数组。

在 C 扩展 micropython 的时候,可以通过 mp_obj_new_tuple 创建一个元祖,该函数有 2 个参数,第一个参数是元祖内数据的数量,第二个参数是一个 mp_obj_t 类型的数组,也就是或,即便是我们返回的是 int 类型数据,你也必须转换成 mp_obj_t 类型的数据,而基础数据类型的数据转换在之前的函数中已经讲到,这里不再重复。

static uint32_t width=0,height=0;
STATIC mp_obj_t moopi_size(size_t n_args, const mp_obj_t *args){
    if(n_args==1){
        width = height = mp_obj_get_int(args[0]);
    }else if(n_args==2){
        width = mp_obj_get_int(args[0]);
        height = mp_obj_get_int(args[1]);
    }
    mp_obj_t res[2] = {
        mp_obj_new_int(width),
        mp_obj_new_int(height),
    };
    return MP_OBJ_FROM_PTR(mp_obj_new_tuple(2,res));
}
MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(moopi_size_obj, 0, 2, moopi_size);

(记得把方法添加到字典中)

以上函数根据参数数量的不同执行不同的操作,在 python 环境中,我们可以使用以下代码实验函数重载:

import moopi
moopi.size()
(0,0)
moopi.size(100)
(100,100)
moopi.size(100,200)
(100,200)

注意,这个函数有可能会报错
在编译的时候,有可能会报一些关键字被占用的错误:

/home/mars/esp/micropython/ports/moopi/build/frozen_content.c:316:5: error: redeclaration of enumerator 'MP_QSTR_size'
MP_QSTR_size,

这是因为 size 这个关键字初次已经被 micropython 自己的一些函数注册过了,我们自定义的类或者模块中使用这些字段的时候会重复注册,所以报这个错。

解决方案分两种,一种是换一个关键字用,当然,这种方法我们肯定不愿意妥协,所以大多时候采用第二种方法。

使用 idf.py fullclean 清理整个项目,或者单击 ESP-IDF Full Clean 按钮清理,但清理项目后记得要重新配置 menuconfig 这也挺麻烦的。

还有中方法就是只删除 build 文件夹中的 frozen_content.c 文件,重新编译即可。

另外,还可以根据函数的类型不同,实现不同类型的重载。

下面函数中我们做一个设置或查询对象位置的函数 location ,这个函数除了可以接受向 size 一样的两种参数中之外,还可以接受通过元祖(Tuple)或者列表(List)的参数设置。

static int32_t x=0,y=0;
STATIC mp_obj_t moopi_location(size_t n_args, const mp_obj_t *args){
    if(n_args==1){
        const mp_obj_type_t *type = mp_obj_get_type(args[0]);
        if(type == &mp_type_int){
            x = y = mp_obj_get_int(args[0]);
        }else if(type == &mp_type_tuple){
            mp_obj_tuple_t *t = MP_OBJ_TO_PTR(args[0]);
            if(t->len==2){
                x = mp_obj_get_int(t->items[0]);
                y = mp_obj_get_int(t->items[1]);
            }
        }else if(type == &mp_type_list){
            mp_obj_list_t *t = MP_OBJ_TO_PTR(args[0]);
            if(t->len==2){
                x = mp_obj_get_int(t->items[0]);
                y = mp_obj_get_int(t->items[1]);
            }
        }
    }else if(n_args==2){
        x = mp_obj_get_int(args[0]);
        y = mp_obj_get_int(args[1]);
    }
    mp_obj_t res[2] = {
        mp_obj_new_int(x),
        mp_obj_new_int(y),
    };
    return MP_OBJ_FROM_PTR(mp_obj_new_tuple(2,res));
}
MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(moopi_location_obj, 0, 2, moopi_location);

(记得把方法添加到字典中)

上面参数中出现了一个新的方法 MP_OBJ_TO_PTRMP_OBJ_FROM_PTR 是对应关系,前者是将任意的 mp_obj_t 类型对象转化能成为 void * 类型的对象,后者是将任意指针对象转换为 mp_obj_t 类型的对象,其实这两个宏有没有不会影响代码的运行,但是在编译过程中有可能报警告。

六、 为模块添加常量

在有些场合,模块或者类中都需要用到预设的常量,本小节,我们将为模块加入两种基础类型的常量,在类中加入常量的方式也是一样的。

常量的加入直接修改字典列表即可,在字典列表中,key 和方法的添加方式是一样的,常量的值一般是 int 类型的,或者字符串类型的,所以可以通过 MP_ROM_INT 或者 MP_ROM_QSTR 转译值,用 MP_ROM_QSTR 做简单的字符串常量还可以,如果稍微复杂的就不行了,具体如何做复杂字符串,希望其他大佬们能给个方案。

{MP_ROM_QSTR(MP_QSTR_ROTATE_0), MP_ROM_INT(0)},
{MP_ROM_QSTR(MP_QSTR_ROTATE_90), MP_ROM_INT(1)},
{MP_ROM_QSTR(MP_QSTR_ROTATE_180), MP_ROM_INT(2)},
{MP_ROM_QSTR(MP_QSTR_ROTATE_270), MP_ROM_INT(3)},

{MP_ROM_QSTR(MP_QSTR_STR), MP_ROM_QSTR(MP_QSTR_My_string)},

可以通过 dir(moopi) 查看已经存在的函数及添加的常量。

七、 为模块添加类

类是面向对象编程的基本概念之一。它允许你创建具有特定属性和方法的自定义对象。类是对象的蓝图,它定义了对象的行为和状态。在 C 扩展 micropython 的中,类不仅可以包含函数和常量,还可以包含有静态函数、属性,以及特殊方法,比如构造函数、析构函数、打印函数、子集等等。

7.1 构造基础类

构造类和构造模块非常类似,按顺序共分为五步:

  1. 定义类的类型结构体
  2. 构建全局成员字典
  3. 将全局字典转换为 micropython 对象;
  4. 定义类原型;
  5. 将类添加到模块中;

记得将 .c 文件加入到编译列表

为了增强代码可读性,建议每个类一个 .c 文件,并把公共的部分放在 .h 文件中。所以本次我们需要增加两个文件,moopi.h 和 modobject.c

7.1.1 定义类的类型结构体

在 C 扩展 micropython 过程中,一切对象都始于一个 C 的传统结构体,通过这个结构体,得以让 C 和 micropython 进行数据交互,所以每个自定义的类都要包含一个这样的结构体,要求是结构体的第一个成员为 mp_obj_base_t 类型数据,在 mp_obj_base_t 中存放了该类实例的类型、构造函数、析构函数、call函数、打印函数等等,同时这个成员还是递归的, mp_obj_base_t 首个成员仍然是他自己。

除此之外,结构体中就是存放我们这个类所需的一些用于驻留内存的数值了,比如我们例程中需要创建一个名字叫 Object 的类,包含 x、y、width、height、parent 几个成员。

typedef struct moopi_object moopi_object_t;

struct moopi_object{
    mp_obj_base_t base;
    int16_t x;
    int16_t y;
    uint16_t width;
    uint16_t height;
    moopi_object_t *parent;
};

这段代码定义完了,但暂时我们还用不到

7.1.2 定义类成员字典并转换成 micropython 对象

我们把所有的类型结构体放在 moopi.h 中,这样方便其他文件调用。

在 micropython 中,类的成员字典和模块的成员字典定义方式类似,都是定义一个 mp_rom_map_elem_t 类型的数组,然后通过 MP_DEFINE_CONST_DICT 宏将其转换为 micropython 对象,不同之处在于,定义类成员字典的时候不用写 name 属性。

const mp_rom_map_elem_t moopi_object_local_dict_table[] = {
};
STATIC MP_DEFINE_CONST_DICT(moopi_object_local_dict, moopi_object_local_dict_table);
7.1.3 定义类原型

在上面几节的函数测试中已经说明,在 C 扩展 Micropython 的过程中,一切传值都是 mp_obj_t 类型的,而这个类型的数据都有一个类型字段,可以通过 mp_obj_get_type 函数获得,所以我们自定义的类也必须有这样一个原型,就像定义模块一样。

const mp_obj_type_t moopi_type_object = {
    {&mp_type_type},
    .name = MP_QSTR_Object,
    .locals_dict = (mp_obj_dict_t *)&moopi_object_local_dict,
};

定义类原型使用的是 mp_obj_type 类型,必填的有三个成员,第一个恒定为 {&mp_type_type} 不可修改,第二个是类的名称,就是通过 mp_obj_get_type_str 函数获取的物理名称;第三个成员是类的成员字典。

7.1.4 加入模块

回到 modmoopi.c 文件中,在类成员中加入新创建的类。

{MP_ROM_QSTR(MP_QSTR_Object), MP_ROM_PTR(&moopi_type_object)},

此时编译烧录已经可以看到类成员了,但成员还没有办法实例化。

import moopi
dir(moopi)
['__class__', '__name__', 'sum', 'Object', 'ROTATE_0', 'ROTATE_180', 'ROTATE_270', 'ROTATE_90', 'STR', 'achieve', 'add', 'average', 'location', 'max', 'sayHello', 'sayHi', 'size']

到此为止,类打添加就已经完成了,但此时如果尝试实例化类,系统则会崩溃:

obj = moopi.Ojbect()

7.2 为类增加构造函数

构造函数不是必须的,我们可以通过其他方式构造类,但如果没有构造函数,用户在尝试实例化对象的时候会导致系统崩溃,所以,如果我们可以不提直接构造方式,但必须保证存在构造函数,要不然就别加到模块字典中,不加到模块的字典中这个类也是存在的,只是不能显示的实例化而已。

上一节中,在定义类原型的时候,我们只给原型添加了三个必要参数,第四个必要参数就是 make_new ,这是一个函数,当用户尝试构造一个类实例的时候,系统会调用该函数并返回对应的实例,或者返回空对象(禁止构造)。

该函数原型是:

STATIC mp_obj_t make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *args)

其中 make_new 是构造函数的名字,没有严格要求,随意写即可,但建议采用 模块名_类名_make_new 这样的方式命名。
参数均为构造时被动传入,第一个是构造该类类型,也就是之前我们定义的 moopi_type_object 本身,n_args 是调用构造方法时候按位置传入的参数数量,n_kw 是按字典传入的参数数量, args 是参数列表,其中包含了按位置传入的参数和按字典传入的参数。

如果自定义类不允许实例化,或者是参数不正确不能实例化,那么在这个函数中直接返回 mp_const_none 即可,但如果允许实例化,那么在这个函数中必要做的几件事如下:

  1. 为类结构开辟空间
  2. 设置实例的对象类型
  3. 返回这个对象
STATIC mp_obj_t moopi_object_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *args)
{
    moopi_object_t *self = (moopi_object_t *)m_new_obj(moopi_object_t);
    self->base.type = &moopi_type_object;
    return MP_OBJ_FROM_PTR(self);
}

创建对象的方式有很多种,这里我们选择了最基础的 m_new_obj 的方式,另外还有其他几种方式:

  • m_new_obj : 创建一个对象实例,在对象弃用时需要手动调用析构函数
  • m_new_obj_with_finaliser : 创建一个对象实例,对象弃用时会自动调用析构函数释放空间
  • m_new_obj_var : 用于创建具有可变大小的对象,也称为变长对象(variable-sized object),需要手动析构
  • m_new_obj_var_with_finaliser : 用于创建具有可变大小的对象,也称为变长对象(variable-sized object),自动释放空间
  • m_new_obj_maybe : 创建一个空对象
  • m_new_obj_var_maybe : 创建一个可变大小的空对象。

说实在的,除了前两个,后面这些我基本也没有深研究是干啥用的,希望其他大佬能够补充一下,最常用的就是上面两个,第二个等下面讲到析构函数的时候再给大家细讲。

函数中第二行也是必须的,创建完对象之后,必须显式的为这个对象设置类型,否则在实例化阶段一样会报错。

最后,通过 MP_OBJ_FROM_PTR 宏将对象强制转换为 mp_obj_t 类型对象返回。

此时在通过实例化的方式获得对象,就已经可以获得成功了,并且通过 dir 去查看这个类实例的时候,可以看到他存在一个 class 的成员,这个成员的值是 Object,也就是我们类的物理名称。

7.2 类的析构函数

micropython 有严格的内存管理机制,当在 Micropython 环境下使用变量对对象进行一次引用后,对象引用计数器会加一,当失去一次引用后,引用计数器会减一,当引用计数为0的时候,会进入系统回收状态,但此时不会进行及时回收,而是当系统 gc 线程调用 gc.collect() 的时候进行回收。

obj = moopi.Ojbect() 及对对象产生了一次引用
a = obj 引用加一
obj = 1 引用减一

如果在创建对象的时候使用了 m_new_obj_with_finaliser ,则系统会管理对象的应用与空间释放,但如果使用 m_new_obj 创建对象,则需要我们手动释放空间。

在 C 扩展 micropython 的时候,环境并没有像提供构造函数那样提供析构函数的注入方式,需要我们自己给成员字典增加一个 del 的成员才可以,但这个成员使用过程中会存在一些问题,后面会讲到。该函数的原型如下:

STATIC mp_obj_t destructor(mp_obj_t self_in)

其中 self_in 及当前对象的指针,直接返回 mp_const_none 即可。
在这个函数中,需要通过 m_del_obj 函数释放对象:

STATIC mp_obj_t moopi_object_destructor(mp_obj_t self_in){
    moopi_object_t *self = MP_OBJ_TO_PTR(self_in);
    m_del_obj(self->base.type,self);
    return mp_const_none;
}
MP_DEFINE_CONST_FUN_OBJ_1(moopi_object_destructor_obj, moopi_object_destructor);

该函数需要注册到类的成员列表中:

const mp_rom_map_elem_t moopi_object_local_dict_table[] = {
    {MP_ROM_QSTR(MP_QSTR___del__), MP_ROM_PTR(&moopi_object_destructor_obj)},
};

此时我们其实是无法直观感受到对象是何时被回收的,按照 ESP32 及 FreeRTOS 的规定,当系统空间不足的时候会触发自动回收线程,但实际在测试过程中会发现,这个功能貌似并没有被打开,只有我们手动调用 gc.collect() 函数的时候系统才会自动回收。

下面我们在析构函数中加一行输出,测试一下。

import moopi
obj = moopi.Object()
obj = moopi.Object()
import gc
gc.coloect()

上面的代码中我们构造了两次 Object 对象,当第二次构建的时候, obj 变量指向了新的对象,原来的对象引用减一,此时已经没有任何变量应用它,所以调用 gc.collect() 的时候会被回收掉。

但这也引发了一个问题:无论是用 m_obj_new 还是 m_new_obj_with_finaliser 定义的实例,micropython 只关注micropython 环境的引用,如果我们在 C 环境中对该实例增加引用时候,micropython 环境其实并不知道,这就会导致,在 C 中引用的失效,从而直接导致系统的崩溃。

在其他版本的 Micropython 中(比如在 RT-Thread 中扩展 micropython 的时候,或者在 X86 环境中扩展Micropython的时候),都可以通过 类似 Py_INCREF 和 Py_DECREF 的方式手动增减对象的引用次数,但是在 micropython 的环境中并没有发现有类似函数或宏可用。

所以如果我们的对象在 C 和 micropython 环境中混用,最好使用 m_new_obj 方式为对象开辟空间,并且不要使用 del 析构函数,而是提供一个手动析构函数。

7.3 为对象增加方法

类对象的方法和函数与库的方法定义方式基本相同,不同之处在于,类方法至少有一个参数,并且第一个参数永远是类实例自身,从第二个参数开始才是真正调用方法时候传入的参数。

STATIC mp_obj_t moopi_object_size(size_t n_args, const mp_obj_t *args){
    moopi_object_t *self = MP_OBJ_TO_PTR(args[0]);
    if(n_args==2){
        self->width = self->height = mp_obj_get_int(args[1]);
    }else if(n_args==3){
        self->width = mp_obj_get_int(args[1]);
        self->height = mp_obj_get_int(args[2]);
    }else{
        mp_obj_t res[2]={
            mp_obj_new_int(self->width),
            mp_obj_new_int(self->height)
        };
        return MP_OBJ_FROM_PTR(mp_obj_new_tuple(2,res));
    }
    return MP_OBJ_FROM_PTR(self);
}
MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(moopi_object_size_obj, 1, 3, moopi_object_size);

(记得把方法添加到字典中)

以上的例程代码中,函数第一行首先从传入参数列表的第一个参数中获取了类实例自身(就是通过 moopi.Object()创建的类实例),这个类实例就是在 make_new 方法中通过 m_new_obj 创建的结构体指针,该结构体携带了该类的所有自定义成员,包括x,y,width,height,parent。

从代码中可以得知,size 函数其实是一个玩出花来的重载函数。首先,这个函数可以接受 0 参数(其实是1个参数)传入,用于查询对象的宽度和高度,此时返回的是对象宽高的元祖;但如果携带一个或者2个参数的时候,则会设置对象的宽高,不过设置完毕后并不像在模块中 size 函数那样返回对象的大小,而是返回了对象自身,这种方式可以对对象进行链式操作,灵感来自于 JQuery 操作起来十分方便。

import moopi 
obj = moopi.Object()
obj.size(100,200).location(10,20)

7.4 类的静态方法

上一小节中定义了 size 和location 函数,这些函数可以通过类实例直接的方式调用,但实际上内部调用仍然是通过对象进行调用的(这个是 Python 的成员函数调用机制),即通过以下方式调用:

moopi.Object.size(obj,100,200)

这样可以直观的感受到,为什么在实际转到 C 函数的时候会多出来一个参数。

利用这个特性,我们可以给类创建一些静态方法,即直接通过类调用的方法,但事先声明,这是比较危险的操作,不建议大家用。

STATIC mp_obj_t moopi_object_new(){
    moopi_object_t *self = (moopi_object_t *)m_new_obj(moopi_object_t);
    self->base.type = &moopi_type_object;
    return MP_OBJ_FROM_PTR(self);
}
MP_DEFINE_CONST_FUN_OBJ_0(moopi_object_new_obj, moopi_object_new);

(记得把方法添加到字典中)

该方法添加后,通过 dir(moopi.Object) 可以看到,该对象已经增加了一个 new 方法,通过这个 new 方法也可以创建一个对象(如果想用单态的话,这是一个不错的方法,但还是建议用),通过 moopi.Object.new() 可以返回一个对象实例,所以这是一个基于对象的静态方法,理论上只能通过对象调用这个方法,但理论是理论,现实就很打脸了,不论是通过 new 方法创建的对象,还是通过构造函数创建的对象,通过 dir(obj) 查看的到时候发现,类实例尽然也有 new 方法,这就比较悲催了,如果用户不小心调用了 obj.new() 系统就抛出参数个数不对的错误,所以这是一个伪静态方法。

不过仍然可以通过一些方案解决,但终归不是很舒服,有种破坏封装的遗憾。

7.5 打印函数

当我们实例化一个对象后,在 REPL 环境下尝试打印这个对象的时候,发现输出的结果是 <Object> ,但如果去看其他的类,比如 machine.Pin 生成的实例,直接输入对象名打印的时候是 Pin(1),那这是如何实现的呢?

其实这个方法就藏在了类的原型中。

上面章节中提到过,所有类的原型都是一个 mp_obj_type_t 结构体,这个结构体中有一些特殊成员,有一些我也没用过,也不咋认识,我挑一些认识的讲一下:

标志成员说明
base类型的头,所有类都包含这个,这里我们恒定为 {&mp_type_type}
flags该类型相关的标志位,用于指示类型的特性和行为 ,这个没用到过
name类的实际名字,不是模块中显示的名字,而是通过 obj.name 打印出来的名字,这两个名称实际上是可以不同的
*print打印函数指针,指向实现__repr__和__str__特殊方法的函数,用于打印对象的字符串表示形式,通过在 REPL 环境下直接输入变量名,或者通过 pirnt() 函数打印出来的内容
make_new初始化函数指针,指向实现__new__和__init__特殊方法的函数,用于创建该类型的实例对象
*call指向实现__call__特殊方法的函数,允许以类似函数调用的方式使用该类型的实例对象
*unary_op指向实现一元操作的函数,用于支持对象的运算操作
*binary_op指向实现二元操作的函数,用于支持对象的运算操作
*attr指向实现属性的加载、存储和删除操作的函数
*subscr指向实现下标运算的加载、存储和删除操作的函数
*getiter指向迭代器获取函数
*iternext指向迭代器的下一个元素的函数
buffer_p如果该类型支持缓冲区协议,指向实现缓冲区操作的函数,没怎么用到过,不过应该挺有用的
protocol指向其他特定协议或接口的结构体或函数指针,也没用到过
*parent指向父类型的指针,可以是单个父类型的指针,也可以是包含多个父类型的元组对象
locals_dict一个字典对象,用于存储类型的局部方法、常量等

上面表格中标注 √ 的是已经讲过的,标注 * 的是接下来会讲到的,没有做任何标注的是我也没用过的,不能拿出来误导大家

本小节着重讲打印输出函数,其函数原型是:

void (*mp_print_fun_t)(const mp_print_t *print, mp_obj_t o, mp_print_kind_t kind);

该函数有三个参数,第一个是一个指向 mp_print_t 结构体的指针,用于控制打印行为。mp_print_t 结构体包含了打印函数的指针和其他相关的数据。通过这个参数,可以访问打印函数及其关联的数据。

第二个参数是触发打印的对象。

第三个参数是一个枚举类型的值,用于指定打印的类型。mp_print_kind_t 定义了不同的打印类型,例如正常的打印、调试信息的打印等。根据打印类型的不同,可以在打印函数中实现不同的行为逻辑。这个参数可以判断实在什么情况下做的输出,比如直接在 REPL 环境下输出变量,该值是1,也就是 PRINT_REPR,如果使用 print() 函数输出,该值是 0 ,也就是 PRINT_STR,等等,其他的大家可以试一下,通过这个值,可以控制在不同环境下可以输出不同内容。

注意,这个函数没有返回值,不用再返回 mp_const_none 了。

在这个函数中,我们是不能直接使用 C 的 printf 输出的,因为那个没有意义,虽然也可以输出内容,但并不是在真正的 python 环境下输出的,这里需要使用 mp_print 函数,这个函数的使用方式和 sprintf 相似,接收2个及以上参数,第一个参数是输出的通道, 这里直接写 print 参数即可, 第二个是格式化字符串,后面的值格式化字符参数。

STATIC void moopi_object_print(const mp_print_t *print, const mp_obj_t self_in, mp_print_kind_t kind)
{
    moopi_object_t *self = MP_OBJ_TO_PTR(self_in);
    if(self!=MP_OBJ_NULL){
        const char *type = mp_obj_get_type_str(self_in);
        mp_printf(print, "<MooPi:%s>(x:%d, y:%d, width:%d, height:%d)", type, self->x, self->y, self->width, self->height);
    }else{
        mp_printf(print, "<MooPi:Object>(null object)");
    }
}

打印函数中,首先从 self_in 中获取对象,如果对象不为空,则打印出对象的 x,y,width,height 信息。
最后,记得给 moopi_type_object 原型加上 .print 成员即可。

const mp_obj_type_t moopi_type_object = {
    {&mp_type_type},
    .name = MP_QSTR_Object,
    .locals_dict = (mp_obj_dict_t *)&moopi_object_local_dict,
    .make_new = moopi_object_make_new,
    .print = moopi_object_print
};

7.6 直接调用函数

这个翻译好像不是很贴切,这个函数和直接在成员中加入 call 效果是相同的,这个函数的作用是能够让对象变得像函数一样能够直接被调用,比如:

obj = moopi.Object()
obj()

这个函数的原型是:

mp_obj_t (*mp_call_fun_t)(mp_obj_t fun, size_t n_args, size_t n_kw, const mp_obj_t *args);

共接收四个参数,第一个参数是调用的对象本身,也就是 self_in,第二个是传入的按位传值的参数数量,第三个参数是按字典传值的参数数量,最后一个是参数列表,注意,args 中先存储的是按位置传值的参数,如果需要取出按字典传值的参数,可以参考 5.2.5 小节。

STATIC mp_obj_t moopi_object_call(mp_obj_t self_in, size_t n_args, size_t n_kw, const mp_obj_t *args)
{
    printf("Called moopi_object_call\n");
    return mp_const_none;
}

最后记得修改修改原型,加上 .call 指向。

该方法的用法是使用类的对象加括号调用,而不是直接类名加括号,类名加括号调用的是构造函数:

import moopi
obj = moopi.Object()
obj()

7.7 类的属性

在网上很多资料中,都是在 micropython 中没有办法给类添加属性,所以只能使用 set/get 方法 实现属性的修改,而且在 ESP32 官方代码中也没有使用属性,用的都是方法实现的。但经过对 micropython 源码的分析,其实是可以通过原型中的 attr 元素直接(并不是间接)实现属性的,也就是本来人家 micropython 就提供了属性是扩展方式,有可能是早起版本中不能直接使用,导致了大家对这部分有所谓误解。

属性元素的函数原型如下:

void (*mp_attr_fun_t)(mp_obj_t self_in, qstr attr, mp_obj_t *dest);

第一个参数是调用属性的类实例,第二个元素是调用属性的字符串转码Hash值,当给属性赋值的时候,第三个值是属性的值,如果只是取值,第三个则作为返回对象用。

attr 是一个被序列化后的值,这个值在整个 micropython 环境中表示唯一的一个字符串(可以理解为 Hash 值,实际上也是),所以我们比较的时候直接用 MP_QSTR_XXX 进行比较即可,并且编译系统会很贴心的帮我们进行转换。dest 用于向内或向外传值,这个参数是一个数组,有两个值,dest[0] 表示返回的值,所以如果需要查询一个属性值,通过 dest[0] 返回即可,dest[1] 表示要设置某个属性值。
如果 dest[0] == MP_OBJ_SENTINEL 的时候,表示调用的是 setter ;如果 dest[0] == MP_OBJ_NULL 的时候,表示是 getter 函数。
我翻阅其他函数库的一些代码,有的判断是 dest[1] != MP_OBJ_NULL 表示调用了 setter 函数(LVGL函数库竟然也有这样的错误),但这样是不严谨的,正常情况下这样做没问题,但是恰巧我们设置的是 是 None 的时候,就会报错了,所以我们还是判断 dest[0] 是否为 MP_OBJ_SENTINEL 最为稳妥。

所以我们可以为其增加一个属性函数:

STATIC void moopi_object_attr(mp_obj_t self_in, qstr attr, mp_obj_t *dest){
    moopi_object_t *self = MP_OBJ_TO_PTR(self_in);
    switch (attr)
    {
    case MP_QSTR_width :
        if(dest[0] == MP_OBJ_SENTINEL){
            self->width = mp_obj_get_int(dest[1]);
            dest[0] = MP_OBJ_NULL;
        }else{
            dest[0] = mp_obj_new_int(self->width);
        }
        break;
    case MP_QSTR_height :
        if(dest[0] == MP_OBJ_SENTINEL){
            self->height = mp_obj_get_int(dest[1]);
            dest[0] = MP_OBJ_NULL;
        }else{
            dest[0] = mp_obj_new_int(self->height);
        }
        break;
    case MP_QSTR_x :
        if(dest[0] == MP_OBJ_SENTINEL){
            self->x = mp_obj_get_int(dest[1]);
            dest[0] = MP_OBJ_NULL;
        }else{
            dest[0] = mp_obj_new_int(self->x);
        }
        
        break;
    case MP_QSTR_y :
        if(dest[0] == MP_OBJ_SENTINEL){
            self->y = mp_obj_get_int(dest[1]);
            dest[0] = MP_OBJ_NULL;
        }else{
            dest[0] = mp_obj_new_int(self->y);        
        }
        break;
    default:
        break;
    }
}

最后在类原型中加入 .attr 的函数指向:

const mp_obj_type_t moopi_type_object = {
    {&mp_type_type},
    .name = MP_QSTR_Object,
    .locals_dict = (mp_obj_dict_t *)&moopi_object_local_dict,
    .make_new = moopi_object_make_new,
    .print = moopi_object_print,
    .call = moopi_object_call,
    .attr = moopi_object_attr,
};

测试一下:

import moopi
obj = moopi.Object()
obj.width
0
obj.width=10
obj.width
10

还是非常好使的,但是这时候会出现一个问题,我们使用dir(obj) 查看的时候,得到的结果令人意外:

['__class__', 'height', 'width', 'x', 'y']

我们没有对 Object 做任何属性的操作,只是加了个属性函数,开发环境竟然贴心的帮我们把所有的属性字段都提取了出来,这是非常喜人的。

但你是否也发现了另外一个问题呢?

我们之前的 size 和 location 函数哪去了?

这是因为我们添加完 .attr 属性后,这个属性所提取出来的属性序列覆盖了我们之前的成员字典,不知道这是否是个 bug ,不管官方如何解释的,我们还有补救的机会,只要在 moopi_object_attr 函数开头加入以下代码即可:

const mp_obj_type_t *type = mp_obj_get_type(self_in);
mp_map_t *locals_map = &type->locals_dict->map;
mp_map_elem_t *elem = mp_map_lookup(locals_map, MP_OBJ_NEW_QSTR(attr), MP_MAP_LOOKUP);
if (elem != NULL)
{
    mp_convert_member_lookup(self_in, type, elem->value, dest);
}

这几行代码的意思是,从类定义中提取本地字典,然后将本地字典加入到序列中。这样在使用 dir(obj) 的时候,就会发现,之前丢失的元素都回来了。

['__class__', '__del__', 'height', 'location', 'new', 'size', 'width', 'x', 'y']

7.8 运算符重载

做过 C++ 开发的同学们都应该有印象, C++ 中有个非常便捷的功能,就是对类的运算符进行重载,比如我们写了一个类叫 Object,这个类的实例名叫 obj,重载运算符后可以使用 a = obj+1, 或者 obj<<3 这样的操作,非常方便,作为胶水语言的 Python 自然不能丢弃这么优秀的编程方式,所以在 micropython 中也非常完美的继承了这一特性,并且可以通过原型的 .unary_op 和 .binary_op 分别对一元运算符和二元运算符进行重载。

一元运算符重载函数原型如下:

mp_obj_t (*mp_unary_op_fun_t)(mp_unary_op_t op, mp_obj_t);

第一个参数是运算符类型枚举值,第二个参数是与本对象运算的数据,可以是任意类型的。

一元运算符一共有 10 个,相对来说比较简单:

常量表示运算符含义建议返回值类型
MP_UNARY_OP_POSITIVE+正号修饰任意
MP_UNARY_OP_NEGATIVE-负号修饰,负值任意
MP_UNARY_OP_INVERT~按位取反任意
MP_UNARY_OP_NOTnot否操作任意
MP_UNARY_OP_BOOLif()逻辑判断bool
MP_UNARY_OP_LENlen()测量长度int
MP_UNARY_OP_HASHhash()获取 Hash 值int
MP_UNARY_OP_ABSabs()绝对值number
MP_UNARY_OP_INTint()取整int
MP_UNARY_OP_SIZEOFsys.getsizeof()获取大小int

这个函数的返回值对于前面四个可以是任意类型的值,其实后面几个也可以是任意类型的,但建议还是要遵循 Python 环境的设定,返回指定类型的值。

STATIC mp_obj_t moopi_object_unary(mp_unary_op_t op, mp_obj_t self_in){
    moopi_object_t *self = MP_OBJ_TO_PTR(self_in);
    switch (op)
    {
    case MP_UNARY_OP_POSITIVE:  // +
        return MP_OBJ_FROM_PTR(mp_obj_new_int(self->x));
    case MP_UNARY_OP_NEGATIVE:  // -
        return MP_OBJ_FROM_PTR(mp_obj_new_int(-self->x));
    case MP_UNARY_OP_INVERT:    // ~
        return MP_OBJ_FROM_PTR(mp_obj_new_int(~self->byte_val));
    case MP_UNARY_OP_NOT:       // not
        return MP_OBJ_FROM_PTR(mp_obj_new_bool(!self->bool_val));
    case MP_UNARY_OP_BOOL:      // if(obj)
        return mp_obj_new_bool(self->bool_val);
    case MP_UNARY_OP_LEN:       // len()
        return MP_OBJ_NEW_SMALL_INT(123);
    case MP_UNARY_OP_HASH:      // hash()
        return MP_OBJ_NEW_SMALL_INT(qstr_compute_hash((const byte *)"12345",5));
    case MP_UNARY_OP_ABS:       // abs()
        return MP_OBJ_FROM_PTR(mp_obj_new_int(self->x));
    case MP_UNARY_OP_INT:       // int()
        return MP_OBJ_NEW_SMALL_INT(self->x);
    case MP_UNARY_OP_SIZEOF:    // sizeof()
        return MP_OBJ_FROM_PTR(sizeof(*self));
    }
    return mp_const_none;
}

这里需要注意,如果在 switch 中没有把所有的枚举值列完,最后一定要加一个 default: break; 否则会出错。

函数设置好后,可以使用代码进行测试:

-obj
len(obj)
hash(obj)

Micropython 的函数重载明显比 C++ 的要强大,仅是一元运算符就这么多了(可惜没有 ++ – 的操作),二元运算符就更多了,而且非常复杂和繁琐,我认识的大概有 34 个,其他还有很多,但都没找到相关资料,没法给大家讲解了。

二元运算符操作函数原型是:

mp_obj_t (*mp_binary_op_fun_t)(mp_binary_op_t op, mp_obj_t, mp_obj_t);

第一个参数仍然是运算符的枚举,第二个参数是 self_in ,也就是前面那个操作数(大概率是把自身写在前面的),第二个是操作数。

二元运算符:

9个关系运算,应该返回一个bool:

常量表示运算符含义
MP_BINARY_OP_LESS<小于运算
MP_BINARY_OP_MORE>大于运算
MP_BINARY_OP_EQUAL=等于运算
MP_BINARY_OP_LESS_EQUAL<=小于等于运算
MP_BINARY_OP_MORE_EQUAL>=大于等于运算
MP_BINARY_OP_NOT_EQUAL!=不等于运算
MP_BINARY_OP_INinin 运算
MP_BINARY_OP_ISisis 运算
MP_BINARY_OP_EXCEPTION_MATCH

13个赋值算数运算符:

常量表示运算符含义
MP_BINARY_OP_INPLACE_OR|=或等运算
MP_BINARY_OP_INPLACE_XOR^=异或等运算
MP_BINARY_OP_INPLACE_AND&=且等运算
MP_BINARY_OP_INPLACE_LSHIFT<<=左移等运算
MP_BINARY_OP_INPLACE_RSHIFT>>=右移等运算
MP_BINARY_OP_INPLACE_ADD+=加等运算
MP_BINARY_OP_INPLACE_SUBTRACT-=减等运算
MP_BINARY_OP_INPLACE_MULTIPLY*=乘等运算
MP_BINARY_OP_INPLACE_MAT_MULTIPLY@=矩阵乘法
MP_BINARY_OP_INPLACE_FLOOR_DIVIDE//=整除等运算
MP_BINARY_OP_INPLACE_TRUE_DIVIDE/=除法等运算
MP_BINARY_OP_INPLACE_MODULO%=取模等运算
MP_BINARY_OP_INPLACE_POWER**=幂等运算

13个算数运算符:

常量表示运算符含义
MP_BINARY_OP_OR|按位或运
MP_BINARY_OP_XOR^按位异或运算
MP_BINARY_OP_AND&按位与运算
MP_BINARY_OP_LSHIFT<<左移运算
MP_BINARY_OP_RSHIFT>>右移运算
MP_BINARY_OP_ADD+加运算
MP_BINARY_OP_SUBTRACT-减运算
MP_BINARY_OP_MULTIPLY*乘运算
MP_BINARY_OP_MAT_MULTIPLY@矩阵乘法运算
MP_BINARY_OP_FLOOR_DIVIDE//整除运算
MP_BINARY_OP_TRUE_DIVIDE/除法运算
MP_BINARY_OP_MODULO%取模运算
MP_BINARY_OP_POWER**幂运算

其他的暂时用不到就不讲了(重点是我也不懂……)

这里我们只简单的举几个例子,就不全部写完了,所以记得 switch 最后一定是 default: breakl; 否则编译不过去。

STATIC mp_obj_t moopi_object_binary(mp_binary_op_t op, mp_obj_t self_in, mp_obj_t value){
    moopi_object_t *self = (moopi_object_t *)MP_OBJ_TO_PTR(self_in);
    
    switch (op)
    {
    case MP_BINARY_OP_LESS : // <
        {
            int32_t val = mp_obj_get_int(value);
            return MP_OBJ_FROM_PTR(mp_obj_new_bool(self->x<val));
        }
        break;
    case MP_BINARY_OP_INPLACE_OR : // |=
        {
            uint8_t val = mp_obj_get_int(value);
            self->byte_val |= val;
            return MP_OBJ_FROM_PTR(self);
        }
        break;
    case MP_BINARY_OP_OR : // |
        {
            uint8_t val = mp_obj_get_int(value);
            return MP_OBJ_FROM_PTR(mp_obj_new_int(self->byte_val | val));
        }
        break;
    default:
        break;
    }
    return mp_const_none;
}

测试:

import moopi
obj = moopi.Object()

obj<10
True
obj.x=100
obj<10
False
o|0xAA
255
obj | = 0xAA
obj
<MooPi:Object>(x:0, y:0, width:0, height:0, byte_val:0xFF)

7.9 下标运算符

众所周知,Python 在数据处理方面有极大的优势,不仅在于他有非常强大的三方函数库,他还具有非常人性化的操作手法,比对于字典的操作,可以使用类似 dict['key'] 这种方式直接存取数据,相比之下,比 JAVA 和 C# 中的字典都好用的多。这种方式叫做下标操作,中括号中的内容及可以是字符串,也可以是数字,甚至可以是任何类型,简直爽的一批。

而在 C 扩展 micropython 的过程中,为类增加下标操作也非常简单,只要为原型添加 .subscr 属性即可,该属性对应的函数原型是:

mp_obj_t (*mp_subscr_fun_t)(mp_obj_t self_in, mp_obj_t index, mp_obj_t value);

第一个参数表示操作对象本身,第二个参数表示操作的下表,可以是任意类型的,最后一个是操作数,如果 value == MP_OBJ_SENTINEL 表示 getter 操作,如果是其他的表示 setter 操作。

所以,这个函数可以写的极为简单:

STATIC mp_obj_t moopi_object_subscr(mp_obj_t self_in, mp_obj_t index, mp_obj_t value)
{
    moopi_object_t *self = (moopi_object_t *)MP_OBJ_TO_PTR(self_in);

    if (value !=MP_OBJ_SENTINEL)
    {
        mp_obj_dict_store(self->dict,index,value);
    }
    
    mp_map_t *map = mp_obj_dict_get_map(self->dict);
    if(mp_map_lookup(map,index,MP_MAP_LOOKUP)!=NULL){
        return mp_obj_dict_get(self->dict,index);
    }
    return mp_const_none;
}

我们为我们的对象结构体增加了一个 mp_obj_ditc_t 类型的字典字段,把下表内容都存放在这个字段中,通过 mp_obj_dict_XXX 一系列函数对字典进行读写,mp_obj_dict_store 为保存一个值, mp_obj_dict_get 表示写入一个值,通过获得字典的 map 字段,可以查看所对应的值是否存在。

最后让我们来一波疯狂的测试:

import moopi
obj = moopi.Object()
obj['key'] = 10
obj['key']
10
obj[10] = 100
obj[10]
100
obj[1] = moopi.Object()
obj[1]
<MooPi:Object>(x:0, y:0, width:0, height:0, byte_val:0x55)
obj[obj[1]]=123
obj[obj[1]]
123

相当的完美!

7.10 迭代器

上一阶段,我们将 Object 类武装成了一个具有字典功能的对象,如果想查询 Object 中一共存储了多少个键值对,可以用 len() 函数,结合前面学到的运算符重载功能即可实现,但如果想使用 iter() 函数遍历这个这个对象呢?目前还不能实现。

我们看下面这段代码:

d = {'a':1,'b':2,'c':3}
i = iter(d)
next(i)
'b'
next(i)
'c'
next(i)
'a'

在 Python 中,是可以通过 iter 和 next 函数来遍历元祖、列表、字典等这些对象的,同样我们如果实现 iter 函数的话其实也是可以完成这样功能的,并且 Micropython 开发环境已经贴心的为我们准备了, .getiter 和 .iternext 两个元素,只要在对象原型中加入这两个函数即可,前者用于返回一个迭代器,后者用于对迭代器进行 next 操作,两个函数原型长这样:

mp_obj_t (*mp_getiter_fun_t)(mp_obj_t self_in, mp_obj_iter_buf_t *iter_buf);
mp_obj_t (*mp_fun_1_t)(mp_obj_t);

第一个是 .getiter 的函数原型,一共接收两个参数,第一个参数是调用 iter 函数的对象,第二个是一个迭代器缓冲区,用于存储迭代器状态和数据,暂时用不到。

第二个原型其实就是一个单参数函数,传入的是 self_in,但需要返回一个迭代的值。

按照常理,通过 .getiter 返回的对象应该是一个可以进行 next 的对象,然后这个对象具有 .iternext 成员,但为了方便演示,我都写到一个函数中,让 .getiter 返回自身,并且在每次调用的时候将其中 iter_index 值设置为 0 从头开始遍历,注意,这种方式仅用于演示,尽量不要用来正式开发中,因为每次调用 iter 都会影响到其他迭代器的值输出,正式开发的时候一定要返回一个可迭代的对象,并且保证对象是独立的。

每次调用 next 的时候,查看当前指向的值是否为空,并且判断是否超出了遍历范围,如果超出遍历范围,说明已经遍历结束了,我们需要返回一个 MP_OBJ_STOP_ITERATION 的值,标志着遍历结束,如果获得对象为空(key 和 value 都为空),说明这不是我们想要的值(具体为什么会出现这个,我猜想应该是在字典中存储以 NULL 结束导致的存在一个空值),继续下一个。

我们上一节中给结构体加了个 dict 元素,是一个字典元素,字典中有个值是 map,存放了字典的值和一些属性,我们可以通过 mp_obj_dict_get_map 获得这个 map ,或者为了效率,直接 self->dict->map 也是可以的。

map 中有两个值我们需要关注,alloc 表示元素的数量(包括那个空值),table 表示存放内容的表,是 mp_map_elem_t 类型的,mp_map_elem_t 中只存在一个 key 和一个 value。

所以我们程序设计的时候,通过 iter() 获取迭代器的时候,将计数器(self->iter_index)归零,通过 next() 获取元素的时候,让迭代器累加,如果超出范围则返回 MP_OBJ_STOP_ITERATION 表示迭代结束,否则返回这个键值对的元祖。

/**
 * @brief 迭代器获取函数
*/
STATIC mp_obj_t moopi_object_getiter(mp_obj_t self_in, mp_obj_iter_buf_t *iter_buf){
    moopi_object_t *self = (moopi_object_t *)MP_OBJ_TO_PTR(self_in);
    self->iter_index = 0;
    return self_in;
}
/**
 * @brief 迭代器下一个元素
*/
STATIC mp_obj_t moopi_object_iternext(mp_obj_t self_in){
    moopi_object_t *self = (moopi_object_t *)MP_OBJ_TO_PTR(self_in);
    mp_map_t *map = mp_obj_dict_get_map(self->dict);
    mp_map_elem_t *elem = NULL;
    do{
        if(self->iter_index<map->alloc){
            elem = map->table+self->iter_index;
        }else{
            return MP_OBJ_STOP_ITERATION;
        }
        
        self->iter_index++;
    }while(elem->key == MP_OBJ_NULL && elem->key == MP_OBJ_NULL);
    mp_obj_t res[2]={elem->key,elem->value};
    return MP_OBJ_FROM_PTR(mp_obj_new_tuple(2,res)); 
}

7.11 类的继承

到此为止,霍霍类原型的成员们已经讲的大差不差了,buffer_p 和 protocol 还没来得及研究, LVGL 中道是用到这个了,带我研究完毕之后再向各位做汇报。

本小细节收个尾,讲一下类的继承。

类的继承在 github 上跟开发组交流了好长时间也没搞明白,可能是语言障碍(我用谷歌翻译的),也可能就本人是单纯的理解能力差,他们给的答案,以及 GPT 个的答案都是写 .parent 成员,这个咱也写了,效果是有一点的,但是差点意思,我把这个叫名义上的继承,但实际上并没有达成。

按照 7.1 章节中构建类的五个步骤,新添加一个 Label 子类:

定义类的类型结构体

struct moopi_label{
    moopi_object_t base;
    char *text;
};

这个类的结构体第一个元素并不是 mp_obj_base_t 而是 moopi_object_t ,而 moopi_object_t 第一个元素是 mp_obj_base_t ,所以从严格意义上来讲, moopi_label 第一个元素仍然是 mp_obj_base_t,这就是在 C 环境下做 struct 继承的方式。(绕口令结束)

构建全局成员字典 并转换为 micropython 对象

const mp_rom_map_elem_t moopi_label_local_dict_table[] = {
};
STATIC MP_DEFINE_CONST_DICT(moopi_label_local_dict, moopi_label_local_dict_table);

加入 make_new 函数

STATIC mp_obj_t moopi_label_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *args)
{
    mp_arg_check_num(n_args, 0, 0 ,1, false);
    // 创建对象
    moopi_label_t *self = (moopi_label_t *)m_new_obj(moopi_label_t);
    // 设置对象实例类型
    self->base.base.type = &moopi_type_label;
    self->text = NULL;
    if(n_args==1 && mp_obj_get_type(args[0]) == &mp_type_str){
        const char *t = mp_obj_str_get_str(args[0]);
        size_t len = strlen(t)+1;
        self->text = m_malloc(len);
        if(self->text!=NULL){
            memcpy(self->text,t,len);
        }
    }
    // 返回对象
    return MP_OBJ_FROM_PTR(self);
}

这个 make_new 函数加了一些料,第一行中使用 mp_arg_check_num 检查参数情况,这个函数参数依次是:输入参数总数量,按字典传值参数总数量,最小允许参数个数,最大允许参数个数,是否允许使用字段传值。如果没有按照要传值,函数会帮我们抛出一个参数类型错误的异常。

这个构造函数既可以不传参,也可以传入一个字符串参数作为 label 标签的内容。
定义类原型

const mp_obj_type_t moopi_type_label = {
    {&mp_type_type},
    .name = MP_QSTR_Label,
    .locals_dict = (mp_obj_dict_t *)&moopi_label_local_dict,
    .make_new = moopi_label_make_new,
};

将类添加到模块中
这里需要注意一下,如果添加文件后直接编译,不会出任何问题,但其实此时并没有把我们下加入的文件捎带上,因为 make 环境首先编译 CMakeLists.txt 文件成 mk 文件,mk 文件中把所有的文件都列出来了,这里面用的都是文件的绝对路径,没有用任何通配符,所以在没有修改 CMakeLists.txt 的情况下编译,其实我们刚才加入的文件是没有被放到编译列表的,所以,如果我们在 CMakeLists.txt 文件中使用的是 file 函数创建的变量,很有可能就会出现不编译的问题,建议重新保存一下这个文件即可。

为 Label 类加入 text 成员

STATIC mp_obj_t moopi_label_text(size_t n_args, const mp_obj_t *args){
    moopi_label_t *self = MP_OBJ_TO_PTR(args[0]);
    
    if(n_args>1){
        const char *t = mp_obj_str_get_str(args[1]);
        size_t len = strlen(t)+1;
        if(self->text!=NULL){
            m_free(self->text);
            self->text=NULL;
        }
        self->text = m_malloc(len);
        if(self->text!=NULL){
            memcpy(self->text,t,len);
        }
    }

    mp_obj_t text = mp_obj_new_str("",0);
    if(self->text !=NULL){
        text = mp_obj_new_str(self->text,strlen(self->text));
    }
    return MP_OBJ_FROM_PTR(text);
}
MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(moopi_label_text_obj,1,2,moopi_label_text);

测试一下,类可以正常看到,成员除了其他正常的,只有一个我们自己添加的 text ,尝试实例化这个类,也能看到。

按照我们设想的,Label 继承自 Object,那只要填写 .parent 应该就能够获得 Object 的所有属性才对,我们试一下:

const mp_obj_type_t moopi_type_label = {
    {&mp_type_type},
    .name = MP_QSTR_Label,
    .locals_dict = (mp_obj_dict_t *)&moopi_label_local_dict,
    .make_new = moopi_label_make_new,
    .parent = &moopi_type_object
};

烧录测试:

import moopi
dir(moopi.Label)
['__class__', '__name__', '__bases__', '__del__', '__dict__', 'location', 'new', 'size', 'text']

看,除了我们自己添加的这些成员之外,还新增加了 Object 的成员,事情看似完美,但老天就非得在你得意的时候给你一棒槌!
我们继续测试:

lab = moopi.Label("ABC")
dir(lab)
['__class__', 'text']

实例化对象后发现,进存在自身的成员,父类的成员丢的一干二净。

通过阅读其他 Micropython 的代码,以及翻阅 Micropython 的源码,还是找到了解决方案,就是为原型增加 .attr 成员,在 .attr 中把父类的属性都列出来,就像我们给 Object 增加属性的时候想法是一样的:

void call_parent_methods(mp_obj_t obj, qstr attr, mp_obj_t *dest)
{
    const mp_obj_type_t *type = mp_obj_get_type(obj);
    while (type->locals_dict != NULL)
    {
        mp_map_t *locals_map = &type->locals_dict->map;
        mp_map_elem_t *elem = mp_map_lookup(locals_map, MP_OBJ_NEW_QSTR(attr), MP_MAP_LOOKUP);
        if (elem != NULL)
        {
            // 添加当前类自己的所有成员
            mp_convert_member_lookup(obj, type, elem->value, dest);
            break;
        }
        if (type->parent == NULL)
        {
            break;
        }
        // 递归搜索父类成员
        type = type->parent;
    }
}

// 定义类的类型结构
const mp_obj_type_t moopi_type_label = {
    {&mp_type_type},
    .name = MP_QSTR_Label,
    .locals_dict = (mp_obj_dict_t *)&moopi_label_local_dict,
    .make_new = moopi_label_make_new,
    .parent = &moopi_type_object,
    .attr = call_parent_methods
};

这样在执行 dir(lab) 的时候,所有的成员就都出来了,而且都可以正常使用,但仍有些小瑕疵,就是父类的属性以及 print 方法并没有同步继承过来,因为在调用子类 attr 方法的时候,父类并不会自动调用 attr 方法,而父类的属性都是在父类的 atrr 方法中构建的,所以并没有带过来。这还需要我们进一步构建更强大的 通用 attr 函数。

到此为止,C 扩展 Micropython 的教程就都结束了,接下来我会结合外设,写一个综合用例。

并且在后续的教程中也会持续更新成员字典中一些其他的魔术方法,比如 __init__,__enter__,__delitem__ 等等。

文章来源:https://blog.csdn.net/suolong123/article/details/135512092
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。