STM32底层理论三支柱:寄存器、时钟、中断
1. 为什么“STM32理论”不是一句空话而是嵌入式工程师的底层生存逻辑很多人第一次看到“STM32理论”四个字下意识觉得这是教科书里那种枯燥的、背了就忘的概念堆砌——中断向量表怎么排APB2总线时钟怎么分频SysTick定时器的重装载值怎么算好像和我明天要焊的那块F103C8T6开发板、要调通的I²C温湿度传感器、要让电机转起来的PWM波形隔着一层厚厚的玻璃。但我在深圳华强北一家做工业HMI的小厂带过三届实习生亲眼见过太多人卡在同一个地方代码烧进去了LED不亮示波器测到引脚有电平跳变但外设就是没响应CubeMX生成的初始化函数明明调用了HAL_GPIO_WritePin却像石沉大海。最后查了一周发现是RCC时钟使能漏写了一行或者GPIO模式配置成了浮空输入而非推挽输出。这些不是“手残”是理论断层带来的系统性失明。“STM32理论”的本质从来不是让你把《ARM Cortex-M3权威指南》从头背到尾而是构建一套可预测、可验证、可迁移的硬件行为心智模型。比如当你知道GPIO的8种工作模式背后其实是寄存器里CNF[1:0]和MODE[1:0]两个字段的组合控制而“推挽输出”模式下ODR寄存器的值直接决定PMOS/NMOS管的导通状态你再看HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET)这行代码就不再是调用一个黑盒函数而是清晰地看见CPU在修改ODR寄存器的第5位从而让PA5引脚输出高电平驱动LED阳极。这种“看见”的能力就是理论落地的起点。更关键的是这套理论决定了你解决问题的路径宽度。没有理论支撑的工程师遇到问题只能靠“试”换库、换例程、换开发环境、甚至换芯片型号。而懂理论的人会立刻拆解问题层级是供电异常电源域是时钟未启动RCC域是引脚复用冲突AFIO域还是中断优先级抢占NVIC域每一层都有对应的寄存器视图和验证手段。我曾帮一家做智能灌溉控制器的客户定位一个间歇性死机问题现象是运行47小时后必卡死。他们之前花了两周时间反复刷固件、更换晶振、加电容滤波毫无进展。我拿到板子后第一件事是用ST-Link Utility读取SCB-ICSR寄存器发现VECTACTIVE字段始终为0x00000000说明根本没进入任何中断服务程序第二步检查SYSCFG-CFGR1发现MEM_MODE被错误配置为0b10系统存储器启动导致复位后从Flash地址0x08000000开始执行但实际Bootloader放在0x08002000。问题根源不是代码bug而是启动模式配置违反了STM32的复位行为理论。这个案例让我彻底明白理论不是考试的筹码而是你在黑暗电路板上唯一能点亮的探照灯。所以“STM32理论”不是悬浮在空中的概念它牢牢钉在三个物理支点上寄存器映射的确定性每个外设地址固定行为可查手册、时钟树的因果性没有时钟就没有一切数字逻辑、中断机制的原子性NVIC如何抢占、如何压栈、如何返回每一步都精确到CPU周期。忽略其中任何一个你的代码就像建在流沙上的房子。接下来我们就从这三个支点出发一层层剥开那些被HAL库封装起来的“魔法”看看底层到底发生了什么。1.1 寄存器映射为什么你写的0x4001080C真的能控制PA5的电平很多初学者对“寄存器”这个词有天然畏惧觉得那是汇编程序员才碰的东西。但事实上在STM32的世界里寄存器就是硬件与软件之间唯一真实的握手协议。它不像高级语言里的变量可以随意命名、动态分配它是一段固化在芯片硅片上的、有明确物理地址的内存空间CPU对它的每一次读写都直接对应着硬件电路的状态切换。以最基础的GPIOA为例。根据STM32F103xx参考手册RM0008第7章GPIOA的基地址是0x40010800。这个地址不是随便定的它是整个APB2总线地址空间规划的一部分由芯片设计时的总线矩阵Bus Matrix硬编码决定。在这个基地址上偏移0x00是CRL寄存器端口配置低寄存器偏移0x04是CRH端口配置高寄存器偏移0x0C是ODR端口输出数据寄存器。我们想让PA5输出高电平核心操作就是向ODR寄存器的bit5写1。这里有个极易被忽略的关键点ODR寄存器是“只写”的。你不能通过read-modify-write的方式去修改它因为读取ODR返回的值并不总是反映引脚当前的真实电平尤其在开漏模式下外部上拉电阻可能将引脚拉高但ODR里还是0。正确的做法是直接写入目标值。这也是为什么HAL库提供了HAL_GPIO_WritePin()而不是HAL_GPIO_TogglePin()作为默认接口——前者是确定性的状态设置后者依赖于读取当前状态存在竞态风险。我们来实操验证一下。假设你用Keil MDK新建一个裸机工程不使用HAL或标准外设库在main函数里写// 启用GPIOA时钟RCC-APB2ENR寄存器bit2 *(volatile uint32_t*)0x40021018 | (1 2); // 配置PA5为推挽输出最大速度50MHzCRL寄存器bit20-23 // CRL地址 0x40010800 0x00 0x40010800 // PA5对应CRL的bit20-23需清零原值再设置 uint32_t *crl (uint32_t*)0x40010800; *crl ~(0xF 20); // 清除bit20-23 *crl | (0x0 20); // CNF00(推挽), MODE10(50MHz) // 设置PA5输出高电平ODR寄存器bit5 uint32_t *odr (uint32_t*)0x4001080C; *odr | (1 5);这段代码没有调用任何库函数它直接操作内存地址。当你把它烧录进芯片PA5就会稳定输出3.3V。为什么因为CPU执行*odr | (1 5)这条指令时会触发一次APB2总线的写事务总线控制器将数据0x00000020发送到地址0x4001080CGPIOA外设模块接收到这个写请求后内部逻辑电路立即将PA5引脚的输出驱动器切换到高电平状态。整个过程没有抽象没有中间层只有地址、数据、和硬件响应。这就是寄存器映射的确定性力量——它给你一种绝对的掌控感你知道自己写的每一个bit都会在物理世界产生一个可测量的结果。提示在实际项目中直接操作地址虽然高效但可读性和可维护性差。更推荐的做法是使用CMSISCortex Microcontroller Software Interface Standard提供的结构体定义。例如GPIOA-ODR | GPIO_ODR_ODR5;这样的写法既保持了底层控制力又利用了编译器的类型安全检查。CMSIS头文件如stm32f10x.h本质上就是把手册里的地址映射翻译成了C语言能理解的结构体成员。1.2 时钟树没有时钟你的代码连第一条指令都跑不起来如果说寄存器是硬件的“门牌号”那么时钟就是通往这扇门的“通行许可”。在STM32中没有任何外设能在没有时钟的情况下工作。这不是一个可选项而是一个铁律。我见过太多人把GPIO初始化代码写得完美无缺却忘了在最开头加上RCC-APB2ENR | RCC_APB2ENR_IOPAEN;这一行结果LED纹丝不动然后开始怀疑人生是不是芯片坏了是不是焊接虚焊是不是开发板供电不足其实答案简单得令人沮丧PA端口的时钟根本就没开。STM32F103的时钟系统是一个精密的“树状网络”源头是三个时钟源HSI内部8MHz RC振荡器、HSE外部晶振通常8MHz或12MHz、以及LSI/LSE低速时钟用于RTC。它们通过PLL锁相环倍频、分频最终生成系统时钟SYSCLK、AHB总线时钟HCLK、APB1总线时钟PCLK1和APB2总线时钟PCLK2。这个结构不是为了炫技而是为了功耗与性能的精细平衡。比如ADC需要较高的采样精度所以它挂在APB2上享受最高72MHz的时钟而看门狗WWDG对时序要求不高挂在APB1上用36MHz就够了。理解时钟树的关键在于抓住两个核心寄存器RCC-CFGR时钟配置寄存器和RCC-CR时钟控制寄存器。RCC-CR负责开关各个时钟源RCC-CFGR则负责配置它们之间的连接关系。例如如果你想用8MHz的HSE作为主时钟并通过PLL倍频到72MHz你需要RCC-CR | RCC_CR_HSEON;—— 打开HSE。等待RCC-CR RCC_CR_HSERDY为真 —— 确认HSE已稳定起振。RCC-CFGR ~RCC_CFGR_SW;—— 清除SW位准备切换系统时钟源。RCC-CFGR | RCC_CFGR_SW_HSE;—— 将HSE设为系统时钟源。RCC-CFGR | RCC_CFGR_PLLSRC_HSE_Div2;—— PLL输入源为HSE/24MHz。RCC-CFGR | RCC_CFGR_PLLMULL9;—— PLL倍频系数为94MHz * 9 36MHz。RCC-CR | RCC_CR_PLLON;—— 打开PLL。等待RCC-CR RCC_CR_PLLRDY为真 —— 确认PLL已锁定。RCC-CFGR | RCC_CFGR_SW_PLL;—— 将PLL输出设为系统时钟源。这9个步骤少一个都不行。特别是第2步和第8步的等待循环绝不能省略。我曾经在一个项目中为了“优化启动时间”把HSE就绪等待循环删掉了结果在一批低温环境下工作的设备上出现了约5%的启动失败率。原因是HSE在-20℃时起振时间比常温长了近30%没有等待就强行切换CPU直接跑飞。这个教训告诉我时钟配置不是一段可以“大概齐”的代码而是一份必须逐字校验的硬件契约。注意CubeMX生成的SystemClock_Config()函数其核心逻辑就是上述9步的C语言实现。它之所以可靠不是因为“自动生成”而是因为它严格遵循了参考手册中关于时钟配置的时序要求和状态检查规范。你可以把它当作一份经过千锤百炼的“标准答案”但更要理解它背后的每一步为什么必须存在。2. 中断当硬件说“我有事要汇报”CPU该如何优雅地暂停手头工作中断是嵌入式系统的灵魂它让单片机从一个被动的“执行器”变成了一个能主动响应外部世界的“协作者”。想象一下如果没有中断你的主程序要想知道按键是否被按下只能一遍遍地轮询GPIO引脚的状态——这不仅浪费CPU cycles更会导致响应延迟不可控。而有了中断按键一按下硬件立刻发出一个信号CPU暂停当前任务跳转到专门的“汇报处理室”中断服务函数ISR快速处理完后再无缝回到原来的工作。这种机制是实时性保障的基石。但在STM32中中断远不止“写个函数名填到中断向量表里”那么简单。它是一套由硬件NVIC、固件启动文件、和软件用户代码三方共同维护的精密协作体系。要真正驾驭它你必须看清这三层的分工与交互。2.1 NVIC硬件层面的中断仲裁官与调度中心NVICNested Vectored Interrupt Controller是Cortex-M3内核集成的中断控制器它不是STM32厂商添加的外设而是ARM公司定义的内核级组件。这意味着无论你用的是STM32、NXP的Kinetis还是TI的TM4C只要它们基于Cortex-M内核NVIC的行为逻辑就是一致的。这是“理论”的普适性价值所在。NVIC的核心职责有三个优先级管理、中断使能/禁止、以及自动上下文保存与恢复。其中优先级管理是最容易被误解的部分。STM32F103支持16个可编程优先级4位抢占优先级4位子优先级但很多人以为“数字越小优先级越高”这并不完全准确。抢占优先级Preemption Priority决定了中断能否打断另一个正在执行的中断子优先级Subpriority则只在抢占优先级相同时决定哪个中断先被响应。举个例子EXTI0中断外部中断线0抢占优先级2子优先级1TIM2中断定时器2抢占优先级2子优先级0USART1中断串口1抢占优先级1子优先级3当TIM2中断正在执行时USART1中断到来因为1 2USART1可以抢占TIM2这是“抢占”。而如果EXTI0和TIM2同时到来由于它们抢占优先级相同都是2则子优先级更小的TIM20 1会先被响应。这个规则决定了你的系统在多中断并发时的确定性行为。更关键的是NVIC在发生中断时会自动将8个核心寄存器R0-R3, R12, LR, PC, xPSR压入当前使用的堆栈主堆栈MSP或进程堆栈PSP并在中断退出时自动弹出。这个过程是硬件完成的无需任何C代码干预它保证了中断服务函数的“原子性”——即你不必担心在中断里修改了R0出来后主程序的R0值就乱了。这是Cortex-M架构相比传统8051等MCU的巨大优势也是你敢于在ISR里写复杂逻辑的底气。2.2 启动文件那个默默帮你把函数名“注册”到向量表里的幕后英雄当你在Keil里写好一个void EXTI0_IRQHandler(void)函数编译链接后它就能在按键按下时被正确调用。这个看似魔法的过程其实在启动文件startup_stm32f10x_md.s里早已安排得明明白白。启动文件的开头定义了一个名为__Vectors的向量表它是一个包含82个32位地址的数组。其中索引0是初始堆栈指针MSP值索引1是复位处理函数Reset_Handler地址索引2是NMI中断地址索引3是HardFault中断地址……而索引6正是EXTI0中断的入口地址。这个向量表被链接器强制放置在Flash的起始地址0x08000000处这是Cortex-M内核复位后自动查找并加载MSP和PC的固定位置。在向量表中EXTI0的条目默认指向一个名为Default_Handler的弱定义weak函数。而你的EXTI0_IRQHandler在编译时会被链接器识别为一个强定义strong函数。根据链接器的“强覆盖弱”规则EXTI0_IRQHandler的地址会自动替换掉向量表中Default_Handler的位置。这样当中断发生时CPU从向量表读取到的就是你亲手写的函数地址。这个机制解释了为什么你不能把中断服务函数写成static void EXTI0_IRQHandler(void)——static关键字会让函数作用域局限于当前文件链接器无法将其地址“注入”到全局向量表中结果就是中断永远得不到响应程序卡死在Default_Handler里通常是死循环。这也是为什么所有官方例程和HAL库的中断函数都必须是全局可见的。实操心得在调试中断不触发时一个极其有效的排查方法是用ST-Link Utility或J-Flash打开Flash的前128字节直接查看向量表内容。找到对应中断的索引位置确认那里存放的地址是否确实是你期望的函数地址。如果看到的是0x08000000或0xFFFFFFFF那基本可以断定是函数名拼写错误、未定义、或被static修饰了。3. 外设驱动的本质GPIO、PWM、I²C、SPI它们只是同一套理论的不同面孔很多人学STM32习惯把GPIO、PWM、I²C、SPI当成互不相干的“模块”今天学GPIO点灯明天学I²C读传感器后天学PWM调电机感觉知识是碎片化的。但如果你深入到寄存器层面就会发现它们共享着同一套底层逻辑都是对特定地址空间的一组寄存器进行读写操作以控制硬件电路的物理行为。所谓的“不同面孔”只是寄存器功能的排列组合不同而已。3.1 GPIO最简单的外设却是理解所有外设的钥匙GPIO通用输入输出之所以被称作“通用”是因为它剥离了所有协议细节只保留了最原始的“电平控制”能力。它就像一个万能的开关和探针既可以输出高/低电平去驱动LED、继电器也可以输入电平去读取按键、传感器状态。正因为其简单它成了验证整个系统是否健康的“黄金标准”。GPIO的寄存器组非常精炼CRL/CRH配置引脚的工作模式输入/输出/复用/模拟和速度。IDR只读读取引脚当前的电平状态注意受外部电路影响。ODR只写设置引脚的输出电平。BSRR写1置位/清零实现原子操作比ODR更安全。BRR写1清零仅用于清零。LCKR锁定配置防止意外修改。当你掌握了GPIO你就掌握了访问任何外设的第一把钥匙。因为所有外设无论是定时器TIM、串口USART还是I²C它们的“使能”、“复位”、“中断标志清除”等操作本质上都是对某个特定地址的某个bit进行写1或写0。例如要启动TIM2你需要写TIM2-CR1 | TIM_CR1_CEN;要清除USART1的接收中断标志你需要写USART1-SR ~USART_SR_RXNE;。这些操作和GPIOA-ODR | GPIO_ODR_ODR5;在本质上毫无区别都是“地址数据”的直接映射。3.2 PWM定时器的副产品而非独立外设PWM脉宽调制常被误认为是一个独立的外设模块就像GPIO或UART一样。但事实上在STM32F103中PWM完全是通用定时器TIM的一个功能模式。TIM2、TIM3、TIM4这些通用定时器其核心是一个16位或32位的向上计数器CNT配合一个自动重装载寄存器ARR和一个捕获/比较寄存器CCR。当CNT从0计数到ARR时产生更新事件UEVCNT归零重新开始而当CNT的值等于CCR时就会触发一个“捕获/比较事件”这个事件可以用来翻转、置位或清零一个指定的GPIO引脚通过OCxM位配置。所以生成一个PWM波形本质上是在配置一个“计数器比较器”的组合逻辑ARR决定了PWM的周期频率Frequency SYSCLK / ((PSC 1) * (ARR 1))CCR决定了PWM的占空比DutyCycle CCR / (ARR 1)CCMR1寄存器的OC1M位决定了比较事件的动作如“冻结”、“置位”、“翻转”、“PWM模式1”等我曾帮一个做LED呼吸灯的客户优化代码。他原来的方案是用一个SysTick定时器每1ms进一次中断在中断里手动改变GPIO电平模拟PWM。结果LED闪烁严重且CPU占用率高达80%。我帮他改用TIM3的PWM模式1只配置了3个寄存器PSC, ARR, CCR剩下的全部交给硬件定时器完成。CPU占用率瞬间降到5%LED光效平滑如丝。这个案例生动地说明用对了理论硬件就是你最高效的免费员工。3.3 I²C与SPI同步串行通信的两种哲学I²C和SPI都是同步串行通信协议但它们的设计哲学截然不同这也直接体现在了STM32的寄存器设计上。I²C追求“线少”用两根线SCL时钟、SDA数据实现多主多从通信。它的复杂性在于总线仲裁和时钟同步。STM32的I²C外设I2C1为此配备了专门的CR1控制寄存器1、OAR1自身地址寄存器、CCR时钟控制寄存器和TRISE上升时间寄存器。其中TRISE的计算公式TRISE (FastMode ? 1 (FTS * Cbust) : 1 (FTS * Cbust))直接关联到物理总线的电容负载Cbust这要求你必须了解PCB走线的寄生电容特性否则通信必然不稳定。SPI追求“速度快”用四根线SCK, MISO, MOSI, NSS实现全双工通信。它的复杂性在于时序模式CPOL/CPHA和数据帧格式8bit/16bit。STM32的SPI外设SPI1的CR1寄存器里CPOL和CPHA两位就直接对应着四种SPI模式Mode0~Mode3。选择错误通信双方就会“鸡同鸭讲”数据全错。有趣的是这两种协议的底层驱动都可以用GPIO模拟Bit-Banging来实现。这再次印证了我们的核心观点所有外设都是GPIO在特定时序约束下的高级应用。当你能用纯GPIO模拟出I²C的起始信号SCL高时SDA由高变低和停止信号SCL高时SDA由低变高你就真正理解了I²C的精髓。4. 从理论到实践一个完整的“按键控制LED呼吸灯”项目拆解理论的价值最终要落在解决真实问题的能力上。现在让我们把前面讲的所有知识点串联起来完成一个经典项目“按下KEY1LED1开始呼吸灯效果再按一次停止”。这个看似简单的功能恰恰是检验你是否真正吃透STM32理论的试金石。4.1 需求分析与硬件资源映射首先明确需求检测一个外部按键KEY1的按下事件下降沿。控制一个LEDLED1的亮度实现平滑的“呼吸”效果亮度由暗到亮再由亮到暗循环往复。呼吸灯的亮度变化必须是连续的、无阶跃的人眼感知为柔和过渡。硬件资源映射以常见的正点原子精英版F103开发板为例KEY1连接在PA0引脚按下时接地低电平有效。LED1连接在PD2引脚高电平点亮共阴极接法。这个映射决定了我们需要操作的外设PA0配置为浮空输入因为外部有上拉电阻并启用外部中断线0EXTI0。PD2配置为复用推挽输出因为我们要用TIM3的CH3通道来输出PWM并将其复用功能映射到TIM3_CH3。4.2 关键技术点与理论应用详解这个项目涉及三个核心技术点每一个都直指我们前面讨论的理论核心1. 外部中断消抖与边沿检测按键是机械器件按下和释放时会产生毫秒级的抖动。如果直接用中断响应一次按键可能会触发数十次中断。解决方案是硬件消抖RC滤波 软件消抖延时检测。理论依据是抖动持续时间远小于人手按下的稳定时间通常10ms而稳定按下100ms。因此我们在EXTI0中断服务函数中不是立刻执行呼吸灯逻辑而是立即关闭EXTI0中断EXTI-IMR ~EXTI_IMR_MR0;防止抖动干扰。启动一个10ms的SysTick定时器或使用HAL_Delay(10)但要注意HAL_Delay依赖SysTick需确保其已初始化。在10ms后再次读取PA0的电平。如果仍是低电平则确认为有效按键执行呼吸灯启停逻辑否则忽略本次中断。最后重新使能EXTI0中断EXTI-IMR | EXTI_IMR_MR0;。2. PWM呼吸灯的数学建模呼吸灯效果本质是一个正弦波或三角波的占空比调制。我们选择更易计算的三角波DutyCycle abs(i - 50) * 2其中i从0到100循环。这样占空比会从0% - 100% - 0%形成一个完美的“呼吸”周期。理论依据是人眼对光强的感知是非线性的韦伯-费希纳定律但用线性变化的占空比已经足够营造出柔和的视觉效果。3. 定时器中断与PWM更新的协同呼吸灯的“节奏”由一个低频定时器如TIM4控制它每20ms产生一次中断在中断里更新TIM3的CCR3寄存器即改变PWM占空比。而TIM3本身则以更高的频率如10kHz运行产生稳定的PWM波形。这种“高低速定时器协同”的架构是嵌入式系统中处理多时间尺度任务的经典范式。理论依据是高频定时器TIM3负责“执行”低频定时器TIM4负责“决策”两者解耦系统健壮性极高。4.3 完整代码实现与逐行注释以下是不依赖HAL库的裸机实现所有关键寄存器操作均给出详细注释体现理论到代码的精准映射#include stm32f10x.h // 全局状态变量 volatile uint8_t g_breathing_enabled 0; volatile uint8_t g_pwm_counter 0; // SysTick中断服务函数1ms基准 void SysTick_Handler(void) { static uint16_t systick_count 0; systick_count; if (systick_count 10) { // 10ms systick_count 0; // 10ms定时到检测PA0电平 if ((GPIOA-IDR GPIO_IDR_IDR0) 0) { // PA0为低确认按键有效 g_breathing_enabled !g_breathing_enabled; // 切换呼吸灯状态 } EXTI-IMR | EXTI_IMR_MR0; // 重新使能EXTI0中断 } } // 外部中断0服务函数按键按下 void EXTI0_IRQHandler(void) { // 立即关闭EXTI0中断防止抖动 EXTI-IMR ~EXTI_IMR_MR0; // 清除中断挂起位 EXTI-PR EXTI_PR_PR0; } // TIM4中断服务函数20ms用于更新PWM占空比 void TIM4_IRQHandler(void) { if (TIM4-SR TIM_SR_UIF) { // 更新中断标志 TIM4-SR ~TIM_SR_UIF; // 清除标志 if (g_breathing_enabled) { // 计算三角波占空比0-100-0 uint16_t duty 0; if (g_pwm_counter 50) { duty g_pwm_counter * 2; // 0~100 } else { duty (100 - g_pwm_counter) * 2; // 100~0 } // 更新TIM3的CCR3寄存器改变PD2的PWM占空比 // TIM3-CCR3地址 0x40000410 *(volatile uint32_t*)0x40000410 duty; g_pwm_counter; if (g_pwm_counter 100) g_pwm_counter 0; } } } int main(void) { // 1. 初始化系统时钟HSEPLL72MHz RCC-CR | RCC_CR_HSEON; while(!(RCC-CR RCC_CR_HSERDY)); RCC-CFGR ~RCC_CFGR_SW; RCC-CFGR | RCC_CFGR_SW_HSE; RCC-CFGR | RCC_CFGR_PLLSRC_HSE_Div2 | RCC_CFGR_PLLMULL9; RCC-CR | RCC_CR_PLLON; while(!(RCC-CR RCC_CR_PLLRDY)); RCC-CFGR | RCC_CFGR_SW_PLL; while((RCC-CFGR RCC_CFGR_SWS) ! RCC_CFGR_SWS_PLL); // 2. 使能GPIOA、GPIOD、AFIO、TIM3、TIM4时钟 RCC-APB2ENR | RCC_APB2ENR_IOPAEN | RCC_APB2ENR_IOPDEN | RCC_APB2ENR_AFIOEN; RCC-APB1ENR | RCC_APB1ENR_TIM3EN | RCC_APB1ENR_TIM4EN; // 3. 配置PA0为浮空输入用于按键 GPIOA-CRL ~(0xF 0); // 清除CNF0[1:0]和MODE0[1:0] GPIOA-CRL | (0x0 0); // CNF00(浮空输入), MODE00(输入模式) // 4. 配置PD2为复用推挽输出用于TIM3_CH3 GPIOD-CRL ~(0xF 8); // 清除CNF2[1:0]和MODE2[1:0] GPIOD-CRL | (0x2 8); // CNF10(复用推挽), MODE10(50MHz) // 5. 配置AFIO将PD2映射到TIM3_CH3 AFIO-MAPR | AFIO_MAPR_TIM3_REMAP_PARTIALREMAP; // 部分重映射 // 注F103的PD2默认就是TIM3_CH3此步在部分板子上可省略但为严谨起见仍写出 // 6. 配置TIM3为PWM模式1频率10kHz // PSC (72MHz / 10kHz) - 1 7199 TIM3-PSC 7199; // ARR 999 (10kHz 100kHz计数频率下1000个计数为10kHz) TIM3-ARR 999; // CCR3 0 (初始占空比0%) TIM3-CCR3 0; // OC3M 110 (PWM模式1)OC3PE 1 (使能预装载) TIM3-CCMR2 | TIM_CCMR2_OC3M_2 | TIM_CCMR2_OC3M_1 | TIM_CCMR2_OC3PE; // CCER 1 (使能CH3输出) TIM3-CCER | TIM_CCER_CC3E; // CR1 1 (使能计数器) TIM3-CR1 | TIM_CR1_CEN; // 7. 配置TIM4为20ms更新中断 TIM4-PSC 7199; // 同样基于72MHzPSC7199 10kHz计数频率 TIM4-ARR 199; // 10kHz / 200 50Hz 20ms TIM4-DIER | TIM_DIER_UIE; // 使能