资讯详情

资讯详情

STM32寄存器白话手册:从GPIO到串口,搞懂底层原理

1. 为什么我要写一份寄存器白话手册刚接触STM32那会儿我也走过弯路。网上教程一搜一大把清一色让你先装CubeMX再生成HAL库代码然后告诉你“只要调用这个函数就行”。问题是我连这个函数背后往哪个地址写了什么值都不知道出了问题只能干瞪眼。后来有一次做项目用HAL库配置一个定时器输出PWM死活不出波形查了两天最后发现是某个寄存器的使能位没置对——HAL库的初始化顺序在某些型号上确实有坑。从那以后我就下定决心把常用外设的寄存器从头到尾捋一遍。这份“白话手册”不是官方参考手册的翻译官方手册动辄上千页每个位域都列得清清楚楚但初学者根本不知道哪些位是关键、哪些位可以先不管。我要做的是把最常用的那些寄存器挑出来用人话解释每个位的含义告诉你为什么要这样配以及踩过哪些坑。适合谁看适合已经能用HAL库点灯、但想搞清楚底层到底发生了什么的开发者也适合那些被HAL库的抽象层搞得云里雾里、想直接操作寄存器提升效率的老哥。STM32的寄存器操作其实没有那么神秘。本质上就是往特定的内存地址写特定的值芯片内部的状态机就会按照你的指令工作。关键在于你要知道哪个地址对应哪个外设、哪个位控制哪个功能、写入的顺序有没有要求。这三件事搞明白了寄存器编程就通了。下面我按外设分类把最核心的寄存器一个个拆开讲。2. 从内存映射到GPIO寄存器点灯背后的真相2.1 内存映射为什么0x4002 0000这个地址能控制LEDSTM32的地址空间是4GB从0x0000 0000到0xFFFF FFFF。芯片设计者把不同的外设映射到不同的地址段比如GPIOA挂在0x4002 0000这个基地址上。你往这个地址写数据实际上是通过总线写到了GPIOA外设的寄存器里。这个过程不需要什么特殊指令就是普通的指针赋值。以STM32F103为例GPIOA的基地址是0x4001 0800GPIOB是0x4001 0C00依次类推。每个GPIO端口有7个寄存器偏移量分别是寄存器偏移量作用CRL0x00配置低8位引脚模式CRH0x04配置高8位引脚模式IDR0x08读取引脚电平ODR0x0C输出电平BSRR0x10原子置位/复位BRR0x14原子复位LCKR0x18锁定配置你要点亮PA5上的LED需要做两件事把PA5配置成推挽输出模式然后把ODR的第5位置1。CRL寄存器每4个位控制一个引脚PA5对应CRL的位[23:20]。设置成0b0011就是推挽输出、最大速度50MHz。代码写出来就是#define GPIOA_BASE 0x40010800 #define GPIOA_CRL *(volatile uint32_t *)(GPIOA_BASE 0x00) #define GPIOA_ODR *(volatile uint32_t *)(GPIOA_BASE 0x0C) GPIOA_CRL ~(0xF 20); // 清除PA5的配置位 GPIOA_CRL | (0x3 20); // 推挽输出50MHz GPIOA_ODR | (1 5); // PA5输出高电平这里有个细节为什么用volatile因为编译器优化的时候可能会觉得你反复写同一个地址没有意义把中间的操作优化掉。加上volatile就是告诉编译器这个地址的内容可能被硬件修改每次访问都必须老老实实去读或写。2.2 BSRR寄存器为什么它比ODR更好用很多人点灯直接用ODR写GPIOA_ODR | (15)。这行代码在大多数情况下没问题但它有一个隐患读-改-写不是原子操作。如果在这条语句执行的过程中来了中断中断里也修改了ODR那中断返回后可能会覆盖掉中断里的修改。BSRR寄存器就是为了解决这个问题设计的。它的低16位是置位位高16位是复位位。写1到低16位的某一位对应的ODR位就被置1写1到高16位的某一位对应的ODR位就被清0。写0没有任何效果。所以你不需要读ODR直接写BSRR就行GPIOA_BSRR (1 5); // PA5置高 GPIOA_BSRR (1 (5 16)); // PA5置低这两条语句都是原子操作不会被中断打断。我在实际项目里凡是涉及中断和主循环同时操作同一个GPIO的情况一律用BSRR省心。2.3 复用功能与重映射PA9为什么能当串口用PA9默认是普通GPIO但它还可以作为USART1的TX引脚。这个切换是通过AFIO复用功能IO的寄存器来控制的。STM32F1系列没有像F4那样的AFR寄存器而是通过AFIO_MAPR寄存器来配置重映射。比如你要用USART1默认TX是PA9、RX是PA10不需要重映射。但如果你想用PB6和PB7作为USART1就需要设置AFIO_MAPR的USART1_REMAP位。同时还要把PA9和PA10配置成复用推挽输出模式也就是CRL里对应的位设成0b1011复用推挽50MHz。这里有个容易踩的坑很多人配置了重映射但忘了开启AFIO时钟。AFIO的时钟在APB2ENR寄存器的第0位不开启的话所有重映射配置都不生效。我当初调CAN通信的时候就是因为忘了开AFIO时钟重映射死活不工作查了半天才发现。3. 时钟树与RCC寄存器系统跑多快你说了算3.1 时钟源选择HSI、HSE、PLL到底用哪个STM32F103有四个时钟源HSI内部8MHz RC振荡器、HSE外部晶振通常8MHz、LSI内部40kHz给看门狗用、LSE外部32.768kHz给RTC用。系统时钟SYSCLK可以来自HSI、HSE或者PLL输出。HSI的精度差温漂大一般只用来做备用时钟。HSE精度高但需要外部晶振电路。PLL可以把HSE或HSI倍频到更高的频率比如8MHz的HSE经过9倍频得到72MHz这就是STM32F103的经典配置。配置时钟的寄存器主要在RCC模块里基地址是0x4002 1000。关键寄存器有RCC_CR0x00时钟源使能和就绪标志RCC_CFGR0x04时钟源选择和分频配置RCC_APB2ENR0x18APB2外设时钟使能RCC_APB1ENR0x1CAPB1外设时钟使能配置72MHz系统时钟的步骤大致是使能HSE等待HSERDY置位设置FLASH等待周期为2配置PLL倍频系数为9PLL源为HSE使能PLL等待PLLRDY切换SYSCLK到PLL等待SWS指示PLL成为系统时钟。每一步都要等待就绪标志不能写完寄存器马上进行下一步。硬件需要时间稳定。我见过有人不等待就往下走结果时钟没切过去程序跑飞了。3.2 APB1和APB2的分频为什么定时器时钟不是72MHzSTM32F103的AHB、APB2最高72MHzAPB1最高36MHz。但定时器的时钟有个特殊规则如果APB分频系数是1定时器时钟等于APB时钟如果分频系数大于1定时器时钟等于APB时钟的2倍。举个例子你设置APB1分频系数为2APB1时钟就是36MHz但挂在APB1上的定时器比如TIM2时钟是72MHz。这个规则在参考手册的时钟树图里有标注但很多人看漏了算波特率或者定时周期的时候怎么算都不对。RCC_CFGR的位[10:8]控制APB1分频位[13:11]控制APB2分频位[7:4]控制AHB分频。分频系数编码如下位值分频系数0xx110021014110811116我一般习惯APB2不分频72MHzAPB1二分频36MHz这样定时器TIM2~TIM7都能跑72MHz算定时时间方便。3.3 外设时钟使能不开启时钟外设就是死物STM32为了省电每个外设的时钟默认是关闭的。你不开启时钟往那个外设的寄存器写值也没用读回来全是0。这个坑我踩过不止一次——配置了半天GPIO结果灯不亮最后发现是RCC_APB2ENR的IOPAEN位没置1。RCC_APB2ENR的常用位位外设0AFIO2GPIOA3GPIOB4GPIOC5GPIOD6GPIOE9ADC111TIM114USART1RCC_APB1ENR的常用位位外设0TIM21TIM32TIM417USART221I2C123USB写代码的时候我习惯把使能时钟放在外设初始化的第一步这样不会忘。4. 中断与NVIC寄存器让CPU知道什么时候该干活4.1 NVIC的基本概念中断优先级是怎么排的NVIC是Cortex-M3内核的一部分不属于STM32外设。它的寄存器地址在0xE000 E000附近。STM32F103支持60个可屏蔽中断每个中断有4位优先级分为抢占优先级和响应优先级。抢占优先级高的可以打断抢占优先级低的中断响应优先级只在同时挂起时决定谁先执行不能打断。STM32F1的优先级分组有5种通过SCB-AIRCR寄存器的位[10:8]设置。最常用的是分组22位抢占优先级2位响应优先级。设置中断优先级的代码NVIC_SetPriority(USART1_IRQn, 1); // 抢占1响应0 NVIC_EnableIRQ(USART1_IRQn);用标准库或者HAL库的时候这些函数帮你封装好了。但如果你直接操作寄存器需要写NVIC_IPR和NVIC_ISER。NVIC_IPR每个字节对应一个中断的优先级NVIC_ISER每个位对应一个中断的使能。4.2 EXTI外部中断按键触发的中断是怎么进来的EXTI外部中断/事件控制器负责把GPIO的电平变化转换成中断信号。每个EXTI线可以映射到某个GPIO端口的对应引脚。比如EXTI0可以映射到PA0、PB0、PC0……但不能同时映射多个需要通过AFIO_EXTICR寄存器选择。配置一个按键中断的步骤配置GPIO为输入模式浮空或上拉开启AFIO时钟设置AFIO_EXTICR选择对应的端口配置EXTI_RTSR上升沿触发或EXTI_FTSR下降沿触发配置EXTI_IMR使能中断在NVIC中使能对应的EXTI中断通道编写中断服务函数在函数里清除EXTI_PR的挂起位最后一步特别重要。EXTI_PR是挂起寄存器中断触发后对应的位会置1。如果你不在中断服务函数里清除它中断会一直触发程序就卡在中断里出不来了。我见过一个新手写的代码按键中断进去之后没清标志结果主循环完全跑不起来他还以为是按键坏了。4.3 中断服务函数的命名为什么必须是那个名字中断服务函数的名字不是随便起的它和启动文件里的中断向量表一一对应。比如USART1的中断服务函数必须叫USART1_IRQHandler你写成USART1_Handler编译器不会报错但中断触发时跳转到的还是默认的无限循环。启动文件startup_stm32f10x_md.s里定义了弱符号的中断向量表每个中断都有一个默认的处理函数通常是一个死循环。你在C文件里定义一个同名的函数链接器就会用你的函数覆盖弱符号。所以名字必须和启动文件里的一致大小写都不能错。我建议直接去启动文件里复制函数名不要凭记忆写。不同型号的启动文件里中断向量表可能不一样比如STM32F103C8T6和STM32F103ZET6的中断数量就不同。5. 定时器寄存器从延时到PWM到输入捕获5.1 基本定时功能PSC和ARR怎么算STM32的通用定时器TIM2~TIM5是16位的高级定时器TIM1、TIM8也是16位但功能更多。定时器的核心是两个寄存器PSC预分频器和ARR自动重装载寄存器。定时器时钟经过PSC分频后驱动计数器CNT计数。CNT从0数到ARR然后产生更新事件CNT清零重新开始。所以定时周期 (PSC1) × (ARR1) / 定时器时钟频率。假设定时器时钟72MHz你要1ms的定时中断设PSC71则计数频率 72MHz / 72 1MHz设ARR999则计数1000次 1ms代码TIM2_PSC 71; TIM2_ARR 999; TIM2_DIER | (1 0); // 使能更新中断 TIM2_CR1 | (1 0); // 使能计数器注意PSC和ARR都是16位寄存器最大值65535。如果你需要更长的定时只能增大PSC或者用软件计数。5.2 PWM输出占空比和频率的独立控制PWM模式利用CCR捕获/比较寄存器来控制占空比。以TIM3的通道1为例配置成PWM模式1设置TIM3_CCMR1的OC1M位为0b110PWM模式1设置TIM3_CCER的CC1E位使能输出设置TIM3_CCR1决定占空比设置TIM3_ARR决定频率占空比 CCR1 / (ARR1)。频率 定时器时钟 / ((PSC1) × (ARR1))。比如你要1kHz的PWM占空比50%PSC71ARR999则频率 72MHz / (72 × 1000) 1kHzCCR1 500则占空比 500/1000 50%改变占空比只需要改CCR1的值不需要动ARR和PSC。这就是PWM的精髓频率和占空比独立可调。我做过一个RGB灯的项目三个通道分别用TIM3的CH1、CH2、CH3每个通道的CCR独立调节实现任意颜色混合。关键是要把GPIO配置成复用推挽输出否则PWM信号出不来。5.3 输入捕获测量外部信号的频率和脉宽输入捕获的原理是当引脚上检测到指定的边沿时当前CNT的值被锁存到CCR寄存器里。通过两次捕获的差值可以算出信号的周期。配置步骤GPIO配置为浮空输入或上拉输入设置CCMR的CC1S位为0b01把通道1映射到TI1设置CCER的CC1P位选择上升沿或下降沿设置CCER的CC1E位使能捕获在中断里读取CCR1的值计算差值测量频率的时候我一般用两个通道一个捕获上升沿一个捕获下降沿这样可以同时得到周期和占空比。或者用一个通道第一次捕获上升沿后切换成下降沿捕获也能算出高电平时间。有个细节要注意CNT是16位的如果信号周期超过65535个计数就会溢出。这时候需要在更新中断里记录溢出次数最后把溢出次数乘以65536再加上CCR差值。我测过一个低频信号周期大概200ms72MHz时钟下CNT要数1440万次溢出200多次必须做溢出处理。6. 串口寄存器数据是怎么一帧一帧发出去的6.1 USART的寄存器概览USART的寄存器不多但每个都很关键寄存器偏移作用SR0x00状态寄存器DR0x04数据寄存器BRR0x08波特率寄存器CR10x0C控制寄存器1CR20x10控制寄存器2CR30x14控制寄存器3GTPR0x18保护时间和预分频发送数据时把数据写入DR硬件自动把数据移位输出。接收数据时从DR读取。SR寄存器里的TXE位表示发送数据寄存器空可以写下一个数据RXNE位表示接收数据寄存器非空可以读数据TC位表示发送完成最后一个位的停止位已经发出。6.2 波特率计算为什么你的串口总是乱码波特率由BRR寄存器决定。BRR的值 外设时钟频率 / 波特率。比如USART1挂在APB2上时钟72MHz你要115200波特率BRR 72000000 / 115200 625 0x271BRR的低4位是小数部分高12位是整数部分。625的整数部分是390x27小数部分是10x1所以BRR 0x271。如果你算出来的BRR值有小数比如625.5那就写0x2718整数39小数8。小数部分越精确波特率误差越小。误差太大会导致通信不稳定或者完全收不到数据。我遇到过有人用HSI做时钟源HSI的实际频率在8MHz附近波动算出来的BRR值偏差大串口就是乱码。换成HSE之后问题立刻消失。所以做串口通信一定要用外部晶振。6.3 中断接收与空闲中断怎么收不定长数据串口接收不定长数据是个经典问题。常见方案有两种一种是每个字节触发一次中断在中断里判断超时另一种是用IDLE空闲中断一帧数据接收完后总线空闲触发一次中断。IDLE中断的配置CR1的IDLEIE位置1。当RXNE置位后如果总线在接收完一个字节后空闲了一个字节的时间IDLE位就会置1触发中断。在中断里读取DR清空数据同时IDLE位也被清除。我一般用IDLE中断配合DMA来收数据。DMA负责把DR里的数据搬到缓冲区IDLE中断负责通知CPU一帧数据收完了。这样CPU不用每个字节都进中断效率高很多。配置DMA的时候要注意DMA的传输长度要设成缓冲区的最大长度实际接收长度通过IDLE中断里读取DMA的剩余传输数量来反推。7. 寄存器编程的调试心得与避坑清单7.1 读-改-写陷阱为什么你的配置总是被覆盖寄存器编程最常见的错误就是读-改-写。比如你要设置GPIOA_CRL的PA5位写GPIOA_CRL | (0x3 20)。这行代码先读CRL的当前值修改位[23:20]再写回去。如果在这期间有中断也修改了CRL的其他位中断返回后你的写操作会把中断的修改覆盖掉。解决办法有两个一是用位带操作Bit-Banding把每个位映射到一个独立的地址写那个地址就是原子操作二是关中断修改完再开中断。位带操作在Cortex-M3上很好用外设寄存器的每个位都可以通过位带别名区访问。位带别名区的计算公式别名地址 0x42000000 (寄存器地址 - 0x40000000) × 32 位序号 × 4。比如GPIOA_ODR的地址是0x4001080C第5位的别名地址是0x42000000 (0x4001080C - 0x40000000) × 32 5 × 4 0x4221019C。往这个地址写1PA5就输出高写0就输出低。7.2 寄存器手册的阅读方法先看框图再看位域官方参考手册每个外设开头都有一张框图标明了各个寄存器之间的数据流和控制关系。很多人直接跳到寄存器描述那一节结果看了一堆位定义却不知道它们怎么配合。我的习惯是先花十分钟看框图搞清楚数据从哪来到哪去然后再看寄存器这样每个位的意义就一目了然了。比如看定时器的框图你能看到时钟从APB总线进来经过PSC到CNTCNT和CCR比较产生输出或者CNT被捕获到CCR。有了这个全局观再看CCMR、CCER、CCR这些寄存器就知道它们各自在数据流的哪个环节起作用。7.3 常见问题速查表现象可能原因排查方法外设完全不工作时钟未使能检查RCC_APBxENR对应位GPIO输出不变模式配置错误检查CRL/CRH对应4位中断不触发NVIC未使能或优先级错误检查NVIC_ISER和IPR中断反复触发未清除挂起标志检查EXTI_PR或SR寄存器串口乱码波特率误差大检查BRR计算和时钟源PWM无输出GPIO未配成复用模式检查CRL/CRH的CNF位定时器不计数CEN位未置1检查CR1的bit0DMA不传输DMA时钟未使能检查RCC_AHBENR这张表是我这些年调试积累下来的基本上覆盖了80%的常见问题。遇到问题先查表能省不少时间。7.4 寄存器编程的适用场景与局限说了这么多寄存器编程的好处也得客观讲一下它的局限。寄存器编程代码可读性差移植性也不好换一个型号可能地址和位定义就变了。对于大型项目HAL库或者LL库能提高开发效率减少出错概率。我的建议是学习阶段用寄存器把底层原理搞清楚实际项目用库函数把精力放在业务逻辑上。但即使是用库函数遇到疑难杂症的时候能看懂寄存器状态的能力会让你排查问题的速度快很多。两者不是对立的而是互补的。另外STM32的寄存器操作在不同系列之间差异不小。F1和F4的GPIO配置方式就完全不同F4用MODER、OTYPER、OSPEEDR、PUPDR四个寄存器替代了F1的CRL和CRH。所以学寄存器不能死记地址要理解设计思路这样换系列的时候才能快速上手。最后分享一个我常用的技巧用调试器直接查看寄存器。在Keil或者IAR里进入调试模式后可以打开外设寄存器窗口实时看到每个寄存器的值。配置外设的时候先单步执行观察寄存器的变化比看手册直观得多。有时候手册里写得含糊的地方看一眼实际寄存器的值就明白了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →