单片机编程早期通过汇编语言编写程序,汇编语言是面向机器的语言,可以直接编译成机器语言运行,因此汇编写的目标程序占用内存较少、运行效率较高,且能直接引用计算机的各种设备资源。但汇编语言指令多且晦涩,太鸡儿难操作了,开发效率低下,维护起来简直是个恶梦。吴工以前看到一个兄弟接手一个老工程师的一份代码,足足一万多行,一万多行在一个文件里,看得那哥们生无可恋。汇编程序除了运行快,好像没有能吸引程序员的地方,因此它通常用于编写系统的核心部分程序,或编写需要耗费大量运行时间和实时性要求较高的程序段(比如STM32的启动文件、RTOS的任务上下文切换)。汤普森和里奇两老爷子发明C语言后,嵌入式编程也开创新时代,顺便提一嘴,汤普森和里奇两老爷子还用C开发了伟大的UNIX操作系统,多年之后,轻年才俊托瓦兹基于UNIX,用C开发了更流行的LINUX操作系统,再多年后,托瓦兹用C又开发了git。
目前单片机编程主流的就是用C语言开发了,C语言非常简洁易学,使用自由,允许直接访问物理地址,可以直接对硬件进行操作,生成代码质量高,程序执行效率高(一般只比汇编程序生成的目标代码效率低10~20%),适用范围大,可移植性好等等,听起来简直就是为嵌入式开发量身定制的,因此结合嵌入式设备的特点,C语言成了嵌入式编程的不二之选。
现在单片机中写程序,基本都是C语言开发,包括底层驱动和上层应用程序。通常用C完全能够完成项目,
但是当系统软件规模上去后,务必要对整个系统各个模块要进行划分,同时要结合面向对象开发的思想,否则越往后,软件将越来越难以维护。另外,在开发软件过程中,经常会遇到一个情况:由于成本或部件停产等等基他原因,原来的某个部件要换型号或厂家,如果软件没有很好的做到接口与实现分离,后期的改动将很痛苦。而“接口与实现分离”是面向对象编程的基本原则。
因此,我更推荐使在合适的时候用C++进行开发单片机程序。
这就引出第一个讨论的话题:为什么推荐用C++开发单片机程序?
有的亲就要反问了:人家UNIX/LINUX那么大的系统内核代码也基本都是C开发的,大家都在用的git也是C写的,还有啥单片机项目需要C++(通常单片机项目规模不会超过这些牛逼的项目), 更何况托瓦兹还公开炮轰过C++是一门很烂的语言。
我说说我的理由:首先C++是支持面向对象的,有着语言层面的支持,因此在构建类的时候更加直接和便捷。而且C++是兼容C语言的,因此在基本的操作上是一样的,不会带来更大的使用障碍。C++运行速度也很快,了解过C++发展的朋友应该知道,C++在发展过程中、每次新标准发布所带来的新特性中,都会评估性能,在编译器层面,也会进行性能的优化,因此不用怀疑C++程序的运行速度,在单片机中也不用担心。
再则,C++提供了STL库,函数丰富,同时支持各种高级的搞法,如类,多态,模板等等,这些都可以通过C语言模拟出来,但最后会发现那指针指得头发晕,如果用C++写可以让代码写得更加优雅和轻松。
面向对象编程并不是语言本身的功能,而是一种编程思想,就像面向过程一样。毫无疑问用C也能写出面向对象的程序,
但由于出生太早,C语言本身是不带有支持面向对象编程的语言特性,如继承、多态、泛型等等,这就决定了用C写出面向对象的程序对程序员基础有一定的要求,不然写出来的程序就像记流水帐一样。看看LINUX内核就知道,里面到处都是面向对象的思想,这同时就带来另一个问题,就是指针大量的使用,指针是C的精华,但也是比较难掌控的部分,毕竟不是每个人都去开发内核。想起一笑话:以前学C的时候,有个同学问老师说,指针有点难啊,该怎么指啊?老师答:就那么指呗!
好了,讨论第二个问题:什么情况下可以用C++开发单片机程序?
前面提到,当年托瓦兹还公开炮轰过C++是一门很烂的语言。我们要看全部过程才知道托瓦兹到底在骂什么。
首先他在骂C++太复杂。这个得承认C++的学习曲线的确陡峭,尤其是刨掉C之外的那部分,什么构造函数(默认构造、拷贝构造、移动构造)、析构函数、虚函数、多重继承、友元、模板特例化等等一票操作真的伤脑。
其次他在骂部分C++程序员水平较差,不利于交流。如果一个团队用C开发,在搭建好的框架内,可能不需要每个人的水平都很高,有个别高手就能解决大部分BUG,但如果一个团队用C++开发,那就要求每个人都要精通C++,不然一个人就能搞死项目。
因此,在选择用C++开发的时候 ,首先要对自己的C++水平有一定的认识,如果掌控不了,最好不用使用,否则后期可能会是个麻烦。单片机项目规模一般不会太大,软件载发通常都是3人以内,在水平足够的情况下,风险基本是可控的。
再则,C++程序一般都会使用STL库,这当然会增加代码量,因此要特别清楚哪些库能用哪些库不能用,不然一不小心代码会急剧膨胀,对于单片机这种内存和容量都不富余的芯片要谨慎使用。好在现在主流的像STM32这种芯片内存和容量都较大,基本可以满足。
所使用的开发环境得支持C++。这个现在也基本支持,像MDK,IAR都支持完C++11标准,所以这也不是问题。
在LINUX中我们经常讲程序开发分为驱动/内核程序开发和应用程序开发,两部分是完全分隔的。在单片机程序里虽然没有严格的分隔,有时甚至能看到揉在一起的代码,但通常我们写程序时也会将其分为驱动层和应用层。驱动层的代码基本就是和芯片寄存器及各种外设打交道的那部分,需要精准的操作内存,能够准备的预判编译器的行为。而在业务逻辑方面,就可以采用C++来完成。
一句话就是:自己能熟练操作C++,并且软硬件平台都支持C++.
好了,讨论第三个问题:怎么使用C++开发单片机程序?
先设置软件开发环境,以MDK为例(STM32F1, 编译器AC6):
代码方面就和一般写C++差不了太多,来个小demo:
场景:我原来机器上用了一个柱塞泵A,后来因需要又要换一个厂家的柱塞泵B,接口协议啥的都不一样。
解决思路:将不同厂家柱塞泵相同的东西抽离出来,设计成基类,将不同部分设计成派生类。柱塞泵都有一些相同的动作,比如:吸液,排液,步进,滴定控制,停止等等。
// 定义注塞泵/注射泵父类结构
class pistonpump_base
{
protected:
uint8_t isManual; //0=自动,1=手动
uint8_t stopflag; //0=无动作,1=急停
int16_t motor_steps; //滴定时电机步进数
uint32_t titrate_num; //滴定数
public:
explicit pistonpump_base(uint8_t mode, uint8_t flag, int16_t steps, uint32_t num)
: isManual(mode), stopflag(flag), motor_steps(steps), titrate_num(num)
{}
virtual ~pistonpump_base(){};
.....
virtual int drainEmpty(void)=0; //排空
virtual int suction(void)=0; //吸满
virtual int titrate(void)=0; //滴定
virtual int stop(void)=0; //停止
virtual int get_position(int*)=0; //查询活塞位置
virtual int valve_ctrl(Piston_Valve_Ctrl_t )=0; //阀控制 0=关闭,1=打开
};
柱塞泵A:继承自柱塞泵基类。
#include "pistonpump.hpp"
class keyto_pump : public pistonpump_base
{
public:
keyto_pump();
~keyto_pump(){}
virtual int drainEmpty(void) override; //排空
virtual int suction(void) override; //吸满
virtual int titrate(void) override; //滴定
virtual int stop(void) override; //停止
virtual int get_position(int*) override; //查询活塞位置
virtual int valve_ctrl(Piston_Valve_Ctrl_t ) override; //阀控制 0=关闭,1=打开
};
在派生类中去实现具体的应用接口。
某一天,公司说要再增加一款柱塞泵B,或者更换一款泵,这时就可以从容的再加一点代码,
#include "pistonpump.hpp"
class runze_sy08 : public pistonpump_base
{
public:
runze_sy08();
~runze_sy08(){}
virtual int drainEmpty(void) override; //排空
virtual int suction(void) override; //吸满
virtual int titrate(void) override; //滴定
virtual int stop(void) override; //停止
virtual int get_position(int*)override; //查询活塞位置
virtual int valve_ctrl(Piston_Valve_Ctrl_t ) override; //阀控制 0=关闭,1=打开
};
应用程序基本不用作任何修改,因为应用程序调用的是基类的接口。至于下面倒底是谁在实现跟本不用管,通常只需要新添加相应的文件.cpp, .hpp就行了。
//应用程序
//手动操作: 排空/吸液/滴定
......
switch(act.step)
{
case STEP_DRAIN: //柱塞泵排液
res = pistonpump->drainEmpty();
break;
case STEP_SUCTION: //柱塞泵吸液
res = pistonpump->suction();
break;
case STEP_TITRATE: //柱塞泵滴定
res = pistonpump->titrate();
break;
default:
break;
}
.......
如果再要增加其他柱塞泵,那就再接着加好了,心中一点不慌。这样就实现了接口与实现的完全分离。而实现这样效果的就是C++中的多态性。更好的地方在于,如果对部件的控制有了更好的想法进行修改代码时,只需要改动相应的派生类的函数,对应用程序而言是无感的,这也是接口与实现分离的体现。
用C语言也是可以实现多态性的,操作起来C++效果差不多,但在应用 程序中理解起来就比较废劲了,因为指针用的很多,这里要补充说明一下,C++实现多态的核心是虚函数,当类中有虚函数声明时,编译器会给类创建一个虚表vtab,这个虚表中装着基类或派生类的虚函数地址,在创建类对象时,编译器会给对象内存中安插一个指针vptr, 这个指针指向vtab. 用C模拟C++多态也就要手动做一个类似的虚表。
看另外一个电子电位器C实现的例子:
.h 文件.....................................
struct potentio_VTable; //虚表前置声明
// 定义父类结构
typedef struct potentio_s
{
int Pot_addr;
int Pot_ref_reg;
int Pot_ana_reg;
struct potentio_VTable *vptr; // 虚表指针
}potentio_t;
// 父类中的虚表结构
struct potentio_VTable{
HAL_StatusTypeDef (*para_set)(potentio_t*, Potentio_Reg_typedef, int, int);
};
.c 文件 .....................................
static HAL_StatusTypeDef _potentio_set_base(potentio_t *this, Potentio_Reg_typedef reg_type, int value, int wipe)
{
printf("Pure virtual function is don't have instance\r\n");
return HAL_OK;
}
//构造函数
void potentio_ctor(potentio_t *this)
{
//定义虚表vtab
static struct potentio_VTable vtab = {
_potentio_set_base,
};
this->vptr = &vtab;
this->Pot_addr = 0;
this->Pot_ana_reg = 0;
this->Pot_ref_reg = 0;
}
//外部调用
HAL_StatusTypeDef potentio_set(potentio_t *this, Potentio_Reg_typedef reg_type, int value, int wipe)
{
return (this->vptr->para_set(this, reg_type, value, wipe));
}
派生类:
typedef struct //ad5423_s
{
potentio_t parent; //继承父类
struct potentio_VTable *vptr; //虚表指针
}ad5243_t;
//构造函数
void ad5423_ctor(ad5243_t * this)
{
//定义虚表vtab
static struct potentio_VTable vtab = {
_ad5423_set,
};
potentio_ctor(&this->parent); //构造父类对象
this->parent.vptr = &vtab; //重点:将父类的虚表指针重映射指向派生类的虚表,以实现多态性。
this->parent.Pot_addr = 0x5E;
this->parent.Pot_ana_reg = 0x80;
this->parent.Pot_ref_reg = 0x00;
}
==========================
//外部应用调用
HAL_StatusTypeDef potentio_set(potentio_t *this, Potentio_Reg_typedef reg_type, int value, int wipe)
{
return (this->vptr->para_set(this, reg_type, value, wipe));
}
后面在应用程序中调用 potentio_set()函数时,就可以根据实际传入的对像调用相应的虚表中的设置函数。
由此,我们可以派生不同的器件,但应用程序不用关心,也不用修改代码。
但是像this->vptr->para_set() 这种操作 ,估计对代码不熟的人找出处都要找半天。一般的IDE也无法直接跳转到para_set()函数。
而这种操作,在LINUX内核中真的是随处可见,代码读起来真的费劲,跳来跳去,不用多久就晕头转向。
通过上面的两个例子,可以了解到在单片机程序中是可以使用C++的,并且在封装、继承、多态性方面的操作是优于C的,这对于后期维护是有利的。而到目前为止,充其量只用到了C++中的C with class部分。C++其实是一个语言联邦,
通常来说有4个方面,分别是:
1. C
2. C with class
3. Template C++
4. STL
以模板为例。在单片机中经常会遇到排序算法,如果我们需要一会对整数排序,一会对浮点排序,一会对字符串排序,
如果用C来写是不是要写几个函数才行,而用模板只需要一个,这将大大简化代码并利于维护。
因此,因此在嵌入式编程中,C++还有很多可以挖掘的东西。