本文写作目标是为了让新人尽快上手并完成工作!
前言:客户需求在变化,产品也在变化,硬件在不断升级,软件也在不断更新,有限的工作时间要实现无限的客户需求,着实不可能,我们能够做的只是更快更多的满足用户需求,项目迭代是任何一个企业都在使用的方法。公司人员流动,员工更替,项目越堆越多,代码越写越难以维护,这些都对新员工造成了巨大的挑战,即使是老员工,也越来越觉得力不从心。本文从技术、工具和方法上来帮助实现老项目的熟悉和新项目的开发。
正文:
嵌入式产品层次分明,无操作系统的产品一般从硬件到应用,分别为硬件层、驱动层、驱动抽象层和应用层。其中驱动抽象层我们称之为HAL库,能否编写HAL库是一个嵌入式软件工程师中级到高级的试金石。有操作系统的产品层次划分略有不同,硬件层——操作系统(包含硬件驱动)——操作系统API——应用。驱动层在有操作系统系统的情况下,驱动需要完成两件事情:1设备驱动中的硬件操作;2.设备驱动中独立于设备的接口。驱动层对外统一表现为操作系统的API,如open(),write(),read(),close()等系统调用函数。
作为嵌入式软件工程师,又可以分为两个角色:驱动工程师和应用工程师,驱动工程师负责基于硬件编写驱动程序,提供接口给应用工程师;应用工程师负责实现项目的软件需求。无操作系统的驱动,对应某一个模块,一般写成.c和.h文件,头文件包含驱动的接口,源文件包含文件的具体实现,比如spi驱动,就有spi.c和spi.h,一般单片机的集成模块包含:GPIO、RTC、Timer、EFM、SRAM、DMA、USART、I2C、SPI、QSPI、CAN、ADC、I2S等,如果看到包含了这些关键的头文件和源文件,基本上能够确定为这些模块的驱动文件,不过在项目的文件夹管理中,一般会将驱动文件放在driver文件夹下。有操作系统的驱动,比较复杂,Linux划分为字符型驱动、块设备驱动、网络设备驱动。单独linux设备驱动工程师,就是一个技术岗位,从Linux源码来看,70%的代码都是驱动代码,也是存放在driver文件夹下。目前Linux驱动采用设备树管理dts,采用make工具来编译,生成.ko的驱动文件,使用insmod命令加载。操作系统的存在,大大增加了驱动工程师的工作量,所以有人说,操作系统就是通过给驱动制造麻烦来达到给上层应用提供便利的目标。作为设备或者传感器的使用者,比较好的一点是设备厂家会往往会提供一个设备驱动样例。
在驱动工程师和应用工程师的交互中,多数情况是应用工程师调用驱动给出的接口,但是也有例外,驱动也会调用应用层的函数,比如回调函数。回调函数的机制,能够让驱动工程师写驱动的时候预留应用层的功能实现接口,具体什么功能,怎么实现功能,还是留给应用工程师解决。回调函数的例子在有操作系统的程序中,随处可见;在我们的串口结构体中就存在回调函数的机制。当你看到函数指针的时候,就要意识到这是回调函数的使用。
作为嵌入式软件工程师,不管是写驱动还是写应用,必须能够看懂代码、搞明白逻辑关系、知道为什么要这么写,还要掌握一些惯用法和代码的最佳实践,并能够实现用户的需求。
如何看懂代码:请参考我技术分享的第二期——阅读代码!总结来说,要对程序组成元素非常熟悉,变量、函数、数组、指针、结构体等,还要对数据结构熟悉,熟悉基本的特性和操作方法,如列表、链表、堆栈、二叉树、图等。还要知道通信中速率不一致必然存在缓存,最好的缓存数据结构对应的是队列,最起码也是一个数组。掌握程序的控制流程,顺序、判断、循环、跳转、递归、并行、回调等。在C语言中,特别要注意枚举和宏定义,牢记do{ … }while(0)的宏定义惯用法。
如何搞明白逻辑关系:这个复杂度随着项目的代码量而指数增长,看起来最为烦躁。以某项目为例:
一共包含了52个类,884个结构体,88个共用体,1035个类型定义,164个枚举定义,72873个常量,2781个枚举常量,969个静态变量,734个全局变量,9687个函数,550个子函数,1717个宏,4344个函数原型,577个子函数原型,8546个数据成员、17个自定义标签。看到这么多代码,肯定头都大了,这里讲述三种方法,对应不同的程序架构。
在单片机的嵌入式程序中,常见的有三种架构:
1-前后台顺序执行程序,初始化+while(1){ //响应中断 },前台为主流程,后台为中断,这个也是我们目前程序所采用的方式。优点:对于初学者来说,这是最容易也是最直观的程序架构,逻辑简单明了,适用于逻辑简单,复杂度比较低的软件开发。缺点:实时性低,由于每个函数或多或少存在毫秒级别的延时,即使是1ms,也会造成其他函数间隔执行时间的不同,虽然可通过定时器中断的方式,但是前提是中断执行函数花的时间必须短。当程序逻辑复杂度提升时,会导致后来维护人员的大脑混乱,很难理清楚该程序的运行状态。
2-时间片轮询系统,这是介于前后台顺序执行法和操作系统之间的一种程序架构设计方案。该设计方案需能帮助嵌入式软件开发者更上一层楼,在嵌入式软件开发过程中,若遇到以下几点,那么该设计方案可以说是最优选择,适用于程序较复杂的嵌入式系统;目前的需求设计需要完全没有必要上操作系统。任务函数无需时刻执行,存在间隔时间(比如按键,一般情况下,都需要软件防抖,初学者的做法通常是延时10ms左右再去判断,但10ms极大浪费了CPU的资源,在这段时间内CPU完全可以处理很多其他事情)实时性有一定的要求。该设计方案需要使用一个定时器,一般情况下定时1ms即可(定时时间可随意定,但中断过于频繁效率就低,中断太长,实时性差),因此需要考虑到每个任务函数的执行时间,建议不能超过1ms(能通过程序优化缩短执行时间则最好优化,如果不能优化的,则必须保证该任务的执行周期必须远大于任务所执行的耗时时间),同时要求主循环或任务函数中不能存在毫秒级别的延时。
3-带有操作系统的,比较常用的有UCOS、FreeRTOS、RT-Thread Nano和RTX 等多种抢占式操作系统(其他如Linux等操作系统不适用于单片机),操作系统的特点是任务优先级和任务独立,在高优先级的任务就绪时,会抢占低优先级的任务,任务调度由操作系统完成,设计者要合理的分配任务实现和优先级,并采用合理的任务延时时间。
针对第一种架构:推荐使用的是流程分析法,具体操作为,从main函数开始,跟踪函数运行,并记录下函数执行顺序,依次进行直到完成一个周期的循环。这里要推荐两个工具,思维导图和doxygen+graphviz,前者用来自己绘制函数关系图,后者用来自动生成函数关系图。
思维导图软件有很多,如freemind、xmind、gitmind等,基本功能都可以绘制节点图和流程图,部分存在节点限制和收费,选择一款自己喜欢的就可以。下图是使用亿图脑图绘制的,初始化主流程。亿图脑图的最大限制是在单个文件里面节点数量最大为100,对应的办法是多建立几个文件,一个文件说清楚一个模块就行了。
有人提出使用viso绘制函数之间的关系图,之前我确实是用viso给《软件设计说明》里面配置的函数流程图,做CSU和CSC功能关系分析,效率不是特别高,一个上午绘制一图是极有可能的。Viso还是作为文档的插图确实好看,但是在分析一个工程流程的时候,不是最佳的,至少通过本人实践是这样认为的。
使用doxygen可以生成注释文档,只要编写注释的时候符合doxygen语法要求即可。所谓Doxygen语法就是在写程序注视时候按照Doxygen语法规则来写注释。只有按照标准的注释规则来写注释,生成的文档才会非常漂亮,否则乱七八糟的。以下表格是语法介绍,在keil编辑器中,如果看到注释// @file file这个词会区别与默认的绿色,那就是满足这个语法要求了。(操作参考:https://mp.weixin.qq.com/s/HA352TEBELNJ9pgCTMkWIg)
命令 | 字段名 | 语法 |
@file | 文件名 | file [< name >] |
@brief | 简介 | brief { brief description } |
@author | 作者 | author { list of authors } |
@mainpage | 主页信息 | mainpage [(title)] |
@date | 年-月-日 | date { date description } |
@author | 版本号 | version { version number } |
@copyright | 版权 | copyright { copyright description } |
@param | 参数 | param [(dir)] < parameter-name> { parameter description } |
@return | 返回 | return { description of the return value } |
@retval | 返回值 | retval { description } |
@bug | 漏洞 | bug { bug description } |
@details | 细节 | details { detailed description } |
@pre | 前提条件 | pre { description of the precondition } |
@see | 参考 | see { references } |
@link | 连接(与@see类库,{@link www.google.com}) | link < link-object> |
@throw | 异常描述 | throw < exception-object> { exception description } |
@todo | 待处理 | todo { paragraph describing what is to be done } |
@warning | 警告信息 | warning { warning message } |
@deprecated | 弃用说明。可用于描述替代方案,预期寿命等 | deprecated { description } |
@example | 弃用说明。可用于描述替代方案,预期寿命等 | deprecated { description } |
针对项目实际,大概总结成为几种常见的注释,见下文。
///
1.文件注释
/**
* @file 文件名
* @brief 简介
* @details 细节
* @author 作者
* @version 版本
* @date 日期
* @company公司
* @copyright 版权
*/
文件注释e.g.
/**
* @file main.c
* @brief 程序运行主函数
* @details 程序运行需要外接串口
* @author jinyh
* @version alpha1.0.0; beta V1.0.0; RC: V1.2.0; release:V2.1.0;
* @date 2021-10-28
* @company NJHZZ.Tech.Co.Ltd
* @copyright All rights reserved.
*/
2.结构体或者类的注释
/**
* @brief 简介结构体功能
*/
结构体注释e.g.
/**
* @brief 定义一个专用的时间结构,将字符型的时间转换为结构体时间
*/
typedef struct __GNSS_TIME
{
unsigned short Year;
unsigned char Month;
unsigned char Day;
unsigned char Hour;
unsigned char Minute;
unsigned char Second;
unsigned char res;
}GNSS_TIME;
3.函数注释
/**
* @brief 函数描述
* @param 函数参数
* @return 返回描述
* @author 作者
* @data 日期
*/
函数注释e.g.
/**
* @brief 本函数实现对经过校验完整的一帧数据解析成为GNSS_GSA数据
* @param pDstData:要保存的结构体,格式为GSA数据;pSrcData:要输入的完整一帧数据
* @return 返回操作是否成功标志,成功:OPERATION_OK;其他情况失败
* @author jinyh
* @data 2021-10-28
*/
sgps_u8 RNSSInstructionParse_GSA(GNSS_GSA * pDstData, sgps_u8 * pSrcData);
4.常量/变量注释
1.上一行注释;2.紧随其后注释。
// 定义一个整型变量Year
int year;
2.紧随其后注释。
int year; // 定义一个整型变量year
int month; /*!< 定义一个整型变量month */
int month; /**< 定义一个整型变量month */
int month; //!< 定义一个整型变量month
int month; ///< 定义一个整型变量month
5.注释对齐
//以最长的变量名称为基准,向后拉一个tab制表符,tab键设置为4个空格。
///
Doxygen配合graphviz使用可以生成函数调用关系图,具体如何使用,请查阅相关资料(如何使用Doxygen 生成函数调用关系图(graphviz 2.30、chm)_thunderclaus的博客-CSDN博客_doxygen 函数调用关系),注意的地方就是如下图所示:在1处,选择dot;2处选择call graph,表示要绘制图;3处选择graphviz的安装路径里面的bin文件夹。选好之后,run,可以生成函数调用关系图。
函数调用关系图示例:下图是一个函数的调用关系图,表示哪些地方调用了这个函数,每个函数包含超链接,点开就能看,并且写文档的话,也可以直接复制,非常方便,强烈推荐。
再回到流程分析中,在程序中如果用C语言实现多数情况为有限状态机,状态机将场景应用划分为一组完全的状态,状态标志位一般为枚举类型,每个状态包含了对应情况的处理,满足一定的条件,会发生状态切换,状态机本身是闭环的,如果存在未处理的状态,往往是bug的来源。分析有限状态机,典型方法就是绘制状态图,这里可以手绘,也可以使用matlab的stateflow功能。当所有流程都梳理过一遍之后,并把状态机绘制出来,项目流程就清楚了。
第二种架构的分析方法为:时序分析法,因为每个任务的时间片不一样,所以执行时序是不一样的,如果一个任务所需要的时间比较长,也会将任务按照状态来切分为一个有限状态机。将每个任务执行的时间点绘制在循环的时间轴上面,就理清楚了。此种模式的应用优点是取消了直接的延时函数,采用定时器中断来产生周期性的时间段,应用有:控制LED的闪烁频率,惯性导航的递推解算,按键的消抖,项目中用到的RTC时钟也属于这种方式的应用。
第三种架构:此种模式下,时序分析和流程分析都无法很成功的绘制出来,采用任务逐个分析法。项目采用了操作系统,会将功能的实现置于一个个任务里面,每个任务都有独特的优先级(如果任务优先级相同,则退化为时间片轮询),一个任务对应一个xx_task.c,从主流程开始到start任务,start任务负责建立起功能功能任务,并会删除自身,然后进入到任务调度循环,按照任务优先级进行单个任务详细分析,单个任务之内可能是一个C++的类,也可能是一个状态机。任务分析之后,就对程序的功能了解了。
如何知道代码为什么这么写,一看注释,二问作者,三问项目技术负责人,四问产品经理。
常见的惯用法和最佳实践:这个需要自行挖掘,每一个程序员都有自己的代码库,用到什么代码就找到什么代码,绝不会是任何函数都是自己重新来编写的。
常见的惯用法有:
1、cpp里的c代码按照c的方式来编译和调用
时常在cpp的代码之中看到这样的代码:
#ifdef __cplusplus
extern "C" {
#endif
//一段代码
#ifdef __cplusplus
}
#endif
2、多使用移位代替乘除操作,效率高
大小端交换操作:
int32_t swapInt32(int32_t value)
{
return ((value & 0x000000FF) << 24) |
((value & 0x0000FF00) << 8) |
((value & 0x00FF0000) >> 8) |
((value & 0xFF000000) >> 24) ;
}
判断2的幂(这个leetcode有同款题目):
static bool is_power_of_2(uint32_t x)
{
return (x != 0) && ((x & (x - 1)) == 0);
}
3、UL类型
0UL--------无符号长整型0
1UL--------无符号长整型1
如果没有UL后缀,则系统默认为 int类型,即,有符号整形
4、结构体中的偏移量
#define offsetof(TYPE,MEMBER) (size_t)&((TYPE*)0)->MEMBER
5、结构体字节对齐
我们可以按照自己设定的对齐大小来编译程序,GNU使用__attribute__选项来设置,比如我们想让刚才的结构按一字节对齐,我们可以这样定义结构体
struct stu{undefined
char sex;
int length;
char name[10];
}__attribute__ ((aligned (1)));
struct stu my_stu;
则sizeof(my_stu)可以得到大小为15。否则不设置aligned的话得到的大小为20。
6、位域,用来操作寄存器的位十分方便,在驱动中十分常见。
7、数组遍历,
int a[N], *p;
for(p = a; p < a + N; p++)
sum += *p;
8、字符串数组声明,
#define STR_LEN 80
Char str[STR_LEN+1];
9、搜索链表
struct Node{
int n;
struct Node* next;
};
struct Node *p;
for(p = first; p != NULL; p = p->next){
/* your code */
}
10、位设置,将第j位设置为1:i |= 1 << j;
将第j位设置为0:i &= ~(1 << j);
11、测试第j位是否被设置
if( i & (1 << j)){
/* your code */
}
12、定义枚举类型
typedef enum{
Mon,
Tue,
Wed,
Thu,
Fri,
Sat,
Sun,
Max
}Weekday;
熟悉原来的项目是新开发功能的基础,新功能必然对应的新的项目需求,此处的开发要求为:从需求变为设计,设计变为实现,实现之后,经过测试然后交付。开启新项目的PDAC循环。
写程序就跟打游戏一样,打怪升级爆装备,享受工作的快乐吧!