前阵子朋友递给我一本系统讲Arm嵌入式系统原理及应用的书我花了一个周末翻完越看越觉得像是在把自己过去几年的STM32F103项目经验重新捋了一遍。这些年无论是带新人入门还是自己画板子调程序我都会先拿这颗芯片讲一遍架构和编程思路。原因很简单Cortex-M3机制足够简单又足够完整外设数量适中资料多踩了坑也容易查原因。这篇内容我打算从原理到实战把F103的架构、内存映射、时钟、GPIO、中断和定时器这些核心环节串起来讲再补上开发环境与调试中真实会遇到的问题。正在学Arm嵌入式或者用它做课设、毕设、产品原型的读者应该都能从中找到可以套用的思路。1. 为什么F103值得做Arm嵌入式的入门芯片1.1 内核是授权的外设是芯片厂商自己拼的很多人在接触STM32F103时会有一个困惑明明用的都是同一颗Cortex-M3内核为什么不同芯片的性能和外设差异这么大答案在于处理器核是从Arm授权的芯片厂商拿到内核后再在周边加入Flash、SRAM、GPIO、定时器、通信接口等外设。所以当我们说学STM32F103时其实在学两套东西一套是Cortex-M3内核如何取指、如何响应中断、如何访问存储一套是芯片厂商在F103上如何组织外设。后面这一套又分两层。第一层是每个外设的功能寄存器第二层是这些外设挂在哪条总线上、时钟怎么开、中断怎么进。F103之所以适合做教材级的芯片正因为它的外设布线非常规整寄存器设计沿用了一个功能一个寄存器位的经典风格。顺着芯片手册的寄存器列表走一遍基本就能知道怎么操作了。等以后再换别的Cortex-M3芯片你会发现套路完全一样只是地址和寄存器宽度有所变化。1.2 从命名看懂选型容量、封装与引脚F103的型号里有一串数字和字母很多人刚看时一头雾水。比如C8T6、RCT6、ZET6拆开看并不复杂中间部分的C、RC、RD、RE、ZC、ZE大致表示Flash和RAM容量差异T表示LQFP封装6表示工业级温度范围7是汽车级。入门阶段最常见的是C8T664KB Flash、20KB SRAM、64个引脚想用更多IO可以换RCT6或ZET6。我的经验是一开始不要追求大容量C8T6的资源覆盖了绝大多数基础实验不够用了再换大批次也完全来得及。选型之外还要学会看封装引脚图别只盯着功能框图。不少新手画完第一块板子后发现某个引脚不能按预期使用比如PC13、PC14、PC15默认和RTC、防侵入检测等功能关联想当普通IO用要先关闭相关备份域功能。这种细节通常要翻到引脚定义的补充说明才能看到。1.3 学习F103时最值得关注的三个层次我在带人时会把学习路径拆成三层。第一层是寄存器操作哪怕只是点亮一颗LED也要搞清楚GPIO的CRL、ODR、BSRR各是什么含义。第二层是外设协作比如串口怎么和DMA配合定时器怎么触发ADC采样这层开始真正接触嵌入式系统的时序与并发。第三层是调试能力会看启动文件、会查向量表、会用调试器在中断现场看寄存器值。很多人容易跳过第二层直接背库函数。但库函数把外设协作包装得太圆滑出了问题反而无从下手。F103的寄存器不算多手册也不算厚建好这层心智模型之后再上HAL库、实时操作系统都会顺很多。2. Cortex-M3的骨架寄存器、工作模式与中断响应2.1 程序员视角下的寄存器组从软件角度看Cortex-M3有一组清晰的寄存器。r0到r12是通用寄存器r13是堆栈指针SPr14是链接寄存器LRr15是程序计数器PC。和更早的ARM7、ARM9不同Cortex-M3不再有ARM状态和Thumb状态来回切换的模型它原生执行Thumb-2指令集指令紧凑分支高效比老内核省了不少判断。堆栈指针部分有两个MSP和PSP。中断处理以及操作系统内核代码使用MSP普通任务代码可以使用PSP。这样设计是为了隔离在跑轻量级实时系统时任务栈和内核栈分开某个任务的栈被撑爆不至于直接把内核数据全部冲掉。裸机开发时只用MSP启动文件已经设置好了。LR这个寄存器在函数调用和中断返回里都有点特殊。正常函数返回时LR保存的是返回地址但进入中断后LR会被硬件写成一个特殊值EXC_RETURN比如0xFFFFFFF9表示返回后进入线程模式并且使用MSP。调试异常时看到这类地址说明当时正处于中断现场。理解这个机制后再去看RTOS的任务切换代码会瞬间明白上下文切换到底在搬什么东西。2.2 中断如何被自动接管Cortex-M3的中断响应相当省心。外设发出请求后NVIC会选择当前优先级最高的请求处理器自动把xPSR、PC、LR、r12以及r3到r0共8个寄存器的值压入当前栈然后从向量表中读出对应中断处理函数的地址并跳过去。返回时根据EXC_RETURN恢复对应栈指针再自动出栈。整个过程由硬件完成不需要软件保存现场也没有复杂的软件任务切换代码。这也是为什么向量表的设计如此重要。向量表里按中断编号存放各种异常入口地址最开始的0地址放的是栈顶指针不是复位后要执行的第一条指令地址紧接着是复位向量再往后是NMI、HardFault等异常然后是长长一排外设中断。初学时可以在调试器的内存窗口里把向量表地址对应的内容读出来和启动文件对一遍很多疑惑会当场解开。2.3 向量表与启动文件里的门道我在看启动文件时发现许多新手把它当成会跑就行的模板可一旦程序上电后卡在HardFault就完全不知道从哪里查。启动文件做的事其实很固定定义栈顶地址、填写向量表、复位后关闭中断、拷贝data段、清bss段、初始化堆栈最后跳进main。出问题时第一件事是看栈顶地址是否在SRAM范围内第二件事是看向量表里有没有错位。有一种常见错误是中断服务函数名称和启动文件里声明的不一致。比如启动文件预置了TIM2_IRQHandler而你写成了TIM2_IRQHandler命名少个下划线或者拼错一个字母链接器不会报错但中断触发时程序会跳到一个空死循环表现就是改了中断程序却不生效。遇到这类问题先从启动文件里的弱函数定义找起比对着代码猜快得多。3. 一眼看穿地址F103内存映射与总线配置的底层逻辑3.1 统一编址CPU眼里所有东西都是地址F103采用统一编址。Flash放在0x08000000开始的区域SRAM放在0x20000000外设寄存器放在0x40000000开始的区域。对CPU来说访问外设寄存器和访问普通内存没有任何区别只是地址对应到不同的物理设备。这也是为什么嵌入式C代码里可以直接对一个寄存器地址赋值比如*(volatile unsigned int *)0x40010800 | 0x00000001;就是一个合法操作它做的事本质上是往GPIOA某个寄存器对应的内存单元写值。看到0x40010800这种地址时我建议先拆着看0x40000000是外设总线基地址偏移0x00010800又可以细分成GPIOA的基地址加上寄存器偏移。芯片手册里每个外设都给了基地址和寄存器地址映射表学会用基地址加偏移查寄存器比死记硬背所有地址高效得多也能让你在任意厂家的头文件里快速找到需要的宏。编程时加上volatile也很有必要它告诉编译器不要对这个地址的访问做缓存优化不然明明改了寄存器值读回来却可能还是旧值。3.2 总线矩阵为什么外设还要分APB1和APB2F103内部有总线矩阵连接CPU、DMA、Flash接口、SRAM和外设总线。外设总线分两条APB1和APB2。APB1最大频率36MHz挂的是USART2、I2C、TIM2/3/4这些外设APB2最大频率72MHz挂的是GPIO、USART1、TIM1、ADC、SPI1。这样分不是随便安排的一部分外设本身工作频率上限低一部分为了降低整体功耗。真正需要注意的是配置波特率和定时器PWM频率时必须确认外设挂在哪条总线上。USART1和USART2的波特率寄存器计算方式相同但一个用APB2时钟一个用APB1时钟。如果默认时钟配置错了串口打印乱码或者波特率差一倍都是可能发生的现象。看芯片手册时我习惯先把每个外设属于哪条总线整理成一张表配置时直接对表省去很多排查时间。3.3 BOOT0/BOOT1和ISP下载的真相F103的启动方式由BOOT0和BOOT1引脚决定。BOOT0为低时从主Flash启动正常跑应用BOOT0为高、BOOT1为低时从系统存储器启动里面有一段芯片出厂固化的BootLoader可以通过串口把程序烧进去BOOT0为高、BOOT1为高时从SRAM启动适合调试启动过程。很多新手把BOOT跳线拨错按下下载键没反应多数就是启动方式被切到了系统存储器。用调试器下载时通常要求BOOT0保持低。但BOOT0是个复用引脚如果被当成普通IO用要注意它的电平状态会影响启动。设计最小系统板时我一般直接给BOOT0加下拉电阻到GND避免悬空。另外一个容易忽视的点是复位电路NRST引脚要接合理容值的电容否则上电时序不稳调试器也可能连不上。4. 时钟树不是玄学72MHz是怎么配出来的4.1 时钟源之间怎么选F103有多个时钟源。HSI是内部8MHz RC振荡器不需要外部晶振上电就能跑但精度一般频率受温度影响明显HSE是外部高速晶振通常接8MHz无源晶振精度高也是PLL的输入源LSI是内部低速时钟给独立看门狗和RTC用LSE是外部32.768kHz晶振主要给RTC做时基。所有外设时钟都从系统时钟SYSCLK出发经过AHB预分频器和APB预分频器再分配到各条外设总线和外设本身。用HAL库开发时时钟树配置会自动生成。但如果是寄存器或者标准外设库就要自己写SystemInit和RCC初始化函数。我的建议是先用图形化配置工具生成一套时钟树再打开生成的代码对照芯片手册里的时钟树框图看一遍这样既能快速配出正确时钟又能看懂每个分频值的来龙去脉。4.2 从8MHz到72MHz的完整计算挂一个8MHz的HSE想得到72MHz系统时钟完整链路是这样的8MHz的HSE进入PLLPLL倍频系数设成9得到PLLCLK 8MHz × 9 72MHz然后把PLLCLK作为SYSCLKAHB预分频为1保持72MHzAPB2分频为1也是72MHzAPB1分频为2得到36MHz。ADC时钟再经过预分频默认6分频后为6MHz刚好落在12MHz上限以内。这套数字就是几乎所有F103工程默认时钟树的模样。你在图形化配置工具里改动HSE频率时工具会自动重新计算PLL倍数。比如HSE用12MHz晶振PLL倍频器大概率会被配成6倍依然得到72MHz。理解这套源-倍频-分频流水线之后换到其它Cortex-M系列芯片时钟树再怎么变也只是换几个分频系数而已。4.3 时钟开启的常见坑忘开门控与HSE起振失败外设的时钟默认是关闭的使用某个外设前必须先把对应总线的门控时钟打开。标准外设库是RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);HAL库是__HAL_RCC_GPIOA_CLK_ENABLE();。忘开时钟是最常见的低级错误表现是操作寄存器毫无反应但程序不报错查半天才发现时钟没开。另一个坑是HSE起振失败。如果外部晶振没焊好、匹配电容太大或者两端焊盘短路程序会卡在等待HSE就绪的超时逻辑里或者自动回退到内部HSI。HSI的8MHz和HSE的8MHz作为PLL输入时最后倍频结果可能相同但HSI精度差、温漂大系统表现会时好时坏。排查时我一般用示波器看晶振引脚波形或者读RCC就绪标志若是匹配电容选得不对波形幅度会很低替换电容后明显改善。5. GPIO寄存器级点灯丢掉库函数看清引脚本质5.1 CRL和CRH到底在忙什么GPIOA、GPIOB这几个端口的基地址相邻每个端口内部有一组寄存器。配置引脚模式主要靠CRL和CRHCRL管引脚0到7CRH管引脚8到15每4位描述一个引脚的模式和输出属性。ODR是输出数据寄存器写1和0直接控制引脚电平。IDR读输入电平。BSRR可以在一个操作里把指定引脚置高或清低且位与位之间互不影响。BRR只负责清位。直接操作寄存器的点灯代码可以写成这样#define GPIOA_BASE (0x40010800UL) #define RCC_BASE (0x40021000UL) #define GPIOA_CRL (*(volatile unsigned int *)(GPIOA_BASE 0x00)) #define GPIOA_BSRR (*(volatile unsigned int *)(GPIOA_BASE 0x10)) #define RCC_APB2ENR (*(volatile unsigned int *)(RCC_BASE 0x18)) void led_init(void) { RCC_APB2ENR | (1 2); // 使能GPIOA时钟 GPIOA_CRL (GPIOA_CRL ~(0xF 4)) | (0x1 4); // PA1推挽输出 } void led_on(void) { GPIOA_BSRR (1 1); } void led_off(void) { GPIOA_BSRR (1 (1 16)); }这段代码里0x1写进PA1对应的4位区域表示50MHz推挽输出。HAL库的GPIO_Init底层也就是这几位寄存器的操作。看懂这层之后再回来看库函数源码就不会觉得它是什么黑魔法了。5.2 推挽、开漏和上下拉你会怎么选多数场景下点灯用推挽输出因为高电平能输出较大电流低电平时也能主动拉低。开漏输出则只做下拉、不做上拉高电平需要通过外部上拉电阻实现。最典型的应用是I2C总线多个设备共享一条线靠外部上拉实现线与逻辑另外电平转换电路也常用开漏因为可以直接通过不同的上拉电压完成电平匹配。输入模式里还有悬空、上拉、下拉之分。按键电路常见两种接法按键一端接地另一端接IO把IO配置成上拉输入按下时读到0或者按键一端接电源IO配成下拉输入按下时读到1。这两种都能用但要注意内部上拉电阻阻值比较大带不动强负载大电流场合必须外接电阻否则电平会被拉偏。5.3 从ODR到BSRR一个容易忽略的原子操作细节普通操作ODR是一种读-改-写流程想置位某一位先读ODR再按位或再写回去。如果在两个地方同时操作同一个端口中间插入一次读取逻辑就可能被破坏轻则引脚电平闪烁重则产生短脉冲。BSRR的高16位清位、低16位置位写1才动作写0不影响而且在硬件层面一步完成不会被中断插队打断。裸机点灯或者做多中断控制的工程我习惯都用BSRR而不是ODR。复用功能也是理解GPIO的关键。USART的TX、RXSPI的SCK、MISO、MOSI定时器的通道输出都要先把引脚模式配成复用推挽或复用开漏同时打开对应的复用时钟。用过HAL库的读者可能遇到过引脚没反应的情况排查到最后多半是忘了打开AFIO时钟或者把复用引脚当普通IO配了。这些坑在寄存器级代码里反而看得更清楚。6. NVIC与SysTick裸机程序的优先级游戏6.1 中断优先级的分组与配置NVIC是Cortex-M3的中断控制器。F103最多支持60来个可屏蔽中断通道但优先级寄存器的有效位宽是4位。这4位会被拆成抢占优先级和子优先级两部分具体怎么拆由AIRCR寄存器里的分组配置决定。抢占优先级高的中断可以打断抢占优先级低的同组或低组中断同组抢占优先级相等的两个中断之间不能互相打断只能按子优先级排队。用标准外设库配置时先调用分组函数如NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);再初始化中断源。很多人问中断优先级能不能随便设我的回答是不能只凭感觉。强实时任务放到高优先级没错但高优先级中断服务函数里绝不能做耗时工作否则低优先级任务可能被长期饿死。做电机控制这类项目时最内层的控制中断应该只做最少量的计算把通信、显示都挪到主循环或低优先级任务里。6.2 SysTick真正的裸机节拍器SysTick是一个24位递减计数器挂在处理器内核上不依赖外部引脚。它每次从重装载值递减到0会自动触发异常并把重装载值重新装入。裸机程序里常把它当作系统节拍产生1ms定时中断再在主循环里用全局计数变量做超时管理。设置重装载值的公式是重装载值 内核时钟频率 / 中断频率 - 1。比如内核时钟72MHz想要1ms中断一次重装载值就是71999。很多代码直接写SysTick_Config(SystemCoreClock / 1000);意思也是1ms中断一次。需要注意SysTick的时钟源可能是HCLK也可能是HCLK/8具体要看系统初始化里的分频设定。分频系数搞错延时要么变成8倍要么变成1/8数码管扫描和按键消抖都会露出破绽。6.3 中断服务函数的最短原则与调试手法中断服务函数的原则是尽快退出。里面尽量只做置标志位、收发少量数据、更新计数这些事耗时逻辑全部放到主循环去处理。我在裸机工程里习惯维护一个事件标志结构体每个中断置自己的位主循环轮询标志再执行对应动作。这种风格不花哨但非常稳排查问题时也清晰。调试中断问题时最难受的是中断标志没清导致重复进入中断。外部中断没清pending位代码看起来就像反复跳进中断。清标志这个动作建议在执行完必要操作后立刻做而不是放在函数末尾。另外可以在中断入口加一个计数器变量串口或调试器观察它的增长能迅速判断中断是否误触发。在线调试时查看NVIC的优先级分组和各中断使能位也是常规操作。7. 定时器、PWM与编码器接口的实战取舍7.1 高级、通用与基本定时器怎么分工F103的定时器大致分三类。TIM1和TIM8是高级控制定时器支持互补输出和刹车信号适合电机、逆变这类控制场景TIM2到TIM5是通用定时器支持PWM、输入捕获、输出比较和编码器接口日常项目里用得最多TIM6和TIM7是基本定时器只能做定时和时基没有外部IO。做原型时我常让TIM3产生PWMTIM2做输入捕获TIM4做系统时基分工很清晰。选择哪颗定时器先看引脚复用表而不是随便挑。不是所有通道都能映射到需要的那根引脚上尤其同时需要使用多个通道时重新映射和引脚冲突经常发生。芯片手册里的复用功能表值得在画板子前反复对照。7.2 占空比、频率与分辨率先算好再填寄存器PWM输出的两个关键寄存器是ARR和CCR。占空比 CCR / (ARR1)频率 定时器时钟 / ((PSC1) × (ARR1))。比如72MHz定时器时钟想要20kHz PWM可以把PSC设为0算出ARR约等于3599再根据想要的占空比设CCR。先拿计算器算好再填代码比在库函数里盲试快得多。有一类问题很典型PWM频率是1kHz但占空比调节步进特别粗。原因在于ARR被设得太小了。ARR1决定了一个周期里的计数台阶数频率固定时ARR越小台阶越少能调的占空比精度就越差。想提高分辨率要么降低频率要么提高定时器输入时钟。这是设计任意PWM时都要摆上桌面的权衡。修改ARR和CCR的时机也值得注意。边修改边输出PWM波形可能抖动甚至出现毛刺。F103定时器支持预装载模式配置ARPE位后ARR和CCR的新值会在更新事件到来时才锁存到实际寄存器。动态调频或调占空比时这个位尤其重要。7.3 输入捕获与编码器模式少写一整个计数器电路定时器除了产生PWM还能测量外部信号的频率和脉宽。输入捕获模式下引脚上的边沿到来时定时器计数值被锁存到捕获寄存器里同时触发事件。把两次捕获值相减再结合定时器频率周期或脉宽就出来了。测速、测距、遥控信号解码都是这个思路。编码器接口是通用定时器另一个杀手锏。将正交编码器AB相的脉冲接到定时器CH1和CH2配置成编码器模式定时器会自动根据两路信号的相位判断方向并计数配合Z相脉冲可以做机械零点定位。做小车和机器人项目时直接用定时器读电机位置省掉外部计数器芯片整个电路和代码都干净很多。这类外设在很多教材里篇幅不长但实际项目价值极高强烈建议动手调一遍。8. 开发环境与调试实录你会遇到的几个事故现场8.1 环境选型寄存器、标准外设库还是HAL初学阶段我建议至少用寄存器或标准外设库完整走一遍GPIO、中断、定时器、USART然后再切入HAL库和图形化配置工具。不是HAL不好而是它封装得太彻底。你要是连底层的CRL、BSRR、NVIC分组都没摸过出问题时只能瞎猜。HAL同样不是终点在低功耗、高安全或者性能敏感的场合直接写寄存器或者自己封装一部分仍然是更可控的。开发环境的选择可以用顺手的主流嵌入式IDE配一个几十元的基础调试器调试体验够用。如果想省去授权问题芯片厂商官方的免费IDE配图形化配置工具也是不错的选择。我的倾向是先把手头环境用熟把原理搞懂不要频繁换工具不然学习成本都消耗在环境折腾上。8.2 SWD、串口与联不上芯片的排查顺序调试接口常见有JTAG和SWD两种。JTAG占用五根线SWD只要SWDIO、SWCLK和地线就能完成下载与在线调试所以我几乎所有板子都留SWD。但要注意F103的SWDIO默认是PA13SWCLK是PA14这两只脚一旦在程序里被配置成普通GPIO输出或复用功能调试器下一次就可能连不上芯片。遇到连接不上目标的情况这个原因排第一。我的排查顺序固定整套先看调试器与板子物理连接GND必须共地再确认目标供电正常、BOOT0为低然后尝试按住复位键在点击下载的瞬间松开复位键最后再考虑换一根更短的杜邦线。串口打印也常用重定向printf时记得选对微库选项同时确认波特率分频与时钟匹配。调试日志不要在中断里疯狂输出否则程序会被拖慢得面目全非。8.3 三个我印象最深的翻车现场第一个是晶振不振。一块自制的板子焊了8MHz晶振程序在内部HSI下能跑一旦切到HSE就卡住。最后发现是匹配电容选小了两档晶振起振不稳定。换到标称值附近后波形恢复正常。这种问题纯靠改软件解决不了必须回到电路设计上找原因。第二个是断电重启后程序乱跳。后来发现复位引脚没接上拉电阻环境里的干扰信号会把芯片误复位。在NRST引脚加上电容和上拉后恢复正常。第三个是RS485通信异常查到最后是总线A、B间的偏置电阻不匹配跟程序一点关系都没有。这三个案例说明嵌入式调试不能只看代码电源、时钟、复位、引脚电气特性都要当成线索。咬死一定是软件问题这个念头往往最耽误时间。9. 学完F103之后我给自己的三条选择9.1 从裸机到轻量级系统补上并发的思维F103的资源跑轻量级实时操作系统完全够用。用任务、线程的方式重新组织点灯、通信和控制逻辑和裸机写法很不一样。芯片速度有限任务切换、优先级翻转这些问题很快就能真实感受到。我建议在F103上完整跑一个多任务例程把任务创建、互斥量、信号量各调一遍再决定要不要往更复杂的系统演进。这一步补的不是API用法而是并发的思维。9.2 再往上走新的Cortex-M家族怎么过渡F103属于Cortex-M3往上还有Cortex-M4、M7、M33这些新内核。它们多了浮点单元、MPU、TrustZone以及更多调试组件主频也更高。但你在F103上建立的内核架构、外设总线、时钟树、中断系统这套心智模型是通用的。换个芯片依然从芯片手册的内存映射这一章开始读依然先看时钟树再看引脚复用表只是寄存器细节变了。会看F103的手册换到其它芯片也不会慌。9.3 技术之外硬件调试的思路才是真正的财富我在实际项目里最受用的不是某个库函数而是先定位现象再读芯片手册再做最小化实验这条路径。F103资料多、问题稳定是练习这套方法论最好的地方。你在一颗几块钱的芯片上能把问题定位到晶振电容、SWD引脚被复用、定时器分频错误这些细节点以后遇到更底层的故障也会知道先从哪里下手。学F103的目的从来不是为了永远用这颗芯片而是让自己变成一个能独立解决嵌入式问题的人。最后说句个人体会。很多人疯狂收集教程、收藏代码却始终没有完整写一遍寄存器点灯也没有亲手配一次时钟树。真正的收获发生在你把某个地址写进代码、然后通过调试器看到引脚电平翻转的那一刻。别急慢即是快。