# Electronic production guide **Repository Path**: volatile-int/electronic-production-guide ## Basic Information - **Project Name**: Electronic production guide - **Description**: 大学生科创电子制作指南(未完成) - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 3 - **Forks**: 1 - **Created**: 2021-05-14 - **Last Updated**: 2026-03-17 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README E唯协会电控部入门教程(控制部分) ## 前言 本教程面向0基础的同学使用,重要的内容使用**粗体**标出,拓展内容使用灰色字单独列出,例如: > 这是一段拓展内容 对拓展内容不感兴趣或者感觉复杂的同学可以略过不读而不影响主要内容。 如果遇到问题,可以阅读每一章后面的FAQ(常见问题解答)部分,或者询问同学或者学长学姐。 ## 初级部分:认识基本电控技术 本篇将从单片机开始,逐步介绍如何从零开始制作一个遥控小车。 ### 1. 认识单片机 - Blink 这一节将会向大家展示如何使用单片机制作一个最简单小制作——一个会闪的灯。本节的目标并不是教会大家什么技巧,而是做一些在正式学习之前的准备(硬件上,软件上或是精神上)。这些内容可能看起来是一大堆莫名其妙繁琐而无聊的步骤,但是如果你成功的做到了最后,当小灯开始按照你的指令闪烁时,你就会发现电子制作的乐趣。 **单片机**有时也称微控制器,是一种体积微小的电子计算机。与常见的电脑不同,单片机运算能力很弱,并不能用于完成办公、玩游戏等工作。但是单片机以其体积小、功耗低、价格低廉的特点广泛的使用在智能家居、工厂、小型电子设备中。我们经常使用单片机来控制机器人,指令机器人在受到指令或者遇到不同的环境时做出不同的动作,单片机在机器人中就相当于神经的中枢或是大脑。 一个软件工程师最开始编写的程序总是Hello World,而硬件工程师的第一个程序就总是Blink。所谓Blink就是控制一个灯闪烁。接下来我们将会进行的就是硬件工程师的第一课,使用单片机控制一个灯的循环闪烁。 在开始之前,我们需要确定有**一个安装有Windows 7版本以上系统的电脑**,并且需要在淘宝上**购买一些工具和材料**。等待购买的东西到达也许会花费几天的时间,在此期间可以考虑先准备好软件环境或者来到协会借用公共的开发板和器材来进行学习。 #### 1.1 购买合适的工具和材料 在我们实现blink之前,我们需要一个**单片机开发板**。并且,为了使我们编写的程序生效,我们还需要**仿真器**来烧录程序到单片机上。这里我们采用最常见的*stm32 Blue Pill板*以及*st-link*进行操作。 ##### 1.1.1 选购blue pill 首先,我们在淘宝上搜索*stm32最小系统板*,可能会得到类似这样的结果: ![](1/1.jpg) 我们可以看到类似这种蓝色长方形的电路板,这就是*Blue Pill板*了。*Blue Pill 板*搭载不同的芯片时价格会有所不同,目前价格在十几元到四十几元左右。我们推荐根据自己的预算,优先选择stm32f103c8t6,然后是stm32f103c6t6,最后是ch32。 > **关于Blue Pill板** > > Blue Pill板是一款流行的单片机最小系统板。因为他的价格适中(疫情之前大约8-10元)、功能丰富所以广受全世界范围电子爱好者的喜爱。这款最小系统板通常搭载有型号为“stm32f103c8t6”的单片机芯片,但是疫情之后单片机产品价格大幅上涨,而stm32f103c8t6作为一种常用的型号价格上涨的很高,所以很多厂商开始选择其它型号的单片机来制作Blue Pill板。stm32f103c6t6和ch32包括gd32都成了很好的选择,使用这些型号制作的Blue Pill板通常价格只有原版的几分之一,使用上的差别并不太大。 > > ![](1/31.png) > **什么是最小系统板和开发板?** > > 在淘宝上我们可以看到各式各样的这些搭载有单片机的电路板,对于这些电路板,我们常常会把他们分为最小系统板与开发板。当然这两种板之间并没有明确的界定,通常最小系统板上面的除了单片机以外的外围电路很少,在使用时需要将需要用到的电路连接到板上才能使用,而开发板则是将一些常用的电路直接制作在电路板上,省去了我们连接电路的麻烦,但是也减少了电路板的灵活性,当我们需要一些开发板上没有的电路时,可能会由于开发板上单片机的接口被板上的电路占据而没有办法拓展我们需要的功能,并且开发板也往往比最小系统板的体积大的多,不利于使用在空间要求较高的项目中。所以在我们实际使用过程中,通常最小系统板用于DIY制作,而开发板用于学习。 > > 我们这里使用的blue pill板通常被界定为最小系统板,之所以使用blue pill而不是其他的开发板,主要原因是: > 1. blue pill板十分常见,无论是国内国外的网站上都可以找到大量可以应用在上面的程序和外围电路。 > 2. blue pill板结构简单使用灵活,方便理解电路并且进行拓展,可以添加外围电路直接用于后面我们制作遥控小车的内容。 > 3. 价格便宜,开发板的价格往往是blue pill的十倍以上。 > > 对开发板感兴趣的同学也可以考虑购买一块开发板用于学习,通常这些开发板的生产商家都会提供相应的视频和文字教程,利用这些资源进行单片机学习是非常方便的。我个人而言更加推荐只具有比较基础的外部电路例如数码管,电位器,按钮,蜂鸣器一类的开发板。因为这类开发板价格相对低一些,而且那些相对昂贵的开发板往往附带一些RS232,RJ45,JTAG,SRAM,TF卡槽这样的外围电路或者接口,这些电路或者接口我们基本不会用到,相反蜂鸣器一类简单的电路是我们经常使用的,没有必要购买过于昂贵的开发板。 在选购Blue Pill板时还需要注意的一点是:**排针焊接的方向**。 ![](1/61.jpg) ![](1/62.jpg) 假如没有方便焊接排针的环境的话(并不推荐在寝室里焊东西,建议到实验室焊接),那么购买已经焊好排针的板子就是一个很好的选择了。这时候就需要注意排针焊接的方向,是向上还是向下,这取决于你愿意使用哪一种接线方式。在初学阶段我们推荐简单方便的杜邦线连接,这样就需要向上焊接排针、而在学习电子与控制结合方面的知识时建议使用面包板,这时就需要向下焊接排针。而在制作遥控小车的时候建议使用pcb或者洞洞板,必要时使用杜邦线,这时需要向下焊接排针。 **如果不确定自己的需求,那么选择购买向上焊接排针吧。** > **我们常用的连线方式** > > 1. 自己制作pcb板将电路固定在板上,这种适合于做一些周期长比赛或者高级的DIY,因为pcb板制作比较麻烦,修改周期也比较长,好处是做出来的电路十分稳定,集成度高,不容易出问题。 > 2. 使用排针排母和洞洞板连接,这种适合于一些周期短的比赛或者DIY,需要自己焊板子,做出来的电路比较稳定,修改起来比pcb方便一些,但也很麻烦。 > 3. 使用排针和面包板连接,这种适合于简单的电路实验,不需要焊接(除了排针),将电路元件插在面包板上就能实现连接,修改起来很方便,但是电路很容易被破坏。 > 4. 使用排针排母和杜邦线连接,这种适合于最简单的学习实验,不需要焊接(除了排针),用杜邦线很容易就能连接电路,但是非常不稳定,因为**杜邦线很容易松动导致虚连**。 > > ![](1/63.jpg) > > ![](1/64.jpg) > > 如果自己制作pcb的话,我们通常不会使用现有的电路板。而使用洞洞板和面包板的话,我们需要将排针向下焊接以方便插入板子,使用杜邦线的话我们建议将排针向上焊接,这样更方便插杜邦线。 ##### 1.1.2 选购stlink 我们在淘宝上搜索*stlink*,可能会得到类似这样的结果: ![](1/3.jpg) 我们需要购买的就是这种白色的小物件,假如你的预算不太充足的话也可以选择图中第一项的彩色壳、外观类似U盘形的st-link。白色的目前为40-100元不等,而彩色壳价格通常也会达到30元左右。 > **关于仿真器** > > 实际上这一类仿真器的种类有很多,比较常见的是jlink,stlink和dap,所有的这些仿真器都能够正常的下载程序。 > > jlink和小型版本的jlink-ob使用方便可靠,但是由于版权问题,便宜的盗版jlink几乎找不到了,正版的jlink价格过于昂贵。dap没有版权问题,但是使用不太广泛,资料和软件资源不容易找到。stlink相对便宜,而且st官方出品的软件都支持stlink,所以这里推荐使用st-link进行学习。 > > 这里比较麻烦的是,标准的白色壳st-link没有单独引出sw接口(只有一个较大的jtag接口),而blue pill等小型板只留有sw接口,这样连接仿真器与电路板就比较麻烦。所以这里可以考虑购买一个jtag转sw的转接板方便插线,如果没有的话当然也可以使用,但是插线会困难一些。或者也可以购买较小的那种彩色壳st-link或者dap这种本身只支持sw接口的仿真器,并不影响使用。就我个人经历而言,烧毁只支持sw接口的小仿真器的次数要高于支持jtag的仿真器的次数,但是使用过程没有什么明显的差别,因为即使是有jtag接口的仿真器我们几乎也只会使用sw,绝大多数情况下jtag接口所提供的调试功能我们都不会用到。 > > 其实仿真器通常也是使用单片机实现的,感兴趣的同学可以尝试自己制作一个。 > > **给单片机下载程序的方式** > > 早期的很多单片机并不自带程序存储器,那时的工程师通常需要将程序通过编程器下载到外部的程序存储器芯片中,然后把程序存储器芯片连接到单片机使单片机工作。后来的单片机通常都自带有程序存储器,工程师们只需要使用编程器将程序直接下载到单片机中就可以了。到了比较现代的单片机,例如AT89S52单片机可以被安装在板子上进行下载程序,而传统的AT89C52只能使用编程器来进行下载。这种可以对已经被安装在电路板上的芯片编程的技术叫做ISP(In-System Programming)。而对于现在的微小封装的的单片机,将这种微小的芯片固定在编程器上并且不折弯或者折断引脚是很难的事情,绝大多数现代的单片机都支持ISP。 > > 图为协会的芯片编程器,AT89C52一类不支持ISP的单片机只能使用这类编程器来进行程序下载。 > > ![](1/69.jpg) > > STM32系列单片机支持的编程方式则更多,可以通过JTAG接口或者串口进行下载程序以外,还可以通过USB下载程序。我们开启USB DFU功能之后编译下载程序,然后就可以使用相应的软件通过USB接口直接升级单片机里面的程序,这样普通的用户就可以不用购买编程器器或者仿真器来升级固件了。 > > ![](1/70.png) ##### 1.1.3 USB转TTL模块和其他 在blinik中,我们只需要blue pill和st-link就能完成所有的操作。但是在后面的UART部分我们需要连接单片机的串口和电脑,但是现代电脑通常不具备串口接口,因此常用USB转TTL模块将USB口转换为串口。此外,虽然不能使用串口进行跟踪变量等调试手段,但是必要时可以使用串口向单片机下载程序。因此推荐在购买板子的时候一起购买一个USB转TTL模块,价格通常是3-5元左右。 ![](1/66.jpg) 除了USB转TTL模块,我们还需要很重要的一项就是连接模块与最小系统板所需要使用的**杜邦线**,杜邦线分公头和母头,通常我们需要的是母对母的杜邦线。长度可以选择15-20cm的,通常这个长度比较够用。 到这里,请检查你的购物车,确定有以下项目: * stm32最小系统板(Blue Pill)一个 * arm仿真器(st-link)一个 * USB转TTL模块一个 * 母对母杜邦线一排 然后下单支付,等待所需材料的到来吧。 #### 1.2 配置软件环境 ##### 1.2.1 安装Keil与pack包 我们在编写与调试单片机程序的时候通常是在**Keil**这款软件上进行,因此我们首先安装Keil软件。 Keil的安装包可以在某些微信公众号或者百度网盘上获取,更方便的做法是从同学或者学长学姐处获取。比较通用且流传较广泛的版本是5.23和 5.27。这里我们以Keil 5.23为例演示安装方法。 首先,我们运行exe主程序(此处为*MDK523.exe*)。确认是否允许对设备更改时选是。之后我们可以看到如下界面: ![](1/4.png) 点击*Next*, 出现如下界面: ![](1/5.png) 勾选*I agree to all the terms of the preceding License Agreement*选项,然后点击*Next*,出现如下界面: ![](1/6.png) **注意!可以选择安装在其他位置,但安装路径中不可以出现空格和中文字符。**点击*Next*,出现如下界面: ![](1/7.png) 填写内容随意,这里填写的信息可能会被附加在由这个软件创建的工程中,因此其他获得这个工程的人可以通过这个信息获知该工程的创建者,有需要的话可以考虑留下有价值的信息。然后点击*Next*,出现如下界面: ![](1/8.png) 等待进度条走完,**在此期间可能出现安装驱动的窗口,一律确认安装**。之后出现如下界面: ![](1/9.png) 取消选择*Show Release Notes*选项,然后点击*Finish*,之后出现如下两个界面: ![](1/11.png) ![](1/10.png) 关闭这两个界面,出现如下确认窗口时选择*是(Y)*: ![](1/12.png) 然后找到*Keil.STM32F1xx_DFP.2.2.0.pack*文件,双击运行后出现如下界面:**如果没有出现请确认这个文件是否位于一个压缩包内,如果位于压缩包内需要先解压然后操作** ![](1/13.png) 点击Next,出现以下界面: ![](1/14.png) 进度条完成后出现以下界面,点击Finish: ![](1/15.png) 至此Keil安装完成,可以在桌面上找到图标如下: ![](1/16.png) 以下为检查安装状态,可以不做: 双击Keil图标打开后,看到如下界面: ![](1/17.png) 如下图所示,点击Help并在下拉菜单中选择*About uVision*: ![](1/18.png) 出现以下界面则说明安装版本正确: ![](1/19.png) 那么我们现在就完成了Keil的安装,可以继续安装STM32CubeMX软件了。 > **Keil** > > Keil是一款ide(集成开发环境)软件。我们在编写程序的时候,无论是网页,游戏,智能家电,工业控制,几乎无一例外的需要很多软件的辅助,而这些软件数量庞大,用法各异,使用起来非常麻烦。于是就有了ide软件,ide软件将这些常用的软件集合起来给用户提供一个统一的界面,更利于学习和使用。Keil作为单片机或者说嵌入式开发ide中的佼佼者,功能十分强大。除了基础的编写代码,编译与烧写之外,Keil还能够支持调试和仿真。尤其是Keil的仿真功能是一众此类软件中最强大的,在编程时可以提供很多帮助。 > > Keil是一款付费的软件,但是编译小型程序(<32K)时可以免费使用。而我们编写的程序往往比较小,所以免费使用基本可以满足我们的要求。Keil安装包的获取可以在[Keil官网](https://www.keil.com/)上获取,但是需要填写邮箱验证,比较麻烦。 ##### 1.2.2 安装STM32CubeMX STM32CubeMX是一个完全免费的软件,下载时可以考虑从ST官网下载最新版本或者从同学或学长学姐处获取。这里以*STM32ubeMX6.3.0*版本为例演示安装过程。 首先我们运行名称类似*SetupSTM32CubeMX-x.x.x-Win.exe*的文件,弹出是否允许对设备更改的窗口选是,然后出现以下界面: ![](1/20.png) 等待进度条完成,会出现以下界面: ![](1/21.png) 点击Next,然后出现以下界面: ![](1/22.png) 勾选*I accept the terms of this license agreement*选项,然后点击*Next*,出现以下界面: ![](1/23.png) 勾选第一个选项,然后点击*Next*,之后出现如下界面: ![](1/24.png) **注意!可以选择安装在其他位置,但安装路径中不可以出现中文字符。**点击*Next*,如果出现以下界面: ![](1/25.png) 点击*确定*,然后就可以看到如下界面: ![](1/26.png) 点击*Next*,然后出现如下界面: ![](1/27.png) 等待进度条完成之后点击*Next*,然后出现以下界面: ![](1/28.png) 点击*Done*,完成安装。 这时可以在桌面上找到图标如下: ![](1/29.png) 双击运行,出现以下界面:**如果没有出现如下界面,而是报告缺少Java 运行环境的话,说明STM32CubeMX版本过低,考虑安装jre或者下载新版的STM32CubeMX使用** ![](1/30.png) 至此,我们就完成了软件的安装部分。 > **STM32CubeMX** > > STM32CubeMX是一款自动生成stm32系列单片机代码的软件,同时也是选择stm32系列单片机型号与规划引脚时非常好用的工具。STM32CubeMX同样可以方便的从[ST官网](https://www.st.com/)获取,也需要首先进行st账号的注册。这里我们推荐注册一个ST账号,因为ST网站上提供很多的方便开发stm32系列单片机的软件和资料,注册账号可以便于获取最新的软件和资料。 ##### 1.2.3 安装ST-LINK驱动 电脑访问硬件需要对应的驱动程序,不幸的是ST-LINK的驱动程序通常不会预装在你的电脑系统中,可以访问ST公司的[ST-LINK驱动下载网页](https://www.st.com/content/my_st_com/en/products/development-tools/software-development-tools/stm32-software-development-tools/stm32-utilities/stsw-link009.license=1634023482118.product=STSW-LINK009.version=2.0.2.html)来下载下载ST-LINK驱动程序。 下载好的ST-LINK驱动程序名字类似*en.stsw-link009_vx.x.x.zip*,**需要先解压缩整个压缩包再安装驱动**。解压之后,在产生的目录中可以看到*dpinst_amd64.exe*这个程序,双击运行。允许对设备的更改后,弹出以下窗口: ![](1/67.png) 点击*下一页(N) >*,等待安装完成之后,弹出窗口: ![](1/68.png) 这样我们就可以电脑连接到ST-LINK调试下载程序了。 > **驱动程序** > > 驱动程序是告诉电脑该怎样操作一种硬件的程序。常见的很多硬件都是“免驱”的,这是因为操作系统如Windows或者Linux通常自带有相当庞大数量的驱动程序库,相当于装有这些操作系统的电脑天生就会操作这些硬件一样。常见的USB鼠标和键盘就是这样,类似的还有之前的USB转TTL模块(通常为CH340或者CP2102芯片),这些硬件无需安装驱动程序就可以被电脑识别。如果你把一个硬件连接在你的电脑上,而电脑提示找不到驱动程序,那么就尝试找到这种硬件的生产厂家的网站下载并安装对应的驱动吧。、 #### 1.3 Blink ##### 1.3.1 创建工程 我们进行实验之前的第一个步骤就是创建一个工程,所谓工程就是我们包括编写的代码与其他相关资料的一个集合,工程文件可以在不同的电脑之前复制传输,便于使用和管理。假如你希望先试一试效果的话,可以首先使用已经创建完成复制好代码的工程,直接到*1.3.3 编译运行*一节,或者跟随本节教程进行工程的创建步骤。 这些步骤可能看起来有些复杂,但是不要担心!在后面的练习中我们会很容易地熟悉这个过程,并且我们现在没有必要弄清楚这些操作的意义。我们只是先熟悉一下单片机的开发流程而已。如果这些复杂的步骤让你感到沮丧,那么直接跳转到*1.3.3 编译运行*一节吧。 首先,我们打开*STM32CubeMX*软件,看到如下界面: ![](1/32.png) 我们点击*ACCESS TO MCU SELECTOR*按钮,出现以下界面: ![](1/33.png) 等待进度条走完,这可能需要一段时间,速度主要取决于网络情况,所以这时请**确保你的网络连接畅通**。这里的主要目的是更新你的数据,第一次运行时有必要确保你的数据是较新的,而之后可以考虑选择*Cancel*来取消更新数据。 之后会出现如下界面: ![](1/34.png) 我们在左上角的Part Number一栏中输入单片机的型号,假如你购买的是*stm32f103c8t6*那么就输入*stm32f103c8*,假如是*stm32f103c6t6*则输入*stm32f103c6*。如果你购买了ch32或者gd32之类的单片机,考虑将ch32或者gd32改为stm32然后输入型号。 这里我们输入*stm32f103c8*之后,可以看到如下的搜索结果: ![](1/35.png) 在窗口右下部的*MCUs/MPUs List*窗口内选择*STM32F103C8*,然后点击右上角蓝色醒目的*Start Project*按钮,随后出现以下界面: ![](1/36.png) 首先我们打开左侧*Categories*一栏中的*System Core*选项,在下拉菜单中选择*RCC*,然后再将出现的*RCC Mode and Configuration*窗口中的*Mode*窗口里面的*High Speed Clock (HSE)*选为*Crystal/Ceramic Resonator*,如图所示: ![](1/37.png) 接下来是左侧*Categories*一栏中的*System Core*选项中的*SYS*,这里我们把*SYS Mode and Configuration*中的*Debug*改为*Serial Wire*,如下图所示: ![](1/38.png) 然后我们切换到*Clock Configuration*选项卡,出现如下界面: ![](1/39.png) 我们在这里做出几处修改:首先修改左下角梯形框中来源选项为*HSE*,梯形框右侧*\*PLLMul*值改为*x9*。然后页面中部的梯形框来源选为*PLLCLK*,最后右侧*APB1 Prescaler*框选为*/2*。完成设置后如下图所示: ![](1/40.png) 接下来我们再次返回*Pinout & Configuration*选项卡,这次我们在右面黑色的芯片形状上点击*PC13*引脚,在弹出的窗口中选择*GPIO_Output*。如下图所示: ![](1/41.png) 到此,我们就完成了STM32CubeMX上的设置,接下来就可以生成工程了。 我们切换到*Project Manager*选项卡,在*Project Name*一栏中输入工程名称。可以随便选择一个英语的没有空格的名称,但是建议工程名称要有意义,便于管理。这里输入Blink作为名称。然后在*Project Location*一项中选择一个路径作为生成工程到的路径:点击灰色的*Browse*按钮,会弹出以下界面: ![](1/60.png) 在界面上的*Look in*的下拉菜单中可以找到桌面或者其他的位置,选好后点击右下部的*打开*按钮就可以选定路径了。我这里选择了桌面,注意这里每个人的桌面路径是不同的。 ![](1/42.png) 然后我们在下面的*Toolchain/IDE*选项中选择*MDK-ARM*(*MDK-ARM*是*Keil*的一个别称) ![](1/43.png) 最后点击右上部蓝色显眼的*GENERATE CODE*按钮生成代码,等待进度条走完之后,出现以下界面说明生成成功: ![](1/44.png) 我们点击*Open Project*就可以在*Keil*中打开所生成的工程了,之后我们可以关闭*STM32CubeMX*的窗口。 ##### 1.3.2 复制代码 在点击*Open Project*后(或者在之前选择的生成路径下的*MDK-ARM*文件夹中,例如我的是*C:\Users\administor\Desktop\Blink\MDK-ARM*中找到*.uvprojx*结尾的通常有*Keil*图标的文件双击打开,我的是*Blink.uvprojx*)就可以在*Keil*中看到*STM32CubeMX*软件在我们之前的设置中生成的代码,如下图所示: ![](1/45.png) 我们在左侧*Project*栏中的树形结构中找到*Project Blink->Blink->Application/User/Core->main.c*,然后双击打开他,如下图所示:如果你的右侧窗口颜色与我的不同,那是正常情况,因为我对窗口的颜色做了特定的设置,如果你有自己喜欢的配色也可以自己进行设定。 ![](1/46.png) 我们在右侧的编辑窗口中找到下面这一段代码: ```c int main(void) { /* USER CODE BEGIN 1 */ /* USER CODE END 1 */ /* MCU Configuration--------------------------------------------------------*/ /* Reset of all peripherals, Initializes the Flash interface and the Systick. */ HAL_Init(); /* USER CODE BEGIN Init */ /* USER CODE END Init */ /* Configure the system clock */ SystemClock_Config(); /* USER CODE BEGIN SysInit */ /* USER CODE END SysInit */ /* Initialize all configured peripherals */ MX_GPIO_Init(); /* USER CODE BEGIN 2 */ /* USER CODE END 2 */ /* Infinite loop */ /* USER CODE BEGIN WHILE */ while (1) { /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ } /* USER CODE END 3 */ } ``` 如下图所示,我们关注的内容是`while (1)`之后的`{`后面的内容,在我的这个文件中是94行: ![](1/47.png) 然后我们把这一段代码做一些修改,像这样: ```c while (1) { HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET); HAL_Delay(1000); HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET); HAL_Delay(1000); /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ } ``` 在编辑器中显示为: ![](1/48.png) **请注意**:代码的位置不能弄错,否则会导致运行状态不正常。 然后我们使用快捷键``(表示按下CTRL键的同时按一下S键)来保存修改,发现窗口上面*main.c\**变为*main.c*,说明保存成功。这样我们就完成了代码的编写。 ##### 1.3.3 编译运行 在编译运行之前,我们首先要连接电脑、*st-link*和单片机。 首先我们要了解关于我们使用的SW接口的一些基本知识:SW接口是用于下载和调试单片机程序的一种接口,它由两根数据线(SWDIO,SWCLK)构成,此外还通常有两根电源线(VCC,GND)。在连接SW接口时要保证这四根线的连接是正确的,方式是*st-link*的VCC线连接单片机板卡的VCC线,*st-link*的GND线连接单片机板卡的GND线,其他两根数据线也要类似的名称相配对。**注意千万不要接反VCC与GND,否则可能导致单片机被烧毁**。 这里面的线有时会有别称: | 线 | 别称 | | ----- | ---------- | | VCC | 3.3,3V3等 | | GND | G,VSS等 | | SWDIO | SWO,DIO等 | | SWCLK | SWK,CLK等 | 假如出现了你没见过的数据线名称,例如SWD之类,那么可以考虑尝试随便接线然后看看能不能正确连接(后面会有不正确连接的示例),或者找名字相近的进行猜测。但是电源线不要乱接,可能导致几十元的板子瞬间损坏。 如果你使用小的只有SW接口的*st-link*,那么壳上通常印有接线图,如果没有的话可以找卖家索要。如果是有大的jtag接口的*st-link*,找到正确的接线位置可能会有些麻烦,而且通常没有接线图。这里附上在网上找到的一张图: ![](1/54.gif) 相信你对照实际的jtag接口很容易能够意识到在*SWCLK/TCLK 9*和*RTCK 11*之间那个小小的黑色块的意义,没错这是用来区分方向的。在连接时要注意:**这里面的SWO不是我们需要的,我们需要的是SWDIO**,然后GND可以随便选一个连接,没有必要全部连接上。 有一个小技巧可以减少你因为电源连错而导致板子烧毁的方法,那就是把*st-link*接上电脑,先接上一根电源线,例如GND,然后一边盯着板子一边插入VCC,假如已经插入VCC之后板子上没有灯亮起,那么多半是你插错了电源,此时用最快的速度拔下VCC吧。因为绝大多数的板子都会设计一个电源指示灯,记住:**任何时候如果电源指示灯没有正常亮起,那么赶快断掉电源吧,这往往说明你的电路接反或者短路了**。 ![](1/65.jpg) 接好了*st-link*与单片机之后,我们把*st-link*通过USB接口与电脑连接。如果单片机上的电源指示灯没有亮起,赶快拔掉电源然后重新检查你的连接。 之后我们打开这个*Keil*工程(如果你没有在*Keil*中打开这个工程的话,参考*1.3.2 复制代码*一节开头部分打开就可以了)。然后我们找到*Options for Target*按钮,很多时候我们形象的称为“魔法棒”按钮,这个按钮非常常用。他在如下图所示的这个位置: ![](1/49.png) 点击*Options for Target*按钮之后,会弹出如下窗口: ![](1/50.png) 这个窗口有很多重要的设定,在高级部分我们会常常与他打交道。目前我们主要关心的是*Debug*选项卡,如下图所示: ![](1/51.png) 在这里我们关心右上部的*Use:*选项,如果你使用的是*st-link*,那么默认的*ST-Link Debugger*就是正确的,如果你使用的是其他的仿真器,那么就切换为相应的名称。然后我们点击*Settings*按钮可以看到下面这种窗口: ![](1/52.png) 假如你见到的窗口类似上图这种:*Debug Adapter*下面的*Unit*一栏为空,那么说明你的电脑没能正确的连接到*st-link*。请检查以下内容: 1. *st-link*是否已经插在电脑的USB口上 2. *st-link*的驱动是否正确安装(参见FAQ) 3. 你的USB线缆是否支持数据传输,可以找一个其他的USB设备用这个线缆连接进行测试或者使用另一根USB线缆。 4. 假如以上方式都不产生效果,纳闷咨询其他人吧。 ![](1/53.png) 假如你见到的窗口类似上图这种:*Debug Adapter*下面的*Unit*一栏有内容,那么说明你的电脑没正确的连接到*st-link*。但是右侧*SW Device*下面显示为*No target connected*,这说明你的*st-link*没有正确连接到单片机。请检查以下内容: 1. sw接口的线序是否正常 2. 单片机是否正常上电(有些开发板设计为接好仿真器之后还需要另外插入一根电源线) 3. 单片机是否损坏(使用万用电表判断板子的VCC与GND是否短接,可以找电子方向的同学帮忙) 3. 如果是第一次能够连接上,第二次不能:那么有可能是前面的*SYS Mode and Configuration*中的*Debug*改为*Serial Wire*这一步骤出现问题,解决方法参考FAQ。 4. 如果以上方式都不产生效果,那么有可能是单片机sw口被关闭或者其他情况,可以到其他人处寻求帮助。 最终,正确的连接状态应该类似下面这种: ![](1/55.png) 如果你看到*Unit*和*SW Device*下面显示正常,那么我们就可以进入下一个环节了。还是这个界面,我们打开*Flash Download*选项卡,勾选*Reset and Run*选项,如下图所示: ![](1/56.png) 如果上图中的Programing Algorithm一栏中没有128K或者64K的选项,请检查之前安装的.pack文件状态,如果没有安装或者安装了错误的版本,则重新安装后,回到这一栏目,点击Add,添加128K或者64K选项。 然后按下面的*确定*按钮,在魔术棒窗口点击*OK*保存设置就大功告成了。 之后我们按``键编译代码,这个步骤的作用是将我们编写的代码翻译为单片机可以理解的格式。编译开始之后屏幕下方的*Build Output*窗口就会开始显示内容。如果没有反应的话,可能是你的``按钮被设定为只有当``键按下时才生效,你可以尝试按下``键的同时再按``键或者通过点击编译按钮来进行编译,编译按钮在窗口左侧,如图所示: ![](1/57.png) 如果编译之后*Build Output*窗口中没有出现*0 Error(s)*,而是出现了非0个*Error*(错误),那么说明你的代码有问题,着重检查是否缺少分号`;`或者逗号和分号有没有写成中文的(中英文的逗号分号有所不同)。下面是一个典型的示范: ![](1/58.png) 99行旁边有一个红色的×,*Build Output*窗口显示有两个*Error*。这里最后一个分号被我替换为了中文的分号。 假如你的编译过程也没有问题,那么我们就可以开始下载程序到单片机了,我们使用``键或者按*Download*按钮来实现这个过程。 ![](1/59.png) 等待屏幕左下的进度条走完,你应该可以注意到你的Blue Pill上有一盏小灯开始闪烁了。如果报错的话,请返回检查你的魔术棒设置或者咨询其他人。如果没报错但是灯没有闪,那么尝试按一下Blue Pill上的*RESET*按钮,按过之后如果开始闪烁,那么请检查魔术棒设置中*Reset and Run*一项是否勾选。 如果烧写时弹出窗口“ST-LINK Firmware Upgrade”,说明购买的ST-LINK固件版本较低。解决办法为: 我们点击Yes之后,会弹出以下窗口: ![](1/72.png) 我们依次点击*Device Connect*和*Yes >>>>*之后就可以完成固件升级。 不过更多的情况是: ![](1/73.png) 这时我们需要按照要求:点击*确定*,然后拔下STLINK再插上以完成restart(重启)操作,然后继续依次点击*Device Connect*和*Yes >>>>*之后就可以完成固件升级。 ![](1/74.png) 如果已经是最新版本,升级会失败,此时不用理会,关闭窗口后继续烧写程序即可。 ##### 1.3.4 做一些小实验 现在我们可以注意Blue Pill板上的灯,思考一下,灯旁边的丝印(白色的小字)内容通常为*PC13*,这个内容我们在之前的过程中是否遇见过?灯的闪烁频率大约是0.5Hz(或者说亮一秒钟然后灭一秒钟),我们是如何控制灯的亮灭的呢? 这里我们对4行代码做一下简单的解释: ```c HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET); HAL_Delay(1000); HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET); HAL_Delay(1000); ``` 首先`HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET);`的意思是令PC13引脚通电,而同样的道理,`HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET);`的意思则是令PC13引脚断电。`HAL_Delay(1000);`的意思是等待1000毫秒也就是1秒钟。 请思考并尝试: 1. 如何令灯闪烁的快一些呢? 2. 令PC13引脚通电之后,灯是亮还是灭,为什么会这样呢? 3. 如果使用`HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);`来代替`HAL_GPIO_WritePin`行可以吗?查询这些单词的含义,推测这些代码的含义和用法。 如果你找不到问题的答案,可以考虑与其他人一起探讨交流。 #### 1.4 FAQ ##### 1.4.1 我没有电脑怎么办? 假如因为一些不可抗力因素导致无法使用电脑的话,那么可以考虑借用别人的电脑。购买例如树莓派4b之类高性能的linux开发板加上一块屏幕和鼠标键盘的话也可以进行micropython单片机编程。用这类高性能开发板进行c语言单片机编程是可行的,但遗憾的是我们目前没有找到一种能让初学者容易学会的方案。所以,学习单片机的话尽可能地找到一个可用的电脑吧。 ##### 1.4.2 我的电脑是Mac或者Linux系统怎么办? 在Mac或者Linux上不可以使用Keil进行编程,但是我们可以使用STM32CubeIde之类的ide,详情可以百度搜索用法或者咨询同学或学长学姐。 ##### 1.4.3 我没有可用的工具和材料怎么办? E唯协会可以提供面向会员的实验器材,如果很难获取到工具或材料可以借用协会的公共实验器材。 ##### 1.4.4 安装软件出错怎么办? 请仔细检查安装过程,注意以下几点: 1. 最一开始是否允许对设备的更改,win7系统通常需要右键点击以管理员身份运行 2. 安装路径是否存在中文符号或者空格例如“E:\单片机软件”或者“C:\Program Files\Keil”都是容易出现问题的安装路径。 3. 仔细检查过程与教程过程是否一致 4. 如果以上都没有问题,考虑重启电脑之后再试一次 5. 如果重启也无法解决问题,考虑咨询其他人吧 ##### 1.4.5 忘记开启Serial Wire(SW)接口导致stlink无法连接单片机怎么办? 首先,我们要了解这一问题出现的原因:我们连接stlink与单片机的四根线构成一种SW接口,类似于我们可以用四根线来构成一个USB接口一样。但是这种接口不仅仅需要导线的物理连接才能工作,还需要单片机内部的收发器支持。默认情况下,新出厂的单片机是开启SW接口的,但是在STM32CubeMX软件中,默认选项会关闭SW接口,如果我们没有修改这一选项直接用默认选项生成程序烧录进单片机的话,这会导致单片机的SW接口无法用于连接stlink烧录程序。 解决的办法就是不让单片机运行之前烧录的错误的程序:我们知道电脑开机的时候往往按住键盘某个按键可以进入BIOS,或者说电脑开机的时候可以自行选择执行操作系统的启动程序还是BIOS的界面程序。单片机也是相同的,STM32单片机在上电时会检测两个特殊引脚(BOOT0和BOOT1)上的电压来决定运行哪一段程序。因此我们只要改变这两个引脚上电时的电压就可以让单片机不再执行关闭SW接口的程序,从而烧录正确的程序进去。 具体步骤为: 1. 将BOOT0一侧的跳线帽拔下,从原来的靠近“0”一侧插到靠近“1”一侧,然后保证连接stlink和电脑正常,按下板上的RESET(重启)键。有些单片机开发板上使用BOOT按键,对于此类板需要按下BOOT0对应的按键同时进行重启,重启结束后才能松开按键,例如树莓派PICO。 2. 烧录正确的程序进去。 3. 将跳线帽还原。 ![](1/71.png) ### 2. 我们的武器 - C语言 > 参考课程:《计算思维(二)》 在上一节的blink中,我们看到很多让人眼花缭乱的代码,甚至,我们还尝试自己添加了4行进去,让小灯按照这4行代码开始闪烁。其实这些代码就是使用**C语言**编写的,我们总是把自己的想法变成C语言写进电脑,然后电脑将C语言转换为单片机能够理解的语言然后存进单片机里面。因此在进行下面的内容之前,我们还需要学习一些关于C语言的基础知识。 #### 2.1 如何学习C语言 对于硬件工程师来说,C语言则是最常用的语言,几乎一切与硬件相关的编程都有C语言的身影。生活中常见的物联网设备,计算机的操作系统,这些往往都是使用C语言进行编程的。对于我们来说,在对机器人底层部分进行编程的时候,往往都使用C语言,可以说C语言是电控技术的基础。 我们有一个不幸的消息是:C语言是一门完备的高级语言,我们没有办法在本篇简短的教程里面说明清楚哪怕仅仅是最基础的C语言语法。不过另一个好消息是:由于C语言使用的如此的广泛而受欢迎,所以关于C语言的教程很容易获得到。有以下几个途径: 1. 图书馆:可以考虑借阅《C和指针》《C Primer Plus》等经典C语言入门读物来进行学习。 2. B站:例如[小甲鱼的C语言教程](https://www.bilibili.com/video/BV17s411N78s)等 3. MOOC网站:例如[隔壁工大的C语言教程](https://www.icourse163.org/course/HIT-69005)等 4. 电子书籍:可以从同学或者学长学姐处获取学习C语言的电子书籍 这些来源的C语言教程内容十分丰富,因此**C语言的教学具体内容不在本教程内,需要大家找合适的教程学习。**本主要讲搭建C语言学习环境与学习的注意事项。 在学习C语言的时候,我们要注意一些问题: 1. 尽量跟随教程示例自己写代码,编程语言的学习非常注重实践。只看书而不去动手实践是很难学成的。 2. 在初期有很多不易理解的地方,例如为什么需要main函数,#include有什么用之类。建议先记住用法,使用熟练之后很多问题就会迎刃而解。有很多问题在C语言的深入学习过程中会得到解答,有些则需要更深入的计算机知识。 3. 保证代码的整洁,注意添加注释,变量的命名要有意义,复杂或者团队合作的代码要编写说明文档。如果你想在编程的路上走得更远,请记住**程序写出来是给人看的,附带能在机器上运行**。养成良好的编程习惯吧。 4. 没有必要学习过于复杂的语法条文。很多教程中有包括运算符优先级、变量声明规则之类非常复杂的规则,这些规则并不需要我们刻意去记忆。我们只需要了解概念,剩下的在实际运用中学习吧。 另外一个重要的问题是要学会搜索提问,程序员之间相互提问和搜索引擎是最重要的获取知识的手段之一,但是搜索和提问的手段是需要注意的,十分推荐大家阅读一篇文章[How To Ask Questions The Smart Way](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md),这里有一些提问的基本方法。在qq群里一些基本的提问注意事项是: * 将遇到的问题用简洁明确的语言描述,尽量用术语,不要用自己的语言描述。大多数时候别人是无法理解自创的词汇的。 * 尽量附带明确完整的报错信息,尤其很多新手的错误是把main打成mian,;打成;这样的很难观察发现的错误,这种错误往往只能从报错信息中找到问题所在。 * 展示遇到问题代码的方式,最差的是用手机摇摇晃晃的体积巨大的录像,这样的大文件很少有人喜欢;其次是手机拍摄的光线昏暗充满莫尔条纹的照片;再次是截屏的图片,最好是完整的代码以文本形式发出(代码量不大的情况下),因为这样被提问者可以直接复制你的代码运行,找出错误,而不用再手打一遍。需要图片的时候不要用手机拍摄,使用快捷键Win+Shift+S可以很方便的截取屏幕上的内容到你的剪切板上,然后用Ctrl+V把图片粘贴到聊天窗口或者任何你想要粘贴的位置。 > **计算机学习书单** > > 如果你之前很少使用计算机,或者完全没有编程经验的话,那么你一定经常苦恼一些问题:什么是内存?什么是CPU?什么是计算机网络?这些知识一方面可以在课程《计算思维(一)》中学到,另一方面可以在图书馆借阅相关的书籍:[《计算机科学导论》](https://book.douban.com/subject/26726452/)[《计算机文化》](https://book.douban.com/subject/1880471/)等。我们推荐阅读机械工业出版社出版的*计算机科学丛书*系列黑皮书,大概类似这样: > > ![](2/17.jpg) > > 如果你已经对计算机科学有了一些基础的了解,希望进一步了解的话,可以考虑阅读[《深入理解计算机系统》](https://book.douban.com/subject/26912767/)(CSAPP)[《计算机组成与设计:硬件 / 软件接口》](https://book.douban.com/subject/2110638/)。这两本书上图都有,是计算机科学经典著作。在电控方面我们不需要了解很多复杂的算法知识,但是很多计算机硬件(计组)知识可以在我们在学习嵌入式系统开发时起很大作用。 > > 如果你的C语言语法已经基本学完,那么我建议阅读[《C专家编程》](https://book.douban.com/subject/2377310/),这本书讲述的一些在C语言实际应用场景中的知识会让你对C语言的理解提高到更高的一个层次。也可以尝试[《现代操作系统》](https://book.douban.com/subject/3852290/)等结合[《UNIX环境高级编程》](https://book.douban.com/subject/25900403/)之类的书籍学习如何应用C语言在操作系统、驱动程序、计算机网络方面,以及一些常用C库函数例如setjmp等的使用。 > > 除了这些关系具体技术的书籍以外,建议大家了解一些开发软件时所需要的技巧知识,例如[《代码大全》](https://book.douban.com/subject/1477390/)、[《重构》](https://book.douban.com/subject/4262627/)和[《设计模式》](https://book.douban.com/subject/1052241/)等。这些书可以告诉我们如何写出更有质量的代码。 > > 如果你感兴趣的话,这里还推荐阅读[《人月神话》](https://book.douban.com/subject/26358448/)。这本书会让你了解复杂的软件工程管理问题,也许会对参加一些大型比赛时不了解如何组织安排的同学提供一些帮助。 #### 2.2 开始C语言 - Hello World 在开始学习C语言之前,需要安装一个C语言的ide软件作为学习使用。这里是以[小熊猫Dev-c++](https://royqh.net/devcpp/)为例,假如你有熟悉使用的环境例如*Visual Studio*或者*Code::Blocks*之类的,那么还是使用你熟悉的环境吧。我们使用*Dev-c++*唯一的理由是他的使用比较简单(本校的C语言教学环境通常就是*Dev-c++*),不必让我们耗费过多的精力在学习环境的使用上。如果你已经学会了C语言基本语法的话,请注意*Dev-c++*并不是一个很好的继续深入学习C语言的环境,例如我本人通常会使用*Vim*与*gcc*来开发C语言。 首先我们运行*Dev-c++*的安装程序,此处为*Dev-Cpp-5.15c.exe*。允许对设备的更改,然后出现以下界面: ![](2/1.png) 点击*OK*,然后出现以下界面: ![](2/2.png) 选择*我接受(I)*,出现以下界面: ![](2/3.png) 点击*下一步(N)*,出现以下界面: ![](2/4.png) 可以选择一个合适的安装位置,但是建议不要让路径中出现中文字符。这里使用提供的默认路径,点击*安装(I)*,出现以下界面: ![](2/6.png) 等待进度条完成后,出现以下界面: ![](2/7.png) 点击*完成(F)*后,就会完成安装并且自动启动*Dev-c++*,出现以下界面: ![](2/5.png) 点击*Next*,出现以下界面: ![](2/12.png) 可以选择自己喜欢的配置,然后点击*Next*,出现以下界面: ![](2/13.png) 点击*OK*,然后我们看到如下界面,点击创建一个新文件: ![](2/10.png) 在*未命名1.cpp*窗口中输入代码: ```c #include int main() { printf("hello world\n"); return 0; } ``` 像这样: ![](2/9.png) 之后点击保存键选择一个位置保存。保存键是新建文件键的右侧相隔一个键的软盘图标。然后我们按``键或者点击界面上部的蓝色三角形按钮运行程序,弹出以下窗口: ![](2/11.png) 点击*Yes*,如果代码正确的话会弹出一个黑色的窗口,类似这种:(我的cmd窗口颜色与默认设置不同) ![](2/14.png) 如果你可以看到窗口中成功显示了*hello world*字样,那么恭喜你完成了一个hello world程序!但是如果没有出现黑色窗口而是像这样: ![](2/15.png) 这说明你的代码出现了问题。 对于C语言初学者而言,以下几点需要特别注意: * main有没有被写成mian,与之类似的有没有其他单词被拼写错误。 * 大括号、双引号和分号等符号必须是英文的(在电脑上通常中文符号与英文符号不同,不能通用),请仔细观察这两者之间的区别。 * 忘加分号或多加分号,在C语言中有些行的末尾需要加分号,而有些不需要,这都会导致问题。 诊断问题最简单的方式就是查看报错信息,这里列出几项常见的报错: 1. *undefined reference to `WinMain'*是说找不到*main*,请检查你的*main*是否拼写正确。 2. *[Error] 'print' was not declared in this scope; did you mean 'printf'?*,这是将*printf*拼写为*print*导致的,类似,如果你看到*'xxx' was not declared in this scope*字样,那最有可能是*'xxx'*拼写错误。 3. 类似*[Error] extended character 鈥?is not valid in an identifier*或*error: stray '\261' in program*这种带有乱码的或者一串奇怪数字序列(还有*\U3000*,*\U00A0*这种)的报错通常由中文符号或者直接从网上复制代码引起。检查你的符号,或者删除空白部分再试。 > 流传广泛的一张常见中文编码错误表 > > ![](2/16.jpg) > > 例如“鈥”就是一种古文码,由编译器程序输出的UTF-8编码被*Dev-c++*以GBK方式读取导致。我们可以运行python代码复现这个错误: > > `print(r'printf(”hello world\n“);'.encode('UTF-8').decode('GBK',errors='ignore'))` > > 输出结果为:printf(鈥漢ello world\n鈥); > > 我们可以看到中文的双引号被错误的翻译为“鈥”,实际上原本编译输出为*[Error] extended character ” is not valid in an identifier*,意为:[错误]扩展的字符“在一个标识符中是无效的。 #### 2.3 FAQ ##### 2.3.1 我可以使用手机或者平板电脑学习C语言吗? 可以,目前有很多手机软件支持编译运行C语言程序,这些软件足够初学者使用了。但是这些软件使用简单的往往容易出问题,不出问题的使用起来对新手不太友好,所以尽量在电脑上学习C语言吧。 ##### 2.3.2 学习C语言需要学好英语吗? 通常来讲,不需要。编程语言长得像英语而不是英语。假如只是学习基础知识的话不需要学习英语,但是如果深入学习的话,我们无法避免的要学会阅读英文资料,所以英语技能还是很重要的。 ##### 2.3.3 C++与C有什么关系? C++是一个更好的C,他在C的基础上扩充了更多的功能。在C++之父的著作《C++程序设计语言》中有一些对于这个问题的精彩论述,总结来说,绝大多数C程序都是C++程序(这也是为什么我们可以使用Dev-C++这个C++的ide来编写C程序的原因),因为C++兼容绝大多数的C语言语法。了解C并不是学习C++的先决条件,但是对于编程初学者而言这两者的区别不大。 ##### 2.3.4 为什么要尽量避免unsigned类型的使用? 有很多时候我们需要表示一些始终大于0的量。对于这些量,我们使用无符号类型就可以在相同的内存占用条件下,使这个变量的表示范围扩充一倍。但是这并不是一个很好的习惯,因为有符号类型在向无符号类型转换时,负数将会转为一个相当大的值。考虑下面的代码: ```c #include int main() { printf("%d\n", -1 < sizeof(main)); return 0; } ``` 这段代码输出了什么?为什么? ##### 2.3.5 数组和指针有什么区别? 数组在整个程序运行期间的值是不变的,编译器在处理数组时总是把数组名替换为一个常数,加上偏移量之后取得这个地址的值。而指针本身是一个变量,编译器在处理指针时首先要取得这个变量存储的值,然后把这个值作为地址再一次取得这个地址的值。考虑下面的代码: 文件1: ```c // file a.c char hello[] = "hello world\n"; ``` 文件2: ```c // file b.c #include extern char *hello; int main(void) { printf("%s", hello); return 0; } ``` 这段代码输出了什么?为什么? ### 3. 最基础的外设 - GPIO 现在我们已经有了一些C语言的基础知识,当然如果你的C语言学的不太好也没关系,在后面的实践过程中我们不会有很复杂的C语言内容。本节将会讲述GPIO外设的使用方法,掌握GPIO之后我们就可以进行一些基础的电子制作了。 GPIO全称为“General-purpose input/output”(通用目的输入输出)。是所有单片机中最基础的外设,几乎任何我们使用单片机的时候我们都要和GPIO打交道。在理解什么是GPIO之前,我们先来了解一下什么是IO(输入和输出)。 我们期待单片机做的事情很简单:根据不同的输入产生不同的输出。例如我们考虑制作一个智能台灯,可以根据环境亮度自动调节本身的发光强度。这时台灯就可以使用一个单片机作为控制器,亮度传感器的参数输入单片机,然后单片机进行运算,再输出特定信号到灯管。如果你已经学过了《计算思维(一)》,你会了解到计算机是由cpu,存储器和io设备组成的。没有io设备的计算机——例如单独的一个机箱,没有显示器,键盘,鼠标之类的输入输出设备,那么他唯一的作用就是作为电暖器。 IO就是单片机与外界进行信息交互的接口,体现在物理层面上就是单片机的引脚,或者是Blue Pill上的排针。单片机的IO有很多种,如本节的GPIO,下一节的UART等都属于是IO。而本节的GPIO其功能只有两个:令引脚通电或断电、以及获取当前引脚是通电还是断电。 #### 3.1 了解和复习一些基础知识 在之前,我们一直使用的是一个比较模糊的概念“通电”或者“断电”。但是这个概念过于模糊,什么是“通电”,什么是“断电呢”?如果你学习过《电子技术》或者《数字电子技术》的话,你会很容易理解这些。如果没学过也没有关系,其实我们可以用高中的物理知识很容易地理解这一点:只有形成闭合回路才有电流,只有两点才能比较电压。因此对于一根线头或者是引脚来说,是不存在“通电”与“断电”的概念的,必须要有两个点或者说两个线才能够说他是“通电”或“断电”。而我们通常说的“通电”是指这个点与GND之间存在一定的电压,“断电”则是这个点与GND之间没有电压或者电压很小。 如果你有一个万用电表或者其他任何的电压表,当你把表接在之前blink中的引脚PC13与GND之间时,你就可以看到PC13与GND之间的电压高低之间反复变化。当PC13脚与GND之间没有电压或电压很小的时候,灯点亮,而当PC13脚与GND之间电压为3.3伏特左右时灯会熄灭。这其实是因为灯的两端一端接在3.3伏特(相对于GND)电压的电源上,另一端接在PC13上。当PC13与GND之间电压为3.3伏特时,灯的两端电压为0,所以灯不亮。反之则灯的两端电压为3.3伏特(实际上是2伏特左右,因为串联了电阻),灯就会亮起。 你应该已经注意到这里我们经常会提到某个脚与GND之间的电压是3.3伏特还是0伏特,为了简化表述,一般称与GND之间电压比较高的状态为**高电平**,而与GND之间电压为0或很低的状态为**低电平**。在这里我们说:当PC13为高电平时灯灭,而PC13为电平的时候灯亮。因此我们可以通过控制PC13的电平来控制灯的亮灭。 #### 3.2 在STM32CubeMX中配置GPIO口 我们首先打开*STM32CubeMX*软件,选择你的单片机型号。通常是*stm32f103c8t6*或者*stm32f103c6t6*,取决于你买的blue pill板所搭载的型号,可以对着光仔细观察单片机表面的文字,例如: ![](3/1.jpg) 这是一颗*stm32f103c8t6*单片机,在搜索框内输入*stm32f103c8*,不要输入*t6*,然后在旁边的框内选择具体型号即可。可以参考*1.1 创建工程*中的内容。 我们在选择好单片机之后,首先要做两件事情,第一个是修改时钟树(修改*RCC*和*Clock Configuration*),另一个是打开调试接口(修改*SYS*)。 ![](1/37.png) ![](1/40.png) ![](1/38.png) 修改时钟树的目的有两个,一个是切换时钟源从内部RC电路到外部晶振,这一操作使得单片机的时钟变得更快更稳定。另一个是提高单片机的主频。单片机与电脑一样,都需要一个主频来驱动他的工作,主频越高他的工作速度就越快。我们通常将单片机的主频提升至他的上限(stm32f103c8和c6的上限都是72MHz)。 > **晶振** > > 晶振其实是*晶体振荡器*的简称,我们开始时通常设定*High Speed Clock (HSE)*选为*Crystal/Ceramic Resonator*,*Crystal/Ceramic Resonator*就是*晶体/陶瓷振荡器*的意思,在实际电路中,晶振常常长得像这个样子: > > ![](3/3.png) > > 图为香橙派开发板上的晶振和在面包板上实现的一个51最小系统的晶振 > > 晶振通常使用石英晶体制成,我们在生活中的常见的石英钟也是基于同样的原理制成的。使用晶振可以让单片机的时钟像石英钟一样稳定可靠。 > > ![](3/15.png) > > 如图所示,在25摄氏度,3.3伏供电时stm32f103c8t6单片机在相同频率下使用内部时钟时电流往往小于使用外部时钟时的电流,在低频时这一现象更加明显。因此在一些低频率,对功耗要求很高的场合常常不使用外部晶振。通常来讲,使用晶振会比使用内部RC电路时钟更加稳定。为了验证这一说法,我们使用了一个采样频率为100M的逻辑分析仪来对同样使用48M主频,但是使用不同的时钟源的两种情况下使用热风枪100摄氏度加热时令单片机输出12MBit/s的SPI信号,但是并不能观测到两者之间的差别,因此猜想在低频的情况下RC振荡器的精度与晶振的精度相差不大。 > > 我们还可以看出在不同工作频率下,单片机的功耗可以相差几十倍。所以如果在意电能损耗的话可以尝试降低工作频率。 打开调试接口的作用很简单,方便我们调试与下载程序。如果我们把一个没有开启调试接口的工程烧录到单片机上的话,可能会导致单片机的SW接口无法连接,就无法直接使用stlink下载程序了。 接下来我们打开两个GPIO口,一个是能够控制灯亮灭的PC13,设置为GPIO_Output,表示我们想要控制这个口的电平高低。然后我选择了PA1口,设置为GPIO_Input,表示我们想要知道这个口连接到的电平高低。如图所示: ![](3/5.png) 然后我们为这两个口添加标签,操作为右键点击对应的口,会弹出如下菜单: ![](3/6.png) 点击*Enter User Label*,然后输入一个英文的,用来帮助我们记忆这个口作用的名称,例如我这里设定为"*led*"(发光二极管,这种灯的学名)和"*input*"(输入)如图所示: ![](3/7.png) 接下来我们就可以生成工程,然后编写代码了。 #### 3.3 使用一个GPIO口控制灯 我们还是打开生成的工程,然后找到*main.c*文件。方法与*1.3.2 复制代码*一节相同。如图所示: ![](3/8.png) 然后我们同样在main函数中的`while (1) {`之后添加两行代码: ```c /* Infinite loop */ /* USER CODE BEGIN WHILE */ while (1) { GPIO_PinState state = HAL_GPIO_ReadPin(input_GPIO_Port, input_Pin); HAL_GPIO_WritePin(led_GPIO_Port, led_Pin, state); /* USER CODE END WHILE */ ``` 如图所示: ![](3/9.png) 第一行`GPIO_PinState state = HAL_GPIO_ReadPin(input_GPIO_Port, input_Pin);`的意思是读取input对应引脚的电平存储在state变量中。注意,如果你的命名不是input的话,需要改成别的Port和Pin。 > 这里的变量类型`GPIO_PinState`实际上是使用typedef定义得到的。查看原定义可以在`GPIO_PinState`上面右键点击,出现以下菜单: > > ![](3/11.png) > > 选择*Go To Definition Of `GPIO_PinState`*,Keil就会自动跳转到`GPIO_PinState`的定义处,如图: > > ![](3/12.png) > > 这是一个枚举类型,定义低电平(GPIO_PIN_RESET)为0,高电平(GPIO_PIN_SET)为1。事实上我们可以直接使用整数类型来代替`GPIO_PinState`,用1来代替GPIO_PIN_SET,用0代替GPIO_PIN_RESET等等。这不会导致编译错误,但是通常会产生警告。 > > 注: > > 我们不建议使用1和0来表示电平,因为1和0还可能表达其他的很多意义,使用GPIO_PIN_SET和GPIO_PIN_RESET可以让我们的代码更清晰易读。使用枚举可以带来很多的好处,可以参考《代码大全》(第二版 303页枚举类型)。 第二行`HAL_GPIO_WritePin(led_GPIO_Port, led_Pin, state);`的意思是让led的电平变为刚才读取到的`state`,即让PC13的电平与PA1的电平相等。 然后我们烧写程序,通常情况下烧写完成之后,灯会亮起。现在我们用一根杜邦线连接3.3引脚和PA1,出现了什么现象?换成连接G和PA1之后,又有什么现象? 或许你无意中触碰PA1引脚之后会发现,灯会随着你手指的触摸而发生闪烁。但是请尽量减少这样的操作,因为人手指上带有的静电有很小的几率导致单片机损坏! > 如果你尝试过用一根杜邦线连接3.3和PA1,你会发现此时灯会熄灭,这是因为单片机会不断地修改PC13的输出电平使其与PA1一致。强制短接3.3和PA1会导致PA1变为高电平,这样PC13也会变为高电平,因为灯的两端(3.3和PC13)都是高电平,所以灯会熄灭。 > > 当我们断开3.3与PA1之间的连接之后,我们会发现灯保持熄灭一段时间之后又会点亮。这是因为STM32CubeMX软件默认情况下会将输入的口模式设定为*浮空输入*模式,在这个模式下GPIO口的电平很容易受外界干扰而改变,即使是手指产生的微弱经典也可以改变他的电平状态。连接3.3与PA1之后,PA1口会被3.3引脚的高电压充电到3.3伏特,如果这个电压不被继续维持的话,他会慢慢的放电到一个更低的电压值。一旦这个电压值达到会被判定为是低电平的时候,PC13也会跟着PA1一起变为低电平,此时灯的两端电平不一致产生电压导致灯被点亮。 > > ![](3/13.png) > > 可以通过上图方式修改GPIO口的输入模式,三种可选的模式分别为*No pill-up and no pull-down*(浮空模式),*Pull-up*(上拉模式),*Pull-down*(下拉模式)。调节不同的模式,尝试理解不同的输入模式有什么区别?可以填写表格来进行研究: > > | 模式 | PA1接3.3时灯的状态 | PA1接G时灯的状态 | PA1什么也不接时灯的状态 | > | ---- | ------------------ | ---------------- | ----------------------- | > | 浮空 | | | 不一定 | > | 上拉 | | | | > | 下拉 | | | | > > *其中浮空模式下,PA1的值不一定,主要取决于什么也不接之前PA1接了什么以及经过了多长时间。 > > GPIO的输出模式也不止1种,默认为推挽输出(Output Push Pull),和另一种需要手动选择的开漏输出(Output Open Drain)。要理解他们的区别可以查阅资料或者询问同学或者老师,理解这些内容可能需要一些《电子技术》课程中的一些知识。 #### 3.4 我们还可以做什么 现在我们可以通过连接PA1到不同的引脚来控制灯的亮灭了,但是这可能并没有什么实用性(也许可以用来制作一个线路通断检测器)。如果我们能够使用PA1连接到能够自动根据环境情况产生高低电平的设备就更有趣了。而事实上,这类设备有很多,而且价格并不会很昂贵,甚至你可以请认识的电子方向的同学来帮你制作一个,这些小小的模块电路十分简单,但是可以让我们的生活变得十分有趣。 这里列举一些常见的简单的使用GPIO就能工作的模块: ![](3/14.png) 倾倒传感器能将是否倾倒这个信息转换为高低电平输出,光照传感器可以判断光照是否超过某个阈值,磁场传感器可以检测磁场强度是否超过某一阈值(感应磁铁用的),人体检测传感器可以检测是否有人通过,声音传感器可以判断音量是否超过某一个值,循线器可以判断一个物体与他距离是否超过某一个阈值或者检测对面的物品是黑色还是白色,测速传感器则可以检测上面的凹槽内是否有物体,蜂鸣器在通电之后会发出响声。 这些模块通常由三或四个引脚,其中两个引脚是电源的正极和负极,通常会被接在单片机的3.3和G上。标有VCC或者+的引脚通常使正极,需要接入3.3。标有GND、G或者-的通常是负极,需要接入G。如果是三脚的模块,剩下的一个脚通常标记为in,out,i/o, DO等等。这个脚直接连接到我们的GPIO口就可以使用了。如果是四个脚的通常多一个AO,这个引脚是模拟输出脚,需要连接ADC才能使用,我们现在不会用到。 例如蜂鸣器模块的I/O引脚接入GPIO引脚之后,我们可以配置GPIO引脚为输出模式,通过让GPIO引脚输出高电平或低电平来控制蜂鸣器是否发出声音。而声音传感器可以将他的OUT引脚连接到配置为输入模式的GPIO引脚,如果声音过大,他的OUT引脚就会产生高电平而声音没有很高的话他的OUT引脚就会是低电平。 我们可以通过将这些模块组合在一起,来制作很多有趣的小制作。例如连接光照传感器和大功率的灯,我们可以制作环境灯光灭掉之后自动亮起的应急灯。将人体检测与一个灯一起接在单片机上,可以作检测是否有人经过的报警器。组合磁场传感器和蜂鸣器,可以做成磁铁检测器等等。 例如:声音传感器模块会在声音较小的时候输出高电平,而一旦声音超过某个预定值他就会输出低电平,预定值可以通过传感器模块上面的电位器来设定。我们使用一个GPIO接口收集声音传感器的输出值,然后根据输出值决定灯的亮灭,这样就做成了一个声控灯。如果我们连接光照传感器和人体感应传感器,还可以制作一个“智能”灯:当夜晚并且有人经过的时候,灯会自动亮起。这样简单的功能完全可以使用我们学习的简单GPIO知识完成。 #### 3.5 FAQ ##### 3.5.1 GPIO口可以接多大电压? 正常情况下,我们要保证单片机连接的外部电压在VSS和VDD之间,即0~3.3v。很多stm32引脚是可以接5v电压的(对于F4系列,几乎所有引脚都是可以接5v,而F1系列中有大部分是可以接5v的),这些引脚在手册中会标注为FT。而一些没有标注为FT的引脚连接5v电压就很危险了,可以尝试使用万用电表的二极管档测量GPIO口到3v3的值,对于FT引脚,显示屏会显示1,表示两个引脚之间没有连接,而对于非FT引脚,电表会显示一个小于1的数值,这表示在表笔之间连接有一个二极管。如果高于4v的电压接入这种引脚,这个二极管就会导通,将一个很大的电流送入单片机内部导致单片机烧毁。判断单片机是否烧毁最常见的办法是用万用表的二极管档测量单片机的3.3和G,如果万用电表蜂鸣器响,则单片机烧毁。 在不同情况下,GPIO口可接受的电压限度是不同的。例如之前我们知道尽量避免用手触摸单片机的引脚,因为人体静电可能会导致单片机损毁,stm32f103c8t6耐受人体静电的能力大约有2000伏。而如果是持续的高于VDD即3.3v的输入则可以很快导致单片机发热烧毁,通常单片机可以容忍一定限度的电压,甚至包括VSS略高于VDD的情况,但最好避免这样的尝试! ##### 3.5.2 多少电压算高电平,多少电压算低电平? 经验来讲,两个阈值分别为1.2和1.8。也就是说0~1.2v通常认为是低电平,而1.8~3.3v认为是高电平,在这之间的电压有可能是低电平也有可能是高电平。产生这种中间值的原因可以参考有关*施密特触发器*的资料。这个值会随着VDD的变化而变化。通常我们的单片机会外接3.3v的线性稳压芯片,从而保持单片机的电压始终小于3.3v左右的一个值,所以单片机输入的高低电平通常不会有很大的波动。 ### 4. 连接不同设备 - UART 利用GPIO,现在我们可以制作一些简单的小制作了。但是我们还可以让事情变得更加有趣! 思考我们在生活中见过的各种各样的电子产品,我们总是希望这些电子产品能够连在一起共同运作。例如我们希望用手机可以控制家里面的空调、电视和灯光,我们希望电脑和手机上的文件可以互相传输协同运作。有了信息的传递,可以让很多事情变得相当的有趣。而且在我们的科创过程中,信息在不同模块之间的传递也是我们在单片机编程时最重要的部分之一。有了这些信息传递的手段,我们才能够将数据从传感器读取到单片机,或者从单片机发送到电脑或者屏幕。在这些信息传递手段中,最简单也最常用的就是UART。 #### 4.1 什么是串口? **UART**是**通用异步收发传输器**的缩写,是一种在不同设备之间传输数据的手段,我们俗称串口。这是因为这种传输数据的方式是最简单的一种串行传输数据的手段,所以通常我们用串口来指代UART。串口用来解决的问题就是:如何将数据从一个设备准确快速的传输到另一个设备上? 我们知道单片机可以控制一个引脚上的电平高低,或者获取到一个引脚上的电平高低。那么如果我们要把数据从一个单片机传输到另一个单片机,一个最简单的办法就是:将要发送数据的单片机的几个引脚配置为输出模式,要接收数据的单片机的几个引脚配置为输入模式,这样只要发送者将要发送的信息编码成高低电平的形式输送到引脚上,另一个单片机就可以通过读取这些引脚上的数据再进行解码就可以得知发送者想要发送的是什么信息了。这里,如果我们用了不止一个引脚来传输信息,这样信息的多个位可以同时并行的传送,这种传输接口就是**并行接口**,而如果只用一个引脚来传输信息,同一时间只能传输一位数据,这样的接口就是**串行接口**。 一个典型的串行接口就是莫斯电码,人们首先将所有的字母编码成由长短脉冲组成的序列,然后把一串想要发送的报文全部按照一定的规律进行编码,通过一根电线传输到很远的地方,在电线的另一端的人们就可以通过监听电线上电流的脉冲再解码就能得知对方传输的报文信息,这也是电报的原理。而串口就是在两个设备之间架起的电报,只要连接上串口线,信息就可以从一个设备传输到另一个设备了。 假如我们配置单片机GPIO口的模式不能改变,那么显然,一根电线只能让一方向另一方传递信息,这种单方向的传输我们称之为**单工通信**。如果我们想要在不改变GPIO口模式的情况向,想要双方能够互相发送信息的话,这样就需要两根线来进行通信,这种两方通过两个传输通道互相传输信息的方式就称之为**全双工通信**。但是实际上,单片机GPIO口的模式是可以被我们在运行的时候改变的,所以我们可以设定一个传输协议,两方可以在传输协议的制约下使用同一根线进行双向传输,同一时间只能有一方向另一方发送,但是发送方和接收方可以互换身份,类似于对讲机组成的网络。这种通信方式我们称之为**半双工通信**。 而我们使用串口是通常都使用全双工串口,这样就需要有两根线用来传输信息,并且还需要一根额外的参考电平线,或者称为地线(不要忘了必须要有两根线才能定义电压和电平)。三根线通常被命名为TX(发送端),RX(接收端)和GND(参考地线)。在通信时,一方的发送端连接另一方的接收端,所以对于串口通信,我们始终是RX接TX,TX接RX。事实上我们甚至可以将一个设备的RX和TX接在一起,这样这个设备发送的任何信息都会被返回给自身,这是一种检查串口设备运行状态的常用方式。 那么是否我们使用串口时就需要直接控制GPIO设备输出高低电平呢?当然不是,实际上串口通常是一个独立于CPU之外的电路,与GPIO类似,都属于外部设备,简称外设。发送时我们只需要将需要发送的数据输送到串口设备,接下来串口设备就可以自动的开始把我们要传输的设备输送到串口线上。接收时串口设备也可以自动的完成接收数据,并将数据存储起来等待我们读取。我们需要做的只是设定串口传输的参数,然后把所有的事情交给串口设备去处理就可以了。 #### \*4.2 串口的原理 对串口或者其他通信方式原理感兴趣的同学可以参考课程《通信原理》的前几章,这些内容不涉及太深奥的数学知识,但是对我们理解串口包括各种通信协议都有很大的帮助。 由于串口设备的存在,我们无需关注串口本身是如何实现信息传输的就可以使用串口来传输数据。但是,显然了解串口的基本工作原理有助于我们编程和制作硬件电路。 我们知道,一个典型的通信系统是由信源、发送器、信道、接收器和信宿构成。我们以电脑发送遥控指令到单片机,令单片机控制遥控小车运动为例讲解。这时一个典型的单工通信系统,信源是电脑,或者说是遥控者,遥控者输入指令到电脑,电脑首先对遥控者发出的指令进行信源编码。例如把“前进”编码为0x01,“后退”编码为0x02等等。然后将编码后的信息通过USB接口传输到USB-TTL模块。USB-TTL模块对传输来的编码进行信道编码和数字调制,也就是将0x01或者0x02这样的数字转换为高低电平组成的序列,然后发送到信道也就是我们的杜邦线上。杜邦线的另一端直接连接在单片机内部的UART外设上,UART外设通过量取杜邦线之间的电压来对信号进行数字解调和信道译码得到0x01、0x02这样的编码。然后通过cpu直接传输、中断或者DMA等方式最终传送到内存中。接下来单片机的cpu就可以读取内存中的数据进行译码得到遥控者发送的“前进”、“后退”等指令,从而做出相应的反应。 ```mermaid graph LR 操作者--发送前进指令-->电脑 电脑--编码为0x01-->USB-TTL USB-TTL--编码调制为一个高低电压的序列-->杜邦线 杜邦线--电压信号-->UART外设 UART外设--总线-->内存 内存--译码为0x01-->CPU CPU-->控制电机使小车前进 ``` 作为一个数字通信系统,我们主要关注他的编码、译码、调制、解调和同步的过程。串口通信的编码译码过程很简单,复杂的部分一般也由CPU处理。调制和解调实际上是利用GPIO进行处理的,0和1分别被GPIO变成低电平和高电平进行发送和接受。串口通信中比较复杂的是通信双方的同步问题。 对于串口的设计者来说,他需要解决的问题就是如何将很长的一串数据表示成一个电平的序列,并且保证接收者能够正确的解读这个序列所表示的信息。请注意,这并不是一件简单的事情:如果我们想要传输一个数字序列0xAA,0x55的话,我们首先要将这一串变成二进制数,便于变成高低电平进行传输。于是我们得到的二进制序列就是1010 1010 0101 0101,我们可以很容易地将这段二级制序列中的1用高电平表示,而0使用低电平表示,但是对于接收方而言,如何理解这样一串数据就成了问题:首先,我们考虑把USB-TTL插在电脑上,此时他量取的电压始终是高电平,这时他会一直读取1吗?当然不会。所以接收方如何知道发送方正在发送数据,还是处于空闲状态?其次,连在一起发送的0和1如何分辨?如果在时间t内信号一直是高电平,那么这段信号被解调为多少个1呢?如果我们无法解决这些问题,就没有办法保证传输的准确性。 我门来回顾一下UART的名称:通用异步收发传输器。这里有一个我们比较在意的词语就是*异步*。所谓异步,就是非同步。我们先来看看什么是同步传输。我们常用的I2C和SPI都属于同步传输,他们的特点就是需要两根数据线(不包括GND)才能够完成一方向另一方的传输。这两根线一根是数据线,另一根是时钟线。同步传输的通信协议通过时钟线来解决判定空闲状态和分辨连续0和1的问题。例如我们可以规定空闲时时钟线一直保持高电平状态,当传输开始时,时钟线由高变低,这样信号的波形就会产生一个*下降沿*,接收方检测到下降沿就进入接受模式,然后发送方让数据线保持要发送的电平,再让时钟线变高再变低,这样接收方就又会检测到一个下降沿,此时接收方读取数据线上的电平,存储起来。以此类推,接收方只在时钟线出现下降沿的时候读取数据线的电平,这样就解决了分辨多个连续的1和0的问题。 而异步通信就是不用时钟线,只用一根数据线的通信方式。这里涉及数字基带通信的码型问题,我们只考虑常用的单极性编码,这里主要又分为单极性归零码和单极性非归零码。单极性归零码典型例子是我们常用的DS18B20温度传感器的1-wire总线,这种编码的每一个码元(一个1或者一个0)之间都用低电平隔开(归零),这样每一个码元都有清晰的界限,接受方只要检测到下降沿之后固定的时间读取一次电平就可以完成信号的接收。而UART采用的是一种单极性的非归零码:这种编码情况下,传输双方**必须先约定一个波特率**。 波特率是指每秒钟内传输码元的数目,确定波特率也就确定了每个码元的时间长度(波特率的倒数)。UART规定空闲状态为高电平,当出现下降沿时表示传输开始,这样就意味着开始传输的第一位一定是0,这一位叫做**起始位**。之后每隔波特率的倒数时间传输一个码元,这样接受方就可以通过计时来确定发送0和1的具体数量和次序。由于计时存在累计误差,从起始位的下降沿开始计时,如果每个码元计时都误差一点的话,累计时间越长误差就越大,因此不能一次传输太多码元,传输一定数量之后要恢复空闲状态,然后再产生下降沿重新开始计时。一次传输的的数据位数叫做**字长**,通常是8或者9位。传输顺序是由**低位到高位**。之后有可加可不加的**校验位**,用来判断传输是否出错,但是由于现代通信手段比较发达,短途的串口通信一般不会出错,因此通常不用校验位。一次传输完成之后,需要令通信信道恢复一定时间的高电平回到空闲状态,因此最后传输的一定是1,这一位叫做**停止位**,通常长为1、1.5或2位。一次串口传输的具体过程用逻辑分析仪捕获如下: ![](4/1.png) STM32CubeMX中默认的串口设置: ![](4/2.png) * Baud Rate(波特率):决定串口传输速度,**收发双方的波特率必须相同**。 * Word Length(字长):一次传输的位数,绝大多数情况下为8. * Parity(校验):校验方式,通常选择无校验None。 * Stop Bits(停止位):停止位的长度。 * Data Direction(数据方向):双工通信,只发送或只接受。 * Over Sampling(过采样):对一个码元进行多次采样,保证读取的准确性。 #### 4.3 利用串口控制灯 在实验我们的串口之前,我们首先需要掌握在电脑上使用串口的方式。现代的电脑通常不会留有串口收发器接口,因此我们一般使用USB-TTL模块来将一个USB口转接为串口。在电脑山可以通过编程方式很容易地操作串口,但是这样的方式对于刚入门的程序员们来说有些复杂,因此有很多简易的串口调试工具被开发出来让我们不必编程也可以直接使用串口。这类软件很多(PuTTY,正点原子的XCOM等等)。这里直接以Microsoft Store里的一款串口调试软件为例讲解。 ![](4/3.png) 首先将USB-TTL模块插入电脑的USB口,此时如果屏幕右下角出现了下图所示的小窗: ![](4/4.png) 请检查USB-TTL模块上的跳线帽是否正常。 ![](4/5.png) 如果没有插上跳线帽的话,找一个跳线帽将VCC和3V3短接,或者用杜邦线将VCC和3V3短接。在后续的步骤中还要短接TXD和RXD,让发送的信号进入到接收端,这样我们可以判断系统工作是否正常。 ![](4/6.png) 接下来打开软件: ![](4/7.png) 如果点击串口号右侧的下来菜单提示没有串口时: ![](4/8.png) 这可能是由于没有对应的驱动程序。通常USB-TTL模块使用CH340或者CP2102作为主控芯片,大多数的电脑会在安装操作系统的时候同时安装有这两种芯片对应的驱动程序,但是偶尔系统中会缺失这个驱动程序,这时需要我们手动安装。 以CH340为例,我们找到这款芯片的生产厂商南京沁恒微电子股份有限公司的官方网站,找到[下载链接](http://www.wch.cn/downloads/file/65.html)。运行下载得到的文件CH341SER.EXE。允许更改设备。 ![](4/9.png) 点击安装,之后出现: ![](4/10.png) 说明安装成功。此时再打开软件就可以看到串口号选择栏出现可用的选项了: ![](4/11.png) 我们点击打开串口,然后尝试发送一些字符,点击右下角发送图标发送: ![](4/12.png) 由于之前短接了TXD和RXD,也就是让发送端与接收端相连,所以发送的任何东西都会不变的显示在接收区域。 接下来我们尝试用串口来控制Blue Pill板上的小灯。 首先,依然是创建STM32CubeMX工程,调整RCC与打开SW接口等工作。然后因为要通过串口控制灯,我们打开USART1,并把与LED灯相连的PC13脚设置为GPIO OUTPUT模式以便控制灯的亮灭。串口的配置方法如下: ![](4/13.png) 在侧边Connecticity栏中选择USART1,然后右侧USART1 Mode and COnfiguration栏中的Mode选择Asynchronous(异步)。然后下面的Configuration采用默认设置即可。注意这里的波特率是115200,**串口通信双方必须波特率相同**,这样接下来我们调节电脑端的时候也要用115200的波特率。 ![](4/14.png) 不要忘记设置PC13脚为GPIO Output,然后我们生成代码,并在Keil中打开,找到main.c中的main函数。 ![](4/15.png) 还是在main函数中的while (1)中编写代码: ```c /* Infinite loop */ /* USER CODE BEGIN WHILE */ while (1) { uint8_t buffer; if (HAL_UART_Receive(&huart1, &buffer, 1, 1000) == HAL_OK) { if (buffer == '1') HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET); else HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); } /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ } /* USER CODE END 3 */ ``` `uint8_t buffer;`用来定义一个叫buffer的变量,类型`uint8_t`实际上是`unsigned char`的简略形式,意思是一个无符号的8位整型变量。因为我们希望一次接收一个字节的数据,正好可以放在这个8位的无符号整型变量中。 然后`HAL_UART_Receive(&huart1, &buffer, 1, 1000)`的意思是从uart1读取到buffer中,数字1的意思是读取1个字节,后面的1000是指1000毫秒。执行这个函数的时候,从执行开始计时,如果在1000毫秒之内接收到了数据,那么就立即把数据写入到buffer,并且返回HAL_OK,如果没有接收到数据,则到1000毫秒的时候什么也不做,返回HAL_TIMEOUT。 因此我们通过判断是否返回了HAL_OK来确定是否接受到数据,如果接受到数据就判断接收到的数据buffer是不是'1',如果是则点亮LED,不是则熄灭LED。 综上所述,这段程序使得在接受到1的时候亮灯,其他时候灭灯。接下来我们开始连接电脑实验。 我们把之前短接过的USB-TTL的TXD和RXD打开,分别连接单片机,连接方法是:**TX接RX,RX接TX**。 我们回到STM32CubeMX界面,观察右侧的单片机引脚图: ![](4/16.png) 我们可以看到开启USART1之后,引脚PA9和PA10变为绿色,旁边分别被标注有USART1_TX和USART1_RX。这两个引脚就是单片机的USART1串口对应的引脚,连接时,由于PA9是TX,按照TX接RX、RX接TX的原则,PA9连接到USB-TTL的RXD。而PA10是RX,要连接到TXD。另外需要十分注意的是:**通信的双方必须共地**,换句话说就是两者的GND必须连接在一起。在本次实验中,由于单片机通过STLINK和电脑共地,USB-TTL通过USB口也和电脑共地,因此不接GND两者也能正常通信。但是在其他的电子制作的情况下,必须随时注意共地的问题,要记住:需要有两根线才有电压的概念,一根数据线没有地线是无法检出电压的。 连接确认无误后,打开串口调试软件,打开串口,这时要注意**波特率一致**。我们在STM32CubeMX中配置波特率为115200,那么在串口调试助手中波特率也要是115200,否则可能出现乱码问题。 此时我们就可以通过发送0来使灯熄灭,发1来使灯重新亮起了。 串口连接常见错误: * RX和TX接反,此时双方完全无法通信,接收方接收不到任何消息。 * 未共地,此时接收方可能完全接受不到消息或者出现乱码。 * 波特率不一致,此时接收方接收消息为乱码。 * 使用串口下载程序后未将BOOT0恢复原位,此时发送任何消息会得到0xFF的回应(显示为乱码)。 USB-TTL模块上通常在VDD和TXD与RXD之间接有LED,由于串口在空闲状态时为高电平,LED两端电压为0,不亮。当串口正在发送消息时,由于串口通信时必然包括低电平的起始位,LED两端有时会有电压,导致LED发光。这是一种很实用的判断串口状态的硬件检测方法,空闲时LED不亮,发送时LED会断断续续的闪烁。使用万用电表量取TXD和RXD与GND之间是否有电压也是一种常用的串口检测手段。 接下来我们使用另一种方式来完成串口控制小灯。关闭Keil,返回STM32CubeMX界面,修改串口选项: ![](4/17.png) 点击NVIC Settings栏,将下面的USART1 global interrupt的Enabled选项勾选。然后重新生成代码,打开main.c中的main函数处,将原来的代码删除。 我们首先将buffer变为全局变量,方便其他的函数访问:方法是将`uint8_t buffer;`这一行置于main函数之外,`/* USER CODE BEGIN PV */`和`/* USER CODE END PV */`这两条注释之间。 ![](4/18.png) 然后在mian函数中的` /* USER CODE BEGIN 2 */`和` /* USER CODE END 2 */`之间添加一行 ```c HAL_UART_Receive_IT(&huart1, &buffer, 1); ``` ![](4/19.png) 最后在main函数之前,`/* USER CODE BEGIN 0 */`和`/* USER CODE END 0 */`之间加入一个函数,注意函数名不能错误: ```c void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (buffer == '1') HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET); else HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); HAL_UART_Receive_IT(&huart1, &buffer, 1); } ``` ![](4/20.png) 然后再编译运行,我们发现这段代码也能正常的工作:接收0后灭灯,接收1后亮灯。 我们发现这样做有一个很大的好处,那就是把main函数中的while(1)空余出来,可以用来编写别的代码。例如我们想要控制两个灯的时候,其中一个灯正常闪烁,另一个受串口控制。如果想要把两段代码全部写入到一个循环中比较困难。而这种写法就很好的处理了这种情况,我们可以在while(1)中执行其他代码,而串口的代码可以自己良好的运行,这种编程方式就是使用**中断**(interrupt)控制输入和输出。与之相对应的之前的方式叫做**轮询**(polling)。 什么是中断呢?所谓中断就是打断当前正在做的事情,转而去做另外的事情,做完之后再回到原来的流程中,这里的打断不是调用函数那种主动的跳转,而是由外部事件触发产生的被动的跳转。例如当我们通常为了早期需要定闹钟,这是因为当我们在睡眠过程中是无法主动的改变当前做的事情的,只有依靠一个外部的事件触发才能离开睡眠状态,这就是一种中断。相对的,如果我们想要使用轮询的方式早起,就需要每过一段起床看一次时间,这样就会导致我们不能时刻只关心当前正在做的事情,还要注意其他事件是否发生。 在计算机领域,中断以及中断代表的编程思想有很大的影响力。我们的计算机中,鼠标和键盘等等基本的IO设备通常都是使用中断来工作的,操作系统的进程切换依赖于定时器中断,系统接口的调用依赖于软中断。我们常见到的各种网页和图形界面通常也是使用类似的事件触发思想来运行。例如我们要在界面中设计一个按钮,当按钮按下时刷新页面,我们就可以把刷新页面的操作编写成一个**回调函数**(call back),再通过函数指针或者委托的方式把函数传递给图形界面系统,这样系统就会在用户点击这个按钮的时候调用我们编写的回调函数实现刷新页面的功能。 在单片机编程的时候也是一样,我们在使用中断的时候大概流程就是: * 开启NVIC对应的中断,设置优先级 * 使能外设的中断功能 * 编写回调函数 这与单片机实现中断的原理有关,在本例中,串口被USART1串口收发器接收到之后,首先判断自身的中断功能是否开启,如果开启则将中断信号发送至NVIC(嵌套向量中断控制器),NVIC判断这个中断是否能够打断当前的流程,如果能打断则送往CPU,CPU内部的中断屏蔽位默认不开启,这样中断信号就成功进入CPU,使CPU跳转到我们编写的回调函数(或称中断服务函数)中了。 开启NVIC在STM32CubeMX中完成,由于本例中只有一个中断,因此不存在优先级的问题,如果存在多个中断时,就需要利用优先级来为中断的重要性来排序,确定谁可以打断谁,关于中断的更多内容在后面我们会进一步学习。开启外设的中断功能这一步骤在main函数中的`HAL_UART_Receive_IT(&huart1, &buffer, 1);`完成,这个函数中的IT就是中断的缩写,意思是开启中断从USART1到buffer传输1个字节。 回调函数就是`void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)`,这个函数里的参数表示是哪一个串口触发了中断,类似网络编程中的多路复用效果,所有串口触发的中断都会交给给函数执行。函数名字的意思是“串口接收完成的回调函数”,你能看出他的意思吗?在这个函数中我们指令单片机每次遇到串口传输完成之后就调用这个函数,判断buffer的内容并作出响应。最后一行`HAL_UART_Receive_IT(&huart1, &buffer, 1);`的意思是重新开启这个中断,这是因为每次中断被触发之后,HAL库会自动的关闭中断功能。请尝试并思考:如果不加这一行,程序执行的效果如何?能否正常运行?为什么HAL库要这样设计呢? > 额外作业:我们在之前学习了如何接收信息,那么如何通过串口发送信息呢?尝试使用`HAL_UART_Transmit`函数发送消息到电脑。让灯亮后单片机答复"Turn on\n",灯灭后答复"Turn off\n"。思考:单片机答复消息对我们调试有什么作用?HAL库的这些函数命名和参数顺序有什么规律?如何发送变量的值到上位机辅助调试?(提示:使用sprintf函数可以在单片机中使用)。 #### \*4.4 通过串口下载程序 实际上,我们不仅可以通过串口来让单片机与电脑之间传输信息,还可以利用串口向单片机下载程序。使用串口下载程序现在用的比较少,但曾经是一个很流行的方式。stm32单片机也可以使用串口进行烧录,而且有部分stm32开发板只能使用串口进行烧录,这里对串口烧录程序进行讲解。 最早期的单片机内部通常没有程序存储器,为单片机编写的程序通常使用一颗外部的存储器芯片烧录,然后使用时单片机会自动从存储器芯片中读取程序执行。目前也有很多这样的单片机:例如树莓派PICO的主控,还有ESP32。这样做可以降低成本节约空间等等。但是比较麻烦,所以后期的单片机通常内置程序存储器,例如AT89C52,但是这种单片机烧录程序需要把单片机芯片固定在芯片座上,用特定的烧录器烧录程序,这对于DIP封装的体积巨大的单片机来说,并不麻烦。但是随着封装技术的发展,出现了大量的贴片封装甚至无引脚封装的单片机,这些单片机有的甚至只有米粒大小,想要烧录程序到这些单片机中,之后还要重新摆放好以便贴片机贴片实在是过于复杂。因此人们希望把单片机贴在板上之后再通过板上的下载口或者接触点进行烧录程序,这种在把单片机焊接在电子系统之后再进行编程的方式叫做ISP(在系统编程)。其原理是单片机厂家在出厂前,先烧录一段简短的程序,这段程序的作用就是利用外部的通信接口(一般是串口)接收程序,然后自己把接受的程序写入到程序存储器中。我们常见的STC89C52单片机通常就是使用串口,通过ISP程序进行烧录的。这样单片机就可以在板上的时候进行烧录了。 stm32单片机在内部专门设有一个ISP程序的存储器,在上电的时候可以通过判断两个引脚BOOT0和BOOT1的电压来确定要执行程序存储器中的程序、SRAM中的程序还是ISP程序。在前面*1.4.5 忘记开启Serial Wire(SW)接口导致stlink无法连接单片机怎么办?*中,我们切换BOOT引脚上的跳线帽位置的操作就是让单片机启动ISP程序,利用ISP程序不关闭SW接口的特点对关闭SW接口的单片机进行烧录。那么如何使用串口向stm32烧录程序呢? 首先我们要解决的问题就是如何让stm32启动ISP程序。最常见的办法有三种,前面提过两种是跳线帽方式和按钮方式,跳线帽方式需要移动跳线帽的位置来让BOOT引脚改变电压,按钮方式则是利用按下按钮的时候再上电,让单片机在上电的时候检测到BOOT0高电平。此外还有一种利用串口流控方式来控制BOOT引脚电压的手段。工业上的串口线往往不止RX,TX和GND,通常还有多个流程控制线,简称流控。流控线的作用类似同步传输中的时钟线,可以用来附带一些信息,例如接收方是否就绪,发送方是否就绪等等。其中常用RTS和DTR两根流控线来控制BOOT电平。这两根线的特点是完全可以由程序控制高低电平,因此只要把这两根线分别接入单片机的BOOT0和BOOT1就可以让电脑自动控制这两个引脚电平的高低了。如果你购买的开发板宣称串口一键下载,通常就是利用流控线来控制BOOT。由于RTS和DTR与BOOT0和BOOT1之间的连接方式不同,对于此种下载方式首先需要读原理图判断RTS和DTR的电平,当然最好的方式是询问卖家。然后在卖家提供的串口下载软件中选择RTS和DTR电平,之后才能正常下载。 这里用st官方的STM32CubeProgrammer来演示串口下载程序: 首先确保单片机启动ISP程序,然后连接USB-TTL模块,再连接电脑,需要注意的是单片机只有上电的时候才能运行程序,所以单片机上不止需要连接RX,TX和GND,电源线也需要一并连接。然后运行STM32CubeProgrammer软件: ![](4/21.png) 右上角的蓝色下拉菜单选为UART,然后选择串口(Port一栏),波特率不需要在意,因为ISP程序可以自动的测量波特率。然后点击绿色的连接按钮。之后软件就可以连接到单片机,并读取出单片机程序存储器的内容: ![](4/22.png) 我们点击上面的Open file选项卡,找到工程中MDK-ARM\\<你的工程名称>\\<你的工程名称>.axf ![](4/23.png) Download选择Verify ![](4/24.png) 然后下载,等待进度条满之后下载完成: ![](4/25.png) #### \*4.5 Keil串口仿真 Keil具备对单片机运行进行仿真的能力,在有些场合,仿真可以起到非常大的作用,接下来我们对之前的程序进行仿真。 在仿真之前,我们首先要修改一些设定,首先打开魔术棒(Options of Target)的Debug一栏,这里是默认的设定: ![](4/26.png) 我们需要修改的有三处,分别是: * 勾选Use Simulator * 左侧Dialog DLL中的内容改为DARMSTM.DLL * 左侧Parameter中的内容改为-pSTM32F103C8(如果是别的型号改为对应的型号) 修改后为: ![](4/27.png) 然后我们点击Debug按钮: ![](4/28.png) 之后就能够进入仿真界面了: ![](4/29.png) 首先我们设定逻辑分析仪:通过逻辑分析仪我们来观察PC13的波形。 ![](4/30.png) 点击这个按钮之后,会显示逻辑分析仪窗口,我们点击Setup: ![](4/31.png) 点击按钮,添加待观察的信号: ![](4/32.png) 输入PORTC.13然后回车,显示为”(PORTC & 0x00002000)>>13“,然后选择Display Type为Bit,最后点击Close关闭窗口。 ![](4/33.png) 然后逻辑分析仪窗口变为: ![](4/34.png) 接下来打开串口窗口: ![](4/35.png) 点击后,通常出现在右下角: ![](4/36.png) 注意:这个串口窗口的工作与终端类似,我们在这个窗口的输入不会被显示,显示的内容只有单片机这个串口的输出,如果单片机没有输出东西的话这个窗口将始终空白。 最后我们点击左上角的运行: ![](4/37.png) 此时,随着我们向串口窗口键盘输入1和0,逻辑分析仪上显示PC13的变化情况: ![](4/38.png) 如果你没有单片机,或者不确定故障的原因在程序还是电路板,那么仿真程序可以帮你验证程序的正确性,还可以把端口的输出情况以波形显示出来,在单片机的调试中可以起到很大的作用。 #### 4.6 FAQ ### 5. 万能的外设 - 定时器 #### 5.1 计数器与定时器 #### 5.2 PWM波 #### 5.3 利用定时器实现呼吸灯 #### \*5.4 定时器中断 #### 5.5 FAQ ### 6. 让东西动起来 - 电机与舵机 preload寄存器 #### 6.1 L298的使用方法 #### \*6.2 H桥电路的基本原理 #### 6.3 利用PWM控制电机速度 #### 6.4 利用PWM控制模拟舵机 #### 6.5 FAQ ### 7. 程序基本思路 - 轮询与中断 在前面的内容中我们已经了解到如何完成小车的各个部分的代码,接下来我们关注的问题就是如何将这些零散的代码组织成为一个整体的工程。在这里我们将会讨论关于一个单片机程序的基本结构以及如何将多个例程整合在一起的方法。 #### 7.1 关于IO的思考 在开始内容之前,我们首先需要讨论的就是IO的问题。IO就是输入和输出的缩写 #### \* 7.2 同步与异步 #### 7.3 利用中断接收串口数据 #### 7.4 FAQ ### 8. 实践 - 制作遥控小车 #### 8.1 组织一个团队 #### 8.2 购买合适的原料 #### \*8.3 构建合适的电路 #### 8.4 设计程序结构 #### 8.5 实现程序代码 #### \*8.6 编写一个上位机 #### 8.7 调试 #### \*8.8 重构 #### 8.9 FAQ ## 进阶部分:常见外设电路的使用 如果你已经完全学会了之前的内容,那么恭喜你,你的能力已经足以完成很多简单的科创比赛了,并且具有独立制作一些小型DIY制作的水平。但是,如果你想要参加一些更高级的竞赛或者更复杂的团队项目时,往往会遇到一些问题,其中最主要的就是如何编写或者移植一些外设的驱动程序:例如读取电机速度的正交编码器,读取电池电压的值,读取IMU的数据,在屏幕上显示一些内容之类。这些各种各样的外设,包括片上的和片外的,他们的驱动程序往往是有一定基础的初学者所遇到的最麻烦的问题。 为了解决这些问题,初学者们往往会选择在网上搜索代码,然后一种一种的进行尝试:一种代码不能编译成功或者产生效果那么便换另一种,并且在这些问题上浪费绝大部分的时间。诚然,编写单片机外设驱动程序并不是一件简单的事情,绝大多数的外部电路的使用方式并不像前面的串口,GPIO,电机驱动器那样简单。但是处理这些问题并不是完全没有方法的,在下面这一部分中,我们将会一起来学习一些常用典型的外部电路的驱动方式,体会开发一个驱动程序的大概流程。最重要的是在这个过程中学会一些最基础的技能:如何查阅文档?如何阅读时序图?什么是阻塞IO,什么是多路复用?学会了这些,不仅仅是为了更好的开发驱动程序,更是为了阅读和移植其他人的程序打下良好的基础。 ### 9. 读取速度 - 编码器 > 掌握定时器读取正交编码器的方法 ### 10. 读取电压 - ADC > 掌握如何使用ADC读取电压 ### 11. 获取运动姿态 - I2C > 掌握使用MPU6050的方法,了解数据手册的阅读 ### 12. 来一段badapple - SPI > 掌握OLED屏幕的使用方法,了解数据手册的阅读 ### 13. 自动读取数据 - DMA > 掌握DMA的使用方法 ## 高级部分:原理与思路 学到这里,你已经是一个基本合格的单片机开发者了,甚至成为同学们眼中的科创大佬:你可以成为一个工期比较长的比赛或者是电赛队伍中的一名主力成员了。但是有很多问题依然会困扰着你,这些问题往往很小,但是致命且难以解决。大多数人会将这些问题归为玄学问题,通过尝试多种不同的办法,向代码中添加大量的补丁可以解决问题,虽然这可能花费相当长的时间。 但是计算机系统本身绝不会存在玄学或者概率性的事件,所有看似偶然的bug一定是某种条件下的必然。想要成为真正的科创大佬,就一定要对这些原理性的知识有一个清醒的认识。这一部分我们将一起探讨单片机的基本工作机制、keil的运行机制和使用技巧以及如何编写优雅的代码。这些知识是通向高手之路的基石。 请注意:创新无止境,实践出真知。这一部分内容只是作者对这些知识的一些粗浅的见解,想要成为真正的高手,还是要投入到无止境的创新实践中去。 ### 14. 万物之源 - 寄存器 在刚刚学习C语言的时候,我一直有一个疑问:我们想要输出一段文字在屏幕上的话,就要调用printf函数。那么printf函数的函数体在哪里?printf函数又是调用了什么函数来实现他的功能的呢?如果我们这样一层一层的找下去,总要有一个不靠调用其他函数而实现显示文字功能的函数存在,那么这个函数又是如何实现他的功能的呢? 对C语言有一些深入了解的同学可以知道,printf函数是c语言标准库的函数,他的实现是由编译器完成的。如果限定以gcc进行编译的话,我们可以通过查找glibc的源代码来找到所有标准库函数的具体实现。例如在glibc-2.34/stdio-common/printf.c中: ```c /* Write formatted output to stdout from the format string FORMAT. */ /* VARARGS1 */ int __printf (const char *format, ...) { va_list arg; int done; va_start (arg, format); done = __vfprintf_internal (stdout, format, arg, 0); va_end (arg); return done; } ``` 事实上我们可以一点一点的找下去,直到找到有关操作系统调用的函数,然后进而还可以转到操作系统的源代码去阅读。 对于单片机而言,我们知道可以通过调用HAL库中的对应函数来控制对应的外设,那么HAL库的函数是如何控制外设的呢?我们可以通过不断查找定义很快的完成这一过程,毕竟HAL库的编写者不会期望单片机这样一个低功耗低速度的计算机系统能够像笔记本或者台式电脑那样良好的处理很多层嵌套的函数调用,也不能期望单片机开发者能够忍受过长的编译时间。 现在让我们回到梦开始的地方,那个Blink程序。我们查看HAL_GPIO_WritePin这个函数的定义: ```c /** * @brief Sets or clears the selected data port bit. * * @note This function uses GPIOx_BSRR register to allow atomic read/modify * accesses. In this way, there is no risk of an IRQ occurring between * the read and the modify access. * * @param GPIOx: where x can be (A..G depending on device used) to select the GPIO peripheral * @param GPIO_Pin: specifies the port bit to be written. * This parameter can be one of GPIO_PIN_x where x can be (0..15). * @param PinState: specifies the value to be written to the selected bit. * This parameter can be one of the GPIO_PinState enum values: * @arg GPIO_PIN_RESET: to clear the port pin * @arg GPIO_PIN_SET: to set the port pin * @retval None */ void HAL_GPIO_WritePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState) { /* Check the parameters */ assert_param(IS_GPIO_PIN(GPIO_Pin)); assert_param(IS_GPIO_PIN_ACTION(PinState)); if (PinState != GPIO_PIN_RESET) { GPIOx->BSRR = GPIO_Pin; } else { GPIOx->BSRR = (uint32_t)GPIO_Pin << 16u; } } ``` 我们会发现其实这个神秘的函数只做了一件事情:向GPIOx->BSRR写入了某个值!注意这里用了写入这个词,而不是赋值。那么GPIOx->BSRR又是什么呢?在这里,GPIOx的取值是GPIOC,因此我们查看GPIOC的定义就可以了: ```c #define GPIOC ((GPIO_TypeDef *)GPIOC_BASE) ``` 而GPIO_TypeDef和GPIOC_BASE分别是: ```c typedef struct { __IO uint32_t CRL; __IO uint32_t CRH; __IO uint32_t IDR; __IO uint32_t ODR; __IO uint32_t BSRR; __IO uint32_t BRR; __IO uint32_t LCKR; } GPIO_TypeDef; #define PERIPH_BASE 0x40000000UL /*!< Peripheral base address in the alias region */ #define APB2PERIPH_BASE (PERIPH_BASE + 0x00010000UL) #define GPIOC_BASE (APB2PERIPH_BASE + 0x00001000UL) ``` 好了,到这里谜团已经解开:如果你的C语言基础良好的话,你会发现这段代码把一个神秘的常数强制解释为一个指向GPIO_TypeDef类型变量的指针,然后向这个GPIO_TypeDef的BSRR成员变量进行了一个赋值操作。因为结构体总是按顺序排布他的成员变量,并且每个成员变量的长度又都是32位,不存在字节对齐的问题,因此整个操作其实是在向一个神秘的内存地址: 0x40000000UL+0x00010000UL+0x00001000UL+4*4=0x40011010UL 写入了一个数字! 事实上,这就是单片机程序的原理。这个地址指向的是一个**寄存器**。单片机对外设的任何操作实际上都是对寄存器的读写,接下来,我们开始讨论有关寄存器的话题。 #### 14.1 寄存器的基本操作 首先我们打开stm32f1xx.h,在文件中我们可以找到一段代码: ```c #define SET_BIT(REG, BIT) ((REG) |= (BIT)) #define CLEAR_BIT(REG, BIT) ((REG) &= ~(BIT)) #define READ_BIT(REG, BIT) ((REG) & (BIT)) #define CLEAR_REG(REG) ((REG) = (0x0)) #define WRITE_REG(REG, VAL) ((REG) = (VAL)) #define READ_REG(REG) ((REG)) #define MODIFY_REG(REG, CLEARMASK, SETMASK) WRITE_REG((REG), (((READ_REG(REG)) & (~(CLEARMASK))) | (SETMASK))) #define POSITION_VAL(VAL) (__CLZ(__RBIT(VAL))) ``` 这里展示了我们对寄存器可以做的一些最基本的操作:置位、清零、读取以及获取最低的1的位置。我们主要关注的是前三种操作,这三种操作使用简单的C语言位操作就可以轻松实现。 我们首先要了解的一个有用的概念:掩码。我们最早接触这一概念应该是在计算思维(一)课程中的子网掩码,路由器可以利用子网掩码来判断一个ip地址是否位于自身的网段内。而在使用寄存器的过程中,我们使用掩码来标记我们感兴趣的位,类似于计算机视觉中利用掩膜来标记ROI。 在之前的例子中,假如我们要控制Blue Pill板上的led灯灭掉。这需要令PC13口输出高电平,对应寄存器操作就是向`GPIOC->BSRR`寄存器的第13位写入一个1。在这个操作中,我们只对这个寄存器的第13位感兴趣,如果不小心修改了其他的位则有可能造成不良后果。实际上,我们通常的做法是`GPIOC->BSRR=(1<<13);`,这样我们就能保证只对第13位写入1而其他位都写入0了。 这里面的`(1<<13)`就是一个掩码,利用掩码我们就可以只对感兴趣的某个或者某几个位进行操作。如果你已经很熟悉与和或这两种运算,你会了解到他们的性质:任何位与1不变,与0为0;任何位或1为1,或0不变。因此我们可以将多个掩码叠加在一起使用,例如想让PC13和PC14一起变为高电平,我们可以使用`GPIOC->BSRR=((1<<13)|(1<<14))`来实现。实际上在HAL库中这些关于IO口的掩码有专门的宏定义,就是我们熟悉的`GPIO_PIN_x`。试试`HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0|GPIO_PIN_1, GPIO_PIN_RESET);`这样的语句,他会产生什么效果呢? 在上面我们操作GPIO口的例子中,我们可以通过对BSRR寄存器写入掩码来进行操作,这是因为BSRR寄存器会忽略所有的0,只有对某一位写入1才会产生效果。但是并不是所有寄存器都具有这样的特性,我们往往需要只修改或者读取一个寄存器中的某个位而不影响其他的位,接下来我们考虑如何完成这样的操作。 实际上这个问题可以参考这三行代码: ```c #define SET_BIT(REG, BIT) ((REG) |= (BIT)) #define CLEAR_BIT(REG, BIT) ((REG) &= ~(BIT)) #define READ_BIT(REG, BIT) ((REG) & (BIT)) ``` 其中REG表示要操作的寄存器,而BIT表示掩码。做几个简单的实验我们就能够理解这三行代码的意义,尝试下面的代码: ```c #include int main() { char p[] = "A"; const unsigned char mask = (1<<5); printf("origin is %s\n", p); *p |= mask; printf("lower is %s\n", p); *p &= ~mask; printf("upper is %s\n", p); printf("%s is %s\n", p, (*p & mask) ? "lower" : "upper"); return 0; } ``` 这段代码的执行结果是什么?为什么? 在Cortex-M3或Cortex-M4处理器中还有一种很有用的访问修改寄存器位的方式,那就是位段。可能我们之前曾经了解过一些相似的概念,例如c语言的bit field,在*c++ primer plus*中这被翻译为位字段。而这里我们要讲的是另一种功能相近而本质上完全不同的东西——bit band。这里参考了*ARM Cortex-M3与Cortex-M4权威指南(第三版)*中的翻译:位段(有些资料翻译为位带)。 位段与位字段不同,位段是全部由硬件实现的,总线硬件会自动的将对某个32位地址的访问转到对某一个位的访问。这实际上是一种相当有用的特性。我们还是用熟悉的GPIO来举例说明:GPIO的ODR寄存器可以用来控制GPIO口的电平高低,例如我们可以写入1<<13来令这个口的13引脚变为高电平,与BSRR不同的是:ODR寄存器的操作不仅会影响到写1的位,同时会令写0的位变为低电平。因此当我们想要通过ODR寄存器来修改GPIO口电平的时候,就需要先读取ODR寄存器原来的输出情况,然后修改想要修改的位,最后再将修改过的值写回ODR中,虽然在C语言中这样的操作可以简单使用|=或者&=这样的运算符在一行之内实现,但是在ARM单片机中却是确确实实的需要3条机器指令才能够完成这一操作。这是因为ARM是一种典型的load-store结构,如果你熟悉ARM架构或者读过TAOCP这类从计算机底层讲起的书籍,你可以很容易理解这一点。 这样我们即使只需要修改ODR中的一个位,也需要执行三个指令才能完成这一操作。而有些情况下,我们可能需要做更多的操作才可以,在后面的内容中我们将会认识到这一点。而位段则可以让每一个位都有一个独立的地址,这样当我们需要修改一个位的时候只需要一次的写入操作就可以达到我们想要的效果。可能三条指令与一条指令的效率相差并不大,但是有些情况下事情会变得相当复杂。假如我们的单片机需要修改PC13输出的同时开启了一个中断,而这个中断的服务程序要修改PC14的输出,考虑以下情景: 在一开始PC13和PC14都输出低电平,然后想要修改PC13为高电平,此时我们首先读取到PC13和PC14都是低电平这一事实,然后在这时忽然产生了一个中断,中断修改PC14为高电平,那么中断返回之后,我们的主程序流程会了解到之前读取到的PC14是低电平这一事实已经改变了吗?不会。因此主程序流程会把PC13为高电平和PC14为低电平写入到ODR中,最终结果就是中断对PC14的修改未能产生任何作用。因此在有中断的情况下这种访问就不仅仅是3条,而是5条:我们需要在修改之前开启中断屏蔽,修改之后关闭中断屏蔽。 如果是在操作系统环境下,事情可能还会变得更加复杂:当两个进程同时访问一个IO的时候,往往需要锁来保证操作的同步,这也是两个进程不能同时访问一个文件的原因,如果操作不当还可能导致死锁,使计算机陷入无限的死循环中。而位段操作能够在一条指令内完成所有的操作,这种操作也叫做原子操作,因为这种操作不能够被中断打断而分解。而这也是我们需要BSRR寄存器的原因,访问BSRR寄存器永远是一个原子操作。所以使用HAL_GPIO_WritePin的时候就不需要担心中断可能产生的麻烦了。 位段操作只能对一小段内存区域进行,但是不必担心,这一小段内存区域足以完整覆盖所有的外设寄存器了。在使用的时候需要首先计算出需要读写的那一位所对应的位段映射地址,*ARM Cortex-M3与Cortex-M4权威指南(第三版)*中给出的计算方式为: ```c #define BIT_BAND(addr, bitnum) ((addr & 0xF0000000) + 0x02000000 + ((addr&0xFFFFF)<<5) + (bithum<<2)) ``` 实际使用的时候可以通过查阅资料或者百度很容易就可以找到合适的代码。 接下来我们一起尝试利用寄存器编写一个简单的ds18b20温度传感器的驱动程序,主要了解GPIO寄存器和TIM寄存器的简单使用,同时也是对之前内容的复习。值得注意的是:我们不鼓励使用寄存器来进行编程,因为不同型号单片机的寄存器千差万别,即使同为stm32系列,f1和f4两个产品家族的寄存器也有所不同,导致很难移植程序,并且寄存器的程序也相对来说难以理解。我们使用寄存器的主要原因是:1. 寄存器在调试时非常好用。 2. 单片机的参考手册往往是以寄存器为基础编写的,了解一个单片机外设的全部功能细节就离不开寄存器。3. 有些单片机厂商不提供库函数。至于性能问题,通常我们在DIY制作的时候,单片机的成本占比很小,与其编写高性能的程序,不如使用高性能的单片机。 #### 14.2 GPIO寄存器 在开始寄存器编程之前,我们首先要做的就是找到单片机的参考手册。stm32系列单片机通常会提供三种手册:Data Sheet、Reference Manual和Programming Manual。其中Data Sheet的主要内容是单片机的电气参数,引脚配置等硬件参数数据,Reference Manual描述单片机片上外设的功能,寄存器编程主要参考Reference Manual。而Programming Manual主要描述单片机CPU的功能和指令集等等,在汇编编程时很有用。 为了找到Reference Manual,我们首先打开STM32CubeMX,选择想要的芯片,然后找到Doc & Resources,这里我使用的是F401CC。 ![](14/1.png) 在Doc & Resources中,我们可以找到RM开头的一个文档: ![](14/2.png) 打开之后就能看到这款单片机的参考手册了。我们找到GPIO一章: ![](14/3.png) 首先我们关注GPIO口的结构:(注:不同产品家族的单片机寄存器可能不同) ![](14/4.png) 观察这张图,我们可以获得关于GPIO口基本功能的信息。在图的左下角,我们可以通过写入BSRR或者ODR来控制GPIO口的输出电平,BSRR通过修改ODR的值来使配置生效,通过读取ODR可以了解当前输出的电平高低,而BSRR是只写的,不能读取数据。沿着数据流向向右走会经过一个梯形框,在前面我们配置工程的时钟树时曾经遇到过类似的符号。实际上这个符号我们可以在数字电子技术一课中学到,他的意义是数据选择器,可以控制梯形底边的某一路数据传送到顶边的输出口,而底边其他的输入数据则被忽略。这里的数据选择器表示GPIO可以选择输出的信号来源于ODR还是内部的复用功能,例如TIM发生的PWM波或者USART产生的串口信号,这个配置是通过MODER寄存器来实现的。 数据选择器右边是输出控制器,可以选择输出的形式为推挽输出或者开漏输出,这两种输出模式我们可以参考课程模拟电子技术中的内容,这两种输出输出低电平的方式是完全相同的,都是打开开关让输出引脚与GND(VSS)短接,不同的是输出高电平的方式:推挽输出通过短接输出引脚和VDD(3.3v)来输出高电平,而开漏输出则是关闭两个开关,让输出引脚浮空。我们在之前GPIO一章中通过实验探讨过浮空、上拉和下拉的区别,浮空输出的电平完全由与他相连的引脚决定,因此可以通过让浮空输出的引脚串接一个电阻到任意高电平来实现让单片机输出非3.3v电平的效果,此外,开漏输出的”线与“特性在半双工通信时有很好的作用,感兴趣的同学可以自行查阅资料。可以通过修改OTYPER寄存器来配置输出是开楼的还是推挽的。同时,输出控制器还可以控制输出的速度,但是在科创实践中我们很少考虑功耗和速度问题,通常采用默认的速度。 最右边输出部分有两个电阻,这两个电阻的通断是可控的,连接到输出引脚和VDD两端的叫上拉电阻,而另一个叫下拉电阻。我们可以通过设置PUPDR寄存器来控制这两个电阻的通断来实现我们想要的上拉和下拉效果。 图的上部是GPIO口的输入部分,虚线框中的三角形叫做施密特触发器,同样我们可以在数字电子技术或者模拟电子技术中学到相关的知识,施密特触发器可以将输入的任意电压转化为3.3v和0v电压。因此我们可以看到通过施密特触发器之前分支出一个箭头指向左面,这是连接内部模数转换器的通道,可以用来采集端口的电压。后面的两路则分别输入到单片机内部的外设和IDR寄存器,这样我们就可以令单片机内部的外设读取端口电压或者从IDR寄存器读取到端口的电平高低了。 接下来我们考虑如何编写ds18b20的驱动程序。首先我们需要下载ds18b20的datasheet,这可以很容易的通过百度得到。ds18b20通过1-wire总线通信,这是一种半双工的通信方式,因此我们需要实时修改GPIO口的模式在输入与输出之间进行切换。datasheet中要求输出引脚需要连接上拉电阻,因此我们需要将GPIO口的上拉电阻打开。我么可以直接通过STM32CubeMX实现: ![](14/5.png) 或者查询参考手册中的PUPDR的说明部分: ![](14/6.png) 这意味着想要上拉(Pull-up)需要我们修改端口对应的PUPDRy的两位为01,而PUPDRy这两位对应为: ![](14/7.png) 例如如果想要修改PA9为上拉,需要的代码就是: ```c GPIOA->PUPDR &= ~GPIO_PUPDR_PUPD9_Msk; // 清除原有的数据 GPIOA->PUPDR |= (1 << GPIO_PUPDR_PUPD9_Pos); // 将01写入对应的位置 ``` 或者我们可以直接向第18位写1,因为手册中显示port A的默认值(Reset values)的PUPDR9本来就是00。 #### 14.3 TIM寄存器 #### 14.4 FAQ ### 15. 了解内核 - Cortex-M 我们已经了解了寄存器是如何控制单片机外设的,接下来我们需要了解另外一个问题:程序是如何被单片机执行的?在进入Keil的Debug模式之后,寄存器窗口中的数字是什么意思?为什么需要startup文件?什么是调用栈? 为了弄清楚这些问题,我们需要 #### 15.1 CPU的运行机制 #### 15.2 汇编语言 #### 15.3 函数调用 7.5 Methods of minimizing function parameter passing overhead 函数参数的数量和长度影响效率 内联函数 #### 15.4 异常 #### 15.5 中断 __irq #### 15.6 FAQ ### 16. 代码的诞生 - 编译与链接 #### 16.1 代码生成的基本过程 #### 16.2 基本的编译器选项 #### 16.3 C语言与编译器 volatile Compiler optimization and the volatile keyword 中断访问的全局变量应该添加volatile $$Super$$和$$Sub$$装饰器 弱定义 纯函数 __packed #### 16.4 链接器 #### 16.5 FAQ ### 17. 强大的工具 - Debugger #### 17.1 断点调试 #### 17.2 仿真 #### 17.3 逻辑分析仪 #### 17.4 常见BUG #### 17.5 FAQ ### 18. 编写优雅的代码 - 软件工程 #### 18.1 代码格式