
简介针对STM32F10x系列微控制器的SYSTEM文件夹是固件库中负责系统级初始化与管理的关键模块适合正在学习或基于ARM Cortex-M3内核进行嵌入式开发的工程师。压缩包内共6个文件以C源文件与头文件为主3个.c和3个.h整体大小仅10KB内容覆盖延时、系统基础支持和串口驱动三大常用功能结构十分精简便于直接移植到Keil或STM32CubeIDE等工程中使用。目前已有667人学习浏览内容组织清晰可作为入门STM32的轻量级参考。这些源码能帮助开发者快速理解系统时钟配置、中断向量处理、GPIO初始化等底层逻辑同时为后续搭配标准库或HAL库使用外部设备打下基础。通过阅读和调试这些代码也能加深对STM32启动流程与库依赖关系的认识提升实际项目中的排错与优化效率。 如果你手里有一块STM32F103开发板几乎不可能避开SYSTEM文件夹。正点原子所有F10x例程的工程树里它都安安静静躺在那里里面只有sys、delay、usart三个子文件却支撑着每一段跑马灯和串口调试代码。我第一次从零搭工程时直接把这个文件夹拖进来编译过了就跑完全没想过它内部在干什么。直到后来自己画板子、换晶振、调试串口波特率乱跳才老老实实把这三个.c文件从头到尾看了一遍。这篇文章打算把SYSTEM文件夹撕开来讲sys.c里的时钟配置为什么不能乱改delay.c里那个滴答定时器延时是怎么换算成微秒和毫秒的usart.c的printf重定向为什么一换板子就失效以及从例程板往自己板子移植时那几条最容易翻车的配置项。适合正在跟STM32例程学习的朋友也适合准备脱离例程、自己搭工程的开发者。1. 例程里那个SYSTEM文件夹到底管住了STM32的哪些底层先搞清楚SYSTEM文件夹的定位。ST官方给出的STM32F10x标准外设库分成了CMSIS和StdPeriph_Driver两大块。CMSIS负责Cortex-M3内核相关的内容比如NVIC、SysTick、SystemCoreClockStdPeriph_Driver是各种外设驱动比如GPIO、USART、RCC。这两层函数已经很够用了但直接用起来还是有点繁琐——你要写一个简单的LED闪烁得先使能GPIO时钟、配置IO模式、再翻转引脚代码量不小而且每个工程都要重复。SYSTEM文件夹就是在库函数之上又做了一层针对开发板的封装本质上是一套精简BSP板级支持包。sys管时钟和基础IOdelay管延时usart管调试输出覆盖了嵌入式开发最初级也最高频的三类需求。这个设计思路在后来的板卡支持包里都能看到影子底层驱动加板级封装的组合是工程代码从“裸机逻辑”走向“可复用工程”的第一步。模块关键文件核心职责syssys.c / sys.h系统时钟初始化、中断分组、位带IO操作宏delaydelay.c / delay.h基于SysTick的微秒和毫秒延时usartusart.c / usart.h串口1初始化、printf重定向、接收中断这三个模块在物理上是相对独立的但几乎所有例程都会一起使用所以被打包成了一个SYSTEM文件夹。你打开任何一个正点原子例程的main函数大概率会看到这样的初始化骨架delay_init(72); // 延时函数初始化传入系统时钟72MHz NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); // 设置中断分组 uart_init(115200); // 串口1初始化波特率115200这三行代码分别调用了SYSTEM文件夹里三个模块的入口。也就是说工程上电后最早的一批动作——定时钟、定期望的中断抢占规则、定调试输出通道全都由这个文件夹决定了。这个结构给了一个很重要的工程启发不是所有代码都要写进main也不是所有功能都要从寄存器摸起。把稳定、通用、容易出错的底层操作抽出来固定好上层业务逻辑才能专注做自己的事。理解了这一点再看SYSTEM文件夹就不再只是“例程自带的骨头架子”而是一份值得逐行读的板级驱动范本。2. sys.c背后时钟树、中断分组以及那套“像51单片机一样点灯”的位带宏2.1 Stm32_Clock_Init在忙什么sys.c里最容易被跳过的就是Stm32_Clock_Init因为它经常在main里“悄悄”出现而且传参很简单。这个函数的原型是void Stm32_Clock_Init(u8 PLL);传入的参数是PLL倍频系数。正点原子F103板子外部晶振一般是8MHz目标系统时钟是72MHz所以调用时传9。函数内部做的事包括配置FLASH等待周期、使能外部高速晶振HSE、等待晶振稳定、配置AHB和APB分频、配置PLL倍频、最后把系统时钟切换到PLL输出。这里有几个细节经常被忽略。第一FLASH等待周期必须跟着系统时钟走。F103在72MHz下运行时FLASH需要2个等待周期如果设置成0或1程序取指速度跟不上CPU频率表现为“偶尔跑飞”“莫名死机”。这种故障极难定位因为看起来像是逻辑错误实际上是被时钟拖垮的。第二APB1总线最大只能36MHz。I2C、USART、TIM这些外设挂在APB1上如果把APB1分频配成1外设时钟直接超规格波特率会错得离谱。官方要求APB1最大36MHz所以72MHz系统时钟下APB1分频系数必须是2。第三PLL倍频不是随便写的。外部晶振频率乘以倍频系数必须落在STM32F103系统时钟允许范围内。标准库代码里9倍频在8MHz晶振下得到72MHz如果换成12MHz晶振倍频系数要改成6。很多人只改stm32f10x.h里的HSE_VALUE不改这里结果SystemInit配出来一套时钟Stm32_Clock_Init又配出来另一套程序外围设备全部乱套。用一个不太严谨但好懂的类比时钟树就像城市供水系统HSE是水源PLL是增压泵AHB和APB分频器是小区减压阀。你不能越过增压泵直接把水源接给CPU也不能在给外设供水时不降压否则“水管”会爆。sys.c里这几段代码干的就是校表、调泵、设阀门这些事。2.2 中断分组整个工程只能设一次sys.c里除了时钟还有一个经常被当成“万能配置”的函数NVIC_PriorityGroupConfig。注意这个函数在F103上整个工程只能调用一次。因为中断分组设置的其实是SCB-AIRCR里的PRIGROUP位段它以全局方式决定整个芯片所有中断的优先级划分规则。如果main里设了分组2某个外设BSP里又设了分组1后者会把前者的设置覆盖掉中断抢占关系就完全不符合预期了。正点原子例程默认用分组2即2位抢占优先级、2位子优先级。这意味着你最多可以定义4个抢占等级每个抢占等级下又有4个子优先级。如果你的项目里中断较多建议沿用这个分组并在所有外设初始化之前设置好之后不要再调用第二次。2.3 sys.h里的位带操作宏老工程师的浪漫sys.h给IO操作提供了一套类似51单片机sbit的宏#define PAout(n) BIT_ADDR(GPIOA_ODR_Addr, n) #define PAin(n) BIT_ADDR(GPIOA_IDR_Addr, n)于是你可以写PAout(0) 0直接把PA0拉低也可以写temp PAin(1)读取PA1电平。表面上看这是“语法糖”底层原理是Cortex-M3的位带机制外设位带区0x40000000上的一个位映射到别名区0x42000000上的一个32位字。修改别名区地址的数值等同于修改原地址的某个bit。这套宏的好处是代码简洁、可读性强特别适合裸机点灯、按键扫描这类高频IO操作。坏处是它直接操作绝对地址严重依赖F103的存储器映射。换到F0、G0或L4系列地址就不一样了宏也要重写。所以它更像“板级专属工具”不适合放进跨平台通用代码里。另外一个容易踩的坑是位带操作和库函数操作混用时如果先调用了GPIO_WriteBit再直接改ODR寄存器的位带地址看起来都是在操作IO但中间的缓存或闭锁逻辑可能不一致。实际调试中我发现混用后偶尔会出现“明明改了硬件没反应”的情况。建议一个端口尽量统一用一种操作方式要么全部位带宏要么全部标准库函数。3. delay.c用SysTick做延时时间是怎么算出来的3.1 为什么是SysTick而不是TIM定时器做延时最直接的想法是空循环但空循环依赖编译优化换个优化等级延时时间就变了极不可靠。用定时器可靠但每路延时功能都要占一路TIM功能一多资源就吃紧。SysTick是Cortex-M3内核里自带的24位递减计数器所有F103芯片都有不需要占用外部TIM资源所以它是做延时功能最划算的选择。这里要提一个概念SysTick在RTOS里通常被设成操作系统的心跳。如果项目里跑了RTOSdelay.c里的延时函数就不能盲目“死等”否则系统调度会被卡住。正点原子新版delay.c用SYSTEM_SUPPORT_OS这个宏做了条件编译在OS环境下会切入系统延时或任务切换逻辑。裸机环境下则保持最纯粹的轮询机制。3.2 fac_us和fac_ms是怎么来的delay_init里有一句关键配置SysTick_CLKSourceConfig(SysTick_CLKSource_HCLK_Div8);它把SysTick的时钟源设置为HCLK的8分频。当系统时钟SYSCLK72MHz时SysTick实际工作时钟为9MHz也就是1秒计数900万个tick。那么1微秒需要9个tick所以fac_us SYSCLK / 8; // 72 / 8 9 fac_ms fac_us * 1000; // 9000delay_us的实现就是把这个换算关系直接放进LOAD寄存器SysTick-LOAD nus * fac_us; // 延时nus微秒需要装载多少个tick SysTick-VAL 0x00; // 清空当前计数值 SysTick-CTRL | SysTick_CTRL_ENABLE_Msk; // 使能计数器然后程序等COUNTFLAG位置1COUNTFLAG是SysTick控制寄存器CTRL的bit16计数器从LOAD递减到0后这个标志位会被硬件置1读CTRL寄存器的动作会顺便把它清掉。所以后面会看到一个do-while循环do { temp SysTick-CTRL; } while ((temp 0x01) !(temp (1 16)));这个循环条件里temp 0x01是确保SysTick还处于使能状态!(temp (1 16))是等COUNTFLAG置位。两个条件同时满足就继续等直到计数完成。delay_ms的逻辑类似只不过用的是fac_ms即SysTick-LOAD nms * fac_ms;这里隐藏了一个经典问题SysTick的LOAD寄存器只有24位最大值是0xFFFFFF也就是16777215。在9MHz工作时钟下单次最多能延时约1.864秒。如果你用的delay_ms是直接装载版本的旧代码传一个大于约1864的参数装载值会被高位截断延时时间完全不对。很多人在网上问“为什么delay_ms(2000)不准”根因就是这个。新版例程里已经做了分段处理把超过安全范围的延时拆成多次调用如果你在看旧版代码遇到长延时一定要自己检查这一段。3.3 RTOS环境下延时函数不能“死等”裸机环境下delay_ms就是一个简单的“重复等待COUNTFLAG置位”的过程。但在RTOS环境下如果任务里调用一个1000ms的延时CPU在这里空转1秒那么比它低优先级的任务全部得不到执行操作系统就名存实亡了。正点原子的处理思路是当检测到OS正在运行且不在中断上下文时延时时间超过一个时间片起点就把CPU让出去由操作系统调度到其他任务等时间到了再切回来。这个设计的关键是把“空等”变成“挂起”从软件架构上解决延时函数和操作系统共存的矛盾。如果你自己做RTOS移植建议直接参考这种分层思路底层保留裸机可用的delay_us和delay_ms上层用宏或弱函数接入RTOS的延时接口。这样无OS时用轮询有OS时自动切换代码可移植性会高很多。4. usart.cprintf重定向的两种坑你看懂了才算真正会用串口4.1 初始化串口时PA9的配置容易漏一个“复用”usart.c里第一件事是初始化串口1。引脚配置这一行很多人会写错GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; // TX引脚复用推挽输出 GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; // RX引脚浮空输入PA9如果只是配置成普通推挽输出GPIO_Mode_Out_PP虽然引脚也有高低电平能力但USART外设的内部信号没法通过它送出去结果就是发不出数据。必须用复用推挽GPIO_Mode_AF_PP把GPIO的控制权交给USART外设收发信号才能正常。初始化顺序也有讲究先使能GPIOA和USART1的时钟再配置GPIO然后调用USART_Init接着使能接收中断USART_ITConfig最后配置NVIC。如果NVIC没配好接收中断永远进不去表现为“能发不能收”。4.2 fputc重定向以及那个容易看混的标志位printf重定向是usart.c里最有价值的部分。标准库的printf最终会调用fputc往“标准输出”写字符在PC上标准输出是显示器在单片机裸机上默认没有。你只要改写fputc把数据引导到串口发送寄存器printf就变成了“串口打印函数”int fputc(int ch, FILE *f) { while ((USART1-SR 0X40) 0); // 等待上一次发送完成 USART1-DR (u8) ch; return ch; }这里有一个非常经典的细节0X40是USART_SR里的TC位表示发送完成Transmission Complete而TXE位是0X80表示发送数据寄存器空TXE。等你条件判断成TC说明当前字符已经彻底移位发送完毕等TXE意味着寄存器空了就可以写下一字节速度更早启动。两种写法都能用但理解它们的区别能帮你解释“串口为什么少发一个字节”这类玄学问题。还有一个更大的坑是半主机模式。在Keil MDK中如果你只改fputc但不勾选Use MicroLIB程序在调用printf时可能进入半主机模式把数据通过调试器而不是串口发送出去。现象就是代码在硬件上跑着串口助手那边什么都没有但调试器里能看到printf的输出。解决方式最省事的是在Keil魔术棒里勾选Use MicroLIB也可以在工程里自己实现半主机相关函数但新手不建议折腾后者。4.3 串口接收的中断状态机你看得懂就能自己改协议usart.c里还定义了一套接收逻辑核心是一个数组USART_RX_BUF和一个状态变量USART_RX_STA。这个状态变量的各位有明确分工位段含义bit15置1表示一帧接收完成bit14置1表示已收到0x0D回车bit13~bit0已接收的字节数接收中断里每收到一字节先判断是不是0x0D收到回车后再判断下一个是不是0x0A只有回车换行连续到达才把bit15置1表示一帧数据完整接收。同时如果接收长度超过USART_REC_LEN会主动清零重新接收防止数组越界。这套设计是一个典型的“行协议”接收器简单可靠适合调试命令、AT指令这类以回车换行结尾的数据。但它也有局限没有缓冲队列。如果主循环处理速度跟不上串口接收速度后面的字节可能被覆盖。遇到高速不定长数据流我更推荐环形缓冲区配合DMA接收空闲中断的方案那套逻辑虽然复杂一点但抗压能力强很多。5. 把SYSTEM文件夹搬到自己板子上我的移植记录和翻车现场5.1 第一个坑外部晶振不是8MHz时钟树全乱正点原子例程默认外部晶振8MHzStm32_Clock_Init(9)得到72MHz。自己画板时如果用了12MHz晶振仍然调用Stm32_Clock_Init(9)会得到108MHz的系统时钟F103最高频率是72MHz结果就是程序跑起来非常不稳定甚至频繁复位。正确的做法是改了晶振就要同时改两处。第一处是stm32f10x.h里的HSE_VALUE宏默认值8000000要改成12000000第二处是main里Stm32_Clock_Init的参数12MHz晶振要得到72MHz倍频系数是6。另外还要确认system_stm32f10x.c里的SystemInit是否也会读取HSE_VALUE并配置PLL如果它默认配置了PLL到72MHz而你又在main里调了一遍Stm32_Clock_Init两次配置不一致系统时钟最终取决于第二次调用结果。这个坑的特点是编译零报错运行偶尔正常偶尔不正常用示波器看时钟才能发现真相。5.2 第二个坑Include Path漏配编译第一个报错就翻车把SYSTEM文件夹复制进工程后还要在Keil的C/C选项卡的Include Path里加上三个头文件路径sys、delay、usart。漏配的话第一个报错通常就是“cannot open source file sys.h”。这个错最好解决但也最大意不得因为复制例程工程文件时路径经常是绝对路径换一台电脑就找不到。我现在的习惯是工程里所有头文件路径全部用相对路径比如.\SYSTEM\sys。这样整个工程文件夹打包发给别人解压后直接编译不会再因为路径问题浪费半天时间。5.3 第三个坑printf能编过但没输出先查MicroLIB遇到printf完全没输出但串口初始化没问题的情况我的排查顺序是检查Keil是否勾选Use MicroLIB检查fputc里等待的标志位是不是被优化掉了检查串口助手波特率、校验位、停止位是否和uart_init参数一致检查PA9是不是被复用成了其他外设。在全部检查过之后还有问题就接示波器看TX引脚的波形。之前有次排查了很久最后发现是PA9和PA10的杜邦线接触不良属于纯粹硬件问题。5.4 第四个坑JTAG引脚被当普通IO用下载器直接罢工F103的PA13、PA14、PA15、PB3、PB4默认是JTAG/SWD功能。如果某个驱动初始化里把这些引脚配置成了普通GPIO可能直接关闭调试口下一次下载就连不上芯片了。正点原子sys.h里提供了一些禁用JTAG的宏比如GPIO_Remap_SWJ_Disable之类的调用移植时要注意如果是自己的板子确认还在用SWD下载就别轻易关闭SWJ功能。万一已经关了只能用串口ISP擦除或拉低BOOT0再恢复非常麻烦。5.5 第五个坑中断分组被重复设置外设中断表现诡异前面说过NVIC_PriorityGroupConfig只能调用一次。但工程里的外设驱动一多每个BSP都可能习惯性地在初始化里配置一次中断分组。如果main设了分组2某个外设驱动里又设了分组0整个中断优先级系统就乱了表现为高优先级中断进不去、两个中断互相抢占、程序卡在某个ISR里出不来。我的建议是统一在main函数最开头设置中断分组并把它注释清楚比如“整个工程只允许设置一次修改前先搜索NVIC_PriorityGroupConfig”。这样后续接手的人不会乱改。结尾说实话SYSTEM文件夹里没有什么黑魔法但它非常精准地展示了嵌入式开发的工程化思维把最常用、最容易出错的部分封装成稳定接口让上层业务逻辑可以专注功能。我第一次完整读完这三个文件时最大的收获不是某个寄存器怎么配而是学会了在工程里主动划清层次。如果这篇文章能帮你的工程跑起来我建议你再做一步在读懂之后试着用自己的代码风格重写一个简版SYSTEM文件夹只保留你需要的那几个功能。这个“拆解再重组”的过程比看十遍教程都有用。我自己就是靠着这条路从复制例程走到了独立做板。本文还有配套的精品资源点击获取