前面几篇把环境搭建、点灯、联网都跑通了,小车依然原地趴着,原因很简单:控制层还没做。这一篇是整个系列里真正让小车“动起来”的关键部分。我要聊的不只是怎么接两根线、写几行 PWM,而是把控制链路从上层指令到轮子转动的完整逻辑拆开,从驱动选型、引脚规划、PWM 调速,到命令解析、状态机、任务拆分的全过程都过一遍。如果你正好在用或准备用 hi3863(ws63)做类似的小车项目,这份实践记录可以直接当参考。
先说明一点:我用的是一块国产低功耗 WiFi SoC 的开发板,核心是 RISC-V 架构,常见的型号标识就是 hi3863 / ws63 系列。不同批次板子的引脚定义、SDK 接口名字略有差异,但控制思路完全一致。文中的函数名我尽量写成和官方 SDK 风格接近的伪代码,实际编译时请以你手里那份 SDK 头文件为准。整套控制方案的结论放在前面:用两路 PWM 控制差速、用状态机组织运动指令、用任务队列隔离网络和电机的实时性冲突。
1. 控制层在整个小车项目里的定位
很多朋友做小车,上来就把电机驱动模块怼到 GPIO 上,写个高低电平翻转,轮子转了就觉得完事了。等你真正想让它按指令跑直线、转弯、避障,就会发现问题全出在控制层的设计上。
1.1 控制层解决的不只是“转不转”,而是“怎么转”
点亮 LED 和驱动电机的本质区别在于:LED 只有两种稳定状态,电机却有无级的速度和持续的方向变化。小车控制层要回答三个问题:
- 往哪走:方向(前进、后退、左转、右转、停止)
- 走多快:速度(PWM 占空比)
- 什么时候切换:动作之间的过渡(从前进到左转,轮速怎么变化)
这三个问题不提前设计好,代码写起来就会非常痛苦。我见过有人在主循环里堆了一百多行 if-else,最后没加一个转向功能就乱成一团。
1.2 从指令到轮子的完整链路
我习惯把控制链路画成三段,这样定位问题的时候非常方便:
控制指令(网络/串口) → 解析和状态机 → PWM 波形 → 驱动模块 → 电机 → 轮子每段之间是解耦的:网络收包只负责把数据塞进队列,状态机只负责计算目标转速,PWM 只负责把转速变成波形。哪一段出问题,单独查哪一段就行,不用把整个程序从头看到尾。
1.3 这一篇的边界
本篇文章聚焦“小车控制”本身,不包括具体的 Wi-Fi 接入步骤、App 开发或者传感器检测。车联网之后能收到什么指令,理解成“上游”;控制层收到指令后怎么执行,理解成“下游”。我这里做的,就是把从上游拿到的那条消息真正变成轮子转动的那一段。
硬件清单很简单:主控板一块、带减速箱的直流电机两个、电机驱动模块一个、电池组一套。没有用复杂的舵机转向结构,靠双轮差速实现转向,这也是这类入门小车最通用的方案。
2. 电机驱动选型与引脚分配
控制层的第一个实际决定,是选哪种驱动模块、怎么接引脚。这一步做错了,后面代码写得再好也白搭。
2.1 驱动模块:大板子还是小模块
市面上最常见的两种方案是 L298N 大板和 TB6612 这类小型驱动模块。我最终选的是 TB6612,原因很实际:
| 对比项 | L298N 大板 | 小型驱动模块(TB6612 风格) |
|---|---|---|
| 体积 | 大,占底板空间 | 小,方便排线 |
| 压降 | 约 2V,电池利用率低 | 约 0.5V,续航更好 |
| 发热 | 电流大时明显烫手 | 发热小 |
| 逻辑电平 | 需要 5V 逻辑,和 3.3V MCU 配合麻烦 | 直接兼容 3.3V |
| 适合场景 | 大功率电机、教学演示 | 低压小功率小车 |
hi3863 / ws63 的 GPIO 是 3.3V 电平,如果直接用 L298N 的老版本,逻辑信号可能出现不触发的情况。TB6612 这类模块的 VCC 支持 2.7V~5.5V,直接给 3.3V 毫无压力。当然,如果你手头只有 L298N 也可以用,只要注意共地和逻辑电平阈值就行。
2.2 引脚分配:上电瞬间的安全要放在第一位
很多人选引脚只看“哪个空着用哪个”,这是新手最容易踩的坑。GPIO 在上电瞬间的电平状态比运行时的功能更重要。如果某个引脚默认上拉,而它刚好接了驱动板的 IN 脚,开机的瞬间电机就会猛冲一下,非常危险。
我当时选了一组比较稳妥的引脚:
| 功能 | 引脚 | 方向 | 说明 |
|---|---|---|---|
| 左电机 IN1 | GPIO4 | 输出 | 控制方向高/低 |
| 左电机 IN2 | GPIO5 | 输出 | 控制方向高/低 |
| 左电机 PWM | GPIO14 | 输出 | 复用为 PWM 通道 |
| 右电机 IN1 | GPIO7 | 输出 | 控制方向高/低 |
| 右电机 IN2 | GPIO8 | 输出 | 控制方向高/低 |
| 右电机 PWM | GPIO15 | 输出 | 复用为 PWM 通道 |
选这组引脚的逻辑有三条。
第一,避开上电默认高电平的引脚。我查了原理图,这几个脚复位后的默认状态是下拉或者浮空,即使驱动板在有电的情况下,也不会因为 MCU 还没初始化完就让电机乱转。
第二,PWM 引脚要确认有定时器通道。不是所有 GPIO 都能输出 PWM,hi3863/ws63 的 PWM 通道是有限的。我选的 GPIO14、GPIO15 正好能映射到两个独立通道,这样可以分别控制左右两轮,互不干扰。
第三,避开下载调试串口。如果 GPIO 和固件下载的 UART0 复用,每次烧录程序时电平来回跳,驱动板可能会收到错误信号。轻则电机抖动,重则连烧录都失败。
2.3 驱动板的使能脚和逻辑电压
TB6612 这类模块通常还有 STBY 引脚,把它直接拉高即可正常工作。但直接接 3.3V 没问题,注意别悬空。悬空状态下模块内部逻辑不确定,会导致电机不受控制。至于 VM 和 VCC 分开接:VM 接电池正极,VCC 接 3.3V,两个不要搞混。我见过有人把 VM 和 VCC 接一起,结果模块冒烟,最后查出来是电压超了规格。
连线的时候还有一个细节:MCU 的地和驱动模块的地必须共地。不共地,逻辑信号根本没有参考电位,PWM 波形会乱飘。我用的是同一条电池负极母线,从小车底板上统一拉地线,避免形成环路。
3. PWM 调速的本质与实现细节
有了硬件连接,接下来就是软件层面最关键的部分:PWM 调速。搞懂频率、占空比、死区这三个概念,电机控制就成功了一半。
3.1 从“开关”到“平均电压”
PWM 的原理说白了就是快速开关电源,让电机两端电压的平均值变化。假设电池 7.4V,给电机 50% 占空比,等效平均电压就是 3.7V。电机转速和平均电压近似成正比,这就是调速的基础。
但是,电机不是灯泡,它有电感、有惯性。占空比太低时,电流平均值不足以克服静摩擦,轮子根本不转;一旦升到某个阈值,轮子又会突然窜动。这个阈值就是“死区”。具体数值和电机型号、电池电压、负载有关,我手里的减速电机大概在 20%~25% 占空比才开始稳定转动。
3.2 频率选 10kHz 还是 20kHz
PWM 频率直接影响噪音和发热。频率太低,比如 1kHz,电机线圈会发出明显的“滋滋”声,人听着难受,电机也容易发热。频率太高,开关损耗增大,驱动模块温度升高。我在 ws63 平台上试了 10kHz 和 20kHz,实际听感 20kHz 更安静,驱动模块也不怎么热,所以最终定在 20kHz。
频率怎么算出来?假设 MCU 的 PWM 时钟源是 160MHz,要得到 20kHz 的 PWM,需要时钟分频加计数周期配合。通常配置接口会给频率参数,你直接填 20000 就行。如果 SDK 只暴露分频和周期寄存器,那就按这个公式反推:
周期计数值 = 时钟源频率 / (分频系数 × PWM频率)比如 160MHz,预分频 80,PWM 频率 20kHz,那么计数值 = 160000000 / (80 × 20000) = 100。占空比 50% 就是前 50 个计数输出高电平,后 50 个输出低电平。
3.3 软件里怎么初始化 PWM
我习惯分三步初始化:IO 复用配成 PWM 功能、打开 PWM 通道、设置初始占空比。伪代码如下:
/* 伪代码:API 名称以实际 SDK 为准 */ void motor_pwm_init(void) { /* 1. 把 GPIO14 / GPIO15 复用成 PWM 功能 */ pin_set_mode(GPIO14, MODE_PWM0); pin_set_mode(GPIO15, MODE_PWM1); /* 2. 打开 PWM 通道,频率 20kHz */ pwm_channel_cfg_t cfg; cfg.frequency = 20000; cfg.duty_cycle = 0; /* 上电先给 0,防止乱转 */ pwm_open(PWM_CH0, &cfg); pwm_open(PWM_CH1, &cfg); /* 3. 方向引脚全部拉低,刹车状态 */ gpio_write(IN1, 0); gpio_write(IN2, 0); gpio_write(IN3, 0); gpio_write(IN4, 0); }注意最后一步:先把方向引脚拉到确定电平,再开 PWM。这样即使 PWM 配置出现意外,也不会立即让电机转动。
3.4 软加减速:给电机一个启动斜坡
直接给电机 70% 占空比,轮子会“噌”地一下弹出去,车身姿态很难控制,也伤电机齿轮。我加了一个简单的软启动斜坡:每次目标速度和当前速度的差值,分多步逼近。
/* 伪代码:斜坡限幅 */ #define MAX_STEP 5 /* 每次控制周期最多改变 5% */ int ramp_to_target(int current, int target) { if (current < target) { current += MAX_STEP; if (current > target) return target; } else if (current > target) { current -= MAX_STEP; if (current < target) return target; } return current; }控制周期 10ms 执行一次,从 0% 到 70% 大约要 140ms。这个坡度的体感比较舒服:起步稳健,也不会迟钝。刹车同理,不要直接把 PWM 清零,而是快速但平缓地降下来,否则小车会点头,尤其在地面滑的时候容易掉头。
4. 命令协议与状态机设计
小车不是一台只按固定程序跑的机器,它必须能响应外部指令。Wi-Fi 或串口传过来的数据杂乱无章,控制程序必须把它们解析成明确动作。这部分我用了最简单的自定义协议加状态机,稳定且容易调试。
4.1 命令协议:越简单越不容易出错
有人喜欢折腾 JSON 或者很复杂的二进制协议,但对控制小车来说,浪费资源且没必要。我定义的指令帧就一个字节命令加一个字节速度:
| 命令 | 含义 | 速度参数 |
|---|---|---|
F | 前进 | 0~100 |
B | 后退 | 0~100 |
L | 原地左转 | 0~100 |
R | 原地右转 | 0~100 |
S | 停止 | 忽略 |
A | 左侧微调(前进时修正跑偏) | 0~100 |
举例来说,收到F 70就代表“前进,速度 70%”。这样一条指令只有 3 个字节,解析简单,出现乱码也容易定位。协议解析的核心是:接收端按分隔符切包,不认识的字符直接丢弃,不要因为半包数据卡住整个解析流程。
4.2 指令解析与状态机实现
状态机是控制系统里最经典的结构。我把它做成四个状态:STOP、FORWARD、BACKWARD、TURN。每种状态对应不同的电机输出模式:
/* 伪代码:状态机执行逻辑 */ typedef enum { STATE_STOP, STATE_FORWARD, STATE_BACKWARD, STATE_TURN } car_state_t; void control_step(car_state_t state, uint8_t speed) { switch (state) { case STATE_FORWARD: motor_set(MOTOR_LEFT, DIR_FORWARD, speed); motor_set(MOTOR_RIGHT, DIR_FORWARD, speed); break; case STATE_BACKWARD: motor_set(MOTOR_LEFT, DIR_BACKWARD, speed); motor_set(MOTOR_RIGHT, DIR_BACKWARD, speed); break; case STATE_TURN: motor_set(MOTOR_LEFT, DIR_FORWARD, speed); motor_set(MOTOR_RIGHT, DIR_FORWARD, 0); /* 原地转向简化版 */ break; case STATE_STOP: default: motor_set(MOTOR_LEFT, DIR_STOP, 0); motor_set(MOTOR_RIGHT, DIR_STOP, 0); break; } }这套状态机最关键的优点是不会出现“左右轮一个前进一个后退”的逻辑矛盾。所有可能输入最后都落到四种明确的输出模式上,排查行为问题时只需要看当前状态对不对,不需要去追几十行条件判断。
4.3 转向动作的可预测性
小车转向有两种常见实现:原地转向(两轮反向)和差速转向(一轮减速)。我把我这块小车做成差速转向,因为直行时用微调可以让它跑直,转弯时则变成内外侧轮速差。
差速转向的实际逻辑是:把基础速度设为目标速度的 70%,内侧轮保持 30%,这样转弯半径比较可控,不会原地打转也不会转不过弯。如果你的车需要灵活掉头,可以改成两轮反向,只要保证转向状态下左右轮的方向组合正确即可。
4.4 不要在主循环里做串口接收阻塞
刚开始我犯过一个错:在控制主循环里直接等串口数据,结果等于用电机控制阻塞了网络接收。正确做法是用队列。串口中断或者网络回调收到数据后放进队列,控制任务从队列取数据解析。这样就算一条指令解析没完成,也不会耽误下一次 PWM 更新。
5. 实时任务拆分,别再让主循环扛所有事
到了这个阶段,代码量已经不小了。如果还把所有功能塞进一个 while(1),网络一点卡顿,电机控制就跟着抖。这块板子的 SDK 默认基于一个轻量级 RTOS,把任务拆开是顺手的事。
5.1 单线程主循环的问题在哪
想象一下:主循环在等待 Wi-Fi 事件阻塞了 50ms,这期间 PWM 占空比没有更新。对电机来说 50ms 的失控已经能产生明显顿挫感了。特别是两轮速度不一致时,一顿一挫小车就跑偏得很厉害。
所以控制任务必须和网络任务分家。控制任务只关心定时器节拍,网络任务只关心收发数据,两者通过任务间通信交换数据。
5.2 任务怎么划分
我最终划分成四个任务,分工很明确:
- walrus/network 任务:负责从 Wi-Fi 或串口接收原始数据,转发到命令队列。
- parse 任务:从命令队列取数据,解析成结构体,再投递到控制队列。
- control 任务:以 10ms 周期运行,从控制队列取指令,更新状态机和 PWM。
- monitor 任务(可选):定期打印电机电流、电池电压等状态信息,方便调试。
任务间通信用的就是 RTOS 的队列和信号量。控制任务只需要等待一个“有新指令”的信号,收到后再从队列拿数据,完全不用关心上层是怎么收到的数据。
5.3 定时器中断里直接改 PWM?
有朋友图省事,直接在定时器中断回调里改 PWM 占空比。我劝你别这么干。PWM 占空比的修改虽然是个寄存器写操作,但你的控制逻辑可能需要判断当前状态、做斜坡限幅、甚至切换方向。这些逻辑放进中断里,时间不固定,容易影响系统稳定。
我采用的方式是:定时器中断只置一个标志位,真正的控制逻辑放在 control 任务里跑。虽然每次控制更新会有最多一个周期的延迟,但 10ms 的周期对电机来说完全够用,换来的是逻辑清晰、系统稳定。
控制任务的伪代码结构如下:
/* 伪代码:控制任务主体 */ void control_task(void) { uint8_t cmd_buf[8]; while (1) { /* 等待指令信号,超时 10ms */ if (queue_receive(cmd_queue, cmd_buf, 10) == OK) { parse_command(cmd_buf); } /* 每 10ms 执行一次状态推进和 PWM 更新 */ update_ramp(); update_pwm_output(); } }这个结构里,update_ramp()负责软加减速,update_pwm_output()负责把目标速度写进实际 PWM。这样即使没有收到新指令,控制任务也会按当前状态持续更新,不会因为网络停顿而让小车失控。
6. 实测阶段最容易翻车的几个现场
代码调通不代表小车就能跑好。真正让我长教训的都是实测里的坑,这里挑四个最典型的分享出来,每个都是自己或者同行真实遇到过的情况。
6.1 事故一:电机一启动,主控就重启
现象:小车放在地上,按下启动按钮,Wi-Fi 连接正常;一旦发送前进指令,电机刚转起来,主控立刻重启。
原因是供电瞬断。电机启动瞬间电流很大,直接把电池电压拉低,MCU 供电不足导致复位。我的电池是两节串联的锂电,内阻不小,电机启动电流瞬间到 2A 甚至更高,电压跌落超过 MCU 的欠压阈值。
处理办法分三层:
- 硬件层:电机电源和 MCU 电源分开走,MCU 用独立的稳压降压模块供电。
- 储能层:在电机电源两端并联一个 470uF 电解电容,吸收启动瞬态电流。
- 软件层:用前面说到的软启动斜坡,减少启动电流尖峰。
特别是软启动,效果很明显。起步速度从 40% 开始爬升,电流冲击比直接给 70% 小很多。改完这三层之后,主控再也没在启动时复位过。
6.2 事故二:PWM 一开,Wi-Fi 丢包严重
现象:电机没转的时候,Wi-Fi 收到 App 指令一切正常;PWM 占空比一拉高,网络立刻丢包、延迟飙升。
电机是很大的干扰源。PWM 的高频开关在电机线缆上辐射电磁干扰,直接影响板载天线的接收。Wi-Fi 对 2.4GHz 频段周围的噪声很敏感,电机一转,信噪比就崩了。
我的处理思路:
- 把 PWM 频率从 20kHz 降到 10kHz,干扰频谱整体下移,对 Wi-Fi 的干扰明显减弱。
- 电机线尽量短,并且绞成双绞线,减小辐射环面积。
- 电机两端并联 100nF 瓷片电容,吸收换向火花噪声。
- 天线区域远离电机和驱动模块,至少留 1~2cm 距离。
改完之后,即使电机全速跑,App 端也能稳定收到实时状态回传,丢包率从肉眼可感知降到几乎为零。
6.3 事故三:上电瞬间小车自己往前冲
现象:开发板刚上电,程序还没开始跑,小车就往前窜了一下。这个问题在第二次开机的时候差点撞到东西。
原因就是我在第 2.2 节里提到的默认电平问题。GPIO 在上电后、程序初始化前有一段不确定状态,如果驱动板的 IN 引脚恰好被上拉到高电平,电机就会短时间通电转动。
解决方法是三层保险:
- 查原理图避开上电默认高电平的引脚。
- 驱动模块的 STBY(待机)引脚不直接接高电平,而是通过一个 10kΩ 电阻上拉到 3.3V。但注意,上电初期 MCU 还没运行,直接上拉其实也等于高电平。更稳妥的做法是:把 STBY 引脚接到一个 GPIO 上,由程序控制,上电先拉低,初始化完成后再拉高。
- 程序启动时,第一步就把所有方向引脚设为确定输出,PWM 通道设为 0%,然后再进行后续初始化。
我现在所有小车项目的启动顺序都是:拉低所有使能 → 拉低方向 → PWM 输出 0 → 初始化系统 → 延时 100ms → 放开使能。这个顺序写死在启动流程里,宁可慢半拍,也不能让车自己冲出去。
6.4 事故四:左轮右轮转速不一致,车子跑不直
现象:明明给了左右轮同样的 PWM 占空比,小车在地板上却向右偏,而且越跑越偏。
原因很朴素:两个电机虽然是同型号,但个体差异明显,齿轮箱摩擦力也不一样。同样的 70% 占空比,左轮空载转速和右轮实测差了差不多 8%。在闭环速度反馈没做的情况下,跑直线只能靠微调。
我加了一个速度校准表:在调试模式下分别测量左右轮的实际转速,把差值折算成 PWM 补偿量。比如右轮偏快,就把它目标速度乘 0.92,然后硬编码到控制逻辑里。这个方法虽然笨,但是实用。
如果你手头有编码器,后续可以上真正的闭环 PID;没有编码器的话,先靠占空比补偿表也能把直线跑得像模像样。校准表从 30% 到 80%,每 10% 测一次,存成一个数组,运行时按目标速度插值取补偿值。
关于调车的一个小习惯
最后分享一个我个人调车时的习惯:轮子悬空测试和地面测试要分开做。悬空时轮子没有负载,占空比响应和地面差别很大。我在地面测试前,一定先把小车垫起来,用 App 完成所有状态切换验证,确认方向、速度、转向逻辑都对,再放到地面做负载测试。这样能省下大量因为“悬空能转但落地不跑”而产生的无效排查时间。
控制这块做到这里,小车的骨架就算真正立起来了。下一步可以考虑加编码器、做速度闭环,或者上测距模块做自动避障。反正控制层已经拆分好,加功能只是在状态机和任务框架里加新分支,不用推翻重来。