C-裸金属嵌入式编程指南-全-

C 裸金属嵌入式编程指南(全)

原文:zh.annas-archive.org/md5/9304e1d162a4c484f7b77bfcc5f4f847

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在一个技术驱动的世界中,嵌入式系统几乎为每个现代设备和创新提供动力,能够开发高效可靠的固件是一项宝贵的技能。从编写基本代码到掌握低级固件开发的过程可能会令人望而却步,但回报是巨大的。无论是家用电器、工业控制系统还是复杂的物联网设备,嵌入式系统都是现代技术背后默默无闻、勤奋工作的引擎。

这本书,《裸机嵌入式 C 编程》,源于一个愿望,即帮助你不仅能够编写功能性的固件,而且能够深入理解控制微控制器核心运作的底层机制。我的目标是带你进行一次深入的技术之旅,深入 ARM 架构微控制器固件开发的内心,特别关注 STM32 系列。这不是一本适合胆小的人的书,也不是一本适合寻找快速捷径的人的书。相反,它是为那些准备好离开预构建库和工具的舒适区,发展编写高效裸机代码所需技能的个人设计的。

那么,究竟什么是裸机编程?简单来说,它是一种编写直接与硬件交互的固件的艺术——没有第三方库提供的抽象层。这种方法需要精确性、对微控制器架构的深入理解,以及读取和操作寄存器的能力,以实现从你的硬件中获得你想要的精确行为。

我为什么写这本书

作为一名在嵌入式系统开发领域拥有多年经验的人,我经常注意到在固件开发教学方式中存在一个差距。许多文本和课程专注于高级开发,推广使用预构建库来抽象化硬件交互的复杂性。虽然这种方法无疑在很多情况下既方便又实用,但它为那些真正希望了解事物在最低级别是如何工作的留下了空白。我相信,理解嵌入式系统开发的“裸机”方面对于成为一名真正的熟练固件工程师是至关重要的。

这本书是我填补这一空白的一次努力。通过逐步指导,我将向你展示如何构建自己的驱动程序,操作寄存器,并编写能够完全控制微控制器的代码。这不仅仅是学习一项新技能——这是关于达到精通。

这本书面向的对象

如果你是一名渴望深入微控制器固件开发世界的开发者、工程师或学生,这本书适合你。如果你是那种更喜欢理解底层发生的事情,而不是依赖在线论坛的复制粘贴解决方案的人,你会发现它特别有价值。无论你是从其他平台过渡,还是寻求在裸机开发中建立坚实的基础,这本书都会给你提供所需的实践经验。

本书涵盖内容

第一章, 设置开发工具

本章介绍了你开发所需的基本工具。从浏览数据手册到设置你的集成开发环境(IDE),本章为后续内容奠定了基础。

第二章, 从内存地址构建外设寄存器

在本章中,我们将深入裸机编程的核心。你将学习如何直接从内存地址定义和访问外设寄存器,使用官方微控制器文档作为你的指南。

第三章, 理解构建过程和探索 GNU 工具链

在本章中,我们将更深入地探讨嵌入式 C 构建过程。你将探索如何使用 GNU 工具链手动编译和链接代码,从而完全控制固件创建的方式。

第四章, 开发链接脚本和启动文件

在本章中,你将学习如何编写自定义链接脚本,以定义固件在微控制器内存中的放置方式,包括分配代码、数据和堆栈等部分。此外,你还将开发一个启动文件,配置微控制器的初始状态,设置堆栈,初始化内存,并跳转到你的主代码。

第五章, “Make”构建系统

自动化构建过程是嵌入式开发的关键部分。本章教你如何使用 Make 构建系统通过创建自定义 Makefile 来简化工作流程,自动化重复性任务。

第六章, 通用微控制器软件接口标准(CMSIS)

CMSIS 简化了 ARM Cortex 微控制器的开发。在本章中,你将学习如何利用 CMSIS 编写高效的代码,利用微控制器的特性同时保持代码的简洁性。

第七章, 通用输入/输出(GPIO)外设

GPIO 允许你的微控制器与外部设备交互。本章将指导你开发 GPIO 的输入和输出驱动程序,这是嵌入式系统中使用最频繁的外设之一。

第八章, 系统滴答定时器(SysTick)

在嵌入式系统中,时间同步至关重要,SysTick 定时器提供了一种简单的方法来生成精确的时间延迟和系统滴答。本章将指导您开发用于嵌入式应用的 SysTick 驱动程序。

第九章, 通用 定时器 (TIM)

本章向您介绍了 STM32 微控制器中的通用定时器(TIM),并教授您如何开发定时器驱动程序以执行需要精确时间同步的任务。

第十章, 通用 异步 接收/发送协议

通信是嵌入式系统的一个关键方面。本章专注于最广泛使用的通信协议之一——UART 协议。您将学习如何开发 UART 驱动程序,使您的微控制器能够从外部设备发送和接收数据。

第十一章, 模数 转换器 (ADC)

许多嵌入式应用需要将模拟信号转换为微控制器可以处理的数字数据。本章介绍了如何配置 ADC 外设,使您能够读取并将模拟输入转换为有意义的数字值。

第十二章, 串行 外围 接口 (SPI)

SPI 是一种在嵌入式系统中常用的高速通信协议。本章将指导您开发 SPI 驱动程序,使您的微控制器能够与其他外围设备(如传感器或存储设备)进行高效通信。

第十三章, 集成电路间 通信 (I2C)

I2C 是另一种流行的设备连接通信协议,常用于嵌入式系统中的短距离通信。本章介绍了 I2C 驱动程序的开发,使您的微控制器能够通过共享总线与多个设备进行通信。

第十四章, 外部中断和 事件 (EXTI)

在嵌入式系统中,响应性至关重要,外部中断允许系统对其环境中的变化做出反应。本章介绍了如何配置和管理外部中断和事件(EXTI),以便及时有效地对外部刺激做出响应。

第十五章, 实时 时钟 (RTC)

对于需要精确时间记录的系统,RTC 外设是必不可少的。在本章中,您将学习如何设置和使用 RTC 在低功耗系统中跟踪时间,即使在微控制器处于睡眠模式时也是如此。

第十六章, 独立 看门狗 (IWDG)

稳定性对于嵌入式系统至关重要,独立看门狗定时器(IWDG)确保系统可以从意外故障中恢复。本章教授您如何配置 IWDG,以便在微控制器停止响应时自动重置,确保可靠运行。

第十七章直接内存访问(DMA)

直接内存访问(DMA)允许数据传输独立于 CPU 进行,显著提高系统效率。本章将介绍如何配置和使用 DMA 进行内存到内存的传输,以及用于 ADC 和 UART 等外围设备的 DMA,从而减轻 CPU 的工作负担。

第十八章嵌入式系统中的电源管理和能源效率

电源管理对于节能系统至关重要,尤其是在电池供电的设备中。在本章的最后,你将学习降低功耗的技术,包括如何使用睡眠模式、唤醒源以及优化固件以在性能和能源效率之间取得最佳平衡。

为了最大限度地利用这本书

为了充分利用这本书,对 C 编程语言有一个基本的了解是很重要的。虽然我们将详细讲解嵌入式系统编程的细节,但了解代码是如何运行的将使材料更容易理解。熟悉微控制器当然有帮助,但不是严格的要求。随着我们的进展,所有你需要的东西都会被介绍。无论你是初学者还是有经验的开发者,这本书都会一步一步地引导你进入裸机嵌入式编程的迷人世界。

本书涵盖的软件/硬件 操作系统要求
STM32CubeIDE Windows
GNU Arm 嵌入式工具链
NUCLEO-411 开发板
10k 电位器
OpenOCD
Notepad++
RealTerm

如果你使用的是这本书的数字版,我们建议你亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助你避免与代码的复制和粘贴相关的任何潜在错误。

下载示例代码文件

你可以从 GitHub(github.com/PacktPublishing/Bare-Metal-Embedded-C-Programming)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“复制到openocd bin文件夹的路径。”

代码块是这样设置的:

// 22: Set PA5(LED_PIN) high 
GPIOA_OD_R |= LED_PIN; 

当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:

//  1: Define base address for peripherals
#define PERIPH_BASE        (0x40000000UL)
//  2: Offset for AHB1 peripheral bus

任何命令行输入或输出都按照以下方式编写:

monitor flash write_image erase 4_makefile_project.elf

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“右键单击此电脑,然后选择属性。”

小贴士或重要笔记

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件联系我们 customercare@packtpub.com,并在邮件主题中提及书名。

勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata 并填写表格。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,我们将不胜感激。请通过电子邮件联系我们 copyright@packt.com 并附上材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《裸机嵌入式 C 编程》,我们非常乐意听到您的想法!请点击此处直接进入该书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都至关重要,并将帮助我们确保我们提供高质量的内容。

下载这本书的免费 PDF 副本

感谢您购买这本书!

您喜欢在路上阅读,但无法随身携带您的印刷书籍吗?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠不会就此结束,您还可以获得独家折扣、时事通讯和每日免费内容的每日访问权限。

按照以下简单步骤获取福利:

  1. 扫描二维码或访问以下链接

二维码

packt.link/free-ebook/9781835460818

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他福利发送到您的邮箱

第一章:设置工具

在嵌入式系统领域,制作高效的固件始于对可用工具的清晰理解。本章将指导你建立一个强大的开发环境,确保你拥有全面的固件开发体验所需的所有工具。

我们讨论的核心是数据表的概念。将这些视为任何微控制器的详细蓝图,包括其功能、规格和复杂细节。然而,挑战往往不仅仅是理解数据表,还包括找到适合您特定微控制器的正确数据表。为了解决这个问题,我将帮助您确定和理解对我们所选微控制器重要的数据表和用户手册。

随着我们不断深入,我们将探讨设置我们的集成开发环境IDE)的复杂性,并认识到它在开发生命周期中的关键作用。此外,你将了解如何配置 GNU Arm 嵌入式工具链和 OpenOCD。这些工具将使我们能够制作固件,从而完全无需 IDE。

在本章中,我们将探讨以下主要主题:

  • 微控制器必备的开发工具

  • 开发板

  • 数据表和手册 – 揭示细节

  • 导航 STM32CubeIDE

技术要求

本章的先决条件如下:

微控制器必备的开发工具

在本节中,我们将探讨构成我们开发过程骨干的基本工具。了解这些工具很重要,因为它们将是我们将想法转化为功能固件时的伴侣。

在选择固件开发工具时,我们有两个主要选项。

  • IDEs:IDE 是一个提供定制图形用户界面GUI)的统一软件应用程序,用于制作软件——在我们的上下文中,是固件。用于微控制器固件开发的流行 IDE 包括以下内容:

  • Keil uVision(也称为 Keil MDK):由 ARM Holdings 开发

  • STM32CubeIDE:由意法半导体公司开发

  • IAR 嵌入式工作台:由 IAR Systems 开发

    这些 IDE 以 GUI 为中心的设计,使用户能够方便地创建新文件、构建、编译和交互式地逐行执行代码。在本书的演示和练习中,我们将使用 STM32CubeIDE。它具有所有必需的功能,并且免费提供,没有任何代码大小限制。

  • 汇编编译链接经常被使用。在本书中,我们将使用开源的 GNU Arm 嵌入式工具链。它基于著名的开源GNU 编译器集合GCC),集成了针对 ARM 的 GCC 编译器、GNU 调试器GDB)调试器和几个其他非常有价值的实用工具。

在下一节中,我们将仔细介绍设置我们首选 IDE——STM32CubeIDE 的过程。

设置 STM32CubeIDE

在本书中,我们将使用 STM32CubeIDE 和 GNU Arm 嵌入式工具链来开发我们的固件。利用像 STM32CubeIDE 这样的 IDE,我们可以轻松地分析和比较 IDE 自动生成的链接脚本和启动文件,以及我们将从头构建的文件。

让我们从下载和安装 STM32CubeIDE 开始:

  1. 打开您的网络浏览器并导航到st.com

  2. 点击STM32 开发者区,然后选择STM32CubeIDE

图 1.1:st.com 的首页

图 1.1:st.com 的首页

  1. 滚动到页面上的所有软件版本部分并点击下载软件。在继续下载之前,您需要登录您的 ST 账户。

图 1.2:stm32cubeide 页面的所有软件版本部分

图 1.2:stm32cubeide 页面上的所有软件版本部分

  1. 如果您没有账户,请点击登录/注册进行注册。如果您已经有了账户,只需登录即可。

  2. 使用您的名字、姓氏和电子邮件地址完成注册表单。

  3. 点击将.zip文件下载到您的下载文件夹。

让我们安装 STM32CubeIDE:

  1. 解压下载的包。

  2. 双击st-stm32cubeide文件以启动安装程序。

  3. 在整个设置过程中点击下一步以保留默认设置。

  4. 选择组件 页面上,确保已选中 SEGGER J-Link 驱动程序ST-LINK 驱动程序。然后,点击 安装

图 1.3:显示选择组件页面的安装程序

图 1.3:显示选择组件页面的安装程序

在我们的计算机上成功安装 STM32CubeIDE 后,我们现在将配置我们的备用开发工具,即 GNU Arm 嵌入式工具链。

设置 GNU Arm 嵌入式工具链

在本节中,我们将介绍设置 GNU Arm 嵌入式工具链的过程——这是开发基于 ARM 微控制器固件的重要工具:

  1. 打开您的网络浏览器并导航到 developer.arm.com/downloads/-/gnu-rm

  2. 滚动页面以找到适合您操作系统的下载链接。对于像我一样使用 Windows 的用户,选择 .exe 版本。对于 Linux 或 macOS 用户,选择适用于您操作系统的相应 .tar 文件。

  3. 下载完成后,双击安装程序以开始安装过程。

  4. 阅读许可协议。然后,通过点击 安装 选择在默认文件夹位置安装。

图 1.4:GNU Arm 嵌入式工具链安装程序

图 1.4:GNU Arm 嵌入式工具链安装程序

  1. 安装完成后,请确保您检查了 添加路径到环境变量 选项。

图 1.5:显示添加环境变量路径选项的安装程序

图 1.5:显示添加环境变量路径选项的安装程序

  1. 点击 完成 以完成设置。

设置 OpenOCD

对于使用 GNU Arm 工具链进行固件开发,OpenOCD 扮演着至关重要的角色,它既可以帮助我们将固件下载到我们的微控制器中,也可以实时调试代码。

让我们设置 OpenOCD:

  1. 打开您的网络浏览器并导航到 openocd.org/pages/getting-openocd.html

  2. 滚动到标题为 非官方二进制软件包 的部分。

  3. 点击链接下载多平台二进制文件。

图 1.6:非官方二进制软件包部分

图 1.6:非官方二进制软件包部分

  1. 确定与您的操作系统兼容的最新版本。要获取完整列表,请点击 显示所有 14 个资源

图 1.7:OpenOCD 软件包

图 1.7:OpenOCD 软件包

  1. 对于 Windows 用户,下载 win32-x64.zip 版本。对于 Linux 或 macOS 用户,下载适用于您操作系统的相应 .tar 文件。

  2. 下载完成后,解压软件包。

  3. 导航到解压后的文件夹,然后进入 bin 子文件夹。在这里,您可以找到 openocd.exe 应用程序。这是我们将在命令提示符中调用以与所选微控制器的特定脚本一起调试或下载代码到微控制器中的应用程序。

xpack-openocd-0.12.0-2 | openocd | scripts 目录结构中,您可以找到针对各种微控制器和开发板的定制脚本。

接下来,我们需要将 OpenOCD 添加到我们的环境变量中:

  1. 首先将整个 openocd 文件夹移动到您的 程序 文件 目录。

图 1.8:OpenOCD 移动到程序文件,显示 bin 文件夹的路径

图 1.8:OpenOCD 移动到程序文件,显示 bin 文件夹的路径

  1. 复制 openocd bin 文件夹的路径。

  2. 右键点击 此电脑,然后选择 属性

  3. 搜索并选择 编辑系统 环境变量

图 1.9:此电脑属性页面

图 1.9:此电脑属性页面

  1. 系统属性 弹出窗口中点击 环境变量 按钮。

图 1.10:系统属性弹出窗口

图 1.10:系统属性弹出窗口

  1. 环境变量 弹出窗口的 用户变量 部分,双击 路径 项。

图 1.11:环境变量弹出

图 1.11:环境变量弹出

  1. 编辑环境变量 弹出窗口中,点击 新建 以创建一个新路径项的行。

  2. 将之前复制的 OpenOCD 路径粘贴到这个新行中。

  3. 通过在各个弹出窗口中点击 确定 来确认您的更改。

图 1.12:编辑环境变量弹出

图 1.12:编辑环境变量弹出

最后,我们已经成功完成了设置过程。我们已配置了两个用于开发 STM32 微控制器固件的必要独立工具——STM32CubeIDE 用于 IDE,以及 GNU Arm Embedded Toolchain,辅以 OpenOCD,以便在没有 IDE 的情况下开发和调试我们的固件。

接下来,我们将把注意力转向我们的开发板。

开发板

在本章的这一部分,我们将深入探讨本书所选开发板的规格和特性。

理解开发板的作用

首先,让我们明确开发板的概念。重要的是要注意,开发板并不等同于微控制器。虽然开发板的名字可能部分来源于其上安装的微控制器,但将板本身称为微控制器是不准确的。开发板允许我们在最终产品中部署的确切微控制器变体上验证我们的固件。因此,在开发板上测试的固件确保在最终产品的微控制器上以相同的方式运行。这就是为什么像 STMicroelectronics 这样的公司提供各种开发板,针对他们产品组合中的每个微控制器进行定制。

同样重要的是要将开发板的作用与原型板,如 Arduino,进行对比。虽然原型板(可能不包含最终产品中打算使用的微控制器)作为初步测试平台,但开发板提升了这一过程。它们使我们能够在为大量产品制造指定的微控制器上严格测试概念和固件的性能评估。本书的目的将专注于 NUCLEO-F411 开发板。

NUCLEO-F411 开发板概述

NUCLEO-F411 开发板配备了一个 STM32F411RE 微控制器,其峰值工作频率可达 100MHz。它拥有丰富的 512 Kbytes 闪存和 128 Kbytes SRAM。此外,该板还配备了多列 berg 引脚,使我们能够轻松地使用跳线连接到各种模块和组件,从传感器和电机到 LED。为了快速简便地进行输入/输出固件测试,该板还内置了用户按钮和 LED,消除了对外部组件的即时需求。

图 1.13:NUCLEO-F411 开发板

图 1.13:NUCLEO-F411 开发板

现在我们已经熟悉了开发板,让我们深入了解对开发板的全面理解和编程所必需的各种文档。

数据表和手册 – 揭示细节

本书的主要目标是编写与我们的微控制器寄存器直接交互的固件代码。这意味着我们的代码与目标微控制器之间没有抽象或中间库。为了实现这一点,了解微控制器的内部架构、理解我们交互的每个寄存器的地址以及了解那些寄存器中相关位的函数至关重要。这正是数据表和手册的作用所在。制造商提供这些文档,让用户了解他们的产品,在我们的情况下,指的是微控制器核心架构、微控制器和开发板。

两个不同的公司在我们的开发板制作中扮演着角色。第一个是 ARM Holdings,它向半导体制造公司如意法半导体、德州仪器和瑞萨等授权处理器和微控制器核心架构设计。这些制造商然后基于从 ARM 授权的设计生产物理微控制器或处理器,通常还会添加自己的定制功能。这就是为什么来自不同制造商的两个不同的微控制器可能共享相同的微控制器核心。例如,德州仪器的 TM4C123 和意法半导体的 STM32F4 都是基于 ARM Cortex-M4 核心。

由于我们选择的发展板,即意法半导体的 NUCLEO-F411,是基于 ARM Cortex-M4 微控制器核心,在接下来的章节中,我们将深入研究该板、其集成微控制器和底层核心的文档。

理解意法半导体的文档

STM32 微控制器之所以受欢迎,一个重要原因是意法半导体(STMicroelectronics)持续致力于提供全面的支持。这包括组织良好的文档和各种固件开发资源。

意法半导体有一系列文档,每个都遵循特定的命名约定。让我们讨论与我们工作相关的那些:

  • RM后跟一个数字。例如,我们微控制器的 RM 是RM0383。这份文档详细说明了我们微控制器中的每个寄存器,解释了每个位的角色,并提供了关于寄存器配置的见解。

  • STM32F411。本文件提供了微控制器的功能概述、完整的内存映射、展示微控制器外设和连接总线的框图,以及微控制器的引脚排列和电气特性。

  • UM后跟一个数字,例如,我们 NUCLEO-F411 的UM1724,这份文档专注于开发板。它描述了我们板上的组件,如 LED 和按钮,是如何连接到微控制器的特定端口和引脚的。

ARM 的通用用户指南

ARM 为它们设计的每个微控制器和处理器核心提供文档。对我们讨论重要的是我们微控制器核心的通用用户指南。由于我们使用的是基于 ARM Cortex-M4 核心的 STM32F411,我们将参考 Cortex-M4 通用用户指南。

这意味着如果我们使用的是基于 ARM Cortex-M7 核心的 STM32F7 微控制器,那么我们就需要获取 Cortex-M7 通用用户指南。这份文档的命名约定只是微控制器核心的名称加上短语通用``用户指南

如其名所示,本文件提供了关于特定微控制器核心的通用信息。这意味着在 Cortex-M4 通用用户指南中提供的信息适用于所有基于 Cortex-M4 核心的微控制器,无论这些微控制器的制造商是谁。相比之下,STMicroelectronics 文档中提供的信息仅适用于 STMicroelectronics 的微控制器。

图 1.14:开发板、微控制器和微控制器核心之间的关系

图 1.14:开发板、微控制器和微控制器核心之间的关系

为什么我们需要通用用户指南?

通用用户指南提供了关于处理器核心核心外设的信息。正如其名称所暗示的,这些核心外设在基于特定核心的所有微控制器中是一致的。Cortex-M4 核心有五个核心外设——系统定时器、浮点单元、系统控制块、内存保护单元和嵌套向量中断控制器。当为这些外设开发裸机驱动程序时,通用用户指南是获取必要细节的权威来源。

此外,本指南还提供了关于微控制器核心指令集、程序员模型、异常模型、故障处理和电源管理的信息。

获取文档

要获取上述文档,您可以在 Google 上使用以下搜索词:

  • STM32F11 参考手册RM0383.

  • STM32F411 数据手册.

  • Nucleo-F11 用户手册UM1724.

  • Cortex-M4 通用 用户指南

这些文档的直接链接也在此章的 技术要求 部分提供。

在分析编程我们的开发板的关键区域的各种文档之前,让我们首先更仔细地看看我们之前安装的 STM32CubeIDE。在下一节中,我们将熟悉其功能和特性。

导航 STM32CubeIDE

当您首次启动 STM32CubeIDE 时,您将看到 信息中心。此中心提供了快速访问许多对 STM32 固件开发有价值的资源。

要退出信息中心,只需在其选项卡上单击 X。如果您稍后想再次访问它,只需导航到 帮助 | 信息中心

图 1.15:信息中心

图 1.15:信息中心

STM32CubeIDE 基于 Eclipse 框架,因此其布局和元素与其他基于 Eclipse 的 IDE 类似。

让我们通过创建新项目的流程来了解一下:

  1. 您可以在空白的 项目资源管理器 窗格中单击 创建一个新的 STM32 项目,或者选择 文件 | 新建 | STM32 项目

图 1.16:显示空项目资源管理器的工位图

图 1.16:显示空项目资源管理器的工位图

  1. 您将看到一个 目标选择 窗口,用于选择项目所需的微控制器或开发板。

  2. 点击 选择 选项卡。

  3. 商业零件 编号 字段中输入 NUCLEO-F411

图 1.17:目标选择窗口

图 1.17:目标选择窗口

  1. 从显示的板列表中选择 NUCLEO-F11RE,然后点击 下一步

图 1.18:选择 NUCLEO-F411 的板列表

图 1.18:选择 NUCLEO-F411 的板列表

  1. 给项目起一个名字。

  2. 对于 目标项目类型,选择

图 1.19:STM32 项目设置窗口

图 1.19:STM32 项目设置窗口

  1. 点击 完成 按钮创建项目。

您将在 项目 资源管理器 窗格中看到包含所有必要启动文件和链接脚本的新项目。

图 1.20:显示新项目的项目资源管理器窗格

图 1.20:显示新项目的项目资源管理器窗格

理解控制图标

最常用的控制图标是 新建构建调试 图标。

图 1.21:控制图标

图 1.21:控制图标

让我们仔细看看这些图标的功能:

  • 新建图标:此图标允许我们创建各种文件,包括源代码、头文件、项目、库等。此功能也可以通过 文件 | 新建 访问。

  • 构建图标:用于构建项目。此功能也可以通过在项目上右键单击并选择 构建项目 来访问。

  • 调试图标:此图标启动调试配置,以方便项目调试。此功能也可以通过在项目上右键单击并选择 调试项目 来访问。

这些控制图标提供了快速访问基本功能的途径,显著提高了生产力和简化了开发过程。

摘要

在本章中,我们旨在为嵌入式固件开发创建一个健壮的环境,重点关注基本工具的精心选择和设置。我们选择的每个工具都在微控制器固件的高效开发中扮演着关键角色。我们探讨了 STM32CubeIDE、GNU Arm Embedded Toolchain 和 OpenOCD 的安装过程,为我们的发展活动奠定了坚实的基础。

我们随后介绍了配备 STM32F411RE 微控制器的 NUCLEO-F411 开发板,作为我们的实验平台,并花费时间识别了板上的某些组件。

我们还强调了了解不同类型的数据表和参考手册的重要性,使我们能够快速获取有关微控制器架构和功能性的详细信息。

接下来,我们将进入下一章,我们将着手开发我们的第一个裸机固件,仅使用我们已编译的文档作为我们的指南。

第二章:从内存地址构建外设寄存器

裸机编程完全是关于直接与微控制器中的寄存器工作,而不通过库,这使我们能够更深入地了解微控制器的功能和限制。这种方法使我们能够优化我们的固件以实现速度和效率,这两个参数在资源通常有限的嵌入式系统中非常重要。

在本章中,我们的旅程从探索各种固件开发方法开始,突出每种方法提供的不同抽象级别。然后我们继续学习如何识别我们开发板上关键组件相关的端口和引脚。这一步对于与微控制器外设建立适当的接口至关重要。

接下来,我们将深入探讨如何使用微控制器的官方文档定义一些外设的地址。这将使我们能够创建那些外设中各种寄存器的地址。

在本章的后半部分,我们的重点转向实际应用。我们将使用我们创建的寄存器地址来配置 PA5,以激活开发板的用户发光二极管(LED)。

在本章中,我们将涵盖以下主要主题:

  • 不同类型的固件开发

  • 定位和理解开发板组件

  • 通过文档洞察定义和创建寄存器

  • 寄存器操作 – 从配置到运行您的第一个固件

到本章结束时,你将在寄存器级别导航和编程 STM32 微控制器方面打下坚实的基础。你将准备好编写你的初始裸机固件,仅依靠从文档和集成开发环境(IDE)收集的信息。

技术要求

本章的所有代码示例都可以在 GitHub 上找到,网址为github.com/PacktPublishing/Bare-Metal-Embedded-C-Programming

不同类型的固件开发

根据微控制器制造商提供的资源,开发特定微控制器的固件有几种方法。当涉及到开发来自意法半导体(STMicroelectronics)的 STM32 微控制器的固件时,我们可以使用以下方法:

  • 硬件抽象层 (HAL):这是意法半导体提供的一个库。它通过提供配置微控制器每个方面的低级 API 来简化过程。HAL 的伟大之处在于其可移植性。我们可以为某个 STM32 微控制器编写代码,并轻松地将其适应到另一个微控制器,这得益于它们 API 的一致性。

  • 底层 (LL):同样来自意法半导体,LL 库是 HAL 的一个更精简的替代品,提供了一种更快、更专业导向的方法,更接近硬件。

  • 裸机 C 编程:使用这种方法,我们直接进入硬件,使用 C 语言直接访问微控制器的寄存器。这更复杂,但提供了对微控制器工作原理的更深入理解。

  • 汇编语言:这与裸机 C 类似,但不是使用 C 语言,而是使用汇编语言直接与微控制器的寄存器交互。

让我们比较一下我们讨论过的四种固件开发方法:HAL、LL、裸机 C 和汇编语言。每种方法都有其独特的风格和抽象级别,影响着我们与微控制器硬件的交互方式。我们将通过配置一个通用输入/输出GPIO)引脚作为输出来说明这些差异。

HAL

以下代码片段演示了如何使用 HAL 初始化 GPIOA 引脚 5 作为输出:

#include "stm32f4xx_hal.h"
GPIO_InitTypeDef GPIO_InitStruct = {0};
// Enable the GPIOA Clock
__HAL_RCC_GPIOA_CLK_ENABLE();
// Configure the GPIO pin
GPIO_InitStruct.Pin = GPIO_PIN_5;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

让我们分析这个片段:

  • #include "stm32f4xx_hal.h": 这行代码包含 STM32F4 系列的 HAL 库,提供对 HAL 函数和数据结构的访问

  • GPIO_InitTypeDef GPIO_InitStruct = {0}: 这里,我们声明并初始化一个GPIO_InitTypeDef结构体实例,该结构体用于配置 GPIO 引脚属性

  • __HAL_RCC_GPIOA_CLK_ENABLE(): 此宏调用启用 GPIO 端口 A 的时钟,确保 GPIO 外设供电并可以工作

  • GPIO_InitStruct.Pin = GPIO_PIN_5: 这行代码设置了要配置的引脚,在这种情况下,端口 A 的 5 号引脚

  • GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP: 在这里,我们将引脚配置为输出引脚

  • HAL_GPIO_Init(GPIOA, &GPIO_InitStruct): 最后,我们使用在GPIO_InitStruct中指定的配置设置初始化 GPIO 引脚(PA5)

此代码片段展示了 HAL 方法在执行常见硬件接口任务时的易用性和可读性。我们可以总结 HAL 方法的优缺点如下:

  • 抽象级别:高

  • 易用性:由于其高级抽象,对初学者来说更容易使用

  • 代码冗长性:更冗长,对于简单任务需要多行代码

  • 可移植性:在 STM32 设备之间表现优异

  • 性能:由于额外的抽象层,略慢

LL

这就是如何使用 LL 库初始化 GPIOA 引脚 5 作为输出引脚:

#include "stm32f4xx_ll_bus.h"
#include "stm32f4xx_ll_gpio.h"
// Enable the GPIOA Clock
LL_AHB1_GRP1_EnableClock(LL_AHB1_GRP1_PERIPH_GPIOA);
// Configure the GPIO pin
LL_GPIO_SetPinMode(GPIOA,LL_GPIO_PIN_5,LL_GPIO_MODE_OUTPUT);

让我们逐行分析:

  • #include "stm32f4xx_ll_bus.h"#include "stm32f4xx_ll_gpio.h" 包含处理总线系统和 GPIO 功能的必要 LL 库文件

  • LL_AHB1_GRP1_EnableClock(LL_AHB1_GRP1_PERIPH_GPIOA): 此函数调用启用 GPIO 端口 A 的时钟

  • LL_GPIO_SetPinMode(GPIOA, LL_GPIO_PIN_5, LL_GPIO_MODE_OUTPUT): 最后,我们将 GPIOA 引脚 5 的模式设置为输出模式

与 HAL 相比,LL 库提供了更直接、更低级的硬件交互方法。在需要更精细的硬件控制和性能的场景中,这通常更受欢迎。

LL 方法的优缺点如下:

  • 抽象级别:中等

  • 易用性:中等,在抽象和直接控制之间取得平衡

  • 代码冗余度:比 HAL 更简洁,提供了更直接与硬件交互的方法

  • 可移植性:良好,但略低于 HAL

  • 性能:比 HAL 更快,因为它更接近硬件

裸机 C

让我们看看如何使用裸机 C 方法完成相同任务:

#define GPIOA_MODER (*(volatile unsigned long *)(GPIOA_BASE + 0x00))
#define RCC_AHB1ENR (*(volatile unsigned long *)(RCC_BASE + 0x30))
// Enable clock for GPIOA
RCC_AHB1ENR |= (1 << 0);
// Set PA5 to output mode
GPIOA_MODER |= (1 << 10); // Set bit 10 (MODER5[1])

让我们分解裸机 C 代码片段:

  • #define GPIOA_MODER (*(volatile unsigned long *) (GPIOA_BASE + 0x00))#define RCC_AHB1ENR (*(volatile unsigned long *)(RCC_BASE + 0x30)) 定义了指向微控制器内存中特定寄存器的指针。GPIOA_MODER指向 GPIO 端口 A 模式寄存器,而RCC_AHB1ENR指向重置和时钟控制RCC)AHB1 外设时钟使能寄存器ER)。使用volatile关键字确保编译器将这些视为内存映射寄存器,防止与优化相关的问题。

  • RCC_AHB1ENR |= (1 << 0): 这行代码启用 GPIOA 的时钟。它通过设置RCC_AHB1ENR寄存器的第一个位(位 0)来实现。位或赋值运算符(|=)确保只更改指定的位,而不会更改寄存器中的其他位。

  • GPIOA_MODER |= (1 << 10): 这行代码将 PA5 设置为输出模式。在 GPIO 端口号模式寄存器(GPIOA_MODER)中,每个引脚由两个位控制。对于 PA5,这些是位 10 和 11(MODER5[1:0])。代码将位 10 设置为1(并假设位 11 已经是0,将其保留为0),将 PA5 配置为通用输出模式。

使用这种方法,我们可以观察到提供的粒度和直接控制。通过直接操作微控制器的寄存器,它提供了非常高的效率和性能:

  • 抽象级别:低

  • 易用性:对于初学者来说具有挑战性,因为它需要深入了解硬件知识

  • 代码冗余度:更简洁,直接

  • 可移植性:有限,因为代码通常针对特定的硬件配置

  • 性能:非常高,因为它允许直接和优化的硬件操作

汇编语言

最后,让我们分析配置 PA5 为输出引脚的汇编语言实现:

EQU GPIOA_MODER, 0x40020000
EQU RCC_AHB1ENR, 0x40023800
; Enable clock for GPIOA
LDR R0, =RCC_AHB1ENR
LDR R1, [R0]
ORR R1, R1, #(1 << 0)
STR R1, [R0]
; Set PA5 as output
LDR R0, =GPIOA_MODER
LDR R1, [R0]
ORR R1, R1, #(1 << 10)
STR R1, [R0]

让我们分解一下:

  • EQU GPIOA_MODER, 0x40020000EQU RCC_AHB1ENR, 0x40023800 定义了 GPIOA 模式寄存器(GPIOA_MODER)和 RCC AHB1 外设时钟 ER(RCC_AHB1ENR)的内存地址常量。'EQU'在汇编中用于将标签等同于值或地址。

其余的汇编指令执行两个主要任务:

  • 启用 GPIOA 的时钟:

    • LDR R0, =RCC_AHB1ENR: 将RCC_AHB1ENR寄存器的地址加载到R0寄存器

    • LDR R1, [R0]: 将RCC_AHB1ENR寄存器的值加载到R1寄存器

    • ORR R1, R1, #(1 << 0): 执行位或操作以设置R1的位 0,打开 GPIOA 的时钟

    • STR R1, [R0]:将更新后的值存储回RCC_AHB1ENR寄存器

  • 将 PA5 设置为输出:

    • LDR R0, =GPIOA_MODER:将GPIOA_MODER寄存器的地址加载到R0

    • LDR R1, [R0]:将GPIOA_MODER寄存器的当前值加载到R1

    • ORR R1, R1, #(1 << 10):使用位或操作设置R1的第 10 位,将 PA5 配置为输出

    • STR R1, [R0]:将更新后的值存储回GPIOA_MODER寄存器

汇编语言方法允许对微控制器进行极其详细和直接的控制。我们经常在需要高性能且每个硬件方面都需要精确管理的项目中使用这种方法。

汇编语言方法为我们提供了以下功能:

  • 抽象级别:最低

  • 易用性:最具挑战性,需要彻底了解微控制器架构

  • 代码冗长性:对于复杂任务可能很冗长,因为它是低级的

  • 可移植性:非常有限,因为它高度特定于微控制器架构

  • 性能:最高,因为它允许进行最优化和直接的控制

以下图表显示了每种方法和其与微控制器架构的接近程度:

图 2.1:固件开发方法,按与微控制器架构的接近程度排列

图 2.1:按与微控制器架构的接近程度排列的固件开发方法

现在我们已经探讨了 STM32 微控制器固件开发的多种方法,我们准备深入裸机 C 编程领域。

我们将开始我们的探索,了解开发板的主要组件是如何连接到板载微控制器的特定引脚的。这一初步步骤对于深入了解硬件布局并为详细编程任务做准备至关重要。

定位和理解开发板组件

在本节中,我们的重点是确定微控制器上连接到开发板用户 LED、用户按钮、berg 引脚和 Arduino 兼容引脚的具体端口和引脚。了解这些连接对于我们的编程任务至关重要。为了准确识别这些连接,我们将查阅NUCLEO-F411 用户手册

图 2.2:展示感兴趣组件的开发板

图 2.2:展示感兴趣组件的开发板

现在,让我们定位开发板上连接到用户 LED 的微控制器引脚。

定位 LED 连接

我们的第一步是通过目录导航找到专门介绍 LED 的部分。这可以通过快速定位手册中的图 2.3来实现。它显示了 LED 部分的页码,使我们能够直接跳转到该部分。

点击页码跳转到 LED 部分。

图 2.3:这是 NUCLEO-F411 用户手册目录的一部分,显示了 LED 部分的页码

图 2.3:这是 NUCLEO-F411 用户手册目录的一部分,显示了 LED 部分的页码

LED部分,我们发现用户 LED,标记为User LD2,连接到 ARDUINO®信号D13。这对应于 PA5 或 PB13 引脚,具体取决于我们板上的 STM32 目标。

由于板与 Arduino IDE 的兼容性,用户手册中使用了双重命名约定。在 Arduino 命名方案中,引脚被分类为模拟(以“A”开头)或数字(以“D”开头)。例如,数字引脚 3 表示为 D3。相反,标准 STM32 约定以“P”开头,后面跟着表示端口的字母,然后是该端口内的引脚编号,例如 PA5 表示 A 端口第 5 个引脚。

要确定我们的开发板上的引脚 D13 是板载微控制器的 PA5 还是 PB13,我们参考手册中的表 11 至 23。这些表格将每个开发板上的 ARDUINO®连接器引脚映射到标准 STM32 引脚。具体来说,我们查看显示表 16图 2.5,它涉及我们的开发板型号。

通过点击如图图 2.4 所示的方式导航到表 11。此操作将带您进入表格序列中的第一个表格。然后,向下滚动直到到达表 16

图 2.4:这是 NUCLEO-F411 用户手册的一部分,显示了用户 LED 的两个可能连接

图 2.4:这是 NUCLEO-F411 用户手册的一部分,显示了用户 LED 的两个可能连接

在查看表 16后,我们发现 D13 确实对应于 PA5。这表明我们的 NUCLEO-F411RE 开发板上的用户 LED 连接到板载微控制器的 PA5 引脚。

图 2.5:表 16 显示 D13 对应 PA5

图 2.5:表 16 显示 D13 对应 PA5

在开发板上发现的另一个有用组件是用户按钮。让我们找到这个组件的引脚连接。

定位用户按钮

开发板上的用户按钮是许多嵌入式实验中输入处理的重要组件,理解其与微控制器的连接对于有效的编程和交互至关重要。

要找到我们板上的用户按钮的连接细节,我们将通过目录导航到专门针对按钮的部分。

通过在手册中定位图 2.6,可以快速完成此操作。该图显示了按钮部分的页码,并允许我们通过点击页码直接跳转到该部分。

图 2.6:这是 NUCLEO-F411 用户手册中提供按钮部分页码的部分

图 2.6:本部分 NUCLEO-F411 用户手册提供了按钮部分的页码

在导航到手册的按钮部分后,我们找到了关于我们的用户按钮B1 User的相关信息。手册告诉我们,这个按钮连接到板载微控制器的PC13引脚。

定位伯格引脚和 Arduino 兼容引脚

LEDs部分,我们了解到我们的 NUCLEO-F411 开发板为其引脚提供了两种主要的命名系统:Arduino 命名系统和标准 STM32 命名系统。虽然裸机编程主要使用标准 STM32 命名,但板上的引脚标签是根据 Arduino 系统标注的。了解这些暴露引脚的实际端口名称和引脚编号非常重要,这样我们才能正确连接和编程外部组件,如传感器和执行器。

图 2**.7显示了带有伯格引脚高亮的我们的 NUCLEO-F411 开发板。

图 2.7:这是带有伯格引脚高亮的 NUCLEO-F411 开发板

图 2.7:这是带有伯格引脚高亮的 NUCLEO-F411 开发板

开发板的 Arduino 引脚位于伯格引脚列的两侧。这在图 2**.8中被突出显示。

图 2.8:此 NUCLEO-F411 开发板带有 Arduino 引脚高亮

图 2.8:此 NUCLEO-F411 开发板带有 Arduino 引脚高亮

让我们先找到 Arduino 引脚的微控制器引脚连接。

Arduino 兼容引脚

为了识别 Arduino 引脚的标准 STM32 名称,我们使用目录导航到标题为ARDUINO®连接器的部分。这引导我们到表 11,它提供了 Arduino 引脚到 STM32 引脚的映射。对于我们的特定 NUCLEO-F411 开发板,我们关注表 16,它为我们型号提供了相关的映射。

接下来,让我们定位伯格引脚的连接。

伯格引脚

与我们对其他组件的方法类似,为了找到伯格引脚的详细信息,我们再次查阅手册的目录,并定位到名为扩展连接器的部分。本节包括展示各种 NUCLEO 板引脚排布的图。然后我们滚动找到与我们特定 NUCLEO 型号对应的引脚排布。在这里,我们的开发板引脚排布以标准 STM32 命名系统呈现。

在这里,我们还发现手册将伯格引脚的列称为 ST morpho 连接器。这意味着每当使用morpho 连接器这个词时,它指的是这些公头伯格引脚。

图 2.9:NUCLEO-F411 开发板的引脚排布

图 2.9:NUCLEO-F411 开发板的引脚排布

在本节中,我们了解到 NUCLEO 开发板使用两种命名系统。首先,是 Arduino 命名系统,这在板上可以明显看到,其次,是详细说明在文档中的标准 STM32 命名系统。我们发现,标准 STM32 命名系统对我们的目的特别相关,因为它直接关联到板载微控制器的引脚名称。

下一节将指导我们如何访问和操作板载微控制器的相关内存位置。我们的重点将放在配置 PA5 引脚作为输出引脚上。这将使我们能够控制连接到 PA5 的 LED。

通过文档洞察定义和创建寄存器

在前一节中,我们确定用户 LED 连接到 PA5 引脚。这意味着它与 GPIO PORTA 的 5 号引脚相连。换句话说,要到达 LED,我们必须通过 PORTA,然后找到该端口的 5 号引脚。

图 2.10所示,微控制器在四个侧面都暴露了引脚。这些引脚被组织成不同的组,称为端口。例如,PORTA 中的引脚用PA前缀表示,而 PORTB 中的引脚以PB开头,等等。这种系统性的安排使我们能够轻松识别和访问特定引脚,以便进行编程和硬件接口任务。

图 2.10:STM32F411 引脚图

图 2.10:STM32F411 引脚图

在下一节中,我们将介绍定位 GPIO PORTA 精确地址的步骤。

定位 GPIO PORTA

为了有效地与微控制器的任何部分进行交互,了解该特定部分的内存地址是至关重要的。我们的下一步是探索微控制器的内存映射。通过这样做,我们可以逐步定位 GPIO PORTA 的地址。

由于我们的重点是板载微控制器而不是开发板,我们需要参考微控制器的数据手册,特别是stm32f411re.pdf文档。

让我们从导航到文档的目录开始。在那里,我们将找到一个名为内存映射的部分。点击相应的页码以跳转到该部分。

在这里,我们找到了一个全面的图表,展示了微控制器的整个内存映射。该图表的相关摘录在图 2.11中展示,显示了整体内存布局。

图 2.11:内存映射

图 2.11:内存映射

内存映射显示,微控制器内部的所有内容都是从0x0000 00000xFFFF FFFF进行寻址的。我们感兴趣的部分是关于外设的部分,因为在那里我们找到了 GPIOA。

在微控制器语境中,外设指的是不是中央处理单元CPU)的一部分,但连接到微控制器以扩展其功能的硬件组件。外设执行特定功能,可以包括以下广泛的组件:

  • GPIO 端口:这些用于与外部设备(如 LED、开关和传感器)接口。它们可以被编程为接收输入信号或发送输出信号。

  • 通信接口:这些包括串行通信接口,如通用异步收发传输器UART)、串行外设接口SPI)和集成电路间通信I2C),这些接口使微控制器能够与其他设备、传感器甚至其他微控制器通信。

  • 定时器和计数器:定时器用于测量时间间隔或生成基于时间的事件,而计数器可用于计数事件或脉冲。

  • 模数转换器(ADCs):ADCs 将模拟信号(如来自温度传感器的信号)转换为微控制器可以处理的数字值。

这里提到的外设代表了在微控制器中常见的类型选择。随着我们进入后续章节,我们将更深入地探讨这些和其他外设。

图 12.11中,内存映射显示所有微控制器外设的地址范围从0x400000000x5FFFFFFF。这意味着 GPIO PORTA 的地址位于这个指定的范围内。

外设基本地址 = 0x40000000

让我们记下外设地址的起始位置,我们将称之为PERIPH_BASE,表示外设的基本地址。我们将需要这个地址来计算 GPIO PORTA 的地址。PERIPH_BASE = 0x40000000

图 12.12显示了内存映射中外设部分的放大视图。在这里,我们观察到外设内存被划分为五个不同的块:APB1、APB2、AHB1、AHB2 和位于顶部的 Cortex-M 内部外设块。

图 2.12:外设内存映射

图 2.12:外设内存映射

这些块,除了 Cortex-M 内部外设外,都是以它们所接口的总线系统命名的——即高级外设总线APB)和高级高性能总线AHB):

  • APB1 和 APB2:这些总线为低带宽外设提供服务,为不需要高速数据传输的设备提供更有效的通信方式。

  • AHB1 和 AHB2:这些是为高速数据传输设计的,用于连接高带宽外设。它们使数据、控制和地址通信更快、更高效。

在数据表的第 54 页至第 56 页,我们找到了一个表格,列出了每个总线和相关外设的边界地址。该表格的一部分在图 2.13中显示,其中我们发现 GPIOA 被分配了一个从0x400200000x4002 03FF的边界地址,并且连接到 AHB1 总线。因此,这表明与 GPIO PORTA 相关的所有寄存器的地址都包含在这个地址范围内。

图 2.13:GPIOA 的边界地址和总线

图 2.13:GPIOA 的边界地址和总线

GPIOA 基址 = PERIPH_BASE + 0x20000 = 0x40020000

从表格中,我们发现 GPIOA 边界地址的起始地址是0x40020000。这表明将0x20000的偏移量添加到PERIPH_BASE地址(即 0x40000000)将得到 GPIOA 的基址,计算为0x40000000 + 0x20000 = 0x40020000。术语“偏移值”指的是添加到基址以推导出特定地址的值。在这种情况下,从PERIPH_BASE地址到 GPIOA 的偏移值是0x20000

理解偏移值的概念对于在微控制器编程中准确计算所需的地址至关重要。这种理解使得在系统内存映射中进行精确导航和操作成为可能。

时钟门控

确定了 GPIOA 的确切地址后,我们的下一步是在配置其寄存器之前启用对它的时钟访问。这一步是必要的,因为默认情况下,所有未使用的外设的时钟都是禁用的,以节省电力。

现代微控制器使用一种称为时钟门控的节能技术。简单来说,时钟门控涉及在微控制器不使用时,选择性地关闭其某些部分的时钟信号。时钟信号是微控制器操作的一个基本部分,它通过提供同步脉冲来驱动时序逻辑,从而同步微控制器电路的活动。然而,当微控制器的某个部分(如外设)未被积极使用时,该部分的时钟信号将被禁用。这种禁用可以防止空闲电路的不必要功耗。因此,在使用任何外设之前,首先需要启用对该外设的时钟访问。

图 2.14所示,列出了一个名为 RCC 的外设,其边界地址范围从0x400238000x40023BFF。该外设的功能包括启用和禁用对其他外设的时钟访问。

RCC 基址地址 = PERIPH_BASE + 0x23800 = 0x40023800

从边界地址信息中,我们可以看到RCC_Base地址是通过将PERIPH_BASE的偏移量0x23800添加到PERIPH_BASE获得的。

图 2.14:RCC 的边界地址和总线

在成功确定配置 GPIOA 引脚 5(控制连接的 LED)所需的两个基本外设的基地址后,我们的下一步是使用这些基地址推导出设置引脚为输出并最终激活 LED 所需的特定寄存器地址。

要找到这些寄存器的详细信息,我们将参考参考手册(RM0383)。这份文档提供了关于所有寄存器和它们配置的全面见解。

AHB1 ER

我们的参考手册,RM0383,是一本超过 800 页的综合性文档,一些 STM32 参考手册甚至超过 1500 页。目标不是从头到尾阅读整个手册,而是培养在需要时高效定位特定信息的能力。之前,我们确定了 GPIOA 连接到 AHB1 总线。我们还了解到,激活此外设需要通过 RCC 外设启用时钟访问。

我们微控制器中的 RCC 外设包括一个特定的寄存器,用于为每个总线启用时钟。在 STM32 微控制器中,寄存器的命名遵循一个简单的模式:外设缩写 + 下划线 + 寄存器缩写。例如,负责控制对 AHB1 总线时钟访问的寄存器命名为RCC_AHB1ENR

让我们进一步解释:

  • RCC代表复位和时钟控制

  • AHB1代表高级高性能总线 1

  • ENR代表使能寄存器

这种系统性的命名约定简化了识别和访问适当寄存器的过程。

要查找关于RCC_AHB1ENR寄存器的信息,我们首先打开参考手册。接下来,我们导航到目录表并搜索标题为“RCC AHB1 外设时钟使能寄存器 (RCC_AHB1ENR)”的部分。一旦找到,我们点击该部分标题旁边提供的页码,直接跳转到文档的相关部分。

让我们从页面顶部展示的细节开始分析,如图图 2.15所示。本节提供了关于寄存器的关键信息,包括以下内容:

  • 寄存器名称: 提供了寄存器的全名及其缩写,即RCC AHB1 外设时钟使能寄存器RCC_AHB1ENR)。

  • 0x30.

  • 0x00000000,表示寄存器在复位时持有的值。换句话说,寄存器的默认值。

  • 访问类型: 寄存器支持各种访问类型——它可以无等待状态地访问,并允许字、半字和字节访问。

  • 寄存器图: 包含寄存器的详细图示,显示所有 32 位,并为每个位提供标签。

图 2.15: RCC AHB1 ER

图 2.15: RCC AHB1 ER

RCC_AHB1ENR 地址 = RCC_BASE + 0x30 = 0x40023830

从这些信息中,我们可以准确地计算出 RCC_AHB1ENR 寄存器的地址。我们通过将 RCC_AHB1ENR 偏移量加到 RCC_BASE 地址上来完成此操作。公式如下:RCC_BASE + RCC_AHB1ENR Offset = 0x40023800 + 0x30 = 0x40023830

参考手册的同一部分还包括对寄存器中每个位的详细描述。我们特别关注名为 'GPIOAEN' 的位,它代表 0。在文档下一页的开始处,我们找到了对 bit0 的精确描述,如图 图 2**.16 所示。该描述解释说,将 bit0 设置为 0 禁用 GPIOA 时钟,而将其设置为 1 启用 GPIOA 时钟。

图 2.16:GPIOAEN 位

图 2.16:GPIOAEN 位

在理解了启用 GPIOA 时钟访问所需的步骤之后,我们下一节将重点介绍如何在寄存器中设置和清除位。

在寄存器中设置和清除位

在裸机编程中,操作寄存器中的单个位是一个基本操作。这种操作对于配置硬件设置、控制外围设备以及优化嵌入式系统的性能非常重要。让我们首先了解位和寄存器。

一个 01。另一方面,寄存器是微控制器中的小型存储位置,用于存储各种操作中的临时数据。寄存器通常是位集合(例如 8 位、16 位或 32 位寄存器),寄存器中的每个位都可以单独操作。在裸机编程中,最常用的两个位操作是设置位和清除位。

设置位

设置一个位意味着将其值更改为 1。我们经常使用这个来激活或启用微控制器中的特定功能。

OR 操作 (|) 用于设置一个位:

register |= 1 << bit_position;

在这个操作中,1 被左移 (<<)bit_position,然后与寄存器的当前值进行 OR 操作。左移操作创建一个二进制值,其中只有目标位是 1,其余都是 0。然后 OR 操作将寄存器中的目标位设置为 1,其余保持不变。

假设 register 初始持有 0011(二进制)值,我们想要设置第三个位(位位置 2,0 索引)。位移后的值将是 0100(二进制表示 1 << 2)。然后 OR 操作如下:

0011: original register value
(0001 <<2) = 0100: bit-shifted value
0011
OR
0100
------
0111 (resulting value)

在这个例子中,原始寄存器值的第一个第二个第四个位保持其值,而第三个位的值被更改为 1

让我们看看设置位的相反操作。

清除位

相反,清除一个位意味着将其值更改为 0,通常用于禁用功能。

使用 AND (&) 和 NOT (~) 操作来清除一个位:

register &= ~(1 << bit_position);

在这里,1 被左移到 bit_position,然后应用位运算的位非操作以创建一个二进制数,其中所有位都是 1,除了目标位。与寄存器进行 AND 操作会清除目标位,而其他位保持不变。

假设 register 初始持有 0111(二进制)值,我们想要清除第三个位(位位置 2,0 索引)。掩码的位移值 将是 0100(二进制表示为 1 << 2)。为了清除位,我们使用位移值的位运算与位运算的位非操作。操作如下:

0111: original register value
(0001 << 2) = 0100: bit-shifted value
~0100 = 1011: bitwise NOT of bit-shifted value
0111
AND
1011
----
0011 (resulting value)

在此操作中,寄存器的第三个位被清除(变为 0),而其他位保持其原始值。

让我们总结最后两节的关键点。首先,我们了解到要启用 GPIOA 的时钟访问,需要在 RCC_AHB1ENR 寄存器中设置 bit0。其次,我们探讨了如何使用位运算设置和清除寄存器中的位。接下来,在下一节中,我们将专注于将 GPIOA 引脚 5(PA5)配置为输出引脚。这一步对于激活连接到 PA5 的 LED 至关重要。这将使我们更接近激活连接到 PA5 的 LED。

GPIO 端口模式寄存器(GPIOx_MODER)

STM32 微控制器中的GPIO 端口模式寄存器是一个专用寄存器,用于设置每个 GPIO 引脚的模式。为了定位有关此寄存器的信息,我们导航到参考手册的目录并查找标题为 GPIO 端口模式寄存器 (GPIOx_MODER) 的部分。通过点击关联的页码,我们直接跳转到该部分。

图 2**.17 展示了页面顶部部分。在这里,我们可以观察到以下细节:

  • GPIOA_MODER 通过 GPIOE_MODER,以及 GPIOH_MODER。这意味着这些 GPIO 端口具有相同的寄存器结构和配置。

  • MODER 寄存器位于端口内存空间的起始位置。因此,GPIOA MODE 寄存器地址与 GPIOA 基址相同。

  • GPIOA_MODER 寄存器是 0xA800 0000。此值表示 GPIOA 引脚在复位时的初始配置状态。

  • GPIOB_MODER 寄存器具有默认的复位值 0x0000 0280

  • 所有其他指定 GPIO 端口的 MODER 寄存器均为 0x0000 0000

GPIOA MODE 寄存器地址 = GPIOA_BASE = 0x40020000

图 2.17:GPIO 端口模式寄存器

图 2.17:GPIO 端口模式寄存器

我们还观察到这是一个 32 位寄存器,其位以成对的方式组织。例如,bit0bit1 一起形成一个称为 MODER0 的对,bit2bit3 形成称为 MODER1 的对,bit4bit5 形成称为 MODER2 的对,以此类推。这些对中的每一个都对应于 GPIO 端口的单个引脚。具体来说,MODER0 控制相应端口的 PIN0 的配置,MODER1 控制 PIN1,其他引脚的模式也以类似的方式继续。

给定我们的目标是配置 PIN5 的模式,我们需要关注 MODER5。在寄存器中,MODER5bit10bit11 组成。这两个位是设置 PIN5 操作模式所需的位。

参考手册提供了一个真值表,如图 2**.18 所示,该表解释了配置引脚所需的两个 MODER 位组合。此表是理解如何设置位以实现所需的引脚配置(无论是作为输入、输出还是备用功能模式)的宝贵资源。

图 2.18: 位配置

图 2.18:MODER 位配置

图 2**.18 所示,GPIO 端口的 MODER 寄存器由位对组成,标记为 2y:2y+1 MODERy[1:0],其中 y 的范围从 0 到 15,代表端口中的每个 16 个引脚(PIN0PIN15)。

在此方程中,y 代表引脚号。对于引脚 5,y = 5。将此值代入方程,我们得到 MODER 寄存器中对应引脚 5 的位位置:

MODER5 的位位置计算为 2y* 和 (**2y) +1*。

y = 5 替换,我们得到以下结果:

25 = 10* 和 25 + 1 = 11*,分别是 1011

因此,MODER 寄存器中的 bits 1011MODER5[1:0])是控制引脚 5 模式的位。通过将这些位设置为特定的值(00011011),我们可以将引脚 5 配置为输入、通用输出、备用功能或模拟模式。

GPIO MODER 寄存器支持四种不同的位组合,每个组合定义了对应引脚的不同操作模式:

  • 00: 当两个位都是 0 时,相应的引脚被设置为输入引脚。这是引脚从外部源接收数据的标准模式。

  • 01: 将位设置为 01 将引脚功能设置为通用输出。在此模式下,引脚可以发送数据,例如点亮 LED。

  • 10: 10 状态配置引脚为备用功能。每个引脚可以提供特定的附加用途(例如 PWM 输出和 I2C 通信线路),并且此模式启用这些功能。

  • 11: 当位设置为 11 时,引脚在模拟模式下运行。此模式通常用于 ADC,用于从模拟传感器读取值。

从这个例子中,我们了解到要配置 PA5 为输出,我们必须将 GPIOA_MODER 寄存器的第 10 位设置为 0,第 11 位设置为 1

让我们总结一下激活连接到 PA5 的 LED 的进展:

  • RCC_AHB1ENR 寄存器的 bit0 设置为 1。这一步对于为 GPIOA 提供电是必不可少的。

  • 配置 PA5 为输出:我们刚刚学习了如何将 PA5 设置为通用输出引脚。

这两个步骤有效地将 PA5 配置为输出引脚。最终的任务涉及控制引脚的输出状态——将其设置为10,分别对应开启或关闭。这相当于向 PA5 发送 3.3v 或 0v,从而打开或关闭连接的 LED。要管理引脚的输出状态,我们需要与输出数据寄存器ODR)交互。定位和配置此寄存器将是下一节的重点。

到目前为止,我们关于激活连接到 PA5 的 LED 的查询有以下信息:

  • 我们知道如何通过RCC_AHB1ENR寄存器启用 GPIOA 的时钟访问

  • 我们知道如何将 GPIOA 的 PIN5 配置为通用输出引脚。

这两个步骤使 PA5 充当输出引脚。最后一步是能够设置引脚的输出状态。状态可以是10,对应开启或关闭,对应向 PA5 发送 3.3v 或 0v,最终对应于打开或关闭连接到 PA5 的 LED。要设置引脚的输出,我们需要访问 ODR;这将是下一节的重点。

GPIO 端口输出数据寄存器(GPIOx_ODR)

在 STM32 微控制器中,GPIO 端口的ODR用于控制每个 GPIO 引脚的输出状态。要查找有关此寄存器的信息,我们参考微控制器参考手册的目录,并定位标题为GPIO 端口输出数据寄存器(GPIOx_ODR)的部分。点击此部分对应的页码将直接带我们到所需的信息。

图 2.19显示了页面顶部,我们可以观察到以下细节:

  • GPIOA_ODR通过GPIOE_ODR,以及GPIOH_ODR。这意味着这些 GPIO 端口具有相同的寄存器结构和配置。

  • 从其相应 GPIO 端口的基地址的0x14。这意味着对于每个 GPIO 端口,ODR 寄存器都可以在这个偏移量处找到端口的基本内存地址。

  • 在复位时为0。此默认状态确保所有 GPIO 引脚最初处于低输出状态。

GPIOA ODR 地址 = GPIOA_BASE + ODR_OFFSET = 0x40020014

ODR_OFFSET = 0x14

图 2.19:GPIO 端口 ODR

让我们深入了解位的结构:

  • 位 31:16(保留):这些位保留,不应用于任何操作。

  • 015,对应于 GPIO 端口中的每个 16 个引脚。它们可以直接编程,并且可以通过软件读取和写入。更改这些位的值会改变相应 GPIO 引脚的输出状态。

让我们考虑我们的最终任务——激活连接到 PA5 的 LED:

  • GPIOA_ODR寄存器的位5上。

  • 使用位运算OR操作,如下所示:

    GPIOA_ODR |= 1 << 5;
    

在这个操作中,我们将 1 左移 5 位(结果是一个二进制值,其中只有第 6 位是 1),然后将其与 GPIOA_ODR 寄存器的当前值进行 OR 操作。这个动作将 PA5 设置为高电平,而不改变端口中其他引脚的状态。

将 PA5 设置为高电平将为连接的 LED 提供电压,从而将其点亮。

现在我们已经了解了如何设置 GPIO 输出引脚的状态,在下一节中,我们将结合我们所学到的所有信息来开发我们的第一个固件。

寄存器操作 – 从配置到运行第一个固件

在本节中,我们将应用本章学到的知识来开发我们的第一个裸机固件。

我们首先创建一个新项目,这个过程我们在 第一章 中已经介绍过。以下是步骤的总结:

  1. 开始一个新项目:在你的 IDE 中,转到 文件 | 新建 | STM32 项目

  2. 选择目标微控制器:将出现一个 目标选择 窗口,提示您为项目选择微控制器或开发板。

  3. 使用板选择器:单击 选择器 选项卡。

  4. 商业零件编号 字段中填写 NUCLEO-F411

  5. 选择我们的板:从出现的板列表中选择 NUCLEO-F411RE,然后单击 下一步

  6. RegisterManipulation

  7. 项目设置。

  8. 最后一步:单击 完成 以创建项目。

一旦项目创建完成,打开项目工作区中的 main.c 文件。清除此文件中所有现有的文本,以便从干净的状态开始编写我们的代码。

为了更清晰地理解,我们将把我们的代码分为两个不同的部分。第一部分将命名为 寄存器定义,第二部分将命名为 主函数

Register Definitions

这段代码定义了用于内存地址和位掩码的常量和宏。以下是控制连接到 PA5 的 LED 所需的所有内存地址和位掩码:

//  1: Define base address for peripherals
#define PERIPH_BASE        (0x40000000UL)
//  2: Offset for AHB1 peripheral bus
#define AHB1PERIPH_OFFSET  (0x00020000UL)
//  3: Base address for AHB1 peripherals
#define AHB1PERIPH_BASE    (PERIPH_BASE + AHB1PERIPH_OFFSET)
//  4: Offset for GPIOA
#define GPIOA_OFFSET       (0x0000UL)
//  5: Base address for GPIOA
#define GPIOA_BASE         (AHB1PERIPH_BASE + GPIOA_OFFSET)
//  6: Offset for RCC
#define RCC_OFFSET         (0x3800UL)
//  7: Base address for RCC
#define RCC_BASE           (AHB1PERIPH_BASE + RCC_OFFSET)
//  8: Offset for AHB1EN register
#define AHB1EN_R_OFFSET    (0x30UL)
//  9: Address of AHB1EN register
#define RCC_AHB1EN_R  (*(volatile unsigned int *)(RCC_BASE +  AHB1EN_R_OFFSET))
//  10: Offset for mode register
#define MODE_R_OFFSET      (0x00UL)
//  11: Address of GPIOA mode register
#define GPIOA_MODE_R  (*(volatile unsigned int *)(GPIOA_BASE + MODE_R_OFFSET))
//  12: Offset for output data register
#define OD_R_OFFSET   (0x14UL)
//  13: Address of GPIOA output data register
#define GPIOA_OD_R    (*(volatile unsigned int *)(GPIOA_BASE +  OD_R_OFFSET))
//  14: Bit mask for enabling GPIOA (bit 0)
#define GPIOAEN       (1U<<0)
//  15: Bit mask for GPIOA pin 5
#define PIN5          (1U<<5)
//  16: Alias for PIN5 representing LED pin
UL suffix at the end of each hexadecimal value as well as the use of keywords such as volatile. Let’s delve into the significance of these in the context of C and C++ programming.
			The UL suffix
			When we see a number in the code ending with `UL`, it’s more than just a part of the number – it’s a clear instruction to the compiler about the type and size of the number:

				*   `U` in `UL` indicates that the number is unsigned. In other words, it’s a positive number with no sign to indicate it could be negative. This designation allows the number to represent a wider range of positive values compared to a signed integer of the same size.
				*   `L` signifies that the number is a long integer. This is important because the size of a long integer can vary based on the system and the compiler. Typically, a long integer is larger than a regular int – often 32 bits, but sometimes 64 bits on certain systems.

			The `UL` suffix collectively ensures that these values are treated as unsigned long integers. This is important for firmware development and other forms of low-level programming, where the exact size and “signedness” of an integer can significantly impact program behavior and memory management. Using `UL` leads to more predictable, platform-independent code, ensuring that the values behave consistently across different compilers and systems.
			The use of “(*(volatile unsigned int *)”
			In the context of bare-metal programming, particularly in our current code snippet, we notice that the address of each register is prefixed with `"(*(volatile unsigned` `int *)".`
			Let’s break down what each part of this notation means and why it’s used:

				*   `(unsigned int *)` expression is a **type cast**. It tells the compiler to treat the subsequent address as a pointer to an unsigned integer. In C and C++, pointers are variables that store the memory address of another variable. In the context of our firmware, this casting means that we are directing the compiler to treat a certain address as the location of an unsigned integer. However, this unsigned integer is not just any number; it corresponds directly to the state of a 32-bit hardware register. Each bit in this 32-bit integer mirrors a specific bit in the register, thereby allowing direct control and monitoring of the hardware’s state through standard programming constructs.
				*   `*`) in `*(unsigned int *)` is used to dereference the pointer. Dereferencing a pointer means accessing the value stored at the memory address the pointer is pointing to. Essentially, it’s not just about knowing where the data is (the address); it’s about actually accessing and using that data.
				*   `volatile`: The `volatile` keyword tells the compiler that the value at the pointer can change at any time, without any action being taken by the code. This is often the case with hardware registers, where the value can change due to hardware events, external inputs, or other aspects of the system outside the program’s control.

			Without `volatile`, the compiler might optimize out certain reads and writes to these addresses under the assumption that the values don’t change unexpectedly. This is because when a compiler processes code, it looks for ways to make the program run more efficiently. This includes removing redundant operations or simplifying code paths. This process occurs during the compilation of the program before it’s run. If the compiler determines that a variable (including a memory-mapped hardware register) doesn’t change its value, it might optimize the code by eliminating repeated reads from or writes to that variable. For example, if a value is read from a register and then read again later, the compiler might assume the value hasn’t changed and use the previously read value instead of accessing the register a second time. The use of `volatile` is a directive to tell the compiler not to apply certain optimizations to accesses of the marked variable, maintaining the integrity of operations.
			In our code snippet, we encounter two additional terms that are very common in bare-metal programming: **bit mask** and **alias**. Let’s take a closer look at each of these concepts to gain a clearer understanding of the roles they play:

				*   `AND`, `OR`, `XOR`) along with the mask to achieve the desired result. For example, a bit mask might be used to turn on a specific LED connected to a microcontroller pin without altering the state of other pins. A bit mask is created by setting the bits we want to manipulate to `1` while keeping others at `0`. This mask is then combined with the register value using the appropriate bitwise operation.

    For example, a `0b00000100` mask used with `OR` will set the third bit of the target register.

				*   `LED_PIN` makes the code self-explanatory. We define aliases using preprocessor directives such as `#define` in C or C++. For example, `#define LED_PIN (1U<<5)` creates an `LED_PIN` alias for the bit mask that represents the sixth pin (zero indexing) in a GPIO port ODR.

			Now, let’s analyze the second section of the code.
			Main Function
			This section of our code outlines the primary operations for controlling the LED connected to PA5 of the microcontroller:

// 行 17:main 函数开始

int main(void)

{

// 18:启用对 GPIOA 的时钟访问

RCC_AHB1EN_R |= GPIOAEN;

GPIOA_MODE_R |= (1U<<10);  // 19:将位 10 设置为 1

GPIOA_MODE_R &= ~(1U<<11); // 20:将位 11 设置为 0

// 21:无限循环开始

while(1)

{

// 22:将 PA5(LED_PIN) 设置为高

GPIOA_OD_R |= LED_PIN;

}  // 23:无限循环结束

}  // 24:main 函数结束


			Let’s break down each part of the code for a clearer understanding:

				*   *Line 17*: This marks the beginning of the main function. This is the entry point of our program where the execution starts:

    ```

    // 17:main 函数开始

    int main(void)

    {

    ```cpp

    				*   *Line 18*: This enables the clock for `GPIOA`. As we learned earlier, the `RCC_AHB1EN_R` register controls the clock to the `AHB1` bus peripherals. The `|= GPIOAEN` operation sets the bit associated with GPIOA (GPIOAEN) in the `RCC_AHB1EN_R` register, ensuring that the GPIOA peripheral has the necessary clock enabled for its operations:

    ```

    // 18:启用对 GPIOA 的时钟访问

    RCC_AHB1EN_R |= GPIOAEN;

    ```cpp

    				*   *Line 19–20*: These lines configure `PA5` as an output pin. Setting bit 10 to `1` and bit 11 to `0` in `GPIOA_MODE_R` configures `PA5` in general-purpose output mode:

    ```

    GPIOA_MODE_R |= (1U<<10);  // 行 19:将位 10 设置为 1

    GPIOA_MODE_R &= ~(1U<<11); // 行 20:将位 11 设置为 0

    ```cpp

    				*   *Line 21–23*: These lines initiate an infinite loop, which is a common practice in embedded systems for continuous operation:

    ```

    // 21:无限循环开始

    while(1)

    {

    // 22:将 PA5(LED_PIN) 设置为高

    GPIOA_OD_R |= LED_PIN;

    }  // 行 23:无限循环结束

    ```cpp

    This is what is inside this loop:

    *   *Line 22*: This line sets PA5 high. This is achieved by setting the respective bit in `GPIOA_OD_R`. The `|= LED_PIN` operation ensures that PA5 outputs a high signal (essentially turning the LED on).
    *   *Line 24*: This marks the end of the main function:

        ```

        }  // 24:main 函数结束

        ```cpp

			Now that we have a clear understanding of each line of the code, let’s proceed to enter the entire code into the `main.c` file. Additionally, for your convenience, this complete source code is available in the GitHub repository for the book. You can find it in the folder titled `Chapter2`.
			This is the entire code:

// 1:定义外设的基址

define PERIPH_BASE        (0x40000000UL)

// 2:AHB1 外设总线偏移量

define AHB1PERIPH_OFFSET  (0x00020000UL)

// 3:AHB1 外设的基址

define AHB1PERIPH_BASE    (PERIPH_BASE + AHB1PERIPH_OFFSET)

//  4: GPIOA 的偏移量

define GPIOA_OFFSET       (0x0000UL)

//  5: GPIOA 的基址

define GPIOA_BASE         (AHB1PERIPH_BASE + GPIOA_OFFSET)

//  6: RCC 的偏移量

define RCC_OFFSET         (0x3800UL)

//  7: RCC 的基址

define RCC_BASE           (AHB1PERIPH_BASE + RCC_OFFSET)

//  8: AHB1EN 寄存器的偏移量

define AHB1EN_R_OFFSET    (0x30UL)

//  9: AHB1EN 寄存器的地址

define RCC_AHB1EN_R  (*(volatile unsigned int *)(RCC_BASE +  AHB1EN_R_OFFSET))

//  10: 模式寄存器的偏移量

define MODE_R_OFFSET      (0x00UL)

//  11: GPIOA 模式寄存器的地址

define GPIOA_MODE_R  (*(volatile unsigned int *)(GPIOA_BASE + MODE_R_OFFSET))

//  12: 输出数据寄存器的偏移量

define OD_R_OFFSET   (0x14UL)

//  13: GPIOA 输出数据寄存器的地址

define GPIOA_OD_R    (*(volatile unsigned int *)(GPIOA_BASE +  OD_R_OFFSET))

//  14: 使能 GPIOA 的位掩码(位 0)

define GPIOAEN       (1U<<0)

//  15: GPIOA 引脚 5 的位掩码

define PIN5          (1U<<5)

//  16: 代表 LED 引脚的 PIN5 的别名

define LED_PIN       PIN5

//  17: 主函数开始

int main(void)

{

//  18: 使能 GPIOA 的时钟访问

RCC_AHB1EN_R |= GPIOAEN;

GPIOA_MODE_R |= (1U<<10);  //  19: 将位 10 设置为 1

GPIOA_MODE_R &= ~(1U<<11); //  20: 将位 11 设置为 0

//  21: 无限循环开始

while(1)

{

//  22: 将 PA5(LED_PIN)置高

GPIOA_OD_R |= LED_PIN;

}  //  23: 无限循环结束

}  //  24: 主函数结束


			To build the project, first select it by clicking on the project name once, and then initiate the build process by clicking on the *build* icon, represented by a hammer symbol, in the IDE.
			![Figure 2.20: The build and run icons](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/brmtl-emb-c-prog/img/B21914_02_20.jpg)

			Figure 2.20: The build and run icons
			Once the build process is complete, the next step involves uploading the program to the microcontroller. To do this, we make sure the project is selected in the IDE, and then initiate the upload by clicking on the *run* icon, represented by a play symbol.
			The first time you run the program, the IDE may prompt you to edit or confirm the launch configurations. A dialog box titled **Edit Configurations** will appear. It’s sufficient to accept the default settings by clicking **OK**. *Figure 2**.21* shows the **Edit Configuration** dialog box.
			![Figure 2.21: The Edit Configuration dialog box](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/brmtl-emb-c-prog/img/B21914_02_21.jpg)

			Figure 2.21: The Edit Configuration dialog box
			Following this confirmation, the IDE will commence the process of uploading your program to the microcontroller. When the upload is complete, you should see the green LED on the development board light up. This indicates that our bare-metal code is functioning as intended and successfully controlling the hardware.
			We have now mastered configuring PA5 to control the LED, starting from the very basics of consulting the documentation for accurate addresses, effectively typecasting these addresses for register access, and creating aliases for specific bits within the registers.
			Summary
			In this chapter, we delved into the core of bare-metal programming, emphasizing the direct interaction with microcontroller registers. This gave us insight into some of the key registers of our microcontroller and the structure of those registers.
			We began by exploring various firmware development approaches, each offering a distinct level of abstraction. These approaches included the HAL, LL, Bare-Metal C programming, and the assembly language. This exploration helped us understand the trade-offs and applications of each approach in firmware development.
			We spent a significant part of the chapter defining addresses of some peripherals using the official documentation. This step was important in creating addresses for various registers within those peripherals. It involved gathering specific memory addresses, typecasting them for register access, and creating aliases for bits in the registers.
			The chapter culminated in a practical application where we configured PA5 to control the User LED on the development board. This exercise integrated the concepts discussed throughout the chapter, showcasing how theoretical knowledge can be applied in real-world firmware programming.
			In the upcoming chapter, we will delve into the build process. This exploration will allow us to understand the sequential stages that our source code undergoes to become an executable program capable of running on our microcontroller.

第三章:理解构建过程和探索 GNU 工具链

原地编程是一项深入理解和精确的旅程,在本章中,我们将探索嵌入式固件构建过程的复杂领域。我们的重点是 GNU Arm 工具链,它是固件开发中的一个重要元素。通过理论与实践相结合的编程练习,你将深入了解集成开发环境(IDEs)如何简化构建过程,以及如何使用 GNU Arm 工具链手动复制这些过程。

随着章节的深入,我们将探讨编译器的细微差别及其针对 Arm Cortex 微控制器的各种选项。本章的编程练习旨在帮助你理解和有效利用 GNU 工具,从编译和链接到分析输出目标文件的深度。

本章将涵盖以下主要内容:

  • 基础——理解嵌入式构建过程

  • 嵌入式系统的 GNU 二进制工具之旅

  • 从 IDE 到命令行——观察构建过程展开

到本章结束时,你将理解嵌入式固件构建过程,并掌握在 IDE 和命令行界面之间流畅切换的技能,这将增强你作为固件开发者的多面性。

技术要求

本章的所有代码示例都可以在 GitHub 上找到,链接为 github.com/PacktPublishing/Bare-Metal-Embedded-C-Programming

基础——理解嵌入式构建过程

从嵌入式固件开发中的高级源代码到可执行二进制映像的旅程复杂且层次繁多。这个过程通常被称为固件构建过程,涉及几个关键阶段——预处理、编译、汇编、链接和定位。这些阶段中的每一个都在将人类可读代码转换为机器可执行指令中扮演着重要角色。图 3.1 展示了整个构建过程以及每个阶段涉及的工具。

图 3.1:构建过程,详细说明了每个阶段的输入和输出文件以及使用的特定工具

图 3.1:构建过程,详细说明了每个阶段的输入和输出文件以及使用的特定工具

让我们检查各个阶段。

预处理阶段

预处理是固件构建过程的初始阶段。在这个阶段,源代码经过一系列转换,以便为编译做准备。通常,嵌入式系统中的源文件是用 C 语言编写的(.c 文件),并伴随头文件(.h 文件)。预处理程序是构建过程中的专用工具,其任务是处理这些输入文件。

在预处理阶段,预处理程序执行几个关键操作:

  • 去除注释:注释对于人类可读性至关重要,但对于机器来说无关紧要,因此从代码中移除。

  • # 符号,称为预处理器指令,被处理。这些指令通常包括宏定义(#define)、条件编译指令(#ifdef#ifndef#endif)和文件包含命令(#include)。预处理器将这些指令替换为其定义的值或相应的代码段。

  • .i 扩展名。这些文件代表经过转换的源代码,去除了注释,并且所有指令都已评估。

下一个阶段是编译阶段。

编译阶段

编译过程在预处理之后立即开始。编译器的角色是将 .i 文件转换为特定架构的汇编代码。在这个阶段,C 语言的高级结构被翻译成目标处理器架构所能理解的更低级、更细粒度的汇编指令。

这个阶段包括以下内容:

  • .i 文件

  • 过程:编译器分析代码结构,优化性能和空间,并将其翻译成汇编语言。

  • .s 扩展名

下一个阶段使用 .s 文件。

汇编阶段

汇编是将包含汇编代码的 .s 文件转换为机器代码的阶段,以目标文件的形式。这个阶段将可读的汇编指令转换为二进制格式。

这个阶段包括以下内容:

  • .s 文件)。

  • 过程:汇编器解释每条汇编指令并将其转换为相应的机器代码。

  • .o 扩展名。这些文件包含二进制代码,并准备好进入链接的下一阶段。

链接阶段

链接是将所有单个目标文件组合成一个完整程序的阶段。这个阶段还整合了任何必要的标准库文件,并解决不同代码模块之间的引用。

这个阶段包括以下内容:

  • .o 文件)和 C 标准库文件。

  • 过程:链接器将所有目标文件缝合在一起,解决符号引用和地址。它处理诸如变量和函数的内存分配等任务。

  • 输出:链接器生成一个可重定位文件,内容全面但还不是最终的可执行代码。

可重定位文件是固件构建过程中的中间输出,在链接阶段创建。它是一个综合文件,结合了所有单个目标文件(.o)和必要的库。然而,它还不是最终的执行文件。重定位的概念在这里起着重要作用。重定位涉及调整可重定位文件中的符号地址到实际的、特定的内存位置。这个过程确保当固件在目标设备上运行时,代码和数据部分的每个部分都正确地放置在内存中。可重定位文件包含固件的所有必要组件,但地址仍然是“相对的”——它们需要在定位阶段进一步调整以适应目标微控制器的独特内存映射,从而创建最终的执行文件。

下一个阶段是我们最终获得可执行代码的阶段。

定位阶段

这是最后一个阶段;它涉及将可重定位文件转换为最终的执行二进制文件。这个阶段由链接脚本指导,它提供了有关目标设备内存布局的必要信息。

这个阶段包括以下内容:

  • 输入:一个可重定位文件和一个链接脚本。

  • 过程:定位器使用链接脚本将代码和数据部分放置到指定的内存位置。它调整地址和偏移量以适应目标内存映射。

  • 输出:一个可执行的二进制文件,通常以可执行和链接格式ELF)或纯二进制格式。

拥有这些知识,我们已准备好深入探讨 GNU Toolchain for Arm。我们的目标是有效地利用工具链中的特定工具来执行构建过程的各个阶段。这将是下一节的重点。

GNU 嵌入式系统二进制工具之旅

在本节中,我们将深入了解 GNU Bin 工具,这是一套随 GNU Toolchain for Arm 安装而来的工具。这些工具(命令)对于固件构建过程的各个阶段以及如调试等额外任务都是必不可少的。

我们将要探索的第一个命令是arm-none-eabi-gcc

arm-none-eabi-gcc

让我们分解命令的各个部分:

  • arm:这指定了目标架构。

  • none:这个组件表示代码正在编译的操作系统。在这里,none表示代码是为裸机环境设计的,这意味着它将直接在硬件上运行,而不需要底层操作系统。

  • eabi:这代表嵌入式应用程序二进制接口。EABI 定义了系统和用户程序、库等的二进制布局标准。它确保编译的代码将在遵循 EABI 标准的任何 Arm 处理器上正确运行。

  • gcc:这是GNU 编译器集合的缩写。

此单个命令一次编译、汇编和链接我们的输入代码。要使用它,请在命令提示符或终端中输入 arm-none-eabi-gcc,然后是源文件,然后是 -o,以及所需的输出文件名。

图 3**.2 的示例:

图 3.2:arm-none-eabi-gcc 命令的用法

图 3.2:arm-none-eabi-gcc 命令的用法

在此示例中,我们指定源文件为 main.c,输出文件为 main.o

一些常见的编译器标志

由于我们刚刚介绍了 -o 编译器标志,让我们借此机会介绍一些其他常用的编译器标志。这些编译器标志用于修改命令行为以及向命令添加选项:

  • -c: 此标志用于编译和汇编但不进行链接。当添加到命令中时,它将代码处理到汇编阶段,但在链接之前停止。

  • -o file: 如前所述,此选项指定输出文件的名称。

  • -g: 在可执行文件中生成调试信息。

  • -Wall: 启用所有警告消息,帮助我们识别代码中的潜在问题。

  • -Werror: 将所有警告视为错误,确保代码质量和稳定性。

  • -I [DIR]: 包含一个指定的目录以搜索头文件;这对于组织大型项目很有用。

  • -ansi-std=STANDARD: 这些标志指定应使用 C 语言的哪个标准版本。

  • -v: 从 GCC 提供详细输出,给我们关于编译过程的详细信息。

表 3.1 提供了标志的总结以及每个标志的示例用法。

标志 用途 示例用法
-c 编译和汇编但不链接 arm-none-eabi-gcc -``c source_file
-``o file 链接到输出文件,文件名为 file arm-none-eabi-gcc source_file -``o output_file
-g 在可执行文件中生成调试信息 arm-none-eabi-gcc -``g source_file
-``Wall 启用所有警告消息 arm-none-eabi-gcc -``Wall source_file
-``Werror 将警告视为错误 arm-none-eabi-gcc -``Werror source_file
-``I [DIR] 包含用于头文件的目录 arm-none-eabi-gcc -I directory_path source_file
-``ansi 使用美国国家标准协会(ANSI)标准 arm-none-eabi-gcc -``ansi source_file
-``std 指定标准版本(例如,C11) arm-none-eabi-gcc -``std=c11 source_file
-v GCC 的详细输出 arm-none-eabi-gcc -``v source_file

表 3.1:一些编译器标志及其示例用法

一些特定架构的标志

除了通用编译器标志之外,还有一些针对特定架构的标志,这些标志可以精确配置各种处理器架构。这些标志对于针对特定 ARM 处理器和它们各自的架构定制构建过程至关重要。让我们深入了解一些最常用的特定架构标志:

  • -mcpu=[NAME]: 指定目标 ARM 处理器。使用此选项将配置编译器以针对特定处理器优化代码。

  • march=[NAME]: 指定目标 ARM 架构。它配置编译器以针对特定的 ARM 架构版本。

  • -mtune=[NAME]: 与 -mcpu 类似,此选项指定了优化目的的目标 ARM 处理器。

  • -thumb: 配置编译器以生成适用于 Thumb 指令集的代码,这是标准 ARM 指令集的压缩版本,提供了更高的代码密度和效率。

  • -marm: 指示编译器生成适用于 ARM 指令集的代码。

  • -mlittle-endian/-mbig-endian: 这些选项指定了生成的代码的端序。小端序是 ARM 处理器中最常见的格式。

让我们看看涉及一些这些标志的示例:

图 3.3:使用一些架构特定标志的 arm-none-eabi-gcc 命令

图 3.3:使用一些架构特定标志的 arm-none-eabi-gcc 命令

在此示例中,我们说以下内容:

  • -c: 编译和汇编但不链接

  • -mcpu=cortex-m4: 为 Cortex-M4 处理器构建

  • -mthumb: 使用 Thumb 指令集

  • -o main.o: 将编译文件输出为 main.o

表 3.2 提供了架构特定标志及其每个标志的示例用法总结。

标志 用途 示例用法
-mcpu=[NAME] 指定目标 ARM 处理器 -mcpu=cortex-m4
-march=[NAME] 指定目标 ARM 架构 -march=armv7-m
-mtune=[NAME] 为特定 ARM 处理器优化 -mtune=cortex-m4
-mthumb 生成适用于 Thumb 指令集的代码 -mthumb
-marm 生成适用于 ARM 指令集的代码 -marm
-mlittle-endian 生成适用于小端序模式的代码 -mlittle-endian
-mbig-endian 生成适用于大端序模式的代码 -mbig-endian

表 3.2:一些架构特定编译器标志及其示例用法

Arm GNU 工具链中的其他命令

除了 arm-none-eabi-gcc 命令之外,还有一些我们在使用 GNU 工具链构建 Arm 时会经常使用的其他重要命令。让我们来检查一些这些命令:

  • arm-none-eabi-nm: arm-none-eabi-nm 命令是一个方便的工具,用于列出对象文件中的符号。在此上下文中,符号指的是程序中的各种标识符,例如函数名、变量名和常量。这个工具对于检查编译文件的内容非常有价值,它为我们提供了关于程序结构和组件的见解。这对于调试目的尤其有用。

  • arm-none-eabi-size:在嵌入式固件开发中,由于内存资源通常有限,了解代码不同部分占用的内存大小至关重要。此工具提供了关于代码各个部分消耗多少内存的宝贵见解,使我们能够就优化和内存管理做出明智的决定。

  • arm-none-eabi-objdump:此工具用于从对象文件中提取和显示详细信息。它提供了对机器指令的深入了解,使其成为彻底分析对象文件的无价资源。这包括代码反汇编、展示部分标题和揭示符号表等功能。当我们需要深入了解编译代码的复杂细节时,其效用变得至关重要,它提供了关于文件结构、内容和操作机制的解释。这有助于我们调试和优化代码。

    反汇编代码是指将机器代码(一组计算机处理器可以直接执行的二进制指令)转换回汇编语言的过程。汇编语言是一种更易于人类阅读的指令形式,尽管与 C 语言相比,它仍然相当低级。图 3**.4展示了 C 语言代码、其对应的汇编语言翻译以及生成的机器代码的比较。

图 3.4:C 语言代码、其对应的汇编语言代码以及生成的机器代码

图 3.4:C 语言代码、其对应的汇编语言代码以及生成的机器代码

  • arm-none-eabi-readelf:此工具提供了关于输出 ELF 文件的详细信息,包括部分标题、程序标题和符号表。当我们处理 ELF 文件时,它非常有用,因为它提供了关于可执行文件结构以及如何在系统上运行的见解。

  • : arm-none-eabi-objcopy:我们使用此工具将对象文件从一种格式转换为另一种格式,或者制作对象文件的副本。

表 3.3提供了这些附加工具的总结以及每个工具的示例用法。

工具 功能 示例用法
arm-none-eabi-nm 列出对象文件中的符号 arm-none-eabi-nm [对象文件]
arm-none-eabi-size 列出对象/可执行文件的部分大小 arm-none-eabi-size [文件]
arm-none-eabi-objdump 输出对象文件的信息 arm-none-eabi-objdump [选项] [对象文件]
arm-none-eabi-readelf 显示 ELF 文件的信息 arm-none-eabi-readelf [选项] [ELF 文件]
arm-none-eabi-objcopy 在格式之间转换/复制对象文件 arm-none-eabi-objcopy [选项] [输入文件] [输出文件]

表 3.3:GNU 工具链中一些常见的 Arm 命令及其示例用法

在本节中,我们探讨了嵌入式固件开发中必不可少的 GNU 二进制工具。这些工具,包括 arm-none-eabi-gcc 等命令,在固件构建过程的各个阶段发挥着重要作用,对于编译、链接和调试等任务来说是无价的。下一节将进一步扩展我们对这些工具的理解;我们将深入研究它们的应用,展示它们在嵌入式固件构建过程中的实用性。

从 IDE 到命令行 - 观察构建过程展开

在本节中,我们的目标是了解 IDE 内部的编译器在启动构建时如何处理我们的代码。此外,我们还将深入研究上一节中讨论的一些 GNU 二进制工具的实际应用。

从 IDE 视角观察构建过程

让我们从回顾上一章中开发的裸机 GPIO 驱动程序开始。

首先,启动您的 STM32CubeIDE。为了正确分析 IDE 执行的构建命令,有必要首先清理项目然后再次构建。原因很简单 - 我们已经在上一章中构建了项目。从那时起,源代码没有进行任何修改,新的构建尝试将跳过我们旨在仔细审查的详细命令执行,因为源代码没有变化。

让我们清理项目:

  1. 项目面板中定位项目。

  2. 右键单击项目名称。

  3. 将会弹出一个菜单。从该菜单中选择清理项目选项。

  4. IDE 现在将清除项目中的任何已编译数据。此操作将项目的构建状态重置为其初始状态,删除任何以前的构建结果。

现在,让我们再次构建它:

  1. 再次在项目面板中同一项目的名称上单击鼠标右键。

  2. 再次弹出一个菜单。这次,选择构建项目选项。

  3. 选择此选项后,IDE 将开始从头开始构建项目的进程。

要在 STM32CubeIDE 中观察构建命令,我们必须找到控制台面板,它通常位于 IDE 界面的底部区域。控制台面板是一个重要的组件,它显示各种操作的实时输出和日志,包括构建过程。这使得它成为监控编译过程中进行的命令和动作的宝贵工具。

如果在当前 IDE 布局中控制台面板不可见,您可以通过位于 IDE 顶部的菜单栏访问它。只需点击窗口菜单,即可显示一个选项的下拉列表。从那里,导航到显示视图,这将展开以显示更多选项。在这些选项中,选择控制台,以便在您的工区内查看该面板。

一旦你有了 2_RegisterManipulation 硬件裸机 GPIO 驱动项目。图 3.5 显示了 2_RegisterManipulation 硬件裸机 GPIO 驱动项目的一些内容:

图 3.5:构建 2_RegisterManipulation 硬件裸机 GPIO 驱动项目后的控制台面板

图 3.5:构建 2_RegisterManipulation 硬件裸机 GPIO 驱动项目后的控制台面板。

图 3.5 提供了构建过程步骤的快照,尽管它只显示了每个步骤的一个片段。要全面了解所有步骤,包括完整的命令行和响应,你应该参考你的 STM32CubeIDE 中的 控制台 面板。

让我们根据行号分析 图 3.5 中的控制台面板。

汇编和 C 文件的编译

行 (1)

make -j8 all:这是一个对 make 构建自动化工具的命令,请求它执行构建。我们将在接下来的章节中学习 make

行 (2)(3)(4)(5)

以下行是针对 arm-none-eabi-gcc 的特定命令,用于编译单个源文件,如 main.csyscalls.csysmem.c。这些命令指定了目标 CPU (-mcpu=cortex-m4) 和其他编译器标志。每个源文件都被编译成一个目标文件 (.o)。

链接过程

行 (6)

带有对象文件列表 (@"objects.list") 和链接脚本 (STM32F411RETX_FLASH.ld) 的 arm-none-eabi-gcc 命令将这些对象文件链接成一个可执行文件 (2_RegisterManipulation.elf)。链接脚本指导代码和数据的不同部分如何在最终的可执行文件中放置。我们将在下一章讨论链接脚本。

尺寸计算:

行 (8)

arm-none-eabi-size 2_RegisterManipulation.elf: 这个命令计算编译程序的尺寸,将其分解为文本(代码)、数据(初始化数据)和 bss(未初始化数据)部分。输出显示了这些部分的字节数以及它们的十进制(dec)和十六进制(hex)格式的总大小。

列表文件的创建:

行 (9)

arm-none-eabi-objdump -h -S 2_RegisterManipulation.elf > "2_RegisterManipulation.list":这个命令将可执行文件反汇编并输出一个详细的列表文件。-h 标志显示头部信息,-S 将源代码与反汇编代码交织在一起。列表文件是编译代码的详细文本表示,包含汇编语言指令及其对应的机器代码,通常还包含原始高级源代码的注释。

从这些日志中我们可以观察到,很明显,我们的 STM32CubeIDE 采用了之前讨论过的相同的 GNU 二进制工具。在我们接下来的章节中,我们将通过命令行手动执行这些命令。这种方法将教会我们如何不使用 IDE,仅使用源代码文件、命令行界面和我们的 GNU Bin 工具套件来构建我们的固件。

使用 GNU 二进制工具

在本节中,我们的重点是直接执行一些 GNU Bin Tools,使用我们的命令行。首先,我想向您展示为什么我会交替使用 命令工具 来描述 GNU Bin Tools。

在您的计算机上找到 GNU Arm Embedded Toolchain 的安装文件夹。在我的计算机上,这是 C:/Program Files(x86)/GNU Arm Embedded Toolchain

一旦您找到了 GNU Arm Embedded Toolchain 文件夹,下一步是访问其中的 bin 文件夹。

打开 bin 文件夹后,您将看到许多工具,每个工具都由一个可执行文件(.exe)表示。仔细观察,您将找到我们的编译器 arm-none-eabi-gcc.exe 以及我们之前讨论过的其他工具。当我们在命令行中输入对应这些工具的命令时,相关的 .exe 文件将被执行。例如,在命令行中输入 arm-none-eabi-gcc 将运行 arm-none-eabi-gcc.exe 可执行文件。

现在,我们已经澄清了这一点,是时候将我们的注意力转向实际测试了。然而,在进入测试阶段之前,需要一些基本的准备工作。让我们创建当前项目 2_RegisterManipulation 的备份:

  1. 2_RegisterManipulation 项目已存储。

  2. 2_RegisterManipulation 项目文件夹中,选择 复制,然后在同一目录下 粘贴

  3. 2_RegisterManipulation-old

备份完成后,我们的下一步是修改 2_Register Manipulation 项目中的 main.c 文件,将 LED 的行为从恒定 开启 状态改为闪烁状态:

  1. 2_RegisterManipulation/Src 目录。

  2. 打开 main.c 文件,并选择在简单的文本编辑器中打开它,例如记事本。

  3. PA5 (LED_PIN) 高电平。它应该看起来像这样:

    //  22: Set PA5(LED_PIN) high
    GPIOA_OD_R |= LED_PIN;
    

    将此代码替换为以下代码以切换 PA5 的状态并创建闪烁效果:

    //  22: Toggle PA5(LED_PIN)
    GPIOA_OD_R ^= LED_PIN;
    for(int i = 0; i < 100000; i++){} // Delay loop for visible blinking
    
  4. main.c 文件。

让我们回到我们的项目文件夹,并通过命令提示符访问 2_RegisterManipulation/Debug 目录。这个特定的文件夹很重要,因为 STM32CubeIDE 会自动将项目的 makefile 放置在这里。理解 makefile 的角色和结构对于嵌入式固件开发至关重要,我们将在接下来的章节中更详细地探讨这个主题。

我们可以通过多种方式通过命令提示符访问文件夹:

Windows 用户可以选择这些方法之一:

  • 在 Windows 资源管理器中找到 2_RegisterManipulation/Debug 文件夹。一旦到达那里,按住 Shift 键,在文件夹内的空白区域右键单击,然后选择 Debug 文件夹。

  • 2_RegisterManipulation/Debug 文件夹中找到文件夹路径,然后复制该路径。然后,从 cdcmd 中打开命令提示符(注意 'cd' 后面的空格),粘贴复制的路径,并按 Enter。这将更改目录到 Debug 文件夹。

其他操作系统的用户可以选择这些方法之一:

  • 导航到该文件夹,并打开您操作系统的特定终端。

  • 使用 cd(更改目录)命令,后跟 2_RegisterManipulation/Debug 文件的绝对路径来导航到它。确切路径可能因项目在系统中的位置而异。

图 3.6:通过 Windows 命令提示符访问调试文件夹

图 3.6:通过 Windows 命令提示符访问调试文件夹

在这个实际练习中,我们将复制 STM32CubeIDE 中使用的命令,直接从其控制台面板中提取它们。我们将逐行执行,如图 3.5 所示,从第 2 行开始(因为我们的当前重点不在于 makefile)。

要做到这一点,请按照以下步骤操作:

  1. 从 STM32CubeIDE 控制台面板复制第 2 行,并将其粘贴到命令提示符中。这一行使用 arm-none-eabi-gcc 编译启动文件,并引用了我系统设置特定的路径:

    main.c file. However, before pasting this command into the command prompt, remove the -fcyclomatic-complexity flag, as it is not supported by some versions of the GNU Toolchain for Arm. Paste the command into a text editor, delete the flag, and then copy the modified command into the command prompt. The command for line number 3 should look like this:
    
    

    syscalls.csystem.c 文件,分别。

    
    
  2. 我们将继续链接这些文件。为此,复制 STM32CubeIDE 控制台面板中的链接命令,该命令位于第 6 行,并将其粘贴到命令提示符中,如下所示:

    arm-none-eabi-size command to display the size of our output .elf file. This will give us insights into the sizes of various sections, such as text, data, and bss. We will discuss these sections in detail later in the book, particularly when we delve into writing linker scripts.
    

要执行此操作,请从 STM32CubeIDE 控制台面板复制第 8 行,并将其粘贴到命令提示符中。运行以下命令:

C:\Users\Ninsaw\Documents\tempWorkspace\2_RegisterManipulation\Debug>arm-none-eabi-size   2_RegisterManipulation.elf

它将显示 .elf 文件的大小细节:

图 3.7:执行 arm-none-eabi-size 命令产生的输出

图 3.7:执行 arm-none-eabi-size 命令产生的输出

观察结果,我们可以确认输出与 STM32CubeIDE 控制台面板中显示的完全匹配。

在这个阶段,我们可以选择使用 arm-none-eabi-objcopy 工具将我们的 .elf 文件转换为 .bin 格式,并使用适当的标志。

在命令提示符中键入以下内容并按 Enter

arm-none-eabi-objcopy -O binary 2_RegisterManipulation.elf 2_RegisterManipulation.bin

让我们分解这个片段:

  • -O binary 指定输出格式,在这种情况下是一个二进制文件

  • 2_RegisterManipulation.elf 是你正在转换的源 ELF 文件

  • 2_RegisterManipulation.bin 是将要创建的输出二进制文件的名称

这最后一步标志着我们第一次构建过程的完成。我们已经成功编译和链接了所有必要的文件,从而创建了我们的最终可执行文件,有两种格式。下一个过程涉及使用 OpenOCD 将固件上传到我们的微控制器。这将在下一节中介绍。

使用 OpenOCD 将固件上传到微控制器

2_RegisterManipulation 可执行文件放入我们的微控制器中。

我们将首先定位我们开发板的正确 OpenOCD 脚本。OpenOCD 附带各种脚本,每个脚本都针对不同的微控制器和开发板量身定制。在我们的情况下,重点是 st_nucleo_f4 系列。要找到正确的脚本,请按照以下步骤操作:

  1. 导航到 OpenOCD 安装目录,通常位于 Windows 用户 Program Files 文件夹中。OpenOCD 文件夹通常命名为 xpack-openocd。一旦到达那里,进入 openocd 子文件夹,然后是 scripts 子文件夹,最后是 board 子文件夹。您将找到一个名为 st_nucleo_f4.cfg 的文件;这是我们为 NUCLEO-F4 开发板必须执行的 OpenOCD 文件。

  2. 要启动 OpenOCD,连接您的开发板,打开命令提示符窗口,并输入以下命令:

    st_nucleo_f4.cfg, contains all the necessary settings for OpenOCD to communicate with the development board.
    

这是执行命令后命令提示符窗口的输出片段:

图 3.8:OpenOCD 的首次输出

图 3.8:OpenOCD 的首次输出

这里显示的信息包括以下内容:

  • 6666 用于 Tcl 连接,端口 4444 用于 Telnet 连接。这些端口用于在调试会话期间向 OpenOCD 发送命令并与之交互。

  • 处理器和调试能力:调试器已识别出 STM32F4 系列微控制器中的 Cortex-M4 r0p1 处理器。此外,它还指出目标设备有六个断点和四个观察点,这在调试过程中设置断点和观察点时至关重要。

  • 3333。此服务器允许 GDBGNU 调试器)客户端连接以进行调试。

当 OpenOCD 运行时,下一步涉及使用 GDB 将固件上传到微控制器。让我们打开另一个命令提示符窗口,仍然在调试文件夹中(因为 OpenOCD 应该在第一个窗口中继续运行),并输入以下命令以启动 GDB:

arm-none-eabi-gdb

一旦 GDB 打开,我们通过运行以下命令与微控制器建立连接:

target remote localhost:3333

此命令将 GDB 连接到本地机器(localhost)上运行的 OpenOCD 服务器,端口号为 3333,这是 OpenOCD 的默认端口。

执行此命令后,两个命令提示符窗口都会返回输出,告诉我们调试已经开始:

图 3.9:运行 GDB 的命令提示符窗口的输出

图 3.9:运行 GDB 的命令提示符窗口的输出

这是运行 st_nucleo_f4.cfg 的命令提示符窗口的输出:

图 3.10:运行 st_nucleo_f4.cfg 的命令提示符窗口的输出

图 3.10:运行 st_nucleo_f4.cfg 的命令提示符窗口的输出

在加载固件之前,我们必须使用以下命令来重置和初始化板:

monitor reset init

让我们将命令分解为其组成部分:

  • monitor:此前缀在 GDB 中用于指示以下命令不是 GDB 命令,而是针对调试服务器(在这种情况下,OpenOCD)的

  • reset:此命令部分指示 OpenOCD 重置目标设备

  • init:这告诉 OpenOCD 为目标设备执行其初始化序列

然后,我们使用以下命令将固件加载到微控制器上:

monitor flash write_image erase 2_RegisterManipulation.elf

此命令将擦除微控制器上的现有固件,并将新的固件(在本例中为2_RegisterManipulation.elf)写入其中。此命令将擦除微控制器上的现有固件,并将新的固件(在本例中为2_RegisterManipulation.elf)写入其中:

  • flash write_image:这是一个 OpenOCD 命令,告诉它将映像写入目标微控制器的闪存中——实际上,用新的固件映像编程微控制器。

  • erase:此选项告诉 OpenOCD 在写入新映像之前擦除闪存。在微控制器编程中,擦除闪存是一个常见的要求,因为它清除任何以前的程序,并确保新的固件被写入干净的内存空间。

在成功加载固件后,我们再次使用相同的reset命令重置了板子。

monitor reset init

然后,我们使用以下命令在微控制器上继续执行代码:

monitor resume

哇!固件现在应该在微控制器上运行了。你应该看到板上的 LED 闪烁,表明新固件的上传和执行成功。

摘要

在本章中,我们探讨了嵌入式固件构建过程的复杂性,特别关注 GNU 工具链。

我们首先了解了嵌入式构建过程,探索了它的多个阶段——预处理、编译、汇编、链接和定位。每个阶段都被分析,阐明了它在将人类可读的源代码转换为可执行机器指令中的重要性。我们深入研究了预处理在准备代码中的作用,编译和汇编在转换和转换代码中的细微差别,以及链接和定位在形成连贯、可执行二进制中的复杂任务。

转向实际应用,本章介绍了嵌入式系统的 GNU 二进制工具。通过回顾我们之前开发的裸机 GPIO 驱动程序,我们观察了 STM32CubeIDE 执行的构建命令,通过我们的命令行界面手动复制这些步骤。这种方法让我们更深入地理解了 IDE 自动化的底层过程和命令。在章节的后半部分,我们逐步介绍了使用 OpenOCD 将我们的固件上传到微控制器的过程,从找到适合开发板的正确 OpenOCD 脚本到执行重置、初始化和运行微控制器上固件的命令。这个实际练习展示了我们之前获得的理论知识的成功应用,标志着我们旅程中的一个重要里程碑。

在下一章中,我们将学习如何编写我们自己的链接脚本和启动文件。这一重要步骤将代表我们掌握从头开始开发完全裸机固件的旅程中的另一个重要里程碑。

第四章:开发链接脚本和启动文件

在本章中,我们深入探讨了嵌入式裸机编程的核心组件,重点关注三个关键领域:微控制器内存模型、链接脚本的编写和启动文件。

首先,我们将探讨微控制器内存模型,以了解内存是如何组织和使用的。这些知识对于在微控制器内存中准确分配程序代码和数据部分非常重要。接下来,我们将深入了解编写链接脚本的技术细节。这些脚本对于正确地将我们的程序映射到微控制器内存的适当部分至关重要,确保可执行程序按预期运行。

最后,我们将学习启动文件,然后编写我们自己的启动文件,重点关注初始化向量表和配置Reset_Handler

在本章中,我们将涵盖以下主要内容:

  • 理解内存模型

  • 链接脚本

  • 编写链接脚本和启动文件

技术要求

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/Bare-Metal-Embedded-C-Programming

理解 STM32 内存模型

虽然 STM32 内存映射包括多个内存区域,但我们在开发链接脚本和启动文件时的主要焦点是两个关键区域:闪存静态随机存取存储器SRAM)。这些区域至关重要,因为它们直接参与程序存储。在本节的开头部分,我们将了解这些内存区域的特点以及它们所扮演的独特角色。

图 4**.1显示了 stm32f411 内存映射的一部分,突出显示了闪存和 SRAM。

图 4.1:STM32F11 内存映射的一部分,突出显示闪存和 SRAM 区域

图 4.1:STM32F11 内存映射的一部分,突出显示闪存和 SRAM 区域

让我们从闪存内存开始。

闪存内存

闪存内存的一个主要优点是其非易失性。这意味着即使断开电源,存储在闪存内存中的数据也会保持完整。在 STM32 微控制器(以及其他微控制器)中,闪存内存通常是存储可执行代码的地方,在正常操作期间是只读的。闪存内存从0x08000000地址开始。然而,其大小取决于具体的 STM32 微控制器型号。

STM32 微控制器有多种系列和型号,提供不同密度的闪存内存,以满足不同的应用需求。

什么是内存密度?

内存密度指的是在给定的物理空间或组件内存储内存的集中度。内存密度通常以每单位物理面积存储的位数或字节来表示,例如每平方毫米的位数或每平方厘米的字节。它衡量了数据在该空间内可以存储的密集程度或紧凑程度。更高的内存密度意味着我们可以在较小的物理空间内存储更多的数据。

另一方面,内存大小指的是给定存储设备中可用的总内存(存储容量)。它通常以字节、千字节(KB)、兆字节(MB)、吉字节(GB)等单位来衡量。

现在,让我们来谈谈闪存的某些操作细节。

闪存,包括 STM32 闪存,具有有限的程序和擦除周期。每次我们写入(编程)或擦除时,都会消耗这些周期中的一个。在设计频繁写入或从闪存中擦除数据的应用程序时,考虑这些限制非常重要。

STM32 微控制器以其低功耗而闻名,这种特性也扩展到了它们的闪存操作。高效的电源管理确保微控制器在读取或写入闪存时能够以最小的功耗运行,这使得 STM32 设备非常适合电池供电的应用。

为了确保数据完整性,STM32 闪存通常包括内置的错误纠正机制。这些机制有助于识别和纠正数据存储和检索过程中可能发生的错误,从而提高了存储固件的可靠性。

以下是一些 STM32 闪存的关键属性:

  • 只读特性:主要用于存储程序代码

  • 0x08000000

  • 变量大小:取决于具体的 STM32 微控制器型号

  • 0x08000004

让我们来看看与编写链接脚本和启动文件相关的其他主要内存区域。

SRAM

SRAM是一种易失性内存,意味着当电源断开时,它会丢失其内容。在 STM32 微控制器中,SRAM 用于程序执行期间的临时数据存储。与用于长期存储程序代码的闪存不同,SRAM 旨在实现高速访问和低延迟,使其非常适合存储变量、中间数据和运行时管理堆栈。

与闪存一样,STM32 微控制器具有不同大小的 SRAM,以满足不同应用的需求。SRAM 的大小决定了可以处理的运行时数据量,并影响微控制器在处理复杂任务或多任务处理时的整体性能。STM32 微控制器中的 SRAM 从0x20000000地址开始。与闪存一样,其大小取决于具体的 STM32 微控制器型号。

以下是一些 STM32 SRAM 的关键属性:

  • 读写:变量和堆栈存储在这里

  • 0x20000000

  • 变量大小:取决于特定的 STM32 微控制器型号

在介绍链接脚本之前,让我们简要提及一个与链接脚本不相关,但对我们理解微控制器内存布局仍有意义的内存区域。

外围内存

外围内存专门用于管理和与微控制器板载外围设备接口。这些外围设备包括定时器、通信接口(UART、SPI、I2C)和模数转换器ADCs)。外围内存由用于配置和管理这些外围设备的寄存器组成。

微控制器架构的一个重要方面是使用内存映射输入/输出I/O)。内存映射 I/O 是一种技术,其中外围寄存器被分配到系统内存空间中的特定地址。这种方法允许固件通过从或向这些内存地址读取或写入来与硬件外围设备交互,就像它与常规内存交互一样。STM32 内存映射的外围内存区域是外围寄存器的内存映射区域。

现在我们已经熟悉了主要内存区域,我们准备学习链接脚本。

链接脚本

链接脚本在构建过程中扮演着重要的角色,尤其是在定义内存布局和分配固件使用的各种内存部分。它们指定固件的各个部分,如代码、数据和未初始化数据,应在微控制器内存中的位置。

虽然链接脚本设置了这些部分的架构和边界,但重要的是要注意,它们并不填充这些部分的数据。实际使用特定值初始化数据的处理由启动代码负责,该代码在微控制器启动时运行。我们向链接器提供这些链接脚本,以有效地指导链接阶段的内存组织。

图 4.2:带有链接器高亮的构建过程

图 4.2:带有链接器高亮的构建过程

理解链接过程

在构建过程中,链接对象文件是一个重要的步骤,它将单个代码片段转换为功能固件。汇编器从源代码生成对象文件,每个对象文件都包含固件所需的代码和数据部分。然而,这些对象文件通常包含对变量和函数的未解决内部引用,使它们本身不完整。例如,一个对象文件可能包含对定义在其他地方的adc_value变量的引用。链接器的任务是合并这些对象文件,系统地解决所有这些未解决符号,以创建一个统一的输出文件。要完全欣赏链接器的细致工作,我们必须了解链接器为每个部分分配的属性。

部分属性及其影响

对象文件中的每个部分都通过一个唯一的名称和大小来识别,具有特定的属性,这些属性规定了应该如何处理它们:

  • 可加载部分:这些部分包含在运行时必须加载到内存中的内容。它们对于程序的执行至关重要,包括可执行代码和初始化数据。

  • 可分配部分:这些部分本身不携带内容。相反,它们指示应该保留内存的某个区域,通常是为在运行时定义的未初始化数据。

  • 不可加载、不可分配的部分:通常,既不可加载也不可分配的部分包含调试信息或元数据,这些信息有助于开发过程,但不是程序执行所必需的。

链接过程的一个关键方面是为每个可分配和可加载的输出部分确定两种类型的地址:虚拟内存地址VMA)和加载内存地址LMA)。

这些是这两个地址的作用:

  • VMA:此地址表示在输出文件执行期间部分将在内存中的位置。它是系统用于访问部分数据或指令的运行时地址。

  • LMA:相反,LMA 是部分在内存中物理加载的地址。

在大多数情况下,VMA 和 LMA 是相同的

当数据部分最初被加载到闪存中,但在启动时被复制到 SRAM 时,会出现一个显著的例外。

为了更清晰地全面理解构建过程的后期阶段,深入探讨我们讨论的另一个基本方面是至关重要的:定位器在构建过程中的具体职责和贡献。

地址重定位和定位器

链接器生成的输出文件并不立即适用于目标微控制器。这是因为链接过程中分配给不同部分的地址不一定与目标设备的实际内存布局相对应。因此,这些地址必须重新定位以准确匹配目标内存空间。这就是定位器的工作。

图 4.3:构建过程,突出可重定位文件、定位器和最终可执行输出之间的关系

图 4.3:构建过程,突出可重定位文件、定位器和最终可执行输出之间的关系

在 GNU 工具链中,定位器功能集成到链接器中,简化了地址重定位的过程。这种能力确保最终的可执行文件正确映射到微控制器的内存中,使其准备好执行。

在本节中,我们考察了构建过程。从这一点来看,我们观察到嵌入式系统开发中链接对象文件的过程涉及对代码和数据部分的细致组织、符号解析和地址重定位。

在下一节中,我们将详细探讨链接脚本的关键组件。这次探索将提供额外的见解,并加深我们对本节讨论的核心元素的理解。

链接脚本的关键组件

链接脚本的关键组件包括内存布局部分定义选项符号,每个都在确保固件正确放置和执行在微控制器内存中发挥独特的作用。

内存布局

这部分链接脚本指定了微控制器中可用的各种内存类型,例如闪存和 SRAM。它包括它们的起始地址和大小,例如闪存从0x08000000开始或 SRAM 从0x20000000开始。

部分定义

链接脚本的一个关键方面是定义如何以及在哪里放置程序的不同部分。包含程序代码的.text部分通常位于闪存的开始处。随后,.bss.data部分在 SRAM 中分配。链接脚本还确保这些部分的正确对齐,以实现高效的内存访问和程序执行:

  • .text:

    • .text部分包含我们程序的执行指令。这是处理器实际执行的代码所在的位置。

    • .text部分的大小取决于你的程序中的代码量。在 STM32 微控制器中,它通常从一个预定义的内存地址开始,通常在闪存的较低区域。例如,0x00000000然后重定位到0x08000000

  • .bss:

    • .bss部分用于未初始化的全局和静态变量。当程序启动时,这部分变量没有初始值。

    • 0.

  • .data:

    • .data部分包含初始化的全局和静态变量。与.bss中的变量不同,这些变量在我们的代码中指定了初始值。

    • .data部分通常从闪存复制到 SRAM,以允许更快的访问和修改。

    • 管理:将这些值从闪存复制到 SRAM 的过程由启动代码处理,该代码在我们程序的 main 函数之前执行。

  • .rodata: 这个部分用于常量数据,例如字符串字面量和常量数组。它是只读的,通常存储在闪存中。

  • .heap.stack是用于动态内存分配(mallocfree)和函数调用栈的部分。它们是 SRAM 的一部分,对于运行时内存管理至关重要。

下表总结了关键部分及其在内存中的位置。

部分 目的 放置在
.text 包含可执行程序指令。 FLASH
.bss 存储未初始化的全局/静态变量。 SRAM
.data 存储带有初始值的初始化全局/静态变量。 FLASH (SRAM 在运行时)
.rodata 存储常量数据(字符串字面量、常量数组)。 FLASH

表 4.1:链接脚本节区和它们在内存中的位置

理解这些节区的特性对于确保可执行文件正常工作非常重要。

选项和符号

选项在链接脚本中是影响链接器行为的命令或指令。一个典型的链接脚本包括设置程序入口点和定义内存布局的指令。

链接脚本中的符号是作为占位符或对微控制器内存空间中特定内存位置、值或地址的引用的标识符。符号可以用来表示内存节区的起始或结束地址或程序中的特定变量。例如,可以定义一个符号来表示闪存开始或 SRAM 区域的起始。我们还可以使用符号来定义在整个固件(如源代码文件)中使用的 重要常量或值。这些可能包括硬件地址、配置值或大小限制。通过使用符号,代码变得更加可读和可维护,因为这些值可以在一个地方(链接脚本)更改,而不是在代码的多个位置更改。

现在我们已经熟悉了链接脚本的关键组件,我们将继续学习这些脚本中的一些基本指令。链接脚本中的每个指令都指导链接器如何处理和组织输入目标文件到最终的可执行文件中。

链接脚本指令

在本节中,我们将学习链接脚本的基本指令。这些指令决定了内存布局以及各种节区(代码、数据等)如何在目标微控制器的内存中分配。我们将探讨关键指令、它们的功能以及它们如何影响编译固件的总体结构和效率。让我们从 MEMORY 指令开始。

内存指令(MEMORY)

MEMORY 指令界定了微控制器的内存区域。MEMORY 节区中定义的每个块代表一个独立的内存区域,其特征在于其名称、起始地址和大小。此指令允许我们定义目标设备的内存布局,指定不同的内存区域及其属性。它在指导链接器如何将程序的节区(代码、数据等)分配到微控制器的物理内存中起着重要作用。

使用模板

MEMORY 指令的一般语法如下:

MEMORY
{
  name (attributes) : ORIGIN = origin, LENGTH = length
}
  • name: 我们赋予内存区域的标识符

  • attributes: 这指定了区域的访问权限,例如读、写和执行权限

  • ORIGIN: 这定义了内存区域的起始地址

  • LENGTH: 这指定内存区域的大小

使用示例

考虑一个微控制器,它使用闪存来存储可执行代码,使用 SRAM 来存储数据。链接脚本可能如下定义这些内存区域:

MEMORY
{
  FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 256K
  SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}

在此示例中,定义了两个内存区域:FLASHSRAM

  • FLASH 被标记为读取(r)和执行(x)权限(rx),表示此区域可以存储可执行代码,但在程序执行期间不可写。它从 0x08000000 地址开始,扩展 256K 字节。

  • SRAM 被授予读取(r)、写入(w)和执行(x)权限(rwx),允许它在运行时存储和修改数据和可执行代码。它从 0x20000000 地址开始,扩展 64K 字节。

MEMORY 指令,通过其综合定义内存区域和属性,为固件开发中的高效和有效内存管理奠定了基础。在继续到下一个指令之前,让我们检查所有可以指定的属性,以详细说明内存部分的特性和权限:

  • r: 此属性允许读取内存。这对于包含程序在执行期间需要读取的可执行代码或常量的内存部分非常重要。

  • w: 此属性允许将数据写入内存。这对于程序在执行期间动态存储数据的内存区域非常重要。

  • x: 此属性允许从指定的内存区域执行代码。它通常分配给包含程序代码的闪存。

  • rw: 这是读取和写入权限的组合,允许在指定的内存区域进行这两种操作。它通常用于如 SRAM 这样的部分,其中存储和修改临时数据和变量。

  • rx: 这表示读取和执行权限的组合。它通常用于闪存,以指示该区域包含处理器可以读取和执行的执行代码。

  • rwx: 此属性组合了所有三种权限,使内存区域完全可读、可写和可执行。这由于安全和系统稳定性考虑而较少使用,但在某些开发或调试场景中可能适用。

  • empty: 如果没有指定属性,则默认情况下内存区域不授予任何访问权限。这可能在权限由固件中的其他方式控制或修改的特殊情况下使用。

现在,让我们检查 ENTRY 指令。

入口指令(ENTRY)

此指令指定程序的入口点,即复位后首先执行的代码片段。

这里是使用模板:

ENTRY(SymbolName)

这里是一个使用示例:

ENTRY(Reset_Handler)

在本例中,Reset_Handler被指定为程序的入口点,这意味着第一个要执行的功能。在固件开发中,Reset_Handler负责初始化系统并跳转到主程序。

接下来,我们介绍SECTIONS指令。

部分指令(SECTIONS)

这个指令定义了从输入文件到输出文件的映射和排序。

使用示例

让我们看看它的模板:

SECTIONS
{
  .output_section_name address :
  {
    input_section_information
  } >memory_region [AT>load_address] [ALIGN(expression)] [:phdr_
    expression] [=fill_expression]
}

参数如下:

  • output_section_name:这是为定义的输出部分给出的名称。常见的名称包括.text用于可执行代码,.data用于初始化数据,以及.bss用于未初始化数据。

  • address:这是可选的,并指定了部分在内存中的起始地址。这通常留给链接器根据脚本中定义的部分和内存区域的顺序来确定。

  • input_section_information:这决定了哪些输入部分(来自编译后的目标文件)应该包含在这个输出部分中。可以使用通配符,如*(.text),来包含所有输入文件中的.text部分。

  • >memory_region:这会将部分分配给在链接脚本MEMORY块中定义的特定内存区域。我们使用这个指令来告诉链接器这个部分应该在目标内存映射中的哪个位置,例如FLASHSRAM

  • [AT>load_address]:这是可选的,并指定了部分加载地址。这在执行地址与加载地址不同的场景中使用。

  • [ALIGN(expression)]:这是可选的,并将部分的开头对齐到由expression指定的值的倍数地址。这对于确保部分从满足特定对齐要求的地址开始特别有用,这可以提高访问速度和兼容性。

  • [:phdr_expression]:这是可选的,并将部分与程序头关联。程序头是可执行和链接格式ELF)文件结构的一部分;它们为系统加载器提供有关如何加载和运行程序不同段的信息。

  • [=fill_expression]:这是可选的,并指定一个字节值来填充部分之间的间隙或填充到部分末尾以达到一定的对齐。这可以用于初始化内存区域到已知状态。

使用示例

让我们看看SECTIONS指令的实际应用示例:

SECTIONS
{
  .text 0x08000000 :
  {
    *(.text)
  } >FLASH
}

在本例中,我们有以下内容:

  • SECTIONS:这个关键字标志着链接脚本中定义输出部分的开始。输出部分是内存区域,用于存放被链接的输入文件的代码和数据。

  • .text 0x08000000:这一行定义了一个名为.text的输出部分,并将其起始地址设置为0x08000000.text部分通常包含可执行代码。

  • { *(.text) }:这一行指定了要放入.text输出部分的内容。*(.text)语法意味着所有输入文件中的.text部分。

  • >FLASH:此指令告诉链接器将.text部分放置在名为FLASH的内存区域中。FLASH区域将在MEMORY指令块中定义。

要理解*(.text)语法的意义,让我们检查合并部分的过程。

部分合并

如我们之前所学的,汇编器为每个源文件生成一个目标文件,每个文件都包含其.text.data.bss和其他部分。然后,链接器将这些部分合并到最终可执行文件的统一.text.data.bss部分中。

考虑一个包含两个源文件(main.cdelay.c)的固件项目。汇编过程产生main.odelay.o,每个都有自己的部分。链接器的任务是将这些部分合并成最终可执行文件的单一代码集合。

下图展示了这一过程。请注意,合并不是通过加法过程完成的;这只是一个视觉辅助工具,以增强你的理解。

图 4.4:涉及两个源文件(main.c 和 delay.c)的合并过程,结果生成最终的可执行文件 final.elf

图 4.4:涉及两个源文件(main.c 和 delay.c)的合并过程,结果生成最终的可执行文件 final.elf

现在,让我们探讨AT >指令的目的。为了做到这一点,我们必须重新审视 LMA 和 VMA 的概念。

仔细观察 LMA 和 VMA

如我们之前所学的,二进制输出文件中的每个可分配和可加载输出部分都与两种类型的地址相关联:LMA(加载地址)和 VMA(虚拟地址)。这些地址对于定义在系统启动及其后续运行操作期间如何以及在哪里处理二进制文件的某个部分至关重要:

  • LMA:这是在程序执行开始之前,该部分在二进制映像中的物理地址。它决定了系统在程序启动时将从哪里将部分加载到内存中。

  • VMA:相反,VMA 是程序执行期间该部分打算被访问的地址。这是系统在引用该部分中的数据或指令时使用的“运行时”地址。对于不使用内存管理单元(MMU)的系统,尤其是微控制器,VMA 通常直接匹配该部分的物理内存地址。

为什么 LMA 和 VMA 很重要?

LMA 和 VMA 之间的区别允许采用灵活的内存管理方法,其中数据可以存储在一个位置(如闪存),但运行在另一个位置(如 SRAM)。例如,初始化的全局和静态变量(通常放置在.data部分)可以存储在闪存中,但需要复制到 SRAM 以实现更快的访问并允许在运行时进行修改。

为了完全理解这一点,让我们考虑以下由 STM32CubeIDE 生成的链接脚本片段:

  .data :
  {
    . = ALIGN(4);
    _sdata = .;  /* create a global symbol at data start */
    *(.data)           /* .data sections */
    *(.data*)          /* .data* sections */
    *(.RamFunc)        /* .RamFunc sections */
    *(.RamFunc*)       /* .RamFunc* sections */
    . = ALIGN(4);
    _edata = .;  /* define a global symbol at data end */
  } >SRAM AT> FLASH

在这个脚本中,最后一行 >SRAM AT> FLASH 包含了两个重要的指令:

  • >SRAM 表示在程序执行期间(VMA),输出 .data 部分被放置在内存的 SRAM 区域。

  • AT> FLASH 指定尽管在执行时部分位于 SRAM 中,但它应该最初被加载到内存中(FLASH)。这对于初始化数据很常见,这些数据存储在闪存中,然后在微控制器初始化代码启动时被复制到 SRAM。

这种对内存地址的详细管理突出了 LMA 和 VMA 在最大化资源受限微控制器效率中的关键作用。通过有效使用 LMA 和 VMA,我们可以确保即使在有限的内存资源下,我们的微控制器也能可靠高效地运行,优化存储和执行效率。

在探索链接器脚本的其他功能之前,让我们熟悉一些其他常用的指令。

其他常用指令

一些其他常用的指令包括 KEEPALIGNPROVIDE>regionAT 指令。让我们来检查它们。

KEEP 指令

KEEP 指令确保在优化过程中,指定的部分或符号不会被链接器删除,即使它们看起来没有被使用。这对于必须存在于最终二进制文件中的中断向量表和初始化函数至关重要。

这里是使用模板:

KEEP(section)

这里是一个使用示例:

KEEP(*(.isr_vector))

在这个示例中,我们正在保留中断向量部分。接下来,让我们看看区域放置指令。

>region 指令

(>region) 区域放置指令告诉链接器将特定的部分放置在特定的内存区域中。可用的内存区域必须在链接器脚本中的 MEMORY 指令块中定义。

这里是使用模板:

section >region

这里是一个使用示例:

.data :
{
  *(.data)
} >SRAM

在这个示例中,我们将 .data 部分放置在 SRAM 内存区域。

ALIGN 指令

ALIGN 指令在链接器脚本中起着至关重要的作用,通过调整位置计数器以与指定的内存边界对齐。位置计数器跟踪链接器在链接过程中为放置部分或输出文件的某部分分配的当前内存地址。

.) 在链接器脚本中。

链接器在处理脚本时,根据脚本中的指令为代码和数据部分分配内存地址,同时位置计数器监控进度。为了确保高效的内存访问并遵守硬件架构要求,部分和变量通常需要对齐到特定的边界。ALIGN 指令通过将位置计数器向上舍入到最接近的符合指定对齐的地址来实现这一点,该对齐必须是 2 的幂。

这里是使用模板:

. = ALIGN(expression);

这里是一个使用示例:

. = ALIGN(4);

在这个示例中,我们将当前位置对齐到 4 字节边界。

接下来,让我们看看 PROVIDE 指令。

PROVIDE 指令

PROVIDE 指令允许我们定义符号,如果它们尚未定义,链接器将包括这些符号在输出文件中。这可以用来设置可能被其他模块可选覆盖的符号的默认值。

这里是使用模板:

PROVIDE(symbol = expression);

这里是一个使用示例:

PROVIDE(_stack_end = ORIGIN(RAM) + LENGTH(RAM));

在这个例子中,我们正在 提供 一个默认的栈结束地址。

接下来,我们有 AT 指令。

AT 指令

当需要将一个段的 LMA 与其 VMA 区分开来时,AT 指令指定了该段的 LMA。这通常用于需要在初始化期间加载到不同内存区域,然后再移动到其运行时位置的段。

这里是使用模板:

section AT> lma_region

这里是一个使用示例:

.data : AT> FLASH
{
  *(.data)
} >SRAM

在这个例子中,.data 段在程序执行期间应驻留在 SRAM 中。然而,它最初是从 FLASH 加载的,如 AT> FLASH 所示。

在下一节中,我们将探讨链接脚本的关键方面:数值常量的表达。

理解链接脚本中的常量

在编写我们的链接脚本时,我们必须牢记链接器对数值前缀和后缀的解释。

首先,让我们澄清链接器如何识别具有特定前缀的整数。以 0 开头的整数被链接器读取为八进制数。另一方面,以 0x 开头的整数被识别为十六进制值。这种区别对于准确定义内存地址和大小很重要。

使用 KM 后缀引入了另一层便利性,允许我们简洁地表示大数字。K 后缀将前面的数字乘以 1024,而 M 将数字扩展为 1,024 的平方。因此,4K 等于 4 乘以 1024,而 4M 扩展为 4 乘以 1,024 的平方。

为了将这些原则付诸实践,让我们探索一个示例,展示这些符号的多样性。想象你需要指定 4K 的内存大小。你可以直接使用 4K,或者选择其十进制等价物,4096,这是通过将 1,024 乘以 4 得到的。或者,这个数量可以用十六进制形式表示为 0x1000

表 4.2 总结了在使用链接脚本中的常量时需要记住的关键点。它突出了修改基本值的前缀和后缀,这为在链接脚本中有效地解释和使用这些符号提供了清晰的参考。

符号 含义 示例 等效 十进制 十六进制 符号
0 八进制前缀 010 8 -
0x 十六进制前缀 0x10 16 -
K 乘以 1,024 4K 4096 0x1000
M 乘以 1,024 两次(平方) 4M 4194304 0x400000

表 4.2:链接脚本数值前缀和后缀的示例

在下一节中,我们将学习关于链接脚本符号的内容,进一步加深我们对链接脚本的理解。

链接脚本符号

链接符号,也简称为符号,是将源代码转换为可执行程序过程中的基本元素。在核心上,一个链接符号包含两个基本组件:一个名称和一个值。这些符号被分配整数值,代表变量、函数或其他程序元素在微控制器内存中的存储地址。

之前,我们了解到在汇编阶段之后,源代码被转换成目标文件。这些目标文件包含机器代码以及变量和函数的未解决引用。链接器的主要任务是合并这些目标文件,解决这些未解决符号,并生成一个准备执行的可执行文件。

在链接符号的上下文中,分配给符号的值代表相应的变量或函数所在的内存地址。

例如:X = 3500 表示 X 的内存地址是 3500

一个名为 X 的符号可能被分配一个值为 3500,表示其内存地址。重要的是要注意,与源代码中变量的值不同,X 链接符号代表其内存地址。

名称 类型 值(内存地址 描述
X 符号 3500 表示存储 X 变量的内存地址。
Y 符号 0x3000 表示存储 Y 变量的内存地址。
foo() 符号 0x4000 表示 foo() 函数所在的内存地址。
bar() 符号 0x5000 表示 bar() 函数所在的内存地址。
x 变量 3500 表示名为 x 的 C 变量的值。
y 变量 4500 表示名为 y 的 C 变量的值。

表 4.3:链接符号与 C 源代码变量赋值的比较

链接符号可以执行各种操作,例如我们在 C 赋值中使用的那些操作。这些操作包括简单的赋值(=)、加法(+=)和减法(-=)等。

图 4.5:链接符号操作示例

图 4.5:链接符号操作示例

在链接过程中,会创建一个符号表,将每个符号映射到内存中的相应地址。这个表作为链接器解决符号引用和确保程序组件正确链接的关键参考。

让我们考虑一个场景,我们有一个 main.c 文件,在这个文件的顶部声明了一个名为 X 的变量,并给它分配了一个值为 568。此外,在这个文件中,有一个名为 blink 的函数。在 blink 函数内部,有操作来打开 LED,等待,然后关闭它。这如图 4.6 所示。

现在,让我们将这个 main.c 文件通过构建过程传递,以生成 main.o 目标文件。在这个过程中,会生成一个符号表。表中的每个符号都与一个地址相关联。

例如,X 符号会被分配地址 0x20000000,同样,blink 函数也会被分配其地址。在下面的图中,blink 函数被分配地址 0x08000000

实质上,就像在 C 编程语言中一样,每个变量都有自己的值。在目标文件中,每个符号都有自己的值,这本质上代表了 C 中相应变量或函数的地址。

因此,当在目标文件中引用 X 时,它不会给出 568,而是会提供 X 的地址。这个过程将值分配给符号并将它们与地址关联起来,从而构建符号表。

图 4.6:源文件中的函数和变量在输出目标文件符号表中的表示

图 4.6:源文件中的函数和变量在输出目标文件符号表中的表示

在本节中,我们深入探讨了链接脚本,强调了关键组件和指令。我们仔细探讨了每个指令,提供了实际使用示例。此外,我们还区分了 LMA 和 VMA,并强调了它们在指导链接器如何放置部分中的重要作用。在下一节中,我们将学习如何从头开始编写自己的链接脚本和启动文件,为您在裸机固件开发中提供另一项重要技能。

编写链接脚本和启动文件

既然我们已经很好地理解了链接脚本及其基本组件,我们就准备编写自己的脚本。然而,在深入编写脚本之前,重新审视微控制器的内存映射并深入了解对象文件中各个部分的 positions 加载内存是非常重要的。

理解不同部分的加载内存

如本章前面所述,输出目标文件被结构化为如 .data.rodata.text.bss 等部分。除了汇编器创建的部分外,我们还必须定义自己的部分来容纳 .isr_vector_tbl 的向量表。

这些部分中的每一个都在组织微控制器的内存布局中发挥着重要作用,有助于最终可执行文件的功能性和效率。

4**.7 展示了闪存区域的放大视图,显示了在闪存内存中放置不同部分的所需顺序。每个部分代表所有输入文件中相同部分的组合。例如,图中描述的 .text 部分是一个统一的 .text 部分,由合并所有输入文件中的 .text 部分形成。

该图表明,放置必须从闪存开始的.isr_vector_tbl部分开始。随后,我们必须放置.text部分,然后是.rodata部分,最后是.data部分。该图没有显示.bss部分的放置,因为我们将在 SRAM 中直接放置.bss部分。此外,在启动代码实现过程中,我们必须将.data部分的内容从闪存复制到 SRAM。

图 4.7:显示各部分放置顺序的闪存区域

图 4.7:显示各部分放置顺序的闪存区域

在我们继续之前,让我们先了解中断和中断向量表的概念。

中断和中断向量表

中断是计算中的基本概念。它们作为管理计算机或微控制器如何处理任务以及如何响应外部和内部事件的一种强大机制。

在本质上,中断是硬件设备或内部软件条件向处理器发出的信号,该信号暂时中止当前操作。此信号表明需要立即关注。当处理器收到中断时,它会暂停当前任务,保存其状态,并执行一个称为 ISR(中断服务例程)的功能来处理中断。完成 ISR 后,处理器恢复其先前任务,确保关键信号得到及时和有效的处理。

中断有哪些类型

我们可以将中断大致分为两类:硬件中断和软件中断:

  • 硬件中断:这些中断来源于外部设备,例如开关、网络适配器或任何需要与处理器通信的外围设备。例如,按下按钮可能会触发一个硬件中断,通知处理器启动电机。

  • 软件中断:与硬件中断不同,软件中断是由软件指令触发的。这些中断被程序用来中断当前进程流程并执行特定的例程。

中断向量表的作用是什么

中断向量表作为一个基本查找表,指导处理器找到每个中断的正确 ISR。ISR 只是一个设计用来处理和管理由中断触发的特定需求的函数。该表本身组织为一个指针数组,每个指针将系统指向给定中断的指定 ISR。当发生中断时,系统引用此表以定位处理中断所需的 ISR 的确切内存地址。这种高效机制使系统能够迅速响应各种事件,例如外部输入、计时器超时和内部状态的变化。

考虑到这一点,我们终于准备好编写链接脚本了。

编写链接脚本

在我们的工作区文件夹中,让我们创建一个名为 3_LinkerscriptAndStartup 的新文件夹。在这个文件夹中,创建一个名为 stm32_ls.ld 的文件,并确保其扩展名为 .ld。如果你使用的是 Windows,并且它询问你是否真的想要更改文件扩展名,请点击 。然后,右键单击文件,使用基本文本编辑器(如 Notepad++)打开它。

我们使用链接脚本的目标可以总结如下:

  • 指定固件的入口点

  • 详细说明可用内存

  • 指定必要的堆和栈大小

  • 定义输出部分

这是我们的完整链接脚本,stm32_ls.ld 文件的内容:

/*Specifying the firmware's entry point*/
ENTRY(Reset_Handler)
/*Detailing the available memory*/
MEMORY
{
    FLASH(rx):ORIGIN =0x08000000,LENGTH =512K
    SRAM(rwx):ORIGIN =0x20000000,LENGTH =128K
}
_estack = ORIGIN(SRAM)+LENGTH(SRAM);
/*Specifying the necessary heap and stack sizes*/
__max_heap_size = 0x200;
__max_stack_size = 0x400;
/*Defining output sections*/
SECTIONS
{
    .text :
    {
     . = ALIGN(4);
      *(.isr_vector_tbl)
      *(.text)
      *(.rodata)
      . = ALIGN(4);
     _etext = .;
    }>FLASH
    .data :
    {
     . = ALIGN(4);
    _sdata = .;
      *(.data)
     . = ALIGN(4);
    _edata = .;
    } > SRAM AT> FLASH  /*>(vma) AT> (lma)*/
        .bss :
    {
     . = ALIGN(4);
    _sbss = .;
    *(.bss)
     . = ALIGN(4);
    _ebss = .;
    }> SRAM
}

让我们将其分解。

指定固件的入口点

ENTRY(Reset_Handler)

如我们之前所学的,ENTRY 指令指定了固件的入口点,这是当固件启动时首先执行的代码片段。在这种情况下,入口点是名为 Reset_Handler 的函数。我们将在启动文件中实现此函数。

详细说明可用内存

MEMORY
{
    FLASH(rx):ORIGIN =0x08000000,LENGTH =512K
    SRAM(rwx):ORIGIN =0x20000000,LENGTH =128K
}

我们的脚本指定了两个内存区域:FLASHSRAM。具有读和执行权限 (rx) 的 FLASH 内存从 0x08000000 地址开始,长度为 512K。具有读、写和执行权限 (rwx) 的 SRAM 内存从 0x20000000 地址开始,长度为 128K

符号创建

_estack = ORIGIN(SRAM)+LENGTH(SRAM);

在这里,我们创建一个名为 _estack 的符号,并将其设置为 SRAM 内存区域的末尾。我们将使用此符号来初始化栈指针。

SRAM 确保它从最大可用地址开始,有效地利用 SRAM 空间进行栈操作。

我们链接脚本中的下一行代码指定了堆和栈的大小。

指定必要的堆和栈大小

__max_heap_size = 0x200;
__max_stack_size = 0x400;

这些行定义了堆的最大大小(0x200 字节)和栈的大小(0x400 字节)。这些大小对于动态内存分配和函数调用管理分别很重要。

下一个部分定义了输出部分。

定义输出部分

在本节中,我们将介绍输出部分。

.text 输出部分

我们链接脚本中的这一部分显示了 .text 输出部分:

.text :
{
  . = ALIGN(4);
  *(.isr_vector_tbl)  /*merge all .isr_vector_tbl sections of input 
  files*/
  *(.text)   /*merge all .text sections of input files*/
  *(.rodata) /*merge all .rodata sections of input files*/
  . = ALIGN(4);
 _etext = .;  /*Create a global symbol to hold end of text section*/
}>FLASH

让我们将其分解:

  • . = ALIGN(4);:

    此指令将 .text 部分的起始位置对齐到 4 字节边界。这提高了内存访问效率,这对于以字大小块获取指令的处理器来说是一个关键考虑因素。

  • *(.``isr_vector_tbl) :

    此指令将输入文件中所有名为 .isr_vector_tbl 的部分拉入当前 .text 部分的当前位置。

  • *(.``text):

    此指令将输入文件中所有名为 .text 的部分拉入当前 .text 部分的当前位置。

  • *(.``rodata):

    此指令将输入文件中所有名为 .rodata 的部分拉入当前 .text 部分的当前位置。

  • . = ALIGN(4);:

    再次,这行代码确保了段的末尾对齐到 4 字节边界。在这里,我们使用它来对齐段的末尾,确保下一个段从对齐的边界开始。

  • _etext = .;:

    在这里,我们在当前位置定义了一个名为 _etext 的符号。这个符号标记了 .text 段的结束。我们将在启动文件中将此符号用作 .text 段结束的指针。

  • }>``FLASH:

    这个指令指定 .text 段应放置在之前在链接脚本的 MEMORY 块中定义的 FLASH 内存段中。

这个部分显示了 .data 输出段:

    .data :
    {
     . = ALIGN(4);
    _sdata = .;   /*Create a global symbol to hold start of data 
    section*/
      *(.data)
     . = ALIGN(4);
    _edata = .;   /*Create a global symbol to hold end of data 
    section*/
    } > SRAM AT> FLASH  /*>(VMA) AT> (LMA)*/

让我们分解一下:

  • = ALIGN(4);:

    这个指令将 .data 段的开始对齐到 4 字节边界。

  • _sdata = .;:

    在这里,我们创建一个名为 _sdata 的符号来表示 .data 段的开始,通过将其设置为当前位置计数器。我们将在启动文件中将此符号用作 .data 段开始的指针。

  • *(.``data):

    这个指令将所有名为 .data 的段从输入文件中拉入当前 .data 段的位置。

  • . = ALIGN(4);:

    这行代码确保了段的末尾对齐到 4 字节边界。

  • _edata= .;:

    与我们之前所做的一样,我们创建一个名为 _edata 的符号来表示 .data 段的末尾,通过将其设置为当前位置计数器。我们将在启动文件中使用此符号。

  • > SRAM AT> FLASH:

    这个指令指定 .data 段的 LMA 和 VMA。

    > SRAM 表示该段应位于 SRAM 中,允许在运行时进行读写访问。

    AT> FLASH 告诉链接器,尽管该段放置在 SRAM 中以执行,但其初始值应存储在 FLASH 中。

这个部分显示了 .bss 输出段:

.bss :
{
 . = ALIGN(4);
_sbss = .;
*(.bss)
 . = ALIGN(4);
_ebss = .;
}> SRAM

这是我们的链接脚本的最终输出段。

如我们之前所学的,.bss 段包含未初始化的全局和静态变量,这些变量将在我们的启动文件中初始化为零。这种零初始化确保了本节中所有变量都以已知状态开始,有助于提高我们固件的稳定性和可预测性。

与其他段类似,我们首先将段对齐到 4 字节边界以提高内存访问效率,然后定义 _sbss_ebss 符号分别标记段的开始和结束。这些符号有助于计算段的大小及其初始化过程。最后,我们将段放置在 SRAM 中,强调虽然它不会在磁盘上的二进制文件中占用空间,但在运行时需要内存分配。

在我们的链接脚本最终确定后,我们将继续实现启动文件。这将是下一节的重点。

编写启动文件

启动文件对于初始化固件至关重要,它执行了几个关键任务以确保系统从上电的那一刻起就能正确运行。

这些任务包括以下内容:

  • 实现向量表:这涉及到定义将中断映射到其处理程序的向量表,确保系统可以高效地响应各种事件。

  • 创建中断处理程序:对于向量表中列出的每个中断,必须实现一个中断处理程序来定义系统如何响应该特定事件。

  • Reset_Handler,如链接脚本中指定的,作为固件的初始入口点。这个函数在复位后立即执行,并负责为主应用程序设置环境。

  • .data部分从FLASH复制到SRAM

  • .bss部分清零,确保所有未初始化的全局和静态变量都以已知状态开始,以保证可靠的运行。

在包含链接脚本的当前文件夹中,创建一个名为stm32f411_startup.c的文件,并确保其扩展名为.c。如果您使用 Windows 并且它询问您是否真的想要更改文件扩展名,请点击。然后,右键单击文件,使用基本文本编辑器(如 Notepad++)打开它。

让我们分析完整的启动代码。

以下是我们用 C 语言编写的完整启动代码,stm32f411_startup.c文件的内容。在以下代码片段中,我们没有显示向量表中所有中断的所有函数原型。完整的源代码可以在本书的资源中找到:

extern uint32_t _estack;
extern uint32_t _etext;
extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sbss;
extern uint32_t _ebss;
void Reset_Handler(void);
int main(void);
void NMI_Handler(void)__attribute__((weak,
alias("Default_Handler")));
void HardFault_Handler (void) __attribute__ ((weak, alias("Default_Handler")));
void MemManage_Handler (void) __attribute__ ((weak, alias("Default_Handler")));
.
.
.
uint32_t vector_tbl[] __attribute__((section(".isr_vector_tbl"))) = {
    (uint32_t)&_estack,
    (uint32_t)&Reset_Handler,
    (uint32_t)&NMI_Handler,
    (uint32_t)&HardFault_Handler,
    (uint32_t)&MemManage_Handler,
.
.
.
};
void Default_Handler(void) {
    while(1) {
    }
}
void Reset_Handler(void)
{
    // Calculate the sizes of the .data and .bss sections
    uint32_t data_mem_size =  (uint32_t)&_edata - (uint32_t)&_sdata;
    uint32_t bss_mem_size  =   (uint32_t)&_ebss - (uint32_t)&_sbss;
    // Initialize pointers to the source and destination of the .data 
    // section
    uint32_t *p_src_mem =  (uint32_t *)&_etext;
    uint32_t *p_dest_mem = (uint32_t *)&_sdata;
    /*Copy .data section from FLASH to SRAM*/
    for(uint32_t i = 0; i < data_mem_size; i++  )
    {
         *p_dest_mem++ = *p_src_mem++;
    }
    // Initialize the .bss section to zero in SRAM
    p_dest_mem =  (uint32_t *)&_sbss;
    for(uint32_t i = 0; i < bss_mem_size; i++)
    {
         /*Set bss section to zero*/
        *p_dest_mem++ = 0;
    }
        // Call the application's main function.
    main();
}

让我们将其分解。

我们首先声明外部符号。

外部符号声明

extern uint32_t _estack;
extern uint32_t _etext;
extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sbss;
extern uint32_t _ebss;

这些行声明了我们在链接脚本中定义的外部符号。每个符号代表在启动过程中使用的具有重要内存地址:

  • _estack:这是堆栈的初始顶部。这个值在启动过程中早期被加载到主堆栈指针寄存器中。

  • _etext:这标志着可执行代码部分的结束和存储在闪存中的数据部分的开始。我们使用这个作为从FLASHSRAM复制初始化数据的参考点。

  • _sdata_edata分别代表 SRAM 中初始化数据段的起始和结束地址。我们使用它们来确定从FLASHRAM的数据复制的尺寸和目的地。

  • _sbss_ebss标记 SRAM 中未初始化数据段(BSS 段)的起始和结束。我们使用这些符号来清除此部分,将其设置为零。

在我们的代码片段中接下来,我们有函数原型及其属性。

函数原型和属性

void Reset_Handler(void);
int main(void);
void NMI_Handler(void)__attribute__((weak,
alias("Default_Handler")));
void HardFault_Handler (void) __attribute__ ((weak, alias("Default_Handler")));
void MemManage_Handler (void) __attribute__ ((weak, alias("Default_Handler")));
.
.
.

在启动文件的这部分,我们声明了Reset_Handler函数的原型、应用程序的main函数以及具有特定属性的几个中断处理程序:

__attribute__((weak, alias("Default_Handler"))): 这个属性使得每个处理程序具有弱链接,并将其别名为名为 Default_Handler 的函数。它允许这些处理程序被显式定义的其他地方具有相同名称的处理程序覆盖。

让我们进一步分解这个语句以了解其重要性:

  • __attribute__:

    我们使用这个关键字来告诉编译器,它应用到的声明具有某些属性,这些属性会影响链接器以及可能运行时对其的处理。属性可以用来控制优化、代码生成、对齐,以及与我们讨论相关,链接特性。

  • weak:

    将函数或变量声明为 weak 意味着它不会阻止链接器使用具有相同名称的具有更强链接的另一个符号。我们使用此来指定可以覆盖的默认实现。

    在我们的中断处理程序上下文中,将它们标记为 weak 允许我们在启动文件中定义默认处理程序,而应用特定的处理程序可以在不修改启动文件的情况下覆盖这些默认处理程序。

  • alias("Default_Handler"):

    这部分属性为另一个符号创建了一个别名,在本例中为 Default_Handler。这意味着符号(例如,NMI_Handler)不仅具有弱链接,而且它也是 Default_Handler 函数的别名。

    这意味着当发生中断,并且没有在其他地方(例如,NMI_Handler)定义具有更强链接(非弱链接)的特定处理程序时,程序将使用 Default_Handler 来代替。这确保了所有中断都有一个处理程序,防止系统因未处理的事件而崩溃。

接下来,我们有向量表数组。

向量表定义

uint32_t vector_tbl[] __attribute__((section(".isr_vector_tbl"))) = {
    (uint32_t)&_estack,
    (uint32_t)&Reset_Handler,
    (uint32_t)&NMI_Handler,
    (uint32_t)&HardFault_Handler,
    (uint32_t)&MemManage_Handler,
…
};

这个数组定义了微控制器的中断向量表,放置在我们定义在链接脚本中的 .isr_vector_tbl 部分。

我们将 _estack 符号设置为向量表的第一个元素,以定义内存中堆栈的初始顶部。在 ARM Cortex 微控制器中,例如我们的 STM32F411,向量表的第一字(32 位)必须包含 主堆栈指针MSP)的初始值。在复位时,处理器将此值加载到 MSP 寄存器中,以便在执行任何代码之前正确设置堆栈指针。

然后,我们指定 Reset_Handler 的地址,然后我们继续按顺序列出 NMI_Handler 和其他后续中断处理程序的地址。这些处理程序的精确放置至关重要,因为每个处理程序都必须位于特定的内存位置,以确保正确功能。这种安排在 RM0383 文档的第 201 页上有详细说明。在我们的 stm32f411_startup.c 文件中定义的完整向量表中,您会注意到在中断处理程序地址之间有策略地放置了零。这些零作为不支持我们特定微控制器变体(STM32F411)的中断位置的占位符。ARM Cortex-M 内核架构旨在支持一套全面的中断,但并非所有中断都在每个微控制器变体中实现。通过在向量表中插入这些不支持的中断的零,我们保持与架构规范的必要对齐,确保系统正确运行。

让我们更仔细地看看数组声明:

uint32_t vector_tbl[] __attribute__((section(".isr_vector_tbl")))={…}
  • uint32_t vector_tbl[]

    这指定了 vector_tbl 数组的每个元素都是一个无符号 32 位整数。我们选择此类型是因为 ARM Cortex-M 微控制器的地址长度为 32 位,而向量表由指向 ISR 处理程序起始地址的内存地址组成。

  • __attribute__((section(".isr_vector_tbl")))

    此属性指示链接器将 vector_tbl 数组放置在名为 .isr_vector_tbl 的输出文件特定部分。

接下来,我们有我们的默认处理程序函数。

默认处理程序

void Default_Handler(void) {
    while(1) {
        // Infinite loop
    }
}

此函数作为任何未实现特定处理程序的中断请求的通用回退。在无限循环中操作实际上防止了程序在发生此类事件后进入未定义状态。

它链接到所有标记为 weak 的中断处理程序,并在应用程序中别名 Default_Handler。此策略确保系统对任何缺乏专用处理程序的中断请求做出统一和安全的响应,从而维护系统稳定性和完整性。

最后,我们有我们的 Reset_Handler 函数。

重置处理程序实现

void Reset_Handler(void) {
    uint32_t data_mem_size =  (uint32_t)&_edata - (uint32_t)&_sdata;
    uint32_t bss_mem_size  =   (uint32_t)&_ebss - (uint32_t)&_sbss;
    uint32_t *p_src_mem =  (uint32_t *)&_etext;
    uint32_t *p_dest_mem = (uint32_t *)&_sdata;
    for(uint32_t i = 0; i < data_mem_size; i++  ) {
        *p_dest_mem++ = *p_src_mem++;
    }
    p_dest_mem =  (uint32_t *)&_sbss;
    for(uint32_t i = 0; i < bss_mem_size; i++) {
        *p_dest_mem++ = 0;
    }
    main();
}

Reset_Handler 的任务是准备系统,以便执行主应用程序。

在函数中,我们首先计算 .data.bss 部分的大小:

    uint32_t data_mem_size =  (uint32_t)&_edata - (uint32_t)&_sdata;
    uint32_t bss_mem_size  =   (uint32_t)&_ebss - (uint32_t)&_sbss;

这里是分解:

  • 通过从 .data 部分的起始地址 (_sdata) 减去结束地址 (_edata) 来计算 .data 部分的大小。此大小用于将初始化数据从 FLASH 复制到 SRAM

  • 以类似的方式计算 .bss 部分的大小,使用起始地址 (_sbss) 和结束地址 (_ebss)。此大小用于在 SRAM 中清除 .bss 部分。

    接下来,在函数中,我们初始化指针以复制 .data 部分:

        uint32_t *p_src_mem =  (uint32_t *)&_etext;
        uint32_t *p_dest_mem = (uint32_t *)&_sdata;
    

这里是分解:

  • 将源指针 (p_src_mem) 初始化为存储在闪存中初始化数据的地址,标记为 _etext

  • 将目标指针 (p_dest_mem) 初始化为 SRAM 中 .data 部分的开始 (_sdata)。

    然后,我们复制 FLASH 中的 .data 部分到 SRAM

        for(uint32_t i = 0; i < data_mem_size; i++  ) {
            *p_dest_mem++ = *p_src_mem++;
        }
    

分解:

  • 通过字(32 位)将 .data 部分从 FLASH 复制到 SRAM。对于每次迭代,p_src_mem 指向的内容被复制到 p_dest_mem 指向的位置,然后两个指针都增加到下一个字。

接下来,我们初始化 .bss 部分的指针为零:

    p_dest_mem =  (uint32_t *)&_sbss;

我们简单地重置目标指针 (p_dest_mem) 到 SRAM 中 .bss 部分的开始 (_sbss),为清零做准备。

我们随后将 .bss 部分清零:

    for(uint32_t i = 0; i < bss_mem_size; i++) {
        *p_dest_mem++ = 0;
    }

此块逐字将 .bss 部分在 SRAM 中清零。对于每次迭代,p_dest_mem 指向的位置被设置为 0,然后 p_dest_mem 增加到下一个字。

最后,我们调用位于源代码 main.c 文件中的 main() 函数:

    main();

在初始化 .data.bss 部分后,此行调用 main 函数,将控制权转移到主应用程序代码。这标志着系统初始化过程的结束和应用程序执行的开始。

现在我们已经完成了我们的链接脚本和启动文件,是时候通过仅使用我们的 main.c 源文件、stm32_ls.ld 链接脚本和 stm32f411_startup.c 启动文件来构建固件,以测试我们的实现了。

测试我们的链接脚本和启动文件

在深入命令行之前,确保我们的链接脚本和启动文件放置正确非常重要。让我们设置我们的项目目录并对我们的 main.c 文件进行一些修改:

  1. 在你的工作空间中找到 3_LinkerAndStartup

  2. 从上一个项目(2_RegisterManipulation)的 main.c 文件,其中包含基础应用程序代码。

  3. 此外,找到 stm32_ls.ld(链接脚本)和 stm32f411_startup.c(启动文件)。

  4. 将这些文件(stm32_ls.ldstm32f411_startup.cmain.c)复制粘贴到 3_LinkerAndStartup 文件夹中。

  5. 从快到慢地查看 main.c 文件:

    • 3_LinkerAndStartup 文件夹中找到 main.c 文件并选择使用简单的文本编辑器,如 Notepad++ 打开它。

    • (LED_PIN)。调整此部分中的延迟间隔以改变 LED 的闪烁速率,从当前的快速速度变为较慢的速度。当前的一个应该看起来像这样:

              //  22: Toggle PA5(LED_PIN)
              GPIOA_OD_R ^= LED_PIN;
          for(int i = 0; i < 100000; i++){}
      

    将当前代码替换为以下片段以以较慢的速度切换 PA5 的状态:

            //  22: Toggle PA5(LED_PIN)
            GPIOA_OD_R ^= LED_PIN;
        for(int i = 0; i < main.c file.
    

现在,让我们通过我们学到的步骤使用命令提示符访问我们的新文件夹第三章。Windows 用户最喜欢的方 法是上下文菜单方法:

在 Windows 资源管理器中导航到 3_LinkerAndStartup 文件夹。一旦到达那里,按住 Shift 键,在文件夹内的空白处 右键单击,然后选择 3_LinkerAndStartup 文件夹。

在命令提示符中,我们首先编译 main.c 文件,我们通过执行以下命令来完成:

arm-none-eabi-gcc -c -mcpu=cortex-m4 -mthumb -std=gnu11 main.c -o main.o

然后,我们执行启动文件:

arm-none-eabi-gcc -c -mcpu=cortex-m4 -mthumb -std=gnu11 stm32f411_startup.c -o stm32f411_startup.o

一旦我们的 main.ostm32f411_startup.o 对象文件准备就绪,我们就使用我们的链接脚本链接所有对象文件 (*.o):

arm-none-eabi-gcc -nostdlib -T stm32_ls.ld *.o -o 3_LinkerAndStartup.elf.elf

此过程生成了 3_LinkerAndStartup.elf 可执行文件。

接下来,我们启动 openocd 以开始上传过程:

openocd -f board/st_nucleo_f4.cfg

随着 OpenOCD 的运行,下一步涉及使用 GNU 调试器 (GDB) 将固件上传到微控制器。让我们打开另一个命令提示符窗口(因为 OpenOCD 应该在第一个窗口中继续运行)并输入以下命令以启动 GDB:

arm-none-eabi-gdb

一旦 GDB 打开,我们通过运行以下命令来与我们的微控制器建立连接:

target remote localhost:3333

让我们使用以下命令重置和初始化板子,就像我们在 第三章 中学习的那样:

monitor reset init

接下来,我们使用以下命令将固件加载到微控制器上:

monitor flash write_image erase 3_LinkerAndStartup.elf

成功加载固件后,我们再次使用相同的复位命令来重置板子:

monitor reset init

最后,我们使用以下命令在微控制器上恢复固件的执行:

monitor resume

就这样;你应该会看到 LED 以较慢的速度闪烁,这表明新固件的成功上传和执行。

摘要

在本章中,我们深入探讨了嵌入式裸机编程的核心组件,重点关注微控制器的内存模型、编写链接脚本和启动文件。我们首先探索了 STM32 微控制器的内存布局,强调了闪存和 SRAM 在存储可执行代码和运行时数据方面的重要性。

我们在章节中投入了相当大的篇幅来构建和理解链接脚本。通过这个过程,我们理解了这些脚本在固件构建过程中的关键作用,通过将编译后的固件部分映射到微控制器的特定内存区域,以确保可执行程序正确运行。我们学习了链接脚本中的各种指令,例如 MEMORYSECTIONS。这些指令对于定义内存布局和指定程序部分在内存中的放置位置和方式至关重要。

我们关于链接脚本的讨论扩展到了定义内存区域、对齐部分和管理部分属性以实现最佳内存利用的实际问题。我们特别关注 LMA 和 VMA,这对于高效的程序加载和执行至关重要。

转到启动文件部分,我们详细阐述了启动文件的作用,包括初始化向量表、设置Reset_Handler以及为主应用程序的执行准备系统。我们学习了将.data部分从FLASH复制到SRAM以及清零.bss部分的步骤,确保我们的固件有一个可预测的启动。

在下一章中,我们将探讨构建系统,突出Make工具的至关重要作用。这一知识将使我们能够通过自动化构建过程来简化我们的构建流程,而不是手动在命令行中输入每个命令。

第五章:“Make”构建系统

在本章中,我们将学习如何使用构建系统自动化我们的整个构建过程,特别是关注make构建系统——在软件开发中自动化编译和链接过程的不可或缺的工具。我们首先定义什么是构建系统,然后探讨其基本目的,这主要涉及自动将源代码转换为可部署的软件,如可执行文件或库。

在本章中,我们将系统地揭示make构建系统的组成部分,从Makefile的基本元素开始,包括目标依赖项配方。在章节的后半部分,我将提供编写 Makefile 的逐步指南,强调执行构建所需的有效语法和结构。

在本章中,我们将涵盖以下主要主题:

  • 构建系统的简介

  • Make 构建系统

  • 为固件项目编写 Makefile

在本章结束时,你将牢固地理解如何利用make构建系统来简化你的开发过程,提高构建时间,并减少在构建和部署固件时的手动错误。

技术要求

本章的所有代码示例都可以在 GitHub 上找到,链接为github.com/PacktPublishing/Bare-Metal-Embedded-C-Programming

构建系统的简介

在软件开发的世界里,构建系统是关键的工具,它能够将源代码转换为可执行程序或其他可用的软件格式。这些系统自动化了编译和链接代码、管理依赖项以及确保软件构建可重复和高效的过程。简单来说,构建系统是指一组工具,它们自动化了将源代码编译成二进制代码、将二进制代码与库链接以及将结果打包成可部署软件单元的过程。这些系统旨在通过跟踪软件项目中哪些部分需要重新编译来处理复杂的依赖链,从而优化构建过程。构建系统负责一系列任务,包括以下内容:

  • 依赖管理:这涉及到识别和解决软件所需的各个组件或库之间的相互依赖关系。

  • 代码编译:将源代码(无论是用 C、C++还是其他编程语言编写的)转换为机器可读的对象代码。

  • 链接:这个过程将编译后的对象文件和必要的库集成到一个统一的可执行文件或库文件中。

  • 打包:这一步为软件的部署做准备,可能包括创建安装程序包或将软件压缩成可分发存档。

  • 测试和验证:在软件发布前执行自动化测试,以确认软件符合预定义的质量基准。

  • 文档生成:构建系统还可以自动化文档的创建。这是通过集成工具如 C/C++ 的 Doxygen、Java 的 Javadoc 或 Python 的 Sphinx 来实现的,这些工具从源代码中提取注释和元数据以生成结构化文档。这种自动化确保了文档与源代码的变化保持同步,从而保持一致性并减少人工错误。

通过整合这些多样化的功能,构建系统显著提高了软件开发过程的效率和可靠性。现代软件项目通常涉及复杂的配置,包括数千个源文件和广泛的外部依赖。构建系统提供了一个关键框架来有效地管理这些复杂性。它们自动化重复性任务,最小化人为错误的可能性,并确保在不同环境中构建的一致性。这种简化不仅提高了生产力,还支持持续集成和持续交付实践的采用,这对于及时和有效地交付软件至关重要。

选择构建系统取决于各种因素,包括项目使用的编程语言、所需的平台兼容性以及开发团队对工具的熟悉程度。一些常用的构建系统包括 makemaven

Make

Make 是最古老且最基础的构建系统之一。它主要用于 C 和 C++ 项目。Make 使用 Makefiles 来指定如何编译和链接源文件。它的主要优势在于其简单性和在不同平台上的广泛支持。

其关键特性包括以下内容:

  • 灵活性:Make 允许我们定义如何编译和链接文件的明确规则。

  • make 可以与各种编译器和编程语言一起使用。在 Windows 上,make 可以在 Minimalist GNU for Windows(MinGW)或 Cygwin 等环境中使用。

接下来,让我们看看 Maven。

Maven

Maven 主要用于 Java 项目。它旨在提供一个全面和标准的框架来构建项目、处理文档、报告、依赖项、源代码管理(SCM)系统、发布和分发。其关键特性包括以下内容:

  • 约定优于配置:Maven 使用标准的目录布局和默认的构建生命周期,以减少在项目配置上花费的时间

  • 依赖管理:它可以自动从仓库下载库和插件,并将它们纳入构建过程

  • 项目管理信息:Maven 可以从项目的元数据生成项目文档、报告和其他信息

  • 构建和发布管理:Maven 支持整个构建生命周期,从编译、打包和测试到部署和发布管理。

  • 可扩展性:Maven 的基于插件的架构允许它通过自定义插件进行扩展,以支持额外的任务。

其他值得注意的构建系统包括基于 Java 的 Apache Ant 和支持多种编程语言的 Gradle,但 Gradle 在 Java 生态系统中特别受欢迎。

在探索 make 构建系统的具体细节之前,熟悉构建系统的基本组件非常重要。这些组件构成了构建过程的基础,包括以下内容:

  • 源代码:用 C、Java、Python 等编程语言编写的原始、可读代码。

  • javac 用于 Java。

  • 链接器:将目标文件组合成单个可执行文件或库文件的工具。

  • 构建脚本:描述构建过程的脚本。它们定义了需要运行的命令及其顺序。

  • 依赖项:在构建过程中需要集成到项目中的外部代码库或工具。

  • 工件:构建系统的输出,可以包括可执行文件、库或其他格式的软件部署或运行所需的文件。

在接下来的章节中,我们将探讨 make 构建系统的基本原理,并学习如何编写 Makefile 以自动化固件项目的构建过程。

Make 构建系统

在本节中,我们将探讨 Make 构建系统,从其基本概念到在固件开发中的实际应用。

Make 的基础知识

make 构建系统的核心组件是 Makefile,它包含一组由工具使用的指令,用于生成 目标。在核心上,Makefile 由 规则 组成。每个规则以 目标 开头,然后是 先决条件,最后是 配方

  • main.oapp.exe。目标也可以是执行的动作名称。

  • main.cadc.c)。

  • make 执行以构建目标。

以下图示说明了简单的 make 规则:

图 5.1:一个 Make 规则,以 main.o 作为目标文件,由先决条件 main.c 生成,使用 arm-none-eabi-gcc main.c –o main.o 脚本

图 5.1:一个 Make 规则,以 main.o 作为目标文件,由先决条件 main.c 生成,使用 arm-none-eabi-gcc main.c –o main.o 脚本。

注意

配方的行必须以制表符开头。

Makefile 还允许我们使用变量来简化和管理复杂的构建命令和配置。例如,我们可以定义一个变量名 CC 来表示 编译器 命令,如图 图 5.2 所示:

图 5.2:一个 Make 规则,以 main.o 作为目标文件,由先决条件 main.c 生成,其中配方中的编译器命令被变量替换

图 5.2:一个 Make 规则,其中 main.o 是目标文件,由先决条件 main.c 生成,其中配方中的编译器命令被一个变量替换

Makefile 中的变量允许我们存储可以在整个文件中重用的文本字符串。定义变量的最基本方法是简单的赋值(=):

CC = arm-none-eabi-gcc

这行设置 CC 变量为 arm-none-eabi-gcc 交叉编译器。

一旦定义,变量就可以在整个 Makefile 中使用,以简化命令和定义。要使用变量,请将其名称用 $(...)${...} 括起来:

$(CC) main.c –o main.o

配方使用 $(CC) 变量来引用之前设置的编译器(arm-none-eabi-gcc)。

除了用户定义的变量外,还有与目标和先决条件相关的特殊变量,在编写 Makefile 时非常有用。

make 中,与目标相关的特殊变量有助于简化指定文件名和文件路径的过程,使 Makefile 中的规则更加通用和可重用。最常用的目标相关特殊变量之一是 '$@' - 目标名称。这个变量代表规则的目标名称。当目标名称在规则中多次重复时,它特别有用,这在链接和编译命令中很常见。

让我们看一个例子:

main.o :  main.c
     arm-none-eabi-gcc main.c –o $@

在这个例子中,$@ 被替换为 main.o,这是规则的目標。

Make 还提供了特殊变量来引用先决条件。最常用的一个变量是 $^。这个变量列出了目标的所有先决条件,它们之间用空格分隔(如果有多个)。让我们看一个例子:

main.o :  main.c
     arm-none-eabi-gcc $^ –o main.o

当执行前面的 Makefile 片段时,$^ 被替换为 main.c,实际上运行了 arm-none-eabi-gcc main.c –o main.o 命令。

Makefile 中的特殊变量在定义构建规则时提高效率和灵活性非常有用。通过有效地使用这些变量,我们可以创建更健壮和可维护的构建系统。

我们将用 图 5**.3 来结束本节。这个图展示了 图 5**.2 中的修订规则,现在包括了我们的用户定义变量以及与目标和先决条件相关的特殊变量。

图 5.3:使用用户定义变量和两个特殊变量的 Make 规则

图 5.3:使用用户定义变量和两个特殊变量的 Make 规则

在下一节中,我将指导您在您的开发计算机上设置 make 构建系统。

安装和配置 Make

在本节中,我们将介绍在 Windows 环境中下载、安装和配置 make 构建系统的过程。让我们开始:

  1. 下载 make:我们首先导航到适当的网站下载适用于 Windows 的 GNU Make。在这个例子中,我们将使用 SourceForge,这是一个流行的开源项目存储库。请访问 gnuwin32.sourceforge.net/packages/make.htm

    完整包,除源代码选项描述下,点击下载列下的设置以开始下载。

  2. 计算机上的make。当你达到标题为C:\Program Files (x86)\GnuWin32的步骤时。

  3. 从任何命令行或脚本中运行make,我们需要将其可执行文件添加到我们的系统环境变量中,遵循我们在第一章中添加 OpenOCD 到环境变量的相同过程。

    我们通过导航到make安装的bin文件夹(C:\Program Files (x86)\GnuWin32\bin)并复制路径来完成这项操作。

    然后,我们执行以下操作:

    1. 右键单击make路径到这个新行。

    2. 通过在各个弹出窗口中点击确定来确认您的更改。

为了确认make已正确设置,打开命令提示符并简单地输入make,如图所示:

make

它应该返回以下内容:

make: *** No targets specified and no makefile found.  Stop.

这确认了 Windows 机器上的make构建系统已正确配置。

在许多 Linux 发行版中,make可以通过发行版的包管理器轻松获得。例如,在 Ubuntu 和其他基于 Debian 的发行版中,我们可以通过运行以下命令来安装make(以及其他构建基本工具,如 GCC 编译器):

 sudo apt install build-essential

在 macOS 上,make是 Xcode 的一部分,这是苹果的开发工具套件。这意味着如果你已经安装了 Xcode,那么你已经有make了。我们还可以通过在终端运行以下命令来安装独立的命令行工具包:

xcode-select –-install

在本节中,我们在我们的开发机器上成功设置了make构建系统。在下一节中,我们将应用本章中介绍的概念来编写我们自己的 Makefile。

为固件项目编写 Makefile

本节的重点是编写一个 Makefile 并成功测试它。让我们开始吧。

在我们的工作区文件夹中,让我们创建一个名为4_Makefiles的新文件夹。在这个文件夹中,创建一个名为Makefile的文件。这个文件必须以大写M开头,并且应该没有扩展名

如果你使用的是 Windows,并且它询问你是否真的想要更改文件扩展名,请点击。然后,右键单击文件,使用基本文本编辑器(如 Notepad++)打开它。

我们对 Makefile 的目标可以总结如下:

  1. main.cstm32f411_startup.c)到目标文件(main.ostm32f411_startup.o)。

  2. stm32_ls.ld)来创建最终的可执行文件(4_makefile_project.elf)。

  3. *.o*.elf.,和*.map),允许在没有遗留之前构建的任何残留物的情况下从头开始。

这是我们完整的 Makefile:

final : 4_makefile_project.elf
main.o : main.c
    arm-none-eabi-gcc -c -mcpu=cortex-m4 -mthumb -std=gnu11 main.c -o 
    main.o
stm32f411_startup.o : stm32f411_startup.c
    arm-none-eabi-gcc -c -mcpu=cortex-m4 -mthumb -std=gnu11 stm32f411_
    startup.c -o stm32f411_startup.o
4_makefile_project.elf : main.o stm32f411_startup.o
    arm-none-eabi-gcc -nostdlib -T stm32_ls.ld *.o -o 4_makefile_
    project.elf -Wl,-Map=4_makefile_project.map
load :
    openocd -f board/st_nucleo_f4.cfg
clean:
del    -f *.o *.elf *.map

让我们将其分解:

  1. final : 4_makefile_project.elf

    这一行处理的是最终目标的创建。

    当我们执行make final时,make将检查4_makefile_project.elf目标是否需要更新,然后再执行它。这是一个依赖关系,其中final纯粹作为一个聚合点来调用所有导致4_makefile_project.elf的构建过程。

  2. main.o : main.c

    arm-none-eabi-gcc -c -mcpu=cortex-m4 -mthumb -std=gnu11 main.c -``o main.o

    make规则将main.c源文件编译成名为main.o的对象文件。仔细检查后,你可以看到这里使用的命令与我们之前在命令提示符下手动编译main.c时使用的命令相同。

  3. stm32f411_startup.o : stm32f411_startup.c

    arm-none-eabi-gcc -c -mcpu=cortex-m4 -mthumb -std=gnu11 stm32f411_startup.c -``o stm32f411_startup.o

    此命令将stm32f411_startup.c源文件编译成名为stm32f411_startup.o的对象文件。

  4. 4_makefile_project.elf : main.o stm32f411_startup.o

    arm-none-eabi-gcc -nostdlib -T stm32_ls.ld *.o -o 4_makefile_project.elf -Wl,-Map=4_makefile_project.map

    此规则将main.ostm32f411_startup.o目标文件链接起来生成最终的可执行文件4_makefile_project.elf。此外,它还生成一个名为4_makefile_project.map的映射文件,该文件显示了代码和数据中的每个部分在内存中的加载位置,这对于调试非常有用。

  5. load:

    openocd -``f board/st_nucleo_f4.cfg

    此规则启动 OpenOCD,开始将最终可执行文件加载到目标硬件上的过程。它执行 OpenOCD,使用针对 STM32 Nucleo F4 板定制的配置文件,具体为st_nucleo_f4.cfg

  6. clean:

    del -f *.o *.elf *.map

    此命令通过删除所有生成的文件来清理构建目录,确保为后续构建提供一个干净的环境。del -f命令强制删除文件,防止出现要求删除确认的提示。*.o*.elf*.map模式分别指定应删除所有对象文件、ELF 可执行文件和映射文件。

当 Makefile 准备就绪时,是时候对其进行测试了。

测试我们的 Makefile

在进入命令行之前,让我们确保链接脚本、启动文件以及所有源文件都已放置在正确的目录中。此外,我们将稍微更新main.c文件,这将使我们能够验证固件最新版本的执行是否正确:

  1. 来自上一个项目(3_LinkerscriptAndStartup)的main.c文件,其中包含基础应用程序代码。

  2. 此外,找到stm32_ls.ld(链接脚本)和stm32f411_startup.c(启动文件)。

  3. 将这些文件(stm32_ls.ldstm32f411_startup.cmain.c)复制粘贴到4_Makefiles文件夹中。

  • main.c文件从慢到快:

    1. 4_ Makefiles文件夹中找到main.c文件,并选择使用简单的文本编辑器,如 Notepad++打开它的选项。

    2. LED_PIN)。调整此部分中的延迟间隔以改变 LED 的闪烁速率,从当前的较慢速度变为快速。当前的应该看起来像这样:

            //  22: Toggle PA5(LED_PIN)
            GPIOA_OD_R ^= LED_PIN;
        for(int i = 0; i < 5000000; i++){}
    

    将当前代码替换为以下片段以将 PA5 的状态切换到更快的速率:

            //  22: Toggle PA5(LED_PIN)
            GPIOA_OD_R ^= LED_PIN;
    for(int i = 0; i < main.c file.
    

现在,让我们通过命令提示符访问我们的新文件夹,按照我们在上一章中使用的步骤进行。

在命令提示符中,只需执行以下命令:

make final

这将创建我们的最终可执行文件,4_makefile_project.elf

接下来,我们将通过执行以下命令开始将最终的可执行文件上传到我们的微控制器:

make load

这将启动 OpenOCD。下一步涉及使用GNU 调试器GDB)将固件上传到微控制器,就像我们在上一章中做的那样。让我们打开另一个命令提示符窗口(因为 OpenOCD 应该在第一个窗口中继续运行)并输入以下命令以启动 GDB:

arm-none-eabi-gdb

一旦 GDB 打开,我们通过运行以下命令与我们的微控制器建立连接:

target remote localhost:3333

让我们重置并初始化板子,就像我们在第三章中学到的那样,使用以下命令:

monitor reset init

接下来,我们使用以下命令将固件加载到微控制器上:

monitor flash write_image erase 4_makefile_project.elf

在成功加载固件后,我们再次使用相同的重置命令重置板子:

monitor reset init

最后,我们使用以下命令恢复微控制器上固件的执行:

monitor resume

你应该看到 LED 以快速闪烁,这表明我们的新固件上传和执行成功。

我们可以通过执行以下命令停止 GDB:

quit

然后,当我们被问及是否真的要退出时,我们执行以下命令:

y

为了清理我们的构建目录,我们将打开构建目录中的命令提示符并执行以下命令:

make clean

这将删除构建目录中的所有.o.elf.map文件。

在结束本章之前,让我们探索一下当我们引入特殊和用户定义变量时,我们的 Makefile 看起来是什么样的。

应用特殊和用户定义变量

让我们将特殊变量和用户定义变量应用到我们的 makefile 中:

CC = arm-none-eabi-gcc
CFLAGS = -c -mcpu=cortex-m4 -mthumb -std=gnu11
LDFLAGS = -nostdlib -T stm32_ls.ld -Wl,-Map= 5_makefile_project_v2.map
final : 5_makefile_project_v2.elf
main.o : main.c
    $(CC) $(CFLAGS) $^ -o $@
stm32f411_startup.o : stm32f411_startup.c
    $(CC) $(CFLAGS) $^ -o $@
5_makefile_project_v2.elf : main.o stm32f411_startup.o
    $(CC) $(LDFLAGS) $^ -o $@
load :
    openocd -f board/st_nucleo_f4.cfg
clean:
    del    -f *.o *.elf *.map

在这个版本中,我们已定义了三个基本变量以简化我们的 Makefile:

  • CC:这个变量代表用于编译源文件的编译器。它通过集中定义编译器来简化 Makefile,使得在需要时更新或更改变得更加容易。

  • CFLAGS:这个变量包含构建源文件所需的编译标志。

  • LDFLAGS:这个变量包含控制如何从目标文件链接可执行文件的链接器标志。

此外,我们已使用特殊变量($@)和依赖项列表($^)来替换make食谱中这些组件的明确提及,从而进一步简化了 Makefile 结构。

将你的当前 makefile 更新到这个新版本,并将名为5_makefile_project_v2.elf的新可执行文件上传到你的微控制器。这个更新版本应该像上一个版本一样无缝运行。

摘要

在本章中,我们开始探索make构建系统,这是软件开发中自动化构建过程的一个基石工具。这次旅程从介绍构建系统是什么以及它们在将源代码转换为可部署软件(如可执行文件和库)中的关键作用开始。

然后,我们深入探讨了make构建系统的具体机制,从 Makefile 的基础元素开始,包括目标依赖项配方。这些组件被彻底讨论,以提供对它们如何在make中交互以管理和简化软件项目的编译和链接的清晰理解。

本章以编写 Makefile 的实际演示结束,有效地巩固了之前讨论的理论概念。这种动手经验确保您能够将这些策略应用到自己的固件项目中。

在下一章中,我们将过渡到固件开发的关键方面之一——外设驱动程序的开发,从通用输入/输出GPI/O)驱动程序开始。这一转变将向您介绍与硬件组件交互的基础知识,这是嵌入式系统开发中的一个关键技能。

第六章:通用微控制器软件接口标准(CMSIS)

在本章中,我们将深入研究通用微控制器软件接口标准CMSIS),这是 Cortex-M 和某些 Cortex-A 处理器的关键框架。我们将从学习如何使用 C 结构定义硬件寄存器开始。这种基础知识将使我们能够阅读和理解微控制器制造商提供的 CMSIS 兼容的头文件。

接下来,我们将探讨 CMSIS 本身,讨论其组件以及它是如何促进高效软件开发的。最后,我们将设置从我们的硅制造商处获取的必要头文件,展示 CMSIS 合规性如何简化生产和提高代码可移植性。

在本章中,我们将涵盖以下主要主题:

  • 使用 C 结构定义外设寄存器

  • 理解 CMSIS

  • 设置所需的 CMSIS 文件

到本章结束时,你将具备对 CMSIS 的扎实理解,以及如何使用它来增强你的 Arm Cortex-M 项目的代码可移植性。

技术要求

本章的所有代码示例都可以在 GitHub 上找到,链接为github.com/PacktPublishing/Bare-Metal-Embedded-C-Programming

使用 C 结构定义外设寄存器

在嵌入式系统开发中,使用 C 结构定义硬件寄存器是一种基本技术,它可以提高代码的可读性和可维护性。在本节中,我们将探讨如何使用 C 结构来表示外设及其寄存器,通过实际示例和类比来简化概念。

在前面的章节中,我们配置了一个通用输入/输出GPIO)引脚(PA5),通过手动定义每个所需寄存器的地址来点亮一个 LED。我们学习了如何从文档中找到正确的地址,定义寄存器和定义寄存器位。虽然这种方法有效,但随着项目的复杂性增加,可能会变得繁琐。

为了简化这个过程,我们可以使用 C 结构来表示外设及其寄存器。这种方法将相关的寄存器组合成一个统一的单元,以匹配我们的微控制器的硬件架构和内存映射,使代码更加直观。

让我们创建一个结构体来表示 GPIO 外设及其相关的寄存器。

为了实现这一点,我们需要获取每个 GPIO 端口的基址以及这些端口内每个寄存器的偏移量。在这里,偏移量指的是寄存器地址相对于外设基址的相对位置。

在深入创建结构体的细节之前,了解如何获取必要的基址和偏移量是很重要的。

获取寄存器的基址和偏移量

第二章中,我们学习了如何在数据手册中定位外设的基址。具体来说,我们查看了 STM32F411 数据手册的第 54 至 56 页,列出了微控制器外设的基址。以下是 GPIO 和 复位和时钟控制RCC)外设的提取基址:

外设 基址
GPIOA 0x4002 0000
GPIOB 0x4002 0400
GPIOC 0x4002 0800
GPIOD 0x4002 0C00
GPIOE 0x4002 1000
GPIOH 0x4002 1C00
RCC 0x4002 3800

表 6.1:GPIO 和 RCC 的基址

此外,在第二章中,我们介绍了如何从参考手册(RM383)中提取寄存器偏移量。以下是所有 GPIO 寄存器的提取偏移量:

寄存器 偏移量
GPIOx_MODER 0x00
GPIOx_OTYPER 0x04
GPIOx_OSPEEDR 0x08
GPIOx_PUPDR 0x0C
GPIOx_IDR 0x10
GPIOx_ODR 0x14
GPIOx_BSRR 0x18
GPIOx_LCKR 0x1C
GPIOx_AFRL 0x20
GPIOx_AFRH 0x24

表 6.2:GPIO 寄存器偏移量

表 6.1 显示了我们 STM32F411 微控制器的所有 GPIO 寄存器及其偏移量,按照它们在内存中出现的顺序排列。我们微控制器中的几乎所有寄存器都是 32 位(4 字节)大小。如 表 6.1 所示,每个寄存器相对于前一个寄存器偏移 4 字节。例如,GPIOx_OTYPER 寄存器位于 0x04,距离 GPIOx_MODER 寄存器位于 0x00(0x04 - 0x00 = 4)有 4 字节。同样,GPIOx_PUPDR 寄存器位于 0x0C,距离 GPIOx_OSPEEDR 寄存器位于 0x08 有 4 字节。

这告诉我们,寄存器在该内存区域中是连续排列的,因为我们知道每个寄存器的大小为 4 字节。

然而,这种连续排列的情况并不总是如此。在某些情况下,外设内部的寄存器之间会留下几个字节的间隙。

现在,偏移量和基址之间有什么关系呢?

想象你的微控制器就像一个大型公寓楼。每个公寓代表一个外设,例如 GPIO 或 RCC,每个公寓的入口就是外设的基址。在每间公寓内部,有几个房间,这些房间代表寄存器。每个房间都有特定的用途,并且位于入口的一定距离处,称为偏移量

例如,当你进入 GPIO 公寓(外设)时,客厅可能是位于入口处(偏移量 0x00)的 GPIOx_MODER 寄存器。厨房可能是位于走廊稍远处的 GPIOx_OTYPER 寄存器(偏移量 0x04)。卧室可能是位于走廊更远处的 GPIOx_OSPEEDR 寄存器(偏移量 0x08),等等。

这种排列表明每个房间(寄存器)都放置在入口(基址)的固定距离(偏移量)处。在我们的情况下,由于每个房间的大小是 4 字节,所以每个后续房间都距离前一个房间 4 字节。然而,在某些公寓(外设)中,房间之间可能会有额外的空间,表明寄存器的非连续放置。这类似于房间之间有一个小走廊,当你检查参考手册中的 RCC 外设等外设时,你会注意到这一点。

现在,让我们继续使用我们迄今为止所学的内容来实现外设结构体。

实现外设结构体

以下是我们GPIO_TypeDef结构体,代表 GPIO 外设:

typedef struct
{
  volatile uint32_t MODER;    /*offset: 0x00      */
  volatile uint32_t OTYPER;   /*offset: 0x04      */
  volatile uint32_t OSPEEDR;  /*offset: 0x08      */
  volatile uint32_t PUPDR;    /*offset: 0x0C      */
  volatile uint32_t IDR;      /*offset: 0x10      */
  volatile uint32_t ODR;      /*offset: 0x14      */
  volatile uint32_t BSRR;     /*offset: 0x18      */
  volatile uint32_t LCKR;     /*offset: 0x1C      */
  volatile uint32_t AFRL;     /*offset: 0x20     */
  volatile uint32_t AFRH;     /*offset: 0x24      */
} GPIO_TypeDef;

让我们分解一下语法:

typedef struct 开始了一个新结构类型的定义。typedef 用于为结构体创建一个别名,允许我们在代码的后续部分使用 GPIO_TypeDef 作为类型名。

结构体中的每个成员都被声明为volatile uint32_t。以下是分解:

  • volatile:这个关键字表示变量的值可以在任何时候改变,通常是由于硬件变化引起的。编译器不应优化对这个变量的访问。

  • uint32_t: 这表示结构体中的每个成员都是一个 32 位(4 字节)的无符号整数。这很重要,因为我们正在处理的寄存器大小也是 32 位。为了确保结构体成员能够准确代表这些寄存器,它们必须匹配这个大小。这种对齐保证了每个成员在内存映射中正确对应其各自的寄存器。

还要注意,结构体成员的排列顺序和大小与参考手册中指定的寄存器相同。

现在,正如我们在第二章中讨论的那样,要使用微控制器中的任何外设,我们首先需要启用对该外设的时钟访问。这是通过 RCC 外设完成的。让我们为 RCC 外设创建一个结构体。

这是我们的 RCC 结构体:

typedef struct
{
  volatile uint32_t DUMMY[12];
  volatile uint32_t AHB1ENR;       /*offset: 0x30*/
} RCC_TypeDef;

STM32F411 微控制器的 RCC 外设大约有 24 个寄存器,这些寄存器不是连续的,在内存区域中留下空隙。我们感兴趣的寄存器是为了 GPIO 外设的AHB1ENR寄存器,其偏移量为 0x30。

在我们的RCC_TypeDef中,我们添加了足够的uint32_t(4 字节)项目填充到结构体中,以达到偏移量 0x30。在这种情况下,是 12 个项目。这是因为在 4 字节乘以 12 等于 48 字节,这对应于十六进制表示法中的0x30。

在这一点上,我们已经定义了两个重要的结构(GPIO_TypeDefRCC_TypeDef),这些结构是配置和控制我们的 GPIO 引脚所必需的。下一步涉及到使用这些结构创建 GPIO 和 RCC 外设的基地址指针。这使我们能够以结构化和可读的方式访问和操作外设寄存器。以下是完成此任务的代码片段:

#define     RCC_BASE     0x40023800
#define     GPIOA_BASE  0x40020000
#define     RCC         ((RCC_TypeDef*) RCC_BASE)
#define    GPIOA         ((GPIO_TypeDef*)GPIOA_BASE)

让我们分解一下:

  • #define RCC_BASE 0x40023800

    这一行定义了 RCC 外设的基地址。地址值取自 表 6.1

  • #define GPIOA_BASE 0x40020000

    这一行定义了 GPIOA 外设的基地址。

  • #define RCC ((RCC_TypeDef*) RCC_BASE)

    这一行定义了一个宏,RCC,它将 RCC_BASE 基地址转换为 RCC_TypeDef* 类型的指针。

    通过这样做,RCC 成为一个指向 RCC 外设的指针,允许我们通过 RCC_TypeDef 结构访问其寄存器。

  • #define GPIOA ((GPIO_TypeDef*) GPIOA_BASE)

    同样,这一行定义了一个宏,GPIOA,它将 GPIOA_BASE 基地址转换为 GPIO_TypeDef* 类型的指针。

    这使得 GPIOA 成为一个指向 GPIOA 外设的指针,使我们能够通过 GPIO_TypeDef 结构访问其寄存器。

完成这些后,我们现在可以测试我们的实现了。让我们在下一节中这样做。

评估基于结构的寄存器访问方法

让我们更新我们之前的工程以使用基于结构的寄存器访问方法:

// 0: Include standard integer types header for fixed-width //integer types
#include <stdint.h>
// 1: GPIO_TypeDef structure definition
typedef struct
{
  volatile uint32_t MODER;    /*offset: 0x00      */
  volatile uint32_t OTYPER;   /*offset: 0x04      */
  volatile uint32_t OSPEEDR;  /*offset: 0x08      */
  volatile uint32_t PUPDR;    /*offset: 0x0C      */
  volatile uint32_t IDR;      /*offset: 0x10      */
  volatile uint32_t ODR;      /*offset: 0x14      */
  volatile uint32_t BSRR;     /*offset: 0x18      */
  volatile uint32_t LCKR;     /*offset: 0x1C      */
  volatile uint32_t AFRL;     /*offset: 0x20     */
  volatile uint32_t AFRH;     /*offset: 0x24      */
} GPIO_TypeDef;
// 2: RCC_TypeDef structure definition
typedef struct
{
  volatile uint32_t DUMMY[12];
  volatile uint32_t AHB1ENR;       /*offset: 0x30*/
} RCC_TypeDef;
// 3: Base address definitions
#define     RCC_BASE     0x40023800
#define     GPIOA_BASE   0x40020000
// 4: Peripheral pointer definitions
#define RCC            ((RCC_TypeDef*) RCC_BASE)
#define GPIOA        ((GPIO_TypeDef*)GPIOA_BASE)
//5: Bit mask for enabling GPIOA (bit 0)
#define GPIOAEN       (1U<<0)
//6: Bit mask for GPIOA pin 5
#define PIN5          (1U<<5)
//7: Alias for PIN5 representing LED pin
#define LED_PIN       PIN5
//  8: Start of main function
int main(void)
{
    //  9: Enable clock access to GPIOA
     RCC->AHB1ENR |=  GPIOAEN;
     GPIOA->MODER |= (1U<<10);  //  10: Set bit 10 to 1
     GPIOA->MODER &= ~(1U<<11); //  11: Set bit 11 to 0
    //  21: Start of infinite loop
    while(1)
    {
        //  12: Set PA5(LED_PIN) high
        GPIOA->ODR^= LED_PIN;
        // 13: Simple delay
                for(int i=0;i<100000;i++){}
    }
}

在这个新的实现中,我们使用 C 结构指针运算符(->)来访问所需的寄存器。以下是 RCC->AHB1ENR 的分解:

  • RCC: 这是一个指向类型为 RCC_TypeDef 的结构的指针。这个指针允许我们通过结构成员访问 RCC 寄存器。

  • ->: 这是 C 中的结构指针运算符。它用于通过指针访问结构的一个成员。

  • AHB1ENR: 这是 RCC_TypeDef 结构的一个成员。

同样,我们使用相同的方法来访问 GPIOA 寄存器。

下面是 GPIOA->MODERGPIOA->ODR 的分解:

  • GPIOA: 这是一个指向类型为 GPIO_TypeDef 的结构的指针,允许访问 GPIOA 寄存器

  • MODER: GPIO_TypeDef 结构的一个成员,代表 GPIO 端口模式寄存器

  • ODR: GPIO_TypeDef 结构的另一个成员,代表 GPIO 端口输出数据寄存器

构建项目并在您的开发板上运行它。它应该与上一个项目以相同的方式工作。

在本节中,我们学习了如何使用 C 结构定义硬件寄存器。这项技术是理解 CMSIS 兼容代码的重要步骤,将在下一节中介绍。

理解 CMSIS

在本节中,我们将探讨 CMSIS,这是一个为 Arm Cortex-M 和某些 Cortex-A 处理器设计的框架。CMSIS是一个供应商无关的硬件抽象层,它标准化了各种基于 Arm Cortex 的微控制器平台之间的软件接口,促进了软件的可移植性和可重用性。让我们首先了解 CMSIS 及其关键优势。

什么是 CMSIS?

CMSIS,发音为See-M-Sys,是由 Arm 开发的标准,旨在提供一种一致且高效的方式与基于 Cortex 的微控制器进行接口。它包括一套全面的 API、软件组件、工具和工作流程,旨在简化并优化嵌入式系统的开发过程。

它的关键优势包括以下内容:

  • 标准化:CMSIS 对所有基于 Cortex 的微控制器接口进行标准化,这使得您可以在不重新学习或重新配置代码库的情况下轻松地在不同微控制器和工具之间切换

  • 便携性:通过提供一致的 API,CMSIS 允许为某一微控制器开发的软件轻松移植到另一个微控制器,增强代码重用并减少开发时间

  • 效率:CMSIS 包括优化的库和函数,如数字信号处理DSP)和神经网络内核,这些可以提升性能并减少内存占用

CMSIS 有几个组件。让我们探索一些常用的组件。

CMSIS 的关键组件

CMSIS 由几个组件组成,每个组件都服务于独特的目的:

  • CMSIS-Core (M):此组件是为所有 Cortex-M 和 SecurCore 处理器设计的。它提供了配置 Cortex-M 处理器核心和外设的标准 API。它还标准化了设备外设寄存器的命名,有助于在切换不同微控制器时减少学习曲线。

  • CMSIS-Driver:这为中间件提供了通用的外设驱动接口,促进了微控制器外设与中间件(如通信堆栈、文件系统或图形用户界面)之间的连接。

  • CMSIS-DSP:此组件提供了一套超过 60 个针对各种数据类型的函数库,针对 Cortex-M4、M7、M33 和 M35P 处理器上可用的单指令多数据SIMD)指令集进行了优化。

  • CMSIS-NN:这代表神经网络。它包括一系列针对 Cortex-M 处理器核心进行优化的高效神经网络内核,旨在最大化性能并最小化内存占用。

  • CMSIS-RTOS:有两个版本,RTOS v1 和 RTOS v2。RTOS v1 支持 Cortex-M0、M0+、M3、M4 和 M7 处理器,为实时操作系统提供公共 API。RTOS v2 扩展了对 Cortex-A5、A7 和 A9 处理器的支持,包括动态对象创建和多核系统支持等功能。

  • CMSIS-Pack: Pack 描述了一个软件组件、设备参数和评估板支持的交付系统。它简化了软件重用并促进了有效的产品生命周期管理。

  • CMSIS-SVD: SVD 代表 System Viewer Description。它定义了由硅供应商维护的设备描述文件,包含对微控制器外设和寄存器的全面描述,格式为 XML。开发工具导入这些文件以自动构建外设调试窗口。

CMSIS 的另一个关键方面是其编码标准。让我们更详细地了解一下这些指南。

CMSIS 编码规则

这里是 CMSIS 中使用的必要编码规则和约定:

  • 符合 ANSI C (C99) 和 C++ (C++03): 这确保了与广泛接受的编程标准的兼容性。

  • <stdint.h>,确保数据表示的一致性。

  • 完整数据类型: 变量和参数使用完整数据类型定义,避免了歧义。

  • MISRA 2012 符合性: 虽然 CMSIS 符合 MISRA 2012 指南,但它并不声称完全符合 MISRA。任何违规规则都会被记录下来以确保透明度。

此外,CMSIS 还使用特定的限定符:

  • __I 用于只读变量(相当于 ANSI C 中的 volatile const

  • __O 用于只写变量

  • __IO 用于读写变量(相当于 ANSI C 中的 volatile

这些限定符在 CMSIS 中提供了一种方便的方式来指定变量的预期访问模式,特别是对于内存映射外设寄存器。

在具备这些背景知识的基础上,让我们继续学习如何在嵌入式项目中使用 CMSIS。

CMSIS-Core 文件

CMSIS-Core 文件分为两个主要组,每个组在开发过程中都发挥着特定的作用。这些组是 CMSIS-Core 标准文件和 CMSIS-Core 设备文件。图 6**.1 提供了 CMSIS-Core 文件结构的全面概述,说明了不同类型文件及其在项目中的作用。

图 6.1:CMSIS-Core 文件

图 6.1:CMSIS-Core 文件

让我们分析一下这个图。

文件分为三个类别:CMSIS-Core 标准文件CMSIS-Core 设备文件用户 程序文件

CMSIS-Core 标准文件类别由 Arm 提供,通常不需要修改。它们包括以下内容:

  • core_<cpu>.h: 该文件提供了对 CPU 和核心特定功能的访问

  • cmsis_compiler.h: 该文件包含核心外设函数、CPU 指令访问和 SIMD 指令访问

  • <arch>_<feature>.h: 该文件定义了特定于架构的属性和功能

  • cmsis_<compiler>_m.h: 这是一个针对工具链的特定文件,有助于提高编译器的兼容性和优化

下一类是CMSIS-Core 设备文件。这些文件由硅供应商(如 STMicroelectronics)提供,可能需要针对特定应用进行修改。它们包括以下内容:

  • system_<Device>.c:此文件处理系统和时钟配置

  • partition_<Device>.h:此文件管理安全属性和中断分配

  • startup_<Device>.c:此文件包含设备启动中断向量

  • <Device>.h:此文件提供对 CMSIS 设备外设功能的访问

  • system_<Device>.h:此文件协助进行系统和时钟配置

第三类是用户程序文件。这些是我们开发者创建的,包括主应用程序代码以及其他对于项目运行至关重要的用户定义功能。

理解 CMSIS 对于在各个基于 Arm Cortex 的微控制器平台上进行高效和标准化的开发是基本的。在本节中,我们探讨了其关键组件、CMSIS 编码标准和对于嵌入式项目开发至关重要的 CMSIS-Core 文件。在下一节中,我们将学习如何将所需的 CMSIS 文件包含到我们的项目中,使我们能够充分利用这个强大的框架来开发嵌入式系统。

设置所需的 CMSIS 文件

在本节中,我们将通过将 CMSIS 文件集成到我们的项目中的过程。这些文件还包含了所有寄存器和它们各自的位定义,这使得在没有手动定义每个寄存器的情况下管理和配置外设变得更加容易。

获取正确的头文件

首先,从 STMicroelectronics 网站下载我们微控制器的软件包:

  1. 打开你的浏览器并访问www.st.com/content/st_com/en.html

  2. 搜索STM32CubeF4以定位 STM32F4 微控制器系列的软件包。

  3. 接下来,下载 STM32CubeF4 软件包:

    1. 定位 STM32CubeF4 软件包并下载最新版本。请确保不要下载补丁版本。

    2. 下载完成后,解压软件包。你将找到几个子文件夹,包括Drivers文件夹。

我们接下来的步骤是组织文件:

  1. 在你的项目工作区中,创建一个名为chip_headers的新文件夹。

  2. chip_headers文件夹内,创建另一个名为CMSIS的文件夹。

  3. 在解压的软件包中导航到Drivers/CMSIS

  4. Drivers/CMSIS中的整个Include文件夹复制到chip_headers/CMSIS

  5. 然后,将Drivers/CMSIS中的Device文件夹复制到chip_headers/CMSIS

最后,我们清理Device文件夹:

  1. 导航到chip_headers/CMSIS/Device/ST/STM32F4xx

  2. 删除所有文件和文件夹,除了 Include 文件夹。这确保你只保留特定微控制器的必要头文件。

今后,我们将持续在项目目录中包含chip_headers文件夹。这种做法确保了在另一台计算机上运行我们的项目的人不会因为缺少头文件而遇到错误。

在下一节中,我们将在 STM32CubeIDE 中创建一个新的项目。设置项目后,我们将复制chip_headers文件夹并将其粘贴到项目目录中。随后,我们将添加chip_headers内的子文件夹到项目的include路径中,确保必要 CMSIS 文件的顺利集成。

与 CMSIS 文件一起工作

在本节中,我们将通过将相关文件夹添加到项目的include路径中来将 CMSIS 文件集成到我们的项目中。然后,我们将通过更新我们的main.c文件以使用这些 CMSIS 文件而不是我们手动定义的外设结构来测试我们的设置。

让我们按照第一章中概述的步骤创建一个新的项目。我将把我的项目命名为header_files。一旦项目创建完成,我将复制并粘贴chip_headers文件夹到项目文件夹中。

我们接下来的任务是将chip_headers文件夹内子文件夹的路径添加到项目的include路径中:

  1. 打开 STM32CubeIDE,右键单击您的项目,并选择属性

  2. 一旦属性窗口打开,展开C/C++ 通用选项。

  3. 选择路径符号

  4. 包含选项卡下,点击添加以添加新的目录。

  5. 输入${ProjDirPath}\chip_headers\CMSIS\Include以添加位于我们CMSIS文件夹中的Include文件夹,然后点击确定保存。

  6. 再次点击添加以添加另一个目录。

  7. 输入${ProjDirPath}\chip_headers\CMSIS\Device\ST\STM32F4xx\Include以添加位于STM32F4xx子目录中的Include文件夹,然后点击确定保存。

图 6**.2展示了在将这两个目录添加到项目的include路径后,项目属性窗口的示意图。

图 6.2:项目属性窗口中的包含选项卡

图 6.2:项目属性窗口中的包含选项卡

让我们分析我们刚刚添加的两行:

  • ${ProjDirPath}\chip_headers\CMSIS\Include

    • ${ProjDirPath}:这是 STM32CubeIDE 中的一个宏,代表您当前项目的根目录。它是一个占位符,动态指向您的项目所在目录。

    • \chip_headers\CMSIS\Include:这指定了相对于项目根目录的路径。它指向chip_headers文件夹内CMSIS目录中的Include文件夹。此文件夹包含通用的 CMSIS 包含文件,为 Cortex-M 处理器提供核心功能和定义。

  • ${ProjDirPath}\chip_headers\CMSIS\Device\ST\STM32F4xx\Include

    • ${ProjDirPath}:如前所述,此宏代表您当前项目的根目录。

    • \chip_headers\CMSIS\Device\ST\STM32F4xx\Include:这指定了相对于项目根目录的另一个路径。它指向位于CMSIS文件夹中的chip_headers文件夹内的Device目录下的STM32F4xx子目录中的Include文件夹。这个文件夹包含 STM32F4xx 系列微控制器的设备特定包含文件,提供针对这个微控制器系列的特定定义和配置。

在关闭项目属性对话框之前,我们需要指定我们正在使用的 STM32F4 微控制器的确切版本。这确保了在我们的项目中启用了针对我们特定微控制器的适当头文件。正如我们所见,STM32F4xx\Include子文件夹包含 STM32F4 系列中各种微控制器的头文件。NUCLEO-F411 开发板配备了 STM32F411 微控制器。

要为 STM32F411 微控制器配置我们的项目:

  1. 点击# 符号选项卡。

  2. 点击添加…以添加一个新的符号。

  3. 名称字段中输入STM32F411xE,然后点击确定

  4. 点击应用并关闭以保存所有更改并关闭项目属性窗口。

图 6**.3展示了STM32F411xE符号。

图 6.3:项目属性窗口中的符号选项卡

图 6.3:项目属性窗口中的符号选项卡

要测试我们的设置,请按照以下步骤操作:

  1. 从我们之前使用基于结构访问方法的项目中复制main.c文件的整个内容。

  2. 打开当前项目中main.c文件。

  3. 删除main.c文件中的所有现有内容。

  4. 将复制的内

  5. main.c文件中,删除所有与手动定义的地址和结构相关的代码,因为我们现在将使用在头文件中提供的寄存器定义。

  6. 在你的项目中包含stm32f4xx.h头文件以访问这些定义。

  7. 在 IDE 中构建项目并在开发板上运行它。

下面的代码片段显示了更新的main.c文件:

//1: Include the stm32f4 header file
#include "stm32f4xx.h"
//2: Bit mask for enabling GPIOA (bit 0)
#define GPIOAEN       (1U<<0)
//3: Bit mask for GPIOA pin 5
#define PIN5          (1U<<5)
//4: Alias for PIN5 representing LED pin
#define LED_PIN       PIN5
int main(void)
{
     //  5: Enable clock access to GPIOA
     RCC->AHB1ENR |=  GPIOAEN;
     //  6: Set PA5 to output mode
     GPIOA->MODER |= (1U<<10);
     GPIOA->MODER &= ~(1U<<11);
    while(1)
    {
        //  7: Set PA5(LED_PIN) high
        GPIOA->ODR^= LED_PIN;
        // 8: Simple delay
        for(int i=0;i<100000;i++){}
    }
}

你会观察到这个项目在构建时没有任何错误,并且与我们的上一个项目以相同的方式工作。这个实现访问了在chip_headers/CMSIS/Device/ST/STM32F4xx/Include/stm32f411xe.h中定义的微控制器寄存器。

检查此文件后,你会发现它包含一个针对我们微控制器每个外设的typedef结构,类似于我们之前手动创建的那个。这意味着从现在开始,我们不需要从文档中手动提取基址和寄存器偏移量。相反,我们只需在我们的项目中包含stm32f4xx.h头文件。这个头文件反过来会包含stm32f411xe.h文件,因为我们已经在项目属性窗口的符号选项卡中指定了我们正在使用STM32F411xE微控制器。

设置所需的头文件显著简化了在 STM32 微控制器上配置和使用外设的过程。这种方法使我们能够利用预定义的寄存器地址和位定义,使我们的代码更易于阅读和维护,同时也能减少开发时间。

摘要

在本章中,我们探讨了 CMSIS,这是 Cortex-M 和某些 Cortex-A 处理器的关键框架。本章为我们提供了增强 Arm Cortex-M 项目代码可移植性和效率的基础知识。

我们首先学习了如何使用 C 结构定义硬件寄存器,这是一种提高代码可读性和可维护性的基本技术。这种知识使我们能够理解微控制器制造商提供的 CMSIS 兼容头文件是如何让我们访问寄存器定义的。

接着,我们探讨了 CMSIS 本身,讨论了其组件以及它是如何促进高效软件开发的。我们考察了 CMSIS 的关键优势,如标准化、可移植性和效率,并介绍了其主要组件,包括 CMSIS-Core、CMSIS-Driver、CMSIS-DSP、CMSIS-NN、CMSIS-RTOS、CMSIS-Pack 和 CMSIS-SVD。

然后,我们转向设置来自我们的硅制造商的必要 CMSIS 文件。这个过程涉及下载相关包、组织文件并将它们集成到我们的项目中。

最后,我们通过将我们的先前项目更新为使用 CMSIS 文件而不是我们手动定义的外设结构来测试我们的设置。这个实际应用展示了 CMSIS 如何简化访问微控制器寄存器,使代码更易于阅读、维护和高效。

在下一章中,我们将学习 GPIO 外设。本章将全面介绍如何在嵌入式系统中配置和使用 GPIO 进行输入/输出应用。

第七章:通用输入/输出(GPIO)外设

在本章中,我们将探讨通用输入/输出GPIO)外设,这是微控制器中一个基本组件。这个外设对于与微控制器接口至关重要,对于嵌入式系统开发来说是基本的。

我们将首先探索 GPIO 端口和引脚的组织结构,涵盖这些引脚的通用和替代功能。接下来,我们将检查与 STM32 微控制器中的 GPIO 外设相关的关键寄存器。最后,我们将应用这些知识,利用本章学到的详细寄存器信息开发输入和输出驱动程序。

在本章中,我们将涵盖以下主要主题:

  • 理解 GPIO 外设

  • STM32 GPIO 寄存器

  • 开发输入和输出驱动程序

到本章结束时,你将能够使用 GPIO 外设有效地与微控制器接口,这将使你能够自信地处理各种输入和输出任务。

技术要求

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/Bare-Metal-Embedded-C-Programming

理解 GPIO 外设

由于我们在第二章中介绍了 GPIO 外设,本节将重申关于 GPIO 的关键要点。

微控制器的引脚被分组到端口中。例如,一个微控制器可能有名为GPIOAGPIOBGPIOC的端口。参见第二章中的图 2**.10。每个端口由单个引脚组成,这些引脚通过其端口名称,后跟其引脚编号来引用。以下是一些命名约定的示例:

  • PA1 指的是端口 A,引脚 1

  • PD7 指的是端口 D,引脚 7

这种命名约定有助于识别和配置特定引脚以执行各种功能。

STM32F411xC/E 微控制器系列具有六个端口:PORTAPORTBPORTCPORTDPORTEPORTH。每个端口都配备了一套全面的寄存器,用于管理配置、数据处理和功能。

这些端口提供了一系列旨在提高通用性和性能的功能。提供的功能包括以下内容:

  • I/O 控制:它们允许我们管理每个端口的最多 16 个输入/输出引脚。

  • 输出状态:引脚可以配置为推挽或开漏模式,并可选择上拉或下拉电阻。

  • 当引脚配置为通用输出时,GPIOx_ODR寄存器。对于替代功能配置,相关外设驱动输出数据。

  • 速度选择:可以设置每个 I/O 引脚的运行速度。

  • 输入状态:引脚可以配置为浮空、上拉、下拉或模拟输入。

  • 当配置为复用功能输入时,GPIOx_IDR寄存器或相关的外设。

  • GPIOx_LCKR寄存器可用于锁定 I/O 配置,防止意外更改。

  • 复用功能选择:每个 I/O 引脚可以配置多达 16 个复用功能,提供引脚使用的灵活性。

在下一节中,我们将探讨 STM32F411 微控制器的一些 GPIO 寄存器。

STM32 GPIO 寄存器

在本节中,我们将探讨 GPIO 外设中一些常见寄存器的特性和功能。

每个 GPIO 端口都包含一组 32 位寄存器,这些寄存器对于配置和控制至关重要。配置寄存器包括以下内容:

  • GPIOx_MODER(模式寄存器)

  • GPIOx_OTYPER(输出类型寄存器)

  • GPIOx_OSPEEDR(输出速度寄存器)

  • GPIOx_PUPDR(上拉/下拉寄存器)

数据寄存器包括以下内容:

  • GPIOx_IDR(输入数据寄存器)

  • GPIOx_ODR(输出数据寄存器)

GPIOx_BSRR(位设置/重置寄存器)和GPIOx_LCKR(锁定寄存器)用于控制引脚状态和访问。此外,复用功能选择寄存器GPIOx_AFRHGPIOx_AFRL管理 GPIO 端口内引脚的复用功能分配。

让我们从 GPIO 模式寄存器开始。

GPIO 模式寄存器(GPIOx_MODER)

GPIO 端口模式寄存器(GPIOx_MODER)是配置 GPIO 端口中每个引脚模式的 重要寄存器。此寄存器允许我们设置每个引脚在不同的模式,例如输入输出复用功能模拟

这是一个 32 位寄存器,分为 16 对位。每对位对应 GPIO 端口中的一个特定引脚,允许对每个引脚进行单独配置。参见第二章中的图 2**.17

这些位的可能配置如下:

  • 00:输入模式(复位状态)

    在此模式下,引脚被配置为输入,可用于从外部设备(如按钮)读取信号。

  • 01:通用输出模式

    此模式将引脚配置为输出,可用于驱动外部信号或组件,如 LED。

  • 10:复用功能模式

    此模式将引脚设置为复用功能,允许它与各种外设接口,如 UART、SPI 或 I2C。

  • 11:模拟模式

    此模式将引脚配置为模拟输入,这对于模数转换器ADC)操作很有用。

让我们看看一个实际例子。

考虑配置端口 A 上的一个引脚(例如,PA5):

  1. 要将PA5设置为通用输出(01),我们可以按照以下步骤操作:

    • 定位到对应PA5 (MODER5[1:0])的位对;这些是bit11bit10

    • bit11设置为0,将bit10设置为1

  2. 要将PA5设置为复用功能(10),我们应该将bit11设置为1,将bit10设置为0

  3. 要将PA5设置为模拟输入(11),我们应该将bit11bit10都写入1

这就是关于GPIOx_MODER寄存器需要了解的全部内容。

让我们继续考察另外两个重要的寄存器:输出数据寄存器(GPIOx_ODR)和输入数据寄存器(GPIOx_IDR)。

GPIO 输出数据寄存器(GPIOx_ODR)和 GPIO 输入数据寄存器(GPIOx_IDR)

GPIO 输出数据寄存器(GPIOx_ODR)和 GPIO 输入数据寄存器(GPIOx_IDR)对于通过 GPIO 引脚管理数据流至关重要。这些寄存器允许读取引脚的状态并设置引脚的状态,使我们的微控制器能够有效地与外部设备交互。

GPIOx_ODR是一个 32 位寄存器,但仅使用低 16 位来控制引脚的输出状态。寄存器中的每个位都对应 GPIO 端口中的一个引脚。

通过写入此寄存器,我们可以设置配置为输出的每个引脚的逻辑电平(高或低)。图 7.1显示了从参考手册中提取的 GPIO 输出数据寄存器ODR)的结构。

图 7.1:GPIO ODR

图 7.1:GPIO ODR

以下是一些示例:

  • 写入bit5(ODR5)PA5设置为高电平状态

  • 写入bit5(ODR5)PA5设置为低电平状态

那么 GPIO 输入数据寄存器呢?

GPIO 输入数据寄存器(GPIOx_IDR)用于读取配置为输入的 GPIO 引脚的当前状态。通过从这个寄存器读取,我们可以确定每个输入是处于高逻辑电平还是低逻辑电平。

这是一个 32 位寄存器,但与 ODR 类似,仅使用低 16 位来读取引脚的状态。寄存器中的每个位都对应 GPIO 端口中的一个引脚。

1的位值表示相应的引脚处于高逻辑电平,而0的位值表示它处于低逻辑电平。

图 7.2显示了 GPIO 输入数据寄存器的结构:

图 7.2:GPIO 输入数据寄存器

图 7.2:GPIO 输入数据寄存器

以下是一些示例:

  • 如果bit5(IDR5)读取为1,则PA5处于高电平状态

  • 如果bit5(IDR5)读取为0,则PA5处于低电平状态

另一个常用的寄存器是 GPIO 位设置/重置寄存器(GPIOx_BSRR)。让我们在下一节中考察这个寄存器。

GPIO 位设置/重置寄存器(GPIOx_BSRR)

GPIO 位设置/重置寄存器(GPIOx_BSRR)是控制 GPIO 引脚状态的关键寄存器。它提供原子位操作来设置或重置单个位,这确保了没有中断可以干扰操作,在修改过程中保持数据完整性。

GPIOx_BSRR是一个 32 位寄存器,分为两个 16 位部分:

  • 位 15:0 (BSy):这些位用于设置相应的 GPIO 引脚

  • 位 31:16 (BRy):这些位用于重置相应的 GPIO 引脚

图 7.3显示了 GPIO 位设置/重置 寄存器BSRR)的结构。

图 7.3:BSRR

图 7.3:BSRR

让我们分析寄存器中的位:

  • 位 31:16(BRy):将任何高 16 位写入1将相应的引脚重置为低电平

  • 位 15:0(BSy):将任何低 16 位写入1将相应的引脚设置为高电平

例如,让我们看看如何使用 BSRR 将 PA5 设置为高电平和低电平:

  • 将 PA5 设置为GPIOA_BSRR寄存器

  • 将 PA5 设置为GPIOA_BSRR寄存器

GPIO BSRR(GPIOx_BSRR)提供了一个强大的机制来控制 GPIO 引脚的状态。通过理解其结构和功能,我们可以执行高效且原子的操作来设置或重置单个引脚。

另一对常用的 GPIO 寄存器是 GPIO 复用功能高和复用功能低寄存器。让我们在下一节中探讨它们。

GPIO 复用功能寄存器(GPIOx_AFRL 和 GPIOx_AFRH)

GPIO 复用功能寄存器AFRs)对于配置 STM32 微控制器中 GPIO 引脚的复用功能非常重要。这些寄存器允许每个引脚被分配一个特定的外围功能,增强了微控制器的多功能性和功能性。

每个 GPIO 端口有两个 AFR:

  • GPIOx_AFRL复用功能低寄存器AFRL),用于 0 到 7 引脚

  • GPIOx_AFRH复用功能高寄存器AFRH),用于 8 到 15 引脚

这些寄存器允许为每个引脚选择复用功能,便于使用引脚进行各种外围接口,如 UART、I2C 和 SPI。

GPIOx_AFRL是一个 32 位寄存器,分为八个 4 位字段。每个 4 位字段对应 GPIO 端口中 0 到 7 范围内的一个引脚。

GPIOx_AFRH也是一个 32 位寄存器,分为八个 4 位字段。在这里,每个 4 位字段对应 GPIO 端口中 8 到 15 范围内的一个引脚。

图 7.4:复用功能低寄存器

图 7.4:复用功能低寄存器

图 7.4说明了 AFRL。为了了解每个引脚可以基于相应的 4 位字段值假设的各种复用功能,我们将参考图 7.5作为我们的指南:

图 7.5:复用功能选择

图 7.5:复用功能选择

图 7.5显示了包含两个多路复用器的图。第一个多路复用器代表 AFRL 的选择器,而第二个代表 AFRH 的选择器。此图来源于参考手册的第 150 页。它有效地展示了如何使用这些寄存器在 STM32F411 微控制器上配置 GPIO 引脚的复用功能。

让我们分解我们看到的内容。

对于GPIOx_AFRLGPIOx_AFRH,图提供了每个引脚可以选定的可能复用功能的列表。复用功能由AF0AF15指定,每个都与特定的外围功能相关,如下所示:

备用功能描述 二进制值
AF0: 系统功能 0000
AF1: TIM1/TIM2 0001
AF2: TIM3/TIM4/TIM5 0010
AF3: TIM9/TIM10/TIM11 0011
AF4: I2C1/I2C2/I2C3 0100
AF5: SPI1/SPI2/SPI3/SPI4 0101
AF6: SPI3/SPI4/SPI5 0110
AF7: USART1/USART2 0111
AF8: USART6 1000
AF9: I2C2/I2C3 1001
AF10: OTG_FS 1010
AF11: 保留 1011
AF12: SDIO 1100
AF13: 保留 1101
AF14: 保留 1110
AF15: EVENTOUT 1111

表 7.1:备用功能选择

例如,要配置引脚 5(GPIOA_AFRL寄存器。我们使用GPIOA_AFRL,因为 PA5 位于 0 到 7 引脚的范围内,这些引脚由该寄存器管理。要配置引脚 10(GPIOA_AFRH寄存器。这是因为GPIOA_AFRH寄存器。

在本节中,我们探讨了 STM32 GPIO 外设中几个基本寄存器的特性和功能。我们从配置每个 GPIO 引脚模式的 GPIO 模式寄存器(GPIOx_MODER)开始,允许设置输入、输出、备用功能或模拟模式。然后我们检查了 GPIO 输出数据寄存器(GPIOx_ODR)和输入数据寄存器(GPIOx_IDR),它们通过设置和读取引脚状态来管理通过 GPIO 引脚的数据流。接下来,我们看了 GPIO BSRR(GPIOx_BSRR),它提供了设置和重置单个引脚状态的原子操作。最后,我们涵盖了 GPIO 备用功能寄存器(GPIOx_AFRLGPIOx_AFRH),它们将特定的外设功能分配给每个引脚,增强了微控制器的多功能性。

在下一节中,我们将利用本节获得的知识开发 GPIO 驱动程序。具体来说,我们将专注于创建输入和输出驱动程序。我们将探讨在通信外设章节中关于备用功能寄存器的实际用法。

开发输入和输出驱动

在本节中,我们将应用关于 GPIO 外设的知识来开发 STM32 微控制器的实际输入和输出驱动程序。由于我们已经熟悉使用 ODR 来切换 LED 的输出驱动程序开发,本节将重点开发使用 BSRR 的输出驱动程序。

使用 BSRR 的 GPIO 输出驱动程序

让我们从在我们的 IDE 中复制我们上一个项目开始:

  1. 在最后一个项目上右键单击并选择复制

  2. 项目资源管理器窗格中右键单击并选择粘贴

  3. 将复制的项目重命名为 GpioInput-Output

接下来,我们将通过创建专门的文件来模块化我们的代码,用于 GPIO 驱动代码:

  1. 在项目的Src文件夹上右键单击并选择新建 | 文件

  2. gpio.c

然后,我们将创建相应的头文件:

  1. 在项目的Inc文件夹上右键单击并选择新建 | 文件

  2. gpio.h

我们接下来的任务是实现gpio.c文件中的驱动程序代码并在gpio.h文件中声明公共函数。

在你的 gpio.c 文件中填充以下代码:

#include "gpio.h"
#define GPIOAEN            (1U<<0)
#define LED_BS5            (1U<<5)  /*Bit Set Pin 5*/
#define LED_BR5            (1U<<21) /*Bit Reset Pin 5*/
void led_init(void)
{
    /*Enable clock access to GPIOA*/
    RCC->AHB1ENR |= GPIOAEN;
    /*Set PA5 mode to output mode*/
    GPIOA->MODER |=(1U<<10);
    GPIOA->MODER &=~(1U<<11);
}
void led_on(void)
{
    /*Set PA5 high*/
    GPIOA->BSRR |=LED_BS5;
}
void led_off(void)
{
    /*Set PA5 high*/
    GPIOA->BSRR |=LED_BR5;
}

让我们分解 gpio.c 文件中的独特元素,重点关注 BSRR 的使用。我们从头文件包含开始:

#include "gpio.h"

这一行包含 gpio.h 头文件,该文件反过来包含 stm32fxx.h 以提供对寄存器定义的访问。

接下来,我们定义我们需要的所有宏:

#define GPIOAEN         (1U<<0)
#define LED_BS5         (1U<<5)  /* Bit Set Pin 5 */
#define LED_BR5         (1U<<21) /* Bit Reset Pin 5 */

让我们分解这些宏:

  • GPIOAEN:此宏用于通过写入 AHB1ENR 寄存器来启用 GPIOA 的时钟。

  • LED_BS5:此宏代表对引脚 PA5 的位设置操作。将此值写入 BSRR 将 PA5 设置为高电平,打开 LED。

  • LED_BR5:此宏代表对引脚 PA5 的位重置操作。将此值写入 BSRR 将 PA5 重置为低电平,关闭 LED。

接下来,我们实现打开 LED 的函数:

void led_on(void)
{
    /* Set PA5 high */
    GPIOA->BSRR |= LED_BS5;
}

GPIOA->BSRR |= LED_BS5 这一行使用 BSRR 将 PA5 设置为高电平。将 1 写入 BSRR 的第 5 位将相应的引脚(PA5)设置为高电平,打开 LED。

然后我们实现关闭 LED 的函数:

void led_off(void)
{
    /* Set PA5 low */
    GPIOA->BSRR |= LED_BR5;
}

类似地,GPIOA->BSRR |= LED_BR5 使用 BSRR 将 PA5 重置为低电平。将 1 写入 BSRR 的第 21 位将相应的引脚(PA5)重置为低电平,关闭 LED。

这是 gpio.h 文件的内容:

#ifndef GPIO_H_
#define GPIO_H_
#include "stm32f4xx.h"
void led_init(void);
void led_on(void);
void led_off(void);
#endif /* GPIO_H_ */

让我们从头文件守卫开始分解:

#ifndef GPIO_H_
#define GPIO_H_
...
#endif /* GPIO_H_ */

头文件守卫防止重复包含相同的头文件,这可能导致错误和冗余声明。#ifndef GPIO_H_ 指令检查 GPIO_H_ 是否已被定义。如果没有,它将定义 GPIO_H_ 并包含文件的其余部分。#endif 指令在末尾关闭了从 #ifndef 开始的条件指令。

接下来,我们有 include 指令:

#include "stm32f4xx.h"

此指令包含 stm32f4xx.h 头文件,该文件反过来包含 stm32f411xe.h 头文件,它为我们微控制器中的所有寄存器提供定义和声明。

然后我们有函数声明:

void led_init(void);
void led_on(void);
void led_off(void);

这些声明允许我们从其他文件(如 main.c)访问在 gpio.c 文件中定义的函数。

现在我们已经完成了 PA5 的 GPIO 输出驱动程序,让我们通过更新 main.c 文件来调用 gpio.c 文件中定义的函数来测试它。以下是更新的 main.c 代码:

#include "gpio.h"
int main(void)
{
    /*Initialize LED*/
    led_init();
    while(1)
    {
            led_on();
            for(int i = 0; i < 100000; i++){}
            led_off();
            for(int i = 0; i < 100000; i++){}
    }
}

代码首先通过包含 gpio.h 头文件来访问在 gpio.c 中定义的 GPIO 函数。在 main 函数中,它首先调用 led_init() 来初始化 PA5 为输出引脚。然后,它进入一个无限循环,通过分别调用 led_on()led_off() 来交替打开和关闭 LED。我们在这些调用之间使用简单的延时循环,以使 LED 在可见的时间内打开和关闭,从而有效地使 LED 连续闪烁。

继续构建项目并在开发板上运行它。你应该看到绿色 LED 在闪烁。

在下一节中,我们将开发使用PC13的 GPIO 输入驱动程序。我们使用PC13是因为开发板的蓝色按钮连接到这个引脚。

GPIO 输入驱动程序

让我们从分析初始化函数开始。将此函数添加到gpio.c文件中:

#define GPIOAEN            (1U<<0)
#define GPIOCEN             (1U<<2)
#define BTN_PIN             (1U<<13)
void button_init(void)
{
    /*Enable clock access to PORTC*/
    RCC->AHB1ENR |=GPIOCEN;
    /*Set PC13 as an input pin*/
    GPIOC->MODER &=~(1U<<26);
    GPIOC->MODER &=~(1U<<27);
}

此函数启用对 GPIO 端口 C 的时钟访问,并将引脚 PC13 配置为输入引脚:

  • GPIOC->MODER: 这是端口 C 的 GPIO 端口模式寄存器。此寄存器中的每一对位对应于特定引脚的模式配置。

  • 清除位 26 和 27:对应于GPIOC->MODER寄存器中引脚 13 的位是位2627

    按位与操作符与按位非操作符(&=~)结合使用,清除这些位,将它们设置为00。正如我们之前所学的,将这些位设置为00将 PC13 配置为输入引脚。

接下来,添加读取引脚状态的函数:

bool get_btn_state(void)
{
    /*Note : BTN is active low*/
    /*Check if button is pressed*/
    if(GPIOC->IDR & BTN_PIN)
    {
        return false;
    }
    else
    {
        return true;
    }
}

此函数读取按钮的状态。按钮内部连接为低电平有效输入。这意味着当按钮按下时,引脚读取低逻辑电平(0),而当按钮未按下时,引脚读取高逻辑电平(1)。

  • GPIOC->IDR: 这是 GPIO 端口 C 的输入数据寄存器。它保存端口中所有引脚的当前状态。

  • 按位与操作符(&): 这检查 IDR 寄存器中对应于BTN_PIN的位是否设置为高。

  • false: 如果位被设置,则按钮未按下

  • true: 如果位未设置,则按钮被按下

为了能够从其他文件,如main.c,访问这些新函数,我们需要将它们的原型添加到gpio.h文件中。

将以下行添加到gpio.h中:

#include <stdbool.h>
void button_init(void);
bool get_btn_state(void);

现在,让我们通过更新main.c文件以调用它们来测试新函数。

以下是更新后的main.c代码:

#include "gpio.h"
bool btn_state;
int main(void)
{
    /*Initialize LED*/
    led_init();
    /*Initialize Pushbutton*/
    button_init();
    while(1)
    {
        /*Get Pushbutton State*/
        btn_state = get_btn_state();
        if(btn_state)
        {
            led_on();
        }
        else
        {
            led_off();
        }
    }
}

让我们分解一下:

  • bool btn_state: 此变量保存按钮的状态

  • led_init(): 将 PA5 配置为输出引脚

  • button_init(): 将 PC13 配置为输入引脚

  • while(1){…}:

    • 使用get_btn_state()持续读取按钮的状态

    • 如果按钮被按下(btn_state为 true),则打开 LED

    • 如果按钮未按下(btn_state为 false),则关闭 LED

构建项目并在开发板上运行它。你应该只在你按下按钮时看到绿色 LED 亮起。

概述

在本章中,我们探讨了 GPIO 外设,这是微控制器中一个关键的外设,对于与各种外部组件进行接口至关重要。我们首先了解了 GPIO 端口和引脚的组织结构,涵盖了通用功能和备用功能。

STM32F411 微控制器系列具有多个端口,每个端口都配备了用于管理配置、数据处理和功能的寄存器。

我们介绍了与 GPIO 外设相关的寄存器,包括配置寄存器,如GPIOx_MODERGPIOx_OTYPERGPIOx_OSPEEDRGPIOx_PUPDR,以及数据寄存器,如GPIOx_IDRGPIOx_ODR。我们还涵盖了GPIOx_BSRR(位设置/重置寄存器)用于原子引脚状态控制和GPIOx_LCKR(锁定寄存器)用于防止意外配置更改。此外,我们还探讨了 GPIO 复用功能寄存器(GPIOx_AFRLGPIOx_AFRH),通过分配特定的外设功能,这些寄存器能够实现引脚的灵活使用。

在实际应用中,我们开发了输出和输入驱动程序。我们首先使用GPIOx_BSRR寄存器创建了一个输出驱动程序来控制连接到 PA5 引脚的 LED。这包括设置必要的宏、实现初始化和控制函数,并通过使 LED 闪烁来测试驱动程序。然后,我们为连接到 PC13 引脚的按钮开发了一个输入驱动程序来读取其状态。这包括将 PC13 配置为输入引脚、实现读取按钮状态的函数,并通过使 LED 对按钮按下做出响应来测试驱动程序。

在下一章中,我们将探讨另一个重要的外设:系统滴答SysTick)定时器。

第八章:系统滴答(SysTick)定时器

在本章中,我们将学习关于系统滴答(SysTick)定时器的内容,这是所有 Arm Cortex 微控制器的一个重要核心外设。我们将从介绍 SysTick 定时器及其最常见用途开始。随后,我们将详细探讨 SysTick 定时器的寄存器。最后,我们将开发一个 SysTick 定时器的驱动程序。

本章我们将涵盖以下主要主题:

  • SysTick 定时器简介

  • 开发 SysTick 定时器驱动程序

到本章结束时,你将很好地理解 SysTick 定时器,并能够有效地在 Arm Cortex-M 项目中实现和利用它。

技术要求

本章的所有代码示例都可以在 GitHub 上找到,地址为

github.com/PacktPublishing/Bare-Metal-Embedded-C-Programming

SysTick 定时器简介

系统滴答(SysTick)定时器,通常称为 SysTick,是所有 Arm Cortex 微控制器的核心组件。无论处理器核心是 Cortex-M0、Cortex-M1 还是 Cortex-M7,以及硅制造商是 STMicroelectronics、Texas Instruments 还是其他任何公司,每个 Arm Cortex 微控制器都包含一个 SysTick 定时器。在本节中,我们将了解这个基本的外设并详细探讨其寄存器。

SysTick 定时器概述

SysTick 定时器是所有 Arm Cortex-M 处理器的24 位向下计数器。它被设计为提供可配置的时间基准,可用于各种目的,如任务调度系统监控时间跟踪。这个定时器为我们提供了一种简单高效的方法来生成周期性中断,并作为实现系统定时功能的基础,包括为实时操作系统(RTOS)生成操作系统(OS)滴答。使用 SysTick 使得我们的代码更具可移植性,因为它属于核心,而不是供应商特定的外设。

SysTick 定时器的关键特性包括以下内容:

  • 24 位可重载计数器:计数器从指定值递减到零,然后自动重载以提供连续的定时操作

  • 核心集成:作为核心的一部分,它需要最小的配置并提供低延迟的中断处理

  • 可配置的时钟源:SysTick 可以从核心时钟或外部参考时钟运行,提供在定时精度和功耗方面的灵活性

  • 中断生成:当计数器达到零时,它可以触发一个中断

SysTick 定时器通常有三个主要用途:

  • 操作系统滴答生成:在实时操作系统(RTOS)环境中,SysTick 通常用于生成系统滴答中断,驱动操作系统调度器

  • 周期性任务执行:它可以用来触发定期任务,如传感器采样或通信检查

  • 时间延迟函数:SysTick 可以在固件中提供精确的延迟,用于各种定时功能

现在,让我们探索 SysTick 定时器中的寄存器。

SysTick 定时器寄存器

SysTick 定时器由四个主要寄存器组成:

  • SysTick 控制和状态寄存器(SYST_CSR

  • SysTick 重载值寄存器(SYST_RVR

  • SysTick 当前值寄存器(SYST_CVR

  • SysTick 校准值寄存器(SYST_CALIB

让我们逐一分析它们,从控制和状态寄存器开始。

SysTick 控制和状态寄存器(SYST_CSR)

SYST_CSR 寄存器控制 SysTick 定时器的操作并提供状态信息。它具有以下位:

  • ENABLE(位 0):启用或禁用 SysTick 计数器

  • TICKINT(位 1):启用或禁用 SysTick 中断

  • CLKSOURCE(位 2):选择时钟源(0 = 外部参考时钟,1 = 处理器时钟)

  • COUNTFLAG(位 16):指示自上次读取以来计数器是否已达到零(1 = 是,0 = 否)

这是 SysTick 控制和状态寄存器的结构:

图 8.1:SysTick 控制和状态寄存器

图 8.1:SysTick 控制和状态寄存器

下一个寄存器是 SysTick 重载值寄存器(SYST_RVR)。

SysTick 重载值寄存器(SYST_RVR)

此寄存器指定要加载到 SysTick 当前值寄存器的起始值。这对于设置定时器的周期至关重要,并且理解其位分配和计算对于有效的 SysTick 配置是必不可少的。

它具有以下字段:

  • 位 [31:24] 保留:这些位是保留的

  • 当计数器启用且计数器达到零时,SYST_CVR 寄存器

这是 SysTick 重载值寄存器的结构:

图 8.2:SysTick 重载值寄存器

图 8.2:SysTick 重载值寄存器

由于 SysTick 是一个 24 位定时器,RELOAD 值可以是 0x000000010x00FFFFFF 范围内的任何值。

要根据所需的定时器周期计算 RELOAD 值,我们确定所需周期的时钟周期数,然后从该数字中减去 1 以获得 RELOAD 值。

例如,如果 RELOAD 值计算如下:

  1. 计算 1 毫秒内的时钟周期数:

    时钟周期 = 16,000,000 个周期/秒 * 0.001 秒 = 16,000 个周期

    注意:1ms = 0.001 秒

  2. 从计算出的时钟周期数中减去 1:

    RELOAD = 16,000 - 1 = 15,999,因为从 0 到 15,999 的计数将给我们 16000 个滴答。

意味着,为了配置 SysTick 定时器以 1 ms 的时间周期和 16 MHz 的时钟,我们将 RELOAD 值设置为 SYST_CVR)。

SysTick 当前值寄存器

SysTick 当前值寄存器(SYST_CVR)保存 SysTick 计数器的当前值。我们可以使用此寄存器来监控倒计时过程,并在必要时重置计数器。

它具有以下字段:

  • 位[31:24]保留:这些位是保留位,不应修改。它们必须写为零。

  • COUNTFLAG位在 SysTick 控制和状态寄存器(SYST_CSR)中。这是 SysTick 当前值寄存器:

图 8.3:SysTick 当前值寄存器

图 8.3:SysTick 当前值寄存器

SysTick 校准值寄存器

SysTick 定时器的最后一个寄存器是 SysTick 校准值寄存器(SYST_CALIB)。此寄存器为我们提供了 SysTick 定时器的校准属性。

这些寄存器的名称在 STM32 头文件中略有不同。表 8.1提供了在Arm 通用用户指南文档中使用的寄存器名称与 STM32 特定头文件中使用的寄存器名称之间的清晰对应关系。这种对应关系将帮助我们正确理解和在我们的代码中引用它们。

功能 Arm 通用 用户指南 STM32 头文件
控制和状态 SYST_CSR SysTick->CTRL
重载值 SYST_RVR SysTick->LOAD
当前值 SYST_CVR SysTick->VAL
校准值 SYST_CALIB SysTick->CALIB

表 8.1:SysTick 寄存器名称对应关系

在下一节中,我们将使用我们所学到的知识开发 SysTick 定时器的驱动程序。

开发 SysTick 定时器的驱动程序

在本节中,我们将开发一个用于生成精确延迟的 SysTick 定时器驱动程序。

首先,在我们的集成开发环境(IDE)中按照我们在第七章中学到的步骤,复制我们上一个项目。将复制的项目重命名为SysTick。接下来,在Src文件夹中创建一个名为sttyck.c的新文件,在Inc文件夹中创建一个名为sttyck.h的新文件,就像我们在上一课中为 GPIO 驱动器所做的那样。

使用以下代码填充你的systick.c文件:

#include "systick.h"
#define CTRL_ENABLE        (1U<<0)
#define CTRL_CLCKSRC    (1U<<2)
#define CTRL_COUNTFLAG    (1U<<16)
/*By default, the frequency of the MCU is 16Mhz*/
#define ONE_MSEC_LOAD     16000
void systick_msec_delay(uint32_t delay)
{
    /*Load number of clock cycles per millisecond*/
    SysTick->LOAD =  ONE_MSEC_LOAD - 1;
    /*Clear systick current value register*/
    SysTick->VAL = 0;
    /*Select internal clock source*/
    SysTick->CTRL = CTRL_CLCKSRC;
    /*Enable systick*/
    SysTick->CTRL |=CTRL_ENABLE;
    for(int i = 0; i < delay; i++)
    {
        while((SysTick->CTRL & CTRL_COUNTFLAG) == 0){}
    }
    /*Disable systick*/
    SysTick->CTRL = 0;
}

让我们将其分解。

我们从包含头文件开始:

#include "systick.h"

这行代码包含了头文件systick.h,它反过来包含stm32fxx.h以提供对寄存器定义的访问。

接下来,我们定义所有需要的宏:

  • #define CTRL_ENABLE (1U << 0): 宏定义用于启用 SysTick 定时器。

  • #define CTRL_CLKSRC (1U << 2): 宏定义选择 SysTick 定时器的内部时钟源。

  • #define CTRL_COUNTFLAG (1U << 16): 宏定义用于检查COUNTFLAG位,该位指示定时器已计数到零。

  • #define ONE_MSEC_LOAD 16000: 宏定义 1 毫秒中的时钟周期数。这假设微控制器的时钟频率为 16 MHz。这是 NUCLEO-F411 开发板的默认配置。

接下来,我们进入函数实现。

首先,我们有以下内容:

SysTick->LOAD = ONE_MSEC_LOAD - 1;

这行代码将 SysTick 定时器加载为 1 毫秒的时钟周期数。

然后,我们使用以下方法清除当前值寄存器以重置定时器:

SysTick->VAL = 0;

接下来,我们选择内部时钟源:

SysTick->CTRL = CTRL_CLKSRC;

要启用 SysTick 定时器,我们使用以下方法:

SysTick->CTRL |= CTRL_ENABLE;

现在,我们进入处理延迟的循环:

for (int i = 0; i < delay; i++)
{
    while ((SysTick->CTRL & CTRL_COUNTFLAG) == 0) {}
}

此循环运行指定的延迟时间。在每个迭代中,它等待 COUNTFLAG 位被设置,这表示定时器已计数到零。

最后,我们禁用 SysTick 定时器:

SysTick->CTRL = 0;

就这样!通过这些步骤,我们已成功使用 SysTick 定时器实现了延迟函数。

我们接下来的任务是填充 systick.h 文件。

这里是代码:

#ifndef SYSTICK_H_
#define SYSTICK_H_
#include <stdint.h>
#include "stm32f4xx.h"
void systick_msec_delay(uint32_t delay);
#endif

在这里,需要 #include <stdint.h> 指令以确保我们可以访问由 C 标准库提供的标准整数类型定义。这些定义包括固定宽度整数类型,如 uint32_tint32_tuint16_t 等,这对于编写可移植和清晰的代码至关重要,尤其是在嵌入式系统编程中。

驱动文件完成后,我们现在可以在 main.c 中进行测试。

首先,让我们通过添加一个新的切换 LED 的函数来增强我们的 gpio.c 文件。这将通过允许我们通过单个函数调用切换 LED 而不是分别调用 led_on()led_off() 来简化我们的代码。

将以下函数添加到你的 gpio.c 文件中:

#define LED_PIN            (1U<<5)
void led_toggle(void)
{
    /*Toggle PA5*/
    GPIOA->ODR ^=LED_PIN;
}

此函数通过在 输出数据寄存器ODR)上执行位异或操作来切换连接到 PA5 引脚的 LED 的状态。

接下来,在 gpio.h 文件中声明此函数,添加以下行:

void led_toggle(void)

最后,按照以下示例更新你的 main.c 文件以调用 SysTick 延迟和 LED 切换函数:

#include "gpio.h"
#include "systick.h"
int main(void)
{
    /*Initialize LED*/
    led_init();
    while(1){
        /*Delay for 500ms*/
        systick_msec_delay(500);
           /* Toggle the LED */
        led_toggle();
    }
}

在本例中,我们以 500ms 的时间间隔 切换 LED。构建项目并在你的开发板上运行它。你应该看到绿色 LED 闪烁。为了进一步实验,你可以修改延迟值并观察 LED 闪烁速率的变化。

摘要

在本章中,我们探讨了 SysTick 定时器,这是所有 Arm Cortex 微控制器的核心外设。我们首先介绍了 SysTick 定时器,讨论了其重要性和常见应用,例如在实时操作系统中生成 OS 拓扑,执行周期性任务,以及提供精确的时间延迟。

我们随后详细检查了 SysTick 定时器的寄存器。这些包括控制状态寄存器(SYST_CSR),它管理定时器的操作和状态;重载值寄存器(SYST_RVR),它设置定时器的倒计时周期;当前值寄存器(SYST_CVR),它持有倒计时的当前值;以及校准值寄存器(SYST_CALIB),它提供了精确计时所需的基本校准属性。我们还提供了 Arm 通用用户指南中使用的寄存器名称与 STM32 头文件中使用的寄存器名称之间的比较,以确保准确编码的清晰对应。

本章以开发一个 SysTick 定时器驱动程序结束。我们回顾了systick_msec_delay函数的创建和实现过程,该函数通过 SysTick 定时器引入毫秒级延迟。为了测试该驱动程序,我们将其与 GPIO 功能集成,以切换我们的绿色 LED,展示了如何在嵌入式系统中实现精确的时序和控制。

在下一章中,我们将学习另一个定时器外设。与 SysTick 定时器不同,这个定时器外设的配置是针对 STM32 微控制器特定的。

第九章:通用定时器(TIM)

在本章中,我们将深入探讨 STM32F411 微控制器中发现的通用定时器TIM)。与 SysTick 定时器不同,这些定时器外设是 STM32 微控制器独有的,并提供了各种应用所需的基本功能。

我们将首先讨论定时器的常见用途,为理解它们在嵌入式系统中的重要性打下基础。随后,我们将探讨 STM32 微控制器上定时器的特定特性。这包括理解定时器时钟源、预分频的机制以及详细查看常用的定时器寄存器。

在本章的后半部分,我们将通过开发定时器驱动程序并将所学知识应用于创建功能性和高效的代码来将理论付诸实践。

在本章中,我们将涵盖以下主要内容:

  • 定时器和它们的应用简介

  • 定时器的常见用例

  • STM32 定时器

  • 定时器驱动程序的开发

到本章结束时,您将很好地理解 STM32 定时器及其驱动程序的开发,这将增强您在项目中有效利用这些外设的能力。

技术要求

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/Bare-Metal-Embedded-C-Programming

定时器和它们的应用简介

定时器是嵌入式系统中的关键组件,在广泛的领域中发挥着基本功能。在本节中,我们将探讨定时器的概念、类型及其各种应用。

问题是,定时器是什么

定时器是微控制器中发现的硬件外设,用于计数时钟脉冲这些脉冲可以用来测量时间间隔、生成精确的延迟或在特定间隔触发事件。定时器可以以多种模式运行,包括向上计数向下计数和生成脉冲宽度调制PWM)信号。

在 STM32 微控制器上使用 SysTick 定时器与通用定时器之间的选择取决于您对定时需求的复杂性和精度。虽然 SysTick 定时器非常适合简单的系统定时任务,例如生成周期性中断或创建延迟,但它灵活性有限,功能也有限,因为它通常被用于实时操作系统RTOS)的滴答。另一方面,正如我们刚才讨论的,通用定时器提供了更多的多功能性,能够处理如 PWM 生成、输入捕获和输出比较等复杂任务。它们还支持多个通道以并发处理定时事件,并提供如精确频率测量和低功耗操作等高级功能。

让我们通过一些典型用例来探讨定时器的重要性。

定时器的常见用例

让我们从计时器开始,用于时间间隔测量。

时间间隔测量

计时器的一个常见用途是在超声波传感器中进行距离测量,广泛应用于机器人、汽车停车系统和障碍物检测。

计时器在这些传感器的操作中起着至关重要的作用。以下是它们的工作原理:

  1. 微控制器向超声波传感器发送触发信号,使其发出超声波脉冲。

  2. 传感器等待脉冲在撞击物体后返回的回声。

  3. 计时器在发出脉冲时开始计数,在接收到回声时停止计数。

  4. 计时器测量的时间间隔用于根据声速计算到物体的距离。

通过精确测量发送脉冲和接收回声之间的时间间隔,系统可以确定到物体的距离,从而实现精确导航和避障。

计时器对于生成精确的延迟也非常重要,例如在发光二极管(LED)矩阵显示屏的刷新机制中。

延迟生成

在嵌入式系统中,LED 矩阵显示屏用于各种应用,包括数字标牌、计分板和简单的图形显示。为了显示图像或文本,微控制器需要以精确的速率刷新显示,以确保平滑的视觉效果。

在这里,发生以下情况:

  • 微控制器驱动一个 LED 矩阵显示屏,需要依次刷新显示行。

  • 计时器生成精确的延迟来控制每一行激活的时间,以便移动到下一行。

    例如,计时器可能被设置为在切换行之间生成 1 毫秒的延迟。

    这确保了每一行都能以一致的时间显示,保持稳定且无闪烁的图像。

现在,让我们看看计时器在嵌入式系统中触发事件的一个示例。

事件触发

从传感器进行周期性数据采样对于环境监测和工业过程控制(IPC)等应用非常重要。计时器可以用来在特定间隔触发模数转换器(ADC),以确保一致的数据采样。

在这里,发生以下情况:

  • 微控制器连接到一个环境传感器(例如,温度或湿度传感器),该传感器输出模拟信号

  • 通用计时器被配置为定期触发 ADC 转换(例如,每 100 毫秒一次)

  • ADC 被设置为使用计时器触发选项,在每次计时器事件自动开始转换。

  • 微控制器的主循环定期检查完成的 ADC 转换并处理数据。

在下一节中,我们将重点关注 STM32 计时器的特性和特点。

STM32 计时器

STM32 微控制器中的计时器分为两大类:通用计时器高级计时器

通用计时器和高级计时器简介

通用定时器非常通用,可用于各种应用,而高级定时器比通用定时器提供更复杂的功能,使其适合高精度和复杂的定时任务。

在 STM32F411 微控制器中,定时器TIM2TIM3TIM4TIM5TIM9TIM10TIM11是通用定时器,而TIM1是高级定时器。

通用定时器的主要特性包括以下内容:

  • 计数器大小:它们具有 TIM3 和 TIM4 的 16 位计数器以及 TIM2 和 TIM5 的 32 位计数器,能够以向上、向下或上/下自动重载模式运行

  • 预分频器:它们配备了一个 16 位可编程预分频器,允许我们将计数器时钟频率除以从 1 到 65,536 的任何因子

  • 通道:它们提供多达四个独立通道

  • 同步:定时器可以与外部信号同步,并与多个定时器互连,增强了在复杂应用中的灵活性和协调性

  • 中断/直接内存访问(DMA)生成:定时器可以根据各种事件生成中断或 DMA 请求,包括计数器溢出/下溢、计数器初始化、触发事件、输入捕获和输出比较

  • 编码器支持:这些定时器还支持增量(正交)编码器和霍尔传感器电路,使其适用于精确定位应用

高级定时器 TIM1 具有 16 位计数器大小。除了计数器大小的差异外,它还具备通用定时器的所有功能,并具有以下附加功能:

  • 互补输出:支持具有可编程死区插入的互补输出

  • 重复计数器:允许在特定的计数周期数之后才更新定时器寄存器

  • 断开输入:它能够在激活时将定时器的输出信号置于复位或已知状态

在我们开始分析参考手册中提供的一些关键寄存器之前,让我们首先了解 STM32 定时器的工作原理。

如何使用 STM32 定时器

定时器的核心是 16 位/32 位计数器和其相关的自动重载寄存器。计数器可以向上或向下计数,其时钟可以被预分频器除以。即使在计数器运行时,我们也可以从计数器、自动重载寄存器和预分频寄存器中读取和写入。

时基单元包括以下寄存器:

  • TIMx_CNT

  • TIMx_PSC

  • TIMx_ARR

预分频寄存器(TIMx_PSC)将定时器的输入时钟频率除以 1 到 65,536 之间的可编程值。这允许以较慢的计数速率来适应更长的定时间隔。

自动重载寄存器(TIMx_ARR)定义了计数器在向上计数模式下重置为零或在下计数模式下重置到自动重载值的值。我们使用它来设置定时器的周期。

除了时基单元中的寄存器外,定时器外设还包括控制寄存器(TIMx_CR1TIMx_CR2等),用于配置各种操作参数,例如启用计数器、设置计数方向以及配置TIMx_SR)表示定时器的状态,例如是否发生了 UEV 以及其他用于控制定时器的辅助寄存器,等等。

如我们之前提到的,定时器有两种计数模式:向上计数模式和向下计数模式。让我们来分解它们:

  • TIMx_ARR)并在溢出时生成 UEV

  • 向下计数模式

    计数器从自动重载值向下计数到 0,并在下溢时生成 UEV

接下来出现的问题是:UEV 究竟是什么?

让我们分解一下:

  • 简而言之,这是超时应该发生的时候

  • 当计数器达到自动重载值或 0(根据计数模式)时,发生 UEV

  • 这个事件可以触发中断或 DMA 请求并设置TIMx_SR

  • UEV 也可以通过设置TIMx_EGR)手动生成

现在我们知道了 UEV 是什么,让我们了解定时器时钟预分频是如何实现的;这将使我们能够准确地计算 UEV 的定时。

定时器时钟预分频

我们使用预分频将高频系统时钟降低到适合驱动定时器的较低频率。图 9**.1 展示了我们的 STM32 微控制器中定时器时钟预分频的过程,显示了系统时钟(SYSCLK)如何通过一系列预分频器分频以驱动定时器计数器(TIMx_CNT):

图 9.1:定时器时钟预分频

图 9.1:定时器时钟预分频

让我们解释这个过程中涉及的每个组件和步骤:

  • 系统时钟(SYSCLK

    这是驱动微控制器的主系统时钟。它作为所有后续操作的初始时钟源。

  • SYSCLK频率以 1、2、4、8、16、64、128、256 或 512 的因子分频。得到的时钟信号被称为HCLK(AHB 时钟)。

  • 高性能总线时钟(HCLK

    这是经过 AHB 预分频器生成的时钟信号。它用于驱动核心和系统总线。

  • 高级外设总线(APB)预分频器

    • APB1 预分频器:这进一步将 HCLK 分频以生成 APB1 外设总线的时钟。可用的分频因子是 1、2、4、8 和 16。

    • APB2 预分频器:类似于 APB1 预分频器,但用于 APB2 外设总线。它也以 1、2、4、8 和 16 的因子分频 HCLK。

  • 定时器预分频器(TIMx_PSC

    每个定时器都有自己的预分频寄存器,可以将 APB1 或 APB2 时钟进一步分频,分频值在 1 到 65,535 之间。这种灵活性允许对定时器的计数速率进行精确控制。

  • TIMx_PSC。这个计数器增加或减少的速率取决于之前预分频器的组合分频效果。

预分频器直接影响定时器计数器(TIMx_CNT)增加或减少的速率。虽然其他预分频器(AHB 和 APB)在相同总线上共享所有外设,但TIMx_PSC预分频器,我们可以精确控制特定定时器的分辨率和周期,而不会影响其他外设。

现在,让我们学习如何计算 UEV。

计算 UEV

当定时器计数器在向上计数模式下达到自动重载寄存器(TIMx_ARR)中设置的值时,就会发生 UEV。计算此事件对于确定定时器的周期并确保其以期望的频率运行至关重要。

我们可以使用以下公式推导 UEV 的频率:

更新事件频率 = TimerClockPrescaler+1X(Period+1)

这里,我们有以下内容:

  • 在使用默认时钟配置(裸机设置)的 NUCLEO-F411 开发板上,SYSCLK

  • TIMx_PSC寄存器。

  • TIMx_ARR寄存器。

让我们看看一个例子。假设我们有以下参数:

  • 定时器时钟(APB1 时钟):16 MHz

  • 预分频器(TIMx_PSC ):15999

  • 周期(TIMx_ARR 值):499

这意味着我们有以下值:

  • 定时器时钟为 16,000,000 Hz

  • TIMx_PSC寄存器设置为 15999,但由于预分频器将时钟除以值加一,我们使用 15999 + 1 = 16000

  • TIMx_ARR寄存器设置为 499,但由于周期计数到值加一,我们使用 499 + 1 = 500

将这些值代入我们的公式,我们得到以下结果:

更新事件频率 =16,000,00015999+1X(499+1)

16,000,0008000000

= 2Hz

这意味着 UEV 以2 Hz 的频率发生,或每秒两次。本节到此结束。在下一节中,我们将开发我们的定时器驱动程序。

开发定时器驱动程序

在本节中,我们将应用关于 TIM 外设的知识来开发一个生成延迟的驱动程序。

首先,在您的 IDE 中创建您之前项目的副本,按照前面章节中概述的步骤进行。将此复制的项目重命名为GTIM。接下来,在Src文件夹中创建一个名为tim.c的新文件,并在Inc文件夹中创建一个名为tim.h的新文件。

我们的目标是开发一个驱动程序,初始化 TIM2 以生成 1 Hz 的超时。在tim.c文件中填充以下代码:

#include "tim.h"
#define TIM2EN        (1U<<0)
#define CR1_CEN        (1U<<0)
void tim2_1hz_init(void)
{
    /*Enable clock access to tim2*/
    RCC->APB1ENR |=TIM2EN;
    /*Set prescaler value*/
    TIM2->PSC =  1600 - 1 ;
    /*Set auto-reload value*/
    TIM2->ARR =  10000 - 1;
    /*Clear counter*/
    TIM2->CNT = 0;
    /*Enable timer*/
    TIM2->CR1 = CR1_CEN;
}

让我们分解一下:

#define TIM2EN    (1U<<0)
#define CR1_CEN   (1U<<0)

TIM2EN实例定义为(1U<<0),这设置了位 0。这用于启用 TIM2 的时钟。

CR1_CEN实例定义为(1U<<0),这也设置了位 0。这用于在 TIM2 控制寄存器 1(CR1)中启用计数器:

    /* Enable clock access to TIM2 */
    RCC->APB1ENR |= TIM2EN;

这行代码通过在 APB1 外设时钟使能寄存器(RCC->APB1ENR)中设置适当的位来启用 TIM2 的时钟:

    /* Set prescaler value */
    TIM2->PSC = 1600 - 1;  // 16,000,000 / 1,600 = 10,000

预分频器值设置为 1599。预分频器将输入时钟频率(16 MHz)除以(1599 + 1),结果为 10,000 Hz(10 kHz)的定时器时钟:

    /* Set auto-reload value */
    TIM2->ARR = 10000 - 1;

自动重载值设置为 9999。这意味着定时器将从 0 计数到 9999,形成一个 10,000 次的周期。由于定时器时钟为 10 kHz,计数 10,000 次将产生 1 秒(10,000 / 10,000 Hz = 1 s):

    /* Clear counter */
    TIM2->CNT = 0;

这行代码将定时器计数器重置为 0。它确保定时器启用时计数从 0 开始:

    /* Enable timer */
    TIM2->CR1 = CR1_CEN;

这行代码通过在 TIM2 控制寄存器 1(CR1)中设置CEN(计数器使能)位来启用定时器。这启动了定时器,并开始根据配置的预分频器和自动重载值进行计数。

总结来说,我们的代码实现了以下功能:

  1. 启用 TIM2 的时钟。

  2. 设置一个预分频器值,将输入时钟除以 10 kHz。

  3. 设置一个自动重载值,使定时器计数到 10,000,形成一个 1 秒的周期。

  4. 清除定时器计数器。

  5. 启用定时器。

我们接下来的任务是填充tim.h文件。

这里是代码:

#include "stm32f4xx.h"
#ifndef TIM_H_
#define TIM_H_
#define SR_UIF  (1U<<0)
void tim2_1hz_init(void);
#endif

以下行定义了一个SR_UIF宏,将第 0 位(最低有效位)设置为 1:

#define SR_UIF  (1U << 0)

此位代表定时器状态寄存器(SR)中的 UIF。当定时器溢出或达到自动重载值时,此标志被设置,表示 UEV。我们将在main.c文件中访问此位。

我们现在已准备好在main.c中进行测试。

按照以下所示更新您的main.c文件:

#include "gpio.h"
#include "tim.h"
int main(void)
{
    /*Initialize LED*/
    led_init();
    /*Initialize timer*/
    tim2_1hz_init();
    while(1)
    {
           led_toggle();
            /*Wait for UIF */
           while(!(TIM2->SR & SR_UIF)){}
           /*Clear UIF*/
           TIM2->SR &=~SR_UIF;
    }
}

在此代码中,我们初始化 LED 和 TIM2,以 1 Hz 的频率切换 LED。代码通过等待定时器的 UIF 被设置,表示已过去 1 秒,然后切换 LED 并清除 UIF 以重复此过程。此循环在主循环中无限期地继续,导致 LED 每秒切换一次。

摘要

在本章中,我们探讨了 STM32F411 微控制器中的通用定时器(TIM),它们与 SysTick 定时器不同,为嵌入式系统应用提供了各种关键功能。我们首先讨论了定时器的基本用途,强调了它们在时间间隔测量、延迟生成和事件触发等任务中的重要性。

我们随后了解了 STM32 定时器的具体细节,详细说明了它们分为通用定时器和高级定时器。

接下来,我们检查了 STM32 计时器的运作原理,重点关注计数器寄存器(TIMx_CNT)、预分频器寄存器(TIMx_PSC)和自动重载寄存器(TIMx_ARR)。我们解释了计时器的时钟预分频机制如何将系统时钟降低到适合计时器的频率,以及这如何影响计时器的操作。

我们还提供了一个计算 UEV 频率的实际示例,展示了如何根据预分频器和自动重载值计算计时器的周期和频率。

最后,我们通过为 TIM2 开发计时器驱动程序来应用理论知识,以生成 1 Hz 的超时。在下一章中,我们将学习另一个有用的外围设备。

第十章:通用异步收发器协议

在本章中,我们将学习通用异步收发器(UART)协议,这是一种在嵌入式系统中广泛使用的通信方法。UART 对于实现微控制器和各种外设之间的通信至关重要,使其成为嵌入式系统开发中的基本组件。

我们将首先讨论通信协议在嵌入式系统中的重要性,并强调 UART 与其他协议(如 SPI 和 I2C)一起的常见用例。在此之后,我们将全面概述 UART 协议,详细说明其操作原理和特性。接下来,我们将从 STM32 参考手册中提取并检查 UART 的相关寄存器,为驱动器开发提供必要的知识基础。最后,我们将应用这些知识来开发裸机 UART 驱动器,展示通过 UART 初始化和传输数据的实际方面。

在本章中,我们将涵盖以下主要内容:

  • 通信协议简介

  • UART 协议概述

  • STM32F4 UART 外设

  • 开发 UART 驱动器

到本章结束时,你将很好地理解 UART 协议以及开发 UART 通信的裸机驱动器所需的技术。

技术要求

本章的所有代码示例都可以在 GitHub 上找到,网址为github.com/PacktPublishing/Bare-Metal-Embedded-C-Programming

通信协议简介

在嵌入式系统领域,通信协议是使微控制器和外设设备能够无缝通信的关键通道。把它们想象成不同设备用来理解和交换信息的语言,确保从你的智能手机到你的智能家居设备都能顺畅工作。

让我们深入了解通信协议是什么,它们是如何分组的,它们的独特特性和优势,并探索一些常见用例来观察这些协议的实际应用。

什么是通信协议?

通信协议是一套规则和约定,允许电子设备相互通信。这些协议定义了数据是如何格式化、传输和接收的,确保设备可以准确可靠地交换信息。没有这些协议,就像试图与说完全不同语言的人交谈一样——通信将会混乱且充满错误。

在嵌入式系统中,这些协议至关重要,因为它们促进了微控制器与传感器、执行器、显示器和其他微控制器等外围设备之间的交互。无论是从传感器向微控制器发送简单的温度读数,还是从摄像头模块流式传输视频数据,通信协议都使其成为可能。

让我们分析通信协议的分类,从宏观的角度开始:可以将通信协议广泛地归类为——串行并行通信。

串行与并行通信

让我们从串行通信开始。

串行通信

在这个类别中,通信协议可以进一步细分为异步和同步协议:

  • 异步:这种类型的通信一次发送一个比特,没有时钟信号来同步发送者和接收者。想象一下像通过邮件发送信件一样,没有预定投递时间。一个常见的例子是 UART,它对于许多应用来说简单且高效。

  • 同步:与异步通信不同,这种通信形式使用时钟信号来协调比特的传输。这就像有一个鼓点来确保每个人都步调一致。例子包括 串行外设接口 (SPI) 和 集成电路间接口 (I2C)。这些协议确保数据完整性和时序,使它们适合更复杂的任务。

并行通信

这种类型涉及在多个通道上同时传输多个比特。想象一下发送一整队汽车而不是一辆——这更快,但需要更多的车道(或引脚,在我们的情况下)。虽然并行通信更快,但由于引脚数量更多,在嵌入式系统中不太常见。此外,它容易受到串扰和信号完整性问题的影响,尤其是在更长的距离上。

我们还可以根据它们的架构对通信协议进行分类。在这个分类体系中,我们有点对点通信和多设备通信。

点对点与多设备通信

让我们看看它们之间的区别。

点对点

这是两个设备之间直接的通信线路。UART 是一个经典的例子,其中数据直接在微控制器和外围设备之间流动。它简单、可靠,非常适合许多嵌入式系统。

多设备(总线)通信

在这里,多个设备共享相同的通信线路,这些线路可以是以下之一:

  • 多主设备:多个设备可以控制通信总线。I2C 是一个很好的例子,因为它允许同一总线上有多个主设备和从设备。这就像一群朋友轮流在对话中交谈。

  • 主从模式:一个主设备控制通信,将流量导向多个从设备。SPI就是这样操作的,一个主设备通过专用线路与多个从设备通信。I2C也可以这样操作。这就像是一位老师(主设备)依次叫学生发言。

最后,通信协议可以根据其数据流能力进行分类。

全双工与半双工对比

让我们看看全双工和半双工之间的区别:

  • 全双工:这允许双向同时通信。想象一下一条双车道道路,汽车可以同时双向行驶。UARTSPI支持全双工通信,这使得它们在实时数据交换中非常高效。

  • 半双工:在这里,通信可以双向进行,但不能同时进行——就像一条单车道道路,汽车必须轮流行驶。I2C通常以半双工模式运行,这对于其预期应用来说效果很好,但在高速数据场景中可能是一个限制。

现在,让我们仔细比较现代嵌入式系统中使用的三种常见通信协议。

比较 UART、SPI 和 I2C

让我们从串行异步接收/发送(UART)开始。

UART

这里是 UART 的一些关键特性:

  • 异步通信:UART 不需要时钟信号。相反,它使用起始位和停止位来同步数据传输。

  • 全双工:UART 可以同时发送和接收数据,这对于许多需要实时通信的应用程序来说非常理想。

  • 简单且成本低:UART 的硬件要求最小,易于实现且成本低。

以下是一些其优点:

  • 易用性:设置 UART 通信非常简单,这使得它成为初学者和简单应用的流行选择。

  • 广泛支持:UART 被大多数微控制器和外设设备普遍支持。

  • 低开销:没有时钟信号意味着使用的引脚更少,减少了复杂性。

然而,它也有一些缺点:

  • 速度限制:与 SPI 和 I2C 相比,UART 通常较慢,这使得它不太适合高速数据传输。

  • 距离限制:在长距离上对噪声的敏感性可能会限制可靠通信的范围。

  • 点对点通信:UART 是为直接、点对点通信设计的,如果多个设备需要通信,这可能是一个限制。

接下来,我们有串行外设接口(SPI)。

SPI

这里是 SPI 的一些关键特性:

  • 同步通信:SPI 使用时钟信号和数据线,确保同步数据传输。

  • 全双工:它允许数据同时发送和接收。

  • 主从架构:一个主设备控制多个从设备,每个设备都有专用线路。

以下是一些其优点:

  • 高速:SPI 支持高速数据传输,这使得它非常适合需要快速通信的应用程序。

  • 多功能性:SPI 可以连接具有不同配置的多个设备,为设计提供灵活性

然而,它也有一些缺点:

  • 需要更多引脚:每个从设备需要一个单独的选择线,这可能会显著增加引脚数

  • 没有标准化的确认机制:与 I2C 不同,SPI 没有内置的确认机制,这可能会使错误检测更具挑战性

  • 有限的多个主能力:SPI 不是为多主系统设计的,这在某些情况下可能是一个限制

我们将要介绍的最后一个通信协议是 I2C。

I2C

下面是 I2C 的一些关键特性:

  • 同步通信:I2C 使用时钟信号进行同步数据传输

  • 多主能力:多个主设备可以共享同一总线,这在更复杂的系统中很有用

  • 双线接口:I2C 只需要两条线(SDA 和 SCL)进行通信,最小化了引脚数

以下是一些其优势:

  • 布线简单:双线接口减少了复杂性和所需的引脚数

  • 多设备支持:I2C 可以轻松连接同一总线上具有唯一地址的多个设备

  • 内置寻址:I2C 具有内置的寻址机制,使得与多个设备的通信变得简单

然而,它也有一些缺点:

  • 较慢的速度:I2C 通常比 SPI 慢,这可能会成为高速应用的限制

  • 复杂的协议:该协议比 UART 和 SPI 更复杂,需要更复杂的数据传输和寻址处理

  • 易受噪声影响:与 UART 类似,I2C 在较长距离上可能会易受噪声影响,这可能会影响通信可靠性

选择合适的通信协议取决于您的具体应用需求。如果您需要简单、直接的通信并且可以容忍较慢的速度,UART是一个不错的选择。对于需要全双工通信的高速应用,SPI是理想的,尤其是如果您能够管理更高的引脚数。当您需要用最少的线缆连接多个设备并且拥有复杂的通信设置时,I2C是您的首选协议。为了帮助您更好地理解何时选择哪种协议,让我们探讨一些常见的用例。

UART、SPI 和 I2C 协议的常见用例

在设计嵌入式系统时,选择合适的通信协议对于确保高效和可靠的数据交换至关重要。UART、SPI 和 I2C 各自具有独特的优势,使它们适用于不同的应用。让我们探讨每个协议的实际用例和引人入胜的案例研究,突出其专业性和现实世界的相关性。

UART

让我们看看 UART 协议的一些常见用例:

  • 与 PC 的串行通信:UART 常用于微控制器和计算机之间的串行通信,尤其是用于调试、固件更新和数据记录。

  • GPS 模块:UART 可用于将 GPS 模块的位置数据传输到微控制器。

  • 蓝牙模块:UART 通过蓝牙使设备能够进行无线通信。

这些用例代表了 UART 最常见的应用之一,但该协议非常灵活,可以用于许多其他需要简单串行通信的场景。

案例研究 - 自主无人机 GPS 模块集成

想象你正在开发一个需要精确导航以执行测绘和制图等任务的自主无人机。使用 UART 集成 GPS 模块可以提供导航所需的实时位置数据。

设置:将 GPS 模块的发送(TX)引脚连接到微控制器的接收(RX)引脚,反之亦然。配置波特率,使其与 GPS 模块的输出相匹配。

操作:GPS 模块持续发送包含位置数据的 NMEA 句子(文本字符串)。微控制器通过 UART 读取这些字符串,解析它们,并使用位置信息来精确导航无人机。

优势:UART 的简单性和广泛支持使得集成 GPS 模块变得简单,提供可靠且连续的数据流,无需复杂的设置。

接下来,我们将探讨 SPI。

SPI

以下是一些 SPI 协议的常见用例:

  • 高速数据传输:它非常适合内存卡、模数转换器(ADCs)、数模转换器(DACs)和显示器等应用。

  • 显示模块:SPI 可用于与需要快速刷新率的高分辨率显示器通信。

  • 传感器和执行器:SPI 可以处理来自各种传感器的高频数据输出。

与 UART 类似,这些示例突出了 SPI 的一些典型用途,但该协议的高速能力使其适用于需要快速数据传输的广泛其他应用。

案例研究 - 工业设备的 SD 卡数据记录

考虑一个工业监控系统,它将来自各种传感器的数据记录到 SD 卡中,以进行长期分析。SPI 是这种高速数据传输的完美协议。

设置:使用 SPI 引脚(MISO、MOSI、SCLK 和 CS)将微控制器连接到 SD 卡。初始化 SPI 总线并配置 SD 卡。

操作:微控制器从传感器(例如,温度、压力和振动)收集数据,并将这些数据实时写入 SD 卡。

优势:SPI 的高速数据传输确保大量数据快速高效地记录,防止数据丢失并确保准确监控。

在这种情况下使用 SPI 允许工业系统精确记录关键参数,这对于预测性维护和运营效率至关重要。

最后,我们有 I2C。

I2C

让我们考虑两个与 I2C 相关的常见用例:

  • 多传感器集成系统:这涉及到在同一 I2C 总线上连接具有不同地址的多个传感器

  • 外围扩展:这涉及到使用 I2C 扩展器向微控制器添加更多 GPIO 引脚

这些用例只是 I2C 应用的两个例子。它能够在单总线上支持多个设备的能力,使其成为许多其他场景下的优秀选择,在这些场景中,可扩展性很重要。

案例研究 - 智能农业的环境监控系统

假设你正在开发一个使用多个传感器(温度、湿度和土壤湿度)以优化农业条件的智能农业系统。I2C 是这种多传感器集成的理想协议。

设置:将所有传感器连接到 I2C 总线(SDA 和 SCL 线)。为每个传感器分配一个唯一的地址。

操作:微控制器按顺序查询每个传感器,收集数据,并对其进行处理以提供见解和控制灌溉、通风和照明系统。

优势:I2C 能够在同一总线上仅用两条线支持多个设备,简化了布线,降低了成本,并节省了 GPIO 引脚,使其成为复杂传感器网络的效率解决方案。

从下一节开始,我们将专注于 UART 协议。在接下来的章节中,我们将介绍 I2C 和 SPI 协议。

UART 协议概述

最基本且最广泛使用的协议之一是 UART。无论你是调试硬件还是使微控制器与外围设备之间进行通信,理解 UART 都是至关重要的。让我们深入了解这个协议的工作原理。

什么是 UART?

UART 是一种硬件通信协议,它使用异步串行通信进行操作,允许调整数据传输速度。"异步"的 UART 特性意味着它不需要时钟信号来对齐发送器和接收器之间位传输的对齐。相反,两个设备必须就特定的波特率达成一致,这决定了数据交换的速度。让我们看看接口。

接口

UART 接口使用两条线进行通信:TX 和 RX。为了在两个设备之间建立连接,我们只需将第一个设备的 TX 引脚连接到第二个设备的 RX 引脚,并将第一个设备的 RX 引脚连接到第二个设备的 TX 引脚。此外,连接两个设备的接地引脚以确保共同的电气参考至关重要。图 10.1显示了两个 UART 设备之间的连接:

图 10.1:UART 接口

图 10.1:UART 接口

UART 是如何工作的

UART 中的数据以包含起始位数据位、可选的奇偶校验位停止位的帧形式传输:

图 10.2:UART 数据包

图 10.2:UART 数据包

下面是这个过程的一步一步分解:

  1. 起始位:传输线路通常保持高电平。为了开始数据传输,发送的 UART 将线路拉低一个时钟周期。这表示新数据帧的开始。

  2. 数据帧:在起始位之后,数据帧通常由 5 到 9 位组成,并从最低有效位LSB)到最高有效位MSB)发送。

  3. 奇偶校验位:这是可选的,用于错误检查。它确保数据中设置的位(1s)的数量是偶数或奇数。

  4. 停止位:这是一位或两位,表示数据包的结束。在停止位期间,线路被驱动到高电平。

让我们更详细地看看起始位、停止位和奇偶校验位。

起始位、停止位和奇偶校验位

这些位构成了 UART 协议的骨架,允许设备同步并验证传输数据的完整性。

起始位

起始位是 UART 通信中标记数据帧开始的初始信号。当发送设备处于空闲状态时,数据线保持在高电压水平(逻辑 1)。为了表示传输的开始,UART 发送器将线路拉低到低电压水平(逻辑 0),持续 1 位时间。这种从高到低的转换通知接收设备有新的数据包到来,允许它同步并准备接收数据。

停止位

在数据位和可选的奇偶校验位传输之后,停止位表示数据帧的结束。发送器将数据线驱动回高电压水平(逻辑 1),持续 1 或 2 位时间,具体取决于配置。停止位确保接收器有足够的时间处理最后一个数据位并为下一个起始位做准备。本质上,停止位充当缓冲区,在连续的数据帧之间提供清晰的分隔,并帮助保持通信设备之间的同步。

奇偶校验位

奇偶校验位是 UART 通信中用于基本错误检查的可选功能。它提供了一种简单的方法来检测在数据传输过程中可能发生的错误。奇偶校验位可以配置为偶校验或奇校验:

  • 偶校验:如果数据帧中 1 的数量是偶数,则奇偶校验位设置为 0;如果 1 的数量是奇数,则设置为 1。这确保了包括奇偶校验位在内的 1 的总数是偶数。

  • 奇校验:如果数据帧中 1 的数量是奇数,则奇偶校验位设置为 0;如果 1 的数量是偶数,则设置为 1。这确保了包括奇偶校验位在内的 1 的总数是奇数。

当接收方接收到数据帧时,它会将奇偶校验位与接收到的数据比特进行比较。如果存在不匹配,则表明在传输过程中发生了错误。虽然奇偶校验不能纠正错误,但它有助于识别错误,并在必要时提示重新传输。

开始位、停止位和奇偶校验位是 UART 通信的基本组成部分,每个部分都在确保数据完整性和同步方面发挥着关键作用。开始位表示传输的开始,停止位标记传输的结束,奇偶校验位提供基本的错误检查机制。它们共同构成了一个可靠且高效的设备间串行通信框架。

在结束本节之前,让我们花一点时间来了解在 UART 通信中使用的速度单位。

理解波特率——嵌入式系统中通信的速度

在嵌入式系统领域,波特率是一个你经常会遇到的术语。无论你是调试微控制器、设置串行通信链路还是处理各种外围设备,理解波特率都是至关重要的。但波特率究竟是什么,为什么它如此重要呢?让我们来分析一下。

波特率是什么?

波特率本质上是指数据在通信信道上传输的速度。它以每秒比特数(bps)来衡量。把它想象成高速公路上的限速:波特率越高,在给定时间内沿通信路径传输的数据就越多。

例如,波特率为 9,600 意味着每 传输 9,600 比特 的数据。换句话说,它设定了数据包发送和接收的速度。

然而,区分波特率和比特率非常重要。波特率指的是每秒信号变化的次数,而比特率是每秒传输的比特数。在简单的系统中,每次信号变化可以代表一个比特,这使得波特率和比特率相同。在更复杂的系统中,每次信号变化可以代表多个比特,从而导致比特率高于波特率。

为什么波特率很重要?

想象一下试图与一个说话速度与你截然不同的人交谈。这将令人困惑且效率低下,对吧?同样的原则也适用于电子设备之间的通信。发送和接收设备需要就一个共同的波特率达成一致,以便正确理解对方。如果它们不一致,数据可能会丢失或混乱,从而导致通信错误。

为了成功通信,发送方和接收方必须具有相同的波特率以正确同步。如果一个设备设置为 9,600 bps,而另一个设置为 115,200 bps,通信将失败,就像一个人说话太快或太慢以至于对方无法理解时,对话会失败一样。

在串行通信中,有一些标准波特率被广泛使用。以下是一些示例:

  • 300 bps: 非常慢,常用于带宽受限的长距离通信

  • 9,600 bps: 许多设备,包括微控制器广泛使用的默认速率

  • 19,200 bps: 更快,常用于数据密集型应用

  • 115,200 bps: 高速通信,常见于需要快速数据传输的应用

这就结束了我们对 UART 协议的概述。在下一节中,我们将探讨 STM32F4 微控制器中的 UART 外设。

STM32F4 UART 外设

STM32 微控制器通常包含多个 UART 外设,但具体数量取决于具体型号。STM32F411 微控制器有三个 UART 外设:

  • USART1

  • USART2

  • USART6

USART 与 UART 的比较

我们的 STM32 文档将 UART 外设称为 USART,因为它代表通用 同步/异步 接收/发送器。这个名字反映了外设的双重功能:

异步模式 (UART): 在此模式下,USART 作为传统的 UART 运行。它不需要时钟信号即可发送和接收数据,这是标准串行通信的典型特征。

同步模式 (USART): 在此模式下,USART 也可以使用同步时钟信号进行操作,允许它与除了数据线外还需要时钟线的设备进行通信。

让我们分析此外设的关键寄存器,从 USART 状态寄存器开始。

USART 状态寄存器 (USART_SR)

USART_SR 寄存器是用于监控 UART 外设状态的主要寄存器之一。它提供了关于各种操作标志和错误的实时信息。

让我们考虑此寄存器中的关键位:

  • 发送数据寄存器空 (TXE): 当数据寄存器为空且准备好写入新数据时,该位被设置。这表示发送器可以发送更多数据。

  • 读取数据寄存器非空 (RXNE): 该位指示数据寄存器包含尚未读取的数据。它表示有要处理的数据传入。

  • 传输完成 (TC): 当最后一个传输完成,包括所有停止位时,该位被设置。它表示数据已完全发送。

  • 溢出错误 (ORE): 该位指示数据丢失,因为数据寄存器在新数据到达之前没有被读取。它表示一个错误条件。

您可以在 STM32F4 参考手册 (RM0383)第 547 页 找到关于此寄存器的详细信息。接下来,我们有 USART_DR)。

USART 数据寄存器 (USART_DR)

USART_DR 寄存器用于发送和接收数据。它作为通过 UART 外设进行数据交换的主要接口。

此寄存器中的关键功能如下:

  • USART_DR 通过 TX 线发送数据。UART 外设以串行方式处理转换和传输。

  • USART_DR 从 RX 线上检索接收到的数据。这应该立即完成,以避免数据溢出。

接下来,我们有 USART_BRR)。

USART 波特率寄存器 (USART_BRR)

USART_BRR 寄存器用于设置 UART 通信的波特率,这对于同步设备之间的数据传输速度至关重要。

此寄存器有两个字段:

  • 尾数:设置波特率的分频因子的整数部分

  • 分数:分频因子的分数部分,用于微调波特率

我们将要检查的最后一个寄存器是 USART_CR1)。

USART 控制寄存器 1 (USART_CR1)

USART_CR1 寄存器是一个综合控制寄存器,它启用了各种 UART 功能和配置。

让我们考虑此寄存器中的关键位:

  • USART 使能 (UE):此位启用或禁用 UART 外设。它必须设置为激活 UART 通信。

  • 字长度 (M):此位配置字长度,允许 8 位或 9 位数据帧。

  • 奇偶校验控制使能 (PCE):此位启用奇偶校验以检测错误。

  • 奇偶校验选择 (PS):此位选择偶校验或奇校验。

  • 发送器使能 (TE):此位启用发送器,允许发送数据。

  • 接收器使能 (RE):此位启用接收器,允许接收数据。

在考虑了这些寄存器之后,我们现在可以开始开发 UART 驱动程序。我们将在下一节中深入探讨。

开发 UART 驱动程序

在本节中,我们将应用我们关于 UART 外设所学的所有知识,开发一个使用 USART2 外设发送数据的驱动程序。

让我们先识别连接到 UART2 外设的 GPIO 引脚。为此,请参考 STM32F411RE 数据表第 39 页的表格。此表列出了微控制器的所有 GPIO 引脚,以及它们的描述和附加功能。如图 10.3 所示,此表的一部分显示 PA1 有一个标记为 USART2_TX 的交替功能:

图 10.3:USART2_TX 引脚

图 10.3:USART2_TX 引脚

要将 PA2 作为 USART2_TX 线使用,我们需要在 GPIOA_MODER 寄存器中将 PA2 配置为交替功能引脚,然后在 GPIOA_AFRL 寄存器中指定 USART2_TX 的交替功能编号。STM32F4 微控制器允许我们从 16 个不同的交替功能中选择,编号从 AF00AF15。数据表中的交替功能映射表(您可以在数据表的第 47 页找到),概述了这些功能和它们对应的编号。如图 10.4 所示,该图来自数据表,配置 PA2AF07 将将其设置为作为 USART2_TX 线的功能:

图 10.4:PA2 交替功能

图 10.4:PA2 交替功能

我们现在有了开发 UART2 发送器驱动程序所需的所有信息。

创建你之前项目的副本,并将其重命名为 UART。接下来,在 Src 文件夹中创建一个名为 uart.c 的新文件,并在 Inc 文件夹中创建一个名为 uart.h 的新文件。用以下代码填充你的 uart.c 文件:

#include <stdint.h>
#include "uart.h"
#define GPIOAEN        (1U<<0)
#define UART2EN        (1U<<17)
#define DBG_UART_BAUDRATE        115200
#define SYS_FREQ                16000000
#define APB1_CLK                SYS_FREQ
#define CR1_TE                (1U<<3)
#define CR1_UE                (1U<<13)
#define SR_TXE                (1U<<7)
static void uart_set_baudrate(uint32_t periph_clk,uint32_t baudrate);
static void uart_write(int ch);
int __io_putchar(int ch)
{
    uart_write(ch);
    return ch;
}
void uart_init(void)
{
    /*Enable clock access to GPIOA*/
    RCC->AHB1ENR |= GPIOAEN;
    /*Set the mode of PA2 to alternate function mode*/
    GPIOA->MODER &=~(1U<<4);
    GPIOA->MODER |=(1U<<5);
    /*Set alternate function type to AF7(UART2_TX)*/
    GPIOA->AFR[0] |=(1U<<8);
    GPIOA->AFR[0] |=(1U<<9);
    GPIOA->AFR[0] |=(1U<<10);
    GPIOA->AFR[0] &=~(1U<<11);
    /*Enable clock access to UART2*/
     RCC->APB1ENR |=    UART2EN;
    /*Configure uart baudrate*/
      uart_set_baudrate(APB1_CLK,DBG_UART_BAUDRATE);
    /*Configure transfer direction*/
     USART2->CR1 = CR1_TE;
    /*Enable UART Module*/
     USART2->CR1 |= CR1_UE;
}
static void uart_write(int ch)
{
    /*Make sure transmit data register is empty*/
    while(!(USART2->SR & SR_TXE)){}
    /*Write to transmit data register*/
    USART2->DR =(ch & 0xFF);
}
static uint16_t compute_uart_bd(uint32_t periph_clk,uint32_t baudrate)
{
    return((periph_clk + (baudrate/2U))/baudrate);
}
static void uart_set_baudrate(uint32_t periph_clk,uint32_t baudrate)
{
    USART2->BRR = compute_uart_bd(periph_clk,baudrate);
}

让我们将其分解。

首先,我们有必要的包含和宏定义。

#include <stdint.h>
#include "uart.h"
#define GPIOAEN (1U<<0)
#define UART2EN (1U<<17)
#define DBG_UART_BAUDRATE 115200
#define SYS_FREQ 16000000
#define APB1_CLK SYS_FREQ
#define CR1_TE (1U<<3)
#define CR1_UE (1U<<13)
#define SR_TXE (1U<<7)

这里是宏的使用方法:

  • GPIOAEN:此宏通过在 AHB1ENR 寄存器中设置位 0 来启用 GPIOA 的时钟。

  • UART2EN:此宏通过在 APB1ENR 寄存器中设置位 17 来启用 UART2 的时钟。

  • DBG_UART_BAUDRATE:此宏定义 UART 通信的波特率,设置为 115200 bps。

  • SYS_FREQ:此宏定义系统频率,设置为 16 MHz,并且是 NUCLEO 开发板上 STM32F411 微控制器的默认频率。

  • APB1_CLK:此宏将 APB1 外设时钟频率设置为系统频率(16 MHz)。

  • CR1_TE:此宏通过在 USART_CR1 寄存器中设置位 3 来启用发射器。

  • CR1_UE:此宏通过在 USART_CR1 寄存器中设置位 13 来启用 UART 模块。

  • SR_TXE:此宏代表 USART_SR 寄存器中的 TXE 位。

接下来,我们有计算和设置波特率的辅助函数:

static uint16_t compute_uart_bd(uint32_t periph_clk, uint32_t baudrate)
{
    return ((periph_clk + (baudrate / 2U)) / baudrate);
}

此辅助函数计算波特率除数。它使用外设时钟和所需的波特率来计算要设置在 波特率 寄存器BRR)中的值:

static void uart_set_baudrate(uint32_t periph_clk, uint32_t baudrate)
{
    USART2->BRR = compute_uart_bd(periph_clk, baudrate);
}

此函数通过将计算出的除数写入 BRR 来设置 UART2 的波特率。让我们将注意力转向初始化函数:

RCC->AHB1ENR |= GPIOAEN;

通过在 AHB1 外设时钟使能寄存器中设置适当的位,此行启用了 GPIOA 的时钟:

GPIOA->MODER &= ~(1U << 4);
GPIOA->MODER |= (1U << 5);

这些行将引脚 PA2 配置为在备用功能模式下操作,这对于 UART 功能是必要的:

GPIOA->AFR[0] |= (1U << 8);
GPIOA->AFR[0] |= (1U << 9);
GPIOA->AFR[0] |= (1U << 10);
GPIOA->AFR[0] &= ~(1U << 11);

这些行将 PA2 配置为备用功能(AF7),对应于 UART2_TX

 RCC->APB1ENR |= UART2EN;

通过在 APB1 外设时钟使能寄存器中设置适当的位,此行启用了 UART2 的时钟:

uart_set_baudrate(APB1_CLK, DBG_UART_BAUDRATE);

此函数调用使用 uart_set_baudrate() 函数设置 UART2 的波特率:

USART2->CR1 = CR1_TE;

通过在控制寄存器中设置发射使能位,此配置将 UART2 配置为传输模式:

USART2->CR1 |= CR1_UE;

通过在控制寄存器中设置 UART 使能位,此操作启用了 UART2 模块。

接下来,我们有写入 UART 的函数:

static void uart_write(int ch)
{
    /* Make sure transmit data register is empty */
    while (!(USART2->SR & SR_TXE)) {}
    /* Write to transmit data register */
    USART2->DR = (ch & 0xFF);
}

让我们将其分解:

while (!(USART2->SR & SR_TXE)) {}

此循环确保在写入新数据之前,传输数据寄存器是空的:

USART2->DR = (ch & 0xFF);

此行将字符写入数据寄存器以进行传输。

最后,我们有一个有用的函数,允许我们将 printf 输出重定向到我们的 UART 发送器:

int __io_putchar(int ch)
{
    uart_write(ch);
    return ch;
}

它调用 uart_write() 发送字符,然后返回字符。

发送字符后,__io_putchar 返回相同的字符,ch

返回字符是一种标准做法,允许函数符合典型的 putchar 函数签名,该签名返回作为 int 变量的写入字符。

我们接下来的任务是填充uart.h文件。以下是代码:

#ifndef __UART_H__
#define __UART_H__
#include "stm32f4xx.h"
void uart_init(void);
uart.c, making it callable from other files. We are now ready to test our driver in main.c. Update your main.c file, like so:

include <stdio.h>

include "uart.h"

int main(void)

{

/初始化调试串口/

uart_init();

while(1)

{

printf("来自 STM32 的问候...\r\n");

}

}


			This main function simply initializes the UART2 peripheral and then continuously prints the sentence `Hello` `from STM32…`.
			Let’s test the project. To do so, we’ll need to install a program on our computer that can display the data that’s received through the computer’s serial port. In this setup, our development board acts as the transmitter, while the computer is the receiver.

				1.  **Install a serial** **terminal program**:
    *   Choose a serial terminal program that’s appropriate for your operating system. Options include *Realterm*, *Tera Term*, *Hercules*, and *Cool Term*.
    *   If you’re using Windows, I recommend Realterm. You can download it from SourceForge: [`sourceforge.net/projects/realterm/`](https://sourceforge.net/projects/realterm/).
    *   Follow the installation wizard to complete the setup.
				2.  **Prepare to identify your development board’s** **serial port**:
    1.  Disconnect your development board from your computer.
    2.  Open Realterm and navigate to the **Port** tab.
    3.  Click on the **Port** drop-down menu; you’ll see a list of available ports. Since your development board is currently disconnected, its port won’t appear in the list. **Take note** of the listed ports.
				3.  **Identify the development** **board’s port**:
    1.  Close Realterm and connect your development board to the computer.
    2.  Reopen Realterm and go back to the **Port** drop-down menu. You should now see a new port in the list, which corresponds to your development board.
    3.  Select this newly added port.
				4.  **Set the** **baud rate**:

    Click the **Baud** drop-down menu and select **115200**. This is the baud rate we configured in our driver.

				5.  **Build and run** **the project**:

    Return to your IDE, build the project, and run the firmware on your microcontroller.

				6.  `Hello from STM32…` continuously being printed in the Terminal window.

			*Figure 1**0**.5* shows the settings described for Realterm:
			![Figure 10.5: Realterm settings](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/brmtl-emb-c-prog/img/B21914_10_5.jpg)

			Figure 10.5: Realterm settings
			Summary
			In this chapter, we learned about the UART protocol, a fundamental communication method that’s widely used in embedded systems. We began by discussing the importance of communication protocols in embedded systems, emphasizing how UART, alongside SPI and I2C, facilitates seamless communication between microcontrollers and peripheral devices.
			Next, we provided a detailed overview of the UART protocol while covering its operational principles, including how data is transmitted asynchronously using start and stop bits, and the role of parity in error checking. We also discussed how the baud rate, a critical aspect of UART communication, is configured to ensure synchronized data transfer between devices.
			Then, we delved into the specifics of the STM32 UART peripheral, examining key registers such as the Status Register (`USART_SR`), Data Register (`USART_DR`), Baud Rate Register (`USART_BRR`), and Control Register 1 (`USART_CR1`). Understanding these registers is essential for configuring UART for effective communication in STM32 microcontrollers.
			Finally, we applied our theoretical understanding by developing a bare-metal UART driver for the STM32F4 microcontroller. This involved initializing the UART peripheral, setting the baud rate, and implementing functions for transmitting data. We also demonstrated how to redirect `printf` output to the UART, enabling easy debugging and data logging through a serial terminal.
			In the next chapter, we will learn about the **analog-to-digital** **converter** (**ADC**).

第十一章:模拟到数字转换器(ADC)

在本章中,我们将学习模拟到数字转换器ADC),这是嵌入式系统中一个重要的外围设备,它使微控制器能够与模拟世界进行接口。我们将首先概述模拟到数字转换过程、ADC 的重要性及其关键规格。

在此之后,我们将从 STM32F411 参考手册中提取和分析 ADC 操作所需的有关寄存器。最后,我们将开发一个裸机 ADC 驱动程序,以展示我们讨论的理论概念的实际应用。

在本章中,我们将涵盖以下主要主题:

  • 模拟到数字转换概述

  • STM32F4 ADC 外围设备

  • 关键 ADC 寄存器和标志

  • 开发 ADC 驱动程序

到本章结束时,您将全面了解 STM32 ADC 外围设备及其开发高效驱动程序所需的技能,这将使您能够有效地将模拟到数字转换功能集成到您的嵌入式系统项目中。

技术要求

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/Bare-Metal-Embedded-C-Programming

模拟到数字转换概述

ADC 是嵌入式系统中的一个关键过程,它允许我们的微控制器解释和处理现实世界的模拟信号。在本节中,我们将逐步介绍这个过程,解释将模拟信号转换为数字值所涉及的每个步骤。

什么是模拟到数字转换?

模拟到数字转换是将连续的模拟信号转换为离散数字表示的过程。模拟信号可以在一定范围内具有任何值,而数字信号具有特定的、量化的级别。这种转换是必不可少的,因为微控制器和数字系统只能处理数字数据。

转换过程通常涉及几个关键步骤:采样、量化和编码。让我们从采样开始分解这些步骤。

采样

采样涉及在定期的时间间隔内测量模拟信号的幅度,这些时间间隔称为采样间隔。结果是近似原始模拟信号的一系列离散值。图 11.1展示了这个过程:

图 11.1:采样过程

图 11.1:采样过程

模拟信号采样的速率被称为采样率采样频率。这通常以每秒的样本数(Hz)来衡量。根据奈奎斯特定理,采样率必须至少是模拟信号中最高频率的两倍,才能准确重建原始信号。

过程的下一步是量化。

量化

量化是将采样模拟值映射到数字域中可用的最近离散级别的过程。每个离散级别对应一个唯一的数字码。

可用于量化的离散级别数量由模拟到数字转换器的分辨率决定。例如,一个 8 位 ADC 有 256 个级别(2⁸),而一个 12 位 ADC 有 4096 个级别(2¹²)。

量化过程固有的引入了一个误差,称为量化误差量化噪声,因为精确的模拟值被近似到最近的数字级别。我们可以通过增加 ADC 的分辨率来最小化这个误差。例如,如果一个模拟信号的范围是 从 0 到 3.3V,并且使用了一个 8 位 ADC,那么量化步长大约是 12.9 mV (3.3V / 256)。一个 1.5V 的模拟输入可能被量化到最接近的数字级别,这可能会略高于或低于 1.5V

过程的最终步骤是编码。

编码

编码是最终步骤,是将量化级别转换为数字系统能够处理的二进制码。每个量化级别由一个唯一的二进制值表示。

ADC 中使用的位数决定了二进制码的长度。例如,一个 10 位 ADC 将为每个采样值生成一个 10 位 二进制数。继续我们之前的例子,如果 1.5V 的量化级别被确定为 116 级别,对于 8 位 ADC,其二进制表示将是 01110100

图 11.2 显示了 6 位模数转换器(ADC) 的编码过程。表中的列显示了 6 位二进制表示的量化级别。对于 6 位 ADC,数字输出范围从最低量化级别 000000 到最高量化级别 111111。每个二进制值对应一个特定的量化级别:

图 11.2:编码过程

图 11.2:编码过程

总结来说,模拟到数字转换过程始于一个模拟输入信号,该信号随时间连续变化。这个信号可能是一个温度传感器的电压、音频信号或任何其他模拟信号。模数转换器通常包括一个采样保持电路,在每个采样间隔捕获并保持模拟信号。这确保信号在转换过程中保持恒定。ADC 的核心执行量化和编码。它将保持的模拟值与一组参考电压进行比较,以确定最接近的匹配数字级别。结果数字码从 ADC 输出,可以被我们的微控制器或数字系统读取以进行进一步处理。

在下一节中,我们将解释本节中使用的一些关键术语,包括分辨率和参考电压VREF)。

ADC 的关键规格 – 分辨率、步长和 VREF

为了有效地使用 ADC,了解它们的关键规格非常重要,这些规格定义了它们在各种应用中的性能。让我们从分辨率开始。

分辨率

ADC 的分辨率决定了它可以产生的不同输出级别的数量,对应于输入电压范围被分成的间隔数量。它通常以比特为单位表示。更高的分辨率允许我们更精确地表示模拟输入信号,减少量化误差并提高测量的准确性。

对于一个N 位 ADC,离散输出级别的数量是2N。例如,一个8 位 ADC 有256(2⁸)个级别,而一个12 位 ADC 有4,096个级别。

表 11.1 展示了常见的 ADC 分辨率及其对应的离散级别数量。以下表格突出了随着分辨率的增加,离散级别数量呈指数增长,从而在模拟输入信号的数字表示中提供更细的粒度:

ADC 分辨率(比特) 离散 级别数量(2^N)
8 256
10 1,024
12 4,096
14 16,384
16 65,536
18 262,144
20 1,048,576
24 16,777,216

表 11.1:常见的 ADC 分辨率

下一个关键规格是VREF

VREF

VREF 是我们 ADC 可以转换的最大电压。模拟输入电压与这个 VREF 进行比较,以产生一个数字值。VREF 的稳定性和精度直接影响 ADC 的准确性,因为 VREF 的任何波动都可能导致数字输出中的相应误差。

我们可以选择从微控制器中提取 VREF,或者为更精确的应用提供外部 VREF。内部参考方便,但可能具有更高的可变性,而外部参考可以提供更好的稳定性和精度。VREF 的选择取决于我们应用的精度要求和被测量的模拟信号的特性。

例如,如果 VREF 是 5V,ADC 可以准确地转换 0V 到 5V 范围内的任何模拟输入信号。

我们将要检查的最后一个是步长。

步长

步长是 ADC 可以区分的最小模拟输入变化。它由 VRED 和分辨率决定,并决定了 ADC 输出的粒度。较小的步长表示更细的分辨率,允许 ADC 检测输入信号中的更小变化。

步长是通过将 VREF 除以 2 的 ADC 分辨率次幂(位数)来计算的:

Stepsize=VREF2N

在这里,VREF是参考电压,N是分辨率位数。

例如,对于一个 VREF = 3.3V 的 10 位 ADC:

Stepsize=3.3V210=3.3V1024=3.22mV

这意味着量化数字输出的每次增加对应于模拟输入中3.22mV的变化。表 11.2列出了常见的 ADC 分辨率、相应的步数和步长,使用 3.3V 的 VREF:

ADC 分辨率(位) 步数(2^N) 步长(mV)
8 256 12.9
10 1,024 3.22
12 4,096 0.805
14 16,384 0.201
16 65,536 0.0504
18 262,144 0.0126
20 1,048,576 0.0032
24 16,777,216 0.000197

表 11.2:3.3V VREF 下的 ADC 分辨率和步长

此表清晰地展示了随着分辨率的增加,步长如何减小,从而允许在模拟输入的数字表示中具有更细的粒度。

这就结束了我们对模拟-数字转换过程的概述。在下一节中,我们将检查我们的 STM32 微控制器的 ADC 外设。

STM32F4 ADC 外设

我们的 STM32F411 微控制器配备了一个 12 位 ADC,能够测量多达19 个多路复用通道的信号。ADC 可以在各种模式下运行,如单次、连续、扫描或不连续,结果存储在一个 16 位数据寄存器中。此外,ADC 还具有模拟看门狗功能,允许系统检测输入电压是否超过预定义的阈值。

在我们解释各种 ADC 模式之前,让我们先了解我们所说的 ADC 通道是什么意思。

ADC 通道

ADC 通道是一个专用路径,通过该路径将模拟信号输入到 ADC 中,以便将其转换为数字值。每个 ADC 通道对应一个配置为模拟模式的特定 GPIO 引脚。

传感器,产生代表物理现象(如温度、光或压力)的模拟信号,通过这些 GPIO 引脚与我们的微控制器接口。通过将 GPIO 引脚配置为模拟输入,微控制器可以在相应的 ADC 通道上接收传感器的模拟输出信号。然后 ADC 将这个连续的模拟信号转换为微控制器可以处理、分析和用于我们嵌入式系统应用中进一步决策任务的离散数字表示。

你可能会想,19 个通道是否意味着我们有 19 个独立的 ADC 模块?这正是多路复用发挥作用的地方。

多路复用 ADC 通道

多路复用允许 ADC 在不同的输入信号之间切换,依次采样每个信号。这是通过 ADC 外围中的模拟多路复用器(MUX)实现的。正如我们之前所学的,每个 ADC 通道都连接到一个配置为模拟输入的特定 GPIO 引脚。模拟 MUX 选择哪个模拟输入信号(来自 GPIO 引脚或内部源)连接到 ADC 的采样电路,在任何给定时间。这种选择由 ADC 的配置寄存器控制。

图 11.3显示了 ADC 通道与 ADC 外围块内模拟多路复用器的连接:

图 11.3:ADC 通道多路复用

图 11.3:ADC 通道多路复用

现在,让我们来检查可用的 ADC 模式。

ADC 模式

在我们的 STM32F411 微控制器中,ADC 可以在几种模式下运行,每种模式都针对特定的应用需求进行了定制。主要的工作模式包括单次转换模式、连续转换模式、扫描模式、不连续模式和注入转换模式。

让我们逐一分析:

  • ADC_CR2寄存器中的0

    示例用例:在特定间隔读取温度传感器的值。

  • ADC_CR2寄存器中的1

    示例用例:持续监测电位计以实时跟踪其位置。

  • 通过设置ADC_CR1寄存器并配置ADC_SQRx寄存器中的通道序列,然后通过设置ADC_CR2寄存器或通过外部触发来启动转换。如果ADC_CR2,则在最后一个通道转换后,序列将重新开始。

    示例用例:在数据采集系统中采样多个传感器输入。

  • 首先通过设置ADC_CR1寄存器来定义每个组中要转换的通道数量。

    示例用例:在多通道系统中降低部分通道的采样率。

  • 通过设置ADC_JSQR寄存器,然后通过设置ADC_CR2寄存器或通过外部触发来启动转换。

    示例用例:在快速充电或放电期间,优先测量电池管理系统(BMS)中的关键电池单元电压。

在探索常见的 ADC 寄存器之前,让我们先了解 STM32F411 微控制器中可用的两种通道类型。

理解 STM32F411 ADC 中的常规通道与注入通道

在 STM32F411 微控制器中,ADC 通过两种主要类型的通道(常规通道和注入通道)提供了一种灵活的方法来处理多个模拟输入。常规通道用于常规的、顺序的转换,非常适合从传感器进行周期性数据采集,其中时间不是极其关键。这些通道遵循由ADC_SQRx寄存器设置的预定义序列,并且可以由软件或外部事件触发。

相比之下,注入通道,如配置在注入转换模式中的通道,是为高优先级时间敏感的任务设计的,当满足特定条件时,会中断常规序列以执行即时转换。这使得注入通道非常适合捕获具有精确时间的关键测量,例如在控制应用中的电机电流感应。此外,ADC 还包括一个模拟看门狗功能,可以监控常规和注入通道的值,如果超出预定义的阈值,将生成中断以处理越界条件。这种双通道能力与模拟看门狗相结合,为各种应用提供了一个强大的框架,从常规的环境监测到关键实时数据处理和安全监控。

在下一节中,我们将检查 ADC 外围的关键寄存器以及与 ADC 操作相关的某些标志。

关键的 ADC 寄存器和标志

在本节中,我们将探讨 ADC 外围中一些关键寄存器的特性和功能。

让我们从 ADC 控制寄存器 1(ADC_CR1)开始。

ADC 控制寄存器 1(ADC_CR1)

这是用于配置 ADC 操作设置的几个主要控制寄存器之一。它提供了各种配置选项,例如分辨率、扫描模式、断续模式和中断使能。

以下是该寄存器中的关键位:

  • RES[1:0]分辨率位):这些位设置 ADC 的分辨率(12 位、10 位、8 位或 6 位)

  • SCAN扫描模式):设置此位启用扫描模式,允许 ADC 按顺序转换多个通道

  • DISCEN断续模式):当设置时,此位在常规通道上启用断续模式

  • AWDEN模拟看门狗使能):此位使能所有常规通道上的模拟看门狗

  • EOCIE转换结束中断使能):当设置时,此位允许在 EOC 标志设置时生成中断

你可以在 STM32F411 参考手册的第 229 页找到关于此寄存器的详细信息(RM0383)。

接下来,我们有 ADC 控制寄存器 2(ADC_CR2)。

ADC 控制寄存器 2(ADC_CR2)

这是另一个处理 ADC 操作不同方面的关键控制寄存器,包括转换开始、数据对齐和外部触发。

下面是该寄存器中的关键位:

  • ADONADC 开启):此位控制 ADC 的开启或关闭

  • CONT连续转换):设置此位启用连续转换模式

  • SWSTART启动常规通道转换):设置此位开始常规通道的转换

  • ALIGN数据对齐):此位设置转换数据的对齐方式(右对齐或左对齐)

  • EXTEN[1:0]:这是一个外部触发器,用于为常规通道选择极性

关于此寄存器的更多信息可以在参考手册的第 231 页找到。

让我们继续到 ADC 常规序列寄存器 (ADC_SQRx).

ADC 常规序列寄存器 (ADC_SQRx)

ADC_SQRx 寄存器定义了 ADC 转换通道的顺序。有多个 SQR 寄存器来处理多达 16 个常规通道的序列。

这里是这个寄存器中的关键位:

  • L[3:0]:常规通道序列长度。这些位设置常规序列中的转换总数。

  • SQ1-SQ16:常规通道序列。这些位指定要转换的通道的顺序。

你可以在参考资料的 235 页上了解更多关于此寄存器的信息。下一个关键的寄存器是 ADC 数据寄存器 (ADC_DR)

ADC 数据寄存器 (ADC_DR)

ADC_DR 寄存器保存转换的结果。这是在转换完成后存储模拟输入数字表示的地方。该寄存器是只读的,数据存储在寄存器的低 16 位中。

我们将要检查的最后一个寄存器是 ADC 状态寄存器 (ADC_SR)。

ADC 状态寄存器 (ADC_SR)

此寄存器包含各种状态标志,指示 ADC 的状态。这些标志对于监控 ADC 的操作和处理中断至关重要。我们将在下一节中检查这些标志。

关键 ADC 标志

ADC 标志是状态指示器,它们通知系统关于 ADC 操作的状态。这些标志对于监控 ADC 的进度、处理中断和管理错误至关重要。

STM32F411 中的关键 ADC 标志如下:

  • ADC_SR 寄存器位于位位置 1 (EOC) 并由硬件在常规转换完成后设置。

    如果 ADC_CR1 寄存器被设置,EOC 标志可以触发中断。在这种情况下,可以触发一个中断服务例程来处理转换后的数据。

  • ADC_SR 寄存器位于位位置 2 (ADC_CR1 寄存器).

  • ADC_SR 寄存器位于位位置 0 (ADC_CR1 寄存器).

  • ADC_SR 寄存器位于位位置 5 (ADC_CR1 寄存器).

  • 开始转换 (STRT) 标志STRT 标志表示 ADC 转换已经开始。我们可以使用这个标志来验证 ADC 是否已启动转换过程。

理解并有效地使用 ADC 标志对于管理我们 STM32 微控制器中的 ADC 操作至关重要。如 EOC、JEOC、AWD、OVR 和 STRT 等标志提供了关于转换状态、数据完整性和阈值监控的基本信息。通过利用这些标志,我们可以增强我们 ADC 实现的可靠性和功能性,确保在嵌入式系统项目中准确及时地获取和处理数据。

在下一节中,我们将应用我们所学到的信息来开发一个用于读取模拟传感器值的 ADC 驱动程序。

开发 ADC 驱动程序

在本节中,我们将应用我们关于 ADC 外设所学的所有知识来开发一个用于从连接到 ADC 通道之一的传感器读取传感器值的驱动程序。

识别 ADC 的 GPIO 引脚

让我们先识别连接到 ADC 通道的 GPIO 引脚。为此,请参考 STM32F411RE 数据手册第 39 页 中的表格。该表格列出了微控制器的所有 GPIO 引脚,以及它们的描述和附加功能。如图 *图 11.4 所示,该表格的一部分揭示了 ADC1_IN1。这表明 PA1 连接到 ADC1,通道 1**:

图 11.4:引脚定义

图 11.4:引脚定义

让我们配置 PA1 以使其作为 ADC 引脚工作。

首先,在你的 IDE 中创建你之前项目的副本,按照前面章节中概述的步骤进行。将这个复制的项目重命名为 ADC。接下来,在 Src 文件夹中创建一个名为 adc.c 的新文件,并在 Inc 文件夹中创建一个名为 adc.h 的新文件。

在你的 adc.c 文件中添加以下代码:

#include "adc.h"
#define GPIOAEN        (1U<<0)
#define ADC1EN        (1U<<8)
#define ADC_CH1        (1U<<0)
#define ADC_SEQ_LEN_1  0x00
#define CR2_ADCON    (1U<<0)
#define CR2_CONT          (1U<<1)
#define CR2_SWSTART     (1U<<30)
#define SR_EOC         (1U<<1)
void pa1_adc_init(void)
{
    /****Configure the ADC GPIO Pin**/
    /*Enable clock access to GPIOA*/
    RCC->AHB1ENR |= GPIOAEN;
    /*Set PA1 mode to analog mode*/
    GPIOA->MODER |=(1U<<2);
    GPIOA->MODER |=(1U<<3);
    /****Configure the ADC Module**/
    /*Enable clock access to the ADC module*/
    RCC->APB2ENR |=ADC1EN;
    /*Set conversion sequence start*/
    ADC1->SQR3 = ADC_CH1;
    /*Set conversion sequence length*/
    ADC1->SQR1 = ADC_SEQ_LEN_1;
    /*Enable ADC module*/
    ADC1->CR2 |=CR2_ADCON;
}
void start_conversion(void)
{
    /*Enable continuous conversion*/
    ADC1->CR2 |=CR2_CONT;
    /*Start ADC conversion*/
    ADC1->CR2 |=CR2_SWSTART;
}
uint32_t adc_read(void)
{
    /*Wait for conversion to be complete*/
    while(!(ADC1->SR & SR_EOC)){}
    /*Read converted value*/
    return (ADC1->DR);
}

让我们分解源代码,从宏定义开始:

#define GPIOAEN        (1U<<0)
#define ADC1EN        (1U<<8)
#define ADC_CH1        (1U<<0)
#define ADC_SEQ_LEN_1  0x00
#define CR2_ADCON     (1U<<0)
#define CR2_CONT     (1U<<1)
#define CR2_SWSTART      (1U<<30)
#define SR_EOC          (1U<<1)

让我们分解这些宏:

  • GPIOAEN:这个宏通过在 AHB1ENR 寄存器中设置第 0 位来启用 GPIOA 的时钟

  • ADC1EN:通过在 APB2ENR 寄存器中设置第 8 位来启用 ADC1 的时钟

  • ADC_CH1:在 SQR3 寄存器中为 ADC 转换选择通道 1

  • ADC_SEQ_LEN_1:在 SQR1 寄存器中将转换序列长度设置为 1

  • CR2_ADCON:通过在 CR2 寄存器中设置第 0 位来启用 ADC 模块

  • CR2_CONT:通过在 CR2 寄存器中设置第 1 位来启用连续转换模式

  • CR2_SWSTART:通过在 CR2 寄存器中设置第 30 位来启动 ADC 转换

  • SR_EOC:这个宏通过读取 状态 寄存器SR)中的第 1 位来等待转换结束

接下来,我们必须分析用于 ADC 功能的 GPIO 引脚的配置序列:

/* Enable clock access to GPIOA */
 RCC->AHB1ENR |= GPIOAEN;

这行代码通过在 AHB1ENR 寄存器中设置适当的位来启用 GPIOA 的时钟,使用 GPIOAEN 宏:

/* Set PA1 mode to analog mode */
    GPIOA->MODER |= (1U<<2);
    GPIOA->MODER |= (1U<<3);

这些行配置 GPIOA_MODER 寄存器。

让我们继续到配置 ADC 参数的代码部分:

/* Enable clock access to the ADC module */
RCC->APB2ENR |= ADC1EN;

这行代码通过在 APB2ENR 寄存器中设置适当的位来启用 ADC1 的时钟,使用 ADC1EN 宏。

/* Set conversion sequence start */
 ADC1->SQR3 = ADC_CH1;

这行代码使用 ADC_CH1 宏在 ADC_SQR3 寄存器中将通道 1 设置为转换序列的开始:

/* Set conversion sequence length */
ADC1->SQR1 = ADC_SEQ_LEN_1;

这行代码使用 ADC_SEQ_LEN_1 宏在 ADC_SQR1 寄存器中将序列长度设置为 1,这意味着只有一个通道将被转换:

/* Enable ADC module */
ADC1->CR2 |= CR2_ADCON;

这行代码通过设置 ADC_CR2 寄存器来启用 ADC 模块。

接下来,我们可以开始转换:

/* Enable continuous conversion */
ADC1->CR2 |= CR2_CONT;

这行代码通过使用 CR2_CONT 宏设置 ADC_CR2 寄存器来启用连续转换模式:

/* Start ADC conversion */
 ADC1->CR2 |= CR2_SWSTART;

这行代码通过使用 CR2_SWSTART 宏设置 ADC_CR2 寄存器来启动 ADC 转换:

接下来,我们必须等待结果准备好:

/* Wait for conversion to be complete */
while (!(ADC1->SR & SR_EOC)) {}

这行代码通过检查 ADC_SR 寄存器来等待转换完成:

/* Read converted value */
return (ADC1->DR);

这行代码从 ADC_DR 寄存器读取转换后的数字值。

总结来说,我们的代码执行以下操作:

  1. 初始化 ADC GPIO 引脚:

    • 启用 GPIOA 的时钟

    • 将 PA1 设置为模拟模式

  2. 配置 ADC 模块:

    • 启用 ADC1 的时钟

    • 将通道 1 设置为转换序列的起始

    • 将转换序列的长度设置为 1

    • 启用 ADC 模块

  3. 开始 ADC 转换过程:

    • 启用连续转换模式

    • 开始 ADC 转换过程

  4. 读取 ADC :

    • 等待转换完成

    • 从 ADC 数据寄存器读取转换后的值

我们接下来的任务是填充adc.h文件。以下是代码:

#ifndef ADC_H__
#define ADC_H__
#include <stdint.h>
#include "stm32f4xx.h"
void pa1_adc_init(void);
void start_conversion(void);
uint32_t adc_read(void);
adc.c, making them callable from other files.
			Let’s move on to the `main.c` file. Update your `main.c` file, like so:

include <stdio.h>

include "adc.h"

include "uart.h"

int sensor_value;

int main(void)

{

/* 初始化调试串口 */

uart_init();

/* 初始化 ADC */

pa1_adc_init();

/* 开始转换 */

start_conversion();

while(1)

{

sensor_value = adc_read();

printf("传感器值: %d\r\n",sensor_value);

}

}


			Let’s break it down:

				*   **Including** **header files**:

    ```

    #include <stdio.h>

    #include "adc.h"

    #include "uart.h"

    ```cpp

    Let’s take a closer look:

    *   `#include <stdio.h>`: This includes the standard input/output library, which provides the `printf()` function for printing the sensor values
    *   `#include "adc.h"`: This includes the header file for the ADC functions, ensuring that the `pa1_adc_init`, `start_conversion`, and `adc_read` functions from our `adc.c` file are available
    *   `#include "uart.h"`: This includes the header file for the UART functions we developed in the previous chapter, ensuring that the `uart_init` function is available				*   **Global** **variable declaration**:

    ```

    int sensor_value;

    ```cpp

    This declares a global variable to store the ADC value that’s read from the sensor.

    				*   **Main function**:

    ```

    /* 初始化调试串口 */

    uart_init();

    ```cpp

    This line initializes the UART peripheral, allowing us to print the sensor value:

    ```

    /* 初始化 ADC */

    pa1_adc_init();

    ```cpp

    This line initializes the ADC:

    ```

    /* 开始转换 */

    start_conversion();

    ```cpp

    This line starts the ADC conversion process.

    				*   **Infinite loop**:

    ```

    sensor_value = adc_read();

    printf("传感器值: %d\r\n", sensor_value);

    ```cpp

    This line prints the sensor value to the terminal or console using the UART. The `\r\n` part at the end of the string ensures that the printed value starts on a new line each time.

			We are now ready to test the project.
			Testing the project
			To test your project, you must connect your sensor or a potentiometer to the development board. Follow these steps:

				1.  **Connect** **a sensor**:
    *   **Signal pin**: Connect the signal pin of your sensor to **PA1**.
    *   **GND pin**: Connect the GND pin of the sensor to one of the GND pins on the development board.
    *   **VCC pin**: Connect the VCC pin to either the 3.3V or 5V pin on the development board. Ensure you verify the required voltage from your sensor’s documentation as different sensors may need either 3.3V or 5V.
				2.  **Use** **a potentiometer**:
    *   If a sensor is not available, you can use a potentiometer instead. A potentiometer is an adjustable resistor that’s used to vary the voltage. It has three terminals: two fixed and one variable (wiper).
    *   **Middle terminal**: Connect the middle terminal (wiper) of the potentiometer to PA1.
    *   **Left terminal**: Connect the left terminal to 3.3V.
    *   **Right terminal**: Connect the right terminal to GND.

    See *Figure 11**.4* for the connection diagram:

			![Figure 11.5: Potentiometer connection](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/brmtl-emb-c-prog/img/B21914_11_5.jpg)

			Figure 11.5: Potentiometer connection
			As you turn the knob of the potentiometer, the resistance between the middle terminal and the fixed terminals (3.3V and GND) will change, which, in turn, changes the voltage output at the middle terminal. This varying voltage will be measured by the ADC.

				1.  **Run** **the project**:
    *   Build and run the project on the development board.
    *   Open **RealTerm** or another serial terminal program and select the appropriate port and baud rate.
    *   You should see the sensor values being printed in real time on the terminal. As you turn the potentiometer knob, the displayed value should change, reflecting the varying output voltage.

			Summary
			In this chapter, we explored the ADC, a vital peripheral in embedded systems that enables microcontrollers to interface with the analog world. We started with an overview of the analog-to-digital conversion process, highlighting its importance and discussing key specifications such as resolution, step size, and VREF.
			Then, we delved into the STM32F411 microcontroller’s ADC peripheral, examining its capabilities and the relevant registers required for ADC operations. This included an overview of key ADC registers, such as `ADC_CR1`, `ADC_CR2`, `ADC_SQRx`, `ADC_SR`, and `ADC_DR`, as well as important ADC flags, such as EOC, JEOC, AWD, OVR, and STRT.
			This chapter also explained the different ADC modes, including single conversion mode, continuous conversion mode, scan mode, discontinuous mode, and injected conversion mode. Each mode was explained with practical use cases to illustrate their applications.
			Next, we examined how multiplexing allows the ADC to switch between multiple input signals, enabling the microcontroller to handle multiple analog inputs efficiently.
			Finally, we applied the theoretical concepts by developing a bare-metal ADC driver. This involved configuring a GPIO pin for ADC input, configuring the ADC module, starting conversions, and reading the ADC values.
			In the next chapter, we will focus on the **Serial Peripheral Interface** (**SPI**), another commonly used communication protocol known for its speed and efficiency in embedded systems.

第十二章:串行外设接口(SPI)

在本章中,我们将学习串行外设接口SPI)协议,这是在嵌入式系统中广泛使用的重要通信协议之一。

我们将首先深入了解 SPI 协议的基础,了解其主从架构、数据传输模式和典型用例。接下来,我们将检查 STM32 微控制器中 SPI 外设的关键寄存器,提供其配置和使用的详细见解。最后,我们将应用这些知识来开发裸机 SPI 驱动程序,展示其实际实现和测试。

在本章中,我们将涵盖以下主要主题:

  • SPI 协议概述

  • STM32F4 SPI 外设

  • 开发 SPI 驱动程序

到本章结束时,您将很好地理解 SPI 协议,并准备好开发 SPI 的裸机驱动程序。

技术要求

本章的所有代码示例都可以在以下 GitHub 链接中找到:

github.com/PacktPublishing/Bare-Metal-Embedded-C-Programming

SPI 协议概述

让我们深入了解 SPI 是什么,它的关键特性,它是如何工作的,以及一些使其如此强大的细微差别。

什么是 SPI?

SPI 是由摩托罗拉开发的同步串行通信协议。与通用异步收发传输器UART)不同,UART 是异步的,SPI依赖于时钟信号来同步设备间的数据传输。它旨在进行短距离通信(通常不超过 30 厘米),主要在微控制器和外围设备(如传感器、SD 卡和显示模块)之间使用。让我们看看其关键特性。

SPI 的关键特性

由于其效率高,SPI 脱颖而出。以下是一些其关键特性:

  • 全双工通信:SPI 支持同时进行数据传输和接收

  • 高速:与集成电路间I2C)和 UART 等协议相比,SPI 可以以更高的速度运行

  • 主从架构:一个主设备控制通信,而一个或多个从设备响应

  • 灵活的数据长度:可以处理各种数据长度,通常是 8 位,但不仅限于这一点

要能够连接两个 SPI 设备,我们必须了解 SPI 接口。

SPI 接口

SPI 使用四条主要通信线路,每条线路都有几个可能遇到的备选名称:

  • 主设备输入从设备输出(MISO):也称为串行数据输出SDO)或数据输出DOUT),这条线路从从设备传输数据到主设备

  • 主设备输出从设备输入(MOSI):也称为串行数据输入SDI)或数据输入DIN),这条线路从主设备传输数据到从设备

  • 串行时钟(SCK):也称为SCLK(或简单地称为CLK),这是由主设备生成的时钟信号,用于同步数据传输

  • 从设备选择(SS):也称为芯片选择(CS)或非从设备选择(NSS),这条线由主设备用于选择与哪个从设备进行通信

当使用多个从设备时,每个从设备通常都有自己的 SS 线,允许主设备单独控制与每个从设备的通信。图 12.1展示了单个主设备和单个从设备之间的 SPI 连接:

图 12.1:SPI 接口

图 12.1:SPI 接口

图 12.2展示了单个主设备控制多个从设备的 SPI 设置:

图 12.2:SPI 接口 – 多个从设备

图 12.2:SPI 接口 – 多个从设备

当多个从设备连接到单个 SPI 总线时,管理 MISO 线对于避免通信错误至关重要。由于所有从设备都共享这条线,如果未正确控制,非选择的从设备可能会干扰所选从设备的信号。为了防止此类问题,使用了几种技术。一种常见的方法是三态缓冲,当从设备的 CS 线处于非活动状态时,每个从设备的 MISO 线进入高阻抗(高-Z)状态,从而有效地将其从总线上断开。这确保只有所选从设备驱动 MISO 线,防止总线冲突。另一种方法是开漏配置,带有上拉电阻,当传输 1 时,MISO 线保持浮空(高-Z),而当传输 0 时,由所选从设备将其拉低。这减少了冲突风险,但可能会由于上拉电阻引入的时间延迟而导致通信速度变慢。

让我们看看 SPI 协议是如何工作的。

SPI 是如何工作的

SPI 的工作原理很简单:主设备生成一个时钟信号,并通过将相应的 SS 线拉低来选择一个从设备进行通信。然后,数据在主设备和从设备之间通过 MOSI 和 MISO 线同时交换。

下面是逐步分解:

  1. 初始化:主设备设置时钟频率和数据格式(例如,8 位数据)。

  2. 从设备选择:主设备将目标从设备的 SS 线拉低。在多从设备配置中,每个从设备都有自己的 CS 线,主设备在发送任何初始化消息之前首先将所有 CS 线设置为高(非活动状态)。这确保未初始化的从设备不会错误地响应不是针对它们的命令。一旦所有 CS 线都确认处于高状态,主设备然后通过将 CS 线拉低来激活所需从设备的 CS 线,以开始受控通信。

  3. 数据传输:主设备通过 MOSI 线向从设备发送数据,而从设备通过 MISO 线向主设备发送数据。

  4. 时钟同步:主设备控制时钟,确保数据在正确的时间被采样和移位。

  5. 完成:一旦数据传输完成,主设备将 SS 线拉高,取消选择从设备。

要成功实现 SPI 驱动程序,理解关键的 SPI 配置参数至关重要。让我们逐一探讨,从时钟相位CPHA)和时钟极性CPOL)开始。

CPHA 和 CPOL

在 SPI 通信中,CPHA 和 CPOL 的设置决定了用于在主从设备之间同步数据传输的时钟信号的时序和特性。这些设置对于确保数据被主从设备正确采样和解释至关重要。以下是关于 CPHA 和 CPOL 如何影响 SPI 通信的详细分析。

CPOL

CPOL 决定了时钟信号(SCK)的空闲状态。它控制在没有数据传输时,时钟信号是高电平还是低电平:

  • CPOL = 0: 当空闲时,时钟信号为低(0)。这意味着在数据传输之间,时钟线保持低电平。

  • CPOL = 1: 当空闲时,时钟信号为高(1)。这意味着在数据传输之间,时钟线保持高电平。

CPHA

CPHA 决定了数据何时采样以及何时移出。它控制数据读写的时钟信号的边缘:

  • CPHA = 0: 数据在时钟脉冲的前沿(第一个边缘)采样,并在后沿(第二个边缘)移出。

  • CPHA = 1: 数据在时钟脉冲的前沿(第一个边缘)移出,并在后沿(第二个边缘)采样

CPOL 和 CPHA 的组合产生了四种不同的 SPI 模式,每种模式都影响数据采样和移位的时序。

选择合适的 SPI 模式对于确保主从设备之间正确通信至关重要。两个设备都必须配置为使用相同的 CPOL 和 CPHA 设置,以正确解释交换的数据。模式的选择取决于设备的具体要求和应用的时序约束。让我们继续探讨 SPI 数据模式。

数据模式

SPI 在处理数据长度方面具有灵活性。虽然8 位数据传输很常见,但 SPI 可以根据应用配置为处理不同的数据长度,例如16 位32 位传输。主从设备需要就数据长度达成一致,以确保准确通信。最后一个配置参数是 SPI 速度。

SPI 速度

SPI 的一个显著优点是其速度。SPI 可以在非常高的频率下运行,通常高达几十MHz,具体取决于主从设备的硬件能力。实际应用中的速度取决于几个因素:

  • 设备能力: 主从设备支持的最大速度

  • 信号完整性: 较高的速度可能导致信号完整性问题,如串扰和反射,尤其是在较长的距离上

  • 功耗: 较高的速度消耗更多的功率,这可能在电池供电的应用中需要考虑。

这就完成了我们对 SPI 协议的概述。在下一节中,我们将分析 STM32F4 微控制器中的 SPI 外设。

STM32F4 SPI 外设

与其他外设一样,STM32 微控制器通常包含多个 SPI 外设;具体数量取决于具体型号。STM32F411 微控制器有五个 SPI 外设,具体如下:

  • SPI1

  • SPI2

  • SPI3

  • SPI4

  • SPI5

关键特性

这里有一些关键特性:

  • 全双工和半双工通信:支持同时双向通信(全双工)或单向通信(半双工)

  • 主/从配置:每个 SPI 外设都可以配置为主设备或从设备

  • 灵活的数据大小:支持从 4 位到 16 位的数据大小

  • 高速通信:在主模式下,能够以高达 42 MHz 的速度运行,在从模式下,能够以高达 21 MHz 的速度运行

  • 直接内存访问(DMA)支持:支持 DMA,无需 CPU 干预即可高效传输数据

  • 负 SS(NSS)引脚管理:用于多从配置的 NSS 引脚的硬件管理

  • 循环冗余校验(CRC)计算:内置硬件 CRC 计算以验证数据完整性

  • 双向模式:支持双向数据模式,允许单条数据线用于发送和接收数据

让我们检查这个外设的关键寄存器。

关键 SPI 寄存器

要在 STM32F411 微控制器上启动 SPI,我们需要配置几个控制 SPI 外设各个方面的寄存器。让我们分解我们将要处理的主要寄存器,从控制寄存器 1 寄存器开始。

SPI 控制寄存器 1(SPI_CR1)

SPI_CR1 寄存器对于配置 SPI 外设至关重要。它包括定义SPI 模式数据格式时钟设置等设置的设置。此寄存器中的关键位包括以下内容:

  • CPHA 设置为 0 表示数据在第一个边缘(前沿)采样,而将其设置为 1 表示数据在第二个边缘(后沿)采样。

  • CPOL 设置为 0 表示空闲时时钟为低电平,将其设置为 1 表示空闲时时钟为高电平。

  • MSTR 设置为 1 使 SPI 外设成为主设备,而 0 将其设置为从设备。

  • 波特率控制(BR[2:0]):这些位配置 SPI 通信的波特率。

  • SPE 设置为 1 以激活 SPI 通信。

  • LSBFIRST 设置为 0 表示首先传输最低有效位(LSB)。

  • 内部 SS(SSI):此位在主模式下用于内部控制 SS 线。

  • 1 启用对 SS 线的软件管理,允许主设备手动控制。

接下来,我们有 SPI 状态寄存器。

SPI 状态寄存器(SPI_SR)

SPI_SR 寄存器提供 SPI 外设的实时状态更新,告知我们各种操作状态和标志。此寄存器中的关键位包括以下内容:

  • 接收缓冲区非空(RXNE):此标志指示接收缓冲区包含未读数据

  • 发送缓冲区为空(TXE):此标志表示发送缓冲区为空,准备好接收新数据

  • CRC 错误标志(CRCERR):当检测到 CRC 错误时,此标志被设置,表示可能的数据损坏

  • 模式故障(MODF):此标志表示模式故障,通常是由于主/从配置不正确引起的

  • 溢出标志(OVR):此标志指示溢出条件,即接收缓冲区没有及时读取

  • 忙标志(BSY):此标志指示 SPI 外设当前正在传输或接收中

最后一个关键寄存器是数据寄存器。

SPI 数据寄存器(SPI_DR)

SPI_DR寄存器是数据传输和接收的通道。它是我们写入要发送的数据和读取已接收数据的所在地:

  • SPI_DR寄存器,数据通过MOSI线发送出去

  • SPI_DR,你从MISO线上获取接收到的数据

考虑到这些寄存器,我们现在可以开发 SPI 驱动程序。让我们在下一节中跳入那里。

开发 SPI 驱动程序

在你的 IDE 中创建你之前项目的副本,并将此复制的项目重命名为SPI。接下来,在Src文件夹中创建一个名为spi.c的新文件,在Inc文件夹中创建一个名为spi.h的新文件。在你的spi.c文件中填充以下代码:

#include "spi.h" 
#define SPI1EN            (1U<<12)
#define GPIOAEN            (1U<<0)
#define SR_TXE            (1U<<1)
#define SR_RXNE            (1U<<0)
#define SR_BSY            (1U<<7)
void spi_gpio_init(void)
{
    /*Enable clock access to GPIOA*/
    RCC->AHB1ENR |= GPIOAEN;
    /*Set PA5,PA6,PA7 mode to alternate function*/
    /*PA5*/
    GPIOA->MODER &=~(1U<<10);
    GPIOA->MODER |=(1U<<11);
    /*PA6*/
    GPIOA->MODER &=~(1U<<12);
    GPIOA->MODER |=(1U<<13);
    /*PA7*/
    GPIOA->MODER &=~(1U<<14);
    GPIOA->MODER |=(1U<<15);
    /*Set PA9 as output pin*/
    GPIOA->MODER |=(1U<<18);
    GPIOA->MODER &=~(1U<<19);
    /*Set PA5,PA6,PA7 alternate function type to SPI1*/
    /*PA5*/
    GPIOA->AFR[0] |=(1U<<20);
    GPIOA->AFR[0] &= ~(1U<<21);
    GPIOA->AFR[0] |=(1U<<22);
    GPIOA->AFR[0] &= ~(1U<<23);
    /*PA6*/
    GPIOA->AFR[0] |=(1U<<24);
    GPIOA->AFR[0] &= ~(1U<<25);
    GPIOA->AFR[0] |=(1U<<26);
    GPIOA->AFR[0] &= ~(1U<<27);
    /*PA7*/
    GPIOA->AFR[0] |=(1U<<28);
    GPIOA->AFR[0] &= ~(1U<<29);
    GPIOA->AFR[0] |=(1U<<30);
    GPIOA->AFR[0] &= ~(1U<<31);
}

接下来,我们有配置 SPI 参数的函数:

void spi1_config(void)
{
    /*Enable clock access to SPI1 module*/
    RCC->APB2ENR |= SPI1EN;
    /*Set clock to fPCLK/4*/
    SPI1->CR1 |=(1U<<3);
    SPI1->CR1 &=~(1U<<4);
    SPI1->CR1 &=~(1U<<5);
    /*Set CPOL to 1 and CPHA to 1*/
    SPI1->CR1 |=(1U<<0);
    SPI1->CR1 |=(1U<<1);
    /*Enable full duplex*/
    SPI1->CR1 &=~(1U<<10);
    /*Set MSB first*/
    SPI1->CR1 &= ~(1U<<7);
    /*Set mode to MASTER*/
    SPI1->CR1 |= (1U<<2);
    /*Set 8 bit data mode*/
    SPI1->CR1 &= ~(1U<<11);
    /*Select software slave management by
     * setting SSM=1 and SSI=1*/
    SPI1->CR1 |= (1<<8);
    SPI1->CR1 |= (1<<9);
    /*Enable SPI module*/
    SPI1->CR1 |= (1<<6);
}
void spi1_transmit(uint8_t *data,uint32_t size)
{
    uint32_t i=0;
    uint8_t temp;
    while(i<size)
    {
        /*Wait until TXE is set*/
        while(!(SPI1->SR & (SR_TXE))){}
        /*Write the data to the data register*/
        SPI1->DR = data[i];
        i++;
    }
    /*Wait until TXE is set*/
    while(!(SPI1->SR & (SR_TXE))){}
    /*Wait for BUSY flag to reset*/
    while((SPI1->SR & (SR_BSY))){}
    /*Clear OVR flag*/
    temp = SPI1->DR;
    temp = SPI1->SR;
}

这是接收数据的函数:

void spi1_receive(uint8_t *data,uint32_t size)
{
    while(size)
    {
        /*Send dummy data*/
        SPI1->DR =0;
        /*Wait for RXNE flag to be set*/
        while(!(SPI1->SR & (SR_RXNE))){}
        /*Read data from data register*/
        *data++ = (SPI1->DR);
        size--;
    }
}

最后,我们有控制 CS 引脚的函数:

void cs_enable(void)
{
    GPIOA->ODR &=~(1U<<9);
}

然后,是取消选择从机的函数:

/*Pull high to disable*/
void cs_disable(void)
{
    GPIOA->ODR |=(1U<<9);
}

让我们逐一查看 SPI 初始化和通信代码的每个部分。我们首先查看定义的宏,然后深入到每个函数。

定义宏

让我们分解宏的含义及其功能:

#define SPI1EN      (1U<<12)
#define GPIOAEN     (1U<<0)
#define SR_TXE      (1U<<1)
#define SR_RXNE     (1U<<0)
#define SR_BSY      (1U<<7)

在这里,我们看到以下内容:

  • SPI1EN:定义为(1U<<12),这设置了位 12。它用于启用 SPI1 外设的时钟。

  • GPIOAEN:定义为(1U<<0),这设置了位 0。这启用了GPIOA的时钟。

  • SR_TXE:定义为(1U<<1)。这表示发送缓冲区为空。

  • SR_RXNE:定义为(1U<<0)。这表示接收缓冲区不为空。

  • SR_BSY:定义为(1U<<7)。这表示 SPI 接口正在忙于传输。

让我们分解初始化函数。

SPI 的 GPIO 初始化

让我们分析 SPI1 GPIO 引脚的配置:

RCC->AHB1ENR |= GPIOAEN;

这行代码通过在AHB1外设时钟使能寄存器中设置适当的位来启用GPIOA的时钟:

/*PA5*/
GPIOA->MODER &=~(1U<<10);
GPIOA->MODER |=(1U<<11);
/*PA6*/
GPIOA->MODER &=~(1U<<12);
GPIOA->MODER |=(1U<<13);
/*PA7*/
GPIOA->MODER &=~(1U<<14);
GPIOA->MODER |=(1U<<15);

这些行将PA5PA6PA7引脚配置为复用功能模式,这对于 SPI 是必要的:

GPIOA->MODER |= (1U<<18);
GPIOA->MODER &= ~(1U<<19);

这将PA9配置为通用输出引脚,它将被用作 SS:

/*PA5*/
GPIOA->AFR[0] |=(1U<<20);
GPIOA->AFR[0] &= ~(1U<<21);
GPIOA->AFR[0] |=(1U<<22);
GPIOA->AFR[0] &= ~(1U<<23);
/*PA6*/
GPIOA->AFR[0] |=(1U<<24);
GPIOA->AFR[0] &= ~(1U<<25);
GPIOA->AFR[0] |=(1U<<26);
GPIOA->AFR[0] &= ~(1U<<27);
/*PA7*/
GPIOA->AFR[0] |=(1U<<28);
GPIOA->AFR[0] &= ~(1U<<29);
GPIOA->AFR[0] |=(1U<<30);
GPIOA->AFR[0] &= ~(1U<<31);

这些行设置复用功能寄存器以配置PA5PA6PA7SPI1

SPI1 配置

接下来,我们有配置 SPI 参数的代码:

RCC->APB2ENR |= SPI1EN;

这行代码通过在 APB2 外设时钟使能寄存器中设置适当的位来启用 SPI1 的时钟:

SPI1->CR1 |=(1U<<3);
SPI1->CR1 &=~(1U<<4);
SPI1->CR1 &=~(1U<<5);

这些行配置 SPI 时钟预分频器,通过将 APB2 外设时钟除以 4 来设置波特率,因为 SPI1 连接到 APB2 总线。波特率由 001 结果决定,外设时钟被除以 4,这决定了数据在 SPI 总线上传输的速度:

SPI1->CR1 |=(1U<<0);
SPI1->CR1 |=(1U<<1);

这些行设置时钟极性和相位以确保正确的数据采样:

SPI1->CR1 &=~(1U<<10);

这行代码确保全双工模式被启用,以实现同时发送和接收:

SPI1->CR1 &= ~(1U<<7);

这行代码配置 SPI 以先发送 MSB:

SPI1->CR1 |= (1U<<2);

这行代码将 SPI1 设置为主模式,使其成为 SPI 总线的控制器:

SPI1->CR1 &= ~(1U<<11);

这行代码配置 SPI 数据帧大小为 8 位:

SPI1->CR1 |= (1<<8);
SPI1->CR1 |= (1<<9);

这些行启用 SS 线。

SPI1->CR1 |= (1<<6);

这行代码启用 SPI 外设以进行操作:

让我们继续到 spi1_transmit() 函数。

使用 SPI 传输数据

这段代码处理数据的发送:

while (!(SPI1->SR & (SR_TXE))) {}

这个循环等待发送缓冲区为空,然后发送下一个字节:

SPI1->DR = data[i];

这行代码发送当前字节的数据:

while ((SPI1->SR & (SR_BSY))) {}

这确保在继续之前 SPI 总线不忙:

temp = SPI1->DR;
temp = SPI1->SR;

这两行在管理 SPI 通信过程中起着至关重要的作用。在主设备通过 SPI 数据寄存器发送数据后,相同的寄存器捕获从设备发送的数据。为了确保传入的数据得到适当处理,我们读取数据寄存器,即使我们不需要该值。这个读取操作自动清除 OVR 标志。在此过程中读取状态寄存器也是建议的。

接下来,我们有 spi1_receive() 函数。

SPI 数据接收

这处理接收数据:

SPI1->DR = 0;

这行代码发送模拟数据以生成时钟脉冲:

while (!(SPI1->SR & (SR_RXNE))) {}

这行代码等待接收到数据:

*data++ = (SPI1->DR);

这行代码读取接收到的数据:

最后的函数是 cs_enable()cs_disable() 函数。

CS 管理

这行代码将 SS 线拉低以启用从设备:

GPIOA->ODR &= ~(1U << 9);

这行代码将 SS 线拉高以禁用从设备:

GPIOA->ODR |= (1U << 9);

我们下一个任务是填充 spi.h 文件。

头文件

这里是代码:

#ifndef SPI_H_
#define SPI_H_
#include "stm32f4xx.h"
#include <stdint.h>
void spi_gpio_init(void);
void spi1_config(void);
void spi1_transmit(uint8_t *data,uint32_t size);
void spi1_receive(uint8_t *data,uint32_t size);
void cs_enable(void);
void cs_disable(void);
#endif

在这里,我们只是公开这些函数,以便在其他文件中访问它们。

为了有效地测试 SPI 驱动器,我们需要一个合适的从设备。在下一节中,我们将深入探讨ADXL345 加速器,我们将使用它作为我们的从设备来测试 SPI 驱动器。

了解 ADXL345 加速器

ADXL345 是数字加速度计世界中的一颗明珠,非常适合测试我们的 SPI 模块。让我们深入了解是什么让这个设备如此特别,以及它是如何融入我们的嵌入式系统项目的。

什么是 ADXL345?

ADXL345 是一个小巧、轻薄、超低功耗的 13 位测量设备,具有可选的测量范围 ±2 g±4 g±8 g±16 g

图 12.3:ADXL345

图 12.3:ADXL345

让我们分析其关键特性。

ADXL345 的关键特性

以下是 ADXL345 的功能列表:

  • 超低功耗:在测量模式下,该设备消耗的电流仅为 23 µA,而在待机模式下仅为 0.1 µA,使其非常适合电池供电的应用。

  • 用户可选分辨率:我们可以从 10 位到 13 位中选择分辨率,提供所有 g 范围内的 4 mg/LSB 的比例因子。

  • 灵活的接口:ADXL345 支持 SPI(3 线和 4 线)和 I2C 数字接口,使我们能够灵活地将它集成到系统中。

  • 特殊传感功能:它包括单次轻击、双击和自由落体检测,以及活动/静止监测。这些功能可以单独映射到两个中断输出引脚,使其对物理事件反应极为灵敏。

  • 宽电源电压范围:它从 2.0 V 到 3.6 V 运行,适应各种电源配置。

  • 强大的性能:ADXL345 可以承受高达 10,000 g 的冲击,确保在恶劣应用中的耐用性。

让我们看看它的常见应用。

应用

由于其强大的功能集,ADXL345 非常适合各种应用:

  • 工业设备:用于机器监控和故障检测

  • 航空航天设备:在可靠性和精度至关重要的系统中

  • 消费电子产品:例如智能手机、游戏设备和可穿戴技术

  • 健康和运动:用于在健康监测设备中跟踪运动和活动

让我们更详细地看看其传感功能。

传感功能

在其核心,ADXL345 测量沿三个轴的加速度:xyz。数据以 16 位二进制补码格式提供,可以通过 SPI 或 I2C 接口访问。

以下是其传感功能:

  • 活动和静止监测:加速度计可以检测运动或其缺失,这使得它在睡眠监测和健身应用中非常出色

  • 轻击检测:它可以识别任何方向的单次和双次轻击,这对于基于手势的控制非常有用

  • 自由落体检测:该设备可以检测是否处于自由落体状态,这可以在安全系统中触发警报或响应

图 12.4显示了xyz轴:

图 12.4:x、y 和 z 轴

图 12.4:x、y 和 z 轴

ADXL345 还提供各种低功耗模式,以帮助智能管理功耗。这些模式允许设备根据我们定义的阈值和活动水平进入睡眠或待机状态。

它还包括一个 32 级FIFO 缓冲区,这有助于暂时存储数据以减轻主处理器的负载。此缓冲区在需要高数据吞吐量或处理器忙于其他任务的应用中特别有用。最后,其引脚排列简单明了:

  • VDD I/O:数字接口供电电压

  • GND:地

  • CS:SPI 通信的 CS

  • INT1 和 INT2:中断输出引脚

  • SDA/SDI/SDIO:I2C 或 SPI 输入的串行数据线

  • SCL/SCLK:I2C 或 SPI 的串行时钟线

在我们深入开发该从设备驱动程序之前,让我们首先探索一些加速度测量的关键概念。

理解关键概念——重力静态加速度、倾斜感应和动态加速度

当与 ADXL345 等加速度计一起工作时,掌握一些支撑其操作和应用的基本概念非常重要。让我们来分解一下静态重力加速度、倾斜感应和动态加速度的含义。

重力静态加速度

重力静态加速度指的是作用于静止物体的重力引起的恒定加速度。这种加速度始终存在,在地球表面上的大小约为9.8 米每秒平方(m/s²)

在 ADXL345 等加速度计的背景下,静态加速度用于确定设备的方向。当加速度计处于静止且水平放置时,它测量沿z轴的重力静态加速度,这有助于确定哪个方向是“向下”。这一能力对于以下应用至关重要:

  • 方向检测:确定设备相对于地球表面的方向

  • 倾斜感应:通过观察重力力在不同轴上的变化来测量设备的倾斜角度

下一个重要的概念是倾斜感应。

倾斜感应

倾斜感应是测量物体相对于重力力的倾斜角度的过程。这是通过分析加速度计的静态加速度读数来实现的。

想象一下手持平板电脑。当你向前、向后或向侧面倾斜它时,内部的加速度计会检测到其xyz轴上的静态加速度变化。通过比较这些变化,设备可以计算出倾斜角度。以下是它是如何工作的:

  • X 轴倾斜:如果设备沿x轴倾斜,检测到的x轴静态加速度将根据倾斜方向增加或减少。

  • Y 轴倾斜:同样,沿y轴倾斜将导致y轴静态加速度读数的变动。

  • Z 轴稳定性:当设备水平放置时,z轴通常检测到重力的全部力量。倾斜的变化会导致这种力量在x轴和y轴之间重新分配。

倾斜感应在以下应用中得到了广泛使用:

  • 屏幕方向:自动调整显示从纵向到横向模式

  • 游戏控制器:检测移动和倾斜以增强游戏体验

  • 工业设备:监测机械或车辆的倾斜以保障稳定性和安全性

最终的关键概念是动态加速度。

动态加速度

动态加速度是指由设备运动或外部力作用产生的加速度。与恒定的静态加速度不同,动态加速度根据设备如何移动而变化。

例如,如果你摇晃或移动加速度计,它会将这些变化测量为动态加速度。这种加速度对于以下方面至关重要:

  • 运动检测:识别设备何时被移动,这可以用于健身追踪器来计数步数

  • 冲击或振动感应:检测突然的冲击或振动,在碰撞检测系统或跌落测试中很有用

  • 振动监测:测量工业机械中的振动,以预测故障或维护需求

在结束本节之前,让我们再澄清一个我们之前介绍的概念:“g。”

当处理如 ADXL345 这样的加速度计时,你经常会遇到±2 g、±4 g、±8 g 或±16 g 这样的术语。这些术语对于理解设备的测量能力和限制至关重要。让我们分解一下 g 的含义以及这些范围如何影响加速度计的性能和应用。

什么是 g?

术语g指的是地球表面的重力加速度,大约为 9.8 米每秒平方m/s²)。它被用作加速度的单位。当我们说加速度计可以测量±2 g 时,这意味着它可以检测到沿轴方向重力两倍的加速度。

通过这一澄清,我们现在可以开始为 ADXL345 设备开发驱动程序。

开发 ADXL345 驱动程序

Src文件夹中创建一个名为adxl345.c的新文件,在Inc文件夹中创建一个名为adxl345.h的新文件。

头文件

将以下内容填充到adxl345.h文件中:

#ifndef ADXL345_H_
#define ADXL345_H_
#include "spi.h"
#include <stdint.h>
#define ADXL345_REG_DEVID                (0x00)
#define ADXL345_REG_DATA_FORMAT          (0x31)
#define ADXL345_REG_POWER_CTL            (0x2D)
#define ADXL345_REG_DATA_START           (0x32)
#define ADXL345_RANGE_4G                 (0x01)
#define ADXL345_RESET                    (0x00)
#define ADXL345_MEASURE_BIT              (0x08)
#define ADXL345_MULTI_BYTE_ENABLE        (0x40)
#define ADXL345_READ_OPERATION           (0x80)
void adxl_init (void);
void adxl_read(uint8_t address, uint8_t * rxdata);
adxl345.h file begins by including our SPI driver with #include "spi.h" and proceeds to define the necessary macros. Let’s break down the macros:

				*   `ADXL345_REG_DEVID (0x00)`: This macro defines the register address for the device ID of the ADXL345
				*   `ADXL345_REG_DATA_FORMAT (0x31)`: This macro defines the register address for setting the data format of the ADXL345
				*   `ADXL345_REG_POWER_CTL (0x2D)`: This macro defines the register address for the power control settings of the ADXL345
				*   `ADXL345_REG_DATA_START (0x32)`: This macro defines the starting register address for reading acceleration data from the ADXL345
				*   `ADXL345_RANGE_4G (0x01)`: This macro defines the value to set the measurement range of the ADXL345 to ±4g
				*   `ADXL345_RESET (0x00)`: This macro defines the reset value for certain registers
				*   `ADXL345_MEASURE_BIT (0x08)`: This macro defines the bit value to enable measurement mode in the power control register
				*   `ADXL345_MULTI_BYTE_ENABLE (0x40)`: This macro defines the bit to enable multi-byte operations
				*   `ADXL345_READ_OPERATION (0x80)`: This macro defines the bit to specify a read operation

			Next, we populate the `adxl345.c` file:

include "adxl345.h"

void adxl_read(uint8_t address, uint8_t * rxdata)

{

/设置读操作/

address |= ADXL345_READ_OPERATION;

/启用多字节模式/

address |= ADXL345_MULTI_BYTE_ENABLE;

/将 cs 线拉低以启用从设备/

cs_enable();

/发送地址/

spi1_transmit(&address,1);

/*读取 6 个字节 */

spi1_receive(rxdata,6);

/将 cs 线拉高以禁用从设备/

cs_disable();

}

void adxl_write (uint8_t address, uint8_t value)

{

uint8_t data[2];

/启用多字节,将地址放入缓冲区/

data[0] = address|ADXL345_MULTI_BYTE_ENABLE;

/将数据放入缓冲区/

data[1] = value;

/将 cs 线拉低以启用从设备/

cs_enable();

/传输数据和地址/

spi1_transmit(data, 2);

/将 cs 线拉高以禁用从设备/

cs_disable();

}

void adxl_init (void)

{

/启用 SPI gpio/

spi_gpio_init();

/配置 SPI/

spi1_config();

/设置数据格式范围为+-4g/

adxl_write (ADXL345_REG_DATA_FORMAT, ADXL345_RANGE_4G);

/重置所有位/

adxl_write (ADXL345_REG_POWER_CTL, ADXL345_RESET);

/配置电源控制测量位/

adxl_write (ADXL345_REG_POWER_CTL, ADXL345_MEASURE_BIT);

}


			Let’s analyze the functions line by line, starting with the `adxl_read()` function.
			Function – adxl_read()
			Let’s break down the read function:

				*   `address |= ADXL345_READ_OPERATION;`: This line sets the MSB of the address to indicate a read operation
				*   `address |= ADXL345_MULTI_BYTE_ENABLE;`: This sets the multi-byte bit to enable multi-byte operations
				*   `cs_enable();`: This function pulls the CS line low, enabling communication with the ADXL345
				*   `spi1_transmit(&address, 1);`: This transmits the address (with read and multi-byte bits set) to the ADXL345
				*   `spi1_receive(rxdata, 6);`: This line reads 6 bytes of data from the ADXL345 and stores it in the buffer pointed to by `rxdata`
				*   `cs_disable();`: This function pulls the CS line high, ending communication with the ADXL345

			Next, we have the `adxl_write()` function.
			Function – adxl_write
			Let’s go through each line of this function:

				*   `data[0] = address | ADXL345_MULTI_BYTE_ENABLE;`: This sets the multi-byte bit and stores the modified address in the buffer
				*   `data[1] = value;`: This stores the data to be written in the buffer
				*   `cs_enable();`: This function pulls the CS line low, enabling communication with the ADXL345
				*   `spi1_transmit(data, 2);`: This transmits the address and data to the ADXL345 in one transaction
				*   `cs_disable();`: This function pulls the CS line high, ending communication with the ADXL345

			Finally, we have the `adxl_init()` function.
			Function – adxl_init
			Let’s analyze the initialization function:

				*   `spi_gpio_init();`: This function initializes the GPIO pins needed for SPI communication
				*   `spi1_config();`: This function configures the SPI settings (clock speed, mode, etc.)
				*   `adxl_write(ADXL345_REG_DATA_FORMAT, ADXL345_RANGE_4G);`: This line writes to the data format register to set the measurement range of the ADXL345 to ±4g
				*   `adxl_write(ADXL345_REG_POWER_CTL, ADXL345_RESET);`: This line writes to the power control register to reset all bits
				*   `adxl_write(ADXL345_REG_POWER_CTL, ADXL345_MEASURE_BIT);`: This line writes to the power control register to set the measure bit, enabling measurement mode

			We are now ready to test the driver inside the `main.c` file. Update your `main.c` file as shown next:

include <stdio.h>

include <stdint.h>

include "stm32f4xx.h"

include "uart.h"

include "adxl345.h"

// 用于存储加速度计数据的变量

int16_t accel_x, accel_y, accel_z;

double accel_x_g, accel_y_g, accel_z_g;

uint8_t data_buffer[6];

int main(void)

{

uart_init();

// 初始化 ADXL345 加速度计

adxl_init();

while (1)

{

// 从数据起始位置读取加速度计数据

// 注册

adxl_read(ADXL345_REG_DATA_START, data_buffer);

// 将高字节和低字节合并形成加速度计数据

accel_x = (int16_t)((data_buffer[1] << 8) | data_buffer[0]);

accel_y = (int16_t)((data_buffer[3] << 8) | data_buffer[2]);

accel_z = (int16_t)((data_buffer[5] << 8) | data_buffer[4]);

// 将原始数据转换为 g 值

accel_x_g = accel_x * 0.0078;

accel_y_g = accel_y * 0.0078;

accel_z_g = accel_z * 0.0078;

// 打印值以进行调试目的

printf("accel_x : %d accel_y : %d  accel_z : %d\n\

r",accel_x,accel_y,accel_z);

}

return 0;

}


			Let’s break down the `main()` function:

				*   `accel_x, accel_y, accel_z`: These are variables to store the raw accelerometer data for each axis.
				*   `accel_x_g, accel_y_g, accel_z_g`: These are variables to store the converted accelerometer data in g units.
				*   `data_buffer[6]`: This is a buffer to hold the raw data bytes read from the ADXL345.
				*   `adxl_init()`: This initializes the ADXL345 accelerometer.
				*   `adxl_read(ADXL345_REG_DATA_START, data_buffer);`: This line reads data from the ADXL345 starting at the specified register (`ADXL345_REG_DATA_START`). The data is stored in `data_buffer`.

			Finally, we have the lines for constructing the final 16-bit values:

accel_x = (int16_t)((data_buffer[1] << 8) | data_buffer[0]);

accel_y = (int16_t)((data_buffer[3] << 8) | data_buffer[2]);

accel_z = (int16_t)((data_buffer[5] << 8) | data_buffer[4]);


			The data read from the ADXL345 is in 2 bytes (high and low) for each axis. These lines combine the bytes to form 16-bit values for each axis:

accel_x_g = accel_x * 0.0078;

accel_y_g = accel_y * 0.0078;

accel_z_g = accel_z * 0.0078;


			These lines convert the raw accelerometer values to g values:

printf("accel_x : %d accel_y : %d  accel_z : %d\n\r",accel_x,accel_y,accel_z);


			This line outputs the raw accelerometer data for debugging purposes:
			Now, let’s test the project. To test the project, compile the code and run it on your microcontroller. Open RealTerm or another serial terminal application and configure it with the appropriate port and baud rate to view the debug messages. Press the black pushbutton on the development board to reset the microcontroller. You should see the *x*, *y*, and *z* accelerometer values continuously being printed. Try moving the accelerometer to observe the values change significantly.
			Summary
			In this chapter, we explored the SPI protocol, a widely used communication protocol in embedded systems for efficient data transfer between microcontrollers and peripherals. We began by understanding the basic principles of SPI, including its master-slave architecture, data transfer modes, and typical use cases, emphasizing its advantages such as full-duplex communication and high-speed operation.
			Next, we examined the SPI peripheral in STM32F4 microcontrollers, focusing on critical registers such as SPI Control Register 1 (`SPI_CR1`), SPI Status Register (`SPI_SR`), and SPI Data Register (`SPI_DR`). We detailed how to configure these registers to set up the SPI peripheral for communication, covering important aspects such as **clock polarity** (**CPOL**) and **clock phase** (**CPHA**), data frame size, and master/slave configuration.
			We then applied this theoretical knowledge by developing a bare-metal SPI driver. The development process included initializing the SPI peripheral, implementing data transmission and reception functions, and handling CS management. We also integrated the SPI driver with an ADXL345 accelerometer, using SPI to communicate with the sensor and retrieve acceleration data. Finally, we tested the driver by reading and displaying the accelerometer data in real time.
			In the next chapter, we will explore the final of the three most common communication protocols in embedded systems: I2C.

第十三章:互集成电路 (I2C)

在本章中,我们将学习 互集成电路I2C)通信协议。我们将从探索 I2C 协议的基本原理开始,包括其操作模式、寻址方法和通信过程。然后,我们将检查 STM32 微控制器中 I2C 外设的关键寄存器,并将这些知识应用于开发裸机 I2C 驱动程序。

在本章中,我们将涵盖以下主要内容:

  • I2C 协议概述

  • STM32 I2C 外设

  • 开发 I2C 驱动程序

到本章结束时,你将牢固掌握 I2C 协议,并具备开发 I2C 硬件驱动程序所需的技能。

技术要求

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/Bare-Metal-Embedded-C-Programming

I2C 协议概述

I2C 是另一种常用协议。让我们探索它是什么,它的关键特性,它是如何工作的,以及它的数据格式。

什么是 I2C?

I2C 是由飞利浦半导体(现为恩智浦半导体)发明的 多主多从、分组交换、单端、串行通信总线。它旨在实现单个设备内部或同一板上的多个设备之间的短距离通信。I2C 以其简单易用而闻名,使其成为微控制器和其他集成电路之间通信的流行选择。让我们看看它的关键特性。

I2C 的关键特性

I2C 具有许多独特特性,使其成为嵌入式系统各种应用的理想选择:

  • 双线接口:I2C 只使用两条线,串行数据SDA)和串行时钟SCL),这简化了布线并减少了微控制器上所需的引脚数量。

  • 多主多从:多个主设备可以在总线上发起通信,多个从设备可以响应。这种灵活性允许复杂的通信设置。I2C 协议支持最多 128 个设备,使用 7 位寻址,但由于保留地址,实际限制为 119。使用 10 位寻址,该协议理论上允许 1,024 个设备,但同样,保留地址略微减少了实际最大值。10 位模式虽然不太常见,但允许同一总线上有更多的设备。

  • 可寻址设备:I2C 总线上的每个设备都有一个唯一的地址,使主设备能够与特定的从设备通信。

  • 同步通信:SCL 线提供时钟信号,确保设备间数据同步传输。

  • 速度变体:I2C 支持各种速度模式,包括标准模式(100 kHz)、快速模式(400 kHz)、快速模式+(1 MHz)和高速模式(3.4 MHz),以满足不同的速度要求。

  • 简单且低成本:该协议的简单性和最小硬件要求使其具有成本效益且易于实现。

让我们看看 I2C 接口。

I2C 接口

I2C 接口由两条主要线路组成:

  • 串行数据(SDA):这条线携带设备之间传输的数据。它是一个双向线,意味着主设备和从设备都可以发送和接收数据。

  • 串行时钟(SCL):这条线携带由主设备生成的时钟信号。它同步主设备和从设备之间的数据传输。

图 13.1:I2C 接口 – 多个从设备

图 13.1:I2C 接口 – 多个从设备

这两条线连接到总线上所有的设备,通过上拉电阻确保在空闲状态下这些线被拉到高电平。让我们看看它是如何工作的。

I2C 的工作原理

理解 I2C 的工作原理涉及查看主设备和从设备的角色寻址方案通信过程

以下是其角色和寻址方案:

  • 主设备:主设备启动通信并生成时钟信号。它控制数据流,并能寻址多个从设备。

  • 从设备:从设备响应主设备的命令并执行请求的操作。每个从设备都有一个唯一的7 位10 位地址,主设备使用该地址来识别它。

通信过程如下:

  1. 起始条件:通信从主设备生成起始条件开始。这涉及到在 SCL 线为高电平时将 SDA 线拉低。

  2. 地址帧:主设备发送目标从设备的地址,后面跟着一个读写位,表示操作类型(0 为写,1 为读)。

  3. 确认(ACK)位:被寻址的从设备在下一个时钟脉冲期间通过将 SDA 线拉低来响应 ACK 位。

  4. 数据帧:数据以 8 位帧的形式传输。每个字节后面跟随接收方的 ACK 位。

  5. 停止条件:主设备通过生成停止条件来结束通信,这涉及到在 SCL 线为高电平时将 SDA 线拉高。

图 13.2:I2C 数据包

图 13.2:I2C 数据包

在我们进入下一节之前,让我们花一点时间来谈谈 I2C 数据传输,用一个例子来说明。

让我们先回顾一下数据帧和起始条件的作用:

  • 数据帧:数据以8 位字节的形式传输。在每个字节之后,接收方发送一个 ACK 位以确认成功接收。

  • 重复起始条件:如果主设备需要与另一个从设备通信或在不释放总线的情况下继续通信,它可以生成重复起始条件而不是停止条件。

让我们看看数据传输:

  • 写操作:主设备发送一个起始条件,带有写位地址帧数据帧。每个数据字节后面跟随从设备的 ACK 位。

  • 读操作:主设备发送一个起始条件,带有读位的地址帧,然后从从设备读取数据帧。每个数据字节由主设备通过 ACK 位确认,除了最后一个字节,它后面跟着一个NACK,表示读操作结束。

为了更好地理解,让我们分析图 13.313.6,从起始和停止条件开始。

图 13.3:起始条件

图 13.3:起始条件

以下为停止条件:

图 13.4:停止条件

图 13.4:停止条件

起始条件发生在主设备将 SDA 线拉低,而 SCL 线保持高电平时。这个序列向 I2C 总线上的所有设备发出信号,表明即将开始一个通信会话,允许主设备为其预期操作占用总线。如果没有有效的起始条件,I²C 通信无法开始。

相反,停止条件标志着通信的结束。主设备将 SDA 线释放到高电平状态,同时 SCL 线保持高电平,这表明通信会话已完成,总线现在可供其他设备使用。正确使用停止条件对于确保没有设备在总线上保持活跃至关重要,这可能导致冲突或通信错误。

接下来,让我们看看 I2C 协议如何区分 0 和 1。

图 13.5:数据传输过程

图 13.5:数据传输过程

图 13.5说明了数据传输过程。数据是逐位发送的,与 SCL 线上的时钟脉冲同步。如图所示,每个数据位在 SCL 线低电平时放置在 SDA 线上。当 SCL 线切换到高电平时,接收设备读取 SDA 线的状态。这个特定的图显示了1的传输,随后是0

最后,让我们检查完整的包以及它与 SDA 和 SCL 线的交互。

图 13.6:完整的包

图 13.6:完整的包

图 13.6提供了一个完整的 I²C 通信包的全面视图,展示了在整个事务过程中 SDA 和 SCL 线之间的关系。通信从起始条件开始,此时 SDA 线被拉低,而 SCL 线保持高电平,标志着新通信序列的启动。

在启动条件之后,传输地址帧。该帧包含目标设备的 7 位地址,后面跟着读/写R/W)位,表示主设备是否打算从从设备读取或写入。然后从设备通过 ACK 位确认地址帧,确认它已准备好进行通信。

在地址帧之后,传输数据帧。数据以 8 位字节的形式发送,每个位被放置在 SDA 线上,而 SCL 线同步地时钟每个位。在每个数据字节之后,接收设备会响应另一个 ACK 位,确保数据被正确接收。

通信以停止条件结束,此时 SDA 线被释放以变高,而 SCL 线也处于高电平。这表示通信会话结束,为其他潜在的通信释放总线。从开始到停止的整个周期构成了 I²C 协议中数据交换的骨架,确保设备之间结构化和可靠的通信。

这就结束了我们对 I2C 协议的概述。在下一节中,我们将分析 STM32F4 微控制器中的 I2C 外设。

STM32F4 I2C 外设

根据您正在使用的 STM32F4 的具体型号,通常可以找到最多三个标记为 I2C1、I2C2 和 I2C3 的 I2C 外设。这些外设使微控制器能够通过标准双线接口与 I2C 兼容的设备进行通信。

STM32F4 微控制器中的 I2C 外设集成了许多功能,增强了它们的通用性和性能:

  • 多主和多从功能:每个 I2C 外设都可以作为主设备和从设备运行,支持多个主配置,其中多个主设备可以控制总线

  • 标准、快速和快速模式+:外设支持多种速度模式,包括标准模式(100 kHz)、快速模式(400 kHz)和快速模式+(1 MHz),这为通信速度提供了灵活性

  • 10 位寻址:除了标准的7 位寻址外,I2C 外设还支持10 位寻址,这使得与更广泛的设备进行通信成为可能

  • 双寻址模式:每个 I2C 外设都可以配置为响应两个不同的地址,这对于复杂的多个设备设置非常有用

  • DMA 支持直接内存访问DMA)支持可用,允许在没有 CPU 干预的情况下进行高效的数据传输

让我们来看看这个外设的关键寄存器。

关键的 I2C 寄存器

在 STM32 微控制器上配置 I2C 外设涉及几个关键寄存器,这些寄存器控制其操作的各个方面。

每个寄存器都有需要正确设置的特定位,以确保正常功能。让我们分解我们将要使用的主要寄存器,从控制寄存器 1开始。

I2C 控制寄存器 1 (I2C_CR1)

I2C_CR1 是用于配置 I2C 外设基本操作设置的几个主要控制寄存器之一。它提供了启用外设、管理起始和停止条件以及控制应答功能的选择。

此寄存器中的关键位包括以下内容:

  • 外设使能(PE):此位启用或禁用 I2C 外设。将此位设置为 1 打开 I2C 外设,而清除它则关闭。

  • START 条件,启动通信。

  • STOP 条件,终止通信。

  • 应答使能(ACK):当设置时,此位在每个字节接收后启用 ACK。

  • 应答/PEC 位置(POS):此位控制 ACK 位的位位置。

  • 软件复位(SWRST):设置此位将重置 I2C 外设。

您可以在 STM32F4 参考手册(RM0383)的第 492 页找到有关此寄存器的详细信息。接下来,让我们看看 I2C 控制 寄存器 2

I2C 控制寄存器 2(I2C_CR2)

I2C_CR2 是另一个关键控制寄存器,用于处理 I2C 操作的不同方面,包括时钟频率中断使能和DMA控制。此寄存器中的关键位包括以下内容:

  • FREQ[5:0](外设时钟频率):这些位设置 I2C 外设时钟频率,以 MHz 为单位

  • DMAEN(DMA 请求使能):当设置时,此位启用 I2C 外设的 DMA 请求

您可以在 STM32F4 参考手册(RM0383)的第 494 页找到有关此寄存器的详细信息。接下来,让我们看看 I2C 时钟 控制寄存器

I2C 时钟控制寄存器(I2C_CCR)

I2C_CCR 配置标准、快速和快速模式加操作的时钟控制设置。此寄存器中的关键位包括以下内容:

  • CCR[11:0](时钟控制):这些位设置时钟控制值,确定 I2C 时钟速度

  • DUTY(快速模式占空比):此位选择快速模式的占空比

  • F/S(I2C 主模式选择):此位在标准模式(0)和快速模式(1)之间进行选择

您可以在 STM32F4 参考手册(RM0383)的第 502 页找到有关此寄存器的详细信息。下一个寄存器是 I2C 上升时间寄存器。

I2C TRISE 寄存器(I2C_TRISE)

I2C_TRISE 配置 I2C 信号的上升时间最大值,确保符合 I2C 规范。此寄存器只有一个字段 – TRISE[5:0](最大上升时间)。这些位设置 SDA 和 SCL 信号的上升时间最大值,以纳秒为单位。

最终寄存器是 I2C 数据寄存器

I2C 数据寄存器(I2C_DR)

I2C_DR 是用于发送接收数据的寄存器。写入此寄存器的数据将被发送,而从总线接收到的数据将存储在此寄存器中。此寄存器只有一个字段 – DR[7:0](8 位数据寄存器):此寄存器保存要发送的 8 位数据或从总线接收到的数据。

考虑到这些寄存器,我们现在可以开始开发 I2C 驱动程序。让我们在下一节中这样做。

开发 I2C 驱动程序

让我们开发 I2C 驱动程序。在您的 IDE 中复制您之前的项目,并将复制的项目重命名为 I2C。接下来,在 Src 文件夹中创建一个名为 i2c.c 的新文件,并在 Inc 文件夹中创建一个名为 i2c.h 的新文件。

初始化函数

让我们填充 i2c.c 文件,从宏和初始化函数开始:

#include "stm32f4xx.h"
#define     GPIOBEN            (1U<<1)
#define     I2C1EN            (1U<<21)
#define     I2C_100KHZ            80
#define     SD_MODE_MAX_RISE_TIME    17
#define    CR1_PE            (1U<<0)
#define    SR2_BUSY            (1U<<1)
#define    CR1_START            (1U<<8)
#define    SR1_SB            (1U<<0)
#define    SR1_ADDR            (1U<<1)
#define    SR1_TXE            (1U<<7)
#define    CR1_ACK            (1U<<10)
#define    CR1_STOP            (1U<<9)
#define    SR1_RXNE            (1U<<6)
#define    SR1_BTF            (1U<<2)
/*
 * PB8 ---- SCL
 * PB9 ----- SDA
 * */
void i2c1_init(void)
{
    /*Enable clock access to GPIOB*/
     RCC->AHB1ENR |=GPIOBEN;
    /*Set PB8 and PB9 mode to alternate function*/
    GPIOB->MODER &=~(1U<<16);
    GPIOB->MODER |=(1U<<17);
    GPIOB->MODER &=~(1U<<18);
    GPIOB->MODER |=(1U<<19);
    /*Set PB8 and PB9 output type to  open drain*/
    GPIOB->OTYPER |=(1U<<8);
    GPIOB->OTYPER |=(1U<<9);
    /*Enable Pull-up for PB8 and PB9*/
    GPIOB->PUPDR |=(1U<<16);
    GPIOB->PUPDR &=~(1U<<17);
    GPIOB->PUPDR |=(1U<<18);
    GPIOB->PUPDR &=~(1U<<19);
    /*Set PB8 and PB9 alternate function type to I2C (AF4)*/
    GPIOB->AFR[1] &=~(1U<<0);
    GPIOB->AFR[1] &=~(1U<<1);
    GPIOB->AFR[1] |=(1U<<2);
    GPIOB->AFR[1] &=~(1U<<3);
    GPIOB->AFR[1] &=~(1U<<4);
    GPIOB->AFR[1] &=~(1U<<5);
    GPIOB->AFR[1] |=(1U<<6);
    GPIOB->AFR[1] &=~(1U<<7);
    /*Enable clock access to I2C1*/
     RCC->APB1ENR |= I2C1EN;
    /*Enter reset mode  */
    I2C1->CR1 |= (1U<<15);
    /*Come out of reset mode  */
    I2C1->CR1 &=~(1U<<15);
    /*Set Peripheral clock frequency*/
    I2C1->CR2 = (1U<<4);   //16 Mhz
    /*Set I2C to standard mode, 100kHz clock */
    I2C1->CCR = I2C_100KHZ;
    /*Set rise time */
    I2C1->TRISE = SD_MODE_MAX_RISE_TIME;
    /*Enable I2C1 module */
    I2C1->CR1 |= CR1_PE;
}

让我们分解到目前为止的内容:

RCC->AHB1ENR |= GPIOBEN;

这行代码通过设置相应的位启用了 GPIOB 的时钟。

GPIOB->MODER &=~(1U<<16);
GPIOB->MODER |=(1U<<17);
GPIOB->MODER &=~(1U<<18);
GPIOB->MODER |=(1U<<19);

这些行将 PB8PB9 引脚配置为 I2C 的备用功能模式。

GPIOB->OTYPER |=(1U<<8);
GPIOB->OTYPER |=(1U<<9);

这些行将引脚配置为开漏,这是 I2C 通信所必需的。

GPIOB->PUPDR |=(1U<<16);
GPIOB->PUPDR &=~(1U<<17);
GPIOB->PUPDR |=(1U<<18);
GPIOB->PUPDR &=~(1U<<19);

这些行启用了 I2C 引脚的上拉电阻

GPIOB->AFR[1] &=~(1U<<0);
GPIOB->AFR[1] &=~(1U<<1);
GPIOB->AFR[1] |=(1U<<2);
GPIOB->AFR[1] &=~(1U<<3);
GPIOB->AFR[1] &=~(1U<<4);
GPIOB->AFR[1] &=~(1U<<5);
GPIOB->AFR[1] |=(1U<<6);
GPIOB->AFR[1] &=~(1U<<7);

这些行配置了 I2C1

 RCC->APB1ENR |= I2C1EN;

这行代码启用了 I2C1 外设的时钟。

I2C1->CR1 |= (1U<<15);
I2C1->CR1 &=~(1U<<15);

这些行重置了 I2C1 外设。

I2C1->CR2 = (1U<<4);

这配置了 I2C1 时钟。

 I2C1->CCR = I2C_100KHZ;

这行代码使用我们定义的宏设置时钟控制寄存器,以 100 kHz 标准模式设置时钟。

I2C1->TRISE = SD_MODE_MAX_RISE_TIME;

这行代码使用我们定义的宏设置 I2C 信号的上升时间。TRISE 寄存器指定信号在 I2C 总线上从低电平到高电平状态的最大转换时间。正确设置此值对于确保 I2C 通信符合 I2C 标准的时序要求非常重要,这有助于保持可靠和稳定的通信。

I2C1->CR1 |= CR1_PE;

这行代码通过设置 PE 位启用了 I2C1 外设。接下来,我们将添加并分析从 I2C 从设备读取字节的函数。

读取函数

让我们分析 read 函数:

void i2c1_byte_read(char saddr, char maddr, char* data) {
      volatile int tmp;
      /* Wait until bus not busy */
      while (I2C1->SR2 & (SR2_BUSY)){}
      /* Generate start */
      I2C1->CR1 |= CR1_START;
      /* Wait until start flag is set */
      while (!(I2C1->SR1 & (SR1_SB))){}
      /* Transmit slave address + Write */
      I2C1->DR = saddr << 1;
      /* Wait until addr flag is set */
      while (!(I2C1->SR1 & (SR1_ADDR))){}
      /* Clear addr flag */
      tmp = I2C1->SR2;
      /* Send memory address */
      I2C1->DR = maddr;
      /*Wait until transmitter empty */
      while (!(I2C1->SR1 & SR1_TXE)){}
     /*Generate restart */
     I2C1->CR1 |= CR1_START;
      /* Wait until start flag is set */
      while (!(I2C1->SR1 & SR1_SB)){}
     /* Transmit slave address + Read */
     I2C1->DR = saddr << 1 | 1;
     /* Wait until addr flag is set */
     while (!(I2C1->SR1 & (SR1_ADDR))){}
    /* Disable Acknowledge */
    I2C1->CR1 &= ~CR1_ACK;
    /* Clear addr flag */
    tmp = I2C1->SR2;
    /* Generate stop after data received */
    I2C1->CR1 |= CR1_STOP;
    /* Wait until RXNE flag is set */
    while (!(I2C1->SR1 & SR1_RXNE)){}
    /* Read data from DR */
      *data++ = I2C1->DR;
}

让我们分解到目前为止的内容:

  • while (I2C1->SR2 & (SR2_BUSY)){}: 这行代码通过检查 I2C 状态寄存器 2 中的 BUSY 位状态,等待 I2C 总线空闲。

  • I2C1->CR1 |= CR1_START;: 这行代码在 I2C 总线上启动一个起始条件。

  • while (!(I2C1->SR1 & (SR1_SB))){}: 这行代码通过检查 I2C 状态寄存器 1 中的 SB 位,等待起始条件被确认。

  • I2C1->DR = saddr << 1;: 这行代码发送带有写位的从设备地址。设备的 7 位地址向左移动 1 位,为 saddr 中保留的 R/W 位腾出空间,我们为从设备后续的写操作准备地址。

  • while (!(I2C1->SR1 & (SR1_ADDR))){}: 这行代码通过检查 I2C 状态寄存器 1 中的 ADDR 位,等待地址被确认。

  • tmp = I2C1->SR2;: 这行代码通过简单地读取 I2C 状态寄存器 2 来清除地址标志。

  • I2C1->DR = maddr;: 这里,我们向从设备发送要读取的内存地址。

  • while (!(I2C1->SR1 & SR1_TXE)){}: 这行代码通过读取 I2C 状态寄存器 1 中的传输缓冲区空 (TXE) 位,等待数据寄存器为空。

  • I2C1->CR1 |= CR1_START;: 这行代码在 I2C 总线上启动一个重启动条件

  • while (!(I2C1->SR1 & SR1_SB)){}:在这里,我们等待直到重启动条件被 I2C 状态寄存器 1 中的SB位确认。

  • I2C1->DR = saddr << 1 | 1;:这一行通过设置从设备的 7 位 I2C 地址和附加 R/W 位来准备 I2C 数据寄存器以进行读取操作。具体来说,saddr << 1将 7 位地址左移一位以留出 LSB 的空间,然后使用位或运算符(| 1)将其设置为 1。这个最终值,LSB 设置为 1,当加载到 I2C1 数据寄存器DR)时,表示读取操作。因此,这一行配置 I2C 外设与从设备通信,请求从它读取数据。

  • while (!(I2C1->SR1 & (SR1_ADDR))){}:这一行等待直到地址被确认。

  • I2C1->CR1 &= ~CR1_ACK;:这一行禁用应答位以准备停止条件。

  • tmp = I2C1->SR2;:这一行通过读取 I2C 状态寄存器 2 来清除地址标志。

  • I2C1->CR1 |= CR1_STOP;:这一行在 I2C 总线上启动停止条件。

  • while (!(I2C1->SR1 & SR1_RXNE)){}:这一行等待直到接收缓冲区不为空,通过读取 I2C 状态寄存器 1 中的RXNE标志。此标志表示已接收到新数据,并且数据已准备好在数据寄存器中。

  • *data++ = I2C1->DR;:这一行负责将接收到的数据字节从 I2C 数据寄存器(DR)存储到由data指针指向的内存位置。

接下来,我们有一个从从设备读取多个字节的函数:

void i2c1_burst_read(char saddr, char maddr, int n, char* data) {
    volatile int tmp;
     /* Wait until bus not busy */
     while (I2C1->SR2 & (SR2_BUSY)){}
     /* Generate start */
     I2C1->CR1 |= CR1_START;
     /* Wait until start flag is set */
     while (!(I2C1->SR1 & SR1_SB)){}
     /* Transmit slave address + Write */
     I2C1->DR = saddr << 1;
     /* Wait until addr flag is set */
     while (!(I2C1->SR1 & SR1_ADDR)){}
    /* Clear addr flag */
     tmp = I2C1->SR2;
    /* Wait until transmitter empty */
    while (!(I2C1->SR1 & SR1_TXE)){}
    /*Send memory address */
    I2C1->DR = maddr;
    /*Wait until transmitter empty */
    while (!(I2C1->SR1 & SR1_TXE)){}
    /*Generate restart */
    I2C1->CR1 |= CR1_START;
    /* Wait until start flag is set */
    while (!(I2C1->SR1 & SR1_SB)){}
    /* Transmit slave address + Read */
    I2C1->DR = saddr << 1 | 1;
    /* Wait until addr flag is set */
    while (!(I2C1->SR1 & (SR1_ADDR))){}
    /* Clear addr flag */
    tmp = I2C1->SR2;
    /* Enable Acknowledge */
      I2C1->CR1 |=  CR1_ACK;
    while(n > 0U)
    {
        /*if one byte*/
        if(n == 1U)
        {
            /* Disable Acknowledge */
            I2C1->CR1 &= ~CR1_ACK;
            /* Generate Stop */
            I2C1->CR1 |= CR1_STOP;
            /* Wait for RXNE flag set */
            while (!(I2C1->SR1 & SR1_RXNE)){}
            /* Read data from DR */
            *data++ = I2C1->DR;
            break;
        }
        else
        {
           /* Wait until RXNE flag is set */
           while (!(I2C1->SR1 & SR1_RXNE)){}
           /* Read data from DR */
           (*data++) = I2C1->DR;
           n--;
        }
    }
}

此函数从 I2C 从设备指定的内存地址读取多个字节数据。以下是它的操作分解:

  1. 等待总线可用:该函数首先确保 I2C 总线不忙,等待直到它空闲以启动通信。

  2. 生成启动条件:它生成启动条件以开始与从设备的通信。

  3. 发送从设备地址用于写入:该函数发送带有写入位的从设备地址,表示它将首先写入数据以指定内存地址。

  4. 等待地址标志并清除它:它等待地址标志被设置,然后通过读取 SR2 寄存器来清除它。

  5. 发送内存地址:将要从其中开始读取的内存地址发送到从设备。

  6. 生成重启动条件:生成重复启动条件以将通信模式从写入切换到读取。

  7. 发送从设备地址用于读取:该函数发送带有读取位的从设备地址,表示它将从从设备读取数据。

  8. 等待地址标志并清除它:再次,它等待地址标志被设置,并通过读取 SR2 寄存器来清除它。

  9. 启用应答:设置应答位。

  10. RXNE标志表示数据已准备好,将数据读入缓冲区,并递减字节数计数器

最后,我们将函数添加到向从设备写入数据。

写入函数

让我们分解写入从设备多个字节的函数:

void i2c1_burst_write(char saddr, char maddr, int n, char* data) {
    volatile int tmp;
    /* Wait until bus not busy */
    while (I2C1->SR2 & (SR2_BUSY)){}
    /* Generate start */
    I2C1->CR1 |= CR1_START;
    /* Wait until start flag is set */
    while (!(I2C1->SR1 & (SR1_SB))){}
    /* Transmit slave address */
    I2C1->DR = saddr << 1;
    /* Wait until addr flag is set */
    while (!(I2C1->SR1 & (SR1_ADDR))){}
    /* Clear addr flag */
    tmp = I2C1->SR2;
    /* Wait until data register empty */
    while (!(I2C1->SR1 & (SR1_TXE))){}
    /* Send memory address */
    I2C1->DR = maddr;
    for (int i = 0; i < n; i++) {
     /* Wait until data register empty */
        while (!(I2C1->SR1 & (SR1_TXE))){}
      /* Transmit memory address */
      I2C1->DR = *data++;
    }
    /* Wait until transfer finished */
    while (!(I2C1->SR1 & (SR1_BTF))){}
    /* Generate stop */
    I2C1->CR1 |= CR1_STOP;
}

此函数将 多个字节 的数据写入 I2C 从设备中的特定内存地址。函数首先等待 I2C 总线空闲,确保没有正在进行的通信。然后生成一个起始条件以与从设备开始通信。通过写入位传输从设备地址,然后函数等待通过读取 SR2 寄存器设置和清除地址标志。在确保数据寄存器为空后,它发送数据写入应开始的数据地址。函数进入一个循环以传输每个字节数据,在每个字节发送之前等待数据寄存器为空。一旦所有字节都已传输,它等待字节传输完成,然后生成一个停止条件以结束通信。

我们下一个任务是填充 i2c.h 文件。

头文件

下面是头文件中的代码:

#ifndef I2C_H_
#define I2C_H_
void i2c1_init(void);
void i2c1_byte_read(char saddr, char maddr, char* data);
void i2c1_burst_read(char saddr, char maddr, int n, char* data);
void i2c1_burst_write(char saddr, char maddr, int n, char* data);
#endif

让我们更新 adxl345 设备的驱动程序,以使用我们开发的 I2C 驱动程序。

ADXL345 I2C 驱动程序

更新 Src 文件夹中的当前 adxl345.c

#include "adxl345.h"
// Variable to store single byte of data
char data;
// Buffer to store multiple bytes of data from the ADXL345
uint8_t data_buffer[6];
void adxl_read_address (uint8_t reg)
{
     i2c1_byte_read( ADXL345_DEVICE_ADDR, reg, &data);
}
void adxl_write (uint8_t reg, char value)
{
    char data[1];
    data[0] = value;
    i2c1_burst_write( ADXL345_DEVICE_ADDR, reg,1, data) ;
}
void adxl_read_values (uint8_t reg)
{
    // Read 6 bytes into wthe data buffer
    i2c1_burst_read(ADXL345_DEVICE_ADDR, reg, 6,(char *)data_buffer);
}
void adxl_init (void)
{
    /*Enable I2C*/
    i2c1_init();
    /*Read the DEVID, this should return 0xE5*/
    adxl_read_address(ADXL345_REG_DEVID);
    /*Set data format range to +-4g*/
    adxl_write (ADXL345_REG_DATA_FORMAT, ADXL345_RANGE_4G);
    /*Reset all bits*/
    adxl_write (ADXL345_REG_POWER_CTL, ADXL345_RESET);
    /*Configure power control measure bit*/
    adxl_write (ADXL345_REG_POWER_CTL, ADXL345_MEASURE_BIT);
}

下面是代码的分解:

  • adxl_read_address 函数从 ADXL345 加速度计的指定寄存器读取单个字节数据。它使用 i2c1_byte_read 函数通过 I2C 总线通信,从由 reg 参数指定的寄存器获取数据并将其存储在 data 变量中。

  • adxl_write 函数将单个字节数据写入 ADXL345 的特定寄存器。它准备一个包含要写入值的单元素数组,然后使用 i2c1_burst_write 将这些数据发送到由 reg 参数指定的寄存器,通过 I2C 接口。

  • adxl_read_values 函数从 ADXL345 读取数据块 - 特别是使用 i2c1_burst_read 读取这些数据,从由 reg 参数指定的寄存器开始,并将其存储在 data_buffer 中以供进一步处理。

  • adxl_init 函数初始化 ADXL345 加速度计。它首先通过调用 i2c1_init 启用 I2C 通信,然后通过读取 DEVID 寄存器检查设备的身份。在此之后,它将数据格式配置为 ±4g 范围,重置电源控制寄存器,并最终将电源控制寄存器设置为开始测量加速度。

接下来,我们更新 Inc 文件夹中的当前 adxl345.h

#ifndef ADXL345_H_
#define ADXL345_H_
#include "i2c.h"
#include <stdint.h>
#define   ADXL345_REG_DEVID                (0x00)
#define   ADXL345_DEVICE_ADDR            (0x53)
#define   ADXL345_REG_DATA_FORMAT           (0x31)
#define   ADXL345_REG_POWER_CTL             (0x2D)
#define   ADXL345_REG_DATA_START            (0x32)
#define   ADXL345_REG_DATA_FORMAT           (0x31)
#define       ADXL345_RANGE_4G                (0x01)
#define       ADXL345_RESET                    (0x00)
#define    ADXL345_MEASURE_BIT                    (0x08)
void adxl_init(void);
void adxl_read_values(uint8_t reg);
main.c file.
			The main function
			Update your `main.c` file, as shown here:

include <stdio.h>

include <stdint.h>

include "stm32f4xx.h"

include "uart.h"

include "adxl345.h"

// 用于存储加速度计数据的变量

int16_t accel_x, accel_y, accel_z;

double accel_x_g, accel_y_g, accel_z_g;

extern uint8_t data_buffer[6];

int main(void)

{

uart_init();

// 初始化 ADXL345 加速度计

adxl_init();

while (1)

{

// 从数据起始地址读取加速度计数据

// 寄存器

adxl_read_values(ADXL345_REG_DATA_START);

// 将高字节和低字节合并形成加速度计数据

accel_x = (int16_t)((data_buffer[1] << 8) | data_buffer[0]);

accel_y = (int16_t)((data_buffer[3] << 8) | data_buffer[2]);

accel_z = (int16_t)((data_buffer[5] << 8) | data_buffer[4]);

// 将原始数据转换为 g 值

accel_x_g = accel_x * 0.0078;

accel_y_g = accel_y * 0.0078;

accel_z_g = accel_z * 0.0078;

printf("accel_x : %d accel_y : %d  accel_z : %d\n\

r",accel_x,accel_y,accel_z);

}

return 0;

}


			It’s time to test the project. To do so, compile the code and run it on your microcontroller. Open RealTerm or another serial terminal application, and then configure it with the appropriate port and baud rate to view the debug messages. Press the black push button on the development board to reset the microcontroller. You should see the *X*, *Y*, and *Z* accelerometer values continuously being printed. Try moving the accelerometer to see how the values change significantly.
			Summary
			In this chapter, we learned about the I2C communication protocol. We began by discussing the fundamental principles of the I2C protocol, including its modes of operation, addressing schemes, and the step-by-step communication process.
			We then delved into the specifics of the STM32 I2C peripheral, highlighting key registers such as Control Register 1 (**I2C_CR1**), Control Register 2 (**I2C_CR2**), the Clock Control Register (**I2C_CCR**), and the Data Register (**I2C_DR**).
			Finally, we applied this theoretical knowledge to develop a bare-metal I2C driver. This driver allows us to initialize the I2C peripheral, perform both single-byte and burst data transfers, and handle communication with an external device such as the ADXL345 accelerometer.
			In the next chapter, we will learn about interrupts, a critical feature in modern microcontrollers that enables responsive and efficient handling of real-time events.

第十四章:外部中断和事件(EXTI)

在本章中,我们将学习关于中断及其在嵌入式系统开发中的关键作用。中断对于创建响应迅速且高效的固件至关重要,它允许微控制器有效地处理实时事件。通过理解中断,你可以开发出能够迅速对外部刺激做出反应的系统,使你的嵌入式应用更加健壮和多功能。

我们将首先探讨中断在固件中的基本作用,通过与异常进行对比来突出它们独特的目的和处理机制。随后,我们将深入研究中断服务例程ISR)、中断向量表IVT)和嵌套向量中断控制器NVIC)的细节,这些共同构成了 Arm Cortex-M 微控制器中断处理的核心。

接下来,我们将专注于 STM32 外部中断EXTI)控制器,这是在 STM32 微控制器中管理外部中断的一个基本外围设备。我们将检查 EXTI 控制器的关键特性和寄存器,学习如何配置和利用它来满足各种应用需求。

最后,我们将通过开发 EXTI 驱动程序来应用这些知识,为你提供实现中断驱动固件的实践经验。这种动手方法将巩固你的理解,并使你能够创建响应迅速、基于中断的系统。

在本章中,我们将涵盖以下主要内容:

  • 中断及其在固件中的作用

  • STM32 EXTI 控制器

  • 开发 EXTI 驱动程序

到本章结束时,你将全面理解中断以及如何为 STM32 微控制器开发裸机 EXTI 驱动程序,这将使你能够创建响应迅速且高效的嵌入式系统。

技术要求

本章的所有代码示例都可以在以下 GitHub 链接中找到:

github.com/PacktPublishing/Bare-Metal-Embedded-C-Programming

中断及其在固件中的作用

中断是嵌入式系统中最重要的机制之一,它允许微控制器有效地对实时事件做出反应。要充分理解它们在固件中的作用,了解中断是什么、它们如何工作以及它们在哪些场景中证明是不可或缺的至关重要。因此,让我们深入探索中断的迷人世界,它们的操作以及它们的实际应用。

什么是中断?

想象你正全神贯注地读书,但这时门铃响了。你暂时停止阅读,接待访客,然后回到你的书。微控制器中的中断工作方式类似。它们是暂时停止程序当前执行的信号,以便运行一个称为 ISR 的特殊例程。一旦 ISR 完成,微控制器就会从上次中断的地方恢复其之前的任务。

中断可以由硬件事件触发,例如定时器溢出、按键或通信接口上的数据接收。它们也可以由软件生成,提供了一种灵活的方式来管理外部和内部事件。现在,让我们看看它们是如何工作的。

中断是如何工作的?

中断处理的核心是上下文切换的概念。当中断发生时,微控制器保存其当前状态——本质上,是所有重要信息的快照,例如程序计数器和 CPU 寄存器。这允许微控制器暂停当前任务,执行中断服务例程(ISR),然后恢复保存的状态以继续之前中断的地方。这个过程通常遵循以下步骤:

  1. 中断请求:一个事件触发一个中断 请求IRQ)。

  2. 确认并优先处理:中断控制器确认请求并根据预定义的级别对其进行优先级排序。

  3. 保存上下文:CPU 保存其当前的执行上下文。

  4. 向量检索:CPU 从中断向量表(IVT)中检索 ISR 的地址。

  5. ISR 执行:ISR 运行以处理中断。

  6. 恢复上下文:在 ISR 完成后,CPU 恢复保存的上下文。

  7. 恢复执行:CPU 恢复中断的任务。

你可能会想知道为什么中断很重要。让我们来了解一下。

中断在固件中的重要性

中断对于创建高效和响应灵敏的嵌入式系统至关重要。以下是它们之所以如此重要的几个关键原因:

  • 实时响应:中断允许微控制器对关键事件做出几乎瞬时的反应。例如,在电机控制系统中,一个中断可以立即处理一个表示电机已达到期望位置的传感器信号。

  • 资源优化:而不是不断轮询事件(这浪费了 CPU 周期和电力),中断允许 CPU 保持低功耗状态或专注于其他任务,直到事件发生。这种优化对于可穿戴设备或远程传感器等电池供电设备至关重要。

  • 优先级和抢占:中断可以被优先级排序,允许更关键的任务抢占不那么关键的任务。这确保了像工业机械中的紧急停止信号这样的高优先级任务能够立即得到处理。

当讨论中断时,我们经常遇到另一个关键术语:异常。尽管它们有相似之处,但在嵌入式系统中它们有不同的用途。让我们来探讨中断和异常之间的区别。

中断与异常的比较

中断是硬件或软件发出的信号,指示需要立即关注的事件。例如,定时器溢出、GPIO 引脚变化和外设数据接收。

如我们之前所学的,中断使嵌入式系统能够处理实时事件,对于响应和高效的系统行为至关重要。当中断发生时,CPU 停止执行主程序,跳转到一个预定义的地址来执行 ISR。

异常是中断正常执行流程的事件,通常是由于除以零操作或访问无效内存地址等错误引起的。虽然与中断类似,但异常通常处理错误条件和系统级事件。

中断与异常的区别

下面是两者之间的一些区别:

  • 来源:中断通常来自外部硬件设备或微控制器内的其他外设,而异常通常是内部 CPU 操作的结果

  • 目的:中断管理实时事件,而异常处理错误条件和系统异常

  • 处理:两者都使用 ISR,但异常通常涉及更复杂的错误处理和恢复机制

要正确理解中断的处理方式,我们需要检查涉及到的三个关键组件:NVIC、ISR 和 IVT。

NVIC、ISR 和 IVT

在 Arm Cortex-M 微控制器(如 STM32 系列)中,NVIC 在管理中断方面发挥着关键作用。让我们来探讨 NVIC 是什么,它是如何工作的,以及为什么它对嵌入式开发如此重要。

NVIC

NVIC 是集成到 Arm Cortex-M 微控制器(如 STM32 系列)中的硬件模块,它负责管理中断的优先级和处理。它使微控制器能够快速有效地响应中断,同时允许嵌套中断,其中高优先级的中断可以抢占低优先级的中断。这种能力对于需要及时响应事件的实时应用至关重要。

它的关键特性包括以下内容:

  • 中断优先级:NVIC 支持多个优先级,允许我们为不同的中断分配不同的优先级。这确保了更关键的任务首先得到处理。

  • 嵌套中断:NVIC 允许高优先级的中断中断低优先级的中断。这一特性对于保持实时应用中的系统响应性至关重要。

  • 动态优先级调整:我们可以在运行时动态调整中断的优先级,以适应不断变化的情况。

    图 14.1 中的图表说明了 NVIC 及其与微控制器内各种组件的连接。

图 14.1:NVIC

图 14.1:NVIC

接下来,我们来看 ISR。

ISR

在嵌入式系统中处理中断时,ISR(中断服务例程)是谜题的关键部分。ISR 是一个专门的功能,CPU 在响应中断时执行。每个中断都有自己的 ISR。当中断发生时,CPU 暂时停止其当前任务,保存其状态,并跳转到 ISR 预定义的地址(函数)以执行必要的代码。

最后一个关键组件是 IVT(中断向量表)。

IVT

当发生中断时,IVT 就像是 CPU 的路线图。它是一个包含所有 ISR 地址的数据结构。每个中断源在这个表中都有一个特定的条目,将其映射到相应的 ISR。当中断被触发时,CPU 会咨询 IVT 以找到与该中断相关联的 ISR 的地址。这种查找确保 CPU 可以快速有效地跳转到正确的代码片段来处理事件。

它的关键特性包括以下内容:

  • 0x00000000

  • ISR 地址:IVT 中的每个条目都包含一个 ISR 的地址。当中断发生时,CPU 使用 IVT 快速定位并跳转到适当的 ISR。

  • 0可能用于复位中断,向量号1用于不可屏蔽中断(NMI),等等。

  • 可配置性:在许多系统中,IVT 可以在系统初始化期间配置,以指向你的固件中定义的 ISR。

比较分析—中断驱动解决方案与基于轮询的解决方案

但当我们不使用中断时会发生什么?让我们通过一些真实的案例研究来探讨使用中断和依赖于轮询的解决方案之间的差异。

案例研究 1—按钮去抖动

在嵌入式系统中,处理用户输入,如按钮按下,是一项常见任务。然而,这些输入的管理方式可以显著影响系统的效率和响应性。一个特别的问题是处理“抖动”问题,由于机械按钮的物理特性,它会产生多个快速信号。如果不妥善管理,这可能导致错误的读取和不规则的行为。在本案例研究中,我们将探讨两种处理按钮去抖动的方法:一种不使用中断,另一种使用中断。

我们将从不使用中断的方法开始。

想象一下,你有一个简单的用户界面,其中有一个按钮,当按下时,会切换 LED 的状态。在没有中断的情况下,最直接的方法是在主循环中持续检查(或“轮询”)按钮的状态。这涉及到反复读取按钮的输入引脚,以查看它是否从高电平变为低电平,指示按下。问题在于,机械按钮由于物理抖动可能会产生虚假信号,导致对单个按下的多次检测。为了处理这个问题,你需要在检测到按下后添加一个延迟,有效地忽略一段时间的进一步信号。

存在一些缺点:

  • 低效:CPU 不断忙于检查按钮状态,浪费了本可以用于其他任务的有价值的处理时间。

  • 延迟响应:添加延迟以处理去抖动意味着系统可能在等待按钮稳定时错过其他重要任务。

让我们看看使用中断的方法。

使用中断,您可以将按钮引脚配置为在下降沿(按钮被按下时)产生中断。当按钮被按下时,ISR 会立即触发,处理去抖动逻辑。主循环保持空闲,可以执行其他任务,无需不断轮询按钮。

有几个好处:

  • 效率:CPU 可以专注于其他任务,并在必要时才响应按钮按下。

  • 即时响应:ISR 立即对按钮按下做出响应,使系统更具响应性。

案例研究 2—传感器数据获取

在嵌入式系统中,另一个常见任务是获取传感器数据,特别是在气象站等应用中,多个传感器持续监控环境条件。处理传感器数据获取的方法可以极大地影响系统的复杂性和效率。让我们比较两种方法:一种不使用中断,另一种利用中断来优化过程。

我们将从不使用中断的方法开始。

考虑一个气象站,它定期从各种传感器(如温度、湿度和压力)读取数据。如果没有使用中断,主循环将包括定期从每个传感器读取数据的代码。这可以通过定时器来实现,在读取之间创建延迟,确保数据在正确的间隔内获取。

有几个缺点:

  • 复杂性:使用轮询管理多个传感器并精确计时可能导致主循环复杂化。

  • 低效:主循环可能会花费大量时间等待定时器到期——再次,浪费 CPU 资源。

让我们看看使用中断的方法。

使用中断,每个传感器可以在新数据可用时触发中断。每个传感器的 ISR 读取数据并将其存储在缓冲区中,供主循环稍后处理。这种方法将数据获取与主循环解耦,允许它专注于数据处理和其他任务。

有几个好处:

  • 简化代码:主循环更简洁,更容易管理,因为它不需要直接处理定时和传感器轮询。

  • 资源效率:CPU 花费更少的时间等待,更多的时间处理,从而更有效地利用资源。

案例研究 3—通信协议

现在,让我们看看中断如何改善通信。在许多嵌入式系统中,微控制器与外部设备之间的有效通信至关重要,无论您是处理传感器、显示器还是其他外围设备。您采取的数据传输和接收管理方法会对您的系统性能产生重大影响,尤其是在 CPU 负载和延迟方面。让我们分析两种处理通过 通用异步收发器/传输器UART)通信的方法:一种不使用中断,另一种利用中断来优化过程。

我们将从不使用中断的方法开始。

让我们看看这样一个场景:一个微控制器通过 UART 与另一个设备通信。如果没有中断,固件将不断检查 UART 状态寄存器,以查看是否有新数据到达或发送器是否准备好发送数据。这种轮询方法确保不会错过任何数据,但它可能会非常占用 CPU 资源。

有几个缺点:

  • 资源效率:连续轮询使 CPU 忙碌,留给其他任务的处理器资源更少

  • 延迟:数据到达和处理之间的时间取决于 UART 状态检查的频率

让我们看看使用中断的方法。

启用 UART 中断允许微控制器自动处理数据接收和传输事件。当新数据到达时,会触发一个中断,ISR 读取数据并处理它。同样,当发送器准备好时,另一个中断可以处理发送数据。

有几个好处:

  • 低 CPU 负载:CPU 可以执行其他任务,仅在必要时处理 UART 事件

  • 实时处理:数据到达后立即处理,减少延迟并提高通信效率

中断为嵌入式系统中实时事件的处理提供了一种强大而高效的方法。与轮询方法相比,中断在响应性、效率和代码简单性方面提供了显著的好处。通过理解和利用中断,您可以开发出更健壮、更高效的固件,能够处理各种实时应用。无论是管理用户输入、获取传感器数据、处理通信协议还是保持精确的时间控制,中断都是不可或缺的工具。

在下一节中,我们将探讨 STM32 EXTI 控制器外设。

STM32 EXTI 控制器

STM32 微控制器中的 EXTI 模块旨在管理外部中断线。这些线可以被 GPIO 引脚上的信号触发,使您的微控制器能够快速有效地对外部环境的变化做出反应。

EXTI 控制器配备了一系列功能,增强了其在嵌入式系统中的灵活性和实用性。让我们详细探讨这些功能,并了解它们的实际应用。

EXTI 的关键特性

这里是 EXTI 模块的关键特性,使其成为 STM32 微控制器中管理外部和内部事件的灵活且强大的工具:

  • 提供多达 23 个独立的中断/事件线,其中最多 16 个来自 GPIO 引脚,其余来自内部信号

  • 每条线都可以独立配置为中断或事件

  • 每条线的边缘检测选项:上升沿、下降沿或两者都检测

  • 每条线都有专用的状态标志来指示挂起的中断/事件

  • 生成软件中断/事件的能力

事件寄存器通过设置对应的状态标志位来表示发生了某些事件,但不触发中断或执行任何代码(ISR)。

例如,事件可以用来在没有执行 ISR 的情况下唤醒系统。

要使用 EXTI 生成中断,我们需要正确配置和启用中断线。这涉及到编程触发寄存器以检测所需的边缘(上升沿、下降沿或两者),并通过在中断屏蔽寄存器中设置适当的位来启用 IRQ。当检测到外部中断线上指定的边缘时,将生成一个 IRQ,并设置相应的挂起位。必须通过在挂起寄存器中写入1来清除此挂起位。

要生成事件,我们只需通过设置适当的触发寄存器和在事件屏蔽寄存器中启用相应的位来配置事件线。

我们还可以通过写入1到软件中断/事件寄存器(EXTI_SWIER)来通过软件生成中断/事件。

将线路配置为中断源涉及三个步骤:

  1. 配置屏蔽位:使用中断屏蔽寄存器EXTI_IMR)设置 23 个中断线的屏蔽位。

  2. 配置触发选择位:使用上升沿触发选择寄存器EXTI_RTSR)和下降沿触发选择寄存器EXTI_FTSR)来设置中断线的期望触发条件。

  3. 启用 NVIC 中断通道:在这里,我们只需配置控制映射到 EXTI 的 NVIC 中断通道的启用屏蔽位。

在我们开发 EXTI 驱动程序之前,让我们首先了解 EXTI 线是如何映射到 GPIO 引脚上的。

外部中断/事件线映射

EXTI 控制器可以将多达 81 个 GPIO(在 STM32F411xC/E 系列中)连接到 16 个外部中断/事件线上。GPIO 引脚通过 SYSCFG_EXTICR 寄存器映射到 EXTI 线上。

GPIO 引脚和 EXTI 线

STM32 微控制器上的每个 GPIO 引脚都可以连接到 EXTI 线,允许它生成外部中断。这种灵活性意味着您可以启用任何 GPIO 引脚的中断,但有一个限制:多个引脚共享相同的 EXTI 线。这种共享基于引脚号,而不是端口。

下面是如何连接引脚的分解:

  • 每个端口的第 0 个引脚连接到 EXTI0_IRQ

  • 每个端口的第 1 个引脚连接到 EXTI1_IRQ

  • 每个端口的第 2 个引脚连接到 EXTI2_IRQ

  • 每个端口的第 3 个引脚连接到 EXTI3_IRQ

  • 等等...

这种映射意味着不同端口的相同编号引脚共享相同的 EXTI 线。例如,PA0、PB0、PC0、PD0、PE0 和 PH0 都连接到 EXTI0。

重要提示

共享 EXTI 线:由于多个引脚共享相同的 EXTI 线,因此您不能同时在不同端口的相同编号的引脚上启用中断。例如,如果您在 PB0 上启用中断,则不能同时也在 PA0 上启用中断,因为这两个引脚都共享 EXTI0。

在 SYSCFG_EXTICR 中的配置:SYSCFG 外部中断配置寄存器(EXTICRs)用于选择哪个端口的引脚将连接到特定的 EXTI 线。这种选择确保了只有一个端口的引脚可以作为给定 EXTI 线的源。

开发 EXTI 驱动程序

STM32 EXTI 模块依赖于几个关键寄存器来配置其操作。这些寄存器允许您设置触发条件、启用中断和管理挂起的中断请求。理解这些寄存器对于在嵌入式项目中有效地使用 EXTI 模块至关重要。

EXTI_IMR

我们使用 EXTI_IMR 来启用或禁用每个 EXTI 线上的中断。

该寄存器中的位命名为 x = 022)。将位设置为 1 解除中断线的屏蔽,允许它生成中断请求。相反,将其设置为 0 屏蔽线,防止它生成中断。

EXTI_RTSR

EXTI_RTSR 配置每个 EXTI 线的上升沿触发。当检测到配置在此寄存器中的线上有上升沿时,它可以生成中断或事件。

该寄存器中的位命名为 x = 022)。将位设置为 1 配置线在上升沿触发。

EXTI_FTSR

EXTI_FTSR 用于配置每个 EXTI 线的下降沿触发。当检测到设置在此寄存器中的线上有下降沿时,它可以生成中断或事件。

该寄存器中的位命名为 x = 022)。将位设置为 1 配置线在下降沿触发。

待处理寄存器 (EXTI_PR)

EXTI_PR 指示哪些 EXTI 线有挂起的中断请求。此寄存器还用于通过向适当的位写入 1 来清除挂起的中断。

该寄存器中的位命名为 x = 022)。设置为 1 的位表示挂起的中断请求。向该位写入 1 清除挂起请求。

让我们配置 PC13 为 EXTI 引脚。

EXTI 驱动程序

在您的 IDE 中创建您之前项目的副本,并将此复制的项目重命名为 EXTI。接下来,在 Src 文件夹中创建一个名为 gpio_exti.c 的新文件,并在 Inc 文件夹中创建一个名为 gpio_exti.h 的新文件。

用以下代码填充 gpio_exti.c

#include "gpio_exti.h"
#define GPIOCEN            (1U<<2)
#define SYSCFGEN        (1U<<14)
void pc13_exti_init(void)
{
    /*Disable global interrupts*/
    __disable_irq();
    /*Enable clock access for GPIOC*/
    RCC->AHB1ENR |=GPIOCEN;
    /*Set PC13 as input*/
    GPIOC->MODER &=~(1U<<26);
    GPIOC->MODER &=~(1U<<27);
    /*Enable clock access to SYSCFG*/
    RCC->APB2ENR |=SYSCFGEN;
    /*Select PORTC for EXTI13*/
    SYSCFG->EXTICR[3] |=(1U<<5);
    /*Unmask EXTI13*/
    EXTI->IMR |=(1U<<13);
    /*Select falling edge trigger*/
    EXTI->FTSR |=(1U<<13);
    /*Enable EXTI13 line in NVIC*/
    NVIC_EnableIRQ(EXTI15_10_IRQn);
    /*Enable global interrupts*/
    __enable_irq();
}

让我们分解 pc13_exti_init 函数中的每个步骤:

__disable_irq();

这行代码禁用全局中断以确保配置过程不被中断,这对于保持一致性并避免竞争条件至关重要。

RCC->AHB1ENR |= GPIOCEN;

这行代码通过在AHB1外设时钟使能寄存器(AHB1ENR)中设置适当的位来启用 GPIOC 的时钟。

GPIOC->MODER &= ~(1U<<26);
GPIOC->MODER &= ~(1U<<27);

这几行代码通过清除 GPIO 模式寄存器(MODER)中适当的位来配置引脚PC13为输入。

RCC->APB2ENR |= SYSCFGEN;

这行代码通过在APB2外设时钟使能寄存器(APB2ENR)中设置适当的位来启用SYSCFG的时钟。SYSCFG对于配置 EXTI 线映射到适当的 GPIO 引脚是必需的。

SYSCFG->EXTICR[3] |= (1U<<5);

这行代码配置SYSCFG外部中断配置寄存器,将 EXTI 线 13 映射到PORTCEXTICR[3]寄存器控制 EXTI 线 12 到 15,设置正确的位确保EXTI13连接到PC13

EXTI->IMR |= (1U<<13);

这行代码通过在中断屏蔽寄存器(IMR)中设置适当的位取消屏蔽 EXTI 线 13。取消屏蔽该线允许它生成中断请求。

EXTI->FTSR |= (1U<<13);

这行代码通过在 FTSR 中设置适当的位将 EXTI 线 13 设置为下降沿触发。这种配置对于检测信号从高到低的转换至关重要。

NVIC_EnableIRQ(EXTI15_10_IRQn);

这行代码在 NVIC 中启用EXTI15_10中断线。EXTI 线 10 到 15 在 NVIC 中共享一个 IRQ,启用它允许微控制器处理这些线的中断。

__enable_irq();

这行代码在配置完成后重新启用全局中断,允许微控制器响应中断。

我们接下来的任务是填充gpio_exti.h文件。

这里是代码:

#ifndef  GPIO_EXTI_H__
#define GPIO_EXTI_H__
#include <stdint.h>
#include "stm32f4xx.h"
#define   LINE13        (1U<<13)
void pc13_exti_init(void);
main.c file:

include <stdio.h>

include "adc.h"

include "uart.h"

include "gpio.h"

include "gpio_exti.h"

uint8_t g_btn_press;

int main(void)

{

/初始化调试 UART/

uart_init();

/初始化 LED/

led_init();

/初始化 EXTI/

pc13_exti_init();

while(1)

{

}

}

static void exti_callback(void)

{

printf("BTN Pressed...\n\r");

led_toggle();

}

void EXTI15_10_IRQHandler(void) {

if((EXTI->PR & LINE13)!=0)

{

/清除 PR 标志/

EXTI->PR |=LINE13;

//执行某些操作...

exti_callback();

}

}


			Let’s break down the code:

				*   The main function initializes the system components and then enters an infinite loop.
				*   The `exti_callback` function is called when the external interrupt occurs.
				*   `EXTI15_10_IRQHandler` handles interrupts for EXTI lines 10 to 15, including line 13 (`PC13`)

if((EXTI->PR & LINE13) != 0)


			The preceding line checks whether the pending bit for EXTI line 13 is set, indicating an interrupt has occurred.

EXTI->PR |= LINE13;


			The preceding line clears the pending bit by writing a `1` to it, acknowledging the interrupt, and allowing it to be processed again.

exti_callback();


			This calls the `exti_callback` function to handle the interrupt, which, in our case, prints a message and toggles the LED.
			To test on the microcontroller, simply build the project and run it. To generate the EXTI interrupt, press the blue push button.
			Open RealTerm and configure the appropriate port and baud rate to view the printed message that confirms the EXTI interrupts occur when the blue push button is pressed.
			Summary
			In this chapter, we learned about the important role of interrupts in embedded systems development. Interrupts are essential for creating responsive and efficient firmware, allowing microcontrollers to handle real-time events effectively. By mastering the concepts of interrupts, you can develop systems that react promptly to external stimuli, enhancing the robustness and versatility of your embedded applications.
			We started by exploring the fundamental role of interrupts in firmware, comparing them with exceptions to highlight their unique purposes and handling mechanisms. We then examined the specifics of the ISR, the IVT, and the NVIC, which together form the backbone of interrupt handling in Arm Cortex-M microcontrollers.
			Next, we focused on the STM32 EXTI controller, a vital peripheral for managing external interrupts in STM32 microcontrollers. We discussed the key features and registers of the EXTI, and how to configure and utilize it for various applications.
			Finally, we applied this knowledge by developing an EXTI driver, providing practical experience in implementing interrupt-driven firmware.
			In the next chapter, we will learn about the **Realtime Clock** (**RTC**) peripheral.

第十五章:实时时钟(RTC)

在本章中,我们将探讨实时时钟(RTC)外设,这是嵌入式系统中时间维护的关键组件。这个外设在需要精确时间和日期维护的应用中至关重要,对于广泛的嵌入式应用来说是基本的。

我们将首先介绍 RTC 及其工作原理。随后,我们将深入研究 STM32 RTC 模块,检查其特性和功能。接下来,我们将分析 STM32 参考手册中的相关寄存器,提供对 RTC 配置和操作的详细理解。最后,我们将应用这些知识来开发 RTC 驱动程序,使您能够在嵌入式项目中实现精确的时间维护。

在本章中,我们将涵盖以下主要内容:

  • 理解 RTC

  • STM32 RTC 模块

  • 一些关键的 RTC 寄存器

  • 开发 RTC 驱动程序

到本章结束时,您将对 RTC 的工作原理有一个扎实的理解,并具备开发裸机 RTC 驱动程序的能力,这将使您能够在嵌入式系统项目中实现精确的时间维护。

技术要求

本章的所有代码示例都可以在 GitHub 上找到,网址为github.com/PacktPublishing/Bare-Metal-Embedded-C-Programming

理解 RTC

在本节中,我们将进入 RTC 的世界,了解它们是什么以及它们是如何工作的,然后通过几个有趣的案例研究来探索常见的用例。让我们开始吧!

RTC 是许多微控制器和嵌入式系统中发现的专用硬件设备。它们的主要功能是在主电源关闭时跟踪当前的时间和日期。想象一下,它们就像是数字世界中的小时间守护者,确保时钟无论发生什么都不会停止滴答。

RTCs 在需要精确时间维护的应用中至关重要。这包括从简单的闹钟到复杂的数据记录系统,在这些系统中,精确的时间戳是必要的。当主系统断电时,RTC 会继续在小型电池上运行,保持准确的时间和日期信息。让我们看看它们是如何工作的。

RTC 是如何工作的?

RTC 的核心是一个晶振,它提供稳定的时钟信号。这个振荡器通常运行在32.768 kHz,这个频率之所以被选择,是因为它很容易被 2 的幂次整除,这使得它对二进制计数非常方便。

下面是一个简化的 RTC 工作原理分解:

  • 晶振:RTC 包含一个晶振,它产生精确的时钟信号。

  • 计数器:这个时钟信号驱动一个计数器。计数器以振荡器频率确定的速率递增。

  • 时间和日期寄存器:计数器的值用于更新时间和日期寄存器,这些寄存器存储当前时间(小时、分钟、秒)和日期(日、月、年)。

  • 电池备份:为确保连续运行,RTCs 通常具有电池备份。即使在主电源关闭时,这也使振荡器保持运行,计数器保持活跃。

让我们考虑一些 RTC 的常见用途。

RTCs 的常见用途

RTCs 非常灵活,被广泛应用于各种应用。让我们通过几个案例研究来探索一些常见的用途。

案例研究 1 – 数据记录

RTCs 最常见的一种应用是在数据记录中。想象一下,你正在设计一个收集温度、湿度和压力数据的气象站。准确的时间戳对于分析随时间变化趋势和模式至关重要。以下是 RTCs 在这个场景中发挥关键作用的方式:

  • 初始化:RTCs 被初始化并设置为当前时间和日期

  • 数据收集:每次读取传感器数据时,RTCs 都提供时间戳

  • 存储:传感器数据以及时间戳存储在内存中

  • 分析:当数据被检索用于分析时,时间戳确保每个读数可以准确地放置在时间线上

在这种情况下,RTCs 确保每条数据都准确标记了时间戳,这使得可以精确跟踪变化和趋势。

案例研究 2 – 闹钟

实时时钟(RTCs)在设计闹钟时也非常基本。无论是简单的床头闹钟还是复杂的调度系统,RTCs 都提供了在正确时刻触发事件所需的精确计时功能。让我们看看一个典型的闹钟场景:

  • 计时:RTCs 跟踪当前时间,持续更新时间寄存器。

  • 闹钟设置:用户为特定时间设置闹钟。此信息存储在 RTCs 的闹钟寄存器中。

  • 闹钟触发:当 RTCs 的时间与闹钟时间匹配时,会触发中断,激活闹钟机制(如发出蜂鸣声或打开灯光)。

在这种情况下,RTCs 确保闹钟在用户设置的精确时间响起,使其成为可靠基于时间的警报的必要组件。此时,你可能会问,“RTCs 有什么特别之处吗?

为什么 RTCs 很重要?

你可能想知道为什么我们不能仅仅使用系统时钟或通用定时器进行计时。答案在于 RTCs 即使在主系统断电时也能保持准确的时间,以下是 RTC 不可或缺的一些关键原因:

  • 准确性:RTCs 使用晶振振荡器,提供高度准确的时间计时

  • 低功耗:RTCs 设计为在非常低的功耗下运行,通常可以在小型电池上运行数年

  • 电池备份:由于电池备份,RTCs 即使在主电源关闭时也能继续计时

  • 独立于主系统:RTCs 独立于主微控制器运行,确保连续计时

通过了解 RTC 的工作原理及其常见用例,我们可以欣赏它们的重要性,并有效地将它们纳入我们的嵌入式项目中。无论您是在构建一个简单的闹钟还是一个复杂的数据记录系统,RTC 都是一个重要的组件,确保您的系统始终知道正确的时间。在下一节中,我们将探讨 STM32F4 微控制器中的 RTC 外设。

STM32 RTC 模块

在本节中,我们将探讨 STM32F4 微控制器系列中的 RTC 模块。让我们首先看看它的功能。

STM32F4 RTC 模块的主要功能

STM32F4 RTC 模块就像计时领域的瑞士军刀,提供了一组丰富的功能,旨在满足众多应用的需求。以下是一些突出的功能:

  • 带有秒级的日历:RTC 模块不仅跟踪小时、分钟和秒,还保持秒级精度。这对于需要精确时间测量的应用特别有用。

  • 闹钟功能:想象一下,您在微控制器内部有两个闹钟。STM32F4 RTC 模块提供了两个可编程的闹钟,闹钟 A闹钟 B,可以在特定时间触发事件。这对于需要定期执行或在一天中的特定时间执行的任务来说非常完美。

  • 低功耗:RTC 模块最大的优点之一是其低功耗。这使得它非常适合电池供电的设备,在这些设备中,节省电力至关重要。

  • 备份域:RTC 可以通过备用电池独立于主电源运行。这意味着即使您的设备断电,RTC 也会继续运行,保持准确的时间。

  • 夏令时:使用 RTC 模块,您可以自动编程夏令时的调整。不再需要每年手动重置两次!

  • 自动唤醒:RTC 可以生成周期性唤醒信号,在预设间隔将您的系统从低功耗模式唤醒。对于需要定期检查或更新的应用,这个特性非常有价值。

  • 篡改检测:对于许多应用来说,安全性是一个关键方面,RTC 模块通过篡改检测为您提供了保障。它可以记录篡改事件,为您的系统提供额外的安全层。

  • 数字校准:在计时方面,精度是王道。RTC 模块包括数字校准功能,以补偿晶振频率的偏差,确保您的计时保持精确无误。

  • 与外部时钟同步:为了提高精度,RTC 可以与外部时钟源同步。这对于需要长时间内保持非常高精度的应用来说非常棒。

现在,让我们分析 STM32F4 RTC 模块的一些关键组件。

STM32F4 RTC 模块的关键组件

让我们更详细地看看 STM32F4 微控制器系列中 RTC 模块的关键组件。我们将逐个分析每个部分,了解它们如何协同工作以提供准确的时间保持和多功能性,从时钟源开始。

时钟源

RTC 模块的驱动器是其时钟源。图 15.1展示了 RTC 模块的详细框图,突出了 RTC 时钟源。此图来自参考手册,提供了 RTC 模块中各种组件及其相互作用的清晰视觉表示:

图 15.1:RTC 模块框图,突出显示时钟源

图 15.1:RTC 模块框图,突出显示时钟源

STM32F4 RTC 可以使用多个时钟源:

  • 低速外部(LSE):一个 32.768 kHz 的晶体振荡器,以其稳定性和低功耗而闻名。这通常是用于精确时间保持的首选时钟源。

  • 低速内部(LSI):一个内部 RC 振荡器,当外部晶体不可用时提供一种不太精确但方便的选项。

  • 高速外部(HSI):一个高速时钟源,虽然可以使用,但由于其较高的功耗,在 RTC 应用中不太常见。

选定的时钟源输入到 RTC 的预分频器,预分频器负责将时钟频率分频到适合时间保持的合适水平:

图 15.2:RTC 模块框图 – 异步和同步预分频器

图 15.2:RTC 模块框图 – 异步和同步预分频器

预分频器

RTC 模块采用两种类型的预分频器:

  • 异步预分频器:此预分频器通常设置为除以128,降低时钟频率到一个较低的水平,可以被同步预分频器管理。它有助于平衡功耗和精度。

  • 同步预分频器:通常配置为除以256,此预分频器进一步降低时钟频率以生成精确的1 Hz 时钟,这对于准确更新时间和日期寄存器至关重要。

这些预分频器确保 RTC 可以高效运行,提供必要的时间保持精度同时节省功耗。接下来,我们有时间和日期寄存器。

时间和日期寄存器

图 15.3突出了 RTC 模块的时间和日期寄存器:

图 15.3:RTC 模块框图 – 时间和日期寄存器

图 15.3:RTC 模块框图 – 时间和日期寄存器

RTC 的核心功能在于时间和日期寄存器:

  • 时间寄存器(RTC_TR):此寄存器以二进制编码十进制(BCD)格式存储当前的小时、分钟和秒。它由预分频器的 1 Hz 时钟每秒更新。

  • 日期寄存器(RTC_DR):此寄存器维护当前的日期,包括年、月和日,也以 BCD 格式存储。

这些寄存器对于维护准确的时间和日期信息至关重要,可以根据需要读取和调整。下一个关键组件是 RTC 报警。

报警

RTC 模块具有两个可编程报警,报警 A 和报警 B。这些报警可以设置为在特定时间触发,为任务调度提供强大的工具:

  • RTC_ALRMARRTC_ALRMBR用于存储报警时间和日期。

  • 中断:当报警被触发时,它可以生成中断,从低功耗状态唤醒微控制器或启动特定功能。

报警模块在以下图中表示:

图 15.4:RTC 模块框图 – 报警

图 15.4:RTC 模块框图 – 报警

接下来,我们讨论唤醒定时器。

唤醒定时器

RTC 模块的另一个关键特性是唤醒定时器,它由RTC_WUTR寄存器管理:

图 15.5:RTC 模块框图 – 唤醒定时器

图 15.5:RTC 模块框图 – 唤醒定时器

这个16 位自动重载定时器可以生成周期性唤醒事件,定期将系统从低功耗模式唤醒。它非常适合传感器读取或系统检查等任务,确保高效节能。

还有篡改检测模块。让我们看看。

篡改检测

安全性是许多应用的一个关键方面,RTC 模块包括篡改检测功能。当检测到篡改尝试时,篡改检测电路可以使用时间戳寄存器记录确切的时间和日期,从而增加一个额外的安全层,特别是在需要可靠的时间记录和事件记录的应用中。接下来,我们讨论校准寄存器功能。

校准和同步

为了保持高精度,RTC 模块包括校准功能:

  • RTC_CALR寄存器允许对时钟频率进行精细调整,以补偿晶振的任何偏差

  • 外部时钟同步:RTC 可以与外部时钟源同步,通过定期调整内部时钟以匹配外部参考来提高精度

这些功能确保 RTC 即使在不同的环境条件下也能保持精确的时间记录。我们还有备份和控制寄存器模块。

备份和控制寄存器

RTC 模块包括几个备份和控制寄存器:

  • 备份寄存器:这些寄存器存储必须保留的关键数据,即使在主电源关闭时也是如此

  • RTC_CR寄存器用于管理 RTC 的配置和操作,包括启用时钟、设置报警和配置唤醒事件

备份和控制寄存器模块在以下图中表示:

图 15.6:RTC 模块框图 – 备份和控制寄存器

图 15.6:RTC 模块框图 – 备份和控制寄存器

最后,是输出控制块。

输出控制

RTC 模块可以通过 RTC_AF1 引脚输出特定信号,例如校准时钟或闹钟输出。这使得 RTC 模块能够与其他组件或系统交互,提供同步信号或触发外部事件。

在下一节中,我们将分析一些配置 RTC 外设的关键寄存器。

一些关键的 RTC 寄存器

在本节中,我们将探讨 RTC 模块中一些重要寄存器的特性和功能。这些寄存器是构建块,使我们能够有效地配置、控制和利用 RTC 的功能。让我们从 RTC_TR)开始。

RTC 时间寄存器 (RTC_TR)

RTC_TR 寄存器负责跟踪当前时间。它以 BCD 格式维护小时、分钟和秒,确保时间易于阅读和操作。以下是该寄存器中的一些关键字段:

  • 小时十位 (HT) 和小时个位 (HU):这些位分别代表小时的十位和个位。它们可以处理 24 小时12 小时 格式。

  • 分钟十位 (MNT) 和分钟个位 (MNU):这些位代表分钟的十位和个位。

  • 秒十位 (ST) 和秒个位 (SU):这些位代表秒的十位和个位。

  • PM:此位在 12 小时 格式下指示 AM/PM 标记。

关于此寄存器的更多信息可以在 参考手册第 450 页 找到。让我们继续到 RTC_DR)。

RTC 日期寄存器 (RTC_DR)

RTC_DR 寄存器负责维护当前日期。它跟踪年份、月份、月份中的日期和星期几,所有这些都在 BCD 格式下。

以下是该寄存器中的关键字段:

  • 年份十位 (YT) 和年份个位 YU):这些位代表年份的十位和个位

  • 月份十位 (MT) 和月份个位 (MU):这些位代表月份的十位和个位

  • 日期十位 (DT) 和日期个位 (DU):这些位代表月份中的日期的十位和个位

  • 星期几个位 (WDU):此位代表星期几(1 到 7)

你可以在 参考手册第 451 页 上了解更多关于此寄存器的信息。下一个关键寄存器是 RTC_CR)。

RTC 控制寄存器 (RTC_CR)

RTC_CR 寄存器是我们控制 RTC 的各种操作模式和功能的地方。此寄存器允许我们启用 RTC、配置闹钟和设置唤醒定时器。

让我们考虑这个寄存器中的关键位:

  • WUTE:启用唤醒定时器。此位启用 RTC 唤醒定时器。

  • TSE:启用时间戳事件。此位启用事件的标记。

  • ALRAE 和 ALRBE:启用闹钟 A 和闹钟 B。这些位启用相应的闹钟。

  • DCE:启用数字校准。此位启用 RTC 时钟的数字校准。

  • FMT:小时格式。此位将小时格式设置为 24 小时或 12 小时(AM/PM)。

更多关于此寄存器的详细信息可以在 参考手册的第 453 页 上找到。接下来,我们有 RTC_ISR)。

实时时钟初始化和状态寄存器 (RTC_ISR)

RTC_ISR 寄存器在初始化实时时钟和监控其状态方面发挥着双重作用。在设置过程中以及检查实时时钟当前状态时,此寄存器至关重要。以下是此寄存器中的关键位:

  • INIT:初始化模式。设置此位将实时时钟置于初始化模式。

  • RSF:寄存器同步标志。此位指示日历寄存器已同步。

  • INITS:初始化状态标志。此位指示实时时钟日历是否已初始化。

  • ALRAF 和 ALRBF:报警 A 和报警 B 标志。这些位指示是否已触发报警。

接下来,我们有 RTC_PRER)。

实时时钟预分频器寄存器 (RTC_PRER)

RTC_PRER 寄存器管理预分频器,这些预分频器将实时时钟时钟源分频以产生用于精确时间保持的 1 Hz 时钟。此寄存器中有两个关键字段:

  • PREDIV_A:异步预分频器。此字段设置异步预分频器的值。

  • PREDIV_S:同步预分频器。此字段设置同步预分频器的值。

正确配置 RTC_PRER 寄存器对于保持实时时钟的准确性至关重要。

接下来,我们将查看实时时钟报警寄存器,RTC_ALRMARRTC_ALRMBR

实时时钟报警寄存器 (RTC_ALRMAR 和 RTC_ALRMBR)

这些寄存器处理报警 A 和 B 的配置。它们允许我们设置报警应触发的特定时间。以下是这些寄存器中的关键字段:

  • ALRMASK:报警掩码位。这些位允许您掩码报警时间的某些部分,从而在如何以及何时触发报警方面提供灵活性。

  • ALRH, ALRMN, 和 ALRS:小时、分钟和秒字段。这些字段设置报警的具体时间。

RTC_ALRMARRTC_ALRMBR 寄存器对于需要可靠基于时间的触发事件的应用至关重要。最后,让我们来探讨 RTC_WUTR)。

实时时钟唤醒定时器寄存器 (RTC_WUTR)

RTC_WUTR 寄存器配置唤醒定时器,使实时时钟能够定期从低功耗模式唤醒系统。此寄存器中的关键字段是 唤醒自动重载值WUT)。此字段设置唤醒定时器的间隔。

在下一节中,我们将应用本节学到的所有知识来开发实时时钟外设的驱动程序。

开发实时时钟驱动程序

在本节中,我们将开发实时时钟日历驱动程序,以便我们可以配置和跟踪时间和日期。

如往常一样,我们将遵循前面章节中概述的步骤创建我们之前项目的副本。我们将此复制的项目重命名为RTC_Calendar。接下来,在Src文件夹中创建一个名为rtc.c的新文件,并在Inc文件夹中创建一个名为rtc.h的新文件。RTC 配置可能相当复杂,因此我们将创建几个辅助函数来模块化初始化过程。

RTC 实现文件

让我们从填充rtc.c文件开始,首先是初始化函数所需的辅助函数。以下是我们在 RTC 配置中将要使用的宏定义:

#include "rtc.h"
#define PWREN        (1U << 28)
#define CR_DBP       (1U << 8)
#define CSR_LSION    (1U << 0)
#define CSR_LSIRDY   (1U << 1)
#define BDCR_BDRST   (1U << 16)
#define BDCR_RTCEN   (1U << 15)
#define RTC_WRITE_PROTECTION_KEY_1 ((uint8_t)0xCAU)
#define RTC_WRITE_PROTECTION_KEY_2 ((uint8_t)0x53U)
#define RTC_INIT_MASK               0xFFFFFFFFU
#define ISR_INITF                   (1U << 6)
#define WEEKDAY_FRIDAY              ((uint8_t)0x05U)
#define MONTH_DECEMBER              ((uint8_t)0x12U)
#define TIME_FORMAT_PM              (1U << 22)
#define CR_FMT                      (1U << 6)
#define ISR_RSF                     (1U << 5)
#define RTC_ASYNCH_PREDIV           ((uint32_t)0x7F)
#define RTC_SYNCH_PREDIV            ((uint32_t)0x00F9)

让我们逐一分析:

  • PWREN (1U << 28): 此宏通过在 APB1 外设时钟使能寄存器中设置位 28 来启用 PWR 模块的时钟。

  • CR_DBP (1U << 8): 此函数通过在 PWR 寄存器中设置位 8 来启用对备份域的访问。

  • CSR_LSION (1U << 0): 此宏通过在时钟控制与状态寄存器中设置位 0 来启用 LSI 振荡器。

  • CSR_LSIRDY (1U << 1): 此宏用于读取LSI寄存器的状态。当LSI稳定且准备好使用时,LSIRDY 位被设置为 1。

  • BDCR_BDRST (1U << 16): 此宏通过在备份域控制寄存器中设置位 16 来强制重置备份域。

  • BDCR_RTCEN (1U << 15): 通过在备份域控制寄存器中设置位 15 来启用 RTC。

  • RTC_WRITE_PROTECTION_KEY_1 ((uint8_t)0xCAU): 此密钥用于禁用 RTC 寄存器的写保护。

  • RTC_WRITE_PROTECTION_KEY_2 ((uint8_t)0x53U): 这是禁用 RTC 寄存器写保护所需的第二个密钥。

  • RTC_INIT_MASK (0xFFFFFFFFU): 此掩码用于将 RTC 外设进入初始化模式。

  • ISR_INITF (1U << 6): ISR寄存器中的此位指示 RTC 外设处于初始化模式。

  • WEEKDAY_FRIDAY ((uint8_t)0x05U): 此宏用于配置日历的星期为星期五。

  • MONTH_DECEMBER ((uint8_t)0x12U): 此宏用于配置日历的月份为十二月。

  • TIME_FORMAT_PM (1U << 22): 此宏将 12 小时制中的时间格式设置为下午。

  • CR_FMT (1U << 6): 此宏在 RTC 控制寄存器(RTC->CR)中将小时格式设置为 24 小时格式。

  • ISR_RSF (1U << 5): ISR寄存器中的此位指示 RTC 寄存器已同步。

  • RTC_ASYNCH_PREDIV ((uint32_t)0x7F): 此值设置 RTC 外设的异步预分频,并用于分频时钟频率。

  • RTC_SYNCH_PREDIV ((uint32_t)0x00F9): 此值设置 RTC 外设的同步预分频,并用于进一步分频时钟频率以提高时间保持精度。

让我们检查负责设置 RTC 外设预分频值的两个函数。

首先,我们有rtc_set_asynch_prescaler

static void rtc_set_asynch_prescaler(uint32_t AsynchPrescaler)
{
  MODIFY_REG(RTC->PRER, RTC_PRER_PREDIV_A, AsynchPrescaler << RTC_
  PRER_PREDIV_A_Pos);
}

此函数设置 RTC 外设的异步预分频值。让我们逐一分析:

  • MODIFY_REG:此宏修改寄存器中的特定位。它在stm32f4xx.h头文件中定义。

  • RTC->PRER:RTC 的PRER寄存器。

  • RTC_PRER_PREDIV_APRER寄存器中异步预分频器位的掩码。

  • AsynchPrescaler << RTC_PRER_PREDIV_A_Pos:此片段将AsynchPrescaler值移位到PRER寄存器中的正确位置。

简而言之,此函数通过更新PRER寄存器中的适当位来配置异步预分频器。接下来,我们有设置同步预分频器的函数——即rtc_set_synch_prescaler

static void rtc_set_synch_prescaler(uint32_t SynchPrescaler)
{
  MODIFY_REG(RTC->PRER, RTC_PRER_PREDIV_S, SynchPrescaler);
}

此函数通过更新PRER寄存器中的适当位来配置同步预分频器:

  • RTC->PRER:RTC 的PRER寄存器

  • RTC_PRER_PREDIV_S:这是PRER寄存器中同步预分频器位的掩码

  • SynchPrescaler:此直接在PRER寄存器中设置同步预分频器值

接下来,我们必须分析其他 RTC 初始化辅助函数,以了解它们如何协同工作来配置和同步 RTC 外设。

首先,我们有_rtc_enable_init_mode函数:

void _rtc_enable_init_mode(void)
{
    RTC->ISR = RTC_INIT_MASK;
}

简而言之,此函数通过将初始化掩码写入ISR寄存器来将 RTC 外设设置为初始化模式:

  • RTC->ISR:这是 RTC 的RTC_ISR寄存器

  • RTC_INIT_MASK:此掩码用于进入初始化模式

接下来,我们有_rtc_disable_init_mode

void _rtc_disable_init_mode(void)
{
    RTC->ISR = ~RTC_INIT_MASK;
}

此函数通过清除 ISR 寄存器中的初始化掩码来禁用初始化模式。在这里,~RTC_INIT_MASK清除初始化掩码,退出初始化模式。

下一个辅助函数是_rtc_isActiveflag_init

uint8_t _rtc_isActiveflag_init(void)
{
    return ((RTC->ISR & ISR_INITF) == ISR_INITF);
}

此函数通过检查ISR_INITF位来返回 1,该位指示 RTC 外设处于初始化模式。

接下来,我们有_rtc_isActiveflag_rs

uint8_t _rtc_isActiveflag_rs(void)
{
    return ((RTC->ISR & ISR_RSF) == ISR_RSF);
}

此函数通过检查ISR_RSF位来返回 1,该位指示 RTC 寄存器已同步。

此外,还有rtc_init_seq函数:

static uint8_t rtc_init_seq(void)
{
    /* Start init mode */
    _rtc_enable_init_mode();
    /* Wait till we are in init mode */
    while (_rtc_isActiveflag_init() != 1) {}
    return 1;
}

此函数通过启用初始化模式并等待 RTC 外设进入初始化模式来启动 RTC 初始化:

  • _rtc_enable_init_mode:此行将 RTC 外设置于初始化模式

  • _rtc_isActiveflag_init:此行等待直到 RTC 外设处于初始化模式

接下来,我们有wait_for_synchro函数:

static uint8_t wait_for_synchro(void)
{
    /* Clear RSF */
    RTC->ISR &= ~ISR_RSF;
    /* Wait for registers to synchronize */
    while (_rtc_isActiveflag_rs() != 1) {}
    return 1;
}

此函数清除同步标志并等待直到 RTC 寄存器同步。

我们还有exit_init_seq函数:

static uint8_t exit_init_seq(void)
{
    /* Stop init mode */
    _rtc_disable_init_mode();
    /* Wait for registers to synchronize */
    return (wait_for_synchro());
}

此函数退出 RTC 初始化模式,并等待寄存器同步,以确保一切设置正确。现在,让我们看看配置日期和时间的函数。

首先,我们有rtc_date_config

static void rtc_date_config(uint32_t WeekDay, uint32_t Day, uint32_t Month, uint32_t Year)
{
  register uint32_t temp = 0U;
  temp = (WeekDay << RTC_DR_WDU_Pos) |\
         (((Year & 0xF0U) << (RTC_DR_YT_Pos - 4U)) | 
         ((Year & 0x0FU) << RTC_DR_YU_Pos)) |\
         (((Month & 0xF0U) << (RTC_DR_MT_Pos - 4U)) | 
         ((Month & 0x0FU) << RTC_DR_MU_Pos)) |\
         (((Day & 0xF0U) << (RTC_DR_DT_Pos - 4U)) | 
         ((Day & 0x0FU) << RTC_DR_DU_Pos));
  MODIFY_REG(RTC->DR, (RTC_DR_WDU | RTC_DR_MT | RTC_DR_MU | RTC_DR_DT 
  | RTC_DR_DU | RTC_DR_YT | RTC_DR_YU), temp);
}

此函数设置 RTC 外设的日期。

我们首先创建一个名为 temp 的临时变量来保存日期值。这个变量通过将星期几、日、月和年移位并组合到 RTC 日期寄存器的适当位置来仔细构建。然后使用 MODIFY_REG 宏更新 RTC 的日期寄存器,使用这个新值。在这里,我们可以有以下值:

  • WeekDay:表示星期几

  • Day:表示月份中的日

  • Month:表示年份中的月

  • Year:表示年份

从本质上讲,rtc_date_config 将日期组件组合成一个单一值,并将其写入 RTC 的日期寄存器,确保 RTC 外设准确跟踪当前日期。

我们有 rtc_time_config 函数用于配置时间:

static void rtc_time_config(uint32_t Format12_24, uint32_t Hours, uint32_t Minutes, uint32_t Seconds)
{
  register uint32_t temp = 0U;
  temp = Format12_24 |\
         (((Hours & 0xF0U) << (RTC_TR_HT_Pos - 4U)) | 
         ((Hours & 0x0FU) << RTC_TR_HU_Pos)) |\
         (((Minutes & 0xF0U) << (RTC_TR_MNT_Pos - 4U)) | 
         ((Minutes & 0x0FU) << RTC_TR_MNU_Pos)) |\
         (((Seconds & 0xF0U) << (RTC_TR_ST_Pos - 4U)) | 
         ((Seconds & 0x0FU) << RTC_TR_SU_Pos));
  MODIFY_REG(RTC->TR, (RTC_TR_PM | RTC_TR_HT | RTC_TR_HU | RTC_TR_MNT 
  | RTC_TR_MNU | RTC_TR_ST | RTC_TR_SU), temp);
}

这个函数设置 RTC 外设中的时间。与日期配置类似,rtc_time_config 首先初始化一个临时变量 temp 来保存时间值。然后将时间组件——格式、小时、分钟和秒——组合到这个变量中。MODIFY_REG 宏更新 RTC 的时间寄存器,使用新构造的时间值。在这里,我们还有以下额外的值:

  • Format12_24:这决定了时间是在 12 小时还是 24 小时格式

  • Hours:表示小时值

  • Minutes:表示分钟值

  • Seconds:表示秒值

现在我们已经实现了初始化所需的全部辅助函数,让我们继续实现 rtc_init() 函数:

void rtc_init(void)
{
    /* Enable clock access to PWR */
    RCC->APB1ENR |= PWREN;
    /* Enable Backup access to config RTC */
    PWR->CR |= CR_DBP;
    /* Enable Low Speed Internal (LSI) */
    RCC->CSR |= CSR_LSION;
    /* Wait for LSI to be ready */
    while((RCC->CSR & CSR_LSIRDY) != CSR_LSIRDY) {}
    /* Force backup domain reset */
    RCC->BDCR |= BDCR_BDRST;
    /* Release backup domain reset */
    RCC->BDCR &= ~BDCR_BDRST;
    /* Set RTC clock source to LSI */
    RCC->BDCR &= ~(1U << 8);
    RCC->BDCR |= (1U << 9);
    /* Enable the RTC */
    RCC->BDCR |= BDCR_RTCEN;
    /* Disable RTC registers write protection */
    RTC->WPR = RTC_WRITE_PROTECTION_KEY_1;
    RTC->WPR = RTC_WRITE_PROTECTION_KEY_2;
    /* Enter the initialization mode */
    if(rtc_init_seq() != 1)
    {
        // Handle initialization failure
    }
    /* Set desired date: Friday, December 29th, 2016 */
    rtc_date_config(WEEKDAY_FRIDAY, 0x29, MONTH_DECEMBER, 0x16);
    /* Set desired time: 11:59:55 PM */
    rtc_time_config(TIME_FORMAT_PM, 0x11, 0x59, 0x55);
    /* Set hour format */
    RTC->CR |= CR_FMT;
    /* Set Asynchronous prescaler */
    rtc_set_asynch_prescaler(RTC_ASYNCH_PREDIV);
    /* Set Synchronous prescaler */
    rtc_set_synch_prescaler(RTC_SYNCH_PREDIV);
    /* Exit the initialization mode */
    exit_init_seq();
    /* Enable RTC registers write protection */
    RTC->WPR = 0xFF;
}

让我们分解一下:

RCC->APB1ENR |= PWREN;

我们首先启用 PWR 模块的时钟。这一点至关重要,因为它允许我们访问和配置 RTC 外设:

PWR->CR |= CR_DBP;

接下来,我们必须启用对备份域的访问。这一步是必要的,以便更改 RTC 配置:

RCC->CSR |= CSR_LSION;

然后,我们启用 LSI 振荡器,它作为 RTC 外设的时钟源:

while((RCC->CSR & CSR_LSIRDY) != CSR_LSIRDY) {}

我们必须等待直到 LSI 振荡器稳定并准备好使用:

RCC->BDCR |= BDCR_BDRST;

为了确保配置干净,我们必须强制备份域复位:

RCC->BDCR &= ~BDCR_BDRST;

然后,我们必须释放复位,允许备份域正常工作:

RCC->BDCR &= ~(1U << 8);
RCC->BDCR |= (1U << 9);

然后,我们必须配置 RTC 外设,使其使用 LSI 振荡器作为其时钟源:

RCC->BDCR |= BDCR_RTCEN;

然后,我们必须通过设置备份域控制寄存器中的适当位来启用 RTC 外设:

RTC->WPR = RTC_WRITE_PROTECTION_KEY_1;
RTC->WPR = RTC_WRITE_PROTECTION_KEY_2;

要允许更改 RTC 寄存器,我们必须禁用写保护:

if(rtc_init_seq() != 1)
{
    // Handle initialization failure
}

我们可以使用我们的 rtc_init_seq() 辅助函数进入初始化模式。如果它失败,我们必须适当地处理错误:

rtc_date_config(WEEKDAY_FRIDAY, 0x29, MONTH_DECEMBER, 0x16);

在这一点上,我们必须使用我们的另一个辅助函数将 RTC 外设配置为所需的日期。在这个例子中,我们将日期设置为 2016 年 12 月 29 日星期五

rtc_time_config(TIME_FORMAT_PM, 0x11, 0x59, 0x55);

然后,我们必须将 RTC 外设设置为所需的日期。在这种情况下,我们将时间设置为 晚上 11:59:55

RTC->CR |= CR_FMT;

接下来,我们必须配置 RTC 外设,使其使用 24 小时格式:

rtc_set_asynch_prescaler(RTC_ASYNCH_PREDIV);
rtc_set_synch_prescaler(RTC_SYNCH_PREDIV);

然后,我们必须设置异步和同步预分频器的值:

exit_init_seq();

在这一点上,我们可以使用我们之前创建的 exit_init_seq() 辅助函数退出初始化模式:

RTC->WPR = 0xFF;

最后,我们必须重新启用 RTC 寄存器的写保护,以防止意外更改。

这个 rtc_init 函数仔细地通过启用必要的时钟、配置备份域、设置 RTC 时钟源和初始化日期和时间来设置 RTC 外设。

在继续到 main.c 文件之前,让我们实现一些辅助函数,这些函数对于处理各种 RTC 任务至关重要,例如转换值和获取当前日期和时间。

让我们从 rtc_convert_dec2bcd 函数开始:

uint8_t rtc_convert_dec2bcd(uint8_t value)
{
    return (uint8_t)((((value) / 10U) << 4U) | ((value) % 10U));
}

此函数接受一个十进制值并返回其等效的 BCD 格式,这对于设置 RTC 值很有用。

让我们更仔细地看看十进制到 BCD 的转换:

  • 首先,((value) / 10U) << 4U 将十位数字左移 4 位

  • 然后,((value) % 10U) 获取个位数字

  • OR 操作将这两个值组合起来形成 BCD 值

在继续之前,让我们更仔细地看看 BCD 格式和转换过程。

理解 BCD 格式

BCD 是一种以二进制形式表示十进制数字的方法。但这里有一个转折:不是将整个数字转换成一个单一的二进制值,而是每个十进制数字都由其自己的二进制序列表示。

BCD 是如何工作的?

在 BCD 中,十进制数字的每一位都是单独编码为 4 位二进制数的。让我们用一个例子来详细说明。

假设你有一个十进制数字 42。在 BCD 中,这将如下表示:

  • 十进制中的 4 是二进制中的 0100

  • 十进制中的 2 是二进制中的 0010

因此,42 在 BCD 中是 0100 0010。注意每个十进制数字是如何转换为 4 位二进制形式,然后组合起来表示整个数字的。

为什么使用 BCD?

你可能想知道,为什么不直接使用常规的二进制?嗯,BCD 有其优点,尤其是在需要显示数字或与人类可读格式接口的数字系统中:

  • 转换简单:在 BCD 和十进制之间进行转换很简单。每个 4 位组直接对应一个十进制数字,这使得读取和转换变得容易。

  • 显示兼容性:像数字时钟、计算器和其他数字显示设备这样的设备通常使用 BCD,因为它简化了将二进制值转换为屏幕上易于显示的格式的过程。

现在,让我们看看这与 RTC 有什么关系。

RTC 配置中的 BCD

当与 RTC 一起工作时,BCD 特别有用。RTC 硬件通常使用 BCD 来存储时间和日期值,因为它简化了这些值的显示和操作方式。例如,将时间设置为 BCD 的 12:34:56 意味着我们有以下表示:

  • 120001 0010

  • 340011 0100

  • 560101 0110

这些每一对都很容易解释,并可以轻松转换回十进制以进行显示或进一步处理。

BCD 格式是一种在二进制系统中编码十进制数字的巧妙方法。通过单独处理每个十进制数字,BCD 简化了许多操作,尤其是在与可读性强的显示或需要精确十进制表示的系统接口时。图 15**.6展示了 BCD 值如何轻松映射到数字显示上:

图 15.7:BCD 格式的显示

图 15.7:BCD 格式的显示

现在,让我们分析rtc_convert_bcd2dec函数:

uint8_t rtc_convert_bcd2dec(uint8_t value)
{
    return (uint8_t)(((uint8_t)((value) & (uint8_t)0xF0U) >> 
    (uint8_t)0x4U) * 10U + ((value) & (uint8_t)0x0FU));
}

这个函数接收一个 BCD 值并返回其十进制等效值,使得在十进制格式下处理 RTC 数据更加容易。

这是 BCD 到十进制的转换:

  • 首先,((value) & (uint8_t)0xF0U) >> (uint8_t)0x4U 提取十位数字

  • 然后,((value) & (uint8_t)0x0FU) 提取个位数字

  • 乘法和加法将这些值组合成二进制值

接下来,我们有一个用于获取日期的辅助函数——即rtc_date_get_day

uint32_t rtc_date_get_day(void)
{
    return (uint32_t)((READ_BIT(RTC->DR, (RTC_DR_DT | RTC_DR_DU))) >> 
    RTC_DR_DU_Pos);
}

这个函数读取 RTC 日期寄存器并返回当前月份。

我们可以通过使用READ_BIT(RTC->DR, (RTC_DR_DT | RTC_DR_DU))来读取日期,它读取日期的十位和个位比特。

将结果右移将值放置在正确的位置。

我们还有一个用于年份的函数——即rtc_date_get_year

uint32_t rtc_date_get_year(void)
{
    return (uint32_t)((READ_BIT(RTC->DR, (RTC_DR_YT | RTC_DR_YU))) >> 
    RTC_DR_YU_Pos);
}

这个函数读取 RTC 日期寄存器并返回当前年份。

这里,READ_BIT(RTC->DR, (RTC_DR_YT | RTC_DR_YU))读取年份的十位和个位比特。

我们还有用于获取月份、秒、分钟和小时的函数,所有这些函数都是使用与其他获取函数相同的方法实现的:

  • 首先,我们有rtc_date_get_month

    uint32_t rtc_date_get_month(void)
    {
        return (uint32_t)((READ_BIT(RTC->DR, (RTC_DR_MT | RTC_DR_
        MU))) >> RTC_DR_MU_Pos);
    }
    
  • 然后,我们有rtc_time_get_second

    uint32_t rtc_time_get_second(void)
    {
        return (uint32_t)(READ_BIT(RTC->TR, (RTC_TR_ST | RTC_TR_SU)) 
        >> RTC_TR_SU_Pos);
    }
    
  • 接下来,是rtc_time_get_minute

    uint32_t rtc_time_get_minute(void)
    {
        return (uint32_t)(READ_BIT(RTC->TR, (RTC_TR_MNT | RTC_TR_
        MNU)) >> RTC_TR_MNU_Pos);
    }
    
  • 最后,是rtc_time_get_hour

    uint32_t rtc_time_get_hour(void)
    {
        return (uint32_t)((READ_BIT(RTC->TR, (RTC_TR_HT | RTC_TR_
        HU))) >> RTC_TR_HU_Pos);
    }
    

在实现了所有这些函数之后,我们的rtc.c文件就完成了。我们的下一个任务是填充rtc.h文件。

头文件

这是代码:

#ifndef RTC_H__
#define RTC_H__
#include <stdint.h>
#include "stm32f4xx.h"
void rtc_init(void);
uint8_t rtc_convert_bcd2dec(uint8_t value);
uint32_t rtc_date_get_day(void);
uint32_t rtc_date_get_year(void);
uint32_t rtc_date_get_month(void);
uint32_t rtc_time_get_second(void);
uint32_t rtc_time_get_minute(void);
uint32_t rtc_time_get_hour(void);
#endif

在这里,我们只是公开了在rtc.c中实现的函数,使它们可以从其他文件中调用。让我们继续测试我们的驱动程序的main.c文件。

主文件

让我们更新main.c文件,使其看起来像这样:

#include <stdio.h>
#include "rtc.h"
#include "uart.h"
#define BUFF_LEN        20
uint8_t time_buff[BUFF_LEN] = {0};
uint8_t date_buff[BUFF_LEN] = {0};
static void display_rtc_calendar(void);
int main(void)
{
    /*Initialize debug UART*/
    uart_init();
    /*Initialize rtc*/
    rtc_init();
    while(1)
    {
        display_rtc_calendar();
    }
}
static void display_rtc_calendar(void)
{
    /*Display format :  hh : mm : ss*/
    sprintf((char *)time_buff,"%.2d :%.2d :%.2d",rtc_convert_
    bcd2dec(rtc_time_get_hour()),
            rtc_convert_bcd2dec(rtc_time_get_minute()),
            rtc_convert_bcd2dec(rtc_time_get_second()));
    printf("Time : %.2d :%.2d :%.2d\n\r",rtc_convert_bcd2dec(rtc_time_
    get_hour()),
            rtc_convert_bcd2dec(rtc_time_get_minute()),
            rtc_convert_bcd2dec(rtc_time_get_second()));
    /*Display format :  mm : dd : yy*/
    sprintf((char *)date_buff,"%.2d - %.2d - %.2d",rtc_convert_
    bcd2dec(rtc_date_get_month()),
            rtc_convert_bcd2dec(rtc_date_get_day()),
            rtc_convert_bcd2dec(rtc_date_get_year()));
    printf("Date : %.2d - %.2d - %.2d    ",rtc_convert_bcd2dec(rtc_
    date_get_month()),
            rtc_convert_bcd2dec(rtc_date_get_day()),
            rtc_convert_bcd2dec(rtc_date_get_year()));
}

让我们分析代码的独特之处,从display_rtc_calendar函数开始。这个函数从 RTC 外设获取当前时间和日期,格式化这些值,打印它们,并将它们存储在缓冲区以供进一步处理。

以下是我们的缓冲区定义:

#define BUFF_LEN 20
uint8_t time_buff[BUFF_LEN] = {0};
uint8_t date_buff[BUFF_LEN] = {0};

这里,我们可以看到以下内容:

  • BUFF_LEN:这定义了存储时间和日期字符串的缓冲区长度

  • time_buff:这是一个数组,用于存储格式化的时间字符串

  • date_buff:这是一个数组,用于存储格式化的日期字符串

让我们更详细地看看时间格式化和显示块:

sprintf((char *)time_buff, "%.2d :%.2d :%.2d",
        rtc_convert_bcd2dec(rtc_time_get_hour()),
        rtc_convert_bcd2dec(rtc_time_get_minute()),
        rtc_convert_bcd2dec(rtc_time_get_second()));
printf("Time : %.2d :%.2d :%.2d\n\r",rtc_convert_bcd2dec(rtc_time_get_hour()),
            rtc_convert_bcd2dec(rtc_time_get_minute()),
            rtc_convert_bcd2dec(rtc_time_get_second()));

在这里,我们可以看到以下内容:

  • sprintf:用于将时间字符串格式化到time_buff

  • rtc_convert_bcd2dec:这个函数将 RTC 中的 BCD 值转换为十进制

  • rtc_time_get_hourrtc_time_get_minutertc_time_get_second:这些函数分别从 RTC 外设检索当前小时、分钟和秒

  • printf:此函数用于将格式化输出打印到串行端口

  • %.2d:此格式说明符表示整数将以至少 2 位打印,如果需要则用前导零填充

现在,我们准备在微控制器上测试我们的 RTC 日历驱动程序。我们可以通过以下步骤测试项目:

  1. 编译并将项目上传到您的微控制器。

  2. 启动 RealTerm 或您首选的串行终端程序。

  3. 配置适当的端口和波特率。

  4. 您应该看到时间和日期值实时打印并更新,如图 图 15**.7 所示:

图 15.8:预期结果

图 15.8:预期结果

摘要

在本章中,我们探讨了 RTC 外设,这是嵌入式系统中用于计时的组件。该外设对于需要精确时间和日期维护的应用至关重要,因此对于广泛的嵌入式应用来说是基本的。

我们首先介绍了 RTC 并理解其功能。这包括深入了解 RTC 的工作原理,涉及关注晶振振荡器、计数器、时间和日期寄存器以及电池备份的重要性。我们通过案例研究说明了这些概念,展示了 RTC 在数据记录、闹钟、时间戳交易和日历功能中的实际应用。

此后,我们检查了 STM32 RTC 模块,强调了其关键特性和功能。我们讨论了以亚秒精度为标准的日历,双可编程闹钟,低功耗,备份域,夏令时调整,自动唤醒,篡改检测,数字校准以及与外部时钟的同步。每个特性都进行了详细说明,以展示其在保持精确计时中的应用和重要性。

接下来,我们分析了 STM32 参考手册中的相关寄存器,提供了对 RTC 配置和操作的详细理解。我们涵盖了 RTC_TRRTC_DRRTC_CRRTC_ISRRTC_PRERRTC_ALRMARRTC_ALRMBRRTC_WUTR 寄存器。每个寄存器的角色和关键字段都得到了解释,以确保您全面了解它们如何有助于 RTC 的功能。

最后,我们将这些知识应用于开发 RTC 驱动程序。我们介绍了创建和配置 RTC 驱动程序的步骤,从初始化序列开始,涵盖了设置日期和时间的函数。我们还实现了在十进制和 BCD 格式之间转换值的辅助函数,以及从 RTC 外设检索当前时间和日期值。

在下一章中,我们将深入了解另一个有用的外设,扩展我们的嵌入式系统开发知识和工具集。

第十六章:独立看门狗(IWDG)

在本章中,我们将学习关于独立看门狗定时器(IWDG)的内容,这是一个用于增强嵌入式系统可靠性的独特组件。IWDG 对于监控系统操作和确保系统能够从意外故障或故障中恢复至关重要。

我们将首先探讨看门狗定时器(WDT)的一般概念及其在嵌入式系统中的重要性。在此之后,我们将检查 WDT 的工作原理和 IWDG 的独特功能。接下来,我们将专门关注 STM32 的 IWDG 实现,查看其关键寄存器和配置。最后,我们将应用这些知识来开发裸机 IWDG 驱动程序,提供实际代码示例以巩固我们的理解。

在本章中,我们将涵盖以下主要主题:

  • 理解看门狗定时器

  • 看门狗定时器(WDT)的类型

  • STM32 IWDG

  • 开发独立看门狗(IWDG)驱动程序

到本章结束时,您将全面了解 IWDG 定时器及其在嵌入式系统中的关键作用。您还将获得开发 STM32 微控制器 IWDG 驱动程序和实施 IWDG 驱动程序所需的技能,以确保您的系统可以保持稳健和可靠。

技术要求

本章的所有代码示例都可以在以下 GitHub 链接中找到:

github.com/PacktPublishing/Bare-Metal-Embedded-C-Programming

理解看门狗定时器

看门狗定时器(WDT)是嵌入式系统中的无名英雄之一。它们默默地监控系统的健康状况,确保系统可以从意外中断中优雅地恢复。想象它们是警惕的守卫,始终在寻找系统故障,如果出现问题,它们会重置微控制器。在本节中,我们将探讨看门狗定时器是什么以及它们如何工作,并深入一些常见用例以说明它们的重要性。

看门狗定时器是什么?

看门狗定时器(WDT)是您的微控制器的守护者。想象一下,您正在使用一个设备,然后出了问题——开发者代码中的错误导致无限循环,或者硬件故障使系统冻结。如果没有看门狗,您的设备就会卡住,可能会造成重大问题,尤其是在医疗设备或汽车系统等关键应用中。

看门狗定时器(WDT)是一种硬件或软件计时器,如果主程序在计时器到期之前未能重置计时器,它将重置系统。这是一个简单但强大的机制,确保您的系统可以从意外问题中恢复。让我们看看它们是如何工作的。

看门狗定时器的工作原理

将看门狗定时器想象成一个需要定期翻转以防止沙子耗尽的沙漏。以下是它如何工作的逐步分解:

  1. 初始化:当您的系统启动时,您将使用特定的超时周期初始化看门狗定时器(WDT)。这个周期是您的系统可以在不重置计时器的情况下运行的最大时间。

  2. 倒计时:WDT 从设定的超时值开始倒计时。如果它达到零,它假定出了问题,并触发系统重置。

  3. 重置定时器:在定时器达到零之前,主程序需要定期重置 WDT。这一行为通常被称为喂狗踢狗。如果程序运行正确,它将继续重置定时器,防止系统重置。

  4. 系统重置:如果你的程序未能及时重置 WDT——可能是因为它卡在一个无限循环中或遇到了一个关键错误——WDT 将到期并重置系统,将其恢复到一个已知状态。

现在我们已经了解了基础知识,让我们看看一些 WDT 在现实世界中扮演关键角色的应用实例。

常见用例

以下是一些实际应用的列表。

工业自动化

在工业自动化中,可靠性至关重要。机器和流程需要连续且无故障地运行。WDT 确保如果可编程逻辑控制器(PLC)或其他控制系统挂起或崩溃,它们可以快速恢复。

示例:想象一个制造工厂中的传送带系统。控制传送带的 PLC 设置了一个 1 秒的 WDT。如果 PLC 软件由于软件错误或外部干扰在 1 秒内未能重置看门狗,WDT 将重置 PLC。这次重置确保传送带可以以最短的中断时间恢复运行,防止潜在的生产损失。

汽车系统

现代车辆在执行各种功能时,如引擎控制和信息娱乐系统,严重依赖嵌入式系统。看门狗定时器(WDTs)在确保这些系统可靠运行方面至关重要。

示例:考虑一辆汽车中的发动机控制单元(ECU)。ECU 监控和控制关键发动机参数。ECU 中的 WDT 可能被设置为 500 毫秒。如果 ECU 软件由于故障未能重置看门狗,WDT 将重置 ECU。这次重置可以防止发动机异常行为,确保车辆安全运行。

医疗设备

在医疗设备中,WDT 可以救命。如起搏器、输液泵和患者监护仪等设备必须无故障运行。

示例:以一个监测生命体征(如心率血压)的患者监护仪为例。监护仪的软件中设置了一个 2 秒的 WDT。如果软件遇到问题,未能重置看门狗,设备将重置。这次重置确保监护仪可以快速恢复并继续提供准确、实时的数据,这对于患者护理至关重要。

消费电子产品

即使在消费电子产品中,WDT 也有助于维护系统可靠性和提升用户体验。想想智能手机、智能家居设备和游戏机。

示例: 在一个智能恒温器中,软件管理温度设置和连接性。一个看门狗定时器确保如果软件冻结,系统将重置并继续运行。这种功能可以防止用户经历长时间的中断,保持家庭舒适和便利。

这些示例说明了看门狗定时器在现代系统中的关键作用。在实现看门狗定时器时,考虑几个关键因素以确保其有效性是至关重要的。让我们看看这些因素中的一些。

实际考虑因素

在实现看门狗定时器时,你必须考虑以下因素:

  • 超时周期: 根据应用需求选择适当的超时周期。太短,可能会造成不必要的重置;太长,可能无法从故障中快速恢复。

  • 复位机制: 确保在代码中定期重置看门狗定时器(喂狗)的部分表明系统正在正确运行。

  • 恢复策略: 计划系统在看门狗定时器重置后的恢复方式。确保关键数据得到保留,系统返回到安全状态。

  • 测试: 仔细测试你的看门狗定时器实现,以确保在各种故障条件下它按预期工作。

接下来,让我们看看可用的看门狗定时器类型。

看门狗定时器类型

根据其功能和集成,看门狗定时器可以根据几种类型进行分类。让我们探索最常见的类型。

内部看门狗定时器

内部看门狗定时器是集成在微控制器中的。这是一个方便的选项,因为它不需要额外的外部组件。这些定时器直接集成到微控制器的架构中,可以通过软件进行配置。

它们具有以下特性:

  • 集成: 无需外部电路

  • 配置: 通常使用微控制器的寄存器进行配置

  • 功率: 它们可以在低功耗模式下继续运行,这使得它们非常适合电池供电的应用

示例用例: 在一个小型物联网设备中,一个内部看门狗定时器可以监控微控制器的操作,而无需添加额外的硬件,确保设备在遇到错误时可以自行重置。

外部看门狗定时器

外部看门狗定时器是连接到微控制器的独立组件。这些定时器提供了额外的灵活性,可以在内部看门狗定时器不足或需要冗余时使用。

下面是他们的一些特性列表:

  • 灵活性: 可以根据具体要求选择(例如,更长的超时周期)

  • 冗余: 添加外部看门狗提供额外的安全层

  • 独立性: 独立于微控制器的时钟和电源运行

示例用例: 在一个关键汽车系统中,一个外部看门狗定时器可以提供额外的安全保护,确保即使内部定时器失败,系统也能重置。

窗口看门狗定时器

窗口看门狗定时器WWDT)通过引入一个窗口周期来增加额外的控制层。系统必须在特定的窗口周期内重置计时器;太早或太晚的重置会导致系统复位。这防止了软件陷入循环,频繁地重置看门狗(这可能会掩盖故障)。

它们的特性包括以下内容:

  • 精度:需要计时器在特定的时窗内重置

  • 故障检测:可以检测早期和晚期的看门狗复位,提供改进的故障检测

  • 安全性:通过确保计时器在适当的间隔内重置来增强系统安全性

示例用例:在医疗设备中,一个 WWDT 确保控制软件在定义的时间间隔内正确运行,增加了一个额外的可靠性层。

IWDGs

IWDGs 被设计成鲁棒且可靠。它们从一个独立的时钟源运行,通常是一个低速内部LSI)时钟,并且独立于主系统时钟运行。这种独立性确保即使在主时钟失败的情况下,它们也能继续工作。

它们的特性包括以下内容:

  • 独立性:从一个独立的时钟源运行

  • 鲁棒性:即使在主系统时钟失败的情况下也能继续工作

  • 最小配置:通常简单易配置和使用

示例用例:在工业控制系统ICS)中,一个 IWDG 确保系统可以从故障中恢复,即使主时钟源被中断。

选择合适的 WDT 取决于多个因素,包括应用的紧迫性、电源限制和所需的可靠性。

选择正确的 WDT

这里有一个快速指南,帮助您选择正确的 WDT:

  • 对于低功耗应用:由于集成度和低功耗,考虑使用内部 WDT

  • 对于高可靠性系统:使用外部 WDT 进行冗余

  • 对于需要精确时序的应用:WWDT 提供了增强的故障检测

  • 对于需要鲁棒操作的系统:IWDGs 即使在主时钟失败的情况下也能保持功能

理解不同类型的看门狗定时器(WDT)及其特性,使我们能够为我们的应用选择合适的型号。无论是为了简单性而使用的内部 WDT,还是为了冗余性而使用的外部 WDT,或者是用于精确控制的 WWDT,亦或是用于鲁棒性的 IWDG,总有一个 WDT 适合每一个需求。

在下一节中,我们将深入探讨 STM32F411 微控制器内部的 IWDG,检查其特性和如何利用它来提高系统可靠性。

STM32 IWDG

在本节中,我们将分析 STM32 IWDG 模块,探讨其主要特性和其他相关信息,以帮助您了解如何在嵌入式应用中利用这一强大功能。

STM32 微控制器具有两种类型的 WDT:IWDG 和窗口看门狗WWDG)。两者都是通过启动系统复位来检测和纠正软件故障,但它们各自具有独特的特性和应用。

IWDG 使用专用的 LSI 时钟运行,确保即使主系统时钟失败,它也能继续工作。这使得它在需要持续监控的应用中非常可靠,无论主时钟的状态如何。相比之下,WWDG 从 APB1 时钟中获取时钟,并具有可配置的时间窗口。系统必须在时间窗口内刷新 WWDG;如果过早或过晚,将触发系统复位。

IWDG(独立看门狗定时器)最适合需要独立看门狗进程且对时间精度要求较低的应用,而 WWDG(窗口看门狗定时器)则适用于需要精确时间窗口的应用。让我们看看 IWDG 的一些关键特性。

IWDG 的关键特性

STM32 微控制器中的 IWDG 具有几个关键特性:

  • 自由运行倒计时器:IWDG 作为一个自由运行的倒计时器运行,从预设值开始连续倒计时。

  • 独立时钟源:它使用独立的电阻-电容RC)振荡器,允许它在低功耗模式(如待机模式)下工作。

  • 超时系统复位:如果 WDT 被激活,倒计时器达到零(0x000),将触发系统复位。

  • 写入访问保护:要修改关键寄存器,需要特定的操作序列,以确保防止意外或恶意的修改。

让我们看看它是如何工作的。

IWDG 的工作原理

IWDG 模块作为微控制器的独立保护措施运行,确保系统可以从软件故障中恢复。图 16.1展示了 IWDG 的模块框图,来源于参考手册:

图 16.1:IWDG 模块框图

图 16.1:IWDG 模块框图

让我们分解其功能模块及其工作方式:

  • 0xCCCC写入键寄存器(IWDG_KR)。这一操作启动了倒计时器,它从0xFFF的复位值开始倒计时。

  • 0xAAAA写入IWDG_KR寄存器,这会将计数器重新加载为从重载寄存器(IWDG_RLR)中读取的值,并防止复位。

  • 硬件看门狗特性:如果启用,IWDG 在电源开启时自动激活。在此模式下,如果计数器达到零之前没有用适当的值写入键寄存器,WDT 将生成复位。

  • IWDG_PR(预分频寄存器)和重载IWDG_RLR寄存器,我们必须暂时禁用写入访问保护,通过将代码0x5555写入IWDG_KR寄存器来实现。完成此操作后,必须立即对这些寄存器进行更改;否则,访问保护将重新启用。

在心中有了 IWDG 模块的总体概述后,让我们逐一分析关键寄存器。

IWDG 寄存器

在本节中,我们将探讨 IWDG 外设中一些关键寄存器的特性和功能。

让我们从 IWDG_KR 开始。

IWDG 密钥寄存器 (IWDG_KR)

IWDG_KR 寄存器是一个用于控制 IWDG 操作的关键寄存器,包括启动看门狗、重载计数器和禁用对其他寄存器的写访问。此寄存器在管理 IWDG 功能方面至关重要。

此寄存器中的关键操作包括以下内容:

  • 0xCCCC: 启动 IWDG。将此值写入 IWDG_KR 将启动 IWDG 定时器。

  • 0xAAAA: 重载计数器。写入此值将重载 IWDG 计数器,防止其达到零并触发系统复位。

  • 0x5555: 禁用写保护。此值允许修改预分频器和重载寄存器。

接下来,让我们讨论 IWDG_PR

IWDG 预分频器寄存器 (IWDG_PR)

IWDG_PR 寄存器用于设置预分频器值,该值通过除以 LSI 时钟来确定 IWDG 时钟的频率。调整此寄存器有助于配置 WDT 的倒计时速度。

此寄存器中的关键位是 PR[2:0]:预分频器值。这些位可以设置为将 LSI 时钟除以 4、8、16、32、64、128 或 256,从而在设置 WDT 间隔时提供灵活性。

接下来,我们转向 IWDG_RLR

IWDG 重载寄存器 (IWDG_RLR)

IWDG_RLR 寄存器定义了 IWDG 计数器的重载值。此值确定在未及时重载的情况下,看门狗触发系统重置的超时时间。

此寄存器中的关键字段是 RL[11:0];这意味着重载值。它是一个 12 位值,用于设置计数器的重载值,其范围从 0x000 到 0xFFF。

最后,我们检查 IWDG_SR

IWDG 状态寄存器 (IWDG_SR)

IWDG_SR 寄存器提供了关于 IWDG 的状态信息,指示是否正在更新预分频器或重载寄存器。

此寄存器中的关键位包括以下内容:

  • PVU预分频器值更新PVU)。此位指示预分频器值正在更新。

  • RVU重载值更新RVU)。此位指示重载值正在更新。

在对 IWDG 的功能和其寄存器有清晰理解的基础上,我们现在可以继续到下一节,我们将开发 IWDG 驱动程序。

开发 IWDG 驱动程序

在本节中,我们将使用本章学到的知识来开发 IWDG 驱动程序。

让我们从设置项目开始。

按照前面章节中概述的步骤,在你的 IDE 中创建你之前项目的副本。将此复制的项目重命名为 IWDG。接下来,在 Src 文件夹中创建一个名为 iwdg.c 的新文件,并在 Inc 文件夹中创建一个名为 iwdg.h 的新文件。

IWDG 实现文件

在你的 iwdg.c 文件中添加以下代码:

#include "iwdg.h"
#define IWDG_KEY_ENABLE                 0x0000CCCCU
#define IWDG_KEY_WR_ACCESS_ENABLE       0x00005555U
#define IWDG_PRESCALER_4              0x00000000U
#define IWDG_RELOAD_VAL              0xFFF
static uint8_t isIwdg_ready(void);
void iwdg_init(void)
{
    /*Enable the IWDG by writing 0x0000CCCC in the IWDG_KR register*/
    IWDG->KR = IWDG_KEY_ENABLE;
    /*Enable register access by writing 0x0000 5555 in the IWDG_KR 
    register*/
    IWDG->KR = IWDG_KEY_WR_ACCESS_ENABLE;
    /*Set the IWDG Prescaler*/
    IWDG->PR =  IWDG_PRESCALER_4;
    /*Set the reload register (IWDG_RLR) to the largest value 0xFFF*/
    IWDG->RLR = IWDG_RELOAD_VAL;
    /*Wait for the registers to be updated (IWDG_SR = 0x0000 0000)*/
    while(isIwdg_ready() != 1){}
    /*Refresh the counter value with IWDG_KR (IWDG_KR = 0x0000 AAAA)*/
    IWDG->KR = IWDG_KEY_RELOAD;
}
static uint8_t isIwdg_ready(void)
{
 return ((READ_BIT(IWDG->SR, IWDG_SR_PVU | IWDG_SR_RVU) == 0U) ? 1UL : 
 0UL);
}

让我们分解一下。首先让我们看看 宏定义

#include "iwdg.h"
#define IWDG_KEY_ENABLE                 0x0000CCCCU
#define IWDG_KEY_WR_ACCESS_ENABLE       0x00005555U
#define IWDG_PRESCALER_4                0x00000000U
#define IWDG_RELOAD_VAL                 0xFFF
  • IWDG_KEY_ENABLE:此宏定义了启用 IWDG 的密钥

  • IWDG_KEY_WR_ACCESS_ENABLE: 这个宏定义了用于启用对 IWDG 寄存器写访问的密钥

  • IWDG_PRESCALER_4: 这个宏定义了 IWDG 的预分频器值

  • IWDG_RELOAD_VAL: 这个宏将重载值设置为最大值,0xFFF,提供最长的超时周期

接下来,我们有 iwdg_init() 函数。

IWDG->KR = IWDG_KEY_ENABLE;

这行代码将0x0000CCCC写入 IWDG 密钥寄存器KR)以启用 IWDG。

IWDG->KR = IWDG_KEY_WR_ACCESS_ENABLE;

这行代码将 0x00005555 写入 IWDG 密钥寄存器以启用对预分频器和重载寄存器的写访问。

IWDG->PR = IWDG_PRESCALER_4;

这行代码将预分频器寄存器设置为按 IWDG_PRESCALER_4 定义的除以 4,以定义预分频器。

IWDG->RLR = IWDG_RELOAD_VAL;

这将重载寄存器设置为最大值 0xFFF 以获得最长的超时周期。

while (isIwdg_ready() != 1) {}

这将等待直到 IWDG 状态寄存器SR)指示预分频器和重载寄存器已更新并准备好。

IWDG->KR = IWDG_KEY_RELOAD;

这将 0x0000AAAA 写入 IWDG 密钥寄存器以重载计数器并防止 IWDG 重置系统。

isIwdg_ready() 函数检查状态寄存器以查看 PVURVU 位是否清除。如果两个位都清除,则返回 1,表示 IWDG 已准备好,否则返回 0。

我们现在已准备好在 main.c 中进行测试。

主文件

按照以下所示更新你的 main.c 文件:

#include <stdio.h>
#include "adc.h"
#include "uart.h"
#include "gpio.h"
#include "iwdg.h"
#include "gpio_exti.h"
uint8_t g_btn_press;
static void check_reset_source(void);
int main(void)
{
    /*Initialize debug UART*/
    uart_init();
    /*Initialize LED*/
    led_init();
    /*Initialize EXTI*/
    pc13_exti_init();
    /*Find reset source*/
    check_reset_source();
    /*Initialize IWDG*/
    iwdg_init();
    while(1)
    {
        if( g_btn_press != 1)
         {
              /*Refresh IWDG down-counter to default value*/
             IWDG->KR = IWDG_KEY_RELOAD;
             led_toggle();
             for(int i = 0; i < 90000; i++){}
         }
    }
}

main 函数首先在连接到蓝色按钮的引脚 PC13 上设置 EXTI,然后调用 check_reset_source 以确定上一次重置是否由 IWDG 引起。之后,它初始化 IWDG 本身,确保系统可以从软件故障中恢复。main 循环持续检查 g_btn_press 的状态;如果按钮未被按下,它刷新 IWDG 以防止系统重置,并切换 LED,提供系统活动的视觉指示。

让我们看看代码的下一部分:

static void check_reset_source(void)
{
if ((RCC->CSR & RCC_CSR_IWDGRSTF) == (RCC_CSR_IWDGRSTF))
      {
          /*Clear IWDG Reset flag*/
          RCC->CSR = RCC_CSR_RMVF;
          /*Turn LED On*/
          led_on();
          printf("RESET was caused by IWDG.....\n\r");
          while( g_btn_press != 1)
          {
          }
          g_btn_press =  0;
      }
}

check_reset_source 函数确定最近的系统重置是否由 IWDG 触发。它首先检查 CSR 寄存器中的 IWDG 重置标志(RCC_CSR_IWDGRSTF)。如果此标志被设置,则确认看门狗触发了重置。然后,通过写入 RCC_CSR_RMVF 位清除此标志,确保标志被重置以供未来检测。作为 IWDG 触发的重置的视觉指示,LED 被点亮,并通过 UART 打印一条消息。函数进入一个循环,等待用户按下按钮(通过 g_btn_press 变量检测)在清除按钮按下状态并退出之前。

然后,有一个回调:

static void exti_callback(void)
{
    g_btn_press = 1;
}

接着是中断处理程序:

void EXTI15_10_IRQHandler(void) {
    if((EXTI->PR & LINE13)!=0)
    {
        /*Clear PR flag*/
        EXTI->PR |=LINE13;
        //Do something...
        exti_callback();
    }
}

exti_callback函数是我们中断处理机制的一个简单但至关重要的部分。它的唯一目的是将g_btn_press标志设置为1,表示检测到了按钮按下。这个标志随后在主循环中用于控制程序流程。EXTI15_10_IRQHandler函数是 10 到 15 行外部中断的中断处理程序。当在第 13 行触发中断时,此处理程序首先检查挂起寄存器(PR)以确认中断源。一旦验证,它通过写入PR寄存器来清除挂起标志。清除中断后,处理程序调用exti_callback来更新g_btn_press标志。

测试项目

要在微控制器上测试项目,请按照以下步骤操作:

  1. 构建和运行项目:编译代码并将其上传到你的微控制器。一旦运行,你应该观察到绿色 LED 闪烁,这表明系统正在正常工作。

  2. 监控串行输出:打开 RealTerm 或其他串行终端应用程序。使用适当的端口和波特率进行配置,以查看调试信息。这将允许你确认系统因 IWDG 而重新启动。

  3. 触发看门狗复位:按下蓝色按钮以停止刷新 IWDG 计时器。在 IWDG 超时期间,系统将重置,你应该在串行终端中看到相应的消息。

概述

在本章中,我们探讨了 IWDG 计时器,这是增强嵌入式系统可靠性的重要组件。我们首先讨论了看门狗(WDT)的一般概念,强调了它们在确保系统可以从意外故障或故障中恢复的作用。我们探讨了 WDT 的工作原理,并突出了 IWDG 的独特特性。

接下来,我们专注于 STM32 对 IWDG 的实现,检查了其关键寄存器和配置选项。我们详细介绍了诸如密钥寄存器(IWDG_KR)、预分频寄存器(IWDG_PR)、重载寄存器(IWDG_RLR)和状态寄存器(IWDG_SR)等基本寄存器的目的和用法。这为我们提供了如何配置和控制 IWDG 以实现稳健系统操作的综合理解。

我们还提供了实际示例来巩固我们的理解,包括裸机 IWDG 驱动程序的开发。这包括初始化 IWDG、配置其预分频和重载值,并实现必要的函数以确保系统可以从软件故障中恢复。

在下一章中,我们将学习直接内存访问DMA)模块,这是用于数据传输的高级功能。

第十七章:直接内存访问(DMA)

在本章中,我们将探讨 直接内存访问DMA),这是微控制器中的一个强大功能,允许外设在不涉及 CPU 的情况下在内存之间传输数据。这种功能对于提高数据吞吐量和释放 CPU 以处理其他任务至关重要,对于高性能嵌入式系统开发是基础。

我们将首先了解 DMA 的基本原理及其在嵌入式系统中的重要性。然后,我们将深入研究 STM32F4 微控制器中 DMA 控制器的具体细节,检查其结构和功能以及它如何管理数据传输。随后,我们将应用这些理论知识来开发针对各种用例的实用 DMA 驱动程序,包括内存到内存传输、模拟数字转换器ADC)数据传输和通用异步收发传输器UART)通信。

在本章中,我们将涵盖以下主要内容:

  • DMA 概述

  • STM32F4 DMA

  • 开发 DMA ADC 驱动程序

  • 开发 DMA UART 驱动程序

  • 开发 DMA 内存到内存驱动程序

到本章结束时,您将全面了解 DMA 的工作原理以及如何在项目中实现它。您将能够开发高效的 DMA 驱动程序来处理各种场景下的数据传输,显著提升嵌入式系统的性能和响应速度。

技术要求

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/Bare-Metal-Embedded-C-Programming

理解直接内存访问(DMA)

DMA 是一个可以显著提升您嵌入式系统性能的功能。如果您在微控制器项目中处理过数据传输,您就知道这对 CPU 来说是一项多么繁重的任务,需要处理所有这些数据移动。这就是 DMA 走进来成为游戏改变者的地方,它将数据传输任务从 CPU 中卸载,使其能够专注于更关键的功能。让我们看看它是如何工作的。

DMA 的工作原理

那么,DMA 究竟是什么,它是如何工作的呢?简单来说,DMA 是一种允许微控制器内的外设直接在内存之间传输数据的方法,无需持续 CPU 干预。想象一下,它就像一个专门的助手,接管了移动盒子(数据)的繁琐任务,让您(CPU)可以专注于更重要的工作,比如解决复杂问题或管理其他外设。

微控制器中的典型 DMA 控制器具有多个通道,每个通道都能够处理特定的数据传输操作。每个通道都可以独立配置,以管理各种外设和内存之间的传输。

下面我们将一步一步地看看 DMA 通常是如何操作的:

  1. 初始化: 配置 DMA 控制器和通道。此设置包括指定源地址和目标地址、数据传输方向以及要传输的数据单元数量。

  2. 触发器: 数据传输由触发器启动,这可以是一个事件,例如外围设备表示它已准备好发送或接收数据,或者是一个软件命令。

  3. 数据传输: 一旦触发,DMA 控制器接管,从源地址读取数据并将其写入目标地址。这个过程一直持续到指定的数据单元数量传输完毕。

  4. 完成: 在传输完成后,DMA 控制器可以生成一个中断来通知 CPU 传输已完成,从而使系统能够执行任何必要的传输后处理。

接下来,让我们看看 DMA 控制器的关键特性。

关键特性

DMA 控制器集成了许多特性,使它们变得多功能且强大。让我们分解一些你经常遇到的 key specifications:

  • 通道和流: DMA 控制器通常具有多个通道和流,每个都能处理不同的传输。例如,STM32F4 微控制器在其 DMA 控制器中最多有 16 个流。

  • 优先级: 通道可以分配不同的优先级,确保更关键传输的优先级高于不那么关键的传输。

  • 传输类型: DMA 可以处理各种类型的传输,包括内存到内存、外设到内存和内存到外设。

  • FIFO: 一些 DMA 控制器配备了 先进先出FIFO)缓冲区,这有助于管理数据流并提高效率,尤其是在突发传输中。

  • 循环模式: 此模式允许 DMA 在循环中连续传输数据,这对于需要持续数据流的设备特别有用,例如音频或视频流。

  • 中断: DMA 控制器可以在传输完成、半传输完成和传输错误时生成中断,使 CPU 能够对传输的不同阶段做出适当的反应。

要了解 DMA 的真正威力,让我们看看一些 DMA 发挥作用的常见用例。

常见用例

这里有一些 DMA 的常见用例:

  • 音频流传输: DMA 在需要连续数据流的应用程序中被大量使用,这对于音频应用至关重要。例如,在数字音频播放器中,音频样本必须连续发送到 数字到模拟转换器DAC)。使用 DMA,音频数据可以从内存流到 DAC,而无需 CPU 干预,确保流畅播放并释放 CPU 来管理用户界面和其他任务。

  • 传感器数据采集:在环境监测或工业自动化等应用中,传感器通常需要以精确的间隔采样数据。例如,ADC 可以配置为连续采样温度数据,DMA 将采样数据直接传输到内存。这种设置确保 CPU 不会被处理每个单独样本所拖累,从而保持高效及时的数据收集。

  • 通信接口:在处理高速通信协议(如 UART、SPI 或 I2C)时,DMA 是救命稻草。考虑这样一个场景,嵌入式系统需要将通过 UART 接收到的数据记录到 SD 卡。如果没有 DMA,CPU 需要处理每个数据字节,对其进行处理,然后将其写入 SD 卡,这可以非常低效。使用 DMA,通过 UART 接收到的数据可以直接写入内存,另一个 DMA 通道可以将其传输到 SD 卡,所有这些操作都只需要最小的 CPU 干预。

  • 图形处理:DMA 在涉及图形的应用程序中也至关重要,例如更新显示缓冲区。在一个需要不断刷新显示的系统中,DMA 可以处理从内存到显示控制器的图像数据传输。这确保了平滑且无闪烁的图形渲染,同时 CPU 可以专注于生成下一帧或管理用户输入。

考虑到这一点,让我们比较一些 DMA 解决方案和非 DMA 解决方案。

案例研究 1 – 音频流

场景:你正在开发一个音频播放系统,该系统从微控制器流式传输数字音频数据到 DAC。

不使用 DMA:CPU 负责从内存中检索每个音频样本并发送到 DAC。鉴于音频应用所需的高采样率(例如,CD 音质音频的 44.1 kHz),CPU 必须每秒处理数万个中断,仅为了保持音频流。这种持续的负载显著限制了 CPU 执行其他任务的能力,可能导致音频故障和系统响应速度降低。

使用 DMA:DMA 控制器被配置为直接从内存传输音频数据到 DAC。CPU 设置 DMA 传输并处理更高级别的任务,仅偶尔检查传输状态。这种设置确保了平滑且不间断的音频播放,同时释放 CPU 来管理系统的其他方面,例如用户界面和控制逻辑。

案例研究 2 – 高速数据采集

场景:你正在开发一个数据采集系统,该系统通过 ADC 连续从多个传感器采样数据,并将数据存储以供后续分析。

不使用 DMA:CPU 必须处理每个 ADC 转换,读取数据并将其存储在内存中。如果采样率很高,CPU 可能会过载,导致样本丢失和数据收集不可靠。这种方法也可能使实时数据处理和分析复杂化,因为 CPU 被数据流管理所困扰。

使用 DMA:ADC 被配置为生成 DMA 请求。每次转换完成后,DMA 控制器将数据从 ADC 传输到内存,无需 CPU 干预。CPU 可以随后批量处理收集到的数据,确保没有样本丢失,并实现实时数据分析和决策。

案例研究 3 – LCD 显示刷新

场景:您正在开发一个图形应用程序,该程序持续更新 LCD 显示屏上的新数据。

不使用 DMA:CPU 必须通过直接将每个像素或数据行发送到 LCD 控制器来更新显示。这个过程可能非常占用 CPU 资源,特别是对于高分辨率显示器,会导致性能下降和用户界面响应性降低。

使用 DMA:DMA 控制器被配置为从内存传输显示数据到 LCD 控制器。CPU 设置 DMA 传输然后专注于生成新的图形数据或处理用户输入。DMA 控制器确保显示平滑高效地更新。

在每个案例研究中,我们都看到了使用 DMA 如何改变系统的能力,释放 CPU 来处理更关键的任务,并确保数据传输高效可靠。在您的项目中理解和实现 DMA 可以导致更健壮、响应更快、性能更高的嵌入式系统。

在下一节中,我们将深入了解 STM32F4 DMA 控制器的具体细节,探讨其架构、关键寄存器和实际实现技术。

STM32F4 微控制器的 DMA 模块

每个 STM32F4 微控制器配备两个 DMA 控制器,每个控制器支持多达8 个流。每个流可以管理多个请求,总共提供16 个流来处理各种数据传输任务。是一个单向路径,它促进了源和目的地之间的数据传输。该架构包括一个仲裁器来优先处理这些 DMA 请求,确保高优先级传输得到及时处理。

让我们看看 STM32F4 DMA 控制器的一些关键特性。

STM32F4 DMA 控制器的关键特性

以下 STM32F4 DMA 控制器的关键特性:

  • 独立 FIFO:每个流包含一个四字 FIFO 缓冲区,可以以直接模式或 FIFO 模式运行。在直接模式下,数据传输在请求后立即发生,而 FIFO 模式允许阈值级缓冲,提高突发数据传输的效率。

  • 灵活配置:每个流可以配置为处理以下内容:

    • 外设到内存

    • 内存到外设

    • 内存到内存传输

此外,可以设置流进行常规或双缓冲传输,后者通过自动交换内存缓冲区实现无缝的数据处理:

  • 优先级和仲裁:DMA 流请求通过软件以四个级别进行优先级排序——非常高。如果两个流的优先级相同,基于流号的硬件优先级确保有序的数据传输。

  • 增量和非增量传输:DMA 控制器支持源和目标地址的增量和非增量寻址。它可以管理4、8 或 16 次打击的突发传输,优化带宽使用。术语打击指的是在单个 DMA 事务中传输的数据的单独单元。

  • 中断和错误处理:每个流支持多个事件标志,如传输完成、半传输、传输错误、FIFO 错误和直接模式错误。这些标志可以触发中断,提供强大的错误处理和状态监控。

要充分利用 STM32F DMA 控制器,了解 DMA 事务和通道选择非常重要。让我们分解这些概念。

  • 使用DMA_SxNDTR寄存器跟踪剩余数据项的数量

  • DMA_SxCR寄存器中的CHSEL位。这种灵活性允许各种外设有效地启动 DMA 请求。

之前我们讨论了 STM32F4 DMA 控制器支持三种不同的传输模式。现在,让我们探索每种模式的特性。

传输模式

有三种传输模式:

  • DMA_SxCR寄存器中的EN位。如果启用,该位使用 FIFO 缓冲区将数据从外设传输到内存。

  • 内存到外设模式:这与外设到内存类似,但传输方向相反。数据从内存加载并发送到外设。

  • 内存到内存模式:这种模式是独特的,因为它不需要外设请求。DMA 流在两个内存位置之间传输数据,如果启用,则使用 FIFO 缓冲区。这对于内存内部的大数据传输特别有用。

DMA 控制器可以自动增加源和目标指针,从而在不同内存区域之间实现高效的数据传输。这可以通过DMA_SxCR寄存器中的PINCMINC位进行配置。

STM32F4 DMA 控制器还提供了数据模式选项。

DMA 数据模式

此数据模式选项包括以下内容:

  • DMA_SxNDTR寄存器。这对于需要连续记录数据的应用程序(如 ADC 采样)特别有用。

  • 双缓冲模式:双缓冲模式通过允许 DMA 控制器自动在两个内存缓冲区之间交换,从而提高效率。这确保了连续的数据处理。当 CPU 处理一个缓冲区时,DMA 可以将下一组数据加载到另一个缓冲区中。

在下一节中,我们将从参考手册中检查 STM32F4 DMA 模块框图。这将帮助我们更好地理解 DMA 控制器的关键特性和功能。

STM32F4 DMA 模块框图

DMA 有两个数据传输端口——一个外围端口和一个内存端口,如图 17.1所示。

图 17.1:DMA 模块,指示数据传输端口

图 17.1:DMA 模块,指示数据传输端口

两个 DMA 模块各有八个不同的流,每个流都专门用于处理来自各种外围设备的内存访问请求。

图 17.2:DMA 模块,指示流

图 17.2:DMA 模块,指示流

每个流可以容纳最多八个可选通道,这些通道可以通过软件配置来启用多个外围设备发起 DMA 请求。然而,在任何给定的流中,一次只能有一个通道处于活动状态。

图 17.3:DMA 模块,放大显示通道

图 17.3:DMA 模块,放大显示通道

要查找 DMA 通道和流与微控制器各种外围设备的映射,请参阅参考手册的第 170 页RM0383)。

在我们开始开发 DMA 驱动程序之前,最后一部分是熟悉关键的 DMA 寄存器。

关键 STM32 DMA 寄存器

在本节中,我们将探讨 DMA 外围中一些关键寄存器的特性和功能,从 DMA 流配置寄存器开始。

DMA 流配置寄存器(DMA_SxCR)

DMA 流配置寄存器(DMA_SxCR)是用于配置 DMA 流操作设置的初级寄存器之一。此寄存器允许我们设置各种参数,例如数据方向、数据项的大小和流的优先级级别。此寄存器中的关键位包括以下内容:

  • EN:流使能。设置此位激活流。

  • CHSEL[2:0]:通道选择。这些位选择流的 DMA 通道。

  • DIR[1:0]:数据传输方向。这些位指定数据传输的方向(外围到内存、内存到外围或内存到内存)。

  • CIRC:循环模式。设置此位启用循环模式,允许连续的数据传输。

  • PINC:外围增量模式。当设置时,此位在每个数据传输后增加外围地址。

  • MINC:内存增量模式。当设置时,此位在每个数据传输后增加内存地址。

  • PSIZE[1:0]:外围数据大小。这些位指定从外围读取或写入的数据项的大小(8 位、16 位或 32 位)。

  • MSIZE[1:0]:内存数据大小。这些位指定从内存读取或写入的数据项的大小。

  • PL[1:0]: 优先级。这些位设置流的优先级(低、中、高或非常高)。

你可以在 STM32F411 参考手册的第 190 页找到关于此寄存器的详细信息(RM0383)。接下来,我们有 DMA 流数据数量寄存器 (DMA_SxNDTR)。

DMA 流数据数量寄存器 (DMA_SxNDTR)

DMA 流数据数量寄存器 (DMA_SxNDTR) 指定了 DMA 流要传输的数据项数量。此寄存器对于控制数据传输的长度至关重要。

此寄存器中只有一个字段是 NDT[15:0]。这代表数据项的数量。此字段指定要传输的数据项总数。每次传输后,此寄存器中的值会递减,直到为零。

关于此寄存器的更多信息可以在参考手册的第 193 页找到。让我们继续到 DMA 流外围地址寄存器 (DMA_SxPAR)。

DMA 流外围地址寄存器 (DMA_SxPAR)

DMA 流外围地址寄存器 (DMA_SxPAR) 存储了将要从中读取或写入数据的外围数据寄存器的地址。

此寄存器中只有一个字段是 PA[31:0]。这代表 外围地址。此字段包含涉及数据传输的外围数据寄存器的地址。

更多关于此寄存器的详细信息可以在参考手册的第 194 页找到。最后,我们有 DMA 流内存地址寄存器 (DMA_SxM0ARDMA_SxM1AR)。

DMA 流内存地址寄存器 (DMA_SxM0AR 和 DMA_SxM1AR)

这些寄存器存储用于数据传输的内存位置的地址。DMA_SxM0AR 寄存器用于单缓冲区模式,而 DMA_SxM0ARDMA_SxM1AR 都用于双缓冲区模式。

这些寄存器中只有一个字段是 MA[31:0]。这代表 内存地址。此字段包含涉及数据传输的内存位置的地址。有关详细信息,请参阅参考手册的第 194 页。

开发 ADC DMA 驱动程序

在本节中,我们将开发三个不同的 DMA 驱动程序 – 一个用于传输 ADC 数据,另一个用于 UART 数据,第三个用于在内存位置之间传输数据。让我们从 ADC DMA 驱动程序开始。

ADC DMA 驱动程序

在你的 IDE 中创建你之前项目的副本,按照前面章节中概述的步骤进行。将此复制的项目重命名为 ADC_DMA。接下来,在 Src 文件夹中创建一个名为 adc_dma.c 的新文件,并在 Inc 文件夹中创建一个名为 adc_dma.h 的新文件。

在你的 adc_dma.c 文件中添加以下代码:

#include "adc_dma.h"
#define GPIOAEN            (1U<<0)
#define ADC1EN            (1U<<8)
#define CR1_SCAN        (1U<<8)
#define CR2_DMA            (1U<<8)
#define CR2_DDS            (1U<<9)
#define CR2_CONT        (1U<<1)
#define CR2_ADCON        (1U<<0)
#define CR2_SWSTART        (1U<<30)
#define DMA2EN                (1U<<22)
#define DMA_SCR_EN          (1U<<0)
#define DMA_SCR_MINC        (1U<<10)
#define DMA_SCR_PINC        (1U<<9)
#define DMA_SCR_CIRC        (1U<<8)
#define DMA_SCR_TCIE        (1U<<4)
#define DMA_SCR_TEIE        (1U<<2)
#define DMA_SFCR_DMDIS        (1U<<2)
uint16_t adc_raw_data[NUM_OF_CHANNELS];
void adc_dma_init(void)
{
    /************GPIO Configuration**********/
    /*Enable clock access to ADC GPIO Pin's Port*/
    RCC->AHB1ENR |= GPIOAEN;
    /*Set PA0 and PA1 mode to analog mode*/
    GPIOA->MODER |= (1U<<0);
    GPIOA->MODER |= (1U<<1);
    GPIOA->MODER |= (1U<<2);
    GPIOA->MODER |= (1U<<3);
    /************ADC Configuration**********/
    /*Enable clock access to ADC*/
    RCC->APB2ENR |= ADC1EN;
    /*Set sequence length*/
    ADC1->SQR1 |= (1U<<20);
    ADC1->SQR1 &= ~(1U<<21);
    ADC1->SQR1 &= ~(1U<<22);
    ADC1->SQR1 &= ~(1U<<23);
    /*Set sequence*/
    ADC1->SQR3 = (0U<<0) | (1U<<5);
    /*Enable scan mode*/
    ADC1->CR1 = CR1_SCAN;
    /*Select to use DMA*/
    ADC1->CR2 |=CR2_CONT |CR2_DMA|CR2_DDS;
    /************DMA Configuration**********/
    /*Enable clock access to DMA*/
    RCC->AHB1ENR |=DMA2EN;
    /*Disable DMA stream*/
    DMA2_Stream0->CR &=~DMA_SCR_EN;
    /*Wait till DMA is disabled*/
    while((DMA2_Stream0->CR & DMA_SCR_EN)){}
    /*Enable Circular mode*/
    DMA2_Stream0->CR |=DMA_SCR_CIRC;
    /*Set MSIZE i.e Memory data size to half-word*/
    DMA2_Stream0->CR |= (1U<<13);
    DMA2_Stream0->CR &= ~(1U<<14);
    /*Set PSIZE i.e Peripheral data size to half-word*/
    DMA2_Stream0->CR |= (1U<<11);
    DMA2_Stream0->CR &= ~(1U<<12);
    /*Enable memory addr increment*/
    DMA2_Stream0->CR |=DMA_SCR_MINC;
    /*Set periph address*/
    DMA2_Stream0->PAR = (uint32_t)(&(ADC1->DR));
    /*Set mem address*/
    DMA2_Stream0->M0AR = (uint32_t)(&adc_raw_data);
    /*Set number of transfer*/
    DMA2_Stream0->NDTR = (uint16_t)NUM_OF_CHANNELS;
    /*Enable DMA stream*/
    DMA2_Stream0->CR |= DMA_SCR_EN;
    /************ADC Configuration**********/
    /*Enable ADC*/
    ADC1->CR2 |=CR2_ADCON;
    /*Start ADC*/
    ADC1->CR2 |=CR2_SWSTART;
}

让我们一步一步地通过代码的每个部分来了解其目的和功能。

  1. adc_dma.h 头文件,它调用 stm32f4xx.h 文件并包含我们 DMA 驱动器通道数的宏。然后我们使用 #define 语句定义了几个常量。这些常量代表各种寄存器和控制标志的位掩码,使代码更易于阅读和维护。

  2. adc_raw_data,用于存储原始 ADC 数据。该数组的大小由预定义的常量 NUM_OF_CHANNELS 确定。

  3. adc_dma_init 函数,我们首先启用 GPIOA 的时钟,这对于配置 ADC 所使用的 GPIO 引脚是必要的。然后我们将 PA0PA1 引脚的模式设置为模拟,因为它们连接到 ADC 通道。

  4. 接下来,我们启用 ADC1 的时钟,并配置 ADC 序列长度和通道序列。我们将 ADC 设置为扫描模式,允许其顺序转换多个通道。此外,我们为 ADC 启用 DMA 和连续转换模式。

  5. DMA2 并确保在配置任何配置之前禁用 DMA 流。我们配置 DMA 流为循环模式,允许连续数据传输。我们将内存和外围数据大小设置为半字(16 位)。我们启用内存地址递增,以正确地遍历 adc_raw_data 数组,并将外围地址设置为 ADC 数据寄存器。我们指定要传输的数据项数量,最后启用 DMA 流。

  6. 启用并启动 ADC:在最后几步中,我们只需启用 ADC 并通过设置适当的控制位来启动转换过程。

我们接下来的任务是填充 adc_dma.h 文件。以下是代码:

#ifndef ADC_DMA_H__
#define ADC_DMA_H__
#include <stdint.h>
#include "stm32f4xx.h"
void adc_dma_init(void);
#define NUM_OF_CHANNELS        2
main.c file. Update your main.c file, as shown here:

include <stdio.h>

include "uart.h"

include "adc_dma.h"

extern uint16_t adc_raw_data[NUM_OF_CHANNELS];

int main(void)

{

/* 初始化调试 UART */

uart_init();

/* 初始化 ADC DMA */

adc_dma_init();

while(1)

{

printf("传感器一的数据值 : %d \n\r ",adc_raw_data[0]);

printf("传感器二的数据值 : %d \n\r ",adc_raw_data[1]);

for( int i = 0; i < 90000; i++){}

}

}


			This code initializes the UART for debugging, and it sets up the ADC with DMA to continuously read sensor data from ADC channels connected to GPIO pins. In the main function, we start by initializing the UART for communication, and then we call the `adc_dma_init` function to configure the ADC and DMA for data transfers. In the infinite loop, we repeatedly print the values from two sensors stored in the `adc_raw_data` array to the console.
			To test the project, follow the steps we outlined in *Chapter 11*. Let’s proceed by developing the UART DMA driver.
			Developing the UART DMA driver
			Create a copy of your previous project in your IDE. Rename this copied project `UART_DMA`. Next, create a new file named `uart_dma.c` in the `Src` folder and another file named `uart_dma.h` in the `Inc` folder. Update your `uart_dma.c` file, as shown here:

include "uart_dma.h"

define UART2EN            (1U<<17)

define GPIOAEN            (1U<<0)

define CR1_TE            (1U<<3)

define CR1_RE            (1U<<2)

define CR1_UE            (1U<<13)

define SR_TXE            (1U<<7)

define CR3_DMAT        (1U<<7)

define CR3_DMAR        (1U<<6)

define SR_TC            (1U<<6)

define CR1_TCIE        (1U<<6)

define UART_BAUDRATE    115200

define CLK                16000000

define DMA1EN                (1U<<21)

define DMA_SCR_EN          (1U<<0)

define DMA_SCR_MINC        (1U<<10)

define DMA_SCR_PINC        (1U<<9)

define DMA_SCR_CIRC        (1U<<8)

define DMA_SCR_TCIE        (1U<<4)

define DMA_SCR_TEIE        (1U<<2)

define DMA_SFCR_DMDIS        (1U<<2)

define HIFCR_CDMEIF5        (1U<<8)

define HIFCR_CTEIF5        (1U<<9)

define HIFCR_CTCIF5        (1U<<11)

define HIFCR_CDMEIF6        (1U<<18)

define HIFCR_CTEIF6        (1U<<19)

define HIFCR_CTCIF6        (1U<<21)

define HIFSR_TCIF5        (1U<<11)

define HIFSR_TCIF6        (1U<<21)

static uint16_t compute_uart_bd(uint32_t periph_clk, uint32_t baudrate);

static void uart_set_baudrate(uint32_t periph_clk, uint32_t baudrate);

char uart_data_buffer[UART_DATA_BUFF_SIZE];

uint8_t g_rx_cmplt;

uint8_t g_tx_cmplt;

uint8_t g_uart_cmplt;

void uart2_rx_tx_init(void)

{

/*************配置 UART GPIO 引脚********************/

/1. 启用 GPIOA 时钟访问/

RCC->AHB1ENR |= GPIOAEN;

/2. 将 PA2 设置为复用功能模式/

GPIOA->MODER &= ~(1U<<4);

GPIOA->MODER |= (1U<<5);

/3. 将 PA3 设置为复用功能模式/

GPIOA->MODER &= ~(1U<<6);

GPIOA->MODER |= (1U<<7);

/4. 将 PA2 的复用功能类型设置为 AF7(UART2_TX)/

GPIOA->AFR[0] |= (1U<<8);

GPIOA->AFR[0] |= (1U<<9);

GPIOA->AFR[0] |= (1U<<10);

GPIOA->AFR[0] &= ~(1U<<11);

/5. 将 PA3 的复用功能类型设置为 AF7(UART2_TX)/

GPIOA->AFR[0] |= (1U<<12);

GPIOA->AFR[0] |= (1U<<13);

GPIOA->AFR[0] |= (1U<<14);

GPIOA->AFR[0] &= ~(1U<<15);

/*************配置 UART 模块********************/

/6. 启用 UART2 时钟访问/

RCC->APB1ENR |= UART2EN;

/7. 设置波特率/

uart_set_baudrate(CLK,UART_BAUDRATE);

/8. 选择使用 DMA 进行 TX 和 RX/

USART2->CR3 = CR3_DMAT |CR3_DMAR;

/9. 设置传输方向/

USART2->CR1 = CR1_TE |CR1_RE;

/10. 清除 TC 标志/

USART2->SR &=~SR_TC;

/11. 启用 TCIE/

USART2->CR1 |=CR1_TCIE;

/12. 启用 UART 模块/

USART2->CR1 |= CR1_UE;

/13. 在 NVIC 中启用 USART2 中断/

NVIC_EnableIRQ(USART2_IRQn);

}


			Next, we have the initialization function:

void dma1_init(void)

{

/启用 DMA 时钟访问/

RCC->AHB1ENR |=DMA1EN;

/在 NVIC 中启用 DMA Stream6 中断/

NVIC_EnableIRQ(DMA1_Stream6_IRQn);

}


			And then, the function for configuring the `rx` stream:

void dma1_stream5_uart_rx_config(void)

{

/禁用 DMA 流/

DMA1_Stream5->CR &=~DMA_SCR_EN;

/等待 DMA 流禁用/

while((DMA1_Stream5->CR & DMA_SCR_EN)){}

/清除流 5 的中断标志/

DMA1->HIFCR = HIFCR_CDMEIF5 |HIFCR_CTEIF5|HIFCR_CTCIF5;

/设置外设地址/

DMA1_Stream5->PAR = (uint32_t)(&(USART2->DR));

/设置内存地址/

DMA1_Stream5->M0AR = (uint32_t)(&uart_data_buffer);

/设置传输数量/

DMA1_Stream5->NDTR = (uint16_t)UART_DATA_BUFF_SIZE;

/选择通道 4/

DMA1_Stream5->CR &= ~(1u<<25);

DMA1_Stream5->CR &= ~(1u<<26);

DMA1_Stream5->CR |= (1u<<27);

/启用内存地址递增/

DMA1_Stream5->CR |=DMA_SCR_MINC;

/启用传输完成中断/

DMA1_Stream5->CR |= DMA_SCR_TCIE;

/启用循环模式/

DMA1_Stream5->CR |=DMA_SCR_CIRC;

/设置传输方向:外设到内存/

DMA1_Stream5->CR &=~(1U<<6);

DMA1_Stream5->CR &=~(1U<<7);

/启用 DMA 流/

DMA1_Stream5->CR |= DMA_SCR_EN;

/在 NVIC 中启用 DMA Stream5 中断/

NVIC_EnableIRQ(DMA1_Stream5_IRQn);

}


			And then, the one for the `tx` stream

void dma1_stream6_uart_tx_config(uint32_t msg_to_snd, uint32_t msg_len)

{

/禁用 DMA 流/

DMA1_Stream6->CR &=~DMA_SCR_EN;

/等待 DMA 流禁用/

while((DMA1_Stream6->CR & DMA_SCR_EN)){}

/清除流 6 的中断标志/

DMA1->HIFCR = HIFCR_CDMEIF6 |HIFCR_CTEIF6|HIFCR_CTCIF6;

/设置外设地址/

DMA1_Stream6->PAR = (uint32_t)(&(USART2->DR));

/设置内存地址/

DMA1_Stream6->M0AR = msg_to_snd;

/设置传输数量/

DMA1_Stream6->NDTR = msg_len;

/选择通道 4/

DMA1_Stream6->CR &= ~(1u<<25);

DMA1_Stream6->CR &= ~(1u<<26);

DMA1_Stream6->CR |= (1u<<27);

/启用内存地址递增/

DMA1_Stream6->CR |=DMA_SCR_MINC;

/设置传输方向:内存到外设/

DMA1_Stream6->CR |=(1U<<6);

DMA1_Stream6->CR &=~(1U<<7);

/设置传输完成中断/

DMA1_Stream6->CR |= DMA_SCR_TCIE;

/启用 DMA 流/

DMA1_Stream6->CR |= DMA_SCR_EN;

}


			Next, the function for computing the baudrate value for the UART:

static uint16_t compute_uart_bd(uint32_t periph_clk, uint32_t baudrate)

{

return ((periph_clk +( baudrate/2U ))/baudrate);

}


			And then, the function for writing the baudrate value to the baudrate register:

static void uart_set_baudrate(uint32_t periph_clk, uint32_t baudrate)

{

USART2->BRR  = compute_uart_bd(periph_clk,baudrate);

}


			Next, we have the interrupt handler for `Stream6`:

void DMA1_Stream6_IRQHandler(void)

{

if((DMA1->HISR) & HIFSR_TCIF6)

{

//执行某些操作

g_tx_cmplt = 1;

/清除标志位/

DMA1->HIFCR |= HIFCR_CTCIF6;

}

}


			And then, the interrupt handler for `Stream5`:

void DMA1_Stream5_IRQHandler(void)

{

if((DMA1->HISR) & HIFSR_TCIF5)

{

g_rx_cmplt = 1;

/清除标志位/

DMA1->HIFCR |= HIFCR_CTCIF5;

}

}

void USART2_IRQHandler(void)

{

g_uart_cmplt  = 1;

/清除 TC 中断标志/

USART2->SR &=~SR_TC;

}


			Let’s go through each part of the code step by step.
			UART initialization
			In the `uart2_rx_tx_init` function, we start by configuring the GPIO pins for UART2 communication. We enable the clock for GPIOA, ensuring that the `PA2` and `PA3` pins can be used. Setting PA2 and PA3 to alternate function mode allows them to serve as `UART2_TX` and `UART2_RX`, respectively. We further specify the alternate function type to `AF7`, which is the type for UART2 operations.
			With the GPIO configuration complete, we proceed to enable the clock for UART2\. We set the baud rate using the `uart_set_baudrate` function. By enabling DMA for both transmission and reception, we offload data handling from the CPU, allowing for more efficient data transfers. We set the transfer direction to both transmit and receive, clear any pending transmission complete flags, and enable the transmission complete interrupt. Finally, we enable the UART module and configure the NVIC to handle UART2 interrupts, ensuring that the system can respond to UART events promptly.
			DMA initialization
			Here, we start by enabling the clock for `DMA1`, ensuring that the DMA controller is powered and ready for configuration. Additionally, we enable the **DMA Stream6** interrupt in the NVIC, preparing the system to handle DMA-related interrupts efficiently.
			DMA configuration for UART reception
			In `dma1_stream5_uart_rx_config`, we configure `USART2-DR`) and the memory address to our `uart_data_buffer`, where incoming data will be stored.
			We specify the number of data items to transfer and select the appropriate DMA channel. Enabling memory address increment mode ensures that the data buffer is filled sequentially. Circular mode is enabled to allow continuous data reception, and the transfer direction is set **from peripheral to memory**. We enable the DMA stream and configure the NVIC to handle Stream5 interrupts, ensuring that the system is prepared for DMA events.
			DMA configuration for UART transmission
			The `dma1_stream6_uart_tx_config` function configures DMA1 Stream6 to transmit data via UART2\. Similar to the reception configuration, we start by disabling the DMA stream and clearing any existing interrupt flags. We set the peripheral address to the UART2 data register and the memory address to the data buffer that will be transmitted.
			We specify the number of data items to transfer and select the appropriate DMA channel. Memory address increment mode is enabled to ensure that data is transmitted sequentially from the buffer. We set the transfer direction **from memory to peripheral** and enable the transfer complete interrupt. Finally, we enable the DMA stream to start the data transmission process.
			Helper functions
			The `compute_uart_bd` function calculates the UART baud rate setting based on the peripheral clock and desired baud rate. The `uart_set_baudrate` function uses this computed value to set the baud rate in the UART’s `BRR` register, ensuring that UART communication occurs at the correct speed.
			Interrupt handlers
			Let’s break down the interrupt handlers:

				*   `DMA1_Stream6_IRQHandler`: This handler responds to DMA Stream6 interrupts. When a transfer completes, it sets the `g_tx_cmplt` flag and clears the interrupt flag, ensuring that the system is aware that the transmission is complete.
				*   `DMA1_Stream5_IRQHandler`: This handler responds to DMA Stream5 interrupts. When a transfer completes, it sets the `g_rx_cmplt` flag and clears the interrupt flag, indicating that new data has been received.
				*   `USART2_IRQHandler`: This handler manages UART2 interrupts. It sets the `g_uart_cmplt` flag when a UART event occurs and clears the transmission complete flag, maintaining the proper flow of UART communication.

			Next, we populate the `uart_dma.h` file. Here is the code:

ifndef UART_DMA_H__

define UART_DMA_H__

include <stdint.h>

include "stm32f4xx.h"

define UART_DATA_BUFF_SIZE        5

void uart2_rx_tx_init(void);

void dma1_init(void);

void dma1_stream5_uart_rx_config(void);

void dma1_stream6_uart_tx_config(uint32_t msg_to_snd, uint32_t msg_len);

main.c 文件:

#include <stdio.h>
#include <string.h>
#include "uart.h"
#include "uart_dma.h"
extern uint8_t g_rx_cmplt;
extern uint8_t g_uart_cmplt;
extern uint8_t g_tx_cmplt;
extern char uart_data_buffer[UART_DATA_BUFF_SIZE];
char msg_buff[150] ={'\0'};
int main(void)
{
    uart2_rx_tx_init();
    dma1_init();
    dma1_stream5_uart_rx_config();
    sprintf(msg_buff,"Initialization...cmplt\n\r");
    dma1_stream6_uart_tx_config((uint32_t)msg_buff,strlen(msg_buff));
    while(!g_tx_cmplt){}
    while(1)
    {
        if(g_rx_cmplt)
        {
            sprintf(msg_buff, "Message received : %s \r\n",uart_data_
            buffer);
            g_rx_cmplt = 0;
            g_tx_cmplt = 0;
            g_uart_cmplt = 0;
            dma1_stream6_uart_tx_config((uint32_t)msg_buff,strlen(msg_
            buff));
            while(!g_tx_cmplt){}
        }
    }
}
        在`main`函数中,我们首先初始化 UART 和 DMA,然后配置 DMA1 Stream5 用于 UART 接收和 DMA1 Stream6 用于 UART 传输。我们准备一个表示初始化完成的消息,并通过 DMA 启动其传输。主循环持续检查是否有 UART 消息被接收。当接收到消息时,它将接收到的数据格式化为响应消息,重置完成标志,并使用 DMA 传输响应。

        测试项目

        为了测试项目,编译代码并将其上传到微控制器。打开 RealTerm 或任何其他串行终端应用程序,然后配置适当的端口和波特率以查看调试消息。按下开发板上的黑色按钮以重置微控制器。通过单击它确保 RealTerm 的输出区域处于活动状态。然后,在键盘上输入任何五个键。你应该在 RealTerm 的输出区域看到这些键。微控制器通过`dma1_stream5_uart_rx_config`函数接收输入的键,将它们存储在`msg_buff`中,并通过`dma1_stream6_uart_tx_config`函数将它们传输到主机的串行端口。最后接收到的数据保留在`msg_buff`中,以便需要时进一步处理。我们输入五个字符,因为`UART_DATA_BUFF_SIZE`在`uart_dma.h`文件中设置为`5`。

        在下一节中,我们将开发我们的最终 DMA 驱动程序——DMA 内存到内存驱动程序。

        开发 DMA 内存到内存驱动程序

        在您的 IDE 中创建您之前项目的副本,并将其重命名为`DMA_MemToMem`。接下来,在`Src`文件夹中创建一个名为`dma.c`的新文件,在`Inc`文件夹中创建一个名为`dma.h`的新文件。更新您的`dma.c`文件,如下所示:
#include "dma.h"
#define DMA2EN                (1U<<22)
#define DMA_SCR_EN          (1U<<0)
#define DMA_SCR_MINC        (1U<<10)
#define DMA_SCR_PINC        (1U<<9)
#define DMA_SCR_TCIE        (1U<<4)
#define DMA_SCR_TEIE        (1U<<2)
#define DMA_SFCR_DMDIS        (1U<<2)
void dma2_mem2mem_config(void)
{
    /*Enable clock access to the dma module*/
    RCC->AHB1ENR |= DMA2EN;
    /*Disable dma stream*/
    DMA2_Stream0->CR = 0;
    /*Wait until stream is disabled*/
    while((DMA2_Stream0->CR & DMA_SCR_EN)){}
    /*Configure dma parameters*/
    /*Set MSIZE i.e Memory data size to half-word*/
    DMA2_Stream0->CR |= (1U<<13);
    DMA2_Stream0->CR &= ~(1U<<14);
    /*Set PSIZE i.e Peripheral data size to half-word*/
    DMA2_Stream0->CR |= (1U<<11);
    DMA2_Stream0->CR &= ~(1U<<12);
    /*Enable memory addr increment*/
    DMA2_Stream0->CR |=DMA_SCR_MINC;
    /*Enable peripheral addr increment*/
    DMA2_Stream0->CR |=DMA_SCR_PINC;
    /*Select mem-to-mem transfer*/
    DMA2_Stream0->CR &= ~(1U<<6);
    DMA2_Stream0->CR |= (1U<<7);
    /*Enable transfer complete interrupt*/
    DMA2_Stream0->CR |= DMA_SCR_TCIE;
    /*Enable transfer error interrupt*/
    DMA2_Stream0->CR |= DMA_SCR_TEIE;
    /*Disable direct mode*/
    DMA2_Stream0->FCR |=DMA_SFCR_DMDIS;
    /*Set DMA FIFO threshold*/
    DMA2_Stream0->FCR |=(1U<<0);
    DMA2_Stream0->FCR |=(1U<<1);
    /*Enable DMA interrupt in NVIC*/
    NVIC_EnableIRQ(DMA2_Stream0_IRQn);
}
        此函数设置`DMA2`控制器以进行内存到内存的数据传输。它首先启用 DMA2 模块的时钟,并在进行任何配置更改之前确保 DMA 流被禁用。函数将内存和外围的数据大小配置为`half-word`(16 位)并启用内存和外围地址的自动递增。它将传输方向设置为`内存到内存`并启用传输完成和传输错误的中断,以确保健壮的错误处理和高效的操作。禁用直接模式以使用`FIFO 模式`,并将 FIFO 阈值设置为`full`。最后,函数启用 DMA 流并配置 NVIC 以处理 DMA 中断,确保系统可以适当地响应 DMA 事件。我们还有`dma_transfer_start`函数:
void dma_transfer_start(uint32_t src_buff, uint32_t dest_buff, uint32_t len)
{
    /*Set peripheral address*/
    DMA2_Stream0->PAR = src_buff;
    /*Set memory address*/
    DMA2_Stream0->M0AR = dest_buff;
    /*Set transfer length*/
    DMA2_Stream0->NDTR = len;
    /*Enable dma stream*/
    DMA2_Stream0->CR |= DMA_SCR_EN;
}
        此函数通过配置源地址和目标地址以及数据传输的长度来初始化 DMA 传输。它首先设置`src_buff`传入值的外围地址和`dest_buff`传入值的内存地址。然后通过将`NDTR`寄存器设置为`len`来指定传输长度,这表示要传输的数据项数量。最后,通过在`CR`寄存器中设置`EN`位来启用 DMA 流,从而从源到目标开始数据传输。这是`dma.h`文件:
#ifndef DMA_H__
#define DMA_H__
#include <stdint.h>
#include "stm32f4xx.h"
#define LISR_TCIF0        (1U<<5)
#define LIFCR_CTCIF0        (1U<<5)
#define LISR_TEIF0        (1U<<3)
#define LIFCR_CTEIF0        (1U<<3)
void dma2_mem2mem_config(void);
void dma_transfer_start(uint32_t src_buff, uint32_t dest_buff, uint32_t len);
main.c file:

include <stdio.h>

include <string.h>

include "uart.h"

include "dma.h"

include "uart.h"

define BUFFER_SIZE        5

uint16_t sensor_data_arr[BUFFER_SIZE] = {892,731,1234,90,23};

uint16_t temp_data_arr[BUFFER_SIZE];

volatile uint8_t g_transfer_cmplt;

int main(void)

{

g_transfer_cmplt = 0;

uart_init();

dma2_mem2mem_config();

dma_transfer_start((uint32_t)sensor_data_arr,(uint32_t) temp_data_

arr, BUFFER_SIZE);

/Wait until transfer complete/

while(!g_transfer_cmplt){}

for( int i = 0; i < BUFFER_SIZE; i++)

{

printf("Temp buffer[%d]: %d\r\n",i,temp_data_arr[i]);

}

g_transfer_cmplt = 0;

while(1)

{

}

}


			The `main` function sets up and initiates a memory-to-memory DMA transfer, transferring data from the globally declared and initialized `sensor_data_arr` to the uninitialized `temp_data_arr`. It starts by initializing the transfer complete flag, `g_transfer_cmplt`, to `0`, ensuring that we can monitor the transfer status. The function then initializes UART for debugging purposes and configures the DMA using `dma2_mem2mem_config`. The DMA transfer is started by calling `dma_transfer_start`, specifying the source (`sensor_data_arr`), destination (`temp_data_arr`), and the length of the transfer (`BUFFER_SIZE`). The function then enters a loop, waiting until the transfer is complete, indicated by `g_transfer_cmplt` being set to `1`. Once the transfer is complete, it prints the contents of the `temp_data_arr` to the console, confirming that the data has been successfully transferred.
			Our `main.c` file also contains the DMA stream’s IRQHandler:

void DMA2_Stream0_IRQHandler(void)

{

/Check if transfer complete interrupt occurred/

if((DMA2->LISR) & LISR_TCIF0)

{

g_transfer_cmplt = 1;

/Clear flag/

DMA2->LIFCR |=LIFCR_CTCIF0;

}

/Check if transfer error occurred/

if((DMA2->LISR) & LISR_TEIF0)

{

/Do something.../

/Clear flag/

DMA2->LIFCR |= LIFCR_CTEIF0;

}

}


			The handler manages both transfer completion and error events. The function first checks whether the transfer complete interrupt flag (`TCIF0`) is set in the low interrupt status register (`LISR`). If this flag is set, it indicates that the DMA transfer has successfully finished. The function then sets the `g_transfer_cmplt` flag to `1` to signal to the main function that the transfer is complete, and it clears the interrupt flag by writing to the low interrupt flag clear register (`LIFCR`). Additionally, the function checks for a transfer error interrupt (`TEIF0`). If a transfer error is detected, it performs any necessary error handling and clears the error flag in `LIFCR`. This interrupt handler ensures smooth operation by promptly handling the completion of data transfers and addressing any errors that might occur during the process.
			Now, it’s time to test the project. To test the project, compile the code and upload it to your microcontroller. Open RealTerm or another serial terminal application, and then configure it with the appropriate port and baud rate to view the debug messages. Press the black push button on the development board to reset the microcontroller. You should see the sensor values printed, indicating that the values have been successfully copied from `sensor_data_arr` to `temp_data_arr`, as our code only prints the contents of `temp_data_arr`.
			Summary
			In this chapter, we learned about DMA, an important feature in microcontrollers for enhancing data throughput and offloading the CPU from routine data transfer tasks. We began by discussing the basic principles of DMA, emphasizing its role in high-performance embedded systems. We explored how DMA works and its significance in improving system efficiency, by allowing peripherals to transfer data directly to and from memory without continuous CPU intervention.
			Then, we focused on the STM32F4 implementation of the DMA controller, examining its key features and configuration options. We detailed the structure of the DMA controller, including its channels, streams, and key registers, such as the Stream Configuration Register (`DMA_SxCR`), Stream Number of Data Register (`DMA_SxNDTR`), Stream Peripheral Address Register (`DMA_SxPAR`), and Stream Memory Address Registers (`DMA_SxM0AR` and `DMA_SxM1AR`). This provided a comprehensive understanding of how to configure and control DMA for various data transfer operations.
			We provided practical examples to solidify our understanding, including the development of DMA drivers for different use cases, such as ADC data transfers, UART communications, and memory-to-memory transfers. These examples involved initializing DMA, setting up the necessary parameters, and implementing functions to handle data transfers efficiently.
			In the next chapter, we will learn about power management energy efficiency techniques in embedded systems.


第十八章:嵌入式系统中的电源管理和能源效率

在本章中,我们将深入研究嵌入式系统中的电源管理和能源效率,这是当今技术驱动世界的一个关键方面。有效的电源管理对于延长电池寿命和确保嵌入式设备的最佳性能至关重要。本章旨在为你提供实施有效电源管理技术的必要知识和技能。

我们将首先探讨各种电源管理技术,为理解如何在嵌入式系统中降低功耗奠定基础。随后,我们将检查 STM32F4 微控制器中可用的不同睡眠模式和低功耗状态,提供它们配置和应用的详细见解。然后,我们将讨论 STM32F4 中的唤醒源和触发器,这对于确保微控制器能够迅速响应外部事件至关重要。最后,我们将通过开发一个进入待机模式和唤醒微控制器的驱动程序来将理论应用于实践,展示如何在现实场景中应用这些概念。

在本章中,我们将涵盖以下主要主题:

  • 电源管理技术概述

  • STM32F4 中的低功耗模式

  • STM32F4 中的唤醒源和触发器

  • 开发进入待机模式和唤醒的驱动程序

到本章结束时,你将彻底理解嵌入式系统中的电源管理,并能够使用 STM32F4 微控制器实现节能设计。这些知识将使你能够创建优化功耗并延长电池寿命的嵌入式系统,这对于现代应用至关重要。

技术要求

本章的所有代码示例都可以在 GitHub 上找到,链接为github.com/PacktPublishing/Bare-Metal-Embedded-C-Programming

电源管理技术概述

在本节中,我们将探索电源管理技术的世界,这是嵌入式系统设计的一个关键方面。随着我们的设备变得更加先进,我们对电池寿命的期望也在增加,了解如何有效管理电源比以往任何时候都更重要。让我们深入了解各种电源管理技术及其实现方式,通过一些案例研究来观察这些技术在实际中的应用。

嵌入式系统中的电源管理涉及一系列硬件和软件策略,旨在降低能耗。这对于电池供电设备尤为重要,高效的电源使用可以显著延长电池寿命。我们将涵盖的主要技术包括动态电压和频率缩放DVFS)、时钟门控、电源门控以及利用低功耗模式。

让我们从 DVFS 开始。

动态电压和频率缩放(DVFS)

DVFS 是一种根据工作负载调整微控制器电压和频率的方法。在低活动期间降低电压和频率可以大大减少功耗。相反,在高需求期间,电压和频率会增加以确保性能。

DVFS 是如何实现的?

在 STM32 微控制器中,可以通过特定的电源控制寄存器管理 DVFS。这些寄存器允许系统根据所需的性能级别动态调整工作点。例如,STM32F4 系列具有几种可以配置以调整系统时钟和核心电压的电源模式。

一个用例示例——智能手机

智能手机是 DVFS 应用的典型例子。当手机处于空闲状态时,它会降低 CPU 频率和电压以节省电池。一旦开始使用应用程序或玩游戏,CPU 会提高其频率和电压以提供必要的性能。这种性能与节能之间的平衡使得现代智能手机如此高效。

另一种常见的技术是时钟门控。

时钟门控

时钟门控是一种技术,当微控制器的某些部分未使用时,会关闭其时钟信号。这防止了不必要的晶体管切换,从而节省了电力。

时钟门控是如何实现的?

时钟门控通常通过时钟控制寄存器进行控制。在 STM32 系列中,可以通过这些寄存器单独启用或禁用每个外设的时钟。例如,如果某个外设如 ADC 不需要,其时钟可以被禁用以节省功耗。

一个用例示例——智能家居设备

智能家居设备,如智能恒温器或灯光,使用时钟门控来高效管理电力。这些设备大部分时间处于低功耗状态,仅在执行特定任务时唤醒。通过关闭未使用外设的时钟,这些设备可以节约能源并延长电池寿命。

另一种技术是电源门控。

电源门控

电源门控通过完全关闭微控制器某些部分的电源,将节能提升到一个新的层次。这种技术确保了关闭部分的零功耗。

电源门控是如何实现的?

电源门控比时钟门控更复杂,通常涉及微控制器内部的专用电源管理单元。这些单元控制微控制器各个域的电源。在 STM32 微控制器中,可以通过电源控制寄存器配置电源门控,以关闭特定的外设,甚至整个微控制器的部分。

一个用例示例——可穿戴设备

可穿戴设备,如健身追踪器,从电源门控技术中受益匪浅。这些设备需要单次充电长时间运行。通过在不用时关闭传感器和其他组件的电源,可穿戴设备可以在不牺牲功能的情况下实现更长的电池寿命。

接下来,让我们讨论低功耗模式。

低功耗模式

低功耗模式是微控制器中预定义的状态,通过禁用或减少各种组件的功能,可以显著降低功耗。这些模式从简单的 CPU 睡眠模式到更复杂的深度睡眠或待机模式不等。

如何实现低功耗模式?

低功耗模式通过电源控制寄存器实现。例如,STM32F4 微控制器提供多种低功耗模式,包括睡眠停止待机。每种模式都提供了不同的节能和唤醒时间之间的平衡。

一个用例示例 – 远程传感器

在农业或环境监测中使用的远程传感器通常使用低功耗模式。这些传感器可能大部分时间处于低功耗状态,定期唤醒以进行测量和传输数据。通过利用低功耗模式,这些传感器可以在单次电池充电下运行数月甚至数年。

现在,让我们更详细地研究几个案例研究,这些研究说明了这些电源管理技术在现实世界应用中的组合使用。

案例研究 1 – 高效智能手表

智能手表是依赖电源管理技术的一个很好的例子。这些设备需要在性能和电池寿命之间取得平衡,因为用户期望它们在单次充电下运行数天。让我们分析一下不同技术在高效智能手表设计中的作用:

  • 动态电压频率调整(DVFS):智能手表使用 DVFS 根据当前的工作负载调整 CPU 频率。当用户与手表交互时,CPU 频率增加以提供流畅的体验。当手表空闲时,频率降低以节省电力。

  • 时钟门控:如 GPS 或心率监测器等外围设备仅在需要时供电。当这些功能不使用时,它们的时钟被门控以节省能源。

  • 电源门控:当显示器关闭时,像显示驱动器这样的组件会完全断电。

  • 低功耗模式:在非活动期间,手表进入深度睡眠模式,仅在检查通知或用户交互时唤醒。

通过结合这些技术,智能手表可以在不牺牲功能的情况下实现令人印象深刻的电池寿命。另一个优秀的例子是太阳能环境监测。

案例研究 2 – 太阳能环境监测器

部署在偏远地区的太阳能环境监测器必须高效运行,以确保连续的数据收集和传输。其角色如下:

  • 动态电压频率调整(DVFS):监控器根据阳光强度和电池充电量调整其工作频率。在阳光最强烈的小时,它以更高的频率运行以处理更多数据。

  • 时钟门控:例如温度、湿度和空气质量等传感器仅在数据收集间隔期间活跃。当不使用时,这些传感器的时钟会被门控。

  • 电源门控:在夜间或阴天期间,非必要组件完全关闭电源以节省能源。

  • 低功耗模式:监控器在数据收集间隔之间进入深度睡眠模式,定期醒来进行测量和传输数据。

通过这些电源管理技术,监控器可以自主运行很长时间,仅依靠太阳能。

电源管理是嵌入式系统设计的重要方面,尤其是在设备变得更加便携和依赖电池的情况下。通过理解和实施 DVFS、时钟门控、电源门控和低功耗模式等技术,我们可以设计出既强大又节能的嵌入式系统。无论是智能手表、远程传感器还是任何其他电池供电设备,有效的电源管理确保了更长的电池寿命和更好的整体性能。随着我们不断推动嵌入式系统能力的边界,掌握这些电源管理技术将比以往任何时候都更加重要。

在下一节中,我们将探索 STM32F4 微控制器中的低功耗模式。

STM32F4 的低功耗模式

在本节中,我们将学习 STM32F4 微控制器中可用的低功耗模式。我们将涵盖各种低功耗模式、如何配置它们以及在项目中使用它们的实际方面。

让我们从了解这些低功耗模式开始。STM32F4 微控制器中的低功耗模式旨在通过禁用或限制某些组件的功能来降低功耗。STM32F4 提供几种低功耗状态,每个状态在节能和唤醒延迟之间提供不同的平衡。这些模式包括睡眠、停止和待机模式。

我们可以通过在从中断服务例程(ISR)返回时执行Cortex®-M4 with FPU 系统控制寄存器中的SLEEPONEXIT位来将我们的系统置于低功耗模式。

让我们深入了解每种低功耗模式的细节,从睡眠模式开始。

睡眠模式

睡眠模式是最基本的低功耗模式,在这种模式下,CPU 时钟停止,但外设继续运行。这种模式提供了快速唤醒时间,使其非常适合需要频繁在活动状态和低功耗状态之间切换的应用。

要进入睡眠模式,我们需要清除系统控制寄存器SCR)中的SLEEPDEEP位,然后执行 WFI 或 WFE 指令,如下面的代码片段所示:

void enter_sleep_mode(void) {
    // Clear the SLEEPDEEP bit to enter Sleep mode
    SCB->SCR &= ~SCB_SCR_SLEEPDEEP_Msk;
    // Request Wait For Interrupt
    __WFI();
}

微控制器在任何中断或事件发生时退出睡眠模式。由于外设保持活跃,任何配置的外设中断都可以唤醒 CPU。

一个示例用例是传感器监控

对于连续传感器监控等应用,睡眠模式提供了一种在不过度牺牲响应性的情况下有效降低功耗的方法。微控制器可以快速唤醒以处理传感器数据,然后返回睡眠模式。

下一个模式是停止模式。

停止模式

停止模式通过停止主内部稳压器和系统时钟提供了比睡眠模式更深层次的节能状态。只有低速时钟(LSI 或 LSE)保持活跃。这种模式提供了适中的唤醒时间和显著的节能效果。

要进入停止模式,在PWR_CR中设置SLEEPDEEP位,然后执行 WFI 或 WFE 指令,如下面的代码片段所示。还可以应用其他配置以进一步降低停止模式下的功耗:

void enter_stop_mode(void) {
    // Set SLEEPDEEP bit to enable deep sleep mode
    SCB->SCR |= SCB_SCR_SLEEPDEEP_Msk;
    // Request Wait For Interrupt
    __WFI();
}

当发生任何外部中断配置的 EXTI 线上的唤醒事件RTC 警报或其他配置的唤醒源时,MCU 将退出停止模式。从停止模式唤醒的时间比从睡眠模式长,但它仍然允许相对快速地返回到完全操作状态。

一个示例用例是周期性 数据记录

在数据记录等应用中,微控制器可以保持在停止模式,并根据 RTC 警报定期唤醒以记录数据,然后返回停止模式。这显著降低了功耗,同时确保了定期数据记录。

最后一种模式是等待模式。

等待模式

等待模式通过关闭大部分内部电路,包括主稳压器,提供了最高的节能效果。只有微控制器的一小部分保持供电以监控唤醒源。这种模式具有最长的唤醒时间,但提供了最低的功耗。

要进入等待模式,在电源控制(PWR_CR)寄存器中设置PDDSSLEEPDEEP位,然后配置唤醒源。以下代码片段演示了如何进入等待模式:

void enter_standby_mode(void) {
    // Clear Wakeup flag
    PWR->CR |= PWR_CR_CWUF;
    // Set the PDDS bit to enter Standby mode
    PWR->CR |= PWR_CR_PDDS;
    // Set the SLEEPDEEP bit to enable deep sleep mode
    SCB->SCR |= SCB_SCR_SLEEPDEEP_Msk;
    // Request Wait For Interrupt
    __WFI();
}

当微控制器从等待模式唤醒时,它执行完整的复位序列,并从复位向量开始执行。

一个示例用例是远程 物联网设备

等待模式非常适合需要长时间在电池供电下运行远程物联网设备。这些设备大部分时间可以保持在等待模式,仅在关键事件或计划任务时唤醒,从而最大化电池寿命。

现在我们已经了解了如何进入各种低功耗模式,我们将探讨如何从这些模式中唤醒。

STM32F4 低功耗模式中的唤醒源和触发器

虽然低功耗模式有助于节省能源,但确保微控制器在需要时能够迅速唤醒同样重要。STM32F4 微控制器系列提供了各种唤醒源和触发器来有效处理这种情况。在本节中,我们将探讨这些唤醒源、它们的工作方式以及它们的实际应用。

理解唤醒源

唤醒源是使微控制器从低功耗状态唤醒的机制。STM32F4 提供了多种唤醒源,每种都适用于不同的场景。这些包括外部中断、RTC 闹钟、看门狗定时器和各种内部事件。通过理解这些触发器,我们可以设计出在功耗效率和响应性之间取得平衡的系统。

唤醒源可以分为以下几类:

  • 外部中断

  • 实时时钟RTC)闹钟

  • 内部事件

让我们深入了解这些唤醒源,以了解它们的工作原理和典型用例。

外部中断

外部中断是 STM32F4 微控制器的主要唤醒源之一。这些中断可以由特定 GPIO 引脚上的事件触发。当微控制器处于低功耗模式时,外部信号,如按钮按下或传感器输出,可以唤醒它。

这就是它的工作原理:

  • GPIO 配置:配置 GPIO 引脚作为中断源。这涉及到设置引脚模式并在所需的边缘(上升沿、下降沿或两者)上启用中断。

  • EXTI 配置:每个 GPIO 引脚都可以映射到一个 EXTI 线路,该线路可以配置为生成中断。

  • NVIC 配置:在嵌套向量中断控制器NVIC)中启用 EXTI 线路中断,以确保微控制器能够响应外部事件。

例子用例包括智能门铃系统智能照明

想象一个智能门铃系统。微控制器保持低功耗模式以节省电池寿命。当有人按下门铃按钮(连接到 GPIO 引脚)时,会触发一个外部中断,唤醒微控制器以处理事件并向房主发送通知。另一个优秀的例子是智能家居照明系统。

智能家居照明系统需要在节省能源的同时对用户输入做出响应。微控制器保持低功耗模式,直到外部中断(例如,运动传感器检测到运动)唤醒它。唤醒后,微控制器处理事件,打开灯光,然后在预定义的不活动期间后再次进入睡眠状态。

我们接下来要检查的下一个唤醒源是 RTC 闹钟。

RTC 闹钟

RTC 是一个多功能的外围设备,可以在特定间隔或预定义的时间生成唤醒事件。它特别适用于需要定期唤醒的应用,如数据记录或计划任务。

这就是它的工作原理:

  • RTC 配置:配置 RTC 以生成报警或周期性唤醒事件。这包括设置 RTC 时钟源、启用唤醒定时器以及设置报警时间。

  • 中断处理:在 NVIC 中启用 RTC 报警或唤醒中断,以确保微控制器在报警或定时器事件发生时唤醒。

一个示例用例环境****监测系统

考虑一个远程环境监测系统,该系统记录温度和湿度数据。微控制器可以被置于低功耗模式,使用 RTC 报警定期唤醒(例如,每小时一次),以读取传感器并记录数据,然后返回到低功耗状态。

我们将要检查的最后唤醒源是内部事件

内部事件

除了外部触发器之外,内部事件也可以从低功耗模式唤醒微控制器。这些事件包括以下内容:

  • 外围事件:由内部外围设备生成的事件,例如 ADC 转换或通信接口活动

  • 系统事件:如电源电压检测或时钟稳定性问题等内部系统事件

这就是它的工作原理:

  • 外围配置:配置外围设备,使其在特定事件发生时生成中断。例如,ADC 可以在转换完成后生成中断。

  • 事件处理:在 NVIC 中启用相关中断以处理这些内部事件并唤醒微控制器。

一个示例用例健身追踪器

一个监测心率的可穿戴健身追踪器可以使用 ADC 读取传感器数据。微控制器处于低功耗模式,当 ADC 完成转换时唤醒,从而允许其处理和存储心率数据。

在我们总结本节之前,让我们总结一些在配置唤醒源时需要记住的关键实际考虑因素。

实际考虑因素

在配置唤醒源时,您必须考虑以下实际方面:

  • 响应时间:确保所选唤醒源可以提供您应用所需的最快响应时间。外部中断通常提供最快的唤醒时间。

  • 功耗:平衡功耗和唤醒需求。RTC 报警和看门狗定时器可以配置为周期性唤醒,同时功耗最小化。

  • 可靠性:为关键应用选择可靠的唤醒源。看门狗定时器对于确保微控制器可以从故障中恢复的安全关键系统至关重要。

  • 外围配置:确保所需的唤醒外围设备已正确配置,并且即使在低功耗状态下,它们的时钟也保持启用。

理解并正确配置这些唤醒源确保您的嵌入式系统既节能又可靠。

在下一节中,我们将学习如何开发一个驱动程序以进入待机模式,并随后唤醒系统。

开发进入待机模式和唤醒的驱动程序

在您的 IDE 中创建您之前项目的副本,按照前面章节中概述的步骤进行。将此复制的项目重命名为StandByModeWithWakeupPin。接下来,在Src文件夹中创建一个名为standby_mode.c的新文件,然后在Inc文件夹中创建一个名为standby_mode.h的新文件。

在您的standby_mode.c文件中填充以下代码:

#include "standby_mode.h"
#define PWR_MODE_STANDBY        (PWR_CR_PDDS)
#define WK_PIN                (1U<<0)
static void set_power_mode(uint32_t pwr_mode);
void wakeup_pin_init(void)
{
    //Enable clock for GPIOA
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
    //Set PA0 as input pin
    GPIOA->MODER &= ~(1U<<0);
    GPIOA->MODER &= ~(1U<<1);
    //No pull
    GPIOA->PUPDR &= ~(1U<<0);
    GPIOA->PUPDR &= ~(1U<<1);
}

此函数负责配置PA0作为退出低功耗模式的唤醒引脚。它通过清除GPIOA模式寄存器中的相应位将PA0设置为输入引脚,然后配置该引脚不带上拉或下拉电阻:

void standby_wakeup_pin_setup(void)
{
    /*Wait for wakeup pin to be released*/
    while(get_wakeup_pin_state() == 0){}
    /*Disable wakeup pin*/
    PWR->CSR &=~(1U<<8);
    /*Clear all wakeup flags*/
    PWR->CR |=(1U<<2);
    /*Enable wakeup pin*/
    PWR->CSR |=(1U<<8);
    /*Enter StandBy mode*/
    set_power_mode(PWR_MODE_STANDBY);
    /*Set SLEEPDEEP bit in the CortexM System Control Register*/
    SCB->SCR |=(1U<<2);
    /*Wait for interrupt*/
    __WFI();
}

此函数使微控制器准备进入待机模式,并确保它可以通过配置的唤醒引脚唤醒。它首先等待唤醒引脚释放,确保在继续之前引脚处于稳定状态。然后该函数禁用唤醒引脚以清除任何残留设置,随后清除所有唤醒标志以重置唤醒状态。重新启用唤醒引脚后,该函数通过配置适当的电源控制寄存器将电源模式设置为待机。最后,该函数执行 WFI 指令,将微控制器置于待机模式,直到发生中断,触发唤醒过程:

uint32_t get_wakeup_pin_state(void)
{
      return ((GPIOA->IDR & WK_PIN) == WK_PIN);
}

此函数检查唤醒引脚(PA0)的当前状态。它读取GPIOA的输入数据寄存器(IDR)并执行与唤醒引脚位掩码(WK_PIN)的位与操作。此操作隔离了PA0的状态。然后该函数将此结果与位掩码本身进行比较,以确定引脚是否为高。如果PA0为高,则函数返回true;否则,返回false

static void set_power_mode(uint32_t pwr_mode)
{
  MODIFY_REG(PWR->CR, (PWR_CR_PDDS | PWR_CR_LPDS | PWR_CR_FPDS | PWR_
  CR_LPLVDS | PWR_CR_MRLVDS), pwr_mode);
}

此函数通过修改PWR_CR中的特定位来配置 STM32F4 微控制器的电源模式。此函数接受一个参数pwr_mode,它指定所需的电源模式设置。它使用MODIFY_REG宏来更新PWR_CR寄存器,具体针对与不同电源模式相关的位,如PDDS深度睡眠掉电)、LPDS低功耗深度睡眠)、FPDS停止模式中闪存掉电)、LPLVDS深度睡眠中低电压下的低功耗稳压器)和MRLVDS深度睡眠中低电压下的主稳压器)。

接下来,我们将填充standby_mode.h文件:

#ifndef STANDBY_MODE_H__
#define STANDBY_MODE_H__
#include <stdint.h>
#include "stm32f4xx.h"
uint32_t get_wakeup_pin_state(void);
void wakeup_pin_init(void);
void standby_wakeup_pin_setup(void);
main.c. Update your main.c file, as shown here:

include <stdio.h>

include <string.h>

include "standby_mode.h"

include "gpio_exti.h"

include "uart.h"

uint8_t g_btn_press;

static void check_reset_source(void);

int main(void)

{

uart_init();

wakeup_pin_init();

/查找复位源/

check_reset_source();

/初始化 EXTI/

pc13_exti_init();

while(1)

{

}

}


			The `main` function starts by initializing the UART for serial communication with `uart_init`, ensuring that we can send debugging information to the serial port. Next, it configures the wake-up pin by calling `wakeup_pin_init`, preparing the microcontroller to respond to external wake-up signals. The `check_reset_source` function is then called to determine the cause of the microcontroller’s reset, whether from standby mode or another source, and to handle any necessary flag clearing. Following this, the `PC13` is initialized with `pc13_exti_init`. The function then enters an infinite `while(1)`, maintaining the program’s operational state and waiting for interrupts or events to occur:

static void check_reset_source(void)

{

/启用对 PWR 的时钟访问/

RCC->APB1ENR |= RCC_APB1ENR_PWREN;

if ((PWR->CSR & PWR_CSR_SBF) == (PWR_CSR_SBF))

{

/清除待机标志/

PWR->CR |= PWR_CR_CSBF;

printf("系统从待机恢复.....\n\r");

/等待唤醒引脚释放/

while(get_wakeup_pin_state() == 0){}

}

/检查并清除唤醒标志位/

if((PWR->CSR & PWR_CSR_WUF) == PWR_CSR_WUF )

{

PWR->CR |= PWR_CR_CWUF;

}

}


			This function determines the cause of the microcontroller’s reset and handles the necessary flags accordingly. It begins by enabling the clock for the power control (`PWR`) peripheral to ensure access to the power control and status registers. It then checks whether the `PWR_CSR` register, which indicates that the system has resumed from standby mode. If the flag is set, it clears the SBF and prints a message, indicating that the system has resumed from standby. The function also waits for the wake-up pin to be released, ensuring that the pin is in a stable state. Additionally, it checks whether the Wakeup flag (`WUF`) is set and, if so, clears the flag to reset the wake-up status.
			This is the interrupt callback function:

static void exti_callback(void)

{

standby_wakeup_pin_setup();

}


			And finally, we have the interrupt handler:

void EXTI15_10_IRQHandler(void) {

if((EXTI->PR & LINE13) != 0)

{

/清除 PR 标志位/

EXTI->PR |= LINE13;

//执行某些操作...

exti_callback();

}

}


			The `exti_callback` function, coupled with `EXTI15_10_IRQHandler`, ensures that the microcontroller properly handles external interrupts from the wake-up pin. The `exti_callback` function is a straightforward handler that calls `standby_wakeup_pin_setup`. The `EXTI15_10_IRQHandler` function is an interrupt service routine specifically for EXTI lines 15 to 10\. It checks whether the interrupt was triggered by line 13 (associated with the wake-up pin), and if so, it clears the interrupt pending flag to acknowledge the interrupt. After clearing the flag, it calls `exti_callback` to handle the wake-up event.
			Now, let’s test the project!
			To test the project, start by pressing the blue push button to enter standby mode. Remember that `PA0` is configured as the wake-up pin and is active low. In normal mode, connect a jumper wire from `PA0` to the ground. To trigger a wake-up event, pull out the jumper wire and connect it to 3.3V, causing a change in logic that will wake the microcontroller from standby mode.
			To test on the microcontroller, simply build the project and run it.
			Open RealTerm and configure the appropriate port and baud rate to view the printed message that confirms the system has resumed from standby mode.
			Summary
			In this chapter, we delved into the critical aspects of power management and energy efficiency in embedded systems. Efficient power management is essential for prolonging battery life and ensuring optimal performance in embedded devices. We began by exploring various power management techniques, laying the foundation for understanding how to reduce power consumption in embedded systems.
			We then examined the different low-power modes available in STM32F4 microcontrollers, providing detailed insights into their configurations and applications. Then, we discussed the wake-up sources and triggers in STM32F4, which are essential to ensure that a microcontroller can promptly come out of low-power modes.
			Finally, we put theory into practice by developing a driver to enter standby mode and wake up the microcontroller.
			With this journey into bare-metal embedded C programming now complete, it’s important to acknowledge the profound expertise you’ve gained. By mastering the nuances of microcontroller architecture and the discipline of register-level programming, you’ve equipped yourself with the tools to create efficient and reliable embedded systems from the ground up. This book was designed to offer more than just technical instruction; it also aimed to instill a deeper understanding of the hardware and a methodical approach to firmware development. As you move forward, remember that true mastery in this field lies in the continuous application and refinement of these principles.

posted @ 2025-10-06 13:13  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报