【嵌入式C语言】OOPC开发从未如此简单!
【说在前面的话】
“为什么要使用C语言来实现面向对象开发?”
“直接用C++不就好了么?”
想必很多人在第一次面对 OOPC(Object-Oriented-Programming-with-ANSI-C)的时候,都会情不自禁的发出类似的疑问。其实,任何针对上述问题的讨论,其本身都是充满争议的——换句话说,无论我给出怎样的答案,都无法令所有人满意——正因如此,本文也无意去趟这摊浑水。
我写这篇文章的目的是为那些长期在MDK环境下从事C语言开发的朋友介绍一种方法:帮助大家在偶尔需要用到“面向对象”概念的时候,能简便快捷的使用C语言“搞定”面向对象开发。
在开始后续内容之前,我们需要约定和强调一些基本原则:
“零消耗”原则:即,我们所要实现的所有面向对象的特性都应该是“零资源消耗”或至少是“极小资源消耗”。这里的原理是:能在编译时刻(Compiletime)搞定的事情,绝不拖到运行时刻(Runtime)。 务实原则:即,我们不在形式上追求与C++类似,除非它的代价是零或者非常小。 “按需实现”原则:即,对任何类的实现来说,我们并不追求把所有的OO特性都实现出来——这完全没有必要——我们仅根据实际应用的需求来实现最小的、必要的面向对象技术。 “傻瓜化”原则:即,类的建立和使用都必须足够傻瓜化。最好所见即所得。
在上述前提下,我们就快速进入到今天的内容吧。
【仅需一次的准备阶段】 首先,我们要下载 PLOOC的 CMSIS-Pack,具体链接如下:
https://raw.githubusercontent.com/GorgonMeducer/PLOOC/master/cmsis-pack/GorgonMeducer.PLOOC.4.6.0.pack
当然,如果你因为某些原因无法访问Github,也可以在关注【裸机思维】公众号后发送关键字 “PLOOC” 来获取网盘链接。
下载成功后,直接双击安装包即可。
一般来说,部署会非常顺利,但如果出现了安装错误,比如下面这种:
则很可能是您所使用的MDK版本太低导致的——是时候更新下MDK啦。关注【裸机思维】公众号后发送关键字"MDK",即可获得最新的MDK网盘链接。
PLOOC 是 Protected-Low-overhead-Object-Oriented-programming-with-ansi-C 的缩写,顾名思义,是一个强调地资源消耗且为私有类成员提供保护的一个面向对象模板。
它是一个开源项目,如果你喜欢,还请多多Star哦!
https://github.com/GorgonMeducer/PLOOC
【如何快速尝鲜】 为了简化用户对 OOC 的学习成本,PLOOC提供了一个无需任何硬件就可以直接仿真执行的例子工程。该例子工程以队列类为例子,展示了:
很多时候千言万语敌不过代码几行——学习OOC确是如此。
例子工程的获取非常简单。首先打开 Pack-Installer,在Device列表中找到Arm,选择任意一款Cortex-M内核(比如 Arm Cortex-M3)。在列表中选择ARMCMx(比如下图中的ARMCM3)。此时,在右边的Example选项卡中,就可以看到最底部出现了一个名为 plooc_example (uVision Simulator)的例子工程。单击Copy,在弹出窗口中选择一个目录位置来保存工程:
单击OK后将打开自动打开如下所示的 MDK 界面:
直接单击编译,如果一切顺利,应该没有任何编译错误:
此时,我们可以直接进入调试模式:
可以看到,调试指针停在了 main() 函数的起始位置。我们先不着急开始全速运行。通过菜单打开 "Debug (printf) Viewer" 窗口:
一开始该窗口会出现在屏幕下方的窗体中,通过拖动的方式,我们可以将其挪到醒目的位置。此时,全速运行就可以看到例子工程所要展示的效果了:
该例子只展示了C99模式下使用PLOOC所构建的队列类(enhanced_byte_queue_t)的效果:
static enhanced_byte_queue_t s_tQueue;
printf("Hello PLOOC!\r\n\r\n"); do { static uint8_t s_chQueueBuffer[QUEUE_BUFFER_SIZE]; const enhanced_byte_queue_cfg_t tCFG = { s_chQueueBuffer, sizeof(s_chQueueBuffer), }; ENHANCED_BYTE_QUEUE.Init(&s_tQueue, (enhanced_byte_queue_cfg_t *)&tCFG); } while(0);
//! you can enqueue ENHANCED_BYTE_QUEUE.Enqueue(&s_tQueue, 'p'); ENHANCED_BYTE_QUEUE.Enqueue(&s_tQueue, 'L'); ENHANCED_BYTE_QUEUE.Enqueue(&s_tQueue, 'O'); ENHANCED_BYTE_QUEUE.Enqueue(&s_tQueue, 'O'); ENHANCED_BYTE_QUEUE.Enqueue(&s_tQueue, 'C'); ENHANCED_BYTE_QUEUE.use_as__i_byte_queue_t.Enqueue(&s_tQueue.use_as__byte_queue_t, '.'); ENHANCED_BYTE_QUEUE.use_as__i_byte_queue_t.Enqueue(&s_tQueue.use_as__byte_queue_t, '.'); ENHANCED_BYTE_QUEUE.use_as__i_byte_queue_t.Enqueue(&s_tQueue.use_as__byte_queue_t, '.');
//! you can dequeue do { uint_fast16_t n = ENHANCED_BYTE_QUEUE.Count(&s_tQueue); uint8_t chByte; printf("There are %d byte in the queue!\r\n", n); printf("let's peek!\r\n"); while(ENHANCED_BYTE_QUEUE.Peek.PeekByte(&s_tQueue, &chByte)) { printf("%c\r\n", chByte); } printf("There are %d byte(s) in the queue!\r\n", ENHANCED_BYTE_QUEUE.Count(&s_tQueue)); printf("Let's remove all peeked byte(s) from queue... \r\n"); ENHANCED_BYTE_QUEUE.Peek.GetAllPeeked(&s_tQueue); printf("Now there are %d byte(s) in the queue!\r\n", ENHANCED_BYTE_QUEUE.Count(&s_tQueue)); } while(0);
其输出为:
类 enhanced_byte_queue_t 实际上是从基类 byte_queue_t 基础上派生出来的,并添加了一个非常有用的功能:可以连续的偷看(Peek)队列里的内容,并可以在需要的时候,要么1)将已经偷看的内容实际都取出来;要么2)从头开始偷看——上述代码就展示了这一功能。
PLOOC 相较普通的OOC模板来说,除了可以隐藏类的私有成员(private member)以外,还能够以零运行时成本实现多肽(Polymorphism)——用通俗的话说就是:PLOOC允许拥有不同参数数量、不同参数类型的多个函数拥有相同的名字。
要获得这样的功能,就要打开 C11(最好是GNU11)的支持。当我们打开工程配置,在“C/C++”选项卡中将 Language C 设置为 c11(最好是gnu11):
重新编译后,进入调试模式,将在输出窗口中看到额外的信息:
这些信息实际上对应如下的代码:
#if defined(__STDC_VERSION__) && __STDC_VERSION__ > 199901L LOG_OUT("\r\n-[Demo of overload]------------------------------\r\n"); LOG_OUT((uint32_t) 0x12345678); LOG_OUT("\r\n"); LOG_OUT(0x12345678); LOG_OUT("\r\n"); LOG_OUT("PI is "); LOG_OUT(3.1415926f); LOG_OUT("\r\n"); LOG_OUT("\r\nShow BYTE Array:\r\n"); LOG_OUT((uint8_t *)main, 100);
LOG_OUT("\r\nShow Half-WORD Array:\r\n"); LOG_OUT((uint16_t *)(((intptr_t)&main) & ~0x1), 100/sizeof(uint16_t));
LOG_OUT("\r\nShow WORD Array:\r\n"); LOG_OUT((uint32_t *)(((intptr_t)&main) & ~0x3), 100/sizeof(uint32_t)); #endif
你看,同一个函数 LOG_OUT() 当我们给它不同数量和类型的参数时,居然可以实现不同的输出效果,是不是特别神奇——这就是面向对象开发中多态的魅力所在。请记住:此时我们仍然使用的是C语言,而不是C++; 在C99下,我们可以实现拥有不同参数个数的函数共享同一个名字; 在C11下,我们可以实现拥有相同参数个数但类型不同的函数共享同一个名字; 我们在运行时刻的开销是0,一切在编译时刻就已经尘埃落定了。我们并没有为这项特性牺牲任何代码空间。
例子工程可以帮助我们快速的熟悉 OOC 的开发模式,那么在任意的普通工程中,我们要如何使用 PLOOC模板呢?
【PLOOC在任意普通工程中的部署】 PLOOC 模板其实是一套头文件,既没有库(lib)也没有C语言源代码,更别提汇编了。
在任意的MDK工程中,只要你已经安装了此前我们提到过的CMSIS-Pack,就可以通过下述工具栏中标注的按钮,打开RTE配置界面:
找到 Language Extension选项,将其展开后勾选PLOOC,单击OK关闭窗口。
此时,我们就可以在工程管理器中看到一个新的列表项“Language Extension”:
它是不可展开的,别担心,这就足够了。打开工程配置,如果你使用的是 Arm Compiler 6(armclang):
则我们需要在 C/C++选项中: -fms-extensions
如果你使用的是 Arm Compiler 5(armcc):
则需要在 C/C++ 选项卡中开启对 GNU Extension 和 C99的支持:
遗憾的是作为一款已经停止更新的编译器,Arm Compiler 5 既不支持C11,也不支持微软扩展(-fms-extensions),这意味着PLOOC中的多态特性无法发挥最大潜能,着实有点遗憾(但拥有不同参数数量的函数还是允许共享同一个名称的)。
至此,我们就完成了PLOOC在一个工程中的部署。是不是特别简单?
也许文章到了一半我才问,已经有点迟了——大家都熟悉基本的面向对象概念吧?比如:
类(class) 私有成员(private member) 公共成员(public member) 保护成员(protected member) 构造函数(constructor) 析构函数(destructor) 类的方法(method) ……
如果不熟悉,还请找本C#或者C++的书略微学习一下为好。后面的内容,我将假设你已经对面向对象的基本开发要素较为熟悉。
那么,我们如何快速的在C语言工程中构建一个类呢?
【新建一个类从未如此简单】
假设我们要创造一个新的类,叫做 my_class1
第一步:引入模板在工程管理器中,添加一个新的group,命名为 my_class1:
右键单击 my_class1,并在弹出的菜单中选择 "Add New Item to Group my_class1":
在弹出的对话框中选择 User Code Template:
展开 Language Extension,可以看到有两个 PLOOC模板,分别对应:
- 基类和普通类(Base Class Template)
- 派生类(Derived Class Template)
由于我们要创建的是一个普通类(未来也可以作为基类),因此选择“Base Class Template”。单击Location右边的 "..." 按钮,选择一个保存代码文件的路径后,单击“Add”。
此时我们可以看到,class_name.c 被添加到了 my_class1中,且MDK自动在编辑器中为我们打开了两个模板文件:class_name.h和class_name.c。
第二步:格式化在编辑器中打开或者选中 class_name.c。通过快捷键CTRL+H打开 替换窗口: - 在Look in中选择Current Document
- 去掉Find Opitons属性框中的 Match whold word前的勾选(这一步骤很重要)
接下来,依次:
- 将小写的 <class_name> 替换为 my_class1
- 将大写的 <CLASS_NAME> 替换为 MY_CLASS1
完成上述步骤后,保存class_name.c。打开class_name.h,重复上述过程,即:
- 将小写的 <class_name> 替换为 my_class1
- 将大写的 <CLASS_NAME> 替换为 MY_CLASS1
完成后保存 class_name.h.
第三步:加入工程编译 在工程管理器中展开 my_class1,并将其中的 class_name.c 删除:
打开class_name.c 所在文件目录:
找到我们刚刚编辑好的两个文件 class_name.c 和 class_name.h:
用我们的类为这两个文件命名:my_class1.c 和 my_class1.h
在MDK工程管理器中,将这两个文件加入 my_class1 下:
如果此前你的工程就是可以正常编译的话,在加入了上述文件后,应该依然可以正常编译:
第四步:如何设计你的类成员变量 打开 my_class1.h,找到 def_class 所在的代码片断:
//! \name class my_class1_t //! @{ declare_class(my_class1_t)
def_class(my_class1_t,
public_member( //!< place your public members here )
private_member( //!< place your private members here ) protected_member( //!< place your private members here ) )
end_def_class(my_class1_t) /* do not remove this for forward compatibility */ //! @}
很容易注意到:
typedef struct my_class1_t my_class1_t; 因此并没有什么特别神秘的地方。
第四步:如何设计构造函数
找到 typedef struct my_class1_cfg_t 对应的代码块:
typedef struct my_class1_cfg_t { //! put your configuration members here } my_class1_cfg_t;
可以看到,这是个平平无奇的结构体。它用于向我们的构造函数传递初始化类时所需的参数。在类的头文件中,你很容易找到构造函数的函数原型:
/*! \brief the constructor of the class: my_class1 */ extern my_class1_t * my_class1_init(my_class1_t *ptObj, my_class1_cfg_t *ptCFG);
可以看到,其第一个参数是指向类实例的指针,而第二个参数就是我们的配置结构体。在类的C源代码文件中,可以找到构造函数的实体:
#undef this #define this (*ptThis) /*! \brief the constructor of the class: my_class1 */ my_class1_t * my_class1_init(my_class1_t *ptObj, my_class1_cfg_t *ptCFG) { /* initialise "this" (i.e. ptThis) to access class members */ class_internal(ptObj, ptThis, my_class1_t); ASSERT(NULL != ptObj && NULL != ptCFG);
return ptObj; }
此时,在构造函数中,我们可以通过 this.xxxx 的方式来访问类的成员,以便根据配置结构体中传进来的内容对类进行初始化。
也许你已经注意到了,我们的模板中并没有任何为类申请空间的代码。这是有意为之。原因如下:
my_class1_cfg_t tCFG = { ... }; my_class1_t *ptNewItem = my_class1_init( (my_class1_t *)malloc(sizeof(my_class1_t), &tCFG); if (NULL == ptNewItem) { printf("Failed to new my_class1_t \r\n"); }
...
free(ptNewItem);
#define new_class(__name, ...) \ ({__name##_cfg_t tCFG = { \ __VA_ARGS__ \ }; \ __name##_init( \ (__name##_t *)malloc(sizeof(__name##_t), \ &tCFG);}) 这可不就是一个根正苗红的 new()方法么,比如:
my_class1_t *ptItem = new_class(my_class, <构造用的参数列表>); if (NULL == ptItem) { printf("Failed to new my_class1_t \r\n"); }
...
free(ptItem); 怎么样,是这个味道吧?析构函数类似,比如my_class1_depose()函数,同样不负责资源的释放——决定权还是在用户的手里,当然你也可以做完一套:
#define free_class(__name, __obj) \ do { \ __name##_depose((__name##_t *)(__obj)); \ free(__obj); \ } while(0) 形成组合拳,从分配资源、构造、析构到最后释放资源一气呵成:
my_class1_t *ptItem = new_class(my_class, <构造用的参数列表>); if (NULL == ptItem) { printf("Failed to new my_class1_t \r\n"); }
...
free_class(my_class, ptItem);
第五步:如何设计构类的方法(method) 我们开篇说过,实践面向对象最重要的是功能,而非形式主义。假设有一个类的方法叫做 method1,理想中,大家一定觉得如下的使用方式是最“正统”的:
my_class1_t *ptItem = new_class(my_class, <构造用的参数列表>); if (NULL == ptItem) { printf("Failed to new my_class1_t \r\n"); }
ptItem.method1(<实参列表>);
free_class(my_class, ptItem); 在C语言中,我们完全可以实现类似的效果——只要你在类的定义中加入函数指针就行了——其实很多OOC的模板都是这么做的(比如lw_oopc)。但你仔细思考一下,在类的结构体中加入函数指针究竟有何利弊:
先来说好处:可以用“优雅”的方式来完成方法的调用; 支持运行时刻的重载(Override);
再来说缺点:
换句话说,对大部分类的大部分情况来说,我们都不需要考虑类的方法重载问题,就算有,很多时候也都是编译时刻的静态重载(plooc_example就展示了静态重载的实现方式),那么在不考虑运行时刻动态重载的应用场景下,直接用普通函数来实现类的方法就是务实的一个选择了。
基于这种考虑,上述例子实际上应该写为:my_class1_t *ptItem = new_class(my_class, <构造用的参数列表>); if (NULL == ptItem) { printf("Failed to new my_class1_t \r\n"); }
my_class1_method1(ptItem,<实参列表>);
free_class(my_class, ptItem); 这里,my_class1_method1() 是 my_class1.h 提供声明、my_class1.c 提供实现的一个函数。前缀 my_class1_ 用于防止命名空间污染。
另外一个值得注意的细节是,OOPC中,任何类的方法,其函数的第一个参数一定是指向类实例的指针——也就是我们常说的 this 指针。以 my_class1_method1() 为例,它的形式为:
#undef this #define this (*ptThis)
void my_class1_method(my_class1_t *ptObj, <形参列表>) { /* initialise "this" (i.e. ptThis) to access class members */ class_internal(ptObj, ptThis, my_class1_t); ... } 这里,class_internal() 用于将 ptObj转变成我们所需的 this指针(这里的ptThis),借助宏的帮助,我们就可以实现 this.xxxx 这样无成本的形式主义了。
第六步:如何设计类的接口(Interface) 我们的模板还为每个类都提供了一个接口,并默认将构造和析构函数都包含在内,比如,我们可以较为优雅的对类进行构造和析构:
static my_class1_t s_tMyClass; ... MY_CLASS.Init(&s_tMyClass, ...); ... MY_CLASS.Depose(&s_tMyClass);
在 my_class1.h 中,我们可以找到这样的结构://! \name interface i_my_class1_t //! @{ def_interface(i_my_class1_t) my_class1_t * (*Init) (my_class1_t *ptObj, my_class1_cfg_t *ptCFG); void (*Depose) (my_class1_t *ptObj); /* other methods */
end_def_interface(i_my_class1_t) /*do not remove this for forward compatibility */ //! @}
假设我们要加入一个新的方法,则只需要在 i_my_class1_t 的接口定义中添加对应的函数指针即可,比如://! \name interface i_my_class1_t //! @{ def_interface(i_my_class1_t) my_class1_t * (*Init) (my_class1_t *ptObj, my_class1_cfg_t *ptCFG); void (*Depose) (my_class1_t *ptObj); /* other methods */ void (*Method1) (my_class1_t *ptObj, <形参列表>); end_def_interface(i_my_class1_t) /*do not remove this for forward compatibility */ //! @} 接下来,我们要在 my_class1.h 中添加对应方法的函数声明:
extern void my_class1_method1(my_class1_t *ptObj, <形参列表>); 这里,值得注意的是,习惯上函数的命名上与接口除大小写歪,还有一个简单的对应关系:即,所有的"."直接替换成"_",比如,使用上:
MY_CLASS1.Method1() 就对应为:
my_class1_method1()
与此同时,我们需要在 my_class1.c 中添加 my_class1_method1() 函数的实体:void my_class1_method1(my_class1_t *ptObj, <形参列表>) { class_internal(ptObj, ptThis, my_class1_t); ... } 并找到名为 MY_CLASS1 的接口实例:
const i_my_class1_t MY_CLASS1 = { .Init = &my_class1_init, .Depose = &my_class1_depose, /* other methods */ }; 在其中初始化我们的新方法(新函数指针) Method1:
const i_my_class1_t MY_CLASS1 = { .Init = &my_class1_init, .Depose = &my_class1_depose, /* other methods */ .Method1 = &my_class1_method1, }; 至此,我们就完成了类方法的添加和初始化。以后,在任何地方,都可以通过
<类名大写>.<接口中方法名>() 的形式来访问类的操作函数了——这也算某种程度上的优雅了吧。
第六步:如何设计派生类(Derived Class) 派生类的创建在基本步骤上与普通类基本一致,除了在模板选择阶段使用对应的模板外,还需要在“格式化”阶段额外添加以下两个替换步骤:
- 将 <BASE_CLASS_NAME> 替换为 基类的大写名称;
- 将 <base_class_name>替换为基类的小写名称;
在类的定义阶段,我们注意到:
//! \name class <class_name>_t //! @{ declare_class(<class_name>_t)
def_class(<class_name>_t, which(implement(<base_class_name>_t)) ... )
end_def_class(<class_name>_t) /* do not remove this for forward compatibility */ //! @} 派生类在原有的类定义基础上多出了的结构,以"," 与类的类型名隔开:which(implement(<base_class_name>_t)) 这里,which() 其实是一个列表,它允许我们实现多重继承。假设我们有多个基类,或是要继承多个接口,则可以写成如下的形式:which( implement(<base_class_name1>_t) implement(<base_class_name2>_t) implement(<interface_name1>_t) implement(<interface_name2>_t) ) 需要注意的是,如果基类或是接口中存在名称冲突(重名)的成员,则可以将 implement() 替换为 inherit() 来避免这种冲突。比如 <interface_name2>_t 与 <base_class_name1>_t 都有一个叫做 wID 的成员,则可以通过将其中之一的implement() 替换为 inherit的方式在新的派生类中避免冲突:which( inherit(<base_class_name1>_t) implement(<base_class_name2>_t) implement(<interface_name1>_t) implement(<interface_name2>_t) )
就像这里所展示的那样,PLOOC支持多继承,这是C++和C#都不曾支持的——这也是 使用C语言来实现OO的魅力之一,具体方法,大家可以自行摸索,这里就不再赘述。
大家都知道,在面向对象中,有一类成员只有当前类和派生类能够访问——我们称之为受保护成员(protected member)。在类的定义中,可以通过 protected_member() 将这些成员囊括起来,比如: //! \name class byte_queue_t //! @{ declare_class(byte_queue_t)
def_class(byte_queue_t,
private_member( implement(mem_t) //!< queue buffer void *pTarget; //!< user target ) protected_member( uint16_t hwHead; //!< head pointer uint16_t hwTail; //!< tail pointer uint16_t hwCount; //!< byte count ) )
end_def_class(byte_queue_t) /* do not remove this for forward compatibility */ //! @} 这里,hwHead、hwTail和hwCount 都只有当前类和派生类能访问。
对于那些只允许派生类访问的方法(函数)来说,我们一般会使用预编译宏的形式将其有条件的保护起来:#if defined(__BYTE_QUEUE_CLASS_IMPLEMENT) || defined(__BYTE_QUEUE_CLASS_INHERIT__) extern mem_t byte_queue_buffer_get(byte_queue_t *ptObj); #endif 这里,受到宏 __BYTE_QUEUE_CLASS_IMPLEMENT 和 __BYTE_QUEUE_CLASS_INHERIT 的保护,函数 byte_queue_buffer_get() 仅能够允许类 byte_queue_t 自身极其派生类才能访问了。
在我们前面创建的 my_class1.h 中我们也有一个类似的例子:#if defined(__MY_CLASS1_CLASS_IMPLEMENT) || defined(__MY_CLASS1_CLASS_INHERIT__) /*! \brief a method only visible for current class and derived class */ extern void my_class1_protected_method_example(my_class1_t *ptObj); #endif
函数 my_class1_protected_method_example() 就是一个仅供 my_class1 极其派生类访问的 受保护的方法。
在派生类中,如果要访问基类的受保护成员,则可以借助 protected_internal() 的帮助,例如:
#undef this #define this (*ptThis)
#undef base #define base (*ptBase)
void enhanced_byte_queue_peek_reset(enhanced_byte_queue_t *ptObj) { /* initialise "this" (i.e. ptThis) to access class members */ class_internal(ptObj, ptThis, enhanced_byte_queue_t); /* initialise "base" (i.e. ptBase) to access protected members */ protected_internal(&this.use_as__byte_queue_t, ptBase, byte_queue_t); ASSERT(NULL != ptObj); /* ------------------atomicity sensitive start---------------- */ this.hwPeek = base.hwTail; this.hwPeekCount = base.hwCount; /* ------------------atomicity sensitive end---------------- */ } 这里,派生类借助 this.use_as__byte_queue_t 获得了对基类的“引用”,并借助 protected_internal() 将其转化为了名为 ptBase 的指针。在 base 宏的帮助下,我们得以通过 base.xxxx 来访问基类的成员。在例子中,我们看到,base.hwTail 和 base.hwCount 正是前面所展示过的 byte_queue_t 的受保护成员。
【说在后面的话】
无论使用何种模板,OOPC来发的一个核心理念应该是“务实”,即:以最小的成本(最好是零成本),占最大的便宜(来自OO所带来的好处)。
此前,我曾经在文章《 真刀真枪模块化(2.5)—— 君子协定》详细介绍过PLOOC的原理和手动部署技术。借助CMSIS-Pack和MDK中RTE的帮助,原本繁琐的手动部署和类的创建过程得到了空前的简化,使用OOPC进行开发从未如此简单过——几乎与直接使用C++相差无几了。
不知不觉间,从2年前第一次将其公开算起,PLOOC已经斩获了一百多个Star——算是我仓库中的明星工程了。从日志上来看,PLOOC相当稳定。距离我上一次“觉得其有必要更新”还是整整一年多前的事情,而加入CMSIS-Pack只是一件锦上添花的事情。
最后,感谢大家的支持——是你们的Star支撑着我一路对项目的持续更新。谢谢!‧‧‧‧‧‧‧‧‧‧‧‧‧‧‧‧ END ‧‧‧‧‧‧‧‧‧‧‧‧‧‧‧
|