1. 为什么STM32F1到今天还值得花时间学我接触STM32F1系列大概是在十多年前那会儿能跑起来的开发板还不多一块最小系统板加上一个简易的下载器就能折腾一整个月。这么多年过去芯片换了一茬又一茬但每次有新人问我“从哪颗芯片入门嵌入式”我还是会先推荐STM32F1。原因很直接它的资料密度足够高外设结构足够典型价格足够低而且你在这颗芯片上学到的寄存器操作、时钟树理解、中断管理思路换到任何一颗Cortex-M内核的芯片上都能直接迁移。STM32F1系列是意法半导体基于ARM Cortex-M3内核推出的32位微控制器家族常见型号包括STM32F103C8T6、STM32F103ZET6、STM32F103RCT6等。它的主频通常跑到72MHz内置Flash从16KB到1MB不等SRAM从6KB到96KB外设覆盖GPIO、USART、SPI、I2C、ADC、DAC、定时器、CAN、USB等。这些参数放在今天看不算亮眼但正是这种“不亮眼”让它成为学习嵌入式系统最好的教具——你不需要面对过于复杂的总线拓扑也不需要处理多核之间的同步问题所有精力都可以放在理解“一个外设从配置到工作的完整链路”上。这篇文章适合三类人看第一类是刚学完C语言、想找一颗芯片把知识落到实处的学生第二类是从8位机比如51单片机转过来、想系统理解32位MCU开发方式的工程师第三类是做产品维护、手里有老项目跑在STM32F1上、需要快速定位问题和做二次开发的人。我会从整体设计思路讲到具体外设配置再到实际调试中踩过的坑尽量把“为什么这么做”讲清楚而不是只给一堆代码让你抄。2. 整体设计思路与选型逻辑拆解2.1 为什么STM32F1适合作为嵌入式入门的第一颗32位芯片很多人会问现在都有F4、H7、G0、L4这些系列了为什么还要学F1我的回答是学习成本和知识迁移效率。F1的外设寄存器映射相对规整参考手册的章节编排也足够清晰你对着手册就能把每一个bit的含义查明白。而更新的一些系列虽然性能强但外设的复用功能、时钟域划分、低功耗模式都更复杂初学者很容易在配置阶段就迷失。另一个现实原因是生态。STM32F1的标准外设库和HAL库资料都非常丰富社区里能搜到的例程、问答、踩坑记录数量极大。你遇到一个USART收不到数据的问题搜一下大概率能找到别人已经解决过的方案。这种“遇到问题能快速找到参考”的环境对初学者来说比芯片本身的性能重要得多。还有一点是硬件成本。一块STM32F103C8T6最小系统板的价格已经低到可以忽略不计配套的ST-Link下载器也很便宜。你可以同时买好几块一块做主机、一块做从机用来验证SPI、I2C、CAN这些总线通信这种“多板协同”的学习方式在F1上成本最低。2.2 标准外设库、HAL库、LL库和寄存器操作该怎么选这是每个学STM32F1的人都会面对的第一个选择。我的建议是分阶段来入门阶段用标准外设库或者HAL库把外设跑通建立信心进阶阶段挑一两个外设用寄存器直接操作理解底层做产品时根据团队习惯和项目周期选择。标准外设库Standard Peripheral Library是ST早期推出的库代码直观函数命名和寄存器对应关系紧密。比如你要配置GPIO就是GPIO_InitTypeDef结构体加上GPIO_Init()函数打开库文件就能看到它最终写的是哪些寄存器。这个库现在已经不再更新但对于F1来说足够用而且很多老项目还在用它维护老代码时必须能看懂。HAL库Hardware Abstraction Layer是ST现在主推的库配合STM32CubeMX工具可以图形化配置引脚和时钟自动生成初始化代码。它的好处是跨系列移植方便F1上写的HAL代码换到F4上大部分能直接编译。缺点是抽象层次高出问题时排查链路长而且代码体积比标准库大。LL库Low Layer可以理解为介于寄存器和HAL之间的轻量级库函数直接操作寄存器效率高但可读性不如标准库直观。寄存器操作则是直接写GPIOA-CRL、RCC-APB2ENR这类寄存器最底层也最能锻炼人。我的实际做法是用CubeMX生成HAL初始化代码然后把关键外设的HAL调用替换成LL库或直接寄存器操作这样既保证了初始化不出错又能在关键路径上控制效率和理解底层。2.3 时钟树配置F1开发中最容易被忽视但最关键的环节STM32F1的时钟树是很多人第一个卡住的地方。外部晶振通常是8MHz经过PLL倍频到72MHz作为系统时钟。但你要注意APB1总线的最高频率是36MHzAPB2是72MHz。如果你把APB1的分频系数设错挂在APB1上的外设比如USART2、I2C1、TIM2就会工作异常。我见过一个典型问题有人用USART2通信波特率怎么算都不对最后发现是APB1的分频系数设成了1导致APB1时钟跑到了72MHz而USART2的波特率计算是基于APB1时钟的实际波特率就偏了一倍。所以配置时钟时一定要先确认每条总线的时钟频率再去看挂在上面的外设。CubeMX里时钟树是图形化的你拖动倍频和分频系数时它会实时显示各总线频率这个功能对初学者非常友好。但如果你用标准库手动配置就要仔细查RCC_CFGR寄存器的每一位。我的习惯是配置完之后用示波器或者逻辑分析仪测一下MCO引脚输出的时钟确认实际频率和预期一致。3. 核心外设配置与实操要点3.1 GPIO从点灯到复用功能配置的完整链路GPIO是所有人接触的第一个外设但很多人只学会了“点灯”没理解GPIO的完整配置逻辑。STM32F1的GPIO有8种模式输入浮空、输入上拉、输入下拉、模拟输入、开漏输出、推挽输出、开漏复用、推挽复用。每种模式对应不同的寄存器位组合。点灯用的是推挽输出配置步骤是使能GPIO时钟、设置CRL或CRH寄存器对应的4个bit、设置输出数据寄存器ODR。但如果你要用USART的TX引脚就要配置成推挽复用输出这时候GPIO的控制权交给外设你写ODR不会影响引脚状态。这里有一个容易踩的坑配置复用功能时必须先使能对应外设的时钟再配置GPIO的复用模式。顺序反了的话外设可能无法正常接管引脚。另外F1的GPIO速度配置有2MHz、10MHz、50MHz三档速度越高功耗和噪声越大点灯用2MHz就够了驱动SPI高速信号才需要50MHz。注意F1的GPIO在复位后默认是浮空输入模式如果你直接写ODR想输出高电平引脚不会有反应必须先配置成输出模式。3.2 USART串口通信波特率计算与中断接收的实战细节USART是调试阶段最重要的外设。F1的USART支持全双工异步通信波特率计算公式是baud fCK / (16 * USARTDIV)其中fCK是挂载总线的时钟频率。以USART1为例它挂在APB2上时钟72MHz要得到115200的波特率USARTDIV 72000000 / (16 * 115200) 39.0625。整数部分是39小数部分是0.0625对应BRR寄存器的值是0x27139 4 | 1。如果你用HAL库直接调用HAL_UART_Init()并传入波特率就行库会自动算。但你要知道它算出来的实际波特率可能有误差误差太大会导致通信不稳定。一般要求误差在2%以内115200在72MHz下算出来的误差很小可以放心用。中断接收是实际项目中最常用的方式。配置步骤是使能USART的接收中断RXNEIE在中断服务函数里读DR寄存器清除标志把数据存入缓冲区。这里有一个经典问题如果你在中断里处理时间太长下一个字节来了还没读完就会触发溢出错误ORE。解决办法是中断里只做数据搬运把处理逻辑放到主循环里。我常用的接收框架是定义一个环形缓冲区中断里把收到的字节写入缓冲区并更新写指针主循环里检查读指针和写指针是否相等不相等就取出数据处理。这样既能保证不丢数据又不会在中断里耗时过长。3.3 定时器从精确延时到PWM输出的参数计算STM32F1的定时器分为高级定时器TIM1、TIM8、通用定时器TIM2到TIM5和基本定时器TIM6、TIM7。通用定时器最常用支持输入捕获、输出比较、PWM输出、编码器接口等功能。配置定时器的核心参数是预分频器PSC和自动重装载寄存器ARR。定时器时钟频率fTIM经过PSC分频后得到计数频率fCNT fTIM / (PSC 1)计数到ARR溢出溢出周期T (ARR 1) / fCNT。比如TIM2挂在APB1上时钟72MHz注意APB1预分频系数为1时定时器时钟等于APB1时钟不为1时是APB1时钟的2倍要得到1ms的定时周期可以设PSC7199ARR9这样fCNT 72MHz / 7200 10kHz计数10次就是1ms。PWM输出的配置稍微复杂一点需要设置捕获/比较模式寄存器CCMR、捕获/比较使能寄存器CCER、以及捕获/比较寄存器CCR。PWM频率由ARR决定占空比由CCR决定。比如你要输出1kHz、占空比50%的PWMARR设为999CCR设为499PSC设为71这样计数频率是1MHz计数1000次是1ms频率1kHzCCR499时高电平占500个计数周期占空比50%。提示高级定时器TIM1和TIM8要输出PWM必须使能刹车和死区寄存器BDTR中的主输出使能位MOE否则PWM不会有输出。这个坑我踩过查了半天才发现是MOE没开。3.4 ADC采样参考电压、采样时间与滤波处理F1的ADC是12位逐次逼近型最多有18个通道支持规则组和注入组转换。实际使用时参考电压通常接VDDA3.3V所以ADC值换算成电压的公式是V ADC_Value * 3.3 / 4096。采样时间需要根据信号源的内阻来选。F1的ADC采样时间可以设为1.5、7.5、13.5、28.5、41.5、55.5、71.5、239.5个ADC时钟周期。信号源内阻越大需要的采样时间越长。如果你测的是电位器分压内阻一般在10kΩ以内选55.5周期就够了如果测的是高内阻传感器就要选239.5周期。ADC的时钟也不能超过14MHz所以ADC预分频器通常设为6分频72MHz / 6 12MHz。这个细节很多人会忽略导致ADC结果跳动很大。软件滤波是实际项目中的常规操作。我一般用两种方法一种是多次采样取平均值比如连续采16次去掉最大最小值再平均另一种是一阶滞后滤波公式是filtered filtered * 0.9 new_value * 0.1适合变化缓慢的信号。两种方法可以结合使用先用平均滤波去掉随机噪声再用滞后滤波平滑趋势。4. 完整实操流程从零搭建一个多外设协同项目4.1 项目需求与硬件连接方案假设我们要做一个简单的数据采集节点通过ADC采集一路电压信号通过USART1把电压值打印到上位机同时用TIM3输出PWM控制一个LED的亮度用TIM2做1ms的系统滴答定时器。硬件连接是电位器中间引脚接PA0ADC通道0USART1的TX接PA9、RX接PA10PWM输出用PB0TIM3通道3LED接在PB0和地之间。这个项目虽然简单但覆盖了GPIO、ADC、USART、TIMER四大外设而且它们之间有协同关系TIM2负责系统时基ADC按固定周期采样USART按需发送数据PWM独立运行。把这条链路跑通基本就掌握了F1开发的骨架。4.2 时钟与引脚初始化代码解析先配置时钟。用HAL库的话在SystemClock_Config()里设置HSE为8MHzPLL倍频9倍到72MHzAHB不分频APB1分频2得到36MHzAPB2不分频得到72MHz。这个配置是F1最常用的标准配置。然后使能各外设时钟GPIOA、GPIOB、USART1、ADC1、TIM2、TIM3。注意ADC1的时钟使能是__HAL_RCC_ADC1_CLK_ENABLE()而ADC的时钟源配置要通过RCC-CFGR的ADCPRE位设置通常设为6分频。引脚配置方面PA0设为模拟输入PA9设为复用推挽输出USART TXPA10设为浮空输入USART RXPB0设为复用推挽输出TIM3 PWM。这里要注意PA9和PA10的复用功能是USART1不是普通的GPIO复用配置时要确保USART1的时钟已经使能。// GPIO初始化示例HAL库 GPIO_InitTypeDef GPIO_InitStruct {0}; // PA0 模拟输入 GPIO_InitStruct.Pin GPIO_PIN_0; GPIO_InitStruct.Mode GPIO_MODE_ANALOG; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); // PA9 复用推挽输出 GPIO_InitStruct.Pin GPIO_PIN_9; GPIO_InitStruct.Mode GPIO_MODE_AF_PP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); // PA10 浮空输入 GPIO_InitStruct.Pin GPIO_PIN_10; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_NOPULL; HAL_GPIO_Init(GPIOA, GPIO_InitStruct);4.3 定时器中断与ADC采样的协同实现TIM2配置为1ms中断。PSC设为71ARR设为999这样计数频率是1MHz计数1000次是1ms。使能更新中断在中断服务函数里置一个标志位主循环检测到标志位后启动ADC转换。ADC配置为单次转换模式规则组只有一个通道0。启动转换后等待EOC标志读取DR寄存器得到ADC值。为了减少噪声我连续采16次去掉最大最小值后取平均。// TIM2中断服务函数 void TIM2_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(htim2, TIM_FLAG_UPDATE) ! RESET) { __HAL_TIM_CLEAR_FLAG(htim2, TIM_FLAG_UPDATE); adc_sample_flag 1; } } // ADC采样与滤波 uint16_t adc_read_filtered(void) { uint16_t samples[16]; uint16_t max_val 0, min_val 4095; uint32_t sum 0; for (int i 0; i 16; i) { HAL_ADC_Start(hadc1); HAL_ADC_PollForConversion(hadc1, 10); samples[i] HAL_ADC_GetValue(hadc1); if (samples[i] max_val) max_val samples[i]; if (samples[i] min_val) min_val samples[i]; sum samples[i]; } sum sum - max_val - min_val; return (uint16_t)(sum / 14); }4.4 PWM输出与串口打印的整合调试TIM3配置为PWM模式1PSC71ARR999CCR3根据电压值动态调整。电压越高CCR越大LED越亮。这样就能直观地看到ADC采样和PWM输出之间的联动关系。USART1配置为115200波特率8位数据位1位停止位无校验。每100ms发送一次电压值格式是Voltage: X.XXX V\r\n。发送时用HAL_UART_Transmit()阻塞发送因为数据量很小不会影响系统实时性。调试时我建议先用固定电压测试比如把PA0接到3.3V看串口打印是不是3.300V左右再接到GND看是不是0.000V。确认ADC和串口都正常后再接电位器调电压观察PWM输出的LED亮度变化。这种分步验证的方法能快速定位问题出在哪个环节。5. 常见问题与排查技巧实录5.1 程序下载后不运行或跑飞这是新手最常遇到的问题。排查顺序是先确认BOOT0和BOOT1引脚的电平。F1的启动模式由这两个引脚决定BOOT00时从Flash启动BOOT01时从系统存储器启动。如果你把BOOT0接高了程序就不会从Flash运行。然后检查复位电路。F1的NRST引脚是低电平复位正常工作时应该是高电平。如果NRST被拉低芯片一直处于复位状态。用万用表测一下NRST对地电压正常应该是3.3V左右。再检查供电。F1的工作电压是2.0V到3.6V典型值3.3V。如果电压低于2.0V芯片可能工作不稳定。另外VDDA和VSSA是模拟部分的供电即使你不用ADC也要保证它们正常连接否则芯片可能无法启动。最后检查晶振。如果用了外部晶振起振电容一般是20pF到30pF晶振频率是8MHz。如果晶振不起振芯片会回退到内部8MHz RC振荡器但时钟树配置如果还是按外部晶振算的系统频率就会不对。用示波器测一下晶振引脚看有没有8MHz的正弦波。5.2 串口乱码或收不到数据串口问题排查有一个清晰的链路先确认波特率是否匹配。上位机设的波特率和代码里的是否一致如果代码里用的是72MHz时钟算出来的115200而上位机设的是9600那肯定是乱码。然后确认时钟配置。如果你改了系统时钟但没有重新算USART的波特率实际波特率就会偏。比如你把系统时钟从72MHz改成48MHzUSART1的时钟也跟着变成48MHz但BRR寄存器的值还是按72MHz算的实际波特率就变成了115200 * 48 / 72 76800和上位机的115200不匹配。再确认TX和RX有没有接反。这个错误太常见了TX接TX、RX接RX是错的必须TX接RX、RX接TX。还有GND有没有共地不共地的话电平参考不一致通信也会失败。如果以上都正常用示波器或者逻辑分析仪抓一下TX引脚的波形看有没有数据发出。有波形但乱码就是波特率问题没波形就是代码没执行到发送函数或者引脚配置错了。5.3 ADC采样值跳动大或不准确ADC跳动大通常有三个原因参考电压不稳、采样时间不够、信号源内阻太大。先测VDDA电压如果VDDA本身就在跳动ADC结果肯定不稳。可以在VDDA和VSSA之间加一个0.1uF和10uF的电容滤波。采样时间不够的话ADC内部的采样保持电容还没充到信号电压就开始了转换结果就会偏小。增加采样时间可以解决但会降低采样率。如果信号源内阻很大比如超过100kΩ即使采样时间拉到最大也可能不够这时候需要在信号源和ADC引脚之间加一个电压跟随器。还有一个容易忽略的点ADC的时钟不能超过14MHz。如果你把ADC预分频设成2分频72MHz / 2 36MHz超过了14MHz的上限ADC结果会完全不可信。一定要设成6分频或8分频。5.4 中断不触发或触发后卡死中断不触发先检查NVIC有没有使能对应的中断通道。HAL库中用HAL_NVIC_EnableIRQ()使能标准库中用NVIC_Init()配置。还要检查中断优先级分组F1支持4位优先级可以分成5组。如果两个中断优先级配置冲突可能导致其中一个不触发。中断触发后卡死通常是中断服务函数里没有清除中断标志。比如TIM的更新中断必须清除TIM_SR的UIF位否则中断会一直触发程序一直停在中断里。USART的接收中断要读DR寄存器来清除RXNE标志如果只读数据不清标志也会反复进中断。还有一种情况是中断里调用了阻塞函数比如HAL_Delay()。HAL_Delay()依赖SysTick中断如果你在比SysTick优先级更高的中断里调用它SysTick中断无法抢占HAL_Delay()就会一直等待导致死锁。中断里尽量只做标志位设置和数据搬运耗时操作放到主循环。5.5 常见问题速查表现象可能原因排查方法解决措施下载后不运行BOOT引脚电平错误测BOOT0电压BOOT0接GND下载后不运行晶振不起振示波器测晶振引脚换晶振或调整起振电容串口乱码波特率不匹配核对代码和上位机设置统一波特率串口乱码系统时钟配置错误测MCO引脚频率重新配置时钟树串口无数据TX/RX接反检查接线TX接RX、RX接TXADC跳动大参考电压不稳测VDDA电压加滤波电容ADC不准采样时间不够增加采样周期设为239.5周期ADC不准ADC时钟超频查预分频设置设为6分频中断不触发NVIC未使能查NVIC配置使能对应中断通道中断卡死未清中断标志查中断服务函数添加清标志代码中断卡死中断里用HAL_Delay查中断服务函数改用标志位主循环处理PWM无输出MOE未使能查BDTR寄存器使能主输出提示这张表里的问题我几乎都遇到过尤其是“中断里用HAL_Delay导致卡死”这一条当年调了一个下午才找到原因。记住一个原则中断服务函数越短越好能置标志位就不要做处理。6. 进阶技巧与项目扩展方向6.1 用DMA解放CPUADC多通道扫描与串口不定长接收当你需要同时采集多路ADC或者高速接收串口数据时轮询和中断方式就会显得吃力。DMA是F1上非常实用的功能可以在不占用CPU的情况下完成数据搬运。ADC多通道扫描配合DMA可以自动把多个通道的转换结果依次存入数组。配置时把ADC设为扫描模式、连续转换模式DMA设为循环模式外设地址是ADC的DR寄存器内存地址是数组首地址数据宽度是半字16位。这样ADC转换完一个通道DMA自动把数据搬到内存CPU完全不用管。串口不定长接收用DMA加空闲中断是经典方案。DMA负责把收到的数据搬到缓冲区空闲中断在总线空闲时触发此时读取DMA的剩余传输数量就能算出收到了多少字节。这个方案在接收长度不固定的数据帧时非常高效我做的几个Modbus从机项目都是用这个方案。6.2 用内部Flash模拟EEPROM保存参数F1没有内置EEPROM但可以用内部Flash模拟。F1的Flash擦写寿命大概是1万次所以不能频繁写。我的做法是在Flash末尾划出一个扇区通常是2KB定义一个结构体存放参数每次修改参数时先擦除扇区再写入。为了减少擦写次数可以加一个缓存机制参数变化时先写到RAM掉电前或者每隔一段时间再写入Flash。写Flash时要注意F1的Flash编程是按半字16位进行的写入前必须先擦除。擦除是按页进行的F103C8T6的页大小是1KB。擦除和写入期间CPU会暂停执行Flash中的代码所以最好把擦写函数放到RAM中执行或者确保擦写期间不取指令。不过实际项目中只要不在擦写时处理中断一般不会出问题。6.3 低功耗模式与唤醒源配置F1支持睡眠、停止、待机三种低功耗模式。睡眠模式只关CPU时钟外设还在跑唤醒最快停止模式关掉所有时钟保留SRAM和寄存器内容唤醒后需要重新配置时钟待机模式功耗最低但SRAM内容丢失唤醒相当于复位。实际项目中用得最多的是停止模式。进入停止模式用PWR_EnterSTOPMode()唤醒源可以是外部中断、RTC闹钟、或者USART接收事件。唤醒后系统时钟会切换回HSI需要重新调用时钟配置函数把时钟切回HSEPLL。这个细节很容易忘忘了的话唤醒后串口波特率就不对了。6.4 从F1迁移到其他STM32系列的注意事项如果你在F1上积累了一定经验想换到F4或者G0系列有几个地方需要特别注意。首先是时钟树不同F4的APB1和APB2最高频率都是84MHz以F407为例而且有多个PLL配置选项。其次是GPIO的寄存器结构变了F4的GPIO有独立的MODER、OTYPER、OSPEEDR、PUPDR寄存器不像F1那样用CRL和CRH合并配置。再就是HAL库的API在不同系列之间有细微差异比如F1的HAL_GPIO_Init()和F4的用法基本一致但底层实现不同。我的建议是在F1上把外设的配置逻辑理解透换系列时重点看参考手册中该外设的差异章节而不是从头学起。STM32各系列之间的外设设计思路是一脉相承的F1上学到的知识大部分都能直接迁移。7. 我个人在实际操作中的几点体会调STM32F1这些年最大的感受是不要怕看参考手册。很多人习惯搜例程、抄代码遇到问题就换一个例程再试。但F1的参考手册写得足够详细每个寄存器的每一位都有说明。你花一个小时把某个外设的寄存器章节读一遍比搜十个例程都管用。另一个体会是示波器和逻辑分析仪是必备工具。串口通信、PWM输出、SPI时序、I2C应答这些用眼睛看代码是看不出问题的必须抓波形。我早期调I2C的时候一直收不到从机应答后来用逻辑分析仪抓波形发现是时序中的保持时间不够调整之后立刻就通了。还有一点养成写调试笔记的习惯。F1的外设多配置参数多今天调通了USART过两个月再调可能又忘了某个细节。我习惯把每个外设的配置步骤、关键寄存器、踩过的坑记在一个文档里下次用的时候直接翻笔记效率高很多。最后不要停留在“点灯”阶段。点灯只是验证工具链和基本流程真正让你成长的是做一个小项目把多个外设串起来遇到问题、解决问题。哪怕只是一个温湿度采集加串口显示的小项目也比单独调十个外设收获大。