Skip to content

Latest commit

 

History

History
636 lines (542 loc) · 45.4 KB

Something Embedded.md

File metadata and controls

636 lines (542 loc) · 45.4 KB

嵌入式软件工程师学习笔记

目标

本文档记录一些本人嵌入式学习过程的经验总结,旨在帮助大家更好地学习嵌入式软件知识!

散记

关于地址资源

  • C语言是操作地址的技术,地址不是无限到位,所以要利用好地址资源。
  • 对于32位机而言,其寻址空间是32位的,即有2的32次方,4GB的空间。

关于内存

  • 在MCU开发中,内存的使用十分关键,同时也是很多初学者容易忽略的知识点。
  • 这里的内存我们指的是MCU中的RAM资源,大致可以分为:全局/静态存储区、堆和栈

全局/静态存储区

  • 全局变量(gobal)是指作用域为全局的变量,其变量生命周期为全周期(直到所有程序完全运行结束)
  • 静态变量(static)同全局变量,其生命周期为全周期,其作用域限定于定义的位置,若变量定义在函数内,则作用域在函数内,若在函数外,作用域为该源文件定义之后的所有位置。
  • 不论是全局变量还是静态变量,其编译后的地址是固定的,可以从map文件中找到明确的地址。而栈和堆中的某个变量地址是不固定的。栈是在运行过程中由MCU自动分配的,而堆则是在运行过程中由程序员编写的程序来分配的。

  • 栈(stack)经常被称为堆栈,这里需要说明一下堆和栈其实是两个概念。栈是一种先入后出的结构,在程序运行时用于储存局部变量和函数上下文。
  • 栈是对一段RAM空间的称呼,POP,PUSH就是针对栈的操作,由SP(stack pointor)来指示栈顶,由此来维护栈。
  • 调用函数时,将会把当前函数的通用寄存器值和局部变量压入栈中,函数返回时,将上述内容从栈中弹出。
  • 栈的生长方向一般是从高地址向低地址。    
  • 通常所说的堆栈溢出,是指栈被push了超过其容量,最后push的内容覆盖了其它区域(如全局变量区)或无实际物理内存可用而造成的错误。          

  • 堆(Heap)在裸奔的单片机开发中很少用到,动态分配来的内存来自于堆。所谓的动态分配在C语言中就是使用malloc和free这类内存申请和释放函数来实现内存的动态地利用。这里的动态是指内存是在程序运行时,根据需要来进行申请的,其申请到的位置也不固定。
  • 堆可以说没有生长方向这个概念,完全取决于malloc的具体实现。
  • 在没有MMU(内存管理单元)的平台上,频繁的使用堆会造成碎片,例如申请了10个32字节的内存块,但间隔释放掉5个,此时虽然有5*32字节的空间,但任然无法申请一个64字节的内存块出来,因为堆中内存都是碎片化存在的。

关于链表

链表对于学纯软件的工程师而言是个非常简单的概念,但对于嵌入式软件工程师,因为面对都是资源受限的MCU的平台,难得有机会使用这样“高大上”的数据结构,所以会觉得陌生。但随着MCU的发展,主频和储存都在不断扩大,嵌软工程师们也开始不断尝试更复杂的数据结构或软件模型。链表就是一个最典型的需要了解的数据结构,因为由它展开的应用非常多,也非常实用。

链表原理

对于链表的原理在这里暂不描述,网上资源很多,以后有机会再补充该章节

链表的应用 —— 软件定时器

因为链表本身是一种逻辑上连续,空间上不要求连续的数据结构,所以链表可以灵活的增加或缩短。对于这个特性,在嵌入式中用于软件定时器模块是非常合适的。
在软件定时器中,链表结构中除了指向下一个节点的ptNext, 还需要两个关键成员:

1.软件定时器节点启动的时间点OldTime;    
2.软件定时器定时时间Period。   

有了这两个变量,结合我们的当前时间点CurrentTime,就可以知道一个定时时间有没有到期(CurrentTime - OldTime > Period)。
下面的代码定义了一个最简单定时器节点。

typedef sturct _T_TIMER
{
    struct _T_TIMER  *ptNext;  /* Address of next node  */
    uint32 u32OldTm;           /* The start time point  */
    uint32 u32Period;          /* The delay time period */
}T_TIMER

下面我们尝试丰富一下这个定时器的功能。
很多和定时相关的功能并不是只定时一次的,而是采用周期的方式,那我们就需要增加一个变量,用以表示周期性计时和单次计时的差别。对上述节点定义做如下修改

typedef sturct _T_TIMER
{
    struct _T_TIMER  *ptNext;   /* Address of next node           */
    uint32 u32OldTm;            /* The start time point           */
    uint32 u32Period;           /* The delay time period          */
    uint16 u16Count;            /* The count for repeating timing */
}T_TIMER

如果u16Count的值为1,那就是单次触发,触发完就可以删除该节点。如果0,那这里可以表示永远重复计时,时间一到就重新启动。
但是这里为什么要定义一个u16类型呢,一个BOOL型的变量不就足够了吗?相信聪明的读者已经发现了,其实我们不经可以做单次触发和无限制触发,我们还可以设定触发次数,比如u16Count的值为10次,那这个定时器就可以计10的相同周期时间。

至此,一个软件定时器模块所需要的数据结构就定义好了,结构中的成员是定时功能所需要的信息,其实我们可以增加更多成员,来构建更有趣的功能。
定时器计时结束可以视为一个事件发生(定时时间到事件),一般情况下对这个事件有相应的操作,比如定时结束就点亮LED,关闭风扇等等。如果我们将此类和某个定时器相关的操作也放进定时器节点中会怎样呢?

typedef uint32 (*PF_TIMER_CB)(void *p);  /* Callback function when time is up */

typedef sturct _T_TIMER
{ 
    struct _T_TIMER  *ptNext;   /* Address of next node              */
    PF_TIMER_CB pfTmCb;         /* Callback function when time is up */
    uint32 u32OldTm;            /* The start time point              */
    uint32 u32Period;           /* The delay time period             */
    uint16 u16Count;            /* The count for repeating timing    */
}T_TIMER

这里设置无限次重触发,在每次定时结束后就执行一次pfTmCb回调函数,那就可以构建一个基于时间轮片的多任务框架。

下面我们再进一步,在多任务嵌入式操作系统中的时间管理模块,就会使用类似于上述的数据结构,但是还需要一个变量,用于标记与该定时器相关联的任务编号。

typedef uint32 (*PF_TIMER_CB)(void *p);  /* Callback function when time is up */

typedef sturct _T_TIMER
{
    struct _T_TIMER  *ptNext;   /* Address of next node              */
    PF_TIMER_CB pfTmCb;         /* Callback function when time is up */
    uint32 u32OldTm;            /* The start time point              */
    uint32 u32Period;           /* The delay time period             */
    uint16 u16Count;            /* The count for repeating timing    */
    uint16 u16TaskId;           /* The ID of destination task        */
}T_TIMER

上述的结构体成员需要注意排序的问题,在主流的32位MCU中注意4字节对齐问题。


关于队列

同链表一下,队列也是一个嵌入式新手容易忽略的,但却很有用的一个数据结构。

队列原理

同样这里也就不再赘述队列原理,如果有空我在丰富该章节。

队列的应用 ———— 缓存

在写通讯类应用的时候,经常会遇到一种情况,就是接收到一条报文,还没有来得及处理又来了一条报文。对于这种“生产速度”瞬时大于“消费速度”的情形,缓存是最常见的方法。 首先我们来定义一个简单的数组型的队列缓存

typedef struct _BUFFER
{
    uint8  *pu8Data;        /* Pointer of buffer data     */
    uint32  u32DataLen;     /* The bytes of per data      */
    uint32  u32DataCntMax;  /* The count of datas         */
    uint32  u32DataCntCur;  /* Current count of datas     */
    uint32  u32DataHead;    /* The position of first data */
}

一般的队列都有头位置和尾位置,头位置是最早进入队列的数据所在的位置,尾位置是下一个数据即将放入的位置,所以通过尾位置减去头位置就可以知道有多少个数据。当然这里还有一个例外,就是头尾位置相同时,可以表示队列为空,或表示队列为满。为了处理这样的误解,我们在队列的数据结构中增加一个计数器用于统计目前有多少个有效数据。每次压入准备压入新数据时,先行比较当前有效数据是否已经达到最大数,这样既可以识别队列是否为满。而头位置、尾位置和如队列的计数三者的关系本身就是知其二可得第三,所以这里设计只有头位置和计数,尾位置通过头位置+计数可获得。


软件框架

谈到框架、架构,总会让人联想到高大上的东西,但实际上这些概念一直被我们使用,只是没有全面地了解它。软件框架不同于链表,队列这样简小且具体的概念,它更难成形描述却默默应用于我们写的代码之中。
本章节从最简单的系统软件框架开始,循序渐进深入理解操作系统原理。重新认识目前设计框架的原理、方法和优缺点,并帮助大家逐步地理解各种操作系统多任务、同步、进程间通信等概念。
内容:

序号 话题 说明
1 常用框架(轮询/前后台)及典型设计方法(状态机) 面向基础,把原理讲透,把现有的软件设计好。
2 分时系统框架(软件定时器的实现及应用) 逐步提升,强化链表等实用数据结构的应用。
3 事件驱动框架 重新认识事件概念,强化对现有软件设计思路。
4 抢占式操作系统 从以往的协作型调度向抢占型调度转变思路,通过否定再认定协作型调度的过程在深入理解现有设计模型的细节(堆、栈、资源与效率)
5 操作系统原理(任务管理、时间管理、消息机制、信号量及其他) 进阶,通用操作系统的原理,框架、调度以外的实用组件
6 简易操作系统实现 实践,亲自体验各种框架的优缺与区别。

常用框架(轮询/前后台)及典型设计方法(状态机)

在嵌入式系统中,C语言是最为主流的开发语言,而如何使用C语言开发功能复杂的代码,就需要使用框架来帮助设计整体的程序。常用的嵌入式框架可以分为轮询框架、前后台框架和多任务框架:

轮询框架
轮询框架最为简单,即只有一各主循环,在这个循环中,每个任务依次执行。这里对任务的概念进行一下说明。刚学编程的时候,我们大多没有任务的概念,所有的代码都写在一个main函数中,慢慢的随着编程熟练和功能变得复杂,我们开始学会用函数封装某些具体的操作流程,比如将按键检测写在一个Key_Input函数中,把led操作写在Led_Output函数当中,在main主函数中循环调用这两个函数。在这里我们可以把这些函数理解为任务,一个任务就是做具体的某一件事所需要的步骤,这和封装好的函数差不多。 解释完任务的概念,我们举个例子:这里有任务ABC,对应调用a(),b(),c()三个函数来执行任务。那在main函数中就是如下的写法:

int main(void)
{
   while(1)   /* Loop forever */
   {
       a();   /* 执行任务A */
       b();   /* 执行任务B */
       c();   /* 执行任务C */
   }
   return 0;
}

如上代码,任务ABC是依次执行并周而复始,只要每个任务执行的时间都很短,那完成一次循环的时间也很短,从宏观上感觉三个任务都是并行的(虽然微观上还是串行的)。这个就是我们最先接触也最熟悉的框架。这个框架会遇到如下的问题:

  1. 某个任务执行时间过长或任务中有长时间的等待(while/for之类的长时间循环)将会影响其他任务的执行。
  2. 外部事件是随机产生的,产生时对应的任务可能刚刚执行完成,错过了对事件响应和处理的时机,必须等待下次循环再进行处理。

从实时性能角度上来看,轮询框架在实时性上没有天然的保证,必须由开发人员处理好每个任务,让任务必须在短时间内执行完成,才能使该框架正常工作。状态机就是一种有效的设计方法,在后面的章节中我们将详细讲解下状态机的运用。

前后台框架
前后台框架在轮询基础上引入了中断来响应事件。循环执行的主程序为后台,一个或多个响应事件的中断为前台。当事件发生时,由前台中断先行响应,若对事件的处理较多,则中断仅记录事件状态,进行标记,退出中断后再由后台主循环中检查标记进行处理。若对事件的处理较少,可以在中断中直接处理完。前后台框架可以在事件发生时打断主循环的运行,并通过中断来响应事件,大大提高了对事件响的应实时性。
优点:实时性得到一定提升;缺点:事件得到实时响应,但处理依旧还是轮询方式。

多任务框架
多任务框架又在前后台框架之上,从实时性角度进一步进行改进。在这一框架之下,主循环程序被改造成对事件敏感,或称为“事件驱动”。在之前所述的后台系统不再是对每一个任务进行轮询,在任务中通过状态机检查自己是否需要执行何种操作。改造后的后台系统检查是否有事件发生,这个事件是给哪个任务的,然后就直接调用那个任务对事件进行处理。这里最大的不同,就是对事件判断的工作从任务中剥离,交由调度任务的上层框架来处理,

状态机

为什么使用状态机

c语言是面向过程的语言,它更多地描述的是处理一个事情的流程。比如我们定义一个任务A,执行A1,A2步骤,等3秒后执行A3步骤,这就是一个操作的过程。但往往实现一个应用不仅仅只有一个过程,比如我们还有一个任务B,执行B1,等待3秒后执行B2,B3,这样我就有了两个并行的任务,因为这两个任务中都有较长时间的操作(等待3秒),所以我们无法直接通过把任务AB串行来实现微观串行、宏观并行的效果。如果我们有支持多线程环境,操作系统会自然地帮我们把两个任务做切分,这样编写任务AB的程序员就不需要考虑独占CPU而造成堵塞的情况。但在大部分的资源受限的MCU环境下是不支持多线程环境的,那就需要程序员寻求一种方法来主动地、人为地分割任务的过程,最常用的方法就是状态机。    

如何使用状态

在无多线程支持的环境中,对任务进行状态分割,每次进入都有一个当前状态,每个状态下有对应的处理。      经典的switch
上面的描述很容易联想到C语言中switch语句——switch一个状态变量,跳转到对应的分支,就可以进行相应的处理。

/**************************************************************************************
                 ________                             _____________ ________ 
                         |  Debounce |               |  Debounce   |
                   Idle  | Press PRE |   Pressed     | Release Pre |  Idle         
                         |           |               |             |    
                         |___________|_______________|             |
                         |           |               |             |
                         V           V               V             V
                    Press Evt    Pressed Evt   Short release Evt   Short release Evt
****************************************************************************************/
swtich(u8St)
{
    case BTN_IDLE_ST:
        .....
        if(BTN_PRESS)
            u8St = BTN_PRESS_PRE_ST;
        break;
    case BTN_PRESS_PRE_ST:
        ....
        if(BTN_PRESS)
        {
            if(TIME_UP)
                u8St =BTN_PRESSED_ST;
        }
        else
            u8St = BTN_IDLE_ST;
        break;
    case BTN_PRESSED_ST:
        ....
        if(BTN_RELEASE)
            u8St = BTN_RELEASE_PRE_ST;
        break;
    case BTN_RELEASE_PRE_ST:
        if(BTN_RELEASE)
        {
            if(TIME_UP)
                u8St = BTN_IDLE_ST; 
        }
        else
            u8St = BTN_PRESSED_ST;
        break;
}

改良的状态转移表
每次的状态切换都会明确指定下一次状态,如果我们对状态进行整理排序,通过一个数组来将下一次的状态整理好,那整体的处理流程将更为清晰。

/**************************************************************************************
                 ________                             _____________ ________ 
                         |  Debounce |               |  Debounce   |
                   Idle  | Press PRE |   Pressed     | Release Pre |  Idle         
                         |           |               |             |    
                         |___________|_______________|             |
                         |           |               |             |
                         V           V               V             V
                    Press Evt    Pressed Evt   Short release Evt   Short release Evt
****************************************************************************************/

const uint8 cg_aau8StateMachine[BTN_STATE_NUM][BTN_TRG_NUM] = 
{
    /*  Situation 1  */   /*  Situation 2 */   /*  Situation 3  */    /* Situation 4  */
    /* Btn NOT press */   /* Btn press    */   /* Btn NOT press */    /* Btn press    */
    /* Time NOT out  */   /* Time NOT out */   /* Time out      */    /* Time out     */
    {BTN_IDLE_ST       , BTN_PRESS_PRE_ST    , BTN_IDLE_ST         , BTN_PRESSED_PRE_ST},  /* BTN_IDLE_ST        */  
    {BTN_IDLE_ST       , BTN_PRESS_PRE_ST    , BTN_IDLE_ST         , BTN_PRESSED_ST    },  /* BTN_PRESS_PRE_ST   */
    {BTN_RELEASE_PRE_ST, BTN_PRESSED_ST      , BTN_RELEASE_PRE_ST  , BTN_PRESSED_ST    },  /* BTN_PRESSED_ST     */
    {BTN_RELEASE_PRE_ST, BTN_PRESSED_ST      , BTN_IDLE_ST         , BTN_PRESSED_ST    }   /* BTN_RELEASE_PRE_ST */
};

     switch(u8BtnSt)
     {
         case BTN_IDLE_ST:
             ....
             break;
         case BTN_PRESS_PRE_ST:
             ....
             break;
         ...
     }

    /*************************** Find the Next state ***************************/
    /* Check if button is press or NOT */
    if(u8BtnSt != ptBtnPara->u8NormalSt)
    {   /* If button is pressed, update index number  */
        u8NextSt++;
    }
    
    /* Check if the debounce or long-press time is out or NOT */
    if(u8TmOut)
    {   /* If time is out, update index number */
        u8NextSt += BTN_TM_TRG_EVT_OFFSET;
    }
    u8BtnSt = cg_aau8StateMachine[u8BtnSt][u8NextSt];

高效的函数指针数组
switch的原理其实是很多个if,越靠后的case则与靠后进行if判断。这里的判断类似于挨家挨户的询问,知道询问的case为想要的状态。既然我们都已经知道了状态,为何不能像数组一样直接索引到具体的状态的操作呢?我们当然可以,而且我们使用的也正是数组。
在switch的方案中,每个case内的代码就是当前状态下的操作。

switch(u8State)
{
    case STATE_A:         /* State A */
    {
        a1();             /* State A下的操作1 */
        a2();             /* State A下的操作2 */
        a3();             /* State A下的操作3 */
        StateUpate();     /* 更新状态         */
        break;
    }
    case STATE_B:         /* State B */
    {
        b1();             /* State B下的操作1 */
        b2();             /* State B下的操作2 */
        b3();             /* State B下的操作3 */
        StateUpate();     /* 更新状态         */
        break;
    }
    case STATE_C:         /* State C */
    {
        c1();             /* State C下的操作1 */
        c2();             /* State C下的操作2 */
        c3();             /* State C下的操作3 */
        StateUpate();     /* 更新状态         */
        break;
    }
    default:              /* 异常情况         */
    {
        break;
    }
}

如果我们把这段代码封装成函数

void State_A(void)    /* State A的操作函数 */
{
    a1();             /* State A下的操作1 */
    a2();             /* State A下的操作2 */
    a3();             /* State A下的操作3 */
    StateUpate();     /* 更新状态         */
}

void State_B(void)    /* State B的操作函数 */
{
    b1();             /* State B下的操作1 */
    b2();             /* State B下的操作2 */
    b3();             /* State B下的操作3 */
    StateUpate();     /* 更新状态         */
}

void State_C(void)    /* State C的操作函数 */
{
    c1();             /* State C下的操作1 */
    c2();             /* State C下的操作2 */
    c3();             /* State C下的操作3 */
    StateUpate();     /* 更新状态         */
}

switch(u8State)
{
    case STATE_A:         /* State A */
    {
        State_A();
        break;
    }
    case STATE_B:         /* State B */
    {
        State_B();
        break;
    }
    case STATE_C:         /* State C */
    {
        State_C();
        break;
    }
    default:              /* 异常情况         */
    {
        break;
    }
}

这样的替换感觉有点多余,但实际上想替换的是switch,我们再做一下修改。

typedef void (*PF_STATE)(void);

void State_A(void)    /* State A的操作函数 */
{
    a1();             /* State A下的操作1 */
    a2();             /* State A下的操作2 */
    a3();             /* State A下的操作3 */
    StateUpate();     /* 更新状态         */
}

void State_B(void)    /* State B的操作函数 */
{
    b1();             /* State B下的操作1 */
    b2();             /* State B下的操作2 */
    b3();             /* State B下的操作3 */
    StateUpate();     /* 更新状态         */
}

void State_C(void)    /* State C的操作函数 */
{
    c1();             /* State C下的操作1 */
    c2();             /* State C下的操作2 */
    c3();             /* State C下的操作3 */
    StateUpate();     /* 更新状态         */
}

PF_STATE apfState[3] = {State_A, State_B,State_C};

while(1)
{
    apfState[u8State]();   /* 执行状态机 */
}
/**************************************************************************************
                 ________                             _____________ ________ 
                         |  Debounce |               |  Debounce   |
                   Idle  | Press PRE |   Pressed     | Release Pre |  Idle         
                         |           |               |             |    
                         |___________|_______________|             |
                         |           |               |             |
                         V           V               V             V
                    Press Evt    Pressed Evt   Short release Evt   Short release Evt
****************************************************************************************/
typedef void (*PF_STATE)(void);

void Btn_Idle(void){....};
void Btn_Press_Pre(void){....};
void Btn_Pressed(void){....};
void Btn_Release_Pre(void){....};

PF_STATE apfState[4] = {Btn_Idle, Btn_Press_Pre, Btn_Pressed, Btn_Release_Pre};

while(1)
{
    apfState[u8State]();   /* 执行状态机 */
}

这样就从switch逐个case的判断,转换为函数指针数组直接的调用。在case比较多的情况下,执行效率会高很多,但是这里没有了default分支,所以在状态更新的时候,需要特别注意异常状态。


指针

  • 指针是C语言的一大特点,它使C语言变得非常强大,但也很危险,所以理解好C的指针对编写C程序有非常大作用。    
  • 有很多人对指针的理解就是“地址”,确实在很多地方这样理解比较容易处理问题,但是指针和地址还是有一定的差别。指针包含两个信息:地址地址上的数据类型。例如:
    int  *pa = (int *)0;
    char *pb = (char *)0;
  • 上例中pa和pb的值都是0,都指向地址为0的空间,但是pa+1的值和pb+1的值就不一样,前者因为是int类型,所以+1后指向地址为4的空间;而后者类型为char,所以+1后指向地址为1的空间。如果把指针的概念简单理解为地址,就不能说明上述的差别。     


编译

用习惯IDE的同学对编译的理解就是一个按钮,而实际上这个按钮的背后有很多细节,可以分为“预处理、编译、汇编、链接”四个阶段

预处理

在预处理阶段,将对C源码进行一次替换处理,形成最终的“源文件”。与预处理阶段相关的操作有#define,#include,#if,#ifdef等。下面举一个简单的例子:
头文件Head.h

#define A 20
#define B 10
#define OPERATION_ADD 

源文件main.c

#include "Head.h"
int main(void)
{
#ifdef OPERATION_ADD
    int c = A + B;
#else
    int c = A - B;
#endif
    return c;
}

在预处理中先对main.c源文件进行include

#define A 20
#define B 10
#define OPERATION_ADD 
int main(void)
{
#ifdef OPERATION_ADD
    int c = A + B;
#else
    int c = A - B;
#endif
    return c;
}

然后对main.c源文件进行条件编译

#define A 20
#define B 10

int main(void)
{
    int c = A + B;
    return c;
}

最后再对main.c源文件进行宏定义替换,我们就获得了真正的用于后续编译的源文件了。

int main(void)
{
    int c = 20 + 10;
    return c;
}

编译

这里的编译不是我们平常理解的宏观定义(完成的预处理、编译、汇编、链接),而是更明确的“由C转换会汇编”的过程。这里几乎所有的操作都有编译器完成,由编译器决定如何将源码的c语句转换成汇编指令。当然程序员对转换的结果有一定程度的控制,这时就需要关注如const,volatile,static等修饰词,同时还有编译器优化等级和偏向这些要素来影响最终的汇编指令。经过编译器的的编译处理,我们上述的源代码将会转换成如下的汇编指令。

main:
        sub     sp, sp, #16
        mov     w0, 30
        str     w0, [sp, 12]
        ldr     w0, [sp, 12]
        add     sp, sp, 16
        ret

汇编

经过上面的编译处理,C语言已经变成更为底层的汇编语句了,但是汇编代码不是最终执行的机器代码,众所周知机器只知道0和1,所以我们还需要对人类依旧可读的汇编语句进行处理转换成机器码,这个操作就叫汇编。

链接

这个时候,几乎所有的代码基都转换成了机器码,但这些机器码还没有地址。所以需要链接器Linker来根据芯片的资源和代码中指定的地址或分区来为各个汇编好的机器码分配地址。当每个函数都有入口地址后,代码中的函数调用也就可以填写对应的地址来实现调用,文件之间的关联。
链接的过程是根据链接脚本进行的(在keil中是sct,IAR中是icf),在链接脚本中写明了地址资源的使用规则,堆栈大小/位置,各个段/区域的划分。

库是某些源文件,经过预处理、编译和汇编之后的文件集合,在库中已经无法看到C源码的具体实现,但依旧可以和其他C源码一同开发,在链接的时候融合成一个hex或bin。


嵌入式操作系统

  • 操作系统的主要功能是管理资源,为应用提供合适的运行环境,分配合适的资源。这里的资源从字面上理解,有外设(GPIO, uart, i2c等),有储存空间(RAM、ROM,文件系统),有处理操作的函数(不可重入函数),这些都可以很容易理解为资源。但对于操作系统而言,还有一个更重要的资源需要分配,那就是时间————CPU运行时间的权利。

调度

  • 在单核的CPU上运行多任务,任务之间只能共享CPU的执行权,同一时刻最多只能有一个任务投入运行,所以对于操作系统,如何合理管理分配“时间”这个资源就变得尤为重要了。
  • 一种简单的分配方式就是时间轮片,每个任务都有固定长度的执行时间,等该任务的执行时间结束,就将CPU的执行权交由下一个任务,从而让多个任务在一段时间内共享“时间”资源。这种微观上的串行也创造了宏观上的并行假象。
  • 再有一种复杂的调度方式就是基于优先级的调度,这种方式对高优先级任务有较高的实时响应性能。不同于时间轮片那样的公平,优先级方式会给高优先级任务更多的机会来占用“时间”资源,确保其能流畅地运行。而低优先级的任务只能在没有高优先级任务需要执行的情况下才能执行,同时,在低优先级任务执行的过程中,如果OS有调度机会且有较高的优先级任务已经就绪,就会抢占当前低优先级任务的执行权,直接让给较高优先级的任务执行。而对于优先级相同的任务,可以采用时间轮片的方式,多个任务一起分时共享。
  • 不论是那种方式分配“时间资源”,这种分配就是调度,也是操作系统最核心的部分。嵌入式操作系统一般都有实时要求,所以操作系统的实时性也是衡量系统是否优秀的重要指标。由上面两个例子可以看出,操作系统的实时性基本是由调度决定的。

时间管理

  • 时间管理是个非常重要的模块,很难找到与时间无关的应用。时间管理模块除了给任务提供延时、计时等服务,操作系统的内核也会使用到时间相关的函数。
  • 对于应用任务而言,时间管理模块展现更多的就是时间相关的API,任务通过这些API获得OS的时间服务。任务可以启动、暂停、停止自身相关的定时器,但所有任务的定时器管理工作是有OS完成的。
  • 应用任务一般都有延时的要求,但是传统的while或for等待(我们称之为死等)是对CPU性能的极大浪费。所以在某个任务有这种堵塞的等待时,操作系统就可以把任务暂时切出,让其他任务投入运行,这样才能高效实用CPU的时间资源。
  • 任务调用延时API实现延时的原理是这样的:任务A投入运行,在运行过程中需要延时3秒钟,于是调用操作系统延时API,并设定3秒,然后这个任务就会设置为非就绪态,然操作系统则把CPU的使用权交给了其他就绪的任务。这时操作系统就默默帮任务A计时这3秒,如果3秒到了,操作系统就会让这个任务设置为就绪态,如果没有其它就绪的高优先级,则会运行任务A,这样任务A就有了3秒的延时,然后再运行后续的程序。
  • 所以从任务的视角来看,它没有延时,只是调用了一下操作系统的API函数,然后立即就执行了后面的函数。这里可以举一个例子:小明就是任务A,小明写完作业准备睡觉,睡觉之前定了一个8小时后响的闹钟,然后就睡觉了,当8个小时过后,闹钟响,小明就醒了继续刷牙洗脸早饭上学。。。在小明看来,睡觉的这8个小时是感觉不到的,眼睛一闭睡着,眼睛一睁醒了,但确确实实存在8个小时的时间逝去。
  • 内核中也有使用时间服务的地方。比如某个任务等一个一个信号量,一般都有一个等待时间,如果在规定的时间内还不能获取到信号量,就返回错误。
  • 一种简易的时间管理模块(或称软件定时器)实现方式就是使用链表保存定时器节点信息,然后操作系统在任务之外不停维护这些定时器节点。

共享资源

  • 如前述,操作系统最大的作用是资源的管理分配,两个任务能独立运行得益于操作系统的工作,但两个任务在都一个硬件平台上能完完全全独立运行吗?实际上有很多场合任务之间会产生关系的,比如想使用相同的资源。

  • 有些资源在使用时不支持两个任务同时访问,但在没有其它任务占用的情况下可以被某个任务所访问,那这样的资源就是共享资源。

  • 为什么对共享资源的访问要特别注意呢,举一个最典型的例子,有两个任务A、B,他们都涉及操作一个全局变量个g_var(初值为0),任务A的操作是g_var+=1,任务B的操作是g_var+=2;在某个时刻运行任务A,对g_var+=1的操作有如下细分的步骤:

     1. 取g_var的值到寄存器Rx中
     2. Rx的值加1
     3. 把Rx的值赋值回g_var中
    
  • 当任务A完成前两步骤时,g_var的值任然是0,而此时的Rx为1,这时发生了任务切换,CPU执行权交给了任务B,而任务B完成了上述的三步骤操作,此刻的g_var值为2,然后切换回任务A执行,任务A继续完成之前未执行的第三步骤,将Rx的值赋给g_var,那此时的g_var的值就为1,宛如任务B没有执行过一样。

     1. A1 取g_var的值到寄存器Rx中,g_var值为0
     2. A2 Rx的值加1,g_var值为0
     3. B1 取g_var的值到寄存器Rx中,g_var值为0
     4. B2 Rx的值加1,g_var值为0
     5. B3 把Rx的值赋值回g_var中,g_var值为2
     6. A3 把Rx的值赋值回g_var中,g_var值为1
    
  • 上述就是一个典型的共享资源冲突的例子,虽然有操作系统让每个任务独立运行,但每个任务在运行的过程中还是要留心共享资源的“共享”问题。

  • 共享资源有哪些?如前述,在某任务使用该资源时,其它任务有可能尝试使用该资源的话,那该资源就是共享资源。我们可以看出内存、硬件外设、不可重入的函数都是共享资源。

  • 因为共享资源在被某个任务占用时,不能被另一个任务所占用,所以正在使用资源的任务必须对该资源进行一定的限制,避免共同访问的情况。

  • 最简单的限制方式就是关闭任务切换,如果任务A占用了某个资源,那就让它以最快的速度处理完,然后重新开启任务切换,这样就可以避免竞争问题。这种方式简单易行,但缺点也很明显,那就是高优先级的任务即使就绪且有任务切换的机会,也无法得到执行。这种方法简单但是粗暴,如果这个资源在被占用的情况下,而其他任务运行时暂时不会访问相同的资源,那这些任务其实还是可以继续运行的。那也就是说,其他任务不能投入运行的条件是该任务正好也使用到这个共享资源,只要在那个时机对任务进行限制即可。

  • 所以只要在任务使用共享资源之前,对任务进行“上锁”操作,在使用完之后进行“解锁”操作即可。其它任务在访问该共享资源时先检查是否上锁,若未上锁说明该资源没有其它任务占用,可以安全访问,若上锁则可以临时堵塞,待该资源被其它任务释放后继续运行使用该资源。

  • 上例中有个有趣的现象,等待资源的任务在等待占用资源的任务进行释放,这样两个独立运行的任务就在时间上实现了同步,关于这部分内容我们将在后续章节分享。

内部通讯

  • 操作系统一般都支持多任务运行,每个虽然是独立运行的,但是任务之间难免有需要“交流”的情况发生,所以操作系统应提供一种机制,用于任务间的通讯,即内部通讯。
  • 事件是最基本的通讯方式,这种方式可以让一个任务提供最基本的信息给其它任务,就好比一个眼神,其动作简单,没有多余的信息,但对于有约定的两个人就能明白眼神背后的意图。
  • 信号量是一种简单实用的机制,简单说来,就是做某件事之前,必须获得指定的信号量,才能得以继续执行,如果得不到该信号量,将会在一定时间后退出等待或无限等待。信号量可以分为二值信号量和计数信号量,可以将二值信号量理解为计数为2的计数信号量。还有一种特殊的二值信号量叫互斥信号量,是专门用于多任务之间对贡献资源的互斥访问的。互斥信号量具有优先级相关的处理,可以缓解互锁带来的影响。
  • 消息(massage)是常见的一种,可以分为直接发送消息----任务A将消息打包好直接发给任务B;还有一种发行-订阅机制,任务A像发行杂志一样,而任务B只需要订阅这类消息即可,可以解除A和B的关联。

中断

  • 不论是否使用操作系统,中断都是最实用的“线程”存在。在操作系统中存在多线程(多任务)的概念,但在裸机程序中,中断确实也可以理解成一个线程。
  • 这里我们复习一下裸机下的中断机制,当主程序运行时,此时发生异步中断事件,MCU在得到该事件后,将当前主循环程序的上下文环境入栈保护,然后进入中断处理服务程序。当完成中断服务程序后,将主循环程序的上下文出栈恢复,主循环程序得意继续运行。整个过程和操作系统中的多任务切换很类似。中断扮演了高优先级(优先级相当高)任务。而主循环则扮演了低优先级任务。
  • 而在操作系统中,中断同样扮演一个线程,所以中断与任务之间同样存在多任务之间存在的问题,比如共享资源访问冲突的问题。此外,因为中断不像正常任务那样可以被软件调度,大多需要外部硬件事件来触发,所以不一定能实用任务所使用的功能,如邮箱,事件等机制,任务也无法通过信号量来和中断同步。

任务

  • 任务一般是指一个具有独立功能的代码模块,是被操作系统调度的基本元素。
  • 使用操作系统最大的便捷就在于,所有的任务可以在各自的while(1)中运行而不会堵塞其它任务。但也因此在很多rtos中任务必须有一个while(1)确保任务不会异常的退出。
  • 在rtos中,每个任务都有自己独立的栈空间,该栈空间除了保存自身的运行时栈的数据,也保存任务本身的状态。在任务进行切换时,会将被切换出的任务上下文保存在该任务的栈中,随后把待切入的任务的之前的上下文从其栈中取出,恢复到通用寄存器中,从而恢复待切入任务的运行环境,继续运行。
  • 但对堆而言,可以采用共用的堆,因为对堆的使用,每次申请来的内存就是独立的,无需划分专用的堆空间(造成空间浪费)。
  • 在有的RTOS中,任务可以被清除,这时需要注意,除了释放专用的栈,还要把已申请的堆空间释放。
  • 而对全局变量而言,一般独立任务都有独立的c文件,使用静态全局变量可以有效减少任务间耦合。
  • 对任务而言,有三个对象是单独属于任务的,描述任务动态信息的TCB,任务的栈,和任务代码本身。
  • 几乎每个RTOS中都有TCB(Task Control Block)的概念,OS通过该TCB来了解任务的状态,如何切换该任务等。
  • 上述TCB中有一个很关键的成员,就是当前任务指针,而这个指针就是指向该任务的栈的。
  • 关于任务代码本身这里可以延伸讨论一下,比如说我们编写一个任务,该任务就是周期打印一个数字,只要启动了这个任务,该任务就会自动打印。如果我们将该任务启动两次,那该任务就会周期打印两个数字出来,这个我们可以称为任务的两个实例。

关于RTOS的学习

  • 很多同学在学习RTOS的时候都会觉得很困惑,感觉很多概念不能立即理解,进而无法继续深入掌握RTOS的使用和原理。其实这和学习的方法有关系,很多同学开始接触RTOS时,直接从RTOS的特点——调度开始看起,虽然这些上层的概念非常重要,但是想要充分理解这些概念,需要具备一定的基础。例如数组的概念大家都很熟悉,但是队列就不一定那么熟悉,信号量可能就更生疏一点,但是队列可以在数组的基础上增加“先入先出”的概念,于是我们就有了队列,而利用队列入队、出队的特点即可实现基本的二值信号量和计数信号量,而信号量也是RTOS中非常重要的组成部分。所以对RTOS的学习,除了要有全局的认识,对底层的数据结构和原理还是要熟练掌握,这样对学习会起到事半功倍的效果。

数据结构

排序

睡眠排序

  • 看算法的同学肯定见过很多排序方法,今天介绍一种“非主流”的睡眠排序。

  • 睡眠还能排序?当然可以,睡眠除了可以恢复体力,美容养颜,延年益寿之外,还确实可以启发程序员想出“非主流”的编程方式。每个人睡眠时间都不一样,用来排序靠谱吗?靠谱!实际上该排序方法就是利用每个“人”不同的睡眠时间来排序。可能说到这,有的同学就已经明白原理了,这里我们举一个栗子。

  • 大家都有过按高矮排队的经历,每次排队的时候,都要和左右的同学比较一下,然后再根据结果不停调整。这种排序总是需要知道相邻同学的身高,然后和自己比较,基于此的排序方式就会比较复杂。那我们可以仅仅知道自己身高的情况下就能排序吗?能!

  • 之前比身高的方式是以相邻的人身高作为标尺,因为标尺不停的变化,所以排序起来会比较复杂,那如果我们统一了标尺,排序就简单很多。我们来尝试一下新的排序方式:所有人的在规定的时间后依次排队,这个规定的时间值=身高(cm)- 150,单位为秒。例如160的小明在10秒后来排队,而170的大明就可以在20秒后站队,这样,身高最矮的同学就排到队头,而最高的则在队尾,大家自然就按高矮排好了队。这样,大家就在仅知道自己身高的情况下,利用了“时间”这个大家统一的标尺,完成了排序,这就是睡眠排序的基本原理。

  • 为什么叫睡眠排序呢?在计算机编程中,我们可以启动很多线程完成不同的工作,每个线程如同人一般可以进行睡眠,在睡眠结束后进行相应的工作。假设有十个不同的数字需要排列大小,那就启动十个线程,每个线程睡眠的时间就是需要排序的数字的值,线程睡眠结束被唤醒就将睡眠时间值打印出来。这样的话,获得的值最小的线程自然最先被唤醒,交出保存的值;而保存最大值得线程自然睡到最后。这样,十个完全独立的线程“无意”地将乱序的数字由小到大排列了起来。

  • 这是一种比较有趣的排序方法,但也有几个问题:

    1. 所排序的数字都非常大,即睡眠时间非常长,这样排序会需要很长时间得到结果。
    2. 所排序的数字都非常小,即睡眠时间都很短,这时线程之间调度不是完全由睡眠时间来决定的(线程优先级,启动线程的先后顺序等因素)
  • 对第一个问题,只需要除以一定的系数,即可缩短排序时间;而第二个问题,同样可以乘以一定的系数,消除线程调度的影响。最怕的就是排序的数字差值较大,这样就不能简单的通过系数来解决了,这就是睡眠排序的缺点。所以睡眠排序比较适合数值差异不大,数量多,数据相对孤立的情况。

  • 使用起来有这么多限制,那这个排序还实用吗?实用,实际上我们生活中很多地方都在默默采用这种排序方式。我们可以看一下百米赛跑,本身就是一种按快慢进行的排序,选手之间不知道彼此的速度,而且速度差异不会很大,此时他们可以采用相同的标尺——时间,在同一起跑线,同一时刻,同一个长度的赛道,由完成的时间来自然排序快慢。

  • 细心的同学想必也会发现,睡眠排序还有另一个特点,就是虽然需要耗费一定时间,但是基本不占用CPU。排序的线程大部分时间是睡眠的,可以释放CPU资源给其他应用,只要找到合适的应用场景,睡眠排序可以发挥很大作用(大数据,分布式计算...)。

  • 讲到这,不知道大家是否对睡眠排序有点兴趣了呢?我将在下篇文章中,抛开复杂的线程,用一个非常简单的方式在单片机中实现睡眠排序,更形象地介绍这种排序方式。

  • 夜深了,真的该睡了~

学习路线

未完待续。。。。


欢迎关注我的微信公众号:墨意MOE