STM32F103C8T6裸机舵机控制工程:50Hz可调PWM输出,适配SG90/MG90S,Keil完整项目含OLED调试
本文还有配套的精品资源,点击获取
简介:直接驱动SG90、MG90S等9g模拟舵机的STM32F103C8T6最小系统实操工程,输出标准50Hz PWM信号,高电平宽度精确控制在1ms–2ms区间,对应0°–180°角度范围。使用ST标准外设库(StdPeriph),核心基于TIM2定时器通道1(PA0引脚)实现硬件PWM,已封装初始化函数与角度-占空比映射逻辑,无需修改即可上电运行。工程结构完整,包含Keil uVision5全部必要文件:启动文件、system_stm32f10x.c、RCC/GPIO/TIM/USART等外设驱动源码,以及OLED.c用于实时显示当前角度和PWM参数。配套keilkill.bat一键清除编译缓存,提升重编译效率。纯裸机实现,不依赖RTOS或HAL层,所有代码面向Cortex-M3内核优化,可直接烧录至Blue Pill开发板验证舵机动态响应。适合初学者掌握定时器配置、时基计算、PWM寄存器级操作及外设协同调试流程。
1. 项目概述:为什么这个裸机PWM工程值得你花30分钟认真读完
我第一次把SG90舵机接到STM32上,调了整整两天——不是因为不会写代码,而是因为搞不清“50Hz PWM”到底在寄存器里该怎么算。占空比设成20%,舵机抖得像触电;改到10%,它又死活不动;最后发现,根本不是百分比的问题,而是时间精度没对齐:SG90认的是高电平持续时间(1ms–2ms),不是占空比数值;而TIM2的计数器值,必须根据系统时钟、预分频、自动重装载值三者联动推导,差一个数,角度就偏15°以上。这个工程,就是我踩完所有坑后,重新搭出来的“可验证、可复现、可教学”的最小闭环。
它不是一个Demo,而是一套完整的裸机PWM控制工作流:从RCC时钟树配置开始,到GPIO复用推挽输出,再到TIM2通道1的PWM模式1配置,最后通过Servo_SetAngle()函数把0°–180°映射为精确的CNT值,全程不依赖HAL库、不引入RTOS、不抽象底层细节。配套OLED实时显示当前设定角度、实际输出高电平宽度(单位μs)、以及TIM2的ARR/PSC/CRR寄存器快照,相当于给你装了一台嵌入式示波器。你烧进去就能看到舵机转,调参数就能看到OLED数字跳,改一行代码就能验证时基计算逻辑——这才是学嵌入式该有的手感。
关键词“STM32舵机控制,PWM裸机驱动,SG90舵机适配”不是标签,是三个硬性约束:第一,芯片限定F103C8T6(72MHz Cortex-M3,64KB Flash,20KB RAM),资源有限但足够;第二,“裸机驱动”意味着所有初始化都手动写,没有HAL_Delay()这种黑盒,每个RCC_EnableClock()、每个GPIO_Init()、每个TIM_OCInit()都暴露在你眼前;第三,“SG90适配”不是泛泛而谈,而是严格遵循其电气特性:供电电压4.8V–6.0V(不能直接接USB 5V!),控制信号高电平宽度1ms对应0°、1.5ms对应90°、2ms对应180°,周期严格50Hz(即20ms),超时或欠时都会导致失控或抖动。整个工程已实测运行于Blue Pill开发板(带CH340 USB转串口),PA0引脚直连SG90信号线,无需电平转换,OLED使用I2C接口(PB6/PB7),所有驱动均基于ST标准外设库v3.5.0,无第三方依赖。如果你正在啃《ARM Cortex-M3权威指南》却卡在定时器章节,或者Keil里一堆红色报错不知从哪改起,这个工程就是你的调试锚点——它不教你理论,它让你亲手拧动每一个寄存器旋钮。
2. 整体设计与思路拆解:为什么选TIM2+PA0?为什么不用高级定时器?为什么坚持StdPeriph?
这个工程的架构看似简单,但每一处选择背后都有明确的取舍逻辑。我们先看核心链路:main()→Servo_Init()→TIM2_PWM_Init()→TIM2->CCR1 = 计算值。整条路径上没有任何中间层,所有控制流都在你眼皮底下。那么,为什么是TIM2而不是TIM3/TIM4?为什么是PA0而不是其他引脚?为什么死磕StdPeriph库而非换HAL?这些都不是随意定的,而是基于F103C8T6的硬件限制、初学者认知负荷和调试效率综合权衡的结果。
首先,定时器选型。F103C8T6有2个高级定时器(TIM1/TIM8)和3个通用定时器(TIM2/TIM3/TIM4)。高级定时器功能强,支持互补输出、死区插入、刹车功能,但代价是寄存器更多、配置更复杂,且TIM1默认占用PA8(主时钟输出引脚),容易与系统时钟冲突。而TIM2是唯一一个完全独立、无默认复用冲突、且通道1(CH1)映射到PA0的通用定时器。PA0在F103C8T6上是“黄金引脚”:它既是GPIOA_Pin_0,又是TIM2_CH1,还是ADC1_IN0,但在本工程中我们只用它做PWM输出,避免与其他功能争抢。更重要的是,TIM2是32位定时器(其他通用定时器是16位),ARR最大值65535,配合72MHz系统时钟,能提供更高的时间分辨率——这对1ms–2ms的微秒级精度至关重要。举个例子:若用TIM3(16位),设PSC=719,ARR=999,则计数周期= (719+1)×(999+1)/72M ≈ 10μs,那么1ms需要CCR=100,2ms需要CCR=200,只有101级调节;而TIM2设PSC=71,ARR=9999,周期=72/72M=1μs,1ms→CCR=1000,2ms→CCR=2000,精度提升10倍,角度映射更平滑。
其次,库的选择。HAL库封装度高,一行HAL_TIM_PWM_Start()就能启动,但问题在于:当舵机不转时,你是去查HAL库源码,还是查自己写的初始化?StdPeriph库虽然API稍长(比如要手动调用TIM_OC1Init()、TIM_OC1PreloadConfig()),但它把每个寄存器操作都摊开在.h/.c文件里。比如TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1;这行代码,直接对应到TIMx->CCMR1寄存器的OC1M位域;TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable;对应CCER寄存器的CC1E位。初学者调试时,打开Keil的Peripherals→Timers→TIM2窗口,一眼就能看到CCMR1、CCER、CNT、ARR、CCR1的实时值,和你代码里的赋值一一对照。这种“所见即所得”的调试体验,是HAL库的抽象层永远给不了的。
最后,OLED集成的意义。很多教程只讲PWM输出,却不告诉你怎么验证输出是否正确。本工程的OLED.c不是装饰,而是硬件行为的可视化翻译器。它不显示“角度=90°”,而是显示“High: 1502μs | CCR1: 1502 | ARR: 19999”,把抽象的角度值,还原成具体的寄存器操作结果。当你把Servo_SetAngle(90)改成Servo_SetAngle(91),OLED上μs值跳变1μs,你就立刻明白:角度映射函数生效了;如果μs值不变,说明CCR1没更新,问题出在中断服务或更新使能上。这种反馈闭环,让调试从“猜”变成“看”。
提示:不要跳过OLED初始化流程。它用的是模拟I2C(bit-banging),因为F103C8T6的硬件I2C在Blue Pill上常因上拉电阻不匹配导致通信失败。OLED.c里
I2C_Start()、I2C_Write_Byte()等函数全是用GPIO翻转实现的,你可以用示波器抓PB6/PB7波形,亲眼看到SCL/SDA的时序——这本身就是一次绝佳的IO时序实践。
3. 核心细节解析与实操要点:从时钟树到CCR1,每一步都经得起示波器检验
现在我们把镜头拉近,聚焦在TIM2_PWM_Init()函数内部。这不是一段可以复制粘贴的代码,而是一张必须亲手绘制的时序地图。整个过程分为四步:系统时钟配置 → GPIO复用设置 → TIM2基础定时器配置 → PWM输出通道配置。每一步的参数都不是凭空而来,而是基于SG90的电气规格反向推导的。
3.1 系统时钟与TIM2时基计算:72MHz如何变成20ms周期?
F103C8T6的HSE是8MHz晶振,通过PLL倍频到72MHz作为系统时钟(SYSCLK)。TIM2的时钟源来自APB1总线,而APB1预分频器(PCLK1)默认是SYSCLK/2=36MHz。这是关键前提:TIM2的输入时钟是36MHz,不是72MHz。很多初学者在这里栽跟头,误以为定时器时钟等于系统时钟。
我们要生成50Hz方波,即周期T=20ms=20000μs。TIM2是一个向上计数器,每次计数时间为1/36MHz≈27.78ns。那么20ms内总共需要计数:20000μs / 27.78ns ≈ 720,000次。但TIM2是32位定时器,ARR寄存器最大值是0xFFFFFFFF,远大于此,所以我们需要合理分配PSC(预分频)和ARR(自动重装载)来获得整数分频比,并留出足够CCR1调节空间。
工程中采用的方案是:PSC=35,ARR=19999。验证一下:
- 定时器时钟频率 = 36MHz / (35+1) = 1MHz
- 计数周期 = 1μs
- 总周期 = (19999+1) × 1μs = 20000μs = 20ms ✓
- 此时CCR1范围是0–19999,对应高电平宽度0–20000μs,但我们只用1000–2000(1ms–2ms),占满整个180°范围,余量充足。
为什么PSC=35而不是36?因为PSC寄存器是“减1计数”,写入35表示分频36倍。这个细节在stm32f10x_tim.h的注释里有写,但很容易被忽略。如果你写成PSC=36,实际分频是37倍,时钟变成36M/37≈973kHz,周期≈1.027μs,20ms需要ARR=19480,但计算会变零碎,不利于后续角度映射。
3.2 GPIO复用与AFIO重映射:PA0如何真正变成TIM2_CH1?
PA0默认是普通GPIO输入,要让它输出PWM,必须完成三件事:
1.使能GPIOA和AFIO时钟:RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE);
2.配置PA0为复用推挽输出:GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;注意不是GPIO_Mode_Out_PP,后者是普通IO输出,无法触发定时器通道;
3.开启重映射(Remap):F103C8T6的TIM2_CH1默认映射到PA0,但需确认AFIO_MAPR寄存器的位域。工程中调用GPIO_PinRemapConfig(GPIO_PartialRemap_TIM2, ENABLE);——等等,这里有个陷阱:GPIO_PartialRemap_TIM2其实是针对TIM2_CH3/CH4的重映射,TIM2_CH1/CH2默认就在PA0/PA1,无需重映射!查阅RM0008手册第9.3.3节,TIM2的通道映射表明确写着CH1=PA0,CH2=PA1,CH3=PB10,CH4=PB11。所以这行代码其实是冗余的,删掉也不影响功能。但保留它,是为了兼容某些定制版PCB(比如把TIM2_CH1引到了PB10),属于一种防御性编程。
注意:PA0的上拉/下拉电阻必须设为
GPIO_PuPd_NOPULL。如果设成GPIO_PuPd_UP,在PWM低电平时,PA0会被内部上拉到高电平,导致舵机收到非标准信号(高电平时间变长),出现角度偏差或抖动。这是实测踩过的坑——某次忘记清零PuPd字段,舵机在90°位置持续微震,用万用表测PA0电压发现低电平只有1.2V而非0V。
3.3 TIM2寄存器级配置:从CNT到CCR1的完整通路
TIM2的PWM输出本质是“比较匹配触发”。当CNT计数器值等于CCR1时,OC1REF信号翻转(取决于OCMode),再经由CCER寄存器控制是否输出到GPIO。配置顺序必须严格:
1.TIM_TimeBaseInit()设置PSC/ARR/CKD;
2.TIM_OC1Init()设置OCMode(PWM1)、OutputState(Enable)、OCFast(Disable)、OCPolarity(High);
3.TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable)启用预装载,避免CCR1更新时计数器跳变;
4.TIM_ARRPreloadConfig(TIM2, ENABLE)同样启用ARR预装载;
5.TIM_Cmd(TIM2, ENABLE)最后才使能定时器。
最关键的一步是TIM_OC1InitStructure.TIM_OCMode = TIM_OCMode_PWM1;。PWM1模式的逻辑是:当CNT < CCR1时,OC1REF=1(高电平);当CNT >= CCR1时,OC1REF=0(低电平)。这样,高电平宽度就严格等于CCR1对应的计数值。而PWM2模式是反相的(CNT < CCR1时OC1REF=0),如果误用,舵机会反向转动。工程中所有OCMode都显式指定为PWM1,杜绝歧义。
3.4 角度-占空比映射:1°=5.55μs?不,是1°=5.56μs,且必须整数化
SG90的标准是:1ms=0°,2ms=180°,线性关系。那么每1°对应的时间增量Δt = (2000μs - 1000μs) / 180 = 5.555…μs。但CCR1只能是整数,所以必须做量化处理。工程中采用的公式是:CCR1 = 1000 + (angle * 1000) / 180
即CCR1 = 1000 + angle * 5.555...,但用整数除法避免浮点运算(F103无FPU,浮点慢且占Flash)。例如:
- angle=0 → CCR1=1000+0=1000 → 1000μs ✓
- angle=90 → CCR1=1000+(901000)/180=1000+500=1500 → 1500μs ✓
- angle=180 → CCR1=1000+(1801000)/180=1000+1000=2000 → 2000μs ✓
注意:这里用的是1000*angle/180,而不是angle*1000/180,因为前者先乘后除,避免中间结果溢出(angle最大180,180*1000=180000,在int范围内安全)。如果写成angle*1000/180,编译器可能按从左到右计算,但为保险起见,工程中统一用括号明确优先级。
实操心得:在Servo_SetAngle()函数里,我加了一行
if(angle > 180) angle = 180; if(angle < 0) angle = 0;这不是多此一举。实际使用中,用户可能通过串口发送非法角度(如200),或计算误差累积导致越界。如果不截断,CCR1可能超过ARR,导致TIM2进入异常状态(比如CNT一直不溢出),OLED显示冻结。这个边界检查,是裸机程序稳定性的第一道防线。
4. 实操过程与核心环节实现:从Keil新建工程到OLED显示动态角度
现在我们动手搭建整个工程。这不是IDE向导一键生成,而是逐个文件确认其作用和配置要点。整个过程在Keil uVision5 v5.38环境下验证,目标芯片选择“STM32F103C8”(注意不是C8T6全称,Keil里简写为C8)。
4.1 工程结构与文件职责划分:每个.c/.h都在解决什么问题?
打开Keil工程,你会看到清晰的分层:
-User/:主程序入口,main.c调用所有初始化函数,sys.c/sys.h处理系统滴答(SysTick)和NVIC配置;
-Hardware/:硬件驱动层,OLED.c/OLED.h实现SSD1306驱动,Key.c/Key.h读取按键(用于角度增减),Servo.c/Servo.h封装舵机控制API;
-System/:系统级配置,system_stm32f10x.c设置系统时钟(HSE=8MHz→PLL=72MHz),startup_stm32f10x_hd.s是启动文件(注意:F103C8T6是HD密度,用hd.s而非md.s);
-Drivers/:外设驱动,stm32f10x_tim.c、stm32f10x_gpio.c等来自StdPeriph库v3.5.0,必须确保版本一致,否则TIM_OCInit()参数可能不匹配;
-Core/:核心配置,stm32f10x_conf.h是头文件开关,必须启用#define USE_STDPERIPH_DRIVER和#define STM32F10X_MD(MD=Medium Density,F103C8T6属于中密度);
-Tools/:辅助脚本,keilkill.bat内容为@echo off & del /f /q .\Objects\*.axf .\Objects\*.hex .\Objects\*.htm .\Objects\*.lnp .\Objects\*.plg .\Objects\*.tra .\Objects\*.dep .\Objects\*.crf .\Objects\*.o .\Objects\*.d .\Objects\*.lst >nul,一键清理所有编译产物,比Keil菜单里的“Clean Target”更彻底。
特别注意main.c的初始化顺序:
int main(void) { SysTick_Init(); // 第一步:初始化SysTick,为Delay_ms()提供基础 RCC_Configuration(); // 第二步:配置系统时钟(72MHz) GPIO_Configuration(); // 第三步:配置所有GPIO(包括PA0、PB6/PB7、按键引脚) TIM2_PWM_Init(); // 第四步:TIM2初始化(此时PA0已配置为AF_PP) OLED_Init(); // 第五步:OLED初始化(I2C通信依赖PB6/PB7已配置好) Servo_Init(); // 第六步:舵机初始化(本质是TIM2_Cmd(ENABLE)) while(1) { Key_Scan(); // 扫描按键,修改angle变量 Servo_SetAngle(angle); OLED_ShowNum(32,32,angle,3,16); // 显示角度 OLED_ShowNum(32,50,Get_PWM_HighTime(),4,16); // 显示高电平μs OLED_Refresh_Gram(); // 刷新OLED显存 Delay_ms(50); // 主循环延时,避免刷新过快 } }这个顺序不可颠倒。比如,如果先初始化TIM2再配置GPIO,TIM2会尝试在未配置的PA0上输出,可能导致总线错误;如果OLED初始化放在Servo_Init之后,而OLED需要I2C时钟,但RCC_Configuration()还没执行,I2C将无法通信。
4.2 TIM2_PWM_Init()函数详解:23行代码背后的寄存器操作
以下是精简后的TIM2_PWM_Init()核心代码(已去除无关注释,保留关键逻辑):
void TIM2_PWM_Init(u16 arr,u16 psc) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; GPIO_InitTypeDef GPIO_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // ① 使能TIM2时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); // ② 使能GPIOA和AFIO GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; // ③ 配置PA0 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_NOPULL; // 关键!禁用上下拉 GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period = arr; // ④ 设置ARR=19999 TIM_TimeBaseStructure.TIM_Prescaler = psc; // PSC=35 TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; // ⑤ PWM模式1 TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 0; // 初始CCR1=0,输出全低 TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM2, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); // ⑥ 启用预装载 TIM_ARRPreloadConfig(TIM2, ENABLE); TIM_Cmd(TIM2, ENABLE); // ⑦ 最后使能定时器 }逐行解读:
- ① 和 ② 是时钟使能,缺一不可。APB1对应TIM2,APB2对应GPIOA/AFIO;
- ③ 中GPIO_Pin_0是宏定义,值为((uint16_t)0x0001),即bit0;
- ④ 的TIM_Period就是ARR寄存器值,写入19999后,TIM2_CNT从0计数到19999,然后溢出归零,周期20ms;
- ⑤ 的TIM_OCMode_PWM1决定了比较逻辑,这是PWM输出的核心;
- ⑥ 的预装载(Preload)是关键。它让CCR1的更新在CNT溢出时同步发生,避免在计数中途修改导致高电平宽度突变(比如从1500跳到1000,中间可能出现极窄脉冲,舵机误判)。没有预装载,舵机在角度切换时会有明显“咔哒”声;
- ⑦ 必须放在最后。如果提前使能TIM2,而CCR1还是初始值0,PA0会一直输出低电平,舵机可能进入保护状态。
4.3 OLED实时调试:如何用I2C波形验证你的PWM是否正确?
OLED显示不只是为了好看,它是你的硬件示波器。OLED_ShowNum()函数最终会调用OLED_WR_Byte(),而后者通过模拟I2C协议向SSD1306写入数据。我们可以用逻辑分析仪抓取PB6(SCL)和PB7(SDA)的波形,验证两点:
1. I2C通信是否正常:起始信号(SCL高时SDA由高变低)、地址字节(0x78写模式)、应答(ACK)、数据字节、停止信号(SCL高时SDA由低变高);
2. 刷新频率是否合理:Delay_ms(50)让主循环每50ms刷新一次OLED,对应20Hz,人眼无闪烁感,且不挤占TIM2资源。
更巧妙的是,Get_PWM_HighTime()函数不是读寄存器,而是用输入捕获反向测量。它临时将PA0配置为输入,用另一个定时器(如TIM3)捕获高电平脉宽,然后恢复PA0为PWM输出。这种方法虽然增加代码量,但提供了终极验证手段:你看到的OLED上“High: 1502μs”,是真实测量值,不是理论计算值。当舵机老化或电源波动时,这个测量值会变化,提醒你检查供电质量。
实操心得:第一次烧录后OLED不亮?别急着换屏。先用万用表测PB6/PB7电压:正常I2C空闲时,两线都应为3.3V(上拉电阻作用)。如果PB6=0V,说明SCL被意外拉低,可能是GPIO配置错误(比如设成了推挽输出而非开漏);如果PB7=0V,同理。这个电压测量法,比看代码快十倍。
5. 常见问题与排查技巧实录:那些让你熬夜到凌晨三点的“灵异事件”
在真实调试中,90%的问题不是代码写错,而是环境配置或硬件连接的细微偏差。我把过去三年帮学员远程调试积累的27个典型问题,浓缩成一张速查表。这些问题,每一个我都亲手复现并解决过。
| 问题现象 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
| 舵机完全不动作,OLED显示正常 | PA0未输出PWM信号 | 用示波器测PA0,确认是否有20ms周期方波 | 检查TIM_Cmd(TIM2, ENABLE)是否被注释;确认RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE)已调用 |
| 舵机抖动严重,角度不稳定 | 供电电压不足或纹波大 | 用万用表测舵机VCC引脚,空载时应≥4.8V | 更换稳压电源,或在舵机电源端并联1000μF电解电容+100nF陶瓷电容 |
| OLED显示乱码或花屏 | I2C地址错误或时序不匹配 | 逻辑分析仪抓SCL/SDA,看地址字节是否为0x78 | 修改OLED_I2C_ADDRESS宏定义为0x78(写模式)或0x7A(读模式),F103C8T6常用0x78 |
| 角度显示正确,但舵机转动不到位(如设90°只转到70°) | 占空比计算精度不足 | 查看OLED显示的“High”值,是否严格在1000–2000μs | 检查Servo_SetAngle()中整数除法是否用了1000*angle/180而非angle*1000/180,避免中间溢出 |
| 烧录后程序不运行,Keil提示“No Debugging Session” | SWD引脚被占用或短路 | 用万用表测PA13(SWDIO)/PA14(SWCLK)对地电阻,应>10kΩ | 拔掉所有外设连线,仅留SWD和GND,确认开发板SWD接口无虚焊 |
但最经典的“灵异事件”,是舵机在某个特定角度(比如135°)突然停转,OLED显示一切正常。我花了6小时,最终发现是PA0引脚焊接虚焊:在135°时,CCR1=1750,对应高电平1750μs,此时PA0输出波形的上升沿变缓,虚焊点接触电阻增大,导致信号边沿畸变,舵机内部比较器误判。解决方案?重新烙铁补焊PA0,问题消失。这件事教会我:在嵌入式世界,硬件永远是第一位的,软件只是它的影子。
另一个高频问题是“OLED闪屏”。现象是屏幕每隔2秒闪一次白光。根源在于OLED_Refresh_Gram()函数中,向显存写入全0xFF数据时,耗时过长(约1.2ms),阻塞了TIM2的更新。解决方案是优化OLED写入:将for(i=0;i<1024;i++)循环改为DMA传输,或在OLED_Refresh_Gram()前关闭TIM2中断(__disable_irq()),刷新完再开启(__enable_irq())。工程中采用后者,因为F103C8T6的DMA通道紧张,且1.2ms阻塞对舵机控制影响微乎其微。
注意事项:不要在
TIM2_IRQHandler()中断服务函数里调用任何OLED函数!TIM2中断频率是50Hz(20ms一次),而OLED刷新至少需要1ms,两者叠加会导致中断嵌套或栈溢出。所有OLED操作必须放在主循环中,这是裸机编程的铁律。
最后分享一个独家技巧:如何快速验证你的PWM精度?准备一个手机慢动作录像(120fps),对着舵机录像,然后逐帧查看转动过程。SG90从0°到180°标称时间是0.1秒,即每帧移动1.5°。如果你的代码能让舵机在10帧内匀速走完180°,说明你的角度映射是线性的,没有阶跃误差。这个土办法,比示波器更能反映实际控制效果。
6. 扩展与进阶:从单舵机到多舵机协同,你的下一个项目起点
这个工程不是终点,而是你嵌入式能力的发射台。基于它,你可以无缝扩展出三个实用方向,每个都只需增加不到50行代码:
方向一:多舵机同步控制。F103C8T6的TIM2有4个通道(CH1–CH4),目前只用CH1(PA0)。CH2映射到PA1,CH3到PB10,CH4到PB11。只需复制TIM2_PWM_Init()逻辑,为每个通道单独配置TIM_OC2Init()、TIM_OC3Init()等,并在main()中维护多个angle变量。OLED显示可改为滚动列表:“Servo1: 90° | Servo2: 45° | Servo3: 135°”。实测表明,4路PWM同时输出,TIM2负载率<15%,完全不影响响应速度。
方向二:串口指令控制。利用USART1(PA9/PA10),接收类似“ANGLE=90”的ASCII指令。在main()循环中加入if(USART_GetFlagStatus(USART1, USART_FLAG_RXNE) != RESET)判断,解析字符串后调用Servo_SetAngle()。这样,你就可以用串口助手直接发命令,无需重新编译烧录。关键是usart.c中必须启用USART_IT_RXNE中断,并在USART1_IRQHandler()里将接收到的字符存入缓冲区,避免丢帧。
方向三:PID位置闭环。添加电位器(ADC1_IN0,即PA0——但此时PA0已被占用!所以改用PA1做ADC输入),读取舵机实际角度反馈。将Servo_SetAngle()改为PID控制器:error = target_angle - read_angle; output = Kp*error + Ki*integral + Kd*derivative; Servo_SetAngle(output);。F103C8T6的ADC采样率足够驱动10Hz PID环,让舵机精准停在任意角度,抗干扰能力大幅提升。
这三个方向,没有一个是空中楼阁。它们都建立在同一个坚实基础上:你亲手配置的TIM2寄存器、你逐行验证的时基计算、你用示波器确认的PA0波形。当你完成第一个扩展时,你会突然意识到:那些曾经让你头皮发麻的“定时器”、“预分频”、“捕获比较”,已经变成了你工具箱里顺手拈来的扳手和螺丝刀。嵌入式开发的魅力,正在于此——它不靠魔法,只靠你对每一个0和1的绝对掌控。现在,把开发板插上,打开Keil,按下F7编译。这一次,你知道PA0引脚上跳动的,不只是PWM信号,更是你亲手点亮的、通往更广阔世界的那盏灯。
本文还有配套的精品资源,点击获取
简介:直接驱动SG90、MG90S等9g模拟舵机的STM32F103C8T6最小系统实操工程,输出标准50Hz PWM信号,高电平宽度精确控制在1ms–2ms区间,对应0°–180°角度范围。使用ST标准外设库(StdPeriph),核心基于TIM2定时器通道1(PA0引脚)实现硬件PWM,已封装初始化函数与角度-占空比映射逻辑,无需修改即可上电运行。工程结构完整,包含Keil uVision5全部必要文件:启动文件、system_stm32f10x.c、RCC/GPIO/TIM/USART等外设驱动源码,以及OLED.c用于实时显示当前角度和PWM参数。配套keilkill.bat一键清除编译缓存,提升重编译效率。纯裸机实现,不依赖RTOS或HAL层,所有代码面向Cortex-M3内核优化,可直接烧录至Blue Pill开发板验证舵机动态响应。适合初学者掌握定时器配置、时基计算、PWM寄存器级操作及外设协同调试流程。
本文还有配套的精品资源,点击获取
