聊到Arm嵌入式入门,STM32F103几乎是一个绕不开的名字。Cortex-M3内核、丰富的外设资源、极其成熟的开发生态,让这颗芯片成为无数实验室和工程师自学路上的第一块开发板。如果你正在学“Arm嵌入式系统原理及应用”这门课,或者打算用STM32F103做毕设原型、项目预研,这篇文章就是按我自己的学习路线和踩坑经历整理的,从内核架构到外设编程,再到开发环境搭建和调试技巧,尽量把关键点都讲透。
我不会按课本目录给你念一遍,而是挑那些“上课时容易忽略、做项目时一定会遇到”的东西讲。比如时钟树为什么要那样配,GPIO的复用功能到底复用什么,中断优先级分组怎么理解,串口调试为什么是嵌入式开发的命脉。这些内容搞明白了,你再看任何一款STM32芯片的数据手册,思路都会清晰很多。
如果你已经有51单片机或者Arduino的基础,学F103会非常快。如果完全没有基础,也不用怕,我会尽量用生活化的类比把概念讲清楚。这篇东西适合所有准备踏入嵌入式开发大门的人,也适合做项目做到一半回头补基础的人。
1. 内容整体设计与思路拆解
1.1 为什么入门要选STM32F103
先说个很现实的问题:市面上Arm芯片那么多,F1、F4、H7,国产的还有一堆Pin-to-Pin兼容的型号,为什么教程和课程偏偏都选F103?
核心原因就四个字:资料够多、生态成熟。F103这颗芯片从2007年问世到现在,十几年过去了,它的资料、例程、开源项目积累量是惊人的。你遇到问题,搜索引擎一查基本都能找到答案。对于初学者来说,学习成本最大的地方不是芯片本身难,而是遇到问题没人问、找不到参考,F103恰好把这个问题解决了。
从硬件角度看,F103用的是Cortex-M3内核,主频最高72MHz,Flash最大512KB,RAM最大64KB。这个配置在今天看来不算高,但作为学习平台非常合适。它不像8位机那样资源捉襟见肘,也不像高端芯片那样复杂到让人无从下手。GPIO、USART、SPI、I2C、定时器、ADC、DAC、DMA、USB、CAN,该有的外设基本都有,足够你把嵌入式开发的核心技能全部过一遍。
而且F103的功耗控制、中断响应、外设设计思路,和后续的F4、H7甚至其他厂商的MCU都是一脉相承的。你把F103吃透了,后面换芯片只是适应新寄存器、新库函数的问题,底层逻辑已经完全打通。
1.2 这“三大件”分别解决什么问题
标题里的“架构、编程与开发”其实对应了三条完全不同的学习线,很多人混在一起学,结果哪条都没学透。
架构,解决的是“芯片内部长什么样”的问题。Cortex-M3内核怎么取指、怎么执行指令,存储器映射怎么安排,总线矩阵怎么把内核和外设连在一起,时钟树怎么分配频率。这些东西搞清楚了,你写代码的时候才能真正理解每一行寄存器配置是在操作什么硬件。
编程,解决的是“怎么控制硬件干活”的问题。这里既包括C语言本身在嵌入式环境下的特殊用法,比如位操作、指针访问寄存器、volatile关键字的意义,也包括对ST标准外设库或者HAL库的理解。很多人觉得写嵌入式代码就是调库,其实不然,你要知道库函数背后操作了哪些寄存器,才能应付那些“库函数解决不了”的坑。
开发,解决的是“怎么把代码烧进去、怎么调试、怎么排查问题”的问题。包括开发环境搭建、下载器配置、仿真调试、串口打印、逻辑分析仪抓波形等等。这部分最容易被忽略,但实际工程项目里,调试能力往往才是决定开发效率的关键。
1.3 配套学习资料应该怎么用
这里说的就是标题里那本书,按我的理解,这类系统教程的正确用法不是从头到尾读一遍,而是当作工具书加项目指导来用。
我自己看嵌入式教材的习惯是三步走。第一步,先看书的前三章,把芯片架构、存储器映射、时钟系统这些底层概念过一遍,有个印象就行,不要求全记住。第二步,直接跳到后面实验章节,跟着做点灯、串口、中断这些经典实验,遇到看不懂的寄存器配置再回头翻前面的原理部分。第三步,做完三四个实验之后,回来通读全书,这时候你会发现之前看不懂的章节突然变得很顺畅,因为你的知识框架已经建立起来了。
千万别一上来就从第一章啃到最后一章,那样大概率前三章就被寄存器地址和总线矩阵劝退了。嵌入式学习是“做中学”的典型领域,动手做一次实验,胜过看十页书。
2. 核心细节解析与实操要点
2.1 Cortex-M3内核的流水线与NVIC机制
Cortex-M3采用三级流水线结构,取指、译码、执行三个阶段并行工作。这就像餐厅里的出餐流程:一个厨师负责接单(取指),一个厨师负责配菜(译码),一个厨师负责炒菜(执行)。这样一来,虽然单个指令的执行时间没有缩短,但整体吞吐率大幅提升。
理解流水线对写代码有什么实际帮助?最大的帮助在于理解中断延迟。Cortex-M3内置了嵌套向量中断控制器(NVIC),它专门负责中断优先级的判断和响应。当中断事件发生时,NVIC会暂停流水线,压栈现场(自动保存R0-R3、R12、LR、PC、xPSR这8个寄存器),然后跳转到中断向量表对应的入口地址执行中断服务函数。
这里有个很多新手不知道的细节:Cortex-M3的中断响应只需要12个时钟周期,这个速度远快于传统的51单片机。因为压栈和取向量是硬件自动完成的,不需要软件干预。所以你在写中断服务函数的时候,函数体越短越好,不要在里面做耗时的运算,尽量只设置标志位,把具体处理放到主循环里做。这是嵌入式开发的一个黄金法则。
NVIC还有一个概念叫中断优先级分组。Cortex-M3支持两种优先级:抢占优先级和子优先级。抢占优先级决定中断是否可以打断另一个中断,子优先级只在抢占优先级相同时才起作用。配置的时候通过AIRCR寄存器选择分组方式,常见的是分组2,也就是2位抢占优先级、6位子优先级。
2.2 存储器映射与启动文件的作用
STM32F103的4GB地址空间被精心划分成了不同的区域。最核心的几个区域是:0x00000000开始的程序存储器(Flash)、0x20000000开始的内置SRAM、0x40000000开始的外设寄存器区。
写代码的时候,普通变量放在SRAM里,程序指令放在Flash里,操作外设就是向0x40000000附近的某个寄存器地址写入特定值。理解了这张地址地图,你再看那些寄存器地址定义就不会觉得莫名其妙了。
启动文件(startup文件)是每个工程里必须有的组件,很多人直接复制过来用,却不知道它干了什么。启动文件主要做三件事:第一,初始化栈指针SP,指向SRAM的最高地址;第二,设置中断向量表,把每个中断服务函数的入口地址放到固定的位置;第三,调用SystemInit函数配置系统时钟,然后跳转到C语言入口main函数。
如果你用的是标准外设库,启动文件里还会帮你处理一些C运行时环境的初始化,比如清零BSS段、拷贝数据段。这就是为什么嵌入式工程的main函数之前还有一段“看不见的代码”在默默工作。
2.3 时钟树与RCC配置的底层逻辑
STM32F103的时钟系统是所有外设工作的基础,但也是最容易让人头晕的部分。简单来说,芯片内部有一个锁相环(PLL)可以把外部晶振的频率倍频上去。默认情况下,外部8MHz晶振经过PLL倍频,最终得到72MHz的系统主频SYSCLK。
系统主频SYSCLK经过AHB预分频器得到APB1和APB2总线的时钟。其中APB2最高支持72MHz,挂载着GPIO、USART1、ADC这些高速外设;APB1最高只有36MHz,挂载着定时器、USART2/3、I2C、SPI这些外设。
这里有一个非常经典的坑:APB1总线频率是36MHz,但定时器的时钟会被自动倍频到72MHz。也就是说,你用定时器做定时或者PWM的时候,计数器的时钟输入是72MHz,而不是36MHz。很多人计算定时器重装载值的时候搞错这个系数,导致定时时间差了一倍。
用标准外设库开发时,RCC_Configuration函数里那一大堆RCC_APB2PeriphClockCmd、RCC_APB1PeriphClockCmd,就是在开启对应外设的时钟。千万不要忘记开时钟,在STM32上,外设时钟默认是关闭的,你直接操作外设寄存器是没有任何反应的。这是从51单片机转过来的人最容易犯的错误,51单片机的外设时钟是常开的。
注意:初始化任何一个外设之前,先确认它的时钟已经开启。这个顺序错了,后面所有配置都是白搭。
2.4 GPIO、USART、定时器、ADC四大核心外设
GPIO是STM32最基础的外设,每个端口有32个配置寄存器控制其模式。每个引脚可以配置为输入模式(浮空、上拉、下拉、模拟)或输出模式(推挽、开漏、复用推挽、复用开漏)。实际项目里最常用的是推挽输出和上拉输入。推挽输出可以提供较大的灌电流和拉电流,直接驱动LED没有问题。
USART串口是嵌入式开发的“眼睛”。调试的时候,把关键变量的值通过串口打印到电脑上看,是最简单高效的调试手段。F103的USART支持115200等常用波特率,配置时注意数据位、停止位、校验位的设置要和上位机完全一致。我自己习惯用8位数据、1位停止位、无校验的标准配置,兼容性最好。
定时器是F103里最灵活的外设。除了基本的定时功能,还能输出PWM、捕获输入脉冲、编码器接口等。通用定时器TIM2-TIM5是16位计数器,有4个独立通道。做PWM输出时,通过修改捕获比较寄存器CCR的值来改变占空比,方向控制电机或者调节LED亮度都用这个方案。
ADC模块有12位分辨率,支持多通道扫描和连续转换。F103的ADC最快采样频率可以到1MHz左右,对于大多数传感器采集场景都够用。配置ADC的时候要注意采样时间的设置,如果信号源内阻较大,采样时间太短会导致采样值不稳定,这时候可以适当加大采样周期。
3. 实操过程与核心环节实现
3.1 开发环境搭建的选型建议
现在做STM32F103开发,主流方案有三个:Keil MDK、IAR EWARM、STM32CubeIDE加HAL库。
我个人推荐新手用Keil MDK加标准外设库入门。原因很简单:网上现成的例程最多,遇到问题最容易被搜索到。虽然Keil的界面比较老旧,但稳定性和调试体验在这个级别芯片上没有任何问题。
STM32CubeIDE则是现在ST主推的官方工具,基于Eclipse,内置了STM32CubeMX图形化配置工具。它的优势是可以通过图形界面生成初始化代码,操作直观,适合完全不想看寄存器的人。但劣势也很明显,HAL库的抽象层级较厚,代码执行效率比标准外设库稍低,而且出了问题不好定位。
我的建议是:学习阶段用标准外设库,理解寄存器操作的本质;做产品或者参加比赛,可以直接用HAL库加快开发速度。两种工具链都值得掌握,切换起来并不困难。
3.2 最小工程模板的搭建步骤
搭建最小工程模板是每个入门者必须亲手做一遍的事。步骤如下:
第一步,准备固件库。把标准外设库解压后,复制Libraries目录下的STM32F10x_StdPeriph_Driver和CMSIS核心文件到你的工程目录。
第二步,在Keil里新建工程,选择芯片型号STM32F103ZET6(或者其他具体型号)。这里要特别注意,如果你的开发板是C8T6,就要选对应的型号,选择错误会导致编译报错或者下载异常。
第三步,添加启动文件、系统文件和外设库源文件到工程里。启动文件要根据芯片容量选择对应版本,比如ld.s代表低密度,md.s代表中等密度,hd.s代表高密度。
第四步,配置C/C++编译选项,在Define栏填写USE_STDPERIPH_DRIVER,这是让标准外设库生效的关键宏定义。同时把Include Path指向所有头文件所在的目录。
第五步,编写main.c文件,先实现一个简单的GPIO翻转程序,编译通过后烧录测试。这一步跑通,你的工程模板就建立起来了。
提示:工程目录里不要用中文路径和空格,有些编译器对中文路径支持不好,会出现莫名其妙的编译错误。
3.3 点灯程序全流程讲解
点灯实验是嵌入式界的“Hello World”,下面给一份标准外设库版的GPIO输出示例:
#include "stm32f10x.h" void GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); } int main(void) { GPIO_Config(); while(1) { GPIO_SetBits(GPIOB, GPIO_Pin_0); delay(); GPIO_ResetBits(GPIOB, GPIO_Pin_0); delay(); } }这段代码的逻辑很清晰:先开GPIOB的时钟,然后配置Pin0为推挽输出模式,速度设为50MHz。主循环里设置高电平、延时、设置低电平、延时,LED就闪烁起来了。
这里解释三个细节。GPIO_Speed参数是配置IO口的翻转速率,不是必须拉满,实际需要多快就配多快,这样可以降低功耗和电磁干扰。GPIO_SetBits和GPIO_ResetBits是库函数,本质上是对GPIOx_BSRR寄存器的操作,分别实现置位和复位。
GPIO_Mode_Out_PP的意思是通用推挽输出。推挽模式下,引脚既能输出高电平也能输出低电平,驱动能力较强。如果是驱动LED,注意LED的正负极连接方向,根据开发板原理图判断是高电平点亮还是低电平点亮,很多新手在这里接反了灯却不亮。
3.4 标准外设库与寄存器操作如何选择
标准外设库的本质是用函数封装寄存器操作,让你不用每次手动查寄存器地址。但库函数并不是银弹,有些场景直接操作寄存器效率更高。
比如在定时器中断服务函数里清零标志位:
TIM_ClearITPendingBit(TIM2, TIM_IT_Update);这行代码实际只做了一件事——把TIM2_SR寄存器的UIF位清0。如果你对性能有极致要求,可以直接写:
TIM2->SR = (uint16_t)~TIM_IT_Update;效果完全一样,但省去了函数调用开销。
我给的建议是:学习阶段两种方式都写一遍。先用库函数写功能,再看库函数源码,找到它操作哪个寄存器,手动再实现一次。这样做完三五个实验,你对寄存器的理解会突飞猛进。以后看别人代码,库函数版本和寄存器版本都能一眼看懂。
4. 从点灯到实战——几个核心外设的编程套路
4.1 中断配置的完整流程
配置一个外部中断的步骤看起来复杂,拆解下来其实就四步。
第一步,初始化GPIO为输入模式。 第二步,把GPIO引脚连接到EXTI外部中断线。F103的PA0-PG0都对应EXTI0,但同一时刻只能有一个引脚占用某条EXTI线,这是新手最常踩的坑——PA0和PB0不能同时触发EXTI0。 第三步,配置NVIC中断优先级。 第四步,编写中断服务函数。
中断服务函数的名字不是随便起的,必须和启动文件里的向量表对应。比如EXTI0_IRQHandler对应EXTI0中断,TIM2_IRQHandler对应TIM2中断。名字写错了,编译器不会报错,但中断永远进不去。
另一个高频重点是,中断服务函数里必须手动清除中断标志位。比如EXTI中断处理完,要调用EXTI_ClearITPendingBit清除挂起位,否则中断会反复触发,程序表现就是“卡死”在主循环之外,看起来像死机了一样。
4.2 定时器从延时到PWM输出
使用定时器做延时,最核心的计算是确定预分频系数和重装载值。
假设系统时钟72MHz,想产生1ms的中断周期。先设置预分频器把72MHz分频到72kHz,那预分频系数就是72-1=71。然后计数器从0数到71就溢出,定时1ms,重装载值就是72-1=71。计算公式是:定时时间 = (预分频系数+1) × (重装载值+1) / 系统时钟频率。
PWM输出的配置大同小异,只是把定时器模式设置为PWM模式1或模式2,然后设置占空比。占空比由CCR寄存器的值决定,比如ARR重装载值是999,CCR值是499,那占空比就是49.9%。电机调速、LED呼吸灯效果都是这么实现的。
用定时器中断做精确延时,比软件延时(空循环)靠谱得多。软件延时受编译器优化等级影响很大,同一个delay函数在不同优化等级下延时时间会变化,而定时器延时是硬件计数,精准且可控。
4.3 串口通信与调试信息打印
串口是嵌入式开发最常用的调试工具。配置串口发送一个字节、一个字符串的代码,几乎是每个项目的基础设施。
标准外设库版串口初始化核心代码如下:
USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); USART_InitStructure.USART_BaudRate = 115200; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_Mode = USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE);注意,USART1挂载在APB2总线上,所以时钟开启函数要用RCC_APB2PeriphClockCmd。USART2和USART3挂载在APB1总线上,要用RCC_APB1PeriphClockCmd,弄混了代码会卡在“等待标志位超时”的地方。
调试信息打印有个小技巧:重定向标准库的fputc函数,让printf直接输出到串口。这样你就能用printf方便地打印变量值、调试信息,这在排查复杂逻辑问题时极其好用。方法是在工程里实现一个fputc函数,在里面调用串口发送函数即可。
4.4 ADC采集传感器数据的处理细节
做ADC采集时,最影响结果的是参考电压和采样时间。F103的ADC参考电压默认是VDDA,也就是电源电压,通常是3.3V。12位分辨率下,满量程是4095,所以理论分辨率约为0.8mV/位。
实际使用中,传感器输出的模拟信号往往有噪声。我的经验是:硬件上在ADC引脚对地加一个0.1uF的滤波电容;软件上做多次采样取平均值,比如连续采样10次去掉最大值最小值再求均值,这样能显著提高数据稳定性。
使用DMA配合ADC连续扫描模式是采集多通道数据的最佳实践。DMA可以把ADC转换结果自动搬运到内存数组里,不需要CPU逐个读取,大大降低了CPU开销。我在做多路传感器采集项目时,基本都是这套方案,稳得很。
5. 常见问题与排查技巧实录
5.1 烧录失败的常见原因与对策
用ST-Link下载程序时提示“No target connected”或者“Target DLL has been cancelled”,这是新手遇到最多的问题。
排查顺序一般是:检查下载器与开发板的连接线是否松动,SWD接口的SWDIO、SWCLK、GND三根线是必需的,然后检查开发板供电是否正常,再看Keil里的Debug配置是否选择了正确的下载器和接口类型。还要确认芯片是否被读保护,有些二手板子里面设置了读保护,导致无法连接,需要先用ST-Link Utility执行全片擦除解除保护。
这里分享一个我常用的备用方案:如果SWD连不上,把芯片的BOOT0引脚拉高,通过串口ISP方式下载程序,也能把芯片救回来。前提是你的板子引出了BOOT0引脚。
5.2 时钟配置错误的几种离奇表现
时钟配置错误是“问题表现最离谱”的一类故障。芯片能运行,但所有时序全部乱掉。典型症状包括:串口打印乱码、定时器时间不准确、PWM频率完全不对。
遇到这类问题,先冷静排查时钟树。确认外部晶振是否起振,PLL配置是否在合法范围内。用示波器或者逻辑分析仪测量MCO引脚输出的时钟信号,可以快速判断系统时钟频率是否正常。如果手头没有仪器,可以用一个最简单的办法:让LED翻转,用手机秒表数一下10秒内闪了多少次,通过计算反推时钟频率是否准确。
最常见的错误是把外部晶振频率配错了。F103的库函数模板默认外部晶振8MHz,但有些开发板用的是12MHz晶振,如果你没改配置就直接用,系统时钟会被超频到108MHz,芯片虽然还能跑,但外设时序全乱,这就是乱码和卡死的来源。
5.3 调试工具的使用心得
硬件调试不比纯软件调试,光靠眼睛看很难定位问题。我平时调试必用的几个工具是:串口助手、逻辑分析仪、万用表。
串口助手用来查看调试日志,我用得最多的是带时间戳的串口工具,这样可以观察某个事件发生的间隔是否稳定。逻辑分析仪用来抓取波形时序,比如验证PWM频率、测量通信协议时序是否满足要求,8通道的入门级逻辑分析仪就够用,价格不高但性价比极高。万用表则用来检查供电电压、引脚电平、通断,简单直接有效。
软件层面,Keil的调试器功能也不可忽视。可以在中断服务函数里打断点,通过Watch窗口查看变量的实时值。但要注意,硬件调试时打断点会影响实时性,如果是PWM输出或者通信协议,打断点会导致波形中断,这种场景下尽量用串口打印代替断点。
5.4 问题速查表
| 现象 | 常见原因 | 排查方向 |
|---|---|---|
| 程序烧录失败 | SWD接口连接不良、芯片读保护 | 检查连接线、解除读保护 |
| LED不亮 | GPIO模式配置错误、引脚接反 | 查看原理图确认电平逻辑 |
| 串口乱码 | 波特率不匹配、时钟配置错误 | 核对波特率与时钟树配置 |
| 中断进不去 | 中断函数名错误、标志位未清除 | 对照启动文件检查函数名 |
| 定时不准确 | APB1倍频系数错误 | 确认定时器时钟是否72MHz |
| ADC数值跳动 | 采样时间过短、无滤波 | 增加采样周期和软件滤波 |
6. 再聊点关于学习路线的事
这本书拿在手里,我建议你先翻到目录,用荧光笔把“中断”“时钟”“串口”这三章重点标出来,这三个知识点是整个嵌入式开发的骨架。剩下的章节比如DMA、CAN、USB,等你真正用到的时候再深入研读也不迟。
我自己带过一些新人,发现大家特别容易陷入一个误区,就是想把所有外设都学完才开始做项目。其实大可不必。你就盯着一个明确的小项目,比如做一个温湿度监测终端,边做边学,哪里不会查哪里,做完一个项目,你对外设的掌握程度会远超那些“学完所有章节再动手”的同行。嵌入式这行,动手永远是第一位的。
最后说回赠书。这次介绍的这本《Arm嵌入式系统原理及应用——STM32F103微控制器架构、编程与开发》,我自己翻完的感受是:架构部分讲得系统,例程代码完整,适合作为手边常备参考书。参与方式很简单,评论区聊一聊你学习嵌入式过程中的困惑或者经验,我会挑选几条走心留言送出。希望拿到书的朋友能真的把它用起来,不是放在书架上吃灰。嵌入式学习没有捷径,但好书、好工具、好方法,能让你少走很多弯路。共勉。