简介:嵌入式开发中,参考例程是工程师快速上手芯片外设的重要资源。以STM32F103C8T6为例,其HAL库风格代码覆盖时钟树、GPIO、串口、定时器与ADC等关键模块,理解启动文件和时钟配置顺序是确保程序正确运行的前提。掌握参考例程的工程结构、调试方法与常见陷阱,能显著提升从原型验证到自制PCB项目的迁移效率。本文围绕STM32F103C8T6参考例程,剖析HAL库外设驱动的参数拆解,并给出烧录配置、启动文件选择、BOOT引脚等避坑经验,帮助开发者在实际项目中快速定位问题并沉淀可复用的固件模板。
1. 拿到STM32F103C8T6开发参考例程代码,先别急着编译
很多人收到开发板或者克隆下一个例程包,第一反应是打开工程、点编译,看到“0 Error”就长舒一口气。但真正把这段STM32F103C8T6开发参考例程代码用进自己项目时,串口乱码、定时器不触发、ADC读出来全是满量程,这些问题一个接一个。参考例程的价值从来不是给你一段能点亮LED的代码,而是给你一套每个外设“默认行为”的样本:时钟配置顺序、外设初始化参数、中断回调在哪里返回。这篇文章不谈包装华丽的入门教程,只讲拿到这套例程后,怎么判断它靠不靠谱、怎么跑通,以及从例程到自制PCB项目之间那条最容易翻车的路。适合自己画最小系统板、做原型验证,或者刚开始用HAL库做产品的开发者。
2. 例程代码的目录与启动链路:先分清哪些文件能删、哪些不能动
2.1 为什么现在的参考例程几乎都是 HAL 库风格
早期的 STM32F103 参考例程多用标准外设库,函数名简洁,比如 GPIO_Init、USART_SendData,直接操作寄存器结构体。后来 HAL 库成为主流,现在的 STM32F103C8T6 开发参考例程代码基本都切到 HAL 风格,原因有三:一是厂商的图形化配置工具只生成 HAL 工程,新开项目用旧库反而要手工迁移;二是 HAL 把外设状态、超时处理、中断回调封装成统一接口,换芯片型号时业务代码改动小;三是例程里想查某个函数行为时,HAL 源码路径清晰,能直接跳到驱动文件里看寄存器赋值。
但代价也很明显。HAL 代码本身占用空间比标准库大,F103C8T6 只有 64KB Flash、20KB RAM,HAL 全功能编译产物通常在 30KB 以上,如果跑 RTOS 或者塞大量字符串,会明显紧张。我的选择习惯是:原型验证、快速出功能用 HAL;量产资源吃紧且外设简单时,把 HAL 初始化完的寄存器值固化下来,删除不用的驱动分支。参考例程正好提供这个“中间态”——它能跑,你才有一个可靠基准去裁剪。
提示:不要拿到例程就全局搜索删 HAL 代码试图省 Flash,先打开 .map 文件确认实际占用再动手。
2.2 参考例程目录拆解:启动文件、时钟配置、外设驱动各管一段
一个典型的 STM32F103C8T6 参考例程包,目录结构通常由图形化配置工具生成,长这样:
project/ ├─ Core/ │ ├─ Inc/ # 用户头文件:main.h、gpio.h、usart.h │ └─ Src/ # 用户源码:main.c、gpio.c、usart.c ├─ Drivers/ │ ├─ CMSIS/ # 内核寄存器定义、系统初始化、启动文件 │ └─ STM32F1xx_HAL_Driver/ # HAL 库源码,按外设拆成 .c/.h ├─ MDK-ARM/ # MDK 工程文件、编译产物、烧录配置 └─ project.ioc # 图形化配置工具的工程描述文件这个目录里,真正决定代码能否运行的灵魂文件是启动文件,对应 C8T6 中容量芯片的是startup_stm32f103xb.s。它定义栈大小、中断向量表,以及复位后调用 SystemInit 和 main 的流程。启动文件选错,编译照样通过,下载后就是没反应。F103C8T6 属于中容量产品,如果你发现例程里放的是startup_stm32f103xe.s(对应大容量芯片),下载到 C8T6 上大概率跑飞。
CMSIS 目录不能动,它提供寄存器定义和内核抽象层。HAL_Driver 目录里不用的 .c 文件可以删除省编译时间,但删之前要确认代码没有引用对应外设头文件,否则编译报缺定义。MDK-ARM 目录放工程文件,如果打不开,多数出在 IDE 版本过旧或者编译器切换上,后面章节会专门讲。整个目录里你真正频繁修改的,只有 Core/Src 下的用户代码。
2.3 从启动文件到 main:初始化顺序决定例程能不能跑
参考例程的 main 函数虽然外设不同,但结构基本固定。我把最典型的骨架拆出来:
int main(void) { HAL_Init(); // 复位外设、配置Flash预取、初始化SysTick SystemClock_Config(); // 配置HSE -> PLL -> 系统时钟72MHz MX_GPIO_Init(); // 用户外设初始化,GPIO、串口、定时器都在这里 while (1) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); HAL_Delay(500); } }HAL_Init 做三件事:复位所有外设、打开 Flash 预取缓冲、以 1ms 周期配置 SysTick。SysTick 被 HAL_Delay 使用,所以不要在 HAL_Init 之前调用任何 HAL 函数。SystemClock_Config 是例程里最容易出错的地方,因为它是图形化工具自动生成的,新手往往忽略。F103C8T6 外部晶振常用 8MHz,内部经 PLL 倍频到 72MHz:
void SystemClock_Config(void) { RCC_OscInitTypeDef RCC_OscInitStruct = {0}; RCC_ClkInitTypeDef RCC_ClkInitStruct = {0}; RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE; RCC_OscInitStruct.HSEState = RCC_HSE_ON; // 开启外部8MHz晶振 RCC_OscInitStruct.HSEPredivValue = RCC_HSE_PREDIV_DIV1; RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON; RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE; RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9; // 8MHz x 9 = 72MHz HAL_RCC_OscConfig(&RCC_OscInitStruct); RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2; RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK; RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1; RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2; // APB1最高36MHz RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1; // APB2最高72MHz HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_2); }RCC_OscInitStruct 负责配置时钟源,HSE_ON 表示启用外部晶振,PLLMUL9 把 8MHz 倍频到 72MHz。RCC_ClkInitStruct 负责总线分频,AHB 不分频,APB1 分频 2 得到 36MHz,APB2 不分频保持 72MHz。这里有个特别容易踩的问题:如果板子晶振不是 8MHz 而是 16MHz,PLLMUL9 会把系统超频到 144MHz,芯片可能直接死掉或者发热明显。改例程的第一件事永远是确认板子外部晶振频率,再回来看 PLL 配置。
FLASH_LATENCY_2 是 Flash 等待周期,系统时钟超过 48MHz 必须设为 2,否则 Flash 读取跟不上内核频率,程序随机跑飞。这也是参考例程和自制板的第一个契合点:时钟配置吃透,后面所有外设的波特率、PWM 频率、ADC 采样时钟才有计算基准。
3. 把参考例程跑起来:编译、烧录与第一次断点调试的实际配置
3.1 工具链选型:MDK、STM32CubeIDE 与命令行 makefile 怎么选
STM32F103C8T6 开发参考例程代码最常见的载体是 MDK 工程,其次是 STM32CubeIDE 工程,少部分给 makefile。选型标准很实际:如果手头调试器是 CMSIS-DAP 类兼容设备,MDK 和 CubeIDE 都能直接跑;如果习惯命令行脚本做自动化编译,或者想把编译过程集成到现有工具链里,选 makefile 工程配 arm-none-eabi-gcc。
| 工具链 | 上手难度 | 断点调试 | 自动化集成 | 适合场景 |
|---|---|---|---|---|
| MDK | 低 | 好用 | 一般 | 存量工程最多,中文资料多 |
| STM32CubeIDE | 低 | 好用 | 一般 | 新开项目、免费、内置配置向导 |
| makefile + GCC | 中 | 需搭配调试器命令 | 强 | 批量编译、持续集成、模板复用 |
我的建议是不要同时开三个工程。选定一套工具链后,把例程从另外两种格式里提取出 Core 和 Drivers 目录,重新建工程导入即可。例程代码本身跨编译器,重点不在工程格式,而在 main.c、外设初始化文件、启动文件三个要素是否配套。
3.2 烧录配置里必须设对的几个关键参数
无论用 MDK 还是 CubeIDE,例程第一次烧录都有几个参数要检查,否则会遇到目标芯片连接失败。在调试器设置里,核心配置是这一组:
调试器类型:按实际硬件选择(CMSIS-DAP 或 ST-Link 兼容设备) 接口:SWD 连接速度:1MHz ~ 4MHz Flash算法:STM32F10x Med-density FlashSWD 只需要 PA13 和 PA14 两根线,加上 GND。开发板上这两个引脚通常直连调试器接口,但自制板要留意:SWD 引脚旁边不要走高频信号线,否则下载到一半报错率极高。连接速度是玄学重灾区,4MHz 以下最稳,面包板飞线时降到 1MHz,失败率会明显下降。
Flash 算法选错是另一个经典翻车点。C8T6 是 Med-density(中密度),64KB Flash,选成 High-density 或 Low-density 都会出现下载失败或者烧完不运行。每次换例程工程,我都先看这个配置项,它比代码本身更容易让板子沉默。
3.3 用 GPIO 翻转和调试器确认例程真的在跑
很多例程自带的点灯代码能证明编译器没毛病,但证明不了时钟配置和外设初始化正确。我常用的验证方式,是加一个 1 秒周期的 GPIO 翻转,把示波器探头放对应引脚上看真实频率:
// 在 main 的 while(1) 里替换原有 LED 操作 while (1) { HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_1); // PB1 翻转 HAL_Delay(500); // 500ms 延时 }如果系统时钟是 72MHz、HAL_Delay 工作正常,PB1 上应该量到约 1Hz 方波,占空比 50%。如果量到明显不对的频率,比如接近 2Hz 或极慢,基本可以断定 SystemClock_Config 里的 PLL 配置和实际晶振不匹配。没有示波器时,直接用调试器下断点:在 while 循环里断住,全速运行再停下,看 SysTick 计数值是不是每秒增长约 1000 次。这个方法不需要额外仪器,确认时钟是否跑对非常快。
3.4 例程下载后无反应时的排查起点
代码能下载成功,说明调试口正常、Flash 写入正常,但不代表板子能跑。按下面顺序排查最省时间:第一,测 3.3V 和 GND 之间的纹波,劣质 USB 转串口模块供电不足会让芯片频繁复位;第二,检查 BOOT0 和 BOOT1 引脚状态,BOOT0 悬空时芯片可能进入系统存储器模式,程序不执行;第三,看复位引脚 NRST 有没有上拉到 3.3V,很多最小系统板复位脚悬空也能跑,但周边有电机或继电器干扰时,会随机复位的正是它。这三项检查完,例程还不跑,才需要怀疑启动文件或烧录算法。
4. 例程里最该吃透的 3 类外设驱动:串口、定时器与 ADC 的参数拆解
4.1 串口例程:从轮询方式升级到中断方式
参考例程里串口通常先给轮询收发,因为代码最短:
HAL_UART_Transmit(&huart1, (uint8_t*)"ready\r\n", 7, 100); // 阻塞发送,超时100ms轮询方式在 while 循环里持续占用 CPU,实际项目很快就会被中断方式替代。改成中断接收时,关键改动是先在初始化末尾挂起接收:
// 串口初始化完成后开启单字节中断接收 HAL_UART_Receive_IT(&huart1, rx_buf, 1); // 每收到一个字节,HAL 库自动回调这个弱函数 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { rx_index = (rx_index + 1) % RX_BUF_SIZE; HAL_UART_Receive_IT(&huart1, &rx_buf[rx_index], 1); // 必须重新挂起 } }HAL_UART_Receive_IT 配置的不是等一条完整消息,而是接收指定字节数后触发回调。上面例程每次只收 1 字节,所以回调里必须再次调用 Receive_IT,否则通信只进行一次就停摆。rx_buf 是环形缓冲区,rx_index 取模避免越界,这是串口例程最常见也最容易漏掉的维护点。
波特率方面,F103 的 USART1 挂载在 APB2 总线(72MHz),计算波特率时要注意分频系数。例程默认 115200、8-N-1 通常没问题,但使用无源晶振时误差来自晶振本身,便宜晶振误差几十 ppm,高波特率下会偶发丢字节。真遇到串口乱码但代码看不出问题,先量晶振频率,别急着改代码。
提示:中断回调函数名是固定的弱函数,多个外设共用回调函数时务必检查函数签名,编译阶段重复定义不报错,跑到运行时才异常。
4.2 定时器例程:PWM 频率与分辨率的冲突选型
定时器例程里 PWM 应用最广。F103C8T6 的通用定时器 TIM2、TIM3、TIM4 都挂载在 APB1 总线,APB1 预分频设为 2 时,定时器时钟是 72MHz。PWM 频率和占空比分辨率由预分频 PSC 和自动重装载 ARR 共同决定:
void MX_TIM2_Init(void) { TIM_OC_InitTypeDef sConfigOC = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 72 - 1; // 72MHz / 72 = 1MHz 计数频率 htim2.Init.Period = 1000 - 1; // 1MHz / 1000 = 1kHz PWM htim2.Init.CounterMode = TIM_COUNTERMODE_UP; HAL_TIM_PWM_Init(&htim2); sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 500; // 占空比 = 500 / 1000 = 50% HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); }参数说明:PWM 频率 = 72MHz / (Prescaler+1) / (Period+1)。占空比分辨率由 Period+1 决定,周期越大分辨率越高,但频率随之降低。做电机调速需要 10kHz 以上频率,Period 只能取 7200 以内,占空比精度约 0.014%;做呼吸灯用 1kHz、Period=1000 就足够。例程给的值通常偏向展示效果而不是应用需求,复制到项目里务必重新计算这两个参数。
定时器例程里连续更新中断的写法也值得留意。很多人初始化外设后忘记调用 HAL_TIM_Base_Start_IT,中断标志永远不会置位,这是定时器例程最常见的“代码对但行为不对”。
4.3 ADC 例程:多通道扫描与 DMA 搬数的配置顺序
ADC 参考例程最容易被照抄出问题的场景是多通道采集。F103C8T6 的 ADC 是 12 位逐次逼近型,采样时间、通道顺序、数据对齐都会影响结果。单通道采集的例程简单,但做多路电压检测时要开扫描模式并配合 DMA:
void MX_ADC1_Init(void) { ADC_ChannelConfTypeDef sConfig = {0}; hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = ENABLE; // 多通道扫描模式 hadc1.Init.ContinuousConvMode = ENABLE; // 连续转换,DMA搬数必须开 hadc1.Init.NbrOfConversion = 2; // 共扫描2个通道 HAL_ADC_Init(&hadc1); sConfig.Channel = ADC_CHANNEL_0; // PA0 sConfig.Rank = ADC_REGULAR_RANK_1; // 第1个转换 HAL_ADC_ConfigChannel(&hadc1, &sConfig); sConfig.Channel = ADC_CHANNEL_1; // PA1 sConfig.Rank = ADC_REGULAR_RANK_2; // 第2个转换 HAL_ADC_ConfigChannel(&hadc1, &sConfig); HAL_ADC_Start_DMA(&hadc1, dma_buf, 2); // 结果直接搬运到内存数组 }DMA 模式下 ADC 完成一次转换后自动把半字数据搬到 dma_buf,CPU 不需要参与,适合连续监测多路模拟量。ScanConvMode 决定是否逐通道扫描,NbrOfConversion 必须与实际配置的通道数一致,漏一个会导致数据错位。ContinuousConvMode 配合 DMA 循环模式,可以达到“每个采样周期整批更新”的效果。要注意,F103 的 ADC 最大输入时钟为 14MHz,采样时间不足时,内阻大的信号源会测出偏低电压。例程的采样周期参数在高阻信号源场景下要调大。
ADC 结果的 12 位数据默认右对齐,范围 0~4095。如果例程里看到结果除以 4095 再乘以 3.3,这是标准换算。但做电流采样或电桥测量时,零点偏移和增益误差不能只靠这个线性公式,需要用标准源做两点校准,参考例程不会替你算这一步。
5. STM32F103C8T6 例程避坑指南:6 个让板子沉默的典型问题
这一章的经验来自我反复跑参考例程、改造成产品原型的过程。每条按现象、原因、解决三个步骤写,方便直接对照排查。
5.1 第一次能烧录,第二次点击下载就提示连接失败
现象:新板子第一次下载例程成功,跑了一会儿 LED 也亮,再改代码下载时,调试器报“Cannot access target”或连接错误。
原因:用户代码把 SWDIO 或 SWCLK 所在引脚(PA13、PA14)配置成了普通 GPIO 或复用功能。程序下载后立刻跑起来,调试口被占用,调试器自然连不上芯片。
解决:在调试器设置里打开 Connect under Reset 选项,让调试器在复位期间连接芯片,此时用户代码还没执行,调试口处于默认状态。如果选项无效,按住板子复位键的同时点击下载,等进度条开始转动再松开。这是调试配置问题,不是芯片坏了。
5.2 把点灯例程的引脚从 PB1 改到 PA5 后灯就不亮了
现象:直接修改 main.c 里 HAL_GPIO_TogglePin 的参数,或者在初始化文件里把引脚号改了,重新编译下载,LED 没反应。
原因:F103 的 GPIO 外设挂在 APB2 总线上,使用前必须打开对应端口时钟。例程初始化函数里往往只开了原来那个端口的时钟,改成 PA5 后没有调用开启 GPIOA 时钟的函数。
解决:在初始化函数里先调用__HAL_RCC_GPIOA_CLK_ENABLE(),再配置 GPIOA 的引脚模式。另外确认引脚配置是GPIO_MODE_OUTPUT_PP(推挽输出)而不是GPIO_MODE_INPUT,输入模式下读不到输出电平,灯自然不亮。
提示:凡是例程里“复制过来就改个引脚”的操作,先检查三件事——时钟开没开、模式是不是 OUTPUT_PP、引脚号和板子原理图对不对。
5.3 串口助手收到的前几个字节是乱码,再往后就正常
现象:每次上电,串口输出的固定字符串前面多出几个随机字符,之后的数据完全正常。
原因:芯片上电瞬间,TXD 引脚电平不稳定;或者主控和 USB 转串口模块冷启动时序不一致,调试助手在双方都未稳定时就开始接收。另一个常见来源是 HAL_Init 到串口初始化完成之间有一段时间,此时引脚状态未定。
解决:在 main 函数初始化串口前加HAL_Delay(50),等电源和复位稳定后再配置引脚。串口调试工具里不要勾选“随启动自动打开串口”,先给板子上电,再手动点打开。如果乱码出现在波特率切换之后,优先怀疑晶振频率偏差。
5.4 BOOT0 引脚悬空导致例程时跑时不跑
现象:代码烧录成功,按复位键偶尔正常、偶尔完全没反应;手碰到芯片附近引脚,程序又正常了。
原因:F103C8T6 的 BOOT0 引脚内部没有可靠下拉。BOOT0 悬空时,引脚电平受周边电场和手指影响,跳到 1 时芯片从系统存储器启动,跳过用户 Flash,例程不执行。
解决:在 BOOT0 引脚接 10kΩ 下拉电阻到 GND,BOOT1 引脚同样接 10kΩ 下拉,保持启动配置为从用户 Flash 启动。自制板最容易漏的就是这两个电阻,例程能烧进去但板子不稳定时,先查这里。
5.5 在串口接收回调里调用 HAL_Delay 导致死机
现象:例程本身跑得好好的,在 HAL_UART_RxCpltCallback 回调里加了一个 HAL_Delay 或阻塞发送,结果程序卡死,调试器停在 HardFault_Handler。
原因:SysTick 中断的优先级在 HAL 库中初始化为最低数值,而串口全局中断优先级较高。串口中断正在执行回调时,如果回调里调用 HAL_Delay 等待 SysTick 中断,SysTick 此时无法抢占串口中断,形成死锁,代码卡死在等待标志位。
解决:不要在中断回调里调用 HAL_Delay、HAL_UART_Transmit 这类阻塞函数。回调里只做数据搬移和置标志位,真正的延时和串口发送放到主循环。若必须高精度延时,用独立定时器而不是 SysTick。
5.6 代码编译零错误,下载也显示成功,但芯片没有任何反应
现象:例程工程编译 0 错误 0 警告,下载进度条走完,复位后 LED 不亮、串口无输出,调试器能看到内核但程序停不下来。
原因:启动文件与芯片容量不匹配。F103C8T6 是中容量 64KB,正确启动文件是startup_stm32f103xb.s。如果例程包保留了高容量芯片的启动文件,向量表地址偏移出错,复位后 PC 指针跳错位置,程序直接跑飞。
解决:在工程设置里检查目标芯片型号是否选 STM32F103C8,确认启动文件是startup_stm32f103xb.s,并核对烧录算法是 STM32F10x Med-density。型号选错还会影响编译器对 Flash 容量的判断,即使代码很小也可能报区域溢出。
6. 把参考例程改造成个人固件模板:验证方法与一个长期收益的好习惯
例程跑通不是终点,真正的收益来自把它沉淀成自己可复用的固件模板。我的做法是:不直接复制例程文件夹到新项目,而是维护一个自己的模板工程。每次从参考例程里拿到验证过的外设初始化代码,用图形化配置工具重新生成一遍,替换掉例程里手工写的部分。图形化工具生成的初始化函数带完整参数结构体和注释,改了引脚或时钟后能再次生成,比在例程里手工修改安全得多。
每个外设模块在模板里都保留独立的 .c/.h 文件和一段自测代码:串口模块用回环测试验证收发,定时器模块用示波器确认 PWM 频率,ADC 模块用精密稳压源对照采样值。验证记录直接写在源文件头部注释里,记测试日期、实测数据和环境参数。这个习惯的收益在三个月后非常明显——当你需要把旧项目的外设配置迁移到新板子,模板文件里的验证记录比参考手册还可靠。
调试三板斧也固定下来:先看寄存器值,再确认外部电平,最后才怀疑代码逻辑。我吃过最大的亏,就是对着一个“能跑”的例程反复调 ADC 采样值,最后发现是外部参考电压没接好。从那以后,每个模板都先验证参考电压和 GND 的实测值,再进入信号调试。这套方法看着琐碎,却能让 STM32F103C8T6 例程到量产固件之间的距离缩短一大截,希望帮到你。
本文还有配套的精品资源,点击获取