1. 为什么第八章值得单独拎出来讲
玩STM32F103的朋友,前面几章大概率已经把GPIO、时钟树、中断向量表这些基础外设摸过一遍了。到了第八章这个位置,通常意味着你开始从"点灯、按键、串口打印"这类验证性实验,往"真正让芯片按我的意图干活"的方向走。这一章涉及的内容,在寄存器手册里往往对应着定时器(TIM)与相关时基单元的深度配置,或者ADC采样与DMA搬运这类需要多外设联动的模块。不管具体是哪一块,第八章的共同特征是:它不再是单一寄存器的读写,而是多个寄存器之间有时序依赖的协同工作。
我见过太多人卡在这个阶段。前面几章照着例子改改参数就能跑,到了第八章,代码烧进去没反应,示波器一测波形不对,或者数据总是差那么一点。问题往往不在某一行代码写错了,而在于没有理解寄存器之间的"先后顺序"和"使能时机"。比如定时器的预分频器和自动重装载寄存器,你什么时候写、什么时候产生更新事件、影子寄存器什么时候生效,这些细节手册上写得分散,例子代码又不解释,导致很多人是"抄对了但不知道为什么对"。
这篇内容就是想把第八章这类"多寄存器协同"的模块,用白话拆开讲清楚。我会以通用定时器TIM的时基配置为主线,因为它是第八章最典型的代表,也是后续PWM输出、输入捕获、编码器接口的基础。同时会穿插ADC与DMA联动时寄存器配置的先后逻辑,因为这两块在第八章经常一起出现。适合已经能跑通基础例程、但想真正搞懂"为什么这么配"的嵌入式开发者,也适合那些被寄存器手册的表格绕晕、想找一条清晰路径的人。
提示:本文所有寄存器名称和位定义均以STM32F103参考手册的通用定时器章节为准,不同型号可能有细微差异,实际开发请以手头芯片的官方文档为最终依据。
2. 时基单元的三个核心寄存器到底怎么配合
2.1 预分频器PSC:不是简单的除法器
很多人第一次看预分频器(TIMx_PSC)的时候,会觉得它就是个分频系数,写个72进去,72MHz就变成1MHz。这个理解不算错,但漏掉了两个关键点:它有一个缓冲寄存器,而且写入后不会立即生效。
TIMx_PSC是一个16位的寄存器,实际参与计数的并不是你写进去的那个值,而是PSC寄存器 + 1。也就是说,你写0,分频系数是1;写1,分频系数是2。这个"+1"的细节,在计算定时时间的时候如果忘了,结果就会差一个计数周期。我刚开始学的时候,算出来总是比实际少一点,查了半天才发现是这里。
更关键的是缓冲机制。当你修改TIMx_PSC的值时,新值不会马上影响当前正在进行的计数,而是等到**下一个更新事件(UEV)**发生时,才从缓冲寄存器加载到实际工作的预分频器里。这个设计是为了防止在计数过程中改变分频比导致输出波形出现毛刺。所以如果你在运行中动态改PSC,必须手动产生一次更新事件,或者等它自然溢出,新分频比才会生效。
// 假设系统时钟72MHz,想要1kHz的定时器溢出频率 // 分频后计数频率 = 72MHz / (PSC+1) // 溢出频率 = 分频后计数频率 / (ARR+1) // 取PSC=71,则计数频率=1MHz;取ARR=999,则溢出频率=1kHz TIM3->PSC = 71; // 预分频值,实际分频系数72 TIM3->ARR = 999; // 自动重装载值,实际计数1000次 TIM3->EGR |= 1; // 产生更新事件,让PSC和ARR的缓冲值立即加载上面这段代码里,最后那行TIM3->EGR |= 1就是手动触发更新事件。如果你不写这行,PSC和ARR会在第一次溢出后才生效,第一次的定时时间会不对。这个坑我在做精确延时的时候踩过,第一次中断总是来得比预期早或晚,后来加上这行才稳定。
2.2 自动重装载寄存器ARR:决定周期的那个数
ARR寄存器决定了计数器数到多少就溢出。它和PSC一样,也有影子寄存器(缓冲寄存器)。在向上计数模式下,计数器从0数到ARR的值,然后产生溢出事件,重新从0开始。所以实际的计数次数是ARR + 1。
这里有个容易混淆的地方:ARR的值和计数器的位数有关。STM32F103的通用定时器TIM2到TIM5是16位的,ARR最大65535。如果你需要更长的定时周期,要么加大PSC,要么用级联的方式。我见过有人直接把ARR写成100000,编译不报错,但实际只取了低16位,结果定时完全不对。这种问题在调试时很隐蔽,因为代码看起来没问题。
ARR还有一个重要特性:它可以被预装载。通过TIMx_CR1寄存器的ARPE位控制。如果ARPE=0,你写ARR的值立即生效;如果ARPE=1,新值在下一个更新事件时生效。在PWM输出时,通常要把ARPE置1,这样你在修改占空比或者频率时,不会出现波形断裂。而在单纯的定时中断里,ARPE可以置0,让新周期立即生效。
2.3 计数器CNT:你读到的值可能不是你以为的
CNT是当前计数器的值,可以读也可以写。读的时候要注意,如果定时器正在运行,读出来的值可能在你读的瞬间就变了。对于16位定时器,一次读取是原子的,问题不大;但如果你要读多个相关寄存器的值做计算,最好先停止计数或者用更新事件同步。
写CNT的时候,它会立即改变当前计数值。这个功能在需要同步多个定时器时很有用。比如你有两个定时器需要相位差90度,可以先配置好主定时器,然后在从定时器里直接写CNT的初始值。但要注意,写CNT不会产生更新事件,也不会影响PSC和ARR的缓冲。
这三个寄存器的配合关系,可以用一个生活化的类比来理解:PSC像是水龙头的阀门开度,决定水流的速度;ARR像是水桶的容量,决定多久装满溢出一次;CNT就是当前水位。你拧阀门(改PSC)的时候,水流不会瞬间变化,要等水桶下一次装满(更新事件)才按新开度来。这个类比虽然不严谨,但能帮你记住缓冲机制的存在。
3. 定时器初始化顺序:为什么你的代码时好时坏
3.1 时钟使能必须放在最前面
这是老生常谈,但第八章的模块往往涉及多个外设,时钟使能的顺序就变得重要了。比如你要用TIM3触发ADC,那么TIM3的时钟和ADC的时钟都要先使能,而且通常建议先使能定时器时钟,再配置定时器,最后使能ADC时钟并配置ADC。虽然STM32的时钟使能本身没有严格的先后要求,但如果你在配置某个外设时它的时钟还没开,写寄存器是无效的,而且不会报错。
// 正确的顺序:先开时钟,再配置 RCC->APB1ENR |= 1 << 1; // 使能TIM3时钟 RCC->APB2ENR |= 1 << 9; // 使能ADC1时钟 // 然后再去写TIM3和ADC1的寄存器我遇到过一种情况:代码在调试器里单步执行时正常,全速运行就不行。后来发现是因为时钟使能写在了配置之后,单步时因为调试器的延迟,时钟已经稳定了,全速时配置写下去的时候时钟还没准备好。虽然这种情况在F103上不常见,但在一些低功耗唤醒场景下确实会出现。养成"先时钟后配置"的习惯,能避免很多玄学问题。
3.2 配置PSC和ARR的时机
在定时器还没启动(CEN=0)的时候,你可以直接写PSC和ARR,然后手动产生一次更新事件,让值加载到影子寄存器。这个顺序是:
- 写TIMx_PSC
- 写TIMx_ARR
- 设置TIMx_EGR的UG位,产生更新事件
- 清除更新标志TIMx_SR的UIF位
- 配置中断(如果需要)
- 设置TIMx_CR1的CEN位,启动计数
第4步很容易被忽略。因为第3步产生更新事件后,UIF标志会置1,如果你开了更新中断,就会立刻进一次中断。这次中断是不必要的,而且如果你在中断里做了计数或者状态切换,就会多算一次。所以产生更新事件后,要手动清一下UIF。
TIM3->PSC = 71; TIM3->ARR = 999; TIM3->EGR |= 1; // 产生更新事件 TIM3->SR &= ~1; // 清除更新中断标志 TIM3->DIER |= 1; // 使能更新中断 TIM3->CR1 |= 1; // 启动定时器3.3 中断优先级的分组问题
第八章的模块经常需要中断,而STM32F103的中断优先级分组是一个全局设置。如果你在项目里用了多个中断,比如定时器中断和串口中断,那么优先级分组必须在任何中断使能之前设置好。我见过有人在初始化定时器中断之后才去设置NVIC分组,结果优先级完全乱了。
// 在main函数最开始,任何外设初始化之前设置分组 NVIC_SetPriorityGrouping(3); // 4位抢占优先级,0位子优先级 // 然后再去配置各个外设的中断分组的选择取决于你的应用。如果中断不多,用分组3(全部是抢占优先级)最简单,谁的数字小谁优先,不支持嵌套。如果需要嵌套,就用分组2或者分组1。这个设置是全局的,改一次影响所有中断,所以要在项目初期就定好。
4. 从寄存器到实际波形:验证时基配置的完整链路
4.1 用GPIO翻转来验证定时精度
配置完定时器,怎么知道它真的按你算的时间在跑?最直接的办法是在中断里翻转一个GPIO,然后用示波器或者逻辑分析仪看波形。但这里有个细节:中断响应本身有延迟,所以GPIO翻转的频率会比你计算的溢出频率略低,而且抖动会比较大。如果你要测的是微秒级的精度,这个方法只能看个大概。
更准确的方法是使用定时器的PWM输出模式,让硬件直接控制引脚,不经过中断。这样测出来的波形就是定时器真实的输出。具体做法是把定时器配置成PWM模式,ARR决定周期,CCR决定占空比,然后测量引脚上的方波频率。如果频率和你计算的完全一致,说明PSC和ARR的配置是对的。
// 用TIM3的通道1输出PWM,引脚为PA6 // 假设系统时钟72MHz,目标频率1kHz,占空比50% TIM3->PSC = 71; // 计数频率1MHz TIM3->ARR = 999; // 周期1ms,频率1kHz TIM3->CCR1 = 500; // 占空比50% TIM3->CCMR1 |= 0x60; // 通道1配置为PWM模式1 TIM3->CCMR1 |= 1 << 3; // 预装载使能 TIM3->CCER |= 1; // 通道1输出使能 TIM3->CR1 |= 1 << 7; // ARPE使能 TIM3->CR1 |= 1; // 启动定时器这段代码里,CCMR1的配置是关键。0x60表示PWM模式1,即计数器小于CCR时输出有效电平。那个1 << 3是OC1PE位,使能预装载,这样你改CCR的时候不会立即生效,而是等更新事件。CR1的1 << 7是ARPE位,使能ARR预装载。这两个预装载在PWM输出时建议都打开,波形会更干净。
4.2 输入捕获模式下的寄存器联动
如果你要用定时器测量外部信号的频率或脉宽,就要用到输入捕获。第八章的输入捕获配置比PWM输出更复杂,因为它涉及边沿检测、输入滤波、预分频器等多个环节。核心寄存器是TIMx_CCMR1和TIMx_CCER。
输入捕获的流程是:外部信号从引脚进来,经过边沿检测器,触发捕获事件,当前CNT的值被锁存到TIMx_CCR1里,同时相应的标志位置1。如果你开了中断,就可以在中断里读CCR1的值。这里要注意,捕获到的值是两个边沿之间的计数值差,你需要用第二次捕获的值减去第一次的值,才能得到脉宽对应的计数。
// 配置TIM3通道1为输入捕获,上升沿触发 TIM3->CCMR1 &= ~0xFF; // 清除通道1配置 TIM3->CCMR1 |= 0x01; // CC1S=01,通道1配置为输入 TIM3->CCMR1 |= 0x00; // IC1PSC=00,不分频 TIM3->CCMR1 |= 0x00; // IC1F=0000,无滤波 TIM3->CCER &= ~(1 << 1); // 上升沿触发 TIM3->CCER |= 1; // 使能捕获 TIM3->DIER |= 1 << 1; // 使能CC1捕获中断 TIM3->CR1 |= 1; // 启动定时器滤波器的配置要根据信号质量来定。如果信号有毛刺,可以设置IC1F为几个时钟周期的滤波。但滤波会引入延迟,对于高频信号要谨慎。我一般先用无滤波跑通,再根据实际波形决定是否加滤波。
4.3 更新事件与捕获事件的优先级冲突
当定时器同时使能了更新中断和捕获中断时,如果两个事件同时发生,捕获事件的优先级高于更新事件。这是硬件决定的,不需要你配置。但如果你在更新中断里做了比较耗时的操作,可能会影响捕获的实时性。所以如果捕获精度要求高,更新中断里的代码要尽量短,或者把更新中断的优先级设得比捕获低。
这个细节在手册里没有特别强调,但实际调试时会遇到。我曾经用输入捕获测一个频率较高的信号,同时开了更新中断做计时,结果捕获到的值偶尔会跳变。后来把更新中断的优先级降低,问题就消失了。
5. ADC与DMA联动时那些手册没明说的顺序
5.1 为什么ADC的校准必须在使能之前
第八章如果涉及ADC,那么校准这一步是绕不开的。STM32F103的ADC有一个自校准功能,可以消除内部电容的误差。但校准必须在ADC使能之前做,而且校准完成后要等校准状态位清零才能继续。
ADC1->CR2 |= 1; // 先给ADC上电 for(volatile int i=0; i<1000; i++); // 等待稳定 ADC1->CR2 |= 1 << 2; // 启动校准 while(ADC1->CR2 & (1 << 2)); // 等待校准完成 // 校准完成后才能配置规则通道和启动转换这个顺序如果搞反了,校准不会生效,采样值会有固定偏差。我见过有人把校准放在ADC使能之后,结果采出来的电压总是差几十毫伏,查了很久才发现是校准没做对。
5.2 DMA请求的使能时机
ADC和DMA联动时,DMA的配置要在ADC启动之前完成,但DMA通道的使能要在ADC的DMA请求使能之后。这个顺序有点绕,我画个简单的逻辑:
- 配置DMA通道:源地址、目标地址、传输长度
- 使能ADC的DMA请求(ADC1->CR2的DMA位)
- 使能DMA通道(DMA1_Channel1->CCR的EN位)
- 启动ADC转换
如果第3步在第2步之前,DMA可能在ADC还没准备好时就触发了,导致传输错误。这个坑我在做多通道ADC采样时踩过,数据总是错位,后来调整了顺序才正常。
5.3 采样时间与通道切换的配合
ADC的采样时间寄存器SMPR决定了每个通道的采样周期。如果采样时间太短,采样电容还没充到信号电压就结束了,结果会偏小。对于高阻抗信号源,采样时间要加长。但采样时间加长会降低转换速率,所以要在精度和速度之间权衡。
多通道扫描时,每个通道的采样时间可以单独设置。但要注意,通道切换后需要一定的稳定时间,如果采样时间设得太短,通道之间的串扰会很明显。我一般会在通道切换后加几个时钟周期的延时,或者把采样时间设成239.5周期(最大值),牺牲速度换稳定。
6. 调试第八章模块时我踩过的三个坑
6.1 影子寄存器导致的"改了没反应"
前面提过PSC和ARR有缓冲,但CCR也有。在PWM模式下,如果你改了CCR的值,但OC1PE位没使能,新值会立即生效,可能导致当前周期出现一个异常窄或异常宽的脉冲。如果OC1PE使能了,新值在下一个更新事件生效,波形就平滑。这个"改了没反应"或者"改了有毛刺"的现象,根源就在预装载使能位。
我的经验是:凡是需要在运行中动态修改的寄存器,都先确认它的预装载使能位。PSC、ARR、CCR都有对应的预装载控制位,在CR1或CCMR寄存器里。配置的时候多花几秒钟检查一下,能省掉后面几小时的调试。
6.2 中断标志清除的时机
定时器的中断标志清除有一个原则:先读SR,再读或写相关寄存器,最后清标志。但很多人是直接写0清标志,这在大多数情况下没问题,但在某些边界条件下会丢中断。比如你正在处理更新中断,清UIF的时候如果又发生了一次更新事件,那个新的中断就丢了。
更稳妥的做法是:在中断服务函数里,先处理业务逻辑,最后清标志。如果业务逻辑耗时较长,可以在处理前先清一次标志,处理完再检查一次。这个技巧在高速中断里很有用。
6.3 时钟树配置对定时器的影响
STM32F103的定时器时钟来源不是固定的。APB1上的定时器(TIM2到TIM7)有一个倍频器:如果APB1的分频系数是1,定时器时钟等于APB1时钟;如果APB1的分频系数大于1,定时器时钟是APB1时钟的2倍。这个细节在时钟树章节里,但第八章配置定时器时如果忘了,算出来的定时时间就会差一倍。
比如系统时钟72MHz,APB1分频系数为2,那么APB1时钟是36MHz,但TIM2到TIM7的时钟是72MHz。如果你按36MHz去算PSC和ARR,实际定时时间会是你预期的一半。这个坑非常隐蔽,因为代码逻辑完全正确,只是时钟频率搞错了。
注意:APB2上的定时器(TIM1和TIM8)没有这个倍频器,它们的时钟直接等于APB2时钟。配置高级定时器时不要套用通用定时器的时钟计算方式。
7. 把第八章的知识串起来:一个完整的采样与输出案例
假设你要做一个简单的信号发生器加采集系统:用TIM3产生PWM波输出,同时用TIM4触发ADC采样,ADC通过DMA把数据搬到内存。这个案例把第八章的几个核心模块都串起来了。
第一步,配置TIM3为PWM输出,频率1kHz,占空比可调。第二步,配置TIM4为更新事件触发,频率10kHz,作为ADC的触发源。第三步,配置ADC1为外部触发模式,触发源选TIM4的更新事件,开启DMA请求。第四步,配置DMA1通道1,把ADC1的数据寄存器搬到内存数组。第五步,启动DMA,启动ADC,启动TIM4,最后启动TIM3。
这个顺序里,TIM4必须在ADC之前启动,否则ADC等不到触发信号。而DMA必须在ADC之前使能,否则ADC转换完成后的DMA请求没人响应。TIM3可以最后启动,因为它只负责输出,不参与采集链路。
// 简化版的关键配置顺序 // 1. DMA配置 DMA1_Channel1->CPAR = (uint32_t)&ADC1->DR; DMA1_Channel1->CMAR = (uint32_t)adc_buffer; DMA1_Channel1->CNDTR = 100; DMA1_Channel1->CCR = 0x25A; // 优先级高,16位,循环模式,读存储器,使能 // 2. ADC配置 ADC1->CR2 |= 1 << 20; // 外部触发,TIM4更新 ADC1->CR2 |= 1 << 8; // 使能DMA ADC1->CR2 |= 1; // 使能ADC // 3. TIM4配置 TIM4->PSC = 71; TIM4->ARR = 99; // 10kHz TIM4->EGR |= 1; TIM4->SR &= ~1; TIM4->CR1 |= 1; // 启动TIM4 // 4. TIM3配置 TIM3->PSC = 71; TIM3->ARR = 999; TIM3->CCR1 = 500; TIM3->CCMR1 |= 0x60; TIM3->CCER |= 1; TIM3->CR1 |= 1 << 7; TIM3->CR1 |= 1; // 启动TIM3这个案例里,每个模块的寄存器配置都不复杂,但顺序错了就出不来结果。我建议你在调试这类联动系统时,先用示波器确认TIM4的输出,再确认ADC的DR寄存器有没有在变,最后看DMA的内存数组。一步一步来,比一次性全开然后抓瞎要快得多。
8. 关于寄存器手册的阅读方法
最后分享一个我读寄存器手册的习惯。第八章这类多寄存器协同的章节,不要从第一个寄存器按顺序读到最后一个。先找到你需要的功能对应的"配置流程图",很多手册在章节开头会有一个框图,标明了信号从哪来、经过哪些寄存器、到哪去。顺着这个框图去查每个寄存器的位定义,比线性阅读效率高得多。
另外,手册里的"位定义表"要重点看复位值和读写属性。有些位是只读的,你写它没反应;有些位是"读/写1清除",你写0没用。这些细节在表格的备注栏里,但很容易被忽略。我一般会把关键寄存器的位定义抄在代码注释里,配置的时候对照着看,减少出错。
寄存器操作没有捷径,但理解每个位背后的硬件逻辑之后,你会发现它比库函数更直接、更可控。第八章是一个分水岭,过了这一关,后面看任何外设的寄存器都不会再发怵。