STM32F103C8T6驱动SG90舵机:从PWM原理到工程实践

STM32F103C8T6驱动SG90舵机:从PWM原理到工程实践

1. 从零开始:为什么选择STM32F103C8T6驱动SG90舵机?

如果你刚开始接触嵌入式开发,或者想从Arduino过渡到更专业的微控制器平台,那么用STM32F103C8T6(俗称“蓝桥杯最小系统板”或“核心板”)来控制一个SG90舵机,绝对是一个绝佳的入门项目。这个组合看似简单,却涵盖了从硬件连接到软件配置,再到核心控制原理的完整知识链。很多新手可能会觉得,用Arduino的Servo库一行代码就能搞定的事情,为什么还要用STM32这么“复杂”的芯片?这里面的门道,恰恰是区分“玩具级”玩法和“工程级”开发的开始。

SG90舵机是一种位置(角度)伺服驱动器,它内部集成了电机、减速齿轮组、控制电路和电位器。你给它一个周期性的PWM(脉冲宽度调制)信号,它就能根据信号中高电平的持续时间(即脉冲宽度),精确地转动到对应的角度。而STM32F103C8T6作为一款经典的ARM Cortex-M3内核MCU,其强大之处在于能提供精准、稳定且可灵活配置的PWM输出,这是Arduino的模拟PWM引脚难以比拟的。更重要的是,通过这个项目,你将系统地学习如何使用ST官方的HAL库和CubeMX工具链,这是现代STM32开发的“标准姿势”,无论是做智能小车、机械臂还是机器人,这都是绕不开的基础技能。

我最初接触这个项目时,也踩过不少坑:比如以为随便一个引脚就能输出PWM,结果舵机纹丝不动;或者配置好了但舵机抖动得厉害;又或者角度控制完全不线性。这些问题的根源,都在于对PWM信号规格和STM32定时器工作模式的理解不够透彻。接下来,我将带你一步步拆解,从硬件原理到软件配置,再到代码调试,手把手让你不仅能让舵机动起来,更能理解它为什么这么动,以及如何让它动得更好。

2. 硬件连接与信号原理:SG90舵机到底需要什么样的PWM?

在写代码之前,我们必须先搞清楚硬件上要做什么。很多教程一上来就讲CubeMX配置,但如果硬件连接错了或者理解有偏差,软件配置得再漂亮也是白搭。

2.1 SG90舵机的PWM信号规格

SG90是一种最常见的9克微型舵机,它有三根线:棕色(GND)、红色(VCC)和橙色(信号线)。它的控制逻辑非常标准:

  • 电源:VCC接5V,GND接MCU的GND。注意:虽然有些SG90标称工作电压为4.8V-6.0V,但直接接STM32的3.3V可能无法正常工作(扭矩不足或无法转动),所以通常需要外部5V供电。MCU的3.3V IO口输出信号控制5V的舵机,这在电平上是兼容的(3.3V高电平 > 舵机逻辑高电平阈值)。
  • 控制信号:需要的是一个周期为20ms(即频率为50Hz)的PWM方波。
  • 脉冲宽度与角度关系
    • 0.5ms的高电平脉冲对应舵机转动到0度(最左位置)。
    • 1.5ms的高电平脉冲对应舵机转动到90度(中间位置)。
    • 2.5ms的高电平脉冲对应舵机转动到180度(最右位置)。

这是一个线性关系。理论上,脉冲宽度在0.5ms到2.5ms之间变化,舵机角度就在0到180度之间变化。但实际应用中,边界值可能会有些许偏差,需要微调。

注意:市面上有些SG90的行程可能是0-180度,有些可能是0-270度(对应脉冲宽度0.5ms-2.5ms),购买时需留意规格书。我们以最常见的180度版本为例。

2.2 STM32F103C8T6的PWM输出能力与引脚选择

STM32的PWM输出功能是由其高级定时器(TIM1, TIM8)和通用定时器(TIM2, TIM3, TIM4)的“输出比较”通道产生的。F103C8T6有3个通用定时器(TIM2, TIM3, TIM4),每个定时器有4个独立的通道(CH1-CH4),每个通道可以映射到特定的GPIO引脚上。

这里第一个关键点来了:不是所有GPIO都能输出PWM!你必须使用具有“定时器通道输出”复用功能的引脚。对于F103C8T6,常见的PWM引脚有:

  • TIM2_CH1: PA0
  • TIM2_CH2: PA1
  • TIM2_CH3: PA2
  • TIM2_CH4: PA3
  • TIM3_CH1: PA6
  • TIM3_CH2: PA7
  • TIM3_CH3: PB0
  • TIM3_CH4: PB1
  • TIM4_CH1: PB6
  • TIM4_CH2: PB7
  • TIM4_CH3: PB8
  • TIM4_CH4: PB9

为了方便,我们选择PA6作为PWM输出引脚,它对应TIM3_CH1。你的硬件连接应该是:

  1. SG90 红线 -> 5V电源正极。
  2. SG90 棕线 -> 5V电源负极,同时与STM32的GND相连(共地!这是必须的)。
  3. SG90 橙线 -> STM32的PA6引脚。

2.3 计算定时器参数:让STM32产生50Hz的PWM

这是整个项目的核心原理部分。STM32的定时器通过一个计数器,配合预分频器(PSC)和自动重装载寄存器(ARR)来产生特定频率的波形。

我们的目标是:产生一个频率为50Hz(周期T=20ms)的PWM,并且能够通过改变一个值(捕获比较寄存器CCR)来精确控制高电平的宽度(0.5ms-2.5ms)。

步骤分解:

  1. 确定计时基准:STM32F103C8T6默认使用内部高速时钟(HSI)8MHz,或者通过外部晶振(通常8MHz)倍频到72MHz系统时钟(SYSCLK)。我们假设使用最常见的72MHz主频。定时器TIM3的时钟(APB1总线)通常是系统时钟的二分频,即36MHz。但CubeMX配置时会自动处理,我们只需关注定时器时钟。
  2. 设定计数周期(ARR):ARR决定了计数器从0数到多少后归零,这个周期就是PWM的周期。为了计算方便,我们让一个计数周期对应1微秒(1us)。这样,20ms的周期就需要20000个计数周期。所以,我们可以先设定ARR = 19999(因为计数器从0开始计数,0到19999是20000个数)。
  3. 计算预分频值(PSC):如何让计数器每1us计一个数呢?定时器的计数频率 = 定时器时钟 / (PSC + 1)。我们需要这个频率是1MHz(因为1/1MHz = 1us)。假设定时器时钟是72MHz(CubeMX中配置TIM3的时钟源为内部时钟,且APB1预分频器不为1时,定时器时钟可能会是72MHz或36MHz,这里以72MHz为例)。那么,PSC = (定时器时钟 / 目标计数频率) - 1 = (72MHz / 1MHz) - 1 = 71。
  4. 计算对应角度的CCR值:CCR(捕获/比较寄存器)的值决定了PWM输出中高电平的宽度。计数器从0开始向上计数,当计数值小于CCR时,输出高电平(或低电平,取决于极性配置);大于等于CCR时,翻转输出。
    • 0度(0.5ms高电平):CCR = 500(因为0.5ms = 500us,1us计一个数)。
    • 90度(1.5ms高电平):CCR = 1500。
    • 180度(2.5ms高电平):CCR = 2500。

参数总结(基于72MHz定时器时钟假设):

  • PSC (Prescaler): 71
  • ARR (AutoReload Register): 19999
  • PWM频率: 72MHz / ((71+1)(19999+1)) = 72,000,000 / (7220000) = 50 Hz
  • CCR范围: 500 ~ 2500,对应0~180度。

实操心得:这些计算是理解PWM配置的基石。在实际使用CubeMX时,你只需要输入想要的PWM频率(50Hz)和计数器分辨率(比如20000),它会自动帮你计算PSC值。但自己会算,才能在被问到“为什么我的舵机转不到180度”时,快速定位是ARR设小了,还是CCR最大值超限了。

3. 使用STM32CubeMX进行图形化配置

理论打通后,我们进入实战环节。STM32CubeMX是ST官方推出的图形化配置工具,能极大简化初始化代码的生成。下面我们一步步配置一个让PA6输出PWM的工程。

3.1 新建工程与时钟树配置

  1. 新建工程:打开CubeMX,点击“New Project”。在Part Number搜索框输入“STM32F103C8”,选择“STM32F103C8Tx”。双击进入项目。
  2. 系统核心(SYS)配置:在左侧“System Core”->“SYS”中,将“Debug”改为“Serial Wire”。这非常重要,它释放了PA13和PA14引脚用于SWD下载调试,否则这两个引脚可能被默认的JTAG功能占用,导致你无法下载程序。
  3. 配置时钟树(RCC)
    • 在“System Core”->“RCC”中,将“High Speed Clock (HSE)”设置为“Crystal/Ceramic Resonator”。这意味着我们将使用外部8MHz晶振。
    • 点击上方“Clock Configuration”标签页。你会看到一个复杂的时钟树图。
    • 找到输入源,选择“HSE”。
    • 将“PLL Source Mux”选择为“HSE”。
    • 将“PLLMUL”设置为“x9”倍频。
    • 将“System Clock Mux”选择为“PLLCLK”。
    • 此时,你应该看到“HCLK”显示为72MHz。APB1总线时钟(定时器时钟源之一)会自动显示为36MHz。但请注意,当APB1预分频系数不为1时,挂载在其上的定时器时钟会是APB1时钟的2倍,即72MHz。CubeMX会处理好这个细节。
    • 简单做法:你也可以直接点击“HCLK”输入框,手动输入72,然后按回车,CubeMX会自动帮你计算并填充其他参数。这是最稳妥的方法。

3.2 定时器与PWM通道配置

  1. 启用定时器:在左侧“Timers”中找到“TIM3”。
  2. 选择时钟源:在TIM3的配置页面,“Clock Source”选择“Internal Clock”。
  3. 配置PWM通道:下方“Channel1”选择“PWM Generation CH1”。这时,右侧芯片图的PA6引脚会自动被标记为绿色(TIM3_CH1)。
  4. 参数设置:切换到“Parameter Settings”标签页。
    • Prescaler (PSC): 输入71。
    • Counter Mode: “Up”(向上计数模式)。
    • Counter Period (ARR): 输入19999。
    • Internal Clock Division (CKD): “No Division”。
    • auto-reload preload: “Enable”。这个建议启用,可以防止在修改ARR时产生毛刺。
  5. PWM模式设置:向下滚动,找到“PWM Generation Channel 1”部分。
    • Mode: “PWM mode 1”。在这个模式下,当计数器小于CCR时,输出有效电平(我们后面定义);大于等于CCR时,输出无效电平。
    • Pulse (CCR): 先设置为1500(对应90度中间位置)。这个值后续在代码中可以动态修改。
    • Output Compare Preload: “Enable”。启用预装载,这样你修改CCR值后,会在下一个更新事件时才生效,保证波形稳定。
    • CH Polarity: “High”。这意味着“有效电平”是高电平。所以,我们的PWM波形将是:前1500个计数周期为高电平,后18500个计数周期为低电平。这正好符合我们的要求。

3.3 GPIO与工程生成设置

  1. GPIO检查:在“Pinout & Configuration”视图的芯片图上,确认PA6已被配置为TIM3_CH1。你也可以在“System Core”->“GPIO”中查看PA6的详细配置,默认模式应为“Alternate Function Push Pull”,无需改动。
  2. 项目管理:点击上方“Project Manager”标签页。
    • “Project”->“Project Name”:给你的工程起个名字,如“SG90_Control”。
    • “Project”->“Project Location”:选择一个路径。
    • “Toolchain / IDE”:选择你使用的IDE,如“MDK-ARM (V5)”对应Keil uVision5,或“STM32CubeIDE”。
  3. 代码生成设置
    • “Code Generator”->“Generated files”: 勾选“Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral”,这会让代码结构更清晰。
    • 同样在“Code Generator”下,建议勾选“Copy all used libraries into the project folder”,这样工程可以独立移植。
  4. 生成代码:点击右上角的“GENERATE CODE”。CubeMX会生成完整的初始化代码工程,并打开你选择的IDE。

4. 编写控制代码:从固定角度到平滑转动

生成了工程,我们来到了最关键的编程环节。HAL库的API已经为我们封装好了底层操作,我们的任务就是调用它们。

4.1 基础启动与固定角度控制

打开工程后,进入主函数所在的main.c文件。我们需要在/* USER CODE BEGIN 2 *//* USER CODE END 2 */之间添加用户代码。

首先,启动PWM输出:

/* USER CODE BEGIN 2 */ // 启动TIM3的通道1输出PWM HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); /* USER CODE END 2 */

然后,在主循环中,我们可以尝试让舵机转到几个固定角度。但注意,直接修改CCR寄存器可能不太直观。HAL库提供了__HAL_TIM_SET_COMPARE宏来方便地设置CCR值。

/* Infinite loop */ /* USER CODE BEGIN WHILE */ while (1) { // 转到0度 __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 500); HAL_Delay(1000); // 等待1秒 // 转到90度 __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 1500); HAL_Delay(1000); // 转到180度 __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 2500); HAL_Delay(1000); /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ } /* USER CODE END 3 */

编译下载程序到开发板,连接好舵机,你应该能看到舵机在0度、90度、180度三个位置来回转动。

4.2 实现角度控制函数与线性扫掠

上面的代码虽然能工作,但很不灵活。我们最好封装一个角度控制函数。

// 在main.c文件开头,USER CODE BEGIN Includes之后添加函数声明 /* USER CODE BEGIN PFP */ void Servo_SetAngle(TIM_HandleTypeDef *htim, uint32_t Channel, float angle); /* USER CODE END PFP */ // 在USER CODE BEGIN 4和USER CODE END 4之间添加函数定义 /* USER CODE BEGIN 4 */ /** * @brief 设置舵机角度 * @param htim: 定时器句柄指针,如&htim3 * @param Channel: 定时器通道,如TIM_CHANNEL_1 * @param angle: 目标角度,范围0.0 ~ 180.0 * @retval None */ void Servo_SetAngle(TIM_HandleTypeDef *htim, uint32_t Channel, float angle) { // 将角度限制在0-180范围内 if(angle < 0.0f) angle = 0.0f; if(angle > 180.0f) angle = 180.0f; // 线性映射:角度(0~180) -> 脉冲宽度(500~2500) // CCR = 500 + (angle / 180.0) * (2500 - 500) uint16_t ccr_val = 500 + (angle / 180.0f) * 2000; // 设置捕获比较寄存器值 __HAL_TIM_SET_COMPARE(htim, Channel, ccr_val); } /* USER CODE END 4 */

现在,主循环可以写得非常清晰,并且可以实现平滑扫掠:

/* USER CODE BEGIN 2 */ HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); /* USER CODE END 2 */ /* Infinite loop */ /* USER CODE BEGIN WHILE */ while (1) { // 从0度平滑扫掠到180度 for(float angle = 0.0f; angle <= 180.0f; angle += 1.0f) { Servo_SetAngle(&htim3, TIM_CHANNEL_1, angle); HAL_Delay(20); // 每20ms移动1度,扫完一圈大约3.6秒 } HAL_Delay(500); // 从180度平滑扫掠回0度 for(float angle = 180.0f; angle >= 0.0f; angle -= 1.0f) { Servo_SetAngle(&htim3, TIM_CHANNEL_1, angle); HAL_Delay(20); } HAL_Delay(500); /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ } /* USER CODE END 3 */

4.3 使用HAL库函数动态调整PWM参数

除了直接操作CCR,HAL库也提供了更规范的函数来启动、停止和修改PWM。例如,我们可以使用HAL_TIM_PWM_StartHAL_TIM_PWM_Stop来控制输出开关,用__HAL_TIM_SET_COMPARE__HAL_TIM_GET_COMPARE来读写CCR值。

一个更工程化的写法是,将舵机控制相关的操作封装到一个独立的servo.c/.h文件中,这样主程序逻辑会更清晰,也便于代码复用。

5. 调试、优化与常见问题排查

让舵机动起来只是第一步,让它稳定、精确、不抖动地工作,才是真正的挑战。下面分享几个我踩过的坑和解决方案。

5.1 舵机抖动问题分析与解决

现象:舵机到达指定位置后不停地震动或发出“滋滋”声,无法稳定。原因与排查

  1. 电源功率不足:这是最常见的原因。SG90在空载时电流约100-200mA,但在堵转或启动瞬间可能超过500mA。USB供电(500mA)或LDO线性稳压器可能无法提供足够电流。
    • 解决:使用独立的5V/1A以上的电源模块(如LM2596降压模块)为舵机供电,并与MCU共地。确保电源线足够粗。
  2. PWM信号不稳定:如果PWM信号的周期或占空比有微小波动,舵机内部的反馈电路会不断尝试修正,导致抖动。
    • 解决:检查代码中是否有其他中断或任务频繁打断PWM定时器。确保修改CCR值的操作是原子的(使用__HAL_TIM_SET_COMPARE宏是安全的)。在CubeMX中启用定时器的“auto-reload preload”和通道的“Output Compare Preload”功能。
  3. 机械负载过重或卡死:如果舵机轴被外力阻挡,它会持续输出最大扭矩试图到达位置,导致发热和抖动。
    • 解决:检查机械结构是否顺畅,避免超过舵机扭矩的负载。

5.2 角度不准或行程范围不对

现象:设置90度,实际可能转到80度或100度;或者无法转到0度或180度的极限位置。原因与排查

  1. PWM参数计算错误:回顾第2.3节的计算。确认你的定时器时钟频率、PSC和ARR值设置正确。一个快速验证的方法是:用示波器或逻辑分析仪测量PA6引脚的波形,看周期是否是20ms,高电平时间是否随CCR值线性变化。
  2. 舵机个体差异与死区:不同厂家、甚至同批次的SG90,其中位脉宽(1.5ms)对应的实际角度可能有±10度的偏差。而且,脉宽在接近极限值(如0.5ms和2.5ms)时,舵机内部的机械限位或电路可能已经饱和,无法继续转动。
    • 解决:进行校准。写一个测试程序,让CCR值从400逐步增加到2600,观察舵机的实际行程。记录下刚好开始转动(对应0度)和刚好转到极限(对应180度)时的CCR值,用这两个值替换公式中的500和2500。
    • 代码示例(校准后)
      #define SERVO_MIN_CCR 420 // 实测0度对应的CCR #define SERVO_MAX_CCR 2480 // 实测180度对应的CCR #define SERVO_CCR_RANGE (SERVO_MAX_CCR - SERVO_MIN_CCR) void Servo_SetAngle_Calibrated(TIM_HandleTypeDef *htim, uint32_t Channel, float angle) { if(angle < 0.0f) angle = 0.0f; if(angle > 180.0f) angle = 180.0f; // 使用校准后的参数进行映射 uint16_t ccr_val = SERVO_MIN_CCR + (angle / 180.0f) * SERVO_CCR_RANGE; __HAL_TIM_SET_COMPARE(htim, Channel, ccr_val); }

5.3 多路舵机控制与定时器资源规划

一个定时器(如TIM3)的四个通道(CH1-CH4)可以独立产生不同占空比的PWM,但它们共享同一个ARR和PSC,即频率必须相同。这对于控制多个同型号舵机来说是完美的,因为它们的控制频率都是50Hz。

如何连接

  • TIM3_CH1 (PA6) -> 舵机1信号线
  • TIM3_CH2 (PA7) -> 舵机2信号线
  • TIM3_CH3 (PB0) -> 舵机3信号线
  • TIM3_CH4 (PB1) -> 舵机4信号线

代码控制:在CubeMX中启用这四个通道的PWM Generation,然后在代码中分别启动它们,并独立设置各自的CCR值。

// 启动所有通道 HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_3); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_4); // 独立控制每个舵机 Servo_SetAngle(&htim3, TIM_CHANNEL_1, 45.0f); // 舵机1转到45度 Servo_SetAngle(&htim3, TIM_CHANNEL_2, 90.0f); // 舵机2转到90度 // ...

如果你需要控制超过4个舵机,或者需要不同频率的PWM,就需要使用多个定时器(如TIM2, TIM4)。F103C8T6的定时器资源有限,需要合理规划。

5.4 进阶:使用DMA实现多路舵机同步更新

在复杂的应用如机械臂中,可能需要同时更新多个舵机的角度,以避免逐个更新带来的不同步和延迟。这时可以使用DMA(直接存储器访问)。

思路:将一个包含所有舵机目标CCR值的数组作为数据源,通过DMA一次性搬运到各个定时器通道的CCR寄存器中。这样,所有更新会在同一个定时器更新事件中同时生效。

配置步骤(以TIM3的4个通道为例)

  1. CubeMX配置DMA:在TIM3配置页的“DMA Settings”中,为TIM3_CH1、CH2、CH3、CH4分别添加DMA请求。方向为“Memory To Peripheral”,数据宽度为“Word”(32位,因为CCR寄存器是16位,但DMA通常以字为单位传输更高效)。
  2. 代码实现
    • 定义一个数组uint32_t servo_ccr[4];存放四个通道的CCR值。
    • 使用HAL_TIM_PWM_Start_DMA函数启动PWM和DMA传输。
    • 当需要更新所有舵机角度时,只需更新servo_ccr数组,然后DMA会在下一个PWM周期自动完成搬运。

这种方法实现了“同步更新”,对于要求动作连贯性的应用至关重要。不过,DMA配置相对复杂,初次接触可以先用简单的__HAL_TIM_SET_COMPARE方式,待基础扎实后再研究。

6. 项目扩展与工程化思考

掌握了基础的单舵机控制后,我们可以将这个项目作为基石,向更实际的应用扩展。

扩展一:加入电位器或传感器进行反馈控制

  • 思路:利用STM32的ADC功能,读取一个电位器的电压值(对应旋转角度),然后映射为舵机的目标角度。这样就实现了一个简单的“手动操纵杆”或位置随动系统。
  • 关键点:ADC采样值需要滤波(如滑动平均滤波)以消除抖动,映射函数可能需要校准。

扩展二:构建简单的二自由度云台

  • 硬件:两个SG90舵机,一个用于水平(Pan)转动,一个用于垂直(Tilt)转动,通过支架连接。
  • 软件:为两个舵机分配不同的定时器通道(如TIM3_CH1和TIM3_CH2)。可以编写函数Set_PanTilt(float pan_angle, float tilt_angle)来同时控制两个轴。

扩展三:与上位机通信

  • 思路:通过STM32的串口(UART)接收来自电脑(如串口助手)或蓝牙模块的指令,解析指令中的角度值,再控制舵机转动。
  • 协议:可以定义简单的文本协议,如”A90T45\n”表示水平轴转到90度,垂直轴转到45度。这涉及到串口中断接收、数据解析和命令处理,是迈向复杂系统的重要一步。

工程化建议

  1. 模块化编程:将舵机驱动、ADC采集、串口解析等功能分别写成独立的.c/.h文件。主程序只负责调用高层接口和调度逻辑。
  2. 使用RTOS:如果项目复杂度增加(如需要同时控制舵机、读取多个传感器、处理通信),可以考虑引入FreeRTOS等实时操作系统,将不同任务分配到不同线程中,提高系统的响应性和可靠性。CubeMX可以很方便地集成FreeRTOS。
  3. 电源管理:多舵机系统对电源要求很高,务必做好电源去耦(在舵机电源入口处并联大电容,如1000uF电解电容和0.1uF陶瓷电容),并考虑使用单独的电源模块。

从点亮一个LED到控制一个舵机,再到构建一个多自由度系统,STM32的学习路径就是这样一环扣一环。SG90舵机项目虽然小,但它像一把钥匙,帮你打开了定时器、PWM、HAL库和硬件控制的大门。理解了这里的每一个参数和步骤,再去学习控制直流电机、步进电机、无刷电机,你会发现底层逻辑都是相通的——无非是更复杂的定时器模式、更精密的信号控制和更强的驱动电路。下次当你看到那些炫酷的机器人舞蹈视频时,你就能清晰地知道,每一个流畅动作的背后,都是无数个精准的PWM脉冲在有序地跳动。