1.嵌入式系统
一个计算机系统与其它系统,如机械系统、电子系统紧密的结合在一起。它在时间上和空间上的限制和程序正确性的定义与整个系统密切相关,无法孤立地考察计算机系统。因此,嵌入式系统编程需要关注
- 程序的可靠性
- 系统资源是有限的
- 实时响应
- 持久性
- 不能内行维护(hands-on maintenance)
2.基本概念
- 正确性(correctness):不仅仅是产生正确的结果,还要在正确的时间得到正确的结果以及只使用允许范围内的资源。
- 容错(fault tolerance):不能时时假定硬件会按规定方式工作。
- 不停机(no downtime):对错误处理和资源管理的极高要求。
- 实时性限制(real-time constraint):每个操作都严格要求在一个时限内完成,称硬实时(hard real time);大多数时间要求操作在时限内完成,称软实时(soft real time)。
- 可预测性(predictability):如果一个操作在一台给定的计算机上每次的执行时间总是相同的,而同类操作的执行时间也应该都是相同的。
- 并发性(concurrency)
C++中不可预测的语言特性:
- 动态内存空间分配new和delete
- 异常
- 动态类型转换dynamic_cast
这些语言特性应该避免在硬实时系统中使用。间接使用这些特性的东西也不能使用,如STL中的vector,map。对于异常,找到每个throw匹配的catch并计算出多长时间到达catch是困难的,需要使用返回代码等老式技术来编写错误处理程序。
对性能和可靠性的追求导致程序员倒退到只使用低级语言特性,但对中大型的程序,这么做会使整体设计陷入混乱,使程序的正确性难以验证,大大增加系统开发的成本和时间。除非真的需要不要执着于代码优化。
第一:不要做优化
第二(仅对行家里手):还是不要做优化
------------------------------------------------------------John Bentley
设计和实现一个具有容错能力的系统涉及的一些一般原则
- 避免资源泄漏(resource leak):资源包括CPU时间、内存、锁、通信信道、文件等等。
- 副本(replicate):为关键资源配置副本
- 自检测(self-check):需要注意检测模块本身是否可靠,报告错误的过程本身可能导致一个新的错误
- 能够迅速排除有错误的代码:系统的模块化
- 对子系统进行监控:对关键子系统都配置三重备份,不仅仅是设置两个热力设备,还能在设备行为不一致时,通过投票来确定正确的结果
交付用户前,需要系统的、全面的测试。
3.内存管理
时间(执行指令)和空间(保存数据和程序的内存)是计算机最重要的两种资源。C++中三种分配内存的方法
- 静态内存(static memory):由链接器分配的,其生命周期为整个程序的运行期间
- 栈内存(stack memory,自动内存):调用函数时分配,函数返回时释放
- 动态内存(dynamic memory):用new操作分配,delete操作释放。
从嵌入式程序设计的角度,将可预测性作为必备的性质,考察这几种内存分配方式,即针对硬实时系统程序设计和安全系统程序设计。
静态内存分配不会引起任何特殊的问题,所有的内存分配工作都在程序开始运行之前就已经完成了。
栈内存分配过多可能导致一些问题,须确保没有任何程序在执行时会使栈溢出,通常意味函数调用的最深层次不能超过限制。如使用循环来避免递归。
动态内存分配通常是被禁止或受到严格限制的:new或者被禁止,或只在启动时使用,而delete则被严格禁止。因为
- 可预测性:动态内存不能保证在固定时间内完成,在已经分配和释放了很多对象后,再分配新的对象,所花费的时间会呈上升趋势。也就是所动态内存分配是不可预测的。
- 碎片(fragmentation)
可以使用栈和存储池技术系统地避免动态内存分配带来的问题。
3.1 动态内存分配存在的问题
new的问题出在new和delete的结合使用上。考察如下代码
Message* get_input(Device&);
while(/*...*/){
Message* p = get_input(dev);
//...
Node* n1 = new Node(arg1, arg2);
//...
delete p;
Node* n2 = new Node(arg3, arg4);
//...
}
在每个循环中,创建了两个Node和分配了一个Message并释放。假设系统使用一个简单的内存管理程序,Message比一个Node稍大。动态内存的使用情况如下图所示
可以看到,每执行一个循环,就会在动态内存中留下一些内存碎片(无法满足新的内存需求的内存)。这是任何频繁使用new和delete的系统长期运行后都会遇到的一个严重问题。
-
那让“语言”和“系统”来处理这个问题呢?
如让系统移动Node,将空闲的空间缩成一片连续的区域,这样就可以存储新的对象了。这是不可行的,因为C++是直接用内存地址来访问对象的,如果系统移动了对象,这些指针就不再指向正确的对象,指针变量保存的地址就变为无效的了。 -
那在移动对象的同时修改指针呢?
实现这个的前提是必须知道数据结构的细节。一般情况下,系统是无法知道指针在哪里的。也就是说,给定一个对象,无法回答“程序中的那些指针现在指向这个对象?”。即使可以(压缩垃圾收集,compacting garbage collection),除了程序所使用的内存外,还需要两倍于此的空间来跟踪指针,以及移动对象。并且高效的垃圾内存收集程序很难达到可预测性。 -
我们自己可以回答“指针在哪?”自己编写程序来进行空间压缩?
如在本例中,在分配Message之前为两个Node分配空间。这是可行的,但一般情况下重整代码是非常困难的。因此倾向于限制动态内存分配的使用,预防比治疗更有效。
3.2 动态分配内存的替代方法
单独使用new操作不会导致碎片,用delete操作释放内存时才会产生空洞(空洞可能成为碎片)。
因此,第一步先禁止delete,这意味着一旦对象分配了内存空间,那么生命周期就会持续到程序结束。
- 不再使用delete了,new就是可预测的吗?
一般是这样,在加电系统初始化期间,我们可以通过使用全局(静态)内存或new分配内存空间。
从程序结构的角度来说,应该尽量避免使用全局数据,但全局内存分配方式可以实现内存空间的预分配。
实现可预测内存分配
- 栈:可以分配不超过预设最大值的内存空间,栈只在栈顶一段增长和缩小,内存空间的分配和释放是不会交叉的 ,所以不存在碎片问题。
- 存储池:指一组相同大小的对象的集合。因为对象都是相同大小,因此不会产生碎片。
3.3 存储池实例
存储池是一种数据结构,可从中分配指定类型的对象,随后可将这些对象释放。一般定义如下
template<typename T, int N>
class Pool{ //N个T类型对象的存储池
public:
Pool(); //创建N个T的存储池
T* get(); //从存储池获取一个T;若无空闲T返回0
void free(T*); //将get()获得的一个T归还存储池
int available()const; //空闲T的数目
private:
//T[N] 所需空间以及跟踪哪些T被分配、哪些未分配所需的数据
};
使用代码示例如下
Pool<Small_buffer, 10> sb_pool;
Pool<Status_indicator, 200> indicator_pool;
Small_buffer* p = sb_pool.get();
//...
sb_pool.free(p);
应该确保存储池不会被消耗尽。根据应用场景,只有在pool有空闲空间的情况下才调用get(),另外一些场景可以检查get的返回值,在返回值为0的情况下进行一些补救措施。
3.4栈实例
与存储池不同的是,栈可以保存不同大小的对象。
template<int N>
class Stack{ //N个字节的栈
public:
Stack(); //创建一个N个字节的栈
void* get(int n); //从栈中分配n个字节,若无空闲返回0
void free(); //将get()返回的最后一个值归还给栈
int available()const; //空闲字节数
private:
//char[N] 所需空间以及跟踪哪些T被分配、哪些未分配所需的数据
};
这种栈的使用方式如下
Stack<50*1024> my_free_store; //用50KB空间构建一个栈
void* pv1 = my_free_store.get(1024);
int* buffer = static_cast<int*>(pv1);
void* pv2 = my_free_store.get(sizeof(Connection));
Connection* pconn = new(pv2)Connection(incoming, outcoming, buffer); //定址new,在pv2指向的内存空间中创建一个对象
4.地址、指针和数组
可预测性是某些嵌入式系统的需求,而可靠性是所有嵌入式系统都需要的。需要避免使用哪些已被证明容易出错的语言特性和程序设计技术。如指针,有两个突出的问题
- (未经检查的和不安全的)显示类型转换------->只要显示类型转换非必须就应该避免使用
- 指向数组元素的指针作为参数传递----------->使用简单的类或标准库功能(如array)
4.1 从将数组作为参数以指针的形式传给函数
如标题所述将导致数组大小丢失 ,产生许多难以修正的bug。如下的代码给出了很好的示例
void poor(Shap* p, int sz){ //糟糕的接口设计,调用者极易出错
for(int i=0; i<sz; ++i){
p[i].draw();
}
}
void f(Shap* q, vector<Circle>& s0){
Polygon s1[10];
Shape s2[10];
Shap* p1 = new Rectangle{Point{0,0}, Point{10, 20}};
poor(&s0[0], s0.size()); //传递来自vector的数组
//元素类型传递错误,s0还可能是空的
//Circle虽然是Shape的衍生类,Circle*->Shap*的转换是允许的
//但是poor中还把Shap*作为数组使用,sizeof(Circle)和sizeof(Shape)不是相等的
poor(s1, 10); //元素类型错误
//sizeof(Polygon)==sizeof(Shape)
//但使用了魔数10
poor(s2, 20); //使用了错误的魔数20(未经定义的数字)
poor(p1, 1);
delete p1;
p1 = 0;
poor(p1, 1); //传递了空的指针
poor(q, max); //可能是正确的,需要检查q指向的数组是否包含至少max个元素
}
“D是B”并不意味着“D的容器是B的容器”
-
不希望数组参数以“指针+大小”的方式传递,那替代方法呢?
最简单的方法是传递容器的引用。 -
但是容器依赖动态内存分配啊?
所以需要定义一个功能与容器相似,但不使用动态内存分配的容器。
template<typename T>
class Array_ref{
public:
Array_ref(T* pp, int s):p(pp), sz(s){}
T& operator[](int n){return p[n];}
const T& operator[](int n) const {return p[n];}
bool assign(Array_ref a){
if(a.sz != sz) return false;
for(int i=0; i<sz; ++i){p[i] = a.p[i];}
}
void reset(Array_ref a){reset(a.p, a.sz);}
void reset(T* pp, int s){p = pp; sz = s;}
int size()const{return sz;}
//默认拷贝操作,Array_ref不拥有任何资源
private:
T* p;
int sz;
}
Array_ref本质是一种引用,并不拥有元素,也不进行内存管理,只不过是一种访问及传递元素序列的机制。设计了辅助函数简化Array_ref的初始化
template<typename T> Array_ref<T> make_ref(T* pp, int s){
return pp? Array_ref<T>(pp, s):Array_ref<T>(nullptr, 0);
}
template<typename T> Array_ref<T> make_ref(vector<T>& v){
return v.size()? Array_ref<T>(&v[0], v.size()):Array_ref<T>(nullptr, 0);
}
template<typename T> Array_ref<T> make_ref(T(&pp)[s]){ //T(&pp)[s]声明了一个引用类型参数pp,pp引用的是一个元素类型为T,元素个数为s的数组
return Array_ref<T>(pp, s); //C++不允许声明空数组,此处不需要检查
}
重写示例
void better(Array_ref<Shape> a){
for(int i=0; i<sz; ++i){
a[i].draw();
}
}
void f(Shap* q, vector<Circle>& s0){
Polygon s1[10];
Shape s2[10];
Shap* p1 = new Rectangle{Point{0,0}, Point{10, 20}};
better(make_ref(s0)); //错误:需要Array_ref<Shape>
better(make_ref(s1)); //错误:需要Array_ref<Shape>
better(make_ref(s2)); //正确
better(make_ref(p1, 1)); //正确
delete p1;
p1 = 0;
better(make_ref(p1, 1)); //正确
better(make_ref(q, max)); //正确(若max合法)
}
4.2 继承和容器
我们需要将Circle对象序列作为Shape对象序列来处理,上一节还是做不到,另外,为了运行时正确的多态行为,必须通过指针或引用访问多态对象而不是点操作符。
一看到对多态对象使用点操作符而不是箭头时,就该想到要出问题了。
- 那应该怎么办呢?
首先必须用指针或引用访问对象,指针的大小是固定的,且我们不修改Array_ref<Shape>
,因此应该使用Array_ref<Shape*>
。
重写better
void better2( const Array_ref<Shape* const> a){ //不修改a, 也不修改指针
for(int i=0; i<sz; ++i){
if(a[i]) a[i]->draw();
}
}
- 如何实现
Array_ref<Circle*>
转换为类似cosnt Array_ref<Shap*>
的东西呢?
答案是通过类型转换运算符。
代码如下
template<typename T>
class Array_ref{
public:
//其它不变
//类型转换运算符实现到Array_ref<Shap*>的转换
template<typename Q>
operator const Array_ref<const Q>(){
//检查元素的隐式转换
static_cast<Q>(*static_cast<T*>(nullptr)); //检查元素,转换
return Array_ref<const Q>(reinterpret_cast<Q*>(p), sz);
}
}
void f(Shap* q, vector<Circle>& s0){
Polygon s1[10];
Shape s2[10];
Shap* p1 = new Rectangle{Point{0,0}, Point{10, 20}};
better(make_ref(s0)); //正确
better(make_ref(s1)); //正确
better(make_ref(s2)); //正确
better(make_ref(p1, 1)); //正确(无需转换)
delete p1;
p1 = 0;
better(make_ref(p1, 1)); //错误 需要Array<Shap*>而不是Shap*
better(make_ref(q, max)); //错误 需要Array<Shap*>而不是Shap*
}
5.位、字节和字
5.1标准模板库类bitset
用于描述和处理二进制位集合。见《C++程序设计原理与实践(进阶篇)》290-296页。
5.2位域
硬件接口是一组二进制位和不同大小的数,C++用位域来处理这种固定的数据布局。见《C++程序设计原理与实践(进阶篇)》296-297页。
6.编码规范
“糟糕的程序设计方式”主要是指使用语言特性的方式容易引起错误,或者表达思想的方式模糊不清。编码规范解决的是后一类问题。它定义一个“排版风格”来为程序员指引方向:对于特定应用,使用哪些C++语言特性比较恰当。编码规范试图解决方案表达方式方面的问题,而不是应用的复杂性方面的问题。
6.1好的编码规范
-
应该针对特定应用领域和特定程序员来设计
-
既有指示性又有限制性,推荐一些基础的库功能作为指示性原则通常是有效的方式
-
一个编码规范就是一个编码原则集合,指明了程序风格
- 指定命名和缩进原则
- 指定允许使用的语言子集
- 指明注释原则
- 指明使用哪些库 -
大多数编码规范的共同目标是提高程序的
- 可靠性
- 可移植性
- 可维护性
- 可测试性
- 重用性
- 可扩展性
- 可读性 -
一个好的编码规范要比没有规范更好
-
一个糟糕的编码规范甚至比没有规范更糟
-
不是所有程序员都喜欢编码规范
6.2编码原则实例
编码原则分类
- 一般原则
- 预处理原则
- 命名和布局原则
- 类原则
- 函数和表达式原则
- 硬实时原则
- 关键系统原则
更详细的内容见《C++程序设计原理与实践(进阶篇)》304-308页。