AVR-研讨会-全-
AVR 研讨会(全)
原文:
zh.annas-archive.org/md5/a301090147ac157b1b01e22a30fba8b7译者:飞龙
前言

微控制器(或简称MCU)是一种小型的完整计算机,能够集成在一个单一的集成电路中。就像你的台式计算机一样,微控制器包含一个处理器、内存、接收来自不同来源输入的设备,以及可以用来控制或与外部设备通信的输出端口。
得益于像 Arduino 和 PICAXE 这样的开发*台的成功,微控制器在电子领域以及爱好者和黑客中被越来越广泛地使用。这样的开发*台简化了初学者的项目,但它们可能比较昂贵;它们还将用户与微控制器之间加了一层抽象,降低了微控制器的性能,且通常无法让用户访问其完整的功能集。经验更丰富的用户可能希望直接控制微控制器或在项目中使用更便宜的零件。或者,如果你是初学者,你可能希望在没有人为加诸额外负担的情况下,开始你的微控制器之旅。
这本书正是为此而来。无论你是完全的初学者还是多年的电子爱好者,AVR 工作坊将向你展示如何利用来自 Microchip AVR 8 位微控制器系列的两款廉价芯片,这些芯片因 Arduino 及兼容板而广为人知。一旦掌握了这些芯片,你将能够最大化其性能,通过更便宜的硬件创造出强大的项目。在这个过程中,你将学习到电子学、C 语言编程等内容。
我将带领你完成 55 个逐步增加难度的项目,这些项目基于 Microchip 的 ATtiny85 和 ATmega328P-PU 微控制器,我将为每个项目解释并演示你需要了解的一切。你将从闪烁一个小灯开始,接着学会计时、捕捉和分析现实世界的数据(如温度),甚至控制小型电动设备。本书不涉及用于物联网的 AVR,因为那是一个更高级的话题,但完成这些项目后,你将能够利用 AVR 微控制器,掌控各种设备、传感器、电机、显示器等,充分实现你的创意和梦想。
我写这本书是为了广泛的人群。你可能是想在微控制器方面提前学习的学生,可能是没有数字电路或微控制器电路经验的电子爱好者,可能是想提升自己工作知识的员工,或只是一个喜欢制作东西的人。这本书适合任何对学习 AVR 技术并利用它创建自己项目的人。
我的目标是让你在读完这本书后,带着持久的知识和自信继续学习和创作。第一章将通过介绍一些使用 AVR 微控制器的酷炫现实世界项目,帮助你入门,然后向你展示如何设置自己的工作站。
第一章:# 入门指南

欢迎来到你的 AVR 微控制器之旅的开始!在本章中,我将向你介绍本书中使用的微控制器,以及一些令人兴奋的基于 AVR 的实际项目示例,然后教你一些电子学的基础知识。
你将学习:
-
• 从哪里获取本书项目所需的零件
-
• 如何为 Windows、macOS 和 Linux 安装所需的软件
-
• 电力和电子元件的基本特性
-
• 关于电子元件的知识,包括电阻器、发光二极管(LED)、电源二极管、电容器等
-
• 如何使用无焊接面包板构建电路
-
• 如何安全地为你的实验提供电源
到本章结束时,你将准备好使用你的 AVR 工作站构建你的第一个项目。
可能性无穷
通过快速浏览本书,你会发现可以将 AVR 微控制器作为众多设备的核心。你将从闪烁的 LED 灯开始,到创建温控器、GPS 日志记录器等项目——但不要仅限于这里所涵盖的项目范围!在完成本书的学习后,你将为探索更高级的项目做好充分准备,就像我在本节中将描述的那些项目。
例如,计算机科学家 Vassilis Serasidis 设计了一款电子测试设备,名为逻辑分析仪,可以同时测量四个电流的值并显示结果。他的设计使用了一种通常见于廉价手机中的低成本 LCD 显示器,以图形形式显示信号,如图 1-1 所示。

图 1-1:一款廉价的逻辑分析仪
你可以使用逻辑分析仪通过 AVR 微控制器运行多种显示方式,从像图 1-1 中展示的廉价黑白显示器到逼真的彩色显示器。有关该项目的更多信息,请访问www.serasidis.gr/circuits/mini_logic_analyzer/miniLogicAnalyzer.htm。
你还可以使用 AVR 来构建像穿戴式电子设备这样的迷你项目,但你需要一个非常小的开发板来实现这一点。电子爱好者 Erik Kettenburg 梦想着基于最小的 AVR 芯片制作这样的开发板。他将这个想法转化为 Digispark 开发板,如图 1-2 所示,尺寸仅为 17.5 × 19 毫米,并通过 2017 年成功的 Kickstarter 众筹活动建立了一个繁荣的业务。

图 1-2:Digispark 开发板的示例
Digispark 开发板的尺寸意味着它所使用的 AVR 微控制器并不像一些更大的芯片那样拥有许多功能——例如,它的程序存储空间较少。然而,Digispark 允许你通过 USB 直接编程微控制器,而通常你需要购买一个单独的编程设备。欲了解更多关于 Digispark 开发板的信息,请访问digistump.com/。
除了将 AVR 用于专业用途,许多人也纯粹为了乐趣而构建基于 AVR 的项目!一个例子是 2009 年由软件工程师 Ben Ryves 构建的 AVR 电视游戏,显示在图 1-3 中。Ben 使用了非常基础的电子元件和一个 AVR 芯片制作了一个设备,可以插入电视并玩经典游戏贪吃蛇和俄罗斯方块。

图 1-3:在 AVR 电视游戏上玩俄罗斯方块
AVR 可以生成视频信号与电视通信,而无需额外的硬件,并且凭借一些想象力,你可以将自己的游戏程序到微控制器中。欲了解更多信息,请访问benryves.com/products/avrtvgame/。
工程师 Adam Heinrich 甚至在 2017 年基于 AVR 芯片自己制造了一个手机,并配备了彩色触摸屏界面。Adam 的“AvrPhone”是便携式的,可以移动使用,显示在图 1-4 中。欲了解更多信息,请访问projects.adamh.cz/avrphone/。

图 1-4:AvrPhone
就像这些创客一样,凭借一些努力,你也可以弥合业余爱好者的实验与完整产品开发之间的差距!但现在,让我们先详细讨论一下你将在本书中使用的各个部件。
Microchip 的 AVR 微控制器
在本书中,你将使用图 1-5 中所示的两款微控制器,它们是 Microchip Technology 作为其 AVR 产品线的一部分生产的。较小的一款是 ATtiny85,它有 8 个引脚,这些引脚是黑色芯片两侧突出的金属片,允许你从微控制器向外发送和接收数据和电力。较大的 AVR 是 ATmega328P-PU,它有 28 个引脚。

图 1-5:我们的 AVR 微控制器,ATtiny85 和 ATmega328P-PU
注意:在本书中以及购买零件时,你可能会看到微控制器标注为“Atmel”。Microchip 在 2016 年收购了 Atmel,但在写作时,一些供应商仍然有 Atmel 品牌的产品;无论哪种标签都可以。
除了大小,ATtiny85 和 ATmega328P-PU 微控制器之间还有一些重要的差异,如 表 1-1 所列。
表 1-1:ATtiny85 和 ATmega328P-PU 微控制器的规格

表 1-1 中的规格描述了每个芯片的物理限制,它们将帮助你确定可以与微控制器一起使用的其他电子元件。每次开始一个新项目时,你都需要仔细考虑这些信息,以下是每项规格的简要说明:
原理图 这是表示与电子元件连接的图示,例如本表中的微控制器。你将在 第二章 中了解更多关于原理图符号的内容。
最大处理速度 这一行告诉你微控制器处理数据的速度,单位是每秒周期数。注意,时钟速度并不总是等于处理速度,因为某些指令可能需要多个周期才能完成。
工作电压 这一行显示了你可以安全使用的电压范围来为微控制器供电。如果你提供的电压低于 1.8V,芯片将无法启动;但如果你尝试提供超过 5.5V 的电压,芯片可能会烧坏!
数字引脚 这一行显示了可以发送或接收数字数据的引脚数量。数字数据通过电压信号表示;数字“1”和“0”分别表示“开”或“关”的电压。这些电压信号随后被组合在一起表示各种形式的数据。所有微控制器都有可以设置为数字输入或输出的引脚,用于控制外部设备。
模拟输入引脚 这一行显示了可用来测量电压水*的物理输入引脚数量。模拟输入引脚可以让你读取像 传感器 这样的设备的信息,这些设备会根据其周围环境的变化输出不同的电压。
闪存 这一行显示芯片上可用的闪存容量。为了告诉你的微控制器该做什么,你需要编写程序,这些程序存储在闪存中,即使关闭电源后仍然会保留。如果程序的文件大小超过了你的 AVR 微控制器的内存容量,它将无法加载!
EEPROM 这一行告诉你芯片上有多少 电可擦可编程只读存储器 (EEPROM) 可用。即使微控制器关闭,EEPROM 也能保存程序生成的数据。例如,如果你希望每次启动项目时在 LCD 上显示某个图像,你可以将该图像存储在 EEPROM 中以供以后使用。
SRAM 本行告诉你芯片上可用的静态随机存取内存(SRAM)的大小。这是存储由你的程序创建的临时数据的内存空间。就像你台式机中的 RAM 一样,SRAM 是你的程序在运行时生成的所有信息存储的地方,直到电源关闭时这些数据被删除。这些数据可能包括传感器数据、计算结果等。
我将在后续章节中详细介绍这些和其他重要功能。现在,让我们开始设置你的 AVR 微控制器实验室。
所需零件和配件
你不需要购买大量昂贵的零件来开始本书中的项目;假设你已经有一台现代个人计算机,你可以以约 50 美元的预算,通过微控制器获得很多乐趣。我会在每个项目中为你提供需要的零件清单,你还可以从nostarch.com/avr-workshop/下载本书中所有使用零件的清单。我建议你现在就订购前几章项目所需的零件,这样就不必等太长时间才能收到货。
电子元件
AVR 微控制器和电子元件可以从许多零售商处购买,这些零售商提供各种产品和配件。在购物时,请务必购买我列出的原装零件,而不是山寨产品,否则你有可能收到有缺陷或性能不佳的商品。不要冒险购买劣质产品,因为从长远来看,这可能会让你付出更多的代价!
在每个项目开始时,务必阅读硬件清单,并确保在开始之前购买正确的组件。以下是一些推荐的 AVR 相关部件和配件供应商。前五个供应商提供全球范围的服务,最后四个是特定国家的供应商,如下所示:
-
• DigiKey:
www.digikey.com/ -
• element14/Farnell:
farnell.com/ -
• PMD Way:
pmdway.com/ -
• SparkFun 电子产品:
www.sparkfun.com/ -
• Mouser:
www.mouser.com/ -
• Freetronics 澳大利亚:
www.freetronics.com/(仅限澳大利亚) -
• Altronics:
www.altronics.com.au/(仅限澳大利亚) -
• Newark:
www.newark.com/(仅限美国) -
• MindKits:
www.mindkits.co.nz/(仅限新西兰)
我可以根据个人经验为这些供应商担保,但全球还有许多其他供应商。一般来说,尽量选择提供技术支持和销售支持的公司,而不仅仅是简单的销售代理或大型零售网站上的商品列表。
选择 AVR 编程器
你需要将像图 1-6 所示的编程器从电脑连接到你的微控制器电路,以便将程序和数据加载到芯片中。找到一个好的编程器对于 AVR 世界的成功至关重要,通过快速的网络搜索“AVR programmer”你会发现很多选择。本书中的项目使用USBasp 编程器,这是一种连接你电脑与 AVR 项目的设备,用于将代码发送到你项目的微控制器。你可以从前一部分列出的任何商店购买,价格(打印时)不到 20 美元。

图 1-6:USBasp AVR 编程器示例
USBasp 应该与 AVR 编程所需的开源软件兼容。这是由 Thomas Fischl 创建的开源设备,只有在与基于 Windows 的 PC 一起使用时才需要 USB 驱动程序。在购买 USBasp 编程器时,确保它附带 6 针(而不是 10 针)排线,以便你可以用它进行本书中的项目。
所需软件
在本节中,你将设置一个工具链,它将让你为 AVR 微控制器编程。这个工具链由一系列软件组成,它将你输入的程序指令转换为 AVR 可以理解的形式,并将其存储到闪存中。编程过程分为三个阶段,每个阶段使用不同的软件(即“链”中的不同“工具”):
-
1. 使用文本编辑器软件,你输入、编辑并保存包含指令的代码,供 AVR 使用。
-
2. 编译器软件将你创建的代码转换为微控制器可以理解的机器代码。
-
3. 接下来,编程软件将机器代码文件上传到微控制器。此时,AVR 应该开始执行你告诉它要做的事情。
你可以在任何基本文本编辑器中编写程序,例如 Windows 上的记事本或 macOS 上的 TextEdit。你还需要一个包含编译器和编程软件的包。在接下来的三部分中,我将解释如何为运行 MacOS、Ubuntu Linux 和 Windows 7 及以上版本的电脑获取并安装这些软件。
macOS 10.6 或更高版本
要在 Mac 上安装所需的软件,请打开 macOS 的终端应用程序并输入以下命令:
`/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`
这将安装允许你在 macOS 上运行 32 位程序的软件。你可能会被提示输入密码,然后软件将开始运行,如图 1-7 所示。

图 1-7:在 macOS 上开始安装工具链。
完成后,输入以下内容到命令行:
`brew tap osx-cross/avr`
完成后,输入以下命令:
`brew install avr-gcc avrdude`
这是最后一步,它安装所需的 AVR 软件。安装完成后,命令提示符会重新出现,如图 1-8 所示;然后你可以关闭终端应用程序。

图 1-8:安装完成。
现在你已经安装了工具链软件,跳到“电流、电压与功率”章节。
Ubuntu Linux 20.04 LTS 或更高版本
要在 Ubuntu Linux 20.04 LTS 中下载并安装 AVR 工具链,首先确保你的系统完成了所有最新的更新。接下来,打开终端窗口并输入以下命令:
`sudo apt-get install gcc build-essential`
如果要求,输入你的密码。然后,可能会询问你是否继续:输入Y并按回车。大量描述各种软件包的文字会滚动显示,然后命令提示符应该再次出现。输入以下命令:
`sudo apt-get install gcc-avr binutils-avr avr-libc gdb-avr`
如果安装过程需要,重新输入密码并授权继续。然后输入以下命令:
`sudo apt-get install avrdude`
在短时间内(取决于计算机和互联网连接的速度),所有必需的软件应该安装完成。现在你需要为 USBasp 编程器安装 USB 驱动程序。为此,输入以下命令:
`sudo apt-get install libusb-dev`
如果系统提示,输入你的密码并输入Y以继续。最后,检查驱动程序是否正常工作。将 USBasp 插入计算机的 USB 端口,然后输入以下命令:
`lsusb`
片刻后,连接到计算机的 USB 设备列表应该显示出来,像这样:
Bus 002 Device 003: ID 045e:00cb Microsoft Corp. Basic Optical Mouse v2.0
Bus 002 Device 004:
`ID 16c0:05dc Van Ooijen Technische Informatica`
Bus 002 Device 002: ID 8087:0020 Intel Corp. Integrated Rate Matching Hub
Bus 002 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
Bus 001 Device 004: ID 04f2:b1d6 Chicony Electronics Co., Ltd CNF9055
Toshiba Webcam
Bus 001 Device 003: ID 0bda:0138 Realtek Semiconductor Corp. RTS5138
Card Reader Controller
Bus 001 Device 002: ID 8087:0020 Intel Corp. Integrated Rate Matching Hub
Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
在这个例子中,USBasp 是列表中的第二个项目。如果你的设备没有出现,请检查与 PC 的连接,重启计算机,然后再次尝试lsusb命令。
注意 由于 Linux 的开源特性,有许多不同的操作系统版本,这些版本在本书中无法涵盖或记录。如果你仍然遇到问题,或者希望了解如何在其他 Linux 版本上安装工具链的详细信息,请访问www.nongnu.org/avr-libc/user-manual/install_tools.html。
安装完成后,继续阅读标题为“电流、电压与功率”的章节。
Windows 7 至 11
在 Microsoft Windows 中设置工具链需要额外的步骤:你首先需要下载并安装软件,然后安装适用于你的编程器的驱动程序。
安装工具链
要在 Windows 上安装所需的软件,请按照以下说明进行操作:
-
- 打开你的网页浏览器,访问
sourceforge.net/projects/winavr/files/WinAVR/下载页面,如图 1-9 所示。
![显示有“下载最新版本”和“获取更新”按钮的 WinAVR 下载网页截图]()
图 1-9:Windows 的 WinAVR 下载页面
- 打开你的网页浏览器,访问
-
2. 点击下载最新版本按钮以开始软件下载。经过一段时间后,工具链安装程序应该已经下载完成。在资源管理器中打开下载文件夹,如图 1-10 所示,您应该能看到 WinAVR 安装文件。
![显示已下载的 WinAVR 安装软件截图]()
图 1-10:WinAVR 软件包
-
3. 双击安装包,选择您的语言后,您将看到 WinAVR 安装向导,如图 1-11 所示。
![Windows 11 中显示的 WinAVR 安装向导欢迎屏幕截图]()
图 1-11:WinAVR 安装向导
-
4. 在安装向导中点击下一步,当出现许可协议时点击我同意。
-
5. 接下来窗口会提示您选择安装工具链的文件夹。您可以通过点击下一步选择默认路径,如图 1-12 所示。
![显示如何在 Windows 11 中选择 WinAVR 软件安装位置的截图]()
图 1-12:选择文件位置
-
6. 接下来出现的窗口,如图 1-13 所示,会要求您选择安装的组件。勾选所有三个框并点击安装。
![显示已选择所有可用组件(安装文件、添加目录到路径、安装程序员记事本)的截图]()
图 1-13:选择要安装的组件
-
7. 安装向导将显示进度条,安装文件时会进行显示。安装完成后,点击完成按钮关闭向导。
-
8. 现在,您应该看到 WinAVR 用户手册页面,如图 1-14 所示。
![浏览器中显示的 WinAVR 用户手册截图]()
图 1-14:WinAVR 用户手册页面
请在您的网页浏览器中收藏此页面,稍后会用到。
安装 USBasp 驱动程序
与其他操作系统不同,Windows 需要您安装驱动程序才能启用 USBasp 编程器。不同品牌的 USBasp 编程器需要不同的驱动程序,您所需的驱动程序取决于您购买的品牌。您可以在 Thomas Fischl 的官网找到通用 USBasp 的 Windows 驱动程序及安装说明:www.fischl.de/usbasp/。如果您使用的是像 Freetronics 这样的“品牌”编程器,请参考产品页面获取正确的驱动程序和安装说明。
一旦你安装了 USBasp 编程器的驱动程序,你可以通过将其插入并访问 Windows 设备管理器来快速确认安装是否成功,如图 1-15 所示。

图 1-15:设备管理器中的 USBasp
你应该看到 USBasp 列在libusb-win32 设备类别中。现在你已经在电脑上安装了本书所需的软件,接下来是时候了解电学基础知识了!
电流、电压与功率
为了使用基于 AVR 的项目构建电子电路,你需要对电力的工作原理有基本的了解。简单来说,电力是一种能量形式,你可以利用并转化为热能、光能、运动和功率。电力有三个对我们而言重要的主要特性:电流、电压和功率。
电流是电能通过电路流动的过程,从电源的正极(例如电池)流向负极。在没有电池供电的电路中,负极通常被称为地(GND)。这种电流被称为直流电(DC)。(对于本书而言,你不会接触到由 110 V 或 230 V 市电提供的交流电[AC]。)电流的单位是安培(或安),简写为 A。小量的电流则以毫安(mA)来衡量,1,000 mA 等于 1 A。
电压是电路正负端之间势能差的表示,单位为伏特(V)。电压越大,电流在电路中的流动速度就越快。
功率是衡量电气设备将能量从一种形式转换为另一种形式的速率。功率的单位是瓦特(W)。例如,一个 100 W 的灯泡比 60 W 的灯泡亮得多,因为更高瓦数的灯泡将更多的电能转化为光。
电压、当前和功率之间存在简单的数学关系:
功率(W) = 电压(V) × 电流(A)
本书后续章节将详细解释此公式的应用。
电子元件
现在你已经了解了电学的基本概念,接下来我们来看看电力如何与电子元件和设备互动。电子元件是电路中的部分,它们控制电流并让你的设计成为现实。就像汽车的多个部件协同工作让我们能够驾驶一样,电子元件也会共同协作,帮助我们利用并控制电力来创造有用的设备。
本书中,我会在需要时解释一些专业元件。以下章节将介绍你在任何项目中需要的一些基本元件。
电阻器
一些元件,如 LED,我们稍后将讨论,工作时只需要少量电流——通常约为 10mA。当元件接收到过多电流时,它会将多余的电流转化为热量,这可能会损坏或摧毁元件。为了减少电流流动,可以在电压源和元件之间添加一个电阻器。电流沿电线自由流动,但当遇到电阻器时,电流流动会受到限制。电阻器将部分电流转化为少量热能,热能的大小与其电阻值成正比。
图 1-16 显示了常用电阻的两个示例。

图 1-16:典型的电阻器
电流流动的限制量由电阻的大小决定,可以是固定的或可变的。电阻的单位是欧姆(Ω),范围从零到数千欧姆(千欧姆,或 kΩ)或数百万欧姆(兆欧姆,或 MΩ)。
读取电阻值
电阻非常小,因此它们的阻值通常无法直接打印在元件上。一种常见的显示电阻阻值的方法是使用一系列颜色编码带,如图 1-16 所示(以灰度显示),每种颜色代表一个数值。倍增带决定了在前面的数字后面添加多少个零来完成该值。具有五个带的电阻比四带电阻具有更高的精度。
以下是如何从左到右读取这些带:
第一带 电阻的第一位数字
第二带 电阻的第二位数字
第三带 倍增器(对于四带电阻)或电阻的第三位数字(对于五带电阻)
第四带 倍增器(对于五带电阻)或电阻的公差,衡量其精度的百分比(对于四带电阻)
第五带 电阻的公差(对于五带电阻)
要确定哪一带是最左边的第一带,请检查哪一带最接*电阻的边缘。第一带通常比最后一带更接*左边缘。
表 1-2 列出了电阻上可能出现的不同颜色带及其对应的数值。
| 表 1-2:电阻带的数值,以欧姆为单位 |
|---|
| 颜色 |
| --- |
| 黑色 |
| 棕色 |
| 红色 |
| 橙色 |
| 黄色 |
| 绿色 |
| 蓝色 |
| 紫色 |
| 灰色 |
| 白色 |
由于制造精确电阻器非常困难,每个电阻器都有一个误差范围,通过最右侧的色环表示。棕色色环表示 1 百分比公差,金色表示 5 百分比公差,银色表示 10 百分比公差。公差越小,电阻器的精度越高。也就是说,1 百分比电阻器的值只能在±1 百分比范围内波动,而 5 百分比电阻器的值则可以在±5 百分比范围内波动。
图 1-17 显示了一个电阻器的示意图。

图 1-17:一个电阻器的示例图
黄色、紫色和橙色的色环分别代表 4、7 和 3,如表 1-2 中所列,棕色色环表示公差(1 百分比)。这些值转化为 47,000 Ω,更常写作 47 kΩ。通常,欧姆(Ω)会简写为 R;例如,一个 220 Ω的电阻器可能被表示为 220 R。
另一种读取电阻值的方法是使用万用表,这是一种非常有用且相对便宜的测试设备,可以测量电压、电阻、电流等。图 1-18 展示了一台万用表正在测量一个电阻器。

图 1-18:一台万用表正在测量一个 560 Ω 1 百分比公差的电阻器
如果你是色盲,万用表是必不可少的。即使你不是色盲,我也强烈建议购买一个:它将为你节省大量时间,并减少由于误读电阻色环而可能引发的错误。和其他好工具一样,购买万用表时,请从信誉良好的零售商处购买,而不是在网上随便寻找最便宜的。
功率额定值
电阻器的功率额定值是它在过热或失效之前能承受的功率,单位为瓦特。在选择电阻器时,你需要考虑功率、电流和电压之间的关系。电流或电压越大,电阻器的功率额定值越大。例如,使用公式功率(W)= 电压(V)× 电流(A),当电压为 5 V 且电流为 20 mA 时,所需的功率处理值为 5 × 0.02 = 0.1 W。这与本书中项目中最常用的 0.25 W 电阻器(图 1-16 中的电阻器是 0.25 W 电阻器)相匹配。
如果本书中的项目需要不同的功率处理,我们将在需要时讲解并讨论最佳的电阻器选择。通常,电阻器的功率等级越大,其物理尺寸也越大。例如,图 1-19 中显示的电阻器是一个 5 W 电阻器,尺寸为长 26 毫米,宽 7.5 毫米。

图 1-19:一只 5 W 电阻器
发光二极管
LED 是一种非常常见且极为有用的元件,能够将电流转换为光。LED 有各种形状、尺寸和颜色。图 1-20 显示了一种常见的 LED。

图 1-20:红色 LED,直径 5 毫米
在电路中连接 LED 时需要小心,因为它们是 极性 的,意味着电流只能朝一个方向流进流出 LED。电流从 阳极(正极)侧进入,从 阴极(负极)侧流出,如图 1-21 所示。任何试图使电流反向流动的操作都会损坏 LED。
幸运的是,LED 被设计成可以区分两端。阳极侧的引脚较长,阴极侧则是 LED 底部的边缘是*的,正如图 1-22 所示。

图 1-21:LED 的电流流动

图 1-22:LED 设计显示阳极(较长的引脚)和阴极(*底)两侧。
在项目中添加 LED 时,必须考虑工作电压和电流。例如,常见的红色 LED 需要大约 1.7 V 电压和 5 到 20 毫安电流。这会带来一些小问题,因为本书中的项目所用的电源将输出 5 V 电压并且电流远高于此值。幸运的是,你可以使用 限流电阻器 来减小流入 LED 的电流。但该选择哪种电阻器呢?这时,欧姆定律就派上用场了。
欧姆定律 表示电压等于电流乘以电阻,即 V = I × R。因此,要计算所需的限流电阻器,你可以使用这个公式:
R = ( V [s] − V [f] ) / I
其中 V [s] 是供电电压(当从我们的 USBasp 编程器为电路供电时为 5V),V [f] 是 LED 的正向电压降(比如 1.7V),I 是 LED 所需的电流(10 mA)。I 的值必须以安培为单位,因此 10 mA 转换为 0.01 A。你可以将这些值用于你的 LED:将它们代入公式后,R 的值为 330 Ω。然而,当电流小于 10 mA 时,LED 仍然会亮起。为了保护敏感的电子元件,最好尽可能使用较低的电流,所以我建议使用 560 Ω,0.25W 的电阻与 LED 配合使用,这样能提供大约 6 mA 的电流。
如果有疑问,始终选择稍微高一些的电阻值,因为比起死掉的 LED,选择一个较暗的 LED 总是更好的!
欧姆定律三角形
欧姆定律指出电流、电阻和电压之间的关系如下:V = I × R 。一种流行的记忆欧姆定律的方法是使用三角形,如下图所示。

欧姆定律三角形
这个图表是一个方便的工具,可以在已知三个值中的两个时计算电压、电流或电阻。例如,如果你需要计算电阻,将手指放在 R 上,剩下的电压除以电流。要计算电压,遮住 V,剩下的是电流乘以电阻。
功率二极管
功率二极管 适合在电路中阻止电流单方向流动。功率二极管的使用方式与 LED 相同,但它们不是在电流流动时发光,而是保护电路免受反向电流的影响。二极管有很多不同的类型;我们将使用常见的 1N4004 版本,如图 1-23 所示。
图 1-24 展示了 1N4004 二极管的示意符号,参见图 1-23。

图 1-23:1N4004 功率二极管

图 1-24:1N4004 示意符号
1N4004 允许最高 1A 的电流从阳极引脚流向阴极引脚。1N4004 还会导致 0.7V 的直流电压降,是在需要时降压的便捷方式。
电容器
电容器是一种可以储存电荷的设备。它由两片金属板和一个绝缘层组成,允许电荷在它们之间积累。当电流流向电容器时,电荷会积累到最大值。一旦电荷达到最大值,电流就会停止流过电容器。然而,电荷会保留在电容器中,并在电容器提供新的路径时流出,这个过程叫做放电。
电容器能够储存的电荷量以法拉(farad)为单位。一法拉实际上是一个非常大的量,因此你通常会看到电容器的数值为皮法拉或微法拉。一皮法拉(pF)是 0.000000000001 法拉,而一微法拉(μF)是 0.000001 法拉。电容器也有额定电压。在本书的项目中,你将只使用低电压,因此你会使用额定电压大于 10 伏左右的电容器。通常来说,在低电压电路中使用额定电压较高的电容器是可以的。电容器常见的电压额定值有 10 伏、16 伏、25 伏和 50 伏。
本书中的项目将使用两种类型的电容器,陶瓷电容器和电解电容器。
陶瓷电容器
图 1-25 展示了一个陶瓷电容器。这些电容器非常小,因此储存的电荷量也很小。它们没有极性,可以用于电流双向流动的电路。由于陶瓷电容器的电容值较小,它们在高频电路中表现非常好,因为它们可以非常快速地充电和放电。

图 1-25:一个 0.1μF 的陶瓷电容器
读取陶瓷电容器的数值需要一些练习,因为它的数值是以某种代码形式打印出来的。前两位数字表示皮法拉(pF)的数值,第三位数字是十位的倍数。例如,图 1-25 中展示的电容器标记为“104”。这意味着 10 后面跟着四个零,即 100,000 皮法拉(相当于 100 纳法,或 0.1 微法)。
零售商或其他项目可能会指定需要你进行一些即时计算的电容值。为了简化这些单位之间的转换,你可以打印出在www.justradios.com/uFnFpF.html网站上提供的优秀转换图表。
电解电容器
电解电容器在体积上比陶瓷电容器大,提供更高的电容值,并且是极性电容器。外壳上的标记表示正极(+)和负极(–)两侧;例如,图 1-26 展示了标记电容器负极的条纹和小符号(–)。

图 1-26:一个电解电容器
像电阻值一样,标记的电容器值也有一定的容差。与电阻器和陶瓷电容器不同,电解电容器的值直接印在其上,无需解码或解释。图 1-26 中的电容器具有 20% 的容差,负极符号的条纹上标明了这一点,并且标有 100 μF 的电容值,该值印在标签的较暗部分。
电解电容器提供电源*滑和稳定,特别是在电流需求大的电路或部件附*。这能防止电路中出现不必要的掉电和噪声。
集成电路
更常见的缩写是IC,集成电路是将一组电子电路嵌入到一块硅片中,并安装在一个坚固的、通常为矩形的塑料外壳内。电流通过金属引脚或脚垫流入或流出。
IC 引脚通过数字标识,操作 IC 的第一步是弄清楚每个引脚对应的是哪个。首先,查找 IC 的引脚图;它通常可以在供应商的官网上找到。(请查看表 1-1,以查看本书中使用的微控制器引脚图。)然后,查看 IC 上有标记的那一侧,以确定哪个引脚是第一个引脚。通常,这个引脚会有一个小圆圈靠*它。如果没有看到小圆圈,将 IC 垂直放置,缺口端朝上。当 IC 水*放置在你面前时,第一个引脚位于 IC 的左下角,见图 1-27。

图 1-27:突出显示 IC 的第一个引脚
在从无焊面包板或其他位置插拔 IC 时,要小心不要弯曲引脚,因为它们非常脆弱,容易受到金属疲劳的影响。最好使用IC 提取器,这是一种简单但非常实用的爪形工具,可以同时拉动 IC 两端,就像在图 1-28 中展示的那样。

图 1-28:IC 提取器使用示例
使用此设备时,你可以交替缓慢地对每一端施加向上的压力,直到 IC 足够松动,可以慢慢地将其从 IC 插座中拉出。
无焊面包板
你需要一个基础*台来搭建你不断变化的电路,无焊接面包板是一个很好的工具。面包板是一个塑料基础,上面有一排排电气连接的插座。面包板有多种尺寸、形状和颜色,如图 1-29 所示。只不过不要在上面切面包。颜色从电气角度来说并不重要,它们只是帮助最终用户区分不同的面包板。

图 1-29:不同形状和大小的面包板
使用面包板的关键是理解插座是如何连接的,无论是短列还是长排,连接方式有所不同。例如,在图 1-30 中的面包板,五个孔的列是纵向连接的,但横向是孤立的。如果你把两根电线插在同一竖直列中,它们会电气连接。相同地,位于横向线条之间的长排插座是横向连接的。你通常需要将电路连接到电源电压和地,这些长的横向孔列非常适合这些连接。

图 1-30:面包板内部连接
当你构建更复杂的电路时,面包板会变得拥挤,你不总能将组件精确放置在想要的位置。你可以通过使用短接线来解决这个问题。销售面包板的零售商通常也会出售各种长度的小盒子电线,比如图 1-31 中展示的那种。

图 1-31:各种面包板接线
为你的项目供电
你可以使用多种方法为你的项目供电。为了保持简单和安全,我将只展示几种容易且便宜的选择。
首先,USBasp 本身可以为你的 AVR 电路提供适当的电压。它只提供有限的电流,大约 450 毫安,但对于小型项目,组件数量有限时,这已经足够。然而,这种方法只有在你的项目靠* USB 插口时才方便使用。
另一个选择是使用带有稳定输出 5V 的主电源插头和直流插孔适配器。如图 1-32 所示,适配器可以让你将电线连接到一个插孔,插头可以连接到这个插孔。这可以避免修改或损坏插头线的端部。

图 1-32:直流插孔适配器
另外,面包板电源是一个极好的选择,因为它既便宜又非常易于使用。这是一个小型电路板,插入到你的无焊接面包板的一端,如图 1-33 所示。电源随后将来自市电插头适配器的电流转换为安全的 5 V 或 3.3 V。

图 1-33:一个无焊接面包板电源
你的最终选择是使用四个可充电的 AA 电池放入电池盒中。当充电后,这些电池每个的电压为 1.2 V。四个电池总共提供 4.8 V,这对于几乎所有项目来说足够接* 5 V。不要使用一次性 AA 电池,因为新电池的初始电压超过 6 V,这会损坏你的项目。你可以将可充电的 AA 电池整齐地放在一个封闭容器中,例如图 1-34 所示的那种。

图 1-34:一个四 AA 电池电池盒
本书中的项目使用了本节中讨论的所有四种电源方法,因此你需要为每种方法准备相应的零件。有关更多详细信息,请查看nostarch.com/avr-workshop/上的零件清单。
在本章中,你通过查看一些可能的示例、安装所需软件、学习电力基础以及了解你将使用的一些零件,为你的 AVR 学习打下了基础。现在你已准备好进入下一章,在那里你将构建基于 AVR 微控制器的第一个电子电路,上传你的第一个代码,并开始深入了解微控制器的不同操作。
第二章:# 第一阶段

现在,您已经安装了所需的软件,并准备好进入 AVR 微控制器的世界,本章将通过一些基础电路和代码带您轻松进入第一个 AVR 项目。
在本章中,您将学习如何:
-
• 测试您的 AVR 项目设置。
-
• 阅读基础电路原理图。
-
• 使用 AVR 微控制器的数字输出引脚控制 LED,包括 ATtiny85 和 ATmega328P-PU。
我还将向您展示如何使用 #define 宏和 for 循环开始编写代码,以及如何使用按位算术和位移操作高效生成输出。
测试硬件和工具链
在此时,明智的做法是检查 USBasp 编程器和之前安装的工具链是否按预期工作。我们将通过三个阶段来检查:构建一个简单的电路,测试 USBasp,并将代码上传到微控制器。
构建电路
在本节中,您将构建一个简单的设备,使 LED 闪烁,这是测试硬件和工具链的一种有趣且简单的方法。要开始,您需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• ATtiny85–20PU 微控制器
-
• 一个 5 毫米红色 LED
-
• 一个 560 Ω 电阻
-
• 七根跳线
现在,让我们专注于实际步骤,将电路连接起来,以确保您的工具链正常工作,并在我们深入更复杂的项目时能够稳定运行。要连接测试电路的组件,首先将无焊面包板放置在*坦的表面上,如图 2-1 所示。

图 2-1:无焊面包板
将 ATtiny85 插入面包板,使其横跨面包板顶部四行的垂直间隙,如图 2-2 所示,确保将微控制器的引脚 1——由引脚旁的小圆圈标记,如第一章中所描述——插入面包板的 e 列,第 1 行。引脚编号是按逆时针方向排列的,因此图中的引脚 4 位于左下角,而引脚 8 位于右上角。

图 2-2:AVR 微控制器在面包板中的位置
现在,拿起 560 Ω 电阻,将一脚插入与 ATtiny85 引脚 2 相同的行,另一脚插入更远几行的位置。如图 2-3 所示,我将第二脚插入第 8 行。

图 2-3:面包板中的电阻
接下来,看看你的 LED。注意,其中一条引脚比另一条长,如 图 2-4 所示。

图 2-4:典型的 LED
将 LED 插入无焊接面包板中,较长的引脚与电阻的下端在同一排(在我们的例子中是第 8 排),较短的引脚则插入距其两排的地方,使用 图 2-5 作为指南。

图 2-5:到目前为止的电路
拿一根跳线,将一端插入 LED 较短引脚所在的同一排,另一端插入 ATtiny85 的 4 号引脚所在的同一排,如 图 2-6 所示。

图 2-6:接线开始了!
现在你已经放置好了组件,我将向你展示如何通过 AVR 编程器将代码从电脑传输到微控制器。
连接并运行编程器
要将 USBasp 编程器连接到面包板,首先将六根公对母跳线连接到 USBasp 上的六个连接引脚,如 图 2-7 所示。

图 2-7:USBasp 连接引脚
接下来,使用 表 2-1 中的映射,将 USBasp 的六个引脚连接到 ATtiny85。每次你编程 ATtiny85 微控制器时,都需要使用这些相同的连接。现在不用担心引脚标签的含义,我将在书中逐步为你解释。
| 表 2-1:USBasp 到 ATtiny85 的连接 |
|---|
| USBasp 引脚 |
| --- |
| RST |
| GND |
| VCC |
| SCK |
| MISO |
| MOSI |
图 2-8 展示了 USBasp 和 ATtiny85 之间的连接,具体描述见 表 2-1。我已移除电路的其余部分,仅展示了连接的示例。

图 2-8:USBasp 连接到 ATtiny85 上的无焊接面包板
接下来,检查 USBasp 是否正常工作,将你的 USBasp 编程器连接到电脑。为此,你需要使用一个名为 AVRDUDE 的程序,它是安装工具链的一部分,用于将代码上传到 AVR 微控制器。打开终端窗口,输入命令 avrdude -p t85 -c usbasp -B 4。该命令包含以下选项:
-
•
-p选择项目中使用的微控制器类型。你刚才使用了t85代表 ATtiny85,稍后你将使用m328p代表 ATmega328P-PU。 -
•
-c选择正在使用的硬件编程器类型。在这种情况下,你指定了usbasp,代表你的 USBasp 编程器。 -
•
-B设置 USBasp 编程器中微控制器的处理速度。你将该值设置为4,将速度降低到 187.5 kHz。这对新微控制器是必要的,因为它们在出厂时的时钟速度为 1 MHz,低于 USBasp 的默认速度。关于速度的更多内容,我将在第十二章 中解释。
软件工具链应该与编程器和微控制器进行通信,你的终端显示应该像下面这样:
C:\>
`avrdude -p t85 -c usbasp -B 4`
avrdude: set SCK frequency to 187500 Hz
avrdude: AVR device initialized and ready to accept instructions
Reading | ################################################## | 100% 0.03s
avrdude: Device signature = 0x1e930b
avrdude: safemode: Fuses OK
avrdude done. Thank you.
C:\>
通过报告设备签名,即 AVR 微控制器类型的唯一标识符,软件工具链表示一切正常。
AVRDUDE 文档(www.nongnu.org/avrdude/user-manual/avrdude_4.html)描述了除了 avrdude 命令中包含的选项之外的其他选项,如果你感兴趣的话。目前,如果你在输入该命令后看到正确的输出,可以跳到项目 0。你已经准备好编程你的 AVR 了!
如果它没有成功怎么办?
如果在运行上一步骤中的命令后,出现类似下面的错误,那么说明编程器和电路之间的硬件连接存在问题:
C:\>
`avrdude -p t85 -c usbasp -B 4`
avrdude: set SCK frequency to 187500 Hz
avrdude: error: programm enable: target doesn’t answer. 1
avrdude: initialization failed, rc=-1
Double-check connections and try again, or use -F to override this check
avrdude done. Thank you.
C:\>
不要像错误信息建议的那样使用 -F 来覆盖检查。相反,请仔细检查 USBasp 与无焊接面包板之间的连线,确保没有连接松动。然后检查电路本身,确认各个组件按“构建电路”部分的说明互相连接。特别是,检查 ATtiny85 的引脚 1 是否与面包板的左上角对齐。错误地放置芯片是最常见的电路错误之一!
如果一切看起来都正确,再次尝试运行 avrdude 命令。它应该可以工作,但如果不行,离开几分钟,然后再回过头来复查整个过程。
安全使用 AVR
在我们进入第一个项目之前,提醒大家注意安全:与任何爱好或手工艺一样,保护自己和周围的人是你的责任。在本书中,我讨论了使用基本手工工具、电池驱动的电器、锋利的刀具、切割工具,以及有时使用热烙铁。在你的项目中,绝对不要直接接触交流电源。也就是说,不要将你制作的任何东西直接连接到墙壁插座。把这部分交给经过训练的持证电工来处理。记住,直接接触交流电流可能致命。
一旦你的电路工作正常,将它们保留在一起,包括 USBasp,因为你需要所有这些东西来完成 第一个项目。
Project 0: 闪烁 LED
现在你已经有了一个工作的电路和编程器连接,是时候创建并上传你的第一个 程序(也称为 代码),这是一组指令,告诉微控制器如何完成某项特定任务。
随着本书项目的逐渐复杂,我们的程序会变得越来越长。为了避免手动输入书中每一段代码,请从 www.nostarch.com/avr-workshop 下载包含本书代码的 ZIP 文件。该文件包含每个项目的文件夹(按章节组织),其中包含代码和完成项目编程所需的其他内容。
注意:如果你正在阅读本书的电子版,请不要直接从书中复制粘贴代码到文本编辑器中。请使用下载的代码文件。
本书中的项目使用 C 编程语言。由于 C 语言在许多微控制器和计算机*台中都很常见,因此如果你需要帮助或想与他人分享工作,应该很容易找到相关的支持。
上传你的第一个 AVR 代码
下载并解压本书的代码文件后,使用文本编辑器打开 Chapter 2 文件夹中的 Project 0 子文件夹内的 main.c 文件。这个 C 文件包含一个小程序,编译并上传到微控制器后,它应该会让你的 LED 闪烁。但为了实现预期效果,你需要将每个项目的 C 文件与 Makefile 配对使用。
Makefile 包含了一组供工具链在编译并上传代码到微控制器时使用的指令,其中包括微控制器类型、微控制器所需的 CPU 速度,以及你计划使用的编程器类型。每当你开始一个新项目时,都应该为该项目创建一个新文件夹,并将 main.c 文件和 Makefile 放入该文件夹内。为了节省你的时间,我已经为 Project 0 和本书中所有其他项目准备好了这些文件。
如果你对 Makefile 的内容感兴趣,可以用文本编辑器打开 Project 0 目录下的 Makefile 文件查看。我会在你进行项目时,介绍书中下载包中 Makefile 的必要修改。
既然你已经熟悉了这些文件类型,现在是时候使用你在本章早些时候构建的电路来实现你的第一个项目了。如果你之前关闭了它,请像测试工具链时那样打开一个终端窗口。接下来,导航到包含项目 0 的两个文件的文件夹,并输入命令make flash。
稍等片刻,工具链应当编译程序文件并创建所需的数据文件,以便上传到微控制器。微控制器随后应开始运行该程序;在这种情况下,你的 LED 应该开始闪烁。在此过程中,状态应显示在终端窗口中,如下所示:
C:\>
`make flash`
avrdude -c USBasp -p attiny85 -B 4 -U flash:w:main.hex:i
avrdude: set SCK frequency to 187500 Hz
avrdude: AVR device initialized and ready to accept instructions
Reading | ################################################## | 100% 0.02s
avrdude: Device signature = 0x1e930b
avrdude: NOTE: FLASH memory has been specified, an erase cycle will be performed
To disable this feature, specify the -D option.
avrdude: erasing chip
avrdude: set SCK frequency to 187500 Hz
avrdude: reading input file "main.hex"
avrdude: writing flash (108 bytes):
Writing | ################################################## | 100% 0.05s
avrdude: 108 bytes of flash written
avrdude: verifying flash memory against main.hex:
avrdude: load data flash data from input file main.hex:
avrdude: input file main.hex contains 108 bytes
avrdude: reading on-chip flash data:
Reading | ################################################## | 100% 0.06s
avrdude: verifying . . .
avrdude: 108 bytes of flash verified
avrdude: safemode: Fuses OK
avrdude done. Thank you.
C:\>
让我们来看一下代码,看看这个程序是如何工作的:
❶ // Project 0 - Blinking an LED
❷ #include <avr/io.h>
#include <util/delay.h>
❸ int main(void)
{
❹ DDRB = 0b00001000; // Set PB3 as output
❺ while(1)
{
PORTB = 0b11111111;
_delay_ms(1000);
PORTB = 0b00000000;
_delay_ms(1000);
}
return 0;
}
main.c的第一行❶是一个注释,标明程序名称并描述其目标。当编写程序时,添加像这样的注释,解释如何使用程序或突出其他重要细节,是一个好习惯;当你回顾代码或与他人共享代码时,这些注释可能会非常有用。注释可以是任意长度的,且可以在程序的任何位置使用。要在单行中添加注释,只需输入两个正斜杠,然后写上注释,像这样:
// Project 0 - Blinking an LED
正斜杠告诉软件工具链中的编译器,在编译程序时忽略行上的其余文本。在你自己的项目中,你可以通过在注释文本之前的行上输入/*字符,然后以*/字符结束注释,从而添加跨越两行或更多行的注释,如下所示:
/*
Project 0
Blinking an LED
by Mary Smith, created 20/10/2022
*/
返回到main.c,include语句❷告诉编译器查找像avr/io.h这样的库文件,以获取编译程序所需的更多信息。有许多库,每个库允许你在代码中使用不同的功能,如果需要,你甚至可以创建自己的库。你将在第十章中学习到这方面的内容。
所有运行程序的指令都出现在int main(void)之后的花括号内,这里是程序的主要部分开始的位置❸。在这些花括号内,程序配置了微控制器的某些参数——也就是一些设置,用来使各种操作发生。首先,程序告诉微控制器哪些物理引脚将作为输入或输出❹。你将 LED 连接到引脚 2,AVR 将其识别为 PB3,因此代码将该引脚设置为输出。(如果现在有些困惑,不用担心;我将在接下来的几章中详细介绍输入和输出。)
最后,while(1)后面花括号中的代码❺应当重复执行,通过持续切换引脚 2 是输出 1 还是 0,来让 LED 闪烁,直到微控制器失去电力或你重置它。1 为 LED 供电,这会使 LED 亮起,而 0 则使 LED 熄灭。
要实验 LED 闪烁的速度,返回到main.c文件,将两行_delay_ms(1000);中的1000替换为你喜欢的任何非负数值。然后保存main.c文件,并重新运行make flash命令。LED 应该会根据你使用的值闪烁得更快或更慢。
如果它没有工作怎么办?
如果代码中有错误,编译器会指示出包含错误的代码行,或与其非常接*的行。例如,当我在项目 0 的第 10 行代码中拼写错误时,运行make flash命令时会发生如下情况。编译器找到了错误,并给出了相应的输出:
avr-gcc -Wall -Os -Iusbdrv -DF_CPU=1000000 -mmcu=attiny85 -c main.c -o main.o
main.c: In function 'main':
main.c:17: error: 'return' undeclared (first use in this function)
main.c:17: error: (Each undeclared identifier is reported only once
main.c:17: error: for each function it appears in.)
main.c:17: error: expected ';' before numeric constant
make: *** [main.o] Error 1
C:>
如果发生这种情况,打开main.c文件,在文本编辑器中找到并修正错误,然后再尝试上传。为了在不上传程序的情况下编译程序,只需在终端窗口中单独运行make命令。
运行make命令是检查代码中错误(如拼写错误)的一种好方法,但它可能无法帮助你发现逻辑错误——即你是否正确地告诉了微控制器你希望它执行的操作。作为一般规则,记住即使程序能编译成功,如果你在编写代码前没有仔细规划给微控制器的指令,它也可能无法按预期行为运行。
控制数字输出
现在你已经了解了本书中将使用的电子元件,让我们再谈谈 ATtiny85 和 ATmega328P-PU 上的数字输出引脚。
总结一下,数字输出引脚是一个可以控制的电流源;它可以是开(on)或关(off)。在 ATtiny85 上最多有六个引脚可以作为输出工作,在 ATmega328P-PU 上最多有八个引脚可以作为输出工作。我说“最多”是因为有些引脚可以有多个功能,具体取决于你如何设置它们。稍后我会在本章中解释如何选择引脚功能。
每个输出引脚提供的最大电流量是有限的。在 ATtiny85 上,这个最大值是 40 mA。然而,你可以通过 IC 的总最大电流为 200 mA。过多的电流可能会引发问题,因此为了避免任何问题,假设你在 ATtiny85 和 ATmega328P-PU 的每个输出引脚上最大电流为 20 mA。在创建自己的项目时,请记住这些额定值;本书中的所有项目都设计得能避免这个问题。
硬件寄存器
理解数字输出的关键是学习硬件寄存器。我们的 AVR 微控制器都有多个寄存器,用于存储与微控制器操作的所有可能设置相关的信息。放入这些寄存器中的数值控制数字输出。
第一个需要考虑的 AVR 寄存器叫做DDR,即数据方向寄存器。这个寄存器用于告诉微控制器哪些引脚是输出,哪些是输入。有些微控制器,比如 ATtiny85,只有一个 DDR 寄存器,而一些像 ATmega328P-PU 这样的微控制器则有多个。第二个需要考虑的寄存器叫做PORT。你将使用这个寄存器来设置哪些引脚是打开的,哪些是关闭的。
每个寄存器的大小为 8 位,每个位可以是 0 或 1,就像二进制数一样。每个位与微控制器上的一个物理引脚相关联。在 DDR x 寄存器中,1 表示引脚为输出,0 表示引脚为输入。在 PORT x 寄存器中,1 表示引脚为开启,0 表示引脚为关闭。
你可以通过查看微控制器的数据手册来了解有多少引脚和寄存器可用(你可以从 Microchip 网站下载 ATtiny85 的数据手册,网址为www.microchip.com/wwwproducts/en/ATtiny85)。例如,图 2-9 中的图示显示,ATtiny85 有一个 PORT 寄存器:PORTB 寄存器,涵盖了引脚 5、6、7、3、2 和 1。

图 2-9:ATtiny85 的引脚和 PORT 寄存器
在图示和数据手册中,PORT 寄存器的名称通常会缩写;在图 2-9 中,PORTB 寄存器被称为 PB,每个引脚被标记为 PB x,其中x是引脚的编号。在代码中,你将称这个寄存器为PORTB。对应的 DDR 寄存器叫做 DDRB,它控制 PORTB 的数据显示方向。请注意,你只能使用 PB0 到 PB4 引脚,因为 PB5 有其他功能,我们稍后会讨论。
要将 DDRB 寄存器中的一些引脚设置为输出,只需将相应的位设置为 1。举个例子,让我们重新看一下项目 0 中的代码:
// Project 0 - Blinking an LED
#include <avr/io.h>
#include <util/delay.h>
int main(void)
{
❶ DDRB = 0b00001000;
for(;;)
{
_delay_ms(1000);
PORTB = 0b11111111;
_delay_ms(1000);
PORTB = 0b00000000;
}
return 0;
}
这段代码将物理引脚 2 设置为输出❶。要将所有 PORTB 引脚设置为输出,你可以使用:
DDRB = 0b11111111;
让我们来看看这是如何工作的。ATtiny85 的 DDRB 寄存器的物理引脚为 1、3、2、7、6 和 5。每个引脚对应寄存器中的一个单独的位;从左到右,这些引脚分别是位 5、3、4、2、1 和 0。因此,举个例子,要将物理引脚 6(PB1)设置为输出,其他引脚设置为输入,你可以使用:
// Bits: 76543210
// Pins: 44132765
DDRB = 0b00000010;
如我们在项目 0 代码中看到的,要将物理引脚 2(PB3)设置为输出,其他引脚设置为输入,你可以使用:
// Bits: 76543210
// Pins: 44132765
DDRB = 0b00001000;
我们将寄存器右侧的位称为第一位或最低有效位(LSB),而寄存器左侧的位称为最后一位或最高有效位(MSB)。刚开始可能会觉得有点不直观,但位是二进制数的等价物,其内容使用相同的方法进行引用。
因为 ATtiny85 只有六个输出,你可以在 DDRB 语句中将最后两个位(6 和 7)设置为 0 或 1。一旦你将引脚设置为输出,可以使用PORT x函数来切换输出的开关。要将所有输出打开,请使用:
PORTB = 0b11111111;
要将它们全部关闭,请使用:
PORTB = 0b00000000;
或者,要打开 3 号引脚(PB4)和 5 号引脚(PB0),并将其余引脚关闭,可以使用:
// Bits: 76543210
// Pins: 44132765
PORTB = 0b00010001;
要实验这些功能,在下一个项目中,你将构建一个类似于项目 0 的电路,但这次将使用四颗 LED。
项目 1:实验 ATtiny85 数字输出
为了练习并增加对使用 ATtiny85 的 DDRB 和 PORTB 寄存器的理解,在这个项目中你将控制四个输出设备(LED)。尽管闪烁的 LED 看起来可能是一个相对简单的例子,但控制数字输出的能力是控制各种对象的基础。
硬件
你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• ATtiny85–20PU 微控制器
-
• 四个 LED
-
• 四个 560 Ω 电阻
-
• 跳线
该项目的电路与项目 0 中的类似,只是多了三颗 LED。请按照图 2-10 中的方式组装电路。

图 2-10:用于项目 1 的主电路物理布局图
一旦你组装好电路,连接 USBasp 编程器。按照表 2-1 中的连接方式进行重复连接。
如果你想尝试创建像图 2-10 中的图示一样的图表,可以使用 Autodesk Tinkercad,网址:www.tinkercad.com/。
代码
打开终端窗口,导航到包含项目 1 两个文件的文件夹,并输入命令make flash。稍等片刻,工具链应会编译程序文件,并创建所需的数据文件上传到微控制器。然后,微控制器将运行该程序,控制数字输出的开关,从而使四个 LED 灯闪烁。
要查看这个是如何工作的,请打开位于Chapter 2文件夹中Project 1子文件夹中的main.c文件:
// Project 1 - Experimenting with ATtiny85 Digital Outputs
#include <avr/io.h>
#include <util/delay.h>
int main(void)
{
❶ DDRB = 0b11111111; // Set pins as outputs
for(;;)
{
❷ _delay_ms(250);
❸ PORTB = 0b00011111;
_delay_ms(250);
❹ PORTB = 0b00000000;
}
return 0;
}
首先我们使用DDRB将所有引脚设置为输出 ❶。_delay_ms()函数 ❷让微控制器暂停一段时间。要使用延时,只需在括号中输入你希望程序暂停的毫秒数:_delay_ms(250)将延时时间设置为 250 毫秒。接下来,我们打开数字输出,使电流从引脚流出,经过电阻和 LED,再流向地面(GND),完成电路 ❸。经过另一个延时后,我们关闭数字输出,导致 LED 关闭 ❹。
到现在为止,你应该理解了如何将 ATtiny85 的引脚设置为输出、打开和关闭它们,并创建延时。通过改变哪些 LED 打开和关闭、延时的长度等,来实验延时和引脚。
使用原理图
项目 0 和 1 分别展示了如何使用图片和物理布局图来构建电路。像图 2-10 中的物理布局图看似是绘制电路的最简单方法,但随着组件的增多,直接的表示方式使物理图变得一团糟。因为你的电路即将变得更加复杂,从现在开始,我将使用原理图(也叫电路图)来展示它们,就像图 2-11 所示的那样。

图 2-11:一个示例原理图
原理图是电流流经各个组件的路线图,组件之间的连线表示这些路径。原理图不是显示组件和电线,而是使用符号和线条。
原理图中的组件
一旦你知道这些符号的含义,阅读原理图就变得很容易了。首先,让我们检查一下你已经使用过的组件符号。
图 2-12 展示了 ATtiny85 微控制器的符号。引脚编号标注得很清楚;别忘了,第 1 引脚位于物理芯片的左上角。其他微控制器和集成电路(IC)使用类似的符号,但其大小取决于引脚数量。

图 2-12:ATtiny85 微控制器符号
图 2-13 展示了电阻符号。通常的做法是将电阻值和部件标识符与电阻符号一起显示(此例中为 220 Ω 和 R1)。这会使所有试图理解原理图的人,包括你,轻松很多!

图 2-13:电阻符号
图 2-14 显示了 LED 符号。二极管家族中的所有成员共享一个常见符号——三角形和竖线,但 LED 符号会显示两个*行的箭头指向三角形的外侧,以表示发光。

图 2-14:LED 符号
现在你已经了解了各种元件的原理图符号,接下来我将向你展示在电路原理图中,元件之间的导线连接是如何表示的。
原理图中的导线
当导线在原理图中交叉或连接时,它们的绘制方式如下:
交叉但不连接的导线
-
当两根导线交叉但没有连接时,可以用两种方式表示交叉,如图 2-15 所示。两种方式都是正确的,选择哪种方式仅仅是个人偏好问题。
![表示电路原理图中不连接导线的两种方式]()
图 2-15:不连接的交叉导线
-
连接的导线
-
当导线物理连接时,会在连接点绘制一个交点标记,如图 2-16 所示。
![电路原理图中连接导线的表示]()
图 2-16:连接的两根导线
-
连接到地的导线
-
图 2-17 中所示的符号表示导线与地(GND)连接。
![电路接地的原理图符号]()
图 2-17:GND 符号
原理图中位于一根导线末端的 GND 符号表示该导线与微控制器的 GND 引脚物理连接。对于你的电路,这也被称为负极。
分析原理图
现在你已经了解了目前为止使用的符号,让我们来分析你为项目 1 绘制的原理图。将图 2-18 中的原理图与图 2-10 中的电路实物图进行对比。

图 2-18:为项目 1 绘制的原理图
上方标有 +5V 的箭头代表面包板上的 5V 电源,ATtiny85 被标注为其芯片名称。LED 1 到 4 与 ATtiny85 相连,连接方式与 图 2-10 中所示相同,正如原始电路一样,所有四个连接到 LED 的电阻都接地(GND),接到微控制器的 4 号引脚。注意 R1、R2、R3 和 R4 连接点处的点,它们表示这些电阻都连接到相同的位置(GND)。图 2-18 中引脚 1 和 3 上的 X 表示这些引脚没有连接任何东西。
在这个原理图中,你可以追踪电流从电源流经电路到地的路径。电流从 5V 电源供应,进入微控制器。我们的代码允许电流根据需要从数字输出引脚流出。电流通过 LED(使 LED 发光)和电阻(调节电流)后,最终到达地并完成电路。
注意 如果你想自己绘制计算机绘制的原理图,可以尝试使用开源的 KiCad 软件包,免费提供或通过捐赠获取,网址:www.kicad.org/。
除了首次使用 ATmega328P-PU,本 项目 还将让你运用新学的电路原理图阅读知识。
项目 2:实验 ATmega328P-PU 数字输出
你可以一眼看出,ATmega328P-PU 拥有比 ATtiny85 更多的数字输出。来自数据手册中的引脚图 图 2-19 提供了详细信息。

图 2-19:ATmega328P-PU 的引脚图和端口寄存器图
有两个寄存器可以用于数字输出:PORTB (PB) 和 PORTD (PD)。在本项目以及接下来的几个项目中,你将使用 PORTB 寄存器。在这个项目中,你将通过更多的 LED 来使用 ATmega328P-PU 的数字输出。
硬件部分
你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• ATmega328P-PU 微控制器
-
• 八个 LED
-
• 八个 560 Ω 电阻
-
• 跳线
我提供了物理布局图和所需电路的原理图,你可以使用它们来组装电路。 图 2-20 显示了你肉眼可见的物理连接,而 图 2-21 中的原理图则以更紧凑、易于理解的形式展示了各组件之间相同的电气连接。

图 2-20: 项目 2 的电路图

图 2-21:项目 2 的原理图
将 USBasp 编程器连接到电路,然后使用表 2-2 中的信息连接编程器和微控制器之间的跳线。请注意这些连接,因为每次你为 ATmega328P-PU 微控制器编程时,它们都将保持一致。
| 表 2-2:USBasp 与 ATmega328P-PU 连接 |
|---|
| USBasp 引脚 |
| --- |
| RST |
| GND |
| VCC |
| SCK |
| MISO |
| MOSI |
在将代码上传到微控制器之前,先测试编程器是否与 ATmega328P-PU 通信。将 USBasp 编程器连接到计算机,并打开终端窗口。输入命令avrdude -p m328p -c usbasp -B 4并按下回车键。软件工具链应该会与编程器和微控制器进行通信,你应该在终端中看到以下输出:
C:\>
`avrdude -p m328p -c usbasp -B 4`
avrdude: set SCK frequency to 187500 Hz
avrdude: AVR device initialized and ready to accept instructions
Reading | ################################################## | 100% 0.03s
avrdude: Device signature = 0x1e950f
avrdude: safemode: Fuses OK
avrdude done. Thank you.
C:\>
这个avrdude命令与之前测试 ATtiny85 设置时使用的命令唯一的区别在于,我们将微控制器参数-p改为了m328p。如果在输入此命令后看到正确的输出,说明可以继续;否则,请查阅第 35 页的“如果没有工作怎么办?”部分。
代码
打开终端窗口,导航到包含项目 2 两个文件的文件夹,并输入命令make flash。稍等片刻,工具链应当编译程序文件,并创建上传到微控制器所需的数据文件。然后微控制器应该会运行该程序,导致所有八个 LED 同时闪烁。
要查看它是如何工作的,请打开位于第二章文件夹中项目 2子文件夹中的main.c文件。
// Project 2 - Experimenting with ATmega328P-PU Digital Outputs
#include <avr/io.h>
#include <util/delay.h>
int main(void)
{
DDRB = 0b11111111; // Set PORTB register as outputs
for(;;)
{
_delay_ms(250);
❶ PORTB = 0b11111111;
_delay_ms(250);
❷ PORTB = 0b00000000;
}
return 0;
}
本项目中的代码与项目 1 中的代码相同。然而,由于 ATmega328P-PU 有一个完整的 PORTB 寄存器(即八个输出),你可以使用PORTB函数控制所有这些输出。为了练习,可以更改PORTB线路,尝试让 LED 闪烁。尝试通过更改❶处的线路,将一半的 LED 打开,另一半关闭:
PORTB = 0b11110000;
和❷处的线路:
PORTB = 0b00001111;
你还可以通过添加更多的PORTB线路并设置不同的开/关状态来增加效果。尽情发挥吧!完成实验后,保持这个电路连接,因为你将在本章剩余部分中继续使用它。
你很快就会看到 PORTB 寄存器的每一位是如何与微控制器的数字输出相对应的。然而,有一种更好的方法可以控制输出,从而创建复杂的模式。在下一个项目中,你将使用相同的电路,通过变量、函数、位移和位运算更高效地控制输出。
项目 3:位移数字输出
在这个项目中,你将学习更高效的方式来控制数字输出。这些技术将使你在不增加过多代码的情况下对输出进行更精细的控制,从而避免浪费微控制器的程序内存。
本项目使用与项目 2 相同的硬件,因此你应该已经设置好了。打开终端窗口,导航到包含项目 3 的两个文件的文件夹,然后输入命令 make flash。再次,工具链应该处理代码,很快 LED 灯应该开始按左到右、然后右到左的重复模式闪烁。
现在打开位于第二章文件夹中的项目 3子文件夹中的 main.c 文件,仔细看看它是如何工作的:
// Project 3 - Bit-Shifting Digital Outputs
#include <avr/io.h>
#include <util/delay.h>
❶ #define TIME 100 // Delay in milliseconds
int main(void)
{
❷ uint8_t i; // 8-bit integer variable "i"
DDRB = 0b11111111; // Set PORTB register as outputs
while (1)
{
❸ for (i = 0; i < 8; i++)
{
❹ _delay_ms(TIME);
PORTB = 0b00000001 << i;
}
❺ for (i = 1; i < 7; i++)
{
_delay_ms(TIME);
PORTB = 0b10000000 >> i;
}
}
return 0;
}
这段代码引入了一些新概念。首先,#define 宏允许你将值分配给单词,这些值被称为常量值或简称常量。常量使得在代码中稍后引用值成为可能,同时也让代码更易于阅读。例如,#define TIME 100 ❶ 告诉编译器在代码中任何使用 TIME 的地方都将 TIME 替换为 100,正如在 __delay_ms_ 行 ❹ 中一样。要更改闪烁延迟,你只需要更改原始 #define 宏中的值,编译器会处理剩余的部分。每次使用 #define 时,你必须将其放在主 int main(void) 循环之前。
在主循环中,我们定义了一个变量,它表示数据。变量的值可以在代码执行过程中改变,而通过 #define 宏定义的常量的值则不能。你可以将变量看作是微控制器内存的一部分,用于存储在程序执行过程中你可以根据需要更改的数字。本书中你将使用的第一个变量类型是整数。在编程术语中,这种类型可以存储一个整数;也就是说,可以是正数、负数或零,且没有小数部分。
要定义一个变量,首先输入类型,然后是标签。行 uint8_t i; ❷ 定义了一个名为 i 的变量,类型为 uint8_t。这种类型的变量可以存储一个介于 0 和 255 之间的整数(u 代表无符号;无符号整数不能存储负数)。字母 i 现在表示一个整数,你可以随时更改其值。
有六种类型的整数变量可以使用:
-
•
uint8_t是一个 8 位无符号整数(0 到 255)。 -
•
int8_t是一个 8 位有符号整数(–128 到 127)。 -
•
uint16_t是一个 16 位无符号整数(0 到 65,535)。 -
•
int16_t是一个 16 位有符号整数(–32,768 到 32,767)。 -
•
uint32_t是一个 32 位无符号整数(0 到 4,294,967,295)。 -
•
int32_t是一个 32 位有符号整数(–2,147,483,648 到 2,147,483,647)。
初看之下,你可能会认为较小的整数类型是多余的,应该只使用int32_t来满足所有整数需求。然而,整数类型越大,微控制器处理这些数字所需的时间就越长。为了最大化效率,在选择整数类型时,请考虑项目的需求,并使用能够容纳最大可能值的最小类型。
这段代码还引入了for 循环,它允许你重复某段代码而无需重新输入。重复输入代码效率低下,浪费内存;for 循环则可以让你设置循环体内的代码重复执行的次数。在项目 3 的代码中有两个for循环。让我们来看第一个 ❸:
for (i = 0; i < 8; i++)
{
_delay_ms(TIME);
PORTB = 0b00000001 << i;
}
for循环会在特定条件为真时重复大括号中的代码。在这个例子中,循环会一直重复,直到变量i的值为 8。在循环的第一部分,我们通过i = 0设置i的初始值。循环的第二部分检查条件是否为真:在此情况下,检查i < 8。循环的第三部分跟踪代码循环的次数;i++表示“每次循环后将i的值加 1”。每次执行大括号内的代码时,i的值增加 1,并且代码会检查i是否小于 8。当i等于 8 时,循环停止,微控制器将继续执行for循环后的代码。
本项目引入的最终概念是位移,这是一种将二进制数中的位向左或向右移动的技术。它帮助你高效地使用二进制数字,在PORT x函数中控制输出引脚的开关。在❸处的for循环每次循环完成时将第一个位向左移一位。这比使用等效的八个PORTB函数更为高效:
PORTB = 0b00000001;
PORTB = 0b00000010;
PORTB = 0b00000100;
PORTB = 0b00001000;
PORTB = 0b00010000;
PORTB = 0b00100000;
PORTB = 0b01000000;
PORTB = 0b10000000;
与其浪费代码在这种函数上,不如使用<<将第一个位向左移动,或者使用>>将其向右移动,后面跟上需要移动的位数。例如,要依次开启和关闭 PORTB 中的前三个输出,可以输入:
PORTB = 0b00000001;
PORTB = 0b00000001 << 1; // equivalent to 0b00000010
PORTB = 0b00000001 << 2; // equivalent to 0b00000100
❸处的for循环演示了如何使用<< i将位向左移。这里,i在第一次循环时值为 0,表示第一个输出被开启。当代码再次循环时,i的值变为 1,表示第二个输出被开启,以此类推。以相同的方式,❺处的for循环使用>>从左到右依次开启 LED 灯。
你可以通过更深入地操作 PORT x 寄存器,利用 位运算 来更有效地控制输出。这是一种通过微控制器直接支持的位操作方式,用于操作数字的各个位。不要让你过去在数学课上的经历吓到你;这非常简单。你可以用来改变寄存器中位的四种操作符是:NOT、AND、OR 和 XOR。你将在 项目 4 到 7 中看到它们的使用方法。
项目 4:实验与 NOT 运算
NOT (~) 运算符会反转一个数字或寄存器中的所有位。如果你在数字前加上波浪符号(~),它将被视为该数字的二进制反码。例如:
~0b00001111 = 0b11110000
你可以通过将 项目 2 中的主循环替换为以下代码,来自己尝试一下:
for(;;)
{
PORTB = 0b00001111; // PORTB pins 3, 2, 1, and 0 turned on
_delay_ms(250);
PORTB = ~0b00001111; // PORTB pins 7, 6, 5, and 4 turned on
_delay_ms(250);
}
在 ~ 这一行之后,PORTB 上的所有引脚应该都点亮。NOT(以及所有其他位运算符)是你在规划项目时可以保留在工具箱中的有用工具。
项目 5:实验与 AND 运算
AND (&) 运算符比较两个二进制数,并返回一个新的二进制数。如果两个数字在相同位置上都有 1,新的数字在该位置会是 1,其他位将是 0。例如:
0b00100010 &
0b10101011
= 0b00100010
AND 运算在你希望根据某个特定值来控制输出时非常有用。项目 5 通过显示从 0 到 255 的二进制数字来演示这一点。使用 项目 3 中相同的电路,并打开位于 Chapter 2 文件夹中的 Project 5 子文件夹里的 main.c 文件:
// Project 5 - AND & Demonstration
#include <avr/io.h>
#include <util/delay.h>
#define TIME 5 // Delay in milliseconds
int main(void)
{
uint8_t i; // 8-bit integer variable "i"
DDRB = 0b11111111; // Set PORTB register as outputs
for(;;)
{
for (i = 0; i < 256; i++)
{
_delay_ms(TIME);
PORTB = 0b11111111 & i; // Displays value of i in binary using LEDs
}
}
return 0;
}
在这段代码中,for 循环从 0 计数到 255。每次代码循环时,它都会对 0b11111111 和变量 i 执行与运算,并将 PORTB 设置为运算结果。例如,假设 i 的值为 9,即 0b00001001。0b00001001 & 0b11111111 的结果将是 0b00001001,因为一位和八位的位匹配。因此,PORTB 的设置将是 0b00001001,并且 1 和 4 号 LED 会点亮。
项目 6:实验与 OR 运算
OR (|) 运算符比较两个二进制数,并返回另一个二进制数,在任何一个操作数的某个位为 1 的位置,结果也为 1。例如:
0b00100110 |
0b10101011
= 0b10101111
这个运算符在你希望根据两个数字中的位是否具有某个特定值来控制输出时非常有用。你可以使用 项目 3 中相同的电路,并打开位于 Chapter 2 文件夹中的 Project 6 子文件夹里的 main.c 文件,来试试这个方法:
// Project 6 - Experimenting with OR
#include <avr/io.h>
#include <util/delay.h>
#define TIME 50 // Delay in milliseconds
int main(void)
{
uint8_t i; // 8-bit integer variable "i"
DDRB = 0b11111111; // Set PORTB register as outputs
for(;;)
{
for (i = 0; i < 255; i++)
{
_delay_ms(TIME);
PORTB = 0b00001111 | i;
}
}
return 0;
}
主循环从 0 计数到 255,如前面的示例所示。每次代码循环时,它都会对 0b00001111 和变量 i 执行 OR 运算,并将 PORTB 设置为运算结果。如果 i 的值为 0,例如 0b00000000,则 0b00001111 | 0b00000000 的结果为 0b00001111。因此,PORTB 的设置将为 0b0001111,右侧的四个 LED 将保持亮起。
随着 i 的值增加,i 中的位数也会增加,更多的 LED 将会亮起。例如,当 i 的值为 128,或 0b10000000 时,结果的 PORTB 为 0b10001111。将此代码加载到你的 AVR 上以查看其实际效果,然后实验代码并创建你自己的 OR 情况来练习。
项目 7:实验 XOR
最后的运算符 XOR (^) 比较两个数字中相同位置的位,并返回一个新的二进制数。任何两个数字在相同位置上有不同的位,新的数字将在该位置上为 1;如果两个数字在同一位置上具有相同的位,新的数字将在该位置上为 0。例如:
0b00100110 ^
0b10101011
= 0b10001101
XOR 运算符在你希望当两个数字的位值不同时时打开输出时非常有用。要查看这一点,可以使用与项目 3 相同的电路,并打开位于 Chapter 2 文件夹下 Project 7 子文件夹中的 main.c 文件:
// Project 7 - Experimenting with XOR
#include <avr/io.h>
#include <util/delay.h>
#define TIME 250 // Delay in milliseconds
int main(void)
{
uint8_t i; // 8-bit integer variable "i"
DDRB = 0b11111111; // Set PORTB register as outputs
for(;;)
{
for (i = 0; i < 255; i++)
{
_delay_ms(TIME);
PORTB = 0b11111111 ^ i;
}
}
return 0;
}
代码的主循环再次从 0 计数到 255。每次循环时,代码都会对 0b11111111 和变量 i 进行 XOR 运算,并将 PORTB 设置为运算结果。例如,如果 i 的值为 15,或 0b00001111,则 0b11111111 ^ 0b00001111 的结果为 0b11110000。
当你在 AVR 上运行此代码时,它应展示从 0 到 255 的二进制计数。然而,在这种情况下,LED 会以反向的方式点亮——也就是说,数字通过 LED 显示时是关闭的,而不是打开的。
本章结束时,我鼓励你实验代码示例。享受创造图案、学习位运算和使用位移的过程,利用你新获得的知识。在下一章中,你将学习如何使用微控制器的输入来创建互动设备。
第三章:# 获取和显示输入

AVR 微控制器可以处理来自外部世界的输入并作出反应,这为互动项目提供了巨大的潜力——例如,能够响应周围温度的项目。在本章中,你将编程 ATtiny85 和 ATmega328P-PU,检测外部设备的输入信号并加以应用。
特别是,你将:
-
• 学习数字输入和按钮的使用。
-
• 使用
if...else和switch...case语句做决策。 -
• 使用七段 LED 显示器制作数字计数器。
-
• 制作一个单节电池测试仪。
-
• 了解 TMP36 温度传感器。
-
• 制作一个数字温度计。
在这个过程中,你将学习如何使用电阻来对抗开关弹跳,并通过创建自己的函数以及使用浮点变量和模拟输入,获得更多的 C 语言编程经验。
数字输入
在第二章中,你学会了使用数字 I/O 引脚作为输出。你也可以使用这些引脚接收来自用户和其他组件的输入。和数字输出一样,数字输入也有两种状态:它们的状态分别是高和低,而不是开或关。使用数字 I/O 引脚作为输入与控制输出类似。在这一节中,你将设置 DDR x,然后监控另一个寄存器的值,称为PINx,它存储数字输入引脚的状态。让我们开始吧!
介绍按钮
最简单的数字输入形式之一是推按钮,如图 3-1 所示。推按钮很容易插入到无焊面包板中。当按下时,它们允许电流通过,微控制器可以通过数字输入检测到电流。

图 3-1:面包板上的简单按键
你将在下一个项目中使用推按钮,因此请注意图 3-1 底部的按钮是如何插入到面包板上的。它的引脚跨越了 23 行和 25 行,这样当你按下按钮时,会在这两行之间建立电连接。图 3-2 展示了这种类型的按键的原理图符号。

图 3-2:推按钮原理图符号
标记为 1 和 2 的线路代表按键的一侧脚,标记为 3 和 4 的线路代表另一侧的脚。当你将电路图符号与 图 3-1 中的实际按键进行比较时,标记为 1 和 2 的脚在第 23 行,标记为 3 和 4 的脚在第 25 行。虚线表示按键内部的开关。当你按下按钮时,开关闭合,允许电流流动,因此你不需要在按钮的两侧各接一根线。
读取数字输入引脚的状态
一旦你通过将值赋给 DDRB 设置 I/O 引脚为输入,该输入引脚的状态将存储在 PIN x 寄存器中。像其他寄存器一样,PIN x 寄存器是 8 位宽的,每一位对应一个物理 I/O 引脚。
将 PIN x 寄存器视为一个二进制数,其中每个位代表对应物理引脚的状态。如果某位为 1,说明该引脚有电流,通过该引脚时为 高电*;如果某位为 0,说明该引脚没有电流,通过时为 低电*。例如,在所有 I/O 引脚都设置为输入的 ATtiny85 上,如果引脚 5(PB0)和 6(PB1)为高电*,那么 PINB 寄存器的值将是 0b00000011。
PIN x 寄存器的数值被赋给一个整数变量,然后与另一个数字进行比较。例如,要检查输入引脚 PB0 和 PB1 是否为高电*,你可以将 PINB 的值与 0b00000011 进行比较。
但够了理论——让我们来制作一些简单的电路,展示微控制器的输入和输出功能!
项目 8:按命令闪烁 LED
在这个项目中,你将通过使 LED 闪烁来实验 ATtiny85 的数字输入,就像你在 第二章 中做的那样。不过这次,只有在你按下按键时,LED 才会闪烁。
硬件
你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• ATtiny85–20PU 微控制器
-
• 一个 LED
-
• 一个 560 Ω 电阻
-
• 一个按键
-
• 一个 10 kΩ 电阻
-
• 跳线
在面包板上组装 图 3-3 中显示的电路。实验完成后,将电路保留在一起;你将在 项目 9、项目 10 和 项目 11 中继续使用这个电路。

图 3-3: 项目 8 的主要电路
将电路组装好后,将 USBasp 编程器连接到 ATtiny85。按照 第 2-1 表 中的连接方式与 第二章 中的说明重复连接。
代码
打开一个终端窗口,导航到本书第三章文件夹下的第 8 项目子文件夹,然后输入命令make flash。工具链将编译程序文件,并将数据上传到微控制器。此时,电路不会执行任何操作,直到你按下按钮。当你按下时,LED 应保持亮起约一秒钟。
让我们看看这个是如何工作的。打开第 8 项目的main.c文件:
// Project 8 - Blinking an LED on Command
#include <avr/io.h>
#include <util/delay.h>
int main(void)
{
❶ DDRB = 0b00001111;
❷ PORTB = 0b00000000;
for (;;)
{
❸ if (PINB == 0b00010000) // If PB4 is HIGH . . .
❹ {
PORTB = 0b00000001; // then turn on PB0 output
_delay_ms(1000); // Wait a moment
PORTB = 0b00000000; // Turn off PB0 output
}
}
return 0;
}
这段代码告诉微控制器不断检查引脚 3(PB4)是否为高电*。如果是,我们会将连接到引脚 5(PB0)的 LED 点亮约一秒钟。
首先,我们设置 DDRB 寄存器,使得 PB0 到 PB3 为输出,PB4 到 PB7 为输入❶。虽然 ATtiny85 总共有八个引脚,并且最多有六个引脚用于输入和输出,但我们仍然在DDRB语句中包含所有八个引脚。接下来,我们关闭内部上拉电阻❷。(稍后我会在本章中详细讲解上拉电阻。)
之后,程序将 PINB 寄存器的值与0b00010000进行比较❸。如果 PB4(引脚 3)上有电流,第四位将为 1,因此 PINB 寄存器将与0b00010000匹配。如果比较结果为相等,花括号中的代码将运行❹,使 LED 亮起一秒钟,然后熄灭。
在下一部分中,我们将详细了解像本项目中的if语句,这些语句用于进行比较和决策。
代码中的决策
有时你希望某些代码仅在条件为真或为假时才执行,比如按钮是否被按下。你可以使用if语句、if...else语句和switch...case语句来测试这些条件,并决定接下来要执行的代码。
if 语句
类似于第 8 项目中的if语句的第一行测试一个条件。如果条件为真(在第 8 项目中,如果PINB的值与0b00010000相匹配),那么花括号中的代码将执行。如果条件为假,则花括号中的代码将被忽略。
要测试条件,你将在if语句中使用以下一个或多个比较运算符:
-
• 等于:
== -
• 不等于:
!= -
• 大于:
> -
• 小于:
< -
• 大于或等于:
>= -
• 小于或等于:
<=
随着时间的推移,你将更频繁地使用比较运算符,它们会变得像第二天性一样。
警告:一个常见的错误是,在测试语句中使用单个等号(=),它表示“设为相等”,而不是使用双等号(==),它表示“测试是否相等”。你可能不会收到工具链的错误消息,但你的代码可能无法正常工作!
你还可以进行各种类型的比较,其中有两个或更多选项供你选择,这可以节省代码空间。以下各节会解释这些情况。
if ... else 语句
你可以通过else为if语句添加另一个动作。例如,你可以将项目 8 中的代码重写如下:
if (PINB == 0b00010000) // If PB4 is high . . .
{
PORTB = 0b00000001; // turn on PB0 output
} else
{
PORTB = 0b00000000; // turn off PB0 output
}
通过此修改,当你按下按钮时,LED 会亮起,若不按下按钮,LED 则熄灭。
进行两项或更多比较
你还可以在同一个if语句中使用两个或更多条件,通过比较运算符来实现。例如,要将整数变量counter的值与 23 和 42 之间的范围进行比较,可以使用两个条件并通过 AND 运算符&&将其连接起来:
if (counter>=23 && counter <42) // If counter is between 23 and 42 . . .
{
PORTB = 0b00000001; // turn on PB0 output
} else
{
PORTB = 0b00000000; // turn off PB0 output
}
请注意,用于比较的 AND 运算符(&&)与在第二章中介绍的按位算术 AND 运算符(&)不同。
你也可以使用 OR 比较。例如,如果你需要测试counter变量的值是否小于 100 或大于 115,可以使用两个条件并通过||运算符将其连接起来:
if (counter<100 || counter >115) // If counter is under 100 or over 115 . . .
{
PORTB = 0b00000001; // turn on PB0 output
} else
{
PORTB = 0b00000000; // turn off PB0 output
}
你将在后续项目中扩展对这些有用运算符的理解。
switch ... case 语句
要比较两个或多个变量,使用switch...case语句比使用多个if...else语句更为简便。switch...case语句会在某个已定义的条件为真时执行代码。
例如,假设你希望根据整数变量counter的值分别执行不同的代码,如 1、2 或 3。你可以使用一个switch...case语句,而不是多个if...else语句:
switch(counter)
{
case 1: // Do something if the value of counter is 1
break; // Finish and exit the switch statement
case 2: // Do something if the value of counter is 2
break; // Finish and exit the switch statement
case 3: // Do something if the value of counter is 3
break; // Finish and exit the switch statement
default: // Do something if counter isn't 1, 2, or 3
// (the "default" section is optional)
}
这段代码末尾的可选default部分让你在switch...case语句中没有任何条件为真的情况下执行代码。
创建你自己的函数
迟早,你会希望多次重复某些代码段或定义自己的指令集。你可以通过创建自己的函数来实现这两个目标,函数可以处理任务、接收变量并对其进行操作,或者像数学函数一样返回一个结果值。我们将在接下来的三个项目中讨论这三种类型的函数。
第一种类型的函数仅仅是重复一些代码:
void
`name()`
{
// Insert your code to run here.
}
在这个例子中,name()是一个占位符。你可以将你的函数命名为任何你喜欢的名称,但名称必须始终以void为前缀。此外,你不能在自己的创建中使用保留字,因为这些词已经在语言中被使用。例如,你不能将函数命名为void void(),因为void在 C、C++等语言中是保留字。你可以在en.cppreference.com/w/c/keyword找到 C 语言的完整保留字列表。
函数的代码放在花括号内。始终将自定义函数放在int main(void)部分之前。
项目 9:一个简单的自定义函数
这个项目演示了创建一个执行任务的简单自定义函数。使用项目 8 的硬件,打开终端窗口,导航到本书第三章文件夹中的项目 9子文件夹,并输入命令make flash,按照常规上传项目 9 的代码。你应该会看到 LED 每五秒闪烁两次。
让我们来看一下代码:
// Project 9 - A Simple Custom Function
#include <avr/io.h>
#include <util/delay.h>
❶ void blinkTwice()
{
PORTB = 0b11111111;
_delay_ms(100);
PORTB = 0b00000000;
_delay_ms(100);
PORTB = 0b11111111;
_delay_ms(100);
PORTB = 0b00000000;
_delay_ms(100);
}
int main(void)
{
// Set PB3 (and all other pins on PORTB) as output
DDRB = 0b11111111;
for(;;)
{
❷ blinkTwice();
_delay_ms(5000);
}
return 0;
}
自定义函数blinkTwice() ❶使 LED 闪烁两次,因为它会将整个 PORTB 寄存器开关两次,并且有短暂的延时。一旦你创建了这样的函数,你可以在代码的任何地方调用它 ❷。
如果你希望能够轻松地改变 LED 闪烁的次数,第二种自定义函数——可以传递值的函数——就派上用场了:
`void name`
(
`type variable`
,
`type variable2`
, . . .)
{
// Insert your code to run here.
}
再次,你会给你的函数命名,但这次括号里会包含两个参数,它们将在函数名称后的代码中使用:type 和 variable,分别指定传递给函数的变量的类型和名称。
项目 10:带内部变量的自定义函数
这个项目演示了创建接受变量作为参数并对这些变量执行操作的自定义函数。使用项目 8 的相同硬件,打开终端窗口,导航到本书第三章文件夹中的项目 10子文件夹,并输入命令make flash,上传项目 10 的代码。你应该会看到 LED 闪烁 11 次。
让我们来看一下这个项目的main.c文件中的代码:
// Project 10 - Custom Functions with Internal Variables
#include <avr/io.h>
#include <util/delay.h>
void delay_ms(int ms)
{
uint8_t i;
for (i = 0; i < ms; i++)
{
_delay_ms(1);
}
}
❶ void blinkLED(uint8_t blinks)
{
uint8_t i;
for (i = 0; i < blinks; i++)
{
PORTB = 0b11111111;
delay_ms(100);
PORTB = 0b00000000;
delay_ms(100);
}
}
int main(void)
{
DDRB = 0b11111111; // Set PB3 as output
for(;;)
{
❷ blinkLED(10);
_delay_ms(3000);
}
return 0;
}
函数blinkLED(uint8_t blinks) ❶接受一个无符号整数,并在for循环中使用它来使 LED 闪烁指定的次数。现在,你可以在代码的任何地方用不同的值调用blinkLED()函数。例如,在这个项目的代码中,我们调用blinkLED(10) ❷使 LED 闪烁 11 次。
要将多个变量传递到自定义函数中,只需在函数名后的括号内,每个变量之间加上逗号。例如,这里我为blinkLED()函数添加了第二个参数blinkDelay,它允许你设置 LED 开关之间的延迟时间。这个参数接着被传递到delay_ms()函数中:
void blinkLED(uint8_t blinks, uint8_t blinkDelay)
{
uint8_t i;
for (i = 0; i < blinks; i++)
{
PORTB = 0b11111111;
delay_ms(blinkDelay);
PORTB = 0b00000000;
delay_ms(blinkDelay);
}
第 10 项工程的代码实际上定义了两个自定义函数:第一个是delay_ms(int ms)。有时你可能需要在代码中使用延时函数,并且希望通过变量来指定延时的长度。使用标准的_delay_ms()函数无法做到这一点,所以你可以创建自己的延时函数!
第 11 项工程:返回值的自定义函数
在本项目中,我将演示第三种自定义函数的创建方式:接受一个或多个变量,在数学运算中使用它们,然后返回结果。
返回操作结果的函数非常有用。可以把它们看作是数学公式的黑盒:数值从一端输入,进行运算,结果从另一端输出。你可以按如下方式创建这样的函数:
`type variable`
(
`type variable`
,
`type variable 2`
, . . .)
{
// Declare a variable to hold the results of the calculations.
// Insert your code to run here.
// Return the declared variable.
}
请注意,你声明的变量必须与函数返回的类型相同。
让我们将其付诸实践。使用第 8 项工程中的相同硬件,在终端窗口中导航到本书第三章文件夹的第 11 项工程子文件夹,输入命令make flash来上传第 11 项工程的代码。你应该会看到 LED 每 2 秒闪烁 12 次。
要了解其工作原理,请打开本项目的main.c文件:
// Project 11 - Custom Functions That Return Values
#include <avr/io.h>
#include <util/delay.h>
void blinkLED(uint8_t blinks)
{
uint8_t i;
for (i = 0; i < blinks; i++)
{
PORTB = 0b11111111;
_delay_ms(100);
PORTB = 0b00000000;
_delay_ms(100);
}
}
❶ uint8_t timesThree(uint8_t subject)
{
uint8_t product;
❷ product = subject * 3;
❸ return product;
}
int main(void)
{
DDRB = 0b11111111; // Set PB3 as output
uint8_t j;
uint8_t k;
for(;;)
{
j = 4;
k = timesThree(j);
blinkLED(k);
_delay_ms(2000);
}
return 0;
}
我们仍在闪烁 LED(这是最后一次了,我保证!),但这次我们调用blinkLED,并将k设置为timesThree()函数的返回值。timesThree()函数将一个整数乘以三。首先我们声明该函数将返回的变量类型——在此为整数(uint8_t) ❶。接着是函数名称timesThree()和一个将保存传入函数的数字的变量。在函数内部,我们声明另一个变量来保存乘法操作的结果 ❷。然后,我们调用return将结果传回代码 ❸。
要在程序的其他部分实际使用timesThree(),只需传递一个参数并将变量设置为其返回值:
k = timesThree(j);
随着你在微控制器方面经验的积累,你会发现创建自己的函数可以节省大量时间。不过现在,让我们讨论一下使用按钮时可能出现的问题,以及如何解决这些问题,以便创建更可靠的项目。
开关抖动
按钮(你在第 8 项工程中首次接触到)容易出现开关抖动(也称为开关弹跳),即按钮在用户仅按下一次后,可能会多次开关。开关抖动是因为按键内部的金属接触点非常小,释放按钮后可能会发生震动,导致开关快速反复地打开和关闭,直到震动停止。
可以使用数字存储示波器(DSO)观察开关反弹,DSO 是一种绘制电压随时间变化的设备。图 3-4 显示了 DSO 在开关反弹期间测量的按钮电压。

图 3-4:测量开关反弹
图 3-4 中的显示器上半部分显示了按钮按下几次后的结果。当图像下半部分的电压线位于较高的水*位置(5 V)时,按钮处于打开状态。
在右上角的"停止"字样下方,两条垂直线突出显示了一段时间。此时间段内按钮的电压被放大显示在屏幕的下半部分。在 A 处,用户释放按钮,电压线降至 0 V。然而,由于物理振动,电压立即跃升至 5 V,直到 B 处,电压发生振荡后才重新归于低电*(关闭状态)直至 C 处。实际上,我们并没有向微控制器传递一次按钮按下,而是不经意地传递了三次。
你无法阻止开关反弹,但你可以防止程序对其做出反应:只需使用_delay_ms()函数,在检测到按钮按下后,强制程序在执行更多代码之前等待。大约 50 毫秒应该足够长,但请用你自己的硬件测试,找到满足你需求的确切时间长度。
保护你的 AVR 免受电压波动影响
在理想的情况下,数字输入引脚要么接收到 5 V 的电信号(高电*),要么没有电信号(低电*)。但实际上,开关反弹和其他缺陷可能会导致输入引脚的电压在 5 V 和 0 V 之间剧烈波动。
在程序中加入延迟有助于防止因开关反弹导致的软件故障,但即使在没有使用引脚时,这些波动也可能发生,这可能会混淆或甚至损坏你的 AVR。幸运的是,你可以通过上拉或下拉电阻来保护 AVR。
上拉电阻
上拉电阻,如图 3-5 所示,可以将数字输入引脚的电压保持尽可能接*高电*。

图 3-5:上拉电阻的示例应用
微控制器输入引脚 3 的电压将始终连接到高电*信号,直到按钮被按下,此时引脚 3 将直接连接到 GND 并变为低电*。电阻器防止你在按下按钮时将 5 V 短路到 GND(这可能会损坏硬件)。
AVR 微控制器有内置的上拉电阻。这是一种减少电路体积的巧妙方式,但问题是,你需要在程序中反转逻辑。例如,如果你有一个按钮连接在数字输入引脚和地(GND)之间,并且内置上拉电阻已启用,那么按下按钮时,输入信号会变为低电*(而不是高电*)。这是为方便而付出的一个小代价。要为设置为输入的引脚启用内置上拉电阻,可以将 1 写入 PORT x中的相应位;要关闭内置上拉电阻,可以将 0 写入这些位。
下拉电阻
一个下拉电阻,如图 3-6 所示,可以将数字输入引脚的电压保持在尽可能低的状态。

图 3-6:下拉电阻的示例使用
微控制器引脚 3 的电压在按钮未按下时保持低电*。当按钮被按下时,引脚 3 直接连接到 5V(高电*)。再次提醒,我们使用电阻来避免当按钮按下时,5V 与 GND 之间发生短路。
介绍七段式 LED 显示
使微控制器响应输入的一种方式是通过显示一个数字。为此,你可以向工具箱中添加一个新组件:七段式 LED 显示器,如图 3-7 所示。

图 3-7:七段式 LED 显示模块
这些小塑料积木包含八个 LED,排列成一个熟悉的数字显示,带有小数点。你可以在各种家用电器中找到它们,它们非常适合显示数字、字母或符号。
七段显示器有多种尺寸和颜色可供选择,电气上,它们与八个独立的 LED 相同,但有一个注意点:为了减少显示器使用的引脚数,所有 LED 的阳极或阴极都被连接在一起。它们分别称为共阳极和共阴极配置。本书中所有的七段显示都使用共阴极模块。该示例显示器的原理符号如图 3-8 所示。

图 3-8:七段式共阴极 LED 显示模块的原理符号
每个 LED 的阴极旁边是其对应的段。显示器的 LED 从 A 到 G 以及 DP(小数点)标记,每个 LED 段有一个阳极引脚,阴极则连接到一个或两个共阴极引脚。
七段数码管的引脚布局因制造商而异,因此在购买时,请确保供应商提供数据表,显示每个段的阳极引脚和阴极引脚。如果有疑问,注意大多数常见型号的引脚 1 位于显示器的左下角,其余引脚按逆时针方向编号。记住,它们仍然是独立的 LED,并且你仍然需要为每个 LED 配置一个限流电阻。
项目 12:构建一个单数字数字计数器
让我们通过制作一个互动设备来巩固你到目前为止所学的内容,我希望它能激发你的想象力:一个单数字数字计数器。你的计数器将有两个按钮(一个用来增加计数,另一个用来将计数器重置为零)和一个七段显示器。
硬件
你需要以下硬件:
-
• USBasp 编程器
-
• 无焊接面包板
-
• ATmega328P-PU 微控制器
-
• 一个共阴极七段数码管显示器
-
• 七个 560 Ω电阻(R1–R7)
-
• 两个按键
-
• 两个 10 kΩ电阻(R8,R9)
-
• 跳线
按照图 3-9 中所示组装电路。

图 3-9: 项目 12 的主要电路
到现在为止,你应该已经记得在开始之前连接 USBasp 并将其接入电路,因此在这个项目之后我就不再提醒你了。
代码
打开一个终端窗口,导航到本书第三章文件夹下的项目 12子文件夹,并输入命令make flash。一旦你将代码上传到微控制器,按下连接到 PD0 的按钮以增加计数,按下另一个按钮将计数重置为零。
要查看它是如何工作的,打开项目 12 的main.c文件:
// Project 12 - Building a Single-Digit Numerical Counter
#include <avr/io.h>
#include <util/delay.h>
#define TIME 150
❶ void displayNumber(uint8_t value)
// Displays numbers 0–9 on a seven-segment LED display
{
switch(value)
{
case 0 : PORTB = 0b00111111; break; // 0
case 1 : PORTB = 0b00000110; break; // 1
case 2 : PORTB = 0b01011011; break; // 2
case 3 : PORTB = 0b01001111; break; // 3
case 4 : PORTB = 0b01100110; break; // 4
case 5 : PORTB = 0b01101101; break; // 5
case 6 : PORTB = 0b01111101; break; // 6
case 7 : PORTB = 0b00000111; break; // 7
case 8 : PORTB = 0b01111111; break; // 8
case 9 : PORTB = 0b01101111; break; // 9
}
}
int main(void)
{
❷ uint8_t i = 0; // Counter value
❸ DDRB = 0b11111111; // Set PORTB to outputs
DDRD = 0b00000000; // Set PORTD to inputs
❹ PORTD = 0b11111100; // Turn off internal pullups for PD0 and PD1
for(;;)
{
❺ displayNumber(i); // Display count
_delay_ms(TIME);
❻ if (PIND == 0b11111110) // If reset button pressed . . .
{
i = 0; // set counter to zero
}
❼ if (PIND == 0b11111101) // If count button pressed . . .
{
i++; // increase counter
if (i > 9) // If counter is greater than 9 . . .
{
i = 0; // set counter to zero
}
}
}
return 0;
}
这段代码定义了一个新函数displayNumber(),该函数接收一个整数并设置 PORTB 的输出,以便打开或关闭 LED 显示器的各个段,从而显示从 0 到 9 的数字❶。通过使用switch...case语句,代码可以根据你想要显示的数字整洁地决定运行哪个PORTB命令。
变量i用于跟踪你想要显示的数字❷。这个变量初始化为零,这样当电源打开时,计数器就会从零开始。
接下来,我们设置 I/O 引脚,使用 PORTB 作为 LED 的输出,PORTD 作为输入,用来检测按钮是否被按下❸。由于按钮连接到 PD0 和 PD1 引脚,因此关闭这两个引脚的内部上拉电阻❹,而打开 PORTD 其余引脚的上拉电阻。这确保了 PORTD 中未使用的引脚始终为高电*。
每次代码循环时,首先显示计数的值 ❺,然后检查重置或计数按钮是否已按下。请注意,当代码将PIND与表示 PD0 ❻或 PD1 ❼上按钮按下的二进制值进行比较时,比较中未使用的位(或输入)为 1,而不是 0。这是因为未使用的输入已激活了内部上拉电阻,使其保持为 1。
当您按下计数按钮时,计数变量i应增加一。如果计数大于 9,则应将其重置为 0,因为您正在使用单位数显示器。
完成这个项目后,您将拥有一个精巧的显示器—但请记住,实践是学习的途径。尝试使用这个程序进行实验!例如,尝试将其改为倒计时器或者通过 LED 创建不同的模式而不是数字。
如果可能,请保持此项目组装完好,因为您将在项目 14 中重新使用大部分内容。
模拟输入
到目前为止,您的项目使用了只有两个电*的数字电信号:高电*和低电*。对于您的微控制器而言,高电*接* 5V,低电*接* 0V(或 GND)。我们使用 PORT x寄存器来闪烁 LED,并使用 PIN x寄存器来检测数字输入是高还是低。图 3-10 展示了用 DSO 测量的数字信号。

图 3-10:数字信号,高电*显示为顶部的水*线,低电*显示为底部
与数字信号不同,模拟信号 可以在高低之间的步骤数不定的情况下变化。例如,图 3-11 显示了一个类似正弦波的模拟电压信号。

图 3-11:正弦模拟信号
注意随着时间的推移,电压在高低电*之间流动。模拟信号可以表示各种设备的各种信息,例如温度或距离传感器。要在项目中使用模拟信号,您需要使用微控制器中连接到特定 I/O 引脚的电压值来测量信号,这些引脚连接到模拟-数字转换器(ADCs) 。ADC 可以将电压转换为数字,然后您可以在代码中使用。参考第二章中的引脚图和端口寄存器图示;模拟输入标记为 ADC x代表模拟-数字转换器 x。
在 ATtiny85 上,您有 PB0、PB1、PB2 和 PB5(物理引脚 5–7 和 1)。在 ATmega328P-PU 上,您有一个全新的寄存器(PORTC),它具有从 PC0 到 PC5 的六个 ADC。
使用 ATtiny85 ADCs
要设置 ATtiny85 上的 ADC 引脚,你需要设置两个新的寄存器。(是的,又有更多的寄存器!你越用就越习惯它们。)第一个寄存器,ADMUX,选择你将连接到 ADC 的引脚。你将前六个位设置为001000,并使用最后两个比特来选择 ADC 的物理引脚。对于 ADC0(引脚 1)是00,对于 ADC1(引脚 7)是01,对于 ADC2(引脚 3)是10,对于 ADC3(引脚 2)是11。例如,要使用物理引脚 3 作为模拟输入,设置 ADMUX 如下:
ADMUX = 0b00100010;
另一个需要设置的寄存器是 ADCSRA,它负责多个设置,包括 ADC 的速度。本书中的所有 ATtiny85 项目都将速度设置为 1 MHz。由于微控制器的 ADC 部分运行速度不同,你需要使用 ADCSRA 设置一个预分频值来决定 ADC 的速度。你通常会使用一个预分频器值为 8,这将 ADC 的速度降到 125 kHz(我们通过将 1 MHz 的速度除以 8 来计算)。相应的 ADCSRA 行是:
ADCSRA = 0b10000011;
将这两个寄存器设置放入一个自定义函数中是个好主意,你可以将其命名为startADC(),例如。这样可以保持代码的整洁,并且在需要时可以轻松调用该函数。
使用 ADC 时,使用位操作来寻址寄存器中的位可能会很有帮助。现在我会保持简单,只给你展示它是如何工作的;我们将在第六章中更详细地回顾这个话题(以及预分频器的使用)。
要从你预设的 ADC 引脚读取值,首先使用以下代码行启动 ADC:
ADCSRA |= (1 << ADSC);
这将 ADCSRA 寄存器中的 ADSC 位设置为 1,告诉你的 ATtiny85 读取模拟输入并将其转换为值。当 ATtiny85 完成时,ADSC 位将返回 0。你需要告诉你的代码稍等片刻,直到这个过程完成,像这样:
while (ADCSRA & (1 << ADSC));
这会将 ADCSRA 寄存器中的 ADSC 位与 1 进行比较,如果两者都是 1,则不执行任何操作。当 ADC 过程完成时,ADSC 位会返回到 0,while() 函数结束,微控制器继续执行。
最终,ADC 的值存储在变量ADCH中。ADCH的值应在 0 到 255 之间。然后,你可以根据需要操作ADCH。
项目 13:制作一个单电池电池测试仪
在这个项目中,你将通过制作一个简单的电池测试仪来使用 ATtiny85 的 ADC。你可以用它来检查 AA、AAA、C 或 D 电池的电压。
警告:不要将电压大于 5 V 的电池(或其他电源)连接到测试仪,也不要将电池接反(请查看图 3-12 中的原理图)。这样做会损坏你的 ATtiny85。
该测试仪使用两个 LED 来指示电池是否良好(电压大于或等于 1.4 V)或电池是否坏(电压小于 1.4 V)。
硬件
你需要以下硬件:
-
• USBasp 编程器
-
• 无焊接面包板
-
• ATtiny85–20PU 微控制器
-
• 两个 LED
-
• 一个 560 Ω电阻
-
• 跳线
按照图 3-12 所示组装电路。请注意,标有正极(+)和负极(−)的两根电线是跳线,用于与要测试的电池接触。将+和−电线连接到电池的相应接点。

图 3-12: 项目 13 的电路图
你可能会发现,使用一些绝缘胶带帮助保持电线与被测试电池连接会有所帮助。你还可以使用红色和绿色 LED 指示灯来显示被测试电池是“坏的”还是“好的”。
代码
打开终端窗口,导航到本书的第三章文件夹下的项目 13子文件夹,并输入命令make flash。上传代码后,找一个 AA、AAA、C 或 D 型电池,将正负极引线按电路图连接到电路。如果电压大于或等于 1.4 V,LED2 应该亮起;如果小于 1.4 V,LED1 应该亮起。
要查看如何实现这一点,打开项目 13 的main.c文件:
// Project 13 - Making a Single-Cell Battery Tester
#include <avr/io.h>
#include <util/delay.h>
❶ void startADC()
// Set up the ADC
{
ADMUX = 0b00100010; // Set ADC pin to 3
ADCSRA = 0b10000011; // Set prescaler speed for 1 MHz
}
int main(void)
{
DDRB = 0b00000011; // Set pins 5 and 6 as outputs
❷ startADC();
for(;;)
{
❸ ADCSRA |= (1 << ADSC); // Start ADC measurement
while (ADCSRA & (1 << ADSC) ); // Wait until conversion completes
_delay_ms(5);
❹ if (ADCH >= 71)
{
// If ADC input voltage is more than or equal to ~1.4 V . . .
PORTB = 0b00000010; // Turn on "battery OK" LED2
❺ } else if (ADCH < 71)
{
// Else, if ADC input voltage is less than ~1.4 V . . .
PORTB = 0b00000001; // Turn on "battery not OK" LED1
}
}
return 0;
}函数startADC()将物理引脚 3 设置为使用其 ADC 功能,并设置预分频器以支持 1 MHz 操作❶。在使用 ADC 之前,我们需要调用这个函数❷。然后我们激活 ADC 进行读取,并等待其完成❸。
来自 ADC 的值——一个介于 0 到 255 之间的数字——存储在变量ADCH中。这个值映射到 ADC 的电压范围,范围是 0 到 5 V。你可以通过一些基本的数学计算找到 ADC 值:
(映射电压 × 256) / 电源电压 = ADC 值
对于我们的示例,我们计算 1.4 V 的匹配 ADC 值如下:
(1.4 V × 256 / 5 V) = 71.68
基于此计算,1.4 V 映射到 ADC 值为 71.4,在我们的代码中我们将其四舍五入为 71,因为在这个项目中我们使用的是整数。这个值用于if语句中,用来判断电池是否适合使用❹或者不适合使用❺。
到此为止,你应该已经理解如何读取外部设备中以变化电压形式呈现的模拟信号。这非常有用,因为许多传感器的值是以变化的电压形式返回的,因此可以通过微控制器的 ADC 引脚轻松读取。
接下来,让我们看看 ATmega328P-PU 上的 ADC,以及一些关于变量类型的更多信息。
使用 ATmega328P-PU 的 ADC
在 ATmega328P-PU 上设置 ADC 引脚与在 ATtiny85 上设置的类似。你将首先设置一些寄存器。第一个是 ADMUX,它有两个功能:指示你想使用哪个 ADC 引脚,并选择 ADC 与正在测量的模拟信号进行比较的参考电压源。
首先,你需要将 ADMUX 寄存器中的 REFS0 位设置为 1,这样就告诉微控制器使用连接到 AV [CC](引脚 20)的电压与模拟信号进行比较。同样,你可以使用位操作来完成这个操作:
ADMUX |= (1 << REFS0);
这是设置寄存器中单个比特的更简便且不易出错的方法,因为它让你避免一次处理所有八个比特——你只需要设置你想要更改的比特。还要记住,你只需要将比特设置为 1,因为默认情况下它们都是 0。
接下来,你需要将 MUX2 和 MUX0 位设置为 1,这样就告诉 ADC 从引脚 28 读取信号:
ADMUX |= (1 << MUX2) | (1 << MUX0);
你将设置的第二个寄存器,ADCSRA,激活 ADC 并设置微控制器中 ADC 的速度。从这里到 第十三章 的所有 ATmega328P-PU 项目都将使用 1 MHz 的速度,对应的 ADCSRA 设置行是:
ADCSRA |= (1 << ADPS1) | (1 << ADPS0);
最后,你需要通过将 ADEN 位设置为 1 来激活 ADC:
ADCSRA |= (1 << ADEN);
如在 项目 12 中所示,最好将寄存器设置放入自己的自定义函数中,我将其命名为 startADC()。
当你想从预设的 ADC 引脚测量一个值时,首先需要按如下方式启动 ADC:
ADCSRA |= (1 << ADSC);
这将 ADCSRA 寄存器中的 ADSC 位设置为 1,这告诉 ATmega328P-PU 读取模拟输入并将其转换为数值。这不是即时的;你的代码需要等待直到 ATmega328P-PU 完成 ADC 读取,此时 ADSC 位返回为 0。以下是一个方便的节省空间的函数,可以用来监视寄存器中位的变化:
loop_until_bit_is_clear(ADCSRA, ADSC);
在这种情况下,它强制代码等待直到 ADSC 位返回为 0;当这种情况发生时,ADC 过程完成,代码可以继续执行。
来自 ADC 的值是一个 10 位数,工具链将其提供在一个虚拟寄存器变量中,正如你猜到的,名为 ADC。然而,对于那些精度要求不高的用途,你可以只使用一个 8 位值,将 ADC 寄存器的最后 2 位丢弃,如下所示:
ADCvalue = ADC;
其中,ADCvalue 是一个整数变量,用于保存来自 ADC 的值。
最后,在使用相同的电源来驱动微控制器及其内部 ADC 时,最好在正负电源线之间使用一个小的*滑电容,如 以下项目 所示。
介绍可调电阻
可调电阻,也称为电位器,通常可以从 0 Ω 调节到其额定值。图 3-13 显示了它们的电路符号。

图 3-13:可调电阻(电位器)符号
可变电阻有三个电连接点,一个在中间,另两个分别在两侧。随着可变电阻轴的旋转,它会增加中间与一侧之间的电阻,同时减少中间与另一侧之间的电阻。
可变电阻可以是线性的或对数的。线性型电位器的电阻在旋转时以恒定的速率变化,而对数型电位器的电阻一开始变化缓慢,随后迅速增加。对数型电位器常用于音频放大电路中,因为它们模拟了人类的听觉反应。你通常可以通过电位器背面的标记识别它是线性型还是对数型。大多数电位器会在电阻值旁标有 A 或 B:A 表示对数型,B 表示线性型。大多数项目使用的是线性可变电阻,如图 3-14 所示。

图 3-14:典型的线性可变电阻
微型可变电阻被称为微调电位器或修整器(见图 3-15)。由于其体积小,微调电位器在电路中调整时非常有用,而且它们也非常适合面包板工作,因为它们可以插入到面包板中。

图 3-15:各种微调电位器
购买微调电位器时,请注意类型。如果可能,选择一种你手头的螺丝刀容易调节的类型。图中封闭式的微调电位器(见图 3-15)更为优选,因为它们比便宜的开式接触型更耐用。
项目 14:使用 ATmega328P-PU ADC 进行实验
在这个项目中,你将使用较大的 ATmega328P-PU 微控制器进行 ADC 实验,并练习更复杂的决策代码。这个项目测量来自微调电位器的信号,信号在 0 V 和 5 V 之间变化。该值会落入四个范围之一,并由四个 LED 中的一个显示出来。
硬件部分
你需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• ATtiny328P-PU 微控制器
-
• 四个 LED
-
• 四个 560 Ω 电阻
-
• 0.1 μF 陶瓷电容器
-
• 10 kΩ 面包板兼容线性微调电位器
-
• 跳线
按照图 3-16 中的方式组装电路。

图 3-16: 项目 14 的电路图
如果你找不到适配面包板的微调电位器,可以使用全尺寸的电位器,尽管你需要将跳线焊接到电位器的三个引脚上,以便与无焊面包板接触。
代码部分
打开终端窗口,导航到本书 第三章 文件夹下的 Project 14 子文件夹,并输入命令 make flash,按常规上传 Project 14 的代码。上传代码后,开始缓慢将可调电位器调到一个极限,然后转到另一个方向,调到另一个极限。LED 应该能指示你当前转动的是可调电位器范围的哪个四分之一。
让我们看看它是如何工作的。打开 Project 14 的 main.c 文件,查看代码:
// Project 14 - Experimenting with an ATmega328P-PU ADC
#include <avr/io.h>
#include <math.h>
#include <util/delay.h>
❶ void startADC()
// Set up the ADC
{
ADMUX |= (1 << REFS0); // Use AVcc pin with ADC
ADMUX |= (1 << MUX2) | (1 << MUX0); // Use ADC5 (pin 28)
ADCSRA |= (1 << ADPS1) | (1 << ADPS0); // Prescaler for 1MHz (/8)
ADCSRA |= (1 << ADEN); // Enable ADC
}
int main(void)
{
uint16_t ADCvalue;
❷ DDRB = 0b11111111; // Set PORTB to outputs
DDRC = 0b00000000; // Set PORTC to inputs
❸ startADC();
for(;;)
{
❹ // Take reading from potentiometer via ADC
ADCSRA |= (1 << ADSC); // Start ADC measurement
loop_until_bit_is_clear(ADCSRA, ADSC);
// Wait for conversion to finish
❺ _delay_ms(10);
❻ // Assign ADC value to "ADCvalue"
ADCvalue = ADC;
❼ if (ADCvalue>=0 && ADCvalue <256)
{
PORTB = 0b00000001;
}
else if (ADCvalue>=256 && ADCvalue<512)
{
PORTB = 0b00000010;
}
else if (ADCvalue>=512 && ADCvalue<768)
{
PORTB = 0b00000100;
}
else if (ADCvalue>=768 && ADCvalue<1023)
{
PORTB = 0b00001000;
}
// Turn off the LEDs in preparation for the next reading
_delay_ms(100);
PORTB = 0b00000000;
}
return 0;
}
首先,我们指定用于输出(LED—PORTB)和 ADC 输入的引脚 ❷,然后我们调用函数 startADC() ❶ 来设置 ADC ❸。我们通过引脚 PC5 ❹ 测量来自可调电位器的值,并在短暂的延迟 ❺ 后将其存储到整数变量 ADCvalue ❻ 中,以便给 ADC 时间完成数据转换。
接下来,代码使用一系列的 if...else 函数 ❼ 来评估 ADC 的值。每个检查 ADC 值是否落在某个范围内,使用 AND(&&)条件运算符,然后如果测试结果为真,则激活一个 LED 进行视觉指示。
最后,在主循环结束时,LED 被关闭,并且稍作延迟,以便有时间进行指示,随后过程再次开始。
使用 AVR 进行算术运算
像口袋计算器一样,AVR 可以为你执行基本的计算。这在你处理模拟到数字转换时非常方便。以下是 AVR 提供的一些数学运算:
432 + 956; // Addition
100 / 20; // Division
5 * 200; // Multiplication
25 - 25; // Subtraction
10 % 4; // Modulo
然而,C 语言处理某些类型的计算与口袋计算器有些不同。例如,在除法运算中,AVR 会直接丢弃余数,而不是四舍五入:16 除以 2 等于 8,10 除以 3 等于 3,18 除以 8 等于 2。我会在遇到其他特殊情况时解释。
当处理会或已经产生小数点的数字时(例如,将 1 除以 3),你需要使用一种新类型的变量,叫做 float。float 类型可以存储的值范围在 −3.39 × 10 ³⁸ 到 3.39 × 10 ³⁸ 之间。要在代码中使用浮点运算,你需要引入一个新的库:
#include <math.h>
你将在 Project 15 中逐渐习惯这种数学运算,并与第一个模拟传感器一起使用,我会在接下来介绍它。
使用外部电源
到目前为止,你一直是通过 AVR 编程器直接为项目供电,这对于小型项目和实验来说是一个简洁的解决方案。然而,这种方法会使你的电路仅能获得不到 5 V 的输出电压,因为编程器的内部电路会降低电压。
如果你使用万用表测量 ATmega328P-PU 在项目 14 或之前项目中的引脚 7 和 8 之间的电压,你会发现它小于 5 V。当使用需要 5 V 电压的组件(如在下一个项目 中使用的 TMP36)时,你需要一个外部电源以确保准确性和可靠性。
添加外部 5 V 电源的一个简单方法是使用面包板电源模块,比如来自 PMD Way 的模块(零件号 20250303),如图 3-17 所示。

图 3-17:面包板电源模块
PMD Way 模块插入到面包板的一端,由常见的 AC 到 DC 电源适配器供电。该单元为面包板的两侧提供 5 V 或 3.3 V 电压,并带有一个方便的电源开关进行控制。它是一个小巧且非常便捷的设备。
TMP36 温度传感器
使用 TMP36 温度传感器(如图 3-18 所示)和一些简单的数学运算,你可以将 AVR 转变为温度计。这个廉价且易于使用的模拟传感器输出随周围温度变化的电压。

图 3-18:TMP36 温度传感器
TMP36 传感器有三个引脚。当你面朝传感器*面并看到其上有文字时,引脚的排列顺序是(从左到右)电压输入、电压输出和接地(GND)。你需要将引脚 1 连接到你项目中的 5 V 电源,引脚 2 连接到微控制器的模拟输入,引脚 3 连接到接地(GND)。图 3-19 显示了 TMP36 的原理图符号。

图 3-19:TMP36 原理图符号
TMP36 的电压输出表示传感器周围的温度;例如,在 25 摄氏度时,输出为 750 毫伏(即 0.75 V),每变化 1 度,电压输出会变化 10 毫伏。TMP36 可以测量从 -40 到 125 摄氏度的温度,但在下一个项目中你只需测量室温。
要从电压中确定温度,将 ADC 中的值乘以 5,再除以 1,024,得到传感器返回的实际电压。接下来,减去 0.5(0.5 V 是 TMP36 用于允许低于 0 度的温度的偏移量),然后乘以 100,得到摄氏温度。
注:如果你想使用华氏度,需将摄氏度乘以 1.8,再加上 32。
由于这是一个模拟设备,输出电压由输入电压决定。如果输入端没有 5 V 或接* 5 V 的电压,输出和温度读数将不正确。
项目 15:创建数字温度计
在这个项目中,你将运用项目 12 中学到的知识,使用 ATmega328P-PU 上的 ADC 创建一个数字温度计的数字显示。为了简化,项目将只显示从 0 摄氏度开始的温度,负温度值不包括在内。
硬件
要构建你的温度计,你需要通过微控制器读取 TMP36 模拟温度传感器,然后使用项目 12 中的七段 LED 显示器逐位显示温度。
你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊接面包板
-
• 5 V 面包板电源
-
• ATmega328P-PU 微控制器
-
• 一个 TMP36 温度传感器
-
• 一个共阴七段 LED 显示器
-
• 七个 560 Ω电阻(R1–R7)
-
• 0.1 μF 陶瓷电容器
-
• 跳线
按照图 3-20 中的示意图组装电路。

图 3-20: 项目 15 的原理图
你将使用 0.1 μF 电容器帮助保持 TMP36 温度传感器的稳定电源;它应该尽量靠* TMP36 的 5 V 和 GND 引脚安装。
代码
打开一个终端窗口,进入本书第三章文件夹下的项目 15子文件夹,输入命令make flash上传项目 15 的代码。上传代码后,LED 模块应该会逐位显示大致的温度。例如,如果温度是 8 摄氏度,显示屏将先显示 0,短暂延迟后,再显示 8。
要了解这个工作原理,打开main.c文件,查看项目 15 中的代码:
// Project 15 - Creating a Digital Thermometer
❶ #include <avr/io.h>
#include <math.h>
#include <util/delay.h>
❷ void startADC()
// Set up the ADC
{
ADMUX |= (1 << REFS0); // Use AVcc pin with ADC
ADMUX |= (1 << MUX2) | (1 << MUX0); // Use ADC5 (pin 28)
ADCSRA |= (1 << ADPS1) | (1 << ADPS0); // Prescaler for 1MHz (/8)
ADCSRA |= (1 << ADEN); // enable ADC}
}
❸ void displayNumber(uint8_t value)
// Displays a number from 0–9 on the seven-segment LED display
{
switch(value)
{
case 0 : PORTB = 0b00111111; break; // 0
case 1 : PORTB = 0b00110000; break; // 1
case 2 : PORTB = 0b01011011; break; // 2
case 3 : PORTB = 0b01111001; break; // 3
case 4 : PORTB = 0b01110100; break; // 4
case 5 : PORTB = 0b01101101; break; // 5
case 6 : PORTB = 0b01101111; break; // 6
case 7 : PORTB = 0b00111000; break; // 7
case 8 : PORTB = 0b01111111; break; // 8
case 9 : PORTB = 0b01111101; break; // 9
}
}
int main(void)
{
❹ uint8_t tens = 0; // Holds tens digit for temperature
uint8_t ones = 0; // Holds ones digit for temperature
float temperature;
float voltage;
uint16_t ADCvalue;
uint8_t finalTemp;
DDRB = 0b11111111; // Set PORTB to outputs
DDRC = 0b00000000; // Set PORTC to inputs
startADC();
for(;;)
{
❺ // Take reading from TMP36 via ADC
ADCSRA |= (1 << ADSC); // Start ADC measurement
while (ADCSRA & (1 << ADSC) ); // Wait for conversion to finish
_delay_ms(10);
// Get value from ADC register, store in ADCvalue
ADCvalue = ADC;
❻ // Convert reading to temperature value (Celsius)
voltage = (ADCvalue * 5);
voltage = voltage / 1024;
temperature = ((voltage - 0.5) * 100);
❼ // Display temperature on LED module
finalTemp = (uint8_t) round(temperature);
tens = finalTemp / 10;
ones = finalTemp % 10;
❽ displayNumber(tens); // Display tens digit
_delay_ms(250);
displayNumber(ones); // Display ones digit
_delay_ms(250);
❾ // Turn off the LED display in preparation for the next reading
PORTB = 0b00000000;
_delay_ms(1000);
}
return 0;
}
在这段代码中,我们首先包含了必要的库(其中包括math.h,用于浮点数学)❶。我们添加了startADC()函数以启动 ADC ❷(该函数在代码的主要部分开始时调用),并且我们重用了项目 12 中的displayNumber()函数❸。
在代码的主要部分,我们声明所需的变量,定义输入和输出引脚,并初始化 ADC ❹。代码的主循环被分为五个步骤:
-
1. TMP36 的电压由 ADC 测量并存储在变量
ADCvalue❺中。 -
2. 使用“介绍 TMP36 温度传感器”中描述的公式,代码将 ADC 的值转换为电压。然后,这个电压被转换为摄氏度的温度 ❻。
-
3. 用于表示温度的数字从
finalTemp中提取,然后通过round()函数四舍五入到最接*的整数。代码通过将温度除以 10 来确定十位数字。如果温度低于 10 度,则此值为 0。通过对温度除以 10 的余数进行取余运算,确定个位数字 ❼。 -
displayNumber()函数用于分别显示温度的十位和个位数字,数字之间有四分之一秒的延迟 ❽。 -
5. 最后,显示器关闭一秒钟 ❾,为显示的值和即将出现的新值之间提供视觉间隔。
这个看似复杂的项目只是将你现有的知识以新的方式组合起来,我希望这能激发你的想象力。在第四章中,我们将转向一个新主题:启用微控制器与 PC 之间的双向通信,用于数据捕获和控制。
第四章:# 使用 USART 与外部世界进行通信

本章将教你如何使用通用同步异步接收发射器(USART),一个专用的双向端口,用于在 AVR 和计算机之间传输信息,允许两者进行通信。USART 让你可以从计算机控制 AVR 项目,它还能帮助你调试项目,因为你可以从 AVR 向计算机发送状态报告,跟踪代码的进展。
在本章中,你将:
-
• 使用终端仿真软件将你的计算机作为输入输出设备,来支持基于 AVR 的项目。
-
• 在 AVR 和计算机之间发送串行数据。
-
• 在 AVR 和计算机之间传输数据,包括数字和字母。
-
• 熟悉 ASCII 码。
在此过程中,你将学会将温度计的读数记录到 PC 上进行后续分析,并构建一个简单的计算器。
介绍 USART
计算机之间有很多种通信方式,其中一种方法是使用串行数据,即按顺序每次发送一个比特的数据。基于 AVR 的项目通过 AVR 的 USART 来实现这一点。ATmega328P-PU 微控制器上的 USART 使用引脚 2 接收数据,使用引脚 3 发送数据。数据以字节的形式发送和接收,每个字节代表 8 位数据。
与直接发送 1 和 0(计算机表示字节的方式)不同,数据的值是通过改变电压电*在一定时间内表示的。高电压表示 1,低电压表示 0。每个字节从起始位开始,起始位总是 0,结束位总是 1。字节数据是从最右边的(即最不重要的)位开始发送和接收的。
我将使用数字存储示波器展示这些字节数据是什么样的,正如你在第三章中看到的,它是一种可以显示电压随时间变化的设备。例如,参见图 4-1,它显示了从 USART 发送的代表数字 10 的字节数据。

图 4-1:在数字存储示波器上表示的字节数据
让我们来看一下如何将这转换为十进制数字 10。起始位总是 0,所以电压首先是低电压,然后是低电压(0),然后是高电压(1),再是低电压,再是高电压,再是低电压,持续四个周期,最后是高电压(结束位,总是 1)。这给我们带来了二进制数字 01010000,但因为字节是从最不重要的位(LSB)开始发送和接收的,所以我们需要将它翻转过来。这就得到 00001010,这是十进制数字 10 的二进制表示。
数据以不同的速度发送和接收。在图 4-1 和本章中的所有项目中,数据传输速度是 4,800bps(每秒比特数)。
USART 通信的硬件和软件
要准备好让你的计算机与 AVR 微控制器进行数据发送和接收,你需要两样东西:一个 USB 转串口转换器和适合的终端软件。
让我们从USB 转串口转换器开始,它是将你的 AVR 项目连接到计算机的最简单方式。这类转换器有许多种类型,但在本书中,我推荐使用一种内置于电缆中的转换器,方便实用。我使用的是 PL2303TA 型 USB 转串口电缆,如图 4-2 所示。CP2102 和 CP2104 型电缆也很受欢迎。按照供应商的说明安装转换器电缆的驱动程序。

图 4-2:PL2303TA 型 USB 转串口转换器电缆
要通过你的 AVR 项目与计算机进行交互,你还需要一个终端仿真器,这是一个简单的程序,用来捕获并显示从 AVR 接收的数据,同时让你能够从计算机向 AVR 发送数据。CoolTerm 是 Roger Meier 开发的一个出色的终端仿真器,适用于多种*台;你可以从他的网站freeware.the-meiers.org/下载。该软件是免费的,但请考虑通过网站捐赠,以支持 Meier 的工作。
一旦你下载并安装了 CoolTerm,将你的 USB 转串口转换器插入计算机,打开 CoolTerm,然后点击窗口顶部的选项按钮。图 4-3 中的界面应该会显示出来。

图 4-3:CoolTerm 的串口选项配置界面
我在本书中的示例中使用的是 Windows 系统,但 CoolTerm 在其他*台上运行时界面应当类似。将串口选项设置更改为与图 4-3 所示设置一致,除了端口(Port),它会根据你的计算机有所不同——将其更改为与你的 USB 转换器名称匹配。例如,对于 Windows PC,使用端口旁边的下拉菜单选择你的 USB 转串口转换器所使用的 COM 端口。
接下来,从左侧列表中选择终端选项,调整设置以匹配图 4-4 中所示的设置,然后点击确定。

图 4-4:CoolTerm 的终端选项配置屏幕
一旦 CoolTerm 正确配置,应该会出现图 4-5 中显示的窗口,表示 CoolTerm 已准备好使用。

图 4-5:CoolTerm 已准备好使用
现在你已经准备好测试你的 USB 到串口转换器和终端软件了。
项目 16:测试 USART
在本项目中,你将首次使用 USART,测试你的 USB 串口连接和硬件。这将为更高级的项目做准备,在这些项目中,你将从 AVR 向计算机发送数据,以调试代码并检查其进度。
硬件
对于这个项目,你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊接面包板
-
• ATmega328P-PU 微控制器
-
• USB 到串口转换器
-
• 跳线
如同之前的项目那样,通过无焊接面包板将 USBasp 连接到你的微控制器。接下来,找到你的 USB 串口转换器上的四个连接端:GND、TX、RX 和 V [CC](或 5V)。按照表格 4-1 中的指示,将前面三个引脚连接到微控制器。如果你使用的是图 4-2 中展示的 PL2303TA 电缆,白色电缆是 RX,绿色电缆是 TX。如果你使用的是不同型号,请参考供应商的说明书以确定正确的电缆。
| 表 4-1:USB 到串口转换器与微控制器连接 |
|---|
| USB 到串口转换器引脚 |
| --- |
| GND |
| TX |
| RX |
你将在本章的接下来的三个项目中使用这块硬件,因此在组装完成后,请保持它完好无损。
代码
打开一个终端窗口,导航到本书第四章文件夹中的项目 16子文件夹,并输入make flash命令。工具链应该会编译子文件夹中的程序文件,然后将数据上传到微控制器。
接下来,切换到终端软件并点击连接按钮。稍等片刻,CoolTerm 窗口应该会显示出永恒的消息Hello world,如图 4-6 所示。

图 4-6:成功!项目 16 的代码在终端上打印“Hello world”。
要了解如何实现这一点,请打开位于项目 16子文件夹中的main.c文件,其中包含以下代码:
// Project 16 - Testing the USART
#include <avr/io.h>
❶ #define USART_BAUDRATE 4800
#define UBRR_VALUE 12
❷ void USARTInit(void)
{
// Set baud rate registers
UBRR0H = (uint8_t)(UBRR_VALUE>>8);
UBRR0L = (uint8_t)UBRR_VALUE;
// Set data type to 8 data bits, no parity, 1 stop bit
UCSR0C |= (1<<UCSZ01)|(1<<UCSZ00);
// Enable transmission and reception
UCSR0B |= (1<<RXEN0)|(1<<TXEN0);
}
❸ void USARTSendByte(uint8_t u8Data)
{
// Wait while previous byte sent
while(!(UCSR0A&(1<<UDRE0))){};
// Transmit data
UDR0 = u8Data;
}
❹ void HelloWorld(void)
{
USARTSendByte(72); // H
USARTSendByte(101); // e
USARTSendByte(108); // l
USARTSendByte(108); // l
USARTSendByte(111); // o
USARTSendByte(32); // space
USARTSendByte(119); // w
USARTSendByte(111); // o
USARTSendByte(114); // r
USARTSendByte(108); // l
USARTSendByte(100); // d
USARTSendByte(32); // space
}
int main(void)
{
// Initialize USART0
❺ USARTInit();
while(1)
{
HelloWorld();
}
}
在使用 USART 之前,必须先进行初始化并设置数据速率(在本例中为 4800bps)❶。所有初始化代码都包含在USARTInit()函数中❷,该函数需要在代码的主循环中调用一次❺。
USARTSendByte()函数❸将一个字节的数据从 USART 发送到计算机。此函数在发送新字节数据之前会等待 USART 清除旧数据,新字节数据以 8 位整数的形式发送(数据类型为uint8_t)。
最后,文本“Hello world”是通过HelloWorld()函数❹发送的。请注意,我们不是直接发送字母,而是发送代表每个字母的数字。为了参考,我在代码中注释了每个数字对应的字母。这些数字是ASCII 码的一部分,最初用于电报和早期通信系统之间的消息传输。你可以在en.wikipedia.org/wiki/ASCII找到 ASCII 控制码表的副本。
你可以通过更改发送给计算机的文本来尝试此代码;只需在USARTSendByte()函数调用中替换你自己的 ASCII 码。不过不要在这上面花太多时间,因为下一个项目将向你展示一种更好的传输文本的方法。
最后,当你完成监控 USART 后,始终在 CoolTerm 中点击断开连接。
项目 17:通过 USART 发送文本
本项目使用的硬件与项目 16 相同。打开一个终端窗口,导航到本书第四章文件夹中的项目 17子文件夹,输入make flash命令,像往常一样上传项目 17 的代码。
接下来,切换到终端软件并点击连接按钮。片刻后,屏幕应再次显示Hello, world—这次是单列显示,如图 4-7 所示。

图 4-7:项目 17 的示例结果
项目 17 的代码与项目 16 相同,唯一不同的是,它使用了字符数组来简化发送文本的过程。这些数组存储一个或多个字符,可以是字母、数字、符号以及你键盘上能生成的任何内容。它们的定义如下:
char i[
`x`
] = ""
其中x是数组中可以出现的最大字符数(通常建议包含这个值)。
要查看如何以这种方式传输文本,请打开位于项目 17子文件夹中的main.c文件,该文件包含以下代码:
// Project 17 - Sending Text with the USART
#include <avr/io.h>
#include <util/delay.h>
#define USART_BAUDRATE 4800
#define UBRR_VALUE 12
void USARTInit(void)
{
// Set baud rate registers
UBRR0H = (uint8_t)(UBRR_VALUE>>8);
UBRR0L = (uint8_t)UBRR_VALUE;
// Set data frame format to 8 data bits, no parity, 1 stop bit
UCSR0C |= (1<<UCSZ01)|(1<<UCSZ00);
// Enable transmission and reception
UCSR0B |= (1<<RXEN0)|(1<<TXEN0);
}
void USARTSendByte(unsigned char u8Data)
{
// Wait while previous byte is sent
while(!(UCSR0A&(1<<UDRE0))){};
// Transmit data
UDR0 = u8Data;
}
❶ void sendString(char myString[])
{
uint8_t a = 0;
while (myString[a])
{
USARTSendByte(myString[a]);
a++;
}
}
int main(void)
{
❷ char z[15] = "Hello, world\r\n"; // Make sure you use " instead of ”
// Initialize USART
USARTInit();
while(1)
{
sendString(z);
_delay_ms(1000);
}
}
在主循环中,我们定义了一个包含Hello, world消息的字符数组 ❷。该消息旁边的\r和\n是无声控制代码,也称为转义序列,它们向终端软件发送信息,但本身不会显示出来。\r指示软件将光标移到行首,\n指示将光标移到下一行的垂直位置;因此,组合\r\n将光标移到终端显示的下一行的开头,从而使输出以有序的列格式打印。
我们使用一个名为sendString()的新函数 ❶,通过从零(数组的第一个位置总是零)开始循环,逐个读取我们定义的数组中的每个字符并将它们发送到 USART,直到没有更多字符。在每次循环迭代中,AVR 将当前字节发送到 USART。
如果在输入代码后收到错误消息,如下所示:
`main.c:42:2: error: stray '\342' in program`
这意味着你使用了错误类型的引号来定义字符数组。确保你使用的是直引号( " )而不是弯引号( ” )。你可能需要更改文本编辑器中的自动更正设置,以防止错误的引号出现。
在下一个项目中,你将学习如何将数据从 AVR 发送到计算机上的终端软件。
项目 18:使用 USART 发送数字
你通常需要在 AVR 和计算机之间发送数字。例如,你可能希望记录硬件生成的数据,发送你创建的接口的输出,或在调试项目时仅发送 AVR 的简单状态报告。在本项目中,你将学习如何发送整数和浮点数。
本项目再次使用与项目 16 相同的硬件。打开终端窗口,导航到本书第四章文件夹中的项目 18子文件夹,并输入make flash命令像往常一样上传代码。
接下来,切换到终端软件并点击连接按钮。片刻之后,终端软件应依次显示一个整数和一个浮点数,如图 4-8 所示。

图 4-8:项目 18 的结果
现在打开位于项目 18子文件夹中的main.c文件,该文件包含以下代码:
// Project 18 - Sending Numbers with the USART
#include <avr/io.h>
#include <stdlib.h>
#include <stdio.h>
#include <util/delay.h>
#include <string.h>
#define USART_BAUDRATE 4800
#define UBRR_VALUE 12
void USARTInit(void)
{
// Set baud rate registers
UBRR0H = (uint8_t)(UBRR_VALUE>>8);
UBRR0L = (uint8_t)UBRR_VALUE;
// Set data frame format to 8 data bits, no parity, 1 stop bit
UCSR0C |= (1<<UCSZ01)|(1<<UCSZ00);
// Enable transmission and reception
UCSR0B |= (1<<RXEN0)|(1<<TXEN0);
}
void USARTSendByte(unsigned char u8Data) // Send a byte to the USART
{
// Wait while previous byte is sent
while(!(UCSR0A&(1<<UDRE0))){};
// Transmit data
UDR0 = u8Data;
}
void sendString(char myString[])
{
uint8_t a = 0;
while (myString[a])
{
USARTSendByte(myString[a]);
a++;
}
}
int main(void)
{
char a[10] = "Float - ";
char b[10] = "Integer - ";
char t[10] = ""; // For our dtostrf test
char newline[4] = "\r\n";
int16_t i = -32767;
float j = -12345.67;
// Initialize USART
USARTInit();
while(1)
{
❶ dtostrf(j,12,2,t);
sendString(a); // "Float - "
sendString(t); // Send float
sendString(newline);
_delay_ms(1000);
❷ dtostrf((float)i,12,0,t);
sendString(b); // "Integer - "
printf(t); // Send integer
sendString(newline);
_delay_ms(1000);
}
}
这段代码将存储在字符数组中的文本发送到计算机,就像在项目 17 中一样。然而,它包括了一些非常有用的新功能。在我们能够将浮动小数点和整数变量发送到 USART 之前,我们需要将它们转换为字符数组。我们通过dtostrf()函数实现这一点,该函数默认包含在 AVR C 编译器中:
dtostrf(float j, w, d, char t[]);
这个函数将浮动小数点数j放入字符数组t[]中。变量d设置小数部分的位数,变量w设置显示数组的总字符数。我们在项目代码中使用dtostrf()函数,将浮动小数点变量转换为字符数组,然后将其发送到 USART ❶。要将整数转换为字符数组,我们使用相同的函数,但在整数变量前加上前缀(float) ❷。
在下一个项目中,你将通过将整数和浮动小数点数转换为字符数组的知识,进一步利用这些知识,将温度计数据发送到你的 PC。
项目 19:创建温度数据记录器
在这个项目中,你将把在项目 15 中使用的 TMP36 温度传感器的读数发送到计算机。终端软件将捕获这些数据并存储为文本文件,你可以在电子表格中打开该文件进行进一步分析。
硬件
你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• 5 V 面包板电源
-
• ATmega328P-PU 微控制器
-
• 一个 TMP36 温度传感器
-
• 0.1 μF 陶瓷电容
-
• 跳线
-
• USB 转串口转换器
按照图 4-9 所示组装电路,然后像往常一样连接你的 USB 转串口转换器。

图 4-9:项目 19 的原理图
打开终端窗口,导航到本书第四章文件夹下的项目 19子文件夹,输入make flash命令上传项目 19 的代码。
接下来,切换到终端软件并点击连接按钮。片刻之后,终端软件应开始显示传感器测量的环境温度,如图 4-10 所示。

图 4-10:温度传感器在工作
如你所见,本项目结合了你现有的温度传感器知识和通过 USART 将文本和数字发送到终端软件的技能。为了展示输出示例中的一些变化,我使用了一个小风扇来改变温度传感器周围的气流。这帮助改变了测量的温度,数据每秒捕获一次。
代码
要查看本项目中使用的函数如何将温度转换为数组并发送出去,请打开位于Project 19子文件夹中的main.c文件,查看代码:
// Project 19 - Creating a Temperature Data Logger
#include <avr/io.h>
#include <stdlib.h>
#include <stdio.h>
#include <math.h>
#include <util/delay.h>
#define USART_BAUDRATE 4800
#define UBRR_VALUE 12
❶ void startADC() // Set up the ADC
{
ADMUX = 0b01000101; // Set ADC pin to 28
ADCSRA = 0b10000011; // Set prescaler speed for 1 MHz
}
void USARTInit(void)
{
// Set baud rate registers
UBRR0H = (uint8_t)(UBRR_VALUE>>8);
UBRR0L = (uint8_t)UBRR_VALUE;
// Set data frame format to 8 data bits, no parity, 1 stop bit
UCSR0C |= (1<<UCSZ01)|(1<<UCSZ00);
// Enable transmission and reception
UCSR0B |= (1<<RXEN0)|(1<<TXEN0);
}
void USARTSendByte(unsigned char u8Data)
{
// Wait while previous byte is sent
while(!(UCSR0A&(1<<UDRE0))){};
// Transmit data
UDR0 = u8Data;
}
void sendString(char myString[])
{
uint8_t a = 0;
while (myString[a])
{
USARTSendByte(myString[a]);
a++;
}
}
int main(void)
{
float temperature;
float voltage;
uint8_t ADCvalue;
char t[10] = ""; // Will hold temperature for sending via USART
char a[14] = "Temperature: "; // Make sure you have " instead of ”
char b[14] = " degrees C "; // Make sure you have " instead of ”
char newline[4] = "\r\n";
DDRD = 0b00000000; // Set PORTD to inputs
startADC();
USARTInit();
while(1)
{
// Get reading from TMP36 via ADC
ADCSRA |= (1 << ADSC); // Start ADC measurement
while (ADCSRA & (1 << ADSC) ); // Wait until conversion is complete
_delay_ms(10);
❷ // Get value from ADC register, place in ADCvalue
ADCvalue = ADC;
❸ // Convert reading to temperature value (Celsius)
voltage = (ADC * 5);
voltage = voltage / 1024;
temperature = ((voltage - 0.5) * 100);
❹ // Send temperature to PC via USART
sendString(a);
dtostrf(temperature,6,2,t);
sendString(t);
sendString(b);
sendString(newline);
_delay_ms(1000);
}
return 0;
}
在❶和❷之间的代码中,初始化了微控制器的 ADC,然后使用它们。接下来,我们将 ADC 数据转换为摄氏温度❸。最后,我们将温度转换为字符数组,并将其发送到终端仿真器,以创建一个漂亮的输出❹。这个过程每秒重复一次。
此时,终端仿真器软件可以将从微控制器接收到的数据捕获到文本文件中,你可以在文本编辑器或电子表格中打开该文件进行进一步分析。要在 CoolTerm 中启用此功能,选择连接 ▸ 捕获到文本/二进制文件 ▸ 开始,如图 4-11 所示。

图 4-11:从终端开始录制数据。
然后,CoolTerm 会要求你选择一个位置并命名文本文件,如图 4-12 所示。完成后,点击保存,录制将开始。你可以使用暂停和停止选项分别暂停和结束录制。

图 4-12:选择文件名和存储文本文件的位置。
一旦你捕获到所有需要的温度数据,在终端仿真器软件中停止录制(连接 ▸ 捕获到文本/二进制文件 ▸ 停止),然后在电子表格软件中打开生成的文本文件。为了演示,我使用了 Excel。因为这是一个文本文件,所以系统会提示你选择一个文本分隔符,这是一个单字符,用于在数据值之间插入,便于其他软件整理数据。选择空格作为分隔符,如图 4-13 所示。请注意,在截图中,选中了“将连续分隔符视为一个”;如果文件中有双空格,这可以删除重复的空白列。

图 4-13:选择分隔符并预览数据。
点击下一步导入数据。这将创建一个整洁的温度数据电子表格,类似于图 4-14 中的示例,你可以随意分析这些数据。

图 4-14:准备分析的温度数据
虽然本项目使用的是温度数据,但你可以使用此方法记录任何通过 AVR 的 USART 发送的数据。如果未来的实验需要记录数据,请记住这一点。现在,我们将转向我们的下一个项目:将计算机终端软件中的数据发送回 AVR。
项目 20:从计算机接收数据
在这个项目中,你将学习如何使用计算机控制基于 AVR 的项目,或通过 USART 实现计算机与微控制器之间的双向数据传输,制作自己的输入设备。
本项目使用的硬件与项目 16 相同。在复现该项目后,打开一个终端窗口,导航到本书第四章文件夹中的项目 20子文件夹,并输入make flash命令以上传项目 20 的代码。
接下来,切换到终端仿真软件并点击选项按钮,然后从窗口左侧的列表中选择终端。将终端模式设置为原始模式,如图 4-15 所示,然后点击确定,再点击连接。

图 4-15:将终端仿真器切换回原始模式
一旦将终端仿真器设置为原始模式,开始在键盘上输入内容。你每次按下的键都会发送到 AVR,然后 AVR 将其返回并显示在终端仿真器中。你输入的任何内容都应该出现在终端窗口中,TX 和 RX 指示灯应闪烁。
要查看 USART 如何接收你的按键并将其发送回来,打开项目 20子文件夹中的main.c文件,查看代码:
// Project 20 - Receiving Data from Your Computer
#include <avr/io.h>
#include <stdlib.h>
#define USART_BAUDRATE 4800
#define UBRR_VALUE 12
void USARTInit(void)
{
// Set baud rate registers
UBRR0H = (uint8_t)(UBRR_VALUE>>8);
UBRR0L = (uint8_t)UBRR_VALUE;
// Set data frame format to 8 data bits, no parity, 1 stop bit
UCSR0C |= (1<<UCSZ01)|(1<<UCSZ00);
// Enable transmission and reception
UCSR0B |= (1<<RXEN0)|(1<<TXEN0);
}
void USARTSendByte(uint8_t sentByte)
{
// Wait while previous byte is sent
while(!(UCSR0A&(1<<UDRE0))){};
// Transmit data
UDR0 = sentByte;
}
❶ uint8_t USARTReceiveByte()
// Receives a byte of data from the computer into the USART register
{
// Wait for byte from computer
while(!(UCSR0A&(1<<RXC0))){};
// Return byte
return UDR0;
}
int main(void)
{
uint8_t tempByte;
// Initialize USART0
USARTInit();
while(1)
{
// Receive data from PC via USART
❷ tempByte = USARTReceiveByte();
// Send same data back to PC via USART
❸ USARTSendByte(tempByte);
}
}
这段代码你现在应该已经非常熟悉;它从常规的 USART 初始化函数开始,然后将字节发送到计算机。然而,它包含了一个新的函数USARTReceiveByte() ❶,该函数等待一个字节的数据到达 USART,然后将该数据存储到一个整数变量中。在这个例子中,函数将传入的字节存储到变量tempByte ❷中。然后,USARTSendByte()函数将相同的字节数据发送回终端仿真器 ❸。就是这么简单:一个字节进来,然后被发回去。
项目 21:构建四功能计算器
到现在为止,你已经学会了如何在 AVR 项目和计算机之间发送和接收数据,使得你的项目能够与外部数据和命令进行交互。在这个项目中,你将运用本章和之前章节所学的所有知识,制作一个简单的四则运算计算器。
与本章之前的项目一样,你将使用基本的 AVR 和 USB 到串行转换器。完成该设置后,打开终端窗口,导航到本书Chapter 4文件夹中的Project 21子文件夹,并输入make flash命令上传该项目的代码。当你将代码闪存到微控制器时,可能会收到如下警告:
warning: 'answer' may be used uninitialized in this function
没关系,你可以继续正常操作。
接下来,打开终端模拟器,确保终端模式设置为原始模式,就像在项目 20 中那样,然后点击连接。片刻后,计算器界面应该出现在终端窗口中,并提示你输入命令。这个计算器可以加法、减法、乘法和除法单个数字。图 4-16 展示了一些示例;尽情尝试,输入你自己的命令,查看结果。

图 4-16:计算器正在工作
打开main.c文件,该文件位于Project 21子文件夹中。代码只是使用之前项目中的函数进行的一系列事件。
// Project 21 - Building a Four-Function Calculator
#include <avr/io.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <math.h>
#include <util/delay.h>
#define USART_BAUDRATE 4800
#define UBRR_VALUE 12
void USARTInit(void)
{
// Set baud rate registers
UBRR0H = (uint8_t)(UBRR_VALUE>>8);
UBRR0L = (uint8_t)UBRR_VALUE;
// Set data frame format to 8 data bits, no parity, 1 stop bit
UCSR0C |= (1<<UCSZ01)|(1<<UCSZ00);
// Enable transmission and reception
UCSR0B |= (1<<RXEN0)|(1<<TXEN0);
}
void USARTSendByte(uint8_t sentByte)
{
// Wait while previous byte is sent
while(!(UCSR0A&(1<<UDRE0))){};
// Transmit data
UDR0 = sentByte;
}
void sendString(char myString[])
{
uint8_t a = 0;
while (myString[a])
{
USARTSendByte(myString[a]);
a++;
}
}
uint8_t USARTReceiveByte()
// Receives a byte of data from the computer into the USART register
{
// Wait for byte from computer
while(!(UCSR0A&(1<<RXC0))){};
// Return byte
return UDR0;
}
int main(void)
{
uint8_t digit1;
uint8_t digit2;
uint8_t operator;
float answer=0;
float d1=0;
float d2=0;
❶ char a[26] = "Enter command (e.g. 5*2) ";
char b[11] = "Answer is ";
char answerString[20] = ""; // Holds answer
char newline[4] = "\r\n";
USARTInit();
while(1)
{
sendString(newline);
sendString(a);
❷ digit1 = USARTReceiveByte();
❸ USARTSendByte(digit1);
❹ switch (digit1) // Convert ASCII code of digit1 to actual number
{
case 48 : digit1 = 0; break;
case 49 : digit1 = 1; break;
case 50 : digit1 = 2; break;
case 51 : digit1 = 3; break;
case 52 : digit1 = 4; break;
case 53 : digit1 = 5; break;
case 54 : digit1 = 6; break;
case 55 : digit1 = 7; break;
case 56 : digit1 = 8; break;
case 57 : digit1 = 9; break;
}
❺ operator = USARTReceiveByte();
❻ USARTSendByte(operator);
❼ digit2 = USARTReceiveByte();
❽ USARTSendByte(digit2);
❾ switch (digit2) // Convert ASCII code of digit2 to actual number
{
case 48 : digit2 = 0; break;
case 49 : digit2 = 1; break;
case 50 : digit2 = 2; break;
case 51 : digit2 = 3; break;
case 52 : digit2 = 4; break;
case 53 : digit2 = 5; break;
case 54 : digit2 = 6; break;
case 55 : digit2 = 7; break;
case 56 : digit2 = 8; break;
case 57 : digit2 = 9; break;
}
sendString(newline);
// Convert entered numbers into float variables
d1 = digit1;
d2 = digit2;
// Calculate result
switch (operator)
{
case 43 : answer = d1 + d2; break; // Add
case 45 : answer = d1 - d2; break; // Subtract
case 42 : answer = d1 * d2; break; // Multiply
case 47 : answer = d1 / d2; break; // Divide
}
// Send result to PC via USART
⓿ sendString(b);
dtostrf(answer,6,2,answerString);
sendString(answerString);
sendString(newline);
_delay_ms(1000);
}
return 0;
}
在这段代码中,我们首先在main()函数中初始化变量,然后通过从❶开始的代码行初始化 USART。程序提示用户输入由 3 字节数据组成的命令:第一个数字、运算符和第二个数字。USART 接收第一个数字❷、运算符❺和第二个数字❼,并分别在❸、❻和❽处将它们发送回终端,提供视觉反馈。
当用户输入一个数字时,终端模拟器将数字的 ASCII 码而不是数字本身发送给 AVR。然后,程序将 ASCII 码转换为实际的数字❹,并将其放入一个整数变量❾中。这个将 ASCII 码转换为数字的过程也决定了输入的运算符是什么(例如,+、-、* 或 /)。
然后,程序通过代码中最后一个switch()函数对这两个数字执行所需的计算。最后,计算结果被转换为字符数组,并通过sendString(b)发送回终端模拟器,以供用户阅读⓿。计算器现在可以进行下一次计算。
本章中的项目展示了如何使用你的计算机作为终端,通过 AVR 发送和接收数据,为你准备记录和分析数据的工作。在下一章中,我将向你展示如何使用中断,这是一种巧妙的方式,让你的 AVR 能够在输入发生时立即响应,而不是在预定的时刻。
第五章:# 使用硬件中断进行控制

到目前为止,本书中你的项目代码都是顺序执行的。任何偏离线性模式的行为,例如检测按钮按下,都需要你监控数字输入。然而,在代码中预先规划按钮按下并不总是高效或现实的。硬件中断使你的程序能够更高效、动态地响应事件。
硬件中断使 AVR 微控制器能够随时响应数字输入引脚状态的变化。某种程度上,它们让你的 AVR 可以进行多任务处理:当按下按钮或在数字输入引脚接收到信号时,AVR 会暂停当前的任务并执行其他代码,这部分代码被称为中断服务程序 (ISR)。在 ISR 代码执行完毕后,AVR 会从中断前暂停的位置继续执行。
中断让你可以编写更有逻辑的代码,使基于 AVR 的项目更直观地运行。本章介绍了两种硬件中断:外部中断和引脚变化中断,使用的微控制器为 ATmega328P-PU。两种中断都由引脚上的状态变化触发(例如,从高电压到低电压的变化)。引脚变化中断可以发生在所有引脚上,而外部中断只能发生在两个引脚上。掌握基本知识后,你将使用中断创建一个基于 USART 显示的计数设备。
外部中断
本节将带你了解初始化外部中断的基础知识,你将在项目 22 中使用这些知识。ATmega328P-PU 使用引脚 4 和 5,分别称为 INT0 和 INT1,用于外部中断。这两个引脚可以检测并报告它们连接的电信号的变化。中断可以设置为响应引脚上以下四种可能的状态变化:
低电* 引脚上的电压变化为低电*状态(等同于 GND)。
任何逻辑变化 引脚上的电压以任何方式变化,无论是从高到低还是从低到高。
下降沿 电压从高变为低。
上升沿 电压从低变为高。
考虑一下哪些选项可以让你的代码响应按钮按下的动作。例如,如果你将按钮连接到 GND 并且微控制器输入端有一个上拉电阻,那么按钮按下时会将输入从高电*切换到低电*。在这种情况下,你通常会使用下降沿中断选项。相反,如果按钮连接在 5V 和微控制器输入之间,并且输入端有一个下拉电阻,那么按钮按下时会将输入从低电*切换到高电*。在这种情况下,你可以使用上升沿中断或任何逻辑变化中断。
总的来说,中断类型的选择将由连接到中断引脚的外部电路决定。在本章中,我将演示各种类型的中断,帮助你根据自己的项目需求选择合适的中断类型。
在代码中设置中断
要使用中断,首先按如下方式添加中断库:
#include <avr/interrupt.h>
接下来,你需要设置一些寄存器——我们将逐一介绍。第一个是 EICRA 寄存器,用于确定引脚响应的四种状态变化中的哪一种。以下是任何 AVR 程序中设置 EICRA 寄存器的模板:
EICRA = 0b0000
`abcd`
;
使用表 5-1 中的指南,通过c和d位设置中断 INT0(引脚 4)并通过a和b位设置中断 INT1(引脚 5)。
| 表 5-1 : EICRA 寄存器选项位 |
|---|
| 位 a/c |
| --- |
| 0 |
| 0 |
| 1 |
| 1 |
例如,你可以使用c和d位,按如下方式设置 INT0 为上升沿中断:
EICRA = 0b00000011; // INT0 rising edge
接下来,设置 EIMSK 寄存器,该寄存器用于启用中断功能,方法如下:
EIMSK = 0b000000
`ab`
;
在这里,位a是 INT1,位b是 INT0。将每个位设置为 1 表示开启,设置为 0 表示关闭。例如,要启用 INT0 的中断功能,使用:
EIMSK = 0b00000001;
由于只有最后一位被设置为 1,因此只有 INT0 会被启用。
设置 EICRA 和 EIMSK 寄存器后,在代码中使用此函数调用启用中断:
sei();
别忘了这一步——如果跳过了,即使你已正确设置寄存器,中断也不会被触发。
这是迄今为止中断所需代码的总结:
#include <avr/interrupt.h> // Enable the interrupt library
EICRA = 0b0000
`abcd`
; // Determine which state changes the interrupt pin responds to
EIMSK = 0b000000
`ab`
; // Turn on the required interrupt
sei(); // Enable interrupts in your code
一旦准备好让你的 AVR 响应中断,你必须定义 ISR——当中断被触发时执行的代码。ISR 是一个自定义函数,具有以下结构:
ISR (INT
`x`
_vect)
{
// Code to be executed when an interrupt is triggered
EIFR =
`y`
;
}
参数INT x _vect指定 ISR 响应的引脚。当你传递一个值给函数时,将INT x _vect中的x替换为0表示 INT0,1表示 INT1。函数体是当中断被触发时执行的代码。我们总是以此命令结束 ISR 代码段:
EIFR =
`y`
;
这将外部中断标志寄存器重置为零,告诉微控制器该 ISR 的中断代码已完成,AVR 可以返回到正常操作。如果只使用一个中断,可以使用以下y值:0b00000001表示 INT0,0b00000010表示 INT1。若项目中使用了两个中断,如果你用整个 8 位值设置 EIFR 寄存器,将会更改两个中断。相反,你可以使用以下方法,只关闭一个中断:
EIFR &= ~(1<<0); // Set interrupt flag register for INT0 to zero
EIFR &= ~(1<<1); // Set interrupt flag register for INT1 to zero
这种逐个处理寄存器位的方式将在下一章中详细解释。同时,让我们在下一个项目中测试引脚变化中断。
第 22 项目:实验上升沿中断
在这个项目中,你将首先编程让微控制器快速闪烁 LED,然后添加逻辑,使得按下按钮时中断 LED 的闪烁并运行其他代码,让 LED 保持亮灯两秒钟,之后恢复闪烁模式。你将使用上升沿中断来实现这一功能。
硬件
对于这个项目,你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊接面包板
-
• ATmega328P-PU 微控制器
-
• 跳线
-
• 一颗 LED
-
• 一颗 560 Ω电阻
-
• 一颗按钮
-
• 一颗 10 kΩ电阻
首先,组装图 5-1 所示的电路。

图 5-1:第 22 项目的主要电路
电阻器和按钮采用的是我在第三章中介绍的下拉配置。当你按下按钮时,电流将流向引脚 4,将其状态从低电*变为高电*。这个上升沿的状态变化将触发中断。
组装好电路后,像之前的项目一样,通过无焊接面包板将 USBasp 连接到微控制器。
代码
打开终端窗口,导航到本书第五章文件夹中的第 22 项目子文件夹,然后输入命令make flash。工具链应编译程序文件并将数据上传到微控制器,此时 LED 应开始快速闪烁,每 50 毫秒开关一次。快速按下按钮(不要长时间按住它,因为这会触发开关抖动),LED 应保持亮灯两秒钟,然后恢复闪烁。
打开main.c文件,查看第 22 项目中的代码是如何工作的:
// Project 22 - Experimenting with Rising Edge Interrupts
// Blink PORTB. If button pressed, turn on PORTB for 2 seconds.
#include <avr/io.h>
#include <util/delay.h>
#include <avr/interrupt.h>
ISR (INT0_vect)
{ // Code to be executed when interrupt is triggered
PORTB = 0b11111111;
_delay_ms(2000);
❶ PORTB = 0b00000000;
❷ EIFR = 0b00000001; // Clear external interrupt flag register
}
void startInt0()
{
// Initialize interrupt 0 (PD2/INT0/pin 4)
// Rising edge (LOW to HIGH at pin 4)
❸ EICRA = 0b00000011;
// Turn on interrupt INT0
❹ EIMSK = 0b00000001;
// Turn on global interrupt enable flag in order for interrupts to be processed
sei();
}
int main(void)
{
// Declare global variables
// Set up GPIO pins etc.
❺ DDRB = 0b11111111; // Set PORTB register as outputs
❻ DDRD = 0b00000000; // Set PORTD pins 4 and 5 as inputs
// Initialize interrupt
startInt0();
for(;;)
❼ {
// Blink LED connected to PB7 (pin 10)
PORTB = 0b00000001;
_delay_ms(50);
PORTB = 0b00000000;
_delay_ms(50);
}
return 0;
}
这段代码定义了一个startInt0()函数来初始化中断。在这个函数中,我们首先设置 EICRA 寄存器,使得 INT0 响应上升沿中断❸,然后设置 EIMSK 寄存器开启 INT0❹,最后调用sei()来启用中断。在代码的主部分,我们将 PORTB 引脚设置为输出,用来控制 LED❺,并将 PORTD 引脚设置为输入❻。PORTD 包含数字引脚 4,它将作为 INT0 中断的输入。
一旦一切初始化完成,for 循环 ❼ 会让 LED 闪烁。由于存在中断,当你按下按钮时,产生的上沿触发硬件中断 INT0。这告诉 AVR 停止闪烁 LED,并运行 ISR ❶ 中的代码。当 ISR 代码执行完毕,EIFR 寄存器被设置为 0 ❷,LED 会恢复正常的快速闪烁。
恭喜!你刚刚看到了一种最常见的中断形式的实现。较少见的情况是需要检测到电流停止流向微控制器,这可以通过下沿中断来实现。你将在 下一个项目 中尝试这个。
项目 23:实验下沿中断
本项目的结果与 项目 22 相同,但这次电路使用了在 第三章 中介绍的上拉配置。默认情况下,数字引脚 4 上会有电流。当你按下按钮时,电流应该停止流向引脚 4,导致其状态从高变低,从而触发下沿中断。
硬件部分
你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• ATmega328P-PU 微控制器
-
• 跳线
-
• 一个 LED
-
• 一个 560 Ω 电阻
-
• 一个按键
-
• 一个 10 kΩ 电阻
首先组装如 图 5-2 所示的电路。

图 5-2: 项目 23 的主电路
在组装好电路后,通过无焊面包板将 USBasp 连接到微控制器,就像在之前的项目中那样。
代码部分
打开终端窗口,导航到本书 第五章 文件夹下的 项目 23 子文件夹,并输入命令 make flash 。项目代码上传完成后,LED 应该开始快速闪烁。快速按下按钮,LED 应该保持亮起两秒钟,然后恢复闪烁。
要查看其工作原理,打开 项目 23 的 main.c 文件:
// Project 23 - Experimenting with Falling Edge Interrupts
// Blink PORTB. If button is pressed, turn on PORTB for 2 seconds.
#include <avr/io.h>
#include <util/delay.h>
#include <avr/interrupt.h>
ISR (INT0_vect) { // Code to be executed when interrupt is triggered
PORTB = 0b11111111;
_delay_ms(2000);
PORTB = 0b00000000;
EIFR = 0b00000001;
}
void startInt0()
{
// Initialize interrupt 0 (PD2/INT0/pin 4)
// Falling edge (HIGH to LOW at pin 4)
❶ EICRA = 0b00000010;
// Turn on interrupt INT0
EIMSK = 0b00000001;
// Turn on global interrupt enable flag for interrupts to be processed
sei();
}
int main(void)
{
// Declare global variables
// Set up GPIO pins etc.
DDRB = 0b11111111; // Set PORTB register as outputs
DDRD = 0b00000000; // Set PORTD pins 4 and 5 as inputs
// Initialize interrupt
startInt0();
for(;;)
{
PORTB = 0b00000001;
_delay_ms(50);
PORTB = 0b00000000;
_delay_ms(50);
}
return 0;
}
本项目的代码与 项目 22 完全相同,唯一的变化是:我们将 EICRA 寄存器设置为 0b00000010,而不是 0b00000011 ❶。根据 表 5-1,最后两位(10)将 INT0 设置为下沿中断类型。EIMSK 保持不变,因为我们仍然使用 INT0 作为中断引脚,通常我们调用 sei() 来使能中断。
玩这些项目,熟悉上升沿和下降沿中断的使用。一旦你习惯了这两种数字输入的方式,你可以根据自己使用的电路设计来选择是用高电*还是低电*触发中断。现在这看起来可能是一个微不足道的选择,但随着你创建更多复杂的基于 AVR 的项目,它会变得非常重要。在某些情况下,你可能无法选择硬件或电路,那么你就必须通过编写代码来绕过它们的限制。
现在让我们尝试一些更有趣的东西。正如我之前提到的,ATmega328P-PU 有两个中断引脚。在下一个项目中,你将使用这两个引脚来让微控制器响应两个不同的中断。
项目 24:实验两个中断
该项目使用两个按钮,使你能够触发两个不同的中断并产生不同的响应。一个按钮触发上升沿中断,并将 LED 点亮一秒钟,另一个按钮触发下降沿中断,并将 LED 点亮两秒钟。
硬件
你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• ATmega328P-PU 微控制器
-
• 跳线
-
• 一只 LED
-
• 一个 560 Ω 电阻
-
• 两个按键开关
-
• 两个 10 kΩ 电阻
按照图 5-3 所示组装电路。

图 5-3:项目 24 的主电路
组装好电路后,将 USBasp 编程器通过无焊面包板连接到你的微控制器,和之前的项目一样。
代码
打开终端窗口,导航到本书第五章文件夹下的项目 24子文件夹,输入命令make flash。和前两个项目一样,一旦代码上传完成,LED 应该开始快速闪烁。按下连接到 INT0(数字引脚 4)的按钮,LED 应该保持亮起一秒钟。按下连接到 INT1(数字引脚 5)的按钮,LED 应该保持亮起两秒钟。
要查看代码如何处理这些中断,打开项目 24 的main.c文件:
// Project 24 - Experimenting with Two Interrupts
// PORTB blinks, INT0 rising interrupt, INT1 falling interrupt
#include <avr/io.h>
#include <util/delay.h>
#include <avr/interrupt.h>
ISR (INT0_vect)
{ // Code to be executed when interrupt INT0 is triggered
PORTB = 0b11111111;
_delay_ms(1000);
PORTB = 0b00000000;
EIFR &= ~(1<<0); // Set interrupt flag register for INT0 to zero
}
ISR (INT1_vect)
{ // Code to be executed when interrupt INT1 is triggered
PORTB = 0b11111111;
_delay_ms(2000);
PORTB = 0b00000000;
EIFR &= ~(1<<1); // Set interrupt flag register for INT1 to zero
}
void startInts()
{
// Initialize interrupt 0 (PD2/INT0/pin 4)
// Rising edge (LOW to HIGH at pin 4)
// Initialize interrupt 1 (PD3/INT1/pin 5)
// Falling edge (HIGH to LOW at pin 5)
❶ EICRA = 0b00001011;
// Turn on interrupts INT0 and INT1
❷ EIMSK = 0b00000011;
// Turn on global interrupt enable flag for interrupts to be processed
sei();
}
int main(void)
{
// Declare global variables
// Set up GPIO pins etc.
❸ DDRB = 0b11111111; // Set PORTB register as outputs
❹ DDRD = 0b00000000; // Set PORTD pins 4 and 5 as inputs
// Initialize interrupts
startInts();
for(;;)
❺ {
PORTB = 0b00000001;
_delay_ms(50);
PORTB = 0b00000000;
_delay_ms(50);
}
return 0;
}
这段代码类似于项目 22 和 23 的代码,只是做了一些小修改。和往常一样,我们首先定义一个 startInts() 函数来初始化中断。在这个函数内部,我们将 EICRA 寄存器设置为响应每个中断 ❶。记住,这个寄存器的设置公式是 0b0000 abcd,其中 a 和 b 对应引脚 5,c 和 d 对应引脚 6。在这里,我们将 EICRA 设置为使 INT0 响应上升沿中断,INT1 响应下降沿中断(0b00001011)。接下来,我们将 EIMSK 寄存器设置为通过将最后两位设置为 1 来打开 INT0 和 INT1 ❷,然后调用 sei() 来启用中断。
在代码的主要部分,我们将 PORTB 设置为输出,这样代码就可以控制 LED ❸。我们还将 PORTD 设置为输入,用于覆盖数字引脚 4 和 5,分别作为 INT0 和 INT1 的输入 ❹。一旦初始化完成,for 循环中的代码 ❺ 将导致 LED 闪烁开关。然而,当按下某个按钮时,相应的中断将被触发,中断的 ISR 中的代码将运行。
在规划具有多个中断的项目时,请记住一个中断不能被另一个中断调用。也就是说,如果一个中断的 ISR 代码正在运行,触发另一个中断不会影响该 ISR 的操作。你不能打断一个中断!
如果你需要使用超过两个中断引脚,或者不能使用数字引脚 4 和 5,但仍然需要你的项目响应状态变化触发器怎么办?解决方案是使用引脚变化中断。
引脚变化中断
虽然使用外部中断 INT0 和 INT1 简单、直接,并且提供了很多控制权,但仅使用两个中断是一个巨大的限制。使用引脚变化中断稍微复杂一点,但它可以提供与引脚数量一样多的中断。
引脚变化中断只能告诉你某个引脚的状态是否发生了变化——它们不能提供有关状态变化的任何细节。虽然外部中断可以检测到从低到高或从高到低的变化,但引脚变化中断只能检测到状态发生了变化。这意味着任何状态的变化都会触发中断,你需要在代码中决定是否要响应这个变化。
用于引脚变化中断的引脚被组织成三个区块:
Bank 0 包括 PCINT0 到 PCINT7。
Bank 1 包括 PCINT8 到 PCINT14。
Bank 2 包括 PCINT16 到 PCINT23。
PCINT 代表 引脚变化中断。每个 Bank 都有自己的 ISR 代码,给你提供了三种可以与各自引脚配合使用的电*变化类型。这为你提供了更多灵活性,但也意味着你不能假设每个 ISR 函数只对应一个引脚。实际上,每个 ISR 函数响应其对应 Bank 中任一引脚的状态变化。因此,你的代码不仅要判断状态变化是什么,还要确定它来自哪个引脚,然后才能做出相应响应。请注意,PCINT15 是不存在的。
请注意,PCINT 编号与引脚编号不同:例如,PCINT8 对应引脚 23,而不是引脚 8。请参考 图 5-4 中的引脚图来查看每个 PCINT 值对应的引脚编号,并确保在代码中不要混淆这两个编号。每个 Bank 的字节在二进制中表示了该 Bank 中引脚的顺序,从高到低排列。
你可以使用 ATmega328P-PU 的引脚图将三个 PCINT xx Bank 与物理引脚编号进行匹配,该引脚图如 图 5-4 所示。

图 5-4:ATmega328P-PU 引脚图
例如,Bank 0 响应于 PCINT0–PCINT7,这意味着你可以通过 图 5-4 右下角的 15–19 引脚和左下角的 9、10、14 引脚来触发它。
在代码中使用引脚变化中断类似于使用外部中断。首先,仍然需要包含中断库。
#include <avr/interrupt.h>
接下来,设置 PCICR 寄存器来启用所需的 PCI(引脚变化中断)Bank,使用以下公式:
PCICR = 0b00000
`xyz`
;
Bank 0 使用位 z 设置,Bank 1 使用位 y 设置,Bank 2 使用位 x 设置。例如,要启用 Bank 0 和 Bank 2,你可以使用:
PCICR = 0b00000101;
记住,每个 Bank 都可以响应多个引脚,因此你需要选择每个 Bank 中哪些引脚可以用于中断,通过启用对应于你选择的每个引脚的中断功能来实现。每个 Bank 都有自己的中断功能,你可以通过 PCMSK0、PCMSK1 和 PCMSK2 寄存器来启用它们。引脚的开启或关闭通过该引脚对应位置上的 1 或 0 来控制。要使用 Bank 0 的引脚 15、Bank 1 的引脚 23 和 Bank 2 的引脚 13,你需要像这样设置 PCMSK x 寄存器:
PCMSK0 = 0b00000010; // We'll use PCINT1 (pin 15) for bank 0 ...
PCMSK1 = 0b00000001; // and use PCINT8 (pin 23) for bank 1 ...
PCMSK2 = 0b10000000; // and use PCINT23 (pin 13) for bank 2
一旦你选择了每个 Bank 中应触发中断的引脚,使用以下代码行来启用中断:
sei();
最后,定义你的 ISR。每个 PCI 都有自己的 ISR,结构如下:
ISR (PCINT
`x`
_vect)
{
// Code to be executed when interrupt is triggered
EIFR &= ~(1<<
`x`
); // Set interrupt flag register to zero
}
将 PCINT x _vect 中的 x 替换为 Bank 0 的 0、Bank 1 的 1 和 Bank 2 的 2,然后你就可以开始添加中断代码了。我们通常以以下命令结束这部分 ISR 代码:
PCIFR =
`y`
;
这将把引脚变更中断标志寄存器重置为零,告知微控制器该特定银行的中断代码已完成,并且可以正常返回主循环中运行的代码。你可以使用以下y的值来设置引脚变更中断标志:0b00000001表示银行 0,0b00000010表示银行 1,0b00000100表示银行 2。
你将在接下来的项目中测试引脚变更中断。
项目 25:实验引脚变更中断
本项目在之前的基础上进行了扩展,使用三个按钮,并与每个 PCI 银行的一个引脚配合,演示如何使用引脚变更中断。按下每个按钮应触发不同的中断,使 LED 点亮一段时间。
硬件
你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊接面包板
-
• ATmega328P-PU 微控制器
-
• 跳线
-
• 一个 LED
-
• 一个 560 Ω电阻
-
• 三个按键
-
• 三个 10 kΩ电阻
首先,组装图 5-5 中所示的电路。

图 5-5: 项目 25 的主要电路
组装好电路后,像之前的项目一样,将 USBasp 通过无焊接面包板连接到微控制器。
代码
打开终端窗口,导航到本书第五章文件夹中的项目 25子文件夹,输入命令make flash上传项目的代码,如往常一样。代码上传完成后,LED 应该开始快速闪烁。按下不同的按钮应该使 LED 亮起一秒、两秒或三秒,具体时间由每个引脚变更中断银行的 ISR 定义。
要查看这如何工作,打开项目 25 的main.c文件:
// Project 25 - Experimenting with Pin-Change Interrupts
#include <avr/io.h>
#include <util/delay.h>
#include <avr/interrupt.h>
ISR (PCINT0_vect)
{ // Code to be executed when PCI bank 0 PCINT1 pin 15 is triggered
PORTB = 0b11111111;
_delay_ms(1000);
PORTB = 0b00000000;
PCIFR = 0b00000001;
}
ISR (PCINT1_vect)
{ // Code to be executed when PCI bank 1 PCINT8 pin 23 is triggered
PORTB = 0b11111111;
_delay_ms(2000);
PORTB = 0b00000000;
PCIFR = 0b00000010;
}
ISR (PCINT2_vect)
{ // Code to be executed when PCI bank 2 PCINT23 pin 13 is triggered
PORTB = 0b11111111;
_delay_ms(3000);
PORTB = 0b00000000;
PCIFR = 0b00000100;
}
void startInts()
{
❶ PCICR = 0b00000111; // Activate all three PCIs
PCMSK0 = 0b00000010; // We'll use PCINT1 (pin 15) for bank 0 ...
PCMSK1 = 0b00000001; // and use PCINT8 (pin 23) for bank 1 ...
PCMSK2 = 0b10000000; // and use PCINT23 (pin 13) for bank 2
sei();
}
int main(void)
{
// Set up GPIO pins etc.
❷ DDRB = 0b11111101; // Set up PORTB register (pin 15 input, rest outputs)
DDRC = 0b00000000; // Set up PORTC register (all inputs)
DDRD = 0b01111111; // Set up PORTD register (pin 13 input, rest outputs)
// Initialize interrupts
startInts();
for(;;)
{
// Blink LED connected to PB7 (pin 10)
❸ PORTB = 0b00000001;
_delay_ms(50);
PORTB = 0b00000000;
_delay_ms(50);
}
return 0;
}
该代码的结构与外部中断项目的代码相同。首先,我们定义一个startInts()函数来初始化中断。在此函数内,我们设置 PCICR 寄存器以启用所有三个 PCI 银行 ❶;然后,我们设置物理引脚,作为每个银行的中断引脚,使用以下三行代码,并调用sei()启用中断。
在代码的主部分,我们设置 PORTB 和 PORTD,使 LED 引脚为输出,PORTC 为中断引脚的输入 ❷。初始化完成后,for循环 ❸中的代码将使 LED 闪烁。然而,当你按下其中一个按钮时,会触发对应 PCI 银行的中断,运行该银行匹配的 ISR。我们通过将 PCIFR 标志设置为 1 来结束每个中断的代码运行。
到目前为止,你已经学会了如何使用中断与数字输入配合,在用户需要时激活代码。为了完成本章的实验,我将向你展示如何在更实际的情况下使用中断。
项目 26:使用中断创建上下计数器
本项目结合了你在第四章的项目 18 中学到的关于中断的知识,并通过 USART 向计算机发送数据。你将构建一个计数设备,使用两个按钮来接受用户输入:一个按钮将计数值增加 1,另一个按钮将其减少。每个按钮都会触发上升沿中断,并调用一个匹配的 ISR 来增加或减少计数器的值。
硬件
你需要以下硬件:
-
• USBasp 程序烧录器
-
• 无焊面包板
-
• ATmega328P-PU 微控制器
-
• USB 转串口转换器
-
• 跳线
-
• 两个按钮
-
• 两个 10 kΩ 电阻
按照图 5-6 所示组装电路。

图 5-6: 项目 26 的原理图
将电路组装好后,将 USBasp 通过无焊面包板连接到你的微控制器,方式与之前的项目相同。接下来,将你的 USB 转串口转换器连接到计算机,像在第四章中一样操作。
代码
打开一个终端窗口,导航到本书第五章文件夹下的项目 26子文件夹,并输入命令make flash,像往常一样上传项目代码。接下来,像在第四章中一样运行终端软件,并点击连接按钮。片刻之后,终端软件应该会显示计数变量的值。
尝试按下每个按钮。每按一下按钮,计数值应该会增加或减少,输出结果应与图 5-7 中的示例类似。

图 5-7:我们计数器的实际运行情况
在这个图中,计数器并不总是看起来只增加或减少 1。这是因为计数值每秒更新一次,而你可以在一秒钟内多次按下按钮。
要查看如何实现这一点,请打开项目 26 的main.c文件:
// Project 26 - Creating an Up/Down Counter Using Interrupts
#include <avr/io.h>
#include <stdlib.h>
#include <util/delay.h>
#include <avr/interrupt.h>
#define USART_BAUDRATE 4800
#define UBRR_VALUE 12
❶ volatile uint8_t i = 100; // Initial value for counter
void USARTInit(void)
{
// Set baud rate registers
UBRR0H = (uint8_t)(UBRR_VALUE>>8);
UBRR0L = (uint8_t)UBRR_VALUE;
// Set data frame format to 8 data bits, no parity, 1 stop bit
UCSR0C |= (1<<UCSZ01)|(1<<UCSZ00);
// Enable transmission and reception
UCSR0B |= (1<<RXEN0)|(1<<TXEN0);
}
void USARTSendByte(uint8_t u8Data)
{
// Wait while previous byte is sent
while(!(UCSR0A&(1<<UDRE0))){};
// Transmit data
UDR0 = u8Data;
}
void sendString(char myString[])
{
uint8_t a = 0;
while (myString[a])
{
USARTSendByte(myString[a]);
a++;
}
}
❷ ISR (INT0_vect)
{ // Code to be executed when interrupt INT0 is triggered
i = i - 1; // Subtract one from the counter
EIFR &= ~(1<<0); // Set interrupt flag register for INT0 to zero
}
❸ ISR (INT1_vect)
{ // Code to be executed when interrupt INT1 is triggered
i = i + 1; // Add one to the counter
EIFR &= ~(1<<1); // Set interrupt flag register for INT1 to zero
}
❹ void startInts()
{
// Initialize interrupt 0 (PD2/INT0/pin 4)
// Rising edge (LOW to HIGH at pin 4)
// Initialize interrupt 1 (PD3/INT1/pin 5)
// Rising edge (LOW to HIGH at pin 5)
EICRA = 0b00001111;
// Turn on interrupts INT0 and INT1
EIMSK = 0b00000011;
// Turn on global interrupt enable flag
sei();
}
int main(void)
{
char a[10] = "Count - "; // Make sure you have " instead of ”
char s[10] = ""; // For our itoa() conversion used in the main loop
char newline[] = "\r\n";
// Set up pins 4 and 5 as inputs for INT0 and INT1
DDRD = 0b00000000;
// Initialize interrupts
startInts();
// Initialize USART
USARTInit();
for(;;)
{
// Send the value of our counter to the USART for display on the PC
itoa(i, s, 10);
sendString(a);
sendString(s);
sendString(newline);
_delay_ms(1000);
}
return 0;
}
本项目演示了如何使用中断接收用户输入,所需的代码比在主代码的每个周期中检查按钮按下要少得多。首先,我们设置了库的初始化和使用 USART 所需的函数。接着我们声明了i变量❶,它存储计数器的值,后面是上一个章节中用于通过 USART 将文本和数字发送到 PC 的方便函数,详见上一章。
主代码和 ISR 函数都需要能够访问i变量,这就是为什么它被定义在int main(void)部分之外的原因。将变量声明在主代码之外使其成为全局变量,这意味着代码的任何部分都可以访问它,而不仅仅是声明变量的特定函数中的代码。当你声明一个全局变量时,应该在其数据类型前加上volatile关键字,告诉编译器该变量可能随时变化,因此每次程序使用它时,微控制器需要从内存中重新加载它。
本项目使用引脚 4 和 5 作为外部上升沿中断,因此接下来我们定义了在 INT0❷和 INT1❸中断触发时执行的代码,并使用 EICRA 和 EIMSK❹初始化它们。一旦主代码开始运行,它应该每秒通过 USART 将计数器变量的值发送到 PC。由于中断的强大功能(以及连接到它们的按钮),每次触发其中一个中断时,相关的 ISR 代码应该根据按下的按钮将计数器变量加或减去 1。你可以通过快速按下和松开任一按钮来测试这一点;随着变量的变化,更新的值应显示在终端中。
关于中断的最终说明
使用硬件中断可以为基于 AVR 的项目提供更多的选项,以便按需完成任务,而不是在预先编程的事件序列中完成。为了简化这对中断的介绍,本章重点介绍了创建响应按钮按下的电路。然而,在实际项目中,你更常见的是编程中断来响应机械设备中的限位开关或传感器发出的信号,以帮助你的项目做出决策。
使用中断时,始终将用于触发中断的引脚声明为输入,使用DDR x函数,否则微控制器将无法检测到触发。同时,将主代码和 ISR 中使用的任何变量声明为volatile,并使它们成为全局变量。
下一章的项目扩展了中断的应用,向你展示了如何使用中断在预设的时间段后运行函数。
第六章:# 使用硬件定时器

我们使用定时器来确定在某个动作发生前需要经过多长时间,这非常有用。例如,你可以设置一个中断,当定时器达到某个值时触发。定时器在后台运行;当微控制器执行代码时,定时器在不断计数。
在本章中,你将学习:
-
• 你在 ATmega328P-PU 微控制器中的各种定时器
-
• 定时器溢出中断
-
• 比较匹配中断时清除定时器
我将向你展示如何定期运行部分代码,为重复的操作创建更长的延迟,并检查内部定时器的准确性。你还将学习一种更高效的方法,来处理寄存器内的单个位。
定时器简介
我们的 AVR 微控制器都有多个定时器,每个定时器包含一个递增的计数变量,其值存储在计数寄存器 中。一旦计数器达到最大值,寄存器中的一个位会改变,计数器会重置为零并重新开始。除了使用定时器触发中断外,你还可以利用它们通过一些巧妙的算术计算来测量经过的时间,基于递增变量的进度。
ATmega328P-PU 有三个定时器——TIMER0、TIMER1 和 TIMER2——每个定时器都有自己的计数寄存器。TIMER0 和 TIMER2 是 8 位计数器,最大值为 255。TIMER1 是 16 位计数器,最大值为 65,535。ATtiny85 也有定时器,但由于 ATmega 具有更多的 I/O 引脚,功能更强大,本章我们只讨论 ATmega。
定时器需要一个 时钟源 来准确计数时间周期。时钟源是一个振荡电路,其输出在精确的频率下在高低之间变化。你可以使用内部时钟源或外部时钟源。在本章中,我们将使用微控制器的内部时钟源,稍后的章节中我会向你展示如何在需要时使用外部时钟源。
到目前为止,我们的微控制器以 1 MHz 的速度运行,你可以使用它们的内部时钟源来驱动定时器。我们通过这个简单的公式来确定定时器计数器每次增量之间的时间间隔:
T = 1 / f
其中 f 是频率(Hz),T 是时间(秒)。例如,我们计算 1 MHz 时的周期为 T = 1 / 1,000,000,结果是百万分之一秒,也就是一微秒。
你可以通过使用 预分频器 来调整周期的长度,预分频器是一个数字,用来将频率除以,从而增加周期时间。当你需要测量超出定时器默认时长的时间时,可以使用预分频器。提供五种预分频器:1、8、64、256 和 1,024。
要计算通过预分频器调整的周期,我们使用以下公式:
T = 1 / (1,000,000 / p )
其中T是秒数,p是预分频器的值。然后我们可以确定在给定寄存器重置之前的时间长度。例如,为了确定 TIMER1 重置前经过的时间长度,你需要将所选预分频器的T值与 TIMER1 计数器的最大值(65,535)相乘。如果你的预分频器是 8,那么每个周期的时间为 0.00008 秒,因此你需要将 65,535 乘以 0.00008,得到 0.52428 秒。这意味着 TIMER1 将在 0.52428 秒后重置。
我已经为你计算了 TIMER1 计数器的值,方便你参考;它们列在表 6-1 中。
| 表 6-1:在 1 MHz 下 TCCR1B 寄存器的预分频器值及其周期时间 |
|---|
| 预分频器类型 |
| --- |
| /1 (无) |
| /8 |
| /64 |
| /256 |
| /1024 |
现在先不讨论更多理论。在接下来的项目中,我们将使用计时器来加深你的理解。
项目 27:实验计时器溢出和中断
在我们的第一个计时器演示中,你将学习如何在计时器计数器溢出时触发一个中断服务程序(ISR),使用的是 TIMER1。你还将通过实验使用预分频器来改变计数器重置前的时间长度。
硬件部分
对于这个项目,你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊接面包板
-
• ATmega328P-PU 微控制器
-
• 跳线电缆
-
• 两个 LED
-
• 两个 560 Ω电阻
按照图 6-1 中的示意图组装电路。

图 6-1:项目 27 的电路图
当你组装好电路后,将 USBasp 通过无焊接面包板连接到你的微控制器,方法与之前的项目相同。完成后,保持电路连接,因为你将会在下一个项目中继续使用它。
代码部分
打开终端窗口,导航到本书第六章文件夹中的项目 27子文件夹,并输入命令make flash。工具链应编译程序文件并像往常一样将数据上传到微控制器。此时,连接到 PB0 的 LED 应该快速闪烁,连接到 PB1 的 LED 应该大约每半秒(精确来说是每 0.52428 秒)快速闪烁一次。
让我们看看这是如何工作的。打开main.c文件以查看项目 27:
// Project 27 - Experimenting with Timer Overflows and Interrupts
#include <avr/io.h>
❶ #include <avr/interrupt.h>
#include <util/delay.h>
❷ ISR(TIMER1_OVF_vect)
{
// Code to be executed when an interrupt is triggered from TIMER1 overflow.
// For this example, quickly blink LED on PB1.
PORTB = 0b00000010;
_delay_ms(5);
PORTB = 0b00000000;
}
void initOVI()
// Set up overflow interrupt and TIMER1
{
❸ TCCR1B = 0b00000010; // Set CS10 and CS11 for /8 prescaler
❹ TIMSK1 = 0b00000001; // Turn on TIMER1 interrupt on overflow
❺ sei(); // Turn on global interrupts
}
int main(void)
{
DDRB = 0b11111111; // Set PORTB register as outputs
initOVI(); // Set up overflow interrupt and TIMER1
for(;;) // Do something (such as blink LED on PB0)
{
PORTB = 0b00000001;
_delay_ms(100);
PORTB = 0b00000000;
_delay_ms(100);
}
return 0;
}
这段代码包含了 initOVI() 函数来初始化 TIMER1 以供使用。首先,我们包含中断的库 ❶ 并定义定时器操作 ❷——这是定时器重置时执行的代码。然后,我们通过设置 TCCR1B 寄存器的第二位 ❸ 将预分频器设置为 8。这会导致 TIMER1 寄存器每 0.52428 秒重置一次。接着,我们将 TIMSK1 寄存器的第 1 位设置为 1 ❹,以便每当 TIMER1 计数器溢出并重置时调用中断,从而初始化我们之前定义的定时器操作,并调用 sei() 启用中断 ❺。
一旦开始运行,LED 应按照 int main(void) 中的指示闪烁,且 TIMER1 计数器将以 125 kHz 的速度递增(记住,我们的时钟速度是 1 MHz,且我们使用了 8 的预分频器),因此每次计数增量需要 0.000008 秒。由于每次计数的时间如此短,计数从 0 到 65,535 仅需 0.52428 秒,此时 TIMER1 计数器溢出,代码会调用中断代码 ❷,短暂闪烁另一个 LED。TIMER1 会重置为零并重新开始计数。
尽管这段代码通过 TCCR1B 寄存器将预分频器设置为 8,你也可以通过设置寄存器的第 2 位、1 位和 0 位,选择其他预分频器,具体值见表 6-1。花些时间用你的项目 27 硬件更改 TCCR1B 寄存器的位,实验一下这对定时的影响。
在下一个项目中,我将向你展示如何定期、规律地运行一段代码。
项目 28:使用 CTC 定时器进行重复动作
比较匹配清零模式(CTC) 是一种不同的计时方法,它在定时器计数器达到某个特定值时调用 ISR,然后将定时器重置为零并重新开始计数。CTC 定时模式在你需要定期运行一段代码时非常有用。
在这个项目中,你将学习如何在计数器达到 15 秒时触发 ISR,再次使用 TIMER1。为了确定持续时间值,首先需要根据表 6-1 中的值计算每秒钟定时器经过的周期数。我们将使用 1,024 的预分频器(如果需要更长的持续时间,你可以选择适当的预分频器)。这样我们就得到 14,648 个周期(向下取整),然后加上 1 以考虑定时器重置回零所需的时间。我们的代码现在应该检查 TIMER1 的计数器值。一旦计数器达到 14,649,代码就会调用 ISR,然后将计数器重置为零。
对于这个项目,使用与项目 27 相同的硬件。电路组装好后,通过无焊接面包板以与前一个项目相同的方式将 USBasp 连接到你的微控制器。完成后,保持电路完整,以便你能在下一个项目中使用它。
打开终端窗口,导航到本书第六章文件夹中的项目 28子文件夹,然后输入命令make flash。一旦项目的代码上传到微控制器,连接到 PB0 的 LED 应该开始快速闪烁,连接到 PB1 的 LED 每 15 秒短暂开关一次。
让我们看看这个是如何工作的。打开项目 28 的main.c文件:
// Project 28 - Using a CTC Timer for Repetitive Actions
#include <avr/io.h>
#include <avr/interrupt.h>
#include <util/delay.h>
❶ ISR(TIMER1_COMPA_vect)
{
// Code to be executed when an interrupt is triggered from TIMER1 overflow.
// For this example, quickly blink LED on PB1.
❷ TCNT1 = 0;
PORTB = 0b00000010;
_delay_ms(10);
PORTB = 0b00000000;
// Reset TIMER1 to zero, so counting can start again.
TCNT1 = 0;
}
❸ void initCTC()
// Set up CTC interrupt and TIMER1
{
❹ OCR1A = 14649; // Number of periods to watch for: 14,649
// Turn on CTC mode and set CS12 and CS10 for /1024 prescaler
❺ TCCR1B = 0b00000101;
❻ TIMSK1 = 0b00000010; // Turn on timer compare interrupt
sei(); // Turn on global interrupts
}
int main(void)
{
DDRB = 0b11111111; // Set PORTB register as outputs
initCTC(); // Set up overflow interrupt and TIMER1
for(;;) // Do something (such as blink LED on PB0)
{
PORTB = 0b00000001;
_delay_ms(100);
PORTB = 0b00000000;
_delay_ms(100);
}
return 0;
}
这段代码包括一个initCTC()函数❸,我们用它来设置定时器。我们通过将 OCR1A 设置为14649❹,告诉代码当定时器达到 14649 时运行 ISR。然后我们将预分频器设置为 1,024❺,并打开定时器比较中断功能❻。
主代码首先运行initCTC()函数,然后开始愉快地闪烁连接到 PB0 的 LED。一旦 TIMER1 计数器达到 14649(即我们的 15 秒标记),ISR 代码❶将会运行。在 ISR 内部,代码首先将 TIMER1 重置为零❷,然后闪烁连接到 PB1 的 LED。
现在你应该理解如何在设定时间后执行 ISR。尝试使用不同的预分频器和数值进行练习,接着我们将继续使用 CTC 来实现更长的延迟时间。
项目 29:使用 CTC 定时器进行带有更长延迟的重复动作
有时候,你可能想要设置一个比项目 28 中更长时间的重复事件——例如,每 15 分钟而不是每 15 秒。由于 OCR1A 寄存器的大小(65,535),我们不能直接输入一个非常大的数字来计数到长时间并指望 CTC 定时器工作,所以我们需要使用一个小的变通方法。我们按照项目 28 的设置配置一个 CTC 定时器,每秒触发一次 ISR。然后我们计算这些秒数,当所需的延迟时间过去时,我们调用一个函数来执行所需的代码。
更详细地说,为了设置更长的周期以进行重复事件,我们做如下操作:
-
1. 使用一个全局变量来存储我们希望用于周期的目标延迟值(以秒为单位)。
-
2. 使用另一个全局变量来存储延迟中经过的秒数。
-
3. 将 CTC 定时器设置为监控 1 秒的持续时间。
-
4. 每秒调用一次 ISR,使其将已过秒数变量加 1,然后检查是否达到了目标延迟值——如果达到了,则执行所需的代码。
使用这种方法,你可以实现一种变体的多任务处理,如下一个项目中所示。
使用与 项目 28 相同的硬件。组装好电路后,通过无焊面包板将 USBasp 连接到微控制器,就像你在之前的项目中所做的那样。
接下来,打开一个终端窗口,导航到本书 第六章 文件夹下的 项目 29 子文件夹,并输入命令 make flash。工具链应编译程序文件并将数据上传到微控制器。此时,连接到 PB0 的 LED 应该快速闪烁,连接到 PB1 的 LED 每秒短暂地开关一次。
让我们看看这是如何工作的。打开 项目 29 的 main.c 文件:
// Project 29 - Using CTC Timers for Repetitive Actions with Longer Delays
#include <avr/io.h>
#include <avr/interrupt.h>
#include <util/delay.h>
❶ uint16_t target = 1;
// Interval in seconds between running function "targetFunction"
❷ uint16_t targetCount = 0;
// Used to track number of seconds for CTC counter resets
❸ void targetFunction()
{
// Do something once target duration has elapsed
PORTB = 0b00000010;
_delay_ms(100);
PORTB = 0b00000000;
}
ISR(TIMER1_COMPA_vect)
{ // Code to be executed when an interrupt is triggered from CTC
❹ targetCount++; // Add one to targetCount
❺ if (targetCount == target)
// If required period of time has elapsed
{
❻ TCNT1 = 0; // Reset TIMER1 to zero
❼ targetFunction(); // Do something
❽ targetCount = 0; // Reset targetCount to zero
}
}
void initCTC()
// Set up CTC interrupt and TIMER1
{
OCR1A = 15625; // Number of periods to watch for - 15625 = 1 second
// Turn on CTC mode and set CS10 and CS11 for /64 prescaler
TCCR1B = 0b00000011;
TIMSK1 = 0b00000010; // Turn on timer compare interrupt
sei(); // Turn on global interrupts
}
int main(void)
{
DDRB = 0b11111111; // Set PORTB register as outputs
initCTC(); // Set up overflow interrupt and TIMER1
for(;;) // Do something (blink LED on PB0)
{
PORTB = 0b00000001;
_delay_ms(100);
PORTB = 0b00000000;
_delay_ms(100);
}
return 0;
}
在这一阶段,您应该已经对大部分代码非常熟悉,例如 ISR() 函数,但也有一些新的组件。首先,有两个新的全局变量,uint16_t target ❶ 和 uint16_t targetCount ❷。我们将 target 设置为运行所需代码时等待的秒数 ❸。在此示例中,target 设置为 1,但您可以将其设置为任何数值,最大可设置为 32,767(约 546.116 分钟),因为这是 16 位整数所能存储的最大值。
ISR 使用变量 targetCount 来累计经过的秒数,因为每当代码调用 ISR(每秒一次)时,它会将 targetCount 增加 1 ❹。当代码调用 ISR 时,它会检查 targetCount 是否与 target 相匹配 ❺。如果匹配,代码会将 TIMER1 重置为零 ❻,然后通过函数 targetFunction() 执行所需的代码 ❼,最后将 targetCount 重置为零 ❽,使得该过程可以重新开始。
虽然我们的示例每秒运行一次 targetFunction() 代码,但请记住,您可以通过更改 target 的值轻松增加时间间隔。例如,要每 5 分钟运行一次 targetFunction(),将 target 设置为 300(5 分钟 × 60 秒 = 300 秒)。
现在您已经有机会尝试 AVR 的 ATmega 定时器,我想简要讨论一下内部定时器的准确性。
检查内部定时器的准确性
您可能会想知道内部定时器能多准确地保持时间。您可以通过运行 项目 29 代码(每秒一次)并使用数字存储示波器测量结果,轻松验证这一点,正如 图 6-2 所示。

图 6-2:使用数字存储示波器测量 项目 29 的输出
连接在 PB0 上的闪烁 LED 连接到示波器的第 1 通道(上信号),而targetFunction()控制的 PB2 上的 LED 连接到第 2 通道(下信号)。你应该能看到信号随着 PB0 上的 LED 每 100 毫秒开启和关闭而上升和下降。第一个 LED 的信号保持低电*,而控制第二个 LED(在 PB1 上)的代码仍在运行,因为微控制器无法同时操作两个任务。在 1 秒钟后,连接在 PB0 上的另一个 LED 会按照targetFunction()的指示开启和关闭,整个过程会重复。
在这个例子中,示波器测得第二个 LED 的频率为 1.018 Hz,即每秒 1.018 次——这非常接*所需的 1 秒。考虑到我们没有在电路中使用任何外部定时硬件,这是一个不错的结果。然而,如果你希望运行更长时间的延迟,你需要考虑这种微小的变化。例如,从 1 秒的 0.018 Hz 偏差可能在 5 分钟内积累成 5.4 秒(5.4 秒的实际时间是通过将 0.018 Hz 乘以 300 秒计算出来的)。在未来的定时项目中,请记住这一点。
通过位运算进行寄存器寻址
从本书开始,我们一直使用二进制数字格式寻址各种寄存器。例如,使用 ATmega328P-PU 时,我们使用以下两行代码将 PB0 引脚设置为输出并打开它:
DDRB = 0b11111111; // Set PORTB to outputs
. . .
PORTB = 0b00000001; // Set PB0 on
这种方法到目前为止效果良好,但它要求我们每次寻址一个或多个位时,都必须考虑寄存器中的每一位。在本节中,我将向你展示如何使用位运算,位运算允许我们仅更改寄存器中的特定位(或多个位),而保持其他位不变。这对于书中的未来项目以及以后的项目都非常有用,因为它让你能够轻松设置单个位或多个位,而不必担心一次更改寄存器中的所有位。
在寄存器中寻址单个位
默认情况下,当我们重置或开启微控制器时,寄存器中的所有位都被设置为 0(低电*或关闭)。然后,我们根据需要将位设置为 1(高电*或开启)或 0,使用以下操作:
将一个位设置为高
-
使用以下代码将一个位设置为高电*(开启),方法是将其设置为 1:
`registername` |= (1 << `bitname` );例如,要打开 PORTB 的 PB7 输出,可以使用以下代码行,因为 7 是与 PB7 引脚相匹配的 PORTB 寄存器中的位号:
PORTB |= (1 << 7);这使你能够在不需要关注寄存器中其他位状态的情况下打开 PB7。
-
在高电*和低电*之间切换位
-
切换一个位是指将其从当前状态更改为另一个状态(从关闭变为开启,或反之)。可以使用以下代码来实现:
`registername` ^= (1 << `bitname` ); // Toggle bit "bitname"例如,要切换 PORTB 的 PB3 的输出,可以使用以下代码,因为 PORTB 寄存器中与 PB3 引脚对应的位编号是 3:
PORTB ^= (1 << 3); // Toggle bit PB3为了演示这一点,你可以通过在代码的
for (;;)循环中使用以下两行代码,在 PB3 上连接的 LED 以 250 毫秒的延迟进行闪烁:PORTB ^= (1 << 3); // Toggle PB3 _delay_ms(250); // Wait 250 milliseconds使用位运算,你还可以节省空间,因为 LED 闪烁的示例只需要两行代码,而不是四行。
-
将位设置为低电*
-
使用以下代码将某一位设置为低电*(关闭),即将其设置为 0:
`registername` &= ~(1 << `bitname` ); // Set bit "bitname" to 0例如,要关闭 PORTB 的 PB7 输出,你可以使用以下代码,因为 7 是 PORTB 寄存器中与 PB7 引脚对应的位编号:
PORTB &= ~(1 << 7);
以这种方式使用位运算比我们之前用来处理寄存器位的二进制数字格式更高效。不过,你还可以进一步改进这种方法,通过使用寄存器位的名称而不是数字,这样可以更容易地确定正在更改哪个位。例如,假设你要设置 TIMSK1 的位 0,以启用 TIMER1 溢出中断。你可以使用TIMSK1 |= (1 << TOIE1),而不是使用TIMSK1 = 0b00000001。
要确定使用哪个寄存器位名来对应位编号(例如,在之前的示例中使用TOIE1而非0b00000001),请查阅你的微控制器的数据手册。如果你还没有下载,可以从 Microchip 官网以 Adobe PDF 格式下载完整的数据手册:
ATtiny85 数据手册 www.microchip.com/wwwproducts/en/ATtiny85/
ATmega 数据手册 www.microchip.com/wwwproducts/en/ATmega328p/
然后你可以学习位名称,以便匹配给定的寄存器,例如 TIMSK1 寄存器,它出现在 ATmega328P-PU 数据手册的 16.11.8 节中,如图 6-3 所示。

图 6-3:ATmega328P-PU TIMSK1 寄存器
现在你可以访问寄存器中的单个位,接下来我将向你展示如何在不影响其他位的情况下,访问同一寄存器中的两个或更多位。
在寄存器中操作多个位
你还可以同时更改一个寄存器中的多个位(同样,无需担心你没有改变的位),通过位运算来实现。不过需要注意的是,你必须对所有位执行相同的操作——例如,你可以在一行中将三个位设置为高电*,但不能在一行中将两个位设置为高电*,一个设置为低电*。如果你需要后者,请改用二进制数字方法。下面是如何操作:
将多个位设置为高电*
-
要同时打开两个位,可以使用以下代码:
`registername` |= (1 << `bitname` )|(1 << `bitname` );例如,要同时打开 PORTB 的 PB0 和 PB7 的输出,你可以使用:
PORTB |= (1 << PORTB7)|(1 << PORTB0);你可以在同一行中添加额外的寻址。例如,你可以如下所示开启 PB0、PB3 和 PB7:
PORTB |= (1 << PORTB7)|(1 << PORTB3)|(1 << PORTB0);你可以使用这种方法来寻址最多七个位。如果你想更改所有位,那么就使用常规的
PORTx函数。 -
在高电*和低电*之间切换多个位
-
要同时切换多个位的高低,可以使用以下代码:
`registername` ^= (1 << `bitname` )|(1 << `bitname` );例如,要切换 PORTB 的 PB0 和 PB3 的输出,可以使用:
PORTB ^= (1 << PORTB3)|(1 << PORTB0);同样,你可以在同一行中添加额外的寻址。例如,要切换 PB0、PB3 和 PB7,你可以使用:
PORTB ^= (1 << PORTB7)|(1 << PORTB3)|(1 << PORTB0); -
将多个位设为低电*
-
要一次性关闭多个位,可以使用以下代码。
`registername` &= ~(1 << `bitname` )&~(1 << `bitname` );在这种情况下,我们在括号操作之间使用
&~字符,而不是管道符(|)字符。例如,要关闭 PORTB 的 PB7 和 PB0 的输出,可以使用:PORTB &= ~(1 << PORTB7)&~(1 << PORTB0);要同时关闭 PB0、PB3 和 PB7,你可以使用:
PORTB &= ~(1 << PORTB7)&~(1 << PORTB3)&~(1 << PORTB0);
现在我们已经回顾了所有这些用于寻址寄存器的位操作,让我们看看如何利用它们来改进我们之前的代码。
项目 30:使用位操作实验溢出定时器
这个项目的结果与项目 27 相同,但我已经重写了代码,利用位操作来寻址寄存器。
使用与项目 27 相同的硬件。组装好电路后,将 USBasp 通过无焊接面包板连接到你的微控制器。打开一个终端窗口,进入本书第六章文件夹中的项目 30子文件夹,并输入命令make flash。上传代码到微控制器后,连接到 PB0 的 LED 应该开始快速闪烁,而连接到 PB1 的 LED 则会每 0.52428 秒快速闪烁一次。
要查看更新后的代码如何工作,打开项目 30 中的main.c文件:
// Project 30 - Experimenting with Overflow Timers Using Bitwise Operations
#include <avr/io.h>
#include <avr/interrupt.h>
#include <util/delay.h>
ISR(TIMER1_OVF_vect)
{
// Code to be executed when an interrupt is triggered from TIMER1 overflow.
// For this example, quickly blink LED on PB1.
❶ PORTB |= (1 << PORTB1); // PB1 on
_delay_ms(5);
❷ PORTB &= ~(1 << PORTB1); // PB1 off
}
void initOVI()
// Set up overflow interrupt and TIMER1
{
❸ TCCR1B |= (1 << CS11); // Set prescaler to /8
❹ TIMSK1 |= (1 << TOIE1); // Enable TIMER1 overflow interrupts
sei(); // Turn on global interrupts
}
int main(void)
{
❺ DDRB |= (1 << DDB0)|(1 << DDB1); // Set PORTB pins 0 and 1 as outputs
initOVI(); // Set up overflow interrupt and TIMER1
for(;;) // Do something (blink LED on PB0)
{
❻ PORTB ^=(1 << PORTB0); // Toggle PB0
_delay_ms(100);
}
return 0;
}
在这段代码中,当代码调用 ISR ❶❷时,LED 会依次闪烁。当设置预分频器时,我们只需要将CS11设置为 1 ❸,因为CS10保持为 0,表示预分频器为 8,这一点可以从表 6-1 中回忆起。(记住,位的默认值是 0。)
我们通过将 TIMSK1 寄存器的TOIE1位设置为 1 ❹来启用 TIMER1 溢出中断,然后将 PORTB 的 PB0 和 PB1 引脚设置为输出,以控制我们的 LED ❺。最后,我们反转 PB0 的状态,以便闪烁 LED ❻。
我鼓励你花些时间熟悉到目前为止在本书中使用的寄存器——PORTB、DDRB 等,并通过之前的项目进行实验,熟悉寄存器的位操作方法。这些方法在下一章中会非常有用,因为我们将开始使用脉宽调制来实验 LED、马达等。
第七章:# 使用脉宽调制

当你需要一个数字输出模拟模拟信号时,例如让 LED 以部分亮度工作,你可以使用脉宽调制(PWM)来调整数字输出引脚之间高低信号的时间间隔。PWM 可以生成多种效果,例如调节 LED 亮度、控制电机转速,并通过将电能转化为振动的工具产生声音。
在本章中,你将:
-
• 学习 PWM 的工作原理以及 AVR 如何生成 PWM 信号。
-
• 使用 ATtiny85 和 ATmega328P-PU 微控制器进行 PWM。
-
• 使用 PWM 通过压电元件制作不同音调的声音。
-
• 学习如何使用 PWM 通过 RGB LED 创建多彩的效果。
脉宽调制与占空比
PWM 允许我们控制 LED 的感知亮度,而不是像之前章节中那样仅仅开关 LED。LED 的亮度由占空比决定,即 PORT x 引脚开启(意味着 LED 点亮)与关闭(LED 熄灭)之间的时间长度。占空比表示“开启”时间的百分比。占空比越大——即 PORT x 引脚在每个周期中保持开启的时间相较于关闭的时间越长——连接到该引脚的 LED 的感知亮度就越高。
此外,PWM 信号的频率越高——即信号开启和关闭的速度越快——视觉效果就越*滑。如果你在控制一个电机,更高的 PWM 频率会使电机的转速更接*实际所需的速度。
图 7-1 展示了四种可能的 PWM 占空比。填充的灰色区域表示 LED 点亮的时间;如你所见,随着占空比的增加,点亮时间也增加。

图 7-1:各种 PWM 占空比
我们只能在 AVR 微控制器上使用某些引脚来实现 PWM。对于 ATtiny85,我们使用 PB0、PB1 和 PB4;对于 ATmega328P-PU,我们使用 PB1 到 PB3 以及 PD3、PD5 和 PD6。为了生成 PWM 信号,我们需要根据所使用的微控制器设置所需的寄存器。我将在本章中展示这两款微控制器的操作。让我们从 ATtiny85 开始。
项目 31:使用 ATtiny85 演示 PWM
在这个项目中,你将学习如何触发 ATtiny85 微控制器提供的 PWM 输出。我们触发每个输出的方式略有不同,但过程总是很简单。
硬件
在这个项目中,你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊接面包板
-
• ATtiny85 微控制器
-
• 跳线
-
• 三个 LED
-
• 三个 560 Ω电阻
按照 图 7-2 所示组装电路。

图 7-2:项目 31 的原理图
组装好电路后,像往常一样将 USBasp 连接到你的微控制器。
代码
打开终端窗口,导航到本书第七章文件夹下的项目 31子文件夹,然后像往常一样输入命令make flash。一旦项目的代码上传到微控制器,连接到 PB4、PB1 和 PB0 的 LED 应该同时开始渐变开关,重复显示亮度逐渐增高后逐渐降低的效果。
让我们看看这是如何工作的。打开 项目 31 的 main.c 文件。
// Project 31 - Demonstrating PWM with the ATtiny85
#include <avr/io.h>
#include <util/delay.h>
void initPWM(void)
{
// Set PB4, PB1, PB0 as outputs
❶ DDRB |= (1 << PORTB4)|(1 << PORTB1)|(1 << PORTB0);
❷ // PB0
// Set timer mode to FAST PWM
TCCR0A |= (1 << WGM01)|(1 << WGM00);
// Connect PWM signal to pin (OC0A => PB0)
TCCR0A |= (1 << COM0A1);
// No prescaler
TCCR0B |= (1 << CS00);
❸ // PB1
// Connect PWM signal to pin (OC0B => PB0)
TCCR0A |= (1 << COM0B1);
❹ // PB4
// Connect PWM signal to pin (OCR0B => PB4)
TCCR1 |= (1 << PWM1A)|(1 << COM1A0);
// Toggle PB4 when timer reaches OCR1B (target)
GTCCR |= (1 << COM1B0);
// Clear PB4 when timer reaches OCR1C (top)
GTCCR |= (1 << PWM1B);
// No prescaler
TCCR1 |= (1 << CS10);
}
int main(void)
{
uint8_t duty = 0;
initPWM();
while (1)
{
❺ for (duty = 1; duty <100; duty++)
{
OCR0A = duty; // PB0
OCR0B = duty; // PB1
OCR1B = duty; // PB4
delay_ms(10);
}
❻ for (duty = 100; duty >0; --duty)
{
OCR0A = duty; // PB0
OCR0B = duty; // PB1
OCR1B = duty; // PB4
_delay_ms(10);
}
}
}
这段代码定义了函数 initPWM()。该函数同时操作每个引脚,但我们将逐一讲解如何初始化和操作每个引脚。
我们首先将所需的引脚 PORTB0、PORTB1 和 PORTB4 设置为输出 ❶。接下来,逐一操作所需的三个寄存器,以在 PORTB0 上启用 PWM。为了将定时器设置为快速 PWM 模式,我们将定时器信号分配给引脚 PORTB0——请注意,我们不使用预分频器,因此 PWM 可以以其最大频率运行 ❷。我们只需操作一个寄存器以允许在 PORTB1 上启用 PWM ❸,但在 PORTB4 上使用 PWM 需要一个不同的定时器,因此我们必须操作不同的寄存器 ❹。
现在是时候为 PWM 引脚分配值,以设置它们的占空比了。微控制器需要一个介于 1 和 254 之间的值,这将映射到一个从 0 到接* 100 的占空比。(如果使用 0,这就是 0 百分比占空比——也就是说,引脚将关闭。如果使用 255,这就是 100 百分比占空比,所以引脚将持续开启。)
三个寄存器存储我们三个 PWM 引脚的占空比值:
-
• OCR0A 用于 PORTB0
-
• OCR0B 用于 PORTB1
-
• OCR1B 用于 PORTB4
接下来,我们添加一个简单的循环,使占空比值逐渐增加,随着时间推移,LED 的亮度逐渐增大 ❺。然后通过另一个循环,逐渐减少 LED 的亮度 ❻。
尝试调整 _delay_ms() 函数中的值,以改变亮度变化的速度。你可能会注意到在较高的占空比值之间,亮度几乎没有变化。这是因为在高频 PWM 操作中(每秒 50 次周期以上),LED 的闪烁速度过快,普通人眼无法察觉其关闭状态。
ATtiny85 的独立 PWM 引脚控制
现在你已经使用 ATtiny85 组装并测试了所有的 PWM 引脚,是时候学习如何使用每个 PWM 引脚,以便将它们应用到你自己的项目中了。
要同时激活所有 PWM 引脚,只需使用在项目 31 中使用的initPWM()函数。(稍后你将学习如何停用它们。)要单独激活和停用每个引脚,请按照以下列表中的说明操作:
激活 ATtiny85 引脚 PORTB0 的 PWM
-
要激活 PORTB0 上的 PWM,请使用以下代码:
DDRB |= (1 << PORTB0); // Set PB0 as output TCCR0A |= (1 << WGM01)|(1 << WGM00); // Set timer mode to FAST PWM // Connect PWM signal to pin (OC0A => PB0) TCCR0A |= (1 << COM0A1); TCCR0B |= (1 << CS00); // No prescaler然后,你可以通过将一个介于 1 和 254 之间的值分配给 OCR0A 寄存器来设置占空比。
-
激活 ATtiny85 引脚 PORTB1 的 PWM
-
要激活 PORTB1 上的 PWM,请使用以下代码:
DDRB |= (1 << PORTB1); // Set PB1 as output TCCR0A |= (1 << WGM01)|(1 << WGM00); // Set timer mode to FAST PWM // Connect PWM signal to pin (OC0B => PB1) TCCR0A |= (1 << COM0B1); TCCR0B |= (1 << CS00); // No prescaler然后,你可以通过将一个介于 1 和 254 之间的值分配给 OCR0B 寄存器来设置占空比。
-
激活 ATtiny85 引脚 PORTB4 的 PWM
-
要激活 PORTB4 上的 PWM,请使用以下代码:
DDRB |= (1 << PORTB4); // Set PB4 as output // Connect PWM signal to pin (OCR0B => PB4) TCCR1 |= (1 << PWM1A)|(1 << COM1A0); // Toggle PB4 when timer reaches OCR1B (target) GTCCR |= (1 << COM1B0); // Clear PB4 when timer reaches OCR1C (top) GTCCR |= (1 << PWM1B); TCCR1 |= (1 << CS10); // No prescaler然后,你可以通过将一个介于 1 和 254 之间的值分配给 OCR1B 寄存器来设置占空比。
-
停用 ATtiny85 PWM
-
如果你的项目需要同时使用某个引脚进行 PWM 和开关输出操作,那么在使用
PORTx|=命令之前,你必须停用 PWM 模式。你需要定义initPWM()和disablePWM()函数来在需要时开启和关闭 PWM。使用以下代码停用所有引脚的 PWM(PORTB0、PORTB1 和 PORTB4):TCCR0A &= ~(1 << WGM01)&~(1 << WGM00); // Turn off fast PWM for PORTB0/1 TCCR0A &= ~(1 << COM0A1); // Disconnect PWM from PORTB0 TCCR0A &= ~(1 << COM0B1); // Disconnect PWM from PORTB1 TCCR1 &= ~(1 << PWM1A)&~(1 << COM1A0); // Turn off PWM for PORTB4 // Disconnect PWM from PORTB4 off timer/counter TCCR1 &= ~(1 << CS10); GTCCR &= ~(1 << PWM1B); // Disable PWM for PORTB4 GTCCR &= ~(1 << COM1B0); // Disconnect PWM from PORTB4
一般来说,将设置 PWM 所需的这些代码行放在一个独立的函数中是个好主意,正如项目 31 中所示。现在我们已经回顾了在 ATtiny85 上使用 PWM 的基本方法,让我们用压电元件来玩点噪音。
项目 32:实验压电元件与 PWM
压电元件是一种将电荷转换为不同形式能量的设备。它可以将电能转换为物理运动,表现为振动,从而产生我们可以听到的声波。通过施加电流并使用 PWM 调节它,你可以改变压电元件的音调。对于这个项目,你可以使用一个小型的预接线压电元件,就像图 7-3 中显示的那样。

图 7-3:一个预接线的 27 毫米压电元件
图 7-4 显示了我们压电元件的电路符号。

图 7-4:压电元件的电路符号
在这个项目中,你将学习通过调整微调电位器来改变压电元件的声音音调。我们将使用一个 ADC 来读取电位器的值,然后用这个值来确定 PWM 控制压电元件的占空比。
硬件
对于这个项目,你需要以下硬件:
-
• USBasp 编程器
-
• 无焊接面包板
-
• ATtiny85 微控制器
-
• 跳线
-
• 预接线压电元件
-
• 10 kΩ 面包板兼容线性微调电位器
按照图 7-5 中的示意图组装电路。

图 7-5:用于 Project 32 的原理图
在组装好电路后,像以往项目一样,通过无焊接面包板将 USBasp 连接到您的微控制器。
代码
打开终端窗口,导航到本书第七章文件夹下的Project 32子文件夹,并输入命令make flash。一旦代码上传到微控制器,缓慢地转动电位器的不同方向,调整蜂鸣器的音调。
要查看此如何工作,请打开 Project 32 的main.c文件:
// Project 32 - Experimenting with Piezo and PWM
#include <avr/io.h>
#include <util/delay.h>
void startADC()
❶ // Set up the ADC
{
ADMUX |= (1 << ADLAR)|(1 << MUX1);
ADCSRA |= (1 << ADEN)|(1 << ADPS1)|(1 << ADPS0);
}
❷ void initPWM(void)
{
DDRB |= (1 << PORTB0); // Set PB0 as output
TCCR0A |= (1 << WGM01)|(1 << WGM00);
TCCR0A |= (1 << COM0A1);
TCCR0B |= (1 << CS00);
}
int main(void)
{
❸ startADC();
❹ initPWM();
for(;;)
{
❺ ADCSRA |= (1 << ADSC); // Start ADC measurement
while (ADCSRA & (1 << ADSC) ); // Wait until conversion completes
_delay_ms(5);
❻ OCR0A = ADCH; // Set PWM duty cycle with ADC value
}
return 0;
}
这段代码回顾了 Project 31 和本章中其他 PWM 示例的内容。它实现了设置占空比的目标,因为代码将 ADC 寄存器的 8 位值放入 PWM 寄存器 OCR0A 中。
该代码初始化了 ADC,并使用引脚 PORTB4 作为输入❶。然后它初始化了 PB0 上的 PWM 输出❷,如 Project 31 所示,启动了 ADC❸,并初始化了 PWM❹。接下来,它读取模拟输入❺,最后将 ADC 值(介于 0 到 255 之间)分配给 PWM 占空比寄存器,从而驱动蜂鸣器❻。
ATmega328P-PU 的单个 PWM 引脚控制
现在是时候介绍可用于 ATmega328P-PU 的 PWM 功能了。表 7-1 列出了六个可与 PWM 一起使用的 ATmega328P-PU 引脚。
| 表 7-1:ATmega328P-PU PWM 引脚 |
|---|
| 端口寄存器位 |
| --- |
| PORTB1 |
| PORTB2 |
| PORTB3 |
| PORTD3 |
| PORTD5 |
| PORTD6 |
让我们来看看如何激活(和停用)这些引脚以用于 PWM。
激活 ATmega328P-PU 引脚 PORTD5/6 上的 PWM
-
要激活 PORTD5/6 上的 PWM,请使用以下方法:
TCCR0A |= (1 << WGM01)|(1 << WGM00); TCCR0B |= (1 << CS01);然后,您可以将 PWM 输出连接到引脚 PORTD5/6,如下所示:
TCCR0A |= (1 << COM0A1); // PWM to OCR0A - PD6 TCCR0A |= (1 << COM0B1); // PWM to OCR0B - PD5通过为占空比寄存器分配 1 到 254 之间的值来设置占空比。如果您想直接控制引脚,使用它们作为常规输入或输出,则需要将它们从 PWM 输出中断开。您可以按如下方式进行操作:
TCCR0A &= ~(1 << COM0A1); // Disconnect PWM from OCR0A - PD6 TCCR0A &= ~(1 << COM0B1); // Disconnect PWM from OCR0B - PD5
激活 ATmega328P-PU 引脚 PORTB1/2 上的 PWM
-
要激活 PORTB1/2 上的 PWM,请使用以下方法:
TCCR1A |= (1 << WGM10); TCCR1B |= (1 << WGM12); TCCR1B |= (1 << CS11);然后,您可以将 PWM 输出连接到引脚 PORTB1/2,如下所示:
TCCR1A |= (1 << COM1A1); // PWM to OCR1A - PB1 TCCR1A |= (1 << COM1B1); // PWM to OCR1B - PB2要断开引脚与 PWM 的连接,请使用以下方法:
TCCR1A &= ~(1 << COM1A1); // Disconnect PWM from OCR1A - PB1 TCCR1A &= ~(1 << COM1B1); // Disconnect PWM from OCR1B - PB2
激活 ATmega328P-PU 引脚 PORTB3 和 PORTD3 上的 PWM
-
要激活 PORTB3 和 PORTD3 上的 PWM,请使用以下方法:
TCCR2A |= (1 << WGM20); TCCR2A |= (1 << WGM21); TCCR2B |= (1 << CS21);然后,您可以将 PWM 输出连接到引脚 PORTB3 和 PORTD3,如下所示:
TCCR2A |= (1 << COM2A1); // PWM to OCR2A - PB3 TCCR2A |= (1 << COM2B1); // PWM to OCR2B - PD3要断开引脚与 PWM 的连接,请使用以下方法:
TCCR2A &= ~(1 << COM2A1); // Disconnect PWM from OCR2A - PB3 TCCR2A &= ~(1 << COM2B1); // Disconnect PWM from OCR2B - PD3
请记住,只有在首先运行激活代码之后,您才能根据需要连接或断开引脚与 PWM 的连接。
你已经看过本章中使用 PWM 的几种方法,接下来我将再给你一个例子:使用 RGB LED 生成颜色。
RGB LED
RGB LED 实际上是由三个 LED 元件组成——一个红色,一个绿色,一个蓝色——这些元件封装在同一个外壳内,如图 7-6 所示。这些 LED 非常适合节省空间,并且你还可以通过调整单独元件的亮度来创造自己的颜色。RGB LED 有多种尺寸可选;一个常见的选择是直径为 10 毫米。

图 7-6:典型 RGB LED
如你所见,RGB LED 有四根引脚。与第三章讨论的单色 LED 一样,这些 LED 有两种类型:共阳极和共阴极。在共阳极配置中,所有三个 LED 的阳极是连接在一起的,而阴极是分开的。共阴极配置则是三个独立的阳极,而三个阴极连接在一起。图 7-7 展示了两者的电路图。

图 7-7:RGB LED 的电路符号:共阳极(左)和共阴极(右)
每个 LED 的引脚配置可能不同,所以请向你的供应商确认。然而,如果你找不到相关信息,最长的引脚通常是共阳极或共阴极引脚。你可以选择透明(LED 外壳透明)或漫射(LED 外壳模糊)RGB LED。我推荐选择后者,因为漫射 LED 在混合其三个基本颜色元件时效果更好,从而产生色彩组合。
本书中的项目,从下一个开始,我们将使用共阴极 RGB LED。
项目 33:实验 RGB LED 和 PWM
这个项目允许你通过调整 PWM 造成的亮度变化,混合 RGB LED 中的两种基本颜色来生成各种颜色。图 7-8 中的图示展示了哪些颜色组合会产生特定的色调。

图 7-8:红色、绿色和蓝色按不同组合混合的结果
例如,混合红色和绿色光将产生黄色光芒。通过增加一种颜色的亮度,同时减少另一种颜色的亮度,你可以逐渐得到多种黄色的色调。这个项目仅混合了两种颜色,但如果你愿意,也可以同时混合三种颜色,创造出白色光。在这个项目中,你将使用 ATmega328P-PU 微控制器;它的引脚数量比 ATtiny85 多,所以你可以用它来操作 RGB LED,同时还可以保留引脚用于其他用途。
硬件
对于这个项目,你需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• ATmega328P-PU 微控制器
-
• 跳线
-
• 一颗扩散型共阴极 RGB LED
-
• 三个 560 Ω 电阻
按照 图 7-9 所示组装电路。

图 7-9: 项目 33 的电路图
打开终端窗口,导航到本书 第七章 文件夹中的 Project 33 子文件夹,并输入命令 make flash。上传代码到微控制器后,你的 RGB LED 应该会开始发光,并且颜色不断变化。(如果你的 LED 透明,可以通过将一些白纸覆盖在 LED 上轻松扩散输出,从而获得更好的效果。)
要查看如何实现这一点,打开 main.c 文件,查看 项目 33:
// Project 33 - Experimenting with RGB LEDs and PWM
#include <avr/io.h>
#include <util/delay.h>
❶ #define wait 10
❷ void initPWM(void)
{
// Timers 1A and 1B
TCCR1A |= (1 << WGM10); // Fast PWM mode
TCCR1B |= (1 << WGM12); // Fast PWM mode
TCCR1B |= (1 << CS11);
TCCR1A |= (1 << COM1A1); // PWM to OCR1A - PB1
TCCR1A |= (1 << COM1B1); // PWM to OCR1B - PB2
// Timer 2
TCCR2A |= (1 << WGM20); // Fast PWM mode
TCCR2A |= (1 << WGM21); // Fast PWM mode
TCCR2B |= (1 << CS21);
TCCR2A |= (1 << COM2A1); // PWM to OCR2A - PB3
}
❸ void PWMblue(uint8_t duty)
// Blue LED is on PB1
{
OCR1A = duty;
}
❹ void PWMred(uint8_t duty)
// Red LED is on PB3
{
OCR2A = duty;
}
❺ void PWMgreen(uint8_t duty)
// Green LED is on PB2
{
OCR1B = duty;
}
int main(void)
{
// Set PORTB1, PORTB2, and PORTB3 as outputs
❻ DDRB |= (1 << PORTB1)|(1 << PORTB2)|(1 << PORTB3);
initPWM();
uint8_t a;
while(1)
{
// Red to green
❼ for (a=1; a<255; a++)
{
PWMred(255-a);
PWMgreen(a);
_delay_ms(wait);
}
// Green to blue
❽ for (a=1; a<255; a++)
{
PWMgreen(255-a);
PWMblue(a);
_delay_ms(wait);
}
// Blue to red
❾ for (a=1; a<255; a++)
{
PWMblue(255-a);
PWMred(a);
_delay_ms(wait);
}
}
}
我们从常规的 initPWM() 函数开始,它将 PORTB1、PORTB2 和 PORTB3 引脚设置为 PWM 输出❷。接着是三个简单的函数,每个 LED 一个❸❹❺,它们传递所需的占空比,这样在需要时就可以轻松控制 LED 中的每种基本颜色。
主要代码将 PORTB1、PORTB2 和 PORTB3 设置为输出引脚,然后初始化 PWM 输出❻。最后,为了混合颜色,我们使用之前定义的三个函数,通过让其中一种颜色在高亮度下启动,另一种颜色在低亮度下启动,然后逐渐增加和减少两种颜色的亮度,依次调整它们的亮度❼❽❾。你可以通过修改 wait 的值来调整颜色过渡的速度❶。
我希望你在使用压电元件制作声音效果和使用 RGB LED 制作光效时感到愉快。你才刚刚开始:PWM 有很多其他用途,包括控制机器人和电风扇的电机,以及学习如何使用 MOSFET 来控制更大的电流。我们将在 下一章 中探索这些内容。
第八章:# 使用 MOSFET 控制电机

AVR 无法直接控制电机。为了实现这一点,我们需要使用外部元件:金属氧化物半导体场效应晶体管(MOSFET),这是一种能够在电路中切换或放大电压的晶体管。
在本章中,你将学习如何:
-
• 使用 PWM 和 MOSFET 控制直流电机。
-
• 使用 MOSFET 控制较大的电流。
-
• 使用电机驱动 IC 将较大的电机与 AVR 微控制器连接。
在此过程中,你将制作一个温控风扇和一个两轮驱动的机器人车辆,基于之前的知识完成更有趣和复杂的项目。到本章结束时,你将掌握将 MOSFET 应用于你自己的项目的技能,无论是为了娱乐还是更严肃的应用,如机器人技术、自动化或玩具。
MOSFET
当我们需要使用小信号(例如来自微控制器数字输出引脚的信号)控制较大电流和电压时,我们使用 MOSFET。MOSFET 有多种不同的尺寸,例如在图 8-1 中所示,以适应不同项目的需求。

图 8-1:各种 MOSFET
我们将使用图 8-1 左下角所示的小型 2N7000 版本,它有三个引脚。当你面对 2N7000 的*面面时,从左到右分别是源极、门极和漏极引脚(我稍后会解释它们的功能)。
图 8-2 显示了 2N7000 MOSFET 的电路符号。

图 8-2:2N7000 MOSFET 的电路符号
操作 MOSFET 很容易。当你向门极引脚施加小电流时,大电流可以从漏极引脚流入并从源极引脚流出。你还可以将 PWM 信号连接到 MOSFET 的门极引脚,从而以不同方式控制灯光、电机等。这正是我们在本章中要关注的内容。
我们的 2N7000 MOSFET 可以持续承受最高 60 V DC 电压,200 mA 电流,或 500 mA 的脉冲电流。在为你的项目选择 MOSFET 时,务必检查其最大电压和电流是否适配你要切换的信号。
每次使用 MOSFET 时,我们都会在 MOSFET 的门极和源极引脚之间连接一个 10 kΩ 电阻,正如你在接下来的项目中看到的那样。这个电阻可以确保当没有电流施加到门极时,门极保持关闭状态,类似于电阻拉低按钮,如第三章所示;它可以防止 MOSFET 随机地轻微开关。
项目 34:使用 PWM 和 MOSFET 控制直流电机
本项目展示了如何使用 PWM 和 MOSFET 控制一个小型直流电机。由于微控制器本身无法为电机提供足够的电流,因此我们使用外部电源和 MOSFET 来处理电机的需求。
硬件
对于这个项目,你需要以下硬件:
-
• USBasp 编程器
-
• 无焊接面包板
-
• ATmega328P-PU 微控制器
-
• 跳线
-
• 小型直流电机和匹配电源
-
• 2N7000 MOSFET
-
• 10 kΩ电阻
像图 8-3 中展示的那种小型直流电机,最大支持 12V 直流电压,足以满足需求。

图 8-3:小型直流电机
你还需要外部电源,比如一个包含多个 AA 电池的电池组。像图 8-4 中展示的 6 个 AA 电池组,提供最多 9V 直流电压,足以顺利驱动 12V 直流电机。

图 8-4:AA 电池组
按照图 8-5 中的示意图组装电路。注意,电池组的黑色/负极引线应连接到 GND。

图 8-5:项目 34 的电路图
完成使用后,不要拆卸电路,因为你将在下一个项目中继续使用该电路的一部分。
代码
打开终端窗口,导航到本书第八章文件夹中的项目 34子文件夹,并输入命令make flash。直流电机应从 0 开始,逐渐加速至完全开启状态,然后降低速度至完全关闭状态,再重复这一过程。
让我们看看这个是如何工作的:
// Project 34 - DC Motor Control with PWM and MOSFET
#include <avr/io.h>
#include <util/delay.h>
❶ #define wait 10
❷ void initPWM(void)
{
// Timers 1A and 1B
TCCR1A |= (1 << WGM10); // Fast PWM mode
TCCR1B |= (1 << WGM12); // Fast PWM mode
TCCR1B |= (1 << CS11);
}
void motorOn(void)
{
❸ TCCR1A &= ~(1 << COM1A1); // Disconnect PWM from PB1
PORTB |= (1 << PORTB1); // Set PB1 on
}
void motorOff(void)
{
❹ TCCR1A &= ~(1 << COM1A1); // Disconnect PWM from PB1
PORTB &= ~(1 << PORTB1); // Set PB1 off
}
void motorPWM(uint8_t duty)
{
❺ TCCR1A |= (1 << COM1A1); // Connect PWM to OCR1A—PB1
OCR1A = duty;
}
int main(void)
{
DDRB |= (1 << PORTB1); // Set PORTB pin 1 as output
❷ initPWM();
uint8_t a;
while(1)
{
motorOff(); // Motor off
_delay_ms(3000);
for (a = 1; a <255; a++) // Slowly increase motor speed
{
motorPWM(a);
_delay_ms(wait);
}
motorOn(); // Motor full on
_delay_ms(1000);
for (a = 254; a > 0;—a) // Slowly decrease motor speed
{
motorPWM(a);
_delay_ms(wait);
}
}
}
你现在应该已经熟悉本项目中使用的代码,因为你在第七章中已经学习过如何使用 PWM,但我们还是一起来回顾一下。首先,我们设置所需的寄存器来初始化 PWM 操作❷。为了简化控制,我们使用了三个函数:motorOn()、motorOff()和motorPWM()。motorOn()函数通过首先断开 PORTB1 与 PWM ❸的连接,然后将其设置为高电*,完全开启电机。这样,电机通过 MOSFET 始终获得 100%的电力。
我们使用motorOff()函数通过断开 PORTB1 与 PWM ❹的连接并将其设置为低电*,完全关闭电机。这样会关闭 MOSFET 的门极引脚,电机就没有电源了。同样,这也是必要的,因为你不能将 0%的占空比发送到 OCR1A 寄存器,并期望它始终保持关闭。即使占空比为 0%,每次硬件定时器重置时,输出也会在重置期间短暂开启。
最后,函数motorPWM()接受所需的占空比值,用于通过 PWM 设置电机速度。它将 PORTB1 连接到 PWM ❺,并将 OCR1A 寄存器加载所需的值。
我们的主代码会反复启动电机,将速度增加到 100%,然后再降低到 0%,最后将电机关闭 3 秒。我们在代码开始时关闭电机,以便用户在启动之前能提前有所准备。你可以通过更改wait ❶的值来调整 PWM 循环中的延迟时间。
现在你已经知道如何控制直流电机,让我们通过构建一个温控风扇系统,将这一技能应用到实际例子中。
项目 35:温控风扇
在这个项目中,你将把现有的电机控制知识与新学到的 MOSFET 技能结合起来,利用温度传感器制作一个温控风扇。
硬件部分
本项目需要以下硬件:
-
• USBasp 编程器
-
• 无焊接面包板
-
• ATmega328P-PU 微控制器
-
• 跳线
-
• 小型直流电机及其匹配电源
-
• 2N7000 MOSFET
-
• TMP36 温度传感器
-
• 0.1 μF 陶瓷电容
-
• 10 kΩ 电阻
你可以使用上一项目中的小型直流电机来看看这个是如何工作的,或者你可以从电气零售商那里购买一个直流电机驱动的冷却风扇,例如图 8-6 所示的 PMD Way(部件号 59119182)。一些风扇可能有四根线,但其中只有两根是必需的(电源和 GND)。再次提醒,我们需要为风扇提供外部电源。

图 8-6 :直流冷却风扇
按照图 8-7 所示组装电路。

图 8-7 :项目 35 的电路图
在组装项目时,请注意电池包或风扇电源的黑色/负极导线将连接到 GND。同时,别忘了将 AV [CC]连接到 5V。
代码部分
打开一个终端窗口,导航到本书第八章文件夹中的项目 35子文件夹,并输入命令make flash。一旦接通电源,项目应该在等待三秒钟后根据当前温度读取温度并启动风扇。
为了了解其工作原理,查看以下代码:
// Project 35 - Temperature-Controlled Fan
#include <avr/io.h>
#include <util/delay.h>
void startADC()
{
ADMUX |= (1 << REFS0); // Use AVcc pin with ADC
ADMUX |= (1 << MUX2) | (1 << MUX0); // Use ADC5 (pin 28)
ADCSRA |= (1 << ADPS1) | (1 << ADPS0); // Prescaler for 1 MHz (/8)
ADCSRA |= (1 << ADEN); // Enable ADC
}
void initPWM(void)
{
// Timers 1A and 1B
TCCR1A |= (1 << WGM10); // Fast PWM mode
TCCR1B |= (1 << WGM12); // Fast PWM mode
TCCR1B |= (1 << CS11);
}
void motorOff(void)
{
TCCR1A &= ~(1 << COM1A1); // Disconnect PWM from PB1
PORTB &= ~(1 << PORTB1); // Set PB1 off
}
void motorOn(void)
{
TCCR1A &= ~(1 << COM1A1); // Disconnect PWM from PB1
PORTB |= (1 << PORTB1); // Set PB1 on
}
void motorPWM(uint8_t duty)
{
TCCR1A |= (1 << COM1A1); // Connect PWM to OCR1A—PB1
OCR1A = duty;
}
int main(void)
{
DDRB |= (1 << PORTB1); // Set PORTB pin 1 as output
❶ startADC();
initPWM();
❷ uint8_t ADCvalue;
float voltage;
float temperature;
// Delay motor action for a few moments on start
❸ _delay_ms(3000);
while(1)
{
// Get reading from TMP36 via ADC
❹ ADCSRA |= (1 << ADSC); // Start ADC measurement
while (ADCSRA & (1 << ADSC) ); // Wait until conversion complete
_delay_ms(10);
// Get value from ADC register, convert to 8-bit value
ADCvalue = ADC >> 2;
// Convert reading to temperature value (Celsius)
voltage = (ADCvalue * (5000 / 256));
❺ temperature = (voltage—500) / 10;
// Now you have a temperature value, take action
❻ if (temperature<25)
{
// Under 25 degrees, turn motor off
motorOff();
}
❼ else if ((temperature>=25) & (temperature <35))
{
// At or above 25 and below 35 degrees, set motor to 50% PWM
motorPWM(127);
}
❽ else if (temperature>=35)
{
// 35 degrees and over, turn motor full on
motorOn();
}
❾ _delay_ms(500); // Prevent rapid motor speed changes
}
}
这段代码基于第四章中项目 19 的 ADC 和温度传感器,以及项目 34 中使用的 PWM 电机控制。首先,我们激活 ADC 来读取 TMP36 温度传感器,并激活 PWM 以实现可调速的电机控制❶(startADC()和initPWM()函数在程序开头定义)。接下来,我们引入用于计算温控器温度所需的变量❷,然后在启动时加入延迟,以防电机在重置或开机后立即启动❸。
在主循环中,我们从 ADC 获取值❹,并将其转换为摄氏度❺。此时,代码可以使用这个温度值来判断是否需要启动电机。在本项目中,如果温度低于 25 度,电机将关闭❻;如果温度在 25 到 34 度之间(含 34 度),风扇以半速运行❼;如果温度达到 35 度或更高,风扇以全速运行❽。
最后,在检查温度后,会有一个短暂的延迟❾,以避免滞后效应——即电路特性快速变化。例如,如果传感器处于气流或飘动的窗帘附*,温度可能会在 24.99 和 25 度之间迅速波动,导致电机持续开关。这个延迟可以帮助我们避免这种情况。
在这一点上,我希望你已经开始看到我们如何将基本的 AVR 代码和工具结合起来,解决新的问题。通过建立在先前知识的基础上,我们已经开始超越前几章的简单项目,向更复杂、更实用的应用迈进。
现在我们已经使用 MOSFET 进行了基本的电机控制实验,接下来我们将继续控制直流电机的旋转方向和速度。为此,我们将使用 L293D 电机驱动 IC。
L293D 电机驱动 IC
为了控制一个或两个小型直流电机的速度和方向,我们将使用 STMicroelectronics 的 L293D 电机驱动 IC,如图 8-8 所示。它与微控制器的封装类型相同,因此我们可以轻松地在无焊接面包板上进行实验。

图 8-8:L293D 电机驱动 IC
你可以使用像 L293D 这样的电机驱动 IC,用于机器人或小型玩具,这些设备的工作电压范围从 4.5 到 36 V 直流电,电流最大可达 600 mA,但在热量方面有一些限制,我稍后会解释。L293D 能为你节省大量时间,因为它负责将电力分配给电机,免去了你构建外部电路的麻烦。它被称为H 型桥 IC,因为它内部有一组 MOSFET 和其他组件,按字母 H 的形状配置,如图 8-9 所示。

图 8-9:L293D IC 框图
幸运的是,我们不需要自己构建 L293D IC 的电路;它已经设置好并准备好了,只需要我们连接电机、控制逻辑和电源。接下来,我们只需连接电机、电源、GND 和来自微控制器的输出。要了解如何将 L293D 接线到一个直流电机,请查看图 8-10 中的引脚图。

图 8-10:L293D IC 引脚图
有四个 GND 引脚:4、5、12 和 13。将它们连接到 GND。接下来,找到两个电源引脚。将第一个电源引脚——V [CC] 1(逻辑电源引脚)连接到 5V,就像我们在之前的项目中为微控制器所做的那样。然后将第二个电源引脚 V [CC] 2 连接到电机电源的正极(最高可达 36V 直流电)。最后,连接电机:一根线连接到引脚 3,另一根连接到引脚 6。
控制电机需要从我们的微控制器的数字输出引脚获取三个信号。首先,我们设置 ENABLE 引脚:要么设置为高电*,以便驱动 IC 向电机提供电力,要么设置为低电*,使电机停止。接下来两个引脚,1A 和 2A,控制电机电力的极性,从而控制电机的旋转方向。当 ENABLE 设置为高电*时,电机将在 1A 为高、2A 为低时朝一个方向旋转,在 1A 为低、2A 为高时朝另一个方向旋转。表格 8-1 总结了这些信息,方便参考。
| 表格 8-1:L293D 单电机控制 |
|---|
| ENABLE 引脚/EN1(引脚 1) |
| --- |
| 高 |
| 高 |
| 低 |
从外观上无法判断电机是正转还是反转;你需要进行测试运行来确定 1A/2A 两种组合在你的电机上分别对应哪种方向。你可以通过向 ENABLE 引脚施加 PWM 信号来改变电机的转速。
关于热量的几点说明
L293D 在接*其最大负载运行时可能会变热(甚至发烫)。在这种情况下不应在无焊接面包板上使用它,因为四个 GND 引脚也用作散热片。这意味着它们可能会融化引脚周围的塑料,导致 L293D 卡在面包板中。如果你要控制较大的电机,可以使用自己的 PCB 来构建电路,使用电机控制的扩展板,或者将电路焊接到条形板上。
现在你已经熟悉了 L293D 的理论,接下来我们在下一个项目中将它付诸实践。
项目 36:使用 L293D 控制直流电机
本项目演示了如何使用 PWM 和 L293D 电机驱动芯片控制一个小型直流电机,使电机可以在两个方向上以不同的速度运转。这将使你获得在下一个项目中构建你的第一个运动机器人所需的所有技能。
硬件
对于这个项目,你需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• ATmega328P-PU 微控制器
-
• 跳线
-
• 小型直流电机及匹配电源
-
• L293D 电机驱动芯片
使用与你在项目 34 中使用的相同的直流电机和匹配电源。按照图 8-11 所示组装电路。

图 8-11: 项目 36 的电路图
在组装电路时,再次将电池组或外部电源的黑色/负极线连接到 GND。
代码
打开终端窗口,进入本书 第八章 文件夹中的 项目 36 子文件夹,输入命令 make flash 。一旦你接通电源,项目将等待三秒钟,然后依次以两个不同的速度正反向操作电机。
让我们看看它是如何工作的。
// Project 36 - DC Motor Control with L293D
#include <avr/io.h>
#include <util/delay.h>
❶ void initPWM(void)
{
TCCR2A |= (1 << WGM20); // Fast PWM mode
TCCR2A |= (1 << WGM21); // Fast PWM mode, part 2
TCCR2B |= (1 << CS21); // PWM Freq = F_CPU/8/256
}
❷ void motorForward(uint8_t duty)
{
// Set direction
❸ PORTB |= (1 << PORTB1); // PB1 HIGH
PORTB &= ~(1 << PORTB2); // PB2 LOW
// Set speed
❹ if (duty == 255)
{
PORTB |= (1 << PORTB3); // Set PORTB3 to on
} else if (duty < 255)
{
❺ TCCR2A |= (1 << COM2A1); // PWM output on OCR2A—PB3
OCR2A = duty; // Set PORTB3 to PWM value
}
}
❻ void motorBackward(uint8_t duty)
{
// Set direction
PORTB &= ~(1 << PORTB1); // PB1 LOW
PORTB |= (1 << PORTB2); // PB2 HIGH
// Set speed
if (duty == 255)
{
PORTB |= (1 << PORTB3); // Set PORTB3 to on
} else if (duty < 255)
{
TCCR2A |= (1 << COM2A1); // PWM output on OCR2A—PB3
OCR2A = duty; // Set PORTB3 to PWM value
}
}
❼ void motorOff(void)
{
// Disconnect PWM output from OCR2A—PB3
TCCR2A &= ~(1 << COM2A1);
// Set ENABLE to zero for brake
PORTB &= ~(1 << PORTB3);
}
int main(void)
{
// Set PORTB3, 2, and 1 as outputs
DDRB |= (1 << PORTB3)|(1 << PORTB2)|(1 << PORTB1);
❽ initPWM();
_delay_ms(3000); // Wait a moment before starting
while(1)
{
❾ motorForward(64);
_delay_ms(2000);
motorOff();
_delay_ms(2000);
motorForward(255);
_delay_ms(2000);
motorOff();
_delay_ms(2000);
motorBackward(64);
_delay_ms(2000);
motorOff();
_delay_ms(2000);
motorBackward(255);
_delay_ms(2000);
motorOff();
_delay_ms(2000);
}
}
这段代码是本章前面电机控制项目的基础,加入了 L293D 所需的部分。我们在点 ❶ 和 ❽ 设置了 PWM。第一个电机控制函数motorForward() ❷使电机朝一个方向旋转,并接受一个 1 到 255 之间的占空比值。根据表 8-1,我们将输出设置为高低电*以实现电机的方向控制 ❸。然后,代码会检查所需的占空比是否为 255 ❹,如果是,则将 ENABLE 引脚设置为高电*,直接以全速运转电机,而不是使用 PWM。然而,如果占空比小于 255,则会为控制 L293D ENABLE 引脚的输出引脚启用 PWM ❺,并将所需的占空比值写入 OCR2A。
motorForward() 中使用的电机控制方法在函数motorBackward() ❻ 中得以重复,不过此时用于电机控制的输出被设置为低高电*,以实现反向旋转。最后,motorOff() 函数 ❼ 通过首先禁用控制 L293D ENABLE 引脚的输出引脚的 PWM,然后将其设置为低电*,从而关闭电机。完成所有这些后,你现在可以使用电机控制函数来控制电机旋转的速度和方向,如代码主循环中所示 ❾。
现在你已经知道如何控制直流电机的速度和方向,让我们使用两个电机来控制一个小型机器人。
项目 37:控制一个双轮驱动机器人
在本项目中,您将学习如何控制一辆小型的两轮驱动机器人。建议的硬件包括两个直流电动机和一个万向轮(一个小型旋转轮子,固定在机器人底部),使您能够轻松控制行驶速度和方向。希望这能激发您创造自己更复杂的机器人作品!
硬件
本项目所需的硬件如下:
-
• USBasp 编程器
-
• 无焊面包板
-
• ATmega328P-PU 微控制器
-
• 跳线
-
• 两个小型直流电动机及匹配电源
-
• 2WD 机器人底盘(例如 PMD Way 零件编号 72341119)
-
• 四节 AA 电池
-
• 1N4004 电源二极管
-
• L293D 电机驱动 IC
底盘
任何机器人车辆的基础是一个坚固的底盘,包含电动机、驱动系统和电源。市场上有许多底盘模型可供选择。为了简化,本项目使用了一个便宜的机器人底盘,配备了两个约为 6 V DC 的小型直流电动机和两个匹配的车轮,如图 8-12 所示。

图 8-12 :两轮驱动机器人底盘(PMD Way 零件编号 72341119)
物理组装机器人底盘的任务因模型而异,但大多数模型需要一些额外的工具,除了套件中提供的工具外,例如螺丝刀。如果您尚未确定最终设计,并希望让您的机器人在临时配置中开始移动,可以使用可重复使用的粘土胶粘剂(如 Blu-Tack)将电子元件附着到底盘上。
电源
机器人底盘所包含的电动机通常在大约 6 V DC 下运行,因此我们将使用图 8-12 中示例底盘附带的四节 AA 电池托架。我们不能直接使用 6 V 来为微控制器电路供电,因此我们将在电源正极和微控制器的 5 V 引脚之间放置一个 1N4004 二极管。该二极管将导致电压下降 0.7 V,使微控制器的电源电压降至大约 5.3 V DC。随着电池使用寿命的减少,电压将再次下降。
按照图 8-13 所示组装电路。

图 8-13 :项目 37 的示意图
再次强调,电池组或外部电源的黑色/负极引线连接到 GND,红色/正极引线连接到 L293D 的 V [CC] 2 引脚和 1N4004 二极管。
代码
打开一个终端窗口,导航到本书 第八章 文件夹中的 Project 37 子文件夹,并输入命令 make flash。一旦移除 AVR 编程器并启动车辆,它应该等待三秒钟后向前移动,然后根据代码主循环中函数的顺序依次向左、向右转动,等等。
这段代码是我们使用 L293D 电机控制器 IC 和 PWM 控制直流电机的实验成果。让我们看看它是如何工作的:
// Project 37 - Controlling a Two-Wheel-Drive Robot Vehicle
#include <avr/io.h>
#include <util/delay.h>
void initPWM(void)
❶
{
TCCR2A |= (1 << WGM20); // Fast PWM mode
TCCR2A |= (1 << WGM21); ); // Fast PWM mode, part 2
TCCR2B |= (1 << CS21); ); // PWM Freq = F_CPU/8/256
}
void moveForward(uint8_t duty)
{
// Set direction
PORTB |= (1 << PORTB4)|(1 << PORTB1);
❷ // PB4,1 HIGH
PORTB &= ~(1 << PORTB5)&~(1 << PORTB2); // PB5,2 LOW
// Set speed
if (duty == 255)
❸
{
PORTB |= (1 << PORTB3); // Set PORTB3 to on
PORTD |= (1 << PORTD3); // Set PORTD3 to on
} else if (duty < 255)
{
TCCR2A |= (1 << COM2A1);
❹ // PWM output on OCR2A—PB3
TCCR2A |= (1 << COM2B1); // PWM to OCR2B—PD3
OCR2A = duty; // Set PORTB3 to PWM value
OCR2B = duty; // Set PORTD3 to PWM value
}
}
void moveBackward(uint8_t duty)
{
// Set direction
PORTB &= ~(1 << PORTB4)&~(1 << PORTB1); // PB4,1 LOW
PORTB |= (1 << PORTB5)|(1 << PORTB2); // PB5,2 HIGH
// Set speed
if (duty == 255)
{
PORTB |= (1 << PORTB3); // Set PORTB3 to on
PORTD |= (1 << PORTD3); // Set PORTD3 to on
} else if (duty < 255)
{
TCCR2A |= (1 << COM2A1); // PWM output on OCR2A—PB3
TCCR2A |= (1 << COM2B1); // PWM to OCR2B—PD3
OCR2A = duty; // Set PORTB3 to PWM value
OCR2B = duty; // Set PORTD3 to PWM value
}
}
void moveLeft(uint8_t duty)
{
// Set direction
PORTB |= (1 << PORTB4)|(1 << PORTB2); // PB4,2 HIGH
PORTB &= ~(1 << PORTB5)&~(1 << PORTB1); // PB5,1 LOW
// Set speed
if (duty == 255)
{
PORTB |= (1 << PORTB3); // Set PORTB3 to on
PORTD |= (1 << PORTD3); // Set PORTD3 to on
} else if (duty < 255)
{
TCCR2A |= (1 << COM2A1); // PWM output on OCR2A—PB3
TCCR2A |= (1 << COM2B1); // PWM to OCR2B—PD3
OCR2A = duty; // Set PORTB3 to PWM value
OCR2B = duty; // Set PORTD3 to PWM value
}
}
void moveRight(uint8_t duty)
{
// Set direction
PORTB |= (1 << PORTB5)|(1 << PORTB1); // PB5,1 HIGH
PORTB &= ~(1 << PORTB4)&~(1 << PORTB2); // PB4,2 LOW
// Set speed
if (duty == 255)
{
PORTB |= (1 << PORTB3); // Set PORTB3 to on
PORTD |= (1 << PORTD3); // Set PORTD3 to on
} else if (duty < 255)
{
TCCR2A |= (1 << COM2A1); // PWM output on OCR2A—PB3
TCCR2A |= (1 << COM2B1); // PWM to OCR2B—PD3
OCR2A = duty; // Set PORTB3 to PWM value
OCR2B = duty; // Set PORTD3 to PWM value
}
}
void motorsOff(void)
❺
{
TCCR2A &= ~(1 << COM2A1); // Disconnect PWM from OCR2A—PB3
TCCR2A &= ~(1 << COM2B1); // Disconnect PWM from OCR2B—PD3
PORTB &= ~(1 << PORTB3); // Set ENABLE pins to zero for brake
PORTD &= ~(1 << PORTD3);
}
int main(void)
{
// Set PORTB5, 4, 3, 2, and 1 as outputs
DDRB |= (1 << PORTB5)|(1 << PORTB4)|(1 << PORTB3)|(1 << PORTB2)|(1 << PORTB1);
❻
DDRD |= (1 << PORTD3);
❼ // Set PORTD3 as output
initPWM();
❽
_delay_ms(3000); // Wait a moment before starting
while(1)
{
moveForward(128);
_delay_ms(2000);
moveLeft(128);
_delay_ms(2000);
moveRight(128);
_delay_ms(2000);
motorsOff();
moveBackward(128);
_delay_ms(2000);
}
}
在 ❶ 和 ❽ 处,代码为两个数字输出启动 PWM,这样就可以控制两个电机。PWM 启动后,调用 moveForward(),这是控制电机的五个函数中的第一个。如果电机的工作方向与代码相反,你可能需要调换每个电机上的接线。这四个函数——moveForward()、moveBackward()、moveLeft() 和 moveRight()——是完全相同的,唯一的区别在于电机旋转的顺序。它们都接受一个占空比值来控制电机的速度。motorsOff() 函数切断两个电机的电源。
在这种情况下,我们通过根据所需的旋转类型将数字输出设为高或低来设置电机的前进方向 ❷。有关所需输出配置,请参阅表 8-1。电机运动函数会检查用户是否需要全速(占空比为 100%,表示为 255) ❸。如果需要,它会简单地将 L293D 的 ENABLE 引脚设置为开启。但是,如果你通过电机运动函数传入较低的占空比值,程序会激活 ENABLE 引脚的 PWM 输出 ❹,并将所需的占空比填充到 PWM 寄存器 OCR2A 和 OCR2B 中。
另外三个运动函数的操作方式类似,只是电机的旋转方向设置为允许向左或向右转动或向后移动。motorsOff() 函数通过关闭 PWM 并将两个 L293D ENABLE 引脚设为低来停止运动 ❺。最后,程序将六个必需的引脚设置为输出,以控制 L293D ❻❼。
你可以使用代码主循环中使用的函数来改变运动方向、通过占空比来调整速度、通过延迟函数来控制持续时间,并在需要时停止电机。
我们使用了一个单独的定时器,为两个电机提供两个 PWM 输出(OCR2A 和 OCR2B),这样它们共享相同的 PWM 生成,从而实现同步。如果你为两个需要一起操作的电机使用两个不同的定时器,PWM 信号将会有所不同,两个电机的操作也会略有不同。
现在我们已经实验了直流电机,在下一章中,我们将研究 AVR 系统的另一个有用工具:内部 EEPROM。
第九章:# 使用内部 EEPROM

当你在 AVR 代码中定义并使用一个变量时,存储的数据只会持续到硬件重置或断电。但是如果你需要保存一些值以备将来使用怎么办?这时我们转向微控制器的 电可擦可编程只读存储器(EEPROM),它是一种特殊类型的存储器,即使断电也能保持信息。
在本章中,你将:
-
• 学习如何将字节、字和浮动点变量存储到微控制器的 EEPROM 中,并从中检索它们。
-
• 构建一个 ATtiny85 EEPROM 存储和检索单元,以及一个使用 ATmega328P-PU 的简单 EEPROM 数据记录器。
-
• 创建一个程序,将温度记录到 ATmega328P-PU 的 EEPROM 中,以供后续检索。
将字节存储到 EEPROM
EEPROM 是一种微控制器组件,无需电力即可保持其内存内容。这一概念源自只读存储器(ROM)IC,例如在游戏机卡带中找到的那些,它们的游戏代码即使在没有连接到主机时仍会保留。在这个概念的基础上,EEPROM 允许主控制器用新信息覆盖旧信息,而当电源断开时,EEPROM 仍能记住这些信息——这就是“电可擦”所指的含义。
不同的 AVR 微控制器具有不同大小的 EEPROM。例如,我们的 ATtiny85 可以存储 512 字节的数据,而 ATmega328P-PU 可以存储 1,024 字节。在本章中,我将向你展示如何在这两种微控制器的 EEPROM 中存储和检索数据,这样你就可以将其用于你自己的项目。根据数据的类型,有几种不同的方法可以将数据存储到 EEPROM 中。我们将首先讨论如何存储字节。
然而,在继续之前,你需要记住两点。首先,EEPROM 的使用寿命约为 100,000 次读写周期。测试表明它们可能会更持久,但在构建自己的项目时要注意大致的使用寿命。其次,请记住,当你上传新的代码到 AVR 时,EEPROM 中的数据会被清除。
要在我们的代码中使用 EEPROM,无论是哪个微控制器,我们首先需要包含 EEPROM 库:
#include <avr/eeprom.h>
然后,为了写入一个字节数据(例如,0 到 255 之间的数字),我们使用以下函数:
eeprom_write_byte((uint8_t*)
`a, b`
);
其中 a 是 EEPROM 中的位置——对于 ATtiny85,范围是 0 到 511;对于 ATmega328P-PU,范围是 0 到 1023——b 是要存储的字节数据,范围是 0 到 255。我们将位置变量 a 前缀加上 (uint8_t*),因为 EEPROM 函数要求该参数为 8 位整数。
你还可以 更新 EEPROM 的位置,以更改其中存储的值,如下所示:
eeprom_update_byte((uint8_t*)
`a, b`
);
其中 a 仍然是 EEPROM 中的位置——对于 ATtiny85 来说在 0 和 511 之间,对于 ATmega328P-PU 来说在 0 和 1023 之间——而 b 是要存储的数据字节,范围是 0 到 255。在将一个数据字节写入位置之前,更新命令首先会检查该位置当前的值。如果要写入的值与当前值相同,则不进行写入。虽然这个检查会增加处理时间,但它避免了不必要的写入 EEPROM,从而延长其寿命。
要检索存储在位置中的字节数据,可以使用以下方法:
uint8_t
`i`
= eeprom_read_byte((uint8_t*)
`a`
);
这将把存储在 EEPROM 位置 a 中的值分配给变量 i。你将在下一个项目中测试一些这些函数。
项目 38:实验 ATtiny85 的 EEPROM
该项目将演示如何从 ATtiny85 的 EEPROM 中写入和检索字节数据。它使用四个 LED 作为快速显示 0 到 15 二进制形式的数字的方式,其中二极管 D1 表示最低有效位(表示 0),而二极管 D4 表示最高有效位(表示 15)。
你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• ATtiny85–20PU 微控制器
-
• 四个 LED
-
• 四个 560 Ω 电阻
-
• 跳线
按照图 9-1 所示组装电路。

图 9-1: 项目 38 的电路图
打开一个终端窗口,导航到本书 第九章 文件夹下的 项目 38 子文件夹,并输入命令 make flash。片刻后,LED 应该会显示 0 到 15 的二进制数字,然后重复。
要查看它是如何工作的,可以查看项目 38 中的 main.c 文件。
// Project 38 - Experimenting with the ATtiny85's EEPROM
#include <avr/io.h>
#include <util/delay.h>
❶ #include <avr/eeprom.h>
int main(void)
{
DDRB = 0b00001111; // Set PORTB3–0 to output for LEDs
int a;
while (1)
{
// Write 0–15 to locations 0-15
for (a=0; a<16; a++)
{
❷ eeprom_update_byte((uint8_t*)a, a);
}
// Read locations 0-15, display data on LEDs
for (a=0; a<16; a++)
{
❸ PORTB = eeprom_read_byte((uint8_t*)a);
_delay_ms(250);
}
// Turn off LEDs
PORTB = 0b00000000;
}
}
如前所述,我们需要包含 EEPROM 库 ❶,以便利用其功能来更新和读取 EEPROM 中的字节。第一个 for 循环重复执行 16 次,更新 EEPROM 中位置 0 . . . 15 的值为 0 . . . 15 ❷。第二个循环从 EEPROM 位置 0 . . . 15 中获取数据,并将 PORTB 寄存器设置为从 EEPROM 中获取的数字 ❸。这会激活连接到 PORTB 相应引脚的 LED,从而以二进制形式显示每个值。
现在你已经知道如何将小数字存储到微控制器的 EEPROM 中,我将向你展示如何使用数据字存储更大的数字。
存储数据字
一个 数据字 使用 16 位,或 2 个字节,来表示 16 位带符号或无符号整数。正如你在第二章中学到的,这些数字的范围是带符号整数的 −32,768 到 32,767,或者无符号整数的 0 到 65,535。例如,一个数据字可以表示 12,345 或 −23,567。要写入数据字,我们再次使用来自 EEPROM 库的函数,像这样包含:
#include <avr/eeprom.h>
要写入一个数据字,我们使用以下函数:
eeprom_write_word((uint16_t*)
`a, b`
);
其中,a是 EEPROM 内的位置,b是要存储的数据字。数据字的大小是 2 字节,而 EEPROM 位置的大小是 1 字节。这意味着,当你写入一个数据字时,它将填充两个 EEPROM 位置。因此,如果你想在 EEPROM 开头写入两个数据字,你需要将第一个数据字写入位置 0,将第二个数据字写入位置 2。
和字节一样,你也可以更新数据字。你可以使用以下函数来做到这一点:
eeprom_update_word((uint16_t*)
`a, b`
);
要检索存储在某个位置的数据字,请使用以下之一:
uint16_t
`i`
= eeprom_read_word((uint16_t*)
`a`
);
// For values between 0 and 65535
int16_t
`i`
= eeprom_read_word((int16_t*)
`a`
);
// For values between -32768 and 32767
这将把存储在 EEPROM 位置a中的值分配给变量i。请注意,a应为存储数据字的两个位置中的第一个,而不是第二个(因此,在我们之前的示例中是 0 或 2)。你将在下一个项目中测试这些功能。
项目 39:一个简单的 EEPROM 数据记录器
在这个项目中,你将创建一个基本的数据记录设备。它不仅展示了如何将数据字写入并从 ATmega328P-PU 的 EEPROM 中读取数据,还结合了 USART 和自定义功能。此项目不是写入任意数字到 EEPROM,而是重复读取数字输入引脚 PORTB0 的状态,将 0 或 1 写入指定的 EEPROM 位置(分别表示低电*或高电*)。我们将使用 USART 创建一个基本的基于文本的界面控制系统,用于记录、检索和擦除 EEPROM 数据。
硬件
你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• ATmega328P-PU 微控制器
-
• USB 到串行转换器
-
• 跳线
按照图 9-2 中的示意图组装电路。

图 9-2: 项目 39 的电路图
别忘了连接 USB 到串行转换器,如第四章所示。示意图中显示的正负点用于记录低高信号。你可以尝试连接正极到 5V 或 GND(负极必须始终连接到 GND)进行实验。
代码
打开终端窗口,进入本书第九章文件夹中的项目 39子文件夹,并输入命令make flash。然后打开你在第四章中安装的终端软件。片刻后,你将被提示输入“Enter 1 to start, 2 to dump, 3 to erase.”按1键启动数据记录功能,按2键让微控制器读取 EEPROM 并将数据发送回终端窗口,或按3键通过将所有 EEPROM 位置写回 0 来擦除数据。
图 9-3 显示了这一序列的示例。(为了节省空间,我已修改了生成此图的代码,只使用了前 10 个 EEPROM 位置。当你运行代码时,你的序列将会长得多。)

图 9-3:项目 39 的示例输出
让我们看看它是如何工作的:
// Project 39 - A Simple EEPROM Datalogger
#include <avr/io.h>
#include <util/delay.h>
#include <avr/eeprom.h>
#include <math.h>
#include <stdio.h>
#include <stlib.h>
#define USART_BAUDRATE 4800
#define UBRR_VALUE 12
❶ #define logDelay 1000
char newline[4] = "\r\n";
❷ void USARTInit(void)
{
// Set baud rate registers
UBRR0H = (uint8_t)(UBRR_VALUE>>8);
UBRR0L = (uint8_t)UBRR_VALUE;
// Set data type to 8 data bits, no parity, 1 stop bit
UCSR0C |= (1<<UCSZ01)|(1<<UCSZ00);
// Enable transmission and reception
UCSR0B |= (1<<RXEN0)|(1<<TXEN0);
}
❸ void USARTSendByte(unsigned char u8Data)
{
// Wait while previous byte is sent
while(!(UCSR0A&(1<<UDRE0))){};
// Transmit data
UDR0 = u8Data;
}
❹ uint8_t USARTReceiveByte()
// Receives a byte of data from the computer into the USART register
{
// Wait for byte from computer
while(!(UCSR0A&(1<<RXC0))){};
// Return byte
return UDR0;
}
❺ void sendString(char myString[])
{
uint8_t a = 0;
while (myString[a])
{
USARTSendByte(myString[a]);
a++;
}
}
❻ void logData()
{
uint16_t portData = 0;
uint16_t location = 0;
char z1[] = "Logging data . . .";
sendString(z1);
for (location=0; location<1024; location++)
{
if (PINB == 0b0000001) // If PORTB0 is HIGH
{
eeprom_update_word((uint16_t*)location, 1);
} else
{
eeprom_update_word((uint16_t*)location, 0);
}
location++; // Skip an EEPROM location as we're using words
USARTSendByte(‘.’);
_delay_ms(logDelay);
}
sendString(newline);
}
❼ void dumpData()
{
uint8_t portData = 0;
uint16_t location = 0;
char t[10] = ""; // For our dtostrf
char z1[] = "Dumping data . . .";
sendString(z1);
sendString(newline);
for (location=0; location<1024; location++)
{
// Retrieve data from EEPROM location
portData=eeprom_read_word((uint16_t*)location);
dtostrf((float)portData,12,0,t);
sendString(t);
sendString(newline);
location++; // Skip an EEPROM location as we're using words
}
sendString(newline);
}
❽ void eraseData()
{
uint16_t location = 0;
char msg2[] = "Erasing data . . .";
char msg3[] = " finished.";
sendString(msg2);
for (location=0; location<1024; location++)
{
eeprom_write_byte((uint16_t*)location, 0); // Write 0 to EEPROM location
USARTSendByte(‘*’);
}
sendString(msg3);
sendString(newline);
}
int main(void)
{
DDRB = 0b11111111; // Set PORTB0 as input
char msg1[44] = "Enter 1 to start, 2 to dump, 3 to erase: ";
uint8_t option;
USARTInit();
while (1)
{
❾ sendString(msg1);
option = USARTReceiveByte();
USARTSendByte(option);
sendString(newline);
switch (option)
{
case 49 : logData(); break;
case 50 : dumpData(); break;
case 51 : eraseData(); break;
}
}
}
首先,我们像往常一样导入所有必要的库,并设置 USART 的数据速度。我们还将 logDelay 设置为 1,000 ❶,指定每个日志事件之间的延迟时间(你可以根据自己的需要更改这个值)。
接下来,我们声明初始化 USART ❷、从 USART 向计算机发送字节 ❸、接收来自另一方向的字节 ❹ 以及向终端仿真器发送字符串 ❺ 所需的函数。当需要时,用户可以调用数据记录函数 logData() ❻。该函数读取 PORTB0 的值,并按顺序将 1(高电*)或 0(低电*)写入从 0 到 1,022 的 EEPROM 位置。由于每个字节需要两个位置,函数会跳过每个第二个位置。如果你想增加日志事件之间的时间,你可以通过调整 logDelay ❶ 的值来改变速度。
dumpData() 函数 ❼ 将每个 EEPROM 位置的值发送到 USART,从而发送到 PC 进行查看。与 logData() 函数一样,它跳过每个第二个位置,因为我们每个字需要两个位置。在运行此函数之前,你可以设置终端软件以捕获输出,便于使用电子表格进行进一步分析,正如在 第四章 的 项目 19 中所演示的那样。
eraseData() 函数 ❽ 在每个 EEPROM 位置写入 0,从而覆盖任何先前存储的数据。虽然在这里并非必需,但这个函数在你将来需要擦除 EEPROM 数据的项目中可能会很有用。
主代码循环提供了一种类似用户界面的方式,通过提示用户选择 ❾,然后使用 switch...case 语句根据显示的菜单选项调用所需的函数。
现在你可以在 EEPROM 中存储和检索字节和单词数据,我们将继续讨论最后一种数据类型:浮动点变量。
存储浮动点变量
一个 浮动点变量 表示一个浮动点数字(如 第三章 中所述),它的值范围从 −3.39 × 10 ³⁸ 到 3.39 × 10 ³⁸。这些变量需要 32 位存储,或 4 字节。要写入浮动点(float)变量,我们再次需要包含 EEPROM 库:
#include <avr/eeprom.h>
然后,我们使用以下函数写入一个数据字(例如,0 到 65,535 之间的数字):
eeprom_write_float((float*)
`a, b`
);
其中,a是 EEPROM 内部位置,b是要存储的浮动数据。
要更新存储在 EEPROM 中的浮动数据,我们使用这个函数:
eeprom_update_float((float*)
`a, b`
);
由于浮动数据占用 4 个字节,而 EEPROM 位置只能容纳 1 个字节,因此存储浮动数据时你需要分配四个 EEPROM 位置。例如,如果你在 EEPROM 的开始部分写入两个浮动数据,你会将第一个写入位置 0,第二个写入位置 4。
要检索存储在给定位置的浮动数据,使用以下函数:
float
`i`
= eeprom_read_float((float*)
`a`
);
这将 EEPROM 位置a中存储的值分配给变量i。请记住,在使用字时,始终需要使用第一个位置。在下一个项目中,你将使用 EEPROM 存储浮动数据的能力。
项目 40:带 EEPROM 的温度记录仪
这个项目结合了你在第三章中学到的使用 TMP36 温度传感器捕获数据的知识,并通过 USART 和自定义函数将浮动变量数据写入 EEPROM 并读取。项目代码会将温度数据存储到 EEPROM 256 次,因此你可以使用终端软件检索并查看读取的温度,或者将数据捕获下来进行电子表格分析。
硬件
你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• 5V 面包板电源
-
• ATmega328P-PU 微控制器
-
• 一个 TMP36 温度传感器
-
• 0.1 μF 陶瓷电容
-
• 跳线
-
• USB 到串行转换器
按照图 9-4 所示组装电路,使用外部电源并连接 USB 到串行转换器。

图 9-4:项目 40 的原理图
打开一个终端窗口,导航到本书第九章子文件夹中的项目 40文件夹,并输入命令make flash。接下来,像上一个项目一样打开终端应用程序。片刻后,你应该会看到提示输入 1 开始、2 转储或 3 擦除。按下计算机键盘上的 1 将启动温度数据记录功能;按下 2 将告诉微控制器读取 EEPROM 并将温度数据发送回终端软件进行显示,按下 3 将擦除数据,将所有 EEPROM 位置恢复为 0。
图 9-5 显示了这个过程的一个示例。(为了节省空间,我已将代码更改为仅使用前六个 EEPROM 位置。)

图 9-5:项目 40 的示例输出
让我们看一下代码,了解它是如何工作的:
// Project 40 - Temperature Logger with EEPROM
#include <avr/io.h>
#include <util/delay.h>
#include <avr/eeprom.h>
#include <math.h>
#include <stdio.h>
#define USART_BAUDRATE 4800
#define UBRR_VALUE 12
❶ #define logDelay 1000
char newline[4] = "\r\n";
❷ void startADC()
// Set up the ADC
{
ADMUX |= (1 << REFS0); // Use AVcc pin with ADC
ADMUX |= (1 << MUX2) | (1 << MUX0); // Use ADC5 (pin 28)
ADCSRA |= (1 << ADPS1) | (1 << ADPS0); // Prescaler for 1MHz (/8)
ADCSRA |= (1 << ADEN); // Enable ADC
}
❸ void USARTInit(void)
{
// Set baud rate registers
UBRR0H = (uint8_t)(UBRR_VALUE>>8);
UBRR0L = (uint8_t)UBRR_VALUE;
// Set data type to 8 data bits, no parity, 1 stop bit
UCSR0C |= (1<<UCSZ01)|(1<<UCSZ00);
// Enable transmission and reception
UCSR0B |= (1<<RXEN0)|(1<<TXEN0);
}
void USARTSendByte(unsigned char u8Data)
{
// Wait while previous byte is sent
while(!(UCSR0A&(1<<UDRE0))){};
// Transmit data
UDR0 = u8Data;
}
uint8_t USARTReceiveByte()
// Receives a byte of data from the computer into the USART register
{
// Wait for byte from computer
while(!(UCSR0A&(1<<RXC0))){};
// Return byte
return UDR0;
}
void sendString(char myString[])
{
uint8_t a = 0;
while (myString[a])
{
USARTSendByte(myString[a]);
a++;
}
}
❹ float readTemperature()
{
float temperature;
float voltage;
uint8_t ADCvalue;
// Get reading from TMP36 via ADC
ADCSRA |= (1 << ADSC); // Start ADC measurement
while (ADCSRA & (1 << ADSC) ); // Wait until conversion is complete
_delay_ms(10);
// Get value from ADC register, place in ADCvalue
ADCvalue = ADC;
// Convert reading to temperature value (Celsius)
voltage = (ADCvalue * 5);
voltage = voltage / 1024;
temperature = ((voltage - 0.5) * 100);
return temperature;
}
❺ void logData()
{
float portData = 0;
uint16_t location = 0;
char z1[] = "Logging data . . .";
sendString(z1);
for (location=0; location<1021; location=location+4)
{
portData=readTemperature();
eeprom_update_float((float*)location,portData);
USARTSendByte(‘.’);
}
sendString(newline);
_delay_ms(logDelay);
}
❻ void dumpData()
{
float portData = 0;
uint16_t location = 0;
char t[10] = ""; // For our dtostrf
char msg1[14] = "Temperature: "; // Make sure you have " instead of "
char msg2[12] = " degrees C ";
char msg3[] = "Dumping data . . .";
char msg4[] = ". . . finished.";
sendString(msg3);
sendString(newline);
for (location=0; location<1021; location=location+4)
{
sendString(msg1);
portData=eeprom_read_float((float*)location); // HERE
dtostrf(portData,8,4,t);
sendString(t);
sendString(msg2);
sendString(newline);
}
sendString(msg4);
sendString(newline);
}
❼ void eraseData()
{
int16_t location = 0;
char msg1[] = "Erasing data . . .";
char msg2[] = " finished.";
sendString(msg1);
for (location=0; location<1024; location++)
{
eeprom_write_byte((uint8_t*)location, 0);
USARTSendByte(‘*’);
}
sendString(msg2);
sendString(newline);
}
int main(void)
{
❽ char msg1[44] = "Enter 1 to start, 2 to dump, 3 to erase: ";
uint8_t option;
DDRD = 0b00000000; // Set PORTD to inputs
startADC();
USARTInit();
while (1)
{
sendString(msg1);
option = USARTReceiveByte();
USARTSendByte(option);
sendString(newline);
switch (option)
{
case 49 : logData(); break;
case 50 : dumpData(); break;
case 51 : eraseData(); break;
}
}
}
本项目再次整合了之前章节中的知识,将一个新思路付诸实践。首先,我们像往常一样导入所有必需的库,并设置 USART 的数据传输速率。我再次将每个日志记录事件之间的延迟设置为 1,000 毫秒,但你可以通过调整 logDelay 值 ❶ 来改变速度。接下来,我们提供初始化和操作 ADC ❷ 和 USART ❸ 所需的函数,和前一个项目一样。
readTemperature() 函数 ❹ 从 TMP36 读取温度数据;我们将从 logData() 函数 ❺ 调用它,后者将这些读数依次存储到 EEPROM 从 0 到 1020 的位置,每次跳过三个位置,因为我们需要为每个浮动变量保留四个位置。
dumpData() 函数 ❻ 将每个 EEPROM 位置的值发送到 USART,然后传输到 PC 供查看。与 logData() 类似,该函数会跳过每个第四个 EEPROM 位置,以便我们有空间存储浮动变量。在运行此函数之前,你可以设置终端软件将输出捕捉到一个文本文件中,然后在电子表格中打开;如果你需要回顾,请参见项目 19 中的第四章。
eraseData() 函数 ❼ 在每个 EEPROM 位置写入 0,从而擦除之前存储的数据。如项目 39 所述,你可能会在自己的项目中用到这个功能。
主要的代码循环提供了一种用户界面,提示用户选择是记录、转储还是擦除数据 ❽,然后使用 switch...case 语句调用所需的函数,按照显示的菜单选项进行操作。
随着学习这些 EEPROM 功能,在本章中你已经迈出了进一步的步伐,向着开发复杂项目的目标靠*,这些项目可能会在你后续的单片机旅程中激发你自己的项目灵感。在下一章,你将学习如何创建自己的库,以节省时间并编写更有用的代码。
第十章:# 编写你自己的 AVR 库

回想一下项目 15 中的内容,它要求我们将 TMP36 温度传感器测量的电压转换为摄氏度。在完成这些计算时,我们调用了数学库,并使用了其中的函数对浮点数进行操作。使用这个库意味着我们不必自己编写数学函数或将其代码包含在项目中,从而节省了时间和精力。
在本章中,你将学习如何创建自己的库,从而在多个项目中复用已测试的函数,提高工作效率。你将构建一个用于重复任务的简单库,一个接受值以执行功能的库,以及一个处理来自传感器的数据并以易于使用的形式返回值的库。这些示例将帮助你掌握制作自定义库所需的技能。
创建你的第一个库
在这一节中,你将创建你的第一个库,随后你将在项目 41 中使用它。首先,考虑列表 10-1 中定义的函数,blinkSlow() 和 blinkFast()。这两个函数分别以慢速或快速的频率使 LED(通过一个电阻连接在 PORTB0 和 GND 之间)闪烁。
#include <avr/io.h>
#include <util/delay.h>
void blinkSlow()
{
uint8_t i;
for (i = 0; i < 5; i++)
{
PORTB |= (1 << PORTB0);
_delay_ms(1000);
PORTB &= ~(1 << PORTB0);
_delay_ms(1000);
}
}
void blinkFast()
{
uint8_t i;
for (i = 0; i < 5; i++)
{
PORTB |= (1 << PORTB0);
_delay_ms(250);
PORTB &= ~(1 << PORTB0);
_delay_ms(250);
}
}
int main(void)
{
DDRB |= (1 << PORTB0); // Set PORTB0 as outputs
while (1)
{
blinkSlow();
_delay_ms(1000);
blinkFast();
_delay_ms(1000);
}
}
列表 10-1:示例代码,展示了两个函数,分别使 LED 以慢速和快速闪烁
用于缓慢或快速闪烁 LED 的自定义函数很方便,但每次想要使用时都将它们输入到项目代码中并不高效。然而,如果将描述这些函数的代码转移到库中,你可以在未来的项目中通过一行代码调用该库,然后根据需要使用这些函数,而无需重新编写它们。现在让我们创建一个这样的库。
库的构成
一个库由两个文件组成:library.h,头文件,和 library.c,源文件,其中“library”是个占位符,代表某个具体库的名称。我们将第一个示例库称为 blinko 库,因此我们的两个文件将是 blinko.h 和 blinko.c。
头文件包含库中函数、变量或其他组件的定义。以下是我们的头文件:
// blinko.h
void blinkSlow();
// Blinks PORTB0 slowly, five times
void blinkFast();
// Blinks PORTB0 rapidly, five times
该文件声明了库中两个函数的名称,void blinkSlow() 和 void blinkFast()。每行后面都有一个注释,描述了函数的目的。养成在库中的自定义函数旁边加上类似注释的习惯。
我们的源文件包含了代码,当我们包含这个库时,它将提供给项目 41 中的主代码:
// blinko.c
❶ #include <avr/io.h>
#include <util/delay.h>
❷ void blinkSlow()
{
uint8_t i;
for (i = 0; i < 5; i++)
{
PORTB |= (1 << PORTB0);
_delay_ms(1000);
PORTB &= ~(1 << PORTB0);
_delay_ms(1000);
}
}
❸ void blinkFast()
{
uint8_t i;
for (i = 0; i < 5; i++)
{
PORTB |= (1 << PORTB0);
_delay_ms(250);
PORTB &= ~(1 << PORTB0);
_delay_ms(250);
}
}
blinko.c 文件与 清单 10-1 中的第一部分相同。我们首先在我们自己的库中包含代码所需的其他库 ❶——这使得我们能够使用 I/O 函数和 _delay_ms()。然后,我们添加了自定义函数 blinkSlow() ❷ 和 blinkFast() ❸,它们将包含在库中。
安装库
为了让库可以在 项目 41 的主代码中使用,我们需要做两件事。首先,我们将头文件和源文件复制到与 main.c 文件和 Makefile 位于同一项目目录中,如 图 10-1 中的目录列表所示。你可以在本书 第十章 文件夹的 项目 41 子文件夹中找到这些文件。

图 10-1:将库文件放置在与项目文件相同的目录中。
第二,我们编辑项目的 Makefile,使得工具链知道在编译代码并上传到微控制器时需要查找库。为此,我们将 blinko.c 添加到 Makefile 中 OBJECTS 行的 main.o 后,如 图 10-2 所示。

图 10-2:将 blinko.c 库添加到 项目 41 的 Makefile 中
现在我们已经安装了库,让我们通过用它来编程一个简单的电路来测试它。
项目 41:你的第一个库
你将需要以下硬件来完成这个项目:
-
• USBasp 编程器
-
• 无焊面包板
-
• ATmega328P-PU 微控制器
-
• 一个 LED
-
• 一个 560 Ω 电阻
按照 图 10-3 中的电路图,在面包板上组装电路。

图 10-3:项目 41 的原理图
保持这个电路完整,因为你将在 下一个项目 中再次使用它。
接下来,打开终端窗口,导航到本书 第十章 文件夹中的 项目 41 子文件夹,然后输入命令 make flash。LED 应该快速闪烁五次,然后慢慢闪烁五次,之后重复。
这个项目的代码相当简单:
// Project 41 - Your First Library
#include <avr/io.h>
#include <util/delay.h>
❶ #include "blinko.h" // Use our new library
int main(void)
{
DDRB = 0b11111111; // Set PORTB as outputs
for(;;)
{
❷ blinkSlow();
_delay_ms(1000);
blinkFast();
_delay_ms(1000);
}
return 0;
}
首先,我们包含我们的新库 ❶。(注意,自定义库名称需要用引号括起来,而不是左右尖括号。)然后,我们利用库函数来让 LED 闪烁 ❷。
尽管这个项目是一个相对简洁的演示,但它展示了创建和使用自定义 AVR 库的基本过程。接下来,我们将看一些更复杂的示例。
创建一个接受值并执行功能的库
现在你已经知道如何创建一个基础库,接下来你可以进入下一个阶段:创建一个能够接受值并对其进行操作的库。我们将再次从一个示例函数开始,并将其转换为一个库。
考虑一下 列表 10-2 中的代码,它使用 blinkType() 函数来设置连接到 PORTB0 的 LED 闪烁的次数,以及开关的时间间隔。
#include <avr/io.h>
#define __DELAY_BACKWARD_COMPATIBLE__ // Required for macOS users
#include <util/delay.h>
void blinkType(int blinks, int duration)
// blinks - number of times to blink the LED
// duration - blink duration in milliseconds
{
uint8_t i;
for (i = 0; i < blinks; i++)
{
PORTB |= (1 << PORTB0);
_delay_ms(duration);
PORTB &= ~(1 << PORTB0);
_delay_ms(duration);
}
}
int main(void)
{
DDRB |= (1 << PORTB0); // Set PORTB0 as outputs
while (1)
{
// Blink LED 10 times, with 150 ms duration
blinkType(10, 150);
_delay_ms(1000);
// Blink LED 5 times, with 1 s duration
blinkType(5, 1000);
_delay_ms(1000);
}
}
列表 10-2:一个示例代码,展示了我们的 LED 闪烁函数,它将被转换为一个库
如你所见,blinkType() 接受两个值并对其进行操作。blinks 值是你希望开启和关闭板载 LED 的次数,而 duration 值是每次闪烁的延迟时间(以毫秒为单位)。让我们将其转化为一个名为 blinko2 的库。
首先,我们需要创建 blinko2.h 头文件,该文件包含库中使用的函数和变量的定义:
// blinko2.h
void blinkType(int blinks, int duration);
// blinks - number of times to blink the LED
// duration - blink duration in milliseconds
如前所述,我们在库中声明函数的名称,并随后添加注释描述其用途。在本例中,我们提供了描述函数参数的注释。
接下来,我们构建我们的 blinko2.c 源文件:
// blinko2.c
❶ #include <avr/io.h>
❷ #define __DELAY_BACKWARD_COMPATIBLE__
#include <util/delay.h>
❸ void blinkType(int blinks, int duration)
// blinks - number of times to blink the LED
// duration - blink duration in milliseconds
{
uint8_t i;
for (i = 0; i < blinks; i++)
{
PORTB |= (1 << PORTB0);
_delay_ms(duration);
PORTB &= ~(1 << PORTB0);
_delay_ms(duration);
}
}
源文件包括操作我们库所需的库 ❶,然后是我们库的函数 ❸,与以往一样。❷ 行是为那些使用 Apple 电脑的用户准备的,因为编译器的版本稍有不同。
下一步是编辑我们将使用此库的项目的 Makefile,你可以在本书的 第十章 文件夹的 Project 42 子文件夹中找到它。在 OBJECTS 行的 main.o 后面添加库名 blinko2.c,如 图 10-4 所示。

图 10-4:将 blinko2.c 库添加到 项目 42 的 Makefile 中
现在你已经设置好了库,让我们来测试一下。
项目 42:使用 blinko2.c 库
你可以使用你为 项目 41 组装的硬件来完成这个项目。打开终端窗口,进入本书 第十章 文件夹的 Project 42 子文件夹,并输入命令 make flash。LED 应该快速闪烁 10 次,然后慢速闪烁 5 次,之后重复。
让我们看看这是如何实现的:
// Project 42 - Using the blinko2.c Library
#include <avr/io.h>
#include <util/delay.h>
❶ #include "blinko2.h" // Use our new library
int main(void)
{
DDRB = 0b11111111; // Set PORTB as outputs
for(;;)
{
// Blink LED 10 times, with 150 ms duration
❷ blinkType(10, 150);
_delay_ms(1000);
// Blink LED 5 times, with 1 s duration
❸ blinkType(5, 1000);
_delay_ms(1000);
}
return 0;
}
与之前一样,我们包含了新的库 ❶,然后使用该库的函数,以短时间间隔快速闪烁 LED ❷,再以较长时间间隔慢速闪烁 ❸。同样,这只是一个简单的示范,给你提供了创建自己 AVR 库的框架,函数可以接受值。如果你想挑战自己,可以尝试基于第七章的示例代码,创建你自己的 PWM 库。
创建一个处理数据并返回值的库
在本章的最终项目中,你将学习如何创建一个可以将值返回到主代码的库。我们将创建一个“温度计”库,不仅能从模拟设备的 TMP36 温度传感器返回值,还包含一个简化在七段显示器上显示数字的函数。
我们的库源代码,你可以在本书第十章文件夹下的项目 43子文件夹中找到,包含两个函数:一个返回温度传感器中的摄氏度值作为浮动变量,另一个接受 0 到 99 之间的整数,在项目 15 的单数字 LED 显示器上显示。我们来看看temperature.h头文件,定义了库中使用的函数和变量:
// thermometer.h
void displayNumber(uint8_t value);
// Displays a number between 0 and 99 on the seven-segment LED display
float readTMP36();
// Returns temperature from TMP36 sensor using ATmega328P-PU pin PC5
一如既往,我们声明了库内函数的名称,并提供了描述其用途的注释。请注意,readTMP36()函数的类型是float,而不是void,因为该函数会返回一个浮动的温度值给我们项目的主代码。
接下来,让我们查看一下我们的thermometer.c源文件:
// thermometer.c
❶ #include <avr/io.h>
#include <math.h>
#include <util/delay.h>
❷ float readTMP36()
// Returns temperature from TMP36 sensor using ATmega328P-PU pin PC5
{
float temperatureC;
float voltage;
uint16_t ADCvalue;
ADCSRA |= (1 << ADSC); // Start ADC measurement
while (ADCSRA & (1 << ADSC) ); // Wait for conversion to finish
_delay_ms(10);
// Get value from ADC (which is 10-bit) register, store in ADCvalue
ADCvalue = ADC;
// Convert reading to temperature value (Celsius)
voltage = (ADCvalue * 5);
voltage = voltage / 1024;
temperatureC = ((voltage - 0.5) * 100);
❸ return temperatureC;
}
❹ void displayNumber(uint8_t value)
// Displays a number between 0 and 99 on the seven-segment LED display
{
uint8_t tens=0;
uint8_t ones=0;
uint8_t delayTime=250;
tens = value / 10;
ones = value % 10;
switch(tens)
{
case 0 : PORTB = 0b00111111; break; // 0
case 1 : PORTB = 0b00000110; break; // 1
case 2 : PORTB = 0b01011011; break; // 2
case 3 : PORTB = 0b01001111; break; // 3
case 4 : PORTB = 0b01100110; break; // 4
case 5 : PORTB = 0b01101101; break; // 5
case 6 : PORTB = 0b01111101; break; // 6
case 7 : PORTB = 0b00000111; break; // 7
case 8 : PORTB = 0b01111111; break; // 8
case 9 : PORTB = 0b01101111; break; // 9
}
_delay_ms(delayTime);
switch(ones)
{
case 0 : PORTB = 0b00111111; break; // 0
case 1 : PORTB = 0b00000110; break; // 1
case 2 : PORTB = 0b01011011; break; // 2
case 3 : PORTB = 0b01001111; break; // 3
case 4 : PORTB = 0b01100110; break; // 4
case 5 : PORTB = 0b01101101; break; // 5
case 6 : PORTB = 0b01111101; break; // 6
case 7 : PORTB = 0b00000111; break; // 7
case 8 : PORTB = 0b01111111; break; // 8
case 9 : PORTB = 0b01101111; break; // 9
}
_delay_ms(delayTime);
PORTB = 0; // Turn off display
}
库中的所有代码现在对你应该都很熟悉了。首先,我们包含了用于我们库的必要库 ❶。readTMP36()函数 ❷返回摄氏度的温度,使用return函数 ❸,与项目 11 中解释的自定义函数类似,位于第三章 。displayNumber(uint8_t value)函数则在单数字 LED 显示器上显示 0 到 99 之间的整数 ❹。
如之前所示,为了使这个库可以在项目 43 中使用,我们将其添加到 Makefile 的第 22 行,如图 10-5 所示。

图 10-5:将thermometer.c库添加到项目 43 的 Makefile 中
现在,你已经准备好使用这个库来构建你的数字温度计。
项目 43:使用 thermometer.c 库创建数字温度计
在这个项目中,你将使用微控制器读取模拟温度传感器(TMP36),并利用 项目 15 的七段 LED 显示器一次显示一个数字的温度。
你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊接面包板
-
• 5V 面包板电源
-
• ATmega328P-PU 微控制器
-
• 一个 TMP36 温度传感器
-
• 一个共阴极七段 LED 显示器
-
• 七个 560 Ω 电阻(R1–R7)
-
• 0.1 μF 陶瓷电容
-
• 跳线
按照 图 10-6 所示组装你的电路。

图 10-6: 项目 43 的电路图
请注意,项目的电源必须尽可能接* 5V,因为 TMP36 是一个模拟传感器,其输出与电源电压和温度有关。
现在打开终端窗口,导航到 第十章 文件夹中的 项目 43 子文件夹,并像往常一样上传 项目 43 的代码。完成后,你应该能在 LED 显示器上看到摄氏度温度——先显示左侧数字,再显示右侧数字。
让我们来看一下这是如何工作的:
// Project 43 - Creating a Digital Thermometer with the thermometer.c Library
❶ #include <avr/io.h>
#include <util/delay.h>
#include "thermometer.h" // Use our new library
❷ void startADC()
// Set up the ADC
{
ADMUX |= (1 << REFS0); // Use AVcc pin with ADC
ADMUX |= (1 << MUX2) | (1 << MUX0); // Use ADC5 (pin 28)
ADCSRA |= (1 << ADPS1) | (1 << ADPS0); // Prescaler for 1MHz (/8)
ADCSRA |= (1 << ADEN); // Enable ADC
}
int main(void)
{
❸ float temperature;
uint8_t finalTemp;
❹ startADC();
❺ DDRB = 0b11111111; // Set PORTB as outputs
for(;;)
{
❻ temperature = readTMP36(); // Get temperature from sensor
finalTemp = (int)temperature;
❼ displayNumber(finalTemp);
_delay_ms(1000);
}
return 0;
}
现在,我们已经将测量和计算代码放入温度计库,你可以看到主代码是多么简单。我们首先包含所需的库 ❶,并定义启动 ADC 的函数 ❷。在代码的主要部分,我们声明了两个变量 ❸,用于存储温度库中的值并传递给 displayNumber() 函数。我们为 TMP36 温度传感器启动 ADC ❹,然后设置 PORTB 上的引脚为 LED 显示器的输出 ❺。
最后,我们通过温度计库中的 readTMP36() 函数从传感器获取温度,将其转换为整数,并显示在 LED 显示器上 ❼。
作为另一个挑战,看看你能否修改 readTMP36(),使其能够返回摄氏度或华氏度的温度,或者自己制作一个 ADC 初始化或 PWM 库,或者简化 displayNumber(uint8_t value) 中的数字显示代码。不管你选择哪个,我希望你能看到将自己的自定义函数转化为便捷库是多么容易。这是你编程工具箱中的一个关键工具。
在 下一章 中,你将学习如何通过 SPI 数据总线使用更多有趣且实用的组件,包括 LED 显示驱动器、移位寄存器和模拟到数字转换器。
第十一章:# AVR 和 SPI 总线

我们可以宽泛地将总线定义为连接两个设备的连接,允许我们将数据从一个设备发送到另一个设备。例如,有几种类型的数据总线可以将您的 AVR 微控制器连接到传感器或显示设备。本章介绍了串行外设接口(SPI)总线,我们用它来直接在主设备和一个或多个从设备之间发送数据字节。
在本章中,您将学习如何:
-
• 使用 AVR 微控制器实现 SPI 总线。
-
• 阅读 SPI 设备数据表以便编写匹配的代码。
-
• 向您的项目添加复位按钮。
-
• 在同一个项目中使用两个不同的基于 SPI 的设备。
在此过程中,您还将学习如何使用 74HC595 移位寄存器 IC 来增加可用的数字输出引脚数量,使用 MAX7219 LED 显示驱动器 IC 显示八位数字,以及使用 MCP3008 ADC IC 测量电压。
总线工作原理
SPI 总线使 AVR 微控制器与许多流行的部件和传感器之间进行通信。它的工作原理类似于我们在第四章和第九章中使用的 USART,通过一个线路从微控制器传输数据,通过另一个线路传输数据到微控制器。但是,SPI 总线还使用第三条连接:时钟线,它携带一个电信号,以恒定频率开关。每当时钟从高电*变为低电*或低电*变为高电*时,数据位(开或关)通过数据线从或向微控制器发送。时钟信号与数据信号同步,允许快速而准确的数据传输。
我们可以使用 DSO 演示数据和时钟线的状态变化。例如,参考图 11-1,显示了沿 SPI 总线传输的一个字节数据。

图 11-1:显示沿 SPI 总线传输的一个字节数据的 DSO
在图 11-1 中,上部波形(左边缘标有 1)是时钟信号,在使用 SPI 总线时激活。信号从 0 V 开始,上升到 5 V,然后返回 0 V,如果正在传输数据,则重复此模式。下部波形(标有 2)表示数据,其中 1 是 5 V 信号,0 是 0 V 信号。从右到左发送的数据是 10110110。
SPI 总线可以同时发送和接收数据,并且可以根据所使用的微控制器或基于 SPI 的设备以不同的速度进行通信。与 SPI 总线的通信采用主从配置:AVR 充当主设备,并确定在给定时间将与哪个设备(从设备)进行通信。
在本书中,我们将使用 ATmega328P-PU 微控制器进行使用 SPI 总线的项目,因为 ATtiny85 的内存或输出引脚不足以运行这些项目。
引脚连接和电压
每个 SPI 设备使用四个引脚与主设备进行通信:
-
• MOSI(主输出,从输入)
-
• MISO(主输入,从输出)
-
• SCK(时钟)
-
• SS(从选择,也称为“锁存”)
这些 SPI 引脚与微控制器的连接如 图 11-2 所示。

图 11-2:典型的 AVR 到 SPI 设备连接
SPI 主设备上的 SS 引脚在 图 11-2 中标记为 PBx;您可以使用任何空闲的 GPIO 引脚,但为了简便,最好使用 PORTB 上的空闲引脚,因为该连接会靠* SPI 引脚。不同的制造商通常会使用自己独特的术语来描述 SPI 总线连接,但在快速检查后,这应该容易理解。
由于我们的 AVR 在以下项目中运行于 5 V 电压,因此您的 SPI 设备必须也能在 5 V 下运行或承受 5 V 操作,请在使用前务必向卖家或制造商确认。如果您必须使用在较低电压下工作的 SPI 设备(如 3.3 V),可以使用如 PMD Way 编号 441079 的电*转换器,如 图 11-3 所示。电*转换器可以将 5 V 数字信号转换为 3.3 V 信号,反之亦然。

图 11-3:PMD Way 编号 441079
这是一个四通道电*转换器板,意味着它可以在同一板上转换四个独立的电气信号。使用电*转换器时,将它连接到 SPI 总线的四根线路之间:将 5 V 线路连接到 HV 引脚,将匹配的较低电压线路连接到 LV 引脚,并将两侧的 GND 引脚连接到板上。上传代码后,请记得断开您的 USBasp 编程器与项目的连接,因为编程器的引脚与 SPI 引脚共享,有时会干扰微控制器与基于 SPI 的设备之间的数据流。
实现 SPI 总线
接下来,让我们研究如何在代码中实现 SPI 总线,以及如何进行硬件连接。我将向你展示一个示例 SPI 部件的参数,然后展示如何调整 图 11-4 中显示的 SPI 控制寄存器(SPCR),以激活 SPI 总线并设置为我们需要的参数。在接下来的项目中,我还将向你展示如何为各种其他 SPI 部件进行相应操作。

图 11-4:来自 ATmega328P-PU 数据手册的 SPCR 图
将 SPIE 保持为 0,因为我们不使用中断,并将 SPE 设置为 1 以启用 SPI 总线。接下来,考虑 DORD 位,它决定数据字节是先发送 MSB(最高有效位,字节的第 7 位)还是 LSB(最低有效位,字节的第 0 位)。你需要根据从设备的数据手册来确定方向,手册中通常会包含像 图 11-5 这样的时序图,或者提供商会提供相应的说明。

图 11-5:SPI 设备(MAX7219)的示例时序图
查看时序图中的 DIN 线。数据沿总线传输,MSB(最高有效位)先传输。如果 D0 在线的起始位置,则 LSB(最低有效位)会先传输。将 DORD 设置为 1 时表示 LSB 先传输,设置为 0 时表示 MSB 先传输。
回到 SPCR,设置 MSTR 为 1,以启用微控制器作为主设备,如果需要启用微控制器作为从设备,则设置为 0。对于我们所有的项目,我们将使用 1。接下来,设置 CPOL 以匹配 SPI 总线中时钟(SCK)信号在空闲时的极性(即在数据传输前后):0 为低信号,1 为高信号。同样,你可以从时序图中获得此信息:在 图 11-5 中,CLK(或时钟/SCK)线在未使用时为低电*,然后在数据的第一位到达时上升,随后交替变化直到数据传输完成,再次降到低电*,因此对于此设备,你应该将其设置为 0。
CPHA,即时钟相位位,决定数据是采样在时钟位的开始还是结束。例如,查看 图 11-5 中的 CLK 和 DIN 线。数据在时钟位的开始处被采样,因为时钟信号在数据位的开始时上升。在这种情况下,你应将 CPHA 设置为 0。如果数据位在时钟位结束时开始,CPHA 应为 1。
最后,你将使用最后两个比特,SPR1 和 SPR0,结合起来设置时钟速度以及 SPI 总线上的数据匹配信号。将这两个比特设置为 0,以获得相对于微控制器的最大速度。同时,使用 DDR x 函数将连接到 SPI 设备 SS 引脚的 AVR 输出引脚设置为输出,然后使用典型的 PORT x 函数将该引脚设置为高电*。
发送数据
现在你已经初始化了 SPI 总线,它应该能够准备好接收和发送数据。我们将首先练习发送数据,然后在本章后面探讨接收数据。要向 SPI 设备发送一个数据字节,你需要做四件事(所有这些我们将在下一个项目中完成):
-
• 使用
PORTx命令将 SS 引脚设置为低电*。 -
• 将你希望发送的数据字节放入 SPDR 寄存器中。
-
• 使用
while(!(SPSR & (1<<SPIF)));等待传输完成。 -
• 使用
PORTx命令将 SS 引脚设置为高电*。
这可能看起来有点复杂,但通过练习和从零部件供应商获得正确的信息,实际上是很简单的。我将解释你构建 SPI 总线项目所需的所有知识,第一个项目使用了一个特别有用的移位寄存器 IC。
项目 44:使用 74HC595 移位寄存器
当你的基于 AVR 的项目没有足够的数字输出引脚时,你可以连接一个或多个移位寄存器,并且仍然可以为 AVR 本身提供足够的输出引脚。移位寄存器是一种集成电路,具有八个数字输出引脚,我们可以通过 SPI 总线向该 IC 发送一个数据字节来控制它。我们本章的项目将使用 74HC595 移位寄存器,如图 11-6 所示。

图 11-6:74HC595 移位寄存器 IC
74HC595 移位寄存器具有八个数字输出,它们的工作方式与 AVR 的数字输出相同,如图 11-7 所示。

图 11-7:74HC595 电路符号
表 11-1 提供了如何将移位寄存器连接到微控制器的详细信息。
| 表 11-1:74HC595 连接 |
|---|
| 引脚 |
| --- |
| 16 |
| 8 |
| QA 到 QH(15, 1–7) |
| 10 |
| 11 |
| 12 |
| 13 |
| 14 |
| 9 |
移位寄存器背后的原理很简单:我们向移位寄存器发送 1 个数据字节(8 位),移位寄存器根据这个字节数据将匹配的八个输出引脚打开或关闭。表示字节的位按照从最高到最低的顺序匹配输出引脚。因此,数据的 MSB(最高有效位)代表移位寄存器的输出引脚 7,而 LSB(最低有效位)代表输出引脚 0。例如,如果我们通过 SPI 总线向移位寄存器发送字节0b10000110,它将打开引脚 7、2 和 1,并且会关闭引脚 0 和引脚 3-6,直到移位寄存器接收到下一个数据字节,或者我们关闭电源。
一旦你向移位寄存器发送了新的数据字节,它会通过 SPI 引脚 9(数据输出引脚)将前一个数据字节发送出去。因此,你可以通过一次操作发送多个字节的数据来控制多个移位寄存器。
注意:你通常可以从输出引脚拉取最多 20 mA 的电流,整个 74HC595 所消耗的总电流不应超过 75 mA。
每连接一个移位寄存器到 SPI 总线,你就会获得额外的八个数字输出引脚。当你需要控制大量 LED 或其他设备时,移位寄存器非常方便。在这个项目中,我们将使用它来控制一个七段数码 LED 显示器。
硬件
为了构建你的显示电路,你需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• 5 V 面包板电源
-
• ATmega328P-PU 微控制器
-
• 一个 74HC595 移位寄存器 IC
-
• 一个共阴极七段 LED 显示屏
-
• 八个 560 Ω电阻(R1–R8)
-
• 跳线
按照图 11-8 所示组装你的电路。

图 11-8:项目 44 的原理图
代码
打开终端窗口,导航到本书第十一章文件夹中的项目 44子文件夹,然后输入命令make flash进行编译并上传数据,和往常一样。上传到微控制器后,别忘了断开编程器。稍等片刻,LED 显示器上应该会按顺序显示从 0 到 9 的数字,然后重复。
让我们查看代码,看看它是如何工作的:
// Project 44 - Using the 74HC595 Shift Register
#include <avr/io.h>
#include <util/delay.h>
❶ void setupSPI()
{
PORTB |= (1 << 0); // SS pin HIGH
// Set up SPI bus
SPCR = 0b01110000;
}
❷ void dispNumSR(uint8_t value)
// Displays a number from 0–9 on the seven-segment LED display
{
// SS pin LOW
❸ PORTB &= ~(1 << PORTB0);
switch(value)
// Determine which byte of data to send to the 74HC595
{
case 0 : SPDR = 0b11111100; break; // 0
case 1 : SPDR = 0b01100000; break; // 1
case 2 : SPDR = 0b11011010; break; // 2
case 3 : SPDR = 0b11110010; break; // 3
case 4 : SPDR = 0b01100110; break; // 4
case 5 : SPDR = 0b10110110; break; // 5
case 6 : SPDR = 0b10111110; break; // 6
case 7 : SPDR = 0b11100000; break; // 7
case 8 : SPDR = 0b11111110; break; // 8
case 9 : SPDR = 0b11100110; break; // 9
}
❹ while(!(SPSR & (1<<SPIF))); // Wait for SPI transmission to finish
// SS pin HIGH
PORTB |= (1 << PORTB0);
}
int main(void)
❺ {
uint8_t i=0;
DDRB = 0b11111111; // Set PORTB as outputs
setupSPI();
while (1)
{
for (i=0; i<10; i++)
{
dispNumSR(i);
_delay_ms(250);
}
}
}
代码中包含几个自定义函数,第一个是setupSPI() ❶。我们使用这个函数来初始化 SPI 总线,并将 SS 引脚设置为高电*,然后按照本章前面解释的内容设置 SPCR 寄存器。我们将 SPCR 寄存器中的 DORD 位设置为 1,因为我们需要按 LSB 优先的顺序向 74HC595 发送数据。我们可以从 74HC595 的数据手册中的时序图中看到需要使用 56LSB 优先的要求,如图 11-9 所示:Q [A](第一个输出引脚)是第一个被设置为高电*的。

图 11-9:74HC595 的时序图
我们还需要确定 CPOL 位的值。我们将其设置为 0,因为时钟信号的极性在空闲时为低电*,或关闭。
最后需要考虑的是 CPHA,即前面提到的时钟相位位。如果你再次参考图示并比较 RCLK 时序与任何 Q 输出,你会发现它们同时从低变高。因此,数据在开始时被采样,所以我们将 CPHA 设置为 0。最后的两个位(1 和 0)我们将其设置为 0,以便将 SPI 总线设置为最大可能速度。
我们的第二个自定义函数,dispNumSR() ❷,接受一个介于 0 和 9 之间的整数,并在 LED 显示器上显示它。 它首先将 SS 引脚置低 ❸,然后使用 switch...case 语句确定要显示的数字的匹配数据字节。
微控制器将每个数据字节的最低位优先发送。 位对应于 74HC595 上的八个输出,我们将其连接到 LED 显示器段 A-G 和小数点,如 图 11-8 所示。 该数据字节然后在每个匹配的 case 语句中放入 SPDR 寄存器。 代码等待传输完成 ❹,然后将 SS 引脚置高以完成数据传输。
代码的主循环 ❺ 简单地将 PORTB 设置为输出,因为它包含我们需要的所有四个 SPI 总线连接引脚,然后调用 setupSPI() 函数设置 SPI 总线,如前所述。 然后,它向 LED 显示器发送数字 0 到 9。
我们项目中的小数点已连接,但未使用。 您可以使用发送到移位寄存器的字节的最后一位打开和关闭它。 为了挑战自己,请尝试修改 dispNumSR() 函数以接受第二个变量以控制小数点的开关。
现在您已经知道如何使用 SPI 总线控制单个设备,让我们尝试同时控制两个设备。
项目 45:使用两个 74HC595 移位寄存器
使用两个或更多移位寄存器是控制 AVR 的许多数字输出的廉价且简单的方法。 例如,该项目使用两个 74HC595 移位寄存器通过两个 LED 显示器显示双位数。
硬件
要构建您的显示电路,您将需要以下硬件:
-
• USBasp 程序员
-
• 无焊面包板
-
• 5 V 面包板电源
-
• ATmega328P-PU 微控制器
-
• 两个 74HC595 移位寄存器
-
• 两个共阴七段 LED 显示器
-
• 16 个 560 Ω 电阻(R1-R16)
-
• 跳线线
如 图 11-10 所示,组装您的电路。

图 11-10:项目 45 的原理图
代码
打开终端窗口,导航到本书 第十一章 文件夹的 Project 45 子文件夹,并输入命令 make flash 编译和上传数据。 几秒钟后,LED 显示器上应显示从 0 到 99 的数字,然后重复。
让我们看看它是如何工作的:
// Project 45 - Using Two 74HC595 Shift Registers
#include <avr/io.h>
#include <util/delay.h>
void setupSPI()
{
PORTB |= (1 << 0); // SS pin HIGH
// Set up SPI bus
SPCR = 0b01110000;
}
❶ void dispNumSR(uint8_t value)
// Displays a number from 00–99 on the seven-segment LED displays
{
uint8_t leftDigit;
uint8_t rightDigit;
❷ uint8_t digitData[] = {0b11111100, 0b01100000, 0b11011010, 0b11110010, 0b01100110,
0b10110110, 0b10111110, 0b11100000, 0b11111110, 0b11100110};
❸ leftDigit = value/10;
rightDigit = value%10;
❹ PORTB &= ~(1 << PORTB0); // SS pin LOW
❺ SPDR = digitData[rightDigit];
❻ while(!(SPSR & (1<<SPIF))); // Wait for SPI transmission to finish
SPDR = digitData[leftDigit];
while(!(SPSR & (1<<SPIF))); // Wait for SPI transmission to finish
❼ PORTB |= (1 << PORTB0); // SS pin HIGH
}
int main(void)
{
uint8_t i=0;
DDRB = 0b11111111; // Set PORTB as outputs
setupSPI();
while (1)
{
for (i=0; i<100; i++)
{
dispNumSR(i);
_delay_ms(250);
}
}
}
这个双数字版本的代码与 项目 44 类似,唯一不同的是由于我们需要控制两个移位寄存器,因此它一次发送两个字节的数据。这一次,dispNumSR() 函数 ❶ 接受 0 到 99 之间的数字,然后通过除法和取模操作 ❸ 将数字拆分为单独的每一位,并将其存储在 leftDigit 和 rightDigit 变量中。
接下来,发送 SPI 数据的操作开始。我们将 SS 引脚设置为低电* ❹,然后将表示数字 0 到 9 的字节(如 digitData 数组 ❷ 中指定)发送到移位寄存器 ❺ 中的右侧数字。等待 ❻ 字节传输完成后,我们以相同的方式发送左侧数字的字节数据。待传输完成后,代码将 SS 引脚设置为高电* ❼,以完成数据传输。两个移位寄存器的 SS 引脚连接在一起,因此我们只需一个数字输出即可控制它们。
第二个(个位)数字的字节先被发送,因为它位于第二个移位寄存器——该字节首先放入第一个移位寄存器,然后个位字节发送后,将十位字节推入第二个移位寄存器。也就是说,第一个移位寄存器包含十位数字的数据,第二个移位寄存器包含个位数字的数据。一旦 SS 引脚被设置为高电*,移位寄存器的输出就会被激活,LED 显示屏开始显示数字。
虽然这个项目控制的是 LED 显示屏,但你现在已经具备了使用多个移位寄存器来扩展 AVR 输出端口的技能。在其他情况下,如果你需要控制更大的数字显示屏——最多可显示八位数字——下一个项目将非常适合你。
项目 46:使用 MAX7219 LED 驱动 IC
当你需要为一个项目使用两个以上的七段数字显示屏时,接线和相关控制可能会变得相当复杂。幸运的是,有一个解决方案:Maxim MAX7219 LED 驱动 IC,这是一款流行的 IC,可以同时控制多达 64 个 LED。反过来,我们可以使用这些 LED,通过 SPI 总线只需四根控制线,就能同时显示八个数字。这个项目展示了如何使用该显示模块。
硬件部分
MAX7219 提供两种封装类型:穿孔式(图 11-11)和表面贴装式(图 11-12)。

图 11-12:表面贴装类型的 MAX7219

图 11-11:穿孔式类型的 MAX7219
穿孔版本在使用无焊面包板或制作自制手工焊接的印刷电路板(PCB)时最为实用。如果您难以找到 MAX7219,Allegro AS1107 是一个直接替代品。
如果您想控制大型数字显示屏,您可以轻松找到预先组装的模块,通常带有四到八个数字,且配备 MAX7219 模块。对于本项目,我们将使用一个八位模块,如图 11-13 所示。

图 11-13:八位 LED 模块
这些模块使用 MAX7219 的表面贴装版本,焊接在模块 PCB 的背面。模块通常包括一些内联排针,以便连接控制线。如果您还没有做,请按照图 11-14 所示将这些排针焊接到模块上。

图 11-14:连接内联排针
要构建电路,您需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• 5 V 面包板电源
-
• ATmega328P-PU 微控制器
-
• MAX7219 八位模块
-
• 470 μF 16 V 电解电容器
-
• 跳线
按照图 11-15 所示,组装您的电路。

图 11-15:项目 46 的原理图
当您按照原理图构建电路时,按照表 11-2 所示连接显示模块。
| 表 11-2:ATmega328P-PU 与 MAX7219 的连接 |
|---|
| ATmega328P-PU |
| --- |
| 7 |
| 8 |
| 17 |
| 14 |
| 19 |
请注意,显示屏可能会快速绘制并停止绘制电流,这有时会影响电源电压。因此,我们使用一个 470 μF 的电解电容器来保持 5 V 电源*稳。您可以在第二章复习电容器的类型。
在我们深入研究代码之前,让我们考虑一下通过 SPCR 寄存器设置 SPI 所需的参数。从图 11-5 的 MAX7219 时序图中,我们可以看到,我们应该将 DORD 位设置为 0,因为 MAX7219 需要首先发送 LSB 数据。我们将 CPOL 位设置为 0,因为时钟信号在数据传输开始时为低电*,我们还将 CPHA 位设置为 0,因为时钟信号的极性在空闲时为低电*。
现在我们需要了解如何控制 MAX7219。每次我们希望 IC 执行某个操作时,都必须发送两字节数据。第一字节是控制寄存器的地址(其他 IC 也有寄存器,就像微控制器一样),第二字节是要存储在该寄存器中的值。这可能是显示亮度等设置配置,或者是表示要在某个数字上显示的数字值。
每个寄存器的可能值在 MAX7219 中使用十六进制数字(16 进制)表示,因此我们将使用这些数字以节省工作量。你可以将十六进制数字存储在 char 类型的变量中。为了方便你的参考和研究,你可以下载并查看 MAX7219 的数据手册,网址为 www.maximintegrated.com/en/products/power/display-power-control/MAX7219.html。
代码
打开终端窗口,导航到本书的 第十一章 文件夹中的 项目 46 子文件夹,输入命令 make flash 来编译并上传数据,像往常一样。片刻之后,显示屏应该会显示八个零,然后向上计数,直到达到 9,999,999,然后重复。
让我们看看这是怎么做的:
// Project 46 - Using the MAX7219 LED Driver IC
#include <avr/io.h>
#include <util/delay.h>
❶ void writeMAX7219(char hexdata1, char hexdata2)
// Sends two bytes in hexadecimal to the MAX7219
{
PORTB &= ~(1 << PORTB0); // SS pin LOW
SPDR = hexdata1; // Send value of hexdata1
while(!(SPSR & (1<<SPIF))); // Wait for SPI transmission to finish
SPDR = hexdata2; // Send value of hexdata2
while(!(SPSR & (1<<SPIF))); // Wait for SPI transmission to finish
PORTB |= (1 << PORTB0); // SS pin HIGH
}
❷ void blankMAX7219()
// Blanks all digits
{
uint8_t i;
for (i=1; i<9; i++) // Blank all digits
{
writeMAX7219(i,15); // Send blank (15) to digit register (i)
}
}
❸ void initMAX7219()
// Set up MAX7219 for use
{
PORTB |= (1 << 0); // SS pin HIGH
SPCR = 0b01010000; // Set up SPI bus for MAX7219
writeMAX7219(0x09,0xFF); // Mode decode for digits
writeMAX7219(0x0B,0x07); // Set scan limit to 8 digits: 0x09 + 0xFF)
writeMAX7219(0x0A,0x01); // Set intensity to 8 - 0x0A + 0x08)
writeMAX7219(0x0C,0x01); // Mode display on
blankMAX7219();
}
❹ void dispMAX7219(uint8_t digit, uint8_t number, uint8_t dp)
// Displays "number" in location "digit" with decimal point on/off
// Digit: 1~8 for location 1~8
// Number: 0~15 for 0~9, - E, H, L, P, blank
// dp: 1 on, 0 off
{
❺ if (dp==1) // Add decimal point
{
number = number + 128;
}
writeMAX7219(digit, number);
}
❻ void numberMAX7219(uint32_t value)
// Displays a number between 0–99999999
uint8_t digits[9];
uint8_t i = 1;
for (i=1; i<9; i++)
{
❼ digits[i]=15; // Sending 15 blanks the digit
}
i = 1;
while (value > 0) // Continue until value > 0
{
digits[i] = value % 10; // Determine and store last digit
value = value / 10; // Divide value by 10
i++;
}
for (i=1; i<9; i++)
{
dispMAX7219(i, digits[i],0);
}
}
int main(void)
❽ {
uint32_t i;
DDRB = 0b11111111; // Set PORTB as outputs
initMAX7219();
while (1)
{
for (i = 0; i<100000000; i++)
{
numberMAX7219(i);
_delay_ms(100);
}
}
}
为了节省时间,我们使用一个自定义函数 writeMAX7219( char hexdata1 , char hexdata2 ) ❶,通过 SPI 总线将两字节十六进制数据发送到 IC。此函数将 SS 引脚拉低,将第一字节数据分配给 SPDR 寄存器,等待传输完成,然后对第二字节重复相同过程,最后将 SS 引脚重新拉高。设置 SPI 总线之后,我们使用另一个自定义函数 initMAX7219() ❸ 将值放入四个配置寄存器来初始化 MAX7219。
在写入任何数字到显示屏之前,我们首先引入 blankMAX7219() 函数 ❷,确保每次写入时清空显示屏。如果没有这个函数,假设我们先写入 32,785,然后再写入 45,显示屏将会显示 32,745。
要在显示屏上显示一个数字,我们使用 writeMAX7219() 发送两字节数据。第一字节数据是数字的位置,从右到左(位置 0 到 7)。每个数字位置的地址恰好是位置加 1;例如,数字 5 的地址在十六进制中是 0x06。第二字节数据是要显示的实际数字。例如,要在我们的模块的最左边显示数字九,我们将发送 0x08 和 0x09,如下所示:
writeMAX7219(0x08, 0x09);
如果方便的话,你也可以使用十进制数字或整数变量:
writeMAX7219(8, 9);
你可以在 MAX7219 数据手册的表格 2 中查看数字位置的地址映射,并在表格 5 中查看可以显示的字符。
现在我们已经设置好writeMAX7219()函数来轻松地将数据写入 MAX7219,我们将在另一个函数中使用这个函数:dispMAX7219(uint8_t digit, uint8_t number, uint8_t dp) ❹。我们用它来在有无小数点的位置显示数字。将dp设置为 1 以显示小数点,或者设置为 0 以不显示。例如,要在模块的最右侧数字上显示没有小数点的数字 3,我们将使用:
`dispMAX7219(1, 3, 0)`
小数点通过向表示数字的字节添加 128(即十六进制的0xF0)来激活❺。
迄今为止提到的所有自定义函数都为我们最终的函数numberMAX7219( uint32_t value ) ❻做了铺垫,该函数接受一个 0 到 99,999,999 之间的整数并在我们的显示模块上显示出来。该函数使用取模和除法将整个数字分解为单独的数字,并将它们放入数组中。然后,它遍历数组并将每个数字发送到显示器。
在函数的开始,我们用数字 15 填充数组❼。这是因为将 15 作为数字值发送给 MAX7219 会使 IC 清空被寻址的数字,从而避免显示未使用的数字的前导零。最后,代码的主循环❽设置了用于 SPI 的接口引脚为输出,并依次在 LED 显示屏上显示数字 0 到 99,999,999。
这看起来可能是一项大工程,但既然你已经掌握了轻松驱动这些较大数字显示器的工具,你可以在自己的项目中重用这些功能。如果你喜欢挑战,为什么不自己编写一个 MAX7219 库呢?与此同时,为了为后续项目做准备,我想介绍一下你 AVR 电路中的一个新组件。
项目 47:添加复位按钮
在本书的未来项目以及你自己的创作中,会有需要将项目复位的时刻,以便它能够像第一次开机时那样重新开始操作。为了实现这一点,你的项目将需要一个复位按钮,我们现在就来构建它。复位按钮节省时间,比断开电源再重新连接方便得多。
要在你的 AVR 项目中添加复位按钮,你将需要以下组件:
-
• 按钮
-
• 10 kΩ电阻
-
• 跳线
ATtiny85 的复位按钮电路见图 11-16。

图 11-16:ATtiny85 的复位按钮电路
图 11-17 显示了 ATmega328P-PU 的复位按钮电路。

图 11-17:ATmega328P-PU 的复位按钮电路
如果你将其与第三章讨论的按钮进行对比,注意到接线上的区别。这种按钮配置,电阻连接在 5V 和 RESET 引脚之间,保持该引脚在正常操作时处于高电*状态,称为上拉配置。当用户按下按钮时,RESET 引脚被设置为低电*,因为按钮直接将引脚连接到 GND。
我们之所以使用这种上拉配置,是因为元件数据手册中的电路符号:RESET 引脚的标签上有一条实心条纹,而其他引脚的标签则没有。这条条纹意味着该引脚在正常操作时的默认输入为高电*,并且当该引脚被设置为低电*时,会激活该引脚所使用的功能。
你无需为项目添加任何代码来支持复位按钮——这只是一个简单的硬件添加。按钮设置好后,我们接下来将控制两个不同的 SPI 设备,并学习如何从 SPI 总线接收数据。这些部分将为我们本章的最终项目做准备,在该项目中我们将制作一个简单的数字电压表。
同一总线上的多个 SPI 设备
你可以在同一 SPI 总线上使用两个或更多不同的 SPI 设备,且这样做只需要为每个设备增加一个额外的数字输出引脚。只需将所有的 SCK、MOSI 和 MISO 引脚连接在一起,然后将 SS 引脚连接到 AVR 上的各自数字输出引脚。例如,图 11-18 展示了两个 SPI 设备在一条总线上,每个设备的 SS 线连接到独立的 PORTB 引脚。

图 11-18:两个 SPI 设备连接到一个微控制器
当需要与特定的 SPI 设备通信时,只需使用适当的 SS 连接并按常规操作进行。我们将在下一个项目中进行此操作,该项目使用了来自项目 46 的 MAX7219 LED 显示器以及一个新设备。
从 SPI 总线接收数据
如前所述,我们通过将数据字节放入 SPDR 寄存器来从微控制器发送数据到 SPI 设备。从 SPI 设备接收数据字节需要两步操作:首先,我们正在通信的 SPI 设备发送一个数据字节,然后 AVR 将这个字节放入 SPDR 寄存器供我们使用。
因此,你可以将 SPI 总线看作是一个连续的数据循环,如图 11-19 所示。

图 11-19:SPI 总线数据传输
当数据从微控制器离开并传送到 SPI 设备时,一位数据也从 SPI 设备离开并返回到 SPDR 寄存器。当你将一个完整的字节数据放入 SPDR 寄存器时,它会传送到 SPI 设备,推动数据从 SPI 设备推出并进入 SPDR 寄存器。
这意味着当你需要从 SPI 设备获取一个字节的数据时,你需要向该设备发送一个字节的数据,以“推动”数据从 SPI 设备返回到 SPDR 寄存器。你将在以下项目中看到这一过程的实现。
项目 48:使用 MCP3008 ADC 集成电路
在第三章中,你开始学习如何使用 AVR 内置的 ADC 引脚测量连接到外部设备(如电位器和温度传感器)的电压。然而,如果你想使用更多的 ADC 引脚,可能会出现 ADC 引脚和其他用途之间的冲突——也就是说,你可能已经计划将微控制器上用于 ADC 的引脚用于其他目的。一个替代方案是使用外部 ADC 集成电路,比如图 11-20 中展示的 Microchip MCP3008 8 通道 ADC 集成电路,它有八个 ADC 引脚。

图 11-20:Microchip MCP3008 8 通道 ADC 集成电路
这八个引脚中的每一个可以测量 0 V 到 5 V 的直流电压,并且每个引脚返回一个 10 位的测量值。MCP3008 易于使用,因为它连接到 SPI 总线,你无需担心其他非 SPI 总线的 AVR 寄存器进行设置或控制。请参阅图 11-21 中的原理图。

图 11-21:MCP3008 原理符号
要将 ATmega328P-PU 连接到 MCP3008,请遵循表 11-3 中的指南。
| 表 11-3:ATmega328P-PU 与 MCP3008 的连接 |
|---|
| ATmega328P-PU |
| --- |
| 5 V |
| 5 V |
| GND |
| 15 |
| 17 |
| 18 |
| 19 |
还有一些 MCP3008 的额外引脚需要考虑。第一个是 V [REF],用于电压参考。我们的 ADC 通过 10 位分辨率测量模拟信号,表示信号的数值在 0 到 1,023 之间。在我们的项目中,我们将 V [REF]引脚连接到 5 V 电源,为 ADC 提供参考电压——即测量信号的上限(下限为零)。
后续,你可能希望测量 0 到 3 V 直流电压之间的信号。你可以将 V [REF]引脚连接到 3 V 信号。这样,测量会更准确,因为 1,023 个可能值将覆盖 0 V 到 3 V 之间的范围,而不是分布在 0 V 到 5 V 之间。
另外八个引脚用于 ADC 通道 0 到 7,可以连接最大为 5 V DC 的信号。不要超过 5 V,否则会损坏 IC。被测信号的负极或 GND 连接到 IC 上的 AGND 引脚。
在这个项目中,你将使用 MCP3008 通过一个 ADC 来测量信号,然后通过我们的 MAX7219 模块显示值,来自 Project 48。除了让你熟悉 MCP3008 外,这个项目还可以很好地展示如何在同一个微控制器上使用两个 SPI 总线设备。
硬件
要构建电路,你需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• Microchip MCP3008 集成电路
-
• 5 V 面包板电源
-
• ATmega328P-PU 微控制器
-
• MAX7219 八位数字显示模块
-
• 470 μF 16 V 电解电容
-
• 跳线
按照图 11-22 所示组装电路。在遵循原理图的同时,将显示模块按照表 11-2 所示连接。

图 11-22:Project 48 的原理图
代码
打开终端窗口,导航到本书第十一章文件夹中的Project 48子文件夹,并输入命令make flash进行编译和上传数据,就像*常一样。由于当前没有连接输入,我们说 ADC 的输入是浮动输入。这意味着返回的值有些随机,显示屏应该会显示随机数字。
现在连接一个输出在 0 V 到 5 V DC 之间的信号,例如 AA 电池或你之前项目中的 TMP36 温度传感器,连接到 Signal+/–引脚。确保将信号或电池的正极连接到 ADC 的 Signal+引脚(引脚 1),负极连接到 GND(在原理图中也标记为 Signal–)。显示屏现在应该显示 ADC 测量的毫伏数值(1 伏等于 1000 毫伏)。如果你没有电池或传感器或任何其他可以测量的东西,可以将 ADC 输入直接连接到 5 V 或 GND 线,看看它分别接* 5 V 或 0 V。
与所有 SPI 设备一样,我们从 SPCR 寄存器中确定 SPI 总线设置的参数。图 11-23 显示了来自 MCP3008 数据手册的时序图(数据手册可在www.microchip.com/wwwproducts/en/MCP3008/ 上找到)。

图 11-23:MCP3008 的时序图
我们可以看到,我们应该将 DORD 位设置为 0,因为 MCP3008 要求先发送最低有效位(LSB)。我们还将 CPOL 和 CPHA 位设置为 0,因为时钟信号在数据传输开始时为低电*,并且时钟信号在空闲时极性为低电*。
现在我们需要了解如何控制 MCP3008。我们将以它最简单的形式使用,即单端 ADC(因此只测量 0 V 到 V [REF],在我们的例子中是 5 V)。每次我们想使用 MCP3008 时,我们将 SPCR 寄存器设置为 0b01010010。(如果你使用多个 SPI 设备,你需要在与每个设备通信之前设置 SPCR。)
接下来,我们向 MCP3008 发送三个字节的数据,以便它通过两个字节将所需的 ADC 值返回给微控制器。我们首先将 0b00000001 放入 SPDR 作为“起始位”,以激活 MCP3008。接下来,我们将一个配置数据字节放入 SPDR。第一个位是 1,表示使用单端 ADC,然后接下来的三个位以二进制形式表示使用哪个 ADC(0 到 7)。由于我们使用的是 ADC 0,所以将这三个位设置为 0。最后四个位未使用,因此我们保持为 0。
一旦我们将配置字节发送给 MCP3008,它将返回一个字节数据,表示 ADC 结果的最重要的两个位(字节的第 0 位和第 1 位)。如前所述,数据在 SPI 总线上循环传输,因此 MCP3008 的字节会出现在 SPDR 寄存器中。为了捕获这个字节数据,我们通过将一个整数变量放入 SPDR 来“推送”它。字节的剩余 6 位将包含随机数据,因此我们使用位运算 & 将它们设置为 0。
最后,我们需要 ADC 结果的最后 8 位,因此我们将一个随机字节的数据(全 0 也可以)通过 SPI 总线发送,放置 0 到 SPDR 中以接收来自 ADC 的字节。等待传输完成后,我们将另一个整数变量等于 SPDR,这时 SPDR 中就包含了剩余的 ADC 数据。
现在我们有 2 字节数据,其中一个包含最高的 2 位(MSB),另一个包含其余 8 位数据(LSB),我们需要将它们转换为一个单一的值:一个 16 位整数,我们称之为 result。为此,我们将 MSB 变量左移 8 位到 result 整数中,然后使用位运算 | 将 LSB 变量放入 result 中。最终,我们得到一个包含 ADC 10 位值的整数变量,其值在 0 到 1,023 之间。
让我们看看代码,了解这如何工作:
// Project 48 - Using the MCP3008 ADC IC
#include <avr/io.h>
#include <util/delay.h>
void writeMAX7219(char hexdata1, char hexdata2)
// Sends two bytes in hexadecimal to MAX7219
{
SPCR = 0b01010000; // Set up SPI bus for MAX7219
PORTB &= ~(1 << PORTB0); // SS pin LOW
SPDR = hexdata1; // Send value of hexdata1
while(!(SPSR & (1<<SPIF))); // Wait for SPI transmission to finish
SPDR = hexdata2; // Send value of hexdata2
while(!(SPSR & (1<<SPIF))); // Wait for SPI transmission to finish
PORTB |= (1 << PORTB0); // SS pin HIGH
}
void blankMAX7219()
{
uint8_t i;
for (i=1; i<9; i++)
{
writeMAX7219(i,15);
}
}
void initMAX7219()
// Set up MAX7219 for use
{
PORTB |= (1 << 0);
SPCR = 0b01010000;
writeMAX7219(0x09,0xFF);
writeMAX7219(0x0B,0x07);
writeMAX7219(0x0A,0x01);
writeMAX7219(0x0C,0x01);
blankMAX7219();
}
void dispMAX7219(uint8_t digit, uint8_t number, uint8_t dp)
{
if (dp==1) // Add decimal point
{
number = number + 128;
}
writeMAX7219(digit, number);
}
void numberMAX7219(uint32_t value)
// Displays a number between 0–99999999
uint8_t digits[9];
uint8_t i = 1;
for (i=1; i<9; i++)
{
digits[i]=15; // Sending 15 blanks the digit
}
i = 1;
while (value > 0) // Continue until value > 0
{
digits[i] = value % 10; // Determine and store last digit
value = value / 10; // Divide value by 10
i++;
}
for (i=1; i<9; i++)
{
dispMAX7219(i, digits[i],0);
}
}
❶ uint16_t readMCP3008()
// Read channel 0 and return value
{
❷ uint8_t LSB;
uint8_t MSB;
uint16_t ADCvalue; // Holds data to return to main code
SPCR = 0b01010010; // Set up SPI bus for MCP3008
// SS on PB1 (15)
PORTB &= ~(1 << PORTB1); // SS pin LOW
❸ SPDR = 0b00000001; // Send start bit
while(!(SPSR & (1<<SPIF))); // Wait for SPI transmission to finish
SPDR = 0b10000000; // Select ADC0
while(!(SPSR & (1<<SPIF)));
// Place top 2 bits of ADC value in MSB, ignore unwanted bits
❹ MSB = SPDR & 0b00000011;
❺ SPDR = 0b00000000; // Request next 8 bits of data
while(!(SPSR & (1<<SPIF)));
// Place lower 8 bits of data in LSB
❻ LSB = SPDR;
❼ PORTB |= (1 << PORTB1); // SS pin HIGH
❽ ADCvalue = MSB << 8 | LSB; // Construct final 10-bit ADC value
return ADCvalue;
}
int main(void)
{
uint16_t ADCoutput;
DDRB = 0b11111111; // Set PORTB as outputs
initMAX7219();
while (1)
{
❾ ADCoutput = readMCP3008();
// Convert ADC value to millivolts
⓿ ADCoutput = ADCoutput * 4.8828;
numberMAX7219(ADCoutput);
_delay_ms(100);
}
}
在这段代码中,我们重用了 Project 48 中的所有 MAX7219 函数,将 ADC 值以毫伏为单位显示。我们的主代码接收 ADC 值,将其转换为毫伏,然后在 MAX7219 显示器上显示。到现在你应该已经熟悉基本结构了。
我们还声明了一个新函数 readMCP3008() ❶,它返回一个 16 位整数,包含 MCP3008 第一个 ADC(0)所测量的值。在函数内部,我们定义了三个变量——两个 8 位整数用于存储 ADC 返回的 MSB 和 LSB 数据,以及一个 16 位整数来返回 ADC 测量的完整值——并设置 MCP3008 的 SPCR 寄存器 ❷。然后我们将 SS 引脚拉低,像往常一样启动 SPI 总线。
在发送启动位以激活 MCP3008 ❸后,我们像往常一样等待 SPI 总线传输完成。接着,我们发送配置字节,告诉 MCP3008 我们希望从通道 0 获取单端 ADC 结果。完成后,MCP3008 会返回结果的 MSB。
我们将 MSB 存储在变量MSB ❹中,并执行按位 & 操作,以去除不必要的随机位。然后,我们通过发送一个随机字节(此处为全 0)❺来请求数据的 LSB,等待传输完成,并将数据存储在LSB ❻中。完成此操作后,我们就完成了对 MCP3008 的操作,因此通过将 SS 引脚设置为高电*❼来将其从 SPI 总线中移除。
我们现在有两个字节的数据,需要将其转换为单个整数作为此函数的返回值。我们通过将 MSB 移位到返回变量 ADCvalue 的高 8 位,并用 | 操作将 LSB 放入其中 ❽。
现在我们已经从 ADC 获取了一个值,接下来进入代码的主循环,将 ADC 读取的值赋给一个 16 位整数 ❾。然而,这个值介于 0 到 1,023 之间,所以我们需要将其转换为毫伏(mV)。我们的 V[REF] 是 5 V,即 5,000 mV。因此,我们将 5,000 除以 1,024 来确定转换这个 ADC 值为毫伏的乘数:4.8828。然后,程序将 ADC 值转换为毫伏 ⓿并将其发送到显示器上。
此时你应该理解如何实现 SPI 总线,包括如何查阅 SPI 设备的数据手册,找到与 AVR 配合使用所需的信息。你还学会了如何利用有用的移位寄存器集成电路(IC)、MAX7219 显示驱动器以及 MCP3008 ADC。这些知识应该为你使用其他基于 SPI 的部件进行自己的项目做好准备。
在下一章中,你将学习如何使用更多有趣且实用的部件,通过另一种类型的数据总线:I²C。
第十二章:# AVR 和 I ² C 总线

集成电路间总线,或 I ² C,是另一种常见的数据总线类型。最初由飞利浦(现在是 NXP)设计,该总线旨在使一个或多个设备能够在短距离内与主设备(如微控制器)进行数据传输和接收。本章将向你展示如何设置 I ² C,并使用它与外部 IC 进行通信,通过学习所需的功能和硬件,并探索一些流行的 I ² C 设备示例。
你将学习如何提高 AVR 微控制器的工作速度,使你能够在 AVR 上实现 I ² C 数据总线。在学习如何控制 MCP23017 I ² C 16 位 I/O 扩展 IC 并从外部 I ² C EEPROM 存储和检索数据后,你将首次使用 DS3231 I ² C 实时钟 IC。
有成千上万的设备使用 I ² C 总线,从显示器和电机控制器到传感器等。完成本章内容后,你将准备好利用这些设备来实现更复杂的项目,如天气监测和显示解决方案、多舵机机器人,以及需要为微控制器添加更多 I/O 端口的项目。
提高 AVR 速度
到目前为止,你的 AVR 项目一直以 1 MHz 的 CPU 速度运行,使用 AVR 的内部振荡器生成所需的时钟信号,用于定时和其他操作。这种方式最小化了复杂性,并减少了电路所需的零件数量。然而,有时你可能需要使用需要更快数据总线的部件。这些部件包括那些通过 I ² C 总线与微控制器通信的部件。为了使用这个总线,你需要了解如何通过更高的 CPU 速度来运行你的项目,从而生成更快的时钟信号。
要提高 ATmega328P-PU 的 CPU 速度,你需要进行两项修改:一项是硬件修改,另一项是项目的 Makefile 修改。为了改变硬件,你需要额外的三个组件:两个 22 pF 陶瓷电容和一个 16 MHz 晶体振荡器。这些通常被称为 晶体,它们能产生一个准确频率的电信号,在本例中是 16 MHz。图 12-1 显示了我们将使用的晶体。

图 12-1:16 MHz 晶体振荡器
晶体是非极化的。我们的 16 MHz 晶体的原理符号如 图 12-2 所示。

图 12-2:晶体振荡器原理符号
晶体决定了微控制器的工作速度。例如,我们将组装的电路工作在 16 MHz,这意味着它每秒可以执行 1600 万条处理器指令。当然,这并不意味着它可以如此快速地执行一行代码或函数,因为执行单行代码需要许多处理器指令。
图 12-3 中的原理图展示了将晶体连接到 ATmega328P-PU 微控制器所需的附加电路。

图 12-3:带外部晶体电路的 ATmega328P-PU
除了上述硬件更改外,你还需要编辑项目的 Makefile,以告诉工具链将微控制器设置为 16 MHz 运行。这对于所有使用外部晶体的项目都是必要的。要为本章的第一个项目执行此操作,打开位于本书第十一章文件夹中的Project 49子文件夹中的 Makefile。向下滚动到第 21 行,标记为FUSES,并将其更新为以下代码行(完成后别忘了保存文件):
FUSES = -U lfuse:w:0xff:m -U hfuse:w:0xde:m -U efuse:w:0x05:m
注:将不同的 Makefile 单独存储是一个好主意,这样你可以在需要时将它们轻松复制到自己的项目文件夹中。如果你从 No Starch Press 网站下载了本书的代码,Makefile 已经为每个项目设置好了。
介绍 I ² C 总线
I ² C 总线的工作方式类似于 SPI 总线,即数据通过一根电缆以串行方式传输和接收,往返于微控制器(串行数据线,通常称为SDA),而另一根电缆则传输时钟信号(串行时钟线,通常称为SCL或SCK)。该信号与数据信号同步,以确保准确的数据传输。我们项目的时钟信号频率为 100 kHz。
注:I ² C 是一个双向总线,数据沿一根线传输或接收。由于时钟信号通过另一根线传输,因此一些供应商(如 Microchip)称 I ² C 为双线串行接口(TWI)。
在 I ² C 总线上,微控制器充当主设备,总线上的每个 IC 都是从设备。每个从设备都有自己的地址,这是一个 7 位数字,允许微控制器与该设备进行通信。每个设备通常有一系列可供选择的 I ² C 总线地址,详细信息请参阅制造商的数据手册。如果一个 IC 有两个或多个潜在的 I ² C 总线地址,我们通过以特定方式连接 IC 引脚来决定使用哪个地址。
图 12-4 展示了 I ² C 总线在工作中的一个示例,通过数字示波器(DSO)捕获。

图 12-4:数据在 I ² C 总线上的传输
一旦激活,使用 I ² C 总线遵循一个简单的逻辑模式,如图 12-4 所示。总线的两条线保持在高电*;我们通过上拉电阻将它们连接到 5 V。我们通过设置数据线为低电*并在时钟线上启动时钟信号来向主设备(微控制器)发送开始信号。接下来,我们发送与我们希望通信的设备的 7 位地址,之后跟随一个 0(表示我们想写入数据)或一个 1(表示我们希望设备向我们发送数据)。
然后,从设备要么通过发送ACK 位(0)确认已接收到来自主设备的字节,要么如果发生错误,则发送NACK 位(1),表示“未确认”,并指示主设备停止发送。如果从设备已经完成向主设备发送数据,它也可能发送 NACK。
你可以在图 12-4 中看到这个操作示例,主设备启动 I ² C 总线,然后发送0x20+0告诉从设备它正在写数据,接着再发送两字节的数据到从设备。十六进制是与 I ² C 总线一起使用的首选进制系统,但如果使用二进制或十进制对你来说更简单或更合适,也可以使用。这看起来可能很复杂,但在查看本章中的项目后,你将能很快掌握 I ² C 技术。
引脚连接和电压
每个 I ² C 设备使用两个引脚——通常标记为 SCL 和 SDA,如前所述——进行通信。这些引脚连接到微控制器上的相应引脚。如果你有多个 I ² C 设备,它们都通过相同的连接回到微控制器。最后,在 5 V(供电电压)和每条 I ² C 线之间放置一个上拉电阻。图 12-5 展示了这个简化示例。

图 12-5:I ² C 总线布线的简化示例
与 SPI 总线一样,你的 I ² C 总线设备必须在 5 V 下工作或能够容忍 5 V 的工作,因为我们的项目中微控制器运行在 5 V 下。在使用之前,确保与卖家或制造商确认。如果你必须使用一个工作在低电压(如 3.3 V)的 I ² C 总线设备,可以使用像第十一章中提到的 I ² C 兼容电*转换器,PMD Way 部件号 441079。
再次使用这些电*转换器非常简单。只需使用一对通道连接 I²C 总线,将 5V 电缆连接到 HV 焊盘,并将匹配的低电压电缆连接到 LV 焊盘,同时将 GND 两侧连接到电路板。
写入 I²C 设备
为了向你展示如何实现 I²C 总线,我将首先解释如何向 I²C 设备写入数据,然后在本章后面介绍如何读取数据。写入数据到 I²C 总线设备需要五个在我们 AVR 工具链中不存在的函数,因此我们使用以下自定义函数来完成必要的操作。这些函数包含在本章的 I²C 总线项目中。
启用 I²C 总线
I2Cenable()函数将两个 GPIO 引脚(ATmega328P-PU 上的 PC5 和 PC4)从常规用途转换为 I²C 总线引脚(分别为 SCL 和 SDA):
void I2Cenable()
{
TWBR = 72; // 100 kHz I2C bus
TWCR |= (1 << TWEN); // Enable I2C on PORTC4 and 5
}
我们首先设置TWBR (TWI 比特率寄存器),该寄存器用于根据 Microchip 提供的公式来确定 I²C 总线的时钟速度。在这里,我们使用值 72,这使得微控制器将 CPU 的速度分频到 100 kHz,作为我们的时钟。我们还将TWEN (双线使能)位设置为 1,这会将 GPIO 引脚转换为 I²C 总线引脚 SCL 和 SDA。
等待 I²C 总线完成
总线操作不是瞬间完成的,因此我们在执行其他 I²C 总线命令后,使用I2Cwait()函数来等待数据传输完成,然后再执行总线上的其他操作:
void I2Cwait()
{
while (!(TWCR & (1<<TWINT)));
}
我们可以通过查看 TWCR 寄存器的TWINT (双线中断)位来检查总线是否繁忙。当总线空闲时,该代码将 TWINT 位设置为 1,因此当 TWINT 为 0 时,代码什么也不做。
启动 I²C 总线
I2CstartWait()函数启动向 I²C 总线上的设备发送数据的过程。它发送二级地址以启用总线上的所需设备,并等待该设备的确认,表明它已准备好使用:
void I2CstartWait(unsigned char address)
{
uint8_t status;
while (1)
{
❶ TWCR = (1<<TWINT) | (1<<TWSTA) | (1<<TWEN);
❷ I2Cwait();
❸ status = TWSR & 0b11111000;
if ((status != 0b00001000) && (status != 0b00010000)) continue;
❹ TWDR = address;
TWCR = (1<<TWINT) | (1<<TWEN);
I2Cwait();
❺ status = TWSR & 0b11111000;
if ((status == 0b00100000)||(status == 0b01011000))
{
❻ TWCR = (1<<TWINT) | (1<<TWEN) | (1<<TWSTO);
while(TWCR & (1<<TWSTO));
continue;
}
break;
}
}
该函数首先通过将 TWCR 寄存器中的 TWINT 设置为 1,设置总线上的启动条件,然后通过设置 TWSTA(双线接口启动条件)和激活总线(TWEN❶)来启动。我们等待这些操作完成❷,然后加载 TWSR 寄存器的值❸,仅当状态位 TWS3 和 TWS4 未设置为 1 时才继续。如果设置为 1,这些位表示启动条件或重复启动条件未成功发送,因此我们无法继续。
在这一点上,公交初始化已经成功,因此我们现在发送二级地址以启用总线上的所需设备。我们通过将地址加载到 TWDR 寄存器❹中,然后通过将 TWCR 寄存器中的 TWINT 和 TWEN 设置为 1 来发送它。接下来会有一个短暂的等待时间,以便完成传输。
我们再次通过加载 TWSR 寄存器的值 ❺ 来检查 I ² C 总线的状态,只有在从设备未忙碌或未确认写入时,我们才会继续。如果设备忙碌或未确认,我们将发送 I ² C 停止命令 ❻ 并等待该指令完成。
向 I ² C 总线写数据
这个函数仅通过 I ² C 总线发送一个字节的数据:
void I2Cwrite(uint8_t data)
{
TWDR = data;
TWCR |= (1 << TWINT)|(1 << TWEN);
I2Cwait();
}
一旦 I ² C 总线通信初始化完成并启动,我们使用此功能向被寻址的设备写入一个字节的数据。我们将数据加载到 TWDR 寄存器中,然后通过 TWCR 寄存器发送出去。接着我们等待片刻,直到该过程完成。你可以连续调用此函数两次或更多次,以发送多个字节的数据。
停止 I ² C 总线
I2Cstop() 函数释放 GPIO 引脚的 I ² C 总线功能,并将其恢复为正常工作:
void I2Cstop()
{
TWCR |= (1 << TWINT)|(1 << TWEN)|(1 << TWSTO);
}
当你的代码完成了 I ² C 总线的操作后,使用此功能停止总线并将用于 SDA 和 SCL 的 GPIO 引脚恢复为正常工作状态。通过三个位的变化来停止总线操作:
-
• 将 TWINT 位设置为 1,表示微控制器完成了对总线的使用。
-
• 将 TWSTO 位设置为 1,向总线发送停止条件,告知总线上的设备总线正在停用。
-
• 将 TWEN 位设置为 1 禁用总线操作。
现在,让我们将理论付诸实践,使用 ATmega328P-PU 通过 I ² C 总线控制一些有趣的设备。我们的第一个设备将是 Microchip MCP23017,它为我们的微控制器添加了 16 个 I/O 引脚。
项目 49:使用 MCP23017 16 位 I/O 扩展器
当你的 AVR 基础项目没有足够的数字 GPIO 引脚时,你可以通过 Microchip MCP23017 添加 16 个额外的引脚,如图 12-6 所示。我们将在本项目中首次使用 MCP23017,并将其作为向 I ² C 总线写数据的演示。

图 12-6:Microchip MCP23017 16 位 I/O 扩展器
MCP23017 有八个可能的总线地址,因此你可以在同一总线上连接最多八个设备,从而获得多达 128 个 GPIO 引脚。I/O 引脚按两组各八个排列,如图 12-7 中的电路符号所示。每个引脚最多可处理 20 mA 电流,尽管整个 MCP23017 的最大连续电流为 125 mA。

图 12-7:Microchip MCP23017 电路符号
要设置 I ² C 总线地址,你需要将标记为 A0 到 A2 的引脚连接到 5 V 或 GND 的组合。如果你只使用一个 MCP23017,你可以通过将三个引脚连接到 GND,将总线地址设置为 0x20。如果你使用两个或更多 MCP23017,或者需要让另一个设备使用 0x20 地址,请参考表 12-1 进行配置。
| 表 12-1 : MCP23017 I ² C 总线地址配置 |
|---|
| 总线地址 |
| --- |
0x20 |
0x21 |
0x22 |
0x23 |
0x24 |
0x25 |
0x26 |
0x27 |
如前所述,总线地址是一个 7 位数字,通过在末尾添加 0 或 1 来补充为 8 位数字,以便写入或读取总线。你可以通过将 1 从右边位移来创建这个 8 位数字,使用 <<1 来创建写入地址,例如 0x20<<1,或转换地址为结果,这在本例中是 0x40。
控制 MCP23017 还涉及写入其配置寄存器,每个寄存器都有自己的地址。为了将引脚设置为输出,我们需要设置两个 8 引脚的 I/O 方向寄存器。它们分别称为 GPIOA 和 GPIOB 寄存器,地址为 0x12 和 0x13。
一旦我们访问了这些寄存器,就会向每个寄存器发送一个 0,以将引脚设置为输出。例如,为了将 GPIOA 寄存器设置为输出,我们会向 I ² C 总线发送以下数据序列:0x20<<1 或 0x40(这是 MCP23017 的 I ² C 总线写入地址),然后是 0x12(GPIOA 寄存器地址),接着是 0x00(0)。现在我们在项目中实现这一操作。
硬件
要构建电路,你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• 5 V 面包板电源
-
• ATmega328P-PU 微控制器
-
• MCP23017 16 位 I/O 集成电路
-
• 多达 16 个 LED(D1–D16)
-
• 多达 16 个 560 Ω 电阻(R1–R16)
-
• 两个 4.7 kΩ 电阻(R17–R18)
-
• 两个 22 pF 陶瓷电容
-
• 16 MHz 晶体振荡器
-
• 跳线
按照图 Figure 12-8 所示组装电路。

图 12-8: Project 49 原理图
代码
打开终端窗口,进入本书第十二章文件夹下的 Project 49 子文件夹,并输入命令 make flash。几秒钟后,每组 LED 应该会反复显示 0 到 255 之间的二进制计数。
让我们检查一下代码,看看是如何实现的:
// Project 49 - Using the MCP23017 16-Bit I/O Expander
#include <avr/io.h>
#include <util/delay.h>
void I2Cenable()
// Enable the I2C bus
{
TWBR = 72; // 100 kHz I2C bus
TWCR |= (1 << TWEN); // Enable I2C on PORTC4 and 5
}
void I2Cwait()
// Wait until I2C finishes an operation
{
// Wait until bit TWINT in TWCR is set to 1
while (!(TWCR & (1<<TWINT)));
}
void I2CstartWait(unsigned char address)
{
uint8_t status;
while (1)
{
// Send START condition
TWCR = (1<<TWINT) | (1<<TWSTA) | (1<<TWEN);
// Wait until transmission completes
I2Cwait();
// Check value of TWSR, and mask out status bits
status = TWSR & 0b11111000;
if ((status != 0b00001000) && (status != 0b00010000)) continue;
// Send device address
TWDR = address;
TWCR = (1<<TWINT) | (1<<TWEN);
// Wait until transmission completes
I2Cwait();
// Check value of TWSR, and mask out status bits
status = TWSR & 0b11111000;
if ((status == 0b00100000 )||(status == 0b01011000))
{
// Secondary device is busy, send stop to terminate write operation
TWCR = (1<<TWINT) | (1<<TWEN) | (1<<TWSTO);
// Wait until stop condition is executed and I2C bus is released
while(TWCR & (1<<TWSTO));
continue;
}
break;
}
}
void I2Cstop()
// Stop I2C bus and release GPIO pins
{
// Clear interrupt, enable I2C, generate stop condition
TWCR |= (1 << TWINT)|(1 << TWEN)|(1 << TWSTO);
}
void I2Cwrite(uint8_t data)
// Send ′data′ to I2C bus
{
TWDR = data;
TWCR |= (1 << TWINT)|(1 << TWEN);
I2Cwait();
}
❶ void initMCP23017()
// Configure MCP23017 ports for all outputs
{
I2CstartWait(0x20<<1); // 0x20 write mode
I2Cwrite(0x00); // IODIRA register
I2Cwrite(0x00); // Set all register A to outputs
I2Cstop();
I2CstartWait(0x20<<1); // 0x20 write mode
I2Cwrite(0x01); // IODIRB register
I2Cwrite(0x00); // Set all register B to outputs
I2Cstop();
}
int main(void)
{
uint8_t i;
❷ I2Cenable();
initMCP23017();
while (1)
{
for (i = 0; i< 256; i++)
{
❸ I2CstartWait(0x20<<1);
❹ I2Cwrite(0x12); // Control register A 0x12
❺ I2Cwrite(i); // Value to send
❻ I2Cstop();
I2CstartWait(0x20<<1);
❼ I2Cwrite(0x13); // Control register B 0x13
❽ I2Cwrite(i); // Value to send
❾ I2Cstop();
_delay_ms(100);
}
}
}
这段代码使用了上一节中描述的五个 I ² C 函数,以简化数据传输。初始化函数 ❶ 便于使用 MCP23017。这将设置 GPIOA 和 GPIOB 寄存器,使所有 I/O 引脚为输出。
接下来,我们初始化 I ² C 总线 ❷ 并寻址每个 GPIO 扩展块以控制输出。我们启动 I ² C 总线 ❸,然后通过发送其地址 ❹ 来寻址 GPIO 寄存器。接着我们发送数据来控制寄存器 ❺ 并停止 I ² C 总线 ❻。之后,在重新启动 I ² C 总线后,代码选择 MCP23017 的第二个扩展块 ❼,发送数据 ❽,然后再次停止总线 ❾。
由于这个项目旨在演示所有可能的输出组合,因此它通过 for 循环发送一个生成的十进制数。然而,如果你想更直观地控制哪些输出引脚,可以使用二进制数。例如,如果你想打开第 7、4 和 0 引脚,你可以发送 0b10010001 而不是十进制数 145,这样相应的输出引脚就会被激活。注意,物理引脚编号与二进制数中的编号是匹配的。
现在你知道如何使用 MCP23017,接下来我们学习如何从 I ² C 设备读取数据。
从 I ² C 设备读取数据
现在你已经可以向 I ² C 设备写入数据,是时候学习如何从这些设备中读取数据了——例如传感器数据、外部存储器和其他类型的输出。读取数据时,在正常初始化 I ² C 总线后,我们使用这个函数:
I2CstartWait(
`address`
);
这次我们在 7 位总线地址的末尾加一个 1(而不是 0,用于写入)。一旦从设备接收到这个地址字节,它就知道将一个或多个字节的数据发送回总线,以供主设备接收。
为了确定从设备读取数据时应使用的正确总线地址,将设备的 I ² C 地址转换为二进制。例如,地址 0x50 转换为二进制是 1010000。在末尾加一个 1,得到 10100001,然后再转换回十六进制,得到 0xA1。
注意:如果你没有能够进行二进制、十进制和十六进制转换的计算器,一个有用的在线网站可以进行各种数学转换:www.rapidtables.com/convert/number/。
接下来,我们使用两个新函数之一,I2Cread() 或 I2CreadACK()。I2Cread() 等待从从设备返回一个字节的数据(没有确认位),并将其放入一个字节变量中:
uint8_t I2Cread()
{
TWCR |= (1 << TWINT)|(1 << TWEN);
I2Cwait();
// Incoming byte is placed in TWDR register
return TWDR;
}
该函数使主设备能够接收一个字节的数据,首先通过设置 TWINT 和 TWEN 来启用总线并释放其使用权限。等待操作完成后,从从设备接收到的字节数据会存储在 TWDR 寄存器中,然后通过 return TWDR; 返回该字节数据。
与 I2Cread() 类似,I2CreadACK() 等待从从设备接收一个字节的数据并将其放入一个字节变量中,但它还考虑了从设备的确认位:
uint8_t I2CreadACK()
{
TWCR |= (1 << TWINT)|(1 << TWEN)|(1 << TWEA);
I2Cwait();
// Incoming byte is placed in TWDR register
return TWDR;
}
这一次,除了设置 TWINT 和 TWEN,我们还将 TWCR 中的 TWEA 设置为 1。这会在主设备成功接收到数据字节时,在总线上生成 ACK(确认位)。
我们根据次要设备的参数选择使用哪个读取功能。有些设备在发送更多数据之前需要 ACK 位,而有些则不需要。
现在,我们已经拥有了完整的 I²C 总线使用功能,接下来可以将它们应用到以下项目中。
项目 50:使用外部 IC EEPROM
项目 39 在第九章中展示了如何使用 ATmega328P-PU 内部 EEPROM 存储在断电时不想删除的数据。进一步拓展这一思路,你还可以使用外部 EEPROM IC,它们提供更大的存储空间,允许你构建没有自己 EEPROM 的微控制器项目。
对于这个项目,我们将使用 Microchip 24LC512-E/P EEPROM IC,示例见图 12-9。

图 12-9:Microchip 24LC512-E/P 外部 EEPROM IC
图 12-10 显示了 Microchip 24LC512-E/P 的原理图符号。

图 12-10:Microchip 24LC512-E/P 原理图符号
和大多数其他 I²C 设备一样,你可以通过将 A0、A1 和 A2 引脚连接到电源或 GND 来设置该 IC 的 7 位地址。如果你只使用一个 24LC512-E/P,可以通过将这三个 A 引脚连接到 GND,将总线地址设置为 0x50。如果你使用两个或更多,或者需要让另一个次级设备使用 0x50 地址,请参考表 12-2 进行配置。
| 表 12-2:24LC512-E/P I²C 总线地址配置 |
|---|
| 总线地址 |
| --- |
0x50 |
0x51 |
0x52 |
0x53 |
0x54 |
0x55 |
0x56 |
0x57 |
24LC512-E/P 可以存储最多 512KB 的数据(或者,除以 8,约 64,000 字节)。本项目演示了如何向 EEPROM 写入和读取数据字节,并将其集成到其他项目中。
硬件
要构建电路,你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• 5 V 面包板电源
-
• ATmega328P-PU 微控制器
-
• Microchip 24LC512-E/P EEPROM IC
-
• 两个 4.7 kΩ 电阻(R1–R2)
-
• 两个 22 pF 陶瓷电容(C1–C2)
-
• 470 μF 16 V 电解电容(C3)
-
• 16 MHz 晶体振荡器
-
• MAX7219 八位数字模块
-
• 跳线
按照图 12-11 所示组装电路。

图 12-11: 项目 50 的电路图
代码
打开一个终端窗口,导航到本书第十二章文件夹下的项目 50子文件夹,并输入命令make flash。片刻之后,MAX7219 显示屏应该会快速显示 0 到 255 之间的数字,将这些值写入 EEPROM。然后,它会以较慢的速度再次显示这些数字,读取 EEPROM 中的值。
让我们看看代码,看看这是如何完成的:
// Project 50 - 24LC512 I2C EEPROM
#include <avr/io.h>
#include <util/delay.h>
void I2Cenable()
// Enable I2C bus
{
TWBR = 72; // 100 kHz I2C bus
TWCR |= (1 << TWEN); // Enable I2C on PORTC4 and 5
}
void I2Cwait()
// Wait until I2C finishes an operation
{
// Wait until bit TWINT in TWCR is set to 1
while (!(TWCR & (1<<TWINT)));
}
void I2CstartWait(unsigned char address)
{
uint8_t status;
while (1)
{
// Send START condition
TWCR = (1<<TWINT) | (1<<TWSTA) | (1<<TWEN);
// Wait until transmission completes
I2Cwait();
// Check value of TWSR, and mask out status bits
status = TWSR & 0b11111000;
if ((status != 0b00001000) && (status != 0b00010000)) continue;
// Send device address
TWDR = address;
TWCR = (1<<TWINT) | (1<<TWEN);
// Wait until transmission completes
I2Cwait();
// Check value of TWSR, and mask out status bits
status = TWSR & 0b11111000;
if ((status == 0b00100000 )||(status == 0b01011000))
{ // Secondary device is busy, so send stop condition to terminate
// write operation
TWCR = (1<<TWINT) | (1<<TWEN) | (1<<TWSTO);
// Wait until stop condition is executed and I2C bus released
while(TWCR & (1<<TWSTO));
continue;
}
break;
}
}
void I2Cstop()
// Stop I2C bus and release GPIO pins
{
// Clear interrupt, enable I2C, generate stop condition
TWCR |= (1 << TWINT)|(1 << TWEN)|(1 << TWSTO);
}
void I2Cwrite(uint8_t data)
// Send ′data′ to I2C bus
{
TWDR = data;
TWCR |= (1 << TWINT)|(1 << TWEN);
I2Cwait();
}
uint8_t I2Cread()
// Read incoming byte of data from I2C bus
{
TWCR |= (1 << TWINT)|(1 << TWEN);
I2Cwait();
// Incoming byte is placed in TWDR register
return TWDR;
}
uint8_t I2CreadACK()
// Read incoming byte of data from I2C bus and ACK signal
{
TWCR |= (1 << TWINT)|(1 << TWEN)|(1 << TWEA);
I2Cwait();
// Incoming byte is placed in TWDR register
return TWDR;
}
void writeMAX7219(char hexdata1, char hexdata2)
// Sends two bytes in hexadecimal to MAX7219
{
PORTB &= ~(1 << PORTB0); // SS pin LOW
SPDR = hexdata1; // Send value of hexdata1
while(!(SPSR & (1<<SPIF))); // Wait for SPI transmission to finish
SPDR = hexdata2; // Send value of hexdata2
while(!(SPSR & (1<<SPIF))); // Wait for SPI transmission to finish
PORTB |= (1 << PORTB0); // SS pin HIGH
}
void blankMAX7219()
// Blanks all digits
{
uint8_t i;
for (i=1; i<9; i++) // Blank all digits
{
writeMAX7219(i,15);
}
}
void initMAX7219()
// Set up MAX7219 for use
{
PORTB |= (1 << 0); // SS pin HIGH
SPCR = (1<<SPE)|(1<<MSTR); // Set up SPI bus for MAX7219
// Mode decode for digits (table 4 page 7 - 0x09 + 0xFF)
writeMAX7219(0x09,0xFF);
writeMAX7219(0x0B,0x07); // Set scan limit to 8 digits - 0x09 + 0xFF)
writeMAX7219(0x0A,0x01); // Set intensity to 8 - 0x0A + 0x08)
// Mode display on (table 4 page 7 - 0x09 + 0xFF)
writeMAX7219(0x0C,0x01);
blankMAX7219();
}
void dispMAX7219(uint8_t digit, uint8_t number, uint8_t dp)
// Displays "number" in location "digit" with decimal point on/off
// Digit: 1~8 for location 1~8
// Number: 0~15 for 0~9, - E, H, L, P, blank
// dp: 1 on, 0 off
{
if (dp==1) // Add decimal point
{
number = number + 128;
}
writeMAX7219(digit, number);
}
void numberMAX7219(uint32_t value)
// Displays a number between 0–99999999 on MAX7219-controlled 8-digit display
{
uint8_t digits[8];
uint8_t i = 1;
for (i=1; i<9; i++)
{
digits[i]=15;
}
i = 1;
while (value > 0) // Continue until value > 0
{ // Determine and store last digit of number
digits[i] = value % 10;
value = value / 10; // Divide value by 10
i++;
}
for (i=1; i<9; i++)
{
dispMAX7219(i, digits[i],0);
}
}
int main(void)
{
uint16_t i;
uint16_t j;
DDRB = 0b11111111; // Set PORTB as outputs
I2Cenable();
initMAX7219();
while (1)
{
dispMAX7219(0,10,0);
for (i = 0; i<256; i++) // Write loop
{
❶ I2CstartWait(0x50<<1); // 0x50 << 1 - 0b10100000
❷ I2Cwrite(i >> 8);
❸ I2Cwrite(i);
❹ I2Cwrite(i);
I2Cstop();
numberMAX7219(i);
_delay_ms(1);
}
for (i = 0; i<256; i++) // Read loop
{
❺ I2CstartWait(0x50<<1); // Write address
❻ I2Cwrite(i >> 8);
❼ I2Cwrite(i);
❽ I2CstartWait((0x50<<1)+1); // Read address - 0b10100001
❾ j = I2Cread();
⓿ I2Cstop();
numberMAX7219(j);
_delay_ms(5);
}
_delay_ms(100);
}
}
这段代码重用了之前项目中的函数,例如第十一章中的 MAX7219 函数和本章项目 49 中的 I²C 总线函数;新代码包含在主循环中。总之,它将值 0 到 255 写入并从 EEPROM 的 0 到 255 位置读取。这在代码的写入和读取循环中完成。
为了向 EEPROM 写入数据,我们首先启动总线,并在写入循环中等待确认 ❶,使用总线地址的“写入”形式。现在,EEPROM 期待接收 2 字节数据,这 2 字节表示 EEPROM 内存中的地址(或位置)(在本例中是写入)。它需要 2 字节数据,因为有超过 256 个可能的地址。因此,代码在循环中创建变量i,作为 16 位整数。
我们发送地址的“高字节” ❷,它详细说明了地址中大于 255 的部分,然后跟随发送“低字节”,它详细说明了地址中小于或等于 255 的部分 ❸。然后,我们将值发送到 EEPROM 进行存储 ❹,MAX7219 显示屏会显示该值供我们参考。
为了从 EEPROM 读取值,我们首先启动总线,并在读取循环中等待确认 ❺,同样使用总线地址的“写入”形式,然后像之前一样发送高 ❻ 和低 ❼ 字节的地址。
接下来,为了从 EEPROM 检索数据,我们通过使用总线地址的读取形式重新启动 I²C 总线 ❽。然后,我们使用新的I2Cread()函数将从 EEPROM 发送的字节取回到微控制器,并将其存储在变量j中 ❾。现在我们从 EEPROM 获取了数据,停止使用 I²C 总线 ⓿,并将这些值显示在 MAX7219 显示模块上供参考。
更多关于高字节和低字节的内容
我们需要将 16 位整数拆分为高字节和低字节,以便通过 I²C(或 SPI)数据总线发送它们。这涉及到将整个 16 位数字右移 8 位以确定高字节,然后通过简单地使用 16 位数字进行 8 位操作来发送低字节,因为这样做实际上会移除高字节。
例如,考虑数字 41,217。它大于 255,因此我们需要 2 个字节的数据来表示它在 AVR 操作中。如果将 41,217 转换为二进制,你会看到它使用了 16 位:
1010000100000001
我们通过将整个数字向右移 8 位来创建 8 位高字节。例如:
10100001 00000001 >> 8 = 10100001 // 我们的高字节
然后,我们通过简单地将它用于 8 位操作来创建低字节。例如:
I2Cwrite( 10100001 00000001)
这与 I2Cwrite(00000001) 的效果相同。
本项目不仅展示了如何将字节数据读写到基于 I²C 的设备,还展示了如何在外部 EEPROM 芯片中存储数据的框架。在下一个项目中,我们将继续讨论我们最后一个 I²C 总线设备——DS3231,这是一个实时钟(RTC)芯片,允许你将时间和日期信息添加到你的项目中。
项目 51:使用 DS3231 实时钟
一旦设置了当前时间和日期,RTC 可以在请求时提供准确的时间和日期数据。RTC 允许你构建各种有趣的项目,从简单的时钟到数据记录设备、报警器等。在本项目中,你将创建一个时钟,使用 RTC 和 MAX7219 显示模块以 24 小时格式显示当前的日期和时间。
市面上有许多不同的 RTC 芯片,有些比其他的更精确。在本章中,我们将使用 Maxim DS3231;它除了备份电池外不需要任何外部电路,精度非常高,而且在模块形式中相当坚固。
DS3231 可作为扩展板从各种零售商处购买,包括 PMD Way 的版本(零件编号 883422),如图 12-12 所示。

图 12-12:DS3231 实时钟 IC 模块
使用扩展板意味着你不需要担心 DS3231 的支持电路,如上拉电阻,也无需连接备份电池,因为板子会为你处理所有这些。你只需要插入一个 CR2032 的纽扣电池作为备份,并将跳线连接到你的项目上。
将扩展板连接到你的项目非常简单:只需使用 V [CC](5V),GND,SCL 和 SDA 连接。DS3231 具有固定的 I²C 总线地址 0x68,转换为写地址 0xD0 和读地址 0xD1。
它有一组寄存器用于存储时间和日期信息,从0x00开始,依次递增,如表 12-3 所示。
| 表 12-3 :DS3231 数据寄存器 |
|---|
| 地址 |
| --- |
0x00 |
0x01 |
0x02 |
0x03 |
0x04 |
0x05 |
0x06 |
在本项目中,我们将仅使用表 12-3 中显示的寄存器。然而,数据表中有更多内容供您查阅,您可以通过以下网址访问:www.maximintegrated.com/en/products/analog/real-time-clocks/DS3231.html。
数据以 二进制编码十进制(BCD) 格式存储在 DS3231 寄存器中,该格式为十进制数字中的每一位分配一个四位二进制代码。因此,我们将在代码中使用简单的 BCD 到十进制转换。
为了设置时间和日期,我们将按顺序从 0x00 开始写入数据字节,使用我们的 I²C 总线写入函数。要获取数据,我们可以像在项目 50 中使用 EEPROM 时一样,从特定地址读取,或者从 0x00 开始读取并发送 ACK,这将导致 DS3231 从每个顺序寄存器按字节发送其余数据。在我们项目的代码中,我们将使用后一种方法。但首先,让我们先组装硬件。
硬件
要构建电路,您需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• 5 V 面包板电源
-
• ATmega328P-PU 微控制器
-
• DS3231 RTC 模块与备份电池
-
• 两个 22 pF 陶瓷电容器(C1–C2)
-
• 470 μF 16 V 电解电容器(C3)
-
• 16 MHz 晶体振荡器
-
• MAX7219 八位数码管模块
-
• 跳线
按照图 12-13 所示组装电路。不要忘记将 MAX7219 和 DS3231 板连接到 5 V 和 GND。

图 12-13: 项目 51 原理图
代码
在按常规将代码上传到微控制器之前,请在文本编辑器中打开本书第十二章文件夹中的Project 51子文件夹中的main.c文件,并滚动到第 309 行。去掉setTimeDS3231()函数前的注释符号。接下来,更新该函数中的参数以匹配您当前的日期和时间。参数按顺序为:小时(24 小时制),分钟,秒,星期几(1 至 7),日期(1 至 31),月份,年份(00 至 99)。例如,假设您认为星期日是每周的第一天——在某些地区,星期一被认为是第一天,因此星期一是 1——如果时间是 2022 年 11 月 1 日(星期二)下午 2:32(即 14:32),您应将该行更改为:
setTimeDS3231(14,32,0,3,6,11,22);
现在保存文件,并像往常一样从终端窗口执行 make flash 命令。然后重新打开 main.c 文件,取消注释 setTimeDS3231() 函数前面的注释符号,保存文件并重新烧录代码。第一次烧录设置时间和日期,第二次则在每次微控制器重置或断电重启时停用设置。如果你跳过了第二次烧录,项目会在每次重置后设置相同的时间和日期。
一旦完成,你应该能在 MAX7219 模块上交替显示当前时间和日期。恭喜你——你已经制作了自己的数字时钟!
现在让我们检查一下代码,看看它是如何工作的:
// Project 51 - Using the DS3231 I2C Real-Time Clock
#include <avr/io.h>
#include <util/delay.h>
// Variables to store time and date
❶ uint8_t hours, minutes, seconds, dow, dom, mo, years;
void I2Cenable()
// Enable I2C bus
{
TWBR = 72; // 100 kHz I2C bus
TWCR |= (1 << TWEN); // Enable I2C on PORTC4 and 5
}
void I2Cwait()
// Wait until I2C finishes an operation
{
// Wait until bit TWINT in TWCR is set to 1
while (!(TWCR & (1<<TWINT)));
}
void I2CstartWait(unsigned char address)
{
uint8_t status;
while (1)
{
// Send START condition
TWCR = (1<<TWINT) | (1<<TWSTA) | (1<<TWEN);
// Wait until transmission completes
I2Cwait();
// Check value of TWSR, and mask out status bits
status = TWSR & 0b11111000;
if ((status != 0b00001000) && (status != 0b00010000)) continue;
// Send device address
TWDR = address;
TWCR = (1<<TWINT) | (1<<TWEN);
// Wait until transmission completes
I2Cwait();
// Check value of TWSR, and mask out status bits
status = TWSR & 0b11111000;
if ((status == 0b00100000 )||(status == 0b01011000))
{
TWCR = (1<<TWINT) | (1<<TWEN) | (1<<TWSTO);
// Wait until stop condition is executed and I2C bus is released
while(TWCR & (1<<TWSTO));
continue;
}
break;
}
}
void I2Cstop()
// Stop I2C bus and release GPIO pins
{
// Clear interrupt, enable I2C, generate stop condition
TWCR |= (1 << TWINT)|(1 << TWEN)|(1 << TWSTO);
}
void I2Cwrite(uint8_t data)
// Send 'data' to I2C bus
{
TWDR = data;
TWCR |= (1 << TWINT)|(1 << TWEN);
I2Cwait();
}
uint8_t I2Cread()
// Read incoming byte of data from I2C bus
{
TWCR |= (1 << TWINT)|(1 << TWEN);
I2Cwait();
// Incoming byte is placed in TWDR register
return TWDR;
}
uint8_t I2CreadACK()
// Read incoming byte of data from I2C bus and ACK signal
{
TWCR |= (1 << TWINT)|(1 << TWEN)|(1 << TWEA);
I2Cwait();
// Incoming byte is placed in TWDR register
return TWDR;
}
void writeMAX7219(char hexdata1, char hexdata2)
// Sends two bytes in hexadecimal to MAX7219
{
PORTB &= ~(1 << PORTB0); // SS pin LOW
SPDR = hexdata1; // Send value of hexdata1
while(!(SPSR & (1<<SPIF))); // Wait for SPI transmission to finish
SPDR = hexdata2; // Send value of hexdata2
while(!(SPSR & (1<<SPIF))); // Wait for SPI transmission to finish
PORTB |= (1 << PORTB0); // SS pin HIGH
}
void blankMAX7219()
// Blanks all digits
{
uint8_t i;
for (i=1; i<9; i++) // Blank all digits
{
writeMAX7219(i,15);
}
}
void initMAX7219()
// Set up MAX7219 for use
{
PORTB |= (1 << 0); // SS pin HIGH (SS)
SPCR = 0b01010000; // Set up SPI bus for MAX7219
// Mode decode for digits (table 4 page 7 - 0x09 + 0xFF
writeMAX7219(0x09,0xFF);
writeMAX7219(0x0B,0x07); // Set scan limit to 8 digits - 0x09 + 0xFF)
writeMAX7219(0x0A,0x01); // Set intensity to 8 - 0x0A + 0x08)
// Mode display on (table 4 page 7 - 0x09 + 0xFF)
writeMAX7219(0x0C,0x01);
blankMAX7219();
}
void dispMAX7219(uint8_t digit, uint8_t number, uint8_t dp)
// Displays "number" in location "digit" with decimal point on/off
// Digit: 1~8 for location 1~8
// Number: 0~15 for 0~9, - E, H, L, P, blank
// dp: 1 on, 0 off
{
if (dp==1) // Add decimal point
{
number = number + 128;
}
writeMAX7219(digit, number);
}
void numberMAX7219(uint32_t value)
// Displays a number between 0–99999999 on MAX7219-controlled 8-digit display
{
uint8_t digits[8];
uint8_t i = 1;
for (i=1; i<9; i++)
{
digits[i]=15;
}
i = 1;
while (value > 0) // Continue until value > 0
{
// Determine and store last digit of number
digits[i] = value % 10;
value = value / 10; // Divide value by 10
i++;
}
for (i=1; i<9; i++)
{
dispMAX7219(i, digits[i],0);
}
}
❷ uint8_t decimalToBcd(uint8_t val)
// Convert integer to BCD
{
return((val/10*16)+(val%10));
}
uint8_t bcdToDec(uint8_t val)
// Convert BCD to integer
{
return((val/16*10)+(val%16));
}
❸ void setTimeDS3231(uint8_t hh, uint8_t mm, uint8_t ss, uint8_t dw,
uint8_t dd, uint8_t mo, uint8_t yy)
// Set the time on DS3231
{
I2CstartWait(0xD0); // DS3231 write
I2Cwrite(0x00); // Start with seconds register
I2Cwrite(decimalToBcd(ss)); // Seconds
I2Cwrite(decimalToBcd(mm)); // Minutes
I2Cwrite(decimalToBcd(hh)); // Hours
I2Cwrite(decimalToBcd(dw)); // Day of week
I2Cwrite(decimalToBcd(dd)); // Date
I2Cwrite(decimalToBcd(mo)); // Month
I2Cwrite(decimalToBcd(yy)); // Year
I2Cstop();
}
❹ void readTimeDS3231()
// Retrieve time and date from DS3231
{
I2CstartWait(0xD0); // DS3231 write
I2Cwrite(0x00); // Seconds register
I2CstartWait(0xD1); // DS3231 read
seconds = bcdToDec(I2CreadACK());
minutes = bcdToDec(I2CreadACK());
hours = bcdToDec(I2CreadACK());
dow = bcdToDec(I2CreadACK());
dom = bcdToDec(I2CreadACK());
mo = bcdToDec(I2CreadACK());
years = bcdToDec(I2CreadACK());
}
❺ void displayTimeMAX7219()
// Display time then date on MAX7219 module
{
blankMAX7219();
readTimeDS3231();
// Display seconds
if (seconds == 0)
{ // Display '00'
dispMAX7219(1,0,0);
dispMAX7219(2,0,0);
} else if (seconds >0 && seconds <10)
{ // Display leading zero
dispMAX7219(1,seconds,0);
dispMAX7219(2,0,0);
} else
{ // Seconds > 10
numberMAX7219(seconds);
}
dispMAX7219(3,10,0); // Display a dash
// Display minutes
if (minutes == 0)
{ // Display '00'
dispMAX7219(4,0,0);
dispMAX7219(5,0,0);
} else if (minutes >0 && minutes <10)
{ // Display leading zero
dispMAX7219(4,minutes,0);
dispMAX7219(5,0,0);
} else
{ // Minutes > 10
dispMAX7219(4,(minutes % 10),0);
dispMAX7219(5,(minutes / 10),0);
}
dispMAX7219(6,10,0); // Display a dash
// Display hours
if (hours == 0)
{ // Display '00'
dispMAX7219(7,0,0);
dispMAX7219(8,0,0);
} else if (hours >0 && hours <10)
{ // Display leading zero
dispMAX7219(7,hours,0);
dispMAX7219(8,0,0);
} else
{ // Hours > 10
dispMAX7219(7,(hours % 10),0);
dispMAX7219(8,(hours / 10),0);
}
_delay_ms(1000);
// Display date
if (dom >0 && dom <10)
{ // Display leading zero
dispMAX7219(7,dom,0);
dispMAX7219(8,0,0);
} else
{ // Seconds > 10
dispMAX7219(8,(dom / 10), 0);
dispMAX7219(7,(dom % 10), 0);
}
dispMAX7219(6,10,0); // Display a dash
// Display month
if (mo >0 && mo <10)
{ // Display leading zero
dispMAX7219(4,mo,0);
dispMAX7219(5,0,0);
} else
{ // Seconds > 10
dispMAX7219(5,(mo / 10), 0);
dispMAX7219(4,(mo % 10), 0);
}
dispMAX7219(3,10,0); // Display a dash
// Display year
if (years == 0)
{ // Display '00'
dispMAX7219(1,0,0);
dispMAX7219(2,0,0);
} else
if (years >0 && years <10)
{ // Display leading zero
dispMAX7219(1,years,0);
dispMAX7219(2,0,0);
} else
{ // Years > 10
dispMAX7219(2,(years / 10), 0);
dispMAX7219(1,(years % 10), 0);
}
_delay_ms(1000);
}
int main(void)
{
DDRB = 0b11111111; // Set PORTB as outputs
I2Cenable();
initMAX7219();
// Uncomment to set time and date, then comment and reflash code
// setTimeDS3231(9,13,0,5,29,4,21); // h,m,s,dow,dom,m,y
while (1)
{
❻ displayTimeMAX7219();
_delay_ms(250);
}
}
再次提醒,这段代码重用了你在之前项目中看到的一些函数(即,第 46 项目 中 MAX7219 显示函数以及本章 第 49 项目 中的 I²C 总线函数)。
首先,我们声明用于处理时间和日期信息的变量 ❶。这些变量将保存写入 DS3231 和从 DS3231 接收的数据。如前所述,DS3231 使用二进制编码十进制(BCD)格式的数据,因此代码中包含了将整数转换为 BCD 以及从 BCD 转换回整数的函数 ❷。
setTimeDS3231() 函数接受时间、星期几和日期,并将其发送到 DS3231 ❸。它首先写入 DS3231 设置寄存器到地址(0x00),然后按 表格 12-3 中描述的顺序依次写入每个数据字节。请注意,每个 I²C 写入函数使用的是十进制到 BCD 的转换函数。
readTimeDS3231() 函数 ❹ 检索时间和日期信息。它从 DS3231 寄存器 0x00 请求一个数据字节,由于该函数在读取过程中使用了 ACK,DS3231 会依次发送来自各寄存器的后续数据字节。这意味着我们可以简单地使用 I2CreadACK() 函数调用七次来检索所有所需的数据。在检索 DS3231 数据时,我们在 I²C 读取函数中使用 BCD 到十进制的转换函数。
接下来是 displayTimeMAX7219() 函数 ❺,它将时间和日期数据组织成数字并显示在 MAX7219 显示屏上。它首先显示时间,然后在短暂延迟后显示日期。如果你愿意,可以删除日期显示,让时钟持续运行。
整个项目被包装在主循环中,在这里我们初始化 GPIO、I²C 和 SPI 总线,然后简单地调用显示函数 ❻,并延迟直到再次调用。作为挑战,为什么不为将来参考编写你自己的 I²C 和 DS3231 库,或者制作一个闹钟呢?
还有很多内容需要学习,包括如何在流行的字符液晶显示模块上显示数据的新信息,我们将在 下一章 中进行探讨。
第十三章:# AVR 与字符液晶显示器

在前几章中,你已经使用了 LED、数字 LED 显示器和较大的 MAX7219 来显示数值。然而,一种常见的液晶显示器(LCD)模块可以让你的 AVR 项目显示更为多样化的输出,包括文本、数值数据以及你自己定义的字符。
在本章中,你将使用字符 LCD 模块显示所有三种类型的数据。为此,你将学习如何将整数转换为字符串变量,并在 LCD 上显示浮动小数。在这个过程中,你将制作自己的数字时钟和数字温度计,可以显示温度的最小值和最大值。
介绍 LCD
我们的基于 LCD 的项目将使用廉价的 LCD,它们可以显示 2 行 16 个字符。任何带有 HD44780 或 KS0066 兼容接口并配有 5V 背光的 LCD,例如图 13-1 中的那款,都应该适用于这些项目。

图 13-1:16×2 字符 LCD 模块
一些较为罕见的 LCD 使用 4.5V 而不是 5V 的背光。如果你的 LCD 是这种情况,可以在 5V 电源与 LCD 的 LED+或 A 引脚之间串联一个 1N4004 二极管。
像图 13-1 中的 LCD 通常没有任何接线或连接器。要在无焊面包板上使用 LCD,你需要焊接一些 0.1 英寸 / 2.54 毫米间距的直插针脚(例如 PMD Way 零件号 1242070A),就像图 13-2 中所示的那样。这些针脚通常是 40 针长的,但你可以轻松地将其裁剪为所需的 16 针长度。

图 13-2:直插针脚
一旦组装完成,你的 LCD 将很容易安装到无焊面包板上,正如图 13-3 所示。请注意引脚 1 至 16 的标签。

图 13-3:安装在无焊面包板上的 LCD
我们的 LCD 的原理图符号如图 13-4 所示。

图 13-4:我们 16×2 字符 LCD 的原理图符号
引脚 DB0 至 DB7 构成 LCD 的 8 位数据接口,它与我们的 ATmega328P-PU 微控制器进行通信。如果你需要节省接线,还可以使用 LCD 的 4 位模式,这只需要 DB4 至 DB7。我们将在项目中使用这种方法。
最后,你还需要一个小的 10 kΩ可调电位器来控制显示的对比度。你可以使用不需要额外焊接的面包板兼容电位器,如图 13-5 中所示的那种。

图 13-5:一个面包板电位器的示例
图 13-5 中电位器的原理图符号显示在图 13-6 中。

图 13-6:我们面包板电位器的原理图符号
一旦你准备好使用无焊面包板来使用 LCD,就该了解如何显示各种类型的数据了。在你的项目中使用 LCD 时,你需要完成以下任务的函数:
-
• 将指令转换为适当的控制信号,以便向 LCD 发送命令
-
• 初始化 LCD 以供使用
-
• 清除 LCD 上的所有数据
-
• 将光标移动到 LCD 上的指定位置
-
• 在 LCD 上显示文本
由于在我们的 AVR 工具链中没有完成这些任务的现成函数,我们将使用以下部分中描述的自定义函数。
你会注意到,这些函数每次都会向 LCD 发送值以实现某种效果。例如,向 LCD 发送0x01会清除屏幕。为了确定我们应该使用哪些值来完成特定的任务,我们参考 LCD 的指令表,这张表格是 HD44780 数据表中的第 6 表格(该表格广泛可用,并包含在本书的代码下载中,地址为nostarch.com/avr-workshop/)。该表格显示了执行特定命令所需的 RS 和 R/_W 引脚的状态,以及该命令的二进制表示。图 13-7 显示了清除显示命令0x01。

图 13-7:LCD“清屏”命令的数字描述
如图所示,为了清除显示,我们需要将 LCD 的引脚 RS 和 R/_W 设置为低电*,然后将0b00000001(或0x01)发送到 LCD。我们将通过commandLCD()函数来完成这一操作(该函数将在接下来的部分介绍),并通过clearLCD()函数(稍后在“清除 LCD”部分中描述)调用它。
在接下来的部分中,请参考 HD44780 数据表中的表格,了解我用来构建其他 LCD 命令的值。之后,你可以使用该表格创建符合你需求的命令。
向 LCD 发送命令
所有发送到 LCD 的信息,无论是设置命令还是显示数据,都是按字节发送的。然而,由于我们使用的是 4 位模式的 LCD 来节省硬件连接,我们需要使用以下函数将数据字节拆分成半字节(4 位),并按正确的顺序将它们发送到 LCD:
void commandLCD(uint8_t _command)
{
❶ PORTD = (PORTD & 0x0F)|(_command & 0xF0);
❷ PORTD &= ~(1<<PD0);
❸ PORTD |= (1<<PD1);
_delay_us(1);
❹ PORTD &= ~(1<<PD1);
_delay_us(200);
❺ PORTD = (PORTD & 0x0F)|(_command << 4);
❻ PORTD |= (1<<PD1);
_delay_us(1);
❼ PORTD &= ~(1<<PD1);
_delay_ms(2);
}
为了理解这段代码的作用,回想一下一个字节的数据由 8 位组成,或者说由 2 个半字节(nibble)组成:高半字节,由第 7 位到第 4 位组成,和低半字节,由第 3 位到第 0 位组成。例如:
`1111`
0000 // Ones are the higher nibble
0000
`1111`
// Ones are the lower nibble
commandLCD() 函数首先获取命令字节 _command 的高半字节 ❶,并使用位运算(参见 第二章)将 GPIO 引脚恢复为低电*。然后它确保 GPIO 引脚设置为与命令字节的高半字节匹配,即命令字节的前半部分。
接下来,它将 LCD 的 RS 引脚设置为低 ❷,这告诉 LCD 我们需要向其指令寄存器发送数据,并迅速将 LCD 的 E 引脚设置为高 ❸ 和低 ❹,这告诉 LCD 将有更多数据到来。
然后该函数使用位运算将低半字节的 4 位上移到高半字节 ❺,这将与用于向 LCD 发送数据的 GPIO 引脚相匹配。最后,它再次设置 LCD 的 E 引脚为高 ❻ 和低 ❼,以完成数据传输。我们使用 _delay_us()(微秒延迟,而非毫秒)函数为 LCD 提供时间来处理这些变化。
初始化 LCD 使用
像许多其他设备一样,LCD 在我们首次在代码中使用之前需要初始化各种参数。我们将使用 initLCD() 函数来完成这个操作:
void initLCD()
{
❶ DDRD = 0b11111111;
_delay_ms(100);
❷ commandLCD(0x02);
❸ commandLCD(0x28);
❹ commandLCD(0x0C);
❺ commandLCD(0x06);
❻ commandLCD(0x01);
_delay_ms(2);
}
这个函数首先将所需的 GPIO 引脚设置为数字输出 ❶。经过短暂延迟以便给 LCD 足够时间唤醒后,它发送命令将光标位置(数据首次显示的位置)重新设置到屏幕的左上角 ❷。接下来的命令配置 LCD 控制器 IC,将其设置为 16×2 字符单元,并使用 4 位数据接口,同时选择一个默认字体,该字体由 5×8 像素的字符组成 ❸。
接下来的命令 ❹ 告诉 LCD 不使用块状光标、不闪烁光标,并开启显示。然后我们告诉 LCD 控制器,需要使光标按增量方式移动 ❺,这样如果我们希望依次显示多个字符,就不需要在每个字符后显式设置光标位置。最后,我们清除 LCD 上的所有字符 ❻,并给它一点时间处理这个变化。
清除 LCD
方便的 clearLCD() 函数会清除 LCD 上的所有数据:
void clearLCD()
{
❶ commandLCD(0x01);
_delay_ms(2);
❷ commandLCD(0x80);
_delay_ms(2);
}
我们发送清屏命令 ❶,然后发送将光标返回到 LCD 左上角的命令 ❷。
设置光标
cursorLCD() 函数将光标设置到 LCD 上的指定位置,之后你可以从该位置开始显示数据:
void cursorLCD(uint8_t column, uint8_t row)
{
if (row == 0 && column<16)
{
❶ commandLCD((column & 0x0F)|0x80);
}
else if (row == 1 && column<16)
{
❷ commandLCD((column & 0x0F)|0xC0);
}
}
我们的 LCD 有 2 行 16 个字符:第 0 行和第 1 行,列编号为 0 到 15。该函数根据接收到的位置数据创建所需的 LCD 命令,用于第 0 行位置 ❶ 和第 1 行位置 ❷。
向 LCD 打印
printLCD() 函数用于在 LCD 上显示数据,如文本或数字:
void printLCD(char *_string)
{
uint8_t i;
❶ for (i=0; _string[i]!=0; i++)
{
❷ PORTD = (PORTD & 0x0F)|(_string[i] & 0xF0);
❸ PORTD |= (1<<PD0);
❹ PORTD |= (1<<PD1);
_delay_us(1);
❺ PORTD &= ~(1<<PD1);
_delay_us(200);
❻ PORTD = (PORTD & 0x0F)|(_string[i] << 4);
❼ PORTD |= (1<<PD1);
_delay_us(1);
❽ PORTD &= ~(1<<PD1);
_delay_ms(2);
}
}
该函数可以接受带引号的文本,如下所示:
printLCD("AVR Workshop!");
printLCD("3.141592654");
或者字符数组,如下所示:
char resultsArray[9];
printLCD(resultsArray);
该函数使用其 for 循环 ❶ 逐个发送数组中的每个字符,将字符表示为标准 ASCII 表中的数字值(在 第四章 讨论)。市场上所有 LCD 显示器应支持值为 33 到 125,包括小写和大写字母、数字以及标准常用符号和标点符号。我们使用 cursorLCD() 或 clearLCD() 函数设置第一个(或唯一)要显示的字符位置。
printLCD() 函数与 commandLCD() 函数非常相似。它首先获取字符字节 _string[i] 的高位半字节 ❷,并使用位运算将 GPIO 引脚清零至低电*。然后确保 GPIO 引脚设置与高位半字节(命令字节的前半部分)匹配。
然后,它将 LCD 的 RS 引脚设置为高电* ❸,告诉 LCD 我们需要向其指令寄存器发送数据,并迅速将 LCD 的 E 引脚打开 ❹ 和关闭 ❺,告诉 LCD 将有更多数据到来。
函数接着使用位运算将低半字节的 4 位移位至高半字节 ❻,这将与用于向 LCD 发送数据的 GPIO 引脚匹配。最后,它再次将 LCD 的 E 引脚打开 ❼ 和关闭 ❽,完成数据传输。我们使用 _delay_us() 函数(延时微秒)让 LCD 有时间处理这些变化。
注意:要使用 printLCD() 显示整数变量的内容,请先使用 itoa( a , b , c ) 将变量 a 转换为字符数组 b,数组长度最大为 c 个字符。您需要在代码中包含 stdlib.h 库以及其他 include 语句,因为它包含 itoa() 函数。
在接下来的项目中,您将把 LCD 应用到实际中。
项目 52:使用 AVR 控制字符 LCD
在此项目中,通过构建自己的 LCD 电路并显示各种信息,您将巩固迄今为止关于控制 LCD 的信息。这将为您在自己的项目中使用 LCD 提供介绍。
硬件
要构建您的电路,您将需要以下硬件:
-
• USBasp 程序员
-
• 无焊面包板
-
• 5 V 面包板电源
-
• ATmega328P-PU 微控制器
-
• 16×2 字符 LCD,配有内联引脚头
-
• 10 kΩ 面包板兼容可调电阻器
-
• 两个 22 pF 陶瓷电容器(C1–C2)
-
• 470 μF 16 V 电解电容器 (C3)
-
• 16 MHz 晶体振荡器
-
• 跳线
按照图 13-8 中的示意图组装电路。

图 13-8:项目 52 的原理图
完成此电路后,请保持电路的组装状态,因为您将在项目 55 中再次使用它。
代码部分
打开一个终端窗口,导航到本书 第十三章 文件夹下的 项目 52 子文件夹,并像往常一样输入命令 make flash。几秒钟后,LCD 应该显示图 13-9 中的文本。

图 13-9:使用项目 52 显示的文本的第一个示例
该文本应很快被一个递增的数字替代,如图 13-10 所示。

图 13-10:来自项目 52 的计数显示例程
让我们检查一下代码,并回顾使之成为可能的函数:
// Project 52 - Using a Character LCD with Your AVR
#include <avr/io.h>
#include <util/delay.h>
#include <stdlib.h>
void initLCD()
{
DDRD = 0b11111111;
_delay_ms(100);
commandLCD(0x02);
commandLCD(0x28);
commandLCD(0x0C);
commandLCD(0x06);
commandLCD(0x01);
_delay_ms(2);
}
void commandLCD(uint8_t _command)
{
PORTD = (PORTD & 0x0F) | (_command & 0xF0
PORTD &= ~(1<<PD0);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1)
_delay_us(200);
PORTD = (PORTD & 0x0F) | (_command << 4);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_ms(2);
}
void clearLCD()
{
commandLCD (0x01);
_delay_ms(2);
commandLCD (0x80);
_delay_ms(2);
}
void printLCD(char *_string)
{
uint8_t i;
for(i=0; _string[i]!=0; i++)
{
PORTD = (PORTD & 0x0F) | (_string[i] & 0xF0);
PORTD |= (1<<PD0);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_us(200);
PORTD = (PORTD & 0x0F) | (_string[i] << 4);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_ms(2);
}
}
void cursorLCD(uint8_t column, uint8_t row)
// Move cursor to desired column (0–15), row (0–1)
{
if (row == 0 && column<16)
{
commandLCD((column & 0x0F)|0x80);
}
else if (row == 1 && column<16)
{
commandLCD((column & 0x0F)|0xC0);
}
}
int main()
{
❶ initLCD();
❷ char numbers[9];
❸ int i;
while(1)
{
❹ cursorLCD(1,0);
printLCD("AVR Workshop!");
cursorLCD(0,1);
printLCD("Learning LCD use");
_delay_ms(1000);
❺ clearLCD();
cursorLCD(1,0);
printLCD("Counting up:");
❻ for (i = 0; i<10; i++)
{
❼ itoa(i,numbers,10);
cursorLCD(i,1);
❽ printLCD(numbers);
_delay_ms(1000);
}
clearLCD();
}
}
这段代码将前面描述的 LCD 函数付诸实践。在代码的主部分,我们首先初始化 LCD ❶,然后声明一个字符数组用于显示数字 ❷,以及用于计数的必要变量 ❸。
接下来,我们设置显示操作。我们使用 cursorLCD() 和 printLCD() 函数 ❹ 来定位和显示文本,然后用 clearLCD() ❺ 清空显示。for 循环 ❻ 会在 LCD 的第二行显示从 0 到 9 的数字(如图 13-10 所示)。我们使用 itoa() ❼ 将整数变量 i 转换为字符数组,然后通过 printLCD() ❽ 显示该数组。
现在你已经了解了如何设置和使用字符型 LCD,让我们通过创建一个数字时钟来充分利用这个技能。
项目 53:基于 AVR 的 LCD 数字时钟制作
在这个项目中,您将结合 DS3231 实时时钟模块和 LCD,制作您自己的数字时钟。
硬件部分
要构建电路,您将需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• 5 V 面包板电源
-
• ATmega328P-PU 微控制器
-
• 16×2 字符 LCD,带有内联插头针
-
• 10 kΩ 面包板兼容可调电位器(可变电阻)
-
• DS3231 RTC 模块,带备份电池
-
• 两个 22 pF 陶瓷电容器 (C1–C2)
-
• 470 μF 16 V 电解电容器 (C3)
-
• 16 MHz 晶体振荡器
-
• 跳线
按照图 13-11 所示组装电路。别忘了将 DS3231 模块连接到 5V 和 GND。

图 13-11:项目 53 的电路图
代码
与项目 51 在第十二章中的内容一样,你首先需要在 DS3231 模块中设置时间和日期。在文本编辑器中,打开Chapter 13文件夹中Project 53子文件夹中的main.c文件,并去掉setTimeDS3231()函数前的注释符号。更新该函数中的参数以匹配你当前的日期和时间。
现在保存文件,然后像往常一样在终端窗口中使用make flash命令。重新打开main.c文件,在相同的函数前放置注释符号,保存文件并重新烧录代码。完成后,你应该会看到当前的时间和日期显示在 LCD 模块上。如下图 13-12 所示,恭喜你——你已经制作了自己的 LCD 数字时钟!

图 13-12: 项目 53 的操作示例
让我们检查一下代码,看看它是如何工作的:
// Project 53 - Building an AVR-Based LCD Digital Clock
#include <avr/io.h>
#include <util/delay.h>
#include <stdlib.h>
// Variables to store time and date
uint8_t hours, minutes, seconds, dow, dom, mo, years;
void I2Cenable()
// Enable I2C bus
{
TWBR = 72; // 100 kHz I2C bus
TWCR |= (1 << TWEN); // Enable I2C on PORTC4 and 5
}
void I2Cwait()
// Wait until I2C finishes an operation
{
// Wait until bit TWINT in TWCR is set to 1
while (!(TWCR & (1<<TWINT)));
}
void I2CstartWait(unsigned char address)
{
uint8_t status;
while (1)
{
// Send START condition
TWCR = (1<<TWINT) | (1<<TWSTA) | (1<<TWEN);
// Wait until transmission completes
I2Cwait();
// Check value of TWSR, and mask out status bits
status = TWSR & 0b11111000;
if ((status != 0b00001000) && (status != 0b00010000)) continue;
// Send device address
TWDR = address;
TWCR = (1<<TWINT) | (1<<TWEN);
// Wait until transmission completes
I2Cwait();
// Check value of TWSR, and mask out status bits
status = TWSR & 0b11111000;
if ((status == 0b00100000 )||(status == 0b01011000))
{
TWCR = (1<<TWINT) | (1<<TWEN) | (1<<TWSTO);
// Wait until stop condition is executed and I2C bus released
while(TWCR & (1<<TWSTO));
continue;
}
break;
}
}
void I2Cstop()
// Stop I2C bus and release GPIO pins
{
// Clear interrupt, enable I2C, generate stop condition
TWCR |= (1 << TWINT)|(1 << TWEN)|(1 << TWSTO);
}
void I2Cwrite(uint8_t data)
// Send ′data′ to I2C bus
{
TWDR = data;
TWCR |= (1 << TWINT)|(1 << TWEN);
I2Cwait();
}
uint8_t I2Cread()
// Read incoming byte of data from I2C bus
{
TWCR |= (1 << TWINT)|(1 << TWEN);
I2Cwait();
// Incoming byte is placed in TWDR register
return TWDR;
}
uint8_t I2CreadACK()
// Read incoming byte of data from I2C bus and ACK signal
{
TWCR |= (1 << TWINT)|(1 << TWEN)|(1 << TWEA);
I2Cwait();
// Incoming byte is placed in TWDR register
return TWDR;
}
uint8_t decimalToBcd(uint8_t val)
// Convert integer to BCD
{
return((val/10*16)+(val%10));
}
uint8_t bcdToDec(uint8_t val)
// Convert BCD to integer
{
return((val/16*10)+(val%16));
}
void setTimeDS3231(uint8_t hh, uint8_t mm, uint8_t ss, uint8_t dw, uint8_t dd,
uint8_t mo, uint8_t yy)
// Set the time on DS3231
{
I2CstartWait(0xD0); // DS3231 write
I2Cwrite(0x00); // Start with hours register
I2Cwrite(decimalToBcd(ss)); // Seconds
I2Cwrite(decimalToBcd(mm)); // Minutes
I2Cwrite(decimalToBcd(hh)); // Hours
I2Cwrite(decimalToBcd(dw)); // Day of week
I2Cwrite(decimalToBcd(dd)); // Date
I2Cwrite(decimalToBcd(mo)); // Month
I2Cwrite(decimalToBcd(yy)); // Year
I2Cstop();
}
void readTimeDS3231()
// Retrieve time and date from DS3231
{
I2CstartWait(0xD0); // DS3231 write
I2Cwrite(0x00); // Seconds register
I2CstartWait(0xD1); // DS3231 read
seconds = bcdToDec(I2CreadACK());
minutes = bcdToDec(I2CreadACK());
hours = bcdToDec(I2CreadACK());
dow = bcdToDec(I2CreadACK());
dom = bcdToDec(I2CreadACK());
mo = bcdToDec(I2CreadACK());
years = bcdToDec(I2CreadACK());
}
void commandLCD(uint8_t _command)
{
// Takes command byte and sends upper nibble, lower nibble to LCD
PORTD = (PORTD & 0x0F) | (_command & 0xF0);
PORTD &= ~(1<<PD0);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_us(200);
PORTD = (PORTD & 0x0F) | (_command << 4);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_ms(2);
}
void initLCD()
{
DDRD = 0b11111111;
_delay_ms(100);
commandLCD(0x02);
commandLCD(0x28);
commandLCD(0x0C);
commandLCD(0x06);
commandLCD(0x01);
_delay_ms(2);
}
void clearLCD()
{
commandLCD (0x01);
_delay_ms(2);
commandLCD (0x80);
_delay_ms(2);
}
void printLCD(char *_string)
{
uint8_t i;
for(i=0; _string[i]!=0; i++)
{
PORTD = (PORTD & 0x0F) | (_string[i] & 0xF0);
PORTD |= (1<<PD0);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_us(200);
PORTD = (PORTD & 0x0F) | (_string[i] << 4);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_ms(2);
}
}
void cursorLCD(uint8_t column, uint8_t row)
// Move cursor to desired column (0–15), row (0–1)
{
if (row == 0 && column<16)
{
commandLCD((column & 0x0F)|0x80);
}
else if (row == 1 && column<16)
{
commandLCD((column & 0x0F)|0xC0);
}
}
int main()
{
initLCD();
I2Cenable();
char numbers[9];
// Uncomment to set time and date, then comment and reflash code
❶ // setTimeDS3231(8,50,0,3,16,6,21); // h, m, s, dow, dom, m, y
while(1)
{
❷ readTimeDS3231();
❸ itoa(hours,numbers,10); // Hours
cursorLCD(4,0);
❹ if (hours==0)
{
printLCD("00");
❺ } else if (hours>0 && hours <10)
{
printLCD("0");
printLCD(numbers);
} else if (hours>=10)
{
printLCD(numbers);
}
cursorLCD(6,0);
printLCD(":");
itoa(minutes,numbers,10); // Minutes
cursorLCD(7,0);
if (minutes==0)
{
printLCD("00");
} else if (minutes>0 && minutes <10)
{
printLCD("0");
printLCD(numbers);
} else if (minutes>=10)
{
printLCD(numbers);
}
cursorLCD(0,9);
printLCD(":");
itoa(seconds,numbers,10); // Seconds
cursorLCD(10,0);
if (seconds==0)
{
printLCD("00");
} else if (seconds>0 && seconds <10)
{
printLCD("0");
printLCD(numbers);
} else if (seconds>=10)
{
printLCD(numbers);
}
cursorLCD(2,1); // Day of week
❻ switch(dow)
{
case 1 : printLCD("Mon"); break;
case 2 : printLCD("Tue"); break;
case 3 : printLCD("Wed"); break;
case 4 : printLCD("Thu"); break;
case 5 : printLCD("Fri"); break;
case 6 : printLCD("Sat"); break;
case 7 : printLCD("Sun"); break;
}
itoa(dom,numbers,10); // Day of month
cursorLCD(6,1);
if (dom<10)
{
printLCD("0");
}
printLCD(numbers);
cursorLCD(8,1);
printLCD("/");
itoa(mo,numbers,10); // Month
cursorLCD(9,1);
if (mo<10)
{
printLCD("0");
}
printLCD(numbers);
cursorLCD(11,1);
printLCD("/");
itoa(years,numbers,10); // Year
cursorLCD(12,1);
printLCD(numbers);
❼ _delay_ms(900);
clearLCD(); // Refresh LCD
}
}
代码的第一部分包括所有 I²C 功能,用于与我们的 DS3231 RTC 模块读取和写入数据,正如在项目 51 中所描述的,在第十二章中,使用与 MAX7219 显示模块相同的方式处理时间和日期信息。它还包括本章之前解释的每个 LCD 函数。接下来,我们需要确保通过setTimeDS3231()函数❶设置时间和日期,然后获取这些信息并以良好的格式在 LCD 上显示。
代码以 24 小时制显示时间,使用两位数字表示小时、分钟和秒。它首先以与项目 51 在第十二章中相同的方式从 DS3231❷获取数据,然后使用itoa()❸将小时、分钟和秒信息转换,并通过cursorLCD()在 LCD 的正确位置显示每个部分。
为了保持正确的间距和信息显示,我们必须确保 LCD 显示单数字值时前面加上零(例如,表示每月的第六天为 06)。为此,代码会检查时间时钟的值是否为零❹或在 1 到 9 之间❺,然后在任何单数字的时间数据前写入所需的零。它会对小时、分钟、秒、日期和月份值进行此操作。
然后,switch...case语句 ❻ 获取星期几数据——一个值从 1 到 7,代表星期日到星期六(或者根据你的地区和偏好,星期一到星期天)——并以缩写形式显示星期几。在所有信息显示完毕后,时钟会等待 900 毫秒 ❼,然后清除显示器,并重新开始。
作为挑战,你可以将这个项目转换成一个带 AM/PM 显示的 12 小时钟,或者添加一个在每天特定时间响起压电蜂鸣器的闹钟。
在 LCD 上显示浮点数
我们的下一个项目需要在 LCD 上显示一个浮点数。与整数类似,浮点数首先需要从浮点数转换为字符数组。为此,我们使用dtostrf()函数,如第四章中所述,然后像往常一样使用printLCD()函数显示字符数组。始终确保为字符数组声明足够的空间,以覆盖整个数字和小数部分。
例如,要显示数字 1.2345678 和 12345.678,可以将项目 54 中的int main()循环替换为以下代码:
int main()
{
❶ float a = 1.2345678;
float b = 12345.678;
❷ char displayNumber[10];
❸ initLCD();
while(1)
{
❹ cursorLCD(0,0);
❺ dtostrf(a,9,7, displayNumber);
printLCD(displayNumber);
cursorLCD(0,1);
❻ dtostrf(b,9,3, displayNumber);
printLCD(displayNumber);
_delay_ms(1000);
}
}
我们声明了两个变量,用于在 LCD 上显示两个示范用的数字 ❶,以及在显示过程中使用的字符数组 ❷。然后,我们像往常一样初始化 LCD ❸,并将光标移动到显示器的左上角 ❹。
代码将数字 1.2345678 转换为一个字符串,使用 10 个字符显示,其中 7 个字符位于小数点后 ❺。最后,它使用 9 个字符显示数字 12345.678,这次有 3 个字符位于小数点后 ❻。
刷新代码后,你应该看到类似图 13-13 中的显示效果。

图 13-13:LCD 上的浮点数
这个示例显示了两个正数。如果你想显示负数,请记得为负号留出一个字符空间,位于第一个数字前面。例如,要显示−123.45,你需要分配七个字符空间。
你将在下一个项目中运用这一新技能。
项目 54:带最小/最大显示的 LCD 数字温度计
在这个项目中,你将制作一个数字温度计,能够显示一段时间内的最小和最大温度,以及当前和*均温度。这个项目是如何将前几章的函数结合到新的、更复杂的项目中的另一个示例。
硬件
要构建你的电路,你需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• 5V 面包板电源
-
• ATmega328P-PU 微控制器
-
• 16×2 字符 LCD,带有内嵌头针
-
• 10 kΩ 面包板兼容调节电位器(可变电阻)
-
• 一个 TMP36 温度传感器
-
• 两个 22 pF 陶瓷电容(C1–C2)
-
• 470 μF 16 V 电解电容(C3)
-
• 0.1 μF 陶瓷电容(C4)
-
• 16 MHz 晶体振荡器
-
• 跳线
按照图 13-14 中的示意图组装电路。别忘了将微控制器的 AV [CC]引脚连接到 5V!

图 13-14:项目 54 原理图
代码
打开终端窗口,导航到本书第十三章文件夹下的项目 54子文件夹,并像往常一样输入命令make flash。片刻后,LCD 应交替显示最小和最大温度,如图 13-15 所示,以及当前和*均温度,如图 13-16 所示。温度读数以摄氏度为单位,涵盖了自上次重置或开启项目以来的时间段。

图 13-15:LCD 显示最小和最大温度

图 13-16:LCD 显示当前和*均温度
让我们来看一下代码,看看它是如何工作的:
// Project 54 - LCD Digital Thermometer with Min/Max Display
#include <avr/io.h>
#include <util/delay.h>
#include <stdlib.h>
#include <math.h>
❶ void startADC()
// Set up the ADC
{
ADMUX |= (1 << REFS0); // Use AVcc pin with ADC
ADMUX |= (1 << MUX2) | (1 << MUX0); // Use ADC5 (pin 28)
// Prescaler for 16MHz (/128)
❷ ADCSRA |= (1 << ADPS2) |(1 << ADPS1) | (1 << ADPS0);
ADCSRA |= (1 << ADEN); // Enable ADC
}
void commandLCD(uint8_t _command)
{
PORTD = (PORTD & 0x0F) | (_command & 0xF0);
PORTD &= ~(1<<PD0);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_us(200);
PORTD = (PORTD & 0x0F) | (_command << 4);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_ms(2);
}
void initLCD()
{
DDRD = 0b11111111;
_delay_ms(100);
commandLCD(0x02);
commandLCD(0x28);
commandLCD(0x0C);
commandLCD(0x06);
commandLCD(0x01);
_delay_ms(2);
}
void clearLCD()
{
commandLCD (0x01);
_delay_ms(2);
commandLCD (0x80);
_delay_ms(2);
}
void printLCD(char *_string)
{
uint8_t i;
for(i=0; _string[i]!=0; i++)
{
PORTD = (PORTD & 0x0F) | (_string[i] & 0xF0);
PORTD |= (1<<PD0);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_us(200);
PORTD = (PORTD & 0x0F) | (_string[i] << 4);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_ms(2);
}
}
void cursorLCD(uint8_t column, uint8_t row)
// Move cursor to desired column (0–15), row (0–1)
{
if (row == 0 && column<16)
{
commandLCD((column & 0x0F)|0x80);
}
else if (row == 1 && column<16)
{
commandLCD((column & 0x0F)|0xC0);
}
}
int main()
{
DDRC = 0b00000000; // Set PORTC as inputs
startADC();
initLCD();
char numbers[9];
float temperature;
float voltage;
float average;
❸ float minimum = -273; // Needs an initial value
float maximum;
uint16_t ADCvalue;
while(1)
{
❹ // Take reading from TMP36 via ADC
ADCSRA |= (1 << ADSC); // Start ADC measurement
while (ADCSRA & (1 << ADSC) ); // Wait for conversion
_delay_ms(10);
// Get value from ADC (which is 10-bit) register
ADCvalue = ADC;
// Convert reading to temperature value (Celsius)
voltage = (ADCvalue * 5);
voltage = voltage / 1024;
❺ temperature = ((voltage - 0.5) * 100);
// Min/max and average
❻ if (temperature < minimum)
{
minimum = temperature;
}
if (temperature > maximum)
{
maximum = temperature;
}
❼ average = ((minimum+maximum)/2);
❽ // Display information
cursorLCD(0,0);
printLCD("Current:");
dtostrf(temperature,6,2,numbers);
printLCD(numbers);
cursorLCD(15,0);
printLCD("C");
cursorLCD(0,1);
printLCD("Average:");
dtostrf(average,6,2,numbers);
printLCD(numbers);
cursorLCD(15,1);
printLCD("C");
_delay_ms(1000);
clearLCD();
cursorLCD(0,0);
printLCD("Minimum:");
dtostrf(minimum,6,2,numbers);
printLCD(numbers);
cursorLCD(15,0);
printLCD("C");
cursorLCD(0,1);
printLCD("Maximum:");
dtostrf(maximum,6,2,numbers);
printLCD(numbers);
cursorLCD(15,1);
printLCD("C");
_delay_ms(1000);
clearLCD();
}
}
本项目的代码分为两个部分:从 TMP36 传感器获取温度(如第三章所示),然后使用 LCD 显示温度值。
我们首先使用一系列函数和命令来激活 28 号引脚上的 ADC,并在主代码中调用它 ❶。startADC()函数与之前项目中的对应函数略有不同;由于我们现在在 16 MHz 的频率下操作微控制器,而不是 1 MHz,我们需要一个更大的分频器来操作 ADC。因此,我们将 ADCSRA 寄存器设置为使用 128 的分频器 ❷。我们通过将 16 MHz 除以 200 kHz(ADC 的理想速度)得到 80;最接*的分频器值是 128,因此我们使用它。
代码从 ADC ❹读取原始数据,并将其转换为摄氏度 ❺。然后,它会判断当前温度是最小值还是最大值 ❻,并计算自上次重置以来测得的*均温度 ❼。请注意,变量minimum的初始值被设置为−273 度 ❸。如果我们没有给它初始值,它将默认为 0,这样我们就无法得到真实的最小温度值(除非传感器在户外,温度从未低于冰点!)。最后,我们使用本章前面提到的 LCD 函数 ❽,在两个屏幕上显示所有这些温度数据。
你当然可以通过将温度值乘以 1.8 并加上 32 来将其转换为华氏温度。或者,如果你想挑战一下自己,为什么不将这个项目与在项目 53 中学到的内容结合起来,制作一个显示当前温度的时钟呢?
完成实验后,让我们继续创建最终的输出类型:自定义字符。
在 LCD 上显示自定义字符
除了使用大多数键盘上可用的标准字母、数字和符号外,你还可以在每个项目中定义最多八个自己的字符。如你所知,LCD 模块中的每个字符由八行五个像素组成,如图 13-17 所示。

图 13-17:每个 LCD 字符由八行五个像素组成。
要显示你自己的自定义字符,你必须首先使用一个包含八个元素的数组(每个字符一行)来定义每个字符。元素的值定义了该行像素的状态。例如,要创建一个简单的“笑脸”,可以像图 13-18 中那样在网格上规划像素。

图 13-18:自定义笑脸字符的元素
通过将每一条横线从与像素开或关状态相匹配的二进制数转换为十进制数,可以将每条横线转化为一个值。然后创建一个数组,通过输入八个十进制值来定义你的自定义字符,如下图所示,参见图 13-18:
uint8_t smiley[] = {27,27,0,4,0,17,10,4};
本章的代码包括一个电子表格,简化了这个数组创建过程。
一旦你创建了数组,你需要将其编程到 LCD 的字符生成 RAM(CGRAM)中。这是一种在 LCD 的控制芯片中使用的 RAM,用于存储显示字符的设计。我们的 LCD 的 CGRAM 中有八个可用位置。为了写入这个字符数据并使用自定义字符,我们将使用接下来的几个自定义函数。
写入数据到 CGRAM
writeLCD()函数将单行数据写入 LCD 的 CGRAM:
void writeLCD(uint8_t _data)
{
PORTD |= (1<<PD0); // RS high
PORTD = (PORTD & 0x0F) | (_data & 0xF0);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_us(200);
PORTD = (PORTD & 0x0F) | (_data);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_ms(2);
}
这个函数的工作方式与我们的commandLCD()函数相同,只是writeLCD()将 LCD 的 RS 引脚设为高,而不是低,这告诉 LCD 接收到的数据是要写入 CGRAM 的,而不是常规命令。它与以下两个函数一起使用。
将自定义字符数据发送到 LCD
createCC()函数将自定义字符数据数组(ccdata[])导入指定的 CGRAM 内存位置(slot),范围从 0 到 7:
void createCC(uint8_t ccdata[], uint8_t slot)
{
uint8_t x;
❶ commandLCD(0x40+(slot*8)); // Select character memory (0-7)
for (x = 0; x<8; x++)
{
❷ writeLCD(ccdata[x]<<4);
}
}
我们指示 LCD 准备字符数据,并将其存储在变量slot中的字符位置❶,然后使用writeLCD()函数依次将字符数组的每个元素发送到 LCD 的 CGRAM❷。
在 LCD 上显示自定义字符
printCCLCD()函数显示 LCD 的八个自定义字符中的一个,并将其存储在位置slot:
void printCCLCD(uint8_t slot)
{
PORTD = (PORTD & 0x0F) | (slot & 0xF0);
PORTD |= (1<<PD0);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_us(200);
PORTD = (PORTD & 0x0F) | (slot << 4);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_ms(2);
}
这个函数的操作方式类似于printLCD(),但它不需要字符串解码,直接在当前光标位置的 CGRAM 位置 0 到 7(slot)显示字符。
下一个项目演示了如何使用这些函数显示自定义字符。
项目 55:显示自定义 LCD 字符
在本项目中,你将重复使用项目 52 的硬件,练习在 LCD 上创建并显示自定义字符。打开一个终端窗口,导航到本书第十三章文件夹中的项目 55子文件夹,然后像往常一样输入命令make flash。片刻后,LCD 应显示八个自定义字符,如图 13-19 所示。

图 13-19: 项目 55 的结果
让我们看看代码,了解它是如何工作的:
// Project 55 - Displaying Custom LCD Characters
#include <avr/io.h>
#include <util/delay.h>
uint8_t ch0[] = {14,10,14,10,0,31,21,21}; // "AM"
uint8_t ch1[] = {14,10,14,8,0,31,21,21}; // "PM"
uint8_t ch2[] = {4,31,17,17,17,31,31,31}; // "Battery"
uint8_t ch3[] = {10,21,17,10,4,0,0,0}; // "Heart"
uint8_t ch4[] = {4,4,31,4,4,0,31,0}; // "+ -"
uint8_t ch5[] = {27,27,0,4,0,17,10,4}; // "Happy face"
uint8_t ch6[] = {17,10,17,4,4,0,14,17}; // "Sad face"
uint8_t ch7[] = {21,10,21,10,21,10,21,10}; // "Pattern"
❶ void writeLCD(uint8_t _data)
// Used for writing to CGRAM
{
PORTD |= (1<<PD0); // RS high
PORTD = (PORTD & 0x0F) | (_data & 0xF0);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_us(200);
PORTD = (PORTD & 0x0F) | (_data);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_ms(2);
}
❷ void commandLCD(uint8_t _command)
{
PORTD = (PORTD & 0x0F) | (_command & 0xF0);
PORTD &= ~(1<<PD0); // RS low
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_us(200);
PORTD = (PORTD & 0x0F) | (_command << 4);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_ms(2);
}
❸ void createCC(uint8_t ccdata[], uint8_t slot)
// Sends custom character data to LCD
{
uint8_t x;
commandLCD(0x40+(slot*8)); // Select character memory (0–7)
for (x = 0; x<8; x++)
{
writeLCD(ccdata[x]<<4);
}
}
❹ void printCCLCD(uint8_t slot)
{
PORTD = (PORTD & 0x0F) | (slot & 0xF0);
PORTD |= (1<<PD0);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_us(200);
PORTD = (PORTD & 0x0F) | (slot << 4);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_ms(2);
}
void initLCD()
{
DDRD = 0b11111111;
_delay_ms(100);
commandLCD(0x02);
commandLCD(0x28);
commandLCD(0x0C);
commandLCD(0x06);
commandLCD(0x01);
_delay_ms(2);
}
void clearLCD()
{
commandLCD (0x01);
_delay_ms(2);
commandLCD (0x80);
_delay_ms(2);
}
void printLCD(char *_string)
{
uint8_t i;
for(i=0; _string[i]!=0; i++)
{
PORTD = (PORTD & 0x0F) | (_string[i] & 0xF0);
PORTD |= (1<<PD0);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_us(200);
PORTD = (PORTD & 0x0F) | (_string[i] << 4);
PORTD |= (1<<PD1);
_delay_us(1);
PORTD &= ~(1<<PD1);
_delay_ms(2);
}
}
void cursorLCD(uint8_t column, uint8_t row)
// Move cursor to desired column (0–15), row (0–1)
{
if (row == 0 && column<16)
{
commandLCD((column & 0x0F)|0x80);
}
else if (row == 1 && column<16)
{
commandLCD((column & 0x0F)|0xC0);
}
}
int main()
{
initLCD();
while(1)
{
❺ createCC(ch0,0); // "AM"
createCC(ch1,1); // "PM"
createCC(ch2,2); // "Battery"
createCC(ch3,3); // "Heart"
createCC(ch4,4); // "+ -"
createCC(ch5,5); // "Happy face"
createCC(ch6,6); // "Sad face"
createCC(ch7,7); // "Pattern"
❻ cursorLCD(0,0);
printCCLCD(0);
cursorLCD(2,0);
printCCLCD(1);
cursorLCD(4,0);
printCCLCD(2);
cursorLCD(6,0);
printCCLCD(3);
cursorLCD(8,0);
printCCLCD(4);
cursorLCD(10,0);
printCCLCD(5);
cursorLCD(12,0);
printCCLCD(6);
cursorLCD(14,0);
printCCLCD(7);
_delay_ms(1000);
clearLCD();
}
}
本项目演示了使用前面描述的三个自定义函数来完成繁重工作时,创建自定义字符是多么简单:它们分别负责写入自定义字符数据❶、向 LCD 发送命令❷以及将自定义字符数据发送到 LCD❸。我们只需插入所需的字符数据❹,然后通过createCC()函数依次将数据输入 LCD 的 CGRAM 的每个位置❺。最后,我们通过cursorLCD()和printCCLCD()函数依次定位光标并显示每个自定义字符❻。
完成本章内容后,你将具备在廉价且流行的 LCD 模块上显示各种文本和数字数据以及你自己创建的自定义字符的技能。作为挑战,尝试创建自己的 AVR LCD 库,以便在未来的项目中更轻松地包含此代码;每次想使用 LCD 时,这个库将节省开发时间并降低复杂性。
在下一章也是最后一章,你将为你不断增长的 AVR 工具箱添加另一个工具:控制伺服电机的能力。
第十四章:# 控制伺服电机

第八章中的多个项目使用了直流电机,适用于旋转设备,如机器人车轮。然而,对于更精确的电机控制选项,您可以使用伺服电机,即伺服机构的简称。伺服电机内部包含电动机,可以通过 PWM 信号旋转到特定的角度位置。
伺服电机在许多应用中都非常有用。例如,您可能会将伺服电机连接到舵角,它是伺服电机旋转的小臂或杆,用来控制遥控汽车的转向。您还可以将一个物理指示器连接到伺服电机,让它显示诸如温度等信息,或者使用伺服电机来升降旋转钻。
在本章中,您将:
-
• 学习如何将 ATmega328P-PU 微控制器连接到伺服电机,并使用 PWM 控制它。
-
• 学习如何独立控制两个伺服电机。
-
• 构建一个模拟温度计和一个模拟时钟。
设置您的伺服电机
市面上有各种类型的伺服电机,从小型单位(如数字相机中的便携式设备)到大型单位(如用于机器人制造组装设备)。在选择伺服电机时,请考虑多个参数:
速度 伺服电机旋转所需的时间,通常以每个角度度数的秒数来衡量。
旋转范围 伺服电机可以旋转的角度范围,例如 180 度(半个完整旋转)或 360 度(一个完整旋转)。
电流 伺服电机的电流消耗。当您将伺服电机与 Arduino 一起使用时,可能需要为伺服电机提供外部电源。
转矩 伺服电机旋转时所能施加的力。转矩越大,伺服电机能够控制的物体就越重。产生的转矩通常与所用电流量成正比。
本章中的示例将使用一种便宜且紧凑的伺服电机,如图 14-1 所示,通常被称为 SG90 型伺服电机。我们将把这种伺服电机与三种不同类型的舵角连接,图中也有显示。

图 14-1:伺服电机与各种舵角
该伺服电机可旋转最多 180 度,如图 14-2 所示。

图 14-2:伺服电机旋转范围示例
伺服电机内部有一个小型直流电机,通过减速齿轮与舵角主轴连接,这些减速齿轮将直流电机的旋转速度降低到适合伺服电机的较慢速度。伺服电机还包含一个反馈控制器,它会测量直流电机轴的旋转位置,从而使伺服电机的位置更加精确。
连接伺服电机
你只需要三根线就能将伺服电机连接到你的微控制器。如果你使用的是 SG90,最深色的线连接到 GND,中间的线连接到 5 V,最浅色的线(脉冲 或 PWM 线)连接到一个具备 PWM 功能的数字引脚。如果你使用的是其他伺服电机,请查阅其数据手册以获得正确的接线方式。我们将使用 图 14-3 中展示的标准伺服电机电路符号。

图 14-3:伺服电机的电路符号
所有你会遇到的爱好者和实验产品中的伺服电机都使用相同的电路符号。
控制伺服电机
我们通过改变连接到伺服电机脉冲线的 PWM 信号的占空比来设置伺服电机的旋转角度。一般来说,伺服电机需要一个频率为 50 Hz,周期为 20 毫秒的 PWM 信号。将信号的占空比设置为不同的值会导致伺服电机的内部控制器将舵机移动到一个与占空比成反比例关系的角度。
以我们的 SG90 伺服电机为例,如果我们将占空比设置为 12%(或 2.4 毫秒,占总周期 20 毫秒的 2.4 毫秒),如 图 14-4 所示,舵机将旋转到 0 度。

图 14-4:0 度的 PWM 信号
如果我们将占空比设置为 3%,如 图 14-5 所示,舵机将旋转到 180 度。

图 14-5:180 度的 PWM 信号
我们将把启用 PWM 输出所需的代码放在一个名为 initPWM() 的函数中:
void initPWM()
{ // Activate PWM on PB1
❶ TCCR1A |= (1 << WGM11);
TCCR1B |= (1 << WGM12)|(1 << WGM13)|(1 << CS11);
// Connect PWM to PB1
❷ TCCR1A |= (1 << COM1A1); // PWM to OCR1A - PB1
❸ ICR1=39999;
}
这个函数将 TIMER1 设置为快速 PWM 模式。它将预分频器设置为 8,以获得 2 MHz 的计时器频率 ❶,并将输出发送到 PB1 ❷。(如果你需要回顾如何生成 PWM 信号,请参阅 第七章。)计时器将从 0 计数到 39,999,然后重置 ❸,每个周期的长度为 0.0000005 秒(时间 = 1/ 频率)。这给出了一个完整的脉冲周期为 20 毫秒。
然后我们将使用 OCR1A 来设置占空比,从而控制伺服电机的位置。我们知道,12% 的占空比会使舵机旋转到 0 度,因此我们可以通过将 40,000(记住计数器从 0 开始计数,到 39,999)乘以 0.12 来计算所需的 OCR1A 值,结果为 4,799。为了完成 180 度的旋转,我们需要将 OCR1A 设置为 1,199(40,000 × 0.12)。
如果你使用的是非 SG90 的伺服电机,确定 0 度和 180 度旋转所需的占空比值,然后使用前述段落中的计算方法来确定所需的 OCR1A 值。你应该能够从伺服电机供应商或零售商那里获得占空比信息。
现在,让我们通过以不同方式旋转伺服电机,将你刚刚学到的知识付诸实践。
项目 56:实验伺服电机
在这个项目中,你将学习伺服控制的基础知识,包括伺服运动所需的电路和指令。
硬件
要构建你的电路,你将需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• 5 V 面包板电源
-
• ATmega328P-PU 微控制器
-
• 两个 22 pF 陶瓷电容器(C1–C2)
-
• 470 μF 16 V 电解电容器(C3)
-
• 16 MHz 晶体振荡器
-
• SG90 伺服电机
-
• 跳线
按照 图 14-6 所示组装电路。

图 14-6: 项目 56 的原理图
请注意在 5 V 和 GND 线路上使用的大型电解电容器。当伺服电机工作时,由于电机的快速启停,可能会产生波动的电压,因此我们使用电容器来*滑电源,使其更加稳定,保持 5 V 电压。
代码
打开一个终端窗口,导航到本书 第十四章 文件夹中的 项目 56 子文件夹,并像往常一样输入命令 make flash。片刻之后,伺服电机应该会快速旋转,从 0 到 180 度,然后以更慢的速度再次旋转,最后再以更慢的速度回到 0 度。
让我们来看一下代码,了解它是如何工作的:
// Project 56 - Experimenting with Servos
#include <avr/io.h>
#include <util/delay.h>
❶ void initPWM()
{
// Activate PWM on PB1
TCCR1A |= (1 << WGM11);
TCCR1B |= (1 << WGM12)|(1 << WGM13)|(1 << CS11);
// Connect PWM to PB1
TCCR1A |= (1 << COM1A1); // PWM to OCR1A - PB1
ICR1=39999;
}
❷ void servoRange()
{
OCR1A=4799; // 0 degrees
_delay_ms(1000);
OCR1A=1199; // 180 degrees
_delay_ms(1000);
}
❸ void servoAngle(uint8_t angle)
{
// Rotate servo to 'angle' position
❹ OCR1A = ((angle-239.95)/-0.05);
// Convert angle to OCR1A (duty cycle) value
}
int main()
{
❺ DDRB|=(1<<PB1);
initPWM();
uint8_t i;
while(1)
{
❻ servoRange();
_delay_ms(1000);
for (i=0; i<=180; i++)
{
❼ servoAngle(i);
_delay_ms(25);
}
for (i=180; i>0; --i)
{
❽ servoAngle(i);
_delay_ms(5);
}
}
}
我们首先定义了三个函数:initPWM() ❶,用于处理 PWM 初始化;servoRange() ❷,为了演示目的,它简单地通过设置 OCR1A 和占空比值,将伺服臂在 0 到 180 度之间旋转;以及有用的自定义函数 servoAngle(uint8_t angle) ❸,它接受一个数字(我们期望的伺服位置的旋转角度),并将其转换为需要存储在 OCR1A ❹ 中的占空比值。这简化了控制伺服的任务,自动将我们想要的角度转换为 4,799 到 1,199 之间的正确占空比,公式为 angle = ( counter – 239.95) / −0.05。这些值通常用于大多数常见的小型伺服电机,但如果不确定,最好向供应商咨询。
在代码的主部分,我们首先将连接到伺服脉冲线的引脚设置为输出 ❺,然后调用 initPWM() 函数启用 PWM。我们调用 servoRange() ❻ 来快速地将伺服臂从 0 到 180 度旋转,然后使用 for 循环 ❼ 和 ❽ 以较慢的速度重复这个过程。每次移动时,伺服臂会在两个方向上各移动一个角度,并引入延迟。
注:servoAngle() 函数的公式是通过线性代数创建的,基于两组点:(4799,0)和(1199,180)。如果你的伺服电机需要不同的占空比值,你可以使用在线工具,如 GeoGebra(www.geogebra.org/m/UyfrABcN),来确定你自己的公式。
现在你已经有了控制伺服电机的代码框架,我们将它与之前关于使用 TMP36 温度传感器的知识结合,来构建一个模拟温度计。
项目 57:创建一个模拟温度计
你可以通过将一个箭头附加到伺服电机的舵轮上,并制作一个带有温度范围的背板来显示温度读数。这个项目会显示 0 到 30 摄氏度之间的温度,但你可以修改它以显示不同的温度范围。
硬件
要构建你的电路,你需要以下硬件:
-
• USBasp 编程器
-
• 无焊接面包板
-
• 5V 面包板电源
-
• ATmega328P-PU 微控制器
-
• 一只 TMP36 温度传感器
-
• 两个 22 pF 陶瓷电容(C1–C2)
-
• 470 μF 16V 电解电容(C3)
-
• 0.1 μF 陶瓷电容(C4)
-
• 16 MHz 晶体振荡器
-
• SG90 兼容伺服电机
-
• 跳线
按照 图 14-7 中所示的方式组装你的电路。别忘了将微控制器的 AV [CC] 引脚连接到 5V。

图 14-7:项目 57 的原理图
图 14-8 显示了伺服电机将要显示的温度范围背板的样子,背板上附有一个小箭头作为指示器。

图 14-8:显示温度的背板
代码
打开终端窗口,进入本书 第十四章 文件夹下的 项目 57 子文件夹,然后像往常一样输入命令 make flash。几秒钟后,伺服电机的舵轮应该会摆动到一个代表温度的角度位置,以摄氏度为单位。
让我们来看一下代码,看看它是如何工作的:
// Project 57 - Creating an Analog Thermometer
#include <avr/io.h>
#include <util/delay.h>
#include <stdlib.h>
#include <math.h>
void startADC()
// Set up the ADC
{
ADMUX |= (1 << REFS0); // Use AVcc pin with ADC
ADMUX |= (1 << MUX2) | (1 << MUX0); // Use ADC5 (pin 28)
ADCSRA |= (1 << ADPS2) |(1 << ADPS1) | (1 << ADPS0);
// Prescaler for 16MHz (/128)
ADCSRA |= (1 << ADEN); // Enable ADC
}
void initPWM()
{
// Activate PWM on PB1
TCCR1A |= (1 << WGM11);
TCCR1B |= (1 << WGM12)|(1 << WGM13)|(1 << CS11);
// Connect PWM to PB1
TCCR1A |= (1 << COM1A1);
// PWM to OCR1A - PB1
ICR1=39999;
}
void servoAngle(uint8_t angle)
{
// Rotate servo to 'angle' position
OCR1A = ((angle-239.95)/-0.05);
// Convert angle to OCR1A (duty cycle) value
}
int main()
{
❶ DDRB|=(1<<PB1); // Set PORTB1 as output for servo control
❷ DDRC|=(0<<PC5); // Set PORTC5 as input for TMP36 measurement
❸ float temperature;
float voltage;
uint16_t ADCvalue;
uint8_t finalAngle;
❹ startADC();
❺ initPWM();
while(1)
{
❻ ADCSRA |= (1 << ADSC); // Start ADC measurement
while (ADCSRA & (1 << ADSC)); // Wait for conversion
_delay_ms(10);
❼ ADCvalue = ADC;
// Convert reading to temperature value (Celsius)
❽ voltage = (ADCvalue * 5);
voltage = voltage / 1024;
temperature = ((voltage - 0.5) * 100);
// Display temperature using servo
❾ finalAngle = 6 * temperature;
servoAngle(finalAngle);
_delay_ms(500);
}
}
我们首先按照常规步骤设置舵机所需的引脚为输出❶,并将 TMP36 传感器设置为输入❷,然后声明存储和转换 TMP36 传感器温度数据所需的变量❸。接着,我们调用函数启动 ADC❹并初始化 PWM❺。接下来,我们通过读取 ADC❻并将其值存入ADCvalue❼,然后进行数学转换将温度转换为摄氏度❽。最后,我们将温度转换为舵机角度,通过将其乘以 6(因为舵机的范围是 0 到 180 度)❾,然后让舵机移动到适当的角度。
到目前为止,你可以利用本书中学到的知识,使用舵机制作各种可控的模拟显示器——例如,一个低电压表或倒计时器。但如果有比一个舵机更好的选择,那就是同时使用两个舵机;接下来你将看到如何做到这一点。
项目 58:控制两个舵机
由于 ATmega328P-PU 微控制器上有多个支持 PWM 的输出引脚,我们可以同时控制两个舵机,适用于更复杂的项目。这个项目将展示如何实现这一点。
硬件
要构建你的电路,你需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• 5V 面包板电源
-
• ATmega328P-PU 微控制器
-
• 一个 TMP36 温度传感器
-
• 两个 22 pF 陶瓷电容(C1–C2)
-
• 470 μF 16V 电解电容(C3)
-
• 0.1 μF 陶瓷电容(C4)
-
• 16 MHz 晶体振荡器
-
• 两个 SG90 兼容舵机
-
• 跳线
按照图 14-9 所示组装电路。

图 14-9:项目 58 的原理图
代码
打开终端窗口,导航到本书第十四章文件夹下的项目 58子文件夹,并像往常一样输入命令make flash。几秒钟后,两个舵机会模拟项目 56 中演示的动作,快速旋转整个范围从 0 到 180 度,然后以较慢的速度重复此动作,再以更慢的速度返回到 0 度。
让我们来看一下这个是如何工作的:
// Project 58 - Controlling Two Servos
#include <avr/io.h>
#include <util/delay.h>
❶ void initPWM()
{
// Activate PWM
TCCR1A |= (1 << WGM11);
TCCR1B |= (1 << WGM12)|(1 << WGM13)|(1 << CS11);
// Connect PWM to PB1 and PB2
❷ TCCR1A |= (1 << COM1A1)|(1 << COM1B1);
// PWM to OCR1A - PB1 and OCR1B - PB2
ICR1=39999;
}
❸ void servoAngleA(uint8_t angle)
{
// Rotate servo on OCR1A to 'angle' position
OCR1A = ((angle-239.95)/-0.05);
// Convert angle to OCR1A (duty cycle) value
}
❹ void servoAngleB(uint8_t angle)
// Rotate servo on OCR1B to 'angle' position
{
OCR1B = ((angle-239.95)/-0.05);
// Convert angle to OCR1A (duty cycle) value
}
❺ void servoRange()
{
OCR1A=4799; // 0 degrees
OCR1B=4799;
_delay_ms(1000);
OCR1A=1199; // 180 degrees
OCR1B=1199; // 180 degrees
_delay_ms(1000);
}
int main()
{
DDRB|=(1<<PB1)|(1<<PB2); // Set PB1 and PB2 to outputs
initPWM();
uint8_t i;
while(1)
{
servoRange();
_delay_ms(1000);
for (i=0; i<=180; i++)
{
servoAngleA(i);
servoAngleB(i);
_delay_ms(25);
}
for (i=180; i>0; --i)
{
servoAngleA(i);
servoAngleB(i);
_delay_ms(5);
}
}
}
在initPWM()函数❶中,激活 PWM 后,我们打开 TCCR1A 中的 COM1B1 位,以便为连接到 PB2 的第二个舵机启用 PWM❷。有两个servoAngle()类型的函数,一个用于舵机 A❸,一个用于舵机 B❹,可以通过接收所需的旋转角度来控制舵机。我已修改servoRange()函数❺,通过分别将所需的值赋给 OCR1A 和 OCR1B 来控制第一个和第二个舵机。
你还可以通过改变servoAngleA/B()函数后的延时或将计数反向(从较高值到较低值)来实验两个舵机的方向。现在你已经可以轻松使用两个舵机了,接下来是将它们应用于模拟时钟。
项目 59:用舵机指针构建模拟时钟
在这个项目中,你将使用两个舵机通过双显示模拟时钟来显示时间。一个舵机显示小时,另一个显示分钟。
硬件
要构建电路,你需要以下硬件:
-
• USBasp 编程器
-
• 无焊面包板
-
• 5 V 面包板电源
-
• ATmega328P-PU 微控制器
-
• 一个 TMP36 温度传感器
-
• 两个 22 pF 陶瓷电容(C1–C2)
-
• 470 μF 16 V 电解电容(C3)
-
• 0.1 μF 陶瓷电容(C4)
-
• 16 MHz 晶体振荡器
-
• DS3231 实时时钟模块,带有备用电池
-
• 两个 SG90 兼容舵机
-
• 跳线
按照图 14-10 所示组装你的电路。别忘了将 DS3231 板连接到 5 V 和 GND。

图 14-10:项目 59 原理图
在上传代码之前,别忘了像在之前使用 DS3231 的项目中一样设置时间,例如项目 51。你还可以创建一个类似于项目 57 中使用的背景显示,如图 14-11 所示——尽情发挥创意。注意,原理图中的舵机 M1 用于显示小时,M2 用于显示分钟。

图 14-11:项目 59 时钟面盘示例
代码
打开一个终端窗口,导航到本书第十四章文件夹中的Project 59子文件夹,像往常一样输入命令make flash。一旦你烧录了代码,你应该能够通过舵机舵盘的位置看到当前时间。
让我们看看这是如何工作的:
// Project 59 - Building an Analog Clock with Servo Hands
#include <avr/io.h>
#include <util/delay.h>
// Variables to store time and date
uint8_t hours, minutes, seconds, dow, dom, mo, years;
void I2Cenable()
// Enable I2C bus
{
TWBR = 72; // 100 kHz I2C bus
TWCR |= (1 << TWEN); // Enable I2C on PORTC4 and 5
}
void I2Cwait()
// Wait until I2C finishes an operation
{
// Wait until bit TWINT in TWCR is set to 1
while (!(TWCR & (1<<TWINT)));
}
void I2CstartWait(unsigned char address)
{
// Start I2C bus
uint8_t status;
while (1)
{
// Send START condition
TWCR = (1<<TWINT) | (1<<TWSTA) | (1<<TWEN);
// Wait until transmission completes
I2Cwait();
// Check value of TWSR, and mask out status bits
status = TWSR & 0b11111000;
if ((status != 0b00001000) && (status != 0b00010000)) continue;
// Send device address
TWDR = address;
TWCR = (1<<TWINT) | (1<<TWEN);
// Wait until transmission completes
I2Cwait();
// Check value of TWSR, and mask out status bits
status = TWSR & 0b11111000;
if ((status == 0b00100000 )||(status == 0b01011000))
{
TWCR = (1<<TWINT) | (1<<TWEN) | (1<<TWSTO);
// Wait until stop condition is executed and I2C bus is released
while(TWCR & (1<<TWSTO));
continue;
}
break;
}
}
void I2Cstop()
// Stop I2C bus and release GPIO pins
{
// Clear interrupt, enable I2C, generate stop condition
TWCR |= (1 << TWINT)|(1 << TWEN)|(1 << TWSTO);
}
void I2Cwrite(uint8_t data)
// Send 'data' to I2C bus
{
TWDR = data;
TWCR |= (1 << TWINT)|(1 << TWEN);
I2Cwait();
}
uint8_t I2Cread()
// Read incoming byte of data from I2C bus
{
TWCR |= (1 << TWINT)|(1 << TWEN);
I2Cwait();
return TWDR;
}
uint8_t I2CreadACK()
// Read incoming byte of data from I2C bus and ACK signal
{
TWCR |= (1 << TWINT)|(1 << TWEN)|(1 << TWEA);
I2Cwait();
// Incoming byte is placed in TWDR register
return TWDR;
}
uint8_t decimalToBcd(uint8_t val)
// Convert integer to BCD
{
return((val/10*16)+(val%10));
}
uint8_t bcdToDec(uint8_t val)
// Convert BCD to integer
{
return((val/16*10)+(val%16));
}
void setTimeDS3231(uint8_t hh, uint8_t mm, uint8_t ss, uint8_t dw,
uint8_t dd, uint8_t mo, uint8_t yy)
// Set time on DS3231
{
I2CstartWait(0xD0); // DS3231 write
I2Cwrite(0x00); // Start with hours register
I2Cwrite(decimalToBcd(ss)); // Seconds
I2Cwrite(decimalToBcd(mm)); // Minutes
I2Cwrite(decimalToBcd(hh)); // Hours
I2Cwrite(decimalToBcd(dw)); // Day of week
I2Cwrite(decimalToBcd(dd)); // Date
I2Cwrite(decimalToBcd(mo)); // Month
I2Cwrite(decimalToBcd(yy)); // Year
I2Cstop();
}
void readTimeDS3231()
// Retrieve time and date from DS3231
{
I2CstartWait(0xD0); // DS3231 write
I2Cwrite(0x00); // Seconds register
I2CstartWait(0xD1); // DS3231 read
seconds = bcdToDec(I2CreadACK());
minutes = bcdToDec(I2CreadACK());
hours = bcdToDec(I2CreadACK());
dow = bcdToDec(I2CreadACK());
dom = bcdToDec(I2CreadACK());
mo = bcdToDec(I2CreadACK());
years = bcdToDec(I2CreadACK());
}
void initPWM()
// Activate PWM
{
TCCR1A |= (1 << WGM11);
TCCR1B |= (1 << WGM12)|(1 << WGM13)|(1 << CS11);
// Connect PWM to PB1 and PB2
TCCR1A |= (1 << COM1A1)|(1 << COM1B1);
ICR1=39999;
}
void servoAngleA(uint8_t angle) // Hours servo
// Rotate servo on OCR1A to 'angle' position
{
OCR1A = ((angle-239.95)/-0.05);
// Convert angle to OCR1A (duty cycle) value
}
void servoAngleB(uint8_t angle) // Minutes servo
// Rotate servo on OCR1B to 'angle' position
{
OCR1B = ((angle-239.95)/-0.05);
// Convert angle to OCR1A (duty cycle) value
}
❶ void displayServoTime()
{ // Displays hours on servo A, minutes on servo B
uint8_t _hours;
uint8_t _minutes;
❷ _hours = hours * 15;
❸ servoAngleA(_hours);
❹ _minutes = minutes * 3;
❺ servoAngleB(_minutes);
}
int main()
{
DDRB = 0b11111111; // Set PORTB as outputs
I2Cenable();
initPWM();
// Uncomment to set time & date, then comment and reflash code
// setTimeDS3231(9,13,0,5,29,4,21); // h,m,s,dow,dom,m,y
while(1)
{
readTimeDS3231();
displayServoTime();
_delay_ms(1000);
}
}
审查代码后,你应该能识别出启用 I²C 总线的部分(如第十二章中所述),以及从 DS3231 RTC 模块获取和设置时间的部分(如第十三章中所述),并通过 PWM 控制舵机(如本章前面讨论的)。
本项目中的新材料在displayServoTime()函数❶中,该函数从 RTC 获取小时和分钟的值,并将它们转换为舵机应该移动到的合适角度。对于显示小时的舵机,我们将 180 度的舵机范围除以 12 小时。这样得到 15,所以我们将小时值乘以 15,得到所需的舵机角度❷,然后命令第一个舵机移动到该位置❸。我们使用类似的过程将分钟转换为角度:180 除以 60 得到 3,因此我们将分钟值乘以 3❹,然后命令第二个舵机移动到该位置❺。
作为最后的挑战,尝试修改代码,使得小时显示从 12 开始,11 结束,而不是从 1 到 12,或者自己制作一个舵机库。你可以用许多方式来扩展这些时钟,并且在使用舵机方面也有很多可能性:例如,你可以尝试将两个舵机用作爬行机器人前臂,或者用来控制老式机械式电灯开关。
那么接下来你应该怎么做呢?本书仅仅是你 AVR 旅程的开始。请查看以下的后记,了解下一步该怎么走。
第十五章:尾声
到目前为止,在阅读过(并且希望你已经制作过)这本书中的 59 个项目之后,你应该具备了创建自己 AVR 单片机项目所需的理解、知识和信心。我相信你将能够运用 AVR 技术解决各种问题,同时也能享受其中的乐趣!(为了获得一些灵感,可以回顾第一章中的一些更高级项目的示例。)
我也希望这本书能激励你深入探索电子学和电气工程的精彩世界。你不会是孤单一人——你会在互联网上找到一个充满活力的 AVR 单片机用户社区,像以下这些地方:
-
• AVR Freaks 论坛:
www.avrfreaks.net/ -
• Reddit:
www.reddit.com/r/avr/new/
你甚至可以寻找本地的黑客空间或俱乐部,在线或亲自参加。
我总是很高兴通过出版商网页上的联系方式接收关于本书的反馈:nostarch.com/contactus/。在你访问时,别忘了看看我其他由 No Starch Press 出版的书籍。最重要的是,不要只是坐着——动手做点什么!











浙公网安备 33010602011771号