当前位置: 首页 > news >正文

w~嵌入式C语言~合集3

我自己的原文哦~     https://blog.51cto.com/whaosoft/13870307

一、单片机多任务事件驱动

单片机的ROM与RAM存贮空间有限,一般没有多线程可用,给复杂的单片机项目带来困扰。

    经过多年的单片机项目实践,借鉴windows消息机制的思想,编写了单片机多任务事件驱动C代码,应用于单片机项目,无论复杂的项目,还是简单的项目,都可以达到优化代码架构的目的。

    经过几轮的精简、优化,现在分享给大家。

    代码分为3个模块:任务列表、事件列表、定时器列表。

    任务列表创建一个全局列表管理任务,通过调用taskCreat()创建事件处理任务,创建成功返回任务ID,任务列表、事件列表与定时器列表通过任务ID关联。

    事件列表创建一个全局循环列表管理事件,调用taskEventIssue()生成一个事件,放到事件循环列表,taskEventLoop()函数放到主线程循环调用,当事件循环列表中有事件时,根据任务ID分发到具体的事件处理任务。

    定时器列表创建一个全局列表管理定时器,taskTimer()建立一个定时器,放到定时器列表执行,当定时时间到,会生成一个定时器事件,放到事件列表,分发到具体的事件处理任务。

//common.h
#ifndef __COMMON_H
#define __COMMON_H#include "stdio.h"
#include <stdlib.h>
#include <string.h>typedef short int16_t;
typedef int int32_t;
typedef long long int64_t;
typedef unsigned char uint8_t;
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef unsigned long long uint64_t;
typedef unsigned char bool;#define false 0
#define true 1#endif // __COMMON_H
//task.h
#ifndef _THREAD_H
#define _THREAD_H#define TASK_MAX 20 // 最多任务数量
#define TASK_EVENT_MAX 100 // 任务队列长度
#define TASK_TIMER_MAX 100 // 定时器最大数量typedef void (*CBTaskEvent)(int taskID,uint32_t eventID);
typedef struct _TASK_EVENT
{int taskID;uint32_t eventID;} TASK_EVENT;int taskCreat(CBTaskEvent task);
void taskLoop();
void taskEventIssue(int taskID,uint32_t eventID);
void taskEventLoop();//定时、休眠typedef struct _TASK_TIMER
{bool isValid;int taskID;uint32_t eventID;uint32_t timeMs;uint32_t start;} TASK_TIMER;void taskTicksInc();
void taskTimer(int taskID,uint32_t eventID,uint32_t time_ms);
void taskTimerLoop();#endif // _THREAD_H
//task.c
#include "common.h"
#include "task.h"CBTaskEvent g_taskList[TASK_MAX]={0};int taskFindEmpty()
{static int index = -1;for(int i=0; i<TASK_MAX; i++){index++;index %= TASK_MAX;if(g_taskList[index]==NULL){return index;}}return -1;
}int taskCreat(CBTaskEvent task)
{int taskID;taskID=taskFindEmpty();if(taskID == -1){printf("error:task list is full!\n");return -1;}g_taskList[taskID] = task;printf("creat task<%d>\n",taskID);return taskID;
}void taskDestroy(int taskID)
{printf("Destroy task<%d>\n",taskID);g_taskList[taskID] = NULL;
}void taskLoop()
{taskEventLoop();taskTimerLoop();
}TASK_EVENT g_taskEventList[TASK_EVENT_MAX];
int g_TKEventWrite=0;
int g_TKEventRead=0;int tkEventGetSize()
{return (g_TKEventWrite + TASK_EVENT_MAX - g_TKEventRead)% TASK_EVENT_MAX;
}void taskEventIssue(int taskID,uint32_t eventID)
{int writePos;if(taskID >= TASK_EVENT_MAX || taskID < 0){printf("taskEventIssue() error:taskID\n");return;}writePos = (g_TKEventWrite + 1)% TASK_EVENT_MAX;if(writePos == g_TKEventRead){printf("taskEventIssue() error:task<%d> event list is full!\n",taskID);return;}g_taskEventList[g_TKEventWrite].taskID=taskID;g_taskEventList[g_TKEventWrite].eventID=eventID;g_TKEventWrite=writePos;//printf("add event:%x\n",eventID);
}void taskEventLoop()
{TASK_EVENT event;CBTaskEvent task;int size;size=tkEventGetSize();while(size-- >0){event=g_taskEventList[g_TKEventRead];g_TKEventRead = (g_TKEventRead + 1)% TASK_EVENT_MAX;task = g_taskList[event.taskID];if(!task){printf("taskEventLoop() error:task is NULL\n");continue;}task(event.taskID,event.eventID);}
}// 定时、休眠uint32_t g_taskTicks=0;uint32_t getTaskTicks()
{return g_taskTicks;
}void taskTicksInc() // 1ms时间基准
{g_taskTicks++;
}uint32_t taskTickDiff(uint32_t now,uint32_t last)
{uint64_t diff;diff = now + 0x100000000 - last;return (diff & 0xffffffff);
}TASK_TIMER g_taskTimerList[TASK_TIMER_MAX]={0};int taskTimerFindEmpty()
{for(int i=0; i<TASK_TIMER_MAX; i++){if(!g_taskTimerList[i].isValid){return i;}}return -1;
}void taskTimer(int taskID,uint32_t eventID,uint32_t time_ms)
{int index;index=taskTimerFindEmpty();if(index==-1){printf("taskTimer() error:timer list is full\n");return;}g_taskTimerList[index].taskID=taskID;g_taskTimerList[index].eventID=eventID;g_taskTimerList[index].timeMs=time_ms;g_taskTimerList[index].start=getTaskTicks();g_taskTimerList[index].isValid=true;printf("add timer:<%d,%x> %ums\n",taskID,eventID,time_ms);}void taskTimerLoop()
{static uint32_t start=0;if(taskTickDiff(getTaskTicks(),start)<3){return;}start=getTaskTicks();for(int i=0; i<TASK_TIMER_MAX; i++){if(g_taskTimerList[i].isValid){if(taskTickDiff(start,g_taskTimerList[i].start)>=g_taskTimerList[i].timeMs){taskEventIssue(g_taskTimerList[i].taskID,g_taskTimerList[i].eventID);g_taskTimerList[i].isValid=false;}}}
}
//test_task.h
#ifndef _TEST_THREAD_H
#define _TEST_THREAD_Hvoid testInit();
void testLoop();#endif //
//test_task.c
#include "common.h"
#include "task.h"#define CTRL_EVENT1 0x01
#define CTRL_EVENT2 0x02
#define CTRL_EVENT3 0x04void eventProcess(int taskID,uint32_t event)
{switch(event){case CTRL_EVENT1:printf("task[%d] CTRL_EVENT1\n",taskID);//taskEventIssue(taskID,CTRL_EVENT2);taskTimer(taskID,CTRL_EVENT2,1000);break;case CTRL_EVENT2:printf("task[%d] CTRL_EVENT2\n",taskID);//taskEventIssue(taskID,CTRL_EVENT3);taskTimer(taskID,CTRL_EVENT3,2000);break;case CTRL_EVENT3:printf("task[%d] CTRL_EVENT3\n",taskID);taskTimer(taskID,CTRL_EVENT1,4000);break;default:break;}
}void testLoop()
{taskLoop();
}void testInit()
{int taskID1,taskID2;printf("testInit()\n");taskID1 = taskCreat((CBTaskEvent)&eventProcess);taskTimer(taskID1,CTRL_EVENT1,5000);taskID2 = taskCreat((CBTaskEvent)&eventProcess);taskEventIssue(taskID2,CTRL_EVENT2);taskDestroy(taskID1);taskDestroy(taskID2);//taskEventIssue(taskID1,CTRL_EVENT1);taskID1 = taskCreat((CBTaskEvent)&eventProcess);taskEventIssue(taskID1,CTRL_EVENT1);
}
二、 嵌入式C语言经典面试题

#error的作用是什么?

#error  指令让预处理器发出一条错误信息,并且会中断编译过程。下面我们从Linux代码中抽取出来一小段代码并做修改得到示例代码:

这段示例代码很简单,当RX_BUF_IDX宏的值不为0~3时,在预处理阶段就会通过 #error  指令输出一条错误提示信息:

"Invalid configuration for 8139_RXBUF_IDX"

下面编译看一看结果:

位操作的基本使用

给一个32bit数据的位置1,怎么用宏来实现?

#define SET_BIT(x, bit) (x |= (1 << bit)) /* 置位第bit位 */

隐式转换规则

如下代码的输出结果是?为什么?

#include <stdio.h>
int main(void)
{unsigned int a = 6;int b = -20;if (a + b > 6)printf("a+b大于6\n");elseprintf("a+b小于6\n");return 0;
}

程序输出结果为:

a+b大于6

原因是因为编译器会将有符号数b转换成为一个无符号数,即此处 a+b 等价于 a+(unsigned int)b 。

该程序运行在32bit环境下,b的值为 0xFFFFFFFF-20+1 = 4294967276 ,即a+b将远远大于6。

C 语言按照一定的规则来进行此类运算的转换,这种规则称为 正常算术转换 ,转换的顺序为:

double>float>unsigned long>long>unsigned int>int

即操作数类型排在后面的与操作数类型排在前面的进行运算时,排在后面的类型将 隐式转换 为排在前面的类型。

typedef与define的区别

(1)#define之后不带分号,typedef之后带分号。

(2)#define可以使用其他类型说明符对宏类型名进行扩展,而 typedef 不能这样做。如:

#define INT1 int
unsigned INT1 n;  //没问题
typedef int INT2;
unsigned INT2 n;  //有问题

INT1可以使用类型说明符unsigned进行扩展,而INT2不能使用unsigned进行扩展。

(3)在连续定义几个变量的时候,typedef 能够保证定义的所有变量均为同一类型,而 #define 则无法保证。如:

#define PINT1 int*;
P_INT1 p1,p2;  //即int *p1,p2;
typedet int* PINT2;
P_INT2 p1,p2;  //p1、p2 类型相同

PINT1定义的p1与p2类型不同,即p1为指向整形的指针变量,p2为整形变量;PINT2定义的p1与p2类型相同,即都是指向 int 类型的指针。

写一个MAX宏

#define MAX(x,y) ((x) > (y) ? (x) : (y))

使用括号把参数括起来可以解决了运算符优先级带来的问题。这样的MAX宏基本可以满足日常使用,但是还有更严谨的高级写法。

感兴趣的可参考文章:

​​https://www.zhaixue.cc/c-arm/c-arm-express.html​​

死循环

嵌入式系统中经常要用到无限循环,你怎么样用C编写死循环呢?

(1)while

while(1) { }

(2)for

for(;;) { }

(3)goto

Loop:…goto Loop;

static的作用

在C语言中,关键字static有三个明显的作用:

1、在函数体修饰变量

一个被声明为静态的变量在这一函数被调用过程中维持其值不变。

2、 在模块内(但在函数体外)修饰变量

一个被声明为静态的变量可以被模块内所用函数访问,但不能被模块外其它函数访问。它是一个本地的全局变量。

3、在模块内修饰函数

一个被声明为静态的函数只可被这一模块内的其它函数调用。那就是,这个函数被限制在声明它的模块的本地范围内使用。

const的作用

下面的声明都是什么意思:

const int a; 
int const a;
const int *a;
int * const a;
int const * a const;
  • 前两个的作用是一样,a是一个常整型数。
  • 第三个意味着a是一个指向常整型数的指针(也就是,整型数是不可修改的,但指针可以)。
  • 第四个意思a是一个指向整型数的常指针(也就是说,指针指向的整型数是可以修改的,但指针是不可修改的)。
  • 最后一个意味着a是一个指向常整型数的常指针(也就是说,指针指向的整型数是不可修改的,同时指针也是不可修改的)。

volatile的作用

以下内容来自百度百科:

一个定义为volatile的变量是说这变量可能会被意想不到地改变,这样,编译器就不会去假设这个变量的值了。精确地说就是,优化器在用到这个变量时必须每次都小心地重新读取这个变量的值,而不是使用保存在寄存器里的备份。下面是volatile变量的几个例子:

1). 并行设备的硬件寄存器(如:状态寄存器)

2). 一个中断服务子程序中会访问到的非自动变量(Non-automatic variables)

3). 多线程应用中被几个任务共享的变量

回答不出这个问题的人是不会被雇佣的。我认为这是区分C程序员和嵌入式系统程序员的最基本的问题。嵌入式系统程序员经常同硬件、中断、RTOS等等打交道,所用这些都要求volatile变量。不懂得volatile内容将会带来灾难。

假设被面试者正确地回答了这是问题(嗯,怀疑这否会是这样),我将稍微深究一下,看一下这家伙是不是直正懂得volatile完全的重要性。

1). 一个参数既可以是const还可以是volatile吗?解释为什么。

2). 一个指针可以是volatile 吗?解释为什么。

3). 下面的函数有什么错误:

int square(volatile int *ptr)
{return *ptr * *ptr;
}

下面是答案:

1). 是的。一个例子是只读的状态寄存器。它是volatile因为它可能被意想不到地改变。它是const因为程序不应该试图去修改它。

2). 是的。尽管这并不很常见。一个例子是当一个中服务子程序修该一个指向一个buffer的指针时。

3). 这段代码的有个恶作剧。这段代码的目的是用来返指针 *ptr 指向值的平方,但是,由于 *ptr 指向一个volatile型参数,编译器将产生类似下面的代码:

int square(volatile int* &ptr)//这里参数应该申明为引用,不然函数体里只会使用副本,外部没法更改
{int a,b;a = *ptr;b = *ptr;return a*b;
}

由于*ptr的值可能在两次取值语句之间发生改变,因此a和b可能是不同的。结果,这段代码可能返回的不是你所期望的平方值!正确的代码如下:

long square(volatile int*ptr)
{int a;a = *ptr;return a*a;
}

变量定义

用变量a给出下面的定义:

  • a)一个整型数
  • b) 一个指向整型数的指针
  • c) 一个指向指针的的指针,它指向的指针是指向一个整型数
  • d) 一个有10个整型数的数组
  • e) 一个有10个指针的数组,该指针是指向一个整型数的:
  • f) 一个指向有10个整型数数组的指针
  • g) 一个指向函数的指针,该函数有一个整型参数并返回一个整型数
  • h) 一个有10个函数指针的数组,该指针指向一个函数,该函数有一个整型参数并返回一个整型数
a) int a; 
b) int *a;
c) int **a;
d) int a[10]; 
e) int *a[10];
f) int ( *a)[10];
g) int ( *a)(int);
h) int ( *a[10])(int);

中断函数

中断是嵌入式系统中重要的组成部分,这导致了很多编译开发商提供一种扩展—让标准C支持中断。具代表事实是,产生了一个新的关键字 __ interrupt 。

下面的代码就使用了 __ interrupt 关键字去定义了一个中断服务子程序(ISR),请评论一下这段代码的。

__interrupt double compute_area (double radius)
{double area = PI * radius * radius;printf(" Area = %f", area);return area;
}

1). ISR 不能返回一个值。

2). ISR 不能传递参数。

3). 在许多的处理器/编译器中,浮点一般都是不可重入的。有些处理器/编译器需要让额处的寄存器入栈,有些处理器/编译器就是不允许在ISR中做浮点运算。此外,ISR应该是短而有效率的,在ISR中做浮点运算是不明智的。

4). 与第三点一脉相承,printf()经常有重入和性能上的问题。

三、状态机框架

Zorb Framework是一个基于面向对象的思想来搭建一个轻量级的嵌入式框架。

本次分享的是Zorb Framework的状态机的实现。

中小型嵌入式程序说白了就是由各种状态机组成,因此掌握了如何构建状态机,开发嵌入式应用程序可以说是手到拈来。

简单的状态机可以用Switch-Case实现,但复杂一点的状态机再继续使用Switch-Case的话,层次会变得比较乱,不方便维护。因此我们为Zorb Framework提供了函数式状态机。

状态机的功能

我们先来看看要实现的状态机提供什么功能:

初步要提供的功能如下:

1、可以设置初始状态

2、可以进行状态转换

3、可以进行信号调度

4、最好可以在进入和离开状态的时候可以做一些自定义的事情

5、最好可以有子状态机

因此,初步设计的数据结构如下:

/* 状态机结构 */
struct _Fsm
{uint8_t Level;                  /* 嵌套层数,根状态机层数为1,子状态机层数自增 *//* 注:严禁递归嵌套和环形嵌套 */List *ChildList;                /* 子状态机列表 */Fsm *Owner;                     /* 父状态机 */IFsmState OwnerTriggerState;    /* 当父状态机为设定状态时,才触发当前状态机 *//* 若不设定,则当执行完父状态机,立即运行子状态机 */IFsmState CurrentState;         /* 当前状态 */bool IsRunning;                 /* 是否正在运行(默认关) *//* 设置初始状态 */void (*SetInitialState)(Fsm * const pFsm, IFsmState initialState);/* 运行当前状态机 */bool (*Run)(Fsm * const pFsm);/* 运行当前状态机和子状态机 */bool (*RunAll)(Fsm * const pFsm);/* 停止当前状态机 */bool (*Stop)(Fsm * const pFsm);/* 停止当前状态机和子状态机 */bool (*StopAll)(Fsm * const pFsm);/* 释放当前状态机 */bool (*Dispose)(Fsm * const pFsm);/* 释放当前状态机和子状态机 */bool (*DisposeAll)(Fsm * const pFsm);/* 添加子状态机 */bool (*AddChild)(Fsm * const pFsm, Fsm * const pChildFsm);/* 移除子状态机(不释放空间) */bool (*RemoveChild)(Fsm * const pFsm, Fsm * const pChildFsm);/* 调度状态机 */bool (*Dispatch)(Fsm * const pFsm, FsmSignal const signal);/* 状态转移 */void (*Transfer)(Fsm * const pFsm, IFsmState nextState);/* 状态转移(触发转出和转入事件) */void (*TransferWithEvent)(Fsm * const pFsm, IFsmState nextState);
};

关于信号,Zorb Framework做了以下定义:

/* 状态机信号0-31保留,用户信号在32以后定义 */
enum {FSM_NULL_SIG = 0,FSM_ENTER_SIG,FSM_EXIT_SIG,FSM_USER_SIG_START = 32/* 用户信号请在用户文件定义,不允许在此定义 */
};

创建状态机:

bool Fsm_create(Fsm ** ppFsm)
{Fsm *pFsm;ZF_ASSERT(ppFsm != (Fsm **)0)/* 分配空间 */pFsm = ZF_MALLOC(sizeof(Fsm));if (pFsm == NULL){ZF_DEBUG(LOG_E, "malloc fsm space error\r\n");return false;}/* 初始化成员 */pFsm->Level = 1;pFsm->ChildList = NULL;pFsm->Owner = NULL;pFsm->OwnerTriggerState = NULL;pFsm->CurrentState = NULL;pFsm->IsRunning = false;/* 初始化方法 */pFsm->SetInitialState = Fsm_setInitialState;pFsm->Run = Fsm_run;pFsm->RunAll = Fsm_runAll;pFsm->Stop = Fsm_stop;pFsm->StopAll = Fsm_stopAll;pFsm->Dispose = Fsm_dispose;pFsm->DisposeAll = Fsm_disposeAll;pFsm->AddChild = Fsm_addChild;pFsm->RemoveChild = Fsm_removeChild;pFsm->Dispatch = Fsm_dispatch;pFsm->Transfer = Fsm_transfer;pFsm->TransferWithEvent = Fsm_transferWithEvent;/* 输出 */*ppFsm = pFsm;return true;
}

调度状态机:

/******************************************************************************* 描述  :调度状态机* 参数  :(in)-pFsm           状态机指针*         (in)-signal         调度信号* 返回  :-true               成功*         -false              失败
******************************************************************************/
bool Fsm_dispatch(Fsm * const pFsm, FsmSignal const signal)
{/* 返回结果 */bool res = false;ZF_ASSERT(pFsm != (Fsm *)0)if (pFsm->IsRunning){if (pFsm->ChildList != NULL && pFsm->ChildList->Count > 0){uint32_t i;Fsm * pChildFsm;for (i = 0; i < pFsm->ChildList->Count; i++){pChildFsm = (Fsm *)pFsm->ChildList->GetElementDataAt(pFsm->ChildList, i);if (pChildFsm != NULL){Fsm_dispatch(pChildFsm, signal);}}}if (pFsm->CurrentState != NULL){/* 1:根状态机时调度2:没设置触发状态时调度3:正在触发状态时调度*/if (pFsm->Owner == NULL || pFsm->OwnerTriggerState == NULL|| pFsm->OwnerTriggerState == pFsm->Owner->CurrentState){pFsm->CurrentState(pFsm, signal);res = true;}}}return res;
}

篇幅有限,其它接口实现可阅读:

​​https://github.com/54zorb/Zorb-Framework​​

状态机测试

/******************************************************************************** @file    app_fsm.c* @author  Zorb* @version V1.0.0* @date    2018-06-28* @brief   状态机测试的实现****************************************************************************** @history** 1. Date:2018-06-28*    Author:Zorb*    Modification:建立文件*******************************************************************************/#include "app_fsm.h"
#include "zf_includes.h"/* 定义用户信号 */
enum Signal
{SAY_HELLO = FSM_USER_SIG_START
};Fsm *pFsm;        /* 父状态机 */
Fsm *pFsmSon;     /* 子状态机 *//* 父状态机状态1 */
static void State1(Fsm * const pFsm, FsmSignal const fsmSignal);
/* 父状态机状态2 */
static void State2(Fsm * const pFsm, FsmSignal const fsmSignal);/******************************************************************************* 描述  :父状态机状态1* 参数  :-pFsm       当前状态机*         -fsmSignal  当前调度信号* 返回  :无
******************************************************************************/
static void State1(Fsm * const pFsm, FsmSignal const fsmSignal)
{switch(fsmSignal){case FSM_ENTER_SIG:ZF_DEBUG(LOG_D, "enter state1\r\n");break;case FSM_EXIT_SIG:ZF_DEBUG(LOG_D, "exit state1\r\n\r\n");break;case SAY_HELLO:ZF_DEBUG(LOG_D, "state1 say hello, and want to be state2\r\n");/* 切换到状态2 */pFsm->TransferWithEvent(pFsm, State2);break;}
}/******************************************************************************* 描述  :父状态机状态2* 参数  :-pFsm       当前状态机*         -fsmSignal  当前调度信号* 返回  :无
******************************************************************************/
static void State2(Fsm * const pFsm, FsmSignal const fsmSignal)
{switch(fsmSignal){case FSM_ENTER_SIG:ZF_DEBUG(LOG_D, "enter state2\r\n");break;case FSM_EXIT_SIG:ZF_DEBUG(LOG_D, "exit state2\r\n\r\n");break;case SAY_HELLO:ZF_DEBUG(LOG_D, "state2 say hello, and want to be state1\r\n");/* 切换到状态1 */pFsm->TransferWithEvent(pFsm, State1);break;}
}/******************************************************************************* 描述  :子状态机状态* 参数  :-pFsm       当前状态机*         -fsmSignal  当前调度信号* 返回  :无
******************************************************************************/
static void SonState(Fsm * const pFsm, FsmSignal const fsmSignal)
{switch(fsmSignal){case SAY_HELLO:ZF_DEBUG(LOG_D, "son say hello only in state2\r\n");break;}
}/******************************************************************************* 描述  :任务初始化* 参数  :无* 返回  :无
******************************************************************************/
void App_Fsm_init(void)
{/* 创建父状态机,并设初始状态 */Fsm_create(&pFsm);pFsm->SetInitialState(pFsm, State1);/* 创建子状态机,并设初始状态 */Fsm_create(&pFsmSon);pFsmSon->SetInitialState(pFsmSon, SonState);/* 设置子状态机仅在父状态State2触发 */pFsmSon->OwnerTriggerState = State2;/* 把子状态机添加到父状态机 */pFsm->AddChild(pFsm, pFsmSon);/* 运行状态机 */pFsm->RunAll(pFsm);
}/******************************************************************************* 描述  :任务程序* 参数  :无* 返回  :无
******************************************************************************/
void App_Fsm_process(void)
{ZF_DELAY_MS(1000);/* 每1000ms调度状态机,发送SAY_HELLO信号 */pFsm->Dispatch(pFsm, SAY_HELLO);
}/******************************** END OF FILE ********************************/

结果:

四、STM32单片机的堆栈

   学习STM32单片机的时候,总是能遇到“堆栈”这个概念。分享本文,希望对你理解堆栈有帮助。

    对于了解一点汇编编程的人,就可以知道,堆栈是内存中一段连续的存储区域,用来保存一些临时数据。堆栈操作由PUSH、POP两条指令来完成。而程序内存可以分为几个区:

  • 栈区(stack)
  • 堆区(Heap)
  • 全局区(static)
  • 文字常亮区程序代码区

    程序编译之后,全局变量,静态变量已经分配好内存空间,在函数运行时,程序需要为局部变量分配栈空间,当中断来时,也需要将函数指针入栈,保护现场,以便于中断处理完之后再回到之前执行的函数。

    栈是从高到低分配,堆是从低到高分配。

普通单片机与STM32单片机中堆栈的区别
    普通单片机启动时,不需要用bootloader将代码从ROM搬移到RAM。

    但是STM32单片机需要,这里我们可以先看看单片机程序执行的过程,单片机执行分三个步骤:

  • 取指令
  • 分析指令
  • 执行指令

    根据PC的值从程序存储器读出指令,送到指令寄存器。然后分析执行执行。这样单片机就从内部程序存储器去代码指令,从RAM存取相关数据。

    RAM取数的速度是远高于ROM的,但是普通单片机因为本身运行频率不高,所以从ROM取指令慢并不影响。

    而STM32的CPU运行的频率高,远大于从ROM读写的速度。所以需要用bootloader将代码从ROM搬移到RAM

    使用栈就象我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是自由度小。使用堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且自由度大。

    其实堆栈就是单片机中的一些存储单元,这些存储单元被指定保存一些特殊信息,比如地址(保护断点)和数据(保护现场)。

    如果非要给他加几个特点的话那就是:

  • 这些存储单元中的内容都是程序执行过程中被中断打断时,事故现场的一些相关参数。如果不保存这些参数,单片机执行完中断函数后就无法回到主程序继续执行了。
  • 这些存储单元的地址被记在了一个叫做堆栈指针(SP)的地方。

结合STM32的开发讲述堆栈

    从上面的描述可以看得出来,在代码中是如何占用堆和栈的。可能很多人还是无法理解,这里再结合STM32的开发过程中与堆栈相关的内容来进行讲述。

    如何设置STM32的堆栈大小?

    在基于MDK的启动文件开始,有一段汇编代码是分配堆栈大小的。

    这里重点知道堆栈数值大小就行。还有一段AREA(区域),表示分配一段堆栈数据段。数值大小可以自己修改,也可以使用STM32CubeMX数值大小配置,如下图所示。

STM32F1默认设置值0x400,也就是1K大小。

Stack_Size EQU 0x400

    函数体内局部变量:

void Fun(void){ char i; int Tmp[256]; //...}

    局部变量总共占用了256*4 + 1字节的栈空间。所以,在函数内有较多局部变量时,就需要注意是否超过我们配置的堆栈大小。

    函数参数:

void HAL_GPIO_Init(GPIO_TypeDef *GPIOx, GPIO_InitTypeDef *GPIO_Init)

    这里要强调一点:传递指针只占4字节,如果传递的是结构体,就会占用结构大小空间。提示:在函数嵌套,递归时,系统仍会占用栈空间。

    堆(Heap)的默认设置0x200(512)字节。

Heap_Size EQU 0x200

    大部分人应该很少使用malloc来分配堆空间。虽然堆上的数据只要程序员不释放空间就可以一直访问,但是,如果忘记了释放堆内存,那么将会造成内存泄漏,甚至致命的潜在错误。

MDK中RAM占用大小分析

    经常在线调试的人,可能会分析一些底层的内容。这里结合MDK-ARM来分析一下RAM占用大小的问题。在MDK编译之后,会有一段RAM大小信息:

    这里4+6=1640,转换成16进制就是0x668,在进行在调试时,会出现:

    这个MSP就是主堆栈指针,一般我们复位之后指向的位置,复位指向的其实是栈顶:

    而MSP指向地址0x20000668是0x20000000偏移0x668而得来。具体哪些地方占用了RAM,可以参看map文件中【Image Symbol Table】处的内容:

五、C语言中的枚举类型enum

举例说明C语言中enum枚举关键字的用法。

用来同时定义多个常量

利用enum定义月份的例子如下。

#include<stdio.h>
enum week {Mon=1,Tue,Wed,Thu,Fri,Sat,Sun};
int main()
{printf("%d",Tue);return 0;
}

这样定义Mon的值为1之后,Tue的值就被默认定义为2,Wed的值为3,依此类推。如果没写Mon=1的话,Mon的默认值就为0。例如:

enum color {red,blue,green,yellow}; //red的值默认为0

从中间开始赋值的情况,见如下例子:

enum color {red,blue,green=5,yellow}; 
//red、bule、green、yellow的值依次为0、1、5、6

用来限定变量的取值范围

有时为了保证程序的健壮性而使用enum。

#include<stdio.h>
enum Month {Jan=1,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,dec};
int main()
{enum Month a =  Feb;printf("%d",a);return 0;
}

比如上面例子,枚举类型a的取值被限定在那12个变量中。

enum类型的定义方法

在定义enum的同时声明变量:

enum Month {Jan=1,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,dec} a,b;
//这样就声明了两个枚举类型a和b

定义完enum之后再声明变量:

enum Month {Jan=1,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,dec};
enum Month a =  Feb;

定义匿名的枚举变量:

enum  {Jan=1,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,dec} a;
//这样就只能使用a这一个枚举类型的变量,不能再定义其他枚举类型了
六、volatile用法

许多程序员都无法正确理解C语言关键字volatile,这并不奇怪。因为大多数C语言书籍通常都是一两句一带而过,本文将告诉你如何正确使用它。

    在C/C++嵌入式代码中,你是否经历过以下情况:

  • 代码执行正常–直到你打开了编译器优化
  • 代码执行正常–直到打开了中断
  • 古怪的硬件驱动
  • RTOS的任务独立运行正常–直到生成了其他任务

    如果你的回答是“yes”,很有可能你没有使用C语言关键字volatile。你并不是唯一的,很多程序员都不能正确使用volatile。不幸的是,大多数c语言书籍对volatile的藐视,只是简单地一带而过。

    volatile用于声明变量时的使用的限定符。它告诉编译器该变量值可能随时发生变化,且这种变化并不是代码引起的。给编译器这个暗示是很重要的。在开始前,我们向来看一看volatile的语法。

C语言关键字volatile语法

    声明一个变量为volatile,可以在数据类型之前或之后加上关键字volatile。下面的语句,把foo声明一个volatile的整型。

volatile int foo;
int volatile foo;

    把指针指向的变量声明为volatile很常见,尤其是I/O寄存器的地址映射。下面的语句,把pReg声明为一个指向8-bit无符号指针,指针指向的内容为volatile。

volatile uint8_t * pReg;
uint8_t volatile * pReg;

    volatile的指针指向非volatile的变量很少见(我只使用过一次),但我还是给出相应的语法。

int * volatile p;

    顺便提一下,关于为什么要在数据类型前使用volatile关键字,请自行百度搜素。

    最后,如果你再struct或者union前使用volatile关键字,表明struct或者union的所有内容都是volatile。如果这不是你的本意,可以在struct或者union成员上使用volatile关键字。

正确使用C语言关键字volatile

    只要变量可能被意外的修改,就需要把该变量声明为volatile。在实际应用中,只有三种类型数据可能被修改:

  • 外设寄存器地址映射
  • 在中断服务程序中修改全局变量
  • 在多线程、多任务应用中,全局变量被多个任务读写

    接下来,我们将分别讨论上述三种情况。

外设寄存器

    嵌入式系统包含真正的硬件,通常会有复杂的外设。这些外设寄存器的值可能被异步的修改。举个简单的例子,我们要把一个8-bit状态寄存器的地址映射到0x1234。在程序中循环查看该状态寄存器的值是否变为非0。C语言操作寄存器的手法

    下面是最容易想到,但错误的实现方法:

当你打开编译器优化时,程序总是执行失败。因为编译器会生成下面的汇编代码:

 程序被优化的原因很简单,既然已经把变量的值读入累加器,就没有必要重新一遍,编译器认为值是不会变化的。就这样,在第三行,程序进入了无限死循环。为了告诉编译器我们的真正意图,我们需要修改函数的声明:

编译器生成的汇编代码:

    像这样,我们得到了正确的动作。

中断服务程序

    在中断服务程序中,经常会修改一些全局变量值,来作为主程序中的判断条件。例如,在串口中断服务程序中,可能会检测是否接收到了ETX(假如是消息的结束标识符)字符。如果接收到了ETX,ISR设置一个全局标志位。

    错误的做法: 

在关闭编译器优化的情况下,程序可能执行正常。然而,任何像样点而优化都会“break”这段程序。问题是编译器并不知道etx_rcvd可能被ISR中被修改。编译器只知道,表达式!ext_rcvd始终为真,你讲用于无法退出循环。结果,循环后面的代码可能被编译器优化掉。

    幸运的话,你的编译器可能会发出警告;不幸的话,(或者你不认真的查看编译器警告),你的程序无法正常执行。当然,你可以责怪编译器执行了“糟糕的优化”。

    解决方式是,将变量etx_rcvd声明为volatile,所有问题(当然,也可能是部分问题)就消失了。

多线程应用

    在实时系统中,尽管有想queues,pipes等这些同步机制,使用全局变量实现两个任务共享信息的做法依然很常见。即使在你的程序中加入了抢占式调度器,你的编译器依然无法知道什么是上下文切换,或何时发生上下文切换。因此从概念上讲,多任务修改全局变量的的做法与中断服务程序中修改全局变量的做法是相同的。

    因此,所有这类全局变量都应该声明为volatile。

    例如下面的程序:

当打开编译器优化时,这段程序可能执行失败。解决方法是将cntr声明为volatile。

总结 

    一些编译器允许你把所有的变量隐式的声明为volatile。请抵制这种诱惑,因为它会令你不再思考,当然也会导致生成低效的代码。

    另外,也不要责怪优化器或直接把它关掉。现代的优化器已经足够优秀,我已经记不清上次遇到优化bug是什么时候了。相反,我常常看到程序员们错误的使用volatile。

    如果你被要求去修改一个很古怪的代码,请在程序中查找一下volatile关键字;如果你什么也没有找到,上面讨论的例子可以向你提供一些解决问题的思路。

七、STM32单片机的C语言基础

C语言是单片机开发中的必备基础知识,本文列举了部分STM32学习中比较常见的一些C语言基础知识。

1 位操作

    下面我们先讲解几种位操作符,然后讲解位操作使用技巧。C语言支持以下六种位操作:

 下面,重点讲解一下位操作在单片机开发中的一些实用技巧。

1.1 在不改变其他位的值的状况下,对某几个位进行设值

    这个场景在单片机开发中经常使用,方法就是我们先对需要设置的位用&操作符进行清零操作,然后用 | 操作符设值。

    比如,我要改变GPIOA的状态,可以先对寄存器的值进行&清零操作:

1.2 移位操作提高代码的可读性

    移位操作在单片机开发中非常重要,下面是delay_init函数的一行代码:

SysTick->CTRL |= 1 << 1;

    这个操作就是将CTRL寄存器的第1位(从0开始算起)设置为1,为什么要通过左移而不是直接设置一个固定的值呢?

    其实这是为了提高代码的可读性以及可重用性。这行代码可以很直观明了的知道,是将第1位设置为1。如果写成:

SysTick->CTRL |= 0X0002;

    这个虽然也能实现同样的效果,但是可读性稍差,而且修改也比较麻烦。

1.3 ~按位取反操作使用技巧

    按位取反在设置寄存器的时候经常被使用,常用于清除某一个/某几个位。下面是delay_us函数的一行代码:

SysTick->CTRL &= ~(1 << 0) ;    /* 关闭SYSTICK */

    该代码可以解读为:仅设置CTRL寄存器的第0位(最低位)为0,其他位的值保持不变。

    同样我们也不使用按位取反,将代码写成:

SysTick->CTRL &= 0XFFFFFFFE;        /* 关闭SYSTICK */

    可见,前者的可读性及可维护性都要比后者好很多。

1.4 ^按位异或操作使用技巧

    该功能非常适合用于控制某个位翻转,常见的应用场景就是控制LED闪烁,如下:

GPIOB->ODR ^= 1 << 5;

    执行一次该代码,就会使PB5的输出状态翻转一次,如果我们的LED接在PB5上,就可以看到LED闪烁了。

2 define宏定义

    define是C语言中的预处理命令,它用于宏定义(定义的是常量),可以提高源代码的可读性,为编程提供方便。常见的格式:

   定义标识符HSE_VALUE的值为8000000,数字后的U表示unsigned的意思。至于define宏定义的其他一些知识,比如宏定义带参数,这里就不多讲解了。

3 ifdef条件编译

    单片机程序开发过程中,经常会遇到一种情况,当满足某条件时对一组语句进行编译,而当条件不满足时则编译另一组语句。

    条件编译命令最常见的形式为:

#ifdef 标识符    程序段1#else    程序段2#endif

    它的作用是:当标识符已经被定义过(一般是用#define命令定义),则对程序段1进行编译,否则编译程序段2。

    其中#else部分也可以没有,即:

#ifdef    程序段1    #endif

    条件编译在HAL库里面是用得很多,在stm32mp1xx_hal_conf.h这个头文件中经常会看到这样的语句:

#if !defined  (HSE_VALUE)      #define HSE_VALUE       24000000U    #endif

    如果没有定义HSE_VALUE这个宏,则定义HSE_VALUE宏,并且HSE_VALUE的值为24000000U。条件编译也是C语言的基础知识吧。

    这里提一下,24000000U中的U表示无符号整型,常见的,UL表示无符号长整型,F表示浮点型。

    这里加了U以后,系统编译时就不进行类型检查,直接以U的形式把值赋给某个对应的内存,如果超出定义变量的范围,则截取。

4 extern变量申明

    C语言中extern可以置于变量或者函数前,以表示变量或者函数的定义在别的文件中,提示编译器遇到此变量和函数时在其他模块中寻找其定义。

    这里面要注意,对于extern申明变量可以多次,但定义只有一次。在我们的代码中你会看到看到这样的语句:

extern uint16_t g_usart_rx_sta;

    这个语句是申明g_usart_rx_sta变量在其他文件中已经定义了,在这里要使用到。

    所以,你肯定可以找到在某个地方有变量定义的语句:

uint16_t g_usart_rx_sta;

    extern的使用比较简单,但是也会经常用到,需要掌握。

5 typedef类型别名

    typedef用于为现有类型创建一个新的名字,或称为类型别名,用来简化变量的定义。typedef在HAL库用得最多的就是定义结构体的类型别名和枚举类型了。

struct _GPIO    {        __IO uint32_t CRL;        __IO uint32_t CRH;        …    };

    定义了一个结构体GPIO,这样我们定义结构体变量的方式为:

struct  _GPIO  gpiox;       /* 定义结构体变量gpiox */

    但这样很繁琐,HAL库中有很多这样的结构体变量需要定义。

    这里我们可以为结体定义一个别名GPIO_TypeDef,这样我们就可以在其他地方通过别名GPIO_TypeDef来定义结构体变量了,方法如下:

typedef struct    {            __IO uint32_t CRL;            __IO uint32_t CRH;            …    } GPIO_TypeDef;

    Typedef为结构体定义一个别名GPIO_TypeDef,这样我们可以通过GPIO_TypeDef来定义结构体变量:

GPIO_TypeDef gpiox;

    这里的GPIO_TypeDef就跟struct _GPIO是等同的作用了,但是GPIO_TypeDef使用起来方便很多。

八、C语言数制与大小端转换

  编程时,经常用到进制转换、字符转换。比如软件界面输入的数字字符串,如何将字符串处理成数字呢?今天就和大家分享一下。

u8、u32转换

    举个例子:ASCII码里: 

    这里写图片描述字符‘A’ , 一个字节8bit 。

    即u8 十六进制为:

0x41

    二进制为:

0100 0001

    而对应的十进制为65整型65,4个字节32bit。

    即u32 十六进制为:

0x41

    二进制为:

0000 0000 0000 0000 0000 0000 0100 0001

    将u32数转换成u8数组。

    注意:这里是字符数组,不是字符串。

    字符串是以空字符(\0)结尾的char数组

void U32ToU8Array(uint8_t *buf, uint32_t u32Value)
{buf[0] = ((u32Value >> 24) & 0xFF);buf[1] = ((u32Value >> 16) & 0xFF);buf[2] = ((u32Value >> 8) & 0xFF);buf[3] = (u32Value & 0xFF);
}

    效果:整型 50 转字符数组 {‘\0’,’\0’,’\0’,’2’}

    u8数组转u32:

void U8ArrayToU32(uint8_t *buf, uint32_t *u32Value)
{*u32Value = (buf[0] <<24) + (buf[1] <<16) + (buf[2] <<8) + (buf[3] <<0);
}

    效果:字符数组 {‘\0’,’\0’,’\0’,’2’}转为整型 50

大小端(高低位)转换

    STM32 默认是小端模式的,那么该如何转为大端?

//转为大端:
pPack[0] = (u8)((len >> 8) & 0xFF);
pPack[1] = (u8)(len & 0xFF);
//转为小端:
pPack[0] = (u8)(len & 0xFF);
pPack[1] =  (u8)((len >> 8) & 0xFF);

    效果:len为数据类型为 u16(short),

    比如 0x11 0x22,转为u8(usigned char)数组。

大端为:

pPack[0] (0x11 ) 
pPack[1] (0x22)

小端为:

pPack[0] (0x22) 
pPack[1] (0x11)

九、单片机掉电检测与数据掉电保存怎么做

单片机在正常工作时,因某种原因造成突然掉电,将会丢失数据存储器(RAM)里的数据。在某些应用场合如测量、控制等领域,单片机正常工作中采集和运算出一些重要数据,待下次上电后需要恢复这些重要数据。

    因此,在一些没有后备供电系统的单片机应用系统中,有必要在系统完全断电之前,把这些采集到的或计算出的重要数据保存在FLASH或EEPROM中。为此,通常做法是在这些系统中加入单片机掉电检测电路与单片机掉电数据保存。

    用法拉电容可简单实现单片机掉电检测与数据掉电保存。电路见下图。这里用6V供电(如7806),为什么用6V不用5V是显而易见的。

电路中的二极管们一般都起两个作用:

    一是起钳位作用,钳去0.6V,保证使大多数实用5V供电的单片机(比如51单片机)都能在4.5V--5.5V之间的标称工作电压下工作。

而4.5-5.5间这1V电压在0.47F电容的电荷流失时间就是我们将来在单片机掉电检测报警后我们可以规划的预警回旋时间。

    二是利用单向导电性保证向储能电容0.47F/5.5V单向冲电。

两只47欧电阻作用:

    第一,对单片机供电限流。

    一般地单片机电源直接接7805上,这是个不保险的做法,为什么?因为7805可提供高达2A的供电电流,异常时足够把单片机芯片内部烧毁。

    有这个47欧姆电阻保护,即使把芯片或者极性插反也不会烧单片机和三端稳压器,但这个电阻也不能太大,上限不要超过220欧,否则对单片机内部编程时,会编程失败(其实是电源不足)。

    第二,和47UF和0.01UF电容一起用于加强电源滤波。

    第三,对0.47F/5.5V储能电容,串入的这只47欧电阻消除"巨量法拉电容"的上电浪涌.实现冲电电流削峰。

    现在我们算一算要充满0.47F电容到5.5V,即使用5.5A恒流对0.47F电容冲电,也需要0.47秒才能冲到5.5V,因此我们可以知道:

    1.如果没有47欧姆电阻限流,上电瞬间三端稳压器必然因强大过电流而进入自保.

    2.长达0.47秒(如果真有5.5A恒流充电的话)缓慢上电,如此缓慢的上电速率,将使得以微分(RC电路)为复位电路的51单片机因为上电太慢无法实现上电复位.(其实要充满0.47UF电容常常需要几分种).

    3.正因为上电时间太慢,将无法和今天大多数主流的以在线写入(ISP)类单片机与写片上位计算机软件上预留的等待应答时间严重不匹配(一般都不大于500MS),从而造成应答失步,故总是提示"通信失败".

    知道这个道理你就不难理解这个电路最上面的二极管和电阻串联起来就是必须要有上电加速电路.这里还用了一只(内部空心不带蓝色的)肖特基二极管(1N5819)从法拉电容向单片机VCC放电,还同时阻断法拉电容对上电加速电路的旁路作用,用肖特基二极管是基于其在小电流下导通电压只有0.2V左右考虑的,目的是尽量减少法拉电容在单片机掉电时的电压损失.多留掉点维持时间。

    三极管9014和钳制位二极管分压电阻垫位电阻(470欧姆)等构成基极上发射极双端输入比较器,实现单片机掉电检测和发出最高优先级的掉电中断,单片机掉电保存程执行。这部分电路相当于半只比较器LM393,但电路更简单耗电更省(掉电时耗电小于0.15MA)。

    47K电阻和470欧姆二极管1N4148一道构成嵌位电路,保证基极电位大约在0.65V左右 (可这样计算0.6(二极管导通电压)+5*0.47/47),这样如果9014发射极电压为0(此时就是外部掉电),三极管9014正好导通,而且因为51单片机P3.2高电平为弱上拉(大约50UA),此时9014一定是导通且弱电流饱和的,这样就向单片机内部发出最高硬件优先级的INX0掉电检测中断。

    而在平时正常供电时,因发射极上也大约有6*0.22/2.2=0.6V电压上顶,不难发现三极管9014一定处于截止状态,而使P3.2维持高电平的,单片机掉电保存中断程序不被触发。

最后还有两个重要软件和硬件note:

软件上

    INX0监测引脚在硬件上(设计)是处于最高优先级的,这里还必须要在软件保证最高级别的优先,从而确保单片机掉电时外部中断0能打断其他任何进程,最高优先地被检测和执行。

硬件上

    凡是驱动单片机外部口线等的以输出高电平驱动外部设备,其电源不能和电片机的供电电压VCC去争抢(例如上拉电阻供电不取自单片机VCC).而应直接接在电源前方,图中4.7K电阻和口线PX.Y就是一个典型示例,接其它口线PX.Y'和负载也雷同。

    这里与上拉4.7K电阻相串联二极管也有两个作用:

  • 钳去0.6V电压以便与单片机工作电压相匹配,防止口线向单片机内部反推电.造成单片机口线功能紊乱。
  • 利用二极管单向供电特性,防止掉电后单片机通过口线向电源和外部设备反供电。

    上面的单片机掉电检测电路,在与掉电保存写入子程序模块结合起来就可以保证在单片机掉电期间,不会因法拉电容上的积累电荷为已经掉电的外部电路无谓供电和向电源反供电造成电容能量泄放缩短掉电维持时间。

    有了这些基础,我们来计算0.47UF的电容从5.5V跌落到4.5V(甚至可以下到3.6V)所能维持的单片机掉电工作时间.这里假设设单片机工作电流为20MA(外设驱动电流已经被屏蔽)不难算出:

T=1V*0.47*1000(1000是因为工作电流为豪安)/20=23.5秒!
十、调试和宏使用技巧

01.调试相关的宏

    在Linux使用gcc编译程序的时候,对于调试的语句还具有一些特殊的语法。gcc编译的过程中,会生成一些宏,可以使用这些宏分别打印当前源文件的信息,主要内容是当前的文件、当前运行的函数和当前的程序行。

    具体宏如下:

__FILE__  当前程序源文件 (char*)
__FUNCTION__  当前运行的函数 (char*)
__LINE__  当前的函数行 (int)

    这些宏不是程序代码定义的,而是有编译器产生的。这些信息都是在编译器处理文件的时候动态产生的。

    测试示例:

#include <stdio.h>int main(void)
{printf("file: %s\n", __FILE__);printf("function: %s\n", __FUNCTION__);printf("line: %d\n", __LINE__);return 0;
}

02.# 字符串化操作符

    在gcc的编译系统中,可以使用#将当前的内容转换成字符串。

    程序示例:

#include <stdio.h>#define DPRINT(expr) printf("<main>%s = %d\n", #expr, expr);int main(void)
{int x = 3;int y = 5;DPRINT(x / y);DPRINT(x + y);DPRINT(x * y);return 0;
}

    执行结果:

deng@itcast:~/tmp$ gcc test.c 
deng@itcast:~/tmp$ ./a.out  
<main>x / y = 0
<main>x + y = 8
<main>x * y = 15

    #expr表示根据宏中的参数(即表达式的内容),生成一个字符串。该过程同样是有编译器产生的,编译器在编译源文件的时候,如果遇到了类似的宏,会自动根据程序中表达式的内容,生成一个字符串的宏。

    这种方式的优点是可以用统一的方法打印表达式的内容,在程序的调试过程中可以方便直观的看到转换字符串之后的表达式。具体的表达式的内容是什么,有编译器自动写入程序中,这样使用相同的宏打印所有表达式的字符串。

//打印字符
#define debugc(expr) printf("<char> %s = %c\n", #expr, expr)
//打印浮点数
#define debugf(expr) printf("<float> %s = %f\n", #expr, expr)
//按照16进制打印整数
#define debugx(expr) printf("<int> %s = 0X%x\n", #expr, expr);

    由于#expr本质上市一个表示字符串的宏,因此在程序中也可以不适用%s打印它的内容,而是可以将其直接与其它的字符串连接。因此,上述宏可以等价以下形式:

//打印字符
#define debugc(expr) printf("<char> #expr = %c\n", expr)
//打印浮点数
#define debugf(expr) printf("<float> #expr = %f\n", expr)
//按照16进制打印整数
#define debugx(expr) printf("<int> #expr = 0X%x\n", expr);

    总结:

    #是C语言预处理阶段的字符串化操作符,可将宏中的内容转换成字符串。

03.## 连接操作符

    在gcc的编译系统中,##是C语言中的连接操作符,可以在编译的预处理阶段实现字符串连接的操作。

    程序示例:

#include <stdio.h>#define test(x) test##xvoid test1(int a)
{printf("test1 a = %d\n", a);
}void test2(char *s)
{printf("test2 s = %s\n", s);
}int main(void)
{test(1)(100);test(2)("hello world");return 0;
}

    上述程序中,test(x)宏被定义为test##x, 他表示test字符串和x字符串的连接。

在程序的调试语句中,##常用的方式如下

#define DEBUG(fmt, args...) printf(fmt, ##args)

    替换的方式是将参数的两个部分以##连接。##表示连接变量代表前面的参数列表。使用这种形式可以将宏的参数传递给一个参数。args…是宏的参数,表示可变的参数列表,使用##args将其传给printf函数.

    总结:

    ##是C语言预处理阶段的连接操作符,可实现宏参数的连接。

04.调试宏第一种形式

    一种定义的方式:

#define DEBUG(fmt, args...)             \{                                   \printf("file:%s function: %s line: %d ", __FILE__, __FUNCTION__, __LINE__);\printf(fmt, ##args);                \}

    程序示例:

#include <stdio.h>#define DEBUG(fmt, args...)             \{                                   \printf("file:%s function: %s line: %d ", __FILE__, __FUNCTION__, __LINE__);\printf(fmt, ##args);                \}int main(void)
{int a = 100;int b = 200;char *s = "hello world";DEBUG("a = %d b = %d\n", a, b);DEBUG("a = %x b = %x\n", a, b);DEBUG("s = %s\n", s);return 0;
}

    总结:

    上面的DEBUG定义的方式是两条语句的组合,不可能在产生返回值,因此不能使用它的返回值。

05.调试宏的第二种定义方式

    调试宏的第二种定义方式

#define DEBUG(fmt, args...)             \printf("file:%s function: %s line: %d "fmt, \__FILE__, __FUNCTION__, __LINE__, ##args)

    程序示例

#include <stdio.h>#define DEBUG(fmt, args...)             \printf("file:%s function: %s line: %d "fmt, \__FILE__, __FUNCTION__, __LINE__, ##args)int main(void)
{int a = 100;int b = 200;char *s = "hello world";DEBUG("a = %d b = %d\n", a, b);DEBUG("a = %x b = %x\n", a, b);DEBUG("s = %s\n", s);return 0;
}

    总结:

    fmt必须是一个字符串,不能使用指针,只有这样才可以实现字符串的功能。

06.对调试语句进行分级审查

    即使定义了调试的宏,在工程足够大的情况下,也会导致在打开宏开关的时候在终端出现大量的信息。而无法区分哪些是有用的。这个时候就要加入分级检查机制,可以定义不同的调试级别,这样就可以对不同重要程序和不同的模块进行区分,需要调试哪一个模块就可以打开那一个模块的调试级别。

    一般可以利用配置文件的方式显示,其实Linux内核也是这么做的,它把调试的等级分成了7个不同重要程度的级别,只有设定某个级别可以显示,对应的调试信息才会打印到终端上。

    可以写出一下配置文件

[debug]
debug_level=XXX_MODULE

    解析配置文件使用标准的字符串操作库函数就可以获取XXX_MODULE这个数值。

int show_debug(int level)
{if (level == XXX_MODULE){#define DEBUG(fmt, args...)             \printf("file:%s function: %s line: %d "fmt, \__FILE__, __FUNCTION__, __LINE__, ##args)       }else if (...){....}
}

    在实际的开发中,一般会维护两种源程序,一种是带有调试语句的调试版本程序,另外一种是不带有调试语句的发布版本程序。然后根据不同的条件编译选项,编译出不同的调试版本和发布版本的程序。

    在实现过程中,可以使用一个调试宏来控制调试语句的开关。

#ifdef USE_DEBUG#define DEBUG(fmt, args...)             \printf("file:%s function: %s line: %d "fmt, \__FILE__, __FUNCTION__, __LINE__, ##args)  
#else#define DEBUG(fmt, args...)#endif

    如果USE_DEBUG被定义,那么有调试信息,否则DEBUG就为空。

    如果需要调试信息,就只需要在程序中更改一行就可以了。

#define USE_DEBUG
#undef USE_DEBUG

    定义条件编译的方式使用一个带有值的宏

#if USE_DEBUG#define DEBUG(fmt, args...)             \printf("file:%s function: %s line: %d "fmt, \__FILE__, __FUNCTION__, __LINE__, ##args)  
#else#define DEBUG(fmt, args...)#endif

   可以使用如下方式进行条件编译

#ifndef USE_DEBUG
#define USE_DEBUG 0
#endif

08.使用do…while的宏定义

    使用宏定义可以将一些较为短小的功能封装,方便使用。宏的形式和函数类似,但是可以节省函数跳转的开销。

    如何将一个语句封装成一个宏,在程序中常常使用do…while(0)的形式。

#define HELLO(str) do { \
printf("hello: %s\n", str); \
}while(0)

    程序示例:

int cond = 1;
if (cond)HELLO("true");
elseHELLO("false");

    对于比较大的程序,可以借助一些工具来首先把需要优化的点清理出来。接下来我们来看看在程序执行过程中获取数据并进行分析的工具:代码剖析程序。

    测试程序:

#include <stdio.h>#define T 100000void call_one()
{int count = T * 1000;while(count--);
}void call_two()
{int count = T * 50;while(count--);
}void call_three()
{int count = T * 20;while(count--);
}int main(void)
{int time = 10;while(time--){call_one();call_two();call_three();}return 0;
}

  编译的时候加入-pg选项:

deng@itcast:~/tmp$ gcc -pg  test.c -o test

    执行完成后,在当前文件中生成了一个gmon.out文件。

deng@itcast:~/tmp$ ./test  
deng@itcast:~/tmp$ ls
gmon.out  test  test.c
deng@itcast:~/tmp$

    使用gprof剖析主程序:

deng@itcast:~/tmp$ gprof test
Flat profile:Each sample counts as 0.01 seconds.%   cumulative   self              self     total           time   seconds   seconds    calls  ms/call  ms/call  name    95.64      1.61     1.61       10   160.68   160.68  call_one3.63      1.67     0.06       10     6.10     6.10  call_two2.42      1.71     0.04       10     4.07     4.07  call_three

    其中主要的信息有两个,一个是每个函数执行的时间占程序总时间的百分比,另外一个就是函数被调用的次数。通过这些信息,可以优化核心程序的实现方式来提高效率。

    当然这个剖析程序由于它自身特性有一些限制,比较适用于运行时间比较长的程序,因为统计的时间是基于间隔计数这种机制,所以还需要考虑函数执行的相对时间,如果程序执行时间过短,那得到的信息是没有任何参考意义的。

    将上述程序时间缩短:

#include <stdio.h>#define T 100void call_one()
{int count = T * 1000;while(count--);
}void call_two()
{int count = T * 50;while(count--);
}void call_three()
{int count = T * 20;while(count--);
}int main(void)
{int time = 10;while(time--){call_one();call_two();call_three();}return 0;
}

剖析结果如下:

deng@itcast:~/tmp$ gcc -pg test.c -o test
deng@itcast:~/tmp$ ./test  
deng@itcast:~/tmp$ gprof test
Flat profile:Each sample counts as 0.01 seconds.no time accumulated%   cumulative   self              self     total           time   seconds   seconds    calls  Ts/call  Ts/call  name    0.00      0.00     0.00       10     0.00     0.00  call_one0.00      0.00     0.00       10     0.00     0.00  call_three0.00      0.00     0.00       10     0.00     0.00  call_two

    因此该剖析程序对于越复杂、执行时间越长的函数也适用。

    那么是不是每个函数执行的绝对时间越长,剖析显示的时间就真的越长呢?可以再看如下的例子。

#include <stdio.h>#define T 100void call_one()
{int count = T * 1000;while(count--);
}void call_two()
{int count = T * 100000;while(count--);
}void call_three()
{int count = T * 20;while(count--);
}int main(void)
{int time = 10;while(time--){call_one();call_two();call_three();}return 0;
}

剖析结果如下:

deng@itcast:~/tmp$ gcc -pg test.c -o test
deng@itcast:~/tmp$ ./test  
deng@itcast:~/tmp$ gprof test
Flat profile:Each sample counts as 0.01 seconds.%   cumulative   self              self     total           time   seconds   seconds    calls  ms/call  ms/call  name    
101.69      0.15     0.15       10    15.25    15.25  call_two0.00      0.15     0.00       10     0.00     0.00  call_one0.00      0.15     0.00       10     0.00     0.00  call_three

    总结:

    在使用gprof工具的时候,对于一个函数进行gprof方式的剖析,实质上的时间是指除去库函数调用和系统调用之外,纯碎应用部分开发的实际代码运行的时间,也就是说time一项描述的时间值不包括库函数printf、系统调用system等运行的时间。这些实用库函数的程序虽然运行的时候将比最初的程序实用更多的时间,但是对于剖析函数来说并没有影响。

十一、STM32的启动模式配置与应用

3种BOOT模式

    所谓启动,一般来说就是指我们下好程序后,重启芯片时,SYSCLK的第4个上升沿,BOOT引脚的值将被锁存。用户可以通过设置BOOT1和BOOT0引脚的状态,来选择在复位后的启动模式

  • Main Flash memory
    是STM32内置的Flash,一般我们使用JTAG或者SWD模式下载程序时,就是下载到这个里面,重启后也直接从这启动程序。
  • System memory
    从系统存储器启动,这种模式启动的程序功能是由厂家设置的。一般来说,这种启动方式用的比较少。系统存储器是芯片内部一块特定的区域,STM32在出厂时,由ST在这个区域内部预置了一段BootLoader, 也就是我们常说的ISP程序, 这是一块ROM,
    出厂后无法修改。一般来说,我们选用这种启动模式时,是为了从串口下载程序,因为在厂家提供的BootLoader中,提供了串口下载程序的固件,可以通过这个BootLoader将程序下载到系统的Flash中。但是这个下载方式需要以下步骤:
    Step1:将BOOT0设置为1,BOOT1设置为0,然后按下复位键,这样才能从系统存储器启动BootLoader
    Step2:最后在BootLoader的帮助下,通过串口下载程序到Flash中
    Step3:程序下载完成后,又有需要将BOOT0设置为GND,手动复位,这样,STM32才可以从Flash中启动可以看到, 利用串口下载程序还是比较的麻烦, 需要跳帽跳来跳去的,非常的不注重用户体验。
  • Embedded Memory
    内置SRAM,既然是SRAM,自然也就没有程序存储的能力了,这个模式一般用于程序调试。假如我只修改了代码中一个小小的地方,然后就需要重新擦除整个Flash,比较的费时,可以考虑从这个模式启动代码(也就是STM32的内存中),用于快速的程序调试,等程序调试完成后,在将程序下载到SRAM中。

开发BOOT模式选择

    通常使用程序代码存储在主闪存存储器,配置方式:BOOT0=0,BOOT1=X。

Flash锁死解决办法

    开发调试过程中,由于某种原因导致内部Flash锁死,无法连接SWD以及Jtag调试,无法读到设备,可以通过修改BOOT模式重新刷写代码。

    修改为BOOT0=1,BOOT1=0即可从系统存储器启动,ST出厂时自带Bootloader程序,SWD以及JTAG调试接口都是专用的。重新烧写程序后,可将BOOT模式重新更换到BOOT0=0,BOOT1=X即可正常使用。

十二、单片机开发中,传感器的数据处理算法

 在传感器使用中,我们常常需要对传感器数据进行各种整理,让应用获得更好的效果,以下介绍几种常用的简单处理方法:

  • 加权平滑:平滑和均衡传感器数据,减小偶然数据突变的影响。
  • 抽取突变:去除静态和缓慢变化的数据背景,强调瞬间变化。
  • 简单移动平均线:保留数据流最近的K个数据,取平均值。

    下面,具体介绍一下这3种处理方法。

加权平滑

    使用算法如下:

    (新值) = (旧值)*(1 - a) + X * a其中a为设置的权值,X为最新数据,程序实现如下:

抽取突变

    此算法采用上面加权平滑的逆算法实现代码如下:

简单移动平均线

    这个算法,保留传感器数据流中最近的K个数据,返回它们的平均值。k表示平均“窗口”的大小,实现代码如下:

十三、STM32标准库、HAL库特点与应用

  新手在入门STM32的时候,一般大多数都会选用标准库和HAL库,而极少部分人会通过直接配置寄存器进行开发。

    对于刚入门的朋友可能没法直观了解这些不同开发发方式彼此之间的区别,本文试图以一种非常直白的方式,用自己的理解去将这些东西表述出来。

配置寄存器

    不少先学了51的朋友可能会知道,会有一小部分人或是教程是通过汇编语言直接操作寄存器实现功能的,这种方法到了STM32就变得不太容易行得通了。

    因为STM32的寄存器数量是51单片机的十数倍,如此多的寄存器根本无法全部记忆,开发时需要经常的翻查芯片的数据手册,此时直接操作寄存器就变得非常的费力了。也有人喜欢去直接操作寄存器,因为这样更接近原理,代码更少,知其然也知其所以然。

标准库

    上面也提到了,STM32有非常多的寄存器,而导致了开发困难,所以为此ST公司就为每款芯片都编写了一份库文件,也就是工程文件里stm32F1xx.....之类的。在这些.c与.h文件中,包括一些常用量的宏定义,把一些外设也通过结构体变量封装起来,如GPIO、时钟等。

    所以我们只需要配置结构体变量成员就可以修改外设的配置寄存器,从而选择不同的功能。也是目前最多人使用的方式,也是学习STM32接触最多的一种开发方式,我也就不多阐述了。

HAL库

    HAL库是ST公司目前主推的开发方式,全称就是Hardware Abstraction Layer(抽象印象层),简单来说就是弱化了开发者对硬件底层知识的依赖。

    同样的功能,标准库可能要用几句话,HAL库只需用一句话就够了。并且HAL库也很好的解决了程序移植的问题。不同型号的STM32芯片它的标准库是不一样的,例如在F4上开发的程序移植到F3上是不能通用的,而使用HAL库,只要使用的是相同的外设,程序基本可以完全复制粘贴,注意是相同外设,意思也就是不能无中生有。例如F7比F3要多几个定时器,不能明明没有这个定时器却非要配置,但其实这种情况不多,绝大多数都可以直接复制粘贴。

    而且使用ST公司研发的STMcube软件,可以通过图形化的配置功能,直接生成整个适用于HAL库的工程文件,可以说是方便至极。相关推荐:​​STM32CubeMX安装教程​​。但是方便的同时也造成了它执行效率偏低。

    综合上面说的,其实笔者还是强烈推荐HAL库的,理由:

  • ST公司已经停止更新标准库,公司主打HAL库的目的已经非常明显了
  • 模块化的HAL库是趋势,低效的短板会被硬件的增强所弥补

    当然底层的基本原理必需是要懂的,HAL库也不是万能的,结合对底层的理解相信一定会让你的开发水准大大提高。

STM32的HAL库、标准库区别

1 句柄

    在STM32的标准库中,假设我们要初始化一个外设(这里以USART为例) 我们首先要初始化他们的各个寄存器。

    在标准库中,这些操作都是利用固件库结构体变量+固件库Init函数实现的:

USART_InitTypeDef USART_InitStructure;USART_InitStructure.USART_BaudRate = bound;//串口波特率USART_InitStructure.USART_WordLength = USART_WordLength_8b;//字长为8位数据格式USART_InitStructure.USART_StopBits = USART_StopBits_1;//一个停止位USART_InitStructure.USART_Parity = USART_Parity_No;//无奇偶校验位USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;//无硬件数据流控制USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; //收发模式USART_Init(USART3, &USART_InitStructure); //初始化串口1

    可以看到,要初始化一个串口,需要对六个位置进行赋值,然后引用Init函数,并且USART_InitStructure并不是一个全局结构体变量,而是只在函数内部的局部变量,初始化完成之后,USART_InitStructure就失去了作用。

    而在HAL库中,同样是USART初始化结构体变量,我们要定义为全局变量。

UART_HandleTypeDef UART1_Handler;

    结构体成员:

typedef struct
{USART_TypeDef                 *Instance;        /*!< UART registers base address        */UART_InitTypeDef              Init;             /*!< UART communication parameters      */
uint8_t                       *pTxBuffPtr;      /*!< Pointer to UART Tx transfer Buffer */
uint16_t                      TxXferSize;       /*!< UART Tx Transfer size              */
uint16_t                      TxXferCount;      /*!< UART Tx Transfer Counter           */
uint8_t                       *pRxBuffPtr;      /*!< Pointer to UART Rx transfer Buffer */
uint16_t                      RxXferSize;       /*!< UART Rx Transfer size              */
uint16_t                      RxXferCount;      /*!< UART Rx Transfer Counter           */DMA_HandleTypeDef             *hdmatx;          /*!< UART Tx DMA Handle parameters      */DMA_HandleTypeDef             *hdmarx;          /*!< UART Rx DMA Handle parameters      */HAL_LockTypeDef               Lock;             /*!< Locking object                     */__IO HAL_UART_StateTypeDef    State;            /*!< UART communication state           */__IO uint32_t                 ErrorCode;        /*!< UART Error code                    */
}UART_HandleTypeDef;

    我们发现,与标准库不同的是,该成员不仅包含了之前标准库就有的六个成员(波特率,数据格式等),还包含过采样、(发送或接收的)数据缓存、数据指针、串口 DMA 相关的变量、各种标志位等等要在整个项目流程中都要设置的各个成员。

    该UART1_Handler就被称为串口的句柄 它被贯穿整个USART收发的流程,比如开启中断:

HAL_UART_Receive_IT(&UART1_Handler, (u8 *)aRxBuffer, RXBUFFERSIZE);

    比如后面要讲到的MSP与Callback回调函数:

void HAL_UART_MspInit(UART_HandleTypeDef *huart);
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart);

    在这些函数中,只需要调用初始化时定义的句柄UART1_Handler就好。

2 MSP函数

    MCU Specific Package单片机的具体方案

    MSP是指和MCU相关的初始化,引用一下正点原子的解释,个人觉得说的很明白:

我们要初始化一个串口,首先要设置和 MCU 无关的东西,例如波特率,奇偶校验,停止位等,这些参数设置和 MCU 没有任何关系,可以使用 STM32F1,也可以是STM32F2/F3/F4/F7上的串口。而一个串口设备它需要一个 MCU 来承载,例如用 STM32F4 来做承载,PA9 做为发送,PA10 做为接收,MSP 就是要初始化 STM32F4 的 PA9,PA10,配置这两个引脚。所以 HAL驱动方式的初始化流程就是:HAL_USART_Init()—>HAL_USART_MspInit(),先初始化与 MCU无关的串口协议,再初始化与 MCU 相关的串口引脚。在 STM32 的 HAL 驱动中HAL_PPP_MspInit()作为回调,被HAL_PPP_Init()函数所调用。当我们需要移植程序到 STM32F1平台的时候,我们只需要修改 HAL_PPP_MspInit 函数内容而不需要修改 HAL_PPP_Init 入口参数内容。

    在HAL库中,几乎每初始化一个外设就需要设置该外设与单片机之间的联系,比如IO口,是否复用等等,可见,HAL库相对于标准库多了MSP函数之后,移植性非常强,但与此同时却增加了代码量和代码的嵌套层级。可以说各有利弊。

    同样,MSP函数又可以配合句柄,达到非常强的移植性:

void HAL_UART_MspInit(UART_HandleTypeDef *huart);

3 Callback函数

    类似于MSP函数,个人认为Callback函数主要帮助用户应用层的代码编写。还是以USART为例,在标准库中,串口中断了以后,我们要先在中断中判断是否是接收中断,然后读出数据,顺便清除中断标志位,然后再是对数据的处理,这样如果我们在一个中断函数中写这么多代码,就会显得很混乱:

void USART3_IRQHandler(void) //串口1中断服务程序
{u8 Res;
if(USART_GetITStatus(USART3, USART_IT_RXNE) != RESET)  //接收中断(接收到的数据必须是0x0d 0x0a结尾){Res =USART_ReceiveData(USART3); //读取接收到的数据
/*数据处理区*/} 
}

    而在HAL库中,进入串口中断后,直接由HAL库中断函数进行托管:

void USART1_IRQHandler(void)                 
{ HAL_UART_IRQHandler(&UART1_Handler); //调用HAL库中断处理公用函数/***************省略无关代码****************/ 
}

    HAL_UART_IRQHandler这个函数完成了判断是哪个中断(接收?发送?或者其他?),然后读出数据,保存至缓存区,顺便清除中断标志位等等操作。比如我提前设置了,串口每接收五个字节,我就要对这五个字节进行处理。在一开始我定义了一个串口接收缓存区:

/*HAL库使用的串口接收缓冲,处理逻辑由HAL库控制,接收完这个数组就会调用HAL_UART_RxCpltCallback进行处理这个数组*/
/*RXBUFFERSIZE=5*/
u8 aRxBuffer[RXBUFFERSIZE];

    在初始化中,我在句柄里设置好了缓存区的地址,缓存大小(五个字节)

/*该代码在HAL_UART_Receive_IT函数中,初始化时会引用*/
huart->pRxBuffPtr = pData;//aRxBuffer
huart->RxXferSize = Size;//RXBUFFERSIZE
huart->RxXferCount = Size;//RXBUFFERSIZE

    则在接收数据中,每接收完五个字节,HAL_UART_IRQHandler才会执行一次Callback函数:

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart);

    在这个Callback回调函数中,我们只需要对这接收到的五个字节(保存在aRxBuffer[]中)进行处理就好了,完全不用再去手动清除标志位等操作。

    所以说Callback函数是一个应用层代码的函数,我们在一开始只设置句柄里面的各个参数,然后就等着HAL库把自己安排好的代码送到手中就可以了~

    综上,就是HAL库的三个与标准库不同的地方之个人见解。

    个人觉得从这三个小点就可以看出HAL库的可移植性之强大,并且用户可以完全不去理会底层各个寄存器的操作,代码也更有逻辑性。但与此带来的是复杂的代码量,极慢的编译速度,略微低下的效率。看怎么取舍了。

HAL库结构

    说到STM32的HAL库,就不得不提STM32CubeMX,其作为一个可视化的配置工具,对于开发者来说,确实大大节省了开发时间。另外STM32CubeIDE集成了STM32CubeMX的功能,是一个集配置与编译与一体的软件,可以尝试一下。软件更新频率很高,持续优化某些bug及性能问题。

  上面两个开发软件是以HAL库为基础的,且目前仅支持HAL库及LL库!

    首先看一下,官方给出的HAL库的文件结构:

    下图是STM32库文件结构。

 stm32f2xx.h 主要包含STM32同系列芯片的不同具体型号的定义,是否使用HAL库等的定义,接着,其会根据定义的芯片信号包含具体的芯片型号的头文件:

#if defined(STM32F205xx)
#include "stm32f205xx.h"
#elif defined(STM32F215xx)
#include "stm32f215xx.h"
#elif defined(STM32F207xx)
#include "stm32f207xx.h"
#elif defined(STM32F217xx)
#include "stm32f217xx.h"
#else
#error "Please select first the target STM32F2xx device used in your application (in stm32f2xx.h file)"
#endif

紧接着,其会包含 stm32f2xx_hal.h。

  • stm32f2xx_hal.h:stm32f2xx_hal.c/h 主要实现HAL库的初始化、系统滴答相关函数、及CPU的调试模式配置
  • stm32f2xx_hal_conf.h :该文件是一个用户级别的配置文件,用来实现对HAL库的裁剪,其位于用户文件目录,不要放在库目录中。

    接下来对于HAL库的源码文件进行一下说明,HAL库文件名均以stm32f2xx_hal开头,后面加上_外设或者模块名(如:stm32f2xx_hal_adc.c):

库文件:stm32f2xx_hal_ppp.c/.h   // 主要的外设或者模块的驱动源文件,包含了该外设的通用APIstm32f2xx_hal_ppp_ex.c/.h  // 外围设备或模块驱动程序的扩展文件。这组文件中包含特定型号或者系列的芯片的特殊API。以及如果该特定的芯片内部有不同的实现方式,则该文件中的特殊API将覆盖_ppp中的通用API。stm32f2xx_hal.c/.h    // 此文件用于HAL初始化,并且包含DBGMCU、重映射和基于systick的时间延迟等相关的API其他库文件
用户级别文件:stm32f2xx_hal_msp_template.c // 只有.c没有.h。它包含用户应用程序中使用的外设的MSP初始化和反初始化(主程序和回调函数)。使用者复制到自己目录下使用模板。stm32f2xx_hal_conf_template.h // 用户级别的库配置文件模板。使用者复制到自己目录下使用system_stm32f2xx.c    // 此文件主要包含SystemInit()函数,该函数在刚复位及跳到main之前的启动过程中被调用。**它不在启动时配置系统时钟(与标准库相反)**。时钟的配置在用户文件中使用HAL API来完成。startup_stm32f2xx.s    // 芯片启动文件,主要包含堆栈定义,终端向量表等stm32f2xx_it.c/.h    // 中断处理函数的相关实现main.c/.h

 根据HAL库的命名规则,其API可以分为以下三大类:

  • 初始化/反初始化函数:HAL_PPP_Init(), HAL_PPP_DeInit()
  • IO 操作函数:HAL_PPP_Read(), HAL_PPP_Write(),HAL_PPP_Transmit(), HAL_PPP_Receive()
  • 控制函数:HAL_PPP_Set (), HAL_PPP_Get ().
  • 状态和错误:HAL_PPP_GetState (), HAL_PPP_GetError ().

注意:目前 LL 库是和 HAL 库捆绑发布的,所以在 HAL 库源码中,还有一些名为 stm32f2xx_ll_ppp 的源码文件,这些文件就是新增的LL库文件。使用 CubeMX 生产项目时,可以选择LL库。

    HAL 库最大的特点就是对底层进行了抽象。在此结构下,用户代码的处理主要分为三部分:

  • 处理外设句柄,实现用户功能
  • 处理MSP
  • 处理各种回调函数,外设句柄定义

    HAL库在结构上,对每个外设抽象成了一个称为ppp_HandleTypeDef的结构体,其中ppp就是每个外设的名字。*所有的函数都是工作在ppp_HandleTypeDef指针之下。

    每个外设/模块实例都有自己的句柄。因此,实例资源是独立的。

    外围进程相互通信:该句柄用于管理进程例程之间的共享数据资源。

    下面,以ADC为例,

/** * @brief  ADC handle Structure definition*/
typedef struct
{ADC_TypeDef                   *Instance;                   /*!< Register base address */ADC_InitTypeDef               Init;                        /*!< ADC required parameters */__IO uint32_t                 NbrOfCurrentConversionRank;  /*!< ADC number of current conversion rank */DMA_HandleTypeDef             *DMA_Handle;                 /*!< Pointer DMA Handler */HAL_LockTypeDef               Lock;                        /*!< ADC locking object */__IO uint32_t                 State;                       /*!< ADC communication state */__IO uint32_t                 ErrorCode;                   /*!< ADC Error code */
}ADC_HandleTypeDef;

从上面的定义可以看出,ADC_HandleTypeDef中包含了ADC可能出现的所有定义,对于用户想要使用ADC只要定义一个ADC_HandleTypeDef的变量,给每个变量赋好值,对应的外设就抽象完了。接下来就是具体使用了。

    当然,对于那些共享型外设或者说系统外设来说,他们不需要进行以上这样的抽象,这些部分与原来的标准外设库函数基本一样。例如以下外设:

  • GPIO
  • SYSTICK
  • NVIC
  • RCC
  • FLASH

    以GPIO 为例,对于HAL_GPIO_Init()函数,其只需要GPIO地址以及其初始化参数即可。

1 三种编程方式

    HAL库对所有的函数模型也进行了统一。在HAL库中,支持三种编程模式:轮询模式、中断模式、DMA模式(如果外设支持)。其分别对应如下三种类型的函数(以ADC为例):

HAL_StatusTypeDef HAL_ADC_Start(ADC_HandleTypeDef* hadc);
HAL_StatusTypeDef HAL_ADC_Stop(ADC_HandleTypeDef* hadc);HAL_StatusTypeDef HAL_ADC_Start_IT(ADC_HandleTypeDef* hadc);
HAL_StatusTypeDef HAL_ADC_Stop_IT(ADC_HandleTypeDef* hadc);HAL_StatusTypeDef HAL_ADC_Start_DMA(ADC_HandleTypeDef* hadc, uint32_t* pData, uint32_t Length);
HAL_StatusTypeDef HAL_ADC_Stop_DMA(ADC_HandleTypeDef* hadc);

    其中,带_IT的表示工作在中断模式下;带_DMA的工作在DMA模式下(注意:DMA模式下也是开中断的);什么都没带的就是轮询模式(没有开启中断的)。至于使用者使用何种方式,就看自己的选择了。

    此外,新的HAL库架构下统一采用宏的形式对各种中断等进行配置(原来标准外设库一般都是各种函数)。针对每种外设主要由以下宏:

  • __HAL_PPP_ENABLE_IT(HANDLE, INTERRUPT):使能一个指定的外设中断
  • __HAL_PPP_DISABLE_IT(HANDLE, INTERRUPT):失能一个指定的外设中断
  • __HAL_PPP_GET_IT (HANDLE, __ INTERRUPT __):获得一个指定的外设中断状态
  • __HAL_PPP_CLEAR_IT (HANDLE, __ INTERRUPT __):清除一个指定的外设的中断状态
  • __HAL_PPP_GET_FLAG (HANDLE, FLAG):获取一个指定的外设的标志状态
  • __HAL_PPP_CLEAR_FLAG (HANDLE, FLAG):清除一个指定的外设的标志状态
  • __HAL_PPP_ENABLE(HANDLE) :使能外设
  • __HAL_PPP_DISABLE(HANDLE) :失能外设
  • __HAL_PPP_XXXX (HANDLE, PARAM) :指定外设的宏定义
  • _HAL_PPP_GET IT_SOURCE (HANDLE, __ INTERRUPT __)检查中断源

2 三大回调函数

    在 HAL 库的源码中,到处可见一些以__weak开头的函数,而且这些函数,有些已经被实现了,比如:

__weak HAL_StatusTypeDef HAL_InitTick(uint32_t TickPriority)
{
/*Configure the SysTick to have interrupt in 1ms time basis*/HAL_SYSTICK_Config(SystemCoreClock/1000U);
/*Configure the SysTick IRQ priority */HAL_NVIC_SetPriority(SysTick_IRQn, TickPriority ,0U);
/* Return function status */
return HAL_OK;
}

有些则没有被实现,例如:

__weak void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi)
{
/* Prevent unused argument(s) compilation warning */UNUSED(hspi);
/* NOTE : This function should not be modified, when the callback is needed,the HAL_SPI_TxCpltCallback should be implemented in the user file*/
}

  所有带有__weak关键字的函数表示,就可以由用户自己来实现。如果出现了同名函数,且不带__weak关键字,那么连接器就会采用外部实现的同名函数。通常来说,HAL库负责整个处理和MCU外设的处理逻辑,并将必要部分以回调函数的形式给出到用户,用户只需要在对应的回调函数中做修改即可。

    HAL 库包含如下三种用户级别回调函数(PPP为外设名):

  1. 外设系统级初始化/解除初始化回调函数(用户代码的第二大部分:对于MSP的处理):​​HAL_PPP_MspInit()​​​和​​HAL_PPP_MspDeInit​​​ 例如:​​__weak void HAL_SPI_MspInit(SPI_HandleTypeDef *hspi)​​。在HAL_PPP_Init() 函数中被调用,用来初始化底层相关的设备(GPIOs, clock, DMA, interrupt)
  2. 处理完成回调函数:​​HAL_PPP_ProcessCpltCallback​​​(Process指具体某种处理,如UART的Tx),例如:​​__weak void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi)​​。当外设或者DMA工作完成后时,触发中断,该回调函数会在外设中断处理函数或者DMA的中断处理函数中被调用
  3. 错误处理回调函数:​​HAL_PPP_ErrorCallback​​​例如:​​__weak void HAL_SPI_ErrorCallback(SPI_HandleTypeDef *hspi)​​。当外设或者DMA出现错误时,触发终端,该回调函数会在外设中断处理函数或者DMA的中断处理函数中被调用。

相关文章:

  • Vue2+ElementUI实现无限级菜单
  • 血泪之arduino库文件找不到ArduinoJSON.h: No such file or directory错误原因
  • 解锁生成式AI潜力的金钥匙
  • 跟着deepseek学golang--Go vs Java vs JavaScript三语言的差异
  • 如何打包python程序为可执行文件
  • 时间序列成像之点对称模式(Symmetrized Dot Pattern,SDP)
  • WPF程序使用Sugar操作数据库
  • 路由器重分发(OSPF+静态路由)
  • 62.不同路径
  • stm32之EXIT外部中断详解
  • [Kaggle]:使用Kaggle服务器训练YOLOv5模型 (白嫖服务器)
  • 语音合成之七语音克隆技术突破:从VALL-E到SparkTTS,如何解决音色保真与清晰度的矛盾?
  • PyTorch数据加载与预处理
  • Redis的两种持久化方式:RDB和AOF
  • OSPF的不规则区域和特殊区域
  • WPF实现多语言切换
  • Java 实用工具类:深入讲解 CollectionUtils
  • CCF CSP 第30次(2023.05)(4_电力网络_C++)
  • C++:string 1
  • 游戏状态管理:用Pygame实现场景切换与暂停功能
  • 10台核电新机组获核准,上海核电厂商独揽超500亿元订单
  • 众信旅游:去年盈利1.06亿元,同比增长228.18%
  • 财政部农业农村司司长吴奇修接受纪律审查和监察调查
  • 《奇袭白虎团》原型人物之一赵顺合辞世,享年95岁
  • 人社部:就业政策储备充足,将会根据形势变化及时推出
  • 上海论坛2025年会聚焦创新的时代,9份复旦智库报告亮相