大多数嵌入式的初学者都是从单片机裸机编程开始的,对于初学者来说,裸机编程更加直观、简单,代码所见及所得,调试也非常方便,区别于使用操作系统需要先了解大量的操作系统基础知识,调度的基本常识,还需要注意各种资源的共享与竞争等概念,并且调试也没有那么直观等等。裸机编程在一些比较简单的项目上还是具有一定的优势的。
接下来我们来看看裸机编程的常见模式和架构。
1.主循环轮询模式
主循环轮询模式就是在主函数中使用一个永不退出的 while(1) 来承载所有的应用逻辑,如下:
int main(void) {
while(1){
do_a();
do_b();
do_c();
}
}
do_a、do_b、do_c 三个函数依次执行,全部执行完毕后再次从 do_a 逻辑开始,以此不断循环。
这种模式是最简单也是最初级的模式,但其也存在很多问题。由于上述三个逻辑会依次执行,那么就会相互影响,do_b 必须要等 do_a 执行完后再执行,do_c 必须要等 do_a 和 do_b 都执行完后才执行,一旦前置逻辑中存在大量的延时,后续逻辑就无法得到及时的运行。
比如后续逻辑中存在一些交互行为,do_b 会判断一个按键的按下状态并做出响应,而此时还在 do_a 中执行延时指令,那么整体运行就会显得非常卡顿,甚至还会因为错过用户按键的时机而导致即使按下了按键,也没有执行对应的反馈。
2.中断执行模式
针对于上面的问题,很多人就会使用中断来解决。对于一些需要立即响应的操作,将其放在中断中,从而避免其被主程序中的其他逻辑所影响,此时代码可能如下所示:
//按键中断
void key_isr(void){
do_b(); //按键按下的操作
}
int main(void) {
while(1){
do_a();
do_c();
}
}
主循环中还是正常执行非交互式的逻辑,而对于上例中按键交互的逻辑 do_b,则放到对应的按键信号捕获中断中(如 GPIO 外部中断)。此时即使在执行主循环中的其他逻辑,由于中断会打断主循环立即运行,所以按键信号会被立刻检测到并响应。
无法及时得到响应的问题解决了,对于一些非常简单的逻辑,这种模式就足够了,但如果主循环中的逻辑有一定的周期性要求,如 do_a 需要每隔 100 毫秒执行一次, do_c 需要 50 毫秒执行一次,于是 do_a 和 do_c 下就会存在 delay(100) 和 delay(50) 的代码:
// 按键中断
void key_isr(void) {
do_b(); // 按键按下的操作
}
void do_a(void) {
delay(100); // 延时100ms
// do_a 逻辑
}
void do_c(void) {
delay(50); // 延时50ms
// do_c 逻辑
}
int main(void) {
while (1) {
do_a();
do_c();
}
}
此时无论 do_a 和 do_c 谁前谁后,他们的执行周期都会拉长到至少 150 毫秒!因为顺序执行的原因,你必须等待上一个逻辑执行完才能执行下一个逻辑。
这种情况下 do_a 和 do_c 任何一个逻辑的周期都无法被满足,这种模式的缺陷也就显现出来了。
3.中断+定时器+主循环的前后台架构
上例的一个最大问题就是主循环的每次执行都要完整地将所有逻辑都执行一遍,而每个逻辑中为了控制自身的周期又用了延时。各个延时就不可避免地影响到其他逻辑的执行,再由于顺序执行的逻辑,其他逻辑的执行又影响到了自身,产生恶性循环,最终没有一个逻辑是符合其自身的周期的。
既然如此,我们可以使用定时器产生一个时间标志,这个标志代表了当前系统运行的时间,主循环中的逻辑再检测这个时间,如果满足自身执行的时间,那么就执行自身逻辑,如果不满足则直接跳出,让其他逻辑执行,中断逻辑仍然不变。这种情况下前台就是中断,后台就是主循环,其代码形式如下:
// 按键中断
void key_isr(void) {
do_b(); // 按键按下的操作
}
// 定时器中断 1ms 进一次
unsigned int tick = 0;
void timer_isr(void) {
tick++;
if (tick > 10000) tick = 0;
}
void do_a(void) {
if (tick % 100 == 0) {
// do_a 逻辑
} else {
return;
}
}
void do_c(void) {
if (tick % 50 == 0) {
// do c 逻辑
} else {
return;
}
}
int main(void) {
while (1) {
do_a();
do_c();
}
}
由上述代码可以看到定时器中断为 1 毫秒,每进一次中断 tick 加 1,在主循环中的 do_a 和 do_c 会首先判断 tick 的值,一旦发现与自己的运行周期相同,则执行自身逻辑,否则退出。此时理想的运行图如下:
由于去掉了每个逻辑中的延时,取而代之的是标志位的判断,其执行速度是非常快的,如上图所示 ,灰色的块表示在运行判断逻辑并且没有满足运行要求。这种情况下每个逻辑都能在其指定的周期内得到执行。
这种架构在裸机编程中可以算得上一种中高级的架构,能够满足大多数不是特别复杂的需求。当然,在上图中我们可以看到 do_a 和 do_b 一个为 100 毫秒,一个为 50 毫秒,存在公倍数情况,也就是说在某一时刻,如这里的 0 毫秒和 100 毫秒,就会出现两个逻辑同时运行的场景。实际在项目中如果要求比较严格,会对这个周期进行一个控制和计算,尽量减少各逻辑同时执行的概率,避免由于同时执行的逻辑过多且过于频繁,执行时间的总和仍然会太长,从而影响整体运行稳定性的问题。
到这里请思考一下,假如 do_a 逻辑本身的执行时间就很长,比如进行一个非常复杂的运算,或者需要读取一个 G 级别的文件,导致单一逻辑的执行时间就超过了最小周期(如例子中的 50 毫秒),那即使 50 毫秒的周期到了,由于 do_a 还没运行完,do_c 也无法得到运行,这时候时间标志已经形同虚设,甚至由于此处是取余判断,假如 do_a 运行了 51 毫秒结束,do_b 在判断的时候已经是 52 毫秒,52%50 不为零,do_b 直接无法执行,时间标志甚至产生了负面影响!
虽说将 “通过取余运算判断是否可以执行的逻辑” 修改为 “设置多个时间标志(如 50ms_flag、100ms_flag等),在中断中判断满足时间就将这些标志置位,主循环中直接对这些标志进行判断的逻辑” 可以避免由于时间后延导致的无法触发逻辑执行问题,但仍然无法解决周期被影响的本质。
怎么办?
4.前后台 + 状态机架构
既然上面的问题是由于主循环中单个应用逻辑自身执行时间太长导致,那么我们就将其拆分,原本一个逻辑只能一次执行完,现在就拆分成多个步骤,每次执行只运行一个步骤而不是完整的逻辑,再用一个变量去记录当前执行到了哪个步骤,下次进入就执行下一个步骤。
这就是状态机编程(以 do_a 为例,其他主循环逻辑同 do_a ):
void do_a(void) {
static unsigned char step = 0;
if (tick % 100 == 0) {
switch (step) {
case 0:
// 执行第一步
step++;
break;
case 1:
// 执行第二步
step++;
break;
case 2:
// 执行第三步
step = 0;
break;
default:
// 未知步骤,归零重来
step = 0;
break;
}
} else {
return;
}
}
可以看到原本 do_a 我们将它看作一个完整不可分割的逻辑,执行完整个 do_a 才会退出,而现在我们将其拆分成了3个步骤,每执行完一个步骤就会退出 do_a 函数,直到下一次进入才会执行下一个步骤,这样一来就能有效缩短一次 do_a 执行的时间,从而大大降低其一次执行时间会超过所有逻辑中最小周期的可能性。主循环中其他应用逻辑也和 do_a 一样,利用更加细分的状态机模式来加快主循环的响应效率,进一步提高了裸机编程的稳定性和时间可控性。
状态机的加入也使得裸机编程走向了其终极形态,使其能够处理更加复杂的逻辑与应用,与此同时,其代码量和复杂度也极速上升,尤其是当你的主循环中有十几个甚至几十个任务逻辑,此时你就会面临地狱级的编程难度。
当然,即使你能够接受地狱级挑战,最终也仍然会遇到一个问题 —— 随着应用逻辑的增多,同一时间执行了大量的状态机分支步骤,这些步骤仅凭人工已经很难再进行拆分了,并且很不幸,它们执行时间的总和超过了预定的周期,最终导致了各种各样的问题。
此时恭喜你,已经走到了裸机编程的巅峰,同时也是裸机编程的尽头。是时候迈开脚步,走向操作系统编程这条路了!