基于stm32F407嵌入式底层驱动开发实践

本目录文件为STM32F407开发板相关教程文档

所有资料发布于百度云与github,

百度云目录说明

本工程包含代码、资料、文档,目录说明如下。

文档说明

build是md转换为html的目录。

source是md文档目录。其中spec是产品说明书,base是基础教程,Advanced是提高教程。

本教程文档使用markdown格式,使用Typora编写。

所有文档使用Sphinx管理。

md文件经过sphinx处理后,生成html文件。文件入口在doc\build\html中的index.html,使用浏览器打开即可浏览所有文档。

sphinx可以将文档处理为pdf,如有需要,可自行转换。

Typora也可以将md文档转换为pdf。

可参考:

《使用ReadtheDocs托管文档》

https://www.xncoding.com/2017/01/22/fullstack/readthedoc.html

屋脊雀STM32F407开发板产品手册


够用的硬件

*能用的代码

实用的教程**

官网:www.wujique.com

github:https://github.com/wujique/stm32f407

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

2018.11.28


硬件

整体产品图

使用核心板与底板设计方式,整体长宽只有一张卡片大小。 底板按照功能进行模块设计,接口全部按照功能分组引出,用户可按照接口自行开发其他芯片核心板。 结构设计精巧,多处进行隐藏性设计。 _images/pic012.jpg

核心板顶面

_images/pic022.jpg整体

  1. 摄像头接口。
  2. USB电源控制电路,实现HOST和DEVICE两种工作状态切换。
  3. usb接口,做HOST时通过OTG转接线转接。
  4. 调试口电源和电源指示灯。
  5. BOOT0选择。拨到下方1状态,进入ISP下载模式,也即是系统区启动。拨到上方0,则从用户FLASH控件启动。 BOOT1通过跳线电阻控制,焊接R105,则连接到高电平;焊接R103则连接到低电平。默认焊接R103,不焊R105。
  6. 复位按键
  7. 用户按键
  8. 外扩接口
  9. LED灯
  10. SPI FLASH
  11. LCD接口,包含触摸屏信号。
  12. 电源电路
  13. 主芯片

核心板底面

_images/pic032.jpg

  1. 调试口接口,包含SW调试信号,串口,电源。
  2. 5V升压电路。
  3. TF卡座。
  4. 纽扣电池
  5. 与底板接口
  6. 与底板接口

底板

_images/pic041.jpg

  1. CAN座子,左边是CANL,右边是CANH。
  2. RS485座子,左边是A,右边是B。
  3. 蜂鸣器
  4. 电源输入,使用MICOR USB接口,保险为1.5A,可直接用手机充电器供电。
  5. USB转串口,同时可做电源输入,保险为1A。
  6. 网口与网络电路。
  7. can芯片
  8. RS485芯片。
  9. 电源电路
  10. SPI FLASH
  11. 触摸按键
  12. 语音芯片喇叭接口
  13. 语音芯片耳机接口
  14. 语音芯片MIC
  15. 语音芯片WM8978
  16. 功放和喇叭接口。
  17. 收音机芯片TEA5767
  18. 功放音源选择,选择DAC语音或收音机声音做为输入。
  19. 收音机天线座子
  20. 与核心板接口
  21. 与核心板接口

关键器件规格参数

  • 核心板
- 型号 规格
CPU STM32F407ZG 1M FLASH,128K+64K RAM,
5V升压 MP1541 500MA
LDO aic1733-33pvt 500ma, 3.3V,700ma关断
SPI FLASH W25Q64FVSI 64MBit,8MByte
  • 底板
- 型号 规格
LDO aic1733-33pvt 500ma, 3.3V,700ma关断
SPI FLASH MX25L3206EM2I-12G 32MBit
usb-uart CP2104 -
网络 LAN8720A -
音频 WM8978 -
CAN SN65HVD230DR 3.3V
485 SP3485E -
收音机 TEA5767 -
音频功放 TDA2822G -

接口说明

  • 液晶接口 _images/pic051.jpgLCD接口
  1. LCD接口为8080接口,16位总线。
  2. 有两根电源线,电压分别是5V和3.3V,5V由升压芯片提供。
  3. 包含4根触摸屏控制信号,如果使用XPT2046,则用作模拟SPI, 如果使用ADC转换检测触摸屏,则作为ADC和IO.
  • 摄像头接口 _images/pic06.jpg摄像头接口
标准通用的DCMI接口摄像头。
  • 核心板接口 下图是核心板的信号接口,实物上排针是放在底面,需要做镜像处理,细节请查看位号图和信号说明图 _images/pic07.jpg核心板底板接口
  • 调试接口 _images/pic08.jpg调试接口
  1. 包含SW调试信号,串口,5V电源。
  2. 兼容屋脊雀设计的CMISIS DAP、DAPLink。
  3. 调试串口同时通过底板经过CP2104转换后经USB输出。
  • 外扩接口 _images/pic09.jpg外扩接口 精心设计
  1. 主要包含4个功能区域:SPI、串口、I2C、普通IO。
  2. SPI区域可以直接接安信可RF24L01、OLED LCD(spi)、COG LCD、2.8寸SPI TFTLCD。图上的方向就是模块方向,例如RF24L01,就是PCB朝核心板内插。
  3. 外扩串口可以直接插安信可的ESP8266模块。
  4. I2C可接OLED LCD(i2c),管脚跟SPI兼容,也就是说,你买一个我们的OLED LCD模块,这个模块通过跳线电阻选择是SPI接口还是I2C接口,然后就可以接到这个外扩接口的I2C或者是SPI接口上。
  5. 外扩普通IO,布局和SPI/I2C兼容,意味着可以使用这些IO模拟SPI或者是I2C。我们的模块就可以接到这个模拟的接口上。 做普通IO口使用,可以接我们的4*4矩阵按键。

外扩接口具体使用方法请查看《基本使用手册》

  • 串口

在外扩接口上有一个外扩串口,但是跟USB、摄像头共用了几个IO。 如果你希望使用一个串口,并且使用摄像头,例如:使用一个摄像头并且用串口接ESP8266模块。 可以这样:

  1. 不使用调试口上的调试串口。
  2. 用杜邦线将ESP8266模块串口接到调试串口。
  3. 其他控制IO用外扩普通IO。
  4. 禁止掉程序的所有调试信息。

电源总图

_images/pic101.jpg电源树 上图是核心板和底板电源树。 左边是底板,右边是核心板。 底板将5V和3V3通过接口向核心板提供连接。 我们的F407核心板只是连接5V,3V3没有接。核心板使用自己LDO将5V转为3V3。

  1. 底板有两路电源输入,都是USB MICRO接口。其中一路是独立电源输入,串1.5A保险,可以用手机充电器供电。另外一路是USB转串口,在用串口时,也可以对系统供电,串的保险是1A。
  2. 底板有3V3 LDO,理论提供500ma电流,实际上是700ma关断。
  3. 核心板同样有两路电源输入,一路是OTG USB,另外一路是调试口输入。调试接口这路加有二极管和1A保险。OTG USB口输入电源只有二极管没有保险丝。
  4. 调试电压输入是通过CMSIS DAP或者DAPLink接入的5V,这个5V通常也是由电脑提供,通常只能提供500ma电流。
  5. 为了实现OTG,核心板除输入电源转3.3V外,还有两个电源模块:一个是将输入电源升压到5V;另外就是OTG电源输入输出切换电路。
  6. 5V升压理论能提供500ma电流,目前除了提供给USB外,还连接到外扩接口和LCD接口。屋脊雀的LCD模组带LDO,因此通常使用的是升压后的5V供电。
  7. 四路电源输入全部是5V输入,可以随便接。也可以同时接多个,多个能提供更多电流,不会造成倒灌。

常用模块功耗

一些列表为一些常用模块功耗,如果使用中发现LCD屏幕背光抖动,或者其他异常,可能是电源不足,压降太大,请将底板的1.5A电源接上。

  • 以下电流都是以工作电压测量,如有必要,请自行转换为5V输入端电流。
模块 工作电压 电流
COG LCD 3.3V 60ma,背光亮
OLED LCD 3.3V 20ma,全屏点亮
2.8 tft LCD 3.3V 100ma,全屏点亮,其中背光80ma
RF24L01 3.3V -
407核心板空载 3.3V 60ma
底板 3.3V 200ma

注意事项

  • USB口电源切换问题

为了实现OTG用一个micro接口,我们设计了一个电源切换电路。 _images/pic118.jpgHOST供电

接U盘,STM32作为HOST,otg_fs_power_switch输出低电平,升压得到的5V通过U901输出,供电给U盘,同时Q903 MOS管关断,防止USB_FS_VBUS反向倒灌到系统5V电源。 接电脑,STM32作为DEVICE,otg_fs_power_switch转输入或者高阻态,U901关断,Q903导通,USB_FS_VBUS供电给核心板使用。 电路没加保险丝,也没有二极管保护。当LCD,摄像头等都接上时,电流可能大于500ma,直接接电脑可能会过流,建议通过带电源的HUB接到电脑。

  • RS485 IO共用问题

在进行硬件设计时,尽可能的少造成IO复用。为了提供一个干净的串口用于调试、命令行交互。只好将485和外扩接口共用,并且和USB、摄像头IO口有复用。 复用细节在原理图有标注。 _images/pic122.jpgIO冲突

- RS485 外扩串口 摄像头 USB
RS485 - 冲突 冲突 冲突
外扩串口 冲突 - 冲突 冲突
摄像头 冲突 冲突 - -
USB 冲突 冲突 - -
  • FM性能
  1. 板载的TEA5767毕竟是一个小模组而已,性能无法和收音机相比。
  2. 电脑电源会带来干扰,降低收音机灵敏度,用充电宝供电并且断开与电脑所有连接,效果会提升不少。
  3. 配套的天线只能做功能测试,如果效果不好,可以在天线尾端接一段导线,并且将导线挂到高处。导线并不是越长越好,太长反而会引入其他干扰。按照FM的波长,天线总长65厘米左右,实测接一段60厘米的导线效果不错。
  4. 空旷处(窗户边)肯定比室内效果要好。
  5. 网络、摄像头、USB、SD卡、TFT LCD屏等,在运行时,都会发射干扰,降低收音机灵敏度。如要解决这个问题,需要增加屏蔽措施,考虑毕竟只是一块开发板,决定不做如此复杂,而且经过测试,在收音机信号良好的情况下,干扰影响不大。
  6. 通过WM8978播放收音比TDA2822效果要好(工作室没能力调音,TDA2822单声道,WM8978立体声)。
  • 核心板插拔 本开发板布局比较紧密,很多器件都靠板边。在插拔式如不注意,可能会造成损坏。 建议插拔时: 1 先取下核心板周围的所有附件,例如摄像头、TFT lcd、USB线、外扩IO口上的所有器件。 2 从底部往上顶,慢慢取出核心板。 3 核心板左边的BOOT拨动开关、上边沿的miscro USB接口,禁止受力。 4 拔核心板时,两个排针要慢慢轮流拔出,不要一下子拔出一边,否则排针将变歪

end

嵌入式开发入门

够用的硬件

能用的代码*

实用的教程* 屋脊雀工作室编撰 -201801227

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


大家好,从今天开始,屋脊雀和大家一起学习《基于STM32F407的嵌入式软件开发实践》。 在正式学习之前,我先跟大家解释一下问题: 1 我们学什么? 2 我们怎么学?

我们学什么

教程名字叫《基于STM32F407嵌入式软件开发实践》,这个标题是很有意思的。

  1. 我们学的是软件开发,而且,是嵌入式软件开发。 对应嵌入式的是台式机软件开发,也叫PC软件开发。 在行业中,嵌入式通常分两块:LINUX环境单片机环境。(实际上嵌入式软件开发还有很多分类)。 单片机环境就是我们常说的裸奔或运行RTOS(实时操作系统)。 我们教程学习的是单片机环境的软件开发,而不是LINUX的ARM开发。
  2. 基于F407 学习嵌入式,需要硬件,也就是大家经常买的开发板。 十多年前,通常从51单片机开始学习,然后学习ARM7,再学习在ARM9上的LINUX开发。 在今天,51单片机虽然没有消亡,仍然在大量的家电类产品上使用。 但是经过意法半导体多年经营,”学习单片机”,已经被“学习STM32”替代。 为什么选择F407这一款STM32芯片呢? 首先,407资源丰富,是单片机中的强者。基于407学习之后,使用低端的芯片基本没有问题了,例如F103系列。 第二点,价格便宜,一片二三十块钱。不像F7或H7系列,一片七八十块钱,价格非常昂贵。F7&H7与407相比,多出来的功能,其实并不常用,初学者完全可以暂时不学习。(F7和H7在实际项目上应用不多,太贵,太贵)
  3. 开发实践 这个是我们课程的重点,我们的软件开发,完全从实际项目流程进行,教程包含的都是多年的工作经验,知识点都是干货。 这也是我们的根本目的,不仅仅要学习芯片功能,更多的要学习软件开发的技巧,驱动架构的接口设计,软件如何分层等。

我们怎么学

好的学习方法,事半功倍。 目前网络很多教程,都比较基础,内容也只是对资料的理解,缺少实践干货。 学习完那些课程后,只能算入门。缺少更深层次的理解。我们的教程,会传授很多实际经验给大家。毕竟我们都有10多年的开发实践,做出来的东西也更接近工作内容。

我们的教程有以下特点:

  1. 基于屋脊雀的F407开发板 这个开发板,是我们工作室精心设计,设计这个硬件是为了软件开发,是为了引出软件开发中的知识点。 例如:我们底板跟核心板都有一个SPI FLASH,为什么?为了引出多个设备共用SPI时如何处理,是为了引出SPI接口驱动如何写这个知识点。 不像其他教程一样,堆砌硬件,每个硬件做教程,出几百页教程几百个视频,需要大量时间学习,无法迅速抓住软件开发的本质。 _images/pic011.jpg整体
  2. 从0开始,模拟项目开发流程 每一个教程,都是一步一步前进,后续的教程,基于前面的教程。 例如: 为了验证硬件是能用的,首先需要点一个灯,这是最基础的。只有灯亮了,才能去调试其他功能。 第二个调试的外设,肯定是串口,为什么?因为串口是我们输出调试信息的重要手段。 调试LCD功能,肯定是先调试显示一些点,显示英文字母。 只有等SPI FLASH或者SD卡文件系统调试好后,才会做显示汉字图片。 _images/pic021.jpg教程目录
  3. 重实践 我们更注重实际项目开发需要的知识,更注重实际项目开发的流程,对于实际开发中不需要深入了解的,暂时不做过多解释。 例如USB协议,实际开发中,通常是由原厂FAE做技术支持,我们只需要使用,所以,我们并不会对协议栈做太深入的探讨。 还有,我们也不会对STM32F407一些太底层的细节做探讨。所以,我们是基于库学习,不会直接操作寄存器。 在学习STM32的过程中,穿插介绍软件开发过程的其他知识,例如版本管理,编程规范等。

学习基础

学习单片机需要基础吗? 我觉得需要,无论你是在学校还是自学,在学习嵌入式开发之前,最好有以下知识储备:

  1. 基本的电路知识 电阻电容你要知道,电压电流你要清楚。
  2. 基本的C语言知识 认真学习过学校的课程就可以了,后面我们会推荐一些自学提高的资料。

还有同学会问: 要不要先学习51?不需要,学过更好,不学也没关系。 要不要汇编?不需要,会,当然很好。

当遇到一些深层次的问题,有汇编帮助,更容易处理问题。 汇编思维会帮助你写出更健壮的程序,在写C代码的时候,你会时刻想着,这样写,会不会溢出啊,会不会有问题啊。(本人毕业后写过3年6052汇编)。

学习需要做什么装备? 需要一台电脑,一套开发板,还有就是定下心


前面我们大概介绍了教程,下面我们介绍一些入门的基本概念

单片机

单片机是什么?单片机的完整名称是单芯片微机。 在百度百科定义是这样:

单片机又称单片微控制器,它不是完成某一个逻辑功能的芯片,而是把一个计算机系统集成到一个芯片上。相当于一个微型的计算机

也就是说,单片机就是个微型电脑。 在STM32这么广泛使用之前,比较流行的单片机,是51单片机. 51单片机,并不是一个型号,而是一种单片机的统称,这些单片机使用8031指令集。 具体的有AT的89S52、台湾STC的51单片机等。 除此外,比较流行的单片机还有AVR等。

当年我们学完51后,就会进一步学习“ARM”。 那ARM又是什么? ARM其实不是芯片,也不是单片机。 ARM是一个内核设计公司,最早,他们设计了ARM7内核,ARM9内核。 习惯上我们把用这两种内核的芯片我们都称之为ARM芯片。 ARM公司并不生产芯片,他只是卖内核给IC公司,芯片是IC公司设计生产的。 当年流行的芯片,ARM7有NXP的LPC2132等,ARM9有三星的2410等。

一般来说,ARM7内核的芯片,不跑LINUX;ARM9可以跑LINUX。 ARM7芯片类似现在的STM32,或者说是CONTEX内核的芯片。

STM32,我们都知道是ST生产的芯片,这些芯片用的内核,也是ARM公司设计的,也就是contex内核。 因为STM32推广的太成功了,所以现在没有说学习contex芯片,都是说学习STM32。

还有一个,其实我们把ARM9内核的芯片,排除在单片机之外。。。。。 所以我们俗称的单片机,都是裸奔或跑RTOS的,不跑LINUX的。

单片机的组成

前面说到,单片机就是一个小电脑。 其实更形象的说,单片机类似一个电脑主机,不包含显示器和键盘等外设。 在每个芯片的规格书上,都会有芯片的组成框图。 其中STM32F407的,在文档《STM32F407_数据手册.pdf》第17页: _images/pic031.jpgstm32 block diagram

  • CPU: 在F407芯片的框图上,左上角红框1框住的,就是芯片的内核,也就是ARM设计的contex内核。这一块内核,就相当于CPU。
  • 硬盘 红框2,是407芯片内部的NOR FLASH,相当于硬盘。单片机FLASH与硬盘不同的是,FLASH可以运行程序,硬盘是不能运行程序的,电脑程序都是从硬盘加载到内存中运行。跑LINUX的“单片机”跟电脑一样,程序也是在内存中运行,因此,通常我们不把这些芯片叫做单片机了。
  • 内存 红框3,是单片机内置的SRAM,相当于内存。通常只用SRAM存储程序运行过程需要的变量。 特殊情况下也可以加载程序到RAM运行。

此外:

  1. 从这个框图,我们可以看出一个芯片,有多少功能,有多少外设,串口有几个,SPI有几个等信息。
  2. 有一些单片机其实是没有RAM和flash的,需要外接,例如LPC2220。
  3. 大部分跑LINUX的芯片,是没有内部RAM和FLASH的。例如MT7688。

程序开发流程

待补充!

嵌入式项目开发流程

待补充!


end

软硬件环境准备

够用的硬件

*能用的代码

实用的教程**

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


在项目开发中,当你拿到硬件提供的板子,第一步做什么呢? 如果理所当然的认为可以直接上电调试,你就错了,有时,会直接烧板的。 因为硬件工程师,不一定可信,而且很多公司的硬件工程师,不会对板子做基本验证,焊好就直接丢给软件用。

硬件检测

收到板子板后:

  1. 先检查是否有明显焊接错误,了解板子的基本构成,查看各IC是否焊接正确。

  2. 用万用表检测GNDVCCV33之间是否短路。

    我们第一版底板VCC5V跟VCC3V3之间短路。 分析解决: 未贴片的PCB两者没有短路,PCB应该没有问题。 查看原理图,发现CAN模块预留了两个0欧电阻,用来选择电压。 只能焊接其中一个,样板两个都焊上了。 焊下期中一个电阻即可解决问题。

  3. 用USB线接上底板,上电测试。 USB线插到带USB转串口芯片CP2140的 micro USB座子。 底板红色电源灯亮,没闻到糊味,也没听到响声,更没看到青烟,开局良好。 电脑提示安装驱动失败,USB转串口用的是CP2104芯片,需要安装驱动才能正常使用。 到官网下载驱动: https://www.silabs.com/products/development-tools/software/usb-to-uart-bridge-vcp-drivers _images/113.pngCP210X驱动下载 下载第一个默认的驱动CP210x_Windows_Drivers.zip 安装过程,根据系统选择X86或X64,然后一直点击下一步即可。 _images/29.pngCP210X安装 安装成功后重新插拔USB线,对开发板重新上电后,在我的电脑–>设备管理里面可以看到USB转的串口: Silicon Labs CP210x USB to UART Bridge(COM6)(串口号由电脑自动分配) _images/39.pngCP210X虚拟串口

  4. 检测核心板 用万用表检测GNDVCCV33之间是否短路

我们第一版核心板VCC3V3与地线短路。 分析解决: 用万用表测试空PCB, 不短路。 先分析原理图,看哪里可能会有问题,然后再对比焊接的PCB实物,看哪里焊错料。 查原理图发现,画原理图的时候,SMT32F407的143PIN,PDR-ON,接了两个0R电阻,发正式版的时忘记修改了。 PDR-ON用法参考《STM32F407_数据手册.pdf》,5.1.6 Power supply scheme章节: _images/44.pngPDR-ON管脚说明 阅读2.2.15 16 等章节可以了解这个管脚的作用。 我们将PDR-ON接到VCC3V3, 去掉R1006电阻。
  1. 核心板插上底板,万用表检测VCC5V跟VCC3V3、GND之间,没有短路。
  2. 上电(USB线连CP2104接口),底板跟核心板电源灯都正常点亮。 没闻到糊味,也没听到响声,更没看到青烟,开局良好。

芯片测试

硬件电路经过检测后,可以上电测试了。 如何验证芯片电路正确呢?可以使用STM32官方下载工具,FLASHER-STM32,《en.flasher-stm32.zip》,将一段程序下载到芯片内,如果下载成功,则说明芯片的最小系统是可以使用的。 软件下载路径如下: http://www.st.com/content/st_com/en/products/development-tools/software-development-tools/stm32-software-development-tools/stm32-programmers/flasher-stm32.html 解压后安装。 安装后运行,界面如下,串口选择开发板上的USB转串口。 超时时间Timeout不要选太短,否则会擦除超时而失败,特别是STM32大容量型号,我们用的ZG就是1M flash的。 _images/54.pngST FLASH 运行 如果现在点击next,下载软件卡一段时间后提示下图错误: _images/65.pngST FLASh 下载失败 这是因为我们还没有让CPU进入下载模式。 阅读文档《STM32™ 微控制器系统存储器自举模式》,STM32有3种启动模式。 _images/73.pngBOOT 配置 通常我们是将程序下载到FLASH中。 下载程序时,让芯片从系统存储器启动,运行芯片自带的BOOT。 下载完成后,让芯片从FLASH启动,运行下载的程序。 因此我们需要将BOOT0管脚接到高电平,BOOT1接低电平,芯片进入系统存储器启动。 在屋脊雀开发板上,只需要将BOOT0拨动开关拨动到1,BOOT1管脚电路上已经接到0。 启动模式设置好后,按核心板上的复位键复位芯片。 再点击next,next,就可以检测到芯片型号了,如下图。 _images/812.pngST FLASH 芯片信息 随便选择一个.hex文件,测试是否能烧录。 _images/99.pngST FLASH 选操作 烧录成功,说明硬件核心部分基本正常,可以开始调试软件了。 _images/107.pngST FLASH 成功

编译工具安装

开发STM32我们使用keil MDK 集成开发环境,MDK在持续更新,我们选用一个较新的稳定版本即可。 http://www2.keil.com/mdk5/524/ 我们选择mdk524.exe,双击安装,选择安装路径,一路Next _images/114.pngMDK安装 软件安装结束后,弹出一个库配置界面 _images/122.pngMDK 库配置 提示框说明了库配置信息 _images/133.png库配置信息 此处可暂时不配置。 MDK安装结束后,需要注册,否则只能编译不超出64K的代码。 打开MDK,在File菜单内查看注册情况 _images/141.pngMDK FILE菜单 目前属于未注册状态,右上角有一个CID号 _images/151.pngMDK未注册 可以通过CID获取到一个注册码,如何获取请自行百度

将注册码拷贝到MDK对话框下部New License ID Code框内,点击Add LIC, _images/181.png注册 注册成功 _images/171.png注册成功

比较早的KEIL版本,只要安装好IDE,就可以正常工作了。 但是后来越来越多芯片,KEIL就分成两部分了,一部分是MDK IDE环境,另一部分是不同芯片的依赖包。 依赖包Pack下载 _images/191.png依赖包安装 左边对话框内找到芯片 (第一次安装MDK时,看不到芯片,只有ARM一个选项,请双击对应的内核,下载芯片支持列表) _images/20.png依赖包芯片 右边则是库说明与安装,我们暂时只选择前面3个芯片支持包,其他的扩展包暂时不使用。 _images/211.png装3个包 点击安装后左下角有安装进度条 _images/221.png进度 安装结束后,安装的Pack显示绿色棱形图标。 _images/231.png安装结束

下载pack很慢,可以直接到http://www.keil.com/dd2/pack/#/eula-container,用下载工具下载。 在资料包内我们提供了407需要的包。 下载完成后,直接双击下载包就可以安装了。

CMSIS DAP

前面我们测试芯片,是通过串口下载程序验证芯片是否正常工作。 这个方法有点麻烦,而使用调试器就相对简单。 调试器有很多种,有JLINK、STLINK等。 以前很多人都是使用盗版的JLINK,除了有版权问题,JLINK还经常会丢固件,固件丢失后JLINK就变砖头了。 现在多了一个选择,那就是CMSIS DAP(DAPLink)。 CMSIS DAP是ARM公司开源mbed项目的一个附属品,用于调试Cortex内核的芯片。 这个小工具除了能像JLINK一样下载调试程序外,还自带USB转串口功能。 我们根据开源的CMSIS DAP,优化设计了一版本硬件,推出一款新的DAP,物美价廉 一个小巧的DAP,可以替代JLINK+USB转串口+电源线,接线非常简洁,电脑桌也更少线缆了。 关于DAP,请查阅《CMSIS DAP产品手册》。 资料下载: https://pan.baidu.com/s/1bHUVe6X6tymktUHk_z91cA

结束

到此,开发STM32需要的软硬件基本准备好。


end

开发环境优化与技能准备

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


上一章节我们准备好了软硬件开发环境,为了提高开发效率,我们还需要做一些必要的工具介绍与环境优化。例如:编码工具的选择。

编码工具的选择

MDK,是一个IDE,除了编译程序,当然包含了编码环境。 但是MDK的编码环境相对其他编码环境来说,功能比较弱。 码农常用的编码环境有:sourceinsight、ATOM、Sublime Text、Eclipse。 综合考虑我们选择sourceinsight作为win环境下的代码编辑工具。

Source Insight是一个面向项目开发的程序编辑器和代码浏览器,它拥有内置的对C/C++, C#和Java等程序的分析。能分析源代码并在工作的同时动态维护它自己的符号数据库,并自动显示有用的上下文信息。

话说这个工具十年前发布了3.5版本后就一直停滞更新,搞得我们这些码农就使用了10年3.5版本。就在大家对更新不再抱任何希望的时候,竟然更新了,就在2017。 官网:https://www.sourceinsight.com/ 如果有条件的,请支持正版。 安装说明见资料包内文档《si4.0安装与doxygen宏配置.docx》 大家感受下其界面 pre/3开发环境优化与技能准备/pic/pic01.jpgSI界面

SI常用操作

见资料包内的文档《si使用入门与常见操作.docx》

C语言提高

当年我在学校学C语言的时候,指针都不教,所以如果你是刚毕业或者是在校生,请你将C语言加深学习。

  1. 首先 建议人手备一本《C程序设计语言》 pre/3开发环境优化与技能准备/pic/pic02.jpgC程序设计语言 这本书要放在工作电脑边,实时查阅。
  2. 建议 读《C语言深度解剖(普通下载).pdf》,如果你懂了这本书,面试中遇到的C语言肯定没问题。 可以买一本实体书支持作者
  3. 其他 资料包内的其他文件也很有用,可以一读,如果现在不理解,等教程全部学习完之后再回头看也可以。

版本管理

版本管理,也叫版本控制。 通俗易懂的说法,就是版本备份。 当你完成一个功能的时候,一定要进行备份。 当你想做一个修改的时候,也请备份。 为什么要备份呢?

  1. 防止代码丢失。
  2. 修改代码过程中,经常会出现一些其他问题,这时就可以将修改前后代码进行对比,根据差异点快速定位问题。

那么如何做备份呢?最简单的办法就是拷贝一份,然后用日期或者修改关键字命名。 这个只能算备份,称不上版本管理。 版本管理通常需要版本管理软件。 常用的版本管理软件有git和SVN,这两个软件的最主要区别就是git是分布式,SVN是集中式。 git是linux版本控制的工具,我们推荐大家用git,因为自己单机使用,git最方便,而SVN需要一个服务器(可以自己电脑搭一个)。 git的使用并不难,最简单的基础就几条指令。

具体使用方法见资料中的《git基本使用.pdf》

对比工具

前面版本管理中提到对比,git本身就有对比功能。 不过我还是推荐大家使用beyoncompare。

具体使用方法见资料中的《beyondcompare基本使用.pdf》


end

基于标准库建立工程模板

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


前面几个小节,我们了解了基本的软件开发流程,配置好了开发环境,今天,我们正式开始软件开发。

建立工程

首先在电脑建立目录,保存工程文件,app文件夹用来存放用户代码,prj文件夹用来保存MDK工程文件。

_images/241.png工程目录 打开MDK软件,创建新工程

_images/251.png创建工程

根据实际情况选择选择芯片,我们使用407系列,Z系列,是ZGT。

_images/261.png选择芯片 点击OK后弹出一个组件选择界面,我们暂时不选择组件

_images/271.png不选择组件 点击OK,工程如图,左边就是工程文件目录 _images/281.png工程 打开prj文件夹,内容如下,.uvprojx后缀的文件就是工程文件,以后可以直接双击这个文件打开工程。 _images/291.png工程文件夹 建立好的工程,还需要进行一些个性化配置。 通过Project下的Option菜单配置。 _images/30.png配置工程 弹出配置界面,点击Target标签: 晶振原来是12,改为8,也就是8MHZ外部晶振。 底下的片上FLASH(IROM1)跟RAM(IRAM1)暂时使用默认的,后续需要我们再修改。 左边的Use MicroLIB在某些情况下也会用到,当前暂时不勾。 _images/311.png配置芯片

LIB,库文件。 在源码中,我们经常看到#include <stdio.h>等语句, 这就是包含库文件的意思。 库文件是一些通用的,标准的功能函数,例如一些常见的数学方法。 MicroLIB是一个精简的库,当空间不够时可以选择使用。 但是相对标准的完整库,少很多功能。 在做一些大模块移植,例如二维码解码库时,可能会编译不过。 更多细节后续慢慢了解。

点击Output标签,勾上Create HEX File,也就是编译后生成HEX文件 _images/322.png配置输出格式 点击C/C++标签 配置好代码优化等级Level1(-O1)。 优化等级一定要在工程一开始就设置,如果在开发中途修改优化等级,所有代码必须重新测试,修改优化等级会造成很多莫名奇妙的问题。 底部Include Paths是头文件路径,后续添加文件夹时需要在这里添加对应目录路径,否则编译会出现错误。 _images/332.png配置头文件 配置完Option后,设置Edit下的Configuration _images/341.png设置CONFIG 主要是把右边中间File&Project Handling下的Automatic reload of externally modified files勾上, 意思就是当文件被其他编辑器修改的时候,自动加载最新的。 为啥要勾上这个呢?因为基本上所有的IDE编辑功能都不好用,编写代码我们将使用另外一个软件。 _images/351.pngimage_1bucn6ckgal4a01qetdh21jnik0.png-149.4kB 到此为止,编译工具MDK基本配置完成。

STM32标准库

为了大力推广STM32,ST公司编写了STM32芯片的标准库。 以前,我们使用一个芯片,都是自己控制芯片的寄存器。 非常繁琐,ST推出标准库后,解放了码农,不再需要关心芯片底层寄存器,更加专注于上层驱动和应用开发。 不推荐大家再使用寄存器的方式写程序,如果要学习操作寄存器,直接看ST的库吧。

ST以前推行标准库,现在推HAL库跟LL库,新出的型号已经不再支持标准库。 _images/361.png推荐CUBE 三种库之间的关系请参考: http://blog.csdn.net/zcshoucsdn/article/details/54613202 针对F407,我们依然使用标准库进行开发。后续429、726等高级型号再考虑使用HAL库。 用标准库的一个原因是我们后面可能会转到GCC环境编译

官方库地址: http://www.st.com/content/st_com/zh/products/embedded-software/mcus-embedded-software/stm32-embedded-software/stm32-standard-peripheral-libraries/stsw-stm32065.html 官网下不了,可以从STMCU下: http://www.stmcu.org/document/detail/index/id-213641

下载后得到一个压缩文件en.stm32f4_dsp_stdperiph_lib.zip,解压后目录如下 _images/371.png库解压目录 目录树如下: _images/381.png目录树B

如何使用标准库,请参考官方文档。除了库代码,更重要的是里面的Examples例程。里面的例程可以说是非常全面。在开发过程中可以参考。特别是USB,ETH等外设的例程。 参考官方例程,是软件开发过程的一个重要手段。 _images/391.png例程

工程模板

前面我们已经建立了一个工程,但是到目前为止,这个工程还是一个空工程。 下面我们将ST的库添加到工程中。

  1. 建立文件夹StLib,专门用于保存ST官方提供的库,目前我们使用了标准外设库,后面还会使用USB库网络ETH库_images/40.png工程模板文件夹
  2. 在StLib内建立文件夹core _images/411.png标准库文件夹
  3. 将STM32F4xx_DSP_StdPeriph_Lib_V1.8.0\Libraries\STM32F4xx_StdPeriph_Driver文件夹拷贝到StLib目录下 _images/421.png拷贝库文件B
  4. STM32F4xx_DSP_StdPeriph_Lib_V1.8.0\Libraries\CMSIS\Device\ST\STM32F4xx\Source\Templates\arm内的启动代码startup_stm32f40_41xxx.s拷贝到StLib\core
  5. 从STM32F4xx_DSP_StdPeriph_Lib_V1.8.0\Libraries\CMSIS\Include内拷贝四个头文件到StLib\core
core_cm4.h core_cmFunc.h core_cmInstr.h core_cmSimd.h

StLib\core文件夹如下: _images/431.png启动代码等

  1. 从STM32F4xx_DSP_StdPeriph_Lib_V1.8.0\Libraries\CMSIS\Device\ST\STM32F4xx\Include拷贝两个头文件到app文件夹
stm32f4xx.h system_stm32f4xx.h
  1. 从STM32F4xx_DSP_StdPeriph_Lib_V1.8.0\Project\STM32F4xx_StdPeriph_Templates拷贝以下文件到app文件夹
main.c main.h stm32f4xx_conf.h stm32f4xx_it.c stm32f4xx_it.h system_stm32f4xx.c

拷贝完成后app目录如下 _images/441.pngapp目录

  1. 将文件添加到工程 右键点击Target1进入Manage Project Items _images/45.pngMDK 工程管理

修改文件夹结构,对Groups命名,并且将源文件添加到对应Groups。 (HAL库里面有一个stm32f4xx_fmc.c文件是STM32F42&STM32F43系列才需要,F407不需要添加) _images/46.png文件结构1

_images/47.png文件结构添加库文件

mdk的源码管理比较弱,只能做一层Groups,无法做成树状的文件目录管理。 IAR可以。

启动代码是汇编文件,添加时要注意文件类型要选择ALL files _images/48.png注意添加汇编代码

  1. 添加完成后工程如下 _images/49.png添加完成后工程目录
  2. 设置头文件路径 在option中的C/C++菜单下添加头文件路径。 _images/50.png头文件路径 添加完成后如下 _images/511.png添加头文件路径后
  3. 按F7进行编译 提示错误,没定义芯片型号,根据错误提示在stm32f4xx.h文件夹内定义宏
*** Using Compiler ‘V5.06 update 1 (build 61)’, folder: ‘D:\keil\ARM\ARMCC\Bin’ Build target ‘wujique’ compiling stm32f4xx_it.c… ..\app\stm32f4xx.h(124): error: #35: #error directive: “Please select first the target STM32F4xx device used in your application (in stm32f4xx.h file)” #error “Please select first the target STM32F4xx device used in your application (in stm32f4xx.h file)” ..\app\stm32f4xx_it.c: 0 warnings, 1 error

很多教程将这个宏定义在MDK内,个人不建议这样做,我认为所有定义应该都尽量定义在源文件内,以便后续移植修改。那天我们要讲代码换一个编译器,例如IAR时,可以避免出错

左键双击错误,源码窗口弹到错误的地方120行,可以看到,原因是我们没有定义芯片。

#if !defined(STM32F40_41xxx) && !defined(STM32F427_437xx) && !defined(STM32F429_439xx) && !defined(STM32F401xx) && !defined(STM32F410xx) && \
    !defined(STM32F411xE) && !defined(STM32F412xG) && !defined(STM32F413_423xx) && !defined(STM32F446xx) && !defined(STM32F469_479xx)
 #error "Please select first the target STM32F4xx device used in your application (in stm32f4xx.h file)"
#endif /* STM32F40_41xxx && STM32F427_437xx && STM32F429_439xx && STM32F401xx && STM32F410xx && STM32F411xE && STM32F412xG && STM32F413_23xx && STM32F446xx && STM32F469_479xx */

往上拉,从68行开始看,找到对应型号,打开对应型号前面的宏。我们使用的型号是STM32F407ZG,我们就打开#define STM32F40_41xxx这个宏。

#if !defined(STM32F40_41xxx) && !defined(STM32F427_437xx) && !defined(STM32F429_439xx) && !defined(STM32F401xx) && !defined(STM32F410xx) && \
    !defined(STM32F411xE) && !defined(STM32F412xG) && !defined(STM32F413_423xx) && !defined(STM32F446xx) && !defined(STM32F469_479xx)
  #define STM32F40_41xxx    /*!< STM32F405RG, STM32F405VG, STM32F405ZG, STM32F415RG, STM32F415VG, STM32F415ZG,
                                      STM32F407VG, STM32F407VE, STM32F407ZG, STM32F407ZE, STM32F407IG, STM32F407IE,
                                      STM32F417VG, STM32F417VE, STM32F417ZG, STM32F417ZE, STM32F417IG and STM32F417IE Devices */

因为我们使用了标准外设库,我们还需要打开131行的宏

#if !defined  (USE_STDPERIPH_DRIVER)
/**
 * @brief Comment the line below if you will not use the peripherals drivers.
   In this case, these drivers will not be included and the application code will
   be based on direct access to peripherals registers
   */
  #define USE_STDPERIPH_DRIVER
#endif /* USE_STDPERIPH_DRIVER */

重新编译,DONE,编译成功,标准库模板完成。

compiling stm32f4xx_spi.c… compiling stm32f4xx_tim.c… compiling stm32f4xx_usart.c… compiling stm32f4xx_wwdg.c… linking… Program Size: Code=1188 RO-data=424 RW-data=20 ZI-data=1652FromELF: creating hex file… “.\Objects\wujique.axf” - 0 Error(s), 0 Warning(s). Build Time Elapsed: 00:00:23

晶振配置

  1. 根据硬件,修改外部晶振, 在stm32f4xx.h中,将原来宏定义 HSE_VALUE ((uint32_t)25000000) 根据实际硬件修改,屋脊雀开发板用的是8M晶振,改为 HSE_VALUE ((uint32_t)8000000)
/**
 * @brief In the following line adjust the value of External High Speed oscillator (HSE)
   used in your application

   Tip: To avoid modifying this file each time you need to use different HSE, you
        can define the HSE value in your toolchain compiler preprocessor.
  */           
#if defined(STM32F40_41xxx) || defined(STM32F427_437xx)  || defined(STM32F429_439xx) || defined(STM32F401xx) || \
    defined(STM32F410xx) || defined(STM32F411xE) || defined(STM32F469_479xx)
 #if !defined  (HSE_VALUE)
  #define HSE_VALUE    ((uint32_t)8000000) /*!< Value of the External oscillator in Hz */
 #endif /* HSE_VALUE */
#elif defined (STM32F412xG) || defined(STM32F413_423xx) || defined(STM32F446xx)
 #if !defined  (HSE_VALUE)
  #define HSE_VALUE    ((uint32_t)8000000) /*!< Value of the External oscillator in Hz */
 #endif /* HSE_VALUE */
#endif /* STM32F40_41xxx || STM32F427_437xx || STM32F429_439xx || STM32F401xx || STM32F411xE || STM32F469_479xx */
  1. 修改PLL配置 在system_stm32f4xx.c文件中,371行,原来#define PLL_M 25,修改为#define PLL_M 8,关于时钟的详细配置,后续章节再做介绍。
/************************* PLL Parameters *************************************/
#if defined(STM32F40_41xxx) || defined(STM32F427_437xx) || defined(STM32F429_439xx) || defined(STM32F401xx) || defined(STM32F469_479xx)
 /* PLL_VCO = (HSE_VALUE or HSI_VALUE / PLL_M) * PLL_N */
 #define PLL_M      8
#elif defined(STM32F412xG) || defined(STM32F413_423xx) || defined (STM32F446xx)
 #define PLL_M      8
#elif defined (STM32F410xx) || defined (STM32F411xE)
 #if defined(USE_HSE_BYPASS)
  #define PLL_M      8    
 #else /* !USE_HSE_BYPASS */
  #define PLL_M      16
 #endif /* USE_HSE_BYPASS */
#else
#endif /* STM32F40_41xxx || STM32F427_437xx || STM32F429_439xx || STM32F401xx || STM32F469_479xx */

结束

到此,STM32F407开发环境配置完成,正式进入软件开发阶段。后面的教程,将基于本模板,慢慢添加各个外设的驱动。


end

IO口输出-流水灯-证明程序在运行

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


在第一部分软硬件准备中,我们通过串口将程序下载到芯片,验证硬件基本正常,但并不知道是程序下载进入是否能正常运行呢。 这次我们用一个简单的流水灯,证明程序下载到芯片后可以正常运行。 流水灯,就像PC软件上的HelloWord一样,每一个做单片机的工程师都会玩过。 什么叫流水灯呢?就是多个LED排成一列,做出不同的效果,其中较典型的就是LED轮流点亮,像流水一样左右流动。

IO口

IO是Input/Output的简称,也即是输出输出的意思。 芯片要控制外部器件,或者是从外部获取状态信息,依赖的就是IO。 一个芯片的管脚,除了电源和地,基本上全部都是IO口。 (有些芯片的部分管脚可能只能做输出或输入中的一种) IO最基本的功能是输出高低电平检测外部是高电平还是低电平,也即是逻辑电路功能。 至于其它的串口,SPI等接口,也是高低电平,只不过利用电平实现了比较复杂的时序,也属于逻辑范畴。 除此外,一个管脚还可以做DAC/ADC等其它模拟电路的功能。 高低电平的电压由芯片IO电压决定,通常高电平是芯片的工作电压,低电平则是0V。 (高低电平会有一定识别范围,TTL电平和CMOS电平不一样) 以前的51单片机高电平是5V,目前更多的芯片是3.3V,还有芯片是1.8V。

一个IO的外接电路、外接器件,要考虑两者之间的电压兼容。 例如,一个3.3V工作电压的单片机,外接一个5V的器件,通常,需要使用电平转换电路。

使用一个IO口通常需要这样配置:

  1. 使能这个IO口的时钟。
  2. 根据需要配置为输出或者输入
  3. 设置IO口模式,是否接上下拉电阻。
  4. 作为输入,读IO状态;作为输出,则设置IO口电平。

以上是基本的IO口操作,不同的芯片会有一点差别。

STM32的IO

STM32功能强大,IO配置也较复杂,现在我们先大概看看IO结构,更多功能后续慢慢了解。 要了解STM32的IO口,请查阅《STM32F4xx中文参考手册.pdf》,在第七章,通用IO。 _images/pic01.jpgIO口参考手册 参考手册分4小节。 在7.3节,有下面这个STM32IO口的结构图。 从这个图,我们可以看到一个IO的输入输出通路、各种配置开关的位置。 如果你硬件比较好,还可以看到这个IO的输入输出结构是怎么样的。 IO口具体如何设置,我们在例程中再说明。 _images/pic02.jpgIO口结构

原理图

一个IO口是如何点亮一个LED的呢? 我们首先看LED电路原理图,一个LED跟一个电阻串联,一端接到电源,一端接到IO口。 _images/pic03.jpgLED原理图 _images/pic04.jpgLED原理图

LED

LED:发光二极管,是二极管,就有正负极。当在正负极之间流过一定电流时,就能发光。电流越大,亮度越大。

在资料文件夹内有一个发光二极管的规格书。LED的规格书中有一个很重要的参数。 《黄绿 0603 (33_40mcd)_PDF_C2289_2015-07-23.pdf》 _images/pic05.jpgLED最大绝对标称值 上图是规格书中的一个表,最需要关注的是第一行,顺向电流20mA,前面说电流越大,亮度越大,但是流过LED的电流有绝对标称值限制,我们使用的这颗LED,就不能大于20ma。 为了限制流过LED的电流,我们在LED上串接了一个1K的电阻。 这个电阻就是通常我们所说的限流电阻。 当IO输出高电平3.3V时,没有电流流过,LED不发光。 当IO输出低电平0V时,电流从3.3V电源留向IO口,LED有电流流过,发光,其中电流可以简略计算:(3.3-0.6)/1K= 2.7ma。 上面的电路使用低电平驱动LED,LED的负极接到IO,这种方式叫灌电流驱动。也可以将LED正极接到IO口,电阻接到地,这样就叫拉电流驱动。不过通常我们都是使用灌电流,原因是很多单片机的灌电流能力比拉电流能力强。比如灌电流可以做到20ma,拉电流可能只有5ma

调试过程

ST官方的标准外设库中,stm32f4xx_gpio.h和stm32f4xx_gpio.c就是操作IO的库文件。在头文件中有函数声明,如下

/* Exported macro ------------------------------------------------------------*/
/* Exported functions --------------------------------------------------------*/

/*  Function used to set the GPIO configuration to the default reset state ****/
void GPIO_DeInit(GPIO_TypeDef* GPIOx);

/* Initialization and Configuration functions *********************************/
void GPIO_Init(GPIO_TypeDef* GPIOx, GPIO_InitTypeDef* GPIO_InitStruct);
void GPIO_StructInit(GPIO_InitTypeDef* GPIO_InitStruct);
void GPIO_PinLockConfig(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);

/* GPIO Read and Write functions **********************************************/
uint8_t GPIO_ReadInputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);
uint16_t GPIO_ReadInputData(GPIO_TypeDef* GPIOx);
uint8_t GPIO_ReadOutputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);
uint16_t GPIO_ReadOutputData(GPIO_TypeDef* GPIOx);
void GPIO_SetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);
void GPIO_ResetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);
void GPIO_WriteBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, BitAction BitVal);
void GPIO_Write(GPIO_TypeDef* GPIOx, uint16_t PortVal);
void GPIO_ToggleBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);

/* GPIO Alternate functions configuration function ****************************/
void GPIO_PinAFConfig(GPIO_TypeDef* GPIOx, uint16_t GPIO_PinSource, uint8_t GPIO_AF);

我们就是通过调用这些函数控制IO口。

函数: C语言中的基本组成要素。 一个函数,就是一段代码的集合,通常,可以算做一个小模块。 函数有函数名、输入参数,函数实体,返回值等要素组成。 例如上面的第一个函数GPIO_DeInit, 他的参数是一个GPIO_TypeDef*指针,参数名叫做GPIOx。 函数没有返回值,所有是void。 头文件只是函数声明,函数实体在c文件中。
编码调试

我们在main.c中增加如下代码

/*初始化LED IO口*/
	RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOG, ENABLE);

	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2| GPIO_Pin_3;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT;
	GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz;
	GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP;
	GPIO_Init(GPIOG, &GPIO_InitStructure);     
	/* Infinite loop */
	while (1)
	{
	    GPIO_ResetBits(GPIOG, GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2|GPIO_Pin_3);
	}

第2行,打开了GPIOG的时钟,所有的外设都需要打开时钟才能工作。 第4行,4个IO口或操作,填入GPIO_Pin,意思就是这4个IO口同时配置,使用相同的配置。 大家可以看一下定义,每个IO定义用一个BIT。

bit是指二进制中的一个位。 但是程序中,我们常用的是16进制 例如下面代码第2行0x0002 0x表示是16进制,值是0x0002 转换为二进制就是0000 0000 0000 0010 第2个bit为1.
#define GPIO_Pin_0                 ((uint16_t)0x0001)  /* Pin 0 selected */
#define GPIO_Pin_1                 ((uint16_t)0x0002)  /* Pin 1 selected */
#define GPIO_Pin_2                 ((uint16_t)0x0004)  /* Pin 2 selected */
#define GPIO_Pin_3                 ((uint16_t)0x0008)  /* Pin 3 selected */
#define GPIO_Pin_4                 ((uint16_t)0x0010)  /* Pin 4 selected */
#define GPIO_Pin_5                 ((uint16_t)0x0020)  /* Pin 5 selected */
#define GPIO_Pin_6                 ((uint16_t)0x0040)  /* Pin 6 selected */
#define GPIO_Pin_7                 ((uint16_t)0x0080)  /* Pin 7 selected */
#define GPIO_Pin_8                 ((uint16_t)0x0100)  /* Pin 8 selected */
#define GPIO_Pin_9                 ((uint16_t)0x0200)  /* Pin 9 selected */
#define GPIO_Pin_10                ((uint16_t)0x0400)  /* Pin 10 selected */
#define GPIO_Pin_11                ((uint16_t)0x0800)  /* Pin 11 selected */
#define GPIO_Pin_12                ((uint16_t)0x1000)  /* Pin 12 selected */
#define GPIO_Pin_13                ((uint16_t)0x2000)  /* Pin 13 selected */
#define GPIO_Pin_14                ((uint16_t)0x4000)  /* Pin 14 selected */
#define GPIO_Pin_15                ((uint16_t)0x8000)  /* Pin 15 selected */
#define GPIO_Pin_All               ((uint16_t)0xFFFF)  /* All pins selected */

第5行,配置为输出模式。模式一共有4中,分别是输入、输出、功能、模拟。功能,就是用作外设功能,例如用作串口,SPI等。模拟就是用作模拟功能的IO,例如ADC/DAC等。

typedef enum
{
  GPIO_Mode_IN   = 0x00, /*!< GPIO Input Mode */
  GPIO_Mode_OUT  = 0x01, /*!< GPIO Output Mode */
  GPIO_Mode_AF   = 0x02, /*!< GPIO Alternate function Mode */
  GPIO_Mode_AN   = 0x03  /*!< GPIO Analog Mode */
}GPIOMode_TypeDef;
enum:枚举,可以简单的认为,后续我们定义的某种变量,只会有有限个值,就可以用枚举。 用枚举可以防止值越界,通常,一堆相同属性的宏定义,最好一起组合定义为枚举。 typedef:类型定义。在上面代码中的意思就是,将一个enum定义为GPIOMode_TypeDef类型 后续用GPIOMode_TypeDef定义的变量,就是这个enum类型。

第6行,配置OType,一共有2种选择

typedef enum
{
  GPIO_OType_PP = 0x00,
  GPIO_OType_OD = 0x01
}GPIOOType_TypeDef;

第7行设置IO口速度,有四种速度选择。

/* Add legacy definition */
#define  GPIO_Speed_2MHz    GPIO_Low_Speed    
#define  GPIO_Speed_25MHz   GPIO_Medium_Speed
#define  GPIO_Speed_50MHz   GPIO_Fast_Speed
#define  GPIO_Speed_100MHz  GPIO_High_Speed

第8行,设置上下拉模式,三种选择:

typedef enum
{
  GPIO_PuPd_NOPULL = 0x00,
  GPIO_PuPd_UP     = 0x01,
  GPIO_PuPd_DOWN   = 0x02
}GPIOPuPd_TypeDef;

第9行,将配置配置到GPIOG,配置后,GPIOG_0,GPIOG_1,GPIOG_2,GPIOG_3,就是输出IO,PP模式,带上拉电阻。

IO口的配置细节,可以通过查看结构体的注释了解
typedef struct
{
  uint32_t GPIO_Pin;              /*!< Specifies the GPIO pins to be configured.
                                       This parameter can be any value of @ref GPIO_pins_define */

  GPIOMode_TypeDef GPIO_Mode;     /*!< Specifies the operating mode for the selected pins.
                                       This parameter can be a value of @ref GPIOMode_TypeDef */

  GPIOSpeed_TypeDef GPIO_Speed;   /*!< Specifies the speed for the selected pins.
                                       This parameter can be a value of @ref GPIOSpeed_TypeDef */

  GPIOOType_TypeDef GPIO_OType;   /*!< Specifies the operating output type for the selected pins.
                                       This parameter can be a value of @ref GPIOOType_TypeDef */

  GPIOPuPd_TypeDef GPIO_PuPd;     /*!< Specifies the operating Pull-up/Pull down for the selected pins.
                                       This parameter can be a value of @ref GPIOPuPd_TypeDef */
}GPIO_InitTypeDef;

我们原理图设计的是低电平点亮LED,因此在while(1)中输出低电平。

while(1)是个死循环,不断重复大括号内的代码,直到遇到break才跳出。 为什么要while?因为CPU一直在运行,如果不是循环,就跑飞了。

下载程序后LED不闪烁,也不亮。 万用表测,电压1.8V左右,应该是IO配置不对。根据原理图分析代码,发现代码错误,127行,应该初始化GPIOG,错写成GPIOF,修正,重新编译后下载,LED正常点亮。 亮了之后我们就让他闪。

	while (1)
	{
            GPIO_ResetBits(GPIOG, GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2|GPIO_Pin_3);
            GPIO_SetBits(GPIOG, GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2|GPIO_Pin_3);
	}

ok,代码改好了,不断输出低电平点亮,然后输出高电平熄灭,重复,重复,灯就闪了。 编译后下载进去看看效果。 闪了吗?没闪。跟刚刚有什么差别? 调试硬件的时候要注意现象细节(例如某个灯冒烟等现象),如果没看到差别,把代码改回去对比一下。 差别就是LED变暗了。为什么?我相信很多人都体会过这个段子。 原因是芯片跑得太快了,快到眼睛看不到亮灭的切换。 变暗,是因为我们尽管看不到亮灭,但是实际上LED亮灭是在切换的。相当于50%时间在亮,50%时间灭,粗略来说,类似积分效果,平均算,效果相当于亮一半,结果就是暗了。。。。(其实严格来说相当于50%占空比PWM调光效果)。 那么要看出亮灭,就需要将亮灭的时间延长,延长到你的眼睛可以看到。加上延时1000ms后代码如下。

	while (1)
	{
		GPIO_ResetBits(GPIOG, GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2|GPIO_Pin_3);
		Delay(100);
		GPIO_SetBits(GPIOG, GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2|GPIO_Pin_3);
		Delay(100);
	}

LED闪烁了,但是闪烁的间隔跟预想不一致。程序设计1秒闪烁,实际大概3秒才闪烁。说明Delay函数延时不正确。 原因可能有: 1 SysTick_Config配置错误,但是这个是官方的函数,暂时不怀疑它。 2 时钟不对,要不就是晶振搞错,要不就是软件配置错误。在上一节我们修改了时钟配置,可能没修改对。经检查,在修改晶振频率时,只顾截图,未修改。晶振修改为8M后,一切正常。

对于错误的解决,要顺藤摸瓜。 并且优先考虑相关因素,优先最新的改动。 有形成可信的逻辑链。 例如:闪烁时间不对,不用考虑IO的问题了,因为已经正常亮灭了。 时钟不对,分析的流程应该是:软件配置对了吗?->硬件焊晶振对了吗?->软件用的官方库有bug?

流水灯

请各位自行实现流水灯。 同时请问,LED闪烁中延时1S中,最短延时多少就可以看到闪烁? 可以百度电影帧率,人眼视觉残留

总结

LED闪烁起来的时候,就证明程序能跑了。


end

串口-重要调试手段

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


很多人喜欢用JLINK调试程序,就个人而言,只有写汇编代码时才经常使用仿真。 调试带串口的CPU,主要还是使用串口输出调试信息。 只有遇到一些很难,并且牵涉到汇编、或者寄存器异常的问题,才会使用仿真器看一下。 主要有以下考虑:

1 串口调试信息比较直观。 2 仿真器单步调试会影响程序本来流程。 3 大工程用仿真器效率不高,特别是做LINUX的时候,更加少用仿真器了。 4 程序是你写的,你的脑中该有程序怎么跑的一个构思存在,当实际与设计不一致时,你应该大概知道哪里有问题,而不是完全靠仿真器。

串口

通常我们所说的串口,也叫UART,RS232,TLL。 (SPI也叫串口,例如串口屏,就是屏幕接口是SPI(I2C),而不是UART)

通用异步收发传输器(Universal Asynchronous Receiver/Transmitter),通常称作UART,是一种异步收发传输器 串行接口简称串口,也称串行通信接口或串行通讯接口(通常指COM接口),是采用串行通信方式的扩展接口。 串行接口 (Serial Interface) 是指数据一位一位地顺序传送,其特点是通信线路简单,只要一对传输线就可以实现双向通信(可以直接利用电话线作为传输线),从而大大降低了成本,特别适用于远距离通信,但传送速度较慢。 还要一种增强串口叫USART: USART:(Universal Synchronous/Asynchronous Receiver/Transmitter)通用同步/异步串行接收/发送器USART是一个全双工通用同步/异步串行收发模块 我们常说的是UART,USART需要同步时钟,经常在IC卡控制上使用。

在以前的电脑主板上,会有一个DB9接头,下图中红框内的,这个接口就是串口。现在的主板基本上没有串口接口了,特别是笔记本,完全没有串口。 _images/pic1.jpg串口配置项 这个DB9接口的信号电平,是RS232电平,不能直接接到单片机(STM32)的串口管脚,需要通过一个RS232电平转换器。 很多开发板现在还提供了DB9接头,但是现在电脑都不带这个接口了,屋脊雀的开发板抛弃了这个大家伙。

那没有了DB9接口,如何使用串口呢?可以通过USB转串口。 STM32的串口信号接到CP2104芯片,CP2104通过USB与电脑连接。 下图就是我们的开发板底板的USB转串口电路。 _images/USB2UART.jpgusb转串口

还可以通过CMSIS DAP调试器进行USB转串口。

一个完整的串口有以下信号:

数据: TXD(pin 3):串口数据输出(Transmit Data) RXD(pin 2):串口数据输入(Receive Data) 握手: RTS(pin 7):发送数据请求(Request to Send) CTS(pin 8):清除发送(Clear to Send) DSR(pin 6):数据发送就绪(Data Send Ready) DCD(pin 1):数据载波检测(Data Carrier Detect) DTR(pin 4):数据终端就绪(Data Terminal Ready) 地线: GND(pin 5):地线 其它 RI(pin 9):铃声指示

这个是完整的通信信号,一般我们只使用数据线与地线。 有一些外接高速模块会使用握手信号,也就是我们常说的流控。 在屋脊雀407开发板的外扩串口上,引出流控信号RTS、CTS,如下图: _images/pic2.jpg外扩串口

串口按位(bit)发送数据,数据格式由起始位(start bit)、数据位(data bit)、奇偶校验位(parity bit)和停止位(stop bit)组成。 在串口调试助手上软件可以看到数据格式配置。 通常数据格式是起始位1BIT、8bit数据、1bit停止位、无校验位。 波特率则是串口每秒传输的BIT速率。可以通过波特率计算串口1秒钟可传输多少数据。

例如常用串口数据格式是10BIT一个字节,那么在115200波特率下,每秒最多传输11520个字节数据。每字节数据传输时间仅仅86.80555555555556us,是一个比较快的速度了。

_images/95.png串口配置项

我们下面看看简单的串口通信时序,发送两个字节数据,0x55、0xaa(55aah)。 使用格式:1起始位,8数据位,1停止位。 _images/pic3.jpg串口时序

串口RX和TX的时序是一样的,一个是发送,一个是接收。

原理图

屋脊雀F407开发板使用了一个USB转串口芯片CP2104,芯片在底板上。这个芯片相对于其他USB转串口芯片,更加可靠,推荐大家使用。 _images/USB2UART.jpgusb转串口 底板通过两个IO与核心板相连,这两根IO属于串口3的TX与RX。 _images/pic4.jpg串口原理图

这两个IO同时从DAP调试口引出,因此,DAP的串口和底板CP2104不要同时使用

STM32串口

STM32芯片外设丰富,有多个串口,我们这次使用的两个管脚是PB10和PB11。

通过查看《STM32F407_数据手册.pdf》第56页,管脚功能映射表可知,PB10 AF7功能,是串口3的TX,PB11 是串口3的RX。 TX和RX,都是对自己而言,PB10就是STM32的发送管脚,那么就要连接到CP2104的RX管脚,以后大家画芯片原理图器件也要这样命名

从文档《STM32F407_数据手册.pdf》第17页可以看出,UART3挂在APB总线上,USART3还支持SMCARD和irDA功能。 _images/96.pngSTM32 串口

串口更详细功能要从《STM32F4xx中文参考手册.pdf》找,第26章 _images/pic5.jpg参考手册 串口

关于设备的所有信息都可以从这里找到,意法半导体是非常有良心的,提供了中文版本,就算英文不好阅读起来也没有障碍。 在这里写再多,也比不上官方文档,尽量不添加官方文档的内容。

对于STM32要吐槽的是:为什么接收硬缓冲只有1个?1个?1个? 上面介绍串口波特率时提到,在115200的波特率下,1个字节只需要86us,如果系统处于高负载情况下,串口中断很可能被其他中断卡住而进不了,造成数据丢失。 要防止数据丢失,有以下手段:

  1. 将这个串口优先级提高到最高并且可以抢占别的中断(中断嵌套);
  2. 通过人工努力优化程序运行逻辑,在时间线上,优化各个模块的运行时间,解决中断丢失问题(各中断尽量错开)。
  3. 使用DMA模拟硬件缓冲。 以前ARM7芯片,通常都有8个或者16个硬BUF。

串口驱动设计

在设计串口驱动之前,思考几点:

问题1. 串口驱动给谁用?需要提供什么样的接口? 问题2. 串口驱动要实现什么功能? 问题3. 串口接收发送如何设计?(对于高速运行的程序来说,串口是一个慢设备) 问题4. 中断如何设计?
  • 问题1 1.串口有可能直接给应用程序使用。应用程序通过串口与PC或其他设备通信。 2.可能连接一个设备模块,例如串口WIFI模块。 在APP层看,只知道WIFI,至于WIFI模块使用什么接口跟CPU连接,APP是不知道的。 因此,在这种情况下,使用串口的是WIFI驱动。
  • 问题2 串口就是实现数据收发功能,串口驱动不应该关心收发的内容。你觉得在串口中断中判断回车换行 (0x0d/0x0a)合适吗?
  • 问题3 由于串口是慢设备,最好的方法就是发送接收都通过缓冲区处理,如果还需要进一步提高性能,可以考虑用硬件DMA。
  • 问题4 所有的中断程序都是越短越好,对串口来说,接收到数据,放入缓冲区就立刻退出中断。

我们的串口驱动程序就根据上面几点思考编写。

环形缓冲技术

也就是常说的RingBuf。 在stmcu论坛有一个帖子说的非常详细,请大家移步阅读。 http://www.stmcu.org/module/forum/thread-616132-1-2.html

编码

在文件夹建立一个mcu_dev文件夹,用于保存CPU片上外设的驱动。 新建两个文件mcu_uart.c、mcu_uart.h,并且添加到MDK跟SI工程,头文件搜索路径也添加_images/pic6.jpg串口驱动 代码就不解释了,在源码中有完整注释。 除了驱动代码外,在main.c中增加串口测试函数调用

        mcu_uart_open(3);
	while (1)
	{
		GPIO_ResetBits(GPIOG, GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2|GPIO_Pin_3);
		Delay(100);
		GPIO_SetBits(GPIOG, GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2|GPIO_Pin_3);
		Delay(100);
		mcu_uart_test();
	}

在stm32f4xx_it.c中增加中断处理

/**
  * @brief  This function handles PPP interrupt request.
  * @param  None
  * @retval None
  */
/*void PPP_IRQHandler(void)
{
}*/

/**
  * @}
  */
void USART3_IRQHandler(void)
{
	mcu_uart3_IRQhandler();    
}
中断是什么,中断如何运行,在后面章节有说明。
  • uart_printf实现 代码如下,注意string数组的大小,我们定义了256,也即是一次输出调试信息字符不能超过这个buf的大小。
/*
使用串口输出调试信息
*/
s8 string[256];//调试信息缓冲,输出调试信息一次不可以大于256

#ifdef __GNUC__
/* With GCC/RAISONANCE, small printf (option LD Linker->Libraries->Small printf
     set to 'Yes') calls __io_putchar() */
#define PUTCHAR_PROTOTYPE int __io_putchar(int ch)
#else
#define PUTCHAR_PROTOTYPE int fputc(int ch, FILE *f)
#endif /* __GNUC__ */

PUTCHAR_PROTOTYPE
{
    /* Place your implementation of fputc here */
    /* e.g. write a character to the USART */
    USART_SendData(USART3, (uint8_t) ch);

    /* Loop until the end of transmission */
    while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET);
    return ch;
}

extern int vsprintf(char * s, const char * format, __va_list arg);
/**
 *@brief:      uart_printf
 *@details:    从串口格式化输出调试信息
 *@param[in]   s8 *fmt  
               ...      
 *@param[out]  无
 *@retval:     
 */
void uart_printf(s8 *fmt,...)
{
    s32 length = 0;
    va_list ap;

    s8 *pt;

    va_start(ap,fmt);
    vsprintf((char *)string,(const char *)fmt,ap);
    pt = &string[0];
    while(*pt!='\0')
    {
        length++;
        pt++;
    }

    mcu_uart_write(PC_PORT, (u8*)&string[0], length);  //写串口

    va_end(ap);
}

调试过程

  • 问题1 PC发送数据给开发板,开发板的LED就不闪,也不再发送字符,说明可能死机了。 因为是PC发送数据触发问题,所以应该是程序接收中断未处理好。 经查,在USART3_IRQHandler中没有添加代码,中断根本没处理,因此串口重复进入中断,造成卡死(其实没死机,只是一直中断出不来,无法执行main函数)。修改好后,PC端发送串口数据,开发板不再死机。 _images/102.png解决串口接收数据死机
  • 问题2 继续测试,发送数据,按照测试程序设计,收到A后,将A发送给电脑,但是现在收不到数据。 在串口中断mcu_uart3_IRQhandler中增加调试信息,收到数据则将数据打印。从调试信息看,能收到数据。 _images/103.png确认串口中断能收到数据 在mcu_uart_read函数内增加调试信息,每次调用都输出当前串口缓冲索引,发现收到数据的时候都是0。 这时候发现,在这个函数的前面调用了mcu_uart_open,而在OPEN中每次都会将串口缓冲两个索引清0。 _images/104.png串口索引每次清零 把open函数去掉 _images/105.png串口调试完成 问题解决,调试串口收发已经正常。

调试信息使用

  • 调试信息是需要管理的

前面我们实现了用串口的printf。现在让我们来定义LOG功能。 考虑问题:

1.LOG除了用串口输出,还可能用USB输出,LCD显示等。 2.LOG要分等级,在发布程序时,要把DEBUG LOG屏蔽。

我们在app文件夹定义一个wjq_log.c和wjq_log.h。 把uart_printf搬到这个源文件内(这个函数后面基本不用了)。 复制uart_printf函数并改造,增加LOG等级判断。 LOG等级定义如下:

typedef enum
{
	LOG_DISABLE = 0,
	LOG_ERR,	//错误
	LOG_FUN,	//功能(用LOG输出算一个功能)
	LOG_INFO,	//信息,例如设备初始化等信息
	LOG_DEBUG,	//调试,正式程序通常屏蔽
}LOG_L;
  • 常用LOG说明

输出调试信息跟变量值 %d,%02x,%08x等是常用输出格式

wjq_log(LOG_FUN, "mcu_dev_uart_read :%d\r\n", len);
%d,打印十进制格式。 %02x,打印十六进制格式,2位 %08x,打印十六进制格式,8位,打印地址时用。 %s,打印字符串

输出当前函数名,文件名,代码行号,当前时间

wjq_log(LOG_FUN, "%s,%s,%d,%s\r\n", __FUNCTION__,__FILE__,__LINE__,__DATE__);

LOG要短 在中断中添加调试信息尽量短,串口是一个慢设备,输出太长的调试信息执行时间较长,会影响程序运行,特别是一些时间敏感的函数。 —可以考虑将LOG做队列输出— 时刻记得LOG的影响 在某些临界处,添加太多调试信息会造成程序流程跟预想不一致。因此需要对各个模块调试信息进行管理,不能一下子打开所有模块的调试信息。

PC串口工具

常用工具有sscom32、Xshell、Docklight。特点如下:

sscom32:简单易用,单片机常用。前面调试串口就是用sscom32。 Xshell:是一个强大的安全终端模拟软件,它支持SSH1, SSH2, 以及Microsoft Windows 平台的TELNET 协议。可以远程登录电脑等,玩Linux的应该常用。同时支持串口,Linux开发时登录命令行控制台就常用。有个人免费版本Docklight:支持帧格式组织解析,对于做串口协议通信很有用。

如果仅仅做调试信息LOG输出,建议使用Xshell,后面我们移植UBOOT的命令行控制台到STM32上,使用Xshell进行交互调试。

思考

1 目前只实现了一个串口的驱动,STM32一般会有多个串口,串口驱动要如何修改?每个串口都写一份代码会很累的。 2 现在程序都是单线程在跑,等后面添加了FREERTOS之后,驱动应该设计?需要考虑什么问题?


end

包罗万象的小程序

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190315

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


我们通过IO和串口的软件开发,已经体验了嵌入式软件开发。 不知道大家有没有疑惑,为什么软件能控制硬件?反正当年我学习51的时候,有这个疑惑。 今天我们就暂停软件开发,分析单片机到底是如何软硬件结合的。 并通过一个基本的程序,分析单片机程序的编译,运行。

软硬件结合

初学者,通常有一个困惑,就是为什么软件能控制硬件? 就像当年的51,为什么只要写P1=0X55,就可以在IO口输出高低电平? 要理清这个问题,先要认识一个概念:地址空间

寻址空间

什么是地址空间呢?所谓的地址空间,就是PC指针的寻址范围,因此也叫寻址空间。

大家应该都知道,我们的电脑有32位系统和64位系统之分,为什么呢?因为32位系统,PC指针就是一个32位的二进制数,也就是0xffffffff,范围只有4G寻址空间。 现在内存越来越大,4G根本不够,所以需要扩展,为了能访问超出4G范围的内存,就有了64位系统。 STM32是多少位的?是32位的,因此PC指针也是32位,寻址空间也就是4G。

我们来看看STM32的寻址空间是怎么样的。 在数据手册《STM32F407_数据手册.pdf》中有一个图,这个图,就是STM32的寻址空间分配。 所有的芯片,都会有这个图,名字基本上都是叫Memory map,用一个新芯片,就先看这个图。 _images/memory_map.jpgstm SRAM

  • 最左边,8个block,每个block 512M,总共就是4G,也就是芯片的寻址空间。
  • block 0 里面有一段叫做FLASH,也就是内部FLASH,我们的程序就是下载到这个地方,起始地址是0X800 0000,大家注意,这个只有1M空间。 现在STM32已经有2M flash的芯片了,超出1M的FLASH放在哪里呢?请自行查看对应的芯片手册。
  • 3 在block 1 内,有两段SRAM,总共128K,这个空间,也就是我们前面说的内存,存放程序使用的变量。如果需要,也可以把程序放到SRAM中运行。 407不是有196K吗?
  • 其实407有196K内存,但是有64k并不是普通的SRAM,而是放在block 0 内的CCM。这两段区域不连续,而且,CCM只能内核使用,外设不能使用,例如DMA就不能用CCM内存,否则就死机。
  • block 2,是Peripherals,也就是外设空间。我们看右边,主要就是APB1/APB2、AHB1/AHB2,什么东西呢?回头再说。
  • block 3、block4、block5,是FSMC的空间,FSMC可以外扩SRAM,NAND FALSH,LCD等外设。

好的,我们分析了寻址空间,我们回过头看看,软件是如何控制硬件的。 在IO口输出的例程中,我们配置IO口是调用库函数,我们看看库函数是怎么做的。 例如:

GPIO_SetBits(GPIOG, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2| GPIO_Pin_3);

这个函数其实就是对一个变量赋值,对GPIOx这个结构体的成员BSRRL赋值。

void GPIO_SetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
  /* Check the parameters */
  assert_param(IS_GPIO_ALL_PERIPH(GPIOx));
  assert_param(IS_GPIO_PIN(GPIO_Pin));

  GPIOx->BSRRL = GPIO_Pin;
}
assert_param:这个是断言,用于判断输入参数是否符合要求 GPIOx是一个输入参数,是一个GPIO_TypeDef结构体指针,所以,要用->获取其成员

GPIOx是我们传入的参数GPIOG,具体是啥?在stm32f4xx.h中有定义。

#define GPIOG               ((GPIO_TypeDef *) GPIOG_BASE)

GPIOG_BASE同样在文件中有定义,如下:

#define GPIOG_BASE            (AHB1PERIPH_BASE + 0x1800)

AHB1PERIPH_BASE,AHB1地址,有点眉目了吧?在进一步看看

/*!< Peripheral memory map */
#define APB1PERIPH_BASE       PERIPH_BASE
#define APB2PERIPH_BASE       (PERIPH_BASE + 0x00010000)
#define AHB1PERIPH_BASE       (PERIPH_BASE + 0x00020000)
#define AHB2PERIPH_BASE       (PERIPH_BASE + 0x10000000)

再找找PERIPH_BASE的定义

#define PERIPH_BASE           ((uint32_t)0x40000000)     

到这里,我们可以看出,操作IO口G,其实就是操作0X40000000+0X1800这个地址上的一个结构体里面的成员。 说白了,就是操作了这个地方的寄存器。 实质跟我们操作普通变量一样,就像下面的两句代码,区别就是变量i是SRAM空间地址,0X40000000+0X1800是外设空间地址。

u32 i;
i = 0x55aa55aa;

这个外设空间地址的寄存器是IO口硬件的一部分。 如下图,左边的输出数据寄存器,就是我们操作的寄存器(内存、变量),它的地址就是0X40000000+0X1800+0x14. _images/pic11.jpgstm SRAM

控制其他外设也类似,就是将数据写到外设寄存器上,跟操作内存一样,就可控制外设了。

寄存器,其实应该是内存的统称,外设寄存器应该叫做特殊寄存器。 慢慢的,所有人都把外设的叫做寄存器,其他的统称内存或RAM。 寄存器为什么能控制硬件外设呢? 因为,初略的说,一个寄存器的一个BIT,就是一个开关, 开就是1,关就是0。通过这个电子开关去控制电路,从而控制外设硬件。

纯软件-包罗万象的小程序

我们已经完成了串口和IO口的控制,但是我们仅仅知道了怎么用,对其他一无所知。 程序怎么跑的?代码到底放在那里?内存又是怎么保存的? 下面,我们通过一个简单的程序,学习嵌入式软件的基本要素。

分析启动代码

  • 函数从哪里开始运行?

每个芯片都有复位功能,复位后,芯片的PC指针(一个寄存器,指示程序运行位置,对于多级流水线的芯片,PC可能跟真正执行的指令位置不一致,这里暂且认为一致)会复位到固定值,一般是0x00000000,在STM32中,复位到0X08000004。因此复位后运行的第一条代码就是0X08000004。 前面我们不是拷贝了一个启动代码文件到工程吗? startup_stm32f40_41xxx.s,这个汇编文件为什么叫启动代码?因为里面的汇编程序,就是复位之后执行的程序。 在文件中,有一段数据表,称为中断向量,里面保存了各个中断的执行地址复位,也是一个中断。 芯片复位时,芯片从中断表中将Reset_Handler这个值(函数指针)加载到PC指针,芯片就会执行Reset_Handler函数了。(一个函数入口就是一个指针)

; Vector Table Mapped to Address 0 at Reset
                AREA    RESET, DATA, READONLY
                EXPORT  __Vectors
                EXPORT  __Vectors_End
                EXPORT  __Vectors_Size

__Vectors       DCD     __initial_sp               ; Top of Stack
                DCD     Reset_Handler              ; Reset Handler
                DCD     NMI_Handler                ; NMI Handler
                DCD     HardFault_Handler          ; Hard Fault Handler
                DCD     MemManage_Handler          ; MPU Fault Handler
                DCD     BusFault_Handler           ; Bus Fault Handler
                DCD     UsageFault_Handler         ; Usage Fault Handler

Reset_Handler函数,先执行SystemInit函数,这个函数在标准库内,主要是初始芯片时钟。然后跳到__main执行,__main函数是什么函数? 是我们在main.c中定义的main函数吗?后面我们再说这个问题。

; Reset handler
Reset_Handler    PROC
                 EXPORT  Reset_Handler             [WEAK]
        IMPORT  SystemInit
        IMPORT  __main

                 LDR     R0, =SystemInit
                 BLX     R0
                 LDR     R0, =__main
                 BX      R0
                 ENDP

芯片是怎么知道开始就执行启动代码的呢?或者说,我们如何把这个启动代码放到复位的位置? 这就牵涉到一个一般情况下不关注的文件wujique.sct,这个文件在wujique\prj\Objects目录下,通常把这个文件叫做分散加载文件,编译工具在链接时,根据这个文件放置各个代码段和变量。

在MDK软件Options菜单Linker下有关于这个菜单的设置。 _images/66.pngMDK linker设置 把Use Memory Layout from Target Dialog前面的勾去掉,之前不可设置的框都可以设置了。点击Edit进行编辑。 _images/67.png用户自定义分散加载 在代码编辑框出现了分散加载文件内容,当前文件只有基本的内容。

其实这个文件功能很强大,通过修改这个文件可以配置程序的很多功能,例如: 1 指定FLASH跟RAM的大小于起始位置,当我们把程序分成BOOT、CORE、APP,甚至进行驱动分离的时候,就可以用上了。 2 指定函数与变量的位置,例如把函数加载到RAM中运行。

_images/68.png分散加载文件 从这个基本的分散加载文件我们可以看出:

  • 第6行 ER_IROM1 0x08000000 0x00080000 定义了ER_IROM1,也就是我们说的内部FLASH,从0x08000000开始,大小0x00080000。
  • 第7行 .o (RESET, +First) 从0x08000000开始,先放置一个.o文件, 并且用(RESET, +First)指定RESET块优先放置,RESET块是什么? 请查看启动代码,中断向量就是一个AREA,名字叫RESET,属于READONLY。 这样编译后,RESET块将放在0x08000000位置,也就是说,中断向量就放在这个地方。 DCD是分配空间,4字节,第一个就是__initial_sp,第二个就是Reset_Handler函数指针。 也就是说,最后编译后的程序,将Reset_Handler这个函数的指针(地址),放在0x800000+4的地方。 所以芯片在复位的时候,就能找到复位函数Reset_Handler。
  • 第8行 *(InRoot$$Sections) 什么鬼?GOOGLE啊!回头再说。
  • 第9行 .ANY (+RO) 意思就是其他的所有RO,顺序往后放。就是说,其他代码,跟着启动代码后面。
  • 第11行 RW_IRAM1 0x20000000 0x00020000 定义了RAM大小。
  • 第12行 .ANY (+RW +ZI) 所有的RW ZI,全部放到RAM里面。RW,ZI,也就是变量,这一行指定了变量保存到什么地址。

分析用户代码

到此,基本启动过程已经分析完。下一步开始分析用户代码,就从main函数开始。 1 程序跳转到main函数后: RCC_GetClocksFreq获取RCC时钟频率; SysTick_Config配置SysTick,在这里打开了SysTick中断,10毫秒一次。 Delay(5);延时50毫秒。

int main(void)
{
  GPIO_InitTypeDef GPIO_InitStructure;

 /*!< At this stage the microcontroller clock setting is already configured,
       this is done through SystemInit() function which is called from startup
       files before to branch to application main.
       To reconfigure the default setting of SystemInit() function,
       refer to system_stm32f4xx.c file */

  /* SysTick end of count event each 10ms */
  RCC_GetClocksFreq(&RCC_Clocks);
  SysTick_Config(RCC_Clocks.HCLK_Frequency / 100);

  /* Add your application code here */
  /* Insert 50 ms delay */
  Delay(5);

2 初始化IO就不说了,进入while(1),也就是一个死循环,嵌入式程序,都是一个死循环,否则就跑飞了。

/*初始化LED IO口*/
RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOG, ENABLE);

GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2| GPIO_Pin_3;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT;

GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz;
GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP;
GPIO_Init(GPIOG, &GPIO_InitStructure);    

/* Infinite loop */
mcu_uart_open(3);
while (1)
{
  GPIO_ResetBits(GPIOG, GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2|GPIO_Pin_3);
  Delay(100);
  GPIO_SetBits(GPIOG, GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2|GPIO_Pin_3);
  Delay(100);
  mcu_uart_test();

  TestFun(TestTmp2);
}

3 在while(1)中调用TestFun函数,这个函数使用两个全局变量,两个局部变量。

/* Private functions ---------------------------------------------------------*/
u32 TestTmp1 = 5;//全局变量,初始化为5
u32 TestTmp2;//全局变量,未初始化

const u32 TestTmp3[10] = {6,7,8,9,10,11,12,13,12,13};

u8 TestFun(u32 x)//函数,带一个参数,并返回一个u8值
{
	u8 test_tmp1 = 4;//局部变量,初始化
	u8 test_tmp2;//局部变量,未初始化

	static u8 test_tmp3 = 0;//静态局部变量

	test_tmp3++;

	test_tmp2 = x;

	if(test_tmp2> TestTmp1)
		test_tmp1 = 10;
	else
		test_tmp1 = 5;

	TestTmp2 +=TestTmp3[test_tmp1];

	return test_tmp1;
}

然后程序就一直在main函数的while循环里面执行。 中断呢?对,还有中断。中断中断,就是中断正常的程序执行流程。 我们查看Delay函数,uwTimingDelay不等于0就死等?谁会将uwTimingDelay改为0?

/**
  * @brief  Inserts a delay time.
  * @param  nTime: specifies the delay time length, in milliseconds.
  * @retval None
  */
void Delay(__IO uint32_t nTime)
{
  uwTimingDelay = nTime;

  while(uwTimingDelay != 0);
}

搜索uwTimingDelay变量,函数TimingDelay_Decrement会将变量一直减到0。

/**
  * @brief  Decrements the TimingDelay variable.
  * @param  None
  * @retval None
  */
void TimingDelay_Decrement(void)
{
  if (uwTimingDelay != 0x00)
  {
    uwTimingDelay--;
  }
}

这个函数在哪里执行?经查找,在SysTick_Handler函数中运行。谁用这个函数?

/**
  * @brief  This function handles SysTick Handler.
  * @param  None
  * @retval None
  */
void SysTick_Handler(void)
{
  TimingDelay_Decrement();
}

经查找,在中断向量表中有这个函数,也即是说这个函数指针保存在中断向量表内。 当发生中断时,就会执行这个函数。 当然,在进出中断会有保存和恢复现场的操作。这个主要涉及到汇编,暂时不进行分析了。有兴趣自己研究研究。通常,现在我们开发程序不用关心上下文切换了。

__Vectors       DCD     __initial_sp               ; Top of Stack
                DCD     Reset_Handler              ; Reset Handler
                DCD     NMI_Handler                ; NMI Handler
                DCD     HardFault_Handler          ; Hard Fault Handler
                DCD     MemManage_Handler          ; MPU Fault Handler
                DCD     BusFault_Handler           ; Bus Fault Handler
                DCD     UsageFault_Handler         ; Usage Fault Handler
                DCD     0                          ; Reserved
                DCD     0                          ; Reserved
                DCD     0                          ; Reserved
                DCD     0                          ; Reserved
                DCD     SVC_Handler                ; SVCall Handler
                DCD     DebugMon_Handler           ; Debug Monitor Handler
                DCD     0                          ; Reserved
                DCD     PendSV_Handler             ; PendSV Handler
                DCD     SysTick_Handler            ; SysTick Handler

余下问题

1 __main函数是什么函数?是我们在main.c中定义的main函数吗? 2 分散加载文件中*(InRoot$$Sections)是什么? 3 ZI段,也就是初始化为0的数据段,什么时候初始化?谁初始化?

为什么这几个问题前面留着不说?因为这是同一个问题。顺藤摸瓜!

通过MAP文件了解代码构成

编译结果

程序编译后,在下方的Build Output窗口会输出信息:

*** Using Compiler 'V5.06 update 5 (build 528)', folder: 'C:\Keil_v5\ARM\ARMCC\Bin'
Build target 'wujique'
compiling stm32f4xx_it.c...
...
assembling startup_stm32f40_41xxx.s...
compiling misc.c...
...
compiling mcu_uart.c...
linking...
Program Size: Code=9038 RO-data=990 RW-data=40 ZI-data=6000  
FromELF: creating hex file...
".\Objects\wujique.axf" - 0 Error(s), 0 Warning(s).
Build Time Elapsed:  00:00:32
  • 编译目标是wujique
  • C文件compiling,汇编文件assembling,这个过程叫编译
  • 编译结束后,就进行link,链接。
  • 最后得到一个编译结果,9038字节code,RO 990,RW 40,ZI 6000。 CODE,是代码,很好理解,那RO、RW、ZI都是什么?
  • FromELF,创建hex文件,FromELF是一个好工具,需要自己添加到option中才能用

map文件配置

更多编译具体信息在map文件中,在MDK Options中我们可以看到,所有信息都放在\Listings\wujique.map

默认很多编译信息可能没钩,钩上所有信息会增加编译时间。 _images/79.pngOptions中的MAP设置

map文件

打开map文件,好乱?习惯就好。我们抓重点就行了。 _images/80.png混乱的MAP

  • map 总信息

从最后看起,看到没? 最后的这一段map内容,说明了整个程序的基本概况。 有多少RO?RO到底是什么? 有多少RW?RW又是什么? ROM为什么不包括ZI Data?为什么包含RW Data?

_images/81.pngMAP最后

  • Image component sizes

往上,看看Image component sizes,这个就比刚刚的总体统计更细了。 这部分内容,说明了每个源文件的概况 首先,是我们自己的源码,这个程序我们的代码不多,只有main.o,wujique_log.o, 和其他一些STM32的库文件。 _images/82.pngImage component sizes

第2部分是库里面的文件,看到没?里面有一个__main.o。 __main函数是不是我们写的main函数?明显不是,我们的main函数是放在main.o文件。 这么小的一个工程,用了这么多库,你以前关注过吗? 估计没有,除非你曾经将一个原本在1M flash上的程序压缩到能在512K上运行。 _images/83.pngImage component 库

第3部分也是库,暂时没去分析这两个是什么东西。 _images/84.pngImage component 第三部分

库文件是什么? 库文件就是别人已经别写好的代码库。 在代码中,我们经常会包含一些头文件,例如:

#include <stdarg.h>
#include <stdlib.h>
#include <string.h>   

这些就是库的头文件。这些头文件保存在MDK开发工具的安装目录下。 我们经常用的库函数有: memcpy、memcmp、strcmp等。 只要代码中包含了这些函数,就会链接库文件。

  • 文件map

再往上,就是文件MAP了,也就时每个文件中的代码段(函数)跟变量在ROM跟RAM中的位置。 首先是ROM 在0x08000000确实放的是startup_stm32f40_41xxx.o中的RESET _images/85.pngmemory map rom 每个文件有有多行,例如串口,4个函数。 _images/pic5.pngmemory map rom

然后是RAM的,main.o中的变量,放在0x20000000,总共有0x0000000c,类型是Data、RW。 串口有两种变量,data和bss,什么是bss?这两个名称,是section name,也就是段的意思。 看前面type和Attr, RW Data,放在.data段;RW Zero放在.bss段,RW Zero,其实就是ZI。 到底哪些变量是RW,哪些是ZI? _images/86.pngmemory map ram

  • Image Symbol Table

再往上就是Image Symbol Table,就更进一步到每个函数或者变量的信息了_images/87.pngMAP 变量

例如,全局变量TestTmp1,是Data,4字节,分配的位置是0x20000004。 _images/88.pngMAP TestTmp1 TestTmp3数组放在哪里?放在0X080024E0这个地方,这可是代码区额。 因为我们用const修饰了这个全局变量数组,告诉编译器,这个数组是不可以改变的,编译器就将这个数组保存到代码中了。 程序中我们经常会使用一些大数组数据,例如字符点阵,通常有几K几十K大,不可能也没必要放到RAM区,整个程序运行过程这些数据都不改变,因此通过const修饰,将其存放到代码区。

const的用处比较多,可以修饰变量,也可以修饰函数。 更多用法自行学习

_images/pic4.pngMAP TestTmp1

那局部变量存放在哪里呢?我们找到了test_tmp3, _images/pic3.pngMAP TestTmp1 没找到test_tmp1/test_tmp2,为什么呢? 在定义时,test_tmp3增加了static定义,意思就是静态局部变量,功能上,相当于全局变量,定义在函数内,限制了这个全局变量只能在这个函数内使用。 哪test_tmp1、test_tmp2放在哪里呢? 局部变量,在编译链接时,并没有分配空间,只有在运行时,才从栈分配空间。

u8 TestFun(u32 x)//函数,带一个参数,并返回一个u8值
{
	u8 test_tmp1 = 4;//局部变量,初始化
	u8 test_tmp2;//局部变量,未初始化

	static u8 test_tmp3 = 0;//静态局部变量

上一部分,我们留了一个问题,哪些变量是RW,哪些是ZI? 我们看看串口变量的情况,UartBuf3放在bss段,其他变量放在.data段。 为什么数组就放在bss?bss是英文Block Started by Symbol的简称。 _images/pic2.png串口变量

到这里,我们可解释下面几个概念了:

Code就是代码,函数。 RO Data,就是只读变量,例如用const修饰的数组。 RW Data,就是读写变量,例如全局变量跟static修饰的局部变量。 ZI Data,就是系统自动初始化为0的读写变量,大部分是数组,放在bss段。 RO Size等于代码加只读变量。 RW Size等于读写变量(包括自动初始化为0的),这个也就是RAM的大小。 ROM Size,也就是我们编译之后的目标文件大小,也就是FLASH的大小。但是?为什么会包含RW Data呢? 因为所有全局变量都需要一个初始化的值(就算没有真正初始化,系统也会分配一个初始化空间),例如我们定义一个变量u8 i = 8;这样的全局变量,8,这个值,就需要保存在FALSH区。 _images/81.pngMAP最后

我们看看函数的情况,前面我们不是有一个问题吗?__main和main是一个函数吗? 查找main后发现,main是main,放在0x08000579 _images/89.pngmap main

__main是__main,放在0x08000189 _images/90.pngmap __main

__main到main之间发生了什么? 还记得分散加载文件中的这句吗?

*(InRoot$$Sections)

__main就在这个段内。下图是__main的地址,在0x08000189。 __Vectors就是中断向量,放在最开始。 _images/91.pngmap vectors

在分散加载文件中,紧跟RESET的就是*(InRoot$$Sections)。 _images/92.png分散加载文件

而且,RESET段正好大小0x00000188。 _images/93.pngRESET 大小

巧合?参考PPT文档《ARM嵌入式软件开发.ppt》,或自行GOOGLE。 _images/94.pngARM应用程序启动

这一段代码都完成什么功能呢? 主要完成ZI代码的初始化,也就是将一部分RAM初始化为0。 其他环境初始化。。。。 通常,我们不用管这一部分。

  • 其他 再往上,就是其他信息了,例如优化了哪些东西,移除了哪些函数。

最后

到这里,一个程序,是怎么组成的,程序是如何运行的,基本有一个总体印象了。 不过,对于中断,后面还会进行详细说明。


end

IO输入-按键检测

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


前面我们已经学习了IO口输出功能,现在我们就学习IO的输入功能。

IO口输入

所谓的IO口输入,其实非常简单,就是可以从一个IO口读取连接在IO口上的电路的电平,高电平,读到1,低电平就读到0。 下面是IO口结构图,前面我们已经看过,如果配置为输入时,只要读取输入数据寄存器就可以获取IO口电平了。 _images/pic12.jpgIO口结构 在IO口输出章节,我们详细说了IO口配置,其中下面两个配置只是针对输出的,输入无效:

/**
  * @brief  GPIO Output type enumeration
  */  
typedef enum
{
  GPIO_OType_PP = 0x00,
  GPIO_OType_OD = 0x01
}GPIOOType_TypeDef;
#define IS_GPIO_OTYPE(OTYPE) (((OTYPE) == GPIO_OType_PP) || ((OTYPE) == GPIO_OType_OD))

/**
  * @brief  GPIO Output Maximum frequency enumeration
  */  
typedef enum
{
  GPIO_Low_Speed     = 0x00, /*!< Low speed    */
  GPIO_Medium_Speed  = 0x01, /*!< Medium speed */
  GPIO_Fast_Speed    = 0x02, /*!< Fast speed   */
  GPIO_High_Speed    = 0x03  /*!< High speed   */
}GPIOSpeed_TypeDef;

按键输入

按键输入是人机交互的一个重要输入手段,最常见的按键就是电脑键盘。 按键输入是IO口输入应用的最简单例子。然而深究起来,按键扫描并没那么简单。

原理图

先看按键原理图 _images/pic31.jpg单个按键原理图 按键的原理比较简单,一个按键两个脚,一个脚接都IO口,一个脚接到地。 当按键按下,两端短路,IO口就接到地,就是低电平。 那没按下时,IO口啥都没接,就是高电平。 为啥是高电平呢?因为IO口在芯片内部可以配置连接一个内部上拉电阻。 如果使用了没有内部上拉电阻的IO,就只能在外部接一个电阻将IO口上拉到高电平。

上拉电阻不能太小,当按键按下时,VCC经过电阻接到地,电流就等于VCC/电阻,太小,漏电流会很大。 普通按键按下的时间比较短,如果是一些状态开关(原理和按键类似),接地可能是一个常态,长时间漏电, 费电。 上拉电阻也不能太大,如果你整一个10M的电阻,很容易耦合干扰,IO口电平乱跳,造成假按键动作。 一般,不是低功耗的设备,4.7K挺好, 低功耗设备,1M差不多。

按键扫描方式

首先,记住流水灯章节提到的问题:芯片跑得很快。从一个IO口读取输入电平,只是一瞬间的事。 第二,手可能会抖动。 第三,机械按键可能会抖动。

用示波器抓按键按下的波形,波形可能如下图,可见状态变化时波形有抖动。 _images/pic21.jpg按键抖动

大概的按键流程如下: _images/pic41.jpg单键扫描流程 这个流程只是判断按下的流程,按键松开同样要做抖动处理。

编码调试

按键属于芯片外设备。 我们在工程目录增加一个board_dev文件夹,在文件夹内添加两个文件: dev_key.c、dev_key.h。 代码具体见源文件。记得添加到MDK工程,头文件路径也要添加。 我们首先调试好IO口输入,如下面代码,通过两个调试信息从串口查看IO口电平。

s32 dev_key_scan(void)
{
	uint8_t sta;

	sta = GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0);
	if(sta == Bit_SET)
	{
		KEY_DEBUG(LOG_DEBUG, "key up\r\n");
	}
	else
	{
		KEY_DEBUG(LOG_DEBUG, "key down\r\n");
	}
}

将这个函数放到main.c的while循环中运行,在while(1)之前,要调用函数dev_key_init,对按键IO进行初始化,初始化为输入IO口。

	/* Infinite loop */
	mcu_uart_open(3);
	dev_key_init();
	while (1)
	{
		dev_key_scan();
		Delay(10);

	}

没有按下按键时,串口输出“key up”,按住按键时输出“key down”。由于程序一直运行,LOG会连续不断输出IO口状态。 IO口调试好之后就处理防抖动。 添加防抖处理的扫描函数如下,注意定义变量的时候,使用了volatile跟static关键字。

s32 dev_key_scan(void)
{
	volatile uint8_t sta;//局部变量,放在栈空间,进入函数时使用,退出后释放。
	static u8 key_sta = 1;//通过static 指定key_sta,函数退出不会释放

	sta = GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0);
	if((sta == Bit_SET) && (key_sta == 0))
	{
		Delay(5);
		sta = GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0);
		if(sta == Bit_SET)
		{
			key_sta = 1;
			KEY_DEBUG(LOG_DEBUG, "key up\r\n");
			return 1;
		}
	}
	else if((sta == Bit_RESET) && (key_sta == 1))
	{
		Delay(5);
		sta = GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0);
		if(sta == Bit_RESET)
		{
			key_sta = 0;
			KEY_DEBUG(LOG_DEBUG, "key down\r\n");
			return 0;
		}
	}
	else
	{
		/*按键没变化*/

		return -1;
	}
}

有个要点:

key_sta变量定义,在函数内定义的是局部变量,但是通过一个static 关键字修饰,即使函数退出,也不释放,下次进入函数,key_sta的值就不会变。 static修饰函数内的局部变量,变量生命周期上,相当于全局变量。使用范围,相当于局部变量,只能在函数内使用。

这个扫描函数的流程完全按照前面的流程图处理:

第6行读IO口状态 第7行判断状态是不是等于Bit_Set,也就是高电平,并且key_sta等于0,也就是说,上一次的状态是0,状态是按下。 这样做的目的是,我们只处理状态变化。 第9行,延时 第10行,再次读IO状态 第11行,判断状态是否为高电平。 第13行,将按键状态改为1,高电平,松开状态。 第18到28行是按下状态处理,原理跟松开一样。

main函数中,按键按下,点亮LED,按键松开,熄灭LED。

/* Infinite loop */
	mcu_uart_open(3);
	wjq_log(LOG_INFO, "hello word!\r\n");
	dev_key_init();
	while (1)
	{
		s32 key;
		key = dev_key_scan();
		if(key == 0)
		{
			GPIO_ResetBits(GPIOG, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2| GPIO_Pin_3);
		}
		else if(key == 1)
		{

			GPIO_SetBits(GPIOG, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2| GPIO_Pin_3);

		}
		Delay(1);

	}

重新编译下载进去后即可验证。

优化改造

到此,一个大家在其他教程中常见,基本的按键扫描就实现了。 但是,能用吗?为什么? 我认为结论是不能用。 有以下问题:

  1. 扫描过程去抖动用了硬延时,俗称死等,啥都没干,白白浪费CPU的时间。
  2. 应用和驱动耦合在一起,耦合就是强关联。 看上面程序,应用就是main.c中的读到按键后点亮LED。驱动就是按键扫描,也就是dev_key_scan函数。应用直接通过调用驱动获取键值,属于耦合(好的设计应该调用驱动提供的接口)。 耦合有什么不好呢?
  1. 驱动只有应用要读按键了才进行按键扫描,多个应用怎么办?如果应用执行不及时,按键会丢失吧?。—–驱动受制于应用
  2. 扫描到按键直接就给应用。——驱动拖累应用

改造后关键代码-扫描

关键思路:每次进入scan函数,只做状态判断。 scan函数可以放到定时器执行,等移植RTOS后,也可以放到线程内。 现在我们先放到main函数的while(1)中,这个循环内有一个Delay,也就相当于间隔10毫秒执行一次scan。 防抖动通过多次扫描实现。 扫描到按键后,将键值写入按键缓冲,至于谁要用,什么时候用,驱动不管。 具体流程请看函数注释。


/**
 *@brief:      dev_key_scan
 *@details:    扫描按键
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
s32 dev_key_scan(void)
{
	volatile u8 sta;//局部变量,放在栈空间,进入函数时使用,退出后释放。
	static u8 new_sta = Bit_SET;
	static u8 old_sta = Bit_SET;
	u8 key_value;
	static u8 cnt = 0;

	if(KeyGd != 0)
		return -1;

	/*读按键状态*/
	sta = GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0);
	/*
		判断跟上次读的状态是不是一样,
		原因是,保证防抖过程的状态是连续一样的。
		不明白可以想象按键状态快速变化。
		这种情况我们不要认为是有按键。
	*/
	if((sta != new_sta))
	{
		cnt = 0;
		new_sta = sta;
	}
	/*
		与上次得到键值的状态比较,
		如果不一样,说明按键有变化
	*/
	if(sta != old_sta)
	{
		cnt++;

		if(cnt >= DEV_KEY_DEBOUNCE)
		{
			/*防抖次数达到,扫描到一个按键变化*/
			cnt = 0;
			key_value = DEV_KEY_PRESS;

			/*判断是松开还是按下*/
			if(sta == Bit_RESET)
			{
				KEY_DEBUG(LOG_DEBUG, "key press!\r\n");
			}
			else
			{
				key_value += DEV_KEY_PR_MASK;
				KEY_DEBUG(LOG_DEBUG, "key rel!\r\n");
			}
			/*键值写入环形缓冲*/
			KeyBuf[KeyW] = key_value;
			KeyW++;
			if(KeyW>= KEY_BUF_SIZE)
			{
				KeyW = 0;
			}
			/*更新状态*/
			old_sta = new_sta;
		}
	}
	return 0;
}

应用驱动分离

按键驱动改造后,应用也要同步修改,修改如下。 虽然现在扫描和点灯还是放在main中,不过这个只是当前测试而已。 而且,虽然放在一起,两个模块在逻辑上已经没有关系了。 就算没有点灯程序,按键扫描也会按照自己的设计执行。跟点灯不再强关联。

/* Infinite loop */
	mcu_uart_open(3);
	wjq_log(LOG_INFO, "hello word!\r\n");
	dev_key_init();

	dev_key_open();
	while (1)
	{
		/*驱动轮询*/
		dev_key_scan();

		/*应用*/
		u8 key;
		s32 res;

		res = dev_key_read(&key, 1);
		if(res == 1)
		{
			if(key == DEV_KEY_PRESS)
			{
				GPIO_ResetBits(GPIOG, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2| GPIO_Pin_3);
			}
			else if(key == DEV_KEY_REL)
			{

				GPIO_SetBits(GPIOG, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2| GPIO_Pin_3);
			}
		}

		Delay(1);

	}

总结

改造后的程序,完全可以用于实际项目。 在按键驱动中,增加了open和close函数,还有设备描述符。

/*按键设备符*/
s32 KeyGd = -1;

这都是为了后续所有设备驱动统一管理做准备。


end

定时器-定时-说中断

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


定时器是芯片上一个最重要的外设。 在写代码时,经常需要一段代码延时一定时间后执行;或者是一段程序间隔一定时间不断重复执行。 最典型的就是嵌入式操作系统,例如freertos,就需要一个定时器作为系统调度心跳时钟。 在调试按键扫描时提到,我们可以将scan函数放到定时器中执行,这也是定时器驱动程序执行的一个例子。 定时器是一个统称,实际上定时仅仅是定时器的一个功能。 定时器的功能通常包括:定时、PWM输出、输入捕获、输入计数等功能。 这次,我们先调试定时功能。

定时器

定时器是什么? 最简单的定时器就像一个倒计时时钟,大家的手机应该都有这个功能。 设定一个时间,例如10分钟,10分钟一到,闹铃就响起。 那单片机的定时器跟手机的定时器有什么区别呢?

  1. 我们用单片机定时器,通常定时都是us/ms/s,很少定时几分钟的。
  2. 单片机定时时间到了,不会直接响闹铃,一般只会在某个状态寄存器的某个bit置位,表明定时到了。如果这个定时器使能了中断,就会产生一个中断。
  3. 单片机的定时器功能更多,例如重复定时,假如定时1s,如果配置为自动重复,那么只要启动定时器,每一秒都会产生闹铃,直到定时器被停止。
  4. 设置更复杂,手机要定时10分钟,直接拨指针或者输入10就可以了。单片机要定时10S,要根据系统时钟计算,选择合适的预分频和定时计数。还要设置相关的中断开关等。

STM32定时器

我们先看看STM32的定时器都有哪些功能。

框图

使用一个芯片的定时器,先了解他的框图。 本次实验我们使用TIM5,在参考手册第15章节有详细说明。 下图是他的框图,看起来非常复杂,可见定时器功能是多么丰富。 如果我们只是当做一个定时器用,就只要关心红框内的三个框: PSC预分频器,也就是我们设置的预分频系数,意思就是,例如我们设置为2,那么每经过2个CK_PSC时钟,CNT计数器才会变化1。 自动重载寄存器,设置的值跟CNT设置的值一样,如果我们设置为重复定时,如果是减计数,当CNT达到0,芯片会自动从重载寄存器拷贝计数值到计数器。加计数时,用CNT值跟重载寄存器比较,判断定时时间是否到。 _images/pic13.jpg定时器框图

时钟

前面说到,单片机定时器跟手机定时器比,单片机设置更复杂,设置要根据系统时钟计算。 那么我们就要先了解系统的时钟,通常在参考手册内会有一个时钟树。 STM32F407芯片时钟树在6.2章节, _images/2.png芯片时钟树 在本章节中,有针对定时器的说明 _images/3.png定时器时钟 在system_stm32f4xx.c文件中,时钟初始化函数static void SetSysClock(void),对PCLK1进行了4分频初始化,那么168/4=42M,预分频是2不是1,因此定时器的时钟为APB时钟倍频,42M*2=84M。

#if defined(STM32F40_41xxx) || defined(STM32F427_437xx) || defined(STM32F429_439xx)
||  defined(STM32F412xG) || defined(STM32F446xx) || defined(STM32F469_479xx)    
    /* PCLK2 = HCLK / 2*/
    RCC->CFGR |= RCC_CFGR_PPRE2_DIV2;

    /* PCLK1 = HCLK / 4*/
    RCC->CFGR |= RCC_CFGR_PPRE1_DIV4;
#endif /* STM32F40_41xxx || STM32F427_437x || STM32F429_439xx  || STM32F412xG
|| STM32F446xx || STM32F469_479xx */

初始化

下面代码为定时器初始化,前面是宏定义,大家写代码一定要多用宏定义,用宏定义的代码,修改起来更方便。

#define TestTim TIM5
/*
    定时器时钟为84M,
    Tout=((SYSTEM_CLK_PERIOD)*(SYSTEM_CLK_PRESCALER))/Ft us.

	预分频,8400个时钟才触发一次定时器计数
	那么一个定时器计数的时间就是(1/84M)*8400 = 100us	  
*/
#define SYSTEM_CLK_PRESCALER    8400                  
#define SYSTEM_CLK_PERIOD       10000//定时周期

/**
 *@brief:      mcu_timer_init
 *@details:    定时器初始化
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
s32 mcu_timer_init(void)
{
    NVIC_InitTypeDef NVIC_InitStructure;
    TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStruct;
    //打开定时器时钟
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM5, ENABLE);
    //复位定时器
    TIM_Cmd(TestTim, DISABLE);
    TIM_SetCounter(TestTim, 0);

    //设定TIM5中断优先级
    NVIC_InitStructure.NVIC_IRQChannel = TIM5_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2;//抢占优先级
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;      //响应优先级
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);

    TIM_TimeBaseInitStruct.TIM_ClockDivision = TIM_CKD_DIV1;
    TIM_TimeBaseInitStruct.TIM_CounterMode = TIM_CounterMode_Up;//向上计数
    TIM_TimeBaseInitStruct.TIM_Period = SYSTEM_CLK_PERIOD - 1;  //周期
    TIM_TimeBaseInitStruct.TIM_Prescaler = SYSTEM_CLK_PRESCALER-1;//分频
    TIM_TimeBaseInitStruct.TIM_RepetitionCounter = 1;
    TIM_TimeBaseInit(TestTim, &TIM_TimeBaseInitStruct);

    TIM_ITConfig(TestTim, TIM_IT_Update, ENABLE);//打开定时器中断

    TIM_Cmd(TestTim, ENABLE);//使能定时器(启动)

		return 0;
}  

在初始化函数内,主要分3部分:

  • 时钟
第25行打开设备时钟。
  • 定时器配置
27行,停止定时器。 28行,复位定时器计数值。 37行,时钟分频,配置为DIV1,也就是没分频,定时器时钟为84M。 38行,计数方式,选择向上计数。 39行,计数周期,也就是要定时多少个计数的意思,使用向上计数,计数达到后产生事件。 40行,设置预分频系数,也就是决定了每个计数的时长。 41行,设置是否自动重加载,意思是:一个定时到了,是否自动开始下一个定时(不断重复)。 42行,执行配置。 46行,启动定时器。
  • 中断
31行,指定配置TIM5中断。 32行,设置抢断优先级。 33行,设置响应优先级。 34行,使能。 35行,执行配置。 44行,打开定时器中断。

预分频系数,也就是Prescaler。 程序中定义为8400,也就是说在84M时钟情况下,8400个时钟才触发一次定时器计数,那么一个定时器计数的时间就是(1/84M)*8400 = 100us,那么要定时1S钟,周期则要设置为1x1000x1000/100=10000。 经过10000次计数后,恰好就是1S,定时器会产生一个状态事件,如果中断使能,则发生中断。

查询模式

如果没有使能中断,想要知道定时器是否已经到时间,需要一直查询定时器的状态寄存器。 如何查询?请看ST提供的库stm32f4xx_tim.c 其中有函数:

/**
  * @brief  Checks whether the specified TIM flag is set or not.
  * @param  TIMx: where x can be 1 to 14 to select the TIM peripheral.
  * @param  TIM_FLAG: specifies the flag to check.
  *          This parameter can be one of the following values:
  *            @arg TIM_FLAG_Update: TIM update Flag
  *            @arg TIM_FLAG_CC1: TIM Capture Compare 1 Flag
  *            @arg TIM_FLAG_CC2: TIM Capture Compare 2 Flag
  *            @arg TIM_FLAG_CC3: TIM Capture Compare 3 Flag
  *            @arg TIM_FLAG_CC4: TIM Capture Compare 4 Flag
  *            @arg TIM_FLAG_COM: TIM Commutation Flag
  *            @arg TIM_FLAG_Trigger: TIM Trigger Flag
  *            @arg TIM_FLAG_Break: TIM Break Flag
  *            @arg TIM_FLAG_CC1OF: TIM Capture Compare 1 over capture Flag
  *            @arg TIM_FLAG_CC2OF: TIM Capture Compare 2 over capture Flag
  *            @arg TIM_FLAG_CC3OF: TIM Capture Compare 3 over capture Flag
  *            @arg TIM_FLAG_CC4OF: TIM Capture Compare 4 over capture Flag
  *
  * @note   TIM6 and TIM7 can have only one update flag.
  * @note   TIM_FLAG_COM and TIM_FLAG_Break are used only with TIM1 and TIM8.    
  *
  * @retval The new state of TIM_FLAG (SET or RESET).
  */
FlagStatus TIM_GetFlagStatus(TIM_TypeDef* TIMx, uint16_t TIM_FLAG)

用这个函数就可以查询定时器的各种状态了。 对应会有一个清标志函数:

void TIM_ClearFlag(TIM_TypeDef* TIMx, uint16_t TIM_FLAG)

必须手动用这个函数清除标志,否则,就不知道下一次定时器的到来了。

中断

在前面章节,我们曾初步了解了芯片系统中断。现在我们再通过定时器中断看看中断的细节。 什么是中断呢?

通常我们的程序是按顺序执行(函数跳转和返回也是按顺序执行),顺序都是我们安排好的(我们上帝之手)。 当中断来临时,停止正在执行的程序,强行执行中断服务程序,中断服务程序运行结束后,在自动返回原来执行程序的位置继续执行。 中断和顺序程序不一样的就是,中断发生时间不确定。假设按键输入设置为IO中断,那么什么时候产生中断?随机的,没人知道什么时候会产生按键。

下面我们通过定时器的程序大概了解一下中断的应用。大家要注意,我们这里仅仅说应用,至于一个中断的切换返回细节,暂不做讨论。

NVIC

在STM32这个芯片中,或者说contex这种芯片中,与中断相关的有两部分:

  1. 外设本身,比如你要使用定时器的中断,必须在定时器中使能对应的中断。
  2. NVIC,嵌套向量控制器。

其中外设只要打开对应中断就行了。 NVIC就比较复杂了,NVIC是芯片控制管理所有中断的模块。 在《STM32F4xx中文参考手册.pdf》第10章有详细说明。 使用NVIC,主要内容是设置中断的优先级。

优先级

contex内核有两个优先级:抢占和响应。 抢占优先级就是:如果A优先级高,B优先级低,当A发生中断,就算B正在处理中断,A也会立刻响应。 响应优先级就是:如果A响应优先级比B高,C中断正在执行,就算B先来,A也可以在C中断结束后,优先执行。前提是A和B的抢占优先级一样,并且不比C高。

通过上面分析,我们可以知道或者通常,把抢占优先级叫做主优先级,响应优先级叫做次优先级。 判断两个中断谁优先级高,先比主优先级,再比次优先级。

优先级分组

哪么优先级怎么设置呢? contex提供了多达8bit用来控制抢占优先级和响应优先级。 STM32自用了其中的低4位,这低4位如何分配给抢占和响应,需要在初始化时调用函数分配:

/**
  * @brief  Configures the priority grouping: pre-emption priority and subpriority.
  * @param  NVIC_PriorityGroup: specifies the priority grouping bits length.
  *   This parameter can be one of the following values:
  *     @arg NVIC_PriorityGroup_0: 0 bits for pre-emption priority
  *                                4 bits for subpriority
  *     @arg NVIC_PriorityGroup_1: 1 bits for pre-emption priority
  *                                3 bits for subpriority
  *     @arg NVIC_PriorityGroup_2: 2 bits for pre-emption priority
  *                                2 bits for subpriority
  *     @arg NVIC_PriorityGroup_3: 3 bits for pre-emption priority
  *                                1 bits for subpriority
  *     @arg NVIC_PriorityGroup_4: 4 bits for pre-emption priority
  *                                0 bits for subpriority
  * @note   When the NVIC_PriorityGroup_0 is selected, IRQ pre-emption is no more possible.
  *         The pending IRQ priority will be managed only by the subpriority.
  * @retval None
  */
void NVIC_PriorityGroupConfig(uint32_t NVIC_PriorityGroup)

一共可以设置5中分组模式,什么意思呢? 例如第一种分组模式NVIC_PriorityGroup_0,这种分组模式,pre优先级没有,也就是不能设置(强制设置会出现意外),4bit都用来表示sub优先级,那么sub优先级就可以设置0~15。 如果设置为NVIC_PriorityGroup_2,pre优先级两位,可以设置0~3,sub优先级同样2位,也可以设置0~3。 每个系统具体如何设置中断优先级,需要根据所有中断需求合理分配。

中断服务函数

一旦中断产生,就需要执行中断服务程序。 定时器的中断程序如下,这个函数就是中断服务函数。在函数我们判断了是不是Update中断,为什么要判断呢?因为定时器中断入口只有一个,定时器中断有多种,当中断产生时,只能通过标志区分是什么中断源。

/**
 *@brief:      mcu_tim5_IRQhandler
 *@details:    定时器中断处理函数
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
void mcu_tim5_IRQhandler(void)
{
    if(TIM_GetITStatus(TIM5, TIM_FLAG_Update) == SET)
    {                                       
        TIM_ClearFlag(TIM5, TIM_FLAG_Update);

        mcu_tim5_test();

    }
}

在stm32f4xx_it.c中断响应中调用void mcu_tim5_IRQhandler(void);

void TIM5_IRQHandler(void)
{
    mcu_tim5_IRQhandler();
}

TIM5_IRQHandler这个函数名,是在中断向量中定义好的,一定要一样

中断向量

前面章节我们其实已经讨论过中断向量,我们看看定时器5的中断向量。

__Vectors       DCD     __initial_sp           ; Top of Stack
                DCD     Reset_Handler          ; Reset Handler
                DCD     NMI_Handler            ; NMI Handler
                DCD     HardFault_Handler      ; Hard Fault Handler
                DCD     MemManage_Handler      ; MPU Fault Handler
                DCD     BusFault_Handler       ; Bus Fault Handler
                DCD     UsageFault_Handler     ; Usage Fault Handler
                DCD     0                      ; Reserved
                DCD     0                      ; Reserved
                DCD     0                      ; Reserved
                DCD     0                      ; Reserved
                DCD     SVC_Handler            ; SVCall Handler
                DCD     DebugMon_Handler       ; Debug Monitor Handler
                DCD     0                      ; Reserved
                DCD     PendSV_Handler         ; PendSV Handler
                DCD     SysTick_Handler        ; SysTick Handler
                ...
                DCD     SDIO_IRQHandler        ; SDIO                                            
                DCD     TIM5_IRQHandler        ; TIM5   

定时器5的中断函数指针在19行(文件137行)。

定时器中断流程

到此,我们已经配置好定时器了,流程大概如下: 1 使能定时器时钟,并配置定时器。 2 设置NVIC定时器中断优先级。 3 打开定时器中断,启动定时器。 4 定时到后,产生时间标志,同时产生中断标志。 5 芯片从中断向量查找中断服务程序入口,执行中断服务程序。执行后返回。 6 重复4。

编码实验

代码见\mcu_dev目录下的mcu_timer.c和mcu_timer.h。 另外,在main函数前调用void mcu_timer_init(void)函数,初始化定时器。

/* Infinite loop */
	mcu_uart_open(3);
	wjq_log(LOG_INFO, "hello word!\r\n");
	dev_key_init();
	mcu_timer_init();

	dev_key_open();
	while (1)
	{
		/*驱动轮询*/
		dev_key_scan();

		/*应用*/
		u8 key;
		s32 res;

		res = dev_key_read(&key, 1);
		if(res == 1)
		{
			if(key == DEV_KEY_PRESS)
			{
				GPIO_ResetBits(GPIOG, GPIO_Pin_0
                                    | GPIO_Pin_1 | GPIO_Pin_2| GPIO_Pin_3);
			}
			else if(key == DEV_KEY_REL)
			{

				GPIO_SetBits(GPIOG, GPIO_Pin_0
                                    | GPIO_Pin_1 | GPIO_Pin_2| GPIO_Pin_3);
			}
		}

		Delay(1);

	}

编译后下载进去,串口可见运行结果,从结果看,确实每秒中进入一次定时器中断。

hello word! tim int 1 tim int 2 tim int 3 tim int 4 tim int 1 tim int 2 tim int 3 tim int 4 tim int 1 tim int 2

总结

定时器功能就实现了。 请大家自行尝试将按键扫描放到定时器中,并且将定时器定时改为5毫秒一次中断。


end

定时器-PWM-蜂鸣器

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


上一章节我们调试了定时器定时功能。现在我们调试定时器输出PWM功能。

PWM

PWM(Pulse Width Modulation)是脉冲宽度调制的缩写。 _images/pic14.jpgPWM波形 简单的说就是高低电平不断切换。在流水灯章节我们曾经提过。 一个高低电平切换就是一个周期,在一个周期内,高电平持续时间占周期的百分比就是常说的占空比。 PWM常用于控制灯光和电机。通常占空比越大,电机转速越快,LED越亮(高电平驱动方式)。

原理图

本次我们使用一个固定频率的50%占空比PWM驱动一个电磁式蜂鸣器。 _images/pic22.jpg蜂鸣器电路 蜂鸣器选用贴片电磁式,参数如下,从表中可以看到,只要我们输出一个4000Hz的频率,就能驱动蜂鸣器。 _images/pic32.jpg蜂鸣器参数

STM32定时器PWM

蜂鸣器beep接在PD13,在数据手册《STM32F407_数据手册.pdf》中可查到,PD13是TIM4的CH2,因此要在这个IO上输出PWM,需要用定时器4,并且是在通道2上输出_images/1.png定时器PWM 上一节我们看过定时器的框图,做PWM功能,需要用到的功能比定时多了输出控制部分。 也就是下图右下角的大红框中的内容。 _images/pic42.jpg定时器框图 如何使用定时器4在PD13上输出4KHz的PWM?我们通过代码讲解。

编码

在board_dev文件夹创建两个新文件dev_buzzer.c、dev_buzzer.h,将这两个文件夹添加到工程。

IO口初始化

把一个IO口作为外设功能,只需要将对应IO口设置为AF模式,并且使用配置函数配置为对应的外设功能,具体见初始化函数注释。

s32 dev_buzzer_init(void)
{
	GPIO_InitTypeDef GPIO_InitStructure;

	RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOD, ENABLE); //---使能 GPIOD 时钟
    GPIO_PinAFConfig(GPIOD,GPIO_PinSource13,GPIO_AF_TIM4); //---管脚复用为 TIM4功能

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; //---复用功能
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz; //---速度 50MHz
    GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; //---推挽复用输出
    GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; //---上拉
    GPIO_Init(GPIOD,&GPIO_InitStructure);

    mcu_tim4_pwm_init(BUZZER_CLK_PERIOD,BUZZER_CLK_PRESCALER);

	return 0;
}

定时器初始化

在初始化函数内,调用mcu_timer.c文件的mcu_tim4_pwm_init函数初始化定时器4。

void mcu_tim4_pwm_init(u32 arr,u32 psc)
{
    TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
    TIM_OCInitTypeDef TIM_OCInitStructure;

    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4,ENABLE);//---TIM4 时钟使能

    TIM_TimeBaseStructure.TIM_ClockDivision=TIM_CKD_DIV1;
    TIM_TimeBaseStructure.TIM_Prescaler = psc - 1; //---定时器分频
    TIM_TimeBaseStructure.TIM_CounterMode=TIM_CounterMode_Up; //---向上计数模式
    TIM_TimeBaseStructure.TIM_Period= arr - 1; //---自动重装载值
    TIM_TimeBaseInit(TIM4,&TIM_TimeBaseStructure);//---初始化定时器 4

    //----初始化 TIM4 PWM 模式
    TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; //---PWM 调制模式 1
    TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; //---比较输出使能
    TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_Low; //---输出极性低

	/*默认配置的是通道2*/
    TIM_OC2Init(TIM4, &TIM_OCInitStructure); //---初始化外设 TIM4
    TIM_SetCompare2(TIM4, arr/2);//---占空比50%
    TIM_OC2PreloadConfig(TIM4, TIM_OCPreload_Enable); //---使能预装载寄存器
    TIM_ARRPreloadConfig(TIM4,ENABLE);

}

相对前面配置定时功能,多了PWM配置。

15行,配置定时器输出模式, 定时器输出有以下6种模式选择。
/** @defgroup TIM_Output_Compare_and_PWM_modes
  * @{
  */

#define TIM_OCMode_Timing                  ((uint16_t)0x0000)
#define TIM_OCMode_Active                  ((uint16_t)0x0010)
#define TIM_OCMode_Inactive                ((uint16_t)0x0020)
#define TIM_OCMode_Toggle                  ((uint16_t)0x0030)
#define TIM_OCMode_PWM1                    ((uint16_t)0x0060)
#define TIM_OCMode_PWM2                    ((uint16_t)0x0070)
16行,比较输出使能,也就是打开PWM输出功能。 17行,输出极性低,这个所谓的输出极性,也就是控制比较寄存器设置的计数小的时候,输出0还是输出1。或者简单的理解就是,先输出低电平还是先输出高电平。 20-23行,执行配置,这个地方要注意,不同的输出通道设置,需要使用不同的函数: 我们用的是通道2,那么用的就是TIM_OC2Init、TIM_SetCompare2、TIM_OC2PreloadConfig。 在库文件中可以看到下面这些函数,定时器有4个通道,就有4套配置函数。 _images/pic51.png定时器输出设置函数

IO口和定时器都配置好后,只要启动定时器,就可以输出PWM了。

打开和关闭的逻辑

经过初始化后,输出PWM,蜂鸣器就会响。 应用上不可能让蜂鸣器一直响,所以要提供OPEN和CLOSE接口。 如下:

/**
 *@brief:      dev_buzzer_open
 *@details:    打开蜂鸣器
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
s32 dev_buzzer_open(void)
{
	GPIO_InitTypeDef GPIO_InitStructure;

	RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOD, ENABLE); //---使能 GPIOD 时钟
    GPIO_PinAFConfig(GPIOD,GPIO_PinSource13,GPIO_AF_TIM4); //---管脚复用为 TIM4功能
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; //---复用功能
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz; //---速度 50MHz
    GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; //---推挽复用输出
    GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; //---上拉
    GPIO_Init(GPIOD,&GPIO_InitStructure);

    TIM_Cmd(TIM4, ENABLE); //---使能 TIM4

	return 0;
}
/**
 *@brief:      dev_buzzer_close
 *@details:    关闭蜂鸣器
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
s32 dev_buzzer_close(void)
{
	GPIO_InitTypeDef GPIO_InitStructure;

    TIM_Cmd(TIM4, DISABLE); //---关闭定时器 TIM4

	RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOD, ENABLE); //---使能 GPIOD 时钟

	/*关闭蜂鸣器时,要将IO改为普通IO,并且输出低电平,否则蜂鸣器会造成大电流*/
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT; //---复用功能
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz; //---速度 50MHz
    GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; //---推挽复用输出
    GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; //---上拉
    GPIO_Init(GPIOD,&GPIO_InitStructure);
	GPIO_ResetBits(GPIOD, GPIO_Pin_13);

	return 0;
}

是不是跟你想的不一样?只要停止或者启动定时器不就行了吗? 功能上,确实是只要停止定时器,PWM就没有输出,蜂鸣器就不响;打开定时器,输出PWM,定时器就会响。 但是实际上会有问题,请看注释。

因为我们关闭PWM输出,是直接停止定时器。那么在停止定时器的时候,PWM输出的电平是随机的,如果正好在输出高电平时停止定时器,蜂鸣器电流上的三极管就一直处于导通状态,此时电流会增大100ma。 所以关闭蜂鸣器后要将IO转为输出模式,并输出低电平。打开蜂鸣器时再将IO设置为PWM模式。

调试

在main函数中增加如下代码,初始化后,按下按键蜂鸣器响,松开按键蜂鸣器关。

/* Infinite loop */
	mcu_uart_open(3);
	uart_printf("hello word!\r\n");
	mcu_timer_init();
	dev_key_init();
	dev_buzzer_init();
	while (1)
	{
		s32 key;
		key = dev_key_scan();
		if(key == 0)
		{
			GPIO_ResetBits(GPIOG, GPIO_Pin_0
				| GPIO_Pin_1 | GPIO_Pin_2| GPIO_Pin_3);
			dev_buzzer_open();
		}
		else if(key == 1)
		{

			GPIO_SetBits(GPIOG, GPIO_Pin_0
				| GPIO_Pin_1 | GPIO_Pin_2| GPIO_Pin_3);
			dev_buzzer_close();

		}
		Delay(1);

	}
  • 调试过程 1 代码编写完成,编译,无错误,下载进去后,蜂鸣器不响。手头暂时没有示波器。咋办? 目前并没有确定是软件问题还是硬件问题,因此,首先要确定硬件没有问题。 前面我们已经调通了IO口输出与定时器中断,可以利用定时器中断操作IO口翻转电平输出一个4K频率的PWM。 经验证,蜂鸣器响,硬件无问题。 2 经查询,发现在设置PWM通道时错误,参考的代码用的是通道1,而我们用的是通道2。初始化PWM时,每一个带OC1字符的函数或者定义都改为OC2即可。 也即是上面mcu_tim4_pwm_init代码中的20、21、22行。 3 目前蜂鸣器是能发出声音了,但是因为暂时没有示波器,无法保证频率是否准确,占空比是否是50%,实际项目中必须用示波器验证。

总结

PWM应用非常广泛,除了控制蜂鸣器,还可以控制LED灯,或者控制LCD背光。 只要学会一种,其他都一样。


end

定时器-捕获-触摸按键

够用的硬件 能用的代码 实用的教程 屋脊雀工作室编撰 -20190101 愿景:做一套能用的开源嵌入式驱动(非LINUX) 官网:www.wujique.com github: https://github.com/wujique/stm32f407 淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf 技术支持邮箱:code@wujique.com、github@wujique.com 资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg QQ群:767214262

前面章节介绍了定时器的定时与输出PWM功能,定时器还有很多功能。 《STM32F4xx中文参考手册.pdf》中定时器就有说明。 本次我们就使用定时器的输入捕获功能,实现一个简单的电容触摸按键

输入捕获

假如我们要检测一个PWM波形的高低电平时间,会怎么做呢? 如果没有输入捕获,我们可以使用一个普通定时器和一个IO输入中断实现。流程大概如下:

  • 将一个IO口配置为中断,上升沿触发。
  • 配置一个定时器,向下计数模式,例如倒计时1000毫秒
  • IO产生中断。
  • 在IO中断中,停止定时器,并且获取剩余计数值,例如是600,说明时间已经过去400毫秒。 同时配置IO为下降沿触发。再启动定时器。
  • IO产生下降沿中断。
  • 在IO中断中,停止定时器,并且获取剩余计数值,例如是300,说明时间已经过去300毫秒。 那么600-300 = 300,就是高电平时间。 也就完成了一次捕获中断,低电平同理。

定时器的输入捕获与此类似,只不过定时器硬件完成了判断IO口上升下降沿并保存时间值的功能。

  • 首先打开定时器,向上计数模式。
  • IO口(定时器通道)产生一个上升沿。
  • 定时器在上升沿时将定时器的计数CNT,保存到CCRx1。
  • IO口(定时器通道)产生一个下降沿。
  • 定时器在下降沿时将定时器的计数CNT,保存到CCRx2。 上升沿到下降沿时长可能超出定时器计数时限,需要做溢出处理。
  • 那么CCRx1跟CCRx2,就是定时器捕获到的值,通过这两个值。 可算出高电平时间,低电平同理。

不同的芯片输入捕获功能基本相同,细节上可能有所差别。

STM32捕获中断

输入捕获,是定时器的功能,前面我们已经用了STM32定时器定时输出两个功能。 输入捕获是定时器的输入功能,除了输入捕获,还有输入计数等其他输入功能。 输入捕获用到下图红框内的功能。 _images/pic15.jpg定时器框图

这看《STM32F4xx中文参考手册.pdf》,不再累赘。 然后通过例程代码进一步熟悉。

电容触摸按键原理

所谓电容式,就是利用电容的充放电检测是否触摸。 学过基本电子学的同学应该都知道,一个真正的器件,会有一个等效电路,而不仅仅是我们说的这个器件功能本身(理想电路)。 例如一个电阻,等效电路与理想电路如下: _images/11.png电阻理想电路 左边是理想电阻,右边是等效电阻,除了电阻,还包含了寄生电感跟寄生电容。寄生性能在低速电路通常不用考虑,在高速电路就是一个重要指标了。 人,就是一个大电阻,也有寄生电容。当人一接触PCB板上的触摸铜箔,就改变了整个触摸电路的寄生电容。触摸电路的放电时间就会改变。

  • 电容触摸按键电路 _images/21.png电容触摸按键电路
  • 充放电波形 下图是触摸按键充放电波形,有几个特点:
  1. 电容很小,在IO口高电平3.3V充电,很快就会充满,所以低电平到高电平非常陡峭。
  2. 放电电阻较大,我们用的是5.1M,所以放电时间比充电时间长很多很多。
  3. 当手指触摸铜箔,电容增大,放电时间变长。 _images/pic21.png充放电波形
  • 检测流程
  • 先用IO口输出高电平,电阻跟触摸铜箔的寄生电容就会充电。
  • 然后将IO口改为输入捕获,这时刚刚充满电的电容就会通过电阻放电,放电结束后(电压达到IO低电平识别范围)就触发输入捕获中断
  • 我们将得到的数据进行分析处理,即可区分是否有触摸

编码

下面进入编码设计。

驱动设计

触摸按键驱动分两部分:

  1. 输入捕获相关的,放到定时器驱动,为touchkey算法提供时间流。
  2. 触摸算法处理部分,单独做一个dev_touchkey驱动,提供触摸按键api给APP使用。

对于这两个驱动的分割,有如下考虑:

  1. 输入捕获就是输入捕获,捕获到时间流后,就上传给上一个模块。 至于这个时间流的具体功能,是触摸按键呢?还是其他,例如磁条卡磁道时间流。定时器会知道吗?不知道,也不应该知道,知道也不应该管。
  2. 触摸模块根据时间流数据处理后得到触摸事件。 我们现在用的是定时器捕获,如果改为普通定时器加IO中断。触摸驱动要识别这两者吗?不需要,而且要兼容。无论定时捕获还是定时+IO中断,对于触摸驱动来说,就是个黑盒子,只要给我时间流数据就行了。
  3. 有一种触摸按键,是直接用芯片处理的,从芯片处就已经得到了触摸按键事件。 假设现在方案是定时捕获,我们完成了驱动,量产了。然后要修改为IC方案。怎么样的驱动设计,改动最小?

定时器配置

在mcu_timer驱动中增加初始化定时器输入捕获内容。 硬件使用PA3作为捕获输入,查看数据手册,PA3是TIM2的CH4,还是TIM5的CH4,是TIM9的CH2。 _images/31.pngPA3对应定时器通道 我们使用TIM2的CH4作为捕获定时器输入。不使用捕获中断,捕获值通过查询获取。 主要有两个函数,一个是初始化定时器捕获,一个是查询获取定时器捕获的值。代码都是对库的调用,具体配置请看源码。

/**
 *@brief:      mcu_timer_cap_init
 *@details:    初始化定时器捕获,不使用中断
 *@param[in]   u32 arr  
               u16 psc  
 *@param[out]  无
 *@retval:     
 */
void mcu_timer_cap_init(u32 arr,u16 psc)
{

	TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
	TIM_ICInitTypeDef TIM2_ICInitStructure;

	//初始化 TIM2
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // 时钟使能
	TIM_TimeBaseStructure.TIM_Period = arr; //设定计数器自动重装值
	TIM_TimeBaseStructure.TIM_Prescaler =psc; //预分频器
	TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1;
	TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; //TIM 向上计数
	TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); // 初始化定时器 2

	//初始化通道 4
	TIM2_ICInitStructure.TIM_Channel = TIM_Channel_4; //选择输入端 IC4 映射到 TIM2
	TIM2_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Falling; //下降沿捕获
	TIM2_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI;
	TIM2_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; //配置输入分频,不分频
	TIM2_ICInitStructure.TIM_ICFilter = 0x00;//配置输入滤波器 不滤波
	TIM_ICInit(TIM2, &TIM2_ICInitStructure);//初始化 TIM2 IC4

	TIM_ClearITPendingBit(TIM2, TIM_IT_CC4|TIM_IT_Update); //清除中断标志
	TIM_SetCounter(TIM2,0);

	TIM_Cmd(TIM2,ENABLE); //使能定时器 2
}
/**
 *@brief:      mcu_timer_get_cap
 *@details:    查询获取定时去捕获值
 *@param[in]   void  
 *@param[out]  无
 *@retval:     捕获值,超时则返回最大值
 */
u32 mcu_timer_get_cap(void)
{

	while(TIM_GetFlagStatus(TIM2, TIM_IT_CC4) == RESET)//等待捕获上升沿
	{
		if(TIM_GetCounter(TIM2) > 0xffffffff-1000)
			return TIM_GetCounter(TIM2);//超时了,直接返回 CNT 的值
	}
	return TIM_GetCapture4(TIM2);
}
16~21行,是对定时器片配置。 24~29行,是对定时器输入的配置,还记得PWM实验吗?调用的是OC接口,现在调用的是IC接口,也就是input config的意思吧。

触摸按键处理

创建dev_touchkey设备驱动。 因为目前没有系统,驱动设计为:定时轮询+缓冲区模式。有系统的时候也可以使用这种模式,也可以考虑修改为线程+邮箱的模式。

  • 驱动伪代码流程
  1. dev_touchkey_init初始化
  2. 在main函数的while(1)中轮询dev_touchkey_task函数
  3. dev_touchkey_task中首先调用dev_touchkey_resetpad函数对按键充电。
  4. 充电完成后执行dev_touchkey_iocap,将IO转为定时器输入捕获通道。
  5. 调用mcu_timer_cap_init函数配置定时器捕获。
  6. 调用mcu_timer_get_cap获取捕获的值。
  7. 通过函数dev_touchkey_scan处理捕获值,如果确定状态变化,将事件写入TouchKeyBuf。

下面代码是触摸电容的检测流程,得到时间流后,再用scan函数处理时间流。

/**
 *@brief:      dev_touchkey_task
 *@details:    触摸按键线程,常驻任务
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
s32 dev_touchkey_task(void)
{
	volatile u32 i = 0;
	u32 cap;

	if(TouchKeyGd != 0)
		return -1;
	//IO输出1,对电容充电
	dev_touchkey_resetpad();
	//延时一点,充电
	for(i=0;i++;i<0x12345);
	//将IO口设置为定时去输入捕获通道
	dev_touchkey_iocap();
	//开定时器捕获,如果预分频8,一个定时器计数是100ns左右 ,这个值要通过调试,
	mcu_timer_cap_init(0xffffffff, 8);
	cap = mcu_timer_get_cap();
	TOUCHKEY_DEBUG(LOG_DEBUG, "\r\n%08x---", cap);

	dev_touchkey_scan(cap);

	return 0;
}

scan处理流程,设计思想可以参考按键扫描(很多数据处理流程都可以参考按键处理)。 具体流程看代码吧。

  • 应用流程
调用dev_touchkey_read读取触摸按键事件。
/**
 *@brief:      dev_touchkey_test
 *@details:    触摸按键测试程序
 *@param[in]   无
 *@param[out]  无
 *@retval:     
 */
s32 dev_touchkey_test(void)
{
	u8 tmp;
	s32 res;

	//dev_touchkey_open();

	res = dev_touchkey_read(&tmp, 1);
	if(1 == res)
	{
		if(tmp == DEV_TOUCHKEY_TOUCH)
		{
			wjq_log(LOG_FUN, "touch key test get a touch event!\r\n");
		}
		else if(tmp == DEV_TOUCHKEY_RELEASE)
		{
			wjq_log(LOG_FUN, "touch key test get a release event!\r\n");
		}
	}
	return 0;

}
  • 缺陷
  1. 使用查询方式获取捕获值,查询就相当于死等,浪费CPU时间,需要改为中断模式。
  2. 充电使用硬延时,同样浪费CPU时间,可以改为定时器。 请大家尝试优化,屋脊雀会再最后提供的整体软件上优化。

调试

  • 第一步 先调试获取时间流功能,获取到的时间流要能够反映触摸变化。

测试程序如下,在main函数while循环中调用即可。

/**
 *@brief:      dev_touchkey_task
 *@details:    触摸按键线程,常驻任务
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
s32 dev_touchkey_task(void)
{
	u32 i = 0;
	u32 cap;

	uart_printf("touchkey touch\r\n");
	//IO输出1,对电容充电
	dev_touchkey_resetpad();
	//延时一点,充电
	for(i=0;i++;i<0x12345);
	//开定时器捕获,如果预分频84,一个定时器计数是1us左右 ,这个值要通过调试,
	uart_printf("touchkey touch start cap.\r\n");
	mcu_timer_cap_init(0xffffffff, 84);
	cap = mcu_timer_get_cap();
	uart_printf("cap value:%08x\r\n", cap);
}
后面驱动进行系统系统整合,这个task函数要放到一个定时器,或者是在while主循环中定时轮询。如果有操作系统,就单独创建一个线程,或者放到守护线程中运行。 而且要整改这个函数,因为现在里面有死等,系统整合后不使用死等,避免占系统时间片资源,可以通过将这个task整改为分步骤执行来实现。 首先充电,然后退出task。 第二次进入task,就是启动cap,然后退出。 第三次进来就是读捕获数据,然后循环重复第一步。

串口调试信息如下,从中看出手不触摸的时候,捕获值为0X3B,手放上去时,0X54。 两者之间差别不大,而且数值都较小。需要加快定时器计数,将两者的捕获值拉大,增强识别度

—hello world!—– touchkey touch touchkey touch start cap. cap value:0000003b
—hello world!—– touchkey touch touchkey touch start cap. cap value:00000054
—hello world!—– touchkey touch touchkey touch start cap. cap value:00000054
—hello world!—– touchkey touch touchkey touch start cap. cap value:0000003b

将dev_touchkey_task中mcu_timer_cap_init函数的预分频改为8,将定时器速度直接加快10倍

mcu_timer_cap_init(0xffffffff, 8);

修改后调试信息如下,可以看出,捕获值已经放大,识别度由原来的20左右变为160,考虑干扰的情况,160上下浮50,识别度已经算可以了。 如果还需要优化,可以通过修改硬件放电电阻阻值,或者增加旁路电容。

—hello world!—– touchkey touch touchkey touch start cap. cap value:00000232
—hello world!—– touchkey touch touchkey touch start cap. cap value:000002ac
—hello world!—– touchkey touch touchkey touch start cap. cap value:00000300
—hello world!—– touchkey touch touchkey touch start cap. cap value:00000234
—hello world!—– touchkey touch touchkey touch start cap. cap value:00000231

注:以上测试数据在第一版硬件上测试

  • 第二步 对数据进行处理并识别

数据处理识别的方案:

时间流cap,连续N次,与上一轮稳定状态的平均值比较,偏差超过门限,则认为是一次变化,同时保存平均值做为新的稳定状态。 根据偏差方向(变大还是变小),判断是触摸还是松开,(上电初始化后第一次变化丢弃)。

源码如下,将dev_touchkey_scan函数添加到dev_touchkey_task获取到cap之后。同时修改main函数,将原来的延时改为10毫秒,加快触摸按键扫描速度。具体见代码。

#define DEV_TOUCHKEY_GATE 50//确认状态变化的门限值,根据硬件性能调节本参数到合适灵敏度即可。
#define DEV_TOUCHKEY_DATA_NUM 4//一轮稳定状态时间流个数,可以通过修改这个调节触摸扫描时间
static u16 TouchKeyLastCap = 0;//最后一次稳定的CAP平均值
/**
 *@brief:      dev_touchkey_scan
 *@details:    扫描触摸捕获的数据流
 *@param[in]   u32  
 *@param[out]  无
 *@retval:                  	
 */
s32 dev_touchkey_scan(u32 cap)
{
	static u16 average = 0;//平均值
	static u8 cap_cnt = 0;//有效捕获计数
	static u8 last_dire = DEV_TOUCHKEY_IDLE;//上一个值的方向,1位变大,触摸,2为变小,松开

	//uart_printf("--%08x-%04x-", cap, TouchKeyLastCap);
	if(cap > TouchKeyLastCap + DEV_TOUCHKEY_GATE)
	{
		if(last_dire != DEV_TOUCHKEY_TOUCH)
		{
			cap_cnt = 0;
			average = 0;
			last_dire = DEV_TOUCHKEY_TOUCH;
		}

		cap_cnt++;
		average = average + cap;
		//uart_printf("b-");
	}
	else if(cap < TouchKeyLastCap - DEV_TOUCHKEY_GATE)
	{
		if(last_dire != DEV_TOUCHKEY_RELEASE)
		{
			cap_cnt = 0;
			average = 0;
			last_dire = DEV_TOUCHKEY_RELEASE;
		}
		cap_cnt++;
		average = average + cap;
		//uart_printf("s-");
	}
	else
	{
		//uart_printf("i-");
		cap_cnt = 0;
		average = 0;
		last_dire = DEV_TOUCHKEY_IDLE;
	}

	//uart_printf("\r\n");
	if(cap_cnt >= DEV_TOUCHKEY_DATA_NUM)
	{

		if(DEV_TOUCHKEY_RELEASE == last_dire)
		{
			uart_printf("\r\n-----------------------rel\r\n");
		}
		else if(DEV_TOUCHKEY_TOUCH == last_dire)
		{
			uart_printf("\r\n-----------------------touch\r\n");
		}

		if(TouchKeyLastCap == 0)
		{
			uart_printf("\r\n-------------------init\r\n");
		}

		TouchKeyLastCap = average/DEV_TOUCHKEY_DATA_NUM;
		cap_cnt = 0;
		average = 0;
	}

}

我们从源码分析,

1 TouchKeyLastCap初始化为0。 2 第18行到第49行代码,判断新的cap值跟TouchKeyLastCap的偏差,如果大于门限,认为是触摸,并且进行记录;小于,认为是松开,同时进行记录;否则认为是没变化,清记录。 3 记录大于DEV_TOUCHKEY_DATA_NUM次后,确认是变化,根据变化方向,判断触摸还是松开,同时更新平均值。

本处理流程,在初始化时没有预先获取一个稳定状态做为非触摸状态。因此在上电的时候无论是触摸还是非触摸,在后续都可以正常识别。 触摸着按键上电,init之后松开,正确识别为rel,后续识别正常。

—hello world!—– ———————–touch ——————-init ———————–rel ———————–touch ———————–rel ———————–touch ———————–rel

上电时没有触摸,init之后触摸,正常识别为touch,后续识别正常。

—hello world!—– ———————–touch ——————-init ———————–touch ———————–rel ———————–touch ———————–rel
  • 第三步 识别结果保存及接口处理

经过上一步,已经能正常获取触摸事件。但是对于一个驱动来说,与上层的接口及数据交互方案是一个重要的驱动设计内容。 在TOUCHKEY驱动中,我们使用环形缓冲区的设计。

  1. 在dev_touchkey_scan函数中识别到事件后。将事件写入缓冲区。
  2. APP通过dev_touchkey_read接口读取事件。

dev_touchkey_scan函数修改增加如下代码

		if(TouchKeyLastCap == 0)
		{
			//uart_printf("\r\n-------------------init\r\n");
		}
		else
		{
			//uart_printf("\r\n-------------------chg\r\n");
			if(last_chg != last_dire)//防止重复上报
			{
				//uart_printf("\r\n--------report\r\n");
				TouchKeyBuf[TouchKeyWrite++] = last_dire;
				if(TouchKeyWrite >= DEV_TOUCHKEY_BUF_SIZE)
				TouchKeyWrite = 0;
			}

			last_chg = last_dire;

		}

实现读函数如下:

/**
 *@brief:      dev_touchkey_read
 *@details:    读设备,获取触摸事件
 *@param[in]   u8 *buf    
               u32 count  
 *@param[out]  无
 *@retval:     
 */
s32 dev_touchkey_read(u8 *buf, u32 count)
{
	u32 cnt = 0;

	while(1)
	{
		if(TouchKeyWrite ==  TouchKeyRead)
			break;

		if(cnt >= count)
			break;

		*(buf+cnt) = TouchKeyBuf[TouchKeyRead++];
		if(TouchKeyRead >= DEV_TOUCHKEY_BUF_SIZE)
			TouchKeyRead = 0;

		cnt++;
	}

	return cnt;

}

dev_touchkey_test就是一个应用。 将dev_touchkey_test放到main函数的while循环中执行。


/**
 *@brief:      dev_touchkey_test
 *@details:    触摸按键测试程序
 *@param[in]   无
 *@param[out]  无
 *@retval:     
 */
s32 dev_touchkey_test(void)
{
	u8 tmp;
	s32 res;

	res = dev_touchkey_read(&tmp, 1);
	if(1 == res)
	{
		if(tmp == DEV_TOUCHKEY_TOUCH)
		{
			uart_printf("touch key test get a touch event!\r\n");
		}
		else if(tmp == DEV_TOUCHKEY_RELEASE)
		{
			uart_printf("touch key test get a release event!\r\n");
		}
	}

}

测试结果

—hello world!—– touch key test get a touch event! touch key test get a release event! touch key test get a touch event! touch key test get a release event! touch key test get a touch event! touch key test get a release event! touch key test get a touch event! touch key test get a release event!

到此,touchkey设备驱动基本完成。当然,可以优化的地方还很多。

思考

  1. 我们已经实现了发现触摸就上报,触摸离开也上报。长按呢?如何处理?该谁处理?
  2. 对于一个应用来说,你是触摸按键还是机械按键,它并不关心。APP,触摸按键,机械按键,三者之间的关系关系如何处理?接口如何处理?

end

I2C-收音机-功放

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


前面已经调试了IO口,定时器,串口。本章节我们调试I2C。 别的教程都是用I2C控制EPROM,实际上EPROM现在用的已经比较少了。 如果只是随便存一点数据,STM32内部FLASH就可以使用。如果要存较多数据,例如字库,一般都使用SPI FLASH。 我们用I2C做一点好玩的,控制TEA5767,一块飞利浦的收音机芯片,玩一波电波情缘。

I2C接口

关于I2C接口,看文档《I2C总线协议.pdf》,周立功写的。 在第12页有一个时序图如下图,我们就从这个图了解I2C。 _images/12.pngI2C时序

  1. I2C通信使用两根线SDA和SCL,SCL是时钟线,SDA是数据线,控制时钟的是主机。
  2. I2C通信过程看图底部英文标识: (1)首先发送起始信号START,然后发送地址ADDRESSS,接着是读写位R/W,主机释放SDA线,从机使用SDA线返回应答位ACK。 地址有7位,紧接着的第8位是数据方向位R/ W, 0 表示发送(写),1 表示请求数据(读)。两者传输时正好组成一个字节。 (2)如果是读,主机释放SDA,由从机控制,但是时钟还是由主机控制。从机在时钟控制下,从SDA线上返回数据,一个字节后,主机控制SDA线发送ACK信号,如此循环直到读结束。 (3)如果是写,主机控制SDA线发送数据,一个字节后,释放SDA线,从机返回ACK信号,如此循环直到写结束。 (4)主机发送结束信号。
  3. I2C总线可挂载多个I2C设备,通过地址区分,地址有7位地址或10位地址,常见芯片通常是7位。
  4. 多个芯片通信时,I2C会进行仲裁(这部分个人不熟悉,需要了解可以认真看文档,或者直接找飞利浦的文档看)。

STM32 I2C

ST的I2C复杂且不好用,口碑不好,大家都在用软件模拟。 听说是为了避开飞利浦专利。 本次我们使用IO口模拟I2C,硬件I2C在摄像头中会使用

收音机模块

板载的收音机模块TEA5767是飞利浦的。 使用总线操控芯片,通常也就是操作芯片里面的寄存器 对于大部分芯片这个说法都是合适的。 TEA5767的功能,通过他的寄存器了解。 请参考微控设计网 DC版主翻译整理的《TEA5767HN低功耗立体声收音机接收器.pdf》

原理图

TEA5767模块通过I2C接口控制,输出双声道信号。天线使用3.5插卡音箱拉杆天线。 收音功能对电源要求较高,在电源端安排了一个100UF的钽电容,电源跟地串了磁珠。I2C信号线也串了100R电阻。 _images/pic22.pngTEA5767电路 TEA5767输出声音信号只有20多mv左右,不能直接推动喇叭,需要功放放大,我们使用的功放是TDA2822。 _images/pic16.jpg运放电路 电路使用TDA2822桥接方式,放大倍数较大。 输入端使用电阻分压,降低输入信号,防止放大过渡。 除了FM信号,另外一路DAC_SOUND也使用功放,通过拨动开关选择哪路音频信号输入到功放。

编码

建立两个驱动:mcu_i2c和dev_tea5767。 权且认为模拟I2C属于片上设备吧,放到mcu_dev目录。 tea5767属于板上设备,代码放到 board_dev。 代码请阅读源文件。

I2C关键代码

请从GIT上下载最新的代码,此处的代码只是教程,不是最新 I2C主要流程如下面函数,其他IO口初始化函数请自行查看代码。

s32 mcu_i2c_transfer(u8 addr, u8 rw, u8* data, s32 datalen)
{
    s32 i;
    u8 ch;

    //发送起始
    mcu_i2c_start();
    //发送地址+读写标志
    //处理ADDR
    if(rw == MCU_I2C_MODE_W)
    {
        addr = ((addr<<1)&0xfe);
        //uart_printf("write\r\n");
    }
    else
    {
        addr = ((addr<<1)|0x01);
        //uart_printf("read\r\n");
    }

    //uart_printf("i2c addr:%02x\r\n", addr);
    mcu_i2c_writebyte(addr);
    mcu_i2c_wait_ack();

    i = 0;
    while(i < datalen)
    {
        //数据传输
        if(rw == MCU_I2C_MODE_W)//写
        {
            ch = *(data+i);
            //uart_printf("i2c:w:%02x\r\n", ch);
            mcu_i2c_writebyte(ch);
            mcu_i2c_wait_ack();

        }
        else if(rw == MCU_I2C_MODE_R)//读
        {
            ch = mcu_i2c_readbyte();  
            mcu_i2c_ack();
            *(data+i) = ch;
            //uart_printf("i2c:r:%02x\r\n", ch);
        }
        i++;
    }

    //发送结束
    mcu_i2c_stop();
    return 0;
}
  1. 输入参数addr是七位地址,不包含读写位。
  2. 参数rw为读写标志。
  3. 首先发送start信号
  4. 根据读写标志处理addr,发送addr后等待ack。
  5. 进入数据传输,读写传输流程分开。
  6. 数据传输结束后发送stop信号。

mcu_i2c_transfer的实现,可见流程是完全按照I2C波形设计的。 值得注意的是函数参数的设计: 1 地址用7位,这个是根据实际设计,I2C地址就是7位的,很多代码将地址设计为8位,将读写标志也包含,个人认为不符合要求。 2 读写标志单独做一个参数,如此无论读写,都只是用一个函数。

TEA5767代码设计

TEA5767实际应用场景并不多,有兴趣的可自行研究。

调试

  • 首先调试I2C
  • 程序跑起来后,在等待I2C的ACK处超时。检查硬件,发现两个调试用的电阻没焊上。
  • 焊上后,还是不行,上逻辑分析仪,没抓到波形。
  • 加一个简单的测试程序,定时翻转两个IO口电平,用逻辑分析仪抓波形,抓不到波形。 _images/22.pngI2C无波形
  • 查看初始化代码,发现早上测试SPI的时候,把I2C初始化屏蔽了,自己坑自己了,打开如下。 _images/32.pngI2C测试波形
  • 抓到正常翻转波形。 _images/4.pngI2C翻转波形
  • 恢复程序,调试信息不再输出超时,但是读回来的数据全部是0x00,肯定不对,用逻辑分析仪抓到的协议也全部是0X00。 _images/5.pngI2C读数据不对
  • 逻辑分析仪能抓到发送波形,程序应该没什么问题。 问题应该是芯片或者I2C接口,再查看,发现PCB上的两个I2C上拉电阻好像有问题,虚焊。重新焊接后,I2C通信正常。 _images/6.pngI2C虚焊

TEA5767

  • 控制 通过I2C控制TEA5767,读写不带寄存器地址。 读,则连续读出5个字节,写,同样一次性写五个字节,但是五个字节数据意义不一样。具体见《TEA5767HN低功耗立体声收音机接收器.pdf》。
  • 寻台 TEA的操作主要是寻台,提供两个函数dev_tea5767_auto_search和dev_tea5767_search。
dev_tea5767_auto_searc 芯片自动寻台,但是寻台并不是很准,寻台成功后,要判断信号强度。以免错寻。
dev_tea5767_search 程序寻台,直接设置一个频率,延时后读信号强度,不符合要求则再设置下一个频率。

测试函数

在main.c中进行测试,首先要初始化I2C接口,再打开tea5767,然后强制设置一个频率(请设置当地FM电台频率),在循环内,当按键按下时,开始寻台。

	mcu_i2c_init();

	//mcu_timer_init();
	dev_key_init();
	dev_buzzer_init();

	dev_tea5767_open();
	dev_tea5767_setfre(97100);

	while (1)
	{
		s32 key;
		key = dev_key_scan();
		if(key == 0)
		{
			GPIO_ResetBits(GPIOG, GPIO_Pin_0
                          | GPIO_Pin_1 | GPIO_Pin_2| GPIO_Pin_3);
			//dev_buzzer_open();
			dev_tea5767_search(1);
		}
		else if(key == 1)
		{

			GPIO_SetBits(GPIOG, GPIO_Pin_0
                           | GPIO_Pin_1 | GPIO_Pin_2| GPIO_Pin_3);
			dev_buzzer_close();

		}
		Delay(5);

		/*测试触摸按键*/
		dev_touchkey_task();
		dev_touchkey_test();
	}
  1. 板载的TEA5767毕竟是一个小模组而已,性能无法和收音机相比。
  2. 电脑电源会带来干扰,降低收音机灵敏度,用充电宝供电并且断开与电脑所有连接,效果会提升不少。
  3. 配套的天线只能做功能测试,如果效果不好,可以在天线尾端接一段导线,并且将导线挂到高处。导线并不是越长越好,太长反而会引入其他干扰。按照FM的波长,天线总长65厘米左右,实测接一段60厘米的导线效果不错。
  4. 空旷处(窗户边)肯定比室内效果要好。
  5. 网络、摄像头、USB、SD卡、TFT LCD屏等,在运行时,都会发射干扰,降低收音机灵敏度。如要解决这个问题,需要增加屏蔽措施,考虑毕竟只是一块开发板,决定不做如此复杂,而且经过测试,在收音机信号良好的情况下,干扰影响不大。
  6. 通过WM8978播放收音比TDA2822效果要好(工作室没能力调音,TDA2822单声道,WM8978立体声)。

思考

多I2C控制器和多个I2C外设之间的交叉组合,如何编写驱动? 在此提前说一下: 要有一点面向对象思想,I2C控制器是一个对象,I2C设备是一个对象。 更重要的是,I2C控制器驱动(代码)也是一个对象,I2C设备的驱动(代码)当然也可以认为是一个对象。

GITHUB仓库最新代码已经实现,请自行查阅

end

DAC-波形-声音的真相

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


人机交互的电子产品经常需要语音提示。如果没有语音外设,可以通过一个DAC输出波形,经简单放大后就能发出声音。 如果音源干净清晰,电路设计好,音质还是非常不错的。 台系(华邦等)的语音芯片通常就是DAC输出音乐。大家小时候用的音乐贺卡,就是用这些芯片制作的。

DAC是什么?

DAC是数字模拟转换器(英语:Digital to analog converter,英文缩写:DAC),是一种将数字信号转换为模拟信号(以电流、电压或电荷的形式)的设备。 上面的定义比较抽象,在单片机来说,形象的说法是:

给一个在DAC位数范围内的值,这个值就是数字量,DAC就根据参考电压,将其转换为电压值,在指定的管脚上输出一个电压,也即是模拟量
  1. 参考电压Vref:DAC转换后输出的最高电压,DAC输出范围0~Vref。精度也是根据参考电压计算。
  2. DAC位数:DAC的关键性能,位数即是DAC精度,也是DAC的输出步进。通常有8位、10位、12位等。例如12位,即是说可以将输出精确到:Vref/0xfff。一个12位的DAC在3.3V参考电压下,输出可以精确到0.805mv。将0X01送到DAC,管脚将输出0.805mv;将0x02送到DAC,管脚将输出1.61mv;将0xfff送到DAC,将输出Vref。

STM32 DAC

查看《STM32F4xx中文参考手册.pdf》 STM32F4系列DAC功能特性如下: _images/pic1.pngDAC特性

功能框图如下,从图可以看出:

  • DAC可以用软件触发、定时器触发、外部IO触发。
  • DAC可以有DMA。
  • 最下方的数模转换器,在控制逻辑控制之下,根据输入电压,在DAC_OUT上输出DAC电压。

_images/pic23.pngDAC通道框图 硬件上使用PA5作为DAC输出测试。 在《STM32F407_数据手册.pdf》管脚描述表格中有说明PA5是DAC2的输出管脚。 _images/PA5DAC.pngPA5DAC

声音

声音是一种波。在电子上,波,就是不同电压值在时间上的序列。 因此,在DAC管脚上,一直持续输出不同的电压值,即可形成一列波,这列波通过放大,通过喇叭转换,震动空气,就变成了声波。 通常的CD音乐采样频率时44.1K,属于高保真。但是实际上,只要8K的采样频率,声音还原质量就很好了。儿童玩具、声音贺卡的声音通常就是8K。 8K采样频率,每个样点间隔就是1s/8k=125us。 因此,将一个8K采样的声音文件,每125us读取一个声音文件里面的样点,在dac上输出,就可以还原声音了

编码调试

调试分三步:

  1. 先调试DAC输出正确电压。
  2. 再调试播放一段内嵌在程序的声音。
  3. 最后调试播放一个WAV声音文件(这一步暂时不做,等文件系统跟SD卡驱动做好后再调试,反正是纯软件调试,不影响验证硬件)。

DAC调试

首先要让DAC能输出指定电压值。 添加mcu_dac.c和mcu_dac.h到工程。

  • 初始化
/**
 *@brief:      mcu_dac_open
 *@details:    打开DAC控制器
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
s32 mcu_dac_open(void)
{
	GPIO_InitTypeDef GPIO_InitStructure;
    DAC_InitTypeDef DAC_InitType;

    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE);//----使能 PA 时钟
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_DAC, ENABLE);//----使能 DAC 时钟

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AN;//---模拟模式
    GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_NOPULL;//---下拉
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);//---初始化 GPIO

    DAC_InitType.DAC_Trigger=DAC_Trigger_None;  //---不使用触发功能 TEN1=0
    DAC_InitType.DAC_WaveGeneration=DAC_WaveGeneration_None;   //---不使用波形发生
    DAC_InitType.DAC_LFSRUnmask_TriangleAmplitude=DAC_LFSRUnmask_Bit0;
    DAC_InitType.DAC_OutputBuffer=DAC_OutputBuffer_Disable ;        //---输出缓存关闭
    //DAC_InitType.DAC_LFSRUnmask_TriangleAmplitude = DAC_TriangleAmplitude_4095; //噪声生成器
	DAC_Init(DAC_Channel_2,&DAC_InitType); //---初始化 DAC 通道 2    

	DAC_Cmd(DAC_Channel_2, ENABLE); //---使能 DAC 通道 2
    DAC_SetChannel2Data(DAC_Align_12b_R, 0); //---12 位右对齐数据格式   输出0

		return 0;
}

上面函数是打开DAC代码,其实也就是初始化配置DAC。 和前面的定时器输出和定时器输入一样,除了使用DAC外设,还需要用IO口。

16~20行,将IO口PA5配置为模拟功能。 22行,配置DAC不使用触发。 23行,不使用波形发生器,DAC可以产生三角波等波形。 24行设置屏蔽/幅值选择,只有用波形发生器才有用。 25行禁止输出缓存。 27行执行配置。
  • 输出电压
/**
 *@brief:      mcu_dac_output
 *@details:    设置DAC输出值
 *@param[in]   u16 vol, 电压,单位MV,0-Vref  
 *@param[out]  无
 *@retval:     
 */
s32 mcu_dac_output_vol(u16 vol)
{

    u32 temp;

    temp = (0xfff*vol)/3300;

    MCU_DAC_DEBUG(LOG_DEBUG, "\r\n---test dac data:%d-----\r\n", temp);

    DAC_SetChannel2Data(DAC_Align_12b_R, temp);//12 位右对齐数据格式
		return 0;
}

13行是电压计算,原理是:

配置值/电压 = 0XFFF/3.3V 配置值是我们要写到DAC的值,电压就是输入参数,单位是mv。 3300也就是3300mv; 0xFFF,因为我们的DAC是12位,也即是说,当我们设置DAC为0XFFF时,DAC输出3.3V。

17行调用函数将配置值写到DAC。

  • 测试程序
s32 mcu_dac_test(void)
{
    uart_printf("\r\n---test dac!-----\r\n");

    mcu_dac_open();
    mcu_dac_output_vol(1500);//1.5v
    while(1);
}

程序设计输出1.5V,测试输出管脚,电压为1.492V,基本准确,偏差0.01V,这个偏差有可能是基准电压,也就是我们的3.3V有偏差。 实测3.3V,只有3.28V,偏差0.02V。如果要求不是很严格的场合,基本算正常。

播放语音调试

在写语音播放代码之前要记住以下几点:

  1. 是app调用DAC声音驱动播放声音,还是DAC声音驱动去找语音数据。
  2. 根据1,请问是APP提供声音数据给DAC驱动还是DAC声音去找声音数据?
  3. 要播放一个保存在SD卡中的8K采样频率的WAV文件,请问:SD卡,8K采样,WAV,这三个参数跟DAC声音驱动是否有关?

对于这几点,个人看法如下:

DAC声音驱动只实现将一定格式的声音数据转换为声音。 格式包含什么呢?采样频率,单声道还是多声道,多少位,这三个参数是DAC需要的。 文件格式是WAV还是PCM还是MP3,跟DAC声音驱动没关系,至于你是放在SD卡还是U盘,那更加没关系了。 这些事情,应该由语音播放中间层处理。

那么 DAC sound驱动要提供什么接口呢?

init—-初始化设备 open—-打开设备,意味则要用这个设备 close—-关闭设备 setting—设置,采样频率,声道,位宽(当然,对于DACsound来说只支持单声道,位宽也是固定的) 提供一个控制接口—-控制启动播放,暂停,停止,查询状态 最后一个接口就是填充数据,如何填充?请思考。

以上的问题在本节暂时不处理,后面等我们做完WM8978的驱动,两个声音驱动一起分析,对于一个声音驱动应该做成什么样子,就更加清晰明了了。 现在先使用最快的速度编写一套代码,让硬件发出声音,以便硬件改版,软件架构问题后续慢慢优化

  • 语音播放流程

播放DAC语音,就是使用DAC和IO口还有定时器的配合。

  1. 初始化DAC和IO。
  2. 定时器设置为125us中断一次。
  3. 在定时器中断中读取语音数据,并用DAC输出电压。
  4. 重启定时器,循环3,直到语音播放结束。
  • 声音数据准备

现在还没有调试WAV解码,也没有完成SD卡文件系统。只好将一段声音内嵌到代码内,这样也可以避免其他模块干扰,只验证DAC播放语音功能。

如何将一段声音内嵌到代码?

  • 代码驱动说明

在board_dev文件夹创建dacsound驱动源码文件:dev_dacsound.c、dev_dacsound.h

在mcu_timer驱动中增加定时器3初始化和中断处理函数,定时125us。 定时器前面已经学习,不再累赘 在中断中调用dev_dacsound_timerinit函数输出DAC电压。

调用dev_dacsound_open初始化dacsound功能。 调用dev_dacsound_play开始播放,函数内开启了定时器。 然后进入主要处理函数dev_dacsound_timerinit,这个函数在定时中断中调用,125us执行一次。

s32 dev_dacsound_timerinit(void)
{
    u8 data1 = 0, data2 = 0;
    s16 data = 0;
    u16 tmp;

	data1 = BeepData[soundindex++];
	data2 = BeepData[soundindex++];
	/*要注意,读到的数据是S16,正负值*/
	data = (s16)((data2 << 8) | data1);
	tmp = (data+0X7FFF)>>4;//12位DAC
	//uart_printf("%04x ", tmp);
	mcu_dac_output(tmp);

	if(soundindex >= BEEP_DATA_LEN)
	{
		uart_printf("dac sound play finish!");
		/*停止定时器*/
		mcu_tim3_stop();
	}
}

处理过程并不复杂,读取两个字节数据,组成一个16位数据,丢到DAC。 需要注意的是:

  1. 声音数据是s16,也就是最高位是正负标志。但是我们的DAC可不支持负数,因此需要将波形直流电平(波形水平中间线,类似X轴),由0V抬高,抬高多少呢?抬高到最高电压的一半,也就是0X7FFF,我们直接加上0X7FFF的偏移。
  2. 我们的DAC是12位的,数据是16位的,数据右移4位匹配。
  3. 本算法有音频失真,请问原因是什么?最新处理方法请查看github上持续更新的代码。

还要记得在stm32f4xx_it.c添加中断入口

void TIM3_IRQHandler(void)
{
    mcu_tim3_IRQhandler();
}
  • 测试 修改main.c,第3行打开dacsound,第12行,当按下按键时,播放语音。
/* Infinite loop */
	mcu_uart_open(3);
	wjq_log(LOG_INFO, "hello word!\r\n");
	mcu_i2c_init();
	dev_key_init();
	//mcu_timer_init();
	dev_buzzer_init();
	dev_tea5767_init();
	dev_dacsound_init();

	dev_key_open();
	dev_dacsound_open();

	//dev_tea5767_open();
	//dev_tea5767_setfre(105700);

	while (1)
	{
		/*驱动轮询*/
		dev_key_scan();

		/*应用*/
		u8 key;
		s32 res;

		res = dev_key_read(&key, 1);
		if(res == 1)
		{
			if(key == DEV_KEY_PRESS)
			{
				//dev_buzzer_open();
				dev_dacsound_play();
				GPIO_ResetBits(GPIOG, GPIO_Pin_0
                                | GPIO_Pin_1 | GPIO_Pin_2| GPIO_Pin_3);
				//dev_tea5767_search(1);
			}
			else if(key == DEV_KEY_REL)
			{
				//dev_buzzer_close();
				GPIO_SetBits(GPIOG, GPIO_Pin_0
                                | GPIO_Pin_1 | GPIO_Pin_2| GPIO_Pin_3);
			}
		}

		Delay(1);

		/*测试触摸按键*/
		//dev_touchkey_task();
		//dev_touchkey_test();
	}

现在应该能听到声音了。

思考

现在我们仅仅是验证了DAC播放语音功能。 如何解码WAV? dacsound提供什么接口? 使用什么机制播放? 这些问题我们等WM8978调试之后优化完成。


end

SPI-SPI FLASH

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


前面我们调试了第一条总线——I2C总线,在嵌入式领域还有另外一条常用总线——SPI。 现在我们就来调试SPI 和 SPI FLASH 。

SPI总线

SPI是串行外设接口(Serial Peripheral Interface)的缩写。 大概资料,百度百科 https://baike.baidu.com/item/SPI%E6%8E%A5%E5%8F%A3/2527392

  1. SPI通常使用4根线连接。 (1)MOSI – 主器件数据输出,从器件数据输入(master out slave in) (2)MISO – 主器件数据输入,从器件数据输出(master in slave out) (3)SCLK –时钟信号,由主器件产生。 (4)NSS – 从器件使能信号,由主器件控制,通常叫CS、片选。
  2. SPI根据时钟极性CPOL与时钟相位CPHA的不同,有4钟工作模式。
  3. SPI使用多个片选管脚就可连接多个从设备。

STM32 SPI控制器

在《STM32F4xx中文参考手册.pdf》“27 串行外设接口 (SPI)”章节有详细说明。 STM32的SPI控制器框图如下: _images/pic11.pngSPI控制器 从中可以看出:

1 移位寄存器只有一个,因为接受和发送是同时进行的。发送从右边出去,接收从左边进来。 2 4根引脚,MOSI、MISO、SCK、NSS(CS)

SPI的大概工作过程就是:

  1. 主设备将对应从设备CS拉低,使能从设备。
  2. 主设备输出时钟信号,主设备移位寄存器的数据按BIT从MOSI上输出,从设备收到BIT后,保存到自己的移位寄存器,同时将自己移位寄存器中的数据从MISO上输出,主设备收到后保存在移位寄存器中。 如此循环,8个BIT传输结束,进行读写操作,然后进行下一个字节的传输。
  3. 传输结束,将从设备片选拉高,结束。 _images/13.pngSPI传输 四种工作模式见参考手册图273。
  4. SPI时钟在《STM32F407_数据手册.pdf》有描述,SPI3最高可以达到21Mbit/s。 _images/23.pngSPI最快速度 在表格11中可以看到APB1的时钟最快42M,SPI3就挂载APB1上,为了达到最快的21M,必须使用2分频。 _images/33.pngSPI分频

前面说到,SPI有4中工作模式,在STM32的参考手册也有说明。

  1. CPOL是时钟极性,如果为1,则是先输出低再输出高。0则相反。
  2. CPHA是时钟相位,如果为1,则是180度。0则是0度。 通俗的说,如果是1,就在时钟的第二个边沿采样。 如果是0,就在时钟的第一个边沿采样。

_images/pic41.png时钟图

从上图也可以看到一个标准的SPI通信时序是怎么样的。 主设备输出时钟,并在MOSI上输出数据。 从设备在MISO上输出数据。 在通信期间,NSS(CS)保持低电平。

SPI FLASH

底板使用的SPI FLASH是MX25L3206EZNI-12G 。 我们就通过这个芯片的规格书《MX25L3206E_DS_EN.pdf》学习SPI FLAHS。

  1. 从名字通常能看出容量大小,32Mbit/8=4Mbyte,我们通常使用Byte,因此这个芯片只有4M,并不是32M,如果别人跟你说32M的FLASH,要搞清楚单位,很可能是32Mbit。
  2. 文档开头会描述性能,软件需要关心的是: (1)工作模式,本芯片支持Mode 0 和Mode3。 (2)1024个sector,每个sector有4K。每个sector都可以单独擦除。 (3)64个BLOCK,每个BLOCK 大小64K,也就是说,一个BLOCK有16个SECTOR。BOLOCK也可以整体擦除。 (4)可以page编程,一个page有256字节。 _images/41.pngSPI FLASH特性
  3. 之后是PERFORMANCE跟SOFTWARE FEATURES,主要是一些参数特性,例如擦除时间等。一般来说不用太关注。除非系统有要求。 比较影响性能的也就是擦除时间。有些厂家的会比较慢。其实相对CPU速度来说,擦除FLASH是一个很慢的过程。 部分FLASH会有额外性能,例如有OTP区,有加密区等等。
  4. FLASH的组织需要关注一下,特别是在FLASH进行替代的时候,组织模式一定要一样。也就是说BLOCK、SECTOR、PAGE的分布要一致。有些芯片只有前面2个BLOCK可以页操作,后续的只能sector操作。 _images/51.pngSPI flash组织
  5. 之后需要认真关注的是命令。命令就是操作FLASH时主机发给FLASH的指令。如何使用后面会说明。 _images/61.pngSPI flash命令
  6. 时序分析 前面几个是SPI时序,一般不看,遇到问题挂示波器才会对比一下。 _images/7.pngSPI falsh时序章节 但是到后面就是命令流程了,也就是说明命令如何使用,FLASH如何操作,本处挑两个说说 _images/8.pngSPI falsh读状态 上图是读FLASH 的状态,操作过程就是:
  1. 主机拉低CS信号,使能FLASH。
  2. 主机将命令05发送给FLASH,这时候SO属于高阻状态,说明FLASH是不回数据的,主机也不需要FLASH回。主机会读到一个0XFF。
  3. 主机继续发送时钟,但是SI线上发送什么数据无所谓,通常我们发送0XFF。此时FLASH就会回数据给主机了。

_images/9.pngSPI falsh写状态 上图是写状态的,大家应该能看懂了,整个过程FLASH没有在SO线上返回数据。 _images/10.pngSPI falsh读数据 上图是读数据,与读状态不同的是,CPU要多发送24bit地址。 在FLASH的规格书中,图29说明了如何编程和擦除,其实也就是前面命令时序的组合。 _images/111.pngSPI falsh擦除流程

  1. 规格书最后就是一些性能参数,封装等信息了。基本与编程无关。
  2. 除了以上特性之外,所有FLASH都共一个特性: FLASH上用于存储数据的每一个BIT,只能由1改写为0。 例如原来FLASH上某个BYTE=0xff,可以将其改写为0x00-0xff的任何值。如果某个BYTE=0x0f,就只能修改为0x00-0x0f之间的值。 擦除可以将一个page、sector、block一次性全部改写为0XFF。 因此,通常在写之前都会进行读出flash、改写数据、擦除flash、写回flash,一共四步操作。完整的流程比较消耗时间。根据特性可以进行优化,例如,知道数据是0XFF,就可以直接改写。
  3. 每次操作FLASH,都是先发一个命令,再进行数据通信。在发命令前,要有一个CS的下降沿。

原理图

屋脊雀F407硬件在底板和核心板上,各配置了一片SPI FLASH。 底板配置的是MX25L3206EM2I,底板的电路在SPI信号上进行了阻容滤波处理。 _images/pic24.png底板SPIFLASH原理图 核心板配置的是W25Q64FVSI(或JVSI),核心板FLASH离CPU较近,没有加阻容滤波处理,这样也能减少空间,毕竟核心板器件还是比较密集。 _images/pic33.jpg核心板SPIFLASH原理图 这两个SPI FLASH都是接在SPI3控制器上,SPI3控制器还是外扩接口的SPI控制器,也即是说,一个SPI上可能接有3个设备或更多。 为什么要配置两片SPI FALSH?当然不是堆硬件,是为了模拟一个情景:多个SPI总线上挂载多个器件,如果你写的SPI跟SPI FLASH驱动不能适应这样的场景,我觉得不是一个好程序。我们提供的源码,就是要处理这种情况。

驱动设计

需要设计两个驱动,一个是cpu上的SPI控制器的驱动;另外一个则是板上外设FLASH的驱动。 分别命名为mcu_spi和dev_flash。具体代码见例程。 在做驱动前我们要认识以下概念:

  • SPI 控制器:STM32上的SPI3就是一个控制器。在程序中就是一些参数或者寄存器。 SPI驱动:为了用SPI3,我们会写一段代码,这段代码就是SPI驱动。
spi驱动是为了配套SPI控制器,在STM32中有多个SPI控制器,SPI驱动有几套?
  • SPI FLASH设备:设备就是实物,我们的硬件有两个SPI FLASH设备。 SPI FLASH驱动:我们写的代码,操作(读写)FLASH的代码,就是驱动。
我们有两个FLASH设备,写几个驱动?

SPI驱动要怎么设计

  1. SPI能干什么。

SPI属于全双工总线。 发送时钟信号,在发送数据的同时会收到数据。这个特性反映在SPI驱动上就是

发送出去一个字节,就会收到一个字节。 接收一个字节,就需要发送一个字节。 因此我们认为,SPI_WRITE或者SPI_READ这样单独读写的接口不符合SPI特性

SPI可以运行在不同的频率和模式。 SPI的CS可以控制。

  1. 用SPI的程序想要SPI干什么?

我们可能用SPI控制LCD,SPI FLASH,RF24L01等设备。 对于这些设备,有只写操作的,也有读写都要操作的。 对于CS管脚,在占用SPI控制器时,有可能要变化电平,例如SPI FALSH,在发送命令时就需要一个CS下降沿。

  1. SPI驱动和SPI设备的关系

在我们的硬件上,只用一个SPI控制器SPI3,配合3根CS线。一套SPI驱动如何控制三个设备呢? 千万不要将CS的控制放到SPI FALSH驱动中。CS属于SPI控制器的一部分。

如果不将CS控制和SPI控制器绑定,配合多个CS时,编写代码很容易造成SPI控制器冲突

要控制多个SPI设备,在接口传入一个参数表明操作哪个接口即可。

因此接口我们设置如下:

s32 mcu_spi_init(void); s32 mcu_spi_open(SPI_DEV dev, SPI_MODE mode, u16 pre); s32 mcu_spi_close(SPI_DEV dev); s32 mcu_spi_transfer(SPI_DEV dev, u8 *snd, u8 *rsv, s32 len); s32 mcu_spi_cs(SPI_DEV dev, u8 sta); 从上到下分别是:初始化,打开(占用),关闭(释放),传输,CS控制。

我们看看初始化代码

s32 mcu_spi_init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    SPI_InitTypeDef SPI_InitStruct;

    //初始化片选,系统暂时设定为3个SPI,全部使用SPI3
    //DEV_SPI_3_1, 核心板上的SPI FLASH
	RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB, ENABLE);
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_14;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT;
    GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP;
    GPIO_Init(GPIOB, &GPIO_InitStructure);
	GPIO_SetBits(GPIOB,GPIO_Pin_14);

    //DEV_SPI_3_2, 底板的SPI FLASH
    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOG, ENABLE);
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_15;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT;
    GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP;
    GPIO_Init(GPIOG, &GPIO_InitStructure);
	GPIO_SetBits(GPIOG,GPIO_Pin_15);

	//DEV_SPI_3_3, 核心板外扩SPI
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
    GPIO_Init(GPIOG, &GPIO_InitStructure);
	GPIO_SetBits(GPIOG,GPIO_Pin_6);

    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB, ENABLE);

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3|GPIO_Pin_4|GPIO_Pin_5;//---PB3~5
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF;//---复用功能
    GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;//---推挽输出
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz;//---100MHz
    GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP;//---上拉
    GPIO_Init(GPIOB, &GPIO_InitStructure);//---初始化

    //配置引脚复用映射
    GPIO_PinAFConfig(GPIOB, GPIO_PinSource3, GPIO_AF_SPI3); //PB3 复用为 SPI3
    GPIO_PinAFConfig(GPIOB, GPIO_PinSource4, GPIO_AF_SPI3); //PB4 复用为 SPI3
    GPIO_PinAFConfig(GPIOB, GPIO_PinSource5, GPIO_AF_SPI3); //PB5 复用为 SPI3

    RCC_APB1PeriphClockCmd(RCC_APB1Periph_SPI3, ENABLE);// ---使能 SPI3 时钟
    // 复位SPI模块
    SPI_I2S_DeInit(SPI_DEVICE);

    SPI_InitStruct.SPI_Direction = SPI_Direction_2Lines_FullDuplex;//---双线双向全双工
    SPI_InitStruct.SPI_Mode = SPI_Mode_Master;//---主模式
    SPI_InitStruct.SPI_DataSize = SPI_DataSize_8b;//---8bit帧结构
    SPI_InitStruct.SPI_CPOL = SPI_CPOL_High;//----串行同步时钟的空闲状态为低电平
    SPI_InitStruct.SPI_CPHA = SPI_CPHA_2Edge;//---数据捕获于第1个时钟沿
    SPI_InitStruct.SPI_NSS = SPI_NSS_Soft; //---SPI_NSS_Hard; 片选由硬件管理,SPI控制器不管理
    SPI_InitStruct.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_4;  //---预分频
    SPI_InitStruct.SPI_FirstBit = SPI_FirstBit_MSB;//---数据传输从 MSB 位开始
    SPI_InitStruct.SPI_CRCPolynomial = 7;//---CRC 值计算的多项式

    SPI_Init(SPI_DEVICE, &SPI_InitStruct);

    //SPI_SSOutputCmd(SPI_DEVICE, DISABLE);
    DevSpi3Gd = -1;
    return 0;
}
6~30行,初始化使用SPI3控制器的3个SPI接口的CS脚,初始化为输出,并且输出高电平。 34~44,初始化SPI控制器3个引脚,也就是把IO配置为AF功能,并且配置为SPI3的AF功能。 46,打开SPI时钟。 48,复位SPI3控制器。 50~60,配置SPI3控制器,每个配置什么意思在代码注释都说明了。

根据硬件定义3个SPI设备号,3个SPI都使用SPI3控制器

typedef enum{
	DEV_SPI_NULL = 0,

	DEV_SPI_3_1 = 0X31,//核心板上的SPI使用SPI3,定义为SPI_3_1
	DEV_SPI_3_2,	//底板板上的SPI使用SPI3,定义为SPI_3_2
	DEV_SPI_3_3,		//外扩的SPI定义为SPI_3_3
}SPI_DEV;

传输是最关键函数 看代码,入口参数3个:

snd和rsv是指针,发送数据从snd取,接收到的数据保存到rsv; len是数据长度。 snd或者rsv其中一个可以为NULL,但是不能全部为0, 如果rsv为NULL,则意味着只是想发送数据,接收到的数据丢弃; 如果snd为空,则意味着只想接收数据,函数会默认发送0XFF: 实际应用中,有外设是不允许默认发送0XFF的,例如触摸控制芯片XPT2046, 如果在读数据时发送0XFF,会立刻启动XTP2046AD转换,造成读到的触摸参数错乱。 最新的SPI通信代码请从GIT上提取(上面的代码会持续更新)
s32 mcu_spi_transfer(u8 *snd, u8 *rsv, s32 len)
{
    s32 i = 0;
    s32 pos = 0;
    u32 time_out = 0;
    u16 ch;

    if( ((snd == NULL) && (rsv == NULL)) || (len < 0) )
    {
        return -1;
    }

    /* 忙等待 */
    time_out = 0;
    while(SPI_I2S_GetFlagStatus(SPI_DEVICE, SPI_I2S_FLAG_BSY) == SET)
    {
        if(time_out++ > MCU_SPI_WAIT_TIMEOUT)
        {
            return(-1);
        }
    }

    /* 清空SPI缓冲数据,防止读到上次传输遗留的数据 */
    time_out = 0;
    while(SPI_I2S_GetFlagStatus(SPI_DEVICE, SPI_I2S_FLAG_RXNE) == SET)
    {
        SPI_I2S_ReceiveData(SPI_DEVICE);
        if(time_out++ > 2)
        {
            return(-1);
        }
    }

    /* 开始传输 */
    for(i=0; i < len; )
    {
        // 写数据
        if(snd == NULL)/*发送指针为NULL,说明仅仅是读数据 */
        {
            //uart_printf("--1--");
            SPI_I2S_SendData(SPI_DEVICE, 0xff);
        }
        else
        {
            ch = (u16)snd[i];
            SPI_I2S_SendData(SPI_DEVICE, ch);
            //uart_printf("s%02x ", ch);
        }
        i++;

        // 等待接收结束
        time_out = 0;
        while(SPI_I2S_GetFlagStatus(SPI_DEVICE, SPI_I2S_FLAG_RXNE) == RESET)
        {
            time_out++;
            if(time_out > MCU_SPI_WAIT_TIMEOUT)
            {
                return -1;
            }    
        }
        // 读数据
        if(rsv == NULL)/* 接收指针为空,读数据后丢弃 */
        {
            //uart_printf("--2--");
            SPI_I2S_ReceiveData(SPI_DEVICE);
        }
        else
        {
            ch = SPI_I2S_ReceiveData(SPI_DEVICE);
            rsv[pos] = (u8)ch;
            //uart_printf("r%02x ", ch);
        }
        pos++;

    }

    return i;
}
  1. 13到21,等待SPI控制器不忙。
  2. 23-32,读走SPI控制器里面的缓存数据。
  3. 进入FOR循环开始传输。
  4. 判断输入参数snd,如果为空(0),就说明只是想读数据,没数据发送。那么我们就自作主张,发送一个0XFF过去。
  5. 发送的同时,控制器就在接收数据了,53到60行等待接收完毕。
  6. 62行开始是接收数据,如果接收缓冲为空(0),我们就仅仅将数据读出来丢弃掉。

SPI FALSH驱动

SPI 驱动编写要考虑两方面:

SPI FLASH的功能实现。 SPI FALSH驱动架构。
1. 功能

FLASH功能包括,擦除,读,写,芯片ID等。 其中各操作还分page,sector,BLOCK,chip; 例如擦,可以擦1个sector,也可以擦1个BLOCK,或者是整片擦除。 读就没有要求,可以从任何地址读。 写也没有要求,可以从任何地址写。 但是为了方便APP使用,特别是文件系统,我们会封装sector擦、读、写函数。

2. 架构

设计SPI FLASH架构前要考虑的问题是:

  1. 多个FLASH挂在多个SPI上。我们的硬件就是两片FLASH挂在两个SPI接口上(同一个硬件SPI控制器)。
  2. FALSH型号自动识别,就算硬件更换了Flash,驱动也不需要修改。

最终的SPI FLASH驱动可以做到,一套SPI FLASH驱动,可以处理多个挂在不同SPI上的不同flash芯片。 要实现这个目标,主要要实现下面3个小目标:

  1. 驱动就是一个软件模块。
  2. 对上提供操作接口。
  3. 对下要求提供挂参数:哪个SPI?什么芯片?

因此我们设计驱动如下:

  • 接口
extern s32 dev_spiflash_readmorebyte(DevSpiFlash *dev, u32 addr, u8 *dst, u32 len);
extern s32 dev_spiflash_write(DevSpiFlash *dev, u8* pbuffer, u32 addr, u16 wlen);
extern s32 dev_spiflash_sector_erase(DevSpiFlash *dev, u32 sector_addr);
extern s32 dev_spiflash_sector_read(DevSpiFlash *dev, u32 sector, u8 *dst);
extern s32 dev_spiflash_sector_write(DevSpiFlash *dev, u32 sector, u8 *src);
extern s32 dev_spiflash_init(void);
extern s32 dev_spiflash_open(DevSpiFlash *dev, char* name);
extern s32 dev_spiflash_test(void);

各接口功能,看名称就知道啥意思了。

  • 芯片参数定义
/*SPI FLASH 信息*/
typedef struct
{
	char *name;
	u32 JID;
	u32 MID;
	/*容量,块数,块大小等信息*/
	u32 sectornum;//总块数
	u32 sector;//块大小
	u32 structure;//总容量

}_strSpiFlash;

/*
	常用的SPI FLASH 参数信息
*/
_strSpiFlash SpiFlashPraList[]=
{
	{"MX25L3206E", 0XC22016, 0XC215, 1024, 4096, 4194304},
	{"W25Q64JVSI", 0Xef4017, 0Xef16, 2048, 4096, 8388608}
};

对于各种芯片,抽象定义一个结构体,结构体成员包含了芯片信息。 然后定义一个列表数组,表明当前支持的芯片型号。当前我们只做了两个型号。 大家可以自己丰富这个表格。

  • 设备树定义
/*SPI FLASH设备定义*/
typedef struct
{
	char *name;//设备名称
	SPI_DEV spi;//挂载在哪条SPI总线
	_strSpiFlash *pra;//设备信息
}DevSpiFlash;

/*
	设备树定义
*/
#define DEV_SPI_FLASH_C 2//总共有两片SPI FLASH

DevSpiFlash DevSpiFlashList[DEV_SPI_FLASH_C]=
{
	/*有一个叫做board_spiflash的SPI FLASH挂在DEV_SPI_3_2上,型号未知*/
	{"board_spiflash", DEV_SPI_3_2, NULL},
	/*有一个叫做board_spiflash的SPI FLASH挂在DEV_SPI_3_1上,型号未知*/
	{"core_spiflash",  DEV_SPI_3_1, NULL},
};

设备树的意义就是告诉驱动,什么东西挂在什么地方。这样驱动就可以跟硬件剥离,也就能兼容更多硬件。 具体驱动设计请看源码,后续会对文档进行更详细更新 我们抽一个SPI FLASH驱动函数分析。

/**
 *@brief:      dev_spiflash_readJTD
 *@details:    读FLASH JTD号
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
static u32 dev_spiflash_readJTD(DevSpiFlash *dev)
{
    u32 JID;
    s32 len = 1;
    u8 command = SPIFLASH_RDJID;
    u8 data[3];

	mcu_spi_cs(dev->spi, 0);
    len = 1;
    mcu_spi_transfer(dev->spi, &command, NULL, len);
    len = 3;
    mcu_spi_transfer(dev->spi, NULL, data, len);
	mcu_spi_cs(dev->spi, 1);

    JID = data[0];
    JID = (JID<<8) + data[1];
    JID = (JID<<8) + data[2];

    return JID;
}

以上是读FLASH JTD函数

15行,拉低CS脚,使能对应的SPI FLASH设备。 17行,写数据,第3个参数为空,我们只是写命令,1个字节数据而已。 19行,读数据,读3个字节,第二个参数为空,函数将默认发送0XFF。 20行,拉高CS脚。

我们看看FLASH规格书中读JTD的时序图,大家对比代码好好体会体会。 _images/readjtd.pngreadjtd

  • 驱动和设备分离的好处 这样的驱动架构有什么好处呢? 请看测试程序,测试程序传入一个名称,就可以操作对应FLASH了。不用关心FLASH是什么型号,挂在什么地方。更加不会去操作CS管脚。
void dev_spiflash_test_fun(char *name)
{
    u32 addr;
    u16 tmp;
    u8 i = 1;
    u8 rbuf[4096];
    u8 wbuf[4096];
    u8 err_flag = 0;

	DevSpiFlash dev;

	s32 res;

    wjq_log(LOG_FUN, ">:-------dev_spiflash_test-------\r\n");
    res = dev_spiflash_open(&dev, name);
	wjq_log(LOG_FUN, ">:-------%s-------\r\n", dev.name);
	if(res == -1)
	{
		wjq_log(LOG_FUN, "open spi flash ERR\r\n");
		while(1);
	}
    i = 0;
    for(tmp = 0; tmp < 4096; tmp++)
    {
        wbuf[tmp] = i;
        i++;
    }
    //sector 1 进行擦除,然后写,校验。
    wjq_log(LOG_FUN, ">:-------test sector erase-------\r\n", addr);

    addr = 0;
    dev_spiflash_sector_erase(&dev, addr);
    wjq_log(LOG_FUN, "erase...");

    dev_spiflash_sector_read(&dev, addr, rbuf);;//读一页回来
    wjq_log(LOG_FUN, "read...");

    for(tmp = 0; tmp < dev.pra->sector; tmp++)
    {
        if(rbuf[tmp] != 0xff)//擦除后全部都是0xff
        {
            wjq_log(LOG_FUN, "%x=%02X ", tmp, rbuf[tmp]);//擦除后不等于0XFF,坏块    
            err_flag = 1;
        }
    }

    dev_spiflash_sector_write(&dev, addr, wbuf);
    wjq_log(LOG_FUN, "write...");

    dev_spiflash_sector_read(&dev, addr, rbuf);
    wjq_log(LOG_FUN, "read...");

    wjq_log(LOG_FUN, "\r\n>:test wr..\r\n");

    for(tmp = 0; tmp < dev.pra->sector; tmp++)
    {
        if(rbuf[tmp] != wbuf[tmp])
        {
            wjq_log(LOG_FUN, "%x ", tmp);//读出来的跟写进去的不相等
            err_flag = 1;
        }
    }

    if(err_flag == 1)
        wjq_log(LOG_FUN, "bad sector\r\n");
    else
        wjq_log(LOG_FUN, "OK sector\r\n");

	dev_spiflash_close(&dev);
}
测试

在main中初始化,按下按键则进行测试。

/* Infinite loop */
	mcu_uart_open(3);
	wjq_log(LOG_INFO, "hello word!\r\n");
	mcu_i2c_init();
	mcu_spi_init();
	dev_key_init();
	//mcu_timer_init();
	dev_buzzer_init();
	dev_tea5767_init();
	dev_dacsound_init();
	dev_spiflash_init();

	dev_key_open();
	//dev_dacsound_open();
	//dev_tea5767_open();
	//dev_tea5767_setfre(105700);

	while (1)
	{
		/*驱动轮询*/
		dev_key_scan();

		/*应用*/
		u8 key;
		s32 res;

		res = dev_key_read(&key, 1);
		if(res == 1)
		{
			if(key == DEV_KEY_PRESS)
			{
				//dev_buzzer_open();
				//dev_dacsound_play();
				dev_spiflash_test();
				GPIO_ResetBits(GPIOG, GPIO_Pin_0
                                  | GPIO_Pin_1 | GPIO_Pin_2| GPIO_Pin_3);
				//dev_tea5767_search(1);
			}
			else if(key == DEV_KEY_REL)
			{
				//dev_buzzer_close();
				GPIO_SetBits(GPIOG, GPIO_Pin_0
                                  | GPIO_Pin_1 | GPIO_Pin_2| GPIO_Pin_3);
			}
		}

		Delay(1);

		/*测试触摸按键*/
		//dev_touchkey_task();
		//dev_touchkey_test();
	}

调试

  • 1

代码写好后,调用FLASH测试程序就死机。第一行调试信息都没有输出。一进函数就死,基本上都是堆栈溢出问题,俗称栈爆了。 一般都是局部变量申请太大,造成堆栈溢出,或者是进函数前堆栈已经临界,函数没申请多少局部变量也会造成溢出。 测试函数申请了8K局部变量造成死机,根本原因是我们没有根据工程实际情况初始化堆栈。

void dev_spiflash_test(void)
{
    u32 addr;
    u16 tmp;
    u8 i = 1;
    u8 rbuf[4096];
    u8 wbuf[4096];
    u8 err_flag = 0;

堆栈在启动代码startup_stm32f40_41xxx.s的开头配置,默认仅仅配置了0X400字节栈。堆的默认配置也不大,都需要根据工程实际情况修改。

; Amount of memory (in bytes) allocated for Stack
; Tailor this value to your application needs
; <h> Stack Configuration
;   <o> Stack Size (in Bytes) <0x0-0xFFFFFFFF:8>
; </h>

Stack_Size      EQU     0x00000400

                AREA    STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem       SPACE   Stack_Size
__initial_sp

; <h> Heap Configuration
;   <o>  Heap Size (in Bytes) <0x0-0xFFFFFFFF:8>
; </h>

Heap_Size       EQU     0x00000200

                AREA    HEAP, NOINIT, READWRITE, ALIGN=3
__heap_base
Heap_Mem        SPACE   Heap_Size
__heap_limit

此处我们暂时将栈放大到16K,以便测试程序运行。 修改后测试程序正常运行。 类似的死机问题还有一个,而且经常会出现,那么就是一退出函数就死机。这样的问题通常都是因为在函数内操作内存越界,例如野指针啊,或者是写数组超出数组范围

  • 2

调试一个新IC外设,一般先调通能读芯片ID。 读ID失败,检查程序是否有笔误,如有,修正。 检查后,还是不行,怀疑硬件问题。 将SPI通信的4条PIN全部改为IO,全部输出高电平,用万用表检测。 然后全部输出低电平,用万用表检测。 同时检测FLASH其他管脚,发现WP电平不对。 检查原理图,发现原理图上WP脚连接有误,应该连接到VCC,但是原理图原来增加了1个下拉电阻用于调试,在焊接时不应该直接焊上,样板错误焊上了。 去掉电阻再量电压,1.7V,半高电平,估计芯片有问题了。 换另外一块样板,上电后测试,电平正常。

  • 3

恢复调试过程对驱动跟测试程序的修改,上电,正常读出FLASH ID。

  • 4

对sector 1进行擦,写,读操作,测试FLASH。测试结果:

hello word! board_spiflash jid:0xc22016 board_spiflash mid:0xc215 core_spiflash jid:0xef4017 core_spiflash mid:0xef16 :——-dev_spiflash_test——- spi flash type:MX25L3206E :——-board_spiflash——- :——-test sector erase——- erase…read…write…read… :test wr.. OK sector :——-dev_spiflash_test——- spi flash type:W25Q64JVSI :——-core_spiflash——- :——-test sector erase——- erase…read…write…read… :test wr.. OK sector

时序确认

经测试,SPI最快设置为SPI_BaudRatePrescaler_4,也就是PCLK(84M)/4=21M,SPI3理论最快速度。也可以正常通信。 使用DSLOGIC逻辑分析仪抓到的波形 _images/17.pngSPI falsh时序波形 放大后可以看到时钟频率时21.05M,但是同时也发现一个问题,在两个字节之间的间隔,很大,浪费通信时间,程序需要优化。 _images/18.pngSPI falsh时序波形间隔大 当然,系统目前还没有跑其他设备,例如I2S,USB,网口等,等全部跑起来,可能就因为干扰,SPI只能降速了。

思考

到此我们实现了一个基本算有点软件架构的SPI FLASH驱动。也为后面其他驱动编写做了一定铺垫。大家可以了解一些LINUX设备驱动的软件架构思想。宋宝华的书《LINUX设备驱动开发详解 基于最新的LINUX4.0内核》第12章。 对于SPI驱动,还要进一步改善。例如:后面我们会加上用IO口模拟SPI功能,模拟SPI跟硬件SPI如何统一接口?一个SPI LCD,既可以接到外扩的SPI3上,也可以接到模拟SPI上。LCD驱动要如何编写,才能灵活用于两种SPI?

这些,我们都会实现。 ~~ 这些,我们都已经实现。 本例程附带的代码,只是为了教程服务,虽然能用,但是架构不是最好的 如果要用于实际项目,请从GITHUB上下载最新代码,最新代码有很好的代码架构设计


end

SDIO-TF CARD

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


  • 本SDIO例程,测试过程中会将SD卡的数据直接删除,相当于将SD卡格式化,请使用一张没有数据的SD卡进行测试。 数据丢失后果自负。

SD卡是我们日常使用的电子设备,例如用在相机、个人数据存储。部分手机也会使用TF卡作扩展存储使用。

SD存储卡是一种基于半导体快闪记忆器的新一代记忆设备,由于它体积小、数据传输速度快、可热插拔等优良的特性,被广泛地于便携式装置上使用,例如数码相机、个人数码助理(外语缩写PDA)和多媒体播放器等。

SD卡标准使用SDIO接口通信,也支持SPI接口。在日常使用情景,例如相机、读卡器等,都是使用SDIO通信,SDIO使用4个数据线,速度比SPI要快。 SD卡使用SDIO口通信过程遵循SD卡规范,这个规范不简单,涉及到较多的命令交互和状态转换。通常芯片厂家都会提供相关例程,用户不需要重新开发SDIO通信程序。本例程通过移植ST官方的例程实现与SD卡通信。

关于SD卡协议细节,请自行学习研究。

SDIO接口

SDIO:安全数字输入输出接口。 STM32F407,原生支持。 关于SDIO接口,在《STM32F4xx中文参考手册.pdf》文档有说明。 _images/pic42.png特性

下图是SDIO框图, _images/pic31.png框图

这是一个总框图,主要说明外部连接。 对于SDIO适配器,并没有详细说明。我猜测原因是,其实SDIO是一个比较复杂的设备,类似USB。 通常这种复杂外设,我们都不会自己开发驱动,都是用官方提供的例程。

  1. 总线的通信基于命令和数据传输。其实这是大部分外设的相同之处。
  2. SDIO接口总共有6根线(4位位宽),其中命令只通过CMD线传输。因此在调试的时候如果命令正常,读写数据不正常,说明仅仅是数据线问题

SDID接口除了用于控制SD卡等卡之外,也可以控制SDIO接口的其他模块,例如SDIO接口的WIFI模块

SDIO接口存储卡

常见的卡有下面两种 SD 卡,俗称大卡。

SD卡是由松下电器、东芝和SanDisk联合推出,1999年8月发布。

_images/pic52.pngSD卡 TF卡,俗称小卡,2004年标准协会更名为micro SD卡。 _images/pic6.pngTF卡

其实在SD卡之前,最先出现的是MMC卡。 下图这种就是MMC卡,只有SD卡一半大小,通常我们都用一个卡尾拼成SD卡用。

MMC(Multi-Media Card,多媒体卡)由西门子公司Siemens和SanDisk于1997年推出。

_images/pic25.pngMMC卡

这些卡的协议基本都是兼容的。具体可以看我们资料包中提供的文档。 大部分文档是sandisk和SD卡协会编写的。 _images/pic7.png协议资料 话说这些资料全英文,一时半会儿看不懂。

原理图

屋脊雀4074开发板选用TF卡座,减少体积,这也是当前电子产品的趋势。 _images/pic17.jpg原理图

SDIO接口使用6根管脚。 CLK是通信时钟 CMD是命令串行通信线。 DATA数据线有四根。 右边的SD_CARD_DET_N是卡插入检测 SDIO接口,CLK不需要上拉电阻,其他5根IO需要加上拉电阻。

驱动设计

前面说到,SDIO是一个复杂的协议,SD卡、TF卡是一个较复杂设备。要完全弄清楚,非常不容易,更加不要说自己写一套了。 通常,这种复杂的协议,我们不自己开发,都是芯片厂提供驱动代码。

前面几个章节的调试,我们直接参考标准库接口,再根据参考手册,就可以进行编码了。 但是类似USB,SD卡,网络等较复杂的设备,通常做法是从官方提供的库入手。 特别是USB通信,个人基本不可能写一套库出来,也没这个必要。

移植调试

在移植之前,先对官方例程进行分析学习。

例程分析

在标准库下面有SD卡例程,路径如下

STM32F4xx_DSP_StdPeriph_Lib_V1.8.0\Project\STM32F4xx_StdPeriph_Examples\SDIO\SDIO_uSDCard

在目录下有一个readme.txt文件。看东西,先从readme入手。 从readme中可以看出,STM32F407芯片的SDIO的驱动在 STM32F4xx_DSP_StdPeriph_Lib_V1.8.0\Utilities\STM32_EVAL\STM3240_41_G_EVAL目录下, 名字叫stm324xg_eval_sdio_sd.c和stm324xg_eval.c。 其中stm324xg_eval.c仅仅提供了较底层的初始化。 看来主要代码都在stm324xg_eval_sdio_sd.c里面。 _images/14.pngstm324xg_eval_sdio_sd.c 例程还包括以下文件

路径:STM32F4xx_DSP_StdPeriph_Lib_V1.8.0\Project\STM32F4xx_StdPeriph_Examples\SDIO\SDIO_uSDCard
  • SDIO/SDIO_uSDCard/system_stm32f4xx.c STM32F4xx system clock configuration file
  • SDIO/SDIO_uSDCard/stm32f4xx_conf.h Library Configuration file
  • SDIO/SDIO_uSDCard/stm32f4xx_it.c Interrupt handlers
  • SDIO/SDIO_uSDCard/stm32f4xx_it.h Interrupt handlers header file
  • SDIO/SDIO_uSDCard/main.c Main program
  • SDIO/SDIO_uSDCard/main.h Main program header file

下一步,我们就浏览这六个文件,熟悉其中的流程与调用关系。 使用SI创建一个SDIO_EVAL工程,将以上提到的文件添加到工程,用SI分析程序相当方便。 为了防止无意中修改库文件,我们把文件拷贝一份

_images/24.pngSD卡例程源文件 从MAIN函数入手,程序首先对SD进行初始化。然后进行块测试,分别进行擦块,单块测试,多块测试。


  /*------------------------------ SD Init ----------------- */
  if((Status = SD_Init()) != SD_OK)
  {
    STM_EVAL_LEDOn(LED4);
  }

  while((Status == SD_OK) && (uwSDCardOperation != SD_OPERATION_END)
        && (SD_Detect()== SD_PRESENT))
  {
    switch(uwSDCardOperation)
    {
      /*-------------------------- SD Erase Test --------- */
      case (SD_OPERATION_ERASE):
      {
        SD_EraseTest();
        uwSDCardOperation = SD_OPERATION_BLOCK;
        break;
      }
      /*------------------- SD Single Block Test --------- */
      case (SD_OPERATION_BLOCK):
      {
        SD_SingleBlockTest();
        uwSDCardOperation = SD_OPERATION_MULTI_BLOCK;
        break;
      }       
      /*------------------- SD Multi Blocks Test --------- */
      case (SD_OPERATION_MULTI_BLOCK):
      {
        SD_MultiBlockTest();
        uwSDCardOperation = SD_OPERATION_END;
        break;
      }              
    }
  }

从函数SD_Error SD_Init(void)跟下去。 函数首先调用了SDIO底层初始化,然后读卡,进行Power On了,上电成功就初始化。 先看看SDIO底层初始化了什么。

SD_Error SD_Init(void)
{
  __IO SD_Error errorstatus = SD_OK;

  /* SDIO Peripheral Low Level Init */
   SD_LowLevel_Init();

  SDIO_DeInit();

  errorstatus = SD_PowerON();

void SD_LowLevel_Init(void)函数就在前面提到过的stm324xg_eval.c文件内,也就是官方的DEMO板的初始化文件。 我个人觉得这些代码应该放到mcu_sdio驱动内。 这个文件与SD卡有关的也就四个函数,前面两个是SDIO口初始化,后面两个是SDIO使用DMA的初始化。

void SD_LowLevel_DeInit(void);
void SD_LowLevel_Init(void);
void SD_LowLevel_DMA_TxConfig(uint32_t *BufferSRC, uint32_t BufferSize);
void SD_LowLevel_DMA_RxConfig(uint32_t *BufferDST, uint32_t BufferSize);

回到SD_Init(void),后续调用的函数全部都在本文件了。 再回到main,看测试程序在哪里。 就放在main函数下面,这些函数我倒觉得应该放在SD卡驱动里面。当然,个人意见。 _images/62.pngSD卡测试函数 其他几个文件,前面例程已经在使用,我们就对比一下看看差异。

stm32f4xx_conf.h,有差别,但是与SD卡无关。 _images/71.pngstm32f4xx_conf.h差异

stm32f4xx_it.c,多了两个中断处理,移植的时候记得拷贝。 _images/810.pngstm32f4xx_it.c差异

stm32f4xx_it.h多一个SDIO中断的声明,无关紧要 _images/97.pngstm32f4xx_it.h差异

system_stm32f4xx.c,差别较大,但是很多都是为了兼容其他芯片的条件编译。 但是这个文件主要是初始化时钟,如果移植后调试不顺利,有问题,需要回来认真分析这个文件。 _images/101.pngsystem_stm32f4xx.c差异

OK,下一步我们就开始移植了。

移植调试过程

  1. 建立一个mcu_sdio驱动,将stm324xg_eval.c里的四个函数作为mcu_sdio驱动,拷贝到mcu_sdio.c。 stm324xg_eval.h里面与SD卡相关的定义也要拷贝到mcu_sdio.h里面。
  2. stm324xg_eval_sdio_sd.c跟stm324xg_eval_sdio_sd.h我们就直接使用。 作为board_dev驱动。
  3. 将main.c里面的SD卡测试程序拷贝到stm324xg_eval_sdio_sd.c最后,作为驱动测试程序使用。 原来的测试程序使用了一些官方硬件上的LED,我们全部改为串口调试信息输出。
  4. 有一个需要特别关注的地方就是static void NVIC_Configuration(void)函数,这个函数的第一行就设置了NVIC的分组,也就是中断优先级分组,这个是ARM核相关。关于NVIC,前面章节有说明。
  5. 把stm32f4xx_it.c里面的两个中断入口拷贝到我们的stm32f4xx_it.c文件。
  6. 将新文件添加到工程,编译。21个错误,一个一个解决。挑几个看看 ..\mcu_dev\mcu_sdio.c(67): error: #20: identifier “SD_DETECT_GPIO_CLK” is undefined 因为mcu_sdio.c没有包含mcu_sdio.h。增加#include “mcu_sdio.h”。 其实在我的个人认识当中,h文件应该是提供给外部使用。 哪些仅仅是驱动内部使用的定义,最好是定义到C文件内,不让其他文件看到。减少不必要的耦合。
  7. 重新编译,还有一个错误 ..\board_dev\stm324xg_eval_sdio_sd.h(39): error: #5: cannot open source input file “stm324xg_eval.h”: No such file or directory 改为包含mcu_sdio.h。
  8. 又有38个错误了。 ..\board_dev\stm324xg_eval_sdio_sd.h(128): error: #20: identifier “uint32_t” is undefined 应该是没包含stm32的头文件, 官方stm324xg_eval_sdio_sd.h包含了#include “stm324xg_eval.h”, 然后stm324xg_eval.h包含了#include “stm32f4xx.h”和#include “stm32_eval_legacy.h”,我们在mcu_sdio.h中包含#include “stm32f4xx.h”。
  9. 编译没有错误了。
  10. 开始做硬件移植,在mcu_sdio.h第20行,例程用PH13做SD卡检测,我们都没有H口。我们用的是PC13.
  11. 在mcu_sdio.c的初始化中,进行了初始化,个人认为这些跟硬件相关的,还是放到一处用宏定义较好,方便移植修改。 数据线,和我们的硬件一样,不需要修改。
/* Configure PC.08, PC.09, PC.10, PC.11 pins: D0, D1, D2, D3 pins */
命令线,也跟我们一样。
/* Configure PD.02 CMD line */
时钟线也一样。
/* Configure PC.12 pin: CLK pin */
  1. 那么就是检测脚不一样,我们看看这个管脚怎么用的。搜索后发现有一个SD_Detect函数在使用。
/**
 * @brief  Detect if SD card is correctly plugged in the memory slot.
 * @param  None
 * @retval Return if SD is detected or not
 */
uint8_t SD_Detect(void)
{
  __IO uint8_t status = SD_PRESENT;

  /*!< Check GPIO to detect SD */
  if (GPIO_ReadInputDataBit(SD_DETECT_GPIO_PORT, SD_DETECT_PIN) != Bit_RESET)
  {
    status = SD_NOT_PRESENT;
  }
  return status;
}
  1. SDCardState SD_GetState(void)和测试程序会使用SD_Detect。到这里看出,检测管脚就是一个普通IO口,没有使用中断等其他功能,直接修改为我们的管脚即可。
  2. 重新编译,插上TF卡,下载程序运行。 初始化不成功。在初始化函数添加调试信息。 成功上电,进入初始化,卡信息也获取成功了,选卡也成功。 _images/131.png选卡成功 我们先看一下卡信息,卡信息结构体如下:
/**
  * @brief SD Card information
  */
typedef struct
{
  SD_CSD SD_csd;
  SD_CID SD_cid;
  uint64_t CardCapacity;  /*!< Card Capacity */
  uint32_t CardBlockSize; /*!< Card Block Size */
  uint16_t RCA;
  uint8_t CardType;
} SD_CardInfo;

CSD跟CID是卡信息,包含比较多信息。例如卡什么卡,什么版本,V1.0,还是2.0等。 本处我们先把容量Capacity跟Block Size打印出来,看跟我们的卡是不是一致。 调试代码如下,要注意的地方是容量的处理:如果直接使用uart_printf打印容量,打印出来一个10,不正确,要拆分为两部分打印。

  /*----------------- Read CSD/CID MSD registers ------------------*/
  errorstatus = SD_GetCardInfo(&SDCardInfo);
    uart_printf("\r\n-------SD_GetCardInfo ok----------\r\n");

	uint32_t *p;
	p = (uint32_t *)&(SDCardInfo.CardCapacity);
	uart_printf("\r\n-------CardCapacity:%08X----------\r\n", *(p+1));
    uart_printf("\r\n-------CardCapacity:%08X----------\r\n", *(p+0));   
    uart_printf("\r\n-------CardBlockSize:%d ----------\r\n", SDCardInfo.CardBlockSize);

    uart_printf("\r\n-------RCA:%d ----------\r\n", SDCardInfo.RCA);
    uart_printf("\r\n-------CardType:%d ----------\r\n", SDCardInfo.CardType);

卡信息调试LOG:

——-dev_sdio_test———- ——-SD_PowerON ok———- ——-SD_InitializeCards ok———- ——-SD_csd.DeviceSize:15271———- ——-SD_GetCardInfo ok———- ——-CardCapacity:00000001———- ——-CardCapacity:DD400000———- ——-CardBlockSize:512 ———- ——-RCA:2 ———- ——-CardType:2 ———-

还有,刚刚等了很久发现,初始化返回结果4,数据超时。

  1. 查看SD_Error SD_EnableWideBusOperation(uint32_t WideMode),分析大概流程后加上调试信息。 应该是陷入static SD_Error SDEnWideBus(FunctionalState NewState)超时。 源码1103行
    else if (SDIO_BusWide_4b == WideMode)
    {
      errorstatus = SDEnWideBus(ENABLE);
      uart_printf("SDEnWideBus:%d\r\n", errorstatus);

经查,在FindSCR处,源码2438行。

   uart_printf("SDIO_GetResponse ok\r\n");
  /*!< Get SCR Register */
  errorstatus = FindSCR(RCA, scr);
    uart_printf("FindSCR:%d\r\n", errorstatus);

FindScr函数内有个while,估计程序就是卡在这里,源码2760行

while (!(SDIO->STA & (SDIO_FLAG_RXOVERR | SDIO_FLAG_DCRCFAIL
  | SDIO_FLAG_DTIMEOUT | SDIO_FLAG_DBCKEND | SDIO_FLAG_STBITERR)))
{
    if (SDIO_GetFlagStatus(SDIO_FLAG_RXDAVL) != RESET)
    {
      *(tempscr + index) = SDIO_ReadData();
      index++;
    }
}
  1. 我想骂人,我刚刚说SD卡读不到数据,硬件说他看下,回来跟我说有3个跳线电阻没焊。我想杀人。 看来以后要先学会怀疑别人,再证明自己清白
  2. 硬件修改后,SD卡初始化成功,但是测试失败。
——-dev_sdio_test———- ——-SD_PowerON ok———- ——-SD_InitializeCards ok———- ——-SD_csd.DeviceSize:15271———- ——-SD_GetCardInfo ok———- ——-CardCapacity:00000001———- ——-CardCapacity:DD400000———- ——-CardBlockSize:512 ———- ——-RCA:2 ———- ——-CardType:2 ———- ——-SD_SelectDeselect ok———- SDIO_GetResponse ok FindSCR:0 2453 mdResp1Error:0 2468 CmdResp1Error:0 SDEnWideBus:0 ——-SD_EnableWideBusOperation:0———- ——-SD_Init ok———- ——-SD_EraseTest….———-

查后,发现卡在等待传输结束的等待上。 SD_Error SD_WaitReadOperation(void)函数第一个等待就过不去。 前面读写已经没问题,唯一的区别就是这里使用了DMA。 这里的while(1),就是等待中断或者DMA标志。源码349行

__IO SD_Error TransferError = SD_OK;
__IO uint32_t TransferEnd = 0;//sdio中断中会赋值0x01;
__IO uint32_t DMAEndOfTransfer = 0;//DMA中断中会赋值0x01;
SD_CardInfo SDCardInfo;

网上百度,发现很多人说ST的DMA BUG,其中一个BUG是,在读写之前要发CMD16设置BLOCK大小,我们使用的库已经修改了这个BUG,源码1335行

  /*!< Set Block Size for Card */
  SDIO_CmdInitStructure.SDIO_Argument = (uint32_t) BlockSize;
  SDIO_CmdInitStructure.SDIO_CmdIndex = SD_CMD_SET_BLOCKLEN;
  SDIO_CmdInitStructure.SDIO_Response = SDIO_Response_Short;
  SDIO_CmdInitStructure.SDIO_Wait = SDIO_Wait_No;
  SDIO_CmdInitStructure.SDIO_CPSM = SDIO_CPSM_Enable;
  SDIO_SendCommand(&SDIO_CmdInitStructure);

还有就是中断处理函数SD_Error SD_ProcessIRQSrc(void),以前没有处理出错信息,现在已经处理了。 (从这里可以学一点,我们自己写的中断处理处理函数,最好也响应错误中断) 本处是DMA传输,DMA传输一般都要求字节对齐,否则会出错或者是死机。我们看下到底是死机了还是一直在等待

  1. 既然可能死机,我们就使用CMSIS DAP调试一下,发现死在中断入口了。

_images/321.png晶振未修改对 _images/331.png晶振未修改对 说明有一个中断源一直在进中断,或者是我们没有处理。 我们在DMA中断中增加了调试信息,但是却没有输出,很奇怪。 源码2065行

void SD_ProcessDMAIRQ(void)
{
  uart_printf("-2-");
  if(DMA2->LISR & SD_SDIO_DMA_FLAG_TCIF)
  {
    DMAEndOfTransfer = 0x01;
    DMA_ClearFlag(SD_SDIO_DMA_STREAM, SD_SDIO_DMA_FLAG_TCIF|SD_SDIO_DMA_FLAG_FEIF);
  }
}

搜索中断入口函数void SD_SDIO_DMA_IRQHANDLER(void); 发现,这个并没有在中断向量中定义,而是在mcu_sdio.h中用宏定义。 真正的中断句柄是DMA2_Stream3_IRQHandler,在移植的时候我们并没有处理,而且也不了解这样做要如何处理。 先改回DMA2_Stream3_IRQHandler试试。mcu_sdio.h第46行

#ifdef SD_SDIO_DMA_STREAM3
 #define SD_SDIO_DMA_STREAM            DMA2_Stream3
 #define SD_SDIO_DMA_CHANNEL           DMA_Channel_4
 #define SD_SDIO_DMA_FLAG_FEIF         DMA_FLAG_FEIF3
 #define SD_SDIO_DMA_FLAG_DMEIF        DMA_FLAG_DMEIF3
 #define SD_SDIO_DMA_FLAG_TEIF         DMA_FLAG_TEIF3
 #define SD_SDIO_DMA_FLAG_HTIF         DMA_FLAG_HTIF3
 #define SD_SDIO_DMA_FLAG_TCIF         DMA_FLAG_TCIF3
 #define SD_SDIO_DMA_IRQn              DMA2_Stream3_IRQn
 #define SD_SDIO_DMA_IRQHANDLER        DMA2_Stream3_IRQHandler
#elif defined SD_SDIO_DMA_STREAM6
  1. 测试通过。SDIO硬件测试完成。 _images/36.pngSDIO测试通过 程序测试通过,那到底是不是真的测试成功了呢? 我们可以用winhex软件查看TF卡内容,开头的几个数据块,已经被我们改为0x00–00XFF顺序增加的数据了,说明操作是成功的。 _images/37.png

总结

调试SD卡这一段写了很多,主要是让大家看看平时调试的方法,很多时候就是

  • 加调试信息,顺藤摸瓜。

遗留问题,SDIO驱动用了一个很大的BUF,这个要修改优化。 目前我们只是为了验证硬件,没有挂载文件系统,后续再加上。


end

I2S-wm8978-音乐播放

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


前面章节我们通过DAC播放声音,声音质量只能做到8K,再高的采样频率,CPU就比较吃力了。 内置的DAC精度也没有那么高,一些高级的随身播放器使用的独立DAC芯片通常达到24位。 为了获取较高的音乐质量,屋脊雀板载了一片WM8978,这个芯片也算是各家开发板的标配了。 由于我们还没有调试文件系统,因此我们本次只完成以下功能:

1 将前面调通的收音机通过WM8978播放。 2 内嵌一段声音文件,通过I2S发送到WM8978播放。

功能1验证WM8978是否可用。 功能2进一步验证I2S跟WM8978是否可用。

在开发阶段,硬件还不是稳定状态,调试软件时要尽量通过少的功能验证硬件。如果一开始上来就直接做从SD卡播放MP3文件,一旦遇到问题,需要排除的模块就太多了。

I2S

百度百科:

I2S(Inter—IC Sound)总线, 又称 集成电路内置音频总线,是飞利浦公司为数字音频设备之间的音频数据传输而制定的一种总线标准,该总线专门用于音频设备之间的数据传输,广泛应用于各种多媒体系统。它采用了沿独立的导线传输时钟与数据信号的设计,通过将数据和时钟信号分离,避免了因时差诱发的失真,为用户节省了购买抵抗音频抖动的专业设备的费用。

在飞利浦公司的I2S标准中,既规定了硬件接口规范,也规定了数字音频数据的格式

I2S有3个主要信号

  1. 串行时钟SCLK,也叫位时钟(BCLK),即对应数字音频的每一位数据,SCLK都有1个脉冲。SCLK的频率=2×采样频率×采样位数。
  2. 帧时钟LRCK,(也称WS),用于切换左右声道的数据。LRCK为“1”表示正在传输的是右声道的数据,为“0”则表示正在传输的是左声道的数据。LRCK的频率等于采样频率。
  3. 串行数据SDATA,就是用二进制补码表示的音频数据。
  • 有时为了使系统间能够更好地同步,还需要另外传输一个信号MCLK,称为主时钟,也叫系统时钟(Sys Clock),是采样频率的256倍或384倍。

I2S格式的信号无论有多少位有效数据,数据的最高位总是出现在LRCK变化(也就是一帧开始)后的第2个SCLK脉冲处。这就使得接收端与发送端的有效位数可以不同。如果接收端能处理的有效位数少于发送端,可以放弃数据帧中多余的低位数据;如果接收端能处理的有效位数多于发送端,可以自行补足剩余的位。这种同步机制使得数字音频设备的互连更加方便,而且不会造成数据错位。 随着技术的发展,在统一的 I2S接口下,出现了多种不同的数据格式。根据SDATA数据相对于LRCK和SCLK的位置不同,分为左对齐(较少使用)、I2S格式(即飞利浦规定的格式)和右对齐(也叫日本格式、普通格式)。

STM32 I2S

stm32没有单独的I2S接口,与SPI共用,资料也在SPI章节。

特性

_images/pic12.pngI2S特性 _images/pic10.pngI2S特性

结构框图

从下面框图也可以看出,I2S管脚和SPI管脚有复用。 _images/pic26.pngI2S框图

全双工

I2S为了支持全双工,在全双工模式下,除了I2S2(I2S3)之外,还用到一个额外的I2S,那就是I2S2_ext(I2S3_ext)。

I2S2_ext和I2S3_ext只能工作在全双工模式下,而且只能工作在从模式。 怎么理解呢?如果从我们的硬件与WM8978通信上来说: 1 WM8978有另个功能,一个是STM32输出数据到WM8978的DAC,播放语音。另外一个是STM23从WM8978读数据,录音。 2 I2S,就是用在播音时的通信,这是一个完整的I2S。 3 I2SX_ext用在录音时的通信,因为这个I2SX_ext没有时钟,因此要配合I2S使用,也就是说I2SX_ext仅仅是一个从机接收数据的功能。

WM8978

WM8978是一颗低功耗、高性能的立体声多媒体数字信号编解码器。该芯片内部集成了24位高性能DAC&ADC,可以播放最高192K@24bit的音频信号,并且自带EQ调节,支持3D音效等功能。不仅如此,该芯片还结合了立体声差分麦克风的前置放大与扬声器、耳机和差分、立体声线输出的驱动,减少了应用时必需的外部组件,直接可以驱动耳机(16Ω@40mW)和喇叭(8Ω/0.9W),无需外加功放电路。

  • 接口 MCU可以通过I2C或者SPI控制WM8978,屋脊雀使用I2C控制。 I2S接口用于传输数据,可以双向传输,播放音乐时MCU通过I2S发送数据到WM8978,录音时MCU通过I2S读取WM8978的数据。
  • 输入 WM8978支持双MIC,硬件上我们只使用一个MIC,同时接到两路MIC输入。 LINE输入不使用,留出测试点。 AUX输入接入收音机音源。
  • 输出 喇叭通过2.0插座引出,配套8欧姆2W音腔喇叭。 耳机输出通过3.5音频座接耳机。 OUT3/OUT4不使用。

I2S传输

在WM8978资料的70页,DIGITAL AUDIO INTERFACES对音频接口有详细说明。 音频接口有4根管脚:

  • ADCDAT:adc data Output
  • DACDAT:dac data Input
  • LRC:Data Left/Right alignment clock
  • BCLK:bit clock, for synchronisation

LCR和BCLK是时钟信号,如果WM8978是主设备,则是输出;从设备,则是输入;通常我们用WM8978做从设备。

WM8978支持5中数据格式:

  • Left justified
  • Right justified
  • I2S
  • DSP mode A
  • DSP mode B

在文档中有这五个模式的数据传输时序图。

第3个格式,I2S,就是我们通常说的飞利浦格式。后面我们就是用这种数据格式,我们看看他的时序图。 _images/pic32.png飞利浦格式

LRC,控制数据左右声道。 BCLK,位时钟 DACDAT/ADCDAT,数据输入输出。

控制

WM8978怎么用?知道WM8978能做什么才知道怎么用。 规格书《WM8978_v4.5.pdf》第一页就有WM8978的框图。 _images/pic43.jpg原理图 一句话说明WM8978的功能:

将输入进行一定音效处理后输出。
  • 有多少路输入?LINE输入,MIC输出,I2S输入,AUX输入。 从框图可以看到: I2S输入数字数据,首先进行音效处理,再通过DAC转换为模拟量后送到输出端。 LINE/AUX/MIC,经过几个电子开关后送到输出端,同时还送到ADC进行采样,然后经过音效处理模块后,又通过I2S送出,其实也就是录音功能。
  • 有多少路输出?喇叭,耳机,OUT3,OUT4。

我们通过I2C写WM8978的寄存器控制WM8978,实际就是控制这个框图中的各个开关、混音器、音量控制、DACADC功能。 更详细的控制可以在规格书第14页看到,一整页大图说明了音频通路。 _images/pic53.png原理图 在规格书第89页,有所有的寄存器说明。

WM8978 的 IIC 接口比较特殊:

  1. 只支持写,不支持读数据;
  2. 寄存器长度为 7 位,数据长度为 9 位。
  3. 寄存器字节的最低位用于传输数据的最高位(也就是 9位数据的最高位,7位寄存器的最低位)
  4. WM8978 的 IIC地址固定为: 0x1A。

DMA

本次实验用到DMA,在编码前我们先学习学习DMA。

DMA是什么

按照国际惯例,百度GOOGLE

DMA(Direct Memory Access,直接内存存取) 是所有现代电脑的重要特色,它允许不同速度的硬件装置来沟通,而不需要依赖于 CPU 的大量中断负载。否则,CPU 需要从来源把每一片段的资料复制到暂存器,然后把它们再次写回到新的地方。在这个时间中,CPU 对于其他的工作来说就无法使用。

从这个就可以看出,DMA就是两个硬件(内存或其他存储结构)直接通信,通信过程不需要CPU干预。

或者说,DMA是一个只有MOV指令的CPU。

对于单片机来说,就两个外设通信,不需要内核干预。 举例: 要将一段内存里面的数据通过串口发送出去。 通常我们就是编写一段程序,一个字节一个字节将数据发送到串口。 在这个过程中,CPU是一直参与传输的。 如果用了DMA,只要配置好DMA后,DMA就会自己将数据从内存传输到串口。 传输过程,CPU可以去做其他事。

STM32 DMA

在《STM32F4xx中文参考手册.pdf》第九章有详细介绍。 特性比较丰富,大家自己看文档,我们看看DMA的框图。 _images/pic61.pngDMA框图 STM32有2个DMA控制器,但是DMA2有点特殊,只能存储器到存储器。

DMA的具体应用,后面编码时我们会详细分析。

原理图

下图是WM8978的原理图。

_images/pic7.jpg原理图 细心的朋友可能发现,前面我们说I2S通信,只要4根管脚,这里怎么多了一个MCLK呢?

有时为了使系统间能够更好地同步,还需要另外传输一个信号MCLK,称为主时钟,也叫系统时钟(Sys Clock),是采样频率的256倍或384倍。

WAV转换成数组

这次我们只是验证I2S硬件,不做WAV解码,直接将声音数据转换成一个数组编译到程序里面。如何转换也是一个技巧,学会后处理数据就很简单。

  1. 直接将一个wav文件拖到UltraEdit就可以查看二进制。 _images/15.pngwav文件二进制格式
  2. 全选,然后右键,选择使用十六进制格式复制。
  3. 新建一个txt,粘贴到txt,保存。
  4. 用UE打开刚刚创建的txt。进入编辑菜单下的列模式,下图右边列模式 _images/25.pngUE列操作
  5. 通过列删除删除右边多余数据,通过列添加添加0x前缀和逗号,将数据修改为数组的格式。 _images/34.pngwav数组
  6. 修改完成后添加数组名跟大小括号。
  7. 拷贝到工程的c源文件,就可以将语音编译进bin,直接下载到芯片使用。

编码调试

调试步骤: 1 通过I2C控制WM8978,将收音机声音从喇叭播出。 2 通过I2C控制WM8978,通过MIC采集声音从喇叭或耳机输出。 3 通过I2S播放一段内嵌语音。

WM8978控制-播放收音机声音和MIC功能

在board_dev文件夹创建WM8978驱动文件。 I2C接口在前面调试收音机时已经调通。根据WM8978的I2C特殊性,封装两个读写函数,开辟个数组,用来记录写到wm8978寄存器的值,读的时候就从这个数组回读。

s32 dev_wm8978_writereg(u8 reg, u16 vaule);
s32 dev_wm8978_readreg(u8 addr, u16 *data);

其他WM8978控制主要有如下:

s32 dev_wm8978_set_phone_vol(u8 volume)
s32 dev_wm8978_set_spk_vol(u8 volume)
s32 dev_wm8978_set_mic_gain(u8 gain)
s32 dev_wm8978_set_line_gain(u8 gain)
s32 dev_wm8978_set_aux_gain(u8 gain)
s32 dev_wm8978_inout(WM8978_INIPUT In, WM8978_OUTPUT Out)

函数名就可以看出功能,从上到下分别是:

耳机音量设置 喇叭音量设置 MIC输入增益设置 LINE输入增益设置 AUX增益设置 输入输出通道配置

当然,上面只是基本功能,复杂音效暂时不处理。 在main函数中添加如下代码,初始化WM8978后,将收音机设置到指定频率,然后打开wm8978,就可以从喇叭或者耳机听到收音机声音。

/* Infinite loop */
	mcu_uart_open(3);
	wjq_log(LOG_INFO, "hello word!\r\n");
	mcu_i2c_init();
	mcu_spi_init();
	dev_key_init();
	//mcu_timer_init();
	dev_buzzer_init();
	dev_tea5767_init();
	dev_dacsound_init();
	dev_spiflash_init();
	dev_wm8978_init();

	//dev_dacsound_open();
	dev_key_open();
	dev_wm8978_open();
	dev_tea5767_open();
	dev_tea5767_setfre(105700);

	while (1)
	{
		/*驱动轮询*/
		dev_key_scan();

		/*应用*/
		u8 key;
		s32 res;

		res = dev_key_read(&key, 1);
		if(res == 1)
		{
			if(key == DEV_KEY_PRESS)
			{
				//dev_buzzer_open();
				//dev_dacsound_play();
				//dev_spiflash_test();
				//dev_sdio_test();
				dev_wm8978_test();
				GPIO_ResetBits(GPIOG, GPIO_Pin_0 | GPIO_Pin_1
					| GPIO_Pin_2| GPIO_Pin_3);
				//dev_tea5767_search(1);
			}

其中,初始化WM898代码如下,先初始化I2S,再初始化默认的WM8978配置:

s32 dev_wm8978_init(void)
{
	mcu_i2s_init();//初始化I2S接口
	dev_wm8978_setting_init();//配置WM8978初始化状态
	return 0;
}

初始化WM8978的默认配置,就是写WM8978的寄存器。

static s32 dev_wm8978_setting_init(void)
{
	s32 ret = -1;

	ret = dev_wm8978_writereg(0,0x00);	// 复位WM8978
	if(ret == -1)		// 复位失败
		return ret;

	dev_wm8978_writereg(1,0x1B);

	dev_wm8978_writereg(2,0x1B0);
	dev_wm8978_writereg(3, 0x000C);	// 使能左右声道混合
	dev_wm8978_writereg(6, 0x0000);	// 由处理器提供时钟信号
	dev_wm8978_writereg(43, 0x0010);	// 设置ROUT2反相,驱动扬声器所必须
	dev_wm8978_writereg(49, 0x0006);	// 扬声器 1.5x 增益, 开启热保护

	dev_wm8978_inout(WM8978_INPUT_NULL,
						WM8978_OUTPUT_NULL);

	dev_wm8978_set_mic_gain(50);
	dev_wm8978_set_phone_vol(40);
	dev_wm8978_set_spk_vol(40);
	dev_wm8978_set_aux_gain(3);
	return ret;
}

然后就是打开WM8978,其实就是配置默认输入输出通道

s32 dev_wm8978_open(void)
{
	dev_wm8978_inout(WM8978_INPUT_DAC|WM8978_INPUT_AUX
					|WM8978_INPUT_LMIC|WM8978_INPUT_RMIC,
		WM8978_OUTPUT_PHONE|WM8978_OUTPUT_SPK);

	return 0;
}

程序运行后,就可以听到收音机的声音从WM8978的喇叭跟耳机输出。 同时,MIC也正常工作了,测试MIC时,可以屏蔽掉下面两句

	dev_tea5767_open();
	dev_tea5767_setfre(105700);

也就是关收音机。 同时请注意:MIC距离喇叭较近,有可能发生啸叫。如发生啸叫,可以调小喇叭音量,降低MIC增益,或则关闭喇叭输出,改为耳机监听MIC输入。

  • 到此我们基本验证了WM8978是能工作的,下一步就验证I2S播放音乐。

I2S 播放音频数据

I2S驱动,参考官方例程。I2S的关键是框架设计,也即是怎么使用I2S传输数据? 软件设计要考虑限制条件:

首先就是速度快,通常播放44K采样频率时,一秒钟就需要传输4422(双声道16BIT)=166K数据,毫无疑问,必须使用DMA传输。 并且需要使用DMA双缓冲机制,双缓冲就可以做到播放时读数据。 第二,播放语音数据来源通常是SD卡或者U盘,读文件系统数据通常速度并不是很快,因此缓冲需要开辟大一点,否则语音将断断续续。
  • 初始化 初始化分两部分,IO口初始化,I2S控制器初始化。
/**
 *@brief:      mcu_i2s_init
 *@details:    初始化I2S接口硬件
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
void mcu_i2s_init (void)
{
	GPIO_InitTypeDef GPIO_InitStructure;

	/*
		LRC 	PB12
		BCLK	PB13
		ADCDAT 	PC2
		DACDAT	PC3
		MCLK	PC6
	*/
	// 初始化时钟
	RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB|RCC_AHB1Periph_GPIOC, ENABLE);

	GPIO_PinAFConfig(GPIOB,	GPIO_PinSource12,		GPIO_AF_SPI2);
	GPIO_PinAFConfig(GPIOB,	GPIO_PinSource13,		GPIO_AF_SPI2);
	GPIO_PinAFConfig(GPIOC, GPIO_PinSource2,		GPIO_AF6_SPI2);
	GPIO_PinAFConfig(GPIOC,	GPIO_PinSource3,		GPIO_AF_SPI2);
	GPIO_PinAFConfig(GPIOC,	GPIO_PinSource6,		GPIO_AF_SPI2);

	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF;			// 复用模式
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz;	// 速度等级
	GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;		// 推挽输出
	GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_NOPULL;	//	无上下拉
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12|GPIO_Pin_13;
	GPIO_Init(GPIOB, &GPIO_InitStructure);

	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2|GPIO_Pin_3|GPIO_Pin_6;		
	GPIO_Init(GPIOC,&GPIO_InitStructure);

}
/**
 *@brief:      mcu_i2s_config
 *@details:    I2S配置
 *@param[in]   u32 AudioFreq   频率
               u16 Standard    标准
               u16 DataFormat  格式
 *@param[out]  无
 *@retval:     
 */
void mcu_i2s_config(u32 AudioFreq, u16 Standard,u16 DataFormat)
{
	I2S_InitTypeDef I2S_InitStructure;
	// 配置IIS PLL时钟
	RCC_I2SCLKConfig(RCC_I2S2CLKSource_PLLI2S);				
	RCC_PLLI2SCmd(ENABLE);// 使能PLL
	while( RCC_GetFlagStatus(RCC_FLAG_PLLI2SRDY) == 0 );// 等待配置完成

	RCC_APB1PeriphClockCmd(RCC_APB1Periph_SPI2, ENABLE);// 初始化IIS时钟

	SPI_I2S_DeInit(SPI2);

	I2S_InitStructure.I2S_AudioFreq = AudioFreq;//设置音频采样频率
	I2S_InitStructure.I2S_Standard = Standard;	//I2S Philips 标准
	I2S_InitStructure.I2S_DataFormat = DataFormat;	//数据长度16位
	I2S_InitStructure.I2S_CPOL = I2S_CPOL_Low;	// 空闲状态电平位低
	I2S_InitStructure.I2S_Mode = I2S_Mode_MasterTx;//主机发送
	I2S_InitStructure.I2S_MCLKOutput = I2S_MCLKOutput_Enable;//主时钟输出
	I2S_Init(SPI2, &I2S_InitStructure);

	I2S_Cmd(SPI2, ENABLE);	// 使能IIS
}
mcu_i2s_init就是IO口初始化,很简单,只要将对应IO设置为AF功能就行了。 我们用的是I2S2,所以设置为SPI2功能即可。
mcu_i2s_config就是I2S控制器的初始化,主要有5个配置:采样频率,通信格式,数据长度,CPOL,主机模式,MCLK时钟输出。

通过查看I2S_InitTypeDef结构体,也可以知道应该如何配置。

typedef struct
{

  uint16_t I2S_Mode;        /*!< Specifies the I2S operating mode.
                              This parameter can be a value of @ref I2S_Mode */

  uint16_t I2S_Standard;    /*!< Specifies the standard used for the I2S communication.
                              This parameter can be a value of @ref I2S_Standard */

  uint16_t I2S_DataFormat;  /*!< Specifies the data format for the I2S communication.
                              This parameter can be a value of @ref I2S_Data_Format */

  uint16_t I2S_MCLKOutput;  /*!< Specifies whether the I2S MCLK output is enabled or not.
                              This parameter can be a value of @ref I2S_MCLK_Output */

  uint32_t I2S_AudioFreq;   /*!< Specifies the frequency selected for the I2S communication.
                              This parameter can be a value of @ref I2S_Audio_Frequency */

  uint16_t I2S_CPOL;        /*!< Specifies the idle state of the I2S clock.
                              This parameter can be a value of @ref I2S_Clock_Polarity */
}I2S_InitTypeDef;
  • DMA配置 DMA前面我们大概了解了一下,其实DMA功能有点复杂。 下面我们通过程序学习学习。
/**
 *@brief:      mcu_i2s_dam_init
 *@details:    初始化I2S使用的DMA通道,双缓冲模式
 *@param[in]   u16 *buffer0  
               u16 *buffer1  
               u32 len       
 *@param[out]  无
 *@retval:     
 */
void mcu_i2s_dma_init(u16 *buffer0,u16 *buffer1,u32 len)
{  
    NVIC_InitTypeDef   NVIC_InitStructure;
    DMA_InitTypeDef  DMA_str;

    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA1,ENABLE);	//使IIS DMA时钟
    DMA_DeInit(DMA1_Stream4);	//恢复初始DMA配置

    DMA_str.DMA_Channel = DMA_Channel_0;  //IIS DMA通道
    DMA_str.DMA_PeripheralBaseAddr = (u32)&SPI2->DR;	//外设地址
    DMA_str.DMA_Memory0BaseAddr = (u32)buffer0;		//缓冲区0
    DMA_str.DMA_DIR = DMA_DIR_MemoryToPeripheral;	//存储器到外设模式
    DMA_str.DMA_BufferSize = len;		//数据长度
    DMA_str.DMA_PeripheralInc = DMA_PeripheralInc_Disable;	//外设非增量模式
    DMA_str.DMA_MemoryInc = DMA_MemoryInc_Enable;	//存储器增量模式
    DMA_str.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;//外设数据长度16位
    DMA_str.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;//存储器数据长度16位
    DMA_str.DMA_Mode = DMA_Mode_Circular;	//循环模式
    DMA_str.DMA_Priority = DMA_Priority_High;	//高优先级
    DMA_str.DMA_FIFOMode = DMA_FIFOMode_Disable; //不使用FIFO      
    DMA_str.DMA_FIFOThreshold = DMA_FIFOThreshold_1QuarterFull;	//FIFO阈值
    DMA_str.DMA_MemoryBurst = DMA_MemoryBurst_Single;	//外设突发单次传输
    DMA_str.DMA_PeripheralBurst = DMA_PeripheralBurst_Single;	//存储器突发单次传输
    DMA_Init(DMA1_Stream4, &DMA_str);	//	初始化DMA
    //配置缓冲区1
    DMA_DoubleBufferModeConfig(DMA1_Stream4,(uint32_t)buffer0, DMA_Memory_0);
    //配置缓冲区1
    DMA_DoubleBufferModeConfig(DMA1_Stream4,(uint32_t)buffer1, DMA_Memory_1);

    DMA_DoubleBufferModeCmd(DMA1_Stream4,ENABLE);	//开启双缓冲模式
    DMA_ITConfig(DMA1_Stream4,DMA_IT_TC,ENABLE);	//开启传输完成中断

    SPI_I2S_DMACmd(SPI2, SPI_I2S_DMAReq_Tx,ENABLE);	//IIS TX DMA使能.

    NVIC_InitStructure.NVIC_IRQChannel = DMA1_Stream4_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0;//抢占优先级
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;  //响应优先级
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);
}
  1. DMA通道 18行

DMA通道设置。STM32有2个DMA,每个DMA的通道和stream在参考手册中都有。 _images/pic8.png

_images/pic9.png

  1. DMA_PeripheralBaseAddr 19行 外设地址,不一定是真正的外设地址。 如果你是外设到内存,或者内存到外设,这里就是一个外设的地址。 这里的外设就是前面表格中说的片上设备,SPI、串口、SDIO等等。

如果你是memory到memory,那这个就是RAM地址或者FLASH地址,或外部RAM SRAM等地址。

  1. DMA_Memory0BaseAddr 20行 memory地址,为什么带个0呢?因为某种情况下,可以用双缓冲。我们这次做I2S播放音乐的时候我们就用双缓冲。

其他情况通常都是单缓冲。

  1. DMA_DIR 21行 DMA方向,有3种: DMA_DIR_PeripheralToMemory DMA_DIR_MemoryToPeripheral DMA_DIR_MemoryToMemory
好了,到这里就有点疑问了,配合前面设置的两个地址来看, 如果是P-M,也就是perpheral to memory,那肯定就是DMA_PeripheralBaseAddr取数据,发到DMA_Memory0BaseAddr。 如果是M-P,那就是反着来。 那M-M呢?我没找到文档哪里写,实测,是从DMA_PeripheralBaseAddr取数据,发到DMA_Memory0BaseAddr。 只有 DMA2 控制器能够执行存储器到存储器的传输。
  1. DMA_BufferSize 22行 准确的说应该是传输长度。 如果配置不准确,传输数据就或出错,或者是一直在DMA传输,永远不结束。 这个长度,跟后面配置字长有关: 如果DMA_PeripheralDataSize跟DMA_MemoryDataSize相等,那就没话说了,该是多长就是多长。 如果两者不相等,DMA_BufferSize是指DMA_PeripheralDataSize数据宽度的数据数。 假如DMA_PeripheralDataSize是半字,DMA_MemoryDataSize是BYTE, DMA_BufferSize=10,就表示DMA要传输10个半字,20个BYTE。

具体看《STM32F4xx中文参考手册.pdf》 9.3.10 可编程数据宽度、封装/解封、字节序 6. DMA_PeripheralInc 23行 DMA_MemoryInc 24行 地址是否自加。我们是将内存的一堆数据发送到I2S控制器,因此,内存自加,I2S控制器不自加。

每次都是发送到&SPI2->DR这个寄存器地址。 7. DMA_PeripheralDataSize 25行 DMA_MemoryDataSize 26行 字长设置,当我把两者都设置为halfword时,发现如果传输奇数个半字,会一直处于发送,DMA不会停止。 因此把DMA_PeripheralDataSize设置为byte,那么传输的字节数就是2*实际要传输的半字, 那么肯定是2的倍数,就能正常。关于这个在文档中也有提及,只是还看不太明白。

这次I2S DMA,都用半字,因此需要保证传输数据是2的整数倍。 8. DMA_Mode 27行 是否循环。所谓的循环就是当一次传输结束后,自动重新启动一次DMA传输,配置和前一次传输一样。 摄像头就用循环,只要配置一次,启动DMA后,一直持续不断的更新数据到屏幕上。 需要注意的是:

使用存储器到存储器模式时,不允许循环模式和直接模式。 9. DMA_Priority 28行

优先级 10. DMA_FIFOMode 29行 FIFOThreshold 30行 是否使用FIFO,也就是是否使用直接模式。 但是,我们的配置是disable,那么就是直接模式?

存储器到存储器不是不允许直接模式吗?
  1. DMA_MemoryBurst 31行 DMA_PeripheralBurst 32行 9.3.11 单次传输和突发传输 “为确保数据一致性,形成突发的每一组传输都不可分割:在突发传输序列期间, AHB 传输会 锁定,并且 AHB 总线矩阵的仲裁器不解除对 DMA 主总线的授权。”

    从文档看来,是保证数据完整性的,也就是说不要传到一半被别的设备打断造成数据错误?

  2. DMA_Init(DMA1_Stream4, &DMA_InitStructure); 33行

    执行配置,第一个参数要选对Stream0,用哪个stream,根据表格选。

  3. 35行~39行

    配置双缓冲

  4. 之后 后面就是使能DMA中断,打开SPI/I2S DMA,配置NVIC中断控制器。 配置完成后,当需要时启动DMA传输即可,传输完成则产生一个中断。

  • DMA中断

当DMA中断产生时,判断DMA缓冲的标志,然后设置BUF标志。在主循环中查询这个BUF标志,根据标志填充数据到对应缓冲。

/**
 *@brief:      mcu_i2s_dma_process
 *@details:    I2S使用的DMA中断处理函数
 *@param[in]   void  
 *@param[out]  无
 *@retval:     

 位 19 CT:当前目标(仅在双缓冲区模式下) (Current target (only in double buffer mode))
此位由硬件置 1 和清零,也可由软件写入。
0:当前目标存储器为存储器 0(使用 DMA_SxM0AR 指针寻址)
1:当前目标存储器为存储器 1(使用 DMA_SxM1AR 指针寻址)
只有 EN 为“0”时,此位才可以写入,以指示第一次传输的目标存储区。在使能数据流
后,此位相当于一个状态标志,用于指示作为当前目标的存储区。
 */
void mcu_i2s_dma_process(void)
{
	if(DMA1_Stream4->CR&(1<<19))
	{
		/*当前目标存储器为1,我们就设置空闲BUF为0*/
		fun_sound_set_free_buf(0);
	}
	else
	{
		fun_sound_set_free_buf(1);
	}
}
  • 双缓冲机制
  1. 启动播放时,将两个缓冲都填上音源数据。
  2. I2S开始传输,传输完一个缓冲,DMA产生中断,根据使用的缓冲设置要写缓冲索引。
  3. 语音播放程序判断是否需要填充,填充哪个缓冲。 千万不要在DMA中断函数内填充缓冲,通过文件系统读几K数据,卡太久中断,会出问题的。
/**
 *@brief:      fun_sound_get_buff_index
 *@details:    查询当前需要填充的BUF
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
static s32 fun_sound_get_buff_index(void)
{
	s32 res;

	res = SoundBufIndex;
	SoundBufIndex = 0xff;
	return res;
}
/**
 *@brief:      fun_sound_set_free_buf
 *@details:    设置空闲缓冲索引

 *@param[in]   u8 *index  
 *@param[out]  无
 *@retval:     
 */
s32 fun_sound_set_free_buf(u8 index)
{
	SoundBufIndex = index;
	return 0;
}

具体填充数据请看源代码。

调试

WM8978 I2S功能先写一个简单的dev_wm8978_test测试函数,首先初始化WM8978,设置I2S的格式(MCU跟WM8978都要配置),初始化I2S DMA,然后预填充两个BUF,启动DMA,进行while循环,在循环中不断查询,需要就填充数据。 在main函数中,当按下按键时,就调用dev_wm8978_test播放。

调试的时候,左声道有点杂音,仅仅是久不久有一点杂音,一开始就以为是I2S通信被干扰了。 分析过程: 首先将填充左声道的BUFF数据全部设置为0,更新程序后发现左声道竟然还有声音。 然后将两个声道的数据全部填充为0,竟然还会有一点点背景音。 不科学,从而知道不是I2S干扰,应该是填缓冲有问题,追查下去发现缓冲填错了。 当I2S使用完缓冲1的数据,就会切换到缓冲2,此时应该填充缓冲1,程序却填充缓冲2了。 修正后,填充数据0,WM8978静音:填充音源数据,左声道没杂音。

思考

wm8978的基本功能就调试通了。 请思考: 我们前面做了一个DAC播放语音,现在又做了一个更加高级的WM8978。这两个驱动要提供什么接口?提供给谁? 或者从上到下分析,APP要播放语音,要什么接口?硬件有两个声卡设备,怎么操作? 对于语音播放来说,仅仅实现DAC播放或者是WM8978播放时远远不够的。 等硬件验证完,文件系统也移植好的时候,我们会做一个语音播放管理程序。


END

FSMC-TFT LCD调试记录

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190402

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


电子设备人机交互包含输入输出,液晶显示是输出主要方法。 屋脊雀F407开发板在核心板上有TFT LCD液晶屏接口,可以接我们接口的8080并口总线液晶屏。

开发阶段先调试LCD显示简单的英文字符,图片与汉字显示暂时不处理。

LCD液晶屏

tft-lcd是薄膜晶体管液晶显示器英文thin film transistor-liquid crystal display的缩写。

_images/pic18.jpgTFT LCD

本次调试的是屋脊雀设计的模块

带四线电阻触摸屏 2.8寸 标准37PIN并口,支持8080或6800接口。 分辨率320*240 16位真彩色(65k色)。 液晶驱动芯片为ILI9341。 双接口接口:FPC30和DIP30。默认使用FPC排线连接。

本次仅仅编写显示驱动,触摸屏驱动下一章再讨论。

LCD组成

我们常说的LCD,其实是LCD模组,包含驱动芯片和液晶面板。 驱动芯片跟液晶面板之间有很多线连接,通过这些线控制液晶面板。 我们开发LCD驱动,其实就是开发驱动芯片的驱动。 常用的LCD 驱动IC非常多,但是从功能上分析,基本类似。 学会一种就可以触类旁通。 下面我们通过9341驱动学习LCD 驱动芯片的功能。

时序

并口有两种时序模式:英特尔的8080时序、摩托罗拉的6800时序。 并口不仅仅用于TFT LCD,其它SDRAM等芯片也使用并口时序通信。

  • 8080时序

_images/16.png8080时序

8080时序,通常有下列接口信号

Vcc(工作主电源) Vss(公共端) Vee(偏置负电源,常用于调整显示对比度) /RES,复位线。DB0~DB7,双向数据线。 D/I,数据/指令选择线(1:数据读写,0:命令读写)。 /CS,片选信号线(如果有多片组合,可有多条片选信号线)。 /WR, MPU向LCD写入数据控制线。 /RD, MPU从LCD读入数据控制线。
  • 6800时序

_images/26.png6800时序

上图即为摩托罗拉的6800时序

在这种模式下,Vcc、Vss、Vee、/RES、DB0~DB7、D/I的功能同模式(1),其他信号线为: R/W,读写控制(1:MPU读, 0:MPU写)。 E,允许信号(多片组合时,可有多条允许信号线)。

ILI9341

《ILI9341_DS_V1.09_20110315.pdf》 打开这个文档即可看到文档标题

a-Si TFT LCD Single Chip Driver 240RGBx320 Resolution and 262K color

从标题就可以看出这个驱动IC的性能:

可以支持240320像素,只是表明驱动IC的性能,不代表LCD模组像素大小,LCD像素可能会小于320240。 RGB 262K色

在第9页有9341这颗芯片的框图 _images/pic27.png9341框图 主要有以下说明:

  1. 左上红框1,说明这颗IC支持多种通信接口,RGB接口、串口、MCU接口。 其中MCU接口就是我们常说的并口,通常是6800或8800时序。 但是模组用什么接口,支持什么接口,不同厂家设计的不一样。 所以用9341的LCD模组,接口可能会不同。
  2. 中间红框2,就是芯片的显存,使用LCD就是将显示数据写到这个地方。
  3. 右上红框和右下红框,就是这颗芯片跟液晶面板的连线 。

通常显示驱动IC就分两部分:控制,显示。

  1. 对于显示,其实就是读写驱动IC里面的显示缓冲区,操作上跟读写外部SRAM基本一样。 但是接口上有区别,在下面FSMC部分有说明。
  2. 控制部分就是读写驱动IC内的命令寄存器,在文档中对每一个命令的操作流程都有说明。 过程跟SPI FLASH的控制过程类似。 控制又可以分为两部分:初始化、显示过程可控制。
初始化一般都是由厂家提供,我们作为用户,用就行了。有问题找原厂。 显示过程的控制大概有:设置显示区域、设置扫描方向、设置屏幕方向、打开或关闭显示、开始填充数据等几个命令。

数据格式

9341支持多种接口,不同接口的数据格式是不一样的。 本次我们用的LCD模组是16BIT并口。格式说明在文档的7.6.5章节 _images/8080-I.png8080-I 在这种接口模式下,数据有两种格式:65K色、256K色。我们只用65K色。 那么一次发送的数据格式就是:RGB565格式。 _images/RGB565.pngRGB565 看上图,第0个字节只有低8位有效,这个字节是命令。 从第1个字节开始,就是显示数据,每个数据16位,从高位到低位分3部分:红色数据5bit、绿色数据6bit、蓝色数据5bit。

显示流程

通常显示流程如下图。 _images/pic33.png显示流程

  1. 初始化包括硬件复位和LCD控制初始化,初始化流程比较复杂,通常由模组厂家提供。
  2. 设置扫描方向,当持续刷新显示数据时,控制器自动移动GRAM指针的方向。 有两个:page和colum.
  3. 设置显示范围,也就是显示窗口的意思,假如一个320*240的屏幕,要显示一张100X100的图片,我们就可以设置一个100X100的显示窗口。
  4. 坐标,也就是开始写GRAM的位置,必须在显示窗口范围内。例如我们设置了一个100*100的窗口,左上角坐标是(1,1)。显示坐标设置为(1,1)。 然后开始写GRAM指令,后面传输的显示数据就会自动在这个窗口内顺序刷新。

上面说的几点,不同的控制器有一点差别

命令

  1. 设置扫描方向 9341使用36H命令控制扫描方向。简单说就是连续读写GRAM时,GRAM指针增长方向。 在8.2.29有命令说明,其中关键的是MY/MX/MV:这3个bit控制memory读写方向。 _images/pic43.png36命令
  2. 设置column范围 2AH命令设置column范围,SC是column起始,EC就是结束。 _images/column.png2A命令

那可设范围是多大呢?请看下面。 根据MADCTL第5个BIT,column不能超过0xEF/0x13f。也就是不能超过239/319。 啥意思?也就是意味着,可以通过MADCTL第5个BIT调换column和page_images/pic81.png2A命令

  1. 设置page范围 2BH命令设置page范围,SP是page起始地址,EP是page结束地址。 base/13FSMC/pic/PAGE.png2B命令 2B命令和2A命令类似。 _images/pic54.png2B命令

很多人说这两个命令时用于设置扫描起始地址,这是不对的。这两个命令时用于设置扫描窗口范围。当设置窗口时,起始地址默认为(sc,sp)

  1. 写GRAM指令 命令2CH用于开始写GRAM。
  2. 写数据 紧跟2C命令后面的数据都是显示数据,直接写到GRAM中,GRAM指针移动根据前面几个指令的设置决定。

FSMC

要了解FSMC接口,我们打开《STM32F4xx中文参考手册.pdf》,翻到第32章:灵活的静态存储控制器。

FSMC 能够连接同步、异步存储器和 16 位 PC 存储卡。 所有外部存储器共享地址、数据和控制信号,但有各自的片选信号。 FSMC 一次只能访问一个外部器件。

_images/42.pngFSMC框图 上面是FSMC框图,从中可以看出,FSMC将设备分为两部分,NOR/PSRAMNAND/PC。 两者有各自的控制器,共用地址线信号线等信号(见框图右边第二个大括号)。 其中,控制信号还细分为三种:NOR/PSRAM信号、NAND信号、PC智能卡信号。 前面说到,操作TFT LCD就是操作驱动IC里面的显示缓冲区,跟操作SRAM一样。 因此我们将TFT LCD接在NOR/PSRAM存储器控制器上,使用FSMC_NE4作为片选信号。 但是LCD与SRAM还是有区别的,LCD没有地址线,多一根RS线用于区分数据线上的是显示数据还是命令。 如果我们将RS接到FSMC的某根地址线上,当我们操作一个地址,让这根线为高电平,就相当于向LCD写数据;当操作另外一个地址,是低电平时,则是命令了。 那么这个地址如何定呢?继续往下看 _images/52.pngFSMC储存区域块划分 上图是FSMC地址的划分,PSRAM在Bank1 _images/63.pngFSMC_PSRAM地址映射1 _images/72.pngFSMC_PSRAM地址映射2 在框图中,FSMC_NE信号有4根,就分别对应其中的四个存储区域。 每个区域64M,偏移地址分别是 0x000 0000 0X400 0000 0X800 0000 0XC00 0000

对于8位跟16位,HADDR内部连接不一样,但是FSMC与外部器件依然是A0连接A0,也就是说: 位宽对总线地址线连接无影响,在内部控制器已经处理

现在我们将RS接在FSMC_A15上。 使用的FSMC_NE4,空间就是BANK1的存储区域4。 基地址是0x6000 0000+0xc00 0000, RS接在FSMC_A15上,那么在地址线上,我们希望的信号是A15出现0和1的变化,以便群命令和数据。 但是,犹豫我们用的是16位位宽,地址会右移1位。也就是说,程序中操作A15地址的变化,才会在硬件信号A15上出现变化。 那么区分命令和数据的地址位,就是(1 0000 0000 0000 0000H). 从而数据地址是0x6c01 xxxx,命令地址是0x6c00 xxxx. 这只是其中的一组地址,只要地址的A16位是0和1的区别,并且地址在0x6c00 0000~0x7000 0000之间,都是符合的。

更多FSMC信息,例如寄存器使用、总线时序等,请参考芯片参考手册,同时分析源码。

原理图

  • 接口 _images/pic61.jpg原理图
1脚背光电源(5V), 2脚背光控制 3脚LCD复位信号 4、5、6、7触摸屏控制信号 8,LCD电源 9、30,地线 10~25是双向数据线 26 CS,TFTLCD片选信号。 27 RS,命令/数据标志( 0,命令; 1,数据)。 28 WR,控制向TFTLCD写入数据。 29 RD,从TFTLCD 读取数据。
  • Lcd原理图 _images/pic71.jpg原理图 上图只是LCD的原理图,不包含触摸屏控制。 左边信号是控制信号。右边是电源和背光。 我们用的LCD默认就是16BIT并口了,不需要IM选择。

编码调试

知识点已经介绍完,下一步就是开始驱动编写,编写驱动前,先进行驱动设计。

驱动设计

通常的显示流程:

APP要在LCD上显示一个字符,调用显示接口,显示接口根据传入的字符内码查找点阵字库,组成显示数据,将显示数据刷新到指定的LCD

根据这个流程,显示要分两层,

  1. 中间层是处理数据,类似GUI层,主要负责处理显示内容,包含查找字符点阵,画圆画线。
  2. 底层就是LCD驱动,仅仅负责将显示数据写到LCD,不关心显示内容。

这样理想的LCD驱动框架到后面我们在实现。 当前主要是验证硬件,只做LCD驱动,并做几个简单的显示接口,可以显示英文,暂时不做中文显示。

在进行LCD驱动设计时,请考虑以下问题:

  1. lcd驱动对下使用FSMC接口操作驱动IC,对上应该提供什么接口?提供什么功能?对上,对谁?
  2. 2.8寸的LCD,很可能有很多不同的驱动IC驱动,如何兼容不同的IC?
  3. 如果一个设备有两个2.8寸的LCD,驱动如何设计?
  4. 后续会做0.96 OLED跟12864 COG LCD驱动,跟当前的TFT LCD驱动什么关系?
我认为LCD只是提供显示缓冲显示功能,或者说,提供一个个点显示功能。如果是黑白屏,就是一个点显示黑还是白,如果是彩屏,就是一个点显示什么颜色。 LCD驱动,不应该提供显示字符,划线等功能,因为这些功能应该是GUI负责。这是需要认识的一个很关键的地方。 假设系统做各种字体的显示,中文,英文,阿拉伯文。首先要有字库,还需要有对字库的处理,将点阵转换为LCD显示缓冲。这些都不是LCD驱动负责。

FSMC调试

我们在官方的例程中找FSMC的例程作为参考。在标准库目录下STM32F4xx_DSP_StdPeriph_Lib_V1.8.0\Project\STM32F4xx_StdPeriph_Examples\FSMC下有两个例程 _images/811.pngFSMC st 例程文件夹 通过读文件夹内的readme文件可知道,第一个例程是如何通过读写将一个外部SRAM作为数据存储,第二个例程是如何将外部SRAM做为一个外部的memory,包括作为堆和栈。 很明显,我们应该参考第一个例程。

@par How to use it ? In order to make the program work, you must do the following:
  • Copy all source files from this example folder to the template folder under Project\STM32F4xx_StdPeriph_Templates
  • Open your preferred toolchain
  • Select the project workspace related to the STM32F40_41xxx device and add the following files in the project source list:
    • Utilities\STM32_EVAL\STM3240_41_G_EVAL\stm324xg_eval.c
    • Utilities\STM32_EVAL\STM3240_41_G_EVAL\stm324xg_eval_fsmc_sram.c
  • Rebuild all files and load your image into target memory
  • Run the example

根据SDIO例程移植的经验,很容易看出,stm324xg_eval_fsmc_sram.c才是我们要的源码。 当然,例程里面的其他文件我们也需要跟我们工程的文件对比一下。

 /* Initialize the SRAM memory */
  SRAM_Init();

  /* Fill the buffer to send */
  Fill_Buffer(aTxBuffer, BUFFER_SIZE, 0x250F);

  /* Write data to the SRAM memory */
  SRAM_WriteBuffer(aTxBuffer, WRITE_READ_ADDR, BUFFER_SIZE);  

  /* Read back data from the SRAM memory */
  SRAM_ReadBuffer(aRxBuffer, WRITE_READ_ADDR, BUFFER_SIZE);

  /* Check the SRAM memory content correctness */   
  for (uwIndex = 0; (uwIndex < BUFFER_SIZE) && (uwWriteReadStatus_SRAM == 0); uwIndex++)
  {
    if (aRxBuffer[uwIndex] != aTxBuffer[uwIndex])
    {
      uwWriteReadStatus_SRAM++;
    }
  }

main.c,很简单,初始化SRAM,写,读,校验。 首先将stm324xg_eval_fsmc_sram.h跟stm324xg_eval_fsmc_sram.c拷贝到mcu_dev目录下,并添加到MDK跟SI的工程。 源代码就只有三个函数,就是在main函数中调用的函数。 在.c文件中包含库文件,.h文件中对stm324xg_eval.h的包含去掉。编译通过。

/* Includes -----*/
#include "stm32f4xx.h"
#include "stm324xg_eval_fsmc_sram.h"

显示IC驱动调试

虽然说我们有驱动IC规格书,通常上还是需要LCD厂家提供驱动例程,特别是初始化代码。 创建驱动文件dev_ILI9341.c跟dev_ILI9341.h,保存在board_dev文件夹内。

  • 按照经验,首先调试到成功读取驱动IC的ID。

先根据硬件,修改FSMC驱动里面的配置。我们参考SRAM_Init函数,重写一个LCD FSMC初始化函数

/**
 *@brief:      mcu_fsmc_lcd_Init
 *@details:    LCD使用的FSMC初始化
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
void mcu_fsmc_lcd_Init(void)
{
  FSMC_NORSRAMInitTypeDef  FSMC_NORSRAMInitStructure;
  FSMC_NORSRAMTimingInitTypeDef  w,r;
  GPIO_InitTypeDef GPIO_InitStructure;

  /* Enable GPIOs clock */
  RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOD | RCC_AHB1Periph_GPIOE |
						 RCC_AHB1Periph_GPIOG, ENABLE);

  /* Enable FSMC clock */
  RCC_AHB3PeriphClockCmd(RCC_AHB3Periph_FSMC, ENABLE);

/*-- GPIOs Configuration -----------------------------------------------------*/
/*
 +-------------------+--------------------+------------------+------------------+
PD0	<-> FSMC_D2  
PD1	<-> FSMC_D3  
PD4	<-> FSMC_NOE
PD5	<-> FSMC_NWE
PD8	<-> FSMC_D13  
PD9	<-> FSMC_D14
PD10 <-> FSMC_D15
PD14 <-> FSMC_D0   
PD15 <-> FSMC_D1   

PE7	<-> FSMC_D4    
PE8	<-> FSMC_D5   
PE9	<-> FSMC_D6  
PE10 <-> FSMC_D7   
PE11 <-> FSMC_D8   
PE12 <-> FSMC_D9   
PE13 <-> FSMC_D10  
PE14 <-> FSMC_D11  
PE15 <-> FSMC_D12  

PG5 <-> FSMC_A15 |
PG12 <-> FSMC_NE4 |
*/

  /* GPIOD configuration */
  GPIO_PinAFConfig(GPIOD, GPIO_PinSource0, GPIO_AF_FSMC);
  GPIO_PinAFConfig(GPIOD, GPIO_PinSource1, GPIO_AF_FSMC);
  GPIO_PinAFConfig(GPIOD, GPIO_PinSource4, GPIO_AF_FSMC);
  GPIO_PinAFConfig(GPIOD, GPIO_PinSource5, GPIO_AF_FSMC);
  GPIO_PinAFConfig(GPIOD, GPIO_PinSource8, GPIO_AF_FSMC);
  GPIO_PinAFConfig(GPIOD, GPIO_PinSource9, GPIO_AF_FSMC);
  GPIO_PinAFConfig(GPIOD, GPIO_PinSource10, GPIO_AF_FSMC);
  GPIO_PinAFConfig(GPIOD, GPIO_PinSource14, GPIO_AF_FSMC);
  GPIO_PinAFConfig(GPIOD, GPIO_PinSource15, GPIO_AF_FSMC);

  GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0| GPIO_Pin_1|GPIO_Pin_4|GPIO_Pin_5 |
				GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_14|GPIO_Pin_15;
  GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF;
  GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz;
  GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;
  GPIO_InitStructure.GPIO_PuPd	= GPIO_PuPd_NOPULL;

  GPIO_Init(GPIOD, &GPIO_InitStructure);

  /* GPIOE configuration */
  GPIO_PinAFConfig(GPIOE, GPIO_PinSource7 , GPIO_AF_FSMC);
  GPIO_PinAFConfig(GPIOE, GPIO_PinSource8 , GPIO_AF_FSMC);
  GPIO_PinAFConfig(GPIOE, GPIO_PinSource9 , GPIO_AF_FSMC);
  GPIO_PinAFConfig(GPIOE, GPIO_PinSource10 , GPIO_AF_FSMC);
  GPIO_PinAFConfig(GPIOE, GPIO_PinSource11 , GPIO_AF_FSMC);
  GPIO_PinAFConfig(GPIOE, GPIO_PinSource12 , GPIO_AF_FSMC);
  GPIO_PinAFConfig(GPIOE, GPIO_PinSource13 , GPIO_AF_FSMC);
  GPIO_PinAFConfig(GPIOE, GPIO_PinSource14 , GPIO_AF_FSMC);
  GPIO_PinAFConfig(GPIOE, GPIO_PinSource15 , GPIO_AF_FSMC);

  GPIO_InitStructure.GPIO_Pin = GPIO_Pin_7 |
				GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_11|
				GPIO_Pin_12|GPIO_Pin_13|GPIO_Pin_14|GPIO_Pin_15;

  GPIO_Init(GPIOE, &GPIO_InitStructure);

  /* GPIOG configuration */
  GPIO_PinAFConfig(GPIOG, GPIO_PinSource5 , GPIO_AF_FSMC);
  GPIO_PinAFConfig(GPIOG, GPIO_PinSource12 , GPIO_AF_FSMC);

  GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5  |GPIO_Pin_12;		

  GPIO_Init(GPIOG, &GPIO_InitStructure);

/*-- FSMC Configuration ------------------------------------------------------*/
  w.FSMC_AddressSetupTime = 15;
  w.FSMC_AddressHoldTime = 0;
  w.FSMC_DataSetupTime = 15;
  w.FSMC_BusTurnAroundDuration = 0;
  w.FSMC_CLKDivision = 0;
  w.FSMC_DataLatency = 0;
  w.FSMC_AccessMode = FSMC_AccessMode_A;

  r.FSMC_AddressSetupTime = 16;
  r.FSMC_AddressHoldTime = 0;
  r.FSMC_DataSetupTime = 24;
  r.FSMC_BusTurnAroundDuration = 0;
  r.FSMC_CLKDivision = 0;
  r.FSMC_DataLatency = 0;
  r.FSMC_AccessMode = FSMC_AccessMode_A;

  FSMC_NORSRAMInitStructure.FSMC_Bank = FSMC_Bank1_NORSRAM4;
  FSMC_NORSRAMInitStructure.FSMC_DataAddressMux = FSMC_DataAddressMux_Disable;
  FSMC_NORSRAMInitStructure.FSMC_MemoryType = FSMC_MemoryType_SRAM;
  FSMC_NORSRAMInitStructure.FSMC_MemoryDataWidth = FSMC_MemoryDataWidth_16b;
  FSMC_NORSRAMInitStructure.FSMC_BurstAccessMode = FSMC_BurstAccessMode_Disable;
  FSMC_NORSRAMInitStructure.FSMC_AsynchronousWait = FSMC_AsynchronousWait_Disable;
  FSMC_NORSRAMInitStructure.FSMC_WaitSignalPolarity = FSMC_WaitSignalPolarity_Low;
  FSMC_NORSRAMInitStructure.FSMC_WrapMode = FSMC_WrapMode_Disable;
  FSMC_NORSRAMInitStructure.FSMC_WaitSignalActive = FSMC_WaitSignalActive_BeforeWaitState;
  FSMC_NORSRAMInitStructure.FSMC_WriteOperation = FSMC_WriteOperation_Enable;
  FSMC_NORSRAMInitStructure.FSMC_WaitSignal = FSMC_WaitSignal_Disable;
  FSMC_NORSRAMInitStructure.FSMC_ExtendedMode = FSMC_ExtendedMode_Enable;
  FSMC_NORSRAMInitStructure.FSMC_WriteBurst = FSMC_WriteBurst_Disable;
  FSMC_NORSRAMInitStructure.FSMC_ReadWriteTimingStruct = &r;
  FSMC_NORSRAMInitStructure.FSMC_WriteTimingStruct = &w;

  FSMC_NORSRAMInit(&FSMC_NORSRAMInitStructure);

  /*!< Enable FSMC Bank1_SRAM4 Bank */
  FSMC_NORSRAMCmd(FSMC_Bank1_NORSRAM4, ENABLE);

}
15/19行,打开设备时钟。 48~92,配置对应的IO口为FSMC功能 94~109,设置FSMC时序 111,选择BANK 113,设置类型,LCD可以设置为SRAM。 114,设置数据宽度。

在《ILI9341_DS_V1.09_20110315.pdf》第18章,有对时序说明,例如下图就是8080-I时序图。 _images/98.png9341 lcd时序

配置好之后,编写ILI6341驱动初始化,读取设备ID。读取ID的命令为0XD3,具体见下图。 _images/106.png9341 读ID命令

初始化代码如下(只是读ID,不是完整初始化)

u16 *LcdReg = (u16*)0x6C000000;
u16 *LcdData = (u16*)0x6C010000;

s32 dev_ILI9341_init(void)
{
	u16 data;

	//初始化背光控制管脚

	//初始FSMC
	mcu_fsmc_lcd_Init();
	uart_printf("init finish!\r\n");
	//延时50毫秒
	Delay(5);

  /*
  LcdReg = 0x0000;//首先使用命令写入要读的寄存器的地址
	data = *LcdData;//读寄存器
	uart_printf("read reg:%04x\r\n", data);
  */
	*LcdReg = 0x00d3;

	data = *LcdData; //dummy read
	data = *LcdData; //读到 0X00
	data = *LcdData; //读取 93
	data<<=8;
	data |= *LcdData; //读取 41

	uart_printf("read reg:%04x\r\n", data);

	return 0;
}

为了方便操作,将命令跟数据地址定义为一个u16指针,初始化为对应的地址。 程序首先初始化FSMC,然后按照D3命令流程进行操作。 将dev_ILI9341_init添加到main函数中,对LCD进行初始化。 下载到硬件后,发现读到的ID是0X00。调试很久,都没能读取ID。 在网上查询,发现一个信息,就是MDK会对程序进行优化,需要增加一些额外的代码。我们先看下是不是程序被优化掉了。 用CMSIS DAP进行调试,在函数加断点。 _images/112.pngFSMC读数据被优化 如图,47/48/49三行读数据的代码,编译后被优化成读一次了。编译器估计是想,连续读三次没有意义。 如何防止优化呢?不需要添加额外代码,编译器功能是很强大的,只需要在定义指针处,添加volatile关键字即可。如下

volatile u16 *LcdReg = (u16*)0x6C000000;
volatile u16 *LcdData = (u16*)0x6C010000;

编译后使用CMSIS DAP进行调试,查看汇编代码,区别很明显,每次读操作都执行了。 _images/121.png修改后不优化 调试信息也可以看到,成功读取到ID _images/132.png读ID成功 对于TFT LCD与FSMC的调试,在硬件上已经完成。 下一步就是如何实现TFTLCD提供给上一层的功能与接口了。

驱动接口定义

在参考屏厂提供的代码时发现,为了兼容多种驱动IC,他们的代码变得如此混乱。

我们决定重新设计。

通过使用函数指针的方式,调用不同的驱动IC函数,而不是在一个函数内通过if-else选择不同驱动IC代码。 每个LCD驱动对外接口和功能设计,经讨论,主要提供以下功能:

	s32 (*init)(void);
	s32 (*draw_point)(u16 x, u16 y, u16 color);
	s32 (*color_fill)(u16 sx,u16 ex,u16 sy,u16 ey, u16 color);
	s32 (*fill)(u16 sx,u16 ex,u16 sy,u16 ey,u16 *color);
	s32 (*onoff)(u8 sta);
	s32 (*prepare_display)(u16 sx, u16 ex, u16 sy, u16 ey);
	void (*set_dir)(u8 scan_dir);
	void (*backlight)(u8 sta);
  1. 初始化
  2. 画点
  3. 将一个矩形区域显示为同一种颜色。
  4. 将一个区域根据输入的颜色显示,点颜色不一样。
  5. 开关显示
  6. 准备显示区域(摄像头显示需要,其实就是接口4的准备过程)
  7. 设置扫描方向
  8. 背光控制

坐标定义

在开始写显示程序是,我们先要对坐标进行定义。并且将用户跟驱动IC进行统一。

  • 用户角度

有横屏竖屏,无论横竖,人眼角度左上角是原点。 坐标使用XY轴概念。例如:

一个320*240的彩屏,竖屏的时候,X轴就是240短边,Y轴就是320长边。 如果是一个COG12864的黑白屏,默认就是横屏,128长边就是X轴,64短边就是Y轴,如果设置为竖屏,则短边是X轴。
  • 驱动IC

没有横屏竖屏的概念,只有扫描方向,也不使用XY轴,而是使用COLUMPAGE(我们简称CP坐标概念)。 不同的扫描方向,不会改变原点位置,也不改变XY方向,只是改变了显示数据的组织顺序。 不同的驱动IC,能设置的扫描方向不一样,同样的扫描方向设置,真正出来的效果也不一样。 因此每个驱动IC都需要将XY轴转换为自己的CP坐标。

所有对外接口提供的是XY轴坐标,驱动内部自行转换。

  • 参数

所有函数都是给人用的,所以坐标使用XY坐标。 按照下面参数顺序: sx:X轴起始,ex:X轴结束坐标; sy:Y轴起始,ey:Y轴结束坐标; 例如:

s32 drv_ILI9325_color_fill(u16 sx,u16 ex,u16 sy,u16 ey,u16 color)

我们定义了一个函数设置屏幕方向:

void dev_lcd_setdir(u8 dir, u8 scan_dir)

dir就是横屏还是竖屏,scan_dir就是扫描方向。 这个函数名并没有使用驱动IC的关键字,就像前面说的,横屏还是竖屏不是驱动IC关心的事。 在这个函数内,根据横屏竖屏对scan_dir进行映射,映射转换之后再设置驱动IC。

显示字符

显示字符函数是从TSLIB移植过来的,只是为了显示基本英文字符。 每个像素对应一个GRAM内存,只要按照字符排列将对应的GRAM设置为对应颜色,就是显示字符了。

液晶识别

很多开发板提供液晶驱动,都是在初始化函数内尝试读ID,根据读到的ID进行if-else选择不同驱动IC的初始化。 这样的代码组织非诚不好。 我们的组织方式如下:

s32 dev_lcd_init(void)
{
	s32 ret = -1;

	/*初始化8080接口,包括背光信号*/
	bus_8080interface_init();

	if(ret != 0)
	{
		/*尝试初始化9341*/
		ret = drv_ILI9341_init();
		if(ret == 0)
		{
			LCD.drv = &TftLcdILI9341Drv;//将9341驱动赋值到LCD
			LCD.dir = H_LCD;//默认竖屏
			LCD.height = 320;
			LCD.width = 240;
		}
	}

	#ifdef TFT_LCD_DRIVER_9325
	if(ret != 0)
	{
		/* 尝试初始化9325 */
		ret = drv_ILI9325_init();
		if(ret == 0)
		{
			LCD.drv = &TftLcdILI9325Drv;
			LCD.dir = H_LCD;
			LCD.height = 320;
			LCD.width = 240;
		}
	}
	#endif
	/*设置屏幕方向,扫描方向*/
	dev_lcd_setdir(H_LCD, L2R_U2D);
	LCD.drv->onoff(1);//打开显示
	bus_8080_lcd_bl(1);//打开背光
	LCD.drv->color_fill(0, LCD.width, 0, LCD.height, YELLOW);

	return 0;
}

LCD初始化函数中调用驱动IC初始化函数,如果返回成功,则说明是这个驱动IC的LCD。 这样的组织方式有什么不一样呢?

  1. 有层次,LCD_INIT不知道各个驱动IC是怎么初始化的,只是调用函数。
  2. 解耦合,每个驱动IC的初始化,只是识别自己的ID,不与其它驱动IC的代码纠缠在一起。

测试

测试函数如下,LCD的初始化不在测试函数初始化,在main函数中初始化。

void dev_lcd_test(void)
{

	while(1)
	{		
		LCD.drv->color_fill(0,LCD.width,0,LCD.height,BLUE);
		Delay(100);
		LCD.drv->color_fill(0,LCD.width/2,0,LCD.height/2,RED);
		Delay(100);
		LCD.drv->color_fill(0,LCD.width/4,0,LCD.height/4,GREEN);
		Delay(100);

		put_string_center (LCD.width/2+50, LCD.height/2+50,
			   "ADCD WUJIQUE !", 0xF800);
		Delay(100);
	}

}

到此,LCD调试结束,至于各种花哨的显示,属于应用范畴。 各位可以尝试修改dev_lcd_setdir(H_LCD, L2R_U2D);看看不同的屏幕方向,不同的扫描方向,都是什么效果。 要看清扫描方向的区别,请在在fill函数内加延时

总结

  1. 本文档除了介绍LCD如何使用,更加希望大家能知道LCD驱动架构如何设计。
  2. 本文档对应的代码并不是最完善的代码,只是为了测试硬件。

如果要用于实际项目,请到github或者官网下载最新代码。最新代码兼容了各种LCD。 并且有一套很好的代码架构。 关于LCD驱动设计,同时可以参考我们编写的《LCD驱动应该怎么写?.pdf》


end

ADC-TSLIB-电阻式触摸屏调试

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


上一节已经将TFT LCD调通,这块LCD表面带了一块四线电阻式触摸屏。 现在,就让我们来调试触摸功能。 为了能深刻领会触摸屏检测方法,我们这次用内部ADC进行触摸屏检测。 下一节我们再使用XPT2046控制触摸屏。

ADC

前面我们已经调试了DAC,ADC就是反过来:将IO口上的电压(模拟信号)转换为数字值。 同样有参考电压,ADC也有位数(精度)。 百度百科:

ADC,Analog-to-Digital Converter的缩写,指模/数转换器或者模数转换器。是指将连续变化的模拟信号转换为离散的数字信号的器件。真实世界的模拟信号,例如温度、压力、声音或者图像等,需要转换成更容易储存、处理和发射的数字形式。模/数转换器可以实现这个功能,在各种不同的产品中都可以找到它的身影。 与之相对应的DAC,Digital-to-Analog Converter,它是ADC模数转换的逆向过程

STM32 ADC

简介

12位ADC是逐次趋近型模数转换器。它具有多达19个复用通道,可测量来自16个外部源、两个内部源和VBAT通道的信号。这些通道的A/D转换可在单次、连续、扫描或不连续采样模式下进行。ADC的结果存储在一个左对齐或右对齐的16位寄存器中。

特性

STM32的ADC功能强大,主要特性如下: _images/pic19.jpgADC特性 本次我们只会用到最简单的单次转换功能。

框图

_images/pic23.jpgADC框图 从框图可以看出:

  1. 有两种触发:注入组、规则组。所谓的注入组,就像中断,可以打断规则组的转换。
  2. 支持DMA和中断。
  3. 左边输入多达16路。

在屋脊雀STM32F407硬件,使用PB0跟PB1作为ADC转换输入。 查数据手册可知PB0是ADC12_IN8,PB1是ADC12_IN9_images/pic34.jpgPB口

电阻触摸屏

四线电阻屏部分内容参考网络文章电阻式触摸屏的基本结构和驱动原理,网址 http://article.cechina.cn/2009-03/200937110119.htm

四线电阻式触摸屏的结构如图1,在玻璃或丙烯酸基板上覆盖有两层透平,均匀导电的ITO层,分别做为X电极和Y电极,它们之间由均匀排列的透明格点分开绝缘。其中下层的ITO与玻璃基板附着,上层的ITO附着在PET薄膜上。X电极和Y电极的正负端由“导电条”(图中黑色条形部分)分别从两端引出,且X电极和Y电极导电条的位置相互垂直。引出端X-,X+,Y-,Y+一共四条线,这就是四线电阻式触摸屏名称的由来。 _images/19.png触摸屏原理1 当有物体接触触摸屏表面并施以一定的压力时,上层的ITO导电层发生形变与下层ITO发生接触,如下图2: _images/27.png触摸屏压原理 该结构可以等效为相应的电路,如图3: _images/35.png触摸屏等效电路

我们将图三转化为原理图 _images/43.png触摸屏等效电路原理图

  1. 在Y+加上VCC,Y-接地,不同的触摸点,R_Y+与R_Y-的分压就不一样,通过X+或者X-,就可以检测出Y轴上的分压值。
  2. 在X+加上VCC,X-接地,不同的触摸点,R_X+与R_X-的分压就不一样,通过Y+或者Y-,就可以检测出X轴上的分压值。
  3. 在X+加上VCC,Y+接地,不同的触摸压力,R_touch阻值不一样,压力越大,阻值越小,通过X-和Y-,就可以检测出R_TOUCH两端的分压值。

通过上面列出三步,我们就可以计算出一次触摸的触摸力度、X坐标、Y坐标。 这就是四线触摸屏的基本原理。

方案原理说明

_images/53.png屋脊雀触摸屏电路 上图为我们使用的LCD上的触摸屏处理电路路。 使用了一片74HC4052“两路四选一模拟开关”作为电子开关,用于切换触摸屏四根线连接到哪里:电压、地、ADC。

TP-X-、TP-X+、TP-Y-、TP-Y+就是电阻屏的四根线。 ADC-TPX、ADC-TPY则为两个ADC的输入信号。 TP-S0、TP-S1为电子开关选择信号。

74HC4052的逻辑

在第6脚使能脚为0时, S1=0, S0=0:AN连到A0,BN连到B0; S1=0, S0=1:AN连到A1,BN连到B1; S1=1, S0=0:AN连到A2,BN连到B2; S1=1, S0=1:AN连到A3,BN连到B3; 使能脚为1,则全部断开。

根据上一节分析的触摸屏原理,要获取触摸信号,我们程序的流程如下:

  1. s1=0,s0=0,这时TP-X-接地,TP-X+接VCC3V3,通过ADC-TPY对TP-Y+上的电压转换,就可以得到X轴上的坐标。
  2. s1=0,s0=1,这时TP-Y-接地,TP-Y+接VCC3V3,通过ADC-TPX对TP-X+上的电压转换,就可以得到Y轴上的坐标。
  3. s1=1,s0=0,这时TP-X-接地,TP-Y-接VCC3V3,通过ADC-TPY对TP-Y+上的电压转换,ADC-TPX对TP-X+上的电压转换,就可以算出R_TOUCH上的压降。因为R_TOUCH较小,为了防止当R_Y-跟R_X-也叫小的时候产生大电流,在VCC3V3跟TP-Y-之间串接了一个10K电阻。

TsLib

根据上一节的分析,通过ADC我们已经能获取到触摸点的坐标与压力值。但是是否就可以使用触摸屏了呢? 可以非常肯定的说,不行。主要有下面两个问题:

  1. 由于不同的触摸压力,加上触摸屏本身的离散性,ADC得到的坐标会飘,也就是常说的飞点。
  2. 触摸屏与LCD虽然物理上是对应的,触摸坐标与显示坐标之间却不是对应的,并且不是普通的线性比例关系。

由于有上面两个原因,对采样得到的数据进行普通的滤波加权,再按线性比例转换为显示屏坐标,效果会很差。 很庆幸的是,在开源界有一个叫做tslib的程序

Tslib是一个开源的程序,能够为触摸屏驱动获得的采样提供诸如滤波、去抖、校准等功能,通常作为触摸屏驱动的适配层,为上层的应用提供了一个统一的接口。

最新的tslib库在github上 https://github.com/kergoth/tslib 由于TSLIB主要是LINUX平台的库,包含了很多LINUX框架代码。对源码我们就不做太多分析。 同时,我们并没有基于最新的库做移植,而是使用更早的1.4版本。1.4源码文件如下。 _images/64.pngTSLIB文件结构 在网络有一篇文章对TSLIB的工作流程做了分析 http://blog.csdn.net/evilcode/article/details/7493704 一些关键信息如下:

pthres 为Tslib 提供的触摸屏灵敏度门槛插件; variance 为Tslib 提供的触摸屏滤波算法插件; dejitter 为Tslib 提供的触摸屏去噪算法插件; linear 为Tslib 提供的触摸屏坐标变换插件。
在tslib 中为应用层提供了2 个主要的接口ts_read()和ts_read_raw(),其中ts_read()为正常情况下的接口,ts_read_raw()为校准情况下的接口。
正常情况下,tslib 对驱动采样到的设备坐标进行处理的一般过程如下: raw device –> pthres –> variance –> dejitter –> linear –> application

驱动程序设计

触摸屏程序架构如下: _images/pic44.jpg驱动架构

  1. 蓝色是应用层。主要就是TSLIB库。
  2. 褐色是接口封装层,主要功能是将两种不同的驱动方案封装统一接口。 触摸屏接口设计如下:
s32 dev_touchscreen_init(void)
s32 dev_touchscreen_open(void)
s32 dev_touchscreen_close(void)
s32 dev_touchscreen_read(struct ts_sample *samp, int nr)
s32 dev_touchscreen_write(struct ts_sample *samp, int nr)
s32 dev_touchscreen_ioctrl(void)

其中dev_touchscreen_read提供给上层使用。TSLIB使用这个接口从缓冲读样点。 dev_touchscreen_write提供给下层使用。触摸检测程序(ADC方案或者XPT2046驱动),检测到样点后用这个函数写到缓冲区。 样点结构体如下,这是tslib中定义的,包含x轴坐标、y轴坐标、压力、时间戳,时间戳我们不用,屏蔽掉。

struct ts_sample //触摸屏一个样点
{
	int		x;
	int		y;
	unsigned int	pressure;
	//struct timeval	tv;//时间,移植到STM32平台,应该不需要
};
  1. 橙色是样点缓冲,是上层与底层联系。通过使用样点华冲,应用层和驱动层能做到解耦合。
  2. 浅紫色就是两种不同方案的驱动代码,他们获取到样点后都填入缓冲。
  3. 绿色是硬件。

编码调试记录

调试ADC触摸屏检测方案分三步:

  1. ADC转换功能。
  2. 触摸检测流程。
  3. TSLIB(校准和测试)。

调试ADC基本功能

在mcu_dev目录下添加mcu_adc.c和mcu_adc.h。 首先调试ADC,跑通。

只要ADC转换可以完成,读到数据就可了,数据是否准确,暂时不用处理。
  • 初始化
void mcu_adc_init(void)
{
    ADC_InitTypeDef ADC_InitStructure;
    ADC_CommonInitTypeDef ADC_CommonInitStructure;
    GPIO_InitTypeDef GPIO_InitStructure;

    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB, ENABLE);//使用GPIOB时钟

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AN;//---模拟模式
    GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_NOPULL;//---不上下拉
    GPIO_Init(GPIOB, &GPIO_InitStructure);//---初始化 GPIO

    RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC2, ENABLE);//使能ADC 时钟

    ADC_CommonInitStructure.ADC_Mode = ADC_Mode_Independent;//独立模式
    //两个采样阶段之间的延迟 5 个时钟
    ADC_CommonInitStructure.ADC_TwoSamplingDelay = 	ADC_TwoSamplingDelay_20Cycles;
    ADC_CommonInitStructure.ADC_DMAAccessMode = ADC_DMAAccessMode_Disabled; //DMA 失能
    ADC_CommonInitStructure.ADC_Prescaler = ADC_Prescaler_Div8;//预分频 6分频。
    ADC_CommonInit(&ADC_CommonInitStructure);//初始化

    ADC_StructInit(&ADC_InitStructure);
    ADC_InitStructure.ADC_Resolution = ADC_Resolution_12b;//12 位模式
    ADC_InitStructure.ADC_ScanConvMode = DISABLE;//非扫描模式
    ADC_InitStructure.ADC_ContinuousConvMode = DISABLE;//非连续转换
    ADC_InitStructure.ADC_ExternalTrigConvEdge = ADC_ExternalTrigConvEdge_None;//禁止触发检测,使用软件触发
    //本值是触发源,我们已经禁止触发,因此本值无意义
    ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T1_CC1;
    ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;//右对齐
    ADC_InitStructure.ADC_NbrOfConversion = 1;//1 个转换在规则序列中, 也就是说一次转换一个通道
    ADC_Init(ADC2, &ADC_InitStructure);//ADC 初始化

    #ifdef MCU_ADC_IRQ
    NVIC_InitTypeDef NVIC_InitStructure;

    NVIC_InitStructure.NVIC_IRQChannel = ADC_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;//抢占优先级
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;      //响应优先级
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);

   ADC_ITConfig(ADC2,  ADC_IT_EOC,  ENABLE);//打开ADC EOC中断
   ADC_ClearFlag(ADC2, ADC_FLAG_EOC);
   #endif

  ADC_Cmd(ADC2, ENABLE);
}
/**

初始化分4部分:

  1. 7到12行,初始化IO,将对应的IO配置为ADC功能。
  2. 14到21行,ADC通用配置
  3. 23到32行,ADC转换配置。
  4. 34到45行,如果使用中断,就配置中断。
  • 转换(查询方式)

第10行设置转换通道组。 第11行启动转换,在while内查询标志,ADC_FLAG_EOC标志置位就说明转换结束了。 54行调用ADC_GetConversionValue函数读取转换结果。

u16 mcu_adc_get_conv(u8 ch)
{
	u16 adcvalue;
	FlagStatus ret;

	//设置指定 ADC 的规则组通道,一个序列,采样时间
	MCU_ADC_DEBUG(LOG_DEBUG, "str--");
	ADC_ClearFlag(ADC2, ADC_FLAG_OVR);

	ADC_RegularChannelConfig(ADC2, ch, 1, ADC_SampleTime_480Cycles );
	ADC_SoftwareStartConv(ADC2); 	//使能指定的 ADC 的软件转换启动功能

	while(1)//等待转换结束
	{
		ret = ADC_GetFlagStatus(ADC2, ADC_FLAG_EOC);
		if(ret == SET)
		{
			MCU_ADC_DEBUG(LOG_DEBUG, "ADC_FLAG_EOC\r\n");
			break;
		}
		ret = ADC_GetFlagStatus(ADC2, ADC_FLAG_AWD);
		if(ret == SET)
		{
			MCU_ADC_DEBUG(LOG_DEBUG, "ADC_FLAG_AWD\r\n");

		}
		ret = ADC_GetFlagStatus(ADC2, ADC_FLAG_JEOC);
		if(ret == SET)
		{
			MCU_ADC_DEBUG(LOG_DEBUG, "ADC_FLAG_JEOC\r\n");

		}
		ret = ADC_GetFlagStatus(ADC2, ADC_FLAG_JSTRT);
		if(ret == SET)
		{
			MCU_ADC_DEBUG(LOG_DEBUG, "ADC_FLAG_JSTRT\r\n");

		}

		ret = ADC_GetFlagStatus(ADC2, ADC_FLAG_STRT);
		if(ret == SET)
		{
			MCU_ADC_DEBUG(LOG_DEBUG, "ADC_FLAG_STRT\r\n");

		}

		ret = ADC_GetFlagStatus(ADC2, ADC_FLAG_OVR);
		if(ret == SET)
		{
			MCU_ADC_DEBUG(LOG_DEBUG, "ADC_FLAG_OVR\r\n");

		}
	}
	adcvalue = ADC_GetConversionValue(ADC2);
	return adcvalue;
}
  • 转换(中断模式)

分两部分:启动转换、中断服务。 启动如下,其实也就是查询模式一样。

s32 mcu_adc_start_conv(u8 ch)
{
	ADC_RegularChannelConfig(ADC2, ch, 1, ADC_SampleTime_480Cycles );
	ADC_SoftwareStartConv(ADC2); 	//使能指定的 ADC 的软件转换启动功能
	return 0;
}

中断服务,其实跟查询模式while循环一样。

void mcu_adc_IRQhandler(void)
{
	volatile u16 adc_value;
	FlagStatus ret;
	ITStatus itret;

	itret = ADC_GetITStatus(ADC2, ADC_IT_EOC);
	if( itret == SET)
    {    

		ret = ADC_GetFlagStatus(ADC2, ADC_FLAG_EOC);
		if(ret == SET)
		{
			//uart_printf("ADC_FLAG_EOC t\r\n");
			adc_value = ADC_GetConversionValue(ADC2);
			MCU_ADC_DEBUG(LOG_DEBUG, "-%d ", adc_value);
			//dev_ts_adc_task(adc_value);

		}

		ret = ADC_GetFlagStatus(ADC2, ADC_FLAG_AWD);
		if(ret == SET)
		{
			MCU_ADC_DEBUG(LOG_DEBUG, "ADC_FLAG_AWD t\r\n");

		}
		ret = ADC_GetFlagStatus(ADC2, ADC_FLAG_JEOC);
		if(ret == SET)
		{
			MCU_ADC_DEBUG(LOG_DEBUG, "ADC_FLAG_JEOC t\r\n");

		}
		ret = ADC_GetFlagStatus(ADC2, ADC_FLAG_JSTRT);
		if(ret == SET)
		{
			MCU_ADC_DEBUG(LOG_DEBUG, "ADC_FLAG_JSTRT t\r\n");

		}

		ret = ADC_GetFlagStatus(ADC2, ADC_FLAG_STRT);
		if(ret == SET)
		{
			//uart_printf("ADC_FLAG_STRT t\r\n");

		}

		ret = ADC_GetFlagStatus(ADC2, ADC_FLAG_OVR);
		if(ret == SET)
		{
			MCU_ADC_DEBUG(LOG_DEBUG, "ADC_FLAG_OVR t\r\n");
			ADC_ClearFlag(ADC2, ADC_FLAG_OVR);
		}

		ADC_ClearITPendingBit(ADC2, ADC_IT_EOC);
    }
}

从上可以看出,其实中断模式就是将查询模式拆成两部分。 中断服务要在stm32f4xx_it.c中调用。

void ADC_IRQHandler(void)
{
	mcu_adc_IRQhandler();
}
  • 测试程序

测试程序,支持查询和中断模式,通过宏MCU_ADC_IRQ控制。

s32 mcu_adc_test(void)
{
	mcu_adc_init();

#ifndef MCU_ADC_IRQ/*查询模式*/
	u16 adc_value;

	wjq_log(LOG_FUN, "mcu_adc_test check\r\n");

	while(1)
	{
		adc_value = mcu_adc_get_conv(ADC_Channel_8);

		wjq_log(LOG_FUN, "ADC_Channel_8:%d\r\n", adc_value);
		Delay(1000);

		adc_value = mcu_adc_get_conv(ADC_Channel_9);
		wjq_log(LOG_FUN, "ADC_Channel_9:%d\r\n", adc_value);
		Delay(1000);
	}
#else/*中断模式*/
	wjq_log(LOG_FUN, "mcu_adc_test int\r\n");

	while(1)
	{		
		wjq_log(LOG_FUN, "r ");
		mcu_adc_start_conv(ADC_Channel_8);
		Delay(1000);
		wjq_log(LOG_FUN, "d ");
		mcu_adc_start_conv(ADC_Channel_9);
		Delay(1000);
	}

#endif
}

在main函数中调用就可以测试了。

  • 测试结果

ADC是12位的,只要转换结果在0-4096范围内,ADC工作就是正常的。

mcu_adc_test int r 3403 d 1482 r 3401 d 3755 r 3399 d 3754 r 1794 d 3752 r 1814 d 3760 r 2752 d 3758 r 3407 d 2440 r 3398 d 2389 r 3405 d 1719 r 2462 d 2696 r 3398 d 1614 r 3402 d 2227 r 3399 d 1593 r 3395

调试触摸屏检测流程

在board_dev目录增加触摸屏驱动文件dev_touchscreen.c和dev_touchscreen.h。代码根据前面分析的触摸屏检测流程实现。

  • 74HC4052硬件测试

首先使用ADC查询模式测试,验证电路切换功能是否正常。 先检测触摸屏压力

用笔压触摸屏,检测到电压变化。理论上,在Y+上检测到的电压应该大于等于X+上的电压。 当压触摸屏时,Y+将变小,但是不会小于X+。压力越大,越靠近X+。

再调试X轴的检测

如果没有触摸,检测到一个不准确的值。当有触摸时,电压值与触摸位置基本成线性关系。因此算法上必须要先判断压力,再检测坐标。

再调试Y轴的检测

与X轴类似

以上三步调通后,开始调试整个流程,并且要加快采样速度。 三步都调通的测试代码如下:

s32 dev_touchscreen_test(void)
{
	u16 adc_x_value;
	u16 adc_y_value;
	u16 pre;//压力差

	dev_touchscreen_init();
	dev_touchscreen_open();

	while(1)
	{
		Delay(1000);
		/*  检测压力 */
		DEV_TP_PRESS_SCAN;
		adc_y_value = mcu_adc_get_conv(ADC_Channel_9);
		adc_x_value = mcu_adc_get_conv(ADC_Channel_8);
		pre = adc_y_value-adc_x_value;
		TS_DEBUG(LOG_FUN,"touch pre:%d, %d, %d\r\n",
		            adc_x_value, adc_y_value, pre);

		if(adc_x_value + 200 > adc_y_value)//200为测试阀门,实际中要调试
		{
			/* 检测X轴*/
			DEV_TP_SCAN_X;
			adc_x_value = mcu_adc_get_conv(ADC_Channel_9);
			//uart_printf("ADC_Channel_8:%d\r\n", adc_x_value);

			/* 检测Y轴*/
			DEV_TP_SCAN_Y;
			adc_y_value = mcu_adc_get_conv(ADC_Channel_8);
			//uart_printf("ADC_Channel_8:%d\r\n", adc_y_value);

			TS_DEBUG(LOG_FUN,"----------get a touch:%d, %d, %d\r\n",
			          adc_x_value, adc_y_value, pre);

		}
}

调试信息如下,可以看出X+的电压基本接近0,在没触摸时,Y+跟X+的差距非常大。

touch pre:69, 221, 152 ———-get a touch:1738, 2193, 152 touch pre:59, 223, 164 ———-get a touch:1741, 2191, 164 touch pre:61, 229, 168 ———-get a touch:1741, 2188, 168 touch pre:2, 4064, 4062 touch pre:9, 4065, 4056 touch pre:2, 4071, 4069 touch pre:9, 4068, 4059

使用中断方式检测触摸屏

使用ADC终端的触屏检测流程如下:

开一个定时器,10-50毫秒启动一次触摸屏压力检测。 ADC结果在ADC中断中处理,如果检测到压下,则启动坐标检测。 采样得到的样点写入样点缓冲。并且连续检测。 因为用最快速度画过触摸屏,大约只需要50毫秒,50毫秒/320个点,一个点才150us。 因此有触摸时,连续转换。为了节省系统开销,只好用ADC中断。

修改Touchscreen驱动,编写使用ADC中断的转换流程。 创建了一个TASK函数,在函数中处理ADC转换的各个步骤。这个函数在ADC中断中调用,每当采样到一个样点,就传入触摸屏处理流程。

void mcu_adc_IRQhandler(void)
{
	volatile u16 adc_value;
	FlagStatus ret;
	ITStatus itret;

	itret = ADC_GetITStatus(ADC2, ADC_IT_EOC);
	if( itret == SET)
	{    

		ret = ADC_GetFlagStatus(ADC2, ADC_FLAG_EOC);
		if(ret == SET)
		{
			//uart_printf("ADC_FLAG_EOC t\r\n");
			adc_value = ADC_GetConversionValue(ADC2);
			MCU_ADC_DEBUG(LOG_DEBUG, "%d ", adc_value);

			dev_ts_adc_task(adc_value);

		}

在这个流程中,有一个地方比较关键,也就是压力的判断,压力差小于250,算下笔,压力差大于2000,算起笔,200到2000,是一个过渡,会抖动,丢弃。当然,250跟2000是假设,具体根据硬件调试情况配置。

然后修改dev_touchscreen_test函数如下,在main中调用。

	struct ts_sample s;
	s32 ret;
	u8 pensta = 1;//没接触

	dev_touchscreen_init();
	dev_touchscreen_open();

	while(1)
	{
		ret = dev_touchscreen_read(&s,1);
		if(ret == 1)
		{
			if(s.pressure != 0 && pensta == 1)
			{
				pensta = 0;
				wjq_log(LOG_FUN, "pen down\r\n");
				wjq_log(LOG_FUN, ">%d %d %d-\r\n", s.pressure, s.x, s.y);
			}
			else if(s.pressure == 0 && pensta == 0)
			{
				pensta = 1;
				wjq_log(LOG_FUN, "\r\n--------pen up--------\r\n");

			}
		}
	}

运行测试程序后,触摸四个角的样点分别如下: 左上角

pen down 34 870 679- ——–pen up——–

左下角

pen down 117 857 3375- ——–pen up——–

右下角

pen down 146 3234 3369- ——–pen up——–

右上角

pen down 50 2991 709- ——–pen up——–

从数据可以看出,坐标基本符合方向。

  • TASK函数说明 整个代码最重要的函数就是s32 dev_ts_adc_task(u16 dac_value),这个函数由ADC中断调用,实现完整的触摸屏检测。
s32 dev_ts_adc_task(u16 dac_value)
{
	static u16 pre_y, pre_x;
	static u16 sample_x;
	static u8 pendownup = 1;
	struct ts_sample tss;

	if(TsAdcGd != 0)
		return -1;

	if(TouchScreenStep == 0)//压力检测第一步ADC转换结束
	{
		pre_y = dac_value;
		TouchScreenStep	= 1;
		mcu_adc_start_conv(ADC_Channel_8);
	}
	else if(TouchScreenStep == 1)
	{
		pre_x = dac_value;
		//TS_DEBUG(LOG_DEBUG, "--press :%d %d\r\n", pre_y, pre_x);

		if(pre_x + DEV_TP_PENDOWN_GATE > pre_y)
		{
			TouchScreenStep	= 2;
			DEV_TP_SCAN_X;
			mcu_tim7_start(2);
		}
		else if(pre_x + DEV_TP_PENUP_GATE < pre_y)
		{//没压力,不进行XY轴检测
			/* 起笔只上送一点缓冲*/
			if(pendownup == 0)
			{
				pendownup = 1;
				tss.pressure = 0;//压力要搞清楚怎么计算
				tss.x = 0xffff;
				tss.y = 0xffff;
				dev_touchscreen_write(&tss,1);

			}
			TouchScreenStep	= 0;

			DEV_TP_PRESS_SCAN;
			//打开一个定时器,定时时间到了才进行压力检测
			mcu_tim7_start(100);
		}
		else
		{
			/*上下笔的过渡,丢弃*/
			TouchScreenStep	= 0;

			DEV_TP_PRESS_SCAN;

			mcu_tim7_start(20);
		}
	}
	else if(TouchScreenStep == 2)
	{
		sample_x =  dac_value;

		TouchScreenStep	= 3;
		DEV_TP_SCAN_Y;
		mcu_tim7_start(2);
	}
	else if(TouchScreenStep == 3)//一轮结束,重启启动压力检测
	{
		//压力要搞清楚怎么计算
		tss.pressure = DEV_TP_PENDOWN_GATE-(pre_y - pre_x);
		tss.x = sample_x;
		tss.y = dac_value;
		dev_touchscreen_write(&tss,1);
		//TS_DEBUG(LOG_DEBUG, "tp :%d, %d, %d\r\n",
		//     tss.pressure, tss.x, tss.y);
		pendownup = 0;

		TouchScreenStep	= 0;
		DEV_TP_PRESS_SCAN;
		mcu_tim7_start(2);
	}
	else//异常,启动压力检测
	{
		TouchScreenStep	= 0;
		DEV_TP_PRESS_SCAN;
		mcu_tim7_start(100);
	}

	return 0;
}
  1. 函数流程根据触摸屏检测流程分步骤执行,整个大流程是循环模式。
  2. 11到16行为第0步,读取Y轴电压,然后启动X轴ADC检测。这一步其实属于第1步,前面有一步启动压力检测,并开始检测Y轴电压。
  3. 17到55行,第1步,这一步比较复杂。读到X轴电压后,进行压力判断。 22行到27行,压下,启动X轴坐标检测。 28到44行,松开,上送一点起笔,重新配置到压力检测,也就是第0步。 46到54行,压力过渡区,重新配置到压力检测,也就是第0步。
  4. 56到63行,读X轴坐标转换结果,配置为Y轴坐标检测。
  5. 64到75,读取Y轴坐标转换结果,写入缓冲,重新配置到压力检测,也就是第0步。
  6. 77到82,处理异常。
这段代码跟前面原理分析一致,大家好好体会整个处理过程。

与TSLIB联合

_images/pic51.jpgtslib参与编译的文件 stm32-raw.c是tslib跟dev_touchscreen.c的接口文件。 ts_input_read函数调用dev_touchscreen_read函数读取样点。

static int ts_input_read(struct tslib_module_info *inf,
			 struct ts_sample *samp, int nr)
{
	struct tslib_input *i = (struct tslib_input *)inf;
	struct tsdev *ts = inf->dev;
	int ret = nr;

	//uart_printf(" ts input read\r\n");
	ret = dev_touchscreen_read(samp, nr);
	#if 0
	if((samp->pressure != 0) && (ret == nr))
	{
		uart_printf("stm32 raw:%d, %d, %d\r\n", samp->pressure, samp->x, samp->y);
	}
	#endif

	return ret;
}

要分析tslib,可以从ts_config.c入手。 由于TSIB比较复杂,后续有一个文档专门解释tslib。 在此,直接提供源码,不做解释。 测试函数如下:

/* Infinite loop */
	mcu_uart_open(3);
	wjq_log(LOG_INFO, "hello word!\r\n");
	mcu_i2c_init();
	mcu_spi_init();
	dev_key_init();
	//mcu_timer_init();
	dev_buzzer_init();
	dev_tea5767_init();
	dev_dacsound_init();
	dev_spiflash_init();
	dev_wm8978_init();
	dev_lcd_init();

	//dev_dacsound_open();
	dev_key_open();
	//dev_wm8978_open();
	//dev_tea5767_open();
	//dev_tea5767_setfre(105700);

	#if 0
	mcu_adc_test();
	#endif

	#if 0
	dev_touchscreen_test();
	#endif

	#if 1
	dev_touchscreen_init();
	dev_touchscreen_open();
	ts_calibrate();
	ts_calibrate_test();
	#endif
	while (1)
	{
  1. dev_touchscreen_init初始化。
  2. dev_touchscreen_open打开设备
  3. ts_calibrate 校准
  4. ts_calibrate_test测试。

测试效果如图: _images/pic62.jpg测试效果 线条还算流畅。

总结

  1. 测试中触摸屏还是出现飞点,请分析解决。
  2. 触摸屏驱动如何提供接口给APP使用?也就是TSLIB要提供什么接口?

end

模拟SPI-XPT2046-电阻式触摸屏调试

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


上一节我们调试了触摸屏,用的是STM32内置ADC方案。 现在就让我们调试常见的XPT2046方案。

XPT2046

XPT2046是什么?很多朋友可能认为XPT2046是一个触摸屏检测IC,这不是很准确。 XPT2046是一个ADC转换芯片,支持4线电阻屏的AD转换。它并没有实现电阻屏检测流程。

XPT2046 是一款 4 导线制触摸屏控制器,内含 12 位分辨率 125KHz转换速率逐步逼近型 A/D 转换器。XPT2046支持从 1.5V 到 5.25V 的低电压 I/O 接口。XPT2046 能通过执行两次 A/D 转换查出被按的屏幕位置, 除此之外,还可以测量加在触摸屏上的压力。内部自带 2.5V参考电压可以作为辅助输入、温度测量和电池监测模式之用,电池监测的电压范围可以从 0V 到 6V。XPT2046片内集成有一个温度传感器。
  • 工作原理

我们直接从数字接口时序看其工作原理: _images/pic110.jpg时序1 DIN是输入数据,首先发送一个字节到XPT2046,这个字节叫命令字:

bit7:S,启动标志,固定为1 bit6-bit4:A2/A1/A0,通道选择,差分模式跟单端不一样,差分模式如下:

  • 001,测Y轴 011,测Z1 100,测Z2 101,测X轴
bit3 :MODE,1 =8位ADC,0 =12位ADC bit2:SEF/DFR,单端模式还是差分模式,XY轴跟压力可以用差分,最好用差分;其他功能只能用单端模式。 bit1-bit0:PD1/PD0, 工作模式: 11,总是处于供电状态;00,在变换之间进入低功耗

发送完命令字后,XPT2046开始转换,BUSY管脚变高。 当BUSY管脚变低时,XPT2046转换结束。 通过DOUT读数据,转换数据是12位(位数可设置,我们用12位)。 分两字节读取,第一字节返回BIT11-BIT5,也就是说,第1字节低7位有效,最高位无效。第二字节返回BIT4-BIT0,高5位有效,低3位无效。 下图是XPT2046压力测试原理,可见Z1,Z2测试位置跟我们用内置ADC的原理一样。 _images/pic24.jpg测试原理 按照上节说的触摸屏转换原理。使用XPT2046的转换流程就是:

测Z2通道, 测试压力Y轴电压。 测Z1通道, 测试压力X轴电压。 测X通道, 测试位置X轴电压。 测Y通道, 测试位置Y轴电压。

为了加快转换数据读取速度,XPT2046提供了其他通信时序。 _images/pic35.jpg快时序 与前面时序的区别就是:在读最后一个字节数据时,启动下一次转换。 需要注意的是,读第一个字节时必须发送0X00,不能发送其他数据

硬件原理

用XPT2046方案的LCD,接口跟用ADC方案的一样。 下图是XPT2046原理图,电阻屏四根线接到2、3、4、5管脚。 SPI 时钟CLD接到原来的ADC-TPX脚, SPI 片选接到ADC-TPY, SPI DIN接到TP-SO, SPI DOUT接到TP-SI。 这四个管脚不是硬件SPI控制器的管脚,需要用IO模拟实现SPI功能。 _images/pic44.png原理图

驱动架构设计

整体架构上一节已经说明。 驱动的大概流程就是:

启动定时器,定时时间为1ms。 在定时中断内用VSPI接口控制XPT2406转换,并读取数据。 对数据按照ADC同样的方法处理。 识别到样点就填入缓冲。

模拟SPI(VSPI)设计

用STM32内置SPI控制器的SPI驱动我们已经完成。 模拟SPI就按照硬件控制器的接口实现。

  • 接口(与硬件SPI统一) VSPI也是SPI,接口当然跟SPI一样
static s32 mcu_vspi_init(void)
static s32 mcu_vspi_open(SPI_DEV dev, SPI_MODE mode, u16 pre)
static s32 mcu_vspi_close(SPI_DEV dev)
static s32 mcu_vspi_transfer(SPI_DEV dev, u8 *snd, u8 *rsv, s32 len)
static s32 mcu_vspi_cs(SPI_DEV dev, u8 sta)

同时将原来的硬件SPI控制器接口函数加上h标志,例如:

static s32 mcu_hspi_init(void);

对于上层来说,模拟SPI还是硬件SPI,都是SPI,因此原来的接口就是统一对外接口,在接口内通过判断,决定调用VSPI接口还是HSPI接口。例如:

s32 mcu_spi_open(SPI_DEV dev, SPI_MODE mode, u16 pre)
  • 多个VSPI的统一

后续我们的外扩IO口可能会接SPI设备,也是需要用IO口模拟SPI的。 前面我们说过驱动和设备的关系。多个VSPI设备肯定只用一套VSPI代码。 为了实现这个目的,我们定义了VSPI设备对象,如下:

typedef struct
{
	char *name;
	SPI_DEV dev;
	s32 gd;

	u32 clkrcc;
	GPIO_TypeDef *clkport;
	u16 clkpin;

	u32 mosircc;
	GPIO_TypeDef *mosiport;
	u16 mosipin;

	u32 misorcc;
	GPIO_TypeDef *misoport;
	u16 misopin;

	u32 csrcc;
	GPIO_TypeDef *csport;
	u16 cspin;
}DevVspiIO;

当要使用一个VSPI设备时,只需要定义一个设备实体,并添加到VPSI列表即可:

DevVspiIO DevVspi1IO={
		"VSPI1",
		DEV_VSPI_1,
		-2,//未初始化

		VSPI1_RCC,
		VSPI1_CLK_PORT,
		VSPI1_CLK_PIN,

		VSPI1_RCC2,
		VSPI1_MOSI_PORT,
		VSPI1_MOSI_PIN,

		VSPI1_RCC2,
		VSPI1_MISO_PORT,
		VSPI1_MISO_PIN,

		VSPI1_RCC,
		VSPI1_CS_PORT,
		VSPI1_CS_PIN,
	};

/*无用的虚拟SPI设备,占位用*/		
DevVspiIO DevVspiNULL={
		"VSPI0",
		DEV_VSPI_0,
		-2,//未初始化;
		};

DevVspiIO *DevVspiIOList[]={
	&DevVspiNULL,

	#ifdef SYS_USE_VSPI1
	&DevVspi1IO,
	#endif

	};

如上,DevVspi1IO就是我们定义的VSPI1设备,然后添加到DevVspiIOList数组。 以后就可以通过mcu_spi_open等spi接口操作这个VSPI设备了。

  • VSPI实现

VSPI的代码就请看源码,不累述。

定时器改造

XPT2046需要定时转换,跟ADC使用定时器一样,也使用定时器7。 但是在ADC中,定时器执行一次就停止了,下一次由TASK启动。 XPT需要连续启动。 我们改造定时器7的代码,让它能用于这两种情况。

s32 mcu_tim7_start(u32 Delay_10us, void (*callback)(void), u8 type)
。。。
/**
 *@brief:      mcu_tim6_IRQhandler
 *@details:    定时器中断处理函数
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
void mcu_tim7_IRQhandler(void)
{
    if(TIM_GetITStatus(TpTim, TIM_FLAG_Update) == SET)
    {                                       
        TIM_ClearFlag(TpTim, TIM_FLAG_Update);
		if(Tim7Type == 1)
			TIM_Cmd(TpTim, DISABLE);//停止定时器

		Tim7Callback();

    }
}

在mcu_tim7_start函数增加两个参数:

callback:中断服务程序回调,当定时器中断发生时,就执行这个callback函数。 type:类型,是一次还是重复。

mcu_tim7_IRQhandler同步改造。 原来ADC方案用到mcu_tim7_start的地方也同步改造。

XTP2046驱动说明

只有一个关键函数dev_xpt2046_task,

void dev_xpt2046_task(void)
{

	static u16 pre_y, pre_x;
	static u16 sample_y, sample_x;

	static u8 pendownup = 1;
	struct ts_sample tss;

	u8 stmp[4];
	u8 rtmp[4];

	if(DevXpt2046Gd != 0)
		return;
	/*

	整个流程分四步
	读Z1,Z2,用于计算压力
	读X,Y轴,用于计算坐标

	1 使用了快速16CLK操作法,过程100us左右。
	经测试,中间不需要延时。
	2 没有使用下笔中断,通过压力判断是否下笔。但是有点疑惑,理论上接触电阻应该很小的,
	 用ADC方案,正常,用XPT2046方案,感觉接触电阻比较大,不知道是哪里没有理解对。

	3 快速CLK操作,也就是在读最后一个字节的时候同时发送下一个转换命令。
	一定要第一个字节发送0X00,第二个字节发送命令。如果第一个字节不是00,而且正好BIT7是1,
	芯片会重新启动转换,读回来的电压值就都不对了。

	4 实测,不需要延时,如果你的SPI时钟较快,请注意延时等待转换结束。
	5 理论上还可以节省一个字节的发送时间,请自行优化。
	*/

	/*------------------------*/

	stmp[0] = XPT2046_CMD_Z2;
	mcu_spi_transfer(XPT2046_SPI, stmp, NULL, 1);
	//vspi_delay(100);
	stmp[0] = 0x00;
	stmp[1] = XPT2046_CMD_Z1;
	mcu_spi_transfer(XPT2046_SPI, stmp, rtmp, 2);
	pre_y = ((u16)(rtmp[0]&0x7f)<<5) + (rtmp[1]>>3);
	/*------------------------*/
	//vspi_delay(100);
	stmp[0] = 0x00;
	stmp[1] = XPT2046_CMD_X;
	mcu_spi_transfer(XPT2046_SPI, stmp, rtmp, 2);
	pre_x = ((u16)(rtmp[0]&0x7f)<<5) + (rtmp[1]>>3);
	/*------------------------*/
	//vspi_delay(100);
	stmp[0] = 0x00;
	stmp[1] = XPT2046_CMD_Y;
	mcu_spi_transfer(XPT2046_SPI, stmp, rtmp, 2);
	sample_x = ((u16)(rtmp[0]&0x7f)<<5) + (rtmp[1]>>3);
	/*------------------------*/
	//vspi_delay(100);
	stmp[0] = 0x00;
	stmp[1] = 0X00;
	mcu_spi_transfer(XPT2046_SPI, stmp, rtmp, 2);
	sample_y = ((u16)(rtmp[0]&0x7f)<<5) + (rtmp[1]>>3);

	/*
		算压力
		简化算法
		实际:
		R触摸电阻=Rx面板*(X位置/4096)*(Z2/Z1-1)
	*/
	if(pre_x +  DEV_XPT2046_PENDOWN_GATE > pre_y)
	{
		/*有压力*/
		tss.pressure = 200;//DEV_XPT2046_PENDOWN_GATE - rpress;
		tss.x = sample_x;
		tss.y = sample_y;
		dev_touchscreen_write(&tss,1);
		//uart_printf("%d,%d,%d\r\n", tss.pressure, tss.x, tss.y);
		pendownup = 0;
	}
	else if(pre_x + DEV_XPT2046_PENUP_GATE < pre_y)//没压力,不进行XY轴检测
	{
		/* 起笔只上送一点缓冲*/
		if(pendownup == 0)
		{
			pendownup = 1;
			tss.pressure = 0;//压力要搞清楚怎么计算
			tss.x = 0xffff;
			tss.y = 0xffff;
			dev_touchscreen_write(&tss,1);
		}

	}
	else
	{
		//uart_printf("--press :%d %d\r\n", pre_y, pre_x);
		/*上下笔的过渡,丢弃*/

	}

}
  1. 36到60行,读取压力跟坐标值。在ADC方案中,这些电压值通过多步读取,XPT2046就直接一次性读取了。如果想优化性能,减少没有触摸时读数据时间,可以按照ADC方案,判断到压力后再读取坐标。
  2. 68行之后的就跟ADC方案类似了,判断压力的三个状态,进行分别数据处理。

XPT2046跟ADC方案的兼容

修改下面三个接口函数,通过宏控制使用ADC方案还是XPT2046方案。具体修改见代码。

/*
	触摸屏方案选择
*/
//#define SYS_USE_TS_ADC_CASE
#define SYS_USE_TS_IC_CASE

extern s32 dev_touchscreen_init(void);
extern s32 dev_touchscreen_open(void);
extern s32 dev_touchscreen_close(void);

测试

测试程序跟上一节一样,测试效果如下图,跟ADC测试效果是有差别的:线条细,飞点少。 原因有两个:

一是ADC采样率更高,同样时间得到的样点比XPT2046多,毕竟XPT2046是1毫秒采样一个样点。 二是内置的ADC稳定性我们还没有处理,或者是我们的电子开关电路没调试好。

但是并不是说XPT2046效果就比ADC好,当快速划线时,由于XPT2046采样率只有1K,会出现断线。 可以通过加快采样率,或者是在应用层软件连线解决。 _images/pic52.jpg测试效果

总结

了解触摸屏原理后,使用XTP2046做触摸检测并不复杂。 通过本章节,我们初步了解了同一个功能使用两种方案应该如何设计驱动程序。


end

DCMI-摄像头功能调试

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


STM32F407芯片带有DCMI接口,在我们的核心板上已经将接口用18PIN的FPC座子引出。 这个接口可以接我们的OV2640接口。 本节我们开始调试摄像头。

DCMI

DCMI接口是ST自己定义的接口。

Digital camera interface (DCMI),是意法半导体公司产品STM32F4xx系列芯片的快速摄像头接口。 通过HSVNC VSVNC PIXCLX和8到14位的数据接口D[0:13]完成控制。

简介

DCMI数字摄像头接口是一个同步并行接口,能接收外部8位、10位、12位或14位CMOS摄像头模块发出的高速数据流。可支持不同的数据格式:YCbCr4:2:2/RGB565逐行视频和压缩数据(JPEG)。 此接口适用于黑白摄像头、X24和X5摄像头,并假定所有预处理(如调整大小)都在摄像头中执行。

特性

_images/pic111.jpgDCMI特性

引脚

数据输入一共有14根引脚。 _images/pic25.jpgDCMI引脚

DCMI框图

从框图可见,DCMI支持DMA传输。 _images/pic36.jpgDCMI框图

OV2640

OV2640 是 OV(OmniVision)公司生产的一颗 1/4 寸的 CMOS UXGA(1632*1232)图像传感器。该传感器体积小、工作电压低,提供单片 UXGA 摄像头和影像处理器的所有功能。通过 SCCB 总线控制,可以输出整帧、子采样、缩放和取窗口等方式的各种分辨率 8/10 位影像数据。该产品 UXGA 图像最高达到 15 帧/秒(SVGA 可达 30 帧, CIF 可达 60 帧)。用户可以完全控制图像质量、数据格式和传输方式。所有图像处理功能过程包括伽玛曲线、白平衡、对比度、色度等都可以通过 SCCB 接口编程。 OmmiVision 图像传感器应用独有的传感器技术,通过减少或消除光学或电子缺陷如固定图案噪声、拖尾、浮散等,提高图像质量,得到清晰的稳定的彩色图像。

主要特性

  1. 总共16321232像素,最大输出尺寸UXGA(16001200),即200W像素。
  2. 模拟功能:模拟放大、增益控制、通道平衡、平衡控制。
  3. 10位ID转换。
  4. 自带数字信号处理器,主要功能:边沿锐化、颜色空间转换、色相和饱和度控制、降噪等。
  5. 自带压缩引擎。

接口

  • SCCB接口

SCCB接口控制图像传感器芯片的运行。SCCB接口相当于I2C。

  • 数字视频接口

OV2640拥有一个10位数字视频接口,接口支持8位接法。

图像格式

OV2640支持多种图像格式: UXGA 1600*1200像素。 SXGA 1280*1024 WXGA+ 1440*900 XVGA 1280*960 WXGA 1280*800 XGA 1024*768 SVGA 800*600 VGA 640*480 CIF 352*288 WQVGA 400*240 QCIF 176*144 QQVGA 160*120

主要操作

OV2640支持传感器窗口设置、图像尺寸设置、图像窗口设置和图像输出大小设置。

  • 传感器窗口设置 设置整个传感器区域感光部分。开窗范围从22~16321220。开窗必须大于图像尺寸设置。
  • 图像尺寸设置 DSP输出图像的尺寸。
  • 图像窗口设置 在DSP输出的图像上开窗,窗口必须小于等于DSP输出图像的尺寸。
  • 图像输出大小设置 最终输出图像的尺寸。这个图像是DSP将图像窗口中的图像缩放而得。

使用

OV2640设置不简单,文档也复杂,我们使用OV260,其实是将ST官方的例程移植到我们的硬件上而已。

接口原理图

_images/pic45.jpgDCMI接口

  1. SCL和SDA是摄像头控制信号,接到CPU硬件I2C上,不使用模拟I2C
  2. RESET和PWDN信号是摄像头复位和上电信号,可以不接。
  3. XCLK是时钟,屋脊雀OV2640摄像头模块不带晶振,因此需要STM32提供时钟。
  4. 17/18脚供电和地。
  5. 余下的就是DCMI信号。其中数据线只使用8根

编码与调试

标准库提供了DCMI例程。 在STM32F4xx_DSP_StdPeriph_Lib_V1.8.0\Project\STM32F4xx_StdPeriph_Examples\DCMI\DCMI_CameraExample目录的readme.txt文件中有详细说明。

This example shows how to use the DCMI to control the OV9655 or OV2640 Camera module mounted on STM324xG-EVAL or STM32437I-EVAL evaluation boards. …

从readme中可知:

  1. 使用SCCB接口配置OV9655,SCCB是一种类似I2C的协议。
  2. 将图像显示到LCD,使用了DMA。是为了释放CPU,以便执行其他任务。
  3. OV9655可以做到15帧。
  4. 相机亮度可以微调(分析后可知,是通过一个ADC采样,然后去设置摄像头)
  5. QQVGA(160x120) or QVGA(320x240)两种格式。

分析例程代码

从main入手分析。

  • 主流程分析

第5行,调用OV2640_HW_Init初始化硬件。 第8/9行,读ID(基本上,调试外设,都是先获取ID)。 第11行,Camera_Config。这个函数在camera_api.c,没有实际功能,只是根据摄像头型号以及ImageFormat(图像格式)调用对应摄像头的驱动函数配置摄像头。 第14行,启动DMA。 第15行,开启DCMI 第18行,设置LCD显示区域 第19/20行,准备写数据到LCD。在这里直接操作LCD寄存器,是一个很粗暴的做法。 然后就进入while(1)了,在while中,根据ADC采样,调节摄像头亮度。

  /* ADC configuration */
  ADC_Config();

  /* Initializes the DCMI interface (I2C and GPIO) used to configure the camera */
  OV2640_HW_Init();

  /* Read the OV9655/OV2640 Manufacturer identifier */
  OV9655_ReadID(&OV9655_Camera_ID);
  OV2640_ReadID(&OV2640_Camera_ID);
  。。。 一些显示LCD提示语
  Camera_Config();
  。。。
  /* Enable DMA2 stream 1 and DCMI interface then start image capture */
  DMA_Cmd(DMA2_Stream1, ENABLE);
  DCMI_Cmd(ENABLE);

  /* LCD Display window */
  LCD_SetDisplayWindow(179, 239, 120, 160);
  LCD_WriteReg(LCD_REG_3, 0x1038);
  LCD_WriteRAM_Prepare();

  while(1)
  {
    /* Get the last ADC3 conversion result data */
    uhADCVal = ADC_GetConversionValue(ADC3);

    /* Change the Brightness of camera using "Brightness Adjustment" register:
       For OV9655 camera Brightness can be positively (0x01 ~ 0x7F)
             and negatively (0x80 ~ 0xFF) adjusted
       For OV2640 camera Brightness can be positively (0x20 ~ 0x40)
             and negatively (0 ~ 0x20) adjusted */
    if(Camera == OV9655_CAMERA)
    {
      OV9655_BrightnessConfig(uhADCVal);
    }
    if(Camera == OV2640_CAMERA)
    {
      OV2640_BrightnessConfig(uhADCVal/2);
    }
  }
  • OV2640驱动

例程中的dcmi_ov2640.c就是OV2640驱动。 开头就是几个长数组,这些数组是配置到OV2640的,不同数组配置不同的图像格式。 话说,这么长的数组,如果没有原厂提供,根据资料你自己能写好吗? 所以我认为这个跟LCD初始化一样,我们不用去分析,只要用就行了。

const char OV2640_QQVGA[][2]
const unsigned char OV2640_QVGA[][2]
const unsigned char OV2640_JPEG_INIT[][2]
const unsigned char OV2640_YUV422[][2]
const unsigned char OV2640_JPEG[][2]
const unsigned char OV2640_160x120_JPEG[][2]
const unsigned char OV2640_176x144_JPEG[][2]
const unsigned char OV2640_320x240_JPEG[][2]
const unsigned char OV2640_352x288_JPEG[][2]

接下来是HW初始化函数,将对应的IO初始化为DCMI和I2C功能。

void OV2640_HW_Init(void)

接下来的一个重要函数就是OV2640初始化,完成DCMI和DMA的初始化。

/**
  * @brief  Configures DCMI/DMA to capture image from the OV2640 camera.
  * @param  ImageFormat: Image format BMP or JPEG
  * @param  BMPImageSize: BMP Image size
  * @retval None
  */
void OV2640_Init(ImageFormat_TypeDef ImageFormat)
{
  DCMI_InitTypeDef DCMI_InitStructure;
  DMA_InitTypeDef  DMA_InitStructure;

  /*** Configures the DCMI to interface with the OV2640 camera module ***/
  /* Enable DCMI clock */
  RCC_AHB2PeriphClockCmd(RCC_AHB2Periph_DCMI, ENABLE);

  /* DCMI configuration */
  DCMI_InitStructure.DCMI_CaptureMode = DCMI_CaptureMode_Continuous;
  DCMI_InitStructure.DCMI_SynchroMode = DCMI_SynchroMode_Hardware;
  DCMI_InitStructure.DCMI_PCKPolarity = DCMI_PCKPolarity_Rising;
  DCMI_InitStructure.DCMI_VSPolarity = DCMI_VSPolarity_Low;
  DCMI_InitStructure.DCMI_HSPolarity = DCMI_HSPolarity_Low;
  DCMI_InitStructure.DCMI_CaptureRate = DCMI_CaptureRate_All_Frame;
  DCMI_InitStructure.DCMI_ExtendedDataMode = DCMI_ExtendedDataMode_8b;

  /* Configures the DMA2 to transfer Data from DCMI */
  /* Enable DMA2 clock */
  RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE);

  /* DMA2 Stream1 Configuration */  
  DMA_DeInit(DMA2_Stream1);

  DMA_InitStructure.DMA_Channel = DMA_Channel_1;  
  DMA_InitStructure.DMA_PeripheralBaseAddr = DCMI_DR_ADDRESS;
  DMA_InitStructure.DMA_Memory0BaseAddr = FSMC_LCD_ADDRESS;
  DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralToMemory;
  DMA_InitStructure.DMA_BufferSize = 1;
  DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
  DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Disable;
  DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Word;
  DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
  DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;
  DMA_InitStructure.DMA_Priority = DMA_Priority_High;
  DMA_InitStructure.DMA_FIFOMode = DMA_FIFOMode_Enable;
  DMA_InitStructure.DMA_FIFOThreshold = DMA_FIFOThreshold_Full;
  DMA_InitStructure.DMA_MemoryBurst = DMA_MemoryBurst_Single;
  DMA_InitStructure.DMA_PeripheralBurst = DMA_PeripheralBurst_Single;

  switch(ImageFormat)
  {
    case BMP_QQVGA:
    {
      /* DCMI configuration */
      DCMI_InitStructure.DCMI_VSPolarity = DCMI_VSPolarity_High;
      DCMI_Init(&DCMI_InitStructure);

      /* DMA2 IRQ channel Configuration */
      DMA_Init(DMA2_Stream1, &DMA_InitStructure);
      break;
    }
    case BMP_QVGA:
    {
      /* DCMI configuration */
      DCMI_Init(&DCMI_InitStructure);

      /* DMA2 IRQ channel Configuration */
      DMA_Init(DMA2_Stream1, &DMA_InitStructure);
      break;
    }
     default:
    {
      /* DCMI configuration */
      DCMI_InitStructure.DCMI_VSPolarity = DCMI_VSPolarity_High;
      DCMI_Init(&DCMI_InitStructure);

      /* DMA2 IRQ channel Configuration */
      DMA_Init(DMA2_Stream1, &DMA_InitStructure);
      break;
    }
  }
}

32~46行是DMA的初始化,前面已经说过DMA,本处就不累赘了。 17~23是DCMI的初始化。

17行,连续模式 18行,硬件同步 19行,PCK上升沿有效 20行,VSYNC低电平有效 21行,HSYNC低电平有效 22行,全帧捕获 23行,8位数据模式。

48行~77行,这么多代码完成了什么功能? 这么多代码只做了一件事,就是当QQVGA模式时,VSYNC用高电平有效。QVGA才用低电平有效

剩下的其他函数就是将前面说的数组配置到OV2640,没什么其他重要功能了。

这样看来,OV2640用起来也不算难。

移植

将dcmi_ov9655.c、dcmi_ov9655.h、dcmi_ov2640.c、dcmi_ov2640.h、camera_api.c、camera_api.h拷贝到我们工程的board_dev目录,添加到SI跟MDK工程,开始移植。 程序结构上做以下修改:

  1. 将DCMI相关函数放到mcu_dcmi.c内。
  2. 将SCCB相关函数放到mcu_i2c.c内。
  3. 原来main函数中的测试代码放到camera_api.c中。

差异:

摄像头上没有晶振,需要使用STM32的MCO1输出时钟给摄像头使用。
  1. 初始化

创建了一个初始化函数,硬件相关的初始化都放在这个函数内。 官方例程放在OV9655和OV2640内,两套,不合理。 这里所谓的初始化,只是初始化摄像头接口。 跟你用什么摄像头,无关。 MCO1_Init就是初始化MCO管脚,输出时钟给摄像头。

s32 dev_camera_init(void)
{
	/* camera xclk use the MCO1 */
	MCO1_Init();
	DCMI_PWDN_RESET_Init();

	/* Initializes the DCMI interface (I2C and GPIO) used to configure the camera */
	BUS_DCMI_HW_Init();

	SCCB_GPIO_Config();
	return 0;
}
  1. 修改I2C配置,调试到能读到摄像头ID

也就是修改

void SCCB_GPIO_Config(void)
uint8_t bus_sccb_writereg(uint8_t DeviceAddr, uint16_t Addr, uint8_t Data)
uint8_t bus_sccb_readreg(uint8_t DeviceAddr, uint16_t Addr)

查参考手册,我们用的硬件I2C是I2C2,把上面3个函数中的控制器全部改为I2C2?—-这方法不好,应该全部改为宏定义。宏定义改起来方便。

调试信息,能正确读到ID。

—hello world!—– init finish! read reg:9341 lcd init ok! camera test…. OV9655 Camera ID 0x96 Camera_Config… test camera! test camera! test camera! test camera!
  1. 移植DCMI跟DMA功能
修改DCMI相关IO。 修改LCD 显示RAM的地址,在LCD章节,我们说过这个地址为什么是0x6C010000。
#define DCMI_DR_ADDRESS       0x50050028
#define FSMC_LCD_ADDRESS      0x6C010000
  1. 摄像头数据采集方向和LCD扫描方向要一致。

LCD驱动器ILI9341如果配置为横屏模式,则要先左右,后上下; 先左还是先右,先上还是先下,图像会不一样。 如果配置为竖屏,则需要使用后面四种扫描方向,也就是先上下,后左右。 简单的说,就是先扫描320像素长边,再扫描240像素的短边。

#define L2R_U2D  (0) //从左到右,从上到下
#define L2R_D2U  (0 + UD_BIT_MASK)//从左到右,从下到上
#define R2L_U2D  (0 + LR_BIT_MASK) //从右到左,从上到下
#define R2L_D2U  (0 + UD_BIT_MASK + LR_BIT_MASK) //从右到左,从下到上

#define U2D_L2R  (LRUD_BIT_MASK)//从上到下,从左到右
#define U2D_R2L  (LRUD_BIT_MASK + LR_BIT_MASK) //从上到下,从右到左
#define D2U_L2R  (LRUD_BIT_MASK + UD_BIT_MASK) //从下到上,从左到右
#define D2U_R2L  (LRUD_BIT_MASK + UD_BIT_MASK+ LR_BIT_MASK) //从下到上,从右到左	 

摄像头调试过程,参考官方例程,很快就调通了。

  1. 代码结构调整

最终的代码,我们对原厂的例程进行了部分的重新封装和调整。 使得代码架构层次清晰,模块化更好。

总结


end

USB 调试记录

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


前面有几节在讨论移植官方例程时都提到过USB例程。 现在就让我们开始移植USB例程。

USB

USB大家都不陌生,但是USB有很多概念,估计大家不一定都能分清楚。

USB,是英文Universal Serial Bus(通用串行总线)的缩写。

协议版本

USB1.1 普遍的USB规范,其高速方式的传输速率为12Mbps,低速方式的传输速率为1.5Mbps(b是Bit的意思),1MB/s(兆字节/秒)=8MBPS(兆位/秒),12Mbps=1.5MB/s。 USB2.0 由USB1.1规范演变而来的。它的传输速率达到了480Mbps。 USB 2.0标准将USB接口速度划分为三类,从高到低分别为480Mbps、12Mbps和1.5Mbps。 High-speed 25Mbps~400Mbps(最大480Mbps) 视频、存储、照片和宽带 Full-speed 500Kbps~10Mbps(最大12Mbps) 宽带、音频和耳麦 Low-speed 10Kbps~100Kbps(最大1.5Mbps) 键盘、鼠标和游戏外设 USB OTG 基本兼容USB2.0协议。通过一根ID线,确认HOST还是SLAVE。 USB3.0 USB3.0 —— 也被认为是SuperSpeedUSB。 实际传输速率大约是3.2Gbps(即320MB/S)。理论上的最高速率是5.0Gbps(即500MB/S)。 USB3.0 引入全双工数据传输。5根线路中2根用来发送数据,另2根用来接收数据,还有1根是地线。

设备分类

HOST 主机,通常的电脑就是HOST设备。 SLAVE 从机,U盘,鼠标键盘等外设就是SLAVE设备。 OTG OTG就是On The Go,正在进行中的意思。既能充当HOST,亦能充当SLAVE。现在的智能手机,大部分都是OTG设备了。

硬件接口

先来一个图(图片出自网络),图中是各种USB接口: _images/1.jpgUSB接口种类 Type-A 电脑上的基本都是Type-A接口。 Type-B 打印机,或者以前一些老的开发板会使用B口。 Type-c 新智能手机流行接口。支持3.0协议。 Mini-USB 2.0协议定义的接口类型,以前MP3常用,5PIN,支持OTG Micro-USB Micro USB是USB 2.0标准的一个便携版本,在TYPE-C之前,智能手机使用Micro接口。5PIN,支持OTG。我们的硬件,3个USB接头都是Micro接口。 以上接口类型,全部有公头母座之分

设备类型

一个USB设备,使用USB线,根据USB规范通信。为了规范,还对这些设备的行为进行了分类,定义了子协议,也就区分了不同的设备。 USB定义了种类代码信息,它被用来识别设备的功能,根据这些功能,以加载设备驱动。这种信息包含在名为基类,子类和协议的3个字节里。 这些类,也就是我们常常听说的:HID、CDC等。

BaseClass DescriptorUsage Description
00h Device Useclass information in the Interface Descriptors
02h Both Communications and CDC Control
03h Interface HID(HumanInterface Device)
其他更多请参考USB关于USB Class Codes的文档。

STM32F407 USB

STM32F407有两个USB接口:FS(全速)、HS高速。而且两个USB都是OTG接口。高速USB需要外接USB芯片。 本次我们调试的是全速USB。

USB简介

_images/pic91.pngUSB简介

USB框图

_images/pic13.pngUSB框图

ST USB 协议栈

要熟悉一个软件,最快的速度还是阅读官方文件。 下面路径可以下载STM32 USB例程,文档名称UM1021。包含一个PDF跟一个75M的例程。 http://www.stmcu.org/document/detail/index/id-213598

CD00289278.pdf stm32_f105-07_f2_f4_usb-host-device_lib.zip

ST官方推广时,会针对某些外设进行介绍,我们可以通过这些文档大概了解STM32的外设使用情况。 在下面路径,左边资料栏里,有每年新IC发布时的培训资料。 http://www.stmcu.org/document/list/index/category-466 base/17usb/pic/3.pngSTM MCU 资料 我们可以找到2012年USB的培训资料

USB培训_Part1_协议.pdf USB培训_Part2_USB_IP及其库的使用.pdf USB培训_Part3_USB_OTG_IP及其库的使用.pdf

这些资料请自行消化。建议先看培训资料

USB库说明

从CD00289278.pdf看起。

例程文件结构

官方例程文件结构 base/17usb/pic/4.pngUSB例程文件结构 其中

Libraries中,有3个USB库,分别是Device、HOST、OTG。 Project目录下有3种例程,分别对应Device、HOST、Host_Device。

我们移植Host_Device里面的OTG工程DRD

DRD例程大概流程

  • 主流程 Demo_Init初始化,然后就进入while,Demo_Process函数。
int main(void)
{
  __IO uint32_t i = 0;

  /*!< At this stage the microcontroller clock setting is already configured,
  this is done through SystemInit() function which is called from startup
  file (startup_stm32fxxx_xx.s) before to branch to application main.
  To reconfigure the default setting of SystemInit() function, refer to
  system_stm32fxxx.c file
  */  

  Demo_Init();

  while (1)
  {

    Demo_Process();

    if (i++ == 0x10000)
    {
      STM_EVAL_LEDToggle(LED1);
      STM_EVAL_LEDToggle(LED2);
      STM_EVAL_LEDToggle(LED3);
      STM_EVAL_LEDToggle(LED4);
      i = 0;
    }      
  }
}

Demo_Init函数中USB相关的只有下面几句代码,调用USBH_Init初始化为HOST模式。

USBH_Init(&USB_OTG_Core,
#ifdef USE_USB_OTG_FS
            USB_OTG_FS_CORE_ID,
#elif defined USE_USB_OTG_HS
            USB_OTG_HS_CORE_ID,
#endif                
            &USB_Host,
            &USBH_MSC_cb,
            &USR_USBH_MSC_cb);

  USB_OTG_BSP_mDelay(500);
  DEMO_UNLOCK();

Demo_Process函数,如果是HOST模式,调用USBH_Process处理。 Demo_Application是USB应用层流程,也是主要流程。

void Demo_Process (void)
{
  if(demo.state == DEMO_HOST)
  {
    if(HCD_IsDeviceConnected(&USB_OTG_Core))
    {
      USBH_Process(&USB_OTG_Core, &USB_Host);
    }
  }
  Demo_Application();

}

Demo_Application函数中,根据流程步骤进行处理

switch (demo.state)
{
case  DEMO_IDLE:
break;
case  DEMO_WAIT:
break;
case  DEMO_HOST:
break;
case  DEMO_DEVICE:
break;    

在DEMO_WAIT步骤中,等待用户选择HOST模式还是DEVICE模式。 DEMO_HOST步骤跟DEMO_DEVICE步骤又分别处理USB状态。

这整个就是一个状态机。

整个主流程只是应用层的。

USB协议栈大概

哪么USB是怎么处理的呢?USB协议大部分使用回调函数。 在选择模式后,就会初始化USB。例如HOST模式:

USBH_Init(&USB_OTG_Core,
#ifdef USE_USB_OTG_FS
          USB_OTG_FS_CORE_ID,
#elif defined USE_USB_OTG_HS
          USB_OTG_HS_CORE_ID,
#endif                
          &USB_Host,
          &USBH_MSC_cb,
          &USR_USBH_MSC_cb);

USBH_MSC_cb和USR_USBH_MSC_cb就是回调函数列表,cb就是call back的意思。 这两个结构体分别是:

USBH_Class_cb_TypeDef  USBH_MSC_cb =
{
  USBH_MSC_InterfaceInit,
  USBH_MSC_InterfaceDeInit,
  USBH_MSC_ClassRequest,
  USBH_MSC_Handle,
};

USBH_Usr_cb_TypeDef USR_USBH_MSC_cb =
{
  USBH_USR_Init,
  USBH_USR_DeInit,
  USBH_USR_DeviceAttached,
  USBH_USR_ResetDevice,
  USBH_USR_DeviceDisconnected,
  USBH_USR_OverCurrentDetected,
  USBH_USR_DeviceSpeedDetected,
  USBH_USR_Device_DescAvailable,
  USBH_USR_DeviceAddressAssigned,
  USBH_USR_Configuration_DescAvailable,
  USBH_USR_Manufacturer_String,
  USBH_USR_Product_String,
  USBH_USR_SerialNum_String,
  USBH_USR_EnumerationDone,
  USBH_USR_UserInput,
  USBH_USR_MSC_Application,
  USBH_USR_DeviceNotSupported,
  USBH_USR_UnrecoveredError

};

我们看USR里面的函数,有初始化,复位设备,断开设备,电流溢出等处理函数。 当USB协议栈发生这些消息是,就会回调这些函数处理。

bsp

与硬件相关的操作放在usb_bsp.c文件内。

void USB_OTG_BSP_Init(USB_OTG_CORE_HANDLE *pdev)
void USB_OTG_BSP_EnableInterrupt(USB_OTG_CORE_HANDLE *pdev)
void USB_OTG_BSP_DriveVBUS(USB_OTG_CORE_HANDLE *pdev, uint8_t state)
void  USB_OTG_BSP_ConfigVBUS(USB_OTG_CORE_HANDLE *pdev)
static void USB_OTG_BSP_TimeInit ( void )
。。。。

大概有两部分:硬件初始化和VBUS控制。VBUS就是控制是否输出电压到USB口,当HOST模式时,就需要提供电源。

三节

从上面分析可以看出,USB大概分3节:应用、协议栈、底层BSP。 我们要管的是两头:应用层和底层。 应用层就是Demo_Application。 底层就是BSP硬件相关的,底层只要根据硬件修改一次就可以了。

硬件说明

接口

接口使用Micro接口,电气信号如下图 base/17usb/pic/2.pngUSB 电气信号 5根信号线

信号 定义说明
Vbus 电源,5V,2.0协议允许500毫安电流。3.0则要求1A。当作为HOST设备时,对外供电
D- 差分数据传输线,HOST设备内置15K下来电阻,当没有设备接入时,为低电平。低速设备会接一个1.5K下来电阻
D+ 差分数据传输线,HOST设备内置15K下来电阻,当没有设备接入时,为低电平。高速设备会接1.5k上拉电阻
ID 低电平,作为HOST设备;高电平则作为SLAVE设备
GND 地线
对于信号线的具体使用,需要深入了解USB协议,特别是握手协议,才更深入了解。我们不深入探讨。
#### OTG供电逻辑
我们的核心板只提供了一个micro USB插座,HOST或DEVICE都是通过这个座子连接。
供电电路如下:
HOST供电

_images/pic37.jpgDEVICE供电 接U盘,STM32作为HOST,otg_fs_power_switch输出低电平,升压得到的5V通过U901输出,供电给U盘,同时Q903 MOS管关断,防止USB_FS_VBUS反向倒灌到系统5V电源。 接电脑,STM32作为DEVICE,otg_fs_power_switch转输入或者高阻态,U901关断,Q903导通,USB_FS_VBUS供电给核心板使用。电路没加保险丝,当LCD,摄像头等都接上时,电流会大于500ma,直接接电脑可能会过流,建议通过带电源的HUB接到电脑。

移植调试过程

1 首先将STM32_USB-Host-Device_Lib_V2.2.0\Libraries下的USB库拷贝到我们工程的StLib目录下 base/17usb/pic/5.png拷贝USB库

2 例程工程跟readme文件 STM32_USB-Host-Device_Lib_V2.2.0\Project\USB_Host_Device_Examples\DRD

2.2.0例程里面没有MDK工程,只有IAR工程,如果没安装IAR,可以看2.1.0库里面的例程。

工程主要有3部分:

  • USB应用 base/17usb/pic/drd_app.jpgDRD例程IAR工程APP
  • 文件系统 base/17usb/pic/fatfs.jpgDRD例程IAR工程文件系统
  • usb库 base/17usb/pic/usblib.jpgDRD例程IAR工程USB库

3 在MDK工程中建立USB文件组织结构并添加对应文件 在MDK中我们将文件如下组织 _images/pic46.jpgMDK工程文件组织方式

4 在我们MDK工程app目录下,建立usb目录,并将例程目录Project\USB_Host_Device_Examples\DRD下的inc跟src文件夹拷贝过来。 inc目录有以下文件,第一个文件和usb开头的文件是我们需要的,其他文件在前面例程已经添加,没有什么差异就删除。 _images/pic53.jpgUSB DRD INC

SRC目录有以下文件,main.c跟stm32fxxx_it.c的代码需要移植。 其他文件拷贝到usb_app目录 _images/pic63.jpgUSB DRD SRC

main.c很简单,主要代码都在dual_func_demo.c stm32fxxx_it.c有三个中断函数需要处理,移植到我们自己的stm32fxxx_it.c

EXTI1是电流检测IO口中断 TIM2_IRQHandler不知道用来做什么,估计是超时管理,细节回头再研究。 OTG_FS_IRQHandler是USB的主要中断。

5 将相关文件添加到MDK工程 _images/pic72.jpg添加文件到工程

core和fatfs

_images/pic8.jpg添加文件到工程 记得添加头文件路径。

6 查看app\usb\inc下的文件,根据实际情况配置。 也可以先编译一次,根据错误提示修改。 主要修改点:

1 usb_conf.h中根据不同的官方硬件,包含了不同的头文件,把这些包含全部屏蔽。 2 在usb_conf.h中打开USE_USB_OTG_FS宏定义 3 经常搞不懂的VBUS应用,在现在的工程内已经没有定义。 在usb_conf_template.h范例里面还有//#define VBUS_SENSING_ENABLED。

7 修改usb_bsp.c,根据硬件配置修改。 过流检测我们用PE2。 电源开关控制,用的是PC0。 过流检测中断函数也需要修改到IO中断2(EXTI_Line2)。

/**
  * @brief  EXTI2_IRQHandler
  *         This function handles External line 1 interrupt request.
  * @param  None
  * @retval None
  */
void EXTI2_IRQHandler(void)
{
  if(EXTI_GetITStatus(EXTI_Line2) != RESET)
  {
      USB_Host.usr_cb->OverCurrentDetected();
      EXTI_ClearITPendingBit(EXTI_Line2);
  }
}

其中ID/DP/DN也要修改。 在main函数中调用dual_func_demo.c文件内的usb_main函数。

更多修改细节,可以通过对比官方例程,有些小地方修改过没记录

U盘测试

通过一根OTG转接线,将U盘接到核心板上的micro接口。

修改Demo_Application函数内的case DEMO_WAIT,强制设置为HOST模式。

	/*选择HOST还是DEVICE*/
  case  DEMO_WAIT:
	demo.state = DEMO_HOST;  
    demo.Host_state = DEMO_HOST_IDLE;
	//demo.state = DEMO_DEVICE;            
    //demo.Device_state = DEMO_DEVICE_IDLE;   
    break;

通过一个OTG转接头,插上U盘。识别金士顿U盘,U盘内有一个wav文件。

Board : wujique stm32f407.
Device: STM32F407.
USB Host Library v2.2.0.
USB Device Library v1.2.0.
USB OTG Driver v2.2.0
STM32 Std Library v1.5.0.
> Full speed device detected
> Mass storage device connected
> Manufacturer : Kingston
> Product : DataTraveler 2.0
> Serial Number : 5B811D00168F
> Enumeration completed
> USB Host Full speed initialized.
> File System initialized.
> Disk capacity : 1998585344 Bytes
   |__STEREO~1.WAV

读卡器测试

使用一根手机数据线将核心板连到电脑。

1 实现sd卡的diso接口。 参考usbh_msc_fatfs.c文件,在stm324xg_eval_sdio_sd.c实现要SD卡的操作函数,并添加到diskio.c文件内。

extern DSTATUS SD_disk_initialize (
                         BYTE drv		/* Physical drive number (0) */
                           );
extern DSTATUS SD_disk_status (
                     BYTE drv		/* Physical drive number (0) */
                       );
extern DRESULT SD_disk_read (
                   BYTE pdrv,			/* Physical drive number (0) */
                   BYTE *buff,			/* Pointer to the data buffer to store read data */
                   DWORD sector,		/* Start sector number (LBA) */
                   UINT count			/* Sector count (1..255) */
                     );
extern DRESULT SD_disk_write (
                    BYTE pdrv,			/* Physical drive number (0) */
                    const BYTE *buff,	/* Pointer to the data to be written */
                    DWORD sector,		/* Start sector number (LBA) */
                    UINT count			/* Sector count (1..255) */
                      );

extern DRESULT SD_disk_ioctl (
                    BYTE drv,		/* Physical drive number (0) */
                    BYTE ctrl,		/* Control code */
                    void *buff		/* Buffer to send/receive control data */
                      );

2 修改Demo_Application函数内的case DEMO_WAIT,强制设置为DEVIC模式。

	/*选择HOST还是DEVICE*/
  case  DEMO_WAIT:
	//demo.state = DEMO_HOST;  
    //demo.Host_state = DEMO_HOST_IDLE;
	demo.state = DEMO_DEVICE;            
    demo.Device_state = DEMO_DEVICE_IDLE;   
    break;

3 编译下载,用MICRO线将开发板连到电脑,电脑能识别到SD卡内的文件。LOG如下:

Board : wujique stm32f407.
Device: STM32F407.
USB Host Library v2.2.0.
USB Device Library v1.2.0.
USB OTG Driver v2.2.0
STM32 Std Library v1.5.0.
> Single Lun configuration.
> microSD is used.
> Device In suspend mode.

-------SD_PowerON ok----------
-------SD_InitializeCards ok----------
-------SD_csd.DeviceSize:15701----------
-------SD_GetCardInfo ok----------
-------CardCapacity:00000001----------
-------CardCapacity:EAB00000----------
-------CardBlockSize:512 ----------
-------RCA:8597 ----------
-------CardType:2 ----------
-------SD_SelectDeselect ok----------
SDIO_GetResponse ok
FindSCR:0
SDEnWideBus:0
-------SD_EnableWideBusOperation:0----------
> MSC Interface started.

总结

当前代码有以下问题需要解决: 1 能不能自动识别HOST/DEVICE? 2 如果系统在使用SD卡,能同时运行读卡器程序吗?


end

ETH LAN8720 调试记录

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


本节我们开始调试网络功能。 主要分三部分:STM32以太网控制器、PHY芯片LAN8720、LWIP协议栈。

STM32 MAC控制器

框图

_images/pic14.png

  1. STM32的以太网是基于DMA控制器的。
  2. 介质访问控制,也就是我们通常说的MAC控制器。这个是以太网功能的核心部分。
  3. 以太网提供3种接口:SMI、MII、RMII。
  4. SMI叫做站管理接口。用来访问PHY的寄存器。
  5. MII&RMII功能一样,都是MAC控制器跟PHY进行数据传输的接口。RMII是精简的MII,用更少的IO口。
  6. 框图里面的外部PHY并不包含在STM32芯片内,我们外部的LAN8720就是这个外部PHY。
  7. 在PHY外,应该还有一个带变压器的网口。

特性

在《STM32F4xx中文参考手册.pdf》中列出了以太网的3种特性

  1. MAC内核特性
  2. DMA特性
  3. PTP特性

细节请看文档。

RMII接口

精简介质独立接口(RMII)只要用7个引脚(MII需要16个),因此我们选用这个接口控制PHY芯片。 RMII有以下特性:

支持10/100M运行速率 参考时钟必须是50MHz 相同的参考时钟必须从外部提供给MAC和外部PHY 提供独立的2位宽的发送和接收数据路径

_images/pic28.png 图中的REF_CLK是共用的参考时钟,50MHz。 我们用LAN8720方案,这个时钟由LAN8720提供给STM32 MAC控制器。

LAN8720A芯片

LAN8720A是低功耗的10/100M以太网PHY芯片,支持通过RMII接口和MAC层通信。

特性

  • 10/100M
  • 支持RMII接口
  • 支持全双工和半双工
  • 使用外部25M晶振,生成50MHz参考时钟给MAC层使用
  • 支持自协商模式
  • 支持HP Auto-MDIX自动翻转
  • 支持SMI串行管理接口

框图

  • 内部框图

_images/pic34.png

  • 应用图

左边10/100M网络控制器就是STM32内部的MAC控制器。 右边的RJ45就是网口。 下边框图说明LAN8720需要一个外部晶振。 _images/pic45.png

更多请参考《LAN8720A.pdf》文档。

LWIP

现在我们天天上网,基本上都知道,有一种TCP/IP协议。 协议是什么?最底层的协议就是数据传输的格式。可以相当于网络上的一种语言。 高级的协议,就是一种行为规范。 以太网世界设备几十亿,如果没有行为规范,机器之间就无法正常进行通信。 不理解的话可以想象在一间房子里面有10个不同国家的人,各自都在胡言乱语。

网络七层协议

国际标准化组织ISO 于1981年正式推荐了一个网络系统结构—-七层参考模型,叫做开放系统互连模型(Open System Interconnection,OSI)。 由于这个标准模型的建立,使得各种计算机网络向它靠拢,大大推动了网络通信的发展。 OSI 参考模型将整个网络通信的功能划分为七个层次,见图。 它们由低到高分别是物理层(PH)、数据链路层(DL)、网络层(N)、传输层(T)、会话层(S)、表示层(P)、应用层(A)。 每层完成一定的功能,每层都直接为其上层提供服务,并且所有层次都互相支持。 第四层到第七层主要负责互操作性,而一层到三层则用于创造两个网络设备间的物理连接。 _images/pic54.jpg

TCP/IP协议

Transmission Control Protocol/Internet Protocol的简写,中译名为传输控制协议/因特网互联协议,又名网络通讯协议,是Internet最基本的协议、Internet国际互联网络的基础,由网络层的IP协议和传输层的TCP协议组成。TCP/IP 定义了电子设备如何连入因特网,以及数据如何在它们之间传输的标准。协议采用了4层的层级结构,每一层都呼叫它的下一层所提供的协议来完成自己的需求。通俗而言:TCP负责发现传输的问题,一有问题就发出信号,要求重新传输,直到所有数据安全正确地传输到目的地。而IP是给因特网的每一台联网设备规定一个地址。

TCP/IP协议只使用了4层结构,跟OSI的对应关系如下图。 _images/pic62.png

LWIP

LwIP是Light Weight (轻型)IP协议,有无操作系统的支持都可以运行。 LwIP实现的重点是在保持TCP协议主要功能的基础上减少对RAM 的占用,它只需十几KB的RAM和40K左右的ROM就可以运行,这使LwIP协议栈适合在低端的嵌入式系统中使用。 lwIP协议栈主要关注的是怎么样减少内存的使用和代码的大小,这样就可以让lwIP适用于资源有限的小型平台例如嵌入式系统。 为了简化处理过程和内存要求,lwIP对API进行了裁减,可以不需要复制一些数据。

学习

我们本次只是移植官方以太网的例子。不会对LWIP做深入学习。 因为TCP/IP协议太复杂了。以前公司做无线通信的同事,每天都抗一本书看,对,就是下面这本,他说这书是一套,总共更有3本。 _images/pic71.png 如果大家想学习网络协议,可以买这个书看看。 如果只是想了解LWIP的使用,那就先从例程上学习学习,再看看LWIP的结构跟接口就可以了。 以后我们会单独出一个对于LWIP的使用说明

原理说明

_images/pic81.jpg原理图

  1. 原理图分两部分:PHY芯片LAN8720A、HR911105A(带变压器RJ45网口)。
  2. PHY芯片通过RMII接口与STM32内部MAC层通信。
  3. LAN8720需要一个25M的晶振。
  4. STM32通过一个SMI接口控制LAN8720。
  5. 第10脚可以配置LAN8720地址。

移植调试

ST提供了ETH例程《STM32F4x7_ETH_LwIP_V1.1.1》。 在Libraries文件夹内有STM32F4x7_ETH_Driver库文件。 Project文件夹内有两个文件夹,FreeRTOS是带操作系统的例程,Standalone则是不带操作系统的例程。 目前我们还没有移植操作系统,先用不带操作系统的例程测试硬件。 我们选择里面的tcp_echo_server例程。 在app文件夹建立一个eth文件,用于存放网络应用。 把例程中src和inc文件夹内的相关文件拷贝到eth。例程的main.c跟main.h改名eth_app。

  • C文件 _images/pic9.jpg原理图
  • 头文件 _images/pic10.jpg原理图
  • 将文件添加到工程 _images/pic112.jpg原理图 lwip文件较多,一共34个。 lwip-1.4.1\src\api目录下8个。 lwip-1.4.1\src\core目录下16个。 lwip-1.4.1\src\core\ipv4目录下8个。 lwip-1.4.1\src\netif目录下的etharp.c lwip-1.4.1\port\STM32F4x7\Standalone目录下的ethernetif.c

修改

  1. 修改ETH_GPIO_Config函数,根据我们的硬件配置GPIO。
  2. 修改所有DP83848_PHY_ADDRESS,改为ETH_PHY_ADRESS,在stm32f4x7_eth_bsp.h宏定义
//#define DP83848_PHY_ADDRESS       0x01 /* Relative to STM324xG-EVAL Board */
#define LAN8720A_PHY_ADDRESS       0x00 /* Relative to WUJIQUE F407 Board */

#define ETH_PHY_ADRESS				LAN8720A_PHY_ADDRESS
  1. 打开宏,使用DHCP,DHCP就是自动获取IP的意思。
#define USE_DHCP       /* enable DHCP, if disabled static address is used */
  1. 打开接口定义,我们用的是RMII模式,原来例程用的是MII模式
/* wujique F407硬件使用RMII接口*/
#define RMII_MODE  // User have to provide the 50 MHz clock by soldering a 50 MHz
                     // oscillator (ref SM7745HEV-50.0M or equivalent) on the U3
                     // footprint located under CN3 and also removing jumper on JP5.
                     // This oscillator is not provided with the board.
                     // For more details, please refer to STM3240G-EVAL evaluation
                     // board User manual (UM1461).

//#define MII_MODE

对源码移植过程大概修改上面这些,具体修改了什么,可以跟原来例程对比。

修改系统滴答

网络需要一个Time_Get_LocalTime函数,其实是一个系统滴答。 我们代码中一直使用一个Delay函数,我们修改这个Delay,改为滴答形式

main函数开始初始化系统滴答,改为1MS

  /* SysTick end of count event each 10ms */
  RCC_GetClocksFreq(&RCC_Clocks);
  SysTick_Config(RCC_Clocks.HCLK_Frequency / 1000);

将原来的延时函数改为下面三个函数: Delay还是延时; Time_Get_LocalTime获取系统滴答; Time_Update放到SysTick_Handler函数内,替换原来的函数。

/* this variable is used to create a time reference incremented by 10ms */
__IO uint32_t LocalTime = 0;
uint32_t timingdelay;

/**
  * @brief  Inserts a delay time.
  * @param  nCount: number of 10ms periods to wait for.
  * @retval None
  */
void Delay(uint32_t nCount)
{
  /* Capture the current local time */
  timingdelay = LocalTime + nCount;  

  /* wait until the desired delay finish */  
  while(timingdelay > LocalTime)
  {     
  }
}

uint32_t Time_Get_LocalTime(void)
{
	return LocalTime;
}

/**
  * @brief  Updates the system local time
  * @param  None
  * @retval None
  */
void Time_Update(void)
{
  LocalTime += SYSTEMTICK_PERIOD_MS;
}

读芯片ID

到现在,大家应该都熟悉外设调试流程了:可以读ID的芯片,肯定是先调试能读取ID,再调试其他功能。 在函数ETH_BSP_Config内增加读ID功能,初始化ETH后就读。

  /* Configure the GPIO ports for ethernet pins */
  ETH_GPIO_Config();

  /* Configure the Ethernet MAC/DMA */
  ETH_MACDMA_Config();

    uart_printf("read phy id\r\n");
	ID1 = ETH_ReadPHYRegister(ETH_PHY_ADRESS, 0X02);
	ID2 = ETH_ReadPHYRegister(ETH_PHY_ADRESS, 0X03);
	uart_printf("PHY ID:%02x %02x\r\n", ID1, ID2);

修改完之后成功读取ID。

获取IP

接上网线,通过路由器分配IP地址。

hello word! ETH_BSP_Config read phy id PHY ID:07 c0f1 PHY_BSR: 782d phy ETH_LINK_FLAG Looking forDHCP serverplease wait… IP address assigned by a DHCP server192.168.2.169

成功获取IP地址

通信测试

我们移植的是tcp_echo_server,也就是一个TCP协议自动回显的server。 在函数tcp_echoserver_init();中有以下初始化代码

void tcp_echoserver_init(void)
{
  /* create new tcp pcb */
  tcp_echoserver_pcb = tcp_new();

  if (tcp_echoserver_pcb != NULL)
  {
    err_t err;

    /* bind echo_pcb to port 7 (ECHO protocol) */
    err = tcp_bind(tcp_echoserver_pcb, IP_ADDR_ANY, 7);

    if (err == ERR_OK)
    {
      /* start tcp listening for echo_pcb */
      tcp_echoserver_pcb = tcp_listen(tcp_echoserver_pcb);

      /* initialize LwIP tcp_accept callback function */
      tcp_accept(tcp_echoserver_pcb, tcp_echoserver_accept);
    }
    else
    {
      /* deallocate the pcb */
      memp_free(MEMP_TCP_PCB, tcp_echoserver_pcb);
      printf("Can not bind pcb\n");
    }
  }
  else
  {
    printf("Can not create new pcb\n");
  }
}

其中第11行代码,将tcp绑定到端口7

我们运行网络调试助手,设置如下图, _images/pic121.jpg原理图

协议选择Tcp Client IP地址选择开发板获取到的地址 使用端口7

点击链接 连接成功后点击发送, 开发板会显数据给电脑。 测试成功。 勾上左下角数据流循环发送,点击发送,就可以重复发送,测试是否会出现丢包。

总结

ST提供的例程有多种,大家可以尝试其他例程。


end

can总线调试记录

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


本节将向大家介绍如何使用STM32F4自带的CAN控制器实现两个开发板之间的CAN通信。

CAN

CAN是控制器局域网络(Controller Area Network, CAN)的简称,是由以研发和生产汽车电子产品著称的德国BOSCH公司开发的,并最终成为国际标准(ISO 11898),是国际上应用最广泛的现场总线之一。 在北美和西欧,CAN总线协议已经成为汽车计算机控制系统和嵌入式工业控制局域网的标准总线,并且拥有以CAN为底层协议专为大型货车和重工机械车辆设计的J1939协议。

百度百科

CAN 是Controller Area Network 的缩写(以下称为CAN),是ISO国际标准化的串行通信协议。在汽车产业中,出于对安全性、舒适性、方便性、低公害、低成本的要求,各种各样的电子控制系统被开发了出来。由于这些系统之间通信所用的数据类型及对可靠性的要求不尽相同,由多条总线构成的情况很多,线束的数量也随之增加。为适应“减少线束的数量”、“通过多个LAN,进行大量数据的高速通信”的需要,1986 年德国电气商博世公司开发出面向汽车的CAN 通信协议。此后,CAN 通过ISO11898 及ISO11519 进行了标准化,在欧洲已是汽车网络的标准协议。 CAN 的高性能和可靠性已被认同,并被广泛地应用于工业自动化、船舶、医疗设备、工业设备等方面。现场总线是当今自动化领域技术发展的热点之一,被誉为自动化领域的计算机局域网。它的出现为分布式控制系统实现各节点之间实时、可靠的数据通信提供了强有力的技术支持。

特点

  • 完成对通信数据的成帧处理 集成了CAN协议的物理层和数据链路层功能,可完成对通信数据的成帧处理,包括位填充、数据块编码、循环冗余检验、优先级判别等项工作。
  • 使网络内的节点个数在理论上不受限制 CAN协议的一个最大特点是废除了传统的站地址编码,而代之以对通信数据块进行编码。采用这种方法的优点可使网络内的节点个数在理论上不受限制,数据块的标识符可由11位或29位二进制数组成,因此可以定义2或2个以上不同的数据块,这种按数据块编码的方式,还可使不同的节点同时接收到相同的数据,这一点在分布式控制系统中非常有用。数据段长度最多为8个字节,可满足通常工业领域中控制命令、工作状态及测试数据的一般要求。同时,8个字节不会占用总线时间过长,从而保证了通信的实时性。CAN协议采用CRC检验并可提供相应的错误处理功能,保证了数据通信的可靠性。CAN卓越的特性、极高的可靠性和独特的设计,特别适合工业过程监控设备的互连,因此,越来越受到工业界的重视,并已公认为最有前途的现场总线之一。
  • 可在各节点之间实现自由通信 CAN总线采用了多主竞争式总线结构,具有多主站运行和分散仲裁的串行总线以及广播通信的特点。CAN总线上任意节点可在任意时刻主动地向网络上其它节点发送信息而不分主次,因此可在各节点之间实现自由通信。CAN总线协议已被国际标准化组织认证,技术比较成熟,控制的芯片已经商品化,性价比高,特别适用于分布式测控系统之间的数据通讯。CAN总线插卡可以任意插在PC AT XT兼容机上,方便地构成分布式监控系统。
  • 结构简单 只有2根线与外部相连,并且内部集成了错误探测和管理模块。
  • 传输距离和速率 CAN总线特点:(1) 数据通信没有主从之分,任意一个节点可以向任何其他(一个或多个)节点发起数据通信,靠各个节点信息优先级先后顺序来决定通信次序,高优先级节点信息在134μs通信; (2) 多个节点同时发起通信时,优先级低的避让优先级高的,不会对通信线路造成拥塞; (3) 通信距离最远可达10KM(速率低于5Kbps)速率可达到1Mbps(通信距离小于40M);(4) CAN总线传输介质可以是双绞线,同轴电缆。CAN总线适用于大数据量短距离通信或者长距离小数据量,实时性要求比较高,多主多从或者各个节点平等的现场中使用。

通信

典型的CAN通信网络如下图。 _images/pic15.png图片

所有CAN节点通过CANH和CANL连接到CAN网络上。 前面我们学习串口的时候知道,串口是发送和接收交叉相连。 CAN节点并没有所谓的发送和接收,所有的CAN节点,都是CANH与CANH相连,CANL与CANL相连。

那么CAN是如何通信的呢?

  • 物理层

请看下图: 当CANH等于CANL,电平都是2.3V,叫做隐性电平。 当CANH为高电平,CANL为低电平时(两者之差大于0.9V),叫做显性电平。 按照规定,隐性代表逻辑1,显性代表逻辑0。 _images/pic29.png图片

电平说清楚了,但是这个没有接收发送管脚,如何通信呢?

  • 数据链路层

前面说到CAN特点的时候,我们提到过:CAN协议不仅仅实现了物理层连接,还实现了数据链路层的功能。 什么叫数据链路层? 在调试网络功能的时候我们提到过OSI的数据链路层。

数据链路层是OSI参考模型中的第二层,介乎于物理层和网络层之间。数据链路层在物理层提供的服务的基础上向网络层提供服务,其最基本的服务是将源自网络层来的数据可靠地传输到相邻节点的目标机网络层。为达到这一目的,数据链路必须具备一系列相应的功能,主要有:如何将数据组合成数据块,在数据链路层中称这种数据块为帧(frame),帧是数据链路层的传送单位;如何控制帧在物理信道上的传输,包括如何处理传输差错,如何调节发送速率以使与接收方相匹配;以及在两个网络实体之间提供数据链路通路的建立、维持和释放的管理。

通俗的说,数据链路层的功能就是保证数据传输的可靠性,并且管理数据传输。 例如数据丢包了,要不要重发?重发几次? 为了达到这个功能,通常需要对原始数据进行封装处理。也就是所谓的帧。 不过大家要搞清楚,数据链路层不是应用层,数据链路只负责数据传输,不关心数据内容和数据功能。

例如前面的串口,它只是一个物理层的。如果用于两个设备之间的通信,为了保证通信的可靠性,我们需要加上一定的机制,例如握手,重发,校验,数据头数据尾等。这些,就是数据链路层功能。

协议

CAN 总线是一个广播类型的总线,所以任何在总线上的节点都可以监听总线上传输的数据。 也就是说总线上的传输不是点到点的,而是一点对多点的传输,这里多点的意思是总线上所有的节点。 但是总线上的节点如何知道那些数据是传送给自己的呢? CAN总线的硬件芯片提供了一种叫做本地过滤的功能,通过这种本地过滤的功能可以过滤掉一些和自己无关的数据,而保留一些和自己有关的信息。

协议就是数据链路层的实现。
  • 帧类型

CAN协议有以下4中帧: _images/pic35.png图片

更信息规范请查阅资料中的<CAN BUS规范v2.0+中文版.pdf>

STM32 CAN

STM32带的CAN控制器叫基本扩展CAN外设,又称bxCAN。支持2.0A和B版本协议。

特性

_images/pic46.png图片

_images/pic55.png图片 上图是CAN的应用拓扑结构。 红框1里面是CAN芯片,也就是我们的VP230芯片。 红框2就是STM32芯片的CAN控制器。

框图

_images/pic63.png图片

STM32有两个CAN,CAN1做主bxCAN,can2做从bxCAN。

过滤器

关于过滤器的说明在文档<7.4,标识符筛选>。

STM32 CAN外设一个重要的功能就是硬件过滤功能。 前面提到,CAN使用的是广播方式通信,一个节点会收到总线上的所有数据(所有帧)。 但是并不是所有消息都是我需要的。有了过滤器,就可以在软件不干预的情况下丢弃那些我不要的数据。

每组过滤器包括了2个可配置的32位寄存器:CAN_FxR0和CAN_FxR1。 这些过滤器相当于关卡,每当收到一条报文时,CAN要先将收到的报文从这些过滤器上”过”一下, 能通过的报文是有效报文,收进相关联FIFO(FIFO1或FIFO2), 不能通过的是无效报文(不是发给”我”的报文),直接丢弃。

我们从配置看看过滤器功能。

/**
  * @brief  CAN filter init structure definition
  */
typedef struct
{
  uint16_t CAN_FilterIdHigh; /*!< Specifies the filter identification number (MSBs for a 32-bit
                                configuration, first one for a 16-bit configuration).
                                This parameter can be a value between 0x0000 and 0xFFFF */

  uint16_t CAN_FilterIdLow; /*!< Specifies the filter identification number (LSBs for a 32-bit
                                configuration, second one for a 16-bit configuration).
                                This parameter can be a value between 0x0000 and 0xFFFF */

  uint16_t CAN_FilterMaskIdHigh; /*!< Specifies the filter mask number or identification number,
                                according to the mode (MSBs for a 32-bit configuration,
                                first one for a 16-bit configuration).
                                This parameter can be a value between 0x0000 and 0xFFFF */

  uint16_t CAN_FilterMaskIdLow; /*!< Specifies the filter mask number or identification number,
                                according to the mode (LSBs for a 32-bit configuration,
                                second one for a 16-bit configuration).
                                This parameter can be a value between 0x0000 and 0xFFFF */

  uint16_t CAN_FilterFIFOAssignment; /*!< Specifies the FIFO (0 or 1) which will be assigned
                                     to the filter.
                                    This parameter can be a value of @ref CAN_filter_FIFO */

  uint8_t CAN_FilterNumber; /*!< Specifies the filter which will be initialized.
                             It ranges from 0 to 13. */

  uint8_t CAN_FilterMode;    /*!< Specifies the filter mode to be initialized.
                            This parameter can be a value of @ref CAN_filter_mode */

  uint8_t CAN_FilterScale;  /*!< Specifies the filter scale.
                            This parameter can be a value of @ref CAN_filter_scale */

  FunctionalState CAN_FilterActivation; /*!< Enable or disable the filter.
                            This parameter can be set either to ENABLE or DISABLE. */
} CAN_FilterInitTypeDef;

上面就是一个CAN过滤器的配置。

  • CAN_FilterMode 过滤模式。有两种掩码模式和列表模式。
#define CAN_FilterMode_IdMask       ((uint8_t)0x00)  /*!< identifier/mask mode */
#define CAN_FilterMode_IdList       ((uint8_t)0x01)  /*!< identifier list mode */
  • CAN_FilterNumber 过滤器编号,STM32有14个过滤器。
  • CAN_FilterScale 设置过滤器刻度,16位或32位。
  • CAN_FilterFIFOAssignment 设置使用过滤器的接收FIFO,有两个:0和1。请看框图右上角。读数据时需要指定FIFO的。
  • CAN_FilterIdHigh、CAN_FilterIdLow 叫做FilterId
  • CAN_FilterMaskIdHigh、CAN_FilterMaskIdLow 叫它FilterMaskId

这四个值,对应过滤器的2个32位寄存器:CAN_FxR1、CAN_FxR2

  1. 如果CAN_FilterScale是16位: 那么CAN_FilterMaskIdLow<<16+CAN_FilterIdLow设置到CAN_FR1。 CAN_FilterMaskIdHigh<<16+CAN_FilterIdHigh设置到CAN_FR2。
  2. 如果CAN_FilterScale是32位: CAN_FilterMaskIdHigh<<16 + CAN_FilterMaskIdLow 设置到CAN_FR2。

CAN_FilterIdHigh<<16 + CAN_FilterIdLow 设置到CAN_FR1

查看设置函数可见上述细节

void CAN_FilterInit(CAN_FilterInitTypeDef* CAN_FilterInitStruct)

按工作模式和宽度,一个过滤器组可以变成以下几中形式之一:

Scale Mode 过滤器数量 FxR1 FxR2
32 掩码 1 要判断的位的值 那些位需要判断
32 列表 2 过滤器1的判断值 过滤器2的判断值
16 掩码 2 过滤器0,[31-16]位指定要关心的位,[15-0]位指定这些位的标准值。 过滤器1,[31-16]位指定要关心的位,[15-0]位指定这些位的标准值
16 列表 4 [15-0]位配置过滤器0,FxR1的[31-16]位配置过滤器1。[15-0]位配置过滤器2,FxR2的[31-16]位配置过滤器3
  • 每组过滤器组必须关联且只能关联一个FIFO。复位默认都关联到FIFO_0。
  • 每个FIFO的所有过滤器都是并联的,只要通过了其中任何一个过滤器,该报文就有效。
  • 如果一个报文既符合FIFO_0,又符合FIFO_1,显然,根据操作顺序,它只会放到FIFO_0中。

更多的CANX相关请查看文档

VP230芯片

VP230是SN65HVD230DR的俗称,是一款TI出的CAN芯片。 我们选用这块芯片的原因是:工作电压3.3V,且可以和常用的5V CAN芯片TJA1050通信

原理图

_images/pic73.jpg原理图

移植官方例程

官方CAN例程有两个:CAN_LoopBack(回环测试);CAN_Networking(组网模式)。 所谓的回环模式就是将自身发送的消息作为接收的消息来处理并存储。 我们移植组网模式。

例程分析

在CAN例程内,并没有任何CAN相关的文件。 _images/pic82.png图片

查看main.c,CAN_Config配置CAN后,进入while循环。 循环中如果判断到按键按下,在else分支就会将键值赋值到发送消息。 然后调用CAN_Transmit发送消息。

/* CAN configuration */
  CAN_Config();

  while(1)
  {
    while(STM_EVAL_PBGetState(BUTTON_KEY) == KEY_PRESSED)
    {
      if(ubKeyNumber == 0x4)
      {
        ubKeyNumber = 0x00;
      }
      else
      {
        LED_Display(++ubKeyNumber);
        TxMessage.Data[0] = ubKeyNumber;
        CAN_Transmit(CANx, &TxMessage);
        /* Wait until one of the mailboxes is empty */
        while((CAN_GetFlagStatus(CANx, CAN_FLAG_RQCP0) !=RESET) || \
              (CAN_GetFlagStatus(CANx, CAN_FLAG_RQCP1) !=RESET) || \
              (CAN_GetFlagStatus(CANx, CAN_FLAG_RQCP2) !=RESET));

        while(STM_EVAL_PBGetState(BUTTON_KEY) != KEY_NOT_PRESSED)
        {
        }
      }
    }
  }

分析CAN_Config函数。 13~25行,配置对应IO为CAN功能。 32~48,can基本配置,具体意思看注释 50~60,滤波器设置,R1和R2都设置为0,也就是所有报文都能通过滤波器。 62~67, 初始化一组发送消息 70行,使能CAN中断。

/**
  * @brief  Configures the CAN.
  * @param  None
  * @retval None
  */
static void CAN_Config(void)
{
  GPIO_InitTypeDef  GPIO_InitStructure;

  /* CAN GPIOs configuration **************************************************/

  /* Enable GPIO clock */
  RCC_AHB1PeriphClockCmd(CAN_GPIO_CLK, ENABLE);

  /* Connect CAN pins to AF9 */
  GPIO_PinAFConfig(CAN_GPIO_PORT, CAN_RX_SOURCE, CAN_AF_PORT);
  GPIO_PinAFConfig(CAN_GPIO_PORT, CAN_TX_SOURCE, CAN_AF_PORT);

  /* Configure CAN RX and TX pins */
  GPIO_InitStructure.GPIO_Pin = CAN_RX_PIN | CAN_TX_PIN;
  GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF;
  GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
  GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;
  GPIO_InitStructure.GPIO_PuPd  = GPIO_PuPd_UP;
  GPIO_Init(CAN_GPIO_PORT, &GPIO_InitStructure);

  /* CAN configuration ********************************************************/  
  /* Enable CAN clock */
  RCC_APB1PeriphClockCmd(CAN_CLK, ENABLE);

  /* CAN register init */
  CAN_DeInit(CANx);

  /* CAN cell init */
  CAN_InitStructure.CAN_TTCM = DISABLE; //非时间出发通信模式
  CAN_InitStructure.CAN_ABOM = DISABLE; //软件自动离线管理
  CAN_InitStructure.CAN_AWUM = DISABLE; //睡眠模式通过软件唤醒
  CAN_InitStructure.CAN_NART = DISABLE; //禁止报文自动传送
  CAN_InitStructure.CAN_RFLM = DISABLE; //报文不锁定,新的覆盖旧的
  CAN_InitStructure.CAN_TXFP = DISABLE; //优先级由报文标识符决定
  CAN_InitStructure.CAN_Mode = CAN_Mode_Normal; //模式设置为普通模式
  CAN_InitStructure.CAN_SJW = CAN_SJW_1tq; //重新同步跳跃宽度为1个时间单位

  /* CAN Baudrate = 1 MBps (CAN clocked at 30 MHz) 这里是设置CAN波特率*/
  CAN_InitStructure.CAN_BS1 = CAN_BS1_6tq; //时间段1占用6个时间单位
  CAN_InitStructure.CAN_BS2 = CAN_BS2_8tq; //时间段2占用8个时间单位
  CAN_InitStructure.CAN_Prescaler = 2;//分频系数
  CAN_Init(CANx, &CAN_InitStructure);

  /* CAN filter init  设置滤波器*/
  CAN_FilterInitStructure.CAN_FilterNumber = 0; //使用滤波器组0
  CAN_FilterInitStructure.CAN_FilterMode = CAN_FilterMode_IdMask; //
  CAN_FilterInitStructure.CAN_FilterScale = CAN_FilterScale_32bit;
  CAN_FilterInitStructure.CAN_FilterIdHigh = 0x0000;
  CAN_FilterInitStructure.CAN_FilterIdLow = 0x0000;
  CAN_FilterInitStructure.CAN_FilterMaskIdHigh = 0x0000;
  CAN_FilterInitStructure.CAN_FilterMaskIdLow = 0x0000;
  CAN_FilterInitStructure.CAN_FilterFIFOAssignment = 0;
  CAN_FilterInitStructure.CAN_FilterActivation = ENABLE;
  CAN_FilterInit(&CAN_FilterInitStructure);

  /* Transmit Structure preparation 消息初始化*/
  TxMessage.StdId = 0x321;
  TxMessage.ExtId = 0x01;
  TxMessage.RTR = CAN_RTR_DATA;
  TxMessage.IDE = CAN_ID_STD;
  TxMessage.DLC = 1;

  /* Enable FIFO 0 message pending Interrupt 初始化中断*/
  CAN_ITConfig(CANx, CAN_IT_FMP0, ENABLE);
}

其中的发送数据结构是库定义的

/**
  * @brief  CAN Tx message structure definition  
  */
typedef struct
{
  uint32_t StdId;  /*!< Specifies the standard identifier.
                        This parameter can be a value between 0 to 0x7FF. */

  uint32_t ExtId;  /*!< Specifies the extended identifier.
                    This parameter can be a value between 0 to 0x1FFFFFFF. */

  uint8_t IDE;     /*!< Specifies the type of identifier for the message that
                        will be transmitted. This parameter can be a value
                        of @ref CAN_identifier_type */

  uint8_t RTR;     /*!< Specifies the type of frame for the message that will
                        be transmitted. This parameter can be a value of
                        @ref CAN_remote_transmission_request */

  uint8_t DLC;     /*!< Specifies the length of the frame that will be
                        transmitted. This parameter can be a value between
                        0 to 8 */

  uint8_t Data[8]; /*!< Contains the data to be transmitted. It ranges from 0
                        to 0xFF. */
} CanTxMsg;

CAN的配置到底什么意思,看stm32f4xx_can.h就可以知道大概了。

在CONFIG之前就调用了NVIC配置初始化CAN的中断优先级

/**
  * @brief  Configures the NVIC for CAN.
  * @param  None
  * @retval None
  */
static void NVIC_Config(void)
{
  NVIC_InitTypeDef  NVIC_InitStructure;

  NVIC_InitStructure.NVIC_IRQChannel = CAN1_RX0_IRQn;
  NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0x0;
  NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0x0;
  NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
  NVIC_Init(&NVIC_InitStructure);
}

其中在中断处理如下: 收到数据后,判断STDID和IDE DLC标志。

/**
  * @brief  This function handles CAN1 RX0 request.
  * @param  None
  * @retval None
  */
void CAN1_RX0_IRQHandler(void)
{
  CAN_Receive(CAN1, CAN_FIFO0, &RxMessage);

  if ((RxMessage.StdId == 0x321)&&(RxMessage.IDE == CAN_ID_STD)
      && (RxMessage.DLC == 1))
  {
    LED_Display(RxMessage.Data[0]);
    ubKeyNumber = RxMessage.Data[0];
  }
}

接收数据的格式也是在stm32f4xx_can.h中有定义。

移植

  1. 将main.c里面的CAN控制器配置代码拷贝到我们工程mcu_can.c.
  2. 将stm32f4xx_it.c中的CAN1_RX0_IRQHandler接收中断拷贝到我们的stm32f4xx_it.c。
  3. 修改IO口配置。使用PB8/PB9,通过查数据手册,可知是CAN1。
  • 另外,官方例程代码层次不是很好,我们重新划分。

具体见代码。

测试

本次测试需要两块开发板进行测试。 接线同向接线,也即是CANH连接CANH,CANL连接CANL。 测试程序如下:

int mcu_can_test(void)
{
	uint8_t data = 0;
  /* NVIC configuration */
  NVIC_CAN_Config();

  /* CAN configuration */
  mcu_can_config();

  while(1)
  {
	#if 1/* 测试发送端,发送后等待接收端响应 */
	Delay(1000);
	data++;
	/* Transmit Structure preparation */
	TxMessage.StdId = 0x321;
	TxMessage.ExtId = 0x01;
	TxMessage.RTR = CAN_RTR_DATA;
	TxMessage.IDE = CAN_ID_STD;
	TxMessage.DLC = 1;

    TxMessage.Data[0] = data;
    CAN_Transmit(CANx, &TxMessage);

    /* Wait until one of the mailboxes is empty */
    while((CAN_GetFlagStatus(CANx, CAN_FLAG_RQCP0) !=RESET) || \
          (CAN_GetFlagStatus(CANx, CAN_FLAG_RQCP1) !=RESET) || \
          (CAN_GetFlagStatus(CANx, CAN_FLAG_RQCP2) !=RESET));

	uart_printf("can transmit :%02x\r\n", data);

	while(1)
	{
		if(CanRxFlag == 1)
		{
			CanRxFlag = 0;

			if ((RxMessage.StdId == 0x321)&&(RxMessage.IDE == CAN_ID_STD)
                           && (RxMessage.DLC == 1))
			{

				uart_printf("can rep :%02x\r\n", RxMessage.Data[0]);

			}

			break;
		}
	}

	#else
	/*  测试接收端,接收到数据后返回给发送端 */
	if(CanRxFlag == 1)
	{
		CanRxFlag = 0;

		if ((RxMessage.StdId == 0x321)&&(RxMessage.IDE == CAN_ID_STD)
                    && (RxMessage.DLC == 1))
		{
			uart_printf("can receive :%02x\r\n", RxMessage.Data[0]);
			/* Transmit Structure preparation */
			TxMessage.StdId = 0x321;
			TxMessage.ExtId = 0x01;
			TxMessage.RTR = CAN_RTR_DATA;
			TxMessage.IDE = CAN_ID_STD;
			TxMessage.DLC = 1;

			TxMessage.Data[0] = RxMessage.Data[0];
			CAN_Transmit(CANx, &TxMessage);

			/* Wait until one of the mailboxes is empty */
			while((CAN_GetFlagStatus(CANx, CAN_FLAG_RQCP0) !=RESET) || \
			      (CAN_GetFlagStatus(CANx, CAN_FLAG_RQCP1) !=RESET) || \
			      (CAN_GetFlagStatus(CANx, CAN_FLAG_RQCP2) !=RESET));

				}
		    uart_printf("can send rep ok\r\n");
	}
	#endif
  }
}

while循环内的代码使用条件编译,分别是接收与发送。 测试时使用两块开发板,一个下载发送的代码,一块下载接收代码。 接收端先启动,发送端后启动。 发送端调试信息如下:

hello word! can transmit :01 can rx message can rep :01 can transmit :02 can rx message can rep :02

接收端调试信息如下:

hello word! can rx message can receive :01 can send rep ok can rx message can receive :02 can send rep ok

发送端发送01,接收端收到01后返回rep,回显01,接收端收到rep:01。 流程重复,发送的数据不断加1 。

总结

  1. CAN的基本应用不难,如果真正应用在项目中,需要考虑更多的协议处理,例如仲裁。
  2. 通过CAN的学习,希望大家有数据链路层的概念。以后做项目,每一种通信,最好加上数据链路层,这样可以保证数据传输的可靠性。

end

RS485-串口驱动改造

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


上一节我们调试了CAN总线,常见的总线还有RS-485总线。 本节我们一起调试407的485总线。

RS485

惯例,百度百科:

RS-485又名TIA-485-A, ANSI/TIA/EIA-485或TIA/EIA-485。 RS485是一个定义平衡数字多点系统中的驱动器和接收器的电气特性的标准,该标准由电信行业协会和电子工业联盟定义。使用该标准的数字通信网络能在远距离条件下以及电子噪声大的环境下有效传输信号。RS-485使得廉价本地网络以及多支路通信链路的配置成为可能。 RS485有两线制和四线制两种接线,四线制只能实现点对点的通信方式,现很少采用,现在多采用的是两线制接线方式,这种接线方式为总线式拓扑结构,在同一总线上最多可以挂接32个节点。 在RS485通信网络中一般采用的是主从通信方式,即一个主机带多个从机。

总线网络

一个典型的RS485网络通常如下图: _images/pic16.png网络

多个端点用双绞线连接在一起。 两端有两个匹配电阻,通常是120欧姆。 这些端点中只有一个做主机。剩下的都是从机。

电平

RS485与CAN有点类似,都是用两根线之间的压差传输数据。 两线之间压差为+(2~6)V,逻辑1。 两线之间压差为-(2~6)V,逻辑0。

协议

RS485仅仅规定了接收端和发送端的电气特性。它并没有规定或推荐任何数据协议。 和CAN对比一下就知道,RS485只是定义了物理层,没有定义数据链路层。

RS485可以认为是一个半双工的串口。 以前调试的串口,用的是RS232电平(连接方式/物理层)。现在调试的就是一个用RS485电平(连接方式/物理层)的串口。 并且是一个半双工的串口。

特点

由于使用了RS485连接方式,相对于RS232的优点就是:

  1. 传输距离远,在低速率时,最远可以传输1200米。
  2. 可以通在网络上连接多个节点。
  3. 相对RS232,电压更低,更安全。
  4. 使用双绞线,差分传输,抗干扰能力强。

SP3485E

SP3485E是EXAR公司的485芯片,有以下特点:

  1. 3.3V工作电压。
  2. 10Mbsps。
  3. 最多支持32个节点。
  4. 输出短路保护。

原理图

_images/pic26.jpg原理图

  • TX接在PA9上,RX接在PA10上,与外扩串口USB摄像头共用。使用RS485时,这三个外设不要使用。
  • RX&TX连接到串口。
  • RE是接收发送控制管脚(485是半双工)

驱动设计与调试

在dev_board建立dev_rs485驱动文件。 RS485驱动基于串口驱动。在前面做串口驱动程序时,并没有考虑多个串口的情况。因此在设计RS485驱动前,要先改造串口驱动。

串口驱动改造

怎么改造?思考下面情景

  • 3个串口:
uart1用来输出调试信息,那么它的上一层就是LOG功能。 uart2用于485,上一层是485外设驱动。 uart3用于8266 wifi模块,上一层则是8266驱动程序。
  • 串口需要什么?或者说一个设备基于什么设备?
1 串口控制器。 2 硬件IO口。 3 硬件RAM(接收发送缓冲) 4 中断 5 不需要时间片轮询。
  • 串口驱动要提供什么接口?
  1. 初始化(重要,不能跟打开混为一谈
  2. 打开
  3. 关闭
  4. 读数据(接收)
  5. 写数据(发送)
  • 串口工作情景在时间角度上,在数据流转角度上是怎么样的?
1 中断接收到数据,保存在缓冲。上一层调用读数据接口,读的其实是接收缓冲内的数据。 2 上一层调用写数据时,将数据通过串口发送出去。 阻塞发送?还是通过中断发送? 这个是可以考虑的,一般情况阻塞发送问题也不大。但是接收肯定不能轮询等待接收。 理由就是:接收,是别人发给你的,你不知道什么时候有数据来。发送,是我要发的,我知道什么时候发。

综上考虑 我们抽象设计一个串口设备(一个结构体)。

/*
@bref:串口设备
*/
typedef struct  
{
	/* 硬件相关*/
	/*
		STM IO 配置需要太多数据,可以直接在代码中定义, 或者用宏定义
		如果是更上一层设备驱动,例如FLASH ,就可以在设备抽象中定义一个用哪个SPI的定义。
	*/

	USART_TypeDef* USARTx;

	/*RAM相关*/
	s32 gd;	//设备句柄 小于等于0则为未打开设备

	u16 size;// buf 大小
	u8 *Buf;//缓冲指针
	u16 Head;//头
	u16 End;//尾
	u8  OverFg;//溢出标志
}_strMcuUart;
  1. 理论上,硬件相关的应该定义在设备结构体内。串口,基本上写了就不改了,因此,直接定义到代码内也可以。
  2. USARTx 串口号。
  3. gd 设备控制符,设备句柄。
  4. size 缓冲大小
  5. Buf 缓冲指针,指向接收缓冲。
  6. Head、End 环形缓冲头尾指针。
  7. OverFg 溢出标志

同时用枚举定义串口设备

typedef enum {
  MCU_UART_1 =0,
  MCU_UART_2,
  MCU_UART_3,
  MCU_UART_MAX,
}
McuUartNum;

串口设备这样定义后,接口就改成如下:

extern s32 mcu_uart_open (McuUartNum comport);
extern s32 mcu_uart_close (McuUartNum comport);
extern s32 mcu_uart_write (McuUartNum comport, u8 *buf, s32 len);
extern s32 mcu_uart_read (McuUartNum comport, u8 *buf, s32 len);
extern s32 mcu_uart_set_baud (McuUartNum comport, s32 baud);
extern s32 mcu_uart_tcflush (McuUartNum comport);
每个接口有一个comport参数,在操作串口时,通过这个参数传入串口设备编号,就能操作串口了。

经过这样改造,串口驱动就可以支持多个串口了。 如果需要支持多平台,就还需要将初始化中相关的硬件参数也抽象到串口设备结构体内。

RS485驱动

485驱动就很简单了。 功能跟串口类似(本来就是基于串口驱动),因此接口定义如下:

extern s32 dev_rs485_init(void);
extern s32 dev_rs485_open(void);
extern s32 dev_rs485_close(void);
extern s32 dev_rs485_read(u8 *buf, s32 len);
extern s32 dev_rs485_write(u8 *buf, s32 len);
extern s32 dev_rs485_ioctl(void);

标准设备接口。

设备初始化,就初始化IO,并且配置为接收模式。

/**
 *@brief:      dev_rs485_init
 *@details:    初始化485设备
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
s32 dev_rs485_init(void)
{
	GPIO_InitTypeDef GPIO_InitStructure;

	RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOG,ENABLE); //使能 PG时钟
	//PG8 推挽输出, 485 模式控制
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8; //GPIOG8
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT;//输出
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz; //速度 100MHz
	GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; //推挽输出
	GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; //上拉
	GPIO_Init(GPIOG, &GPIO_InitStructure); //初始化 PG8

	//初始化设置为接收模式
	GPIO_ResetBits(GPIOG, GPIO_Pin_8);

	return 0;
}

打开设备的时候才打开串口

/**
 *@brief:      dev_rs485_open
 *@details:    打开RS485设备
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
s32 dev_rs485_open(void)
{
	mcu_uart_open(DEV_RS485_UART);
	mcu_uart_set_baud(DEV_RS485_UART, 9600);
	return 0;
}

跟串口不一样的就是发送,要将485设置为发送模式,并且是阻塞发送。发送结束后切回接收模式。

/**
 *@brief:      dev_rs485_write
 *@details:    rs485发送数据
 *@param[in]   u8 *buf  
               s32 len  
 *@param[out]  无
 *@retval:     
 */
s32 dev_rs485_write(u8 *buf, s32 len)
{
	s32 res;

	GPIO_SetBits(GPIOG, GPIO_Pin_8);//设置为发送模式
	res = mcu_uart_write(DEV_RS485_UART, buf, len);
	GPIO_ResetBits(GPIOG, GPIO_Pin_8);//发送结束后设置为接收模式

	return res;
}

更多请查看代码

调试
1 下程序,接收端没有接收到 2 看原理图,串口跟485芯片的地方不知道为什么加了3个电阻,先去掉。 3 去掉电阻后还是不行。 4 详细对原理图,发现硬件RX跟TX接反。

测试代码与CAN类似,接收端跟发送端使用不同程序。发送端循环发送字符串”rs485 test”,接收端收到字符串后通过调试串口输出。

s32 dev_rs485_test(void)
{
	u8 buf[20];
	u8 len;
	s32 res;

	dev_rs485_init();
	dev_rs485_open();

	#if 0// 发送端测试
	while(1)
	{
		Delay(1000);
		res = dev_rs485_write("rs485 test\r\n", 13);
		uart_printf("dev rs485 write:%d\r\n", res);
	}
	#else//接收端测试
	while(1)
	{
		Delay(20);
		len = dev_rs485_read(buf, sizeof(buf));
		if(len > 0)
		{
			buf[len] = 0;
			uart_printf("%s", buf);
			memset(buf, 0, sizeof(buf));
		}
	}
	#endif
}

总结

RS485是一个简单的设备。 本节更希望大家了解串口驱动的改造。


end

RTC

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


本章我们调试STM32F407的内置实时时钟(RTC),同时介绍与RTC相关的BKP功能。

RTC

RTC = Real-Time Clock,实时时钟。 实时时钟有两种方式:芯片内置、CPU外置。 外置的RTC通常会选用一款芯片,例如PCF8563、DS1302。 当前大部分单片机芯片都有内置的RTC。

RTC通常有以下特点:

  1. 低功耗。
  2. 使用纽扣电池供电。
  3. 主进入睡眠后,RTC依然运行。
  4. 时钟32.768K

STM32 RTC

_images/pic210.png

框图

_images/pic17.png

特性

_images/pic36.png

备份寄存器BKP

细节在<23.3.13 入侵检测>

STM32F407 RTC附带两个入侵检测输入。与入侵检测配套RTC系统包含20个备份寄存器(80字节)。发生入侵检测事件时,将复位备份寄存器。 只要RTC后备电池正常,RTC和备份寄存器区就会一直工作。不会在系统复位或电源复位时复位,也不会从器件待机模式唤醒时复位。

写操作

RTC 寄存器和备份寄存器都属于后备区,每次写操作前,需要先解锁后备区,以防后备区数据被串改。

更多资料请查看中文参考手册23章

编码调试

本节我们仅验证RTC能正常工作。

编码

RTC初始化函数

/**
 *@brief:      mcu_rtc_init
 *@details:    复位时初始化RTC
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
s32 mcu_rtc_init(void)
{

	RTC_InitTypeDef RTC_InitStructure;
	volatile u32 cnt = 0;

  	RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR|RCC_AHB1Periph_BKPSRAM, ENABLE);
	/*操作RTC寄存器,需要使能备份区*/
	PWR_BackupAccessCmd(ENABLE);

	if(RTC_ReadBackupRegister(RTC_BKP_DR0)!=0x55aa)
	{
		wjq_log(LOG_DEBUG, " init rtc\r\n");
		/*开启LSE时钟*/
		RCC_LSEConfig(RCC_LSE_ON);
		/*等待RCC LSE时钟就绪*/
		while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) == RESET)
		{
			cnt++;
			if(cnt>0x2000000)
			{
				wjq_log(LOG_ERR, "lse not rdy\r\n");
				return -1;

			}
		}		

		RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE);
		RCC_RTCCLKCmd(ENABLE);
                //RTC异步分频系数(1~0X7F)
                RTC_InitStructure.RTC_AsynchPrediv = 0x7F;
                //RTC同步分频系数(0~7FFF)
                RTC_InitStructure.RTC_SynchPrediv  = 0xFF;
                //RTC设置为,24小时格式
                RTC_InitStructure.RTC_HourFormat   = RTC_HourFormat_24;
                RTC_Init(&RTC_InitStructure);

		RTC_TimeTypeDef RTC_TimeStructure;

		RTC_TimeStructure.RTC_H12 = RTC_H12_AM;
		RTC_TimeStructure.RTC_Hours = 0;
		RTC_TimeStructure.RTC_Minutes = 0;
		RTC_TimeStructure.RTC_Seconds = 0;
		RTC_SetTime(RTC_Format_BIN, &RTC_TimeStructure);

		RTC_DateTypeDef RTC_DateStructure;
		RTC_DateStructure.RTC_Date = 1;
		RTC_DateStructure.RTC_Month = RTC_Month_January;
		RTC_DateStructure.RTC_WeekDay = RTC_Weekday_Thursday;
		RTC_DateStructure.RTC_Year = 0;//已1970年为起点,
		RTC_SetDate(RTC_Format_BIN, &RTC_DateStructure);

		RTC_WriteBackupRegister(RTC_BKP_DR0,0x55aa);	//标记已经初始化过了
	}

	wjq_log(LOG_INFO, " init rtc finish\r\n");
	return 0;
}
14行,打开RTC时钟 16行,解锁BKP区 19~59行,配置RTC。并不是每次都配置RTC,因此我们用一个BKP寄存器记录是否已经配置。 22行,开启LSE时钟,LSE时钟就是外部RTC晶振,32.768K。STM32也支持使用内部时钟。 24~33,等待LSE时钟稳定。 38~41,配置RTC时钟。 45~56,配置时间,分两部分,日期和时间。 58行,写BKP标志,只要RTC不断电,下次初始化就不会再初始化RTC了。

设置获取时间函数,这四个函数都是调用库函数而已。

s32 mcu_rtc_set_time(u8 hours, u8 minutes, u8 seconds)
s32 mcu_rtc_set_date(u8 year, u8 weekday, u8 month, u8 date)
s32 mcu_rtc_get_date(void)
s32 mcu_rtc_get_time(void)

测试

测试函数大概如下,在main函数中初始化RTC,在while(1)循环中按下按键后打印当前RTC时钟。

int main(void)
{
  GPIO_InitTypeDef GPIO_InitStructure;

	/* Configure the NVIC Preemption Priority Bits */
  NVIC_PriorityGroupConfig(NVIC_PriorityGroup_1);

  /* SysTick end of count event */
  RCC_GetClocksFreq(&RCC_Clocks);
  SysTick_Config(RCC_Clocks.HCLK_Frequency / (1000/SYSTEMTICK_PERIOD_MS));

  /* Add your application code here */
  /* Insert 5 ms delay */
  Delay(5);

	/* Infinite loop */
	mcu_uart_init();
	mcu_uart_open(PC_PORT);
	wjq_log(LOG_INFO, "hello word!\r\n");
	mcu_rtc_init();
	dev_key_init();

	dev_key_open();
	while (1)
	{
		/*驱动轮询*/
		dev_key_scan();
		eth_loop_task();

		/*应用*/
		u8 key;
		s32 res;
		res = dev_key_read(&key, 1);
		if(res == 1)
		{
			if(key == DEV_KEY_PRESS)
			{
				/*读时间*/
				mcu_rtc_get_date();
				mcu_rtc_get_time();
				/*设置时间*/
				//mcu_rtc_set_date(2018, 2, 4, 17);
				//mcu_rtc_set_time(2, 47, 0);
			}
			else if(key == DEV_KEY_REL)
			{

			}
		}
		Delay(1);

	}
}
  • 测试1

没有初始化过的RTC:新硬件,或者是旧硬件扣下纽扣电池,并等待电容放完电(可以短路RTC电源快速放电) 上电后调试信息会看到,程序进入RTC初始化。

hello word! init rtc init rtc finish bus_vspi_init VSPI1 vspi init finish! board_spiflash jid:0xc22016 board_spiflash mid:0xc215 core_spiflash jid:0xef4017 core_spiflash mid:0xef16
  • 测试2

经第一步后,直接按复位键复位系统,可见RTC没有进入初始化。

hello word! init rtc finish bus_vspi_init VSPI1 vspi init finish! board_spiflash jid:0xc22016 board_spiflash mid:0xc215 core_spiflash jid:0xef4017 core_spiflash mid:0xef16
  • 测试3

按下按键,查看系统时间,可见时间走走动。

1970, 1, 1, 4 0, 1, 34 1970, 1, 1, 4 0, 1, 40 1970, 1, 1, 4 0, 1, 42 1970, 1, 1, 4 0, 1, 47
  • 测试4

拔掉外电,只保留纽扣电池,放一段时间,再重新上电,可见RTC正常运行,并没有复位。

hello word! init rtc finish bus_vspi_init VSPI1 vspi init finish! board_spiflash jid:0xc22016 board_spiflash mid:0xc215 core_spiflash jid:0xef4017 core_spiflash mid:0xef16 1970, 1, 1, 4 0, 8, 41 1970, 1, 1, 4 0, 8, 42 1970, 1, 1, 4 0, 8, 43 1970, 1, 1, 4 0, 8, 44

总结

本次我们验证了RTC的硬件和基本功能。 其实RTC还有很丰富的功能,例如闹钟,大家可以自行实验。


end

内存管理

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


前面已经将所有的硬件驱动实现,验证了硬件功能。但是每一个硬件都是单独测试的,而且并不完善。下一步,我们需要对各个驱动进行整合完善。在整合之前,需要做一些基础工作。其中之一就是实现内存管理。 什么叫内存管理呢?为什么要做内存管理? 前面我们已经大概了解了程序中的变量 现在我们复习一下:局部变量全局变量

局部变量在进入函数时从栈空间分配,退出函数前释放。 全局变量则在整个程序运行其中一直使用。在程序编译时就已经分配了RAM空间。

那还有没有第三种变量呢?可以说没有。 但是如果从生存周期上看,是有的: 一个变量,在多个函数内使用,但是又不是整个程序运行期间都使用。 或: 一个变量,在一段时间内使用,不是整个程序运行生命周期都要用,但是用这个变量的函数会退出,然后重复进入 (用static定义的局部变量相当于全局变量)

如果不使用动态内存管理,这样的变量就只能定义为全局变量。 如果将这些变量定义为指针,当要使用时,通过内存管理分配,使用完后就释放,这就叫做动态分配。 举个实际的例子:

一个设备,有三种通信方式:串口,USB,网络,在通信过程每个通信方式需要1K RAM。 经过分析,3种通信方式不会同时使用。 那么,如果不使用动态内存,则需要3K变量。 如果使用内存管理动态分配,则只需要1K内存就可以了。 (这个只是举例,如果简单的系统,确定三种方式不同时使用,可以直接复用内存)

通信方式只是举例,其实一个系统中,并不是所有设备都一直使用,如果使用动态内存管理,RAM的峰值用量将会大大减少。

内存管理方案

不发明车轮,只优化轮胎。

内存管理是编程界的一个大话题,有很多经典的方案。很多人也在尝试写新的方案。 内存分配模块我们使用K&R C examples作为基础,然后进行优化。 K&R是谁?就是写《C程序设计语言》的两个家伙。如果你没有这本书,真遗憾。 这本书的8.7章节,<实例–存储分配程序>,介绍了一种基本的存储分配方法。 代码见alloc.c,整个代码只有120行,而且结构很美

K&R 内存管理方案分析

下面我们结合代码分析这种内存分配方案。 代码在wujique\Utilities\alloc文件夹。

内存分析

  • 初始化

在malloc函数中,如果是第一次调用就会初始化内存链表。 代码原来是通过获取堆地址,在堆上建立内存池。我们把他改为更直观的数组定义方式。 内存建立后的内存视图如下: _images/110.png内存初始化

内存分配的最小单元是:

typedef struct ALLOC_HDR
{
  struct
  {
    struct ALLOC_HDR *ptr;
    unsigned int size;/*本快内存容量*/
  } s;
  unsigned int align;
  unsigned int pad;
} ALLOC_HDR;

这也就是内存管理结构体。在32位ARM系统上,这个结构体是16字节。

  • 第一次分配

每次分配,就是在一块可以分配的空间尾部切割一块出来,切割的大小是16字节的倍数,而且会比需要的内存多一块头。 这块头在内存释放时需要使用。这一块,也就是内存管理的开销。 _images/28.png内存第一次分配

  • 分配释放后

经过多次分配释放后,内存可能如下图, 绿色是两块不连续的空闲块,黄色是分配出去的块。 分配出去的块,已经不在内存链表里面。 _images/38.png内存分配释放

缺点

一般情况上面的代码已经能满足需求。 但是,有以下缺陷:

  • 缺点1:容易碎片化

分配使用首次适应法,也即是找到一块大于等于要分配内存的空闲块,立刻进行分配。这种方法的优点是速度较快,缺点是容易内存碎片化,分配时将很多大块内存切割成小内存了。 经过多次分配后,很可能出现以下情况:

空闲内存总量还有10K,但是却被分散在10个块内,而且没有大容量的内存块,再申请2K内存就出现失败。 如果对时间并不是那么敏感,我们可以使用最适合法,也即是遍历空闲链表,查找一个最合适的内存(大于要分配内存且容量最小的空闲块),减少大内存被切碎的概率。 需要注意的是,最适合法,除了会增加分配时间,不会减少内存碎片数量,只是增加了空闲内存的集中度。 假设经过多次分配后,空闲总量还是10K,也是分散在10个空闲块,但是在这10个空闲块中,会有5K的大块,再申请2K的时候,就可以申请到2K内存了。
  • 缺点2:内存消耗

内存分配方案使用了一个结构体,每次分配的最小单位就是这个结构体的大小16字节。

typedef struct ALLOC_HDR
{
  struct
  {
    struct ALLOC_HDR *ptr;
    unsigned int size;/*本快内存容量*/
  } s;
  unsigned int align;
  unsigned int pad;
} ALLOC_HDR;

一次分配,最少就是2个结构体(一个结构体用于管理分配出去的内存,其余结构体做为申请内存),也就是32字节。 如果代码有大量小内存申请,例如申请100次8个字节

需求内存:100X8=800字节 实际消耗内存100X32 = 3200字节 利用率只有800/3200 =25%

如果内存分配只有25%的使用率,对于小内存嵌入式设备来说,是致命的方案缺陷。

如何解决呢? 我们可以参考LINUX内存分配方案SLAB。 在LINUX中,有很多模块需要申请固定大小的内存(例如node结构体),为了加快分配速度,系统会使用malloc先从大内存池中申请一批node结构体大小的内存,作为一个slab内存池。当需要分配node结构体时,就直接从slab内存池申请。 同理,可以将内存分配优化为: 需要小内存时,从大块内存池分配一块大内存,例如512,使用新算法管理,用于小内存分配。 当512消耗尽,再从大内存池申请第二块512字节大内存。 当小内存释放时,判断小块内存池是否为空,如为空,将小块内存池释放回大内存池。 那如何管理这个小内存池呢?

  • 缺点3:没有管理已分配内存

内存分配没有将已分配内存管理起来。 我们可以对已分配内存进行统一管理:

1 已分配内存在头部有原来的结构体,通过ptr指针,将所有已分配内存连接在已分配链表上。 2 利用不使用的align跟pad成员,记录分配时间跟分配对象(记录哪个驱动申请的内存)

通过上面优化后,就可以统计已经分配了多少内存,还有多少空闲内存,哪个模块申请了最多内存等数据。

使用

1 将代码中的所有free改为为wjq_free,malloc改为wjq_malloc。

串口缓冲用了free跟malloc. fatfs的syscall.c 用了 lwip的mem.h用了。

2 修改启动代码, 栈跟堆改小。不用库的malloc,堆可以完全不要。栈,还是要保留,但是不需要那么大,如果函数内用到比较大的局部变量,改为动态申请。

Stack_Size      EQU     0x00002000

                AREA    STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem       SPACE   Stack_Size
__initial_sp

; <h> Heap Configuration
;   <o>  Heap Size (in Bytes) <0x0-0xFFFFFFFF:8>
; </h>

Heap_Size       EQU     0x00000010

                AREA    HEAP, NOINIT, READWRITE, ALIGN=3
__heap_base
Heap_Mem        SPACE   Heap_Size
__heap_limit

3 内存池开了80K,编译不过

linking… .\Objects\wujique.axf: Error: L6406E: No space in execution regions with .ANY selector matching dev_touchscreen.o(.bss). .\Objects\wujique.axf: Error: L6406E: No space in execution regions with .ANY selector matching mcu_uart.o(.bss). .\Objects\wujique.axf: Error: L6406E: No space in execution regions with .ANY selector matching etharp.o(.bss). .\Objects\wujique.axf: Error: L6406E: No space in execution regions with .ANY selector matching mcu_can.o(.bss). .\Objects\wujique.axf: Error: L6406E: No space in execution regions with .ANY selector matching netconf.o(.bss).

先把内存池改小,编译通过之后,分析 map文件,用了较多全局变量的统统改小或者改为动态申请。 分析map文件,还可以检查还有没有使用库里面的malloc。

Code (inc. data) RO Data RW Data ZI Data Debug Object Name
   124         32          0          4      40976       1658   alloc.o
    16          0          0          0          0       2474   def.o
    96         34       8640          4          0       1377   dev_dacsound.o
   300         36          0          0          0       2751   dev_esp8266.o
   204         38          0          1          0       1446   dev_key.o
   436         98          0         10         16       3648   dev_touchkey.o
   310         18          0         14       3000       3444   dev_touchscreen.o
   932         18          0          4          0      15981   dhcp.o
     0          0          0          0       3964       5933   dual_func_demo.o
   280         14         12          0        200       5963   etharp.o
     0          0          0          0          0      35864   ethernetif.o
     0          0          0          0          0       3820   inet.o
    98          0          0          0          0       2022   inet_chksum.o
     0          0          0          0          0       4163   init.o
   168          4          0         20          0       4763   ip.o
     0          0          4          0          0       6463   ip_addr.o
   386          4          0          0          0       4118   ip_frag.o
   264         38          0          8         16     383399   main.o
    84          8          0          0          0       1410   mcu_adc.o
    60         32          0          1         68       1511   mcu_can.o
    12          0          0          0          0        521   mcu_dac.o
   128         14          0          0          0       2352   mcu_i2c.o
    28          8          0          1          0        630   mcu_i2s.o
   336         92          0          0          0       2689   mcu_rtc.o
   430         86          0          1          0       4396   mcu_timer.o
  1564         82          0          0        328       9072   mcu_uart.o
   504         20          0         12          0       4510   mem.o
    56         10          0          0       9463       3250   memp.o
   120         14          0          0          0       1651   misc.o
     0          0          0          0         56       1066   netconf.o
   118          0          0          0          0       4267   netif.o
   684          0          0          0          0       6971   pbuf.o
    36          8        392          0       8192        824   startup_stm32f40_41xxx.o

从上面数据可以看出以下源文件用了较多RAM。

alloc.o 内存池 dev_touchscreen.o 触摸屏缓冲 dual_func_demo.o USB,应该能优化 memp.o 什么鬼?又一个内存池?应该是要优化掉 startup_stm32f40_41xxx.o 启动代码,是栈跟堆用的RAM.

由于编译器的优化,项目没用到的代码没有编译进来,上面的map数据并不完整。 等后面我们做完全部测试程序,所有用到的代码都会参与连接,到时还需要优化一次。

总结

内存管理暂时到此,等后面所有功能都完成后,再进行一次优化。 如果对内存分配时间有更高要求,可使用伙伴内存分配法。 大家可以参考《都江堰操作系统与嵌入式系统设计》,这个文档里面的一些软件设计策略非常好。 http://www.djyos.com/?page_id=50

最新内存管理请查看屋脊雀在github上托管的代码


end

COG LCD & OLED LCD 调试记录

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


前面我们调试过彩屏,彩屏显示效果很好,但是价格高。 而且在一些低主频的单片机上带不动,如果换SPI的彩屏,240x320像素太大,刷屏慢。 在很多场合,人机交互界面并不需要那么复杂。简单的交互,只要一块128*64像素的单色屏就足够了。

LCD种类

液晶LCD有很多种类,很早之前,12864就是一种屏幕的通称。这种屏幕是绿色的,很大,非常笨重,通常在工业仪器上使用。 现在常用的液晶除了TFT,就是COGOLED

COG lcd

很多人可能不知道COG LCD是什么,我觉得跟现在开发板销售有关:大家都出大屏,玩酷炫界面。离实际应用太远。 单片机产品使用的LCD其实更常见的是COG LCD。

所谓的COG LCD:

COG是Chip On Glass的缩写,就是驱动芯片直接绑定在玻璃上,透明的。

实物如下图: _images/coglcd.jpgcog 显示效果 _images/pic113.jpgCOG显示效果 这种LCD通常像素不高,常用的有128X64,128X32。 一般只支持黑白显示,也有灰度屏。 接口通常是SPI,I2C。也有号称支持8位并口的,不过基本不会用。3根IO能解决的问题,没必要用8根吧? 常用的驱动IC:STR7565。

OLED lcd

买过开发板的应该基本用过。新技术,大家都感觉高档,手环智能手表等时尚产品多是用OLED。OLED目前屏幕较小,大一点的都很贵。 在控制上跟COG LCD类似,区别是两者的显示方式不一样。从程序角度看,最大的差别就是:OLED LCD不用控制背光。 实物如下图, _images/OLED.jpgoled 裸屏 _images/pic27.jpg裸屏

常见的是SPI和I2C接口。 常见驱动IC:SSD1615。

接口

硬件接口

COG屏跟OLED屏,内置控制器不同,但是基本上都支持I2C,SPI通信。 这次调试的模块使用SPI通信。 _images/pic38.jpg1 上图是COG LCD信号

1脚A0,选择命令或数据通信。 3脚SDA,相当于SPI的 mosi 4脚时钟 5脚片选 6脚复位信号 9脚背光

_images/pic47.jpg2 上图是OLED模块接口,跟COG接口是兼容的。 这两个LCD,可以接到我们核心板的外扩SPI接口上,使用SPI3控制器。 外扩接口信号如下图: _images/pic55.jpg外扩接口 LCD的1脚接到外扩接口的1脚即可。

控制

spi接口的LCD控制其实跟TFT LCD的控制逻辑相似。 SPI上的数据也分命令和显示数据,由A0脚的电平决定。 OLED和COG LCD的初始化,通常也是由模组厂提供。

驱动设计

硬件接口

COG跟OLED,也是LCD,那么功能应该跟TFT lcd是一致的。 驱动提供的接口也应该一致,都是下面的接口:

typedef struct  
{
	u16 id;

	s32 (*init)(void);
	s32 (*draw_point)(u16 x, u16 y, u16 color);
	s32 (*color_fill)(u16 sx,u16 ex,u16 sy,u16 ey, u16 color);
	s32 (*fill)(u16 sx,u16 ex,u16 sy,u16 ey,u16 *color);
	s32 (*onoff)(u8 sta);
	s32 (*prepare_display)(u16 sx, u16 ex, u16 sy, u16 ey);
	void (*set_dir)(u8 scan_dir);
	void (*backlight)(u8 sta);
}_lcd_drv;
如果某种LCD不支持某些接口,执行空操作即可。 例如OLED就没有背光控制。

层次

在调试TFT LCD的时候,在驱动中实现了一些显示函数,例如:

s32 dev_lcd_drawpoint(u16 x, u16 y, u16 color)
void put_string_center(int x, int y, char *s, unsigned colidx)
...

dev_lcd开头的函数,其实应该归类为GUI层(或者LCD panel层),而不是LCD 驱动层。 当时为了方便,暂时放在TFT LCD源码内。现在添加了COG跟OLED驱动,要将LCD中间层的分离出来。 函数层次应该如下: _images/pic64.jpg层次 因此我们将这些函数抽取出来,单独做一个dev_lcd.c的源码。

LCD PANNEL接口跟LCD驱动连通

这个框架其实非常简单: 1 在dev_lcd_init函数内选择要初始化的LCD,

/* 初始化OLED LCD */
		ret = drv_ssd1615_init();
		if(ret == 0)
		{
			LCD.drv = &OledLcdSSD1615rv;
			LCD.dir = W_LCD;
			LCD.height = 64;
			LCD.width = 128;
		}

2 初始化之后将对应LCD的驱动_lcd_drv指针赋值到LCD结构体,LCD结构体如下,第一个结构体成员就是驱动,另外四个是相关参数。

struct _strlcd_obj
{
	_lcd_drv *drv;

	u8  dir;	//横屏还是竖屏控制:0,竖屏;1,横屏。
	u8  scandir;//扫描方向
	u16 width;	//LCD 宽度
	u16 height;	//LCD 高度

};

3 当应用程序调用函数时,例如put_char,在函数内通过LCD结构体内的drv指针调用对应的LCD驱动函数。

COG/OLED驱动

STR7565跟SSD1615非常类似,两个驱动除了初始化外,其他驱动函数共用。

  • 硬件接口

我们定义一个LCD接口,叫做bus_seriallcd,主要基于SPI3控制器,还有命令数据选择脚、复位管脚,COG还有背光控制管脚。 这个接口,封装以下接口给LCD驱动使用:

void bus_seriallcd_IO_init(void)
s32 bus_seriallcd_bl(u8 sta)
s32 bus_seriallcd_init()
s32 bus_seriallcd_open()
s32 bus_seriallcd_close()
s32 bus_seriallcd_write_data(u8 *data, u16 len)
s32 bus_seriallcd_write_cmd(u8 cmd)
  • 显存

COG 跟 OLED每个点的显示数据只用一个BIT表示。 _images/pic74.jpg 如上图的说明,str7565芯片内部显存65*132bit,我们的64*128液晶,并没有全部用完。 不同的液晶使用的显存不一样,要根据实际情况做偏移,我们的液晶从(0.0)开始,因此不需要做偏移。 显示一个点用一个位,但是我们操作一次是写一个字节,为了操作方便,我们开辟一片显示缓存,用于记录当前LCD显示内容。

struct _cog_drv_data
{
	u8 gram[8][128];
};

修改显示内容时先将数据组织到显存,再通过刷屏函数更新到LCD。 一个像素点占显存的一个BIT,但是SPI一次传输的是1个字节8个BIT。 刷屏函数如下:

/**
 *@brief:      drv_ST7565_refresh_gram
 *@details:       刷新指定区域到屏幕上
                  坐标是横屏模式坐标
 *@param[in]   u16 sc  
               u16 ec  
               u16 sp  
               u16 ep  
 *@param[out]  无
 *@retval:     static
 */
static s32 drv_ST7565_refresh_gram(u16 sc, u16 ec, u16 sp, u16 ep)
{
	struct _cog_drv_data *gram;
	u8 i;

	//uart_printf("drv_ST7565_refresh:%d,%d,%d,%d\r\n", sc,ec,sp,ep);
	gram = (struct _cog_drv_data *)&LcdGram;

	bus_seriallcd_open();
        for(i=sp/8; i <= ep/8; i++)
        {
        	bus_seriallcd_write_cmd (0xb0+i);    //设置页地址(0~7)
        	bus_seriallcd_write_cmd (((sc>>4)&0x0f)+0x10);      //设置显示位置—列高地址
        	bus_seriallcd_write_cmd (sc&0x0f);      //设置显示位置—列低地址

        	bus_seriallcd_write_data(&(gram->gram[i][sc]), ec-sc+1);

	}
	bus_seriallcd_close();

	return 0;
}
  • 测试

现在程序不能支持多个lcd同时工作,需要在初始化函数中选择使用哪个LCD。

#if 1
	if(ret != 0)
	{
		/* 初始化COG 12864 LCD */
		ret = drv_ST7565_init();
		if(ret == 0)
		{
			LCD.drv = &CogLcdST7565Drv;
			LCD.dir = W_LCD;
			LCD.height = 64;
			LCD.width = 128;
		}
	}
	#else
	if(ret != 0)
	{
		/* 初始化OLED LCD */
		ret = drv_ssd1615_init();
		if(ret == 0)
		{
			LCD.drv = &OledLcdSSD1615rv;
			LCD.dir = W_LCD;
			LCD.height = 64;
			LCD.width = 128;
		}
	}
	#endif

测试程序也和彩屏不一样,

put_string_center (20, 32,
			   "ADCD WUJIQUE !", BLACK);
		Delay(1000);
		LCD.drv->color_fill(0,LCD.width,0,LCD.height,WHITE);
		Delay(1000);
		LCD.drv->color_fill(0,LCD.width,0,LCD.height,BLACK);
		Delay(1000);
		LCD.drv->color_fill(0,LCD.width,0,LCD.height,WHITE);
		Delay(1000);

更多驱动细节请查看代码dev_str7565.c

总结

请看下节,用VSPI控制LCD。

实际项目最好不要使用本节例程的驱动,请从github上获取最新的驱动代码 本节只是LCD驱动架构演进的中间过程。


end

VSPI控制COG LCD & I2C控制OLED

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


上一节我们已经点亮了COG LCD跟OLED LCD,用的是外扩SPI。 在核心板上的外扩接口中,除了硬件SPI外,还有多个IO口,可以用来模拟SPI。 还有一个I2C,正好可以用来控制I2C接口的OLED LCD。 就让我们来完善我们的LCD驱动,让它支持更多方式吧。

驱动

驱动上一节已经实现,不需要修改。

底层接口

上一节我们已经定义了一些函数,用于LCD驱动操作硬件。 如下:

void bus_seriallcd_IO_init(void)
s32 bus_seriallcd_bl(u8 sta)
s32 bus_seriallcd_init()
s32 bus_seriallcd_open()
s32 bus_seriallcd_close()
s32 bus_seriallcd_write_data(u8 *data, u16 len)
s32 bus_seriallcd_write_cmd(u8 cmd)

其中bus_seriallcd_IO_init内部接口。 现在我们要添加模拟SPI跟I2C。因此将这个串行LCD接口抽象一个结构体,

typedef struct  
{
	char * name;

	s32 (*init)(void);
	s32 (*open)(void);
	s32 (*close)(void);
	s32 (*writedata)(u8 *data, u16 len);
	s32 (*writecmd)(u8 cmd);
	s32 (*bl)(u8 sta);
}_lcd_bus;

也即是说,一个LCD接口,只需要提供这些功能即可。 然后定义三个LCD接口,并且实现他们的功能函数,以下分别是I2C、VSPI、SPI。 函数具体实现请看代码。

_lcd_bus BusSerialLcdVI2C={
		.name = "BusSerivaLcdVI2C",
		.init =bus_seriallcd_vi2c_init,
		.open =bus_seriallcd_vi2c_open,
		.close =bus_seriallcd_vi2c_close,
		.writedata =bus_seriallcd_vi2c_write_data,
		.writecmd =bus_seriallcd_vi2c_write_cmd,
		.bl =bus_seriallcd_vi2c_bl,				
};

_lcd_bus BusSerialLcdVSpi={
		.name = "BusSerivaLcdVSpi",
		.init =bus_seriallcd_vspi_init,
		.open =bus_seriallcd_vspi_open,
		.close =bus_seriallcd_vspi_close,
		.writedata =bus_seriallcd_vspi_write_data,
		.writecmd =bus_seriallcd_vspi_write_cmd,
		.bl =bus_seriallcd_vspi_bl,				
};

_lcd_bus BusSerialLcdSpi={
		.name = "BusSerivaLcdSpi",
		.init =bus_seriallcd_spi_init,
		.open =bus_seriallcd_spi_open,
		.close =bus_seriallcd_spi_close,
		.writedata =bus_seriallcd_spi_write_data,
		.writecmd =bus_seriallcd_spi_write_cmd,
		.bl =bus_seriallcd_spi_bl,				
};

那我们用哪个接口呢? 定义一个接口指针,要用哪个接口就赋值对应结构体。

_lcd_bus *LcdBusDrv = &BusSerialLcdVI2C;

并且修改驱动,原来直接调用函数的地方改为变量指针,例如:

static s32 drv_ST7565_refresh_gram(u16 sc, u16 ec, u16 sp, u16 ep)
{
	struct _cog_drv_data *gram;
	u8 i;

	//uart_printf("drv_ST7565_refresh:%d,%d,%d,%d\r\n", sc,ec,sp,ep);
	gram = (struct _cog_drv_data *)&LcdGram;

	LcdBusDrv->open();
    for(i=sp/8; i <= ep/8; i++)
    {
        LcdBusDrv->writecmd (0xb0+i);    //设置页地址(0~7)
        LcdBusDrv->writecmd (((sc>>4)&0x0f)+0x10);      //设置显示位置—列高地址
        LcdBusDrv->writecmd (sc&0x0f);      //设置显示位置—列低地址

         LcdBusDrv->writedata(&(gram->gram[i][sc]), ec-sc+1);

	}
	LcdBusDrv->close();

	return 0;
}

模拟SPI

在触摸芯片章节我们已经实现了一个模拟SPI。 现在多增加一个。


#define VSPI2_CS_PORT GPIOF
#define VSPI2_CS_PIN GPIO_Pin_12

#define VSPI2_CLK_PORT GPIOF
#define VSPI2_CLK_PIN GPIO_Pin_11

#define VSPI2_MOSI_PORT GPIOF
#define VSPI2_MOSI_PIN GPIO_Pin_10

#define VSPI2_MISO_PORT GPIOF
#define VSPI2_MISO_PIN GPIO_Pin_9

#define VSPI2_RCC RCC_AHB1Periph_GPIOF

DevVspiIO DevVspi2IO={
		"VSPI2",
		DEV_VSPI_2,
		-2,//未初始化

		VSPI2_RCC,
		VSPI2_CLK_PORT,
		VSPI2_CLK_PIN,

		VSPI2_RCC,
		VSPI2_MOSI_PORT,
		VSPI2_MOSI_PIN,

		VSPI2_RCC,
		VSPI2_MISO_PORT,
		VSPI2_MISO_PIN,

		VSPI2_RCC,
		VSPI2_CS_PORT,
		VSPI2_CS_PIN,
	};

I2C

I2C就用前面调好的,不需要修改。

层次拆分

到此,我们实现了功能,但是,你有没有感觉,LCD硬件接口LCD驱动,是两层意思? 也就是说驱动分四层: LCD中间层——LCD驱动层——LCD硬件接口——对应的通信接口。 对应: LCD显示 —— SSD1565驱动——SPI LCD接口——SPI驱动

总结

由于我们程序良好的架构设计,仅仅修改了LCD硬件接口层的处理方式,就将原来使用SPI接口的LCD驱动,改成VSPI接口和I2C接口。 而且,LCD驱动可以说基本上没修改。

  1. LCD驱动已经能支持我们的外扩3种接口。 请问: TFT 8080接口是否能用这种封装?
  2. 现在的代码,同时只能支持一个硬件设备。具体用什么设备,在dev_lcd_init中选,用什么总线,通过LcdBusDrv指针选。 如果需要同时使用,怎么办?比如8080,VSPI,I2C,SPI,四个接口全部接上LCD。

请看下一节。

本节例程代码,只是给大家参考,如果设计项目使用,建议用屋脊雀在github上的最新代码架构


end

LCD驱动应该怎么写?

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


网络上配套STM32开发板有很多LCD例程,主要是TFT LCD跟OLED的。 从这些例程,大家都能学会如何点亮一个LCD。 但是不知道有多少人会直接使用这些代码,至少我不用,不是不用,而是用不了。 因为这些代码都有下面这些问题:

1 分层不清晰,通俗讲就是模块化太差。 2 接口乱。其实只要接口不乱,分层就会好很多了。 3 可移植性差。 4 通用性差。

为什么这样说呢?如果你已经了解了LCD的操作,请思考如下情景:

1 代码空间不够,只能保留9341的驱动,其他LCD驱动全部删除。能一键(一个宏定义)删除吗?删除后要改多少地方才能编译通过? 2 有一个新产品,收银设备。系统有两个LCD,一个叫做主显示,收银员用;一个叫顾显,顾客看金额。怎么办?这些例程代码要怎么改才能支持两个屏幕?复制一套然后改函数名称?这样确实能完成任务,只不过程序从此就进入恶性循环了。文艺点说,就是程序变得不美了。 3 一个OLED,原来接在SPI,后来改到I2C,容易改吗? 4 原来只是支持中文,现在要卖到南美,要支持多米尼加语言,好改吗?

大家慢慢想。

LCD种类概述

在讨论怎么写LCD驱动之前,我们先大概了解一下嵌入式常用LCD。 只是概述一些跟驱动架构设计有关的概念。至于原理跟细节,在此不做深入讨论,会有专门文章介绍,或者参考网络文档。

TFT lcd

TFT LCD,也就是我们常说的彩屏。 通常像素较高,例如常见的2.8寸,320X240像素。4.0寸的,像素800X400。 这些屏通常使用并口,也就是8080或6800接口(STM32 的FSMC接口); 或者是RGB接口,STM32F429等部分较贵的芯片支持。 其他例如手机上使用的有MIPI接口。 也有一些支持SPI接口的,不过除非是比较小的屏幕,否则不建议使用SPI接口,速度慢,刷屏闪屏。 玩STM32常用的TFT lcd屏幕驱动IC通常有:ILI9341/ILI9325等。 2.8寸 tft lcd _images/pic114.jpg2.8 4寸 IPS _images/pic28.jpg3.0

COG lcd

很多人可能不知道COG LCD是什么,我觉得跟现在开发板销售方向有关系,大家都出大屏,玩酷炫界面。 使用单片机的产品,COG LCD其实占比非常大。 所谓的COG LCD,

COG是Chip On Glass的缩写,就是驱动芯片直接绑定在玻璃上,透明的。

实物像下图: _images/pic39.jpgcog 这种LCD通常像素不高,常用的有128X64,128X32。 一般只支持黑白显示,也有灰度屏,我没怎么用过。 接口通常是SPI,I2C。也有号称支持8位并口的,不过基本不会用,3根IO能解决的问题,没必要用8根吧? 常用的驱动IC:STR7565。

OLED lcd

买过开发板的应该基本用过。新技术,大家都感觉高档,在手环等产品常用。OLED目前屏幕较小,大一点的都很贵。 在控制上跟COG LCD类似,区别是两者的显示方式不一样。从我们程序角度来看,最大的差别就是,OLED LCD,不用控制背光。。。。。 实物如下图, _images/pic48.jpgoled 常见的是SPI跟I2C接口。 常见驱动IC:SSD1615。

硬件场景

接下来的讨论,都基于以下硬件信息: 1 有一个TFT屏幕,接在FSMC接口,什么型号屏幕?不知道。 2 有一个COG LCD,接在几根IO口上,驱动IC是STR7565,128X32像素。 3 有一个OLED LCD,接在硬件SPI3和几根IO口上,驱动IC是SSD1315,128x64像素。 4 有一个OLED LCD,接在I2C接口上,驱动IC是SSD1315,128x64像素 _images/pic56.jpg场景

预备知识

在进入讨论之前,我们先大概说一下下面几个概念,对于这些概念,如果你想深入了解,请GOOGLE。

面向对象

面向对象,是编程界的一个概念,常在C++中出现。 什么叫面向对象呢? 编程有两种要素:程序(方法),数据(属性)。 例如:一个LED,我们可以点亮或者熄灭它,这叫方法。 LED什么状态?亮还是灭?这就是属性。 我们通常这样编程:

u8 ledsta = 0;

void ledset(u8 sta)
{

}

这样的编程有一个问题,假如我们有10个这样的LED,怎么写? 最简单粗暴的方法就是用10个函数。不过只要是做过一点程序的人都会觉得这个方法太蠢。 至少,大家都会在函数增加一个参数,然后在函数里面用if-else或者switch语句处理不同LED。 其实,更高级一点的是用面向对象,将方法和属性分开。 我们可将每一个LED封装为一个对象。可以这样做:


/*
定义一个结构体,将LED这个对象的属性跟方法封装。
这个结构体就是一个对象。
但是这个不是一个真实的存在,而是一个对象的抽象。
*/
typedef struct
{
	u8 sta;
	void (*setsta)(u8 sta);
}LedObj;

/*
	声明一个LED对象,名称叫做LED1,
	并且实现它的方法drv_led1_setsta
*/
void drv_led1_setsta(u8 sta)
{

}

LedObj LED1={
		.sta = 0,
		.setsta = drv_led1_setsta,
	};

/*
	声明一个LED对象,名称叫做LED2,
	并且实现它的方法drv_led2_setsta
*/
void drv_led2_setsta(u8 sta)
{

}

LedObj LED2={
		.sta = 0,
		.setsta = drv_led2_setsta,
	};

/*
	操作LED的函数,参数指定哪个led
*/
void ledset(LedObj *led, u8 sta)
{
	led->setsta(sta);
}
抛砖引玉,很多地方不正确,但是不想展开,大家自己搜索资料学习。

是的,在C语言中,实现面向对象的手段就是结构体的使用。 上面的代码,对于API来说,就很友好了。 操作所有LED,使用同一个接口,只需告诉接口哪个LED。 大家想想,前面说的LCD硬件场景。 4个LCD,如果不面向对象,显示汉字的接口是不是要实现4个?每个屏幕一个?

驱动与设备分离

如果要深入了解驱动与设备分离,请看LINUX驱动的书籍。

什么是设备? 设备就是属性,就是参数,就是驱动程序要用到的数据和硬件接口。 那么驱动就是控制这些数据和接口的代码过程。 通常来说,如果LCD的驱动IC相同,就用相同的驱动。有些不同的IC也可以用相同的,例如SSD1315跟STR7565,除了初始化,其他都可以用相同的驱动。 例如一个COG lcd:

驱动IC是STR7565 128*64像素 用SPI3 背光用PF5 命令线用PF4 复位脚用PF3

上面所有的信息综合,就是一个设备。 驱动就是STR7565的驱动。

为什么要驱动跟设备分离,因为要解决下面问题:

有一个新产品,收银设备,系统有两个LCD,一个叫做主显示,收银员用,一个叫顾显,顾客看金额使用。怎么办?这些例程代码要怎么改才能支持两个屏幕?复制一套然后改函数名称?这样确实能完成任务,只不过程序从此就进入恶性循环了。

这个问题,两个设备用同一套程序控制才是最好的解决办法。

驱动与设备分离的手段:

在驱动程序接口中增加设备参数,驱动用到的所有资源从设备参数传入。

驱动如何跟设备绑定呢?通过设备的驱动IC型号。

模块化

模块化就是将一段程序封装,提供一套相同接口,给不同的驱动使用。 不模块化就是,在不同的驱动中都实现这段程序。 例如字库处理,在显示汉字的时候,我们要找点阵,在打印机打印汉字的时候,我们也要找点阵,你觉得程序要怎么写? 把点阵处理做成一个模块,就是模块化。

非模块化的典型特征就是一根线串到底,没有任何层次感。

LCD到底是什么

前面我们说了面向对象,想要对LCD进行抽象,得出一个对象,就需要知道LCD到底是什么。 我们问自己下面几个问题: 1 LCD能做什么? 2 要LCD做什么? 3 谁想要LCD做什么?

刚刚接触嵌入式的朋友可能不是很了解,可能会想不通。我们模拟一下LCD的功能操作数据流。 APP想要显示一个汉字。

1 首先,需要一个显示汉字的接口,APP调用这个接口就可以显示汉字了。假设接口叫做lcd_display_hz。 2 汉字从哪来?从点阵字库来,所以在lcd_display_hz函数内就要调用一个叫做find_font的函数获取点阵。 3 获取点阵后要将点阵显示到LCD上,那么我们调用一个ILL9341_dis的接口,将点阵刷新到驱动IC型号为ILI9341的LCD上。 4 ILI9341_dis怎么将点阵显示上去?调用一个8080_WRITE的接口。

好的,这个就是大概过程,我们从这个过程去抽象LCD功能接口。 汉字跟LCD对象有关吗?无关。在LCD眼里,无论汉字还是图片,都是一个个点。 那么前面问题的答案就是:

1 LCD可以一个点一个点显示内容。 2 要LCD显示汉字或图片—–转化后就是显示一堆点 3 APP想要LCD显示图片或文字。

结论就是: 所有LCD对象的功能就是显示点。 那么驱动只要提供显示点的接口就可以了,显示一个点,显示一片点。 抽象接口如下:

/*
	LCD驱动定义
*/
typedef struct  
{
	u16 id;

	s32 (*init)(DevLcd *lcd);
	s32 (*draw_point)(DevLcd *lcd, u16 x, u16 y, u16 color);
	s32 (*color_fill)(DevLcd *lcd, u16 sx,u16 ex,u16 sy,u16 ey, u16 color);
	s32 (*fill)(DevLcd *lcd, u16 sx,u16 ex,u16 sy,u16 ey,u16 *color);
	s32 (*onoff)(DevLcd *lcd, u8 sta);
	s32 (*prepare_display)(DevLcd *lcd, u16 sx, u16 ex, u16 sy, u16 ey);
	void (*set_dir)(DevLcd *lcd, u8 scan_dir);
	void (*backlight)(DevLcd *lcd, u8 sta);
}_lcd_drv;

上面的接口,也就是对应的驱动,包含了一个驱动id号。

1 id,驱动型号 2 初始化 3 画点 4 将一片区域的点显示某种颜色 5 将一片区域的点显示某些颜色 6 显示开关 7 准备刷新区域(主要彩屏直接DMA刷屏使用) 8 设置扫描方向 9 背光控制

LCD驱动框架

我们设计了如下的驱动框架 _images/pic65.jpg驱动框架 从上到下分别是:

  1. GUI层:如果不使用GUI,普通的划线,画圆等,也算GUI。
  2. LCD驱动层:主要是封装下一层驱动IC层的接口,以便GUI层用一套接口操作多种LCD。
  3. 驱动IC驱动层,实现不同的LCD控制,对上提供同样的接口(前面说的_lcd_drv结构体)
  4. 对不同的硬件接口封装,以便一种驱动使用多种接口,例如SSD1315驱动可以用I2C,也可以用SPI。
  5. 接口层,例如SPI驱动,其实不算LCD功能范畴。

代码分析

代码分四层:

  1. GUI和LCD驱动层,代码在下面两个文件 dev_lcd.c dev_lcd.h
  2. 显示驱动IC层 dev_str7565.c & dev_str7565.h dev_ILI9341.c & dev_ILI9341.h
  3. LCD接口封装层 dev_lcdbus.c&dev_lcdbus.h
  4. 接口层 mcu_spi.c & mcu_spi.h mcu_i2c.c & mcu_i2c.h stm324xg_eval_fsmc_sram.c & stm324xg_eval_fsmc_sram.h

GUI和LCD层

这层主要有3个功能 1 设备管理 首先定义了一堆LCD参数结构体,结构体包含ID,像素。 并且把这些结构体组合到一个list数组内。

/*
	各种LCD的规格参数
*/
_lcd_pra LCD_IIL9341 ={
		.id	  = 0x9341,
		.width = 240,	//LCD 宽度
		.height = 320,	//LCD 高度
};
...
/*各种LCD列表*/
_lcd_pra *LcdPraList[5]=
			{
				&LCD_IIL9341,		
				&LCD_IIL9325,
				&LCD_R61408,
				&LCD_Cog12864,
				&LCD_Oled12864,
			};

然后定义了所有驱动list数组,数组内容就是驱动,在对应的驱动文件内实现。

/*
	所有驱动列表
	驱动列表
*/
_lcd_drv *LcdDrvList[] = {
					&TftLcdILI9341Drv,
					&TftLcdILI9325Drv,
					&CogLcdST7565Drv,
					&OledLcdSSD1615rv,

定义了设备树,即是定义了系统有多少个LCD,接在哪个接口,什么驱动IC。 如果是一个完整系统,可以做成一个类似LINUX的设备树。

/*
	设备树定义
	指明系统有多少个LCD设备,挂在哪个LCD总线上。
*/
#define DEV_LCD_C 4//系统存在3个LCD设备
LcdObj LcdObjList[DEV_LCD_C]=
{
	{"i2coledlcd",  LCD_BUS_I2C,  0X1315},
	{"vspioledlcd", LCD_BUS_VSPI, 0X1315},
	{"spicoglcd",   LCD_BUS_SPI,  0X7565},
	{"tftlcd",      LCD_BUS_8080, NULL},
};

2 接口封装

void dev_lcd_setdir(DevLcd *obj, u8 dir, u8 scan_dir)
s32 dev_lcd_init(void)
DevLcd *dev_lcd_open(char *name)
s32 dev_lcd_close(DevLcd *dev)
s32 dev_lcd_drawpoint(DevLcd *lcd, u16 x, u16 y, u16 color)
s32 dev_lcd_prepare_display(DevLcd *lcd, u16 sx, u16 ex, u16 sy, u16 ey)
s32 dev_lcd_display_onoff(DevLcd *lcd, u8 sta)
s32 dev_lcd_fill(DevLcd *lcd, u16 sx,u16 ex,u16 sy,u16 ey,u16 *color)
s32 dev_lcd_color_fill(DevLcd *lcd, u16 sx,u16 ex,u16 sy,u16 ey,u16 color)
s32 dev_lcd_backlight(DevLcd *lcd, u8 sta)

大部分接口都是对驱动IC接口的二次封装。有区别的是初始化和打开接口。 初始化,就是根据前面定义的设备树,寻找对应驱动,找到对应设备参数,并完成设备初始化。 打开函数,根据传入的设备名称,查找设备,找到后返回设备句柄,后续的操作全部需要这个设备句柄。

3 简易GUI层 主要是一些简单的显示字符函数。

s32 dev_lcd_put_string(DevLcd *lcd, FontType font, int x, int y, char *s, unsigned colidx)

其他划线画圆的函数目前只是测试,后续会完善。

驱动IC层

驱动IC层主要完成不同的显示IC驱动,也就是实现下面这个结构体内的所有函数,例如STR7565驱动:

_lcd_drv CogLcdST7565Drv = {
	.id = 0X7565,

	.init = drv_ST7565_init,
	.draw_point = drv_ST7565_drawpoint,
	.color_fill = drv_ST7565_color_fill,
	.fill = drv_ST7565_fill,
	.onoff = drv_ST7565_display_onoff,
	.prepare_display = drv_ST7565_prepare_display,
	.set_dir = drv_ST7565_scan_dir,
	.backlight = drv_ST7565_lcd_bl
	};

接口封装层

为了上一层驱动IC层能用于不同的接口,需要对接口进行统一抽象封装。 抽象接口如下:

/*
	LCD接口定义
*/
typedef struct  
{
	char * name;

	s32 (*init)(void);
	s32 (*open)(void);
	s32 (*close)(void);
	s32 (*writedata)(u8 *data, u16 len);
	s32 (*writecmd)(u8 cmd);
	s32 (*bl)(u8 sta);
}_lcd_bus;

接口包含:初始化,打开,关闭,写数据,写命令,背光控制。 当前没有将8080封装,接口抽象没有读数据

实现3个接口封装:SPI、VSPI、I2C:

_lcd_bus BusSerialLcdSpi={
		.name = "BusSerivaLcdSpi",
		.init =bus_seriallcd_spi_init,
		.open =bus_seriallcd_spi_open,
		.close =bus_seriallcd_spi_close,
		.writedata =bus_seriallcd_spi_write_data,
		.writecmd =bus_seriallcd_spi_write_cmd,
		.bl =bus_seriallcd_spi_bl,				
};

_lcd_bus BusSerialLcdVSpi={
		.name = "BusSerivaLcdVSpi",
		.init =bus_seriallcd_vspi_init,
		.open =bus_seriallcd_vspi_open,
		.close =bus_seriallcd_vspi_close,
		.writedata =bus_seriallcd_vspi_write_data,
		.writecmd =bus_seriallcd_vspi_write_cmd,
		.bl =bus_seriallcd_vspi_bl,				
};

_lcd_bus BusSerialLcdVI2C={
		.name = "BusSerivaLcdVI2C",
		.init =bus_seriallcd_vi2c_init,
		.open =bus_seriallcd_vi2c_open,
		.close =bus_seriallcd_vi2c_close,
		.writedata =bus_seriallcd_vi2c_write_data,
		.writecmd =bus_seriallcd_vi2c_write_cmd,
		.bl =bus_seriallcd_vi2c_bl,				
};

接口层

8080层比较简单,用的是官方接口。 SPI、VSPI、I2C接口参考其他章节。

总体流程

前面说的几个模块时如何联系在一起的呢? 请看下面结构体:

/*
	初始化的时候会根据设备数定义,
	并且匹配驱动跟参数,并初始化变量。
	打开的时候只是获取了一个指针
*/
struct _strDevLcd
{
	s32 gd;//句柄,控制是否可以打开

	LcdObj	 *dev;
	/* LCD参数,固定,不可变*/
	_lcd_pra *pra;

	/* LCD驱动 */
	_lcd_drv *drv;

	/*驱动需要的变量*/
	u8  dir;	//横屏还是竖屏控制:0,竖屏;1,横屏。
	u8  scandir;//扫描方向
	u16 width;	//LCD 宽度
	u16 height;	//LCD 高度

	void *pri;//私有数据,黑白屏跟OLED屏在初始化的时候会开辟显存
};

每一个设备都会有一个这样的机构体,这个结构体在初始化LCD时初始化。

  • 成员dev指向设备树,从这个成员可以知道设备名称,挂在哪个LCD总线,设备ID。
typedef struct
{
	char *name;//设备名字
	LcdBusType bus;//挂在那条LCD总线上
	u16 id;
}LcdObj;

其中LcdBusType定义如下:

/*
系统总共有三种LCD总线
*/
typedef enum{
	LCD_BUS_NULL = 0,
	LCD_BUS_SPI,
	LCD_BUS_VSPI,
	LCD_BUS_I2C,//OLED使用,只要两根线,背光也不需要控制,复位也不需要
	LCD_BUS_8080,
	LCD_BUS_MAX
}LcdBusType;

-成员pra指向LCD参数,可以知道LCD的规格。

typedef struct
{
	u16 id;
	u16 width;	//LCD 宽度  竖屏
	u16 height;	//LCD 高度    竖屏
}_lcd_pra;

-成员drv指向驱动,所有操作通过drv实现。

typedef struct  
{
	u16 id;

	s32 (*init)(DevLcd *lcd);

	s32 (*draw_point)(DevLcd *lcd, u16 x, u16 y, u16 color);
	s32 (*color_fill)(DevLcd *lcd, u16 sx,u16 ex,u16 sy,u16 ey, u16 color);
	s32 (*fill)(DevLcd *lcd, u16 sx,u16 ex,u16 sy,u16 ey,u16 *color);

	s32 (*prepare_display)(DevLcd *lcd, u16 sx, u16 ex, u16 sy, u16 ey);

	s32 (*onoff)(DevLcd *lcd, u8 sta);
	void (*set_dir)(DevLcd *lcd, u8 scan_dir);
	void (*backlight)(DevLcd *lcd, u8 sta);
}_lcd_drv;
  • 成员dir、scandir、 width、 height是驱动要使用的通用变量。因为每个LCD都有一个结构体,一套驱动程序就能控制多个设备而互不干扰。
  • 成员pri是一个私有指针,某些驱动可能需要有些比较特殊的变量,就全部用这个指针记录,通常这个指针指向一个结构体,结构体由驱动定义,并且在设备初始化时申请变量空间。 目前主要用于COG LCD跟OLED LCD显示缓存。

整个LCD驱动,就通过这个结构体组合在一起。 具体如下: 1 初始化,根据设备树LcdObjList,找到驱动跟参数,然后初始化结构体DevLcdList。 2 要使用LCD前,调用dev_lcd_open函数。打开成功就返回一个DevLcd结构体指针。 3 调用dev_lcd_drawpoint函数显示一个点

s32 dev_lcd_drawpoint(DevLcd *lcd, u16 x, u16 y, u16 color)
{
	if(lcd == NULL)
		return -1;

	return lcd->drv->draw_point(lcd, x-1, y-1, color);
}

第一个参数Lcd就是打开LCD是得到的指针,函数通过指针调用对应驱动的draw_point函数。 加入是str7565驱动,就是执行下面函数

static s32 drv_ST7565_drawpoint(DevLcd *lcd, u16 x, u16 y, u16 color)

在这个函数前面有两行代码,这两行代码的作用是根据传入的总线类型,找到对应LCD总线的操作函数。

	_lcd_bus *LcdBusDrv;
	LcdBusDrv = dev_lcdbus_find(lcd->dev->bus);

加入lcd->dev->bus是LCD_BUS_VSPI,LcdBusDrv将指向BusSerialLcdSpi, 那么最后的几句代码,就是执行BusSerialLcdSpi内的函数。

/*效率不高*/
	LcdBusDrv->open();
    LcdBusDrv->writecmd (0xb0 + page );   
    LcdBusDrv->writecmd (((colum>>4)&0x0f)+0x10);
    LcdBusDrv->writecmd (colum&0x0f);    
    LcdBusDrv->writedata( &(gram->gram[page][colum]), 1);
	LcdBusDrv->close();

BusSerialLcdSpi内的函数将直接操作对应SPI的接口操作硬件。

用法和好处

  • 好处1

请看测试程序

void dev_lcd_test(void)
{
	DevLcd *LcdCog;
	DevLcd *LcdOled;
	DevLcd *LcdTft;

	/*  打开三个设备 */
	LcdCog = dev_lcd_open("coglcd");
	if(LcdCog==NULL)
		uart_printf("open cog lcd err\r\n");

	LcdOled = dev_lcd_open("oledlcd");
	if(LcdOled==NULL)
		uart_printf("open oled lcd err\r\n");

	LcdTft = dev_lcd_open("tftlcd");
	if(LcdTft==NULL)
		uart_printf("open tft lcd err\r\n");

	/*打开背光*/
	dev_lcd_backlight(LcdCog, 1);
	dev_lcd_backlight(LcdOled, 1);
	dev_lcd_backlight(LcdTft, 1);

	dev_lcd_put_string(LcdOled, FONT_SONGTI_1212, 10,1, "ABC-abc,", BLACK);
	dev_lcd_put_string(LcdOled, FONT_SIYUAN_1616, 1, 13, "这是oled lcd", BLACK);
	dev_lcd_put_string(LcdOled, FONT_SONGTI_1212, 10,30, "www.wujique.com", BLACK);
	dev_lcd_put_string(LcdOled, FONT_SIYUAN_1616, 1, 47, "屋脊雀工作室", BLACK);

	dev_lcd_put_string(LcdCog, FONT_SONGTI_1212, 10,1, "ABC-abc,", BLACK);
	dev_lcd_put_string(LcdCog, FONT_SIYUAN_1616, 1, 13, "这是cog lcd", BLACK);
	dev_lcd_put_string(LcdCog, FONT_SONGTI_1212, 10,30, "www.wujique.com", BLACK);
	dev_lcd_put_string(LcdCog, FONT_SIYUAN_1616, 1, 47, "屋脊雀工作室", BLACK);

	dev_lcd_put_string(LcdTft, FONT_SONGTI_1212, 20,30, "ABC-abc,", RED);
	dev_lcd_put_string(LcdTft, FONT_SIYUAN_1616, 20,60, "这是tft lcd", RED);
	dev_lcd_put_string(LcdTft, FONT_SONGTI_1212, 20,100, "www.wujique.com", RED);
	dev_lcd_put_string(LcdTft, FONT_SIYUAN_1616, 20,150, "屋脊雀工作室", RED);

	while(1);
}

使用一个函数dev_lcd_open,可以打开3个LCD,获取LCD设备。 然后调用dev_lcd_put_string就可以在不同的LCD上显示。 其他所有的gui操作接口都只有一个。 这样的设计对于APP层来说,就很友好。 显示效果 _images/pic75.jpg显示效果

  • 好处2

现在的设备树是这样定义的

LcdObj LcdObjList[DEV_LCD_C]=
{
	{"oledlcd", LCD_BUS_VSPI, 0X1315},
	{"coglcd", LCD_BUS_SPI,  0X7565},
	{"tftlcd", LCD_BUS_8080, NULL},
};

某天,oled lcd要接到SPI上,只需要将设备树数组里面的参数改一下,就可以了,当然,在一个接口上不能接两个设备。

LcdObj LcdObjList[DEV_LCD_C]=
{
	{"oledlcd", LCD_BUS_SPI, 0X1315},
	{"tftlcd", LCD_BUS_8080, NULL},
};

字库

暂时不做细说,例程的字库放在SD卡中,各位移植的时候根据需要修改。 具体参考font.c

总结

本章节之后 ,一个LCD驱动的雏形,基本完成了。 也基本上将LCD驱动框架的实现思想说明了。 但是还有很多需要改进优化的地方。 最终的软件架构,请参考github仓库最新代码。 或参考《产品手册(软件).pdf》


end

汉字点阵字库模块

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


前面几个小节,我们已经点亮了多种LCD,实现英文显示功能。 现在,我们开始添加汉字点阵功能。 汉字显示原理和英文一样,都是在LCD上描点。区别是,英文只有26个字母,算上大小写和其他字符,也就128个字符,就是ASC编码范围。 字符如何描点,只要在程序内保存字符点阵数据,也就是128个点阵数据。 汉字就不一样了,汉字有几千几万个,但是也没有办法,只能一个汉字做一个点阵数据,所有的点阵数据组成一个点阵字库。

汉字编码标准

编码标准

在讨论点阵字库前,先要了解字符编码。 英文的字符编码,就是ASC字符编码。

ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)是基于拉丁字母的一套电脑编码系统,主要用于显示现代英语和其他西欧语言。它是现今最通用的单字节编码系统,并等同于国际标准ISO/IEC 646。

下图就是ASC码表 _images/pic115.jpgASC码表 英文字符少,只需要一个字节编码。汉字那么多字符,通常用2字节编码,部分字符还用了4字节编码(这些字一般人都不认识)。

常用的汉字内码标准有GB2312、GBK、GB18030、BIG5、UNICODE。

  • 大陆编码
GB=国标 GB2312包含7000多字符,GBK包含2万1左右字符,GB18030包含2万7千左右字符。 3者向下兼容,也即是说,GBK是GB2312的扩展,GB18030则是GBK的扩展。

在标准中,还有一个区位码和内码的概念。 区位码,表示这个汉字在编码标准中的位置,区位码从1开始编码。 但是,由于国际通用标准ASC编码时0到128,因此在保存文件时,汉字不能用,所以,保存时,需要偏移, 偏移后的编码叫机内码,俗称内码。在编程中,我们都是用内码,因为保存文件都是用内码的。 在取点阵时,就需要根据内码算出区位码,根据区位码到点阵字库取点阵数据。

更多细节请参考《汉字编码字符gb18030.pdf》。 下图是国标编码标准 _images/pic211.pngASC码表

  • 台湾编码

BIG5是台湾汉字编码标准,主要是繁体。

  • 国际编码

UNICODE编码。

字库概念

点阵字库

点阵字库,也就是包含很多点阵字体的一个文件。 点阵字体也叫位图字体,其中每个字形都以一组二维像素信息表示。 点阵字体优点是显示速度快,不像矢量字体需要计算;其最大的缺点是不能放大,一旦放大后就会发现文字边缘的锯齿。

  • 大小

汉字最小一般是12x12,还有16x16,20x20,24x24,32x32,48x48。 一个16x16的GB18030字库,大概750K。

  • 取模方式

大方向有两种:纵向取模、横向取模。 字节顺序有多种。 两者配合起来就有很多种取模方式了。 下面是取模软件的4种方式: _images/pic37.png字体取模方式_images/pic47.png字体取模方式 _images/pic56.png字体取模方式_images/pic64.png字体取模方式

  • 取点阵算法

不同的字库取点阵算法会有差异。请根据使用的点阵设计算法。 我们自己生成的汉字点阵算法比较简单:

addr = (hcode-0x81)*190;
if(lcode<0x7f)
{
	addr = addr+lcode-0x40;
}
else
{
	addr = addr+lcode-0x41;
}
addr = addr*FontList[type]->size;
hcode是汉字内码高字节,lcode是汉字内码低字节,经过计算后,得到汉字在点阵中的偏移addr。 其中size是一个汉字点阵的字节数,一个16*16的汉字,通常是32字节。 本算法没有处理4字节内码的汉字

矢量字库

矢量字库保存的是对每一个汉字的描述信息,比如一个笔划的起始、终止坐标,半径、弧度等等。在显示、打印这一类字库时,要经过一系列的数学运算才能输出结果,但是这一类字库保存的汉字理论上可以被无限地放大,笔划轮廓仍然能保持圆滑,打印时使用的字库均为此类字库。

因为LCD是一个点一个点显示的,矢量字库通过处理后,最后还是一堆点阵数据,才能在LCD上显示。

制作点阵字库

字库版权

字体是有版权的,通过字模软件获取点阵,理论上是侵权的。大家可以看方正官网 http://ifont.foundertype.com/index/embedfont.html

5、从Windwos系统复制出的字体是否可以免费使用吗? 从操作系统和各类网站上都可以找到字库。如果认为这些字体都可以任意的免费使用就错了。字库是知识产权产品,字库的 著作权属于字库的设计开发者。根据相关法律,必须在获得版权人的授权后才可以使用。 6、用工具从TTF字库中生成的点阵字库是否可以使用? 网站上有许多“点阵字库生成工具”。这些工具的主要作用就是用TTF字库生成任意尺寸的点阵字库。 点阵字库是TTF字库的一部分。许多TTF字库为了使小字是显示清晰,都内嵌了点阵字库。所以未经字库版权人授权从TTF字库 中生成点阵字库在产品中使用的行为实际上是盗版行为。

当然,既然有开源软件,肯定就有开源字体 为了长远着想,我们要找一款开源字体做点阵字库。 关于字体授权,大家可以参考知乎的一个话题:https://www.zhihu.com/question/19727859

思源宋体

经过长达一点年半的研发,Adobe 联合 Google 于 2017 年 4 月 3 日发布了思源宋体( Source Han Serif,Google 称 Noto Serif CJK)。和思源黑体一样,思源宋体以 “SIL 开放字体许可证” 开源发行,且同样含简繁日韩四种汉字写法和七种粗细字重给出。

先在电脑上安装思源汉字字体,安装方法可以参考 https://baijiahao.baidu.com/s?id=1563997223669087&wfr=spider&for=pc

制作字库

易木软件工作室开发了一个点阵生成软件。 选择字体,选择编码标准,设置点阵宽高,在调整字体大小。 _images/pic72.png字库软件 其中,字体取模方式如下: _images/pic47.png字体取模方式 点击生成即可。 我们用软件生成思源字体的点阵字库shscn1212.DZK、shscn1616.DZK。

生成的字库可能不包含四字节内码汉字。

应用点阵字体

存储

字库较大,1212的561K,1616的748K。不可能保存在单片机内。 只能保存在外部储存上。最好是保存在核心板上的FLASH。 但是目前我们还没有在FLASH上做好文件系统,就暂时放在TF卡上。 通过FATFS文件系统读取字库,速度可能会慢一点,等FLASH管理处理好后,再搬移到核心板上的FALSH上。

前面调试USB时已经移植好文件系统,在main函数初始化硬件后挂载SD卡。

s32 fun_mount_sd(void)

SD卡中包含以下字体文件:

songti1616.DZK songti1212.DZK shscn1616.DZK shscn1212.DZK

其中前面两个字体是宋体,有一定版权风险,仅供测试。 后面两个是思源字体,开源。

ASC字体内嵌在代码中,\Utilities\font文件夹中的三个文件就是:

font_6x12.c、font_8x8.c、font_8x16.c

接口

提供两个接口,一个获取asc点阵,一个获取汉字点阵。

extern s32 font_get_asc(FontType type, char *ch, char *buf);
extern s32 font_get_hz(FontType type, char *ch, char *buf);

其中第一个参数type为枚举类型,后续增加新字体,可以增加。

/*
	字体类型定义
*/
typedef enum{
	FONT_SONGTI_1616 = 0,//1616字体,对应的ASC则是8*16
	FONT_SONGTI_1212,	//1212字体,对应的ASC是6*12
	FONT_SIYUAN_1616,
	FONT_SIYUAN_1212,
	FONT_LIST_MAX
}FontType;

在lcd中间层只增加了一个函数,最主要也是这个函数。

s32 dev_lcd_put_string(DevLcd *lcd, FontType font, int x, int y, char *s, unsigned colidx)

汉字功能就添加完成了。

使用

在代码中直接嵌入中文,文件保存中文用的就是内码,编译后就是一个内码字符串。

dev_lcd_put_string(LcdOled, FONT_SONGTI_1212, 10,1, "ABC-abc,", BLACK);
dev_lcd_put_string(LcdOled, FONT_SIYUAN_1616, 1, 13, "这是oled lcd", BLACK);
dev_lcd_put_string(LcdOled, FONT_SONGTI_1212, 10,30, "www.wujique.com", BLACK);
dev_lcd_put_string(LcdOled, FONT_SIYUAN_1616, 1, 47, "屋脊雀工作室", BLACK);

总结

如何实现多国语言?


end

wav文件播放

够用的硬件 能用的代码 实用的教程 屋脊雀工作室编撰 -20190101 愿景:做一套能用的开源嵌入式驱动(非LINUX) 官网:www.wujique.com github: https://github.com/wujique/stm32f407 淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf 技术支持邮箱:code@wujique.com、github@wujique.com 资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg QQ群:767214262

前面硬件调试时,已经完成WM8978调试,由于当时没有文件系统支撑,没做音乐播放。 如今,FATFS已经使用,就让我们开始完成WAV播放功能。

WAV格式

前面调试I2S和WM8978时,其实已经提到过WAV文件,当时只是将WAV文件转换为数组直接播放,并没有深入了解WAV文件的格式。

WAV为微软公司(Microsoft)开发的一种声音文件格式,它符合RIFF(Resource Interchange File Format)文件规范,用于保存Windows平台的音频信息资源,被Windows平台及其应用程序所广泛支持,该格式也支持MSADPCM,CCITT A LAW等多种压缩运算法,支持多种音频数字,取样频率和声道,标准格式化的WAV文件和CD格式一样,也是44.1K的取样频率,16位量化数字,因此在声音文件质量和CD相差无几!

在资料中包含了几个WAV格式说明文档

WAV文件头

_images/pic18.pngWAV 上图就是一个WAV文件的格式,在代码中我们如下面定义:

/*wav 文件结构*/
typedef struct _TWavHeader
{          
	/*RIFF块*/
    int rId;    //标志符(RIFF)  0x46464952
    int rLen;   //数据大小,包括数据头的大小和音频文件的大小   (文件总大小-8)      
    int wId;    //格式类型("WAVE")   0x45564157

    /*Format Chunk*/
    int fId;    //"fmt " 带一个空格 0X20746D66     
    int fLen;   //Sizeof(WAVEFORMATEX)          
    short wFormatTag;       //编码格式,包括 1 WAVE_FORMAT_PCM,WAVEFORMAT_ADPCM等         
    short nChannels;        //声道数,单声道为1,双声道为2         
    int nSamplesPerSec;   //采样频率         
    int nAvgBytesPerSec;  //每秒的数据量         
    short nBlockAlign;      //块对齐          
    short wBitsPerSample;   //WAVE文件的采样大小         
    int dId;              //"data"     有可能是FACT块     
    int wSampleLength;    //音频数据的大小
    /*紧跟后面可能有一个fact 块,跟压缩有关,如果没有,就是data块*/
}TWavHeader;

int 型变量占用4字节空间,short型变量占用2字节空间。

我们可以通过winhex软件查看一个wav的格式,其中头部如下: _images/pic212.pngWAV

前面4个字节就是RIFF标志。程序中写的是0X46464952,这是经常遇到的问题,读文件后保存到一个变量中,字节顺序是不一样的。

音频数据排布

WAV文件有多种格式:单声道,双声道。8位、16位、24位。 文件中样点保存顺序是:左声道样点-右声道样点-左声道样点-右声道样点。单声道就只有一个声道的样点。 每个样点的数据顺序是:低字节-高字节。如果是8位,就只有一个字节,16位则有2字节,24位则有3字节。

中间层设计

音乐播放功能,不属于设备驱动,也不算应用程序。 我们通常把它叫做中间件。 所谓的中间件,就是将驱动设备接口进行二次封装,并完成一定的流程。向APP提供固定接口,屏蔽更多的底层细节。 前面我们做LCD时,LCD显示接口也可以算做中间件。

接口设计

我们在做程序,要时刻考虑上下游,特别是上游,也就是应用工程师开始应用调用底层。

  • 驱动要提供什么给上层使用?
  • 中间层要封装什么样的流程?

音乐播放就是一个中间层。需要提供什么接口呢? 可以参考音乐播放器的功能:

播放 停止 暂停 设置音量 下上一首 指定起始位置播放(快进快退) 歌词

这些功能都是音乐播放的功能,但是那些是中间层实现?提供什么接口?

  1. 播放,中间层肯定要实现,但是只是播放指定文件,也就是输入一个文件名作为参数。
  2. 停止/暂停也是中间层要实现。
  3. 音量,中间层也需要提供接口。
  4. 上下一首呢?由于中间层不管理播放列表,这个功能就不该中间层实现,而是由APP层通过播放接口实现。
  5. 指定起始位置播放?APP层实现,但是中间层要提供接口:APP通过接口获取文件播放时长,再提供一个接口设置播放位置。

本次测试我们只做了下面几个接口,其他请大家自行完善。

/**
 *@brief:      fun_sound_play
 *@details:    通过指定设备播放指定声音
 *@param[in]   char *name  
               char *dev   
 *@param[out]  无
 *@retval:     
 */
int fun_sound_play(char *name, char *dev)
/**
 *@brief:      fun_sound_get_sta
 *@details:    查询音乐播放状态
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
SOUND_State fun_sound_get_sta(void)
/**
 *@brief:      fun_sound_stop
 *@details:    停止音乐播放
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
s32 fun_sound_stop(void)

/**
 *@brief:      fun_sound_pause
 *@details:    暂停播放
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
s32 fun_sound_pause(void)

/**
 *@brief:      fun_sound_resume
 *@details:    恢复播放
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
s32 fun_sound_resume(void)

/**
 *@brief:      fun_sound_setvol
 *@details:    设置音量
 *@param[in]   u8 vol  
 *@param[out]  无
 *@retval:     
 */
s32 fun_sound_setvol(u8 vol)

流程设计

流程设计基本按照前面WM8978实验中做的测试程序。

双DMA缓冲。 在DMA中断中设置标志缓冲切换。 在主TASK中填充数据。
  • 播放
/**
 *@brief:      fun_sound_play
 *@details:    通过指定设备播放指定声音
 *@param[in]   char *name  
               char *dev   
 *@param[out]  无
 *@retval:     
 */
int fun_sound_play(char *name, char *dev)
{
	FRESULT res;
	unsigned int len;

	SoundSta = SOUND_BUSY;
	/*
		打开文件是否需要关闭?
		同时打开很多文件事发后会内存泄漏。
	*/
	res = f_open(&SoundFile, name, FA_READ);
	if(res != FR_OK)
	{
		SOUND_DEBUG(LOG_DEBUG, "sound open file err:%d\r\n", res);
		SoundSta = SOUND_IDLE;
		return -1;
	}

	SOUND_DEBUG(LOG_DEBUG, "sound open file ok\r\n");

	wav_header = (TWavHeader *)wjq_malloc(sizeof(TWavHeader));
	if(wav_header == 0)
	{
		SOUND_DEBUG(LOG_DEBUG, "sound malloc err!\r\n");
		SoundSta = SOUND_IDLE;
		return -1;
	}
	SOUND_DEBUG(LOG_DEBUG, "sound malloc ok\r\n");

	res = f_read(&SoundFile, (void *)wav_header, sizeof(TWavHeader), &len);
	if(res != FR_OK)
	{
		SOUND_DEBUG(LOG_DEBUG, "sound read err\r\n");
		SoundSta = SOUND_IDLE;
		return -1;
	}

	SOUND_DEBUG(LOG_DEBUG, "sound read ok\r\n");
	if(len != sizeof(TWavHeader))
	{
		SOUND_DEBUG(LOG_DEBUG, "read wav header err %d\r\n", len);
		SoundSta = SOUND_IDLE;
		return -1;
	}

	SOUND_DEBUG(LOG_DEBUG, "---%x\r\n", wav_header->rId);
	SOUND_DEBUG(LOG_DEBUG, "---%x\r\n", wav_header->rLen);
	SOUND_DEBUG(LOG_DEBUG, "---%x\r\n", wav_header->wId);
	SOUND_DEBUG(LOG_DEBUG, "---%x\r\n", wav_header->fId);
	SOUND_DEBUG(LOG_DEBUG, "---%x\r\n", wav_header->fLen);
	SOUND_DEBUG(LOG_DEBUG, "---wave 格式 %x\r\n", wav_header->wFormatTag);
	SOUND_DEBUG(LOG_DEBUG, "---声道      %x\r\n", wav_header->nChannels);
	SOUND_DEBUG(LOG_DEBUG, "---采样频率  %d\r\n", wav_header->nSamplesPerSec);
	SOUND_DEBUG(LOG_DEBUG, "---每秒数据量 %d\r\n", wav_header->nAvgBytesPerSec);
	SOUND_DEBUG(LOG_DEBUG, "---样点字节数 %d\r\n", wav_header->nBlockAlign);
	SOUND_DEBUG(LOG_DEBUG, "---位宽 :    %d bit\r\n", wav_header->wBitsPerSample);
	SOUND_DEBUG(LOG_DEBUG, "---data =    %x\r\n", wav_header->dId);
	SOUND_DEBUG(LOG_DEBUG, "---数据长度: %x\r\n", wav_header->wSampleLength);

	if(wav_header->nSamplesPerSec <= I2S_AudioFreq_16k)
	{
		SoundBufSize = DAC_SOUND_BUFF_SIZE2;
	}
	else
	{
		SoundBufSize = I2S_DMA_BUFF_SIZE1;
	}
	/*

	*/
	SoundBufP[0] = (u16 *)wjq_malloc(SoundBufSize*2);
	SoundBufP[1] = (u16 *)wjq_malloc(SoundBufSize*2);

	SOUND_DEBUG(LOG_DEBUG, "%08x, %08x\r\n", SoundBufP[0], SoundBufP[1]);
	if(SoundBufP[0] == NULL)
	{

		SOUND_DEBUG(LOG_DEBUG, "sound malloc err\r\n");
		SoundSta = SOUND_IDLE;
		return -1;
	}

	if(SoundBufP[1] == NULL )
	{
		wjq_free(SoundBufP[0]);
		SoundSta = SOUND_IDLE;
		return -1;
	}

	/*根据文件内容设置采样频率跟样点格式*/
	u8 format;
	if(wav_header->wBitsPerSample == 16)
	{
		format =	WM8978_I2S_Data_16b; 	
	}
	else if(wav_header->wBitsPerSample == 24)
	{
		format =	WM8978_I2S_Data_24b; 	
	}
	else if(wav_header->wBitsPerSample == 32)
	{
		format =	WM8978_I2S_Data_32b; 	
	}

	/*打开指定设备*/
	if(0 == strcmp(dev, "wm8978"))
	{
		dev_wm8978_open();
		dev_wm8978_dataformat(wav_header->nSamplesPerSec,
			WM8978_I2S_Phillips, format);
		mcu_i2s_dma_init(SoundBufP[0], SoundBufP[1], SoundBufSize);
		SoundDevType = SOUND_DEV_2CH;
	}

	playlen = 0;

	u32 rlen;

	/*音源单声道,设备双声道,对数据复制一份到另外一个声道*/
	if((wav_header->nChannels == 1) && (SoundDevType == SOUND_DEV_2CH))
	{
		rlen = SoundBufSize;
		f_read(&SoundFile, (void *)SoundBufP[0], rlen, &len);
		fun_sound_deal_1ch_data((u8*)SoundBufP[0]);
		f_read(&SoundFile, (void *)SoundBufP[1], rlen, &len);
		fun_sound_deal_1ch_data((u8*)SoundBufP[1]);

	}
	else
	{
		rlen = SoundBufSize*2;
		f_read(&SoundFile, (void *)SoundBufP[0], rlen, &len);
		f_read(&SoundFile, (void *)SoundBufP[1], rlen, &len);
	}

	playlen += rlen*2;

	if(0 == strcmp(dev, "wm8978"))
	{
		dev_wm8978_transfer(1);//启动I2S传输
	}

	SoundSta = SOUND_PLAY;

	return 0;
}
函数参数包含文件名和音频设备。dev就是用来指定音频设备,用WM8978播放还是DACSOUND播放。 19~66行,读文件,并判断是不是WAV文件,如果是WAV文件,就将WAV文件头读出。 68~96,申请两个缓冲区。 99~112,设置格式 115~120,打开设备 127~144,准备数据,填满两个缓冲。 151,启动设备,开始播放。
  • 流程 流程也是一个TASK函数,在main函数的while(1)中执行。
/**
 *@brief:      fun_sound_task
 *@details:    声音播放轮询任务,执行间隔不可以太久,-
               否则声音会有杂音,也就是断续
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
void fun_sound_task(void)
{
	FRESULT res;
	unsigned int len;
	volatile s32 buf_index = 0;
	int rlen;
	u16 i;
	u8 *p;

	if(SoundSta == SOUND_BUSY
		|| SoundSta == SOUND_IDLE)
		return;

	buf_index = fun_sound_get_buff_index();
	if(0xff != buf_index)
	{
	    if(SoundSta == SOUND_PAUSE)//暂停
	    {
		for(i=0;i<SoundBufSize;i++)
		{
			*(SoundBufP[buf_index]+i)= 0x0000;
		}
	    }
	    else
	    {

		if((wav_header->nChannels == 1) && (SoundDevType == SOUND_DEV_2CH))
		{
		    rlen = SoundBufSize;
		    res = f_read(&SoundFile, (void *)SoundBufP[buf_index], rlen, &len);
		    fun_sound_deal_1ch_data((u8*)SoundBufP[buf_index]);
		}
		else
		{
		    rlen = SoundBufSize*2;
		    res = f_read(&SoundFile, (void *)SoundBufP[buf_index], rlen, &len);
		}

		//memset(SoundBufP[buf_index], 0, SoundRecBufSize*2);

		playlen += len;

		/*
		u盘有BUG,有时候读到的数据长度不对
		稳健的做法是用已经播放的长度跟音源长度比较。
		*/
		if(len < rlen)
		{
		    SOUND_DEBUG(LOG_DEBUG, "play finish %d, playlen:%x\r\n", len, playlen);
		    fun_sound_stop();

		}
	   }
    }

}

流程还比较简单,就是通过fun_sound_get_buff_index查看是否有缓冲空了,空了就填数据。

测试

在SD卡中放几个WAV文件。 在main中,按下按键后调用下面函数播放

/**
 *@brief:      fun_sound_test
 *@details:    测试播放
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
void fun_sound_test(void)
{
	SOUND_DEBUG(LOG_DEBUG, "play sound\r\n");
	fun_sound_play("1:/mono_16bit_8k.wav", "wm8978");		
}

总结

WAV的格式还是很简单的,加上上次已经基本上设计好了语音播放的程序架构,做一个WAV播放功能还是很容易的。 如何让DACSOUND设备也能播放WAV文件?


end

I2SEXT-WM8978-录音

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


在WM8978调试章节,我们提到过STM32的I2S_ext功能。 使用这个外扩的I2S,就能实现I2S全双工,就能实现录音功能。 现在让我们一起调试录音功能。

I2S_ext

STM32的I2S支持全双工。如下说明。

_images/pic19.pngWAV

I2S_ext没有时钟,必须配合I2S使用。 通常,我们用I2S做主设备,输出时钟,发送I2S数据到外部设备。 I2S_ext也使用I2S的时钟,从外部设备接收I2S数据。

WM8978录音配置

WM8978的驱动我们已经完成,现在只需要看看录音需要设置什么寄存器。

硬件

下图是8978线路图。 WM8978支持左右声道两个MIC。板子太小,根本无法区分左右声道,因此将两个MIC连在一起。 只使用一个MIC电路。 _images/pic213.pngWAV

配置

录音功能涉及到WM8978的两个模块:

  1. MIC模块。

MIC模块用于配置是否使用MIC输入的音源。只要配置好MIC模块,MIC输入的声音就可以从WM8978播放。

  1. ADC模块。

MIC模块输入的是模拟信号,要通过I2S传输到STM32,需要经过ADC转换。

如下图,左边红框是MIC模块,中间红框是ADC模块。

_images/pic310.jpgWAV

对这两个模块的配置主要有一下: _images/pic48.pngWAV

R45/R46、R47/R48,设置PGA增益,也就是MIC的输入增益。左边中间红框是设置增益的。 R44,控制MIC是否输入到PGA。左边上下两个红框处。 R14,配置ADC。中间红框。

更多配置请查看代码

驱动设计

底层驱动设计两部分:I2S_ext、WM8978。

I2S_ext

I2S_ext驱动类似I2S。

同样需要配置I2S通信格式。 同样使用DMA传输。 同样使用双缓冲DAM模式。
  • 配置
void mcu_i2sext_config(u32 AudioFreq, u16 Standard,u16 DataFormat)
{  
	I2S_InitTypeDef I2S2ext_InitStructure;
	//I2S_FullDuplexConfig会进行转换
	I2S2ext_InitStructure.I2S_Mode = I2S_Mode_MasterTx;
	I2S2ext_InitStructure.I2S_Standard=Standard;//IIS标准
	I2S2ext_InitStructure.I2S_DataFormat=DataFormat;//IIS数据长度
	//主时钟输出,i2sext无效
	I2S2ext_InitStructure.I2S_MCLKOutput=I2S_MCLKOutput_Enable;
	I2S2ext_InitStructure.I2S_AudioFreq=AudioFreq;//IIS频率设置
	I2S2ext_InitStructure.I2S_CPOL=I2S_CPOL_Low;//空闲状态时钟电平
        //初始化I2S2ext配置
	I2S_FullDuplexConfig(I2S2ext, &I2S2ext_InitStructure);

	I2S_Cmd(I2S2ext, ENABLE);		//I2S2ext I2S EN使能.
}
  • DAM设置
/**
 *@brief:      mcu_i2sext_dma_init
 *@details:    设置I2S EXT DMA缓冲
 *@param[in]   u16* buf0  
               u16 *buf1  
               u32 len    
 *@param[out]  无
 *@retval:     
 */
void mcu_i2sext_dma_init(u16* buf0, u16 *buf1, u32 len)
{

	NVIC_InitTypeDef   NVIC_InitStructure;
	DMA_InitTypeDef  DMA_InitStructure;

	RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA1,ENABLE);//DMA1时钟使能

	DMA_DeInit(I2S2_EXT_DMA);
	while (DMA_GetCmdStatus(I2S2_EXT_DMA) != DISABLE){}//等待DMA1_Stream3可配置
        //清空DMA1_Stream3上所有中断标志
	DMA_ClearITPendingBit(I2S2_EXT_DMA,DMA_IT_FEIF3|DMA_IT_DMEIF3
		             |DMA_IT_TEIF3|DMA_IT_HTIF3|DMA_IT_TCIF3);

	/* 配置 DMA Stream */
	DMA_InitStructure.DMA_Channel = DMA_Channel_3;
	DMA_InitStructure.DMA_PeripheralBaseAddr = (u32)&I2S2ext->DR;
	DMA_InitStructure.DMA_Memory0BaseAddr = (u32)buf0;//DMA 存储器0地址
	DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralToMemory;//外设到存储器模式
	DMA_InitStructure.DMA_BufferSize = len;//数据传输量
	//外设非增量模式
	DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
	DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;//存储器增量模式
	//外设数据长度:16位
	DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
	//存储器数据长度:16位
	DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
	DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;// 使用循环模式
	DMA_InitStructure.DMA_Priority = DMA_Priority_High;
	DMA_InitStructure.DMA_FIFOMode = DMA_FIFOMode_Disable; //不使用FIFO模式		  
	DMA_InitStructure.DMA_FIFOThreshold = DMA_FIFOThreshold_1QuarterFull;
	DMA_InitStructure.DMA_MemoryBurst = DMA_MemoryBurst_Single;//外设突发单次传输
	//存储器突发单次传输
	DMA_InitStructure.DMA_PeripheralBurst = DMA_PeripheralBurst_Single;
	DMA_Init(I2S2_EXT_DMA, &DMA_InitStructure);//初始化DMA Stream
	//双缓冲模式配置
	DMA_DoubleBufferModeConfig(I2S2_EXT_DMA, (u32)buf0, DMA_Memory_0);
	//双缓冲模式配置
	DMA_DoubleBufferModeConfig(I2S2_EXT_DMA, (u32)buf1, DMA_Memory_1);

	DMA_DoubleBufferModeCmd(I2S2_EXT_DMA,ENABLE);//双缓冲模式开启

	DMA_ITConfig(I2S2_EXT_DMA,DMA_IT_TC,ENABLE);//开启传输完成中断

	SPI_I2S_DMACmd(I2S2ext, SPI_I2S_DMAReq_Rx, ENABLE);//I2S2ext RX DMA请求使能.

	NVIC_InitStructure.NVIC_IRQChannel = DMA1_Stream3_IRQn;
	NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority =0x00;//抢占优先级0
	NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0x00;//子优先级1
	NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;//使能外部中断通道
	NVIC_Init(&NVIC_InitStructure);//配置

}

看注释基本明白要如何配置,跟播音的设置类似。 要谨记的是,I2S_ext需要I2S配合输出时钟

WM8978

WM8978的基本配置在播音例程已经配置好,只需要在OPEN的时候打开MIC输入。

s32 dev_wm8978_open(void)
{
	dev_wm8978_inout(WM8978_INPUT_DAC|WM8978_INPUT_LMIC|WM8978_INPUT_RMIC|WM8978_INPUT_ADC,
					WM8978_OUTPUT_SPK|WM8978_OUTPUT_PHONE);
	return 0;
}

并且在配置数据格式的时候同时配置I2S_ext的数据格式。

s32 dev_wm8978_dataformat(u32 Freq, u8 Standard, u8 Format)
{
	u16 standard;
	u16 dataformat;

	dev_wm8978_set_dataformat(Standard, Format);

        ...

	mcu_i2s_config(Freq, standard, dataformat);
	mcu_i2sext_config(Freq, standard, dataformat);

	return 0;
}

录音中间层设计

前面的I2S_ext驱动设计,跟I2S一样,都是使用双缓冲DMA格式,理所当然的我们都能想到:录音的中间层设计跟播音应该差不多。

接口设计

要求和播音基本一致:

开始录音 暂停 继续 停止。

本次测试我们不做暂停功能,只需要实现播放和停止即可。

  • 开始录音
/**
 *@brief:      fun_sound_rec
 *@details:    启动录音
 *@param[in]   char *name  
 *@param[out]  无
 *@retval:     
 */
s32 fun_sound_rec(char *name)
{
	FRESULT fres;
	u32 len;

	SOUND_DEBUG(LOG_DEBUG, "sound rec\r\n");
	RecWavSize = 0;
	SoundRecBufSize = SoundBufSize;

	/*  创建WAV文件 */
	fres=f_open(&SoundRecFile,(const TCHAR*)name, FA_CREATE_ALWAYS | FA_WRITE);
	if(fres != FR_OK)			//文件创建失败
	{
		SOUND_DEBUG(LOG_DEBUG, "create rec file err!\r\n");
		return -1;
	}

	recwav_header = (TWavHeader *)wjq_malloc(sizeof(TWavHeader));
	if(recwav_header == NULL)
	{
		SOUND_DEBUG(LOG_DEBUG, "rec malloc err!\r\n");
		return -1;
	}

	recwav_header->rId=0X46464952;
	recwav_header->rLen = 0;//录音结束后填
	recwav_header->wId = 0X45564157;//wave
	recwav_header->fId=0X20746D66;
	recwav_header->fLen = 16;
	recwav_header->wFormatTag = 0X01;
	recwav_header->nChannels = 2;
	//这个采样频率需要特殊处理,暂时不做。
	recwav_header->nSamplesPerSec = SOUND_REC_FRE;
	recwav_header->nAvgBytesPerSec =
		(recwav_header->nSamplesPerSec)*(recwav_header->nChannels)*(16/8);
	recwav_header->nBlockAlign = recwav_header->nChannels*(16/8);
	recwav_header->wBitsPerSample = 16;
	recwav_header->dId = 0X61746164;
	recwav_header->wSampleLength = 0;

	fres=f_write(&SoundRecFile,(const void*)recwav_header,
				sizeof(TWavHeader), &len);
	if((fres!= FR_OK)
		|| (len != sizeof(TWavHeader)))
	{

		SOUND_DEBUG(LOG_DEBUG, "rec write err!\r\n");
		wjq_free(recwav_header);
		return -1;		
	}
	else
	{
		SOUND_DEBUG(LOG_DEBUG, "create rec wav ok!\r\n");
	}

	/*  测试录音     */
	SoundRecBufP[0] = (u16 *)wjq_malloc(SoundRecBufSize*2);
	SoundRecBufP[1] = (u16 *)wjq_malloc(SoundRecBufSize*2);

	SOUND_DEBUG(LOG_DEBUG, "%08x, %08x\r\n", SoundRecBufP[0], SoundRecBufP[1]);
	if(SoundRecBufP[0] == NULL)
	{

		SOUND_DEBUG(LOG_DEBUG, "sound malloc err\r\n");
		return -1;
	}

	if(SoundRecBufP[1] == NULL )
	{
		wjq_free(SoundRecBufP[0]);
		return -1;
	}

	dev_wm8978_open();
	dev_wm8978_dataformat(SOUND_REC_FRE, WM8978_I2S_Phillips,
			WM8978_I2S_Data_16b);

	mcu_i2s_dma_init(RecPlayTmp, RecPlayTmp, 1);
	dev_wm8978_transfer(1);//启动I2S传输

	mcu_i2sext_dma_init(SoundRecBufP[0], SoundRecBufP[1], SoundRecBufSize);
	mcu_i2sext_dma_start();

	SOUND_DEBUG(LOG_DEBUG, "rec--------------------\r\n");

	return 0;
}
17~58,创建WAV文件。 61~76,准备双缓冲 78~85, 配置WM8978,启动录音。
  • 停止录音
/**
 *@brief:      fun_rec_stop
 *@details:    停止录音
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
s32 fun_rec_stop(void)
{
	u32 len;
	dev_wm8978_transfer(0);
	mcu_i2sext_dma_stop();

	recwav_header->rLen = RecWavSize+36;
	recwav_header->wSampleLength = RecWavSize;
	f_lseek(&SoundRecFile,0);

	f_write(&SoundRecFile,(const void*)recwav_header,
		sizeof(TWavHeader),&len);//写入头数据
	f_close(&SoundRecFile);

	wjq_free(SoundRecBufP[0]);
	wjq_free(SoundRecBufP[1]);
	wjq_free(recwav_header);
	return 0;
}
停止录音,保存WAV文件,释放内存。

流程

跟放音一样使用双缓冲,所以录音流程也基本一样。

/**
 *@brief:      fun_rec_task
 *@details:    录音线程
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
void fun_rec_task(void)
{
	int buf_index = 0;
	u32 len;
	FRESULT fres;

	buf_index = fun_rec_get_buff_index();
	if(0xff != buf_index)
	{
		//uart_printf("rec buf full:%d!\r\n", buf_index);
		RecWavSize += SoundRecBufSize*2;

		fres = f_write(&SoundRecFile,(const void*)SoundRecBufP[buf_index],
				2*SoundRecBufSize, &len);
		if(fres != FR_OK)
		{
			SOUND_DEBUG(LOG_DEBUG, "write err\r\n");
		}

		if(len!= 2*SoundRecBufSize)
		{
			SOUND_DEBUG(LOG_DEBUG, "len err\r\n");
		}

	}
}
在主任务循环中运行task函数,发送有缓冲满了,将数据读出来写到wav文件。

测试

增加下面两个函数,fun_rec_test录音,fun_play_rec_test播放录音得到的WAV文件。


/**
 *@brief:      fun_rec_test
 *@details:    开始录音
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
void fun_rec_test(void)
{
	fun_sound_rec("1:/rec9.wav");
}

void fun_play_rec_test(void)
{
	fun_sound_play("1:/rec9.wav", "wm8978");
}

在main函数的while循环中,当按下按键,开始录音,松开按键,播放录音文件。

总结

在前期播音和WAV文件解码两个章节的基础上,实现I2S录音可以说不难。 要树立的概念就是I2S_ext必须使用I2S的时钟。 我们做的录音和播音,是两个单独的功能。 请问如果要在播音的时候能录音,程序要如果修改?


end

DAC SOUND驱动改造–播放WAV文件

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


前面播放WAV音频和I2S录音两个小节,我们接触了一种叫做中间件的程序。 我可以可以再总结一下:

所谓的中间件,通常是实现一种功能的抽象接口。这一层代码,对应用屏蔽了硬件实现,只提供功能接口。 例如:LCD中间件,GUI也可以算中间件,应用层主要调用LCD显示接口,就可以在各种LCD伤显示内容。 那么,语音播放中间件,就是APP播放音乐,可以在多种硬件声音设备上播放。

前面章节我们已经实现了WM8978播放,我们硬件正好还有一个DAC SOUND的设备。 怎么样修改DAC SOUND代码,让其在语音播放中间件下也能工作?

框架设计

不用多想就可以知道,既然都是在SOUND中间件下工作的硬件,那么驱动,肯定应该差不多。 我们已经完成了WM8978的驱动设计,DAC SOUND按其修改,肯定是最合理的。

DAC SOUND

前面我们做的DAC SOUND功能:

启动一个定时器,按照音频采样率,定时从数组读取样点,通过DAC输出。

DAC SOUND和WM8978的一个最大不同点就是:它是单声道的

双缓冲

如果要根据I2S驱动修改DAC SOUND,只需要简单的将数组改为双缓冲。 语音中间件只需要将原来打开WM8978设备改为DAC设备就可以了。 中间件数据处理部分代码都不需要修改。

代码说明

初始化函数,没变,也就是初始化IO口。

/*

	DAC 播放声音,固定播放8K单声道16BIT的音源。

*/

u16 *DacSoundSampleP0;
u16 *DacSoundSampleP1;
u16 *DacSoundCrBufP;//当前使用的BUF

u32 DacSoundSampleBufSize;
u32 DacSoundSampleIndex;

s32 dev_dacsound_init(void)
{
	GPIO_InitTypeDef GPIO_InitStructure;

	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5;
        GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT;//---模拟模式
        GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP;//---下拉
        GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
        GPIO_Init(GPIOA, &GPIO_InitStructure);//---初始化 GPIO

	GPIO_ResetBits(GPIOA, GPIO_Pin_5);

	return 0;
}

打开播放函数,除了初始化DAC和定时器,还需要初始化两个缓冲的指针。

s32 dev_dacsound_open(void)
{
	mcu_dac_open();
	mcu_tim3_init();
	DacSoundSampleIndex = 0;
	DacSoundCrBufP = DacSoundSampleP0;

	return 0;
}

空函数,暂时不做太复杂,只播放8K采样率的WAV。

/**
 *@brief:      dev_dacsound_dataformat
 *@details:       设置播放配置,DAC播放固定支持8K 16BIT 单声-

 *@param[in]   u32 Freq     
               u8 Standard  
               u8 Format    
 *@param[out]  无
 *@retval:     
 */
s32 dev_dacsound_dataformat(u32 Freq, u8 Standard, u8 Format)
{
	return 0;
}

初始化缓冲指针,模拟I2S DMA配置双缓冲。

/**
 *@brief:      dev_dacsound_setbuf
 *@details:    设置播放缓冲
 *@param[in]   u16 *buffer0  
               u16 *buffer1  
               u32 len       
 *@param[out]  无
 *@retval:     
 */
s32 dev_dacsound_setbuf(u16 *buffer0,u16 *buffer1,u32 len)
{
	DacSoundSampleP0 = buffer0;
	DacSoundSampleP1 = buffer1;
	DacSoundSampleBufSize = len;

	return 0;
}

dev_dacsound_transfer函数,启动播放,也是模拟I2S的函数

/**
 *@brief:      dev_dacsound_transfer
 *@details:    启动或停止DAC播放
 *@param[in]   u8 sta  
 *@param[out]  无
 *@retval:     
 */
s32 dev_dacsound_transfer(u8 sta)
{
	if(sta == 1)
	{
		/*打开定时器,启动播放*/
		DACSOUND_DEBUG(LOG_DEBUG, "dac sound play\r\n");
		mcu_tim3_start();
	}
	else
	{
		/*停止定时器*/
		mcu_tim3_stop();
	}

	return 0;
}

停止播放

s32 dev_dacsound_close(void)
{
	dev_dacsound_init();
	return 0;
}

定时器中断函数,在这个函数内将缓冲的数据通过DAC输出。 流程跟原来基本一致。 需要修改的是取数据的方法。

/**
 *@brief:      dev_dacsound_timerinit
 *@details:    在定时器中断中调用,每125US输出一个DAC数据
 			   能不能改为DMA?
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
s32 dev_dacsound_timerinit(void)
{
    s16 data = 0;
    u16 tmp;

	if(DacSoundSampleIndex >= DacSoundSampleBufSize)
	{
		if(DacSoundCrBufP == DacSoundSampleP0)
		{
			DacSoundCrBufP = DacSoundSampleP1;
			fun_sound_set_free_buf(0);
		}
		else
		{
			DacSoundCrBufP = DacSoundSampleP0;
			fun_sound_set_free_buf(1);
		}
		DacSoundSampleIndex = 0;

	}

	/*要注意,读到的数据是S16,正负值*/
	data = *(DacSoundCrBufP + DacSoundSampleIndex);
	/*
		先压缩,也就是减少音量,在负数时候压缩(除)
		压缩方向时中位值,如果先将负数调整为正数(抬高直流电平),
		压缩方向就会变成音频的最低值,音效会失真。
	*/
	data = data/(16+30);//12位DAC,再加上音量设置,
	/*再调整中位值(直流电平),因为音频数据有负数,DAC输出没有负数*/
	tmp = (data+0x800);
	mcu_dac_output(tmp);

	DacSoundSampleIndex++;
	return 0;
}

中间件修改

在 int fun_sound_play(char *name, char *dev)内,原来只有WM8978设备, 现添加DAC SOUND设备。


	if(0 == strcmp(dev, "wm8978"))
	{
		dev_wm8978_open();
		dev_wm8978_dataformat(wav_header->nSamplesPerSec,
			WM8978_I2S_Phillips, format);
		mcu_i2s_dma_init(SoundBufP[0], SoundBufP[1], SoundBufSize);
		SoundDevType = SOUND_DEV_2CH;
		dev_wm8978_transfer(1);//启动I2S传输
	}
	else if(0 == strcmp(dev, "dacsound"))
	{
		dev_dacsound_open();
		dev_dacsound_dataformat(wav_header->nSamplesPerSec,
			WM8978_I2S_Phillips, format);
		dev_dacsound_setbuf(SoundBufP[0], SoundBufP[1], SoundBufSize);
		SoundDevType = SOUND_DEV_1CH;
		dev_dacsound_transfer(1);
	}

fun_sound_stop函数同样添加

if(SoundDevType == SOUND_DEV_2CH)
{
  dev_wm8978_transfer(0);
}
else if(SoundDevType == SOUND_DEV_1CH)
{
  dev_dacsound_transfer(0);
  dev_dacsound_close();
}

应用

只需要在播放语音的时候指定DAC设备。

/**
 *@brief:      fun_sound_test
 *@details:    测试播放
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
void fun_sound_test(void)
{
	SOUND_DEBUG(LOG_DEBUG, "play sound\r\n");
	fun_sound_play("1:/mono_16bit_8k.wav", "dacsound");		

}

总结

简单吧?确实简单。 这么简单的原因是,我们良好的架构设计。 但愿我们能教会大家写好代码。


end

详解矩阵按键扫描

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


按键输入是人接交互的一个重要输入途径。电脑键盘,就是一个最常见的矩阵按键。 在其他电子消费产品中,经常见到4*4矩阵按键。 用户按下按键,程序如何知道用户按下哪个键? 现在就让我们一起学习按键扫描的原理。

单键扫描

单键,并不是指一个按键,而是指一个IO口控制一个按键,原理图如下: _images/pic116.jpg单键原理图 PA0这个IO口接到按键的一端,按键另外一端接到地。 当按键按下,两端短路,IO口就接到地,就是低电平。 那没按下时,IO口啥都没接,为啥是高电平呢? 因为IO口在芯片内部可以配置连接一个内部上拉电阻。 如果使用了没有内部上拉电阻的IO,就只能在外部接一个电阻将IO口上拉到高电平。

按键扫描方式

首先我们要记住的以下常识:

  1. 芯片跑得很快。 从一个IO口读取输入电平,只是一瞬间的事情。
  2. 手可能会抖动。
  3. 机械按键可能会抖动。

用示波器抓按键的波形,可能如下图: _images/pic29.jpg按键抖动

很明显,如果我们在抖动时间段读IO状态,得到的值将是随机的。 因此,按键扫描最重要的一个功能就是去抖动。 去抖动的原理很简单:

间隔一定时间读几次,电平连续相同则认为状态是可靠的。

通常大家看到的教程,按键流程如下: _images/pic311.jpg单键扫描流程 这样的单键扫描流程有以下问题:

  1. 去抖动通过硬延时实现。
  2. 只对按下去抖动,没有对松开去抖动。

硬延时在真正的项目开发中,是绝对不建议使用的,无论是按键扫描还是其他功能,都不应该用硬延时。 无论按下还是松开,都应该有去抖动功能。 就算一些没有抖动的按键,也要加上,毕竟产品销售出去后,环境变化,可能会受到外部干扰。 如果按键被干扰造成误动作,将是一个失败的产品。

那么如何优化掉硬延时呢? 在功能稍微复杂的单片机系统中,常用的是轮询模式。 轮询模式的代码模式大概如下:

main(void)
{

	初始化

	while(1)
	{
		扫描按键();
		扫描串口();
		........
	}

}

在while循环中,轮流执行各个驱动的代码。 我们通常把这些函数叫做任务,TASK。 在这些任务函数中,一般都是检测状态或执行一些简单的代码,就会退出,不会在任务中卡太久。 如果某个任务卡太久,其他任务的响应时间就会很差。

但是很多驱动或者任务,经常是需要等待某种状态,或者是要延时一定时间再判断状态。 就像按键一样,要间隔一定时间再次去读IO状态。怎么做呢? 在轮询模式下,驱动常用的一种编码手段就是步骤拆分。什么叫步骤拆分? 假设轮询按键扫描的间隔是2毫秒(2毫秒执行一次扫描按键)。 那么我们就在按键扫描里面增加一个防抖计数和一个步骤计数

scan_key(void)
{
	第一步,检测按键是否按下

	第二步,防抖计数自加,判断防抖计数,
		记到5次,就到10毫秒了。
		再判断按键有没有按下
}

程序流程如上面的伪代码。每次进入scan_key这个函数,只会执行一个步骤。 这样,陷入scan_key函数的时间将会很短,仅仅执行几条代码,没有延时。 在while中需要轮询的其他功能就可以很快得到执行。

对于第二个问题:松开没有去抖动。 我们的解决方法并不是松开的时候也增加去抖动,而是将松开和按下合并处理。 这就牵涉到如何对某种事物进行抽象。 前面的做法,是将一个按键抽象为按下和松开两个状态(也可以叫两种变化,或两种事件)。 我们的做法是将按键抽象为一个电平变化的事件,无论按下还是松开,都是从一种电平变化为另外一种电平。 那么,按下和松开,就可以用同一种扫描方法,用同一段代码扫描。 得到变化事件后,在根据IO状态识别松开还是按下。

这个小观念的转变,对代码架构有很大影响。 如果要总结为什么这样做,可以称之为“统一”。 将几种不同的东西,提取他们的共同点,编写一段代码共用。 延伸开说,也就是模块化。

对于单键扫描的程序,在此就不展开了,大家可以根据原理自己尝试编写。

矩阵按键的扫描原理

单键模式有一个很大缺陷:当需要较多的按键时,需要的IO口就多。 例如16个按键的时候,就需要16个IO口。 如果使用矩阵按键,只需要8根IO。 下面的接法就叫做矩阵按键。 _images/pic49.jpg矩阵按键原理图

在8根IO上要串上限流电阻,上图没画出

从图上可以看出,只需要8根IO口,就可以实现16个按键的输入。 原理是什么呢?我们通过分析扫描方法解释原理。 矩阵按键扫描通常有两种方法:交叉扫描逐行扫描

  • 交叉扫描速度快,程序简单,扫描结果通过查表获取。但是缺陷也多。
  • 逐行扫描需要轮询所有行,程序也稍微复杂,但是可以识别键盘的多种状态。

通常我们说的按键扫描都是用逐行扫描。就像字面意思说的,逐行-逐行-扫描。 例如上面原理图:

KPP-ROW0输出0,其他ROW输出1,读取4根COL IO的状态,就可得到第一行四个按键(1、2、3、功能)的状态了。(当然,还需要去抖动)。 然后KPP-ROW1输入0,其他ROW输出1,读4根COL IO的状态。。。。。。 。。。。 不断重复从ROW0到ROW3。

这就叫做矩阵按键逐行扫描。 上面仅仅说明原理,真正的逐行扫描当然没那么简单。 下面我们就用一个完整的逐行扫描说明程序应该如何写。

/**
 *@brief:      dev_keypad_init
 *@details:    初始化矩阵按键IO口
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
s32 dev_keypad_init(void)
{
	/*
	c:PF8-PF11   当做输输入
	r:PF12-PF15  当做输出
	*/

	GPIO_InitTypeDef  GPIO_InitStructure;

	RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOF, ENABLE);

	/* r */
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12|GPIO_Pin_13|GPIO_Pin_14|GPIO_Pin_15;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT;
	GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz;
	GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP;
	GPIO_Init(GPIOF, &GPIO_InitStructure);

	/* c */
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_11;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN;
	GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP;
	GPIO_Init(GPIOF, &GPIO_InitStructure);

	GPIO_SetBits(GPIOF, GPIO_Pin_12|GPIO_Pin_13|GPIO_Pin_14|GPIO_Pin_15);

	u8 i;
	for(i = 0; i< KEY_PAD_ROW_NUM; i++)
	{
		KeyPadCtrl[i].dec = 0;
		KeyPadCtrl[i].oldsta = KEYPAD_INIT_STA_MASK;
		KeyPadCtrl[i].newsta = KEYPAD_INIT_STA_MASK;
	}

}
/**
 *@brief:      dev_keypad_scan
 *@details:    按键扫描,在定时器或者任务中定时执行
 *@param[in]   void  
 *@param[out]  无
 *@retval:     
 */
s32 dev_keypad_scan(void)
{
	u16 ColSta;
	u8 chgbit;
	static u8 scanrow = 0;
	u8 keyvalue;

	if(DevKeypadGd == -1)
		return -1;

	/*读输入的状态,如果不是连续IO,先拼成连续IO*/
	ColSta = GPIO_ReadInputData(GPIOF);
	ColSta = (ColSta>>8)&KEYPAD_INIT_STA_MASK;

	/*记录新状态,新状态必须是连续稳定,否则重新计数*/
	if(ColSta != KeyPadCtrl[scanrow].newsta)
	{
		KeyPadCtrl[scanrow].newsta = ColSta;
		KeyPadCtrl[scanrow].dec = 0;
	}

	/*如新状态与旧状态有变化,进行扫描判断*/
	if(ColSta != KeyPadCtrl[scanrow].oldsta)
	{
		uart_printf(" chg--");
		KeyPadCtrl[scanrow].dec++;
		if(KeyPadCtrl[scanrow].dec >= KEY_PAD_DEC_TIME)//大于防抖次数
		{
		    /*确定有变化*/
		    KeyPadCtrl[scanrow].dec = 0;
		    /*新旧对比,找出变化位*/
		    chgbit = KeyPadCtrl[scanrow].oldsta^KeyPadCtrl[scanrow].newsta;
		    uart_printf("row:%d, chage bit:%02x\r\n",scanrow,chgbit);

		    /*根据变化的位,求出变化的按键位置*/
		    u8 i;
		    for(i=0;i<KEY_PAD_COL_NUM;i++)
		    {
			if((chgbit & (0x01<<i))!=0)
			{
			    keyvalue = 	scanrow*KEY_PAD_COL_NUM+i;
			    /*添加通断(按下松开)标志*/
			    if((KeyPadCtrl[scanrow].newsta&(0x01<<i)) == 0)
			    {
				uart_printf("press\r\n");
			    }
			    else
			    {
				uart_printf("rel\r\n");
				keyvalue += KEYPAD_PR_MASK;
			    }
			    /**/
			    KeyPadBuff[KeyPadBuffW] =keyvalue+1;//+1,从1开始,不从0开始
			    KeyPadBuffW++;
			    if(KeyPadBuffW>=KEYPAD_BUFF_SIZE)
				KeyPadBuffW = 0;
			}
		    }

    		    KeyPadCtrl[scanrow].oldsta = KeyPadCtrl[scanrow].newsta;

		}
	}

	/*将下一行的IO输出0*/
	scanrow++;
	if(scanrow >= KEY_PAD_ROW_NUM)
		scanrow = 0;

	GPIO_SetBits(GPIOF, GPIO_Pin_12|GPIO_Pin_13|GPIO_Pin_14|GPIO_Pin_15);

	switch(scanrow)
	{
		case 0:
			GPIO_ResetBits(GPIOF, GPIO_Pin_12);
			break;
		case 1:
			GPIO_ResetBits(GPIOF, GPIO_Pin_13);
			break;
		case 2:
			GPIO_ResetBits(GPIOF, GPIO_Pin_14);
			break;
		case 3:
			GPIO_ResetBits(GPIOF, GPIO_Pin_15);
			break;
	}

}
  1. dev_keypad_init初始化函数,完成IO口初始化,并初始化扫描过程用到的变量。
  2. dev_keypad_scan就是扫描函数,这个函数可以放到定时器,也可以放在main函数的 while状态轮询,轮询间隔会影响防抖效果
  3. 62/63行,读取4根col的状态,如果你的col不是连续的IO,最好在这里拼成连续的,方便下面处理。 估计有同学会问,怎么没有输出对应的ROW就读COL状态? 前面我们说过步骤拆分,在这里就是将“对应ROW输出0电平”,“读COL”,拆分为两个步骤。 上一次退出扫描函数的时候,将下一行对应ROW输出0,等下一次进入扫描的时候才读取COL。为什么? 我们一直强调,CPU很快,如果你对应的ROW输出0,然后立马读COL,间隔很短,IO口电平变化可是要时间的。 有同学又会说了,我知道,IO口电平变化需要可能几百纳秒,那我输出0后,延时1us,再读,应该可以读到真正电平了吧?硬延时1us,通常也是可以接受的。 是的,很多情况下我们也可以这么做,但是,会有意外。 原因就是,电平变化时间除了跟CPU本身性能有关,还跟外部硬件有关,例如PCB板材,按键材料,PCB走线,甚至是环境温度湿度都会影响电平变化。 曾经发生过换了PCB板厂,发到东北的产品发生按键无效事件。
IO电平变化时间由ns上升到us。 我们在上一轮扫描就将对应ROW输出0,下一次轮询的时候读COL状态,间隔通常是ms级的,可以避免上面问题。
  1. 66-70行,意思就是不仅仅是跟上一次的稳定状态不一样(有变化),而且需要在防抖过程中多次读取的状态一样,不一样就重新开始去抖动计数。
  2. 73行到113行就是去抖动跟按键识别。
  3. 73-78,状态变化,而且连续变化次数达到去抖动计数,我们就认为是一个稳定的变化了。
  4. 82,新旧状态异或,找出变化的位。(这样处理的好处就是,当同一行的两个按键同时按下时,我们都可以识别)
  5. 87 行这个for循环的意思就是,每一个col的变化我们都要识别。
  6. 91行,识别到变化按键的物理位置编码。
我们提供的是位置编码,能不能提供功能编码?例如,第一个键是按键1,最后一个键是确认。可以,不建议,而且是非常不建议。 功能是谁的定义?谁关心?应用关心,那就让它去管,反正我按键扫描就告诉你,第一个按键按下,至于是1还是确认,你自己定。 如果扫描给出的键值是功能键值,那就麻烦了,因为这个按键是什么功能,只要客户说一声,然后,改一下丝印。怎么的?要改改底层驱动?
  1. 93-101行,判断是按下还是松开,并在键值上添加标志位。
  2. 103-106,将键值填入缓冲。关于这个缓冲,又是一个驱动设计的要点
很多人写的按键扫描,都是直接通过return返回键值。我就问你,当一个系统比较复杂的时候,合适吗? 明显不合适,假如一个系统,有两个菜单,每个菜单都需要按键功能,难道这两个菜单都调用扫描函数? 这种现象叫什么?叫做耦合:两个不同模块扯在一起。 耦合,是大忌;解耦合才是正道。 解耦合的一个手段就是数据缓冲。 扫描模块扫到键值,丢入缓冲,至于谁要这个按键?I Don’t care! 再提供一个函数用于读缓冲内的键值。谁想要谁读,按键扫描程序自己运行到天荒地老。
  1. 115-135行,就是将下一ROW输出0。

一个按键矩阵按键扫描流程就是这样。 怎么用这个按键扫描呢? 这样用:

main()
{
		dev_keypad_init();
		dev_keypad_open();
		其他初始化

		while(1)
		{
			dev_keypad_scan();
			其他轮询任务
			...。。。
			应用处理
			dev_keypad_read(&key,1);
			读到按键就处理。
		}
}

问题

这个矩阵按键的硬件设计是有缺陷的,会产生幽灵键。 当你按下124三个按键,按键5即使没按下,程序扫描出来的结果也是按下。 要解决这个问题,需要在所有按键上增加二极管。 通常我们会放弃三键按下这种情况,通过软件判断,将三件按下时产生幽灵键的情况抛弃。 具体可以参考文档《矩阵式键盘的先天缺陷与解决方案.docx

总结

矩阵按键扫描曾是我毕业后第一个产品用到的程序。 在领导细心关怀下,花了两周时间才算想清楚各种细节。 我认为如果你能搞清楚矩阵扫描的各种细节,对其他驱动的编写,大有帮助。


end

FreeRtos移植

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


待补充完善

很多同学可能从一开始就盼着这天了。 其实我想说的是,什么操作系统都是纸老虎,等你学会了,就知道,不过如此。 学嵌入式的路程,基本上是:裸奔-RTOS-LINUX。 我们现在就开始结束裸奔。 怎么学RTOS呢?我觉得按照下面四个步骤学习,效果不错。

  1. 了解RTOS的概念。
  2. 找一个合适的RTOS了解概况。
  3. 用RTOS跑起来。
  4. 再回头研究RTOS的实现。

RTOS

什么是RTOS? RTOS = Real Time Operating System,实时操作系统。

基本概念

操作系统,我们电脑上的WINDOW就是一个操作系统。 与WINDOWS对应的,就是我们经常说的linux。但是我们经常有一个误解,其实linux不是一个操作系统,而是操作系统内核。 基于linux内核的操作系统很多,各种LINUX发行版都是,例如红帽,ubuntu等。 安卓系统也是基于linux内核的。

那实时操作系统和普通电脑手机上的操作系统有什么区别呢? 先看看百度百科定义:

实时操作系统(RTOS)是指当外界事件或数据产生时,能够接受并以足够快的速度予以处理,其处理的结果又能在规定的时间之内来控制生产过程或对处理系统做出快速响应,调度一切可利用的资源完成实时任务,并控制所有实时任务协调一致运行的操作系统。提供及时响应和高可靠性是其主要特点。

上面是定义,很多同学学习时,都会认为实时操作系统就是一个响应很快的操作系统。 这个是片面的。实时,确实比WIDOW这些PC操作系统快,但快,不是我们的指标。 实时,更准确的描述是,响应时间确定,稳定。 如果一个响应,说是十秒钟之内会响应,就永远不会11秒才响应,这才叫实时。 WINDOW就不是一个实时系统,很多操作经常会被其他应用卡住,甚至死机。

说到这里,都是书面内容,我们还是不明白RTOS到底是什么鬼。 RTOS通常有以下特点:

小,通常只有几个文件,代码量也很小,需要的内存也很小,通常几K就能跑起来。 如果功能不复杂,任务不多,在一个增强型8051都能运行起来。

还是不懂什么是RTOS,怎么办?

大循环任务架构

我们要一个RTOS来做什么? 我们用RTOS的一个最重要功能,是任务管理。 经过前面的实验,我们知道一个裸奔的嵌入式程序,就是一个大循环,一个while(1)里面的大循环。 所有放在while(1)循环里面的任务,都不能卡死。只要一个任务卡死,其他任务就都不执行了。 为了达到大循环的目的,我们介绍一个程序架构方法:步骤拆分。不知道大家还记得吗? 代码框架如下:

void n_task(void)
{
	switch(步骤)
	{
		case 步骤1:
			break;
		case 步骤2:
			break;
		case 步骤3:
			break;
	}
}

void b_task(void)
{
	switch(步骤)
	{
		case 步骤1:
			break;
		case 步骤2:
			break;
		case 步骤3:
			break;
	}
}

void app_task(void)
{
	switch(步骤)
	{
		case 步骤1:
			break;
		case 步骤2:
			break;
		case 步骤3:
			break;
	}
}

void main(void)
{
	初始化();
	while(1)
	{
		/*  底层驱动 */
		n_task();
		b_task();

		/* 应用流程 */
		app_task();
	}
}

但是,我要说的是:大循环,是有点反人类的。每一个任务(驱动流程),都要去管别人,自己怎么执行的,也会受到别人影响。要很小心的设计步骤,才能解决所有任务的运行矛盾。 如果,一个程序,有复杂的APP,跟底层的矛盾,就更难解决了。而且,复杂的应用层,很难改为大循环。

小循环代码架构

与大循环对应的,就是小循环。 什么是小循环? 每个功能,都写成一个while(1),就是小循环。 在想用while(1)的地方,就用while(1),就是小循环。 例如,我们在按键扫描驱动中,在scan函数中用while(1)一直扫描。 代码框架如下:

void n_task(void)
{
	while(1)
	{
		switch(步骤)
		{
			case 步骤1:
				break;
			case 步骤2:
				break;
			case 步骤3:
				break;
                }
	}
}

void b_task(void)
{
	while(1)
	{
		while(1)
		{
			if(条件符合)
				break;
		}
		while(1)
		{
			if(条件符合)
				break;
		}
		while(1)
		{
			if(条件符合)
				break;
		}
	}
}

void app_task(void)
{
	while(1)
	{
		while(1)
		{
			if(条件符合)
				break;
		}
		while(1)
		{
			if(条件符合)
				break;
			else
			{
				while(1)
				{
					等某种状态
						break;
				}
			}
		}
	}
}

void main(void)
{
	初始化();
	while(1)
	{
		/*  底层驱动 */
		n_task();
		b_task();

		/* 应用流程 */
		app_task();
	}
}

这样每个驱动都是一个死循环,程序怎么跑呢?肯定跑不起来。 嵌入式OS,就是让各个死循环跑起来的代码。

每个TASK我们就叫做任务,OS,就是实现多任务运行。 在每个task里面,可以随便按照你自己的需求写多个小循环。

基本原理

RTOS多任务是怎么实现的呢? 如果你学过汇编,可能就容易理解。 首先,如何打断代码执行?中断,对,就是中断。 每一个RTOS都会有一个定时器中断,我们叫做系统滴答,例如,系统滴答是2ms,那么就是2ms就会产生一次定时中断,这个中断打断了正在执行的程序。在这个定时中运行RTOS的管理代码,这时,控制权在RTOS手上,想怎么搞就怎么搞。普通的中断一般会返回原来的程序位置运行。 系统滴答中断,故意不回到原来的地方,而是切到其他地方运行,那么,就完成了一次任务切换。 为了回到上次的任务,就需要将上次的运行状态记住。等需要的时候,就可以继续执行。

任务管理,是RTOS的最基本功能。为了支持多任务,一个RTOS还需要其他功能,例如任务间通信的各种手段:互斥锁,邮箱,信号等。

FreeRtos

很早之前,说到RTOS,都是说UCOS。 其他,RTOS有很多很多:UCOS、freertos、rtems。。。。 以前UCOS流行,因为他说开源的。但是,其实UCOS开源不免费,商业使用是需要授权费的。 而freertos,是免费的。现在物联网兴起,很多zigbee和wifi芯片都选择免费的freertos。所以这几年freertos爆发了。 我们的代码都是免费的,我们当然选择免费的freertos。

百度百科

FreeRTOS是一个迷你的实时操作系统内核。作为一个轻量级的操作系统,功能包括:任务管理、时间管理、信号量、消息队列、内存管理、记录功能、软件定时器、协程等,可基本满足较小系统的需要。 由于RTOS需占用一定的系统资源(尤其是RAM资源),只有μC/OS-II、embOS、salvo、FreeRTOS等少数实时操作系统能在小RAM单片机上运行。相对μC/OS-II、embOS等商业操作系统,FreeRTOS操作系统是完全免费的操作系统,具有源码公开、可移植、可裁减、调度策略灵活的特点,可以方便地移植到各种单片机上运行,其最新版本为10.0.1版。

官网:https://www.freertos.org/

移植FreeRtos

如何移植? 规范的方法:

  1. 看freertos的程序包,参考他的移植。一个程序要推广,官方会移植到很多平台。 freertos的demo,有170多个芯片的范例,总有一个适合你。
  2. 看平台的例程,我们可以从ST官网找到freertos的移植范例。

我们更多时候,是网络搜索别人移植好的。

下面我们不管参考哪个例程,只说说移植需要考虑的一些细节问题。

  1. 移植前,把所有freertos文件增加ft前缀。

我们已经移植了LWIP,有一些源码文件和freertos重名。 但是我的程序是在所有硬件都编写了驱动之后才移植freertos,代码包含了LWIP。

在移植过程出现了很多错误,例如
..\Utilities\FreeRTOS\Source\timers.c(76): error:  #20: identifier
"TimerCallbackFunction_t" is undefined
  	TimerCallbackFunction_t	pxCallbackFunction;
		/*<< The function that will be called when the timer expires. */
..\Utilities\FreeRTOS\Source\timers.c(104): error:  #20: identifier
"PendedFunction_t" is undefined
  	PendedFunction_t	pxCallbackFunction;
		/* << The callback function to execute. */
..\Utilities\FreeRTOS\Source\timers.c(220): error:  #20: identifier
"TimerCallbackFunction_t" is undefined
  									TimerCallbackFunction_t pxCallbackFunction,
..\Utilities\FreeRTOS\Source\timers.c(279): error:  #20: identifier
"TimerHandle_t" is undefined

但是这些定义命名全部都在timers.h里面有定义。 点开工程timer.c,查看里面的头文件,你会发现竟然包含的是LWIP的头文件。 base/31freertos/pic/1.jpg头文件包含错误 为了避免更多麻烦,决定在freertos所有头文件加上ft前缀 。

还有就是我们选择屏蔽下面代码FreeRTOSConfig.h

//#define vPortSVCHandler SVC_Handler
//#define xPortPendSVHandler PendSV_Handler
//#define xPortSysTickHandler SysTick_Handler

而是直接将这些系统用的函数塞到stm32f4xx_it.c文件中的中断内。 1 保持所有中断响应都在这个文件内处理 2 我们要用与freertos系统无关的延时函数Delay。 3 freeRTOS通常会将systick定义10毫秒,比较粗,我们可能需要将系统滴答精确到毫秒。

同时在FreeRTOSConfig.h文件最后定义,也就是内存分配用我们自己的,不用freertos提供的。

#define pvPortMalloc wjq_malloc
#define vPortFree	wjq_free

尽快启动任务,这样就可以将startup_stm32f40_41xxx里面定义的堆跟栈设置为最少,甚至设置为0

总结


end

简易菜单

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


声明:本处所说的菜单是用在128*64这种小屏幕的菜单,例如下面这种,不是彩屏上的GUI。

_images/pic117.jpg

作为一个底层驱动工程师,驱动写完了,是要写硬件测试程序的。 这个测试程序,一般给测试部/硬件工程师用来测试硬件, 也会给工厂产线测试准成品。

开始的人偷懒,不想一秒就直接上,所有菜单都这样做,一层套一层

void test_main(void)
{
        while(1)
        {
                get_key(&key);
                switch(key)
                {
                        case 1:
                                test_key();
                                break;
                        case 2:
                                test_lcd();
                                break;
                        ....
                }
        }
}

当菜单越来越多,就开始纠结了,这样写维护不便,看起来也不美,还浪费程序空间。

作为一个天天看《编程之美》的码农,决定改变现状。 酷狗百度一番,找到了两个参考: 《基于二叉树的多层的液晶菜单界面设计》 《基于节点编号的通用树状菜单设计方法与实现.pdf》 按照他们的设计方法,鼓捣了一个版本,能用,挺好,但是也纠结。 因为他们用了树这种数据结构。对于程序运行来说,非常好,效率高。 但是对于我来说,菜单代码是一次性的,但是菜单内容,却是会经常改的。 让我用人脑去维护一个包含几十个上百个菜单的树,不容易。

想来想去,这些菜单到底有什么不好?对于我来说,为什么不好用? 得出下面结论:

  1. 管得太宽 菜单,你就管菜单切换就行了,到了最低一层,也就是实际的测试功能,就不要管了。 菜单切换是类似的,实际测试都是不同的。比如在菜单中,按键1,是进入第一个菜单。但是在测试中,按键1,功能都不一样。 如果菜单连这个也要管,相同动作功能太多,无法进行统一抽象,就很难模块化。
  2. 出发点不一样 上面说到的菜单,出发点都是如何设计一个好的菜单数据结构,让程序快速,高效运行。 我想要的却是一个容易维护的菜单结构,至于菜单的代码有多乱多纠结,没关系, 而且,几百上千个菜单,就算用轮询的方法,也不过几百us吧,没关系。

根据需求,我重新设计了一个菜单结构体

/**
 * @brief  菜单对象
*/
typedef struct _strMenu
{
    MenuLel l;     ///<菜单等级
    char cha[MENU_LANG_BUF_SIZE];   ///中文
    char eng[MENU_LANG_BUF_SIZE];   ///英文
    MenuType type;  ///菜单类型
    s32 (*fun)(void);  ///测试函数

} MENU;

是的,就这么简单,每一个菜单都是这个结构体 用这个结构体填充一个列表,就是我们的菜单了


const MENU EMenuListTest[]=
{
        MENU_L_0,//菜单等级
        "测试程序",//中文
        "test",        //英文
        MENU_TYPE_LIST,//菜单类型
        NULL,//菜单函数,功能菜单才会执行,有子菜单的不会执行

                MENU_L_1,//菜单等级
                "LCD",//中文
                "LCD",        //英文
                MENU_TYPE_LIST,//菜单类型
                NULL,//菜单函数,功能菜单才会执行,有子菜单的不会执行
                        MENU_L_2,//菜单等级
                        "VSPI OLED",//中文
                        "VSPI OLED",        //英文
                        MENU_TYPE_FUN,//菜单类型
                        test_oled,//菜单函数,功能菜单才会执行,有子菜单的不会执行

                        MENU_L_2,//菜单等级
                        "I2C OLED",//中文
                        "I2C OLED",        //英文
                        MENU_TYPE_FUN,//菜单类型
                        test_i2coled,//菜单函数,功能菜单才会执行,有子菜单的不会执行

                MENU_L_1,//菜单等级
                "声音",//中文
                "sound",        //英文
                MENU_TYPE_LIST,//菜单类型
                NULL,//菜单函数,功能菜单才会执行,有子菜单的不会执行
                        MENU_L_2,//菜单等级
                        "蜂鸣器",//中文
                        "buzzer",        //英文
                        MENU_TYPE_FUN,//菜单类型
                        test_test,//菜单函数,功能菜单才会执行,有子菜单的不会执行

                        MENU_L_2,//菜单等级
                        "DAC音乐",//中文
                        "DAC music",        //英文
                        MENU_TYPE_FUN,//菜单类型
                        test_test,//菜单函数,功能菜单才会执行,有子菜单的不会执行

                        MENU_L_2,//菜单等级
                        "收音",//中文
                        "FM",        //英文
                        MENU_TYPE_FUN,//菜单类型
                        test_test,//菜单函数,功能菜单才会执行,有子菜单的不会执行

                MENU_L_1,//菜单等级
                "触摸屏",//中文
                "tp",        //英文
                MENU_TYPE_LIST,//菜单类型
                NULL,//菜单函数,功能菜单才会执行,有子菜单的不会执行

                        MENU_L_2,//菜单等级
                        "校准",//中文
                        "calibrate",        //英文
                        MENU_TYPE_FUN,//菜单类型
                        test_cal,//菜单函数,功能菜单才会执行,有子菜单的不会执行

                        MENU_L_2,//菜单等级
                        "测试",//中文
                        "test",        //英文
                        MENU_TYPE_FUN,//菜单类型
                        test_tp,//菜单函数,功能菜单才会执行,有子菜单的不会执行

                MENU_L_1,//菜单等级
                "按键",//中文
                "KEY",        //英文
                MENU_TYPE_FUN,//菜单类型
                test_key,//菜单函数,功能菜单才会执行,有子菜单的不会执行

        /*最后的菜单是结束菜单,无意义*/                        
        MENU_L_0,//菜单等级
        "END",//中文
        "END",        //英文
        MENU_TYPE_NULL,//菜单类型
        NULL,//菜单函数,功能菜单才会执行,有子菜单的不会执行
};

这个菜单列表有什么特点和要求呢? 1 需要一个根节点和结束节点 2 子节点必须跟父节点,类似下面结构

-----------------------------------------------
根节点
        第1个1级菜单
                       第1个子菜单
                       第2个子菜单
                       第3个子菜单
        第2个1级菜单
                       第1个子菜单
                                     第1个孙菜单
                                     第2个孙菜单
                       第2个子菜单
                       第3个子菜单
        第3个1级菜单
        第4个1级菜单
        第5个1级菜单
结束节点
------------------------------------------------

第2个1级菜单有3个子菜单,子菜单是2级菜单,其中第1个子菜单下面又有2个孙菜单(3级菜单)。

维护菜单,就是维护这个列表,添加删除修改,非常容易。 那菜单程序怎么样呢?管他呢。 定义好菜单后,通过下面函数运行菜单,

 emenu_run(WJQTestLcd, (MENU *)&WJQTestList[0], sizeof(WJQTestList)/sizeof(MENU), FONT_SONGTI_1616, 2);        

-第1个参数是在哪个LCD上显示菜单, -第2个是菜单列表, -第3个是菜单长度, -第4个四字体, -第5则是行间距

注意: 运行这个菜单需要有rtos,因为菜单代码是while(1)的,陷进去就不出来了。 需要有其他线程(TASK)维护系统,例如按键扫描。

代码托管在github:https://github.com/wujique/stm32f407/tree/sw_arch 相关文件:emenu.c、emenu.h、emenu_test.c

当前代码: 1实现了双列菜单,用数字键选择进入下一层。每页最多显示8个菜单(4*4键盘用1-8键) 2 实现了单列菜单,通过上下翻查看菜单,确认键进入菜单。 3 天顶菜单未实现,谁有兴趣可以加上。 3 基于LCD驱动架构,这个简易菜单自适应于多种LCD。

效果如下,有需要的尽管拿去,不用谢。

显示效果

128*64 OLED

_images/oled1.jpg _images/oled2.jpg

128*128 tft lcd

_images/tft1.jpg _images/tft2.jpg

320*240 tft lcd

_images/tft3.jpg _images/tft4.jpg

总结

类似菜单在我开发的产品上已经推广使用。 经测试,可以明显减少测试程序代码量,节省程序空间。 并且易于修改和维护。


end

系统测试程序

够用的硬件

能用的代码

实用的教程

屋脊雀工作室编撰 -20190101

愿景:做一套能用的开源嵌入式驱动(非LINUX)

官网:www.wujique.com

github: https://github.com/wujique/stm32f407

淘宝:https://shop316863092.taobao.com/?spm=2013.1.1000126.2.3a8f4e6eb3rBdf

技术支持邮箱:code@wujique.com、github@wujique.com

资料下载:https://pan.baidu.com/s/12o0Vh4Tv4z_O8qh49JwLjg

QQ群:767214262


待补充完善

在提供底层驱动程序给APP使用前,需要完成底层测试。 驱动测试程序一般由对应驱动人员编写,类似白盒测试。并且将部分测试程序用于硬件生产测试。 主要有以下几点要求:

  1. 测试程序要使用提供给APP的接口编写。
  2. 要测试到软件接口的各种边界。
  3. 测试硬件的步骤,测试案例要能充分暴露硬件问题。

下面我们对我们硬件的测试程序做简要说明。

1 Lcd

  1. 彩屏LCD,需要测试显示图像,仅仅显示三原色,不能保证并口是连接完好。

2 矩阵键盘

  1. 在做生产测试时,要限制流程,不测试完按键不能直接退出,需要进一步确认退出才能退出。 防止测试人员遗漏测试部分按键。

end

版权说明

	1 源码归屋脊雀工作室所有。
	2 源码可以用于的其他商业用途(配套开发板销售除外),不须授权。
	3 屋脊雀工作室不对代码功能做任何保证,请使用者自行测试,后果自负。
	4 可随意修改源码并分发,但不可直接销售本代码获利,并且保留版权说明。
	5 如发现BUG或有优化,欢迎发布更新。请联系:code@wujique.com
	6 使用本源码则相当于认同本版权说明。
	7 如侵犯你的权利,请联系:code@wujique.com
	8 保留所有文档所有权利。
	9 一切解释权归屋脊雀工作室所有。