主题
🔵 STM.C.1 — 上手完成第一台遥控车
🔵 从点亮第一颗 LED 到通过蓝牙遥控小车,共 9 节内容。
在本章结束时,你将得到一辆可以遥控的灵活电动小车,或许可以开始你们的碰碰车大赛。
STM.C.1.0 — 万事开头难:项目介绍与环境搭建
2:58:52一个项目速通单片机 STM32F103C8T6 第1章:上手完成第一台遥控车|GPIO PWM UART 蓝牙 电机 状态机 TIM 定时器 串口 驱动
本节我们将做:
了解如何将程序编译、链接、烧录到板子上并运行,选用最官方、最现代化、最 AI 化的开发方式
下载并安装 VS Code
安装 STM32CubeIDE for VS Code 扩展(VS Code 扩展市场搜
STM32CubeIDE)安装 STM32CubeMX(图形化配置引脚与时钟,务必启用“Serial Wire Debug”)
下载:STM32CubeMX 6.18.1 网盘(分享文件
SetupSTM32CubeMX-6.18.1-Win-x86_64.zip)创建 STM32F103C8T6 工程并完成初步配置
在
main.c的/* USER CODE BEGIN Includes */处加入:c#include "chapter.c"在
/* USER CODE BEGIN 2 */处加入:cchapter_main();新建
Core/Src/chapter.c,本章各节的代码依次粘贴到这里:c#include "main.h" void chapter_main(void) { int a = 3; int b = 4; int c; c = a + b; return; }安装 ST-Link USB 驱动
安装 STM32 烧录补丁插件 STM32 One-Click Build & Flash
在扩展市场(
Ctrl+Shift+X)搜索STM32 One-Click Build & Flash或烧录或电导实验室,找到对应插件点击安装即可。
核心知识点:
- 开发流程:CubeMX 配置引脚/时钟 → 生成工程 → VS Code 编译(CMake + Ninja + arm-none-eabi-gcc)→ ST-Link 烧录运行

相关资源:
STM.C.1.1 — 从一束光开始:GPIO 推挽输出
▶ 27:58 起2:58:52一个项目速通单片机 STM32F103C8T6 第1章:上手完成第一台遥控车|GPIO PWM UART 蓝牙 电机 状态机 TIM 定时器 串口 驱动
本节我们将做:
- 编写第一个 STM32 程序,点亮板载 LED
- 控制 LED 以 1 秒间隔闪烁
核心知识点:
- 硬件外设、寄存器:CPU执行程序时,会与硬件外设的寄存器交流。
- GPIO通用输入输出-推挽输出:控制该引脚连接到GND或VCC。

技能点:
- 函数调用、while循环
c
//******** STM.C.1.1 ********//
// ▼ CubeMX 需配置(在STM32CubeMX 中打开.ioc工程配置文件):
// ① 时钟源:左侧 Pinout & Configuration → Categories → System Core → RCC → High Speed Clock (HSE) → 选 “Crystal/Ceramic Resonator”
// ② 调试口:Categories → System Core → SYS → Debug → 选 “Serial Wire”(否则会禁用 SWD,导致之后无法烧录,如果烧录了禁用SWD的版本,需要将boot0连接到1 然后烧录新版本进行覆盖)
// ③ 时钟树:上方 Clock Configuration 标签页 → Input frequency 填 8(MHz,外部晶振)
// → PLL Source 选 HSE → PLL Mul 选 ×9 → System Clock Mux 选 PLLCLK(HCLK 自动显示 72MHz)
// → APB1 Prescaler 选 /2(36MHz,APB1 上限 36MHz)→ APB2 Prescaler 选 /1(72MHz)
// (72MHz 是最常用的主频;TIM2/TIM3 因此在 72MHz 下计数)
// ④ 引脚:芯片图上点击 PC13 → 选 GPIO_Output(推挽输出;本章不填 User Label,代码用原始 PC13)
// ⑤ 点右上角 GENERATE CODE;main() 会自动调用 SystemClock_Config() 与 MX_GPIO_Init() 完成时钟与 PC13 配置
#include "main.h" /* HAL 类型与 GPIO_PIN_x 引脚编号都来自它 */
/*
- 函数调用:通过调用函数来执行特定的任务, main() 已调用 chapter_main() 来执行我们的内容。
*/
void chapter_main(void)
{
// (PC13 已在 main() 里由 MX_GPIO_Init() 初始化成输出,无需再写初始化代码)
/*
- while 循环:无限循环,程序将一直在此运行。若想退出循环并向下运行,需要 break;。
*/
while (1)
{
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET); // 拉低 → LED 点亮(板载 LED 低电平点亮)
HAL_Delay(1000); // 亮灯持续 1000 毫秒(1 秒)
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET); // 拉高 → LED 熄灭
HAL_Delay(1000); // 灭灯持续 1000 毫秒(1 秒)
}
}STM.C.1.2 — 相信光,成为光:GPIO 上拉输入
▶ 42:41 起2:58:52一个项目速通单片机 STM32F103C8T6 第1章:上手完成第一台遥控车|GPIO PWM UART 蓝牙 电机 状态机 TIM 定时器 串口 驱动
本节我们将做:
- 连接按键到 GPIO 1,通过按键控制 LED 的开关
- 每次按下按键切换 LED 状态
核心知识点:
- 引脚复用:一个引脚可以连接至不同的硬件外设,通过切换实现不同的功能。
- GPIO通用输入输出-上拉输入:可用检测该引脚是否通过按键连接到了 GND。
- 上升沿和下降沿:当电平出现从低到高的变化或从高到低的变化时,执行相应的操作。
- 面包板与杜邦线:快速搭建电路的工具,便于实验和调试。

连接图

参考:引脚定义图

参考:电路原理图

技能点:
- 宏定义、变量、flag标志位、作用域、条件语句、static变量
c
//******** STM.C.1.2 ********//
// ▼ CubeMX 需配置(接续第 1 节):
// ① 引脚:芯片图上点击 PA0 → 选 GPIO_Input
// ② 下方 GPIO 面板 → PA0 的 Pull-up/Pull-down → 选 “Pull-up”(上拉:平时高电平,按下接地为低)
// ③ GENERATE CODE;PA0 初始化也会进 main() 的 MX_GPIO_Init()
#include <stdbool.h> /* 本节起使用 bool 类型,故在本节开头引入 */
/* 宏定义:使用 #define 定义一个常量或宏,以便在代码中使用更具描述性的名称来代替具体的值。 */
#define BUTTON_PORT GPIOA
#define BUTTON_PIN GPIO_PIN_0 /* 按键:PA0,按下接地(读到低电平) */
#define BOARD_LED_PORT GPIOC
#define BOARD_LED_PIN GPIO_PIN_13 /* 板载 LED:PC13,低电平点亮 */
void chapter_main(void)
{
// (PC13、PA0 已在 main() 的 MX_GPIO_Init() 初始化:LED=输出,按键=上拉输入)
/*
变量:在程序中用于存储数据的容器,这里使用 bool 类型的变量 last_button_state 来记录上一次按钮的状态。
*/
/*
flag 标志位:用于记录系统的某项状态
*/
bool last_button_state = true; // 记录上一次按钮的状态,初始为未按下(高电平)
while (1)
{
/*
作用域:
在 while 循环内定义的变量 current_button_state 只在循环体内有效,每次循环都会重新定义和赋值。
而 last_button_state 定义在循环外部,因此它的值在整个程序运行期间都保持不变,直到被更新为当前按钮状态。
*/
bool cur = (HAL_GPIO_ReadPin(BUTTON_PORT, BUTTON_PIN) == GPIO_PIN_SET); // 读取当前按钮状态
/*
if() 条件语句:根据括号内的条件判断是否执行大括号内的代码块,经常包含 &&(逻辑与)或 ||(逻辑或)等逻辑运算符来组合多个条件。
*/
if (last_button_state && !cur) // 检测到按钮从未按下变为按下的状态变化(下降沿)
{
/*
static 变量:在函数内定义的 static 变量在程序周期内只初始化一次、不被销毁。但作用域仍然限制。
*/
static bool led_on = false; // 记录 LED 当前状态
led_on = !led_on; // 切换 LED 状态
HAL_GPIO_WritePin(BOARD_LED_PORT, BOARD_LED_PIN, led_on ? GPIO_PIN_RESET : GPIO_PIN_SET); // 低电平点亮
}
last_button_state = cur; // 将当前按钮状态保存为上一次状态,以便下一次循环比较
HAL_Delay(10); // 延迟
}
}STM.C.1.3 — 呼吸的光才有灵魂:PWM 与 TIM 定时器
▶ 1:06:12 起2:58:52一个项目速通单片机 STM32F103C8T6 第1章:上手完成第一台遥控车|GPIO PWM UART 蓝牙 电机 状态机 TIM 定时器 串口 驱动
本节我们将做:
- 使用 PWM 实现 LED 呼吸灯效果(亮度从暗到亮再到暗循环)
- 通过按键切换呼吸 / 常亮模式
核心知识点:
- PWM脉宽调制:通过快速改变高低电平,用高低电平的比例模拟输出的强度。

连接图:

技能点:
- include头文件、PWM配置结构体、if-else条件语句、const常量、全局变量、边界条件控制
c
//******** STM.C.1.3 ********//
// 呼吸的光才有灵魂:PWM + TIM 定时器
//▼ CubeMX 需配置(接续第 2 节):
// ① 定时器:左侧 Categories → Timers → TIM2 → 在 TIM2 面板 Channel2 下拉 → 选 “PWM Generation CH2”
// (芯片图 PA1 会自动变绿并复用为 TIM2_CH2)
// ② 参数:TIM2 → Parameter Settings → Prescaler = 71;Counter Period = 1000(Pulse 初始占空比可填 0)
// ③ GENERATE CODE(生成 MX_TIM2_Init 与 htim2);代码中还需 HAL_TIM_PWM_Start() 真正启动 PWM 输出
/*
PWM配置结构体:通过 pwm_config 结构体配置 PWM 的频率、分频等参数,再初始化到对应的 PWM 切片中。
(STM32 中这些参数已由 CubeMX 的 MX_TIM2_Init() 配好,代码里只需启动 PWM 并改占空比)
*/
/* 本节才用到定时器句柄 → 才声明(句柄本体由 CubeMX 在 main.c 生成) */
extern TIM_HandleTypeDef htim2;
/*
const变量:在函数内定义的 const 变量在程序周期内值不可修改,且只初始化一次。适用于常量值的定义。
全局变量:在函数外部定义的变量,在整个程序中都可以访问和修改。
*/
const int brightness_step = 10; // 亮度变化步长
void init_pwm_led(void)
{
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_2); // 启动 TIM2_CH2 的 PWM 输出(PA1)
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_2, 0); // 初始占空比为 0(LED 关闭)
}
void chapter_main(void)
{
init_pwm_led(); // 启动 PWM(GPIO 由 MX_GPIO_Init、PWM 参数由 MX_TIM2_Init 完成)
bool last_button_state = true; // 记录上一次按钮的状态,初始为未按下(高电平)
while (1)
{
static bool breathing = true; // 新增:记录当前是否呼吸模式 注意作用域的影响
static int brightness = 0; // 新增:记录当前亮度
static bool direction = true; // 新增:记录亮度变化方向
bool cur = (HAL_GPIO_ReadPin(BUTTON_PORT, BUTTON_PIN) == GPIO_PIN_SET); // 读取当前按钮状态
if (last_button_state && !cur) // 检测到按钮从未按下变为按下的状态变化(下降沿)
{
static bool led_on = false; // 记录 LED 当前状态
led_on = !led_on; // 切换 LED 状态
HAL_GPIO_WritePin(BOARD_LED_PORT, BOARD_LED_PIN, led_on ? GPIO_PIN_RESET : GPIO_PIN_SET);
breathing = !breathing; // 新增:切换呼吸模式状态
}
/*
if-else 条件语句:根据括号内的条件判断执行不同的代码块。
*/
if (breathing)
{
if (direction)
{
brightness += brightness_step; // 增加亮度
/*
边界条件控制:当亮度值达到上限(1000)时停止增加并翻转方向,实现亮度从暗到亮再到暗的循环变化。
*/
if (brightness >= 1000)
{ // 达到最大亮度
brightness = 1000;
direction = false; // 切换为减少亮度
}
}
else
{
brightness -= brightness_step; // 减少亮度
/*
边界条件控制:当亮度值达到下限(0)时停止减少并翻转方向,形成完整的呼吸周期。
*/
if (brightness <= 0)
{ // 达到最小亮度
brightness = 0;
direction = true; // 切换为增加亮度
}
}
}
else
{
brightness = 0; // 关闭 LED
}
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_2, brightness); // 设置 PWM 占空比以调整 LED 亮度
last_button_state = cur; // 将当前按钮状态保存为上一次状态
HAL_Delay(10); // 延迟
}
}STM.C.1.4 — 才第四节就这么复杂了?:参数传入返回与 USB 通信
▶ 1:21:16 起2:58:52一个项目速通单片机 STM32F103C8T6 第1章:上手完成第一台遥控车|GPIO PWM UART 蓝牙 电机 状态机 TIM 定时器 串口 驱动
本节我们将做:
- 将之前的逻辑封装成函数,提高代码复用性和可读性
- 实现
check_button_pressed()和update_breathing_led()两个新函数
核心知识点:
- 函数定义与调用:将代码封装成函数,提高代码的复用性和可读性。
- 自定义print调试输出:通过在代码中添加打印语句,将变量值和程序状态输出到 USB 进行调试和分析。
- USB通信:通过 USB 将编码器数据发送到电脑进行调试和分析。

💡提醒:USB 通信的三点说明
- 先分清两条线的分工:ST-Link 用来烧录程序,USB 线用来供电和通信。
- USB 通信是直接通过板载的 USB 接口(Type-C 或 Micro-USB)连接电脑完成的,不经过 ST-Link —— 所以只做 USB 调试打印时,不插 ST-Link 也可以,一根 USB 线就能同时供电并通信。
- 如果电脑没有识别到设备,把 USB 线拔掉后重新插入 STM32,通常就能被正确识别。
相关链接:
技能点:
- 函数返回值、函数传入参数、自定义print调试输出
c
//******** STM.C.1.4 ********//
// ▼ CubeMX 需配置(接续第 3 节)——本节的打印走“USB 虚拟串口”:
// ① USB:左侧 Categories → Connectivity → USB → Mode → 选 “Device (FS)”
// (芯片图 PA11/PA12 自动复用为 USB_DM / USB_DP)
// ② 中间件:左侧列表往下 → “Middleware and Software Packs” → USB_DEVICE → 勾选 Activate
// → Class for FS IP → 选 “Communication Device Class (Virtual Port Com)”(即 CDC 虚拟串口)
// ③ 时钟:上方 Clock Configuration 标签页 → USB → 确保 48 MHz(72MHz 主频下自动按 /1.5 分频)
// ④ GENERATE CODE;main() 会自动调用 MX_USB_DEVICE_Init()(若 chapter_main(); 被清空需补回)
#include <stdio.h>
#include <stdarg.h>
#include <string.h>
#include "usbd_cdc_if.h" /* USB 虚拟串口(CDC):本节打印用它发给电脑,故在本节引入 */
/*
函数返回值:
函数可以返回一个值,表示函数执行的结果或状态。
check_button_pressed 函数用于检测按钮是否被按下,并返回一个布尔值表示按钮状态。
*/
bool check_button_pressed(void)
{
static bool last_button_state = true; // 记录上一次按钮的状态,初始为未按下(高电平)
bool cur = (HAL_GPIO_ReadPin(BUTTON_PORT, BUTTON_PIN) == GPIO_PIN_SET); // 读取当前按钮状态
if (last_button_state && !cur)
{
last_button_state = cur; // 更新 last_button_state
return true; // 检测到按钮按下,返回 true
}
last_button_state = cur; // 更新 last_button_state
return false; // 没有检测到按钮按下,返回 false
}
/*
函数传入参数:
函数可以接受参数,这些参数在函数内部使用来执行特定的操作。
update_breathing_led 函数接受一个布尔参数 breathing,表示当前是否处于呼吸模式,根据该参数来更新 LED 的状态。
*/
int update_breathing_led(bool breathing)
{
if (!breathing)
{
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_2, 0); // 关闭 LED
return -1; // 用 -1 表示“当前未在呼吸”
}
else
{
static int brightness = 0; // 记录当前亮度
static bool direction = true; // 记录亮度变化方向
if (direction)
{
brightness += brightness_step; // 增加亮度
if (brightness >= 1000)
{ // 达到最大亮度
brightness = 1000;
direction = false; // 切换为减少亮度
}
}
else
{
brightness -= brightness_step; // 减少亮度
if (brightness <= 0)
{ // 达到最小亮度
brightness = 0;
direction = true; // 切换为增加亮度
}
}
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_2, brightness); // 设置 PWM 占空比以调整 LED 亮度
return brightness; // 返回当前亮度值
}
}
// 自定义 print():把格式化文本通过 USB 虚拟串口(CDC) 发到电脑。
void print(const char *fmt, ...)
{
char buf[64];
va_list args;
va_start(args, fmt);
vsnprintf(buf, sizeof(buf), fmt, args); // 先把格式化结果写入 buf
va_end(args);
CDC_Transmit_FS((uint8_t *)buf, (uint16_t)strlen(buf)); // 通过 USB 虚拟串口发给电脑
}
void chapter_main(void)
{
init_pwm_led(); // 启动 PWM
bool breathing = true; // 记录当前是否呼吸模式
while (1)
{
if (check_button_pressed())
{
static bool led_on = false; // 记录 LED 当前状态
led_on = !led_on; // 切换 LED 状态
HAL_GPIO_WritePin(BOARD_LED_PORT, BOARD_LED_PIN, led_on ? GPIO_PIN_RESET : GPIO_PIN_SET);
breathing = !breathing; // 切换呼吸模式状态
}
int now_brightness = update_breathing_led(breathing); // 更新呼吸灯状态
/*
自定义print()函数:用于将信息输出到 USB 进行调试和分析,这里我们可以输出当前的亮度值来观察呼吸灯的变化。
*/
print("Brightness: %d\n", now_brightness); // 打印当前亮度值到 USB 虚拟串口进行调试
HAL_Delay(10); // 延迟
}
}STM.C.1.5 — 我要用 GPIO 和 PWM 干票大的:TB6612 与电机控制
▶ 1:32:28 起2:58:52一个项目速通单片机 STM32F103C8T6 第1章:上手完成第一台遥控车|GPIO PWM UART 蓝牙 电机 状态机 TIM 定时器 串口 驱动
本节我们将做:
- 使用 TB6612FNG 电机驱动芯片驱动两个直流电机
- 通过按键循环切换前进 → 后退 → 停止
核心知识点:
- 直流电机原理:通过改变电机两端的电压极性来控制转动方向。
- 电机驱动芯片:TB6612FNG 是一个常用的电机驱动芯片,可以控制两个直流电机的转动方向和速度。
- 电压与共地:务必!务必!务必!共地连接以保证电路的正常工作。


连接图:

技能点:
- 引脚资源分配、累加取模
c
//******** STM.C.1.5 ********//
// ▼ CubeMX 需配置(接续第 4 节):
// ① 方向脚:芯片图依次点击 PB12、PB13、PB14、PB15 → 各选 GPIO_Output
// 下方 GPIO 面板 User Label(建议):PB12=IN1_A、PB13=IN2_A、PB14=IN1_B、PB15=IN2_B
// (main.h 会生成 IN1_A_Pin/IN2_A_Pin/IN1_B_Pin/IN2_B_Pin 等宏)
// ② 定时器:左侧 Categories → Timers → TIM3 → Channel1、Channel2 都选 “PWM Generation CH1/CH2”
// (芯片图 PA6/PA7 自动复用为 TIM3_CH1/CH2)
// ③ 参数:TIM3 → Parameter Settings → Prescaler=71;Counter Period=1000(同 TIM2)
// ④ GENERATE CODE;代码中还需 HAL_TIM_PWM_Start() 启动两路 PWM
/*
引脚资源分配:单片机的引脚支持不同的功能,因此要合理分配引脚资源,避免冲突。
这里两个电机的 4 个方向脚(PB12~PB15,输出)与 2 路 PWM(PA6/PA7 = TIM3_CH1/CH2)
已在 CubeMX 里分配好,引脚宏 IN1_A_Pin/IN2_A_Pin/IN1_B_Pin/IN2_B_Pin 由 main.h 生成。
*/
/* 本节才用到电机定时器句柄 → 才声明 */
extern TIM_HandleTypeDef htim3;
void init_motor(void)
{
// 方向脚(GPIOB)已在 MX_GPIO_Init() 配为输出;这里只需启动两路 PWM(速度由占空比决定)
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);
}
void chapter_main(void)
{
init_motor(); // 启动两路 PWM(GPIO 由 MX_GPIO_Init、PWM 由 MX_TIM3_Init 完成)
int motor_state_step = 0; // 0: 前进, 1: 后退, 2: 停止
while (1)
{
if (check_button_pressed())
{
/*
累加取模:通过对 motor_state_step 进行累加并取模来循环切换电机状态,实现前进、后退和停止的功能。
*/
motor_state_step = (motor_state_step + 1) % 3; // 循环切换状态
}
if (motor_state_step == 0)
{
// 前进
HAL_GPIO_WritePin(IN1_A_GPIO_Port, IN1_A_Pin, GPIO_PIN_SET); // 10:A 正转
HAL_GPIO_WritePin(IN2_A_GPIO_Port, IN2_A_Pin, GPIO_PIN_RESET);
HAL_GPIO_WritePin(IN1_B_GPIO_Port, IN1_B_Pin, GPIO_PIN_RESET); // 01:B 反转。注意电机 B 与 A 相反
HAL_GPIO_WritePin(IN2_B_GPIO_Port, IN2_B_Pin, GPIO_PIN_SET);
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 500); // 50% 占空比
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 500); // 50% 占空比
}
else if (motor_state_step == 1)
{
// 后退
HAL_GPIO_WritePin(IN1_A_GPIO_Port, IN1_A_Pin, GPIO_PIN_RESET); // 01:A 反转
HAL_GPIO_WritePin(IN2_A_GPIO_Port, IN2_A_Pin, GPIO_PIN_SET);
HAL_GPIO_WritePin(IN1_B_GPIO_Port, IN1_B_Pin, GPIO_PIN_SET); // 10:B 正转。注意电机 B 与 A 相反
HAL_GPIO_WritePin(IN2_B_GPIO_Port, IN2_B_Pin, GPIO_PIN_RESET);
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 500); // 50% 占空比
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 500); // 50% 占空比
}
else
{
// 停止
HAL_GPIO_WritePin(IN1_A_GPIO_Port, IN1_A_Pin, GPIO_PIN_SET); // 两脚同高 → 刹车
HAL_GPIO_WritePin(IN2_A_GPIO_Port, IN2_A_Pin, GPIO_PIN_SET);
HAL_GPIO_WritePin(IN1_B_GPIO_Port, IN1_B_Pin, GPIO_PIN_SET);
HAL_GPIO_WritePin(IN2_B_GPIO_Port, IN2_B_Pin, GPIO_PIN_SET);
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 0); // 停止电机 A
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 0); // 停止电机 B
}
HAL_Delay(10); // 延迟
}
}STM.C.1.6 — 恭喜你,你已经会做多级 API 了:多层 API 设计
▶ 1:57:06 起2:58:52一个项目速通单片机 STM32F103C8T6 第1章:上手完成第一台遥控车|GPIO PWM UART 蓝牙 电机 状态机 TIM 定时器 串口 驱动
本节我们将做:
- 封装底层电机控制逻辑,提供
set_motor_speed()和drive()高级 API - 实现差速转向:通过组合前进速度和转向速度控制两轮
核心知识点:
- 多层API设计:对不同的数据抽象层提供不同的接口,使得代码更模块化、更易于维护和扩展。
- 小车运动控制:通过组合前进速度和转向速度来实现小车的运动控制。

技能点:
- 差速转向叠加原理、速度限幅
c
//******** STM.C.1.6 ********//
#define MAX_SPEED 1000
// 第一层 API,传入电机ID与速度,直接控制电机转动
void set_motor_speed(int motor_id, int speed)
{
// motor_id: 0 - 左轮, 1 - 右轮
uint16_t in1, in2; // 方向引脚掩码(值是 GPIO_PIN_xx,uint16_t)
GPIO_TypeDef *port1, *port2; // 方向引脚所在端口(值是 GPIOB 等,是 GPIO_TypeDef* 指针)
uint32_t ch; // 定时器通道
if (motor_id == 0)
{
// 左轮 ← 物理 Motor A(PB12/PB13 = IN1_A/IN2_A)
in1 = IN1_A_Pin;
in2 = IN2_A_Pin;
port1 = IN1_A_GPIO_Port;
port2 = IN2_A_GPIO_Port;
ch = TIM_CHANNEL_1;
}
else
{
// 右轮 ← 物理 Motor B(PB14/PB15 = IN1_B/IN2_B)
in1 = IN2_B_Pin;
in2 = IN1_B_Pin;
port1 = IN2_B_GPIO_Port;
port2 = IN1_B_GPIO_Port;
ch = TIM_CHANNEL_2;
}
if (speed > 0)
{
HAL_GPIO_WritePin(port1, in1, GPIO_PIN_SET); // IN1/IN2 对调以纠正接线反向
HAL_GPIO_WritePin(port2, in2, GPIO_PIN_RESET);
__HAL_TIM_SET_COMPARE(&htim3, ch, speed); // 正转,占空比 = speed
}
else if (speed < 0)
{
HAL_GPIO_WritePin(port1, in1, GPIO_PIN_RESET);
HAL_GPIO_WritePin(port2, in2, GPIO_PIN_SET);
__HAL_TIM_SET_COMPARE(&htim3, ch, -speed); // 反转,占空比 = -speed
}
else
{
// 停止
HAL_GPIO_WritePin(port1, in1, GPIO_PIN_SET);
HAL_GPIO_WritePin(port2, in2, GPIO_PIN_SET);
__HAL_TIM_SET_COMPARE(&htim3, ch, 0);
}
}
// 第二层 API,传入正向速度和转向速度,控制两轮速度实现差速转向
void drive(int forward_speed, int turn_speed)
{
// forward_speed: -1000 到 1000,负数表示后退,正数表示前进
// turn_speed: -1000 到 1000,负数表示左转,正数表示右转
int left_motor_speed = forward_speed + turn_speed; // 左轮速度 = 前进速度 + 转向速度
int right_motor_speed = forward_speed - turn_speed; // 右轮速度 = 前进速度 - 转向速度
/*
速度限幅:通过检查计算后的速度值是否超过 MAX_SPEED 阈值,
将左右轮速度限制在 [-1000, 1000] 范围内,防止电机驱动芯片过载。
*/
if (left_motor_speed > MAX_SPEED) left_motor_speed = MAX_SPEED;
if (left_motor_speed < -MAX_SPEED) left_motor_speed = -MAX_SPEED;
if (right_motor_speed > MAX_SPEED) right_motor_speed = MAX_SPEED;
if (right_motor_speed < -MAX_SPEED) right_motor_speed = -MAX_SPEED;
set_motor_speed(0, left_motor_speed); // 控制左轮
set_motor_speed(1, right_motor_speed); // 控制右轮
}
void chapter_main(void)
{
init_motor(); // 启动两路 PWM
int drive_state_step = 0; // 0: 前进, 1: 后退, 2: 左转, 3: 右转, 4: 停止
while (1)
{
if (check_button_pressed())
{
drive_state_step = (drive_state_step + 1) % 5; // 循环切换状态
}
if (drive_state_step == 0) drive( 500, 0); // 前进
else if (drive_state_step == 1) drive(-500, 0); // 后退
else if (drive_state_step == 2) drive( 0, -500); // 左转
else if (drive_state_step == 3) drive( 0, 500); // 右转
else drive( 0, 0); // 停止
HAL_Delay(10); // 延迟
}
}STM.C.1.7 — 追着车跑一定很累吧:UART 串口与蓝牙模块
▶ 2:09:19 起2:58:52一个项目速通单片机 STM32F103C8T6 第1章:上手完成第一台遥控车|GPIO PWM UART 蓝牙 电机 状态机 TIM 定时器 串口 驱动
本节我们将做:
- 配置 HC-05 蓝牙模块的 UART 通信
- 实现 UART 回显:蓝牙发送的字符通过串口返回
- 按键发送 "Hello, world!" 字符串
核心知识点:
- 串口通信:通过 UART 协议进行数据传输。
- FIFO缓冲区:UART 使用环形缓冲区来管理数据的接收和发送。
- HC-05 蓝牙模块:常用的蓝牙串口模块,可以通过 UART 与单片机通信。


连接图:

技能点:
- uart_is_readable/writable可读/可写判断、指针遍历字符串
相关资源:
使用外部 USB 转串口模块(如 CH340 / CP2102)时,Windows 不自带驱动,需要额外安装。请根据模块芯片型号下载对应驱动:
c
//******** STM.C.1.7 ********//
// ▼ CubeMX 需配置(接续第 6 节):
// ① 串口:左侧 Categories → Connectivity → USART1 → Mode → 选 “Asynchronous”
// (芯片图 PA9/PA10 自动复用为 USART1_TX / USART1_RX)
// ② 参数:USART1 → Parameter Settings → Baud Rate = 115200;Word Length = 8 Bits;
// Parity = None;Stop Bits = 1(与 HC-05 蓝牙模块默认串口参数一致)
// ③ NVIC:Categories → System Core → NVIC → 勾选 “USART1 global interrupt”
// (本节改用中断接收,每个字节到达时自动进入 USART1_IRQHandler)
// ④ GENERATE CODE;生成 MX_USART1_UART_Init() 与 huart1(若 chapter_main(); 被清空需补回)
/* huart1(USART1) 由 CubeMX 生成,供第 7/8 节蓝牙透传使用 */
extern UART_HandleTypeDef huart1;
/* ---------- USART1 环形缓冲区的接收与发送 ----------
* F103 串口只有一个字节的接收缓冲,不会自动放到环形缓存区中,
* 因此要手动配置中断,每次收到新的字符自动放置到环形缓冲区中。
* 关于中断将在 2.1 中更详细介绍,这里只大概理解即可
*/
#define UART_RX_RING 32
static volatile uint8_t uart_rx_ring[UART_RX_RING]; /* 收到的字节环形缓冲 */
static volatile uint8_t uart_rx_head = 0; /* 中断写入位置 */
static volatile uint8_t uart_rx_tail = 0; /* 主循环读出位置 */
volatile uint8_t uart_rx_byte = 0; /* 中断当前收到的字节 */
/* 收完一个字节的回调:存入环形缓冲,并立即重新挂起接收(等下一个字节) */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
uint8_t next = (uint8_t)((uart_rx_head + 1) % UART_RX_RING);
if (next != uart_rx_tail) /* 缓冲未满才写入(满则丢弃) */
{
uart_rx_ring[uart_rx_head] = uart_rx_byte;
uart_rx_head = next;
}
HAL_UART_Receive_IT(&huart1, (uint8_t *)&uart_rx_byte, 1);
}
}
/* 环形缓冲读取接口:USART1 中断负责写入;uart_handler 与
uart_handler_control 都从这里读字节。 */
bool uart_rx_available(void)
{
return uart_rx_tail != uart_rx_head;
}
uint8_t uart_rx_read(void)
{
uint8_t c = uart_rx_ring[uart_rx_tail];
uart_rx_tail = (uint8_t)((uart_rx_tail + 1) % UART_RX_RING);
return c;
}
/* ---------- 双向环形缓冲区代码部分结束 ---------- */
void chapter_main(void)
{
// (USART1 已在 main() 的 MX_USART1_UART_Init() 初始化;按键 PA0 已由 MX_GPIO_Init 初始化)
/* 开启 USART1 接收中断:每个字节一到就存入环形缓冲,主循环再从容取出 */
HAL_NVIC_SetPriority(USART1_IRQn, 6, 0);
HAL_NVIC_EnableIRQ(USART1_IRQn);
HAL_UART_Receive_IT(&huart1, (uint8_t *)&uart_rx_byte, 1);
// 接收到的数据再通过 UART 发送回去,形成回显
while (1)
{
/*
环形缓冲可读判断 uart_rx_available():中断负责写入,主循环只判断有没有新字节,
*/
while (uart_rx_available())
{
uint8_t c = uart_rx_read(); // 从环形缓冲取一个字符(uart_rx_read)
HAL_UART_Transmit(&huart1, &c, 1, 100); // 原样发回去(回显)
}
/*
指针遍历字符串:通过指针 msg 遍历字符串中的每个字符,
逐个发送直到遇到字符串结束符 \0。
*/
if (check_button_pressed())
{
const char *msg = "Hello, world!\r\n";
while (*msg) // 还没到结尾 \0 就继续
{
HAL_UART_Transmit(&huart1, (uint8_t *)msg++, 1, 100); // 一次发一个字符
}
}
}
}STM.C.1.8 — 遥控车!启动!:状态机与通信协议
▶ 2:41:40 起2:58:52一个项目速通单片机 STM32F103C8T6 第1章:上手完成第一台遥控车|GPIO PWM UART 蓝牙 电机 状态机 TIM 定时器 串口 驱动
本节我们将做:
- 整合所有功能,通过蓝牙接收控制指令控制小车
- 使用状态机解析 5 字节通信协议
核心知识点:
- 状态机:通过定义不同的状态来管理程序的流程和逻辑,使得程序更清晰、更易于维护。
- 通信协议设计:设计一个简单的协议来解析从蓝牙模块接收到的控制指令。

技能点:
- 状态机设计、缓冲区轮询、switch条件语句、校验和验证
c
//******** STM.C.1.8 ********//
// 协议帧:0xA5 | forward(有符号) | turn(有符号) | 校验和 | 0x5A
static enum {
IDLE, // 等待帧头
GET_FORWARD, // 已收帧头,等待 forward 字节
GET_TURN, // 已收 forward,等待 turn 字节
GET_CHECKSUM, // 已收 turn,等待校验和字节
GET_TAIL // 已收校验和,等待帧尾
} uart_state = IDLE;
// 解析结果:两个有符号速度量(本章写入,供主循环读取)
int16_t forward_speed = 0, turn_speed = 0;
/* 主循环:把缓冲里攒下的字节逐个喂给状态机解析 */
void uart_handler(void)
{
while (uart_rx_available())
{
uint8_t c = uart_rx_read();
static int8_t byte1 = 0, byte2 = 0;
/*
switch 条件语句:根据 uart_state 的不同值执行不同的代码块,实现状态机的逻辑。
*/
switch (uart_state)
{
case IDLE:
if (c == 0xA5)
{
uart_state = GET_FORWARD; // 检测到帧头,进入下一状态
byte1 = 0; // 清零中间变量
byte2 = 0;
}
break;
case GET_FORWARD:
byte1 = (int8_t)c; // 转换为有符号,支持负数(后退)
uart_state = GET_TURN;
break;
case GET_TURN:
byte2 = (int8_t)c;
uart_state = GET_CHECKSUM;
break;
case GET_CHECKSUM:
/*
校验和验证:通过 (byte1 + byte2) & 0xFF 计算校验和,
与接收到的校验字节比较,确保数据传输的准确性。
*/
if (((byte1 + byte2) & 0xFF) == c)
{
uart_state = GET_TAIL; // 校验通过,等待帧尾
}
else
{
uart_state = IDLE; // 校验失败,复位
}
break;
case GET_TAIL:
if (c == 0x5A) // 帧尾校验
{
forward_speed = byte1 * 4; // 前进速度(*4 放大到 -1000~1000)
turn_speed = byte2 * 4; // 转向速度
}
uart_state = IDLE;
break;
}
}
}
void chapter_main(void)
{
// 所有 GPIO/PWM/串口初始化已在 main() 里由 CubeMX 生成的
// MX_GPIO_Init() / MX_TIM2_Init() / MX_TIM3_Init() / MX_USART1_UART_Init() 完成,
// 这里只需要把两路 PWM 真正“开始输出”(CubeMX 不会自动 Start)。
init_pwm_led(); // 启动 TIM2_CH2 呼吸灯 PWM
init_motor(); // 启动 TIM3 两路电机 PWM
/* 开启 USART1 接收中断:每个字节一到就存入环形缓冲,不再 10ms 轮询丢帧 */
HAL_NVIC_SetPriority(USART1_IRQn, 6, 0);
HAL_NVIC_EnableIRQ(USART1_IRQn);
HAL_UART_Receive_IT(&huart1, (uint8_t *)&uart_rx_byte, 1);
while (1)
{
/*
状态机协议解析:主循环把“中断收进缓冲”的字节逐个取出来解析(uart_drain),
提取 forward_speed 和 turn_speed 两个控制量。
*/
uart_handler(); // 缓冲字节 → 状态机 → forward_speed/turn_speed
/*
控制指令应用:将解析出的 forward_speed 和 turn_speed 传入 drive 函数,
drive 内部通过差速转向算法控制左右轮电机,实现小车运动。
*/
drive(forward_speed, turn_speed); // 根据解析的指令控制小车运动
update_breathing_led(true); // 呼吸灯
HAL_Delay(10); // 延迟
}
}恭喜你,你已经完成了 STM.C.1 课程的所有内容!驾驶你的小遥控车,能忍住不来一场碰碰车比赛吗?