STM32单片机学习教程_stm32单片机教程,2024年最新32岁的程序员被裁

先自我介绍一下,小编浙江大学毕业,去过华为、字节跳动等大厂,目前阿里P7

深知大多数程序员,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年最新Golang全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友。
img
img
img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

如果你需要这些资料,可以添加V获取:vip1024b (备注go)
img

正文

../../_images/b03_29.png

5. IO&点亮LED

5.1. 概述

芯片如何控制外部电路?

从本课开始,让我们一点一点把单片机系统的知识框架搭建起来。

本课,学习最简单的、也是最基本的:GPIO口。

本课讲以下几个问题:

  1. IO是什么?GPIO是什么?
  2. STM32的IO有什么特点?
  3. LED如何使用?
  4. 编码和调试过程(这段看视频更好)

5.2. IO 是什么?

IO是Input&Output,也就是输入和输出。

我们选的STM32F103VET6这款芯片的,管脚总共有100根。

但是《STM32F103数据手册.pdf》中有写:IO口只有80,为什么?

[外链图片转存中…(img-IdmDlxFt-1645022852660)]

在数据手册的第14页有管脚定义图,如下:

[外链图片转存中…(img-hdMxFjjm-1645022852661)]

从管脚名称看到,P前缀的的管脚, 就是IO口。100脚的STM32,有PA、PB、PC、PD、PE,一共5组IO。

除了IO,还有电源和地、晶振输入、BOOT配置等其他功能的管脚。

  1. 普通电源有5组。
  2. 模拟电源有1组。
  3. 备份电源一组。
  4. 参考电压VREF有一组。
  5. 复位脚和启动配置BOOT0各一根管脚。
  6. 晶振两组,其中RTC晶振可做普通IO使用。
  7. 73脚是空的,没有连接。

有些朋友可能有疑问,怎么都没看到SPI、I2C等功能的管脚?

通常IO口都能复用作其他功能,比如SPI、I2C等。在数据手册的管脚定义中有几页说明管脚可以复用做什么功能。

比如:

[外链图片转存中…(img-ZykRaYfG-1645022852661)]

PA0,主功能是PA0,可用作串口的CTS、ADC、TIM6、TIM2、TIM8等功能。

从此可见,我们说IO功能,通常只是一个IO口的基本功能:GPIO, 通用输入输出的意思。

5.2.1. GPIO功能

GPIO能用来做什么呢?

要理解这个GPIO,先要定下一个概念:除了DA转换和AD转换, 其他的IO口都是数字逻辑功能

对GPIO来说,功能很简单,分两个:

  1. 输出-在管脚上输出数字逻辑电平。
  2. 输入-检测管脚的逻辑电平

芯片用的是TTL电平:

数字电平有两种,高电平和低电平。

高电平是逻辑1,低电平是逻辑0.

高电平是芯片的IO电压,STM32没有独立IO电压,IO电压就是是芯片工作电压,低电平是就地电平。

实际上呢,高电平和低电平是有一个范围的,并不仅仅是3.3V和0V

5.2.2. GPIO驱动能力

一个IO口输出电流或输入电流的能力。

在中文版数据手册 第31页中,有下面这个表格:

[外链图片转存中…(img-Xk53TSCj-1645022852661)]

灌电流和拉电流都是25mA

在设计外围电路时,电流不能超过这个值,否则会烧芯片。比如大电流的LCD背光,就需要在外部添加三极管驱动电路。

还有一个需要注意的,有些芯片,灌电流和拉电流的最大值不一样,灌电流可能只有5ma。

5.3. STM32 IO特点

以前的单片机,比如8051,GPIO口非常简单,只要设置GPIO口的方向是输出还是输入,就可以工作了。用起来虽然简单,却无法满足各种应用场景。因此,高级的芯片,IO口通常有很多功能可以配置。

STM32的GPIO就是这样,如果操作寄存器操作的话,相当复杂。在《STM32F10x微控制器参考手册.pdf》中,第七章就是讲GPIO功能的。

../../_images/b04_05.png

  • 首先要知道,IO口有8种模式:

../../_images/b04_06.png

其中GPIO用到的有5种。模拟输入是AD转换使用。推挽复用和开漏复用模式用于GPIO外的其他外设功能,比如SPI。

GPIO五种模式,要区分输入和输出:浮空、上拉、下拉,都是说输入。推挽、开漏,是输出。

驱动LED用输出功能。那选推挽还是开漏模式呢?要先搞清楚推挽和开漏输出的区别。

推挽开漏
高电平驱动能力外部上拉电阻提供
低电平驱动能力
电平转换速度外部上拉电阻决定,电阻越小,反应越快,功耗越大
线与功能不支持支持
电平转换不支持支持
  1. 开漏模式,IO口内部没有上拉电阻,没有接MOS管,所以开漏电路不能输出高电平;要输出高电平,需要外部接上拉电阻,如果外部上拉电阻接的电压不是芯片IO电压,就相当于实现了IO口电平转换功能。

比如,推挽模式下,IO口输出高电平就是3.3V,用开漏模式,外部接电阻上拉到5V,那么输出高电平就是5V。
2. 电平转换速度指芯片0/1翻转的速度。
3. 线与,两根IO直接连接在一起,电平按照与逻辑。通常用在一些可挂载多设备的总线上,比如I2C。

原理参考:https://www.cnblogs.com/lweleven/p/mcuioout.html

因此,驱动LED用推挽还是开漏?除了必须用开漏的场合,我们都习惯用推挽输出

  • 第二,SMT32的IO有IO速度需要配置。

[外链图片转存中…(img-VKheACVn-1645022852662)]

如果不知道如何选,全部用50M,功能肯定正常。但是可能会增加电流,增加EMC辐射。

经验:

普通功能的IO,通常2M就可以了。

如果一个IO用作I2C通信,速度通常就10K到400K,选10M就好了。

如果是用作SPI功能,可能会到20M速度,那就要选50M了。

到此,我们基本了解了STM32 GPIO的功能。下面看看ST的库都提供了什么函数给我们用。

5.4. ST库函数

打开上一节我们创建的工程。在库函数中找到stm32f10x_gpio.c和stm32f10x_gpio.h

函数有下面这些:

void GPIO_DeInit(GPIO_TypeDef* GPIOx);
void GPIO_AFIODeInit(void);
void GPIO_Init(GPIO_TypeDef* GPIOx, GPIO_InitTypeDef* GPIO_InitStruct);
void GPIO_StructInit(GPIO_InitTypeDef* GPIO_InitStruct);
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_PinLockConfig(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);
void GPIO_EventOutputConfig(uint8_t GPIO_PortSource, uint8_t GPIO_PinSource);
void GPIO_EventOutputCmd(FunctionalState NewState);
void GPIO_PinRemapConfig(uint32_t GPIO_Remap, FunctionalState NewState);
void GPIO_EXTILineConfig(uint8_t GPIO_PortSource, uint8_t GPIO_PinSource);
void GPIO_ETH_MediaInterfaceConfig(uint32_t GPIO_ETH_MediaInterface);

GPIO_Init:初始化IO口,

GPIO_SetBits:IO口输出1

GPIO_ResetBits:IO口输出0

GPIO_WriteBit:IO口输出状态,相当于GPIO_SetBits和GPIO_ResetBits组合。

GPIO_Write:输出IO口状态。

GPIO_WriteBit是在指定的IO口上输出相同的状态,GPIO_Write是在一组IO上输出需要的状态,。

我们看参数:

GPIO_SetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);

将GPIOx这组IO口中,GPIO_Pin指定的IO口,输出高电平。

GPIO_ResetBits功能和GPIO_SetBits相反。

GPIO_WriteBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, BitAction BitVal);

将GPIOx这组IO口中GPIO_Pin指定的IO口设置为BitVal的状态。

GPIO_Write(GPIO_TypeDef* GPIOx, uint16_t PortVal);

将GPIOx这组IO口设置为PortVal的状态。注意,是一次设置一组IO

我们在来看看初始化的接口

void GPIO_Init(GPIO_TypeDef* GPIOx, GPIO_InitTypeDef* GPIO_InitStruct)

关键是第二个参数,这是一个结构体,定义如下:

typedef struct
{
uint16_t GPIO_Pin; /*!< Specifies the GPIO pins to be configured.
This parameter can be any value of @ref GPIO_pins_define */

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

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

第1个参数GPIO_Pin指定要配置的IO口。

第2个参数GPIO_Speed配置IO口速度。

第3个参数GPIO_Mode配置IO口模式。

其中GPIO_Speed和GPIO_Mode类型是枚举,如下:

  • 速度

typedef enum
{
GPIO_Speed_10MHz = 1,
GPIO_Speed_2MHz,
GPIO_Speed_50MHz
}GPIOSpeed_TypeDef;

  • 模式

typedef enum
{ GPIO_Mode_AIN = 0x0,
GPIO_Mode_IN_FLOATING = 0x04,
GPIO_Mode_IPD = 0x28,
GPIO_Mode_IPU = 0x48,
GPIO_Mode_Out_OD = 0x14,
GPIO_Mode_Out_PP = 0x10,
GPIO_Mode_AF_OD = 0x1C,
GPIO_Mode_AF_PP = 0x18
}GPIOMode_TypeDef;

配置IO的时候,选用这里的定义即可。

我们看下例程,在目录STM32F10x_StdPeriph_Lib_V3.5.0\Project\STM32F10x_StdPeriph_Examples\GPIO\IOToggle

中的main函数,初始化IO口的代码如下:

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

/* GPIOD Periph clock enable */
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD, ENABLE);

/* Configure PD0 and PD2 in output pushpull mode */
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_2;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_Init(GPIOD, &GPIO_InitStructure);

  1. 调用RCC_APB2PeriphClockCmd函数打开GPIOD时钟。

这个是需要注意的,ST的芯片每个设备都有时钟,使用之前都需要打开。

  1. GPIO_Pin设置为GPIO_Pin_0 | GPIO_Pin_2, 说明过一次配置两个IO口。
  2. 速度配置为GPIO_Speed_50MHz, 也就是50M
  3. GPIO_Mode设置为GPIO_Mode_Out_PP, 也就是输出推挽模式。
  4. 调用函数GPIO_Init进行配置。

5.5. 如何使用LED

LED是什么?

发光二极管简称为LED。因化学性质又分有机发光二极管OLED和无机发光二极管LED。

它本质上是一个二极管,所以就有正负极。

加上电压就会亮,但是LED其实是一个电流器件,有电流了才会亮。

电流越大,亮度越大,但是不能超过规格。

在资料文件夹内有一个发光二极管的规格书。LED的规格书中有一个很重要的参数。 《黄绿 0603 (33_40mcd)_PDF_C2289_2015-07-23.pdf》

[外链图片转存中…(img-F2AKdT1W-1645022852663)]

第1行,顺向电流,就是LED的工作电流,不能超过20mA。

LED驱动有两种方式:低电平(灌电流)和高电平(拉电流),如下图

[外链图片转存中…(img-8BqsnSrL-1645022852663)]

我们的板子选用灌电流模式,4位LED电路原理图如下:

[外链图片转存中…(img-NPD26xgg-1645022852663)]

LED正极通过一个限流电阻接到VCC3V3,也就是高电平。负极接到IO口。

当IO口输出低电平,电流从电阻流过,流过LED,流入IO口。LED就会发光。

限流电阻是防止流过LED的电流大于LED的顺向电流最大值。

电流的计算可以粗略如下:

I=(3.3-0.6)/1K = 2.7mA

这个电路的接法,电流是流入IO口的,就是灌电流。

5.6. 编码与调试

请看视频,文档待补充

  1. 对原理图 找到对应的IO, PE15 PD8 PD9 PD10
  2. 拷贝例程初始化代码,修改为我们的IO口。
  3. 单步运行,发现 还没设置IO口,只是初始化 就亮了, 为什么?
  4. 修改,先设置输入输出的值,再初始化IO口。
  5. 写流水灯,无延时, 单步运行,流水灯功能正常。全速,没有出现流水,但是亮度变暗了。为什么?

讲程序,简一点C语言的编码知识。

十进制 十六进制的数值定义。

加延时 关键字 volatile

C 语言知识点: 宏定义

5.7. 作业问题

问题, 为什么代码能控制IO口?

看库函数到底做了什么

6. 提高效率的工具

si

beyondcompare

参考百度网盘,目录W108_F103_Tech\ref\5 tool 目录中的文档。

7. 点亮数码管

本节课利用已经学习的LED知识去控制一个8位数码管。

本节的原理比较简单。不需要多少时间讲。

更多时间是跟大家一起编码调试,从中学习一些编码思路和学习方法。

7.1. 什么是数码管

数码管是什么?下图就是一个数码管

../../_images/pic1.png

从硬件上个看,其实就是8个LED组合在一起。8个LED应该有16个引脚,但是数码管上只有10个引脚。为什么呢?

请看下图:

[外链图片转存中…(img-8wLAw7QT-1645022852664)]

1个LED有两个引脚,要控制LED,1个引脚接控制信号,另外一个引脚接电源或者地(高驱动或低驱动,下同)。

那么,当有8个LED,只需要8根IO口控制状态,其他IO全部接到地或者电源即可。

当用高驱动时,LED负极全部接到地,这种数码管就叫做共阴极数码管。

当用低电平驱动时,LED正极全部接到电源,这种数码管就叫做共阳极数码管。

数码管实物中,小数点LED通常单独引出两个引脚,由我们在电路图上连接在一起。

7.2. 原理图

从原理可知,控制数码管需要8根IO口。下图就是原理图。

../../_images/pic3.png

IO口选择PE7—PE14,这8个IO口是连续的,方便代码控制。

共阴极数码管,所有负极接到地。正极通过1个限流电阻接到控制IO口。

7.3. 接口设计

什么是接口?

  1. 两件事物之间的交互通道叫做接口。
  2. 软件中,应用程序控制硬件用的函数,就是接口。
  3. 从上往下看,即用户角度看硬件,用户想要什么功能?
  4. 从下往上看,硬件有什么功能?能提供什么功能?(注意二者区别)

刚刚开始学编程,这些设计理念可以了解,慢慢实践

一位数码管有什么功能?

  1. 首先,有8个LED可以点亮。
  2. 然后,8个数码管可以组成数字。

从用户角度看,我们用数码管做什么呢?通常我们需要的功能是显示数字,而不是点亮某个段。

所以,我们就定义数码管的功能是:显示数字

函数接口如下:

/*
定义一个seg_display
输入参数有2个,分别是char型的num,char型的dot
没有返回值。
*/
void seg_display(char num, char dot)

num就是要显示的数字:0~9

dot表示要不要点亮小数点

到此,我们编码前的学习和设计就完成了,下面开始实现功能。

7.4. 编码调试

  1. 第一步,点亮LED

这一步在上一节调试LED时已经学过,代码如下:

/*
调用库函数RCC_APB2PeriphClockCmd
传入两个参数RCC_APB2Periph_GPIOD,ENABLE
RCC_APB2Periph_GPIOD是一个宏定义,
ENABLE是一个新定义的枚举类型FunctionalState
*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOE, ENABLE);

GPIO_ResetBits(GPIOE, GPIO_Pin_7|GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_11|GPIO_Pin_12|GPIO_Pin_13|GPIO_Pin_14);
/* Configure PD0 and PD2 in output pushpull mode /
/
Configure PD0 and PD2 in output pushpull mode */
/*赋值给结构体变量GPIO_InitStructure的成员,
注意,GPIO_InitStructure是实体,所以用点,
如果是一个结构体指针,就用->
GPIO_InitStructure->GPIO_Pin
*/
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_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_Init(GPIOE, &GPIO_InitStructure);

GPIO_SetBits(GPIOE, GPIO_Pin_7);
GPIO_SetBits(GPIOE, GPIO_Pin_8);
GPIO_SetBits(GPIOE, GPIO_Pin_9);
GPIO_SetBits(GPIOE, GPIO_Pin_10);
GPIO_SetBits(GPIOE, GPIO_Pin_11);
GPIO_SetBits(GPIOE, GPIO_Pin_12);
GPIO_SetBits(GPIOE, GPIO_Pin_13);
GPIO_SetBits(GPIOE, GPIO_Pin_14);

几个关键点:

  1. 记得打开IO口时钟。
  2. 先设置状态,再配置IO口为输出。防止IO口配置完后LED闪一下

(特别是在控制电机时,一定要配置为确定状态后再将GPIO外设连接到IO口)
3. 然后调用GPIO_SetBits控制IO口。
4. 用调试器一个一个LED数码管轮流测试,看是不是能点亮、熄灭。

为什么要一个一个调试?因为这样测试可以要测试出硬件上IO口短路的情况。

如果8个IO口一起控制亮灭,就无法知道IO口有没有短路。

  1. 用直接控制IO的方法实现接口

接口函数原型我们已经定义好:

void seg_display(char num, char dot)

既然我们都会控制LED了,那么,就用控制LED的方法实现这个函数。

GPIO_ResetBits(GPIOE, GPIO_Pin_7|GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_11|GPIO_Pin_12|GPIO_Pin_13|GPIO_Pin_14);

if(dot == 1)
{
GPIO_SetBits(GPIOE, GPIO_Pin_7);
}

switch(num)
{
case 0:
GPIO_SetBits(GPIOE, GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_11|GPIO_Pin_12|GPIO_Pin_13);
break;

case 1:
GPIO_SetBits(GPIOE, GPIO_Pin_9|GPIO_Pin_10);
break;

case 2:
GPIO_SetBits(GPIOE, GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_11|GPIO_Pin_12|GPIO_Pin_14);
break;

case 3:
GPIO_SetBits(GPIOE,GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_11|GPIO_Pin_14);
break;

case 4:
GPIO_SetBits(GPIOE, GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_13|GPIO_Pin_14);
break;

case 5:
GPIO_SetBits(GPIOE, GPIO_Pin_8|GPIO_Pin_10|GPIO_Pin_11|GPIO_Pin_13|GPIO_Pin_14);
break;

case 6:
GPIO_SetBits(GPIOE, GPIO_Pin_8|GPIO_Pin_10|GPIO_Pin_11|GPIO_Pin_12|GPIO_Pin_13|GPIO_Pin_14);
break;

case 7:
GPIO_SetBits(GPIOE, GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10);
break;

case 8:
GPIO_SetBits(GPIOE, GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_11|GPIO_Pin_12|GPIO_Pin_13|GPIO_Pin_14);
break;

case 9:
GPIO_SetBits(GPIOE, GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_13|GPIO_Pin_14);
break;

default:
break;
}

进入函数后,先把所有的LED都熄灭。

然后用if语句判断dot参数,如果等于1,就点亮小数点。

再用switch语句,根据num的值,点亮不同的LED,组成num指定的数字。

如此,就是实现了功能,在main函数中调用这个函数,就能显示指定的数字了。(先用调试器加断点运行查看执行结果)
3. 用同时操作一组IO口的方法

上面的方法尽管实现了功能,但是你有没有觉得,这么简单的功能用了这么复杂的代码,是不是很不美?代码也很啰嗦。

其实我们有更简洁的方法。很多同学可能想到直接把GPIO_SetBits函数的第二个参数定义为一个数字,看起来就更简洁了。

我们说的不是这种优化,我们能把代码优化得很简单。(用GPIO_SetBits也可以优化,大家自己实验

先看下GPIO_SetBits和GPIO_ResetBits函数。这两个函数操作不同的寄存器,对指定的IO进行置位或清零。

如果我们去查规格书,可以发现我们可以直接设置一个寄存器输出0或1,而不是置位或清零操作。不用看寄存器,直接看库函数也能看到。

注意额,不是GPIO_WriteBit,这个函数只是将GPIO_SetBits和GPIO_ResetBits组合使用。

我们说的是GPIO_Write,这个函数直接写ODR寄存器,你写入什么值,IO口就输出什么值。

用这个函数还有一个好处:将8个LED当做一个整体。

代码如下:

if(dot == 1)
{
GPIO_SetBits(GPIOE, GPIO_Pin_7);
}

switch(num)
{
case 0:
GPIO_Write(GPIOE, 0x3f00);
break;

case 1:
GPIO_Write(GPIOE, 0x0600);
break;

case 2:
GPIO_Write(GPIOE, 0x5b00);
break;

case 3:
GPIO_Write(GPIOE,0x4f00);
break;

case 4:
GPIO_Write(GPIOE, 0x6600);
break;

case 5:
GPIO_Write(GPIOE, 0x6d00);
break;

case 6:
GPIO_Write(GPIOE, 0x7d00);
break;

case 7:
GPIO_Write(GPIOE, 0x0700);
break;

case 8:
GPIO_Write(GPIOE,0x7f00);
break;

case 9:
GPIO_Write(GPIOE, 0x6700);
break;

default:
break;
}

这种方法跟置位方法的区别是:

置位需要两步,先清所有IO。

再置位要点亮的IO。

GPIO_Write一步就可以将所有IO设置为指定值。

  1. 用查表法

表是什么?表,就是数组

从上面的switch我们可以看出,不同数字对应不同的值。而且:

switch的参数num是连续的值0~9

因此我们可以用表获取要写到IO口的值,然后,抛弃switch。

/*
定义一个全局数组SegTab,数组成员类型是uint16_t
并初始化数组。
这个数组是数码管显示0-9的段定义。
请看seg_display函数,
例如,第一个值是0x3f00
在seg_display,取这个数,输出到IO口,LED就能显示0。
*/
uint16_t SegTab[10]={0x3f00, 0x0600, 0x5b00, 0x4f00, 0x6600, 0x6d00, 0x7d00, 0x0700, 0x7f00, 0x6700};

if(dot == 1)
{
GPIO_SetBits(GPIOE, GPIO_Pin_7);
}

if(num >= 10)
return;

GPIO_Write(GPIOE, SegTab[num]);

用了表,SegTab表中的值就是对应的数码管点亮值。

很长的switch变为一行代码。

  1. 修复同时控制一组IOBUG

用上面的函数做实验,我们会发现,其他IO口也被我们控制了,不应该这样。

原因是GPIO_Write一次性写一组IO,但是我们只是用了其中的8个IO。另外8根IO也被我们输出为0了。

解决这个问题的方法就是:

读回–>修改–>写进

记住,这是一个重要方法。

代码如下:

uint16_t tmp;

if(dot == 1)
{
GPIO_SetBits(GPIOE, GPIO_Pin_7);
}

if(num >= 10)
return;

tmp = GPIO_ReadOutputData(GPIOE);
tmp = tmp&0x80ff;
tmp = tmp | SegTab[num];
GPIO_Write(GPIOE, tmp);

GPIO_ReadOutputData读回当前GPIOE的输出值,注意,是读输出值,而不是输入值

位与上0x80ff,意思是将为0的位清零,这些位就是我们准备要设置的IO口。

位或上要写的值SegTab[num],或的功能是有1为1。

再输出。

如此,GPIO_Write操作就只会改变数码管的IO口。

请先理解位与和位或。

和逻辑与逻辑或是不一样的。

  1. 小数点也合进来。

小数点的IO正好也在GPIOE,同一组IO口,可以合并进来。

最终代码

/*
写一个IO口,回读–写模式
为什么呢?因为我们只是使用了一个IO口中的几个管脚
比如GPIOE,一共有16个脚, 我们只是用了8个脚。
GPIO_Write函数是一次性设置16个脚。
如果不回读直接设置,那么,除了我们使用的8个脚之外的脚就会被意外改变。
/
tmp = GPIO_ReadOutputData(GPIOE);
/清空我们使用的几个管脚对应的位/
tmp = tmp&0x807f;//位与,注意和&&的区别,&&是逻辑比较
/
将我们要使用的几个管脚设置为我们需要的值,
比如,显示0,那么值就是 SegTab[0], 也就是0x3f00,
或操作是有1为1.
那么,经过下面的或操作,
我们的管脚,需要设置为1的位,就会是1,
我们不使用的管脚,原来是1的,现在也不会被改变,还是1.
*/
tmp = tmp | SegTab[num];//位或,注意和||的区别

if(dot == 1)
{
/*
如果需要显示数码管的小数点,就将对应位设置为1
0x0080, 为1的位是bit7,因为数码管的小数点接在GPIOE.7上。
*/
tmp = tmp | 0x0080;
}
GPIO_Write(GPIOE, tmp);

本文档没列出所有代码,请查看例程代码获取完整版本。

7.5. 结束

SegTab这个数组,就是在数码管这个现实设备上显示数字的点阵字库。

8. 程序各种要素说明

这节课我们用一个最简单的程序跟大家讲清楚程序的构成。(请看视频)

8.1. 概述

  • 硬件

首先要知道硬件的组成。

在前面章节我们说过,芯片包含FlashRAM

他们虽然不是相同的东西,但是都属于同一个地址空间,32位芯片的地址空间大小是4G。

比如ST32,FLASH通常从0X8000000开始,而RAM就从0x20000000开始。

高级点的芯片,可能会有外部SDRAM,内核也会为这SDRAM分配一段地址。

地址,就是地址,比如你们家的门牌号,酒店的房间号。

TODO添加STM32芯片地址映射图。

  • 程序

程序包含什么?

写代码的时候包含函数过程变量

编译得到的目标文件包含函数过程和变量的初始化值

  • 变量

变量有很多种:全局变量,局部变量、静态变量。。。

变量保存在哪里?

下面我们就从一个简单的程序来分析上面问题。

8.2. 包罗万象的小程序

8.2.1. 程序入口

程序入口,程序启动执行的第一条代码就叫程序入口。

或者说,芯片上电开始执行的第1条用户代码。

这条代码在哪?

我们写代码,通常都是从main函数开始写,我们也会把main函数叫做函数入口。

那么main函数是芯片复位的第一条代码吗?

实际不是,在执行main函数之前,已经执行了很多代码了。

其中最早执行的,也就是芯片复位的第一条代码,就是我们经常说的启动代码。

在我们的STM32工程中,启动代码就是startup_stm32f10x_hd.s。

这是一个汇编文件。我们一起来看看这个启动代码。这个文件是一个汇编文件。

; 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
DCD 0 ; Reserved

这就是启动代码的入口。但是这里放的并不是代码,而是函数指针,这些函数指针就是中断向量。

DCD的意思是分配一个空间来保存后面的值。

__Vectors是一个标号,等下在分散加载文件中会提到。

现在我们只要知道这里保存的是中断向量,并且,复位也是一个中断。

当芯片复位时,芯片从这里找到对应的函数指针Reset_Handler,然后跳到这个函数执行。

这个函数同样在启动文件中,如下:

; Reset handler
Reset_Handler PROC
EXPORT Reset_Handler [WEAK]
IMPORT __main
IMPORT SystemInit
LDR R0, =SystemInit
BLX R0
LDR R0, =__main
BX R0
ENDP

复位后芯片做了什么呢?

  1. 调用SystemInit函数。
  2. 调用__main。

SystemInit在system_stm32f10x.c文件中,这个函数完成芯片的时钟配置。

__main函数在哪呢?在工程中找不到的。是不是main?不是。

这是一个编译系统根据不同芯片生成的一个库函数。

在这个库函数中完成变量(RAM)的初始化,居然后跳到真正的main函数执行。

8.2.2. 函数

int main(void)是我们接触的第一个函数。

函数的定义包含名称、参数、返回值。

我们可以定义一些子函数。

8.2.3. 变量
  • 全局变量

在函数外定义的叫做全局变量。

比如main函数中,SegTab就是一个全局变量,这个变量的类型是一个uint16_t数组。

/*
定义一个全局数组SegTab,数组成员类型是uint16_t
并初始化数组。
这个数组是数码管显示0-9的段定义。
请看seg_display函数,
例如,第一个值是0x3f00
在seg_display,取这个数,输出到IO口,LED就能显示0。
*/
uint16_t SegTab[10]={0x3f00, 0x0600, 0x5b00, 0x4f00, 0x6600, 0x6d00, 0x7d00, 0x0700, 0x7f00, 0x6700};

变量是保存在RAM中的,我们都知道RAM是易失性存储,掉电数据就没了,那数组的些值是如何赋值给数组的呢?

这问题有两个方面:

  1. 编译的时候,这些值会保存在代码中。同时还保存这些值和变量的关系。(细节暂时不研究)
  2. 在启动代码中,执行__main函数时,会根据这些关系执行初始化变量的过程,然后才执行用户的main函数。
  3. 这个过程就是编译器生成的,如果你用一些很便宜的单片机,比如台湾的一些小单片机,这个过程就需要自己写代码实现,通常是用汇编写。
  • 局部变量

在函数内定义的变量就是局部变量,例如seg_display函数中的tmp就是一个局部变量。

/*
定义一个seg_display
输入参数有2个,分别是char型的num,char型的dot
没有返回值。
*/
void seg_display(char num, char dot)
{
uint16_t tmp;

局部变量同样也是在RAM上。但是具体在哪呢?地址是哪里?

局部变量的地址是不固定的。当调用函数时,从栈上分配。函数退出后就释放了。

  • 变量有效域

局部变量只在函数中有效。

全局变量呢?

这个不是芯片的知识,是C语言的知识。和编译系统也有关系,在MDK中,全局变量在声明之后的C代码中都可以调用。

还可以通过EXTERN在外部文件中声明后调用。

局部变量可以通过static定义成类似全局变量,但仅限本函数使用。

static还可以限制全局变量只在本文件有效。

8.3. 分散加载文件

为什么启动代码就是上电执行的第一条指令呢?

因为我们用分散加载文件(链接文件)指定启动代码保存在芯片复位时指向的位置。

分析分散加载文件

; ************************************************************* ; *** Scatter-Loading Description File generated by uVision *** ; *************************************************************

LR_IROM1 0x08000000 0x00080000 { ; load region size_region ER_IROM1 0x08000000 0x00080000 { ; load address = execution address *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00010000 { ; RW data .ANY (+RW +ZI) } }

定义了IROM1,地址和范围就是芯片Flash的定义。

其中,放在最全面的是reset,也就是启动代码中定义的AREA RESET, DATA, READONLY

紧接则放置的是InRoot$$Sections,这些代码是编译器链接时根据芯片和内核自动添加的。

我们可以认为这就是__main。

最后放其他代码,也就是RO段。

定义IRAM1,就是RAM,内存。

所有的RW段和ZI段都放在RAM中。

8.4. 编译结果如何看?

  1. 在MDK IDE界面有编译过程和最终结果:

compiling stm32f10x_sdio.c…
compiling stm32f10x_rcc.c…
compiling stm32f10x_usart.c…
compiling stm32f10x_spi.c…
compiling stm32f10x_tim.c…
compiling main.c…
compiling stm32f10x_wwdg.c…
linking…
Program Size: Code=1336 RO-data=336 RW-data=24 ZI-data=1632
FromELF: creating hex file…
“.\Objects\stm32_tech.axf” - 0 Error(s), 0 Warning(s).
Build Time Elapsed: 00:00:19

Program Size: Code=1336 RO-data=336 RW-data=24 ZI-data=1632

这一句说明生成的目标文件大小,代码1336字节,RO(只读变量)336字节,RW(读写变量)24字节,ZI数据1632字节。

  1. 更细的情况,可以通过map文件查看。map文件在Listings\目录下,名字叫stm32_tech.map

用文件编辑器打开就能看到内容。

map文件最开始是最细的地方,最后是整体情况。拖到最后,就能看到下面内容:

==============================================================================

Code (inc. data) RO Data RW Data ZI Data Debug

1336 96 336 24 1632 236688 Grand Totals
1336 96 336 24 1632 236688 ELF Image Totals
1336 96 336 24 0 0 ROM Totals

==============================================================================

Total RO Size (Code + RO Data) 1672 ( 1.63kB)
Total RW Size (RW Data + ZI Data) 1656 ( 1.62kB)
Total ROM Size (Code + RO Data + RW Data) 1696 ( 1.66kB)

==============================================================================

这些内容跟IDE中看到的基本类似。这是程序的总体情况。有一个地方需要注意:

Total RO Size (Code + RO Data):

Total RW Size (RW Data + ZI Data)

Total ROM Size (Code + RO Data + RW Data)

Total ROM Size就是最终的目标文件,也就是写到FLASH上的内容,请问,为什么包含RW Data的大小?

因为RW数据需要一个初始化值,这个值并不是凭空而来,而是代码中定义了,编译后保存在ROM中。

所以ROM会包含RW。

往回看,则是Image component sizes。map文件每个大段之间用等号分开。

==============================================================================

Image component sizes

Code (inc. data) RO Data RW Data ZI Data Debug Object Name

304 20 0 24 0 1705 main.o
0 0 0 0 0 203136 misc.o
64 26 304 0 1536 792 startup_stm32f10x_hd.o
298 0 0 0 0 12407 stm32f10x_gpio.o
26 0 0 0 0 16706 stm32f10x_it.o
32 6 0 0 0 557 stm32f10x_rcc.o
328 28 0 0 0 1845 system_stm32f10x.o


1058 80 336 24 1536 237148 Object Totals
0 0 32 0 0 0 (incl. Generated)
6 0 0 0 0 0 (incl. Padding)


Code (inc. data) RO Data RW Data ZI Data Debug Library Member Name

8 0 0 0 0 68 __main.o

本段说明了组成程序的各个文件的信息,每一个.o文件对应一个.c文件。

从这我们还能看到程序暗地里使用了多少个函数库。

再往上:

Memory Map of the image,说明各文件使用的RAM分类情况。

Image Symbol Table,这是个文件中使用的函数和RAM情况。

在往上的内容我们基本也不会看了。
2. 这里我们关键看下函数入口的情况。

RESET 0x08000000 Section 304 startup_stm32f10x_hd.o(RESET)
!!!main 0x08000130 Section 8 __main.o(!!!main)
!!!scatter 0x08000138 Section 52 __scatter.o(!!!scatter)
!!handler_copy 0x0800016c Section 26 __scatter_copy.o(!!handler_copy)
!!handler_zi 0x08000188 Section 28 __scatter_zi.o(!!handler_zi)

在0x08000000,放的确实是向量表。芯片复位时就会从这里开始执行代码。

除了__main,还有一些我们不知道是什么东西的代码放在启动代码后面。

8.5. 为什么能控制外设?

因为有外设寄存器。

外设寄存器是跟RAM一样的存在。(RAM是可以读写的,外设寄存器有些不能写)。

这些寄存器链接到对应的硬件。

TOTO请看规格书地址空间map图

我们只要写这些寄存器,就能实现对应外设的功能。

我们看ST提供的库,比如下面函数

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->BSRR = GPIO_Pin;
}

要设置一个GPIO的输出,就是配置GPIOx->BSRR = GPIO_Pin;

这时什么意思呢?

typedef struct
{
__IO uint32_t CRL;
__IO uint32_t CRH;
__IO uint32_t IDR;
__IO uint32_t ODR;
__IO uint32_t BSRR;
__IO uint32_t BRR;
__IO uint32_t LCKR;
} GPIO_TypeDef;

我们可以看到,BSRR 是结构体GPIO_TypeDef的内容。

GPIO_SetBits(GPIOE, GPIO_Pin_7);

使用这个函数的时候我们会传入一个GPIOE,这是一个GPIO_TypeDef结构体指针。

而GPIOE的定义是下面这些宏定义:

#define PERIPH_BASE ((uint32_t)0x40000000) /*!< Peripheral base address in the alias region */

#define APB2PERIPH_BASE (PERIPH_BASE + 0x10000)

#define GPIOE_BASE (APB2PERIPH_BASE + 0x1800)

#define GPIOE ((GPIO_TypeDef *) GPIOE_BASE)

意思是:

0x40000000这个地址上,是PERIPH_BASE,也就是PERIPH外设的基地址,起始地址。

PERIPH_BASE偏移0x10000的地方,放的是APB2PERIPH,也就是APB2总线上的外设。

APB2PERIPH_BASE偏移0x1800的地方,是GPIOE外设寄存器地址。

第四行则是把一个uint32_t的值强行类型转换为GPIO_TypeDef指针。

如此,就能通过GPIOE这个宏定义找到 GPIOE外设的相关寄存器。

9. 动态扫描数码管

前面我们学习了如何使用一位LED显示数字,很简单是吧?

现在我们加点难度。一位数码管只能显示一位数字,现在我们要显示8位数字(或者显示时间)。

那么我们就需要8位数码管,如果按照1位数码管的硬件接法,8位数码管就需要64根IO。

相当于1个LED使用1根IO口控制。

大家觉得可行吗?当然可行,我们芯片有100根管脚,80多根IO。

但是你只打算用芯片控制8位数码管吗?肯定不是嘛!这样的方案肯定是非常浪费IO的。

那怎么办嗯?要解决这个问题,要用到一个原理两个芯片

9.1. 一个原理

不知道大家是否了解过以前的胶片电影,一张一张的画片,连续播放就能看到活生生的,会动的人。

这是为什么呢?

原理是“视觉暂留”。

科学实验证明,人眼在某个视像消失后,仍可使该物像在视网膜上滞留0.1-0.4秒左右。电影胶片以每秒24格画面匀速转动,一系列静态画面就会因视觉暂留作用而造成一种连续的视觉印象,产生逼真的动感。

我们在数码管上能不能用这个原理呢?

8个数码管都用同样的IO控制亮灭,轮流显示。

只要同一个LED的点亮间隔不大于0.4秒(实际要比这个小),那么我们就会一直看到这个数码管是亮着的。(和真正一直亮会有什么差别?)

这样我们就只要8根IO了?

如何选择8个数码管该点亮哪个?

前面我们用共阴极数码管,阴极是接到地线的。

我们可以用IO口控制阴极,只有对应的IO是低电平,这个数码管才有亮。如果阴极是高电平,数码管就不会亮。

如此,我们需要8+8根IO就够了。省去了48根IO,太有成就了。

9.2. 两个芯片

我们都是高兴太早,通常IO口还是不够, 使用16根IO也是很浪费的。

那这么办呢?利用数字电路,有两个芯片能帮上我们的忙。

74HC13874HC595

138是三八译码器,595是8位串行输入、并行输出的位移缓存器。

74是一系列数字功能芯片,注意中间字母的区别。我们选用的是HC类型,HC表示是CMOS电平,或者简单说就是3.3V电压。

  • 三八译码器

三八译码器是什么?我们从数据手册一探究竟。

打开数据手册,标题:SNx4HC138 3-Line To 8-Line Decoders/Demultiplexers

翻译为中文就是:SNx4HC138 3线转8线译码器/多路分配器,怎么转呢?往下看。

我们选用的型号是SN74HC138PWR。型号这些数字和字母都是什么意思呢?

SN74是芯片系列。

HC是芯片种类。

138是芯片具体型号。

PW是封装,TSSOP16。

R包装形式,编带。

138的功能:用3根线的电平,选择8根线中的一根线输出低电平,其他输出高电平

芯片电气信号

../../_images/pic5.jpg

真值表如下:

[外链图片转存中…(img-mJ1o0ZfK-1645022852665)]

左边是输入,右边是输出。

ENABLE信号通常我们输出H-L-L,也就是默认使能,不进行控制。

C/B/A,38译码器3根输入线,一共有8种组合。

输出信号8根,根据3根输入线的状态,选择其中1根输出低电平,其他线输出高电平。

因此,选中的数码管是低电平,那么就只能用共阴极数码管

  • 595功能

打开595手册

标题:8-Bit Shift Registers With 3-State Output Registers

意思:8位移位寄存器,具有3态输出。

我们选用的型号是:SN74HC595PWR, 名称含义与138类似。

芯片电气信号

../../_images/pic7.jpg

时序图

[外链图片转存中…(img-3d3ygTeg-1645022852666)]

14脚SER输入,11 脚SRCLK上升沿,从14脚输入1位数据。8次之后,就有一个BYTE的数据保存在595中。当时钟继续输出,数据将从9脚输出,因此,可以通过多个595串联实现更多的移位位数。两个595就可以组成16位移位寄存器。

12脚RCLK上升沿,保存在595中的8位数据,从595的8个并行输出引脚输出(OE需要低电平)

10脚SRCLR是复位脚,低电平有效 ,上电后输出高即可。

更多细节可参考:https://baike.baidu.com/item/74HC595/9886491

我们用三八译码器控制刷管的共阴极,595控制数码管的正极。三八译码器决定哪个数码管亮,595决定亮的内容。如此,我们就只需要7个IO口就搞定了。

9.3. 硬件原理

节省IO是一种共识,所以要用8位数码管时,我们不需要用8位单独的数码管组成。

而是用2个内部连接好信号的4位数码管。如下图:

[外链图片转存中…(img-AmISNISi-1645022852666)]

这种数码管内部已经将共用的信号连在一起。同样,也有共阴极和共阳极数码管之分。

内部连接信号如下:

[外链图片转存中…(img-o9LPVyVM-1645022852667)]

[外链图片转存中…(img-8PLVK4bm-1645022852667)]

电路图根据前面分析的原理设计,如下图:

../../_images/pic4.jpg

9.4. 调试

9.4.1. 第一步

静态显示,38译码器设定一个固定输出,选中一个数码管,控制595输出,让数码管显示不同数字。

  • 初始化硬件

/*
595_SDI— ADC-TPX—PB0—数据输入
595_LCLK—ADC-TPY—PB1—数据锁存—上升沿锁存
595_SCLK—TP-S0—PC5—数据移位—上升沿移位
595_RST—TP-S1—PC4—芯片复位–低电平复位

A138_A0—FSMC_D2—PD0
A138_A1—FSMC_D1—PD15
A138_A2—FSMC_D0—PD14
/
void seg_init(void)
{
/
GPIOD Periph clock enable */
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD, ENABLE);

/* 38译码器输入0 ,选中第4个数码管*/
GPIO_ResetBits(GPIOD, GPIO_Pin_0|GPIO_Pin_14|GPIO_Pin_15);
/* Configure PD0 and PD2 in output pushpull mode */
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0|GPIO_Pin_14|GPIO_Pin_15;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_Init(GPIOD, &GPIO_InitStructure);

GPIO_ResetBits(GPIOB, GPIO_Pin_0|GPIO_Pin_1);
/* Configure PD0 and PD2 in output pushpull mode */
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0|GPIO_Pin_1;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_Init(GPIOB, &GPIO_InitStructure);

GPIO_ResetBits(GPIOC, GPIO_Pin_4|GPIO_Pin_5);
/* Configure PD0 and PD2 in output pushpull mode */
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4|GPIO_Pin_5;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_Init(GPIOC, &GPIO_InitStructure);

/* 拉高复位信号 */
GPIO_SetBits(GPIOC, GPIO_Pin_4);

}

  • 138驱动

/*
选择数码管,控制138选中对应数码管
pos参数就是位置
*/
void seg_select(uint8_t pos)
{
if (pos == 1) {
GPIO_SetBits(GPIOD, GPIO_Pin_14);
GPIO_ResetBits(GPIOD, GPIO_Pin_0|GPIO_Pin_15);
} else if (pos == 2) {
GPIO_SetBits(GPIOD, GPIO_Pin_0|GPIO_Pin_14);
GPIO_ResetBits(GPIOD, GPIO_Pin_15);
} else if(pos == 3) {
GPIO_SetBits(GPIOD, GPIO_Pin_15|GPIO_Pin_14);
GPIO_ResetBits(GPIOD, GPIO_Pin_0);
} else if(pos == 4) {
GPIO_SetBits(GPIOD, GPIO_Pin_0|GPIO_Pin_15|GPIO_Pin_14);
} else if (pos == 5) {
GPIO_ResetBits(GPIOD, GPIO_Pin_0|GPIO_Pin_15|GPIO_Pin_14);
} else if(pos == 6) {
GPIO_ResetBits(GPIOD, GPIO_Pin_15|GPIO_Pin_14);
GPIO_SetBits(GPIOD, GPIO_Pin_0);
} else if(pos == 7) {
GPIO_ResetBits(GPIOD, GPIO_Pin_0|GPIO_Pin_14);
GPIO_SetBits(GPIOD, GPIO_Pin_15);
} else if(pos == 8) {
GPIO_ResetBits(GPIOD, GPIO_Pin_14);
GPIO_SetBits(GPIOD, GPIO_Pin_0|GPIO_Pin_15);
}
}

  • 595驱动

/*
输出一位数码管显示数据
*/
void seg_display_1seg(uint8_t segbit)
{
uint8_t tmp;
uint8_t cnt = 0;

tmp = segbit;

cnt = 0;
/* 拉低 595_LCLK*/
GPIO_ResetBits(GPIOB, GPIO_Pin_1);

while(1) {
/* 拉低 595_SCLK*/
GPIO_ResetBits(GPIOC, GPIO_Pin_5);
/* 将数据从 SDI发出去*/
if((tmp & 0x80)== 0x00)//注意操作符的优先级
{
GPIO_ResetBits(GPIOB, GPIO_Pin_0);
} else {
GPIO_SetBits(GPIOB, GPIO_Pin_0);
}

tmp = tmp<<1; //移位

delay(100);
/* 拉高 595_SCLK 移位数据 */
GPIO_SetBits(GPIOC, GPIO_Pin_5);
delay(100);

cnt++;
if(cnt >= 8)
break;
}

GPIO_SetBits(GPIOB, GPIO_Pin_1);
delay(100);

}

  • 应用

在main中初始化数码管,138固定输出值,调用595驱动函数输出各种数字。

seg_init();

/*
第一步,调试595和138功能
在第1个数码管显示0-9
*/
seg_select(1);
seg_display_1seg(0x3f);
seg_display_1seg(0x06);
seg_display_1seg(0x5b);
seg_display_1seg(0x4f);
seg_display_1seg(0x66);
seg_display_1seg(0x6d);
seg_display_1seg(0x7d);
seg_display_1seg(0x07);
seg_display_1seg(0x7f);
seg_display_1seg(0x67);
seg_display_1seg(0x3f|0x80);

输出数字对应的数码管段值,列入一个数组,索引就是数字,比如显示数字1,输出的数码管段值就是SegTab1, 也就是0x06。

uint8_t SegTab[10]={0x3f, 0x06, 0x5b, 0x4f, 0x66, 0x6d, 0x7d, 0x07, 0x7f, 0x67};

单步运行看效果。

9.4.2. 第二步

固定显示,595输出固定值,调试38译码器,让数字在数码管上轮流显示相同的数字。

代码和第一步类似。

经过第一第二步调试后,138和595驱动就完成了。

9.4.3. 第三步

第一第二步只是实现了单个数码管显示,属于静态显示。

前面讲原理时讲过,8位数码管使用动态显示方法。需要将38译码器和595配合,才能动态刷8位数据,我们现在尝试固定显示12345678。

动态刷新是一个循环,因此放在while循环中实现。

代码如下:

seg_select(1);
seg_display_1seg(0x3f);

seg_select(2);
seg_display_1seg(0x06);

seg_select(3);
seg_display_1seg(0x5b);

seg_select(4);
seg_display_1seg(0x4f);

seg_select(5);
seg_display_1seg(0x66);

seg_select(6);
seg_display_1seg(0x6d);

seg_select(7);
seg_display_1seg(0x7d);

seg_select(8);
seg_display_1seg(0x07);

单步运行,看效果。发现一个问题,在调用138切换数码管时,会将前面显示的内容显示到下一个位置。

比如,数码管1显示1,调用数码管138切换显示位置到数码管2,这时,数码管2会显示1。这是个问题。

我们全速运行程序看看效果。显示的内容并不是87654321,而是76543218,而且有重影,影子隐隐约约是我们要的效果:87654321。

如何解决这个问题呢?

方法是:在切换显示位置前,将显示内容清零,也就是将595的输出内容输出为0。

增加一个seg_clear函数实现这个功能。实现如下:

void seg_clear(void)
{
uint8_t cnt = 0;

cnt = 0;
/* 拉低 595_LCLK*/
GPIO_ResetBits(GPIOB, GPIO_Pin_1);

while(1) {
/* 拉低 595_SCLK*/
GPIO_ResetBits(GPIOC, GPIO_Pin_5);
/* 将数据从 SDI*/
GPIO_ResetBits(GPIOB, GPIO_Pin_0);
delay(100);
/* 拉高 595_SCLK 移位数据 */
GPIO_SetBits(GPIOC, GPIO_Pin_5);
delay(100);

cnt++;
if(cnt >= 8)
break;
}

GPIO_SetBits(GPIOB, GPIO_Pin_1);
delay(100);

}

在前面的测试函数中所有seg_select函数之前都添加本函数。

编译下载全速运行,效果正常。

9.4.4. 第四步

经过第三步调试,8位数码管的功能已经实现了。

那,驱动算完成了吗?没有。为什么?

先介绍一个很重要的概念:时间片

什么是时间片呢?拿LED和8位数码管进行对比。

LED,只要将IO置位,就能点亮,之后如果不改变LED状态,不需要再管它。

8位数码管呢?因为我们用动态扫描方法,不能仅仅将8位数码管输出一次内容之后就不管了,要一直刷新。

前面原理也说过,每个数码管刷一次的时间间隔不能小于24ms。

这种需要定时操作的,我们通常就说这个功能需要时间片。

好,了解了时间片。那么应用程序要如何使用呢?

应用程序只是想在数码管上显示一些数字而已,数码管怎么显示的,它是不管的。

为了显示数字,让应用程序间隔24ms就调用你的程序刷新显示,这明显不合理,专业术语叫强耦合,本来不相关的。

讲到这,不知道大家是否明白。不了解也没关系,后面再慢慢理解。

总之,矛盾就是:应用只是想显示一数字,数码管驱动要时间片维持显示

怎么实现呢?用缓冲。缓冲就是一个组数,这个数组是应用和驱动之间的联系。

应用程序将要显示的内容放到缓冲。驱动将缓冲中的内容显示到数码管。

如此,就达到了最简单的模块分离

程序设计中有一个理论:生产者和消费者。

数码管驱动和应用虽然不是真正的生产者和消费者,但是使用缓冲的逻辑是相似的。

  • 有8位数码管,就定义包含8个空间的数组。

/* 动态扫描 添加缓冲功能 /
/
8位数码管的显示内容 /
char BufIndex = 0;
/
缓冲,保存的是对应数码管段值 */
char Seg8DisBuf[8]={0x7f,0x07,0x7d,0x6d,0x66,0x4f,0x5b,0x06};

  • 定义一个函数,用于动态刷新数码管。这个函数最好放在定时或者RTOS的定时任务中执行。现在我们还没学会,可以放在main函数中的while运行。

/*
动态刷新
定时调用本函数,
本函数对应用层屏蔽,意思是:应用层不知道我是通过动态刷新实现8位数码管功能。
*/
void seg_display_task(void )
{
seg_clear();
seg_select(BufIndex+1);
seg_display_1seg(Seg8DisBuf[BufIndex]);

BufIndex++;
if(BufIndex >=8)
BufIndex = 0;
}

  • 定义一个函数,给应用程序调用,改变数码管缓冲的值。

/*
segbit 数码管段值,为1的bit点亮
seg 数码管位置,1~8
*/
void seg_fill_disbuf(uint8_t segbit, uint8_t seg)
{
Seg8DisBuf[seg-1] = segbit;
return;
}

  • 在main函数中调用数码管刷新功能。

/-----------------驱动--------------/
/* 使用显示缓冲方法,要改变显示内容,
调用函数seg_fill_disbuf改变Seg8DisBuf中的内容即可 */
seg_display_task();

/-----------------应用-----------------/
cnt++;
if(cnt >= 1000) {
cnt=0;
disnum ++;
if(disnum > 9)
disnum = 0;

seg_fill_disbuf(SegTab[disnum], 1);

}
/----------------------------------/
delay(1000);

驱动是数码管的内容,while循环最后delay 1000,也就是刷新间隔。现在没定时器,暂时定一个值,数码管不闪烁即可。

应用就是延时1000次个delay(1000)后,改变数码管1显示的数字,从0显示到9。

编译下载看效果。

10. 代码结构调整

传说有人写的程序只有一个main.c,一万行代码,这是一个神奇的故事

本节主要通过代码讲解如何模块化代码。

10.1. 概述

代码结构调整有很多方式,今天只说最简单的。

  1. 源码模块化—-接口标准化
  2. 硬件相关宏定义

10.2. 源码模块化

模块化步奏:

  1. 将一个功能、一个模块、一种设备的相关代码封装在同一个.c源文件中。
  2. 内部使用的函数用static宏控制,不允许外部使用。
  3. 内部的定义,比如宏、结构体,定义在c文件。
  4. 这个源文件有一个相同名字的头文件。
  5. 对外的定义,宏、结构体等,定义在头文件。
  6. 变量只能定义在源文件,不对外直接暴露变量。

我们就拿上一节的代码整理。

数码管,就是一个设备。这个设备提供的功能是什么呢?点亮对应的段?显示数字和小数点?

我认为:点亮对应的段,才是八段数码管的根本功能

显示数字属于应用层功能。

为什么这样划分呢?有以下原因:

设备驱动尽量只提供自己能实现的功能本质,数码管的功能本质就是每个段点亮。

不同的段点亮后,组成的到底是数字还是字母,最好不要放在设备驱动中。

当项目越大,程序越复杂,参与开发的工程师多时,越能感觉到这样划分的合理性。

假如你数码管驱动只提供显示数字的接口,但是新项目需要显示一些自定义的字符,

那到底是改驱动还是改应用呢?

不过在实践上,数码管是一个小驱动,将显示数字接口放在设备驱动中,也并不是不可。

但设备较复杂,例如LCD,可以在驱动和应用间封装一层pannel层,用于实现各种应用需要的接口。

  • 建立一个dev目录

在dev目录下建立两个文件:seg.c、seg.h

seg_init, seg_display_1segseg_selectseg_clearseg_display_taskseg_fill_disbuf这几个函数拷贝到seg.c文件。

同时拷贝BufIndexSeg8DisBuf这两个变量。

  • 对外的函数是:seg_display_taskseg_fill_disbufseg_init,这三个函数拷声明贝到头文件seg.h,并且加上extern前缀。如下:

#ifndef SEG_H
#define SEG_H

extern void seg_init(void);
extern void seg_display_task(void );
extern void seg_fill_disbuf(uint8_t segbit, uint8_t seg);

#endif

其中#ifndef等三个宏定义是为了解决重复包含的问题。每个头文件都会有这三行指令,后面的宏不一样而已。

  • 为了防止外部函数调用内部函数,对没有在头文件声明的函数加上static,在外部调用此函数时,编译会出错。
  • 把调试过程的函数剪切到seg.c,定义一个函数seg_test。
  • 把变量BufIndexSeg8DisBuf也加上static前缀,seg.c文件外部就不能直接操作这两个变量了。
  • 在main.c头部增加#include "seg.h",包含seg驱动的头文件。
  • 打开MDK工程,在工程目录新建一个目录,并添加seg.c到目录,并修改工程头文件路径。
  • 重新编译下载。

10.3. 宏定义

宏定义的第一个好处:将某个定义在一个地方定义,后续要修改这个定义的时候,只要改一个地方即可。

在数码管的驱动中,硬件IO口的定义在seg_initseg_display_1segseg_selectseg_clear四个函数中都有使用,如果我们要修改某个IO的硬件连接,可能就需要修改这四个函数。

如果我们将这些函数中硬件相关的定义统一到一个宏定义,只要修改一个地方,就能改变整个数码管驱动的硬件连接。

#define SEG_138_A0_PIN GPIO_Pin_0
#define SEG_138_A1_PIN GPIO_Pin_15
#define SEG_138_A2_PIN GPIO_Pin_14
#define SEG_138_A_PORT GPIOD

#define SEG_595_SDI_PIN GPIO_Pin_0
#define SEG_595_SDI_PORT GPIOB

#define SEG_595_LCLK_PIN GPIO_Pin_1
#define SEG_595_LCLK_PORT GPIOB

#define SEG_595_SCLK_PIN GPIO_Pin_5
#define SEG_595_SCLK_PORT GPIOC

#define SEG_595_RST_PIN GPIO_Pin_4
#define SEG_595_RST_PORT GPIOC

宏定义如上,相关函数修改为宏即可,具体见代码。

编译下载验证

10.4. 推荐

《林锐 高质量C-C++编程指南》

《嵌入式C精华》

11. IO输入与按键扫描

输出输入是GPIO的两种功能,前面我们点亮LED、控制数码管,用的是IO口输出。现在我们开始学习IO口输入功能。

11.1. 概述

IO口输入的意思就是:读取IO口上的电平:高电平为1,低电平为0。

通常我们默认默认高电平就是CPU工作电压,比如STM32就是3.3V。低电平就是GND电压,也就是0V。

但是其实这并不严格,在CPU的规格书中标有输入电压电平范围,比如00.6V,就认为是低电平,2.7V3.3V就是高电平。这些细节除非特殊应用场合,平常不用特别注意。

还有一个要点,一些复杂的芯片,会有多种输入电压,比如一些ARM9芯片,会有内核电压、内存电压、IO电压,GPIO的高低电平对应的是IO电压。

11.2. IO口配置

一个IO口做为输入,通常有哪些可以选择的配置呢?

不同的CPU会有差异,也有共同点。现在我们看看STM32配置一个IO口为输出,有哪些配置。

不知道大家还是否记得GPIOMode_TypeDef枚举定义。请看下面:

typedef enum
{ GPIO_Mode_AIN = 0x0,
GPIO_Mode_IN_FLOATING = 0x04,
GPIO_Mode_IPD = 0x28,
GPIO_Mode_IPU = 0x48,
GPIO_Mode_Out_OD = 0x14,
GPIO_Mode_Out_PP = 0x10,
GPIO_Mode_AF_OD = 0x1C,
GPIO_Mode_AF_PP = 0x18
}GPIOMode_TypeDef;

上面就是STM32 IO的模式,输入的模式我们讲过了。哪些是输入模式呢?

GPIO_Mode_IN_FLOATINGGPIO_Mode_IPDGPIO_Mode_IPU这三种模式就是输入模式。

从名字看,三种模式的区别仅仅是上下拉电阻配置不一样。分别是:FLOATING-浮空、IPD-下拉、IPU-上拉。

所以,如果用ST的库函数配置一个IO位输入,是非常简单的。

GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_Init(GPIOB, &GPIO_InitStructure);

第1行代码选择要配置的IO,可以同时配置多个。

第2行选择速度。

第3行关键,输入模式。

然后调用GPIO_Init函数初始化即可。

那么如何获取输入状态呢?看库函数头文件都提供了哪些功能函数就可以知道了。

只有两个函数:

uint16_t GPIO_ReadInputData(GPIO_TypeDef* GPIOx)

uint8_t GPIO_ReadInputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)

这两个函数的功能,注释说的很清楚。

第1个函数是读整组IO的状态,所以返回值是一个uint16_t,也就是一个16位的数,正好对应一组IO口。

第2个函数是读一个Bit,也就是一个指定的IO口的值。返回值是一个uint8_t,但是并不是会返回8位。看函数:返回值有两种,Bit_SET和Bit_RESET,高电平,返回Bit_SET,低电平返回Bit_RESET。

11.3. 按键原理

理解了IO输入原理,按键原理就简单了。

11.3.1. 按键硬件接法

一个IO只能输入两种状态,接在上面的按键,当然也只会有两种状态。

我们教学板上有4个按键接在IO口上,请看原理图:

[外链图片转存中…(img-6HTiwh1e-1645022852670)]

[外链图片转存中…(img-4TaB0F4f-1645022852671)]

4个按键的原理图是一样的。

按键一端接到地,另外一端接到IO口。

在IO口这端,通过一个电阻接到VCC,这个电阻就是我们常说的上拉电阻。

按键按下,IO口接到地,输入低电平。按键松开,IO口通过电阻接到VCC,输入高电平。

上拉电阻

如果我们把IO口配置为上拉模式,内部已经有一个上拉电阻。但是这个电阻阻值通常是固定的。

在某些情况,我们需要灵活配置上拉电阻。

上拉电阻作用是当按键没有按下时,把IO口的状态维持在文档的高电平,防止程序读到意外状态。

但是,这个电阻还有一个重要的要点,就是电流。当按键按住,VCC将通过这个电阻连接到地线。

流过的电流=VCC/R。

所以这个电阻不能选太小的,太小电流大。比如在一些低功耗设备,按键会一直按住,进入睡眠,如果电阻很小,电流就很大。有时我们会用1M的电阻,这样一个按键的电流就只有3uA。

但是电阻也不能选太大,越大的电阻本身寄生的电容电感就很大,很容有受到外部干扰。

通常,如果不是要求超低功耗的设备,用几K几十K的电阻。

超低功耗设备选择1M电阻,最大不超过3M。

11.4. 调试

11.4.1. 第一步

使用单步调试,确定IO口输入有反应。

初始化代码:

/* 按键接在 PB12 PB13 PB14 PB15*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12|GPIO_Pin_13|GPIO_Pin_14|GPIO_Pin_15;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_Init(GPIOB, &GPIO_InitStructure);

测试代码

while(1)
{
/* 读一组IO口输入 */
sta = GPIO_ReadInputData(GPIOB);

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以添加V获取:vip1024b (备注Go)
img

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
0x48,
GPIO_Mode_Out_OD = 0x14,
GPIO_Mode_Out_PP = 0x10,
GPIO_Mode_AF_OD = 0x1C,
GPIO_Mode_AF_PP = 0x18
}GPIOMode_TypeDef;

上面就是STM32 IO的模式,输入的模式我们讲过了。哪些是输入模式呢?

GPIO_Mode_IN_FLOATINGGPIO_Mode_IPDGPIO_Mode_IPU这三种模式就是输入模式。

从名字看,三种模式的区别仅仅是上下拉电阻配置不一样。分别是:FLOATING-浮空、IPD-下拉、IPU-上拉。

所以,如果用ST的库函数配置一个IO位输入,是非常简单的。

GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_Init(GPIOB, &GPIO_InitStructure);

第1行代码选择要配置的IO,可以同时配置多个。

第2行选择速度。

第3行关键,输入模式。

然后调用GPIO_Init函数初始化即可。

那么如何获取输入状态呢?看库函数头文件都提供了哪些功能函数就可以知道了。

只有两个函数:

uint16_t GPIO_ReadInputData(GPIO_TypeDef* GPIOx)

uint8_t GPIO_ReadInputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)

这两个函数的功能,注释说的很清楚。

第1个函数是读整组IO的状态,所以返回值是一个uint16_t,也就是一个16位的数,正好对应一组IO口。

第2个函数是读一个Bit,也就是一个指定的IO口的值。返回值是一个uint8_t,但是并不是会返回8位。看函数:返回值有两种,Bit_SET和Bit_RESET,高电平,返回Bit_SET,低电平返回Bit_RESET。

11.3. 按键原理

理解了IO输入原理,按键原理就简单了。

11.3.1. 按键硬件接法

一个IO只能输入两种状态,接在上面的按键,当然也只会有两种状态。

我们教学板上有4个按键接在IO口上,请看原理图:

[外链图片转存中…(img-6HTiwh1e-1645022852670)]

[外链图片转存中…(img-4TaB0F4f-1645022852671)]

4个按键的原理图是一样的。

按键一端接到地,另外一端接到IO口。

在IO口这端,通过一个电阻接到VCC,这个电阻就是我们常说的上拉电阻。

按键按下,IO口接到地,输入低电平。按键松开,IO口通过电阻接到VCC,输入高电平。

上拉电阻

如果我们把IO口配置为上拉模式,内部已经有一个上拉电阻。但是这个电阻阻值通常是固定的。

在某些情况,我们需要灵活配置上拉电阻。

上拉电阻作用是当按键没有按下时,把IO口的状态维持在文档的高电平,防止程序读到意外状态。

但是,这个电阻还有一个重要的要点,就是电流。当按键按住,VCC将通过这个电阻连接到地线。

流过的电流=VCC/R。

所以这个电阻不能选太小的,太小电流大。比如在一些低功耗设备,按键会一直按住,进入睡眠,如果电阻很小,电流就很大。有时我们会用1M的电阻,这样一个按键的电流就只有3uA。

但是电阻也不能选太大,越大的电阻本身寄生的电容电感就很大,很容有受到外部干扰。

通常,如果不是要求超低功耗的设备,用几K几十K的电阻。

超低功耗设备选择1M电阻,最大不超过3M。

11.4. 调试

11.4.1. 第一步

使用单步调试,确定IO口输入有反应。

初始化代码:

/* 按键接在 PB12 PB13 PB14 PB15*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12|GPIO_Pin_13|GPIO_Pin_14|GPIO_Pin_15;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_Init(GPIOB, &GPIO_InitStructure);

测试代码

while(1)
{
/* 读一组IO口输入 */
sta = GPIO_ReadInputData(GPIOB);

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以添加V获取:vip1024b (备注Go)
[外链图片转存中…(img-5jpKwkMP-1713417679670)]

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 3
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值