简介:这份资源面向嵌入式初学者与STM32开发者,围绕STM32F103C8T6微控制器,通过蓝牙模块远程控制舵机转动,解决无线操控舵机角度这一典型嵌入式应用问题。包内共131个文件,以c与h源码、o目标文件、d依赖文件、crf交叉引用文件为主,另含hex烧录文件、uvprojx与uvoptx工程文件、map与lst编译信息、sct分散加载脚本及bat批处理脚本,压缩包约3.11MB,构成一套可直接用Keil打开的完整工程。资源重点覆盖UART串口接收蓝牙指令、TIM定时器输出PWM控制舵机角度,并提示舵机应接独立外接电源以避免电流不足导致转动异常。已有3900人学习下载,读者可据此掌握蓝牙通信配置、PWM参数调节与硬件连线排错思路,适合作为课程设计或嵌入式入门的实操参考。
1. 从一块吃灰的最小系统板说起:蓝牙控舵机到底能跑多稳
手头攒了几块 STM32F103C8T6 最小系统板,加上一个 SG90 舵机和一块 HC-05 蓝牙模块,这套组合在桌面级控制实验里出镜率极高。很多人第一次接触「stm32 舵机 蓝牙」这三个词,是想做一个手机能遥控的小云台、机械臂关节或者智能小车转向机构。核心逻辑其实不复杂:STM32 产生 50Hz 的 PWM 波,脉宽在 0.5ms 到 2.5ms 之间变化对应舵机 0 到 180 度;蓝牙模块通过串口把手机指令传进来,MCU 解析后调整 PWM 比较寄存器的值。真正让人翻车的往往不是原理,而是供电、串口中断和定时器配置这三件事。这篇笔记按我实际调试的顺序拆一遍,从硬件接线到代码落地,再到那些让人抓头发的玄学问题,目标是让你照着做完就能用手机滑条控制舵机转动,而不是停在「编译通过但舵机不动」的阶段。
2. 硬件选型与接线:为什么你的舵机一抖就复位
2.1 核心器件清单与供电边界
这套方案里,STM32F103C8T6 负责产生精确的 PWM 和解析串口数据,HC-05 负责透传手机指令,SG90 或 MG996R 负责执行角度。选 F103C8T6 的理由很直接:它有三个通用定时器,TIM1 到 TIM4 里随便挑一个都能输出 50Hz 的 PWM,主频 72MHz 下预分频和自动重装载值的组合很宽裕。HC-05 默认波特率 9600,配对密码 1234 或 0000,支持从机模式,手机端用任意蓝牙串口助手就能发数据。
供电是第一个分水岭。SG90 空载电流约 100mA,堵转能冲到 700mA 以上;MG996R 堵转直接上 2.5A。STM32 最小系统板上的 3.3V 稳压芯片(通常是 RT9193 或 AMS1117)输出能力只有几百毫安,拿它给舵机供电,结果就是舵机一动、MCU 复位,串口也跟着断。常见做法是舵机单独走一路 5V 电源,比如 18650 电池经降压模块,或者直接用一个 5V 2A 的适配器,STM32 和 HC-05 由另一路 3.3V 或 5V 供电,两路共地。
| 器件 | 供电电压 | 峰值电流 | 与 MCU 连接方式 |
|---|---|---|---|
| STM32F103C8T6 | 3.3V | 50mA | — |
| HC-05 | 3.6V~6V | 40mA | USART TX/RX 交叉 |
| SG90 | 4.8V~6V | 700mA | PWM 信号线 |
| MG996R | 4.8V~7.2V | 2.5A | PWM 信号线 |
接线时注意 HC-05 的 RXD 要接 STM32 的 TX(PA9),TXD 接 RX(PA10),交叉别接反。舵机信号线接 PA8(TIM1_CH1)或 PA0(TIM2_CH1),具体看你在 CubeMX 里把哪个通道配成了 PWM 输出。
2.2 定时器 PWM 配置:50Hz 不是随便凑的
SG90 的数据手册写得很清楚:控制信号是周期 20ms 的脉冲,高电平宽度 0.5ms 对应 0 度,1.5ms 对应 90 度,2.5ms 对应 180 度。20ms 周期就是 50Hz,这个频率不能随便改,改了舵机会抖或者不动。
STM32F103C8T6 的 TIM1 挂在 APB2 总线上,时钟 72MHz。要得到 50Hz,需要经过预分频器和自动重装载寄存器两级分频。公式是:
PWM 频率 = 72,000,000 / ((PSC + 1) * (ARR + 1))
我一般取 PSC = 71,这样定时器计数时钟变成 1MHz,每个计数代表 1 微秒。然后 ARR = 19999,周期就是 20000 微秒,正好 20ms。这样设置的好处是 CCR 寄存器的值直接对应脉宽微秒数,CCR = 500 就是 0.5ms,CCR = 1500 就是 1.5ms,写代码时不用再换算,省心。
// TIM1 PWM 初始化,PA8 输出 void Servo_PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_TIM1, ENABLE); // PA8 复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 定时器基础配置:72MHz 分频到 1MHz,周期 20ms TIM_TimeBaseStructure.TIM_Period = 19999; // ARR TIM_TimeBaseStructure.TIM_Prescaler = 71; // PSC TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM1, &TIM_TimeBaseStructure); // PWM 模式 1,初始脉宽 1.5ms 对应 90 度 TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 1500; // CCR1 TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM1, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM1, ENABLE); TIM_Cmd(TIM1, ENABLE); TIM_CtrlPWMOutputs(TIM1, ENABLE); // 高级定时器必须开 }这段代码里有个容易漏的点:TIM1 是高级定时器,除了 TIM_Cmd 之外还必须调用 TIM_CtrlPWMOutputs 使能主输出,否则 PA8 上量不到波形。我见过不止一个人卡在这里,示波器一搭发现没信号,查了半天 GPIO 和时钟,最后发现是这行没写。
参数调整上,如果你用的是 TIM2 或 TIM3,它们挂在 APB1 上,时钟也是 72MHz(因为 APB1 预分频不为 1 时定时器时钟会倍频),PSC 和 ARR 的取值不变。但 TIM2 到 TIM4 是通用定时器,不需要 TIM_CtrlPWMOutputs 那行。
3. 蓝牙串口通信:从手机指令到角度值的映射
3.1 HC-05 配置与串口初始化
HC-05 出厂默认波特率通常是 9600,数据位 8,停止位 1,无校验。上电后如果没配对,模块进入 AT 模式(KEY 脚拉高),可以用串口助手发 AT 指令改名字、改波特率、设密码。但大多数场景下默认参数就够用,直接拿来透传。
STM32 这边用 USART1,PA9 做 TX,PA10 做 RX。配置时注意波特率要和 HC-05 一致,我一般保持 9600,虽然慢但稳定,传几个字节的角度指令绰绰有余。中断接收是必须的,因为手机发数据的时间不确定,轮询会丢包。
// USART1 初始化,9600 波特率,接收中断 void Bluetooth_UART_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); // PA9 TX 复用推挽 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // PA10 RX 浮空输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = 9600; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); USART_Cmd(USART1, ENABLE); }中断优先级这里给的是 1 和 1,实际项目里如果还有别的中断,要把串口接收的抢占优先级设得比定时器低,避免打断 PWM 更新导致舵机抖动。但在这个简单场景里,只有一个串口中断,怎么设都行。
3.2 指令协议设计与解析逻辑
手机端发什么格式,决定了 MCU 端怎么解析。最简单的方案是发单个字符,比如 '0' 到 '9' 对应 0 到 180 度分十档。但这样精度太粗,滑条控制需要更细的粒度。我一般用两种协议:
一种是 ASCII 字符串,比如发 "090" 表示 90 度,"180" 表示 180 度,以换行符结尾。解析时用状态机逐字节收,遇到 '\n' 就转成整数。这种协议可读性好,手机端用任意串口助手都能发,调试方便。
另一种是二进制协议,两个字节:第一个字节是帧头 0xAA,第二个字节是角度值 0 到 180。这种效率高,但手机端要自己写编码逻辑,适合做 APP 的场景。
// 串口接收中断服务函数,ASCII 协议解析 volatile uint8_t rx_buffer[8]; volatile uint8_t rx_index = 0; volatile uint8_t cmd_ready = 0; volatile uint16_t target_angle = 90; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t ch = USART_ReceiveData(USART1); if (ch == '\n' || ch == '\r') { if (rx_index > 0) { rx_buffer[rx_index] = '\0'; // 字符串转整数,限制在 0 到 180 uint16_t angle = atoi((char *)rx_buffer); if (angle > 180) angle = 180; target_angle = angle; cmd_ready = 1; } rx_index = 0; } else if (rx_index < sizeof(rx_buffer) - 1) { rx_buffer[rx_index++] = ch; } else { rx_index = 0; // 溢出保护,丢弃整帧 } USART_ClearITPendingBit(USART1, USART_IT_RXNE); } }这段中断里做了三件事:收字节、判断帧尾、转角度值。rx_index 溢出保护是必须的,否则手机端发了一长串乱码,缓冲区越界会踩到别的变量。cmd_ready 标志位在主循环里检查,收到完整指令后再更新 CCR 寄存器,避免在中断里做浮点运算或复杂计算。
主循环里把角度值映射成 CCR 值:
// 角度转 CCR:0 度对应 500,180 度对应 2500 uint16_t angle_to_ccr(uint16_t angle) { return 500 + (angle * 2000) / 180; } int main(void) { Servo_PWM_Init(); Bluetooth_UART_Init(); while (1) { if (cmd_ready) { cmd_ready = 0; uint16_t ccr = angle_to_ccr(target_angle); TIM_SetCompare1(TIM1, ccr); } } }angle_to_ccr 这个线性映射把 0 到 180 度映射到 500 到 2500 微秒。注意整数运算的顺序,先乘后除避免精度丢失。如果写成 500 + angle * (2000 / 180),整数除法会把 2000/180 截断成 11,180 度算出来只有 2480,舵机转不到头。
4. 避坑与排查:那些让舵机抽风的真实原因
4.1 现象:舵机上电就转到极限位置并发出嗡嗡声
原因通常是 PWM 信号没出来,舵机收不到有效脉冲,内部比较电路判断为异常信号,驱动电机往一个方向顶死。用示波器量 PA8,如果一直是高电平或低电平,检查 TIM_CtrlPWMOutputs 是否调用、GPIO 是否配成复用推挽、定时器时钟是否使能。还有一种可能是 CCR 初始值设成了 0 或超过 ARR,导致占空比异常。
解决:按第 2 章的初始化代码逐行核对,重点看 TIM1 的主输出使能。如果用的是 TIM2 或 TIM3,确认 GPIO 模式是 AF_PP 而不是 Out_PP。
4.2 现象:手机发指令,舵机偶尔动一下,大部分时间没反应
原因多半是串口接收丢包。HC-05 在 9600 波特率下,两个字节间隔超过 1ms 就可能被 MCU 当成两帧。如果手机端是滑条连续发数据,MCU 中断里处理不及时,后面的字节就覆盖了前面的。
解决:中断里只做最轻量的收字节和置标志位,解析和 PWM 更新放到主循环。另外在手机端加 50ms 的发送间隔,或者用二进制协议减少字节数。如果还是丢,把波特率提到 115200,HC-05 支持这个速率,STM32 的 USART1 在 72MHz 下配 115200 误差也在允许范围内。
4.3 现象:舵机转到某个角度后突然复位,串口断开重连
这是典型的供电塌陷。舵机启动瞬间电流冲高,把 3.3V 或 5V 轨拉低,STM32 的欠压复位电路触发。用万用表量舵机电源引脚,在舵机动作瞬间看电压跌落多少,如果从 5V 掉到 4V 以下,基本就是电源功率不够。
解决:舵机单独供电,电源线尽量短粗,在舵机电源引脚并一个 470uF 到 1000uF 的电解电容,再并一个 0.1uF 陶瓷电容滤高频。STM32 和 HC-05 的电源和舵机电源共地但分开走线,避免大电流在地线上产生压降干扰 MCU。
4.4 现象:舵机抖动,角度不准,回中位置每次不一样
SG90 这类廉价舵机本身就有回差,齿轮间隙导致正转和反转到同一角度时实际位置差几度。另外 PWM 信号的抖动也会让舵机内部比较器频繁调整。如果定时器配置没问题,示波器量脉宽很稳,那就是舵机机械精度的问题,换 MG996R 或数字舵机会好很多。
解决:软件上可以在目标角度附近做死区处理,比如角度变化小于 2 度就不更新 CCR。硬件上给舵机供电加 LC 滤波,信号线上串一个 100 欧姆电阻并 0.1uF 电容到地,能滤掉高频干扰。
4.5 现象:HC-05 配对成功但发数据没反应
先确认 HC-05 的 TXD 和 RXD 有没有接反,这是最高频的错误。然后看模块的指示灯:配对成功后应该是慢闪,如果还是快闪说明没连上。手机端蓝牙串口助手要选对设备,有些手机默认连的是 HC-05 的音频通道而不是串口通道。最后检查 STM32 的串口中断是否使能了 RXNE,NVIC 里 USART1_IRQn 有没有开。
解决:用 USB 转 TTL 直接接 HC-05,在电脑串口助手里发 AT 指令确认模块本身能通,再接到 STM32 上排查 MCU 侧。这样能把问题范围缩小到一半。
5. 进阶技巧:用定时器中断做多路舵机与角度平滑
单路舵机跑通之后,下一步往往是控制多路。STM32F103C8T6 的 TIM1 有四个通道,TIM2 到 TIM4 各有四个通道,理论上能同时输出十几路 PWM。但同一个定时器的所有通道共享 ARR 和 PSC,所以频率必须一致,都是 50Hz,这正好符合舵机要求。多路舵机的做法是在初始化时把四个通道都配上,每个通道独立设置 CCR。
// TIM1 四通道 PWM 初始化,PA8~PA11 void Servo_PWM_4CH_Init(void) { // ... 时钟和 GPIO 配置省略,PA8~PA11 都设为 AF_PP // 定时器基础配置同上:PSC=71, ARR=19999 TIM_OCInitTypeDef TIM_OCInitStructure; TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OCInitStructure.TIM_Pulse = 1500; TIM_OC1Init(TIM1, &TIM_OCInitStructure); TIM_OC2Init(TIM1, &TIM_OCInitStructure); TIM_OC3Init(TIM1, &TIM_OCInitStructure); TIM_OC4Init(TIM1, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_OC3PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_OC4PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM1, ENABLE); TIM_Cmd(TIM1, ENABLE); TIM_CtrlPWMOutputs(TIM1, ENABLE); }四路初始化之后,用 TIM_SetCompare1 到 TIM_SetCompare4 分别更新角度。注意 PA8 到 PA11 这四个引脚在最小系统板上可能引出来了,也可能没引全,买板子的时候看清楚引脚排针。
角度平滑是另一个实用技巧。手机滑条直接映射角度时,舵机是跳变的,从 0 度到 180 度瞬间到位,齿轮冲击大,电流也冲得高。我一般加一个简单的线性插值,每 10ms 更新一次 CCR,每次变化不超过 2 度,这样舵机转动柔和,电源压力也小。
// 定时器 2 中断,10ms 周期,做角度平滑 void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); for (int i = 0; i < 4; i++) { if (current_angle[i] < target_angle[i]) { current_angle[i] += 2; if (current_angle[i] > target_angle[i]) current_angle[i] = target_angle[i]; } else if (current_angle[i] > target_angle[i]) { current_angle[i] -= 2; if (current_angle[i] < target_angle[i]) current_angle[i] = target_angle[i]; } } TIM_SetCompare1(TIM1, angle_to_ccr(current_angle[0])); TIM_SetCompare2(TIM1, angle_to_ccr(current_angle[1])); TIM_SetCompare3(TIM1, angle_to_ccr(current_angle[2])); TIM_SetCompare4(TIM1, angle_to_ccr(current_angle[3])); } }TIM2 的配置和 TIM1 类似,但不需要 PWM 输出,只开更新中断就行。PSC 还是 71,ARR 设成 9999,这样 10ms 触发一次。current_angle 和 target_angle 都是数组,串口中断里只改 target_angle,平滑逻辑在定时器中断里跑,两个中断各司其职,不会互相阻塞。
验证方法很简单:手机端发一个 180 度的指令,用示波器看 PA8 的脉宽是不是在 1 秒左右从 0.5ms 平滑过渡到 2.5ms,而不是瞬间跳变。如果跳变,说明平滑逻辑没生效,检查 TIM2 中断是否使能、NVIC 优先级是否被串口抢占。
从那以后我每次调舵机项目,都强制先用示波器确认四路 PWM 的脉宽和周期,再上舵机,最后才接蓝牙。这个顺序能省掉大量「到底是代码问题还是硬件问题」的纠结时间。希望帮到你。
本文还有配套的精品资源,点击获取