☰
第 12 章 综合实战:完整信号链与双电机
2026/9/25 22:28:08 网站建设 项目流程

最后一章把全书串成一条完整的"信号链",并完成:
①从代码到电机动作的每一环;②完整演示程序逐行(真实 main.cpp 全文);
③接线清单;④排查流程;⑤双电机挑战(fade 服务实例计数的实际演练,
附完整可抄跑程序);⑥毕业自检。


12.1 完整信号链:代码到电机动作的每一环

main.cpp(剧本) │ setSpeed(80) ▼ DCMotor::setSpeed ├─ std::clamp(80, -100, 100) → 80 ├─ 方向:setDirection(true) → IN1=1, IN2=0(GPIO16/17 输出高/低) └─ 速度:speedToDuty(80) → 818 → applyDuty(818) └─ ledc_set_duty_and_update(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, 818, 0) (第 4 参是 hpoint=0,立即生效,不是渐变时间) ▼ LEDC 硬件:计数器与比较值 818 比较 → 占空比 80% 方波(20kHz) ▼ GPIO18 输出方波 → L298N 的 ENA ▼ L298N:IN1/IN2 选通 S1、S4;ENA 的占空比决定平均电压 ▼ 电机:平均电压 80% → 80% 转速正转

每个环节失败,症状不同——排查时按这个链条逐环检查(12.4)。

12.2 演示程序逐行(真实 main.cpp 全文,77 行)

下面就是仓库里main/main.cpp的逐字全文,包括所有注释——
注释也是正文的一部分,它们解释"为什么这么接、为什么这么写":

#include"esp_err.h"#include"esp_log.h"#include"freertos/FreeRTOS.h"#include"freertos/task.h"#include"motor_control.h"staticconstexprconstchar*TAG="l298n";// L298N 一路 H 桥的接线,请与实际硬件保持一致。//// 当前模组是 ESP32-S3-WROOM-1-N16R8(16MB Quad Flash + 8MB Octal PSRAM),// 有一批 GPIO 被封装内的存储器占用,不能当普通 IO 用:// - GPIO 26~32:Quad Flash(任何 S3 模组都一样)// - GPIO 33~37:Octal PSRAM(仅 NxR8 / NxR16 这类八线模组)// 所以真正可用的只有 0~25 和 38~48,再扣掉 USB-JTAG 的 19/20// 以及 strapping 脚 0/3/45/46。这里选的 16/17/18 不受影响。staticconstexprgpio_num_t MOTOR_IN1=GPIO_NUM_16;staticconstexprgpio_num_t MOTOR_IN2=GPIO_NUM_17;staticconstexprgpio_num_t MOTOR_ENA=GPIO_NUM_18;// 演示节奏:每档 5%,每档间隔 150 msstaticconstexprintkStepPercent=5;staticconstexprTickType_t kStepDelayMs=150;extern"C"voidapp_main(void){ESP_LOGI(TAG,"L298N 直流电机演示启动");// 在 app_main 中构造并初始化。请不要把它改成全局对象:// C++ 全局构造发生在 app_main 之前,那时外设驱动尚未就绪,// 且构造函数无法把错误码返回给调用方。DCMotormotor({.in1=MOTOR_IN1,.in2=MOTOR_IN2,.ena=MOTOR_ENA,.channel=LEDC_CHANNEL_0,.timer=LEDC_TIMER_0,.freq_hz=20000,// 20 kHz,高于人耳听感范围,可消除电机啸叫.resolution=10,// 1024 级,约 0.1% 的速度分辨率});// 若要驱动 L298N 的第二路 H 桥,务必换用另一个定时器,// 否则两路电机会共享频率与分辨率,改一处会影响另一处:// DCMotor motor_b({.in1 = GPIO_NUM_4, .in2 = GPIO_NUM_5, .ena = GPIO_NUM_6,// .channel = LEDC_CHANNEL_1, .timer = LEDC_TIMER_1});// motor_b.init();constesp_err_t err=motor.init();if(err!=ESP_OK){ESP_LOGE(TAG,"电机初始化失败: %s,程序终止",esp_err_to_name(err));return;}while(true){ESP_LOGI(TAG,"正转加速 0%% -> 100%%");for(intspeed=0;speed<=100;speed+=kStepPercent){ESP_ERROR_CHECK(motor.setSpeed(speed));vTaskDelay(pdMS_TO_TICKS(kStepDelayMs));}vTaskDelay(pdMS_TO_TICKS(1000));ESP_LOGI(TAG,"反转加速 0%% -> -100%%");for(intspeed=0;speed>=-100;speed-=kStepPercent){ESP_ERROR_CHECK(motor.setSpeed(speed));vTaskDelay(pdMS_TO_TICKS(kStepDelayMs));}vTaskDelay(pdMS_TO_TICKS(1000));// 两种停止方式的区别很直观:制动会立刻停住,滑行则靠惯性慢慢停下ESP_LOGI(TAG,"能耗制动:短接绕组,快速停住");ESP_ERROR_CHECK(motor.stop(DCMotor::StopMode::Brake));vTaskDelay(pdMS_TO_TICKS(2000));ESP_LOGI(TAG,"自由滑行:关断输出,靠惯性停下");ESP_ERROR_CHECK(motor.stop(DCMotor::StopMode::Coast));vTaskDelay(pdMS_TO_TICKS(2000));}}

逐段讲

第 7~23 行:常量与引脚。TAG是日志标签(第 6 章)。引脚常量块
上方的注释值得反复读:这块 N16R8 模组上,GPIO 26~32 被 Quad Flash
占用、33~37 被 Octal PSRAM 占用,19/20 是 USB-JTAG,0/3/45/46 是
strapping 脚(这两类脚是什么、为什么占不得,2.5 和 4.6 都讲过)——
可用池里挑了干净的 16/17/18 给 IN1/IN2/ENA。
kStepPercent = 5、kStepDelayMs = 150定下演示节奏:每档 5%、
每档停 150ms。

第 28~39 行:构造对象。注释把"为什么不能在 app_main 之前构造
(全局对象)"讲死了:构造函数只存配置不碰硬件,硬件动作全在init(),
这样错误才能用返回值交给你处理。初始化用了 C++ 的指定初始化器
(.字段 = 值,1.9 讲过的写法),把引脚、通道、定时器逐个显式
传入——Config的
默认引脚是GPIO_NUM_NC(没接),不存在"默认就是 16/17/18"的好事。
频率 20kHz 避开人耳(消啸叫)、分辨率 10bit(1024 档、约 0.1% 调速
精度),对应第 7 章的两个旋钮。

第 41~45 行:第二路电机的注释。这是 12.5 双电机的伏笔:
两路电机必须用不同的 LEDC 定时器,否则共享频率与分辨率、
改一处影响另一处。

第 47~51 行:init 的错误处理。注意这里不是
ESP_ERROR_CHECK(motor.init()):初始化失败通常是接线/配置问题,
属于"可以体面退出"的场合——打一条ESP_LOGE(带
esp_err_to_name(err)翻译好的错误名)然后return,
app_main 返回即主任务结束,不炸。而循环里的setSpeed失败
意味着运行中硬件出问题,才用ESP_ERROR_CHECK宁可 abort。
同一本书里两种策略并存,是刻意的示范(第 6 章讲过的取舍)。

第 53~76 行:while(true) 剧本。一轮的节奏是:

正转 0→100(每档 5%、150ms,约 3.15s)→ 停 1s 反转 0→-100(约 3.15s)→ 停 1s Brake 停住,观察 2s → Coast 停下,观察 2s → 循环

(日志格式串里的%%是转义,打出来是单个%。)
几个值得咂摸的机制点:

  • 每个加速循环的第一档都是setSpeed(0)——按 9.5,它固定执行
    stop(Brake),脚本开头先"抱死"一下再起步,顺带验证刹车分支;
  • 从正转 100% 切到反转时,剧本先经过setSpeed(0)这一档:
    短接绕组刹住,之后speed_已是 0,再进负速档就不触发换向
    保护死区了。也就是说,这个剧本是用"过零"自然完成了换向保护想做
    的事;
  • 最后一组 Brake vs Coast 各停 2s,就是第 9 章 9.6 对比实验的
    自动化版:一个"立刻定住",一个"靠惯性慢慢溜",肉眼可辨。

变体剧本:一次性演示(完整可编译)

如果你想要一个"跑一遍就收工"的教学版(正转 30% → 80% → 刹车 →
反转 50% → 滑行),下面是完整可抄跑的写法——引脚等参数同样必须
显式传入,不依赖任何"默认值":

#include"esp_err.h"#include"esp_log.h"#include"freertos/FreeRTOS.h"#include"freertos/task.h"#include"motor_control.h"staticconstexprconstchar*TAG="l298n";extern"C"voidapp_main(void){ESP_LOGI(TAG,"L298N 一次性演示");DCMotormotor({.in1=GPIO_NUM_16,.in2=GPIO_NUM_17,.ena=GPIO_NUM_18,.channel=LEDC_CHANNEL_0,.timer=LEDC_TIMER_0,.freq_hz=20000,.resolution=10,});constesp_err_t err=motor.init();if(err!=ESP_OK){ESP_LOGE(TAG,"电机初始化失败: %s,程序终止",esp_err_to_name(err));return;}ESP_ERROR_CHECK(motor.setSpeed(30));// 慢速正转vTaskDelay(pdMS_TO_TICKS(1500));ESP_ERROR_CHECK(motor.setSpeed(80));// 快速正转vTaskDelay(pdMS_TO_TICKS(1500));ESP_ERROR_CHECK(motor.setSpeed(0));// 0 → 固定能耗制动(9.5)vTaskDelay(pdMS_TO_TICKS(1000));ESP_ERROR_CHECK(motor.setSpeed(-50));// 反转 50%vTaskDelay(pdMS_TO_TICKS(1500));ESP_ERROR_CHECK(motor.stop(DCMotor::StopMode::Coast));// 这次滑行}

这个剧本里+80 → setSpeed(0) → -50是先刹到零再换向,
speed_已归 0,换向保护死区不会触发。但如果你偷懒,把
setSpeed(80)后面直接写成setSpeed(-50)——setSpeed会检测到
目标方向与当前speed_相反,先stop(Brake)短接绕组并
esp_rom_delay_us(5000)停 5ms,然后才反向通电:占空比还没加上去,
先经历 5ms 的换向制动死区。这层保护就是为"遥控车突然倒车"这类
场景兜底的(物理原因见 9.4)。类比一句:手动挡车换挡前要先踩离合
断开动力,这 5ms 就是电机的那一脚"离合"。

预期日志序列(真实剧本,第一轮)

烧录成功、接线无误的话,monitor 里第一轮长这样(节选到第一轮结束,
之后每约 12 秒原样循环):

I (1095) l298n: L298N 直流电机演示启动 I (1103) DCMotor: 就绪: IN1=16 IN2=17 ENA=18 | ch=0 timer=0 | 20000 Hz / 10 bit I (1104) l298n: 正转加速 0% -> 100% I (5254) l298n: 反转加速 0% -> -100% I (9404) l298n: 能耗制动:短接绕组,快速停住 I (11404) l298n: 自由滑行:关断输出,靠惯性停下

逐行对质,每一行都能在第 8/12 章的代码里找到出处:

  • 第 1 行是main.cpp开场白(TAG 是l298n);第 2 行来自
    motor_control.cpp的init()末尾(TAG 是DCMotor——两个文件
    各署各的名,第 6 章讲过);
  • 第 2 行的20000 Hz是ledc_get_freq的回读值,受时钟分频
    取整影响可能与请求值差一点(7.7 第 8 步),本例恰好整除;
  • 加速循环中间没有每档日志——5%、10%……只有阶段标题各一条;
    %%打出来就是单个%;
  • 时间戳能对上节奏:"正转"到"反转"间隔 ≈ 21 档×150ms + 1s 停顿
    ≈ 4.15s,往后依次 +4.15s、+2s、+2s,一整轮约 12s 后从头循环
    (你的数值会略有出入,间距对了就行)。

日志到此为止都在正常说话;电机若还不动,按 12.3 的清单回头查硬件。

12.3 实物接线清单:十步(全程断电操作,最后一步才通电)

先立三个硬件事实(与 8.5 同一口径,接线前必须心里有数):

  1. 跳线帽:多数 L298N 模块的 ENA/ENB 排针出厂带着跳线帽短接到
    5V——等于把使能端硬接常开。不拔掉它、不把杜邦线插到 ENA 排针,
    PWM 调速完全无效(电机常全速);
  2. 丝印:接线柱丝印常为"12V / GND / 5V",其中"12V"就是 Vs,
    电机电源入口(也有板子写作"Bat");"5V"多是模块稳压的输出,
    不是给 ESP32 供电的入口;
  3. 两地供电:电机用独立电池盒 6~12V,ESP32 用 USB 供电——
    两个"地方"之间必须共一条地线,且电机电流不走芯片。

十步检查表,一步都别跳:

  • ① 先断电:拔掉 ESP32 的 USB、撤下电池盒与驱动板的连接;
    第一件事就是拔掉 ENA(以及 ENB)的跳线帽——头号坑最先排
  • ② 电机电源:电池盒 +(6~12V)→ 接线柱"12V"(即 Vs/Bat 电源入口),
    − → “GND”(电机电流走这条路,不经过芯片)
  • ③ 共地:开发板 GND → 驱动板 GND(共地,双方同一个 0V 基准;
    不共地时 IN1/IN2/ENA 的电平全是悬空的,一切逻辑都不可信)
  • ④ GPIO16 → IN1(方向信号,推挽输出 0/1)
  • ⑤ GPIO17 → IN2
  • ⑥ GPIO18 → ENA 排针(PWM 方波——正好取代第①步跳线帽让出的位置)
  • ⑦ 电机两根线 → OUT1 / OUT2(转向若与预期相反,对调两根线
    或改代码里的速度符号即可,见 8.5)
  • ⑧ 断电三查·其一:接线柱上有没有散落的铜丝可能碰到"12V"与"GND"
    (这俩直接短路,电池盒会发烫)
  • ⑨ 断电三查·其二、其三:有没有把"5V"孔的线误接到 ESP32 的 GPIO
    或 5V 脚?共地线接没接?IN1/IN2/ENA 三个位置对不对?
  • ⑩ 最后才通电:先接上电机电池盒,再插 ESP32 的 USB,
    开 monitor 对照 12.2 末尾的预期日志

【运行期安全框】

  • 摸温度:运行中时不时摸一下 L298N 的散热片和电机外壳——温热正常,
    "烫得不敢摸"不正常(第一怀疑对象是堵转或短路,立即断电排查);
  • 每轮约 12s:剧本是"正转→反转→刹车 2s→滑行 2s"约 12 秒一轮
    连续循环,别让人走开电机还空转半小时,观察完随时拔 USB 断电;
  • 随时断电:闻到异味、听到怪响、摸到发烫——先拔电源再谈别的。

12.4 排查流程:按信号链逐环查

症状先查环节再查
没日志、不烧录烧录链(第 2 章)线/端口/驱动
有日志、电机不动电源 + 共地接线清单
电机只震不转ENA 的 PWM占空比/频率配置
只朝一个方向方向逻辑IN1/IN2 接线与真值表
刹车刹不住ENA 占空比是否拉满 100%9.5 核心坑
初始化失败fade 服务顺序 timer→channel→fade
一转就重启电源电流不足换大电流电源
崩溃第 11 章流程类型→地址→调用链

12.5 双电机挑战:实例计数的实战

资源分配(S3 的账本)

资源数量双电机怎么分
LEDC 定时器4同频同分辨率可共享 1 个;想各自独立就各 1 个
LEDC 通道8电机 A 用通道 0,电机 B 用通道 1
fade 服务全芯片 1 个只装一次,最后实例才卸载
GPIO每个电机 3 个A:16/17/18,B:4/5/6(19/20 是 USB-JTAG、26~37 被 Flash/PSRAM 占用,都别碰)

第二路的引脚方案不用我们自己发明——main.cpp第 41~45 行的注释
就是官方答案:GPIO4/5/6 + LEDC_CHANNEL_1 + LEDC_TIMER_1,并且
告诫务必换用另一个定时器:挂在同一定时器上的通道共享频率和
分辨率,改一路的setFrequency()/setResolution()会连带把另一路
也改了。

把 B 路落到实物板上(沿用 12.3 的断电流程,电源和共地两路共用、
不必新接):

  • GPIO4 → IN3、GPIO5 → IN4(B 路方向线),GPIO6 → ENB 排针
    (ENB 的跳线帽也要拔,道理同 ENA,12.3 第①步)
  • B 电机两根线 → OUT3 / OUT4

DCMotor::Config本来就有channel/timer/speed_mode这些字段
(第 8 章),双电机不需要扩展任何代码,显式传参就行。

完整示例程序(真实类,可抄跑)

#include"esp_err.h"#include"esp_log.h"#include"freertos/FreeRTOS.h"#include"freertos/task.h"#include"motor_control.h"staticconstexprconstchar*TAG="dual";extern"C"voidapp_main(void){ESP_LOGI(TAG,"L298N 双电机演示启动");// A 路:IN1=16 IN2=17 ENA=18,通道 0 + 定时器 0DCMotormotor_a({.in1=GPIO_NUM_16,.in2=GPIO_NUM_17,.ena=GPIO_NUM_18,.channel=LEDC_CHANNEL_0,.timer=LEDC_TIMER_0,});// B 路:IN1=4 IN2=5 ENA=6,通道 1 + 定时器 1// 必须换定时器:同一定时器上的通道共享频率与分辨率DCMotormotor_b({.in1=GPIO_NUM_4,.in2=GPIO_NUM_5,.ena=GPIO_NUM_6,.channel=LEDC_CHANNEL_1,.timer=LEDC_TIMER_1,});ESP_ERROR_CHECK(motor_a.init());// 实例计数=1:装 fade 服务ESP_ERROR_CHECK(motor_b.init());// 实例计数=2:服务已装,不重装while(true){ESP_LOGI(TAG,"A 正转加速,B 反转加速");for(ints=0;s<=100;s+=10){ESP_ERROR_CHECK(motor_a.setSpeed(s));ESP_ERROR_CHECK(motor_b.setSpeed(-s));vTaskDelay(pdMS_TO_TICKS(150));}vTaskDelay(pdMS_TO_TICKS(1000));ESP_LOGI(TAG,"A 刹车停,B 滑行停");ESP_ERROR_CHECK(motor_a.stop(DCMotor::StopMode::Brake));ESP_ERROR_CHECK(motor_b.stop(DCMotor::StopMode::Coast));vTaskDelay(pdMS_TO_TICKS(2000));}}

(freq_hz/resolution没写,用Config里的默认值 20000Hz/10bit;
引脚没有默认值,所以必须传。)

这个程序还自带一帧"同框对比":每轮末尾 A 用 Brake、B 用 Coast——
A 短接绕组立刻定住,B 关断输出、引脚高阻,靠惯性溜一会儿才
停
(9.6 的活版)。两台电机同板同电源并排跑,两种停法的差别一眼
分明。

为什么这个顺序对(底层逻辑回顾)

  • fade 服务是全局单例(7.6):装两次报"已存在";
  • 每个 DCMotor 实例的 init/deinit 只负责给s_active_instances
    加一/减一;
  • 只有计数归零的那个实例才真正卸载(deinit()里的
    if (--s_active_instances == 0 && s_ledc_fade_installed))——
    保证另一个实例还能用;
  • 这就是s_ledc_fade_installed+s_active_instances的完整意义。

两个电机独立控制后,自然想让它们"各跑各的剧本"——把每个电机的
控制放进独立 FreeRTOS 任务,就是第 13 章的多任务应用。

12.6 六个升级实验

  1. 速度曲线:改真实剧本的kStepPercent/kStepDelayMs
    (比如 10%/500ms 对比 2%/80ms),观察加速曲线;想体验"硬件级"
    PWM 渐变要另用ledc_set_fade_with_time()+ledc_fade_start()
    ——applyDuty走的是ledc_set_duty_and_update(hpoint=0,
    立即生效),没有渐变时间这回事(9.5);
  2. 停法对比:motor.stop(DCMotor::StopMode::Coast);vs
    motor.stop(DCMotor::StopMode::Brake);(第 9 章);
  3. 双电机交替:跑 12.5 的完整程序,A 正转时 B 反转,
    验证实例计数 + 通道/定时器分配;
  4. 遥控小车:WiFi 收指令 → 队列 → 电机任务执行(第 16 章);
  5. 组件化重构:把 DCMotor 移进components/(第 17 章);
  6. OTA 升级:给小车配网络升级(第 18 章)。

12.7 毕业自检表

  • 能说出烧录成功 vs 程序报错的判断依据(Calling app_main())
  • 能用公式解释 PWM 平均电压、频率与分辨率的关系
  • 能画出 H 桥四开关与正转/反转/刹车的对应
  • 能解释 fade 服务:为什么配置后装、为什么最后实例卸载
  • 能独立完成:接线 → 编译 → 烧录 → 看日志 → 排查 → 修改 → 再烧
  • 崩溃时会用 类型 + EXCVADDR + addr2line 定位
  • 能说出固件各段(text/data/bss)与 Flash/SRAM 的对应

12.8 想一想 + 小测验

想一想:

  1. 双电机共享定时器的前提条件是什么?共享之后会有什么副作用?
  2. 若 B 电机先 deinit 而 A 还在转,fade 服务会怎样?为什么安全?
  3. 信号链中"只震不转"最可能断在哪一环?

小测验:

  1. 真实演示剧本的第一个动作?(A. 反转 B. 正转从 0% 到 100% 每档 5% 加速 C. 刹车)
  2. setSpeed(80)的占空比档位约是?(A. 818 B. 80 C. 1023)
  3. 谁负责卸载 fade 服务?(A. 先退出者 B. 最后退出者 C. 谁都不)
  4. 双电机示例里 B 路的 ENA 引脚是?(A. GPIO6 B. GPIO16 C. GPIO33)
    (B 路完整三脚:IN1/IN2/ENA = GPIO4/5/6)

12.9 本章总结

  • 信号链:代码 → clamp → 方向+占空比 → LEDC → ENA → L298N → 电机;
  • 排查 = 沿链逐环:电源/共地 → 烧录 → 日志 → fade → 崩溃;
  • 双电机:Config显式传 GPIO4/5/6 + 通道 1 + 定时器 1,
    加 fade 实例计数,是"组件化设计"的活例子;
  • 动手 + 看日志,是嵌入式成长唯一的捷径。

恭喜完成!下一步建议:book/README.md的进阶路线,
或把 DCMotor 扩展成组件放进components/复用。

进阶路线(第三部分):第 13 章 FreeRTOS → 第 14 章外设 → 第 15 章中断 →
第 16 章 WiFi → 第 17 章组件化 → 第 18 章 OTA,一路走到"能交付产品"。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询