给自己的学习总结帖~~ 这里仅都是c语言 嵌入式相关代码第3季啊
一、在Keil中打印日志找bug
大多数嵌入式开发者都会碰到对程序进行调试或者查找BUG的情况,常见的两种方法都是仿真和通过日志进行的。
有条件仿真的是更好,但是在编写嵌入式软件程序过程中,并不是所有的硬件都支持仿真或者方便仿真,这样大多数方法便是加打印信息进行找问题。
打印的接口有很多种,比如串口,网络,显示等等。简单的硬件开发串口估计是最常用的。
这里简单记录下一种日志打印方法。
目的:
1)方便查找打印日志
2)日志格式规范
3)可以查看用户添加信息,文件信息,函数接口,以及文件所在行号
4)支持format格式
5)可以支持实时时间
基本知识:
C语言中的__FILE__、LINE__和__FUNCTION
1、FILE 用于指示本行代码所在源文件的文件名;
2、__LINE__用于指示本行代码所在源文件中的位置(行数);
3、__FUNCTION__用于指示本行代码所在函数(函数名);
注:
1)"FILE "、"LINE"、"FUNCTION"等均大小写敏感
2)支持需要添加头文件#include<stdio.h>
源码
源码主要使用宏定义的方式,为了支持文件、行号,函数等等,不能使用单独的函数进行定义。具体原因可以思考下,不过多赘述。
C文件目前只有一个数组定义char szBuf[DEBUG_MAX_SIZE];。
为了兼容,INFO,WARN和ERR三个共用一个buf,声明全局变量更主要的一个原因是使用了串口的中断发送函数usart1_send_buf_with_txe,而不是循环发送完成再退出,这里传输是进行指针传递的,发送使用中断的好处就是高效率,不耽误其他程序跑。
当然移植的时候完全可以根据自己的接口和实现方式进行更换。
实现效果
二、 HAL库实现STM32的外设驱动
#1、如何控制单片机?
单片机的内存映射图解析
这里以STM32F429芯片为例,讲解下单片机芯片内存映射图。从此图中可以看到芯片的外设被分配了512M的空间,然而真正的外设其实没有使用到512M的内存空间。
然后我们操作外设时,只需要操作它对应的内存地址即可。更加详细的外设内存地址,可以参考芯片的用户手册(不是数据手册)的Memory map章节。
因为单片机是将外设映射到内存地址上,所以我们可以像操作内存一样来操作外设(写/读)。
我们在操作内存时是通过地址来进行操作的,由于单片机已经将外设与内存进行了映射,所以我们在操作单片机外设时只需要操作外设映射的内存地址就行。
内存如何操作?
在C语言中操作内存,我们可以用指针来进行操作。在汇编语言中由于没有指针这个概念,所以我们在操作地址时只能用一些内存读写指令来完成。比如:LDR,STR
结构体操作与宏定义操作的对比
C语言——宏定义形式:
C语言——结构体操作:
汇编语言操作内存
#2、寄存器方式操作单片机
代码结构框架:
文件结构
一个芯片头文件:外设的地址宏定义以及外设相关的结构体定义
查看芯片的用户手册(注意:不是数据手册)中寄存器对应的相应地址。然后使用宏定义来将它们定义好,同时定义好结构体来更加方便的管理外设寄存器组。这里以STM32F1系列为例
- 一个启动文件:汇编编写的、中断向量表等
- 用户代码文件:剩余就是用户代码文件了
- 各个芯片的外设驱动函数编写(读/写、控制)、以及用户逻辑部分代码。
#3、使用HAL库方式操作单片机
HAL库与固件库的区别
HAL全称Hardware abstract layer(硬件抽象层),这是一个大家公认并且遵守的一种函数名称命名、资源定义。因为是统一的命名规范,所以当用户更换芯片平台后由于函数命名与所使用的资源都与具体硬件没有关系,这样就不需要修改用户层代码了。
而所谓的标准库其实就是芯片厂商公司内部自己命名与实现的库并且各个厂商的命名规则不一样,这样就会导致可移植性变差。当用户更换了芯片平台后由于它们各自的API函数不一样就会导致用户需要修改应用层代码。
HAL库设计
1. HAL框架设计
2. HAL资源命名规则
HAL函数命名规则:
3. 文件结构:
一个芯片头文件:外设的地址宏定义以及外设相关的结构体定义。
查看芯片的用户手册(注意:不是数据手册)中寄存器对应的相应地址。然后使用宏定义来将它们定义好,同时定义好结构体来更加方便的管理外设寄存器组。这里以STM32F1系列为例。
一个启动文件:汇编编写的、中断向量表等。
一个HAL库全局头文件:一些全局的宏定义以及包含其他外设头文件。
HAL库文件:芯片外设的驱动函数。
#4、HAL库组成说明
HAL库的数据结构体
- • 外设句柄结构体
- • 初始化和配置结构体
- • 特定的处理结构体(读写/控制)
外设句柄结构体(跟硬件不相关):
比如:下面这个串口结构体
初始化结构体(跟硬件相关):
比如:下面这个串口硬件相关的结构体
特定的处理结构体(跟硬件有关):
比如:下面这个ADC处理处理结构体
HAL库公共资源
HAL Status:状态枚举
HAL Locked:用于防止共享资源被意外访问
公共的宏定义:NULL 和 HAL_MAX_DELAY
HAL库中断回调函数实现说明
(1)使用 __ weak 定义好回调函数。如果用户自己重写了回调函数,那么编译器就会使用用户重写的这个回调函数。其中__ weak 这个关键字是编译器定义的。
(2)使用函数指针。定义一个全局的函数指针变量,在初始化函数时将我们自定义的回调函数赋值给这个全局的函数指针变量(这一步也叫做:注册)。然后在中断函数中通过这个全局的函数指针变量来调用我们自定义的回调函数。
三、 单片机轮询程序框架
1 总程序架构的重要性
很多人尤其是初学者在写代码的时候往往都是想一点写一点,最开始没有一个整体的规划,导致后面代码越写越乱,bug不断。
最终代码跑起来看似没有问题(有可能也真的没有问题),但是要加一个功能的时候会浪费大量的时间,甚至导致整个代码的崩溃。
所以,在一个项目开始的时候多花一些时间在代码的架构设计上是十分有必要的。代码架构确定好了之后你会发现敲代码的时候会特别快,并且在后期调试的时候也不会像无头苍蝇一样胡乱找问题。当然,调试也是一门技术。
在学习实时操作系统的过程中,发现实时操作系统框架与个人的业务代码之间的耦合性就非常低,都是只需要将业务代码通过一定的接口函数注册好后就交给操作系统托管了,十分方便。
但是操作系统的调度过于复杂,这里就使用操作系统的思维方式来重构这个时间片轮询框架。实现该框架的完全解耦,用户只需要包含头文件,并且在使用过程中不需要改动已经写好的库文件。
2 参考代码
首先来个demo,该demo是使用电脑开两个线程:一个线程模拟单片机的定时器中断产生时间片轮询个时钟,另一个线程则模拟主函数中一直运行的时间片轮询调度程序。
运行结果如下:
由以上例子可见,这个框架使用十分方便,甚至可以完全不知道其原理,仅仅通过几个简单的接口就可以迅速创建任务并加入到时间片轮询的框架中,十分好用。
3 时间片轮询架构
其实该部分主要使用了面向对象的思维,使用结构体作为对象,并使用结构体指针作为参数传递,这样作可以节省资源,并且有着极高的运行效率。
其中最难的部分是侵入式链表的使用,这种链表在一些操作系统内核中使用十分广泛,这里是参考RT-Thread实时操作系统中的侵入式链表实现。
h文件:
.c文件:
4 底层侵入式双向链表
该链表是linux内核中使用十分广泛,也十分经典,其原理具体可以参考文章:
.h文件:
.c文件:
四、嵌入式编程:上下文切换,解决代码耦合问题
我们通常认为,在中断中,不能执行耗时的操作,否则会影响系统的稳定性,尤其对于嵌入式编程。对于带操作系统的程序而言,可以通过操作系统的调度,将中断处理分成两个部分,耗时的操作可以放到线程中去执行,但是对于没有操作系统的情况,又应该如何处理呢
比较常见的,我们可能会定义一些全局变量,作为flag,然后在mainloop中不停的判断这些flag,再在中断中修改这些flag,最后在mainloop中执行具体的逻辑,但是这样,无疑会增加耦合,增加程序维护成本。
cpost
cpost正是应用在这种情况下的一个简单但又十分方便的工具,它可以特别方便的进行上下文的切换,减少模块耦合。
cpost链接:
cpost借鉴的Android的handler机制,通过在mainloop中跑一个任务,然后在其他地方,可以是中断,也可以是模块逻辑中,直接抛出需要执行的函数,使其脱离调用处的上下文,运行在mainloop中。cpost还支持延迟处理,可以指定函数在抛出后多久执行。
使用:
cpost的使用十分简单,这里以使用在嵌入式无操作系统中为例,主要用作中断延迟处理的情况
1、配置系统tick
配置cpost.h中的宏CPOST_GET_TICK(),配置成获取系统tick,以stm32 hal为例:
2、配置处理进程
在mainloop调用cpostProcess函数:
3、抛出任务
在中断等需要进行上下文切换的地方调用cpsot接口,使其在mainloop中运行:
原理解析:
cpost的原理其实很简单,其代码量也十分少,总共加起来就只有几十行代码,cpost维护了一个而全局的数组
其中,数组的每一个元素表示包含了需要执行的函数和参数,当调用cpost接口时,被post的函数和参数会被保存在这个数组中,然后mainloop中运行的cpostProcess函数会遍历这个数组,当满足条件时,执行对应的函数,从而达到上下文切换的目的。
其实,cpost的方式,和一开始提到的使用全局的flag进行上下文切换的方法很像,只不过,cpost通过一个数组的维护和直接post函数的方式,省去了维护flag的成本,也不需要将需要执行的函数耦合到mianloop中,从而变得简单易用。
cevent应用
对于模块化编程来说,如何实现各模块间的解耦一直是一个比较令人头疼的问题,特别是对于嵌入式编程,由于控制逻辑复杂,并且对程序体积有控制,经常容易写出各独立模块之间相互调用的问题。由此,cpost中的cevent组件,通过模仿Android系统中的广播机制,提供了一种非常简单的模块间解耦实现。
原理:
cevent借鉴的是Android系统的广播机制,一方面,各模块在工作的时候,都会有多个具体的事件点,在高耦合的编程中,可能会在这些地方调用其他模块的功能,比如说,在通信模块接收到指令的时候,需要闪烁一下指示灯。
使用cevent,我们可以在这些地方抛出一个事件,当前模块不需要关心在这各地方需要执行哪些其他模块的逻辑,由其他模块,或者用户定义一个事件监听,当具体的事件发生时,执行相应的动作。
使用:
cevent使用注册的方式监听事件,会依赖于编译环境,目前支持keil,iar,和gcc,对于gcc,需要修改链接文件(.ld),在只读数据区添加:
1、初始化cevent
系统初始化时,调用ceventInit:
2、注册cevent事件监听
在c文件中,调用CEVENT_EXPORT导出事件监听:
3、发送cevent事件
在事件发生的地方,调用ceventPost抛出事件:
使用cevent解耦模块初始化
嵌入式编程中,我们习惯会在程序启动的时候,调用各个模块的初始化函数,其实这也是一种耦合,会造成main函数中出现很长的初始化代码,借助cevent,我们可以对初始化进行优化解耦。
1、定义初始化事件
定义初始化事件的值,对于初始化,有些模块可能会依赖于其他模块的初始化,会有一个先后顺序要求,所以这里我们可以把初始化分成两个阶段,定义两个事件,当然,如果有更复杂的要求,可以再多分几个阶段,只需要多定义几个事件就行
2、初始化cevent,抛出事件
在main函数中初始化cevent,并抛出初始化事件:
3、注册事件监听
对所有需要初始化的函数注册事件监听,这里我以对letter-shell注册事件监听为例,分为两个部分,初始化串口和初始化shell。
在serial模块中,将串口初始化注册到初始化第一阶段,cevent支持将不大于7个的参数直接传递到注册的监听函数中,下面的注册方式,相当于在EVENT_INIT_STAGE1事件发生的地方,也就是main函数中对应的位置,调用serialInit(&debugSerial)
然后再shell模块中,将shell初始化函数注册到初始化第二阶段。
使用cevent解耦mainloop
再无操作系统的嵌入式编程中,我们如果同时希望运行多个模块的逻辑,通常是在mainloop中循环调用,这种将函数写入mainloop的做法,也会增加耦合
通过使用cevent,也可以很方便的消除这种耦合:
1、定义mainloop事件
定义mainloop事件的值。
2、在mainloop中抛出事件
去掉mainloop中对其他模块的调用,改为排除mainloop事件:
3、在各模块中注册事件监听
分别在各个模块中,注册对mainloop事件的监听:
结语
cevent是一个非常小的模块,本身代码及其简单,但是,通过模仿广播机制,让cevent可以发挥很强大的功能,通过,还可以结合cpost,实现延迟事件等功能。
五、单片机多任务事件驱动
单片机的ROM与RAM存贮空间有限,一般没有多线程可用,给复杂的单片机项目带来困扰。
经过多年的单片机项目实践,借鉴windows消息机制的思想,编写了单片机多任务事件驱动C代码,应用于单片机项目,无论复杂的项目,还是简单的项目,都可以达到优化代码架构的目的。
经过几轮的精简、优化,现在分享给大家。
代码分为3个模块:任务列表、事件列表、定时器列表。
任务列表创建一个全局列表管理任务,通过调用taskCreat()创建事件处理任务,创建成功返回任务ID,任务列表、事件列表与定时器列表通过任务ID关联。
事件列表创建一个全局循环列表管理事件,调用taskEventIssue()生成一个事件,放到事件循环列表,taskEventLoop()函数放到主线程循环调用,当事件循环列表中有事件时,根据任务ID分发到具体的事件处理任务。
定时器列表创建一个全局列表管理定时器,taskTimer()建立一个定时器,放到定时器列表执行,当定时时间到,会生成一个定时器事件,放到事件列表,分发到具体的事件处理任务。
六、 嵌入式C语言经典面试题
#error的作用是什么?
#error 指令让预处理器发出一条错误信息,并且会中断编译过程。下面我们从Linux代码中抽取出来一小段代码并做修改得到示例代码:
这段示例代码很简单,当RX_BUF_IDX宏的值不为0~3时,在预处理阶段就会通过 #error 指令输出一条错误提示信息:
"Invalid configuration for 8139_RXBUF_IDX"
下面编译看一看结果:
位操作的基本使用
给一个32bit数据的位置1,怎么用宏来实现?
隐式转换规则
如下代码的输出结果是?为什么?
程序输出结果为:
原因是因为编译器会将有符号数b转换成为一个无符号数,即此处 a+b 等价于 a+(unsigned int)b 。
该程序运行在32bit环境下,b的值为 0xFFFFFFFF-20+1 = 4294967276 ,即a+b将远远大于6。
C 语言按照一定的规则来进行此类运算的转换,这种规则称为 正常算术转换 ,转换的顺序为:
即操作数类型排在后面的与操作数类型排在前面的进行运算时,排在后面的类型将 隐式转换 为排在前面的类型。
typedef与define的区别
(1)#define之后不带分号,typedef之后带分号。
(2)#define可以使用其他类型说明符对宏类型名进行扩展,而 typedef 不能这样做。如:
INT1可以使用类型说明符unsigned进行扩展,而INT2不能使用unsigned进行扩展。
(3)在连续定义几个变量的时候,typedef 能够保证定义的所有变量均为同一类型,而 #define 则无法保证。如:
PINT1定义的p1与p2类型不同,即p1为指向整形的指针变量,p2为整形变量;PINT2定义的p1与p2类型相同,即都是指向 int 类型的指针。
写一个MAX宏
使用括号把参数括起来可以解决了运算符优先级带来的问题。这样的MAX宏基本可以满足日常使用,但是还有更严谨的高级写法。
感兴趣的可参考文章:
https://www.zhaixue.cc/c-arm/c-arm-express.html
死循环
嵌入式系统中经常要用到无限循环,你怎么样用C编写死循环呢?
(1)while
(2)for
(3)goto
static的作用
在C语言中,关键字static有三个明显的作用:
1、在函数体修饰变量
一个被声明为静态的变量在这一函数被调用过程中维持其值不变。
2、 在模块内(但在函数体外)修饰变量
一个被声明为静态的变量可以被模块内所用函数访问,但不能被模块外其它函数访问。它是一个本地的全局变量。
3、在模块内修饰函数
一个被声明为静态的函数只可被这一模块内的其它函数调用。那就是,这个函数被限制在声明它的模块的本地范围内使用。
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). 下面的函数有什么错误:
下面是答案:
1). 是的。一个例子是只读的状态寄存器。它是volatile因为它可能被意想不到地改变。它是const因为程序不应该试图去修改它。
2). 是的。尽管这并不很常见。一个例子是当一个中服务子程序修该一个指向一个buffer的指针时。
3). 这段代码的有个恶作剧。这段代码的目的是用来返指针 *ptr 指向值的平方,但是,由于 *ptr 指向一个volatile型参数,编译器将产生类似下面的代码:
由于*ptr的值可能在两次取值语句之间发生改变,因此a和b可能是不同的。结果,这段代码可能返回的不是你所期望的平方值!正确的代码如下:
变量定义
用变量a给出下面的定义:
- a)一个整型数
- b) 一个指向整型数的指针
- c) 一个指向指针的的指针,它指向的指针是指向一个整型数
- d) 一个有10个整型数的数组
- e) 一个有10个指针的数组,该指针是指向一个整型数的:
- f) 一个指向有10个整型数数组的指针
- g) 一个指向函数的指针,该函数有一个整型参数并返回一个整型数
- h) 一个有10个函数指针的数组,该指针指向一个函数,该函数有一个整型参数并返回一个整型数
中断函数
中断是嵌入式系统中重要的组成部分,这导致了很多编译开发商提供一种扩展—让标准C支持中断。具代表事实是,产生了一个新的关键字 __ interrupt 。
下面的代码就使用了 __ interrupt 关键字去定义了一个中断服务子程序(ISR),请评论一下这段代码的。
1). ISR 不能返回一个值。
2). ISR 不能传递参数。
3). 在许多的处理器/编译器中,浮点一般都是不可重入的。有些处理器/编译器需要让额处的寄存器入栈,有些处理器/编译器就是不允许在ISR中做浮点运算。此外,ISR应该是短而有效率的,在ISR中做浮点运算是不明智的。
4). 与第三点一脉相承,printf()经常有重入和性能上的问题。