傻孩子(GorgonMeducer) · 2022年08月15日 · 北京市

【例说Arm-2D界面设计】“手撸GUI”的利器——场景播放器

image.png

【说在前面的话】


在前面的文章《【喂到嘴边了的模块】准备徒手撸GUI?用Arm-2D三分钟就够了》中,我们介绍了如何借助cmsis-pack快速的在MDK中部署arm-2d

在过去的一段时间内,想必很多人都完成了部署,看到了下面的画面吧?

image.png

如果还没有,推荐先跟着上一篇文章的手把手图文教程——完成基本的部署吧。本文将在此基础上继续为您介绍如何使用arm-2d来简化我们手撸GUI的过程。

为了避免让大家产生疑惑,这里我们需要再次明确一下我们所要面对的开发环境:

  • 资源相对紧张的MCU,无法负担起传统的嵌入式GUI(比如以体积“小巧”著称的LVGL):

    • Flash <= 64K,或者
    • 应用本身已经占用了大量Flash空间,留给GUI的空间非常有限
    • SRAM <= 16K
  • 需要实现的GUI界面较为简单(这点在随后会详细介绍)
  • 帧率要求较低(传说中的8帧不卡、9帧流畅、10帧电竞)

【基于面板的界面设计】


从用户的角度来说,如果一个嵌入式产品带了彩屏,很自然的就会希望它能提供像智能手机(或平板设备)一样的操作体验——但从开发者的角度来说,用户的这一期望往往会被错误的理解为:用户希望嵌入式产品上的图形界面能像手机那样支持“这样或那样”的滑动、滚动效果——如果能做到当然最好,但其实这并不是这些“类智能手机界面”设计的核心。

让我把话挑明了吧——流畅的滑动只是添料,甚至是可以完全丢弃的——真正核心的是一套与传统Windows图形界面设计完全不同的理念。关于这套设计理念,有一套叫做“人本界面”的设计方法论作为支撑,感兴趣的小伙伴可以在豆瓣上搜索同名的图书。

就本文要讨论的内容来说,我们可以简单的关注以下的一些要点:

  • 智能设备的界面强调“简洁”、并希望“让用户的注意力一次只集中在一件简单的事物上”。

基于这一原则,又派生出了如下的特点:

  • 与Windows不同,智能设备的界面很少(或者极力避免)窗口重叠
  • 界面的基本单位不是“窗体(Window)”,而是以整个屏幕为基本单位的“面板(Panel)”
  • 每个面板的内容都尽可能简单、通过留白的方式强调那些需要用户注意的内容;
  • 每个面板的功能都尽可能单一:

    • 一般避免在同一个面板中挤进多个不太相关的功能
    • 相关的内容,如果能够放得下,且美观,则可以有主次的布置在同一个面板中以减少用户切换面板带来的不便;
    • 如果相关的内容如果无法在同一个面板中展示,则一定会添加快捷方式方便用户快速进行面板的切换;
  • 面板间的切换方式以大家熟悉的PPT页面切换方式类似

    • 对滑动切换来说,要么不做,要做就要“丝滑”(差不多30FPS),否则会给用户带来“卡顿”的不适感
    • 完全没有动画的切换往往会给用户“设备反应迅速”的错觉,对负担不起高帧率的嵌入式设备来说,反而是最好的选择

仔细回想一下,身边的智能设备,是不是都基本满足上述特点?——其实我们熟悉的手机和平板也是如此。

image.png

基于上述原则,我们甚至可以总结出一套简单有效的“嵌入式界面设计八股”:

  • 用户界面分成三个部分:状态面板导航面板功能面板
  • 状态面板又叫待机面板,用于显示状态信息(比如温度、时间、产品Logo、产品当前状态等等)。

    • 通常在待机界面上按下任意键(或者进行任意触摸)进入导航面板
    • 一般用户超过一段时间没有与界面进行交互后会自动进入状态面板,所以状态面板有时候又叫待机面板
  • 导航面板:也就是大家常说的菜单

    • 一般导航面板以图标、列表或者按钮的形式存在,
    • 一般避免超出屏幕范围的内容,最好做到让用户对所有选项“尽收眼底”
    • 导航面板可以通过子面板的形式实现多级菜单,从而简化开发
  • 功能面板:实现具体功能的面板,一般由导航面板进入

    • 每个面板的功能都尽可能单一,比如专门设置温度、专门设置时间等等
    • 相关的导航面板之间可以通过类似左右箭头(或者底部导航快捷按钮)的机制进行快捷切换

image.png

【什么是场景(scene)】


“场景(scene)”是arm-2d为“手撸GUI”的用户引入的一个概念,通过配套的“场景播放器(scene player)”,极大的简化了基于面板的界面开发。

一般来说,一个简单的面板用一个场景就可以搞定;而稍微复杂点的面板则可以通过多个场景(以及基于状态机的场景切换)来搞定——总的原则就是,无论多复杂的面板,都可以拆分成一个个简单的场景来分而治之

也许你已经注意到了:原本面板本身就已经很简单了,那么所谓“复杂的面板”根据状态机拆分成多个场景后是不是更加简单了?——是的,每个场景的功能都是极其单一和简单的——极大的简化了每个场景的实现难度

image.png

举个例子:有个面板的功能是设置温度,当超过某一特定值后,需要弹出一个窗口提醒用户当前设置值有某些注意事项。这样的面板在设计时就可以拆分成两个场景:1)一个正常的数值设置场景,实现一个类似滑条的功能让用户设置温度;2)一个专门的场景来提示用户注意事项——通过这样的安排,每个场景都可以非常单一

再比如:某个面板的用来设置多个相关的选项,并且当用户开启某个开关后,会出现一些隐藏选项(或者原本不可设置的选项变成可选)。此时,就可以根据这个开关的状态,引入两个场景:一个对应开关关闭时的面板,一个对应开关开启时的面板——总之,面板拆的越细致,每个场景的设计就越简单

【场景(scene)的数据结构和构成】


场景在arm-2d中以类arm_2d_scene_t 来描述:


/*!
 * \brief a class for describing scenes which are the combination of a
 *        background and a foreground with a dirty-region-list support
 * 
 */
typedef struct arm_2d_scene_t arm_2d_scene_t;
struct arm_2d_scene_t {
    arm_2d_scene_t *ptNext;                                                     //!< next scene
    arm_2d_scene_player_t *ptPlayer;                                            //!< points to the host scene player
    arm_2d_region_list_item_t       *ptDirtyRegion;                             //!< dirty region list for the foreground 
    arm_2d_helper_draw_handler_t    *fnBackground;                              //!< the function pointer for the background 
    arm_2d_helper_draw_handler_t    *fnScene;                                   //!< the function pointer for the foreground
    void (*fnOnBGStart)(arm_2d_scene_t *ptThis);                                //!< on-start-drawing-background event handler
    void (*fnOnBGComplete)(arm_2d_scene_t *ptThis);                             //!< on-complete-drawing-background event handler
    void (*fnOnFrameStart)(arm_2d_scene_t *ptThis);                             //!< on-frame-start event handler
    void (*fnOnFrameCPL)(arm_2d_scene_t *ptThis);                               //!< on-frame-complete event handler

    /*!
     * \note We use fnDepose to free the resources
     */
    void (*fnDepose)(arm_2d_scene_t *ptThis);                                   //!< on-scene-depose event handler
    struct {
        uint8_t bOnSwitchingIgnoreBG    : 1;                                    //!< ignore background during switching period
        uint8_t bOnSwitchingIgnoreScene : 1;                                    //!< ignore forground during switching period
    };
};

其数据结构并不复杂。

image.png

数据结构的主体是这两个指针:

  • fnScene:指向一个由用户提供的绘图函数:

    • 绘制一个场景中所有的内容;或者
    • 当场景中存在“不会变化且不会被覆盖的背景”和“少数”内容会发生变化的前景时,专门用于绘制前景——此时就需要通过ptDirtyRegion来指向描述前景变化区域的脏矩阵(Dirty Region List)。
  • fnBackground:指向一个由用户提供的绘图函数,专门绘制一个场景中那些“只需要绘制一次”且“未来不会被前景覆盖或者变化”的内容,最典型的就是绘制场景中的背景图片;

需要特别说明的是:

  • fnBackground只会在绘制每个场景的第一帧时调用;
  • 随后的每一帧就只会调用fnScene
  • fnBackground 会绘制整个屏幕;
  • 脏矩阵(ptDirtyRegion)只对fnScene有效;
  • 当ptDirtyRegion 为 NULL时,fnScene也是绘制整个屏幕

    • 这意味着,当ptDirtyRegionNULL时,fnBackground 绘制的内容会 100% 被覆盖掉——也就是说完全没用。这意味着:
    • 当且仅当我们指定了有效的脏矩阵时,fnBackground 才是实际有意义的

如果你对“背景”和“前景”的分工感到似懂非懂,不妨看下面这个例子:

image.png

在这个场景中:

  • 作为背景的狗头实际上不会发生变化,因此我们只需在fnBackground所指向的绘图函数中绘制即可;
  • 动态进度条由于其内容一直在变化,因此需要在fnScene所指向的绘图函数中“配合脏矩阵”进行重复绘制。

为了方便应用开发,arm_2d_scene_t提供了一系列事件处理程序接口(回调函数),它们与背景、场景的绘制关系如下:

image.png

可以看到,这里的事件处理顺序并不复杂,大家可以根据实际的应用需求各取所需。

【场景播放器(scene player)的本质是什么】


场景播放器的本质是一个针对场景(scene)的队列(FIFO):

image.png

  • 用户可以预先生成多个场景,并通过函数arm_2d_scene_player_append_scenes压入队列中;
  • 队列的头部就是当前生效的场景;
  • 用户可以在任意时刻通过函数arm_2d_scene_player_switch_to_next_scene来安全的触发场景切换,

    • 所谓的场景切换就是丢弃队列当前的头部场景——换成下一个;
  • 场景切换后,被丢弃的场景会调用fnDepose ,用户可以利用这个函数为对应场景“擦屁股”

    • 比如,假设一个场景(arm_2d_scene_t)对象本身就是动态分配的(从malloc中分配),那么就可以通过 fnDepose 方法来将内存释放掉(比如调用free函数)。
  • 场景播放器提供了 arm_2d_scene_player_flush_fifo方法,它会清空整个队列。

    • 被清空出去的场景都会被依次调用fnDepose,因此不用担心内存泄露的问题。
  • 场景切换是支持特效的,比如:淡入淡出、滑动和擦除等等

image.png

【用场景开发也太简单了8!】


前面洋洋洒洒的做了这么多理论铺垫,也许会让你对scene的使用产生了“非常复杂”的错觉或者担忧,但实际情况却相反:借助cmsis-packRTE的帮助,创建scene几乎只要点几下鼠标就可以搞定,而且立即就可以使用。

假设你已经根据《【喂到嘴边了的模块】准备徒手撸GUI?用Arm-2D三分钟就够了》的描述,完成了 arm-2d的部署,并且成功的加入了一个 Display Adapter,此时我们应该能看到这样的效果:

image.png

此时,打开RTE,展开Acceleration后在Arm-2D Helper中找到Scene

image.png

Scene的右边,我们可以通过“增加数值”的方式向工程中添加指定数量的场景。单击确定后,对应数量的场景模板会加入到工程管理器中:

image.png

这里的arm_2d_scene_0.harm_2d_scene_0.c分别对应我们新加入的场景的头文件和源代码。

打开 main.c,加入对场景的头文件引用:

#include "arm_2d_scene_0.h"

其实,所谓的Display Adapter就是场景播放器(arm_2d_scene_player_t):


ARM_NOINIT
extern
arm_2d_scene_player_t DISP0_ADAPTER;

在初始化完Display Adapter后,我们调用场景的初始化函数arm_2d_scene0_init()——将它们加入指定的场景播放器队列中:


#include "arm_2d_scene_0.h"
...
int main (void) 
{
    arm_irq_safe {
        arm_2d_init();
    }         
 
    disp_adapter0_init();

    arm_2d_scene0_init(&DISP0_ADAPTER);
    
    while(1) {
        disp_adapter0_task();
    }
    
 }

调用函数arm_2d_scene_player_switch_to_next_scene()来切换到我们新加入的场景中:



#include "arm_2d_scene_0.h"
...
int main (void) 
{
    arm_irq_safe {
        arm_2d_init();
    }         
 
    disp_adapter0_init();

    arm_2d_scene0_init(&DISP0_ADAPTER);
    arm_2d_scene_player_switch_to_next_scene(&DISP0_ADAPTER);
    
    while(1) {
        disp_adapter0_task();
    }
    
 }

为了方便观察效果,不妨设置一个场景切换效果:


#include "arm_2d_scene_0.h"
...
int main (void) 
{
    arm_irq_safe {
        arm_2d_init();
    }         
 
    disp_adapter0_init();

    /* 初始化场景 scene0,并将其加入到场景播放器 DISP0_ADAPTER 中 */
    arm_2d_scene0_init(&DISP0_ADAPTER);
    
    /* 设置切换特效为 淡入淡出(白色) */
    arm_2d_scene_player_set_switching_mode( 
        &DISP0_ADAPTER,
        ARM_2D_SCENE_SWITCH_MODE_FADE_WHITE);
    /* 设置切换持续时间为 3000ms */
    arm_2d_scene_player_set_switching_period(
        &DISP0_ADAPTER, 
        3000);
    
    /* 申请切换到新加入的场景中 */
    arm_2d_scene_player_switch_to_next_scene(&DISP0_ADAPTER);
    
    while(1) {
        disp_adapter0_task();
    }
    
 }

编译后运行,可以看到类似如下的效果:

640.gif

可以看到,场景播放器从默认的“转圈圈”界面以“渐明渐暗”的形式切换到了我们的新场景scene0中。

image.png

细心的小伙伴可能很快就注意到了一个奇怪的地方:为啥很快 scene0 又消失在白屏中了呢?要解答这一疑问不妨打开arm_2d_scene_0.c一探究竟。注意到时间处理函数__on_scene0_frame_complete


static void __on_scene0_frame_complete(arm_2d_scene_t *ptScene)
{
    ARM_2D_UNUSED(ptScene);
    
    /* switch to next scene after 3s */
    if (arm_2d_helper_is_time_out(3000)) {
        arm_2d_scene_player_switch_to_next_scene(ptScene->ptPlayer);
    }
}

结合初始化代码:


void arm_2d_scene0_init(arm_2d_scene_player_t *ptDispAdapter)
{
    ...
    arm_2d_scene_t *ptScene = (arm_2d_scene_t *)malloc(sizeof(arm_2d_scene_t));
    assert(NULL != ptScene);
    
    *ptScene = (arm_2d_scene_t){
        .fnBackground   = NULL,
        .fnScene        = &__pfb_draw_scene0_handler,
        .ptDirtyRegion  = (arm_2d_region_list_item_t *)s_tDirtyRegions,
        
        /* Please uncommon the callbacks if you need them
         */
        //.fnOnBGStart    = &__on_scene0_background_start,
        //.fnOnBGComplete = &__on_scene0_background_complete,
        //.fnOnFrameStart = &__on_scene0_frame_start,
        .fnOnFrameCPL   = &__on_scene0_frame_complete,
        .fnDepose       = &__on_scene0_depose,
    };
    arm_2d_scene_player_append_scenes( ptDispAdapter, ptScene, 1);
}

根据前面事件调用关系流程图,我们容易发现:

  • __on_scene0_frame_complete 在场景的每一帧绘制完成后都被调用一次;
  • 函数arm_2d_helper_is_time_out() 会在超过3000毫秒后返回true,并申请场景切换
  • 由于场景播放器的队列中已经没有下一个场景,因此以白屏告终。

破案了!!!

image.png

此外,观察arm_2d_scene_0.c可以发现,该模板已经为我们打好了所有的基础,并添加了所有基础的代码:

  • 我们在__pfb_draw_scene0_background_handler中绘制背景:

static
IMPL_PFB_ON_DRAW(__pfb_draw_scene0_background_handler)
{
    ARM_2D_UNUSED(pTarget);
    ARM_2D_UNUSED(bIsNewFrame);

    /*-----------------------draw back ground begin-----------------------*/



    /*-----------------------draw back ground end  -----------------------*/
    arm_2d_op_wait_async(NULL);

    return arm_fsm_rt_cpl;
}
  • 我们在 __pfb_draw_scene0_handler中绘制场景,且其中已经包含了一个用Mask打印cmsis-logo和左上角红色字符串"scene0"的例子代码:

static
IMPL_PFB_ON_DRAW(__pfb_draw_scene0_handler)
{
    ARM_2D_UNUSED(pTarget);
    ARM_2D_UNUSED(ptTile);
    ARM_2D_UNUSED(bIsNewFrame);
    
    /*-----------------------draw the foreground begin-----------------------*/
    
    /* following code is just a demo, you can remove them */
    
    arm_2d_fill_colour(ptTile, NULL, GLCD_COLOR_WHITE);

#if 0
    /* draw the cmsis logo in the centre of the screen */
    arm_2d_align_centre(ptTile->tRegion, c_tileCMSISLogo.tRegion.tSize) {
        arm_2d_tile_copy_with_src_mask( &c_tileCMSISLogo,
                                        &c_tileCMSISLogoMask,
                                        ptTile,
                                        &__centre_region,
                                        ARM_2D_CP_MODE_COPY);
    }
#else
    /* draw the cmsis logo using mask in the centre of the screen */
    arm_2d_align_centre(ptTile->tRegion, c_tileCMSISLogo.tRegion.tSize) {
        arm_2d_fill_colour_with_mask_and_opacity(   
                                            ptTile, 
                                            &__centre_region, 
                                            &c_tileCMSISLogoMask, 
                                            (__arm_2d_color_t){GLCD_COLOR_BLACK},
                                            64);
    }
#endif

    /* draw text at the top-left corner */
    arm_lcd_text_set_target_framebuffer((arm_2d_tile_t *)ptTile);
    arm_lcd_text_set_colour(GLCD_COLOR_RED, GLCD_COLOR_WHITE);
    arm_lcd_text_location(0,0);
    arm_lcd_puts("Scene 0");

    /*-----------------------draw the foreground end  -----------------------*/
    arm_2d_op_wait_async(NULL);

    return arm_fsm_rt_cpl;
}
  • 初始化代码中已经包含了脏矩阵的范例和如何开启其它事件处理程序的方法:

void arm_2d_scene0_init(arm_2d_scene_player_t *ptDispAdapter)
{
    assert(NULL != ptDispAdapter);

    /*! define dirty regions */
    IMPL_ARM_2D_REGION_LIST(s_tDirtyRegions, static)

        /* a dirty region to be specified at runtime*/
        ADD_REGION_TO_LIST(s_tDirtyRegions,
            0  /* initialize at runtime later */
        ),
        
        /* add the last region:
         * it is the top left corner for text display 
         */
        ADD_LAST_REGION_TO_LIST(s_tDirtyRegions,
            .tLocation = {
                .iX = 0,
                .iY = 0,
            },
            .tSize = {
                .iWidth = 320,
                .iHeight = 8,
            },
        ),

    END_IMPL_ARM_2D_REGION_LIST()
    
    /* get the screen region */
    arm_2d_region_t tScreen
        = arm_2d_helper_pfb_get_display_area(
            &ptDispAdapter->use_as__arm_2d_helper_pfb_t);
    
    /* initialise dirty region 0 at runtime
     * this demo shows that we create a region in the centre of a screen(320*240)
     * for a image stored in the tile c_tileCMSISLogoMask
     */
    s_tDirtyRegions[0].tRegion.tLocation = (arm_2d_location_t){
        .iX = ((tScreen.tSize.iWidth - c_tileCMSISLogoMask.tRegion.tSize.iWidth) >> 1),
        .iY = ((tScreen.tSize.iHeight - c_tileCMSISLogoMask.tRegion.tSize.iHeight) >> 1),
    };
    s_tDirtyRegions[0].tRegion.tSize = c_tileCMSISLogoMask.tRegion.tSize;
    
    
    arm_2d_scene_t *ptScene = (arm_2d_scene_t *)malloc(sizeof(arm_2d_scene_t));
    assert(NULL != ptScene);
    
    *ptScene = (arm_2d_scene_t){
        .fnBackground   = NULL,
        .fnScene        = &__pfb_draw_scene0_handler,
        .ptDirtyRegion  = (arm_2d_region_list_item_t *)s_tDirtyRegions,
        
        /* Please uncommon the callbacks if you need them
         */
        //.fnOnBGStart    = &__on_scene0_background_start,
        //.fnOnBGComplete = &__on_scene0_background_complete,
        //.fnOnFrameStart = &__on_scene0_frame_start,
        .fnOnFrameCPL   = &__on_scene0_frame_complete,
        .fnDepose       = &__on_scene0_depose,
    };
    arm_2d_scene_player_append_scenes( ptDispAdapter, ptScene, 1);
}

是不是非常贴心呢?

image.png

【一些值得注意的细节】


细节一:模板中使用了动态的方式来生成场景

虽然不是必须的,但场景的模板中使用了动态的方式来生成场景:


void arm_2d_scene0_init(arm_2d_scene_player_t *ptDispAdapter)
{
    assert(NULL != ptDispAdapter);
    ...

    arm_2d_scene_t *ptScene = (arm_2d_scene_t *)malloc(sizeof(arm_2d_scene_t));
    assert(NULL != ptScene);
    
    *ptScene = (arm_2d_scene_t){
        ...
        .fnDepose       = &__on_scene0_depose,
    };
    arm_2d_scene_player_append_scenes( ptDispAdapter, ptScene, 1);
}

并在场景的__on_scene0_depose函数中(也就是场景废弃事件处理程序 fnDepose里)进行了释放:


static void __on_scene0_depose(arm_2d_scene_t *ptScene)
{
    ptScene->ptPlayer = NULL;
    free(ptScene);
}

细节二:场景切换的多种模式

现阶段,场景播放器为用户提供多种切换特效:

typedef enum {
    /* valid switching visual effects begin */
    ARM_2D_SCENE_SWITCH_MODE_NONE           = 0,                                //!< no switching visual effect
    ARM_2D_SCENE_SWITCH_MODE_USER           = 1,                                //!< user defined switching visual effect
    ARM_2D_SCENE_SWITCH_MODE_FADE_WHITE     = 2,                                //!< fade in fade out (white)
    ARM_2D_SCENE_SWITCH_MODE_FADE_BLACK     = 3,                                //!< fade in fade out (black)
    ARM_2D_SCENE_SWITCH_MODE_SLIDE_LEFT     = 4,                                //!< slide left
    ARM_2D_SCENE_SWITCH_MODE_SLIDE_RIGHT,                                       //!< slide right
    ARM_2D_SCENE_SWITCH_MODE_SLIDE_UP,                                          //!< slide up
    ARM_2D_SCENE_SWITCH_MODE_SLIDE_DOWN,                                        //!< slide down
    ARM_2D_SCENE_SWITCH_MODE_ERASE_LEFT     = 8,                                //!< erase to the right
    ARM_2D_SCENE_SWITCH_MODE_ERASE_RIGHT,                                       //!< erase to the left
    ARM_2D_SCENE_SWITCH_MODE_ERASE_UP,                                          //!< erase to the top
    ARM_2D_SCENE_SWITCH_MODE_ERASE_DOWN,                                        //!< erase to the bottom
    ...
};

这其中就包含了大家常见的:

  • 淡入淡出:

    • 以白色为过渡:ARM_2D_SCENE_SWITCH_MODE_FADE_WHITE
    • 以黑色为过渡:ARM_2D_SCENE_SWITCH_MODE_FADE_BLACK
  • 滑动效果:LEFT / RIGHT / UP / DOWN
  • 擦除效果:LEFT / RIGHT / UP / DOWN

用户可以通过函数 arm_2d_scene_player_set_switching_mode() 来指定切换模式。

此外,用户还可以通过函数arm_2d_scene_player_set_switching_period() 来指定“整个切换过程需要多长时间内完成”——这意味着,如果具体的硬件带宽不足(刷新率不够),场景播放器会以跳帧的方式来满足时间要求。

【说在后面的话】


我们在文章的开头简单介绍了现代嵌入式GUI设计以“面板”为基本单位的设计模式,并以此引入了场景(scene)这个概念。

相信借助arm-2d场景播放器(scene player),尤其是在“滑动场景切换特效”的帮助下,在资源受限的环境中,“手撸GUI”的难度将大大降低。

Arm-2D作为Cortex-M处理器的“显卡驱动”,不仅能为已有的GUI协议栈(比如LVGL)提供底层加速,还为资源受限的MCU实现GUI提供了一种“基于面板(Panel)开发”的解决方案。

在下一篇文章中,我们将着重介绍 Arm-2D 是如何为缺乏XIP(一种将外部SPI Flash中的内容映射到4G地址空间中的外设) 的处理器提供“图片资源载入”的简便方案的。敬请期待。

原文:裸机思维
作者:GorgonMeducer 傻孩子

专栏推荐文章

如果你喜欢我的思维,欢迎订阅裸机思维欢迎添加极术小姐姐微信(id:aijishu20)加入技术交流群,请备注研究方向。
推荐阅读
关注数
1466
内容数
108
探讨嵌入式系统开发的相关思维、方法、技巧。
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息