STM32F103标准库例程实战:从GPIO到CAN的底层原理与调试技巧
发布时间:2026/8/31 2:38:48 作者:尧图编辑部 阅读量:1,286

简介这是一套面向STM32F103初学者与嵌入式开发者的标准库函数实践教程聚焦ARM Cortex-M3内核MCU的外设驱动与系统级应用开发解决从环境搭建到复杂外设协同控制的学习断层问题。资源共3274个文件涵盖1242个头文件.h、1219个源码文件.c、130个依赖文件.d及大量工程配置.uvprojx/.uvoptx、编译输出.axf/.hex和调试支持文件.dbgconf/.sct总大小28.63MB结构完整、模块清晰每个实验均含独立Keil工程与详细注释。已有560人学习下载覆盖跑马灯、按键中断、定时器、PWM、DAC、SPI、SD卡、DS18B20单总线测温等10类典型应用尤其包含ALIENTEK MINISTM32平台实测工程模板与多外设协同案例便于读者快速掌握标准库编程范式并迁移至实际项目。 我玩STM32F103这一系也有快十年了从寄存器版本一路写过来后来才转到标准库再后来才接触HAL库。说实话如果你现在让我给刚入坑的朋友推荐一个上手的路径我依然会说先去把STD库标准外设库的例程吃透。尤其是STM32F103标准例程-V3这类打包好的例程合集虽然看起来名字长、乱糟糟的但它恰恰是理解F103这颗芯片最扎实的入口。这篇文章我就结合自己实际跑过的代码、踩过的坑好好聊聊怎么用好这份库函数例程帮你从看着例程跑马灯进化到能自己改外设、自己造工程。1. 内容整体设计与思路拆解1.1 例程包到底在“教”你什么先说个可能被很多人忽略的事实一套标准的STM32F103库函数例程它的核心价值不在于让LED点亮这个结果而在于它展示了一颗Cortex-M3内核的MCU如何通过固件库把寄存器操作这件事封装成有意义的功能模块。举个例子你要让PA5输出一个高电平寄存器写法是GPIOA-ODR | (1 5);库函数写法是GPIO_SetBits(GPIOA, GPIO_Pin_5);表面上看库函数只是把寄存器操作包了一层。但如果认真研究整个GPIO初始化流程你会发现库函数背后那套GPIO_InitTypeDef结构体、GPIO_Init函数、RCC_APB2PeriphClockCmd函数之间是有严密逻辑关系的。我用一个生活化的类比来解释寄存器操作像是你直接去厨房里的煤气灶点火、拧水龙头、自己把菜倒进锅里而库函数像是你雇了一个厨师你只需要告诉他做一盘西红柿炒鸡蛋他会按流程帮你切菜、开火、放油、下锅。你付出的代价是多写几行配置结构体的代码换来的却是——代码可读性大幅提升、移植性好、以后换到F4/F7系列的HAL库时心智模型一脉相承。而例程包的存在就是把厨师培训手册递到你手里。你不需要一开始就懂时钟树、不懂DMA也没关系照着例程走一遍功能跑起来了再去反推原理这是最有效的学习路径。1.2 为什么选库函数而不是寄存器或HAL很多新手会问现在ST主推的是HAL库和STM32CubeMX为什么还要回头啃标准库我个人的答案是标准库是半透明的HAL库是高度封装的。可以这样理解寄存器操作是看裸机电路图每根线都要自己接标准库是看模块框图你知道每个模块的输入输出内部逻辑也能看到一部分HAL库则是拿了一个黑盒子CubeMX帮你把所有初始化代码生成好你只关心业务逻辑。对于学习来说标准库恰好卡在有抽象、又没完全抽象的位置。你依然能看到RCC-APB2ENR是怎么被操作的也能看到TIM-ARR和TIM-PSC这些寄存器的直接赋值过程。这样当你遇到功能不正常的问题时你有能力往下追到寄存器层面去排查问题而如果用HAL库很多人遇到问题只会重新生成代码、重启板子、发帖求助因为他根本没机会看到HAL_GPIO_WritePin内部到底改了什么寄存器。此外标准库例程在F103上的代码量级非常小。编译出来一个点灯工程也就十几KB的Flash。对于调试、烧录、反汇编学习都很友好。1.3 例程包的文件结构怎么读拿到STM32F103标准例程-V3这类包第一件事不是打开工程而是先摸清目录。通常一个规范的例程包是这么组织的Project/ Leds/ - 每个功能的独立工程 Timer/ Uart/ SPI/ I2C/ CAN/ DMA/ ... Library/ CMSIS/ core_cm3.h startup_stm32f10x_hd.s FWlib/ inc/ src/ User/ main.c stm32f10x_it.c system_stm32f10x.c我见过很多学习者直接双击Project下某个子文件夹里的.uvprojx文件编译通过了就开跑这没问题。但更好的习惯是先浏览一遍FWLIB的src目录看看里面有哪些外设驱动源文件。你不需要每个都读一遍但至少知道这个包集成了哪些外设的驱动。比如你会看到stm32f10x_gpio.c、stm32f10x_usart.c、stm32f10x_tim.c、stm32f10x_spi.c、stm32f10x_i2c.c、stm32f10x_can.c——这就等于一份目录告诉你这颗芯片上你能玩哪些东西。2. 核心细节解析与实操要点2.1 时钟系统是理解一切的地基如果说STM32F103有什么地方是新手最容易懵的那一定是时钟树。很多例程跑不起来不是代码逻辑问题而是时钟配置出了问题。STM32F103外部通常接8MHz晶振经过PLL倍频后得到最高72MHz的系统时钟。库函数里和时钟最相关的文件是system_stm32f10x.c你基本不需要改它但你要读懂它做的事情SystemInit();这个函数在main执行之前就已经被启动文件startup_stm32f10x_hd.s调用了。它负责把系统时钟切换到PLL输出的72MHz。很多新手以为我只要在main里调用了SystemInit才有时钟其实不是。关于时钟你至少要明白3个概念HSE外部高速时钟通常接8M晶振。PLL锁相环通过倍频系数把HSE变成系统时钟源。APB1/APB2分频APB2最高72MHzAPB1最高36MHz挂在两条总线上的外设速度上限不同。USART1、SPI1、TIM1挂在APB2上USART2/3、SPI2、TIM2/3/4挂在APB1上。这个分布直接决定你给串口配波特率时时钟源是72MHz还是36MHz。比如在使用USART2时你的波特率寄存器值计算必须基于36MHz的APB1时钟来算否则波特率会差一倍甚至更多。2.2 GPIO配置的几个“隐性规则”GPIO大概是每个例程里最简单的外设但越简单的东西越有细节。在库函数例程里你几乎每次都会写这么一段GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_5; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure);这段代码里有几个容易忽略的点第一RCC_APB2PeriphClockCmd绝不是可有可无的。有些外设比如GPIOA的寄存器在复位后是关闭时钟的你不开时钟寄存器写了等于白写。初学者最容易犯的错就是在初始化GPIO时忘记开时钟结果就是程序没反应。第二GPIO_Mode的类型很多GPIO_Mode_Out_PP是推挽输出GPIO_Mode_Out_OD是开漏输出GPIO_Mode_IN_FLOATING是浮空输入GPIO_Mode_IPU是上拉输入。选择错误的模式会导致功能异常——比如你想用开漏输出接I2C总线结果选了推挽输出总线就拉不低你想读取外部按键的电平结果忘了配置上拉按键没按下时引脚悬空电平随机跳变。第三GPIO_Speed只对输出模式有意义。它决定输出驱动电路的翻转速率。50MHz模式比2MHz模式有更强的驱动能力但也带来更大的EMI。如果你只是驱动一个LEDGPIO_Speed_2MHz完全够用如果你要用软件模拟SPI、I2C就把速度调高到50MHz。2.3 外设初始化结构体的共性规律当你读过GPIO、USART、TIM、SPI、I2C等多个外设的例程后你一定会发现一个共性规律——所有初始化都是经典三步走开外设时钟填充初始化结构体调用外设初始化函数以USART为例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_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, USART_InitStructure); USART_Cmd(USART1, ENABLE);注意第6步USART_Cmd(USART1, ENABLE)。这也是很多人容易漏的一步。标准库里很多外设在初始化之后还需要调用一个使能函数才能真正开始工作。比如USART有USART_CmdSPI有SPI_CmdI2C有I2C_CmdADC有ADC_Cmd。漏掉这一步外设不会工作而且很难排查因为编译器不会报错硬件也不会有明显提示。我建议你把三步走扩展成四步走多出来的第四步就是调用使能命令。每次写外设初始化代码时先在脑子里过一遍有没有这四步能省下很多查Bug的时间。3. 实操过程与核心环节实现3.1 工程模板搭建从零手工建工程我看过很多人拿到例程包后只会编译运行现成的工程一旦要新建一个属于自己的功能就手足无措。这里我分享一下怎么从零手工搭建一个标准库工程模板这套流程我自己用了很多年几乎不会出问题。第一步准备文件从例程包里复制这三个目录到你的新工程目录Library/ User/ Project/然后清空User里的main.c保留标准骨架文件如stm32f10x_it.c、system_stm32f10x.c。第二步在Keil里新建工程打开Keil MDKProject - New uVision Project。选择你的芯片型号我一般选STM32F103ZET6或STM32F103C8T6取决于手头板子。比较关键的一步Keil会弹窗问你Copy STM32 Startup File to Project Folder?这里建议选择否因为我们要用标准库自己带的startup文件而不是Keil自动生成的。第三步添加分组并加入源文件在Keil里创建这些分组User添加User目录下的main.c、stm32f10x_it.c、system_stm32f10x.c。FWlib按需添加src目录下的源文件比如stm32f10x_gpio.c、stm32f10x_rcc.c、stm32f10x_usart.c。Startup添加startup_stm32f10x_hd.s。CMSIS添加core_cm3.c如果有。第四步配置全局宏定义和头文件路径这是最容易出错的一步。在Options for Target - C/C选项卡里Define栏填STM32F10X_HD如果你的芯片是容量大于256KB的如果是中容量比如F103C8T6填STM32F10X_MD。Include Paths栏添加这些目录User、Library/CMSIS、Library/FWlib/inc。关于Define里的宏我再多说一句。标准库里有一个stm32f10x.h里面根据STM32F10X_HD、STM32F10X_MD等宏来决定芯片型号相关的定义。如果你用的是C8T6却填了HD会导致中断向量表和Flash容量描述不匹配编译能过但下载后程序可能异常。第五步配置下载器在Options - Debug选项卡里选择你的调试器ST-Link或J-Link在Utilities选项卡里勾选Reset and Run。这里有一个我踩过的坑如果不勾选Reset and Run每次下载完程序后需要手动按一下板子的复位键程序才会跑很多新手不知道这一点以为程序没烧进去。3.2 从点灯到按键GPIO输入输出的完整解读在例程包里最简单的莫过于LED例程。但千万别小看它我会把点灯例程重新拆解一遍因为它包含了GPIO输出、延时、循环控制三个最基本的编程元素。标准库的点灯代码大概是这样的int main(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_5; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); while (1) { GPIO_SetBits(GPIOA, GPIO_Pin_5); Delay(0xFFFF); GPIO_ResetBits(GPIOA, GPIO_Pin_5); Delay(0xFFFF); } }如果把GPIO_SetBits换成操作寄存器它的原型是这样的void GPIO_SetBits(GPIO_TypeDef* GPIOx, uint16_t Pin) { GPIOx-BSRR Pin; }注意F103的GPIO用BSRR寄存器来置位用BRR寄存器来复位。BSRR写1对应的位置为高电平这是只写寄存器读它没有意义。这个设计和51单片机很不一样——51里直接操作P01就可以改变电平而F103提供了更细粒度的控制。如果你去直接操作ODR寄存器也能实现同样的效果但要小心使用读-改-写的方式时会打断其他Pin的操作而BSRR是原子操作这也是例程里推荐用SetBits/ResetBits的原因。3.3 串口通信调试利器与数据收发全流程串口是嵌入式开发里最重要的调试工具比什么仿真器都直观。我在例程包里串口例程的基础上讲讲怎么把它变成自己趁手的调试工具。基本初始化代码刚才已经给过了串口初始化和GPIO的区别在于你需要把TX引脚配置为复用推挽输出GPIO_Mode_AF_PPRX引脚配置为浮空输入或上拉输入GPIO_Mode_IN_FLOATING。接下来是发送一个字节和接收一个字节的核心代码void USART_SendByte(USART_TypeDef* USARTx, uint8_t ch) { while (USART_GetFlagStatus(USARTx, USART_FLAG_TXE) RESET); USART_SendData(USARTx, ch); } uint8_t USART_ReceiveByte(USART_TypeDef* USARTx) { while (USART_GetFlagStatus(USARTx, USART_FLAG_RXNE) RESET); return USART_ReceiveData(USARTx); }关键在于那两个while循环。TXE是发送数据寄存器空标志只有它为1时你才能往数据寄存器里写新数据RXNE是接收数据寄存器非空标志只有它为1时你才能取出接收到的数据。如果你不检查标志位就直接发送数据会丢不检查标志位就读取读出来的是垃圾数据。很多人在串口例程上翻车不是上面这段代码的问题而是引脚搞错了。STM32F103C8T6上板载串口常见连接是PA9USART1_TX和PA10USART1_RX。注意这两个引脚不是随便就能用的你要么直接使用这个默认映射要么通过AFIO重映射把它换到PB6/PB7但需要在使能AFIO时钟并调用GPIO_PinRemapConfig。入门阶段就用默认映射别折腾重映射。3.4 定时器PWM输出频率和占空比的参数换算定时器例程是F103例程包里的重头戏。它能生成可调频率、可调占空比的PWM波。很多电机驱动、LED调光、蜂鸣器控制都离不开PWM。F103输出PWM有两个关键寄存器PSC预分频器和ARR自动重装载值。PWM频率计算公式是PWM频率 定时器时钟 / ((PSC 1) * (ARR 1))举例定时器时钟为72MHz你想输出1kHz的PWM可以选PSC71ARR999这样频率 72,000,000 / ((711) * (9991)) 72,000,000 / 72,000 1000Hz占空比则由CCR寄存器的值决定。假设ARR999你设置CCR250占空比就是25%。这里的换算逻辑是占空比 CCR / (ARR 1)库函数里设置占空比的代码是TIM_SetCompare1(TIM3, 250);这个函数会修改TIM3-CCR1寄存器。很多人会有疑问为什么改CCR后PWM立即生效而改ARR后要等一个周期因为TIM3的预装载寄存器属性不同如果你在初始化时设置了TIM_OCInitStructure.TIM_OCPolarity、TIM_OCInitStructure.TIM_Pulse并调用了TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable)那么CCR值会在更新事件时生效。从我实际调试电机驱动的经验来看改变占空比时最好使用TIM_SetCompare函数而不是直接改结构体重新调用初始化因为后者会把定时器重新配置一遍可能导致输出瞬间抖动。3.5 CAN通信例程从标准帧到收发验证网上有不少人专门搜索stm32f103 can通讯例程就是因为CAN通信在工业控制、车载领域实在太常用了。F103内置bxCAN控制器支持2.0A和2.0B协议标准库例程里通常都带CAN收发回环测试。先说配置流程。CAN初始化核心步骤是开启CAN和GPIO时钟把引脚配置为复用推挽。调用CAN_DeInit复位CAN控制器。配置CAN_InitTypeDef结构体波特率、工作模式正常/回环/静默。配置过滤器。使能CAN。其中波特率的计算是一个难点。CAN总线的波特率不是随便设置的它由多个时间段组成同步段(SYNC_SEG)、传播时间段(BS1)、相位缓冲段(BS2)以及波特率预分频器(BRP)。位时间计算公式位时间 1 / 波特率 (1 BS1 BS2) * (BRP 1) / 外设时钟如果APB1时钟是36MHz想要500kbps波特率一种常用配置是BRP 1, BS1 8, BS2 7 位时间 (1 8 7) * 2 / 36MHz 32 / 36MHz ≈ 0.8888us 波特率 ≈ 1.125MHz不对换个参数BRP 0, BS1 8, BS2 7 位时间 (1 8 7) * 1 / 36MHz 16 / 36MHz ≈ 0.444us 波特率 ≈ 2.25MHz也不对实际上对于36MHz的APB1时钟想要500kbps需要位时间 1 / 500kbps 2us 即 (1 BS1 BS2) * (BRP 1) / 36MHz 2us (1 BS1 BS2) * (BRP 1) 72一种常用组合是BRP1BS1BS2136比如BS120、BS215这组值也在STM32的CAN位时间要求范围内BS1在1~16BS2在1~8之间实际需要看支持的合法范围。这里我不再展开每一个合法组合的计算表但要提醒你如果两端的CAN波特率不一致总线上设备之间是收不到数据的。所以在例程调试时先用回环模式CAN_Mode_LoopBack验证本机收发再用正常模式对接外部设备。回环模式的配置很简单把CAN_InitStructure.CAN_TTCM、CAN_ABOM、CAN_AWUM、CAN_NART、CAN_RFLM、CAN_TXFP这些标志按需设置然后把CAN_SJW、CAN_BS1、CAN_BS2、CAN_Prescaler设好就行。配置完成后发送函数用CAN_Transmit接收则在中断或轮询里用CAN_Receive。4. 常见问题与排查技巧实录4.1 编译报错若干与处理方案错误1cannot open source input file stm32f10x.h原因头文件路径没配置好。解决在C/C选项卡里的Include Paths中添加Library/CMSIS和Library/FWlib/inc路径。错误2undefined symbol GPIO_Init原因没有把stm32f10x_gpio.c加到工程里。标准库的特点是用到什么外设就添加什么源文件不像HAL库一次性全编译。解决在FWlib分组里添加stm32f10x_gpio.c。错误3#error Please select first the target STM32F10x device used in your application原因在stm32f10x.h里它通过宏定义来判断芯片型号。解决在C/C选项卡的Define里填入STM32F10X_MD或者STM32F10X_HD。这里有一个值得说的细节很多人为了省事直接把stm32f10x.h里#define STM32F10X_HD改成#define STM32F10X_MD或者反过来。这样做虽然也能编译通过但不推荐——因为你改的是公共头文件如果多个工程共用一套Library会影响其他工程。规范做法是在每个工程里用编译器宏定义来指定型号。4.2 下载失败或芯片锁死的自救玩F103的人几乎都遇到过用Keil下载时提示Error: Flash Download failed - Target DLL has been cancelled或者No target connected。这类问题通常有几种原因调试器没接好检查ST-Link/V2的SWDIO、SWCLK、GND三根线是否连通。芯片进入低功耗模式程序里执行了WFI或WFE指令内核进入了睡眠状态调试器无法连接。这时候按住板子复位键在Keil里点下载等下载开始的瞬间松开复位通常能抢在程序跑起来之前进入下载流程。调试接口被禁用如果你在程序里配置了SWJ引脚为普通IOSWD接口就失效了。解决办法是使用串口ISP下载把BOOT0拉高、BOOT1拉低用USART1下载一个正常程序后再把BOOT0拉回来。Flash保护被打开如果你程序里调用了Flash级别的读保护调试器连接会被拒绝。这时候需要用ST-Link Utility或STM32CubeProgrammer执行全片擦除来解除保护。第三点我特别强调一下因为我自己就在这个坑里浪费过半天时间。当时我用PA13/PA14做按键调试顺手把SWD引脚重映射成普通IO结果程序烧录一次后第二次就没法下载了。最后是拉了BOOT0用串口ISP把程序擦掉才救回来。4.3 串口打印乱码的排查方向串口输出乱码是新手最常见的问题没有之一。乱码的本质是发送端的波特率和接收端的波特率不一致或者字符编码格式不对。排查顺序确认波特率计算在main里调用SystemInit()后检查系统时钟是不是真的72MHz。如果你用的是外部8MHz晶振但实际板上焊的是12MHz晶振系统时钟会变成108MHz串口波特率自然也是错的。检查串口助手设置数据位8位、停止位1位、无校验这是最常见的配置。检查电源稳定性如果USB转串口模块供电不稳也会导致乱码尤其是板子和大功率电机共用一个电源时。当然乱码还有可能是发送函数的问题。如果你自己写的发送函数没等待TXE标志一直往发送寄存器里写数据会覆盖出来的就是乱码。4.4 例程跑了但现象不对排查方法的进阶示范有一次我帮人调试一个SPI驱动OLED屏的例程。现象是例程里的测试画面能出来但使用自己写的显示函数时花屏。排查过程我分享一下思路第一步先用示波器看CLK、MOSI的电平波形。发现CLK频率太高周期只有0.1us对应10MHz。OLED的SPI接口最高只支持2MHz。第二步返回例程里改SPI的预分频系数。在库函数里就是用SPI_BaudRatePrescaler_16替换SPI_BaudRatePrescaler_4。问题解决。这个小案例说明什么说明例程能跑通只代表在这个条件下跑通。不同外设、不同器件对时序的要求不同你在套用例程时必须检查SPI速率、I2C速率、串口波特率这些参数是否匹配你的实际硬件。我把一些常见问题整理成了一张速查表现象可能原因排查/解决程序不运行未勾选Reset and RunKeil Utilities里勾选或手动复位LED不亮GPIO时钟未开启检查RCC_APB2PeriphClockCmd串口乱码时钟频率与晶振不匹配检查HSE_VALUE是否设置为8MHzPWM频率不对PSC和ARR计算错误按公式重算确认定时器时钟源频率CAN通信失败波特率位时间配置不匹配用回环模式自测再用示波器测总线电平程序烧录后无法再次下载SWD引脚被复用或禁用拉高BOOT0用串口ISP擦除4.5 调试思路打印和示波器配合开发F103我通常的调试三件套是串口打印、逻辑分析仪、示波器。串口打印用于看程序运行状态比如关键的标志位、状态机跳转、变量值变化。你可以在例程的串口收发基础上自己封装一个printf重定向int fputc(int ch, FILE *f) { USART_SendByte(USART1, (uint8_t)ch); return ch; }加上这个后就能直接用printf(value %d\r\n, value)打印非常方便。但要注意标准库的printf默认是通过fputc输出到串口的如果你同时启用了微库MicroLIB需要确认串口和fputc的对应关系。示波器则用于看波形PWM的频率和占空比、SPI时钟时序、UART波形帧格式、CAN总线电平。对于F103开发一台几百块的数字示波器就够用了。逻辑分析仪适合调试时序协议比如I2C的起始条件、SPI的MISO数据时序。用逻辑分析仪能直观看到每一根线上的电平变化比示波器更适合抓长时序。5. 例程包之外的工程化补充5.1 中断与优先级别让中断把你晾在一边很多例程在演示外设时用的是轮询方式比如串口接收就是while(USART_GetFlagStatus(...) RESET)。但在实际项目中中断是绕不开的。F103使用NVIC管理中断优先级。初始化中断的基本流程是NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure);然后使能串口接收中断USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);这时候每当串口收到一个字节MCU会跳到stm32f10x_it.c里的USART1_IRQHandler函数执行。你需要在这个中断函数里读取接收到的数据void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t ch USART_ReceiveData(USART1); // 处理ch } }这里有一个我自己总结的中断处理守则中断处理函数里尽量别做耗时操作比如printf、delay、复杂计算。正确做法是把数据放入环形缓冲区主循环里再处理。中断标志位必须清除。有些中断标志在读数据寄存器后会自动清除但有些比如USART_IT_IDLE需要手动清除否则会一直进中断。优先级抢占如果两个中断同时发生抢占优先级高的先执行如果抢占优先级相同子优先级高的先执行。5.2 把例程改成自己的模块化代码当你把例程跑熟以后我建议做这样一件事把每个外设的初始化代码从main.c里剥离出来封装成独立的.c/.h文件。比如可以建一个bsp_usart.c内容包含void USART1_Init(uint32_t baudrate) { // 初始化代码 } void USART1_SendString(char* str) { while (*str) { USART_SendByte(USART1, (uint8_t)*str); } }再建一个bsp_usart.h声明这些函数。这样做的意义不在于代码更漂亮而在于每个模块的初始化逻辑可以复用不用每次重新翻例程。出问题时可以快速定位是初始化问题还是业务逻辑问题。以后换芯片、换工程时模块化的代码可以整体移植。我现在手上几个量产项目很多外设模块的底层代码最初就是从F103标准例程包里扒出来再改的。经历过实际项目的打磨比原始例程更健壮比如加了参数合法性检查、超时处理等。5.3 从标准库过渡到HAL迁移思路分享也许你最终还是要去用HAL库。毕竟ST已经不再维护标准库新出的芯片也不再提供标准库。但有了标准库的基础迁到HAL库会顺畅很多。我自己迁移时的体会是两者的外设寄存器是完全相同的区别只是封装方式。标准库里的GPIO_Init在HAL里叫HAL_GPIO_Init标准库里的USART_SendData在HAL里叫HAL_UART_Transmit。函数名变了但参数背后的物理意义不变。如果你认真玩过标准库你会很自然地理解HAL库的句柄概念——在HAL里每个外设都有一个句柄结构体比如UART_HandleTypeDef huart1这个结构体里存了这个外设的所有配置和状态。而在标准库时代这些信息分散在结构体初始化和寄存器里。所以你可以把标准库的底子当成理解HAL的桥梁。6. 写在最后的一点个人体会这么多年下来我始终觉得STM32F103和标准库这套组合是最适合做底层功底的教材。它不复杂——单核、Cortex-M3、没有缓存一致性这种概念它的库函数也不至于太抽象——你还能从函数名猜到寄存器操作的大概。你在F103标准库例程上花的时间是有长期回报的。如果一定要给后来者一个建议那就是不要急着一口气把例程包里的所有DEMO跑一遍那没有意义。你应该挑几个核心模块——GPIO、USART、TIM、SPI、CAN——把它们吃透读每一个函数的实现搞清楚每一行初始化代码在置哪个寄存器然后亲手把这些代码改造成自己想要的功能。等到你能不看例程自己写出一个定时器PWM输出的时候你就算真正入门了。再分享一个小技巧保留一份自己注释过的例程在最开始学习的阶段我习惯在每个外设初始化结构体旁边写上这个参数决定XXX、取值受YYY限制这个习惯帮我省下了大量反复查手册的时间。最后想说的是库函数例程不是工作它是工具。用的好的人从来不会记每个参数的取值范围而是知道去哪里查、怎么验证、怎么排查。希望这篇文章能帮你建立起这套属于自己的调试方法。本文还有配套的精品资源点击获取