STM32F103标准库3.6.0工程搭建与外设实战避坑

发布时间:2026/10/26 19:52:41
STM32F103标准库3.6.0工程搭建与外设实战避坑

简介:STM32F10x标准库3.6.0最终版面向使用STM32F10x系列芯片的嵌入式开发者、高校学生与初学者,提供无需直接操作寄存器的外设函数接口,便于快速完成工程搭建、外设驱动、课程实验与代码维护。压缩包共960个文件,约21.25MB,以349个C源文件、277个头文件为核心,配合大量txt说明、汇编启动文件、工程配置及链接脚本,覆盖库函数实现、示例工程、启动代码和操作说明书等常用内容。已有2823人学习下载,适合作为开发参考、项目移植与版本归档。读者可从中获得完整标准外设库目录、芯片外设驱动实现、启动与工程模板,以及随包说明书中的调用说明,便于对照理解库函数用法、减少寄存器配置成本,并提升代码可读性与项目移植效率,也能为毕业设计和中小型嵌入式项目提供可靠的基础代码参考。

1. 还在维护老项目的人,绕不开这个库

上周有个朋友拿来一块两年前画的板子,芯片是 STM32F103,代码里清一色GPIO_Init、RCC_APB2PeriphClockCmd,一看就是 STM32F10x 标准库的手笔。这个以 3.6.0 为版本号的最终整合包,内容就是 CMSIS 内核文件、StdPeriph_Driver 外设驱动、六种容量的启动文件、一套工程模板和几十个片上外设例程,让你不必逐条对照寄存器手册,也能把 GPIO、串口、定时器、DMA、ADC 跑起来。

它适合谁:手上还悬着老项目要继续改的、课程设计要求必须用寄存器或标准库思路的、想搞清 HAL 之前那一代代码长什么样的,以及准备用标准库当"参考实现"来理解寄存器位定义的人。它不算新,但它把 F1 系列的外设抽象写得足够直白,这一点到今天依然有用。

2. 包内结构拆解:从 CMSIS 内核文件到 StdPeriph_Driver 的分层逻辑

拿到压缩包先别急着双击工程,把目录摊开看一眼,后面少走不少弯路。标准库的文件组织是严格按"内核—芯片—外设"三层分的,理解这个分层,比记住某个 API 名字重要得多。

2.1 顶层三个目录各管什么

拷进自己工程时,通常只动 Libraries,Project 和 Utilities 是用来抄例程的:

Libraries/ # 真正要拷进工程的 ├── CMSIS/ │ └── CM3/ │ ├── CoreSupport/ # core_cm3.c / core_cm3.h,内核访问层 │ └── DeviceSupport/ # 芯片相关 │ └── ST/STM32F10x/ │ ├── stm32f10x.h # 寄存器结构体、外设基地址、中断号 │ ├── system_stm32f10x.c/.h # SystemInit、时钟树配置 │ └── startup/arm/ # startup_stm32f10x_xx.s,按容量选 └── STM32F10x_StdPeriph_Driver/ ├── inc/ # stm32f10x_gpio.h、rcc.h、usart.h、misc.h… └── src/ # 与 inc 一一对应的 .c Project/ ├── Template/ # 最小工程骨架:main.c、it.c、conf.h └── Examples/ # 按外设分目录的例程,GPIO/USART/TIM/ADC/DMA… Utilities/STM32_EVAL/ # 板级 BSP,官方评估板用的,一般不用

CoreSupport里的core_cm3.h提供的是 NVIC、SysTick、SCB 这些内核外设的内联函数,跟具体芯片无关;DeviceSupport才是 STM32F10x 特有的东西,寄存器结构体定义、外设基地址、中断号全在stm32f10x.h里。驱动层只依赖这两层,不反向依赖,所以你把Libraries整个搬到任何 F1 工程都能直接用。

有个细节值得留意:stm32f10x.h开头有一大段条件编译,要求你必须在工程里定义STM32F10X_LD/MD/HD/XL/CL中的某一个,否则直接#error "Please select first the target STM32F10x device used"。这个宏决定了中断向量数量、外设基地址偏移,也决定了后面启动文件该配哪一份,是新手第一个卡点。

2.2 stm32f10x_conf.h 与 assert_param 的开关作用

stm32f10x_conf.h这个文件很多人当摆设,其实它管两件事。第一是头文件聚合:所有外设驱动的.h都在这里#include,驱动源文件里统一只写#include "stm32f10x_conf.h",你要裁掉不用的外设,注释掉对应行就行,编译时间会明显下降。第二是参数断言:

/* stm32f10x_conf.h 末尾的断言定义,USE_FULL_ASSERT 打开时生效 */ #ifdef USE_FULL_ASSERT #define assert_param(expr) ((expr) ? (void)0 : assert_failed((uint8_t *)__FILE__, __LINE__)) void assert_failed(uint8_t* file, uint32_t line); #else #define assert_param(expr) ((void)0) /* 关闭时被优化成空语句,零开销 */ #endif

每个库函数入口都有assert_param(IS_GPIO_ALL_PERIPH(GPIOx));这样的检查。关掉时它是空语句,不占空间;打开后参数传错会走到assert_failed,你能直接拿到文件名和行号。我一般调试阶段打开、出货前关掉,因为它确实会多占几 KB Flash,而且在中断里调用还可能拉长响应时间。

注意:打开USE_FULL_ASSERT却不实现assert_failed函数,链接阶段会报未定义符号;有些人只在stm32f10x_it.c里写了个空实现,结果断言失败时程序静默死循环,比直接报错更难查。

2.3 启动文件按容量选,选错就是白忙

startup/arm/下面那一堆.s文件不是随便挑一个就行,Flash 容量不同,中断向量表长度和默认堆栈大小都不一样:

启动文件对应容量Flash 区间典型型号
startup_stm32f10x_ld.s小容量16–32 KBSTM32F103C4/C6
startup_stm32f10x_md.s中容量64–128 KBSTM32F103C8/CB、R8/RB
startup_stm32f10x_hd.s大容量256–512 KBSTM32F103RC/RE、VC/VE
startup_stm32f10x_xl.s超大容量512 KB 以上STM32F103ZE/ZG
startup_stm32f10x_cl.s互联型—STM32F105/107
startup_stm32f10x_ld/md/hd_vl.s超值型—STM32F100 系列

这个.s文件里最重要的三段是:向量表(__Vectors)、Reset_Handler、以及末尾的__initial_sp。Reset_Handler会先BL SystemInit配时钟,再BL __main走 C 库初始化(拷贝.data、清零.bss),最后才进你的main。所以任何"程序没进 main"的问题,八成出在这一段,不在这段之后的 C 代码里。

3. 从零搭工程:把 3.6.0 标准库挂进一颗 STM32F103 的完整步骤

例程能跑不代表你会建工程,中间那层配置才是真正需要抄下来的部分。这一章按"拷文件—建工程—配宏—写 main—验证"五步走。

3.1 目录怎么拷,包含路径怎么排

先在自己工程根目录建这样的结构,把库里对应文件拷进来(不要在原包里直接改,原包留着当参照):

MyProject/ ├── User/ # main.c、stm32f10x_it.c/h、stm32f10x_conf.h ├── Libraries/ │ ├── CMSIS/ │ │ ├── core_cm3.c/h │ │ ├── system_stm32f10x.c/h │ │ ├── stm32f10x.h │ │ └── startup_stm32f10x_hd.s │ └── StdPeriph_Driver/ │ ├── inc/*.h │ └── src/*.c └── Project/ # Keil 工程文件放这

包含路径(Include Paths)按这个顺序加:..\User、..\Libraries\CMSIS、..\Libraries\StdPeriph_Driver\inc。路径顺序不重要,但少一个就会在编译时报cannot open source input file "stm32f10x.h",这是最高频的报错,先查这里。

3.2 Keil 里的五个关键配置项

在 Keil MDK 里新建 Target,器件选你实际的型号(比如 STM32F103RC),然后逐项确认:

  1. Target 页:Xtal 填你板上的晶振频率,通常是 8 MHz;需要printf重定向到串口就勾上 Use MicroLIB。
  2. C/C++ 页的 Define:填USE_STDPERIPH_DRIVER, STM32F10X_HD(容量宏按你芯片改)。USE_STDPERIPH_DRIVER一旦漏掉,stm32f10x.h就不会包含stm32f10x_conf.h,后面所有外设类型都变成未定义。
  3. C/C++ 页的 Include Paths:上面那三条。
  4. Debug 页:选 ST-Link 或 J-Link,Settings 里 Port 选 SW,Flash Download 勾上 Reset and Run。
  5. Output 页:勾 Create HEX File,方便脱机烧录。

这里有个血泪经验:先加启动文件,再加驱动文件。Keil 的 Groups 里建Startup、CMSIS、StdPeriph、User四个组,把startup_stm32f10x_hd.s单独放Startup组,凡是出现在 Groups 里的.c才能被编译,很多人文件拷全了但忘了加进工程,最后报一堆undefined symbol。

3.3 用 arm-none-eabi 走命令行也能编

不想装 IDE 或者要接 CI 的话,标准库本身是纯 C,用开源工具链照样能编。下面是一段可以照抄的 Makefile 核心部分:

# 交叉工具链前缀,装好 gcc-arm-none-eabi 后一般就叫这个名字 CROSS = arm-none-eabi- CC = $(CROSS)gcc OBJCOPY = $(CROSS)objcopy TARGET = demo # 容量宏必须和芯片对上,它同时影响寄存器定义和中断向量数量 DEFS = -DSTM32F10X_HD -DUSE_STDPERIPH_DRIVER -DHSE_VALUE=8000000 CFLAGS = -mcpu=cortex-m3 -mthumb -O0 -g3 -Wall $(DEFS) \ -ILibraries/CMSIS -ILibraries/StdPeriph_Driver/inc -IUser LDFLAGS = -T stm32f103rc.ld -Wl,--gc-sections -specs=nano.specs -specs=nosys.specs SRCS = $(wildcard User/*.c) \ $(wildcard Libraries/StdPeriph_Driver/src/*.c) \ Libraries/CMSIS/system_stm32f10x.c \ Libraries/CMSIS/core_cm3.c OBJS = $(SRCS:.c=.o) ASM = Libraries/CMSIS/startup_stm32f10x_hd.s all: $(TARGET).bin $(TARGET).elf: $(OBJS) $(ASM) $(CC) $(CFLAGS) $^ $(LDFLAGS) -o $@ %.bin: %.elf $(OBJCOPY) -O binary $< $@

几个参数值得单独说:-mcpu=cortex-m3 -mthumb是 Cortex-M3 的硬性要求,漏掉-mthumb会编出 ARM 指令集代码,上电直接跑飞;-specs=nano.specs换用精简版 C 库,能省下十几 KB;-specs=nosys.specs是因为裸机上没有系统调用,不加上会在链接_write、_sbrk时失败。链接脚本stm32f103rc.ld要按芯片的 Flash 起始地址0x08000000和 RAM 起始地址0x20000000自己写,容量对不上的话,程序会烧进去但一运行就 HardFault。

3.4 第一个能跑起来的 main.c

工程配好后,先别急着上外设,跑通一颗 LED 就能验证"时钟 + 启动 + 链接"整条链路:

#include "stm32f10x.h" /* 简单延时,靠空循环凑时间,只用于点灯验证 */ static void delay(volatile uint32_t n) { while (n--) { __NOP(); } } int main(void) { GPIO_InitTypeDef gpio; /* 1. 先开时钟,标准库里任何外设操作之前都必须做这一步 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); /* 2. 填结构体:PA5 推挽输出,速度 50MHz */ gpio.GPIO_Pin = GPIO_Pin_5; gpio.GPIO_Mode = GPIO_Mode_Out_PP; /* 推挽输出,能提供灌电流和拉电流 */ gpio.GPIO_Speed = GPIO_Speed_50MHz; /* 只影响边沿陡度,点灯用 2MHz 也够 */ GPIO_Init(GPIOA, &gpio); while (1) { GPIO_SetBits(GPIOA, GPIO_Pin_5); /* 输出高 */ delay(2000000); GPIO_ResetBits(GPIOA, GPIO_Pin_5); /* 输出低 */ delay(2000000); } }

这段代码里有三个容易忽略的点。第一,RCC_APB2PeriphClockCmd必须在GPIO_Init之前调用,否则写进寄存器的配置根本不生效,现象是"程序在跑但引脚不动"。第二,GPIO_Speed不是输出频率,而是输出驱动电路的翻转速率,只有做 SPI、SDIO 这类高速信号时才需要调到 50MHz,普通点灯设 2MHz 更省功耗。第三,标准库的初始化函数内部会先读寄存器再改位,所以同一个端口的不同引脚可以分批初始化,不会互相覆盖。

SystemInit是启动文件里自动调用的,你不需要在main里手动调。它把系统时钟配到 72MHz(SetSysClockTo72),并更新全局变量SystemCoreClock,后面算波特率、定时器分频都依赖这个值。

4. 核心模块拆解:GPIO、RCC、USART、TIM+DMA 四条代码链路

标准库有二十多个外设驱动,真正常用的就那么几个。把下面四条链路吃透,剩下的大多是同样的套路:开时钟 → 填结构体 → 调 Init → 配中断或 DMA → 使能。

4.1 RCC:72MHz 到底是怎么配出来的

打开system_stm32f10x.c,SetSysClockTo72里干的事其实就五步,用寄存器写出来比看库函数更直观:

/* 节选自 SetSysClockTo72,注释是按执行顺序补的 */ RCC->CR |= ((uint32_t)RCC_CR_HSEON); /* 1. 打开外部晶振 */ while ((RCC->CR & RCC_CR_HSERDY) == 0) { } /* 等它起振,注意这里是死等 */ FLASH->ACR |= FLASH_ACR_PRFTBE; /* 2. 开预取缓冲 */ FLASH->ACR &= (uint32_t)((uint32_t)~FLASH_ACR_LATENCY); FLASH->ACR |= (uint32_t)FLASH_ACR_LATENCY_2; /* 72MHz 必须 2 等待周期 */ RCC->CFGR |= (uint32_t)RCC_CFGR_HPRE_DIV1; /* 3. AHB = 72MHz */ RCC->CFGR |= (uint32_t)RCC_CFGR_PPRE2_DIV1; /* APB2 = 72MHz */ RCC->CFGR |= (uint32_t)RCC_CFGR_PPRE1_DIV2; /* APB1 = 36MHz,上限就是 36 */ RCC->CFGR &= (uint32_t)((uint32_t)~(RCC_CFGR_PLLSRC | RCC_CFGR_PLLXTPRE | RCC_CFGR_PLLMULL)); RCC->CFGR |= (uint32_t)(RCC_CFGR_PLLSRC_HSE | RCC_CFGR_PLLMULL9); /* 4. 8MHz × 9 = 72MHz */ RCC->CR |= ((uint32_t)RCC_CR_PLLON); while ((RCC->CR & RCC_CR_PLLRDY) == 0) { } RCC->CFGR &= (uint32_t)((uint32_t)~(RCC_CFGR_SW)); RCC->CFGR |= (uint32_t)RCC_CFGR_SW_PLL; /* 5. 切换系统时钟源到 PLL */ while ((RCC->CFGR & (uint32_t)RCC_CFGR_SWS) != (uint32_t)0x08) { }

关键参数是RCC_CFGR_PLLMULL9这个倍频系数和FLASH_ACR_LATENCY_2这个等待周期,两者是绑定的:主频超过 48MHz 就必须把 Flash 等待周期改成 2,否则取指会读到错误数据,表现为"程序跑着跑着莫名跳飞"。另外 APB1 的时钟上限只有 36MHz,所以PPRE1_DIV2不能省,串口 2、定时器 2/3/4 都挂在 APB1 上,分频配错会导致波特率整体偏一倍。

如果你板上的晶振不是 8MHz,改stm32f10x.h里的HSE_VALUE宏或者编译时加-DHSE_VALUE=12000000,不要直接改SetSysClockTo72里的倍频数,因为库里其他地方的超时计数也是按 HSE 算的。

4.2 USART:中断接收加环形缓冲

串口是调试的第一入口,但直接用USART_ReceiveData阻塞轮询,主循环会被拖死。常见做法是开接收中断,配一个环形缓冲区:

#include "stm32f10x.h" #define RX_BUF_SIZE 128 static volatile uint8_t s_rx_buf[RX_BUF_SIZE]; static volatile uint16_t s_head = 0, s_tail = 0; void bsp_usart1_init(uint32_t baud) { GPIO_InitTypeDef gpio; USART_InitTypeDef uart; NVIC_InitTypeDef nvic; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); gpio.GPIO_Pin = GPIO_Pin_9; /* TX 配复用推挽 */ gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); gpio.GPIO_Pin = GPIO_Pin_10; /* RX 必须配浮空或上拉输入 */ gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &gpio); uart.USART_BaudRate = baud; uart.USART_WordLength = USART_WordLength_8b; uart.USART_StopBits = USART_StopBits_1; uart.USART_Parity = USART_Parity_No; uart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; uart.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &uart); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); /* 收到一个字节就进中断 */ nvic.NVIC_IRQChannel = USART1_IRQn; nvic.NVIC_IRQChannelPreemptionPriority = 1; nvic.NVIC_IRQChannelSubPriority = 1; nvic.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&nvic); USART_Cmd(USART1, ENABLE); } /* 中断函数名必须和启动文件向量表里的一字不差 */ void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t ch = (uint8_t)USART_ReceiveData(USART1); /* 读 DR 会自动清 RXNE */ uint16_t next = (s_head + 1) % RX_BUF_SIZE; if (next != s_tail) { s_rx_buf[s_head] = ch; s_head = next; } /* 缓冲区满就丢弃这个字节,业务层靠数据帧头尾自己校验 */ } if (USART_GetFlagStatus(USART1, USART_FLAG_ORE) != RESET) { (void)USART_ReceiveData(USART1); /* 读 SR 再读 DR 才能清溢出标志 */ } } /* 业务层调用:返回实际取到的字节数,0 表示当前没数据 */ uint16_t bsp_usart1_read(uint8_t *buf, uint16_t len) { uint16_t n = 0; while (n < len && s_tail != s_head) { buf[n++] = s_rx_buf[s_tail]; s_tail = (s_tail + 1) % RX_BUF_SIZE; } return n; }

USART_Mode_Rx | USART_Mode_Tx这个或运算是必须的,只写一个方向另一个方向就哑了。NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2)要在main开头调一次,把优先级分组定下来,否则抢占优先级和子优先级的位数不确定,中断嵌套行为会变成玄学。

还有一个坑:ORE(溢出错误)必须处理。主循环来不及取数据时,接收移位寄存器会覆盖上一个字节,如果不清 ORE,中断会一直触发,CPU 卡在中断里出不来。F1 系列上清 ORE 的方式就是"先读 SR 再读 DR",USART_ReceiveData内部已经把这两步做了,所以那句(void)强制丢弃是有效的。

4.3 TIM:PWM 输出的分频与占空比算法

用 TIM3 的通道 1 在 PA6 上出一路 1kHz、50% 占空比的 PWM:

void bsp_pwm_init(void) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tb; TIM_OCInitTypeDef oc; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); gpio.GPIO_Pin = GPIO_Pin_6; /* TIM3_CH1 默认就在 PA6 */ gpio.GPIO_Mode = GPIO_Mode_AF_PP; /* 复用推挽,写 Out_PP 是出不来的 */ gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); /* TIM3 挂在 APB1 上,定时器时钟是 72MHz;72 分频后计数频率 1MHz */ tb.TIM_Prescaler = 72 - 1; tb.TIM_Period = 1000 - 1; /* 计 1000 个数溢出一次,即 1kHz */ tb.TIM_CounterMode = TIM_CounterMode_Up; tb.TIM_ClockDivision = TIM_CKD_DIV1; tb.TIM_RepetitionCounter = 0; /* 只有高级定时器 TIM1/TIM8 用得到 */ TIM_TimeBaseInit(TIM3, &tb); oc.TIM_OCMode = TIM_OCMode_PWM1; oc.TIM_OutputState = TIM_OutputState_Enable; oc.TIM_Pulse = 500; /* CCR1 = 500,占空比 500/1000 */ oc.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM3, &oc); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); /* 改占空比时在更新事件生效 */ TIM_ARRPreloadConfig(TIM3, ENABLE); TIM_Cmd(TIM3, ENABLE); }

频率公式是72MHz / (Prescaler+1) / (Period+1),三个数随便改两个,第三个反推就行。TIM_Pulse改的是捕获比较寄存器 CCR1,运行中直接赋新值就能调占空比,因为开了OCPreload,新值会等到下一个周期开始才生效,不会切出毛刺。

注意TIM_RepetitionCounter这个字段,它只在 TIM1、TIM8 这类高级定时器上有效,给 TIM3 赋值会被忽略,所以填 0 就行。另外高级定时器还要额外调TIM_CtrlPWMOutputs(TIM1, ENABLE)打开主输出,漏了这一步引脚一直是低电平,很难查。

4.4 DMA:让 ADC 采样不占 CPU

最后看一条 DMA 链路,ADC1 连续扫描两个通道,DMA1 通道 1 自动把结果搬到内存数组:

static volatile uint16_t s_adc_val[2]; void bsp_adc_dma_init(void) { ADC_InitTypeDef adc; DMA_InitTypeDef dma; GPIO_InitTypeDef gpio; RCC_ADCCLKConfig(RCC_PCLK2_Div6); /* ADC 时钟 72/6 = 12MHz */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); /* DMA 在 AHB 上,别开错 */ gpio.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1; /* ADC12_IN0 / IN1 */ gpio.GPIO_Mode = GPIO_Mode_AIN; /* 模拟输入,必须 AIN */ GPIO_Init(GPIOA, &gpio); dma.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; /* 源:ADC 数据寄存器 */ dma.DMA_MemoryBaseAddr = (uint32_t)s_adc_val; /* 目的:内存数组 */ dma.DMA_DIR = DMA_DIR_PeripheralSRC; dma.DMA_BufferSize = 2; dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma.DMA_MemoryInc = DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; dma.DMA_Mode = DMA_Mode_Circular; /* 循环模式,采完自动重来 */ dma.DMA_Priority = DMA_Priority_High; dma.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel1, &dma); DMA_Cmd(DMA1_Channel1, ENABLE); adc.ADC_Mode = ADC_Mode_Independent; adc.ADC_ScanConvMode = ENABLE; /* 多通道必须开扫描 */ adc.ADC_ContinuousConvMode = ENABLE; adc.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; /* 软件触发 */ adc.ADC_DataAlign = ADC_DataAlign_Right; adc.ADC_NbrOfChannel = 2; ADC_Init(ADC1, &adc); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); /* 上电必须校准 */ while (ADC_GetResetCalibrationStatus(ADC1)) { } ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)) { } ADC_SoftwareStartConvCmd(ADC1, ENABLE); }

几个参数对应关系要记牢:DMA_PeripheralDataSize必须和外设数据宽度一致,ADC 是 12 位结果放在 16 位寄存器里,所以用HalfWord;DMA_Mode_Circular配合ADC_ContinuousConvMode才能一直采;ADC_SampleTime_55Cycles5是采样窗口,接高阻抗信号源时要往上调,不然采到的值偏低。

DMA1 通道和外设的对应关系是固定的,ADC1 只能走通道 1,串口 1 的发送只能走通道 4,这个映射查参考手册的 DMA 请求映射表,改不了。

5. 避坑与排查:编译过、下载成功却跑不起来的五种现场

标准库工程的坑有个共同特点:编译阶段一声不吭,运行时表现千奇百怪。下面五条都是我在实际项目里反复遇到的,按"现象→原因→解决"记下来,能省不少时间。

5.1 现象:下载后不进 main,停在启动文件里或直接 HardFault

原因:容量宏和启动文件不匹配。比如芯片是 STM32F103RC(256KB,属于大容量),却用了startup_stm32f10x_md.s,中断向量表长度不够,超出部分的中断号全部错位;或者 Keil 里 Define 填的是STM32F10X_MD,stm32f10x.h里算出来的 RAM 大小和实际不符。

解决:三样东西必须一致——芯片实际容量、启动文件名、STM32F10X_xx宏。改完执行一次 Rebuild,不要只点 Build,头文件里的条件编译有时候不会触发重编。如果还是不放心,在main第一行加个GPIO_SetBits翻转引脚,用示波器看有没有波形,比打断点可靠。

5.2 现象:灯闪得飞快或者串口输出全是乱码

原因:HSE_VALUE和板上晶振对不上。库默认按 8MHz 算 PLL 倍频和 Flash 等待周期,你板上焊的是 12MHz,实际主频就变成 108MHz(超频了,能跑但不可靠),波特率也跟着整体偏移,串口助手收到的自然全是乱码。

解决:先确认板上晶振丝印,再改HSE_VALUE。改完顺手在main里打印一下SystemCoreClock,或者用调试器看这个全局变量的值,确认是 72000000 再往下写业务。

5.3 现象:打开断言后程序卡死,串口没有任何输出

原因:stm32f10x_conf.h里USE_FULL_ASSERT打开了,但工程里没有实现assert_failed,或者实现成了while(1);。断言触发后停在死循环里,看起来就像"卡住了",而且因为串口还没初始化完,什么信息都没有。

解决:调试阶段把assert_failed实现成"点亮一个错误指示灯 + 记录line参数到全局变量",然后用调试器看这个变量,直接定位到是哪个库函数第几行断言失败。出货前把USE_FULL_ASSERT注释掉,既省 Flash 又省执行时间。

5.4 现象:串口中断只进一次,之后再也不响应

原因:中断函数名写错了,比如把USART1_IRQHandler写成USART1_IRQhandler,C 语言区分大小写,编译器不会报错,链接器也不会报错(启动文件里的弱符号默认实现是while(1)),程序就默默跳进默认死循环。另一种是标志位没清干净,反复进同一个中断。

解决:中断函数名直接从startup_stm32f10x_hd.s的向量表里复制,别手敲。RXNE 标志读 DR 就清,TXE 标志写 DR 就清,ORE 要先读 SR 再读 DR。另外NVIC_PriorityGroupConfig只在初始化时调一次,放在main开头,别在多个外设初始化里重复调,否则前面设好的优先级分组会被覆盖。

5.5 现象:JTAG 口占了引脚,PB3、PB4、PA15 死活控制不了

原因:STM32F103 上电默认把 PA15、PB3、PB4 分配给 JTAG 的 JTDI、JTDO、NJTRST,你在这三个脚上做普通 GPIO 输出,写寄存器完全没反应。这个现象特别迷惑人,因为代码逻辑、时钟、初始化全对。

解决:如果只用 SWD 调试,直接关掉 JTAG 保留 SWD:

/* 必须在 AFIO 时钟打开之后调用,重映射寄存器才写得进去 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE); GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE); /* 释放 PA15/PB3/PB4 */

注意这一步要放在 GPIO 初始化之前,而且在执行到这句之前,那几个脚还归 JTAG 管。写完之后重新上电,引脚才真正交还给 GPIO。

6. 二次开发改造:在标准库上包一层 BSP,把工程变成可复用模板

标准库最容易被吐槽的一点是"业务代码和寄存器操作糊在一起",main.c里直接GPIO_SetBits,换块板子就得全局搜索替换。想把老工程救回来,最有效的动作是在标准库和业务之间插一层板级支持包(BSP),把外设差异关在驱动里。

6.1 用结构体把外设抽象成设备表

先定义一层薄接口,业务层只认函数指针,不认 USART1 还是 USART2:

/* bsp_uart.h —— 业务层唯一需要包含的头文件 */ #ifndef BSP_UART_H #define BSP_UART_H #include <stdint.h> typedef struct { void (*init)(uint32_t baud); int (*write)(const uint8_t *buf, uint16_t len); /* 返回实际发出的字节数 */ uint16_t (*read)(uint8_t *buf, uint16_t len); /* 返回实际读到的字节数 */ } bsp_uart_t; /* index 0 对应调试串口,1 对应业务串口,具体映射在 .c 里写死 */ const bsp_uart_t *bsp_uart_get(uint8_t index); #endif

实现文件里用一张静态表把三个串口挂上去,标准库的调用全部收在这个文件内部。这样带来两个好处:换芯片型号时只改这一个文件;单元测试时可以把这张表换成桩函数,不接硬件也能跑业务逻辑。

迁移到别的库(比如后来换 HAL)时,改动量也集中在bsp_uart.c里。下图是常见 API 的对应关系,我在做移植评估时一般先列这张表,估出工作量再动手:

动作标准库 3.6.0HAL 库
开 GPIO 时钟RCC_APB2PeriphClockCmd__HAL_RCC_GPIOA_CLK_ENABLE()
GPIO 初始化GPIO_Init+GPIO_InitTypeDefHAL_GPIO_Init+GPIO_InitTypeDef
写引脚GPIO_SetBits/GPIO_ResetBitsHAL_GPIO_WritePin
读引脚GPIO_ReadInputDataBitHAL_GPIO_ReadPin
串口发送USART_SendData+ 等 TXEHAL_UART_Transmit
串口中断自己在USARTx_IRQHandler里清标志HAL_UART_IRQHandler统一分发
定时器 PWMTIM_OCxInit+TIM_CmdHAL_TIM_PWM_Start

表格里最需要注意的是中断那一行:标准库把中断处理完全交给你,HAL 用一个统一的HAL_UART_IRQHandler分发并把状态机维护在句柄里。所以从标准库往 HAL 迁的时候,业务层如果依赖了自行维护的环形缓冲区,迁移后要重新对一遍读写索引的时序,否则会出现偶发丢字节。

6.2 改造后的验证方法:三段式上线检查

封装完不要一次性把所有外设都改掉,按三段验证,每段通过再往下走:

第一段只验启动与时钟。在新工程里保留原main的点灯逻辑,确认SystemCoreClock是 72000000,LED 闪烁周期和原来一致。这一段过不了,说明链接脚本或启动文件有问题,后面所有调试都没有意义。

第二段验串口链路。把 BSP 的write接到printf重定向,连续打印一万行递增计数,同时开接收中断做回环。跑半小时不丢字节,才说明波特率、中断优先级、缓冲区大小是匹配的。

本文还有配套的精品资源,点击获取