众所周知,KEIL / MDK是支持C++编程方式的。但是就目前来说,使用C++开发嵌入式的程序员还是比较少,就我个人认为原因是一方面KEIL / MDK对C++程序的支持还不够全面,另一方面则是C++程序的体量相较于C程序过于庞大,对于小型的应用来说没有必要,而且嵌入式开发程序员通常C++功底并不高,C才是他们的拿手好戏。但我认为随着MCU性能的逐渐提升,嵌入式C++的开发潜力将会越来越多的被发掘。并且C++的标准还在更新,总体来说C++的活力是高于C的。当然,最重要的一点则是C++兼容了C,有人甚至说只要C不死,C++就不会死!我认为这是很有道理的。
作为一个嵌入式和智能终端都有一点接触的程序员来说,我当然是更希望我的代码能够更容易地移植。那么使用C++编程的方式无疑就要合理一些。最近两天刚刚忙完期末测评,突然兴起就写了这篇博文,那么接下来我就以自己在KEIL / MDK上建的一个STM32F1的C++模板工程为例,聊一聊C++编程的相关事项,如果有错误或者不足请各位勿怪,也欢迎各位留言和我交流。
准备C++模板工程:
首先我们要新建一个KEIL / MDK工程,如果没建过的话就花点时间百度教程吧,如果不想多费力气那也OK,直接把一个C工程的main.c替换为main.cpp即可。工程的配置均保持原样无需改变。但此时编译的这个工程应该会出一些错误,当文件后缀是C的时候IDE会使用C编译器进行编译,如果文件后缀是CPP则IDE使用C++编译器进行编译,工程包含的头文件是使用C++编译器进行编译的,不过头文件声明的还是C文件的符号,所以IDE会无法正确编译链接。此时我们应该将头文件所有声明C符号的部分用预编译宏加extern "C" { }的形式包含起来,告诉编译器该段要使用C编译器进行编译。具体形式如下:
#ifdef __cplusplus
extern "C" {
#endif
... your C declared part ...
#ifdef __cplusplus
}
#endif
当使用STM32标准库编写程序的时候会发现它所有的头文件都是这样处理过的。也就是说库函数完全兼容C++。这也是我们开发C++程序的强大后盾。在准备了基本工程后就可以在main.cpp文件中使用C++编程了,但还应该注意的是,C编译器不能直接引进CPP文件的符号,同理C++编译器也不能直接引进C文件的符号。当C文件要extern 一个CPP文件的符号时,这个CPP文件的符号定义应该用extern "C" { }进行修饰,否则不能通过编译链接。同样地,当CPP文件要引进一个C文件的符号时也要在CPP文件中使用extern "C" { }进行修饰。另外汇编启动文件的[WEAK]声明仅对C文件符号有效,所以我们编写外设中断服务方法时应该写在C文件中,或者在CPP文件中使用exetrn "C" { }修饰符。
建立的C++模板工程文件目录如下:
其中serial.c和retarget.c都是在KEIL / MDK的安装目录下复制过来的,这两个文件提供了对C标准库和C++标准库的部分支持。添加上述文件到工程后我们就能正常使用C++的std标准库进行编程了。关于serial.c和retarget.c我们需要知道以下一些知识:
KEIL / MDK在semihosting模式下,标准库的输入输出流并没有定向到串口,要正常使用std标准库就需要先关闭semihosting模式,即使用预编译指令如下:
#pragma import(__use_no_semihosting_swi)
关闭semihosting后需要对部分标准库方法进行重定向编写,既将输入输出流重定向至STM32的串口端。而retarget.c里面就实现了相关的方法。
在serial.c中定义了对串口的初始化以及发送和接收的方法。为了让串口能在运行系统入口方法前被初始化而不是在用户main.c中初始化,文件定义了如下方法进行对串口的提前初始化。
/*----------------------------------------------------------------------------
Superclass to initialize the serial interface
*----------------------------------------------------------------------------*/
/* 引进原始__rt_entry方法 */
extern void $Super$$__rt_entry(void);
/* 定义新__rt_entry方法 */
void $Sub$$__rt_entry(void) {
SER_Init();
/* 调用原始__rt_entry方法; */
$Super$$__rt_entry();
}
通过$super$$和$sub$$两个编译器指令的结合使用,将串口初始化方法“填”进了系统的入口方法中。其中SER_Init()方法初始化了USART1。我们也无需再在main.c中初始化串口。另外标准库需要用户提供堆内存进行支持。STM32的启动文件中已经定义了用户堆。所以我们无需再定义,但默认的堆容量对于标准库来说还是太过于小,要手动修改堆的尺寸如下:
Heap_Size EQU 0x00001000 ; Extend for using C++ std lib
还有需要注意的是,KEIL / MDK 的C++编译器默认失能异常捕获机制,如果你的代码中用到了该机制,那么就需要在工程配置的"Options for Target - C/C++ - Misc controls"选项中添加'--exceptions'选项。添加如下图所示:
C++模板工程测试:
弄完上面的这些步骤之后,这个模板工程就可以随心所欲地进行C++程序编写了,下面我们编写一点简单代码进行测试标准库,代码如下:
#include
#include
#include "ledx.h"
#include "tick.h"
int main(void) {
LED_Init(); // 初始化LED
Tick_Init(); // 初始化SYSTICK
std::string str("the float number is ");
float number = 0;
while (1) {
std::cout << str << number++ << std::endl;
led1_on();
led2_on();
delay_ms(500);
led1_off();
led2_off();
delay_ms(500);
}
}
烧录运行并输出到PC串口助手的内容如下:
可以看到运行结果是正确的。但实际情况却是几乎没人愿意这么用在嵌入式开发中,原因也很简单。
这是上面那段测试程序编译链接后的输出窗口,勤俭持家的嵌入式程序员看到这个估计要炸锅了吧!这点代码用纯C写最多占6KB FLASH和极少的RAM资源,但引入C++标准库后居然要占用52KB FLASH和27KB RAM,这不太科学啊!可能习惯编写终端的高富帅程序员看了会不以为然:这么点都不舍得花,怎么能过上有品质的生活啊!但我们的嵌入式程序员却表示:有钱也不能这样花啊,我们的每一分钱都要花得值当,再说我们的积蓄哪里有这么多!(这才是原因,哈哈尴尬!)
优化C++程序:
当然以上只是玩笑话,不过作为一个优秀的嵌入式开发程序员,代码优化永远都是重中之重。那么我们为了勤俭节约一把,自然是不能滥用标准库的,不过得益于C++的优越性,我们可以轻松地编写自己的功能类用于处理一般性的问题,就拿以上测试程序为例,不就是需要一个类似cout输出形式的类嘛!这还不容易!然后我们就开始简单地编写一个类用于实现上述功能。
下面是这个类的头文件部分:
#include "stdio.h"
#include "stdarg.h"
#include "string.h"
// NAME SPACE OUT DEFINE
namespace cchar {
// CONST TYPE VARIABLE
const char tint = 'd';
const char tuint = 'u';
const char tchar = 'c';
const char tftp = 'f';
// CONST ENTER VARIABLE
const char endl = '\n';
}
using namespace cchar;
// STRING OUTPUT CLASS
class ostring {
public:
ostring& operator << (const char* str);
ostring& operator << (int num);
ostring& operator << (unsigned int num);
ostring& operator << (short num);
ostring& operator << (unsigned short num);
ostring& operator << (char num);
ostring& operator << (unsigned char num);
ostring& operator << (float num);
ostring& operator << (double num);
private:
void put_string(const char* str, unsigned int cnt);
void put_number(char type, ...);
};
我们声明了一个ostring类,类里面啥数据成员都木有,只重载不同数据类型的运算符"<<"即可,私有的两个成员函数负责整合字符串并向串口发送。
下面是ostring类的实现文件:
#include "ostr.hpp"
extern "C" { extern int put_char(int c);}
// OUT STRING CLASS BASE PRINT FUNCTION
void ostring::put_string(const char* str, unsigned int cnt) {
for (unsigned int index = 0; index < cnt; index++) {
put_char(str[index]);
}
}
// STRING OUTPUT CLASS PUT NUMBER FUNCTION
void ostring::put_number(char type, ...) {
va_list arg;
char temp[20];
unsigned char cnt = 0;
va_start(arg, type);
switch (type) {
case tint:
cnt = sprintf(temp, "%d", va_arg(arg, int));
break;
case tuint:
cnt = sprintf(temp, "%d", va_arg(arg, unsigned int));
break;
case tchar:
cnt = sprintf(temp, "%c", va_arg(arg, int));
break;
case tftp:
cnt = sprintf(temp, "%f", va_arg(arg, double));
break;
}
va_end(arg);
put_string(temp, cnt);
}
// STRING OUTPUT CLASS OPERATOR FUNCTION
ostring& ostring::operator << (const char* str) {
put_string(str, strlen(str));
return *this;
}
ostring& ostring::operator << (int num) {
put_number(tint, num);
return *this;
}
ostring& ostring::operator << (unsigned int num) {
put_number(tuint, num);
return *this;
}
ostring& ostring::operator << (short num) {
put_number(tint, num);
return *this;
}
ostring& ostring::operator << (unsigned short num) {
put_number(tuint, num);
return *this;
}
ostring& ostring::operator << (char num) {
put_number(tchar, num);
return *this;
}
ostring& ostring::operator << (unsigned char num) {
put_number(tuint, num);
return *this;
}
ostring& ostring::operator << (float num) {
put_number(tftp, num);
return *this;
}
ostring& ostring::operator << (double num) {
put_number(tftp, num);
return *this;
}
这个CPP文件引进了发送单个字符到串口的方法put_char,put_string方法简单地将一个字符串依次发送出去,而put_number方法则是使用C标准库的可变参数对数据类型进行分类,再整合到字符串中再依次发出。这里为了节约开销,我没有用模板函数(试过的,开销显然比这个大)。把这两个文件添加进工程后再运行刚刚的测试程序,
首先将测试程序修改成如下:
#include "ledx.h"
#include "tick.h"
#include "ostr.hpp"
int main(void) {
LED_Init(); // 初始化LED
Tick_Init(); // 初始化SYSTICK
ostring cout;
float number = 0;
while (1) {
cout << "the float number is " << number++ << endl;
led1_on();
led2_on();
delay_ms(500);
led1_off();
led2_off();
delay_ms(500);
}
}
哈哈!现在我们也能像标准库那样输出了!再来看看这个程序的开销如何
可以看出来吧?仅仅用了7KB不到的FLASH和6KB RAM资源,而且我们还没有将划分给标准库的那部分RAM回收,所以RAM实际上消耗是很小的。最后运行一下这个程序,其输出到串口助手的截图如下:
结果依然是正确的,和标准输出唯一不同的是我没有控制输出精度。
C++模板工程对操作系统的支持:
在写这篇博文之前我也验证了在C++模板工程上运行RTOS的可行性。结果当然是可行的,而且不会出现任何问题。这正是得益于C++对C的兼容性。也就是说,C++具备在嵌入式开发的一切条件,真的是只欠东风(程序员)!另外,我还在C++模板工程上成功运行了自己这学期用C写的一个RTOS。我把它叫做REGINA,在上一篇博文里简短地介绍了一下,有兴趣的朋友不妨去下载下来使用看看,它是一个免费的自由软件,而它的体量已经被我精简地非常不错了。博文地址是http://blog.csdn.net/hlld__/article/details/78865260。不过如果有兴趣的你看到那篇博文后可能会感觉很奇怪,原谅我是用较官方性的语言来写的,这样能营造一些正式的气氛!哈哈!
通过这个简单的实例,我们还模仿了用于标准输出的类,并实现了相关的功能。在实际嵌入式开发中为了节约体量还会干很多这样的事情,但就像我前面说的那样,C++给予了程序员这样的便利,仅仅很小的工作量就能实现我们想要的功能。而且相较与C程序而言,C++程序的可移植性、代码的可扩展性显然更高。更为重要的是C++处理大批次数据的能力及实现复杂逻辑的困难程度都要优于C程序,我相信随着MCU的运算能力不断增强,使用C++开发的程序员将会越来越多,而嵌入式C++的开发潜力也将更多地体现出来。