\\参加RT-Thread线上活动,请移步文末
本篇内容比较简单,但却很繁琐,篇幅也很长,毕竟是囊括了整个操作系统的生命周期。这篇文章的目的是作为后续设计多任务开发的铺垫,后续会单独再抽出一篇分析任务的相关知识。另外本篇文章以单核MCU为背景,并且以最新的3.1.xLTS版本源码进行分析。主要内容目录如下:
基于bsp/stm32/stm32f103-mini-system为背景
Cortex-M3的堆栈基础概念
C语言main函数和rt-thread的main
rt-thread操作系统的传统初始化与自动初始化组件
任务是怎样运行起来的
Idle任务与新的构想
基于bsp/stm32/stm32f103-mini-system的开机介绍
关于体系结构的知识这里不做过多的介绍,因为这些知识要讲清楚的话足以写出一本大部头的书出来。不过会简单介绍一些必要的东西。
Stm32f103单片机是cortex-m3内核,在cortex-m3内核中使用双堆栈psp和msp,模式分为线程模式和handler模式,权限级别分为非特权级别和特权级别(现在只需要知道这么多就行了),handler模式就是当处理发生中断的时候自动进入的模式,其handler模式永远为特权级。
上电开机最开始运行的是MCU内部的ROM部分,这部分代表我们通常看不到,其通常是对芯片进行必要的初始化,比如FLASH和RAM的时钟初始化等,然后跳转到用户flash区域运行用户代码。在STM32中用户flash地址从0x08000000开始。我们写的代码都是从这里开始运行的。其次由于cortexM规定其用户FLASH区域的最前面必须是一张中断向量表。所以也就是说STM32的0x08000000开始是一张中断向量表,这是必须的也是默认的,当然在之后还可以重映射其它地方的向量表。这张向量表中的第一项是一个栈地址,第二项复位向量地址。下面贴一段向量表部分代码(摘录自startup_stm32f103xb.s):
向👉滑动可查看全部>>
__Vectors DCD __initial_sp ; Topof Stack
DCD Reset_Handler ; Reset Handler
DCD NMI_Handler ; NMIHandler
DCD HardFault_Handler ; Hard Fault Handler
DCD MemManage_Handler ; MPU Fault Handler
DCD BusFault_Handler ; Bus Fault Handler
DCD UsageFault_Handler ; Usage Fault Handler
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD SVC_Handler ; SVCall Handler
DCD DebugMon_Handler ; Debug Monitor Handler
DCD 0 ; Reserved
DCD PendSV_Handler ; PendSV Handler
DCD SysTick_Handler ; SysTick Handler
另外需要注意的是开机后会自动进入复位异常,通常我们叫上电复位过程,不过意外的是上电复位处理的模式是特权级线程模式。在特权模式下堆栈指针将使用MSP,非特权模式下可以被切换到PSP。RT-Thread操作系统就是这么做的。所以回过头来看,中断向量表第一项指定了MSP的栈起始地址,并被自动加载到MSP,第二项指定了复位向量地址,也被自动加载到PC并运行。这样一来开机后我们能通过debug看到PC指针最先指向复位向量的第一条指令上。我们看一下stm32f103在armcc编译器上的复位向量代码:
向👉滑动可查看全部>>
; Reset handler
Reset_Handler PROC
EXPORT Reset_Handler [WEAK]
IMPORT __main
IMPORT SystemInit
LDR R0, =SystemInit
BLX R0
LDR R0, =__main
BX R0
ENDP
这是一段汇编代码,其完成两件事,第一件事调用systemInit函数完成一些初始化,第二件事跳转到__main函数。其中systemInit函数我们是可以找到并可以修改的一个C语言实现的函数(暂时不讨论,有兴趣的可以看system_stm32f1xx.c)。而这个__main就牛逼了,这既不是我们自己写的C语言的main也看不到它在哪里实现的。但是现在进入__main后它就是会跑到你最终用C语言写的main。这个__main的来龙去脉稍后会在第三部分分析。
Cortex-M3的堆栈基础概念
在Cortex-M3的处理器内核上堆栈指针分为PSP和MSP。handler模式下总是使用MSP,线程模式可以通过CONTROL寄存器来配置(修改的时候必须处于特权模式才可以)。
之所以需要这样设计就是为了将普通软件和系统软件通过权限隔离开,避免普通用户权限操作系统关键资源带来安全风险。当我们使用带有操作系统的环境进行开发时,操作系统就会将关键操作例如任务切换、中断处理等在特权模式操作。而其它的操作都会运行在非特权模式下完成。
操作系统一般都会将必要的操作封装出API接口,以提供给普通软件调用。而这背后的设计思想就是通过触发异常,然后进入特权模式运行异常向量处理程序。而这段异常处理程序早就让操作系统实现了,进而这部分特权操作是操作系统接管处理的。这也就避免用户普通软件去进行不必要的特权操作。例如用户任务想主动放弃CPU从而调用yield,yield将进行任务切换,其中过程大概是“选出另一个任务”->”触发SVC或者Pendsv异常”->进入SVC/Pendsv的handler异常处理程序,此时是特权模式,完成操作后返回到新任务运行。在RT-Thread中进入任务切换是通过触发Pendsv异常。
C语言main函数和RT-Thread的main
前面提到过开机启动最后进入复位向量处运行,最终调用__main就跑到我们外面写的C语言的main函数了。但这并非这么简单,在从__main到我们的main中间还有一系列操作比如初始化堆栈、初始化全局变量区域、初始化C运行时库等,然后再在最后调用用户的main函数。
不过在不同的编译器上这个__main并非时固定的,这里也就armcc是如此,如果是GCC和IAR的话其就不太一样,不过不影响我们分析核心主题。这里仅以借用armcc为例来分析主题中心思想。另外在说明RT-Thread中开启RT_USING_USER_MAIN的时候在ARMCC编译器上还有一个支持挂钩的操作,这种操作一般见于补丁修复的时候。其实现方式是在原有函数的名字前加上$Sub$$前缀就可以将原有函数劫持下来,并通过加上$Sub$$前缀再调用原始函数。具体如下:The followingexample shows how to use $Super$$ and $Sub$$ to insert a callto the function ExtraFunc() before the call to the legacy function foo().
向👉滑动可查看全部>>
extern void ExtraFunc(void);extern void $Super$$foo(void); /* this functionis called instead of the original foo() */void $Sub$$foo(void){ ExtraFunc(); /* does some extra setup work */ $Super$$foo(); /* calls the original foo() function *//* To avoid calling the original foo() function * omit the $Super$$foo(); function call. */}
上例中原本有一个原始函数叫做foo,但是现在通过$Sub$$foo来劫持所有调用foo的地方,自动会调用$Sub$$foo,然后新的¥Sub$$foo里面先调用自己的扩展实现ExtraFunc后,再接着调用原始版本的foo函数,不过调用原始的foo是加了前缀$Super$$的$Super$$foo.
当使用RT-Thread操作系统开启RT_USING_USER_MAIN后就是利用这种骚操作来完成RT-Thread操作系统的初始化过程的。(代码摘录自components.c)
向👉滑动可查看全部>>
extern int $Super$$main(void);/* re-definemain function */int $Sub$$main(void){ rtthread_startup();return 0;}
关于rtthread_startup函数稍后再讲解,不过先接着看下面这个函数:
向👉滑动可查看全部>>
/* the systemmain thread */void main_thread_entry(void*parameter){extern int main(void);extern int $Super$$main(void); /* RT-Thread components initialization*/ rt_components_init(); /* invoke system main function */# defined(__CC_ARM) || defined(__CLANG_ARM) $Super$$main(); /* for ARMCC. */# defined(__ICCARM__) || defined(__GNUC__) main();# }
上面这个函数其实是个小任务,就是完成组件初始化后再跳转到用户main函数的。这个小任务再rtthread_startup中调用rt_application_init时创建的,所以此时rt-thread系统早就以经跑起来了。也就是说当调用rtthread_startup后正常情况就不再会返回到原来的调用地方,接下来会交给系统的调度器去接管,切换运行任务去了。看下面的代码了解rt_application_init:
向👉滑动可查看全部>>
void rt_application_init(void)
{
rt_thread_t tid;
#ifdef RT_USING_HEAP
tid = rt_thread_create("main", main_thread_entry, RT_NULL,
RT_MAIN_THREAD_STACK_SIZE, RT_MAIN_THREAD_PRIORITY, 20);
RT_ASSERT(tid != RT_NULL);
#else
rt_err_t result;
tid = &main_thread;
result = rt_thread_init(tid, "main", main_thread_entry, RT_NULL,
main_stack, sizeof(main_stack), RT_MAIN_THREAD_PRIORITY, 20);
RT_ASSERT(result == RT_EOK);
/* if not define RT_USING_HEAP, using toeliminate the warning */
(void)result;
#endif
rt_thread_startup(tid);
}
至此,关于各种main的子子孙孙以经差不多了解清楚了,其流程大概如下:
ResetHandle->__main->$Sub$$main->(rtthread_startup->rt_application_init->main_thread_entry)->$Super$$main。其中$Super$$main就是我们的用户main函数。如果没有启用RT_USING_USER_MAIN那就简单了,其流程如下:
ResetHandle->__main->main
接下来再接着分析$Sub$$main中调用的rtthread_startup函数。
RT-Thread操作系统的传统初始化与自动初始化组件
这里着重讨论rtthread_startup函数,因为这就是RT-Thread操作系统的入口和初始化流程。不过既然说到rtthread_startup函数了,就不得不一起介绍一下RT-Thread操作系统的自动初始化组件了。
rtthread_startup函数时一个函数调用链,依次调用各个阶段的初始化函数,并在最后启动调度器不在返回。代码摘录自components.c
向👉滑动可查看全部>>
int rtthread_startup(void){ rt_hw_interrupt_disable(); /* board level initialization * NOTE: please initialize heap insideboard initialization. */ rt_hw_board_init(); /* show RT-Thread version */ rt_show_version(); /* timer system initialization */ rt_system_timer_init(); /* scheduler system initialization */ rt_system_scheduler_init();
以上代码我们主要脉络是这样的:先关闭全局中断->初始化硬件板上的资源->打印RT-Thread的LOGO->系统定时器功能初始化->调度器初始化->signal功能初始化->应用程序初始化(这个通常是用来创建用户任务的)->系统软timer任务初始化->系统idle任务初始化->启动调度器,永远不再返回。
这里我们先来说一下为什么要先关闭全局中断,因为在初始化过程中,有可能MCU就有其它的中断和异常触发了,这个时候系统还没有初始化完成,这就势必导致系统出现故障,所以先关闭全局中断,并在启动调度器后再打开。
rt_hw_board_init非常关键,在这个函数里面必须完成一些必须的初始化过程:堆内存系统的初始化和硬件资源模块以及如果开启了自动初始化组件时还需要调用rt_components_board_init完成必要的初始化,这个函数是自动初始化组件的一个接口。(代码摘录自bspstm32librariesHAL_Driversdrv_common.c)
向👉滑动可查看全部>>
RT_WEAK void rt_hw_board_init()
{
#ifdef SCB_EnableICache
/* EnableI-Cache---------------------------------------------------------*/
SCB_EnableICache();
#endif
#ifdef SCB_EnableDCache
/* Enable D-Cache---------------------------------------------------------*/
SCB_EnableDCache();
#endif
/* HAL_Init() function is called at thebeginning of the program */
HAL_Init();
/* System clock initialization */
SystemClock_Config();
rt_hw_systick_init();
/* Heap initialization */
#if defined(RT_USING_HEAP)
rt_system_heap_init((void*)HEAP_BEGIN, (void*)HEAP_END);
#endif
/* Pin driver initialization is open bydefault */
#ifdef RT_USING_PIN
rt_hw_pin_init();
#endif
/* USART driver initialization is openby default */
#ifdef RT_USING_SERIAL
rt_hw_usart_init();
#endif
/* Set the shell console output device*/
#ifdef RT_USING_CONSOLE
rt_console_set_device(RT_CONSOLE_DEVICE_NAME);
#endif
/* Board underlying hardwareinitialization */
#ifdef RT_USING_COMPONENTS_INIT
rt_components_board_init();
#endif
}
然后回到rtthread_startup函数中再看rt_application_init函数,由于我们是用的stm32的BSP,这个bsp系列是使用自动初始化组件和RT_USING_USER_MAIN功能的,所以过程稍微隐蔽一些,先是再rt_application_init中创建了一个小任务,然后再在小任务中调用了rt_components_init,这也是自动初始化组件的接口。如果没有开启自动初始化组件的话,通常我们的用户任务可以在rt_application_init中创建了。也可以像这里的实现一样,先创建一个小任务,然后再在小任务里完成一些初始化和创建用户任务。
然后再回到rthtread_startup中看到有初始化软timer和idle任务的,其中软件timer功能是可以通过裁剪配置选择的,如果打开后就可以在后续创建softtimer。否则所有的timer都会在OS TICK的中断上下文中计时。另外这个idle任务也是系统中必不可上和优先级最低的任务。即使我们启动调度器后没有创建任何用户任务,系统中也有一个idle任务在运行。Idle任务的优先级最低,在此我建议开发人员最好不要将自己的用户任务优先级配置成最低以免和idle竞争时间片,这会给你今后的开发带来不必要的麻烦。关于这个问题,我最后会提出一些新的设计构想。不过这里先要介绍一下idle任务的功能。Idle任务会在系统空闲时被调度运行,所以我们通常在idle任务里做低功耗设计。其次idle任务里还会完成系统资源的回收。例如被删除的任务,被删除的module等。
最后rthtread_startup启动调度器rt_system_scheduler_start开始调度系统的任务,从此就开始运行任务,不再返回。这里又要记住一个概念,在上文提到的PSP和MSP,到目前为止MCU还是使用一开始中断向量表中指定的MSP栈。但是当调度任务后,任务会有自己的栈,且rt-thread系统会将任务的栈切换到PSP栈指针。值得注意的是,这个MSP是全局共享的,所有的中断程序都会使用这个栈空间,所以我们需要根据自己的情况来配置这个MSP栈的空间大小。
接下来我们再来介绍自动初始化组件。RT-Thread中的自动初始化组件思路来自于Linux内核。其实现手段是将需要初始化的函数接口通过链接器指令放在特殊的section中。这个section的概念是当我们程序最终链接成一个image后会形成一个标准格式的文件,其中armcc中叫做ARM ELF。详细的介绍可以查阅官方资料。其中ELF文件就有将代码分成成为section的区域,可以称作段。并且可以指定自己的代码放在指定名称的段中,且可以指定这个section段的ROM地址。这样当我们设计玩初始化接口后,通过链接器的指令以及链接脚本文件将我们的初始化代码放在特定的地方,并且利用命名规则来做到顺序排序。等需要调用初始化的时候可以利用这些section的地址转换成函数指针直接批量循环调用。通常你会再MDK的工程文件中链接器参数中看到这样的指令:--keep *.o(.rti_fn.*),这是为了在链接阶段保证这些自定义段不被删除。同时也可以看出rti_fn就是自动初始化组件的section名字。类似的将函数放置在这些段中的链接器指令如下:(摘录自rtdef.h)
向👉滑动可查看全部>>
/*initialization export */#ifdef RT_USING_COMPONENTS_INITtypedef int (*init_fn_t)(void);#ifdef _MSC_VER/* we do notsupport MS VC++ compiler */#define INIT_EXPORT(fn,level)#else #if RT_DEBUG_INITstruct rt_init_desc {const char* fn_name;const init_fn_t fn; };#define INIT_EXPORT(fn, level) const char __rti_##fn##_name[] =#fn; RT_USED const struct rt_init_desc __rt_init_desc_##fn SECTION( level)= { __rti_##fn##_name, fn};#else#define INIT_EXPORT(fn, level) RT_USED const init_fn_t __rt_init_##fn SECTION( level)= fn#endif#endif#else#define INIT_EXPORT(fn, level)#endif /* board initroutines will be called in board_init() function */#define INIT_BOARD_EXPORT(fn) INIT_EXPORT(fn, ) /*pre/device/component/env/app init routines will be called in init_thread *//* componentspre-initialization (pure software initilization) */#define INIT_PREV_EXPORT(fn) INIT_EXPORT(fn, )/* deviceinitialization */#define INIT_DEVICE_EXPORT(fn) INIT_EXPORT(fn, )/* componentsinitialization (dfs, lwip, ...) */#define INIT_COMPONENT_EXPORT(fn) INIT_EXPORT(fn, )/* environmentinitialization (mount disk, ...) */#define INIT_ENV_EXPORT(fn) INIT_EXPORT(fn, )/* appliationinitialization (rtgui application etc ...) */#define INIT_APP_EXPORT(fn) INIT_EXPORT(fn, )
其中不同的数字代表不同的初始化顺序,可以根据需要来选择。接着如上文提到的两个函数rt_components_board_init和rt_components_init是如何实现的:摘录自components.c
向👉滑动可查看全部>>
#ifdef RT_USING_COMPONENTS_INIT
/*
* Components Initialization will initializesome driver and components as following
* order:
* rti_start --> 0
* BOARD_EXPORT --> 1
* rti_board_end --> 1.end
*
* DEVICE_EXPORT --> 2
* COMPONENT_EXPORT --> 3
* FS_EXPORT --> 4
* ENV_EXPORT --> 5
* APP_EXPORT --> 6
*
* rti_end --> 6.end
*
* These automatically initialization, thedriver or component initial function must
* be defined with:
* INIT_BOARD_EXPORT(fn);
* INIT_DEVICE_EXPORT(fn);
* ...
* INIT_APP_EXPORT(fn);
* etc.
*/
static int rti_start(void)
{
return 0;
}
INIT_EXPORT(rti_start,"0");
static int rti_board_start(void)
{
return 0;
}
INIT_EXPORT(rti_board_start,"0.end");
static int rti_board_end(void)
{
return 0;
}
INIT_EXPORT(rti_board_end,"1.end");
static int rti_end(void)
{
return 0;
}
INIT_EXPORT(rti_end,"6.end");
/**
* RT-Thread Components Initialization forboard
*/
void rt_components_board_init(void)
{
#if RT_DEBUG_INIT
int result;
const struct rt_init_desc *desc;
for (desc = &__rt_init_desc_rti_board_start; desc < &__rt_init_desc_rti_board_end; desc ++)
{
rt_kprintf("initialize %s", desc->fn_name);
result = desc->fn();
rt_kprintf(":%d done", result);
}
#else
const init_fn_t *fn_ptr;
for (fn_ptr = &__rt_init_rti_board_start; fn_ptr < &__rt_init_rti_board_end; fn_ptr++)
{
(*fn_ptr)();
}
#endif
}
/**
* RT-Thread Components Initialization
*/
void rt_components_init(void)
{
#if RT_DEBUG_INIT
int result;
const struct rt_init_desc *desc;
rt_kprintf("do components initialization.");
for (desc = &__rt_init_desc_rti_board_end; desc < &__rt_init_desc_rti_end; desc++)
{
rt_kprintf("initialize %s", desc->fn_name);
result = desc->fn();
rt_kprintf(":%d done", result);
}
#else
const init_fn_t *fn_ptr;
for (fn_ptr = &__rt_init_rti_board_end; fn_ptr < &__rt_init_rti_end; fn_ptr++)
{
(*fn_ptr)();
}
#endif
}
之所以要分开这两个函数就是因为board阶段的初始化比其它普通的组件初始化早,board阶段的初始化通常没什么系统资源依赖。而其它情况下则通常在操作系统以经完成必要的初始化后才能做的初始化才会放在rt_components_init里。
任务是怎样运行起来的
要说明任务是怎么运行起来的,就得知道任务是怎么创建得,其次结合之前写的文章<源码解读·RT-Thread多任务调度算法>就差不多了。那么这里就介绍一下任务的创建。照样用上面的rt_application_init里创建任务的代码来举例:
向👉滑动可查看全部>>
void rt_application_init(void)
{
rt_thread_t tid;
#ifdef RT_USING_HEAP
tid = rt_thread_create("main", main_thread_entry, RT_NULL,
RT_MAIN_THREAD_STACK_SIZE, RT_MAIN_THREAD_PRIORITY, 20);
RT_ASSERT(tid != RT_NULL);
#else
rt_err_t result;
tid = &main_thread;
result =rt_thread_init(tid, "main", main_thread_entry, RT_NULL,
main_stack, sizeof(main_stack),RT_MAIN_THREAD_PRIORITY, 20);
RT_ASSERT(result == RT_EOK);
/* if not define RT_USING_HEAP, using toeliminate the warning */
(void)result;
#endif
rt_thread_startup(tid);
}
首先要说明的是RT-Thread任务创建有两种,一种是动态的,一种是静态的。所谓的动态就是其任务栈自动在堆内存中分配;静态是用户自己指定栈空间,当然通常这个栈来自于用户定义的数组。如上例中当RT_USING_HEAP宏被打开,也就是有堆内存的时候会采用rt_thread_create接口来创建动态资源的任务。当然可以利用rt_thread_init来创建一个静态资源的任务。先来了解一下这两个函数在创建任务时的一些参数:”main”这是任务的名称,任务名称用一个字符串来指定,不是很重要,不过最好能起到一定的说明性,有利于今后调试用。main_thread_entry这是任务的入口函数,所谓的任务就是一个C语言中的函数而已。RT_NULL,这是传给任务入口函数的参数,如果没有就为NULL.因为RT_Thread中的任务原型为:void (*entry)(void*parameter);RT_MAIN_THREAD_STACK_SIZE为任务的栈大小,以字节为单位。RT_MAIN_THREAD_PRIORITY为任务的优先级号。20为任务的时间片大小。其中静态任务中还有tid代表任务的TCB数据结构句柄。main_stack为栈空间起始地址。当用动态创建的方法创建成功后会返回一个任务的TCB任务句柄出来。之后我们利用rt_thread_startup(任务句柄)的形式启动任务即可。例如上例中rt_thread_startup(tid);不过rt_thread_startup函数真正的功能是将任务放置与调度队列中,并值任务状态为ready,由此交给调度器去调度,能不能立马运行取决与调度器的调度。一般情况下,要想任务获得运行必须满足的条件:调度器已经运行,任务以经ready,没有更高优先级任务,没有中断发生。只要条件满足调度器就会调度此任务,做好必要的栈初始化和状态置位,就会切换到任务开始运行。只要任务获得运行就会使用创建任务时指定的栈空间。
不过一般的任务通常是一直运行,持续的服务。形式如下:
向👉滑动可查看全部>>
void task(void *parameter)
{
while (1)
{
// do_work();
}
}
idle任务与新的构想
上面解释过idle任务在rt-thread操作系统中的功能:释放资源、低功耗设计。
关于资源释放通常是任务的析构过程,这就是任务的结束。例如上例中的main_thread_entry任务之所以称为小任务的原因就是它做完事情就结束了。那么可能就会想,既然任务都结束了那么它的资源如何释放呢?比如栈空间,TCB等。这就是idle该干的事情。即使所有的用户任务都结束,最后也会剩下idle任务在运行。如果有必要的话,可以在idle任务中可以通过调用低功耗组件进入低功耗或者干脆调用电源开关控制来关机。
其次idle任务占用了最低优先级。虽然用户任务也可以使用和idle任务相同的优先级,但是并不建议这样做,比如在低功耗设计时就会出问题。另外我个人在思考一个问题,idel任务既然以经在设计之初就明确了其获得运行的条件,那么何不做成无需优先级的任务,唯一的调度决策就是:当调度器没有任务处于ready状态时就切换到idel任务运行。这就无需关注最低优先级被idle霸占的问题了。
线上活动
1、【RT-Thread能力认证考试12月——RCEA】经过第一次考试的验证,RT-Thread能力认证得到了更多社区开发者和产业界的大力支持!(点此查看)如果您有晋升、求职、寻找更好机会的需要,有深入学习和掌握RT-Thread的需求,欢迎垂询/报考!
能力认证官网链接:https://www.rt-thread.org/page/rac.html(在外部浏览器打开)
扫码报名
2、【智能战车DIY活动】如果你想要打造一辆战车给孩子当玩具,想和小伙伴“robomaster”,用作课程demo调动学生兴趣····加入我们!1、在制作智能战车的过程中,您获得来自RT-Thread官方团队的全程技术支持与指导2、前3名优胜者,有1000元现金奖励与徽章!3、参与活动,即得参与奖(T恤、书籍等)
马上报名
#题外话# 喜欢RT-Thread不要忘了在GitHub上留下你的STAR哦,你的star对我们来说非常重要!链接地址:https://github.com/RT-Thread/rt-thread
你可以添加微信18917005679为好友,注明:公司+姓名,拉进 RT-Thread 官方微信交流群
RT-Thread
让物联网终端的开发变得简单、快速,芯片的价值得到最大化发挥。Apache2.0协议,可免费在商业产品中使用,不需要公布源码,无潜在商业风险。
长按二维码,关注我们
点击“阅读原文”进入Github点star!
本文分享自微信公众号 - RTThread物联网操作系统(RTThread)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。
来源:oschina
链接:https://my.oschina.net/u/4428324/blog/4619533