以优化 MaixPy 的启动速度为例,说说 K210 的双核使用及原子操作。

本篇文章,也是在总结自己使用 K210 芯片的过程中留下的一些痕迹,如果觉得有帮助,不清楚的地方都可以直接留言告诉我。

文章大纲

这次我编写了一个大纲说明,方便查阅的朋友得知本篇主题

  • 优化 MaixPy 的 SD 卡挂载和启动问题。
  • 示范一下双核的使用。
  • 双核中需要的原子操作。

MaixPy 进入系统很慢,慢在哪里了?

现在 MaixPy 项目也进入到了稳定阶段,它迟早会成为一个优秀的 MCU 项目参考,至少我们可以从中学会很多观念,运用很多复杂代码。

但还有很多优化空间,如一直存在的启动速度过慢的问题,我们理一下 MaixPy 的启动过程。

  • 调用 maixpy_main 经过标准的 K210 BSP 启动过程,设置相应的芯片初始化,再配置 PLL \ RTC \ FLASH \ CORE 等硬件资源。

MaixPy/components/micropython/port/src/maixpy_main.c#L646-L703

  • 再来进入到 mp_task 导入 MicroPython 的环境代码,选择执行的核心,并设置 Gc HeapSize 区域,它就类似于一个死循环,维持 MicroPython 的执行,在进入 REPL 之前,要完成 内存、外设、系统 的初始化。

MaixPy/components/micropython/port/src/maixpy_main.c#L488-L578

  • 才到执行内置的 _boot.py 和 main.py 等内置代码。

经过了调试确认 SD 卡的 spi 驱动初始化占用了至少 3s 的循环时间,主要影响的地方在我上次修改 SD 的读取超时导致的时间过长,但这个问题不是因为靠改变等待时间长度来解决的。

此前 MaixPy 的 SD 卡启动存在问题,会在运行一段时间后出现不稳定情况,从而在 Python 层面丢失 SD 卡的内容,后来量测数据确认为 K210 与 SPI 之间出现了死锁,就主机和从机都在等对方给应答,理论上只要保证 SD 卡每次的读写失败都会退回上一层 SPI 初始化就可以从根本上解决这个问题,但这并不在本文中继续讨论。

这是在这之前的代码,我们可以看到如下逻辑:

  • sdcard_is_present 是为了确认 spi 与 sdcard 能够通信成功。
  • init_sdcard_fs 是为了进一步初始化 SD 插入 MicroPython 环境中。

这里介绍一下关于 MicroPython 的 VFS 注入 SD 卡操作,实时上可以用这样的代码去完成 os 模块的盘符加载的:

import esp

class FlashBdev:

    SEC_SIZE = 4096
    RESERVED_SECS = 1
    START_SEC = esp.flash_user_start() // SEC_SIZE + RESERVED_SECS
    NUM_BLK = 0x6b - RESERVED_SECS

    def __init__(self, blocks=NUM_BLK):
        self.blocks = blocks

    def readblocks(self, n, buf):
        #print("readblocks(%s, %x(%d))" % (n, id(buf), len(buf)))
        esp.flash_read((n + self.START_SEC) * self.SEC_SIZE, buf)

    def writeblocks(self, n, buf):
        #print("writeblocks(%s, %x(%d))" % (n, id(buf), len(buf)))
        #assert len(buf) <= self.SEC_SIZE, len(buf)
        esp.flash_erase(n + self.START_SEC)
        esp.flash_write((n + self.START_SEC) * self.SEC_SIZE, buf)

    def ioctl(self, op, arg):
        #print("ioctl(%d, %r)" % (op, arg))
        if op == 4:  # BP_IOCTL_SEC_COUNT
            return self.blocks
        if op == 5:  # BP_IOCTL_SEC_SIZE
            return self.SEC_SIZE

bdev = FlashBdev((size - 20480) // FlashBdev.SEC_SIZE - FlashBdev.START_SEC)

我们只需要能够构建这个 vfs 对象并提供 readblocks 和 writeblocks 给到 os 去执行该对象所映射的接口就可以添加到 MicroPython 中的 vfs 中,所以理解了这一层,我们就知道 Python 是如何连接到具体的 SD 卡读写操作的,关于这个的代码,我也留个标记 MaixPy/components/micropython/port/src/standard_lib/machine/machine_sdcard.c#L84-L102

那么我们回到主题上,排除了上层代码的问题,将目光定位到 SD 卡驱动上,我们不难发现 spi 的驱动方式只存在于单线程流程,也就是 K210 先使用 SPI 对目标的通信和等待后再做后续操作。

从整体上来看问题,单独拿出来,这个再正常不过了,从大局来看,如果没有 sd 的卡的芯片岂不是也要等待?

因为从该逻辑只考虑软件,而不考虑硬件。如果从硬件上考虑问题,则需要硬件上做一个引脚识别或协议判断,从而不超时等待来确认 SD 卡是否存在,但目前来看,只能软件默默的等待应答。

而不使用 RTOS 的情况下,也就没有办法让出当前的时间片给其他线程的话,我们还能怎么做?

可以多靠硬件的资源来解决问题,一方面可以透过定时器中断,另一方面则可以使用双核,而不需要总是想要通过软件的逻辑来解决问题,有时候结合硬件可以轻松解决问题。

所以我最后选择了使用多核,主要的修改记录如下。

MaixPy/commit/450f20b9956d88107109499a7eabfaf24021f4ed

最终的优化结果为复位芯片后 250ms 进入 repl 接口,这就保证了 MaixPy IDE 连接可以在 2s 内完成,在这之前需要等待 10 秒才能完成连接,而使用双核就是为了不想在底层代码引入 RTOS 的代码依赖。

双核要如何使用?

K210 的双核使用相关的 API 示范在 bsp 的 kendryte-standalone-sdk/lib/bsp/entry_user.ckendryte-standalone-sdk/lib/bsp/include/entry.h

我们看一下的双核接口调用的方法,且不说初始化,我们可以看如下代码:


typedef int (*dual_func_t)(int);
corelock_t lock;
volatile dual_func_t dual_func = 0;
void *arg_list[16];

void core1_task(void *arg)
{
    while (1)
    {
        if (dual_func)
        { //corelock_lock(&lock);
            (*dual_func)(1);
            dual_func = 0;
            //corelock_unlock(&lock);
        }

        //usleep(1);
    }
}
int core1_function(void *ctx)
{
    // vTaskStartScheduler();
    core1_task(NULL);
    return 0;
}

使用的方法很简单,只需要这样一行。

dual_func = sd_preload; // int sd_preload(int core)

当然,这样看起来很草率,如果封装一下会更好看。

不过这样修改运行后 MaixPy 就变成白屏了,这是为什么呢?

使用双核出现了问题?

通常这是因为双核的使用上在使用同一个资源的时候,出现了冲突,如 MaixPy 启动后就会使用 lcd.display 进行 lcd_draw_picture 绘图操作,那么它做了什么呢?

这是由于 lcd 在 K210 上的绘图前需要对缓冲区进行翻转的,因此可以在这做一层简单的两路重复操作的优化。

    g_pixs_draw_pic_half_size = g_pixs_draw_pic_size/2;
    g_pixs_draw_pic_half_size = (g_pixs_draw_pic_half_size%2) ? (g_pixs_draw_pic_half_size+1) : g_pixs_draw_pic_half_size;
    g_pixs_draw_pic = p+g_pixs_draw_pic_half_size;

    dual_func = swap_pixs_half; // 注册函数

    for(i=0; i< g_pixs_draw_pic_half_size; i+=2)
    {
        #if LCD_SWAP_COLOR_BYTES
            g_lcd_display_buff[i] = SWAP_16(*(p+1));
            g_lcd_display_buff[i+1] = SWAP_16(*(p));
        #else
            g_lcd_display_buff[i] = *(p+1);
            g_lcd_display_buff[i+1] = *p;
        #endif
        p+=2;
    }

    while(dual_func){} // 等待注册的函数执行完成

这也就是为什么启动后会白屏,因为它上电后双核挂载 SD 卡的期间进行双核加速的绘图就锁死了,那么怎么办呢?有两个方法分别是 信号量 和 临界区 的方式去让资源互斥,虽然最终我选择了信号量的方式,但也会提及临界区的原子操作进行说明。

加入一个 maixpy_sdcard_loading 的变量,可装饰为 volatile 变量(但我没有这样使用它),设置 volatile 保证了永远都是读取变量的实时值,也就避开变量缓存的情况,此时绘图函数只需要保证在 SD 卡挂载释放双核操作才可以使用双核加速,问题得到解决。

        if (maixpy_sdcard_loading) {
            for(i=0; i< g_pixs_draw_pic_size; i+=2)
            {
                #if LCD_SWAP_COLOR_BYTES
                    g_lcd_display_buff[i] = SWAP_16(*(p+1));
                    g_lcd_display_buff[i+1] = SWAP_16(*(p));
                #else
                    g_lcd_display_buff[i] = *(p+1);
                    g_lcd_display_buff[i+1] = *p;
                #endif
                p+=2;
            }
        } else {

但这样做是不优雅,且有些乱来的操作,如果不是 LCD 调用双核呢?其他的模块难道也要等它?

所以想要真正解决这个问题,最好就是封装在资源的访问操作之间,进行临界区的加锁形成资源互斥的形式,也就是所谓的线程安全函数,最好保证函数均可分时复用退出,否则和单核无异,不然就是新一代 MTK 8核围观传说 XD 。

原子操作要如何使用?

双核中需要的原子操作,有类似于临界区的 lock 和 unlock 的实现,具体的实现可以继续往深处查看,但本质不变,我们可以从 BSP 中获取这个定义。

我们在这个头文件 kendryte-standalone-sdk/lib/bsp/include/atomic.h 中获取它的使用方法如下。


#include "atomic.h"

spinlock_t lock = SPINLOCK_INIT;

int set_call_back() {
      spinlock_lock(&lock);

      // your operator

      spinlock_unlock(&lock);
}

实际上这是很基础的东西,在 BSP 这里提供了自旋锁 spinlock 的实现,并加入了 CAS 的 try_lock 的方式,形成如下原子操作的雏形。

  • 试图获取锁,如果失败则退出,确保了双核在并行执行时调取互斥资源的时候不会冲突。
      spinlock_lock(&lock);
      // your operator
      spinlock_unlock(&lock);
  • 试图设置变量到目标值,如果变量持续不满足则失败,类似 CAS 操作。
    int var = 0;
    atomic_add(&(var), 1);

实际上使用起来并不困难,取决于自己的场景需求,如果想要在 MicroPython 层面上开放该函数接口,恐怕还需要考虑,尤其是不熟悉的开发者调用,系统随时可能崩溃。

后记

在没有 RTOS 的时候,也并非没有多线程的方法,如使用一些定时器中断、外部触发中断等接口,或者像双核这类硬件提供的资源,也是可以达到目的。

不过代码并不会因为使用多线程而提高性能,想要用好多线程,应该要在思维上一定要保持状态机多路并行的方式,用异步并行的方式来思考问题,如在硬件中存在多和物理世界中不断循环的执行单元,而你要做的就是等待这个单元的状态发生改变再进行下一段操作,所以代码里的设计思维可以如下:


def loop():

      if state si 0:
            pass
      if state is 1:
            pass

保持住状态机的思维方式,让代码都可以分时执行,那么在接入 RTOS 的时候代码架构上也不会发生任何改变,只需要注意分配到各个函数在整个芯片中执行周期的比例即可。

这次的内容很基础,所以就到这里吧。

junhuanchen 2020年10月5日

这次为了不引入 RTOS 环境,借助双核操作跳过了 SD 卡的阻塞运行,使得 MaixPy 进入 MPY 的 REPL 快了从而执行了对 SD 卡的访问,导致了 SD 卡的读写操作冲突,由于硬件挂载设备的通信需要时间,所以一旦出现 SPI SDCard 不稳定,那么上层就得不到 SD 卡的路径进行访问。
在发现 SPI SDCard 工作不稳定的情况下,通过打开日志的 printf 后可以克服,这就说明是时序问题,做嵌入式软件需要对硬件的特性十分敏感,硬件电路不一定会输出我们想要的结果,所以我们必须能够量测到这样的现象,但实在量测不到怎么办?我们可以通过逻辑上的思考来想象代码的执行过程,这个内容想在讲解 ESP8285 实现软串口的芯片内部接收实现的时候,如何才能有效的结合硬件特性,让代码在不确定的环境中符合预期的工作。

posted @ 2020-10-05 19:13  Juwan  阅读(1222)  评论(0编辑  收藏  举报