IAR 8051 C/C++ 概述 - 以及使用C++ 相比C 的提升

节选自IAR C/C++ Compiler User Guide,42 ~ 45 页。

语言概述

IAR 8051 C/C++ 编译器支持:

C:

C 是在嵌入式领域使用最广泛的高级编程语言。你可以构建遵循以下标准的独立C 语言应用程序:

  • 标准 C - 也就是C99。以下称为标准C ;
  • C89 - 也被称为C94、C90 或者ANSI C。启用MISRA C 功能时,这个标准是必要的;

C++:

C++ 是一种现代的面向对象编程语言,注:现在一般说C++ 是所谓的“多范式”,就是无所不包的意思,它拥有一个全功能(注:半残废)的库,特别适合模块化编程。以下C++ 标准可供使用:

  • 嵌入式C++ (Embedded C++, EC++)- 是C++ 标准的子集,为嵌入式系统编程而设计。这个标准由一个工业协会设计,即嵌入式C++ 技术委员会。参见原文Using C++ 章节,注:以后也会龟速翻译
  • IAR 扩展嵌入式C++ (IAR Extended Embedded C++, EC++)- EC++ 扩展了一些额外的功能,完整支持模板技术、命名空间、新式类型转换运算符以及标准模板库(STL);

每种支持的语言都可以在“严格”或“宽松”模式下使用,或者是“启用IAR 扩展的宽松模式”。严格模式即严格遵守语言标准,而宽松模式则允许一些广为流传的非标准变体。注:比如把main 函数定义成 void main()其实并不符合C 标准,标准的形式只有int main(…),即便嵌入式环境返回值没有意义。

除此之外,也可以在程序中完全或部分的使用汇编语言,参见IAR Assembler User Guide for 8051

器件支持

支持的8051 器件

IAR C/C++ 编译器支持所有基于标准8051 的微处理器架构,同时还支持以下扩展:

  • 多个数据指针(DPTR)。代码生成器支持集成最多八个DPTR,注:可能不需要自己亲自写汇编来提高访问xdata 的效率
  • 扩展的代码存储空间,支持最大16M 字节。
  • 扩展的数据存储空间,支持最大16M 字节;
  • 对美信DS80C390/DS80C00 及相似器件的支持,包括多DPTR、扩展指令集以及扩展的栈空间(放在xdata 区域的调用栈);
  • Mentor Graphics M8051W/M8051EW 核以及基于此的器件;

参阅基本的项目配置 @ 原文56 页,以了解如何根据所用的器件配置项目。

预定义的支持文件

头文件

8051/inc 目录下可以找到所有支持的器件的头文件,包含了寄存器定义等内容。

链接器配置文件

8051/config 目录下包含了现成的链接器配置文件,用于所有支持的器件。这些文件的扩展名是*.xcl*,包含了链接器需要的信息。更多详情参见安排代码和数据 - 链接器配置文件 @ 原文122 页,以及IAR Linker and Library Tools Reference Guide

器件描述文件

这些文件用于帮调试器了解对应器件的配置,比如存储空间的定义和外设寄存器等,这些文件存储于8051/config 路径下,扩展名是*.ddf*。外设寄存器的信息可以定义在其他文件里,扩展名是*.sfr*,然后要被*.ddf* 文件包含。更多信息参见C-SPY® Debugging Guide for 8051

示例和入门

8051/example 路径下有IAR 提供的示例项目,它们包含了现成的项目和工作区配置、源代码以及其他相关的文件。

对嵌入式系统的特别支持

这一节简要介绍了编译器提供的一些扩展,用于支持8051 微控制器的特性。

扩展的关键字

编译器提供了一组关键字用于调整代码的生成,比如说,有一些关键字可以用来控制变量的存储类型,以及用来声明特殊的函数类型。IDE 环境的默认状态是启用语言扩展的。在命令行环境下,可以用-e 参数启用扩展。关于**-e** 的额外信息,参见原文302 页。

关于扩展关键字的具体信息,参见Extended keywords 章节,以及数据存储 @ 原文67 页 和函数 @ 原文95 页。

#pragma 预编译指令

#pragma 预编译指令可以控制编译器的行为,比如:调整存储方式、是否支持扩展关键字、是否显示警告信息等。#pragma 预编译指令总是启用,它们与标准C 相容,并且当你想让源代码可移植时,它们会很有用。

更多信息参见Pragma directives 章节。

预定义符号

借助这些预定义的预处理器符号(注:就是宏),你可以在代码中判断所处的编译时环境,比如代码和数据存储模式。更多信息参见The preprocessor 章节。

使用底层功能

在程序中和硬件相关的部分,使用底层功能有时候是必要的。编译器提供了几种实现的方式,如:内建函数(注:就是Keil 里的intrins.h)、C/C++ 和汇编模块混合编程以及内联汇编。关于这些方法的信息,参见混合C 与汇编 @ 原文191 页。

注 - 大概介绍一下用C++ 的好处

不必一直强调C++ 不透明,会占用更多资源之类的偏见,只要不用那些动态特性,比如虚函数、异常、RTTI 之类的,那C++ 就只是更甜的C,哪怕只有命名空间就已经很香了。代码体积大之类的也是一样,如果它体积大,那也是因为你的用法让它不得不大,比如用了一堆模板。更何况,君不见还有人拿python 和lua 搞嵌入式呢,不也挺愉快的。当然更多的学习肯定是必要的,推荐从C++11 开始。

另外,想有好的体验肯定要有好的编辑器作为基础,扔掉Keil 和IAR 的辣鸡IDE 吧,推荐用VS code,Keil 和IAR 的支持插件都有,感谢开源。

1. 命名空间

为了避免不同模块的函数和其他全局符号同名造成错误,C 里一般是给名字加前缀,比如这种形式:

data = AMAZING_TOOL_get_data(void);

不用说也知道这太丑了,不谈打字累的问题,看代码的时候一团一团的前缀也会让人眼睛发酸。相比之下,C++ 引入了命名空间来分割不同模块的符号,不会像C 一样一include 头文件,所有符号就直接冲进来了。有了命名空间,上面那种写法就可以改成这样:

data = amazing_tool::get_data(void);

好像要打的字还更多了,但是一来,冒号比下划线容易敲(可能),二来,现在你有了选择的余地。比如:

void do_the_fxxk_thing(void) {
	using namespace amazing_tool;
	auto data1 = get_data();
	auto data2 = get_data();
	auto data3 = get_data();	
	//auto data = amazing_tool::get_data()   当然也可以继续这么写
	//...
}

使用using namespace 把所有符号直接导进全局环境里当然是坏习惯,等于重回C 了,但是在单个函数内部用的话就只局限在这个局部,问题不大,哪怕出错了也好解决。

2. 枚举类和typedef

与上面的问题相似,C 的枚举中的成员符号会直接暴露到全局环境中,因此不光枚举本身的名字要加前缀,枚举量一样要加前缀,这就非常之蛋疼,比如:

enum _AMAZING_OpMode {
	AMAZING_op_mode1 = 0,
	AMAZING_op_mode2,
	AMAZING_op_mode3,
};
typedef enum _AMAZING_OpMode AMAZING_OpMode;
...
AMAZING_OpMode mode = AMAZING_op_mode3;

C++11 中添加了枚举类,一方面解决了符号名称的问题,另一方面还允许定义枚举量的数据类型,除了不直接支持位运算,有点不爽,别的方面已经差不多完美了,对比:

namespace amazing {
enum class OpMode: uint8_t {
	op_mode1 = 0,
	op_mode2,
	op_mode3,
};

} //namespace amazing
...
auto mode = amazing::OpMode::op_mode3;

命名空间的问题已经说过了,多打这点字不是问题。除了枚举量的名字不会到处都是,现在还可以保证mode 变量的底层数据表示是uint8_t 类型,拿来给寄存器赋值什么的都更方便可控了。

而且,C++ 中定义枚举、结构或者类的时候,不再像C 一样后面还得有一行typedef 才能正常用(否则变量类型前面还得再加一个enum 或者struct),可以说都是很暖心的改进。

除此之外,还有一点实际中很方便的提升,就是在有代码提示的时候,敲出OpMode:: 之后,代码提示会自动显示所有枚举量成员,类似于结构体指针敲出->之后自动提示成员。这一点其实也是很甜的,如果试过的话。

3. 内联函数和常量表达式

这两个可以说是和嵌入式环境绝配的C++ 特性,比如,如果有几个经常要用的寄存器或者引脚操作的话,为了避免代码重复,以及提高可维护性和可读性,会考虑拿函数封装一下。当然嵌入式环境不能像一般桌面开发一样资源毫不吝啬,函数随便套,简单的固定操作的话,用C 的时候应该会考虑用宏来做,比如:

//把一个引脚拉低几个时钟周期之后再拉高。
#define DO_IT() PIN0 = 0; __nop__(); __nop__(); PIN0 = 1
...
DO_IT();

讲究一点还可以整个do{ ... }while( ) 套一下。这样一来就不会有函数的调用和内存开销,虽然可能会多占点ROM,不过一般,廉价的单片机上RAM 资源应该更稀缺吧,几十个字节多整两个变量就占完了。

然而,宏的问题应该算是众所周知了,真的不知道的话可以去搜一下用宏可能带来多少麻烦。最关键的就是:难看;难写;难调试。以及命名污染的问题,宏当然不会被命名空间限制。所以在C++ 里,现在可以换个方式来更好的完成同样的事:

//把一个引脚拉低几个时钟周期之后再拉高。
inline void do_it() {
	PIN0 = 0;
	__nop__();
	__nop__();
	PIN0 = 1;
}
...
do_it();

这样的一个内联函数,在编译完成之前,看起来和普通函数都没有区别,编译后,函数的内容都会有序的原地插进去,像宏一样,除了占点ROM 以外,没有别的开销。当然也不是没有差别,比如函数指针就用不了了(不绝对)。不过编译器并不保证一定会把内联函数内联进去,一般会取决于优化等级和倾向,比如速度优先还是代码体积优先,因为如果内联函数内部比较复杂,到处内联的话肯定导致代码体积膨胀。只是几条简单指令的话一般没问题,可以直接看看汇编检查一下,应该没人会往内联函数里放一堆局部变量然后搞运算。

宏的另一种经典用途是定义常量,以及进行常量计算,比如类似这种:

#define PI    (314)
#define PI_2  (2 * PI)
#define 平方(n) (n * n)
...
int 结果 = 10 * 平方(PI)

像样的编译器都有常量折叠优化(以及unicode 支持),意思是后面那行赋值在实际运行中多半是不用计算的,编译器可以直接把表达式里的所有常量值代进去算出来结果,程序代码里根本不会有计算的部分,直接就是一行赋值。要注意区分一下,这里说的常量指的是编译期常量,基本等同于字面量,就是程序里直接写死的数值,或者说是立即数。与之相对,const 修饰的常量一般并不会被视为编译期常量,它等同于只读的变量,会有内存占用。比如说一个函数可以用const 修饰参数,但是显然,函数的实际参数值在编译时是难以确定的。而且const 只读变量可以取地址,字面量肯定不行。当然C/C++ 的const 很多概念是糊成一片的,也算是历史包袱的一部分。

上面这些常量定义和常量运算在C++ 中都不需要再用宏定义了,与const 不同,现在有了更名副其实的constexpr,如下:

constexpr int PI = 314;
constexpr int PI_2 = PI * 2;
constexpr int 平方(int n) {
	return n * n;
}
...
int 结果 = 10 * 平方(PI); 

这些代码和宏定义等效,多写的字是因为更严格的定义,比如现在可以直接规定常量的数据类型了。另一个相同点是,编译器并不保证宏函数运算一定会被折叠成常量,因为宏的参数也可以是变量,这时候肯定就不能折叠了,或者是宏太复杂,编译器算不过来。用constexpr 修饰的函数也一样,并不保证它的运算不会进入运行期,而且没有跟上最新C++ 标准的编译器还会要求函数里只能有一条表达式,也不是大问题,你可以嵌套一堆三目运算符。与之相反,constexpr 直接修饰的常量,比如上面的PI_2,则保证是编译期常量,如果给它赋值的表达式PI * 2 编译器算不出来就会报错。所以说还是C++ 特有的拧巴感。

也有办法可以保证运算在编译的时候完成,利用的就是对常量的保证——如果用函数给常量赋值,那么编译器就会要求函数的返回值必须能直接算出来,否则编译错误。类似这样写:

constexpr int 结果 = 10 * 平方(PI);

不过这样写无论如何都会报错,因为编译器一看平方函数的参数,发现里面有一个int n,谁知道这个平平无奇的n 会代表什么呢。编译器会说,参数n 按要求应该是个字面量,这样函数才能直接算出结果,但它可能不是,所以编译器会报错。当然你会很冤枉,明明我传的参数也是个常量,为什么编译器都不多看一眼,但是没办法,确实没有额外的约束,让int n一定是个常量,constexpr 不能用来修饰参数。而如果没有约束,编译器就可能不对函数调用做处理,导致不希望的结果。

所以解决方案也很简单,换一个可以强制参数必须是常量的写法就好了,如下(这个例子我好像并没有测试过):

template <int N>
constexpr int 平方() {
	return N * N;
}
...
constexpr int 结果 = 10 * 平方<PI>();

现在函数连参数都没了,而模板参数必须能在编译期处理,所以如果传入的参数不是常量,那就会报错。只不过写着丑,用着也丑,参数类型只能是各种整型,还会让模板PTSD 患者发癫。所以到了C++ 20 标准,又推出了更新的特性来解决这问题,也就是专门用来修饰常量函数的consteval 关键字和专门用来修饰常量的constinit 关键字,这两个关键字一结合,几乎取代了constexpr,写法如下:

constinit int PI = 314;
constinit int PI_2 = PI * 2;
consteval int 平方(int n) {
	return n * n;
}
...
constinit 结果 = 10 * 平方(PI); 

基本就是查找替换的活。此时可以保证平方函数能且只能在编译时求值,运行时完全不会有它的影子,相应的,它的参数也只能是常量。不过一方面,constexpr 也不能完全被取代,既能运行时也能编译时,这是缺点,也是优点,不用一份功能写两遍了。另一方面,C++20 看名字也知道就是这两年的事,嵌入式领域的支持会更薄弱,只知道给AVR 用的avrgcc 现在有支持C++20 的民间版本,IAR 的话还没试。大部分时候还是至少要保持C++11 的兼容性,只能用constexpr,把编译器的优化等级开高点,一般也不会有问题。

除此之外,用consteval 还有一个麻烦,它是有传递性的,这意味着所有对consteval 函数的调用都只能使用常量,如果想在一个普通函数内用函数参数调用consteval 函数就会通不过编译,虽说这个麻烦其实也是理所当然的结果。如下:

consteval int 平方(int n) {
	return n * n;
}

int gan(int x) {
	printf('%d\n', 10 * 平方(x));    //编译错误
	return 平方(x)
}

即便gan 函数的参数实际上是手写的字面量。解决方案同样是用模板:

template <int X>
int gan() {
	printf('%d\n', 10 * 平方(X));
	return 平方(X);
}

顺便一说,从发展的眼光看C++ 的特性会更有利于理解——新的特性都是用于、或者尝试用于解决已有的问题。而已有特性的缺陷又不停呼唤着新特性的推出。如果事先不了解它们希望处理什么问题,那理解当然是存在不足的。

4. 函数默认参数

简单但是用起来还不错的特性,很多时候能取代以往的函数重载。可以少打点字,减少失误率,但是反过来也有可能增加失误率。关于默认参数是有一些坑的,不过一般用起来也不会踩到。

用法如下:

void show_something(int what, OpMode mode_a = OpMode::mode3, OpMode mode_b = OpMode::mode1) {
	...
}
...
show_something(10);
show_something(10, OpMode::mode1);
show_something(10, OpMode::mode2, OpMode::mode2);

一个函数,三种写法。也不是什么稀奇的东西。

5. 模板和变参模板

这部分的话一般其实很少用到,以单片机程序的复杂度,犯不上搞这些,相对来说也比较复杂。总之还是放着了,有时候万一需要搞一个参数长度可变的函数这种花活的话就要涉及到这部分。

7. RAII

基本上就是自动调用一组函数,在启动/打开什么东西之后还需要关闭它的场景会有用,类似于Python 的with 的语句发挥的作用。示例如下:

class ScopeGuard {
	ScopeGuard(T arg) {
		do_start();
	}

	~ScopeGuard() {
		do_end();
	}
}
...
void play() {
	auto arg = something();
	ScopeGuard obj{arg};
	...
}

以上代码中定义了一个类ScopeGuard ,它的用途是在play 函数内部的局部变量obj 初始化的时候,在构造函数中执行别的什么代码,实现开启或者开始什么东西的准备工作,然后当play 函数在任何地方(包括因为异常而跳出,不包括中断)退出时,对象被析构,自动在析构函数里完成关闭操作。也就是说只有开启的部分需要操心。

很多时候这个自动关闭的机制是有用的,不光是避免忘记写。当一个函数内部有多个return 语句,也就是有多个退出路径的时候,可能很难想到个简单好看的办法把先前开启的各种东西统一关闭掉,硬要保证只有一个退出点又会导致代码写的比较拧巴,手动goto 跳来跳去同样,看着很危险,所以使用RAII 的技巧是很合适的。

虽说这会在函数局部,也就是栈上构造一个对象,不过可以看到,这个类里除了构造和析构函数以外什么都没有,obj 对象里当然也空无一物,所以其实就是个用来调用构造和析构的摆设,并不会占内存。这一点和Java、Python 之类的所有对象都是动态构造的不一样,它们的一个对象变量至少是一个指针。如果不知道的话,补充说一句,虚函数以外的成员函数都是静态链接的,就是说其实和普通的函数没有不同,不会留个函数指针之类的东西在对象里。另外,定义在类内部的成员函数是默认内联的,所以如果内部操作比较简单的话,函数调用开销也不会有。

8. 总结

这些只是我自己平时比较能用到的东西,也算是一种使用C++ 的哲学,就是只用C++ 全部特性中,最能帮自己完成工作的一部分。基于这种思想,C++ 在有些地方被划分为几种不同的类型,也就是面向对象的C(C with class)——差不多等于在用Java、加强版C——只把C++ 当作C 的一个新版本,补了一下C 不好用的地方、模板黑魔法——把模板提到中心位置。
总之,不管怎么说,相比C,C++ 提供了更多的选择,一个C++ 程序也可以没有一个地方和C 有差异,这也是一种选择。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值